From 41f1e224c4be847ed691e8e7d5efdecb942e73f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B8=A1=E5=8F=A3=E6=B5=AA=E4=BA=BA?= Date: Sun, 18 Oct 2026 23:58:37 +0800 Subject: [PATCH] fix: add Frame Flow management link and connection settings --- README.md | 6 ++- README.zh-CN.md | 8 ++-- RELEASE_NOTES.md | 8 ++-- SHA256SUMS | 14 +++--- plugin/manifest.json | 10 ++++- plugin/ui/index.js | 57 +++++++++++++++++++++++- pyproject.toml | 2 +- tests/ui.cjs | 104 +++++++++++++++++++++++++++++-------------- 8 files changed, 157 insertions(+), 52 deletions(-) diff --git a/README.md b/README.md index 4b1f593..cd62ae5 100644 --- a/README.md +++ b/README.md @@ -27,11 +27,13 @@ For development, set `PIGWAY_ADAPTER_PATH` to this repository's absolute `plugin ## Build -Current adapter version: **1.0.3**. +Current adapter version: **1.0.4**. ```sh mkdir -p dist -python3 package.py plugin dist/pigway-frame-flow-1.0.3.zip +python3 package.py plugin dist/pigway-frame-flow-1.0.4.zip ``` [Documentation](docs/README.md) + +Use System configuration → Frame Flow → Frame Flow connection to edit the service URL, API token and optional management URL. The overview link defaults to the service URL. A blank token preserves the existing credential. diff --git a/README.zh-CN.md b/README.zh-CN.md index 1345622..f9f6d6b 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -2,11 +2,11 @@ 简体中文 | [English](README.md) -独立 ZIP 插件 `pigway-frame-flow` v1.0.3,采用现有 Console adapter protocol v1,仅依赖 Python 标准库。源码在独立仓库 `pigway-console-adapter-frame-flow` 维护,无需修改 Console 核心或部署额外服务。 +独立 ZIP 插件 `pigway-frame-flow` v1.0.4,采用现有 Console adapter protocol v1,仅依赖 Python 标准库。源码在独立仓库 `pigway-console-adapter-frame-flow` 维护,无需修改 Console 核心或部署额外服务。 ## 安装 -1. 在 Console「机器管理」上传 `dist/pigway-frame-flow-1.0.3.zip`,启用插件。 +1. 在 Console「机器管理」上传 `dist/pigway-frame-flow-1.0.4.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 秒采集一次。卡片和告警随 Console 中英文切换,资源显示 API 上报名称或 Console 中设置的显示名称。 @@ -26,7 +26,7 @@ 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.3.zip +.venv/bin/python package.py plugin dist/pigway-frame-flow-1.0.4.zip ``` 开发时可将 `PIGWAY_ADAPTER_PATH` 指向本目录 `plugin` 的绝对路径。ZIP 安装默认停用;生产安装与设备登记需在 Console 中执行。本地验证覆盖模拟接口、RPC 和隔离安装。 @@ -36,3 +36,5 @@ Frame Flow API 契约测试为可选集成验证,需要另行安装 Frame Flow 历史页的新增指标译名需 Console 配套支持(生产已安装)。仅安装插件也可正常采集并显示双语概览卡片。浏览器测试:安装 Playwright 后执行 `node tests/ui.cjs`。 [文档目录](docs/README.md) + +系统配置 → 选择 Frame Flow → Frame Flow 接入,可修改服务地址、API 令牌和管理入口。首页标题旁的链接默认打开服务地址;管理入口可单独设置,令牌留空保留现有值。 diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 76d6ca8..6dd7fe2 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,5 +1,7 @@ -# pigway-console-adapter-frame-flow v1.0.3 +# pigway-console-adapter-frame-flow v1.0.4 -统一开源许可证、贡献和安全反馈入口;完善当前版本的文档导航、源码分发和校验和。插件 ZIP 携带许可证。运行功能边界保持项目首页所述。 +Frame Flow 首页卡片新增管理入口链接,默认打开已登记的服务地址;可在「系统配置 → 选择 Frame Flow → Frame Flow 接入」单独设置管理入口。 -本轮 6 项 Python 测试通过。源码语法、文档入口、打包和校验和检查通过;生产运行状态以实际部署验收为准。 +新增服务地址、API 令牌和管理入口配置页。令牌留空保留原值;管理入口留空恢复服务地址。链接只允许不带账号密码的 HTTP(S) 地址。采集能力与只读边界不变。 + +6 项 Python 测试通过;12 组中英文、深浅主题和桌面/平板/手机浏览器检查通过,并验证默认链接、自定义入口、恢复默认、保存失败和无效 URL 拦截。 diff --git a/SHA256SUMS b/SHA256SUMS index 46f9f70..08d4d89 100644 --- a/SHA256SUMS +++ b/SHA256SUMS @@ -2,16 +2,16 @@ 469e82c2812830629864510971466868e94f5e973c7abf9c3f674753a7fae187 .gitignore d2705ae14671873d176e9a306b82566bafe9b7c4504cb2396bc027867e60af71 CONTRIBUTING.md ea01675553f3737ca0f0cefb9459566a7cab8a44f545ec3bdda0a17144681dcd LICENSE -281a088726d22cf31f0cfca312289cb1e6d2edd59ad17daa1930aa3e49337796 README.md -03ecbd2b6710097aa01f54a82123c72c7aaebe478dbf49385184d9be55018280 README.zh-CN.md -2c25fdab1918da78bb51eac81ef8359df0434b736afdde8f296519ee797b682a RELEASE_NOTES.md +f581bd3a145e7be4434915233236bd61e557f1eb268a580d210b37f852d99634 README.md +716162ae46314bc3ba502214fcf9eeb5d621578ea6305c840d7620d86ba9bb9d README.zh-CN.md +2df5c048c1a44f729c1a8be804a62162af1d8ff349d5aeb3c9bd03339d7c639d RELEASE_NOTES.md 6cead6c1378cd840f655adc3d467e2cd6fd3ab368d7200a3afbfe85d9ed159be SECURITY.md 43d322e37914b73c4d0ca9974137b540bf7ad92afa5d168e22fc278c7eaedf90 docs/README.md 416ab1f006fae6d448603e7812f26b196296b8999c8555245bce8b7e36b87fe0 package.py ea01675553f3737ca0f0cefb9459566a7cab8a44f545ec3bdda0a17144681dcd plugin/LICENSE -a590a3b3bde8712ee2abe0c49e30f736a4dd40e6487fb708b9b24a0b44371d9d plugin/manifest.json -a4f8b2f66e2ae19386ac62edfd2285890a4f48336e7a791098584f53a7b05a64 plugin/ui/index.js +d32ba37c71a2417ea99f53a461c6c319c1529d71323c58d0b6d3ba3114585484 plugin/manifest.json +eebc45e8d2306d35e8175aad426436414944e5d8b55184928f812c7fd9804d5b plugin/ui/index.js 9cb8ae3c212c73b3205a6ba352c15685b83a4f4add88c9b2b493147314dad93c plugin/worker.py -721d1d363229b7fee62065ccff86677e5e12c778ef0adfc2de152bc7b35858e2 pyproject.toml +6a8cec7b18ca801f5721c4a331e2886858064ba15130b1aa5a1072df7be89b79 pyproject.toml a427151c6e34c78fb0239bdb2f26d5bad685dae37e012477c5b61ec7a8cbb523 tests/test_adapter.py -69d4bdd8b4395cfceff573d981e475cd74eadb946c71532e0d9fe084ae8682e3 tests/ui.cjs +96a3d9616999aecbf6ae4d3409f02d0d61cc9b292db3e4fafc0da6a8586b251f tests/ui.cjs diff --git a/plugin/manifest.json b/plugin/manifest.json index 79e696c..833ec3e 100644 --- a/plugin/manifest.json +++ b/plugin/manifest.json @@ -1,7 +1,7 @@ { "id": "pigway-frame-flow", "name": "PIGWay Frame Flow", - "version": "1.0.3", + "version": "1.0.4", "protocol_version": 1, "runtime": "python3", "entrypoint": "worker.py", @@ -29,6 +29,14 @@ "zh": "Frame Flow API 令牌", "en": "Frame Flow API token" } + }, + "management_url": { + "type": "string", + "format": "http-origin", + "label": { + "zh": "管理入口地址(留空使用服务地址)", + "en": "Management URL (blank uses service URL)" + } } }, "ui": { diff --git a/plugin/ui/index.js b/plugin/ui/index.js index 1e149f2..205eee0 100644 --- a/plugin/ui/index.js +++ b/plugin/ui/index.js @@ -1,6 +1,17 @@ import { scopeAlertHealth, summaryStatus } from '/assets/js/overview-health.js'; import { state } from '/assets/js/state.js'; -import { esc, pluginText as text } from '/assets/js/shared.js'; +import { $, esc, pluginText as text } from '/assets/js/shared.js'; + +import { get } from '/assets/js/navigation.js'; + +function managementLink(host) { + try { + const url = new URL(host.connection?.management_url || host.connection?.url || host.url); + if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) return ''; + const label = text('打开 Frame Flow 管理', 'Open Frame Flow management'); + return ``; + } catch { return ''; } +} const metricValue = metric => { if (metric.unit === 'percent') return `${metric.value}%`; @@ -46,5 +57,47 @@ export function hostCard(entry) { }); }).join(''); const frames = resources.filter(resource => resource.type === 'device'); - return `

${esc(entry.host.name || 'Frame Flow')}

${frames.map(frame => `${esc(frame.name || text('画框', 'Frame'))}`).join('')}
${current ? text('在线', 'Online') : text('状态未确认', 'Unconfirmed')}
${!current ? `

${text('以下为上次采集数据', 'Showing the last collected data')}

` : ''}
${cards}
${(snapshot.alerts || []).length || !current || snapshot.alerts_complete !== true ? `
${text('已读取范围内的告警', 'Alerts within monitored scope')}${summaryStatus(scopeAlertHealth(snapshot.alerts, snapshot.alerts_complete, current), state.LANG)}
${alertRows || (current && snapshot.alerts_complete ? text('无告警', 'No alerts') : text('状态未确认', 'Unconfirmed'))}
` : ""}
`; + return `

${esc(entry.host.name || 'Frame Flow')}

${managementLink(entry.host)}
${frames.map(frame => `${esc(frame.name || text('画框', 'Frame'))}`).join('')}
${current ? text('在线', 'Online') : text('状态未确认', 'Unconfirmed')}
${!current ? `

${text('以下为上次采集数据', 'Showing the last collected data')}

` : ''}
${cards}
${(snapshot.alerts || []).length || !current || snapshot.alerts_complete !== true ? `
${text('已读取范围内的告警', 'Alerts within monitored scope')}${summaryStatus(scopeAlertHealth(snapshot.alerts, snapshot.alerts_complete, current), state.LANG)}
${alertRows || (current && snapshot.alerts_complete ? text('无告警', 'No alerts') : text('状态未确认', 'Unconfirmed'))}
` : ""}
`; +} + + +export function renderConfig() { + const host = state.fleetHosts.find(h => h.id === state.currentConfigHost); + const connection = host.connection || {}; + $('mainSavebar').classList.add('hidden'); + $('configMenu').innerHTML = ``; + $('configRows').innerHTML = `

