机架管理前后端(rack/):FastAPI + SQLite + Vue3,经统一门户上线
- 后端 prrack:MQTT(phonerail-backend @ 127.0.0.1:1884)↔ 遥测入库 / 事件增量同步 / WebSocket 推送; 命令白名单 + 重传同 seq;审计记门户 X-Auth-User(只绑 127.0.0.1,main.py 拒绝非回环)。 - 自动恢复:只看电流(平线 / 过低,服务器够不到手机 adb),先长按电源再断电重启, 每小时限次后停手;默认逐台关闭。adb 探活接口 recovery.Prober 预留。 - 前端:总览 / 板子页(五路操作、电流电压曲线)/ 事件 / 操作记录 / 自动恢复;手机宽度可用。 - 部署 deploy/deploy.sh:服务器 Python 3.8 上先跑测试再替换;systemd phonerail-rack,每日备份。 - 门户应用 id 16、key rack(phonerail 已被需求文档站占用),授权 admin + staff。 测试 26 例(本机 3.14 / 服务器 3.8 均过)。线上板 b81f3fc9c8bc 在线,WS 经门户 101 + 1 条/s 遥测。 Co-Authored-By: Claude Opus 5.5 <[email protected]>
这个提交包含在:
@@ -0,0 +1,111 @@
|
||||
<template>
|
||||
<div class="ov">
|
||||
<section class="boards">
|
||||
<el-empty v-if="!boards.length" description="还没有板子上线。新板子经 USB 配好 wifi / mqtt 之后会自动出现在这里。" />
|
||||
<router-link v-for="b in boards" :key="b.id" :to="`/board/${b.id}`" class="card board">
|
||||
<div class="bh">
|
||||
<div>
|
||||
<div class="bname">{{ b.name || b.id }}</div>
|
||||
<div class="muted">{{ b.location || '未填位置' }} · {{ b.fw }}</div>
|
||||
</div>
|
||||
<el-tag v-if="!b.online || b.stale" type="info" size="small">通信中断 · {{ ago(b.last_tele) }}</el-tag>
|
||||
<el-tag v-else type="success" size="small">在线</el-tag>
|
||||
</div>
|
||||
<div class="slots">
|
||||
<div v-for="i in 5" :key="i" class="slot" :style="{ borderColor: color(b, i) }">
|
||||
<div class="sh">
|
||||
<span class="sn">{{ i }}</span>
|
||||
<span class="lbl">{{ phone(b, i).label }}</span>
|
||||
<el-icon v-if="phone(b, i).auto_recover" title="已开自动恢复"><Refresh /></el-icon>
|
||||
</div>
|
||||
<div class="val" :style="{ color: color(b, i) }">{{ health(b, i).text }}</div>
|
||||
<div class="num" v-if="rail(b, i)?.pr">{{ mA(rail(b, i)?.ua) }}<small> mA</small></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bf muted" v-if="b.status">
|
||||
输入 {{ V(b.status.in?.mv) }} V / {{ mA(b.status.in?.ua) }} mA ·
|
||||
温度 {{ temps(b) }} ·
|
||||
风扇 {{ b.status.fan?.duty / 10 }}%
|
||||
<span v-if="b.status.fan?.stall?.some(x => x)" class="bad"> · 风扇停转</span>
|
||||
<span v-if="b.status.derate" class="bad"> · 过温</span>
|
||||
</div>
|
||||
</router-link>
|
||||
</section>
|
||||
|
||||
<aside class="card feed">
|
||||
<div class="fh">实时动态</div>
|
||||
<div v-if="!state.feed.length" class="muted">暂无。事件、操作、自动恢复都会出现在这里。</div>
|
||||
<div v-for="(f, k) in state.feed" :key="k" class="fi">
|
||||
<span class="muted">{{ fmtTime(f.ts) }}</span>
|
||||
<span class="who">{{ boardName(f.board) }}</span>
|
||||
<template v-if="f.kind === 'event'">
|
||||
<span :class="{ bad: SEVERE.has(f.ev[3]) }">{{ f.ev[2] ? `#${f.ev[2]} ` : '' }}{{ evName(f.ev[3]) }}</span>
|
||||
</template>
|
||||
<template v-else-if="f.kind === 'audit'">
|
||||
<span>{{ f.row.user }}:#{{ f.row.rail ?? '' }} {{ f.row.request.cmd }} → {{ rspText(f.row.response) }}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="warn">自动恢复 #{{ f.row.rail }}:{{ RECOVERY_ZH[f.row.state] }}({{ REASON_ZH[f.row.reason] || f.row.reason }})</span>
|
||||
</template>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { Refresh } from '@element-plus/icons-vue'
|
||||
import { state } from '../store'
|
||||
import { rspText } from '../api'
|
||||
import { mA, V, C, ago, fmtTime, railHealth, LEVEL_COLOR, RECOVERY_ZH, REASON_ZH } from '../util'
|
||||
|
||||
// 与 backend/prrack/protocol.py 的 EVENT_TYPES / EVENT_SEVERE 同一张表(数字来自固件 evlog.h)
|
||||
const EV = ['', '板子上电', '事件丢失', '过压关路', '欠压关路', '反灌断 VBUS', '顶高断 VBUS', 'eFuse 故障',
|
||||
'PG 变化', 'I²C 失联', '过温降载', '风扇停转', '重启序列', '命令被拒', 'mux 失联全断', '刀片不在位', 'PD 限功率']
|
||||
const SEVERE = new Set([3, 4, 5, 6, 7, 9, 11, 14])
|
||||
const evName = t => EV[t] || `事件 ${t}`
|
||||
|
||||
const boards = computed(() => Object.values(state.boards).sort((a, b) =>
|
||||
(a.location + a.name + a.id).localeCompare(b.location + b.name + b.id)))
|
||||
const rail = (b, i) => b.status?.rails?.[i - 1]
|
||||
const phone = (b, i) => b.phones?.find(p => p.rail === i) || {}
|
||||
const health = (b, i) => railHealth(b, rail(b, i))
|
||||
const color = (b, i) => LEVEL_COLOR[health(b, i).level]
|
||||
const temps = b => (b.status.temp || []).map(t => (t == null ? '—' : C(t) + '°')).join(' / ')
|
||||
const boardName = id => state.boards[id]?.name || id
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ov { display: grid; grid-template-columns: 1fr 320px; gap: 16px; align-items: start; }
|
||||
.boards { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(420px, 100%), 1fr)); gap: 16px; }
|
||||
.board { display: block; color: inherit; text-decoration: none; transition: box-shadow .15s; }
|
||||
.board:hover { box-shadow: 0 4px 16px rgba(15, 23, 42, .08); }
|
||||
.bh { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 12px; }
|
||||
.bname { font-weight: 600; font-size: 16px; }
|
||||
.slots { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 8px; }
|
||||
.slot { border: 2px solid; border-radius: 8px; padding: 8px 6px; min-height: 86px; }
|
||||
.sh { display: flex; align-items: center; gap: 4px; font-size: 12px; color: var(--muted); }
|
||||
.sn { font-weight: 700; color: #0f172a; }
|
||||
.lbl { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }
|
||||
.val { font-size: 13px; font-weight: 600; margin-top: 6px; }
|
||||
.num { font-size: 18px; font-weight: 600; margin-top: 2px; font-variant-numeric: tabular-nums; }
|
||||
.num small { font-size: 11px; color: var(--muted); font-weight: 400; }
|
||||
.bf { margin-top: 10px; }
|
||||
.bad { color: #dc2626; } .warn { color: #d97706; }
|
||||
.feed { position: sticky; top: 68px; max-height: calc(100vh - 90px); overflow: auto; }
|
||||
.fh { font-weight: 600; margin-bottom: 8px; }
|
||||
.fi { font-size: 13px; padding: 6px 0; border-bottom: 1px dashed var(--line); display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.who { color: #334155; font-weight: 500; }
|
||||
@media (max-width: 1000px) {
|
||||
.ov { grid-template-columns: 1fr; }
|
||||
.feed { position: static; max-height: 360px; }
|
||||
}
|
||||
@media (max-width: 520px) {
|
||||
.boards { grid-template-columns: 1fr; }
|
||||
.slots { gap: 4px; }
|
||||
.slot { padding: 5px 3px; min-height: 72px; }
|
||||
.val { font-size: 12px; }
|
||||
.num { font-size: 14px; }
|
||||
.num small { display: none; }
|
||||
}
|
||||
</style>
|
||||
在新工单中引用
屏蔽一个用户