Ctrl K
ComunidadGuías…

src/dashboard/index-sketch.html

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
<!DOCTYPE html>
<html lang="zh-CN" data-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WindsurfAPI · Console</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght,SOFT,WONK@9..144,300..900,0..100,0..1&family=Caveat:wght@400..700&family=Kalam:wght@300;400;700&family=JetBrains+Mono:wght@400;500;700&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
/* ─────────────────────────────────────────────
   WindsurfAPI · Wireframe Console
   Tiempos-ish serif × handwritten sketch
   ───────────────────────────────────────────── */

:root[data-theme="light"] {
  --paper:        #f4efe6;
  --paper-2:      #ece5d6;
  --paper-3:      #e2d9c4;
  --ink:          #161412;
  --ink-2:        #2b2722;
  --ink-3:        #4a4339;
  --ink-mute:     #7a6f5f;
  --ink-dim:      #a39780;
  --rule:         #2b2722;
  --rule-soft:    #5b5247;
  --rule-faint:   #b8ad95;
  --accent:       #c33b1f;          /* red ink */
  --accent-soft:  rgba(195,59,31,.08);
  --highlight:    #f4d35e;          /* highlighter yellow */
  --good:         #3a7a3a;
  --warn:         #b87a1a;
  --error:        #b53224;
  --info:         #2a5a8c;
  --grain:        url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='220' height='220'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='.92' numOctaves='2' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 .12 0 0 0 0 .1 0 0 0 0 .08 0 0 0 .08 0'/></filter><rect width='100%' height='100%' filter='url(%23n)'/></svg>");
}
:root[data-theme="dark"] {
  --paper:        #14130f;
  --paper-2:      #1a1815;
  --paper-3:      #221f1a;
  --ink:          #f0e8d8;
  --ink-2:        #e1d8c5;
  --ink-3:        #c8bda5;
  --ink-mute:     #8a8068;
  --ink-dim:      #5e5645;
  --rule:         #d6cbb1;
  --rule-soft:    #8a8068;
  --rule-faint:   #4a4232;
  --accent:       #ff6b4a;
  --accent-soft:  rgba(255,107,74,.12);
  --highlight:    #d4a72c;
  --good:         #7fc77f;
  --warn:         #e0a85a;
  --error:        #ff7a6a;
  --info:         #80b3e6;
  --grain:        url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='220' height='220'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='.92' numOctaves='2' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 .9 0 0 0 0 .85 0 0 0 0 .75 0 0 0 .05 0'/></filter><rect width='100%' height='100%' filter='url(%23n)'/></svg>");
}

* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { height: 100%; }
body {
  font-family: 'Inter', -apple-system, 'PingFang SC', 'Microsoft YaHei', system-ui, sans-serif;
  background: var(--paper);
  background-image: var(--grain);
  color: var(--ink);
  font-size: 14px;
  line-height: 1.55;
  -webkit-font-smoothing: antialiased;
  display: flex;
  min-height: 100vh;
  letter-spacing: .005em;
}

/* Selection */
::selection { background: var(--highlight); color: var(--ink); }

/* Scrollbar */
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--rule-faint); border-radius: 0; border: 3px solid var(--paper); }
::-webkit-scrollbar-thumb:hover { background: var(--rule-soft); }

a { color: var(--accent); text-decoration: underline; text-decoration-style: wavy; text-underline-offset: 3px; text-decoration-thickness: 1px; }
a:hover { text-decoration-style: solid; }

code, .mono {
  font-family: 'JetBrains Mono', ui-monospace, monospace;
  font-size: 12px;
  font-feature-settings: 'tnum' 1;
}

/* ─── Type system ──────────────────────────── */
.serif      { font-family: 'Fraunces', 'Tiempos Headline', Georgia, serif; font-variation-settings: 'opsz' 36, 'SOFT' 50, 'WONK' 0; letter-spacing: -.018em; }
.serif-italic { font-family: 'Fraunces', Georgia, serif; font-style: italic; font-variation-settings: 'opsz' 36, 'SOFT' 100, 'WONK' 1; }
.handwritten { font-family: 'Caveat', 'Kalam', cursive; letter-spacing: .01em; }
.label-hand { font-family: 'Kalam', 'Caveat', cursive; font-weight: 400; letter-spacing: .02em; }

/* ─── Hand-drawn box (SVG-rendered border) ─── */
.box {
  position: relative;
  background: var(--paper-2);
  padding: 22px 24px;
}
.box::before {
  content: '';
  position: absolute; inset: 0;
  background-image: var(--box-svg);
  background-repeat: no-repeat;
  background-size: 100% 100%;
  pointer-events: none;
  z-index: 1;
}
.box > * { position: relative; z-index: 2; }

/* Reusable wobbly border drawn by inline SVG via data-uri.
   Light & dark themes pick the right ink via currentColor on the svg. */

/* Simpler: dashed wobble borders using SVG filter `feTurbulence` displacement on a rect. */
.wbox {
  position: relative;
  background: var(--paper-2);
}
.wbox-fill { background: var(--paper-2); }
.wbox > svg.wbox-bg {
  position: absolute; inset: 0; width: 100%; height: 100%;
  pointer-events: none;
  display: block;
}
.wbox > .wbox-inner {
  position: relative;
  padding: 22px 24px;
  z-index: 2;
}

/* ─── Sidebar ──────────────────────────────── */
.sidebar {
  width: 240px;
  flex-shrink: 0;
  background: var(--paper);
  border-right: 1px solid var(--rule-faint);
  position: fixed;
  top: 0; left: 0; bottom: 0;
  display: flex;
  flex-direction: column;
  z-index: 10;
  background-image:
    linear-gradient(to right, transparent calc(100% - 1px), var(--rule-faint) calc(100% - 1px)),
    var(--grain);
}

.sidebar .brand {
  padding: 24px 22px 18px;
  position: relative;
}
.sidebar .brand-row {
  display: flex; align-items: baseline; gap: 8px;
}
.sidebar .brand-mark {
  font-family: 'Fraunces', serif;
  font-weight: 600;
  font-size: 28px;
  font-variation-settings: 'opsz' 144, 'SOFT' 100, 'WONK' 1;
  letter-spacing: -.04em;
  line-height: 1;
}
.sidebar .brand-mark .amp { color: var(--accent); font-style: italic; }
.sidebar .brand-tag {
  font-family: 'Caveat', cursive;
  font-size: 18px;
  color: var(--ink-mute);
  margin-left: 4px;
  transform: rotate(-3deg);
  display: inline-block;
}
.sidebar .brand-rule {
  height: 12px; margin-top: 14px;
  background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 200 12' preserveAspectRatio='none'><path d='M2 6 Q 18 2, 35 7 T 70 5 T 105 8 T 140 4 T 175 7 T 198 6' fill='none' stroke='currentColor' stroke-width='1.4' stroke-linecap='round'/></svg>");
  background-repeat: no-repeat;
  background-size: 100% 100%;
  color: var(--rule-soft);
  filter: contrast(1);
}

.sidebar nav { flex: 1; padding: 8px 14px; overflow-y: auto; }
.sidebar .nav-group { margin-bottom: 22px; }
.sidebar .nav-group-label {
  font-family: 'Caveat', cursive;
  font-size: 18px;
  color: var(--ink-mute);
  padding: 4px 6px 8px;
  display: flex;
  align-items: center;
  gap: 6px;
}
.sidebar .nav-group-label::after {
  content: '';
  flex: 1;
  height: 4px;
  background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 4' preserveAspectRatio='none'><path d='M2 2 Q 10 0, 20 2 T 40 2 T 60 2 T 80 2 T 98 2' fill='none' stroke='currentColor' stroke-width='1' stroke-linecap='round'/></svg>");
  background-repeat: no-repeat;
  background-size: 100% 100%;
  color: var(--rule-faint);
  margin-left: 4px;
}

.sidebar nav a {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 8px 10px;
  color: var(--ink-2);
  font-size: 14px;
  font-weight: 500;
  text-decoration: none;
  position: relative;
  border-radius: 0;
  transition: color .15s, background .15s;
  margin-bottom: 1px;
}
.sidebar nav a:hover {
  color: var(--ink);
  background: var(--paper-2);
}
.sidebar nav a.active {
  color: var(--ink);
  background: var(--paper-3);
}
.sidebar nav a.active::before {
  content: '→';
  position: absolute;
  left: -6px;
  font-family: 'Caveat', cursive;
  font-weight: 700;
  font-size: 22px;
  color: var(--accent);
  line-height: 1;
}
.sidebar nav a svg { width: 16px; height: 16px; stroke-width: 1.5; flex-shrink: 0; opacity: .8; }
.sidebar nav a .nav-pill {
  margin-left: auto;
  font-family: 'JetBrains Mono', monospace;
  font-size: 10px;
  padding: 1px 6px;
  border: 1px solid var(--rule-faint);
  color: var(--ink-mute);
}

.sidebar .footer {
  padding: 14px 22px;
  border-top: 1px solid var(--rule-faint);
  display: flex;
  align-items: center;
  gap: 8px;
  font-size: 11px;
  color: var(--ink-mute);
}
.sidebar .footer .ver { font-family: 'JetBrains Mono', monospace; }
.theme-toggle, .lang-toggle {
  background: transparent;
  border: 1px solid var(--rule-faint);
  color: var(--ink-2);
  padding: 4px 8px;
  font-family: 'JetBrains Mono', monospace;
  font-size: 11px;
  cursor: pointer;
  transition: all .15s;
}
.theme-toggle:hover, .lang-toggle:hover {
  border-color: var(--ink);
  color: var(--ink);
}

/* ─── Main ─────────────────────────────────── */
.main {
  margin-left: 240px;
  flex: 1;
  padding: 32px 40px 80px;
  min-height: 100vh;
  max-width: 1500px;
  position: relative;
}

/* Decorative margin line (notebook feel) */
.main::before {
  content: '';
  position: absolute;
  left: 26px;
  top: 0; bottom: 0;
  width: 1px;
  background: linear-gradient(to bottom, transparent, var(--rule-faint) 6%, var(--rule-faint) 94%, transparent);
  opacity: .5;
  pointer-events: none;
}

.page-header {
  display: flex;
  align-items: flex-end;
  justify-content: space-between;
  gap: 24px;
  flex-wrap: wrap;
  margin-bottom: 28px;
  padding-bottom: 18px;
  border-bottom: 1.5px solid var(--ink);
  position: relative;
}
.page-header::after {
  /* second wobble line for "double rule" feel */
  content: '';
  position: absolute;
  left: 0; right: 0; bottom: -5px;
  height: 6px;
  background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 400 6' preserveAspectRatio='none'><path d='M2 3 Q 30 1, 60 3 T 120 3 T 180 3 T 240 3 T 300 3 T 360 3 T 398 3' fill='none' stroke='currentColor' stroke-width='1' stroke-linecap='round'/></svg>");
  background-repeat: no-repeat;
  background-size: 100% 100%;
  color: var(--rule-soft);
  pointer-events: none;
}
.page-num {
  font-family: 'Caveat', cursive;
  font-size: 22px;
  color: var(--accent);
  display: inline-block;
  transform: rotate(-4deg);
  margin-right: 8px;
}
.page-title {
  font-family: 'Fraunces', serif;
  font-variation-settings: 'opsz' 96, 'SOFT' 80, 'WONK' 1;
  font-weight: 500;
  font-size: 44px;
  letter-spacing: -.025em;
  line-height: 1.05;
  color: var(--ink);
}
.page-title .em {
  font-style: italic;
  color: var(--accent);
  font-variation-settings: 'opsz' 96, 'SOFT' 100, 'WONK' 1;
}
.page-subtitle {
  font-family: 'Caveat', cursive;
  font-size: 20px;
  color: var(--ink-mute);
  margin-top: 6px;
}
.page-meta {
  font-family: 'JetBrains Mono', monospace;
  font-size: 11px;
  color: var(--ink-mute);
  text-align: right;
}
.page-meta b { color: var(--ink); font-weight: 500; }
.page-meta .stamp {
  display: inline-block;
  border: 1.5px solid var(--accent);
  color: var(--accent);
  padding: 2px 10px;
  font-family: 'Caveat', cursive;
  font-size: 16px;
  letter-spacing: .12em;
  text-transform: uppercase;
  transform: rotate(-2deg);
  margin-top: 6px;
}

.panel { display: none; animation: fadeIn .25s ease; }
.panel.active { display: block; }
@keyframes fadeIn {
  from { opacity: 0; transform: translateY(6px); }
  to { opacity: 1; transform: translateY(0); }
}

/* ─── Section ──────────────────────────────── */
.section {
  position: relative;
  margin-bottom: 28px;
}
.section-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 16px;
  flex-wrap: wrap;
  margin-bottom: 14px;
  padding-bottom: 8px;
  border-bottom: 1px dashed var(--rule-soft);
}
.section-title {
  font-family: 'Fraunces', serif;
  font-variation-settings: 'opsz' 60, 'SOFT' 100, 'WONK' 1;
  font-weight: 500;
  font-size: 22px;
  letter-spacing: -.015em;
  color: var(--ink);
  display: flex;
  align-items: baseline;
  gap: 10px;
}
.section-title .num {
  font-family: 'Caveat', cursive;
  font-style: normal;
  color: var(--accent);
  font-size: 18px;
  border: 1.5px solid var(--accent);
  border-radius: 50%;
  width: 26px; height: 26px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  flex-shrink: 0;
}
.section-desc {
  font-size: 13px;
  color: var(--ink-mute);
  margin-top: 4px;
  max-width: 60ch;
}
.section-actions {
  display: flex;
  gap: 8px;
  flex-wrap: wrap;
  align-items: center;
}

/* Sketch frame around content */
.frame {
  position: relative;
  background: var(--paper-2);
  padding: 20px 22px;
}
.frame::before {
  content: '';
  position: absolute;
  inset: 0;
  border: 1.5px solid var(--ink);
  pointer-events: none;
  /* Slight rotation feels off — instead use a doubled shadow border */
}
.frame::after {
  content: '';
  position: absolute;
  inset: 4px -4px -4px 4px;
  border: 1px solid var(--rule-soft);
  pointer-events: none;
  z-index: 0;
}
.frame > * { position: relative; z-index: 1; }

.frame.tight { padding: 0; }
.frame-head {
  padding: 14px 20px;
  border-bottom: 1px dashed var(--rule-soft);
  background: var(--paper-3);
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  flex-wrap: wrap;
}
.frame-head .ft {
  font-family: 'Fraunces', serif;
  font-weight: 500;
  font-size: 16px;
  font-variation-settings: 'opsz' 36, 'WONK' 1;
}
.frame-body { padding: 18px 20px; }
.frame-body.tight { padding: 0; }

/* ─── Cards / Stats grid ───────────────────── */
.metrics-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
  gap: 0;
  margin-bottom: 28px;
  border: 1.5px solid var(--ink);
  background: var(--paper-2);
}
.metric {
  padding: 18px 20px;
  border-right: 1px dashed var(--rule-soft);
  border-bottom: 1px dashed var(--rule-soft);
  position: relative;
  background: var(--paper-2);
  transition: background .15s;
}
.metric:hover { background: var(--paper-3); }
.metric .m-label {
  font-family: 'Caveat', cursive;
  font-size: 17px;
  color: var(--ink-mute);
  display: flex;
  align-items: center;
  gap: 8px;
  margin-bottom: 4px;
}
.metric .m-icon {
  width: 18px; height: 18px;
  opacity: .7;
}
.metric .m-value {
  font-family: 'Fraunces', serif;
  font-variation-settings: 'opsz' 96, 'SOFT' 100, 'WONK' 1;
  font-weight: 500;
  font-size: 38px;
  letter-spacing: -.03em;
  line-height: 1.1;
  font-feature-settings: 'tnum' 1, 'lnum' 1;
}
.metric .m-value.italic { font-style: italic; color: var(--accent); }
.metric .m-sub {
  font-size: 12px;
  color: var(--ink-mute);
  margin-top: 6px;
  font-family: 'JetBrains Mono', monospace;
}
.metric .m-spark {
  position: absolute;
  right: 14px; top: 16px;
  width: 64px; height: 26px;
  opacity: .7;
}
.metric.accent .m-value { color: var(--accent); font-style: italic; }
.metric.good .m-value { color: var(--good); }
.metric.warn .m-value { color: var(--warn); }
.metric.error .m-value { color: var(--error); }

/* ─── Buttons ──────────────────────────────── */
.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 6px;
  padding: 7px 14px;
  font-size: 13px;
  font-weight: 500;
  font-family: inherit;
  background: var(--paper);
  color: var(--ink);
  border: 1.5px solid var(--ink);
  cursor: pointer;
  transition: all .12s;
  white-space: nowrap;
  line-height: 1.2;
  position: relative;
  border-radius: 0;
}
.btn::after {
  /* sketchy double shadow */
  content: '';
  position: absolute;
  inset: 2px -3px -3px 2px;
  border: 1px solid var(--rule-soft);
  pointer-events: none;
  z-index: -1;
  transition: inset .12s;
}
.btn:hover:not(:disabled) {
  background: var(--ink);
  color: var(--paper);
}
.btn:hover:not(:disabled)::after { inset: 3px -4px -4px 3px; }
.btn:active:not(:disabled) { transform: translate(1px, 1px); }
.btn:active:not(:disabled)::after { inset: 1px -2px -2px 1px; }
.btn:disabled { opacity: .45; cursor: not-allowed; }
.btn svg { width: 14px; height: 14px; stroke-width: 1.5; }

.btn-primary {
  background: var(--accent);
  color: #fff;
  border-color: var(--accent);
}
.btn-primary:hover:not(:disabled) { background: var(--ink); border-color: var(--ink); }

.btn-ghost {
  background: transparent;
  border-color: var(--rule-faint);
  color: var(--ink-2);
}
.btn-ghost:hover:not(:disabled) {
  background: var(--paper-3);
  color: var(--ink);
  border-color: var(--ink);
}
.btn-ghost::after { display: none; }

