From 9e1b9cf17991b446151cad34ae32011e1da7247d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B8=A1=E5=8F=A3=E6=B5=AA=E4=BA=BA?= Date: Thu, 15 Oct 2026 23:30:11 +0800 Subject: [PATCH] feat: unify Raspberry Pi disk presentation and manual discovery --- README.md | 4 +++ RELEASE_NOTES.md | 7 +++++ STORAGE-DISCOVERY.md | 52 +++++++++++++++++++++++++++++++++++ plugin/manifest.json | 3 +- plugin/ui/config.js | 2 ++ plugin/ui/overview.js | 3 +- plugin/ui/storage.js | 20 ++++++++++++++ plugin/worker.py | 20 ++++++++++++-- tests/test_storage_metrics.py | 25 +++++++++++++++++ 9 files changed, 132 insertions(+), 4 deletions(-) create mode 100644 RELEASE_NOTES.md create mode 100644 STORAGE-DISCOVERY.md create mode 100644 plugin/ui/storage.js create mode 100644 tests/test_storage_metrics.py diff --git a/README.md b/README.md index 4dd119b..ecb5410 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,9 @@ # PIGWay Raspberry Pi Console Plugin +磁盘呈现统一版本已上线,需要 Console v2.7.0:默认按设备返回顺序,同组支持上移/下移、显示名称及隐藏,所有显示偏好保存到 Console,首页与配置页共用。Agent 的名称和隐藏定义继续作为设备默认值保留。扫描仍只由配置页按钮触发。生产验证见相邻 Console 的 docs/STORAGE-PRESENTATION-v2.7.0-2026-10-15.md。 + +当前 v1.6.0 支持统一磁盘显示设置与手动扫描,需要 Console v2.7.0、Agent v3.11.1;新磁盘仅手动发现,已知磁盘容量继续刷新。见 [生产验证](STORAGE-DISCOVERY.md)。 + 独立维护的树莓派接入插件:连接现有 PIGWay Raspberry Pi Agent,不在树莓派安装新进程。设备 API、状态转换、配置字段、服务/进程监控、硬件界面及其翻译均由本仓库维护。 依赖 Python 标准库;接入 Console JSON-RPC v1 与可信前端扩展 `console-ui-v1`。插件 ID 保持 `pigway-agent`,已有设备可直接继续使用。 diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md new file mode 100644 index 0000000..14198de --- /dev/null +++ b/RELEASE_NOTES.md @@ -0,0 +1,7 @@ +# Raspberry Pi Console Adapter v1.6.1 + +新增磁盘手动发现,设备卡片与配置页统一采用Console保存的排序、显示名称和隐藏设置。分区容量继续排列在公共指标网格,挂载路径放在提示中,避免孤立斜杠。 + +沿用独立插件pigway-agent及原设备记录、服务/进程和硬件插件控制。支持Agent3.11.2提供的容量周更元数据,建议Console2.7.5。 + +验证:4项指标测试;三设备生产页面和中英文桌面/平板/手机遍历通过,原Pi插件1.6.1保持在线,无新增扫描或协议更改。 diff --git a/STORAGE-DISCOVERY.md b/STORAGE-DISCOVERY.md new file mode 100644 index 0000000..aae367c --- /dev/null +++ b/STORAGE-DISCOVERY.md @@ -0,0 +1,52 @@ +# 磁盘手动扫描(2026-10-15) + +后续显示层已升级为 Console v2.7.0 / Ubuntu 插件 v1.5.0 / Raspberry Pi 插件 v1.6.0 / Synology 插件 v1.9.0:默认原样使用设备返回顺序,取消 NAS 特殊倒序和优先级。显示名称、同组排序、隐藏统一保存到 Console,首页与配置共用,支持恢复默认顺序。设备已有名称/隐藏定义保留为默认上报值;本篇后文“名称保存在 Agent”等描述为当时的设备定义行为,不代表最新 Console 显示偏好的存储位置。最新验收见相邻 pigway-web-manager/docs/STORAGE-PRESENTATION-v2.7.0-2026-10-15.md;所有实际扫描行为保持手动。 + +## 已上线状态 + +Ubuntu Agent v1.3.1 / Console 插件 v1.4.1,Raspberry Pi Agent v3.11.1 / 插件 v1.5.1,Synology 插件 v1.8.1 已上线。两台 Agent 各识别两个实际分区,监控/API 服务 active,循环错误 0。 + +三种设备在线;中英文及 1440/820/390 三种屏宽的生产界面通过。设备名称、顺序和隐藏设置保留。Console 重启、页面加载及浏览器验收前后,NAS storage_observed_at 未改变,本次没有额外扫描 NAS 硬盘。尚未插入新物理磁盘,也未进行休眠/唤醒实测。 + +用户最终确认“三种设备都手动发现新增磁盘”。此前自动发现与 NAS 每 5 分钟扫描的候选方案已替换。 + +## 使用 + +Ubuntu、树莓派:Console → 系统配置 → 选择设备 → 磁盘设置 → 扫描磁盘。扫描后重新打开该页查看新磁盘,概览随正常轮询更新。名称和隐藏设置保存到 Agent,隐藏不停止历史采集。 + +NAS:与其他设备一样在配置 → 磁盘设置中点击“扫描磁盘”,首页只展示数据。磁盘清单、容量、温度、健康状态仅在手动扫描时更新;页面保留采集时间。CPU、内存和网络等快速采集继续工作。已有卷别名仍由 Console 保存。 + +v1.4.1 / v1.5.1 / v1.8.1 统一配置页扫描入口,保存按钮上方保留 20px 间距,扫描与保存反馈分别显示。按用户最终要求,NAS 每个机箱内硬盘采用 DSM 返回顺序反转;存储空间同样反转,主空间和 SSD 空间优先。生产三种屏宽、中英文已验证入口、间距、倒序及优先级;NAS 存储采集时间未改变。前两次发布因验收脚本固定计入网络卡片位置而自动回退,修正为直接验证存储卡片后通过。 + +扫描有 60 秒冷却,NAS 在途请求合并。页面加载、普通刷新、定时轮询和重启不会重新枚举磁盘。部署时执行过一次明确的初始扫描以保存现有 Agent 磁盘清单。 + +## 实现与边界 + +认证 PUT /api/v1/storage/scan 调用 lsblk,读取宿主机挂载点,将块设备清单保存到对应配置旁的 .storage.json(0600)。监控服务只读取清单;已知磁盘挂载和容量每 30 秒更新,新增磁盘必须手动扫描。根分区原有指标和告警保留兼容。 + +UUID 优先作为标识,其次为设备序列号和分区编号,再退回路径并标记 stable_identity=false。展示可用叶设备,去除重复挂载及 loop/swap 等伪设备。未挂载设备不伪造使用率。配置过的条目在后续扫描未发现时保留为断开;扫描失败保留原清单。 + +不执行挂载、格式化、分区或 SMART 指令。本次没有增加每磁盘断开/容量告警。 + +Agent 手工配置使用 status.system.storage_inventory[].id 的 32 位十六进制 key: + +```ini +[storage_names] +0123456789abcdef0123456789abcdef = 媒体盘 +[storage_visible] +0123456789abcdef0123456789abcdef = true +``` + +NAS 普通 collect 不再调度存储任务,只有 device.refresh(group=storage) 触发慢查询。重启复用上次摘要;过旧健康数据标为未确认,不确认旧告警恢复。只展示 DSM hdd_info / vol_info 上报的硬盘、扩展柜和卷,未验证任意 USB 外接盘是否均由接口提供。 + +## 验证和备份 + +两种 Agent 覆盖挂载点转义、bind 去重、伪设备、未挂载状态、UUID 稳定性、百分号名称、隐藏设置、手动发现和重启后禁止自动枚举。两种插件的中英文/三屏宽测试覆盖 0% 使用率、名称转义、隐藏恢复和显式扫描;打开设置不会扫描。NAS 15 项 Python 回归及六种浏览器布局通过。 + +生产备份: + +- Ubuntu:/var/backups/pigway-ubuntu-agent-storage-1792043168 +- Raspberry Pi:/var/backups/pigway-pi-control-storage-1792043209 +- Console 容器:/data/storage-release-backups/1792043974(旧插件与 SQLite 备份) + +ops/deploy-storage.py 检查 SHA-256 清单、备份三个程序文件与配置,仅重启两个项目服务;验证失败恢复原文件。Console 更新验证三个插件版本、全部设备在线、显示设置保留、NAS 未额外扫描,并执行生产浏览器检查。 diff --git a/plugin/manifest.json b/plugin/manifest.json index 7bc023d..483ff63 100644 --- a/plugin/manifest.json +++ b/plugin/manifest.json @@ -1,11 +1,12 @@ { "id": "pigway-agent", "name": "PIGWay Raspberry Pi Agent", - "version": "1.4.0", + "version": "1.6.1", "protocol_version": 1, "entrypoint": "worker.py", "runtime": "python3", "capabilities": [ + "refresh", "status", "logs", "config", diff --git a/plugin/ui/config.js b/plugin/ui/config.js index c7db661..dc5949c 100644 --- a/plugin/ui/config.js +++ b/plugin/ui/config.js @@ -1,4 +1,5 @@ import { state as appState } from "/assets/js/state.js"; +import { addStorageSettings } from "./storage.js"; import { renderDevice } from "./device.js"; import { renderHosts } from "/assets/js/devices.js"; import { @@ -345,6 +346,7 @@ export function renderConfig() { .forEach((b) => (b.onclick = () => deleteHost(b.dataset.deleteHost))); bindBulkSync(); updateConditional(); + addStorageSettings(); } export function markChanged() { let n = 0; diff --git a/plugin/ui/overview.js b/plugin/ui/overview.js index 1e71e86..2d1898f 100644 --- a/plugin/ui/overview.js +++ b/plugin/ui/overview.js @@ -1,4 +1,5 @@ import { state as appState } from "/assets/js/state.js"; +import { storageCards } from "./storage.js"; import { esc } from "/assets/js/shared.js"; import { tr } from "/assets/js/i18n.js"; import { bytes, formatUptime, meter } from "/assets/js/overview.js"; @@ -22,5 +23,5 @@ export function agentHostCard(entry) { diskTotal = y.disk_total_bytes; const percent = n => Number.isFinite(n) ? `${n.toFixed(1)}%` : "--"; const progress = n => Number.isFinite(n) ? meter(n) : ""; - return `

