feat(admin): consolidate dashboard overview cards
This commit is contained in:
parent
3bed132c30
commit
7ffa9eb645
|
|
@ -17,14 +17,14 @@ class AdminStorageTests(unittest.TestCase):
|
|||
def test_storage_page_exposes_trash_and_physical_total(self) -> None:
|
||||
uid = uuid4()
|
||||
scanned = datetime(2026, 8, 28, tzinfo=timezone.utc)
|
||||
total_result = MagicMock()
|
||||
total_result.scalar_one.return_value = 1
|
||||
totals_result = MagicMock()
|
||||
totals_result.one.return_value = (1, 100, 40, 2, 1)
|
||||
rows_result = MagicMock()
|
||||
rows_result.all.return_value = [
|
||||
(uid, "user@example.com", "测试用户", "tester", 100, 2, 40, 1, scanned)
|
||||
]
|
||||
session = MagicMock()
|
||||
session.execute.side_effect = [total_result, rows_result]
|
||||
session.execute.side_effect = [totals_result, rows_result]
|
||||
|
||||
with patch("core.agent_builder.load_config", return_value={"quotas": {}}):
|
||||
page = _storage_page(session, 0, 20)
|
||||
|
|
@ -34,6 +34,14 @@ class AdminStorageTests(unittest.TestCase):
|
|||
self.assertEqual(row["trash_bytes"], 40)
|
||||
self.assertEqual(row["trash_file_count"], 1)
|
||||
self.assertEqual(row["physical_bytes"], 140)
|
||||
self.assertEqual(page["total"], 1)
|
||||
self.assertEqual(page["totals"], {
|
||||
"bytes_used": 100,
|
||||
"trash_bytes": 40,
|
||||
"physical_bytes": 140,
|
||||
"file_count": 2,
|
||||
"trash_file_count": 1,
|
||||
})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
|
|
|||
|
|
@ -96,23 +96,36 @@ class StaticVendorTests(unittest.TestCase):
|
|||
self.assertIn("永久删除", admin_js)
|
||||
self.assertNotIn("localStorage.setItem", admin_js)
|
||||
|
||||
def test_admin_has_cross_module_operations_summary(self) -> None:
|
||||
def test_admin_consolidates_overview_into_eight_summary_cards(self) -> None:
|
||||
html = ADMIN_HTML.read_text(encoding="utf-8")
|
||||
admin_js = (JS_DIR / "admin.js").read_text(encoding="utf-8")
|
||||
|
||||
self.assertIn('id="ops-summary" class="ops-summary"', admin_js)
|
||||
self.assertIn(".ops-summary {", html)
|
||||
for label in (
|
||||
labels = (
|
||||
"运行容量",
|
||||
"任务状态",
|
||||
"活跃用户",
|
||||
"Token 与缓存",
|
||||
"Windows Node",
|
||||
"存储",
|
||||
"工具健康",
|
||||
"用户活跃",
|
||||
"任务状态",
|
||||
"成本",
|
||||
"Token 与缓存",
|
||||
):
|
||||
"工具健康",
|
||||
)
|
||||
positions = []
|
||||
for label in labels:
|
||||
self.assertIn(f'label: "{label}"', admin_js)
|
||||
positions.append(admin_js.index(f'label: "{label}"'))
|
||||
self.assertEqual(positions, sorted(positions))
|
||||
self.assertNotIn("function renderRuntime", admin_js)
|
||||
self.assertNotIn("function renderTasks", admin_js)
|
||||
self.assertNotIn("function renderUsersAndUsage", admin_js)
|
||||
self.assertIn('["s-overview", "总览"]', admin_js)
|
||||
self.assertIn('["s-usage", "用量趋势"]', admin_js)
|
||||
self.assertIn('unit: "活动任务 / 在线节点"', admin_js)
|
||||
self.assertIn("tokenSummaryHTML(tokens7d, totalTokens)", admin_js)
|
||||
self.assertIn("storageTotals.physical_bytes", admin_js)
|
||||
self.assertIn("repeat(auto-fit, minmax(190px, 1fr))", html)
|
||||
self.assertIn('apiGet("/v1/admin/storage/users?page=0&page_size=1")', admin_js)
|
||||
self.assertIn('target: "s-windows-node"', admin_js)
|
||||
self.assertIn('target: "s-toolfail"', admin_js)
|
||||
|
|
|
|||
22
web/admin.py
22
web/admin.py
|
|
@ -97,7 +97,7 @@ def _users_section(s: Any, cutoff_7d: datetime) -> dict:
|
|||
|
||||
|
||||
def _storage_page(s: Any, page: int, page_size: int) -> dict:
|
||||
"""分页的各用户磁盘用量(bytes desc + user_id 兜底);附 per-user 配额。
|
||||
"""分页的各用户磁盘用量,并附全站聚合与 per-user 配额。
|
||||
|
||||
bytes_used 是用户配额占用;trash_bytes 是不计配额但仍占磁盘的隐藏回收站。
|
||||
数据源 user_disk_usage(后台扫描快照,只含扫过的用户);total 为该表行数。
|
||||
|
|
@ -106,7 +106,15 @@ def _storage_page(s: Any, page: int, page_size: int) -> dict:
|
|||
from core.storage.disk_quota import parse_bytes
|
||||
|
||||
quota = parse_bytes((load_config().get("quotas") or {}).get("disk_bytes_per_user"))
|
||||
total = s.execute(select(func.count()).select_from(UserDiskUsage)).scalar_one()
|
||||
total, bytes_used, trash_bytes, file_count, trash_file_count = s.execute(
|
||||
select(
|
||||
func.count(),
|
||||
func.coalesce(func.sum(UserDiskUsage.bytes_used), 0),
|
||||
func.coalesce(func.sum(UserDiskUsage.trash_bytes), 0),
|
||||
func.coalesce(func.sum(UserDiskUsage.file_count), 0),
|
||||
func.coalesce(func.sum(UserDiskUsage.trash_file_count), 0),
|
||||
).select_from(UserDiskUsage)
|
||||
).one()
|
||||
rows = [
|
||||
{
|
||||
"user_id": str(uid),
|
||||
|
|
@ -140,7 +148,15 @@ def _storage_page(s: Any, page: int, page_size: int) -> dict:
|
|||
]
|
||||
return {
|
||||
"page": page, "page_size": page_size, "total": total,
|
||||
"quota_bytes": quota, "rows": rows,
|
||||
"quota_bytes": quota,
|
||||
"totals": {
|
||||
"bytes_used": int(bytes_used or 0),
|
||||
"trash_bytes": int(trash_bytes or 0),
|
||||
"physical_bytes": int(bytes_used or 0) + int(trash_bytes or 0),
|
||||
"file_count": int(file_count or 0),
|
||||
"trash_file_count": int(trash_file_count or 0),
|
||||
},
|
||||
"rows": rows,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -59,21 +59,23 @@
|
|||
.msg a { color: var(--accent); }
|
||||
|
||||
/* 目录 + 内容两栏;目录 sticky 跟随滚动 */
|
||||
#layout { display: grid; grid-template-columns: 172px minmax(0, 1fr); gap: 16px; align-items: start; }
|
||||
#layout { display: grid; grid-template-columns: 158px minmax(0, 1fr); gap: 14px; align-items: start; }
|
||||
#toc {
|
||||
position: sticky; top: 70px; display: flex; flex-direction: column; gap: 2px;
|
||||
padding: 7px; background: rgba(255,255,255,.82); border: 1px solid var(--border);
|
||||
position: sticky; top: 70px; display: flex; flex-direction: column; gap: 3px;
|
||||
padding: 8px; background: rgba(255,255,255,.88); border: 1px solid var(--border);
|
||||
border-radius: var(--r-lg); box-shadow: var(--shadow-soft);
|
||||
}
|
||||
#toc .toc-label { padding: 4px 8px 6px; color: #959ba1; font-size: 10px; font-weight: 700; letter-spacing: .8px; }
|
||||
#toc .toc-label { padding: 4px 9px 7px; color: #8a9299; font-size: 10px; font-weight: 700; letter-spacing: .8px; }
|
||||
#toc a {
|
||||
display: flex; align-items: center; gap: 7px; min-height: 31px;
|
||||
color: var(--muted); text-decoration: none; font-size: 12px; padding: 5px 8px;
|
||||
border-radius: 5px; border: 1px solid transparent; transition: .15s ease;
|
||||
position: relative; display: flex; align-items: center; gap: 8px; min-height: 34px;
|
||||
color: var(--muted); text-decoration: none; font-size: 12.5px; padding: 6px 8px;
|
||||
border-radius: 6px; border: 1px solid transparent; transition: .15s ease;
|
||||
}
|
||||
#toc .nav-index { width: 20px; color: #a0a6ac; font: 10px/1 var(--mono); }
|
||||
#toc .nav-index { width: 20px; flex: 0 0 auto; color: #9aa2a9; font: 10px/1 var(--mono); }
|
||||
#toc a > span:last-child { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
#toc a:hover { background: var(--panel-soft); color: var(--text-strong); }
|
||||
#toc a.active { color: var(--accent); border-color: #f0d5d2; background: var(--accent-soft); font-weight: 650; }
|
||||
#toc a.active::before { content: ""; position: absolute; left: -1px; top: 8px; bottom: 8px; width: 3px; border-radius: 0 3px 3px 0; background: var(--accent); }
|
||||
#toc a.active .nav-index { color: var(--accent); }
|
||||
#content { min-width: 0; }
|
||||
.page-intro {
|
||||
|
|
@ -89,24 +91,24 @@
|
|||
.page-intro p { position: relative; margin: 3px 0 0; color: var(--muted); font-size: 11px; }
|
||||
.anchor { scroll-margin-top: 68px; }
|
||||
|
||||
/* 首屏运营摘要:跨模块聚合关键状态,点击卡片跳到对应明细。 */
|
||||
.ops-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; margin-bottom: 10px; }
|
||||
/* 首屏运营摘要:第一排业务运行,第二排基础设施与质量。 */
|
||||
.ops-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; margin-bottom: 10px; }
|
||||
.summary-card {
|
||||
min-width: 0; min-height: 112px; display: flex; flex-direction: column; padding: 10px 12px 9px;
|
||||
min-width: 0; min-height: 156px; display: flex; flex-direction: column; padding: 13px 14px 11px;
|
||||
color: inherit; text-decoration: none; background: var(--panel); border: 1px solid var(--border);
|
||||
border-radius: var(--r-lg); box-shadow: var(--shadow-soft); transition: .15s ease;
|
||||
}
|
||||
.summary-card:hover { border-color: #c9ced2; box-shadow: 0 3px 10px rgba(18,24,30,.06); transform: translateY(-1px); }
|
||||
.summary-card[href]:hover { border-color: #c9ced2; box-shadow: 0 3px 10px rgba(18,24,30,.06); transform: translateY(-1px); }
|
||||
.summary-card .label-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||
.summary-card .label { min-width: 0; color: var(--muted); font-size: 11px; font-weight: 600; }
|
||||
.summary-card .jump { flex: 0 0 auto; color: #a0a6ac; font-size: 12px; }
|
||||
.summary-card .summary-value { margin-top: 4px; color: var(--text-strong); font: 650 21px/1.2 var(--mono); letter-spacing: -.4px; }
|
||||
.summary-card .summary-value .unit { color: var(--muted); font-size: 11px; font-weight: 550; letter-spacing: 0; }
|
||||
.summary-card .summary-sub { margin-top: 3px; overflow: hidden; color: var(--muted); font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.summary-card .summary-meta { display: flex; align-items: flex-end; justify-content: space-between; gap: 8px; margin-top: auto; padding-top: 7px; color: var(--muted); font-size: 10px; }
|
||||
.summary-card .label { min-width: 0; color: #626b73; font-size: 13px; font-weight: 650; }
|
||||
.summary-card .jump { flex: 0 0 auto; color: #929aa1; font-size: 14px; }
|
||||
.summary-card .summary-value { margin-top: 6px; color: var(--text-strong); font: 680 28px/1.15 var(--mono); letter-spacing: -.7px; }
|
||||
.summary-card .summary-value .unit { color: var(--muted); font-size: 11px; font-weight: 600; letter-spacing: 0; white-space: nowrap; }
|
||||
.summary-card .summary-sub { margin-top: 5px; overflow: hidden; color: var(--muted); font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.summary-card .summary-meta { display: flex; align-items: flex-end; justify-content: space-between; gap: 8px; margin-top: auto; padding-top: 8px; color: var(--muted); font-size: 11px; }
|
||||
.summary-card .summary-meta span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.summary-card .summary-meta span:last-child { text-align: right; }
|
||||
.summary-card .summary-meter { height: 3px; margin-top: 5px; overflow: hidden; border-radius: 999px; background: #e8ebed; }
|
||||
.summary-card .summary-meter { height: 4px; margin-top: 6px; overflow: hidden; border-radius: 999px; background: #e8ebed; }
|
||||
.summary-card .summary-meter > i { display: block; width: var(--value); height: 100%; border-radius: inherit; background: #89939d; }
|
||||
.summary-card.warn { border-color: #efd8b7; background: #fffaf2; }
|
||||
.summary-card.warn .summary-value { color: var(--warn); }
|
||||
|
|
@ -114,9 +116,20 @@
|
|||
.summary-card.danger { border-color: #edc1bd; background: var(--accent-soft); }
|
||||
.summary-card.danger .summary-value { color: var(--danger); }
|
||||
.summary-card.danger .summary-meter > i { background: var(--danger); }
|
||||
.summary-spark { height: 18px; display: flex; align-items: flex-end; gap: 2px; margin-top: 5px; }
|
||||
.summary-spark i { flex: 1; min-width: 2px; height: var(--value); border-radius: 2px 2px 0 0; background: rgba(184,58,49,.28); }
|
||||
|
||||
.summary-dual { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; margin-top: 7px; }
|
||||
.summary-dual > div { min-width: 0; padding-right: 8px; border-right: 1px solid var(--border-soft); }
|
||||
.summary-dual > div:last-child { padding-right: 0; border-right: 0; }
|
||||
.summary-dual span { display: block; color: var(--muted); font-size: 10px; font-weight: 600; }
|
||||
.summary-dual strong { display: block; overflow: hidden; margin-top: 1px; color: var(--text-strong); font: 680 23px/1.2 var(--mono); letter-spacing: -.6px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.summary-task-group { display: grid; grid-template-columns: 27px minmax(0, 1fr); gap: 4px; margin-top: 5px; }
|
||||
.summary-group-label { padding-top: 2px; color: #8b939a; font-size: 9px; font-weight: 700; }
|
||||
.summary-task-group > div { display: flex; flex-wrap: wrap; gap: 3px; min-width: 0; }
|
||||
.summary-status { padding: 1px 5px; border-radius: 999px; background: #f0f2f3; color: #4d555c; font: 10px/1.4 var(--mono); white-space: nowrap; }
|
||||
.summary-status b { font-weight: 750; }
|
||||
.summary-status.run { color: #1565c0; background: #e7f3ff; }
|
||||
.summary-status.err { color: var(--danger); background: var(--accent-soft); }
|
||||
.summary-status.ok { color: var(--ok); background: #e8f5e9; }
|
||||
.task-summary .summary-value { font-size: 25px; }
|
||||
.card-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 7px; }
|
||||
.card-head h2 { margin: 0; }
|
||||
.ctrl { display: flex; gap: 6px; }
|
||||
|
|
@ -126,33 +139,14 @@
|
|||
}
|
||||
.sublabel { color: var(--muted); font-size: 10px; margin-bottom: 3px; font-weight: 600; letter-spacing: .2px; }
|
||||
|
||||
.grid { display: grid; gap: 8px; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); }
|
||||
/* auto-fit 会折叠空轨道,让不足一整行的指标卡平分剩余宽度。 */
|
||||
.grid { display: grid; gap: 8px; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); }
|
||||
.card {
|
||||
background: var(--panel); border: 1px solid var(--border); border-radius: var(--r-lg);
|
||||
padding: 12px 14px; margin-bottom: 10px; box-shadow: var(--shadow-soft);
|
||||
}
|
||||
.card h2 { margin: 0 0 8px; font-size: 13px; color: var(--text-strong); font-weight: 650; letter-spacing: .1px; }
|
||||
|
||||
/* 大数字 stat 块 */
|
||||
.stat { position: relative; overflow: hidden; min-height: 102px; display: flex; flex-direction: column; background: linear-gradient(145deg,#fff,var(--panel-soft)); border: 1px solid var(--border-soft); border-radius: 7px; padding: 10px 12px 9px; }
|
||||
.stat::before { content: ""; position: absolute; inset: 0 auto 0 0; width: 3px; background: #d9dde1; }
|
||||
.stat .k { color: var(--muted); font-size: 11px; }
|
||||
.stat .v { color: var(--text-strong); font-size: 20px; font-weight: 650; font-family: var(--mono); margin-top: 3px; letter-spacing: -.4px; line-height: 1.25; }
|
||||
.stat .v .unit { color: var(--muted); font-size: 12px; font-weight: 550; letter-spacing: 0; }
|
||||
.stat .sub { color: var(--muted); font-size: 11px; margin-top: 2px; }
|
||||
.stat .foot { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-top: auto; padding-top: 7px; color: var(--muted); font-size: 10px; }
|
||||
.stat .foot span:last-child { text-align: right; }
|
||||
.stat .meter { height: 3px; margin-top: 5px; overflow: hidden; border-radius: 999px; background: #e8ebed; }
|
||||
.stat .meter > i { display: block; width: var(--value); height: 100%; border-radius: inherit; background: #89939d; }
|
||||
.stat.warn { border-color: #efd8b7; background: #fffaf2; }
|
||||
.stat.warn::before { background: var(--warn); }
|
||||
.stat.warn .v { color: var(--warn); }
|
||||
.stat.warn .meter > i { background: var(--warn); }
|
||||
.stat.danger { border-color: #edc1bd; background: var(--accent-soft); }
|
||||
.stat.danger::before { background: var(--danger); }
|
||||
.stat.danger .v { color: var(--danger); }
|
||||
.stat.danger .meter > i { background: var(--danger); }
|
||||
|
||||
/* chips(状态分布) */
|
||||
.chips { display: flex; flex-wrap: wrap; gap: 5px; }
|
||||
.chip {
|
||||
|
|
@ -334,7 +328,7 @@
|
|||
}
|
||||
select:focus, input:focus, textarea:focus { border-color: var(--accent) !important; box-shadow: 0 0 0 3px var(--accent-ring); outline: none; }
|
||||
|
||||
@media (min-width: 821px) and (max-width: 1080px) {
|
||||
@media (min-width: 821px) and (max-width: 1240px) {
|
||||
#layout { grid-template-columns: 148px minmax(0, 1fr); gap: 12px; }
|
||||
#toc .nav-index { display: none; }
|
||||
.ops-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
|
|
@ -342,8 +336,7 @@
|
|||
|
||||
@media (max-width: 820px) {
|
||||
main { padding: 10px; }
|
||||
.grid { grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); }
|
||||
.stat .v { font-size: 18px; }
|
||||
.grid { grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); }
|
||||
/* header 紧凑化:缩 padding/gap、字号,gen-at 时间戳截断不撑宽 */
|
||||
header { padding: 8px 10px; gap: 8px; flex-wrap: wrap; }
|
||||
header .title { font-size: 14px; }
|
||||
|
|
@ -372,12 +365,15 @@
|
|||
.page-intro { padding: 12px 14px; }
|
||||
.page-intro h1 { font-size: 17px; }
|
||||
.ops-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.summary-card { min-height: 150px; }
|
||||
.summary-card .summary-value { font-size: 26px; }
|
||||
.anchor { scroll-margin-top: 150px; }
|
||||
.card { padding: 12px; }
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.ops-summary { grid-template-columns: 1fr; }
|
||||
.summary-card { min-height: 146px; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
|
|
|
|||
|
|
@ -12,8 +12,8 @@ const PAGE_SIZE = 20;
|
|||
const RANGE_OPTS = [["all", "全部"], ["7d", "近7天"], ["30d", "近30天"]];
|
||||
const SORT_OPTS = [["cost", "按成本"], ["tokens", "按用量"]];
|
||||
const SECTIONS = [
|
||||
["s-runtime", "运行态"], ["s-tasks", "任务"], ["s-usage", "用户与用量"],
|
||||
["s-models", "按模型"], ["s-users", "各用户用量"], ["s-storage", "存储"],
|
||||
["s-overview", "总览"], ["s-usage", "用量趋势"], ["s-models", "按模型"],
|
||||
["s-users", "各用户"], ["s-storage", "存储"],
|
||||
["s-windows-node", "Windows Node"],
|
||||
["s-external", "外部系统"],
|
||||
["s-toolfail", "工具失败"],
|
||||
|
|
@ -92,38 +92,44 @@ function ctrlHTML(prefix, range, sort) {
|
|||
function rangeLabel(r) { return (RANGE_OPTS.find(o => o[0] === r) || [, "全部"])[1]; }
|
||||
|
||||
// ───── 渲染各 section ─────
|
||||
function statCard(k, v, sub, cls, foot = [], progress = null) {
|
||||
const footHTML = foot.length
|
||||
? `<div class="foot"><span>${escapeHtml(foot[0] || "")}</span><span>${escapeHtml(foot[1] || "")}</span></div>`
|
||||
: "";
|
||||
const meterHTML = progress == null ? "" : `<div class="meter" aria-hidden="true"><i style="--value:${Math.max(0, Math.min(100, progress))}%"></i></div>`;
|
||||
return `<div class="stat ${cls || ""}"><div class="k">${escapeHtml(k)}</div>`
|
||||
+ `<div class="v">${v}</div>`
|
||||
+ (sub ? `<div class="sub">${escapeHtml(sub)}</div>` : "")
|
||||
+ footHTML + meterHTML + `</div>`;
|
||||
}
|
||||
|
||||
function sparkHTML(rows) {
|
||||
const values = (rows || []).map(row => Number(row.cost_cny) || 0);
|
||||
if (!values.length) return "";
|
||||
const max = Math.max(...values, 1);
|
||||
const bars = values.map(value => {
|
||||
const height = value > 0 ? Math.max(12, Math.round(value / max * 100)) : 4;
|
||||
return `<i style="--value:${height}%"></i>`;
|
||||
}).join("");
|
||||
return `<div class="summary-spark" aria-hidden="true">${bars}</div>`;
|
||||
}
|
||||
|
||||
function summaryCard({ target, label, value, unit = "", sub = "", meta = [], tone = "", progress = null, visual = "" }) {
|
||||
function summaryCard({ target = "", label, value = "", unit = "", sub = "", meta = [], tone = "", progress = null, body = "", extraClass = "" }) {
|
||||
const meter = progress == null ? "" : `<div class="summary-meter" aria-hidden="true"><i style="--value:${Math.max(0, Math.min(100, progress))}%"></i></div>`;
|
||||
const metaHTML = meta.length
|
||||
? `<div class="summary-meta"><span>${escapeHtml(meta[0] || "")}</span><span>${escapeHtml(meta[1] || "")}</span></div>`
|
||||
: "";
|
||||
return `<a class="summary-card ${tone}" href="#${target}">`
|
||||
+ `<div class="label-row"><span class="label">${escapeHtml(label)}</span><span class="jump" aria-hidden="true">↘</span></div>`
|
||||
+ `<div class="summary-value">${value}${unit ? ` <span class="unit">${escapeHtml(unit)}</span>` : ""}</div>`
|
||||
const tag = target ? "a" : "article";
|
||||
const href = target ? ` href="#${target}"` : "";
|
||||
return `<${tag} class="summary-card ${tone} ${extraClass}"${href}>`
|
||||
+ `<div class="label-row"><span class="label">${escapeHtml(label)}</span>${target ? `<span class="jump" aria-hidden="true">↘</span>` : ""}</div>`
|
||||
+ (value ? `<div class="summary-value">${value}${unit ? ` <span class="unit">${escapeHtml(unit)}</span>` : ""}</div>` : "")
|
||||
+ body
|
||||
+ (sub ? `<div class="summary-sub" title="${escapeHtml(sub)}">${escapeHtml(sub)}</div>` : "")
|
||||
+ metaHTML + visual + meter + `</a>`;
|
||||
+ metaHTML + meter + `</${tag}>`;
|
||||
}
|
||||
|
||||
const TASK_LABELS = {
|
||||
active: "活动", completed: "完成", abandoned: "废弃",
|
||||
running: "运行", cancelling: "取消中", error: "错误", idle: "空闲", cancelled: "取消",
|
||||
};
|
||||
|
||||
function taskGroupHTML(label, statuses, preferredOrder) {
|
||||
const entries = Object.entries(statuses || {}).sort((a, b) => {
|
||||
const ai = preferredOrder.indexOf(a[0]);
|
||||
const bi = preferredOrder.indexOf(b[0]);
|
||||
return (ai < 0 ? preferredOrder.length : ai) - (bi < 0 ? preferredOrder.length : bi);
|
||||
});
|
||||
const values = entries.map(([status, count]) => {
|
||||
const cls = status === "error" ? "err" : (["running", "cancelling"].includes(status) ? "run" : (status === "completed" ? "ok" : ""));
|
||||
return `<span class="summary-status ${cls}">${escapeHtml(TASK_LABELS[status] || status)} <b>${Number(count) || 0}</b></span>`;
|
||||
}).join("") || `<span class="summary-status">无</span>`;
|
||||
return `<div class="summary-task-group"><span class="summary-group-label">${label}</span><div>${values}</div></div>`;
|
||||
}
|
||||
|
||||
function tokenSummaryHTML(tokens7d, totalTokens) {
|
||||
return `<div class="summary-dual">`
|
||||
+ `<div><span>近 7 天</span><strong>${fmtTokens(tokens7d)}</strong></div>`
|
||||
+ `<div><span>累计</span><strong>${fmtTokens(totalTokens)}</strong></div>`
|
||||
+ `</div>`;
|
||||
}
|
||||
|
||||
function renderOpsSummary() {
|
||||
|
|
@ -148,20 +154,38 @@ function renderOpsSummary() {
|
|||
const nodeJobs = softwareNodes.reduce((sum, node) => sum + (Number(node.active_job_count) || 0), 0);
|
||||
const nodeTone = softwareNodesLoaded && nodeTotal && !nodeOnline ? "danger" : (nodeOffline || nodeDisabled ? "warn" : "");
|
||||
|
||||
const runStatuses = tasks.by_run_status || {};
|
||||
const runningTasks = (Number(runStatuses.running) || 0) + (Number(runStatuses.cancelling) || 0);
|
||||
const errorTasks = Number(runStatuses.error) || 0;
|
||||
const taskTone = errorTasks ? "warn" : "";
|
||||
const taskBody = taskGroupHTML("任务", tasks.by_status, ["active", "completed", "abandoned"])
|
||||
+ taskGroupHTML("执行", runStatuses, ["running", "cancelling", "error", "idle", "cancelled", "completed"]);
|
||||
|
||||
const userTotal = Number(users.total) || 0;
|
||||
const activeUsers = Number(users.active_7d) || 0;
|
||||
const activeRate = userTotal ? Math.round(activeUsers / userTotal * 100) : 0;
|
||||
|
||||
const tokensIn = Number(usage.tokens_in) || 0;
|
||||
const tokensOut = Number(usage.tokens_out) || 0;
|
||||
const totalTokens = tokensIn + tokensOut;
|
||||
const tokens7d = byDay.reduce((sum, row) => sum + (Number(row.tokens_in) || 0) + (Number(row.tokens_out) || 0), 0);
|
||||
const cacheHit = Number(usage.tokens_cache_hit) || 0;
|
||||
const hitRate = tokensIn ? Math.round(cacheHit / tokensIn * 100) : 0;
|
||||
|
||||
const storage = storageSummaryData;
|
||||
const topStorage = storage && (storage.rows || [])[0];
|
||||
const storageTotals = (storage && storage.totals) || {};
|
||||
const quota = storage && Number(storage.quota_bytes);
|
||||
const storageRatio = topStorage && quota > 0 ? (Number(topStorage.bytes_used) || 0) / quota : 0;
|
||||
const scannedUsers = storage ? Number(storage.total) || 0 : 0;
|
||||
const aggregateQuota = quota > 0 ? quota * scannedUsers : 0;
|
||||
const storageRatio = aggregateQuota ? (Number(storageTotals.bytes_used) || 0) / aggregateQuota : 0;
|
||||
const storageTone = levelClass(storageRatio);
|
||||
const storageValue = !storage ? "—" : topStorage
|
||||
? (quota > 0 ? `${Math.round(storageRatio * 100)}%` : humanSize(topStorage.bytes_used || 0))
|
||||
: "0";
|
||||
const storageSub = !storage ? "正在读取存储快照" : topStorage
|
||||
? `最高占用 · ${userLabelText(topStorage)}`
|
||||
: "暂无用户存储快照";
|
||||
const storageMeta = topStorage
|
||||
? [`${humanSize(topStorage.bytes_used || 0)} · ${topStorage.file_count || 0} 个文件`, `回收站 ${humanSize(topStorage.trash_bytes || 0)}`]
|
||||
: [storage ? `已扫描 ${storage.total || 0} 人` : "加载中", ""];
|
||||
const storageValue = storage ? humanSize(storageTotals.physical_bytes || 0) : "—";
|
||||
const storageSub = storage
|
||||
? `配额内 ${humanSize(storageTotals.bytes_used || 0)} · 回收站 ${humanSize(storageTotals.trash_bytes || 0)}`
|
||||
: "正在读取存储快照";
|
||||
const storageMeta = storage
|
||||
? [`${storageTotals.file_count || 0} 个文件`, `已扫描 ${scannedUsers} 人`]
|
||||
: ["加载中", ""];
|
||||
|
||||
const failureRows = (toolFailuresData && toolFailuresData.clusters) || [];
|
||||
const activeFailures = failureRows.filter(item => (item.count_24h || 0) > 0);
|
||||
|
|
@ -174,145 +198,53 @@ function renderOpsSummary() {
|
|||
const malformed = Number(((toolWireData || {}).total || {}).malformed_24h) || 0;
|
||||
const toolTone = systemicCount || malformed ? "danger" : (localCount ? "warn" : "");
|
||||
|
||||
const userTotal = Number(users.total) || 0;
|
||||
const activeUsers = Number(users.active_7d) || 0;
|
||||
const activeRate = userTotal ? Math.round(activeUsers / userTotal * 100) : 0;
|
||||
|
||||
const runStatuses = tasks.by_run_status || {};
|
||||
const runningTasks = (Number(runStatuses.running) || 0) + (Number(runStatuses.cancelling) || 0);
|
||||
const errorTasks = Number(runStatuses.error) || 0;
|
||||
const completedTasks = Number(runStatuses.completed) || 0;
|
||||
const runTaskTotal = Object.values(runStatuses).reduce((sum, value) => sum + (Number(value) || 0), 0);
|
||||
const completionRate = runTaskTotal ? Math.round(completedTasks / runTaskTotal * 100) : 0;
|
||||
|
||||
const events = Number(usage.n_events) || 0;
|
||||
const cost = Number(usage.cost_cny) || 0;
|
||||
const cost7d = byDay.reduce((sum, row) => sum + (Number(row.cost_cny) || 0), 0);
|
||||
const tokensIn = Number(usage.tokens_in) || 0;
|
||||
const tokensOut = Number(usage.tokens_out) || 0;
|
||||
const totalTokens = tokensIn + tokensOut;
|
||||
const cacheHit = Number(usage.tokens_cache_hit) || 0;
|
||||
const hitRate = tokensIn ? Math.round(cacheHit / tokensIn * 100) : 0;
|
||||
const rss = runtime.rss_peak_mb != null ? `${Math.round(runtime.rss_peak_mb)} MB` : "—";
|
||||
|
||||
root.innerHTML = [
|
||||
summaryCard({
|
||||
target: "s-runtime", label: "运行容量", value: maxWorkers ? `${active} / ${maxWorkers}` : String(active),
|
||||
sub: maxWorkers ? `并发占用 ${runPct}%` : "当前活跃 run", meta: [maxWorkers ? `空闲 ${Math.max(0, maxWorkers - active)}` : "并发上限未设置", `${runtime.sse_subs || 0} 个 SSE`],
|
||||
label: "运行容量", value: maxWorkers ? `${active} / ${maxWorkers}` : String(active), unit: maxWorkers ? "活跃 / 上限" : "活跃 run",
|
||||
sub: maxWorkers ? `并发占用 ${runPct}% · SSE ${runtime.sse_subs || 0}` : `SSE ${runtime.sse_subs || 0}`,
|
||||
meta: [maxWorkers ? `空闲 ${Math.max(0, maxWorkers - active)}` : "并发上限未设置", `内存峰值 ${rss}`],
|
||||
tone: runTone, progress: maxWorkers ? runPct : null,
|
||||
}),
|
||||
summaryCard({
|
||||
target: "s-windows-node", label: "Windows Node", value: softwareNodesLoaded ? `${nodeOnline} / ${nodeTotal}` : "—",
|
||||
sub: softwareNodesLoaded ? `${nodeOnline} 在线 · ${nodeJobs} 个活动任务` : "正在读取节点状态",
|
||||
label: "任务状态", value: `${runningTasks} / ${tasks.total || 0}`, unit: "运行中 / 总数",
|
||||
body: taskBody, tone: taskTone, extraClass: "task-summary",
|
||||
}),
|
||||
summaryCard({
|
||||
target: "s-users", label: "活跃用户", value: `${activeUsers} / ${userTotal}`, unit: "近 7 天 / 总数",
|
||||
sub: `近 7 天活跃率 ${activeRate}%`, meta: [`活跃 ${activeUsers}`, `未活跃 ${Math.max(0, userTotal - activeUsers)}`], progress: activeRate,
|
||||
}),
|
||||
summaryCard({
|
||||
target: "s-usage", label: "Token 与缓存", body: tokenSummaryHTML(tokens7d, totalTokens),
|
||||
sub: `输入 ${fmtTokens(tokensIn)} · 输出 ${fmtTokens(tokensOut)}`,
|
||||
meta: [`缓存命中 ${hitRate}%`, `命中 ${fmtTokens(cacheHit)}`], progress: hitRate, extraClass: "token-summary",
|
||||
}),
|
||||
summaryCard({
|
||||
target: "s-windows-node", label: "Windows Node", value: softwareNodesLoaded ? `${nodeJobs} / ${nodeOnline}` : "—", unit: "活动任务 / 在线节点",
|
||||
sub: softwareNodesLoaded ? `节点总数 ${nodeTotal}` : "正在读取节点状态",
|
||||
meta: softwareNodesLoaded ? [`离线 ${nodeOffline}`, `禁用 ${nodeDisabled}`] : ["加载中", ""], tone: nodeTone,
|
||||
}),
|
||||
summaryCard({
|
||||
target: "s-storage", label: "存储", value: storageValue,
|
||||
unit: topStorage && quota > 0 ? "最高配额占用" : "", sub: storageSub, meta: storageMeta,
|
||||
tone: storageTone, progress: topStorage && quota > 0 ? Math.round(storageRatio * 100) : null,
|
||||
target: "s-storage", label: "存储", value: storageValue, unit: "物理总占用",
|
||||
sub: storageSub, meta: storageMeta, tone: storageTone,
|
||||
progress: aggregateQuota ? Math.round(storageRatio * 100) : null,
|
||||
}),
|
||||
summaryCard({
|
||||
target: "s-usage", label: "成本", value: fmtCNY(cost), unit: "累计",
|
||||
sub: `近 7 天 ${fmtCNY(cost7d)}`, meta: [`${events} 次事件`, `均次 ${fmtCNY(events ? cost / events : 0)}`],
|
||||
}),
|
||||
summaryCard({
|
||||
target: "s-toolfail", label: "工具健康", value: toolFailuresData ? String(failureCount) : "—", unit: "近 24h 失败",
|
||||
sub: toolFailuresData ? `系统性聚集 ${systemicCount} · 单任务 ${localCount}` : "正在汇总工具调用状态",
|
||||
meta: toolFailuresData ? [`链路残余 ${malformed}`, `质量门 ${gateCount}`] : ["加载中", ""], tone: toolTone,
|
||||
}),
|
||||
summaryCard({
|
||||
target: "s-usage", label: "用户活跃", value: String(userTotal), unit: "用户",
|
||||
sub: `近 7 天活跃 ${activeUsers}`, meta: [`活跃率 ${activeRate}%`, `未活跃 ${Math.max(0, userTotal - activeUsers)}`], progress: activeRate,
|
||||
}),
|
||||
summaryCard({
|
||||
target: "s-tasks", label: "任务状态", value: String(runningTasks), unit: "运行中",
|
||||
sub: `累计任务 ${tasks.total || 0}`, meta: [`错误 ${errorTasks}`, `完成率 ${completionRate}%`],
|
||||
tone: errorTasks ? "warn" : "", progress: completionRate,
|
||||
}),
|
||||
summaryCard({
|
||||
target: "s-models", label: "成本", value: fmtCNY(cost),
|
||||
sub: `${events} 次事件 · 均次 ${fmtCNY(events ? cost / events : 0)}`, meta: [`近 7 天 ${fmtCNY(cost7d)}`, "累计口径"], visual: sparkHTML(byDay),
|
||||
}),
|
||||
summaryCard({
|
||||
target: "s-models", label: "Token 与缓存", value: fmtTokens(totalTokens), unit: "总 token",
|
||||
sub: `输入 ${fmtTokens(tokensIn)} · 输出 ${fmtTokens(tokensOut)}`, meta: [`缓存命中 ${hitRate}%`, `命中 ${fmtTokens(cacheHit)}`], progress: hitRate,
|
||||
}),
|
||||
].join("");
|
||||
}
|
||||
|
||||
function renderRuntime(r) {
|
||||
const active = r.active_runs || 0;
|
||||
const max = r.max_workers || 0;
|
||||
const ratio = max ? active / max : 0;
|
||||
const pct = Math.round(ratio * 100);
|
||||
const available = Math.max(0, max - active);
|
||||
const rss = r.rss_peak_mb != null ? Math.round(r.rss_peak_mb) + " MB" : "—";
|
||||
return `<div class="card"><h2>实时运行态</h2><div class="grid">`
|
||||
+ statCard(
|
||||
"活跃 run",
|
||||
`${active}${max ? ` <span class="unit">/ ${max}</span>` : ""}`,
|
||||
max ? "并发工作槽位" : "当前运行任务",
|
||||
levelClass(ratio),
|
||||
max ? [`占用 ${pct}%`, `空闲 ${available}`] : [],
|
||||
max ? pct : null,
|
||||
)
|
||||
+ statCard(
|
||||
"SSE 订阅",
|
||||
r.sse_subs || 0,
|
||||
"当前流式连接",
|
||||
"",
|
||||
[active ? `关联 ${active} 个活跃 run` : "暂无活跃 run", (r.sse_subs || 0) ? "通道已连接" : "暂无订阅"],
|
||||
)
|
||||
+ statCard(
|
||||
"内存峰值",
|
||||
rss,
|
||||
"进程 RSS high-water",
|
||||
"",
|
||||
["进程级统计", "重启后重置"],
|
||||
)
|
||||
+ `</div></div>`;
|
||||
}
|
||||
|
||||
function renderTasks(t) {
|
||||
const order = ["active", "completed", "abandoned"];
|
||||
const statusChips = Object.entries(t.by_status || {})
|
||||
.sort((a, b) => order.indexOf(a[0]) - order.indexOf(b[0]))
|
||||
.map(([k, n]) => `<span class="chip ${k === "completed" ? "ok" : ""}">${escapeHtml(k)} <b>${n}</b></span>`)
|
||||
.join("") || `<span class="empty">无</span>`;
|
||||
const runChips = Object.entries(t.by_run_status || {})
|
||||
.map(([k, n]) => {
|
||||
const c = k === "error" ? "err" : (k === "running" || k === "cancelling") ? "run" : "";
|
||||
return `<span class="chip ${c}">${escapeHtml(k)} <b>${n}</b></span>`;
|
||||
}).join("") || `<span class="empty">无</span>`;
|
||||
return `<div class="card"><h2>任务(共 ${t.total || 0})</h2>`
|
||||
+ `<div style="margin-bottom:7px;"><div class="sublabel">status</div><div class="chips">${statusChips}</div></div>`
|
||||
+ `<div><div class="sublabel">run_status</div><div class="chips">${runChips}</div></div>`
|
||||
+ `</div>`;
|
||||
}
|
||||
|
||||
function renderUsersAndUsage(users, usage) {
|
||||
const u = usage.total || {};
|
||||
const hitRate = u.tokens_in ? Math.round(u.tokens_cache_hit / u.tokens_in * 100) : 0;
|
||||
const activeRate = users.total ? Math.round((users.active_7d || 0) / users.total * 100) : 0;
|
||||
const idleUsers = Math.max(0, (users.total || 0) - (users.active_7d || 0));
|
||||
const avgCost = u.n_events ? (Number(u.cost_cny) || 0) / u.n_events : 0;
|
||||
const totalTokens = (u.tokens_in || 0) + (u.tokens_out || 0);
|
||||
const outputShare = totalTokens ? Math.round((u.tokens_out || 0) / totalTokens * 100) : 0;
|
||||
return `<div class="card"><h2>用户与用量总览(all-time)</h2><div class="grid">`
|
||||
+ statCard(
|
||||
"用户数", users.total || 0, `近 7 天活跃 ${users.active_7d || 0}`, "",
|
||||
[`活跃率 ${activeRate}%`, `未活跃 ${idleUsers}`], activeRate,
|
||||
)
|
||||
+ statCard(
|
||||
"总成本", fmtCNY(u.cost_cny), `${u.n_events || 0} 次事件`, "",
|
||||
[`均次 ${fmtCNY(avgCost)}`, "累计口径"],
|
||||
)
|
||||
+ statCard(
|
||||
"输入 token", fmtTokens(u.tokens_in), `缓存命中 ${hitRate}%`, "",
|
||||
[`命中 ${fmtTokens(u.tokens_cache_hit)}`, `未命中 ${fmtTokens(Math.max(0, (u.tokens_in || 0) - (u.tokens_cache_hit || 0)))}`], hitRate,
|
||||
)
|
||||
+ statCard(
|
||||
"输出 token", fmtTokens(u.tokens_out), `总 token ${fmtTokens(totalTokens)}`, "",
|
||||
[`输出占比 ${outputShare}%`, `输入占比 ${100 - outputShare}%`], outputShare,
|
||||
)
|
||||
+ `</div></div>`;
|
||||
}
|
||||
|
||||
function renderByDay(rows) {
|
||||
rows = rows || [];
|
||||
const maxCost = Math.max(0, ...rows.map(r => r.cost_cny || 0));
|
||||
|
|
@ -1080,11 +1012,11 @@ function wirePager(prefix, page, maxPage, go) {
|
|||
function ensureSkeleton() {
|
||||
if ($("layout")) return;
|
||||
$("main").innerHTML = `<div id="layout">`
|
||||
+ `<nav id="toc" aria-label="管理后台导航"><div class="toc-label">管理模块</div>` + SECTIONS.map(([id, label], index) =>
|
||||
+ `<nav id="toc" aria-label="管理后台导航"><div class="toc-label">页面导航</div>` + SECTIONS.map(([id, label], index) =>
|
||||
`<a href="#${id}" data-target="${id}"><span class="nav-index">${String(index + 1).padStart(2, "0")}</span><span>${label}</span></a>`).join("") + `</nav>`
|
||||
+ `<div id="content"><section class="page-intro"><h1>系统运行概览</h1>`
|
||||
+ `<div id="content"><section id="s-overview" class="page-intro anchor"><h1>系统运行概览</h1>`
|
||||
+ `<p>集中查看服务状态、资源用量与外部能力,数据默认每 10 秒自动更新。</p></section>`
|
||||
+ `<section id="ops-summary" class="ops-summary" aria-label="运营摘要"></section>` + SECTIONS.map(([id]) =>
|
||||
+ `<section id="ops-summary" class="ops-summary" aria-label="系统运营摘要"></section>` + SECTIONS.slice(1).map(([id]) =>
|
||||
`<div id="${id}" class="anchor"></div>`).join("") + `</div>`
|
||||
+ `</div>`;
|
||||
renderOpsSummary();
|
||||
|
|
@ -1115,11 +1047,7 @@ function setupScrollSpy() {
|
|||
function renderMetrics(d) {
|
||||
overviewData = d;
|
||||
$("gen-at").textContent = d.generated_at ? "更新于 " + fmtTime(d.generated_at) : "";
|
||||
$("s-runtime").innerHTML = renderRuntime(d.runtime || {});
|
||||
$("s-tasks").innerHTML = renderTasks(d.tasks || {});
|
||||
$("s-usage").innerHTML =
|
||||
renderUsersAndUsage(d.users || {}, d.usage || {})
|
||||
+ renderByDay((d.usage || {}).by_day_7d);
|
||||
$("s-usage").innerHTML = renderByDay((d.usage || {}).by_day_7d);
|
||||
renderWindowsNodes();
|
||||
renderOpsSummary();
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue