diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md
index 979687f..79a8b7e 100644
--- a/DEPLOYMENT.md
+++ b/DEPLOYMENT.md
@@ -11,3 +11,11 @@
- 未重启容器,未修改 Frame Flow 配置或执行画框写入。API 令牌仅在服务器内读取并保存至 Console 敏感字段,未进入源码或报告。
停用插件可暂停新采集并保留设备与历史;不需要回滚 Console 核心。
+
+## v1.0.1 中文显示修复
+
+2026-10-17(Asia/Shanghai):为插件增加 console-ui-v1 卡片及中英文指标/告警标签,随 Console 语言切换。`living-room` 仅是内部资源标识,显示名称直接使用 API 上报的“渡口浪人的相框”;已清除临时显示别名,未修改 Frame Flow 或厂商名称、资源 ID 和历史归属。
+
+历史页补充 7 个指标的中英文译名;基于生产原镜像仅加入这一文件改动,运行镜像为 `pigway-device-console-manager:v2.8.2-frame-flow-i18n1`,原镜像保留。Console 容器已重建,其他服务未重启。数据库、旧插件、原 Compose 和 history.js 备份于 `/srv/pigway/apps/pigway-device-console/backups/frame-i18n-20261017-150652`。
+
+新包 SHA-256:`455b1ec5fb5d43aeb042528da0d13ebf1b5b214a2f56234038c4639cb0cac83c`。6 项 Python 测试通过;生产中英文与桌面/平板/手机共 6 次页面检查通过,无横向溢出及页面脚本错误;四台登记设备在线。
diff --git a/README.md b/README.md
index f3d230a..a8b5936 100644
--- a/README.md
+++ b/README.md
@@ -1,13 +1,13 @@
# PIGWay Console Adapter — Frame Flow
-独立 ZIP 插件 `pigway-frame-flow` v1.0.0,采用现有 Console adapter protocol v1,仅依赖 Python 标准库。源码在独立仓库 `pigway-console-adapter-frame-flow` 维护,无需修改 Console 核心或部署额外服务。
+独立 ZIP 插件 `pigway-frame-flow` v1.0.1,采用现有 Console adapter protocol v1,仅依赖 Python 标准库。源码在独立仓库 `pigway-console-adapter-frame-flow` 维护,无需修改 Console 核心或部署额外服务。
## 安装
-1. 在 Console「机器管理」上传 `dist/pigway-frame-flow-1.0.0.zip`,启用插件。
+1. 在 Console「机器管理」上传 `dist/pigway-frame-flow-1.0.1.zip`,启用插件。
2. 添加设备,选择 PIGWay Frame Flow,填写 Console 能访问的 Frame Flow HTTP(S) 根地址(不含 `/api/v1`)和 **Frame Flow API 令牌**,不是 KoKonna Key 或网页登录密码。
3. 若使用受限 API 客户端,授予 `devices:read`、`jobs:read`;默认 `FRAME_FLOW_TOKEN` 也可使用。令牌保存于 Console 敏感字段。跨不可信网络使用 HTTPS。
-4. 查看通用资源看板、告警与历史;默认每 60 秒采集一次。无需自定义前端。
+4. 查看通用资源看板、告警与历史;默认每 60 秒采集一次。卡片和告警随 Console 中英文切换,资源显示 API 上报名称或 Console 中设置的显示名称。
采集 `/health`、`/ready`、`/api/v1/devices`、`/api/v1/jobs`,不发送写请求、不刷新画框、不执行任务。设备接口可能按 Frame Flow 现有缓存规则查询厂商只读状态。
@@ -24,9 +24,11 @@ python3 -m venv .venv
.venv/bin/python -m pip install pytest ruff
.venv/bin/python -m pytest -q
mkdir -p dist
-.venv/bin/python package.py plugin dist/pigway-frame-flow-1.0.0.zip
+.venv/bin/python package.py plugin dist/pigway-frame-flow-1.0.1.zip
```
开发时可将 `PIGWAY_ADAPTER_PATH` 指向本目录 `plugin` 的绝对路径。ZIP 安装默认停用;生产安装与设备登记需在 Console 中执行。本地验证覆盖模拟接口、RPC 和隔离安装;生产部署记录见 [DEPLOYMENT.md](DEPLOYMENT.md)。
Frame Flow API 契约测试为可选集成验证,需要另行安装 Frame Flow 及其依赖。例如相邻项目保留现有虚拟环境时,执行 `PYTHONPATH=../pigway-frame-flow ../pigway-frame-flow/.venv/bin/python -m pytest -q`。独立环境未安装 Frame Flow 时跳过该项,其余测试正常运行。
+
+历史页的新增指标译名需 Console 配套支持(生产已安装)。仅安装插件也可正常采集并显示双语概览卡片。浏览器测试:安装 Playwright 后执行 `node tests/ui.cjs`。
diff --git a/plugin/manifest.json b/plugin/manifest.json
index 8ebee97..cb1db1b 100644
--- a/plugin/manifest.json
+++ b/plugin/manifest.json
@@ -1,7 +1,7 @@
{
"id": "pigway-frame-flow",
"name": "PIGWay Frame Flow",
- "version": "1.0.0",
+ "version": "1.0.1",
"protocol_version": 1,
"runtime": "python3",
"entrypoint": "worker.py",
@@ -30,5 +30,9 @@
"en": "Frame Flow API token"
}
}
+ },
+ "ui": {
+ "contract": "console-ui-v1",
+ "entrypoint": "ui/index.js"
}
}
diff --git a/plugin/ui/index.js b/plugin/ui/index.js
new file mode 100644
index 0000000..edd7dfb
--- /dev/null
+++ b/plugin/ui/index.js
@@ -0,0 +1,30 @@
+import { state } from '/assets/js/state.js';
+import { esc, pluginText as text } from '/assets/js/shared.js';
+
+const typeName = type => ({service: text('服务', 'Service'), device: text('画框', 'Frame'), job: text('任务', 'Job')}[type] || type);
+const metricValue = metric => {
+ if (metric.unit === 'percent') return `${metric.value}%`;
+ if (metric.unit === 'count') return `${metric.value} ${text('个', 'items')}`;
+ const values = {
+ service_ready: [text('未就绪', 'Not ready'), text('就绪', 'Ready')],
+ device_online: [text('离线', 'Offline'), text('在线', 'Online')],
+ job_enabled: [text('停用', 'Disabled'), text('启用', 'Enabled')],
+ last_run_success: [text('失败或待确认', 'Failed or unconfirmed'), text('成功', 'Succeeded')],
+ };
+ return values[metric.key]?.[metric.value] ?? String(metric.value);
+};
+
+export function hostCard(entry) {
+ const snapshot = entry.snapshot || {};
+ const resources = snapshot.resources || [];
+ const current = entry.dataCurrent === true;
+ const label = item => item.label?.[state.LANG] || item.key || item.id;
+ const name = id => resources.find(r => r.id === id)?.name || id;
+ const alertRows = (snapshot.alerts || []).map(alert => {
+ const separator = alert.id.indexOf(':');
+ const kind = alert.id.split(':')[0];
+ const resource = separator < 0 ? '' : `${kind.startsWith('JOB_') ? 'job' : 'device'}:${alert.id.slice(separator + 1)}`;
+ return `
${esc(label(alert))}${esc(resource ? name(resource) : '')}
`;
+ }).join('');
+ return `${esc(entry.host.name || 'Frame Flow')}
${current ? text('在线', 'Online') : text('状态未确认', 'Unconfirmed')}${!current ? `${text('以下为上次采集数据', 'Showing the last collected data')}
` : ''}${resources.map(resource => `${esc(resource.name || resource.id)} · ${esc(typeName(resource.type))}
${(snapshot.metrics || []).filter(m => m.resource_id === resource.id).map(metric => `
${esc(label(metric))}
${esc(metricValue(metric))}
`).join('')}
`).join('')}${text('当前告警', 'Current alerts')}
${alertRows || text('无告警', 'No alerts')}`;
+}
diff --git a/plugin/worker.py b/plugin/worker.py
index e9806e4..ba8e7de 100644
--- a/plugin/worker.py
+++ b/plugin/worker.py
@@ -16,6 +16,15 @@ OUTPUT_LOCK = threading.Lock()
DEVICE_LOCKS = {}
LOCK = threading.Lock()
MAX_MESSAGE = 16 * 1024 * 1024
+METRIC_LABELS = {
+ "service_ready": {"zh": "服务就绪", "en": "Service ready"},
+ "device_online": {"zh": "画框在线", "en": "Frame online"},
+ "battery_percent": {"zh": "电量", "en": "Battery"},
+ "job_enabled": {"zh": "任务启用", "en": "Job enabled"},
+ "last_run_success": {"zh": "最近运行成功", "en": "Last run succeeded"},
+ "device_count": {"zh": "画框数量", "en": "Frame count"},
+ "job_count": {"zh": "任务数量", "en": "Job count"},
+}
class AdapterError(Exception):
@@ -89,10 +98,28 @@ def collect(device):
alerts = []
def metric(rid, key, value, unit="boolean"):
- metrics.append({"resource_id": rid, "key": key, "value": value, "unit": unit})
+ metrics.append(
+ {
+ "resource_id": rid,
+ "key": key,
+ "value": value,
+ "unit": unit,
+ "label": METRIC_LABELS[key],
+ }
+ )
def alert(identifier):
- alerts.append({"id": identifier, "severity": 1})
+ kind = identifier.split(":", 1)[0]
+ labels = {
+ "SERVICE_NOT_READY": {"zh": "服务未就绪", "en": "Service not ready"},
+ "DEVICE_OFFLINE": {"zh": "画框离线", "en": "Frame offline"},
+ "JOB_FAILED": {"zh": "任务最近运行失败", "en": "Last job run failed"},
+ "JOB_UNKNOWN": {
+ "zh": "任务最近结果待确认",
+ "en": "Last job result unconfirmed",
+ },
+ }
+ alerts.append({"id": identifier, "severity": 1, "label": labels[kind]})
metric("service", "service_ready", int(ready["status"] == "ready"))
if ready["status"] != "ready":
diff --git a/pyproject.toml b/pyproject.toml
index 1415174..4f7d01c 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "pigway-console-adapter-frame-flow"
-version = "1.0.0"
+version = "1.0.1"
requires-python = ">=3.12"
dependencies = []
diff --git a/tests/test_adapter.py b/tests/test_adapter.py
index 1e07454..1f980a5 100644
--- a/tests/test_adapter.py
+++ b/tests/test_adapter.py
@@ -168,3 +168,13 @@ def test_current_frame_flow_api_contract(tmp_path):
m["key"] == "service_ready" and m["value"] == 1 for m in snapshot["metrics"]
)
assert any(r["type"] == "device" for r in snapshot["resources"])
+
+
+def test_metric_and_alert_labels_follow_console_language(endpoint):
+ device, _, _ = endpoint
+ snapshot = worker.collect(device)
+ assert all(set(metric["label"]) == {"zh", "en"} for metric in snapshot["metrics"])
+ assert all(set(alert["label"]) == {"zh", "en"} for alert in snapshot["alerts"])
+ frame = next(r for r in snapshot["resources"] if r["type"] == "device")
+ assert frame["id"] == "device:frame"
+ assert frame["name"] == "Frame"
diff --git a/tests/ui.cjs b/tests/ui.cjs
new file mode 100644
index 0000000..2516da0
--- /dev/null
+++ b/tests/ui.cjs
@@ -0,0 +1,27 @@
+const {chromium}=require('playwright');
+const fs=require('node:fs'),path=require('node:path'),assert=require('node:assert/strict');
+(async()=>{
+const browser=await chromium.launch({headless:true,channel:'chrome'});
+try {
+const source=fs.readFileSync(path.join(__dirname,'../plugin/ui/index.js'),'utf8').replace(/^import .*;\n/gm,'').replace('export function hostCard','function hostCard');
+const labels={service_ready:{zh:'服务就绪',en:'Service ready'},device_online:{zh:'画框在线',en:'Frame online'},battery_percent:{zh:'电量',en:'Battery'}};
+const entry={host:{name:'Frame Flow'},dataCurrent:true,snapshot:{resources:[{id:'service',name:'Frame Flow',type:'service'},{id:'device:living-room',name:'渡口浪人的相框',type:'device'}],metrics:[{resource_id:'service',key:'service_ready',value:1,unit:'boolean',label:labels.service_ready},{resource_id:'device:living-room',key:'device_online',value:1,unit:'boolean',label:labels.device_online},{resource_id:'device:living-room',key:'battery_percent',value:28,unit:'percent',label:labels.battery_percent}],alerts:[]}};
+for(const lang of ['zh','en'])for(const width of [1440,820,390]) {
+const page=await browser.newPage({viewport:{width,height:1000}});
+await page.goto('http://192.168.89.19/');
+const result=await page.evaluate(({source,entry,lang})=>{
+ const state={LANG:lang};const text=(zh,en)=>lang==='zh'?zh:en;
+ const esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
+ const html=eval(source+'\nhostCard(entry)');
+ document.body.innerHTML=`${html}`;
+ return {text:document.body.innerText,overflow:document.documentElement.scrollWidth>innerWidth};
+},{source,entry,lang});
+assert(result.text.includes('渡口浪人的相框'));
+assert(!result.text.includes('living-room'));
+assert(result.text.includes(lang==='zh'?'服务就绪':'Service ready'));
+assert.equal(result.overflow,false);
+await page.close();
+}
+console.log('6 browser checks passed');
+} finally {await browser.close()}
+})().catch(e=>{console.error(e);process.exit(1)});