1535 lines
41 KiB
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>
|