优化agent

This commit is contained in:
imengying committed 2026-08-28 00:54:37 +08:00
1 parent 0cdcbe40f4
commit 09db33d5fa
12 files changed
+206 -103

No files matched your search

+3 -1
View File
@@ -107,7 +107,9 @@ function HomeView() {
<StatsBar servers={servers} config={config} exchangeRates={exchangeRates} />
{config.show_search || config.show_groups ? <div className="toolbar">
{config.show_search ? <div className="search-box"><Search size={16} /><input aria-label={ui(locale, "搜索节点", "Search servers")} placeholder={ui(locale, "搜索节点", "Search servers")} value={query} onChange={(event) => setQuery(event.target.value)} /></div> : null}
{config.show_groups ? <div className="group-tabs" aria-label={ui(locale, "节点分组", "Server groups")}>{groups.map((item) => <button className={group === item ? "active" : ""} key={item} onClick={() => setGroup(item)}>{item === "__all__" ? ui(locale, "全部", "All") : item}</button>)}</div> : null}
{/* role="group" 让 aria-label 生效(无 role 的 div 是 generic,规范禁止命名,标签会被丢掉);
选中态用 aria-pressed,不用 role="radio",避免欠下方向键 + roving tabindex 的实现。 */}
{config.show_groups ? <div className="group-tabs" role="group" aria-label={ui(locale, "节点分组", "Server groups")}>{groups.map((item) => <button className={group === item ? "active" : ""} aria-pressed={group === item} key={item} onClick={() => setGroup(item)}>{item === "__all__" ? ui(locale, "全部", "All") : item}</button>)}</div> : null}
<span className="result-count">{ui(locale, `${visible.length} 个节点`, `${visible.length} servers`)}</span>
</div> : null}
{error ? <div className="error-band"><span>{error}</span><button onClick={() => void reload()}>{ui(locale, "重试", "Retry")}</button></div> : null}
+40 -33
View File
@@ -518,18 +518,20 @@ export function AdminPanel({
return (
<div className={`admin-page ${dark ? "admin-dark" : ""}`}>
{error ? <div className="admin-toast error" role="alert" aria-live="assertive"><CircleAlert aria-hidden="true" /><span>{error}</span></div>
: notice ? <div className="admin-toast" role="status" aria-live="polite"><CircleCheck aria-hidden="true" /><span>{notice}</span></div> : null}
{/* toast 的定位是按后台的顶栏算的;登录页没有顶栏,那里的错误走表单内提示 */}
{authenticated ? (error ? <div className="admin-toast error" role="alert" aria-live="assertive"><CircleAlert aria-hidden="true" /><span>{error}</span></div>
: notice ? <div className="admin-toast" role="status" aria-live="polite"><CircleCheck aria-hidden="true" /><span>{notice}</span></div> : null) : null}
{!authenticated ? <div className="admin-login-stage">
<button className="admin-back" type="button" onClick={onClose}><ArrowLeft size={14} />返回</button>
<button className="admin-login-theme" type="button" onClick={onToggleTheme} title={dark ? "切换浅色主题" : "切换深色主题"}>{dark ? <Sun size={16} /> : <Moon size={16} />}</button>
<button className="admin-back" type="button" onClick={onClose}><ArrowLeft size={15} />返回</button>
<button className="admin-login-theme" type="button" onClick={onToggleTheme} title={dark ? "切换浅色主题" : "切换深色主题"}>{dark ? <Sun size={15} /> : <Moon size={15} />}</button>
<form className="login-form glass-panel" onSubmit={login}>
<SiteLogo src={siteLogoUrl} alt="" width="48" height="48" />
<div className="login-copy"><h1>管理员登录</h1></div>
<label><span>用户名</span><input autoFocus type="text" autoComplete="username" value={username} onChange={(event) => setUsername(event.target.value)} required /></label>
<label><span>密码</span><input type="password" autoComplete="current-password" value={password} onChange={(event) => setPassword(event.target.value)} required /></label>
<div className="login-copy"><h1>管理员登录</h1><p>{config.site_name}</p></div>
<label><span>用户名</span><input autoFocus type="text" autoComplete="username" value={username} onChange={(event) => setUsername(event.target.value)} required aria-invalid={error ? true : undefined} aria-describedby={error ? "login-error" : undefined} /></label>
<label><span>密码</span><input type="password" autoComplete="current-password" value={password} onChange={(event) => setPassword(event.target.value)} required aria-invalid={error ? true : undefined} aria-describedby={error ? "login-error" : undefined} /></label>
{config.turnstile_login_enabled || config.turnstile_enabled ? <div className="login-turnstile"><TurnstileWidget siteKey={config.turnstile_site_key} action="admin-login" theme={dark ? "dark" : "light"} resetKey={turnstileReset} onVerify={setTurnstileToken} onError={setError} /></div> : null}
<button className="primary-btn login-submit" disabled={busy || ((config.turnstile_login_enabled || config.turnstile_enabled) && !turnstileToken)} type="submit"><KeyRound size={16} />{busy ? "验证中" : "登录"}</button>
{error ? <p className="login-error" id="login-error" role="alert"><CircleAlert size={15} aria-hidden="true" />{error}</p> : null}
<button className="primary-btn login-submit" disabled={busy || ((config.turnstile_login_enabled || config.turnstile_enabled) && !turnstileToken)} type="submit"><KeyRound size={15} />{busy ? "验证中" : "登录"}</button>
</form>
</div> : <section className="admin-shell" aria-label="管理面板">
<header className="admin-topbar">
@@ -538,33 +540,38 @@ export function AdminPanel({
<strong>{config.site_name}</strong>
</div>
<div className="admin-topbar-actions">
<button type="button" onClick={onToggleTheme} title={dark ? "切换浅色主题" : "切换深色主题"} aria-label={dark ? "切换浅色主题" : "切换深色主题"}>{dark ? <Sun size={17} /> : <Moon size={17} />}</button>
<button type="button" onClick={onClose} title="主页" aria-label="主页"><ArrowLeft size={17} />主页</button>
<button type="button" onClick={() => void logout()} title="退出" aria-label="退出"><LogOut size={17} />退出</button>
<button type="button" onClick={onToggleTheme} title={dark ? "切换浅色主题" : "切换深色主题"} aria-label={dark ? "切换浅色主题" : "切换深色主题"}>{dark ? <Sun size={15} /> : <Moon size={15} />}</button>
<button type="button" onClick={onClose} title="主页" aria-label="主页"><ArrowLeft size={15} />主页</button>
<button type="button" onClick={() => void logout()} title="退出" aria-label="退出"><LogOut size={15} />退出</button>
</div>
</header>
<div className="admin-body">
<aside className="admin-sidebar">
<span className="admin-nav-label">管理</span>
<nav className="admin-tabs">
<button className={tab === "servers" ? "active" : ""} onClick={() => selectTab("servers")}><ServerCog size={19} />服务器</button>
<button className={tab === "latency" ? "active" : ""} onClick={() => selectTab("latency")}><RadioTower size={19} />延迟检测</button>
<button className={tab === "appearance" ? "active" : ""} onClick={() => selectTab("appearance")}><Eye size={19} />站点设置</button>
<button className={tab === "themes" ? "active" : ""} onClick={() => selectTab("themes")}><Palette size={19} />主题商店</button>
<button className={tab === "themeSettings" ? "active" : ""} onClick={() => { selectTab("themeSettings"); void loadThemeSettings(); }}><SlidersHorizontal size={19} />主题设置</button>
<button className={tab === "alerts" ? "active" : ""} onClick={() => selectTab("alerts")}><AlertTriangle size={19} />通知</button>
<button className={tab === "security" ? "active" : ""} onClick={() => selectTab("security")}><ShieldCheck size={19} />登录与安全</button>
<button className={tab === "data" ? "active" : ""} onClick={() => { selectTab("data"); if (!database) void loadDatabase(); }}><Database size={19} />监控数据库</button>
<button className={tab === "about" ? "active" : ""} onClick={() => selectTab("about")}><Info size={19} />关于</button>
{/* 这个 span 在窄屏是 display:none,但被 aria-labelledby 直接引用的节点即使隐藏也参与名称计算,
所以地标名在所有断点都在,不用另写一份 aria-label。 */}
<span className="admin-nav-label" id="admin-nav-label">管理</span>
{/* 用 nav + aria-current 而不是 role="tablist":tablist 会盖掉 nav 的导航地标,
且 APG 的 tab 模式要求方向键 + roving tabindex,而主题设置和监控数据库点了要发请求,
方向键扫过就会连带触发。每项换的是整块内容和它自己的 h1,本质是页内导航,不是 tab 面板。 */}
<nav className="admin-tabs" aria-labelledby="admin-nav-label">
<button type="button" className={tab === "servers" ? "active" : ""} aria-current={tab === "servers" ? "page" : undefined} onClick={() => selectTab("servers")}><ServerCog size={17} />服务器</button>
<button type="button" className={tab === "latency" ? "active" : ""} aria-current={tab === "latency" ? "page" : undefined} onClick={() => selectTab("latency")}><RadioTower size={17} />延迟检测</button>
<button type="button" className={tab === "appearance" ? "active" : ""} aria-current={tab === "appearance" ? "page" : undefined} onClick={() => selectTab("appearance")}><Eye size={17} />站点设置</button>
<button type="button" className={tab === "themes" ? "active" : ""} aria-current={tab === "themes" ? "page" : undefined} onClick={() => selectTab("themes")}><Palette size={17} />主题商店</button>
<button type="button" className={tab === "themeSettings" ? "active" : ""} aria-current={tab === "themeSettings" ? "page" : undefined} onClick={() => { selectTab("themeSettings"); void loadThemeSettings(); }}><SlidersHorizontal size={17} />主题设置</button>
<button type="button" className={tab === "alerts" ? "active" : ""} aria-current={tab === "alerts" ? "page" : undefined} onClick={() => selectTab("alerts")}><AlertTriangle size={17} />通知</button>
<button type="button" className={tab === "security" ? "active" : ""} aria-current={tab === "security" ? "page" : undefined} onClick={() => selectTab("security")}><ShieldCheck size={17} />登录与安全</button>
<button type="button" className={tab === "data" ? "active" : ""} aria-current={tab === "data" ? "page" : undefined} onClick={() => { selectTab("data"); if (!database) void loadDatabase(); }}><Database size={17} />监控数据库</button>
<button type="button" className={tab === "about" ? "active" : ""} aria-current={tab === "about" ? "page" : undefined} onClick={() => selectTab("about")}><Info size={17} />关于</button>
</nav>
</aside>
<div className="admin-content">
<header className="admin-content-header"><h1>{adminPages[tab].title}</h1><p>{adminPages[tab].description}</p></header>
{tab === "servers" ? (
<div className="admin-section">
<div className="section-head"><div><h3>监控节点</h3><span>{servers.length} 个节点 · 可拖动上下排序</span></div><div className="section-actions"><button className="primary-btn compact" onClick={() => openEditor()}><Plus size={16} />添加</button></div></div>
<div className="batch-toolbar"><label className="select-all"><Checkbox checked={allSelected} onChange={() => setSelectedIds(allSelected ? [] : servers.map((server) => server.id))} />全选</label>{selectedIds.length ? <button className="danger-btn compact" onClick={() => void removeSelected()}><Trash2 size={14} />删除选中 ({selectedIds.length})</button> : <span>批量操作</span>}</div>
<div className="section-head"><div><h3>监控节点</h3><span>{servers.length} 个节点 · 可拖动上下排序</span></div><div className="section-actions"><button className="primary-btn compact" onClick={() => openEditor()}><Plus size={15} />添加</button></div></div>
<div className="batch-toolbar"><label className="select-all"><Checkbox checked={allSelected} onChange={() => setSelectedIds(allSelected ? [] : servers.map((server) => server.id))} />全选</label>{selectedIds.length ? <button className="danger-btn compact" onClick={() => void removeSelected()}><Trash2 size={15} />删除选中 ({selectedIds.length})</button> : <span>批量操作</span>}</div>
<div className="server-list">
{servers.map((server, index) => (
<div className={`server-row ${draggingId === server.id ? "dragging" : ""}`} key={server.id} onDragOver={(event) => { event.preventDefault(); event.dataTransfer.dropEffect = "move"; }} onDrop={(event) => void dropServer(event, server.id)}>
@@ -592,8 +599,8 @@ export function AdminPanel({
{!theme.builtin ? <a href={theme.url} target="_blank" rel="noreferrer"><span>{theme.url}</span><ExternalLink size={13} /></a> : null}
</div>
<div className="theme-row-actions">
{!theme.builtin ? <button type="button" className="secondary-btn compact" disabled={busy} onClick={() => void previewTheme(theme)}><Eye size={14} />预览</button> : null}
<button type="button" className={theme.active ? "theme-active-btn" : "primary-btn compact"} disabled={busy || theme.active} onClick={() => void activateTheme(theme)}>{theme.active ? <><Check size={14} />使用中</> : "启用"}</button>
{!theme.builtin ? <button type="button" className="secondary-btn compact" disabled={busy} onClick={() => void previewTheme(theme)}><Eye size={15} />预览</button> : null}
<button type="button" className={theme.active ? "theme-active-btn" : "primary-btn compact"} disabled={busy || theme.active} onClick={() => void activateTheme(theme)}>{theme.active ? <><Check size={15} />使用中</> : "启用"}</button>
{!theme.builtin ? <button type="button" className="icon-btn danger" disabled={busy} title="删除主题" onClick={() => void removeTheme(theme)}><Trash2 size={15} /></button> : null}
</div>
</article>)}
@@ -604,7 +611,7 @@ export function AdminPanel({
<p className="settings-hint">仅支持 GitHub 地址</p>
<div className="form-grid"><label><span>主题名称</span><input required maxLength={80} value={themeName} onChange={(event) => setThemeName(event.target.value)} placeholder="例如:Ocean" /></label><label><span>主题 URL</span><input required type="url" maxLength={2048} value={themeUrl} onChange={(event) => setThemeUrl(event.target.value)} placeholder="https://github.com/user/theme" /></label></div>
<label><span>主题说明(可选)</span><textarea rows={2} maxLength={300} value={themeDescription} onChange={(event) => setThemeDescription(event.target.value)} placeholder="简短描述主题风格和来源" /></label>
<div className="form-actions"><button className="primary-btn" disabled={busy}><Plus size={16} />添加主题</button></div>
<div className="form-actions"><button className="primary-btn" disabled={busy}><Plus size={15} />添加主题</button></div>
</form>
</div>
) : settings && tab !== "about" ? (
@@ -647,20 +654,20 @@ export function AdminPanel({
<div className="section-title"><Database size={15} />D1 数据维护</div>
<div className="data-stat-grid">{database ? <><DataStat label="节点" value={database.server_count} /><DataStat label="在线" value={database.online_count} /><DataStat label="历史行数" value={database.history_rows.toLocaleString()} /></> : <p className="settings-hint">正在读取数据库统计...</p>}</div>
<div className="usage-section">
<div className="usage-head"><div><div className="section-title"><Coins size={15} />每日汇率</div><p className="settings-hint">{exchangeRates ? `${exchangeRates.source} · ${exchangeRates.date || "等待首次更新"}${exchangeRates.stale ? " · 数据待更新" : ""}` : "正在读取 D1 汇率快照"}</p></div><button type="button" className="secondary-btn compact" disabled={busy} onClick={() => void refreshExchangeRates()}><RotateCw size={14} />立即更新</button></div>
<div className="usage-head"><div><div className="section-title"><Coins size={15} />每日汇率</div><p className="settings-hint">{exchangeRates ? `${exchangeRates.source} · ${exchangeRates.date || "等待首次更新"}${exchangeRates.stale ? " · 数据待更新" : ""}` : "正在读取 D1 汇率快照"}</p></div><button type="button" className="secondary-btn compact" disabled={busy} onClick={() => void refreshExchangeRates()}><RotateCw size={15} />立即更新</button></div>
{exchangeRates ? <div className="usage-table-wrap"><table className="usage-table"><thead><tr><th>币种</th><th>1 CNY 可兑换</th></tr></thead><tbody>{ASSET_CURRENCIES.map((currency) => <tr key={currency}><th scope="row">{currency}</th><td>{exchangeRates.rates[currency]?.toLocaleString(undefined, { maximumFractionDigits: 6 }) ?? "--"}</td></tr>)}</tbody></table></div> : <div className="usage-empty">尚未读取</div>}
</div>
<div className="usage-section">
<div className="usage-head"><div><div className="section-title"><Cloud size={15} />Cloudflare 用量</div><p className="settings-hint">统计周期使用 UTC</p></div><button type="button" className="secondary-btn compact" disabled={busy} onClick={() => void loadCloudflareUsage()}><RotateCw size={14} />{cloudflareUsage ? "刷新用量" : "查询用量"}</button></div>
<div className="usage-head"><div><div className="section-title"><Cloud size={15} />Cloudflare 用量</div><p className="settings-hint">统计周期使用 UTC</p></div><button type="button" className="secondary-btn compact" disabled={busy} onClick={() => void loadCloudflareUsage()}><RotateCw size={15} />{cloudflareUsage ? "刷新用量" : "查询用量"}</button></div>
<div className="form-grid"><label><span>Cloudflare Account ID</span><input autoComplete="off" type="password" maxLength={32} value={settings.cloudflare_account_id} onChange={(event) => updateSettings("cloudflare_account_id", event.target.value)} placeholder="32 位账户 ID" /></label><label><span>Cloudflare API Token</span><input autoComplete="off" type="password" value={settings.cloudflare_api_token} onChange={(event) => updateSettings("cloudflare_api_token", event.target.value)} placeholder="Account Analytics: Read" /></label></div>
<div className="usage-config-actions"><p className="settings-hint">Token 需要账户级 Account Analytics: Read 权限,并授权对应账户;留空读取 Worker Secret。</p><button type="submit" className="primary-btn compact" disabled={busy}><Save size={14} />保存用量配置</button></div>
<div className="usage-config-actions"><p className="settings-hint">Token 需要账户级 Account Analytics: Read 权限,并授权对应账户;留空读取 Worker Secret。</p><button type="submit" className="primary-btn compact" disabled={busy}><Save size={15} />保存用量配置</button></div>
{cloudflareUsage ? <><div className="usage-table-wrap"><table className="usage-table cloudflare-usage-table"><thead><tr><th>周期</th><th>D1 读取</th><th>D1 写入</th><th>Workers 请求</th><th>DO 请求(估算)</th><th>DO 时长 (GB-s)</th></tr></thead><tbody><UsageRow label="今日" usage={cloudflareUsage.today} /><UsageRow label="昨日" usage={cloudflareUsage.yesterday} /></tbody></table></div><div className="usage-do-breakdown"><UsageDoBreakdown label="今日" usage={cloudflareUsage.today} /><UsageDoBreakdown label="昨日" usage={cloudflareUsage.yesterday} /></div></> : <div className="usage-empty">尚未读取</div>}
</div>
<div className="data-actions"><button type="button" className="secondary-btn" onClick={() => void loadDatabase()}>刷新统计</button><button type="button" className="danger-btn" onClick={() => void clearHistory()}><Trash2 size={15} />清空历史指标</button></div>
<p className="settings-hint">清空历史不会删除节点、密钥或最新状态。</p>
</> : null}
{tab !== "data" ? <div className="form-actions"><button className="primary-btn" disabled={busy}><Save size={16} />保存{tab === "security" ? "账号与安全设置" : "设置"}</button></div> : null}
{tab !== "data" ? <div className="form-actions"><button className="primary-btn" disabled={busy}><Save size={15} />保存{tab === "security" ? "账号与安全设置" : "设置"}</button></div> : null}
</form>
) : tab === "about" ? (
<div className="admin-section about-page">
@@ -684,10 +691,10 @@ export function AdminPanel({
<div className="form-grid three"><label><span>到期日期</span><input type="date" value={formatDate(form.expires_at)} onChange={(event) => updateForm("expires_at", event.target.value ? Math.floor(new Date(`${event.target.value}T00:00:00Z`).getTime() / 1000) : null)} /></label><label><span>Agent 上报间隔(秒)</span><input min="15" max="3600" type="number" value={form.report_interval} onChange={(event) => updateForm("report_interval", Number(event.target.value))} /></label><label><span>指标采样间隔(秒)</span><input min="1" max="60" type="number" value={form.collect_interval} onChange={(event) => updateForm("collect_interval", Number(event.target.value))} /></label></div>
<div className="form-grid"><label><span>统计网卡(逗号分隔,留空自动)</span><input value={form.network_interface} onChange={(event) => updateForm("network_interface", event.target.value)} placeholder="eth0,ens3" /></label><label><span>下行流量当前值(GB)</span><input min="0" step="0.1" type="number" value={rxCurrentGb} onChange={(event) => setRxCurrentGb(event.target.value)} /></label><label><span>上行流量当前值(GB)</span><input min="0" step="0.1" type="number" value={txCurrentGb} onChange={(event) => setTxCurrentGb(event.target.value)} /></label><label><span>Agent 下载加速(可选)</span><input value={form.agent_mirror} onChange={(event) => updateForm("agent_mirror", event.target.value.trim())} placeholder="https://ghproxy.net" /></label></div>
<div className="settings-toggles editor-toggles"><Toggle label="自动续费" checked={form.auto_renewal} onChange={(value) => updateForm("auto_renewal", value)} /><Toggle label="Agent 自动更新" checked={form.auto_update} onChange={(value) => updateForm("auto_update", value)} /><Toggle label="隐藏节点" checked={form.hidden} onChange={(value) => updateForm("hidden", value)} /><Toggle label="关闭离线告警" checked={form.offline_notify_disabled} onChange={(value) => updateForm("offline_notify_disabled", value)} /></div>
<div className="form-actions"><button type="button" className="secondary-btn" onClick={() => setEditing(null)}>取消</button><button className="primary-btn" disabled={busy}><Save size={16} />保存节点</button></div>
<div className="form-actions"><button type="button" className="secondary-btn" onClick={() => setEditing(null)}>取消</button><button className="primary-btn" disabled={busy}><Save size={15} />保存节点</button></div>
</form></div> : null}
{installCommand ? <div className="submodal-backdrop" role="presentation" onMouseDown={installDialog.onBackdropMouseDown}><section ref={installDialog.dialogRef} className="install-modal glass-panel" role="dialog" aria-modal="true" aria-labelledby="install-dialog-title" tabIndex={-1}><header><div><span className="eyebrow">Agent 部署</span><h3 id="install-dialog-title">安装命令</h3></div><div className="segmented install-platform" aria-label="Agent 平台"><button type="button" className={installPlatform === "linux" ? "active" : ""} onClick={() => setInstallPlatform("linux")}>Linux</button><button type="button" className={installPlatform === "windows" ? "active" : ""} onClick={() => setInstallPlatform("windows")}>Windows</button><button type="button" className={installPlatform === "macos" ? "active" : ""} onClick={() => setInstallPlatform("macos")}>macOS ARM</button><button type="button" className={installPlatform === "freebsd" ? "active" : ""} onClick={() => setInstallPlatform("freebsd")}>FreeBSD</button></div></header><div className="install-list"><pre>{installCommand}</pre></div><div className="form-actions"><button className="secondary-btn" type="button" onClick={() => setInstall(null)}>关闭</button><button className="primary-btn" type="button" onClick={() => void copyInstallCommand()}><Copy size={16} />复制</button></div></section></div> : null}
{installCommand ? <div className="submodal-backdrop" role="presentation" onMouseDown={installDialog.onBackdropMouseDown}><section ref={installDialog.dialogRef} className="install-modal glass-panel" role="dialog" aria-modal="true" aria-labelledby="install-dialog-title" tabIndex={-1}><header><div><span className="eyebrow">Agent 部署</span><h3 id="install-dialog-title">安装命令</h3></div>{/* role="group" 是 aria-label 生效的前提,aria-pressed 表达选中态,理由同侧栏那处注释 */}<div className="segmented install-platform" role="group" aria-label="Agent 平台"><button type="button" className={installPlatform === "linux" ? "active" : ""} aria-pressed={installPlatform === "linux"} onClick={() => setInstallPlatform("linux")}>Linux</button><button type="button" className={installPlatform === "windows" ? "active" : ""} aria-pressed={installPlatform === "windows"} onClick={() => setInstallPlatform("windows")}>Windows</button><button type="button" className={installPlatform === "macos" ? "active" : ""} aria-pressed={installPlatform === "macos"} onClick={() => setInstallPlatform("macos")}>macOS ARM</button><button type="button" className={installPlatform === "freebsd" ? "active" : ""} aria-pressed={installPlatform === "freebsd"} onClick={() => setInstallPlatform("freebsd")}>FreeBSD</button></div></header><div className="install-list"><pre>{installCommand}</pre></div><div className="form-actions"><button className="secondary-btn" type="button" onClick={() => setInstall(null)}>关闭</button><button className="primary-btn" type="button" onClick={() => void copyInstallCommand()}><Copy size={15} />复制</button></div></section></div> : null}
</div>
);
}
+3 -3
View File
@@ -94,13 +94,13 @@ export function AlertRuleManager({ servers, onError, onNotice }: {
const unit = form.metric === "net_in" || form.metric === "net_out" ? "MiB/s" : "%";
return <div className="alert-rule-manager">
<div className="section-head"><div><h3>资源告警规则</h3><span>最多 20 条,可指定服务器和统计窗口</span></div><button type="button" className="primary-btn compact" onClick={() => open()}><Plus size={14} />新建规则</button></div>
<div className="section-head"><div><h3>资源告警规则</h3><span>最多 20 条,可指定服务器和统计窗口</span></div><button type="button" className="primary-btn compact" onClick={() => open()}><Plus size={15} />新建规则</button></div>
<div className="alert-rule-list">
{rules.map((rule) => <div className="alert-rule-row" key={rule.id}>
<Checkbox checked={rule.enabled} onChange={() => void toggle(rule)} ariaLabel={`${rule.name}启用状态`} />
<div><strong>{rule.name}</strong><small>{metricLabels[rule.metric]} ≥ {rule.threshold} {rule.metric.startsWith("net_") ? "MiB/s" : "%"} · {rule.duration_minutes} 分钟{rule.aggregation === "continuous" ? "持续" : "平均"} · {rule.server_ids.length ? `${rule.server_ids.length} 台服务器` : "全部服务器"}</small></div>
<button type="button" className="icon-btn" title="编辑规则" onClick={() => open(rule)}><Pencil size={14} /></button>
<button type="button" className="icon-btn danger" title="删除规则" onClick={() => void remove(rule)}><Trash2 size={14} /></button>
<button type="button" className="icon-btn" title="编辑规则" onClick={() => open(rule)}><Pencil size={15} /></button>
<button type="button" className="icon-btn danger" title="删除规则" onClick={() => void remove(rule)}><Trash2 size={15} /></button>
</div>)}
{!rules.length ? <div className="list-empty">尚未配置资源告警规则</div> : null}
</div>
+2 -2
View File
@@ -136,7 +136,7 @@ export function LatencyManager({
return <div className="admin-section latency-section">
<div className="section-head">
<div><h3>延迟任务</h3><span>{tasks.length} / 128 个任务 · TCP / ICMP</span></div>
<button className="primary-btn compact" type="button" disabled={tasks.length >= 128} onClick={() => open()}><Plus size={16} />添加</button>
<button className="primary-btn compact" type="button" disabled={tasks.length >= 128} onClick={() => open()}><Plus size={15} />添加</button>
</div>
<div className="latency-task-list">
{tasks.map((task) => <div className="latency-task-row" key={task.id}>
@@ -164,7 +164,7 @@ export function LatencyManager({
</div>
</div>
<label className="toggle-row"><span><b>默认分配给新服务器</b></span><Checkbox checked={form.default_enabled} onChange={(checked) => setForm((current) => ({ ...current, default_enabled: checked }))} /></label>
<div className="form-actions"><button type="button" className="secondary-btn" onClick={() => setEditing(null)}>取消</button><button className="primary-btn" disabled={busy}><Save size={16} />{busy ? "保存中" : "保存任务"}</button></div>
<div className="form-actions"><button type="button" className="secondary-btn" onClick={() => setEditing(null)}>取消</button><button className="primary-btn" disabled={busy}><Save size={15} />{busy ? "保存中" : "保存任务"}</button></div>
</form></div> : null}
</div>;
}
+15 -5
View File
@@ -62,8 +62,10 @@ function QualityPanel({ label, value, bars }: {
value: string;
bars: LatencyBar[];
}) {
// 不挂 aria-label:label 和 value 已经是下面的可见文本,重复;
// 而且整张卡片是 <button>,role=button 的子树在无障碍树里会被整体裁掉,挂了也读不到。
return (
<div className="quality-panel" aria-label={`${label} ${value}`}>
<div className="quality-panel">
<div><span>{label}</span><b>{value}</b></div>
<QualityBars bars={bars} />
</div>
@@ -113,8 +115,11 @@ export function NodeCard({ server, config, onOpen }: { server: Server; config: C
? ui(locale, "加载中", "Loading")
: ui(locale, "未匹配到线路", "No lines matched");
// 名字后面带上在线状态:卡片是 <button>,子树被裁掉,这个 aria-label 是读屏唯一能拿到的字符串。
// 状态在视觉上只由 status-dot 的颜色表达(WCAG 1.4.1 靠颜色传达信息),这里补成文字。
// 只放名字 + 状态,不塞 CPU/内存:一屏几十张卡,念完就太长了,数值留给详情页。
return (
<button className={`node-card glass-panel ${online ? "" : "offline"}`} onClick={onOpen} type="button" aria-label={ui(locale, `查看 ${server.name} 详情`, `View ${server.name} details`)}>
<button className={`node-card glass-panel ${online ? "" : "offline"}`} onClick={onOpen} type="button" aria-label={ui(locale, `${server.name},${online ? "在线" : "离线"},查看详情`, `${server.name}, ${online ? "online" : "offline"}, view details`)}>
<header className="node-header">
<span className={`status-dot ${online ? "online" : ""}`} />
<strong title={server.name}>{server.name}</strong>
@@ -137,16 +142,21 @@ export function NodeCard({ server, config, onOpen }: { server: Server; config: C
<Metric label={ui(locale, "流量", "Traffic")} value={server.traffic_limit > 0 ? `${traffic.toFixed(1)}%` : "∞"} used={traffic} sub={`${formatBytes(usedTraffic)} / ${server.traffic_limit > 0 ? formatBytes(server.traffic_limit) : "∞"}`} muted={!online} valueTone={server.traffic_limit <= 0 ? "" : traffic >= 95 ? "danger-text" : traffic >= 60 ? "warning-text" : "success-text"} />
</div>
{/* 这三个面板原来各挂一个 aria-label(实时速率 / 累计流量 / 剩余周期),全都没生效:
无 role 的 div 是 generic,规范禁止命名;就算补上 role="group",整张卡片是 <button>,
role=button 的子树在无障碍树里会被整体裁掉,里面加什么都读不到。所以移除而不是修补。
读屏用户拿不到这些数值,真正的出路是把卡片从 <button> 改成内容 + 覆盖层按钮,
那是一次会动 CSS 和焦点行为的改造,没在这次一并做。 */}
<div className={`data-grid ${showExpiryPanel ? "" : "two-columns"}`}>
<div className="data-panel" aria-label="实时速率">
<div className="data-panel">
<CompactLine icon={<ChevronUp size={11} />} tone="success-text">{formatSpeed(server.net_out)}</CompactLine>
<CompactLine icon={<ChevronDown size={11} />} tone="info-text">{formatSpeed(server.net_in)}</CompactLine>
</div>
<div className="data-panel" aria-label="累计流量">
<div className="data-panel">
<CompactLine icon={<Upload size={11} />}>{formatBytes(server.net_tx_total)}</CompactLine>
<CompactLine icon={<Download size={11} />}>{formatBytes(server.net_rx_total)}</CompactLine>
</div>
{showExpiryPanel ? <div className="data-panel" aria-label="剩余周期">
{showExpiryPanel ? <div className="data-panel">
{config.show_expiry ? <CompactLine icon={<CalendarDays size={11} />}>{formatExpire(server, locale)}</CompactLine> : null}
{config.show_price ? <CompactLine icon={<Coins size={11} />}>{server.price === -1
? ui(locale, "免费", "Free")
+10 -6
View File
@@ -412,12 +412,16 @@ export function NodeDetails({ server, liveLatencyResults, threshold, retentionDa
<section className="chart-section">
<div className="chart-controls">
<div className="segmented" aria-label="图表类型">
<button className={chartType === "load" ? "active" : ""} onClick={() => setChartType("load")}><Activity size={14} />{ui(locale, "负载", "Load")}</button>
{pingEnabled ? <button className={chartType === "latency" ? "active" : ""} onClick={() => setChartType("latency")}><RadioTower size={14} />{ui(locale, "延迟", "Latency")}</button> : null}
{/* role="group" 是让 aria-label 生效的前提:无 role 的 div 是 generic,
规范禁止给它命名,浏览器会把 aria-label 丢掉。下面几处同理。
选中态用 aria-pressed 而不是 role="radio":radio 组要求方向键 + roving tabindex,
这里保持 Tab 逐个走的现状。同文件延迟任务图例用的也是 aria-pressed。 */}
<div className="segmented" role="group" aria-label={ui(locale, "图表类型", "Chart type")}>
<button className={chartType === "load" ? "active" : ""} aria-pressed={chartType === "load"} onClick={() => setChartType("load")}><Activity size={14} />{ui(locale, "负载", "Load")}</button>
{pingEnabled ? <button className={chartType === "latency" ? "active" : ""} aria-pressed={chartType === "latency"} onClick={() => setChartType("latency")}><RadioTower size={14} />{ui(locale, "延迟", "Latency")}</button> : null}
</div>
<div className="segmented range-control" aria-label="时间范围">
{ranges.map((range) => <button className={hours === range.value ? "active" : ""} key={range.value} onClick={() => chartType === "load" ? setLoadHours(range.value) : setLatencyHours(range.value)}>{range.label}</button>)}
<div className="segmented range-control" role="group" aria-label={ui(locale, "时间范围", "Time range")}>
{ranges.map((range) => <button className={hours === range.value ? "active" : ""} aria-pressed={hours === range.value} key={range.value} onClick={() => chartType === "load" ? setLoadHours(range.value) : setLatencyHours(range.value)}>{range.label}</button>)}
</div>
</div>
@@ -447,7 +451,7 @@ export function NodeDetails({ server, liveLatencyResults, threshold, retentionDa
<small>{series.loss == null ? "--" : `${series.loss.toFixed(2)}%`} 平均丢包</small>
</div>)}
</div>
<div className="latency-task-legend" aria-label={ui(locale, "延迟任务", "Latency tasks")}>
<div className="latency-task-legend" role="group" aria-label={ui(locale, "延迟任务", "Latency tasks")}>
{latencySeries.map((series) => {
const hidden = hiddenLatencyTaskIds.has(series.id);
return <button
+4 -1
View File
@@ -1,8 +1,11 @@
export function ProgressBar({ value }: { value: number }) {
const safe = Math.min(100, Math.max(0, value || 0));
const status = safe >= 90 ? "danger" : safe >= 70 ? "warning" : "good";
// 纯装饰:调用方 Metric 已经把同一个百分比作为可见文本渲染在旁边,再报一遍是重复。
// aria-hidden 明说它不进无障碍树。原来这里挂 aria-label,但无 role 的 span 是 generic,
// 规范禁止给它命名,那个标签从来没生效过。
return (
<span className="progress-track" aria-label={`${safe.toFixed(1)}%`}>
<span className="progress-track" aria-hidden="true">
<span className={`progress-fill ${status}`} style={{ width: `${safe}%` }} />
</span>
);
+40 -34
View File
@@ -5,17 +5,22 @@
.modal-backdrop, .submodal-backdrop { position: fixed; z-index: 80; inset: 0; display: flex; align-items: center; justify-content: center; padding: 24px; background: rgb(5 10 12 / .56); backdrop-filter: blur(6px); }
.submodal-backdrop { z-index: 90; background: rgb(5 10 12 / .42); }
/* 两处色值受无障碍下限约束,改动前先算比值:
--admin-border-strong 是 input/select/textarea/secondary-btn/checkbox 的边界,
而这些控件的背景和容器同为 --admin-surface,边框是唯一可辨识的轮廓,
受 WCAG 1.4.11 约束需 >= 3:1(装饰性的 --admin-border 不受此约束)。
--admin-accent 会落在 --admin-active-bg 上当选中态文字,需 >= 4.5:1。 */
.admin-page, .admin-loading {
--admin-bg: #f4f6f8;
--admin-surface: #fff;
--admin-text: #111827;
--admin-muted: #4b5563;
--admin-border: #d9dee5;
--admin-border-strong: #c7ced8;
--admin-border-strong: #8592a6; /* 白底 3.15:1 */
--admin-border-hover: #aeb8c5;
--admin-hover: #eef1f5;
--admin-active-bg: #e3f3ef;
--admin-accent: #0d7d6f;
--admin-accent: #0c7367; /* 选中底 5.00:1,白底 5.73:1 */
--admin-accent-border: #0a6e61;
}
.admin-page.admin-dark, .admin-loading.admin-dark {
@@ -24,7 +29,7 @@
--admin-text: #f3f4f6;
--admin-muted: #b9c0c7;
--admin-border: #343b40;
--admin-border-strong: #485159;
--admin-border-strong: #616d78; /* 卡片底 3.11:1 */
--admin-border-hover: #65717b;
--admin-hover: #242b2d;
--admin-active-bg: #173830;
@@ -40,6 +45,9 @@
.admin-page label > span { color: var(--admin-text); font-size: 13px; font-weight: 600; }
.admin-page .icon-btn { color: var(--admin-muted); }
.admin-page .icon-btn:hover { color: var(--admin-text); background: var(--admin-hover); }
/* 后台按钮里的图标统一尺寸。lucide 的 size 渲染成 width/height 属性,
CSS 在层叠里压过表现属性,所以这条说了算 —— JSX 的 size 必须和这里一致,
否则代码里的数字是假的。改这里也要改对应的 size。 */
.admin-page button svg { width: 15px; height: 15px; }
.admin-page .primary-btn, .admin-page .secondary-btn, .admin-page .danger-btn { min-height: 36px; font-size: 13px; }
.primary-btn.compact { min-height: 34px; padding: 6px 11px; }
@@ -62,6 +70,9 @@
.login-turnstile { display: flex; min-height: 66px; justify-content: center; }
.login-turnstile:has(.turnstile-widget.testing) { min-height: 51px; }
.login-turnstile:has(.turnstile-widget.verified) { min-height: 0; }
/* 登录错误就近显示,不用右上角的 toast —— 那个的 top 是按后台顶栏算的 */
.login-error { display: flex; align-items: flex-start; gap: 7px; margin: 0; border: 1px solid color-mix(in srgb, var(--danger) 34%, var(--admin-border)); border-radius: 7px; padding: 9px 11px; color: var(--danger); background: color-mix(in srgb, var(--danger) 7%, transparent); font-size: 13px; line-height: 1.5; }
.login-error svg { flex: 0 0 auto; margin-top: 1px; }
.login-submit { width: 100%; min-height: 40px; }
.eyebrow { display: block; margin-bottom: 3px; color: var(--admin-accent); font-size: 11px; font-weight: 750; text-transform: uppercase; }
@@ -78,7 +89,7 @@
.admin-nav-label { display: block; margin: 0 11px 8px; color: var(--admin-muted); font-size: 12px; font-weight: 700; }
.admin-tabs { display: flex; flex-direction: column; gap: 5px; border: 0; padding: 0; }
.admin-tabs button { display: flex; min-height: 44px; align-items: center; gap: 9px; border-radius: 6px; padding: 0 12px; color: var(--admin-text); font-size: 14px; font-weight: 600; text-align: left; }
.admin-tabs button svg { width: 17px; height: 17px; }
.admin-tabs button svg { width: 17px; height: 17px; } /* 覆盖上面的 15px,见该处注释 */
.admin-tabs button:hover { background: var(--admin-hover); }
.admin-tabs button.active { border-left: 3px solid var(--admin-accent-border); padding-left: 9px; color: var(--admin-accent); background: var(--admin-active-bg); font-weight: 700; }
.admin-content { width: 100%; max-width: 100%; height: 100%; min-width: 0; overflow-y: auto; color: var(--admin-text); background: var(--admin-bg); }
@@ -244,36 +255,8 @@
.telegram-token-field { grid-column: 1 / -1; }
}
@media (max-width: 639px) {
.modal-backdrop, .submodal-backdrop { align-items: stretch; padding: 0; }
.admin-shell, .editor-modal, .install-modal, .latency-editor { width: 100%; max-height: 100dvh; border: 0; border-radius: 0; }
.admin-shell { height: 100dvh; min-height: 100dvh; }
.admin-back { top: 12px; left: 12px; }
.login-form { width: calc(100% - 26px); padding: 24px 20px; }
.admin-body { display: flex; height: calc(100vh - 62px); min-height: 0; flex-direction: column; }
.admin-tabs { overflow-x: auto; flex-direction: row; border-right: 0; border-bottom: 1px solid var(--admin-border); padding: 7px 10px; }
.admin-tabs button { min-height: 33px; flex: 0 0 auto; }
.admin-content { height: calc(100vh - 112px); }
.admin-section, .settings-form { padding: 14px; }
.section-head { align-items: flex-start; }
.server-row { padding-block: 6px; grid-template-columns: 18px 20px 9px minmax(0, 1fr) auto; }
.form-grid, .form-grid.three, .settings-toggles, .theme-option-grid { grid-template-columns: 1fr; }
.theme-row { grid-template-columns: minmax(0, 1fr); }
.theme-row-actions { justify-content: flex-start; }
.data-stat-grid { grid-template-columns: 1fr 1fr 1fr; }
.usage-do-breakdown { grid-template-columns: 1fr; }
.usage-table th, .usage-table td { padding-inline: 5px; font-size: 11px; }
.editor-modal, .install-modal { padding: 15px 13px; }
.telegram-fields { grid-template-columns: 1fr; }
.telegram-token-field { grid-column: auto; }
.editor-toggles { grid-template-columns: 1fr 1fr; }
.latency-task-row { padding-block: 8px; grid-template-columns: 70px minmax(0, 1fr) auto; }
.latency-task-row .latency-task-meta:nth-of-type(2) { display: none; }
.latency-task-row .latency-task-meta:nth-of-type(3) { grid-column: 2; }
.latency-task-row .row-actions { grid-column: 3; grid-row: 1 / 3; }
.latency-editor { padding: 15px 13px; }
}
/* 断点顺序必须由宽到窄:760 和 639 都命中窄屏且特异性相同,写在后面的赢。
639 块只保留在 760 之上的收窄项,重复或已被 760 取代的声明都删掉了。 */
@media (max-width: 760px) {
.admin-shell { grid-template-rows: 58px minmax(0, 1fr); }
.admin-topbar { gap: 10px; padding: 0 12px; }
@@ -303,6 +286,29 @@
.admin-login-theme { top: 12px; right: 12px; }
}
@media (max-width: 639px) {
.modal-backdrop, .submodal-backdrop { align-items: stretch; padding: 0; }
.admin-shell, .editor-modal, .install-modal, .latency-editor { width: 100%; max-height: 100dvh; border: 0; border-radius: 0; }
.admin-shell { min-height: 100dvh; }
.login-form { width: calc(100% - 26px); padding: 24px 20px; }
.admin-section, .settings-form { padding: 14px; }
.server-row { padding-block: 6px; grid-template-columns: 18px 20px 9px minmax(0, 1fr) auto; }
.form-grid, .form-grid.three, .settings-toggles, .theme-option-grid { grid-template-columns: 1fr; }
/* 与 .settings-toggles 同元素,撤销上一行的单列 */
.editor-toggles { grid-template-columns: 1fr 1fr; }
.theme-row { grid-template-columns: minmax(0, 1fr); }
.theme-row-actions { justify-content: flex-start; }
.usage-do-breakdown { grid-template-columns: 1fr; }
.usage-table th, .usage-table td { padding-inline: 5px; font-size: 11px; }
.editor-modal, .install-modal, .latency-editor { padding: 15px 13px; }
.telegram-fields { grid-template-columns: 1fr; }
.telegram-token-field { grid-column: auto; }
.latency-task-row { padding-block: 8px; grid-template-columns: 70px minmax(0, 1fr) auto; }
.latency-task-row .latency-task-meta:nth-of-type(2) { display: none; }
.latency-task-row .latency-task-meta:nth-of-type(3) { grid-column: 2; }
.latency-task-row .row-actions { grid-column: 3; grid-row: 1 / 3; }
}
.server-name-main { display: flex; min-width: 0; align-items: center; gap: 7px; }
.server-name-main .region-flag { flex: 0 0 auto; font-size: 16px; line-height: 1; }
.server-name-main strong { overflow: hidden; font-size: 14px; text-overflow: ellipsis; white-space: nowrap; }
-3
View File
@@ -71,7 +71,6 @@ CREATE TABLE metric_history (
disk_utilization REAL NOT NULL DEFAULT 0,
sample_count INTEGER NOT NULL DEFAULT 1 CHECK(sample_count > 0),
latest_timestamp INTEGER NOT NULL,
latest_json TEXT NOT NULL CHECK(json_valid(latest_json)),
latency_json TEXT NOT NULL DEFAULT '[]' CHECK(json_valid(latency_json)),
PRIMARY KEY(server_id, timestamp)
) WITHOUT ROWID;
@@ -109,7 +108,6 @@ CREATE TABLE metric_history_old (
disk_utilization REAL NOT NULL DEFAULT 0,
sample_count INTEGER NOT NULL DEFAULT 1 CHECK(sample_count > 0),
latest_timestamp INTEGER NOT NULL,
latest_json TEXT NOT NULL CHECK(json_valid(latest_json)),
latency_json TEXT NOT NULL DEFAULT '[]' CHECK(json_valid(latency_json)),
PRIMARY KEY(server_id, timestamp)
) WITHOUT ROWID;
@@ -144,7 +142,6 @@ CREATE TABLE metric_history_hourly (
disk_utilization REAL NOT NULL DEFAULT 0,
sample_count INTEGER NOT NULL DEFAULT 1 CHECK(sample_count > 0),
latest_timestamp INTEGER NOT NULL,
latest_json TEXT NOT NULL CHECK(json_valid(latest_json)),
latency_json TEXT NOT NULL DEFAULT '[]' CHECK(json_valid(latency_json)),
PRIMARY KEY(server_id, timestamp)
) WITHOUT ROWID;
+32 -1
View File
@@ -284,7 +284,7 @@ try {
),
);
agent.send(JSON.stringify({ type: "update", batchId: `smoke-${timestamp}`, samples }));
const [ack] = await Promise.all([ackPromise, updatePromise]);
const [ack, update] = await Promise.all([ackPromise, updatePromise]);
if (
ack.ts <= 0 ||
ack.persisted !== true ||
@@ -296,6 +296,37 @@ try {
) {
throw new Error(`Invalid Agent metric ACK: ${JSON.stringify(ack)}`);
}
// 身份字段不该出现在发往浏览器的样本里(Worker 侧剥离),但必须仍能从
// bootstrap 拿到 —— 前端靠 { ...server, ...live.metrics } 的覆盖顺序兜底。
// fixture 里 gpu_model="NVIDIA T4"、agent_version="smoke" 都是非空的。
const omitted = [
"cpu_model",
"os",
"kernel",
"arch",
"virtualization",
"gpu_model",
"agent_version",
];
for (const entry of update.updates.flatMap((u) => u.samples ?? [])) {
const leaked = omitted.filter((field) => entry.data?.[field] !== undefined);
if (leaked.length) {
throw new Error(`Live sample leaked identity fields: ${leaked.join(", ")}`);
}
if (entry.data?.cpu === undefined) {
throw new Error("Live sample lost a dynamic field (cpu)");
}
}
const bootstrap = await fetch(new URL("/api/bootstrap", baseUrl)).then((r) => r.json());
const persisted = bootstrap.servers?.find((s) => s.id === serverId);
if (!persisted) {
throw new Error(`Bootstrap missing server ${serverId}`);
}
const missing = omitted.filter((field) => !persisted[field]);
if (missing.length) {
throw new Error(`Bootstrap missing identity fields: ${missing.join(", ")}`);
}
}
} finally {
await Promise.all([
+5 -11
View File
@@ -60,7 +60,6 @@ pub(crate) fn metric_history_ddl(table: &str) -> String {
disk_utilization REAL NOT NULL DEFAULT 0,
sample_count INTEGER NOT NULL DEFAULT 1 CHECK(sample_count > 0),
latest_timestamp INTEGER NOT NULL,
latest_json TEXT NOT NULL CHECK(json_valid(latest_json)),
latency_json TEXT NOT NULL DEFAULT '[]' CHECK(json_valid(latency_json)),
PRIMARY KEY(server_id, timestamp)
) WITHOUT ROWID"#
@@ -1028,14 +1027,14 @@ pub async fn save_reports_with_history(
tcp_connections, udp_connections, gpu_usage,
disk_read_bps, disk_write_bps, disk_read_iops, disk_write_iops,
disk_await_ms, disk_utilization, sample_count,
latest_timestamp, latest_json, latency_json
latest_timestamp, latency_json
) SELECT
?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14,
?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25, ?26,
?27, ?28, ?29, ?30
?27, ?28, ?29
WHERE NOT EXISTS (
SELECT 1 FROM server_latest_state
WHERE server_id = ?1 AND last_batch_id = ?31
WHERE server_id = ?1 AND last_batch_id = ?30
) ON CONFLICT(server_id, timestamp) DO UPDATE SET
cpu=(metric_history.cpu * metric_history.sample_count +
excluded.cpu * excluded.sample_count) /
@@ -1074,9 +1073,6 @@ pub async fn save_reports_with_history(
disk_utilization=MAX(metric_history.disk_utilization, excluded.disk_utilization),
sample_count=metric_history.sample_count + excluded.sample_count,
latest_timestamp=MAX(metric_history.latest_timestamp, excluded.latest_timestamp),
latest_json=CASE
WHEN excluded.latest_timestamp >= metric_history.latest_timestamp THEN excluded.latest_json
ELSE metric_history.latest_json END,
latency_json=CASE
WHEN json_array_length(excluded.latency_json) = 0 THEN metric_history.latency_json
ELSE (
@@ -1161,7 +1157,6 @@ pub async fn save_reports_with_history(
number(history_point.disk_utilization),
number(sample_count.max(1)),
number(latest_timestamp),
text(&latest_json),
text(&latency_json),
text(batch_id),
])?;
@@ -1657,14 +1652,14 @@ pub async fn cleanup_history(db: &D1Database, retention_days: i64) -> Result<()>
net_rx_total, net_tx_total, processes, tcp_connections, udp_connections,
gpu_usage, disk_read_bps, disk_write_bps, disk_read_iops, disk_write_iops,
disk_await_ms, disk_utilization, sample_count,
latest_timestamp, latest_json, latency_json
latest_timestamp, latency_json
) SELECT
m.server_id, m.bucket, m.cpu, m.load1, m.load5, m.load15, m.mem_used,
m.mem_total, m.swap_used, m.swap_total, m.disk_used, m.disk_total,
m.net_in, m.net_out, m.net_rx_total, m.net_tx_total, m.processes,
m.tcp_connections, m.udp_connections, m.gpu_usage, m.disk_read_bps,
m.disk_write_bps, m.disk_read_iops, m.disk_write_iops, m.disk_await_ms,
m.disk_utilization, m.sample_count, m.latest_timestamp, '{}',
m.disk_utilization, m.sample_count, m.latest_timestamp,
COALESCE(l.latency_json, '[]')
FROM metrics m LEFT JOIN latency_packed l
ON l.server_id = m.server_id AND l.bucket = m.bucket
@@ -1693,7 +1688,6 @@ pub async fn cleanup_history(db: &D1Database, retention_days: i64) -> Result<()>
disk_utilization=excluded.disk_utilization,
sample_count=excluded.sample_count,
latest_timestamp=excluded.latest_timestamp,
latest_json=excluded.latest_json,
latency_json=excluded.latency_json"#,
)
.bind(&[number(cutoff), number(complete_hours_before)])?;
+52 -3
View File
@@ -28,6 +28,25 @@ const MAX_ALERT_WINDOW_SAMPLES: usize = 24 * 60 + 1;
const ALERT_STORAGE_PREFIX: &str = "resource-alert:";
const DASHBOARD_HUB_NAME: &str = "dashboard";
/// 机器身份字段,一批里逐条重复且几乎不变,实时样本不带它们。
///
/// 前端 `mergeServerLive` 是 `{ ...server, ...live.metrics }`,样本里缺的键会露出
/// bootstrap 那份,而 `SERVER_SELECT` 从 `server_latest_state.latest_json` 里
/// `json_extract` 出这七个字段,所以前端不需要任何改动。
///
/// 代价:agent 升级后这些值要等下一次 bootstrap 轮询才刷新。
/// 只影响展示,不影响任何判定 —— 告警和持久化读的是 `AgentReport` 本身,
/// 剥离只发生在发往浏览器的副本上。
const BROWSER_OMITTED_REPORT_FIELDS: [&str; 7] = [
"cpu_model",
"os",
"kernel",
"arch",
"virtualization",
"gpu_model",
"agent_version",
];
fn server_hub_name(server_id: &str) -> String {
format!("server:{server_id}")
}
@@ -427,6 +446,9 @@ fn batch_update_parts_at(
let mut data = serde_json::to_value(report)?;
if let Some(data) = data.as_object_mut() {
data.remove("timestamp");
for key in BROWSER_OMITTED_REPORT_FIELDS {
data.remove(key);
}
}
samples.push(CachedLiveSample {
ts: timestamp,
@@ -1382,9 +1404,10 @@ mod tests {
consume_agent_message_budget, finish_d1_flush, live_ack_payload, next_d1_write_ms,
outbound_close_code, trim_cached_live_samples, trim_socket_attachment, update_alert_window,
AgentMessageBudget, AlertEvaluationServer, AlertWindowSample, CachedLiveSample,
SocketAttachment, TrafficAttachment, ALERT_WINDOW_SECONDS, CACHED_LIVE_TTL_SECONDS,
MAX_AGENT_BYTES_PER_WINDOW, MAX_AGENT_MESSAGES_PER_WINDOW, MAX_AGENT_SAMPLES_PER_WINDOW,
MAX_CACHED_LIVE_BYTES, MAX_CACHED_LIVE_SAMPLES, MAX_SERIALIZED_ATTACHMENT_BYTES,
SocketAttachment, TrafficAttachment, ALERT_WINDOW_SECONDS, BROWSER_OMITTED_REPORT_FIELDS,
CACHED_LIVE_TTL_SECONDS, MAX_AGENT_BYTES_PER_WINDOW, MAX_AGENT_MESSAGES_PER_WINDOW,
MAX_AGENT_SAMPLES_PER_WINDOW, MAX_CACHED_LIVE_BYTES, MAX_CACHED_LIVE_SAMPLES,
MAX_SERIALIZED_ATTACHMENT_BYTES,
};
use crate::db::HistoryMetricAggregate;
use crate::latency::LatencyMetricAggregates;
@@ -1510,6 +1533,32 @@ mod tests {
assert!(payload["updates"][0]["samples"][0]["data"]
.get("timestamp")
.is_none());
// 身份字段不上线:前端靠 bootstrap 那份。fixture 里 cpu_model="CPU"、
// agent_version="test" 都是非空的,所以这几条断言不是空转。
for field in BROWSER_OMITTED_REPORT_FIELDS {
assert!(
payload["updates"][0]["samples"][0]["data"]
.get(field)
.is_none(),
"{field} 不应出现在发往浏览器的样本里"
);
}
// 同一批的动态字段必须留着,别把剥离范围扩大了
for field in [
"cpu",
"mem_used",
"net_in",
"uptime",
"cpu_cores",
"gpu_usage",
] {
assert!(
payload["updates"][0]["samples"][0]["data"]
.get(field)
.is_some(),
"{field} 是动态字段,不该被剥离"
);
}
let attachment = SocketAttachment {
role: "agent".to_string(),
server_id: Some("node-a".to_string()),