[verified] refactor: establish service and frontend boundaries

This commit is contained in:
chick
2026-07-15 02:43:41 +08:00
parent 0ac6faa6a3
commit e04a16e817
29 changed files with 917 additions and 598 deletions
+22 -1
View File
@@ -29,7 +29,7 @@ npm start
```json
{
"server": { "host": "0.0.0.0", "port": 8788 },
"server": { "host": "127.0.0.1", "port": 8788 },
"instances": [
{
"id": "simadmin-open",
@@ -70,6 +70,27 @@ npm test
npm start
```
## 架构与阶段边界
第一阶段采用无构建链的可组合架构:
- `server/index.js` 是唯一 bootstrap,负责路径、监听和优雅关闭;`server/app.js``buildApp()` 无导入副作用,可通过 Fastify `inject()` 测试。
- `server/config/` 提供 immutable snapshot、串行事务和 temp + atomic rename;模板只用于首次加载,提交始终写实际 `config.json`
- `server/clients/` 在配置成功提交后 reconcile;`server/status/``server/proxy/` 是独立边界。
- `public/domain``infrastructure``state``router` 放置 Node 可测试模块,`app.js` 暂作 DOM composition/controller。
- `styles.css` 依次导入 tokens、base、components、responsive。
验证命令:
```bash
npm test
find server public test -name '*.js' -exec node --check {} \;
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` 会保持,但这会暴露当前尚未鉴权的控制面。
## 注意
- 当前项目是本地管理面板,不修改 SimAdmin 本体。
+1 -1
View File
@@ -1,6 +1,6 @@
{
"server": {
"host": "0.0.0.0",
"host": "127.0.0.1",
"port": 8788
},
"instances": [
+24 -173
View File
@@ -8,7 +8,7 @@
"name": "multi-simadmin",
"version": "0.1.0",
"dependencies": {
"@fastify/static": "^8.3.0",
"@fastify/static": "^9.1.3",
"fastify": "^5.6.2"
},
"devDependencies": {}
@@ -164,9 +164,9 @@
}
},
"node_modules/@fastify/static": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/@fastify/static/-/static-8.3.0.tgz",
"integrity": "sha512-yKxviR5PH1OKNnisIzZKmgZSus0r2OZb8qCSbqmw34aolT4g3UlzYfeBRym+HJ1J471CR8e2ldNub4PubD1coA==",
"version": "9.1.3",
"resolved": "https://registry.npmjs.org/@fastify/static/-/static-9.1.3.tgz",
"integrity": "sha512-aXrYtsiryLhRxRNaxNqsn7FUISeb7rB9q4eHUPIot5aeQBLNahnz1m6thzm7JWC1poSGXS9XrX8DvuMivp2hkQ==",
"funding": [
{
"type": "github",
@@ -181,19 +181,10 @@
"dependencies": {
"@fastify/accept-negotiator": "^2.0.0",
"@fastify/send": "^4.0.0",
"content-disposition": "^0.5.4",
"content-disposition": "^1.0.1",
"fastify-plugin": "^5.0.0",
"fastq": "^1.17.1",
"glob": "^11.0.0"
}
},
"node_modules/@isaacs/cliui": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz",
"integrity": "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==",
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=18"
"glob": "^13.0.0"
}
},
"node_modules/@lukeed/ms": {
@@ -301,15 +292,16 @@
}
},
"node_modules/content-disposition": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
"integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==",
"license": "MIT",
"dependencies": {
"safe-buffer": "5.2.1"
},
"engines": {
"node": ">= 0.6"
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/cookie": {
@@ -325,20 +317,6 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
"shebang-command": "^2.0.0",
"which": "^2.0.1"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -496,41 +474,18 @@
"node": ">=20"
}
},
"node_modules/foreground-child": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
"integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
"license": "ISC",
"dependencies": {
"cross-spawn": "^7.0.6",
"signal-exit": "^4.0.1"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/glob": {
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz",
"integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==",
"deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
"version": "13.0.6",
"resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz",
"integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==",
"license": "BlueOak-1.0.0",
"dependencies": {
"foreground-child": "^3.3.1",
"jackspeak": "^4.1.1",
"minimatch": "^10.1.1",
"minipass": "^7.1.2",
"package-json-from-dist": "^1.0.0",
"path-scurry": "^2.0.0"
},
"bin": {
"glob": "dist/esm/bin.mjs"
"minimatch": "^10.2.2",
"minipass": "^7.1.3",
"path-scurry": "^2.0.2"
},
"engines": {
"node": "20 || >=22"
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
@@ -571,27 +526,6 @@
"node": ">= 10"
}
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"license": "ISC"
},
"node_modules/jackspeak": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.2.3.tgz",
"integrity": "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==",
"license": "BlueOak-1.0.0",
"dependencies": {
"@isaacs/cliui": "^9.0.0"
},
"engines": {
"node": "20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/json-schema-ref-resolver": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz",
@@ -655,9 +589,9 @@
"license": "MIT"
},
"node_modules/lru-cache": {
"version": "11.5.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz",
"integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==",
"version": "11.5.2",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
"integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
@@ -708,21 +642,6 @@
"node": ">=14.0.0"
}
},
"node_modules/package-json-from-dist": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
"license": "BlueOak-1.0.0"
},
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/path-scurry": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz",
@@ -841,26 +760,6 @@
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
"license": "MIT"
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safe-regex2": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.1.tgz",
@@ -932,39 +831,6 @@
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"license": "MIT",
"dependencies": {
"shebang-regex": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/shebang-regex": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/signal-exit": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
"license": "ISC",
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/sonic-boom": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz",
@@ -1027,21 +893,6 @@
"engines": {
"node": ">=0.6"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"license": "ISC",
"dependencies": {
"isexe": "^2.0.0"
},
"bin": {
"node-which": "bin/node-which"
},
"engines": {
"node": ">= 8"
}
}
}
}
+2 -3
View File
@@ -9,8 +9,7 @@
"test": "node --test"
},
"dependencies": {
"@fastify/static": "^8.3.0",
"@fastify/static": "^9.1.3",
"fastify": "^5.6.2"
},
"devDependencies": {}
}
}
+48 -52
View File
@@ -1,9 +1,13 @@
import { escapeHtml, value, formatBytes } from './domain/formatters.js'
import { createApiClient, normalizeTargetUrl } from './infrastructure/api-client.js'
import { RequestCoordinator } from './state/request-coordinator.js'
import { createViewRouter, resolveView } from './router/view-router.js'
let instances = []
let statuses = new Map()
let catalog = []
let activeId = null
let activeFilter = 'all'
let activeModule = 'all'
let configPath = 'config.json'
let autoRefreshTimer = null
@@ -20,10 +24,10 @@ const modules = [
{ 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'] },
]
function escapeHtml(text) {
return String(text ?? '').replace(/[&<>"']/g, ch => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[ch]))
}
function value(v) { return v === undefined || v === null || v === '' ? '-' : String(v) }
const requests = new RequestCoordinator()
const api = createApiClient()
const routeView = createViewRouter({ controls: () => $$('[data-view]'), panels: () => $$('.view') })
function boolText(v) {
if (v === true) return '开启'
if (v === false) return '关闭'
@@ -79,27 +83,10 @@ 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 proxiedUrl(endpoint) {
if (!activeId) return null
const normalized = endpoint.startsWith('/') ? endpoint : `/${endpoint}`
return `/api/proxy/${encodeURIComponent(activeId)}${normalized}`
}
function firstValue(...items) {
for (const item of items) if (item !== undefined && item !== null && item !== '') return item
return null
}
function formatBytes(n) {
if (n === undefined || n === null || n === '') return '-'
const num = Number(n)
if (!Number.isFinite(num)) return value(n)
const units = ['B', 'KB', 'MB', 'GB', 'TB']
let size = Math.abs(num)
let i = 0
while (size >= 1024 && i < units.length - 1) { size /= 1024; i += 1 }
const signed = num < 0 ? -size : size
return `${signed.toFixed(size >= 10 || i === 0 ? 0 : 1)} ${units[i]}`
}
function formatSpeed(speed) {
if (!speed || typeof speed !== 'object') return '-'
if (Array.isArray(speed.interfaces)) {
@@ -236,6 +223,7 @@ 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
@@ -252,6 +240,7 @@ function openDeviceDetail(id) {
}
function setActiveHome() {
requests.invalidate('api-console'); requests.invalidate('login')
activeId = null
localStorage.removeItem('multi-simadmin-active')
$('loginBtn').disabled = true
@@ -364,10 +353,8 @@ function renderEndpointList() {
}
function switchView(view) {
if (!['home', 'detail', 'rawpage', 'api'].includes(view)) view = activeId ? 'detail' : 'home'
$$('[data-view]').forEach(btn => btn.classList.toggle('active', btn.dataset.view === view))
$$('.view').forEach(panel => panel.classList.toggle('active', panel.id === `${view}View`))
localStorage.setItem('multi-simadmin-view', view)
view = resolveView(view, Boolean(activeId))
routeView(view)
}
function selectInstance(id) { openDeviceDetail(id) }
@@ -399,11 +386,8 @@ async function reloadConfigAndRefresh({ selectId = activeId, toast = '' } = {})
async function saveInstanceFromForm() {
const originalId = $('instanceOriginalId').value
const payload = instancePayloadFromForm()
const method = originalId ? 'PUT' : 'POST'
const url = originalId ? `/api/instances/${encodeURIComponent(originalId)}` : '/api/instances'
const res = await fetch(url, { method, headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload) })
const data = await res.json().catch(() => ({}))
if (!res.ok) throw new Error(data.error || `保存失败:HTTP ${res.status}`)
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 ? '设备地址已更新' : '设备地址已添加' })
}
@@ -412,12 +396,7 @@ 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
const res = await fetch(`/api/instances/${encodeURIComponent(id)}`, { method: 'DELETE' })
const data = await res.json().catch(() => ({}))
if (!res.ok) {
showToast(data.error || `删除失败:HTTP ${res.status}`, 4200)
return
}
try { await api.deleteInstance(id) } catch (error) { showToast(error.message, 4200); return }
await reloadConfigAndRefresh({ selectId: null, toast: '设备地址已删除' })
}
@@ -429,13 +408,11 @@ async function deleteCurrentInstanceFromDialog() {
}
async function loadConfig({ preserveSelection = activeId } = {}) {
const [configRes, catalogRes] = await Promise.all([fetch('/api/config'), fetch('/api/catalog')])
if (!configRes.ok) throw new Error(`配置读取失败:HTTP ${configRes.status}`)
const data = await configRes.json()
const [data, catalogData] = await Promise.all([api.config(), api.catalog().catch(() => ({ groups: [] }))])
instances = data.instances || []
configPath = data.configPath || 'config.json'
$('configPath').textContent = configPath
if (catalogRes.ok) catalog = (await catalogRes.json()).groups || []
catalog = catalogData.groups || []
const saved = preserveSelection || localStorage.getItem('multi-simadmin-active')
activeId = saved && instances.some(x => x.id === saved) ? saved : null
renderEndpointList()
@@ -447,9 +424,12 @@ async function refreshStatus({ silent = false } = {}) {
$('refreshBtn').disabled = true
if (!silent) showToast('正在刷新全部实例…', 1200)
try {
const res = await fetch('/api/status')
if (!res.ok) throw new Error(`状态读取失败:HTTP ${res.status}`)
const data = await res.json()
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
}
const data = result.value
statuses = new Map((data.instances || []).map(item => [item.id, item]))
renderHomeCards()
if (activeId) {
@@ -471,13 +451,16 @@ async function loginActive() {
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 res = await fetch(`/api/instances/${encodeURIComponent(activeId)}/login`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({}) })
if (!res.ok) showToast(`登录失败:HTTP ${res.status}`, 4200)
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)
}
@@ -492,6 +475,7 @@ async function runEndpoint() {
showToast('请先在首页选择设备卡片')
return
}
const requestOwnerId = activeId
const endpoint = $('endpointInput').value.trim()
const method = $('methodSelect').value
if (!endpoint.startsWith('/')) {
@@ -507,9 +491,19 @@ async function runEndpoint() {
if (body !== undefined) init.body = JSON.stringify(body)
}
const started = performance.now()
const res = await fetch(proxiedUrl(endpoint), init)
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)
const text = await res.text()
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}`
@@ -539,13 +533,15 @@ function bindEvents() {
}))
$('loginForm').addEventListener('submit', async (event) => {
event.preventDefault()
if (!activeId) return
const loginInstanceId = $('loginDialog').dataset.instanceId
if (!loginInstanceId) return
const password = $('passwordInput').value
$('passwordInput').value = ''
$('loginDialog').close()
const res = await fetch(`/api/instances/${encodeURIComponent(activeId)}/login`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ password }) })
if (!res.ok) showToast(`登录失败:HTTP ${res.status}`, 4200)
else showToast('会话已刷新')
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) => {
+10
View File
@@ -0,0 +1,10 @@
export function value(input) { return input === undefined || input === null || input === '' ? '-' : String(input) }
export function escapeHtml(text) { return String(text ?? '').replace(/[&<>"']/g, ch => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[ch])) }
export function formatBytes(input) {
if (input === undefined || input === null || input === '') return '-'
const number = Number(input)
if (!Number.isFinite(number)) return value(input)
const units = ['B', 'KB', 'MB', 'GB', 'TB']; let size = Math.abs(number); let index = 0
while (size >= 1024 && index < units.length - 1) { size /= 1024; index += 1 }
return `${number < 0 ? -size : size.toFixed(size >= 10 || index === 0 ? 0 : 1)} ${units[index]}`
}
+27
View File
@@ -0,0 +1,27 @@
export function normalizeTargetUrl(raw) {
const url = new URL(raw)
if (!['http:', 'https:'].includes(url.protocol)) throw new Error('target URL must use http or https')
url.hash = ''; url.search = ''
return url.toString().replace(/\/$/, '')
}
export function createApiClient({ fetchImpl = globalThis.fetch } = {}) {
const request = async (url, options) => {
const response = await fetchImpl(url, options)
const data = await response.json().catch(() => ({}))
if (!response.ok) throw new Error(data.error || `HTTP ${response.status}`)
return data
}
return {
request,
config: () => request('/api/config'),
catalog: () => request('/api/catalog'),
status: () => request('/api/status'),
proxy: (id, endpoint, options) => fetchImpl(`/api/proxy/${encodeURIComponent(id)}${endpoint}`, options),
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' }),
login: (id, password) => request(`/api/instances/${encodeURIComponent(id)}/login`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(password === undefined ? {} : { password }) }),
logout: id => request(`/api/instances/${encodeURIComponent(id)}/logout`, { method: 'POST' }),
}
}
+11
View File
@@ -0,0 +1,11 @@
export const VIEW_IDS = Object.freeze(['home', 'detail', 'rawpage', 'api'])
export function resolveView(view, hasActiveInstance = false) {
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 panel of panels()) panel.classList.toggle('active', panel.id === `${view}View`)
storage?.setItem('multi-simadmin-view', view)
}
}
+14
View File
@@ -0,0 +1,14 @@
export class RequestCoordinator {
#versions = new Map()
async run(channel, ownerId, operation) {
const version = (this.#versions.get(channel) || 0) + 1
this.#versions.set(channel, version)
try {
const value = await operation()
return { accepted: this.#versions.get(channel) === version, ownerId, value }
} catch (error) {
return { accepted: this.#versions.get(channel) === version, ownerId, error }
}
}
invalidate(channel) { this.#versions.set(channel, (this.#versions.get(channel) || 0) + 1) }
}
+4 -17
View File
@@ -1,17 +1,4 @@
: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);
}
*{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)}}
.island-card{border-radius:24px;background:var(--animal-bg-color-content);color:var(--animal-text-color-body);border:1.5px solid #d4c4a8;box-shadow:var(--animal-shadow-base)}.pattern-default{background:radial-gradient(circle,rgba(196,184,158,.15) 1.5px,transparent 1.5px),radial-gradient(circle,rgba(196,184,158,.1) 1px,transparent 1px),rgb(247,243,223);background-size:28px 28px,14px 14px;background-position:0 0,7px 7px}.pattern-app-teal{background:radial-gradient(circle,rgba(130,213,187,.18) 1.5px,transparent 1.5px),radial-gradient(circle,rgba(170,235,210,.12) 1px,transparent 1px),#e8faf5;background-size:28px 28px,14px 14px;border-color:#82d5bb;color:#2a6b5a}.pattern-app-yellow{background:radial-gradient(circle,rgba(247,205,103,.18) 1.5px,transparent 1.5px),radial-gradient(circle,rgba(255,230,160,.12) 1px,transparent 1px),#fff8e0;background-size:28px 28px,14px 14px;border-color:#f7cd67;color:#7a6528}.pattern-app-blue{background:radial-gradient(circle,rgba(136,157,240,.18) 1.5px,transparent 1.5px),radial-gradient(circle,rgba(180,195,255,.12) 1px,transparent 1px),#e8edff;background-size:28px 28px,14px 14px;border-color:#889df0;color:#4a5a8a}.pattern-purple{background:radial-gradient(circle,rgba(183,125,238,.18) 1.5px,transparent 1.5px),radial-gradient(circle,rgba(220,180,255,.12) 1px,transparent 1px),#f0e8ff;background-size:28px 28px,14px 14px;border-color:#b77dee;color:#6a3a9a}.pattern-app-pink{background:#ffe8ee;border-color:#f8a6b2}.section-ribbon{--rf:#8ac68a;--rb:#509050;display:inline-flex;align-items:center;min-height:34px;padding:6px 18px;border-radius:12px;background:var(--rf);color:#fff;font-weight:900;letter-spacing:.04em;box-shadow:0 4px 0 0 var(--rb)}.color-app-yellow{--rf:#f7cd67;--rb:#d4a030;color:#725d42}.color-app-green{--rf:#8ac68a;--rb:#509050}.color-purple{--rf:#b77dee;--rb:#8a4ac0}.color-app-pink{--rf:#f8a6b2;--rb:#d77485}
h1,h2,h3,h4,p{margin:0}.eyebrow{margin:0 0 6px;color:#8a7b66;font-size:12px;font-weight:900;letter-spacing:.08em;text-transform:uppercase}h1{font-size:34px;line-height:1.05;font-weight:900;color:#794f27}.topbar-copy,.board-caption{margin-top:8px;color:#725d42;line-height:1.5}.animal-btn{position:relative;display:inline-flex;align-items:center;justify-content:center;gap:8px;min-height:45px;padding:0 20px;border:2px solid transparent;border-radius:50px;font-weight:800;text-decoration:none;line-height:1;transition:all var(--animal-motion-duration-base) var(--animal-motion-ease);user-select:none}.primary-btn{color:#794f27;background:#f8f8f0;border-color:#f8f8f0;box-shadow:0 5px 0 0 #bdaea0}.primary-btn:hover:not(:disabled),.default-btn:hover:not(:disabled){transform:translateY(-1px)}.default-btn{color:var(--animal-text-color);background:var(--animal-bg-color);border-color:var(--animal-border-color);box-shadow:var(--animal-shadow-sm)}.danger-btn{color:#fff;background:var(--animal-error-color);border-color:var(--animal-error-color)}.disabled,button:disabled{opacity:.5;pointer-events:none}
.sim-shell{height:100vh;display:grid;grid-template-rows:auto minmax(0,1fr) auto;gap:16px;padding:20px}.sim-topbar{display:flex;align-items:center;justify-content:space-between;gap:18px;padding:20px 24px}.hero-actions{display:flex;gap:10px;flex-wrap:wrap;justify-content:flex-end}.sim-main{min-height:0;overflow:hidden}.view{display:none;min-height:0;height:100%;overflow:auto}.view.active{display:block}.home-view{display:none}.home-view.active{display:grid;grid-template-rows:auto minmax(0,1fr);gap:16px}.home-toolbar{display:grid;grid-template-columns:minmax(280px,.8fr) minmax(420px,1.2fr);gap:16px;align-items:end;padding:18px}.home-tools{display:grid;grid-template-columns:260px minmax(220px,1fr);gap:12px;align-items:center}.wallet-row{display:grid;grid-template-columns:repeat(4,1fr);gap:8px}.fleet-card{border-radius:999px;background:#d1da49;color:#3d5a1a;border:2px solid rgba(90,107,40,.18);padding:9px 8px;text-align:center;box-shadow:inset 0 -2px 0 rgba(61,52,40,.08)}.fleet-card b{display:block;font-size:22px;font-weight:900;line-height:1}.fleet-card span{display:block;margin-top:2px;font-size:11px;font-weight:800}.search-pill{height:48px;display:flex;align-items:center;gap:8px;background:rgb(247,243,223);border:2.5px solid #c4b89e;border-radius:50px;padding:0 16px;color:#8a7b66}.search-pill input{width:100%;border:0;outline:0;background:transparent}.island-tabs{grid-column:1 / -1;display:flex;gap:8px;flex-wrap:wrap}.tab-leaf{border:0;border-radius:999px;background:#f7f3df;color:#725d42;padding:9px 16px;font-weight:900}.tab-leaf.active{background:#19c8b9;color:#fff}.home-device-grid{min-height:0;overflow:auto;display:grid;grid-template-columns:repeat(auto-fill,minmax(340px,1fr));gap:16px;padding:2px 4px 16px}.sim-device-card{border:2px solid rgba(212,196,168,.88);border-radius:28px;background:linear-gradient(180deg,rgba(255,255,255,.76),rgba(247,243,223,.92));padding:18px;box-shadow:0 8px 22px rgba(61,52,40,.1);transition:transform .2s ease, box-shadow .2s ease, border-color .2s ease}.sim-device-card:hover{transform:translateY(-3px);box-shadow:0 14px 30px rgba(61,52,40,.15);border-color:#19c8b9}.sim-card-head{display:flex;justify-content:space-between;gap:12px;align-items:flex-start}.sim-card-head h2{font-size:24px;color:#4d3a22}.instance-url{display:block;margin-top:4px;color:#8a7b66;font-size:12px;word-break:break-all}.status-pill{display:inline-flex;align-items:center;justify-content:center;min-height:28px;padding:0 10px;border-radius:999px;background:#eee;color:#725d42;font-size:12px;font-weight:900;white-space:nowrap}.status-pill.ok,.sim-device-card.ok .status-pill{background:#dff5d7;color:#3f7624}.status-pill.warn,.sim-device-card.warn .status-pill{background:#fff0bf;color:#8a6010}.status-pill.bad,.sim-device-card.bad .status-pill{background:#ffe0e0;color:#9a3030}.sim-card-model{margin-top:14px;color:#725d42;font-weight:900}.sim-card-kpis{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;margin-top:14px}.sim-card-kpis span,.signal-item,.metric{border-radius:18px;background:rgba(255,255,255,.62);border:2px solid rgba(232,226,214,.8);padding:12px;min-width:0}.sim-card-kpis em,.signal-item span,.metric span{display:block;color:#9f927d;font-size:12px;font-weight:900;font-style:normal}.sim-card-kpis b,.signal-item b,.metric b{display:block;margin-top:5px;color:#794f27;font-size:16px;font-weight:900}.sim-card-kpis b{overflow:visible;text-overflow:clip;white-space:normal;overflow-wrap:anywhere;word-break:break-word;line-height:1.35}.sim-card-foot{display:flex;justify-content:space-between;gap:10px;margin-top:14px;color:#8a7b66;font-size:12px;font-weight:800;flex-wrap:wrap;overflow:visible;text-overflow:clip;white-space:normal}.sim-card-foot span{overflow-wrap:anywhere;word-break:break-word}.sim-card-model,.instance-url{overflow:visible;text-overflow:clip;white-space:normal;overflow-wrap:anywhere;word-break:break-word;line-height:1.35}.sim-config-note{display:flex;gap:8px;align-items:center;justify-content:flex-end;color:#8a7b66;font-size:12px}.sim-config-note code{background:rgba(255,255,255,.6);border-radius:8px;padding:4px 8px}
.detail-view.active{display:grid;grid-template-rows:auto minmax(0,1fr);gap:16px}.workspace-hero{padding:20px 24px;display:flex;align-items:center;justify-content:space-between;gap:18px}.detail-layout{min-height:0;display:grid;grid-template-columns:minmax(320px,.75fr) minmax(0,1.25fr);grid-template-rows:auto minmax(0,1fr) auto;gap:16px;overflow:auto}.signal-board{padding:18px}.signal-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:10px;margin-top:18px}.nook-metrics{display:grid;grid-template-columns:repeat(2,minmax(140px,1fr));gap:12px}.device-detail-board{grid-column:2;grid-row:1 / span 2;padding:18px;overflow:auto}.device-detail-grid{display:grid;grid-template-columns:repeat(2,minmax(220px,1fr));gap:14px;margin-top:18px}.device-info-card{background:rgba(255,255,255,.62);border:2px solid rgba(212,196,168,.72);border-radius:24px;padding:16px;min-width:0;box-shadow:inset 0 -2px 0 rgba(61,52,40,.04)}.device-info-card h4{margin:0 0 12px;color:#794f27;font-size:18px;font-weight:900}.device-info-card dl{display:grid;gap:8px;margin:0}.device-info-card dl div{display:grid;grid-template-columns:86px minmax(0,1fr);gap:10px;align-items:baseline}.device-info-card dt{color:#9f927d;font-size:12px;font-weight:900}.device-info-card dd{margin:0;color:#4d3a22;font-weight:800;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.detail-actions-card{grid-column:1 / -1;padding:18px}.detail-actions{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px;margin-top:18px}.detail-action{min-height:64px;border:2px solid rgba(212,196,168,.85);background:rgba(255,255,255,.72);border-radius:20px;padding:10px 14px;text-align:left;font-weight:900;color:#4d3a22}.detail-action:hover{border-color:#19c8b9;transform:translateY(-1px)}.detail-action span{display:block}.detail-action code{display:block;margin-top:4px;color:#8a7b66;font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.raw-action{background:#e8edff;color:#4a5a8a}.api-layout.active{display:grid;grid-template-columns:320px minmax(0,1fr);gap:16px}.api-layout:before{content:'调试接口为次级入口;日常使用请从首页卡片进入设备详情。';display:block;grid-column:1 / -1;color:#8a7b66;font-weight:800;margin-left:4px}.api-catalog,.api-console{padding:18px}.endpoint-list{margin-top:16px;display:grid;gap:12px}.endpoint-group h4{margin:0 0 8px}.endpoint-row{width:100%;border:0;border-radius:14px;background:rgba(255,255,255,.58);padding:9px 10px;text-align:left;font-size:12px}.endpoint-row span{font-weight:900;color:#6a3a9a;margin-right:6px}.console-toolbar{display:grid;grid-template-columns:110px minmax(0,1fr) 100px;gap:10px}.console-toolbar select,.console-toolbar input,.request-body{border:2px solid #d4c4a8;border-radius:14px;background:#fff;padding:10px}.request-body{width:100%;min-height:110px;margin-top:12px}.raw-output{min-height:340px;overflow:auto;background:#3a3028;color:#f8f0dc;border-radius:18px;padding:16px;white-space:pre-wrap}.rawpage-view.active{display:grid;grid-template-rows:auto minmax(0,1fr);gap:12px}.frame-toolbar{padding:14px 18px;display:flex;gap:12px;align-items:center}.frame{width:100%;height:100%;border:0;border-radius:24px;background:#fff}.empty-state{padding:28px;text-align:center}.login-card,.instance-form{width:min(560px,calc(100vw - 32px));padding:24px}.login-card input,.instance-form input,.instance-form textarea{width:100%;border:2px solid #d4c4a8;border-radius:14px;background:#fff;padding:10px;margin-top:6px}.instance-form{display:grid;gap:12px}.instance-form label{font-weight:900}.form-menu,dialog menu{display:flex;justify-content:flex-end;gap:10px;padding:0;margin:10px 0 0}.toast{position:fixed;right:20px;bottom:20px;background:#4d3a22;color:#fff;border-radius:999px;padding:12px 18px;font-weight:900;box-shadow:var(--animal-shadow-lg);z-index:20}.compact .sim-device-card{padding:14px}.compact .sim-card-kpis{grid-template-columns:repeat(3,1fr);gap:8px}.compact .device-info-card{padding:12px}.compact .device-info-card dl{gap:5px}
@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}}
@import url('./styles/tokens.css');
@import url('./styles/base.css');
@import url('./styles/components.css');
@import url('./styles/responsive.css');
+2
View File
@@ -0,0 +1,2 @@
*{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)}}
+4
View File
@@ -0,0 +1,4 @@
.island-card{border-radius:24px;background:var(--animal-bg-color-content);color:var(--animal-text-color-body);border:1.5px solid #d4c4a8;box-shadow:var(--animal-shadow-base)}.pattern-default{background:radial-gradient(circle,rgba(196,184,158,.15) 1.5px,transparent 1.5px),radial-gradient(circle,rgba(196,184,158,.1) 1px,transparent 1px),rgb(247,243,223);background-size:28px 28px,14px 14px;background-position:0 0,7px 7px}.pattern-app-teal{background:radial-gradient(circle,rgba(130,213,187,.18) 1.5px,transparent 1.5px),radial-gradient(circle,rgba(170,235,210,.12) 1px,transparent 1px),#e8faf5;background-size:28px 28px,14px 14px;border-color:#82d5bb;color:#2a6b5a}.pattern-app-yellow{background:radial-gradient(circle,rgba(247,205,103,.18) 1.5px,transparent 1.5px),radial-gradient(circle,rgba(255,230,160,.12) 1px,transparent 1px),#fff8e0;background-size:28px 28px,14px 14px;border-color:#f7cd67;color:#7a6528}.pattern-app-blue{background:radial-gradient(circle,rgba(136,157,240,.18) 1.5px,transparent 1.5px),radial-gradient(circle,rgba(180,195,255,.12) 1px,transparent 1px),#e8edff;background-size:28px 28px,14px 14px;border-color:#889df0;color:#4a5a8a}.pattern-purple{background:radial-gradient(circle,rgba(183,125,238,.18) 1.5px,transparent 1.5px),radial-gradient(circle,rgba(220,180,255,.12) 1px,transparent 1px),#f0e8ff;background-size:28px 28px,14px 14px;border-color:#b77dee;color:#6a3a9a}.pattern-app-pink{background:#ffe8ee;border-color:#f8a6b2}.section-ribbon{--rf:#8ac68a;--rb:#509050;display:inline-flex;align-items:center;min-height:34px;padding:6px 18px;border-radius:12px;background:var(--rf);color:#fff;font-weight:900;letter-spacing:.04em;box-shadow:0 4px 0 0 var(--rb)}.color-app-yellow{--rf:#f7cd67;--rb:#d4a030;color:#725d42}.color-app-green{--rf:#8ac68a;--rb:#509050}.color-purple{--rf:#b77dee;--rb:#8a4ac0}.color-app-pink{--rf:#f8a6b2;--rb:#d77485}
h1,h2,h3,h4,p{margin:0}.eyebrow{margin:0 0 6px;color:#8a7b66;font-size:12px;font-weight:900;letter-spacing:.08em;text-transform:uppercase}h1{font-size:34px;line-height:1.05;font-weight:900;color:#794f27}.topbar-copy,.board-caption{margin-top:8px;color:#725d42;line-height:1.5}.animal-btn{position:relative;display:inline-flex;align-items:center;justify-content:center;gap:8px;min-height:45px;padding:0 20px;border:2px solid transparent;border-radius:50px;font-weight:800;text-decoration:none;line-height:1;transition:all var(--animal-motion-duration-base) var(--animal-motion-ease);user-select:none}.primary-btn{color:#794f27;background:#f8f8f0;border-color:#f8f8f0;box-shadow:0 5px 0 0 #bdaea0}.primary-btn:hover:not(:disabled),.default-btn:hover:not(:disabled){transform:translateY(-1px)}.default-btn{color:var(--animal-text-color);background:var(--animal-bg-color);border-color:var(--animal-border-color);box-shadow:var(--animal-shadow-sm)}.danger-btn{color:#fff;background:var(--animal-error-color);border-color:var(--animal-error-color)}.disabled,button:disabled{opacity:.5;pointer-events:none}
.sim-shell{height:100vh;display:grid;grid-template-rows:auto minmax(0,1fr) auto;gap:16px;padding:20px}.sim-topbar{display:flex;align-items:center;justify-content:space-between;gap:18px;padding:20px 24px}.hero-actions{display:flex;gap:10px;flex-wrap:wrap;justify-content:flex-end}.sim-main{min-height:0;overflow:hidden}.view{display:none;min-height:0;height:100%;overflow:auto}.view.active{display:block}.home-view{display:none}.home-view.active{display:grid;grid-template-rows:auto minmax(0,1fr);gap:16px}.home-toolbar{display:grid;grid-template-columns:minmax(280px,.8fr) minmax(420px,1.2fr);gap:16px;align-items:end;padding:18px}.home-tools{display:grid;grid-template-columns:260px minmax(220px,1fr);gap:12px;align-items:center}.wallet-row{display:grid;grid-template-columns:repeat(4,1fr);gap:8px}.fleet-card{border-radius:999px;background:#d1da49;color:#3d5a1a;border:2px solid rgba(90,107,40,.18);padding:9px 8px;text-align:center;box-shadow:inset 0 -2px 0 rgba(61,52,40,.08)}.fleet-card b{display:block;font-size:22px;font-weight:900;line-height:1}.fleet-card span{display:block;margin-top:2px;font-size:11px;font-weight:800}.search-pill{height:48px;display:flex;align-items:center;gap:8px;background:rgb(247,243,223);border:2.5px solid #c4b89e;border-radius:50px;padding:0 16px;color:#8a7b66}.search-pill input{width:100%;border:0;outline:0;background:transparent}.island-tabs{grid-column:1 / -1;display:flex;gap:8px;flex-wrap:wrap}.tab-leaf{border:0;border-radius:999px;background:#f7f3df;color:#725d42;padding:9px 16px;font-weight:900}.tab-leaf.active{background:#19c8b9;color:#fff}.home-device-grid{min-height:0;overflow:auto;display:grid;grid-template-columns:repeat(auto-fill,minmax(340px,1fr));gap:16px;padding:2px 4px 16px}.sim-device-card{border:2px solid rgba(212,196,168,.88);border-radius:28px;background:linear-gradient(180deg,rgba(255,255,255,.76),rgba(247,243,223,.92));padding:18px;box-shadow:0 8px 22px rgba(61,52,40,.1);transition:transform .2s ease, box-shadow .2s ease, border-color .2s ease}.sim-device-card:hover{transform:translateY(-3px);box-shadow:0 14px 30px rgba(61,52,40,.15);border-color:#19c8b9}.sim-card-head{display:flex;justify-content:space-between;gap:12px;align-items:flex-start}.sim-card-head h2{font-size:24px;color:#4d3a22}.instance-url{display:block;margin-top:4px;color:#8a7b66;font-size:12px;word-break:break-all}.status-pill{display:inline-flex;align-items:center;justify-content:center;min-height:28px;padding:0 10px;border-radius:999px;background:#eee;color:#725d42;font-size:12px;font-weight:900;white-space:nowrap}.status-pill.ok,.sim-device-card.ok .status-pill{background:#dff5d7;color:#3f7624}.status-pill.warn,.sim-device-card.warn .status-pill{background:#fff0bf;color:#8a6010}.status-pill.bad,.sim-device-card.bad .status-pill{background:#ffe0e0;color:#9a3030}.sim-card-model{margin-top:14px;color:#725d42;font-weight:900}.sim-card-kpis{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;margin-top:14px}.sim-card-kpis span,.signal-item,.metric{border-radius:18px;background:rgba(255,255,255,.62);border:2px solid rgba(232,226,214,.8);padding:12px;min-width:0}.sim-card-kpis em,.signal-item span,.metric span{display:block;color:#9f927d;font-size:12px;font-weight:900;font-style:normal}.sim-card-kpis b,.signal-item b,.metric b{display:block;margin-top:5px;color:#794f27;font-size:16px;font-weight:900}.sim-card-kpis b{overflow:visible;text-overflow:clip;white-space:normal;overflow-wrap:anywhere;word-break:break-word;line-height:1.35}.sim-card-foot{display:flex;justify-content:space-between;gap:10px;margin-top:14px;color:#8a7b66;font-size:12px;font-weight:800;flex-wrap:wrap;overflow:visible;text-overflow:clip;white-space:normal}.sim-card-foot span{overflow-wrap:anywhere;word-break:break-word}.sim-card-model,.instance-url{overflow:visible;text-overflow:clip;white-space:normal;overflow-wrap:anywhere;word-break:break-word;line-height:1.35}.sim-config-note{display:flex;gap:8px;align-items:center;justify-content:flex-end;color:#8a7b66;font-size:12px}.sim-config-note code{background:rgba(255,255,255,.6);border-radius:8px;padding:4px 8px}
.detail-view.active{display:grid;grid-template-rows:auto minmax(0,1fr);gap:16px}.workspace-hero{padding:20px 24px;display:flex;align-items:center;justify-content:space-between;gap:18px}.detail-layout{min-height:0;display:grid;grid-template-columns:minmax(320px,.75fr) minmax(0,1.25fr);grid-template-rows:auto minmax(0,1fr) auto;gap:16px;overflow:auto}.signal-board{padding:18px}.signal-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:10px;margin-top:18px}.nook-metrics{display:grid;grid-template-columns:repeat(2,minmax(140px,1fr));gap:12px}.device-detail-board{grid-column:2;grid-row:1 / span 2;padding:18px;overflow:auto}.device-detail-grid{display:grid;grid-template-columns:repeat(2,minmax(220px,1fr));gap:14px;margin-top:18px}.device-info-card{background:rgba(255,255,255,.62);border:2px solid rgba(212,196,168,.72);border-radius:24px;padding:16px;min-width:0;box-shadow:inset 0 -2px 0 rgba(61,52,40,.04)}.device-info-card h4{margin:0 0 12px;color:#794f27;font-size:18px;font-weight:900}.device-info-card dl{display:grid;gap:8px;margin:0}.device-info-card dl div{display:grid;grid-template-columns:86px minmax(0,1fr);gap:10px;align-items:baseline}.device-info-card dt{color:#9f927d;font-size:12px;font-weight:900}.device-info-card dd{margin:0;color:#4d3a22;font-weight:800;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.detail-actions-card{grid-column:1 / -1;padding:18px}.detail-actions{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px;margin-top:18px}.detail-action{min-height:64px;border:2px solid rgba(212,196,168,.85);background:rgba(255,255,255,.72);border-radius:20px;padding:10px 14px;text-align:left;font-weight:900;color:#4d3a22}.detail-action:hover{border-color:#19c8b9;transform:translateY(-1px)}.detail-action span{display:block}.detail-action code{display:block;margin-top:4px;color:#8a7b66;font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.raw-action{background:#e8edff;color:#4a5a8a}.api-layout.active{display:grid;grid-template-columns:320px minmax(0,1fr);gap:16px}.api-layout:before{content:'调试接口为次级入口;日常使用请从首页卡片进入设备详情。';display:block;grid-column:1 / -1;color:#8a7b66;font-weight:800;margin-left:4px}.api-catalog,.api-console{padding:18px}.endpoint-list{margin-top:16px;display:grid;gap:12px}.endpoint-group h4{margin:0 0 8px}.endpoint-row{width:100%;border:0;border-radius:14px;background:rgba(255,255,255,.58);padding:9px 10px;text-align:left;font-size:12px}.endpoint-row span{font-weight:900;color:#6a3a9a;margin-right:6px}.console-toolbar{display:grid;grid-template-columns:110px minmax(0,1fr) 100px;gap:10px}.console-toolbar select,.console-toolbar input,.request-body{border:2px solid #d4c4a8;border-radius:14px;background:#fff;padding:10px}.request-body{width:100%;min-height:110px;margin-top:12px}.raw-output{min-height:340px;overflow:auto;background:#3a3028;color:#f8f0dc;border-radius:18px;padding:16px;white-space:pre-wrap}.rawpage-view.active{display:grid;grid-template-rows:auto minmax(0,1fr);gap:12px}.frame-toolbar{padding:14px 18px;display:flex;gap:12px;align-items:center}.frame{width:100%;height:100%;border:0;border-radius:24px;background:#fff}.empty-state{padding:28px;text-align:center}.login-card,.instance-form{width:min(560px,calc(100vw - 32px));padding:24px}.login-card input,.instance-form input,.instance-form textarea{width:100%;border:2px solid #d4c4a8;border-radius:14px;background:#fff;padding:10px;margin-top:6px}.instance-form{display:grid;gap:12px}.instance-form label{font-weight:900}.form-menu,dialog menu{display:flex;justify-content:flex-end;gap:10px;padding:0;margin:10px 0 0}.toast{position:fixed;right:20px;bottom:20px;background:#4d3a22;color:#fff;border-radius:999px;padding:12px 18px;font-weight:900;box-shadow:var(--animal-shadow-lg);z-index:20}.compact .sim-device-card{padding:14px}.compact .sim-card-kpis{grid-template-columns:repeat(3,1fr);gap:8px}.compact .device-info-card{padding:12px}.compact .device-info-card dl{gap:5px}
+1
View File
@@ -0,0 +1 @@
@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}}
+10
View File
@@ -0,0 +1,10 @@
: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);
}
+82
View File
@@ -0,0 +1,82 @@
import Fastify from 'fastify'
import fastifyStatic from '@fastify/static'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { redactInstance } from './core.js'
import { registerStatusRoutes } from './status/routes.js'
import { registerProxyRoutes } from './proxy/routes.js'
import { ConfigNotFoundError, ConfigValidationError } from './config/errors.js'
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']
const CATALOG = [
{ id: 'overview', name: '总览', endpoints: ['/api/device', '/api/sim', '/api/network', '/api/stats', '/api/connectivity'] },
{ id: 'sms', name: '短信', endpoints: ['/api/sms/stats', '/api/sms/list', '/api/sms/send', '/api/sms/conversation'] },
{ id: 'network', name: '网络/小区', endpoints: ['/api/data', '/api/roaming', '/api/airplane-mode', '/api/radio-mode', '/api/band-lock', '/api/cell-lock', '/api/apn', '/api/cells'] },
{ id: 'device-network', name: '设备网络', endpoints: ['/api/device-network/ddns/status', '/api/device-network/ddns/config', '/api/device-network/wlan/status', '/api/device-network/wlan/profiles'] },
{ id: 'calls', name: '电话', endpoints: ['/api/calls', '/api/call/history', '/api/ims/status', '/api/voicemail/status'] },
{ id: 'esim', name: 'eSIM', endpoints: ['/api/work-mode', '/api/esim/config', '/api/esim/lpac/status', '/api/esim/euicc', '/api/esim/profiles'] },
{ 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 } = {}) {
if (!configStore || !clientRegistry) throw new TypeError('configStore and clientRegistry are required')
const app = Fastify({ logger, bodyLimit: 60 * 1024 * 1024 })
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: '/' })
const findClient = (id, reply) => {
const client = clientRegistry.get(id)
if (!client) reply.code(404).send({ error: 'instance not found' })
return client
}
const configError = (reply, error) => reply.code(error instanceof ConfigNotFoundError ? 404 : error instanceof ConfigValidationError ? 400 : 500).send({ error: String(error.message || error) })
let reconcileError = null
let retryTimer = null
const reconcile = () => {
try { clientRegistry.reconcile(configStore.snapshot.instances); reconcileError = null; return true }
catch (error) {
reconcileError = error
if (!retryTimer) retryTimer = setTimeout(() => { retryTimer = null; reconcile() }, reconcileRetryMs)
return false
}
}
const committed = async action => {
const result = await action()
return { result, reconciled: reconcile() }
}
app.get('/api/health', async () => ({ ok: true }))
app.get('/api/ready', async () => reconcileError ? ({ ready: false, degraded: true, reason: 'registry_reconcile_pending' }) : ({ ready: true }))
app.get('/api/config', async () => ({ configPath: configStore.configPath, instances: configStore.snapshot.instances.map(redactInstance), features: FEATURES }))
app.get('/api/instances', async () => ({ instances: configStore.snapshot.instances.map(redactInstance) }))
app.post('/api/instances', async (request, reply) => {
try { const { result, reconciled } = await committed(() => configStore.add(request.body || {})); return { instance: redactInstance(result), configPath: configStore.configPath, ...(reconciled ? {} : { reconcilePending: true }) } }
catch (error) { return configError(reply, error) }
})
app.put('/api/instances/:id', async (request, reply) => {
try { const { result, reconciled } = await committed(() => configStore.update(request.params.id, request.body || {})); return { instance: redactInstance(result), configPath: configStore.configPath, ...(reconciled ? {} : { reconcilePending: true }) } }
catch (error) { return configError(reply, error) }
})
app.delete('/api/instances/:id', async (request, reply) => {
try { const { result, reconciled } = await committed(() => configStore.delete(request.params.id)); return { removed: redactInstance(result), configPath: configStore.configPath, ...(reconciled ? {} : { reconcilePending: true }) } }
catch (error) { return configError(reply, error) }
})
registerStatusRoutes(app, { registry: clientRegistry, findClient })
app.post('/api/instances/:id/login', async (request, reply) => {
const client = findClient(request.params.id, reply); if (!client) return
const supplied = Object.hasOwn(request.body || {}, 'password')
return { ...(await client.ensureAuthenticated(supplied ? { credential: request.body.password } : {})), hasSavedPassword: Boolean(configStore.snapshot.instances.find(item => item.id === request.params.id)?.auth?.password) }
})
app.post('/api/instances/:id/logout', async (request, reply) => {
const client = findClient(request.params.id, reply); if (!client) return
try { await client.fetchJson('/api/auth/logout', { method: 'POST' }) } catch {}
client.jar.clear(); client.clearEphemeralSecret?.(); return { ok: true }
})
registerProxyRoutes(app, { findClient })
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' }))
app.addHook('onClose', async () => { if (retryTimer) clearTimeout(retryTimer); clientRegistry.close() })
return app
}
+45
View File
@@ -0,0 +1,45 @@
import { createSimAdminClient } from '../core.js'
function sameInstance(a, b) {
return a && b && JSON.stringify(a) === JSON.stringify(b)
}
export class ClientRegistry {
constructor({ createClient = createSimAdminClient } = {}) {
this.createClient = createClient
this.clients = new Map()
}
reconcile(instances) {
const previous = this.clients
const nextClients = new Map()
const staged = []
try {
for (const instance of instances) {
const current = previous.get(instance.id)
const next = current && sameInstance(current.instance, instance) ? current : this.createClient(instance)
nextClients.set(instance.id, next)
if (next !== current && ![...previous.values()].includes(next)) staged.push(next)
}
} catch (error) {
for (const client of staged) { client.close?.(); client.jar?.clear?.() }
throw error
}
this.clients = nextClients
for (const [id, client] of previous) {
if (nextClients.get(id) !== client) { client.close?.(); client.jar?.clear?.() }
}
return this
}
get(id) { return this.clients.get(id) }
values() { return this.clients.values() }
delete(id) {
const client = this.clients.get(id)
if (!client) return false
client.close?.()
client.jar?.clear?.()
return this.clients.delete(id)
}
close() {
for (const id of [...this.clients.keys()]) this.delete(id)
}
}
+6
View File
@@ -0,0 +1,6 @@
export class ConfigValidationError extends Error {
constructor(message) { super(message); this.name = 'ConfigValidationError' }
}
export class ConfigNotFoundError extends Error {
constructor(message) { super(message); this.name = 'ConfigNotFoundError' }
}
+89
View File
@@ -0,0 +1,89 @@
import { readFile, open, rename, unlink } from 'node:fs/promises'
import path from 'node:path'
import { normalizeConfig, normalizeInstance, serializeConfig, immutableSnapshot } from './schema.js'
import { ConfigNotFoundError, ConfigValidationError } from './errors.js'
export async function atomicWriteJson(destination, value) {
const temp = path.join(path.dirname(destination), `.${path.basename(destination)}.${process.pid}.${crypto.randomUUID()}.tmp`)
try {
const handle = await open(temp, 'wx', 0o600)
try {
await handle.writeFile(`${JSON.stringify(value, null, 2)}\n`)
await handle.sync()
} finally { await handle.close() }
await rename(temp, destination)
try {
const directory = await open(path.dirname(destination), 'r')
try { await directory.sync() } finally { await directory.close() }
} catch (error) {
if (!['EINVAL', 'ENOTSUP', 'EISDIR', 'EPERM', 'EACCES'].includes(error.code)) throw error
}
} catch (error) {
await unlink(temp).catch(() => {})
throw error
}
}
export class FileConfigStore {
#snapshot
#queue = Promise.resolve()
constructor({ configPath, examplePath, snapshot, atomicWrite = atomicWriteJson, env = process.env }) {
this.configPath = configPath
this.examplePath = examplePath
this.atomicWrite = atomicWrite
this.env = { ...env }
this.#snapshot = immutableSnapshot({ ...snapshot, configPath })
}
static async open({ configPath, examplePath, atomicWrite, env } = {}) {
let text
try { text = await readFile(configPath, 'utf8') }
catch (error) {
if (error.code !== 'ENOENT') throw error
if (!examplePath) throw new ConfigNotFoundError(`config not found: ${configPath}`)
text = await readFile(examplePath, 'utf8')
}
const effectiveEnv = env || process.env
return new FileConfigStore({ configPath, examplePath, atomicWrite, env: effectiveEnv, snapshot: normalizeConfig(JSON.parse(text), effectiveEnv) })
}
get snapshot() { return this.#snapshot }
transact(mutator) {
const operation = this.#queue.then(async () => {
const draft = structuredClone(this.#snapshot)
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 })
return result?.id ? this.#snapshot.instances.find(item => item.id === result.id) || result : result
})
this.#queue = operation.catch(() => {})
return operation
}
add(payload) {
return this.transact(draft => {
const next = normalizeInstance(payload, draft.instances.length)
if (draft.instances.some(item => item.id === next.id)) throw new ConfigValidationError(`duplicate instance id: ${next.id}`)
draft.instances.push(next)
return next
})
}
update(id, payload) {
return this.transact(draft => {
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 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
return next
})
}
delete(id) {
return this.transact(draft => {
const index = draft.instances.findIndex(item => item.id === id)
if (index < 0) throw new ConfigNotFoundError(`instance not found: ${id}`)
return draft.instances.splice(index, 1)[0]
})
}
}
+71
View File
@@ -0,0 +1,71 @@
import { ConfigValidationError } from './errors.js'
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')
url.hash = ''
url.search = ''
return url.toString().replace(/\/$/, '')
}
export function normalizeInstance(instance, index = 0) {
if (!instance || typeof instance !== 'object') throw new ConfigValidationError(`instances[${index}] must be an object`)
const id = String(instance.id || '').trim()
const name = String(instance.name || id || `SimAdmin ${index + 1}`).trim()
const rawUrl = String(instance.url || '').trim()
if (!id || !/^[a-zA-Z0-9_.-]+$/.test(id)) throw new ConfigValidationError(`instances[${index}].id is required and may only contain letters, numbers, _, -, .`)
if (!rawUrl) throw new ConfigValidationError(`instances[${index}].url is required`)
const password = instance.auth?.password ?? instance.password ?? ''
const mode = password ? 'password' : String(instance.auth?.mode || 'none')
if (!['none', 'password'].includes(mode)) throw new ConfigValidationError(`instances[${index}].auth.mode must be none or password`)
return {
id, name, url: normalizeBaseUrl(rawUrl),
description: String(instance.description || '').trim(),
tags: Array.isArray(instance.tags) ? instance.tags.map(String) : [],
auth: { mode, password: password ? String(password) : '' },
capabilities: Array.isArray(instance.capabilities) ? instance.capabilities.map(String) : [],
}
}
export function normalizeConfig(raw = {}, env = process.env) {
const instances = Array.isArray(raw.instances) ? raw.instances.map(normalizeInstance) : []
const ids = new Set()
for (const item of instances) {
if (ids.has(item.id)) throw new ConfigValidationError(`duplicate instance id: ${item.id}`)
ids.add(item.id)
}
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')
return {
server: { host: raw.server?.host ?? env.HOST ?? '127.0.0.1', port },
instances,
raw: { ...raw, instances },
}
}
export function serializeConfig(snapshot) {
return {
...(snapshot.raw || {}),
server: snapshot.raw?.server || snapshot.server,
instances: snapshot.instances.map(instance => ({
id: instance.id, name: instance.name, url: instance.url,
description: instance.description || '', tags: instance.tags || [],
auth: instance.auth?.password ? { mode: 'password', password: instance.auth.password } : { mode: 'none' },
capabilities: instance.capabilities || [],
})),
}
}
export function immutableSnapshot(value) {
const clone = structuredClone(value)
const freeze = object => {
if (object && typeof object === 'object' && !Object.isFrozen(object)) {
Object.freeze(object)
for (const item of Object.values(object)) freeze(item)
}
return object
}
return freeze(clone)
}
+16 -177
View File
@@ -1,116 +1,7 @@
import { readFile, writeFile } from 'node:fs/promises'
import { existsSync } from 'node:fs'
import path from 'node:path'
export { normalizeBaseUrl, normalizeInstance } from './config/schema.js'
export const DEFAULT_TIMEOUT_MS = Number(process.env.MULTI_SIMADMIN_TIMEOUT_MS || 6000)
const HOP_BY_HOP_HEADERS = new Set(['connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization', 'te', 'trailer', 'transfer-encoding', 'upgrade', 'host', 'content-length'])
export function normalizeBaseUrl(rawUrl) {
const url = new URL(rawUrl)
url.hash = ''
url.search = ''
return url.toString().replace(/\/$/, '')
}
export function normalizeInstance(instance, index = 0) {
if (!instance || typeof instance !== 'object') throw new Error(`instances[${index}] must be an object`)
const id = String(instance.id || '').trim()
const name = String(instance.name || id || `SimAdmin ${index + 1}`).trim()
const url = String(instance.url || '').trim()
if (!id || !/^[a-zA-Z0-9_.-]+$/.test(id)) throw new Error(`instances[${index}].id is required and may only contain letters, numbers, _, -, .`)
if (!url) throw new Error(`instances[${index}].url is required`)
const password = instance.auth?.password ?? instance.password ?? ''
return {
id,
name,
url: normalizeBaseUrl(url),
description: String(instance.description || '').trim(),
tags: Array.isArray(instance.tags) ? instance.tags.map(String) : [],
auth: {
mode: password ? 'password' : String(instance.auth?.mode || 'none'),
password: password ? String(password) : '',
},
capabilities: Array.isArray(instance.capabilities) ? instance.capabilities.map(String) : [],
}
}
export async function loadConfig({ configPath, defaultConfigPath }) {
const resolvedPath = existsSync(configPath) ? configPath : defaultConfigPath
const text = await readFile(resolvedPath, 'utf8')
const raw = JSON.parse(text)
const instances = Array.isArray(raw.instances) ? raw.instances.map(normalizeInstance) : []
const ids = new Set()
for (const item of instances) {
if (ids.has(item.id)) throw new Error(`duplicate instance id: ${item.id}`)
ids.add(item.id)
}
return {
configPath: resolvedPath,
raw: { ...raw, instances },
server: {
host: raw.server?.host || process.env.HOST || '0.0.0.0',
port: Number(process.env.PORT || raw.server?.port || 8788),
},
instances,
}
}
function serializableInstance(instance) {
return {
id: instance.id,
name: instance.name,
url: instance.url,
description: instance.description || '',
tags: instance.tags || [],
auth: instance.auth?.password ? { mode: 'password', password: instance.auth.password } : { mode: 'none' },
capabilities: instance.capabilities || [],
}
}
async function persistConfig(config) {
const raw = {
...(config.raw || {}),
server: config.raw?.server || config.server,
instances: config.instances.map(serializableInstance),
}
await writeFile(config.configPath, `${JSON.stringify(raw, null, 2)}\n`)
config.raw = raw
}
export async function addInstanceToConfig(config, payload) {
const next = normalizeInstance(payload, config.instances.length)
if (config.instances.some(item => item.id === next.id)) throw new Error(`duplicate instance id: ${next.id}`)
config.instances.push(next)
await persistConfig(config)
return next
}
export async function updateInstanceInConfig(config, id, payload) {
const index = config.instances.findIndex(item => item.id === id)
if (index < 0) throw new Error(`instance not found: ${id}`)
const current = config.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 next = normalizeInstance(merged, index)
if (next.id !== id && config.instances.some(item => item.id === next.id)) throw new Error(`duplicate instance id: ${next.id}`)
config.instances[index] = next
await persistConfig(config)
return next
}
export async function deleteInstanceFromConfig(config, id) {
const index = config.instances.findIndex(item => item.id === id)
if (index < 0) throw new Error(`instance not found: ${id}`)
const [removed] = config.instances.splice(index, 1)
await persistConfig(config)
return removed
}
export function redactInstance(instance) {
return {
id: instance.id,
@@ -164,7 +55,7 @@ function pickNetwork(data) {
if (!data || typeof data !== 'object') return null
return {
operator: data.operator || data.operator_name || data.provider || null,
registration: data.registration || data.registration_state || data.status || null,
registration: data.registration || data.registration_state || data.registration_status || data.status || null,
accessTechnology: data.access_technology || data.accessTechnology || data.rat || data.mode || data.technology_preference || null,
signal: data.signal || data.signal_strength || data.rssi || null,
mcc: data.mcc || null,
@@ -264,6 +155,7 @@ class CookieJar {
}
export function createSimAdminClient(instance, { fetchImpl = fetch, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
instance = structuredClone(instance)
const jar = new CookieJar()
async function request(endpoint, options = {}) {
@@ -292,87 +184,34 @@ export function createSimAdminClient(instance, { fetchImpl = fetch, timeoutMs =
return { ok: response.ok, status: response.status, latencyMs: Date.now() - startedAt, data, text: data ? undefined : text.slice(0, 1000), headers: response.headers }
}
async function ensureAuthenticated() {
let ephemeralSecret = ''
async function ensureAuthenticated({ credential } = {}) {
const status = await fetchJson('/api/auth/status')
const auth = apiData(status.data) || {}
const protectionEnabled = auth.settings?.password_protection_enabled ?? auth.settings?.passwordProtectionEnabled
if (auth.authenticated || protectionEnabled === false || auth.configured === false) {
return { configured: auth.configured ?? null, authenticated: Boolean(auth.authenticated || protectionEnabled === false), loginAttempted: false, statusCode: status.status }
}
if (!instance.auth?.password) return { configured: auth.configured ?? null, authenticated: false, loginAttempted: false, statusCode: status.status, reason: 'password_required' }
const password = credential === undefined ? (ephemeralSecret || instance.auth?.password) : String(credential || '')
if (!password) {
if (status.status === 404 && instance.auth?.mode === 'none') return { configured: null, authenticated: null, loginAttempted: false, statusCode: status.status, reason: 'auth_status_unsupported' }
return { configured: auth.configured ?? null, authenticated: false, loginAttempted: false, statusCode: status.status, reason: 'password_required' }
}
if (credential !== undefined) ephemeralSecret = ''
const login = await request('/api/auth/login', {
method: 'POST',
headers: { 'content-type': 'application/json', accept: 'application/json' },
body: JSON.stringify({ password: instance.auth.password }),
body: JSON.stringify({ password }),
})
if (credential !== undefined && login.ok) ephemeralSecret = password
return { configured: auth.configured ?? null, authenticated: login.ok, loginAttempted: true, statusCode: login.status, reason: login.ok ? null : 'login_failed' }
}
return { instance, jar, request, fetchJson, ensureAuthenticated }
function clearEphemeralSecret() { ephemeralSecret = '' }
return { instance, jar, request, fetchJson, ensureAuthenticated, clearEphemeralSecret }
}
export function buildClients(instances, options = {}) {
return new Map(instances.map(instance => [instance.id, createSimAdminClient(instance, options)]))
}
export async function collectInstanceStatus(client) {
const result = {
...redactInstance(client.instance),
reachable: false,
authenticated: null,
latencyMs: null,
statusCode: null,
error: null,
authStatus: null,
summary: summarizeInstanceSnapshot({}),
raw: {},
checkedAt: new Date().toISOString(),
}
try {
const health = await client.fetchJson('/api/health')
result.reachable = health.ok
result.latencyMs = health.latencyMs
result.statusCode = health.status
result.raw.health = health.data || health.text || null
const auth = await client.ensureAuthenticated()
result.authenticated = auth.authenticated
result.authStatus = auth
const endpoints = {
device: '/api/device', sim: '/api/sim', network: '/api/network', smsStats: '/api/sms/stats', data: '/api/data', ota: '/api/ota/status', stats: '/api/stats', calls: '/api/calls'
}
await Promise.all(Object.entries(endpoints).map(async ([key, endpoint]) => {
try {
const response = await client.fetchJson(endpoint)
if (response.status === 401) result.authenticated = false
if (response.ok) result.raw[key] = response.data
} catch {}
}))
result.summary = summarizeInstanceSnapshot(result.raw)
} catch (error) {
result.error = error?.name === 'AbortError' ? 'timeout' : String(error?.message || error)
}
return result
}
export function safeProxyPath(rest = '') {
const clean = String(rest || '').replace(/^\/+/, '')
if (!clean || clean === '..' || clean.includes('..')) throw new Error('invalid proxy path')
return `/${clean}`
}
export async function proxyToInstance({ client, request, reply, rest }) {
const targetPath = safeProxyPath(rest)
const queryIndex = request.url.indexOf('?')
const query = queryIndex >= 0 ? request.url.slice(queryIndex) : ''
const headers = new Headers()
for (const [name, value] of Object.entries(request.headers || {})) {
if (!HOP_BY_HOP_HEADERS.has(name.toLowerCase()) && value !== undefined) headers.set(name, Array.isArray(value) ? value.join(', ') : String(value))
}
const body = ['GET', 'HEAD'].includes(request.method) ? undefined : request.body
const upstream = await client.request(`${targetPath}${query}`, { method: request.method, headers, body, redirect: 'manual' })
reply.code(upstream.status)
upstream.headers.forEach((value, name) => {
if (!HOP_BY_HOP_HEADERS.has(name.toLowerCase()) && name.toLowerCase() !== 'set-cookie') reply.header(name, value)
})
return reply.send(Buffer.from(await upstream.arrayBuffer()))
}
+22 -143
View File
@@ -1,151 +1,30 @@
import Fastify from 'fastify'
import fastifyStatic from '@fastify/static'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import {
addInstanceToConfig,
buildClients,
collectInstanceStatus,
createSimAdminClient,
deleteInstanceFromConfig,
loadConfig,
proxyToInstance,
redactInstance,
updateInstanceInConfig,
} from './core.js'
import { buildApp } from './app.js'
import { FileConfigStore } from './config/file-config-store.js'
import { ClientRegistry } from './clients/client-registry.js'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const ROOT = path.resolve(__dirname, '..')
const PUBLIC_DIR = path.join(ROOT, 'public')
const CONFIG_PATH = process.env.MULTI_SIMADMIN_CONFIG || path.join(ROOT, 'config.json')
const DEFAULT_CONFIG_PATH = path.join(ROOT, 'config.example.json')
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
const configPath = process.env.MULTI_SIMADMIN_CONFIG || path.join(root, 'config.json')
const examplePath = path.join(root, 'config.example.json')
const configStore = await FileConfigStore.open({ configPath, examplePath })
const clientRegistry = new ClientRegistry().reconcile(configStore.snapshot.instances)
const app = await buildApp({ configStore, clientRegistry, logger: true })
const config = await loadConfig({ configPath: CONFIG_PATH, defaultConfigPath: DEFAULT_CONFIG_PATH })
const clients = buildClients(config.instances)
const app = Fastify({ logger: true, bodyLimit: 60 * 1024 * 1024 })
await app.register(fastifyStatic, { root: PUBLIC_DIR, prefix: '/' })
function clientById(id, reply) {
const client = clients.get(id)
if (!client) reply.code(404).send({ error: 'instance not found' })
return client
let closing = false
async function shutdown(signal) {
if (closing) return
closing = true
app.log.info({ signal }, 'shutting down')
await app.close()
}
function sendConfigError(reply, error) {
const message = String(error?.message || error)
const status = /not found/.test(message) ? 404 : /duplicate|required|invalid|URL/.test(message) ? 400 : 500
return reply.code(status).send({ error: message })
}
function refreshClientFor(instance) {
clients.set(instance.id, createSimAdminClient(instance))
}
app.get('/api/config', async () => ({
configPath: config.configPath,
instances: config.instances.map(redactInstance),
features: [
'passwordless-status',
'password-session-login',
'read-write-api-proxy',
'device-sim-network-sms-data-ota-summary',
'iframe-fallback',
],
}))
app.get('/api/instances', async () => ({ instances: config.instances.map(redactInstance) }))
app.post('/api/instances', async (request, reply) => {
try {
const instance = await addInstanceToConfig(config, request.body || {})
refreshClientFor(instance)
return { instance: redactInstance(instance), configPath: config.configPath }
} catch (error) {
return sendConfigError(reply, error)
}
})
app.put('/api/instances/:id', async (request, reply) => {
try {
const oldId = request.params.id
const instance = await updateInstanceInConfig(config, oldId, request.body || {})
if (instance.id !== oldId) clients.delete(oldId)
refreshClientFor(instance)
return { instance: redactInstance(instance), configPath: config.configPath }
} catch (error) {
return sendConfigError(reply, error)
}
})
app.delete('/api/instances/:id', async (request, reply) => {
try {
const removed = await deleteInstanceFromConfig(config, request.params.id)
clients.delete(removed.id)
return { removed: redactInstance(removed), configPath: config.configPath }
} catch (error) {
return sendConfigError(reply, error)
}
})
app.get('/api/status', async () => ({
instances: await Promise.all([...clients.values()].map(collectInstanceStatus)),
}))
app.get('/api/status/:id', async (request, reply) => {
const client = clientById(request.params.id, reply)
if (!client) return
return collectInstanceStatus(client)
})
app.post('/api/instances/:id/login', async (request, reply) => {
const client = clientById(request.params.id, reply)
if (!client) return
const password = request.body?.password || client.instance.auth?.password || ''
if (password) client.instance.auth.password = String(password)
const result = await client.ensureAuthenticated()
return { ...result, hasSavedPassword: Boolean(client.instance.auth?.password) }
})
app.post('/api/instances/:id/logout', async (request, reply) => {
const client = clientById(request.params.id, reply)
if (!client) return
try { await client.fetchJson('/api/auth/logout', { method: 'POST' }) } catch {}
client.jar.clear()
return { ok: true }
})
app.all('/api/proxy/:id/*', async (request, reply) => {
const client = clientById(request.params.id, reply)
if (!client) return
return proxyToInstance({ client, request, reply, rest: request.params['*'] })
})
app.get('/api/catalog', async () => ({
groups: [
{ id: 'overview', name: '总览', endpoints: ['/api/device', '/api/sim', '/api/network', '/api/stats', '/api/connectivity'] },
{ id: 'sms', name: '短信', endpoints: ['/api/sms/stats', '/api/sms/list', '/api/sms/send', '/api/sms/conversation'] },
{ id: 'network', name: '网络/小区', endpoints: ['/api/data', '/api/roaming', '/api/airplane-mode', '/api/radio-mode', '/api/band-lock', '/api/cell-lock', '/api/apn', '/api/cells'] },
{ id: 'device-network', name: '设备网络', endpoints: ['/api/device-network/ddns/status', '/api/device-network/ddns/config', '/api/device-network/wlan/status', '/api/device-network/wlan/profiles'] },
{ id: 'calls', name: '电话', endpoints: ['/api/calls', '/api/call/history', '/api/ims/status', '/api/voicemail/status'] },
{ id: 'esim', name: 'eSIM', endpoints: ['/api/work-mode', '/api/esim/config', '/api/esim/lpac/status', '/api/esim/euicc', '/api/esim/profiles'] },
{ id: 'notify-auto-ota', name: '通知/自动化/升级', endpoints: ['/api/notifications/config', '/api/notifications/logs', '/api/automation/config', '/api/automation/logs', '/api/ota/status'] },
],
}))
app.get('/api/reload-note', async () => ({ message: 'Edit config.json and restart this process to apply changes.' }))
app.setNotFoundHandler(async (request, reply) => {
if (request.url.startsWith('/api/')) return reply.code(404).send({ error: 'not found' })
return reply.sendFile('index.html')
})
for (const signal of ['SIGINT', 'SIGTERM']) process.once(signal, () => shutdown(signal).catch(error => { app.log.error(error); process.exitCode = 1 }))
try {
await app.listen({ host: config.server.host, port: config.server.port })
app.log.info(`Multi SimAdmin listening on http://${config.server.host}:${config.server.port}`)
app.log.info(`Loaded ${config.instances.length} instance(s) from ${config.configPath}`)
} catch (err) {
app.log.error(err)
process.exit(1)
await app.listen({ ...configStore.snapshot.server })
app.log.info(`Loaded ${configStore.snapshot.instances.length} instance(s) from ${configPath}`)
} catch (error) {
app.log.error(error)
await app.close().catch(() => {})
process.exitCode = 1
}
+14
View File
@@ -0,0 +1,14 @@
import { proxyToInstance } from './service.js'
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
}
})
}
+58
View File
@@ -0,0 +1,58 @@
const BLOCKED_REQUEST_HEADERS = new Set([
'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization', 'te', 'trailer',
'transfer-encoding', 'upgrade', 'host', 'content-length', 'cookie', 'authorization',
'forwarded', 'x-forwarded-for', 'x-forwarded-host', 'x-forwarded-proto', 'x-forwarded-port', 'accept-encoding',
])
const BLOCKED_RESPONSE_HEADERS = new Set([...BLOCKED_REQUEST_HEADERS, 'set-cookie', 'content-encoding'])
export function safeProxyPath(rest = '') {
const raw = String(rest || '')
if (!raw || raw.startsWith('/') || raw.includes('\\')) throw new Error('invalid proxy path')
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')
}
for (let depth = 0; depth < 16; depth += 1) {
assertSafe(value)
let decoded
try { decoded = decodeURIComponent(value) } catch { throw new Error('invalid proxy path') }
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')
}
export function proxyHeaders(input = {}) {
const headers = new Headers()
for (const [name, value] of Object.entries(input)) {
if (!BLOCKED_REQUEST_HEADERS.has(name.toLowerCase()) && value !== undefined) headers.set(name, Array.isArray(value) ? value.join(', ') : String(value))
}
return headers
}
export async function proxyToInstance({ client, request, reply, rest }) {
const targetPath = safeProxyPath(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')
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 === '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 }
}
const upstream = await client.request(target.toString(), { method: request.method, headers, body, redirect: 'manual' })
reply.code(upstream.status)
upstream.headers.forEach((value, name) => {
if (!BLOCKED_RESPONSE_HEADERS.has(name.toLowerCase())) reply.header(name, value)
})
return reply.send(Buffer.from(await upstream.arrayBuffer()))
}
+9
View File
@@ -0,0 +1,9 @@
import { collectInstanceStatus } from './service.js'
export function registerStatusRoutes(app, { registry, collect = collectInstanceStatus, findClient }) {
app.get('/api/status', async () => ({ instances: await Promise.all([...registry.values()].map(collect)) }))
app.get('/api/status/:id', async (request, reply) => {
const client = findClient(request.params.id, reply)
if (client) return collect(client)
})
}
+30
View File
@@ -0,0 +1,30 @@
import { redactInstance, summarizeInstanceSnapshot } from '../core.js'
export async function collectInstanceStatus(client) {
const result = {
...redactInstance(client.instance), reachable: false, authenticated: null, latencyMs: null,
statusCode: null, error: null, authStatus: null, summary: summarizeInstanceSnapshot({}), raw: {}, checkedAt: new Date().toISOString(),
}
try {
const health = await client.fetchJson('/api/health')
result.latencyMs = health.latencyMs
result.statusCode = health.status
result.raw.health = health.data || health.text || null
const auth = await client.ensureAuthenticated()
result.authenticated = auth.authenticated
result.authStatus = auth
const endpoints = { device: '/api/device', sim: '/api/sim', network: '/api/network', smsStats: '/api/sms/stats', data: '/api/data', ota: '/api/ota/status', stats: '/api/stats', calls: '/api/calls' }
await Promise.all(Object.entries(endpoints).map(async ([key, endpoint]) => {
try {
const response = await client.fetchJson(endpoint)
if (response.ok) { result.raw[key] = response.data; result.reachable = true }
if (response.status === 401) result.authenticated = false
} catch {}
}))
result.reachable ||= health.ok
result.summary = summarizeInstanceSnapshot(result.raw)
} catch (error) {
result.error = error?.name === 'AbortError' ? 'timeout' : String(error?.message || error)
}
return result
}
+119
View File
@@ -0,0 +1,119 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { mkdtemp, readFile, writeFile, access } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { FileConfigStore } from '../server/config/file-config-store.js'
import { ClientRegistry } from '../server/clients/client-registry.js'
import { buildApp } from '../server/app.js'
import { safeProxyPath } from '../server/proxy/service.js'
import { RequestCoordinator } from '../public/state/request-coordinator.js'
import { normalizeTargetUrl } from '../public/infrastructure/api-client.js'
async function fixtureConfig(instances = []) {
const dir = await mkdtemp(path.join(tmpdir(), 'multi-simadmin-phase1-'))
const configPath = path.join(dir, 'config.json')
const examplePath = path.join(dir, 'config.example.json')
await writeFile(examplePath, JSON.stringify({ server: { port: 8788 }, instances }))
return { dir, configPath, examplePath }
}
function fakeClient(instance, calls = []) {
return {
instance,
jar: { clear() {} },
ensureAuthenticated: async () => ({ authenticated: true }),
fetchJson: async () => ({ ok: true, status: 200, latencyMs: 1, data: {} }),
request: async (url, options) => {
calls.push({ url, options })
return new Response('{}', { headers: { 'content-type': 'application/json', 'set-cookie': 'secret=x' } })
},
close() {},
}
}
test('FileConfigStore uses example only as template and commits immutable snapshots atomically', async () => {
const { configPath, examplePath } = await fixtureConfig()
const before = await readFile(examplePath, 'utf8')
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' })
assert.equal(JSON.parse(await readFile(configPath, 'utf8')).instances[0].id, 'one')
assert.equal(await readFile(examplePath, 'utf8'), before)
})
test('failed persistence does not publish memory and transactions serialize', async () => {
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/)
assert.equal(store.snapshot.instances.length, 0)
assert.equal(writes, 1)
await assert.rejects(access(configPath))
})
test('ClientRegistry reconciles only committed config and handles rename/delete', () => {
const closed = []
const registry = new ClientRegistry({ createClient: i => ({ instance: i, close: () => closed.push(i.id) }) })
registry.reconcile([{ id: 'old', url: 'http://a' }])
registry.reconcile([{ id: 'new', url: 'http://a' }])
assert.equal(registry.get('old'), undefined)
assert.equal(registry.get('new').instance.id, 'new')
assert.deepEqual(closed, ['old'])
registry.close()
assert.deepEqual(closed, ['old', 'new'])
})
test('proxy path and target URL contracts reject origin escape vectors', () => {
assert.equal(safeProxyPath('api/device'), '/api/device')
for (const value of ['../evil', 'api\\evil', '%2f%2fevil', '%5cevil', 'api/%2e%2e/x']) {
assert.throws(() => safeProxyPath(value), /invalid proxy path/)
}
assert.equal(normalizeTargetUrl('https://example.test/'), 'https://example.test')
assert.throws(() => normalizeTargetUrl('file:///tmp/x'), /http or https/)
})
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 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 })
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' } })
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')
assert.equal(registry.get('two'), undefined)
assert.ok(registry.get('renamed'))
const removed = await app.inject({ method: 'DELETE', url: '/api/instances/renamed' })
assert.equal(removed.json().removed.id, 'renamed')
assert.equal((await app.inject({ method: 'POST', url: '/api/instances/one/login', payload: {} })).statusCode, 200)
assert.deepEqual((await app.inject({ method: 'POST', url: '/api/instances/one/logout' })).json(), { ok: true })
assert.equal((await app.inject('/api/status/one')).statusCode, 200)
const proxy = await app.inject({ method: 'POST', url: '/api/proxy/one/api/test', headers: { cookie: 'browser=x', authorization: 'Bearer x', forwarded: 'bad', 'content-type': 'application/json' }, payload: { hello: 'world' } })
assert.equal(proxy.statusCode, 200)
assert.equal(proxy.headers['set-cookie'], undefined)
assert.deepEqual(proxy.json(), {})
assert.equal(calls[0].options.headers.has('cookie'), false)
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.deepEqual((await app.inject('/api/missing')).json(), { error: 'not found' })
})
test('request coordinator is latest-wins per owner and binds result to request owner', async () => {
const coordinator = new RequestCoordinator()
let finishFirst
const first = coordinator.run('status', 'device-a', () => new Promise(resolve => { finishFirst = resolve }))
const second = coordinator.run('status', 'device-b', async () => 'new')
assert.deepEqual(await second, { accepted: true, ownerId: 'device-b', value: 'new' })
finishFirst('old')
assert.deepEqual(await first, { accepted: false, ownerId: 'device-a', value: 'old' })
})
+165
View File
@@ -0,0 +1,165 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { mkdtemp, readFile, writeFile, readdir, stat } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { FileConfigStore, atomicWriteJson } from '../server/config/file-config-store.js'
import { normalizeConfig } from '../server/config/schema.js'
import { ClientRegistry } from '../server/clients/client-registry.js'
import { safeProxyPath, proxyHeaders } from '../server/proxy/service.js'
import { createSimAdminClient } from '../server/core.js'
import { collectInstanceStatus } from '../server/status/service.js'
import { buildApp } from '../server/app.js'
import { RequestCoordinator } from '../public/state/request-coordinator.js'
import { createApiClient } from '../public/infrastructure/api-client.js'
import { createViewRouter } from '../public/router/view-router.js'
const projectRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..')
async function files(instances = []) {
const dir = await mkdtemp(path.join(tmpdir(), 'msa-blockers-'))
const configPath = path.join(dir, 'config.json')
const examplePath = path.join(dir, 'example.json')
await writeFile(examplePath, JSON.stringify({ server: { port: 8788 }, instances }))
return { dir, configPath, examplePath }
}
function client(instance, request) {
return { instance, request, jar: { clear() {} }, fetchJson: async () => ({ ok: true, status: 200, latencyMs: 1, data: {} }), ensureAuthenticated: async () => ({ authenticated: true }) }
}
test('proxy path rejects normalization and repeated-decoding structure changes', () => {
assert.equal(safeProxyPath('api/a%20b'), '/api/a%20b')
for (const input of ['%252e%252e/x', 'api/%252f/x', 'api/%255c/x', 'api/a%2525252fetc', 'api/%2525252e%2525252e/x', 'api/%2525253fx', 'api\\x', '//evil/x', '%2f%2fevil/x', 'api/%3fadmin', 'api/%253fadmin', 'api/%23x', 'api/%2523x', 'api/%2e%2e/x']) {
assert.throws(() => safeProxyPath(input), /invalid proxy path/, input)
}
assert.equal(proxyHeaders({ 'accept-encoding': 'gzip', accept: 'json' }).has('accept-encoding'), false)
})
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' } })
await Promise.all([
store.add({ id: 'a', url: 'http://a.test' }),
store.add({ id: 'b', url: 'http://b.test' }),
])
assert.equal(store.snapshot.server.host, 'env-host')
assert.equal(store.snapshot.server.port, 4321)
assert.deepEqual(JSON.parse(await readFile(configPath, 'utf8')).instances.map(x => x.id), ['a', 'b'])
})
test('atomicWriteJson replaces content, leaves no temp and creates POSIX 0600', async () => {
const { dir, configPath } = await files()
await writeFile(configPath, 'old', { mode: 0o644 })
await atomicWriteJson(configPath, { ok: true })
assert.deepEqual(JSON.parse(await readFile(configPath, 'utf8')), { ok: true })
assert.deepEqual((await readdir(dir)).filter(x => x.endsWith('.tmp')), [])
if (process.platform !== 'win32') assert.equal((await stat(configPath)).mode & 0o777, 0o600)
})
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/)
})
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: '' } }
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.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 store = await FileConfigStore.open({ configPath, examplePath })
let credential
let cleared = false
const fake = { instance: structuredClone(store.snapshot.instances[0]), jar: { clear() {} }, ensureAuthenticated: async options => { credential = options?.credential; return { authenticated: true } }, clearEphemeralSecret: () => { cleared = true }, fetchJson: async () => ({ ok: true }) }
const registry = new ClientRegistry({ createClient: () => fake }).reconcile(store.snapshot.instances)
const app = await buildApp({ configStore: store, clientRegistry: registry, staticFiles: false }); t.after(() => app.close())
const login = await app.inject({ method: 'POST', url: '/api/instances/one/login', payload: { password: 'temporary' } })
assert.equal(credential, 'temporary')
assert.equal(fake.instance.auth.password, '')
assert.equal(login.json().hasSavedPassword, false)
await app.inject({ method: 'POST', url: '/api/instances/one/logout' })
assert.equal(cleared, true)
})
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 bodies = []
const c = createSimAdminClient(instance, { fetchImpl: async (url, options = {}) => {
const pathname = new URL(url).pathname
if (pathname === '/api/auth/status') return new Response(JSON.stringify({ configured: true, authenticated: false, settings: { password_protection_enabled: true } }), { status: 200 })
bodies.push(options.body)
return new Response('{}', { status: 401 })
} })
const failed = await c.ensureAuthenticated({ credential: 'wrong' })
assert.equal(failed.authenticated, false)
const later = await c.ensureAuthenticated()
assert.equal(later.loginAttempted, false)
assert.equal(later.reason, 'password_required')
assert.deepEqual(bodies, ['{"password":"wrong"}'])
})
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 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 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')
await app.inject({ method: 'POST', url: '/api/proxy/one/api/x', headers: { 'content-type': 'text/plain' }, payload: 'hello' }); assert.equal(calls[1].options.body, 'hello')
await app.inject({ method: 'POST', url: '/api/proxy/one/api/x', headers: { 'content-type': 'application/octet-stream' }, payload: Buffer.from('bin') }); assert.ok(Buffer.isBuffer(calls[2].options.body))
const empty = await app.inject({ method: 'POST', url: '/api/proxy/one/api/logout' }); assert.equal(empty.statusCode, 503); assert.equal(calls[3].options.body, undefined)
const unsupported = await app.inject({ method: 'POST', url: '/api/proxy/one/api/x', headers: { 'content-type': 'multipart/form-data; boundary=x' }, payload: '--x--' }); assert.equal(unsupported.statusCode, 415); assert.deepEqual(unsupported.json(), { error: 'unsupported proxy content type' })
})
test('committed config survives reconcile failure and readiness degrades then retry recovers', async t => {
const { configPath, examplePath } = await files()
const store = await FileConfigStore.open({ configPath, examplePath })
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' } })
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))
assert.equal((await app.inject('/api/ready')).json().ready, true); assert.ok(registry.get('x'))
})
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 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')
})
test('bootstrap copies immutable listen options before handing them to Fastify', async () => {
const source = await readFile(path.join(projectRoot, 'server/index.js'), 'utf8')
assert.match(source, /app\.listen\(\{\s*\.\.\.configStore\.snapshot\.server\s*\}\)/)
assert.doesNotMatch(source, /app\.listen\(configStore\.snapshot\.server\)/)
})
test('coordinator structures rejection and stale errors; api client/router modules are usable', async () => {
const coordinator = new RequestCoordinator(); let rejectOld
const old = coordinator.run('login', 'a', () => new Promise((_, reject) => { rejectOld = reject }))
assert.deepEqual(await coordinator.run('login', 'b', async () => 'ok'), { accepted: true, ownerId: 'b', value: 'ok' })
rejectOld(new Error('old')); const stale = await old
assert.equal(stale.accepted, false); assert.equal(stale.ownerId, 'a'); assert.match(stale.error.message, /old/)
const api = createApiClient({ fetchImpl: async () => new Response('{"ok":true}') }); assert.deepEqual(await api.config(), { ok: true })
const classes = () => ({ active: false, toggle(_name, value) { this.active = value } }); const control = { dataset: { view: 'home' }, classList: classes() }; const panel = { id: 'homeView', classList: classes() }
createViewRouter({ controls: () => [control], panels: () => [panel], storage: null })('home'); assert.equal(control.classList.active, true); assert.equal(panel.classList.active, true)
})
+2 -29
View File
@@ -1,10 +1,7 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { mkdtemp, readFile, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { createSimAdminClient, normalizeInstance, summarizeInstanceSnapshot, redactInstance, loadConfig, addInstanceToConfig, updateInstanceInConfig, deleteInstanceFromConfig } from '../server/core.js'
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)
@@ -66,6 +63,7 @@ test('snapshot summary exposes device-monitoring fields from stats, sms, data an
assert.equal(snapshot.sim.present, true)
assert.equal(snapshot.sim.phoneNumber, '13800138000')
assert.equal(snapshot.network.operator, '中国移动')
assert.equal(snapshot.network.registration, 'registered')
assert.equal(snapshot.network.signal, -73)
assert.equal(snapshot.network.accessTechnology, 'LTE')
assert.equal(snapshot.sms.total, 12)
@@ -83,28 +81,3 @@ test('snapshot summary exposes device-monitoring fields from stats, sms, data an
assert.equal(snapshot.system.info.os, 'OpenWrt')
assert.equal(snapshot.system.uptime, 3600)
})
test('config store can add update and delete instances while persisting json', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'multi-simadmin-'))
const configPath = path.join(dir, 'config.json')
await writeFile(configPath, JSON.stringify({ server: { host: '127.0.0.1', port: 9999 }, instances: [] }, null, 2))
const config = await loadConfig({ configPath, defaultConfigPath: configPath })
const added = await addInstanceToConfig(config, { id: 'home-cpe', name: '家里 CPE', url: 'http://192.168.1.1/', auth: { password: 'pw' }, tags: ['home'] })
assert.equal(added.id, 'home-cpe')
assert.equal(added.url, 'http://192.168.1.1')
assert.equal(config.instances.length, 1)
const updated = await updateInstanceInConfig(config, 'home-cpe', { name: '客厅 CPE', url: 'http://192.168.1.2', auth: { mode: 'none' }, description: 'main router' })
assert.equal(updated.name, '客厅 CPE')
assert.equal(updated.auth.password, '')
const persisted = JSON.parse(await readFile(configPath, 'utf8'))
assert.equal(persisted.instances[0].name, '客厅 CPE')
assert.equal(persisted.instances[0].url, 'http://192.168.1.2')
const removed = await deleteInstanceFromConfig(config, 'home-cpe')
assert.equal(removed.id, 'home-cpe')
assert.equal(config.instances.length, 0)
assert.deepEqual(JSON.parse(await readFile(configPath, 'utf8')).instances, [])
})
+9 -2
View File
@@ -24,7 +24,8 @@ test('home screen is a simadmin card wall and device detail owns interactions',
assert.match(app, /function openDeviceDetail/)
assert.match(app, /function renderDeviceDetails/)
assert.match(app, /data-open-detail/)
assert.match(app, /\['home', 'detail', 'rawpage', 'api'\]/)
const router = await text('public/router/view-router.js')
assert.match(router, /\['home', 'detail', 'rawpage', 'api'\]/)
assert.match(app, /温度/)
assert.match(app, /流量|速率/)
assert.match(app, /内存|磁盘/)
@@ -37,7 +38,13 @@ test('home screen is a simadmin card wall and device detail owns interactions',
})
test('home device cards show complete content without ellipsis truncation', async () => {
const css = await text('public/styles.css')
const entry = await text('public/styles.css')
const css = await text('public/styles/components.css')
assert.match(entry, /styles\/tokens\.css/)
assert.match(entry, /styles\/base\.css/)
assert.match(entry, /styles\/components\.css/)
assert.match(entry, /styles\/responsive\.css/)
assert.match(css, /\.sim-device-card/)
assert.match(css, /\.home-device-grid/)