Files
dictia-public/deployment/asr-proxy/dashboard.html

1535 lines
41 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DictIA GPU Monitor</title>
<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=JetBrains+Mono:wght@300;400;500;600;700&family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<style>
:root {
--bg-primary: #0a0e17;
--bg-card: #111827;
--bg-card-hover: #151d2e;
--border-subtle: #1e293b;
--border-hover: #2a3a52;
--accent-cyan: #00e5ff;
--accent-cyan-dim: rgba(0, 229, 255, 0.15);
--accent-cyan-glow: rgba(0, 229, 255, 0.4);
--accent-amber: #ffb300;
--accent-amber-dim: rgba(255, 179, 0, 0.15);
--accent-red: #ff3d3d;
--accent-red-dim: rgba(255, 61, 61, 0.15);
--accent-green: #00e676;
--text-primary: #e2e8f0;
--text-secondary: #94a3b8;
--text-muted: #475569;
--font-mono: 'JetBrains Mono', monospace;
--font-display: 'Outfit', sans-serif;
}
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
background-color: var(--bg-primary);
color: var(--text-primary);
font-family: var(--font-display);
min-height: 100vh;
overflow-x: hidden;
position: relative;
}
/* Subtle grid overlay */
body::before {
content: '';
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-image:
linear-gradient(rgba(30, 41, 59, 0.18) 1px, transparent 1px),
linear-gradient(90deg, rgba(30, 41, 59, 0.18) 1px, transparent 1px);
background-size: 40px 40px;
pointer-events: none;
z-index: 0;
}
/* Scanline overlay for that CRT feel */
body::after {
content: '';
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: repeating-linear-gradient(
0deg,
transparent,
transparent 2px,
rgba(0, 0, 0, 0.03) 2px,
rgba(0, 0, 0, 0.03) 4px
);
pointer-events: none;
z-index: 0;
}
.container {
max-width: 1100px;
margin: 0 auto;
padding: 0 24px 40px;
position: relative;
z-index: 1;
}
/* ---- Error Banner ---- */
.error-banner {
position: fixed;
top: 0;
left: 0;
right: 0;
background: linear-gradient(135deg, rgba(255, 61, 61, 0.12), rgba(255, 61, 61, 0.06));
border-bottom: 1px solid rgba(255, 61, 61, 0.3);
color: var(--accent-red);
font-family: var(--font-mono);
font-size: 0.8rem;
padding: 10px 24px;
text-align: center;
z-index: 100;
transform: translateY(-100%);
transition: transform 0.3s ease;
backdrop-filter: blur(10px);
}
.error-banner.visible {
transform: translateY(0);
}
/* ---- Header ---- */
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24px 0 20px;
border-bottom: 1px solid var(--border-subtle);
margin-bottom: 40px;
opacity: 0;
animation: fadeSlideDown 0.5s ease forwards;
}
.header-title {
font-family: var(--font-display);
font-weight: 600;
font-size: 1.1rem;
letter-spacing: 0.3em;
text-transform: uppercase;
color: var(--text-primary);
}
.header-title span {
color: var(--accent-cyan);
}
.proxy-badge {
display: flex;
align-items: center;
gap: 8px;
font-family: var(--font-mono);
font-size: 0.75rem;
color: var(--text-secondary);
background: rgba(0, 230, 118, 0.06);
border: 1px solid rgba(0, 230, 118, 0.2);
padding: 5px 14px;
border-radius: 20px;
transition: all 0.3s ease;
}
.proxy-badge.unhealthy {
background: var(--accent-red-dim);
border-color: rgba(255, 61, 61, 0.3);
}
.proxy-badge-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--accent-green);
box-shadow: 0 0 6px var(--accent-green);
}
.proxy-badge.unhealthy .proxy-badge-dot {
background: var(--accent-red);
box-shadow: 0 0 6px var(--accent-red);
}
/* ---- Hero Status ---- */
.hero {
display: flex;
flex-direction: column;
align-items: center;
padding: 48px 0 40px;
opacity: 0;
animation: fadeSlideDown 0.5s ease 0.1s forwards;
}
.status-ring-container {
position: relative;
width: 120px;
height: 120px;
margin-bottom: 24px;
}
.status-ring {
width: 120px;
height: 120px;
border-radius: 50%;
border: 3px solid var(--accent-red);
background: var(--accent-red-dim);
position: relative;
transition: border-color 0.6s ease, background 0.6s ease, box-shadow 0.6s ease;
display: flex;
align-items: center;
justify-content: center;
}
.status-ring.running {
border-color: var(--accent-cyan);
background: var(--accent-cyan-dim);
box-shadow:
0 0 30px rgba(0, 229, 255, 0.2),
0 0 60px rgba(0, 229, 255, 0.1);
}
.status-ring.running::before {
content: '';
position: absolute;
top: -8px;
left: -8px;
right: -8px;
bottom: -8px;
border-radius: 50%;
border: 1px solid rgba(0, 229, 255, 0.25);
animation: pulseRing 2.5s ease-in-out infinite;
}
.status-ring.running::after {
content: '';
position: absolute;
top: -16px;
left: -16px;
right: -16px;
bottom: -16px;
border-radius: 50%;
border: 1px solid rgba(0, 229, 255, 0.1);
animation: pulseRing 2.5s ease-in-out 0.5s infinite;
}
.status-ring-inner {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--accent-red);
transition: background 0.6s ease, box-shadow 0.6s ease;
}
.status-ring.running .status-ring-inner {
background: var(--accent-cyan);
box-shadow: 0 0 20px var(--accent-cyan-glow);
animation: innerPulse 2s ease-in-out infinite;
}
@keyframes pulseRing {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.08); opacity: 0.4; }
}
@keyframes innerPulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.6; transform: scale(1.4); }
}
.status-label {
font-family: var(--font-mono);
font-size: 1.6rem;
font-weight: 600;
letter-spacing: 0.15em;
color: var(--accent-red);
transition: color 0.6s ease;
margin-bottom: 6px;
}
.status-label.running {
color: var(--accent-cyan);
}
.zone-label {
font-family: var(--font-mono);
font-size: 0.85rem;
color: var(--text-muted);
letter-spacing: 0.05em;
}
.active-requests-badge {
margin-top: 14px;
display: none;
align-items: center;
gap: 6px;
font-family: var(--font-mono);
font-size: 0.8rem;
color: var(--accent-amber);
background: var(--accent-amber-dim);
border: 1px solid rgba(255, 179, 0, 0.25);
padding: 5px 16px;
border-radius: 20px;
}
.active-requests-badge.visible {
display: flex;
}
.active-requests-badge::before {
content: '';
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--accent-amber);
animation: innerPulse 1.2s ease-in-out infinite;
}
/* ---- Stats Grid ---- */
.stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-bottom: 32px;
}
.stat-card {
background: var(--bg-card);
border: 1px solid var(--border-subtle);
border-radius: 12px;
padding: 24px 20px;
text-align: center;
transition: all 0.3s ease;
opacity: 0;
animation: fadeSlideUp 0.5s ease forwards;
}
.stat-card:nth-child(1) { animation-delay: 0.2s; }
.stat-card:nth-child(2) { animation-delay: 0.3s; }
.stat-card:nth-child(3) { animation-delay: 0.4s; }
.stat-card:nth-child(4) { animation-delay: 0.5s; }
.stat-card:hover {
background: var(--bg-card-hover);
border-color: var(--border-hover);
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.3), 0 0 1px rgba(0, 229, 255, 0.1);
transform: translateY(-2px);
}
.stat-value {
font-family: var(--font-mono);
font-size: 1.8rem;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 6px;
transition: color 0.3s ease;
line-height: 1.2;
}
.stat-label {
font-family: var(--font-display);
font-size: 0.78rem;
font-weight: 400;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.1em;
}
.stat-sublabel {
font-family: var(--font-mono);
font-size: 0.7rem;
color: var(--text-muted);
margin-top: 2px;
opacity: 0.7;
}
/* ---- Budget Bar ---- */
.budget-section {
margin-bottom: 32px;
opacity: 0;
animation: fadeSlideUp 0.5s ease 0.6s forwards;
}
.budget-bar-container {
background: var(--bg-card);
border: 1px solid var(--border-subtle);
border-radius: 12px;
padding: 24px;
}
.budget-bar-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 14px;
}
.budget-bar-title {
font-family: var(--font-display);
font-size: 0.8rem;
font-weight: 500;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.1em;
}
.budget-bar-percent {
font-family: var(--font-mono);
font-size: 0.9rem;
font-weight: 600;
color: var(--accent-cyan);
transition: color 0.3s ease;
}
.budget-bar-track {
width: 100%;
height: 10px;
background: rgba(30, 41, 59, 0.6);
border-radius: 5px;
overflow: hidden;
position: relative;
}
.budget-bar-fill {
height: 100%;
border-radius: 5px;
background: linear-gradient(90deg, var(--accent-cyan), var(--accent-amber));
transition: width 0.8s cubic-bezier(0.25, 0.46, 0.45, 0.94);
position: relative;
min-width: 0;
}
.budget-bar-fill::after {
content: '';
position: absolute;
top: 0;
right: 0;
width: 30px;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.15));
border-radius: 0 5px 5px 0;
}
.budget-bar-text {
font-family: var(--font-mono);
font-size: 0.78rem;
color: var(--text-muted);
margin-top: 10px;
text-align: center;
}
/* ---- Controls ---- */
.controls {
display: flex;
gap: 16px;
justify-content: center;
margin-bottom: 32px;
opacity: 0;
animation: fadeSlideUp 0.5s ease 0.7s forwards;
}
.btn {
font-family: var(--font-mono);
font-size: 0.85rem;
font-weight: 500;
letter-spacing: 0.08em;
padding: 12px 36px;
border-radius: 8px;
cursor: pointer;
transition: all 0.25s ease;
background: transparent;
position: relative;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
min-width: 160px;
}
.btn-start {
color: var(--accent-cyan);
border: 1px solid rgba(0, 229, 255, 0.35);
}
.btn-start:hover:not(:disabled) {
background: rgba(0, 229, 255, 0.08);
border-color: var(--accent-cyan);
box-shadow: 0 0 20px rgba(0, 229, 255, 0.15);
}
.btn-stop {
color: var(--accent-red);
border: 1px solid rgba(255, 61, 61, 0.35);
}
.btn-stop:hover:not(:disabled) {
background: rgba(255, 61, 61, 0.08);
border-color: var(--accent-red);
box-shadow: 0 0 20px rgba(255, 61, 61, 0.15);
}
.btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.btn .spinner {
display: none;
width: 14px;
height: 14px;
border: 2px solid transparent;
border-top-color: currentColor;
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
.btn.loading .spinner {
display: block;
}
.btn.loading .btn-text {
opacity: 0.5;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* ---- Instance Details ---- */
.instance-section {
margin-bottom: 32px;
opacity: 0;
animation: fadeSlideUp 0.5s ease 0.8s forwards;
}
.instance-card {
background: var(--bg-card);
border: 1px solid var(--border-subtle);
border-radius: 12px;
padding: 24px;
}
.instance-card-header {
font-family: var(--font-display);
font-size: 0.78rem;
font-weight: 500;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.1em;
margin-bottom: 18px;
padding-bottom: 12px;
border-bottom: 1px solid var(--border-subtle);
}
.instance-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px 24px;
}
.instance-item-label {
font-family: var(--font-display);
font-size: 0.68rem;
font-weight: 500;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.08em;
margin-bottom: 4px;
}
.instance-item-value {
font-family: var(--font-mono);
font-size: 0.88rem;
font-weight: 500;
color: var(--text-primary);
}
.instance-item-value .sub-text {
font-size: 0.72rem;
margin-left: 6px;
}
.instance-item-value .sub-text.color-green { color: var(--accent-green); }
.instance-item-value .sub-text.color-amber { color: var(--accent-amber); }
.instance-item-value .sub-text.color-red { color: var(--accent-red); }
/* ---- Zone Fallback Map ---- */
.zone-section {
margin-bottom: 32px;
opacity: 0;
animation: fadeSlideUp 0.5s ease 0.9s forwards;
}
.zone-card {
background: var(--bg-card);
border: 1px solid var(--border-subtle);
border-radius: 12px;
padding: 24px;
}
.zone-card-header {
font-family: var(--font-display);
font-size: 0.78rem;
font-weight: 500;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.1em;
margin-bottom: 18px;
padding-bottom: 12px;
border-bottom: 1px solid var(--border-subtle);
}
.zone-grid {
display: flex;
gap: 14px;
flex-wrap: wrap;
}
.zone-block {
flex: 1;
min-width: 180px;
background: rgba(10, 14, 23, 0.6);
border: 1px solid var(--border-subtle);
border-radius: 10px;
padding: 16px;
transition: all 0.3s ease;
position: relative;
}
.zone-block:hover {
background: var(--bg-card-hover);
border-color: var(--border-hover);
}
.zone-block.status-running {
border-color: rgba(0, 229, 255, 0.35);
}
.zone-block.status-running.active-zone {
border-color: rgba(0, 229, 255, 0.5);
background: rgba(0, 229, 255, 0.04);
box-shadow: 0 0 20px rgba(0, 229, 255, 0.06), inset 0 0 20px rgba(0, 229, 255, 0.03);
}
.zone-block.status-no_capacity {
border-color: rgba(255, 61, 61, 0.3);
}
.zone-block.status-starting {
border-color: rgba(255, 179, 0, 0.35);
}
.zone-block.status-unknown {
border-color: var(--border-subtle);
}
.zone-block.status-quota_exceeded {
border-color: rgba(255, 61, 61, 0.3);
}
.zone-block-top {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.zone-dot {
width: 7px;
height: 7px;
border-radius: 50%;
flex-shrink: 0;
}
.zone-block.status-running .zone-dot {
background: var(--accent-cyan);
box-shadow: 0 0 6px var(--accent-cyan);
}
.zone-block.status-no_capacity .zone-dot {
background: var(--accent-red);
box-shadow: 0 0 6px var(--accent-red);
}
.zone-block.status-starting .zone-dot {
background: var(--accent-amber);
box-shadow: 0 0 6px var(--accent-amber);
animation: innerPulse 1.2s ease-in-out infinite;
}
.zone-block.status-unknown .zone-dot {
background: var(--text-muted);
}
.zone-block.status-quota_exceeded .zone-dot {
background: var(--accent-amber);
box-shadow: 0 0 6px var(--accent-amber);
}
.zone-block-label {
font-family: var(--font-mono);
font-size: 0.78rem;
font-weight: 500;
color: var(--text-primary);
}
.zone-block-gpu {
font-family: var(--font-mono);
font-size: 0.68rem;
color: var(--text-secondary);
margin-bottom: 4px;
margin-left: 15px;
}
.zone-block-status {
font-family: var(--font-mono);
font-size: 0.66rem;
text-transform: uppercase;
letter-spacing: 0.06em;
margin-left: 15px;
margin-bottom: 6px;
}
.zone-block.status-running .zone-block-status { color: var(--accent-cyan); }
.zone-block.status-no_capacity .zone-block-status { color: var(--accent-red); }
.zone-block.status-starting .zone-block-status { color: var(--accent-amber); }
.zone-block.status-unknown .zone-block-status { color: var(--text-muted); }
.zone-block.status-quota_exceeded .zone-block-status { color: var(--accent-red); }
.zone-block-tried {
font-family: var(--font-mono);
font-size: 0.62rem;
color: var(--text-muted);
opacity: 0.7;
margin-left: 15px;
}
/* ---- Request History Table ---- */
.history-section {
margin-bottom: 32px;
opacity: 0;
animation: fadeSlideUp 0.5s ease 1.0s forwards;
}
.history-card {
background: var(--bg-card);
border: 1px solid var(--border-subtle);
border-radius: 12px;
overflow: hidden;
}
.history-card-header {
font-family: var(--font-display);
font-size: 0.78rem;
font-weight: 500;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.1em;
padding: 16px 20px 12px;
border-bottom: 1px solid var(--border-subtle);
}
.history-table-wrap {
overflow-x: auto;
scrollbar-width: thin;
scrollbar-color: var(--border-subtle) transparent;
}
.history-table-wrap::-webkit-scrollbar {
height: 4px;
}
.history-table-wrap::-webkit-scrollbar-track {
background: transparent;
}
.history-table-wrap::-webkit-scrollbar-thumb {
background: var(--border-subtle);
border-radius: 2px;
}
.history-table {
width: 100%;
border-collapse: collapse;
font-family: var(--font-mono);
font-size: 0.75rem;
}
.history-table thead th {
font-family: var(--font-display);
font-size: 0.68rem;
font-weight: 500;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.08em;
text-align: left;
padding: 10px 16px;
border-bottom: 1px solid var(--border-subtle);
white-space: nowrap;
}
.history-table tbody tr {
transition: background 0.15s ease;
}
.history-table tbody tr:hover {
background: rgba(30, 41, 59, 0.3);
}
.history-table tbody td {
padding: 8px 16px;
color: var(--text-secondary);
white-space: nowrap;
border-bottom: 1px solid rgba(30, 41, 59, 0.3);
}
.history-table tbody tr:last-child td {
border-bottom: none;
}
.type-badge {
display: inline-block;
font-family: var(--font-mono);
font-size: 0.68rem;
font-weight: 600;
padding: 2px 10px;
border-radius: 10px;
letter-spacing: 0.04em;
}
.type-badge.type-asr {
color: var(--accent-cyan);
background: var(--accent-cyan-dim);
}
.type-badge.type-llm {
color: var(--accent-amber);
background: var(--accent-amber-dim);
}
.status-badge {
display: inline-block;
font-family: var(--font-mono);
font-size: 0.68rem;
font-weight: 600;
padding: 2px 10px;
border-radius: 10px;
letter-spacing: 0.04em;
}
.status-badge.status-ok {
color: var(--accent-green);
background: rgba(0, 230, 118, 0.1);
}
.status-badge.status-err {
color: var(--accent-red);
background: var(--accent-red-dim);
}
.history-empty {
padding: 24px;
text-align: center;
font-family: var(--font-mono);
font-size: 0.75rem;
color: var(--text-muted);
opacity: 0.5;
}
/* ---- Activity Log ---- */
.log-section {
opacity: 0;
animation: fadeSlideUp 0.5s ease 1.1s forwards;
}
.log-container {
background: var(--bg-card);
border: 1px solid var(--border-subtle);
border-radius: 12px;
overflow: hidden;
}
.log-header {
padding: 16px 20px 12px;
border-bottom: 1px solid var(--border-subtle);
font-family: var(--font-display);
font-size: 0.78rem;
font-weight: 500;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.1em;
}
.log-entries {
max-height: 200px;
overflow-y: auto;
padding: 8px 0;
scrollbar-width: thin;
scrollbar-color: var(--border-subtle) transparent;
}
.log-entries::-webkit-scrollbar {
width: 4px;
}
.log-entries::-webkit-scrollbar-track {
background: transparent;
}
.log-entries::-webkit-scrollbar-thumb {
background: var(--border-subtle);
border-radius: 2px;
}
.log-entry {
padding: 6px 20px;
font-family: var(--font-mono);
font-size: 0.72rem;
color: var(--text-muted);
display: flex;
gap: 12px;
line-height: 1.5;
transition: background 0.15s ease;
}
.log-entry:hover {
background: rgba(30, 41, 59, 0.3);
}
.log-time {
color: var(--text-secondary);
white-space: nowrap;
flex-shrink: 0;
}
.log-msg { flex: 1; }
.log-msg.status-running { color: var(--accent-cyan); }
.log-msg.status-terminated { color: var(--accent-red); }
.log-msg.status-error { color: var(--accent-red); opacity: 0.8; }
.log-msg.status-action { color: var(--accent-amber); }
.log-empty {
padding: 20px;
text-align: center;
font-family: var(--font-mono);
font-size: 0.75rem;
color: var(--text-muted);
opacity: 0.5;
}
/* ---- Footer ---- */
.footer {
text-align: center;
padding: 24px 0 8px;
font-family: var(--font-mono);
font-size: 0.7rem;
color: var(--text-muted);
opacity: 0;
animation: fadeSlideUp 0.5s ease 1.2s forwards;
}
/* ---- Animations ---- */
@keyframes fadeSlideDown {
from { opacity: 0; transform: translateY(-12px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes fadeSlideUp {
from { opacity: 0; transform: translateY(16px); }
to { opacity: 1; transform: translateY(0); }
}
/* ---- Responsive ---- */
@media (max-width: 768px) {
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
.header-title {
font-size: 0.85rem;
letter-spacing: 0.15em;
}
.stat-value {
font-size: 1.5rem;
}
.controls {
flex-direction: column;
align-items: center;
}
.btn {
width: 100%;
max-width: 280px;
}
.instance-grid {
grid-template-columns: repeat(2, 1fr);
}
.zone-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
}
.zone-block {
min-width: 0;
}
}
@media (max-width: 480px) {
.stats-grid {
grid-template-columns: 1fr;
}
.container {
padding: 0 16px 32px;
}
.status-ring-container {
width: 100px;
height: 100px;
}
.status-ring {
width: 100px;
height: 100px;
}
.status-label {
font-size: 1.3rem;
}
.header {
flex-direction: column;
gap: 12px;
text-align: center;
}
.instance-grid {
grid-template-columns: 1fr;
}
.zone-grid {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<div class="error-banner" id="errorBanner">
<span id="errorText">Connection error: unable to reach proxy</span>
</div>
<div class="container">
<!-- Header -->
<header class="header">
<h1 class="header-title"><span>DICTIA</span> GPU MONITOR</h1>
<div class="proxy-badge" id="proxyBadge">
<div class="proxy-badge-dot"></div>
<span id="proxyStatus">proxy: connecting...</span>
</div>
</header>
<!-- Hero Status -->
<section class="hero">
<div class="status-ring-container">
<div class="status-ring" id="statusRing">
<div class="status-ring-inner"></div>
</div>
</div>
<div class="status-label" id="statusLabel">---</div>
<div class="zone-label" id="zoneLabel">---</div>
<div class="active-requests-badge" id="activeRequestsBadge">
<span id="activeRequestsText">0 active requests</span>
</div>
</section>
<!-- Stats Grid -->
<section class="stats-grid">
<div class="stat-card">
<div class="stat-value" id="gpuTime">--</div>
<div class="stat-label">GPU Time</div>
<div class="stat-sublabel">This Month</div>
</div>
<div class="stat-card">
<div class="stat-value" id="estCost">--</div>
<div class="stat-label">Estimated Cost</div>
<div class="stat-sublabel" id="costBreakdown">USD</div>
</div>
<div class="stat-card">
<div class="stat-value" id="reqCount">--</div>
<div class="stat-label">Total Requests</div>
<div class="stat-sublabel">This Month</div>
</div>
<div class="stat-card">
<div class="stat-value" id="budgetLeft">--</div>
<div class="stat-label">Remaining</div>
<div class="stat-sublabel" id="budgetOfLabel">of --h</div>
</div>
</section>
<!-- Budget Bar -->
<section class="budget-section">
<div class="budget-bar-container">
<div class="budget-bar-header">
<span class="budget-bar-title">Monthly Budget</span>
<span class="budget-bar-percent" id="budgetPercent">--%</span>
</div>
<div class="budget-bar-track">
<div class="budget-bar-fill" id="budgetFill" style="width: 0%"></div>
</div>
<div class="budget-bar-text" id="budgetText">--h / --h</div>
</div>
</section>
<!-- Controls -->
<section class="controls">
<button class="btn btn-start" id="btnStart" onclick="gpuAction('start')" disabled>
<span class="spinner"></span>
<span class="btn-text">START GPU</span>
</button>
<button class="btn btn-stop" id="btnStop" onclick="gpuAction('stop')" disabled>
<span class="spinner"></span>
<span class="btn-text">STOP GPU</span>
</button>
</section>
<!-- Instance Details -->
<section class="instance-section" id="instanceSection">
<div class="instance-card">
<div class="instance-card-header">Instance Details</div>
<div class="instance-grid" id="instanceGrid">
<div class="instance-item">
<div class="instance-item-label">IP</div>
<div class="instance-item-value" id="instIp">---</div>
</div>
<div class="instance-item">
<div class="instance-item-label">Machine</div>
<div class="instance-item-value" id="instMachine">---</div>
</div>
<div class="instance-item">
<div class="instance-item-label">GPU</div>
<div class="instance-item-value" id="instGpu">---</div>
</div>
<div class="instance-item">
<div class="instance-item-label">Idle</div>
<div class="instance-item-value" id="instIdle">---</div>
</div>
<div class="instance-item">
<div class="instance-item-label">OAuth Token</div>
<div class="instance-item-value" id="instToken">---</div>
</div>
<div class="instance-item">
<div class="instance-item-label">Cost Rate</div>
<div class="instance-item-value" id="instCostRate">---</div>
</div>
</div>
</div>
</section>
<!-- Zone Fallback Map -->
<section class="zone-section" id="zoneSection">
<div class="zone-card">
<div class="zone-card-header">Zone Fallback Map</div>
<div class="zone-grid" id="zoneGrid">
<!-- Populated by JS -->
</div>
</div>
</section>
<!-- Request History -->
<section class="history-section" id="historySection">
<div class="history-card">
<div class="history-card-header">Request History</div>
<div class="history-table-wrap">
<table class="history-table" id="historyTable">
<thead>
<tr>
<th>Time</th>
<th>Type</th>
<th>Duration</th>
<th>Status</th>
<th>Zone</th>
</tr>
</thead>
<tbody id="historyBody">
<tr><td colspan="5"><div class="history-empty">No requests yet</div></td></tr>
</tbody>
</table>
</div>
</div>
</section>
<!-- Event Log -->
<section class="log-section">
<div class="log-container">
<div class="log-header">Event Log</div>
<div class="log-entries" id="logEntries">
<div class="log-empty">Waiting for data...</div>
</div>
</div>
</section>
<!-- Footer -->
<div class="footer">
Last updated: <span id="lastUpdated">--:--:--</span>
</div>
</div>
<script>
(function() {
// ---------- State ----------
let lastGpuState = null;
let logItems = [];
const MAX_LOG = 10;
let actionInProgress = false;
// ---------- DOM refs ----------
const $ = id => document.getElementById(id);
const els = {
errorBanner: $('errorBanner'),
errorText: $('errorText'),
proxyBadge: $('proxyBadge'),
proxyStatus: $('proxyStatus'),
statusRing: $('statusRing'),
statusLabel: $('statusLabel'),
zoneLabel: $('zoneLabel'),
activeReqBadge: $('activeRequestsBadge'),
activeReqText: $('activeRequestsText'),
gpuTime: $('gpuTime'),
estCost: $('estCost'),
reqCount: $('reqCount'),
budgetLeft: $('budgetLeft'),
budgetOfLabel: $('budgetOfLabel'),
budgetPercent: $('budgetPercent'),
budgetFill: $('budgetFill'),
budgetText: $('budgetText'),
btnStart: $('btnStart'),
btnStop: $('btnStop'),
logEntries: $('logEntries'),
lastUpdated: $('lastUpdated'),
};
// ---------- Helpers ----------
function formatTime(hours) {
const h = Math.floor(hours);
const m = Math.round((hours - h) * 60);
if (h === 0) return `${m}m`;
if (m === 0) return `${h}h`;
return `${h}h ${m}m`;
}
function formatHours(hours) {
return hours.toFixed(1) + 'h';
}
function timestamp() {
return new Date().toLocaleTimeString('en-US', { hour12: false });
}
function addLog(msg, statusClass) {
logItems.unshift({ time: timestamp(), msg, statusClass });
if (logItems.length > MAX_LOG) logItems.pop();
renderLog();
}
function renderLog() {
if (logItems.length === 0) {
els.logEntries.innerHTML = '<div class="log-empty">Waiting for data...</div>';
return;
}
els.logEntries.innerHTML = logItems.map(item =>
`<div class="log-entry">
<span class="log-time">${item.time}</span>
<span class="log-msg ${item.statusClass || ''}">${item.msg}</span>
</div>`
).join('');
}
function showError(msg) {
els.errorText.textContent = msg;
els.errorBanner.classList.add('visible');
}
function hideError() {
els.errorBanner.classList.remove('visible');
}
// ---------- Fetch & Update ----------
async function fetchData() {
try {
const [healthRes, statsRes] = await Promise.all([
fetch('/health'),
fetch('/stats'),
]);
if (!healthRes.ok || !statsRes.ok) {
throw new Error(`HTTP ${healthRes.status} / ${statsRes.status}`);
}
const health = await healthRes.json();
const stats = await statsRes.json();
hideError();
updateDashboard(health, stats);
updateAdvanced(health, stats);
} catch (err) {
showError('Connection error: ' + err.message);
addLog('Fetch failed: ' + err.message, 'status-error');
}
}
function updateDashboard(health, stats) {
// Proxy status
const proxyOk = health.proxy === 'healthy';
els.proxyStatus.textContent = proxyOk ? 'proxy: healthy' : 'proxy: ' + health.proxy;
els.proxyBadge.classList.toggle('unhealthy', !proxyOk);
// GPU status
const gpuState = (health.gpu_instance || 'unknown').toUpperCase();
const isRunning = gpuState === 'RUNNING';
els.statusRing.classList.toggle('running', isRunning);
els.statusLabel.textContent = gpuState;
els.statusLabel.classList.toggle('running', isRunning);
// Zone
els.zoneLabel.textContent = health.gpu_zone || stats.active_zone || '---';
// Active requests
const activeReq = health.active_requests || 0;
if (activeReq > 0) {
els.activeReqBadge.classList.add('visible');
els.activeReqText.textContent = activeReq + ' active request' + (activeReq !== 1 ? 's' : '');
} else {
els.activeReqBadge.classList.remove('visible');
}
// Log state changes
if (lastGpuState !== null && lastGpuState !== gpuState) {
addLog(`GPU state changed: ${lastGpuState} \u2192 ${gpuState}`, isRunning ? 'status-running' : 'status-terminated');
} else if (lastGpuState === null) {
addLog(`Dashboard initialized \u2014 GPU: ${gpuState}`, isRunning ? 'status-running' : 'status-terminated');
}
lastGpuState = gpuState;
// Stats cards
const gpuHours = stats.gpu_hours || health.usage?.gpu_hours || 0;
els.gpuTime.textContent = formatTime(gpuHours);
const cost = stats.estimated_cost_usd;
els.estCost.textContent = cost != null ? '$' + cost.toFixed(2) : '--';
const gpuCost = stats.gpu_cost_usd;
const fixedCost = stats.fixed_cost_usd;
const breakdownEl = $('costBreakdown');
if (breakdownEl && gpuCost != null && fixedCost != null) {
breakdownEl.textContent = 'GPU $' + gpuCost.toFixed(2) + ' + Infra $' + fixedCost.toFixed(2);
}
const requests = stats.requests_count != null ? stats.requests_count : (health.usage?.requests_count || 0);
els.reqCount.textContent = requests;
const remaining = stats.remaining_hours != null ? stats.remaining_hours : 0;
els.budgetLeft.textContent = formatHours(remaining);
const limit = stats.monthly_limit_hours || health.usage?.gpu_limit_hours || 50;
els.budgetOfLabel.textContent = 'of ' + limit + 'h';
// Budget bar
const used = gpuHours;
const pct = limit > 0 ? Math.min((used / limit) * 100, 100) : 0;
els.budgetPercent.textContent = pct.toFixed(1) + '%';
els.budgetFill.style.width = pct + '%';
els.budgetText.textContent = used.toFixed(2) + 'h / ' + limit.toFixed(1) + 'h';
// Color the percent based on usage
if (pct > 80) {
els.budgetPercent.style.color = 'var(--accent-red)';
} else if (pct > 50) {
els.budgetPercent.style.color = 'var(--accent-amber)';
} else {
els.budgetPercent.style.color = 'var(--accent-cyan)';
}
// Buttons
if (!actionInProgress) {
els.btnStart.disabled = isRunning;
els.btnStop.disabled = !isRunning;
}
// Timestamp
els.lastUpdated.textContent = timestamp();
}
// ---------- Advanced Monitoring ----------
function formatGpuModel(raw) {
if (!raw) return '---';
// "nvidia-l4" → "NVIDIA L4", "nvidia-tesla-t4" → "NVIDIA Tesla T4"
return raw
.split('-')
.map(function(part) {
if (part.toLowerCase() === 'nvidia') return 'NVIDIA';
if (part.toLowerCase() === 'tesla') return 'Tesla';
return part.toUpperCase();
})
.join(' ');
}
function formatSecondsShort(sec) {
if (sec == null) return '---';
const m = Math.floor(sec / 60);
const s = Math.floor(sec % 60);
if (m > 0) return m + 'm ' + s + 's';
return s + 's';
}
function formatMinutesFromSec(sec) {
if (sec == null) return '---';
const m = Math.floor(sec / 60);
const s = Math.floor(sec % 60);
return m + 'm ' + s + 's';
}
function tokenExpiryHtml(sec) {
if (sec == null) return '---';
const m = Math.floor(sec / 60);
let cls, label;
if (sec < 60) {
cls = 'color-red';
label = 'expires in ' + sec + 's';
} else if (sec < 300) {
cls = 'color-amber';
label = 'expires in ' + m + 'm';
} else {
cls = 'color-green';
label = 'expires in ' + m + 'm';
}
return '<span class="sub-text ' + cls + '">' + label + '</span>';
}
function updateAdvanced(health, stats) {
// ---- Instance Details ----
const ip = health.gpu_ip;
$('instIp').textContent = ip || '---';
$('instMachine').textContent = health.machine_type || '---';
$('instGpu').textContent = formatGpuModel(health.gpu_model);
// Idle with shutdown countdown
const idleSec = health.idle_seconds;
const shutdownIn = health.auto_shutdown_in;
let idleHtml = formatSecondsShort(idleSec);
if (shutdownIn != null) {
idleHtml += ' <span class="sub-text color-amber">shutdown in ' + formatSecondsShort(shutdownIn) + '</span>';
}
$('instIdle').innerHTML = idleHtml;
// OAuth token
const tokenSec = health.token_expires_in;
if (tokenSec != null) {
const m = Math.floor(tokenSec / 60);
$('instToken').innerHTML = m + 'm' + tokenExpiryHtml(tokenSec);
} else {
$('instToken').textContent = '---';
}
// Cost rate
const costRate = stats.cost_per_hour;
$('instCostRate').textContent = costRate != null ? '$' + costRate.toFixed(2) + '/hr' : '---';
// ---- Zone Fallback Map ----
const zoneGrid = $('zoneGrid');
const fallbacks = stats.zone_fallbacks;
const activeZone = stats.active_zone || health.gpu_zone || '';
if (fallbacks && fallbacks.length > 0) {
zoneGrid.innerHTML = fallbacks.map(function(z) {
const st = (z.status || 'unknown').replace(/\s+/g, '_');
const isActive = z.label === activeZone;
const cls = 'zone-block status-' + st + (isActive ? ' active-zone' : '');
const gpuLabel = formatGpuModel(z.gpu);
const lastTried = z.last_tried ? z.last_tried.split('T')[1].substring(0, 5) : '--:--';
const statusText = (z.status || 'unknown').replace(/_/g, ' ');
return '<div class="' + cls + '">' +
'<div class="zone-block-top">' +
'<div class="zone-dot"></div>' +
'<div class="zone-block-label">' + escapeHtml(z.label) + '</div>' +
'</div>' +
'<div class="zone-block-gpu">' + escapeHtml(gpuLabel) + '</div>' +
'<div class="zone-block-status">' + escapeHtml(statusText) + '</div>' +
'<div class="zone-block-tried">last tried: ' + lastTried + '</div>' +
'</div>';
}).join('');
} else {
zoneGrid.innerHTML = '<div style="color:var(--text-muted);font-family:var(--font-mono);font-size:0.75rem;opacity:0.5;padding:8px;">No zone data available</div>';
}
// ---- Request History Table ----
const historyBody = $('historyBody');
const recent = stats.recent_requests;
if (recent && recent.length > 0) {
historyBody.innerHTML = recent.map(function(req) {
// Time: HH:MM:SS
let timeStr = '---';
if (req.time) {
const tPart = req.time.split('T')[1];
timeStr = tPart ? tPart.substring(0, 8) : req.time;
}
// Type badge
const typeLower = (req.type || '').toLowerCase();
const typeCls = typeLower === 'asr' ? 'type-asr' : 'type-llm';
const typeBadge = '<span class="type-badge ' + typeCls + '">' + escapeHtml(req.type || '---') + '</span>';
// Duration
let durStr = '---';
if (req.duration_sec != null) {
if (req.duration_sec >= 60) {
const dm = Math.floor(req.duration_sec / 60);
const ds = Math.floor(req.duration_sec % 60);
durStr = dm + 'm ' + ds + 's';
} else {
durStr = req.duration_sec.toFixed(1) + 's';
}
}
// Status badge
const statusOk = req.status >= 200 && req.status < 300;
const statusCls = statusOk ? 'status-ok' : 'status-err';
const statusBadge = '<span class="status-badge ' + statusCls + '">' + (req.status || '---') + '</span>';
// Zone (muted)
const zone = req.zone || '---';
return '<tr>' +
'<td>' + timeStr + '</td>' +
'<td>' + typeBadge + '</td>' +
'<td>' + durStr + '</td>' +
'<td>' + statusBadge + '</td>' +
'<td style="color:var(--text-muted)">' + escapeHtml(zone) + '</td>' +
'</tr>';
}).join('');
} else {
historyBody.innerHTML = '<tr><td colspan="5"><div class="history-empty">No requests yet</div></td></tr>';
}
}
function escapeHtml(str) {
const d = document.createElement('div');
d.textContent = str;
return d.innerHTML;
}
// ---------- GPU Actions ----------
window.gpuAction = async function(action) {
const btn = action === 'start' ? els.btnStart : els.btnStop;
const endpoint = action === 'start' ? '/gpu/start' : '/gpu/stop';
btn.classList.add('loading');
btn.disabled = true;
els.btnStart.disabled = true;
els.btnStop.disabled = true;
actionInProgress = true;
addLog(`Sending ${action.toUpperCase()} command...`, 'status-action');
try {
const res = await fetch(endpoint, { method: 'POST' });
if (!res.ok) {
const body = await res.text();
throw new Error(`HTTP ${res.status}: ${body}`);
}
const data = await res.json().catch(() => ({}));
addLog(`${action.toUpperCase()} command accepted`, 'status-action');
// Small delay then refresh to let the backend state propagate
await new Promise(r => setTimeout(r, 2000));
await fetchData();
} catch (err) {
addLog(`${action.toUpperCase()} failed: ${err.message}`, 'status-error');
showError(`Action failed: ${err.message}`);
} finally {
btn.classList.remove('loading');
actionInProgress = false;
// Re-enable will happen on next fetchData
await fetchData();
}
};
// ---------- Init ----------
fetchData();
setInterval(fetchData, 10000);
})();
</script>
</body>
</html>