${esc(m.name || h.name || m.id)}

${esc(formatUptime(s.uptime_seconds))}
ID ${esc(m.id || h.id)}${appState.LANG === "zh" ? "主机名" : "Hostname"} ${esc(m.hostname || v.hostname || "--")}${esc(v.manufacturer || "")} ${esc(v.model || "--")}${appState.LANG === "zh" ? "序列号" : "Serial"} ${esc(v.serial || "--")}${appState.LANG === "zh" ? "硬件版本" : "Revision"} ${esc(v.revision || "--")}${esc(v.os || "--")}${appState.LANG === "zh" ? "内核" : "Kernel"} ${esc(v.kernel || "--")}${esc(v.architecture || "--")} · CPU ${esc(v.cpu_count || "--")}${appState.LANG === "zh" ? "程序版本" : "Agent"} ${esc(s.version || "--")}
${tr("在线")}
${tr("处理器负载")}
${percent(y.cpu_percent)}
${progress(y.cpu_percent)}
${appState.LANG === "zh" ? "频率" : "Frequency"} ${esc(y.cpu_freq_mhz)} MHz · ${esc(y.temperature_c)}°C${fanMetric(s.plugins || {})}
${tr("内存占用")}
${percent(y.memory_percent)}
${progress(y.memory_percent)}
${bytes(memUsed)} / ${bytes(memTotal)} · ${appState.LANG === "zh" ? "可用" : "Available"} ${bytes(y.memory_available_bytes)}
${tr("磁盘占用")}
${percent(y.disk_percent)}
${progress(y.disk_percent)}
${bytes(diskUsed)} / ${bytes(diskTotal)} · ${appState.LANG === "zh" ? "剩余" : "Free"} ${bytes(y.disk_free_bytes)}
${tr("主网络")}
↓ ${bytes(n.rx_bytes_per_second)}/s · ↑ ${bytes(n.tx_bytes_per_second)}/s
${esc(n.interface || "--")} · ${n.kind === "WIF" ? `${esc(n.metric)} dBm · ` : n.link_speed_mbps ? `${esc(n.link_speed_mbps)} Mbps · ` : ""}${esc(n.ip_label)} ${esc(n.ip)} · ${n.link === true ? (appState.LANG === "zh" ? "已连接" : "Connected") : n.link === false ? (appState.LANG === "zh" ? "已断开" : "Disconnected") : (appState.LANG === "zh" ? "状态未确认" : "Unconfirmed")}
${tr("显示状态")}
${esc(ownerName(d.owner))}
${appState.LANG === "zh" ? "告警状态独立于硬件插件" : "Alert state is independent of hardware plugins"}
${tr("当前告警")}
${(d.alerts || []).length}
${(d.alerts || []).map((a) => esc(ownerName(a.id))).join(" · ") || tr("无告警")}
${pluginSummary(s.plugins || {}, h.id)}

