agnes-the-ai-analyst/webapp/templates/catalog.html
Petr 5fc9526627 Phase 2: Replace demo YAML metrics with OpenMetadata catalog data
- Add get_metric_by_fqn() to OpenMetadataClient
- Add get_metrics() to CatalogEnricher with TTL caching
- Implement _parse_om_metric() to extract category/grain from OpenMetadata tags
- Implement _load_metrics_from_catalog() to fetch and categorize metrics
- Implement _build_om_metric_detail() to convert OpenMetadata format to MetricParser JSON
- Add /api/catalog/metrics/<fqn> endpoint for metric detail modal
- Update _load_metrics_data() to prefer catalog over YAML fallback
- Update metric_modal.js to route catalog:{fqn} to catalog API endpoint
- Delete 10 demo YAML files from docs/metrics/
- Replace metric tests with new unit tests for catalog parsing functions (19 tests)

Catalog metrics provide single source of truth vs maintaining demo YAML files.
UI remains unchanged - only data source changes from YAML to OpenMetadata catalog.
2026-03-12 15:10:42 +01:00

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 &middot; ~{{ 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 &middot; {{ 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>&copy; {{ config.INSTANCE_COPYRIGHT or 'AI Data Analyst' }} &middot; 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, '&quot;')}">${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') }}"></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>