.btn-danger { background: var(--error); border-color: var(--error); color: #fff; }
.btn-danger:hover:not(:disabled) { background: var(--ink); border-color: var(--ink); }

.btn-good { background: var(--good); border-color: var(--good); color: #fff; }
.btn-good:hover:not(:disabled) { background: var(--ink); border-color: var(--ink); }

.btn-sm { padding: 4px 10px; font-size: 12px; }
.btn-xs { padding: 2px 8px; font-size: 11px; }
.btn-xs::after, .btn-sm::after { inset: 1px -2px -2px 1px; }
.btn-icon { padding: 6px; }

.btn-group { display: inline-flex; gap: 8px; flex-wrap: wrap; }

.btn.active, .btn.active:hover {
  background: var(--ink); color: var(--paper);
}

/* ─── Form Controls ────────────────────────── */
.input, .select, .textarea {
  display: block;
  width: 100%;
  padding: 8px 12px;
  font-size: 13px;
  font-family: 'JetBrains Mono', ui-monospace, monospace;
  background: var(--paper);
  border: 1.5px solid var(--rule-soft);
  border-bottom-style: solid;
  color: var(--ink);
  outline: none;
  transition: all .12s;
  line-height: 1.4;
  border-radius: 0;
}
.input:hover, .select:hover, .textarea:hover { border-color: var(--ink-3); }
.input:focus, .select:focus, .textarea:focus {
  border-color: var(--accent);
  background: var(--paper-2);
}
.input::placeholder, .textarea::placeholder { color: var(--ink-dim); font-style: italic; font-family: 'Caveat', cursive; font-size: 16px; }

/* underline-only variant (more sketch-y) */
.input.underline {
  border: none;
  border-bottom: 1.5px solid var(--rule-soft);
  background: transparent;
  padding: 6px 2px;
}
.input.underline:focus { border-bottom-color: var(--accent); background: transparent; }

.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='%237a6f5f' stroke-width='1.8' 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;
}
.select option { background: var(--paper); color: var(--ink); }

.field { display: flex; flex-direction: column; gap: 6px; flex: 1; min-width: 0; }
.field-label {
  font-family: 'Caveat', cursive;
  font-size: 17px;
  color: var(--ink-2);
  font-weight: 500;
  display: flex;
  align-items: center;
  gap: 6px;
}
.field-label::before {
  content: '↳';
  color: var(--accent);
  opacity: .7;
}
.field-hint { font-size: 11px; color: var(--ink-mute); font-style: italic; }
.field-row {
  display: grid;
  gap: 14px;
  grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
  align-items: end;
}
.field-row-actions { display: flex; gap: 8px; margin-top: 16px; flex-wrap: wrap; }

/* Checkbox — hand-drawn box */
.checkbox {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  cursor: pointer;
  font-size: 13px;
  color: var(--ink-2);
  user-select: none;
}
.checkbox input { position: absolute; opacity: 0; pointer-events: none; }
.checkbox .box-mark {
  width: 16px; height: 16px;
  border: 1.5px solid var(--ink-3);
  display: flex;
  align-items: center;
  justify-content: center;
  background: var(--paper);
  flex-shrink: 0;
  position: relative;
}
.checkbox input:checked + .box-mark::after {
  content: '✓';
  font-family: 'Caveat', cursive;
  font-weight: 700;
  font-size: 18px;
  color: var(--accent);
  line-height: 1;
  transform: translate(0, -1px);
}
.checkbox:hover .box-mark { border-color: var(--accent); }

/* Switch — toggle drawn as flipping ink dot */
.switch {
  position: relative;
  display: inline-flex;
  align-items: center;
  width: 46px; height: 22px;
  flex-shrink: 0;
  cursor: pointer;
}
.switch input { opacity: 0; width: 0; height: 0; }
.switch-slider {
  position: absolute; inset: 0;
  background: var(--paper);
  border: 1.5px solid var(--ink-3);
  transition: .2s;
}
.switch-slider::before {
  content: ''; position: absolute;
  height: 14px; width: 14px;
  left: 2px; top: 2px;
  background: var(--ink-3);
  transition: .2s;
}
.switch input:checked + .switch-slider {
  background: var(--accent-soft);
  border-color: var(--accent);
}
.switch input:checked + .switch-slider::before {
  transform: translateX(22px);
  background: var(--accent);
}

/* Segmented (tabs) */
.segmented {
  display: inline-flex;
  border: 1.5px solid var(--ink);
  background: var(--paper);
}
.segmented label {
  padding: 6px 14px;
  font-size: 12px;
  font-family: inherit;
  font-weight: 500;
  color: var(--ink-2);
  cursor: pointer;
  border-right: 1px solid var(--rule-soft);
  position: relative;
}
.segmented label:last-child { border-right: none; }
.segmented label input { position: absolute; opacity: 0; pointer-events: none; }
.segmented label:has(input:checked) {
  background: var(--ink);
  color: var(--paper);
}
.segmented label:hover:not(:has(input:checked)) { background: var(--paper-3); }

/* ─── Tables ───────────────────────────────── */
.table-wrap {
  overflow-x: auto;
  background: var(--paper);
}
table {
  width: 100%;
  border-collapse: collapse;
  font-size: 13px;
}
thead th {
  text-align: left;
  padding: 10px 14px;
  font-family: 'Caveat', cursive;
  font-size: 18px;
  font-weight: 400;
  color: var(--ink);
  background: var(--paper-3);
  border-bottom: 1.5px solid var(--ink);
  white-space: nowrap;
  letter-spacing: .01em;
}
tbody td {
  padding: 11px 14px;
  border-bottom: 1px dashed var(--rule-soft);
  vertical-align: middle;
}
tbody tr:last-child td { border-bottom: none; }
tbody tr { transition: background .1s; }
tbody tr:hover td { background: var(--paper-3); }
.empty-row td {
  text-align: center;
  color: var(--ink-mute);
  padding: 36px 16px;
  font-family: 'Caveat', cursive;
  font-size: 18px;
  font-style: italic;
}

/* Expand chevron */
.expand-chev {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 20px; height: 20px;
  border: 1px dashed var(--rule-soft);
  background: transparent;
  color: var(--ink-mute);
  cursor: pointer;
  transition: all .15s;
  font-family: 'Caveat', cursive;
  font-size: 14px;
  margin-right: 6px;
  vertical-align: middle;
}
.expand-chev:hover { color: var(--accent); border-color: var(--accent); }
.expand-chev.open { color: var(--accent); border-style: solid; transform: rotate(90deg); }

.detail-row > td {
  padding: 0 !important;
  background: var(--paper-3) !important;
  border-bottom: 1.5px solid var(--ink) !important;
}
.detail-wrap { padding: 22px 28px; }
.detail-grid {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 18px;
  margin-bottom: 14px;
}
@media (max-width: 1100px) { .detail-grid { grid-template-columns: 1fr; } }
.detail-card {
  background: var(--paper-2);
  border: 1.5px solid var(--ink);
  padding: 14px 16px;
  position: relative;
}
.detail-card::after {
  content: '';
  position: absolute;
  inset: 3px -3px -3px 3px;
  border: 1px solid var(--rule-soft);
  z-index: -1;
}
.detail-card-head {
  display: flex;
  align-items: baseline;
  gap: 8px;
  font-family: 'Fraunces', serif;
  font-variation-settings: 'opsz' 36, 'WONK' 1;
  font-style: italic;
  font-weight: 500;
  font-size: 16px;
  color: var(--ink);
  margin-bottom: 12px;
  padding-bottom: 6px;
  border-bottom: 1px dashed var(--rule-soft);
}
.detail-card-head .num-circle {
  font-family: 'Caveat', cursive;
  font-style: normal;
  color: var(--accent);
  font-size: 14px;
  border: 1.5px solid var(--accent);
  border-radius: 50%;
  width: 22px; height: 22px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
}
.detail-kv {
  display: grid;
  grid-template-columns: 100px 1fr;
  gap: 5px 14px;
  font-size: 13px;
  line-height: 1.6;
}
.detail-kv > .k { color: var(--ink-mute); font-family: 'Caveat', cursive; font-size: 15px; }
.detail-kv > .v { color: var(--ink); font-feature-settings: 'tnum' 1; }
.detail-kv code { font-size: 11px; padding: 1px 5px; background: var(--paper); border: 1px solid var(--rule-faint); }

/* Progress bar — pen scratch */
.bar {
  height: 10px;
  border: 1.5px solid var(--ink);
  background: var(--paper);
  position: relative;
  overflow: hidden;
}
.bar-fill {
  height: 100%;
  background-image: repeating-linear-gradient(45deg, var(--accent), var(--accent) 4px, var(--accent-soft) 4px, var(--accent-soft) 7px);
  transition: width .6s cubic-bezier(.2,.8,.2,1);
}
.bar-fill.good {
  background-image: repeating-linear-gradient(45deg, var(--good), var(--good) 4px, transparent 4px, transparent 7px);
}
.bar-fill.warn {
  background-image: repeating-linear-gradient(45deg, var(--warn), var(--warn) 4px, transparent 4px, transparent 7px);
}
.bar-fill.error {
  background-image: repeating-linear-gradient(45deg, var(--error), var(--error) 4px, transparent 4px, transparent 7px);
}

.detail-bar {
  display: grid;
  grid-template-columns: 60px 1fr 50px;
  align-items: center;
  gap: 10px;
  margin-bottom: 8px;
  font-size: 12px;
}
.detail-bar .b-label { color: var(--ink-mute); font-family: 'Caveat', cursive; font-size: 15px; }
.detail-bar .b-pct { font-family: 'JetBrains Mono', monospace; font-size: 11px; text-align: right; font-weight: 600; }
.detail-bar .b-reset {
  grid-column: 2 / 4;
  font-size: 10.5px;
  color: var(--ink-mute);
  font-family: 'JetBrains Mono', monospace;
  margin-top: 2px;
  font-style: italic;
}

/* ─── Badges ───────────────────────────────── */
.badge {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  padding: 1px 8px;
  font-family: 'Caveat', cursive;
  font-size: 14px;
  font-weight: 500;
  line-height: 1.5;
  border: 1.2px solid currentColor;
  background: var(--paper);
  border-radius: 0;
}
.badge::before {
  content: '';
  width: 6px; height: 6px;
  border-radius: 50%;
  background: currentColor;
  flex-shrink: 0;
}
.badge.active, .badge.running, .badge.success { color: var(--good); }
.badge.error, .badge.stopped { color: var(--error); }
.badge.disabled, .badge.muted { color: var(--ink-mute); }
.badge.warn { color: var(--warn); }
.badge.info, .badge.pro { color: var(--info); }
.badge.no-dot::before { display: none; }

/* Tier */
.tier {
  display: inline-flex;
  align-items: center;
  padding: 1px 10px;
  font-family: 'Fraunces', serif;
  font-variation-settings: 'WONK' 1;
  font-weight: 600;
  font-size: 11px;
  text-transform: uppercase;
  letter-spacing: .12em;
  cursor: pointer;
}
.tier.pro { background: var(--ink); color: var(--paper); }
.tier.free { background: transparent; color: var(--info); border: 1.2px solid var(--info); }
.tier.expired { background: transparent; color: var(--error); border: 1.2px solid var(--error); }
.tier.unknown { background: transparent; color: var(--ink-mute); border: 1.2px dashed var(--rule-soft); }

/* ─── Login overlay ────────────────────────── */
.login-overlay {
  position: fixed;
  inset: 0;
  background: var(--paper);
  background-image: var(--grain);
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 200;
}
.login-box {
  background: var(--paper-2);
  border: 2px solid var(--ink);
  padding: 40px 36px;
  width: 400px;
  position: relative;
}
.login-box::after {
  content: '';
  position: absolute;
  inset: 6px -6px -6px 6px;
  border: 1px solid var(--rule-soft);
  z-index: -1;
}
.login-box .login-mark {
  font-family: 'Fraunces', serif;
  font-variation-settings: 'opsz' 144, 'SOFT' 100, 'WONK' 1;
  font-weight: 500;
  font-size: 56px;
  letter-spacing: -.04em;
  text-align: center;
  margin-bottom: 4px;
  line-height: 1;
}
.login-box .login-mark .em { font-style: italic; color: var(--accent); }
.login-box .login-tag {
  font-family: 'Caveat', cursive;
  font-size: 22px;
  color: var(--ink-mute);
  text-align: center;
  margin-bottom: 30px;
  transform: rotate(-2deg);
}
.login-box .input { margin-bottom: 16px; }
.login-box .btn { width: 100%; padding: 11px; font-weight: 600; font-size: 14px; }

/* ─── Toast ────────────────────────────────── */
.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 16px;
  background: var(--paper);
  border: 1.5px solid var(--ink);
  font-size: 13px;
  max-width: 380px;
  display: flex;
  align-items: center;
  gap: 10px;
  animation: slideIn .25s ease;
  pointer-events: auto;
  position: relative;
  font-family: inherit;
}
.toast::after {
  content: '';
  position: absolute;
  inset: 3px -3px -3px 3px;
  border: 1px solid var(--rule-soft);
  z-index: -1;
}
.toast.success { border-left: 4px solid var(--good); }
.toast.error { border-left: 4px solid var(--error); }
.toast.info { border-left: 4px solid var(--info); }
.toast .toast-icon {
  font-family: 'Caveat', cursive;
  font-size: 22px;
  font-weight: 700;
  flex-shrink: 0;
}
.toast.success .toast-icon { color: var(--good); }
.toast.error .toast-icon { color: var(--error); }
.toast.info .toast-icon { color: var(--info); }
@keyframes slideIn {
  from { opacity: 0; transform: translateX(20px); }
  to { opacity: 1; transform: translateX(0); }
}

/* ─── Modal ────────────────────────────────── */
.modal-overlay {
  position: fixed; inset: 0;
  background: rgba(0,0,0,.4);
  backdrop-filter: blur(3px);
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 100;
  animation: fadeIn .15s ease;
}
.modal {
  background: var(--paper);
  border: 2px solid var(--ink);
  width: 90%;
  max-width: 460px;
  position: relative;
  animation: modalIn .2s cubic-bezier(.16,1,.3,1);
}
.modal::after {
  content: '';
  position: absolute;
  inset: 6px -6px -6px 6px;
  border: 1px solid var(--rule-soft);
  z-index: -1;
}
@keyframes modalIn {
  from { opacity: 0; transform: translateY(-12px); }
  to { opacity: 1; transform: translateY(0); }
}
.modal-header { padding: 22px 26px 10px; }
.modal-title {
  font-family: 'Fraunces', serif;
  font-variation-settings: 'opsz' 36, 'WONK' 1;
  font-weight: 500;
  font-size: 22px;
}
.modal-desc {
  font-family: 'Caveat', cursive;
  font-size: 17px;
  color: var(--ink-mute);
  margin-top: 4px;
}
.modal-body { padding: 12px 26px 22px; }
.modal-footer {
  padding: 14px 26px;
  border-top: 1px dashed var(--rule-soft);
  display: flex;
  justify-content: flex-end;
  gap: 10px;
  background: var(--paper-2);
}

/* ─── Logs / mono blocks ───────────────────── */
.log-container {
  background: var(--paper-2);
  border: 1.5px solid var(--ink);
  height: 540px;
  overflow-y: auto;
  font-family: 'JetBrains Mono', monospace;
  font-size: 12px;
  line-height: 1.65;
  position: relative;
}
.log-container::after {
  content: '';
  position: absolute;
  inset: 4px -4px -4px 4px;
  border: 1px solid var(--rule-soft);
  pointer-events: none;
}
.log-entry {
  padding: 3px 16px;
  border-bottom: 1px dotted var(--rule-faint);
  display: flex;
  gap: 10px;
  align-items: baseline;
  word-break: break-all;
}
.log-entry:hover { background: var(--paper-3); }
.log-entry .ts { color: var(--ink-mute); flex-shrink: 0; font-size: 11px; }
.log-entry .lvl {
  font-weight: 700;
  flex-shrink: 0;
  width: 50px;
}
.log-entry.debug .lvl { color: var(--ink-mute); }
.log-entry.info .lvl { color: var(--info); }
.log-entry.warn .lvl { color: var(--warn); }
.log-entry.error .lvl { color: var(--error); }
.log-entry.error { background: var(--accent-soft); }

.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 container (hand-drawn axes) ────── */
.chart-card {
  background: var(--paper-2);
  border: 1.5px solid var(--ink);
  padding: 22px;
  margin-bottom: 24px;
  position: relative;
}
.chart-card::after {
  content: '';
  position: absolute;
  inset: 4px -4px -4px 4px;
  border: 1px solid var(--rule-soft);
  z-index: -1;
}
.chart-canvas-wrap {
  position: relative;
  width: 100%;
  height: 280px;
  margin-top: 14px;
}
.chart-canvas-wrap canvas { width: 100%; height: 100%; display: block; }
.chart-legend {
  display: flex;
  gap: 18px;
  flex-wrap: wrap;
  justify-content: center;
  margin-top: 14px;
  padding-top: 12px;
  border-top: 1px dashed var(--rule-soft);
  font-family: 'Caveat', cursive;
  font-size: 16px;
}
.chart-legend-item {
  display: flex; align-items: center; gap: 6px;
  cursor: pointer;
}
.chart-legend-swatch { width: 18px; height: 4px; }

/* ─── LS pool cards ────────────────────────── */
.ls-pool {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
  gap: 0;
  border: 1.5px solid var(--ink);
}
.ls-inst {
  padding: 14px 16px;
  background: var(--paper-2);
  border-right: 1px dashed var(--rule-soft);
  border-bottom: 1px dashed var(--rule-soft);
  font-size: 12px;
}
.ls-inst .ls-key {
  font-family: 'Fraunces', serif;
  font-variation-settings: 'WONK' 1;
  font-weight: 500;
  font-size: 15px;
  display: flex;
  align-items: center;
  gap: 8px;
  margin-bottom: 6px;
}
.ls-inst .ls-dot {
  width: 8px; height: 8px;
  border-radius: 50%;
  background: var(--good);
  flex-shrink: 0;
}
.ls-inst .ls-dot.pending { background: var(--warn); }
.ls-inst .ls-meta { color: var(--ink-mute); font-family: 'JetBrains Mono', monospace; font-size: 11px; margin-top: 2px; }

/* Model chip */
.model-chips { display: flex; flex-wrap: wrap; gap: 6px; margin: 10px 0; }
.model-chip {
  display: inline-flex; align-items: center; gap: 6px;
  padding: 4px 10px;
  font-family: 'JetBrains Mono', monospace;
  font-size: 11.5px;
  background: var(--paper);
  border: 1.2px solid var(--rule-soft);
  color: var(--ink-2);
  cursor: pointer;
  transition: all .12s;
}
.model-chip:hover { border-color: var(--ink); color: var(--ink); }
.model-chip.selected { background: var(--ink); color: var(--paper); border-color: var(--ink); }
.model-chip.blocked { opacity: .5; text-decoration: line-through; }
.model-chip .remove { color: var(--accent); font-weight: 700; cursor: pointer; padding: 0 2px; }
.provider-group { margin-bottom: 14px; }
.provider-label {
  font-family: 'Caveat', cursive;
  font-size: 17px;
  color: var(--ink-mute);
  margin-bottom: 6px;
  display: flex; align-items: center; gap: 6px;
}
.provider-label::after {
  content: ''; flex: 1; height: 1px; background: var(--rule-faint); margin-left: 4px;
}

/* Bars panel */
.bars-row {
  display: flex;
  align-items: flex-end;
  gap: 4px;
  height: 180px;
  padding: 0 10px;
  border-bottom: 1.5px solid var(--ink);
  border-left: 1.5px solid var(--ink);
  position: relative;
}
.bars-row::before {
  content: '';
  position: absolute;
  bottom: -1.5px; left: 0; right: 0;
  height: 6px;
  pointer-events: none;
}
.bar-wrap {
  flex: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  height: 100%;
  justify-content: flex-end;
  position: relative;
}
.b-bar {
  width: 100%;
  min-width: 6px;
  max-width: 28px;
  background-image: repeating-linear-gradient(45deg, var(--ink), var(--ink) 3px, transparent 3px, transparent 6px);
  transition: all .3s;
  cursor: pointer;
  border: 1.5px solid var(--ink);
  border-bottom: none;
}
.b-bar.has-errors {
  background-image: repeating-linear-gradient(45deg, var(--error), var(--error) 3px, transparent 3px, transparent 6px);
  border-color: var(--error);
}
.bar-wrap:hover .b-bar { filter: brightness(1.1); }

/* Capability list */
.cap-list { display: inline-flex; flex-wrap: wrap; gap: 4px; }
.cap-item {
  font-size: 10px;
  padding: 1px 7px;
  font-family: 'JetBrains Mono', monospace;
  font-weight: 600;
  border: 1px solid currentColor;
}
.cap-item.ok { color: var(--good); }
.cap-item.fail { color: var(--error); text-decoration: line-through; }

/* Spinner */
.spinner {
  display: inline-block;
  width: 14px; height: 14px;
  border: 2px solid var(--rule-faint);
  border-top-color: var(--accent);
  border-radius: 50%;
  animation: spin .7s linear infinite;
  vertical-align: middle;
}
@keyframes spin { to { transform: rotate(360deg); } }

/* Utilities */
.hidden { display: none !important; }
.text-mute { color: var(--ink-mute); }
.text-dim { color: var(--ink-dim); }
.text-sm { font-size: 12px; }
.text-xs { font-size: 11px; }
.nowrap { white-space: nowrap; }
.break-all { word-break: break-all; }
.flex { display: flex; }
.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; }
.italic { font-style: italic; }

/* ─── Responsive ───────────────────────────── */
@media (max-width: 900px) {
  .sidebar { width: 60px; }
  .sidebar .brand-tag, .sidebar .brand-rule, .sidebar nav a span:not(.nav-pill), .sidebar .nav-group-label { display: none; }
  .sidebar .brand { padding: 18px 0; text-align: center; }
  .sidebar .brand-mark { font-size: 26px; }
  .sidebar nav a { justify-content: center; padding: 10px; }
  .sidebar .footer { flex-direction: column; gap: 6px; padding: 12px; }
  .main { margin-left: 60px; padding: 22px 18px 60px; }
  .page-title { font-size: 32px; }
}

/* Annotation */
.anno {
  font-family: 'Caveat', cursive;
  font-size: 17px;
  color: var(--ink-mute);
  display: inline-flex;
  align-items: center;
  gap: 6px;
}
.anno::before {
  content: '✱';
  color: var(--accent);
  font-size: 14px;
}

/* "Sticker" — diagonal label */
.sticker {
  display: inline-block;
  font-family: 'Caveat', cursive;
  font-size: 18px;
  padding: 2px 12px;
  border: 1.5px solid var(--accent);
  color: var(--accent);
  transform: rotate(-2.5deg);
  background: var(--paper);
}

/* Avatar circle (login history) */
.avatar-letter {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 28px; height: 28px;
  border: 1.5px solid var(--ink);
  background: var(--paper);
  font-family: 'Fraunces', serif;
  font-variation-settings: 'WONK' 1;
  font-weight: 500;
  font-size: 14px;
  border-radius: 50%;
  color: var(--ink);
}

/* Decorative arrow */
.draw-arrow {
  display: inline-block;
  width: 22px;
  height: 12px;
  background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 22 12' fill='none' stroke='currentColor' stroke-width='1.4' stroke-linecap='round' stroke-linejoin='round'><path d='M2 6 Q 8 4, 14 6 T 20 6'/><path d='M16 2 L 20 6 L 16 10'/></svg>");
  background-repeat: no-repeat;
  color: currentColor;
}
</style>
<script type="module">
import { initializeApp } from 'https://www.gstatic.com/firebasejs/11.6.0/firebase-app.js';
import { getAuth, signInWithPopup, GoogleAuthProvider, GithubAuthProvider } from 'https://www.gstatic.com/firebasejs/11.6.0/firebase-auth.js';
const _fbApp = initializeApp({
  apiKey: 'AIzaSyDsOl-1XpT5err0Tcnx8FFod1H8gVGIycY',
  authDomain: 'exa2-fb170.firebaseapp.com',
  projectId: 'exa2-fb170',
});
const _fbAuth = getAuth(_fbApp);
window._firebaseOAuth = async function(provider) {
  const p = provider === 'github' ? new GithubAuthProvider() : new GoogleAuthProvider();
  if (provider === 'google') p.addScope('email');
  const result = await signInWithPopup(_fbAuth, p);
  const idToken = await result.user.getIdToken();
  return {
    idToken,
    refreshToken: result.user.stsTokenManager?.refreshToken || '',
    email: result.user.email || '',
    provider,
  };
};
</script>
</head>
<body>

