src/dashboard/index.html
Rama master · réplica de lectura del repositorio original.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title data-i18n="pageTitle.main">WindsurfAPI bydwgx1337 控制台</title>
<script>
// Set theme before first paint to avoid a light-cream FOUC (default is dark).
try {
var t = localStorage.getItem('dashboard_theme') || 'dark';
if (t === 'dark') document.documentElement.setAttribute('data-theme', 'dark');
} catch (e) { document.documentElement.setAttribute('data-theme', 'dark'); }
</script>
<style>
:root {
color-scheme: light; /* native controls (date pickers, scrollbars) = light */
/* ── Cool-neutral light theme (pre-workbuddy KiroStudio palette: zinc
greys, clay accent — no warm cast). Ratios measured 2026-10-25 with
WCAG 2.2 sRGB math; see evidence/DashboardColor.md for the palette
table and every new pair. ── */
--paper: #f4f4f5; /* neutral grey-white main background */
--paper-2: #ececee; /* secondary surface */
--paper-3: #e2e2e5; /* faint zone base */
--ink: #18181b; /* neutral near-black (zinc-900) */
--ink-soft: #3f3f46;
--mid-gray: #a1a1aa;
/* ── Surface / elevation ramp ────────────────────────────────────────────
One ladder, no parallel palette:
0 page --bg / --paper
1 card --surface
2 control --surface-2 (fields, segmented wells)
3 raised --surface-3 (chips, highest fill)
--bg-elev is the field step. Shadows pair with the same ramp:
--shadow-sm (card) → --shadow (hover/popover) → --shadow-lg (modal).
Adjacent steps keep ≥1.08 separation, asserted by
scripts/check-dashboard-contrast.mjs. */
--bg: var(--paper);
--bg-elev: var(--surface-2);
--surface: #ffffff;
--surface-2: var(--paper-2);
--surface-3: var(--paper-3);
--border: rgba(24,24,27,.10);
--border-strong: rgba(24,24,27,.18);
--border-control: rgba(24,24,27,.58); /* interactive boundary — ≥3:1 on every surface */
--text: var(--ink);
--text-muted: #52525b;
--text-dim: #626269; /* 6.05 surface / 5.13 surface-2 / 4.68 surface-3 — 10px labels sit on --surface-2 */
--text-faint: #a1a1aa; /* disabled + decorative only, never body copy */
/* Anthropic accent — signature clay orange. --accent is the fill/dot hue;
--accent-text is the AA label/link hue; --accent-hover/active/focus are
the interaction states (fills and rings). */
--accent: #d06b49; /* 3.57 surface / 3.04 accent-soft — fill only */
--accent-hover: #c86543;
--accent-active: #b85738;
--accent-focus: #a84a2e; /* 5.70 surface — 3:1 focus-ring floor */
--accent-text: #a3462b; /* 6.05 surface / 5.16 accent-soft / 4.68 surface-3 */
--accent-soft: rgba(208,107,73,.14);
--on-accent: #18181b; /* 4.96 on accent — white would be 3.57 */
--gradient-accent: linear-gradient(135deg, var(--accent-soft) 0%, transparent 72%);
--blue: #6a9bcc;
--green: #788c5d;
/* Status fills (--success/--warn/--error/--info) carry --on-accent labels
and 3:1 dots. The -text twins are the AA label hue on the matching
-soft wash (and on --surface). */
--success: #788c5d; /* 4.82 with --on-accent */
--success-hover: #7f9462; /* explicit hover fill — 5.33 with --on-accent */
--success-soft: rgba(120,140,93,.16);
--success-text: #4f6b36; /* 6.02 surface / 5.08 success-soft / 4.66 surface-3 */
--on-success: var(--on-accent);
--warn: #b07d1c; /* 4.89 with --on-accent */
--warn-hover: #b8892a; /* 5.61 with --on-accent */
--warn-soft: rgba(217,162,87,.16);
--warn-text: #8a5a00; /* 5.93 surface / 5.25 warn-soft / 4.58 surface-3 */
--on-warn: var(--on-accent);
--error: #cc6658; /* 4.73 with --on-accent */
--error-hover: #d47466; /* 5.47 with --on-accent */
--error-soft: rgba(198,93,78,.14);
--error-text: #ad392c; /* 6.18 surface / 5.19 error-soft / 4.78 surface-3 */
--on-error: var(--on-accent);
--info: #5b8cc0; /* 5.03 with --on-accent */
--info-hover: #6a9bcc; /* 5.23 with --on-accent */
--info-soft: rgba(106,155,204,.16);
--info-text: #2a6599; /* 6.14 surface / 5.29 info-soft / 4.75 surface-3 */
--on-info: var(--on-accent);
--think: #6b5b95;
--think-soft: rgba(107,91,149,.12);
--tip-bg: rgba(24,24,27,.98);
--tip-fg: #fafafa;
--tip-border: rgba(250,250,250,.14);
/* Overlay scrims — cool ink, matching the zinc ladder. */
--scrim: rgba(24,24,27,.45);
--scrim-strong: rgba(24,24,27,.70);
/* data-viz palette: req = blue line+area, succ = green rate, err = red. */
--chart-req: #4c9aff;
--chart-succ: #4a9d6a;
--chart-err: #e0524a;
--chart-blue: #4c9aff;
--grid: rgba(24,24,27,.09);
/* geometry — 12 card / 8 control / 14 section */
--radius: 12px;
--radius-sm: 8px;
--radius-lg: 14px;
--radius-xs: 4px;
--page-pad: clamp(16px, 2vw, 36px);
/* ── Spacing scale (UISpec P2) ───────────────────────────────────────────
Tokens are named by their px value so a rule reads without counting steps:
--space-12 IS 12px. Even values only (2px grid); 1px stays a literal
hairline. The account + usage surfaces below use these instead of raw px,
enforced by scripts/check-dashboard-scale.mjs — a new off-scale padding
fails the gate instead of quietly widening the drift. */
--space-2: 2px;
--space-4: 4px;
--space-6: 6px;
--space-8: 8px;
--space-10: 10px;
--space-12: 12px;
--space-14: 14px;
--space-16: 16px;
--space-20: 20px;
--space-24: 24px;
--space-32: 32px;
/* 44 px row budget. The comfortable account row lands on 44 with 5 px of
vertical cell padding (see the .acct-tier-stack note); 5 is deliberately
NOT on the 2 px grid so a scale sweep cannot silently move the row height. */
--acct-row-pad-y: 5px;
/* ── Type scale (UISpec P2) ──────────────────────────────────────────────
One dense ladder for the console. 3xs/2xs are table micro-labels, xs/sm
are meta, md/lg body, xl+ headings and KPI values. */
--fs-3xs: 9px;
--fs-2xs: 10px;
--fs-xs: 11px;
--fs-sm: 12px;
--fs-md: 13px;
--fs-lg: 14px;
--fs-xl: 15px;
--fs-2xl: 18px;
--fs-3xl: 22px;
--fs-4xl: 24px;
/* ── Provider swatches (UISpec P2-12) ────────────────────────────────────
Brand-adjacent hues tuned to ≥3:1 against --surface in THIS theme
(graphical-object rule, WCAG 1.4.11) — the old hardcoded set was 2.4–2.9
on one theme or the other. The dark block carries the lighter ramp because
the dark surface is #141414. Recomputed by
scripts/check-dashboard-contrast.mjs; the swatch never carries information
alone (the provider name sits next to it). */
--prov-anthropic: #b45309;
--prov-openai: #047857;
--prov-google: #1a56db;
--prov-deepseek: #6d28d9;
--prov-xai: #be123c;
--prov-alibaba: #be185d;
--prov-moonshot: #0e7490;
--prov-zhipu: #7e22ce;
--prov-minimax: #b91c1c;
--prov-windsurf: #4338ca;
--prov-other: #52525b;
/* OAuth brand blocks. White on Google's #4285F4 is 3.56 (AA fail for text);
the deeper blue clears 4.5 while staying recognisably Google. */
--brand-google-bg: #1967d2;
--brand-google-fg: #ffffff;
--brand-google-bg-hover: #1558d6;
--brand-github-bg: #24292e;
--brand-github-fg: #ffffff;
--brand-github-bg-hover: #1b1f23;
--shadow-sm: 0 1px 2px rgba(24,24,27,.05), 0 1px 3px rgba(24,24,27,.08);
--shadow: 0 2px 4px rgba(24,24,27,.04), 0 4px 12px rgba(24,24,27,.06);
--shadow-lg: 0 8px 24px rgba(24,24,27,.10), 0 2px 8px rgba(24,24,27,.06);
--ink-shadow-soft: var(--shadow-sm);
/* font stacks — local system sans (no Google Fonts dependency) */
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', system-ui, sans-serif;
--font-display: ui-serif, Georgia, 'Source Han Serif SC', 'SimSun', serif;
--serif: var(--font-display);
--hand: var(--font);
--mono: 'Cascadia Code', 'Consolas', 'SFMono-Regular', monospace;
}
/* ── Dark theme (toggle via [data-theme="dark"] on <html>) ── */
[data-theme="dark"] {
color-scheme: dark; /* native controls follow the dark theme */
/* ── Cool-neutral dark theme (Vercel-style near-black, zero warm cast;
default theme). Each surface step keeps ≥1.08 separation from the one
below it so a card still reads as a card on near-black. ── */
--paper: #080808;
--paper-2: #141414;
--paper-3: #1f1f1f;
--ink: #ededed;
--ink-soft: #c4c4c6;
--mid-gray: #8a8a8f;
--bg: var(--paper);
--bg-elev: var(--surface-2);
--surface: #141414;
--surface-2: #1d1d1d;
--surface-3: #262626;
--border: rgba(255,255,255,.10);
--border-strong: rgba(255,255,255,.18);
--border-control: rgba(255,255,255,.42); /* interactive boundary — ≥3:1 on every surface */
--text: var(--ink);
--text-muted: #a1a1aa;
--text-dim: #90909a; /* 5.83 on surface — was #71717a (3.81 FAIL) */
--text-faint: #71717a;
--accent: #d97757; /* 5.90 — fill only */
--accent-hover: #c86543;
--accent-active: #b85f42;
--accent-focus: #e89a7d; /* 8.20 surface */
--accent-text: #df8a6b; /* 7.01 surface / 5.28 accent-soft */
--accent-soft: rgba(217,119,87,.20);
--on-accent: #141414; /* 5.86 — white would be 3.12 */
--gradient-accent: linear-gradient(135deg, var(--accent-soft) 0%, transparent 72%);
--success: #788c5d; /* 5.01 */
--success-hover: #86996a; /* 5.95 with --on-accent */
--success-soft: rgba(120,140,93,.22);
--success-text: #96b277; /* 7.84 surface / 5.90 success-soft */
--on-success: var(--on-accent);
--warn: #d9a257; /* 8.12 */
--warn-hover: #e3b06b; /* 9.38 with --on-accent */
--warn-soft: rgba(217,162,87,.22);
--warn-text: #d9a95f; /* 8.59 surface / 5.73 warn-soft */
--on-warn: var(--on-accent);
--error: #cc6658; /* 4.92 */
--error-hover: #d97a6c; /* 6.10 with --on-accent */
--error-soft: rgba(198,93,78,.22);
--error-text: #da8578; /* 6.67 surface / 5.21 error-soft */
--on-error: var(--on-accent);
--info: #6a9bcc; /* 6.29 */
--info-hover: #8abcf0; /* 9.24 with --on-accent */
--info-soft: rgba(106,155,204,.22);
--info-text: #7cb2ee; /* 8.30 surface / 5.88 info-soft */
--on-info: var(--on-accent);
--think: #b3a4d0;
--think-soft: rgba(179,164,208,.16);
--blue: #6a9bcc;
--green: #788c5d;
--grid: rgba(255,255,255,.09);
--tip-bg: rgba(28,28,30,.98);
--tip-fg: var(--text);
--tip-border: rgba(255,255,255,.14);
--scrim: rgba(0,0,0,.55);
--scrim-strong: rgba(0,0,0,.70);
/* Provider swatches: lighter ramp for the dark surface (≥3:1 on #141414). */
--prov-anthropic: #f0a86e;
--prov-openai: #6ee7b7;
--prov-google: #93c5fd;
--prov-deepseek: #c4b5fd;
--prov-xai: #fda4af;
--prov-alibaba: #f9a8d4;
--prov-moonshot: #67e8f9;
--prov-zhipu: #d8b4fe;
--prov-minimax: #fca5a5;
--prov-windsurf: #a5b4fc;
--prov-other: #a1a1aa;
--shadow-sm: 0 1px 2px rgba(0,0,0,.30), 0 1px 3px rgba(0,0,0,.40);
--shadow: 0 2px 4px rgba(0,0,0,.30), 0 4px 14px rgba(0,0,0,.40);
--shadow-lg: 0 8px 24px rgba(0,0,0,.50), 0 2px 8px rgba(0,0,0,.40);
}
/* dark: top inset highlight gives cards/sections a subtle glass depth */
[data-theme="dark"] .card,
[data-theme="dark"] .section {
box-shadow: inset 0 1px 0 rgba(255,255,255,.04), var(--shadow-sm);
}
[data-theme="dark"] .card:hover { box-shadow: inset 0 1px 0 rgba(255,255,255,.05), var(--shadow); }
* { margin: 0; padding: 0; box-sizing: border-box; }
*::before, *::after { box-sizing: border-box; }
html, body { height: 100%; }
/* Accessibility: honor reduced-motion. Decorative/idle animation (chart header
shimmer, gacha pulses, etc.) is suppressed; transitions collapse to instant.
Operational spinners keep a minimal animation so "loading" stays legible. */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: .001ms !important;
animation-iteration-count: 1 !important;
transition-duration: .001ms !important;
scroll-behavior: auto !important;
}
/* A loading spinner is informative motion, not decorative — keep it spinning
(slower) so "loading" is still conveyed under reduced-motion. */
.spinner { animation-duration: .8s !important; animation-iteration-count: infinite !important; }
}
body {
font-family: var(--font);
background-color: var(--paper);
color: var(--text);
font-size: 14px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
display: flex;
min-height: 100vh;
}
/* Scrollbar */
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--surface-3); border-radius: 5px; border: 2px solid var(--bg); }
::-webkit-scrollbar-thumb:hover { background: var(--border-strong); }
a { color: var(--accent-text); text-decoration: none; }
code {
font-family: var(--mono);
font-size: 12px;
background: var(--surface-2);
color: var(--text);
padding: 2px 6px;
border-radius: 4px;
border: 1px solid var(--border);
}
/* ─── Sidebar ──────────────────────────────────── */
.sidebar {
width: 232px;
background: var(--surface);
border-right: 1px solid var(--border);
position: fixed;
top: 0; left: 0; bottom: 0;
display: flex;
flex-direction: column;
z-index: 10;
}
.sidebar .brand {
padding: 20px 22px 18px;
display: flex;
align-items: center;
gap: 10px;
border-bottom: 1px solid var(--border);
}
.sidebar .brand-logo {
width: 32px; height: 32px;
border-radius: 8px;
background: linear-gradient(135deg, var(--accent) 0%, var(--accent-hover) 100%);
display: flex; align-items: center; justify-content: center;
font-weight: 800; color: var(--on-accent); font-size: 15px;
box-shadow: 0 4px 12px var(--accent-soft);
}
.sidebar .brand-name { font-size: 15px; font-weight: 600; letter-spacing: -.01em; }
.sidebar .brand-sub { font-size: 12px; color: var(--text-dim); margin-top: 1px; }
.sidebar nav { flex: 1; padding: 12px 10px; overflow-y: auto; }
.sidebar nav .nav-group { margin-bottom: 18px; }
.sidebar nav .nav-group-label {
font-size: 12px;
text-transform: uppercase;
letter-spacing: .08em;
color: var(--text-dim);
padding: 0 12px 6px;
font-weight: 600;
}
.sidebar nav a {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
color: var(--text-muted);
font-size: 13px;
font-weight: 500;
border-radius: var(--radius-sm);
transition: all .15s;
margin-bottom: 2px;
}
.sidebar nav a:hover { color: var(--text); background: var(--surface-2); }
.sidebar nav a.active {
color: var(--text);
background: var(--surface-3);
box-shadow: inset 2px 0 0 var(--accent);
}
.sidebar nav a svg { width: 16px; height: 16px; stroke-width: 2; flex-shrink: 0; }
.sidebar .footer {
padding: 12px 18px;
font-size: 12px;
color: var(--text-dim);
border-top: 1px solid var(--border);
display: flex;
flex-direction: column;
gap: 8px;
}
.sidebar .footer-actions { display: flex; align-items: center; gap: 8px; }
.sidebar .footer-meta { display: flex; align-items: center; gap: 6px; white-space: nowrap; overflow: hidden; }
.sidebar .footer-meta .ver { overflow: hidden; text-overflow: ellipsis; }
.footer-icon-btn { display: inline-flex; align-items: center; justify-content: center; padding: 4px 6px; }
.footer-icon-btn svg { width: 15px; height: 15px; display: block; }
.sidebar .footer .ver { font-family: var(--mono); }
/* ─── Main Layout ─────────────────────────────── */
.main {
margin-left: 232px;
flex: 1; min-width: 0;
padding: 20px var(--page-pad) 60px;
min-height: 100vh;
max-width: 2000px;
/* min-width:0 lets this flex column shrink; .table-wrap owns horizontal scroll (accounts was 1628px @1440). */
}
.page-header {
position: relative;
isolation: isolate;
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24px;
gap: 16px;
flex-wrap: wrap;
}
/* Subtle accent wash behind the title/actions. Absolute, so it adds no box
to the layout and cannot move the header's 24px rhythm. */
.page-header::before {
content: "";
position: absolute;
inset: -10px -14px;
z-index: -1;
border-radius: var(--radius);
background: var(--gradient-accent);
pointer-events: none;
}
.page-title {
font-family: var(--font);
font-size: 24px;
font-weight: 650;
letter-spacing: -.02em;
}
.page-subtitle {
font-size: 12px;
color: var(--text-muted);
margin-top: 4px;
text-transform: uppercase;
letter-spacing: .22em;
}
.panel { display: none; animation: fadeIn .2s ease; }
.panel.active { display: block; }
@keyframes fadeIn {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
/* ─── Cards ───────────────────────────────────── */
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 16px 18px;
box-shadow: var(--shadow-sm);
transition: transform .18s cubic-bezier(.16,1,.3,1), box-shadow .18s cubic-bezier(.16,1,.3,1), border-color .18s;
/* Consistent shape so a row of KPI cards lines up (no "gnawed" ragged edges):
fixed min-height, header pinned top, value block pushed to a common baseline. */
display: flex;
flex-direction: column;
min-height: 104px;
}
.card:hover { transform: translateY(1px); box-shadow: var(--shadow-sm); border-color: var(--border-strong); }
/* ─── Self-Healing cells (C1) ─── */
.heal-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 10px; }
.heal-cell { border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 12px 14px; background: var(--surface); box-shadow: var(--shadow-sm); border-left: 3px solid var(--border); transition: border-color .18s, box-shadow .18s; }
.heal-cell.good { border-left-color: var(--green); }
.heal-cell.bad { border-left-color: var(--error); }
.heal-cell.neutral { border-left-color: var(--blue); }
.heal-cell:hover { box-shadow: var(--shadow-sm); transform: translateY(1px); }
.heal-cell .heal-num { font-size: 26px; font-weight: 700; font-variant-numeric: tabular-nums; line-height: 1.1; letter-spacing: -.02em; }
.heal-cell.good .heal-num { color: var(--success-text); }
.heal-cell.bad.hot .heal-num { color: var(--error-text); }
.heal-cell.neutral .heal-num { color: var(--info-text); }
.heal-cell.zero .heal-num { color: var(--text-dim); }
.heal-cell .heal-lbl { font-size: 12px; color: var(--text-muted); letter-spacing: .06em; text-transform: uppercase; margin-top: 2px; }
.heal-cell .heal-ok { font-size: 12px; color: var(--success-text); letter-spacing: .04em; }
@keyframes healflash { 0% { transform: scale(.94); opacity: .6; } 14% { transform: scale(1.06); opacity: 1; } 100% { transform: scale(1); opacity: 1; } }
.heal-num.bump { animation: healflash .45s cubic-bezier(.16,1,.3,1); }
/* ─── GlowGrid — account-pool "compute core" wall (KiroStudio-style) ───
Each account = a small breathing core, tinted by health, lit with a low-freq
glow. Hover lifts + intensifies the halo; a live hit flashes the core. */
.gg-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(24px, 1fr)); gap: 6px; }
.gg-core {
position: relative; aspect-ratio: 1; border-radius: 5px; cursor: pointer;
transition: transform .2s ease-out;
}
.gg-core:hover { z-index: 10; transform: translateY(-2px) scale(1.18); }
.gg-core-breathe {
position: absolute; inset: 0; border-radius: 5px; pointer-events: none;
animation: gg-breathe var(--gg-dur, 4.4s) ease-in-out infinite;
animation-delay: var(--gg-delay, 0s);
}
@keyframes gg-breathe { 0%,100% { opacity: .4; } 50% { opacity: .62; } }
.gg-core-spec { /* top lens highlight */
position: absolute; left: 2px; right: 2px; top: 2px; height: 33%;
border-radius: 999px; background: rgba(255,255,255,.12); filter: blur(1.5px);
pointer-events: none; transition: background .2s;
}
.gg-core:hover .gg-core-spec { background: rgba(255,255,255,.28); }
.gg-core-halo { /* hover outer glow, transparent until hover */
position: absolute; inset: 0; border-radius: 5px; pointer-events: none;
opacity: 0; transition: opacity .2s;
}
.gg-core:hover .gg-core-halo { opacity: 1; }
.gg-core-flash { /* live-hit: white core + tint burst, single replay */
position: absolute; inset: 0; border-radius: 5px; pointer-events: none;
animation: gg-flash .9s ease-out forwards;
}
@keyframes gg-flash { 0% { opacity: 1; transform: scale(.9); } 100% { opacity: 0; transform: scale(1.35); } }
@media (prefers-reduced-motion: reduce) {
.gg-core-breathe, .gg-core-flash { animation: none; }
.gg-core-breathe { opacity: .7; }
}
.gg-legend { margin-top: 14px; display: flex; flex-wrap: wrap; gap: 6px 16px; font-size: 12px; color: var(--text-muted); }
.gg-legend span.lg { display: inline-flex; align-items: center; gap: 6px; }
.gg-legend .lg-sw { width: 10px; height: 10px; border-radius: 3px; display: inline-block; }
/* Saturated core "on fire": a flickering ember glow layer (canvas-free, cheap —
only the handful of RPM-saturated cores get it). Two offset flame flickers +
a rising hue give a live-fire feel without a WebGL context. */
.gg-core.hot { z-index: 2; }
.gg-core .gg-flame {
position: absolute; inset: -2px; border-radius: 6px; pointer-events: none;
background:
radial-gradient(60% 80% at 30% 100%, rgba(255,90,20,.95), rgba(255,60,10,.25) 55%, transparent 72%),
radial-gradient(55% 75% at 68% 100%, rgba(255,170,40,.9), rgba(255,110,20,.2) 55%, transparent 72%),
radial-gradient(50% 60% at 50% 100%, rgba(255,230,120,.95), transparent 65%);
mix-blend-mode: screen; filter: blur(1px);
animation: gg-flame 2.2s ease-in-out infinite alternate;
}
.gg-core .gg-flame::after {
content: ''; position: absolute; inset: 0; border-radius: 6px;
box-shadow: 0 0 10px 2px rgba(255,120,30,.7), 0 -3px 8px 1px rgba(255,80,15,.55);
}
@keyframes gg-flame {
0% { transform: translateY(0) scaleY(1); opacity: .9; }
100% { transform: translateY(-1px) scaleY(1.14); opacity: 1; }
}
/* Live-hit flash: a request just flowed through this core — white core + tint
burst that scales out and fades once (re-triggered by remounting via key). */
.gg-core .gg-hit {
position: absolute; inset: 0; border-radius: 5px; pointer-events: none;
background: radial-gradient(circle at 50% 45%, rgba(255,255,255,.95), var(--gg-hit-tint, rgba(120,220,255,.6)) 55%, transparent 72%);
animation: gg-hit .7s ease-out forwards;
}
@keyframes gg-hit { 0% { opacity: 1; transform: scale(.85); } 100% { opacity: 0; transform: scale(1.4); } }
@media (prefers-reduced-motion: reduce) {
.gg-core .gg-flame, .gg-core .gg-hit { animation: none; }
}
/* ─── StatusBars view (dense ops-panel rows) ─── */
.sbar-list { display: flex; flex-direction: column; gap: 6px; }
.sbar {
position: relative; display: flex; align-items: center; gap: 10px;
height: 36px; padding: 0 12px 0 15px; border: 1px solid var(--border);
border-radius: 8px; overflow: hidden; cursor: pointer;
transition: border-color .15s, background .15s;
}
.sbar:hover { border-color: var(--border-strong); }
.sbar-band { position: absolute; inset: 0 auto 0 0; width: 3px; }
.sbar-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.sbar-id { font-family: var(--mono); font-size: 12px; font-variant-numeric: tabular-nums; color: var(--text); flex-shrink: 0; }
.sbar-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; color: var(--text-muted); }
.sbar-tier { flex-shrink: 0; font-family: var(--mono); font-size: 12px; text-transform: uppercase; letter-spacing: .04em; color: var(--text-muted); background: var(--surface-2); border-radius: 999px; padding: 2px 8px; }
.sbar-fire { flex-shrink: 0; font-size: 12px; }
.sbar-metric { flex-shrink: 0; font-family: var(--mono); font-size: 12px; font-variant-numeric: tabular-nums; text-align: right; min-width: 34px; }
.sbar-metric .u { font-size: 12px; color: var(--text-muted); opacity: 1; }
.sbar-rpm { color: var(--info-text); }
.sbar-inflight { color: var(--accent-text); min-width: 52px; }
.sbar-ok { color: var(--success-text); min-width: 30px; }
.sbar-err { color: var(--error-text); min-width: 26px; }
.sbar-muted { color: var(--text-dim); min-width: 26px; }
.sbar.hot { border-color: rgba(255,120,30,.5); box-shadow: 0 0 8px rgba(255,110,20,.25) inset; }
/* WebGL fire (KiroStudio FireCanvas port): a full-width burning band behind the
row content on saturated accounts. Absolutely positioned so it fills the row,
clipped to the border-radius by .sbar's overflow:hidden. `screen` blend makes
the flame add light over the dark row without a hard rectangle. Sits under the
text/metrics (z-index 0; children default above). Only mounted on the hottest
.hot rows (see _mountBarFires), capped by _FIRE_MAX. */
.sbar-fire-canvas {
position: absolute; inset: 0; width: 100%; height: 100%;
pointer-events: none; mix-blend-mode: screen; z-index: 0; opacity: .92;
}
.sbar.hot > *:not(.sbar-fire-canvas) { position: relative; z-index: 1; }
/* When the emoji fallback is used (no WebGL2) the row keeps its CSS glow. */
@media (prefers-reduced-motion: reduce) { .sbar-fire-canvas { display: none; } }
/* ─── Mouse-following account hover card (both pool views) ─── */
.pool-hover-card {
display: none; position: fixed; z-index: 300; pointer-events: none;
min-width: 180px; max-width: 260px; padding: 10px 12px;
background: var(--tip-bg); color: var(--tip-fg);
border: 1px solid var(--tip-border); border-radius: 10px;
box-shadow: var(--shadow-lg); font-size: 12px; line-height: 1.55;
animation: phc-rise .13s ease-out;
}
@keyframes phc-rise { from { opacity: 0; transform: translateY(3px); } to { opacity: 1; transform: none; } }
.phc-head { display: flex; align-items: center; gap: 6px; font-weight: 600; font-family: var(--mono); }
.phc-dot { width: 8px; height: 8px; border-radius: 50%; }
.phc-fire { font-size: var(--fs-sm); font-weight: 500; color: var(--warn-text); }
.phc-email { color: var(--text-muted); font-size: 12px; margin: 2px 0 6px; word-break: break-all; }
.phc-row { display: flex; justify-content: space-between; gap: 14px; }
.phc-k { color: var(--text-muted); }
.phc-v { font-variant-numeric: tabular-nums; }
/* ─── Rankings (C2) ─── */
.rank-row { margin-bottom: 10px; }
.rank-top { display: flex; align-items: center; gap: 8px; margin-bottom: 3px; }
.rank-no { width: 20px; height: 20px; flex: none; display: flex; align-items: center; justify-content: center; border-radius: 50%; font-family: var(--hand); font-weight: 700; font-size: 12px; background: var(--paper-3); color: var(--text-muted); }
.rank-row.first .rank-no { background: var(--accent-soft); color: var(--accent-text); border: 1.5px solid var(--accent); }
.rank-label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rank-val { font-variant-numeric: tabular-nums; color: var(--text-muted); }
.rank-track { height: 7px; border-radius: 9999px; background: var(--paper-3); overflow: hidden; }
.rank-row.first .rank-track { height: 9px; }
.rank-fill { height: 100%; border-radius: 9999px; width: 0; background: linear-gradient(90deg, var(--accent), var(--accent-hover)); transition: width 700ms cubic-bezier(.16,1,.3,1); }
.rank-sub { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 5px; padding-left: 28px; }
.rank-chip { display: inline-flex; align-items: center; gap: 4px; font-size: 12px; color: var(--text-muted); font-variant-numeric: tabular-nums; background: var(--paper-3); border-radius: 6px; padding: 1px 7px; line-height: 1.6; }
.rank-chip-dot { width: 6px; height: 6px; border-radius: 50%; flex: none; }
@media (prefers-reduced-motion: reduce) { .rank-fill { transition: none; } }
.card-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 8px;
min-height: 16px;
margin-bottom: 0;
}
/* Value block sits at the same vertical position across all cards regardless of
whether a card has an icon or a longer title. */
.card-title {
font-size: 12px;
font-weight: 500;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: .12em;
}
.card-icon {
width: 18px; height: 18px;
color: var(--text-dim);
}
.card-value {
font-size: 27px;
font-weight: 700;
letter-spacing: -.02em;
line-height: 1.2;
font-variant-numeric: tabular-nums;
margin-top: auto; /* pin the value block to a common baseline across the row */
}
.card-sub { font-size: 12px; color: var(--text-muted); margin-top: 4px; min-height: 15px; }
/* Loading skeleton: pure-CSS shimmer placeholder shown before data arrives.
The universal reduced-motion rule above freezes the shimmer automatically. */
.skeleton {
position: relative;
overflow: hidden;
background: var(--surface, rgba(255,255,255,.04));
border-radius: 6px;
color: transparent !important;
}
.skeleton::after {
content: "";
position: absolute;
inset: 0;
transform: translateX(-100%);
background: linear-gradient(90deg, transparent, var(--border, rgba(255,255,255,.08)), transparent);
animation: skeleton-shimmer 1.4s ease-in-out infinite;
}
@keyframes skeleton-shimmer { 100% { transform: translateX(100%); } }
.skeleton-line { height: 1em; margin: 6px 0; }
.skeleton-card { height: 96px; }
.batch-bar { display: flex; align-items: center; gap: 12px; padding: 8px 12px; margin-bottom: 10px; background: var(--surface, rgba(255,255,255,.04)); border: 1px solid var(--border, rgba(255,255,255,.08)); border-radius: 8px; }
.batch-bar .batch-count { font-size: var(--fs-md); font-weight: 600; color: var(--accent-text); }
.card.accent .card-value { color: var(--accent-text); }
.card.success .card-value { color: var(--success-text); }
.card.warn .card-value { color: var(--warn-text); }
.card.error .card-value { color: var(--error-text); }
.card.info .card-value { color: var(--info-text); }
/* semantic left rail + tinted icon so status reads at a glance in dark */
.card.success, .card.info, .card.warn, .card.error, .card.accent { border-left: 3px solid transparent; }
.card.success { border-left-color: var(--success); }
.card.info { border-left-color: var(--info); }
.card.warn { border-left-color: var(--warn); }
.card.error { border-left-color: var(--error); }
.card.accent { border-left-color: var(--accent); }
.card.success .card-icon { color: var(--success-text); }
.card.info .card-icon { color: var(--info-text); }
.card.warn .card-icon { color: var(--warn-text); }
.card.error .card-icon { color: var(--error-text); }
.card.accent .card-icon { color: var(--accent-text); }
.metrics-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 14px;
margin-bottom: 24px;
}
/* KPI/summary cards carry the same accent wash as the page header. The image
paints over --surface; no geometry, border or content changes. */
.metrics-grid .card { background-image: var(--gradient-accent); }
/* Overview has 6 KPI cards — use column counts that divide evenly (3×2 / 2×3)
so there is never a lone orphan card dangling on its own row. */
#overview-cards { grid-template-columns: repeat(3, 1fr); }
@media (max-width: 1100px) { #overview-cards { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 560px) { #overview-cards { grid-template-columns: 1fr; } }
/* ─── Section Block ───────────────────────────── */
.section {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
margin-bottom: 20px;
overflow: hidden;
box-shadow: var(--shadow-sm);
}
.section-header {
padding: 16px 20px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
/* Compact account-table density (App.toggleAccountsDensity): denser rows so a
large pool scans faster. Toggled via the accounts-list density button and
persisted in localStorage. */
#accounts-table.accounts-compact th,
#accounts-table.accounts-compact td {
padding-top: var(--acct-row-pad-y);
padding-bottom: var(--acct-row-pad-y);
font-size: var(--fs-sm);
}
#accounts-density-btn.active { background: var(--accent-soft); color: var(--accent-text); }
.section-title {
font-family: var(--font);
font-size: 16px;
font-weight: 650;
letter-spacing: -.02em;
}
.section-desc {
font-size: 12px;
color: var(--text-muted);
margin-top: 2px;
font-weight: 400;
letter-spacing: .02em;
}
.section-body { padding: 20px; }
.section-body.tight { padding: 0; }
/* ─── Buttons ─────────────────────────────────── */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 8px 16px;
font-size: 13px;
font-weight: 500;
font-family: var(--font);
border: 1px solid transparent;
border-radius: var(--radius-sm);
cursor: pointer;
transition: all .15s;
background: var(--surface-2);
color: var(--text);
white-space: nowrap;
line-height: 1.2;
}
.btn:hover:not(:disabled) { background: var(--surface-3); border-color: var(--border-strong); }
.btn:active:not(:disabled) { transform: translateY(1px); }
.btn:disabled { opacity: .5; cursor: not-allowed; }
.btn svg { width: 14px; height: 14px; }
.btn-primary {
background: var(--accent);
color: var(--on-accent);
border-color: var(--accent);
box-shadow: 0 1px 3px var(--accent-soft);
}
.btn-primary:hover:not(:disabled) { background: var(--accent-hover); border-color: var(--accent-hover); }
.btn-primary:active:not(:disabled) {
background: var(--accent-hover);
border-color: var(--accent-active);
box-shadow: inset 0 1px 2px rgba(0,0,0,.18);
}
.btn-outline {
background: transparent;
border-color: var(--border-control);
color: var(--text);
}
.btn-outline:hover:not(:disabled) { background: var(--surface-2); border-color: var(--accent); }
.btn-ghost { background: transparent; border-color: transparent; color: var(--text-muted); }
.btn-ghost:hover:not(:disabled) { background: var(--surface-2); color: var(--text); }
.btn-danger {
background: var(--error);
color: var(--on-error);
border-color: var(--error);
}
.btn-danger:hover:not(:disabled) {
background: var(--error-hover);
border-color: var(--error-hover);
color: var(--on-error);
}
.btn-success {
background: var(--success);
color: var(--on-success);
border-color: var(--success);
}
.btn-success:hover:not(:disabled) {
background: var(--success-hover);
border-color: var(--success-hover);
color: var(--on-success);
}
.btn-sm { padding: 5px 10px; font-size: 12px; }
.btn-xs { padding: 3px 8px; font-size: 12px; }
.btn-icon { padding: 7px; }
.btn-group { display: inline-flex; gap: 6px; flex-wrap: wrap; }
.btn.active, .btn.active:hover {
background: var(--accent); color: var(--on-accent); border-color: var(--accent);
}
/* ─── Switch ──────────────────────────────────── */
.switch { position: relative; display: inline-block; width: 40px; height: 22px; flex-shrink: 0; }
.switch input { opacity: 0; width: 0; height: 0; }
.switch-slider {
position: absolute; cursor: pointer; inset: 0;
background: var(--surface-2); border: 1px solid var(--border-control);
border-radius: 22px; transition: .18s;
}
.switch-slider::before {
content: ''; position: absolute; height: 16px; width: 16px;
left: 2px; bottom: 2px; background: var(--text);
border-radius: 50%; transition: .18s;
}
.switch input:checked + .switch-slider {
background: var(--accent); border-color: var(--accent);
}
.switch input:checked + .switch-slider::before { transform: translateX(18px); background: var(--on-accent); }
/* ─── Form Controls ───────────────────────────── */
.input, .select, .textarea {
display: block;
width: 100%;
padding: 9px 12px;
font-size: 13px;
font-family: var(--font);
background: var(--bg-elev);
border: 1px solid var(--border-control);
border-radius: var(--radius-sm);
color: var(--text);
outline: none;
transition: all .15s;
line-height: 1.35;
}
.input:hover, .select:hover, .textarea:hover { border-color: var(--accent); }
.input:focus, .select:focus, .textarea:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
}
.input::placeholder, .textarea::placeholder { color: var(--text-dim); }
/* type=search clear-X: themed, visible cancel affordance (default is near-invisible
on a dark field). Appears only when the field has a value. */
.input[type=search]::-webkit-search-cancel-button {
-webkit-appearance: none;
height: 13px; width: 13px; margin-left: 6px; cursor: pointer;
background: var(--text-dim);
-webkit-mask: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><path d='M18 6L6 18M6 6l12 12' stroke='black' stroke-width='3' stroke-linecap='round'/></svg>") center/contain no-repeat;
mask: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><path d='M18 6L6 18M6 6l12 12' stroke='black' stroke-width='3' stroke-linecap='round'/></svg>") center/contain no-repeat;
}
.input[type=search]::-webkit-search-cancel-button:hover { background: var(--text); }
.select {
appearance: none;
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%236a675e' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'><polyline points='6 9 12 15 18 9'/></svg>");
background-repeat: no-repeat;
background-position: right 10px center;
padding-right: 32px;
cursor: pointer;
}
/* Chevron follows --text-dim per theme (a data URI cannot read a custom prop,
so the two ramps are spelled out; the old #a1a1aa was 2.56 on white). */
[data-theme="dark"] .select { background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%239a978e' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'><polyline points='6 9 12 15 18 9'/></svg>"); }
.select option { background: var(--surface); color: var(--text); }
/* ── Custom themed select (enhances a hidden native <select>) ── */
.cs { position: relative; display: block; width: 100%; }
.cs-native {
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
opacity: 0; pointer-events: none;
}
.cs-trigger {
display: flex; align-items: center; justify-content: space-between; gap: 8px;
width: 100%; padding: 9px 12px; font-size: 13px; font-family: var(--font);
background: var(--bg-elev); border: 1px solid var(--border-control);
border-radius: var(--radius-sm); color: var(--text); cursor: pointer;
text-align: left; line-height: 1.35; transition: border-color .15s, box-shadow .15s;
}
.cs-trigger:hover { border-color: var(--accent); }
.cs-trigger:focus-visible,
.cs-trigger[aria-expanded="true"] {
outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft);
}
.cs-trigger[disabled] { opacity: .5; cursor: not-allowed; }
.cs-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cs-chevron { flex-shrink: 0; width: 12px; height: 12px; color: var(--text-dim); transition: transform .15s; }
.cs-trigger[aria-expanded="true"] .cs-chevron { transform: rotate(180deg); }
.cs-pop {
position: fixed; z-index: 150; min-width: 120px; max-height: 280px; overflow-y: auto;
background: var(--surface); border: 1px solid var(--border-strong); border-radius: var(--radius);
box-shadow: var(--shadow-lg); padding: 5px; transform-origin: top;
animation: modalIn .16s cubic-bezier(.16,1,.3,1);
}
.cs-pop::-webkit-scrollbar { width: 8px; }
.cs-pop::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 4px; border: 2px solid var(--surface); }
.cs-pop::-webkit-scrollbar-track { background: transparent; }
.cs-opt {
display: flex; align-items: center; justify-content: space-between; gap: 10px;
padding: 8px 10px; font-size: 13px; color: var(--text-muted); border-radius: var(--radius-sm);
cursor: pointer; white-space: nowrap; transition: background .1s, color .1s;
}
.cs-opt + .cs-opt { margin-top: 1px; }
.cs-opt[aria-selected="true"] { color: var(--text); font-weight: 500; }
.cs-opt.cs-hi { background: var(--accent-soft); color: var(--text); }
.cs-opt[aria-disabled="true"] { opacity: .4; cursor: not-allowed; }
.cs-opt .cs-check { color: var(--accent-text); flex-shrink: 0; opacity: 0; font-size: 12px; }
.cs-opt[aria-selected="true"] .cs-check { opacity: 1; }
/* ── Custom number stepper (enhances native input[type=number]) ── */
/* inline-flex + max-content so the wrapper shrink-wraps its input — otherwise a
max-width-constrained input leaves the wrapper full-width and the absolutely
positioned .ns-btns float far to the right of the (narrow) field. */
.ns { position: relative; display: inline-flex; width: max-content; max-width: 100%; vertical-align: middle; }
.ns-input { flex: 1; padding-right: 30px; font-variant-numeric: tabular-nums; }
.ns-input::-webkit-outer-spin-button,
.ns-input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
.ns-input[type=number] { -moz-appearance: textfield; }
.ns:focus-within .ns-input { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
.ns-btns {
position: absolute; right: 1px; top: 1px; bottom: 1px; width: 26px;
display: flex; flex-direction: column; border-left: 1px solid var(--border);
}
.ns-btn {
flex: 1; display: flex; align-items: center; justify-content: center;
background: transparent; border: none; color: var(--text-dim); cursor: pointer;
font-size: 12px; line-height: 1; padding: 0; user-select: none;
}
.ns-btn:hover { color: var(--text); background: var(--surface-2); }
.ns-btn:first-child { border-radius: 0 var(--radius-sm) 0 0; }
.ns-btn:last-child { border-radius: 0 0 var(--radius-sm) 0; border-top: 1px solid var(--border); }
.ns-input[disabled] ~ .ns-btns .ns-btn { opacity: .5; cursor: not-allowed; }
/* Keyboard focus visibility for controls whose real input is opacity:0 (switch /
checkbox / segmented) plus nav + segmented buttons — otherwise keyboard users
have no visible focus. Mouse users are unaffected (:focus-visible only). */
.switch input:focus-visible + .switch-slider,
.checkbox input:focus-visible + .box,
.segmented label:has(input:focus-visible) {
outline: 2px solid var(--accent-focus); outline-offset: 2px;
box-shadow: none; border-color: var(--accent);
}
/* P1-C6: a wash-only ring (1.17 light / 1.33 dark against --surface) is not a
sufficient sole focus indicator. Sidebar links, segmented buttons and
role=button controls get a solid 2px accent outline with offset. */
.sidebar nav a:focus-visible,
.seg-btn:focus-visible,
[role="button"]:focus-visible {
outline: 2px solid var(--accent-focus);
outline-offset: 2px;
box-shadow: none;
border-radius: 3px;
}
.btn:focus-visible { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
/* Solid-fill buttons: a ring floated off the fill (gap + accent) so it reads on
any background. */
.btn-primary:focus-visible, .btn-danger:focus-visible, .btn-success:focus-visible {
box-shadow: 0 0 0 2px var(--bg), 0 0 0 4px var(--accent-focus);
}
a:focus-visible { outline: 2px solid var(--accent-focus); outline-offset: 2px; border-radius: 2px; }
.field { display: flex; flex-direction: column; gap: 6px; flex: 1; min-width: 0; }
.field-label {
font-size: 12px;
color: var(--text-muted);
font-weight: 500;
}
.field-hint {
font-size: 12px;
color: var(--text-dim);
line-height: 1.5;
}
.field-row {
display: grid;
gap: 12px;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
align-items: end;
}
.field-row-actions {
display: flex;
gap: 8px;
margin-top: 14px;
flex-wrap: wrap;
}
/* Input group (prefix + input) */
.input-group { display: flex; gap: 6px; align-items: stretch; }
.input-group .input { flex: 1; }
/* Switch / Checkbox */
.checkbox {
display: inline-flex;
align-items: center;
gap: 8px;
cursor: pointer;
font-size: 13px;
color: var(--text-muted);
user-select: none;
}
.checkbox input { position: absolute; opacity: 0; pointer-events: none; }
.checkbox .box {
width: 16px; height: 16px;
border: 1.5px solid var(--border-control);
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-elev);
transition: all .15s;
flex-shrink: 0;
}
.checkbox input:checked + .box {
background: var(--accent);
border-color: var(--accent);
}
.checkbox input:checked + .box::after {
content: '';
width: 4px; height: 8px;
border: solid #fff;
border-width: 0 2px 2px 0;
transform: rotate(45deg) translateY(-1px);
}
.checkbox:hover .box { border-color: var(--accent); }
/* Themed bare checkbox / radio. Paints EVERY raw <input type=checkbox|radio>
that is NOT already wrapped by a styled component (.switch toggle, .checkbox
with .box, .segmented radiogroup — those hide the native input via opacity:0
and draw their own control). One rule themes the model picker, batch-select,
blocked-models editor, and every settings checkbox at once — existing and
future — with no per-element markup. Uses theme vars so light/dark both work. */
input[type=checkbox]:not(.switch input):not(.checkbox input),
input[type=radio]:not(.segmented input) {
appearance: none;
-webkit-appearance: none;
width: 16px;
height: 16px;
margin: 0;
flex-shrink: 0;
vertical-align: -3px;
border: 1.5px solid var(--border-control);
background: var(--bg-elev);
cursor: pointer;
transition: background .15s, border-color .15s, box-shadow .15s;
position: relative;
}
input[type=checkbox]:not(.switch input):not(.checkbox input) { border-radius: 4px; }
input[type=radio]:not(.segmented input) { border-radius: 50%; }
input[type=checkbox]:not(.switch input):not(.checkbox input):hover,
input[type=radio]:not(.segmented input):hover { border-color: var(--accent); }
input[type=checkbox]:not(.switch input):not(.checkbox input):checked,
input[type=radio]:not(.segmented input):checked {
background: var(--accent);
border-color: var(--accent);
}
/* checkbox tick (matches .checkbox .box::after) */
input[type=checkbox]:not(.switch input):not(.checkbox input):checked::after {
content: '';
position: absolute;
left: 4px; top: 1px;
width: 4px; height: 8px;
border: solid #fff;
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
/* radio inner dot */
input[type=radio]:not(.segmented input):checked::after {
content: '';
position: absolute;
left: 50%; top: 50%;
width: 6px; height: 6px;
border-radius: 50%;
background: #fff;
transform: translate(-50%, -50%);
}
input[type=checkbox]:not(.switch input):not(.checkbox input):focus-visible,
input[type=radio]:not(.segmented input):focus-visible {
outline: none;
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 35%, transparent);
}
input[type=checkbox]:not(.switch input):not(.checkbox input):disabled,
input[type=radio]:not(.segmented input):disabled {
opacity: .45;
cursor: not-allowed;
}
/* Tab-style radio group */
.segmented {
display: inline-flex;
background: var(--bg-elev);
border: 1px solid var(--border-control);
border-radius: var(--radius-sm);
padding: 3px;
gap: 2px;
}
.segmented label {
padding: 6px 14px;
font-size: 12px;
font-weight: 500;
color: var(--text-muted);
cursor: pointer;
border-radius: 5px;
transition: all .15s;
position: relative;
}
.segmented label input { position: absolute; opacity: 0; pointer-events: none; }
.segmented label:hover { color: var(--text); }
.segmented label:has(input:checked) {
background: var(--surface-3);
color: var(--text);
box-shadow: 0 1px 2px rgba(0,0,0,.3);
}
/* ─── Tables ──────────────────────────────────── */
.table-wrap {
overflow-x: auto;
border-radius: var(--radius);
}
table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
thead th {
text-align: left;
padding: 11px 16px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: .04em;
color: var(--text-muted);
background: var(--surface-2);
border-bottom: 1px solid var(--border);
white-space: nowrap;
position: sticky;
top: 0;
z-index: 1;
}
thead th.th-help {
cursor: help;
}
thead th.th-help::after {
content: " ⓘ";
opacity: .5;
font-size: 12px;
margin-left: 3px;
letter-spacing: normal;
}
tbody td {
padding: 11px 16px;
border-bottom: 1px solid var(--border);
vertical-align: middle;
}
tbody tr:last-child td { border-bottom: none; }
tbody tr { transition: background .1s; }
tbody tr:hover td { background: var(--surface-2); }
.empty-row td {
text-align: center;
color: var(--text-dim);
padding: 32px 16px;
font-style: italic;
}
/* Centered empty-state placeholder for chart/health panels (icon + title + sub). */
.panel-empty {
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 8px; width: 100%; height: 100%; min-height: 120px; padding: 28px 16px; text-align: center; color: var(--text-dim);
}
.panel-empty .pe-icon { width: 34px; height: 34px; opacity: .45; stroke: currentColor; }
.panel-empty .pe-title { font-size: 13px; font-weight: 600; color: var(--text-muted); }
.panel-empty .pe-sub { font-size: 12px; color: var(--text-dim); max-width: 300px; line-height: 1.5; }
.table-pagination {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
padding: 10px 12px;
border-top: 1px solid var(--border);
background: var(--surface);
color: var(--text-muted);
font-size: 12px;
flex-wrap: wrap;
}
.table-pagination .select {
width: auto;
min-width: 84px;
padding-top: 5px;
padding-bottom: 5px;
}
.table-pagination .cs { width: auto; min-width: 84px; }
.table-pagination .page-label {
min-width: 160px;
text-align: center;
color: var(--text-muted);
}
/* ─── Account detail (drawer body) ──────────────────── */
.account-detail-wrap {
padding: 20px 28px;
background: linear-gradient(180deg, var(--accent-soft), transparent 80%);
}
.detail-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-bottom: 14px;
}
@media (max-width: 1100px) { .detail-grid { grid-template-columns: 1fr; } }
.detail-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 14px 16px;
}
.detail-card-head {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: .06em;
margin-bottom: 10px;
}
.detail-card-head .dot {
width: 6px; height: 6px;
border-radius: 2px;
background: var(--accent);
}
.detail-kv {
display: grid;
grid-template-columns: 90px 1fr;
gap: 4px 14px;
font-size: 12px;
line-height: 1.6;
}
.detail-kv > .k { color: var(--text-dim); }
.detail-kv > .v { color: var(--text); font-variant-numeric: tabular-nums; }
.detail-kv code { font-size: 12px; padding: 1px 5px; }
/* ─── DevinMap cards (account drawer) ─────────────────── */
/* Every list is a bounded vertical scroller with overflow-x hidden: the card
width is whatever the drawer gives it (full width at 390, one grid column at
1440), so a long model id or a wide delta string must wrap or ellipsize, never
widen the page. */
.dm-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
gap: 10px 18px;
}
.dm-stat {
display: flex;
align-items: baseline;
gap: 6px;
min-width: 0;
padding: 6px 10px;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
}
.dm-stat-value {
font-family: var(--mono);
font-size: 15px;
font-weight: 600;
color: var(--text);
white-space: nowrap;
}
.dm-stat-label {
font-size: 12px;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: .05em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.dm-subhead {
display: flex;
align-items: center;
gap: 8px;
margin: 12px 0 6px;
padding-top: 10px;
border-top: 1px dashed var(--border);
font-size: 12px;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: .06em;
}
.dm-subhead .dm-subhead-count {
margin-left: auto;
font-family: var(--mono);
color: var(--text-dim);
letter-spacing: 0;
}
.dm-rows,
.dm-attention {
display: flex;
flex-direction: column;
gap: 4px;
max-height: 190px;
overflow-y: auto;
overflow-x: hidden;
}
.dm-row {
display: grid;
grid-template-columns: auto auto minmax(0, 1fr);
align-items: center;
gap: 8px;
min-width: 0;
padding: 4px 8px;
background: var(--surface-2);
border-radius: 6px;
font-size: 12px;
}
.dm-row-time {
font-family: var(--mono);
font-size: 12px;
color: var(--text-dim);
white-space: nowrap;
}
.dm-row-delta {
font-family: var(--mono);
font-size: 12px;
min-width: 0;
overflow-wrap: anywhere;
}
/* Request-history rows: time · model · tokens · credit. The model column is the
only flexible one, so a 60-char selector ellipsizes instead of widening the
drawer; the three numeric columns are tabular and never wrap. */
.dm-req-row {
display: grid;
grid-template-columns: 38px minmax(0, 1fr) 58px 46px;
align-items: center;
gap: 8px;
min-width: 0;
padding: 4px 8px;
background: var(--surface-2);
border-radius: 6px;
font-size: 12px;
}
.dm-req-model {
font-family: var(--mono);
font-size: 12px;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dm-req-num {
font-family: var(--mono);
font-size: 12px;
color: var(--text-dim);
text-align: right;
white-space: nowrap;
}
.dm-req-num.is-credit { color: var(--text); }
.dm-model-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
min-width: 0;
padding: 4px 8px;
background: var(--surface-2);
border-radius: 6px;
font-size: 12px;
}
.dm-model-name {
font-family: var(--mono);
font-size: 12px;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dm-inline {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px 10px;
min-width: 0;
}
.dm-note {
margin-top: 8px;
font-size: 12px;
line-height: 1.5;
color: var(--text-dim);
}
.dm-controls {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
margin-bottom: 10px;
}
.dm-controls .select { flex: 1 1 140px; min-width: 0; max-width: 260px; }
.dm-empty {
padding: 8px 2px;
font-size: 12px;
color: var(--text-dim);
}
.detail-bar {
display: grid;
grid-template-columns: 60px 1fr 50px;
align-items: center;
gap: 10px;
margin-bottom: 6px;
font-size: 12px;
}
.detail-bar .bar-label { color: var(--text-dim); font-size: 12px; }
.detail-bar .bar-track {
height: 7px;
border-radius: 4px;
background: var(--surface-2);
overflow: hidden;
border: 1px solid var(--border);
}
.detail-bar .bar-fill {
height: 100%;
border-radius: 3px;
transition: width .6s cubic-bezier(.2,.8,.2,1);
}
.detail-bar .bar-pct {
text-align: right;
color: var(--text);
font-family: var(--mono);
font-size: 12px;
font-weight: 600;
}
.detail-bar .bar-reset {
grid-column: 2 / 4;
font-size: 12px;
color: var(--text-dim);
font-family: var(--mono);
margin-top: 2px;
}
/* Model list grouped by provider */
.model-list-card { grid-column: 1 / -1; }
.model-group {
margin-bottom: 14px;
}
.model-group:last-child { margin-bottom: 0; }
.model-group-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
padding-bottom: 5px;
border-bottom: 1px dashed var(--border);
}
.model-group-name {
font-size: 12px;
font-weight: 600;
color: var(--text);
letter-spacing: .02em;
}
.model-group-count {
font-size: 12px;
color: var(--text-dim);
font-family: var(--mono);
}
.model-chips {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 6px;
}
.detail-model-chip {
display: grid;
grid-template-columns: 10px 1fr auto;
align-items: center;
gap: 8px;
padding: 6px 10px;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: 6px;
font-size: 12px;
transition: all .15s ease;
}
.detail-model-chip:hover {
border-color: var(--border-strong);
background: var(--surface-3);
}
.detail-model-chip.blocked {
opacity: .45;
text-decoration: line-through;
text-decoration-color: var(--text-dim);
text-decoration-thickness: 1px;
}
.detail-model-chip-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--success);
}
.detail-model-chip.blocked .detail-model-chip-dot { background: var(--error); }
.detail-model-chip-name {
font-family: var(--mono);
font-size: 12px;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.detail-model-chip-cost {
font-family: var(--mono);
font-size: 12px;
color: var(--warn-text);
padding: 1px 6px;
background: rgba(245,158,11,.08);
border: 1px solid rgba(245,158,11,.2);
border-radius: 10px;
white-space: nowrap;
font-weight: 600;
}
/* Provider swatch: colour comes from the theme-aware --prov-* tokens, so the
light and dark ramps can each clear 3:1 on their own surface (P2-12). The
provider name always sits beside it — the colour is never the only signal. */
.provider-swatch {
width: 14px; height: 14px;
border-radius: var(--radius-xs);
display: inline-block;
margin-right: var(--space-6);
vertical-align: middle;
background: var(--prov-other);
}
.provider-swatch[data-provider="anthropic"] { background: var(--prov-anthropic); }
.provider-swatch[data-provider="openai"] { background: var(--prov-openai); }
.provider-swatch[data-provider="google"] { background: var(--prov-google); }
.provider-swatch[data-provider="deepseek"] { background: var(--prov-deepseek); }
.provider-swatch[data-provider="xai"] { background: var(--prov-xai); }
.provider-swatch[data-provider="alibaba"] { background: var(--prov-alibaba); }
.provider-swatch[data-provider="moonshot"] { background: var(--prov-moonshot); }
.provider-swatch[data-provider="zhipu"] { background: var(--prov-zhipu); }
.provider-swatch[data-provider="minimax"] { background: var(--prov-minimax); }
.provider-swatch[data-provider="windsurf"] { background: var(--prov-windsurf); }
.provider-swatch[data-provider="other"] { background: var(--prov-other); }
/* ─── Badges ──────────────────────────────────── */
/* One badge base + one variant matrix (P1-S1). The legacy `badge-success`
class in static markup is an alias of `.badge.success`; the same matrix
covers error/warn/info/muted. Shape, size and label colour are shared so a
status cannot drift by component. */
.badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
line-height: 1.5;
border: 1px solid transparent;
/* AA rule (UIResearch §2.4): the LABEL is --text on a soft wash (14/16% in
light, 20/22% in dark; 10.49–15.70:1 both themes); the HUE lives in the
6 px dot, where 3:1 is the graphical-object floor. workbuddy's hue-on-soft
labels measured 3.69–5.58 and failed AA in four pairs — do not restore that. */
color: var(--text);
background: var(--surface-3);
--badge-dot: var(--text-dim);
}
.badge::before {
content: '';
width: 6px; height: 6px;
border-radius: 50%;
background: var(--badge-dot);
}
.badge.active, .badge.running, .badge.success, .badge-success {
background: var(--success-soft);
--badge-dot: var(--success);
}
.badge.error, .badge.stopped, .badge-danger, .badge-error {
background: var(--error-soft);
--badge-dot: var(--error);
}
.badge.banned {
background: var(--error-soft);
--badge-dot: var(--error);
box-shadow: inset 0 0 0 1px var(--error);
}
.badge.disabled, .badge.muted, .badge-muted {
background: var(--surface-3);
--badge-dot: var(--text-dim);
}
.badge.warn, .badge-warn {
background: var(--warn-soft);
--badge-dot: var(--warn);
}
.badge.info, .badge.pro, .badge-info {
background: var(--info-soft);
--badge-dot: var(--info);
}
.badge.no-dot::before { display: none; }
/* SessionReauth: the "needs a fresh token" notice (drawer body + bound login
modal). Terminal state, so it borrows the banned chip's error treatment. */
.reauth-notice {
margin: 0 0 12px;
padding: 10px 12px;
border-radius: var(--radius);
border-left: 3px solid var(--error);
background: var(--surface-2);
}
.reauth-notice-title { font-weight: 600; color: var(--error-text); margin-bottom: 4px; }
.reauth-notice .btn { margin-top: 8px; }
/* Tier pill — same base geometry as .badge (P1-S1). */
.tier {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
line-height: 1.5;
text-transform: uppercase;
letter-spacing: .04em;
}
.tier.pro { background: var(--accent); color: var(--on-accent); }
.tier.free { background: var(--info-soft); color: var(--info-text); }
.tier.expired { background: var(--error-soft); color: var(--error-text); }
.tier.unknown { background: var(--surface-3); color: var(--text-muted); }
/* ─── LS Pool Cards ───────────────────────────── */
.ls-pool {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 12px;
}
.ls-inst {
padding: 14px 16px;
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
font-size: 12px;
}
.ls-inst .ls-key {
font-weight: 600;
color: var(--text);
font-size: 13px;
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 6px;
}
.ls-inst .ls-dot {
width: 7px; height: 7px;
border-radius: 2px;
background: var(--success);
}
.ls-inst .ls-dot.pending { background: var(--warn); }
.ls-inst .ls-meta { color: var(--text-dim); font-family: var(--mono); font-size: 12px; margin-top: 2px; }
.ls-telemetry {
margin-top: 12px;
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
font-family: var(--mono);
font-size: 12px;
color: var(--text-dim);
}
.ls-telemetry .badge {
font-family: var(--mono);
font-size: 12px;
text-transform: none;
letter-spacing: 0;
}
.ls-telemetry .ls-telemetry-detail {
min-width: 180px;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.native-bridge-panel {
margin-top: 12px;
padding: 14px 16px;
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
}
.native-bridge-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 10px;
}
.native-bridge-title {
font-size: 13px;
font-weight: 600;
}
.native-bridge-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 8px;
margin-bottom: 10px;
}
.native-bridge-kv {
padding: 8px 10px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
}
.native-bridge-kv .k {
display: block;
font-size: 12px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: .04em;
}
.native-bridge-kv .v {
display: block;
margin-top: 3px;
font-family: var(--mono);
font-size: 12px;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.native-bridge-decisions {
display: flex;
flex-direction: column;
gap: 6px;
}
.native-bridge-decision {
display: grid;
grid-template-columns: minmax(160px, 1fr) minmax(120px, 1fr) minmax(180px, 2fr);
gap: 8px;
align-items: center;
font-family: var(--mono);
font-size: 12px;
color: var(--text-muted);
}
.native-bridge-tools {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@media (max-width: 720px) {
.native-bridge-decision { grid-template-columns: 1fr; }
/* Cockpit two-up sections stack on narrow screens so the trend chart and
connect metrics don't get crushed. */
.grid-2 { grid-template-columns: 1fr !important; }
}
/* ─── Model Chips ─────────────────────────────── */
.model-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin: 10px 0;
}
.model-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 11px;
border-radius: 6px;
font-size: 12px;
border: 1px solid var(--border);
background: var(--bg-elev);
color: var(--text-muted);
cursor: pointer;
transition: all .15s;
font-weight: 500;
}
.model-chip:hover {
border-color: var(--accent);
color: var(--text);
}
.model-chip.selected {
background: var(--accent-soft);
border-color: var(--accent);
color: var(--accent-text);
}
/* Listed but not serveable by this deployment right now (no entitled account, or the name
maps to nothing on this transport). Dimmed rather than hidden: the allow/deny list is
rendered from this same set, so a hidden chip would be an entry the operator can no
longer click to remove. Still clickable for exactly that reason. */
.model-chip.unreachable {
color: var(--text-dim);
border-style: dashed;
}
.model-chip.unreachable:hover { color: var(--text-muted); }
/* #235 — does this model cost quota. Only rendered when the answer is KNOWN; absence of a
badge means "no rate table yet", which must not look like either answer. */
.chip-badge {
margin-left: 5px;
padding: 2px 8px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
line-height: 1.5;
letter-spacing: .02em;
}
.chip-badge.free { background: var(--success); color: var(--on-accent); }
.chip-badge.paid { background: var(--warn); color: var(--on-accent); }
.model-chip .remove {
color: var(--error-text);
font-weight: 700;
cursor: pointer;
padding: 0 2px;
}
.provider-group { margin-bottom: 16px; }
.provider-label {
font-size: 12px;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: .08em;
margin-bottom: 6px;
font-weight: 600;
}
/* ─── Log Viewer ──────────────────────────────── */
.log-container {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
height: 520px;
overflow-y: auto;
font-family: var(--mono);
font-size: 12px;
line-height: 1.6;
}
.log-entry {
padding: 3px 16px;
border-bottom: 1px solid rgba(255,255,255,.02);
display: flex;
gap: 10px;
align-items: baseline;
word-break: break-all;
}
.log-entry:hover { background: var(--surface-2); }
.log-entry .ts { color: var(--text-dim); flex-shrink: 0; }
.log-entry .lvl {
font-weight: 700;
flex-shrink: 0;
width: 50px;
}
.log-entry.debug .lvl { color: var(--text-dim); }
.log-entry.info .lvl { color: var(--info-text); }
.log-entry.warn .lvl { color: var(--warn-text); }
.log-entry.error .lvl { color: var(--error-text); }
.log-entry.error { background: rgba(239,68,68,.03); }
.log-controls {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
margin-bottom: 14px;
}
.log-controls .input, .log-controls .select { width: auto; }
.log-controls .search { flex: 1; min-width: 200px; }
/* ─── Chart ───────────────────────────────────── */
.chart {
padding: 22px 24px 20px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
margin-bottom: 20px;
position: relative;
overflow: hidden;
}
.chart::before {
content: "";
position: absolute;
top: 0; left: 0; right: 0;
height: 2px;
background: linear-gradient(90deg, #3b82f6, #a78bfa, #3b82f6);
background-size: 200% 100%;
animation: chart-shimmer 3s linear infinite;
}
@keyframes chart-shimmer { 0%{background-position:200% 0} 100%{background-position:0 0} }
.chart h3 { font-size: 14px; font-weight: 600; margin: 0; letter-spacing: -.01em; }
.chart-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
margin-bottom: 18px;
}
.chart-title-group { min-width: 0; flex: 1; }
.chart-subtitle {
font-size: 12px;
color: var(--text-dim);
margin-top: 4px;
letter-spacing: .01em;
}
.chart-controls {
display: flex;
gap: 8px;
flex-wrap: wrap;
align-items: center;
}
/* Segmented button group — for chart-type / range switches */
.seg-group {
display: inline-flex;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: 8px;
padding: 3px;
gap: 2px;
}
.seg-btn {
padding: 5px 11px;
font-size: 12px;
font-weight: 500;
color: var(--text-muted);
background: transparent;
border: none;
border-radius: 6px;
cursor: pointer;
transition: all .18s ease;
font-family: inherit;
letter-spacing: -.005em;
white-space: nowrap;
}
.seg-btn:hover {
color: var(--text);
background: rgba(255,255,255,.03);
}
.seg-btn.active {
color: var(--on-accent);
background: var(--accent);
box-shadow: var(--ink-shadow-soft);
}
/* Main chart canvas */
.chart-canvas-wrap {
position: relative;
width: 100%;
height: 280px;
padding-top: 6px;
}
.chart-canvas-wrap canvas {
width: 100%;
height: 100%;
display: block;
}
.chart-range-indicator {
position: absolute;
top: 8px;
right: 8px;
font-size: 12px;
color: var(--text-dim);
font-family: var(--mono);
padding: 3px 8px;
background: rgba(255,255,255,.02);
border: 1px solid var(--border);
border-radius: 6px;
pointer-events: none;
letter-spacing: .01em;
}
/* Custom date range popup */
.custom-range-popup {
position: relative;
margin-top: 14px;
padding: 18px;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
max-width: 420px;
opacity: 0;
transform: translateY(-6px) scale(.98);
transition: opacity .16s ease, transform .16s ease;
pointer-events: none;
}
.custom-range-popup.open { opacity: 1; transform: none; pointer-events: auto; }
.custom-range-title {
font-size: 13px;
font-weight: 600;
margin-bottom: 14px;
color: var(--text);
}
.custom-range-fields {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
margin-bottom: 10px;
}
.custom-range-fields label {
display: flex;
flex-direction: column;
gap: 5px;
font-size: 12px;
color: var(--text-dim);
font-weight: 500;
letter-spacing: .01em;
}
.custom-range-fields input[type="date"] {
font-family: var(--mono);
font-size: 12px;
color-scheme: light dark; /* follow the active theme, not always dark */
}
.custom-range-hint {
font-size: 12px;
color: var(--text-dim);
margin-bottom: 14px;
}
.custom-range-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
}
.chart-tooltip {
position: absolute;
pointer-events: none;
background: var(--tip-bg);
color: var(--tip-fg);
padding: 8px 12px;
border-radius: 8px;
font-size: 12px;
line-height: 1.55;
border: 1px solid var(--border-strong);
box-shadow: 0 8px 28px rgba(0,0,0,.5);
backdrop-filter: blur(10px);
opacity: 0;
/* Cursor-following tooltip: JS sets left/top to the box origin (no centering
translate) and tracks the pointer, so only opacity is transitioned —
animating left/top would lag the cursor. */
transition: opacity .12s ease;
z-index: 5;
white-space: nowrap;
}
.chart-tooltip.show { opacity: 1; }
.chart-tooltip .tt-time { color: var(--text-dim); font-size: 12px; margin-bottom: 4px; }
.chart-tooltip .tt-row { display: flex; align-items: center; gap: 6px; }
.chart-tooltip .tt-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.chart-tooltip .tt-label { color: var(--text-dim); margin-right: 4px; }
.chart-tooltip .tt-value { font-weight: 600; font-variant-numeric: tabular-nums; margin-left: auto; }
.chart-tooltip .tt-sep { height: 1px; background: var(--border, rgba(128,128,128,.2)); margin: 6px 0 4px; }
.chart-tooltip .tt-subhead { color: var(--text-dim); font-size: 12px; text-transform: uppercase; letter-spacing: .04em; margin-bottom: 3px; }
/* Inline help "?" with an i18n hover/focus bubble — reusable info tooltip.
Text is injected via App._helpTip(key) or a static [data-i18n-tip] span.
Themed bg/fg so text is legible in BOTH themes; a small arrow points at the
icon. Placement variants dodge .section overflow:hidden clipping. */
.help-tip {
position: relative; display: inline-flex; align-items: center; justify-content: center;
width: 15px; height: 15px; margin-left: 5px; border-radius: 50%;
border: 1px solid transparent; background: var(--accent-soft); color: var(--accent-text);
font-size: 12px; font-weight: 700; line-height: 1; cursor: help;
vertical-align: middle; outline: none; user-select: none;
transition: color .15s ease, border-color .15s ease, background .15s ease, box-shadow .15s ease, transform .15s ease;
}
.help-tip:hover, .help-tip:focus-visible {
color: var(--on-accent); border-color: var(--accent);
background: var(--accent); transform: translateY(-.5px);
}
.help-tip:focus-visible { box-shadow: 0 0 0 3px var(--accent-soft); }
.help-tip__pop {
position: absolute; top: calc(100% + 9px); left: -2px; width: max-content;
max-width: min(260px, 78vw);
padding: 9px 11px; background: var(--tip-bg); color: var(--tip-fg);
border: 1px solid var(--tip-border); border-radius: 9px;
box-shadow: 0 10px 30px rgba(0,0,0,.28), 0 2px 6px rgba(0,0,0,.16);
backdrop-filter: blur(10px);
font-size: 12px; font-weight: 400; line-height: 1.55; white-space: normal; text-align: left;
opacity: 0; pointer-events: none; transform: translateY(-4px) scale(.97);
transform-origin: top left;
transition: opacity .16s ease, transform .16s cubic-bezier(.16,1,.3,1);
z-index: 30;
}
/* Arrow pointing back at the ? icon (border trick: outer=border color, inner=bg). */
.help-tip__pop::before { content: ""; position: absolute; bottom: 100%; left: 9px; border: 6px solid transparent; border-bottom-color: var(--tip-border); }
.help-tip__pop::after { content: ""; position: absolute; bottom: 100%; left: 10px; border: 5px solid transparent; border-bottom-color: var(--tip-bg); }
.help-tip:hover .help-tip__pop,
.help-tip:focus-visible .help-tip__pop,
.help-tip:focus-within .help-tip__pop { opacity: 1; transform: translateY(0) scale(1); pointer-events: auto; }
/* Placement variants dodge .section overflow:hidden clipping + right edge spill. */
.help-tip--top .help-tip__pop { top: auto; bottom: calc(100% + 9px); transform-origin: bottom left; transform: translateY(4px) scale(.97); }
.help-tip--top .help-tip__pop::before { bottom: auto; top: 100%; border-bottom-color: transparent; border-top-color: var(--tip-border); }
.help-tip--top .help-tip__pop::after { bottom: auto; top: 100%; border-bottom-color: transparent; border-top-color: var(--tip-bg); }
.help-tip--top:hover .help-tip__pop,
.help-tip--top:focus-visible .help-tip__pop,
.help-tip--top:focus-within .help-tip__pop { transform: translateY(0) scale(1); }
.help-tip--right .help-tip__pop { left: auto; right: -2px; transform-origin: top right; }
.help-tip--right .help-tip__pop::before { left: auto; right: 9px; }
.help-tip--right .help-tip__pop::after { left: auto; right: 10px; }
.help-tip--top.help-tip--right .help-tip__pop { transform-origin: bottom right; }
@media (pointer: coarse) { .help-tip { width: 18px; height: 18px; font-size: 12px; } }
@media (prefers-reduced-motion: reduce) { .help-tip, .help-tip__pop { transition: none; } }
/* Chart legend */
.chart-legend {
display: flex;
gap: 18px;
justify-content: center;
margin-top: 14px;
padding-top: 14px;
border-top: 1px solid var(--border);
flex-wrap: wrap;
}
.chart-legend-item {
display: flex;
align-items: center;
gap: 7px;
font-size: 12px;
color: var(--text-muted);
cursor: pointer;
user-select: none;
transition: opacity .15s ease;
}
.chart-legend-item.muted { color: var(--text-dim); opacity: 1; }
.chart-legend-item:hover { color: var(--text); }
.chart-legend-swatch {
width: 14px; height: 4px;
border-radius: 2px;
}
.chart-legend-swatch.dot {
width: 10px; height: 10px;
border-radius: 50%;
}
.chart-legend-value {
font-family: var(--mono);
font-size: 12px;
color: var(--text-dim);
font-variant-numeric: tabular-nums;
}
/* Expandable model-stats rows: clickable summary row + on-demand detail row. */
.model-row { cursor: pointer; transition: background .12s ease; }
.model-row:hover { background: var(--surface-2); }
.model-row:focus-visible { outline: 2px solid var(--accent-focus); outline-offset: -2px; }
.row-chevron { display: inline-block; width: 1em; color: var(--text-muted); font-size: 12px; transition: transform .12s ease; }
.model-detail-row > td { padding: 0 !important; background: var(--surface-2); }
.model-detail { display: flex; flex-wrap: wrap; gap: 20px; padding: 12px 16px; }
.model-detail-item { display: flex; flex-direction: column; gap: 2px; }
.mdi-label { font-size: 12px; text-transform: uppercase; letter-spacing: .04em; color: var(--text-muted); }
.mdi-val { font-variant-numeric: tabular-nums; font-size: 13px; color: var(--text); }
/* Stats request log: filter bar, numeric columns, bounded cells. */
.stats-log-meta { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.stats-filter-bar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px 14px;
padding: 10px 16px;
border-bottom: 1px solid var(--border);
background: var(--surface-2);
}
.stats-filter-bar label { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--text-muted); white-space: nowrap; }
.stats-filter-bar .select { width: auto; min-width: 140px; max-width: 260px; }
#stats-log-table .numeric, .stats-agg-table .numeric { text-align: right; font-variant-numeric: tabular-nums; }
.cell-ellipsis { display: inline-block; max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; vertical-align: bottom; }
.bars {
display: flex;
align-items: flex-end;
gap: 4px;
height: 160px;
padding-bottom: 4px;
border-bottom: 1px solid rgba(255,255,255,.05);
}
.bar-wrap {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
height: 100%;
justify-content: flex-end;
position: relative;
}
.bar {
width: 100%;
min-width: 6px;
max-width: 28px;
background: linear-gradient(to top, var(--accent), var(--accent-hover));
border-radius: 4px 4px 1px 1px;
transition: all .3s cubic-bezier(.4,0,.2,1);
cursor: pointer;
position: relative;
}
.bar::after {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
background: linear-gradient(to top, transparent 60%, rgba(255,255,255,.12));
pointer-events: none;
}
.bar.has-errors {
background: linear-gradient(to top, rgba(239,68,68,.6), rgba(251,146,60,.9));
}
.bar-wrap:hover .bar {
box-shadow: 0 0 16px var(--accent-soft), inset 0 0 8px rgba(255,255,255,.08);
transform: scaleY(1.02) scaleX(1.08);
filter: brightness(1.15);
}
.bar-wrap:hover .bar.has-errors {
box-shadow: 0 0 16px rgba(239,68,68,.4), inset 0 0 8px rgba(255,255,255,.08);
}
.bar-label {
font-size: 12px;
color: var(--text-muted);
margin-top: 6px;
font-family: var(--mono);
transition: color .2s;
}
.bar-wrap:hover .bar-label { color: var(--text); }
.bar-tooltip {
display: none;
position: absolute;
bottom: calc(100% + 8px);
left: 50%;
transform: translateX(-50%);
background: rgba(15,15,25,.95);
color: #e0e0e0;
padding: 8px 12px;
border-radius: 8px;
font-size: 12px;
line-height: 1.5;
white-space: nowrap;
pointer-events: none;
z-index: 10;
box-shadow: 0 8px 24px rgba(0,0,0,.5);
border: 1px solid rgba(255,255,255,.08);
backdrop-filter: blur(8px);
}
.bar-tooltip b { color: var(--accent-text); }
.bar-tooltip::after {
content: "";
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 6px solid transparent;
border-top-color: rgba(15,15,25,.95);
}
.bar-wrap:hover .bar-tooltip { display: block; animation: tt-in .15s ease; }
@keyframes tt-in { from{opacity:0;transform:translateX(-50%) translateY(4px)} to{opacity:1;transform:translateX(-50%) translateY(0)} }
/* ─── Toast ───────────────────────────────────── */
.diff-stat { font-size: 12px; color: var(--text-muted); margin-bottom: 8px; }
.diff-box { max-height: 50vh; overflow: auto; font-family: var(--mono, monospace); font-size: 12px; line-height: 1.5; background: var(--bg-elev, rgba(0,0,0,.2)); border-radius: 8px; padding: 8px; margin: 0; }
.diff-line { white-space: pre-wrap; word-break: break-all; padding: 0 6px; }
.diff-add { background: rgba(74,222,128,.14); color: var(--success-text); }
.diff-del { background: rgba(248,113,113,.14); color: var(--error-text); }
.diff-ctx { color: var(--text-muted); }
.toast-stack {
position: fixed;
bottom: 24px;
right: 24px;
display: flex;
flex-direction: column-reverse;
gap: 10px;
z-index: 9999;
pointer-events: none;
}
.toast {
padding: 12px 18px;
border-radius: var(--radius-sm);
font-size: 13px;
background: var(--surface);
border: 1px solid var(--border);
box-shadow: var(--shadow-lg);
animation: slideIn .25s ease;
pointer-events: auto;
max-width: 400px;
display: flex;
align-items: center;
gap: 10px;
}
.toast.success { border-left: 3px solid var(--success); }
.toast.error { border-left: 3px solid var(--error); }
.toast.info { border-left: 3px solid var(--info); }
@keyframes slideIn {
from { opacity: 0; transform: translateX(20px); }
to { opacity: 1; transform: translateX(0); }
}
/* ─── Modal ───────────────────────────────────── */
.modal-overlay {
position: fixed;
inset: 0;
background: var(--scrim-strong);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
animation: fadeIn .15s ease;
}
.modal {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
width: 90%;
max-width: 440px;
box-shadow: var(--shadow-lg);
overflow: hidden;
animation: modalIn .2s cubic-bezier(.16,1,.3,1);
}
@keyframes modalIn {
from { opacity: 0; transform: translateY(-10px) scale(.98); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
.modal-header {
padding: 20px 24px 12px;
}
.modal-title { font-size: 16px; font-weight: 600; }
.modal-desc { font-size: 13px; color: var(--text-muted); margin-top: 4px; }
.modal-body { padding: 12px 24px 20px; }
.modal-footer {
padding: 14px 24px;
background: var(--surface-2);
display: flex;
justify-content: flex-end;
gap: 8px;
border-top: 1px solid var(--border);
}
/* ─── Login Overlay ───────────────────────────── */
.login-overlay {
position: fixed;
inset: 0;
background: radial-gradient(ellipse at center, var(--accent-soft) 0%, var(--bg) 70%);
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
}
.login-box {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 36px 32px;
width: min(380px, calc(100% - 32px));
box-shadow: var(--shadow-lg);
}
.login-box .login-logo {
width: 56px; height: 56px;
margin: 0 auto 18px;
border-radius: 14px;
background: linear-gradient(135deg, var(--accent) 0%, var(--accent-hover) 100%);
display: flex; align-items: center; justify-content: center;
font-weight: 800; font-size: 22px; color: var(--on-accent);
box-shadow: 0 10px 30px var(--accent-soft);
}
.login-box h3 { text-align: center; font-size: 18px; margin-bottom: 8px; }
.login-box p { text-align: center; font-size: 13px; color: var(--text-muted); margin-bottom: 22px; }
.login-box .input { margin-bottom: 14px; }
.login-box .btn { width: 100%; padding: 11px; font-weight: 600; }
/* ─── Spinner ─────────────────────────────────── */
.spinner {
display: inline-block;
width: 14px; height: 14px;
border: 2px solid rgba(255,255,255,.2);
border-top-color: currentColor;
border-radius: 50%;
animation: spin .7s linear infinite;
vertical-align: middle;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* ─── Capability Markers ──────────────────────── */
.cap-list { display: inline-flex; flex-wrap: wrap; gap: 4px; }
.cap-item {
font-size: 12px;
padding: 2px 8px;
border-radius: 999px;
font-family: var(--mono);
font-weight: 600;
line-height: 1.5;
}
.cap-item.ok { background: var(--success-soft); color: var(--success-text); }
.cap-item.fail { background: var(--error-soft); color: var(--error-text); text-decoration: line-through; }
/* P1 #2/#3/#8: inline lifecycle states (never toast-only, never silent). */
#overview-stale-banner {
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
margin: 0 0 16px; padding: 12px 16px;
background: var(--warn-soft); border: 1px solid var(--warn);
border-radius: var(--radius); color: var(--text); font-size: 13px;
}
.overview-error { border-left: 3px solid var(--error); }
.modal-confirm-require { padding-top: 0; }
.modal-confirm-require .field-label { display: block; margin-bottom: 6px; }
.inline-action-error {
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
margin-bottom: 12px; padding: 10px 12px;
background: var(--error-soft); border: 1px solid var(--error);
border-radius: var(--radius-sm); color: var(--text); font-size: 13px;
}
.inline-save-state { display: inline-flex; align-items: center; gap: 6px; margin-left: 8px; font-size: 12px; color: var(--text-muted); }
.inline-save-state.is-error { color: var(--error-text); }
.inline-save-state.is-ok { color: var(--success-text); }
/* ─── Utilities ───────────────────────────────── */
.flex { display: flex; }
.flex-col { display: flex; flex-direction: column; }
.items-center { align-items: center; }
.justify-between { justify-content: space-between; }
.gap-2 { gap: 8px; } .gap-3 { gap: 12px; } .gap-4 { gap: 16px; }
.mt-2 { margin-top: 8px; } .mt-3 { margin-top: 12px; } .mt-4 { margin-top: 16px; }
.text-muted { color: var(--text-muted); }
.text-dim { color: var(--text-dim); }
.text-sm { font-size: 12px; }
.text-xs { font-size: 12px; }
.nowrap { white-space: nowrap; }
.hidden { display: none !important; }
.break-all { word-break: break-all; }
/* ─── Responsive ──────────────────────────────── */
@media (max-width: 900px) {
.sidebar { width: 60px; }
.sidebar .brand-name, .sidebar .brand-sub, .sidebar nav a span, .sidebar .footer .ver, .sidebar .nav-group-label { display: none; }
.sidebar .brand { justify-content: center; padding: 20px 0; }
.sidebar nav a { justify-content: center; padding: 10px; }
.main { margin-left: 60px; padding: 20px 16px; }
}
/* P1 #5: mobile app bar + labelled off-canvas drawer (below 720px). */
.mobile-appbar, .mobile-nav-mask { display: none; }
@media (max-width: 720px) {
.mobile-appbar {
display: flex; align-items: center; gap: 8px;
position: fixed; top: 0; left: 0; right: 0; z-index: 130;
height: 56px; padding: 0 10px;
background: var(--surface); border-bottom: 1px solid var(--border);
}
.mobile-menu-btn { min-width: 44px; min-height: 44px; padding: 8px; }
.mobile-menu-btn svg { width: 20px; height: 20px; }
.mobile-appbar-title { font-weight: 600; font-size: 15px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.mobile-appbar-spacer { flex: 1; }
.mobile-appbar-btn { min-width: 44px; min-height: 44px; padding: 6px 8px; }
.mobile-appbar-icon svg { width: 16px; height: 16px; }
.mobile-nav-mask:not(.hidden) { display: block; position: fixed; inset: 0; z-index: 135; background: var(--scrim); }
body.mobile-nav-open { overflow: hidden; }
.sidebar {
width: min(280px, 86vw); z-index: 140;
transform: translateX(-100%); transition: transform .2s ease;
}
.sidebar.open { transform: none; }
/* Restore labels hidden by the 900px icon-rail rule: the drawer is labelled. */
.sidebar .brand-name, .sidebar .brand-sub, .sidebar nav a span,
.sidebar .footer .ver, .sidebar .nav-group-label { display: block; }
.sidebar .brand { justify-content: flex-start; padding: 20px 22px 18px; }
.sidebar nav a { justify-content: flex-start; padding: 12px; min-height: 44px; }
.sidebar nav a svg { width: 18px; height: 18px; }
.main { margin-left: 0; padding: 72px 16px 80px; }
#settings-search { display: none; }
.sidebar.settings-open #settings-search { display: block; }
/* P1 #5 touch targets: row menus, pagination and filters stay >=44px. */
.row-menu-btn, .table-pagination .btn, .btn-xs { min-height: 44px; min-width: 44px; }
#accounts-table .acct-check-cell { min-height: 44px; }
#accounts-table input[type=checkbox] { width: 22px; height: 22px; }
.acct-filters .input, .acct-filters .select { min-height: 44px; }
}
.contributor-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(360px, 1fr)); gap: 12px; }
.contributor-card { display: flex; gap: 14px; padding: 14px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); transition: border-color .15s; min-width: 0; }
.contributor-card:hover { border-color: var(--accent); }
.contributor-card .avatar { width: 48px; height: 48px; border-radius: 50%; flex: none; border: 2px solid var(--border); background: var(--surface-2); object-fit: cover; }
.contributor-card .meta { display: flex; flex-direction: column; gap: 4px; flex: 1; min-width: 0; }
.contributor-card .meta-top { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; align-items: start; }
.contributor-card .identity { display: flex; align-items: center; gap: 8px; min-width: 0; flex-wrap: wrap; }
.contributor-card .links { display: flex; align-items: center; gap: 6px; justify-content: flex-end; flex-wrap: wrap; }
.contributor-card .meta-top .name { color: var(--accent-text); font-weight: 600; font-size: 15px; text-decoration: none; }
.contributor-card .meta-top .name:hover { text-decoration: underline; }
.contributor-card .meta-top .pr-link { font-size: 12px; color: var(--text-dim); text-decoration: none; padding: 2px 8px; border: 1px solid var(--border); border-radius: 4px; }
.contributor-card .meta-top .pr-link:hover { color: var(--accent-text); border-color: var(--accent); }
.contributor-card .meta-top .merged-at { font-size: 12px; color: var(--text-dim); }
.contributor-card .pr-title { font-weight: 500; margin-top: 2px; font-size: 13px; line-height: 1.45; }
.contributor-card .summary-toggle { margin-top: 6px; font-size: 12px; color: var(--text-dim); }
.contributor-card .summary-toggle summary { cursor: pointer; width: fit-content; user-select: none; }
.contributor-card .summary-toggle summary:hover { color: var(--accent-text); }
.contributor-card .summary { font-size: 13px; color: var(--text-muted); line-height: 1.65; margin-top: 6px; }
.contributor-card .rarity-badge { display: inline-flex; align-items: center; justify-content: center; min-width: 38px; padding: 2px 8px; border-radius: 4px; font-size: 12px; font-weight: 800; letter-spacing: 0.7px; font-family: var(--mono); line-height: 1.5; }
/* v2.0.60 — extended rarity ladder. Rarer = more dramatic visual:
GOD glows with animated rainbow (project creator only); MR pulses cyan;
LR has a cool prism gradient; UR keeps the existing fire palette. */
.contributor-card .rarity-GOD {
background: linear-gradient(110deg, #ffd700 0%, #ff6b9d 25%, #c084fc 50%, #60a5fa 75%, #34d399 100%);
background-size: 220% 220%;
color: #1a1208;
text-shadow: 0 0 6px rgba(255,255,255,0.55);
font-weight: 900;
box-shadow: 0 0 0 1px rgba(255,215,0,0.55), 0 0 14px rgba(192,132,252,0.4);
animation: rarity-god-pulse 4s ease-in-out infinite;
}
@keyframes rarity-god-pulse {
0%, 100% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
}
.contributor-card .rarity-MR {
background: linear-gradient(135deg, #06b6d4 0%, #8b5cf6 50%, #ec4899 100%);
color: #fff;
font-weight: 800;
box-shadow: 0 0 0 1px rgba(139,92,246,0.45), 0 0 10px rgba(6,182,212,0.25);
}
.contributor-card .rarity-LR {
background: linear-gradient(135deg, #a855f7 0%, #6366f1 50%, #06b6d4 100%);
color: #fff;
font-weight: 800;
box-shadow: 0 0 0 1px rgba(99,102,241,0.42);
}
.contributor-card .rarity-UR { background: linear-gradient(135deg, #f5d06f, #e11d48 52%, #7c3aed); color: #fff; box-shadow: 0 0 0 1px rgba(225,29,72,0.28); }
.contributor-card .rarity-SSR { background: linear-gradient(135deg, #f59e0b, #b45309); color: #fff7ed; box-shadow: 0 0 0 1px rgba(180,83,9,0.25); }
.contributor-card .rarity-SR { background: var(--info-soft); color: var(--info-text); border: 1px solid rgba(56,189,248,0.28); }
.contributor-card .rarity-R { background: var(--surface-2); color: var(--text-muted); border: 1px solid var(--border); }
.contributor-card .contribution-count { font-size: 12px; color: var(--text-dim); padding: 2px 8px; border: 1px solid var(--border); border-radius: 4px; line-height: 1.5; }
.contributor-card .history-list { list-style: none; padding: 0; margin: 6px 0 0; display: flex; flex-direction: column; gap: 8px; }
.contributor-card .history-item { padding: 10px 12px; border: 1px solid var(--border); border-radius: 4px; background: var(--surface-2); }
.contributor-card .history-head { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.contributor-card .history-head .pr-link { font-size: 12px; color: var(--text-dim); text-decoration: none; padding: 2px 8px; border: 1px solid var(--border); border-radius: 4px; }
.contributor-card .history-head .pr-link:hover { color: var(--accent-text); border-color: var(--accent); }
.contributor-card .history-head .merged-at { font-size: 12px; color: var(--text-dim); }
.contributor-card .history-rarity-label { font-size: 12px; color: var(--text-dim); font-style: italic; flex: 1; min-width: 0; }
.contributor-card .history-title { font-size: 13px; font-weight: 500; margin-top: 6px; line-height: 1.45; }
.contributor-card .history-summary { font-size: 12px; color: var(--text-muted); line-height: 1.65; margin-top: 6px; }
@media (max-width: 640px) {
.contributor-list { grid-template-columns: 1fr; }
.contributor-card { padding: 12px; gap: 12px; }
.contributor-card .avatar { width: 40px; height: 40px; }
.contributor-card .meta-top { grid-template-columns: 1fr; }
.contributor-card .links { justify-content: flex-start; }
}
/* Screen-reader-only: visually hidden but present in the a11y tree (chart data
tables, etc.). Standard clip-rect technique — no display:none (that hides it
from screen readers too). */
.sr-only {
position: absolute !important;
width: 1px; height: 1px;
padding: 0; margin: -1px;
overflow: hidden; clip: rect(0, 0, 0, 0);
white-space: nowrap; border: 0;
}
/* ─── Merged account management page (2026-10-22 account-UI merge) ─────
Geometry follows workbuddy2api-hub via UIResearch §2.2/§3; colours come from
the token blocks above. Row target 44 px comfortable / 36 px compact. */
.acct-kpis {
display: grid;
grid-template-columns: repeat(5, 1fr);
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
margin-bottom: var(--space-16);
overflow: hidden;
}
.acct-kpi { padding: var(--space-12) var(--space-16); border-left: 1px solid var(--border); min-width: 0; }
.acct-kpi:first-child { border-left: 0; }
.acct-kpi-label {
font-size: var(--fs-sm); font-weight: 600; text-transform: uppercase; letter-spacing: .06em;
color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.acct-kpi-value { font-size: var(--fs-3xl); font-weight: 700; line-height: 1.2; margin-top: var(--space-2); font-variant-numeric: tabular-nums; }
.acct-kpi-sub { font-size: var(--fs-sm); color: var(--text-dim); margin-top: 1px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.acct-toolbar { display: flex; align-items: center; gap: var(--space-8); flex-wrap: wrap; margin-bottom: var(--space-12); }
/* The three credential verbs are one group, sized like the toolbar clusters so
the header stays one line at 1440 and wraps as a unit at 390. */
.acct-add-group { display: inline-flex; align-items: center; gap: var(--space-6); flex-wrap: wrap; }
.acct-load-status { font-size: var(--fs-sm); color: var(--text-muted); margin: calc(-1 * var(--space-4)) 0 var(--space-10); }
.acct-load-status[hidden] { display: none; }
.acct-skeleton-row td { padding: var(--space-10) var(--space-16) !important; }
.acct-skeleton-row .skeleton { height: 12px; border-radius: var(--radius-xs); }
.tb-group {
display: inline-flex; align-items: center; gap: var(--space-4); padding: var(--space-4);
background: var(--surface-2); border: 1px solid var(--border); border-radius: var(--radius);
}
.tb-group .btn { border-color: transparent; background: transparent; }
.tb-group .btn:hover:not(:disabled) { background: var(--surface); border-color: var(--border); }
/* `.btn` sets display:inline-flex, which beats the UA `[hidden]` rule. Every
hidden-by-state button (fleet cancel, fleet details, filter clear) needs this
or it stays visible while `hidden` is set. */
.btn[hidden] { display: none; }
.acct-op-status { font-size: var(--fs-sm); color: var(--text-muted); min-height: 18px; }
/* Last fleet run. Lives outside the selection bar so a probe-all (no selected
rows) still has a readable failure list after the run. */
.fleet-panel {
border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface);
padding: var(--space-8) var(--space-12); margin-bottom: var(--space-12); font-size: var(--fs-sm);
}
.fleet-panel-head { display: flex; align-items: center; gap: var(--space-8); flex-wrap: wrap; }
.fleet-panel-head #accounts-fleet-summary { color: var(--text-muted); }
.fleet-list { margin: var(--space-6) 0 0; padding-left: var(--space-20); max-height: 200px; overflow: auto; }
.fleet-list li { margin: var(--space-2) 0; color: var(--text-muted); word-break: break-all; }
.fleet-item { display: flex; align-items: baseline; gap: var(--space-6); flex-wrap: wrap; }
.fleet-item-email { color: var(--text); font-weight: 600; }
.fleet-item-result {
flex: none; padding: 0 var(--space-6); border-radius: var(--radius-xs); font-size: var(--fs-sm); font-weight: 700;
}
.fleet-item-result.ok { background: var(--success-soft); color: var(--text); }
.fleet-item-result.fail { background: var(--error-soft); color: var(--text); }
.fleet-item-result.skip { background: var(--warn-soft); color: var(--text); }
.acct-filters { display: flex; align-items: center; gap: var(--space-8); flex-wrap: wrap; margin-bottom: var(--space-12); }
.acct-filters .input, .acct-filters .select { width: auto; height: 32px; padding-top: var(--space-4); padding-bottom: var(--space-4); }
.acct-filters .cs { width: auto; flex: 0 0 auto; min-width: 150px; }
.acct-filters .cs-trigger { height: 32px; padding-top: var(--space-4); padding-bottom: var(--space-4); }
.acct-filters .input { flex: 1 1 240px; min-width: 160px; max-width: 360px; }
.acct-filter-count { font-size: var(--fs-sm); color: var(--text-muted); font-variant-numeric: tabular-nums; }
.acct-filter-clear[hidden] { display: none; }
#accounts-table { min-width: 1070px; table-layout: fixed; }
#accounts-table th, #accounts-table td { padding: var(--acct-row-pad-y) var(--space-16); }
/* Sortable header: the whole label is the control, and aria-sort on the th is
the state (a screen reader announces it; the caret is decoration). */
#accounts-table th.th-help { cursor: default; }
.th-sort {
display: inline-flex; align-items: center; gap: var(--space-4);
padding: 0; border: 0; background: transparent; color: inherit;
font: inherit; letter-spacing: inherit; text-transform: inherit; cursor: pointer;
}
.th-sort:hover { color: var(--text); }
.th-sort:focus-visible { outline: 2px solid var(--accent-focus); outline-offset: 2px; border-radius: var(--radius-xs); }
.th-sort-mark { font-size: var(--fs-sm); color: var(--text-dim); letter-spacing: normal; }
#accounts-table th[aria-sort="ascending"] .th-sort-mark::after { content: "▲"; }
#accounts-table th[aria-sort="descending"] .th-sort-mark::after { content: "▼"; }
#accounts-table th[aria-sort="ascending"] .th-sort,
#accounts-table th[aria-sort="descending"] .th-sort { color: var(--text); }
.acct-sort-field { display: inline-flex; align-items: center; }
.acct-sort-select { width: auto; height: 32px; padding-top: var(--space-4); padding-bottom: var(--space-4); }
#accounts-table .acct-check-col { width: 40px; padding-left: var(--space-12); padding-right: 0; }
#accounts-table .acct-account-col { width: auto; min-width: 260px; }
#accounts-table .acct-tier-col { width: 88px; }
#accounts-table .acct-state-col { width: 150px; }
#accounts-table .acct-quota-col { width: 168px; }
#accounts-table .acct-models-col { width: 92px; }
#accounts-table .acct-rpm-col { width: 96px; }
#accounts-table .acct-last-col { width: 132px; }
#accounts-table .acct-actions-col { width: 44px; padding-left: var(--space-4); padding-right: var(--space-8); }
#accounts-table tbody td { overflow: hidden; }
#accounts-table.accounts-compact th, #accounts-table.accounts-compact td { padding: var(--space-4) var(--space-12); font-size: var(--fs-sm); }
#accounts-table.accounts-compact .acct-name { font-size: var(--fs-sm); }
#accounts-table.accounts-compact .acct-line2 { font-size: var(--fs-sm); }
.acct-line1 { display: flex; align-items: center; gap: var(--space-6); min-width: 0; }
/* Tier cell is the one cell allowed two lines. It is budgeted so the row still
lands on the 44 px comfortable target: pill (17.2) + 1 px gap + 11 px subline
+ 10 px cell padding ≈ 40 px. Before this rule the subline inherited 1.5
line-height and pushed rows with a reported plan to 49 px. */
.acct-tier-stack { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
.acct-tier-sub {
font-size: var(--fs-sm); line-height: 1.1; color: var(--text-dim);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.acct-name { font-size: var(--fs-lg); font-weight: 600; line-height: 1.25; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.acct-open { border: 0; background: none; padding: 0; color: var(--text); font-family: inherit; text-align: left; cursor: pointer; }
.acct-open:hover { color: var(--accent-text); text-decoration: underline; }
.acct-open:focus-visible { outline: 2px solid var(--accent-focus); outline-offset: 1px; border-radius: 2px; }
.acct-line2 { display: flex; align-items: center; gap: var(--space-6); font-size: var(--fs-sm); line-height: 1.3; color: var(--text-dim); min-width: 0; }
.acct-line2 code { background: transparent; border: 0; padding: 0; font-size: var(--fs-sm); color: var(--text-dim); }
.acct-line2 .acct-key { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* Last check lives on the existing second line, so the 44px row budget holds. */
.acct-last-check {
margin-left: auto; flex: none; display: inline-flex; align-items: center; gap: var(--space-4);
max-width: 260px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
color: var(--text-dim); font-size: var(--fs-sm);
}
.acct-last-check.is-skip { color: var(--warn-text); }
.acct-last-check-text { overflow: hidden; text-overflow: ellipsis; }
.acct-last-check-retry {
border: 0; background: transparent; padding: 0; color: var(--accent-text);
font: inherit; font-size: var(--fs-sm); cursor: pointer; text-decoration: underline; flex: none;
}
.acct-last-check-retry:focus-visible { outline: 2px solid var(--accent-focus); outline-offset: 1px; }
.note-chip {
display: inline-flex; align-items: center; max-width: 120px; height: 18px; padding: 0 var(--space-6);
border-radius: 999px; border: 1px dashed var(--border-strong);
font-size: var(--fs-sm); line-height: 16px; color: var(--text-muted);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: none;
}
.method-chip { font-size: var(--fs-sm); color: var(--text-dim); flex: none; }
.acct-band { box-shadow: inset 3px 0 0 var(--text-dim); }
.acct-state { display: inline-flex; align-items: center; gap: var(--space-6); flex-wrap: wrap; }
.acct-state-dot { width: 7px; height: 7px; border-radius: 50%; flex: none; }
.acct-countdown { font-size: var(--fs-sm); color: var(--text-muted); font-variant-numeric: tabular-nums; }
.acct-error-count { font-size: var(--fs-sm); color: var(--error-text); font-weight: 600; }
.acct-batch-chip { font-size: var(--fs-sm); font-weight: 600; padding: 0 var(--space-6); border-radius: var(--radius-xs); }
.acct-batch-chip.ok { background: var(--success-soft); color: var(--text); }
.acct-batch-chip.fail { background: var(--error-soft); color: var(--text); }
.acct-batch-chip.skip { background: var(--warn-soft); color: var(--text); }
.row-menu-btn {
display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 28px;
border: 1px solid transparent; border-radius: var(--radius-sm); background: transparent;
color: var(--text-muted); font-size: var(--fs-xl); line-height: 1; cursor: pointer;
}
.row-menu-btn:hover, .row-menu-btn[aria-expanded="true"] { background: var(--surface-2); border-color: var(--border); color: var(--text); }
.row-menu-btn:focus-visible { outline: 2px solid var(--accent-focus); outline-offset: 1px; }
.row-menu {
position: fixed; z-index: 160; min-width: 190px; padding: var(--space-4);
background: var(--surface); border: 1px solid var(--border-strong); border-radius: var(--radius-sm);
box-shadow: var(--shadow-lg);
}
.row-menu [role="menuitem"] {
display: flex; align-items: center; width: 100%; padding: var(--space-8) var(--space-10); border: 0; border-radius: var(--radius-xs);
background: transparent; color: var(--text); font: inherit; font-size: var(--fs-sm); text-align: left; cursor: pointer;
}
.row-menu [role="menuitem"]:hover, .row-menu [role="menuitem"]:focus-visible { background: var(--surface-2); outline: none; }
.row-menu [role="menuitem"].danger { color: var(--error-text); }
.row-menu .menu-sep { height: 1px; margin: var(--space-4) var(--space-6); background: var(--border); }
#accounts-batch-bar {
position: sticky; bottom: 0; z-index: 4;
margin: 0; padding: var(--space-10) var(--space-12); border-radius: 0;
border: 0; border-top: 1px solid var(--border);
background: var(--surface-2); box-shadow: 0 -8px 18px -14px rgba(24,24,27,.55);
flex-wrap: wrap;
}
#accounts-batch-bar .batch-scope { font-size: var(--fs-sm); color: var(--text-muted); }
#accounts-batch-bar .batch-scope button {
border: 0; background: transparent; padding: 0 var(--space-2); color: var(--accent-text);
font: inherit; font-size: var(--fs-sm); font-weight: 600; cursor: pointer; text-decoration: underline;
}
#accounts-batch-bar .batch-progress { font-size: var(--fs-sm); color: var(--text-muted); font-variant-numeric: tabular-nums; }
#accounts-batch-bar .batch-fail-link { border: 0; background: transparent; color: var(--error-text); font: inherit; font-size: var(--fs-sm); cursor: pointer; text-decoration: underline; }
#accounts-batch-bar .batch-fail-pop {
position: absolute; bottom: calc(100% + 6px); left: 12px; max-width: 520px; max-height: 220px; overflow: auto;
padding: var(--space-10) var(--space-12); background: var(--surface); border: 1px solid var(--border-strong);
border-radius: var(--radius-sm); box-shadow: var(--shadow-lg); font-size: var(--fs-sm);
}
#accounts-batch-bar .batch-fail-pop ul { margin: var(--space-6) 0 0; padding-left: var(--space-20); }
#accounts-batch-bar .batch-fail-pop li { margin: var(--space-2) 0; color: var(--text-muted); word-break: break-all; }
.acct-empty { padding: var(--space-32) var(--space-16); text-align: center; }
.acct-empty-title { font-family: var(--font-display); font-size: var(--fs-xl); font-weight: 600; }
.acct-empty-sub { font-size: var(--fs-md); color: var(--text-muted); margin-top: var(--space-4); }
.acct-empty-actions { margin-top: var(--space-14); display: flex; gap: var(--space-8); justify-content: center; flex-wrap: wrap; }
.acct-empty.is-error .acct-empty-title { color: var(--error-text); }
/* Detail drawer (replaces the colspan=12 detail row) */
.acct-drawer-mask {
position: fixed; inset: 0; z-index: 140;
background: var(--scrim); backdrop-filter: blur(2px);
}
.acct-drawer {
position: fixed; top: 0; right: 0; bottom: 0; z-index: 141;
width: min(480px, 100vw); display: flex; flex-direction: column;
background: var(--surface); border-left: 1px solid var(--border); box-shadow: var(--shadow-lg);
}
.acct-drawer.hidden, .acct-drawer-mask.hidden { display: none; }
.acct-drawer-head { display: flex; align-items: flex-start; gap: var(--space-12); padding: var(--space-16) var(--space-20); border-bottom: 1px solid var(--border); }
.acct-drawer-title { font-size: var(--fs-xl); font-weight: 700; line-height: 1.3; word-break: break-all; }
.acct-drawer-sub { font-size: var(--fs-sm); color: var(--text-dim); margin-top: var(--space-4); display: flex; align-items: center; gap: var(--space-8); flex-wrap: wrap; }
.acct-drawer-body { flex: 1; overflow-y: auto; padding: var(--space-16) var(--space-20); }
.acct-drawer-foot {
position: sticky; bottom: 0; display: flex; align-items: center; gap: var(--space-8); flex-wrap: wrap;
padding: var(--space-12) var(--space-20); border-top: 1px solid var(--border); background: var(--surface-2);
}
.acct-drawer-foot .spacer { flex: 1; }
.acct-drawer .account-detail-wrap { padding: 0; background: none; }
.acct-drawer .detail-grid { grid-template-columns: 1fr; gap: var(--space-12); margin-bottom: 0; }
.acct-drawer .detail-card { border-radius: var(--radius); }
/* Primary cards answer the four questions; technical cards live behind this
disclosure so the drawer's first screen is not a wall of ledgers. */
.detail-advanced { margin-top: var(--space-6); border-top: 1px dashed var(--border); padding-top: var(--space-8); }
.detail-advanced > summary {
cursor: pointer; list-style: none; display: flex; align-items: baseline; gap: var(--space-8);
padding: var(--space-6) var(--space-2); font-size: var(--fs-sm); font-weight: 600; color: var(--text-muted);
}
.detail-advanced > summary::-webkit-details-marker { display: none; }
.detail-advanced > summary::before { content: '▸'; color: var(--text-dim); }
.detail-advanced[open] > summary::before { content: '▾'; }
.detail-advanced > summary:hover { color: var(--text); }
.detail-advanced > summary:focus-visible { outline: 2px solid var(--accent-focus); outline-offset: 2px; }
.detail-advanced-hint { font-weight: 400; font-size: var(--fs-sm); color: var(--text-dim); }
.detail-advanced .detail-grid { margin-top: var(--space-10); }
/* Add-account modal: 4 tabs (login / paste / import / local) */
.add-modal { width: min(720px, 100%); max-width: 720px; max-height: 88vh; display: flex; flex-direction: column; }
.add-modal-head { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--space-12); padding: var(--space-16) var(--space-20) var(--space-10); }
.add-tabs { display: flex; gap: var(--space-4); padding: 0 var(--space-20); border-bottom: 1px solid var(--border); flex-wrap: wrap; }
.add-tab {
padding: var(--space-8) var(--space-12); border: 0; border-bottom: 2px solid transparent; background: transparent;
color: var(--text-muted); font: inherit; font-size: var(--fs-md); font-weight: 600; cursor: pointer;
}
.add-tab:hover { color: var(--text); }
.add-tab[aria-selected="true"] { color: var(--text); border-bottom-color: var(--accent); }
.add-tab:focus-visible { outline: 2px solid var(--accent-focus); outline-offset: -2px; }
.add-modal-body { padding: var(--space-16) var(--space-20) var(--space-20); overflow-y: auto; }
.add-panel { display: none; }
.add-panel.active { display: block; }
.add-result { margin-top: var(--space-14); }
.add-result .section { margin-bottom: 0; }
.add-panel .section { border: 0; box-shadow: none; margin-bottom: var(--space-12); }
.add-panel .section-header { padding: 0 0 var(--space-8); border-bottom: 0; }
.add-panel .section-body { padding: 0; }
.add-panel .section-body.tight { padding: 0; }
.add-panel .field-row { margin-bottom: var(--space-10); }
/* Login step 1: two method cards + a secondary token link. */
.login-methods { display: grid; gap: var(--space-10); margin-bottom: var(--space-4); }
.login-methods.hidden { display: none; }
.login-method {
display: flex; flex-direction: column; align-items: flex-start; gap: var(--space-4); width: 100%;
padding: var(--space-14) var(--space-16); text-align: left; cursor: pointer;
background: var(--surface); border: 1px solid var(--border-strong); border-radius: var(--radius);
color: var(--text); font: inherit;
}
.login-method:hover { border-color: var(--accent); background: var(--surface-2); }
.login-method:focus-visible { outline: 2px solid var(--accent-focus); outline-offset: 2px; }
.login-method-title { font-size: var(--fs-lg); font-weight: 700; }
.login-method-desc { font-size: var(--fs-sm); color: var(--text-muted); line-height: 1.5; }
.login-method-token { justify-self: start; }
/* OAuth brand blocks (P2 palette). These were inline `background:#4285F4` with
white text — 3.56, an AA text failure — plus a hardcoded hex outside the
theme system. Tokens keep the brand hue while clearing 4.5. */
.oauth-brand-btn { padding: var(--space-10) var(--space-24); font-weight: 600; gap: var(--space-8); }
.oauth-brand-google { background: var(--brand-google-bg); color: var(--brand-google-fg); }
.oauth-brand-google:hover:not(:disabled) { background: var(--brand-google-bg-hover); border-color: transparent; }
.oauth-brand-github { background: var(--brand-github-bg); color: var(--brand-github-fg); }
.oauth-brand-github:hover:not(:disabled) { background: var(--brand-github-bg-hover); border-color: transparent; }
/* Token step panel: neutral border. Its accent stripe was a leftover of the
pre-workbuddy palette; the panel is a step, not a status. */
.login-token-step {
margin-top: var(--space-14); padding: var(--space-14);
background: var(--surface-2); border-radius: var(--radius);
border-left: 3px solid var(--border-strong);
}
/* "Try another way" / OAuth-only hint panels: same neutral treatment. */
.login-alt-panel {
margin-top: var(--space-14); padding: var(--space-12);
background: var(--surface-2); border-radius: var(--radius);
border-left: 3px solid var(--border-strong);
}
/* ── Usage page (P2 scale) ──────────────────────────────────────────────
The ACU panel reuses .card/.metrics-grid; only its geometry lives here so
the inline px that used to sit in the renderer has one home. */
.usage-org-bar { padding: var(--space-12) var(--space-16); }
.usage-metrics { margin-bottom: var(--space-20); }
.usage-card-value { font-size: var(--fs-4xl); }
.usage-retry { margin-top: var(--space-12); }
.usage-org-inline { display: flex; align-items: center; gap: var(--space-8); flex-wrap: wrap; }
.login-method-panel.hidden { display: none; }
.login-panel-head { display: flex; align-items: center; gap: var(--space-10); margin-bottom: var(--space-10); }
.login-panel-title { font-size: var(--fs-md); font-weight: 700; }
.login-steps { margin: 0; padding-left: var(--space-20); display: grid; gap: var(--space-10); font-size: var(--fs-sm); color: var(--text-muted); line-height: 1.6; }
.login-steps li { display: grid; gap: var(--space-6); justify-items: start; }
.login-steps li > .btn { justify-self: start; }
.login-advanced { margin-top: var(--space-14); border-top: 1px dashed var(--border); padding-top: var(--space-8); }
.login-advanced > summary { cursor: pointer; font-size: var(--fs-sm); font-weight: 600; color: var(--text-muted); padding: var(--space-4) 0; }
.login-advanced > summary:hover { color: var(--text); }
.login-advanced > summary:focus-visible { outline: 2px solid var(--accent-focus); outline-offset: 2px; }
.login-advanced-body { padding-top: var(--space-8); }
.login-advanced-body .section { margin-bottom: var(--space-8); }
@media (max-width: 1100px) {
.acct-kpis { grid-template-columns: repeat(3, 1fr); }
.acct-kpi:nth-child(4) { border-left: 0; }
.acct-kpi:nth-child(n+4) { border-top: 1px solid var(--border); }
#accounts-table { min-width: 1070px; }
}
@media (max-width: 720px) {
.acct-kpis { grid-template-columns: repeat(2, 1fr); }
.acct-kpi { border-left: 0; }
.acct-kpi:nth-child(even) { border-left: 1px solid var(--border); }
.acct-kpi:nth-child(n+3) { border-top: 1px solid var(--border); }
.acct-filters .input { max-width: none; flex-basis: 100%; }
#accounts-table { min-width: 0; table-layout: auto; }
#accounts-table thead { display: none; }
#accounts-table, #accounts-table tbody { display: block; width: 100%; }
#accounts-table tr {
display: grid; grid-template-columns: 1fr 1fr; gap: 2px 10px;
padding: 12px; margin: 0 0 10px; border: 1px solid var(--border); border-radius: var(--radius);
background: var(--surface);
}
#accounts-table td { display: block; border: 0; padding: 2px 0; overflow: visible; }
#accounts-table td[data-label]::before {
content: attr(data-label); display: block; font-size: 12px; font-weight: 600;
letter-spacing: .04em; text-transform: uppercase; color: var(--text-dim);
}
#accounts-table td.acct-account-cell, #accounts-table td.acct-check-cell { grid-column: 1 / -1; }
#accounts-table td.acct-check-cell { display: flex; align-items: center; gap: 8px; }
#accounts-table td.acct-actions-cell { grid-column: 1 / -1; display: flex; justify-content: flex-end; padding-top: 6px; }
#accounts-table .acct-band { box-shadow: none; }
/* P1 #9: the card transformation belongs to the accounts table only. Other
tables (requests, stats, proxy, future tables) keep a real scroll port. */
.accounts-table-wrap { overflow-x: visible; }
.table-wrap:not(.accounts-table-wrap) { overflow-x: auto; overscroll-behavior-inline: contain; }
.acct-drawer { width: 100%; top: auto; height: 88vh; border-left: 0; border-top: 1px solid var(--border); border-radius: var(--radius-lg) var(--radius-lg) 0 0; }
#accounts-batch-bar { position: fixed; left: 12px; right: 12px; bottom: 12px; border: 1px solid var(--border-strong); border-radius: var(--radius); box-shadow: var(--shadow-lg); }
}
.acct-blocked-count { margin-left: var(--space-4); padding: 0 var(--space-4); border-radius: var(--radius-xs); background:var(--warn-soft); color:var(--text); font-size: var(--fs-sm); font-weight:600; }
.acct-quota { font-size: var(--fs-sm); line-height: 1.25; min-width: 0; }
.acct-quota-head { display: flex; align-items: center; gap: var(--space-4); font-size: var(--fs-sm); white-space: nowrap; overflow: hidden; }
.acct-quota-head b { font-weight: 600; overflow: hidden; text-overflow: ellipsis; }
.acct-quota-head > span:last-child { margin-left: auto; }
.acct-quota-bar { display: flex; align-items: center; gap: var(--space-4); margin-top: 1px; line-height: 1; }
.acct-quota-label { width: 10px; flex: none; font-size: var(--fs-sm); font-weight: 600; color: var(--text-dim); }
.acct-quota-track { flex: 1; height: 3px; background: var(--surface-3); border-radius: 2px; overflow: hidden; min-width: 30px; }
.acct-quota-track.na { outline: 1px dashed var(--text-dim); outline-offset: -1px; }
.acct-quota-pct { width: 26px; text-align: right; font-size: var(--fs-sm); font-variant-numeric: tabular-nums; }
.acct-state { gap: var(--space-4); flex-wrap: nowrap; min-width: 0; }
.acct-state .badge { padding: 1px var(--space-6); font-size: var(--fs-sm); flex: none; }
.acct-state-dot { flex: none; }
.acct-countdown { flex: none; padding: 0 var(--space-4); border-radius: var(--radius-xs); background: var(--surface-2); font-variant-numeric: tabular-nums; }
.acct-open { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
</style>
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
<!-- Firebase signInWithPopup was removed: on a self-hosted public origin it always
fails with auth/unauthorized-domain (Firebase authorized-domain lock). Quick
Login now drives the Windsurf official sign-in + paste-token flow instead
(App.oauthLogin), which runs on windsurf.com and is not bound to our origin. -->
</head>
<body>
<!-- P1 #5: below 720px the fixed rail becomes a top app bar + labelled drawer. -->
<header class="mobile-appbar" id="mobile-appbar">
<button class="btn btn-ghost mobile-menu-btn" id="mobile-menu-btn" type="button"
aria-label="打开导航" data-i18n-aria-label="aria.openNav"
aria-expanded="false" aria-controls="app-sidebar" onclick="App.toggleMobileNav()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
</button>
<span class="mobile-appbar-title" id="mobile-appbar-title" data-i18n="nav.overview">仪表盘</span>
<span class="mobile-appbar-spacer"></span>
<button class="btn btn-ghost mobile-appbar-btn" type="button" onclick="App.toggleLang()" data-i18n-title="action.switchLang" title="Switch language">
<span id="mobile-lang-indicator">中文</span>
</button>
<button class="btn btn-ghost mobile-appbar-btn mobile-appbar-icon" type="button" onclick="App.toggleTheme()" data-i18n-title="theme.toggle" title="Toggle theme">
<span id="mobile-theme-indicator"></span>
</button>
</header>
<div class="mobile-nav-mask hidden" id="mobile-nav-mask" onclick="App.closeMobileNav()"></div>
<!-- ════════ Sidebar ════════ -->
<aside class="sidebar" id="app-sidebar">
<div class="brand">
<div class="brand-logo">W</div>
<div>
<div class="brand-name">WindsurfAPI <span style="font-size: 12px;opacity:.6;vertical-align:top;margin-left:2px">bydwgx1337</span></div>
<div class="brand-sub" data-i18n="brand.sub">管理控制台</div>
</div>
</div>
<nav>
<!-- IA reorganized into 4 work areas: Overview / Accounts / Usage / Settings.
Every data-panel + href + onclick target is preserved unchanged — only
the grouping and group labels changed. Panel section IDs are untouched. -->
<div class="nav-group">
<div class="nav-group-label" data-i18n="nav.group.overview">概览</div>
<a class="active" href="#overview" data-panel="overview">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>
<span data-i18n="nav.overview">仪表盘</span>
</a>
</div>
<div class="nav-group">
<div class="nav-group-label" data-i18n="nav.group.account">账号</div>
<a href="#accounts" data-panel="accounts">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
<span data-i18n="nav.accounts">账号管理</span>
</a>
</div>
<div class="nav-group">
<div class="nav-group-label" data-i18n="nav.group.usage">用量</div>
<a href="#devin" data-panel="devin">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M7 9l3 3-3 3"/><path d="M13 15h4"/></svg>
<span data-i18n="nav.devin">Devin 面板</span>
</a>
<a href="#usage" data-panel="usage">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M3 3v18h18"/><path d="M7 14l4-4 3 3 5-6"/></svg>
<span data-i18n="nav.usage">用量</span>
</a>
<a href="#stats" data-panel="stats">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></svg>
<span data-i18n="nav.stats">统计分析</span>
</a>
</div>
<div class="nav-group" id="settings-nav-group">
<div class="nav-group-label" data-i18n="nav.group.settings">设置 · 高级</div>
<input id="settings-search" class="input" type="text" data-i18n-placeholder="settings.search.placeholder" placeholder="筛选设置…" oninput="App.filterSettingsNav(this.value)" style="margin:2px 0 6px;font-size:12px;padding:5px 8px;width:100%;box-sizing:border-box;" />
<a href="#settings" data-panel="settings">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
<span data-i18n="nav.settings">全局设置</span>
</a>
<a href="#models" data-panel="models">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>
<span data-i18n="nav.models">模型控制</span>
</a>
<a href="#proxy" data-panel="proxy">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M17 1l4 4-4 4"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><path d="M7 23l-4-4 4-4"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/></svg>
<span data-i18n="nav.proxy">代理配置</span>
</a>
<a href="#logs" data-panel="logs">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>
<span data-i18n="nav.logs">运行日志</span>
</a>
<a href="#experimental" data-panel="experimental">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M10 2v7.31"/><path d="M14 9.3V2"/><path d="M8.5 2h7"/><path d="M14 9.3a6.5 6.5 0 1 1-4 0"/></svg>
<span data-i18n="nav.experimental">实验性功能</span>
</a>
<a href="#credits" data-panel="credits">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>
<span data-i18n="nav.credits">致谢</span>
</a>
</div>
</nav>
<div class="footer">
<div class="footer-actions">
<button id="lang-toggle-btn" class="btn btn-ghost btn-xs" onclick="App.toggleLang()" data-i18n-title="action.switchLang" title="Switch language">
<span id="lang-indicator">中文</span>
</button>
<button id="theme-toggle-btn" class="btn btn-ghost btn-xs footer-icon-btn" onclick="App.toggleTheme()" data-i18n-title="theme.toggle">
<span id="theme-indicator"></span>
</button>
</div>
<div class="footer-meta">
<span>© Windsurf</span>
<span class="ver" id="sidebar-ver" data-i18n-title="footer.version" title="版本">v1.2.0</span>
</div>
</div>
</aside>
<!-- ════════ Main ════════ -->
<main class="main">
<!-- Overview -->
<section class="panel active" id="p-overview">
<div class="page-header">
<div>
<h1 class="page-title" data-i18n="page.overview">仪表盘</h1>
<div class="page-subtitle" data-i18n="pageSubtitle.overview">系统运行状态与关键指标概览</div>
</div>
<div class="btn-group">
<button class="btn btn-outline btn-sm" id="btn-check-update" onclick="App.checkUpdate()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M21 12a9 9 0 0 1-9 9 9 9 0 0 1-6.24-2.52L3 21"/><path d="M3 12a9 9 0 0 1 9-9 9 9 0 0 1 6.24 2.52L21 3"/><path d="M21 3v6h-6"/><path d="M3 21v-6h6"/></svg>
<span data-i18n="action.checkUpdate">检查更新</span>
</button>
<button class="btn btn-primary btn-sm hidden" id="btn-apply-update" onclick="App.applyUpdate()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M12 2v20M2 12h20"/></svg>
<span id="btn-apply-update-text" data-i18n="action.applyUpdate">一键更新并重启</span>
</button>
<button class="btn btn-outline warn btn-sm hidden" id="btn-rollback-update" onclick="App.rollbackUpdate()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M9 14 4 9l5-5"/><path d="M4 9h10.5a5.5 5.5 0 0 1 5.5 5.5v0a5.5 5.5 0 0 1-5.5 5.5H11"/></svg>
<span id="btn-rollback-update-text" data-i18n="action.rollbackUpdate">回滚到上个版本</span>
</button>
</div>
</div>
<div id="update-status" class="section hidden" style="padding:12px;margin-bottom:12px"></div>
<div id="overview-stale-banner" class="banner banner-warn hidden" role="status" aria-live="polite"></div>
<div class="metrics-grid" id="overview-cards"></div>
<!-- Cockpit: pool health → connect self-healing → trend → rankings.
Each is rendered idempotently by loadOverview from real telemetry;
hidden/empty when its data source is unavailable. -->
<div class="section" id="pool-health-overview">
<div class="section-header">
<div>
<div class="section-title" data-i18n="section.poolHealth.title">账号池健康</div>
<div class="section-desc" data-i18n="section.poolHealth.desc">按最坏优先汇总各账号健康信号</div>
</div>
<div class="seg-group" role="radiogroup" data-i18n-aria-label="aria.poolViewGroup" aria-label="账号池视图" id="pool-view-switch">
<button class="seg-btn pool-view-btn" role="radio" aria-checked="false" data-view="grid" onclick="App.setPoolView('grid')" data-i18n="poolView.grid">发光网格</button>
<button class="seg-btn pool-view-btn active" role="radio" aria-checked="true" data-view="bars" onclick="App.setPoolView('bars')" data-i18n="poolView.bars">状态条</button>
</div>
</div>
<div class="section-body"><div id="pool-health-summary"></div></div>
</div>
<div class="grid-2" style="display:grid;grid-template-columns:1fr;gap:16px;">
<div class="section" id="connect-metrics-overview">
<div class="section-header">
<div>
<div class="section-title" data-i18n="section.connectMetrics.title">连接自愈</div>
<div class="section-desc" data-i18n="section.connectMetrics.desc">实时恢复计数 — 重登、故障转移、限流、失效 token</div>
</div>
</div>
<div class="section-body"><div id="connect-metrics-status" style="width:100%;"></div></div>
</div>
</div>
<div class="section" id="catalog-diff-overview">
<div class="section-header">
<div>
<div class="section-title" data-i18n="section.catalogDiff.title">模型目录差异</div>
<div class="section-desc" data-i18n="section.catalogDiff.desc">实时目录与内置冷启动快照的集合差异 — 只读,不代表套餐权益</div>
</div>
</div>
<div class="section-body"><div id="catalog-diff-body"></div></div>
</div>
<div class="section" id="overview-rankings">
<div class="section-header">
<div>
<div class="section-title" data-i18n="section.rankings.title">排行</div>
<div class="section-desc" data-i18n="section.rankings.desc">按请求量(仅本地计数器)</div>
</div>
</div>
<div class="section-body"><div id="overview-rankings-body"></div></div>
</div>
<div class="section" id="recent-requests-section">
<div class="section-header">
<div>
<div class="section-title" data-i18n="section.recentRequests.title">最近请求</div>
<div class="section-desc" data-i18n="section.recentRequests.desc">最新 20 条已完成请求 — 仅元数据,不含请求头与提示词</div>
</div>
<div style="display:flex;align-items:center;gap:10px">
<span id="recent-requests-meta" class="text-sm text-muted"></span>
<button class="btn btn-outline btn-sm" id="btn-refresh-requests" onclick="App.loadRecentRequests()" data-i18n-title="section.recentRequests.refreshTitle" title="重新加载最近请求">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
<span data-i18n="section.recentRequests.refresh">刷新</span>
</button>
</div>
</div>
<div class="section-body tight">
<div class="table-wrap" tabindex="0" role="region" aria-label="可滚动表格" data-i18n-aria-label="aria.scrollableTable">
<table aria-label="最近请求表格" data-i18n-aria-label="aria.recentRequests">
<thead><tr>
<th data-i18n="table.header.time">时间</th>
<th data-i18n="table.header.model">模型</th>
<th data-i18n="table.header.status">状态</th>
<th data-i18n="table.header.duration">耗时</th>
<th data-i18n="table.header.class">分类</th>
<th data-i18n="table.header.caller">调用方</th>
</tr></thead>
<tbody id="recent-requests-body">
<tr class="empty-row"><td colspan="6" data-i18n="table.empty.noRequestData">暂无请求数据</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<div class="section" id="ls-section">
<div class="section-header">
<div>
<div class="section-title" data-i18n="section.lsStatus.title">Language Server</div>
<div class="section-desc" data-i18n="section.lsStatus.desc">语言服务器实例状态</div>
</div>
<div style="display:flex;gap:8px">
<button class="btn btn-outline btn-sm" onclick="App.updateLsBinary()" data-i18n-title="action.updateLsBinaryTitle" title="Download latest language_server binary and restart pool">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
<span data-i18n="action.updateLsBinary">更新 LS</span>
</button>
<button class="btn btn-outline btn-sm" onclick="App.restartLs()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
<span data-i18n="action.restart">重启</span>
</button>
</div>
</div>
<div class="section-body">
<div id="ls-status"></div>
<div id="ls-binary-info" style="margin-top:12px;font-size:12px;color:var(--text-muted)"></div>
</div>
</div>
<div class="section" id="native-bridge-overview-section">
<div class="section-header">
<div>
<div class="section-title" data-i18n="section.nativeBridge.title">原生桥接</div>
<div class="section-desc" data-i18n="section.nativeBridge.desc">路由决策与灰度门可见性</div>
</div>
</div>
<div class="section-body">
<div id="native-bridge-status"></div>
</div>
</div>
</section>
<!-- Accounts -->
<section class="panel" id="p-accounts">
<div class="page-header">
<div>
<h1 class="page-title" data-i18n="page.accounts">账号管理</h1>
<div class="page-subtitle" id="accounts-subtitle" data-i18n="pageSubtitle.accounts">维护账号池,检查各账号的套餐层级与可用模型</div>
</div>
<!-- One credential-entry surface (the add modal), three inline entry verbs.
The old standalone add-account box and the separate login page are gone,
so this group is the only place on the page that opens a credential form. -->
<div class="acct-add-group" role="group" aria-label="登录、导入或粘贴凭证" data-i18n-aria-label="accounts.add.groupLabel">
<button class="btn btn-primary" id="accounts-login-btn" onclick="App.openAddAccountModal('login')" data-i18n="accounts.add.login">登录</button>
<button class="btn btn-outline" id="accounts-import-btn" onclick="App.openAddAccountModal('import')" data-i18n="accounts.add.import">导入</button>
<button class="btn btn-outline" id="accounts-add-btn" onclick="App.openAddAccountModal('paste')" data-i18n="accounts.add.paste">粘贴凭证</button>
</div>
</div>
<!-- v2.0.57 Fix 5 — drought mode banner: shown only when every active
account has weekly% < 5. Hidden by default; loadDrought() flips it. -->
<div id="drought-banner" class="banner banner-warn" style="display:none;margin:0 0 16px;padding:14px 18px;border-radius:var(--radius);background:var(--warn-soft);border:1px solid var(--border-strong);color:var(--text)">
<strong data-i18n="drought.title">配额预警:</strong>
<span id="drought-message" data-i18n="drought.body">所有账号本周配额都低于阈值,建议补账号或等待重置</span><span data-i18n-tip="drought"></span>
<span id="drought-detail" class="text-sm text-muted" style="margin-left:8px"></span>
</div>
<!-- A failed GET is its own state: the last valid table stays painted (never
replace a real pool with an empty one) and this banner explains the
staleness with a retry. Hidden unless a load actually fails. -->
<div id="accounts-error" class="banner banner-error" style="display:none;margin:0 0 16px;padding:12px 16px;border-radius:var(--radius);background:var(--error-soft);border:1px solid var(--error);color:var(--text)" role="alert">
<span id="accounts-error-text"></span>
<button class="btn btn-outline btn-xs" style="margin-left:10px" onclick="App.loadAccounts()" data-i18n="accounts.retry">重试</button>
</div>
<div class="acct-load-status" id="accounts-load-status" role="status" aria-live="polite" hidden></div>
<!-- KPI strip — six cells, one panel, zero extra requests (stats rides the list response). -->
<div class="acct-kpis" id="accounts-kpis" aria-live="polite"></div>
<!-- Fleet toolbar: long-running pool operations live here, not in the page header. -->
<div class="acct-toolbar">
<div class="tb-group" role="group" data-i18n-aria-label="accounts.toolbar.fleet">
<button class="btn btn-outline btn-sm" onclick="App.startFleetRun('probe')" data-i18n="accounts.toolbar.probeAll">全部检查</button>
<button class="btn btn-outline btn-sm" onclick="App.startFleetRun('refresh')" data-i18n="accounts.toolbar.refreshAll">刷新全部余额</button>
<button class="btn btn-outline btn-sm" id="accounts-fleet-cancel" onclick="App.cancelFleetRun()" hidden data-i18n="accounts.fleet.cancel">取消</button>
<button class="btn btn-outline btn-sm" id="accounts-fleet-reopen" onclick="App.reopenFleetPanel()" hidden data-i18n="accounts.fleet.reopen">本次结果</button>
<button class="btn btn-outline btn-sm" onclick="App.exportAccounts()" data-i18n="accounts.export.label">导出</button>
</div>
<div class="tb-group" role="group" data-i18n-aria-label="accounts.toolbar.view">
<button class="btn btn-ghost btn-sm" id="accounts-density-btn" onclick="App.toggleAccountsDensity()" aria-pressed="false" data-i18n-title="accounts.density.title" title="切换紧凑/宽松密度"><span data-i18n="accounts.density.label">紧凑</span></button>
</div>
<!-- Sort: server-side (sort/order query params), persisted with the page
size. The quota and last-used columns are also clickable headers. -->
<label class="acct-sort-field">
<span class="sr-only" data-i18n="accounts.sort.label">排序</span>
<select id="accounts-sort" class="select acct-sort-select" onchange="App.setAccountsSort(this.value)" data-i18n-title="accounts.sort.hint" title="点一次排序,再点一次反向">
<option value="" data-i18n="accounts.sort.default">默认顺序</option>
<option value="quota_weekly" data-i18n="table.header.quota">配额</option>
<option value="balance" data-i18n="table.header.credits">余额</option>
<option value="last_used" data-i18n="table.header.lastUsed">最后使用</option>
<option value="errors" data-i18n="table.header.errorCount">错误数</option>
</select>
</label>
<span class="acct-op-status" id="accounts-op-status" aria-live="polite"></span>
</div>
<!-- Result panel for the last fleet run. Independent of the batch bar on
purpose: a probe-all has no selected rows, and the failures must stay
readable after the run (and after the bar closes). -->
<div id="accounts-fleet-panel" class="fleet-panel" hidden>
<div class="fleet-panel-head">
<span id="accounts-fleet-summary"></span>
<button class="btn btn-ghost btn-xs" id="accounts-fleet-excluded" hidden onclick="App.toggleFleetExcluded()" aria-expanded="false" data-i18n="accounts.fleet.excludedToggle">已排除非活跃账号</button>
<button class="btn btn-ghost btn-xs" id="accounts-fleet-toggle" aria-expanded="false" hidden onclick="App.toggleFleetDetails()" data-i18n="accounts.fleet.details">查看明细</button>
<button class="btn btn-ghost btn-xs" id="accounts-fleet-dismiss" onclick="App.dismissFleetPanel()" data-i18n="accounts.fleet.dismiss">关闭</button>
</div>
<ul id="accounts-fleet-list" class="fleet-list" hidden></ul>
<ul id="accounts-fleet-excluded-list" class="fleet-list" hidden></ul>
</div>
<!-- Filter bar: search / tier / status groups / count / clear. Every control
maps to a server query param, so a filter never silently means "this page". -->
<div class="acct-filters" role="search">
<input id="accounts-search" type="search" class="input" autocomplete="off" oninput="App.onAccountsSearchInput(this.value)" data-i18n-placeholder="accounts.filter.searchPlaceholder" placeholder="搜索邮箱 / ID / 标签">
<select id="accounts-tier-filter" class="select" onchange="App.setAccountsTier(this.value)" data-i18n-title="accounts.tierFilter.title" title="按层级筛选">
<option value="" data-i18n="accounts.tierFilter.all">全部层级</option>
<option value="pro" data-i18n="accounts.tierFilter.pro">Pro</option>
<option value="free" data-i18n="accounts.tierFilter.free">Free</option>
<option value="unknown" data-i18n="accounts.tierFilter.unknown">未知</option>
<option value="expired" data-i18n="accounts.tierFilter.expired">已过期</option>
</select>
<!-- Status vocabulary: three human groups by default. The ten raw states
stay one click away behind the "more statuses" button, each with a
one-line explanation; "needs attention" is the SAME `filter=flagged`
predicate as the KPI cell. -->
<select id="accounts-status-filter" class="select" onchange="App.setAccountsStatus(this.value)" data-i18n-title="accounts.filter.statusTitle" title="按状态筛选">
<option value="" data-i18n="accounts.filter.allStatus" data-i18n-title="accounts.filter.allStatusTip" title="不按状态筛选">全部状态</option>
<option value="active" data-i18n="accounts.status.active" data-i18n-title="accounts.statusTip.active" title="能接请求:未停用、未限流、配额未耗尽">正常</option>
<option value="disabled" data-i18n="accounts.status.disabled" data-i18n-title="accounts.statusTip.disabled" title="你手动停用的账号">已停用</option>
<option value="attention" data-i18n="accounts.filter.attention" data-i18n-title="accounts.filter.attentionTip" title="需处理">需处理</option>
</select>
<button class="btn btn-ghost btn-xs" id="accounts-status-more" aria-expanded="false" aria-controls="accounts-status-filter" onclick="App.toggleStatusDetails()" data-i18n="accounts.filter.moreStatus">更多状态</button>
<template id="accounts-status-details">
<option value="active" data-status-detail data-i18n="accounts.status.active" data-i18n-title="accounts.statusTip.active" title="能接请求:未停用、未限流、配额未耗尽">正常</option>
<option value="disabled" data-status-detail data-i18n="accounts.status.disabled" data-i18n-title="accounts.statusTip.disabled" title="你手动停用的账号">已停用</option>
<option value="banned" data-status-detail data-i18n="accounts.status.banned" data-i18n-title="accounts.statusTip.banned" title="Windsurf 封禁了这个账号">已封禁</option>
<option value="reauth" data-status-detail data-i18n="accounts.status.reauth" data-i18n-title="accounts.statusTip.reauth" title="登录凭证已失效,需要重新登录">需要重新授权</option>
<option value="error" data-status-detail data-i18n="accounts.status.error" data-i18n-title="accounts.statusTip.error" title="最近一次请求或检查失败">异常</option>
<option value="quota_cooled" data-status-detail data-i18n="accounts.status.quotaCooled" data-i18n-title="accounts.statusTip.quota_cooled" title="配额用尽,等待重置">配额耗尽</option>
<option value="rate_limited" data-status-detail data-i18n="accounts.status.rateLimited" data-i18n-title="accounts.statusTip.rate_limited" title="被服务限流,稍后自动恢复">限流中</option>
<option value="capacity" data-status-detail data-i18n="accounts.status.capacity" data-i18n-title="accounts.statusTip.capacity" title="当前模型容量不足,换模型或稍后重试">模型繁忙</option>
<option value="near_limit" data-status-detail data-i18n="accounts.status.nearLimit" data-i18n-title="accounts.statusTip.near_limit" title="每分钟请求用量已接近上限">接近上限</option>
<option value="watch" data-status-detail data-i18n="accounts.status.watch" data-i18n-title="accounts.statusTip.watch" title="有历史错误计数,但仍可接请求">有错误</option>
</template>
<span class="acct-filter-count" id="accounts-filter-count"></span>
<button class="btn btn-ghost btn-xs acct-filter-clear" id="accounts-filter-clear" onclick="App.clearAccountsFilters()" hidden data-i18n="accounts.filter.clear">清除筛选</button>
</div>
<div class="section">
<div class="section-body tight">
<div id="accounts-batch-bar" class="batch-bar" style="display:none">
<span class="batch-count" id="accounts-batch-count"></span>
<span class="batch-scope" id="accounts-batch-scope"></span>
<div class="btn-group">
<button class="btn btn-ghost btn-xs" onclick="App.batchAccountOp('enable')" data-i18n="batchOps.enable">启用</button>
<button class="btn btn-ghost btn-xs" onclick="App.batchAccountOp('disable')" data-i18n="batchOps.disable">禁用</button>
<button class="btn btn-ghost btn-xs" onclick="App.batchAccountOp('probe')" data-i18n="batchOps.probe">检查</button>
<button class="btn btn-ghost btn-xs" onclick="App.batchAccountOp('refresh')" data-i18n="batchOps.refresh">刷新余额</button>
<button class="btn btn-ghost btn-xs" onclick="App.batchAccountOp('reset')" data-i18n="accounts.batch.reset">重置错误</button>
<button class="btn btn-ghost btn-xs" onclick="App.batchAccountOp('export')" data-i18n="accounts.batch.export">导出所选</button>
<button class="btn btn-ghost btn-xs" style="color: var(--error-text)" onclick="App.batchAccountOp('delete')" data-i18n="batchOps.delete">删除</button>
<button class="btn btn-ghost btn-xs" onclick="App.clearAccountSelection()" data-i18n="batchOps.clear">清除选择</button>
</div>
<span class="batch-progress" id="accounts-batch-progress" aria-live="polite"></span>
<button class="batch-fail-link" id="accounts-batch-fail" hidden onclick="App.toggleBatchFailures()" data-i18n="accounts.batch.viewFailures">查看失败</button>
<div class="batch-fail-pop" id="accounts-batch-fail-pop" hidden></div>
</div>
<div class="table-wrap accounts-table-wrap">
<table id="accounts-table">
<thead><tr><th class="acct-check-col" style="text-align:center"><input type="checkbox" id="accounts-select-all" aria-label="选择本页" data-i18n-aria-label="accounts.selectAllPage" data-i18n-title="accounts.selectAllPage" title="选择本页" onchange="App.toggleSelectAllAccounts(this.checked)"></th><th class="acct-account-col" data-i18n="table.header.account">账号</th><th class="acct-tier-col" data-i18n="table.header.tier">层级</th><th class="acct-state-col" data-i18n="table.header.status">状态</th><th class="acct-quota-col th-help" data-i18n-title="creditsBar.columnTooltip" title="配额使用情况" aria-sort="none"><button type="button" class="th-sort" onclick="App.setAccountsSort('quota_weekly')" data-i18n-title="accounts.sort.hint" title="点一次排序,再点一次反向"><span data-i18n="table.header.quota">配额</span><span class="th-sort-mark" aria-hidden="true"></span></button></th><th class="acct-models-col" data-i18n="table.header.models">模型</th><th class="acct-rpm-col" data-i18n="table.header.rpm">RPM</th><th class="acct-last-col" aria-sort="none"><button type="button" class="th-sort" onclick="App.setAccountsSort('last_used')" data-i18n-title="accounts.sort.hint" title="点一次排序,再点一次反向"><span data-i18n="table.header.lastUsed">最后使用</span><span class="th-sort-mark" aria-hidden="true"></span></button></th><th class="acct-actions-col" data-i18n="table.header.action">操作</th></tr></thead>
<tbody></tbody>
</table>
</div>
<div class="table-pagination" id="accounts-pagination">
<button class="btn btn-ghost btn-xs" id="accounts-prev" onclick="App.setAccountsPage(App.accountsPage - 1)" data-i18n="action.prev">上一页</button>
<span class="page-label" id="accounts-page-label">-</span>
<select class="select" id="accounts-page-size" onchange="App.setAccountsPageSize(this.value)">
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
<option value="200">200</option>
</select>
<button class="btn btn-ghost btn-xs" id="accounts-next" onclick="App.setAccountsPage(App.accountsPage + 1)" data-i18n="action.next">下一页</button>
</div>
</div>
</div>
<!-- Detail drawer: replaces the old inline detail row (a colspan cell). -->
<div class="acct-drawer-mask hidden" id="acct-drawer-mask" onclick="App.closeAccountDrawer()"></div>
<aside class="acct-drawer hidden" id="acct-drawer" role="dialog" aria-modal="true" aria-labelledby="acct-drawer-title" tabindex="-1">
<div class="acct-drawer-head">
<div style="min-width:0;flex:1">
<div class="acct-drawer-title" id="acct-drawer-title"></div>
<div class="acct-drawer-sub" id="acct-drawer-sub"></div>
</div>
<button class="btn btn-ghost btn-sm" onclick="App.closeAccountDrawer()" aria-label="关闭" data-i18n-aria-label="action.close">×</button>
</div>
<div class="acct-drawer-body" id="acct-drawer-body"></div>
<div class="acct-drawer-foot" id="acct-drawer-foot"></div>
</aside>
</section>
<!-- Add / login / import modal — the ONLY entry point for new credentials. -->
<div class="modal-overlay hidden" id="add-account-overlay" role="presentation" onclick="if(event.target===this)App.closeAddAccountModal()">
<div class="modal add-modal" role="dialog" aria-modal="true" aria-labelledby="add-modal-title">
<div class="modal-header add-modal-head">
<div>
<!-- Title follows the active tab (set by App.setAddModalTitle), so the
entry verb the user clicked is the one they land on. -->
<div class="modal-title" id="add-modal-title"></div>
<div class="modal-desc" data-i18n="accounts.add.desc">选择一种方式添加账号:登录、粘贴凭证、批量导入或从本机扫描</div>
</div>
<button class="btn btn-ghost btn-sm" onclick="App.closeAddAccountModal()" aria-label="关闭" data-i18n-aria-label="action.close">×</button>
</div>
<!-- SessionReauth: shown only when the modal was opened from an account
row. Every submit then carries accountId, so the login REPLACES that
account's token instead of adding a duplicate row. -->
<div id="add-reauth-banner" class="reauth-notice hidden" role="status">
<div class="reauth-notice-title" data-i18n="accounts.reauth.bannerTitle">需要重新授权</div>
<div class="text-sm" id="add-reauth-banner-text"></div>
<button class="btn btn-outline btn-sm" onclick="App.clearReauthBinding()" data-i18n="accounts.reauth.cancelBinding">取消绑定</button>
</div>
<div class="add-tabs" role="tablist" data-i18n-aria-label="accounts.add.tabsLabel" onkeydown="App.onAddTabKeydown(event)">
<button class="add-tab active" role="tab" id="add-tab-login" aria-selected="true" aria-controls="add-panel-login" onclick="App.switchAddTab('login')" data-i18n="accounts.add.tab.login">登录</button>
<button class="add-tab" role="tab" id="add-tab-paste" aria-selected="false" aria-controls="add-panel-paste" tabindex="-1" onclick="App.switchAddTab('paste')" data-i18n="accounts.add.tab.paste">粘贴凭证</button>
<button class="add-tab" role="tab" id="add-tab-import" aria-selected="false" aria-controls="add-panel-import" tabindex="-1" onclick="App.switchAddTab('import')" data-i18n="accounts.add.tab.import">批量导入</button>
<button class="add-tab" role="tab" id="add-tab-local" aria-selected="false" aria-controls="add-panel-local" tabindex="-1" onclick="App.switchAddTab('local')" data-i18n="accounts.add.tab.local">本机扫描</button>
</div>
<div class="add-modal-body">
<div class="add-panel active" id="add-panel-login" role="tabpanel" aria-labelledby="add-tab-login">
<!-- Step 1: choose ONE method. The proxy fields, the saved-password
checkbox and the login history are under the advanced disclosure,
so the first screen is two choices plus a secondary token link —
not four stacked sections competing for attention. -->
<div class="login-methods" id="wl-method-step" role="group" data-i18n-aria-label="accounts.login.methodsLabel">
<button type="button" class="login-method" id="wl-method-oauth-btn" onclick="App.selectLoginMethod('oauth')">
<span class="login-method-title" data-i18n="section.oauthLogin.title">Google / GitHub 登录</span>
<span class="login-method-desc" data-i18n="section.oauthLogin.desc">在 Windsurf 官方页面完成登录,回到这里粘贴 Token。</span>
</button>
<button type="button" class="login-method" id="wl-method-email-btn" onclick="App.selectLoginMethod('email')">
<span class="login-method-title" data-i18n="section.emailLogin.title">邮箱登录</span>
<span class="login-method-desc" data-i18n="section.emailLogin.desc">有密码的账号用密码登录;没有密码的账号改用邮箱验证码。</span>
</button>
<button type="button" class="btn btn-ghost btn-sm login-method-token" id="wl-method-token-btn" onclick="App.selectLoginMethod('token')" data-i18n="accounts.login.pasteToken">粘贴 Token</button>
</div>
<div class="login-method-panel hidden" id="wl-panel-oauth">
<div class="login-panel-head">
<button type="button" class="btn btn-ghost btn-xs" onclick="App.selectLoginMethod(null)" data-i18n="accounts.login.back">换一种方式</button>
<span class="login-panel-title" data-i18n="section.oauthLogin.title">Google / GitHub 登录</span>
</div>
<div class="section-body">
<div style="display:flex;gap:12px;flex-wrap:wrap">
<button class="btn oauth-brand-btn oauth-brand-google" onclick="App.oauthLogin('google')" id="oauth-google-btn">
<svg width="18" height="18" viewBox="0 0 48 48"><path fill="#FFC107" d="M43.6 20.1H42V20H24v8h11.3C33.9 33.1 29.3 36 24 36c-6.6 0-12-5.4-12-12s5.4-12 12-12c3.1 0 5.8 1.2 8 3l5.7-5.7C34 6 29.3 4 24 4 13 4 4 13 4 24s9 20 20 20 20-9 20-20c0-1.3-.2-2.7-.4-3.9z"/><path fill="#FF3D00" d="M6.3 14.7l6.6 4.8C14.5 15.5 18.8 12 24 12c3.1 0 5.8 1.2 8 3l5.7-5.7C34 6 29.3 4 24 4 16.3 4 9.7 8.3 6.3 14.7z"/><path fill="#4CAF50" d="M24 44c5.2 0 9.9-2 13.4-5.2l-6.2-5.2C29.2 35.1 26.7 36 24 36c-5.2 0-9.6-3.6-11.2-8.5l-6.5 5C9.5 39.6 16.2 44 24 44z"/><path fill="#1976D2" d="M43.6 20.1H42V20H24v8h11.3c-.8 2.2-2.2 4.1-4.1 5.6l6.2 5.2C37 39.2 44 34 44 24c0-1.3-.2-2.7-.4-3.9z"/></svg>
<span data-i18n="oauth.google">Google 登录</span>
</button>
<button class="btn oauth-brand-btn oauth-brand-github" onclick="App.oauthLogin('github')" id="oauth-github-btn">
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M12 0C5.37 0 0 5.37 0 12c0 5.3 3.44 9.8 8.2 11.38.6.11.82-.26.82-.58v-2.03c-3.34.73-4.04-1.61-4.04-1.61-.55-1.39-1.34-1.76-1.34-1.76-1.08-.74.08-.73.08-.73 1.2.08 1.84 1.23 1.84 1.23 1.07 1.83 2.81 1.3 3.5 1 .1-.78.42-1.3.76-1.6-2.67-.3-5.47-1.33-5.47-5.93 0-1.31.47-2.38 1.24-3.22-.13-.3-.54-1.52.12-3.18 0 0 1-.32 3.3 1.23a11.5 11.5 0 016.02 0c2.28-1.55 3.29-1.23 3.29-1.23.66 1.66.25 2.88.12 3.18.77.84 1.24 1.91 1.24 3.22 0 4.61-2.8 5.63-5.48 5.92.43.37.82 1.1.82 2.22v3.29c0 .32.21.7.82.58C20.56 21.8 24 17.3 24 12 24 5.37 18.63 0 12 0z"/></svg>
<span data-i18n="oauth.github">GitHub 登录</span>
</button>
</div>
<div id="oauth-status" aria-live="polite" style="margin-top:8px;font-size:12px;color:var(--text-muted)"></div>
<!-- Three steps, each with its own action button. Step 2 fills the
box and imports; step 3 imports whatever is in the box. -->
<div id="oauth-token-step" class="login-token-step hidden">
<div style="font-weight:600;margin-bottom:6px" data-i18n="oauth.step2Title">粘贴 Windsurf 返回的 Token</div>
<div style="font-size:12px;color:var(--text-muted);margin-bottom:10px" data-i18n="oauth.step2Desc">登录完成后,Windsurf 会显示一串 Token。复制它,或直接复制整条回调地址。</div>
<ol class="login-steps">
<li>
<span data-i18n="oauth.step1">在新标签页完成登录(Google、GitHub 或邮箱验证码均可)。</span>
<button class="btn btn-outline btn-sm" onclick="App.fetchWindsurfToken()" id="oauth-fetch-token-btn" data-i18n="oauth.openSignin">打开 Windsurf 登录页</button>
</li>
<li>
<span data-i18n="oauth.step2">登录完成后页面会显示 Token;点“复制”或直接全选地址栏。</span>
<button class="btn btn-outline btn-sm" onclick="App.pasteOAuthTokenFromClipboard()" id="oauth-paste-btn" data-i18n="oauth.pasteFromClipboard">从剪贴板粘贴</button>
</li>
<li>
<span data-i18n="oauth.step3">回到这里,粘贴并添加;Token 会立即写入账号池。</span>
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-top:6px">
<input id="oauth-token-input" type="text" class="input" style="flex:1;min-width:220px" data-i18n-placeholder="oauth.urlOrTokenPlaceholder" placeholder="粘贴回调 URL 或 Auth Token...">
<button class="btn btn-primary" onclick="App.submitOAuthToken()" id="oauth-import-btn" data-i18n="oauth.addWithToken">用 Token 添加</button>
</div>
</li>
</ol>
<a id="oauth-manual-link" href="#" class="hidden" onclick="return App.openWindsurfSignin(event)" style="display:inline-block;margin-top:8px;font-size:12px;color: var(--accent-text)" data-i18n="oauth.openManually">登录页没弹出来?点这里手动打开</a>
</div>
</div>
</div>
<div class="login-method-panel hidden" id="wl-panel-email">
<div class="login-panel-head">
<button type="button" class="btn btn-ghost btn-xs" onclick="App.selectLoginMethod(null)" data-i18n="accounts.login.back">换一种方式</button>
<span class="login-panel-title" data-i18n="section.emailLogin.title">邮箱登录</span>
</div>
<div class="section-body">
<div class="field-row">
<div class="field">
<label class="field-label" data-i18n="field.email.label">邮箱</label>
<input id="wl-email" class="input" data-i18n-placeholder="field.email.placeholder" placeholder="your-email@example.com" autocomplete="off" onblur="App.windsurfProbeFromInput()" oninput="App.onWindsurfEmailInput()">
<div class="field-hint" id="wl-probe-status" aria-live="polite"></div>
</div>
<div class="field" id="wl-password-field">
<label class="field-label" data-i18n="field.password.label">密码</label>
<input id="wl-password" class="input" type="password" data-i18n-placeholder="field.password.placeholder" placeholder="••••••••" autocomplete="off" onkeydown="if(event.key==='Enter')App.windsurfLogin()">
</div>
</div>
<div class="field-row-actions">
<button class="btn btn-primary" onclick="App.windsurfLogin()" id="wl-btn">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/><polyline points="10 17 15 12 10 7"/><line x1="15" y1="12" x2="3" y2="12"/></svg>
<span data-i18n="action.login">登录</span>
</button>
<label class="checkbox" style="margin-left:auto">
<input type="checkbox" id="wl-auto-add" checked>
<span class="box"></span>
<span data-i18n="field.autoAdd">登录成功自动加入账号池</span>
</label>
</div>
<!-- Email-code path: shown the moment the probe reports
has_password:false. The code is requested by the operator
(the Login / Send-code buttons), never auto-sent on blur. -->
<div id="wl-email-code-field" class="hidden" style="margin-top:14px;padding-top:14px;border-top:1px dashed var(--border)">
<div class="field-row">
<div class="field">
<label class="field-label" data-i18n="field.otpCode.label">验证码 / OTP Code</label>
<input id="wl-email-code" class="input" inputmode="numeric" autocomplete="one-time-code" maxlength="10" data-i18n-placeholder="field.otpCode.placeholder" placeholder="123456" onkeydown="if(event.key==='Enter')App.windsurfCompleteEmailCode()">
<div class="field-hint" data-i18n="field.otpCode.hint">从邮箱获取 6 位验证码</div>
</div>
<div class="field" style="align-self:flex-end">
<button class="btn btn-outline" onclick="App.windsurfSendEmailCode()" id="wl-send-code-btn">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M22 2L11 13"/><path d="M22 2l-7 20-4-9-9-4 20-7z"/></svg>
<span id="wl-send-code-label" data-i18n="action.sendCode">发送验证码</span>
</button>
</div>
</div>
<div class="field-row-actions">
<button class="btn btn-primary" onclick="App.windsurfCompleteEmailCode()" id="wl-code-login-btn" disabled>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M20 6L9 17l-5-5"/></svg>
<span data-i18n="action.verifyAndLogin">验证并登录</span>
</button>
<button class="btn btn-ghost btn-sm" onclick="App.windsurfSendEmailCode({host:'devin'})" id="wl-devin-code-btn" data-i18n="emailLogin.tryDevinHost">没收到邮件?改用 app.devin.ai 发送</button>
</div>
<div id="wl-code-status" aria-live="polite" style="margin-top:8px;font-size:12px;color:var(--text-muted)"></div>
</div>
<!-- OAuth-only path: has_password:false and no email connection. -->
<div id="wl-oauth-hint" class="login-alt-panel hidden">
<div class="text-sm" data-i18n="emailLogin.oauthOnlyHint">该账号没有密码,也不支持邮箱验证码。请用上面的 Google / GitHub 按钮登录,或粘贴 show-auth-token 页面的 Token。</div>
<button class="btn btn-outline" style="margin-top:8px" onclick="App.showOAuthTokenStep()" data-i18n="emailLogin.useToken">改用 Token 登录</button>
</div>
</div>
</div>
<!-- Advanced: proxy, saved-password opt-in, login history. Closed by
default so the first screen is the two methods above. -->
<details class="login-advanced" id="wl-advanced">
<summary data-i18n="accounts.login.advanced">高级选项(代理 / 登录历史 / 存储密码)</summary>
<div class="login-advanced-body">
<label class="checkbox" style="margin:8px 0 12px" data-i18n-title="field.storeCredentialHint" title="加密保存密码用于自动续期">
<input type="checkbox" id="wl-store-cred">
<span class="box"></span>
<span data-i18n="field.storeCredential">加密保存密码以在会话失效时自动续期</span>
</label>
<div class="section" id="wl-proxy-section">
<div class="section-header">
<div>
<div class="section-title" data-i18n="section.loginProxy.title">登录代理(可选)</div>
<div class="section-desc" data-i18n="section.loginProxy.desc">为本次登录指定代理;留空则使用全局代理设置。代理生效后账号的后续聊天请求也会经此代理出站</div>
</div>
</div>
<div class="section-body">
<div class="field-row">
<div class="field" style="max-width:120px">
<label class="field-label" data-i18n="field.type.label">类型</label>
<select id="wl-proxy-type" class="select">
<option value="http">HTTP</option>
<option value="https">HTTPS</option>
<option value="socks5">SOCKS5</option>
</select>
</div>
<div class="field" style="flex:2">
<label class="field-label" data-i18n="field.proxyHost.label">主机</label>
<input id="wl-proxy-host" class="input" data-i18n-placeholder="field.proxyHost.placeholder" placeholder="留空=使用全局">
</div>
<div class="field" style="max-width:110px">
<label class="field-label" data-i18n="field.port.label">端口</label>
<input id="wl-proxy-port" class="input" data-i18n-placeholder="field.port.placeholder" placeholder="8080" type="number">
</div>
<div class="field">
<label class="field-label" data-i18n="field.username.label">用户名</label>
<input id="wl-proxy-user" class="input" data-i18n-placeholder="field.username.placeholder" placeholder="可选">
</div>
<div class="field">
<label class="field-label" data-i18n="field.passwordProxy.label">密码</label>
<input id="wl-proxy-pass" class="input" type="password" data-i18n-placeholder="field.passwordProxy.placeholder" placeholder="可选">
</div>
</div>
<div style="display:flex;gap:8px;align-items:center;margin-top:10px">
<button class="btn btn-outline btn-sm" onclick="App.testLoginProxy()" id="wl-proxy-test-btn">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M9 12l2 2 4-4"/><circle cx="12" cy="12" r="10"/></svg>
<span data-i18n="proxy.test.button">测试代理</span>
</button>
<span id="wl-proxy-test-result" class="text-sm text-muted"></span>
</div>
</div>
</div>
<div class="section">
<div class="section-header">
<div>
<div class="section-title" data-i18n="section.loginHistory.title">登录历史</div>
<div class="section-desc" data-i18n="section.loginHistory.desc">本地记录最近 50 条登录操作</div>
</div>
</div>
<div class="section-body tight">
<div class="table-wrap" tabindex="0" role="region" aria-label="可滚动表格" data-i18n-aria-label="aria.scrollableTable">
<table id="wl-history-table">
<thead><tr><th data-i18n="table.header.time">时间</th><th data-i18n="table.header.email">邮箱</th><th data-i18n="table.header.status">状态</th><th data-i18n="table.header.proxy">代理</th><th data-i18n="table.header.action">操作</th></tr></thead>
<tbody></tbody>
</table>
</div>
</div>
</div>
</div>
</details>
</div>
<div class="add-panel" id="add-panel-paste" role="tabpanel" aria-labelledby="add-tab-paste">
<div class="section">
<div class="section-header">
<div>
<div class="section-title" data-i18n="section.addAccount.title">添加账号</div>
<div class="section-desc" data-i18n="section.addAccount.desc">支持 API Key 或 Auth Token 两种方式</div>
</div>
</div>
<div class="section-body">
<div style="padding:8px 12px;margin-bottom:12px;background:var(--surface-2);border-radius:var(--radius);font-size:12px;color:var(--text-muted);line-height:1.6">
<span data-i18n="help.tokenGuide">推荐用 Token 方式添加账号 点</span>
<a href="https://windsurf.com/show-auth-token" target="_blank" style="color: var(--accent-text);font-weight:600">windsurf.com/show-auth-token</a>
<span data-i18n="help.tokenGuideEnd">登录后复制 Token 粘贴到下面就行 所有登录方式(邮箱/Google/GitHub)都能用</span>
</div>
<div class="field-row">
<div class="field" style="max-width:180px">
<label class="field-label" data-i18n="field.type.label">类型</label>
<select id="acc-type" class="select">
<option value="token" selected>Auth Token</option>
<option value="api_key">API Key</option>
</select>
</div>
<div class="field" style="flex:2">
<label class="field-label" data-i18n="field.key.label">Key / Token</label>
<input id="acc-key" class="input" data-i18n-placeholder="field.key.placeholder" placeholder="粘贴 Auth Token">
</div>
<div class="field" style="max-width:200px">
<label class="field-label" data-i18n="field.label.label">标签</label>
<input id="acc-label" class="input" data-i18n-placeholder="field.label.placeholder" placeholder="可选">
</div>
</div>
<div class="field-row" style="margin-top:12px">
<div class="field" style="flex:1">
<label class="field-label" data-i18n="field.proxy.label">代理</label>
<input id="acc-proxy" class="input" data-i18n-placeholder="field.proxy.placeholder" placeholder="http://proxy:8080 或 socks5://user:pass@host:port(可选)">
</div>
</div>
<div class="field-hint" style="margin-top:4px" data-i18n="field.proxy.hint">留空则使用全局代理设置</div>
<div class="field-row-actions">
<button class="btn btn-primary" onclick="App.addAccount()" data-i18n="action.add">添加账号</button>
</div>
</div>
</div>
</div>
<div class="add-panel" id="add-panel-import" role="tabpanel" aria-labelledby="add-tab-import">
<div class="section">
<div class="section-header">
<div>
<div class="section-title" data-i18n="section.smartImport.title">智能导入</div>
<div class="section-desc" data-i18n="section.smartImport.desc">粘贴即识别,支持批量。自动识别 Session Token / show-auth-token 链接 / Firebase Refresh / 邮箱密码 / 标签配对,可混合。auth1_ 是中间票,不能当池密钥。</div>
</div>
</div>
<div class="section-body">
<textarea id="import-text-area" class="input" rows="5" style="width:100%;font-family:var(--mono);font-size:12px;resize:vertical" data-i18n-placeholder="section.smartImport.placeholder" placeholder="devin-session-token$... 或 show-auth-token 链接 或 email----password 每行一个,可混合"></textarea>
<div style="margin-top:10px"><button class="btn btn-primary" onclick="App.importAccountsFromText()" data-i18n="section.smartImport.button">智能导入</button></div>
<div id="import-text-result" style="margin-top:10px"></div>
</div>
</div>
<div class="section">
<div class="section-header">
<div>
<div class="section-title" data-i18n="field.batchInput.label">批量导入</div>
<div class="section-desc" data-i18n="field.batchInput.hint">每行一个账号。支持:邮箱 密码、邮箱----密码、邮箱|密码、邮箱,密码、邮箱:密码。也支持上传 JSON 文件。代理可选,格式:[代理] 邮箱 密码。</div>
</div>
</div>
<div class="section-body">
<textarea id="wl-batch-input" class="textarea" rows="6" placeholder="user1@mail.com pass123 http://proxy:8080 user2@mail.com pass456 socks5://user:pass@1.2.3.4:1080 user3@mail.com pass789"></textarea>
<input id="wl-batch-file" type="file" accept=".json,application/json" class="hidden" onchange="App.handleWindsurfBatchFile(event)">
<div class="field-row-actions">
<button class="btn btn-outline" onclick="App.pasteWindsurfBatch()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><rect x="8" y="2" width="8" height="4" rx="1" ry="1"/></svg>
<span data-i18n="action.readClipboard">读取剪贴板</span>
</button>
<button class="btn btn-outline" onclick="App.triggerWindsurfBatchFile()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
<span data-i18n="action.uploadJson">上传 JSON</span>
</button>
<button class="btn btn-ghost hidden" id="wl-batch-file-clear" onclick="App.clearWindsurfBatchFile()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
<span data-i18n="action.clearFile">清除文件</span>
</button>
<button class="btn btn-primary" onclick="App.windsurfLoginBatch()" id="wl-batch-btn">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M3 5h18"/><path d="M3 12h18"/><path d="M3 19h18"/></svg>
<span data-i18n="action.batchImport">批量导入并登录</span>
</button>
</div>
<div class="field-hint" id="wl-batch-file-status" data-i18n="batch.fileNone">未选择 JSON 文件</div>
</div>
</div>
</div>
<div class="add-panel" id="add-panel-local" role="tabpanel" aria-labelledby="add-tab-local">
<div class="section">
<div class="section-header">
<div>
<div class="section-title" data-i18n="accounts.add.localTitle">从本机 Windsurf 导入</div>
<div class="section-desc" data-i18n="accounts.add.localDesc">读取本机 Windsurf 客户端已登录的凭证(仅 loopback 访问)</div>
</div>
</div>
<div class="section-body">
<div class="field-row-actions">
<button id="local-import-btn" class="btn btn-outline" onclick="App.discoverLocalWindsurf()" data-i18n="action.importLocal" data-i18n-title="action.importLocalTitle" title="从本地 Windsurf 客户端读取已登录的凭证(仅本机访问)">从本地 Windsurf 导入</button>
</div>
<div id="local-import-hint" style="margin-top:8px;color:var(--text-muted);font-size:12px;line-height:1.6"></div>
<div id="local-windsurf-result" style="margin-top:12px"></div>
</div>
</div>
</div>
<div class="add-result hidden" id="wl-result"></div>
</div>
</div>
</div>
<!-- Models -->
<section class="panel" id="p-settings">
<div class="page-header">
<div>
<h1 class="page-title" data-i18n="page.settings">全局设置</h1>
<div class="page-subtitle" data-i18n="pageSubtitle.settings">跨浏览器共享的偏好与安全阈值,可自定义</div>
</div>
<div class="btn-group">
<button class="btn btn-ghost btn-sm" onclick="App.loadSettings()" data-i18n-title="action.refresh" title="刷新">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M21 12a9 9 0 0 1-9 9 9 9 0 0 1-6.24-2.52L3 21"/><path d="M3 12a9 9 0 0 1 9-9 9 9 0 0 1 6.24 2.52L21 3"/><path d="M21 3v6h-6"/><path d="M3 21v-6h6"/></svg>
<span data-i18n="action.refresh">刷新</span>
</button>
</div>
</div>
<div class="section">
<div class="section-header">
<div>
<div class="section-title" data-i18n="section.settings.ux.title">界面偏好</div>
<div class="section-desc" data-i18n="section.settings.ux.desc">跨浏览器/设备共享(存于服务端)</div>
</div>
</div>
<div class="section-body">
<label class="checkbox">
<input type="checkbox" id="set-oauth-skip-chooser" onchange="App.savePref('oauthSkipChooser', this.checked)">
<span class="box"></span>
<span data-i18n="settings.oauthSkipChooser">Google/GitHub 登录时跳过"打开/复制"选择弹窗,直接打开登录页</span>
</label>
</div>
</div>
<div class="section">
<div class="section-header">
<div>
<div class="section-title" data-i18n="section.settings.security.title">安全与锁定</div>
<div class="section-desc" data-i18n="section.settings.security.desc">登录失败锁定阈值与时长,可调整或关闭</div>
</div>
</div>
<div class="section-body" id="set-security-body">
<div class="text-sm text-muted" data-i18n="settings.loading">加载中…</div>
</div>
</div>
<div class="section">
<div class="section-header">
<div>
<div class="section-title" data-i18n="section.settings.runtimeEnv.title">运行时后端</div>
<div class="section-desc" data-i18n="section.settings.runtimeEnv.desc">这些开关可在本页直接切换并写入 runtime-config.json,立即生效、无需重启;未手动切换过的项跟随部署时的环境变量</div>
</div>
</div>
<div class="section-body" id="set-runtime-env-body">
<div class="text-sm text-muted" data-i18n="settings.loading">加载中…</div>
</div>
</div>
<div class="section" style="margin-top:24px">
<div class="section-header">
<div>
<div class="section-title" data-i18n="section.settings.serviceControl.title">服务控制</div>
<div class="section-desc" data-i18n="section.settings.serviceControl.desc">重启网关进程,让需要重启才生效的配置生效或从卡死中恢复。重启期间会先排空在途请求(最多约 10 秒),再由进程守护(systemd / PM2 / docker)自动拉起。</div>
</div>
</div>
<div class="section-body">
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap">
<button class="btn btn-outline warn" id="btn-restart-service" onclick="App.restartService()" data-i18n="action.restartService">重启服务</button>
<span class="text-sm text-muted" id="restart-status"></span>
</div>
</div>
</div>
<div class="section" style="margin-top:24px">
<div class="section-header">
<div>
<div class="section-title" data-i18n="section.systemPrompts.title">系统提示词</div>
<div class="section-desc" data-i18n="section.systemPrompts.desc">工具注入、对话模式等内置提示词模板。修改后立即生效,点"重置"恢复默认。</div>
</div>
</div>
<div class="section-body" id="system-prompts-editor" style="display:grid;gap:14px"></div>
</div>
<div class="section" style="margin-top:24px">
<div class="section-header">
<div>
<div class="section-title" data-i18n="section.credentials.title">凭证管理</div>
<div class="section-desc" data-i18n="section.credentials.desc">运行时改 API_KEY 和 Dashboard 密码,写入 runtime-config.json 立即生效,不用重启容器或重读 .env。改完之后下一次请求需要用新值;当前会话会要求重新登录。</div>
</div>
</div>
<div class="section-body" style="display:grid;gap:14px">
<div style="padding:12px;background:var(--surface-2);border-radius:var(--radius);display:grid;gap:8px">
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
<strong data-i18n="credentials.apiKey">API_KEY</strong>
<span class="text-sm text-muted" id="credentials-apikey-source"></span>
<span class="text-sm" style="font-family:monospace" id="credentials-apikey-masked"></span>
</div>
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
<input type="password" class="input" id="credentials-apikey-input" data-i18n-placeholder="credentials.apiKeyPlaceholder" placeholder="新 API_KEY(≥ 8 字符;留空清除运行时覆盖)" style="flex:1;min-width:240px">
<button class="btn btn-outline btn-sm" onclick="App.toggleApiKeyVisibility()" data-i18n="credentials.show">显示/隐藏</button>
<button class="btn btn-primary btn-sm" onclick="App.saveCredential('apiKey')" data-i18n="credentials.save">保存</button>
</div>
<div class="text-sm text-muted" data-i18n="credentials.apiKeyHint">改完后所有 chat 客户端要换成新 KEY;旧 KEY 立即失效。</div>
</div>
<div style="padding:12px;background:var(--surface-2);border-radius:var(--radius);display:grid;gap:8px">
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
<strong data-i18n="credentials.dashboardPassword">Dashboard 密码</strong>
<span class="text-sm text-muted" id="credentials-dashboardpw-source"></span>
<span class="text-sm" id="credentials-dashboardpw-status"></span>
</div>
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
<input type="password" class="input" id="credentials-dashboardpw-input" data-i18n-placeholder="credentials.dashboardPwPlaceholder" placeholder="新 Dashboard 密码(≥ 8 字符;留空清除运行时覆盖)" style="flex:1;min-width:240px">
<button class="btn btn-outline btn-sm" onclick="App.toggleDashboardPwVisibility()" data-i18n="credentials.show">显示/隐藏</button>
<button class="btn btn-primary btn-sm" onclick="App.saveCredential('dashboardPassword')" data-i18n="credentials.save">保存</button>
</div>
<div class="text-sm text-muted" data-i18n="credentials.dashboardPwHint">用 scrypt 派生哈希存盘;改完之后会自动登出当前会话,需要用新密码重登。</div>
</div>
<div class="text-sm text-muted" data-i18n="credentials.bruteForceHint">为防暴破:同一 IP 连续 5 次 dashboard 登录失败会被锁 30 分钟(2.0.56 起)。</div>
</div>
</div>
<div class="section" style="margin-top:24px">
<div class="section-header"><div>
<div class="section-title"><span data-i18n="section.settings.breaker.title">熔断与限流</span><span data-i18n-tip="circuitBreaker"></span></div>
<div class="section-desc" data-i18n="section.settings.breaker.desc">账号错误熔断与自愈退避阈值,可热调,空则回退环境变量/默认</div>
</div></div>
<div class="section-body" id="set-breaker-body">
<div class="text-sm text-muted" data-i18n="settings.loading">加载中…</div>
</div>
</div>
</section>
<section class="panel" id="p-models">
<div class="page-header">
<div>
<h1 class="page-title" data-i18n="page.models">模型控制</h1>
<div class="page-subtitle" data-i18n="pageSubtitle.models">配置模型访问策略,限制可用模型范围</div>
</div>
</div>
<div class="section">
<div class="section-header">
<div>
<div class="section-title" data-i18n="section.modelPolicy.title">访问策略</div>
<div class="section-desc" data-i18n="section.modelPolicy.desc">选择"全部允许"不限制;"白名单"只允许列出的模型;"黑名单"屏蔽列出的模型</div>
</div>
</div>
<div class="section-body">
<div class="segmented" id="model-mode-group">
<label><input type="radio" name="model-mode" value="all" onchange="App.setModelMode(this.value)"><span data-i18n="model.mode.all">全部允许</span></label>
<label><input type="radio" name="model-mode" value="allowlist" onchange="App.setModelMode(this.value)"><span data-i18n="model.mode.allowlist">白名单</span></label>
<label><input type="radio" name="model-mode" value="blocklist" onchange="App.setModelMode(this.value)"><span data-i18n="model.mode.blocklist">黑名单</span></label>
</div>
<div class="field hidden mt-4" id="model-default-field">
<label class="field-label" data-i18n="field.defaultModel.label">默认模型</label>
<input id="model-default" class="input" data-i18n-placeholder="field.defaultModel.placeholder" placeholder="留空则拒绝清单外请求" onchange="App.setDefaultModel(this.value)">
<div class="field-hint" data-i18n="field.defaultModel.hint">清单外的模型请求会改用此模型,而不是被拒绝。留空表示拒绝(默认)。</div>
</div>
</div>
</div>
<div class="section hidden" id="model-list-section">
<div class="section-header">
<div>
<div class="section-title" id="model-list-title" data-i18n="section.modelList.title">模型清单</div>
<div class="section-desc" id="model-list-hint"></div>
</div>
</div>
<div class="section-body">
<div class="field-row">
<div class="field">
<label class="field-label" data-i18n="field.searchModel.label">搜索模型</label>
<input id="model-search" class="input" type="search" data-i18n-placeholder="field.searchModel.placeholder" placeholder="输入模型名称筛选..." oninput="App.filterModels()">
</div>
<div class="field" style="max-width:220px">
<label class="field-label" data-i18n="field.provider.label">供应商</label>
<select id="model-provider-filter" class="select" onchange="App.filterModels()">
<option value="" data-i18n="field.provider.all">全部供应商</option>
<option value="anthropic">Anthropic</option>
<option value="openai">OpenAI</option>
<option value="google">Google</option>
<option value="deepseek">DeepSeek</option>
<option value="xai">xAI</option>
<option value="alibaba">Alibaba</option>
<option value="moonshot">Moonshot</option>
<option value="windsurf">Windsurf</option>
</select>
</div>
</div>
<div id="model-list-current" class="mt-4"></div>
<div id="model-chips-container" class="mt-4"></div>
</div>
</div>
</section>
<!-- Proxy -->
<section class="panel" id="p-proxy">
<div class="page-header">
<div>
<h1 class="page-title" data-i18n="page.proxy">代理配置</h1>
<div class="page-subtitle" data-i18n="pageSubtitle.proxy">全局或按账号配置出口代理,独立代理将启动独立的语言服务器实例</div>
</div>
</div>
<div class="section">
<div class="section-header">
<div>
<div class="section-title" data-i18n="section.globalProxy.title">全局代理</div>
<div class="section-desc" data-i18n="section.globalProxy.desc">未配置独立代理的账号将使用此配置</div>
</div>
</div>
<div class="section-body">
<div class="field-row">
<div class="field" style="max-width:140px">
<label class="field-label" data-i18n="field.type.label">类型</label>
<select id="proxy-type" class="select">
<option value="http">HTTP</option>
<option value="https">HTTPS</option>
<option value="socks5">SOCKS5</option>
</select>
</div>
<div class="field">
<label class="field-label" data-i18n="field.host.label">主机</label>
<input id="proxy-host" class="input" data-i18n-placeholder="field.host.placeholder" placeholder="proxy.example.com">
</div>
<div class="field" style="max-width:140px">
<label class="field-label" data-i18n="field.port.label">端口</label>
<input id="proxy-port" class="input" type="number" data-i18n-placeholder="field.port.placeholder" placeholder="8080">
</div>
</div>
<div class="field-row mt-3">
<div class="field">
<label class="field-label" data-i18n="field.username.label">用户名</label>
<input id="proxy-user" class="input" data-i18n-placeholder="field.proxyUser.placeholder" placeholder="代理认证用户名">
</div>
<div class="field">
<label class="field-label" data-i18n="field.passwordProxy.label">密码</label>
<div class="input-group">
<input id="proxy-pass" class="input" type="password" data-i18n-placeholder="field.proxyPass.placeholder" placeholder="代理认证密码">
<button class="btn btn-outline btn-icon" onclick="const el=document.getElementById('proxy-pass');el.type=el.type==='password'?'text':'password'" data-i18n-title="action.showPassword" title="显示/隐藏密码">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
</button>
</div>
</div>
</div>
<div class="field-row-actions">
<button class="btn btn-primary" onclick="App.saveGlobalProxy()" data-i18n="action.save">保存</button>
<button class="btn btn-outline" onclick="App.clearGlobalProxy()" data-i18n="action.clear">清除</button>
<span id="proxy-current" class="text-sm text-muted" style="margin-left:auto;align-self:center"></span>
</div>
</div>
</div>
<div class="section">
<div class="section-header">
<div>
<div class="section-title" data-i18n="section.accountProxy.title">账号独立代理</div>
<div class="section-desc" data-i18n="section.accountProxy.desc">为特定账号设置专属代理;每个独立代理会启动独立 LS 实例</div>
</div>
</div>
<div class="section-body tight">
<div class="table-wrap" tabindex="0" role="region" aria-label="可滚动表格" data-i18n-aria-label="aria.scrollableTable">
<table id="proxy-accounts-table">
<thead><tr><th data-i18n="table.header.account">账号</th><th data-i18n="table.header.proxy">代理</th><th data-i18n="table.header.action">操作</th></tr></thead>
<tbody></tbody>
</table>
</div>
<div class="table-pagination" id="proxy-accounts-pagination">
<button class="btn btn-ghost btn-xs" id="proxy-accounts-prev" onclick="App.setProxyPage(App.proxyPage - 1)" data-i18n="action.prev">上一页</button>
<span class="page-label" id="proxy-accounts-page-label">-</span>
<select class="select" id="proxy-accounts-page-size" onchange="App.setProxyPageSize(this.value)">
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
<option value="200">200</option>
</select>
<button class="btn btn-ghost btn-xs" id="proxy-accounts-next" onclick="App.setProxyPage(App.proxyPage + 1)" data-i18n="action.next">下一页</button>
</div>
</div>
</div>
</section>
<!-- Logs -->
<section class="panel" id="p-logs">
<div class="page-header">
<div>
<h1 class="page-title" data-i18n="page.logs">运行日志</h1>
<div class="page-subtitle" data-i18n="pageSubtitle.logs">通过 SSE 实时流式接收服务端日志</div>
</div>
</div>
<div class="log-controls">
<select id="log-level" class="select" style="width:130px" onchange="App.filterLogs()">
<option value="" data-i18n="log.level.all">所有级别</option>
<option value="debug">Debug</option>
<option value="info">Info</option>
<option value="warn">Warn</option>
<option value="error">Error</option>
</select>
<input id="log-search" class="input search" type="search" data-i18n-placeholder="log.searchPlaceholder" placeholder="搜索日志内容..." oninput="App.debouncedFilterLogs()">
<label class="checkbox">
<input type="checkbox" id="log-autoscroll" checked>
<span class="box"></span>
<span data-i18n="field.autoScroll">自动滚动</span>
</label>
<button class="btn btn-outline btn-sm" onclick="App.clearLogView()" data-i18n="log.clearView">清空视图</button>
<!-- v2.0.60 — log export. Type select drives which slice to dump
(api requests vs system housekeeping vs both); format selects
machine-readable jsonl vs human-readable txt. -->
<select id="log-export-type" class="select" style="width:130px">
<option value="all" data-i18n="log.export.all">全部日志</option>
<option value="api" data-i18n="log.export.api">API 请求日志</option>
<option value="system" data-i18n="log.export.system">系统运行日志</option>
</select>
<select id="log-export-format" class="select" style="width:120px">
<option value="jsonl">JSONL</option>
<option value="txt" data-i18n="log.export.txt">纯文本</option>
</select>
<button class="btn btn-primary btn-sm" onclick="App.exportLogs()" data-i18n="log.export.btn">下载日志</button>
</div>
<div class="log-container" id="log-container"></div>
</section>
<!-- ACU & usage (Q3) -->
<section class="panel" id="p-usage">
<div class="page-header">
<div>
<h1 class="page-title" data-i18n="page.usage">用量</h1>
<div class="page-subtitle" data-i18n="pageSubtitle.usage">ACU 是 Devin 的用量单位;这里先回答本周期还剩多少,再看已用、每日与按产品的消耗。同一工作区的多个账号只显示一次。</div>
</div>
<div class="btn-group">
<button class="btn btn-ghost btn-sm" onclick="App.loadUsage(true)" data-i18n-title="action.refresh" title="刷新">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M21 12a9 9 0 0 1-9 9 9 9 0 0 1-6.24-2.52L3 21"/><path d="M3 12a9 9 0 0 1 9-9 9 9 0 0 1 6.24 2.52L21 3"/><path d="M21 3v6h-6"/><path d="M3 21v-6h6"/></svg>
<span data-i18n="action.refresh">刷新</span>
</button>
</div>
</div>
<div id="usage-org-bar" class="section usage-org-bar hidden"></div>
<div id="usage-content"></div>
</section>
<!-- DevIn panel: one screen for the Devin surface the proxy can actually read.
The session/catalog/quota/settings split mirrors the four questions an
operator asks during an incident. Anything the proxy cannot observe is
labelled with a reason (never rendered as a zero or an empty list). -->
<section class="panel" id="p-devin">
<div class="page-header">
<div>
<h1 class="page-title" data-i18n="page.devin">Devin 面板</h1>
<div class="page-subtitle" data-i18n="pageSubtitle.devin">会话与轨迹、模型目录、用量配额、运行时设置。只显示代理真实读到的数据;读不到的项标明原因,不用 0 冒充。</div>
</div>
<div class="btn-group">
<button class="btn btn-ghost btn-sm" onclick="App.loadDevin(true)" data-i18n-title="action.refresh" title="刷新">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M21 12a9 9 0 0 1-9 9 9 9 0 0 1-6.24-2.52L3 21"/><path d="M3 12a9 9 0 0 1 9-9 9 9 0 0 1 6.24 2.52L21 3"/><path d="M21 3v6h-6"/><path d="M3 21v-6h6"/></svg>
<span data-i18n="action.refresh">刷新</span>
</button>
</div>
</div>
<div class="metrics-grid" id="devin-kpis"></div>
<div class="section">
<div class="section-header">
<div>
<div class="section-title" data-i18n="section.devin.runtime.title">运行时</div>
<div class="section-desc" data-i18n="section.devin.runtime.desc">语言服务器进程、准入计数与断连续传缓存。全部来自本进程状态,打开页面不产生上游调用。</div>
</div>
</div>
<div class="section-body" id="devin-runtime"><div class="text-sm text-muted" data-i18n="settings.loading">加载中…</div></div>
</div>
<div class="section">
<div class="section-header">
<div>
<div class="section-title" data-i18n="section.devin.sessions.title">会话与轨迹</div>
<div class="section-desc" data-i18n="section.devin.sessions.desc">代理侧已计费轮次(按账号去重合并)。Cascade 轨迹与 ACP 会话不在代理可见范围内,原因写在来源表里。</div>
</div>
</div>
<div class="section-body" id="devin-sessions"><div class="text-sm text-muted" data-i18n="settings.loading">加载中…</div></div>
</div>
<div class="section">
<div class="section-header">
<div>
<div class="section-title" data-i18n="section.devin.catalog.title">模型目录与状态</div>
<div class="section-desc" data-i18n="section.devin.catalog.desc">本地可达性、免费/计费三态与实时目录差异。上游权限(entitlement)需要点“上游对账”,那一步会产生上游调用。</div>
</div>
<div class="btn-group">
<button class="btn btn-outline btn-sm" id="devin-reconcile-btn" onclick="App.loadDevinReconcile()" data-i18n="devin.catalog.reconcile.run">上游对账</button>
</div>
</div>
<div class="section-body" id="devin-catalog"><div class="text-sm text-muted" data-i18n="settings.loading">加载中…</div></div>
</div>
<div class="section">
<div class="section-header">
<div>
<div class="section-title" data-i18n="section.devin.quota.title">用量与配额</div>
<div class="section-desc" data-i18n="section.devin.quota.desc">干旱判定与阈值来自本地缓存;工作区 ACU 需要点“读取 ACU”,冷缓存时会读一次上游。</div>
</div>
<div class="btn-group">
<button class="btn btn-ghost btn-sm" onclick="App.navigate('usage')" data-i18n="devin.quota.acu.full">完整用量</button>
<button class="btn btn-outline btn-sm" id="devin-acu-btn" onclick="App.loadDevinAcu()" data-i18n="devin.quota.acu.load">读取 ACU</button>
</div>
</div>
<div class="section-body" id="devin-quota"><div class="text-sm text-muted" data-i18n="settings.loading">加载中…</div></div>
</div>
<div class="section">
<div class="section-header">
<div>
<div class="section-title" data-i18n="section.devin.settings.title">设置</div>
<div class="section-desc" data-i18n="section.devin.settings.desc">Devin 相关开关的实际生效值与来源;写入入口在全局设置页。配额/按量策略可直接在此保存。</div>
</div>
<div class="btn-group">
<button class="btn btn-ghost btn-sm" onclick="App.navigate('settings')" data-i18n="devin.settings.open">打开全局设置</button>
</div>
</div>
<div class="section-body" id="devin-settings"><div class="text-sm text-muted" data-i18n="settings.loading">加载中…</div></div>
</div>
</section>
<!-- Stats -->
<section class="panel" id="p-stats">
<div class="page-header">
<div>
<h1 class="page-title" data-i18n="page.stats">统计分析</h1>
<div class="page-subtitle" data-i18n="pageSubtitle.stats">请求量、成功率、延迟分位数与账号/模型维度统计</div>
</div>
<div class="btn-group">
<button class="btn btn-ghost btn-sm" onclick="App.loadStats()" data-i18n-title="action.refresh" title="刷新">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M21 12a9 9 0 0 1-9 9 9 9 0 0 1-6.24-2.52L3 21"/><path d="M3 12a9 9 0 0 1 9-9 9 9 0 0 1 6.24 2.52L21 3"/><path d="M21 3v6h-6"/><path d="M3 21v-6h6"/></svg>
<span data-i18n="action.refresh">刷新</span>
</button>
<button class="btn btn-danger btn-sm" onclick="App.resetStats()" data-i18n="action.reset">重置统计</button>
</div>
</div>
<div class="metrics-grid" id="stats-cards"></div>
<div class="chart chart-main">
<div class="chart-header">
<div class="chart-title-group">
<h3 data-i18n="section.requestChart.title">请求量时间序列</h3>
<div class="chart-subtitle" data-i18n="section.requestChart.subtitle">切换图表类型和时间范围 · 悬停查看具体数值</div>
</div>
<div class="chart-controls">
<div class="seg-group" role="radiogroup" data-i18n-aria-label="aria.chartTypeGroup" aria-label="图表类型">
<button class="seg-btn stats-type-btn" role="radio" aria-checked="false" data-type="area" onclick="App.setChartType('area')" data-i18n="section.requestChart.typeArea">面积图</button>
<button class="seg-btn stats-type-btn" role="radio" aria-checked="false" data-type="line" onclick="App.setChartType('line')" data-i18n="section.requestChart.typeLine">折线图</button>
<button class="seg-btn stats-type-btn active" role="radio" aria-checked="true" data-type="bar" onclick="App.setChartType('bar')" data-i18n="section.requestChart.typeBar">柱状图</button>
<button class="seg-btn stats-type-btn" role="radio" aria-checked="false" data-type="stacked" onclick="App.setChartType('stacked')" data-i18n="section.requestChart.typeStacked">堆叠图</button>
</div>
<div class="seg-group" role="radiogroup" data-i18n-aria-label="aria.statsRangeGroup" aria-label="统计时间范围">
<button class="seg-btn stats-range-btn" role="radio" aria-checked="false" data-range="6" onclick="App.setStatsRange(6)" data-i18n="range.6h">6小时</button>
<button class="seg-btn stats-range-btn active" role="radio" aria-checked="true" data-range="24" onclick="App.setStatsRange(24)" data-i18n="range.24h">24小时</button>
<button class="seg-btn stats-range-btn" role="radio" aria-checked="false" data-range="168" onclick="App.setStatsRange(168)" data-i18n="range.7d">7天</button>
<button class="seg-btn stats-range-btn" role="radio" aria-checked="false" data-range="720" onclick="App.setStatsRange(720)" data-i18n="range.30d">30天</button>
<button class="seg-btn stats-range-btn" role="radio" aria-checked="false" data-range="all" onclick="App.setStatsRange('all')" data-i18n="range.all">全部</button>
<button class="seg-btn stats-range-btn" role="radio" aria-checked="false" data-range="custom" onclick="App.openCustomRange()" data-i18n="range.custom">自定义</button>
</div>
</div>
</div>
<div class="chart-canvas-wrap">
<canvas id="stats-canvas" role="img" data-i18n-aria-label="aria.statsChart" aria-label="请求量时间序列图"></canvas>
<div class="chart-tooltip" id="stats-tooltip"></div>
<div class="chart-range-indicator" id="stats-range-indicator"></div>
</div>
<div class="custom-range-popup" id="custom-range-popup" style="display:none">
<div class="custom-range-title" data-i18n="range.customTitle">自定义日期范围</div>
<div class="custom-range-fields">
<label>
<span data-i18n="range.customStart">起始日期</span>
<input type="date" id="range-start" class="input">
</label>
<label>
<span data-i18n="range.customEnd">结束日期</span>
<input type="date" id="range-end" class="input">
</label>
</div>
<div class="custom-range-hint" data-i18n="range.customHint">最多显示最近 30 天数据</div>
<div class="custom-range-actions">
<button class="btn btn-ghost btn-sm" onclick="App.closeCustomRange()" data-i18n="range.customCancel">取消</button>
<button class="btn btn-primary btn-sm" onclick="App.applyCustomRange()" data-i18n="range.customApply">应用</button>
</div>
</div>
<div class="chart-legend" id="stats-legend"></div>
</div>
<div class="section" id="stats-log-section">
<div class="section-header">
<div>
<div class="section-title" data-i18n="section.statsLog.title">请求日志</div>
<div class="section-desc" data-i18n="section.statsLog.desc">逐请求明细:时间、模型、账号、状态、延迟、Token 与费用。每次上游尝试一行,费用单位为 Devin credits。</div>
</div>
<div class="stats-log-meta">
<span id="stats-log-summary" class="text-sm text-muted"></span>
<button class="btn btn-ghost btn-xs" onclick="App.loadStatsLog()" data-i18n-title="action.refresh" title="刷新">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M21 12a9 9 0 0 1-9 9 9 9 0 0 1-6.24-2.52L3 21"/><path d="M3 12a9 9 0 0 1 9-9 9 9 0 0 1 6.24 2.52L21 3"/><path d="M21 3v6h-6"/><path d="M3 21v-6h6"/></svg>
<span data-i18n="action.refresh">刷新</span>
</button>
</div>
</div>
<div class="stats-filter-bar">
<label><span data-i18n="section.statsLog.filters.model">模型</span>
<select class="select" id="stats-log-model" onchange="App.setStatsLogFilter('model', this.value)"></select>
</label>
<label><span data-i18n="section.statsLog.filters.account">账号</span>
<select class="select" id="stats-log-account" onchange="App.setStatsLogFilter('account', this.value)"></select>
</label>
<label><span data-i18n="section.statsLog.filters.status">状态</span>
<select class="select" id="stats-log-status" onchange="App.setStatsLogFilter('status', this.value)"></select>
</label>
<button class="btn btn-ghost btn-xs" onclick="App.resetStatsLogFilters()" data-i18n="section.statsLog.filters.reset">清除筛选</button>
<span class="text-sm text-muted" id="stats-log-retention"></span>
</div>
<div class="section-body tight">
<div class="table-wrap" tabindex="0" role="region" aria-label="可滚动表格" data-i18n-aria-label="aria.scrollableTable">
<table id="stats-log-table" aria-label="请求日志表格" data-i18n-aria-label="aria.statsLog">
<thead><tr>
<th data-i18n="table.header.time">时间</th>
<th data-i18n="table.header.model">模型</th>
<th data-i18n="table.header.account">账号</th>
<th data-i18n="table.header.status">状态</th>
<th class="numeric" data-i18n="table.header.duration">耗时</th>
<th class="numeric" data-i18n="table.header.tokensIn">输入 Token</th>
<th class="numeric" data-i18n="table.header.tokensOut">输出 Token</th>
<th class="numeric" data-i18n="table.header.cost">费用 (credits)</th>
</tr></thead>
<tbody id="stats-log-body">
<tr class="empty-row"><td colspan="8" data-i18n="table.empty.noRequestData">暂无请求数据</td></tr>
</tbody>
</table>
</div>
<div class="table-pagination" id="stats-log-pagination">
<button class="btn btn-ghost btn-xs" id="stats-log-prev" onclick="App.setStatsLogPage(App.statsLogPage - 1)" data-i18n="action.prev">上一页</button>
<span class="page-label" id="stats-log-page-label">-</span>
<select class="select" id="stats-log-page-size" onchange="App.setStatsLogPageSize(this.value)">
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
<option value="200">200</option>
</select>
<button class="btn btn-ghost btn-xs" id="stats-log-next" onclick="App.setStatsLogPage(App.statsLogPage + 1)" data-i18n="action.next">下一页</button>
</div>
</div>
</div>
<div class="section" id="stats-agg-section">
<div class="section-header">
<div>
<div class="section-title" data-i18n="section.statsAgg.title">聚合统计</div>
<div class="section-desc" data-i18n="section.statsAgg.desc">与请求日志同一筛选窗口;表内合计与日志共用同一分母,不做估算。</div>
</div>
<div class="seg-group" role="radiogroup" aria-label="聚合维度" data-i18n-aria-label="aria.statsAggGroup">
<button class="seg-btn stats-agg-tab active" role="radio" aria-checked="true" data-agg="model" onclick="App.setStatsAggTab('model')" data-i18n="section.statsAgg.byModel">按模型</button>
<button class="seg-btn stats-agg-tab" role="radio" aria-checked="false" data-agg="account" onclick="App.setStatsAggTab('account')" data-i18n="section.statsAgg.byAccount">按账号</button>
<button class="seg-btn stats-agg-tab" role="radio" aria-checked="false" data-agg="day" onclick="App.setStatsAggTab('day')" data-i18n="section.statsAgg.byDay">按天</button>
</div>
</div>
<div class="section-body tight">
<div class="table-wrap" tabindex="0" role="region" aria-label="可滚动表格" data-i18n-aria-label="aria.scrollableTable">
<table id="stats-agg-table" class="stats-agg-table">
<thead id="stats-agg-head"></thead>
<tbody id="stats-agg-body"></tbody>
</table>
</div>
<div id="stats-agg-note" class="text-sm text-muted" style="margin-top:8px" hidden></div>
</div>
</div>
</section>
<section class="panel" id="p-credits">
<div class="page-header">
<div>
<h1 class="page-title" data-i18n="page.credits">致谢</h1>
<div class="page-subtitle" data-i18n="pageSubtitle.credits">感谢以下朋友为项目提交 PR / 审计代码 / 修复问题</div>
</div>
</div>
<div class="section">
<div class="section-header">
<div class="section-title" data-i18n="credits.contributors">核心贡献者</div>
</div>
<div class="section-body" id="credits-body">
<div class="text-sm text-dim" data-i18n="status.loading">加载中…</div>
</div>
</div>
<div class="section">
<div class="section-header">
<div class="section-title" data-i18n="credits.cta.title">想加入这份名单?</div>
</div>
<div class="section-body">
<div class="section-desc" data-i18n="credits.cta.desc">欢迎到 GitHub 提 issue 或 pull request — 不管是修 bug、加模型、改 UI 还是发现安全问题,都会被记录在这里。</div>
<div style="margin-top:12px">
<a class="btn btn-outline btn-sm" target="_blank" rel="noopener" href="https://github.com/dwgx/WindsurfAPI/issues">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" style="width:14px;height:14px;margin-right:6px"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
<span data-i18n="credits.cta.issue">提 issue</span>
</a>
<a class="btn btn-outline btn-sm" target="_blank" rel="noopener" href="https://github.com/dwgx/WindsurfAPI/pulls" style="margin-left:8px">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" style="width:14px;height:14px;margin-right:6px"><circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M13 6h3a2 2 0 0 1 2 2v7"/><line x1="6" y1="9" x2="6" y2="21"/></svg>
<span data-i18n="credits.cta.pr">提 PR</span>
</a>
</div>
</div>
</div>
</section>
<section class="panel" id="p-experimental">
<div class="page-header">
<div>
<h1 class="page-title" data-i18n="page.experimental">实验性功能</h1>
<div class="page-subtitle" data-i18n="pageSubtitle.experimental">尚未稳定的优化项;如果发现异常可以随时关闭</div>
</div>
</div>
<div class="section">
<div class="section-header">
<div class="section-title" data-i18n="section.cascadeReuse.title">Cascade 对话复用</div>
</div>
<div class="section-body">
<div class="section-desc" style="margin-bottom:12px" data-i18n="section.cascadeReuse.desc">多轮对话时复用同一个 cascade_id,只把最新一条 user 消息发给 Windsurf,让服务端维持上下文缓存。命中时可显著降低 TTFB 与上传体积;未命中或账号/LS 变更会自动回退到新会话。需要客户端保留完整历史并按顺序追加(如 new-api、OpenWebUI)。</div>
<div style="display:flex;align-items:center;gap:16px;margin-bottom:20px;padding:12px;background:var(--surface-2);border-radius:var(--radius)">
<label class="switch">
<input type="checkbox" id="exp-cascade-reuse" onchange="App.toggleExperimental('cascadeConversationReuse', this.checked)">
<span class="switch-slider"></span>
</label>
<div>
<div style="font-weight:500" data-i18n="section.cascadeReuse.enable">启用 Cascade 对话复用</div>
<div class="text-sm text-muted" data-i18n="section.cascadeReuse.hint">默认开启。关闭后当前对话池立即停止复用。</div>
</div>
</div>
<div class="metrics-grid" id="exp-pool-cards"></div>
<div style="margin-top:12px">
<button class="btn btn-outline btn-sm" onclick="App.clearConversationPool()" data-i18n="action.clearPool">清空对话池</button>
<button class="btn btn-ghost btn-sm" onclick="App.loadExperimental()" data-i18n="action.refresh">刷新</button>
</div>
</div>
</div>
<div class="section" style="margin-top:24px">
<div class="section-header">
<div class="section-title" data-i18n="section.droughtRestrict.title">配额低水位时屏蔽 premium 模型</div>
</div>
<div class="section-body">
<div class="section-desc" style="margin-bottom:12px" data-i18n="section.droughtRestrict.desc">所有账号本周配额都低于 5% 时,对 premium 模型(claude-opus / claude-sonnet 等)请求直接返 503,避免上游 rate-limit 烧最后一点配额。免费层模型(gemini-2.5-flash 等)保持可用。关闭后即使在 drought mode 也照常下发,accept 上游可能立即 429。</div>
<div style="display:flex;align-items:center;gap:16px;padding:12px;background:var(--surface-2);border-radius:var(--radius)">
<label class="switch">
<input type="checkbox" id="exp-drought-restrict" onchange="App.toggleExperimental('droughtRestrictPremium', this.checked)">
<span class="switch-slider"></span>
</label>
<div>
<div style="font-weight:500" data-i18n="section.droughtRestrict.enable">启用 drought-mode premium 屏蔽</div>
<div class="text-sm text-muted" data-i18n="section.droughtRestrict.hint">默认开启。drought 状态由账号池自动判定(GET /dashboard/api/drought 看实时值)。</div>
</div>
</div>
<div style="display:flex;align-items:center;gap:10px;margin-top:12px;flex-wrap:wrap">
<label class="field-label" style="margin:0" for="exp-drought-threshold" data-i18n="section.droughtRestrict.thresholdLabel">Drought 阈值(本周配额 %)</label>
<input type="number" id="exp-drought-threshold" class="input" min="0" max="100" step="1" style="max-width:110px" onchange="App.saveDroughtThreshold(this.value)">
<span class="text-sm text-muted" data-i18n="section.droughtRestrict.thresholdHint">账号本周配额低于此值即计入 drought</span>
</div>
</div>
</div>
<div class="section" style="margin-top:24px">
<div class="section-header">
<div class="section-title" data-i18n="section.stickyByUser.title">粘性会话按用户绑定</div>
</div>
<div class="section-body">
<div class="section-desc" style="margin-bottom:12px" data-i18n="section.stickyByUser.desc">开启后,同一 user 无论请求什么模型都会固定绑定到同一个上游账号。关闭时按 (user, model) 二元组分别绑定(不同模型可能分配不同账号,适合账号有模型限制的场景)。需启用 STICKY_SESSION_ENABLED=1 才生效。</div>
<div style="display:flex;align-items:center;gap:16px;padding:12px;background:var(--surface-2);border-radius:var(--radius)">
<label class="switch">
<input type="checkbox" id="exp-sticky-by-user" onchange="App.toggleExperimental('stickyBindByUserOnly', this.checked)">
<span class="switch-slider"></span>
</label>
<div>
<div style="font-weight:500" data-i18n="section.stickyByUser.enable">忽略模型维度</div>
<div class="text-sm text-muted" data-i18n="section.stickyByUser.hint">默认关闭。开启后 user_a 下的 opus/haiku/gemini 全部共享同一个绑定。</div>
</div>
</div>
</div>
</div>
<div class="section" style="margin-top:24px">
<div class="section-header">
<div class="section-title" data-i18n="section.stickyNoFallback.title">粘性会话禁止回退</div>
</div>
<div class="section-body">
<div class="section-desc" style="margin-bottom:12px" data-i18n="section.stickyNoFallback.desc">开启后,粘性会话绑定的账号如果遇到限流/错误/模型不可用,<strong>不会</strong>自动切换到账号池中的其他账号,而是直接将错误返回给客户端。这样可以确保每个 user 严格只消耗自己绑定账号的额度。需启用 STICKY_SESSION_ENABLED=1 才生效。</div>
<div style="display:flex;align-items:center;gap:16px;padding:12px;background:var(--surface-2);border-radius:var(--radius)">
<label class="switch">
<input type="checkbox" id="exp-sticky-no-fallback" onchange="App.toggleExperimental('stickyNoFallback', this.checked)">
<span class="switch-slider"></span>
</label>
<div>
<div style="font-weight:500" data-i18n="section.stickyNoFallback.enable">禁止账号回退</div>
<div class="text-sm text-muted" data-i18n="section.stickyNoFallback.hint">默认关闭。开启后绑定账号失败时不会切换到其他账号消耗额度。</div>
</div>
</div>
</div>
</div>
<div class="section" style="margin-top:24px">
<div class="section-header">
<div class="section-title" data-i18n="section.nativeToolCall.title">原生 tool_call</div>
</div>
<div class="section-body">
<div class="section-desc" style="margin-bottom:12px" data-i18n="section.nativeToolCall.desc">开启后工具定义走原生 protobuf 线协议(已标定的 #10 ToolDef + #6 ChatToolCall),而非提示词模拟(<tool_call> 文本标记)。原生路对弱模型(fable)多工具更稳、不受提示词模拟的空回复限制。关闭则回退到提示词模拟(当前默认稳定路径)。</div>
<div style="display:flex;align-items:center;gap:16px;padding:12px;background:var(--surface-2);border-radius:var(--radius)">
<label class="switch">
<input type="checkbox" id="exp-native-toolcall" onchange="App.toggleExperimental('nativeToolCall', this.checked)">
<span class="switch-slider"></span>
</label>
<div>
<div style="font-weight:500" data-i18n="section.nativeToolCall.enable">启用原生 tool_call</div>
<div class="text-sm text-muted" data-i18n="section.nativeToolCall.hint">默认开启(runtime-config.js 的 experimental.nativeToolCall)。关闭则回退到提示词模拟。</div>
</div>
</div>
</div>
</div>
<div class="section" style="margin-top:24px">
<div class="section-header">
<div class="section-title" data-i18n="section.clineCompat.title">Cline 兼容模式</div>
<span class="badge badge-success" id="cline-compat-badge" style="display:none" data-i18n="section.clineCompat.badgeOn">Cline 兼容 ON</span>
</div>
<div class="section-body">
<div class="section-desc" style="margin-bottom:12px" data-i18n="section.clineCompat.desc">为 Cline 插件做原生级兼容。Cline 的 OpenAI Compatible provider 底层是 @ai-sdk/openai-compatible,对 tool_call 的 arguments 有 isParsableJson 门槛——参数串不是合法 JSON 就静默丢弃这次工具调用(Claude 对无参数工具会发空串)。开启后,识别为 Cline 的请求(按 User-Agent)在标准 /v1 端点会自动套用兼容垫片(tool_call 参数归一为合法 JSON)。专用端点 /v1/cline/*(如 /v1/cline/chat/completions)无论此开关是否开启都始终生效——命名空间本身就是显式选择。默认关闭,标准 /v1 对其他客户端保持字节等价。</div>
<div style="display:flex;align-items:center;gap:16px;padding:12px;background:var(--surface-2);border-radius:var(--radius)">
<label class="switch">
<input type="checkbox" id="exp-cline-compat" onchange="App.toggleExperimental('clineCompat', this.checked)">
<span class="switch-slider"></span>
</label>
<div>
<div style="font-weight:500" data-i18n="section.clineCompat.enable">启用 Cline 特征识别兼容</div>
<div class="text-sm text-muted" data-i18n="section.clineCompat.hint">默认关闭。开启后按 User-Agent 识别 Cline 请求自动套兼容层;/v1/cline/* 专用端点不受此开关约束。</div>
</div>
</div>
<div style="display:flex;align-items:center;gap:10px;margin-top:12px;flex-wrap:wrap">
<span class="text-sm text-muted" data-i18n="section.clineCompat.repairLabel">已修复 tool_call 参数次数</span>
<span class="badge" id="cline-compat-repairs" data-kpi>0</span>
<span class="text-sm text-muted" data-i18n="section.clineCompat.repairHint">(把空/坏 JSON 参数归一为 {} 的累计次数,防 @ai-sdk 静默丢工具)</span>
<button class="btn btn-ghost btn-xs" onclick="App.loadClineCompat()" data-i18n="action.refresh">刷新</button>
</div>
</div>
</div>
<div class="section" style="margin-top:24px">
<div class="section-header">
<div class="section-title" data-i18n="section.ccCompat.title">Claude Code 兼容</div>
<span class="badge badge-success" id="cc-compat-badge" style="display:none" data-i18n="section.ccCompat.badgeOn">已启用</span>
</div>
<div class="section-body">
<div class="section-desc" style="margin-bottom:12px" data-i18n="section.ccCompat.desc">为 Claude Code 做原生级兼容。检测激活后,识别为 Claude Code 的请求会自动套用兼容层:剥离顶层 schema combinator(anyOf/oneOf/allOf)并把竞品身份行中和,避开上游 content-policy 拦截。专用端点 /v1/cc/*(如 /v1/cc/messages)无论此开关是否开启都始终生效——命名空间本身就是显式选择。默认关闭,标准端点对其他客户端保持字节等价。</div>
<div style="display:flex;align-items:center;gap:16px;padding:12px;background:var(--surface-2);border-radius:var(--radius)">
<label class="switch">
<input type="checkbox" id="exp-cc-compat" onchange="App.toggleExperimental('ccCompat', this.checked)">
<span class="switch-slider"></span>
</label>
<div>
<div style="font-weight:500" data-i18n="section.ccCompat.enable">启用 Claude Code 特征识别兼容</div>
<div class="text-sm text-muted" data-i18n="section.ccCompat.hint">默认关闭。开启后按检测特征识别 Claude Code 请求自动套兼容层;/v1/cc/* 专用端点不受此开关约束。</div>
</div>
</div>
<div style="display:flex;align-items:center;gap:10px;margin-top:12px;flex-wrap:wrap">
<span class="text-sm text-muted" data-i18n="section.ccCompat.schemaLabel">Schema 规范化</span>
<span class="badge" id="cc-compat-schema" data-kpi>0</span>
<span class="text-sm text-muted" data-i18n="section.ccCompat.schemaHint">(剥离顶层 combinator 的累计次数)</span>
</div>
<div style="display:flex;align-items:center;gap:10px;margin-top:12px;flex-wrap:wrap">
<span class="text-sm text-muted" data-i18n="section.ccCompat.identityLabel">身份中和</span>
<span class="badge" id="cc-compat-identity" data-kpi>0</span>
<span class="text-sm text-muted" data-i18n="section.ccCompat.identityHint">(中和竞品身份行的累计次数)</span>
<button class="btn btn-ghost btn-xs" onclick="App.loadCcCompat()" data-i18n="action.refresh">刷新</button>
</div>
</div>
</div>
<div class="section" style="margin-top:24px">
<div class="section-header">
<div class="section-title" data-i18n="section.preflight.title">发送前预检配额</div>
</div>
<div class="section-body">
<div class="section-desc" style="margin-bottom:12px" data-i18n="section.preflight.desc">每次 chat 前先探一次 server.codeium.com 剩余配额,无额度账号直接快速失败,避免烧上游 429。代价是每请求多一次网络往返。</div>
<div style="display:flex;align-items:center;gap:16px;padding:12px;background:var(--surface-2);border-radius:var(--radius)">
<label class="switch"><input type="checkbox" id="exp-preflight" onchange="App.toggleExperimental('preflightRateLimit', this.checked)"><span class="switch-slider"></span></label>
<div>
<div style="font-weight:500" data-i18n="section.preflight.enable">启用发送前预检</div>
<div class="text-sm text-muted" data-i18n="section.preflight.hint">默认关闭。会增加延迟;账号频繁耗尽时再开。</div>
</div>
</div>
</div>
</div>
<div class="section" style="margin-top:24px">
<div class="section-header">
<div>
<div class="section-title" data-i18n="section.skin.title">界面风格</div>
<div class="section-desc" data-i18n="section.skin.desc">控制台外观。默认现代风;手绘草稿风是实验性 UI,部分功能简化或采用不同布局。切换后页面会刷新。</div>
</div>
</div>
<div class="section-body">
<div style="display:flex;align-items:center;gap:16px;padding:12px;background:var(--surface-2);border-radius:var(--radius)">
<select id="skin-select" class="input" style="max-width:240px" onchange="App.switchSkin(this.value)">
<option value="modern" data-i18n="skin.modern">默认现代风</option>
<option value="sketch" data-i18n="skin.sketch">手绘草稿风(实验)</option>
</select>
<div class="text-sm text-muted" data-i18n="skin.hint">选择后立即写入 cookie 并刷新</div>
</div>
</div>
</div>
</section>
</main>
<!-- Login Overlay -->
<div class="login-overlay hidden" id="login-overlay">
<div class="login-box">
<div class="login-logo">W</div>
<h3 data-i18n="login.title">控制台登录</h3>
<p data-i18n="login.subtitle">请输入管理密码以继续</p>
<input type="password" id="login-password" class="input" data-i18n-placeholder="login.passwordPlaceholder" placeholder="Dashboard 密码" onkeydown="if(event.key==='Enter')App.login()">
<button class="btn btn-primary" id="login-btn" onclick="App.login()" data-i18n="login.button">登 录</button>
<div id="login-error" style="margin-top:12px;color: var(--error-text);font-size:13px;min-height:18px"></div>
</div>
</div>
<!-- Modal Container -->
<div id="modal-container"></div>
<!-- Toast Container -->
<div class="toast-stack" id="toast-stack" aria-live="polite" aria-atomic="true"></div>
<script>
// Comprehensive i18n system with locale bundles
const I18n = {
locale: 'zh-CN',
bundles: {},
loaded: false,
// Load locale bundle from server
async loadLocale(lang) {
const code = lang === 'zh' ? 'zh-CN' : (lang === 'en' ? 'en' : lang);
try {
const res = await fetch(`/dashboard/i18n/${code}.json`);
if (!res.ok) throw new Error(`Failed to load ${code}`);
this.bundles[code] = await res.json();
this.loaded = true;
return true;
} catch (e) {
console.error('i18n load failed:', e);
return false;
}
},
// Get translation with placeholder interpolation
t(key, vars = {}) {
const code = this.locale === 'zh' ? 'zh-CN' : this.locale;
const bundle = this.bundles[code] || {};
const keys = key.split('.');
let val = bundle;
for (const k of keys) {
val = val?.[k];
if (val === undefined) break;
}
// Flat-key fallback: some keys are stored with a literal dot in the property
// name (e.g. settings."emailLockThreshold.hint") rather than fully nested, so
// the segment walk above misses them. Try progressively-shorter object paths
// with the remainder as one flat property before giving up.
if (typeof val !== 'string') {
for (let i = keys.length - 1; i >= 1; i--) {
let node = bundle;
for (let j = 0; j < i && node; j++) node = node?.[keys[j]];
const flat = node?.[keys.slice(i).join('.')];
if (typeof flat === 'string') { val = flat; break; }
}
}
// Fallback to key if not found
if (typeof val !== 'string') {
// Try embedded default (for zh-CN, use text from HTML).
// The DOM lookup is only meaningful when the key is a real dotted
// i18n identifier (alnum + dot + underscore). A backend error
// message accidentally passed as a key — e.g. `error.docker API
// POST /containers/create -> 404: {"message":"..."}` — would blow
// up `querySelector` because `"` and `{` are CSS-selector syntax.
// Guard with CSS.escape AND a sanity charset check so we never
// throw out of the i18n resolver.
if (code === 'zh-CN' && /^[A-Za-z0-9_.-]+$/.test(key)) {
try {
const el = document.querySelector(`[data-i18n="${CSS.escape(key)}"]`);
if (el?.dataset.i18nOrig) return el.dataset.i18nOrig;
} catch { /* selector still rejected — give up and return the key */ }
}
return key;
}
// Interpolate {{var}} placeholders
return val.replace(/\{\{(\w+)\}\}/g, (_, k) => vars[k] ?? '');
},
// Apply translations to all elements with data-i18n attributes
apply(root = document) {
// Text content
root.querySelectorAll('[data-i18n]').forEach(el => {
const key = el.dataset.i18n;
const vars = {};
// Parse data-i18n-vars if present: "count:5,total:10"
if (el.dataset.i18nVars) {
el.dataset.i18nVars.split(',').forEach(pair => {
const [k, v] = pair.split(':');
if (k && v !== undefined) vars[k.trim()] = v.trim();
});
}
const text = this.t(key, vars);
// Store original for zh mode
if (!el.dataset.i18nOrig) el.dataset.i18nOrig = el.textContent;
if (this.locale !== 'zh-CN' && this.locale !== 'zh') {
el.textContent = text;
} else {
el.textContent = el.dataset.i18nOrig;
}
});
// Placeholders
root.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
const key = el.dataset.i18nPlaceholder;
const text = this.t(key);
if (!el.dataset.i18nPlaceholderOrig) el.dataset.i18nPlaceholderOrig = el.placeholder;
if (this.locale !== 'zh-CN' && this.locale !== 'zh') {
el.placeholder = text;
} else {
el.placeholder = el.dataset.i18nPlaceholderOrig;
}
});
// Title attributes
root.querySelectorAll('[data-i18n-title]').forEach(el => {
const key = el.dataset.i18nTitle;
const text = this.t(key);
if (!el.dataset.i18nTitleOrig) el.dataset.i18nTitleOrig = el.title;
if (this.locale !== 'zh-CN' && this.locale !== 'zh') {
el.title = text;
} else {
el.title = el.dataset.i18nTitleOrig;
}
});
// aria-label attributes (a11y: e.g. radiogroup labels on the segmented
// controls). Same store-original / restore-on-zh dance as title above.
root.querySelectorAll('[data-i18n-aria-label]').forEach(el => {
const key = el.dataset.i18nAriaLabel;
const text = this.t(key);
if (!el.dataset.i18nAriaLabelOrig) el.dataset.i18nAriaLabelOrig = el.getAttribute('aria-label') || '';
if (this.locale !== 'zh-CN' && this.locale !== 'zh') {
el.setAttribute('aria-label', text);
} else {
el.setAttribute('aria-label', el.dataset.i18nAriaLabelOrig);
}
});
// Help-tip hydration for static markup: <span data-i18n-tip="key"
// data-tip-placement="top"></span>. Rebuilt from the key each apply (so a
// language switch re-localizes); className assigned (not appended) = idempotent.
root.querySelectorAll('[data-i18n-tip]').forEach(el => {
const txt = this.t('tooltip.help.' + el.dataset.i18nTip);
const p = el.dataset.tipPlacement;
el.className = 'help-tip' + (p ? ' help-tip--' + p : '');
el.setAttribute('tabindex', '0'); el.setAttribute('role', 'note');
el.setAttribute('aria-label', txt);
el.innerHTML = '?<span class="help-tip__pop" role="tooltip"></span>';
el.querySelector('.help-tip__pop').textContent = txt; // textContent avoids escaping
});
// Update HTML lang attribute
document.documentElement.lang = this.locale === 'zh' || this.locale === 'zh-CN' ? 'zh-CN' : 'en';
// Update language indicator
const zhMode = this.locale === 'zh' || this.locale === 'zh-CN';
const indicator = document.getElementById('lang-indicator');
if (indicator) indicator.textContent = zhMode ? '中文' : 'English';
{
const indicator = document.getElementById('mobile-lang-indicator');
if (indicator) indicator.textContent = zhMode ? '中文' : 'English';
}
const toggleBtn = document.getElementById('lang-toggle-btn');
if (toggleBtn) toggleBtn.title = I18n.t(zhMode ? 'footer.langToggleToEn' : 'footer.langToggleToZh');
},
async setLocale(lang) {
const code = lang === 'zh' ? 'zh-CN' : (lang === 'en' ? 'en' : lang);
if (!this.bundles[code]) {
await this.loadLocale(lang);
}
this.locale = code;
localStorage.setItem('lang', lang);
this.apply();
},
async init() {
const saved = localStorage.getItem('lang');
const browser = navigator.language;
const defaultLang = saved || (browser.startsWith('zh') ? 'zh' : 'en');
await this.setLocale(defaultLang);
}
};
// Global translation helper for JS strings
function t(key, vars) { return I18n.t(key, vars); }
const App = {
password: localStorage.getItem('dp') || '',
lang: localStorage.getItem('lang') || 'zh',
sseConn: null,
logEntries: [],
pollers: {},
allModels: [],
modelAccessConfig: { mode: 'all', list: [] },
loginHistory: JSON.parse(localStorage.getItem('wl_history') || '[]'),
_filterTimer: null,
accountsPage: 1,
accountsPageSize: Number(localStorage.getItem('accountsPageSize') || 50),
// Server-side ordering (GET /accounts?sort=&order=). Persisted next to the
// page size so a reload keeps the view the operator set up (UISpec P2-10).
accountsSort: ['quota_weekly', 'balance', 'last_used', 'errors'].includes(localStorage.getItem('accountsSort'))
? localStorage.getItem('accountsSort') : '',
accountsOrder: localStorage.getItem('accountsOrder') === 'asc' ? 'asc' : 'desc',
accountsTier: '',
accountsCompact: localStorage.getItem('accountsCompact') === '1',
accountsTotal: 0,
accountsHasMore: false,
proxyPage: 1,
proxyPageSize: Number(localStorage.getItem('proxyAccountsPageSize') || 50),
_accountDetails: new Map(),
applyLang() {
// Delegate to new I18n system
I18n.apply();
},
// Centralized error i18n: translate `error.<code>` from server, fall back to
// the literal message or a localized default. Replaces 6+ inline
// `error.${err.message}` lookups across the dashboard (PR #92, smeinecke).
translateError(errorCode, fallbackKey = 'error.unknown') {
const errKey = errorCode ? `error.${errorCode}` : '';
if (errKey && I18n.t(errKey) !== errKey) return I18n.t(errKey);
// Some codes carry a detail after a colon (e.g. ERR_OAUTH_UPSTREAM:access_denied).
// Fall back to the base-code key so the localized message still shows.
if (errorCode && errorCode.includes(':')) {
const baseKey = `error.${errorCode.slice(0, errorCode.indexOf(':'))}`;
if (I18n.t(baseKey) !== baseKey) return I18n.t(baseKey);
}
const raw = String(errorCode || '');
// An unmapped identifier is not a sentence (`ERR_BATCH_OP_INVALID`,
// `probe_skipped`). Show the actionable default; callers keep the code for
// the tooltip / technical detail instead of printing it at the user.
if (/^[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+$/.test(raw) || /^[a-z][a-z0-9]*(?:_[a-z0-9]+)+$/.test(raw)) {
return I18n.t(fallbackKey === 'error.unknown' ? 'error.genericAction' : fallbackKey);
}
return raw || I18n.t(fallbackKey);
},
renderLsPoolTelemetry(ls = {}) {
const pool = ls.pool || {};
const guard = pool.memoryGuard || ls.memoryGuard || {};
const stats = ls.admissionStats || {};
const ready = Number.isFinite(pool.ready) ? pool.ready : (ls.instances || []).filter(i => i.ready).length;
const starting = Number.isFinite(pool.starting) ? pool.starting : (ls.instances || []).filter(i => !i.ready).length;
const occupancy = Number.isFinite(pool.occupancy) ? pool.occupancy : ((ls.instances || []).length || 0);
const cap = pool.maxInstances || ls.maxInstances || '-';
const guardKnown = guard.okToSpawn !== undefined || guard.availableBytes != null || guard.minAvailableBytes != null;
const guardBlocked = guardKnown && guard.okToSpawn === false;
const guardClass = guardBlocked ? 'warn' : 'success';
const guardText = guardKnown
? `${I18n.t(guardBlocked ? 'overview.guard.blocked' : 'overview.guard.ok')} ${this.fmtBytes(guard.availableBytes)}/${this.fmtBytes(guard.minAvailableBytes)}`
: I18n.t('overview.guard.unknown');
const blockReason = pool.blockReason || (guardBlocked ? 'memory_guard' : null);
const last = stats.lastFailure || stats.lastWait || stats.lastEviction || null;
const lastKind = last?.errorType || last?.kind || null;
const lastKey = last?.key ? ` ${last.key}` : '';
const lastAge = last?.at ? ` ${this.fmtMs(Date.now() - last.at)} ago` : '';
const lastTitle = last?.message || last?.kind || '';
const details = [
`Pool ${occupancy}/${cap}`,
`ready ${ready}`,
`starting ${starting}`,
`pending ${pool.pending || 0}`,
`stopping ${pool.stopping || 0}`,
`active ${pool.activeRequests || 0}`,
`evictable ${pool.idleEvictable ? 'yes' : 'no'}`,
].join(' | ');
return `
<div class="ls-telemetry">
<span class="badge info no-dot">${this.esc(details)}</span>
<span class="badge ${guardClass} no-dot">${this.esc(guardText)}</span>
${blockReason ? `<span class="badge warn no-dot">${this.esc(blockReason)}</span>` : ''}
${lastKind ? `<span class="ls-telemetry-detail" title="${this.esc(lastTitle)}">Last ${this.esc(lastKind)}${this.esc(lastKey)}${this.esc(lastAge)}</span>` : ''}
</div>`;
},
renderNativeBridgeTelemetry(health = {}) {
const root = document.getElementById('native-bridge-status');
if (!root) return;
const stats = health.nativeBridge || {};
const cfg = health.nativeBridgeConfig || {};
const recent = Array.isArray(stats.recentDecisions) ? stats.recentDecisions.slice(-5).reverse() : [];
const reasons = stats.decisionReasons || {};
const topReasons = Object.entries(reasons)
.sort(([, a], [, b]) => Number(b || 0) - Number(a || 0))
.slice(0, 5)
.map(([reason, count]) => `${reason}:${count}`)
.join(' | ');
const mode = cfg.off ? 'off override' : (cfg.mode || 'off');
const modeClass = cfg.off ? 'error' : (cfg.mode ? 'success' : 'muted');
const gateBits = [
cfg.hasApiKeyGate ? 'api-key' : '',
cfg.hasAccountGate ? 'account' : '',
cfg.models?.length ? `models=${cfg.models.join(',')}` : '',
cfg.providers?.length ? `providers=${cfg.providers.join(',')}` : '',
cfg.routes?.length ? `routes=${cfg.routes.join(',')}` : '',
cfg.callers?.length ? `callers=${cfg.callers.length}` : '',
].filter(Boolean).join(' | ');
const NB = (k, v) => I18n.t('overview.nativeBridge.' + k, v);
const tools = Array.isArray(cfg.tools) && cfg.tools.length ? cfg.tools.join(',') : NB('defaultTools');
const last = stats.lastDecision || null;
const lastReason = last?.reason || NB('none');
const rows = recent.map(d => {
const badgeClass = d.enabled ? 'success' : 'warn';
const mapped = (d.mappedTools || []).join(',') || '-';
const unmapped = (d.unmappedTools || []).join(',') || '-';
const when = d.at ? new Date(d.at).toLocaleTimeString() : '-';
return `
<div class="native-bridge-decision" title="${this.esc(d.reason || '')}">
<div><span class="badge ${badgeClass} no-dot">${this.esc(d.reason || 'unknown')}</span></div>
<div>${this.esc(d.modelKey || '-')} · ${this.esc(d.route || '-')} · ${this.esc(when)}</div>
<div class="native-bridge-tools">${NB('mapped')} ${this.esc(mapped)} | ${NB('unmapped')} ${this.esc(unmapped)}</div>
</div>`;
}).join('');
root.innerHTML = `
<div class="native-bridge-panel">
<div class="native-bridge-header">
<div class="native-bridge-title">${NB('title')}</div>
<span class="badge ${modeClass} no-dot">${this.esc(mode)}</span>
</div>
<div class="native-bridge-grid">
<div class="native-bridge-kv"><span class="k">${NB('decisions')}</span><span class="v">${NB('decisionsVal', { total: stats.decisions || 0, on: stats.enabledDecisions || 0, off: stats.disabledDecisions || 0 })}</span></div>
<div class="native-bridge-kv"><span class="k">${NB('lastReason')}</span><span class="v" title="${this.esc(lastReason)}">${this.esc(lastReason)}</span></div>
<div class="native-bridge-kv"><span class="k">${NB('tools')}</span><span class="v" title="${this.esc(tools)}">${this.esc(tools)}</span></div>
<div class="native-bridge-kv"><span class="k">${NB('gates')}</span><span class="v" title="${this.esc(gateBits || NB('none'))}">${this.esc(gateBits || NB('none'))}</span></div>
<div class="native-bridge-kv"><span class="k">${NB('topReasons')}</span><span class="v" title="${this.esc(topReasons || NB('none'))}">${this.esc(topReasons || NB('none'))}</span></div>
</div>
<div class="native-bridge-decisions">
${rows || `<div class="text-sm text-muted">${NB('empty')}</div>`}
</div>
</div>`;
},
async toggleLang() {
const newLang = I18n.locale === 'zh' || I18n.locale === 'zh-CN' ? 'en' : 'zh';
await I18n.setLocale(newLang);
this.lang = newLang;
this._labelNavLinks();
this.refreshActivePanelI18n();
},
// Light/dark theme toggle. Persists to localStorage; applied to <html> so the
// [data-theme="dark"] CSS block takes over. initTheme() runs at boot.
toggleTheme() {
const cur = document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light';
const next = cur === 'dark' ? 'light' : 'dark';
this.applyTheme(next);
try { localStorage.setItem('dashboard_theme', next); } catch { /* private mode */ }
},
applyTheme(theme) {
if (theme === 'dark') document.documentElement.setAttribute('data-theme', 'dark');
else document.documentElement.removeAttribute('data-theme');
const ind = document.getElementById('theme-indicator');
// Clean inline SVG (sun when dark→click-to-light, moon when light→click-to-dark).
const icon = theme === 'dark'
? '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>'
: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>';
if (ind) ind.innerHTML = icon;
const mobileInd = document.getElementById('mobile-theme-indicator');
if (mobileInd) mobileInd.innerHTML = icon;
// Canvas charts read theme vars at paint time only — repaint on toggle so
// grid/axis/label colors follow the new theme instead of lagging a cycle.
if (this._lastStatsData) {
if (typeof this.renderMainChart === 'function') this.renderMainChart(this._lastStatsData);
}
if (typeof this.refreshActivePanelI18n === 'function') this.refreshActivePanelI18n();
},
initTheme() {
let saved = 'dark';
try { saved = localStorage.getItem('dashboard_theme') || 'dark'; } catch { /* private mode */ }
this.applyTheme(saved);
},
refreshActivePanelI18n() {
const activePanelId = document.querySelector('.panel.active')?.id?.replace(/^p-/, '');
if (!activePanelId) return;
const loaders = {
overview: 'loadOverview',
accounts: 'loadAccounts', models: 'loadModels', proxy: 'loadProxy',
logs: 'loadLogs', stats: 'loadStats', usage: 'loadUsage',
experimental: 'loadExperimental', credits: 'loadCredits', settings: 'loadSettings',
};
const loader = loaders[activePanelId];
if (loader && typeof this[loader] === 'function') this[loader]();
},
// Register a polling loader. Guarantees exactly one timer per key even if
// the loader is invoked repeatedly, and auto-pauses while the tab is
// hidden to avoid hammering the server in background tabs.
poll(key, fn, intervalMs) {
if (this.pollers[key]) clearInterval(this.pollers[key]);
this.pollers[key] = setInterval(() => {
if (document.hidden) return;
// Only poll while the OWNING panel is active — a poller key matches its
// panel id (overview/stats/bans → p-overview/…). Skip full innerHTML
// rewrites + canvas repaints for panels the user isn't looking at.
const active = document.querySelector('.panel.active')?.id;
if (active && active !== 'p-' + key) return;
fn();
}, intervalMs);
},
// Panel-independent poller: runs regardless of which panel is active (only
// skips when the tab is hidden). For cross-cutting heartbeats like the
// pool-event notifier that must observe the pool no matter where the user is.
pollGlobal(key, fn, intervalMs) {
if (this.pollers[key]) clearInterval(this.pollers[key]);
this.pollers[key] = setInterval(() => {
if (document.hidden) return;
fn();
}, intervalMs);
},
// Idempotently upgrade native <select> / <input type=number> into themed
// custom controls. The native element STAYS in the DOM as the source of truth
// (.value / data-name / onchange all keep working); the custom UI only mirrors
// and drives it. Safe to call after any render (init, prompt modal, tables).
enhanceControls(root) {
const r = root || document;
r.querySelectorAll('select:not([data-enhanced])').forEach((sel) => this._enhanceSelect(sel));
r.querySelectorAll('input[type=number]:not([data-enhanced])').forEach((inp) => this._enhanceNumber(inp));
},
_enhanceSelect(sel) {
sel.dataset.enhanced = '1';
const root = document.createElement('span');
root.className = 'cs';
root.setAttribute('data-cs-root', '');
sel.parentNode.insertBefore(root, sel);
root.appendChild(sel);
sel.classList.add('cs-native');
const trigger = document.createElement('button');
trigger.type = 'button';
trigger.className = 'cs-trigger';
trigger.setAttribute('aria-haspopup', 'listbox');
trigger.setAttribute('aria-expanded', 'false');
const lbl = sel.getAttribute('aria-label') || sel.id || 'select';
trigger.setAttribute('aria-label', lbl);
const labelEl = document.createElement('span');
labelEl.className = 'cs-label';
const chevron = document.createElement('span');
chevron.className = 'cs-chevron';
chevron.innerHTML = "<svg viewBox='0 0 24 24' width='12' height='12' fill='none' stroke='currentColor' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'><polyline points='6 9 12 15 18 9'/></svg>";
trigger.appendChild(labelEl);
trigger.appendChild(chevron);
root.appendChild(trigger);
const refreshLabel = () => {
const opt = sel.options[sel.selectedIndex];
labelEl.textContent = opt ? opt.textContent : '';
};
// Proxy .value through the native prototype getter/setter so all existing
// reads/writes keep working; a PROGRAMMATIC write only refreshes the visible
// label and NEVER dispatches change (avoids skin-revert / pagination-writeback
// loops). A USER pick below dispatches change explicitly.
const proto = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value');
Object.defineProperty(sel, 'value', {
configurable: true,
get() { return proto.get.call(sel); },
set(v) { proto.set.call(sel, v); refreshLabel(); },
});
if (sel.disabled) trigger.disabled = true;
refreshLabel();
this._csWireInteraction(sel, root, trigger, labelEl, refreshLabel);
},
_csWireInteraction(sel, root, trigger, labelEl, refreshLabel) {
let pop = null, hi = -1, cleanup = null;
const opts = () => Array.from(sel.options);
const isOpen = () => trigger.getAttribute('aria-expanded') === 'true';
const commit = (idx) => {
const o = sel.options[idx];
if (!o || o.disabled) return;
const proto = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value');
proto.set.call(sel, o.value);
refreshLabel();
sel.dispatchEvent(new Event('change', { bubbles: true })); // USER pick → fire onchange
close();
};
const paintHi = () => {
if (!pop) return;
Array.from(pop.children).forEach((el, i) => el.classList.toggle('cs-hi', i === hi));
if (pop.children[hi]) {
pop.children[hi].scrollIntoView({ block: 'nearest' });
trigger.setAttribute('aria-activedescendant', pop.children[hi].id);
}
};
const place = () => {
if (!pop) return;
const r = trigger.getBoundingClientRect();
pop.style.left = r.left + 'px';
pop.style.minWidth = r.width + 'px';
// Flip above the trigger when there isn't room below (trigger low in the
// viewport), so the list is never clipped off-screen.
const popH = pop.offsetHeight || 240;
const belowRoom = window.innerHeight - r.bottom;
if (belowRoom < popH + 8 && r.top > belowRoom) {
pop.style.top = Math.max(4, r.top - popH - 4) + 'px';
pop.style.transformOrigin = 'bottom';
} else {
pop.style.top = (r.bottom + 4) + 'px';
pop.style.transformOrigin = 'top';
}
};
const close = () => {
if (!pop) return;
pop.remove(); pop = null;
trigger.setAttribute('aria-expanded', 'false');
trigger.removeAttribute('aria-activedescendant');
if (cleanup) { cleanup(); cleanup = null; }
requestAnimationFrame(() => trigger.focus());
};
const open = () => {
if (isOpen() || sel.disabled) return;
pop = document.createElement('div');
pop.className = 'cs-pop';
pop.setAttribute('role', 'listbox');
opts().forEach((o, i) => {
const el = document.createElement('div');
el.className = 'cs-opt';
el.id = 'cs-opt-' + Math.random().toString(36).slice(2, 8);
el.setAttribute('role', 'option');
el.setAttribute('aria-selected', String(i === sel.selectedIndex));
if (o.disabled) el.setAttribute('aria-disabled', 'true');
const t = document.createElement('span'); t.textContent = o.textContent;
const c = document.createElement('span'); c.className = 'cs-check'; c.textContent = '✓';
el.appendChild(t); el.appendChild(c);
el.addEventListener('mouseenter', () => { hi = i; paintHi(); });
el.addEventListener('mousedown', (e) => { e.preventDefault(); commit(i); });
pop.appendChild(el);
});
document.body.appendChild(pop);
trigger.setAttribute('aria-expanded', 'true');
place();
hi = sel.selectedIndex >= 0 ? sel.selectedIndex : 0;
paintHi();
const onDocDown = (e) => { if (!root.contains(e.target) && !pop.contains(e.target)) close(); };
// Scrolling INSIDE the popover (it is overflow-y:auto) must NOT close it —
// only an OUTER scroll should, since the fixed popover would otherwise drift
// away from its trigger. Reposition on outer scroll instead of closing so
// the list tracks the trigger while an ancestor scrolls.
const onScroll = (e) => { if (pop && pop.contains(e.target)) return; place(); };
const onResize = () => close();
// Capture-phase Esc: while the dropdown is open, Esc closes ONLY the
// dropdown and is stopped before it reaches an enclosing modal's own
// capture-phase Esc handler (which would otherwise close the whole modal).
const onKey = (e) => { if (e.key === 'Escape') { e.stopPropagation(); e.preventDefault(); close(); } };
document.addEventListener('mousedown', onDocDown, true);
document.addEventListener('keydown', onKey, true);
window.addEventListener('scroll', onScroll, true);
window.addEventListener('resize', onResize, true);
cleanup = () => {
document.removeEventListener('mousedown', onDocDown, true);
document.removeEventListener('keydown', onKey, true);
window.removeEventListener('scroll', onScroll, true);
window.removeEventListener('resize', onResize, true);
};
};
const move = (d) => {
const n = sel.options.length; if (!n) return;
let i = hi;
for (let k = 0; k < n; k++) { i = (i + d + n) % n; if (!sel.options[i].disabled) break; }
hi = i; paintHi();
};
trigger.addEventListener('click', () => isOpen() ? close() : open());
trigger.addEventListener('keydown', (e) => {
if (!isOpen()) {
if (e.key === 'Enter' || e.key === ' ' || e.key === 'ArrowDown') { e.preventDefault(); open(); }
return;
}
if (e.key === 'ArrowDown') { e.preventDefault(); move(1); }
else if (e.key === 'ArrowUp') { e.preventDefault(); move(-1); }
else if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); commit(hi); }
else if (e.key === 'Escape') { e.preventDefault(); close(); }
});
// A native focus (e.g. prompt() autofocus targets input,select) transfers to
// the visible trigger so the keyboard user lands on the themed control.
sel.addEventListener('focus', () => trigger.focus());
},
_enhanceNumber(inp) {
inp.dataset.enhanced = '1';
const root = document.createElement('span');
root.className = 'ns';
root.setAttribute('data-ns-root', '');
inp.parentNode.insertBefore(root, inp);
root.appendChild(inp);
inp.classList.add('ns-input');
const btns = document.createElement('span');
btns.className = 'ns-btns';
const mk = (dir, glyph, ariaKey) => {
const b = document.createElement('button');
b.type = 'button'; b.className = 'ns-btn'; b.tabIndex = -1;
b.textContent = glyph;
b.setAttribute('aria-label', I18n.t(ariaKey));
return b;
};
const up = mk(1, '▲', 'aria.increment');
const down = mk(-1, '▼', 'aria.decrement');
btns.appendChild(up); btns.appendChild(down);
root.appendChild(btns);
const step = Number(inp.step) > 0 ? Number(inp.step) : 1;
// Respect min/max declared on the input; an empty attr => no bound that side.
const clamp = (n) => {
const lo = inp.min !== '' ? Number(inp.min) : -Infinity;
const hi = inp.max !== '' ? Number(inp.max) : Infinity;
return Math.min(hi, Math.max(lo, n));
};
const stepBy = (d) => {
if (inp.disabled) return;
const cur = parseFloat(inp.value);
const next = clamp((Number.isFinite(cur) ? cur : 0) + d * step);
inp.value = String(next);
inp.dispatchEvent(new Event('input', { bubbles: true }));
};
// Fire `change` only at a commit point (button release / each arrow key),
// mirroring native <input> semantics so onchange-bound persistence
// (saveTunable / saveDroughtThreshold) runs — without flooding a PUT + toast
// on every frame of hold-to-repeat.
const commit = () => inp.dispatchEvent(new Event('change', { bubbles: true }));
// Hold-to-repeat: immediate step, then accelerating repeat. Timers cleared on
// release/leave to avoid leaks; a real step commits once on release.
let hold = null, delay = null, stepped = false;
const startHold = (d) => {
stepped = true; stepBy(d);
delay = setTimeout(() => { hold = setInterval(() => stepBy(d), 60); }, 200);
};
const stopHold = () => {
if (delay) { clearTimeout(delay); delay = null; }
if (hold) { clearInterval(hold); hold = null; }
if (stepped) { stepped = false; commit(); }
};
[ [up, 1], [down, -1] ].forEach(([b, d]) => {
b.addEventListener('mousedown', (e) => { e.preventDefault(); startHold(d); });
b.addEventListener('mouseup', stopHold);
b.addEventListener('mouseleave', stopHold);
b.addEventListener('touchend', stopHold);
});
inp.addEventListener('keydown', (e) => {
if (e.key === 'ArrowUp') { e.preventDefault(); stepBy(1); commit(); }
else if (e.key === 'ArrowDown') { e.preventDefault(); stepBy(-1); commit(); }
});
},
async init() {
// Initialize i18n first
await I18n.init();
this.initTheme();
this.lang = I18n.locale === 'zh-CN' ? 'zh' : 'en';
const auth = await this.api('GET', '/auth');
// v2.0.61 (#110): when DASHBOARD_PASSWORD isn't configured AND we're
// bound to a public host, the backend returns `locked:true`. Show a
// dedicated message instead of a useless password prompt the user
// can't possibly satisfy. Without this hint operators land here from
// a fresh `docker compose up` and think the dashboard is broken.
if (auth.locked) {
const overlay = document.getElementById('login-overlay');
const pwEl = document.getElementById('login-password');
const btnEl = document.getElementById('login-btn');
const errEl = document.getElementById('login-error');
overlay.classList.remove('hidden');
if (pwEl) pwEl.style.display = 'none';
if (btnEl) btnEl.style.display = 'none';
if (errEl) {
errEl.style.color = 'var(--text-muted)';
errEl.style.fontSize = '13px';
errEl.style.lineHeight = '1.65';
errEl.style.textAlign = 'left';
errEl.innerHTML = `
<strong style="color: var(--warn-text);display:block;margin-bottom:8px">${I18n.t('login.lockedTitle') || 'Dashboard locked'}</strong>
<div>${I18n.t('login.lockedDesc') || 'Server bound to public host without DASHBOARD_PASSWORD. v2.0.55 fail-closed: API_KEY no longer doubles as the dashboard password.'}</div>
<div style="margin-top:10px">${I18n.t('login.lockedFix') || 'Fix:'}</div>
<pre style="background:var(--surface-2);padding:10px;border-radius:6px;font-size:12px;margin-top:6px;white-space:pre-wrap"># .env
DASHBOARD_PASSWORD=YOUR_STRONG_PASSWORD
# restart container
docker compose up -d --force-recreate</pre>
<div style="margin-top:8px;font-size:12px">${I18n.t('login.lockedDocs') || 'See'} <a href="https://github.com/dwgx/WindsurfAPI/blob/master/docs/releases/RELEASE_NOTES_2.0.55.md" target="_blank" style="color: var(--accent-text)">${I18n.t('login.releaseNotes', {version: '2.0.55'})}</a>.</div>
`;
}
return;
}
if (auth.required && !auth.valid) {
document.getElementById('login-overlay').classList.remove('hidden');
return;
}
document.getElementById('login-overlay').classList.add('hidden');
document.querySelectorAll('.sidebar nav a').forEach(a => {
a.onclick = (e) => { e.preventDefault(); this.navigate(a.dataset.panel); };
});
this._labelNavLinks();
// Load shared UI prefs (e.g. oauthSkipChooser) once after auth so the OAuth
// chooser and Settings page reflect the backend-stored preference.
this.api('GET', '/settings/prefs').then(r => { this._prefs = r.prefs || {}; }).catch(() => { this._prefs = {}; });
// Upgrade all static native selects / number inputs into themed controls.
this.enhanceControls(document);
const hash = location.hash.slice(1);
if (hash) this.navigate(hash);
else this.loadOverview();
// Panel-independent pool-event heartbeat (rides zero extra upstream cost —
// reads the in-memory pool via /pool-events). Surfaces cooldown/429/quota
// flapping as deduped, transition-only toasts regardless of active panel.
this.startPoolEvents();
},
async login() {
const pw = document.getElementById('login-password').value;
const btn = document.getElementById('login-btn');
const errEl = document.getElementById('login-error');
if (errEl) errEl.textContent = '';
if (!pw) {
if (errEl) errEl.textContent = I18n.t('login.empty');
return;
}
if (btn) { btn.disabled = true; btn.dataset.origLabel ||= btn.textContent; btn.textContent = I18n.t('login.checking'); }
try {
// Pre-flight: check the password against /auth before storing it so a
// wrong attempt actually shows feedback instead of silently re-rendering
// the same overlay (which looks like "no response" to users).
const r = await fetch('/dashboard/api/auth', { headers: { 'X-Dashboard-Password': pw } });
const data = await r.json().catch(() => ({}));
if (data.locked) {
if (errEl) errEl.textContent = I18n.t('login.locked');
return;
}
if (data.required && !data.valid) {
if (errEl) errEl.textContent = I18n.t('login.wrong');
document.getElementById('login-password')?.focus();
return;
}
this.password = pw;
this.bumpAuthEpoch();
localStorage.setItem('dp', pw);
await this.init();
} catch (e) {
if (errEl) errEl.textContent = I18n.t('login.networkError') + ': ' + e.message;
} finally {
if (btn) { btn.disabled = false; if (btn.dataset.origLabel) btn.textContent = btn.dataset.origLabel; }
}
},
// P1 #5: the desktop icon rail hides link text at <=900px, so every link
// also carries a title + aria-label. Labels are read from the live i18n span
// and refreshed after a language switch.
_labelNavLinks() {
document.querySelectorAll('.sidebar nav a[data-panel]').forEach((a) => {
const label = a.querySelector('span')?.textContent?.trim();
if (!label) return;
a.title = label;
a.setAttribute('aria-label', label);
});
},
// P1 #5: mobile drawer controls. The same nav markup is reused; only its
// presentation changes below 720px, so every panel link stays labelled.
toggleMobileNav(force) {
const sidebar = document.getElementById('app-sidebar');
const mask = document.getElementById('mobile-nav-mask');
const btn = document.getElementById('mobile-menu-btn');
if (!sidebar) return;
const open = force == null ? !sidebar.classList.contains('open') : !!force;
sidebar.classList.toggle('open', open);
if (mask) mask.classList.toggle('hidden', !open);
if (btn) btn.setAttribute('aria-expanded', String(open));
if (document.body && document.body.classList) document.body.classList.toggle('mobile-nav-open', open);
},
closeMobileNav() {
this.toggleMobileNav(false);
},
navigate(panel) {
// Removed pages stay reachable as aliases so old docs links do not 404:
// #windsurf-login opens the add modal on its 登录 tab; #bans lands on
// 账号管理 with 仅异常 on. Neither is a destination in the sidebar.
if (panel === 'windsurf-login') {
this.navigate('accounts');
this.openAddAccountModal('login');
return;
}
if (panel === 'bans') {
this.accountsFlagged = true;
this.navigate('accounts');
return;
}
this._overviewLoadGeneration = (this._overviewLoadGeneration || 0) + 1;
document.querySelectorAll('.panel').forEach(p => p.classList.remove('active'));
document.querySelectorAll('.sidebar nav a').forEach(a => a.classList.remove('active'));
const el = document.getElementById('p-' + panel);
const nav = document.querySelector(`[data-panel="${panel}"]`);
if (el) el.classList.add('active');
if (nav) nav.classList.add('active');
const sidebar = document.getElementById('app-sidebar');
if (sidebar) sidebar.classList.toggle('settings-open', panel === 'settings');
const mobileTitle = document.getElementById('mobile-appbar-title');
const navLabel = nav && nav.querySelector('span');
if (mobileTitle && navLabel) mobileTitle.textContent = navLabel.textContent;
if (typeof window !== 'undefined' && window.matchMedia && window.matchMedia('(max-width: 720px)').matches) this.closeMobileNav();
location.hash = panel;
Object.values(this.pollers).forEach(clearInterval);
this.pollers = {};
if (this.sseConn) { this.sseConn.close(); this.sseConn = null; }
// Dispose pool fires when navigating away — display:none does NOT pause
// requestAnimationFrame (only tab-hidden does), so without this every hot-row
// fire keeps running its 3-pass GL forever in the background. Switching back
// to overview re-mounts via _renderPoolHealth → _mountBarFires.
this._teardownFires();
const loaders = {
overview: 'loadOverview',
accounts: 'loadAccounts', models: 'loadModels', proxy: 'loadProxy',
logs: 'loadLogs', stats: 'loadStats', usage: 'loadUsage',
devin: 'loadDevin',
experimental: 'loadExperimental',
credits: 'loadCredits', settings: 'loadSettings',
};
if (loaders[panel]) this[loaders[panel]]();
},
// Bump on any auth-context change (login / logout / 401). A response that
// arrives after the epoch changed is stale (belongs to the previous password)
// and is discarded so it can't paint data into a freshly-switched session.
bumpAuthEpoch() { this._authEpoch = (this._authEpoch || 0) + 1; },
async api(method, path, body) {
// In-flight GET dedupe: concurrent identical GETs (common when several panel
// sections mount at once) share ONE fetch instead of hammering the endpoint.
// Only GETs are deduped — writes must always execute.
if (method === 'GET' && !body) {
this._inflightGets ||= new Map();
if (this._inflightGets.has(path)) return this._inflightGets.get(path);
const promise = this._apiRaw(method, path, body).finally(() => this._inflightGets.delete(path));
this._inflightGets.set(path, promise);
return promise;
}
return this._apiRaw(method, path, body);
},
async _apiRaw(method, path, body) {
const epochAtStart = this._authEpoch || 0;
const opts = { method, headers: { 'Content-Type': 'application/json' } };
if (this.password) opts.headers['X-Dashboard-Password'] = this.password;
if (body) opts.body = JSON.stringify(body);
try {
const r = await fetch('/dashboard/api' + path, opts);
// Stale-response guard: the auth context changed while this was in flight
// (password switch / logout) — drop the result so it can't overwrite the
// new session's view with data fetched under the old credential.
if ((this._authEpoch || 0) !== epochAtStart) {
// Same shape as 401/429/offline. An empty object let loadAccounts
// paint an empty table when /overview 401'd first and bumped the
// epoch while GET /accounts was still in flight (#257).
return { success: false, error: 'stale' };
}
const data = await r.json().catch(() => ({}));
// 429 = IP brute-force lockout. Blindly continuing (esp. the 15s/30s poll
// loops + the ~dozen parallel calls each panel fires) hammers the banned
// endpoint and stacks a wall of identical toasts. Halt ALL polling and show
// one clear "banned, retry in Ns" overlay instead of spamming.
// A 401/429 from an upstream provider (Devin) is not an admin-auth
// failure. The dashboard API forwards the upstream status and includes
// `httpStatus`; only a response without that marker is the dashboard's
// own password/IP-lock gate. Clearing the saved admin password or
// starting an IP-lock countdown on an upstream quota error leaves the
// operator locked out of a dashboard whose own auth is still valid.
const upstreamFailure = (r.status === 401 || r.status === 429)
&& (data?.httpStatus === r.status || data?.upstream === true);
if (r.status === 429 && !upstreamFailure) {
this.handleLockout(data.retryAfterMs || (data.error && parseInt(String(data.error).match(/(\d+)s/)?.[1], 10) * 1000) || 0);
const lockErr = (data && data.error && (data.error.message || data.error)) || 'Too many failed attempts';
return { success: false, error: lockErr };
}
if (r.status === 401 && !upstreamFailure) {
// Stale/wrong stored password: drop it so polls stop re-sending a bad
// credential (which would rack up failed attempts and trigger a ban).
// The password is persisted in localStorage 'dp' (see :3180) — clearing
// sessionStorage would miss it and the bad password would survive reload.
this.password = null;
this.bumpAuthEpoch();
try { localStorage.removeItem('dp'); } catch {}
this.stopAllPolls();
document.getElementById('login-overlay').classList.remove('hidden');
// A lockout (handleLockout) hides the password field + submit button
// while its countdown runs. If a 401 lands mid-countdown we re-show the
// overlay but must also restore those controls, otherwise the user is
// left staring at a login box with no inputs until the timer expires.
const pwEl = document.getElementById('login-password');
const btnEl = document.getElementById('login-btn');
if (pwEl) pwEl.style.display = '';
if (btnEl) btnEl.style.display = '';
// Do not return {}. addAccount / submitOAuthToken treat a missing
// error as toast.addFailed ("Add failed") — the #257 report.
const authErr = (data && data.error && (data.error.message || data.error)) || 'Unauthorized';
return { success: false, error: authErr };
}
// Surface non-2xx failures instead of silently returning error-shaped data
// as if it were a valid result. The body still comes back to the caller,
// but `success: false` is forced onto it so every `success === false`
// loader guard stays reachable on 404/500 — and on a non-JSON error body,
// where there is nothing to spread (#257).
if (!r.ok) {
const rawError = data && data.error && (data.error.message || data.error);
this.toast(
rawError ? this.translateError(String(rawError), 'error.genericAction') : I18n.t('toast.requestFailed', { status: r.status }),
'error',
rawError ? String(rawError) : '',
);
const failed = (data && typeof data === 'object' && !Array.isArray(data)) ? { ...data } : {};
failed.success = false;
return failed;
}
return data;
} catch (e) {
const msg = e.message || 'network_error';
this.toast(msg, 'error');
return { success: false, error: msg };
}
},
stopAllPolls() {
Object.values(this.pollers || {}).forEach(clearInterval);
this.pollers = {};
// A fleet run's poll is a timeout chain, not an interval. Stop it too: the
// lockout/401 path must not keep hammering a banned endpoint.
if (this._fleetTimer) { clearTimeout(this._fleetTimer); this._fleetTimer = null; }
// Also tear down the logs SSE stream. Without this, stopping polls while
// sitting on the logs panel during a 429/401 lockout leaves the fetch
// ReadableStream open and still hitting the banned /logs/stream endpoint.
if (this.sseConn) { try { this.sseConn.close(); } catch {} this.sseConn = null; }
},
// Show a single non-spammy lockout overlay with a live countdown. Stops all
// polling so we don't keep hitting the banned endpoint.
handleLockout(retryAfterMs) {
this.stopAllPolls();
if (this._lockoutTimer) return; // already showing — don't stack
const overlay = document.getElementById('login-overlay');
const errEl = document.getElementById('login-error');
const pwEl = document.getElementById('login-password');
const btnEl = document.getElementById('login-btn');
if (overlay) overlay.classList.remove('hidden');
if (pwEl) pwEl.style.display = 'none';
if (btnEl) btnEl.style.display = 'none';
let secs = Math.max(1, Math.ceil((retryAfterMs || 0) / 1000)) || 60;
const render = () => {
if (errEl) {
errEl.style.color = 'var(--warn)';
errEl.textContent = I18n.t('login.banned', { secs });
}
};
render();
this._lockoutTimer = setInterval(() => {
secs--;
if (secs <= 0) {
clearInterval(this._lockoutTimer); this._lockoutTimer = null;
if (pwEl) pwEl.style.display = '';
if (btnEl) btnEl.style.display = '';
if (errEl) errEl.textContent = '';
} else render();
}, 1000);
},
// `detail` is the raw server code, shown only as the tooltip: the visible
// sentence stays human, and support can still read the identifier.
toast(msg, type = 'success', detail = '') {
const t = document.createElement('div');
t.className = 'toast ' + type;
t.textContent = msg;
t.style.cursor = 'pointer';
t.title = detail ? `${detail} · ${I18n.t('toast.dismiss')}` : I18n.t('toast.dismiss');
document.getElementById('toast-stack').appendChild(t);
let done = false;
const dismiss = () => {
if (done) return; done = true;
t.style.opacity = '0';
t.style.transform = 'translateX(20px)';
t.style.transition = 'all .2s';
setTimeout(() => t.remove(), 200);
};
t.addEventListener('click', dismiss); // click to dismiss early
// Errors linger longer than successes so users can actually read them.
setTimeout(dismiss, type === 'error' ? 8000 : 3000);
},
// ─── KPI count-up ─────────────────────────────
// Smoothly interpolate a numeric KPI from its previous value to the new one
// via rAF. Honors reduced-motion (snaps to final). Preserves the element's
// prefix/suffix (e.g. "$", "%") so "$12.30" animates the number in place.
_animateNumber(el, to, { prefix = '', suffix = '', decimals = 0, ms = 600 } = {}) {
if (!el) return;
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
const fmt = (n) => prefix + (decimals ? n.toFixed(decimals) : Math.round(n).toLocaleString()) + suffix;
const from = Number(el.dataset.kpiVal);
const target = Number(to);
if (reduce || !Number.isFinite(from) || from === target) {
el.textContent = fmt(target); el.dataset.kpiVal = String(target); return;
}
const t0 = performance.now();
const step = (now) => {
const p = Math.min(1, (now - t0) / ms);
const eased = 1 - Math.pow(1 - p, 3); // easeOutCubic
el.textContent = fmt(from + (target - from) * eased);
if (p < 1) requestAnimationFrame(step);
else { el.textContent = fmt(target); el.dataset.kpiVal = String(target); }
};
requestAnimationFrame(step);
},
// After an innerHTML re-render, run count-up on every [data-kpi] value using
// the value stashed on the previous element (kept in this._prevKpi by key).
_runKpiCountUp(root) {
this._prevKpi ||= {};
(root || document).querySelectorAll('[data-kpi]').forEach((el) => {
const key = el.dataset.kpi;
const to = Number(el.dataset.kpiTo);
if (!Number.isFinite(to)) return;
const prev = this._prevKpi[key];
if (Number.isFinite(prev)) el.dataset.kpiVal = String(prev); // seed for animation
this._animateNumber(el, to, {
prefix: el.dataset.kpiPrefix || '', suffix: el.dataset.kpiSuffix || '',
decimals: Number(el.dataset.kpiDecimals) || 0,
});
this._prevKpi[key] = to;
});
},
// ─── Pool-event notifications ─────────────────
// INLINE MIRROR of src/dashboard/pool-events.js (single-file "just open it"
// constraint). src/dashboard/check-inline-sync.js asserts the two stay in sync —
// edit BOTH. Turns the /pool-events heartbeat into deduped, transition-only
// toasts (healthy ↔ cooling ↔ capacity ↔ quota ↔ disabled).
_poolAccountState(a) {
if (!a) return 'unknown';
if (a.status === 'disabled') return 'disabled';
if (a.status === 'banned') return 'banned';
if (a.status === 'reauth') return 'reauth';
if (a.status === 'error') return 'error';
if (a.quotaCooled) return 'quota';
if (a.rateLimited) return 'rate_limited';
if (a.capacityThrottled) return 'capacity';
return 'active';
},
_poolClassify(from, to) {
if (from === to) return null;
if (to === 'active') return { kind: 'recovered', severity: 'success' };
const sev = (to === 'disabled' || to === 'quota' || to === 'banned' || to === 'reauth') ? 'error' : 'warn';
return { kind: to, severity: sev };
},
_poolCollapse(events, collapseAt) {
const byKind = new Map();
for (const e of events) {
if (!byKind.has(e.kind)) byKind.set(e.kind, []);
byKind.get(e.kind).push(e);
}
const out = [];
for (const [kind, group] of byKind) {
if (group.length >= collapseAt) out.push({ type: 'summary', kind, severity: group[0].severity, count: group.length });
else out.push(...group);
}
return out;
},
_poolPush(snapshot, now) {
const st = this._poolState;
const list = Array.isArray(snapshot) ? snapshot : [];
if (!st.primed) {
for (const a of list) st.lastState.set(a.id, this._poolAccountState(a));
st.primed = true;
return [];
}
const raw = [];
const seen = new Set();
for (const a of list) {
if (!a || a.id == null) continue;
seen.add(a.id);
const to = this._poolAccountState(a);
const from = st.lastState.get(a.id) ?? 'active';
st.lastState.set(a.id, to);
const t = this._poolClassify(from, to);
if (!t) continue;
if (st.lastEmitAt.has(a.id) && now - st.lastEmitAt.get(a.id) < st.backoffMs) continue;
st.lastEmitAt.set(a.id, now);
raw.push({ type: 'account', id: a.id, ref: a.ref || a.id, kind: t.kind, severity: t.severity, from, to });
}
for (const id of [...st.lastState.keys()]) {
if (!seen.has(id)) { st.lastState.delete(id); st.lastEmitAt.delete(id); }
}
return this._poolCollapse(raw, st.collapseAt);
},
startPoolEvents() {
this._poolState = { primed: false, lastState: new Map(), lastEmitAt: new Map(), backoffMs: 60000, collapseAt: 3 };
const tick = async () => {
if (!this.password && !this._noAuth) return;
const r = await this.api('GET', '/pool-events').catch(() => null);
if (!r || !Array.isArray(r.accounts)) return;
// 20 s diff, no re-render: repaint only the rows whose resolved state
// changed (UIResearch §3.12). The table itself refreshes on demand.
this._patchAccountStates(r.accounts);
const events = this._poolPush(r.accounts, Date.now());
for (const e of events) {
const sev = e.severity === 'success' ? 'success' : (e.severity === 'error' ? 'error' : 'info');
const kindKey = (e.type === 'summary' ? 'summary_' : '') + e.kind;
const msg = I18n.t('notify.pool.' + kindKey, e.type === 'summary' ? { count: e.count } : { ref: e.ref });
this.toast(msg, sev);
}
};
this.pollGlobal('pool-events', tick, 20000);
tick(); // prime immediately (silent first tick establishes baseline)
},
// ─── Config save-diff gate ────────────────────
// INLINE MIRROR of src/dashboard/diff.js (single-file constraint) —
// src/dashboard/check-inline-sync.js keeps them in sync; edit BOTH.
_diffToLines(value) {
if (value == null) return [];
const text = typeof value === 'string' ? value : JSON.stringify(value, null, 2);
return text.split('\n');
},
_lineDiff(before, after) {
const a = this._diffToLines(before), b = this._diffToLines(after);
const n = a.length, m = b.length;
const lcs = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
for (let i = n - 1; i >= 0; i--)
for (let j = m - 1; j >= 0; j--)
lcs[i][j] = a[i] === b[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]);
const out = []; let i = 0, j = 0;
while (i < n && j < m) {
if (a[i] === b[j]) { out.push({ op: 'ctx', text: a[i] }); i++; j++; }
else if (lcs[i + 1][j] >= lcs[i][j + 1]) { out.push({ op: 'del', text: a[i] }); i++; }
else { out.push({ op: 'add', text: b[j] }); j++; }
}
while (i < n) { out.push({ op: 'del', text: a[i] }); i++; }
while (j < m) { out.push({ op: 'add', text: b[j] }); j++; }
return out;
},
// Show a before→after diff modal for a dangerous write; commit only on confirm.
// Returns the api() result on apply, or null when the user cancels / no change.
async saveWithDiff({ before, after, method, path, body }) {
const diff = this._lineDiff(before, after);
const changed = diff.filter(d => d.op !== 'ctx');
if (!changed.length) { this.toast(I18n.t('confirm.saveDiffNoChange'), 'info'); return null; }
const add = changed.filter(d => d.op === 'add').length;
const del = changed.filter(d => d.op === 'del').length;
const rows = diff.map(d => {
const cls = d.op === 'add' ? 'diff-add' : (d.op === 'del' ? 'diff-del' : 'diff-ctx');
const sign = d.op === 'add' ? '+' : (d.op === 'del' ? '-' : ' ');
return `<div class="diff-line ${cls}">${this.esc(sign + ' ' + d.text)}</div>`;
}).join('');
const html = `<div class="diff-stat">${this.esc(I18n.t('confirm.saveDiffStat', { add, del }))}</div>
<pre class="diff-box">${rows}</pre>`;
const ok = await this.confirm(I18n.t('confirm.saveDiffTitle'), html, { html: true, wide: true, okText: I18n.t('confirm.saveDiffApply') });
if (!ok) return null;
return this.api(method, path, body);
},
// ─── Modal helpers ───────────────────────────
confirm(title, desc, opts = {}) {
return new Promise(resolve => {
const wrap = document.createElement('div');
wrap.className = 'modal-overlay';
const isDanger = !!opts.danger;
// opts.html=true lets callers pass rich markup in `desc` (e.g. the
// per-account blocked-models editor). opts.wide=true widens the modal
// so the 2-col model grid has room to breathe.
const descHtml = desc
? (opts.html
? `<div class="modal-body">${desc}</div>`
: `<div class="modal-desc">${this.esc(desc)}</div>`)
: '';
const widthStyle = opts.wide ? ' style="max-width:640px;width:92vw"' : '';
// Destructive confirmations require typing the exact scope (bulk delete
// above the visible page). The OK button stays disabled until it matches.
const requireText = opts.requireText == null ? '' : String(opts.requireText);
const requireHtml = requireText
? `<div class="modal-body modal-confirm-require">
<label class="field-label" for="modal-confirm-text">${this.esc(opts.requireTextLabel || I18n.t('confirm.typeToConfirm', { text: requireText }))}</label>
<input id="modal-confirm-text" class="input" data-confirm-text autocomplete="off" spellcheck="false" inputmode="numeric">
</div>`
: '';
// Title is trusted caller input but may contain <code>/<b>; use esc by
// default to match prior behaviour.
wrap.innerHTML = `
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="modal-title-c"${widthStyle}>
<div class="modal-header">
<div class="modal-title" id="modal-title-c">${opts.titleHtml ? title : this.esc(title)}</div>
${opts.html ? '' : (desc ? `<div class="modal-desc">${this.esc(desc)}</div>` : '')}
</div>
${opts.html ? descHtml : ''}
${requireHtml}
<div class="modal-footer">
<button class="btn btn-ghost" data-act="cancel">${this.esc(opts.cancelText || I18n.t('button.cancel'))}</button>
<button class="btn ${isDanger ? 'btn-danger' : 'btn-primary'}" data-act="ok"${requireText ? ' disabled' : ''}>${this.esc(opts.okText || I18n.t('button.confirm'))}</button>
</div>
</div>`;
document.getElementById('modal-container').appendChild(wrap);
const prevFocus = document.activeElement; // restore on close (a11y)
const okBtn = wrap.querySelector('[data-act=ok]');
const textInput = wrap.querySelector('[data-confirm-text]');
const confirmMatches = () => !requireText || (textInput && textInput.value.trim() === requireText);
if (textInput) {
textInput.addEventListener('input', () => { okBtn.disabled = !confirmMatches(); });
}
const onKey = (e) => {
// Defer Esc to an open custom-select dropdown (it closes itself first).
if (document.querySelector('.cs-pop')) return;
if (e.key === 'Escape') { e.preventDefault(); close(false); }
else if (e.key === 'Tab') { this._trapFocus(e, wrap); }
// P1 #1: a danger dialog must never approve on a bare Enter. Only a
// deliberate click on the named destructive button may resolve true.
else if (e.key === 'Enter' && !isDanger && e.target.tagName !== 'BUTTON') { e.preventDefault(); close(true); }
};
const close = (v) => {
document.removeEventListener('keydown', onKey, true);
wrap.remove();
if (prevFocus && prevFocus.focus) { try { prevFocus.focus(); } catch {} }
resolve(v);
};
wrap.querySelector('[data-act=cancel]').onclick = () => close(false);
okBtn.onclick = () => { if (confirmMatches()) close(true); };
wrap.onclick = (e) => { if (e.target === wrap) close(false); };
document.addEventListener('keydown', onKey, true);
// P1 #1: focus Cancel initially on destructive dialogs so an impatient
// Enter/space cannot confirm an irreversible action. Ordinary dialogs keep
// the affirmative default; a typed-confirmation dialog starts in its field.
setTimeout(() => {
const target = isDanger
? wrap.querySelector('[data-act=cancel]')
: (textInput || wrap.querySelector('[data-act=ok]'));
target?.focus();
}, 50);
});
},
// Keep Tab focus cycling inside an open modal (a11y focus trap). Wraps from the
// last focusable to the first (and vice-versa with Shift+Tab), so keyboard
// users can't tab out to the page behind the dialog.
_trapFocus(e, wrap) {
const f = wrap.querySelectorAll('a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])');
if (!f.length) return;
const first = f[0];
const last = f[f.length - 1];
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
},
prompt(title, desc, fields) {
return new Promise(resolve => {
const wrap = document.createElement('div');
wrap.className = 'modal-overlay';
const fieldHtml = fields.map(f => `
<div class="field mt-3">
<label class="field-label">${this.esc(f.label)}</label>
${f.type === 'select' ? `
<select class="select" data-name="${this.esc(f.name)}">
${f.options.map(o => `<option value="${this.esc(o.value)}" ${o.value === f.value ? 'selected' : ''}>${this.esc(o.label)}</option>`).join('')}
</select>
` : `
<input class="input" data-name="${this.esc(f.name)}" type="${f.type || 'text'}" placeholder="${this.esc(f.placeholder || '')}" value="${this.esc(f.value || '')}">
`}
${f.hint ? `<div class="field-hint">${this.esc(f.hint)}</div>` : ''}
</div>
`).join('');
wrap.innerHTML = `
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="modal-title-p">
<div class="modal-header">
<div class="modal-title" id="modal-title-p">${this.esc(title)}</div>
${desc ? `<div class="modal-desc">${this.esc(desc)}</div>` : ''}
</div>
<div class="modal-body">${fieldHtml}</div>
<div class="modal-footer">
<button class="btn btn-ghost" data-act="cancel">${I18n.t('button.cancel')}</button>
<button class="btn btn-primary" data-act="ok">${I18n.t('button.confirm')}</button>
</div>
</div>`;
document.getElementById('modal-container').appendChild(wrap);
const prevFocus = document.activeElement; // restore on close (a11y)
// Theme the modal's dynamic selects/number inputs. data-name stays on the
// native element, so the [data-name].value harvest below is unaffected.
this.enhanceControls(wrap);
const submit = () => {
const values = {};
wrap.querySelectorAll('[data-name]').forEach(el => values[el.dataset.name] = el.value);
close(values);
};
const onKey = (e) => {
// Defer Esc to an open custom-select dropdown (it closes itself first).
if (document.querySelector('.cs-pop')) return;
if (e.key === 'Escape') { e.preventDefault(); close(null); }
else if (e.key === 'Tab') { this._trapFocus(e, wrap); }
// Enter submits from a text input (but not from a textarea or the custom
// select trigger, which use Enter themselves).
else if (e.key === 'Enter' && e.target.tagName === 'INPUT') { e.preventDefault(); submit(); }
};
const close = (v) => {
document.removeEventListener('keydown', onKey, true);
wrap.remove();
if (prevFocus && prevFocus.focus) { try { prevFocus.focus(); } catch {} }
resolve(v);
};
wrap.querySelector('[data-act=cancel]').onclick = () => close(null);
wrap.querySelector('[data-act=ok]').onclick = submit;
wrap.onclick = (e) => { if (e.target === wrap) close(null); };
document.addEventListener('keydown', onKey, true);
setTimeout(() => wrap.querySelector('input, select')?.focus(), 100);
});
},
// ─── Overview ────────────────────────────────
showSelfUpdateUnavailable(status, apply, response) {
// The standard hint always works (run `docker compose pull && up -d`
// on the host). When docker mode also reported a reason — e.g. the
// user has docker.sock mounted but the container wasn't started by
// compose — surface that detail too so they know how to opt in.
const dockerHintParts = [];
if (response && response.dockerReason && response.dockerReason !== 'no-docker-sock') {
dockerHintParts.push(I18n.t('status.dockerSelfUpdateBlocked', {
reason: this.esc(response.dockerReason),
detail: this.esc(response.dockerDetail || ''),
}));
}
status.innerHTML = `
<div style="color: var(--warn-text);font-weight:600;margin-bottom:4px">${I18n.t('status.selfUpdateUnavailable')}</div>
<div class="text-sm text-muted">${I18n.t('status.selfUpdateDockerHint')}</div>
${dockerHintParts.join('')}`;
if (apply) {
apply.classList.add('hidden');
apply.disabled = true;
}
},
async checkUpdate() {
const btn = document.getElementById('btn-check-update');
const status = document.getElementById('update-status');
const apply = document.getElementById('btn-apply-update');
btn.disabled = true;
apply.disabled = true;
status.classList.remove('hidden');
status.innerHTML = '<span class="text-muted">' + I18n.t('status.checkingUpdate') + '</span>';
try {
const r = await this.api('GET', '/self-update/check');
if (!r.ok) {
if (r.error === 'ERR_SELF_UPDATE_UNAVAILABLE') {
this.showSelfUpdateUnavailable(status, apply, r);
return;
}
if (/not a git repository/i.test(r.error || '')) {
status.innerHTML = `
<div style="color: var(--warn-text);font-weight:600;margin-bottom:4px">${I18n.t('status.notGitRepo')}</div>
<div class="text-sm text-muted">${I18n.t('status.notGitRepoHint')}</div>`;
apply.classList.add('hidden');
return;
}
apply.classList.add('hidden');
apply.disabled = true;
throw new Error(this.translateError(r.error, 'error.apiError'));
}
if (r.mode === 'docker') {
// Git mode unavailable but docker.sock is mounted — backend will
// pull the image and spawn a deployer sidecar to recreate this
// container in place. Show a "ready to apply" prompt instead of
// the git diff card.
status.innerHTML = `
<div style="color: var(--accent-text);font-weight:600;margin-bottom:6px">${I18n.t('status.dockerReady')}</div>
<div class="text-sm" style="display:grid;gap:3px">
<div>${I18n.t('status.dockerImage')} <code>${this.esc(r.image || '')}</code></div>
<div>${I18n.t('status.dockerProject')} <code>${this.esc(r.project || '')}</code></div>
</div>`;
apply.classList.remove('hidden');
apply.disabled = false;
apply.dataset.mode = 'docker';
return;
}
apply.dataset.mode = 'git';
if (r.diverged) {
status.innerHTML = `
<div style="color: var(--warn-text);font-weight:600;margin-bottom:6px">${I18n.t('status.updateDiverged')}</div>
<div class="text-sm text-muted">${I18n.t('status.updateDivergedHint', {
current: this.esc(r.commit || ''),
target: this.esc(r.remoteCommit || ''),
})}</div>`;
apply.classList.add('hidden');
apply.disabled = true;
} else if (r.behind) {
const unreleasedNote = r.unreleasedCount > 0
? `<div class="text-sm text-muted">${I18n.t('status.unreleasedHint', { count: r.unreleasedCount, tag: this.esc(r.latestTag || '') })}</div>`
: '';
status.innerHTML = `
<div style="color: var(--accent-text);font-weight:600;margin-bottom:6px">${I18n.t('status.updateAvailable')}</div>
<div class="text-sm" style="display:grid;gap:3px">
<div>${I18n.t('status.currentVersion')} <code>${this.esc(r.commit)}</code> <span class="text-muted">${this.esc(r.localMessage)}</span></div>
<div>${I18n.t('status.remoteVersion')} <code>${this.esc(r.remoteCommit)}</code> <span class="text-muted">${this.esc(r.remoteMessage || '')}</span></div>
${unreleasedNote}
</div>`;
apply.classList.remove('hidden');
apply.disabled = false;
} else {
const unreleasedNote = r.unreleasedCount > 0
? `<div class="text-sm text-muted" style="margin-top:6px">${I18n.t('status.unreleasedHint', { count: r.unreleasedCount, tag: this.esc(r.latestTag || '') })}</div>`
: '';
status.innerHTML = '<span style="color: var(--success-text)">✓ ' + I18n.t('status.upToDate', {commit: this.esc(r.commit), message: this.esc(r.localMessage)}) + '</span>' + unreleasedNote;
apply.classList.add('hidden');
apply.disabled = true;
}
} catch (err) {
status.innerHTML = `<span style="color: var(--error-text)">✗ ${this.esc(err.message)}</span>`;
} finally {
btn.disabled = false;
}
},
async applyUpdate() {
const ok = await this.confirm(I18n.t('button.updateAndRestart'), I18n.t('status.updateAndRestartHint'), { okText: I18n.t('button.updateAndRestart'), danger: true });
if (!ok) return;
const status = document.getElementById('update-status');
const apply = document.getElementById('btn-apply-update');
apply.disabled = true;
status.innerHTML = '<span class="text-muted">' + I18n.t('status.pullingRestarting') + '</span>';
try {
// Capture the old process identity before the update schedules its
// restart. Commit-only polling can be fooled by an unrelated listener
// serving the new tree, and a same-PID response can be the old process
// that never restarted at all.
let previousHealth = null;
try {
const previousResponse = await fetch('/health', { cache: 'no-store' });
if (previousResponse.ok) previousHealth = await previousResponse.json();
} catch { /* the update endpoint remains the source of truth for start */ }
let r = await this.api('POST', '/self-update');
if (r.dirty) {
const filesList = (r.dirtyFiles || []).slice(0, 10).map(f => this.esc(f)).join('<br>');
const force = await this.confirm(
I18n.t('status.workingDirDirty'),
I18n.t('status.workingDirDirtyHint', { files: filesList }),
{ okText: I18n.t('button.forceOverride'), danger: true, html: true }
);
if (!force) {
status.innerHTML = '<span class="text-muted">' + I18n.t('status.cancelled') + '</span>';
apply.disabled = false;
return;
}
r = await this.api('POST', '/self-update', { forceReset: true });
}
if (r.mode === 'docker') {
if (!r.ok) {
// r.reason is the short stable code ("deployer-pull-failed",
// "deployer-create-failed", etc.) and the only thing we can
// map to a localized i18n key. r.detail is the raw upstream
// error string — long, unstable, often containing characters
// that explode CSS selectors if treated as an i18n key. Show
// reason as the localized message; show detail underneath
// for debugging.
const localized = this.translateError(r.reason, 'error.updateFailed');
const msg = r.detail ? `${localized} — ${r.detail}` : localized;
throw new Error(msg);
}
const delaySec = r.delaySeconds || 8;
status.innerHTML = `
<div style="color: var(--success-text);font-weight:600;margin-bottom:6px">${I18n.t('status.dockerUpdating', { image: this.esc(r.image || ''), delay: delaySec })}</div>
<div class="text-sm text-muted">${I18n.t('status.dockerSidecarStarted', { id: this.esc(r.deployerId || '') })}</div>`;
setTimeout(() => location.reload(), (delaySec + 4) * 1000);
return;
}
if (!r.ok) {
if (r.error === 'ERR_SELF_UPDATE_UNAVAILABLE') {
this.showSelfUpdateUnavailable(status, apply, r);
return;
}
const conflicts = r.error === 'ERR_IGNORED_PATH_CONFLICT' && Array.isArray(r.conflictPaths)
? r.conflictPaths.slice(0, 10).join(', ')
: '';
const message = this.translateError(r.error, 'error.updateFailed');
throw new Error(conflicts ? `${message}: ${conflicts}` : message);
}
if (r.changed) {
status.innerHTML = `
<div style="color: var(--success-text);font-weight:600;margin-bottom:6px">${I18n.t('status.updateComplete', { before: this.esc(r.before), after: this.esc(r.after) })}</div>
<div class="text-sm text-muted">${I18n.t('status.restarting')}</div>
<pre style="margin-top:8px;font-size: 12px;max-height:200px;overflow:auto;padding:8px;background:var(--surface-2);border-radius:4px">${this.esc(r.pullOutput || '')}</pre>`;
// 失败回滚:更新后轮询健康,30s 内服务未恢复 → 提示 + 一键回滚按钮
this._pollHealthAfterUpdate(status, apply, {
expectedCommit: r.after,
previousPid: previousHealth?.pid,
});
} else {
status.innerHTML = '<span class="text-muted">' + I18n.t('status.noUpdateNeeded') + '</span>';
apply.classList.add('hidden');
}
} catch (err) {
status.innerHTML = `<span style="color: var(--error-text)">✗ ${this.esc(err.message)}</span>`;
} finally {
apply.disabled = false;
}
},
_pollHealthAfterUpdate(status, apply, { expectedCommit = '', previousPid = null } = {}) {
let tries = 0;
const maxTries = 15; // 2s × 15 = 30s
const expected = typeof expectedCommit === 'string'
? expectedCommit.trim().toLowerCase()
: '';
const oldPid = Number.isSafeInteger(previousPid) && previousPid > 0
? previousPid
: null;
const isCommitId = value => /^[0-9a-f]{7,64}$/.test(value);
const healthMatchesUpdate = (h) => {
if (!h || h.status !== 'ok' || !expected) return false;
const actual = typeof h.commit === 'string' ? h.commit.trim().toLowerCase() : '';
const abbreviated = Math.min(actual.length, expected.length) <= 12;
const commitsCorrespond = actual === expected
|| (abbreviated && (actual.startsWith(expected) || expected.startsWith(actual)));
if (!isCommitId(expected) || !isCommitId(actual) || !commitsCorrespond) return false;
const pid = h.pid;
if (!Number.isSafeInteger(pid) || pid <= 0) return false;
// When the old process identity was observable, require a genuinely new
// process. This closes the same-commit/same-listener false-success path.
return oldPid === null || pid !== oldPid;
};
const check = async () => {
try {
// 轮询公共 /health(不是 /dashboard/api/*)—— dashboard API 没有 /health 路由
const hr = await fetch('/health', { cache: 'no-store' });
if (!hr.ok) throw new Error(`health ${hr.status}`);