[verified] refactor: establish service and frontend boundaries
This commit is contained in:
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"server": {
|
||||
"host": "0.0.0.0",
|
||||
"host": "127.0.0.1",
|
||||
"port": 8788
|
||||
},
|
||||
"instances": [
|
||||
|
||||
Generated
+24
-173
@@ -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
@@ -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
@@ -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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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) => {
|
||||
|
||||
@@ -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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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]}`
|
||||
}
|
||||
@@ -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' }),
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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');
|
||||
|
||||
@@ -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)}}
|
||||
@@ -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}
|
||||
@@ -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}}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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' }
|
||||
}
|
||||
@@ -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]
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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()))
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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' })
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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, [])
|
||||
})
|
||||
|
||||
@@ -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/)
|
||||
|
||||
Reference in New Issue
Block a user