<!-- ════════ Sidebar ════════ -->
<aside class="sidebar">
  <div class="brand">
    <div class="brand-row">
      <div class="brand-mark">W<span class="amp">&amp;</span>API</div>
    </div>
    <div class="brand-tag">a console for the curious</div>
    <div class="brand-rule"></div>
  </div>
  <nav>
    <div class="nav-group">
      <div class="nav-group-label" data-zh="概览" data-en="overview">概览</div>
      <a href="#overview" class="active" 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-zh="仪表盘" data-en="Dashboard">仪表盘</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-zh="统计分析" data-en="Statistics">统计分析</span>
      </a>
    </div>
    <div class="nav-group">
      <div class="nav-group-label" data-zh="账号" data-en="accounts">账号</div>
      <a href="#windsurf-login" data-panel="windsurf-login">
        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><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-zh="登录取号" data-en="Login &amp; Acquire">登录取号</span>
      </a>
      <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-zh="账号管理" data-en="Account Manager">账号管理</span>
        <span class="nav-pill" id="nav-accounts-count">·</span>
      </a>
      <a href="#bans" data-panel="bans">
        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
        <span data-zh="异常监测" data-en="Anomalies">异常监测</span>
      </a>
    </div>
    <div class="nav-group">
      <div class="nav-group-label" data-zh="系统" data-en="system">系统</div>
      <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-zh="模型控制" data-en="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-zh="代理配置" data-en="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-zh="运行日志" data-en="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-zh="实验性功能" data-en="Experimental">实验性功能</span>
      </a>
    </div>
    <div class="nav-group">
      <div class="nav-group-label" data-zh="关于" data-en="about">关于</div>
      <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-zh="致谢" data-en="Credits">致谢</span>
      </a>
    </div>
  </nav>
  <div class="footer">
    <button class="theme-toggle" onclick="App.toggleTheme()" title="theme"><span id="theme-ind"><svg class="ic" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:inline-block;vertical-align:-2px"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg></span></button>
    <button class="lang-toggle" onclick="App.toggleLang()" title="language"><span id="lang-ind">中</span> / EN</button>
    <button class="lang-toggle" onclick="App.switchSkin('modern')" title="switch to default skin">默认皮</button>
    <span class="ver" id="sidebar-ver">v1.2.0</span>
  </div>
</aside>

<!-- ════════ Main ════════ -->
<main class="main">

  <!-- ─── Overview ─── -->
  <section class="panel active" id="p-overview">
    <header class="page-header">
      <div>
        <div><span class="page-num">№ 01</span><span class="anno" data-zh="今天怎么样?" data-en="How are we today?">今天怎么样?</span></div>
        <h1 class="page-title"><em class="em">Dash</em>board</h1>
        <div class="page-subtitle" data-zh="系统运行状态与关键指标" data-en="Runtime state &amp; key metrics">系统运行状态与关键指标</div>
      </div>
      <div class="page-meta">
        <div><span data-zh="时间" data-en="now">now</span> · <b id="hdr-now">—</b></div>
        <div class="stamp" id="hdr-stamp">DRAFT</div>
        <div class="btn-group" style="margin-top:10px;justify-content:flex-end">
          <button class="btn btn-ghost btn-sm" id="btn-check-update" onclick="App.checkUpdate()" data-zh="检查更新" data-en="Check update">检查更新</button>
          <button class="btn btn-primary btn-sm hidden" id="btn-apply-update" onclick="App.applyUpdate()" data-zh="一键更新" data-en="Update now">一键更新</button>
        </div>
      </div>
    </header>

    <div id="update-status" class="frame hidden" style="padding:14px;margin-bottom:18px"></div>

    <div class="metrics-grid" id="overview-cards"></div>

    <div class="section">
      <div class="section-head">
        <div>
          <div class="section-title"><span class="num">i</span><span data-zh="语言服务器" data-en="Language Server">语言服务器</span></div>
          <div class="section-desc" data-zh="多实例 Cascade language-server 池状态" data-en="Cascade language-server pool status">多实例 Cascade language-server 池状态</div>
        </div>
        <div class="section-actions">
          <button class="btn btn-ghost btn-sm" onclick="App.restartLs()" data-zh="重启" data-en="Restart"><svg class="ic" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:inline-block;vertical-align:-2px"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg> 重启</button>
        </div>
      </div>
      <div class="frame tight"><div class="frame-body" id="ls-status"></div></div>
    </div>
  </section>

  <!-- ─── Stats ─── -->
  <section class="panel" id="p-stats">
    <header class="page-header">
      <div>
        <div><span class="page-num">№ 02</span><span class="anno" data-zh="数据告诉我们什么?" data-en="What's the data say?">数据告诉我们什么?</span></div>
        <h1 class="page-title"><em class="em">Stat</em>istics</h1>
        <div class="page-subtitle" data-zh="请求量 · 成功率 · 模型分布" data-en="Volume · success rate · model split">请求量 · 成功率 · 模型分布</div>
      </div>
      <div class="page-meta">
        <span class="sticker" data-zh="实时" data-en="LIVE">LIVE</span>
        <button class="btn btn-ghost btn-sm" onclick="App.resetStats()" data-zh="清空统计" data-en="Reset stats" style="margin-left:10px">清空统计</button>
      </div>
    </header>

    <div class="metrics-grid" id="stats-cards"></div>

    <div class="chart-card">
      <div class="section-head" style="border:none;margin:0 0 4px;padding:0">
        <div>
          <div class="section-title"><span class="num">~</span><span data-zh="请求量趋势" data-en="Request volume">请求量趋势</span></div>
          <div class="section-desc" data-zh="按小时聚合,最近 24h" data-en="Hourly buckets, last 24h">按小时聚合,最近 24h</div>
        </div>
        <div class="segmented" id="stats-range">
          <label><input type="radio" name="range" value="24h" checked onchange="App.loadStats()">24h</label>
          <label><input type="radio" name="range" value="7d" onchange="App.loadStats()">7d</label>
          <label><input type="radio" name="range" value="30d" onchange="App.loadStats()">30d</label>
        </div>
      </div>
      <div class="bars-row" id="stats-bars"></div>
      <div class="chart-legend">
        <div class="chart-legend-item"><span class="chart-legend-swatch" style="background:var(--ink)"></span>requests</div>
        <div class="chart-legend-item"><span class="chart-legend-swatch" style="background:var(--error)"></span>errors</div>
      </div>
    </div>

    <div class="section">
      <div class="section-head">
        <div>
          <div class="section-title"><span class="num">M</span><span data-zh="模型使用统计" data-en="Model breakdown">模型使用统计</span></div>
          <div class="section-desc" data-zh="按模型聚合 · 请求量 · 成功率 · 平均耗时与 p50 / p95" data-en="Per-model · volume · success · latency p50 / p95">按模型聚合 · 请求量 · 成功率 · 平均耗时与 p50 / p95</div>
        </div>
      </div>
      <div class="frame tight">
        <div class="frame-body tight">
          <div class="table-wrap">
            <table id="model-stats-table">
              <thead><tr><th>Model</th><th>Requests</th><th>Success</th><th>Errors</th><th>Rate</th><th>Avg</th><th>p50</th><th>p95</th></tr></thead>
              <tbody></tbody>
            </table>
          </div>
        </div>
      </div>
    </div>

    <div class="section">
      <div class="section-head">
        <div>
          <div class="section-title"><span class="num">A</span><span data-zh="账号维度统计" data-en="Per-account stats">账号维度统计</span></div>
          <div class="section-desc" data-zh="每个账号的请求量与成功率" data-en="Volume &amp; success rate per account">每个账号的请求量与成功率</div>
        </div>
      </div>
      <div class="frame tight">
        <div class="frame-body tight">
          <div class="table-wrap">
            <table id="account-stats-table">
              <thead><tr><th>Account</th><th>Requests</th><th>Success</th><th>Errors</th><th>Rate</th></tr></thead>
              <tbody></tbody>
            </table>
          </div>
          <div id="account-stats-note" class="text-mute" style="margin-top:8px;font-size:12px"></div>
        </div>
      </div>
    </div>
  </section>

  <!-- ─── Windsurf Login ─── -->
  <section class="panel" id="p-windsurf-login">
    <header class="page-header">
      <div>
        <div><span class="page-num">№ 03</span><span class="anno" data-zh="先取个号?" data-en="Grab a key?">先取个号?</span></div>
        <h1 class="page-title"><em class="em">Acquire</em> a Key</h1>
        <div class="page-subtitle" data-zh="Google · GitHub · 邮箱密码 三种方式" data-en="Google · GitHub · email/password">Google · GitHub · 邮箱密码 三种方式</div>
      </div>
    </header>

    <div class="section">
      <div class="section-head">
        <div>
          <div class="section-title"><span class="num">1</span><span data-zh="快捷登录(推荐)" data-en="OAuth (recommended)">快捷登录(推荐)</span></div>
          <div class="section-desc" data-zh="Google / GitHub 直接登录 Windsurf,无需密码" data-en="Sign in with Google or GitHub — no password.">Google / GitHub 直接登录 Windsurf,无需密码</div>
        </div>
      </div>
      <div class="frame">
        <div style="display:flex;gap:14px;flex-wrap:wrap">
          <button class="btn" onclick="App.oauthLogin('google')" id="oauth-google-btn" style="padding:10px 22px;font-weight:600;gap:10px">
            <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>
            Google
          </button>
          <button class="btn" onclick="App.oauthLogin('github')" id="oauth-github-btn" style="padding:10px 22px;font-weight:600;gap:10px">
            <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.58A11.99 11.99 0 0024 12C24 5.37 18.63 0 12 0z"/></svg>
            GitHub
          </button>
        </div>
        <div id="oauth-status" style="margin-top:14px;font-family:'Caveat',cursive;font-size:18px;color:var(--ink-mute)"></div>
      </div>
    </div>

    <div class="section">
      <div class="section-head">
        <div>
          <div class="section-title"><span class="num">2</span><span data-zh="邮箱密码登录" data-en="Email + password (registered)">邮箱密码登录</span></div>
          <div class="section-desc" data-zh="仅限邮箱+密码注册的账号 第三方登录请用上面的按钮;支持单个登录或按「邮箱 密码」格式批量导入" data-en="Email/password registered accounts only — for OAuth use the buttons above. Single login or batch import with «email password» per line.">仅限邮箱+密码注册的账号 第三方登录请用上面的按钮;支持单个登录或按「邮箱 密码」格式批量导入</div>
        </div>
      </div>
      <div class="frame">
        <div class="field-row">
          <div class="field"><label class="field-label">Email</label><input class="input" id="wl-email" placeholder="you@example.com"></div>
          <div class="field"><label class="field-label">Password</label><input class="input" id="wl-password" type="password" placeholder="••••••••"></div>
        </div>
        <div class="field-row-actions">
          <label class="checkbox"><input type="checkbox" id="wl-auto-add" checked><span class="box-mark"></span><span data-zh="登录成功后自动加入账号池" data-en="Auto-add to pool on success">登录成功后自动加入账号池</span></label>
          <div style="flex:1"></div>
          <button class="btn btn-primary" id="wl-btn" onclick="App.windsurfLogin()" data-zh="登录取号" data-en="Acquire">登录取号 <svg class="ic" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:inline-block;vertical-align:-2px"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg></button>
        </div>
      </div>
    </div>

    <div class="section">
      <div class="section-head">
        <div>
          <div class="section-title"><span class="num">3</span><span data-zh="批量导入" data-en="Batch import">批量导入</span></div>
          <div class="section-desc" data-zh="每行一组:email password [proxy]。proxy 可省略;格式 type://[user:pass@]host:port,例 socks5://127.0.0.1:7890 或 http://u:p@1.2.3.4:8080。空白行忽略。" data-en="One per line: email password [proxy]. Proxy is optional; format type://[user:pass@]host:port (e.g. socks5://127.0.0.1:7890 or http://u:p@1.2.3.4:8080). Blank lines ignored.">每行一组:email password [proxy]。proxy 可省略;格式 type://[user:pass@]host:port,例 socks5://127.0.0.1:7890 或 http://u:p@1.2.3.4:8080。空白行忽略。</div>
        </div>
        <button class="btn btn-ghost btn-sm" onclick="App.pasteWindsurfBatch()"><svg class="ic" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:inline-block;vertical-align:-2px"><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> paste</button>
      </div>
      <div class="frame">
        <textarea class="textarea input" id="wl-batch-input" rows="6" style="font-family:'JetBrains Mono',monospace;resize:vertical;width:100%" placeholder="alice@example.com  hunter2&#10;bob@example.com  letme1n"></textarea>
        <div class="field-row-actions">
          <button class="btn btn-primary" id="wl-batch-btn" onclick="App.windsurfLoginBatch()" data-zh="批量导入" data-en="Batch import">批量导入</button>
        </div>
      </div>
    </div>

    <div id="wl-result" class="frame hidden" style="margin-bottom:24px"></div>

    <div class="section">
      <div class="section-head">
        <div><div class="section-title"><span class="num">H</span><span data-zh="登录历史" data-en="Login history">登录历史</span></div></div>
      </div>
      <div class="frame tight"><div class="frame-body tight">
        <div class="table-wrap">
          <table id="wl-history-table">
            <thead><tr><th>Time</th><th>Email</th><th>Status</th><th>Proxy</th><th></th></tr></thead>
            <tbody></tbody>
          </table>
        </div>
      </div></div>
    </div>

    <div class="section" id="wl-proxy-section">
      <div class="section-head">
        <div>
          <div class="section-title"><span class="num">P</span><span data-zh="登录代理(可选)" data-en="Login proxy (optional)">登录代理(可选)</span></div>
          <div class="section-desc" data-zh="为本次登录指定代理;留空则使用全局代理设置。代理生效后账号的后续聊天请求也会经此代理出站" data-en="Specify a proxy for this login; leave blank to use the global proxy. Once set, the account's later chat requests will egress through this proxy too.">为本次登录指定代理;留空则使用全局代理设置。代理生效后账号的后续聊天请求也会经此代理出站</div>
        </div>
      </div>
      <div class="frame">
        <div class="field-row">
          <div class="field" style="max-width:120px"><label class="field-label">Type</label><select class="select" id="wl-proxy-type"><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">Host</label><input class="input" id="wl-proxy-host" placeholder="留空 = 使用全局"></div>
          <div class="field" style="max-width:110px"><label class="field-label">Port</label><input class="input" id="wl-proxy-port" placeholder="8080" type="number"></div>
          <div class="field"><label class="field-label">User</label><input class="input" id="wl-proxy-user" placeholder="optional"></div>
          <div class="field"><label class="field-label">Pass</label><input class="input" id="wl-proxy-pass" type="password" placeholder="optional"></div>
        </div>
        <div class="field-row-actions" style="margin-top:8px">
          <button class="btn btn-ghost btn-sm" id="wl-proxy-test-btn" onclick="App.testLoginProxy()" data-zh="测试代理" data-en="Test proxy">测试代理</button>
          <span id="wl-proxy-test-result" class="text-sm text-mute" style="margin-left:10px"></span>
        </div>
      </div>
    </div>
  </section>

  <!-- ─── Accounts ─── -->
  <section class="panel" id="p-accounts">
    <header class="page-header">
      <div>
        <div><span class="page-num">№ 04</span><span class="anno" data-zh="谁在工作?" data-en="Who's working?">谁在工作?</span></div>
        <h1 class="page-title"><em class="em">Account</em> Manager</h1>
        <div class="page-subtitle" data-zh="点击 <svg class="ic" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:inline-block;vertical-align:-2px"><polyline points="9 18 15 12 9 6"/></svg> 展开看每个账号的额度 / 计划 / 模型清单" data-en="Click <svg class="ic" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:inline-block;vertical-align:-2px"><polyline points="9 18 15 12 9 6"/></svg> to expand quota · plan · models">点击 <svg class="ic" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:inline-block;vertical-align:-2px"><polyline points="9 18 15 12 9 6"/></svg> 展开看每个账号的额度 / 计划 / 模型清单</div>
      </div>
      <div class="page-meta">
        <div class="btn-group" style="justify-content:flex-end">
          <button class="btn btn-ghost btn-sm" onclick="App.probeAll()"><svg class="ic" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:inline-block;vertical-align:-2px"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg> probe all</button>
          <button class="btn btn-ghost btn-sm" onclick="App.refreshAllCredits()"><svg class="ic" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:inline-block;vertical-align:-2px"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg> refresh credits</button>
        </div>
      </div>
    </header>

    <div id="ls-pool-card"></div>

    <!-- v2.0.57 Fix 5 — drought mode banner. Hidden until loadDrought()
         flips it. Hand-drawn warning frame to fit the sketch theme. -->
    <div id="drought-banner" style="display:none;margin:10px 0 16px;padding:14px 18px;border:1.5px dashed #d97706;background:#fff7e6;border-radius:8px;color:#874d00">
      <strong data-zh="配额预警 ·" data-en="Quota drought ·">配额预警 ·</strong>
      <span id="drought-message" data-zh="所有账号本周配额都低于阈值,建议补账号或等待重置" data-en="every account's weekly quota is under the threshold; add accounts or wait for reset">所有账号本周配额都低于阈值,建议补账号或等待重置</span>
      <span id="drought-fail-open-message" style="display:none" data-zh="当前可用账号中没有免费模型,因此低配额保护不会阻止付费模型;账号本身不支持的模型仍会被隐藏和拦截" data-en="no free model is available for the active accounts, so low-quota protection will not block paid models; models unsupported by an account are still hidden and blocked">当前可用账号中没有免费模型,因此低配额保护不会阻止付费模型;账号本身不支持的模型仍会被隐藏和拦截</span>
      <span id="drought-detail" class="text-mute" style="margin-left:8px;font-size:12px"></span>
    </div>

    <div class="section">
      <div class="section-head">
        <div><div class="section-title"><span class="num">+</span><span data-zh="添加账号" data-en="Add an account">添加账号</span></div>
          <div class="section-desc" data-zh="粘贴 API Key 或 Token(来自 windsurf.com/show-auth-token)" data-en="Paste an API Key or auth token (from windsurf.com/show-auth-token)">粘贴 API Key 或 Token</div>
        </div>
      </div>
      <div class="frame">
        <div class="field-row">
          <div class="field"><label class="field-label">Type</label><select class="select" id="acc-type"><option value="token" selected>Token</option><option value="api_key">API Key</option></select></div>
          <div class="field" style="flex:3"><label class="field-label">Key / Token</label><input class="input" id="acc-key" placeholder="paste here"></div>
          <div class="field"><label class="field-label">Label (optional)</label><input class="input" id="acc-label" placeholder="my-laptop"></div>
        </div>
        <div class="field-row-actions">
          <button class="btn btn-primary" onclick="App.addAccount()" data-zh="加入账号池" data-en="Add to pool">+ 加入账号池</button>
        </div>
      </div>
    </div>

    <div class="section">
      <div class="section-head">
        <div><div class="section-title"><span class="num">▤</span><span data-zh="账号池" data-en="Account pool">账号池</span></div></div>
      </div>
      <div class="frame tight"><div class="frame-body tight">
        <div class="table-wrap">
          <table id="accounts-table">
            <thead><tr>
              <th>ID</th><th>Email</th><th>Tier</th><th>RPM</th><th>Credits</th><th>Models</th><th>Status</th><th>Errs</th><th>Last used</th><th>Key</th><th></th>
            </tr></thead>
            <tbody></tbody>
          </table>
        </div>
      </div></div>
    </div>
  </section>

  <!-- ─── Bans ─── -->
  <section class="panel" id="p-bans">
    <header class="page-header">
      <div>
        <div><span class="page-num">№ 05</span><span class="anno" data-zh="出问题了吗?" data-en="Anything broken?">出问题了吗?</span></div>
        <h1 class="page-title"><em class="em">Anom</em>alies</h1>
        <div class="page-subtitle" data-zh="错误账号与异常状态" data-en="Errored accounts &amp; abnormal states">错误账号与异常状态</div>
      </div>
    </header>
    <div class="metrics-grid" id="ban-cards"></div>
    <div class="section">
      <div class="section-head">
        <div><div class="section-title"><span class="num">!</span><span data-zh="账号健康状况" data-en="Account health">账号健康状况</span></div></div>
      </div>
      <div class="frame tight"><div class="frame-body tight">
        <div class="table-wrap">
          <table id="ban-table">
            <thead><tr><th>Account</th><th>Status</th><th>Errors</th><th>Last used</th><th>Action</th></tr></thead>
            <tbody></tbody>
          </table>
        </div>
      </div></div>
    </div>
  </section>

  <!-- ─── Models ─── -->
  <section class="panel" id="p-models">
    <header class="page-header">
      <div>
        <div><span class="page-num">№ 06</span><span class="anno" data-zh="哪些可以叫?" data-en="Which models?">哪些可以叫?</span></div>
        <h1 class="page-title"><em class="em">Model</em> Control</h1>
        <div class="page-subtitle" data-zh="允许 / 禁用名单 + 模型目录" data-en="Allow / block list &amp; model catalog">允许 / 禁用名单 + 模型目录</div>
      </div>
    </header>

    <div class="section">
      <div class="section-head">
        <div><div class="section-title"><span class="num">∀</span><span data-zh="访问模式" data-en="Access mode">访问模式</span></div></div>
      </div>
      <div class="frame">
        <div class="segmented" style="margin-bottom:16px">
          <label><input type="radio" name="model-mode" value="all" onchange="App.setModelMode('all')" checked>全部</label>
          <label><input type="radio" name="model-mode" value="allowlist" onchange="App.setModelMode('allowlist')">白名单</label>
          <label><input type="radio" name="model-mode" value="blocklist" onchange="App.setModelMode('blocklist')">黑名单</label>
        </div>
        <div id="model-list-section" class="hidden">
          <div class="anno" id="model-list-title">当前清单</div>
          <div id="model-list-current" style="margin:6px 0 16px"></div>
          <div class="field-row" style="margin-bottom:12px">
            <div class="field"><label class="field-label">Search</label><input class="input" id="model-search" placeholder="qwen, claude, gpt …" oninput="App.filterModels()"></div>
            <div class="field"><label class="field-label">Provider</label><select class="select" id="model-provider-filter" onchange="App.filterModels()"><option value="">All providers</option></select></div>
          </div>
          <div id="model-chips-container" style="max-height:50vh;overflow:auto;padding-right:8px"></div>
          <div class="field-hint" id="model-list-hint" style="margin-top:8px"></div>
        </div>
      </div>
    </div>
  </section>

  <!-- ─── Proxy ─── -->
  <section class="panel" id="p-proxy">
    <header class="page-header">
      <div>
        <div><span class="page-num">№ 07</span><span class="anno" data-zh="走哪条路?" data-en="Which route?">走哪条路?</span></div>
        <h1 class="page-title"><em class="em">Proxy</em> Routing</h1>
        <div class="page-subtitle" data-zh="全局代理 + 每账号代理" data-en="Global proxy &amp; per-account overrides">全局代理 + 每账号代理</div>
      </div>
    </header>
    <div class="section">
      <div class="section-head">
        <div><div class="section-title"><span class="num">G</span><span data-zh="全局代理" data-en="Global proxy">全局代理</span></div>
          <div class="section-desc" id="proxy-current">未配置</div>
        </div>
      </div>
      <div class="frame">
        <div class="field-row">
          <div class="field"><label class="field-label">Type</label><select class="select" id="proxy-type"><option>http</option><option>https</option><option>socks5</option></select></div>
          <div class="field"><label class="field-label">Host</label><input class="input" id="proxy-host" placeholder="proxy.host"></div>
          <div class="field"><label class="field-label">Port</label><input class="input" id="proxy-port" placeholder="8080" type="number"></div>
          <div class="field"><label class="field-label">Username</label><input class="input" id="proxy-user" placeholder="optional"></div>
          <div class="field"><label class="field-label">Password</label><input class="input" id="proxy-pass" type="password" placeholder="optional"></div>
        </div>
        <div class="field-row-actions">
          <button class="btn btn-primary" onclick="App.saveGlobalProxy()" data-zh="保存" data-en="Save">保存</button>
          <button class="btn btn-ghost" onclick="App.clearGlobalProxy()" data-zh="清除" data-en="Clear">清除</button>
        </div>
      </div>
    </div>
    <div class="section">
      <div class="section-head">
        <div><div class="section-title"><span class="num">a</span><span data-zh="每账号代理" data-en="Per-account">每账号代理</span></div></div>
      </div>
      <div class="frame tight"><div class="frame-body tight">
        <div class="table-wrap">
          <table id="proxy-accounts-table">
            <thead><tr><th>Account</th><th>Proxy</th><th>Action</th></tr></thead>
            <tbody></tbody>
          </table>
        </div>
      </div></div>
    </div>
  </section>

  <!-- ─── Logs ─── -->
  <section class="panel" id="p-logs">
    <header class="page-header">
      <div>
        <div><span class="page-num">№ 08</span><span class="anno" data-zh="刚才发生了什么?" data-en="What happened?">刚才发生了什么?</span></div>
        <h1 class="page-title"><em class="em">Run</em> Logs</h1>
        <div class="page-subtitle" data-zh="实时流式日志 · SSE" data-en="Live stream · SSE">实时流式日志 · SSE</div>
      </div>
    </header>
    <div class="log-controls">
      <select class="select" id="log-level" onchange="App.applyLogFilter()">
        <option value="">all levels</option>
        <option value="debug">debug</option>
        <option value="info">info</option>
        <option value="warn">warn</option>
        <option value="error">error</option>
      </select>
      <input class="input search" id="log-search" placeholder="search …" oninput="App.applyLogFilter()">
      <label class="checkbox"><input type="checkbox" id="log-tail" checked><span class="box-mark"></span>auto-scroll</label>
      <button class="btn btn-ghost btn-sm" onclick="App.clearLogs()">clear</button>
    </div>
    <div class="log-container" id="log-container"></div>
  </section>

  <!-- ─── Experimental ─── -->
  <section class="panel" id="p-experimental">
    <header class="page-header">
      <div>
        <div><span class="page-num">№ 09</span><span class="anno" data-zh="不一定稳哦" data-en="Beware: unstable">不一定稳哦</span></div>
        <h1 class="page-title"><em class="em">Exp</em>erimental</h1>
        <div class="page-subtitle" data-zh="尚未稳定 — 出问题随时关闭" data-en="Toggle off any time">尚未稳定 — 出问题随时关闭</div>
      </div>
    </header>
    <div class="section">
      <div class="section-head">
        <div>
          <div class="section-title"><span class="num">~</span><span data-zh="Cascade 对话复用" data-en="Cascade reuse">Cascade 对话复用</span></div>
          <div class="section-desc" data-zh="多轮对话复用 cascade_id,让服务端维持上下文缓存。" data-en="Reuse cascade_id across turns to hit server-side context cache.">多轮对话复用 cascade_id,让服务端维持上下文缓存。</div>
        </div>
      </div>
      <div class="frame">
        <div style="display:flex;align-items:center;gap:14px;margin-bottom:18px">
          <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-zh="启用 Cascade 对话复用" data-en="Enable cascade reuse">启用 Cascade 对话复用</div>
            <div class="text-sm text-mute" data-zh="默认关闭。开启后立即生效。" data-en="Off by default. Takes effect immediately.">默认关闭。开启后立即生效。</div>
          </div>
        </div>
        <!-- v2.0.58 — drought-mode premium gate toggle -->
        <div style="display:flex;align-items:center;gap:14px;margin-bottom:18px;padding-top:14px;border-top:1px dashed var(--rule-faint)">
          <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-zh="drought 时屏蔽 premium 模型" data-en="Block premium models during drought">drought 时屏蔽 premium 模型</div>
            <div class="text-sm text-mute" data-zh="所有账号本周配额都 < 5% 时对 premium 模型请求返 503,避免烧最后配额。免费层模型(gemini-2.5-flash 等)保持可用。" data-en="Refuse premium models with 503 when every account is below 5% weekly so the last quota isn't burned on a 429. Free-tier models still go through.">所有账号本周配额都 < 5% 时对 premium 模型请求返 503,避免烧最后配额。</div>
          </div>
        </div>
        <div class="metrics-grid" id="exp-pool-cards" style="margin:0;border:1.5px solid var(--rule-faint)"></div>
        <div class="field-row-actions">
          <button class="btn btn-ghost btn-sm" onclick="App.clearConversationPool()">clear pool</button>
          <button class="btn btn-ghost btn-sm" onclick="App.loadExperimental()"><svg class="ic" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:inline-block;vertical-align:-2px"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg> refresh</button>
        </div>
      </div>
    </div>

    <div class="section" style="margin-top:24px">
      <div class="section-head">
        <div>
          <div class="section-title"><span class="num">~</span><span data-zh="系统提示词" data-en="System prompts">系统提示词</span></div>
          <div class="section-desc" data-zh="工具注入、对话模式等内置提示词。改完点 save;reset 恢复默认。" data-en="Built-in prompt templates. Edit and save; reset to restore defaults.">工具注入、对话模式等内置提示词。改完点 save;reset 恢复默认。</div>
        </div>
      </div>
      <div class="frame">
        <div id="system-prompts-editor" style="display:grid;gap:14px"></div>
      </div>
    </div>

    <div class="section" style="margin-top:24px">
      <div class="section-head">
        <div>
          <div class="section-title"><span class="num">~</span><span data-zh="凭证管理" data-en="Credentials">凭证管理</span></div>
          <div class="section-desc" data-zh="运行时改 API_KEY 和 Dashboard 密码,写入 runtime-config.json 立即生效,不重启容器。改完之后下一次请求要用新值。" data-en="Rotate API_KEY and dashboard password from this panel; persisted to runtime-config.json. New value takes effect on the next request.">运行时改 API_KEY 和 Dashboard 密码,写入 runtime-config.json 立即生效,不重启容器。改完之后下一次请求要用新值。</div>
        </div>
      </div>
      <div class="frame" style="display:grid;gap:14px">
        <div style="display:grid;gap:8px">
          <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
            <strong>API_KEY</strong>
            <span class="text-mute" id="credentials-apikey-source"></span>
            <span 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" placeholder="新 API_KEY(≥ 8 字符;留空清除运行时覆盖)" style="flex:1;min-width:240px">
            <button class="btn btn-ghost btn-sm" onclick="App.toggleApiKeyVisibility()" data-zh="显隐" data-en="show/hide">显隐</button>
            <button class="btn btn-sm" onclick="App.saveCredential('apiKey')" data-zh="保存" data-en="save">保存</button>
          </div>
          <div class="text-sm text-mute" data-zh="改完之后所有 chat 客户端要换成新 KEY;旧 KEY 立即失效。" data-en="All chat clients must use the new KEY after save; the old KEY stops working immediately.">改完之后所有 chat 客户端要换成新 KEY;旧 KEY 立即失效。</div>
        </div>
        <div style="display:grid;gap:8px">
          <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
            <strong data-zh="Dashboard 密码" data-en="Dashboard password">Dashboard 密码</strong>
            <span class="text-mute" id="credentials-dashboardpw-source"></span>
            <span 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" placeholder="新 Dashboard 密码(≥ 8 字符;留空清除运行时覆盖)" style="flex:1;min-width:240px">
            <button class="btn btn-ghost btn-sm" onclick="App.toggleDashboardPwVisibility()" data-zh="显隐" data-en="show/hide">显隐</button>
            <button class="btn btn-sm" onclick="App.saveCredential('dashboardPassword')" data-zh="保存" data-en="save">保存</button>
          </div>
          <div class="text-sm text-mute" data-zh="scrypt 派生哈希存盘;改完会自动登出,需用新密码重登。" data-en="scrypt-derived; you'll be signed out and need to re-login with the new password.">scrypt 派生哈希存盘;改完会自动登出,需用新密码重登。</div>
        </div>
        <div class="text-sm text-mute" data-zh="同一 IP 连续 5 次 dashboard 登录失败会被锁 30 分钟(v2.0.56 起)。" data-en="Same IP gets locked for 30 minutes after 5 failed dashboard logins (since v2.0.56).">同一 IP 连续 5 次 dashboard 登录失败会被锁 30 分钟(v2.0.56 起)。</div>
      </div>
    </div>

    <div class="section" style="margin-top:24px">
      <div class="section-head">
        <div>
          <div class="section-title"><span class="num">~</span><span data-zh="界面风格" data-en="Console skin">界面风格</span></div>
          <div class="section-desc" data-zh="切回默认现代风皮肤。选择后立即写 cookie 并刷新。" data-en="Switch back to the default modern skin. Selecting writes a cookie and reloads.">切回默认现代风皮肤。选择后立即写 cookie 并刷新。</div>
        </div>
      </div>
      <div class="frame">
        <div style="display:flex;align-items:center;gap:14px">
          <select id="skin-select" class="select" style="max-width:240px" onchange="App.switchSkin(this.value)">
            <option value="modern" data-zh="默认现代风" data-en="Default (modern)">默认现代风</option>
            <option value="sketch" data-zh="手绘草稿风(当前)" data-en="Sketch (current)">手绘草稿风(当前)</option>
          </select>
          <span class="text-sm text-mute" data-zh="当前为草稿风" data-en="Currently sketch skin">当前为草稿风</span>
        </div>
      </div>
    </div>
  </section>

  <!-- ─── Credits ─── -->
  <section class="panel" id="p-credits">
    <header class="page-header">
      <div>
        <div><span class="page-num">№ 10</span><span class="anno" data-zh="谢谢你们" data-en="Thank you">谢谢你们</span></div>
        <h1 class="page-title"><em class="em">Cre</em>dits</h1>
        <div class="page-subtitle" data-zh="为这个项目提交 PR / 审计代码 / 修复 bug 的朋友们" data-en="People who shipped PRs · audited code · fixed bugs">为这个项目提交 PR / 审计代码 / 修复 bug 的朋友们</div>
      </div>
    </header>
    <div class="section">
      <div class="frame" id="credits-body">
        <div class="text-mute italic">loading …</div>
      </div>
    </div>
    <div class="section">
      <div class="section-head">
        <div><div class="section-title"><span class="num"><svg class="ic" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:inline-block;vertical-align:-2px"><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><span data-zh="想加入这份名单?" data-en="Want to join this list?">想加入这份名单?</span></div></div>
      </div>
      <div class="frame">
        <p>欢迎到 GitHub 提 issue 或 PR — 修 bug、加模型、改 UI、发现安全问题都会被记录在这里。</p>
        <div class="field-row-actions">
          <a class="btn" target="_blank" rel="noopener" href="https://github.com/dwgx/WindsurfAPI/issues">提 issue</a>
          <a class="btn" target="_blank" rel="noopener" href="https://github.com/dwgx/WindsurfAPI/pulls">提 PR</a>
        </div>
      </div>
    </div>
  </section>
