Each catalog_data bucket renders as its own top-level Data Package card instead of nested accordions under a single Core Business Data wrapper. Tables flat-listed per package, mirroring the Agnes Internal card. Pluralization fix (1 table / N tables). Includes the 0.54.11 release-cut. Co-authored-by: pcernik-grpn <pcernik-grpn@users.noreply.github.com>
2510 lines
90 KiB
HTML
2510 lines
90 KiB
HTML
{% extends "base.html" %}
|
|
|
|
{% block title %}Data Packages - Data Analyst Portal{% endblock %}
|
|
|
|
{% block head_extra %}
|
|
<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">
|
|
<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 ── */
|
|
|
|
|
|
|
|
/* ── Source Cards ── */
|
|
.source-cards {
|
|
max-width: var(--width-app);
|
|
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);
|
|
}
|
|
|
|
/* Request-access modal CSS dropped in v0.25 — feature removed. */
|
|
|
|
.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;
|
|
}
|
|
|
|
/* ── Query Mode Badges ── */
|
|
.query-mode-badge {
|
|
font-size: 10px;
|
|
font-weight: 600;
|
|
padding: 2px 7px;
|
|
border-radius: 4px;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.3px;
|
|
white-space: nowrap;
|
|
}
|
|
.query-mode-badge.local {
|
|
background: var(--primary-light);
|
|
color: var(--primary);
|
|
}
|
|
.query-mode-badge.live {
|
|
background: rgba(16, 183, 127, 0.1);
|
|
color: #047857;
|
|
}
|
|
|
|
.table-sync-info {
|
|
font-size: 11px;
|
|
color: var(--text-secondary);
|
|
margin-top: 2px;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 5px;
|
|
}
|
|
.table-sync-info .live-dot {
|
|
width: 6px;
|
|
height: 6px;
|
|
border-radius: 50%;
|
|
background: #10B77F;
|
|
animation: pulse-live 2s ease-in-out infinite;
|
|
flex-shrink: 0;
|
|
}
|
|
@keyframes pulse-live {
|
|
0%, 100% { opacity: 1; box-shadow: 0 0 0 0 rgba(16, 183, 127, 0.4); }
|
|
50% { opacity: 0.7; box-shadow: 0 0 0 3px rgba(16, 183, 127, 0); }
|
|
}
|
|
|
|
.data-freshness-note {
|
|
padding: 8px 24px;
|
|
font-size: 12px;
|
|
color: var(--text-secondary);
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
border-top: 1px solid var(--border-light);
|
|
}
|
|
.data-freshness-note svg {
|
|
flex-shrink: 0;
|
|
opacity: 0.5;
|
|
}
|
|
|
|
.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', v=git_version) }}">
|
|
<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>
|
|
{% endblock %}
|
|
|
|
{% block layout %}
|
|
<!-- ═══════════════ PAGE TITLE ═══════════════ -->
|
|
{% set page_hero_eyebrow = "Data" %}
|
|
{% set page_hero_title = "Data Packages" %}
|
|
{% set page_hero_subtitle = "Browse the data sources available to your AI agents." %}
|
|
{% include "_page_hero.html" %}
|
|
|
|
<!-- ═══════════════ SOURCE CARDS ═══════════════ -->
|
|
<div class="source-cards">
|
|
|
|
{# ── Data Packages: one source-card per bucket ─────────────────────
|
|
Previously all buckets were nested as accordions under a single
|
|
"Core Business Data" wrapper card. Each bucket is now its own
|
|
top-level Data Package card, matching the page hero title and
|
|
the bucket grain of the table_registry. The Internal and
|
|
Business Metrics cards below follow the same flat-list pattern. #}
|
|
{% for category in catalog_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">{{ category.name }}</div>
|
|
<div class="source-card-meta">{{ category.count }} table{{ 's' if category.count != 1 else '' }}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{# Plain list — tables shown directly; no accordion since the card IS the package. #}
|
|
<div class="accordion-content expanded" style="display: block;">
|
|
{% for table in category.tables %}
|
|
<div class="table-row" {% if table.query_mode != 'remote' %}onclick="openProfiler('{{ table.name }}')"{% endif %}>
|
|
<div class="table-row-left">
|
|
<div class="table-row-name">
|
|
{{ table.name }}
|
|
{% if table.query_mode == 'remote' %}
|
|
<span class="query-mode-badge live">Live</span>
|
|
{% else %}
|
|
<span class="query-mode-badge local">Local</span>
|
|
{% endif %}
|
|
</div>
|
|
<div class="table-row-desc">{{ table.description }}</div>
|
|
<div class="table-sync-info">
|
|
{% if table.query_mode == 'remote' %}
|
|
<span class="live-dot"></span> Queried directly from BigQuery
|
|
{% elif table.last_sync %}
|
|
Synced {{ table.last_sync }}
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
<div class="table-row-right">
|
|
<span class="rows-badge{{ ' large' if table.rows_large }}">{{ table.rows_display }}</span>
|
|
{% if table.query_mode != 'remote' %}
|
|
<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>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
</div>
|
|
{% endfor %}
|
|
|
|
{# ── Card: Agnes Internal ─────────────────────────────────────────
|
|
Renders the three system tables (agnes_sessions / agnes_telemetry /
|
|
agnes_audit) in their own card so they don't pollute the
|
|
Core Business Data header counters. Read-only — no profiler
|
|
click, no `In stack` toggle; analysts query them via
|
|
`agnes query`. #}
|
|
{% if internal_card %}
|
|
<div class="source-card">
|
|
<div class="source-card-header">
|
|
<div class="source-card-left">
|
|
<div class="source-card-icon" style="background: rgba(15, 118, 110, 0.1);">
|
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#0F766E" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
|
<ellipse cx="12" cy="5" rx="9" ry="3"/>
|
|
<path d="M3 5v14a9 3 0 0 0 18 0V5"/>
|
|
<path d="M3 12a9 3 0 0 0 18 0"/>
|
|
</svg>
|
|
</div>
|
|
<div class="source-card-info">
|
|
<div class="source-card-name">Agnes Internal</div>
|
|
<div class="source-card-desc">System data — sessions, telemetry, audit. Also available locally for analysis.</div>
|
|
<div class="source-card-meta">{{ internal_card.count }} table{{ 's' if internal_card.count != 1 else '' }}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{# Plain list — no accordion, no profile, no In stack toggle. #}
|
|
<div class="accordion-content expanded" style="display: block;">
|
|
{% for table in internal_card.tables %}
|
|
<div class="table-row" style="cursor: default;">
|
|
<div class="table-row-left">
|
|
<div class="table-row-name">
|
|
<code>{{ table.id }}</code>
|
|
</div>
|
|
<div class="table-row-desc">{{ table.description }}</div>
|
|
</div>
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
|
|
<!-- ── 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>
|
|
|
|
{% if data_stats and data_stats.last_updated %}
|
|
<div class="data-freshness-note">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
|
Calculated from data synced {{ data_stats.last_updated }}
|
|
</div>
|
|
{% endif %}
|
|
|
|
{% 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.display_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>
|
|
|
|
|
|
<!-- ═══════════════ 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 -->
|
|
|
|
|
|
|
|
|
|
<!-- Mermaid.js for relationship diagrams -->
|
|
{% endblock %}
|
|
|
|
{% block scripts %}
|
|
<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>
|
|
<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>
|
|
<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>
|
|
{% endblock %}
|