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 => ``).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(/