[verified] refactor: harden operations and redesign device console

This commit is contained in:
chick
2026-07-15 16:33:16 +08:00
parent e04a16e817
commit 4977afdc20
24 changed files with 769 additions and 779 deletions
+5 -1
View File
@@ -63,6 +63,10 @@ npm start
- `auth.mode``none` 表示无密码;`password` 表示由本地聚合服务代登录。
- `auth.password`:可选。本地保存后可自动刷新会话;不要提交真实密码。
### 为什么只接受 IP 地址?
聚合服务会代理设备管理 API。为避免 DNS 重绑定将已校验主机名切换到本机或元数据地址,设备 URL 必须使用明确的 IPv4/IPv6 字面地址;局域网 IP(如 `192.168.x.x`)可正常使用。
## 常用命令
```bash
@@ -89,7 +93,7 @@ npm audit --omit=dev --audit-level=high
git diff --check
```
阶段只提供架构端口和基础安全不变量。控制面完整鉴权、CSRF、完整 SSRF(DNS 解析/重绑定与私网 CIDR policy)、审计/权限,以及前端 CRUD 可达性和整体 UI 重构留到第二阶段。默认监听 `127.0.0.1`;显式配置 `0.0.0.0` 会保持,但这会暴露当前尚未鉴权的控制面
阶段安全加固后,服务仅允许监听 loopback;显式配置 `0.0.0.0`、LAN 地址或非 loopback 主机将拒绝启动。实例目标必须为允许的 IP 字面地址,拒绝凭据、loopback、unspecified、link-local、metadata、multicast 及主机名,以避免 DNS 重绑定。生产代理使用精确路径/方法白名单,写操作要求短期一次性确认令牌
## 注意
+91 -558
View File
@@ -1,577 +1,110 @@
import { escapeHtml, value, formatBytes } from './domain/formatters.js'
import { createApiClient, normalizeTargetUrl } from './infrastructure/api-client.js'
import { RequestCoordinator } from './state/request-coordinator.js'
import { filterAndSortFleet, fleetStatusKind, emptyFleetReason } from './state/fleet-view-model.js'
import { instanceDraftPayload, requestDraft, isWriteMethod } from './state/operation-draft.js'
import { createViewRouter, resolveView } from './router/view-router.js'
let instances = []
let statuses = new Map()
let catalog = []
let activeId = null
let activeFilter = 'all'
let configPath = 'config.json'
let autoRefreshTimer = null
const $ = (id) => document.getElementById(id)
const $$ = (selector, root = document) => [...root.querySelectorAll(selector)]
const modules = [
{ id: 'overview', title: '设备总览', desc: '设备、SIM、网络、连接状态与流量统计。适合日常巡检和资产核对。', tone: 'ok', endpoints: ['/api/device', '/api/sim', '/api/network', '/api/stats', '/api/connectivity'] },
{ id: 'sms', title: '短信中心', desc: '短信统计、列表、会话读取与短信发送。发送类接口请先在 API 工作台确认请求体。', tone: 'warn', endpoints: ['/api/sms/stats', '/api/sms/list', '/api/sms/conversation', '/api/sms/send'] },
{ id: 'network', title: '蜂窝网络', desc: '数据开关、漫游、飞行模式、无线制式、频段/小区锁定与 APN。', tone: 'danger', endpoints: ['/api/data', '/api/roaming', '/api/airplane-mode', '/api/radio-mode', '/api/band-lock', '/api/cell-lock', '/api/apn', '/api/cells'] },
{ id: 'device-network', title: '设备网络', desc: 'WLAN、DDNS、连接地址与局域网访问能力。', tone: 'neutral', endpoints: ['/api/device-network/wlan/status', '/api/device-network/wlan/profiles', '/api/device-network/ddns/status', '/api/device-network/ddns/config', '/api/network/connection-addresses'] },
{ id: 'calls', title: '电话能力', desc: '通话列表、历史记录、IMS 与语音信箱状态。', tone: 'neutral', endpoints: ['/api/calls', '/api/call/history', '/api/ims/status', '/api/voicemail/status'] },
{ id: 'esim', title: 'eSIM / 工作模式', desc: 'eUICC、profile、LPAC 状态、工作模式与 eSIM 配置。', tone: 'warn', endpoints: ['/api/work-mode', '/api/esim/config', '/api/esim/lpac/status', '/api/esim/euicc', '/api/esim/profiles'] },
{ id: 'notify-auto-ota', title: '通知 / 自动化 / OTA', desc: '通知配置与日志、自动化任务日志、在线升级状态。', tone: 'neutral', endpoints: ['/api/notifications/config', '/api/notifications/logs', '/api/automation/config', '/api/automation/logs', '/api/ota/status'] },
]
const requests = new RequestCoordinator()
const api = createApiClient()
let instances = [], statuses = new Map(), catalog = [], activeId = null, activeFilter = 'all', activeSort = 'name'
let lastSuccessfulRefresh = null, currentView = 'home', outputText = '', outputPrettyText = '', pendingDeleteId = null, pendingApiRequest = null
let instanceDialogEpoch = 0, deleteDialogEpoch = 0, loginDialogEpoch = 0
const $ = id => document.getElementById(id), $$ = (selector, root = document) => [...root.querySelectorAll(selector)]
const requests = new RequestCoordinator(), api = createApiClient()
const routeView = createViewRouter({ controls: () => $$('[data-view]'), panels: () => $$('.view') })
function boolText(v) {
if (v === true) return '开启'
if (v === false) return '关闭'
return value(v)
const modules = [
{ name: '设备与网络', endpoints: ['/api/device','/api/sim','/api/network','/api/stats','/api/connectivity','/api/data','/api/roaming','/api/airplane-mode','/api/radio-mode','/api/band-lock','/api/cell-lock','/api/apn'] },
{ name: '短信与通话', endpoints: ['/api/sms/stats','/api/sms/list','/api/sms/conversation','/api/sms/send','/api/calls','/api/call/history'] },
{ name: '系统服务', endpoints: ['/api/work-mode','/api/esim/config','/api/notifications/config','/api/automation/config','/api/ota/status'] },
]
const firstValue = (...items) => items.find(x => x !== undefined && x !== null && x !== '') ?? null
const activeInstance = () => instances.find(x => x.id === activeId)
const activeStatus = () => statuses.get(activeId)
const tagsFromInput = text => String(text || '').split(',').map(x => x.trim()).filter(Boolean)
const statusText = s => ({ online: `在线${s?.latencyMs != null ? ` · ${s.latencyMs}ms` : ''}`, auth: '需登录', offline: '离线', unknown: '未知' })[fleetStatusKind(s)]
const showToast = message => { $('toast').textContent = message; $('toast').hidden = false; clearTimeout(showToast.timer); showToast.timer = setTimeout(() => { $('toast').hidden = true }, 3000) }
function persistentError(message = '') { $('persistentErrorText').textContent = message; $('persistentError').hidden = !message }
function setPending(button, pending, label) { button.disabled = pending; if (label) button.textContent = pending ? label : button.dataset.idleLabel }
function invalidateDialog(id) {
if (id === 'instanceDialog') { instanceDialogEpoch += 1; setPending($('saveInstanceBtn'), false, '保存中…') }
if (id === 'deleteDialog') { deleteDialogEpoch += 1; setPending($('confirmDeleteBtn'), false, '删除中…') }
if (id === 'loginDialog') { loginDialogEpoch += 1; requests.invalidate('login'); $('submitLogin').disabled = false; $('passwordInput').value = '' }
if (id === 'apiDeleteDialog') pendingApiRequest = null
}
function activeInstance() { return instances.find(x => x.id === activeId) }
function activeStatus() { return statuses.get(activeId) }
function tagsFromInput(text) { return String(text || '').split(',').map(x => x.trim()).filter(Boolean) }
function instancePayloadFromForm() {
return {
id: $('instanceIdInput').value.trim(),
name: $('instanceNameInput').value.trim(),
url: $('instanceUrlInput').value.trim(),
description: $('instanceDescInput').value.trim(),
tags: tagsFromInput($('instanceTagsInput').value),
auth: { password: $('instancePasswordInput').value },
}
}
function statusKind(s) {
if (!s) return 'unknown'
if (!s.reachable) return 'offline'
if (s.authenticated === false) return 'auth'
return 'online'
}
function statusText(s) {
const kind = statusKind(s)
if (kind === 'online') return `在线${s.latencyMs != null ? ` ${s.latencyMs}ms` : ''}`
if (kind === 'auth') return '需登录'
if (kind === 'offline') return '离线'
return '未知'
}
function pillClass(s) {
const kind = statusKind(s)
if (kind === 'online') return 'ok'
if (kind === 'auth') return 'warn'
if (kind === 'offline') return 'bad'
return ''
}
function showToast(message, timeout = 2600) {
const toast = $('toast')
toast.textContent = message
toast.hidden = false
clearTimeout(showToast._timer)
showToast._timer = setTimeout(() => { toast.hidden = true }, timeout)
}
function flattenEndpoints() {
return [...new Set([
...catalog.flatMap(g => g.endpoints || []),
...modules.flatMap(m => m.endpoints || []),
])].sort()
}
function inferMethod(endpoint) {
if (/send|config|data$|roaming$|airplane-mode$|radio-mode$|band-lock$|cell-lock$|work-mode|login|logout/i.test(endpoint)) return 'POST'
return 'GET'
}
function firstValue(...items) {
for (const item of items) if (item !== undefined && item !== null && item !== '') return item
return null
function closeDialog(id) { invalidateDialog(id); const dialog=$(id); if(dialog.open)dialog.close() }
function frameClear() { $('frame').hidden = true; $('frame').removeAttribute('src') }
function switchView(requested) {
const view = resolveView(requested, Boolean(activeId)); currentView = view
if (view !== 'rawpage') frameClear()
if (view === 'rawpage') { const item = activeInstance(); $('frame').src = item.url; $('frame').hidden = false }
routeView(view)
const item = activeInstance(), titles = { home: '设备首页', detail: item ? `${item.name || item.id} · 设备详情` : '设备详情', rawpage: item ? `${item.name || item.id} · 原始页面` : '原始页面', api: item ? `${item.name || item.id} · API 工作台` : 'API 工作台' }
$('pageHeading').textContent = titles[view]; $('pageSubtitle').textContent = view === 'home' ? '集中巡检你的 SimAdmin 设备' : item?.url || ''
document.title = `${titles[view]} · Multi SimAdmin Island`
}
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`
if (Array.isArray(speed.interfaces)) return speed.interfaces.map(x => `${x.interface || 'net'}${formatBytes(firstValue(x.rx_bytes_per_sec,x.rx))}/s · ↑ ${formatBytes(firstValue(x.tx_bytes_per_sec,x.tx))}/s`).join(' ')
return `${formatBytes(firstValue(speed.rx,speed.download,speed.rx_bytes_per_sec))}/s · ↑ ${formatBytes(firstValue(speed.tx,speed.upload,speed.tx_bytes_per_sec))}/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 formatTemperature(temp) { if (temp == null || temp === '') return '-'; if (typeof temp === 'object') return Object.entries(temp).map(([k,v]) => `${k}: ${v}${typeof v === 'number' ? '℃' : ''}`).join(' · '); return `${temp}${typeof temp === 'number' ? '℃' : ''}` }
function renderFleetStats() {
const total = instances.length
const counts = { online: 0, auth: 0, offline: 0, unknown: 0 }
for (const inst of instances) counts[statusKind(statuses.get(inst.id))] += 1
$('fleetStats').innerHTML = [
['全部', total], ['在线', counts.online], ['需登录', counts.auth], ['离线', counts.offline],
].map(([label, num]) => `<div class="fleet-card"><b>${num}</b><span>${label}</span></div>`).join('')
const counts = { all: instances.length, online: 0, auth: 0, offline: 0, unknown: 0 }; for (const item of instances) counts[fleetStatusKind(statuses.get(item.id))]++
$('fleetStats').innerHTML = Object.entries({ all:'全部', online:'在线', auth:'需登录', offline:'离线', unknown:'未知' }).map(([k,label]) => `<button type="button" data-stat-filter="${k}" aria-pressed="${activeFilter === k}"><b>${counts[k]}</b><span>${label}</span></button>`).join('')
$$('[data-stat-filter]').forEach(btn => btn.addEventListener('click', () => setFilter(btn.dataset.statFilter)))
}
function filteredInstances() {
const q = $('instanceSearch')?.value.trim().toLowerCase() || ''
return instances.filter(item => {
const s = statuses.get(item.id)
const kind = statusKind(s)
if (activeFilter !== 'all' && kind !== activeFilter) return false
if (!q) return true
const haystack = [item.id, item.name, item.url, item.description, JSON.stringify(s?.summary || {})].join(' ').toLowerCase()
return haystack.includes(q)
})
}
function deviceSummary(item) {
const s = statuses.get(item.id)
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 setFilter(filter) { activeFilter = filter; $$('[data-filter]').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.filter === filter))); renderHomeCards() }
function deviceSummary(item) { const s = statuses.get(item.id), sum = s?.summary || {}; return { s, device:sum.device||{}, sim:sum.sim||{}, network:sum.network||{}, data:sum.data||{}, sms:sum.sms||{}, system:sum.system||{}, ota:sum.ota||{} } }
function renderHomeCards() {
const root = $('homeDeviceGrid')
renderFleetStats()
if (!instances.length) {
root.innerHTML = '<section class="empty-state mini-empty"><h3>未配置设备</h3><p>点击“添加设备”新增 SimAdmin 地址后,这里会出现设备卡片。</p></section>'
return
}
const list = filteredInstances()
if (!list.length) {
root.innerHTML = '<section class="empty-state"><h3>没有匹配设备</h3><p>调整搜索关键字或状态筛选。</p></section>'
return
}
root.innerHTML = list.map(item => {
const { s, device, sim, network, data, sms, system, ota } = deviceSummary(item)
const dataState = firstValue(data.active, data.connected, data.enabled)
return `<article class="sim-device-card ${pillClass(s)}" data-open-detail="${escapeHtml(item.id)}" role="button" tabindex="0" aria-label="打开 ${escapeHtml(item.name || item.id)} 详情">
<div class="sim-card-head">
<div>
<p class="eyebrow">${escapeHtml(item.id)}</p>
<h2>${escapeHtml(item.name || item.id)}</h2>
<span class="instance-url">${escapeHtml(item.url)}</span>
</div>
<span class="status-pill ${pillClass(s)}">${escapeHtml(statusText(s))}</span>
</div>
<div class="sim-card-model">${escapeHtml(value([device.manufacturer, device.model].filter(Boolean).join(' ') || device.imei || '未知设备'))}</div>
<div class="sim-card-kpis">
<span><em>SIM</em><b>${escapeHtml(sim.present === false ? '未插卡' : value(sim.iccid))}</b></span>
<span><em>信号</em><b>${escapeHtml(value(firstValue(network.signal, sim.signal)))}</b></span>
<span><em>温度</em><b>${escapeHtml(formatTemperature(system.temperature))}</b></span>
<span><em>流量</em><b>${escapeHtml(formatSpeed(system.networkSpeed))}</b></span>
<span><em>数据</em><b>${escapeHtml(dataState === true ? '已连接' : dataState === false ? '未连接' : '-')}</b></span>
<span><em>短信</em><b>${escapeHtml(sms.total != null ? `${sms.total}` : '-')}</b></span>
</div>
<div class="sim-card-foot">
<span>${escapeHtml(value(network.operator || sim.operator || network.registration))}</span>
<span>${escapeHtml(value(ota.currentVersion || device.firmware || device.revision))}</span>
</div>
</article>`
}).join('')
$$('[data-open-detail]', root).forEach(card => {
const open = () => openDeviceDetail(card.dataset.openDetail)
card.addEventListener('click', open)
card.addEventListener('keydown', (event) => {
if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); open() }
})
})
renderFleetStats(); const query = $('instanceSearch').value, list = filterAndSortFleet(instances, statuses, { filter:activeFilter, query, sort:activeSort }), reason = emptyFleetReason({ total:instances.length, query, filter:activeFilter, visible:list.length })
if (reason) { const copy = { config:['未配置设备','添加第一个 SimAdmin 地址,开始建设你的设备岛。'], search:['搜索无结果','没有设备匹配当前搜索词,请修改或清空搜索。'], filter:['筛选无结果','当前状态下没有设备,可切换到“全部”。'] }[reason]; $('homeDeviceGrid').innerHTML = `<section class="empty-state"><h2>${copy[0]}</h2><p>${copy[1]}</p></section>`; return }
$('homeDeviceGrid').innerHTML = list.map(item => { const { s,device,sim,network,data,sms,system,ota } = deviceSummary(item); return `<article class="sim-device-card ${fleetStatusKind(s)}"><div class="sim-card-head"><div><p class="eyebrow">${escapeHtml(item.id)}</p><h2>${escapeHtml(item.name || item.id)}</h2></div><span class="status-pill">${escapeHtml(statusText(s))}</span></div><p class="instance-url">${escapeHtml(item.url)}</p><p>${escapeHtml(item.description || '暂无描述')}</p><div class="tag-row">${(item.tags||[]).map(tag=>`<span>${escapeHtml(tag)}</span>`).join('')}</div><div class="sim-card-model">${escapeHtml([device.manufacturer,device.model].filter(Boolean).join(' ') || device.imei || '未知设备')}</div><dl class="sim-card-kpis"><div><dt>ICCID / SIM</dt><dd>${escapeHtml(sim.present === false ? '未插卡' : value(sim.iccid))}</dd></div><div><dt>信号</dt><dd>${escapeHtml(value(firstValue(network.signal,sim.signal)))}</dd></div><div><dt>温度</dt><dd>${escapeHtml(formatTemperature(system.temperature))}</dd></div><div><dt>实时流量</dt><dd>${escapeHtml(formatSpeed(system.networkSpeed))}</dd></div><div><dt>短信</dt><dd>${escapeHtml(sms.total != null ? `${sms.total}` : '-')}</dd></div><div><dt>版本 / Commit</dt><dd>${escapeHtml(value(ota.currentVersion || device.firmware || device.revision))}${ota.currentCommit ? ` · ${escapeHtml(ota.currentCommit)}` : ''}</dd></div></dl><div class="card-actions"><button class="animal-btn primary-btn" type="button" data-open-detail="${escapeHtml(item.id)}">查看详情</button><button class="animal-btn default-btn" type="button" data-edit-instance="${escapeHtml(item.id)}">编辑设备</button></div></article>` }).join('')
$$('[data-open-detail]').forEach(b => b.addEventListener('click', () => openDeviceDetail(b.dataset.openDetail))); $$('[data-edit-instance]').forEach(b => b.addEventListener('click', () => openInstanceDialog(b.dataset.editInstance)))
}
function renderInstances() { renderHomeCards() }
function openDeviceDetail(id) {
const item = instances.find(x => x.id === id)
if (!item) return
if (activeId !== id) { requests.invalidate('api-console'); requests.invalidate('login') }
activeId = id
localStorage.setItem('multi-simadmin-active', id)
$('loginBtn').disabled = false
$('openExternal').href = item.url
$('openExternal').classList.remove('disabled')
$('emptyState').hidden = true
$('frame').hidden = false
$('frame').src = item.url
switchView('detail')
renderHomeCards()
renderOverview()
renderDeviceDetails()
renderDetailActions()
function detailCard(title, rows) { return `<article class="device-info-card"><h4>${escapeHtml(title)}</h4><dl>${rows.map(([k,v])=>`<div><dt>${escapeHtml(k)}</dt><dd>${escapeHtml(value(v))}</dd></div>`).join('')}</dl></article>` }
function renderDeviceDetails() { renderDetail() }
function renderDetail() {
const item=activeInstance(), s=activeStatus(); if (!item) return; const {device,sim,network,data,sms,system,ota}=deviceSummary(item)
$('activeKicker').textContent=`${item.id} · ${statusText(s)}`; $('activeName').textContent=item.name||item.id; $('activeMeta').textContent=`${item.url}${item.description ? ` · ${item.description}` : ''}`
const signal=[['状态',statusText(s)],['运营商',network.operator||sim.operator],['信号',firstValue(network.signal,sim.signal)],['温度',formatTemperature(system.temperature)],['流量',formatSpeed(system.networkSpeed)],['数据连接',value(firstValue(data.active,data.connected,data.enabled))]]; $('signalPanel').innerHTML=signal.map(([k,v])=>`<div><span>${k}</span><b>${escapeHtml(value(v))}</b></div>`).join('')
$('overview').innerHTML=[['延迟',s?.latencyMs!=null?`${s.latencyMs}ms`:'-'],['SIM',sim.present===false?'未插卡':sim.iccid],['短信',sms.total],['版本',ota.currentVersion||device.firmware]].map(([k,v])=>`<div class="metric"><span>${k}</span><b>${escapeHtml(value(v))}</b></div>`).join('')
$('detailDeviceGrid').innerHTML=[detailCard('设备信息',[['名称',item.name],['ID',item.id],['地址',item.url],['型号',device.model],['厂商',device.manufacturer],['IMEI',device.imei],['版本',ota.currentVersion||device.firmware],['Commit',ota.currentCommit]]),detailCard('SIM 卡',[['ICCID',sim.iccid],['IMSI',sim.imsi],['号码',sim.phoneNumber],['短信中心',sim.smsCenter]]),detailCard('蜂窝网络',[['运营商',network.operator],['注册状态',network.registration],['网络制式',network.accessTechnology],['信号',network.signal]]),detailCard('系统资源',[['温度',formatTemperature(system.temperature)],['CPU',Array.isArray(system.cpuLoad)?system.cpuLoad.join(' / '):system.cpuLoad],['内存',JSON.stringify(system.memory||{})],['磁盘',JSON.stringify(system.disk||{})]])].join('')
const eps=['/api/device','/api/sim','/api/network','/api/stats','/api/sms/stats','/api/ota/status']; $('detailActions').innerHTML=`<button type="button" data-view="rawpage">打开原始管理页</button>${eps.map(ep=>`<button type="button" data-detail-ep="${ep}"><span>在工作台打开</span><code>${ep}</code></button>`).join('')}`; $$('[data-detail-ep]').forEach(b=>b.addEventListener('click',()=>{ switchView('api'); $('endpointInput').value=b.dataset.detailEp; $('methodSelect').value='GET'; updateWriteUI() })); $$('[data-view]',$('detailActions')).forEach(b=>b.addEventListener('click',()=>switchView(b.dataset.view)))
}
function setActiveHome() {
requests.invalidate('api-console'); requests.invalidate('login')
activeId = null
localStorage.removeItem('multi-simadmin-active')
$('loginBtn').disabled = true
$('openExternal').href = '#'
$('openExternal').classList.add('disabled')
$('emptyState').hidden = false
$('frame').hidden = true
$('frame').removeAttribute('src')
switchView('home')
renderHomeCards()
}
function renderHero() {
const { item, s, device, sim, network, system, data, ota } = getMonitorData()
if (!item) {
$('activeKicker').textContent = 'DEVICE DETAIL'
$('activeName').textContent = '选择一个 SimAdmin 设备'
$('activeMeta').textContent = '从首页卡片进入详情页后,可查看状态并使用功能交互。'
$('signalPanel').innerHTML = [
['状态', '等待选择'], ['设备', '-'], ['温度', '-'], ['流量速率', '-'],
].map(([k, v]) => `<div class="signal-item"><span>${k}</span><b>${v}</b></div>`).join('')
return
}
$('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)],
['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, s, sim, network, data, sms, system } = getMonitorData()
if (!item) return
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()
}
function renderDeviceDetails() {
const { item, device, sim, network, data, sms, ota, system, s } = getMonitorData()
if (!item) return
$('detailDeviceGrid').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 renderDetailActions() {
const disabled = activeId ? '' : ' disabled'
const primary = [
['设备状态', '/api/device'], ['SIM 信息', '/api/sim'], ['蜂窝网络', '/api/network'], ['系统资源', '/api/stats'],
['短信统计', '/api/sms/stats'], ['通话记录', '/api/calls'], ['OTA 状态', '/api/ota/status'], ['数据连接', '/api/data'],
]
$('detailActions').innerHTML = `
<button type="button" class="detail-action raw-action" data-view="rawpage">打开原始管理页</button>
${primary.map(([label, ep]) => `<button type="button" class="detail-action"${disabled} data-detail-ep="${escapeHtml(ep)}"><span>${escapeHtml(label)}</span><code>${escapeHtml(ep)}</code></button>`).join('')}
`
$$('[data-detail-ep]', $('detailActions')).forEach(btn => btn.addEventListener('click', () => {
switchView('api')
$('endpointInput').value = btn.dataset.detailEp
$('methodSelect').value = inferMethod(btn.dataset.detailEp)
runEndpoint()
}))
$$('[data-view]', $('detailActions')).forEach(btn => btn.addEventListener('click', () => switchView(btn.dataset.view)))
}
function renderFeatures() { renderDeviceDetails(); renderDetailActions() }
function renderEndpointList() {
const groups = catalog.length ? catalog : modules.map(m => ({ id: m.id, name: m.title, endpoints: m.endpoints }))
$('endpointList').innerHTML = groups.map(group => `<section class="endpoint-group">
<h4>${escapeHtml(group.name || group.id)}</h4>
${(group.endpoints || []).map(ep => `<button class="endpoint-row" data-ep="${escapeHtml(ep)}"><span>${escapeHtml(inferMethod(ep))}</span> ${escapeHtml(ep)}</button>`).join('')}
</section>`).join('')
$$('.endpoint-row', $('endpointList')).forEach(row => row.addEventListener('click', () => {
$('endpointInput').value = row.dataset.ep
$('methodSelect').value = inferMethod(row.dataset.ep)
$$('.endpoint-row', $('endpointList')).forEach(x => x.classList.toggle('active', x === row))
}))
}
function switchView(view) {
view = resolveView(view, Boolean(activeId))
routeView(view)
}
function selectInstance(id) { openDeviceDetail(id) }
function openInstanceDialog(id = '') {
const item = id ? instances.find(x => x.id === id) : null
$('instanceOriginalId').value = item?.id || ''
$('instanceFormRibbon').textContent = item ? '编辑设备地址' : '添加设备地址'
$('instanceIdInput').value = item?.id || ''
$('instanceNameInput').value = item?.name || ''
$('instanceUrlInput').value = item?.url || ''
$('instanceDescInput').value = item?.description || ''
$('instanceTagsInput').value = (item?.tags || []).join(', ')
$('instancePasswordInput').value = ''
$('instancePasswordInput').placeholder = item?.auth?.hasPassword ? '留空将清除已保存密码;输入新密码可替换' : '留空表示无密码'
$('deleteInstanceBtn').hidden = !item
$('instanceDialog').showModal()
setTimeout(() => (item ? $('instanceNameInput') : $('instanceIdInput')).focus(), 50)
}
async function reloadConfigAndRefresh({ selectId = activeId, toast = '' } = {}) {
await loadConfig({ preserveSelection: selectId })
await refreshStatus({ silent: true })
if (selectId && instances.some(x => x.id === selectId)) selectInstance(selectId)
else setActiveHome()
if (toast) showToast(toast)
}
async function saveInstanceFromForm() {
const originalId = $('instanceOriginalId').value
const payload = instancePayloadFromForm()
payload.url = normalizeTargetUrl(payload.url)
const data = originalId ? await api.updateInstance(originalId, payload) : await api.createInstance(payload)
$('instanceDialog').close()
await reloadConfigAndRefresh({ selectId: data.instance?.id || payload.id, toast: originalId ? '设备地址已更新' : '设备地址已添加' })
}
async function deleteInstance(id) {
const item = instances.find(x => x.id === id)
if (!item) return
if (!confirm(`确定删除设备“${item.name || item.id}”?\n配置会立即写入 config.json。`)) return
try { await api.deleteInstance(id) } catch (error) { showToast(error.message, 4200); return }
await reloadConfigAndRefresh({ selectId: null, toast: '设备地址已删除' })
}
async function deleteCurrentInstanceFromDialog() {
const id = $('instanceOriginalId').value
if (!id) return
$('instanceDialog').close()
await deleteInstance(id)
}
async function loadConfig({ preserveSelection = activeId } = {}) {
const [data, catalogData] = await Promise.all([api.config(), api.catalog().catch(() => ({ groups: [] }))])
instances = data.instances || []
configPath = data.configPath || 'config.json'
$('configPath').textContent = configPath
catalog = catalogData.groups || []
const saved = preserveSelection || localStorage.getItem('multi-simadmin-active')
activeId = saved && instances.some(x => x.id === saved) ? saved : null
renderEndpointList()
renderHomeCards()
if (activeId) openDeviceDetail(activeId)
}
async function refreshStatus({ silent = false } = {}) {
$('refreshBtn').disabled = true
if (!silent) showToast('正在刷新全部实例…', 1200)
function openDeviceDetail(id) { if (!instances.some(x=>x.id===id)) return; if(activeId!==id){ requests.invalidate('api-console'); requests.invalidate('login'); loginDialogEpoch += 1; $('submitLogin').disabled=false; pendingApiRequest=null; $('runEndpoint').disabled=false; $('runEndpoint').textContent='发送请求'; frameClear(); clearApiOutput() } activeId=id; localStorage.setItem('multi-simadmin-active',id); const item=activeInstance(); $('openExternal').href=item.url; renderDetail(); switchView('detail') }
function setActiveHome() { requests.invalidate('api-console'); requests.invalidate('login'); loginDialogEpoch += 1; $('submitLogin').disabled=false; pendingApiRequest=null; $('runEndpoint').disabled=false; $('runEndpoint').textContent='发送请求'; activeId=null; localStorage.removeItem('multi-simadmin-active'); frameClear(); switchView('home'); renderHomeCards() }
function openInstanceDialog(id='') { instanceDialogEpoch += 1; const item=instances.find(x=>x.id===id); $('instanceDialogTitle').textContent=item?'编辑设备':'添加设备'; $('instanceOriginalId').value=item?.id||''; $('instanceIdInput').value=item?.id||''; $('instanceNameInput').value=item?.name||''; $('instanceUrlInput').value=item?.url||''; $('instanceDescInput').value=item?.description||''; $('instanceTagsInput').value=(item?.tags||[]).join(', '); $('instancePasswordInput').value=''; $('clearPasswordConfirm').checked=false; $('passwordPreserve').checked=Boolean(item); $('passwordClear').checked=!item; $('passwordPreserve').disabled=!item; $('deleteInstanceBtn').hidden=!item; $('instanceFormError').hidden=true; updatePasswordUI(); $('instanceDialog').showModal() }
function updatePasswordUI(){ const action=$('instanceForm').elements.passwordAction.value; $('newPasswordLabel').hidden=action!=='set'; $('instancePasswordInput').required=action==='set'; $('clearPasswordLabel').hidden=action!=='clear' || !$('instanceOriginalId').value }
async function saveInstanceFromForm(){ const epoch=instanceDialogEpoch, editing=Boolean($('instanceOriginalId').value), btn=$('saveInstanceBtn'); if(btn.disabled)return; btn.dataset.idleLabel='保存'; setPending(btn,true,'保存中…'); $('instanceFormError').hidden=true; try { const payload=instanceDraftPayload({ id:$('instanceIdInput').value,name:$('instanceNameInput').value,url:normalizeTargetUrl($('instanceUrlInput').value),description:$('instanceDescInput').value,tags:tagsFromInput($('instanceTagsInput').value),passwordAction:$('instanceForm').elements.passwordAction.value,password:$('instancePasswordInput').value,clearConfirmed:$('clearPasswordConfirm').checked },editing); const data=editing?await api.updateInstance($('instanceOriginalId').value,payload):await api.createInstance(payload); if(epoch!==instanceDialogEpoch)return; $('instanceDialog').close(); await reload({ selectId:data.instance?.id||payload.id }); showToast(editing?'设备已更新':'设备已添加') } catch(error){ if(epoch!==instanceDialogEpoch)return; $('instanceFormError').textContent=error.message; $('instanceFormError').hidden=false } finally { if(epoch===instanceDialogEpoch)setPending(btn,false,'保存中…') } }
function openDeleteDialog(){ const item=instances.find(x=>x.id===$('instanceOriginalId').value); deleteDialogEpoch += 1; if(!item)return; pendingDeleteId=item.id; $('deleteDetails').innerHTML=`<dl><div><dt>名称</dt><dd>${escapeHtml(item.name||item.id)}</dd></div><div><dt>ID</dt><dd>${escapeHtml(item.id)}</dd></div><div><dt>URL</dt><dd>${escapeHtml(item.url)}</dd></div></dl>`; $('deleteError').hidden=true; $('deleteDialog').showModal() }
async function confirmDelete(){ const epoch=deleteDialogEpoch, btn=$('confirmDeleteBtn'); if(btn.disabled)return; btn.dataset.idleLabel='确认删除'; setPending(btn,true,'删除中…'); try{ await api.deleteInstance(pendingDeleteId); if(epoch!==deleteDialogEpoch)return; $('deleteDialog').close(); $('instanceDialog').close(); await reload({selectId:null}); setActiveHome(); showToast('设备已删除') }catch(error){if(epoch!==deleteDialogEpoch)return;$('deleteError').textContent=error.message;$('deleteError').hidden=false}finally{if(epoch===deleteDialogEpoch)setPending(btn,false,'删除中…')} }
function renderEndpointList(){ const q=$('endpointSearch').value.trim().toLowerCase(), groups=catalog.length?catalog:modules; $('endpointList').innerHTML=groups.map(g=>{ const eps=(g.endpoints||[]).filter(ep=>ep.toLowerCase().includes(q)); return eps.length?`<section><h3>${escapeHtml(g.name||g.id)}</h3>${eps.map(ep=>`<button type="button" class="endpoint-row" data-ep="${escapeHtml(ep)}">${escapeHtml(ep)}</button>`).join('')}</section>`:'' }).join('')||'<p>没有匹配接口</p>'; $$('.endpoint-row').forEach(b=>b.addEventListener('click',()=>{ $('endpointInput').value=b.dataset.ep; $('methodSelect').value='GET'; updateWriteUI(); $$('.endpoint-row').forEach(x=>x.setAttribute('aria-pressed',String(x===b))) })) }
function updateWriteUI(){ const write=isWriteMethod($('methodSelect').value); $('writeWarning').hidden=!write; $('requestBody').disabled=$('methodSelect').value==='GET' }
function clearApiOutput(){ outputText='';outputPrettyText='';$('rawOutput').textContent='设备已切换。选择接口并点击“发送请求”。' }
function parseBody(){ const raw=$('requestBody').value.trim(); return raw?JSON.parse(raw):undefined }
async function runEndpoint(confirmed=false){
if(!activeId)return
let draft
try {
const result = await requests.run('fleet-status', 'fleet', () => api.status())
if (!result.accepted || result.error) {
if (result.accepted && result.error) showToast(result.error.message || '刷新失败', 4200)
return
if(confirmed){ draft=pendingApiRequest; pendingApiRequest=null; if(!draft||draft.owner!==activeId)return }
else {
const method=$('methodSelect').value, endpoint=$('endpointInput').value.trim()
if(!endpoint.startsWith('/')){persistentError('接口路径必须以 / 开头');return}
const body=method==='GET'?undefined:parseBody(); draft={...requestDraft(method,endpoint,body),owner:activeId,body}
if(isWriteMethod(method)){
pendingApiRequest=draft
$('apiDeleteDetails').textContent=`${activeInstance().name||activeId} (${activeId}) · ${method} ${endpoint}`
$('apiDeleteBody').textContent=body===undefined?'(无请求体)':JSON.stringify(body,null,2)
$('apiDeleteDialog').showModal(); return
}
}
const data = result.value
statuses = new Map((data.instances || []).map(item => [item.id, item]))
renderHomeCards()
if (activeId) {
renderOverview()
renderDeviceDetails()
renderDetailActions()
}
if (!silent) showToast(`已刷新 ${statuses.size} 个实例`)
} catch (error) {
showToast(error.message || '刷新失败', 4200)
} finally {
$('refreshBtn').disabled = false
}
}
async function loginActive() {
if (!activeId) return
const item = activeInstance()
if (!item) return
// If config already has a saved password, try direct refresh first; otherwise ask for password.
if (item.auth?.hasPassword) {
const loginOwnerId = activeId
showToast('正在使用本地配置密码刷新会话…', 1600)
const result = await requests.run('login', loginOwnerId, () => api.login(loginOwnerId))
if (!result.accepted || result.ownerId !== activeId) return
if (result.error) { showToast(result.error.message || '登录失败', 4200); return }
await refreshStatus({ silent: true })
return
}
$('loginDialog').showModal()
$('loginDialog').dataset.instanceId = activeId
setTimeout(() => $('passwordInput').focus(), 50)
}
function parseBody() {
const raw = $('requestBody').value.trim()
if (!raw) return undefined
return JSON.parse(raw)
}
async function runEndpoint() {
if (!activeId) {
showToast('请先在首页选择设备卡片')
return
}
const requestOwnerId = activeId
const endpoint = $('endpointInput').value.trim()
const method = $('methodSelect').value
if (!endpoint.startsWith('/')) {
showToast('接口路径必须以 / 开头')
return
}
$('rawOutput').textContent = '调用中…'
try {
const init = { method }
if (method !== 'GET') {
init.headers = { 'content-type': 'application/json' }
const body = parseBody()
if (body !== undefined) init.body = JSON.stringify(body)
}
const started = performance.now()
const result = await requests.run('api-console', requestOwnerId, async () => {
const normalized = endpoint.startsWith('/') ? endpoint : `/${endpoint}`
const res = await api.proxy(requestOwnerId, normalized, init)
return { res, text: await res.text() }
})
if (!result.accepted || result.ownerId !== activeId) return
if (result.error) {
$('rawOutput').textContent = result.error.message || String(result.error)
showToast('接口调用失败', 4200)
return
}
const { res, text } = result.value
const elapsed = Math.round(performance.now() - started)
let formatted = text
try { formatted = JSON.stringify(JSON.parse(text), null, 2) } catch {}
$('rawOutput').textContent = `HTTP ${res.status} · ${elapsed}ms · ${method} ${endpoint}\n\n${formatted}`
} catch (error) {
$('rawOutput').textContent = error.message || String(error)
showToast('接口调用失败', 4200)
}
}
function bindEvents() {
$('refreshBtn').addEventListener('click', () => refreshStatus())
$('homeBtn').addEventListener('click', setActiveHome)
$('addInstanceBtn').addEventListener('click', () => openInstanceDialog())
$('loginBtn').addEventListener('click', loginActive)
$('runEndpoint').addEventListener('click', runEndpoint)
$('instanceSearch').addEventListener('input', renderHomeCards)
$('backToFleetBtn').addEventListener('click', setActiveHome)
$('densityBtn').addEventListener('click', () => {
document.body.classList.toggle('compact')
localStorage.setItem('multi-simadmin-density', document.body.classList.contains('compact') ? 'compact' : 'normal')
})
$$('[data-view]').forEach(btn => btn.addEventListener('click', () => switchView(btn.dataset.view)))
$$('#statusFilters .tab-leaf').forEach(btn => btn.addEventListener('click', () => {
activeFilter = btn.dataset.filter
$$('#statusFilters .tab-leaf').forEach(x => x.classList.toggle('active', x === btn))
renderHomeCards()
}))
$('loginForm').addEventListener('submit', async (event) => {
event.preventDefault()
const loginInstanceId = $('loginDialog').dataset.instanceId
if (!loginInstanceId) return
const password = $('passwordInput').value
$('passwordInput').value = ''
$('loginDialog').close()
const result = await requests.run('login', loginInstanceId, () => api.login(loginInstanceId, password))
if (!result.accepted || result.ownerId !== activeId) return
if (result.error) { showToast(result.error.message || '登录失败', 4200); return }
showToast('会话已刷新')
await refreshStatus({ silent: true })
})
$('instanceForm').addEventListener('submit', async (event) => {
event.preventDefault()
try {
await saveInstanceFromForm()
} catch (error) {
showToast(error.message || '保存失败', 5200)
}
})
$('deleteInstanceBtn').addEventListener('click', deleteCurrentInstanceFromDialog)
$('endpointInput').addEventListener('keydown', (event) => {
if (event.key === 'Enter') runEndpoint()
})
}
function restorePrefs() {
if (localStorage.getItem('multi-simadmin-density') === 'compact') document.body.classList.add('compact')
switchView(localStorage.getItem('multi-simadmin-view') || 'home')
}
try {
restorePrefs()
bindEvents()
await loadConfig()
await refreshStatus({ silent: true })
autoRefreshTimer = setInterval(() => refreshStatus({ silent: true }), 30000)
window.addEventListener('beforeunload', () => clearInterval(autoRefreshTimer))
} catch (error) {
console.error(error)
showToast(error.message || '初始化失败', 8000)
$('rawOutput').textContent = error.stack || String(error)
} catch(error){ persistentError(`请求参数无效:${error.message||error}`); return }
const {owner,method,path:endpoint,body}=draft, btn=$('runEndpoint'), token=requests.next('api-console'); if(btn.disabled)return
btn.dataset.idleLabel='发送请求'; setPending(btn,true,'发送中…'); $('rawOutput').textContent='请求进行中…'; persistentError(''); const started=performance.now()
try{ const init={method}; if(isWriteMethod(method)){ const prepared=await api.prepareConfirmation({instanceId:owner,method,path:endpoint,body}); if(!requests.isCurrent('api-console',token)||owner!==activeId)return; init.headers={'content-type':'application/json','x-confirmation-token':prepared.token}; if(body!==undefined)init.body=JSON.stringify(body) } const res=await api.proxy(owner,endpoint,init), text=await res.text(); if(!requests.isCurrent('api-console',token)||owner!==activeId)return; const elapsed=Math.round(performance.now()-started); outputText=text; try{outputPrettyText=JSON.stringify(JSON.parse(text),null,2)}catch{outputPrettyText=text} $('rawOutput').textContent=`HTTP ${res.status} ${res.statusText} · ${elapsed}ms · ${draft.method} ${draft.path}\n\n${$('outputPretty').getAttribute('aria-pressed')==='true'?outputPrettyText:outputText}`; if(!res.ok)persistentError(`API 请求失败:HTTP ${res.status} ${res.statusText}\n${text}`) }catch(error){ if(requests.isCurrent('api-console',token)&&owner===activeId){$('rawOutput').textContent=error.message||String(error);persistentError(`API 请求失败:${error.message||error}`)} }finally{ if(requests.isCurrent('api-console',token)) setPending(btn,false,'发送中…') }
}
async function refreshStatus({silent=false}={}){ const btn=$('refreshBtn'), token=requests.next('fleet-status'); btn.disabled=true; try{const data=await api.status(); if(!requests.isCurrent('fleet-status',token))return; statuses=new Map((data.instances||[]).map(x=>[x.id,x]));lastSuccessfulRefresh=new Date();$('staleBanner').hidden=true;persistentError('');renderHomeCards();if(activeId)renderDetail();if(!silent)showToast(`已刷新 ${statuses.size} 个设备`)}catch(error){if(!requests.isCurrent('fleet-status',token))return;$('staleBanner').textContent=`刷新失败,当前显示上次成功数据。上次成功:${lastSuccessfulRefresh?lastSuccessfulRefresh.toLocaleString():'尚无'}${error.message}`;$('staleBanner').hidden=false;persistentError(`状态刷新失败:${error.message}`)}finally{if(requests.isCurrent('fleet-status',token))btn.disabled=false} }
async function loadConfig(){ const [data,cat]=await Promise.all([api.config(),api.catalog().catch(()=>({groups:[]}))]);instances=data.instances||[];$('configPath').textContent=data.configPath||'config.json';catalog=cat.groups||[];renderEndpointList();const saved=localStorage.getItem('multi-simadmin-active');activeId=saved&&instances.some(x=>x.id===saved)?saved:null;renderHomeCards() }
async function reload({selectId=activeId}={}){await loadConfig();await refreshStatus({silent:true});if(selectId&&instances.some(x=>x.id===selectId))openDeviceDetail(selectId);else setActiveHome()}
async function initialize(){ $('initialLoading').hidden=false;$('fatalState').hidden=true;$('homeContent').hidden=true;persistentError('');try{await loadConfig();await refreshStatus({silent:true});$('initialLoading').hidden=true;$('homeContent').hidden=false;if(activeId){renderDetail();switchView(resolveView(localStorage.getItem('multi-simadmin-view')||'home',true))}else switchView('home')}catch(error){$('initialLoading').hidden=true;$('fatalText').textContent=error.message;$('fatalState').hidden=false} }
function bindEvents(){ $$('[data-dialog-cancel]').forEach(button=>button.addEventListener('click',()=>closeDialog(button.dataset.dialogCancel))); for(const id of ['instanceDialog','deleteDialog','loginDialog','apiDeleteDialog']) $(id).addEventListener('cancel',()=>invalidateDialog(id)); $('refreshBtn').addEventListener('click',()=>refreshStatus());$('addInstanceBtn').addEventListener('click',()=>openInstanceDialog());$('backToFleetBtn').addEventListener('click',setActiveHome);$('editActiveBtn').addEventListener('click',()=>openInstanceDialog(activeId));$('instanceSearch').addEventListener('input',renderHomeCards);$('sortSelect').addEventListener('change',()=>{activeSort=$('sortSelect').value;renderHomeCards()});$('densityBtn').addEventListener('click',()=>{const compact=document.body.classList.toggle('compact');$('densityBtn').setAttribute('aria-pressed',String(compact))});$$('[data-filter]').forEach(b=>b.addEventListener('click',()=>setFilter(b.dataset.filter)));$$('[data-view]').forEach(b=>b.addEventListener('click',e=>{e.preventDefault();switchView(b.dataset.view)}));$$('[name=passwordAction]').forEach(r=>r.addEventListener('change',updatePasswordUI));$('instanceForm').addEventListener('submit',e=>{e.preventDefault();saveInstanceFromForm()});$('deleteInstanceBtn').addEventListener('click',openDeleteDialog);$('confirmDeleteBtn').addEventListener('click',e=>{e.preventDefault();confirmDelete()});$('endpointSearch').addEventListener('input',renderEndpointList);$('methodSelect').addEventListener('change',updateWriteUI);$('runEndpoint').addEventListener('click',()=>runEndpoint());$('confirmApiDelete').addEventListener('click',e=>{e.preventDefault();$('apiDeleteDialog').close();runEndpoint(true)});$('outputPretty').addEventListener('click',()=>{$('outputPretty').setAttribute('aria-pressed','true');$('outputRaw').setAttribute('aria-pressed','false');if(outputText)$('rawOutput').textContent=$('rawOutput').textContent.split('\n\n')[0]+'\n\n'+outputPrettyText});$('outputRaw').addEventListener('click',()=>{$('outputPretty').setAttribute('aria-pressed','false');$('outputRaw').setAttribute('aria-pressed','true');if(outputText)$('rawOutput').textContent=$('rawOutput').textContent.split('\n\n')[0]+'\n\n'+outputText});$('copyOutput').addEventListener('click',async()=>{try{await navigator.clipboard.writeText($('rawOutput').textContent);showToast('结果已复制')}catch{showToast('复制不可用,请手动选择文本')}});$('dismissError').addEventListener('click',()=>persistentError(''));$('retryInit').addEventListener('click',initialize);$('loginBtn').addEventListener('click',()=>{loginDialogEpoch+=1;$('submitLogin').disabled=false;$('loginDialog').dataset.instanceId=activeId;$('passwordInput').value='';$('loginError').textContent='';$('loginError').hidden=true;$('loginDialog').showModal()});$('loginForm').addEventListener('submit',async e=>{e.preventDefault();const epoch=loginDialogEpoch,id=$('loginDialog').dataset.instanceId,btn=$('submitLogin');btn.disabled=true;$('loginError').hidden=true;const password=$('passwordInput').value;const result=await requests.run('login',id,()=>api.login(id,password));$('passwordInput').value='';if(epoch!==loginDialogEpoch)return;if(result.accepted&&id===activeId){if(result.error){$('loginError').textContent=result.error.message;$('loginError').hidden=false}else if(!result.value.authenticated){$('loginError').textContent='登录失败,请检查密码';$('loginError').hidden=false}else{$('loginDialog').close();await refreshStatus({silent:true});showToast('会话已刷新')}}if(epoch===loginDialogEpoch)btn.disabled=false});updateWriteUI() }
bindEvents(); await initialize()
+48 -153
View File
@@ -1,168 +1,63 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Multi SimAdmin Island</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Nunito:wght@400;500;600;700;800;900&family=Noto+Sans+SC:wght@400;500;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/styles.css" />
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>设备首页 · Multi SimAdmin Island</title><link rel="stylesheet" href="/styles.css">
</head>
<body>
<div class="island-bg" aria-hidden="true">
<span class="cloud cloud-a"></span>
<span class="cloud cloud-b"></span>
<span class="leaf leaf-a"></span>
<span class="leaf leaf-b"></span>
</div>
<div id="appShell" class="sim-shell">
<header class="sim-topbar island-card pattern-app-teal">
<div>
<p class="eyebrow">MULTI SIMADMIN</p>
<h1>SimAdmin 设备卡片墙</h1>
<p class="topbar-copy">首页直接展示每个 SimAdmin 设备卡片;点击卡片进入设备详情页,再进行状态查看和功能交互。</p>
</div>
<div class="hero-actions">
<button id="homeBtn" class="animal-btn default-btn" type="button">设备首页</button>
<button id="refreshBtn" class="animal-btn primary-btn" type="button">刷新状态</button>
<button id="addInstanceBtn" class="animal-btn primary-btn" type="button"> 添加设备</button>
<button id="densityBtn" class="animal-btn default-btn" type="button">切换密度</button>
</div>
<div class="island-bg" aria-hidden="true"></div>
<div id="appShell" class="app-shell">
<aside class="desktop-sidebar" aria-label="主导航">
<a class="brand" href="#" data-view="home"><span>🏝️</span><b>SIM 岛</b></a>
<nav class="side-nav">
<button type="button" data-view="home" aria-current="page"><span>设备首页</span></button>
<button type="button" data-view="detail"><span>设备详情</span></button>
<button type="button" data-view="rawpage"><span>原始页面</span></button>
<button type="button" data-view="api"><span>API 工作台</span></button>
</nav>
<div class="sidebar-foot"><span>本地配置</span><code id="configPath">config.json</code></div>
</aside>
<div class="app-content">
<header class="sim-topbar">
<div><p class="eyebrow">MULTI SIMADMIN</p><h1 id="pageHeading">设备首页</h1><p id="pageSubtitle">集中巡检你的 SimAdmin 设备</p></div>
<div class="hero-actions"><button id="refreshBtn" class="animal-btn default-btn" type="button">刷新状态</button><button id="addInstanceBtn" class="animal-btn primary-btn" type="button"> 添加设备</button></div>
</header>
<div id="persistentError" class="persistent-message error" role="alert" hidden><span id="persistentErrorText"></span><button id="dismissError" type="button">关闭</button></div>
<div id="staleBanner" class="persistent-message warn" role="status" hidden></div>
<main class="sim-main">
<section id="homeView" class="view active home-view" aria-label="SimAdmin 卡片首页">
<section class="home-toolbar island-card pattern-default">
<div>
<div class="section-ribbon color-app-green">设备卡片墙</div>
<p class="board-caption">每张卡片对应一个 SimAdmin;卡片上直接显示在线状态、SIM、信号、温度、流量和短信摘要。</p>
</div>
<div class="home-tools">
<section class="wallet-row" id="fleetStats"></section>
<label class="search-pill" aria-label="搜索设备">
<span></span>
<input id="instanceSearch" type="search" placeholder="搜索设备、标签、地址、ICCID…" autocomplete="off" />
</label>
<nav class="island-tabs" id="statusFilters" aria-label="状态筛选">
<button class="tab-leaf active" data-filter="all">全部</button>
<button class="tab-leaf" data-filter="online">在线</button>
<button class="tab-leaf" data-filter="auth">需登录</button>
<button class="tab-leaf" data-filter="offline">离线</button>
<section id="homeView" class="view active home-view" aria-labelledby="pageHeading">
<div id="initialLoading" class="state-card" role="status">正在读取本地配置与设备状态…</div>
<div id="fatalState" class="state-card error" role="alert" hidden><h2>应用无法初始化</h2><p id="fatalText"></p><button id="retryInit" class="animal-btn primary-btn" type="button">重试</button></div>
<section id="homeContent" hidden>
<div class="fleet-overview island-card"><div><h2>设备卡片墙</h2><p>状态、资产与连接信息一览</p></div><div id="fleetStats" class="wallet-row"></div></div>
<div class="home-toolbar island-card">
<label for="instanceSearch">搜索设备</label><input id="instanceSearch" type="search" placeholder="名称、ID、标签、地址、ICCID…" autocomplete="off">
<label for="sortSelect">排序</label><select id="sortSelect"><option value="name">名称</option><option value="status">状态</option><option value="latency">延迟</option></select>
<button id="densityBtn" class="animal-btn default-btn" type="button" aria-pressed="false">紧凑密度</button>
<nav class="status-filters" id="statusFilters" aria-label="设备状态筛选">
<button type="button" data-filter="all" aria-pressed="true">全部</button><button type="button" data-filter="online" aria-pressed="false">在线</button><button type="button" data-filter="auth" aria-pressed="false">需登录</button><button type="button" data-filter="offline" aria-pressed="false">离线</button><button type="button" data-filter="unknown" aria-pressed="false">未知</button>
</nav>
</div>
</section>
<section id="homeDeviceGrid" class="home-device-grid" aria-label="设备卡片列表"></section>
</section>
<section id="detailView" class="view detail-view" aria-label="设备详情页">
<header class="workspace-hero island-card pattern-app-teal detail-hero">
<div>
<p id="activeKicker" class="eyebrow">DEVICE DETAIL</p>
<h1 id="activeName">选择一个 SimAdmin 设备</h1>
<p id="activeMeta">从首页设备卡片进入详情页后,可查看完整状态并使用功能入口。</p>
</div>
<div class="hero-actions">
<button id="backToFleetBtn" type="button" class="animal-btn default-btn">← 返回卡片墙</button>
<button id="loginBtn" type="button" class="animal-btn default-btn" disabled>登录/刷新会话</button>
<a id="openExternal" href="#" target="_blank" rel="noreferrer" class="animal-btn default-btn disabled">原站打开</a>
</div>
</header>
<section class="detail-layout">
<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 monitor-kpis" aria-label="关键状态指标"></section>
<section class="module-board island-card pattern-default device-detail-board">
<div class="board-head">
<div class="section-ribbon color-app-green">设备详情</div>
<p class="board-caption">设备、SIM、蜂窝网络、系统资源、温度、流量、短信与版本。</p>
</div>
<div id="detailDeviceGrid" class="device-detail-grid"></div>
</section>
<section class="island-card pattern-purple detail-actions-card">
<div class="section-ribbon color-purple">功能交互</div>
<div id="detailActions" class="detail-actions"></div>
</section>
<section id="homeDeviceGrid" class="home-device-grid" aria-label="设备卡片列表"></section>
</section>
</section>
<section id="rawpageView" class="view rawpage-view">
<div class="frame-toolbar island-card pattern-app-blue">
<button type="button" class="animal-btn default-btn" data-view="detail">← 返回详情</button>
<span id="frameHint">原始 SimAdmin 页面会在下方显示;若目标站禁止 iframe,请使用“原站打开”。</span>
</div>
<section id="emptyState" class="empty-state island-card pattern-default">
<div class="section-ribbon color-app-pink">等待选择</div>
<h3>还没有选择设备</h3>
<p>先在首页点击一个设备卡片,再进入原始页面。</p>
</section>
<iframe id="frame" class="frame" title="SimAdmin instance" hidden></iframe>
<section id="detailView" class="view detail-view" aria-label="设备详情">
<header class="workspace-hero island-card"><div><p id="activeKicker" class="eyebrow">DEVICE DETAIL</p><h2 id="activeName">设备详情</h2><p id="activeMeta"></p></div><div class="hero-actions"><button id="backToFleetBtn" class="animal-btn default-btn" type="button">返回首页</button><button id="editActiveBtn" class="animal-btn default-btn" type="button">编辑设备</button><button id="loginBtn" class="animal-btn default-btn" type="button">登录/刷新会话</button><a id="openExternal" class="animal-btn default-btn" href="#" target="_blank" rel="noopener noreferrer">原站打开</a></div></header>
<section class="detail-layout"><section class="signal-board island-card"><h3>实时状态</h3><div id="signalPanel" class="signal-grid"></div></section><section id="overview" class="nook-metrics" aria-label="关键指标"></section><section class="island-card detail-board"><h3>设备详情</h3><div id="detailDeviceGrid" class="device-detail-grid"></div></section><section class="island-card detail-actions-card"><h3>功能入口</h3><div id="detailActions" class="detail-actions"></div></section></section>
</section>
<section id="apiView" class="view api-layout">
<aside class="api-catalog island-card pattern-purple">
<div class="section-ribbon color-purple">调试接口</div>
<div id="endpointList" class="endpoint-list"></div>
</aside>
<section class="api-console island-card pattern-default">
<div class="console-toolbar">
<select id="methodSelect" aria-label="请求方法">
<option>GET</option>
<option>POST</option>
<option>DELETE</option>
</select>
<input id="endpointInput" value="/api/device" spellcheck="false" aria-label="接口路径" />
<button id="runEndpoint" class="animal-btn primary-btn" type="button">发送</button>
</div>
<textarea id="requestBody" class="request-body" placeholder='POST/DELETE 请求体 JSON,例如 {"enabled":true}'></textarea>
<pre id="rawOutput" class="raw-output">从设备详情页进入后,可对当前设备调用代理接口。</pre>
</section>
<section id="rawpageView" class="view rawpage-view" aria-label="原始管理页"><div class="frame-toolbar island-card"><button type="button" class="animal-btn default-btn" data-view="detail">返回详情</button><span id="frameHint">若目标站禁止 iframe,请使用“原站打开”。</span></div><iframe id="frame" class="frame" title="SimAdmin 原始管理页面" hidden></iframe></section>
<section id="apiView" class="view api-layout" aria-label="API 工作台">
<aside class="api-catalog island-card"><h2>接口目录</h2><label for="endpointSearch">搜索接口</label><input id="endpointSearch" type="search" placeholder="搜索 endpoint"><div id="endpointList" class="endpoint-list"></div></aside>
<section class="api-console island-card"><div class="console-toolbar"><label for="methodSelect">方法</label><select id="methodSelect"><option>GET</option><option>POST</option><option>PUT</option><option>PATCH</option><option>DELETE</option></select><label for="endpointInput">路径</label><input id="endpointInput" value="/api/device" spellcheck="false"><button id="runEndpoint" class="animal-btn primary-btn" type="button">发送请求</button></div><div id="writeWarning" class="danger-warning" role="alert" hidden>写操作可能改变设备状态。发送前将创建一次性确认令牌。</div><label for="requestBody">JSON 请求体(GET 不发送请求体)</label><textarea id="requestBody" class="request-body" placeholder='{"enabled":true}'></textarea><div class="output-tools"><button id="outputPretty" type="button" aria-pressed="true">Pretty</button><button id="outputRaw" type="button" aria-pressed="false">Raw</button><button id="copyOutput" type="button">复制结果</button></div><pre id="rawOutput" class="raw-output">选择接口不会自动发送。确认设备和参数后点击“发送请求”。</pre></section>
</section>
</main>
<footer class="config-note sim-config-note">
<span>本地配置</span>
<code id="configPath">config.json</code>
</footer>
</div>
<dialog id="loginDialog">
<form method="dialog" id="loginForm" class="login-card island-card pattern-default">
<div class="section-ribbon color-app-teal">会话通行证</div>
<h3>登录 SimAdmin</h3>
<p>输入该实例管理员密码;只发送到本地聚合服务,不写入浏览器存储。</p>
<input id="passwordInput" type="password" placeholder="管理员密码" autocomplete="current-password" />
<menu>
<button value="cancel" class="animal-btn default-btn">取消</button>
<button id="submitLogin" value="default" class="animal-btn primary-btn">登录</button>
</menu>
</form>
</dialog>
<dialog id="instanceDialog">
<form method="dialog" id="instanceForm" class="instance-form island-card pattern-default">
<div id="instanceFormRibbon" class="section-ribbon color-app-green">添加设备地址</div>
<p class="form-help">配置会立即写入本地 config.json,并更新左侧设备列表。</p>
<input type="hidden" id="instanceOriginalId" />
<label>设备 ID <input id="instanceIdInput" required pattern="[a-zA-Z0-9_.-]+" placeholder="home-cpe" /></label>
<label>显示名称 <input id="instanceNameInput" required placeholder="客厅 CPE" /></label>
<label>设备地址 <input id="instanceUrlInput" required type="url" placeholder="http://192.168.1.1" /></label>
<label>描述 <textarea id="instanceDescInput" placeholder="位置、用途或备注"></textarea></label>
<label>标签 <input id="instanceTagsInput" placeholder="home, 5g, backup" /></label>
<label>管理员密码 <input id="instancePasswordInput" type="password" placeholder="留空表示无密码或清除已保存密码" autocomplete="new-password" /></label>
<menu class="form-menu">
<button value="cancel" class="animal-btn default-btn">取消</button>
<button id="deleteInstanceBtn" type="button" class="animal-btn danger-btn" hidden>删除</button>
<button id="saveInstanceBtn" value="default" class="animal-btn primary-btn">保存</button>
</menu>
</form>
</dialog>
<div id="toast" class="toast" hidden></div>
<script src="/app.js" type="module"></script>
</body>
</html>
<nav class="mobile-nav" aria-label="移动端主导航"><button type="button" data-view="home" aria-current="page"><span>首页</span></button><button type="button" data-view="detail"><span>详情</span></button><button type="button" data-view="rawpage"><span>原页</span></button><button type="button" data-view="api"><span>API</span></button></nav>
</div>
<dialog id="loginDialog" aria-labelledby="loginTitle"><form method="dialog" id="loginForm" class="dialog-card"><h2 id="loginTitle">登录 SimAdmin</h2><p>密码仅发送至本地聚合服务。</p><label for="passwordInput">管理员密码</label><input id="passwordInput" type="password" autocomplete="current-password"><p id="loginError" class="form-error" role="alert" hidden></p><menu><button type="button" data-dialog-cancel="loginDialog" class="animal-btn default-btn">取消</button><button id="submitLogin" value="default" class="animal-btn primary-btn">登录</button></menu></form></dialog>
<dialog id="instanceDialog" aria-labelledby="instanceDialogTitle"><form method="dialog" id="instanceForm" class="dialog-card"><h2 id="instanceDialogTitle">添加设备</h2><input type="hidden" id="instanceOriginalId"><label for="instanceIdInput">设备 ID</label><input id="instanceIdInput" required pattern="[a-zA-Z0-9_.-]+"><label for="instanceNameInput">显示名称</label><input id="instanceNameInput" required><label for="instanceUrlInput">设备地址</label><input id="instanceUrlInput" required type="url"><label for="instanceDescInput">描述</label><textarea id="instanceDescInput"></textarea><label for="instanceTagsInput">标签(逗号分隔)</label><input id="instanceTagsInput"><fieldset id="passwordActions"><legend>保存密码</legend><label><input id="passwordPreserve" type="radio" name="passwordAction" value="preserve" checked> 保留现有密码</label><label><input id="passwordSet" type="radio" name="passwordAction" value="set"> 设置新密码</label><label><input id="passwordClear" type="radio" name="passwordAction" value="clear"> 清除密码</label></fieldset><label id="newPasswordLabel" for="instancePasswordInput" hidden>新密码<input id="instancePasswordInput" type="password" autocomplete="new-password"></label><label id="clearPasswordLabel" class="confirm-check" hidden><input id="clearPasswordConfirm" type="checkbox"> 我确认清除已保存密码</label><p id="instanceFormError" class="form-error" role="alert" hidden></p><menu><button type="button" data-dialog-cancel="instanceDialog" class="animal-btn default-btn">取消</button><button id="deleteInstanceBtn" type="button" class="animal-btn danger-btn" hidden>删除设备</button><button id="saveInstanceBtn" value="default" class="animal-btn primary-btn">保存</button></menu></form></dialog>
<dialog id="deleteDialog" aria-labelledby="deleteDialogTitle"><form method="dialog" class="dialog-card"><h2 id="deleteDialogTitle">确认删除设备</h2><div id="deleteDetails" class="delete-details"></div><p>此操作将立即写入本地配置,且不可撤销。</p><p id="deleteError" class="form-error" role="alert" hidden></p><menu><button type="button" data-dialog-cancel="deleteDialog" class="animal-btn default-btn">取消</button><button id="confirmDeleteBtn" value="default" class="animal-btn danger-primary">确认删除</button></menu></form></dialog>
<dialog id="apiDeleteDialog" aria-labelledby="apiDeleteTitle"><form method="dialog" class="dialog-card"><h2 id="apiDeleteTitle">确认发送写操作</h2><p id="apiDeleteDetails"></p><pre id="apiDeleteBody" class="raw-output"></pre><p>该请求将改变设备状态,请核对设备、方法、路径和请求体。</p><menu><button type="button" data-dialog-cancel="apiDeleteDialog" class="animal-btn default-btn">取消</button><button id="confirmApiDelete" value="default" class="animal-btn danger-primary">确认并发送</button></menu></form></dialog>
<div id="toast" class="toast" aria-live="polite" role="status" hidden></div>
<script src="/app.js" type="module"></script>
</body></html>
+1
View File
@@ -18,6 +18,7 @@ export function createApiClient({ fetchImpl = globalThis.fetch } = {}) {
catalog: () => request('/api/catalog'),
status: () => request('/api/status'),
proxy: (id, endpoint, options) => fetchImpl(`/api/proxy/${encodeURIComponent(id)}${endpoint}`, options),
prepareConfirmation: payload => request('/api/proxy-confirmations/prepare', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload) }),
createInstance: payload => request('/api/instances', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload) }),
updateInstance: (id, payload) => request(`/api/instances/${encodeURIComponent(id)}`, { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload) }),
deleteInstance: id => request(`/api/instances/${encodeURIComponent(id)}`, { method: 'DELETE' }),
+6 -1
View File
@@ -1,10 +1,15 @@
export const VIEW_IDS = Object.freeze(['home', 'detail', 'rawpage', 'api'])
export function resolveView(view, hasActiveInstance = false) {
if (!hasActiveInstance && view !== 'home') return 'home'
return VIEW_IDS.includes(view) ? view : hasActiveInstance ? 'detail' : 'home'
}
export function createViewRouter({ panels, controls, storage = globalThis.localStorage } = {}) {
return view => {
for (const control of controls()) control.classList.toggle('active', control.dataset.view === view)
for (const control of controls()) {
const active = control.dataset.view === view
control.classList.toggle('active', active)
if (active) control.setAttribute?.('aria-current', 'page'); else control.removeAttribute?.('aria-current')
}
for (const panel of panels()) panel.classList.toggle('active', panel.id === `${view}View`)
storage?.setItem('multi-simadmin-view', view)
}
+30
View File
@@ -0,0 +1,30 @@
export function fleetStatusKind(status) {
if (!status) return 'unknown'
if (!status.reachable) return 'offline'
if (status.authenticated === false) return 'auth'
return 'online'
}
export function filterAndSortFleet(instances, statuses, { filter = 'all', query = '', sort = 'name' } = {}) {
const needle = String(query).trim().toLocaleLowerCase()
const rank = { online: 0, auth: 1, offline: 2, unknown: 3 }
return instances.filter(item => {
const status = statuses.get(item.id)
if (filter !== 'all' && fleetStatusKind(status) !== filter) return false
if (!needle) return true
return [item.id, item.name, item.url, item.description, ...(item.tags || []), JSON.stringify(status?.summary || {})]
.join(' ').toLocaleLowerCase().includes(needle)
}).slice().sort((a, b) => {
const sa = statuses.get(a.id); const sb = statuses.get(b.id)
if (sort === 'latency') return (sa?.latencyMs ?? Infinity) - (sb?.latencyMs ?? Infinity) || String(a.name || a.id).localeCompare(String(b.name || b.id))
if (sort === 'status') return rank[fleetStatusKind(sa)] - rank[fleetStatusKind(sb)] || String(a.name || a.id).localeCompare(String(b.name || b.id))
return String(a.name || a.id).localeCompare(String(b.name || b.id))
})
}
export function emptyFleetReason({ total = 0, query = '', filter = 'all', visible = 0 } = {}) {
if (!total) return 'config'
if (!visible && String(query).trim()) return 'search'
if (!visible && filter !== 'all') return 'filter'
return null
}
+21
View File
@@ -0,0 +1,21 @@
export function instanceDraftPayload(values, editing = false) {
const action = editing ? (values.passwordAction || 'preserve') : (values.passwordAction || (values.password ? 'set' : 'clear'))
if (!['preserve', 'set', 'clear'].includes(action)) throw new Error('无效密码操作')
if (action === 'set' && !String(values.password || '')) throw new Error('设置密码时必须输入密码')
if (action === 'clear' && editing && !values.clearConfirmed) throw new Error('清除密码需要显式确认')
const payload = {
id: String(values.id || '').trim(), name: String(values.name || '').trim(), url: String(values.url || '').trim(),
description: String(values.description || '').trim(), tags: Array.isArray(values.tags) ? values.tags : [], passwordAction: action,
}
if (action === 'set') payload.password = String(values.password)
return payload
}
export function requestDraft(method, path, body) {
const normalizedMethod = String(method || 'GET').toUpperCase()
const result = { method: normalizedMethod, path: String(path || '') }
if (normalizedMethod !== 'GET' && body !== undefined) result.body = body
return result
}
export const isWriteMethod = method => ['POST', 'PUT', 'PATCH', 'DELETE'].includes(String(method).toUpperCase())
+9 -4
View File
@@ -1,14 +1,19 @@
export class RequestCoordinator {
#versions = new Map()
async run(channel, ownerId, operation) {
next(channel) {
const version = (this.#versions.get(channel) || 0) + 1
this.#versions.set(channel, version)
return version
}
isCurrent(channel, version) { return this.#versions.get(channel) === version }
async run(channel, ownerId, operation) {
const version = this.next(channel)
try {
const value = await operation()
return { accepted: this.#versions.get(channel) === version, ownerId, value }
return { accepted: this.isCurrent(channel, version), ownerId, value }
} catch (error) {
return { accepted: this.#versions.get(channel) === version, ownerId, error }
return { accepted: this.isCurrent(channel, version), ownerId, error }
}
}
invalidate(channel) { this.#versions.set(channel, (this.#versions.get(channel) || 0) + 1) }
invalidate(channel) { return this.next(channel) }
}
+1 -2
View File
@@ -1,2 +1 @@
*{box-sizing:border-box}html,body{min-height:100%}body{margin:0;min-height:100vh;font-family:var(--animal-font-family);font-weight:500;letter-spacing:.01em;color:var(--animal-text-color);background:var(--animal-bg-color);overflow:hidden}button,input,select,textarea{font:inherit;color:inherit}button{cursor:pointer}button:focus-visible,a:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible,.sim-device-card:focus-visible{outline:3px solid #ffcc00;outline-offset:3px}
.island-bg{position:fixed;inset:0;z-index:-1;overflow:hidden;background:radial-gradient(circle at 12% 10%,rgba(130,213,187,.28),transparent 24%),radial-gradient(circle at 88% 6%,rgba(247,205,103,.28),transparent 22%),linear-gradient(180deg,#f8f8f0 0%,#f4efd9 54%,#e8dfbd 100%)}.island-bg:before{content:"";position:absolute;inset:0;background-image:radial-gradient(circle,rgba(196,184,158,.15) 1.5px,transparent 1.5px),radial-gradient(circle,rgba(196,184,158,.1) 1px,transparent 1px);background-size:28px 28px,14px 14px;background-position:0 0,7px 7px}.cloud{position:absolute;border-radius:999px;background:rgba(255,255,255,.72)}.cloud:before,.cloud:after{content:"";position:absolute;border-radius:50%;background:inherit}.cloud-a{width:170px;height:48px;left:8%;top:7%}.cloud-a:before{width:72px;height:72px;left:28px;top:-28px}.cloud-a:after{width:56px;height:56px;right:26px;top:-18px}.cloud-b{width:210px;height:58px;right:8%;top:12%;opacity:.7}.cloud-b:before{width:86px;height:86px;left:34px;top:-36px}.cloud-b:after{width:72px;height:72px;right:28px;top:-26px}.leaf{position:absolute;color:#8ac68a;font-size:18px;opacity:.42;animation:leafFloat 7s ease-in-out infinite}.leaf-a{left:46%;top:8%}.leaf-b{right:26%;bottom:9%;animation-delay:1.7s}@keyframes leafFloat{0%,100%{transform:translateY(0) rotate(0)}50%{transform:translateY(-12px) rotate(12deg)}}
*{box-sizing:border-box;min-width:0}html,body{min-height:100%;max-width:100%}body{margin:0;min-height:100vh;font-family:var(--animal-font-family);font-weight:500;letter-spacing:.01em;color:var(--animal-text-color);background:var(--animal-bg-color);overflow-x:hidden}button,input,select,textarea{font:inherit;color:inherit}button,a,input,select,textarea{min-height:44px}button{cursor:pointer}button:focus-visible,a:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible{outline:3px solid #ffcc00;outline-offset:3px}h1,h2,h3,h4,p{margin-top:0}p,span,b,dd,dt,label,h1,h2,h3,h4,a,button{overflow-wrap:break-word;word-break:break-word}.island-bg{position:fixed;inset:0;z-index:-1;background:radial-gradient(circle at 12% 8%,rgba(130,213,187,.3),transparent 25%),radial-gradient(circle at 88% 5%,rgba(247,205,103,.3),transparent 24%),linear-gradient(180deg,#f8f8f0,#f4efd9)}code,pre{overflow-wrap:normal;word-break:normal}
File diff suppressed because one or more lines are too long
+2 -1
View File
@@ -1 +1,2 @@
@media(max-width:1260px){.home-toolbar{grid-template-columns:1fr}.detail-layout{grid-template-columns:1fr}.device-detail-board{grid-column:auto;grid-row:auto}.detail-actions-card{grid-column:auto}.device-detail-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.nook-metrics{grid-template-columns:repeat(4,1fr)}}@media(max-width:900px){body{overflow:auto}.sim-shell{height:auto;padding:14px}.sim-topbar,.workspace-hero{flex-direction:column;align-items:flex-start}.sim-main{overflow:visible}.view{overflow:visible}.home-tools{grid-template-columns:1fr}.home-device-grid{overflow:visible;grid-template-columns:1fr}.api-layout.active{grid-template-columns:1fr}.nook-metrics,.device-detail-grid{grid-template-columns:1fr 1fr}.hero-actions{justify-content:flex-start}}@media(max-width:560px){.wallet-row,.signal-grid,.nook-metrics,.device-detail-grid,.sim-card-kpis{grid-template-columns:1fr}.hero-actions{width:100%}.hero-actions .animal-btn{width:100%}.console-toolbar{grid-template-columns:1fr}.device-info-card dl div{grid-template-columns:76px minmax(0,1fr)}}@media(prefers-reduced-motion:reduce){*,*:before,*:after{animation:none!important;transition:none!important}}
@media(max-width:1023px){.api-layout.active{grid-template-columns:1fr}.endpoint-list{max-height:300px}.console-toolbar{grid-template-columns:auto 100px;align-items:center}.console-toolbar label:nth-of-type(2),.console-toolbar input,.console-toolbar .animal-btn{grid-column:1/-1}.console-toolbar label{margin-bottom:-6px}.dialog-card{max-height:calc(100dvh - 20px);overflow-y:auto;margin-block:10px}}
@media(max-width:767px){button,a,input,select,textarea{min-height:44px}.desktop-sidebar{display:none}.app-content{margin-left:0;padding:12px 12px 82px}.sim-topbar,.fleet-overview,.workspace-hero{align-items:flex-start;flex-direction:column}.sim-topbar .hero-actions,.workspace-hero .hero-actions{width:100%}.home-toolbar,.console-toolbar{grid-template-columns:1fr}.home-toolbar label,.console-toolbar label{margin-bottom:-6px}.status-filters{grid-column:auto}.home-device-grid,.device-detail-grid,.signal-grid,.nook-metrics{grid-template-columns:1fr}.detail-layout{grid-template-columns:1fr}.detail-board,.detail-actions-card{grid-column:auto;grid-row:auto}.api-layout.active{grid-template-columns:1fr}.endpoint-list{max-height:300px}.mobile-nav{position:fixed;z-index:10;inset:auto 0 0;display:grid;grid-template-columns:repeat(4,1fr);background:#f0e8d8;border-top:2px solid #d4c9b4;padding:6px max(6px,env(safe-area-inset-right)) max(6px,env(safe-area-inset-bottom)) max(6px,env(safe-area-inset-left))}.mobile-nav button{min-height:52px;border:0;border-radius:14px;background:transparent;color:#725d42}.mobile-nav button.active{background:#82d5bb}.mobile-nav span{display:block;font-size:11px}.sim-card-kpis{grid-template-columns:1fr}.frame{height:70vh}}@media(min-width:768px){.app-content{padding-inline:18px}.home-device-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(min-width:1024px){.app-content{padding-inline:24px}.home-device-grid{grid-template-columns:repeat(auto-fill,minmax(280px,1fr))}}@media(min-width:1440px){:root{--sidebar-width:240px}.app-content{padding-inline:32px}.home-device-grid{grid-template-columns:repeat(3,minmax(0,1fr));gap:18px}}@media(max-width:390px){.app-content{padding-inline:10px}.hero-actions .animal-btn,.card-actions .animal-btn{flex:1}.dialog-card{padding:16px}}@media(max-width:320px){.app-content{padding-inline:8px}.sim-topbar h1{font-size:26px}.animal-btn{padding-inline:12px}}@media(prefers-reduced-motion:reduce){*,*:before,*:after{animation:none!important;transition:none!important;scroll-behavior:auto!important}}
+1 -10
View File
@@ -1,10 +1 @@
:root{
--animal-primary-color:#19c8b9;--animal-primary-color-hover:#3dd4c6;--animal-primary-color-active:#50b9ab;--animal-primary-color-bg:#e6f9f6;
--animal-success-color:#6fba2c;--animal-warning-color:#f5c31c;--animal-error-color:#e05a5a;
--animal-text-color:#794f27;--animal-text-color-body:#725d42;--animal-text-color-secondary:#9f927d;--animal-text-color-muted:#8a7b66;--animal-text-color-disabled:#c4b89e;
--animal-border-color:#aaa69d;--animal-border-color-hover:#827157;--animal-border-color-light:#e8e2d6;
--animal-bg-color:#f8f8f0;--animal-bg-color-content:rgb(247,243,223);--animal-bg-color-secondary:#f0e8d8;--animal-bg-color-disabled:#f0ece2;
--animal-font-family:Nunito,'Noto Sans SC',-apple-system,'PingFang SC','Hiragino Sans GB','Microsoft YaHei',sans-serif;
--animal-shadow-sm:0 2px 4px 0 rgba(61,52,40,.06);--animal-shadow-base:0 3px 10px 0 rgba(61,52,40,.1);--animal-shadow-lg:0 8px 24px 0 rgba(61,52,40,.14);
--animal-motion-duration-base:.25s;--animal-motion-ease:cubic-bezier(.4,0,.2,1);
}
:root{--animal-primary-color:#19c8b9;--animal-primary-color-hover:#3dd4c6;--animal-primary-color-active:#11a89b;--animal-success-color:#6fba2c;--animal-warning-color:#f5c31c;--animal-error-color:#e05a5a;--animal-text-color:#794f27;--animal-text-color-body:#725d42;--animal-text-color-secondary:#9f927d;--animal-text-color-muted:#8a7b66;--animal-border-color:#9f927d;--animal-border-light:#d4c9b4;--animal-bg-color:#f8f8f0;--animal-bg-content:rgb(247,243,223);--animal-bg-secondary:#f0e8d8;--animal-font-family:Nunito,'Noto Sans SC','PingFang SC','Hiragino Sans GB','Microsoft YaHei',sans-serif;--animal-motion:.25s cubic-bezier(.4,0,.2,1);--sidebar-width:224px}
+37 -2
View File
@@ -5,7 +5,10 @@ import { fileURLToPath } from 'node:url'
import { redactInstance } from './core.js'
import { registerStatusRoutes } from './status/routes.js'
import { registerProxyRoutes } from './proxy/routes.js'
import { defaultProxyPolicy } from './proxy/policy.js'
import { ConfirmationStore } from './proxy/confirmations.js'
import { ConfigNotFoundError, ConfigValidationError } from './config/errors.js'
import { isIP } from 'node:net'
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
const FEATURES = ['passwordless-status', 'password-session-login', 'read-write-api-proxy', 'device-sim-network-sms-data-ota-summary', 'iframe-fallback']
@@ -19,9 +22,41 @@ const CATALOG = [
{ id: 'notify-auto-ota', name: '通知/自动化/升级', endpoints: ['/api/notifications/config', '/api/notifications/logs', '/api/automation/config', '/api/automation/logs', '/api/ota/status'] },
]
export async function buildApp({ configStore, clientRegistry, logger = false, staticFiles = true, publicDir = path.join(ROOT, 'public'), reconcileRetryMs = 100 } = {}) {
export async function buildApp({ configStore, clientRegistry, logger = false, staticFiles = true, publicDir = path.join(ROOT, 'public'), reconcileRetryMs = 100, proxyPolicy = defaultProxyPolicy, confirmationOptions = {} } = {}) {
if (!configStore || !clientRegistry) throw new TypeError('configStore and clientRegistry are required')
const app = Fastify({ logger, bodyLimit: 60 * 1024 * 1024 })
const parseAuthority = raw => {
const value = String(raw || '').trim().toLowerCase()
if (!value || /[\s/@]/.test(value)) return null
let hostname, port = ''
if (value.startsWith('[')) { const match=value.match(/^\[([^\]]+)\](?::(\d{1,5}))?$/); if(!match)return null; [,hostname,port='']=match }
else { const match=value.match(/^([^:]+)(?::(\d{1,5}))?$/); if(!match)return null; [,hostname,port='']=match }
if(port && Number(port)>65535)return null
return { hostname, port }
}
const loopbackAuthority = raw => {
const authority=parseAuthority(raw); if(!authority)return null
const {hostname}=authority
if(hostname === 'localhost') return { ...authority, hostname: 'localhost' }
if(isIP(hostname) === 6 && (hostname === '::1' || hostname === '0:0:0:0:0:0:0:1')) return { ...authority, hostname: '::1' }
if(isIP(hostname)!==4)return null
const octets=hostname.split('.').map(Number)
return octets[0]===127 ? authority : null
}
const configuredRawHost = String(configStore.snapshot.server.host).replace(/^\[|\]$/g, '').toLowerCase()
const configuredHost = isIP(configuredRawHost) === 6 ? '::1' : configuredRawHost
const configuredPort = String(configStore.snapshot.server.port)
app.addHook('onRequest', async (request, reply) => {
const authority=loopbackAuthority(request.headers.host)
const injectDefault = request.headers.host === 'localhost:80' && request.raw.socket?.localPort == null
if (!authority || (!injectDefault && (authority.hostname !== configuredHost || authority.port !== configuredPort))) return reply.code(421).send({ error: 'configured loopback Host required' })
if (request.headers.origin) {
let origin
try { origin = new URL(request.headers.origin) } catch {}
const originAuthority = origin ? loopbackAuthority(origin.host) : null
if (!origin || origin.protocol !== 'http:' || !originAuthority || originAuthority.hostname !== authority.hostname || originAuthority.port !== authority.port) return reply.code(403).send({ error: 'cross-origin request rejected' })
}
})
app.addContentTypeParser('application/octet-stream', { parseAs: 'buffer' }, (_request, body, done) => done(null, body))
app.addContentTypeParser(/^multipart\//, { parseAs: 'buffer' }, (_request, body, done) => done(null, body))
if (staticFiles) await app.register(fastifyStatic, { root: publicDir, prefix: '/' })
@@ -73,7 +108,7 @@ export async function buildApp({ configStore, clientRegistry, logger = false, st
try { await client.fetchJson('/api/auth/logout', { method: 'POST' }) } catch {}
client.jar.clear(); client.clearEphemeralSecret?.(); return { ok: true }
})
registerProxyRoutes(app, { findClient })
registerProxyRoutes(app, { findClient, policy: proxyPolicy, configStore, confirmationStore: new ConfirmationStore(confirmationOptions) })
app.get('/api/catalog', async () => ({ groups: CATALOG }))
app.get('/api/reload-note', async () => ({ message: 'Configuration changes are applied immediately.' }))
app.setNotFoundHandler(async (request, reply) => request.url.startsWith('/api/') ? reply.code(404).send({ error: 'not found' }) : staticFiles ? reply.sendFile('index.html') : reply.code(404).send({ error: 'not found' }))
+12 -4
View File
@@ -32,7 +32,7 @@ export class FileConfigStore {
this.examplePath = examplePath
this.atomicWrite = atomicWrite
this.env = { ...env }
this.#snapshot = immutableSnapshot({ ...snapshot, configPath })
this.#snapshot = immutableSnapshot({ ...snapshot, revision: Number(snapshot.revision || 0), configPath })
}
static async open({ configPath, examplePath, atomicWrite, env } = {}) {
let text
@@ -52,7 +52,7 @@ export class FileConfigStore {
const result = await mutator(draft)
const normalized = normalizeConfig(serializeConfig(draft), this.env)
await this.atomicWrite(this.configPath, serializeConfig(normalized))
this.#snapshot = immutableSnapshot({ ...normalized, configPath: this.configPath })
this.#snapshot = immutableSnapshot({ ...normalized, revision: this.#snapshot.revision + 1, configPath: this.configPath })
return result?.id ? this.#snapshot.instances.find(item => item.id === result.id) || result : result
})
this.#queue = operation.catch(() => {})
@@ -71,8 +71,16 @@ export class FileConfigStore {
const index = draft.instances.findIndex(item => item.id === id)
if (index < 0) throw new ConfigNotFoundError(`instance not found: ${id}`)
const current = draft.instances[index]
const merged = { ...current, ...payload, id: payload.id ? String(payload.id).trim() : current.id,
auth: payload.auth !== undefined || payload.password !== undefined ? payload.auth || { password: payload.password || '' } : current.auth }
const action = payload.passwordAction
if (action !== undefined && !['preserve', 'set', 'clear'].includes(action)) throw new ConfigValidationError('passwordAction must be preserve, set or clear')
if (payload.auth && Object.hasOwn(payload.auth, 'password')) throw new ConfigValidationError("auth.password is ambiguous; use passwordAction='set' or 'clear'")
if (Object.hasOwn(payload, 'password') && action === undefined) throw new ConfigValidationError("password requires passwordAction='set'")
if (action === 'set' && !String(payload.password || '')) throw new ConfigValidationError("passwordAction='set' requires a non-empty password")
const auth = action === 'set' ? { mode: 'password', password: String(payload.password) }
: action === 'clear' ? { mode: 'none', password: '' }
: current.auth
const { passwordAction: _passwordAction, password: _password, auth: _payloadAuth, ...metadata } = payload
const merged = { ...current, ...metadata, id: payload.id ? String(payload.id).trim() : current.id, auth }
const next = normalizeInstance(merged, index)
if (next.id !== id && draft.instances.some(item => item.id === next.id)) throw new ConfigValidationError(`duplicate instance id: ${next.id}`)
draft.instances[index] = next
+24 -1
View File
@@ -1,10 +1,26 @@
import { ConfigValidationError } from './errors.js'
import { isIP } from 'node:net'
export function normalizeBaseUrl(rawUrl) {
let url
try { url = new URL(rawUrl) } catch { throw new ConfigValidationError('instance URL is invalid') }
if (!['http:', 'https:'].includes(url.protocol)) throw new ConfigValidationError('instance URL must use http or https')
if (url.username || url.password) throw new ConfigValidationError('instance URL must not contain credentials')
const hostname = url.hostname.replace(/^\[|\]$/g, '').toLowerCase()
const ipv4 = hostname.split('.').map(Number)
const isIpv4 = ipv4.length === 4 && ipv4.every(part => Number.isInteger(part) && part >= 0 && part <= 255)
const prohibitedIpv4 = isIpv4 && (
ipv4[0] === 127 || ipv4[0] === 0 ||
(ipv4[0] === 169 && ipv4[1] === 254) ||
(ipv4[0] >= 224)
)
const prohibitedIpv6 = hostname === '::' || hostname === '::1' || /^fe[89ab][0-9a-f]:/.test(hostname) || hostname.startsWith('ff') || hostname.startsWith('::ffff:') || hostname.startsWith('64:ff9b:')
const prohibitedMetadata = hostname === '100.100.100.200' || hostname === '168.63.129.16'
const prohibitedTransition = hostname.startsWith('2002:')
const isIpLiteral = isIP(hostname) !== 0
if (!isIpLiteral || prohibitedIpv4 || prohibitedIpv6 || prohibitedMetadata || prohibitedTransition) {
throw new ConfigValidationError('instance target must be a permitted IP literal; hostnames and local/metadata addresses are prohibited')
}
url.hash = ''
url.search = ''
return url.toString().replace(/\/$/, '')
@@ -38,8 +54,15 @@ export function normalizeConfig(raw = {}, env = process.env) {
}
const port = Number(env.PORT ?? raw.server?.port ?? 8788)
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new ConfigValidationError('server.port must be an integer from 1 to 65535')
const host = String(env.HOST ?? raw.server?.host ?? '127.0.0.1').trim()
const bareHost = host.replace(/^\[|\]$/g, '').toLowerCase()
const octets = bareHost.split('.')
const ipv4Loopback = octets.length === 4 && octets[0] === '127' && octets.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255)
if (!(bareHost === 'localhost' || bareHost === '::1' || bareHost === '0:0:0:0:0:0:0:1' || ipv4Loopback)) {
throw new ConfigValidationError('server.host must be a loopback host (127/8, ::1, or localhost)')
}
return {
server: { host: raw.server?.host ?? env.HOST ?? '127.0.0.1', port },
server: { host, port },
instances,
raw: { ...raw, instances },
}
+63
View File
@@ -0,0 +1,63 @@
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
export function canonicalQuery(input = '') {
const params = new URLSearchParams(String(input).replace(/^\?/, ''))
params.sort()
return params.toString()
}
export function canonicalProxyTarget(input) {
if (typeof input !== 'string' || !input.startsWith('/') || input.startsWith('//')) throw new Error('invalid confirmation path')
let url
try { url = new URL(input, 'http://confirmation.local') } catch { throw new Error('invalid confirmation path') }
if (url.origin !== 'http://confirmation.local' || url.hash) throw new Error('invalid confirmation path')
return `${url.pathname}${canonicalQuery(url.search) ? `?${canonicalQuery(url.search)}` : ''}`
}
export function bodyDigest(body) {
const stable = value => {
if (value === undefined) return 'undefined'
if (Buffer.isBuffer(value)) return value
if (value === null || typeof value !== 'object') return JSON.stringify(value)
if (Array.isArray(value)) return `[${value.map(stable).join(',')}]`
return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${stable(value[key])}`).join(',')}}`
}
const value = stable(body)
return createHash('sha256').update(value).digest('hex')
}
export class ConfirmationStore {
#entries = new Map()
constructor({ ttlMs = 30_000, maxEntries = 1024, now = Date.now } = {}) { this.ttlMs = ttlMs; this.maxEntries = maxEntries; this.now = now }
prepare(binding) {
const now = this.now()
for (const [key, entry] of this.#entries) if (entry.expiresAt <= now) this.#entries.delete(key)
while (this.#entries.size >= this.maxEntries) this.#entries.delete(this.#entries.keys().next().value)
const token = randomBytes(32).toString('base64url')
const expiresAt = now + this.ttlMs
this.#entries.set(token, { ...binding, expiresAt })
return { token, expiresAt }
}
consume(token, binding) {
if (typeof token !== 'string' || token.length < 16) return false
const entry = this.#entries.get(token)
if (!entry) return false
this.#entries.delete(token)
if (entry.expiresAt <= this.now()) return false
const expected = Buffer.from(JSON.stringify(binding))
const actual = Buffer.from(JSON.stringify(Object.fromEntries(Object.keys(binding).map(key => [key, entry[key]]))))
const matches = expected.length === actual.length && timingSafeEqual(expected, actual)
return matches
}
}
export function confirmationBinding({ instance, revision, method, target, body }) {
return {
instanceId: instance.id,
revision,
origin: new URL(instance.url).origin,
method: String(method).toUpperCase(),
target: canonicalProxyTarget(target),
bodyDigest: bodyDigest(body),
}
}
+53
View File
@@ -0,0 +1,53 @@
const AUTH_PATH = /^\/api\/(?:auth(?:\/|$)|login(?:\/|$)|logout(?:\/|$))/i
export const DEFAULT_READ_PATHS = Object.freeze([
'/api/health', '/api/device', '/api/sim', '/api/network', '/api/stats', '/api/connectivity',
'/api/sms/stats', '/api/sms/list', '/api/sms/send', '/api/sms/conversation',
'/api/data', '/api/roaming', '/api/airplane-mode', '/api/radio-mode', '/api/band-lock', '/api/cell-lock', '/api/apn', '/api/cells',
'/api/device-network/ddns/status', '/api/device-network/ddns/config', '/api/device-network/wlan/status', '/api/device-network/wlan/profiles',
'/api/calls', '/api/call/history', '/api/ims/status', '/api/voicemail/status',
'/api/work-mode', '/api/esim/config', '/api/esim/lpac/status', '/api/esim/euicc', '/api/esim/profiles',
'/api/notifications/config', '/api/notifications/logs', '/api/automation/config', '/api/automation/logs', '/api/ota/status',
])
// Write access is deliberately narrower than the readable catalog. Every listed
// operation still requires a one-use server confirmation.
export const DEFAULT_WRITE_PATHS = Object.freeze({
'/api/sms/send': ['POST'],
'/api/data': ['POST', 'PUT', 'PATCH'],
'/api/roaming': ['POST', 'PUT', 'PATCH'],
'/api/airplane-mode': ['POST', 'PUT', 'PATCH'],
'/api/radio-mode': ['POST', 'PUT', 'PATCH'],
'/api/band-lock': ['POST', 'PUT', 'PATCH', 'DELETE'],
'/api/cell-lock': ['POST', 'PUT', 'PATCH', 'DELETE'],
'/api/apn': ['POST', 'PUT', 'PATCH', 'DELETE'],
'/api/device-network/ddns/config': ['POST', 'PUT', 'PATCH'],
'/api/device-network/wlan/profiles': ['POST', 'PUT', 'PATCH', 'DELETE'],
'/api/work-mode': ['POST', 'PUT', 'PATCH'],
'/api/esim/config': ['POST', 'PUT', 'PATCH'],
'/api/esim/profiles': ['POST', 'DELETE'],
'/api/notifications/config': ['POST', 'PUT', 'PATCH'],
'/api/automation/config': ['POST', 'PUT', 'PATCH'],
})
export function createProxyPolicy({ readPaths = DEFAULT_READ_PATHS, writePaths = DEFAULT_WRITE_PATHS } = {}) {
const readable = new Set(readPaths)
const writable = new Map(Object.entries(writePaths).map(([path, methods]) => [path, new Set(methods.map(method => method.toUpperCase()))]))
return Object.freeze({
authorize(method, path) {
method = String(method).toUpperCase()
if (AUTH_PATH.test(path)) return { allowed: false, statusCode: 403, reason: 'authentication endpoints cannot be proxied' }
if (method === 'GET' || method === 'HEAD') return readable.has(path)
? { allowed: true, dangerous: false }
: { allowed: false, statusCode: 403, reason: 'proxy path is not allowed' }
if (!writable.has(path)) return readable.has(path)
? { allowed: false, statusCode: 405, reason: 'proxy method is not allowed' }
: { allowed: false, statusCode: 403, reason: 'proxy path is not allowed' }
return writable.get(path).has(method)
? { allowed: true, dangerous: true }
: { allowed: false, statusCode: 405, reason: 'proxy method is not allowed' }
},
})
}
export const defaultProxyPolicy = createProxyPolicy()
+50 -7
View File
@@ -1,14 +1,57 @@
import { proxyToInstance } from './service.js'
import { proxyToInstance, safeProxyPath, ProxyRequestError } from './service.js'
import { defaultProxyPolicy } from './policy.js'
import { ConfirmationStore, confirmationBinding } from './confirmations.js'
function requestTarget(request, rest) {
const path = safeProxyPath(rest)
const query = request.url.includes('?') ? request.url.slice(request.url.indexOf('?')) : ''
return `${path}${query}`
}
export function registerProxyRoutes(app, {
findClient,
proxy = proxyToInstance,
policy = defaultProxyPolicy,
configStore,
confirmationStore = new ConfirmationStore(),
}) {
app.post('/api/proxy-confirmations/prepare', async (request, reply) => {
try {
const input = request.body || {}
const client = findClient(input.instanceId, reply)
if (!client) return
const method = String(input.method || '').toUpperCase()
const target = String(input.path || '')
const parsed = new URL(target, 'http://confirmation.local')
if (parsed.origin !== 'http://confirmation.local') throw new Error('invalid confirmation path')
const decision = policy.authorize(method, parsed.pathname)
if (!decision.allowed) return reply.code(decision.statusCode).send({ error: decision.reason })
if (!decision.dangerous) return reply.code(400).send({ error: 'confirmation is only available for dangerous writes' })
const binding = confirmationBinding({ instance: client.instance, revision: configStore.snapshot.revision, method, target, body: input.body })
return confirmationStore.prepare(binding)
} catch (error) { return reply.code(400).send({ error: error.message }) }
})
export function registerProxyRoutes(app, { findClient, proxy = proxyToInstance }) {
app.all('/api/proxy/:id/*', async (request, reply) => {
const client = findClient(request.params.id, reply)
if (!client) return
try { return await proxy({ client, request, reply, rest: request.params['*'] }) }
catch (error) {
if (/invalid proxy path/.test(error.message)) return reply.code(400).send({ error: error.message })
if (error.statusCode === 415) return reply.code(415).send({ error: error.message })
throw error
try {
const target = requestTarget(request, request.params['*'])
const pathname = new URL(target, 'http://proxy.local').pathname
const decision = policy.authorize(request.method, pathname)
if (!decision.allowed) return reply.code(decision.statusCode).send({ error: decision.reason })
if (decision.dangerous) {
const binding = confirmationBinding({ instance: client.instance, revision: configStore.snapshot.revision, method: request.method, target, body: request.body })
const token = request.headers['x-confirmation-token']
if (!token) return reply.code(428).send({ error: 'confirmation token required' })
if (!confirmationStore.consume(token, binding)) return reply.code(403).send({ error: 'invalid, expired, replayed, or mismatched confirmation token' })
}
return await proxy({ client, request, reply, rest: request.params['*'] })
} catch (error) {
if (error instanceof ProxyRequestError && error.code === 'invalid_path') return reply.code(400).send({ error: 'invalid proxy path' })
if (error instanceof ProxyRequestError && error.code === 'unsupported_content_type') return reply.code(415).send({ error: 'unsupported proxy content type' })
request.log?.error?.({ err: error }, 'upstream proxy failed')
return reply.code(502).send({ error: 'upstream request failed' })
}
})
}
+17 -8
View File
@@ -5,23 +5,32 @@ const BLOCKED_REQUEST_HEADERS = new Set([
])
const BLOCKED_RESPONSE_HEADERS = new Set([...BLOCKED_REQUEST_HEADERS, 'set-cookie', 'content-encoding'])
export class ProxyRequestError extends Error {
constructor(code) {
super(code === 'unsupported_content_type' ? 'unsupported proxy content type' : 'invalid proxy path')
this.code = code
}
}
const invalidPath = () => new ProxyRequestError('invalid_path')
export function safeProxyPath(rest = '') {
const raw = String(rest || '')
if (!raw || raw.startsWith('/') || raw.includes('\\')) throw new Error('invalid proxy path')
if (!raw || raw.startsWith('/') || raw.includes('\\')) throw invalidPath()
let value = raw
const assertSafe = candidate => {
if (/[\\?#]/.test(candidate) || candidate.startsWith('//') || candidate.split('/').some(segment => segment === '..' || segment === '.')) throw new Error('invalid proxy path')
if (/%(?:2f|5c|3f|23|2e)/i.test(candidate)) throw new Error('invalid proxy path')
if (/[\\?#]/.test(candidate) || candidate.startsWith('//') || candidate.split('/').some(segment => segment === '..' || segment === '.')) throw invalidPath()
if (/%(?:2f|5c|3f|23|2e)/i.test(candidate)) throw invalidPath()
}
for (let depth = 0; depth < 16; depth += 1) {
assertSafe(value)
let decoded
try { decoded = decodeURIComponent(value) } catch { throw new Error('invalid proxy path') }
try { decoded = decodeURIComponent(value) } catch { throw invalidPath() }
if (decoded === value) return `/${raw}`
value = decoded
}
// Refuse inputs whose semantics still change after a bounded number of decodes.
throw new Error('invalid proxy path')
throw invalidPath()
}
export function proxyHeaders(input = {}) {
@@ -37,17 +46,17 @@ export async function proxyToInstance({ client, request, reply, rest }) {
const query = request.url.includes('?') ? request.url.slice(request.url.indexOf('?')) : ''
const target = new URL(`${targetPath}${query}`, new URL(client.instance.url).origin)
const origin = new URL(client.instance.url).origin
if (target.origin !== origin) throw new Error('invalid proxy path')
if (target.origin !== origin) throw invalidPath()
const headers = proxyHeaders(request.headers)
let body
if (!['GET', 'HEAD'].includes(request.method)) {
const type = (headers.get('content-type') || '').split(';')[0].trim().toLowerCase()
if (request.body === undefined && !type) body = undefined
else if (type.startsWith('multipart/')) { const error = new Error('unsupported proxy content type'); error.statusCode = 415; throw error }
else if (type.startsWith('multipart/')) throw new ProxyRequestError('unsupported_content_type')
else if (type === 'application/json' || type.endsWith('+json')) body = request.body === undefined ? undefined : JSON.stringify(request.body)
else if (type.startsWith('text/') || type === 'application/x-www-form-urlencoded') body = request.body
else if (Buffer.isBuffer(request.body)) body = request.body
else { const error = new Error('unsupported proxy content type'); error.statusCode = 415; throw error }
else throw new ProxyRequestError('unsupported_content_type')
}
const upstream = await client.request(target.toString(), { method: request.method, headers, body, redirect: 'manual' })
reply.code(upstream.status)
+7 -6
View File
@@ -39,7 +39,7 @@ test('FileConfigStore uses example only as template and commits immutable snapsh
const store = await FileConfigStore.open({ configPath, examplePath })
assert.equal(store.snapshot.server.host, '127.0.0.1')
assert.ok(Object.isFrozen(store.snapshot.instances))
await store.add({ id: 'one', url: 'http://127.0.0.1:3000' })
await store.add({ id: 'one', url: 'http://192.0.2.10:3000' })
assert.equal(JSON.parse(await readFile(configPath, 'utf8')).instances[0].id, 'one')
assert.equal(await readFile(examplePath, 'utf8'), before)
})
@@ -48,7 +48,7 @@ test('failed persistence does not publish memory and transactions serialize', as
const { configPath, examplePath } = await fixtureConfig()
let writes = 0
const store = await FileConfigStore.open({ configPath, examplePath, atomicWrite: async () => { writes += 1; throw new Error('disk full') } })
await assert.rejects(store.add({ id: 'x', url: 'http://127.0.0.1' }), /disk full/)
await assert.rejects(store.add({ id: 'x', url: 'http://192.0.2.11' }), /disk full/)
assert.equal(store.snapshot.instances.length, 0)
assert.equal(writes, 1)
await assert.rejects(access(configPath))
@@ -76,16 +76,17 @@ test('proxy path and target URL contracts reject origin escape vectors', () => {
})
test('buildApp supports inject without listening and covers health/readiness/config/auth/proxy/404', async t => {
const { configPath, examplePath } = await fixtureConfig([{ id: 'one', url: 'http://127.0.0.1:3000' }])
const { configPath, examplePath } = await fixtureConfig([{ id: 'one', url: 'http://192.0.2.10:3000' }])
const store = await FileConfigStore.open({ configPath, examplePath })
const calls = []
const registry = new ClientRegistry({ createClient: instance => fakeClient(instance, calls) }).reconcile(store.snapshot.instances)
const app = await buildApp({ configStore: store, clientRegistry: registry, staticFiles: false })
const testPolicy = { authorize: (_method, path) => path === '/api/test' ? { allowed: true, dangerous: false } : { allowed: false, statusCode: 403, reason: 'test policy' } }
const app = await buildApp({ configStore: store, clientRegistry: registry, staticFiles: false, proxyPolicy: testPolicy })
t.after(() => app.close())
assert.deepEqual((await app.inject('/api/health')).json(), { ok: true })
assert.deepEqual((await app.inject('/api/ready')).json(), { ready: true })
assert.equal((await app.inject('/api/config')).statusCode, 200)
const created = await app.inject({ method: 'POST', url: '/api/instances', payload: { id: 'two', url: 'https://example.test' } })
const created = await app.inject({ method: 'POST', url: '/api/instances', payload: { id: 'two', url: 'https://192.0.2.15' } })
assert.equal(created.statusCode, 200)
const renamed = await app.inject({ method: 'PUT', url: '/api/instances/two', payload: { id: 'renamed' } })
assert.equal(renamed.json().instance.id, 'renamed')
@@ -104,7 +105,7 @@ test('buildApp supports inject without listening and covers health/readiness/con
assert.equal(calls[0].options.headers.has('authorization'), false)
assert.equal(calls[0].options.headers.has('forwarded'), false)
assert.equal(calls[0].options.body, JSON.stringify({ hello: 'world' }))
assert.equal(new URL(calls[0].url).origin, 'http://127.0.0.1:3000')
assert.equal(new URL(calls[0].url).origin, 'http://192.0.2.10:3000')
assert.deepEqual((await app.inject('/api/missing')).json(), { error: 'not found' })
})
+14 -13
View File
@@ -38,12 +38,12 @@ test('proxy path rejects normalization and repeated-decoding structure changes',
test('FileConfigStore preserves constructor env across transactions and serializes real writes', async () => {
const { configPath, examplePath } = await files()
const store = await FileConfigStore.open({ configPath, examplePath, env: { HOST: 'env-host', PORT: '4321' } })
const store = await FileConfigStore.open({ configPath, examplePath, env: { HOST: 'localhost', PORT: '4321' } })
await Promise.all([
store.add({ id: 'a', url: 'http://a.test' }),
store.add({ id: 'b', url: 'http://b.test' }),
store.add({ id: 'a', url: 'http://192.0.2.21' }),
store.add({ id: 'b', url: 'http://192.0.2.22' }),
])
assert.equal(store.snapshot.server.host, 'env-host')
assert.equal(store.snapshot.server.host, 'localhost')
assert.equal(store.snapshot.server.port, 4321)
assert.deepEqual(JSON.parse(await readFile(configPath, 'utf8')).instances.map(x => x.id), ['a', 'b'])
})
@@ -59,24 +59,24 @@ test('atomicWriteJson replaces content, leaves no temp and creates POSIX 0600',
test('schema rejects invalid port and auth mode', () => {
for (const port of [0, 65536, 1.5, 'wat']) assert.throws(() => normalizeConfig({ server: { port }, instances: [] }, {}), /port/)
assert.throws(() => normalizeConfig({ instances: [{ id: 'x', url: 'http://x.test', auth: { mode: 'token' } }] }, {}), /auth.mode/)
assert.throws(() => normalizeConfig({ instances: [{ id: 'x', url: 'http://192.0.2.27', auth: { mode: 'token' } }] }, {}), /auth.mode/)
})
test('registry reconcile builds next map before publishing and closes staged clients on create failure', () => {
const old = { id: 'old', url: 'http://old.test', auth: { mode: 'none', password: '' } }
const old = { id: 'old', url: 'http://192.0.2.24', auth: { mode: 'none', password: '' } }
let stagedClosed = false
const registry = new ClientRegistry({ createClient: item => {
if (item.id === 'bad') throw new Error('boom')
return { instance: item, close() { if (item.id === 'staged') stagedClosed = true } }
} }).reconcile([old])
assert.throws(() => registry.reconcile([{ id: 'staged', url: 'http://staged.test' }, { id: 'bad', url: 'http://bad.test' }]), /boom/)
assert.throws(() => registry.reconcile([{ id: 'staged', url: 'http://192.0.2.26' }, { id: 'bad', url: 'http://192.0.2.23' }]), /boom/)
assert.equal(stagedClosed, true)
assert.equal(registry.get('old').instance.id, 'old')
assert.equal(registry.get('bad'), undefined)
})
test('temporary login credential is ephemeral, saved flag comes from store, logout clears it', async t => {
const { configPath, examplePath } = await files([{ id: 'one', url: 'http://sim.test' }])
const { configPath, examplePath } = await files([{ id: 'one', url: 'http://192.0.2.25' }])
const store = await FileConfigStore.open({ configPath, examplePath })
let credential
let cleared = false
@@ -92,7 +92,7 @@ test('temporary login credential is ephemeral, saved flag comes from store, logo
})
test('failed temporary login credential is not retained for later automatic attempts', async () => {
const instance = { id: 'x', url: 'http://x.test', auth: { mode: 'none', password: '' } }
const instance = { id: 'x', url: 'http://192.0.2.27', auth: { mode: 'none', password: '' } }
const bodies = []
const c = createSimAdminClient(instance, { fetchImpl: async (url, options = {}) => {
const pathname = new URL(url).pathname
@@ -109,14 +109,15 @@ test('failed temporary login credential is not retained for later automatic atte
})
test('proxy body/header contract handles JSON text buffer, gzip metadata and stable 415', async t => {
const { configPath, examplePath } = await files([{ id: 'one', url: 'http://sim.test/base' }])
const { configPath, examplePath } = await files([{ id: 'one', url: 'http://192.0.2.25/base' }])
const store = await FileConfigStore.open({ configPath, examplePath })
const calls = []
const registry = new ClientRegistry({ createClient: instance => client(instance, async (url, options) => {
calls.push({ url: String(url), options })
return new Response('decoded', { status: 503, headers: { 'content-encoding': 'gzip', 'content-length': '999', 'x-upstream': 'yes' } })
}) }).reconcile(store.snapshot.instances)
const app = await buildApp({ configStore: store, clientRegistry: registry, staticFiles: false }); t.after(() => app.close())
const testPolicy = { authorize: () => ({ allowed: true, dangerous: false }) }
const app = await buildApp({ configStore: store, clientRegistry: registry, staticFiles: false, proxyPolicy: testPolicy }); t.after(() => app.close())
const json = await app.inject({ method: 'POST', url: '/api/proxy/one/api/x?q=1', headers: { 'content-type': 'application/json', 'accept-encoding': 'gzip' }, payload: { x: 1 } })
assert.equal(json.statusCode, 503); assert.equal(calls[0].options.body, '{"x":1}'); assert.equal(new URL(calls[0].url).pathname, '/api/x'); assert.equal(new URL(calls[0].url).search, '?q=1')
assert.equal(calls[0].options.headers.has('accept-encoding'), false); assert.equal(json.headers['content-encoding'], undefined); assert.equal(json.headers['content-length'], '7')
@@ -132,7 +133,7 @@ test('committed config survives reconcile failure and readiness degrades then re
let fail = true
const registry = new ClientRegistry({ createClient: instance => { if (fail) throw new Error('factory'); return client(instance, async () => new Response('{}')) } })
const app = await buildApp({ configStore: store, clientRegistry: registry, staticFiles: false, reconcileRetryMs: 10 }); t.after(() => app.close())
const created = await app.inject({ method: 'POST', url: '/api/instances', payload: { id: 'x', url: 'http://x.test' } })
const created = await app.inject({ method: 'POST', url: '/api/instances', payload: { id: 'x', url: 'http://192.0.2.27' } })
assert.equal(created.statusCode, 200); assert.equal(created.json().reconcilePending, true); assert.equal((await app.inject('/api/ready')).json().ready, false)
fail = false
await new Promise(resolve => setTimeout(resolve, 30))
@@ -141,7 +142,7 @@ test('committed config survives reconcile failure and readiness degrades then re
test('status supports registration_status and health 404 with successful business endpoint', async () => {
const values = new Map([['/api/health', { ok: false, status: 404, latencyMs: 2, data: null }], ['/api/auth/status', { ok: false, status: 404, data: null }], ['/api/device', { ok: true, status: 200, data: { model: 'X' } }], ['/api/network', { ok: true, status: 200, data: { registration_status: 'registered' } }]])
const instance = { id: 'x', name: 'x', url: 'http://x.test', auth: { mode: 'none', password: '' } }
const instance = { id: 'x', name: 'x', url: 'http://192.0.2.27', auth: { mode: 'none', password: '' } }
const c = createSimAdminClient(instance, { fetchImpl: async url => { const v = values.get(new URL(url).pathname) || { ok: false, status: 404, data: null }; return new Response(JSON.stringify(v.data), { status: v.status }) } })
const status = await collectInstanceStatus(c)
assert.equal(status.reachable, true); assert.notEqual(status.authenticated, false); assert.equal(status.summary.network.registration, 'registered')
+182
View File
@@ -0,0 +1,182 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { mkdtemp, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { normalizeConfig } from '../server/config/schema.js'
import { FileConfigStore } from '../server/config/file-config-store.js'
import { ClientRegistry } from '../server/clients/client-registry.js'
import { buildApp } from '../server/app.js'
import { createProxyPolicy } from '../server/proxy/policy.js'
import { ConfirmationStore } from '../server/proxy/confirmations.js'
async function storeFor(instances = [{ id: 'one', url: 'http://192.168.8.1', auth: { password: 'old-secret' } }]) {
const dir = await mkdtemp(path.join(tmpdir(), 'msa-p2-'))
const configPath = path.join(dir, 'config.json')
const examplePath = path.join(dir, 'example.json')
await writeFile(examplePath, JSON.stringify({ instances }))
return FileConfigStore.open({ configPath, examplePath, env: {} })
}
function fakeRegistry(store, calls = []) {
return new ClientRegistry({ createClient: instance => ({
instance, jar: { clear() {} }, clearEphemeralSecret() {},
fetchJson: async () => ({ ok: true, status: 200, data: {} }), ensureAuthenticated: async () => ({ authenticated: true }),
request: async (url, options) => { calls.push({ url: String(url), options }); return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } }) },
}) }).reconcile(store.snapshot.instances)
}
test('listen schema defaults to loopback and rejects every non-loopback host', () => {
assert.equal(normalizeConfig({}, {}).server.host, '127.0.0.1')
for (const host of ['0.0.0.0', '192.168.1.2', 'example.com', '::', '169.254.1.1']) {
assert.throws(() => normalizeConfig({ server: { host } }, {}), /loopback/i)
}
for (const host of ['localhost', '127.0.0.2', '127.255.255.255', '::1', '[::1]']) {
assert.equal(normalizeConfig({ server: { host } }, {}).server.host, host)
}
})
test('instance targets allow LAN but reject credentials and local/metadata targets', () => {
assert.equal(normalizeConfig({ instances: [{ id: 'lan', url: 'http://192.168.1.1' }] }, {}).instances[0].url, 'http://192.168.1.1')
for (const url of [
'http://169.254.169.254/latest', 'http://169.254.1.1', 'http://127.0.0.1',
'http://localhost', 'http://device.example.test', 'http://[::1]', 'http://[fe80::1]', 'http://[fe90::1]',
'http://[::ffff:127.0.0.1]', 'http://[::ffff:169.254.169.254]', 'http://0x7f000001',
'http://2130706433', 'http://100.100.100.200/latest/meta-data',
'http://[64:ff9b::7f00:1]', 'http://[64:ff9b::a9fe:a9fe]',
'http://168.63.129.16', 'http://[2002:7f00:1::]',
'http://user:***@192.168.1.1',
]) {
assert.throws(() => normalizeConfig({ instances: [{ id: 'x', url }] }, {}), /credentials|target/i)
}
})
test('control plane rejects non-loopback Host and cross-origin browser requests', async t => {
const store = await storeFor(); const registry = fakeRegistry(store)
const app = await buildApp({ configStore: store, clientRegistry: registry, staticFiles: false }); t.after(() => app.close())
assert.equal((await app.inject({ url: '/api/health', headers: { host: '127.0.0.1:8788' } })).statusCode, 200)
assert.equal((await app.inject({ url: '/api/health', headers: { host: '127.0.0.1:1' } })).statusCode, 421)
assert.equal((await app.inject({ url: '/api/health', headers: { host: 'localhost:8788' } })).statusCode, 421)
assert.equal((await app.inject({ url: '/api/health', headers: { host: 'attacker.example' } })).statusCode, 421)
assert.equal((await app.inject({ url: '/api/health', headers: { host: '[::1]evil.example' } })).statusCode, 421)
assert.equal((await app.inject({ url: '/api/health', headers: { host: '127.0.0.1:bad:evil' } })).statusCode, 421)
assert.equal((await app.inject({ url: '/api/health', headers: { host: '127.0.0.1:8788', origin: 'https://attacker.example' } })).statusCode, 403)
assert.equal((await app.inject({ url: '/api/health', headers: { host: '127.0.0.1:8788', origin: 'http://127.0.0.1:9999' } })).statusCode, 403)
assert.equal((await app.inject({ url: '/api/health', headers: { host: '127.0.0.1:8788', origin: 'https://127.0.0.1:8788' } })).statusCode, 403)
assert.equal((await app.inject({ url: '/api/health', headers: { host: '127.0.0.1:8788', origin: 'http://127.0.0.1:8788' } })).statusCode, 200)
})
test('expanded IPv6 loopback config accepts equivalent exact authorities', async t => {
const dir = await mkdtemp(path.join(tmpdir(), 'msa-p2-v6-'))
const configPath = path.join(dir, 'config.json'), examplePath = path.join(dir, 'example.json')
await writeFile(examplePath, JSON.stringify({ server: { host: '0:0:0:0:0:0:0:1', port: 8788 }, instances: [{ id: 'one', url: 'http://192.168.8.1' }] }))
const store = await FileConfigStore.open({ configPath, examplePath, env: {} })
const registry = fakeRegistry(store)
const app = await buildApp({ configStore: store, clientRegistry: registry, staticFiles: false }); t.after(() => app.close())
assert.equal((await app.inject({ url: '/api/health', headers: { host: '[::1]:8788' } })).statusCode, 200)
assert.equal((await app.inject({ url: '/api/health', headers: { host: '[0:0:0:0:0:0:0:1]:8788' } })).statusCode, 200)
assert.equal((await app.inject({ url: '/api/health', headers: { host: '[0:0:0:0:0:0:0:1]:8788', origin: 'http://[0:0:0:0:0:0:0:1]:8788' } })).statusCode, 200)
assert.equal((await app.inject({ url: '/api/health', headers: { host: '[0:0:0:0:0:0:0:1]:8788', origin: 'http://[::1]:8788' } })).statusCode, 200)
assert.equal((await app.inject({ url: '/api/health', headers: { host: '[::1]:8789' } })).statusCode, 421)
})
test('proxy failures return a stable error without leaking upstream exception details', async t => {
const store = await storeFor()
const registry = new ClientRegistry({ createClient: instance => ({ instance, request: async () => { throw new Error('SECRET_UPSTREAM_DETAIL') } }) }).reconcile(store.snapshot.instances)
const app = await buildApp({ configStore: store, clientRegistry: registry, staticFiles: false }); t.after(() => app.close())
const response = await app.inject({ url: '/api/proxy/one/api/device' })
assert.equal(response.statusCode, 502)
assert.equal(response.body.includes('SECRET_UPSTREAM_DETAIL'), false)
})
test('upstream cannot impersonate controlled proxy errors to leak details', async t => {
const store = await storeFor()
for (const error of [new Error('SECRET invalid proxy path DETAIL'), Object.assign(new Error('SECRET_UNSUPPORTED_DETAIL'), { statusCode: 415 })]) {
const registry = new ClientRegistry({ createClient: instance => ({ instance, request: async () => { throw error } }) }).reconcile(store.snapshot.instances)
const app = await buildApp({ configStore: store, clientRegistry: registry, staticFiles: false }); t.after(() => app.close())
const response = await app.inject({ url: '/api/proxy/one/api/device' })
assert.equal(response.statusCode, 502)
assert.equal(response.body.includes('SECRET'), false)
}
})
test('confirmation store bounds pending tokens and invalidates a token on first consume attempt', () => {
const store = new ConfirmationStore({ maxEntries: 2, ttlMs: 1000, now: () => 0 })
const first = store.prepare({ id: 1 }).token
store.prepare({ id: 2 }); store.prepare({ id: 3 })
assert.equal(store.consume(first, { id: 1 }), false)
const token = store.prepare({ id: 4 }).token
assert.equal(store.consume(token, { id: 999 }), false)
assert.equal(store.consume(token, { id: 4 }), false)
})
test('password update has preserve/set/clear semantics and rejects legacy empty password', async () => {
const store = await storeFor()
assert.equal(store.snapshot.revision, 0)
await store.update('one', { name: 'renamed', passwordAction: 'preserve' })
assert.equal(store.snapshot.instances[0].name, 'renamed')
assert.equal(store.snapshot.instances[0].auth.password, 'old-secret')
assert.equal(store.snapshot.revision, 1)
await store.update('one', { name: 'changed' })
assert.equal(store.snapshot.instances[0].auth.password, 'old-secret')
assert.equal(store.snapshot.revision, 2)
await assert.rejects(store.update('one', { auth: { password: '' } }), /ambiguous/i)
await assert.rejects(store.update('one', { passwordAction: 'set', password: '' }), /non-empty/i)
await store.update('one', { passwordAction: 'set', password: 'new-secret' })
assert.equal(store.snapshot.instances[0].auth.password, 'new-secret')
await store.update('one', { passwordAction: 'clear' })
assert.equal(store.snapshot.instances[0].auth.password, '')
})
test('metadata update API never returns passwords and legacy empty password is 400', async t => {
const store = await storeFor(); const registry = fakeRegistry(store)
const app = await buildApp({ configStore: store, clientRegistry: registry, staticFiles: false }); t.after(() => app.close())
const bad = await app.inject({ method: 'PUT', url: '/api/instances/one', payload: { auth: { password: '' } } })
assert.equal(bad.statusCode, 400); assert.match(bad.json().error, /ambiguous/i)
const good = await app.inject({ method: 'PUT', url: '/api/instances/one', payload: { name: 'safe' } })
assert.equal(good.statusCode, 200); assert.equal(JSON.stringify(good.json()).includes('old-secret'), false)
})
test('production proxy policy rejects unknown/auth paths and disallowed methods before upstream', async t => {
const store = await storeFor(); const calls = []; const registry = fakeRegistry(store, calls)
const app = await buildApp({ configStore: store, clientRegistry: registry, staticFiles: false }); t.after(() => app.close())
assert.equal((await app.inject('/api/proxy/one/api/device')).statusCode, 200)
assert.equal((await app.inject('/api/proxy/one/api/x')).statusCode, 403)
assert.equal((await app.inject('/api/proxy/one/api/auth/login')).statusCode, 403)
assert.equal((await app.inject({ method: 'POST', url: '/api/proxy/one/api/device' })).statusCode, 405)
assert.equal(calls.length, 1)
})
test('injectable policy can allow fake paths while dangerous writes require bound one-use confirmation', async t => {
let now = 1000
const store = await storeFor(); const calls = []; const registry = fakeRegistry(store, calls)
const policy = createProxyPolicy({ readPaths: ['/api/x'], writePaths: { '/api/x': ['POST'] } })
const app = await buildApp({ configStore: store, clientRegistry: registry, staticFiles: false, proxyPolicy: policy, confirmationOptions: { now: () => now, ttlMs: 100 } }); t.after(() => app.close())
const missing = await app.inject({ method: 'POST', url: '/api/proxy/one/api/x?b=2&a=1', payload: { value: 1 } })
assert.equal(missing.statusCode, 428); assert.equal(calls.length, 0)
const prepared = await app.inject({ method: 'POST', url: '/api/proxy-confirmations/prepare', payload: { instanceId: 'one', method: 'POST', path: '/api/x?b=2&a=1', body: { value: 1 } } })
assert.equal(prepared.statusCode, 200); const token = prepared.json().token
const tampered = await app.inject({ method: 'POST', url: '/api/proxy/one/api/x?b=2&a=1', headers: { 'x-confirmation-token': token }, payload: { value: 2 } })
assert.equal(tampered.statusCode, 403); assert.equal(calls.length, 0)
const okToken = (await app.inject({ method: 'POST', url: '/api/proxy-confirmations/prepare', payload: { instanceId: 'one', method: 'POST', path: '/api/x?b=2&a=1', body: { value: 1 } } })).json().token
const ok = await app.inject({ method: 'POST', url: '/api/proxy/one/api/x?a=1&b=2', headers: { 'x-confirmation-token': okToken }, payload: { value: 1 } })
assert.equal(ok.statusCode, 200); assert.equal(calls.length, 1)
const replay = await app.inject({ method: 'POST', url: '/api/proxy/one/api/x?a=1&b=2', headers: { 'x-confirmation-token': okToken }, payload: { value: 1 } })
assert.equal(replay.statusCode, 403); assert.equal(calls.length, 1)
const expiring = (await app.inject({ method: 'POST', url: '/api/proxy-confirmations/prepare', payload: { instanceId: 'one', method: 'POST', path: '/api/x', body: null } })).json().token
now += 101
assert.equal((await app.inject({ method: 'POST', url: '/api/proxy/one/api/x', headers: { 'x-confirmation-token': expiring }, payload: null })).statusCode, 403)
assert.equal(calls.length, 1)
})
test('confirmation is invalidated by target revision/origin change', async t => {
const store = await storeFor(); const calls = []; const registry = fakeRegistry(store, calls)
const policy = createProxyPolicy({ writePaths: { '/api/x': ['POST'] } })
const app = await buildApp({ configStore: store, clientRegistry: registry, staticFiles: false, proxyPolicy: policy }); t.after(() => app.close())
const token = (await app.inject({ method: 'POST', url: '/api/proxy-confirmations/prepare', payload: { instanceId: 'one', method: 'POST', path: '/api/x', body: {} } })).json().token
await store.update('one', { name: 'revision bump' })
const response = await app.inject({ method: 'POST', url: '/api/proxy/one/api/x', headers: { 'x-confirmation-token': token }, payload: {} })
assert.equal(response.statusCode, 403); assert.equal(calls.length, 0)
})
+90
View File
@@ -0,0 +1,90 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import { filterAndSortFleet, emptyFleetReason } from '../public/state/fleet-view-model.js'
import { instanceDraftPayload, requestDraft } from '../public/state/operation-draft.js'
import { resolveView } from '../public/router/view-router.js'
const root = new URL('../', import.meta.url)
const text = path => readFile(new URL(path, root), 'utf8')
test('fleet view model filters, searches and sorts without mutating source', () => {
const source = [{ id: 'z', name: 'Zulu', url: 'http://z', tags: ['备用'] }, { id: 'a', name: 'Alpha', url: 'http://a', tags: [] }]
const statuses = new Map([['z', { reachable: false, latencyMs: 80 }], ['a', { reachable: true, authenticated: true, latencyMs: 12 }]])
assert.deepEqual(filterAndSortFleet(source, statuses, { filter: 'online', sort: 'latency' }).map(x => x.id), ['a'])
assert.deepEqual(filterAndSortFleet(source, statuses, { query: '备用', sort: 'name' }).map(x => x.id), ['z'])
assert.deepEqual(source.map(x => x.id), ['z', 'a'])
assert.equal(emptyFleetReason({ total: 0 }), 'config')
assert.equal(emptyFleetReason({ total: 2, query: 'x', visible: 0 }), 'search')
assert.equal(emptyFleetReason({ total: 2, filter: 'offline', visible: 0 }), 'filter')
})
test('operation drafts enforce explicit password and request semantics', () => {
assert.deepEqual(instanceDraftPayload({ id: 'one', name: 'One', url: 'http://one', passwordAction: 'preserve' }, true).passwordAction, 'preserve')
assert.throws(() => instanceDraftPayload({ id: 'one', name: 'One', url: 'http://one', passwordAction: 'set', password: '' }, true), /密码/)
assert.throws(() => instanceDraftPayload({ id: 'one', name: 'One', url: 'http://one', passwordAction: 'clear' }, true), /确认/)
assert.deepEqual(requestDraft('GET', '/api/device', { ignored: true }), { method: 'GET', path: '/api/device' })
assert.equal(requestDraft('PATCH', '/api/data', { enabled: true }).body.enabled, true)
})
test('protected views fall back home without an active device', () => {
for (const view of ['detail', 'rawpage', 'api']) assert.equal(resolveView(view, false), 'home')
assert.equal(resolveView('rawpage', true), 'rawpage')
})
test('phase two shell, CRUD, API console and accessibility contracts exist', async () => {
const [html, app, css, responsive, api] = await Promise.all([
text('public/index.html'), text('public/app.js'), text('public/styles/components.css'), text('public/styles/responsive.css'), text('public/infrastructure/api-client.js'),
])
assert.doesNotMatch(html, /fonts\.googleapis|fonts\.gstatic/)
assert.match(html, /class="desktop-sidebar"/)
assert.match(html, /class="mobile-nav"/)
assert.match(html, /aria-live="polite"[^>]*role="status"/)
assert.match(html, /id="fatalState"[^>]*role="alert"/)
assert.match(html, /id="persistentError"[^>]*role="alert"/)
assert.match(html, /id="deleteDialog"[\s\S]*aria-labelledby="deleteDialogTitle"/)
assert.match(html, /id="apiDeleteDialog"[\s\S]*aria-labelledby="apiDeleteTitle"/)
assert.match(html, /id="passwordPreserve"[\s\S]*id="passwordSet"[\s\S]*id="passwordClear"/)
assert.match(html, /id="endpointSearch"/)
assert.match(html, /id="outputPretty"[\s\S]*id="outputRaw"[\s\S]*id="copyOutput"/)
assert.match(html, /<option>PUT<\/option>[\s\S]*<option>PATCH<\/option>/)
assert.match(html, /rel="noopener noreferrer"/)
assert.doesNotMatch(app, /\bconfirm\s*\(/)
assert.doesNotMatch(app, /role="button"/)
assert.match(app, /passwordAction/)
assert.match(app, /prepareConfirmation/)
assert.match(app, /x-confirmation-token/)
assert.match(app, /lastSuccessfulRefresh/)
assert.match(app, /requests\.isCurrent/)
assert.match(app, /prepared[\s\S]*requests\.isCurrent\('api-console'/)
assert.match(app, /try\s*\{[\s\S]*instanceDraftPayload/)
assert.match(app, /try\s*\{[\s\S]*parseBody/)
assert.match(app, /clearPasswordConfirm'\)\.checked=false/)
assert.match(app, /\[data-dialog-cancel\]/)
assert.match(app, /instanceDialogEpoch\s*\+=\s*1/)
assert.match(app, /deleteDialogEpoch\s*\+=\s*1/)
assert.match(app, /loginDialogEpoch\s*\+=\s*1/)
assert.match(app, /if\s*\(!result\.value\.authenticated\)/)
assert.match(app, /pendingApiRequest/)
assert.match(app, /isWriteMethod\(method\)[\s\S]*apiDeleteDialog'\)\.showModal/)
assert.match(html, /id="apiDeleteDetails"[\s\S]*id="apiDeleteBody"/)
assert.match(app, /passwordInput'\)\.value=''/)
assert.match(app, /loginError'\)\.hidden=true/)
assert.match(app, /loginBtn'\)\.addEventListener[\s\S]*submitLogin'\)\.disabled=false/)
assert.match(app, /function openDeviceDetail[\s\S]*submitLogin'\)\.disabled=false/)
assert.match(app, /function setActiveHome[\s\S]*submitLogin'\)\.disabled=false/)
assert.match(app, /document\.title/)
assert.match(app, /removeAttribute\('src'\)/)
assert.doesNotMatch(css, /minmax\(340px/)
assert.doesNotMatch(css, /\.island-card\{[^}]*box-shadow/s)
assert.doesNotMatch(css, /text-overflow\s*:\s*ellipsis|white-space\s*:\s*nowrap/)
assert.match(css, /overflow-wrap:break-word/)
assert.match(responsive, /max-width:767px/)
assert.match(responsive, /min-width:768px/)
assert.match(responsive, /min-width:1024px/)
assert.match(responsive, /min-width:1440px/)
assert.match(responsive, /min-height:44px/)
assert.match(api, /proxy-confirmations\/prepare/)
})
+4 -4
View File
@@ -4,11 +4,11 @@ import assert from 'node:assert/strict'
import { createSimAdminClient, normalizeInstance, summarizeInstanceSnapshot, redactInstance } from '../server/core.js'
test('normalizeInstance supports passwordless and password-protected instances without exposing password', () => {
const open = normalizeInstance({ id: 'open-1', name: '开放设备', url: 'http://127.0.0.1:3000/' }, 0)
const open = normalizeInstance({ id: 'open-1', name: '开放设备', url: 'http://192.0.2.12:3000/' }, 0)
assert.equal(open.auth.mode, 'none')
assert.equal(open.url, 'http://127.0.0.1:3000')
assert.equal(open.url, 'http://192.0.2.12:3000')
const protectedOne = normalizeInstance({ id: 'locked', url: 'http://cpe.local', auth: { password: 'secret' } }, 1)
const protectedOne = normalizeInstance({ id: 'locked', url: 'http://192.0.2.13', auth: { password: 'secret' } }, 1)
assert.equal(protectedOne.auth.mode, 'password')
assert.equal(protectedOne.auth.password, 'secret')
assert.equal(redactInstance(protectedOne).auth.hasPassword, true)
@@ -30,7 +30,7 @@ test('client stores simadmin_session from login and sends it on later proxied re
}
return new Response('{}', { status: 404 })
}
const client = createSimAdminClient(normalizeInstance({ id: 'locked', url: 'http://sim.local', auth: { password: 'secret' } }, 0), { fetchImpl })
const client = createSimAdminClient(normalizeInstance({ id: 'locked', url: 'http://192.0.2.14', auth: { password: 'secret' } }, 0), { fetchImpl })
const auth = await client.ensureAuthenticated()
assert.equal(auth.authenticated, true)
const proxied = await client.fetchJson('/api/device')