[verified] refactor: harden operations and redesign device console
This commit is contained in:
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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' }),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
@@ -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 +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
@@ -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 +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
@@ -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' }))
|
||||
|
||||
@@ -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
@@ -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 },
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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' })
|
||||
})
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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,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')
|
||||
|
||||
Reference in New Issue
Block a user