${text('Frame Flow 接入', 'Frame Flow connection')}

${text('配置保存在 Console;管理入口留空时,首页链接使用 Frame Flow 服务地址。API 令牌留空保留现有值。', 'Settings are stored in Console. Leave the management URL blank to use the Frame Flow service URL on the overview. A blank API token keeps the existing value.')}

${[ + ['url', text('Frame Flow 地址', 'Frame Flow URL'), connection.url || host.url || ''], + ['token', text('API 令牌(留空保留)', 'API token (blank keeps existing)'), ''], + ['management_url', text('管理入口地址(留空使用服务地址)', 'Management URL (blank uses service URL)'), connection.management_url || ''], + ].map(([key, label, value]) => `
`).join('')}
`; + const page = $('configRows').querySelector('[data-config-page="frame-flow"]'); + $('configMenu').onclick = event => { + if (!event.target.closest('[data-config-section="frame-flow"]')) return; + $('configMenu').querySelectorAll('button').forEach(b => b.classList.toggle('active', b.dataset.configSection === 'frame-flow')); + $('configRows').querySelectorAll('[data-config-page]').forEach(p => p.classList.toggle('active', p === page)); + $('mainSavebar').classList.add('hidden'); + }; + $('saveFrameFlowConnection').onclick = async () => { + const button = $('saveFrameFlowConnection'), message = $('frameFlowConnectionMessage'); + const values = Object.fromEntries([...page.querySelectorAll('[data-frame-flow-connection]')].map(input => [input.dataset.frameFlowConnection, input.value.trim()])); + for (const [key, value] of Object.entries(values)) { + if (key === 'token' || (key === 'management_url' && !value)) continue; + try { + const url = new URL(value); + if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.pathname !== '/' || url.search || url.hash) throw Error(); + } catch { + message.textContent = text('请使用不含路径或账号密码的 HTTP(S) 根地址', 'Use an HTTP(S) origin without paths or credentials'); + message.className = 'error'; return; + } + } + button.disabled = true; + try { + const result = await get('/api/v1/hosts', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ operation: 'save', id: host.id, adapter_id: host.adapter_id, connection: values }) }); + state.fleetHosts = result.hosts; + page.querySelector('[data-frame-flow-connection="token"]').value = ''; + message.textContent = text('已保存', 'Saved'); message.className = 'success'; + } catch (error) { message.textContent = error.message; message.className = 'error'; } + finally { button.disabled = false; } + }; } diff --git a/pyproject.toml b/pyproject.toml index b2fdcc4..9c840bc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "pigway-console-adapter-frame-flow" -version = "1.0.3" +version = "1.0.4" requires-python = ">=3.12" dependencies = [] readme = "README.md" diff --git a/tests/ui.cjs b/tests/ui.cjs index 4f52aa6..83ab44e 100644 --- a/tests/ui.cjs +++ b/tests/ui.cjs @@ -1,35 +1,73 @@ -const {chromium}=require('playwright'); +const {chromium}=require(process.env.PLAYWRIGHT_MODULE||'playwright'); const fs=require('node:fs'),path=require('node:path'),assert=require('node:assert/strict'); +const root=path.resolve(__dirname,'..'),consoleRoot=path.resolve(root,'../pigway-device-console'); +const manifest=JSON.parse(fs.readFileSync(path.join(root,'plugin/manifest.json'))); (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:[]}}; -entry.snapshot.resources.push({id:'job:slideshow',name:'家庭照片轮播',type:'job'}); -entry.snapshot.metrics.push({resource_id:'job:slideshow',key:'job_enabled',value:1,unit:'boolean'},{resource_id:'job:slideshow',key:'last_run_success',value:1,unit:'boolean'}); -for(const lang of ['zh','en'])for(const width of [1440,820,390]) { -const page=await browser.newPage({bypassCSP:true,viewport:{width,height:1000}}); -await page.goto('http://192.168.89.19/'); -const result=await page.evaluate(async ({source,entry,lang})=>{ - const {scopeAlertHealth,summaryStatus}=await import('/assets/js/overview-health.js'); - 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,subtitle:document.querySelector(".host-head .device-details")?.textContent,nested:document.querySelectorAll(".group .metric, .metric .metric").length,status:document.querySelector(".metric [data-semantic]")?.textContent}; -},{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); -assert.equal(result.nested,0); -assert.equal(result.subtitle,'渡口浪人的相框'); -assert.equal(result.status,lang==='zh'?'正常':'Normal'); -assert(result.text.includes(lang==='zh'?'轮播任务':'Slideshow task')); -assert(result.text.includes('家庭照片轮播 · '+(lang==='zh'?'启用':'Enabled'))); -await page.close(); -} -console.log('6 browser checks passed'); -} finally {await browser.close()} -})().catch(e=>{console.error(e);process.exit(1)}); + const browser=await chromium.launch({headless:true}); + try{ + for(const lang of ['zh','en'])for(const theme of ['light','dark'])for(const width of [1440,820,390]){ + let host={id:'frame-service',name:'Frame Flow',adapter_id:manifest.id,category:'host',adapter_enabled:true,capabilities:['status'],url:'https://frames.example.test',connection:{url:'https://frames.example.test'}}; + let writes=[],failSave=false; + const page=await browser.newPage({viewport:{width,height:1000}}),errors=[]; + page.on('pageerror',error=>errors.push(error.message)); + await page.addInitScript(({lang,theme})=>{localStorage.setItem('pigwayLanguage',lang);localStorage.setItem('pigwayTheme',theme)},{lang,theme}); + await page.route('http://console.test/**',route=>{ + const p=new URL(route.request().url()).pathname; + if(p.startsWith('/api/')){ + if(route.request().method()==='PUT'){ + assert.equal(p,'/api/v1/hosts');const body=route.request().postDataJSON();writes.push(body); + assert.equal(body.operation,'save');assert.equal(body.adapter_id,manifest.id);assert.equal(body.connection.token,''); + if(failSave)return route.fulfill({status:500,json:{error:'Save failed'}}); + host={...host,url:body.connection.url,connection:{...body.connection}}; + return route.fulfill({json:{hosts:[host]}}); + } + const values={ + '/api/v1/auth/status':{authenticated:true,setup_required:false}, + '/api/v1/hosts':{hosts:[host]}, + '/api/v1/adapters':{adapters:[{...manifest,enabled:true,state:'ready'}]}, + '/api/v1/fleet/status':{hosts:[{host,online:true,snapshot:{identity:{},resources:[{id:'frame',name:'Living room frame',type:'device'},{id:'job:slideshow',name:'Slideshow',type:'job'}],metrics:[{resource_id:'job:slideshow',key:'job_enabled',value:1},{resource_id:'job:slideshow',key:'last_run_success',value:1}],alerts:[],alerts_complete:true}}]}, + }; + return route.fulfill({json:values[p]||{}}); + } + const file=p.startsWith('/adapter-assets/')?path.join(root,'plugin/ui/index.js'):path.join(consoleRoot,'web',p==='/'?'index.html':p.replace(/^\/assets\//,'')); + if(!fs.existsSync(file))return route.fulfill({status:404}); + return route.fulfill({contentType:file.endsWith('.js')?'text/javascript':file.endsWith('.css')?'text/css':'text/html',body:fs.readFileSync(file)}); + }); + const link=()=>page.locator('#hostCards .host-title a'); + const config=async()=>{ + await page.locator('[data-tab="config"]').click(); + await page.locator('[data-config-section="frame-flow"]').click(); + await page.locator('#saveFrameFlowConnection').waitFor(); + }; + await page.goto('http://console.test');await link().waitFor(); + assert.equal(await link().getAttribute('href'),'https://frames.example.test/'); + assert.equal(await link().getAttribute('target'),'_blank'); + assert.equal(await link().getAttribute('rel'),'noopener noreferrer'); + assert.equal(await link().getAttribute('aria-label'),lang==='zh'?'打开 Frame Flow 管理':'Open Frame Flow management'); + assert.equal(await page.locator('#hostCards .device-details').textContent(),'Living room frame'); + await config(); + assert.equal(await page.locator('[data-frame-flow-connection="url"]').inputValue(),'https://frames.example.test'); + assert.equal(await page.locator('[data-frame-flow-connection="token"]').inputValue(),''); + assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1)); + if(lang==='zh'&&theme==='light')await page.locator('#config').screenshot({path:'/tmp/frame-flow-config-'+width+'.png'}); + if(lang==='zh'&&theme==='light'&&width===1440){ + const management=page.locator('[data-frame-flow-connection="management_url"]'); + await management.fill('javascript:alert(1)');await page.locator('#saveFrameFlowConnection').click(); + await page.locator('#frameFlowConnectionMessage.error').waitFor();assert.equal(writes.length,0); + await management.fill('https://manage.example.test');await page.locator('#saveFrameFlowConnection').click(); + await page.locator('#frameFlowConnectionMessage.success').waitFor();assert.equal(writes.length,1); + await page.reload();await link().waitFor();assert.equal(await link().getAttribute('href'),'https://manage.example.test/'); + await config();failSave=true; + await page.locator('[data-frame-flow-connection="management_url"]').fill('https://changed.example.test');await page.locator('#saveFrameFlowConnection').click(); + await page.getByText('Save failed',{exact:true}).waitFor(); + assert.equal(await page.locator('[data-frame-flow-connection="management_url"]').inputValue(),'https://changed.example.test'); + failSave=false;await page.locator('[data-frame-flow-connection="management_url"]').fill('');await page.locator('#saveFrameFlowConnection').click(); + await page.locator('#frameFlowConnectionMessage.success').waitFor();await page.reload();await link().waitFor(); + assert.equal(await link().getAttribute('href'),'https://frames.example.test/'); + host.connection.management_url='javascript:alert(1)';await page.reload();await page.locator('#hostCards h2').waitFor();assert.equal(await link().count(),0); + } + assert.deepEqual(errors,[]);await page.close(); + } + console.log('PASS Frame Flow management link/configuration: default/custom/reset URL, save failure, unsafe URLs, API-only edits; 12 language/theme/viewport combinations'); + }finally{await browser.close();} +})().catch(error=>{console.error(error);process.exit(1)});