优化agent
This commit is contained in:
1 parent
0cdcbe40f4
commit
09db33d5fa
12 files changed
+206
-103
No files matched your search
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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
@@ -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()),
|
||||
|
||||
Reference in new issue
Block a user