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}`
}
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() {
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 `<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>
<div class="instance-name">${escapeHtml(item.name || item.id)}</div>
@@ -145,17 +217,16 @@ function renderInstances() {
</div>
<span class="status-pill ${pillClass(s)}">${escapeHtml(statusText(s))}</span>
</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">
<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>
</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>`
}).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]) => `<div class="signal-item"><span>${k}</span><b>${v}</b></div>`).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]) => `<div class="signal-item"><span>${escapeHtml(k)}</span><b>${escapeHtml(v)}</b></div>`).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]) => `<div class="signal-item"><span>${escapeHtml(k)}</span><b title="${escapeHtml(value(v))}">${escapeHtml(value(v))}</b></div>`).join('')
}
function renderOverview() {
const item = activeInstance()
const s = activeStatus()
const { item, s, device, sim, network, data, sms, system } = getMonitorData()
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()
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]) => `<div class="metric"><span>${escapeHtml(k)}</span><b title="${escapeHtml(value(v))}">${escapeHtml(value(v))}</b></div>`).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 => `<button class="${t.id === activeModule ? 'active' : ''}" data-module="${escapeHtml(t.id)}">${escapeHtml(t.title)}</button>`).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 = `<section class="empty-state mini-empty"><h3>设备监控大屏</h3><p>选择左侧任一设备后,这里会按分类展示真实设备信息、温度、流量、系统资源、SIM 与短信状态。</p></section>`
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 => `<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 renderFeatures() { renderDeviceDetails() }
function renderEndpointList() {
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>
<p id="activeKicker" class="eyebrow">NO ISLAND SELECTED</p>
<h1 id="activeName">选择一个 SimAdmin 小岛</h1>
<p id="activeMeta">查看设备、SIM、网络、短信、eSIM、OTA 与原始管理页面</p>
<p id="activeMeta">集中展示设备信息、联网状态、SIM、信号、温度、流量、系统资源、短信与版本</p>
</div>
<div class="hero-actions">
<button id="loginBtn" type="button" class="animal-btn default-btn" disabled>登录/刷新会话</button>
@@ -71,26 +71,26 @@
</header>
<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 app-yellow" data-view="api"><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-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>
</nav>
<section id="dashboardView" class="view active dashboard-view">
<section class="signal-board island-card pattern-app-yellow">
<div class="section-ribbon color-app-yellow">小岛告示牌</div>
<section id="dashboardView" class="view active dashboard-view device-dashboard" aria-label="设备监控大屏">
<section class="signal-board island-card pattern-app-yellow monitor-summary-card">
<div class="section-ribbon color-app-yellow">实时状态</div>
<div id="signalPanel" class="signal-grid"></div>
</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="section-ribbon color-app-green">功能应用</div>
<div id="moduleTabs" class="module-tabs"></div>
<div class="section-ribbon color-app-green">设备详情</div>
<p class="board-caption">按设备、SIM、蜂窝网络、系统资源、温度、流量和短信分类展示真实状态。</p>
</div>
<div id="featureGrid" class="app-grid"></div>
<div id="featureGrid" class="device-detail-grid"></div>
</section>
</section>
File diff suppressed because one or more lines are too long