From 8ca1728ec544cd1f8939d7e0c4e40622dace3592 Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 1 Sep 2026 16:37:31 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AF=BC=E5=87=BA=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E5=90=8D=20+=20=E7=94=A8=E6=88=B7LD=E6=9D=BF?= =?UTF-8?q?=E6=95=B0=20(=E5=BF=AB=E7=85=A7=E7=BA=A7=E4=B8=A4=E5=88=97,=20?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=20group=3Dusers=20=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E7=BA=A7=E5=AF=BC=E5=87=BA)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 快照级 CSV/JSON 追加 用户名/用户LD板数 列 (DomainDetail 逐域聚合, 板键=rack+cluster+ld) - 新增 group=users: 每快照x每用户一行, 含板号/Rack/域数/设计/T-Pod/运行时间 - 导出弹窗加导出粒度选项; README 同步 --- README.md | 21 ++++++-- app.py | 116 ++++++++++++++++++++++++++++++++++++++--- templates/history.html | 12 ++++- 3 files changed, 136 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 7bc2c0b..d48d14b 100644 --- a/README.md +++ b/README.md @@ -113,11 +113,13 @@ curl -X POST http://<服务器>:5100/api/collect \ ### 导出历史数据 -`/api/export` 端点支持 CSV (Excel 友好, 带 UTF-8 BOM) 和 JSON 两种格式,可按时间范围和来源过滤。 +`/api/export` 端点支持 CSV (Excel 友好, 带 UTF-8 BOM) 和 JSON 两种格式,可按时间范围和来源过滤,并支持两种导出粒度。 ``` -GET /api/export?format=csv # 默认: CSV 全部 -GET /api/export?format=json # JSON 全部 +GET /api/export?format=csv # 默认: 快照级 CSV 全部 +GET /api/export?format=json # 快照级 JSON 全部 +GET /api/export?group=users # 用户级: 每条快照 × 每个用户一行 +GET /api/export?group=users&format=json # 用户级 JSON GET /api/export?days=7 # 最近 7 天 GET /api/export?since=2026-07-01&until=2026-07-31 # 自定义范围 GET /api/export?source=collect.py # 按来源模糊匹配 @@ -126,14 +128,23 @@ GET /api/export?format=json&days=30&source=scmp03 # 组合 参数说明: - `format`: `csv` (默认) 或 `json` +- `group`: `snapshots` (默认) 或 `users` — 见下方两种粒度 - `days`: 限制最近 N 天 (0/缺省 = 全部) - `since` / `until`: `YYYY-MM-DD` 或 ISO 时间戳 - `source`: 模糊匹配 `source` 字段 (如 `collect.py`、`scmp03`) - `limit`: 最多返回条数 (默认 10000, 上限 100000) -CSV 文件名: `palladium_snapshots_YYYYMMDD_HHMMSS.csv`,包含 19 列(时间本地+UTC、仿真器/硬件/ConfigMgr、系统状态、来源、板/域计数、利用率、作业数等)。 +**粒度一: 按快照 (`group=snapshots`, 默认)** — 每条快照一行,CSV 文件名 `palladium_snapshots_YYYYMMDD_HHMMSS.csv`,21 列(时间本地+UTC、仿真器/硬件/ConfigMgr、系统状态、来源、板/域计数、利用率、作业数等),末尾追加: +- `用户名`: 该快照下占用 LD 板的所有用户 (逗号分隔,如 `pzuser102, pzuser103`) +- `用户LD板数`: 与用户名顺序对应的各用户占用 LD 板数 (如 `6, 5`) -Web 界面入口:历史记录页右上角「📤 导出」按钮。 +JSON 格式每行额外附 `users` 数组 (含每用户板号/域数/设计名/运行时间明细)。 + +**粒度二: 按用户 (`group=users`)** — 每条快照 × 每个占用 LD 板的用户一行,CSV 文件名 `palladium_users_YYYYMMDD_HHMMSS.csv`,12 列: 快照 ID、时间(本地/UTC)、数据来源、**用户名**、**用户LD板数**、LD 板号、Rack、占用域数、设计名、T-Pod、运行时间。 + +> 用户名/LD 板数来自详细版 `test_server` 输出的逐域归属数据 (DomainDetail);短格式 (`test_server -short`) 快照无逐域归属,快照级导出用户列显示 `-`,用户级导出中不出现该快照。 + +Web 界面入口:历史记录页右上角「📤 导出」按钮,弹窗内可选导出粒度。 ## 解析器支持的输出格式 diff --git a/app.py b/app.py index 0738d35..acc38a1 100644 --- a/app.py +++ b/app.py @@ -1116,8 +1116,80 @@ EXPORT_FIELDS = [ ("disabled_domains", "已禁用域"), ("not_exist_domains", "不存在域"), ("active_jobs", "活跃作业"), + ("user_names", "用户名"), + ("user_ld_counts", "用户LD板数"), ] +# 用户级导出 (group=users): 每条快照 × 每个占用 LD 板的用户一行 +EXPORT_USER_FIELDS = [ + ("id", "快照 ID"), + ("timestamp_local", "时间(本地)"), + ("timestamp_utc", "时间(UTC)"), + ("source", "数据来源"), + ("owner", "用户名"), + ("ld_count", "用户LD板数"), + ("boards", "LD板号"), + ("racks", "Rack"), + ("domain_count", "占用域数"), + ("designs", "设计名"), + ("pods", "T-Pod"), + ("elap_times", "运行时间"), +] + + +def _collect_user_board_stats(snapshot_ids): + """ + 按快照 × 用户聚合 LD 板使用情况 (基于 DomainDetail 逐域归属)。 + + 返回 {snapshot_id: [user_stat, ...]}, user_stat 含: + owner / ld_count / boards / racks / domain_count / designs / pods / elap_times + 列表按用户名排序; 短格式 (无逐域归属) 快照不会出现在结果里。 + LD 板唯一键 = (rack, cluster, ld_index) 三元组。 + """ + result = {} + if not snapshot_ids: + return result + q = ( + DomainDetail.query + .filter(DomainDetail.snapshot_id.in_(snapshot_ids)) + .filter(~DomainDetail.owner.in_(("NONE", "", "—"))) + ) + for d in q.yield_per(2000): + users = result.setdefault(d.snapshot_id, {}) + u = users.setdefault(d.owner, { + "boards": set(), "racks": set(), "domain_count": 0, + "designs": set(), "pods": set(), "elap_times": set(), + }) + u["boards"].add((d.rack, d.cluster, d.ld_index)) + if d.rack is not None: + u["racks"].add(d.rack) + u["domain_count"] += 1 + if d.design and d.design not in ("--", "NONE"): + u["designs"].add(d.design) + # 过滤 "--" / "-- --" 等纯占位 T-Pod + if d.t_pod and d.t_pod.strip("- ").strip(): + u["pods"].add(d.t_pod) + if d.elap_time and d.elap_time not in ("--",): + u["elap_times"].add(d.elap_time) + + out = {} + for sid, users in result.items(): + items = [] + for owner in sorted(users): + u = users[owner] + items.append({ + "owner": owner, + "ld_count": len(u["boards"]), + "boards": ", ".join(f"LD{ld}" for _, _, ld in sorted(u["boards"])), + "racks": ", ".join(str(r) for r in sorted(u["racks"])), + "domain_count": u["domain_count"], + "designs": ", ".join(sorted(u["designs"])) or "-", + "pods": ", ".join(sorted(u["pods"])) or "-", + "elap_times": ", ".join(sorted(u["elap_times"])) or "-", + }) + out[sid] = items + return out + def _build_export_query(): """ @@ -1205,46 +1277,76 @@ def api_export(): Query: - format: csv | json (默认 csv) + - group: snapshots (默认, 每条快照一行, 追加用户名+各用户 LD 板数两列) + users (每条快照 × 每个占用 LD 板的用户一行: 用户名/LD板数/板号等) - days: 限制最近 N 天 - since: 起始时间 (YYYY-MM-DD 或 ISO) - until: 截止时间 - source: 模糊匹配 source 字段 - limit: 最多 N 条 (默认 10000, 上限 100000) - CSV 文件名: palladium_snapshots_YYYYMMDD_HHMMSS.csv - JSON 文件名: palladium_snapshots_YYYYMMDD_HHMMSS.json + CSV 文件名: + 快照级: palladium_snapshots_YYYYMMDD_HHMMSS.csv + 用户级: palladium_users_YYYYMMDD_HHMMSS.csv """ + group = request.args.get("group", "snapshots").strip().lower() + if group not in ("snapshots", "users"): + group = "snapshots" + q = _build_export_query() - rows = [_snapshot_to_row(s) for s in q.all()] + snaps = q.all() + + # 按快照 × 用户聚合 LD 板使用 (详细版输出才有 DomainDetail; 短格式快照无用户数据) + user_stats = _collect_user_board_stats([s.id for s in snaps]) + + rows = [] + for s in snaps: + users = user_stats.get(s.id, []) + row = _snapshot_to_row(s) + row["user_names"] = ", ".join(u["owner"] for u in users) if users else "-" + row["user_ld_counts"] = ", ".join(str(u["ld_count"]) for u in users) if users else "-" + if group == "users": + # 用户级: 每个用户一行, 带快照公共字段 + 该用户的 LD 板明细 + for u in users: + r = dict(row) + r.update(u) + rows.append(r) + else: + # 快照级: JSON 附 users 明细数组 (CSV 只写 user_names / user_ld_counts 两列) + row["users"] = users + rows.append(row) fmt = request.args.get("format", "csv").lower() ts_str = datetime.now().strftime("%Y%m%d_%H%M%S") + base_name = "palladium_users" if group == "users" else "palladium_snapshots" if fmt == "json": body = { "exported_at": datetime.now(timezone.utc).isoformat(), + "group": group, "count": len(rows), "rows": rows, } resp = jsonify(body) resp.headers["Content-Disposition"] = ( - f'attachment; filename="palladium_snapshots_{ts_str}.json"' + f'attachment; filename="{base_name}_{ts_str}.json"' ) return resp # ── CSV (默认) ── + fields = EXPORT_USER_FIELDS if group == "users" else EXPORT_FIELDS buf = io.StringIO() # 写 BOM 让 Excel 直接识别 UTF-8 (否则中文乱码) buf.write("\ufeff") writer = csv.writer(buf) - writer.writerow([label for _, label in EXPORT_FIELDS]) + writer.writerow([label for _, label in fields]) for r in rows: - writer.writerow([r.get(key, "") for key, _ in EXPORT_FIELDS]) + writer.writerow([r.get(key, "") for key, _ in fields]) csv_data = buf.getvalue() resp = Response(csv_data, mimetype="text/csv") resp.headers["Content-Disposition"] = ( - f'attachment; filename="palladium_snapshots_{ts_str}.csv"' + f'attachment; filename="{base_name}_{ts_str}.csv"' ) return resp diff --git a/templates/history.html b/templates/history.html index 95d14bf..53f87bf 100644 --- a/templates/history.html +++ b/templates/history.html @@ -110,6 +110,7 @@ function closeExportModal() { } function doExport() { const fmt = document.getElementById('expFormat').value; + const group = document.getElementById('expGroup').value; const range = document.getElementById('expRange').value; const since = document.getElementById('expSince').value; const until = document.getElementById('expUntil').value; @@ -117,6 +118,7 @@ function doExport() { const params = new URLSearchParams(); params.set('format', fmt); + params.set('group', group); if (range !== 'all') params.set('days', range); if (range === 'custom') { if (since) params.set('since', since); @@ -144,6 +146,13 @@ function toggleRangeFields() {