Flask will now include git commit hash as URL parameter (v=abc1234) for metric_modal.js and other static assets. This ensures browser doesn't cache stale JavaScript when code changes. Cache invalidation based on actual git history rather than timestamps.
2443 lines
87 KiB
HTML
2443 lines
87 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Data Catalog - Data Analyst Portal</title>
|
|
{% if not config.THEME_FONT_URL %}
|
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
|
{% endif %}
|
|
<style>
|
|
:root {
|
|
/* Colors - Design System */
|
|
--primary: #0073D1;
|
|
--primary-light: rgba(0, 115, 209, 0.1);
|
|
--text-primary: #1A253C;
|
|
--text-secondary: #6B7280;
|
|
--background: #F5F7FA;
|
|
--surface: #FFFFFF;
|
|
--border: #E5E7EB;
|
|
--border-light: #F3F4F6;
|
|
--success: #10B77F;
|
|
--warning: #F59F0A;
|
|
--error: #EA580C;
|
|
|
|
/* Typography */
|
|
--font-primary: 'Inter', system-ui, sans-serif;
|
|
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
|
|
|
/* Shadows */
|
|
--shadow-sm: rgba(0, 0, 0, 0.05) 0px 1px 2px 0px;
|
|
--shadow-md: rgba(0, 0, 0, 0.1) 0px 4px 6px -1px;
|
|
}
|
|
|
|
* {
|
|
margin: 0;
|
|
padding: 0;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
body {
|
|
font-family: var(--font-primary);
|
|
font-size: 14px;
|
|
color: var(--text-primary);
|
|
background: var(--background);
|
|
line-height: 1.5;
|
|
}
|
|
|
|
/* ── Header (dashboard-style) ── */
|
|
.header {
|
|
background: var(--surface);
|
|
border-bottom: 1px solid var(--border);
|
|
padding: 0 32px;
|
|
height: 72px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
position: sticky;
|
|
top: 0;
|
|
z-index: 100;
|
|
}
|
|
|
|
.header-left {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 16px;
|
|
}
|
|
|
|
.header-back {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 32px;
|
|
height: 32px;
|
|
border-radius: 6px;
|
|
color: var(--text-secondary);
|
|
text-decoration: none;
|
|
transition: all 0.15s ease;
|
|
}
|
|
|
|
.header-back:hover {
|
|
background: var(--border-light);
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.header-logo-group {
|
|
display: flex;
|
|
flex-direction: column;
|
|
justify-content: center;
|
|
gap: 2px;
|
|
}
|
|
|
|
.header-logo svg {
|
|
display: block;
|
|
}
|
|
|
|
.header-subtitle {
|
|
font-size: 11px;
|
|
font-weight: 500;
|
|
color: var(--text-secondary);
|
|
letter-spacing: 0.4px;
|
|
text-transform: uppercase;
|
|
margin-top: 2px;
|
|
}
|
|
|
|
.header-right {
|
|
font-size: 12px;
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
/* ── Page Title ── */
|
|
.page-title {
|
|
max-width: 900px;
|
|
margin: 0 auto;
|
|
padding: 32px 24px 24px;
|
|
}
|
|
|
|
.page-title h1 {
|
|
font-size: 24px;
|
|
font-weight: 800;
|
|
color: var(--text-primary);
|
|
margin-bottom: 4px;
|
|
}
|
|
|
|
.page-title p {
|
|
font-size: 14px;
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
/* ── Source Cards ── */
|
|
.source-cards {
|
|
max-width: 900px;
|
|
margin: 0 auto;
|
|
padding: 0 24px 32px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 20px;
|
|
}
|
|
|
|
.source-card {
|
|
background: var(--surface);
|
|
border: 1px solid var(--border);
|
|
border-radius: 12px;
|
|
overflow: hidden;
|
|
box-shadow: var(--shadow-sm);
|
|
}
|
|
|
|
.source-card-header {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
justify-content: space-between;
|
|
padding: 20px 24px;
|
|
gap: 16px;
|
|
}
|
|
|
|
.source-card-left {
|
|
display: flex;
|
|
gap: 14px;
|
|
flex: 1;
|
|
min-width: 0;
|
|
}
|
|
|
|
.source-card-icon {
|
|
width: 42px;
|
|
height: 42px;
|
|
border-radius: 10px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.source-card-icon.primary {
|
|
background: var(--primary-light);
|
|
}
|
|
|
|
.source-card-icon.jira {
|
|
background: rgba(107, 114, 128, 0.1);
|
|
}
|
|
|
|
.source-card-info {
|
|
flex: 1;
|
|
min-width: 0;
|
|
}
|
|
|
|
.source-card-name {
|
|
font-size: 16px;
|
|
font-weight: 700;
|
|
color: var(--text-primary);
|
|
margin-bottom: 2px;
|
|
}
|
|
|
|
.source-card-desc {
|
|
font-size: 13px;
|
|
color: var(--text-secondary);
|
|
margin-bottom: 6px;
|
|
}
|
|
|
|
.source-card-meta {
|
|
font-size: 12px;
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.source-card-right {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
/* ── Toggle Switch ── */
|
|
.toggle-switch {
|
|
position: relative;
|
|
width: 36px;
|
|
height: 20px;
|
|
}
|
|
|
|
.toggle-switch input {
|
|
opacity: 0;
|
|
width: 0;
|
|
height: 0;
|
|
}
|
|
|
|
.toggle-slider {
|
|
position: absolute;
|
|
cursor: pointer;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
background-color: #E5E7EB;
|
|
transition: .2s;
|
|
border-radius: 20px;
|
|
}
|
|
|
|
.toggle-slider:before {
|
|
position: absolute;
|
|
content: "";
|
|
height: 14px;
|
|
width: 14px;
|
|
left: 3px;
|
|
bottom: 3px;
|
|
background-color: white;
|
|
transition: .2s;
|
|
border-radius: 50%;
|
|
}
|
|
|
|
input:checked + .toggle-slider {
|
|
background-color: var(--primary);
|
|
}
|
|
|
|
input:checked + .toggle-slider:before {
|
|
transform: translateX(16px);
|
|
}
|
|
|
|
.toggle-switch.locked .toggle-slider {
|
|
cursor: not-allowed;
|
|
background-color: var(--primary);
|
|
opacity: 0.6;
|
|
}
|
|
|
|
.toggle-switch.locked .toggle-slider:before {
|
|
transform: translateX(16px);
|
|
}
|
|
|
|
input:disabled + .toggle-slider {
|
|
cursor: not-allowed;
|
|
opacity: 0.6;
|
|
}
|
|
|
|
/* ── Badges ── */
|
|
.badge-included {
|
|
flex-shrink: 0;
|
|
font-size: 11px;
|
|
font-weight: 500;
|
|
color: var(--primary);
|
|
background: var(--primary-light);
|
|
border-radius: 6px;
|
|
padding: 4px 10px;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.badge-subscribed {
|
|
flex-shrink: 0;
|
|
font-size: 11px;
|
|
font-weight: 500;
|
|
color: var(--primary);
|
|
background: var(--primary-light);
|
|
border-radius: 6px;
|
|
padding: 4px 10px;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.badge-unsubscribed {
|
|
flex-shrink: 0;
|
|
font-size: 11px;
|
|
font-weight: 500;
|
|
color: var(--text-secondary);
|
|
background: var(--border-light);
|
|
border-radius: 6px;
|
|
padding: 4px 10px;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
/* ── Accordion ── */
|
|
.accordion-category {
|
|
border-top: 1px solid var(--border-light);
|
|
}
|
|
|
|
.accordion-trigger {
|
|
width: 100%;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
padding: 12px 24px;
|
|
background: none;
|
|
border: none;
|
|
cursor: pointer;
|
|
font-family: var(--font-primary);
|
|
font-size: 14px;
|
|
font-weight: 500;
|
|
color: var(--text-primary);
|
|
text-align: left;
|
|
transition: background 0.1s ease;
|
|
}
|
|
|
|
.accordion-trigger:hover {
|
|
background: var(--border-light);
|
|
}
|
|
|
|
.accordion-chevron {
|
|
width: 16px;
|
|
height: 16px;
|
|
color: var(--text-secondary);
|
|
transition: transform 0.2s ease;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.accordion-trigger.expanded .accordion-chevron {
|
|
transform: rotate(90deg);
|
|
}
|
|
|
|
.accordion-count {
|
|
font-size: 11px;
|
|
font-weight: 500;
|
|
color: var(--text-secondary);
|
|
background: var(--border-light);
|
|
padding: 1px 7px;
|
|
border-radius: 9999px;
|
|
margin-left: auto;
|
|
}
|
|
|
|
.accordion-content {
|
|
display: none;
|
|
}
|
|
|
|
.accordion-content.expanded {
|
|
display: block;
|
|
}
|
|
|
|
/* ── Table Rows inside Accordion ── */
|
|
.table-row {
|
|
display: flex;
|
|
align-items: center;
|
|
padding: 10px 24px 10px 50px;
|
|
border-top: 1px solid var(--border-light);
|
|
cursor: pointer;
|
|
transition: background 0.1s ease;
|
|
}
|
|
|
|
.table-row:hover {
|
|
background: rgba(243, 244, 246, 0.5);
|
|
}
|
|
|
|
.table-row-left {
|
|
flex: 1;
|
|
min-width: 0;
|
|
}
|
|
|
|
.table-row-name {
|
|
font-weight: 500;
|
|
font-family: var(--font-mono);
|
|
font-size: 13px;
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.table-row-desc {
|
|
font-size: 12px;
|
|
color: var(--text-secondary);
|
|
margin-top: 1px;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.table-row-right {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
flex-shrink: 0;
|
|
margin-left: 16px;
|
|
}
|
|
|
|
.rows-badge {
|
|
font-size: 12px;
|
|
font-weight: 500;
|
|
padding: 2px 8px;
|
|
border-radius: 4px;
|
|
background: var(--border-light);
|
|
color: var(--text-secondary);
|
|
font-variant-numeric: tabular-nums;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.rows-badge.large {
|
|
background: rgba(245, 159, 10, 0.1);
|
|
color: #B45309;
|
|
}
|
|
|
|
.profile-link {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
font-size: 11px;
|
|
color: var(--primary);
|
|
opacity: 0.4;
|
|
transition: opacity 0.15s;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.table-row:hover .profile-link {
|
|
opacity: 1;
|
|
}
|
|
|
|
/* ── Jira Unsubscribed State ── */
|
|
.source-card-unsubscribed {
|
|
padding: 16px 24px;
|
|
border-top: 1px solid var(--border-light);
|
|
font-size: 13px;
|
|
color: var(--text-secondary);
|
|
background: var(--border-light);
|
|
}
|
|
|
|
/* ── Jira Attachments Option ── */
|
|
.jira-attachment-option {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 14px 24px;
|
|
border-top: 1px solid var(--border-light);
|
|
background: var(--background);
|
|
}
|
|
|
|
.jira-attachment-label {
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.jira-attachment-label span:first-child {
|
|
font-size: 13px;
|
|
font-weight: 500;
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.jira-attachment-label span:last-child {
|
|
font-size: 12px;
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
/* ── Footer ── */
|
|
.footer {
|
|
text-align: center;
|
|
padding: 24px;
|
|
color: var(--text-secondary);
|
|
font-size: 12px;
|
|
}
|
|
|
|
.footer a {
|
|
color: var(--primary);
|
|
text-decoration: none;
|
|
}
|
|
|
|
.footer a:hover {
|
|
text-decoration: underline;
|
|
}
|
|
|
|
/* ── Responsive ── */
|
|
@media (max-width: 640px) {
|
|
.source-card-header {
|
|
flex-direction: column;
|
|
}
|
|
|
|
.source-card-right {
|
|
align-self: flex-end;
|
|
}
|
|
|
|
.table-row {
|
|
padding-left: 24px;
|
|
}
|
|
|
|
.table-row-desc {
|
|
white-space: normal;
|
|
}
|
|
}
|
|
|
|
/* ═══════════════════════════════════════════════ */
|
|
/* Profiler Modal - preserved from original */
|
|
/* ═══════════════════════════════════════════════ */
|
|
.profiler-overlay {
|
|
display: none;
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
background: rgba(0, 0, 0, 0.5);
|
|
z-index: 1000;
|
|
padding: 40px 24px;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.profiler-overlay.active {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
justify-content: center;
|
|
}
|
|
|
|
.profiler-modal {
|
|
max-width: 1100px;
|
|
width: 100%;
|
|
max-height: calc(100vh - 80px);
|
|
background: var(--surface);
|
|
border-radius: 12px;
|
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
|
overflow: hidden;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.profiler-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding: 20px 24px;
|
|
border-bottom: 1px solid var(--border);
|
|
background: var(--background);
|
|
}
|
|
|
|
.profiler-header-left {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
}
|
|
|
|
.profiler-header h2 {
|
|
font-size: 20px;
|
|
font-weight: 600;
|
|
font-family: var(--font-mono);
|
|
}
|
|
|
|
.profiler-close {
|
|
width: 32px;
|
|
height: 32px;
|
|
border: none;
|
|
background: none;
|
|
cursor: pointer;
|
|
border-radius: 6px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
color: var(--text-secondary);
|
|
transition: all 0.15s;
|
|
}
|
|
|
|
.profiler-close:hover {
|
|
background: var(--border-light);
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.profiler-body {
|
|
padding: 24px;
|
|
overflow-y: auto;
|
|
overflow-x: hidden;
|
|
flex: 1;
|
|
min-height: 0;
|
|
}
|
|
|
|
/* Alert badges */
|
|
.alert-badge {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
font-size: 11px;
|
|
font-weight: 600;
|
|
padding: 2px 8px;
|
|
border-radius: 4px;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.02em;
|
|
}
|
|
|
|
.alert-badge.constant { background: #FEF3C7; color: #92400E; }
|
|
.alert-badge.unique { background: #EDE9FE; color: #5B21B6; }
|
|
.alert-badge.high_missing { background: #FEE2E2; color: #991B1B; }
|
|
.alert-badge.missing { background: #FEF3C7; color: #92400E; }
|
|
.alert-badge.imbalance { background: #DBEAFE; color: #1E40AF; }
|
|
.alert-badge.zeros { background: #DBEAFE; color: #1E40AF; }
|
|
.alert-badge.high_cardinality { background: #E0E7FF; color: #3730A3; }
|
|
|
|
/* Quality badge */
|
|
.quality-badge {
|
|
font-size: 12px;
|
|
font-weight: 600;
|
|
padding: 3px 10px;
|
|
border-radius: 9999px;
|
|
}
|
|
|
|
.quality-badge.good { background: #D1FAE5; color: #065F46; }
|
|
.quality-badge.warning { background: #FEF3C7; color: #92400E; }
|
|
.quality-badge.poor { background: #FEE2E2; color: #991B1B; }
|
|
|
|
/* Profiler navigation tabs */
|
|
.profiler-tabs {
|
|
display: flex;
|
|
gap: 0;
|
|
border-bottom: 1px solid var(--border);
|
|
padding: 0 24px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.profiler-tab {
|
|
padding: 10px 20px;
|
|
font-size: 13px;
|
|
font-weight: 500;
|
|
color: var(--text-secondary);
|
|
background: none;
|
|
border: none;
|
|
cursor: pointer;
|
|
border-bottom: 2px solid transparent;
|
|
transition: all 0.15s;
|
|
}
|
|
|
|
.profiler-tab:hover {
|
|
color: var(--text-primary);
|
|
background: var(--border-light);
|
|
}
|
|
|
|
.profiler-tab.active {
|
|
color: var(--primary);
|
|
border-bottom-color: var(--primary);
|
|
}
|
|
|
|
.profiler-tab .tab-count {
|
|
font-size: 11px;
|
|
font-weight: 600;
|
|
background: var(--border-light);
|
|
padding: 1px 6px;
|
|
border-radius: 9999px;
|
|
margin-left: 6px;
|
|
}
|
|
|
|
.profiler-tab.active .tab-count {
|
|
background: rgba(0, 115, 209, 0.1);
|
|
color: var(--primary);
|
|
}
|
|
|
|
.profiler-section {
|
|
display: none;
|
|
}
|
|
|
|
.profiler-section.active {
|
|
display: block;
|
|
}
|
|
|
|
/* Overview section */
|
|
.overview-grid {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 24px;
|
|
margin-bottom: 24px;
|
|
}
|
|
|
|
.overview-stats-table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
}
|
|
|
|
.overview-stats-table td {
|
|
padding: 8px 12px;
|
|
font-size: 13px;
|
|
border-bottom: 1px solid var(--border-light);
|
|
}
|
|
|
|
.overview-stats-table td:first-child {
|
|
font-weight: 500;
|
|
color: var(--text-primary);
|
|
width: 55%;
|
|
}
|
|
|
|
.overview-stats-table td:last-child {
|
|
color: var(--text-secondary);
|
|
text-align: right;
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
|
|
.overview-stats-table .highlight {
|
|
color: #DC2626;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.overview-title {
|
|
font-size: 15px;
|
|
font-weight: 600;
|
|
margin-bottom: 12px;
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
/* Variable cards */
|
|
.variable-card {
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px;
|
|
margin-bottom: 16px;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.variable-header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
padding: 12px 16px;
|
|
background: var(--background);
|
|
border-bottom: 1px solid var(--border);
|
|
}
|
|
|
|
.variable-name {
|
|
font-weight: 600;
|
|
font-family: var(--font-mono);
|
|
font-size: 14px;
|
|
color: var(--primary);
|
|
}
|
|
|
|
.variable-type {
|
|
font-size: 11px;
|
|
font-weight: 500;
|
|
color: var(--text-secondary);
|
|
background: var(--border-light);
|
|
padding: 2px 8px;
|
|
border-radius: 4px;
|
|
}
|
|
|
|
.variable-body {
|
|
display: grid;
|
|
grid-template-columns: auto 1fr;
|
|
gap: 0;
|
|
}
|
|
|
|
.variable-stats {
|
|
padding: 12px 16px;
|
|
min-width: 0;
|
|
}
|
|
|
|
.variable-stats table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
}
|
|
|
|
.variable-stats td {
|
|
padding: 4px 0;
|
|
font-size: 13px;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.variable-stats td:first-child {
|
|
color: var(--text-secondary);
|
|
padding-right: 12px;
|
|
}
|
|
|
|
.variable-stats td:last-child {
|
|
text-align: right;
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
|
|
.variable-chart {
|
|
padding: 12px 16px;
|
|
display: flex;
|
|
align-items: flex-start;
|
|
justify-content: center;
|
|
min-height: 120px;
|
|
min-width: 0;
|
|
}
|
|
|
|
.variable-chart canvas {
|
|
max-width: 100%;
|
|
max-height: 150px;
|
|
}
|
|
|
|
/* Numeric range bar visualization */
|
|
.num-viz {
|
|
width: 100%;
|
|
font-size: 12px;
|
|
}
|
|
|
|
.num-range {
|
|
margin-bottom: 10px;
|
|
}
|
|
|
|
.num-range-labels {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
font-size: 11px;
|
|
color: var(--text-secondary);
|
|
margin-bottom: 4px;
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
|
|
.num-range-track {
|
|
position: relative;
|
|
height: 14px;
|
|
background: var(--border-light);
|
|
border-radius: 7px;
|
|
margin-bottom: 6px;
|
|
}
|
|
|
|
.num-range-whisker {
|
|
position: absolute;
|
|
top: 0;
|
|
height: 100%;
|
|
background: rgba(0, 115, 209, 0.15);
|
|
border-radius: 7px;
|
|
}
|
|
|
|
.num-range-iqr {
|
|
position: absolute;
|
|
top: 0;
|
|
height: 100%;
|
|
background: rgba(0, 115, 209, 0.45);
|
|
border-radius: 5px;
|
|
}
|
|
|
|
.num-range-marker {
|
|
position: absolute;
|
|
top: -3px;
|
|
width: 2px;
|
|
height: 20px;
|
|
border-radius: 1px;
|
|
}
|
|
|
|
.num-range-legend {
|
|
display: flex;
|
|
gap: 10px;
|
|
font-size: 10px;
|
|
color: var(--text-secondary);
|
|
margin-top: 2px;
|
|
position: relative;
|
|
overflow: visible;
|
|
}
|
|
|
|
.num-range-legend span::before {
|
|
content: '';
|
|
display: inline-block;
|
|
width: 8px;
|
|
height: 8px;
|
|
border-radius: 2px;
|
|
margin-right: 3px;
|
|
vertical-align: middle;
|
|
}
|
|
|
|
.num-range-legend .legend-mean::before { background: #DC2626; }
|
|
.num-range-legend .legend-median::before { background: #059669; }
|
|
.num-range-legend .legend-iqr::before { background: rgba(0, 115, 209, 0.45); }
|
|
|
|
.num-constant {
|
|
text-align: center;
|
|
padding: 12px 0;
|
|
color: var(--text-secondary);
|
|
font-size: 13px;
|
|
}
|
|
|
|
.num-constant strong {
|
|
color: var(--text-primary);
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
|
|
.num-stats-row {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 6px;
|
|
margin-top: 10px;
|
|
}
|
|
|
|
.num-stat {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
background: var(--border-light);
|
|
border-radius: 10px;
|
|
padding: 3px 8px;
|
|
font-size: 11px;
|
|
line-height: 1.3;
|
|
cursor: help;
|
|
position: relative;
|
|
}
|
|
|
|
/* Tooltip trigger styling */
|
|
[data-tip] {
|
|
cursor: help;
|
|
}
|
|
|
|
/* Floating tooltip element (appended to body via JS) */
|
|
.tip-box {
|
|
position: fixed;
|
|
background: var(--text-primary);
|
|
color: #fff;
|
|
font-size: 11px;
|
|
font-weight: 400;
|
|
font-family: var(--font-primary);
|
|
line-height: 1.4;
|
|
padding: 6px 10px;
|
|
border-radius: 6px;
|
|
max-width: 260px;
|
|
pointer-events: none;
|
|
opacity: 0;
|
|
transition: opacity 0.15s ease;
|
|
z-index: 9999;
|
|
}
|
|
|
|
.tip-box.visible {
|
|
opacity: 1;
|
|
}
|
|
|
|
.num-stat-label {
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.num-stat-value {
|
|
font-weight: 500;
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
|
|
.num-stat.alert-zeros .num-stat-value {
|
|
color: #DC2626;
|
|
}
|
|
|
|
/* Categorical bar chart */
|
|
.cat-bars {
|
|
width: 100%;
|
|
padding: 8px 0;
|
|
}
|
|
|
|
.cat-bar-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
margin-bottom: 4px;
|
|
font-size: 12px;
|
|
}
|
|
|
|
.cat-bar-label {
|
|
width: 100px;
|
|
text-align: right;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
color: var(--text-secondary);
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.cat-bar-track {
|
|
flex: 1;
|
|
height: 18px;
|
|
background: var(--border-light);
|
|
border-radius: 2px;
|
|
overflow: hidden;
|
|
position: relative;
|
|
}
|
|
|
|
.cat-bar-fill {
|
|
height: 100%;
|
|
background: var(--primary);
|
|
border-radius: 2px;
|
|
display: flex;
|
|
align-items: center;
|
|
padding: 0 6px;
|
|
min-width: 30px;
|
|
}
|
|
|
|
.cat-bar-count {
|
|
font-size: 11px;
|
|
font-weight: 500;
|
|
color: white;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.cat-bar-count.outside {
|
|
color: var(--text-secondary);
|
|
margin-left: 6px;
|
|
}
|
|
|
|
/* Boolean bar */
|
|
.bool-bar {
|
|
display: flex;
|
|
height: 24px;
|
|
border-radius: 4px;
|
|
overflow: hidden;
|
|
width: 100%;
|
|
margin-top: 8px;
|
|
}
|
|
|
|
.bool-bar .true-part {
|
|
background: var(--primary);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
color: white;
|
|
font-size: 11px;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.bool-bar .false-part {
|
|
background: #E5E7EB;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
color: var(--text-secondary);
|
|
font-size: 11px;
|
|
font-weight: 600;
|
|
}
|
|
|
|
/* Insights list */
|
|
.insight-group {
|
|
margin-bottom: 16px;
|
|
border-radius: 8px;
|
|
border: 1px solid var(--border-light);
|
|
overflow: hidden;
|
|
}
|
|
.insight-group.border-imbalance { border-left: 3px solid #3B82F6; }
|
|
.insight-group.border-unique { border-left: 3px solid #7C3AED; }
|
|
.insight-group.border-high_missing { border-left: 3px solid #DC2626; }
|
|
.insight-group.border-missing { border-left: 3px solid #D97706; }
|
|
.insight-group.border-zeros { border-left: 3px solid #3B82F6; }
|
|
.insight-group.border-high_cardinality { border-left: 3px solid #4338CA; }
|
|
.insight-group.border-constant { border-left: 3px solid #D97706; }
|
|
|
|
.insight-group-header {
|
|
display: flex;
|
|
align-items: baseline;
|
|
gap: 10px;
|
|
padding: 10px 16px;
|
|
background: var(--background);
|
|
}
|
|
|
|
.alert-explanation {
|
|
font-size: 12px;
|
|
color: var(--text-secondary);
|
|
line-height: 1.4;
|
|
}
|
|
|
|
.insight-items {
|
|
padding: 0;
|
|
}
|
|
|
|
.insight-item {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 7px 16px;
|
|
border-top: 1px solid var(--border-light);
|
|
font-size: 13px;
|
|
}
|
|
|
|
.insight-col {
|
|
font-weight: 500;
|
|
font-family: var(--font-mono);
|
|
color: var(--primary);
|
|
cursor: pointer;
|
|
text-decoration: none;
|
|
}
|
|
|
|
.insight-col:hover {
|
|
text-decoration: underline;
|
|
}
|
|
|
|
.insight-detail {
|
|
color: var(--text-secondary);
|
|
font-size: 12px;
|
|
}
|
|
|
|
.variable-highlight {
|
|
animation: highlightPulse 2s ease-out;
|
|
}
|
|
|
|
@keyframes highlightPulse {
|
|
0% { box-shadow: 0 0 0 3px rgba(0, 115, 209, 0.4); }
|
|
100% { box-shadow: none; }
|
|
}
|
|
|
|
/* Missing values chart */
|
|
.missing-chart-container {
|
|
height: 200px;
|
|
margin-bottom: 24px;
|
|
}
|
|
|
|
/* Relationships */
|
|
.relationship-card {
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px;
|
|
padding: 16px;
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
.relationship-header {
|
|
font-size: 13px;
|
|
font-weight: 600;
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.relationship-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
padding: 6px 0;
|
|
font-size: 13px;
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.relationship-table-link {
|
|
font-family: var(--font-mono);
|
|
font-weight: 500;
|
|
color: var(--primary);
|
|
cursor: pointer;
|
|
text-decoration: none;
|
|
}
|
|
|
|
.relationship-table-link:hover {
|
|
text-decoration: underline;
|
|
}
|
|
|
|
.relationship-direction {
|
|
font-size: 11px;
|
|
padding: 1px 6px;
|
|
border-radius: 4px;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.relationship-direction.belongs_to { background: #DBEAFE; color: #1E40AF; }
|
|
.relationship-direction.has_many { background: #D1FAE5; color: #065F46; }
|
|
|
|
/* Relationship Mermaid diagram */
|
|
.relationship-diagram-wrap {
|
|
background: var(--background);
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px;
|
|
padding: 16px;
|
|
margin-bottom: 16px;
|
|
overflow-x: auto;
|
|
}
|
|
|
|
.relationship-diagram-wrap .mermaid {
|
|
display: flex;
|
|
justify-content: center;
|
|
}
|
|
|
|
.relationship-diagram-wrap svg {
|
|
max-width: 100%;
|
|
height: auto;
|
|
}
|
|
|
|
.relationship-diagram-wrap svg .node.clickable-node {
|
|
cursor: pointer;
|
|
}
|
|
|
|
.relationship-diagram-wrap svg .node.clickable-node:hover {
|
|
filter: brightness(0.92);
|
|
transition: filter 0.15s ease;
|
|
}
|
|
|
|
.relationship-legend {
|
|
display: flex;
|
|
gap: 16px;
|
|
justify-content: center;
|
|
margin-top: 10px;
|
|
font-size: 11px;
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.relationship-legend-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 5px;
|
|
}
|
|
|
|
.relationship-legend-dot {
|
|
width: 10px;
|
|
height: 10px;
|
|
border-radius: 50%;
|
|
display: inline-block;
|
|
}
|
|
|
|
.relationship-legend-dot.current { background: #2563eb; }
|
|
.relationship-legend-dot.parent { background: #DBEAFE; border: 1px solid #93C5FD; }
|
|
.relationship-legend-dot.child { background: #D1FAE5; border: 1px solid #6EE7B7; }
|
|
.relationship-legend-dot.metric { background: #FEF3C7; border: 1px solid #FCD34D; }
|
|
|
|
/* Metrics badges */
|
|
.metric-badge {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
font-size: 12px;
|
|
font-weight: 500;
|
|
padding: 4px 10px;
|
|
background: rgba(0, 115, 209, 0.08);
|
|
color: var(--primary);
|
|
border-radius: 6px;
|
|
margin: 4px 4px 4px 0;
|
|
}
|
|
|
|
/* Sample data table */
|
|
.sample-table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
font-size: 12px;
|
|
overflow-x: auto;
|
|
display: block;
|
|
}
|
|
|
|
.sample-table th {
|
|
font-weight: 600;
|
|
background: var(--background);
|
|
padding: 8px 12px;
|
|
border: 1px solid var(--border);
|
|
white-space: nowrap;
|
|
font-family: var(--font-mono);
|
|
font-size: 11px;
|
|
}
|
|
|
|
.sample-table td {
|
|
padding: 6px 12px;
|
|
border: 1px solid var(--border-light);
|
|
max-width: 200px;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
|
|
/* Loading spinner */
|
|
.profiler-loading {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 60px;
|
|
color: var(--text-secondary);
|
|
gap: 12px;
|
|
}
|
|
|
|
.spinner {
|
|
width: 32px;
|
|
height: 32px;
|
|
border: 3px solid var(--border);
|
|
border-top-color: var(--primary);
|
|
border-radius: 50%;
|
|
animation: spin 0.8s linear infinite;
|
|
}
|
|
|
|
@keyframes spin {
|
|
to { transform: rotate(360deg); }
|
|
}
|
|
|
|
/* Empty state */
|
|
.profiler-empty {
|
|
text-align: center;
|
|
padding: 48px;
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.profiler-empty p {
|
|
margin-top: 8px;
|
|
font-size: 13px;
|
|
}
|
|
</style>
|
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/metric_modal.css') }}">
|
|
<!-- Prism.js for SQL syntax highlighting -->
|
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/themes/prism.min.css">
|
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"></script>
|
|
{% include '_theme.html' %}
|
|
</head>
|
|
<body>
|
|
|
|
<!-- ═══════════════ HEADER ═══════════════ -->
|
|
<header class="header">
|
|
<div class="header-left">
|
|
<a href="{{ url_for('dashboard') }}" class="header-back" title="Back to Dashboard">
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<path d="M19 12H5M12 19l-7-7 7-7"/>
|
|
</svg>
|
|
</a>
|
|
<div class="header-logo-group">
|
|
<div class="header-logo">
|
|
{{ config.LOGO_SVG | safe }}
|
|
</div>
|
|
<span class="header-subtitle">Data Catalog</span>
|
|
</div>
|
|
</div>
|
|
<div class="header-right">
|
|
{% if data_stats.last_updated %}Last sync: {{ data_stats.last_updated }}{% endif %}
|
|
</div>
|
|
</header>
|
|
|
|
<!-- ═══════════════ PAGE TITLE ═══════════════ -->
|
|
<div class="page-title">
|
|
<h1>Data Catalog</h1>
|
|
<p>Browse available data sources and manage your subscriptions</p>
|
|
</div>
|
|
|
|
<!-- ═══════════════ SOURCE CARDS ═══════════════ -->
|
|
<div class="source-cards">
|
|
|
|
<!-- ── Card 1: Core Business Data ── -->
|
|
<div class="source-card">
|
|
<div class="source-card-header">
|
|
<div class="source-card-left">
|
|
<div class="source-card-icon primary">
|
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#0073D1" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
|
<polyline points="14 2 14 8 20 8"/>
|
|
<line x1="16" y1="13" x2="8" y2="13"/>
|
|
<line x1="16" y1="17" x2="8" y2="17"/>
|
|
<polyline points="10 9 9 9 8 9"/>
|
|
</svg>
|
|
</div>
|
|
<div class="source-card-info">
|
|
<div class="source-card-name">Core Business Data</div>
|
|
<div class="source-card-desc">Core business data from internal systems</div>
|
|
<div class="source-card-meta">{{ data_stats.tables }} tables · ~{{ data_stats.rows_display }} rows total</div>
|
|
</div>
|
|
</div>
|
|
<div class="source-card-right">
|
|
<span class="badge-included">Always included</span>
|
|
<label class="toggle-switch locked">
|
|
<input type="checkbox" checked disabled>
|
|
<span class="toggle-slider"></span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
{% for category in catalog_data %}
|
|
<div class="accordion-category">
|
|
<button class="accordion-trigger" onclick="toggleAccordion(this)">
|
|
<svg class="accordion-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<polyline points="9 18 15 12 9 6"/>
|
|
</svg>
|
|
{{ category.name }}
|
|
<span class="accordion-count">{{ category.count }} tables</span>
|
|
</button>
|
|
<div class="accordion-content">
|
|
{% for table in category.tables %}
|
|
<div class="table-row" onclick="openProfiler('{{ table.name }}')">
|
|
<div class="table-row-left">
|
|
<div class="table-row-name">{{ table.name }}</div>
|
|
<div class="table-row-desc">{{ table.description }}</div>
|
|
</div>
|
|
<div class="table-row-right">
|
|
<span class="rows-badge{{ ' large' if table.rows_large }}">{{ table.rows_display }}</span>
|
|
<span class="profile-link">
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7z"/><circle cx="12" cy="12" r="3"/></svg>
|
|
Profile
|
|
</span>
|
|
</div>
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
|
|
<!-- ── Card: Business Metrics ── -->
|
|
{% if metrics_data %}
|
|
{% set metrics_total = namespace(n=0) %}
|
|
{% for c in metrics_data %}{% set metrics_total.n = metrics_total.n + c.metrics|length %}{% endfor %}
|
|
<div class="source-card">
|
|
<div class="source-card-header">
|
|
<div class="source-card-left">
|
|
<div class="source-card-icon primary">
|
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#0073D1" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
|
<path d="M3 3v18h18"/>
|
|
<path d="M18.7 8l-5.1 5.2-2.8-2.7L7 14.3"/>
|
|
</svg>
|
|
</div>
|
|
<div class="source-card-info">
|
|
<div class="source-card-name">Business Metrics</div>
|
|
<div class="source-card-desc">Standardized metric definitions with SQL examples and documentation</div>
|
|
<div class="source-card-meta">{{ metrics_total.n }} metrics · {{ metrics_data|length }} categories</div>
|
|
</div>
|
|
</div>
|
|
<div class="source-card-right">
|
|
<span class="badge-included">Always included</span>
|
|
<label class="toggle-switch locked">
|
|
<input type="checkbox" checked disabled>
|
|
<span class="toggle-slider"></span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
{% for category in metrics_data %}
|
|
<div class="accordion-category">
|
|
<button class="accordion-trigger" onclick="toggleAccordion(this)">
|
|
<svg class="accordion-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<polyline points="9 18 15 12 9 6"/>
|
|
</svg>
|
|
<span class="category-tag {{ category.css }}">{{ category.label }}</span>
|
|
<span class="accordion-count">{{ category.metrics|length }} metric{{ 's' if category.metrics|length != 1 }}</span>
|
|
</button>
|
|
<div class="accordion-content">
|
|
{% for metric in category.metrics %}
|
|
<div class="table-row" onclick="openMetricModal('{{ metric.path }}')">
|
|
<div class="table-row-left">
|
|
<div class="table-row-name">{{ metric.name }}</div>
|
|
<div class="table-row-desc">{{ metric.description }}</div>
|
|
</div>
|
|
<div class="table-row-right">
|
|
<span class="rows-badge">{{ metric.grain }}</span>
|
|
</div>
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
{% endif %}
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
<!-- ═══════════════ PROFILER MODAL (preserved 1:1) ═══════════════ -->
|
|
<div id="profilerOverlay" class="profiler-overlay" onclick="if(event.target===this)closeProfiler()">
|
|
<div class="profiler-modal">
|
|
<div class="profiler-header">
|
|
<div class="profiler-header-left">
|
|
<h2 id="profilerTitle">-</h2>
|
|
<span id="profilerQuality" class="quality-badge"></span>
|
|
<span id="profilerAlertSummary"></span>
|
|
</div>
|
|
<button class="profiler-close" onclick="closeProfiler()">
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<path d="M18 6L6 18M6 6l12 12"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
<!-- Tabs (outside scrollable body so they stay fixed) -->
|
|
<div class="profiler-tabs" id="profilerTabs" style="display:none;">
|
|
<button class="profiler-tab active" onclick="switchTab('overview')">Overview</button>
|
|
<button class="profiler-tab" onclick="switchTab('variables')" id="tabVariables">Columns</button>
|
|
<button class="profiler-tab" onclick="switchTab('alerts')" id="tabAlerts">Insights</button>
|
|
<button class="profiler-tab" onclick="switchTab('missing')" id="tabMissing">Missing Values</button>
|
|
<button class="profiler-tab" onclick="switchTab('relationships')" id="tabRelationships">Relationships</button>
|
|
<button class="profiler-tab" onclick="switchTab('sample')" id="tabSample">Sample</button>
|
|
</div>
|
|
<div class="profiler-body">
|
|
<div id="profilerLoading" class="profiler-loading">
|
|
<div class="spinner"></div>
|
|
<span>Loading profile data...</span>
|
|
</div>
|
|
<div id="profilerContent" style="display:none;">
|
|
|
|
<!-- Overview Section -->
|
|
<div id="sectionOverview" class="profiler-section active"></div>
|
|
|
|
<!-- Variables Section -->
|
|
<div id="sectionVariables" class="profiler-section"></div>
|
|
|
|
<!-- Alerts Section -->
|
|
<div id="sectionAlerts" class="profiler-section"></div>
|
|
|
|
<!-- Missing Values Section -->
|
|
<div id="sectionMissing" class="profiler-section">
|
|
<div class="missing-chart-container">
|
|
<canvas id="missingChart"></canvas>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Relationships Section -->
|
|
<div id="sectionRelationships" class="profiler-section"></div>
|
|
|
|
<!-- Sample Section -->
|
|
<div id="sectionSample" class="profiler-section"></div>
|
|
</div>
|
|
<div id="profilerError" class="profiler-empty" style="display:none;">
|
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="var(--text-secondary)" stroke-width="1.5" opacity="0.5"><circle cx="12" cy="12" r="10"/><path d="M12 8v4M12 16h.01"/></svg>
|
|
<p id="profilerErrorMsg">Profile data not available for this table.</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<footer class="footer">
|
|
<p>© {{ config.INSTANCE_COPYRIGHT or 'AI Data Analyst' }} · Updated daily</p>
|
|
</footer>
|
|
|
|
<script>
|
|
/* ═══════════════ ACCORDION ═══════════════ */
|
|
function toggleAccordion(trigger) {
|
|
const content = trigger.nextElementSibling;
|
|
const isExpanded = trigger.classList.contains('expanded');
|
|
|
|
if (isExpanded) {
|
|
trigger.classList.remove('expanded');
|
|
content.classList.remove('expanded');
|
|
} else {
|
|
trigger.classList.add('expanded');
|
|
content.classList.add('expanded');
|
|
}
|
|
}
|
|
|
|
/* ═══════════════ DATASET SUBSCRIPTIONS ═══════════════ */
|
|
/* ═══════════════ PROFILER (preserved 1:1) ═══════════════ */
|
|
let currentCharts = [];
|
|
let currentProfile = null;
|
|
let mermaidDiagramRendered = false;
|
|
let currentDiagramNodeMap = {};
|
|
|
|
function openProfiler(tableName, initialTab) {
|
|
const overlay = document.getElementById('profilerOverlay');
|
|
const loading = document.getElementById('profilerLoading');
|
|
const content = document.getElementById('profilerContent');
|
|
const error = document.getElementById('profilerError');
|
|
|
|
overlay.classList.add('active');
|
|
loading.style.display = 'flex';
|
|
content.style.display = 'none';
|
|
document.getElementById('profilerTabs').style.display = 'none';
|
|
error.style.display = 'none';
|
|
document.body.style.overflow = 'hidden';
|
|
|
|
document.getElementById('profilerTitle').textContent = tableName;
|
|
|
|
fetch(`/api/catalog/profile/${tableName}`)
|
|
.then(r => {
|
|
if (!r.ok) throw new Error('not found');
|
|
return r.json();
|
|
})
|
|
.then(data => {
|
|
currentProfile = data;
|
|
loading.style.display = 'none';
|
|
content.style.display = 'block';
|
|
document.getElementById('profilerTabs').style.display = 'flex';
|
|
renderProfile(data, initialTab);
|
|
})
|
|
.catch(err => {
|
|
loading.style.display = 'none';
|
|
document.getElementById('profilerTabs').style.display = 'none';
|
|
error.style.display = 'block';
|
|
});
|
|
}
|
|
|
|
function closeProfiler() {
|
|
document.getElementById('profilerOverlay').classList.remove('active');
|
|
document.getElementById('profilerTabs').style.display = 'none';
|
|
document.body.style.overflow = '';
|
|
destroyCharts();
|
|
currentProfile = null;
|
|
mermaidDiagramRendered = false;
|
|
currentDiagramNodeMap = {};
|
|
}
|
|
|
|
function destroyCharts() {
|
|
currentCharts.forEach(c => c.destroy());
|
|
currentCharts = [];
|
|
}
|
|
|
|
function switchTab(tabName) {
|
|
document.querySelectorAll('.profiler-tab').forEach(t => t.classList.remove('active'));
|
|
document.querySelectorAll('.profiler-section').forEach(s => s.classList.remove('active'));
|
|
event.target.classList.add('active');
|
|
document.getElementById('section' + tabName.charAt(0).toUpperCase() + tabName.slice(1)).classList.add('active');
|
|
|
|
// Lazy-render Mermaid diagram when Relationships tab is first shown
|
|
if (tabName === 'relationships' && !mermaidDiagramRendered && typeof mermaid !== 'undefined') {
|
|
const mermaidEl = document.querySelector('#sectionRelationships .mermaid');
|
|
if (mermaidEl && !mermaidEl.getAttribute('data-processed')) {
|
|
mermaid.run({ nodes: [mermaidEl] }).then(() => {
|
|
attachDiagramClickHandlers();
|
|
});
|
|
mermaidDiagramRendered = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
function navigateToVariable(columnName) {
|
|
// Switch to Columns tab
|
|
document.querySelectorAll('.profiler-tab').forEach(t => t.classList.remove('active'));
|
|
document.querySelectorAll('.profiler-section').forEach(s => s.classList.remove('active'));
|
|
const varTab = document.querySelector('#tabVariables');
|
|
if (varTab) varTab.classList.add('active');
|
|
document.getElementById('sectionVariables').classList.add('active');
|
|
|
|
// Scroll to the variable card within the modal body
|
|
const card = document.getElementById('var_' + columnName);
|
|
if (card) {
|
|
// Use scrollIntoView within the scrollable .profiler-body container
|
|
setTimeout(() => {
|
|
card.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
card.classList.add('variable-highlight');
|
|
setTimeout(() => card.classList.remove('variable-highlight'), 2000);
|
|
}, 50);
|
|
}
|
|
}
|
|
|
|
function navigateToInsight(insightType) {
|
|
// Switch to Insights tab
|
|
document.querySelectorAll('.profiler-tab').forEach(t => t.classList.remove('active'));
|
|
document.querySelectorAll('.profiler-section').forEach(s => s.classList.remove('active'));
|
|
const tab = document.querySelector('#tabAlerts');
|
|
if (tab) tab.classList.add('active');
|
|
document.getElementById('sectionAlerts').classList.add('active');
|
|
|
|
const group = document.getElementById('insight_' + insightType);
|
|
if (group) {
|
|
setTimeout(() => {
|
|
group.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
group.classList.add('variable-highlight');
|
|
setTimeout(() => group.classList.remove('variable-highlight'), 2000);
|
|
}, 50);
|
|
}
|
|
}
|
|
|
|
function formatNumber(n) {
|
|
if (n === null || n === undefined) return '-';
|
|
if (Math.abs(n) >= 1e9) return (n / 1e9).toFixed(1) + 'B';
|
|
if (Math.abs(n) >= 1e6) return (n / 1e6).toFixed(1) + 'M';
|
|
if (Math.abs(n) >= 1e3) return (n / 1e3).toFixed(1) + 'K';
|
|
if (Number.isInteger(n)) return n.toLocaleString();
|
|
return Number(n).toFixed(2);
|
|
}
|
|
|
|
const INSIGHT_TOOLTIPS = {
|
|
unique: "Every value in this column is different. This is typical for ID columns and usually means this column can uniquely identify each row.",
|
|
high_cardinality: "This column has a very large number of distinct values. It may be an ID or free-text field. Not ideal for grouping or filtering in reports.",
|
|
high_missing: "Most values in this column are empty. Consider whether this data is collected consistently or if this column is only relevant for specific records.",
|
|
missing: "Some values in this column are empty. Check if the gaps are expected (e.g. optional fields) or indicate a data collection issue.",
|
|
zeros: "A large portion of values are zero. This may be normal (e.g. no change periods) or could indicate missing data entered as zero.",
|
|
imbalance: "One value dominates this column (appears in 90%+ of rows). This is normal for status or flag columns but limits analytical usefulness.",
|
|
constant: "This column has the same value in every row. It provides no analytical value and can usually be ignored in analysis.",
|
|
};
|
|
|
|
function renderAlertBadge(alert, clickable) {
|
|
const tooltip = INSIGHT_TOOLTIPS[alert] || '';
|
|
const label = alert.replace('_', ' ');
|
|
if (clickable) {
|
|
return `<span class="alert-badge ${alert}" title="${tooltip}" style="cursor:pointer;" onclick="event.stopPropagation();navigateToInsight('${alert}')">${label}</span>`;
|
|
}
|
|
return `<span class="alert-badge ${alert}" title="${tooltip}">${label}</span>`;
|
|
}
|
|
|
|
function renderProfile(data, initialTab) {
|
|
destroyCharts();
|
|
|
|
// Header badges
|
|
const quality = data.avg_completeness || 0;
|
|
const qBadge = document.getElementById('profilerQuality');
|
|
qBadge.textContent = quality.toFixed(1) + '% complete';
|
|
qBadge.className = 'quality-badge ' + (quality >= 95 ? 'good' : quality >= 80 ? 'warning' : 'poor');
|
|
|
|
const alertCount = (data.alerts || []).length;
|
|
document.getElementById('profilerAlertSummary').innerHTML = alertCount > 0
|
|
? `<span class="alert-badge imbalance">${alertCount} insight${alertCount > 1 ? 's' : ''}</span>`
|
|
: '';
|
|
|
|
// Tab counts
|
|
const cols = data.columns || [];
|
|
document.getElementById('tabVariables').innerHTML = `Columns <span class="tab-count">${cols.length}</span>`;
|
|
document.getElementById('tabAlerts').innerHTML = `Insights <span class="tab-count">${alertCount}</span>`;
|
|
|
|
renderOverview(data);
|
|
renderVariables(data);
|
|
renderAlerts(data);
|
|
renderMissing(data);
|
|
renderRelationships(data);
|
|
renderSample(data);
|
|
|
|
// Set active tab (default: overview)
|
|
const targetTab = initialTab || 'overview';
|
|
document.querySelectorAll('.profiler-tab').forEach(t => t.classList.remove('active'));
|
|
document.querySelectorAll('.profiler-section').forEach(s => s.classList.remove('active'));
|
|
|
|
const targetSection = document.getElementById('section' + targetTab.charAt(0).toUpperCase() + targetTab.slice(1));
|
|
if (targetSection) {
|
|
targetSection.classList.add('active');
|
|
// Activate matching tab button
|
|
document.querySelectorAll('.profiler-tab').forEach(t => {
|
|
if (t.getAttribute('onclick') && t.getAttribute('onclick').includes("'" + targetTab + "'")) {
|
|
t.classList.add('active');
|
|
}
|
|
});
|
|
// Trigger lazy Mermaid render if opening relationships tab directly
|
|
if (targetTab === 'relationships' && !mermaidDiagramRendered && typeof mermaid !== 'undefined') {
|
|
const mermaidEl = document.querySelector('#sectionRelationships .mermaid');
|
|
if (mermaidEl && !mermaidEl.getAttribute('data-processed')) {
|
|
mermaid.run({ nodes: [mermaidEl] }).then(() => {
|
|
attachDiagramClickHandlers();
|
|
});
|
|
mermaidDiagramRendered = true;
|
|
}
|
|
}
|
|
} else {
|
|
// Fallback to overview
|
|
document.querySelector('.profiler-tab').classList.add('active');
|
|
document.getElementById('sectionOverview').classList.add('active');
|
|
}
|
|
}
|
|
|
|
function renderOverview(data) {
|
|
const vt = data.variable_types || {};
|
|
const vtHtml = Object.entries(vt).map(([t, c]) =>
|
|
`<tr><td>${t}</td><td>${c}</td></tr>`
|
|
).join('');
|
|
|
|
const missingPct = data.missing_cells_pct || 0;
|
|
const missingClass = missingPct > 20 ? ' class="highlight"' : '';
|
|
|
|
const dr = data.date_range;
|
|
const dateRow = dr ? `<tr><td>Date Coverage</td><td>${dr.earliest} to ${dr.latest}</td></tr>` : '';
|
|
|
|
// Catalog enrichment section
|
|
let catalogHtml = '';
|
|
if (data.catalog) {
|
|
const cat = data.catalog;
|
|
const tagsHtml = (cat.tags || []).length > 0
|
|
? `<div style="margin-top:8px;"><span style="font-size:11px;color:var(--text-secondary);font-weight:500;">Tags:</span> ${cat.tags.map(t => `<span class="metric-badge" style="display:inline-block;margin:2px 4px 2px 0;">${t}</span>`).join('')}</div>`
|
|
: '';
|
|
const ownersHtml = (cat.owners || []).length > 0
|
|
? `<div style="margin-top:8px;"><span style="font-size:11px;color:var(--text-secondary);font-weight:500;">Owners:</span> ${cat.owners.join(', ')}</div>`
|
|
: '';
|
|
const tierHtml = cat.tier
|
|
? `<div style="margin-top:8px;"><span style="font-size:11px;color:var(--text-secondary);font-weight:500;">Tier:</span> <strong>${cat.tier}</strong></div>`
|
|
: '';
|
|
const urlHtml = cat.url
|
|
? `<div style="margin-top:8px;"><a href="${cat.url}" target="_blank" style="font-size:11px;color:var(--primary);text-decoration:none;">View in Data Catalog →</a></div>`
|
|
: '';
|
|
|
|
catalogHtml = `<div style="margin-top:16px;border-top:1px solid var(--border);padding-top:16px;"><div class="overview-title">Data Catalog</div>${tierHtml}${ownersHtml}${tagsHtml}${urlHtml}</div>`;
|
|
}
|
|
|
|
document.getElementById('sectionOverview').innerHTML = `
|
|
<div class="overview-grid">
|
|
<div>
|
|
<div class="overview-title">Dataset Statistics</div>
|
|
<table class="overview-stats-table">
|
|
<tr><td>Number of variables</td><td>${data.column_count || 0}</td></tr>
|
|
<tr><td>Number of observations</td><td>${formatNumber(data.row_count || 0)}</td></tr>
|
|
<tr><td>Missing cells</td><td${missingClass}>${formatNumber(data.missing_cells || 0)}</td></tr>
|
|
<tr><td>Missing cells (%)</td><td${missingClass}>${missingPct.toFixed(1)}%</td></tr>
|
|
<tr><td>Duplicate rows</td><td>${formatNumber(data.duplicate_rows || 0)}</td></tr>
|
|
<tr><td>File size</td><td>${(data.file_size_mb || 0).toFixed(2)} MB</td></tr>
|
|
${dateRow}
|
|
<tr><td>Last sync</td><td>${data.last_sync ? data.last_sync.substring(0, 16).replace('T', ' ') : '-'}</td></tr>
|
|
</table>
|
|
</div>
|
|
<div>
|
|
<div class="overview-title">Variable Types</div>
|
|
<table class="overview-stats-table">${vtHtml}</table>
|
|
${data.description ? `<div style="margin-top:16px;"><div class="overview-title">Description</div><p style="font-size:13px;color:var(--text-secondary);">${data.description}</p></div>` : ''}
|
|
${(data.used_by_metrics || []).length > 0 ? `<div style="margin-top:16px;"><div class="overview-title">Used by Metrics</div><div>${data.used_by_metrics.map(m => m.file ? `<a class="metric-badge" style="cursor:pointer;text-decoration:none;" onclick="openMetricModal('${m.file}')">${m.name}</a>` : `<span class="metric-badge">${typeof m === 'string' ? m : m.name}</span>`).join('')}</div></div>` : ''}
|
|
${catalogHtml}
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
function buildNumericViz(ns, idx) {
|
|
const hasRange = ns.min != null && ns.max != null;
|
|
const isConstant = hasRange && ns.min === ns.max;
|
|
|
|
let rangeHtml = '';
|
|
|
|
if (isConstant) {
|
|
rangeHtml = `<div class="num-constant">Constant value: <strong>${formatNumber(ns.min)}</strong></div>`;
|
|
} else if (hasRange) {
|
|
const range = ns.max - ns.min;
|
|
const pos = (val) => Math.max(0, Math.min(100, ((val - ns.min) / range) * 100));
|
|
|
|
// Whisker (p5 -> p95)
|
|
let whiskerHtml = '';
|
|
if (ns.p5 != null && ns.p95 != null) {
|
|
const wLeft = pos(ns.p5);
|
|
const wWidth = pos(ns.p95) - wLeft;
|
|
whiskerHtml = `<div class="num-range-whisker" style="left:${wLeft}%;width:${wWidth}%"></div>`;
|
|
}
|
|
|
|
// IQR box (p25 -> p75)
|
|
let iqrHtml = '';
|
|
if (ns.p25 != null && ns.p75 != null) {
|
|
const iLeft = pos(ns.p25);
|
|
const iWidth = pos(ns.p75) - iLeft;
|
|
iqrHtml = `<div class="num-range-iqr" style="left:${iLeft}%;width:${iWidth}%"></div>`;
|
|
}
|
|
|
|
// Mean marker (red)
|
|
let meanMarker = '';
|
|
if (ns.mean != null) {
|
|
meanMarker = `<div class="num-range-marker" style="left:${pos(ns.mean)}%;background:#DC2626;transform:translateX(-50%)"></div>`;
|
|
}
|
|
|
|
// Median marker (green)
|
|
let medianMarker = '';
|
|
if (ns.median != null) {
|
|
medianMarker = `<div class="num-range-marker" style="left:${pos(ns.median)}%;background:#059669;transform:translateX(-50%)"></div>`;
|
|
}
|
|
|
|
rangeHtml = `<div class="num-range">
|
|
<div class="num-range-labels">
|
|
<span>${formatNumber(ns.min)}</span>
|
|
<span>${formatNumber(ns.max)}</span>
|
|
</div>
|
|
<div class="num-range-track">
|
|
${whiskerHtml}${iqrHtml}${meanMarker}${medianMarker}
|
|
</div>
|
|
<div class="num-range-legend">
|
|
<span class="legend-mean" data-tip="Average value. If far from Median, data is skewed by extreme values.">Mean</span>
|
|
<span class="legend-median" data-tip="Middle value. Half of records are above, half below this point.">Median</span>
|
|
<span class="legend-iqr" data-tip="Where 50% of your data falls. Narrow = consistent values, wide = high variation.">Typical range</span>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
// Stat pills with business-friendly tooltips
|
|
const PILL_TIPS = {
|
|
mean: 'Average value across all rows. Useful for understanding the typical size, but can be skewed by extreme values.',
|
|
median: 'The middle value when sorted. More reliable than Mean when data has outliers \u2014 if Mean and Median differ a lot, you have skewed data.',
|
|
std: 'Standard Deviation \u2014 how spread out the values are. Low = values cluster near the mean. High = wide variation across records.',
|
|
zeros: 'How many records have value zero. High percentage may indicate missing data entered as zero, or genuinely inactive records.',
|
|
negative: 'Records with negative values. Could indicate returns, corrections, or data entry errors depending on the business context.',
|
|
};
|
|
const pills = [];
|
|
if (ns.mean != null) pills.push(`<span class="num-stat" data-tip="${PILL_TIPS.mean}"><span class="num-stat-label">Mean</span><span class="num-stat-value">${formatNumber(ns.mean)}</span></span>`);
|
|
if (ns.median != null) pills.push(`<span class="num-stat" data-tip="${PILL_TIPS.median}"><span class="num-stat-label">Median</span><span class="num-stat-value">${formatNumber(ns.median)}</span></span>`);
|
|
if (ns.stddev != null) pills.push(`<span class="num-stat" data-tip="${PILL_TIPS.std}"><span class="num-stat-label">Std</span><span class="num-stat-value">${formatNumber(ns.stddev)}</span></span>`);
|
|
if (ns.zeros) {
|
|
const alertClass = ns.zeros_pct > 50 ? ' alert-zeros' : '';
|
|
pills.push(`<span class="num-stat${alertClass}" data-tip="${PILL_TIPS.zeros}"><span class="num-stat-label">Zeros</span><span class="num-stat-value">${formatNumber(ns.zeros)} (${ns.zeros_pct.toFixed(1)}%)</span></span>`);
|
|
}
|
|
if (ns.negative) {
|
|
pills.push(`<span class="num-stat" data-tip="${PILL_TIPS.negative}"><span class="num-stat-label">Negative</span><span class="num-stat-value">${formatNumber(ns.negative)} (${ns.negative_pct.toFixed(1)}%)</span></span>`);
|
|
}
|
|
|
|
return `<div class="num-viz">
|
|
${rangeHtml}
|
|
<div style="height:140px;"><canvas id="hist_${idx}"></canvas></div>
|
|
<div class="num-stats-row">${pills.join('')}</div>
|
|
</div>`;
|
|
}
|
|
|
|
function renderVariables(data) {
|
|
const cols = data.columns || [];
|
|
let html = '';
|
|
|
|
cols.forEach((col, idx) => {
|
|
const alerts = (col.alerts || []).map(a => renderAlertBadge(a, true)).join(' ');
|
|
const type = col.type || 'STRING';
|
|
const simplifiedType = type.includes('TIMESTAMP') ? 'DateTime' :
|
|
type === 'DATE' ? 'Date' :
|
|
type === 'BOOLEAN' ? 'Boolean' :
|
|
type === 'NUMERIC' || type.includes('INT') || type.includes('FLOAT') || type.includes('DOUBLE') ? 'Numeric' :
|
|
'Text';
|
|
|
|
const missingPct = 100 - (col.completeness_pct || 100);
|
|
const missingClass = missingPct > 30 ? ' class="highlight"' : missingPct > 5 ? ' style="color:#B45309;"' : '';
|
|
|
|
const distinctPct = col.unique_pct || 0;
|
|
const distinctClass = distinctPct >= 99.9 ? ' style="color:#DC2626;"' : '';
|
|
|
|
let chartHtml = '';
|
|
|
|
// Categorical bar chart
|
|
if (col.string_stats && col.string_stats.top_values && col.string_stats.top_values.length > 0) {
|
|
const topVals = col.string_stats.top_values.slice(0, 8);
|
|
const maxCount = topVals[0].count;
|
|
chartHtml = '<div class="cat-bars">' + topVals.map(v => {
|
|
const pct = (v.count / maxCount) * 100;
|
|
const label = v.value.length > 15 ? v.value.substring(0, 15) + '...' : v.value;
|
|
const countStr = formatNumber(v.count);
|
|
const showInside = pct > 25;
|
|
return `<div class="cat-bar-row">
|
|
<span class="cat-bar-label" title="${v.value}">${label}</span>
|
|
<div class="cat-bar-track">
|
|
<div class="cat-bar-fill" style="width:${Math.max(pct, 3)}%">
|
|
${showInside ? `<span class="cat-bar-count">${countStr}</span>` : ''}
|
|
</div>
|
|
</div>
|
|
${!showInside ? `<span class="cat-bar-count outside">${countStr}</span>` : ''}
|
|
</div>`;
|
|
}).join('') + '</div>';
|
|
}
|
|
|
|
// Numeric stats with range bar visualization
|
|
if (col.numeric_stats) {
|
|
const ns = col.numeric_stats;
|
|
chartHtml = buildNumericViz(ns, idx);
|
|
}
|
|
|
|
// Date stats - labels beside chart for maximum chart size
|
|
if (col.date_stats) {
|
|
const ds = col.date_stats;
|
|
chartHtml = `<div style="display:flex;gap:16px;align-items:stretch;width:100%;padding:8px 0;">
|
|
<div style="font-size:12px;min-width:130px;flex-shrink:0;display:flex;flex-direction:column;justify-content:center;gap:6px;">
|
|
<div><span style="color:var(--text-secondary);display:block;font-size:10px;">Earliest</span><span style="font-weight:500;">${ds.earliest}</span></div>
|
|
<div><span style="color:var(--text-secondary);display:block;font-size:10px;">Latest</span><span style="font-weight:500;">${ds.latest}</span></div>
|
|
<div><span style="color:var(--text-secondary);display:block;font-size:10px;">Span</span><span style="font-weight:500;">${formatNumber(ds.span_days)} days</span></div>
|
|
</div>
|
|
<div style="flex:1;min-height:140px;"><canvas id="datehist_${idx}"></canvas></div>
|
|
</div>`;
|
|
}
|
|
|
|
// Boolean bar
|
|
if (col.boolean_stats) {
|
|
const bs = col.boolean_stats;
|
|
const truePct = bs.true_pct || 0;
|
|
chartHtml = `<div style="padding:8px 0;width:100%;">
|
|
<div class="bool-bar">
|
|
<div class="true-part" style="width:${truePct}%">${truePct > 15 ? `True ${truePct.toFixed(1)}%` : ''}</div>
|
|
<div class="false-part" style="width:${100 - truePct}%">${(100 - truePct) > 15 ? `False ${(100 - truePct).toFixed(1)}%` : ''}</div>
|
|
</div>
|
|
<div style="display:flex;justify-content:space-between;font-size:11px;color:var(--text-secondary);margin-top:4px;">
|
|
<span>True: ${formatNumber(bs.true_count)}</span>
|
|
<span>False: ${formatNumber(bs.false_count)}</span>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
// Fallback: sample values
|
|
if (!chartHtml && col.sample_values && col.sample_values.length > 0) {
|
|
chartHtml = `<div style="font-size:12px;padding:8px 0;color:var(--text-secondary);overflow:hidden;width:100%;">
|
|
<div style="font-weight:500;margin-bottom:4px;">Sample values</div>
|
|
${col.sample_values.slice(0, 5).map(v => `<div style="font-family:var(--font-mono);font-size:11px;padding:2px 0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${String(v).replace(/"/g, '"')}">${v}</div>`).join('')}
|
|
</div>`;
|
|
}
|
|
|
|
html += `
|
|
<div class="variable-card" id="var_${col.name}">
|
|
<div class="variable-header">
|
|
<span class="variable-name">${col.name}</span>
|
|
<span class="variable-type">${simplifiedType}</span>
|
|
${col.is_primary_key ? '<span class="alert-badge unique">PK</span>' : ''}
|
|
${alerts}
|
|
</div>
|
|
<div class="variable-body">
|
|
<div class="variable-stats">
|
|
<table>
|
|
<tr><td>Distinct</td><td${distinctClass}>${formatNumber(col.unique_count)}</td></tr>
|
|
<tr><td>Distinct (%)</td><td${distinctClass}>${distinctPct.toFixed(1)}%</td></tr>
|
|
<tr><td>Missing</td><td${missingClass}>${formatNumber(col.null_count || 0)}</td></tr>
|
|
<tr><td>Missing (%)</td><td${missingClass}>${missingPct.toFixed(1)}%</td></tr>
|
|
</table>
|
|
</div>
|
|
<div class="variable-chart">${chartHtml}</div>
|
|
</div>
|
|
</div>`;
|
|
});
|
|
|
|
document.getElementById('sectionVariables').innerHTML = html;
|
|
|
|
// Render Chart.js histograms for numeric columns
|
|
cols.forEach((col, idx) => {
|
|
if (col.numeric_stats && col.numeric_stats.histogram) {
|
|
const canvas = document.getElementById(`hist_${idx}`);
|
|
if (canvas) {
|
|
const h = col.numeric_stats.histogram;
|
|
const chart = new Chart(canvas, {
|
|
type: 'bar',
|
|
data: {
|
|
labels: h.bins || h.counts.map((_, i) => i),
|
|
datasets: [{
|
|
data: h.counts,
|
|
backgroundColor: 'rgba(0, 115, 209, 0.6)',
|
|
borderRadius: 2,
|
|
}]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
plugins: { legend: { display: false } },
|
|
scales: {
|
|
x: { display: false },
|
|
y: { display: false }
|
|
}
|
|
}
|
|
});
|
|
currentCharts.push(chart);
|
|
}
|
|
}
|
|
|
|
if (col.date_stats && col.date_stats.histogram) {
|
|
const canvas = document.getElementById(`datehist_${idx}`);
|
|
if (canvas) {
|
|
const h = col.date_stats.histogram;
|
|
const chart = new Chart(canvas, {
|
|
type: 'bar',
|
|
data: {
|
|
labels: h.bins || h.counts.map((_, i) => i),
|
|
datasets: [{
|
|
data: h.counts,
|
|
backgroundColor: 'rgba(0, 115, 209, 0.6)',
|
|
borderRadius: 2,
|
|
}]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
plugins: { legend: { display: false } },
|
|
scales: {
|
|
x: { ticks: { maxTicksLimit: 6, font: { size: 9 } } },
|
|
y: { display: false }
|
|
}
|
|
}
|
|
});
|
|
currentCharts.push(chart);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
function extractInsightDetail(alert) {
|
|
// Strip column name prefix from message to avoid redundancy
|
|
const msg = alert.message || '';
|
|
const col = alert.column || '';
|
|
const rest = msg.startsWith(col) ? msg.substring(col.length).trim() : msg;
|
|
// Clean up leading connectors like "is", "has"
|
|
return rest.replace(/^(is|has)\s+/, '');
|
|
}
|
|
|
|
function renderAlerts(data) {
|
|
const alerts = data.alerts || [];
|
|
if (alerts.length === 0) {
|
|
document.getElementById('sectionAlerts').innerHTML = '<div class="profiler-empty"><p>No data quality insights detected.</p></div>';
|
|
return;
|
|
}
|
|
|
|
// Group alerts by type for cleaner display
|
|
const byType = {};
|
|
alerts.forEach(a => {
|
|
if (!byType[a.type]) byType[a.type] = [];
|
|
byType[a.type].push(a);
|
|
});
|
|
|
|
let html = '';
|
|
Object.entries(byType).forEach(([type, items]) => {
|
|
const tip = INSIGHT_TOOLTIPS[type] || '';
|
|
html += `<div class="insight-group border-${type}" id="insight_${type}">
|
|
<div class="insight-group-header">
|
|
${renderAlertBadge(type)}
|
|
${tip ? `<span class="alert-explanation">${tip}</span>` : ''}
|
|
</div>
|
|
<div class="insight-items">`;
|
|
items.forEach(a => {
|
|
const detail = extractInsightDetail(a);
|
|
html += `<div class="insight-item">
|
|
<a class="insight-col" onclick="navigateToVariable('${a.column}')">${a.column}</a>
|
|
<span class="insight-detail">${detail}</span>
|
|
</div>`;
|
|
});
|
|
html += '</div></div>';
|
|
});
|
|
document.getElementById('sectionAlerts').innerHTML = html;
|
|
}
|
|
|
|
function renderMissing(data) {
|
|
const cols = (data.columns || []).filter(c => c.completeness_pct !== undefined);
|
|
if (cols.length === 0) return;
|
|
|
|
const canvas = document.getElementById('missingChart');
|
|
if (!canvas) return;
|
|
|
|
const sorted = [...cols].sort((a, b) => (a.completeness_pct || 0) - (b.completeness_pct || 0));
|
|
|
|
const chart = new Chart(canvas, {
|
|
type: 'bar',
|
|
data: {
|
|
labels: sorted.map(c => c.name),
|
|
datasets: [{
|
|
label: 'Completeness %',
|
|
data: sorted.map(c => c.completeness_pct || 0),
|
|
backgroundColor: sorted.map(c => {
|
|
const pct = c.completeness_pct || 0;
|
|
return pct >= 95 ? 'rgba(16, 183, 127, 0.6)' :
|
|
pct >= 80 ? 'rgba(245, 159, 10, 0.6)' :
|
|
'rgba(220, 38, 38, 0.6)';
|
|
}),
|
|
borderRadius: 2,
|
|
}]
|
|
},
|
|
options: {
|
|
indexAxis: 'y',
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
plugins: { legend: { display: false } },
|
|
scales: {
|
|
x: { min: 0, max: 100, ticks: { callback: v => v + '%' } },
|
|
y: { ticks: { font: { size: 10, family: "'Inter', sans-serif" } } }
|
|
}
|
|
}
|
|
});
|
|
currentCharts.push(chart);
|
|
|
|
// Adjust canvas container height based on column count
|
|
canvas.parentElement.style.height = Math.max(200, cols.length * 22 + 40) + 'px';
|
|
}
|
|
|
|
function sanitizeMermaidId(name) {
|
|
return 'n_' + name.replace(/[^a-zA-Z0-9_]/g, '_');
|
|
}
|
|
|
|
function truncateLabel(text, max) {
|
|
return text.length > max ? text.substring(0, max) + '...' : text;
|
|
}
|
|
|
|
function buildMermaidDiagram(tableName, relations, metrics) {
|
|
const MAX_NODES = 15;
|
|
const parents = relations.filter(r => r.direction === 'belongs_to');
|
|
const children = relations.filter(r => r.direction === 'has_many');
|
|
const metricsList = metrics || [];
|
|
|
|
const currentId = sanitizeMermaidId(tableName);
|
|
const nodeMap = {}; // sanitizedId -> {type, name, file?}
|
|
let lines = ['flowchart LR'];
|
|
|
|
// Current table node (bold box)
|
|
lines.push(` ${currentId}["<b>${tableName}</b>"]`);
|
|
nodeMap[currentId] = { type: 'current', name: tableName };
|
|
|
|
let nodeCount = 1;
|
|
let overflowParents = 0;
|
|
let overflowChildren = 0;
|
|
const renderedParentIds = [];
|
|
const renderedChildIds = [];
|
|
|
|
// Parent nodes (belongs_to) - current --> parent
|
|
parents.forEach(r => {
|
|
if (nodeCount >= MAX_NODES) { overflowParents++; return; }
|
|
const pid = sanitizeMermaidId(r.table);
|
|
const label = truncateLabel(r.join_column, 20);
|
|
lines.push(` ${currentId} -->|"${label}"| ${pid}["${r.table}"]`);
|
|
renderedParentIds.push(pid);
|
|
nodeMap[pid] = { type: 'table', name: r.table };
|
|
nodeCount++;
|
|
});
|
|
|
|
// Child nodes (has_many) - child --> current
|
|
children.forEach(r => {
|
|
if (nodeCount >= MAX_NODES) { overflowChildren++; return; }
|
|
const cid = sanitizeMermaidId(r.table);
|
|
const label = truncateLabel(r.foreign_column || r.join_column, 20);
|
|
lines.push(` ${cid}["${r.table}"] -->|"${label}"| ${currentId}`);
|
|
renderedChildIds.push(cid);
|
|
nodeMap[cid] = { type: 'table', name: r.table };
|
|
nodeCount++;
|
|
});
|
|
|
|
// Metric nodes - current table -.-> metric (dashed)
|
|
const renderedMetricIds = [];
|
|
let overflowMetrics = 0;
|
|
metricsList.forEach(m => {
|
|
if (nodeCount >= MAX_NODES) { overflowMetrics++; return; }
|
|
const mid = sanitizeMermaidId('metric_' + m.name);
|
|
lines.push(` ${currentId} -.->|"metric"| ${mid}("${truncateLabel(m.name, 20)}")`);
|
|
renderedMetricIds.push(mid);
|
|
nodeMap[mid] = { type: 'metric', name: m.name, file: m.file };
|
|
nodeCount++;
|
|
});
|
|
|
|
// Overflow nodes
|
|
if (overflowParents > 0) {
|
|
lines.push(` ${currentId} -.->|" "| n_more_parents("+${overflowParents} more")`);
|
|
lines.push(` style n_more_parents stroke-dasharray: 5 5,fill:#f8fafc,color:#94a3b8`);
|
|
}
|
|
if (overflowChildren > 0) {
|
|
lines.push(` n_more_children("+${overflowChildren} more") -.->|" "| ${currentId}`);
|
|
lines.push(` style n_more_children stroke-dasharray: 5 5,fill:#f8fafc,color:#94a3b8`);
|
|
}
|
|
if (overflowMetrics > 0) {
|
|
lines.push(` ${currentId} -.->|" "| n_more_metrics("+${overflowMetrics} more")`);
|
|
lines.push(` style n_more_metrics stroke-dasharray: 5 5,fill:#f8fafc,color:#94a3b8`);
|
|
}
|
|
|
|
// Styling - only for actually rendered nodes
|
|
lines.push(` style ${currentId} fill:#2563eb,stroke:#1d4ed8,color:#fff,font-weight:bold`);
|
|
renderedParentIds.forEach(pid => {
|
|
lines.push(` style ${pid} fill:#dbeafe,stroke:#93c5fd,color:#1e40af`);
|
|
});
|
|
renderedChildIds.forEach(cid => {
|
|
lines.push(` style ${cid} fill:#d1fae5,stroke:#6ee7b7,color:#065f46`);
|
|
});
|
|
renderedMetricIds.forEach(mid => {
|
|
lines.push(` style ${mid} fill:#fef3c7,stroke:#fcd34d,color:#92400e`);
|
|
});
|
|
|
|
return { diagram: lines.join('\n'), nodeMap };
|
|
}
|
|
|
|
function attachDiagramClickHandlers() {
|
|
const wrap = document.querySelector('.relationship-diagram-wrap');
|
|
if (!wrap) return;
|
|
|
|
const svg = wrap.querySelector('svg');
|
|
if (!svg) return;
|
|
|
|
svg.querySelectorAll('.node').forEach(node => {
|
|
// Mermaid node IDs: "flowchart-n_contract_line-0"
|
|
const match = node.id.match(/^flowchart-(.+?)-\d+$/);
|
|
if (!match) return;
|
|
|
|
const sanitizedId = match[1];
|
|
const entry = currentDiagramNodeMap[sanitizedId];
|
|
if (!entry || entry.type === 'current') return;
|
|
|
|
node.classList.add('clickable-node');
|
|
if (entry.type === 'metric' && entry.file) {
|
|
node.addEventListener('click', () => {
|
|
openMetricModal(entry.file);
|
|
});
|
|
} else if (entry.type === 'table') {
|
|
node.addEventListener('click', () => {
|
|
closeProfiler();
|
|
setTimeout(() => openProfiler(entry.name, 'relationships'), 300);
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
function renderRelationships(data) {
|
|
const rels = data.related_tables || [];
|
|
const metrics = data.used_by_metrics || [];
|
|
|
|
if (rels.length === 0 && metrics.length === 0) {
|
|
document.getElementById('sectionRelationships').innerHTML = '<div class="profiler-empty"><p>No relationships or metric references found.</p></div>';
|
|
return;
|
|
}
|
|
|
|
let html = '';
|
|
|
|
// Mermaid diagram (relationships + metrics)
|
|
if (rels.length > 0 || metrics.length > 0) {
|
|
const tableName = document.getElementById('profilerTitle').textContent;
|
|
const result = buildMermaidDiagram(tableName, rels, metrics);
|
|
currentDiagramNodeMap = result.nodeMap;
|
|
|
|
let legendHtml = '<span class="relationship-legend-item"><span class="relationship-legend-dot current"></span> Current table</span>';
|
|
if (rels.some(r => r.direction === 'belongs_to'))
|
|
legendHtml += '<span class="relationship-legend-item"><span class="relationship-legend-dot parent"></span> References (belongs_to)</span>';
|
|
if (rels.some(r => r.direction === 'has_many'))
|
|
legendHtml += '<span class="relationship-legend-item"><span class="relationship-legend-dot child"></span> Referenced by (has_many)</span>';
|
|
if (metrics.length > 0)
|
|
legendHtml += '<span class="relationship-legend-item"><span class="relationship-legend-dot metric"></span> Business Metric</span>';
|
|
|
|
html += `<div class="relationship-diagram-wrap">
|
|
<pre class="mermaid">${result.diagram}</pre>
|
|
<div class="relationship-legend">${legendHtml}</div>
|
|
</div>`;
|
|
|
|
const outgoing = rels.filter(r => r.direction === 'belongs_to');
|
|
const incoming = rels.filter(r => r.direction === 'has_many');
|
|
|
|
if (outgoing.length > 0) {
|
|
html += '<div class="relationship-card"><div class="relationship-header">References (belongs to)</div>';
|
|
outgoing.forEach(r => {
|
|
html += `<div class="relationship-item">
|
|
<span class="relationship-direction belongs_to">belongs_to</span>
|
|
<a class="relationship-table-link" onclick="closeProfiler();setTimeout(()=>openProfiler('${r.table}'),300)">${r.table}</a>
|
|
<span>via ${r.join_column} → ${r.foreign_column || r.join_column}</span>
|
|
</div>`;
|
|
});
|
|
html += '</div>';
|
|
}
|
|
|
|
if (incoming.length > 0) {
|
|
html += '<div class="relationship-card"><div class="relationship-header">Referenced by (has many)</div>';
|
|
incoming.forEach(r => {
|
|
html += `<div class="relationship-item">
|
|
<span class="relationship-direction has_many">has_many</span>
|
|
<a class="relationship-table-link" onclick="closeProfiler();setTimeout(()=>openProfiler('${r.table}'),300)">${r.table}</a>
|
|
<span>via ${r.foreign_column || r.join_column}</span>
|
|
</div>`;
|
|
});
|
|
html += '</div>';
|
|
}
|
|
}
|
|
|
|
if (metrics.length > 0) {
|
|
html += '<div class="relationship-card"><div class="relationship-header">Used by Business Metrics</div>';
|
|
html += '<div>' + metrics.map(m => {
|
|
if (m.file) {
|
|
return `<a class="metric-badge" style="cursor:pointer;text-decoration:none;" onclick="openMetricModal('${m.file}')">${m.name}</a>`;
|
|
}
|
|
return `<span class="metric-badge">${m.name}</span>`;
|
|
}).join('') + '</div>';
|
|
html += '</div>';
|
|
}
|
|
|
|
document.getElementById('sectionRelationships').innerHTML = html;
|
|
mermaidDiagramRendered = false;
|
|
}
|
|
|
|
function renderSample(data) {
|
|
const rows = data.sample_rows || [];
|
|
if (rows.length === 0) {
|
|
document.getElementById('sectionSample').innerHTML = '<div class="profiler-empty"><p>No sample data available.</p></div>';
|
|
return;
|
|
}
|
|
|
|
const cols = Object.keys(rows[0]);
|
|
let html = '<div style="overflow-x:auto;"><table class="sample-table"><thead><tr>';
|
|
html += cols.map(c => `<th>${c}</th>`).join('');
|
|
html += '</tr></thead><tbody>';
|
|
rows.forEach(row => {
|
|
html += '<tr>' + cols.map(c => {
|
|
const v = row[c];
|
|
const display = v === null ? '<span style="color:#D1D5DB;">null</span>' :
|
|
String(v).length > 50 ? String(v).substring(0, 50) + '...' : String(v);
|
|
return `<td title="${String(v || '')}">${display}</td>`;
|
|
}).join('') + '</tr>';
|
|
});
|
|
html += '</tbody></table></div>';
|
|
document.getElementById('sectionSample').innerHTML = html;
|
|
}
|
|
|
|
// Close on Escape
|
|
document.addEventListener('keydown', e => {
|
|
if (e.key === 'Escape') closeProfiler();
|
|
});
|
|
|
|
// Floating tooltip for [data-tip] elements
|
|
(function() {
|
|
const box = document.createElement('div');
|
|
box.className = 'tip-box';
|
|
document.body.appendChild(box);
|
|
let timer = null;
|
|
|
|
document.addEventListener('mouseover', e => {
|
|
const el = e.target.closest('[data-tip]');
|
|
if (!el) return;
|
|
clearTimeout(timer);
|
|
timer = setTimeout(() => {
|
|
box.textContent = el.getAttribute('data-tip');
|
|
box.classList.add('visible');
|
|
const r = el.getBoundingClientRect();
|
|
let top = r.bottom + 6;
|
|
let left = r.left + r.width / 2;
|
|
// Measure box to avoid right edge overflow
|
|
box.style.left = '0px';
|
|
box.style.top = '0px';
|
|
const bw = box.offsetWidth;
|
|
left = Math.max(8, Math.min(left - bw / 2, window.innerWidth - bw - 8));
|
|
// If below viewport, flip above
|
|
if (top + box.offsetHeight > window.innerHeight - 8) {
|
|
top = r.top - box.offsetHeight - 6;
|
|
}
|
|
box.style.left = left + 'px';
|
|
box.style.top = top + 'px';
|
|
}, 400);
|
|
});
|
|
|
|
document.addEventListener('mouseout', e => {
|
|
const el = e.target.closest('[data-tip]');
|
|
if (!el) return;
|
|
clearTimeout(timer);
|
|
box.classList.remove('visible');
|
|
});
|
|
})();
|
|
</script>
|
|
|
|
<!-- ═══════════════ METRIC MODAL ═══════════════ -->
|
|
<div id="metricModalOverlay" class="metric-modal-overlay">
|
|
<div id="metricModal" class="metric-modal" onclick="event.stopPropagation()">
|
|
<!-- Header -->
|
|
<div class="metric-modal-header">
|
|
<div class="metric-modal-title-section">
|
|
<h2 id="metricModalTitle" class="metric-modal-title"></h2>
|
|
<div id="metricModalMetadata" class="metric-metadata-chips"></div>
|
|
</div>
|
|
<button class="metric-modal-close" onclick="closeMetricModal()" aria-label="Close modal">
|
|
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Tabs -->
|
|
<nav class="metric-tabs">
|
|
<button class="metric-tab active" data-tab="tabOverview" onclick="switchMetricTab('tabOverview')">
|
|
Overview
|
|
</button>
|
|
<button class="metric-tab" data-tab="tabHowToUse" onclick="switchMetricTab('tabHowToUse')">
|
|
How to Use
|
|
</button>
|
|
<button class="metric-tab" data-tab="tabSQLExamples" onclick="switchMetricTab('tabSQLExamples')">
|
|
SQL Examples
|
|
</button>
|
|
<button class="metric-tab" data-tab="tabTechnical" onclick="switchMetricTab('tabTechnical')">
|
|
Metric Details
|
|
</button>
|
|
</nav>
|
|
|
|
<!-- Body -->
|
|
<div id="metricModalBody" class="metric-modal-body">
|
|
<!-- Content loaded dynamically -->
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Prism.js for SQL syntax highlighting -->
|
|
<script src="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/prism.min.js"></script>
|
|
<script src="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/components/prism-sql.min.js"></script>
|
|
<script src="{{ url_for('static', filename='js/metric_modal.js', v=git_version) }}"></script>
|
|
|
|
<!-- Mermaid.js for relationship diagrams -->
|
|
<script type="module">
|
|
import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs';
|
|
mermaid.initialize({
|
|
startOnLoad: false,
|
|
theme: 'base',
|
|
themeVariables: {
|
|
fontFamily: 'Inter, system-ui, sans-serif',
|
|
fontSize: '13px',
|
|
primaryColor: '#dbeafe',
|
|
primaryBorderColor: '#2563eb',
|
|
primaryTextColor: '#1e40af',
|
|
lineColor: '#94a3b8',
|
|
secondaryColor: '#f1f5f9',
|
|
tertiaryColor: '#f5f7fa',
|
|
edgeLabelBackground: '#f1f5f9',
|
|
},
|
|
flowchart: {
|
|
curve: 'basis',
|
|
padding: 12,
|
|
htmlLabels: true,
|
|
useMaxWidth: true,
|
|
},
|
|
});
|
|
// Expose mermaid globally for lazy rendering
|
|
window.mermaid = mermaid;
|
|
</script>
|
|
</body>
|
|
</html>
|