From 0e746f961c26e4d8ff130ce8625a069f1c3d077c Mon Sep 17 00:00:00 2001 From: chick Date: Tue, 7 Jul 2026 23:33:27 +0800 Subject: [PATCH] Refocus dashboard on device monitoring --- public/app.js | 209 ++++++++++++++++++++++++++------------- public/index.html | 22 ++--- public/styles.css | 4 + server/core.js | 35 ++++++- test/server-core.test.js | 42 ++++++-- test/ui-contract.test.js | 26 +++++ 6 files changed, 247 insertions(+), 91 deletions(-) create mode 100644 test/ui-contract.test.js diff --git a/public/app.js b/public/app.js index 3b8fdf5..e2341e3 100644 --- a/public/app.js +++ b/public/app.js @@ -99,6 +99,77 @@ function proxiedUrl(endpoint) { return `/api/proxy/${encodeURIComponent(activeId)}${normalized}` } +function firstValue(...items) { + for (const item of items) if (item !== undefined && item !== null && item !== '') return item + return null +} +function formatBytes(n) { + if (n === undefined || n === null || n === '') return '-' + const num = Number(n) + if (!Number.isFinite(num)) return value(n) + const units = ['B', 'KB', 'MB', 'GB', 'TB'] + let size = Math.abs(num) + let i = 0 + while (size >= 1024 && i < units.length - 1) { size /= 1024; i += 1 } + const signed = num < 0 ? -size : size + return `${signed.toFixed(size >= 10 || i === 0 ? 0 : 1)} ${units[i]}` +} +function formatSpeed(speed) { + if (!speed || typeof speed !== 'object') return '-' + if (Array.isArray(speed.interfaces)) { + return speed.interfaces.map(item => `${item.interface || 'net'} ↓ ${formatBytes(firstValue(item.rx_bytes_per_sec, item.rx, item.download))}/s · ↑ ${formatBytes(firstValue(item.tx_bytes_per_sec, item.tx, item.upload))}/s`).join(' | ') + } + const rx = firstValue(speed.rx, speed.download, speed.recv, speed.received, speed.rx_bytes_per_sec) + const tx = firstValue(speed.tx, speed.upload, speed.sent, speed.tx_bytes_per_sec) + return `↓ ${formatBytes(rx)}/s · ↑ ${formatBytes(tx)}/s` +} +function formatTemperature(temp) { + if (temp === undefined || temp === null || temp === '') return '-' + if (typeof temp === 'number') return `${temp}℃` + if (typeof temp === 'string') return temp.includes('°') || temp.includes('℃') ? temp : `${temp}℃` + if (Array.isArray(temp)) { + return temp.map(item => `${item.type || item.zone || 'temp'}: ${value(item.temperature)}℃`).join(' | ') || '-' + } + if (typeof temp === 'object') { + return Object.entries(temp).map(([k, v]) => `${k}: ${typeof v === 'number' ? `${v}℃` : value(v)}`).join(' · ') || '-' + } + return value(temp) +} +function formatPercentPair(obj) { + if (!obj || typeof obj !== 'object') return '-' + if (Array.isArray(obj)) { + return obj.map(item => `${item.mount_point || item.name || 'disk'} ${formatPercentPair(item)}`).join(' | ') + } + const used = Number(firstValue(obj.used, obj.used_bytes)) + const total = Number(firstValue(obj.total, obj.total_bytes)) + const percent = Number(firstValue(obj.used_percent, obj.percent)) + if (Number.isFinite(used) && Number.isFinite(total) && total > 0) return `${formatBytes(used)} / ${formatBytes(total)} (${Math.round(Number.isFinite(percent) ? percent : used / total * 100)}%)` + const available = firstValue(obj.available, obj.available_bytes, obj.free, obj.free_bytes) + if (available != null && total) return `可用 ${formatBytes(available)} / 总 ${formatBytes(total)}` + return Object.entries(obj).map(([k, v]) => `${k}: ${typeof v === 'number' ? formatBytes(v) : value(v)}`).join(' · ') || '-' +} +function formatUptime(seconds) { + if (seconds && typeof seconds === 'object') return seconds.uptime_formatted || seconds.formatted || formatUptime(seconds.uptime_seconds) + const n = Number(seconds) + if (!Number.isFinite(n)) return value(seconds) + const d = Math.floor(n / 86400) + const h = Math.floor((n % 86400) / 3600) + const m = Math.floor((n % 3600) / 60) + return [d ? `${d}天` : '', h ? `${h}小时` : '', `${m}分`].filter(Boolean).join(' ') +} +function metricCard(label, val, tone = '') { + return `
${escapeHtml(label)}${escapeHtml(value(val))}
` +} +function detailCard(title, rows, tone = '') { + return `

${escapeHtml(title)}

${rows.map(([k, v]) => `
${escapeHtml(k)}
${escapeHtml(value(v))}
`).join('')}
` +} +function getMonitorData() { + const item = activeInstance() + const s = activeStatus() + const sum = s?.summary || {} + return { item, s, sum, device: sum.device || {}, sim: sum.sim || {}, network: sum.network || {}, data: sum.data || {}, sms: sum.sms || {}, ota: sum.ota || {}, system: sum.system || {} } +} + function renderFleetStats() { const total = instances.length const counts = { online: 0, auth: 0, offline: 0, unknown: 0 } @@ -137,7 +208,8 @@ function renderInstances() { const sum = s?.summary || {} const network = sum.network || {} const sim = sum.sim || {} - return `
+ const system = sum.system || {} + return `
${escapeHtml(item.name || item.id)}
@@ -145,17 +217,16 @@ function renderInstances() {
${escapeHtml(statusText(s))}
+
+ 信号${escapeHtml(value(network.signal || sim.signal))} + 温度${escapeHtml(formatTemperature(system.temperature))} + 速率${escapeHtml(formatSpeed(system.networkSpeed))} + SIM${escapeHtml(sim.present === false ? '未插卡' : value(sim.iccid))} +
- ${item.description ? `

${escapeHtml(item.description)}

` : ''} -
- 认证${item.auth?.hasPassword ? '密码会话' : '无密码'} - 运营商${escapeHtml(value(network.operator || sim.operator))} - 网络${escapeHtml(value(network.accessTechnology || network.registration))} - ICCID${escapeHtml(value(sim.iccid))} -
` }).join('') $$('.instance-card', root).forEach(card => card.addEventListener('click', (event) => { @@ -167,85 +238,85 @@ function renderInstances() { } function renderHero() { - const item = activeInstance() - const s = activeStatus() - const sum = s?.summary || {} + const { item, s, device, sim, network, system, data, ota } = getMonitorData() if (!item) { - $('activeKicker').textContent = 'NO DEVICE SELECTED' - $('activeName').textContent = '选择一个 SimAdmin 实例' - $('activeMeta').textContent = '统一查看设备、SIM、网络、短信、eSIM、OTA 与原始管理页面。' + $('activeKicker').textContent = 'DEVICE MONITOR' + $('activeName').textContent = '选择一个设备查看实时状态' + $('activeMeta').textContent = '主页面展示各设备的状态、温度、流量、SIM、信号、系统资源与短信统计。' $('signalPanel').innerHTML = [ - ['状态', '等待选择'], ['当前实例', '-'], ['会话', '-'], ['刷新', '-'], + ['状态', '等待选择'], ['设备', '-'], ['温度', '-'], ['流量速率', '-'], ].map(([k, v]) => `
${k}${v}
`).join('') return } - $('activeKicker').textContent = item.id - $('activeName').textContent = item.name || item.id - $('activeMeta').textContent = item.description ? `${item.description} · ${item.url}` : item.url + $('activeKicker').textContent = `${item.id} · ${statusText(s)}` + $('activeName').textContent = `${item.name || item.id} 设备监控` + $('activeMeta').textContent = `${device.manufacturer || ''} ${device.model || ''}`.trim() || item.url $('signalPanel').innerHTML = [ - ['状态', statusText(s)], - ['设备', value(sum.device?.model || sum.device?.name)], - ['运营商', value(sum.network?.operator || sum.sim?.operator)], - ['网络', value(sum.network?.accessTechnology || sum.network?.registration)], - ['短信', sum.sms ? `总 ${value(sum.sms.total)} / 未读 ${value(sum.sms.unread)}` : '-'], - ['版本', value(sum.ota?.currentVersion || sum.device?.firmwareVersion)], - ].map(([k, v]) => `
${escapeHtml(k)}${escapeHtml(v)}
`).join('') + ['在线状态', statusText(s)], + ['SIM / 运营商', `${sim.present === false ? '未插卡' : '已插卡'} · ${value(network.operator || sim.operator)}`], + ['信号 / 制式', `${value(network.signal || sim.signal)} · ${value(network.accessTechnology || network.registration)}`], + ['温度', formatTemperature(system.temperature)], + ['流量速率', formatSpeed(system.networkSpeed)], + ['数据连接', firstValue(data.active, data.connected, data.enabled) === true ? '已连接' : firstValue(data.active, data.connected, data.enabled) === false ? '未连接' : '-'], + ['系统负载', Array.isArray(system.cpuLoad) ? system.cpuLoad.join(' / ') : value(system.cpuLoad)], + ['版本', value(ota.currentVersion || device.firmware || device.revision)], + ].map(([k, v]) => `
${escapeHtml(k)}${escapeHtml(value(v))}
`).join('') } function renderOverview() { - const item = activeInstance() - const s = activeStatus() + const { item, s, device, sim, network, data, sms, system } = getMonitorData() if (!item) { - $('overview').innerHTML = '
提示先选择左侧设备
' + $('overview').innerHTML = [ + metricCard('设备', `${instances.length} 台`), + metricCard('在线', [...statuses.values()].filter(x => statusKind(x) === 'online').length), + metricCard('温度', '选择设备后显示'), + metricCard('流量', '选择设备后显示'), + ].join('') renderHero() + renderDeviceDetails() return } - const sum = s?.summary || {} - const metrics = [ - ['状态', s ? (s.reachable ? (s.authenticated === false ? '在线/未登录' : '在线') : `离线`) : '未知'], - ['延迟', s?.latencyMs != null ? `${s.latencyMs}ms` : '-'], - ['型号', sum.device?.model || sum.device?.name], - ['IMEI', sum.device?.imei], - ['ICCID', sum.sim?.iccid], - ['号码', sum.sim?.phoneNumber || sum.sim?.msisdn], - ['运营商', sum.network?.operator || sum.sim?.operator], - ['网络', sum.network?.accessTechnology || sum.network?.registration], - ['数据连接', sum.data ? `${boolText(sum.data.enabled)} / ${boolText(sum.data.connected)}` : '-'], - ['短信', sum.sms ? `总 ${value(sum.sms.total)} / 未读 ${value(sum.sms.unread)}` : '-'], - ] - $('overview').innerHTML = metrics.map(([k, v]) => `
${escapeHtml(k)}${escapeHtml(value(v))}
`).join('') + const dataState = firstValue(data.active, data.connected, data.enabled) + $('overview').innerHTML = [ + metricCard('状态', s ? (s.reachable ? (s.authenticated === false ? '在线/未登录' : '在线') : '离线') : '未知', pillClass(s)), + metricCard('延迟', s?.latencyMs != null ? `${s.latencyMs}ms` : '-'), + metricCard('信号', firstValue(network.signal, sim.signal), 'signal'), + metricCard('温度', formatTemperature(system.temperature), 'temperature'), + metricCard('流量速率', formatSpeed(system.networkSpeed), 'traffic'), + metricCard('数据连接', dataState === true ? '已连接' : dataState === false ? '未连接' : '-'), + metricCard('SIM', sim.present === false ? '未插卡' : value(sim.iccid)), + metricCard('短信', sms.total != null ? `总 ${sms.total} / 收 ${value(sms.incoming)} / 发 ${value(sms.outgoing)}` : '-'), + ].join('') renderHero() + renderDeviceDetails() } -function renderModuleTabs() { - const tabs = [{ id: 'all', title: '全部' }, ...modules.map(m => ({ id: m.id, title: m.title.split(/[ /]/)[0] }))] - $('moduleTabs').innerHTML = tabs.map(t => ``).join('') - $$('button[data-module]', $('moduleTabs')).forEach(btn => btn.addEventListener('click', () => { - activeModule = btn.dataset.module - renderFeatures() - })) +function renderDeviceDetails() { + const { item, device, sim, network, data, sms, ota, system, s } = getMonitorData() + if (!item) { + $('featureGrid').innerHTML = `

设备监控大屏

选择左侧任一设备后,这里会按分类展示真实设备信息、温度、流量、系统资源、SIM 与短信状态。

` + return + } + $('featureGrid').innerHTML = [ + detailCard('设备信息', [ + ['名称', item.name || item.id], ['地址', item.url], ['型号', device.model], ['厂商', device.manufacturer], ['IMEI', device.imei], ['电源', boolText(device.powered)], ['在线', boolText(device.online)], ['固件/版本', ota.currentVersion || device.firmware || device.revision], ['Commit', ota.currentCommit], + ], 'device'), + detailCard('SIM 卡', [ + ['状态', sim.present === false ? '未插卡' : sim.present === true ? '已插卡' : '-'], ['ICCID', sim.iccid], ['IMSI', sim.imsi], ['号码', sim.phoneNumber], ['短信中心', sim.smsCenter], ['MCC/MNC', [network.mcc || sim.mcc, network.mnc || sim.mnc].filter(Boolean).join('/') || '-'], + ], 'sim'), + detailCard('蜂窝网络', [ + ['运营商', network.operator || sim.operator], ['注册状态', network.registration], ['网络制式', network.accessTechnology], ['信号强度', firstValue(network.signal, sim.signal)], ['数据连接', firstValue(data.active, data.connected, data.enabled) === true ? '已连接' : firstValue(data.active, data.connected, data.enabled) === false ? '未连接' : '-'], ['漫游', boolText(data.roaming)], + ], 'network'), + detailCard('温度 / 系统', [ + ['温度', formatTemperature(system.temperature)], ['CPU 负载', Array.isArray(system.cpuLoad) ? system.cpuLoad.join(' / ') : system.cpuLoad], ['内存', formatPercentPair(system.memory)], ['磁盘', formatPercentPair(system.disk)], ['运行时间', formatUptime(system.uptime)], ['系统', system.info ? Object.values(system.info).filter(Boolean).join(' · ') : '-'], + ], 'system'), + detailCard('流量 / 短信', [ + ['实时速率', formatSpeed(system.networkSpeed)], ['短信总数', sms.total], ['接收短信', sms.incoming], ['发送短信', sms.outgoing], ['推送成功', sms.pushed], ['通话记录', s?.summary?.calls?.calls?.length ?? '-'], + ], 'traffic'), + ].join('') } -function renderFeatures() { - renderModuleTabs() - const disabled = activeId ? '' : ' disabled' - const visible = activeModule === 'all' ? modules : modules.filter(m => m.id === activeModule) - $('featureGrid').innerHTML = visible.map(m => `
-
-

${escapeHtml(m.id)}

${escapeHtml(m.title)}

- ${m.endpoints.length} API -
-

${escapeHtml(m.desc)}

- ${m.tone === 'danger' ? '
包含写操作/网络切换类接口,执行前确认目标设备。
' : ''} -
${m.endpoints.map(ep => `${escapeHtml(ep)}`).join('')}
-
`).join('') - $$('button[data-ep]', $('featureGrid')).forEach(btn => btn.addEventListener('click', () => { - switchView('api') - $('endpointInput').value = btn.dataset.ep - $('methodSelect').value = inferMethod(btn.dataset.ep) - runEndpoint() - })) -} +function renderFeatures() { renderDeviceDetails() } function renderEndpointList() { const groups = catalog.length ? catalog : modules.map(m => ({ id: m.id, name: m.title, endpoints: m.endpoints })) diff --git a/public/index.html b/public/index.html index f0c2e76..62edc1c 100644 --- a/public/index.html +++ b/public/index.html @@ -62,7 +62,7 @@

NO ISLAND SELECTED

选择一个 SimAdmin 小岛

-

查看设备、SIM、网络、短信、eSIM、OTA 与原始管理页面。

+

集中展示设备信息、联网状态、SIM、信号、温度、流量、系统资源、短信与版本。

@@ -71,26 +71,26 @@ -
-
-
小岛告示牌
+
+
+
实时状态
-
+
-
+
-
功能应用
-
+
设备详情
+

按设备、SIM、蜂窝网络、系统资源、温度、流量和短信分类展示真实状态。

-
+
diff --git a/public/styles.css b/public/styles.css index 2a45317..803edb2 100644 --- a/public/styles.css +++ b/public/styles.css @@ -17,3 +17,7 @@ .workspace-hero{padding:22px 24px;display:flex;align-items:center;justify-content:space-between;gap:18px}.eyebrow{margin:0 0 6px;color:#8a7b66;font-size:12px;font-weight:900;letter-spacing:.08em;text-transform:uppercase}h1,h2,h3,p{margin:0}h1{font-size:34px;line-height:1.05;font-weight:900;color:#794f27;letter-spacing:.02em}.workspace-hero p:last-child{margin-top:8px;color:#725d42;line-height:1.5}.hero-actions{display:flex;gap:10px;white-space:nowrap}.phone-dock{display:grid;grid-template-columns:repeat(4,112px);gap:14px}.phone-app{height:96px;border:0;border-radius:36px;color:#fff;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:6px;font-weight:900;transition:all .25s cubic-bezier(.4,0,.2,1);box-shadow:0 6px 0 rgba(61,52,40,.12)}.phone-app:hover{transform:translateY(-2px) rotate(-1deg)}.phone-app:active{transform:translateY(2px);box-shadow:0 2px 0 rgba(61,52,40,.12)}.phone-app.active{outline:4px solid rgba(255,204,0,.55)}.phone-app span{font-size:26px}.phone-app b{font-size:13px}.app-teal{background:#82d5bb}.app-yellow{background:#f7cd67;color:#725d42}.app-blue{background:#889df0}.app-green{background:#8ac68a}.view{display:none;min-height:0;flex:1;overflow:auto}.view.active{display:block}.dashboard-view{display:none}.dashboard-view.active{display:grid;grid-template-columns:minmax(280px,.82fr) minmax(0,1.18fr);grid-template-rows:auto minmax(0,1fr);gap:16px}.signal-board{padding:18px}.signal-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:10px;margin-top:18px}.signal-item,.metric{border-radius:20px;background:rgba(255,255,255,.56);border:2px solid rgba(232,226,214,.8);padding:13px}.signal-item span,.metric span{display:block;color:#9f927d;font-size:12px;font-weight:800}.signal-item b,.metric b{display:block;margin-top:6px;color:#794f27;font-size:18px;font-weight:900;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.nook-metrics{display:grid;grid-template-columns:repeat(2,minmax(140px,1fr));gap:12px}.module-board{grid-column:1/-1;padding:18px;min-height:0}.board-head{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:18px}.app-grid{display:grid;grid-template-columns:repeat(4,minmax(170px,1fr));gap:14px}.feature{min-height:168px;border-radius:34px;padding:16px;color:#fff;transition:all .25s cubic-bezier(.4,0,.2,1);box-shadow:0 5px 0 rgba(61,52,40,.13);overflow:hidden}.feature:nth-child(8n+1){background:#82d5bb}.feature:nth-child(8n+2){background:#f7cd67;color:#725d42}.feature:nth-child(8n+3){background:#889df0}.feature:nth-child(8n+4){background:#f8a6b2}.feature:nth-child(8n+5){background:#8ac68a}.feature:nth-child(8n+6){background:#e59266}.feature:nth-child(8n+7){background:#b77dee}.feature:nth-child(8n+8){background:#d1da49;color:#3d5a1a}.feature:hover{transform:translateY(-3px) rotate(-.6deg)}.feature-head{display:flex;align-items:flex-start;justify-content:space-between;gap:10px}.feature .eyebrow{color:inherit;opacity:.78}.feature h4{margin:0;font-size:18px;font-weight:900}.feature p{margin-top:8px;font-size:13px;line-height:1.45;color:inherit;opacity:.9}.endpoint-pills{display:flex;flex-wrap:wrap;gap:6px;margin-top:13px}.endpoint-pills button{border:2px solid rgba(255,255,255,.55);border-radius:16px;background:rgba(255,255,255,.28);color:inherit;font-size:11px;font-weight:800;padding:5px 7px}.endpoint-pills button:hover{background:rgba(255,255,255,.45)}.danger-note{margin-top:10px;font-size:12px;font-weight:800;color:#fff8e3}.api-layout.active{display:grid;grid-template-columns:330px minmax(0,1fr);gap:16px}.api-catalog,.api-console{padding:18px;min-height:0}.api-catalog{overflow:auto}.endpoint-group{margin:16px 0}.endpoint-group h4{margin:0 0 8px;font-size:14px;font-weight:900;color:inherit}.endpoint-row{display:block;width:100%;text-align:left;border:2px solid rgba(255,255,255,.7);border-radius:18px;background:rgba(255,255,255,.45);color:inherit;font-size:12px;font-weight:800;padding:9px 11px;margin-bottom:7px}.endpoint-row:hover,.endpoint-row.active{background:#fff9e3;border-color:#19c8b9;color:#794f27}.api-console{display:flex;flex-direction:column;gap:12px}.console-toolbar{display:flex;gap:10px}.console-toolbar select{width:110px}.console-toolbar input{flex:1}input,select,textarea{border:2.5px solid #c4b89e;background:rgb(247,243,223);border-radius:50px;padding:11px 15px;color:#725d42;font-weight:700}textarea{border-radius:20px}.request-body{min-height:96px;resize:vertical}.raw-output{flex:1;min-height:410px;margin:0;border:2px solid #d4c4a8;border-radius:20px;background:#fff8e7;color:#725d42;padding:16px;overflow:auto;font:600 12px/1.6 var(--animal-font-family)}.rawpage-view.active{display:flex;flex-direction:column;gap:12px}.frame-toolbar{padding:14px 16px;font-weight:700}.frame{width:100%;flex:1;min-height:580px;border:2px solid #d4c4a8;border-radius:24px;background:white}.empty-state{padding:42px}.mini-empty{padding:18px;text-align:center}.empty-state h3{margin-top:18px;font-size:24px;color:#794f27}.mini-empty h3{margin-top:0;font-size:18px}.empty-state p{margin-top:8px;color:#725d42}dialog{border:0;background:transparent}dialog::backdrop{background:rgba(61,52,40,.35);backdrop-filter:blur(3px)}.login-card,.instance-form{width:min(520px,90vw);padding:24px}.login-card h3{margin-top:18px;font-size:24px;color:#794f27}.login-card p,.form-help{margin-top:8px;color:#725d42;line-height:1.5}.login-card input{width:100%;margin:16px 0}.login-card menu,.form-menu{display:flex;justify-content:flex-end;gap:10px;margin:0;padding:0}.instance-form{max-height:88vh;overflow:auto}.instance-form label{display:block;margin-top:13px;color:#794f27;font-size:13px;font-weight:900}.instance-form input,.instance-form textarea{display:block;width:100%;margin-top:6px}.instance-form textarea{min-height:78px;resize:vertical}.form-menu{margin-top:18px}.form-menu .danger-btn{margin-right:auto}.toast{position:fixed;right:22px;bottom:22px;border:2px solid #d4c4a8;border-radius:20px;background:rgb(247,243,223);color:#794f27;padding:13px 16px;font-weight:800;box-shadow:var(--animal-shadow-lg);z-index:10}.compact .island-shell{gap:14px;padding:14px}.compact .feature{min-height:134px;padding:12px;border-radius:26px}.compact .workspace-hero{padding:16px}.compact .phone-app{height:78px;border-radius:28px}.compact .app-grid{gap:10px} @media(max-width:1260px){.island-shell{grid-template-columns:340px minmax(0,1fr)}.app-grid{grid-template-columns:repeat(3,1fr)}.dashboard-view.active{grid-template-columns:1fr}.nook-metrics{grid-template-columns:repeat(4,1fr)}.phone-dock{grid-template-columns:repeat(4,96px)}}@media(max-width:900px){body{overflow:auto}.island-shell{height:auto;grid-template-columns:1fr;padding:14px}.nook-sidebar{max-height:none}.nook-workspace{overflow:visible}.workspace-hero{flex-direction:column;align-items:flex-start}.phone-dock{grid-template-columns:repeat(4,1fr)}.dashboard-view.active,.api-layout.active{grid-template-columns:1fr}.app-grid,.nook-metrics{grid-template-columns:1fr 1fr}}@media(max-width:560px){.wallet-row,.signal-grid,.app-grid,.nook-metrics{grid-template-columns:1fr}.sidebar-actions,.phone-dock{grid-template-columns:1fr 1fr}.hero-actions{flex-direction:column;width:100%}.hero-actions .animal-btn{width:100%}} @media(prefers-reduced-motion:reduce){*,*:before,*:after{animation:none!important;transition:none!important}} + + +/* Device monitoring rebuild: keep API as secondary, make dashboard data-first. */ +.device-dashboard.active{grid-template-columns:minmax(320px,.72fr) minmax(0,1.28fr);grid-template-rows:auto minmax(0,1fr);align-items:stretch}.monitor-summary-card{min-height:100%}.monitor-kpis{align-content:start}.device-detail-board{grid-column:2;grid-row:1 / span 2;padding:18px;overflow:auto}.board-caption{color:#8a7b66;margin-top:10px;line-height:1.5}.device-detail-grid{display:grid;grid-template-columns:repeat(2,minmax(220px,1fr));gap:14px;margin-top:18px}.device-info-card{background:rgba(255,255,255,.62);border:2px solid rgba(212,196,168,.72);border-radius:24px;padding:16px;min-width:0;box-shadow:inset 0 -2px 0 rgba(61,52,40,.04)}.device-info-card h4{margin:0 0 12px;color:#794f27;font-size:18px;font-weight:900}.device-info-card dl{display:grid;gap:8px;margin:0}.device-info-card dl div{display:grid;grid-template-columns:86px minmax(0,1fr);gap:10px;align-items:baseline}.device-info-card dt{color:#9f927d;font-size:12px;font-weight:900}.device-info-card dd{margin:0;color:#4d3a22;font-weight:800;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.monitor-device-card .instance-card-actions{margin-top:10px}.monitor-mini-grid{grid-template-columns:repeat(2,1fr);margin-top:12px}.monitor-metric.temperature b{color:#c05b28}.monitor-metric.traffic b{color:#2f7e98}.monitor-metric.signal b{color:#4b8f24}.status-pill.signal,.status-pill.temperature,.status-pill.traffic{background:#e6f9f6;color:#2a6b5a}.compact .device-info-card{padding:12px}.compact .device-info-card dl{gap:5px}.api-layout:before{content:'调试接口为次级入口;日常巡检请回到设备监控页。';display:block;grid-column:1 / -1;color:#8a7b66;font-weight:800;margin:0 0 8px 4px}@media(max-width:1260px){.device-dashboard.active{grid-template-columns:1fr}.device-detail-board{grid-column:auto;grid-row:auto}.device-detail-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:760px){.device-detail-grid{grid-template-columns:1fr}.device-info-card dl div{grid-template-columns:76px minmax(0,1fr)}} diff --git a/server/core.js b/server/core.js index d99f445..afe3249 100644 --- a/server/core.js +++ b/server/core.js @@ -137,7 +137,9 @@ function pickDevice(data) { imei: data.imei || data.IMEI || null, manufacturer: data.manufacturer || data.vendor || null, model: data.model || data.device_model || null, - firmware: data.firmware || data.firmware_version || data.version || null, + firmware: data.firmware || data.firmware_version || data.version || data.revision || null, + revision: data.revision || null, + powered: data.powered ?? data.power ?? null, online: data.online ?? data.is_online ?? null, } } @@ -148,9 +150,12 @@ function pickSim(data) { return { iccid: data.iccid || data.ICCID || null, imsi: data.imsi || data.IMSI || null, - phoneNumber: data.phone_number || data.phoneNumber || data.msisdn || null, + phoneNumber: data.phone_number || data.phoneNumber || data.msisdn || (Array.isArray(data.phone_numbers) ? data.phone_numbers[0] : null) || null, + phoneNumbers: data.phone_numbers || data.phoneNumbers || null, operator: data.operator || data.operator_name || null, signal: data.signal || data.signal_strength || null, + present: data.present ?? data.inserted ?? null, + smsCenter: data.sms_center || data.smsCenter || null, } } @@ -160,8 +165,10 @@ function pickNetwork(data) { return { operator: data.operator || data.operator_name || data.provider || null, registration: data.registration || data.registration_state || data.status || null, - accessTechnology: data.access_technology || data.accessTechnology || data.rat || data.mode || null, + accessTechnology: data.access_technology || data.accessTechnology || data.rat || data.mode || data.technology_preference || null, signal: data.signal || data.signal_strength || data.rssi || null, + mcc: data.mcc || null, + mnc: data.mnc || null, } } @@ -172,6 +179,10 @@ function pickSms(data) { total: data.total ?? data.total_count ?? data.count ?? null, unread: data.unread ?? data.unread_count ?? null, conversations: data.conversations ?? data.conversation_count ?? null, + incoming: data.incoming ?? null, + outgoing: data.outgoing ?? null, + pushed: data.pushed ?? null, + pushAttempted: data.push_attempted ?? data.pushAttempted ?? null, } } @@ -182,6 +193,7 @@ function pickDataStatus(data) { enabled: data.enabled ?? data.data_enabled ?? null, connected: data.connected ?? data.is_connected ?? null, roaming: data.roaming ?? data.roaming_allowed ?? null, + active: data.active ?? null, } } @@ -192,6 +204,22 @@ function pickOta(data) { currentVersion: data.current_version || data.currentVersion || data.version || null, latestVersion: data.latest_version || data.latestVersion || null, updateAvailable: data.update_available ?? data.updateAvailable ?? null, + pendingUpdate: data.pending_update ?? data.pendingUpdate ?? null, + currentCommit: data.current_commit || data.currentCommit || null, + } +} + +function pickSystem(data) { + data = apiData(data) + if (!data || typeof data !== 'object') return null + return { + cpuLoad: data.cpu_load ?? data.cpuLoad ?? null, + memory: data.memory || null, + disk: data.disk || null, + networkSpeed: data.network_speed || data.networkSpeed || null, + info: data.system_info || data.systemInfo || null, + temperature: data.temperature || null, + uptime: data.uptime ?? null, } } @@ -203,6 +231,7 @@ export function summarizeInstanceSnapshot(raw) { sms: pickSms(raw.smsStats), data: pickDataStatus(raw.data), ota: pickOta(raw.ota), + system: pickSystem(raw.stats), stats: apiData(raw.stats) || null, calls: apiData(raw.calls) || null, } diff --git a/test/server-core.test.js b/test/server-core.test.js index 6b56522..b798319 100644 --- a/test/server-core.test.js +++ b/test/server-core.test.js @@ -41,21 +41,47 @@ test('client stores simadmin_session from login and sends it on later proxied re assert.match(calls.at(-1).options.headers.cookie, /simadmin_session=abc123/) }) -test('snapshot summary merges status from device, sim, network, sms and system endpoints', () => { +test('snapshot summary exposes device-monitoring fields from stats, sms, data and hardware endpoints', () => { const snapshot = summarizeInstanceSnapshot({ - device: { data: { model: 'CPE X', imei: '123' } }, - sim: { data: { iccid: '8986', operator: 'CMCC' } }, - network: { data: { accessTechnology: 'LTE', registration: 'registered' } }, - smsStats: { data: { total: 12, unread: 2 } }, - data: { data: { enabled: true, connected: true } }, - ota: { data: { current_version: '1.2.3' } }, + device: { data: { model: 'CPE X', imei: '123', manufacturer: 'Quectel', powered: true, revision: 'RG500QEAAAR13A01' } }, + sim: { data: { iccid: '8986', imsi: '460001234', phone_numbers: ['13800138000'], operator: 'CMCC', present: true } }, + network: { data: { operator_name: '中国移动', signal_strength: -73, registration_status: 'registered', technology_preference: 'LTE' } }, + smsStats: { data: { total: 12, incoming: 9, outgoing: 3, pushed: 8 } }, + data: { data: { active: true } }, + ota: { data: { current_version: '1.2.3', current_commit: 'abc123', pending_update: false } }, + stats: { data: { + cpu_load: [0.2, 0.4, 0.6], + memory: { total: 1024, used: 512, free: 512 }, + disk: { total: 2048, used: 1024, free: 1024 }, + network_speed: { rx: 1024, tx: 2048 }, + system_info: { os: 'OpenWrt', kernel: '6.6.1' }, + temperature: { cpu: 47.5, modem: 41 }, + uptime: 3600, + } }, }) assert.equal(snapshot.device.model, 'CPE X') + assert.equal(snapshot.device.manufacturer, 'Quectel') + assert.equal(snapshot.device.powered, true) assert.equal(snapshot.sim.iccid, '8986') + assert.equal(snapshot.sim.present, true) + assert.equal(snapshot.sim.phoneNumber, '13800138000') + assert.equal(snapshot.network.operator, '中国移动') + assert.equal(snapshot.network.signal, -73) assert.equal(snapshot.network.accessTechnology, 'LTE') assert.equal(snapshot.sms.total, 12) - assert.equal(snapshot.data.connected, true) + assert.equal(snapshot.sms.incoming, 9) + assert.equal(snapshot.sms.outgoing, 3) + assert.equal(snapshot.sms.pushed, 8) + assert.equal(snapshot.data.active, true) assert.equal(snapshot.ota.currentVersion, '1.2.3') + assert.equal(snapshot.ota.currentCommit, 'abc123') + assert.equal(snapshot.ota.pendingUpdate, false) + assert.deepEqual(snapshot.system.temperature, { cpu: 47.5, modem: 41 }) + assert.deepEqual(snapshot.system.networkSpeed, { rx: 1024, tx: 2048 }) + assert.equal(snapshot.system.memory.used, 512) + assert.equal(snapshot.system.disk.free, 1024) + assert.equal(snapshot.system.info.os, 'OpenWrt') + assert.equal(snapshot.system.uptime, 3600) }) test('config store can add update and delete instances while persisting json', async () => { diff --git a/test/ui-contract.test.js b/test/ui-contract.test.js new file mode 100644 index 0000000..c194e87 --- /dev/null +++ b/test/ui-contract.test.js @@ -0,0 +1,26 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' + +const root = new URL('../', import.meta.url) + +async function text(path) { + return readFile(new URL(path, root), 'utf8') +} + +test('dashboard primary surface is a device monitoring board, not an API catalog', async () => { + const html = await text('public/index.html') + const app = await text('public/app.js') + + assert.match(html, /设备监控|设备详情|实时状态/) + assert.match(app, /renderDeviceDetails/) + assert.match(app, /温度/) + assert.match(app, /流量|速率/) + assert.match(app, /内存|磁盘/) + assert.match(app, /SIM|ICCID/) + assert.match(app, /短信/) + + const dashboardBlock = html.match(/
\s*