</main>

<!-- Login overlay -->
<div class="login-overlay hidden" id="login-overlay">
  <div class="login-box">
    <div class="login-mark">W<span class="em">&amp;</span>API</div>
    <div class="login-tag">a console for the curious</div>
    <input type="password" id="login-password" class="input" placeholder="dashboard password" onkeydown="if(event.key==='Enter')App.login()">
    <button class="btn btn-primary" id="login-btn" onclick="App.login()"><svg class="ic" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:inline-block;vertical-align:-2px"><polyline points="15 10 20 15 15 20"/><path d="M4 4v7a4 4 0 0 0 4 4h12"/></svg> <span id="login-btn-label">enter</span></button>
    <div id="login-error" style="margin-top:12px;color:var(--error);font-family:'Caveat',cursive;font-size:18px;min-height:22px"></div>
  </div>
</div>

<div id="modal-container"></div>
<div class="toast-stack" id="toast-stack"></div>

<script>
/* ──────────────────────────────────────────────
   Tiny i18n — element-attribute-driven
   ────────────────────────────────────────────── */
const I18n = {
  locale: localStorage.getItem('lang') || 'zh',
  apply() {
    document.querySelectorAll('[data-zh]').forEach(el => {
      const v = this.locale === 'en' ? el.dataset.en : el.dataset.zh;
      if (v != null) el.textContent = v;
    });
    document.documentElement.lang = this.locale === 'en' ? 'en' : 'zh-CN';
    const ind = document.getElementById('lang-ind');
    if (ind) ind.textContent = this.locale === 'en' ? 'EN' : '中';
  },
  setLocale(l) { this.locale = l; localStorage.setItem('lang', l); this.apply(); },
};

/* ──────────────────────────────────────────────
   App
   ────────────────────────────────────────────── */
