1522 lines
57 KiB
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 — 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>•</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">↺ 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">—</div>
|
|
<div class="stat-sub" id="sReady">—</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-label">Avg Quality (last 50)</div>
|
|
<div class="stat-value" id="sQuality">—</div>
|
|
<div class="stat-sub" id="sQualSub">—</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-label">Model Trust</div>
|
|
<div class="stat-value" id="sTrust">—</div>
|
|
<div class="stat-sub" id="sTrustSub">—</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-label">R² Score</div>
|
|
<div class="stat-value" id="sR2">—</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">—</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">—</div>
|
|
<div class="stat-sub" id="sParamsSub">—</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-label">SLA Breaches</div>
|
|
<div class="stat-value" id="sSLA">—</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">—</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 — 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">—</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 — 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 — 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()">↓ EXPORT CSV</button>
|
|
<button class="btn btn-ghost" onclick="window.open('/api/v1/ml/status','_blank')">↗ 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>•</span> API ERROR';
|
|
}
|
|
}
|
|
|
|
function updateSysStatus(data) {
|
|
const chip = document.getElementById('sysStatus');
|
|
if (data.ready_to_train) {
|
|
chip.className = 'status-chip';
|
|
chip.innerHTML = '<span>•</span> READY';
|
|
} else {
|
|
chip.className = 'status-chip warn';
|
|
chip.innerHTML = `<span>•</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 ?? '—');
|
|
set('sReady', data.ready_to_train ? '✓ 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) + '%' : '—');
|
|
el('sQuality').className = 'stat-value ' + qualClass(q);
|
|
set('sQualSub', q != null ? `min ${trend.min_quality} / max ${trend.max_quality}` : '—');
|
|
|
|
const ts = model.trust_score || val.trust_score;
|
|
set('sTrust', model.model_trained ? `${ts || 0}/5` : '—');
|
|
set('sTrustSub', val.trust_level || 'not trained');
|
|
|
|
const r2 = val.r2_score;
|
|
set('sR2', r2 != null ? (r2 * 100).toFixed(1) + '%' : '—');
|
|
el('sR2').className = 'stat-value ' + (r2 > .75 ? 'v-green' : r2 > .5 ? 'v-amber' : 'v-red');
|
|
|
|
set('sMae', val.mae != null ? val.mae.toFixed(2) : '—');
|
|
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) : '—';
|
|
set('sLatency', avgLat !== '—' ? avgLat + 'ms' : '—');
|
|
|
|
// 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 ?? '—');
|
|
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) : '—'}</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]}` : '—'}</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) : '—';
|
|
|
|
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) + '%' : '—'}</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' : '—'}</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 || '—'}</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>
|
|
→
|
|
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 ? '✓' : '×'}</span>
|
|
<span class="badge ${model.offpeak_model_trained ? 'badge-green' : 'badge-red'}">OFF-PEAK ${model.offpeak_model_trained ? '✓' : '×'}</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() : '—'}</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 — 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> |