W / src /dashboard /index.html
Ac66's picture
Upload folder using huggingface_hub
2b64d42 verified
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title data-i18n="pageTitle.main">WindsurfAPI bydwgx1337 控制台</title>
<style>
:root {
--bg: #09090b;
--bg-elev: #0d0d10;
--surface: #111114;
--surface-2: #17171c;
--surface-3: #1c1c22;
--border: #26262e;
--border-strong: #32323c;
--text: #f4f4f5;
--text-muted: #a1a1aa;
--text-dim: #71717a;
--accent: #6366f1;
--accent-hover: #7c7ff5;
--accent-soft: rgba(99, 102, 241, .14);
--success: #22c55e;
--success-soft: rgba(34, 197, 94, .14);
--warn: #f59e0b;
--warn-soft: rgba(245, 158, 11, .14);
--error: #ef4444;
--error-soft: rgba(239, 68, 68, .14);
--info: #3b82f6;
--info-soft: rgba(59, 130, 246, .14);
--radius: 10px;
--radius-sm: 6px;
--radius-lg: 14px;
--shadow: 0 1px 2px rgba(0,0,0,.4), 0 4px 12px rgba(0,0,0,.2);
--shadow-lg: 0 10px 40px rgba(0,0,0,.4);
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', system-ui, sans-serif;
--mono: 'JetBrains Mono', 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
*::before, *::after { box-sizing: border-box; }
html, body { height: 100%; }
body {
font-family: var(--font);
background: var(--bg);
color: var(--text);
font-size: 14px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
display: flex;
min-height: 100vh;
}
/* Scrollbar */
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--surface-3); border-radius: 5px; border: 2px solid var(--bg); }
::-webkit-scrollbar-thumb:hover { background: var(--border-strong); }
a { color: var(--accent); text-decoration: none; }
code {
font-family: var(--mono);
font-size: 12px;
background: var(--surface-2);
color: var(--text);
padding: 2px 6px;
border-radius: 4px;
border: 1px solid var(--border);
}
/* ─── Sidebar ──────────────────────────────────── */
.sidebar {
width: 232px;
background: var(--surface);
border-right: 1px solid var(--border);
position: fixed;
top: 0; left: 0; bottom: 0;
display: flex;
flex-direction: column;
z-index: 10;
}
.sidebar .brand {
padding: 20px 22px 18px;
display: flex;
align-items: center;
gap: 10px;
border-bottom: 1px solid var(--border);
}
.sidebar .brand-logo {
width: 32px; height: 32px;
border-radius: 8px;
background: linear-gradient(135deg, var(--accent) 0%, #8b5cf6 100%);
display: flex; align-items: center; justify-content: center;
font-weight: 800; color: #fff; font-size: 15px;
box-shadow: 0 4px 12px rgba(99,102,241,.35);
}
.sidebar .brand-name { font-size: 15px; font-weight: 600; letter-spacing: -.01em; }
.sidebar .brand-sub { font-size: 11px; color: var(--text-dim); margin-top: 1px; }
.sidebar nav { flex: 1; padding: 12px 10px; overflow-y: auto; }
.sidebar nav .nav-group { margin-bottom: 18px; }
.sidebar nav .nav-group-label {
font-size: 10px;
text-transform: uppercase;
letter-spacing: .08em;
color: var(--text-dim);
padding: 0 12px 6px;
font-weight: 600;
}
.sidebar nav a {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
color: var(--text-muted);
font-size: 13px;
font-weight: 500;
border-radius: var(--radius-sm);
transition: all .15s;
margin-bottom: 2px;
}
.sidebar nav a:hover { color: var(--text); background: var(--surface-2); }
.sidebar nav a.active {
color: var(--text);
background: var(--surface-3);
box-shadow: inset 2px 0 0 var(--accent);
}
.sidebar nav a svg { width: 16px; height: 16px; stroke-width: 2; flex-shrink: 0; }
.sidebar .footer {
padding: 12px 18px;
font-size: 11px;
color: var(--text-dim);
border-top: 1px solid var(--border);
display: flex;
justify-content: space-between;
align-items: center;
}
.sidebar .footer .ver { font-family: var(--mono); }
/* ─── Main Layout ─────────────────────────────── */
.main {
margin-left: 232px;
flex: 1;
padding: 28px 36px 40px;
min-height: 100vh;
/* max-width: 1400px; — auto-width for long email rows, prevent action columns from squeezing */
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24px;
gap: 16px;
flex-wrap: wrap;
}
.page-title {
font-size: 22px;
font-weight: 700;
letter-spacing: -.02em;
}
.page-subtitle {
font-size: 13px;
color: var(--text-muted);
margin-top: 2px;
}
.panel { display: none; animation: fadeIn .2s ease; }
.panel.active { display: block; }
@keyframes fadeIn {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
/* ─── Cards ───────────────────────────────────── */
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 18px 20px;
transition: border-color .15s;
}
.card:hover { border-color: var(--border-strong); }
.card-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 4px;
}
.card-title {
font-size: 12px;
font-weight: 500;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: .04em;
}
.card-icon {
width: 18px; height: 18px;
color: var(--text-dim);
}
.card-value {
font-size: 26px;
font-weight: 700;
letter-spacing: -.02em;
line-height: 1.2;
margin-top: 6px;
}
.card-sub { font-size: 12px; color: var(--text-muted); margin-top: 4px; }
.card.accent .card-value { color: var(--accent); }
.card.success .card-value { color: var(--success); }
.card.warn .card-value { color: var(--warn); }
.card.error .card-value { color: var(--error); }
.card.info .card-value { color: var(--info); }
.metrics-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 14px;
margin-bottom: 24px;
}
/* ─── Section Block ───────────────────────────── */
.section {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
margin-bottom: 20px;
overflow: hidden;
}
.section-header {
padding: 16px 20px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.section-title {
font-size: 14px;
font-weight: 600;
letter-spacing: -.01em;
}
.section-desc {
font-size: 12px;
color: var(--text-muted);
margin-top: 2px;
font-weight: 400;
}
.section-body { padding: 20px; }
.section-body.tight { padding: 0; }
/* ─── Buttons ─────────────────────────────────── */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 8px 16px;
font-size: 13px;
font-weight: 500;
font-family: var(--font);
border: 1px solid transparent;
border-radius: var(--radius-sm);
cursor: pointer;
transition: all .15s;
background: var(--surface-2);
color: var(--text);
white-space: nowrap;
line-height: 1.2;
}
.btn:hover:not(:disabled) { background: var(--surface-3); border-color: var(--border-strong); }
.btn:active:not(:disabled) { transform: translateY(1px); }
.btn:disabled { opacity: .5; cursor: not-allowed; }
.btn svg { width: 14px; height: 14px; }
.btn-primary {
background: var(--accent);
color: #fff;
border-color: var(--accent);
box-shadow: 0 1px 3px rgba(99,102,241,.3);
}
.btn-primary:hover:not(:disabled) { background: var(--accent-hover); border-color: var(--accent-hover); }
.btn-outline {
background: transparent;
border-color: var(--border-strong);
color: var(--text);
}
.btn-outline:hover:not(:disabled) { background: var(--surface-2); border-color: var(--accent); }
.btn-ghost { background: transparent; border-color: transparent; color: var(--text-muted); }
.btn-ghost:hover:not(:disabled) { background: var(--surface-2); color: var(--text); }
.btn-danger {
background: var(--error);
color: #fff;
border-color: var(--error);
}
.btn-danger:hover:not(:disabled) { background: #dc2626; border-color: #dc2626; }
.btn-success {
background: var(--success);
color: #fff;
border-color: var(--success);
}
.btn-success:hover:not(:disabled) { background: #16a34a; border-color: #16a34a; }
.btn-sm { padding: 5px 10px; font-size: 12px; }
.btn-xs { padding: 3px 8px; font-size: 11px; }
.btn-icon { padding: 7px; }
.btn-group { display: inline-flex; gap: 6px; flex-wrap: wrap; }
.btn.active, .btn.active:hover {
background: var(--accent); color: #fff; border-color: var(--accent);
}
/* ─── Switch ──────────────────────────────────── */
.switch { position: relative; display: inline-block; width: 40px; height: 22px; flex-shrink: 0; }
.switch input { opacity: 0; width: 0; height: 0; }
.switch-slider {
position: absolute; cursor: pointer; inset: 0;
background: var(--surface-2); border: 1px solid var(--border);
border-radius: 22px; transition: .18s;
}
.switch-slider::before {
content: ''; position: absolute; height: 16px; width: 16px;
left: 2px; bottom: 2px; background: var(--text);
border-radius: 50%; transition: .18s;
}
.switch input:checked + .switch-slider {
background: var(--accent); border-color: var(--accent);
}
.switch input:checked + .switch-slider::before { transform: translateX(18px); background: #fff; }
/* ─── Form Controls ───────────────────────────── */
.input, .select, .textarea {
display: block;
width: 100%;
padding: 9px 12px;
font-size: 13px;
font-family: var(--font);
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text);
outline: none;
transition: all .15s;
line-height: 1.35;
}
.input:hover, .select:hover, .textarea:hover { border-color: var(--border-strong); }
.input:focus, .select:focus, .textarea:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
}
.input::placeholder, .textarea::placeholder { color: var(--text-dim); }
.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='%23a1a1aa' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'><polyline points='6 9 12 15 18 9'/></svg>");
background-repeat: no-repeat;
background-position: right 10px center;
padding-right: 32px;
cursor: pointer;
}
.select option { background: var(--surface); color: var(--text); }
.field { display: flex; flex-direction: column; gap: 6px; flex: 1; min-width: 0; }
.field-label {
font-size: 12px;
color: var(--text-muted);
font-weight: 500;
}
.field-hint {
font-size: 12px;
color: var(--text-dim);
line-height: 1.5;
}
.field-row {
display: grid;
gap: 12px;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
align-items: end;
}
.field-row-actions {
display: flex;
gap: 8px;
margin-top: 14px;
flex-wrap: wrap;
}
/* Input group (prefix + input) */
.input-group { display: flex; gap: 6px; align-items: stretch; }
.input-group .input { flex: 1; }
/* Switch / Checkbox */
.checkbox {
display: inline-flex;
align-items: center;
gap: 8px;
cursor: pointer;
font-size: 13px;
color: var(--text-muted);
user-select: none;
}
.checkbox input { position: absolute; opacity: 0; pointer-events: none; }
.checkbox .box {
width: 16px; height: 16px;
border: 1.5px solid var(--border-strong);
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-elev);
transition: all .15s;
flex-shrink: 0;
}
.checkbox input:checked + .box {
background: var(--accent);
border-color: var(--accent);
}
.checkbox input:checked + .box::after {
content: '';
width: 4px; height: 8px;
border: solid #fff;
border-width: 0 2px 2px 0;
transform: rotate(45deg) translateY(-1px);
}
.checkbox:hover .box { border-color: var(--accent); }
/* Tab-style radio group */
.segmented {
display: inline-flex;
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 3px;
gap: 2px;
}
.segmented label {
padding: 6px 14px;
font-size: 12px;
font-weight: 500;
color: var(--text-muted);
cursor: pointer;
border-radius: 5px;
transition: all .15s;
position: relative;
}
.segmented label input { position: absolute; opacity: 0; pointer-events: none; }
.segmented label:hover { color: var(--text); }
.segmented label:has(input:checked) {
background: var(--surface-3);
color: var(--text);
box-shadow: 0 1px 2px rgba(0,0,0,.3);
}
/* ─── Tables ──────────────────────────────────── */
.table-wrap {
overflow-x: auto;
border-radius: var(--radius);
}
table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
thead th {
text-align: left;
padding: 11px 16px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: .04em;
color: var(--text-muted);
background: var(--surface-2);
border-bottom: 1px solid var(--border);
white-space: nowrap;
}
thead th.th-help {
cursor: help;
}
thead th.th-help::after {
content: " ⓘ";
opacity: .5;
font-size: 10px;
margin-left: 3px;
letter-spacing: normal;
}
tbody td {
padding: 11px 16px;
border-bottom: 1px solid var(--border);
vertical-align: middle;
}
tbody tr:last-child td { border-bottom: none; }
tbody tr { transition: background .1s; }
tbody tr:hover td { background: var(--surface-2); }
.empty-row td {
text-align: center;
color: var(--text-dim);
padding: 32px 16px;
font-style: italic;
}
/* ─── Account detail panel (expand-in-row) ──────────── */
.expand-chevron {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: 4px;
border: 1px solid transparent;
background: transparent;
color: var(--text-dim);
cursor: pointer;
transition: all .15s ease;
font-size: 10px;
margin-right: 6px;
vertical-align: middle;
}
.expand-chevron:hover {
color: var(--text);
background: var(--surface-2);
border-color: var(--border);
}
.expand-chevron.open {
color: var(--accent);
transform: rotate(90deg);
}
.account-detail-row > td {
padding: 0 !important;
background: #0d0d10 !important;
border-bottom: 1px solid var(--border) !important;
}
.account-detail-wrap {
padding: 20px 28px;
background: linear-gradient(180deg, rgba(99,102,241,.04), transparent 80%);
}
.detail-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-bottom: 14px;
}
@media (max-width: 1100px) { .detail-grid { grid-template-columns: 1fr; } }
.detail-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
padding: 14px 16px;
}
.detail-card-head {
display: flex;
align-items: center;
gap: 8px;
font-size: 11px;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: .06em;
margin-bottom: 10px;
}
.detail-card-head .dot {
width: 6px; height: 6px;
border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 6px var(--accent);
}
.detail-kv {
display: grid;
grid-template-columns: 90px 1fr;
gap: 4px 14px;
font-size: 12.5px;
line-height: 1.6;
}
.detail-kv > .k { color: var(--text-dim); }
.detail-kv > .v { color: var(--text); font-variant-numeric: tabular-nums; }
.detail-kv code { font-size: 11px; padding: 1px 5px; }
.detail-bar {
display: grid;
grid-template-columns: 60px 1fr 50px;
align-items: center;
gap: 10px;
margin-bottom: 6px;
font-size: 12px;
}
.detail-bar .bar-label { color: var(--text-dim); font-size: 11px; }
.detail-bar .bar-track {
height: 7px;
border-radius: 4px;
background: var(--surface-2);
overflow: hidden;
border: 1px solid var(--border);
}
.detail-bar .bar-fill {
height: 100%;
border-radius: 3px;
transition: width .6s cubic-bezier(.2,.8,.2,1);
}
.detail-bar .bar-pct {
text-align: right;
color: var(--text);
font-family: var(--mono);
font-size: 11px;
font-weight: 600;
}
.detail-bar .bar-reset {
grid-column: 2 / 4;
font-size: 10.5px;
color: var(--text-dim);
font-family: var(--mono);
margin-top: 2px;
}
/* Model list grouped by provider */
.model-list-card { grid-column: 1 / -1; }
.model-group {
margin-bottom: 14px;
}
.model-group:last-child { margin-bottom: 0; }
.model-group-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
padding-bottom: 5px;
border-bottom: 1px dashed var(--border);
}
.model-group-name {
font-size: 11px;
font-weight: 600;
color: var(--text);
letter-spacing: .02em;
}
.model-group-count {
font-size: 10px;
color: var(--text-dim);
font-family: var(--mono);
}
.model-chips {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 6px;
}
.model-chip {
display: grid;
grid-template-columns: 10px 1fr auto;
align-items: center;
gap: 8px;
padding: 6px 10px;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: 6px;
font-size: 11.5px;
transition: all .15s ease;
}
.model-chip:hover {
border-color: var(--border-strong);
background: var(--surface-3);
}
.model-chip.blocked {
opacity: .45;
text-decoration: line-through;
text-decoration-color: var(--text-dim);
text-decoration-thickness: 1px;
}
.model-chip-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--success);
}
.model-chip.blocked .model-chip-dot { background: var(--error); }
.model-chip-name {
font-family: var(--mono);
font-size: 11px;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.model-chip-cost {
font-family: var(--mono);
font-size: 10px;
color: var(--warn);
padding: 1px 6px;
background: rgba(245,158,11,.08);
border: 1px solid rgba(245,158,11,.2);
border-radius: 10px;
white-space: nowrap;
font-weight: 600;
}
.provider-swatch {
width: 14px; height: 14px;
border-radius: 3px;
display: inline-block;
margin-right: 6px;
vertical-align: middle;
}
/* ─── Badges ──────────────────────────────────── */
.badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 999px;
font-size: 11px;
font-weight: 600;
line-height: 1.5;
border: 1px solid transparent;
}
.badge::before {
content: '';
width: 6px; height: 6px;
border-radius: 50%;
background: currentColor;
}
.badge.active, .badge.running, .badge.success {
background: var(--success-soft);
color: var(--success);
}
.badge.error, .badge.stopped {
background: var(--error-soft);
color: var(--error);
}
.badge.disabled, .badge.muted {
background: var(--surface-3);
color: var(--text-muted);
}
.badge.warn {
background: var(--warn-soft);
color: var(--warn);
}
.badge.info, .badge.pro {
background: var(--info-soft);
color: var(--info);
}
.badge.no-dot::before { display: none; }
/* Tier pill */
.tier {
display: inline-flex;
align-items: center;
padding: 2px 10px;
border-radius: 5px;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: .04em;
}
.tier.pro { background: linear-gradient(135deg, #6366f1, #8b5cf6); color: #fff; }
.tier.free { background: var(--info-soft); color: var(--info); }
.tier.expired { background: var(--error-soft); color: var(--error); }
.tier.unknown { background: var(--surface-3); color: var(--text-muted); }
/* ─── LS Pool Cards ───────────────────────────── */
.ls-pool {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 12px;
}
.ls-inst {
padding: 14px 16px;
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
font-size: 12px;
}
.ls-inst .ls-key {
font-weight: 600;
color: var(--text);
font-size: 13px;
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 6px;
}
.ls-inst .ls-dot {
width: 8px; height: 8px;
border-radius: 50%;
background: var(--success);
box-shadow: 0 0 8px var(--success);
}
.ls-inst .ls-dot.pending { background: var(--warn); box-shadow: 0 0 8px var(--warn); }
.ls-inst .ls-meta { color: var(--text-dim); font-family: var(--mono); font-size: 11px; margin-top: 2px; }
/* ─── Model Chips ─────────────────────────────── */
.model-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin: 10px 0;
}
.model-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 11px;
border-radius: 6px;
font-size: 12px;
border: 1px solid var(--border);
background: var(--bg-elev);
color: var(--text-muted);
cursor: pointer;
transition: all .15s;
font-weight: 500;
}
.model-chip:hover {
border-color: var(--accent);
color: var(--text);
}
.model-chip.selected {
background: var(--accent-soft);
border-color: var(--accent);
color: var(--accent);
}
.model-chip .remove {
color: var(--error);
font-weight: 700;
cursor: pointer;
padding: 0 2px;
}
.provider-group { margin-bottom: 16px; }
.provider-label {
font-size: 10px;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: .08em;
margin-bottom: 6px;
font-weight: 600;
}
/* ─── Log Viewer ──────────────────────────────── */
.log-container {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
height: 520px;
overflow-y: auto;
font-family: var(--mono);
font-size: 12px;
line-height: 1.6;
}
.log-entry {
padding: 3px 16px;
border-bottom: 1px solid rgba(255,255,255,.02);
display: flex;
gap: 10px;
align-items: baseline;
word-break: break-all;
}
.log-entry:hover { background: var(--surface-2); }
.log-entry .ts { color: var(--text-dim); flex-shrink: 0; }
.log-entry .lvl {
font-weight: 700;
flex-shrink: 0;
width: 50px;
}
.log-entry.debug .lvl { color: var(--text-dim); }
.log-entry.info .lvl { color: var(--info); }
.log-entry.warn .lvl { color: var(--warn); }
.log-entry.error .lvl { color: var(--error); }
.log-entry.error { background: rgba(239,68,68,.03); }
.log-controls {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
margin-bottom: 14px;
}
.log-controls .input, .log-controls .select { width: auto; }
.log-controls .search { flex: 1; min-width: 200px; }
/* ─── Chart ───────────────────────────────────── */
.chart {
padding: 22px 24px 20px;
background: linear-gradient(135deg, var(--surface), rgba(59,130,246,.03));
border: 1px solid var(--border);
border-radius: var(--radius);
margin-bottom: 20px;
position: relative;
overflow: hidden;
}
.chart::before {
content: "";
position: absolute;
top: 0; left: 0; right: 0;
height: 2px;
background: linear-gradient(90deg, #3b82f6, #a78bfa, #3b82f6);
background-size: 200% 100%;
animation: chart-shimmer 3s linear infinite;
}
@keyframes chart-shimmer { 0%{background-position:200% 0} 100%{background-position:0 0} }
.chart h3 { font-size: 14px; font-weight: 600; margin: 0; letter-spacing: -.01em; }
.chart-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
margin-bottom: 18px;
}
.chart-title-group { min-width: 0; flex: 1; }
.chart-subtitle {
font-size: 12px;
color: var(--text-dim);
margin-top: 4px;
letter-spacing: .01em;
}
.chart-controls {
display: flex;
gap: 8px;
flex-wrap: wrap;
align-items: center;
}
/* Segmented button group — for chart-type / range switches */
.seg-group {
display: inline-flex;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: 8px;
padding: 3px;
gap: 2px;
}
.seg-btn {
padding: 5px 11px;
font-size: 12px;
font-weight: 500;
color: var(--text-muted);
background: transparent;
border: none;
border-radius: 6px;
cursor: pointer;
transition: all .18s ease;
font-family: inherit;
letter-spacing: -.005em;
white-space: nowrap;
}
.seg-btn:hover {
color: var(--text);
background: rgba(255,255,255,.03);
}
.seg-btn.active {
color: #fff;
background: linear-gradient(135deg, #3b82f6, #6366f1);
box-shadow: 0 1px 3px rgba(59,130,246,.35), 0 0 0 0.5px rgba(255,255,255,.06) inset;
}
/* Main chart canvas */
.chart-canvas-wrap {
position: relative;
width: 100%;
height: 280px;
padding-top: 6px;
}
.chart-canvas-wrap canvas {
width: 100%;
height: 100%;
display: block;
}
.chart-range-indicator {
position: absolute;
top: 8px;
right: 8px;
font-size: 11px;
color: var(--text-dim);
font-family: var(--mono);
padding: 3px 8px;
background: rgba(255,255,255,.02);
border: 1px solid var(--border);
border-radius: 6px;
pointer-events: none;
letter-spacing: .01em;
}
/* Custom date range popup */
.custom-range-popup {
position: relative;
margin-top: 14px;
padding: 18px;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: 10px;
max-width: 420px;
}
.custom-range-title {
font-size: 13px;
font-weight: 600;
margin-bottom: 14px;
color: var(--text);
}
.custom-range-fields {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
margin-bottom: 10px;
}
.custom-range-fields label {
display: flex;
flex-direction: column;
gap: 5px;
font-size: 11px;
color: var(--text-dim);
font-weight: 500;
letter-spacing: .01em;
}
.custom-range-fields input[type="date"] {
font-family: var(--mono);
font-size: 12px;
color-scheme: dark;
}
.custom-range-hint {
font-size: 11px;
color: var(--text-dim);
margin-bottom: 14px;
}
.custom-range-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
}
.chart-tooltip {
position: absolute;
pointer-events: none;
background: rgba(17,17,24,.96);
color: var(--text);
padding: 8px 12px;
border-radius: 8px;
font-size: 12px;
line-height: 1.55;
border: 1px solid var(--border-strong);
box-shadow: 0 8px 28px rgba(0,0,0,.5);
backdrop-filter: blur(10px);
opacity: 0;
transform: translate(-50%, 0);
transition: opacity .12s ease, left .08s ease-out;
z-index: 5;
white-space: nowrap;
}
.chart-tooltip.show { opacity: 1; }
.chart-tooltip .tt-time { color: var(--text-dim); font-size: 11px; margin-bottom: 4px; }
.chart-tooltip .tt-row { display: flex; align-items: center; gap: 6px; }
.chart-tooltip .tt-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.chart-tooltip .tt-label { color: var(--text-dim); margin-right: 4px; }
.chart-tooltip .tt-value { font-weight: 600; font-variant-numeric: tabular-nums; }
/* Chart legend */
.chart-legend {
display: flex;
gap: 18px;
justify-content: center;
margin-top: 14px;
padding-top: 14px;
border-top: 1px solid var(--border);
flex-wrap: wrap;
}
.chart-legend-item {
display: flex;
align-items: center;
gap: 7px;
font-size: 12px;
color: var(--text-muted);
cursor: pointer;
user-select: none;
transition: opacity .15s ease;
}
.chart-legend-item.muted { opacity: .4; }
.chart-legend-item:hover { color: var(--text); }
.chart-legend-swatch {
width: 14px; height: 4px;
border-radius: 2px;
}
.chart-legend-swatch.dot {
width: 10px; height: 10px;
border-radius: 50%;
}
.chart-legend-value {
font-family: var(--mono);
font-size: 11px;
color: var(--text-dim);
font-variant-numeric: tabular-nums;
}
/* Secondary chart grid (pie etc.) */
.chart-grid {
display: grid;
grid-template-columns: 1fr;
gap: 20px;
margin-bottom: 20px;
}
@media (min-width: 960px) {
.chart-grid { grid-template-columns: 1fr; }
}
.chart-pie-body {
display: grid;
grid-template-columns: 200px 1fr;
gap: 28px;
align-items: center;
}
@media (max-width: 680px) {
.chart-pie-body { grid-template-columns: 1fr; }
}
.chart-pie-body canvas {
width: 200px;
height: 200px;
display: block;
margin: 0 auto;
}
.pie-legend {
display: flex;
flex-direction: column;
gap: 8px;
font-size: 12px;
}
.pie-legend-item {
display: grid;
grid-template-columns: 12px 1fr auto auto;
gap: 10px;
align-items: center;
padding: 6px 10px;
border-radius: 6px;
transition: background .15s ease;
}
.pie-legend-item:hover { background: var(--surface-2); }
.pie-legend-dot {
width: 10px; height: 10px; border-radius: 3px;
}
.pie-legend-name {
font-family: var(--mono);
font-size: 11px;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.pie-legend-count {
font-variant-numeric: tabular-nums;
color: var(--text-muted);
font-size: 11px;
}
.pie-legend-pct {
font-variant-numeric: tabular-nums;
color: var(--text-dim);
font-size: 11px;
min-width: 38px;
text-align: right;
}
.bars {
display: flex;
align-items: flex-end;
gap: 4px;
height: 160px;
padding-bottom: 4px;
border-bottom: 1px solid rgba(255,255,255,.05);
}
.bar-wrap {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
height: 100%;
justify-content: flex-end;
position: relative;
}
.bar {
width: 100%;
min-width: 6px;
max-width: 28px;
background: linear-gradient(to top, rgba(59,130,246,.6), rgba(96,165,250,.9));
border-radius: 4px 4px 1px 1px;
transition: all .3s cubic-bezier(.4,0,.2,1);
cursor: pointer;
position: relative;
}
.bar::after {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
background: linear-gradient(to top, transparent 60%, rgba(255,255,255,.12));
pointer-events: none;
}
.bar.has-errors {
background: linear-gradient(to top, rgba(239,68,68,.6), rgba(251,146,60,.9));
}
.bar-wrap:hover .bar {
box-shadow: 0 0 16px rgba(96,165,250,.4), inset 0 0 8px rgba(255,255,255,.08);
transform: scaleY(1.02) scaleX(1.08);
filter: brightness(1.15);
}
.bar-wrap:hover .bar.has-errors {
box-shadow: 0 0 16px rgba(239,68,68,.4), inset 0 0 8px rgba(255,255,255,.08);
}
.bar-label {
font-size: 9px;
color: var(--text-dim);
margin-top: 6px;
font-family: var(--mono);
opacity: .6;
transition: opacity .2s;
}
.bar-wrap:hover .bar-label { opacity: 1; color: var(--text); }
.bar-tooltip {
display: none;
position: absolute;
bottom: calc(100% + 8px);
left: 50%;
transform: translateX(-50%);
background: rgba(15,15,25,.95);
color: #e0e0e0;
padding: 8px 12px;
border-radius: 8px;
font-size: 12px;
line-height: 1.5;
white-space: nowrap;
pointer-events: none;
z-index: 10;
box-shadow: 0 8px 24px rgba(0,0,0,.5);
border: 1px solid rgba(255,255,255,.08);
backdrop-filter: blur(8px);
}
.bar-tooltip b { color: #60a5fa; }
.bar-tooltip::after {
content: "";
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 6px solid transparent;
border-top-color: rgba(15,15,25,.95);
}
.bar-wrap:hover .bar-tooltip { display: block; animation: tt-in .15s ease; }
@keyframes tt-in { from{opacity:0;transform:translateX(-50%) translateY(4px)} to{opacity:1;transform:translateX(-50%) translateY(0)} }
/* ─── Toast ───────────────────────────────────── */
.toast-stack {
position: fixed;
bottom: 24px;
right: 24px;
display: flex;
flex-direction: column-reverse;
gap: 10px;
z-index: 9999;
pointer-events: none;
}
.toast {
padding: 12px 18px;
border-radius: var(--radius-sm);
font-size: 13px;
background: var(--surface);
border: 1px solid var(--border);
box-shadow: var(--shadow-lg);
animation: slideIn .25s ease;
pointer-events: auto;
max-width: 400px;
display: flex;
align-items: center;
gap: 10px;
}
.toast.success { border-left: 3px solid var(--success); }
.toast.error { border-left: 3px solid var(--error); }
.toast.info { border-left: 3px solid var(--info); }
@keyframes slideIn {
from { opacity: 0; transform: translateX(20px); }
to { opacity: 1; transform: translateX(0); }
}
/* ─── Modal ───────────────────────────────────── */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,.7);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
animation: fadeIn .15s ease;
}
.modal {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
width: 90%;
max-width: 440px;
box-shadow: var(--shadow-lg);
overflow: hidden;
animation: modalIn .2s cubic-bezier(.16,1,.3,1);
}
@keyframes modalIn {
from { opacity: 0; transform: translateY(-10px) scale(.98); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
.modal-header {
padding: 20px 24px 12px;
}
.modal-title { font-size: 16px; font-weight: 600; }
.modal-desc { font-size: 13px; color: var(--text-muted); margin-top: 4px; }
.modal-body { padding: 12px 24px 20px; }
.modal-footer {
padding: 14px 24px;
background: var(--surface-2);
display: flex;
justify-content: flex-end;
gap: 8px;
border-top: 1px solid var(--border);
}
/* ─── Login Overlay ───────────────────────────── */
.login-overlay {
position: fixed;
inset: 0;
background: radial-gradient(ellipse at center, rgba(99,102,241,.08) 0%, var(--bg) 70%);
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
}
.login-box {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 36px 32px;
width: 380px;
box-shadow: var(--shadow-lg);
}
.login-box .login-logo {
width: 56px; height: 56px;
margin: 0 auto 18px;
border-radius: 14px;
background: linear-gradient(135deg, var(--accent) 0%, #8b5cf6 100%);
display: flex; align-items: center; justify-content: center;
font-weight: 800; font-size: 22px; color: #fff;
box-shadow: 0 10px 30px rgba(99,102,241,.35);
}
.login-box h3 { text-align: center; font-size: 18px; margin-bottom: 8px; }
.login-box p { text-align: center; font-size: 13px; color: var(--text-muted); margin-bottom: 22px; }
.login-box .input { margin-bottom: 14px; }
.login-box .btn { width: 100%; padding: 11px; font-weight: 600; }
/* ─── Spinner ─────────────────────────────────── */
.spinner {
display: inline-block;
width: 14px; height: 14px;
border: 2px solid rgba(255,255,255,.2);
border-top-color: currentColor;
border-radius: 50%;
animation: spin .7s linear infinite;
vertical-align: middle;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* ─── Capability Markers ──────────────────────── */
.cap-list { display: inline-flex; flex-wrap: wrap; gap: 4px; }
.cap-item {
font-size: 10px;
padding: 1px 7px;
border-radius: 4px;
font-family: var(--mono);
font-weight: 600;
}
.cap-item.ok { background: var(--success-soft); color: var(--success); }
.cap-item.fail { background: var(--error-soft); color: var(--error); text-decoration: line-through; }
/* ─── Utilities ───────────────────────────────── */
.flex { display: flex; }
.flex-col { display: flex; flex-direction: column; }
.items-center { align-items: center; }
.justify-between { justify-content: space-between; }
.gap-2 { gap: 8px; } .gap-3 { gap: 12px; } .gap-4 { gap: 16px; }
.mt-2 { margin-top: 8px; } .mt-3 { margin-top: 12px; } .mt-4 { margin-top: 16px; }
.text-muted { color: var(--text-muted); }
.text-dim { color: var(--text-dim); }
.text-sm { font-size: 12px; }
.text-xs { font-size: 11px; }
.nowrap { white-space: nowrap; }
.hidden { display: none !important; }
.break-all { word-break: break-all; }
/* ─── Responsive ──────────────────────────────── */
@media (max-width: 900px) {
.sidebar { width: 60px; }
.sidebar .brand-name, .sidebar .brand-sub, .sidebar nav a span, .sidebar .footer .ver, .sidebar .nav-group-label { display: none; }
.sidebar .brand { justify-content: center; padding: 20px 0; }
.sidebar nav a { justify-content: center; padding: 10px; }
.main { margin-left: 60px; padding: 20px 16px; }
}
.contributor-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(360px, 1fr)); gap: 12px; }
.contributor-card { display: flex; gap: 14px; padding: 14px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); transition: border-color .15s; min-width: 0; }
.contributor-card:hover { border-color: var(--accent); }
.contributor-card .avatar { width: 48px; height: 48px; border-radius: 50%; flex: none; border: 2px solid var(--border); background: var(--surface-2); object-fit: cover; }
.contributor-card .meta { display: flex; flex-direction: column; gap: 4px; flex: 1; min-width: 0; }
.contributor-card .meta-top { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; align-items: start; }
.contributor-card .identity { display: flex; align-items: center; gap: 8px; min-width: 0; flex-wrap: wrap; }
.contributor-card .links { display: flex; align-items: center; gap: 6px; justify-content: flex-end; flex-wrap: wrap; }
.contributor-card .meta-top .name { color: var(--accent); font-weight: 600; font-size: 15px; text-decoration: none; }
.contributor-card .meta-top .name:hover { text-decoration: underline; }
.contributor-card .meta-top .pr-link { font-size: 12px; color: var(--text-dim); text-decoration: none; padding: 2px 8px; border: 1px solid var(--border); border-radius: 4px; }
.contributor-card .meta-top .pr-link:hover { color: var(--accent); border-color: var(--accent); }
.contributor-card .meta-top .merged-at { font-size: 11px; color: var(--text-dim); }
.contributor-card .pr-title { font-weight: 500; margin-top: 2px; font-size: 13px; line-height: 1.45; }
.contributor-card .summary-toggle { margin-top: 6px; font-size: 12px; color: var(--text-dim); }
.contributor-card .summary-toggle summary { cursor: pointer; width: fit-content; user-select: none; }
.contributor-card .summary-toggle summary:hover { color: var(--accent); }
.contributor-card .summary { font-size: 13px; color: var(--text-muted); line-height: 1.65; margin-top: 6px; }
.contributor-card .rarity-badge { display: inline-flex; align-items: center; justify-content: center; min-width: 38px; padding: 2px 8px; border-radius: 4px; font-size: 10px; font-weight: 800; letter-spacing: 0.7px; font-family: var(--font-mono, ui-monospace, monospace); line-height: 1.5; }
/* v2.0.60 — extended rarity ladder. Rarer = more dramatic visual:
GOD glows with animated rainbow (project creator only); MR pulses cyan;
LR has a cool prism gradient; UR keeps the existing fire palette. */
.contributor-card .rarity-GOD {
background: linear-gradient(110deg, #ffd700 0%, #ff6b9d 25%, #c084fc 50%, #60a5fa 75%, #34d399 100%);
background-size: 220% 220%;
color: #1a1208;
text-shadow: 0 0 6px rgba(255,255,255,0.55);
font-weight: 900;
box-shadow: 0 0 0 1px rgba(255,215,0,0.55), 0 0 14px rgba(192,132,252,0.4);
animation: rarity-god-pulse 4s ease-in-out infinite;
}
@keyframes rarity-god-pulse {
0%, 100% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
}
.contributor-card .rarity-MR {
background: linear-gradient(135deg, #06b6d4 0%, #8b5cf6 50%, #ec4899 100%);
color: #fff;
font-weight: 800;
box-shadow: 0 0 0 1px rgba(139,92,246,0.45), 0 0 10px rgba(6,182,212,0.25);
}
.contributor-card .rarity-LR {
background: linear-gradient(135deg, #a855f7 0%, #6366f1 50%, #06b6d4 100%);
color: #fff;
font-weight: 800;
box-shadow: 0 0 0 1px rgba(99,102,241,0.42);
}
.contributor-card .rarity-UR { background: linear-gradient(135deg, #f5d06f, #e11d48 52%, #7c3aed); color: #fff; box-shadow: 0 0 0 1px rgba(225,29,72,0.28); }
.contributor-card .rarity-SSR { background: linear-gradient(135deg, #f59e0b, #b45309); color: #fff7ed; box-shadow: 0 0 0 1px rgba(180,83,9,0.25); }
.contributor-card .rarity-SR { background: var(--info-soft); color: var(--info); border: 1px solid rgba(59,130,246,0.28); }
.contributor-card .rarity-R { background: var(--surface-2); color: var(--text-muted); border: 1px solid var(--border); }
.contributor-card .contribution-count { font-size: 11px; color: var(--text-dim); padding: 2px 8px; border: 1px solid var(--border); border-radius: 4px; line-height: 1.5; }
.contributor-card .history-list { list-style: none; padding: 0; margin: 6px 0 0; display: flex; flex-direction: column; gap: 8px; }
.contributor-card .history-item { padding: 10px 12px; border: 1px solid var(--border); border-radius: 4px; background: var(--surface-2); }
.contributor-card .history-head { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.contributor-card .history-head .pr-link { font-size: 12px; color: var(--text-dim); text-decoration: none; padding: 2px 8px; border: 1px solid var(--border); border-radius: 4px; }
.contributor-card .history-head .pr-link:hover { color: var(--accent); border-color: var(--accent); }
.contributor-card .history-head .merged-at { font-size: 11px; color: var(--text-dim); }
.contributor-card .history-rarity-label { font-size: 11px; color: var(--text-dim); font-style: italic; flex: 1; min-width: 0; }
.contributor-card .history-title { font-size: 13px; font-weight: 500; margin-top: 6px; line-height: 1.45; }
.contributor-card .history-summary { font-size: 12px; color: var(--text-muted); line-height: 1.65; margin-top: 6px; }
@media (max-width: 640px) {
.contributor-list { grid-template-columns: 1fr; }
.contributor-card { padding: 12px; gap: 12px; }
.contributor-card .avatar { width: 40px; height: 40px; }
.contributor-card .meta-top { grid-template-columns: 1fr; }
.contributor-card .links { justify-content: flex-start; }
}
</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-logo">W</div>
<div>
<div class="brand-name">WindsurfAPI <span style="font-size:10px;opacity:.6;vertical-align:top;margin-left:2px">bydwgx1337</span></div>
<div class="brand-sub" data-i18n="brand.sub">管理控制台</div>
</div>
</div>
<nav>
<div class="nav-group">
<div class="nav-group-label" data-i18n="nav.group.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-i18n="nav.overview">仪表盘</span>
</a>
<a href="#stats" data-panel="stats">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></svg>
<span data-i18n="nav.stats">统计分析</span>
</a>
</div>
<div class="nav-group">
<div class="nav-group-label" data-i18n="nav.group.account">账号</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-i18n="nav.windsurf-login">登录取号</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-i18n="nav.accounts">账号管理</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-i18n="nav.bans">异常监测</span>
</a>
</div>
<div class="nav-group">
<div class="nav-group-label" data-i18n="nav.group.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-i18n="nav.models">模型控制</span>
</a>
<a href="#proxy" data-panel="proxy">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M17 1l4 4-4 4"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><path d="M7 23l-4-4 4-4"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/></svg>
<span data-i18n="nav.proxy">代理配置</span>
</a>
<a href="#logs" data-panel="logs">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>
<span data-i18n="nav.logs">运行日志</span>
</a>
<a href="#experimental" data-panel="experimental">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M10 2v7.31"/><path d="M14 9.3V2"/><path d="M8.5 2h7"/><path d="M14 9.3a6.5 6.5 0 1 1-4 0"/></svg>
<span data-i18n="nav.experimental">实验性功能</span>
</a>
</div>
<div class="nav-group">
<div class="nav-group-label" data-i18n="nav.group.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-i18n="nav.credits">致谢</span>
</a>
</div>
</nav>
<div class="footer">
<button id="lang-toggle-btn" class="btn btn-ghost btn-xs" onclick="App.toggleLang()" title="Switch language" style="margin-right:8px">
<span id="lang-indicator">中文</span>
</button>
<span>© Windsurf</span>
<span class="ver" id="sidebar-ver" data-i18n-title="footer.version" title="版本">v1.2.0</span>
</div>
</aside>
<!-- ════════ Main ════════ -->
<main class="main">
<!-- Overview -->
<section class="panel active" id="p-overview">
<div class="page-header">
<div>
<h1 class="page-title" data-i18n="page.overview">仪表盘</h1>
<div class="page-subtitle" data-i18n="pageSubtitle.overview">系统运行状态与关键指标概览</div>
</div>
<div class="btn-group">
<button class="btn btn-outline btn-sm" id="btn-check-update" onclick="App.checkUpdate()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M21 12a9 9 0 0 1-9 9 9 9 0 0 1-6.24-2.52L3 21"/><path d="M3 12a9 9 0 0 1 9-9 9 9 0 0 1 6.24 2.52L21 3"/><path d="M21 3v6h-6"/><path d="M3 21v-6h6"/></svg>
<span data-i18n="action.checkUpdate">检查更新</span>
</button>
<button class="btn btn-primary btn-sm hidden" id="btn-apply-update" onclick="App.applyUpdate()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M12 2v20M2 12h20"/></svg>
<span id="btn-apply-update-text" data-i18n="action.applyUpdate">一键更新并重启</span>
</button>
</div>
</div>
<div id="update-status" class="section hidden" style="padding:12px;margin-bottom:12px"></div>
<div class="metrics-grid" id="overview-cards"></div>
<div class="section">
<div class="section-header">
<div>
<div class="section-title" data-i18n="section.lsStatus.title">Language Server</div>
<div class="section-desc" data-i18n="section.lsStatus.desc">语言服务器实例状态</div>
</div>
<div style="display:flex;gap:8px">
<button class="btn btn-outline btn-sm" onclick="App.updateLsBinary()" title="Download latest language_server binary and restart pool">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
<span data-i18n="action.updateLsBinary">更新 LS</span>
</button>
<button class="btn btn-outline btn-sm" onclick="App.restartLs()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
<span data-i18n="action.restart">重启</span>
</button>
</div>
</div>
<div class="section-body">
<div id="ls-status"></div>
<div id="ls-binary-info" style="margin-top:12px;font-size:12px;color:var(--text-muted)"></div>
</div>
</div>
</section>
<!-- Windsurf Login -->
<section class="panel" id="p-windsurf-login">
<div class="page-header">
<div>
<h1 class="page-title" data-i18n="page.windsurf-login">登录取号</h1>
<div class="page-subtitle" data-i18n="pageSubtitle.windsurf-login">支持 Google / GitHub / 邮箱密码 三种方式登录 Windsurf 获取 API Key</div>
</div>
</div>
<div class="section">
<div class="section-header">
<div>
<div class="section-title" data-i18n="section.oauthLogin.title">快捷登录(推荐)</div>
<div class="section-desc" data-i18n="section.oauthLogin.desc">用你的 Google 或 GitHub 账号直接登录 Windsurf 无需密码</div>
</div>
</div>
<div class="section-body">
<div style="display:flex;gap:12px;flex-wrap:wrap">
<button class="btn" onclick="App.oauthLogin('google')" id="oauth-google-btn" style="background:#4285F4;color:#fff;padding:10px 24px;font-weight:600;display:flex;align-items:center;gap:8px">
<svg width="18" height="18" viewBox="0 0 48 48"><path fill="#FFC107" d="M43.6 20.1H42V20H24v8h11.3C33.9 33.1 29.3 36 24 36c-6.6 0-12-5.4-12-12s5.4-12 12-12c3.1 0 5.8 1.2 8 3l5.7-5.7C34 6 29.3 4 24 4 13 4 4 13 4 24s9 20 20 20 20-9 20-20c0-1.3-.2-2.7-.4-3.9z"/><path fill="#FF3D00" d="M6.3 14.7l6.6 4.8C14.5 15.5 18.8 12 24 12c3.1 0 5.8 1.2 8 3l5.7-5.7C34 6 29.3 4 24 4 16.3 4 9.7 8.3 6.3 14.7z"/><path fill="#4CAF50" d="M24 44c5.2 0 9.9-2 13.4-5.2l-6.2-5.2C29.2 35.1 26.7 36 24 36c-5.2 0-9.6-3.6-11.2-8.5l-6.5 5C9.5 39.6 16.2 44 24 44z"/><path fill="#1976D2" d="M43.6 20.1H42V20H24v8h11.3c-.8 2.2-2.2 4.1-4.1 5.6l6.2 5.2C37 39.2 44 34 44 24c0-1.3-.2-2.7-.4-3.9z"/></svg>
<span data-i18n="oauth.google">Google 登录</span>
</button>
<button class="btn" onclick="App.oauthLogin('github')" id="oauth-github-btn" style="background:#24292e;color:#fff;padding:10px 24px;font-weight:600;display:flex;align-items:center;gap:8px">
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M12 0C5.37 0 0 5.37 0 12c0 5.3 3.44 9.8 8.2 11.38.6.11.82-.26.82-.58v-2.03c-3.34.73-4.04-1.61-4.04-1.61-.55-1.39-1.34-1.76-1.34-1.76-1.08-.74.08-.73.08-.73 1.2.08 1.84 1.23 1.84 1.23 1.07 1.83 2.81 1.3 3.5 1 .1-.78.42-1.3.76-1.6-2.67-.3-5.47-1.33-5.47-5.93 0-1.31.47-2.38 1.24-3.22-.13-.3-.54-1.52.12-3.18 0 0 1-.32 3.3 1.23a11.5 11.5 0 016.02 0c2.28-1.55 3.29-1.23 3.29-1.23.66 1.66.25 2.88.12 3.18.77.84 1.24 1.91 1.24 3.22 0 4.61-2.8 5.63-5.48 5.92.43.37.82 1.1.82 2.22v3.29c0 .32.21.7.82.58C20.56 21.8 24 17.3 24 12 24 5.37 18.63 0 12 0z"/></svg>
<span data-i18n="oauth.github">GitHub 登录</span>
</button>
</div>
<div id="oauth-status" style="margin-top:8px;font-size:12px;color:var(--text-muted)"></div>
</div>
</div>
<div class="section">
<div class="section-header">
<div>
<div class="section-title" data-i18n="section.emailLogin.title">邮箱密码登录</div>
<div class="section-desc" data-i18n="section.emailLogin.desc">仅限邮箱+密码注册的账号 第三方登录请用上面的按钮;支持单个登录或按“邮箱 密码”格式批量导入</div>
</div>
</div>
<div class="section-body">
<div class="field-row">
<div class="field">
<label class="field-label" data-i18n="field.email.label">邮箱</label>
<input id="wl-email" class="input" data-i18n-placeholder="field.email.placeholder" placeholder="your-email@example.com" autocomplete="off">
</div>
<div class="field">
<label class="field-label" data-i18n="field.password.label">密码</label>
<input id="wl-password" class="input" type="password" data-i18n-placeholder="field.password.placeholder" placeholder="••••••••" autocomplete="off" onkeydown="if(event.key==='Enter')App.windsurfLogin()">
</div>
</div>
<div class="field-row-actions">
<button class="btn btn-primary" onclick="App.windsurfLogin()" id="wl-btn">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/><polyline points="10 17 15 12 10 7"/><line x1="15" y1="12" x2="3" y2="12"/></svg>
<span data-i18n="action.login">登录</span>
</button>
<label class="checkbox" style="margin-left:auto">
<input type="checkbox" id="wl-auto-add" checked>
<span class="box"></span>
<span data-i18n="field.autoAdd">登录成功自动加入账号池</span>
</label>
</div>
<div style="margin-top:16px;padding-top:16px;border-top:1px dashed var(--border)">
<label class="field-label" data-i18n="field.batchInput.label">批量导入</label>
<textarea
id="wl-batch-input"
class="textarea"
rows="6"
placeholder="user1@mail.com pass123&#10;http://proxy:8080 user2@mail.com pass456&#10;socks5://user:pass@1.2.3.4:1080 user3@mail.com pass789"
></textarea>
<div class="field-hint" data-i18n="field.batchInput.hint">每行一个账号。格式:<code>[代理] 邮箱 密码</code>。代理可选,支持 http/socks5。会自动绑定代理到对应账号。</div>
<div class="field-row-actions">
<button class="btn btn-outline" onclick="App.pasteWindsurfBatch()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><rect x="8" y="2" width="8" height="4" rx="1" ry="1"/></svg>
<span data-i18n="action.readClipboard">读取剪贴板</span>
</button>
<button class="btn btn-primary" onclick="App.windsurfLoginBatch()" id="wl-batch-btn">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M3 5h18"/><path d="M3 12h18"/><path d="M3 19h18"/></svg>
<span data-i18n="action.batchImport">批量导入并登录</span>
</button>
</div>
</div>
</div>
</div>
<div class="section hidden" id="wl-result"></div>
<div class="section">
<div class="section-header">
<div>
<div class="section-title" data-i18n="section.loginHistory.title">登录历史</div>
<div class="section-desc" data-i18n="section.loginHistory.desc">本地记录最近 50 条登录操作</div>
</div>
</div>
<div class="section-body tight">
<div class="table-wrap">
<table id="wl-history-table">
<thead><tr><th data-i18n="table.header.time">时间</th><th data-i18n="table.header.email">邮箱</th><th data-i18n="table.header.status">状态</th><th data-i18n="table.header.proxy">代理</th><th data-i18n="table.header.action">操作</th></tr></thead>
<tbody></tbody>
</table>
</div>
</div>
</div>
<div class="section" id="wl-proxy-section">
<div class="section-header">
<div>
<div class="section-title" data-i18n="section.loginProxy.title">登录代理(可选)</div>
<div class="section-desc" data-i18n="section.loginProxy.desc">为本次登录指定代理;留空则使用全局代理设置。代理生效后账号的后续聊天请求也会经此代理出站</div>
</div>
</div>
<div class="section-body">
<div class="field-row">
<div class="field" style="max-width:120px">
<label class="field-label" data-i18n="field.type.label">类型</label>
<select id="wl-proxy-type" class="select">
<option value="http">HTTP</option>
<option value="https">HTTPS</option>
<option value="socks5">SOCKS5</option>
</select>
</div>
<div class="field" style="flex:2">
<label class="field-label" data-i18n="field.proxyHost.label">主机</label>
<input id="wl-proxy-host" class="input" data-i18n-placeholder="field.proxyHost.placeholder" placeholder="留空=使用全局">
</div>
<div class="field" style="max-width:110px">
<label class="field-label" data-i18n="field.port.label">端口</label>
<input id="wl-proxy-port" class="input" data-i18n-placeholder="field.port.placeholder" placeholder="8080" type="number">
</div>
<div class="field">
<label class="field-label" data-i18n="field.username.label">用户名</label>
<input id="wl-proxy-user" class="input" data-i18n-placeholder="field.username.placeholder" placeholder="可选">
</div>
<div class="field">
<label class="field-label" data-i18n="field.passwordProxy.label">密码</label>
<input id="wl-proxy-pass" class="input" type="password" data-i18n-placeholder="field.passwordProxy.placeholder" placeholder="可选">
</div>
</div>
<div style="display:flex;gap:8px;align-items:center;margin-top:10px">
<button class="btn btn-outline btn-sm" onclick="App.testLoginProxy()" id="wl-proxy-test-btn">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M9 12l2 2 4-4"/><circle cx="12" cy="12" r="10"/></svg>
<span data-i18n="proxy.test.button">测试代理</span>
</button>
<span id="wl-proxy-test-result" class="text-sm text-muted"></span>
</div>
</div>
</div>
</section>
<!-- Accounts -->
<section class="panel" id="p-accounts">
<div class="page-header">
<div>
<h1 class="page-title" data-i18n="page.accounts">账号管理</h1>
<div class="page-subtitle" data-i18n="pageSubtitle.accounts">维护账号池,探测各账号订阅层级与可用模型</div>
</div>
<div class="btn-group">
<button class="btn btn-outline" onclick="App.refreshAllCredits()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M21 12a9 9 0 0 1-9 9 9 9 0 0 1-6.24-2.52L3 21"/><path d="M3 12a9 9 0 0 1 9-9 9 9 0 0 1 6.24 2.52L21 3"/><path d="M21 3v6h-6"/><path d="M3 21v-6h6"/></svg>
<span data-i18n="action.refresh">刷新余额</span>
</button>
<button class="btn btn-outline" onclick="App.probeAll()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<span data-i18n="action.probe">全部探测</span>
</button>
</div>
</div>
<!-- v2.0.57 Fix 5 — drought mode banner: shown only when every active
account has weekly% < 5. Hidden by default; loadDrought() flips it. -->
<div id="drought-banner" class="banner banner-warn" style="display:none;margin:0 0 16px;padding:14px 18px;border-radius:var(--radius);background:#fff7e6;border:1px solid #ffd591;color:#874d00">
<strong data-i18n="drought.title">配额预警:</strong>
<span id="drought-message" data-i18n="drought.body">所有账号本周配额都低于阈值,建议补账号或等待重置</span>
<span id="drought-detail" class="text-sm text-muted" style="margin-left:8px"></span>
</div>
<div class="section">
<div class="section-header">
<div>
<div class="section-title" data-i18n="section.addAccount.title">添加账号</div>
<div class="section-desc" data-i18n="section.addAccount.desc">支持 API Key 或 Auth Token 两种方式</div>
</div>
</div>
<div class="section-body">
<div style="padding:8px 12px;margin-bottom:12px;background:var(--surface-2);border-radius:var(--radius);font-size:12px;color:var(--text-muted);line-height:1.6">
<span data-i18n="help.tokenGuide">推荐用 Token 方式添加账号 点</span>
<a href="https://windsurf.com/show-auth-token" target="_blank" style="color:var(--accent);font-weight:600">windsurf.com/show-auth-token</a>
<span data-i18n="help.tokenGuideEnd">登录后复制 Token 粘贴到下面就行 所有登录方式(邮箱/Google/GitHub)都能用</span>
</div>
<div class="field-row">
<div class="field" style="max-width:180px">
<label class="field-label" data-i18n="field.type.label">类型</label>
<select id="acc-type" class="select">
<option value="token" selected>Auth Token</option>
<option value="api_key">API Key</option>
</select>
</div>
<div class="field" style="flex:2">
<label class="field-label" data-i18n="field.key.label">Key / Token</label>
<input id="acc-key" class="input" data-i18n-placeholder="field.key.placeholder" placeholder="粘贴 Auth Token(从 windsurf.com/show-auth-token 获取)">
</div>
<div class="field" style="max-width:200px">
<label class="field-label" data-i18n="field.label.label">标签</label>
<input id="acc-label" class="input" data-i18n-placeholder="field.label.placeholder" placeholder="可选">
</div>
</div>
<div class="field-row" style="margin-top:12px">
<div class="field" style="flex:1">
<label class="field-label" data-i18n="field.proxy.label">代理</label>
<input id="acc-proxy" class="input" data-i18n-placeholder="field.proxy.placeholder" placeholder="http://proxy:8080 或 socks5://user:pass@host:port(可选)">
</div>
</div>
<div class="field-hint" style="margin-top:4px" data-i18n="field.proxy.hint">留空则使用全局代理设置</div>
<div class="field-row-actions">
<button class="btn btn-primary" onclick="App.addAccount()" data-i18n="action.add">添加账号</button>
<button id="local-import-btn" class="btn btn-outline" onclick="App.discoverLocalWindsurf()" data-i18n="action.importLocal" title="从本地 Windsurf 客户端读取已登录的凭证(仅本机访问)">从本地 Windsurf 导入</button>
</div>
<div id="local-import-hint" style="margin-top:8px;color:var(--text-muted);font-size:12px;line-height:1.6"></div>
<div id="local-windsurf-result" style="margin-top:12px"></div>
</div>
</div>
<div id="ls-pool-card"></div>
<div class="section">
<div class="section-header">
<div>
<div class="section-title" data-i18n="section.accountList.title">账号列表</div>
<div class="section-desc" data-i18n="section.accountList.desc">点击探测按钮可单独更新某个账号的能力信息</div>
</div>
</div>
<div class="section-body tight">
<div class="table-wrap">
<table id="accounts-table">
<thead><tr><th data-i18n="table.header.id">ID</th><th data-i18n="table.header.label">标签</th><th data-i18n="table.header.tier">层级</th><th data-i18n="table.header.rpm">RPM</th><th class="th-help" data-i18n="table.header.credits" data-i18n-title="creditsBar.columnTooltip" title="配额使用情况&#10;&#10;日 — 每日高级请求剩余百分比(重置频繁)&#10;周 — 每周高级请求剩余百分比(Trial 账号重点看这条)&#10;&#10;数值越高 = 剩余越多 = 越安全&#10;悬停单条进度条可查看重置时间">余额</th><th data-i18n="table.header.models">可用模型</th><th data-i18n="table.header.status">状态</th><th data-i18n="table.header.error">错误</th><th data-i18n="table.header.lastUsed">最后使用</th><th data-i18n="table.header.key">Key</th><th data-i18n="table.header.action">操作</th></tr></thead>
<tbody></tbody>
</table>
</div>
</div>
</div>
</section>
<!-- Models -->
<section class="panel" id="p-models">
<div class="page-header">
<div>
<h1 class="page-title" data-i18n="page.models">模型控制</h1>
<div class="page-subtitle" data-i18n="pageSubtitle.models">配置模型访问策略,限制可用模型范围</div>
</div>
</div>
<div class="section">
<div class="section-header">
<div>
<div class="section-title" data-i18n="section.modelPolicy.title">访问策略</div>
<div class="section-desc" data-i18n="section.modelPolicy.desc">选择"全部允许"不限制;"白名单"只允许列出的模型;"黑名单"屏蔽列出的模型</div>
</div>
</div>
<div class="section-body">
<div class="segmented" id="model-mode-group">
<label><input type="radio" name="model-mode" value="all" onchange="App.setModelMode(this.value)"><span data-i18n="model.mode.all">全部允许</span></label>
<label><input type="radio" name="model-mode" value="allowlist" onchange="App.setModelMode(this.value)"><span data-i18n="model.mode.allowlist">白名单</span></label>
<label><input type="radio" name="model-mode" value="blocklist" onchange="App.setModelMode(this.value)"><span data-i18n="model.mode.blocklist">黑名单</span></label>
</div>
</div>
</div>
<div class="section hidden" id="model-list-section">
<div class="section-header">
<div>
<div class="section-title" id="model-list-title" data-i18n="section.modelList.title">模型清单</div>
<div class="section-desc" id="model-list-hint"></div>
</div>
</div>
<div class="section-body">
<div class="field-row">
<div class="field">
<label class="field-label" data-i18n="field.searchModel.label">搜索模型</label>
<input id="model-search" class="input" data-i18n-placeholder="field.searchModel.placeholder" placeholder="输入模型名称筛选..." oninput="App.filterModels()">
</div>
<div class="field" style="max-width:220px">
<label class="field-label" data-i18n="field.provider.label">供应商</label>
<select id="model-provider-filter" class="select" onchange="App.filterModels()">
<option value="" data-i18n="field.provider.all">全部供应商</option>
<option value="anthropic">Anthropic</option>
<option value="openai">OpenAI</option>
<option value="google">Google</option>
<option value="deepseek">DeepSeek</option>
<option value="xai">xAI</option>
<option value="alibaba">Alibaba</option>
<option value="moonshot">Moonshot</option>
<option value="windsurf">Windsurf</option>
</select>
</div>
</div>
<div id="model-list-current" class="mt-4"></div>
<div id="model-chips-container" class="mt-4"></div>
</div>
</div>
</section>
<!-- Proxy -->
<section class="panel" id="p-proxy">
<div class="page-header">
<div>
<h1 class="page-title" data-i18n="page.proxy">代理配置</h1>
<div class="page-subtitle" data-i18n="pageSubtitle.proxy">全局或按账号配置出口代理,独立代理将启动独立的语言服务器实例</div>
</div>
</div>
<div class="section">
<div class="section-header">
<div>
<div class="section-title" data-i18n="section.globalProxy.title">全局代理</div>
<div class="section-desc" data-i18n="section.globalProxy.desc">未配置独立代理的账号将使用此配置</div>
</div>
</div>
<div class="section-body">
<div class="field-row">
<div class="field" style="max-width:140px">
<label class="field-label" data-i18n="field.type.label">类型</label>
<select id="proxy-type" class="select">
<option value="http">HTTP</option>
<option value="https">HTTPS</option>
<option value="socks5">SOCKS5</option>
</select>
</div>
<div class="field">
<label class="field-label" data-i18n="field.host.label">主机</label>
<input id="proxy-host" class="input" data-i18n-placeholder="field.host.placeholder" placeholder="proxy.example.com">
</div>
<div class="field" style="max-width:140px">
<label class="field-label" data-i18n="field.port.label">端口</label>
<input id="proxy-port" class="input" type="number" data-i18n-placeholder="field.port.placeholder" placeholder="8080">
</div>
</div>
<div class="field-row mt-3">
<div class="field">
<label class="field-label" data-i18n="field.username.label">用户名</label>
<input id="proxy-user" class="input" data-i18n-placeholder="field.proxyUser.placeholder" placeholder="代理认证用户名">
</div>
<div class="field">
<label class="field-label" data-i18n="field.passwordProxy.label">密码</label>
<div class="input-group">
<input id="proxy-pass" class="input" type="password" data-i18n-placeholder="field.proxyPass.placeholder" placeholder="代理认证密码">
<button class="btn btn-outline btn-icon" onclick="const el=document.getElementById('proxy-pass');el.type=el.type==='password'?'text':'password'" data-i18n-title="action.showPassword" title="显示/隐藏密码">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
</button>
</div>
</div>
</div>
<div class="field-row-actions">
<button class="btn btn-primary" onclick="App.saveGlobalProxy()" data-i18n="action.save">Save</button>
<button class="btn btn-outline" onclick="App.clearGlobalProxy()" data-i18n="action.clear">Clear</button>
<span id="proxy-current" class="text-sm text-muted" style="margin-left:auto;align-self:center"></span>
</div>
</div>
</div>
<div class="section">
<div class="section-header">
<div>
<div class="section-title" data-i18n="section.accountProxy.title">账号独立代理</div>
<div class="section-desc" data-i18n="section.accountProxy.desc">为特定账号设置专属代理;每个独立代理会启动独立 LS 实例</div>
</div>
</div>
<div class="section-body tight">
<div class="table-wrap">
<table id="proxy-accounts-table">
<thead><tr><th data-i18n="table.header.account">账号</th><th data-i18n="table.header.proxy">代理</th><th data-i18n="table.header.action">操作</th></tr></thead>
<tbody></tbody>
</table>
</div>
</div>
</div>
</section>
<!-- Logs -->
<section class="panel" id="p-logs">
<div class="page-header">
<div>
<h1 class="page-title" data-i18n="page.logs">运行日志</h1>
<div class="page-subtitle" data-i18n="pageSubtitle.logs">通过 SSE 实时流式接收服务端日志</div>
</div>
</div>
<div class="log-controls">
<select id="log-level" class="select" style="width:130px" onchange="App.filterLogs()">
<option value="" data-i18n="log.level.all">所有级别</option>
<option value="debug">Debug</option>
<option value="info">Info</option>
<option value="warn">Warn</option>
<option value="error">Error</option>
</select>
<input id="log-search" class="input search" data-i18n-placeholder="log.searchPlaceholder" placeholder="搜索日志内容..." oninput="App.debouncedFilterLogs()">
<label class="checkbox">
<input type="checkbox" id="log-autoscroll" checked>
<span class="box"></span>
<span data-i18n="field.autoScroll">自动滚动</span>
</label>
<button class="btn btn-outline btn-sm" onclick="App.clearLogView()" data-i18n="log.clearView">清空视图</button>
<!-- v2.0.60 — log export. Type select drives which slice to dump
(api requests vs system housekeeping vs both); format selects
machine-readable jsonl vs human-readable txt. -->
<select id="log-export-type" class="select" style="width:130px">
<option value="all" data-i18n="log.export.all">全部日志</option>
<option value="api" data-i18n="log.export.api">API 请求日志</option>
<option value="system" data-i18n="log.export.system">系统运行日志</option>
</select>
<select id="log-export-format" class="select" style="width:120px">
<option value="jsonl">JSONL</option>
<option value="txt" data-i18n="log.export.txt">纯文本</option>
</select>
<button class="btn btn-primary btn-sm" onclick="App.exportLogs()" data-i18n="log.export.btn">下载日志</button>
</div>
<div class="log-container" id="log-container"></div>
</section>
<!-- Stats -->
<section class="panel" id="p-stats">
<div class="page-header">
<div>
<h1 class="page-title" data-i18n="page.stats">统计分析</h1>
<div class="page-subtitle" data-i18n="pageSubtitle.stats">请求量、成功率、延迟分位数与账号/模型维度统计</div>
</div>
<div class="btn-group">
<button class="btn btn-ghost btn-sm" onclick="App.loadStats()" data-i18n-title="action.refresh" title="刷新">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M21 12a9 9 0 0 1-9 9 9 9 0 0 1-6.24-2.52L3 21"/><path d="M3 12a9 9 0 0 1 9-9 9 9 0 0 1 6.24 2.52L21 3"/><path d="M21 3v6h-6"/><path d="M3 21v-6h6"/></svg>
<span data-i18n="action.refresh">刷新</span>
</button>
<button class="btn btn-danger btn-sm" onclick="App.resetStats()" data-i18n="action.reset">重置统计</button>
</div>
</div>
<div class="metrics-grid" id="stats-cards"></div>
<div class="chart chart-main">
<div class="chart-header">
<div class="chart-title-group">
<h3 data-i18n="section.requestChart.title">请求量时间序列</h3>
<div class="chart-subtitle" data-i18n="section.requestChart.subtitle">切换图表类型和时间范围 · 悬停查看具体数值</div>
</div>
<div class="chart-controls">
<div class="seg-group" role="tablist">
<button class="seg-btn stats-type-btn" data-type="area" onclick="App.setChartType('area')" data-i18n="section.requestChart.typeArea">面积图</button>
<button class="seg-btn stats-type-btn" data-type="line" onclick="App.setChartType('line')" data-i18n="section.requestChart.typeLine">折线图</button>
<button class="seg-btn stats-type-btn active" data-type="bar" onclick="App.setChartType('bar')" data-i18n="section.requestChart.typeBar">柱状图</button>
<button class="seg-btn stats-type-btn" data-type="stacked" onclick="App.setChartType('stacked')" data-i18n="section.requestChart.typeStacked">堆叠图</button>
</div>
<div class="seg-group" role="tablist">
<button class="seg-btn stats-range-btn" data-range="6" onclick="App.setStatsRange(6)" data-i18n="range.6h">6小时</button>
<button class="seg-btn stats-range-btn active" data-range="24" onclick="App.setStatsRange(24)" data-i18n="range.24h">24小时</button>
<button class="seg-btn stats-range-btn" data-range="168" onclick="App.setStatsRange(168)" data-i18n="range.7d">7天</button>
<button class="seg-btn stats-range-btn" data-range="720" onclick="App.setStatsRange(720)" data-i18n="range.30d">30天</button>
<button class="seg-btn stats-range-btn" data-range="all" onclick="App.setStatsRange('all')" data-i18n="range.all">全部</button>
<button class="seg-btn stats-range-btn" data-range="custom" onclick="App.openCustomRange()" data-i18n="range.custom">自定义</button>
</div>
</div>
</div>
<div class="chart-canvas-wrap">
<canvas id="stats-canvas"></canvas>
<div class="chart-tooltip" id="stats-tooltip"></div>
<div class="chart-range-indicator" id="stats-range-indicator"></div>
</div>
<div class="custom-range-popup" id="custom-range-popup" style="display:none">
<div class="custom-range-title" data-i18n="range.customTitle">自定义日期范围</div>
<div class="custom-range-fields">
<label>
<span data-i18n="range.customStart">起始日期</span>
<input type="date" id="range-start" class="input">
</label>
<label>
<span data-i18n="range.customEnd">结束日期</span>
<input type="date" id="range-end" class="input">
</label>
</div>
<div class="custom-range-hint" data-i18n="range.customHint">最多显示最近 30 天数据</div>
<div class="custom-range-actions">
<button class="btn btn-ghost btn-sm" onclick="App.closeCustomRange()" data-i18n="range.customCancel">取消</button>
<button class="btn btn-primary btn-sm" onclick="App.applyCustomRange()" data-i18n="range.customApply">应用</button>
</div>
</div>
<div class="chart-legend" id="stats-legend"></div>
</div>
<div class="chart-grid">
<div class="chart chart-pie">
<div class="chart-header">
<div class="chart-title-group">
<h3 data-i18n="section.modelPie.title">模型请求量分布</h3>
<div class="chart-subtitle" data-i18n="section.modelPie.desc">前 8 个模型的请求量占比</div>
</div>
</div>
<div class="chart-pie-body" style="position:relative">
<canvas id="model-pie-canvas"></canvas>
<div class="chart-tooltip" id="model-pie-tooltip"></div>
<div class="pie-legend" id="model-pie-legend"></div>
</div>
</div>
</div>
<div class="section">
<div class="section-header">
<div>
<div class="section-title" data-i18n="section.modelStats.title">模型使用统计</div>
<div class="section-desc" data-i18n="section.modelStats.desc">按模型聚合:请求量、成功率、平均耗时与 p50 / p95 延迟分位数</div>
</div>
</div>
<div class="section-body tight">
<div class="table-wrap">
<table id="model-stats-table">
<thead><tr><th data-i18n="table.header.model">模型</th><th data-i18n="table.header.requests">请求</th><th data-i18n="table.header.success">成功</th><th data-i18n="table.header.errors">错误</th><th data-i18n="table.header.successRate">成功率</th><th data-i18n="table.header.avg">平均</th><th data-i18n="table.header.p50">p50</th><th data-i18n="table.header.p95">p95</th></tr></thead>
<tbody></tbody>
</table>
</div>
</div>
</div>
<div class="section">
<div class="section-header">
<div>
<div class="section-title" data-i18n="section.accountStats.title">账号维度统计</div>
<div class="section-desc" data-i18n="section.accountStats.desc">每个账号 ID 前 8 位的请求量与成功率</div>
</div>
</div>
<div class="section-body tight">
<div class="table-wrap">
<table id="account-stats-table">
<thead><tr><th data-i18n="table.header.accountId">账号 ID</th><th data-i18n="table.header.requests">请求</th><th data-i18n="table.header.success">成功</th><th data-i18n="table.header.errors">错误</th><th data-i18n="table.header.successRate">成功率</th></tr></thead>
<tbody></tbody>
</table>
</div>
</div>
</div>
</section>
<!-- Bans -->
<section class="panel" id="p-bans">
<div class="page-header">
<div>
<h1 class="page-title" data-i18n="page.bans">异常监测</h1>
<div class="page-subtitle" data-i18n="pageSubtitle.bans">追踪错误账号和异常状态</div>
</div>
</div>
<div class="metrics-grid" id="ban-cards"></div>
<div class="section">
<div class="section-header">
<div class="section-title" data-i18n="section.banStatus.title">账号健康状况</div>
</div>
<div class="section-body tight">
<div class="table-wrap">
<table id="ban-table">
<thead><tr><th data-i18n="table.header.account">账号</th><th data-i18n="table.header.status">状态</th><th data-i18n="table.header.errorCount">错误数</th><th data-i18n="table.header.lastUsed">最后使用</th><th data-i18n="table.header.action">操作</th></tr></thead>
<tbody></tbody>
</table>
</div>
</div>
</div>
</section>
<section class="panel" id="p-credits">
<div class="page-header">
<div>
<h1 class="page-title" data-i18n="page.credits">致谢</h1>
<div class="page-subtitle" data-i18n="page.credits.sub">感谢以下朋友为项目提交 PR / 审计代码 / 修复问题</div>
</div>
</div>
<div class="section">
<div class="section-header">
<div class="section-title" data-i18n="credits.contributors">核心贡献者</div>
</div>
<div class="section-body" id="credits-body">
<div class="text-sm text-dim" data-i18n="status.loading">加载中…</div>
</div>
</div>
<div class="section">
<div class="section-header">
<div class="section-title" data-i18n="credits.cta">想加入这份名单?</div>
</div>
<div class="section-body">
<div class="section-desc" data-i18n="credits.cta.desc">欢迎到 GitHub 提 issue 或 pull request — 不管是修 bug、加模型、改 UI 还是发现安全问题,都会被记录在这里。</div>
<div style="margin-top:12px">
<a class="btn btn-outline btn-sm" target="_blank" rel="noopener" href="https://github.com/dwgx/WindsurfAPI/issues">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" style="width:14px;height:14px;margin-right:6px"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
<span data-i18n="credits.cta.issue">提 issue</span>
</a>
<a class="btn btn-outline btn-sm" target="_blank" rel="noopener" href="https://github.com/dwgx/WindsurfAPI/pulls" style="margin-left:8px">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" style="width:14px;height:14px;margin-right:6px"><circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M13 6h3a2 2 0 0 1 2 2v7"/><line x1="6" y1="9" x2="6" y2="21"/></svg>
<span data-i18n="credits.cta.pr">提 PR</span>
</a>
</div>
</div>
</div>
</section>
<section class="panel" id="p-experimental">
<div class="page-header">
<div>
<h1 class="page-title" data-i18n="page.experimental">实验性功能</h1>
<div class="page-subtitle" data-i18n="pageSubtitle.experimental">尚未稳定的优化项;如果发现异常可以随时关闭</div>
</div>
</div>
<div class="section">
<div class="section-header">
<div class="section-title" data-i18n="section.cascadeReuse.title">Cascade 对话复用</div>
</div>
<div class="section-body">
<div class="section-desc" style="margin-bottom:12px" data-i18n="section.cascadeReuse.desc">多轮对话时复用同一个 cascade_id,只把最新一条 user 消息发给 Windsurf,让服务端维持上下文缓存。命中时可显著降低 TTFB 与上传体积;未命中或账号/LS 变更会自动回退到新会话。需要客户端保留完整历史并按顺序追加(如 new-api、OpenWebUI)。</div>
<div style="display:flex;align-items:center;gap:16px;margin-bottom:20px;padding:12px;background:var(--surface-2);border-radius:var(--radius)">
<label class="switch">
<input type="checkbox" id="exp-cascade-reuse" onchange="App.toggleExperimental('cascadeConversationReuse', this.checked)">
<span class="switch-slider"></span>
</label>
<div>
<div style="font-weight:500" data-i18n="section.cascadeReuse.enable">启用 Cascade 对话复用</div>
<div class="text-sm text-muted" data-i18n="section.cascadeReuse.hint">默认关闭。开启后对当前对话池立即生效。</div>
</div>
</div>
<div class="metrics-grid" id="exp-pool-cards"></div>
<div style="margin-top:12px">
<button class="btn btn-outline btn-sm" onclick="App.clearConversationPool()" data-i18n="action.clearPool">清空对话池</button>
<button class="btn btn-ghost btn-sm" onclick="App.loadExperimental()" data-i18n="action.refresh">刷新</button>
</div>
</div>
</div>
<div class="section" style="margin-top:24px">
<div class="section-header">
<div class="section-title" data-i18n="section.droughtRestrict.title">配额低水位时屏蔽 premium 模型</div>
</div>
<div class="section-body">
<div class="section-desc" style="margin-bottom:12px" data-i18n="section.droughtRestrict.desc">所有账号本周配额都低于 5% 时,对 premium 模型(claude-opus / claude-sonnet 等)请求直接返 503,避免上游 rate-limit 烧最后一点配额。免费层模型(gemini-2.5-flash 等)保持可用。关闭后即使在 drought mode 也照常下发,accept 上游可能立即 429。</div>
<div style="display:flex;align-items:center;gap:16px;padding:12px;background:var(--surface-2);border-radius:var(--radius)">
<label class="switch">
<input type="checkbox" id="exp-drought-restrict" onchange="App.toggleExperimental('droughtRestrictPremium', this.checked)">
<span class="switch-slider"></span>
</label>
<div>
<div style="font-weight:500" data-i18n="section.droughtRestrict.enable">启用 drought-mode premium 屏蔽</div>
<div class="text-sm text-muted" data-i18n="section.droughtRestrict.hint">默认开启。drought 状态由账号池自动判定(GET /dashboard/api/drought 看实时值)。</div>
</div>
</div>
</div>
</div>
<!-- v2.0.70 (#112 follow-up): quiet-window auto-update toggle. -->
<div class="section" style="margin-top:24px">
<div class="section-header">
<div class="section-title" data-i18n="section.quietWindow.title">空档自动更新</div>
</div>
<div class="section-body">
<div class="section-desc" style="margin-bottom:12px" data-i18n="section.quietWindow.desc">每分钟看一次最近 5 分钟内的请求量,连续在阈值以下就自动 docker compose pull + recreate。冷启动 10 分钟内不动;成功后 24 小时冷却;失败不冷却。需要 /var/run/docker.sock 挂进容器(默认 docker-compose 已挂)。</div>
<div style="display:flex;align-items:center;gap:16px;padding:12px;background:var(--surface-2);border-radius:var(--radius)">
<label class="switch">
<input type="checkbox" id="exp-quiet-window" onchange="App.toggleExperimental('autoUpdateQuietWindow', this.checked)">
<span class="switch-slider"></span>
</label>
<div>
<div style="font-weight:500" data-i18n="section.quietWindow.enable">启用空档自动更新</div>
<div class="text-sm text-muted" data-i18n="section.quietWindow.hint">默认关闭。当前状态:<span id="quiet-window-status">加载中…</span></div>
</div>
<div style="margin-left:auto;display:flex;gap:8px">
<button class="btn btn-ghost btn-sm" onclick="App.loadQuietWindowStatus()" data-i18n="action.refresh">刷新</button>
<button class="btn btn-outline btn-sm" onclick="App.runQuietWindowNow()" data-i18n="action.runNow">立即测试</button>
</div>
</div>
</div>
</div>
<div class="section" style="margin-top:24px">
<div class="section-header">
<div>
<div class="section-title" data-i18n="section.systemPrompts.title">系统提示词</div>
<div class="section-desc" data-i18n="section.systemPrompts.desc">工具注入、对话模式等内置提示词模板。修改后立即生效,点"重置"恢复默认。</div>
</div>
</div>
<div class="section-body" id="system-prompts-editor" style="display:grid;gap:14px"></div>
</div>
<div class="section" style="margin-top:24px">
<div class="section-header">
<div>
<div class="section-title" data-i18n="section.credentials.title">凭证管理</div>
<div class="section-desc" data-i18n="section.credentials.desc">运行时改 API_KEY 和 Dashboard 密码,写入 runtime-config.json 立即生效,不用重启容器或重读 .env。改完之后下一次请求需要用新值;当前会话会要求重新登录。</div>
</div>
</div>
<div class="section-body" style="display:grid;gap:14px">
<div style="padding:12px;background:var(--surface-2);border-radius:var(--radius);display:grid;gap:8px">
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
<strong data-i18n="credentials.apiKey">API_KEY</strong>
<span class="text-sm text-muted" id="credentials-apikey-source"></span>
<span class="text-sm" style="font-family:monospace" id="credentials-apikey-masked"></span>
</div>
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
<input type="password" class="input" id="credentials-apikey-input" data-i18n-placeholder="credentials.apiKeyPlaceholder" placeholder="新 API_KEY(≥ 8 字符;留空清除运行时覆盖)" style="flex:1;min-width:240px">
<button class="btn btn-outline btn-sm" onclick="App.toggleApiKeyVisibility()" data-i18n="credentials.show">显示/隐藏</button>
<button class="btn btn-primary btn-sm" onclick="App.saveCredential('apiKey')" data-i18n="credentials.save">保存</button>
</div>
<div class="text-sm text-muted" data-i18n="credentials.apiKeyHint">改完后所有 chat 客户端要换成新 KEY;旧 KEY 立即失效。</div>
</div>
<div style="padding:12px;background:var(--surface-2);border-radius:var(--radius);display:grid;gap:8px">
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
<strong data-i18n="credentials.dashboardPassword">Dashboard 密码</strong>
<span class="text-sm text-muted" id="credentials-dashboardpw-source"></span>
<span class="text-sm" id="credentials-dashboardpw-status"></span>
</div>
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
<input type="password" class="input" id="credentials-dashboardpw-input" data-i18n-placeholder="credentials.dashboardPwPlaceholder" placeholder="新 Dashboard 密码(≥ 8 字符;留空清除运行时覆盖)" style="flex:1;min-width:240px">
<button class="btn btn-outline btn-sm" onclick="App.toggleDashboardPwVisibility()" data-i18n="credentials.show">显示/隐藏</button>
<button class="btn btn-primary btn-sm" onclick="App.saveCredential('dashboardPassword')" data-i18n="credentials.save">保存</button>
</div>
<div class="text-sm text-muted" data-i18n="credentials.dashboardPwHint">用 scrypt 派生哈希存盘;改完之后会自动登出当前会话,需要用新密码重登。</div>
</div>
<div class="text-sm text-muted" data-i18n="credentials.bruteForceHint">为防暴破:同一 IP 连续 5 次 dashboard 登录失败会被锁 30 分钟(2.0.56 起)。</div>
</div>
</div>
<div class="section" style="margin-top:24px">
<div class="section-header">
<div>
<div class="section-title" data-i18n="section.skin.title">界面风格</div>
<div class="section-desc" data-i18n="section.skin.desc">控制台外观。默认现代风;手绘草稿风是实验性 UI,部分功能简化或采用不同布局。切换后页面会刷新。</div>
</div>
</div>
<div class="section-body">
<div style="display:flex;align-items:center;gap:16px;padding:12px;background:var(--surface-2);border-radius:var(--radius)">
<select id="skin-select" class="input" style="max-width:240px" onchange="App.switchSkin(this.value)">
<option value="modern" data-i18n="skin.modern">默认现代风</option>
<option value="sketch" data-i18n="skin.sketch">手绘草稿风(实验)</option>
</select>
<div class="text-sm text-muted" data-i18n="skin.hint">选择后立即写入 cookie 并刷新</div>
</div>
</div>
</div>
</section>
</main>
<!-- Login Overlay -->
<div class="login-overlay hidden" id="login-overlay">
<div class="login-box">
<div class="login-logo">W</div>
<h3 data-i18n="login.title">控制台登录</h3>
<p data-i18n="login.subtitle">请输入管理密码以继续</p>
<input type="password" id="login-password" class="input" data-i18n-placeholder="login.passwordPlaceholder" placeholder="Dashboard 密码" onkeydown="if(event.key==='Enter')App.login()">
<button class="btn btn-primary" id="login-btn" onclick="App.login()" data-i18n="login.button">登 录</button>
<div id="login-error" style="margin-top:12px;color:var(--error);font-size:13px;min-height:18px"></div>
</div>
</div>
<!-- Modal Container -->
<div id="modal-container"></div>
<!-- Toast Container -->
<div class="toast-stack" id="toast-stack"></div>
<script>
// Comprehensive i18n system with locale bundles
const I18n = {
locale: 'zh-CN',
bundles: {},
loaded: false,
// Load locale bundle from server
async loadLocale(lang) {
const code = lang === 'zh' ? 'zh-CN' : (lang === 'en' ? 'en' : lang);
try {
const res = await fetch(`/dashboard/i18n/${code}.json`);
if (!res.ok) throw new Error(`Failed to load ${code}`);
this.bundles[code] = await res.json();
this.loaded = true;
return true;
} catch (e) {
console.error('i18n load failed:', e);
return false;
}
},
// Get translation with placeholder interpolation
t(key, vars = {}) {
const code = this.locale === 'zh' ? 'zh-CN' : this.locale;
const bundle = this.bundles[code] || {};
const keys = key.split('.');
let val = bundle;
for (const k of keys) {
val = val?.[k];
if (val === undefined) break;
}
// Fallback to key if not found
if (typeof val !== 'string') {
// Try embedded default (for zh-CN, use text from HTML).
// The DOM lookup is only meaningful when the key is a real dotted
// i18n identifier (alnum + dot + underscore). A backend error
// message accidentally passed as a key — e.g. `error.docker API
// POST /containers/create -> 404: {"message":"..."}` — would blow
// up `querySelector` because `"` and `{` are CSS-selector syntax.
// Guard with CSS.escape AND a sanity charset check so we never
// throw out of the i18n resolver.
if (code === 'zh-CN' && /^[A-Za-z0-9_.-]+$/.test(key)) {
try {
const el = document.querySelector(`[data-i18n="${CSS.escape(key)}"]`);
if (el?.dataset.i18nOrig) return el.dataset.i18nOrig;
} catch { /* selector still rejected — give up and return the key */ }
}
return key;
}
// Interpolate {{var}} placeholders
return val.replace(/\{\{(\w+)\}\}/g, (_, k) => vars[k] ?? '');
},
// Apply translations to all elements with data-i18n attributes
apply(root = document) {
// Text content
root.querySelectorAll('[data-i18n]').forEach(el => {
const key = el.dataset.i18n;
const vars = {};
// Parse data-i18n-vars if present: "count:5,total:10"
if (el.dataset.i18nVars) {
el.dataset.i18nVars.split(',').forEach(pair => {
const [k, v] = pair.split(':');
if (k && v !== undefined) vars[k.trim()] = v.trim();
});
}
const text = this.t(key, vars);
// Store original for zh mode
if (!el.dataset.i18nOrig) el.dataset.i18nOrig = el.textContent;
if (this.locale !== 'zh-CN' && this.locale !== 'zh') {
el.textContent = text;
} else {
el.textContent = el.dataset.i18nOrig;
}
});
// Placeholders
root.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
const key = el.dataset.i18nPlaceholder;
const text = this.t(key);
if (!el.dataset.i18nPlaceholderOrig) el.dataset.i18nPlaceholderOrig = el.placeholder;
if (this.locale !== 'zh-CN' && this.locale !== 'zh') {
el.placeholder = text;
} else {
el.placeholder = el.dataset.i18nPlaceholderOrig;
}
});
// Title attributes
root.querySelectorAll('[data-i18n-title]').forEach(el => {
const key = el.dataset.i18nTitle;
const text = this.t(key);
if (!el.dataset.i18nTitleOrig) el.dataset.i18nTitleOrig = el.title;
if (this.locale !== 'zh-CN' && this.locale !== 'zh') {
el.title = text;
} else {
el.title = el.dataset.i18nTitleOrig;
}
});
// Update HTML lang attribute
document.documentElement.lang = this.locale === 'zh' || this.locale === 'zh-CN' ? 'zh-CN' : 'en';
// Update language indicator
const zhMode = this.locale === 'zh' || this.locale === 'zh-CN';
const indicator = document.getElementById('lang-indicator');
if (indicator) indicator.textContent = zhMode ? '中文' : 'English';
const toggleBtn = document.getElementById('lang-toggle-btn');
if (toggleBtn) toggleBtn.title = I18n.t(zhMode ? 'footer.langToggleToEn' : 'footer.langToggleToZh');
},
async setLocale(lang) {
const code = lang === 'zh' ? 'zh-CN' : (lang === 'en' ? 'en' : lang);
if (!this.bundles[code]) {
await this.loadLocale(lang);
}
this.locale = code;
localStorage.setItem('lang', lang);
this.apply();
},
async init() {
const saved = localStorage.getItem('lang');
const browser = navigator.language;
const defaultLang = saved || (browser.startsWith('zh') ? 'zh' : 'en');
await this.setLocale(defaultLang);
}
};
// Global translation helper for JS strings
function t(key, vars) { return I18n.t(key, vars); }
const App = {
password: localStorage.getItem('dp') || '',
lang: localStorage.getItem('lang') || 'zh',
sseConn: null,
logEntries: [],
pollers: {},
allModels: [],
modelAccessConfig: { mode: 'all', list: [] },
loginHistory: JSON.parse(localStorage.getItem('wl_history') || '[]'),
_filterTimer: null,
applyLang() {
// Delegate to new I18n system
I18n.apply();
},
// Centralized error i18n: translate `error.<code>` from server, fall back to
// the literal message or a localized default. Replaces 6+ inline
// `error.${err.message}` lookups across the dashboard (PR #92, smeinecke).
translateError(errorCode, fallbackKey = 'error.unknown') {
const errKey = errorCode ? `error.${errorCode}` : '';
if (errKey && I18n.t(errKey) !== errKey) return I18n.t(errKey);
return errorCode || I18n.t(fallbackKey);
},
async toggleLang() {
const newLang = I18n.locale === 'zh' || I18n.locale === 'zh-CN' ? 'en' : 'zh';
await I18n.setLocale(newLang);
this.lang = newLang;
this.refreshActivePanelI18n();
},
refreshActivePanelI18n() {
const activePanelId = document.querySelector('.panel.active')?.id?.replace(/^p-/, '');
if (!activePanelId) return;
const loaders = {
overview: 'loadOverview', 'windsurf-login': 'loadWindsurfLogin',
accounts: 'loadAccounts', models: 'loadModels', proxy: 'loadProxy',
logs: 'loadLogs', stats: 'loadStats', bans: 'loadBans',
experimental: 'loadExperimental', credits: 'loadCredits',
};
const loader = loaders[activePanelId];
if (loader && typeof this[loader] === 'function') this[loader]();
},
// Register a polling loader. Guarantees exactly one timer per key even if
// the loader is invoked repeatedly, and auto-pauses while the tab is
// hidden to avoid hammering the server in background tabs.
poll(key, fn, intervalMs) {
if (this.pollers[key]) clearInterval(this.pollers[key]);
this.pollers[key] = setInterval(() => {
if (document.hidden) return;
fn();
}, intervalMs);
},
async init() {
// Initialize i18n first
await I18n.init();
this.lang = I18n.locale === 'zh-CN' ? 'zh' : 'en';
const auth = await this.api('GET', '/auth');
// v2.0.61 (#110): when DASHBOARD_PASSWORD isn't configured AND we're
// bound to a public host, the backend returns `locked:true`. Show a
// dedicated message instead of a useless password prompt the user
// can't possibly satisfy. Without this hint operators land here from
// a fresh `docker compose up` and think the dashboard is broken.
if (auth.locked) {
const overlay = document.getElementById('login-overlay');
const pwEl = document.getElementById('login-password');
const btnEl = document.getElementById('login-btn');
const errEl = document.getElementById('login-error');
overlay.classList.remove('hidden');
if (pwEl) pwEl.style.display = 'none';
if (btnEl) btnEl.style.display = 'none';
if (errEl) {
errEl.style.color = 'var(--text-muted)';
errEl.style.fontSize = '13px';
errEl.style.lineHeight = '1.65';
errEl.style.textAlign = 'left';
errEl.innerHTML = `
<strong style="color:var(--warn);display:block;margin-bottom:8px">${I18n.t('login.lockedTitle') || 'Dashboard locked'}</strong>
<div>${I18n.t('login.lockedDesc') || 'Server bound to public host without DASHBOARD_PASSWORD. v2.0.55 fail-closed: API_KEY no longer doubles as the dashboard password.'}</div>
<div style="margin-top:10px">${I18n.t('login.lockedFix') || 'Fix:'}</div>
<pre style="background:var(--surface-2);padding:10px;border-radius:6px;font-size:12px;margin-top:6px;white-space:pre-wrap"># .env
DASHBOARD_PASSWORD=YOUR_STRONG_PASSWORD
# restart container
docker compose up -d --force-recreate</pre>
<div style="margin-top:8px;font-size:12px">${I18n.t('login.lockedDocs') || 'See'} <a href="https://github.com/dwgx/WindsurfAPI/blob/master/docs/releases/RELEASE_NOTES_2.0.55.md" target="_blank" style="color:var(--accent)">v2.0.55 release notes</a>.</div>
`;
}
return;
}
if (auth.required && !auth.valid) {
document.getElementById('login-overlay').classList.remove('hidden');
return;
}
document.getElementById('login-overlay').classList.add('hidden');
document.querySelectorAll('.sidebar nav a').forEach(a => {
a.onclick = (e) => { e.preventDefault(); this.navigate(a.dataset.panel); };
});
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 errEl = document.getElementById('login-error');
if (errEl) errEl.textContent = '';
if (!pw) {
if (errEl) errEl.textContent = I18n.t('login.empty');
return;
}
if (btn) { btn.disabled = true; btn.dataset.origLabel ||= btn.textContent; btn.textContent = I18n.t('login.checking'); }
try {
// Pre-flight: check the password against /auth before storing it so a
// wrong attempt actually shows feedback instead of silently re-rendering
// the same overlay (which looks like "no response" to users).
const r = await fetch('/dashboard/api/auth', { headers: { 'X-Dashboard-Password': pw } });
const data = await r.json().catch(() => ({}));
if (data.locked) {
if (errEl) errEl.textContent = I18n.t('login.locked');
return;
}
if (data.required && !data.valid) {
if (errEl) errEl.textContent = I18n.t('login.wrong');
document.getElementById('login-password')?.focus();
return;
}
this.password = pw;
localStorage.setItem('dp', pw);
await this.init();
} catch (e) {
if (errEl) errEl.textContent = I18n.t('login.networkError') + ': ' + e.message;
} finally {
if (btn) { btn.disabled = false; if (btn.dataset.origLabel) btn.textContent = btn.dataset.origLabel; }
}
},
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();
if (r.status === 401) {
document.getElementById('login-overlay').classList.remove('hidden');
return {};
}
return data;
} catch (e) { this.toast(e.message, 'error'); return {}; }
},
toast(msg, type = 'success') {
const t = document.createElement('div');
t.className = 'toast ' + type;
t.textContent = msg;
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';
// opts.html=true lets callers pass rich markup in `desc` (e.g. the
// per-account blocked-models editor). opts.wide=true widens the modal
// so the 2-col model grid has room to breathe.
const descHtml = desc
? (opts.html
? `<div class="modal-body">${desc}</div>`
: `<div class="modal-desc">${this.esc(desc)}</div>`)
: '';
const widthStyle = opts.wide ? ' style="max-width:640px;width:92vw"' : '';
// Title is trusted caller input but may contain <code>/<b>; use esc by
// default to match prior behaviour.
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 || I18n.t('button.cancel'))}</button>
<button class="btn ${opts.danger ? 'btn-danger' : 'btn-primary'}" data-act="ok">${this.esc(opts.okText || I18n.t('button.confirm'))}</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 mt-3">
<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">${I18n.t('button.cancel')}</button>
<button class="btn btn-primary" data-act="ok">${I18n.t('button.confirm')}</button>
</div>
</div>`;
document.getElementById('modal-container').appendChild(wrap);
const 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);
});
},
// ─── Overview ────────────────────────────────
showSelfUpdateUnavailable(status, apply, response) {
// The standard hint always works (run `docker compose pull && up -d`
// on the host). When docker mode also reported a reason — e.g. the
// user has docker.sock mounted but the container wasn't started by
// compose — surface that detail too so they know how to opt in.
const dockerHintParts = [];
if (response && response.dockerReason && response.dockerReason !== 'no-docker-sock') {
dockerHintParts.push(I18n.t('status.dockerSelfUpdateBlocked', {
reason: this.esc(response.dockerReason),
detail: this.esc(response.dockerDetail || ''),
}));
}
status.innerHTML = `
<div style="color:var(--warning,#f59e0b);font-weight:600;margin-bottom:4px">${I18n.t('status.selfUpdateUnavailable')}</div>
<div class="text-sm text-muted">${I18n.t('status.selfUpdateDockerHint')}</div>
${dockerHintParts.join('')}`;
if (apply) {
apply.classList.add('hidden');
apply.disabled = true;
}
},
async checkUpdate() {
const btn = document.getElementById('btn-check-update');
const status = document.getElementById('update-status');
const apply = document.getElementById('btn-apply-update');
btn.disabled = true;
status.classList.remove('hidden');
status.innerHTML = '<span class="text-muted">' + I18n.t('status.checkingUpdate') + '</span>';
try {
const r = await this.api('GET', '/self-update/check');
if (!r.ok) {
if (r.error === 'ERR_SELF_UPDATE_UNAVAILABLE') {
this.showSelfUpdateUnavailable(status, apply, r);
return;
}
if (/not a git repository/i.test(r.error || '')) {
status.innerHTML = `
<div style="color:var(--warning,#f59e0b);font-weight:600;margin-bottom:4px">${I18n.t('status.notGitRepo')}</div>
<div class="text-sm text-muted">${I18n.t('status.notGitRepoHint')}</div>`;
apply.classList.add('hidden');
return;
}
throw new Error(this.translateError(r.error, 'error.apiError'));
}
if (r.mode === 'docker') {
// Git mode unavailable but docker.sock is mounted — backend will
// pull the image and spawn a deployer sidecar to recreate this
// container in place. Show a "ready to apply" prompt instead of
// the git diff card.
status.innerHTML = `
<div style="color:var(--accent);font-weight:600;margin-bottom:6px">${I18n.t('status.dockerReady')}</div>
<div class="text-sm" style="display:grid;gap:3px">
<div>${I18n.t('status.dockerImage')} <code>${this.esc(r.image || '')}</code></div>
<div>${I18n.t('status.dockerProject')} <code>${this.esc(r.project || '')}</code></div>
</div>`;
apply.classList.remove('hidden');
apply.dataset.mode = 'docker';
return;
}
apply.dataset.mode = 'git';
if (r.behind) {
status.innerHTML = `
<div style="color:var(--accent);font-weight:600;margin-bottom:6px">${I18n.t('status.updateAvailable')}</div>
<div class="text-sm" style="display:grid;gap:3px">
<div>${I18n.t('status.currentVersion')} <code>${this.esc(r.commit)}</code> <span class="text-muted">${this.esc(r.localMessage)}</span></div>
<div>${I18n.t('status.remoteVersion')} <code>${this.esc(r.remoteCommit)}</code> <span class="text-muted">${this.esc(r.remoteMessage || '')}</span></div>
</div>`;
apply.classList.remove('hidden');
} else {
status.innerHTML = '<span style="color:var(--success)">✓ ' + I18n.t('status.upToDate', {commit: this.esc(r.commit), message: this.esc(r.localMessage)}) + '</span>';
apply.classList.add('hidden');
}
} catch (err) {
status.innerHTML = `<span style="color:var(--error)">✗ ${this.esc(err.message)}</span>`;
} finally {
btn.disabled = false;
}
},
async applyUpdate() {
const ok = await this.confirm(I18n.t('button.updateAndRestart'), I18n.t('status.updateAndRestartHint'), { okText: I18n.t('button.updateAndRestart'), danger: true });
if (!ok) return;
const status = document.getElementById('update-status');
const apply = document.getElementById('btn-apply-update');
apply.disabled = true;
status.innerHTML = '<span class="text-muted">' + I18n.t('status.pullingRestarting') + '</span>';
try {
let r = await this.api('POST', '/self-update');
if (r.dirty) {
const filesList = (r.dirtyFiles || []).slice(0, 10).map(f => this.esc(f)).join('<br>');
const force = await this.confirm(
I18n.t('status.workingDirDirty'),
I18n.t('status.workingDirDirtyHint', { files: filesList }),
{ okText: I18n.t('button.forceOverride'), danger: true, html: true }
);
if (!force) {
status.innerHTML = '<span class="text-muted">' + I18n.t('status.cancelled') + '</span>';
apply.disabled = false;
return;
}
r = await this.api('POST', '/self-update', { forceReset: true });
}
if (r.mode === 'docker') {
if (!r.ok) {
// r.reason is the short stable code ("deployer-pull-failed",
// "deployer-create-failed", etc.) and the only thing we can
// map to a localized i18n key. r.detail is the raw upstream
// error string — long, unstable, often containing characters
// that explode CSS selectors if treated as an i18n key. Show
// reason as the localized message; show detail underneath
// for debugging.
const localized = this.translateError(r.reason, 'error.updateFailed');
const msg = r.detail ? `${localized}${r.detail}` : localized;
throw new Error(msg);
}
const delaySec = r.delaySeconds || 8;
status.innerHTML = `
<div style="color:var(--success);font-weight:600;margin-bottom:6px">${I18n.t('status.dockerUpdating', { image: this.esc(r.image || ''), delay: delaySec })}</div>
<div class="text-sm text-muted">${I18n.t('status.dockerSidecarStarted', { id: this.esc(r.deployerId || '') })}</div>`;
setTimeout(() => location.reload(), (delaySec + 4) * 1000);
return;
}
if (!r.ok) {
if (r.error === 'ERR_SELF_UPDATE_UNAVAILABLE') {
this.showSelfUpdateUnavailable(status, apply, r);
return;
}
throw new Error(this.translateError(r.error, 'error.updateFailed'));
}
if (r.changed) {
status.innerHTML = `
<div style="color:var(--success);font-weight:600;margin-bottom:6px">${I18n.t('status.updateComplete', { before: this.esc(r.before), after: this.esc(r.after) })}</div>
<div class="text-sm text-muted">${I18n.t('status.restarting')}</div>
<pre style="margin-top:8px;font-size:11px;max-height:200px;overflow:auto;padding:8px;background:var(--surface-2);border-radius:4px">${this.esc(r.pullOutput || '')}</pre>`;
setTimeout(() => location.reload(), 8000);
} else {
status.innerHTML = '<span class="text-muted">' + I18n.t('status.noUpdateNeeded') + '</span>';
apply.classList.add('hidden');
}
} catch (err) {
status.innerHTML = `<span style="color:var(--error)">✗ ${this.esc(err.message)}</span>`;
} finally {
apply.disabled = false;
}
},
async loadOverview() {
const d = await this.api('GET', '/overview');
// Fetch /health (no auth) in parallel for version/commit info
try {
const h = await fetch('/health').then(r => r.json()).catch(() => null);
if (h) {
const sv = document.getElementById('sidebar-ver');
if (sv) {
const label = h.commit ? `v${h.version} · ${h.commit}` : `v${h.version}`;
sv.textContent = label;
sv.title = h.commitMessage
? `${h.commitMessage}\n${h.commitDate || ''}${h.branch ? ` (${h.branch})` : ''}`
: I18n.t('footer.version') + ' ' + h.version;
}
this._versionInfo = h;
}
} catch {}
const uptime = d.uptime ? this.fmtDuration(d.uptime) : '-';
const ls = d.langServer || {};
const poolCount = (ls.instances || []).length || (ls.running ? 1 : 0);
document.getElementById('overview-cards').innerHTML = `
<div class="card success">
<div class="card-header"><div class="card-title">${I18n.t('card.activeAccounts.title')}</div>
<svg class="card-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/></svg>
</div>
<div class="card-value">${d.accounts?.active || 0}</div>
<div class="card-sub">${I18n.t('card.activeAccounts.subtitle', { total: d.accounts?.total || 0, error: d.accounts?.error || 0 })}</div>
</div>
<div class="card info">
<div class="card-header"><div class="card-title">${I18n.t('card.totalRequests.title')}</div>
<svg class="card-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>
</div>
<div class="card-value">${d.totalRequests || 0}</div>
<div class="card-sub">${I18n.t('card.totalRequests.subtitle', { rate: d.successRate || 0 })}</div>
</div>
<div class="card">
<div class="card-header"><div class="card-title">${I18n.t('card.uptime.title')}</div>
<svg class="card-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
</div>
<div class="card-value" style="font-size:20px">${uptime}</div>
<div class="card-sub">${I18n.t('card.uptime.subtitle', { time: d.startedAt ? new Date(d.startedAt).toLocaleString() : '-' })}</div>
</div>
<div class="card ${ls.running ? 'success' : 'error'}">
<div class="card-header"><div class="card-title">${I18n.t('card.languageServer.title')}</div>
<svg class="card-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg>
</div>
<div class="card-value" style="font-size:20px">${I18n.t(ls.running ? 'card.languageServer.running' : 'card.languageServer.stopped')}</div>
<div class="card-sub">${I18n.t('card.languageServer.subtitle', { count: poolCount, port: ls.port || '-' })}</div>
</div>
<div class="card info">
<div class="card-header"><div class="card-title">${I18n.t('card.responseCache.title')}</div>
<svg class="card-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/><path d="M3 12c0 1.66 4 3 9 3s9-1.34 9-3"/></svg>
</div>
<div class="card-value">${d.cache?.hitRate || '0.0'}%</div>
<div class="card-sub">${I18n.t('card.responseCache.subtitle', { hits: d.cache?.hits || 0, misses: d.cache?.misses || 0, size: d.cache?.size || 0, max: d.cache?.maxSize || 0 })}</div>
</div>
`;
const instances = ls.instances || [];
if (instances.length > 0) {
document.getElementById('ls-status').innerHTML = `
<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' ? I18n.t('card.defaultInstance') : i.key.replace(/^px_/, ''))}
</div>
<div class="ls-meta">${I18n.t('field.port.label')} ${i.port} · PID ${i.pid || '-'}</div>
<div class="ls-meta">${this.esc(i.proxy || I18n.t('card.noProxy'))}</div>
</div>
`).join('')}
</div>`;
} else {
document.getElementById('ls-status').innerHTML = `
<span class="badge ${ls.running ? 'running' : 'stopped'}">${I18n.t(ls.running ? 'card.languageServer.running' : 'card.languageServer.stopped')}</span>
<span class="text-sm text-muted" style="margin-left:12px">PID ${ls.pid || '-'} · ${I18n.t('field.port.label')} ${ls.port || '-'} · ${I18n.t('card.restarts')} ${ls.restartCount || 0}</span>
`;
this.loadLsBinary();
}
this.poll('overview', () => this.loadOverview(), 15000);
},
async restartLs() {
const ok = await this.confirm(I18n.t('confirm.restartTitle'), I18n.t('confirm.restartDesc'), { danger: true, okText: I18n.t('button.restart') });
if (!ok) return;
await this.api('POST', '/langserver/restart', { confirm: true });
this.toast(I18n.t('toast.restarting'), 'info');
},
async loadLsBinary() {
const el = document.getElementById('ls-binary-info');
if (!el) return;
try {
const r = await this.api('GET', '/langserver/binary');
if (!r.ok) {
const msg = I18n.t('toast.lsBinaryUnreadable', { error: r.error || 'unknown' });
el.innerHTML = `<span style="color:var(--text-error,#ef4444)">${msg}</span> · <code style="font-family:monospace">${r.path || ''}</code>`;
return;
}
const sizeMb = (r.sizeBytes / (1024 * 1024)).toFixed(1);
const age = Math.floor((Date.now() - new Date(r.mtime).getTime()) / 86400000);
el.textContent = I18n.t('toast.lsBinaryStat', { path: r.path, sizeMb, sha: r.sha256, age });
} catch (e) {
el.textContent = I18n.t('toast.lsBinaryReadFailed', { error: e.message });
}
},
async updateLsBinary() {
const ok = await this.confirm(
I18n.t('confirm.updateLsTitle'),
I18n.t('confirm.updateLsDesc'),
{ danger: false, okText: I18n.t('confirm.updateLsButton') },
);
if (!ok) return;
this.toast(I18n.t('toast.lsBinaryUpdating'), 'info');
try {
const r = await this.api('POST', '/langserver/update', {});
if (r.ok) {
// Three meaningfully-different outcomes; collapsing them into one
// "restarted N instances" toast was the source of "LS update has
// no effect" reports — restarted=0 alone can't tell a user
// whether nothing happened, the binary was already current, or
// the live pool was just cold.
const errs = (r.restartErrors || []).length;
let msgKey, vars;
if (!r.binaryChanged && r.beforeSha) {
msgKey = 'toast.lsBinaryAlreadyCurrent';
vars = { sha: r.beforeSha };
} else if (errs) {
msgKey = 'toast.lsBinaryUpdatedWithErrors';
vars = { count: r.restarted, errors: errs, before: r.beforeSha || '?', after: r.afterSha || '?' };
} else if (r.poolEmpty) {
msgKey = 'toast.lsBinaryUpdatedColdPool';
vars = { before: r.beforeSha || '?', after: r.afterSha || '?' };
} else {
msgKey = 'toast.lsBinaryUpdated';
vars = { count: r.restarted, before: r.beforeSha || '?', after: r.afterSha || '?' };
}
this.toast(I18n.t(msgKey, vars), 'success');
if (this.loadLsBinary) await this.loadLsBinary();
} else {
this.toast(I18n.t('toast.lsBinaryUpdateFailed', { error: r.error || r.stderr || 'unknown' }), 'error');
}
} catch (e) {
this.toast(I18n.t('toast.lsBinaryUpdateRequestFailed', { error: e.message }), 'error');
}
},
// ─── Windsurf Login ──────────────────────────
loadWindsurfLogin() {
this.renderLoginHistory();
// v2.0.60 — preflight local-windsurf availability so the import
// button reflects the truth (disabled + hint on public bind).
this.checkLocalImportAvailability();
},
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 = I18n.t('oauth.status.loggingIn', { label });
status.style.color = 'var(--text-muted)';
try {
if (!window._firebaseOAuth) throw new Error(I18n.t('error.firebaseLoadFailed'));
const cred = await window._firebaseOAuth(provider);
status.textContent = I18n.t('oauth.status.codeiumRegistering', { 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 = '#22c55e';
status.textContent = I18n.t('oauth.status.loginSuccess', { email: r.email || label });
this.toast(I18n.t('toast.loginSuccess', { label }), 'success');
this.loadAccounts();
const entry = { time: new Date().toISOString(), email: r.email || label, proxy: I18n.t('proxy.direct'), status: 'success', method: label };
this.pushLoginHistory(entry);
} catch (err) {
status.style.color = '#ef4444';
status.textContent = I18n.t('oauth.status.loginFailed', { label, error: err.message });
const errKey = err.message ? `error.${err.message}` : null;
const translatedErr = (errKey && I18n.t(errKey) !== errKey) ? I18n.t(errKey) : err.message;
this.toast(I18n.t('toast.loginFailed', { label, error: translatedErr }), '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;
},
getWindsurfProxyLabel(proxy) {
if (!proxy) return I18n.t('proxy.global');
if (typeof proxy === 'string') return proxy;
return `${proxy.type}://${proxy.host}:${proxy.port}`;
},
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();
},
showWindsurfLoginResult(html) {
const rd = document.getElementById('wl-result');
rd.classList.remove('hidden');
rd.innerHTML = html;
},
getWindsurfLoginFailActions(r) {
if (!r.isAuthFail) return '';
const inlineId = 'inline-token-' + Math.random().toString(36).slice(2, 8);
const emailAttr = this.esc(r.email || '');
return `
<div style="margin-top:12px;padding:12px;background:var(--surface-2);border-radius:var(--radius);border-left:3px solid var(--accent)">
<div style="font-weight:600;margin-bottom:8px">${I18n.t('oauth.tryAnotherWay')}</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px">
<button class="btn btn-sm" style="background:#4285F4;color:#fff" onclick="App.oauthLogin('google')">${I18n.t('oauth.google')}</button>
<button class="btn btn-sm" style="background:#24292e;color:#fff" onclick="App.oauthLogin('github')">${I18n.t('oauth.github')}</button>
</div>
<div style="border-top:1px dashed var(--border);padding-top:12px">
<div style="font-weight:600;margin-bottom:6px">${I18n.t('oauth.inlineTokenTitle')}</div>
<div style="font-size:12px;color:var(--text-muted);margin-bottom:8px">${I18n.t('oauth.inlineTokenDesc')}</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">
<button class="btn btn-sm btn-outline" onclick="App.openWindsurfTokenUrl('${inlineId}')">${I18n.t('oauth.openWindsurfToken')}</button>
<input id="${inlineId}" type="text" class="input" style="flex:1;min-width:240px" placeholder="${I18n.t('oauth.tokenPlaceholder')}">
<button class="btn btn-sm btn-primary" onclick="App.addAccountFromInlineToken('${inlineId}','${emailAttr}')">${I18n.t('oauth.addWithToken')}</button>
</div>
</div>
</div>`;
},
openWindsurfTokenUrl(inputId) {
// Use the 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; using the
// full URL bypasses the auth-protected /show-auth-token landing's signin
// bounce and shows the token directly after OAuth completes.
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(I18n.t('toast.enterKeyOrToken'), '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(I18n.t('account.addSuccess'), 'success');
input.value = '';
this.loadAccounts();
} else {
this.toast(this.translateError(r.error, 'toast.addFailed'), 'error');
}
} catch (err) {
this.toast(this.translateError(err.message, 'error.unknown'), 'error');
} finally {
if (btn) btn.disabled = originalDisabled || false;
}
},
renderSingleWindsurfLoginResult(r, autoAdd) {
if (r.success) {
this.showWindsurfLoginResult(`
<div class="section-header">
<div>
<div class="section-title" style="color:var(--success)">✓ ${I18n.t('loginResult.success')}</div>
<div class="section-desc">${I18n.t('loginResult.apiKeyGenerated', {autoAdd: autoAdd ? I18n.t('loginResult.addedToPool') : ''})}</div>
</div>
</div>
<div class="section-body">
<table style="background:transparent">
<tr><td style="color:var(--text-muted);width:120px">${I18n.t('loginResult.fields.email')}</td><td>${this.esc(r.email)}</td></tr>
<tr><td style="color:var(--text-muted)">${I18n.t('loginResult.fields.name')}</td><td>${this.esc(r.name || '-')}</td></tr>
<tr><td style="color:var(--text-muted)">${I18n.t('loginResult.fields.apiKey')}</td><td><code class="break-all">${this.esc(r.apiKey_masked || r.apiKey || '')}</code></td></tr>
${r.account ? `<tr><td style="color:var(--text-muted)">${I18n.t('loginResult.fields.accountId')}</td><td><code>${r.account.id}</code> <span class="badge active">${r.account.status}</span></td></tr>` : ''}
</table>
</div>`);
return;
}
const errKey = r.error ? `error.${r.error}` : null;
const errMsg = (errKey && I18n.t(errKey) !== errKey) ? I18n.t(errKey) : (r.error || I18n.t('error.unknown'));
this.showWindsurfLoginResult(`
<div class="section-header">
<div>
<div class="section-title" style="color:var(--error)">${I18n.t('loginResult.fail')}</div>
</div>
</div>
<div class="section-body"><p class="text-sm">${this.esc(errMsg)}</p>${this.getWindsurfLoginFailActions(r)}</div>`);
},
renderBatchWindsurfLoginResult(results, autoAdd) {
const successCount = results.filter(r => r.success).length;
const failCount = results.length - successCount;
this.showWindsurfLoginResult(`
<div class="section-header">
<div>
<div class="section-title" style="color:${failCount ? 'var(--warning, #f59e0b)' : 'var(--success)'}">${I18n.t('loginResult.batchComplete')}</div>
<div class="section-desc">${I18n.t('loginResult.batchDesc', {total: results.length, success: successCount, fail: failCount, autoAdd: autoAdd ? I18n.t('loginResult.addedToPool') : ''})}</div>
</div>
</div>
<div class="section-body tight">
<div class="table-wrap">
<table>
<thead><tr><th>${I18n.t('table.header.email')}</th><th>${I18n.t('table.header.status')}</th><th>${I18n.t('table.header.note')}</th></tr></thead>
<tbody>
${results.map(r => `
<tr>
<td>${this.esc(r.email || '-')}</td>
<td><span class="badge ${r.success ? 'active' : 'error'}">${r.success ? I18n.t('batch.success') : I18n.t('batch.fail')}</span></td>
<td class="text-sm">${r.success
? `<span class="break-all"><code>${this.esc(r.apiKey_masked || (r.apiKey ? r.apiKey.slice(0, 16) + '...' : '-'))}</code>${r.account ? ` · ${I18n.t('table.header.account')} ${this.esc(r.account.id)}` : ''}</span>`
: (() => { const errKey = r.error ? `error.${r.error}` : null; return `<span style="color:var(--error)">${this.esc((errKey && I18n.t(errKey) !== errKey) ? I18n.t(errKey) : (r.error || I18n.t('error.unknown')))}</span>`; })()}</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
</div>`);
},
parseWindsurfBatchInput(text) {
const entries = [];
const errors = [];
String(text || '').replace(/\r\n?/g, '\n').split('\n').forEach((raw, idx) => {
const line = raw.trim();
if (!line) return;
const m = line.match(/^(\S+)\s+(.+)$/);
if (!m) {
errors.push(I18n.t('batch.formatError', {line: idx + 1}));
return;
}
const email = m[1].trim();
const password = m[2].trim();
if (!email.includes('@') || !password) {
errors.push(I18n.t('batch.formatError', {line: idx + 1}));
return;
}
entries.push({ email, password });
});
return { entries, errors };
},
async pasteWindsurfBatch() {
const el = document.getElementById('wl-batch-input');
if (!navigator.clipboard || !window.isSecureContext) {
el?.focus();
return this.toast(I18n.t('toast.clipboardUnsupported'), 'info');
}
try {
const text = await navigator.clipboard.readText();
if (!text) return this.toast(I18n.t('toast.clipboardEmpty'), 'info');
el.value = text;
this.toast(I18n.t('toast.clipboardRead'), 'success');
} catch (err) {
this.toast(I18n.t('toast.clipboardError', { error: err.message }), 'error');
}
},
async windsurfLogin() {
const email = document.getElementById('wl-email').value.trim();
const password = document.getElementById('wl-password').value.trim();
if (!email || !password) return this.toast(I18n.t('toast.enterEmailPassword'), '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> ' + I18n.t('status.loading');
const entry = { time: new Date().toISOString(), email, proxy: this.getWindsurfProxyLabel(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.renderSingleWindsurfLoginResult(r, autoAdd);
this.toast(autoAdd ? I18n.t('toast.loginSuccessAdded') : I18n.t('toast.loginSuccess'), 'success');
this.loadAccounts();
} else {
entry.status = 'error: ' + (r.error || 'unknown');
this.renderSingleWindsurfLoginResult(r, autoAdd);
const errKey = r.error ? `error.${r.error}` : null;
this.toast((errKey && I18n.t(errKey) !== errKey) ? I18n.t(errKey) : (r.error || I18n.t('toast.loginFailed')), 'error');
}
} catch (err) {
entry.status = 'error: ' + err.message;
const errKey = err.message ? `error.${err.message}` : null;
this.toast((errKey && I18n.t(errKey) !== errKey) ? I18n.t(errKey) : err.message, 'error');
}
this.pushLoginHistory(entry);
btn.disabled = false;
btn.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/><polyline points="10 17 15 12 10 7"/><line x1="15" y1="12" x2="3" y2="12"/></svg> ' + I18n.t('action.login');
},
async windsurfLoginBatch() {
const input = document.getElementById('wl-batch-input').value;
if (!input.trim()) return this.toast(I18n.t('batch.pasteFirst'), 'error');
const autoAdd = document.getElementById('wl-auto-add').checked;
const btn = document.getElementById('wl-batch-btn');
const lineCount = input.trim().split('\n').filter(l => l.trim()).length;
btn.disabled = true;
btn.innerHTML = '<span class="spinner"></span> ' + I18n.t('batch.importing');
this.showWindsurfLoginResult(`
<div class="section-header">
<div>
<div class="section-title">${I18n.t('batch.importing')}</div>
<div class="section-desc">${I18n.t('batch.importingDesc', {count: lineCount})}</div>
</div>
</div>`);
try {
const r = await this.api('POST', '/batch-import', { text: input, autoAdd });
if (!r.success || !Array.isArray(r.results)) {
const errKey = r.error ? `error.${r.error}` : null;
this.showWindsurfLoginResult(`
<div class="section-header">
<div><div class="section-title" style="color:var(--error)">${I18n.t('loginResult.fail')}</div></div>
</div>
<div class="section-body"><p class="text-sm">${this.esc((errKey && I18n.t(errKey) !== errKey) ? I18n.t(errKey) : (r.error || I18n.t('error.unknown')))}</p></div>`);
const translatedErr = errKey && I18n.t(errKey) !== errKey ? I18n.t(errKey) : r.error;
return this.toast(translatedErr || I18n.t('batch.importFailed'), 'error');
}
this.renderBatchWindsurfLoginResult(r.results, autoAdd);
this.pushLoginHistoryEntries(r.results.map(item => ({
time: new Date().toISOString(),
email: item.email,
proxy: this.getWindsurfProxyLabel(item.proxy),
status: item.success ? 'success' : `error: ${item.error || 'unknown'}`,
apiKey: item.success ? (item.apiKey_masked || (item.apiKey ? item.apiKey.slice(0, 16) + '...' : undefined)) : undefined,
})));
this.toast(I18n.t('batch.importComplete', {success: r.successCount || 0, fail: r.failCount || 0}), (r.failCount || 0) ? 'info' : 'success');
if ((r.successCount || 0) > 0) {
this.loadAccounts();
document.getElementById('wl-batch-input').value = '';
}
} catch (err) {
const errKey = err.message ? `error.${err.message}` : null;
this.showWindsurfLoginResult(`
<div class="section-header">
<div><div class="section-title" style="color:var(--error)">${I18n.t('loginResult.fail')}</div></div>
</div>
<div class="section-body"><p class="text-sm">${this.esc((errKey && I18n.t(errKey) !== errKey) ? I18n.t(errKey) : err.message)}</p></div>`);
const translatedErr = (errKey && I18n.t(errKey) !== errKey) ? I18n.t(errKey) : err.message;
this.toast(translatedErr, 'error');
} finally {
btn.disabled = false;
btn.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M3 5h18"/><path d="M3 12h18"/><path d="M3 19h18"/></svg> ' + I18n.t('action.batchImport');
}
},
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>${this.esc(h.email)}</td>
<td>
<span class="badge ${ok ? 'active' : 'error'}">${ok ? I18n.t('batch.success') : I18n.t('batch.fail')}</span>
${!ok ? (() => { const errCode = (h.status||'').replace('error: ',''); const errKey = `error.${errCode}`; const errMsg = I18n.t(errKey) !== errKey ? I18n.t(errKey) : errCode; return `<div class="text-xs" style="color:var(--error);margin-top:3px;max-width:240px;overflow:hidden;text-overflow:ellipsis" title="${this.esc(errCode)}">${this.esc(errMsg)}</div>`; })() : ''}
</td>
<td class="text-sm">${this.esc(h.proxy || '-')}</td>
<td><button class="btn btn-ghost btn-xs" onclick="App.removeLoginHistory(${i})">${I18n.t('action.delete')}</button></td>
</tr>`;
}).join('') || `<tr class="empty-row"><td colspan="5">${I18n.t('table.empty.loginHistory')}</td></tr>`;
},
removeLoginHistory(idx) {
this.loginHistory.splice(idx, 1);
localStorage.setItem('wl_history', JSON.stringify(this.loginHistory));
this.renderLoginHistory();
},
// ─── Accounts ────────────────────────────────
async loadAccounts() {
// v2.0.57 Fix 5 — refresh drought banner alongside accounts so a low
// pool state shows up the moment the user opens the panel.
this.loadDrought();
const [d, ov] = await Promise.all([
this.api('GET', '/accounts'),
this.api('GET', '/overview').catch(() => null),
]);
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-header">
<div>
<div class="section-title">${I18n.t('section.lsPool.sectionTitle')}</div>
<div class="section-desc">${I18n.t('section.lsPool.sectionDesc', { count: instances.length })}</div>
</div>
</div>
<div class="section-body">
<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' ? I18n.t('section.lsPool.defaultInstance') : i.key.replace(/^px_/, ''))}
</div>
<div class="ls-meta">${I18n.t('section.lsPool.port')} ${i.port} · PID ${i.pid || '-'}</div>
<div class="ls-meta">${this.esc(i.proxy || I18n.t('section.lsPool.noProxy'))}</div>
</div>
`).join('')}
</div>
</div>
</div>`;
} else {
poolCard.innerHTML = '';
}
}
const tbody = document.querySelector('#accounts-table tbody');
const tierLabel = { pro: 'PRO', free: 'FREE', expired: I18n.t('tier.expiredLabel'), unknown: I18n.t('tier.unknown') };
tbody.innerHTML = (d.accounts || []).map(a => {
const tier = a.tier || 'unknown';
// GetUserStatus snapshot — authoritative plan/trial/credit info (may be null)
const us = a.userStatus || null;
const usTipLines = [];
let tierSubline = '';
if (us) {
if (us.planName) usTipLines.push(`Plan: ${us.planName}`);
if (us.trialEndMs) {
const daysLeft = Math.max(0, Math.ceil((us.trialEndMs - Date.now()) / 86400000));
usTipLines.push(`Trial ends: ${new Date(us.trialEndMs).toLocaleDateString()} (${daysLeft}d left)`);
tierSubline = `${daysLeft}d trial`;
} else if (us.planName) {
tierSubline = us.planName.length > 12 ? us.planName.slice(0, 12) + '…' : us.planName;
}
if (us.monthlyPromptCredits > 0) {
usTipLines.push(`Prompt: ${us.promptCreditsUsed}/${us.monthlyPromptCredits}`);
}
if (us.monthlyFlowCredits > 0) {
usTipLines.push(`Flow: ${us.flowCreditsUsed}/${us.monthlyFlowCredits}`);
}
if (us.allowedModels?.length) {
usTipLines.push(`Allowed cascade models: ${us.allowedModels.length}`);
}
if (a.userStatusLastFetched) {
const ago = Math.round((Date.now() - a.userStatusLastFetched) / 60000);
usTipLines.push(`Fetched: ${ago}m ago`);
}
}
const tierTooltip = usTipLines.length
? usTipLines.join('\n')
: I18n.t('status.clickToEditTier');
// Compact summary: avail/total + 编辑 button. Clicking opens modal.
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" style="min-width:96px" onclick="App.openBlockedModal('${this.escJsAttr(a.id)}')" title="${I18n.t('account.editModels')}">
<span style="color:var(--success,#10b981);font-weight:600">${availCount}</span>
<span style="color:var(--text-dim)">/${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">${I18n.t('card.rateLimit.badge')}</span>` : '';
const rpmUsed = a.rpmUsed ?? 0;
const rpmLimit = a.rpmLimit ?? 0;
const rpmPct = rpmLimit > 0 ? Math.min(100, Math.round((rpmUsed / rpmLimit) * 100)) : 0;
const rpmColor = rpmPct >= 90 ? 'var(--error)' : rpmPct >= 60 ? 'var(--warn, #f59e0b)' : 'var(--success, #10b981)';
const rpmCell = rpmLimit > 0
? `<div style="min-width:90px">
<div class="text-xs" style="display:flex;justify-content:space-between;margin-bottom:2px">
<span>${rpmUsed}/${rpmLimit}</span><span style="color:${rpmColor}">${rpmPct}%</span>
</div>
<div style="height:4px;background:var(--surface-3);border-radius:2px;overflow:hidden">
<div style="height:100%;width:${rpmPct}%;background:${rpmColor};transition:width .3s"></div>
</div>
</div>`
: `<span class="text-xs text-dim">-</span>`;
// Credit / quota cell — collapses the legacy credit contract and the
// newer daily/weekly percent contract into a single progress bar.
const cr = a.credits || null;
let creditCell;
if (!cr) {
creditCell = `<span class="text-xs text-dim">${I18n.t('status.notFetched')}</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)}">${I18n.t('status.fetchFailed')}</span>`;
} else {
const planName = cr.planName || '-';
const fetchedAgo = cr.fetchedAt ? Math.round((Date.now() - cr.fetchedAt) / 60000) + 'm ago' : '-';
const barColor = (v) => v == null ? 'var(--text-dim)' : v <= 10 ? 'var(--error)' : v <= 30 ? 'var(--warn, #f59e0b)' : 'var(--success, #10b981)';
const bar = (label, pct, detail) => {
const noData = pct == null;
const v = noData ? 0 : Math.max(0, Math.min(100, pct));
const c = noData ? 'var(--text-dim)' : barColor(v);
// v2.0.75 (#123 wnfilm): when upstream did not return the
// bucket (dailyPercent=null but weeklyPercent has a value)
// the bar previously read "--%" with a faint trough — easy
// to mistake for a refresh failure. Print "N/A" with a
// dashed bar so it's visually distinct from a 0% / failure
// state, and mirror the explanation into the tooltip.
return `<div style="display:flex;align-items:center;gap:3px;margin-top:2px" title="${detail}">
<span class="text-xs" style="width:14px;flex-shrink:0;color:var(--text-dim);font-size:10px;font-weight:600">${label}</span>
<div style="flex:1;height:4px;background:var(--surface-3);border-radius:2px;overflow:hidden;min-width:40px${noData ? ';outline:1px dashed var(--text-dim);outline-offset:-1px' : ''}">
<div style="height:100%;width:${noData ? 0 : v}%;background:${c};border-radius:2px;transition:width .4s ease"></div>
</div>
<span class="text-xs" style="width:28px;text-align:right;color:${c};font-size:10px;font-style:${noData ? 'italic' : 'normal'}">${noData ? 'N/A' : v.toFixed(0)+'%'}</span>
</div>`;
};
const fmtReset = (unix) => unix ? new Date(unix * 1000).toLocaleString() : I18n.t('creditsBar.noResetTime');
const dailyTip = cr.dailyPercent != null
? I18n.t('creditsBar.dailyDetail', { pct: cr.dailyPercent.toFixed(0), reset: fmtReset(cr.dailyResetAt) })
: I18n.t('creditsBar.dailyNA');
const weeklyTip = cr.weeklyPercent != null
? I18n.t('creditsBar.weeklyDetail', { pct: cr.weeklyPercent.toFixed(0), reset: fmtReset(cr.weeklyResetAt) })
: I18n.t('creditsBar.weeklyNA');
const shortD = I18n.t('creditsBar.dailyShort');
const shortW = I18n.t('creditsBar.weeklyShort');
creditCell = `<div style="min-width:120px;max-width:160px">
<div class="text-xs" style="margin-bottom:2px"><b>${this.esc(planName.slice(0, 12))}</b> <span style="color:var(--text-dim)">${fetchedAgo}</span></div>
${bar(shortD, cr.dailyPercent, dailyTip)}
${bar(shortW, cr.weeklyPercent, weeklyTip)}
</div>`;
}
const isExpanded = this._expandedAccounts?.has(a.id);
return `
<tr${isExpanded ? ' class="expanded"' : ''}>
<td>
<button class="expand-chevron ${isExpanded ? 'open' : ''}" onclick="App.toggleAccountDetail('${this.escJsAttr(a.id)}')" title="${I18n.t('account.detail.toggle')}">▸</button>
<code>${a.id}</code>
</td>
<td>${this.esc(a.email)}</td>
<td>
<div style="display:flex;flex-direction:column;gap:2px">
<span class="tier ${this.esc(tier)}" style="cursor:pointer" title="${this.esc(tierTooltip)}" onclick="App.overrideTier('${this.escJsAttr(a.id)}','${this.escJsAttr(tier)}')">${tierLabel[tier] || this.esc(tier)}${a.tierManual ? ' ✎' : ''}</span>
${tierSubline ? `<span class="text-xs text-dim" style="font-size:10px">${this.esc(tierSubline)}</span>` : ''}
</div>
</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'}">${a.errorCount}</td>
<td class="text-sm nowrap">${a.lastUsed ? new Date(a.lastUsed).toLocaleString() : '-'}</td>
<td class="nowrap">
<code title="${I18n.t('action.revealKey')}" style="cursor:pointer" onclick="App.revealAndCopyKey('${this.escJsAttr(a.id)}')">${this.esc(a.apiKey_masked || a.keyPrefix || '')}</code>
</td>
<td class="nowrap">
<div class="btn-group">
<button class="btn btn-ghost btn-xs" onclick="App.probeAccount('${this.escJsAttr(a.id)}')" title="${I18n.t('action.probeTitle')}">${I18n.t('action.probe')}</button>
<button class="btn btn-ghost btn-xs" onclick="App.refreshCredits('${this.escJsAttr(a.id)}')" title="${I18n.t('account.credits.refreshTitle')}">${I18n.t('table.header.credits')}</button>
${a.status === 'active'
? `<button class="btn btn-ghost btn-xs" onclick="App.toggleAccount('${this.escJsAttr(a.id)}','disabled')">${I18n.t('action.disable')}</button>`
: `<button class="btn btn-success btn-xs" onclick="App.toggleAccount('${this.escJsAttr(a.id)}','active')">${I18n.t('action.enable')}</button>`}
<button class="btn btn-ghost btn-xs" onclick="App.resetErrors('${this.escJsAttr(a.id)}')">${I18n.t('action.reset')}</button>
<button class="btn btn-ghost btn-xs" style="color:var(--error)" onclick="App.deleteAccount('${this.escJsAttr(a.id)}')">${I18n.t('action.delete')}</button>
</div>
</td>
</tr>${isExpanded ? `<tr class="account-detail-row"><td colspan="11">${this.renderAccountDetail(a)}</td></tr>` : ''}`;
}).join('') || `<tr class="empty-row"><td colspan="11">${I18n.t('table.empty.accounts')}</td></tr>`;
},
_expandedAccounts: new Set(),
_modelsCatalog: null,
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;
},
toggleAccountDetail(id) {
if (!this._expandedAccounts) this._expandedAccounts = new Set();
if (this._expandedAccounts.has(id)) {
this._expandedAccounts.delete(id);
} else {
this._expandedAccounts.add(id);
this._ensureModelsCatalog().then(() => this.loadAccounts());
return;
}
this.loadAccounts();
},
renderAccountDetail(a) {
const cr = a.credits || {};
const us = a.userStatus || {};
const catalog = this._modelsCatalog || {};
const T = (k, v) => I18n.t('account.detail.' + k, v);
const barColor = (v) => v == null ? 'var(--text-dim)' : v <= 10 ? 'var(--error)' : v <= 30 ? 'var(--warn, #f59e0b)' : 'var(--success, #10b981)';
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 ${T('ago')}`;
if (s < 3600) return `${Math.floor(s/60)}m ${T('ago')}`;
if (s < 86400) return `${Math.floor(s/3600)}h ${T('ago')}`;
return `${Math.floor(s/86400)}d ${T('ago')}`;
};
const renderBar = (label, pct, resetAt, noDataText) => {
const isN = pct == null;
const v = isN ? 0 : Math.max(0, Math.min(100, pct));
const c = isN ? 'var(--text-dim)' : barColor(v);
const track = `<div class="bar-track"><div class="bar-fill" style="width:${v}%;background:${c}"></div></div>`;
const val = isN ? '—' : v.toFixed(0) + '%';
const reset = resetAt
? `<div class="bar-reset">⟳ ${T('resetAt')} ${fmtUnix(resetAt)}</div>`
: (isN && noDataText ? `<div class="bar-reset" style="color:var(--warn)">${noDataText}</div>` : '');
return `<div class="detail-bar">
<span class="bar-label">${label}</span>
${track}
<span class="bar-pct" style="color:${c}">${val}</span>
${reset}
</div>`;
};
// Group tierModels by provider
const tierModels = a.tierModels || [];
const blocked = new Set(a.blockedModels || []);
const providerOrder = ['anthropic', 'openai', 'google', 'deepseek', 'xai', 'alibaba', 'moonshot', 'zhipu', 'minimax', 'windsurf', 'other'];
const providerColors = {
anthropic: '#d97706', openai: '#10b981', google: '#4285f4', deepseek: '#7c3aed',
xai: '#e11d48', alibaba: '#ec4899', moonshot: '#06b6d4', zhipu: '#8b5cf6',
minimax: '#f43f5e', windsurf: '#6366f1', other: '#71717a',
};
const providerLabels = {
anthropic: 'Anthropic · Claude', openai: 'OpenAI · GPT / o', google: 'Google · Gemini',
deepseek: 'DeepSeek', xai: 'xAI · Grok', alibaba: 'Alibaba · Qwen',
moonshot: 'Moonshot · Kimi', zhipu: 'Zhipu · GLM', minimax: 'MiniMax',
windsurf: 'Windsurf · SWE', other: 'Other',
};
const groups = {};
for (const m of tierModels) {
const info = catalog[m] || {};
const prov = info.provider || 'other';
(groups[prov] = groups[prov] || []).push({ id: m, ...info });
}
const availCount = tierModels.length - blocked.size;
const modelGroupsHtml = providerOrder
.filter(p => groups[p])
.map(p => {
const models = groups[p].sort((a, b) => (a.credit || 0) - (b.credit || 0));
const blockedIn = models.filter(m => blocked.has(m.id)).length;
const availIn = models.length - blockedIn;
return `<div class="model-group">
<div class="model-group-head">
<span class="model-group-name"><span class="provider-swatch" style="background:${providerColors[p]}"></span>${providerLabels[p] || p}</span>
<span class="model-group-count">${availIn}/${models.length}</span>
</div>
<div class="model-chips">
${models.map(m => `<div class="model-chip ${blocked.has(m.id) ? 'blocked' : ''}" title="${m.id}">
<span class="model-chip-dot"></span>
<span class="model-chip-name">${this.esc(m.id)}</span>
${m.credit != null ? `<span class="model-chip-cost" title="${T('creditPerCall')}">${m.credit}${T('creditUnit')}</span>` : `<span class="model-chip-cost" style="color:var(--text-dim);background:transparent;border-color:var(--border)">—</span>`}
</div>`).join('')}
</div>
</div>`;
}).join('');
// Plan dates
let trialDaysLeft = null;
if (us.trialEndMs) trialDaysLeft = Math.max(0, Math.ceil((us.trialEndMs - Date.now()) / 86400000));
return `
<div class="account-detail-wrap">
<div class="detail-grid">
<div class="detail-card">
<div class="detail-card-head"><span class="dot"></span>${T('plan.title')}</div>
<div class="detail-kv">
<span class="k">${T('plan.name')}</span><span class="v"><b>${this.esc(cr.planName || us.planName || '—')}</b></span>
<span class="k">${T('plan.tier')}</span><span class="v"><span class="tier ${this.esc(a.tier || 'unknown')}" style="padding:1px 8px;font-size:10px">${this.esc(a.tier || 'unknown')}</span>${a.tierManual ? ' <span style="color:var(--text-dim);font-size:10px">✎ manual</span>' : ''}</span>
<span class="k">${T('plan.start')}</span><span class="v">${fmtDate(cr.planStart || us.planStart)}</span>
<span class="k">${T('plan.end')}</span><span class="v">${fmtDate(cr.planEnd || us.planEnd)}${trialDaysLeft != null ? ` <span style="color:${trialDaysLeft <= 3 ? 'var(--error)' : 'var(--text-dim)'};font-size:11px">(${trialDaysLeft}d left)</span>` : ''}</span>
${cr.overageBalance != null ? `<span class="k">${T('plan.overage')}</span><span class="v">$${cr.overageBalance.toFixed(4)}</span>` : ''}
</div>
</div>
<div class="detail-card">
<div class="detail-card-head"><span class="dot" style="background:var(--warn);box-shadow:0 0 6px var(--warn)"></span>${T('quota.title')}</div>
${renderBar(T('quota.daily'), cr.dailyPercent, cr.dailyResetAt, I18n.t('creditsBar.dailyNA'))}
${renderBar(T('quota.weekly'), cr.weeklyPercent, cr.weeklyResetAt, I18n.t('creditsBar.weeklyNA'))}
<div style="margin-top:10px;padding-top:10px;border-top:1px dashed var(--border)" class="detail-kv">
${cr.prompt?.limit ? `<span class="k">${T('quota.promptCredits')}</span><span class="v"><b>${cr.prompt.remaining ?? 0}</b> / ${cr.prompt.limit} <span style="color:var(--text-dim);font-size:10px">(${T('quota.used')} ${cr.prompt.used ?? 0})</span></span>` : `<span class="k">${T('quota.promptCredits')}</span><span class="v" style="color:var(--text-dim)">${T('quota.notApplicable')}</span>`}
${cr.flex?.limit ? `<span class="k">${T('quota.flexCredits')}</span><span class="v"><b>${cr.flex.remaining ?? 0}</b> / ${cr.flex.limit} <span style="color:var(--text-dim);font-size:10px">(${T('quota.used')} ${cr.flex.used ?? 0})</span></span>` : `<span class="k">${T('quota.flexCredits')}</span><span class="v" style="color:var(--text-dim)">${T('quota.notApplicable')}</span>`}
<span class="k">${T('quota.fetchedAt')}</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="dot" style="background:var(--success);box-shadow:0 0 6px var(--success)"></span>
${T('models.title')}
<span style="color:var(--text-dim);font-weight:400;font-size:11px;margin-left:6px">${availCount} / ${tierModels.length} ${T('models.available')}${blocked.size > 0 ? ` · ${blocked.size} ${T('models.blocked')}` : ''}</span>
<div style="margin-left:auto;display:flex;gap:4px">
<button class="btn btn-ghost btn-xs" onclick="App.openBlockedModal('${this.escJsAttr(a.id)}')" style="font-size:10px">${I18n.t('account.editModels')}</button>
</div>
</div>
${tierModels.length === 0 ? `<div style="color:var(--text-dim);font-size:12px;padding:10px 0">${I18n.t('toast.noTierModels')}</div>` : modelGroupsHtml}
</div>
<div class="detail-card" style="grid-column:1/-1">
<div class="detail-card-head"><span class="dot" style="background:var(--info);box-shadow:0 0 6px var(--info)"></span>${T('runtime.title')}</div>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:20px">
<div class="detail-kv">
<span class="k">${T('runtime.status')}</span><span class="v"><span class="badge ${a.status}">${a.status}</span>${a.rateLimited ? ` <span class="badge warn">${I18n.t('card.rateLimit.badge')}</span>` : ''}</span>
<span class="k">${T('runtime.errors')}</span><span class="v" style="color:${a.errorCount > 0 ? 'var(--error)' : 'inherit'}">${a.errorCount || 0}</span>
<span class="k">${T('runtime.rpm')}</span><span class="v">${a.rpmUsed ?? 0} / ${a.rpmLimit ?? 0}</span>
</div>
<div class="detail-kv">
<span class="k">${T('runtime.lastUsed')}</span><span class="v">${fmtAgo(a.lastUsed)}</span>
<span class="k">${T('runtime.lastProbed')}</span><span class="v">${fmtAgo(a.lastProbed || a.userStatusLastFetched)}</span>
<span class="k">${T('runtime.creditsFetched')}</span><span class="v">${fmtAgo(cr.fetchedAt)}</span>
</div>
<div class="detail-kv">
<span class="k">${T('runtime.accountId')}</span><span class="v"><code>${a.id}</code></span>
<span class="k">${T('runtime.apiKey')}</span><span class="v"><code>${this.esc(a.apiKey_masked || a.keyPrefix || '')}</code></span>
${cr.lastError ? `<span class="k">${T('runtime.lastError')}</span><span class="v" style="color:var(--error);font-size:11px">${this.esc(cr.lastError).slice(0, 80)}</span>` : ''}
</div>
</div>
</div>
</div>
</div>`;
},
async refreshCredits(id) {
try {
const r = await this.api('POST', `/accounts/${id}/refresh-credits`, {});
if (r.ok) {
this.toast(I18n.t('toast.credits.refreshed'));
this.loadAccounts();
} else {
this.toast(this.translateError(r.error, 'toast.credits.refreshFailed'), 'error');
}
} catch (e) { this.toast(I18n.t('toast.credits.refreshFailed') + ': ' + e.message, 'error'); }
},
async refreshAllCredits() {
this.toast(I18n.t('toast.credits.refreshing'), 'info');
try {
const r = await this.api('POST', '/accounts/refresh-credits', {});
if (r.success) {
const okCount = (r.results || []).filter(x => x.ok).length;
const failCount = (r.results || []).length - okCount;
this.toast(I18n.t('toast.credits.allRefreshed', { ok: okCount, fail: failCount }));
this.loadAccounts();
} else {
this.toast(this.translateError(r.error, 'toast.credits.refreshFailed'), 'error');
}
} catch (e) { this.toast(I18n.t('toast.credits.refreshFailed') + ': ' + e.message, 'error'); }
},
async probeAll() {
this.toast(I18n.t('toast.probe.allDoing'), 'info');
try {
const r = await this.api('POST', '/accounts/probe-all', {});
if (r.success) {
const tierLabel = { pro: 'Pro', free: 'Free', expired: I18n.t('tier.expiredLabel'), unknown: I18n.t('tier.unknown') };
const summary = (r.results || []).map(x => `${x.email}: ${tierLabel[x.tier] || x.tier || x.error}`).join('; ');
this.toast(I18n.t('toast.probeAllComplete', { summary }));
this.loadAccounts();
} else {
this.toast(this.translateError(r.error, 'error.probeFailed'), 'error');
}
} catch (e) { this.toast(I18n.t('error.probeFailed') + ': ' + e.message, 'error'); }
},
async probeAccount(id) {
this.toast(I18n.t('toast.probe.doing'), 'info');
try {
const r = await this.api('POST', `/accounts/${id}/probe`, {});
if (r.success) {
const tierLabel = { pro: 'Pro', free: 'Free', expired: I18n.t('tier.expiredLabel'), unknown: I18n.t('tier.unknown') };
this.toast(I18n.t('toast.probeComplete', { tier: tierLabel[r.tier] || r.tier }));
this.loadAccounts();
} else {
this.toast(this.translateError(r.error, 'error.probeFailed'), 'error');
}
} catch (e) { this.toast(I18n.t('error.probeFailed') + ': ' + e.message, 'error'); }
},
async openBlockedModal(id) {
try {
const d = await this.api('GET', '/accounts', null);
const acct = (d.accounts || []).find(a => a.id === id);
if (!acct) return this.toast(I18n.t('error.accountNotFound'), 'error');
const tierModels = acct.tierModels || [];
const blocked = new Set(acct.blockedModels || []);
if (!tierModels.length) return this.toast(I18n.t('toast.noTierModels'), 'info');
const providerOf = (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[providerOf(m)] = groups[providerOf(m)] || []).push(m);
const total = tierModels.length;
const availInit = total - blocked.size;
const html = `
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:10px;padding:8px 10px;background:var(--surface-2);border-radius:6px">
<div class="text-xs text-dim">${I18n.t('modal.blockedModels.hint')}</div>
<div style="font-size:13px">
${I18n.t('modal.blockedModels.enabled')} <span id="bm-avail" style="color:var(--success,#10b981);font-weight:600">${availInit}</span>
<span style="color:var(--text-dim)"> / ${total}</span>
</div>
</div>
<div style="display:flex;gap:6px;margin-bottom:10px">
<button class="btn btn-ghost btn-xs" onclick="App.blockedModalToggleAll(true)">${I18n.t('action.selectAll')}</button>
<button class="btn btn-ghost btn-xs" onclick="App.blockedModalToggleAll(false)">${I18n.t('action.selectNone')}</button>
<button class="btn btn-ghost btn-xs" onclick="App.blockedModalInvert()">${I18n.t('action.invert')}</button>
</div>
<div style="max-height:50vh;overflow:auto;padding:2px 2px 2px 0">
${Object.entries(groups).map(([provider, models]) => `
<div class="bm-group" data-provider="${provider}" style="margin-bottom:12px;border:1px solid var(--border);border-radius:6px;padding:8px 10px">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;margin-bottom:6px;padding-bottom:6px;border-bottom:1px solid var(--border)">
<input type="checkbox" class="bm-group-cb" data-provider="${provider}">
<span style="text-transform:uppercase;letter-spacing:.5px;font-size:11px;color:var(--text-muted);font-weight:600">${provider}</span>
<span class="text-xs text-dim" data-role="count">(${models.filter(m => !blocked.has(m)).length}/${models.length})</span>
</label>
<div style="display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:2px 10px">
${models.map(m => `
<label style="display:flex;align-items:center;gap:6px;padding:3px 4px;border-radius:3px;cursor:pointer;font-size:12px" onmouseover="this.style.background='var(--surface-2)'" onmouseout="this.style.background=''">
<input type="checkbox" class="blocked-model-cb" data-model="${this.esc(m)}" data-provider="${provider}" ${blocked.has(m) ? '' : 'checked'}>
<span>${this.esc(m)}</span>
</label>
`).join('')}
</div>
</div>
`).join('')}
</div>`;
// Fire-and-forget: confirm() drops the modal into the DOM synchronously
// so we can wire listeners on the next microtask. We don't await its
// Promise — _bindBlockedModal replaces the OK handler with one that
// PATCHes and closes itself, so the original resolve() only fires on
// Cancel, which we ignore.
this.confirm(I18n.t('modal.blockedModels.title', {email: acct.email}), html, { okText: I18n.t('action.save'), html: true, wide: true });
setTimeout(() => this._bindBlockedModal(id), 0);
} catch (e) { this.toast(I18n.t('error.openFailed') + ': ' + e.message, 'error'); }
},
// Bind listeners once the blocked-models modal is mounted.
// Listeners live on the modal so they're GC'd when it closes.
// `accountId` is passed by the caller so the PATCH target can't drift
// if the user opens two modals in quick succession.
_bindBlockedModal(accountId) {
const root = document.querySelector('.modal-overlay:last-child');
if (!root) return;
const updateGroup = (provider) => {
const group = root.querySelector(`.bm-group[data-provider="${provider}"]`);
if (!group) return;
const boxes = group.querySelectorAll('.blocked-model-cb');
const checked = [...boxes].filter(b => b.checked).length;
const groupCb = group.querySelector('.bm-group-cb');
groupCb.checked = checked === boxes.length;
groupCb.indeterminate = checked > 0 && checked < boxes.length;
group.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', () => {
const provider = gcb.dataset.provider;
root.querySelectorAll(`.blocked-model-cb[data-provider="${provider}"]`).forEach(cb => { cb.checked = gcb.checked; });
updateGroup(provider);
updateTotal();
});
});
// Intercept OK to PATCH, then close. We replace the handler confirm()
// installed so the modal stays open on network errors.
const okBtn = root.querySelector('[data-act=ok]');
const cancelBtn = root.querySelector('[data-act=cancel]');
if (!okBtn || okBtn.dataset.bmBound) return;
okBtn.dataset.bmBound = '1';
const id = accountId;
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; cancelBtn.disabled = true;
okBtn.textContent = I18n.t('status.saving');
try {
const r = await self.api('PATCH', `/accounts/${id}`, { blockedModels: newBlocked });
if (r.success) {
self.toast(I18n.t('modal.blockedModels.saved', {enabled: root.querySelectorAll('.blocked-model-cb:checked').length, disabled: newBlocked.length}), 'success');
root.remove();
self.loadAccounts();
} else {
self.toast(self.translateError(r.error, 'error.saveFailed'), 'error');
okBtn.disabled = false; cancelBtn.disabled = false; okBtn.textContent = I18n.t('action.save');
}
} catch (e) {
self.toast(I18n.t('error.saveFailed') + ': ' + e.message, 'error');
okBtn.disabled = false; cancelBtn.disabled = false; okBtn.textContent = I18n.t('action.save');
}
};
},
blockedModalToggleAll(checked) {
const root = document.querySelector('.modal-overlay:last-child');
if (!root) return;
root.querySelectorAll('.blocked-model-cb').forEach(cb => { cb.checked = checked; });
root.querySelectorAll('.blocked-model-cb').forEach(cb => cb.dispatchEvent(new Event('change')));
},
blockedModalInvert() {
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();
const proxy = document.getElementById('acc-proxy').value.trim();
if (!key) return this.toast(I18n.t('toast.enterKeyOrToken'), 'error');
const body = type === 'api_key' ? { api_key: key, label, proxy } : { token: key, label, proxy };
const r = await this.api('POST', '/accounts', body);
if (r.success) { this.toast(I18n.t('account.addSuccess')); document.getElementById('acc-key').value = ''; document.getElementById('acc-label').value = ''; document.getElementById('acc-proxy').value = ''; this.loadAccounts(); }
else this.toast(this.translateError(r.error, 'toast.addFailed'), 'error');
},
// v2.0.60 — preflight the local-windsurf availability so the dashboard
// doesn't surface a confusing 403 ERR_LOCAL_IMPORT_NOT_AVAILABLE_PUBLIC_BIND
// after the user clicks. Called from loadWindsurfLogin() / on panel
// open. Disables the button + shows a friendly hint when unavailable.
async checkLocalImportAvailability() {
const btn = document.getElementById('local-import-btn');
const hint = document.getElementById('local-import-hint');
if (!btn) return;
try {
const a = await this.api('GET', '/accounts/import-local-availability');
if (a.available) {
btn.disabled = false;
btn.classList.remove('disabled');
if (hint) hint.textContent = '';
return;
}
btn.disabled = true;
btn.classList.add('disabled');
if (hint) {
hint.textContent = a.hint || I18n.t('localImport.unavailable');
hint.style.color = 'var(--text-muted)';
hint.style.fontSize = '12px';
hint.style.lineHeight = '1.6';
}
} catch (e) {
// If the new endpoint isn't there, leave the button as-is so older
// backends still work.
}
},
async exportLogs() {
const type = document.getElementById('log-export-type')?.value || 'all';
const fmt = document.getElementById('log-export-format')?.value || 'jsonl';
const level = document.getElementById('log-level')?.value || '';
try {
const params = new URLSearchParams({ type, format: fmt });
if (level) params.set('level', level);
const url = '/dashboard/api/logs/export?' + params.toString();
// Use fetch + blob so we can pass the X-Dashboard-Password header
// (raw <a download> can't set headers).
const resp = await fetch(url, {
headers: { 'X-Dashboard-Password': sessionStorage.getItem('dashboard_password') || '' },
});
if (!resp.ok) {
this.toast(I18n.t('toast.exportFailed') + ': HTTP ' + resp.status, 'error');
return;
}
const blob = await resp.blob();
const cd = resp.headers.get('Content-Disposition') || '';
const m = cd.match(/filename="([^"]+)"/);
const filename = m ? m[1] : `logs.${fmt}`;
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = filename;
document.body.appendChild(a);
a.click();
setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 100);
this.toast(I18n.t('toast.exportOk', { filename }) || 'downloaded', 'success');
} catch (e) {
this.toast(I18n.t('toast.exportFailed') + ': ' + (e.message || ''), 'error');
}
},
async discoverLocalWindsurf() {
const root = document.getElementById('local-windsurf-result');
if (!root) return;
root.innerHTML = `<div class="text-sm" style="color:var(--text-muted)">${I18n.t('localImport.scanning')}</div>`;
try {
const r = await this.api('GET', '/accounts/import-local');
if (r.error) {
const errKey = r.error ? `error.${r.error}` : null;
const msg = (errKey && I18n.t(errKey) !== errKey) ? I18n.t(errKey) : (r.message || r.error);
root.innerHTML = `<div class="text-sm" style="color:var(--error)">${this.esc(msg)}</div>`;
return;
}
if (!r.accounts || r.accounts.length === 0) {
const sourcesHtml = (r.sources || []).map(s => `<li>${this.esc(s.path)}${s.ok ? `${s.accountCount} ${I18n.t('localImport.found')}` : this.esc(s.reason || 'unknown')}</li>`).join('');
root.innerHTML = `
<div style="padding:12px;background:var(--surface-2);border-radius:var(--radius);font-size:13px;line-height:1.6">
<div style="color:var(--warn);font-weight:600">${I18n.t('localImport.empty')}</div>
<div style="color:var(--text-muted);margin-top:4px">${I18n.t('localImport.scannedPaths')}:</div>
<ul style="margin:6px 0 0 18px;color:var(--text-muted);font-size:12px">${sourcesHtml}</ul>
<div style="color:var(--text-muted);margin-top:6px">SQLite: ${r.sqliteSupport} · ${r.platform}</div>
</div>`;
return;
}
const rows = r.accounts.map((a, i) => `
<tr>
<td>${this.esc(a.email || a.label || '-')}</td>
<td><code>${this.esc(a.apiKeyMasked)}</code></td>
<td class="text-xs" style="color:var(--text-muted)">${this.esc(a.source)}</td>
<td>
<button class="btn btn-sm btn-primary" onclick="App.importLocalWindsurfAccount(${i})">${I18n.t('localImport.importBtn')}</button>
</td>
</tr>`).join('');
this._localWindsurfAccounts = r.accounts;
root.innerHTML = `
<div style="padding:12px;background:var(--surface-2);border-radius:var(--radius)">
<div style="font-weight:600;margin-bottom:8px">${I18n.t('localImport.foundCount', { count: r.accounts.length })}</div>
<table class="table table-compact">
<thead><tr><th>${I18n.t('localImport.account')}</th><th>${I18n.t('localImport.key')}</th><th>${I18n.t('localImport.source')}</th><th></th></tr></thead>
<tbody>${rows}</tbody>
</table>
</div>`;
} catch (e) {
root.innerHTML = `<div class="text-sm" style="color:var(--error)">${this.esc(e.message || String(e))}</div>`;
}
},
async importLocalWindsurfAccount(idx) {
const acc = this._localWindsurfAccounts?.[idx];
if (!acc) return;
const r = await this.api('POST', '/accounts', { api_key: acc.apiKey, label: acc.label });
if (r.success) {
this.toast(I18n.t('localImport.importSuccess', { email: acc.email || acc.label }));
this.loadAccounts();
} else {
const errKey = r.error ? `error.${r.error}` : null;
const msg = (errKey && I18n.t(errKey) !== errKey) ? I18n.t(errKey) : (r.error || I18n.t('toast.addFailed'));
this.toast(msg, 'error');
}
},
async toggleAccount(id, status) { await this.api('PATCH', `/accounts/${id}`, { status }); this.loadAccounts(); },
async overrideTier(id, current) {
const newTier = await this.prompt(
I18n.t('modal.overrideTier.title'),
I18n.t('modal.overrideTier.desc', {current}),
[{ name: 'tier', label: I18n.t('field.tier.label'), type: 'select', options: [
{ value: 'pro', label: I18n.t('modal.overrideTier.options.pro') },
{ value: 'free', label: I18n.t('modal.overrideTier.options.free') },
{ value: 'unknown', label: I18n.t('modal.overrideTier.options.unknown') },
], value: current }]
);
if (!newTier) return;
await this.api('PATCH', `/accounts/${id}`, { tier: newTier.tier });
this.toast(I18n.t('account.tierSet', {tier: newTier.tier}), 'success');
this.loadAccounts();
},
async resetErrors(id) { await this.api('PATCH', `/accounts/${id}`, { resetErrors: true }); this.loadAccounts(); this.toast(I18n.t('account.resetErrors')); },
async deleteAccount(id) {
const ok = await this.confirm(I18n.t('modal.deleteAccount.title'), I18n.t('modal.deleteAccount.desc'), { danger: true, okText: I18n.t('action.delete') });
if (!ok) return;
await this.api('DELETE', `/accounts/${id}`);
this.loadAccounts();
this.toast(I18n.t('account.deleteSuccess'));
},
// ─── Models ──────────────────────────────────
async loadModels() {
const [modelData, accessData] = await Promise.all([
this.api('GET', '/models'),
this.api('GET', '/model-access'),
]);
this.allModels = modelData.models || [];
this.modelAccessConfig = accessData.mode ? accessData : { mode: 'all', list: [] };
document.querySelector(`input[name="model-mode"][value="${this.modelAccessConfig.mode}"]`).checked = true;
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 = I18n.t(isAllow ? 'section.modelList.titleAllow' : 'section.modelList.titleBlock');
document.getElementById('model-list-hint').textContent = I18n.t(isAllow ? 'section.modelList.descAllow' : 'section.modelList.descBlock');
const current = this.modelAccessConfig.list;
if (current.length > 0) {
document.getElementById('model-list-current').innerHTML = `
<div class="text-xs text-muted" style="margin-bottom:6px">${I18n.t('model.list.current', {count: current.length})}</div>
<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>`;
} else {
document.getElementById('model-list-current').innerHTML = `<div class="text-xs text-dim">${I18n.t('model.list.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 filtered = this.allModels.filter(m => {
if (search && !m.name.toLowerCase().includes(search) && !m.provider.toLowerCase().includes(search)) return false;
if (provider && m.provider !== provider) return false;
return true;
});
const grouped = {};
for (const m of filtered) {
if (!grouped[m.provider]) grouped[m.provider] = [];
grouped[m.provider].push(m);
}
const container = document.getElementById('model-chips-container');
container.innerHTML = Object.entries(grouped).map(([prov, models]) => `
<div class="provider-group">
<div class="provider-label">${prov}</div>
<div class="model-chips">${models.map(m => {
const inList = list.includes(m.id);
return `<span class="model-chip ${inList ? 'selected' : ''}" onclick="App.toggleModelInList('${this.escJsAttr(m.id)}')">${this.esc(m.name)}</span>`;
}).join('')}</div>
</div>
`).join('') || `<div class="text-sm text-dim">${I18n.t('table.empty.models')}</div>`;
},
async setModelMode(mode) {
await this.api('PUT', '/model-access', { mode });
this.modelAccessConfig.mode = mode;
this.updateModelListUI();
this.toast(I18n.t('toast.modeUpdated'));
},
async toggleModelInList(modelId) {
const idx = this.modelAccessConfig.list.indexOf(modelId);
if (idx > -1) {
await this.api('POST', '/model-access/remove', { model: modelId });
this.modelAccessConfig.list.splice(idx, 1);
} else {
await this.api('POST', '/model-access/add', { model: modelId });
this.modelAccessConfig.list.push(modelId);
}
this.updateModelListUI();
},
async removeModelFromList(modelId) {
await this.api('POST', '/model-access/remove', { model: modelId });
this.modelAccessConfig.list = this.modelAccessConfig.list.filter(m => m !== modelId);
this.updateModelListUI();
},
// ─── Proxy ───────────────────────────────────
async loadProxy() {
const d = await this.api('GET', '/proxy');
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 || '';
// Password is never sent down now — show a placeholder when one is
// stored so the user knows leaving the field blank keeps it.
document.getElementById('proxy-pass').value = '';
document.getElementById('proxy-pass').placeholder = d.global.hasPassword ? I18n.t('proxy.placeholderSaved') : I18n.t('proxy.placeholderPassword');
this._globalProxyHasPassword = !!d.global.hasPassword;
const h = (d.global.host || '').replace(/:\d+$/, '');
const authInfo = d.global.username ? `(${I18n.t('proxy.auth')}: ${d.global.username})` : '';
document.getElementById('proxy-current').textContent = `${I18n.t('proxy.current')}: ${d.global.type}://${h}:${d.global.port}${authInfo}`;
} else {
this._globalProxyHasPassword = false;
document.getElementById('proxy-pass').placeholder = I18n.t('proxy.placeholderPassword');
document.getElementById('proxy-current').textContent = I18n.t('proxy.notConfigured');
}
const accts = await this.api('GET', '/accounts');
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">${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">${I18n.t('proxy.none')}</span>`}</td>
<td class="nowrap">
<div class="btn-group">
<button class="btn btn-outline btn-xs" onclick="App.editAccountProxy('${this.escJsAttr(a.id)}','${this.escJsAttr(a.email)}')">${I18n.t('action.configure')}</button>
${p ? `<button class="btn btn-ghost btn-xs" style="color:var(--error)" onclick="App.clearAccountProxy('${this.escJsAttr(a.id)}')">${I18n.t('action.clear')}</button>` : ''}
</div>
</td>
</tr>`;
}).join('') || `<tr class="empty-row"><td colspan="3">${I18n.t('table.empty.accountsProxy')}</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,
};
// Only send the password field when the user actually typed something
// OR when there's no stored password yet — otherwise the server
// interprets the omission as "keep existing".
const pwInput = document.getElementById('proxy-pass').value;
if (pwInput || !this._globalProxyHasPassword) cfg.password = pwInput;
if (!cfg.host) return this.toast(I18n.t('toast.enterProxyHost'), 'error');
const r = await this.api('PUT', '/proxy/global', cfg);
if (r && r.error) return this.toast(r.error, 'error');
this.toast(I18n.t('toast.globalProxySaved'));
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(I18n.t('toast.globalProxyCleared'));
this.loadProxy();
},
async editAccountProxy(id, label) {
const values = await this.prompt(
I18n.t('modal.configureProxy.title'),
I18n.t('modal.configureProxy.desc', {label}),
[
{ name: 'type', label: I18n.t('field.type.label'), type: 'select', value: 'http', options: [
{ value: 'http', label: 'HTTP' },
{ value: 'https', label: 'HTTPS' },
{ value: 'socks5', label: 'SOCKS5' },
]},
{ name: 'host', label: I18n.t('field.host.label'), placeholder: I18n.t('field.host.placeholder') },
{ name: 'port', label: I18n.t('field.port.label'), type: 'number', placeholder: I18n.t('field.port.placeholder') },
{ name: 'username', label: I18n.t('field.username.label'), placeholder: I18n.t('field.username.placeholder') },
{ name: 'password', label: I18n.t('field.passwordProxy.label'), type: 'password', placeholder: I18n.t('field.passwordProxy.placeholder') },
]
);
if (!values || !values.host) return;
await this.api('PUT', `/proxy/accounts/${id}`, {
type: values.type || 'http',
host: values.host,
port: parseInt(values.port) || 8080,
username: values.username || '',
password: values.password || '',
});
this.toast(I18n.t('toast.proxyConfigured'));
this.loadProxy();
},
async clearAccountProxy(id) {
await this.api('DELETE', `/proxy/accounts/${id}`);
this.toast(I18n.t('toast.proxyCleared'));
this.loadProxy();
},
// ─── Logs ────────────────────────────────────
//
// Uses fetch + ReadableStream instead of EventSource so the dashboard
// password rides in an X-Dashboard-Password header rather than a ?pwd=
// query string (URL-logged in proxies, browser history). The query
// fallback in dashboard/api.js was removed once all callers migrated.
loadLogs() {
if (this.sseConn) { try { this.sseConn.close(); } catch {} this.sseConn = null; }
this.logEntries = [];
document.getElementById('log-container').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(response => {
if (!response.ok || !response.body) throw new Error('SSE connection failed');
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
const pump = () => reader.read().then(({ done, value }) => {
if (done) return;
buffer += decoder.decode(value, { stream: true });
// SSE frames are separated by a blank line; each frame has one or
// more `data:` lines we reassemble here.
let idx;
while ((idx = buffer.indexOf('\n\n')) !== -1) {
const frame = buffer.slice(0, idx);
buffer = buffer.slice(idx + 2);
const payload = frame.split('\n')
.filter(l => l.startsWith('data: '))
.map(l => l.slice(6))
.join('\n');
if (!payload) continue;
try {
const entry = JSON.parse(payload);
this.logEntries.push(entry);
if (this.logEntries.length > 500) this.logEntries.shift();
this.renderLogEntry(entry);
} catch {}
}
return pump();
});
return pump();
})
.catch((err) => {
if (err?.name !== 'AbortError') {
// Silent — EventSource used to swallow errors too. Left as a
// hook in case we want to surface reconnection UI later.
}
});
},
renderLogEntry(entry) {
const container = document.getElementById('log-container');
const levelFilter = document.getElementById('log-level').value;
const search = document.getElementById('log-search').value.toLowerCase();
if (levelFilter && entry.level !== levelFilter) return;
if (search && !entry.msg.toLowerCase().includes(search)) return;
const div = document.createElement('div');
div.className = 'log-entry ' + entry.level;
const ts = new Date(entry.ts).toLocaleTimeString();
div.innerHTML = `<span class="ts">${ts}</span><span class="lvl">${entry.level.toUpperCase()}</span><span>${this.esc(entry.msg)}</span>`;
container.appendChild(div);
if (container.children.length > 500) container.removeChild(container.firstChild);
if (document.getElementById('log-autoscroll').checked) container.scrollTop = container.scrollHeight;
},
debouncedFilterLogs() {
clearTimeout(this._filterTimer);
this._filterTimer = setTimeout(() => this.filterLogs(), 200);
},
filterLogs() {
const container = document.getElementById('log-container');
const frag = document.createDocumentFragment();
const levelFilter = document.getElementById('log-level').value;
const search = document.getElementById('log-search').value.toLowerCase();
for (const entry of this.logEntries) {
if (levelFilter && entry.level !== levelFilter) continue;
if (search && !entry.msg.toLowerCase().includes(search)) continue;
const div = document.createElement('div');
div.className = 'log-entry ' + entry.level;
const ts = new Date(entry.ts).toLocaleTimeString();
div.innerHTML = `<span class="ts">${ts}</span><span class="lvl">${entry.level.toUpperCase()}</span><span>${this.esc(entry.msg)}</span>`;
frag.appendChild(div);
}
container.innerHTML = '';
container.appendChild(frag);
},
clearLogView() {
this.logEntries = [];
document.getElementById('log-container').innerHTML = '';
},
// ─── Stats ───────────────────────────────────
statsRange: 24, // number of hours, or 'all', or 'custom'
statsCustomRange: null, // { start: Date, end: Date } when range='custom'
statsChartType: 'bar',
statsMetrics: { requests: true, success: true, errors: true },
_lastStatsData: null,
// v2.0.59 — warmer, less saturated palette. Old indigo/emerald/rose
// read as "alarming" against the dark plot background; the new tones
// are still readable but feel calmer (warm amber for requests, soft
// teal-mint for success, dusty coral for errors).
_chartColors: {
requests: '#e0b482', // warm amber
success: '#7cc8a6', // soft teal-mint
errors: '#e08a8a', // dusty coral
},
_pieColors: ['#e0b482', '#7cc8a6', '#e08a8a', '#f5d491', '#9bb8d4', '#c9a6cf', '#84c5c9', '#dda5b3'],
setStatsRange(value) {
this.statsRange = value;
this.statsCustomRange = null;
document.querySelectorAll('.stats-range-btn').forEach(b => {
b.classList.toggle('active', String(b.dataset.range) === String(value));
});
this.closeCustomRange();
if (this._lastStatsData) this.renderMainChart(this._lastStatsData);
},
openCustomRange() {
const popup = document.getElementById('custom-range-popup');
if (!popup) return;
popup.style.display = 'block';
// Default to last 7 days
const end = new Date();
const start = new Date(end.getTime() - 7 * 86400_000);
const fmt = d => d.toISOString().slice(0, 10);
const sEl = document.getElementById('range-start');
const eEl = document.getElementById('range-end');
if (sEl && !sEl.value) sEl.value = fmt(start);
if (eEl && !eEl.value) eEl.value = fmt(end);
document.querySelectorAll('.stats-range-btn').forEach(b => {
b.classList.toggle('active', b.dataset.range === 'custom');
});
},
closeCustomRange() {
const popup = document.getElementById('custom-range-popup');
if (popup) popup.style.display = 'none';
},
applyCustomRange() {
const sEl = document.getElementById('range-start');
const eEl = document.getElementById('range-end');
if (!sEl?.value || !eEl?.value) { this.toast(I18n.t('range.customStart'), 'error'); return; }
const start = new Date(sEl.value + 'T00:00:00');
const end = new Date(eEl.value + 'T23:59:59');
if (isNaN(start) || isNaN(end) || start > end) { this.toast('Invalid range', 'error'); return; }
this.statsRange = 'custom';
this.statsCustomRange = { start, end };
this.closeCustomRange();
document.querySelectorAll('.stats-range-btn').forEach(b => {
b.classList.toggle('active', b.dataset.range === 'custom');
});
if (this._lastStatsData) this.renderMainChart(this._lastStatsData);
},
setChartType(type) {
this.statsChartType = type;
document.querySelectorAll('.stats-type-btn').forEach(b => {
b.classList.toggle('active', b.dataset.type === type);
});
if (this._lastStatsData) this.renderMainChart(this._lastStatsData);
},
toggleChartMetric(metric) {
const m = this.statsMetrics;
const tryOff = { ...m, [metric]: !m[metric] };
if (!tryOff.requests && !tryOff.success && !tryOff.errors) return;
this.statsMetrics = tryOff;
if (this._lastStatsData) this.renderMainChart(this._lastStatsData);
},
async loadStats() {
const d = await this.api('GET', '/stats');
this._lastStatsData = d;
const totalReq = d.totalRequests || 0;
const successRate = totalReq > 0 ? ((d.successCount/totalReq)*100).toFixed(1) : '0.0';
const allP95 = Object.values(d.modelCounts || {})
.filter(m => m.p95Ms > 0).map(m => m.p95Ms);
const avgP95 = allP95.length ? Math.round(allP95.reduce((a,b)=>a+b,0) / allP95.length) : 0;
const uptimeSec = d.startedAt ? Math.floor((Date.now() - d.startedAt) / 1000) : 0;
const fmtUptime = s => s < 60 ? `${s}s` : s < 3600 ? `${Math.floor(s/60)}m` : s < 86400 ? `${(s/3600).toFixed(1)}h` : `${(s/86400).toFixed(1)}d`;
// v2.0.69 (#118 wnfilm): show fresh_input / cache_read / cache_write /
// output as separate buckets so users can tell whether their quota
// burn is real input or just cache traffic. Numbers come from the
// tokenTotals state recordTokenUsage feeds on every completed turn.
const tt = d.tokenTotals || { fresh_input: 0, cache_read: 0, cache_write: 0, output: 0, total: 0, requests_with_usage: 0 };
const fmtTok = (n) => n >= 1_000_000 ? (n/1_000_000).toFixed(1) + 'M'
: n >= 1_000 ? (n/1_000).toFixed(1) + 'K'
: String(n);
const tokensCardSub = tt.requests_with_usage > 0
? `${tt.requests_with_usage} req · total ${fmtTok(tt.total)}`
: (I18n.t('card.tokens.subtitle.empty') || 'no usage data yet');
document.getElementById('stats-cards').innerHTML = `
<div class="card info"><div class="card-header"><div class="card-title">${I18n.t('card.stats.title')}</div></div><div class="card-value">${totalReq}</div><div class="card-sub">${I18n.t('card.stats.subtitle', { time: fmtUptime(uptimeSec) })}</div></div>
<div class="card success"><div class="card-header"><div class="card-title">${I18n.t('card.success.title')}</div></div><div class="card-value">${d.successCount || 0}</div><div class="card-sub">${I18n.t('card.success.subtitle', { rate: successRate })}</div></div>
<div class="card error"><div class="card-header"><div class="card-title">${I18n.t('card.errors.title')}</div></div><div class="card-value">${d.errorCount || 0}</div><div class="card-sub">${I18n.t('card.errors.subtitle', { rate: totalReq > 0 ? (100-Number(successRate)).toFixed(1) : '0.0' })}</div></div>
<div class="card accent"><div class="card-header"><div class="card-title">${I18n.t('card.p95Latency.title')}</div></div><div class="card-value">${avgP95}<span style="font-size:14px;font-weight:400;margin-left:4px">ms</span></div><div class="card-sub">${I18n.t('card.p95Latency.subtitle', { count: allP95.length })}</div></div>
<div class="card info" style="grid-column: span 2;">
<div class="card-header"><div class="card-title">${I18n.t('card.tokens.title') || 'Token usage breakdown'}</div></div>
<div style="display:grid;grid-template-columns:repeat(4, 1fr);gap:12px;margin-top:8px;">
<div><div style="font-size:11px;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.04em;">fresh input</div><div style="font-size:18px;font-weight:600;">${fmtTok(tt.fresh_input)}</div></div>
<div><div style="font-size:11px;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.04em;">cache read</div><div style="font-size:18px;font-weight:600;">${fmtTok(tt.cache_read)}</div></div>
<div><div style="font-size:11px;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.04em;">cache write</div><div style="font-size:18px;font-weight:600;">${fmtTok(tt.cache_write)}</div></div>
<div><div style="font-size:11px;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.04em;">output</div><div style="font-size:18px;font-weight:600;">${fmtTok(tt.output)}</div></div>
</div>
<div class="card-sub" style="margin-top:8px;">${tokensCardSub}</div>
</div>
`;
const models = d.modelCounts || {};
const sortedModels = Object.entries(models).sort(([,a],[,b]) => b.requests - a.requests);
const tbody = document.querySelector('#model-stats-table tbody');
tbody.innerHTML = sortedModels.map(([m, s]) => {
const rate = s.requests > 0 ? ((s.success/s.requests)*100).toFixed(1) : '0.0';
const rateColor = Number(rate) >= 95 ? 'var(--success)' : Number(rate) >= 80 ? 'var(--warning, #f59e0b)' : 'var(--error)';
return `
<tr>
<td><code>${this.esc(m)}</code></td>
<td>${s.requests}</td>
<td style="color:var(--success)">${s.success}</td>
<td style="color:${s.errors > 0 ? 'var(--error)' : 'inherit'}">${s.errors}</td>
<td style="color:${rateColor};font-weight:600">${rate}%</td>
<td class="text-sm">${s.avgMs || 0} ms</td>
<td class="text-sm">${s.p50Ms || 0} ms</td>
<td class="text-sm">${s.p95Ms || 0} ms</td>
</tr>`;
}).join('') || `<tr class="empty-row"><td colspan="8">${I18n.t('table.empty.noRequestData')}</td></tr>`;
const accounts = d.accountCounts || {};
const sortedAccts = Object.entries(accounts).sort(([,a],[,b]) => b.requests - a.requests);
const acctBody = document.querySelector('#account-stats-table tbody');
if (acctBody) {
acctBody.innerHTML = sortedAccts.map(([aid, s]) => {
const rate = s.requests > 0 ? ((s.success/s.requests)*100).toFixed(1) : '0.0';
return `<tr>
<td><code>${this.esc(aid)}</code></td>
<td>${s.requests}</td>
<td style="color:var(--success)">${s.success}</td>
<td style="color:${s.errors > 0 ? 'var(--error)' : 'inherit'}">${s.errors}</td>
<td>${rate}%</td>
</tr>`;
}).join('') || `<tr class="empty-row"><td colspan="5">${I18n.t('table.empty.noAccountRequestData')}</td></tr>`;
}
this.renderMainChart(d);
this.renderModelPie(d);
this.poll('stats', () => this.loadStats(), 30000);
},
_setupCanvas(canvas) {
const dpr = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
const ctx = canvas.getContext('2d');
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.scale(dpr, dpr);
return { ctx, W: rect.width, H: rect.height };
},
_filterBuckets(raw) {
const all = (raw || []).map(b => ({
hour: b.hour,
t: new Date(b.hour).getTime(),
requests: b.requests || 0,
errors: b.errors || 0,
success: Math.max(0, (b.requests || 0) - (b.errors || 0)),
}));
if (this.statsRange === 'custom' && this.statsCustomRange) {
const { start, end } = this.statsCustomRange;
const inRange = all.filter(b => b.t >= start.getTime() && b.t <= end.getTime());
return this._padTimeline(inRange, start.getTime(), end.getTime());
}
if (this.statsRange === 'all' || this.statsRange === -1) return all;
// v2.0.59 — fixed-window ranges (6h/24h/7d/30d) now backfill missing
// hours with zero buckets so the chart timeline stays continuous.
// Without this, only hours with traffic show as bars — making 24h
// look like "6 pts" with huge gaps when traffic is sparse.
const n = Number(this.statsRange);
if (!Number.isFinite(n) || n <= 0) return all.slice(-n);
const endMs = Math.floor(Date.now() / 3600_000) * 3600_000;
const startMs = endMs - (n - 1) * 3600_000;
return this._padTimeline(all.filter(b => b.t >= startMs && b.t <= endMs), startMs, endMs);
},
// Generate a contiguous hourly timeline between [startMs, endMs] (both
// inclusive, hour-aligned) and zero-fill any hour the backend didn't
// emit a bucket for. Keeps the bar chart visually rhythmic when traffic
// is sparse — empty bars render as flat baseline ticks instead of huge
// visible gaps that read as "broken chart".
_padTimeline(present, startMs, endMs) {
const map = new Map();
for (const b of present) map.set(Math.floor(b.t / 3600_000) * 3600_000, b);
const out = [];
const startHour = Math.floor(startMs / 3600_000) * 3600_000;
const endHour = Math.floor(endMs / 3600_000) * 3600_000;
for (let t = startHour; t <= endHour; t += 3600_000) {
const hit = map.get(t);
if (hit) { out.push(hit); continue; }
out.push({
hour: new Date(t).toISOString(),
t,
requests: 0,
errors: 0,
success: 0,
});
}
return out;
},
// Aggregate contiguous buckets so the chart stays readable when we have
// hundreds of data points (e.g. 30d = 720 hourly buckets).
_downsample(buckets, maxPoints) {
if (buckets.length <= maxPoints) return buckets;
const step = Math.ceil(buckets.length / maxPoints);
const out = [];
for (let i = 0; i < buckets.length; i += step) {
const g = buckets.slice(i, i + step);
const mid = g[Math.floor(g.length / 2)];
out.push({
hour: mid.hour,
t: mid.t,
requests: g.reduce((a, b) => a + b.requests, 0),
errors: g.reduce((a, b) => a + b.errors, 0),
success: g.reduce((a, b) => a + b.success, 0),
});
}
return out;
},
// Catmull-Rom → Bezier spline. Set move=false to continue the current
// sub-path (important for area fills where we start from the baseline).
_smoothPath(ctx, points, move = true) {
if (points.length < 1) return;
if (move) ctx.moveTo(points[0].x, points[0].y);
else ctx.lineTo(points[0].x, points[0].y);
for (let i = 0; i < points.length - 1; i++) {
const p0 = points[i - 1] || points[i];
const p1 = points[i];
const p2 = points[i + 1];
const p3 = points[i + 2] || p2;
const tension = 0.28;
const cp1x = p1.x + (p2.x - p0.x) * tension;
const cp1y = p1.y + (p2.y - p0.y) * tension;
const cp2x = p2.x - (p3.x - p1.x) * tension;
const cp2y = p2.y - (p3.y - p1.y) * tension;
ctx.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, p2.x, p2.y);
}
},
renderMainChart(d) {
const canvas = document.getElementById('stats-canvas');
const legend = document.getElementById('stats-legend');
const rangeIndicator = document.getElementById('stats-range-indicator');
if (!canvas) return;
let buckets = this._filterBuckets(d.hourlyBuckets);
const MAX_POINTS = 160;
const originalLength = buckets.length;
buckets = this._downsample(buckets, MAX_POINTS);
const { ctx, W, H } = this._setupCanvas(canvas);
if (rangeIndicator) {
const rangeText = this._rangeLabel(originalLength, buckets.length);
rangeIndicator.textContent = rangeText;
rangeIndicator.style.display = rangeText ? 'block' : 'none';
}
if (buckets.length < 1) {
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = 'rgba(255,255,255,.25)';
ctx.font = '500 13px var(--font), sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(I18n.t('table.empty.noData'), W / 2, H / 2);
if (legend) legend.innerHTML = '';
this._chartState = null;
this._chartHitMap = null;
return;
}
const pad = { t: 18, r: 14, b: 30, l: 44 };
const cW = W - pad.l - pad.r, cH = H - pad.t - pad.b;
const step = buckets.length > 1 ? cW / (buckets.length - 1) : cW;
const slotW = buckets.length > 1 ? cW / buckets.length : cW;
const m = { ...this.statsMetrics };
const type = this.statsChartType;
let maxY;
if (type === 'stacked') {
maxY = Math.max(1, ...buckets.map(b => (m.success ? b.success : 0) + (m.errors ? b.errors : 0) || (m.requests ? b.requests : 0)));
} else if (type === 'bar') {
maxY = Math.max(1, ...buckets.map(b => b.requests));
} else {
const vals = [];
if (m.requests) vals.push(...buckets.map(b => b.requests));
if (m.success) vals.push(...buckets.map(b => b.success));
if (m.errors) vals.push(...buckets.map(b => b.errors));
maxY = Math.max(1, ...vals);
}
const niceY = (n) => {
const exp = Math.pow(10, Math.floor(Math.log10(n)));
const frac = n / exp;
const fr = frac <= 1 ? 1 : frac <= 2 ? 2 : frac <= 2.5 ? 2.5 : frac <= 5 ? 5 : 10;
return fr * exp;
};
maxY = niceY(maxY);
const xAt = (i) => buckets.length > 1 ? pad.l + i * step : pad.l + cW / 2;
const yAt = (v) => pad.t + cH - (v / maxY) * cH;
// Persist state — hit-map uses final coords so tooltip works during animation
this._chartState = { ctx, W, H, buckets, pad, cH, cW, slotW, step, maxY, m, type, xAt, yAt };
this._chartHitMap = { buckets, xAt, yAt, pad, cH, slotW, W, H };
// Bind hover tooltip once
this._bindChartHover();
// Animate: paint repeatedly with 0 → 1 progress
cancelAnimationFrame(this._chartAnim || 0);
const start = performance.now();
const dur = 480;
const loop = (now) => {
const t = Math.min(1, (now - start) / dur);
const p = 1 - Math.pow(1 - t, 3); // ease-out cubic
this._paintChart(p);
if (t < 1) this._chartAnim = requestAnimationFrame(loop);
};
this._chartAnim = requestAnimationFrame(loop);
// Legend with totals (drawn once, doesn't animate)
if (legend) {
const totals = {
requests: buckets.reduce((a, b) => a + b.requests, 0),
success: buckets.reduce((a, b) => a + b.success, 0),
errors: buckets.reduce((a, b) => a + b.errors, 0),
};
const items = [
{ key: 'requests', label: I18n.t('section.requestChart.legendRequests'), color: this._chartColors.requests },
{ key: 'success', label: I18n.t('section.requestChart.legendSuccess'), color: this._chartColors.success },
{ key: 'errors', label: I18n.t('section.requestChart.legendErrors'), color: this._chartColors.errors },
];
legend.innerHTML = items.map(it => `
<div class="chart-legend-item ${m[it.key] ? '' : 'muted'}" onclick="App.toggleChartMetric('${it.key}')">
<span class="chart-legend-swatch dot" style="background:${it.color}"></span>
<span>${it.label}</span>
<span class="chart-legend-value">${totals[it.key]}</span>
</div>
`).join('');
}
},
_paintChart(progress) {
const s = this._chartState;
if (!s) return;
const { ctx, W, H, buckets, pad, cH, cW, slotW, maxY, m, type, xAt, yAt } = s;
const p = progress;
ctx.clearRect(0, 0, W, H);
// Background gradient fade for the plot area — very subtle
const bgGrad = ctx.createLinearGradient(0, pad.t, 0, pad.t + cH);
bgGrad.addColorStop(0, 'rgba(129,140,248,.02)');
bgGrad.addColorStop(1, 'rgba(129,140,248,0)');
ctx.fillStyle = bgGrad;
ctx.fillRect(pad.l, pad.t, cW, cH);
// Horizontal grid (dashed)
ctx.save();
ctx.strokeStyle = 'rgba(255,255,255,.045)';
ctx.lineWidth = 1;
ctx.setLineDash([2, 4]);
for (let i = 0; i <= 4; i++) {
const y = pad.t + (cH / 4) * i;
ctx.beginPath();
ctx.moveTo(pad.l, y);
ctx.lineTo(W - pad.r, y);
ctx.stroke();
}
ctx.restore();
// Y-axis labels
ctx.fillStyle = 'rgba(255,255,255,.38)';
ctx.font = '500 10px "JetBrains Mono", "Consolas", monospace';
ctx.textAlign = 'right';
ctx.textBaseline = 'middle';
for (let i = 0; i <= 4; i++) {
const v = Math.round(maxY * (4 - i) / 4);
const y = pad.t + (cH / 4) * i;
ctx.fillText(this._fmtNum(v), pad.l - 10, y);
}
const base = pad.t + cH;
const drawSmoothSeries = (values, color, opts = {}) => {
// Animate: lerp each y from the baseline toward target by progress.
const points = values.map((v, i) => {
const targetY = yAt(v);
return { x: xAt(i), y: base + (targetY - base) * p };
});
if (points.length < 1) return;
if (opts.fill && points.length >= 2) {
const grad = ctx.createLinearGradient(0, pad.t, 0, pad.t + cH);
grad.addColorStop(0, color + '4d');
grad.addColorStop(1, color + '00');
ctx.fillStyle = grad;
ctx.beginPath();
ctx.moveTo(points[0].x, base);
this._smoothPath(ctx, points, false);
ctx.lineTo(points[points.length - 1].x, base);
ctx.closePath();
ctx.fill();
}
ctx.save();
ctx.strokeStyle = color;
ctx.lineWidth = opts.thin ? 1.5 : 2.25;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
if (opts.dash) ctx.setLineDash([5, 4]);
ctx.shadowColor = color;
ctx.shadowBlur = opts.fill ? 8 : 4;
ctx.beginPath();
if (points.length >= 2) {
this._smoothPath(ctx, points);
} else {
ctx.moveTo(points[0].x, points[0].y);
}
ctx.stroke();
ctx.restore();
// Data points — only when not too dense, on nonzero, and only after
// the growth animation is far enough along so dots don't pop.
if (points.length <= 80 && p > 0.6) {
const dotOpacity = Math.min(1, (p - 0.6) / 0.4);
ctx.save();
ctx.globalAlpha = dotOpacity;
points.forEach((pt, i) => {
if (values[i] === 0) return;
ctx.beginPath();
ctx.arc(pt.x, pt.y, 2.5, 0, Math.PI * 2);
ctx.fillStyle = '#09090b';
ctx.fill();
ctx.strokeStyle = color;
ctx.lineWidth = 1.8;
ctx.stroke();
});
ctx.restore();
}
};
const drawBars = () => {
const gap = Math.min(3, slotW * 0.2);
const barW = Math.max(2, slotW - gap);
buckets.forEach((b, i) => {
const v = b.requests;
const x = xAt(i) - barW / 2;
// v2.0.59 — render a 1px baseline tick for empty hours so the
// timeline has visible rhythm even when traffic is sparse.
if (v === 0) {
ctx.fillStyle = 'rgba(255,255,255,.08)';
ctx.fillRect(x, base - 1, barW, 1);
return;
}
const h = (v / maxY) * cH * p;
const errRatio = v > 0 ? b.errors / v : 0;
const color = this._errorTintedColor(errRatio);
const y = base - h;
const grad = ctx.createLinearGradient(0, y, 0, y + h);
grad.addColorStop(0, color);
grad.addColorStop(1, color + 'cc');
ctx.fillStyle = grad;
const rr = Math.min(barW / 2, 3);
this._roundRect(ctx, x, y, barW, h, rr);
ctx.fill();
if (barW > 4 && h > 2) {
ctx.fillStyle = 'rgba(255,255,255,.1)';
this._roundRect(ctx, x, y, barW, Math.min(2, h), rr);
ctx.fill();
}
});
};
const drawStacked = () => {
const gap = Math.min(3, slotW * 0.2);
const barW = Math.max(2, slotW - gap);
buckets.forEach((b, i) => {
const cx = xAt(i);
// v2.0.59 — baseline tick for empty hours so the chart looks
// continuous even when traffic is sparse (24h with 6 active
// hours used to render as huge gaps).
if ((m.success ? b.success : 0) + (m.errors ? b.errors : 0) === 0
&& (m.requests ? b.requests : 0) === 0) {
ctx.fillStyle = 'rgba(255,255,255,.08)';
ctx.fillRect(cx - barW / 2, base - 1, barW, 1);
return;
}
let stackV = 0;
const segments = [];
if (m.success && b.success > 0) segments.push({ v: b.success, color: this._chartColors.success });
if (m.errors && b.errors > 0) segments.push({ v: b.errors, color: this._chartColors.errors });
if (!segments.length && m.requests && b.requests > 0) segments.push({ v: b.requests, color: this._chartColors.requests });
segments.forEach((seg, si) => {
const segBottomY = base - (stackV / maxY) * cH * p;
const segTopY = base - ((stackV + seg.v) / maxY) * cH * p;
const h = segBottomY - segTopY;
if (h <= 0) { stackV += seg.v; return; }
const isTop = si === segments.length - 1;
const rr = Math.min(barW / 2, 3);
const grad = ctx.createLinearGradient(0, segTopY, 0, segBottomY);
grad.addColorStop(0, seg.color);
grad.addColorStop(1, seg.color + 'b3');
ctx.fillStyle = grad;
if (isTop) {
this._roundRectTop(ctx, cx - barW / 2, segTopY, barW, h, rr);
} else {
ctx.fillRect(cx - barW / 2, segTopY, barW, h);
}
ctx.fill();
stackV += seg.v;
});
});
};
// Render by type
if (type === 'bar') {
drawBars();
} else if (type === 'stacked') {
drawStacked();
} else {
// area / line
const fill = type === 'area';
// Order matters: draw success below errors below requests for z-order
if (m.success) drawSmoothSeries(buckets.map(b => b.success), this._chartColors.success, { fill: fill, thin: false });
if (m.errors) drawSmoothSeries(buckets.map(b => b.errors), this._chartColors.errors, { fill: false, thin: true, dash: false });
if (m.requests && type === 'line') drawSmoothSeries(buckets.map(b => b.requests), this._chartColors.requests, { fill: false, thin: false });
if (m.requests && type === 'area') drawSmoothSeries(buckets.map(b => b.requests), this._chartColors.requests, { fill: true, thin: false });
}
// v2.0.60 — hover crosshair: vertical line + highlight ring on the
// current bucket's bar, only after the entry animation finishes
// (p === 1) so it doesn't fight the growth tweens.
if (p === 1 && Number.isInteger(this._chartCursorIdx) && this._chartCursorIdx >= 0 && this._chartCursorIdx < buckets.length) {
const cx = xAt(this._chartCursorIdx);
ctx.save();
ctx.strokeStyle = 'rgba(224,180,130,.42)';
ctx.lineWidth = 1;
ctx.setLineDash([3, 3]);
ctx.beginPath();
ctx.moveTo(cx, pad.t);
ctx.lineTo(cx, pad.t + cH);
ctx.stroke();
ctx.restore();
const hb = buckets[this._chartCursorIdx];
const total = (m.success ? hb.success : 0) + (m.errors ? hb.errors : 0) || hb.requests;
if (total > 0) {
const yT = yAt(total);
ctx.save();
ctx.fillStyle = 'rgba(255,255,255,0.95)';
ctx.beginPath();
ctx.arc(cx, yT, 4, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = 'rgba(224,180,130,.85)';
ctx.lineWidth = 1.5;
ctx.stroke();
ctx.restore();
} else {
// Empty bucket — small marker on baseline so user still sees they're hovering.
ctx.save();
ctx.fillStyle = 'rgba(224,180,130,.6)';
ctx.beginPath();
ctx.arc(cx, base, 3, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
}
// X-axis labels — smart step based on time span
ctx.fillStyle = 'rgba(255,255,255,.38)';
ctx.font = '500 10px "JetBrains Mono", "Consolas", monospace';
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
const spanHours = buckets.length > 1 ? (buckets[buckets.length - 1].t - buckets[0].t) / 3600_000 : 0;
const useDate = spanHours >= 48;
const labelCount = Math.min(8, buckets.length);
const labelStep = Math.max(1, Math.floor(buckets.length / labelCount));
let lastLabel = null;
let lastX = -Infinity;
const minGapPx = useDate ? 48 : 36;
buckets.forEach((b, i) => {
if (i % labelStep !== 0 && i !== buckets.length - 1) return;
const dt = new Date(b.hour);
const txt = useDate
? `${dt.getMonth() + 1}/${dt.getDate()}`
: dt.getHours().toString().padStart(2, '0') + ':00';
const x = xAt(i);
if (txt === lastLabel) return;
if (x - lastX < minGapPx && i !== buckets.length - 1) return;
lastLabel = txt;
lastX = x;
ctx.fillText(txt, x, pad.t + cH + 10);
});
},
_bindChartHover() {
const canvas = document.getElementById('stats-canvas');
const tipEl = document.getElementById('stats-tooltip');
if (!canvas || !tipEl || canvas._hoverBound) return;
canvas._hoverBound = true;
canvas.addEventListener('mousemove', (ev) => {
const hm = this._chartHitMap;
if (!hm) return;
const rect = canvas.getBoundingClientRect();
const x = ev.clientX - rect.left;
const y = ev.clientY - rect.top;
if (x < hm.pad.l || x > hm.W - 8 || y < hm.pad.t || y > hm.pad.t + hm.cH) {
tipEl.classList.remove('show');
if (this._chartCursorIdx !== -1) {
this._chartCursorIdx = -1;
this._paintChart(1);
}
return;
}
let best = 0, bestD = Infinity;
hm.buckets.forEach((_, i) => {
const d = Math.abs(hm.xAt(i) - x);
if (d < bestD) { bestD = d; best = i; }
});
// v2.0.60 — repaint with cursor index when it changes, so the
// vertical crosshair "snaps" to the closest bucket as the mouse
// moves. Skip when index is unchanged to avoid wasted draws.
if (this._chartCursorIdx !== best) {
this._chartCursorIdx = best;
this._paintChart(1);
}
const b = hm.buckets[best];
const rate = b.requests > 0 ? ((b.success / b.requests) * 100).toFixed(1) : '—';
const time = new Date(b.hour).toLocaleString();
const c = this._chartColors;
tipEl.innerHTML = `
<div class="tt-time">${this.esc(time)}</div>
<div class="tt-row"><span class="tt-dot" style="background:${c.requests}"></span><span class="tt-label">${I18n.t('section.requestChart.tooltipRequests')}</span><span class="tt-value">${b.requests}</span></div>
<div class="tt-row"><span class="tt-dot" style="background:${c.success}"></span><span class="tt-label">${I18n.t('section.requestChart.tooltipSuccess')}</span><span class="tt-value">${b.success}</span></div>
<div class="tt-row"><span class="tt-dot" style="background:${c.errors}"></span><span class="tt-label">${I18n.t('section.requestChart.tooltipErrors')}</span><span class="tt-value">${b.errors}</span></div>
<div class="tt-row"><span class="tt-label">${I18n.t('section.requestChart.tooltipRate')}</span><span class="tt-value">${rate}%</span></div>
`;
tipEl.classList.add('show');
const tipRect = tipEl.getBoundingClientRect();
let left = hm.xAt(best);
const halfW = tipRect.width / 2;
left = Math.min(hm.W - halfW - 6, Math.max(halfW + 6, left));
const top = hm.pad.t + 4;
tipEl.style.left = left + 'px';
tipEl.style.top = top + 'px';
});
canvas.addEventListener('mouseleave', () => {
tipEl.classList.remove('show');
if (this._chartCursorIdx !== -1) {
this._chartCursorIdx = -1;
this._paintChart(1);
}
});
},
_bindPieHover() {
const canvas = document.getElementById('model-pie-canvas');
const tipEl = document.getElementById('model-pie-tooltip');
if (!canvas || !tipEl || canvas._pieHoverBound) return;
canvas._pieHoverBound = true;
canvas.addEventListener('mousemove', (ev) => {
const hm = this._pieHitMap;
if (!hm) { tipEl.classList.remove('show'); return; }
const rect = canvas.getBoundingClientRect();
const x = ev.clientX - rect.left;
const y = ev.clientY - rect.top;
const dx = x - hm.cx, dy = y - hm.cy;
const r = Math.hypot(dx, dy);
if (r < hm.rInner || r > hm.rOuter) { tipEl.classList.remove('show'); return; }
// Pie starts at -π/2 going clockwise. Normalize cursor angle into the
// same 0..2π space relative to the start angle so segment boundaries
// align with the drawn arcs.
let a = Math.atan2(dy, dx) + Math.PI / 2;
if (a < 0) a += Math.PI * 2;
const startAngle0 = -Math.PI / 2;
const seg = hm.segments.find(s => {
const sNorm = s.startAngle - startAngle0;
const eNorm = s.endAngle - startAngle0;
return a >= sNorm && a < eNorm;
});
if (!seg) { tipEl.classList.remove('show'); return; }
const pct = (seg.count / hm.total * 100).toFixed(1);
const successRate = seg.count > 0 ? (seg.success / seg.count * 100).toFixed(1) : '—';
tipEl.innerHTML = `
<div class="tt-time">${this.esc(seg.name)}</div>
<div class="tt-row"><span class="tt-dot" style="background:${seg.color}"></span><span class="tt-label">${I18n.t('section.requestChart.tooltipRequests')}</span><span class="tt-value">${seg.count} (${pct}%)</span></div>
<div class="tt-row"><span class="tt-label">${I18n.t('section.requestChart.tooltipSuccess')}</span><span class="tt-value">${seg.success}</span></div>
<div class="tt-row"><span class="tt-label">${I18n.t('section.requestChart.tooltipErrors')}</span><span class="tt-value">${seg.errors}</span></div>
<div class="tt-row"><span class="tt-label">${I18n.t('section.requestChart.tooltipRate')}</span><span class="tt-value">${successRate}%</span></div>
`;
tipEl.classList.add('show');
// Tooltip lives inside chart-pie-body (position:relative). Position it
// relative to that parent using cursor coords already in canvas space.
tipEl.style.left = x + 'px';
tipEl.style.top = Math.max(0, y - 12) + 'px';
});
canvas.addEventListener('mouseleave', () => { tipEl.classList.remove('show'); });
},
_roundRect(ctx, x, y, w, h, r) {
if (h <= 0 || w <= 0) return;
const rr = Math.min(r, w / 2, h / 2);
ctx.beginPath();
ctx.moveTo(x + rr, y);
ctx.lineTo(x + w - rr, y);
ctx.quadraticCurveTo(x + w, y, x + w, y + rr);
ctx.lineTo(x + w, y + h);
ctx.lineTo(x, y + h);
ctx.lineTo(x, y + rr);
ctx.quadraticCurveTo(x, y, x + rr, y);
ctx.closePath();
},
_roundRectTop(ctx, x, y, w, h, r) {
if (h <= 0 || w <= 0) return;
const rr = Math.min(r, w / 2, h);
ctx.beginPath();
ctx.moveTo(x + rr, y);
ctx.lineTo(x + w - rr, y);
ctx.quadraticCurveTo(x + w, y, x + w, y + rr);
ctx.lineTo(x + w, y + h);
ctx.lineTo(x, y + h);
ctx.lineTo(x, y + rr);
ctx.quadraticCurveTo(x, y, x + rr, y);
ctx.closePath();
},
// Interpolate indigo → amber → rose based on error ratio. Returns hex so
// callers can safely append alpha suffixes like '+cc'.
_errorTintedColor(ratio) {
const clamp01 = (x) => Math.max(0, Math.min(1, x));
const r = clamp01(ratio);
if (r === 0) return this._chartColors.requests; // indigo
const mix = (c1, c2, t) => {
const p1 = this._hexToRgb(c1), p2 = this._hexToRgb(c2);
const rr = Math.round(p1.r + (p2.r - p1.r) * t);
const gg = Math.round(p1.g + (p2.g - p1.g) * t);
const bb = Math.round(p1.b + (p2.b - p1.b) * t);
const to2 = (n) => n.toString(16).padStart(2, '0');
return '#' + to2(rr) + to2(gg) + to2(bb);
};
if (r <= 0.3) return mix('#818cf8', '#fbbf24', r / 0.3);
return mix('#fbbf24', '#fb7185', (r - 0.3) / 0.7);
},
_hexToRgb(h) {
const s = h.replace('#', '');
return {
r: parseInt(s.slice(0, 2), 16),
g: parseInt(s.slice(2, 4), 16),
b: parseInt(s.slice(4, 6), 16),
};
},
_fmtNum(n) {
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1).replace(/\.0$/, '') + 'M';
if (n >= 1_000) return (n / 1_000).toFixed(1).replace(/\.0$/, '') + 'k';
return String(n);
},
_rangeLabel(originalPts, shownPts) {
if (!originalPts) return '';
const fmt = (d) => `${d.getMonth()+1}/${d.getDate()}`;
if (this.statsRange === 'custom' && this.statsCustomRange) {
const { start, end } = this.statsCustomRange;
return `${fmt(start)}${fmt(end)} · ${originalPts} pts`;
}
if (this.statsRange === 'all' || this.statsRange === -1) {
return `All · ${originalPts} pts`;
}
const hours = Number(this.statsRange);
if (hours <= 72) return `${hours}h · ${originalPts} pts`;
const days = Math.round(hours / 24);
const note = shownPts < originalPts ? ` (sampled to ${shownPts})` : '';
return `${days}d · ${originalPts} pts${note}`;
},
renderModelPie(d) {
const canvas = document.getElementById('model-pie-canvas');
const legend = document.getElementById('model-pie-legend');
if (!canvas) return;
const entries = Object.entries(d.modelCounts || {})
.map(([name, s]) => ({ name, count: s.requests || 0, success: s.success || 0, errors: s.errors || 0 }))
.filter(e => e.count > 0)
.sort((a, b) => b.count - a.count);
const top = entries.slice(0, 8);
const others = entries.slice(8).reduce((acc, e) => ({
count: acc.count + e.count, success: acc.success + e.success, errors: acc.errors + e.errors,
}), { count: 0, success: 0, errors: 0 });
if (others.count > 0) top.push({ name: 'others', count: others.count, success: others.success, errors: others.errors });
const total = top.reduce((a, b) => a + b.count, 0);
const { ctx, W, H } = this._setupCanvas(canvas);
ctx.clearRect(0, 0, W, H);
if (total === 0) {
ctx.fillStyle = 'rgba(255,255,255,.3)';
ctx.font = '13px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(I18n.t('table.empty.noData'), W / 2, H / 2);
if (legend) legend.innerHTML = '';
this._pieHitMap = null;
return;
}
const cx = W / 2, cy = H / 2;
const rOuter = Math.min(W, H) / 2 - 6;
const rInner = rOuter * 0.58;
const segments = [];
let angle = -Math.PI / 2;
top.forEach((seg, i) => {
const frac = seg.count / total;
const a2 = angle + frac * Math.PI * 2;
ctx.beginPath();
ctx.moveTo(cx + Math.cos(angle) * rInner, cy + Math.sin(angle) * rInner);
ctx.arc(cx, cy, rOuter, angle, a2);
ctx.arc(cx, cy, rInner, a2, angle, true);
ctx.closePath();
ctx.fillStyle = this._pieColors[i % this._pieColors.length];
ctx.fill();
segments.push({ ...seg, color: this._pieColors[i % this._pieColors.length], startAngle: angle, endAngle: a2 });
angle = a2;
});
this._pieHitMap = { cx, cy, rOuter, rInner, total, segments, W, H };
this._bindPieHover();
// Center label
ctx.fillStyle = 'var(--text)';
ctx.fillStyle = '#f4f4f5';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.font = '600 22px var(--mono), monospace';
ctx.fillText(total, cx, cy - 6);
ctx.fillStyle = 'rgba(255,255,255,.4)';
ctx.font = '10px sans-serif';
ctx.fillText(I18n.t('section.requestChart.legendRequests'), cx, cy + 12);
if (legend) {
legend.innerHTML = top.map((seg, i) => {
const pct = (seg.count / total * 100).toFixed(1);
return `<div class="pie-legend-item" title="${this.esc(seg.name)}">
<span class="pie-legend-dot" style="background:${this._pieColors[i % this._pieColors.length]}"></span>
<span class="pie-legend-name">${this.esc(seg.name)}</span>
<span class="pie-legend-count">${seg.count}</span>
<span class="pie-legend-pct">${pct}%</span>
</div>`;
}).join('');
}
},
async resetStats() {
const ok = await this.confirm(I18n.t('modal.resetStats.title'), I18n.t('modal.resetStats.desc'), { danger: true, okText: I18n.t('action.reset') });
if (!ok) return;
await this.api('DELETE', '/stats');
this.toast(I18n.t('toast.statsReset'));
this.loadStats();
},
// ─── Experimental features ───────────────────
PROVIDER_LABELS: {
anthropic: 'Anthropic · Claude',
openai: 'OpenAI · GPT / o Series',
google: 'Google · Gemini',
deepseek: 'DeepSeek',
xai: 'xAI · Grok',
alibaba: 'Alibaba · Qwen',
moonshot: 'Moonshot · Kimi',
zhipu: 'Zhipu · GLM',
minimax: 'MiniMax',
windsurf: 'Windsurf · SWE',
},
async loadExperimental() {
// v2.0.56: refresh the credentials snapshot whenever the user opens
// the experimental/settings panel so the masked key + source labels
// stay in sync with what runtime-config.json actually holds.
this.loadCredentials();
const d = await this.api('GET', '/experimental');
const cb = document.getElementById('exp-cascade-reuse');
if (cb) cb.checked = !!d.flags?.cascadeConversationReuse;
// v2.0.58 — drought-mode premium gate toggle.
const cbDrought = document.getElementById('exp-drought-restrict');
if (cbDrought) cbDrought.checked = !!d.flags?.droughtRestrictPremium;
// v2.0.70 (#112 follow-up) — quiet-window auto-update toggle.
const cbQw = document.getElementById('exp-quiet-window');
if (cbQw) cbQw.checked = !!d.flags?.autoUpdateQuietWindow;
this.loadQuietWindowStatus();
// 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';
}
const pool = d.conversationPool || {};
document.getElementById('exp-pool-cards').innerHTML = `
<div class="card info">
<div class="card-header"><div class="card-title">${I18n.t('card.hitRate.title')}</div></div>
<div class="card-value">${pool.hitRate || '0.0'}%</div>
<div class="card-sub">${I18n.t('card.hitRate.subtitle', { hits: pool.hits || 0, misses: pool.misses || 0 })}</div>
</div>
<div class="card">
<div class="card-header"><div class="card-title">${I18n.t('card.poolSize.title')}</div></div>
<div class="card-value">${pool.size || 0}</div>
<div class="card-sub">${I18n.t('card.poolSize.subtitle', { max: pool.maxSize || 0, ttl: Math.round((pool.ttlMs || 0) / 60000) })}</div>
</div>
<div class="card">
<div class="card-header"><div class="card-title">${I18n.t('card.stores.title')}</div></div>
<div class="card-value">${pool.stores || 0}</div>
<div class="card-sub">${I18n.t('card.stores.subtitle', { expired: pool.expired || 0, evicted: pool.evictions || 0 })}</div>
</div>
`;
// System prompts editor
const sp = await this.api('GET', '/system-prompts');
const spHolder = document.getElementById('system-prompts-editor');
if (spHolder) {
const labels = {
toolReinforcement: I18n.t('experimental.toolReinforcement'),
communicationWithTools: I18n.t('experimental.communicationWithTools'),
communicationNoTools: I18n.t('experimental.communicationNoTools')
};
spHolder.innerHTML = Object.entries(sp.prompts || {}).map(([key, text]) => {
const safeKey = this.esc(key);
const keyArg = this.escJsAttr(key);
const promptId = this.systemPromptDomId(key);
return `
<div style="border:1px solid var(--border);border-radius:var(--radius);padding:12px;background:var(--surface)">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px">
<div><code style="color:var(--accent);font-weight:600">${safeKey}</code> <span class="text-sm text-muted">${this.esc(labels[key] || key)}</span></div>
<div class="btn-group">
<button class="btn btn-ghost btn-xs" onclick="App.resetSystemPrompt('${keyArg}')">${I18n.t('experimental.default')}</button>
<button class="btn btn-primary btn-xs" onclick="App.saveSystemPrompt('${keyArg}')">${I18n.t('experimental.save')}</button>
</div>
</div>
<textarea id="${promptId}" class="input" rows="3" style="width:100%;font-family:var(--mono);font-size:12px;resize:vertical;line-height:1.5">${this.esc(text)}</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} ${I18n.t('toast.saved')}`, 'success');
},
async resetSystemPrompt(key) {
const ok = await this.confirm(I18n.t('action.reset'), `${key} ${I18n.t('confirm.restoreDefaultDesc') || 'restore to default?'}`, { okText: I18n.t('action.reset') });
if (!ok) return;
await this.api('DELETE', `/system-prompts/${encodeURIComponent(key)}`);
this.toast(`${key} ${I18n.t('experimental.restored') || 'reset'}`, 'success');
this.loadExperimental();
},
async testLoginProxy() {
const host = document.getElementById('wl-proxy-host').value.trim();
const port = parseInt(document.getElementById('wl-proxy-port').value) || 0;
if (!host || !port) return this.toast(I18n.t('toast.enterHostPort'), 'error');
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;
const btn = document.getElementById('wl-proxy-test-btn');
const out = document.getElementById('wl-proxy-test-result');
btn.disabled = true;
out.textContent = I18n.t('status.testing');
out.style.color = 'var(--text-muted)';
try {
const r = await this.api('POST', '/test-proxy', { host, port, username, password, type });
if (r.ok) {
out.textContent = I18n.t('status.proxySuccess', { ip: r.egressIp, latency: r.latencyMs });
out.style.color = 'var(--success)';
} else {
out.textContent = I18n.t('status.proxyFailed', { error: r.error, latency: r.latencyMs });
out.style.color = 'var(--error)';
}
} catch (e) {
out.textContent = `✗ ${e.message}`;
out.style.color = 'var(--error)';
} finally {
btn.disabled = false;
}
},
async toggleExperimental(key, enabled) {
try {
await this.api('PUT', '/experimental', { [key]: enabled });
this.toast(enabled ? I18n.t('toast.experimentalEnabled') : I18n.t('toast.experimentalDisabled'), 'success');
this.loadExperimental();
} catch (e) {
this.toast(I18n.t('toast.toggleFailed', { error: e.message }), 'error');
this.loadExperimental();
}
},
// v2.0.70 (#112 follow-up) — quiet-window status panel + manual run.
async loadQuietWindowStatus() {
const el = document.getElementById('quiet-window-status');
if (!el) return;
try {
const r = await this.api('GET', '/auto-update/quiet-window');
const reason = r.decision?.reason || 'unknown';
const labelKey = ({
disabled: 'quietWindow.status.disabled',
'cold-start': 'quietWindow.status.coldStart',
cooldown: 'quietWindow.status.cooldown',
busy: 'quietWindow.status.busy',
eligible: 'quietWindow.status.eligible',
})[reason];
const label = labelKey ? I18n.t(labelKey) : reason;
const ring = r.ringSize || 0;
const last = r.lastUpdateAt
? new Date(r.lastUpdateAt).toLocaleString()
: I18n.t('quietWindow.never');
el.textContent = `${label} - ring=${ring} - ${I18n.t('quietWindow.lastUpdate')} ${last}`;
} catch (e) {
el.textContent = `${I18n.t('quietWindow.loadFailed')}: ${e.message}`;
}
},
async runQuietWindowNow() {
try {
const r = await this.api('POST', '/auto-update/quiet-window/run', {});
const reason = r.result?.reason || 'unknown';
const triggered = r.result?.result?.ok ? ` - ${I18n.t('quietWindow.triggered')}` : '';
this.toast(`tick reason=${reason}${triggered}`, 'info');
this.loadQuietWindowStatus();
} catch (e) {
this.toast(`${I18n.t('quietWindow.triggerFailed')}: ${e.message}`, 'error');
}
},
async clearConversationPool() {
const ok = await this.confirm(I18n.t('action.clearPool'), I18n.t('confirm.clearPoolDesc') || 'All active Cascade sessions will be cleared. Continue?', { okText: I18n.t('action.clearPool') });
if (!ok) return;
const r = await this.api('DELETE', '/experimental/conversation-pool');
this.toast(`${I18n.t('toast.cleared')} ${r.cleared || 0}`, 'success');
this.loadExperimental();
},
// ─── Drought banner (v2.0.57 Fix 5) ─────────────────────────
async loadDrought() {
const banner = document.getElementById('drought-banner');
const msg = document.getElementById('drought-message');
const detail = document.getElementById('drought-detail');
if (!banner || !msg || !detail) return;
try {
const d = await this.api('GET', '/drought');
if (!d?.drought) {
banner.style.display = 'none';
return;
}
banner.style.display = '';
const lw = d.lowestWeeklyPercent;
const ld = d.lowestDailyPercent;
const summary = (lw == null ? '' : `min weekly=${lw.toFixed(0)}%`)
+ (ld == null ? '' : ` · min daily=${ld.toFixed(0)}%`)
+ ` · ${d.knownAccounts}/${d.activeAccounts} accounts known`;
msg.textContent = I18n.t('drought.body') || msg.textContent;
detail.textContent = summary;
} catch (e) {
// no /drought endpoint on older backends — silently hide.
banner.style.display = 'none';
}
},
// ─── Credentials (v2.0.56) ──────────────────────────────────
async loadCredentials() {
try {
const d = await this.api('GET', '/settings/credentials');
document.getElementById('credentials-apikey-source').textContent = '[' + (d.apiKeySource || 'unset') + ']';
document.getElementById('credentials-apikey-masked').textContent = d.apiKey_masked || '(unset)';
document.getElementById('credentials-dashboardpw-source').textContent = '[' + (d.dashboardPasswordSource || 'unset') + ']';
document.getElementById('credentials-dashboardpw-status').textContent =
d.dashboardPasswordSet ? (I18n.t('credentials.set') || 'set') : (I18n.t('credentials.unset') || 'unset');
} catch (e) {
this.toast('Failed to load credentials: ' + (e.message || ''), 'error');
}
},
toggleApiKeyVisibility() {
const el = document.getElementById('credentials-apikey-input');
el.type = el.type === 'password' ? 'text' : 'password';
},
toggleDashboardPwVisibility() {
const el = document.getElementById('credentials-dashboardpw-input');
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);
const value = el.value;
const labelMap = {
apiKey: I18n.t('credentials.apiKey') || 'API_KEY',
dashboardPassword: I18n.t('credentials.dashboardPassword') || 'Dashboard password',
};
const cleared = value === '';
const okConfirm = await this.confirm(
labelMap[field],
cleared
? (I18n.t('credentials.confirmClear') || 'Clear runtime override and fall back to .env value?')
: (I18n.t('credentials.confirmSave') || 'After saving, the old value stops working immediately. Continue?'),
{ okText: I18n.t('credentials.save') || 'Save' }
);
if (!okConfirm) return;
try {
const r = await this.api('PUT', '/settings/credentials', { [field]: value });
el.value = '';
this.toast(r.success ? (I18n.t('credentials.saved') || 'Saved') : 'Saved', 'success');
// After rotating dashboard password, the current session is no longer
// authenticated. Force a re-login flow so the user gets the right
// prompt rather than a misleading "session expired" later.
if (field === 'dashboardPassword' && !cleared) {
sessionStorage.removeItem('dashboard_password');
setTimeout(() => location.reload(), 800);
return;
}
// Same for API_KEY when the runtime fallback is also the dashboard
// password on localhost binds — safest is reload + reprompt.
if (field === 'apiKey') {
// Only force reload when the dashboard auth might have been the
// API_KEY fallback (localhost+no DASHBOARD_PASSWORD). The server
// /settings/credentials response doesn't tell us, so refresh the
// panel state and let the next API call surface 401 if needed.
this.loadCredentials();
} else {
this.loadCredentials();
}
} catch (e) {
this.toast('Save failed: ' + (e.message || ''), 'error');
}
},
// ─── Console skin (default ↔ experimental sketch UI) ────────
switchSkin(skin) {
const next = skin === 'sketch' ? 'sketch' : 'modern';
// Year-long cookie, lax samesite. path=/ so /dashboard sees it.
const oneYear = 365 * 24 * 3600;
document.cookie = `dashboard_skin=${next}; path=/; max-age=${oneYear}; samesite=lax`;
location.reload();
},
// ─── Ban Detection ───────────────────────────
async loadBans() {
const d = await this.api('GET', '/accounts');
const accounts = d.accounts || [];
const errored = accounts.filter(a => a.status === 'error' || a.errorCount > 0);
document.getElementById('ban-cards').innerHTML = `
<div class="card ${errored.length > 0 ? 'error' : 'success'}">
<div class="card-header"><div class="card-title">${I18n.t('card.abnormalAccounts.title')}</div></div>
<div class="card-value">${errored.length}</div>
<div class="card-sub">${I18n.t('card.abnormalAccounts.subtitle', { count: accounts.length })}</div>
</div>
<div class="card warn">
<div class="card-header"><div class="card-title">${I18n.t('card.disabled.title')}</div></div>
<div class="card-value">${accounts.filter(a => a.status === 'error').length}</div>
<div class="card-sub">${I18n.t('card.disabled.subtitle')}</div>
</div>
<div class="card info">
<div class="card-header"><div class="card-title">${I18n.t('card.rateLimit.title')}</div></div>
<div class="card-value">${accounts.filter(a => a.rateLimited).length}</div>
<div class="card-sub">${I18n.t('card.rateLimit.subtitle')}</div>
</div>
`;
const tbody = document.querySelector('#ban-table tbody');
tbody.innerHTML = accounts.map(a => {
const flagged = a.status === 'error' || a.errorCount > 0;
return `<tr style="${flagged ? 'background:rgba(239,68,68,.03)' : ''}">
<td>${this.esc(a.email)} <code class="text-xs">${a.id}</code></td>
<td><span class="badge ${a.status}">${a.status}</span></td>
<td style="color:${a.errorCount > 0 ? 'var(--error)' : 'inherit'}">${a.errorCount}</td>
<td class="text-sm">${a.lastUsed ? new Date(a.lastUsed).toLocaleString() : '-'}</td>
<td class="nowrap">
<div class="btn-group">
${a.status === 'error' ? `<button class="btn btn-success btn-xs" onclick="App.toggleAccount('${this.escJsAttr(a.id)}','active');setTimeout(()=>App.loadBans(),500)">${I18n.t('action.enable')}</button>` : ''}
${a.errorCount > 0 ? `<button class="btn btn-outline btn-xs" onclick="App.resetErrors('${this.escJsAttr(a.id)}');setTimeout(()=>App.loadBans(),500)">${I18n.t('action.reset')}</button>` : ''}
</div>
</td>
</tr>`;
}).join('') || `<tr class="empty-row"><td colspan="5">${I18n.t('table.empty.banList')}</td></tr>`;
this.poll('bans', () => this.loadBans(), 30000);
},
// ─── Helpers ─────────────────────────────────
fmtDuration(secs) {
const d = Math.floor(secs / 86400), h = Math.floor((secs % 86400) / 3600), m = Math.floor((secs % 3600) / 60);
const s = Math.floor(secs % 60);
if (d > 0) return `${d}${I18n.t('time.day')} ${h}${I18n.t('time.hour')} ${m}${I18n.t('time.minute')}`;
if (h > 0) return `${h}${I18n.t('time.hour')} ${m}${I18n.t('time.minute')}`;
return `${m}${I18n.t('time.minute')} ${s}${I18n.t('time.second')}`;
},
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(I18n.t('toast.copyFailed'), 'error');
} catch (e) {
this.toast(e?.message || I18n.t('toast.copyFailed'), 'error');
}
},
copyKey(key) {
const fallback = () => {
try {
const ta = document.createElement('textarea');
ta.value = key;
ta.style.position = 'fixed';
ta.style.left = '-9999px';
document.body.appendChild(ta);
ta.focus();
ta.select();
const ok = document.execCommand('copy');
ta.remove();
this.toast(ok ? I18n.t('toast.apiKeyCopied') : I18n.t('toast.copyFailed'), ok ? 'success' : 'error');
} catch (e) {
this.toast(I18n.t('toast.copyFailedError', {error: e.message}), 'error');
}
};
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(key).then(() => this.toast(I18n.t('toast.apiKeyCopied'))).catch(fallback);
} else {
fallback();
}
},
// Roster lives in /dashboard/data/contributors.json so default UI
// and sketch UI share one source of truth. Fetched at panel-open.
CONTRIBUTORS_CACHE: null,
async fetchContributors() {
if (this.CONTRIBUTORS_CACHE) return this.CONTRIBUTORS_CACHE;
try {
const res = await fetch('/dashboard/data/contributors.json');
if (!res.ok) throw new Error(`status ${res.status}`);
const data = await res.json();
this.CONTRIBUTORS_CACHE = Array.isArray(data?.contributors) ? data.contributors : [];
} catch (e) {
console.error('fetchContributors failed:', e);
this.CONTRIBUTORS_CACHE = [];
}
return this.CONTRIBUTORS_CACHE;
},
contributorRarity(weight) {
// v2.0.60 — three new tiers above the previous UR ceiling so the
// rarity ladder doesn't compress everyone past S+ into one badge.
// GOD : project creator / maintainer (only dwgx)
// MR : "Mythic Rare" — multi-PR contributors above S+ (e.g.
// baily-zhang has 4 ship-ready PRs spanning fingerprint /
// dashboard / opus-multimodal — beyond a single S+ slot)
// LR : "Legendary Rare" — single S++ tier (reserved for
// exceptional one-shot work that materially shifts arch)
if (weight === 'GOD') return 'GOD';
if (weight === 'MR') return 'MR';
if (weight === 'LR' || weight === 'S++') return 'LR';
if (weight === 'S+') return 'UR';
if (weight === 'S' || weight === 'A+') return 'SSR';
if (weight === 'A' || weight === 'B+') return 'SR';
return 'R';
},
weightRank(w) {
return ({ 'GOD': 100, 'MR': 12, 'LR': 10, 'S++': 10, 'S+': 6, 'S': 5, 'A+': 4, 'A': 3, 'B+': 2, 'B': 1 })[w] || 0;
},
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();
const groups = this.groupContributors(list);
body.innerHTML = `<div class="contributor-list">${groups.map(g => {
const latest = g.latest;
const count = g.prs.length;
const rarity = this.contributorRarity(g.topWeight);
const summaryText = count > 1
? I18n.t('credits.history', { count })
: I18n.t('credits.contributionDetails');
const items = g.prs.map(p => {
const r = this.contributorRarity(p.weight);
return `
<li class="history-item">
<div class="history-head">
<a class="pr-link" target="_blank" rel="noopener" href="https://github.com/dwgx/WindsurfAPI/pull/${p.pr}">PR #${p.pr}</a>
<span class="merged-at">${this.esc(p.mergedAt || '')}</span>
${p.weight ? `<span class="rarity-badge rarity-${this.esc(r)}" title="${this.esc(p.weightLabel || '')}">${this.esc(r)}</span>` : ''}
${p.weightLabel ? `<span class="history-rarity-label">${this.esc(p.weightLabel)}</span>` : ''}
</div>
<div class="history-title">${this.esc(p.title)}</div>
<div class="history-summary">${this.esc(p.summary)}</div>
</li>`;
}).join('');
return `
<div class="contributor-card">
<img class="avatar" src="https://github.com/${this.esc(g.login)}.png?size=128" alt="${this.esc(g.login)}" loading="lazy" onerror="this.style.visibility='hidden'">
<div class="meta">
<div class="meta-top">
<div class="identity">
<a class="name" target="_blank" rel="noopener" href="https://github.com/${this.esc(g.login)}">@${this.esc(g.login)}</a>
${g.topWeight ? `<span class="rarity-badge rarity-${this.esc(rarity)}" title="${this.esc(latest.weightLabel || '')}">${this.esc(rarity)}</span>` : ''}
<span class="contribution-count">×${count}</span>
</div>
<div class="links">
<a class="pr-link" target="_blank" rel="noopener" href="https://github.com/dwgx/WindsurfAPI/pull/${latest.pr}">PR #${latest.pr}</a>
<span class="merged-at">${this.esc(latest.mergedAt || '')}</span>
</div>
</div>
<div class="pr-title">${this.esc(latest.title)}</div>
<details class="summary-toggle">
<summary>${this.esc(summaryText)}</summary>
<ol class="history-list">${items}</ol>
</details>
</div>
</div>`;
}).join('')}</div>`;
},
esc(s) { return String(s == null ? '' : s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;'); },
systemPromptDomId(key) {
return `sp-${encodeURIComponent(String(key == null ? '' : key)).replace(/%/g, '_')}`;
},
// For values interpolated into an inline `onclick="App.f('${...}')"`
// string: the HTML attribute parser will decode &#39; back to ', which
// then breaks out of the JS string literal. Do JS-level string escaping
// (backslashify quotes and brackets) instead of HTML entity encoding.
// Safe to place inside double-quoted onclick="..." because the output
// contains no unescaped " character.
escJsAttr(s) {
// U+2028 / U+2029 (JS line separators) are expressed as escape
// sequences below. A raw U+2028/2029 inside a regex literal is a
// line terminator and breaks 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]);
},
};
window.addEventListener('DOMContentLoaded', () => App.init());
</script>
</body>
</html>