rack:界面改版(仪器前面板风格 + 暗色模式)+ 底板温度曲线
- 总览:每块板一块面板、五个刀片槽(状态灯 + DIN 读数 + 最近两分钟电流小曲线,平线 = 疑似卡死);
只有异常 / 故障 / 执行中上色;顶上一句话概况,有问题直接列出是哪几路
- 单板页:新增「底板温度」(两路 NTC + 风扇占空比,上下两块共用时间轴,10 分钟 ~ 7 天);
每路电流 / 电压去掉双 Y 轴,改上下两块,电压固定 3.5~4.1 V;名称 / 位置就地编辑
- 主题:theme.css 亮 / 暗两套变量(跟随系统,Element Plus 暗色变量同挂 html.dark),图表切换时重画;
读数用系统自带 DIN 系字体(不加载网络字体);温度两色按本页亮暗底色跑过 dataviz validate_palette.js 全通过
- 后端:board_tele_min 分钟聚合(温度带 min/max,留 90 天)+ GET /api/boards/{id}/history(Db.board_history)
- 修一个偶发失败的测试(test_ota_events_are_named_by_arg:只推一次遥测,赶上 Hub 正在同步时会漏触发);
后端 42 例本机 + 服务器全绿,已部署;线上已取到板 B 温度 24.4 / 26.0 °C
Co-Authored-By: Claude Opus 5.5 <[email protected]>
这个提交包含在:
@@ -108,6 +108,12 @@ def create_app(hub: Hub, rec: RecoveryManager, static_dir: Optional[str] = None,
|
||||
except CmdTimeout as e:
|
||||
raise HTTPException(504, str(e))
|
||||
|
||||
@app.get("/api/boards/{board}/history")
|
||||
def board_history(board: str, start: int, end: Optional[int] = None):
|
||||
"""整板曲线(温度 / 风扇 / 输入),见 Db.board_history。"""
|
||||
_board_or_404(hub, board)
|
||||
return hub.db.board_history(board, start, end or now_ms())
|
||||
|
||||
@app.get("/api/boards/{board}/rails/{rail}/history")
|
||||
def history(board: str, rail: int, start: int, end: Optional[int] = None):
|
||||
_board_or_404(hub, board)
|
||||
|
||||
+46
-1
@@ -52,6 +52,14 @@ CREATE TABLE IF NOT EXISTS board_tele (
|
||||
in_mv INTEGER, in_ua INTEGER, temp0 INTEGER, temp1 INTEGER, fan_duty INTEGER
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS board_tele_idx ON board_tele (board_id, ts);
|
||||
-- 整板遥测的分钟聚合(温度 / 风扇 / 输入),和 tele_min 一样留 90 天:「前天夜里底板多热」要看得到
|
||||
CREATE TABLE IF NOT EXISTS board_tele_min (
|
||||
ts INTEGER NOT NULL, board_id TEXT NOT NULL,
|
||||
t0_avg INTEGER, t0_min INTEGER, t0_max INTEGER,
|
||||
t1_avg INTEGER, t1_min INTEGER, t1_max INTEGER,
|
||||
fan_avg INTEGER, in_mv_avg INTEGER, in_ua_avg INTEGER, n INTEGER,
|
||||
PRIMARY KEY (board_id, ts)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
board_id TEXT NOT NULL, seq INTEGER NOT NULL,
|
||||
t_ms INTEGER, rail INTEGER, type INTEGER, arg INTEGER, mv INTEGER, ua INTEGER,
|
||||
@@ -162,7 +170,17 @@ class Db:
|
||||
"CAST(AVG(ua) AS INTEGER), MIN(ua), MAX(ua), COUNT(*) FROM tele "
|
||||
"WHERE ts >= ? AND ts < ? AND ua IS NOT NULL GROUP BY m, board_id, rail",
|
||||
(start, upto))
|
||||
return cur.rowcount
|
||||
n = cur.rowcount
|
||||
row = self.conn.execute("SELECT MAX(ts) FROM board_tele_min").fetchone()
|
||||
bstart = (row[0] + 60000) if row[0] is not None else 0
|
||||
cur = self.conn.execute(
|
||||
"INSERT OR IGNORE INTO board_tele_min (ts, board_id, t0_avg, t0_min, t0_max, t1_avg, t1_min, "
|
||||
"t1_max, fan_avg, in_mv_avg, in_ua_avg, n) "
|
||||
"SELECT ts/60000*60000 AS m, board_id, CAST(AVG(temp0) AS INTEGER), MIN(temp0), MAX(temp0), "
|
||||
"CAST(AVG(temp1) AS INTEGER), MIN(temp1), MAX(temp1), CAST(AVG(fan_duty) AS INTEGER), "
|
||||
"CAST(AVG(in_mv) AS INTEGER), CAST(AVG(in_ua) AS INTEGER), COUNT(*) FROM board_tele "
|
||||
"WHERE ts >= ? AND ts < ? GROUP BY m, board_id", (bstart, upto))
|
||||
return n + cur.rowcount
|
||||
|
||||
def prune(self, now: Optional[int] = None):
|
||||
t = now if now is not None else now_ms()
|
||||
@@ -170,6 +188,7 @@ class Db:
|
||||
self.conn.execute("DELETE FROM tele WHERE ts < ?", (t - RAW_KEEP_S * 1000,))
|
||||
self.conn.execute("DELETE FROM board_tele WHERE ts < ?", (t - RAW_KEEP_S * 1000,))
|
||||
self.conn.execute("DELETE FROM tele_min WHERE ts < ?", (t - MIN_KEEP_S * 1000,))
|
||||
self.conn.execute("DELETE FROM board_tele_min WHERE ts < ?", (t - MIN_KEEP_S * 1000,))
|
||||
|
||||
def history(self, board: str, rail: int, t0: int, t1: int, max_points: int = 1500) -> dict:
|
||||
"""[t0, t1) 的电压电流曲线。跨度 ≤ 原始数据保留期且点数不多时回原始 1 s 数据,
|
||||
@@ -192,3 +211,29 @@ class Db:
|
||||
res = "minute"
|
||||
return {"resolution": res, "bucket_ms": bucket,
|
||||
"points": [[r["t"], r["mv"], r["ua"], r["ua_min"], r["ua_max"]] for r in rows]}
|
||||
|
||||
def board_history(self, board: str, t0: int, t1: int, max_points: int = 1500) -> dict:
|
||||
"""[t0, t1) 的整板曲线:两路 NTC 温度(m°C,带每桶 min/max)、风扇占空比(‰)、输入电压电流。
|
||||
取数规则同 `history()`:近期短跨度回原始 1 s,否则回分钟聚合。
|
||||
points:[t, t0, t0_min, t0_max, t1, t1_min, t1_max, fan, in_mv, in_ua];读不到的是 None。"""
|
||||
span = max(1, t1 - t0)
|
||||
raw_ok = t0 >= now_ms() - RAW_KEEP_S * 1000 and span <= 6 * 3600 * 1000
|
||||
if raw_ok:
|
||||
bucket = max(1000, span // max_points)
|
||||
rows = self.q(
|
||||
"SELECT ts/?*? AS t, CAST(AVG(temp0) AS INTEGER) a0, MIN(temp0) n0, MAX(temp0) x0, "
|
||||
"CAST(AVG(temp1) AS INTEGER) a1, MIN(temp1) n1, MAX(temp1) x1, CAST(AVG(fan_duty) AS INTEGER) f, "
|
||||
"CAST(AVG(in_mv) AS INTEGER) mv, CAST(AVG(in_ua) AS INTEGER) ua FROM board_tele "
|
||||
"WHERE board_id=? AND ts>=? AND ts<? GROUP BY t ORDER BY t", (bucket, bucket, board, t0, t1))
|
||||
res = "raw"
|
||||
else:
|
||||
bucket = max(60000, span // max_points // 60000 * 60000)
|
||||
rows = self.q(
|
||||
"SELECT ts/?*? AS t, CAST(AVG(t0_avg) AS INTEGER) a0, MIN(t0_min) n0, MAX(t0_max) x0, "
|
||||
"CAST(AVG(t1_avg) AS INTEGER) a1, MIN(t1_min) n1, MAX(t1_max) x1, CAST(AVG(fan_avg) AS INTEGER) f, "
|
||||
"CAST(AVG(in_mv_avg) AS INTEGER) mv, CAST(AVG(in_ua_avg) AS INTEGER) ua FROM board_tele_min "
|
||||
"WHERE board_id=? AND ts>=? AND ts<? GROUP BY t ORDER BY t", (bucket, bucket, board, t0, t1))
|
||||
res = "minute"
|
||||
return {"resolution": res, "bucket_ms": bucket,
|
||||
"points": [[r["t"], r["a0"], r["n0"], r["x0"], r["a1"], r["n1"], r["x1"], r["f"], r["mv"], r["ua"]]
|
||||
for r in rows]}
|
||||
|
||||
@@ -129,3 +129,27 @@ def test_a_real_websocket_library_is_installed():
|
||||
deploy.sh 在服务器上跑这套测试,所以缺库会在换上去之前被拦住。"""
|
||||
from prrack.main import ws_library_ok
|
||||
assert ws_library_ok()
|
||||
|
||||
|
||||
def test_board_history_raw_and_minute(env):
|
||||
c, hub, sim, db = env
|
||||
base = now_ms() // 60000 * 60000 - 3 * 60000
|
||||
rows = [(base + i * 1000, B, 12000, 1500000, 35000 + i, 40000 - i, 300) for i in range(120)]
|
||||
db.many("INSERT INTO board_tele VALUES (?,?,?,?,?,?,?)", rows)
|
||||
h = c.get("/api/boards/%s/history" % B, params={"start": base, "end": base + 120000}).json()
|
||||
assert h["resolution"] == "raw" and len(h["points"]) >= 100
|
||||
p = h["points"][0]
|
||||
assert p[1] == 35000 and p[4] == 40000 and p[7] == 300 and p[8] == 12000
|
||||
# 分钟聚合:两个整分钟,每分钟 min/max 就是那一分钟里的首尾
|
||||
assert db.downsample(base + 180000) > 0
|
||||
m = db.board_history(B, base, base + 120000 + 1) # 跨度小也能强制走聚合:t0 早于原始保留期时
|
||||
rows_min = db.q("SELECT * FROM board_tele_min WHERE board_id=? ORDER BY ts", (B,))
|
||||
assert [r["t0_min"] for r in rows_min][:2] == [35000, 35060]
|
||||
assert [r["t0_max"] for r in rows_min][:2] == [35059, 35119]
|
||||
assert rows_min[0]["t1_min"] == 40000 - 59 and rows_min[0]["fan_avg"] == 300
|
||||
assert m["resolution"] == "raw"
|
||||
old = db.board_history(B, base - 10 * 86400000, base + 120000)
|
||||
# 10 天跨度 ⇒ 桶约 9 分钟,两分钟落进同一桶:min 取两分钟的最小、max 取最大
|
||||
assert old["resolution"] == "minute" and old["points"][0][2] == 35000
|
||||
assert max(p[3] for p in old["points"]) == 35119
|
||||
assert c.get("/api/boards/nope/history", params={"start": 0}).status_code == 404
|
||||
|
||||
@@ -199,8 +199,13 @@ def test_ota_events_are_named_by_arg(env):
|
||||
c, hub, sim = env
|
||||
sim._ev(0, 18, 3, 3, 996064) # 失败:哈希不对
|
||||
sim._ev(0, 18, 5) # 回滚
|
||||
sim.tick(now_ms())
|
||||
assert wait_for(lambda: len([e for e in c.get("/api/events").json() if e["type"] == 18]) == 2)
|
||||
|
||||
# 每次检查前都推一条遥测:Hub 正在同步上一批事件(启动时的 BOOT)时会跳过这一条遥测带来的
|
||||
# 「有新事件」,只推一次的话偶尔再也没人触发同步(服务器上 3 次里红过 1 次)。真板 1 s 一条,没有这个问题。
|
||||
def synced():
|
||||
sim.tick(now_ms())
|
||||
return len([e for e in c.get("/api/events").json() if e["type"] == 18]) == 2
|
||||
assert wait_for(synced)
|
||||
ev = {e["arg"]: e for e in c.get("/api/events").json() if e["type"] == 18}
|
||||
assert ev[3]["name"] == "固件升级(失败):哈希不对" and ev[3]["severe"] is True
|
||||
assert ev[5]["name"] == "固件升级(回滚)" and ev[5]["severe"] is True
|
||||
|
||||
在新工单中引用
屏蔽一个用户