const App = {
  password: localStorage.getItem('dp') || '',
  sseConn: null,
  logEntries: [],
  pollers: {},
  allModels: [],
  modelAccessConfig: { mode: 'all', list: [] },
  loginHistory: JSON.parse(localStorage.getItem('wl_history') || '[]'),
  _expandedAccounts: new Set(),
  _modelsCatalog: null,

  /* ── theme ── */
  toggleTheme() {
    const cur = document.documentElement.dataset.theme;
    const next = cur === 'light' ? 'dark' : 'light';
    document.documentElement.dataset.theme = next;
    localStorage.setItem('theme', next);
    document.getElementById('theme-ind').innerHTML = next === 'light' ? `<svg class="ic" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:inline-block;vertical-align:-2px"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>` : `<svg class="ic" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:inline-block;vertical-align:-2px"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>`;
  },
  toggleLang() { I18n.setLocale(I18n.locale === 'zh' ? 'en' : 'zh'); },
  /* ── skin (sketch ↔ default modern UI) ── */
  switchSkin(skin) {
    const next = skin === 'sketch' ? 'sketch' : 'modern';
    const oneYear = 365 * 24 * 3600;
    document.cookie = `dashboard_skin=${next}; path=/; max-age=${oneYear}; samesite=lax`;
    location.reload();
  },

  /* ── boot ── */
  async init() {
    const t = localStorage.getItem('theme');
    if (t) {
      document.documentElement.dataset.theme = t;
      const ind = document.getElementById('theme-ind');
      if (ind) ind.innerHTML = t === 'light' ? `<svg class="ic" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:inline-block;vertical-align:-2px"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>` : `<svg class="ic" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:inline-block;vertical-align:-2px"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>`;
    }
    I18n.apply();
    setInterval(() => {
      const el = document.getElementById('hdr-now');
      if (el) el.textContent = new Date().toLocaleTimeString();
    }, 1000);
    document.getElementById('hdr-now').textContent = new Date().toLocaleTimeString();

    const auth = await this.api('GET', '/auth');
    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); };
    });
    const hash = location.hash.slice(1);
    if (hash) this.navigate(hash); else this.loadOverview();
  },

  async login() {
    const pw = document.getElementById('login-password').value;
    const btn = document.getElementById('login-btn');
    const lbl = document.getElementById('login-btn-label');
    const errEl = document.getElementById('login-error');
    const en = I18n.locale === 'en';
    if (errEl) errEl.textContent = '';
    if (!pw) {
      if (errEl) errEl.textContent = en ? 'password required' : '请输入密码';
      return;
    }
    if (btn) btn.disabled = true;
    if (lbl) { lbl.dataset.orig ||= lbl.textContent; lbl.textContent = en ? '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 = en
          ? 'backend has no password configured — set DASHBOARD_PASSWORD or API_KEY env and restart'
          : '后端未配置密码:请在服务端设置 DASHBOARD_PASSWORD 或 API_KEY 环境变量并重启';
        return;
      }
      if (data.required && !data.valid) {
        if (errEl) errEl.textContent = en ? 'wrong password' : '密码不正确';
        document.getElementById('login-password')?.focus();
        return;
      }
      this.password = pw;
      localStorage.setItem('dp', pw);
      await this.init();
    } catch (e) {
      if (errEl) errEl.textContent = (en ? 'cannot reach server: ' : '无法连接服务器: ') + e.message;
    } finally {
      if (btn) btn.disabled = false;
      if (lbl && lbl.dataset.orig) lbl.textContent = lbl.dataset.orig;
    }
  },

  navigate(panel) {
    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');
    location.hash = panel;
    Object.values(this.pollers).forEach(clearInterval);
    this.pollers = {};
    if (this.sseConn) { this.sseConn.close(); this.sseConn = null; }
    const loaders = {
      overview: 'loadOverview', 'windsurf-login': 'loadWindsurfLogin',
      accounts: 'loadAccounts', models: 'loadModels', proxy: 'loadProxy',
      logs: 'loadLogs', stats: 'loadStats', bans: 'loadBans',
      experimental: 'loadExperimental', credits: 'loadCredits',
    };
    if (loaders[panel]) this[loaders[panel]]();
  },

  async api(method, path, body) {
    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);
      const data = await r.json().catch(() => ({}));
      // Forwarded upstream 401/429 is not dashboard auth failure (see index.html).
      const upstreamFailure = (r.status === 401 || r.status === 429)
        && (data?.httpStatus === r.status || data?.upstream === true);
      if (r.status === 401 && !upstreamFailure) {
        this.password = null;
        try { localStorage.removeItem('dp'); } catch {}
        Object.values(this.pollers).forEach(clearInterval);
        this.pollers = {};
        document.getElementById('login-overlay')?.classList.remove('hidden');
        const authErr = (data && data.error && (data.error.message || data.error)) || 'Unauthorized';
        return { success: false, error: authErr };
      }
      if (r.status === 429 && !upstreamFailure) {
        const lockErr = (data && data.error && (data.error.message || data.error)) || 'Too many failed attempts';
        return { success: false, error: lockErr };
      }
      // Force the failure envelope onto non-2xx bodies (mirrors index.html) so
      // the `success === false` loader guards stay reachable on 404/500 (#257).
      if (!r.ok) {
        const failed = (data && typeof data === 'object' && !Array.isArray(data)) ? { ...data } : {};
        failed.success = false;
        return failed;
      }
      return data;
    } catch (e) {
      this.toast(e.message, 'error');
      return { success: false, error: e.message || 'network_error' };
    }
  },

  poll(key, fn, ms) {
    if (this.pollers[key]) clearInterval(this.pollers[key]);
    this.pollers[key] = setInterval(() => { if (!document.hidden) fn(); }, ms);
  },

  esc(s) {
    if (s == null) return '';
    return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
  },
  systemPromptDomId(key) {
    return `sp-${encodeURIComponent(String(key == null ? '' : key)).replace(/%/g, '_')}`;
  },
  // For values interpolated into an inline onclick JS string
  // (onclick="App.f('...')"): the HTML attribute parser decodes &#39; back to
  // ' BEFORE the JS engine sees it, so HTML-escaping alone — the previous
  // escAttr helper composed esc() first, which consumed every ' into &#39;
  // and left its .replace(/'/g,"\\'") no target — handed the JS string a
  // live quote. Escape for the JS string instead: the output below contains
  // no raw ' " < > & at all, which makes it simultaneously safe inside the
  // double-quoted attribute. Same helper contract as index.html's escJsAttr.
  escJsAttr(s) {
    // U+2028 / U+2029 are expressed as escape sequences below — a raw one is
    // a JS line terminator and inside this regex literal would break the
    // whole source file.
    const map = {
      "\\": "\\\\", "'": "\\'", "\"": "\\\"",
      "<": "\\x3c", ">": "\\x3e", "&": "\\x26",
      "\r": "\\r", "\n": "\\n",
      "\u2028": "\\u2028", "\u2029": "\\u2029",
    };
    return String(s == null ? "" : s).replace(/[\\'"<>&\r\n\u2028\u2029]/g, c => map[c]);
  },

  /* ── toast ── */
  toast(msg, type = 'success') {
    const t = document.createElement('div');
    t.className = 'toast ' + type;
    const icon = type === 'success' ? `<svg class="ic" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:inline-block;vertical-align:-2px"><polyline points="20 6 9 17 4 12"/></svg>` : type === 'error' ? `<svg class="ic" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:inline-block;vertical-align:-2px"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>` : 'i';
    t.innerHTML = `<span class="toast-icon">${icon}</span><span>${this.esc(msg)}</span>`;
    document.getElementById('toast-stack').appendChild(t);
    setTimeout(() => {
      t.style.opacity = '0'; t.style.transform = 'translateX(20px)'; t.style.transition = 'all .2s';
      setTimeout(() => t.remove(), 200);
    }, 3000);
  },

  /* ── modal helpers ── */
  confirm(title, desc, opts = {}) {
    return new Promise(resolve => {
      const wrap = document.createElement('div');
      wrap.className = 'modal-overlay';
      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:680px;width:92vw"' : '';
      wrap.innerHTML = `
        <div class="modal"${widthStyle}>
          <div class="modal-header">
            <div class="modal-title">${opts.titleHtml ? title : this.esc(title)}</div>
            ${opts.html ? '' : (desc ? `<div class="modal-desc">${this.esc(desc)}</div>` : '')}
          </div>
          ${opts.html ? descHtml : ''}
          <div class="modal-footer">
            <button class="btn btn-ghost" data-act="cancel">${this.esc(opts.cancelText || 'Cancel')}</button>
            <button class="btn ${opts.danger ? 'btn-danger' : 'btn-primary'}" data-act="ok">${this.esc(opts.okText || 'OK')}</button>
          </div>
        </div>`;
      document.getElementById('modal-container').appendChild(wrap);
      const close = (v) => { wrap.remove(); resolve(v); };
      wrap.querySelector('[data-act=cancel]').onclick = () => close(false);
      wrap.querySelector('[data-act=ok]').onclick = () => close(true);
      wrap.onclick = (e) => { if (e.target === wrap) close(false); };
    });
  },

  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" style="margin-top:14px">
          <label class="field-label">${this.esc(f.label)}</label>
          ${f.type === 'select'
            ? `<select class="select" data-name="${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="${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">
          <div class="modal-header">
            <div class="modal-title">${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">Cancel</button>
            <button class="btn btn-primary" data-act="ok">OK</button>
          </div>
        </div>`;
      document.getElementById('modal-container').appendChild(wrap);
      const close = (v) => { wrap.remove(); resolve(v); };
      wrap.querySelector('[data-act=cancel]').onclick = () => close(null);
      wrap.querySelector('[data-act=ok]').onclick = () => {
        const values = {};
        wrap.querySelectorAll('[data-name]').forEach(el => values[el.dataset.name] = el.value);
        close(values);
      };
      wrap.onclick = (e) => { if (e.target === wrap) close(null); };
      setTimeout(() => wrap.querySelector('input, select')?.focus(), 100);
    });
  },

  /* ── format ── */
  fmtDuration(s) {
    if (!s) return '-';
    const d = Math.floor(s / 86400);
    const h = Math.floor((s % 86400) / 3600);
    const m = Math.floor((s % 3600) / 60);
    if (d) return `${d}d ${h}h`;
    if (h) return `${h}h ${m}m`;
    return `${m}m ${s % 60}s`;
  },

  /* ── update ── */
  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;
    status.classList.remove('hidden');
    status.innerHTML = '<span class="text-mute italic">checking …</span>';
    try {
      const r = await this.api('GET', '/self-update/check');
      if (!r.ok) {
        if (/not a git repository/i.test(r.error || '')) {
          status.innerHTML = `<div style="color:var(--warn);font-weight:600;margin-bottom:4px">Not a git repository</div><div class="text-sm text-mute">Self-update only works for git checkouts.</div>`;
          apply.classList.add('hidden');
          return;
        }
        throw new Error(r.error || 'API error');
      }
      if (r.behind) {
        status.innerHTML = `<div style="color:var(--accent);font-weight:600;margin-bottom:6px">Update available</div><div class="text-sm" style="display:grid;gap:3px"><div>local <code>${this.esc(r.commit)}</code> <span class="text-mute">${this.esc(r.localMessage)}</span></div><div>remote <code>${this.esc(r.remoteCommit)}</code> <span class="text-mute">${this.esc(r.remoteMessage || '')}</span></div></div>`;
        apply.classList.remove('hidden');
      } else {
        status.innerHTML = `<span style="color:var(--good)"><svg class="ic" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:inline-block;vertical-align:-2px"><polyline points="20 6 9 17 4 12"/></svg> up to date — ${this.esc(r.commit)} ${this.esc(r.localMessage)}</span>`;
        apply.classList.add('hidden');
      }
    } catch (err) {
      status.innerHTML = `<span style="color:var(--error)"><svg class="ic" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:inline-block;vertical-align:-2px"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg> ${this.esc(err.message)}</span>`;
    } finally { btn.disabled = false; }
  },

  async applyUpdate() {
    const ok = await this.confirm('Update & restart?', 'Pull latest then exec a restart.', { okText: 'Update & restart', 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-mute italic">pulling & restarting …</span>';
    try {
      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('Working dir dirty', `<div>${filesList}</div>`, { okText: 'Force', danger: true, html: true });
        if (!force) { status.innerHTML = '<span class="text-mute">cancelled</span>'; apply.disabled = false; return; }
        r = await this.api('POST', '/self-update', { forceReset: true });
      }
      if (!r.ok) throw new Error(r.error || 'update failed');
      if (r.changed) {
        status.innerHTML = `<div style="color:var(--good);font-weight:600;margin-bottom:6px"><svg class="ic" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:inline-block;vertical-align:-2px"><polyline points="20 6 9 17 4 12"/></svg> ${this.esc(r.before)} <svg class="ic" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:inline-block;vertical-align:-2px"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> ${this.esc(r.after)}</div><div class="text-sm text-mute">restarting…</div>`;
        setTimeout(() => location.reload(), 8000);
      } else {
        status.innerHTML = '<span class="text-mute">no update needed</span>';
        apply.classList.add('hidden');
      }
    } catch (err) {
      status.innerHTML = `<span style="color:var(--error)"><svg class="ic" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:inline-block;vertical-align:-2px"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg> ${this.esc(err.message)}</span>`;
    } finally { apply.disabled = false; }
  },

  /* ── overview ── */
  async loadOverview() {
    const d = await this.api('GET', '/overview') || {};
    if (d.success === false) return;
    try {
      const h = await fetch('/health').then(r => r.json()).catch(() => null);
      if (h) {
        const sv = document.getElementById('sidebar-ver');
        if (sv) sv.textContent = h.commit ? `v${h.version}·${h.commit.slice(0,7)}` : `v${h.version}`;
      }
    } catch {}
    const uptime = d.uptime ? this.fmtDuration(d.uptime) : '-';
    const ls = d.langServer || {};
    const poolCount = (ls.instances || []).length || (ls.running ? 1 : 0);
    const totalAccts = d.accounts?.total || 0;
    const navAcct = document.getElementById('nav-accounts-count');
    if (navAcct) navAcct.textContent = totalAccts;

    document.getElementById('overview-cards').innerHTML = `
      ${this.metric({ label: 'active accounts', value: d.accounts?.active || 0, sub: `${d.accounts?.total || 0} total · ${d.accounts?.error || 0} errored`, kind: 'good' })}
      ${this.metric({ label: 'total requests', value: d.totalRequests || 0, sub: `${d.successRate || 0}% success`, kind: 'accent' })}
      ${this.metric({ label: 'uptime', value: uptime, sub: d.startedAt ? new Date(d.startedAt).toLocaleString() : '—', valueSize: 22 })}
      ${this.metric({ label: 'language server', value: ls.running ? 'running' : 'stopped', sub: `${poolCount} inst · :${ls.port || '-'}`, kind: ls.running ? 'good' : 'error', valueSize: 22 })}
      ${this.metric({ label: 'response cache', value: (d.cache?.hitRate || '0.0') + '%', sub: `${d.cache?.hits || 0} / ${d.cache?.misses || 0} · ${d.cache?.size || 0}/${d.cache?.maxSize || 0}` })}
    `;
    const instances = ls.instances || [];
    if (instances.length > 0) {
      document.getElementById('ls-status').innerHTML = `<div class="ls-pool" style="border:none;margin:-18px -20px">${
        instances.map(i => `<div class="ls-inst">
          <div class="ls-key"><span class="ls-dot ${i.ready ? '' : 'pending'}"></span>${this.esc(i.key === 'default' ? 'default' : i.key.replace(/^px_/, ''))}</div>
          <div class="ls-meta">port ${i.port} · pid ${i.pid || '-'}</div>
          <div class="ls-meta">${this.esc(i.proxy || 'no proxy')}</div>
        </div>`).join('')
      }</div>`;
    } else {
      document.getElementById('ls-status').innerHTML = `<span class="badge ${ls.running ? 'running' : 'stopped'}">${ls.running ? 'running' : 'stopped'}</span> <span class="text-sm text-mute">pid ${ls.pid || '-'} · port ${ls.port || '-'} · restarts ${ls.restartCount || 0}</span>`;
    }
    this.poll('overview', () => this.loadOverview(), 15000);
  },

  metric(o) {
    const sz = o.valueSize ? `font-size:${o.valueSize}px` : '';
    return `<div class="metric ${o.kind || ''}">
      <div class="m-label">${this.esc(o.label)}</div>
      <div class="m-value" style="${sz}">${this.esc(o.value)}</div>
      <div class="m-sub">${this.esc(o.sub || '')}</div>
    </div>`;
  },

  async restartLs() {
    const ok = await this.confirm('Restart language server?', 'Active sessions will be reset.', { danger: true, okText: 'Restart' });
    if (!ok) return;
    await this.api('POST', '/langserver/restart', { confirm: true });
    this.toast('restarting…', 'info');
  },

  /* ── windsurf-login ── */
  loadWindsurfLogin() { this.renderLoginHistory(); },

  isFirebaseOAuthOriginBlocked(err) {
    const msg = String(err?.message || err || '');
    return /auth\/(?:requests-from-referer-.*are-blocked|unauthorized-domain)|requests-from-referer-.*are-blocked|unauthorized-domain/i.test(msg);
  },

  async oauthLogin(provider) {
    const btn = document.getElementById(`oauth-${provider}-btn`);
    const status = document.getElementById('oauth-status');
    const label = provider === 'google' ? 'Google' : 'GitHub';
    if (btn) btn.disabled = true;
    status.textContent = `signing in via ${label} …`;
    status.style.color = 'var(--ink-mute)';
    try {
      if (!window._firebaseOAuth) throw new Error('firebase not loaded');
      const cred = await window._firebaseOAuth(provider);
      status.textContent = `registering with codeium … (${label})`;
      const r = await this.api('POST', '/oauth-login', { idToken: cred.idToken, refreshToken: cred.refreshToken, email: cred.email, provider: cred.provider, autoAdd: true });
      if (r.error) throw new Error(r.error);
      status.style.color = 'var(--good)';
      status.innerHTML = `<svg class="ic" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:inline-block;vertical-align:-2px"><polyline points="20 6 9 17 4 12"/></svg> logged in as ${this.esc(r.email || label)}`;
      this.toast(`${label} login OK`, 'success');
      this.loadAccounts();
      this.pushLoginHistory({ time: new Date().toISOString(), email: r.email || label, proxy: 'direct', status: 'success', method: label });
    } catch (err) {
      const rawError = err?.message || String(err);
      const originBlocked = this.isFirebaseOAuthOriginBlocked(err);
      const fallback = I18n.locale === 'en'
        ? `${label} OAuth is blocked for this dashboard origin by Firebase. Use Auth Token from windsurf.com/show-auth-token and add it in Account Management.`
        : `${label} OAuth 被 Firebase 拒绝了当前 Dashboard 来源。请从 windsurf.com/show-auth-token 复制 Auth Token 后在账号管理里添加。`;
      const message = originBlocked ? fallback : rawError;
      status.style.color = 'var(--error)';
      status.innerHTML = `<svg class="ic" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:inline-block;vertical-align:-2px"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg> ${this.esc(message)}`;
      this.toast(originBlocked ? message : `${label} login failed: ${rawError}`, 'error');
    } finally { if (btn) btn.disabled = false; }
  },

  getWindsurfLoginProxy() {
    const proxyHost = document.getElementById('wl-proxy-host').value.trim();
    return proxyHost ? {
      type: document.getElementById('wl-proxy-type').value,
      host: proxyHost,
      port: parseInt(document.getElementById('wl-proxy-port').value) || 8080,
      username: document.getElementById('wl-proxy-user').value,
      password: document.getElementById('wl-proxy-pass').value,
    } : null;
  },
  proxyLabel(p) { return p ? `${p.type}://${p.host}:${p.port}` : 'direct'; },

  pushLoginHistory(entry) { this.pushLoginHistoryEntries([entry]); },
  pushLoginHistoryEntries(entries) {
    for (let i = entries.length - 1; i >= 0; i--) this.loginHistory.unshift(entries[i]);
    if (this.loginHistory.length > 50) this.loginHistory.length = 50;
    localStorage.setItem('wl_history', JSON.stringify(this.loginHistory));
    this.renderLoginHistory();
  },
  removeLoginHistory(idx) { this.loginHistory.splice(idx, 1); localStorage.setItem('wl_history', JSON.stringify(this.loginHistory)); this.renderLoginHistory(); },

  showWLResult(html) {
    const rd = document.getElementById('wl-result');
    rd.classList.remove('hidden');
    rd.innerHTML = html;
  },

  getWindsurfLoginFailActions(r, email) {
    if (!r.isAuthFail) return '';
    const inlineId = 'inline-token-' + Math.random().toString(36).slice(2, 8);
    // The address lands inside an inline onclick JS string below — esc() is
    // the wrong context (its &#39; decodes back to a live quote).
    const emailAttr = this.escJsAttr(email || r.email || '');
    const en = I18n.locale === 'en';
    const t = {
      tryOther: en ? 'Try another way' : '换种方式登录',
      google: en ? 'Sign in with Google' : 'Google 登录',
      github: en ? 'Sign in with GitHub' : 'GitHub 登录',
      title: en ? 'Or paste Auth Token directly (recommended for OAuth-only accounts)' : '或者直接粘贴 Auth Token(推荐 OAuth-only 账号用这个)',
      desc: en ? 'Click the button to open windsurf.com signin page, copy the token shown after login, paste it back here.' : '点按钮打开 windsurf.com 登录页,复制登录完成后显示的 token,粘回这里。',
      open: en ? 'Open windsurf.com Token' : '打开 windsurf.com 拿 Token',
      placeholder: en ? 'Paste Auth Token here…' : '粘贴 Auth Token …',
      add: en ? 'Add via Token' : '用 Token 添加',
    };
    return `
      <div style="margin-top:14px;padding:14px;background:var(--paper-2,rgba(0,0,0,.03));border-radius:8px;border-left:3px solid var(--accent,#c89c5c)">
        <div style="font-weight:600;margin-bottom:8px">${t.tryOther}</div>
        <div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px">
          <button class="btn btn-sm" style="background:#4285F4;color:#fff" onclick="App.oauthLogin('google')">${t.google}</button>
          <button class="btn btn-sm" style="background:#24292e;color:#fff" onclick="App.oauthLogin('github')">${t.github}</button>
        </div>
        <div style="border-top:1px dashed var(--ink-mute,#999);padding-top:12px">
          <div style="font-weight:600;margin-bottom:6px">${t.title}</div>
          <div class="text-sm text-mute" style="margin-bottom:10px">${t.desc}</div>
          <div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">
            <button class="btn btn-sm btn-ghost" onclick="App.openWindsurfTokenUrl('${inlineId}')">${t.open}</button>
            <input id="${inlineId}" type="text" class="input" style="flex:1;min-width:240px" placeholder="${t.placeholder}">
            <button class="btn btn-sm btn-primary" onclick="App.addAccountFromInlineToken('${inlineId}','${emailAttr}')">${t.add}</button>
          </div>
        </div>
      </div>`;
  },

  openWindsurfTokenUrl(inputId) {
    // Editor backup-token signin URL extracted from real Windsurf editor 2.0.67
    // (extension.js getLoginUrl with forceShowAuthToken:true). The public client_id
    // is the canonical Windsurf editor identifier.
    const state = (crypto.getRandomValues
      ? Array.from(crypto.getRandomValues(new Uint8Array(8))).map(b => b.toString(16).padStart(2, '0')).join('')
      : Math.random().toString(36).slice(2));
    const url = 'https://windsurf.com/windsurf/signin?response_type=token&client_id=3GUryQ7ldAeKEuD2obYnppsnmj58eP5u&redirect_uri=show-auth-token&state=' + state + '&prompt=login&redirect_parameters_type=query&workflow=';
    window.open(url, '_blank');
    setTimeout(() => document.getElementById(inputId)?.focus(), 100);
  },

  async addAccountFromInlineToken(inputId, label) {
    const input = document.getElementById(inputId);
    if (!input) return;
    const token = input.value.trim();
    if (!token) return this.toast('paste token first', 'error');
    const btn = input.nextElementSibling;
    const originalDisabled = btn?.disabled;
    if (btn) btn.disabled = true;
    try {
      const r = await this.api('POST', '/accounts', { token, label });
      if (r.success) {
        this.toast('account added', 'success');
        input.value = '';
        this.loadAccounts();
      } else {
        this.toast(r.error || 'add failed', 'error');
      }
    } catch (err) {
      this.toast(err.message, 'error');
    } finally {
      if (btn) btn.disabled = originalDisabled || false;
    }
  },

  async windsurfLogin() {
    const email = document.getElementById('wl-email').value.trim();
    const password = document.getElementById('wl-password').value.trim();
    if (!email || !password) return this.toast('email + password required', 'error');
    const proxy = this.getWindsurfLoginProxy();
    const autoAdd = document.getElementById('wl-auto-add').checked;
    const btn = document.getElementById('wl-btn');
    btn.disabled = true;
    btn.innerHTML = '<span class="spinner"></span> logging in …';
    const entry = { time: new Date().toISOString(), email, proxy: this.proxyLabel(proxy), status: 'pending' };
    try {
      const r = await this.api('POST', '/windsurf-login', { email, password, proxy, autoAdd });
      if (r.success) {
        entry.status = 'success';
        entry.apiKey = r.apiKey_masked || (r.apiKey?.slice(0,16) + '…');
        this.showWLResult(`<div class="anno" style="color:var(--good);font-size:20px"><svg class="ic" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:inline-block;vertical-align:-2px"><polyline points="20 6 9 17 4 12"/></svg> login OK</div>
          <table style="margin-top:12px">
            <tr><td class="text-mute">email</td><td>${this.esc(r.email)}</td></tr>
            <tr><td class="text-mute">name</td><td>${this.esc(r.name || '-')}</td></tr>
            <tr><td class="text-mute">api key</td><td><code class="break-all">${this.esc(r.apiKey_masked || r.apiKey || '')}</code></td></tr>
          </table>`);
        this.toast(autoAdd ? 'login OK · added to pool' : 'login OK', 'success');
        this.loadAccounts();
      } else {
        entry.status = 'error: ' + (r.error || 'unknown');
        this.showWLResult(`<div class="anno" style="color:var(--error);font-size:20px"><svg class="ic" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:inline-block;vertical-align:-2px"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg> login failed</div><p class="text-sm">${this.esc(r.error || 'unknown')}</p>${this.getWindsurfLoginFailActions(r, email)}`);
        this.toast(r.error || 'login failed', 'error');
      }
    } catch (err) {
      entry.status = 'error: ' + err.message;
      this.toast(err.message, 'error');
    }
    this.pushLoginHistory(entry);
    btn.disabled = false;
    btn.innerHTML = `登录取号 <svg class="ic" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:inline-block;vertical-align:-2px"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg>`;
  },

  async testLoginProxy() {
    const host = document.getElementById('wl-proxy-host').value.trim();
    const port = parseInt(document.getElementById('wl-proxy-port').value) || 0;
    const out = document.getElementById('wl-proxy-test-result');
    const btn = document.getElementById('wl-proxy-test-btn');
    if (!host || !port) { if (out) { out.textContent = '请填 host + port'; out.style.color = 'var(--error)'; } return; }
    const type = document.getElementById('wl-proxy-type').value;
    const username = document.getElementById('wl-proxy-user').value.trim();
    const password = document.getElementById('wl-proxy-pass').value;
    if (btn) btn.disabled = true;
    if (out) { out.textContent = 'testing…'; out.style.color = 'var(--ink-mute)'; }
    try {
      const r = await this.api('POST', '/test-proxy', { host, port, username, password, type });
      if (out) {
        if (r.ok) {
          out.textContent = `OK · egress ${r.egressIp || '?'} · ${r.latencyMs || 0}ms`;
          out.style.color = 'var(--good)';
        } else {
          out.textContent = `failed: ${r.error || 'unknown'}`;
          out.style.color = 'var(--error)';
        }
      }
    } catch (e) {
      if (out) { out.innerHTML = `<svg class="ic" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:inline-block;vertical-align:-2px"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg> ` + this.esc(e.message); out.style.color = 'var(--error)'; }
    } finally {
      if (btn) btn.disabled = false;
    }
  },

  async pasteWindsurfBatch() {
    const el = document.getElementById('wl-batch-input');
    if (!navigator.clipboard) { el?.focus(); return this.toast('clipboard unsupported', 'info'); }
    try {
      const text = await navigator.clipboard.readText();
      if (!text) return this.toast('clipboard empty', 'info');
      el.value = text;
      this.toast('pasted', 'success');
    } catch (err) { this.toast('clipboard error: ' + err.message, 'error'); }
  },

  async windsurfLoginBatch() {
    const input = document.getElementById('wl-batch-input').value;
    if (!input.trim()) return this.toast('paste accounts first', 'error');
    const autoAdd = document.getElementById('wl-auto-add').checked;
    const btn = document.getElementById('wl-batch-btn');
    btn.disabled = true; btn.innerHTML = '<span class="spinner"></span> importing …';
    try {
      const r = await this.api('POST', '/batch-import', { text: input, autoAdd });
      if (!r.success || !Array.isArray(r.results)) {
        this.toast(r.error || 'batch failed', 'error'); return;
      }
      this.showWLResult(`<div class="anno" style="font-size:20px">batch · ${r.successCount || 0} ok / ${r.failCount || 0} fail</div>
        <div class="table-wrap" style="margin-top:12px"><table>
          <thead><tr><th>email</th><th>status</th><th>note</th></tr></thead>
          <tbody>${r.results.map(x => `<tr><td>${this.esc(x.email || '-')}</td>
            <td><span class="badge ${x.success ? 'active' : 'error'}">${x.success ? 'ok' : 'fail'}</span></td>
            <td class="text-sm">${x.success ? `<code class="break-all">${this.esc(x.apiKey_masked || x.apiKey?.slice(0,16) + '…' || '-')}</code>` : `<span style="color:var(--error)">${this.esc(x.error || 'unknown')}</span>`}</td></tr>`).join('')}</tbody>
        </table></div>`);
      this.pushLoginHistoryEntries(r.results.map(x => ({ time: new Date().toISOString(), email: x.email, proxy: 'batch', status: x.success ? 'success' : 'error: ' + (x.error || 'unknown'), apiKey: x.success ? x.apiKey_masked : undefined })));
      this.toast(`batch · ${r.successCount || 0} ok / ${r.failCount || 0} fail`);
      if ((r.successCount || 0) > 0) { this.loadAccounts(); document.getElementById('wl-batch-input').value = ''; }
    } catch (err) { this.toast(err.message, 'error'); }
    finally { btn.disabled = false; btn.textContent = '批量导入'; }
  },

  renderLoginHistory() {
    const tbody = document.querySelector('#wl-history-table tbody');
    if (!tbody) return;
    tbody.innerHTML = this.loginHistory.map((h, i) => {
      const ok = h.status === 'success';
      return `<tr>
        <td class="text-sm nowrap">${new Date(h.time).toLocaleString()}</td>
        <td><span style="display:inline-flex;align-items:center;gap:8px"><span class="avatar-letter">${this.esc((h.email || '?')[0].toUpperCase())}</span>${this.esc(h.email)}</span></td>
        <td><span class="badge ${ok ? 'active' : 'error'}">${ok ? 'ok' : 'fail'}</span>${!ok ? `<div class="text-xs" style="color:var(--error);margin-top:3px">${this.esc((h.status||'').replace('error: ',''))}</div>` : ''}</td>
        <td class="text-sm">${this.esc(h.proxy || '-')}</td>
        <td><button class="btn btn-ghost btn-xs" onclick="App.removeLoginHistory(${i})">×</button></td>
      </tr>`;
    }).join('') || `<tr class="empty-row"><td colspan="5">no history yet — sign in above ↑</td></tr>`;
  },

  /* ── accounts ── */
  async _ensureModelsCatalog() {
    if (this._modelsCatalog) return this._modelsCatalog;
    try { const r = await this.api('GET', '/models'); this._modelsCatalog = {}; for (const m of (r.models || [])) this._modelsCatalog[m.id] = m; }
    catch { this._modelsCatalog = {}; }
    return this._modelsCatalog;
  },

  async loadDrought() {
    const banner = document.getElementById('drought-banner');
    const msg = document.getElementById('drought-message');
    const failOpenMsg = document.getElementById('drought-fail-open-message');
    const detail = document.getElementById('drought-detail');
    if (!banner) return;
    try {
      const d = await this.api('GET', '/drought');
      if (!d?.drought) { banner.style.display = 'none'; return; }
      banner.style.display = '';
      if (msg && failOpenMsg) {
        msg.style.display = d.restrictionFailOpen ? 'none' : '';
        failOpenMsg.style.display = d.restrictionFailOpen ? '' : 'none';
      }
      if (detail) {
        const lw = d.lowestWeeklyPercent;
        const ld = d.lowestDailyPercent;
        const parts = [];
        if (lw != null) parts.push(`min weekly=${lw.toFixed(0)}%`);
        if (ld != null) parts.push(`min daily=${ld.toFixed(0)}%`);
        parts.push(`${d.knownAccounts}/${d.activeAccounts} accounts known`);
        detail.textContent = parts.join(' · ');
      }
    } catch { banner.style.display = 'none'; }
  },

  async loadAccounts() {
    // v2.0.57 Fix 5: refresh drought banner alongside accounts.
    this.loadDrought();
    const [d, ov] = await Promise.all([
      this.api('GET', '/accounts'),
      this.api('GET', '/overview').catch(() => null),
    ]);
    // Same fail-closed as index.html: 401/429/offline must not wipe the table.
    if (!Array.isArray(d?.accounts)) return;
    const instances = ov?.langServer?.instances || [];
    const poolCard = document.getElementById('ls-pool-card');
    if (poolCard) {
      if (instances.length > 0) {
        poolCard.innerHTML = `<div class="section">
          <div class="section-head"><div><div class="section-title"><span class="num">L</span><span>language server pool</span></div>
            <div class="section-desc">${instances.length} instance(s)</div></div></div>
          <div class="ls-pool">${instances.map(i => `<div class="ls-inst">
            <div class="ls-key"><span class="ls-dot ${i.ready ? '' : 'pending'}"></span>${this.esc(i.key === 'default' ? 'default' : i.key.replace(/^px_/, ''))}</div>
            <div class="ls-meta">port ${i.port} · pid ${i.pid || '-'}</div>
            <div class="ls-meta">${this.esc(i.proxy || 'no proxy')}</div>
          </div>`).join('')}</div></div>`;
      } else { poolCard.innerHTML = ''; }
    }

    const tbody = document.querySelector('#accounts-table tbody');
    const tierLabel = { pro: 'PRO', free: 'FREE', expired: 'EXP', unknown: '?' };
    tbody.innerHTML = (d.accounts || []).map(a => {
      const tier = a.tier || 'unknown';
      const tierModels = a.tierModels || [];
      const blockedCount = (a.blockedModels || []).length;
      const availCount = tierModels.length - blockedCount;
      const capsHtml = tierModels.length
        ? `<button class="btn btn-ghost btn-xs" onclick="App.openBlockedModal('${this.escJsAttr(a.id)}')" title="edit">
             <span style="color:var(--good);font-weight:600">${availCount}</span><span class="text-mute">/${tierModels.length}</span>
             ${blockedCount > 0 ? `<span class="badge warn" style="margin-left:4px">−${blockedCount}</span>` : ''}
           </button>`
        : `<span class="text-xs text-dim">—</span>`;

      const rateLimitBadge = a.rateLimited ? ` <span class="badge warn">RL</span>` : '';
      const rpmUsed = a.rpmUsed ?? 0, rpmLimit = a.rpmLimit ?? 0;
      const rpmPct = rpmLimit > 0 ? Math.min(100, Math.round((rpmUsed / rpmLimit) * 100)) : 0;
      const rpmKind = rpmPct >= 90 ? 'error' : rpmPct >= 60 ? 'warn' : 'good';
      const rpmCell = rpmLimit > 0
        ? `<div style="min-width:80px"><div class="text-xs" style="display:flex;justify-content:space-between;margin-bottom:2px"><span>${rpmUsed}/${rpmLimit}</span><span>${rpmPct}%</span></div><div class="bar" style="height:5px"><div class="bar-fill ${rpmKind}" style="width:${rpmPct}%"></div></div></div>`
        : `<span class="text-xs text-dim">—</span>`;

      const cr = a.credits || null;
      let creditCell;
      if (!cr) creditCell = `<span class="text-xs text-dim italic">unfetched</span>`;
      else if (cr.lastError && cr.percent == null && !cr.prompt?.limit) creditCell = `<span class="text-xs" style="color:var(--error)" title="${this.esc(cr.lastError)}">fetch failed</span>`;
      else {
        const planName = cr.planName || '-';
        const fetchedAgo = cr.fetchedAt ? Math.round((Date.now() - cr.fetchedAt) / 60000) + 'm' : '-';
        const renderBar = (lbl, pct) => {
          // v2.0.75 (#123): "N/A" reads more clearly than an em-dash
          // when upstream just doesn't return that bucket (Trial Daily
          // is null but Weekly has a value — easy to mistake for a
          // refresh failure). italic + dashed border separates it
          // visually from a 0% / failure state.
          if (pct == null) return `<div class="detail-bar" style="margin:0;grid-template-columns:18px 1fr 36px" title="upstream returned no data for this bucket"><span class="b-label">${lbl}</span><div class="bar" style="height:5px;border-width:1px;border-style:dashed"><div class="bar-fill"></div></div><span class="b-pct text-dim italic">N/A</span></div>`;
          const v = Math.max(0, Math.min(100, pct));
          const k = v <= 10 ? 'error' : v <= 30 ? 'warn' : 'good';
          return `<div class="detail-bar" style="margin:0;grid-template-columns:18px 1fr 36px"><span class="b-label">${lbl}</span><div class="bar" style="height:5px;border-width:1px"><div class="bar-fill ${k}" style="width:${v}%"></div></div><span class="b-pct">${v.toFixed(0)}%</span></div>`;
        };
        creditCell = `<div style="min-width:130px"><div class="text-xs" style="margin-bottom:3px"><b>${this.esc(planName.slice(0,12))}</b> <span class="text-mute">${fetchedAgo}</span></div>${renderBar('D', cr.dailyPercent)}${renderBar('W', cr.weeklyPercent)}</div>`;
      }
      const isExpanded = this._expandedAccounts.has(a.id);
      return `<tr${isExpanded ? ' class="expanded"' : ''}>
        <td><button class="expand-chev ${isExpanded ? 'open' : ''}" onclick="App.toggleAccountDetail('${this.escJsAttr(a.id)}')"><svg class="ic" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:inline-block;vertical-align:-2px"><polyline points="9 18 15 12 9 6"/></svg></button><code>${this.esc((a.id||'').slice(0,12))}</code></td>
        <td>${this.esc(a.email)}</td>
        <td><span class="tier ${this.esc(tier)}" onclick="App.overrideTier('${this.escJsAttr(a.id)}','${this.escJsAttr(tier)}')">${tierLabel[tier] || this.esc(tier)}${a.tierManual ? ' ' + `<svg class="ic" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:inline-block;vertical-align:-2px"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>` : ''}</span></td>
        <td>${rpmCell}</td>
        <td>${creditCell}</td>
        <td>${capsHtml}</td>
        <td><span class="badge ${a.status}">${a.status}</span>${rateLimitBadge}</td>
        <td style="color:${a.errorCount > 0 ? 'var(--error)' : 'inherit'};font-family:'JetBrains Mono',monospace">${a.errorCount}</td>
        <td class="text-sm nowrap">${a.lastUsed ? new Date(a.lastUsed).toLocaleString() : '—'}</td>
        <td class="nowrap"><code title="reveal & copy" style="cursor:pointer" onclick="App.revealAndCopyKey('${this.escJsAttr(a.id)}')">${this.esc(a.apiKey_masked || a.id || '')}</code></td>
        <td class="nowrap"><div class="btn-group">
          <button class="btn btn-ghost btn-xs" onclick="App.probeAccount('${this.escJsAttr(a.id)}')">probe</button>
          <button class="btn btn-ghost btn-xs" onclick="App.refreshCredits('${this.escJsAttr(a.id)}')"><svg class="ic" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:inline-block;vertical-align:-2px"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg></button>
          ${a.status === 'active'
            ? `<button class="btn btn-ghost btn-xs" onclick="App.toggleAccount('${this.escJsAttr(a.id)}','disabled')">disable</button>`
            : `<button class="btn btn-good btn-xs" onclick="App.toggleAccount('${this.escJsAttr(a.id)}','active')">enable</button>`}
          <button class="btn btn-ghost btn-xs" onclick="App.resetErrors('${this.escJsAttr(a.id)}')">reset</button>
          <button class="btn btn-ghost btn-xs" style="color:var(--error)" onclick="App.deleteAccount('${this.escJsAttr(a.id)}')">×</button>
        </div></td>
      </tr>${isExpanded ? `<tr class="detail-row"><td colspan="11">${this.renderAccountDetail(a)}</td></tr>` : ''}`;
    }).join('') || `<tr class="empty-row"><td colspan="11">no accounts yet · paste a key above ↑ or grab one from /windsurf-login</td></tr>`;
  },

  toggleAccountDetail(id) {
    if (this._expandedAccounts.has(id)) { this._expandedAccounts.delete(id); this.loadAccounts(); }
    else { this._expandedAccounts.add(id); this._ensureModelsCatalog().then(() => this.loadAccounts()); }
  },

  renderAccountDetail(a) {
    const cr = a.credits || {};
    const us = a.userStatus || {};
    const catalog = this._modelsCatalog || {};
    const fmtUnix = u => u ? new Date(u * 1000).toLocaleString() : '—';
    const fmtDate = v => v ? new Date(v).toLocaleDateString() : '—';
    const fmtAgo = ts => {
      if (!ts) return '—';
      const s = Math.floor((Date.now() - ts) / 1000);
      if (s < 60) return `${s}s ago`;
      if (s < 3600) return `${Math.floor(s/60)}m ago`;
      if (s < 86400) return `${Math.floor(s/3600)}h ago`;
      return `${Math.floor(s/86400)}d ago`;
    };
    const renderBar = (lbl, pct, resetAt, naText) => {
      const isN = pct == null;
      const v = isN ? 0 : Math.max(0, Math.min(100, pct));
      const k = isN ? '' : v <= 10 ? 'error' : v <= 30 ? 'warn' : 'good';
      return `<div class="detail-bar"><span class="b-label">${lbl}</span><div class="bar"><div class="bar-fill ${k}" style="width:${v}%"></div></div><span class="b-pct">${isN ? '—' : v.toFixed(0)+'%'}</span>${resetAt ? `<div class="b-reset"><svg class="ic" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:inline-block;vertical-align:-2px"><polyline points="23 4 23 10 17 10"/><path d="M20.49 9A9 9 0 1 1 5.64 5.64L23 10"/></svg> reset ${fmtUnix(resetAt)}</div>` : (isN && naText ? `<div class="b-reset">${naText}</div>` : '')}</div>`;
    };

    const tierModels = a.tierModels || [];
    const blocked = new Set(a.blockedModels || []);
    const provOf = (m) => {
      if (m.startsWith('claude')) return 'anthropic';
      if (m.startsWith('gpt') || m.startsWith('o3') || m.startsWith('o4')) return 'openai';
      if (m.startsWith('gemini')) return 'google';
      if (m.startsWith('grok')) return 'xai';
      if (m.startsWith('deepseek')) return 'deepseek';
      if (m.startsWith('qwen')) return 'alibaba';
      if (m.startsWith('kimi')) return 'moonshot';
      if (m.startsWith('swe') || m.startsWith('arena')) return 'windsurf';
      return 'other';
    };
    const provLabel = { anthropic: 'Anthropic', openai: 'OpenAI', google: 'Google', deepseek: 'DeepSeek', xai: 'xAI', alibaba: 'Alibaba', moonshot: 'Moonshot', zhipu: 'Zhipu', minimax: 'MiniMax', windsurf: 'Windsurf', other: 'Other' };
    const groups = {};
    for (const m of tierModels) {
      const info = catalog[m] || {};
      const p = info.provider || provOf(m);
      (groups[p] = groups[p] || []).push({ id: m, ...info });
    }
    const availCount = tierModels.length - blocked.size;
    let trialDaysLeft = null;
    if (us.trialEndMs) trialDaysLeft = Math.max(0, Math.ceil((us.trialEndMs - Date.now()) / 86400000));

    return `<div class="detail-wrap">
      <div class="detail-grid">
        <div class="detail-card">
          <div class="detail-card-head"><span class="num-circle">P</span>plan</div>
          <div class="detail-kv">
            <span class="k">name</span><span class="v"><b>${this.esc(cr.planName || us.planName || '—')}</b></span>
            <span class="k">tier</span><span class="v"><span class="tier ${this.esc(a.tier || 'unknown')}">${this.esc(a.tier || 'unknown')}</span>${a.tierManual ? ' <span class="text-mute text-xs"><svg class="ic" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:inline-block;vertical-align:-2px"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg> manual</span>' : ''}</span>
            <span class="k">start</span><span class="v">${fmtDate(cr.planStart || us.planStart)}</span>
            <span class="k">end</span><span class="v">${fmtDate(cr.planEnd || us.planEnd)}${trialDaysLeft != null ? ` <span style="color:${trialDaysLeft <= 3 ? 'var(--error)' : 'var(--ink-mute)'}">(${trialDaysLeft}d)</span>` : ''}</span>
            ${cr.overageBalance != null ? `<span class="k">overage</span><span class="v">$${cr.overageBalance.toFixed(4)}</span>` : ''}
          </div>
        </div>
        <div class="detail-card">
          <div class="detail-card-head"><span class="num-circle">Q</span>quota</div>
          ${renderBar('daily', cr.dailyPercent, cr.dailyResetAt, 'no daily quota data')}
          ${renderBar('weekly', cr.weeklyPercent, cr.weeklyResetAt, 'no weekly quota data')}
          <div style="margin-top:12px;padding-top:10px;border-top:1px dashed var(--rule-soft)" class="detail-kv">
            ${cr.prompt?.limit ? `<span class="k">prompt</span><span class="v"><b>${cr.prompt.remaining ?? 0}</b> / ${cr.prompt.limit} <span class="text-mute text-xs">used ${cr.prompt.used ?? 0}</span></span>` : `<span class="k">prompt</span><span class="v text-mute">N/A</span>`}
            ${cr.flex?.limit ? `<span class="k">flex</span><span class="v"><b>${cr.flex.remaining ?? 0}</b> / ${cr.flex.limit}</span>` : `<span class="k">flex</span><span class="v text-mute">N/A</span>`}
            <span class="k">fetched</span><span class="v">${fmtAgo(cr.fetchedAt)}</span>
          </div>
        </div>

        <div class="detail-card" style="grid-column:1/-1">
          <div class="detail-card-head"><span class="num-circle">M</span>models <span class="text-mute text-xs" style="font-style:normal;font-family:'JetBrains Mono',monospace">${availCount} / ${tierModels.length} available${blocked.size > 0 ? ` · ${blocked.size} blocked` : ''}</span><div style="margin-left:auto"><button class="btn btn-ghost btn-xs" onclick="App.openBlockedModal('${this.escJsAttr(a.id)}')">edit</button></div></div>
          ${tierModels.length === 0 ? `<div class="text-mute italic" style="padding:6px 0">no allowed models — run probe</div>` : Object.entries(groups).map(([p, ms]) => {
            ms.sort((x,y) => (x.credit||0)-(y.credit||0));
            const blockedIn = ms.filter(m => blocked.has(m.id)).length;
            return `<div class="provider-group">
              <div class="provider-label"><span>${this.esc(provLabel[p] || p)}</span><span class="text-xs text-mute" style="font-family:'JetBrains Mono',monospace">${ms.length-blockedIn}/${ms.length}</span></div>
              <div class="model-chips">${ms.map(m => `<div class="model-chip ${blocked.has(m.id) ? 'blocked' : ''}" title="${m.id}"><span>${this.esc(m.id)}</span>${m.credit != null ? `<span style="color:var(--accent);font-weight:600">${m.credit}c</span>` : ''}</div>`).join('')}</div>
            </div>`;
          }).join('')}
        </div>

        <div class="detail-card" style="grid-column:1/-1">
          <div class="detail-card-head"><span class="num-circle">R</span>runtime</div>
          <div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:24px">
            <div class="detail-kv">
              <span class="k">status</span><span class="v"><span class="badge ${a.status}">${a.status}</span></span>
              <span class="k">errors</span><span class="v" style="color:${a.errorCount > 0 ? 'var(--error)' : 'inherit'}">${a.errorCount || 0}</span>
              <span class="k">rpm</span><span class="v">${a.rpmUsed ?? 0} / ${a.rpmLimit ?? 0}</span>
            </div>
            <div class="detail-kv">
              <span class="k">last used</span><span class="v">${fmtAgo(a.lastUsed)}</span>
              <span class="k">last probe</span><span class="v">${fmtAgo(a.lastProbed || a.userStatusLastFetched)}</span>
              <span class="k">credits @</span><span class="v">${fmtAgo(cr.fetchedAt)}</span>
            </div>
            <div class="detail-kv">
              <span class="k">id</span><span class="v"><code>${this.esc(a.id)}</code></span>
              <span class="k">key</span><span class="v"><code>${this.esc(a.apiKey_masked || a.id || '')}</code></span>
              ${cr.lastError ? `<span class="k">last err</span><span class="v" style="color:var(--error);font-size:11px">${this.esc(cr.lastError).slice(0,80)}</span>` : ''}
            </div>
          </div>
        </div>
      </div>
    </div>`;
  },

  copyKey(k) { navigator.clipboard?.writeText(k).then(() => this.toast('copied', 'success')); },
  async revealAndCopyKey(accountId) {
    try {
      const r = await this.api('POST', `/account/${encodeURIComponent(accountId)}/reveal-key`);
      if (r?.apiKey) this.copyKey(r.apiKey);
      else this.toast('copy failed', 'error');
    } catch (e) {
      this.toast(e?.message || 'copy failed', 'error');
    }
  },

  async refreshCredits(id) { try { const r = await this.api('POST', `/accounts/${id}/refresh-credits`, {}); if (r.ok) { this.toast('credits refreshed'); this.loadAccounts(); } else this.toast(r.error || 'fail', 'error'); } catch (e) { this.toast(e.message, 'error'); } },
  async refreshAllCredits() { this.toast('refreshing all …', 'info'); try { const r = await this.api('POST', '/accounts/refresh-credits', {}); if (r.success) { this.toast(`${(r.results||[]).filter(x=>x.ok).length} refreshed`); this.loadAccounts(); } } catch (e) { this.toast(e.message, 'error'); } },
  async probeAll() { this.toast('probing all …', 'info'); try { const r = await this.api('POST', '/accounts/probe-all', {}); if (r.success) { this.toast('probe done'); this.loadAccounts(); } } catch (e) { this.toast(e.message, 'error'); } },
  async probeAccount(id) { this.toast('probing …', 'info'); try { const r = await this.api('POST', `/accounts/${id}/probe`, {}); if (r.success) { this.toast(`tier: ${r.tier}`); this.loadAccounts(); } } catch (e) { this.toast(e.message, 'error'); } },

  async openBlockedModal(id) {
    try {
      const d = await this.api('GET', '/accounts');
      const acct = (d.accounts || []).find(a => a.id === id);
      if (!acct) return this.toast('account not found', 'error');
      const tierModels = acct.tierModels || [];
      const blocked = new Set(acct.blockedModels || []);
      if (!tierModels.length) return this.toast('no models', 'info');
      const provOf = (m) => {
        if (m.startsWith('claude')) return 'anthropic';
        if (m.startsWith('gpt') || m.startsWith('o3') || m.startsWith('o4')) return 'openai';
        if (m.startsWith('gemini')) return 'google';
        if (m.startsWith('grok')) return 'xai';
        if (m.startsWith('deepseek')) return 'deepseek';
        if (m.startsWith('qwen')) return 'alibaba';
        if (m.startsWith('kimi')) return 'moonshot';
        if (m.startsWith('swe') || m.startsWith('arena')) return 'windsurf';
        return 'other';
      };
      const groups = {};
      for (const m of tierModels) (groups[provOf(m)] = groups[provOf(m)] || []).push(m);
      const total = tierModels.length;
      const html = `
        <div style="display:flex;justify-content:space-between;gap:12px;margin-bottom:10px;padding:8px 12px;background:var(--paper-3);border:1px dashed var(--rule-soft)">
          <div class="anno">tick = enabled</div>
          <div>enabled <span id="bm-avail" style="color:var(--good);font-weight:600">${total - blocked.size}</span> <span class="text-mute">/ ${total}</span></div>
        </div>
        <div style="display:flex;gap:6px;margin-bottom:10px">
          <button class="btn btn-ghost btn-xs" onclick="App.bmAll(true)">all</button>
          <button class="btn btn-ghost btn-xs" onclick="App.bmAll(false)">none</button>
          <button class="btn btn-ghost btn-xs" onclick="App.bmInvert()">invert</button>
        </div>
        <div style="max-height:50vh;overflow:auto">${Object.entries(groups).map(([p, ms]) => `
          <div class="bm-group" data-provider="${p}" style="margin-bottom:14px;border:1px solid var(--rule-faint);padding:10px">
            <label class="checkbox" style="font-family:'Caveat',cursive;font-size:17px;margin-bottom:6px;padding-bottom:6px;border-bottom:1px dashed var(--rule-soft);display:flex;width:100%">
              <input type="checkbox" class="bm-group-cb" data-provider="${p}"><span class="box-mark"></span>
              <span style="font-weight:600">${this.esc(p)}</span>
              <span class="text-xs text-mute" data-role="count" style="margin-left:auto;font-family:'JetBrains Mono',monospace">${ms.filter(m => !blocked.has(m)).length}/${ms.length}</span>
            </label>
            <div style="display:grid;grid-template-columns:repeat(2,1fr);gap:4px 10px">
              ${ms.map(m => `<label class="checkbox"><input type="checkbox" class="blocked-model-cb" data-model="${this.esc(m)}" data-provider="${p}" ${blocked.has(m) ? '' : 'checked'}><span class="box-mark"></span><span class="mono text-xs">${this.esc(m)}</span></label>`).join('')}
            </div>
          </div>`).join('')}</div>`;
      this.confirm(`models · ${acct.email}`, html, { okText: 'save', html: true, wide: true });
      setTimeout(() => this._bindBlockedModal(id), 0);
    } catch (e) { this.toast(e.message, 'error'); }
  },

  _bindBlockedModal(accountId) {
    const root = document.querySelector('.modal-overlay:last-child');
    if (!root) return;
    const updateGroup = (p) => {
      const g = root.querySelector(`.bm-group[data-provider="${p}"]`);
      if (!g) return;
      const boxes = g.querySelectorAll('.blocked-model-cb');
      const checked = [...boxes].filter(b => b.checked).length;
      const gcb = g.querySelector('.bm-group-cb');
      gcb.checked = checked === boxes.length;
      gcb.indeterminate = checked > 0 && checked < boxes.length;
      g.querySelector('[data-role=count]').textContent = `${checked}/${boxes.length}`;
    };
    const updateTotal = () => {
      const all = root.querySelectorAll('.blocked-model-cb');
      const checked = [...all].filter(b => b.checked).length;
      const el = root.querySelector('#bm-avail'); if (el) el.textContent = checked;
    };
    root.querySelectorAll('.bm-group').forEach(g => updateGroup(g.dataset.provider));
    updateTotal();
    root.querySelectorAll('.blocked-model-cb').forEach(cb => cb.addEventListener('change', () => { updateGroup(cb.dataset.provider); updateTotal(); }));
    root.querySelectorAll('.bm-group-cb').forEach(gcb => gcb.addEventListener('change', () => {
      root.querySelectorAll(`.blocked-model-cb[data-provider="${gcb.dataset.provider}"]`).forEach(cb => cb.checked = gcb.checked);
      updateGroup(gcb.dataset.provider); updateTotal();
    }));
    const okBtn = root.querySelector('[data-act=ok]');
    if (!okBtn || okBtn.dataset.bound) return;
    okBtn.dataset.bound = '1';
    const self = this;
    okBtn.onclick = async () => {
      const newBlocked = [];
      root.querySelectorAll('.blocked-model-cb').forEach(cb => { if (!cb.checked) newBlocked.push(cb.dataset.model); });
      okBtn.disabled = true; okBtn.textContent = 'saving …';
      try {
        const r = await self.api('PATCH', `/accounts/${accountId}`, { blockedModels: newBlocked });
        if (r.success) { self.toast('saved'); root.remove(); self.loadAccounts(); }
        else { self.toast(r.error || 'save failed', 'error'); okBtn.disabled = false; okBtn.textContent = 'save'; }
      } catch (e) { self.toast(e.message, 'error'); okBtn.disabled = false; okBtn.textContent = 'save'; }
    };
  },
  bmAll(checked) { const root = document.querySelector('.modal-overlay:last-child'); if (!root) return; root.querySelectorAll('.blocked-model-cb').forEach(cb => { cb.checked = checked; cb.dispatchEvent(new Event('change')); }); },
  bmInvert() { const root = document.querySelector('.modal-overlay:last-child'); if (!root) return; root.querySelectorAll('.blocked-model-cb').forEach(cb => { cb.checked = !cb.checked; cb.dispatchEvent(new Event('change')); }); },

  async addAccount() {
    const type = document.getElementById('acc-type').value;
    const key = document.getElementById('acc-key').value.trim();
    const label = document.getElementById('acc-label').value.trim();
    if (!key) return this.toast('paste key or token', 'error');
    const body = type === 'api_key' ? { api_key: key, label } : { token: key, label };
    const r = await this.api('POST', '/accounts', body);
    if (r.success) { this.toast('account added'); document.getElementById('acc-key').value = ''; document.getElementById('acc-label').value = ''; this.loadAccounts(); }
    else this.toast(r.error || 'add failed', 'error');
  },
  async toggleAccount(id, status) { await this.api('PATCH', `/accounts/${id}`, { status }); this.loadAccounts(); },
  async overrideTier(id, current) {
    const v = await this.prompt('override tier', `current: ${current}`, [{ name: 'tier', label: 'tier', type: 'select', value: current, options: [{ value: 'pro', label: 'pro' }, { value: 'free', label: 'free' }, { value: 'unknown', label: 'unknown' }] }]);
    if (!v) return;
    await this.api('PATCH', `/accounts/${id}`, { tier: v.tier });
    this.toast('tier set'); this.loadAccounts();
  },
  async resetErrors(id) { await this.api('PATCH', `/accounts/${id}`, { resetErrors: true }); this.loadAccounts(); this.toast('errors reset'); },
  async deleteAccount(id) {
    const ok = await this.confirm('delete account?', 'this cannot be undone', { danger: true, okText: 'delete' });
    if (!ok) return;
    await this.api('DELETE', `/accounts/${id}`);
    this.loadAccounts(); this.toast('deleted');
  },

  /* ── models ── */
  async loadModels() {
    const [m, a] = await Promise.all([this.api('GET', '/models'), this.api('GET', '/model-access')]);
    if (m?.success === false || a?.success === false) return;
    this.allModels = m.models || [];
    this.modelAccessConfig = a.mode ? a : { mode: 'all', list: [] };
    document.querySelector(`input[name="model-mode"][value="${this.modelAccessConfig.mode}"]`).checked = true;
    const sel = document.getElementById('model-provider-filter');
    const provs = [...new Set(this.allModels.map(x => x.provider))].sort();
    sel.innerHTML = '<option value="">all providers</option>' + provs.map(p => `<option>${this.esc(p)}</option>`).join('');
    this.updateModelListUI();
  },
  updateModelListUI() {
    const sec = document.getElementById('model-list-section');
    if (this.modelAccessConfig.mode === 'all') { sec.classList.add('hidden'); return; }
    sec.classList.remove('hidden');
    const isAllow = this.modelAccessConfig.mode === 'allowlist';
    document.getElementById('model-list-title').textContent = isAllow ? 'allow list — only these models' : 'block list — these models are denied';
    document.getElementById('model-list-hint').textContent = isAllow ? 'click chips below to allow them' : 'click chips below to block them';
    const current = this.modelAccessConfig.list;
    const cont = document.getElementById('model-list-current');
    cont.innerHTML = current.length
      ? `<div class="model-chips">${current.map(m => `<span class="model-chip selected">${this.esc(m)}<span class="remove" onclick="App.removeModelFromList('${this.escJsAttr(m)}')">×</span></span>`).join('')}</div>`
      : `<div class="text-xs text-dim italic">empty</div>`;
    this.filterModels();
  },
  filterModels() {
    const search = (document.getElementById('model-search')?.value || '').toLowerCase();
    const provider = document.getElementById('model-provider-filter')?.value || '';
    const list = this.modelAccessConfig.list;
    const grouped = {};
    for (const m of this.allModels) {
      if (search && !m.name.toLowerCase().includes(search) && !m.provider.toLowerCase().includes(search)) continue;
      if (provider && m.provider !== provider) continue;
      (grouped[m.provider] = grouped[m.provider] || []).push(m);
    }
    document.getElementById('model-chips-container').innerHTML = Object.entries(grouped).map(([p, ms]) => `
      <div class="provider-group">
        <div class="provider-label">${this.esc(p)}</div>
        <div class="model-chips">${ms.map(m => `<span class="model-chip ${list.includes(m.id) ? 'selected' : ''}" onclick="App.toggleModelInList('${this.escJsAttr(m.id)}')">${this.esc(m.name)}</span>`).join('')}</div>
      </div>`).join('') || '<div class="text-mute italic">no matches</div>';
  },
  async setModelMode(mode) { await this.api('PUT', '/model-access', { mode }); this.modelAccessConfig.mode = mode; this.updateModelListUI(); this.toast('mode updated'); },
  async toggleModelInList(id) {
    const idx = this.modelAccessConfig.list.indexOf(id);
    if (idx > -1) { await this.api('POST', '/model-access/remove', { model: id }); this.modelAccessConfig.list.splice(idx, 1); }
    else { await this.api('POST', '/model-access/add', { model: id }); this.modelAccessConfig.list.push(id); }
    this.updateModelListUI();
  },
  async removeModelFromList(id) { await this.api('POST', '/model-access/remove', { model: id }); this.modelAccessConfig.list = this.modelAccessConfig.list.filter(x => x !== id); this.updateModelListUI(); },

  /* ── proxy ── */
  async loadProxy() {
    const d = await this.api('GET', '/proxy');
    if (d?.success === false) return;
    if (d.global) {
      document.getElementById('proxy-type').value = d.global.type || 'http';
      document.getElementById('proxy-host').value = (d.global.host || '').replace(/:\d+$/, '');
      document.getElementById('proxy-port').value = d.global.port || '';
      document.getElementById('proxy-user').value = d.global.username || '';
      document.getElementById('proxy-pass').value = '';
      document.getElementById('proxy-pass').placeholder = d.global.hasPassword ? '(saved)' : 'optional';
      this._globalProxyHasPassword = !!d.global.hasPassword;
      const h = (d.global.host || '').replace(/:\d+$/, '');
      document.getElementById('proxy-current').textContent = `current: ${d.global.type}://${h}:${d.global.port}${d.global.username ? ' (auth: ' + d.global.username + ')' : ''}`;
    } else {
      document.getElementById('proxy-current').textContent = 'not configured';
    }
    const accts = await this.api('GET', '/accounts?view=summary&pageSize=200');
    if (accts?.success === false) return;
    const tbody = document.querySelector('#proxy-accounts-table tbody');
    const pa = d.perAccount || {};
    tbody.innerHTML = (accts.accounts || []).map(a => {
      const p = pa[a.id];
      return `<tr>
        <td>${this.esc(a.email)} <code class="text-xs">${this.esc(a.id)}</code></td>
        <td>${p ? `<code>${this.esc(p.type)}://${p.username ? this.esc(p.username) + '@' : ''}${this.esc(p.host)}:${this.esc(String(p.port))}</code>` : `<span class="text-sm text-dim italic">none</span>`}</td>
        <td class="nowrap"><div class="btn-group"><button class="btn btn-ghost btn-xs" onclick="App.editAccountProxy('${this.escJsAttr(a.id)}','${this.escJsAttr(a.email)}')">configure</button>${p ? `<button class="btn btn-ghost btn-xs" style="color:var(--error)" onclick="App.clearAccountProxy('${this.escJsAttr(a.id)}')">clear</button>` : ''}</div></td>
      </tr>`;
    }).join('') || `<tr class="empty-row"><td colspan="3">no accounts yet</td></tr>`;
  },
  async saveGlobalProxy() {
    const cfg = { type: document.getElementById('proxy-type').value, host: document.getElementById('proxy-host').value.trim(), port: document.getElementById('proxy-port').value, username: document.getElementById('proxy-user').value };
    const pw = document.getElementById('proxy-pass').value;
    if (pw || !this._globalProxyHasPassword) cfg.password = pw;
    if (!cfg.host) return this.toast('enter host', 'error');
    const r = await this.api('PUT', '/proxy/global', cfg);
    if (r && r.error) return this.toast(r.error, 'error');
    this.toast('saved'); this.loadProxy();
  },
  async clearGlobalProxy() { await this.api('DELETE', '/proxy/global'); ['proxy-host','proxy-port','proxy-user','proxy-pass'].forEach(id => document.getElementById(id).value = ''); this.toast('cleared'); this.loadProxy(); },
  async editAccountProxy(id, label) {
    const v = await this.prompt('configure proxy', `for ${label}`, [
      { name: 'type', label: 'type', type: 'select', value: 'http', options: [{value:'http',label:'HTTP'},{value:'https',label:'HTTPS'},{value:'socks5',label:'SOCKS5'}] },
      { name: 'host', label: 'host', placeholder: 'proxy.host' },
      { name: 'port', label: 'port', type: 'number', placeholder: '8080' },
      { name: 'username', label: 'username', placeholder: 'optional' },
      { name: 'password', label: 'password', type: 'password', placeholder: 'optional' },
    ]);
    if (!v || !v.host) return;
    const r = await this.api('PUT', `/proxy/accounts/${id}`, { type: v.type || 'http', host: v.host, port: parseInt(v.port) || 8080, username: v.username || '', password: v.password || '' });
    if (r && r.error) return this.toast(r.error, 'error');
    this.toast('saved'); this.loadProxy();
  },
  async clearAccountProxy(id) { await this.api('DELETE', `/proxy/accounts/${id}`); this.toast('cleared'); this.loadProxy(); },

  /* ── logs ──
     Uses fetch + X-Dashboard-Password header to align with the production
     SSE contract (server expects header, payload shape `{ts, level, msg}`).
     EventSource cannot set custom headers, so the original sketch wiring
     (?token= query) silently failed against the real backend. */
  loadLogs() {
    if (this.sseConn) { try { this.sseConn.close(); } catch {} this.sseConn = null; }
    this.logEntries = [];
    const c = document.getElementById('log-container');
    if (c) c.innerHTML = '';

    const headers = { 'Accept': 'text/event-stream' };
    if (this.password) headers['X-Dashboard-Password'] = this.password;
    const controller = new AbortController();
    this.sseConn = { close: () => controller.abort() };

    fetch('/dashboard/api/logs/stream', { headers, signal: controller.signal })
      .then(r => {
        if (!r.ok || !r.body) throw new Error('SSE connect failed');
        const reader = r.body.getReader();
        const dec = new TextDecoder();
        let buf = '';
        const pump = () => reader.read().then(({ done, value }) => {
          if (done) return;
          buf += dec.decode(value, { stream: true });
          let idx;
          while ((idx = buf.indexOf('\n\n')) !== -1) {
            const frame = buf.slice(0, idx);
            buf = buf.slice(idx + 2);
            const payload = frame.split('\n').filter(l => l.startsWith('data: ')).map(l => l.slice(6)).join('\n');
            if (!payload) continue;
            try { this.appendLog(JSON.parse(payload)); } catch {}
          }
          return pump();
        });
        return pump();
      })
      .catch(() => { /* silent — abort or transient */ });
  },
  appendLog(e) {
    this.logEntries.push(e);
    if (this.logEntries.length > 5000) this.logEntries.splice(0, this.logEntries.length - 5000);
    this.renderLogEntry(e);
  },
  renderLogEntry(e) {
    const lvl = (document.getElementById('log-level')?.value || '').toLowerCase();
    const search = (document.getElementById('log-search')?.value || '').toLowerCase();
    if (lvl && (e.level || '').toLowerCase() !== lvl) return;
    // Production payload uses `msg`; sketch UI used to read `message`.
    const text = e.msg || e.message || '';
    if (search && !text.toLowerCase().includes(search)) return;
    const c = document.getElementById('log-container');
    if (!c) return;
    const div = document.createElement('div');
    div.className = 'log-entry ' + (e.level || 'info').toLowerCase();
    const ts = e.ts || e.time || Date.now();
    div.innerHTML = `<span class="ts">${new Date(ts).toLocaleTimeString()}</span><span class="lvl">${this.esc((e.level || 'info').toUpperCase())}</span><span>${this.esc(text)}</span>`;
    c.appendChild(div);
    if (document.getElementById('log-tail')?.checked) c.scrollTop = c.scrollHeight;
  },
  applyLogFilter() {
    const c = document.getElementById('log-container'); c.innerHTML = '';
    this.logEntries.forEach(e => this.renderLogEntry(e));
  },
  clearLogs() { this.logEntries = []; document.getElementById('log-container').innerHTML = ''; },

  /* ── stats ── */
  async loadStats() {
    /* Backend response: { totalRequests, successCount, errorCount, startedAt,
       modelCounts: { [model]: { requests, success, errors, totalMs, avgMs, p50Ms, p95Ms } },
       accountCounts: { [aid]: { requests, success, errors } },
       hourlyBuckets: [ { hour, requests, errors } ] }.
       Original sketch read r.hourly / r.models / r.accounts which were never sent. */
    const r = await this.api('GET', '/stats');
    if (r?.success === false) return;
    const total = r.totalRequests || 0;
    const success = r.successCount || 0;
    const errors = r.errorCount || 0;
    const successRate = total > 0 ? Math.round((success / total) * 1000) / 10 : 0;

    // Aggregate latency across models for the top card.
    const modelEntries = Object.entries(r.modelCounts || {}).map(([model, s]) => ({
      model,
      requests: s.requests || 0,
      success: s.success || 0,
      errors: s.errors || 0,
      successRate: s.requests > 0 ? Math.round((s.success / s.requests) * 1000) / 10 : 0,
      avg: s.avgMs || 0,
      p50: s.p50Ms || 0,
      p95: s.p95Ms || 0,
    })).sort((a, b) => b.requests - a.requests);
    const grandTotalMs = Object.values(r.modelCounts || {}).reduce((n, s) => n + (s.totalMs || 0), 0);
    const avgLatency = total > 0 ? Math.round(grandTotalMs / total) : 0;
    const sortedP50 = modelEntries.map(m => m.p50).filter(Boolean).sort((a, b) => a - b);
    const sortedP95 = modelEntries.map(m => m.p95).filter(Boolean).sort((a, b) => a - b);
    const overallP50 = sortedP50.length ? sortedP50[Math.floor(sortedP50.length / 2)] : 0;
    const overallP95 = sortedP95.length ? sortedP95[Math.floor(sortedP95.length * 0.95)] || sortedP95[sortedP95.length - 1] : 0;

    document.getElementById('stats-cards').innerHTML = `
      ${this.metric({ label: 'total requests', value: total, sub: '', kind: 'accent' })}
      ${this.metric({ label: 'success', value: success, sub: `${successRate}%`, kind: 'good' })}
      ${this.metric({ label: 'errors', value: errors, sub: '', kind: 'error' })}
      ${this.metric({ label: 'avg latency', value: avgLatency + ' ms', sub: `p50 ${overallP50} · p95 ${overallP95}` })}
    `;

    // Hourly bucket bar strip — backend ships `hourlyBuckets[*].requests` not `count`.
    // Slice window follows the segmented range radio: 24h / 7d / 30d.
    const range = document.querySelector('#stats-range input[name=range]:checked')?.value || '24h';
    const sliceN = range === '7d' ? 168 : range === '30d' ? 720 : 24;
    const buckets = (r.hourlyBuckets || []).slice(-sliceN);
    const maxV = Math.max(1, ...buckets.map(b => b.requests || 0));
    document.getElementById('stats-bars').innerHTML = buckets.map(b => {
      const h = ((b.requests || 0) / maxV) * 96;
      const t = b.hour ? new Date(b.hour) : null;
      const label = t
        ? (range === '24h'
            ? t.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
            : t.toLocaleString([], { month: '2-digit', day: '2-digit', hour: '2-digit' }))
        : '';
      return `<div class="bar-wrap"><div class="b-bar ${b.errors > 0 ? 'has-errors' : ''}" style="height:${h}%" title="${label} · ${b.requests || 0} req · ${b.errors || 0} err"></div></div>`;
    }).join('') || '<div class="text-mute italic" style="margin:auto">no data yet</div>';

    const tbody = document.querySelector('#model-stats-table tbody');
    tbody.innerHTML = modelEntries.map(m => `<tr>
      <td><code>${this.esc(m.model)}</code></td>
      <td>${m.requests}</td><td>${m.success}</td><td>${m.errors}</td>
      <td>${m.successRate}%</td>
      <td>${m.avg} ms</td><td>${m.p50}</td><td>${m.p95}</td>
    </tr>`).join('') || `<tr class="empty-row"><td colspan="8">no model data yet</td></tr>`;

    const accountEntries = Object.entries(r.accountCounts || {}).map(([id, s]) => ({
      id,
      requests: s.requests || 0,
      success: s.success || 0,
      errors: s.errors || 0,
      successRate: s.requests > 0 ? Math.round((s.success / s.requests) * 1000) / 10 : 0,
    })).sort((a, b) => b.requests - a.requests);
    const tbody2 = document.querySelector('#account-stats-table tbody');
    tbody2.innerHTML = accountEntries.map(a => `<tr>
      <td><code>${this.esc(a.id.slice(0,8))}</code></td>
      <td>${a.requests}</td><td>${a.success}</td><td>${a.errors}</td>
      <td>${a.successRate}%</td>
    </tr>`).join('') || `<tr class="empty-row"><td colspan="5">no account data yet</td></tr>`;

    // Per-account rows only cover requests that carried an account id;
    // global/unauthenticated paths still count in the total. State the
    // remainder so the table does not silently sum short of the total card.
    const attribution = r.accountAttribution || {};
    const unattributed = Number(attribution.unattributed) || 0;
    const noteEl = document.getElementById('account-stats-note');
    if (noteEl) {
      noteEl.textContent = unattributed > 0
        ? `${unattributed} of ${total} requests have no account attribution (global/unauthenticated paths); they are counted in the total and excluded from the rows above.`
        : '';
    }

    // 30s auto-refresh — production UI keeps this running so the panel
    // stays live without manual reload.
    this.poll('stats', () => this.loadStats(), 30000);
  },

  async resetStats() {
    const ok = await this.confirm('reset stats', 'This wipes all accumulated counters. Continue?', { okText: 'reset', danger: true });
    if (!ok) return;
    try {
      await this.api('DELETE', '/stats');
      this.toast('stats reset', 'success');
      this.loadStats();
    } catch (e) {
      this.toast(e.message, 'error');
    }
  },

  /* ── bans ── derive from /accounts; the sketch's original `/bans` 404'd. */
  async loadBans() {
    const d = await this.api('GET', '/accounts?view=summary&filter=flagged&pageSize=200');
    if (d?.success === false) return;
    const accounts = d.accounts || [];
    const errored = accounts.filter(a => a.status === 'error' || (a.errorCount || 0) > 0);
    const rateLimited = accounts.filter(a => a.rateLimited);
    const disabled = accounts.filter(a => a.status === 'error');
    const total = d.stats?.total ?? d.total ?? accounts.length;
    const healthy = Math.max(0, total - (d.stats?.flagged ?? errored.length));
    document.getElementById('ban-cards').innerHTML = `
      ${this.metric({ label: 'abnormal', value: errored.length, kind: 'error' })}
      ${this.metric({ label: 'disabled', value: disabled.length, kind: 'warn' })}
      ${this.metric({ label: 'rate-limited', value: rateLimited.length, kind: 'warn' })}
      ${this.metric({ label: 'healthy', value: healthy, kind: 'good' })}
    `;
    const tbody = document.querySelector('#ban-table tbody');
    tbody.innerHTML = accounts.map(a => {
      const flagged = a.status === 'error' || (a.errorCount || 0) > 0;
      return `<tr style="${flagged ? 'background:rgba(181,50,36,.04)' : ''}">
        <td>${this.esc(a.email)} <code class="text-xs">${this.esc((a.id||'').slice(0,8))}</code></td>
        <td><span class="badge ${a.status}">${a.status}</span></td>
        <td style="color:${(a.errorCount||0) > 0 ? 'var(--error)' : 'inherit'};font-family:'JetBrains Mono',monospace">${a.errorCount || 0}</td>
        <td class="text-sm">${a.lastUsed ? new Date(a.lastUsed).toLocaleString() : '—'}</td>
        <td><div class="btn-group">
          ${(a.errorCount||0) > 0 ? `<button class="btn btn-ghost btn-xs" onclick="App.resetErrors('${this.escJsAttr(a.id)}')">reset</button>` : ''}
          ${a.status === 'error' ? `<button class="btn btn-good btn-xs" onclick="App.toggleAccount('${this.escJsAttr(a.id)}','active')">enable</button>` : ''}
        </div></td>
      </tr>`;
    }).join('') || `<tr class="empty-row"><td colspan="5">all healthy</td></tr>`;
    this.poll('bans', () => this.loadBans(), 30000);
  },

  /* ── experimental ──
     Backend serves cascadeConversationReuse under `flags.*` and accepts
     `PUT /experimental` for writes + `DELETE /experimental/conversation-pool`
     for clearing. The original sketch wired PATCH and POST /clear-pool which
     returned 404, so toggles silently reverted on next load. */
  async loadExperimental() {
    // v2.0.56: refresh credentials snapshot when the panel opens.
    this.loadCredentials();
    const r = await this.api('GET', '/experimental');
    // Keep the last good panel on a failed read instead of showing the flag as
    // off and the pool as empty (#257).
    if (r?.success === false) return;
    const flag = r.flags?.cascadeConversationReuse ?? r.cascadeConversationReuse;
    const cb = document.getElementById('exp-cascade-reuse');
    if (cb) cb.checked = !!flag;
    const pool = r.conversationPool || {};
    document.getElementById('exp-pool-cards').innerHTML = `
      ${this.metric({ label: 'pool size', value: pool.size || 0 })}
      ${this.metric({ label: 'hits', value: pool.hits || 0, kind: 'good' })}
      ${this.metric({ label: 'misses', value: pool.misses || 0, kind: 'warn' })}
      ${this.metric({ label: 'hit rate', value: (pool.hitRate || '0.0') + '%' })}
    `;
    // Reflect current skin cookie in the dropdown so reload state is visible.
    const skinSel = document.getElementById('skin-select');
    if (skinSel) {
      const m = document.cookie.match(/(?:^|;\s*)dashboard_skin=([^;]+)/);
      const cur = m ? decodeURIComponent(m[1]) : 'modern';
      skinSel.value = (cur === 'sketch') ? 'sketch' : 'modern';
    }
    // System prompts editor — moved here so the experimental panel matches
    // the production UI. Render is best-effort: if /system-prompts is gone
    // (older backend) just hide the section silently.
    await this.loadSystemPrompts();
  },

  /* ── credentials (v2.0.56) — runtime-rotatable API_KEY + dashboard pw */
  async loadCredentials() {
    try {
      const d = await this.api('GET', '/settings/credentials');
      // Do not relabel a live credential as "unset" because the read failed (#257).
      if (d?.success === false) return;
      const ks = document.getElementById('credentials-apikey-source');
      const km = document.getElementById('credentials-apikey-masked');
      const ds = document.getElementById('credentials-dashboardpw-source');
      const dst = document.getElementById('credentials-dashboardpw-status');
      if (ks) ks.textContent = '[' + (d.apiKeySource || 'unset') + ']';
      if (km) km.textContent = d.apiKey_masked || '(unset)';
      if (ds) ds.textContent = '[' + (d.dashboardPasswordSource || 'unset') + ']';
      if (dst) dst.textContent = d.dashboardPasswordSet ? '已设置' : '未设置';
    } catch (e) { /* panel not mounted yet */ }
  },
  toggleApiKeyVisibility() {
    const el = document.getElementById('credentials-apikey-input');
    if (el) el.type = el.type === 'password' ? 'text' : 'password';
  },
  toggleDashboardPwVisibility() {
    const el = document.getElementById('credentials-dashboardpw-input');
    if (el) el.type = el.type === 'password' ? 'text' : 'password';
  },
  async saveCredential(field) {
    const inputId = field === 'apiKey' ? 'credentials-apikey-input' : 'credentials-dashboardpw-input';
    const el = document.getElementById(inputId);
    if (!el) return;
    const value = el.value;
    const cleared = value === '';
    const label = field === 'apiKey' ? 'API_KEY' : 'Dashboard 密码';
    const okConfirm = confirm(`${label}: ${cleared ? '清除运行时覆盖,回退 .env 值?' : '改完旧值立即失效,要继续吗?'}`);
    if (!okConfirm) return;
    try {
      const r = await this.api('PUT', '/settings/credentials', { [field]: value });
      el.value = '';
      this.toast(r.success ? 'saved' : '保存失败');
      if (field === 'dashboardPassword' && !cleared) {
        sessionStorage.removeItem('dashboard_password');
        setTimeout(() => location.reload(), 800);
        return;
      }
      this.loadCredentials();
    } catch (e) {
      this.toast('保存失败: ' + (e.message || ''));
    }
  },
  async toggleExperimental(key, val) {
    await this.api('PUT', '/experimental', { [key]: val });
    this.toast(`${key}: ${val}`);
    this.loadExperimental();
  },
  async clearConversationPool() {
    await this.api('DELETE', '/experimental/conversation-pool');
    this.toast('pool cleared');
    this.loadExperimental();
  },

  /* ── system prompts editor — moved over from the production UI so the
     experimental panel exposes the same prompt template controls. */
  async loadSystemPrompts() {
    const holder = document.getElementById('system-prompts-editor');
    if (!holder) return;
    const sp = await this.api('GET', '/system-prompts');
    // A failed read must not replace the editor with the "no editable prompts"
    // placeholder — that says the backend has none, which is a different claim (#257).
    if (sp?.success === false) return;
    const prompts = sp?.prompts || {};
    const keys = Object.keys(prompts);
    if (!keys.length) {
      holder.innerHTML = '<div class="text-mute italic">no editable prompts on this backend</div>';
      return;
    }
    holder.innerHTML = keys.map(k => {
      const keyArg = this.escJsAttr(k);
      const promptId = this.systemPromptDomId(k);
      return `
      <div style="border:1px dashed var(--rule-soft);padding:12px">
        <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px">
          <div><code style="color:var(--accent);font-weight:600">${this.esc(k)}</code></div>
          <div class="field-row-actions" style="margin:0">
            <button class="btn btn-ghost btn-xs" onclick="App.resetSystemPrompt('${keyArg}')">reset</button>
            <button class="btn btn-primary btn-xs" onclick="App.saveSystemPrompt('${keyArg}')">save</button>
          </div>
        </div>
        <textarea id="${promptId}" class="input mono" rows="3" style="width:100%;resize:vertical">${this.esc(prompts[k] || '')}</textarea>
      </div>
    `;
    }).join('');
  },
  async saveSystemPrompt(key) {
    const ta = document.getElementById(this.systemPromptDomId(key));
    if (!ta) return;
    await this.api('PUT', '/system-prompts', { [key]: ta.value.trim() });
    this.toast(`${key} saved`);
  },
  async resetSystemPrompt(key) {
    const ok = await this.confirm('reset prompt', `restore default for ${key}?`, { okText: 'reset' });
    if (!ok) return;
    await this.api('DELETE', `/system-prompts/${encodeURIComponent(key)}`);
    this.toast(`${key} reset`);
    this.loadExperimental();
  },

  /* ── credits page ── */
  CONTRIBUTORS_CACHE: null,
  async fetchContributors() {
    if (this.CONTRIBUTORS_CACHE) return this.CONTRIBUTORS_CACHE;
    try {
      const r = await fetch('/dashboard/data/contributors.json');
      if (!r.ok) throw new Error(`status ${r.status}`);
      const data = await r.json();
      this.CONTRIBUTORS_CACHE = Array.isArray(data?.contributors) ? data.contributors : [];
    } catch (e) {
      console.error('fetchContributors failed:', e);
      this.CONTRIBUTORS_CACHE = [];
    }
    return this.CONTRIBUTORS_CACHE;
  },
  weightRank(w) { return ({ 'S+': 6, 'S': 5, 'A+': 4, 'A': 3, 'B+': 2, 'B': 1 })[w] || 0; },
  contributorRarity(weight) {
    if (weight === 'S+') return 'UR';
    if (weight === 'S' || weight === 'A+') return 'SSR';
    return 'SR';
  },
  groupContributors(list) {
    const map = new Map();
    for (const c of list) {
      if (!map.has(c.login)) map.set(c.login, { login: c.login, prs: [] });
      map.get(c.login).prs.push(c);
    }
    const groups = [];
    for (const g of map.values()) {
      g.prs.sort((a, b) => (b.mergedAt || '').localeCompare(a.mergedAt || ''));
      g.latest = g.prs[0];
      g.topWeight = g.prs.reduce((best, p) => this.weightRank(p.weight) > this.weightRank(best) ? p.weight : best, '');
      groups.push(g);
    }
    groups.sort((a, b) =>
      this.weightRank(b.topWeight) - this.weightRank(a.topWeight)
      || (b.latest.mergedAt || '').localeCompare(a.latest.mergedAt || '')
    );
    return groups;
  },
  async loadCredits() {
    const body = document.getElementById('credits-body');
    if (!body) return;
    const list = await this.fetchContributors();
    if (!list.length) {
      body.innerHTML = `<div class="text-mute italic">no contributors</div>`;
      return;
    }
    const groups = this.groupContributors(list);
    body.innerHTML = `<div style="display:flex;flex-direction:column;gap:14px">${groups.map(g => {
      const latest = g.latest;
      const count = g.prs.length;
      const rarity = this.contributorRarity(g.topWeight);
      const histItems = g.prs.map(p => {
        const r = this.contributorRarity(p.weight);
        return `<li style="margin:8px 0;padding-left:10px;border-left:1.5px solid var(--rule-soft)">
          <div style="display:flex;gap:8px;align-items:baseline;flex-wrap:wrap">
            <a class="serif" style="color:var(--ink);text-decoration:underline dotted" target="_blank" rel="noopener" href="https://github.com/dwgx/WindsurfAPI/pull/${p.pr}">PR #${p.pr}</a>
            <span class="text-mute text-xs">${this.esc(p.mergedAt || '')}</span>
            ${p.weight ? `<span class="stamp" style="font-size:11px;padding:1px 8px;margin-top:0">${this.esc(r)}</span>` : ''}
            ${p.weightLabel ? `<span class="text-mute text-xs">${this.esc(p.weightLabel)}</span>` : ''}
          </div>
          <div style="margin-top:3px">${this.esc(p.title)}</div>
          <div class="text-mute text-sm" style="margin-top:3px">${this.esc(p.summary)}</div>
        </li>`;
      }).join('');
      return `<div style="display:flex;gap:14px;padding:14px;border:1px dashed var(--rule-soft);background:var(--paper)">
        <img src="https://github.com/${this.esc(g.login)}.png?size=128" alt="${this.esc(g.login)}" loading="lazy" onerror="this.style.visibility='hidden'" style="width:56px;height:56px;border-radius:50%;border:1.5px solid var(--ink);flex-shrink:0">
        <div style="flex:1;min-width:0">
          <div style="display:flex;gap:10px;align-items:baseline;flex-wrap:wrap">
            <a href="https://github.com/${this.esc(g.login)}" target="_blank" rel="noopener" class="serif" style="font-size:20px;color:var(--ink);text-decoration:none">@${this.esc(g.login)}</a>
            ${g.topWeight ? `<span class="stamp" style="font-size:13px;padding:1px 10px;margin-top:0">${this.esc(rarity)}</span>` : ''}
            <span class="text-mute text-sm">×${count}</span>
            <span style="flex:1"></span>
            <a class="text-sm" style="color:var(--ink);text-decoration:underline dotted" target="_blank" rel="noopener" href="https://github.com/dwgx/WindsurfAPI/pull/${latest.pr}">PR #${latest.pr}</a>
            <span class="text-mute text-xs">${this.esc(latest.mergedAt || '')}</span>
          </div>
          <div class="serif" style="margin-top:6px;font-size:16px">${this.esc(latest.title)}</div>
          <details style="margin-top:6px">
            <summary class="text-mute text-sm" style="cursor:pointer">${count > 1 ? `${count} 次贡献 · 展开历史` : '查看详情'}</summary>
            <ol style="list-style:none;padding:0;margin:8px 0 0">${histItems}</ol>
          </details>
        </div>
      </div>`;
    }).join('')}</div>`;
  },
};

document.addEventListener('DOMContentLoaded', () => App.init());
</script>
</body>
</html>