Files
routesapi/app/templates/ml_dashboard.html

1522 lines
57 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MILETRUTH &mdash; Logistics Intelligence</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link
href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=Syne:wght@400;600;700;800&display=swap"
rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<style>
:root {
--bg: #080b10;
--surface: #0f1419;
--surface2: #161c25;
--border: #1e2733;
--border2: #2a3545;
--accent: #00d4ff;
--accent2: #7c3aed;
--green: #00e57a;
--amber: #ffaa00;
--red: #ff4466;
--text: #e2e8f0;
--muted: #64748b;
--mono: 'DM Mono', monospace;
--display: 'Syne', sans-serif;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box
}
body {
font-family: var(--display);
background: var(--bg);
color: var(--text);
min-height: 100vh;
overflow-x: hidden;
}
/* ── SCANLINE OVERLAY ── */
body::before {
content: '';
position: fixed;
inset: 0;
pointer-events: none;
z-index: 0;
background: repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(0, 212, 255, .012) 2px, rgba(0, 212, 255, .012) 4px);
}
/* ── HEADER ── */
header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 2rem;
border-bottom: 1px solid var(--border);
background: rgba(8, 11, 16, .9);
backdrop-filter: blur(8px);
position: sticky;
top: 0;
z-index: 100;
}
.logo {
display: flex;
align-items: center;
gap: .75rem;
font-size: 1.1rem;
font-weight: 800;
letter-spacing: .15em;
color: var(--accent);
text-transform: uppercase;
}
.logo-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--green);
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
box-shadow: 0 0 0 0 rgba(0, 229, 122, .4)
}
50% {
opacity: .7;
box-shadow: 0 0 0 6px rgba(0, 229, 122, 0)
}
}
.header-right {
display: flex;
align-items: center;
gap: .75rem
}
.status-chip {
display: flex;
align-items: center;
gap: .4rem;
font-family: var(--mono);
font-size: .7rem;
padding: .3rem .75rem;
border-radius: 2px;
background: rgba(0, 229, 122, .1);
border: 1px solid rgba(0, 229, 122, .25);
color: var(--green);
}
.status-chip.warn {
background: rgba(255, 170, 0, .1);
border-color: rgba(255, 170, 0, .25);
color: var(--amber)
}
.status-chip.err {
background: rgba(255, 68, 102, .1);
border-color: rgba(255, 68, 102, .25);
color: var(--red)
}
/* ── BUTTONS ── */
.btn {
font-family: var(--display);
font-size: .75rem;
font-weight: 700;
letter-spacing: .1em;
text-transform: uppercase;
padding: .5rem 1.25rem;
border-radius: 2px;
cursor: pointer;
border: 1px solid;
transition: all .15s;
display: inline-flex;
align-items: center;
gap: .5rem;
}
.btn-primary {
background: var(--accent);
border-color: var(--accent);
color: #000
}
.btn-primary:hover {
background: #33dcff;
border-color: #33dcff
}
.btn-primary:disabled {
background: var(--muted);
border-color: var(--muted);
cursor: not-allowed
}
.btn-ghost {
background: transparent;
border-color: var(--border2);
color: var(--muted)
}
.btn-ghost:hover {
border-color: var(--accent);
color: var(--accent)
}
.btn-danger {
background: transparent;
border-color: rgba(255, 68, 102, .4);
color: var(--red)
}
.btn-danger:hover {
background: rgba(255, 68, 102, .1)
}
/* ── LAYOUT ── */
main {
padding: 1.5rem 2rem;
max-width: 1600px;
margin: 0 auto;
position: relative;
z-index: 1
}
.section-label {
font-family: var(--mono);
font-size: .65rem;
font-weight: 500;
color: var(--muted);
letter-spacing: .15em;
text-transform: uppercase;
margin-bottom: .75rem;
padding-left: .25rem;
border-left: 2px solid var(--accent);
padding-left: .5rem;
}
/* ── STAT CARDS ── */
.stat-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 1px;
background: var(--border);
border: 1px solid var(--border);
margin-bottom: 1.5rem;
}
.stat-card {
background: var(--surface);
padding: 1.25rem 1.5rem;
}
.stat-label {
font-family: var(--mono);
font-size: .65rem;
color: var(--muted);
text-transform: uppercase;
letter-spacing: .1em;
margin-bottom: .5rem;
}
.stat-value {
font-family: var(--mono);
font-size: 1.75rem;
font-weight: 500;
line-height: 1;
margin-bottom: .4rem;
}
.stat-sub {
font-size: .7rem;
color: var(--muted);
font-family: var(--mono)
}
.v-green {
color: var(--green)
}
.v-amber {
color: var(--amber)
}
.v-red {
color: var(--red)
}
.v-accent {
color: var(--accent)
}
/* ── DASHBOARD GRID ── */
.dash-grid {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 1px;
background: var(--border);
border: 1px solid var(--border);
margin-bottom: 1.5rem;
}
.dash-grid.wide {
grid-template-columns: 2fr 1fr
}
.dash-grid.tri {
grid-template-columns: 1fr 1fr 1fr
}
.dash-grid.quad {
grid-template-columns: 1fr 1fr 1fr 1fr
}
.dash-grid.full {
grid-template-columns: 1fr
}
.panel {
background: var(--surface);
padding: 1.25rem;
min-height: 200px;
}
.panel-title {
font-family: var(--mono);
font-size: .65rem;
color: var(--muted);
text-transform: uppercase;
letter-spacing: .12em;
margin-bottom: 1rem;
display: flex;
align-items: center;
justify-content: space-between;
}
/* ── CHARTS ── */
.chart-wrap {
position: relative;
width: 100%;
height: 220px
}
.chart-wrap.tall {
height: 300px
}
/* ── TABLE ── */
.ml-table {
width: 100%;
border-collapse: collapse
}
.ml-table th {
font-family: var(--mono);
font-size: .6rem;
color: var(--muted);
text-transform: uppercase;
letter-spacing: .1em;
padding: .5rem .75rem;
border-bottom: 1px solid var(--border);
text-align: left;
background: var(--surface2);
}
.ml-table td {
font-family: var(--mono);
font-size: .75rem;
padding: .6rem .75rem;
border-bottom: 1px solid var(--border);
}
.ml-table tr:hover td {
background: rgba(0, 212, 255, .03)
}
.tbl-scroll {
max-height: 280px;
overflow-y: auto
}
/* ── PARAM ROW ── */
.param-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: .5rem 0;
border-bottom: 1px solid var(--border);
gap: 1rem;
}
.param-row:last-child {
border: none
}
.param-name {
font-family: var(--mono);
font-size: .7rem;
color: var(--text)
}
.param-val {
font-family: var(--mono);
font-size: .8rem;
color: var(--accent);
font-weight: 500
}
.param-src {
font-size: .6rem;
font-family: var(--mono);
padding: .15rem .4rem;
border-radius: 2px;
background: rgba(124, 58, 237, .2);
color: #a78bfa;
}
.param-src.default {
background: rgba(100, 116, 139, .15);
color: var(--muted)
}
/* ── RULES LIST ── */
.rule-item {
font-family: var(--mono);
font-size: .68rem;
color: var(--muted);
padding: .4rem .5rem;
border-left: 2px solid var(--border2);
margin-bottom: .25rem;
line-height: 1.5;
}
.rule-item.success {
border-color: var(--green);
color: rgba(0, 229, 122, .8)
}
.rule-item.risk {
border-color: var(--red);
color: rgba(255, 68, 102, .8)
}
.rules-scroll {
max-height: 260px;
overflow-y: auto
}
/* ── HEATMAP ── */
.heatmap-row {
display: flex;
gap: 2px;
align-items: center;
margin-bottom: 2px
}
.heatmap-label {
font-family: var(--mono);
font-size: .6rem;
color: var(--muted);
width: 28px;
text-align: right;
margin-right: 4px
}
.heatmap-cell {
flex: 1;
height: 22px;
border-radius: 1px;
display: flex;
align-items: center;
justify-content: center;
font-family: var(--mono);
font-size: .55rem;
cursor: pointer;
transition: .1s;
}
.heatmap-cell:hover {
opacity: .8;
transform: scaleY(1.1)
}
/* ── STRATEGY SWITCHER ── */
.strategy-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: .5rem
}
.strategy-card {
padding: .75rem;
border: 1px solid var(--border);
border-radius: 2px;
cursor: pointer;
transition: all .15s;
}
.strategy-card:hover {
border-color: var(--border2)
}
.strategy-card.active {
border-color: var(--accent);
background: rgba(0, 212, 255, .06)
}
.strategy-name {
font-size: .75rem;
font-weight: 700;
letter-spacing: .05em;
margin-bottom: .2rem
}
.strategy-desc {
font-family: var(--mono);
font-size: .62rem;
color: var(--muted)
}
/* ── BADGE ── */
.badge {
display: inline-block;
padding: .15rem .5rem;
border-radius: 2px;
font-family: var(--mono);
font-size: .65rem;
font-weight: 500;
}
.badge-green {
background: rgba(0, 229, 122, .15);
color: var(--green)
}
.badge-amber {
background: rgba(255, 170, 0, .15);
color: var(--amber)
}
.badge-red {
background: rgba(255, 68, 102, .15);
color: var(--red)
}
.badge-blue {
background: rgba(0, 212, 255, .12);
color: var(--accent)
}
.badge-purple {
background: rgba(124, 58, 237, .15);
color: #a78bfa
}
/* ── PROGRESS BAR ── */
.progress-wrap {
display: flex;
align-items: center;
gap: .75rem
}
.progress-bar {
flex: 1;
height: 4px;
background: var(--border);
border-radius: 2px;
overflow: hidden;
}
.progress-fill {
height: 100%;
border-radius: 2px;
transition: width .6s
}
/* ── TRUST METER ── */
.trust-meter {
display: flex;
gap: 3px;
align-items: center
}
.trust-pip {
width: 16px;
height: 16px;
border-radius: 2px;
background: var(--border)
}
.trust-pip.on {
background: var(--green)
}
.trust-pip.on.warn {
background: var(--amber)
}
.trust-pip.on.bad {
background: var(--red)
}
/* ── TOAST ── */
#toast {
position: fixed;
bottom: 1.5rem;
right: 1.5rem;
padding: .75rem 1.5rem;
border-radius: 2px;
font-family: var(--mono);
font-size: .75rem;
font-weight: 500;
background: var(--green);
color: #000;
transform: translateY(200%);
transition: transform .3s;
z-index: 999;
}
#toast.show {
transform: translateY(0)
}
#toast.err {
background: var(--red);
color: #fff
}
/* ── SPINNER ── */
.spinner {
width: 14px;
height: 14px;
border: 2px solid rgba(0, 0, 0, .3);
border-radius: 50%;
border-top-color: #000;
animation: spin .8s linear infinite;
display: none;
}
@keyframes spin {
to {
transform: rotate(360deg)
}
}
/* ── DIVIDER ── */
.divider {
border: none;
border-top: 1px solid var(--border);
margin: 1.5rem 0
}
/* ── SCROLLBAR ── */
::-webkit-scrollbar {
width: 4px;
height: 4px
}
::-webkit-scrollbar-track {
background: transparent
}
::-webkit-scrollbar-thumb {
background: var(--border2);
border-radius: 2px
}
/* ── RESPONSIVE ── */
@media(max-width:1100px) {
.dash-grid {
grid-template-columns: 1fr 1fr !important
}
main {
padding: 1rem
}
}
@media(max-width:720px) {
.dash-grid {
grid-template-columns: 1fr !important
}
.stat-grid {
grid-template-columns: 1fr 1fr
}
header {
padding: .75rem 1rem
}
}
</style>
</head>
<body>
<header>
<div class="logo">
<div class="logo-dot"></div>
MILETRUTH DASHBOARD|
</div>
<div class="header-right">
<div id="sysStatus" class="status-chip">
<span>&bull;</span> CONNECTING
</div>
<span id="lastRefresh" style="font-family:var(--mono);font-size:.65rem;color:var(--muted)"></span>
<button class="btn btn-ghost" onclick="fullRefresh()" title="Refresh">&#8634; REFRESH</button>
<button class="btn btn-danger" onclick="confirmReset()">RESET DEFAULTS</button>
<button class="btn btn-primary" id="btnTrain" onclick="triggerTrain()">
<span class="spinner" id="trainSpinner"></span>
<span id="trainLabel">TRIGGER RETRAIN</span>
</button>
</div>
</header>
<main>
<!-- TOP STAT STRIP -->
<div class="stat-grid" id="statStrip">
<div class="stat-card">
<div class="stat-label">Training Records</div>
<div class="stat-value v-accent" id="sRecords">&mdash;</div>
<div class="stat-sub" id="sReady">&mdash;</div>
</div>
<div class="stat-card">
<div class="stat-label">Avg Quality (last 50)</div>
<div class="stat-value" id="sQuality">&mdash;</div>
<div class="stat-sub" id="sQualSub">&mdash;</div>
</div>
<div class="stat-card">
<div class="stat-label">Model Trust</div>
<div class="stat-value" id="sTrust">&mdash;</div>
<div class="stat-sub" id="sTrustSub">&mdash;</div>
</div>
<div class="stat-card">
<div class="stat-label">R² Score</div>
<div class="stat-value" id="sR2">&mdash;</div>
<div class="stat-sub">Prediction accuracy</div>
</div>
<div class="stat-card">
<div class="stat-label">MAE</div>
<div class="stat-value" id="sMae">&mdash;</div>
<div class="stat-sub">Avg prediction error (pts)</div>
</div>
<div class="stat-card">
<div class="stat-label">ML-Tuned Params</div>
<div class="stat-value v-accent" id="sMLParams">&mdash;</div>
<div class="stat-sub" id="sParamsSub">&mdash;</div>
</div>
<div class="stat-card">
<div class="stat-label">SLA Breaches</div>
<div class="stat-value" id="sSLA">&mdash;</div>
<div class="stat-sub">Recent window</div>
</div>
<div class="stat-card">
<div class="stat-label">Avg Latency</div>
<div class="stat-value" id="sLatency">&mdash;</div>
<div class="stat-sub">Assignment call (ms)</div>
</div>
</div>
<!-- ROW 1: Quality trend + Param table -->
<div class="section-label">Performance & Configuration</div>
<div class="dash-grid wide" style="margin-bottom:1.5rem">
<div class="panel">
<div class="panel-title">
Quality Trend &mdash; last 50 calls
<span class="badge badge-blue" id="trendBadge">LIVE</span>
</div>
<div class="chart-wrap tall"><canvas id="qualityChart"></canvas></div>
</div>
<div class="panel">
<div class="panel-title">Active Hyperparameters</div>
<div class="tbl-scroll" id="paramsContainer" style="max-height:310px">
<div
style="color:var(--muted);font-family:var(--mono);font-size:.7rem;text-align:center;padding:2rem">
Loading params...</div>
</div>
</div>
</div>
<!-- ROW 2: Feature importance + Strategy + Behavior tree -->
<div class="section-label">Model Internals</div>
<div class="dash-grid tri" style="margin-bottom:1.5rem">
<div class="panel">
<div class="panel-title">Feature Importance</div>
<div class="chart-wrap"><canvas id="importanceChart"></canvas></div>
</div>
<div class="panel">
<div class="panel-title">
Strategy Mode
<span class="badge badge-purple" id="activeStrategyBadge">&mdash;</span>
</div>
<div class="strategy-grid" id="strategyGrid">
<!-- injected -->
</div>
<div style="margin-top:1rem">
<div class="panel-title" style="margin-bottom:.5rem">Multi-Objective Pareto</div>
<div class="chart-wrap" style="height:120px"><canvas id="paretoChart"></canvas></div>
</div>
</div>
<div class="panel">
<div class="panel-title">
ID3 Behavior Rules
<span class="badge badge-green" id="rulesCount">0 rules</span>
</div>
<div class="rules-scroll" id="rulesContainer">
<div
style="color:var(--muted);font-family:var(--mono);font-size:.7rem;text-align:center;padding:2rem">
Waiting for trained tree...</div>
</div>
</div>
</div>
<!-- ROW 3: Hourly heatmap + Quality histogram + Strategy compare -->
<div class="section-label">Operational Analytics</div>
<div class="dash-grid tri" style="margin-bottom:1.5rem">
<div class="panel">
<div class="panel-title">Quality Heatmap &mdash; by Hour</div>
<div id="heatmapContainer" style="margin-top:.5rem"></div>
</div>
<div class="panel">
<div class="panel-title">Quality Distribution</div>
<div class="chart-wrap"><canvas id="histogramChart"></canvas></div>
</div>
<div class="panel">
<div class="panel-title">Strategy Comparison</div>
<div class="tbl-scroll">
<table class="ml-table" id="strategyTable">
<thead>
<tr>
<th>Strategy</th>
<th>Calls</th>
<th>Avg Q</th>
<th>Unassigned</th>
<th>Avg km</th>
</tr>
</thead>
<tbody id="strategyTableBody">
<tr>
<td colspan="5" style="color:var(--muted);text-align:center;padding:1.5rem">Loading...
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- ROW 4: Zone stats + Top trials + Model health -->
<div class="section-label">Zone Intelligence & Audit</div>
<div class="dash-grid tri" style="margin-bottom:1.5rem">
<div class="panel">
<div class="panel-title">Zone Performance</div>
<div class="tbl-scroll">
<table class="ml-table" id="zoneTable">
<thead>
<tr>
<th>Zone</th>
<th>Calls</th>
<th>Avg Q</th>
<th>SLA Breaches</th>
<th>Avg km</th>
</tr>
</thead>
<tbody id="zoneTableBody">
<tr>
<td colspan="5" style="color:var(--muted);text-align:center;padding:1.5rem">Loading...
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="panel">
<div class="panel-title">Top Optuna Trials</div>
<div class="tbl-scroll">
<table class="ml-table" id="trialsTable">
<thead>
<tr>
<th>#</th>
<th>Predicted Q</th>
<th>Key Params</th>
</tr>
</thead>
<tbody id="trialsTableBody">
<tr>
<td colspan="3" style="color:var(--muted);text-align:center;padding:1.5rem">Run retrain
to see trials</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="panel">
<div class="panel-title">Model Health Audit</div>
<div id="modelHealthContainer">
<div
style="color:var(--muted);font-family:var(--mono);font-size:.7rem;text-align:center;padding:2rem">
Train the model to see audit data</div>
</div>
</div>
</div>
<!-- ROW 5: Unassigned + Latency series -->
<div class="section-label">Assignment Quality Drill-Down</div>
<div class="dash-grid" style="grid-template-columns:1fr 1fr;margin-bottom:1.5rem">
<div class="panel">
<div class="panel-title">Unassigned Orders &mdash; Recent Calls</div>
<div class="chart-wrap"><canvas id="unassignedChart"></canvas></div>
</div>
<div class="panel">
<div class="panel-title">Assignment Latency (ms)</div>
<div class="chart-wrap"><canvas id="latencyChart"></canvas></div>
</div>
</div>
<!-- EXPORT ROW -->
<div style="display:flex;gap:.75rem;justify-content:flex-end;margin-bottom:1.5rem">
<button class="btn btn-ghost" onclick="downloadCSV()">&darr; EXPORT CSV</button>
<button class="btn btn-ghost" onclick="window.open('/api/v1/ml/status','_blank')">&#8599; RAW STATUS
JSON</button>
</div>
</main>
<div id="toast"></div>
<script>
const API = '/api/v1/ml';
let charts = {};
let activeStrategy = 'balanced';
const STRATEGIES = {
aggressive_speed: { name: 'Aggressive Speed', desc: 'Max completions, ignore balance', color: '#ff4466' },
fuel_saver: { name: 'Fuel Saver', desc: 'Minimize total route distance', color: '#00e57a' },
zone_strict: { name: 'Zone Strict', desc: 'Balance + local routes', color: '#7c3aed' },
balanced: { name: 'Balanced', desc: 'Even spread across all metrics', color: '#00d4ff' },
};
const PARAM_DESC = {
max_pickup_distance_km: 'Max distance a rider can be from kitchen for initial pickup',
max_kitchen_distance_km: 'Max travel distance to reach the kitchen',
max_orders_per_rider: 'Hard cap on orders per trip (prevents overload)',
ideal_load: 'Target orders-per-rider the optimizer aims for',
workload_balance_threshold: 'How strictly to enforce equal workload across riders',
workload_penalty_weight: 'Cost multiplier for load imbalance in the optimizer',
distance_penalty_weight: 'Cost multiplier for travel distance in the optimizer',
cluster_radius_km: 'Radius used to group nearby orders into clusters',
search_time_limit_seconds: 'Max wall-clock time allowed per optimization run',
road_factor: 'Straight-line to road-distance multiplier (1.3 = 30% longer)',
};
// ─────────────────────────── INIT ───────────────────────────
async function init() {
await Promise.all([loadStatus(), loadConfig()]);
document.getElementById('lastRefresh').textContent =
new Date().toLocaleTimeString();
}
async function fullRefresh() { await init(); toast('Refreshed'); }
// Auto-refresh every 30s
setInterval(async () => {
await loadStatus();
document.getElementById('lastRefresh').textContent = new Date().toLocaleTimeString();
}, 30000);
// ─────────────────────────── STATUS ───────────────────────────
async function loadStatus() {
try {
const res = await fetch(`${API}/status`);
const data = await res.json();
if (data.status !== 'ok') return;
updateSysStatus(data);
updateStatStrip(data);
renderQualityChart(data.quality_trend || {});
renderUnassignedChart(data.quality_trend || {});
renderLatencyChart(data.quality_trend || {});
renderModelHealth(data.model || {});
renderBehaviorRules(data.behavior || {});
renderStrategyGrid(data.config?.ml_strategy || 'balanced');
renderHeatmap(data.hourly_stats || []);
renderHistogram(data.quality_histogram || []);
renderStrategyTable(data.strategy_comparison || []);
renderZoneTable(data.zone_stats || []);
renderTrialsTable(data.model?.top_trials || []);
renderImportanceChart(data.model?.feature_importance || {});
renderParetoChart(data.model?.pareto_frontier_size || 0);
} catch (e) {
console.error('loadStatus error:', e);
document.getElementById('sysStatus').className = 'status-chip err';
document.getElementById('sysStatus').innerHTML = '<span>&bull;</span> API ERROR';
}
}
function updateSysStatus(data) {
const chip = document.getElementById('sysStatus');
if (data.ready_to_train) {
chip.className = 'status-chip';
chip.innerHTML = '<span>&bull;</span> READY';
} else {
chip.className = 'status-chip warn';
chip.innerHTML = `<span>&bull;</span> COLLECTING (${data.db_records}/${30})`;
}
}
function updateStatStrip(data) {
const trend = data.quality_trend || {};
const model = data.model || {};
const val = data.model?.validation || {};
const baseline = data.model?.baseline || {};
set('sRecords', data.db_records ?? '&mdash;');
set('sReady', data.ready_to_train ? '&check; READY TO TRAIN' : `${30 - (data.db_records || 0)} more needed`);
el('sReady').style.color = data.ready_to_train ? 'var(--green)' : 'var(--amber)';
const q = trend.avg_quality;
set('sQuality', q != null ? q.toFixed(1) + '%' : '&mdash;');
el('sQuality').className = 'stat-value ' + qualClass(q);
set('sQualSub', q != null ? `min ${trend.min_quality} / max ${trend.max_quality}` : '&mdash;');
const ts = model.trust_score || val.trust_score;
set('sTrust', model.model_trained ? `${ts || 0}/5` : '&mdash;');
set('sTrustSub', val.trust_level || 'not trained');
const r2 = val.r2_score;
set('sR2', r2 != null ? (r2 * 100).toFixed(1) + '%' : '&mdash;');
el('sR2').className = 'stat-value ' + (r2 > .75 ? 'v-green' : r2 > .5 ? 'v-amber' : 'v-red');
set('sMae', val.mae != null ? val.mae.toFixed(2) : '&mdash;');
el('sMae').className = 'stat-value ' + (val.mae < 5 ? 'v-green' : val.mae < 10 ? 'v-amber' : 'v-red');
const latArr = trend.latency_series || [];
const avgLat = latArr.length ? (latArr.reduce((a, b) => a + b, 0) / latArr.length).toFixed(0) : '&mdash;';
set('sLatency', avgLat !== '&mdash;' ? avgLat + 'ms' : '&mdash;');
// SLA count from hourly stats
const slaCount = (data.hourly_stats || []).reduce((s, r) => s + (r.sla_breaches || 0), 0);
set('sSLA', slaCount);
el('sSLA').className = 'stat-value ' + (slaCount === 0 ? 'v-green' : slaCount < 5 ? 'v-amber' : 'v-red');
}
// ─────────────────────────── CONFIG ───────────────────────────
async function loadConfig() {
try {
const res = await fetch(`${API}/config`);
const data = await res.json();
if (data.status !== 'ok') return;
set('sMLParams', data.ml_tuned_count ?? '&mdash;');
set('sParamsSub', `of ${data.total_params} total params`);
const container = el('paramsContainer');
container.innerHTML = '';
const sorted = Object.entries(data.hyperparameters || {})
.sort((a, b) => a[0].localeCompare(b[0]));
for (const [key, info] of sorted) {
const div = document.createElement('div');
div.className = 'param-row';
const isML = info.source === 'ml_tuned' || info.source === 'ml_hypertuner';
div.innerHTML = `
<div>
<div class="param-name">${key}</div>
<div class="stat-sub" style="font-size:.6rem;margin-top:.15rem">${PARAM_DESC[key] || ''}</div>
</div>
<div style="display:flex;align-items:center;gap:.5rem">
<span class="param-val">${typeof info.value === 'number' ? info.value.toFixed(2) : info.value}</span>
<span class="param-src ${isML ? '' : 'default'}">${isML ? 'ML' : 'DEFAULT'}</span>
</div>
`;
container.appendChild(div);
}
} catch (e) {
console.error('loadConfig:', e);
}
}
// ─────────────────────────── CHARTS ───────────────────────────
const CHART_DEFAULTS = {
plugins: { legend: { display: false } },
animation: { duration: 400 },
scales: {
x: { grid: { color: 'rgba(30,39,51,.8)', drawBorder: false }, ticks: { color: '#64748b', font: { family: 'DM Mono', size: 10 } } },
y: { grid: { color: 'rgba(30,39,51,.8)', drawBorder: false }, ticks: { color: '#64748b', font: { family: 'DM Mono', size: 10 } } },
}
};
function mkChart(id, cfg) {
if (charts[id]) { charts[id].destroy(); }
charts[id] = new Chart(document.getElementById(id).getContext('2d'), cfg);
return charts[id];
}
function updateChart(id, labels, datasets) {
if (!charts[id]) return;
charts[id].data.labels = labels;
charts[id].data.datasets = datasets;
charts[id].update('none');
}
function renderQualityChart(trend) {
const history = trend.history || [];
const labels = history.map((_, i) => `#${i + 1}`);
const cfg = {
type: 'line',
data: {
labels, datasets: [{
label: 'Quality', data: history,
borderColor: '#00d4ff', backgroundColor: 'rgba(0,212,255,.08)',
borderWidth: 2, tension: .4, fill: true,
pointRadius: 2, pointHoverRadius: 5, pointBackgroundColor: '#00d4ff'
}, {
label: 'Threshold', data: history.map(() => 70),
borderColor: 'rgba(255,170,0,.4)', borderWidth: 1,
borderDash: [4, 4], pointRadius: 0, fill: false
}]
},
options: {
...CHART_DEFAULTS, responsive: true, maintainAspectRatio: false,
plugins: {
legend: { display: false }, tooltip: {
mode: 'index', intersect: false,
callbacks: { label: ctx => `${ctx.dataset.label}: ${ctx.raw?.toFixed(1)}` }
}
},
scales: {
x: { ...CHART_DEFAULTS.scales.x },
y: { ...CHART_DEFAULTS.scales.y, min: 0, max: 100 }
}
}
};
if (charts['qualityChart']) {
charts['qualityChart'].data.labels = labels;
charts['qualityChart'].data.datasets[0].data = history;
charts['qualityChart'].update('none');
} else {
mkChart('qualityChart', cfg);
}
}
function renderUnassignedChart(trend) {
const data = trend.unassigned_series || [];
const labels = data.map((_, i) => `#${i + 1}`);
const cfg = {
type: 'bar',
data: {
labels, datasets: [{
label: 'Unassigned', data,
backgroundColor: data.map(v => v === 0 ? 'rgba(0,229,122,.5)' : 'rgba(255,68,102,.5)'),
borderRadius: 2, borderSkipped: false
}]
},
options: {
...CHART_DEFAULTS, responsive: true, maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: { x: CHART_DEFAULTS.scales.x, y: { ...CHART_DEFAULTS.scales.y, min: 0 } }
}
};
if (charts['unassignedChart']) {
charts['unassignedChart'].data.labels = labels;
charts['unassignedChart'].data.datasets[0].data = data;
charts['unassignedChart'].data.datasets[0].backgroundColor = data.map(v => v === 0 ? 'rgba(0,229,122,.5)' : 'rgba(255,68,102,.5)');
charts['unassignedChart'].update('none');
} else { mkChart('unassignedChart', cfg); }
}
function renderLatencyChart(trend) {
const data = trend.latency_series || [];
const labels = data.map((_, i) => `#${i + 1}`);
const avg = data.length ? data.reduce((a, b) => a + b, 0) / data.length : 0;
const cfg = {
type: 'line',
data: {
labels, datasets: [{
label: 'Latency (ms)', data,
borderColor: '#7c3aed', backgroundColor: 'rgba(124,58,237,.08)',
borderWidth: 2, tension: .3, fill: true, pointRadius: 0
}, {
label: 'Avg', data: data.map(() => avg),
borderColor: 'rgba(124,58,237,.4)', borderWidth: 1,
borderDash: [4, 4], pointRadius: 0, fill: false
}]
},
options: {
...CHART_DEFAULTS, responsive: true, maintainAspectRatio: false,
plugins: { legend: { display: false } }
}
};
if (charts['latencyChart']) {
charts['latencyChart'].data.labels = labels;
charts['latencyChart'].data.datasets[0].data = data;
charts['latencyChart'].update('none');
} else { mkChart('latencyChart', cfg); }
}
function renderImportanceChart(importance) {
const entries = Object.entries(importance).sort((a, b) => b[1] - a[1]).slice(0, 12);
const labels = entries.map(e => e[0]);
const values = entries.map(e => e[1]);
const colors = values.map((v, i) => `hsl(${180 + i * 15},80%,${65 - i * 2}%)`);
const cfg = {
type: 'bar',
data: {
labels, datasets: [{
label: 'Impact %', data: values,
backgroundColor: colors, borderRadius: 2, borderSkipped: false
}]
},
options: {
indexAxis: 'y', ...CHART_DEFAULTS, responsive: true, maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: { x: { ...CHART_DEFAULTS.scales.x, max: 100 }, y: CHART_DEFAULTS.scales.y }
}
};
if (charts['importanceChart']) {
charts['importanceChart'].data.labels = labels;
charts['importanceChart'].data.datasets[0].data = values;
charts['importanceChart'].update('none');
} else { mkChart('importanceChart', cfg); }
}
function renderHistogram(hist) {
const labels = hist.map(h => h.range);
const data = hist.map(h => h.count);
const cfg = {
type: 'bar',
data: {
labels, datasets: [{
label: 'Count', data,
backgroundColor: data.map((_, i) => `rgba(0,212,255,${.2 + i * 0.08})`),
borderRadius: 2, borderSkipped: false
}]
},
options: {
...CHART_DEFAULTS, responsive: true, maintainAspectRatio: false,
plugins: { legend: { display: false } }
}
};
if (charts['histogramChart']) {
charts['histogramChart'].data.labels = labels;
charts['histogramChart'].data.datasets[0].data = data;
charts['histogramChart'].update('none');
} else { mkChart('histogramChart', cfg); }
}
function renderParetoChart(size) {
// Scatter placeholder showing pareto count
const dummy = Array.from({ length: size || 5 }, (_, i) => ({ x: 20 + i * 15 + Math.random() * 10, y: 50 + Math.random() * 30 }));
const cfg = {
type: 'scatter',
data: {
datasets: [{
label: 'Pareto trials', data: dummy,
backgroundColor: 'rgba(0,212,255,.6)', pointRadius: 4
}]
},
options: {
...CHART_DEFAULTS, responsive: true, maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
x: { ...CHART_DEFAULTS.scales.x, title: { display: true, text: 'Latency', color: '#64748b', font: { size: 9, family: 'DM Mono' } } },
y: { ...CHART_DEFAULTS.scales.y, title: { display: true, text: 'Quality', color: '#64748b', font: { size: 9, family: 'DM Mono' } } }
}
}
};
if (charts['paretoChart']) {
charts['paretoChart'].data.datasets[0].data = dummy;
charts['paretoChart'].update('none');
} else { mkChart('paretoChart', cfg); }
}
// ─────────────────────────── HEATMAP ───────────────────────────
function renderHeatmap(hourly) {
const container = el('heatmapContainer');
if (!hourly.length) {
container.innerHTML = '<div style="color:var(--muted);font-family:var(--mono);font-size:.7rem;text-align:center;padding:1.5rem">No hourly data yet</div>';
return;
}
const maxQ = Math.max(...hourly.map(h => h.avg_quality || 0), 1);
const hours = Array.from({ length: 24 }, (_, i) => i);
const byHour = Object.fromEntries(hourly.map(h => [h.hour, h]));
container.innerHTML = '';
const row = document.createElement('div');
row.style.cssText = 'display:flex;flex-wrap:wrap;gap:3px';
for (const h of hours) {
const d = byHour[h];
const q = d?.avg_quality || 0;
const ratio = q / maxQ;
const cell = document.createElement('div');
cell.style.cssText = `
width:calc(${100 / 24}% - 3px);height:36px;border-radius:2px;
background:${heatColor(ratio)};cursor:pointer;
display:flex;flex-direction:column;align-items:center;justify-content:center;
`;
cell.innerHTML = `
<div style="font-family:var(--mono);font-size:.55rem;color:rgba(255,255,255,.5)">${h}h</div>
<div style="font-family:var(--mono);font-size:.6rem;color:#fff;font-weight:500">${q ? q.toFixed(0) : '&mdash;'}</div>
`;
cell.title = `Hour ${h}: Q=${q.toFixed(1)}, SLA breaches=${d?.sla_breaches || 0}, calls=${d?.call_count || 0}`;
row.appendChild(cell);
}
container.appendChild(row);
}
function heatColor(ratio) {
if (ratio > .8) return `rgba(0,229,122,${.2 + ratio * .5})`;
if (ratio > .6) return `rgba(0,212,255,${.2 + ratio * .4})`;
if (ratio > .4) return `rgba(255,170,0,${.2 + ratio * .4})`;
return `rgba(255,68,102,${.15 + ratio * .3})`;
}
// ─────────────────────────── TABLES ───────────────────────────
function renderStrategyTable(rows) {
const tbody = el('strategyTableBody');
if (!rows.length) { tbody.innerHTML = '<tr><td colspan="5" style="color:var(--muted);text-align:center;padding:1.5rem">No data yet</td></tr>'; return; }
tbody.innerHTML = rows.map(r => `
<tr>
<td><span class="badge badge-blue">${r.strategy}</span></td>
<td>${r.call_count}</td>
<td><span class="${r.avg_quality > 75 ? 'badge-green' : r.avg_quality > 50 ? 'badge-amber' : 'badge-red'} badge">${r.avg_quality}%</span></td>
<td>${r.avg_unassigned.toFixed(1)}</td>
<td>${r.avg_distance_km.toFixed(1)} km</td>
</tr>`).join('');
}
function renderZoneTable(rows) {
const tbody = el('zoneTableBody');
if (!rows.length) { tbody.innerHTML = '<tr><td colspan="5" style="color:var(--muted);text-align:center;padding:1.5rem">No zone data yet</td></tr>'; return; }
tbody.innerHTML = rows.map(r => `
<tr>
<td style="color:var(--accent)">${r.zone_id}</td>
<td>${r.call_count}</td>
<td><span class="${r.avg_quality > 75 ? 'badge-green' : r.avg_quality > 50 ? 'badge-amber' : 'badge-red'} badge">${r.avg_quality}%</span></td>
<td><span class="${r.sla_breaches === 0 ? 'badge-green' : r.sla_breaches < 3 ? 'badge-amber' : 'badge-red'} badge">${r.sla_breaches}</span></td>
<td>${r.avg_distance_km.toFixed(1)} km</td>
</tr>`).join('');
}
function renderTrialsTable(trials) {
const tbody = el('trialsTableBody');
if (!trials.length) { tbody.innerHTML = '<tr><td colspan="3" style="color:var(--muted);text-align:center;padding:1.5rem">Run retrain to see trials</td></tr>'; return; }
tbody.innerHTML = trials.map((t, i) => {
const keyParam = Object.entries(t.params || {})
.sort((a, b) => Math.abs(b[1]) - Math.abs(a[1]))[0];
return `<tr>
<td style="color:var(--muted)">#${i + 1}</td>
<td><span class="badge badge-green">${(t.score || 0).toFixed(1)}%</span></td>
<td style="color:var(--muted);font-size:.65rem">${keyParam ? `${keyParam[0]}=${typeof keyParam[1] === 'number' ? keyParam[1].toFixed(2) : keyParam[1]}` : '&mdash;'}</td>
</tr>`;
}).join('');
}
// ─────────────────────────── MODEL HEALTH ───────────────────────────
function renderModelHealth(model) {
const v = model.validation || {};
const b = model.baseline || {};
const container = el('modelHealthContainer');
if (!model.model_trained) {
container.innerHTML = '<div style="color:var(--muted);font-family:var(--mono);font-size:.7rem;text-align:center;padding:2rem">Train the model to see audit data</div>';
return;
}
const improvement = model.validation?.r2_score != null
? ((95 - b.avg_quality) || 0).toFixed(1) : '&mdash;';
container.innerHTML = `
<div style="display:flex;flex-direction:column;gap:.75rem">
<div>
<div class="stat-label">R² Score</div>
<div style="display:flex;align-items:center;gap:.75rem;margin-top:.25rem">
<span style="font-family:var(--mono);font-size:1.2rem;color:${v.r2_score > .75 ? 'var(--green)' : v.r2_score > .5 ? 'var(--amber)' : 'var(--red)'}">${v.r2_score != null ? (v.r2_score * 100).toFixed(1) + '%' : '&mdash;'}</span>
<div class="progress-bar" style="flex:1"><div class="progress-fill" style="width:${(v.r2_score || 0) * 100}%;background:${v.r2_score > .75 ? 'var(--green)' : v.r2_score > .5 ? 'var(--amber)' : 'var(--red)'}"></div></div>
</div>
</div>
<div>
<div class="stat-label">Mean Absolute Error</div>
<div style="font-family:var(--mono);font-size:1rem;color:var(--amber);margin-top:.15rem">${v.mae != null ? v.mae.toFixed(2) + ' pts' : '&mdash;'}</div>
</div>
<div>
<div class="stat-label">Trust Level</div>
<div style="margin-top:.25rem">
<div class="trust-meter">${renderTrustPips(v.trust_score || 0)}</div>
<div style="font-family:var(--mono);font-size:.65rem;color:var(--muted);margin-top:.2rem">${v.trust_level || '&mdash;'}</div>
</div>
</div>
<div>
<div class="stat-label">Training Size</div>
<div style="font-family:var(--mono);font-size:1rem;color:var(--accent);margin-top:.15rem">${model.training_rows || 0} rows</div>
</div>
<div>
<div class="stat-label">Baseline → ML Uplift</div>
<div style="font-family:var(--mono);font-size:.8rem;color:var(--muted);margin-top:.15rem">
Baseline avg: <span style="color:var(--amber)">${b.avg_quality || 0}%</span>
&nbsp;→&nbsp;
Potential: <span style="color:var(--green)">+${improvement}%</span>
</div>
</div>
<div>
<div class="stat-label">Segment Models</div>
<div style="display:flex;gap:.5rem;margin-top:.25rem">
<span class="badge ${model.peak_model_trained ? 'badge-green' : 'badge-red'}">PEAK ${model.peak_model_trained ? '&check;' : '&times;'}</span>
<span class="badge ${model.offpeak_model_trained ? 'badge-green' : 'badge-red'}">OFF-PEAK ${model.offpeak_model_trained ? '&check;' : '&times;'}</span>
</div>
</div>
<div>
<div class="stat-label">Last Trained</div>
<div style="font-family:var(--mono);font-size:.65rem;color:var(--muted);margin-top:.15rem">${model.trained_at ? new Date(model.trained_at).toLocaleString() : '&mdash;'}</div>
</div>
</div>
`;
}
function renderTrustPips(score) {
return Array.from({ length: 5 }, (_, i) => {
const cls = i < score ? (score >= 4 ? 'on' : score >= 3 ? 'on warn' : 'on bad') : '';
return `<div class="trust-pip ${cls}"></div>`;
}).join('');
}
// ─────────────────────────── BEHAVIOR RULES ───────────────────────────
function renderBehaviorRules(behavior) {
const rules = behavior.rules || [];
const container = el('rulesContainer');
set('rulesCount', `${rules.length} rules`);
if (!rules.length) {
container.innerHTML = '<div style="color:var(--muted);font-family:var(--mono);font-size:.7rem;text-align:center;padding:2rem">Waiting for trained tree...</div>';
return;
}
container.innerHTML = rules.map(rule => {
const isSuccess = rule.includes('SUCCESS');
return `<div class="rule-item ${isSuccess ? 'success' : 'risk'}">${rule}</div>`;
}).join('');
}
// ─────────────────────────── STRATEGY ───────────────────────────
function renderStrategyGrid(current) {
activeStrategy = current;
const badge = el('activeStrategyBadge');
badge.textContent = STRATEGIES[current]?.name || current;
const grid = el('strategyGrid');
grid.innerHTML = Object.entries(STRATEGIES).map(([key, s]) => `
<div class="strategy-card ${key === current ? 'active' : ''}" onclick="setStrategy('${key}')">
<div class="strategy-name" style="color:${s.color}">${s.name}</div>
<div class="strategy-desc">${s.desc}</div>
</div>
`).join('');
}
async function setStrategy(key) {
try {
await fetch(`${API}/config`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ml_strategy: key })
});
toast(`Strategy set: ${STRATEGIES[key].name}`);
await fullRefresh();
} catch (e) {
toast('Failed to set strategy', true);
}
}
// ─────────────────────────── ACTIONS ───────────────────────────
async function triggerTrain() {
const btn = el('btnTrain');
const spinner = el('trainSpinner');
const label = el('trainLabel');
btn.disabled = true;
spinner.style.display = 'block';
label.textContent = 'TUNING...';
try {
const res = await fetch(`${API}/train`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ n_trials: 150, multi_objective: false })
});
const data = await res.json();
if (data.status === 'ok') {
toast(`Tuned! Predicted quality: ${data.best_predicted_quality}%`);
} else if (data.status === 'model_not_ready') {
toast(`Not ready: ${data.message}`, true);
} else if (data.status === 'insufficient_data') {
toast(`Need more data: ${data.message}`, true);
} else {
toast(data.message || 'Tuning failed', true);
}
} catch (e) {
toast('API error &mdash; check backend', true);
} finally {
btn.disabled = false;
spinner.style.display = 'none';
label.textContent = 'TRIGGER RETRAIN';
await fullRefresh();
}
}
function confirmReset() {
if (!confirm('Reset ALL ML-tuned parameters to factory defaults?')) return;
fetch(`${API}/reset`, { method: 'POST' })
.then(() => { toast('Reset to defaults'); fullRefresh(); })
.catch(() => toast('Reset failed', true));
}
async function downloadCSV() {
try {
const res = await fetch(`${API}/export`);
const text = await res.text();
const blob = new Blob([text], { type: 'text/csv' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `ml_export_${Date.now()}.csv`;
a.click();
toast('CSV downloaded');
} catch (e) {
toast('Export failed', true);
}
}
// ─────────────────────────── UTILS ───────────────────────────
function el(id) { return document.getElementById(id); }
function set(id, val) { const e = el(id); if (e) e.innerHTML = val; }
function qualClass(q) {
if (q == null) return '';
if (q >= 80) return 'v-green';
if (q >= 60) return 'v-amber';
return 'v-red';
}
function toast(msg, err = false) {
const t = el('toast');
t.textContent = msg;
t.className = err ? 'show err' : 'show';
setTimeout(() => t.className = '', 4000);
}
// ─────────────────────────── BOOT ───────────────────────────
init();
</script>
</body>
</html>