Refocus dashboard on device monitoring

This commit is contained in:
chick
2026-07-07 23:33:27 +08:00
parent 2505ee561a
commit 0e746f961c
6 changed files with 247 additions and 91 deletions
+140 -69
View File
@@ -99,6 +99,77 @@ function proxiedUrl(endpoint) {
return `/api/proxy/${encodeURIComponent(activeId)}${normalized}` 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 `<div class="metric monitor-metric ${tone}"><span>${escapeHtml(label)}</span><b title="${escapeHtml(value(val))}">${escapeHtml(value(val))}</b></div>`
}
function detailCard(title, rows, tone = '') {
return `<article class="device-info-card ${tone}"><h4>${escapeHtml(title)}</h4><dl>${rows.map(([k, v]) => `<div><dt>${escapeHtml(k)}</dt><dd title="${escapeHtml(value(v))}">${escapeHtml(value(v))}</dd></div>`).join('')}</dl></article>`
}
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() { function renderFleetStats() {
const total = instances.length const total = instances.length
const counts = { online: 0, auth: 0, offline: 0, unknown: 0 } const counts = { online: 0, auth: 0, offline: 0, unknown: 0 }
@@ -137,7 +208,8 @@ function renderInstances() {
const sum = s?.summary || {} const sum = s?.summary || {}
const network = sum.network || {} const network = sum.network || {}
const sim = sum.sim || {} const sim = sum.sim || {}
return `<article class="instance-card ${item.id === activeId ? 'active' : ''}" data-id="${escapeHtml(item.id)}"> const system = sum.system || {}
return `<article class="instance-card monitor-device-card ${item.id === activeId ? 'active' : ''}" data-id="${escapeHtml(item.id)}">
<div class="instance-top"> <div class="instance-top">
<div> <div>
<div class="instance-name">${escapeHtml(item.name || item.id)}</div> <div class="instance-name">${escapeHtml(item.name || item.id)}</div>
@@ -145,17 +217,16 @@ function renderInstances() {
</div> </div>
<span class="status-pill ${pillClass(s)}">${escapeHtml(statusText(s))}</span> <span class="status-pill ${pillClass(s)}">${escapeHtml(statusText(s))}</span>
</div> </div>
<div class="mini-grid monitor-mini-grid">
<span>信号<b>${escapeHtml(value(network.signal || sim.signal))}</b></span>
<span>温度<b>${escapeHtml(formatTemperature(system.temperature))}</b></span>
<span>速率<b>${escapeHtml(formatSpeed(system.networkSpeed))}</b></span>
<span>SIM<b>${escapeHtml(sim.present === false ? '未插卡' : value(sim.iccid))}</b></span>
</div>
<div class="instance-card-actions"> <div class="instance-card-actions">
<button type="button" class="mini-action" data-edit-id="${escapeHtml(item.id)}">编辑</button> <button type="button" class="mini-action" data-edit-id="${escapeHtml(item.id)}">编辑</button>
<button type="button" class="mini-action danger" data-delete-id="${escapeHtml(item.id)}">删除</button> <button type="button" class="mini-action danger" data-delete-id="${escapeHtml(item.id)}">删除</button>
</div> </div>
${item.description ? `<p class="instance-desc">${escapeHtml(item.description)}</p>` : ''}
<div class="mini-grid">
<span>认证<b>${item.auth?.hasPassword ? '密码会话' : '无密码'}</b></span>
<span>运营商<b>${escapeHtml(value(network.operator || sim.operator))}</b></span>
<span>网络<b>${escapeHtml(value(network.accessTechnology || network.registration))}</b></span>
<span>ICCID<b>${escapeHtml(value(sim.iccid))}</b></span>
</div>
</article>` </article>`
}).join('') }).join('')
$$('.instance-card', root).forEach(card => card.addEventListener('click', (event) => { $$('.instance-card', root).forEach(card => card.addEventListener('click', (event) => {
@@ -167,85 +238,85 @@ function renderInstances() {
} }
function renderHero() { function renderHero() {
const item = activeInstance() const { item, s, device, sim, network, system, data, ota } = getMonitorData()
const s = activeStatus()
const sum = s?.summary || {}
if (!item) { if (!item) {
$('activeKicker').textContent = 'NO DEVICE SELECTED' $('activeKicker').textContent = 'DEVICE MONITOR'
$('activeName').textContent = '选择一个 SimAdmin 实例' $('activeName').textContent = '选择一个设备查看实时状态'
$('activeMeta').textContent = '统一查看设备、SIM、网络、短信、eSIM、OTA 与原始管理页面。' $('activeMeta').textContent = '主页面展示各设备的状态、温度、流量、SIM、信号、系统资源与短信统计。'
$('signalPanel').innerHTML = [ $('signalPanel').innerHTML = [
['状态', '等待选择'], ['当前实例', '-'], ['会话', '-'], ['刷新', '-'], ['状态', '等待选择'], ['设备', '-'], ['温度', '-'], ['流量速率', '-'],
].map(([k, v]) => `<div class="signal-item"><span>${k}</span><b>${v}</b></div>`).join('') ].map(([k, v]) => `<div class="signal-item"><span>${k}</span><b>${v}</b></div>`).join('')
return return
} }
$('activeKicker').textContent = item.id $('activeKicker').textContent = `${item.id} · ${statusText(s)}`
$('activeName').textContent = item.name || item.id $('activeName').textContent = `${item.name || item.id} 设备监控`
$('activeMeta').textContent = item.description ? `${item.description} · ${item.url}` : item.url $('activeMeta').textContent = `${device.manufacturer || ''} ${device.model || ''}`.trim() || item.url
$('signalPanel').innerHTML = [ $('signalPanel').innerHTML = [
['状态', statusText(s)], ['在线状态', statusText(s)],
['设备', value(sum.device?.model || sum.device?.name)], ['SIM / 运营商', `${sim.present === false ? '未插卡' : '已插卡'} · ${value(network.operator || sim.operator)}`],
['运营商', value(sum.network?.operator || sum.sim?.operator)], ['信号 / 制式', `${value(network.signal || sim.signal)} · ${value(network.accessTechnology || network.registration)}`],
['网络', value(sum.network?.accessTechnology || sum.network?.registration)], ['温度', formatTemperature(system.temperature)],
['短信', sum.sms ? `${value(sum.sms.total)} / 未读 ${value(sum.sms.unread)}` : '-'], ['流量速率', formatSpeed(system.networkSpeed)],
['版本', value(sum.ota?.currentVersion || sum.device?.firmwareVersion)], ['数据连接', firstValue(data.active, data.connected, data.enabled) === true ? '已连接' : firstValue(data.active, data.connected, data.enabled) === false ? '未连接' : '-'],
].map(([k, v]) => `<div class="signal-item"><span>${escapeHtml(k)}</span><b>${escapeHtml(v)}</b></div>`).join('') ['系统负载', Array.isArray(system.cpuLoad) ? system.cpuLoad.join(' / ') : value(system.cpuLoad)],
['版本', value(ota.currentVersion || device.firmware || device.revision)],
].map(([k, v]) => `<div class="signal-item"><span>${escapeHtml(k)}</span><b title="${escapeHtml(value(v))}">${escapeHtml(value(v))}</b></div>`).join('')
} }
function renderOverview() { function renderOverview() {
const item = activeInstance() const { item, s, device, sim, network, data, sms, system } = getMonitorData()
const s = activeStatus()
if (!item) { if (!item) {
$('overview').innerHTML = '<div class="metric"><span>提示</span><b>先选择左侧设备</b></div>' $('overview').innerHTML = [
metricCard('设备', `${instances.length}`),
metricCard('在线', [...statuses.values()].filter(x => statusKind(x) === 'online').length),
metricCard('温度', '选择设备后显示'),
metricCard('流量', '选择设备后显示'),
].join('')
renderHero() renderHero()
renderDeviceDetails()
return return
} }
const sum = s?.summary || {} const dataState = firstValue(data.active, data.connected, data.enabled)
const metrics = [ $('overview').innerHTML = [
['状态', s ? (s.reachable ? (s.authenticated === false ? '在线/未登录' : '在线') : `离线`) : '未知'], metricCard('状态', s ? (s.reachable ? (s.authenticated === false ? '在线/未登录' : '在线') : '离线') : '未知', pillClass(s)),
['延迟', s?.latencyMs != null ? `${s.latencyMs}ms` : '-'], metricCard('延迟', s?.latencyMs != null ? `${s.latencyMs}ms` : '-'),
['号', sum.device?.model || sum.device?.name], metricCard('号', firstValue(network.signal, sim.signal), 'signal'),
['IMEI', sum.device?.imei], metricCard('温度', formatTemperature(system.temperature), 'temperature'),
['ICCID', sum.sim?.iccid], metricCard('流量速率', formatSpeed(system.networkSpeed), 'traffic'),
['号码', sum.sim?.phoneNumber || sum.sim?.msisdn], metricCard('数据连接', dataState === true ? '已连接' : dataState === false ? '未连接' : '-'),
['运营商', sum.network?.operator || sum.sim?.operator], metricCard('SIM', sim.present === false ? '未插卡' : value(sim.iccid)),
['网络', sum.network?.accessTechnology || sum.network?.registration], metricCard('短信', sms.total != null ? `${sms.total} / 收 ${value(sms.incoming)} / 发 ${value(sms.outgoing)}` : '-'),
['数据连接', sum.data ? `${boolText(sum.data.enabled)} / ${boolText(sum.data.connected)}` : '-'], ].join('')
['短信', sum.sms ? `${value(sum.sms.total)} / 未读 ${value(sum.sms.unread)}` : '-'],
]
$('overview').innerHTML = metrics.map(([k, v]) => `<div class="metric"><span>${escapeHtml(k)}</span><b title="${escapeHtml(value(v))}">${escapeHtml(value(v))}</b></div>`).join('')
renderHero() renderHero()
renderDeviceDetails()
} }
function renderModuleTabs() { function renderDeviceDetails() {
const tabs = [{ id: 'all', title: '全部' }, ...modules.map(m => ({ id: m.id, title: m.title.split(/[ /]/)[0] }))] const { item, device, sim, network, data, sms, ota, system, s } = getMonitorData()
$('moduleTabs').innerHTML = tabs.map(t => `<button class="${t.id === activeModule ? 'active' : ''}" data-module="${escapeHtml(t.id)}">${escapeHtml(t.title)}</button>`).join('') if (!item) {
$$('button[data-module]', $('moduleTabs')).forEach(btn => btn.addEventListener('click', () => { $('featureGrid').innerHTML = `<section class="empty-state mini-empty"><h3>设备监控大屏</h3><p>选择左侧任一设备后,这里会按分类展示真实设备信息、温度、流量、系统资源、SIM 与短信状态。</p></section>`
activeModule = btn.dataset.module return
renderFeatures() }
})) $('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() { function renderFeatures() { renderDeviceDetails() }
renderModuleTabs()
const disabled = activeId ? '' : ' disabled'
const visible = activeModule === 'all' ? modules : modules.filter(m => m.id === activeModule)
$('featureGrid').innerHTML = visible.map(m => `<article class="feature">
<div class="feature-head">
<div><p class="eyebrow ${m.tone === 'danger' ? '' : 'accent'}">${escapeHtml(m.id)}</p><h4>${escapeHtml(m.title)}</h4></div>
<span class="status-pill ${m.tone === 'danger' ? 'bad' : m.tone === 'warn' ? 'warn' : m.tone === 'ok' ? 'ok' : ''}">${m.endpoints.length} API</span>
</div>
<p>${escapeHtml(m.desc)}</p>
${m.tone === 'danger' ? '<div class="danger-note">包含写操作/网络切换类接口,执行前确认目标设备。</div>' : ''}
<div class="endpoint-pills">${m.endpoints.map(ep => `<button${disabled} data-ep="${escapeHtml(ep)}">${escapeHtml(ep)}</button>`).join('')}</div>
</article>`).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 renderEndpointList() { function renderEndpointList() {
const groups = catalog.length ? catalog : modules.map(m => ({ id: m.id, name: m.title, endpoints: m.endpoints })) const groups = catalog.length ? catalog : modules.map(m => ({ id: m.id, name: m.title, endpoints: m.endpoints }))
+11 -11
View File
@@ -62,7 +62,7 @@
<div> <div>
<p id="activeKicker" class="eyebrow">NO ISLAND SELECTED</p> <p id="activeKicker" class="eyebrow">NO ISLAND SELECTED</p>
<h1 id="activeName">选择一个 SimAdmin 小岛</h1> <h1 id="activeName">选择一个 SimAdmin 小岛</h1>
<p id="activeMeta">查看设备、SIM、网络、短信、eSIM、OTA 与原始管理页面</p> <p id="activeMeta">集中展示设备信息、联网状态、SIM、信号、温度、流量、系统资源、短信与版本</p>
</div> </div>
<div class="hero-actions"> <div class="hero-actions">
<button id="loginBtn" type="button" class="animal-btn default-btn" disabled>登录/刷新会话</button> <button id="loginBtn" type="button" class="animal-btn default-btn" disabled>登录/刷新会话</button>
@@ -71,26 +71,26 @@
</header> </header>
<nav class="phone-dock" aria-label="主视图"> <nav class="phone-dock" aria-label="主视图">
<button class="phone-app active app-teal" data-view="dashboard"><span>🏝️</span><b>总览</b></button> <button class="phone-app active app-teal" data-view="dashboard"><span>📡</span><b>设备监控</b></button>
<button class="phone-app app-yellow" data-view="api"><span>{}</span><b>接口</b></button>
<button class="phone-app app-blue" data-view="rawpage"><span></span><b>原页</b></button> <button class="phone-app app-blue" data-view="rawpage"><span></span><b>原页</b></button>
<button class="phone-app app-yellow" data-view="api"><span>{}</span><b>调试接口</b></button>
<button id="densityBtn" class="phone-app app-green" type="button"><span></span><b>密度</b></button> <button id="densityBtn" class="phone-app app-green" type="button"><span></span><b>密度</b></button>
</nav> </nav>
<section id="dashboardView" class="view active dashboard-view"> <section id="dashboardView" class="view active dashboard-view device-dashboard" aria-label="设备监控大屏">
<section class="signal-board island-card pattern-app-yellow"> <section class="signal-board island-card pattern-app-yellow monitor-summary-card">
<div class="section-ribbon color-app-yellow">小岛告示牌</div> <div class="section-ribbon color-app-yellow">实时状态</div>
<div id="signalPanel" class="signal-grid"></div> <div id="signalPanel" class="signal-grid"></div>
</section> </section>
<section id="overview" class="nook-metrics"></section> <section id="overview" class="nook-metrics monitor-kpis" aria-label="关键状态指标"></section>
<section class="module-board island-card pattern-default"> <section class="module-board island-card pattern-default device-detail-board">
<div class="board-head"> <div class="board-head">
<div class="section-ribbon color-app-green">功能应用</div> <div class="section-ribbon color-app-green">设备详情</div>
<div id="moduleTabs" class="module-tabs"></div> <p class="board-caption">按设备、SIM、蜂窝网络、系统资源、温度、流量和短信分类展示真实状态。</p>
</div> </div>
<div id="featureGrid" class="app-grid"></div> <div id="featureGrid" class="device-detail-grid"></div>
</section> </section>
</section> </section>
File diff suppressed because one or more lines are too long
+32 -3
View File
@@ -137,7 +137,9 @@ function pickDevice(data) {
imei: data.imei || data.IMEI || null, imei: data.imei || data.IMEI || null,
manufacturer: data.manufacturer || data.vendor || null, manufacturer: data.manufacturer || data.vendor || null,
model: data.model || data.device_model || 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, online: data.online ?? data.is_online ?? null,
} }
} }
@@ -148,9 +150,12 @@ function pickSim(data) {
return { return {
iccid: data.iccid || data.ICCID || null, iccid: data.iccid || data.ICCID || null,
imsi: data.imsi || data.IMSI || 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, operator: data.operator || data.operator_name || null,
signal: data.signal || data.signal_strength || 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 { return {
operator: data.operator || data.operator_name || data.provider || null, operator: data.operator || data.operator_name || data.provider || null,
registration: data.registration || data.registration_state || data.status || 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, 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, total: data.total ?? data.total_count ?? data.count ?? null,
unread: data.unread ?? data.unread_count ?? null, unread: data.unread ?? data.unread_count ?? null,
conversations: data.conversations ?? data.conversation_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, enabled: data.enabled ?? data.data_enabled ?? null,
connected: data.connected ?? data.is_connected ?? null, connected: data.connected ?? data.is_connected ?? null,
roaming: data.roaming ?? data.roaming_allowed ?? 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, currentVersion: data.current_version || data.currentVersion || data.version || null,
latestVersion: data.latest_version || data.latestVersion || null, latestVersion: data.latest_version || data.latestVersion || null,
updateAvailable: data.update_available ?? data.updateAvailable ?? 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), sms: pickSms(raw.smsStats),
data: pickDataStatus(raw.data), data: pickDataStatus(raw.data),
ota: pickOta(raw.ota), ota: pickOta(raw.ota),
system: pickSystem(raw.stats),
stats: apiData(raw.stats) || null, stats: apiData(raw.stats) || null,
calls: apiData(raw.calls) || null, calls: apiData(raw.calls) || null,
} }
+34 -8
View File
@@ -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/) 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({ const snapshot = summarizeInstanceSnapshot({
device: { data: { model: 'CPE X', imei: '123' } }, device: { data: { model: 'CPE X', imei: '123', manufacturer: 'Quectel', powered: true, revision: 'RG500QEAAAR13A01' } },
sim: { data: { iccid: '8986', operator: 'CMCC' } }, sim: { data: { iccid: '8986', imsi: '460001234', phone_numbers: ['13800138000'], operator: 'CMCC', present: true } },
network: { data: { accessTechnology: 'LTE', registration: 'registered' } }, network: { data: { operator_name: '中国移动', signal_strength: -73, registration_status: 'registered', technology_preference: 'LTE' } },
smsStats: { data: { total: 12, unread: 2 } }, smsStats: { data: { total: 12, incoming: 9, outgoing: 3, pushed: 8 } },
data: { data: { enabled: true, connected: true } }, data: { data: { active: true } },
ota: { data: { current_version: '1.2.3' } }, 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.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.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.network.accessTechnology, 'LTE')
assert.equal(snapshot.sms.total, 12) 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.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 () => { test('config store can add update and delete instances while persisting json', async () => {
+26
View File
@@ -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(/<section id="dashboardView"[\s\S]*?<\/section>\s*<section id="apiView"/)
assert.ok(dashboardBlock, 'dashboardView block should exist before apiView')
assert.doesNotMatch(dashboardBlock[0], /API 地图|endpointList|接口路径/)
})