${tr("服务与进程监控")}

${(s.watches || []).map((w) => `
${esc(w.name)} · ${esc(w.target)}${tr(w.up ? "正常" : "停止")}
`).join("") || `
${tr("暂无数据")}
`}
`; + return `

${esc(m.name || h.name || m.id)}

${esc(formatUptime(s.uptime_seconds))}
ID ${esc(m.id || h.id)}${appState.LANG === "zh" ? "主机名" : "Hostname"} ${esc(m.hostname || v.hostname || "--")}${esc(v.manufacturer || "")} ${esc(v.model || "--")}${appState.LANG === "zh" ? "序列号" : "Serial"} ${esc(v.serial || "--")}${appState.LANG === "zh" ? "硬件版本" : "Revision"} ${esc(v.revision || "--")}${esc(v.os || "--")}${appState.LANG === "zh" ? "内核" : "Kernel"} ${esc(v.kernel || "--")}${esc(v.architecture || "--")} · CPU ${esc(v.cpu_count || "--")}${appState.LANG === "zh" ? "程序版本" : "Agent"} ${esc(s.version || "--")}
${tr("在线")}
${tr("处理器负载")}
${percent(y.cpu_percent)}
${progress(y.cpu_percent)}
${appState.LANG === "zh" ? "频率" : "Frequency"} ${esc(y.cpu_freq_mhz)} MHz · ${esc(y.temperature_c)}°C${fanMetric(s.plugins || {})}
${tr("内存占用")}
${percent(y.memory_percent)}
${progress(y.memory_percent)}
${bytes(memUsed)} / ${bytes(memTotal)} · ${appState.LANG === "zh" ? "可用" : "Available"} ${bytes(y.memory_available_bytes)}
${Array.isArray(y.storage_inventory) ? storageCards(y,entry.snapshot?.resources) : `
${tr("磁盘占用")}
${percent(y.disk_percent)}
${progress(y.disk_percent)}
${bytes(diskUsed)} / ${bytes(diskTotal)} · ${appState.LANG === "zh" ? "剩余" : "Free"} ${bytes(y.disk_free_bytes)}
`}
${tr("主网络")}
↓ ${bytes(n.rx_bytes_per_second)}/s · ↑ ${bytes(n.tx_bytes_per_second)}/s
${esc(n.interface || "--")} · ${n.kind === "WIF" ? `${esc(n.metric)} dBm · ` : n.link_speed_mbps ? `${esc(n.link_speed_mbps)} Mbps · ` : ""}${esc(n.ip_label)} ${esc(n.ip)} · ${n.link === true ? (appState.LANG === "zh" ? "已连接" : "Connected") : n.link === false ? (appState.LANG === "zh" ? "已断开" : "Disconnected") : (appState.LANG === "zh" ? "状态未确认" : "Unconfirmed")}
${tr("显示状态")}
${esc(ownerName(d.owner))}
${appState.LANG === "zh" ? "告警状态独立于硬件插件" : "Alert state is independent of hardware plugins"}
${tr("当前告警")}
${(d.alerts || []).length}
${(d.alerts || []).map((a) => esc(ownerName(a.id))).join(" · ") || tr("无告警")}
${pluginSummary(s.plugins || {}, h.id)}

