sync: import complete app.py/mailer.py/collect.py + dev customizations

完整功能 (相对于 ac9a060):
- app.py: 1276 行 (vs 633 行) — 含 /api/email/* + /api/export + Chart 小数
- mailer.py: 645 行 — SMTP 邮件模块 + scheduler
- collect.py: 233 行 — Python 3.6 兼容 (typing.Dict 代替 dict[X,Y])
- README.md: 整合版
- templates/email_config.html: 邮件配置 Web UI
- static/style.css: 邮件状态徽章等新样式

清理:
- 删除 'palladium-monitor/' 子目录 (远端残留, 已废弃)
This commit is contained in:
Hermes
2026-07-08 15:23:24 +08:00
parent ac9a060b46
commit 83621cb213
12 changed files with 23 additions and 1371 deletions
-33
View File
@@ -1,33 +0,0 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
*.egg-info/
.pytest_cache/
.mypy_cache/
# Virtual env
venv/
env/
.venv/
# IDE
.vscode/
.idea/
*.swp
.DS_Store
# 项目数据 (含真实快照, 不上传)
instance/*.db
instance/*.sqlite
instance/*.sqlite3
# 日志
*.log
logs/
# 测试 fixture
test_fixtures/
/tmp/
-64
View File
@@ -1,64 +0,0 @@
# Palladium Monitor
LD 逻辑板使用监控系统 — 解析 `test_server -short` 输出,可视化展示 Palladium 仿真器的逻辑板状态、逻辑板利用率、作业信息和 T-Pod 可用性。
## 功能
- **逻辑板状态可视化** — Rack / Cluster / Board 三级结构,每块 LD 板的 8 个逻辑板 (D0-D7) 以色块展示
- 🟢 可用 (`-`)
- 🔵 已加载/预留 (`<num>`)
- 🔴 已禁用 (`D`)
- ⬜ 不存在 (`X`)
- **实时指标** — 逻辑板总数、在线/离线、逻辑板利用率、活跃作业数
- **作业信息** — 用户、PID、T-Pod、设计名、已运行时间
- **T-Pod 可用性** — 按 Rack 展示 HDSB / T-Pod 的可用/锁定/预留/不可用状态
- **历史趋势** — 利用率折线图 (Chart.js),支持历史快照查看和清理
- **自动刷新** — 15s/30s/1m/5m 可选间隔
- **多数据来源** — 手动粘贴、API 推送、定时采集脚本
## 部署
### 监控服务器
```bash
pip install -r requirements.txt --break-system-packages
python3 app.py
```
访问 `http://<服务器IP>:5100`
### 数据采集 (Palladium 主机)
在 Palladium 主机部署 `collect.sh`
```bash
chmod +x collect.sh
# 测试
./collect.sh --dry-run
# crontab 每30分钟采集
*/30 * * * * /path/to/collect.sh >> /var/log/palladium-collect.log 2>&1
```
## 配置
collect.sh 可通过环境变量覆盖默认配置:
| 变量 | 默认值 | 说明 |
|------|--------|------|
| `TEST_SERVER_CMD` | `/edatools/vxe/VXE23.03.001/bin/test_server -short` | 采集命令 |
| `MONITOR_URL` | `http://10.168.1.209:5100/api/collect` | 监控服务器 API |
| `TIMEOUT_SEC` | `300` | 命令超时秒数 |
## API 端点
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/` | 仪表板页面 |
| GET | `/history` | 历史快照列表 |
| GET | `/snapshot/<id>` | 快照详情 |
| POST | `/api/collect` | 接收原始输出 (JSON) |
| GET | `/api/latest` | 最新快照 JSON |
| GET | `/api/history` | 历史趋势 JSON |
| POST | `/api/cleanup?days=N` | 清理 N 天前数据 |
-633
View File
@@ -1,633 +0,0 @@
#!/usr/bin/env python3
"""
Palladium Monitor — LD逻辑板使用监控系统
解析 `test_server -short` 输出,展示 Palladium 仿真器的
逻辑板 (LD) 状态、逻辑板利用率、作业信息和 T-Pod 可用性。
"""
import os
import re
import json
import subprocess
from datetime import datetime, timezone, timedelta
from flask import Flask, render_template, request, jsonify, redirect, url_for
from flask_sqlalchemy import SQLAlchemy
# ═══ Config ═══
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
INSTANCE_DIR = os.path.join(BASE_DIR, "instance")
os.makedirs(INSTANCE_DIR, exist_ok=True)
app = Flask(__name__)
app.config["SECRET_KEY"] = os.environ.get("PALLADIUM_SECRET", "palladium-z1-monitor-2026")
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///" + os.path.join(INSTANCE_DIR, "palladium.db")
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
db = SQLAlchemy(app)
# ═══ Domain State Constants ═══
DOMAIN_AVAILABLE = "available"
DOMAIN_DOWNLOADED = "downloaded"
DOMAIN_DISABLED = "disabled"
DOMAIN_NOT_EXIST = "not_exist"
DOMAIN_COLORS = {
DOMAIN_AVAILABLE: "#28a745",
DOMAIN_DOWNLOADED: "#007bff",
DOMAIN_DISABLED: "#dc3545",
DOMAIN_NOT_EXIST: "#4a4a5a",
}
DOMAIN_LABELS = {
DOMAIN_AVAILABLE: "可用",
DOMAIN_DOWNLOADED: "已加载",
DOMAIN_DISABLED: "已禁用",
DOMAIN_NOT_EXIST: "不存在",
}
# ═══ Models ═══
class ScanSnapshot(db.Model):
__tablename__ = "snapshots"
id = db.Column(db.Integer, primary_key=True)
timestamp = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), index=True)
emulator = db.Column(db.String(100), default="")
hardware = db.Column(db.String(100), default="")
configmgr = db.Column(db.String(200), default="")
system_status = db.Column(db.String(20), default="")
source = db.Column(db.String(50), default="manual")
raw_output = db.Column(db.Text)
total_boards = db.Column(db.Integer, default=0)
online_boards = db.Column(db.Integer, default=0)
offline_boards = db.Column(db.Integer, default=0)
used_boards = db.Column(db.Integer, default=0)
total_domains = db.Column(db.Integer, default=0)
available_domains = db.Column(db.Integer, default=0)
downloaded_domains = db.Column(db.Integer, default=0)
disabled_domains = db.Column(db.Integer, default=0)
not_exist_domains = db.Column(db.Integer, default=0)
active_jobs = db.Column(db.Integer, default=0)
utilization = db.Column(db.Float, default=0.0)
boards = db.relationship("BoardStatus", backref="snapshot", cascade="all, delete-orphan", lazy=True)
jobs = db.relationship("JobInfo", backref="snapshot", cascade="all, delete-orphan", lazy=True)
pods = db.relationship("PodInfo", backref="snapshot", cascade="all, delete-orphan", lazy=True)
class BoardStatus(db.Model):
__tablename__ = "board_statuses"
id = db.Column(db.Integer, primary_key=True)
snapshot_id = db.Column(db.Integer, db.ForeignKey("snapshots.id"), nullable=False, index=True)
rack = db.Column(db.Integer)
cluster = db.Column(db.Integer)
ld_index = db.Column(db.Integer)
status = db.Column(db.String(20))
ccd = db.Column(db.String(20))
d0 = db.Column(db.String(20), default="-")
d1 = db.Column(db.String(20), default="-")
d2 = db.Column(db.String(20), default="-")
d3 = db.Column(db.String(20), default="-")
d4 = db.Column(db.String(20), default="-")
d5 = db.Column(db.String(20), default="-")
d6 = db.Column(db.String(20), default="-")
d7 = db.Column(db.String(20), default="-")
class JobInfo(db.Model):
__tablename__ = "job_infos"
id = db.Column(db.Integer, primary_key=True)
snapshot_id = db.Column(db.Integer, db.ForeignKey("snapshots.id"), nullable=False, index=True)
job_index = db.Column(db.Integer)
owner = db.Column(db.String(100), default="")
pid = db.Column(db.String(200), default="")
t_pod = db.Column(db.String(200), default="")
design = db.Column(db.String(200), default="")
elap_time = db.Column(db.String(20), default="")
reserved_key = db.Column(db.String(100), default="")
class PodInfo(db.Model):
__tablename__ = "pod_infos"
id = db.Column(db.Integer, primary_key=True)
snapshot_id = db.Column(db.Integer, db.ForeignKey("snapshots.id"), nullable=False, index=True)
rack = db.Column(db.Integer)
all_hdsb = db.Column(db.Text, default="")
all_t_pods = db.Column(db.Text, default="")
available_hdsb = db.Column(db.Text, default="")
available_t_pods = db.Column(db.Text, default="")
locked_hdsb = db.Column(db.Text, default="")
locked_t_pods = db.Column(db.Text, default="")
reserved_hdsb = db.Column(db.Text, default="")
reserved_t_pods = db.Column(db.Text, default="")
unavailable_hdsb = db.Column(db.Text, default="")
unavailable_t_pods = db.Column(db.Text, default="")
# ═══ Parser ═══
def parse_test_server_output(raw_text):
result = {"system": {}, "racks": [], "jobs": [], "pods": []}
# 系统信息 (Configmgr 值可能与 System Status 拼接)
emu_match = re.search(r"Emulator:\s*(\S+)", raw_text)
hw_match = re.search(r"Hardware:\s*(.+?)\s+Configmgr:", raw_text)
cfg_match = re.search(r"Configmgr:\s*(.+?)(?:System\s*Status|$)", raw_text)
status_match = re.search(r"System\s*Status:\s*(\w+)", raw_text)
result["system"] = {
"emulator": emu_match.group(1).strip() if emu_match else "",
"hardware": hw_match.group(1).strip() if hw_match else "",
"configmgr": cfg_match.group(1).strip() if cfg_match else "",
"system_status": status_match.group(1).strip() if status_match else "",
}
lines = raw_text.split("\n")
current_rack = None
board_re = re.compile(
r"^\s*(\d+)\s+(ONLINE|OFFLINE)\s+"
r"(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+"
r"(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s*$"
)
for line in lines:
rack_match = re.match(r"Rack\s+(\d+)\s+has\s+(\d+)\s+clusters", line)
if rack_match:
current_rack = int(rack_match.group(1))
result["racks"].append({"rack": current_rack, "clusters": []})
continue
cluster_match = re.match(r"Cluster\s+(\d+)\s+has\s+(\d+)\s+boards\s+CCD:\s*(\w+)", line)
if cluster_match and result["racks"]:
result["racks"][-1]["clusters"].append({
"cluster": int(cluster_match.group(1)),
"ccd": cluster_match.group(3),
"boards": [],
})
continue
board_match = board_re.match(line)
if board_match and result["racks"] and result["racks"][-1]["clusters"]:
result["racks"][-1]["clusters"][-1]["boards"].append({
"ld": int(board_match.group(1)),
"status": board_match.group(2),
"domains": [board_match.group(i) for i in range(3, 11)],
})
continue
# 作业信息
job_section = re.search(r"Job Information:.*?(?=Target Pod Information:|Exit code:|$)", raw_text, re.DOTALL)
if job_section:
for jline in job_section.group(0).split("\n"):
jline = jline.strip()
if not jline or jline.startswith("Job Information") or jline.startswith("Index"):
continue
parts = jline.split()
if len(parts) >= 7:
result["jobs"].append({
"index": int(parts[0]),
"owner": parts[1],
"pid": parts[2],
"t_pod": " ".join(parts[3:-3]),
"design": parts[-3],
"elap_time": parts[-2],
"reserved_key": parts[-1],
})
# T-Pod 信息
pod_section = re.search(r"Target Pod Information:.*?(?=Exit code:|$)", raw_text, re.DOTALL)
if pod_section:
for m in re.finditer(r"Rack\s+(\d+):(.*?)(?=Rack\s+\d+:|Exit code:|$)", pod_section.group(0), re.DOTALL):
rack_num = int(m.group(1))
rack_text = m.group(2)
all_hdsb = {}
all_t_pods = {}
fields = {
"available_hdsb": "", "available_t_pods": "",
"locked_hdsb": "", "locked_t_pods": "",
"reserved_hdsb": "", "reserved_t_pods": "",
"unavailable_hdsb": "", "unavailable_t_pods": "",
}
for rline in rack_text.split("\n"):
rline = rline.strip()
if not rline:
continue
all_h_match = re.match(r"All length (\d+) HDSB:\s*(.+)", rline)
if all_h_match:
all_hdsb[all_h_match.group(1)] = all_h_match.group(2).strip()
continue
all_t_match = re.match(r"All length (\d+) T-Pods:\s*(.+)", rline)
if all_t_match:
all_t_pods[all_t_match.group(1)] = all_t_match.group(2).strip()
continue
for field_key in fields:
label = field_key.replace("_", " ").title()
label = label.replace("Hdsb", "HDSB").replace("T Pods", "T-Pods")
if rline.startswith(label + ":"):
fields[field_key] = rline.split(":", 1)[1].strip()
break
result["pods"].append({
"rack": rack_num,
"all_hdsb": json.dumps(all_hdsb),
"all_t_pods": json.dumps(all_t_pods),
**fields,
})
return result
def classify_domain(raw_value):
v = raw_value.strip()
if v == "-":
return DOMAIN_AVAILABLE
if v == "X":
return DOMAIN_NOT_EXIST
if v == "D":
return DOMAIN_DISABLED
if v.isdigit():
return DOMAIN_DOWNLOADED
return DOMAIN_AVAILABLE
def calculate_stats(parsed):
total_boards = online = offline = used = 0
total_dom = avail = dl = dis = ne = 0
for rack in parsed["racks"]:
for cluster in rack["clusters"]:
for board in cluster["boards"]:
total_boards += 1
if board["status"] == "ONLINE":
online += 1
else:
offline += 1
board_used = False
for d in board["domains"]:
state = classify_domain(d)
total_dom += 1
if state == DOMAIN_AVAILABLE:
avail += 1
elif state == DOMAIN_DOWNLOADED:
dl += 1
board_used = True
elif state == DOMAIN_DISABLED:
dis += 1
elif state == DOMAIN_NOT_EXIST:
ne += 1
if board_used:
used += 1
util = round(used / total_boards * 100, 1) if total_boards > 0 else 0.0
return {
"total_boards": total_boards,
"online_boards": online,
"offline_boards": offline,
"used_boards": used,
"total_domains": total_dom,
"available_domains": avail,
"downloaded_domains": dl,
"disabled_domains": dis,
"not_exist_domains": ne,
"active_jobs": len(parsed["jobs"]),
"utilization": util,
}
# ═══ Template Filters ═══
@app.template_filter("domain_state")
def domain_state_filter(raw_value):
return classify_domain(raw_value)
@app.template_filter("domain_color")
def domain_color_filter(raw_value):
return DOMAIN_COLORS.get(classify_domain(raw_value), "#888")
@app.template_filter("domain_label")
def domain_label_filter(raw_value):
return DOMAIN_LABELS.get(classify_domain(raw_value), "未知")
@app.template_filter("fmt_time")
def fmt_time_filter(dt):
if not dt:
return ""
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone().strftime("%Y-%m-%d %H:%M:%S")
@app.template_filter("fmt_time_short")
def fmt_time_short_filter(dt):
if not dt:
return ""
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone().strftime("%H:%M:%S")
@app.template_filter("from_json")
def from_json_filter(s):
if not s:
return {}
try:
return json.loads(s)
except (ValueError, TypeError):
return {}
# ═══ Routes ═══
@app.route("/")
def dashboard():
snapshot = ScanSnapshot.query.order_by(ScanSnapshot.timestamp.desc()).first()
stats = None
racks_data = []
jobs_data = []
pods_data = []
history = []
if snapshot:
stats = _snapshot_stats(snapshot)
racks_data = _build_racks(snapshot.id)
jobs_data = JobInfo.query.filter_by(snapshot_id=snapshot.id).all()
pods_data = PodInfo.query.filter_by(snapshot_id=snapshot.id).order_by(PodInfo.rack).all()
history = ScanSnapshot.query.order_by(ScanSnapshot.timestamp.desc()).limit(50).all()
history = list(reversed(history))
return render_template("dashboard.html", snapshot=snapshot, stats=stats,
racks=racks_data, jobs=jobs_data, pods=pods_data, history=history)
@app.route("/history")
def history():
page = request.args.get("page", 1, type=int)
pagination = ScanSnapshot.query.order_by(ScanSnapshot.timestamp.desc()).paginate(
page=page, per_page=30, error_out=False)
return render_template("history.html", pagination=pagination)
@app.route("/snapshot/<int:sid>")
def snapshot_detail(sid):
snapshot = ScanSnapshot.query.get_or_404(sid)
stats = _snapshot_stats(snapshot)
racks_data = _build_racks(sid)
jobs_data = JobInfo.query.filter_by(snapshot_id=sid).all()
pods_data = PodInfo.query.filter_by(snapshot_id=sid).order_by(PodInfo.rack).all()
return render_template("snapshot.html", snapshot=snapshot, stats=stats,
racks=racks_data, jobs=jobs_data, pods=pods_data)
@app.route("/paste", methods=["POST"])
def paste_data():
raw_output = request.form.get("raw_output", "").strip()
if not raw_output:
return jsonify({"error": "无输出内容"}), 400
store_snapshot(raw_output, source="paste")
return redirect(url_for("dashboard"))
@app.route("/api/collect", methods=["POST"])
def api_collect():
data = request.get_json(silent=True)
if not data or "raw_output" not in data:
return jsonify({"error": "缺少 raw_output 字段"}), 400
sid = store_snapshot(data["raw_output"], source=data.get("source", "api"))
snap = db.session.get(ScanSnapshot, sid)
return jsonify({"ok": True, "snapshot_id": sid, "stats": _snapshot_stats(snap)})
@app.route("/api/latest")
def api_latest():
snapshot = ScanSnapshot.query.order_by(ScanSnapshot.timestamp.desc()).first()
if not snapshot:
return jsonify({"error": "无数据"}), 404
boards = BoardStatus.query.filter_by(snapshot_id=snapshot.id).all()
jobs = JobInfo.query.filter_by(snapshot_id=snapshot.id).all()
pods = PodInfo.query.filter_by(snapshot_id=snapshot.id).all()
return jsonify({
"snapshot": {"id": snapshot.id, "timestamp": snapshot.timestamp.isoformat() if snapshot.timestamp else None,
"emulator": snapshot.emulator, "hardware": snapshot.hardware,
"configmgr": snapshot.configmgr, "system_status": snapshot.system_status,
"source": snapshot.source},
"stats": _snapshot_stats(snapshot),
"boards": [{"rack": b.rack, "cluster": b.cluster, "ld": b.ld_index,
"status": b.status, "ccd": b.ccd,
"domains": [b.d0, b.d1, b.d2, b.d3, b.d4, b.d5, b.d6, b.d7]} for b in boards],
"jobs": [{"index": j.job_index, "owner": j.owner, "pid": j.pid,
"t_pod": j.t_pod, "design": j.design,
"elap_time": j.elap_time, "reserved_key": j.reserved_key} for j in jobs],
"pods": [{"rack": p.rack,
"all_hdsb": json.loads(p.all_hdsb) if p.all_hdsb else {},
"all_t_pods": json.loads(p.all_t_pods) if p.all_t_pods else {},
"available_hdsb": p.available_hdsb, "available_t_pods": p.available_t_pods,
"locked_hdsb": p.locked_hdsb, "locked_t_pods": p.locked_t_pods,
"reserved_hdsb": p.reserved_hdsb, "reserved_t_pods": p.reserved_t_pods,
"unavailable_hdsb": p.unavailable_hdsb, "unavailable_t_pods": p.unavailable_t_pods} for p in pods],
})
@app.route("/api/history")
def api_history():
snapshots = ScanSnapshot.query.order_by(ScanSnapshot.timestamp.desc()).limit(100).all()
snapshots = list(reversed(snapshots))
return jsonify({"data": [{
"timestamp": s.timestamp.isoformat() if s.timestamp else None,
"utilization": s.utilization, "online_boards": s.online_boards,
"offline_boards": s.offline_boards, "used_boards": s.used_boards,
"active_jobs": s.active_jobs,
} for s in snapshots]})
@app.route("/api/snapshot/<int:sid>/delete", methods=["POST"])
def delete_snapshot(sid):
snapshot = ScanSnapshot.query.get_or_404(sid)
db.session.delete(snapshot)
db.session.commit()
return redirect(url_for("history"))
@app.route("/api/cleanup", methods=["POST"])
def cleanup_old():
days = request.args.get("days", 30, type=int)
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
old = ScanSnapshot.query.filter(ScanSnapshot.timestamp < cutoff).all()
for s in old:
db.session.delete(s)
db.session.commit()
return jsonify({"ok": True, "deleted": len(old)})
# ═══ Helpers ═══
def _snapshot_stats(snapshot):
return {
"total_boards": snapshot.total_boards,
"online_boards": snapshot.online_boards,
"offline_boards": snapshot.offline_boards,
"used_boards": snapshot.used_boards,
"total_domains": snapshot.total_domains,
"available_domains": snapshot.available_domains,
"downloaded_domains": snapshot.downloaded_domains,
"disabled_domains": snapshot.disabled_domains,
"not_exist_domains": snapshot.not_exist_domains,
"active_jobs": snapshot.active_jobs,
"utilization": snapshot.utilization,
}
def _build_racks(snapshot_id):
boards = BoardStatus.query.filter_by(snapshot_id=snapshot_id).order_by(
BoardStatus.rack, BoardStatus.cluster, BoardStatus.ld_index).all()
rack_map = {}
for b in boards:
if b.rack not in rack_map:
rack_map[b.rack] = {}
if b.cluster not in rack_map[b.rack]:
rack_map[b.rack][b.cluster] = {"cluster": b.cluster, "ccd": b.ccd or "UNKNOWN", "boards": []}
rack_map[b.rack][b.cluster]["boards"].append({
"ld": b.ld_index, "status": b.status,
"domains": [b.d0, b.d1, b.d2, b.d3, b.d4, b.d5, b.d6, b.d7],
})
return [{"rack": rn, "clusters": [rack_map[rn][c] for c in sorted(rack_map[rn])]}
for rn in sorted(rack_map)]
def store_snapshot(raw_output, source="manual"):
parsed = parse_test_server_output(raw_output)
stats = calculate_stats(parsed)
sysinfo = parsed["system"]
snap = ScanSnapshot(
emulator=sysinfo.get("emulator", ""), hardware=sysinfo.get("hardware", ""),
configmgr=sysinfo.get("configmgr", ""), system_status=sysinfo.get("system_status", ""),
source=source, raw_output=raw_output,
total_boards=stats["total_boards"], online_boards=stats["online_boards"],
offline_boards=stats["offline_boards"], used_boards=stats["used_boards"],
total_domains=stats["total_domains"], available_domains=stats["available_domains"],
downloaded_domains=stats["downloaded_domains"], disabled_domains=stats["disabled_domains"],
not_exist_domains=stats["not_exist_domains"], active_jobs=stats["active_jobs"],
utilization=stats["utilization"],
)
db.session.add(snap)
db.session.flush()
for rack in parsed["racks"]:
for cluster in rack["clusters"]:
for board in cluster["boards"]:
d = board["domains"]
db.session.add(BoardStatus(
snapshot_id=snap.id, rack=rack["rack"], cluster=cluster["cluster"],
ld_index=board["ld"], status=board["status"], ccd=cluster["ccd"],
d0=d[0], d1=d[1], d2=d[2], d3=d[3], d4=d[4], d5=d[5], d6=d[6], d7=d[7],
))
for job in parsed["jobs"]:
db.session.add(JobInfo(
snapshot_id=snap.id, job_index=job["index"], owner=job["owner"],
pid=job["pid"], t_pod=job["t_pod"], design=job["design"],
elap_time=job["elap_time"], reserved_key=job["reserved_key"],
))
for pod in parsed["pods"]:
db.session.add(PodInfo(
snapshot_id=snap.id, rack=pod["rack"],
all_hdsb=pod.get("all_hdsb", ""), all_t_pods=pod.get("all_t_pods", ""),
available_hdsb=pod.get("available_hdsb", ""), available_t_pods=pod.get("available_t_pods", ""),
locked_hdsb=pod.get("locked_hdsb", ""), locked_t_pods=pod.get("locked_t_pods", ""),
reserved_hdsb=pod.get("reserved_hdsb", ""), reserved_t_pods=pod.get("reserved_t_pods", ""),
unavailable_hdsb=pod.get("unavailable_hdsb", ""), unavailable_t_pods=pod.get("unavailable_t_pods", ""),
))
db.session.commit()
return snap.id
# ═══ Seed ═══
SAMPLE_OUTPUT = r"""Emulator: sc01_emu Hardware: Palladium Z1 Configmgr: V21.02.102.s002System Status: PARTIAL
Rack 0 has 2 clusters
Cluster 0 has 6 boards CCD: ONLINE
LD Status D0 D1 D2 D3 D4 D5 D6 D7
0 ONLINE - - - - - - - -
1 ONLINE - - - - - - - -
2 ONLINE - - - - - - - -
3 ONLINE - - - - - - - -
4 ONLINE - - - - - - - -
5 ONLINE - - - - - - - -
Cluster 1 has 6 boards CCD: ONLINE
LD Status D0 D1 D2 D3 D4 D5 D6 D7
6 ONLINE 1 1 1 1 1 1 1 1
7 ONLINE 1 1 1 1 1 1 1 1
8 ONLINE - - - - - - - -
9 OFFLINE X X X X X X X X
10 ONLINE - - - - - - - -
11 ONLINE - - - - - - - -
Rack 1 has 2 clusters
Cluster 3 has 6 boards CCD: ONLINE
LD Status D0 D1 D2 D3 D4 D5 D6 D7
18 ONLINE - - - - - - - -
19 ONLINE - - - - - - - -
20 ONLINE - - - - - - - -
21 ONLINE - - - - - - - -
22 ONLINE - - - - - - - -
23 ONLINE - - - - - - - -
Cluster 4 has 6 boards CCD: ONLINE
LD Status D0 D1 D2 D3 D4 D5 D6 D7
24 ONLINE - - - - - - - -
25 ONLINE - - - - - - - -
26 ONLINE - - - - - - - -
27 ONLINE - - - - - - - -
28 ONLINE - - - - - - - -
29 ONLINE - - - - - - - -
X: Domain does not exist
D: Domain is disabled
-: Domain is available
<num>: Domain is downloaded or reserved
Job Information:
Index Owner PID T-Pod Design ElapTime ReservedKey
1 pzuser101 scmp03:106144 -- -- top 00:35:39 --
Target Pod Information:
Rack 0:
All length 5 HDSB: T4
All length 5 T-Pods: T0, T1, T2, T3, T5, T6, T7, T8, T9
Available HDSB: T4
Available T-Pods: T0, T1, T2, T3, T5
Locked HDSB: NONE
Locked T-Pods: NONE
Reserved HDSB: NONE
Reserved T-Pods: NONE
Unavailable HDSB: NONE
Unavailable T-Pods: T6, T7, T8, T9
Rack 1:
All length 5 HDSB: T4
All length 30 HDSB: T5
Available HDSB: T4, T5
Available T-Pods: NONE
Locked HDSB: NONE
Locked T-Pods: NONE
Reserved HDSB: NONE
Reserved T-Pods: NONE
Unavailable HDSB: NONE
Unavailable T-Pods: NONE
Exit code: 0
Deinitializing IB - 0 - 0
"""
def seed_sample_data():
if ScanSnapshot.query.first():
return
store_snapshot(SAMPLE_OUTPUT, source="sample")
print("[seed] 示例数据已写入")
if __name__ == "__main__":
with app.app_context():
db.create_all()
seed_sample_data()
print("════════════════════════════════════════════════════════")
print(" Palladium Monitor")
print(" http://0.0.0.0:5100")
print("════════════════════════════════════════════════════════")
app.run(host="0.0.0.0", port=5100, debug=True)
-109
View File
@@ -1,109 +0,0 @@
#!/bin/bash
# ═══════════════════════════════════════════════════════════════
# collect.sh — Palladium 数据采集脚本 (Shell 版, 无需 Python)
#
# 在 Palladium 主机上运行, 执行 test_server -short 并推送到监控服务器
#
# 用法:
# ./collect.sh # 使用默认配置
# MONITOR_URL=http://10.0.0.1:5100/api/collect ./collect.sh
# TEST_SERVER_CMD="/edatools/vxe/VXE23.03.001/bin/test_server -short" ./collect.sh
# ./collect.sh --dry-run # 只采集不推送
#
# Crontab (每30分钟):
# */30 * * * * /path/to/collect.sh >> /var/log/palladium-collect.log 2>&1
# ═══════════════════════════════════════════════════════════════
# ─── 配置 (可通过环境变量覆盖) ───────────────────────────────
TEST_SERVER_CMD="${TEST_SERVER_CMD:-/edatools/vxe/VXE23.03.001/bin/test_server -short}"
MONITOR_URL="${MONITOR_URL:-http://10.168.1.209:5100/api/collect}"
TIMEOUT_SEC="${TIMEOUT_SEC:-300}"
LOG_PREFIX="[Palladium-Collect]"
# ─── 参数解析 ───────────────────────────────────────────────
DRY_RUN=false
while [[ $# -gt 0 ]]; do
case "$1" in
--dry-run) DRY_RUN=true; shift ;;
--help|-h)
head -18 "$0" | tail -15
exit 0 ;;
*) shift ;;
esac
done
# ─── 日志函数 ───────────────────────────────────────────────
log() { echo "$LOG_PREFIX $(date '+%Y-%m-%d %H:%M:%S') $*"; }
# ─── 执行采集 ───────────────────────────────────────────────
log "执行: $TEST_SERVER_CMD"
OUTPUT=$(timeout "$TIMEOUT_SEC" bash -c "$TEST_SERVER_CMD" 2>&1)
EXIT_CODE=$?
if [ $EXIT_CODE -ne 0 ]; then
log "[!] 命令退出码: $EXIT_CODE"
# test_server 可能输出 exit code != 0 但仍有有效输出, 继续处理
fi
if [ -z "$OUTPUT" ]; then
log "[!] 命令无输出, 跳过"
exit 1
fi
OUTPUT_LEN=${#OUTPUT}
log "采集成功, 输出 ${OUTPUT_LEN} 字节"
if [ "$DRY_RUN" = true ]; then
echo "──────────────────────────────────────────"
echo "$OUTPUT"
echo "──────────────────────────────────────────"
log "dry-run 模式, 不推送"
exit 0
fi
# ─── 推送到监控服务器 ───────────────────────────────────────
log "推送到: $MONITOR_URL"
# 用 python3 做 JSON 转义 (如果可用), 否则用 sed
if command -v python3 &>/dev/null; then
JSON_PAYLOAD=$(python3 -c "
import sys, json
print(json.dumps({'raw_output': sys.stdin.read(), 'source': 'cron'}))
" <<< "$OUTPUT")
else
# 简单转义 (无 python3 的后备方案)
ESCAPED=$(echo "$OUTPUT" | sed 's/\\/\\\\/g' | sed 's/"/\\"/g' | sed ':a;N;$!ba;s/\n/\\n/g')
JSON_PAYLOAD="{\"raw_output\": \"$ESCAPED\", \"source\": \"cron\"}"
fi
HTTP_CODE=$(curl -s -o /tmp/palladium_resp.json -w "%{http_code}" \
-X POST "$MONITOR_URL" \
-H "Content-Type: application/json" \
-d "$JSON_PAYLOAD" \
--connect-timeout 10 \
--max-time 30 2>/dev/null)
if [ "$HTTP_CODE" = "200" ]; then
RESP=$(cat /tmp/palladium_resp.json 2>/dev/null)
log "推送成功 — $(echo "$RESP" | grep -o '"snapshot_id":[0-9]*' | head -1)"
# 打印简要统计
if command -v python3 &>/dev/null; then
echo "$RESP" | python3 -c "
import sys, json
try:
d = json.load(sys.stdin)
s = d.get('stats', {})
if s:
print(f' 板: {s[\"total_boards\"]} (在线 {s[\"online_boards\"]}, 离线 {s[\"offline_boards\"]})')
print(f' 逻辑板: 利用率 {s[\"utilization\"]}% (使用 {s[\"used_boards\"]}/{s[\"total_boards\"]})')
print(f' 作业: {s[\"active_jobs\"]}')
except: pass
" 2>/dev/null
fi
else
log "[!] 推送失败: HTTP $HTTP_CODE"
cat /tmp/palladium_resp.json 2>/dev/null
exit 1
fi
rm -f /tmp/palladium_resp.json 2>/dev/null
-2
View File
@@ -1,2 +0,0 @@
Flask==3.0.3
Flask-SQLAlchemy==3.1.1
-79
View File
@@ -1,79 +0,0 @@
:root{--bg-primary:#0d1117;--bg-secondary:#161b22;--bg-card:#1c2330;--bg-card-hover:#232b3a;--border:#30363d;--border-light:#3a4250;--text-primary:#e6edf3;--text-secondary:#8b949e;--text-muted:#6e7681;--accent:#58a6ff;--green:#3fb950;--red:#f85149;--orange:#d29922;--blue:#58a6ff;--gray:#6e7681;--radius:8px;--shadow:0 2px 8px rgba(0,0,0,.3)}
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans SC","Microsoft YaHei",sans-serif;background:var(--bg-primary);color:var(--text-primary);font-size:14px;line-height:1.5}
.topnav{display:flex;align-items:center;justify-content:space-between;padding:0 24px;height:56px;background:var(--bg-secondary);border-bottom:1px solid var(--border);position:sticky;top:0;z-index:100}
.nav-brand{display:flex;align-items:center;gap:8px}.nav-logo{font-size:22px}.nav-title{font-size:17px;font-weight:700}
.nav-links{display:flex;align-items:center;gap:4px}
.nav-links a{padding:8px 16px;color:var(--text-secondary);text-decoration:none;border-radius:var(--radius);transition:all .15s;font-weight:500}
.nav-links a:hover{color:var(--text-primary);background:var(--bg-card)}.nav-links a.active{color:var(--accent);background:rgba(88,166,255,.1)}
.nav-btn{padding:8px 16px;background:var(--accent);color:#fff;border:none;border-radius:var(--radius);cursor:pointer;font-weight:600;font-size:13px;transition:opacity .15s}.nav-btn:hover{opacity:.85}
.container{max-width:1600px;margin:0 auto;padding:20px 24px 60px}
.system-bar{display:flex;align-items:center;flex-wrap:wrap;gap:24px;padding:12px 20px;background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);margin-bottom:16px}
.sys-item{display:flex;flex-direction:column;gap:2px}.sys-label{font-size:11px;color:var(--text-muted);text-transform:uppercase;letter-spacing:.5px}
.sys-value{font-size:14px;font-weight:600;font-family:"SF Mono","Fira Code",Consolas,monospace}
.refresh-controls{margin-left:auto;display:flex;align-items:center;gap:8px}
.refresh-controls label{display:flex;align-items:center;gap:4px;font-size:13px;color:var(--text-secondary);cursor:pointer}
.refresh-controls select{background:var(--bg-primary);color:var(--text-primary);border:1px solid var(--border);border-radius:4px;padding:3px 8px;font-size:12px}
.btn-sm{padding:4px 8px;font-size:12px;background:var(--bg-card-hover);color:var(--text-primary);border:1px solid var(--border);border-radius:4px;cursor:pointer;text-decoration:none;transition:all .15s}.btn-sm:hover{background:var(--border-light)}.btn-sm.btn-danger{color:var(--red);border-color:var(--red)}.btn-sm.btn-danger:hover{background:rgba(248,81,73,.15)}
.status-badge{display:inline-block;padding:2px 10px;border-radius:12px;font-size:12px;font-weight:700}
.status-ok{background:rgba(63,185,80,.15);color:var(--green)}.status-warning{background:rgba(210,153,34,.15);color:var(--orange)}.status-error{background:rgba(248,81,73,.15);color:var(--red)}.status-unknown{background:rgba(110,118,129,.15);color:var(--gray)}
.status-online{background:rgba(63,185,80,.15);color:var(--green)}.status-offline{background:rgba(248,81,73,.15);color:var(--red)}
.metrics-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:12px;margin-bottom:20px}
.metric-card{display:flex;align-items:center;gap:14px;padding:16px;background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);transition:border-color .15s}.metric-card:hover{border-color:var(--border-light)}
.metric-icon{font-size:28px}.metric-label{font-size:12px;color:var(--text-muted);text-transform:uppercase;letter-spacing:.5px}
.metric-value{font-size:28px;font-weight:700;line-height:1.2}.metric-value.metric-warn{color:var(--orange)}.metric-sub{font-size:12px;color:var(--text-secondary);margin-top:2px}
.section-header{display:flex;align-items:center;justify-content:space-between;margin:24px 0 12px}.section-header h2{font-size:18px;font-weight:700}
.legend{display:flex;gap:16px;font-size:12px;color:var(--text-secondary)}.legend-item{display:flex;align-items:center;gap:4px}
.rack-card{background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);margin-bottom:12px;overflow:hidden}
.rack-header{display:flex;align-items:center;justify-content:space-between;padding:10px 16px;background:var(--bg-secondary);border-bottom:1px solid var(--border)}
.rack-title{font-size:15px;font-weight:700}.rack-count{font-size:12px;color:var(--text-muted)}
.cluster-section{padding:12px 16px;border-bottom:1px solid var(--border)}.cluster-section:last-child{border-bottom:none}
.cluster-header{display:flex;align-items:center;gap:12px;margin-bottom:10px;font-size:14px;font-weight:600;color:var(--text-secondary)}
.ccd-badge{padding:1px 8px;border-radius:10px;font-size:11px;font-weight:600}.ccd-online{background:rgba(63,185,80,.15);color:var(--green)}.ccd-offline{background:rgba(248,81,73,.15);color:var(--red)}
.boards-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(170px,1fr));gap:8px}
.board-card{background:var(--bg-primary);border:1px solid var(--border);border-radius:6px;padding:8px 10px;transition:all .15s}.board-card:hover{border-color:var(--border-light);transform:translateY(-1px)}
.board-card.board-offline{border-color:rgba(248,81,73,.4);background:rgba(248,81,73,.05)}
.board-top{display:flex;align-items:center;justify-content:space-between;margin-bottom:6px}
.board-ld{font-size:13px;font-weight:700}.board-status-badge{padding:1px 6px;border-radius:8px;font-size:10px;font-weight:600}
.domains-row{display:grid;grid-template-columns:repeat(8,1fr);gap:2px}
.domain-cell{text-align:center;font-size:10px;font-weight:700;padding:4px 0;border-radius:3px;min-width:18px;transition:transform .1s;cursor:default;font-family:"SF Mono","Fira Code",Consolas,monospace}
.domain-cell:hover{transform:scale(1.15);z-index:5;position:relative}
.domain-available{background:rgba(63,185,80,.15);color:var(--green);border:1px solid rgba(63,185,80,.3)}
.domain-downloaded{background:rgba(88,166,255,.2);color:var(--blue);border:1px solid rgba(88,166,255,.4)}
.domain-disabled{background:rgba(248,81,73,.15);color:var(--red);border:1px solid rgba(248,81,73,.3)}
.domain-not_exist{background:rgba(110,118,129,.1);color:var(--gray);border:1px solid rgba(110,118,129,.2)}
.card{background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);overflow:hidden;margin-bottom:12px}.card.empty-state{padding:24px;text-align:center;color:var(--text-muted)}
.data-table{width:100%;border-collapse:collapse;font-size:13px}.data-table thead{background:var(--bg-secondary)}
.data-table th{padding:10px 14px;text-align:left;font-weight:600;color:var(--text-muted);text-transform:uppercase;font-size:11px;letter-spacing:.5px;border-bottom:1px solid var(--border);white-space:nowrap}
.data-table td{padding:8px 14px;border-bottom:1px solid rgba(48,54,61,.5);color:var(--text-primary)}.data-table tbody tr:hover{background:var(--bg-card-hover)}.data-table tbody tr:last-child td{border-bottom:none}
.text-mono{font-family:"SF Mono","Fira Code",Consolas,monospace;font-size:12px}
.text-green{color:var(--green)}.text-red{color:var(--red)}.text-blue{color:var(--blue)}.text-gray{color:var(--gray)}
.pods-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:12px;margin-bottom:12px}
.pod-card{background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);overflow:hidden}
.pod-header{padding:8px 14px;background:var(--bg-secondary);border-bottom:1px solid var(--border);font-weight:700;font-size:14px}
.pod-table{width:100%;border-collapse:collapse;font-size:12px}.pod-table td{padding:5px 14px;border-bottom:1px solid rgba(48,54,61,.4)}.pod-table tr:last-child td{border-bottom:none}
.pod-label{color:var(--text-secondary);white-space:nowrap;width:40%}.pod-sublabel{color:var(--text-muted);font-size:11px;padding-left:24px!important}.pod-value{font-family:"SF Mono","Fira Code",Consolas,monospace}
.pod-row-available td{color:var(--green)}.pod-row-locked td{color:var(--orange)}.pod-row-reserved td{color:var(--blue)}.pod-row-unavailable td{color:var(--red)}
.util-bar{position:relative;width:120px;height:20px;background:var(--bg-primary);border-radius:10px;overflow:hidden;border:1px solid var(--border)}
.util-fill{height:100%;background:linear-gradient(90deg,var(--green),var(--blue));border-radius:10px;transition:width .3s}
.util-text{position:absolute;top:0;left:0;right:0;bottom:0;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;text-shadow:0 1px 2px rgba(0,0,0,.5)}
.pagination{display:flex;align-items:center;justify-content:center;gap:16px;margin-top:16px}
.page-btn{padding:6px 14px;background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);color:var(--text-primary);text-decoration:none;font-size:13px;transition:all .15s}.page-btn:hover{border-color:var(--accent);color:var(--accent)}
.page-info{font-size:13px;color:var(--text-secondary)}
.tag{display:inline-block;padding:1px 8px;background:rgba(88,166,255,.1);color:var(--accent);border-radius:4px;font-size:11px;font-weight:600}
.modal-overlay{position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.7);display:flex;align-items:center;justify-content:center;z-index:200}
.modal{background:var(--bg-card);border:1px solid var(--border);border-radius:12px;width:90%;max-width:800px;max-height:85vh;display:flex;flex-direction:column;box-shadow:0 8px 32px rgba(0,0,0,.5)}
.modal-header{display:flex;align-items:center;justify-content:space-between;padding:16px 20px;border-bottom:1px solid var(--border)}.modal-header h2{font-size:16px;font-weight:700}
.modal-close{background:none;border:none;color:var(--text-secondary);font-size:18px;cursor:pointer;padding:4px 8px;border-radius:4px}.modal-close:hover{background:var(--bg-card-hover);color:var(--text-primary)}
.modal-body{padding:16px 20px;overflow-y:auto;flex:1}.modal-hint{margin-bottom:10px;color:var(--text-secondary);font-size:13px}.modal-hint code{background:var(--bg-primary);padding:1px 6px;border-radius:3px;font-size:12px;color:var(--accent)}
.paste-textarea{width:100%;background:var(--bg-primary);color:var(--text-primary);border:1px solid var(--border);border-radius:var(--radius);padding:12px;font-family:"SF Mono","Fira Code",Consolas,monospace;font-size:12px;resize:vertical;line-height:1.6}.paste-textarea:focus{outline:none;border-color:var(--accent)}
.modal-footer{display:flex;justify-content:flex-end;gap:8px;padding:12px 20px;border-top:1px solid var(--border)}
.btn{padding:8px 16px;border:none;border-radius:var(--radius);cursor:pointer;font-weight:600;font-size:13px;text-decoration:none;transition:all .15s}
.btn-primary{background:var(--accent);color:#fff}.btn-primary:hover{background:#4493f8}.btn-secondary{background:var(--bg-card-hover);color:var(--text-primary);border:1px solid var(--border)}.btn-secondary:hover{border-color:var(--border-light)}.btn-lg{padding:12px 24px;font-size:15px}
.empty-state-full{text-align:center;padding:80px 20px}.empty-icon{font-size:64px;margin-bottom:16px}.empty-state-full h2{font-size:22px;margin-bottom:8px}.empty-state-full p{color:var(--text-secondary);margin-bottom:20px}.empty-actions{margin-bottom:16px}.empty-hint{color:var(--text-muted)!important;font-size:13px}.empty-hint code{background:var(--bg-card);padding:2px 8px;border-radius:4px;color:var(--accent)}
.snapshot-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:16px}.snapshot-meta{font-size:13px;color:var(--text-secondary);margin-top:4px}
.raw-output-section{margin-top:24px;background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);padding:12px 16px}
.raw-output-section summary{cursor:pointer;font-weight:600;color:var(--text-secondary);padding:4px 0}
.raw-output{margin-top:12px;background:var(--bg-primary);border:1px solid var(--border);border-radius:var(--radius);padding:12px;font-family:"SF Mono","Fira Code",Consolas,monospace;font-size:11px;color:var(--text-secondary);overflow-x:auto;white-space:pre-wrap;max-height:500px;overflow-y:auto}
.card canvas{padding:16px}
@media(max-width:768px){.topnav{padding:0 12px}.container{padding:12px}.system-bar{gap:12px}.refresh-controls{margin-left:0}.boards-grid{grid-template-columns:1fr}.metrics-grid{grid-template-columns:1fr}}
-53
View File
@@ -1,53 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Palladium Monitor{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
{% block head %}{% endblock %}
</head>
<body>
<nav class="topnav">
<div class="nav-brand">
<span class="nav-logo"></span>
<span class="nav-title">Palladium Monitor</span>
</div>
<div class="nav-links">
<a href="{{ url_for('dashboard') }}" class="{% if request.endpoint == 'dashboard' %}active{% endif %}">仪表板</a>
<a href="{{ url_for('history') }}" class="{% if request.endpoint == 'history' %}active{% endif %}">历史记录</a>
<button class="nav-btn" onclick="openPasteModal()">📥 粘贴数据</button>
</div>
</nav>
<main class="container">
{% block content %}{% endblock %}
</main>
<div id="pasteModal" class="modal-overlay" style="display:none;">
<div class="modal">
<div class="modal-header">
<h2>📥 粘贴 test_server -short 输出</h2>
<button class="modal-close" onclick="closePasteModal()"></button>
</div>
<form action="{{ url_for('paste_data') }}" method="POST">
<div class="modal-body">
<p class="modal-hint">在 Palladium 主机执行 <code>test_server -short</code>,将输出粘贴到下方:</p>
<textarea name="raw_output" rows="20" class="paste-textarea"
placeholder="Emulator: sc01_emu Hardware: Palladium ..."></textarea>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closePasteModal()">取消</button>
<button type="submit" class="btn btn-primary">解析并存储</button>
</div>
</form>
</div>
</div>
<script>
function openPasteModal() { document.getElementById('pasteModal').style.display = 'flex'; }
function closePasteModal() { document.getElementById('pasteModal').style.display = 'none'; }
document.getElementById('pasteModal').addEventListener('click', function(e) {
if (e.target === this) closePasteModal();
});
</script>
{% block scripts %}{% endblock %}
</body>
</html>
-227
View File
@@ -1,227 +0,0 @@
{% extends "base.html" %}
{% block title %}仪表板 — Palladium Monitor{% endblock %}
{% block head %}
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
{% endblock %}
{% block content %}
{% if snapshot %}
<div class="system-bar">
<div class="sys-item"><span class="sys-label">仿真器</span><span class="sys-value">{{ snapshot.emulator }}</span></div>
<div class="sys-item"><span class="sys-label">硬件</span><span class="sys-value">{{ snapshot.hardware }}</span></div>
<div class="sys-item"><span class="sys-label">ConfigMgr</span><span class="sys-value">{{ snapshot.configmgr }}</span></div>
<div class="sys-item">
<span class="sys-label">系统状态</span>
{% set ss = snapshot.system_status.upper() %}
{% if ss == 'OK' %}<span class="status-badge status-ok">● {{ snapshot.system_status }}</span>
{% elif 'PARTIAL' in ss %}<span class="status-badge status-warning">● {{ snapshot.system_status }}</span>
{% elif 'DOWN' in ss or 'ERROR' in ss %}<span class="status-badge status-error">● {{ snapshot.system_status }}</span>
{% else %}<span class="status-badge status-unknown">● {{ snapshot.system_status }}</span>{% endif %}
</div>
<div class="sys-item"><span class="sys-label">最后更新</span><span class="sys-value">{{ snapshot.timestamp | fmt_time }}</span></div>
<div class="sys-item"><span class="sys-label">数据来源</span><span class="sys-value">{{ snapshot.source }}</span></div>
<div class="sys-item refresh-controls">
<label><input type="checkbox" id="autoRefresh" checked> 自动刷新</label>
<select id="refreshInterval">
<option value="15">15s</option>
<option value="30" selected>30s</option>
<option value="60">1m</option>
<option value="300">5m</option>
</select>
<button class="btn-sm" onclick="location.reload()">🔄</button>
</div>
</div>
<div class="metrics-grid">
<div class="metric-card">
<div class="metric-icon">🖥️</div>
<div class="metric-body">
<div class="metric-label">逻辑板总数</div>
<div class="metric-value">{{ stats.total_boards }}</div>
<div class="metric-sub"><span class="text-green">在线 {{ stats.online_boards }}</span> · <span class="text-red">离线 {{ stats.offline_boards }}</span></div>
</div>
</div>
<div class="metric-card">
<div class="metric-icon">📊</div>
<div class="metric-body">
<div class="metric-label">逻辑板利用率</div>
<div class="metric-value {{ 'metric-warn' if stats.utilization > 80 else '' }}">{{ stats.utilization }}%</div>
<div class="metric-sub">使用 {{ stats.used_boards }} / 空闲 {{ stats.online_boards - stats.used_boards }}</div>
</div>
</div>
<div class="metric-card">
<div class="metric-icon">🔲</div>
<div class="metric-body">
<div class="metric-label">逻辑板状态</div>
<div class="metric-value">{{ stats.total_boards }}</div>
<div class="metric-sub">
<span class="text-green">● {{ stats.online_boards - stats.used_boards }} 空闲</span>
<span class="text-blue">● {{ stats.used_boards }} 使用</span>
<span class="text-red">● {{ stats.offline_boards }} 离线</span>
</div>
</div>
</div>
<div class="metric-card">
<div class="metric-icon">⚙️</div>
<div class="metric-body">
<div class="metric-label">活跃作业</div>
<div class="metric-value">{{ stats.active_jobs }}</div>
<div class="metric-sub">{% if jobs %}{{ jobs[0].owner }} · {{ jobs[0].elap_time }}{% else %}无活跃作业{% endif %}</div>
</div>
</div>
</div>
<div class="section-header">
<h2>🏗️ 逻辑板状态</h2>
<div class="legend">
<span class="legend-item"><span class="domain-cell domain-available">-</span> 可用</span>
<span class="legend-item"><span class="domain-cell domain-downloaded">1</span> 已加载</span>
<span class="legend-item"><span class="domain-cell domain-disabled">D</span> 已禁用</span>
<span class="legend-item"><span class="domain-cell domain-not_exist">X</span> 不存在</span>
</div>
</div>
{% for rack in racks %}
<div class="rack-card">
<div class="rack-header"><span class="rack-title">Rack {{ rack.rack }}</span><span class="rack-count">{{ rack.clusters | length }} clusters</span></div>
{% for cluster in rack.clusters %}
<div class="cluster-section">
<div class="cluster-header">
<span>Cluster {{ cluster.cluster }}</span>
{% set ccd = cluster.ccd.upper() %}
{% if ccd == 'ONLINE' %}<span class="ccd-badge ccd-online">CCD: {{ cluster.ccd }}</span>
{% else %}<span class="ccd-badge ccd-offline">CCD: {{ cluster.ccd }}</span>{% endif %}
</div>
<div class="boards-grid">
{% for board in cluster.boards %}
<div class="board-card board-{{ board.status | lower }}">
<div class="board-top">
<span class="board-ld">LD {{ board.ld }}</span>
<span class="board-status-badge status-{{ board.status | lower }}">{{ board.status }}</span>
</div>
<div class="domains-row">
{% for d in board.domains %}
<div class="domain-cell domain-{{ d | domain_state }}" title="D{{ loop.index0 }}: {{ d | domain_label }}">{{ d }}</div>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
{% endfor %}
<div class="section-header"><h2>📋 作业信息</h2></div>
{% if jobs %}
<div class="card">
<table class="data-table">
<thead><tr><th>#</th><th>用户</th><th>PID</th><th>T-Pod</th><th>设计名</th><th>已运行</th><th>预留Key</th></tr></thead>
<tbody>
{% for job in jobs %}
<tr>
<td>{{ job.job_index }}</td>
<td class="text-mono">{{ job.owner }}</td>
<td class="text-mono">{{ job.pid }}</td>
<td class="text-mono">{{ job.t_pod }}</td>
<td>{{ job.design }}</td>
<td class="text-mono">{{ job.elap_time }}</td>
<td>{{ job.reserved_key }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="card empty-state">当前无活跃作业</div>
{% endif %}
<div class="section-header"><h2>🎯 T-Pod 可用性</h2></div>
<div class="pods-grid">
{% for pod in pods %}
<div class="pod-card">
<div class="pod-header">Rack {{ pod.rack }}</div>
<table class="pod-table">
{% set all_h = pod.all_hdsb | from_json %}
{% set all_t = pod.all_t_pods | from_json %}
{% for length, val in all_h.items() %}
<tr><td class="pod-sublabel">HDSB L{{ length }}</td><td class="pod-value">{{ val }}</td></tr>
{% endfor %}
{% for length, val in all_t.items() %}
<tr><td class="pod-sublabel">T-Pods L{{ length }}</td><td class="pod-value">{{ val }}</td></tr>
{% endfor %}
<tr class="pod-row-available"><td class="pod-label">可用 HDSB</td><td class="pod-value">{{ pod.available_hdsb }}</td></tr>
<tr class="pod-row-available"><td class="pod-label">可用 T-Pods</td><td class="pod-value">{{ pod.available_t_pods }}</td></tr>
<tr class="pod-row-locked"><td class="pod-label">锁定 HDSB</td><td class="pod-value">{{ pod.locked_hdsb }}</td></tr>
<tr class="pod-row-locked"><td class="pod-label">锁定 T-Pods</td><td class="pod-value">{{ pod.locked_t_pods }}</td></tr>
<tr class="pod-row-reserved"><td class="pod-label">预留 HDSB</td><td class="pod-value">{{ pod.reserved_hdsb }}</td></tr>
<tr class="pod-row-reserved"><td class="pod-label">预留 T-Pods</td><td class="pod-value">{{ pod.reserved_t_pods }}</td></tr>
<tr class="pod-row-unavailable"><td class="pod-label">不可用 HDSB</td><td class="pod-value">{{ pod.unavailable_hdsb }}</td></tr>
<tr class="pod-row-unavailable"><td class="pod-label">不可用 T-Pods</td><td class="pod-value">{{ pod.unavailable_t_pods }}</td></tr>
</table>
</div>
{% endfor %}
</div>
{% if history | length > 1 %}
<div class="section-header"><h2>📈 利用率趋势</h2></div>
<div class="card"><canvas id="utilChart" height="80"></canvas></div>
{% endif %}
{% else %}
<div class="empty-state-full">
<div class="empty-icon">📊</div>
<h2>暂无监控数据</h2>
<p>请通过以下方式导入数据:</p>
<div class="empty-actions">
<button class="btn btn-primary btn-lg" onclick="openPasteModal()">📥 粘贴 test_server -short 输出</button>
</div>
<p class="empty-hint">或在 Palladium 主机配置定时采集脚本 <code>collect.sh</code></p>
</div>
{% endif %}
{% endblock %}
{% block scripts %}
{% if snapshot and history | length > 1 %}
<script>
const ctx = document.getElementById('utilChart').getContext('2d');
const labels = [{% for h in history %}'{{ h.timestamp | fmt_time_short }}'{% if not loop.last %}, {% endif %}{% endfor %}];
const utilData = [{% for h in history %}{{ h.utilization }}{% if not loop.last %}, {% endif %}{% endfor %}];
const usedData = [{% for h in history %}{{ h.used_boards }}{% if not loop.last %}, {% endif %}{% endfor %}];
new Chart(ctx, {
type: 'line',
data: {
labels: labels,
datasets: [
{ label: '利用率 (%)', data: utilData, borderColor: '#007bff', backgroundColor: 'rgba(0,123,255,0.1)', yAxisID: 'y', tension: 0.3, fill: true },
{ label: '使用逻辑板', data: usedData, borderColor: '#28a745', backgroundColor: 'rgba(40,167,69,0.1)', yAxisID: 'y1', tension: 0.3, fill: false }
]
},
options: {
responsive: true,
interaction: { mode: 'index', intersect: false },
scales: {
x: { ticks: { color: '#9ba1b3', maxRotation: 45 } },
y: { type: 'linear', position: 'left', title: { display: true, text: '利用率 %', color: '#007bff' }, min: 0, max: 100, ticks: { color: '#007bff' } },
y1: { type: 'linear', position: 'right', title: { display: true, text: '使用逻辑板', color: '#28a745' }, ticks: { color: '#28a745' }, grid: { drawOnChartArea: false } }
},
plugins: { legend: { labels: { color: '#e4e6eb' } } }
}
});
</script>
{% endif %}
<script>
let refreshTimer = null;
function setupRefresh() {
if (refreshTimer) clearTimeout(refreshTimer);
const cb = document.getElementById('autoRefresh');
const sel = document.getElementById('refreshInterval');
if (!cb || !sel) return;
if (cb.checked) { refreshTimer = setTimeout(function(){ location.reload(); }, parseInt(sel.value) * 1000); }
}
document.addEventListener('DOMContentLoaded', setupRefresh);
document.getElementById('autoRefresh')?.addEventListener('change', setupRefresh);
document.getElementById('refreshInterval')?.addEventListener('change', setupRefresh);
</script>
{% endblock %}
-62
View File
@@ -1,62 +0,0 @@
{% extends "base.html" %}
{% block title %}历史记录 — Palladium Monitor{% endblock %}
{% block content %}
<div class="section-header">
<h2>📅 历史快照</h2>
<button class="btn btn-sm btn-secondary" onclick="cleanupOld()">🧹 清理30天前数据</button>
</div>
{% if pagination.items %}
<div class="card">
<table class="data-table">
<thead>
<tr><th>ID</th><th>时间</th><th>仿真器</th><th>系统状态</th><th>逻辑板</th><th>逻辑板利用率</th><th>使用/总数</th><th>作业数</th><th>来源</th><th>操作</th></tr>
</thead>
<tbody>
{% for snap in pagination.items %}
<tr>
<td>{{ snap.id }}</td>
<td class="text-mono">{{ snap.timestamp | fmt_time }}</td>
<td>{{ snap.emulator }}</td>
<td>
{% set ss = snap.system_status.upper() %}
{% if ss == 'OK' %}<span class="status-badge status-ok">{{ snap.system_status }}</span>
{% elif 'PARTIAL' in ss %}<span class="status-badge status-warning">{{ snap.system_status }}</span>
{% elif 'DOWN' in ss or 'ERROR' in ss %}<span class="status-badge status-error">{{ snap.system_status }}</span>
{% else %}<span class="status-badge status-unknown">{{ snap.system_status }}</span>{% endif %}
</td>
<td>{{ snap.total_boards }} <span class="text-green">({{ snap.online_boards }}</span>/<span class="text-red">{{ snap.offline_boards }})</span></td>
<td>
<div class="util-bar">
<div class="util-fill" style="width: {{ snap.utilization }}%"></div>
<span class="util-text">{{ snap.utilization }}%</span>
</div>
</td>
<td class="text-mono">{{ snap.used_boards }} / {{ snap.total_boards }}</td>
<td>{{ snap.active_jobs }}</td>
<td><span class="tag">{{ snap.source }}</span></td>
<td>
<a href="{{ url_for('snapshot_detail', sid=snap.id) }}" class="btn-sm">查看</a>
<form method="POST" action="{{ url_for('delete_snapshot', sid=snap.id) }}" style="display:inline;" onsubmit="return confirm('确认删除快照 #{{ snap.id }}?')">
<button type="submit" class="btn-sm btn-danger">删除</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="pagination">
{% if pagination.has_prev %}<a href="{{ url_for('history', page=pagination.prev_num) }}" class="page-btn">← 上一页</a>{% endif %}
<span class="page-info">第 {{ pagination.page }} / {{ pagination.pages }} 页 (共 {{ pagination.total }} 条)</span>
{% if pagination.has_next %}<a href="{{ url_for('history', page=pagination.next_num) }}" class="page-btn">下一页 →</a>{% endif %}
</div>
{% else %}
<div class="card empty-state">暂无历史数据</div>
{% endif %}
<script>
function cleanupOld() {
if (!confirm('确认清理30天前的历史数据?')) return;
fetch('/api/cleanup?days=30', { method: 'POST' }).then(r => r.json()).then(data => { alert('已清理 ' + data.deleted + ' 条快照'); location.reload(); }).catch(e => alert('清理失败: ' + e));
}
</script>
{% endblock %}
-106
View File
@@ -1,106 +0,0 @@
{% extends "base.html" %}
{% block title %}快照 #{{ snapshot.id }} — Palladium Monitor{% endblock %}
{% block content %}
<div class="snapshot-header">
<div>
<h2>📸 快照 #{{ snapshot.id }}</h2>
<div class="snapshot-meta">{{ snapshot.timestamp | fmt_time }} · 来源: {{ snapshot.source }} · {{ snapshot.emulator }} / {{ snapshot.hardware }}</div>
</div>
<a href="{{ url_for('dashboard') }}" class="btn btn-secondary">← 返回仪表板</a>
</div>
<div class="system-bar">
<div class="sys-item"><span class="sys-label">仿真器</span><span class="sys-value">{{ snapshot.emulator }}</span></div>
<div class="sys-item"><span class="sys-label">硬件</span><span class="sys-value">{{ snapshot.hardware }}</span></div>
<div class="sys-item"><span class="sys-label">ConfigMgr</span><span class="sys-value">{{ snapshot.configmgr }}</span></div>
<div class="sys-item">
<span class="sys-label">系统状态</span>
{% set ss = snapshot.system_status.upper() %}
{% if ss == 'OK' %}<span class="status-badge status-ok">{{ snapshot.system_status }}</span>
{% elif 'PARTIAL' in ss %}<span class="status-badge status-warning">{{ snapshot.system_status }}</span>
{% elif 'DOWN' in ss or 'ERROR' in ss %}<span class="status-badge status-error">{{ snapshot.system_status }}</span>
{% else %}<span class="status-badge status-unknown">{{ snapshot.system_status }}</span>{% endif %}
</div>
</div>
<div class="metrics-grid">
<div class="metric-card"><div class="metric-icon">🖥️</div><div class="metric-body"><div class="metric-label">逻辑板</div><div class="metric-value">{{ stats.total_boards }}</div><div class="metric-sub"><span class="text-green">在线 {{ stats.online_boards }}</span> · <span class="text-red">离线 {{ stats.offline_boards }}</span></div></div></div>
<div class="metric-card"><div class="metric-icon">📊</div><div class="metric-body"><div class="metric-label">逻辑板利用率</div><div class="metric-value">{{ stats.utilization }}%</div><div class="metric-sub">使用 {{ stats.used_boards }} / 空闲 {{ stats.online_boards - stats.used_boards }}</div></div></div>
<div class="metric-card"><div class="metric-icon">⚙️</div><div class="metric-body"><div class="metric-label">活跃作业</div><div class="metric-value">{{ stats.active_jobs }}</div></div></div>
</div>
<div class="section-header">
<h2>🏗️ 逻辑板状态</h2>
<div class="legend">
<span class="legend-item"><span class="domain-cell domain-available">-</span> 可用</span>
<span class="legend-item"><span class="domain-cell domain-downloaded">1</span> 已加载</span>
<span class="legend-item"><span class="domain-cell domain-disabled">D</span> 已禁用</span>
<span class="legend-item"><span class="domain-cell domain-not_exist">X</span> 不存在</span>
</div>
</div>
{% for rack in racks %}
<div class="rack-card">
<div class="rack-header"><span class="rack-title">Rack {{ rack.rack }}</span></div>
{% for cluster in rack.clusters %}
<div class="cluster-section">
<div class="cluster-header">
<span>Cluster {{ cluster.cluster }}</span>
{% set ccd = cluster.ccd.upper() %}
{% if ccd == 'ONLINE' %}<span class="ccd-badge ccd-online">CCD: {{ cluster.ccd }}</span>
{% else %}<span class="ccd-badge ccd-offline">CCD: {{ cluster.ccd }}</span>{% endif %}
</div>
<div class="boards-grid">
{% for board in cluster.boards %}
<div class="board-card board-{{ board.status | lower }}">
<div class="board-top"><span class="board-ld">LD {{ board.ld }}</span><span class="board-status-badge status-{{ board.status | lower }}">{{ board.status }}</span></div>
<div class="domains-row">
{% for d in board.domains %}
<div class="domain-cell domain-{{ d | domain_state }}" title="D{{ loop.index0 }}: {{ d | domain_label }}">{{ d }}</div>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
{% endfor %}
<div class="section-header"><h2>📋 作业信息</h2></div>
{% if jobs %}
<div class="card">
<table class="data-table">
<thead><tr><th>#</th><th>用户</th><th>PID</th><th>T-Pod</th><th>设计名</th><th>已运行</th><th>预留Key</th></tr></thead>
<tbody>
{% for job in jobs %}
<tr><td>{{ job.job_index }}</td><td class="text-mono">{{ job.owner }}</td><td class="text-mono">{{ job.pid }}</td><td class="text-mono">{{ job.t_pod }}</td><td>{{ job.design }}</td><td class="text-mono">{{ job.elap_time }}</td><td>{{ job.reserved_key }}</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="card empty-state">该快照无活跃作业</div>
{% endif %}
<div class="section-header"><h2>🎯 T-Pod 可用性</h2></div>
<div class="pods-grid">
{% for pod in pods %}
<div class="pod-card">
<div class="pod-header">Rack {{ pod.rack }}</div>
<table class="pod-table">
{% set all_h = pod.all_hdsb | from_json %}
{% set all_t = pod.all_t_pods | from_json %}
{% for length, val in all_h.items() %}<tr><td class="pod-sublabel">HDSB L{{ length }}</td><td class="pod-value">{{ val }}</td></tr>{% endfor %}
{% for length, val in all_t.items() %}<tr><td class="pod-sublabel">T-Pods L{{ length }}</td><td class="pod-value">{{ val }}</td></tr>{% endfor %}
<tr class="pod-row-available"><td class="pod-label">可用 HDSB</td><td class="pod-value">{{ pod.available_hdsb }}</td></tr>
<tr class="pod-row-available"><td class="pod-label">可用 T-Pods</td><td class="pod-value">{{ pod.available_t_pods }}</td></tr>
<tr class="pod-row-locked"><td class="pod-label">锁定 HDSB</td><td class="pod-value">{{ pod.locked_hdsb }}</td></tr>
<tr class="pod-row-locked"><td class="pod-label">锁定 T-Pods</td><td class="pod-value">{{ pod.locked_t_pods }}</td></tr>
<tr class="pod-row-reserved"><td class="pod-label">预留 HDSB</td><td class="pod-value">{{ pod.reserved_hdsb }}</td></tr>
<tr class="pod-row-reserved"><td class="pod-label">预留 T-Pods</td><td class="pod-value">{{ pod.reserved_t_pods }}</td></tr>
<tr class="pod-row-unavailable"><td class="pod-label">不可用 HDSB</td><td class="pod-value">{{ pod.unavailable_hdsb }}</td></tr>
<tr class="pod-row-unavailable"><td class="pod-label">不可用 T-Pods</td><td class="pod-value">{{ pod.unavailable_t_pods }}</td></tr>
</table>
</div>
{% endfor %}
</div>
<details class="raw-output-section">
<summary>📄 原始 test_server 输出</summary>
<pre class="raw-output">{{ snapshot.raw_output }}</pre>
</details>
{% endblock %}
+16
View File
@@ -794,6 +794,22 @@ body {
gap: 16px;
}
.form-section:first-of-type { border-top: none; padding-top: 0; }
.form-section-title {
grid-column: 1 / -1;
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 4px;
padding-top: 4px;
}
/* 输入框提示文字 */
.input-hint {
display: block;
font-size: 11px;
color: var(--text-muted);
margin-top: 2px;
}
/* 全宽字段 */
.form-group.full-width { grid-column: 1 / -1; }
+7 -3
View File
@@ -93,19 +93,23 @@
</div>
</div>
<!-- 发送时间 -->
<!-- 发送邮件时间 -->
<div class="form-section">
<div class="form-section-title">🕐 发送邮件时间</div>
<div class="form-group form-group-small">
<label for="cfg_send_hour">发送时间 (小时)</label>
<input type="number" id="cfg_send_hour" placeholder="9" min="0" max="23">
<input type="number" id="cfg_send_hour" placeholder="9" min="0" max="23" step="1">
<span class="input-hint">023 (24小时制)</span>
</div>
<div class="form-group form-group-small">
<label for="cfg_send_minute">发送时间 (分钟)</label>
<input type="number" id="cfg_send_minute" placeholder="0" min="0" max="59">
<input type="number" id="cfg_send_minute" placeholder="0" min="0" max="59" step="1">
<span class="input-hint">059</span>
</div>
<div class="form-group">
<label for="cfg_monitor_base_url">监控服务器地址</label>
<input type="url" id="cfg_monitor_base_url" placeholder="http://127.0.0.1:5100">
<span class="input-hint">邮件中「查看仪表板」链接的跳转地址</span>
</div>
</div>