feat: 导出增加用户名 + 用户LD板数 (快照级两列, 新增 group=users 用户级导出)

- 快照级 CSV/JSON 追加 用户名/用户LD板数 列 (DomainDetail 逐域聚合, 板键=rack+cluster+ld)
- 新增 group=users: 每快照x每用户一行, 含板号/Rack/域数/设计/T-Pod/运行时间
- 导出弹窗加导出粒度选项; README 同步
This commit is contained in:
Your Name
2026-09-01 16:37:31 +08:00
parent 5c42d802c0
commit 8ca1728ec5
3 changed files with 136 additions and 13 deletions
+16 -5
View File
@@ -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=csv # 默认: 快照级 CSV 全部
GET /api/export?format=json # JSON 全部 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?days=7 # 最近 7 天
GET /api/export?since=2026-07-01&until=2026-07-31 # 自定义范围 GET /api/export?since=2026-07-01&until=2026-07-31 # 自定义范围
GET /api/export?source=collect.py # 按来源模糊匹配 GET /api/export?source=collect.py # 按来源模糊匹配
@@ -126,14 +128,23 @@ GET /api/export?format=json&days=30&source=scmp03 # 组合
参数说明: 参数说明:
- `format`: `csv` (默认) 或 `json` - `format`: `csv` (默认) 或 `json`
- `group`: `snapshots` (默认) 或 `users` — 见下方两种粒度
- `days`: 限制最近 N 天 (0/缺省 = 全部) - `days`: 限制最近 N 天 (0/缺省 = 全部)
- `since` / `until`: `YYYY-MM-DD` 或 ISO 时间戳 - `since` / `until`: `YYYY-MM-DD` 或 ISO 时间戳
- `source`: 模糊匹配 `source` 字段 (如 `collect.py``scmp03`) - `source`: 模糊匹配 `source` 字段 (如 `collect.py``scmp03`)
- `limit`: 最多返回条数 (默认 10000, 上限 100000) - `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 界面入口:历史记录页右上角「📤 导出」按钮,弹窗内可选导出粒度。
## 解析器支持的输出格式 ## 解析器支持的输出格式
+109 -7
View File
@@ -1116,8 +1116,80 @@ EXPORT_FIELDS = [
("disabled_domains", "已禁用域"), ("disabled_domains", "已禁用域"),
("not_exist_domains", "不存在域"), ("not_exist_domains", "不存在域"),
("active_jobs", "活跃作业"), ("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(): def _build_export_query():
""" """
@@ -1205,46 +1277,76 @@ def api_export():
Query: Query:
- format: csv | json (默认 csv) - format: csv | json (默认 csv)
- group: snapshots (默认, 每条快照一行, 追加用户名+各用户 LD 板数两列)
users (每条快照 × 每个占用 LD 板的用户一行: 用户名/LD板数/板号等)
- days: 限制最近 N 天 - days: 限制最近 N 天
- since: 起始时间 (YYYY-MM-DD 或 ISO) - since: 起始时间 (YYYY-MM-DD 或 ISO)
- until: 截止时间 - until: 截止时间
- source: 模糊匹配 source 字段 - source: 模糊匹配 source 字段
- limit: 最多 N 条 (默认 10000, 上限 100000) - limit: 最多 N 条 (默认 10000, 上限 100000)
CSV 文件名: palladium_snapshots_YYYYMMDD_HHMMSS.csv CSV 文件名:
JSON 文件名: palladium_snapshots_YYYYMMDD_HHMMSS.json 快照级: 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() 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() fmt = request.args.get("format", "csv").lower()
ts_str = datetime.now().strftime("%Y%m%d_%H%M%S") ts_str = datetime.now().strftime("%Y%m%d_%H%M%S")
base_name = "palladium_users" if group == "users" else "palladium_snapshots"
if fmt == "json": if fmt == "json":
body = { body = {
"exported_at": datetime.now(timezone.utc).isoformat(), "exported_at": datetime.now(timezone.utc).isoformat(),
"group": group,
"count": len(rows), "count": len(rows),
"rows": rows, "rows": rows,
} }
resp = jsonify(body) resp = jsonify(body)
resp.headers["Content-Disposition"] = ( resp.headers["Content-Disposition"] = (
f'attachment; filename="palladium_snapshots_{ts_str}.json"' f'attachment; filename="{base_name}_{ts_str}.json"'
) )
return resp return resp
# ── CSV (默认) ── # ── CSV (默认) ──
fields = EXPORT_USER_FIELDS if group == "users" else EXPORT_FIELDS
buf = io.StringIO() buf = io.StringIO()
# 写 BOM 让 Excel 直接识别 UTF-8 (否则中文乱码) # 写 BOM 让 Excel 直接识别 UTF-8 (否则中文乱码)
buf.write("\ufeff") buf.write("\ufeff")
writer = csv.writer(buf) writer = csv.writer(buf)
writer.writerow([label for _, label in EXPORT_FIELDS]) writer.writerow([label for _, label in fields])
for r in rows: 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() csv_data = buf.getvalue()
resp = Response(csv_data, mimetype="text/csv") resp = Response(csv_data, mimetype="text/csv")
resp.headers["Content-Disposition"] = ( resp.headers["Content-Disposition"] = (
f'attachment; filename="palladium_snapshots_{ts_str}.csv"' f'attachment; filename="{base_name}_{ts_str}.csv"'
) )
return resp return resp
+11 -1
View File
@@ -110,6 +110,7 @@ function closeExportModal() {
} }
function doExport() { function doExport() {
const fmt = document.getElementById('expFormat').value; const fmt = document.getElementById('expFormat').value;
const group = document.getElementById('expGroup').value;
const range = document.getElementById('expRange').value; const range = document.getElementById('expRange').value;
const since = document.getElementById('expSince').value; const since = document.getElementById('expSince').value;
const until = document.getElementById('expUntil').value; const until = document.getElementById('expUntil').value;
@@ -117,6 +118,7 @@ function doExport() {
const params = new URLSearchParams(); const params = new URLSearchParams();
params.set('format', fmt); params.set('format', fmt);
params.set('group', group);
if (range !== 'all') params.set('days', range); if (range !== 'all') params.set('days', range);
if (range === 'custom') { if (range === 'custom') {
if (since) params.set('since', since); if (since) params.set('since', since);
@@ -144,6 +146,13 @@ function toggleRangeFields() {
<button class="modal-close" onclick="closeExportModal()"></button> <button class="modal-close" onclick="closeExportModal()"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div class="form-group">
<label>导出粒度</label>
<select id="expGroup" class="form-control">
<option value="snapshots" selected>按快照 — 每条快照一行 (含用户名 + 各用户 LD 板数)</option>
<option value="users">按用户 — 每条快照 × 每个用户一行 (用户名 / LD 板数 / 板号)</option>
</select>
</div>
<div class="form-group"> <div class="form-group">
<label>格式</label> <label>格式</label>
<select id="expFormat" class="form-control"> <select id="expFormat" class="form-control">
@@ -179,7 +188,8 @@ function toggleRangeFields() {
</div> </div>
<p class="modal-hint"> <p class="modal-hint">
导出上限 10 万条; 超过会自动截断。<br> 导出上限 10 万条; 超过会自动截断。<br>
CSV 默认包含 19 列: 时间(本地+UTC)、仿真器/硬件/ConfigMgr、系统状态、来源、板/域计数、利用率(1 位小数)、作业数等。 按快照: 21 列 — 时间(本地+UTC)、仿真器/硬件/ConfigMgr、系统状态、来源、板/域计数、利用率、作业数、用户名、各用户 LD 板数 (多用户逗号分隔, 顺序对应)。<br>
按用户: 12 列 — 快照时间/来源 + 用户名、该用户 LD 板数、板号、Rack、占用域数、设计名、T-Pod、运行时间。用户名/LD 板数来自详细版 test_server 逐域归属数据。
</p> </p>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">