${tr("服务与进程监控")}

${(s.watches || []).map((w) => `
${esc(w.name)} · ${esc(w.target)}${tr(w.up ? "正常" : "停止")}
`).join("") || `
${tr("暂无数据")}
`}
`; } diff --git a/plugin/ui/storage.js b/plugin/ui/storage.js new file mode 100644 index 0000000..9d5c4bc --- /dev/null +++ b/plugin/ui/storage.js @@ -0,0 +1,20 @@ +import { esc, pluginText as text } from "/assets/js/shared.js"; +import { bytes, meter } from "/assets/js/overview.js"; +const states = {mounted:["已挂载","Mounted"],unmounted:["未挂载","Unmounted"],missing:["设备未连接","Device disconnected"],unknown:["状态未确认","Unconfirmed"]}; +export function storageCards(system, resources) { + if(!Array.isArray(system.storage_inventory))return ""; + const inventory=new Map(system.storage_inventory.map(d=>["storage-"+d.id,d])); + const presented=resources?.filter(r=>inventory.has(r.id)); + const disks=presented?.length?presented.map(r=>({...inventory.get(r.id),name:r.name,visible:!r.hidden})):system.storage_inventory; + return disks.filter(d=>d.visible!==false).map(d=>{ + const percent=Number.isFinite(d.used_percent)&&d.state==="mounted"?d.used_percent:null; + const label=text(...(states[d.state]||states.unknown)); + const detail=percent===null?label:`${bytes(d.used_bytes)} / ${bytes(d.capacity_bytes)} · ${text("剩余","Free")} ${bytes(d.free_bytes)}`; + const location=d.mountpoint?`${text("挂载点","Mount point")}: ${d.mountpoint}`:d.device?`${text("设备","Device")}: ${d.device}`:""; + return `
${esc(d.name)}
${percent===null?esc(label):percent.toFixed(1)+"%"}
${percent===null?"":meter(percent)}
${esc(detail)}
`; + }).join(""); +} +export async function addStorageSettings() { + const editor=await import("/assets/js/storage-presentation.js"); + return editor.installStoragePresentation(); +} diff --git a/plugin/worker.py b/plugin/worker.py index 298e35b..a2e3d2c 100644 --- a/plugin/worker.py +++ b/plugin/worker.py @@ -96,14 +96,27 @@ def collect(device): at = source.get('storage_observed_at') if metric['key'].startswith('disk_') else source.get('observed_at') if metric['key'] != 'uptime_seconds' else value.get('timestamp') if isinstance(at, (int, float)) and not isinstance(at, bool) and math.isfinite(at) and at > 0: metric['observed_at'] = at + resources = [{'id': 'host', 'type': 'host'}] + for disk in value['system'].get('storage_inventory', []): + rid = 'storage-' + disk['id'] + resources.append({'id': rid, 'type': 'filesystem' if disk.get('filesystem') else 'disk', 'parent_id': 'host', + 'name': disk['name'], 'state': disk['state'], + 'details': {'device': disk.get('device', ''), 'mountpoint': disk.get('mountpoint', ''), + 'visible': disk.get('visible', True)}}) + if disk['state'] == 'mounted' and not value['system'].get('storage_inventory_error'): + for key, unit in [('capacity_bytes', 'bytes'), ('used_bytes', 'bytes'), ('free_bytes', 'bytes'), ('used_percent', 'percent')]: + number = disk.get(key) + if isinstance(number, (int, float)) and not isinstance(number, bool) and math.isfinite(number): + metrics.append({'resource_id': rid, 'key': key, 'value': number, 'unit': unit, + 'observed_at': value['system']['storage_inventory_observed_at']}) return {'schema_version': 1, 'observed_at': value.get('timestamp'), 'identity': {'reported_name': value['machine'].get('name'), 'category': 'host', 'details': value.get('device', {})}, 'collection': {**declaration, 'interval_seconds': collection_interval(value)}, 'capabilities': MANIFEST['capabilities'], 'metrics': metrics, - 'resources': [{'id': 'host', 'type': 'host'}], + 'resources': resources, 'alerts': value.get('display', {}).get('alerts', []), - 'alerts_complete': isinstance(value.get('display', {}).get('alerts'), list) and not storage_plan.get('error',False), + 'alerts_complete': isinstance(value.get('display', {}).get('alerts'), list) and not storage_plan.get('error',False) and not value['system'].get('storage_inventory_error', False), 'view': value} @@ -118,6 +131,9 @@ def dispatch(method, params): return request(device, '/health') if method == 'device.collect': return collect(device) + if method == 'device.refresh': + if params.get('group') != 'storage':raise AdapterError('UNSUPPORTED') + return request(device, '/storage/scan', 'PUT', {}) if method == 'device.logs.query': return request(device, '/logs', query=params.get('query')) if method == 'device.read': diff --git a/tests/test_storage_metrics.py b/tests/test_storage_metrics.py new file mode 100644 index 0000000..fb84c8c --- /dev/null +++ b/tests/test_storage_metrics.py @@ -0,0 +1,25 @@ +import sys +import unittest +from pathlib import Path +from unittest.mock import patch +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "plugin")) +import worker + + +class StorageMetricsTests(unittest.TestCase): + def test_per_disk_history_has_own_observation_and_keeps_hidden_collection(self): + value = {"timestamp": 100, "machine": {}, "display": {"alerts": []}, "system": { + "storage_inventory_observed_at": 90, "storage_inventory_error": False, + "storage_inventory": [ + {"id": "a"*32, "name": "Data", "filesystem": "ext4", "visible": False, "state": "mounted", "capacity_bytes": 1000, "used_bytes": 0, "free_bytes": 1000, "used_percent": 0}, + {"id": "b"*32, "name": "USB", "state": "unmounted", "capacity_bytes": 2000}, + ]}} + with patch.object(worker, "request", return_value=value): + result = worker.collect({}) + self.assertEqual(len(result["resources"]), 3) + self.assertEqual(len(result["metrics"]), 4) + self.assertTrue(all(m["observed_at"] == 90 for m in result["metrics"])) + self.assertEqual(result["metrics"][1]["value"], 0) + value["system"]["storage_inventory_error"] = True + with patch.object(worker, "request", return_value=value): + self.assertEqual(worker.collect({})["metrics"], [])