diff --git a/tests/test_admin_storage.py b/tests/test_admin_storage.py index 9df51cf..684eb4e 100644 --- a/tests/test_admin_storage.py +++ b/tests/test_admin_storage.py @@ -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__": diff --git a/tests/test_static_vendor.py b/tests/test_static_vendor.py index b0085af..afe7899 100644 --- a/tests/test_static_vendor.py +++ b/tests/test_static_vendor.py @@ -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) diff --git a/web/admin.py b/web/admin.py index a33689e..3dca8d3 100644 --- a/web/admin.py +++ b/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, } diff --git a/web/static/admin.html b/web/static/admin.html index c86f91a..de393fd 100644 --- a/web/static/admin.html +++ b/web/static/admin.html @@ -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) { diff --git a/web/static/js/admin.js b/web/static/js/admin.js index 242c94f..c8acdd2 100644 --- a/web/static/js/admin.js +++ b/web/static/js/admin.js @@ -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 - ? `