From 143ab369c9262a5e75c3555fe29658cefc201c06 Mon Sep 17 00:00:00 2001 From: chick Date: Tue, 7 Jul 2026 21:26:43 +0800 Subject: [PATCH] Initial Multi SimAdmin dashboard --- .gitignore | 8 + README.md | 75 +++ config.example.json | 27 + package-lock.json | 1047 ++++++++++++++++++++++++++++++++++++++ package.json | 16 + public/app.js | 388 ++++++++++++++ public/index.html | 141 +++++ public/styles.css | 13 + server/core.js | 293 +++++++++++ server/index.js | 105 ++++ test/server-core.test.js | 56 ++ 11 files changed, 2169 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 config.example.json create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 public/app.js create mode 100644 public/index.html create mode 100644 public/styles.css create mode 100644 server/core.js create mode 100644 server/index.js create mode 100644 test/server-core.test.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..716065e --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +node_modules/ +config.json +.env +.env.* +.DS_Store +npm-debug.log* +coverage/ +.hermes/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..636803c --- /dev/null +++ b/README.md @@ -0,0 +1,75 @@ +# Multi SimAdmin + +一个用于聚合管理多个 [SimAdmin](https://github.com/3899/SimAdmin) 实例的轻量本地运维面板。 + +## 功能 + +- 只需要在 `config.json` 配置多个 SimAdmin 地址。 +- 深色专业 Dashboard:实例舰队列表、状态筛选、搜索、当前设备摘要。 +- 支持无密码实例状态读取;有密码实例可通过本地服务代登录并保持会话。 +- 统一代理 SimAdmin API,内置 API 工作台,便于读取/调试设备、SIM、网络、短信、eSIM、OTA 等接口。 +- 保留原始 SimAdmin 页面 iframe 嵌入;若目标站禁止 iframe,可一键在原站打开。 +- 敏感配置留在本地 `config.json`,仓库只提交 `config.example.json`。 + +## 快速开始 + +```bash +cp config.example.json config.json +npm install +npm start +``` + +默认访问: + +## 配置 + +编辑 `config.json`: + +```json +{ + "server": { "host": "0.0.0.0", "port": 8788 }, + "instances": [ + { + "id": "simadmin-open", + "name": "无密码 SimAdmin", + "url": "http://192.168.68.1:3000", + "description": "密码保护关闭或尚未配置密码的实例", + "auth": { "mode": "none" }, + "tags": ["open"] + }, + { + "id": "simadmin-password", + "name": "有密码 SimAdmin", + "url": "http://192.168.68.2:3000", + "description": "服务端代登录并保存会话 Cookie;密码只保存在本地 config.json", + "auth": { + "mode": "password", + "password": "CHANGE_ME" + }, + "tags": ["password"] + } + ] +} +``` + +字段说明: + +- `id`:唯一标识,只能包含字母、数字、下划线、短横线、点号。 +- `name`:页面显示名称。 +- `url`:SimAdmin 原始访问地址。 +- `description`:可选描述。 +- `auth.mode`:`none` 表示无密码;`password` 表示由本地聚合服务代登录。 +- `auth.password`:可选。本地保存后可自动刷新会话;不要提交真实密码。 + +## 常用命令 + +```bash +npm test +npm start +``` + +## 注意 + +- 当前项目是本地管理面板,不修改 SimAdmin 本体。 +- `config.json` 可能包含真实设备地址和密码,已在 `.gitignore` 中排除。 +- 部分写操作接口(短信发送、网络模式、频段/小区锁定等)会改变设备状态,执行前请确认目标实例。 diff --git a/config.example.json b/config.example.json new file mode 100644 index 0000000..bdc0a14 --- /dev/null +++ b/config.example.json @@ -0,0 +1,27 @@ +{ + "server": { + "host": "0.0.0.0", + "port": 8788 + }, + "instances": [ + { + "id": "simadmin-open", + "name": "无密码 SimAdmin", + "url": "http://192.168.68.1:3000", + "description": "密码保护关闭或尚未配置密码的实例", + "auth": { "mode": "none" }, + "tags": ["open"] + }, + { + "id": "simadmin-password", + "name": "有密码 SimAdmin", + "url": "http://192.168.68.2:3000", + "description": "服务端代登录并保存会话 Cookie;密码只保存在本地 config.json", + "auth": { + "mode": "password", + "password": "CHANGE_ME" + }, + "tags": ["password"] + } + ] +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..eeeb9f5 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1047 @@ +{ + "name": "multi-simadmin", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "multi-simadmin", + "version": "0.1.0", + "dependencies": { + "@fastify/static": "^8.3.0", + "fastify": "^5.6.2" + }, + "devDependencies": {} + }, + "node_modules/@fastify/accept-negotiator": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@fastify/accept-negotiator/-/accept-negotiator-2.0.1.tgz", + "integrity": "sha512-/c/TW2bO/v9JeEgoD/g1G5GxGeCF1Hafdf79WPmUlgYiBXummY0oX3VVq4yFkKKVBKDNlaDUYoab7g38RpPqCQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/ajv-compiler": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.5.tgz", + "integrity": "sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^3.0.0" + } + }, + "node_modules/@fastify/error": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz", + "integrity": "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/fast-json-stringify-compiler": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-5.1.0.tgz", + "integrity": "sha512-PxcYtKLbQ8Z+yApiqjK8FwxIwvEj38k2OiLc17u8dkJSlmfi2wHHPaSnaoqBPQqtvF8YVsDgDpP2snDCfFrpfw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fast-json-stringify": "^7.0.0" + } + }, + "node_modules/@fastify/forwarded": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@fastify/forwarded/-/forwarded-3.0.1.tgz", + "integrity": "sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/merge-json-schemas": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz", + "integrity": "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@fastify/proxy-addr": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz", + "integrity": "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/forwarded": "^3.0.0", + "ipaddr.js": "^2.1.0" + } + }, + "node_modules/@fastify/send": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@fastify/send/-/send-4.1.0.tgz", + "integrity": "sha512-TMYeQLCBSy2TOFmV95hQWkiTYgC/SEx7vMdV+wnZVX4tt8VBLKzmH8vV9OzJehV0+XBfg+WxPMt5wp+JBUKsVw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@lukeed/ms": "^2.0.2", + "escape-html": "~1.0.3", + "fast-decode-uri-component": "^1.0.1", + "http-errors": "^2.0.0", + "mime": "^3" + } + }, + "node_modules/@fastify/static": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/@fastify/static/-/static-8.3.0.tgz", + "integrity": "sha512-yKxviR5PH1OKNnisIzZKmgZSus0r2OZb8qCSbqmw34aolT4g3UlzYfeBRym+HJ1J471CR8e2ldNub4PubD1coA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/accept-negotiator": "^2.0.0", + "@fastify/send": "^4.0.0", + "content-disposition": "^0.5.4", + "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" + } + }, + "node_modules/@lukeed/ms": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz", + "integrity": "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, + "node_modules/abstract-logging": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", + "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==", + "license": "MIT" + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/avvio": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/avvio/-/avvio-9.2.0.tgz", + "integrity": "sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/error": "^4.0.0", + "fastq": "^1.17.1" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "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==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "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", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/fast-decode-uri-component": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", + "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-json-stringify": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-7.0.0.tgz", + "integrity": "sha512-YV53BAbR3Qwq37wfD1oZ97YJ0nYj6CwfzKXQ38ock9XxI2EnLOdl5psKms6Evook6ACckytZJOaKY0ThmIi1uw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/merge-json-schemas": "^0.2.0", + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^3.0.0", + "json-schema-ref-resolver": "^3.0.0", + "rfdc": "^1.2.0" + } + }, + "node_modules/fast-querystring": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", + "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==", + "license": "MIT", + "dependencies": { + "fast-decode-uri-component": "^1.0.1" + } + }, + "node_modules/fast-uri": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz", + "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastify": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.10.0.tgz", + "integrity": "sha512-A9L0ziuWGQHgEEVgF3davQ9vbD93IuX+lo2IsxapQmu5b/Y/ynn9m9K5JHt9dvyJXOFc5iN0Zk5GHEOqnzhWjg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/ajv-compiler": "^4.0.5", + "@fastify/error": "^4.0.0", + "@fastify/fast-json-stringify-compiler": "^5.0.0", + "@fastify/proxy-addr": "^5.0.0", + "abstract-logging": "^2.0.1", + "avvio": "^9.0.0", + "fast-json-stringify": "^7.0.0", + "find-my-way": "^9.6.0", + "light-my-request": "^6.0.0", + "pino": "^9.14.0 || ^10.1.0", + "process-warning": "^5.0.0", + "rfdc": "^1.3.1", + "secure-json-parse": "^4.0.0", + "semver": "^7.6.0", + "toad-cache": "^3.7.0" + } + }, + "node_modules/fastify-plugin": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-5.1.0.tgz", + "integrity": "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/find-my-way": { + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.6.0.tgz", + "integrity": "sha512-Zf4Xve4RymLl7NgaavNebZ01joJ8MfVerOG43wy7SHLO+r+K0C6d/SE0BiR7AV5V1VOCFlOP7ecdo+I4qmiHrQ==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-querystring": "^1.0.0", + "safe-regex2": "^5.0.0" + }, + "engines": { + "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", + "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" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.4.0.tgz", + "integrity": "sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==", + "license": "MIT", + "engines": { + "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", + "integrity": "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/light-my-request": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz", + "integrity": "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause", + "dependencies": { + "cookie": "^1.0.1", + "process-warning": "^4.0.0", + "set-cookie-parser": "^2.6.0" + } + }, + "node_modules/light-my-request/node_modules/process-warning": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.1.tgz", + "integrity": "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "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==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "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", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/pino": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", + "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ret": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz", + "integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "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", + "integrity": "sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ret": "~0.5.0" + }, + "bin": { + "safe-regex2": "bin/safe-regex2.js" + } + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/secure-json-parse": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", + "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "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", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/thread-stream": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz", + "integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==", + "license": "MIT", + "dependencies": { + "real-require": "^1.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/thread-stream/node_modules/real-require": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz", + "integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==", + "license": "MIT" + }, + "node_modules/toad-cache": { + "version": "3.7.4", + "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.4.tgz", + "integrity": "sha512-m1TdR/rvT7kgGJZhspNtXdsdYk0fddFpJJFlG5s+UkPFo6lkLoZ3YLOaovPYjq1R75NP5JfeTlSHaOsE09peCg==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "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" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..bcf451e --- /dev/null +++ b/package.json @@ -0,0 +1,16 @@ +{ + "name": "multi-simadmin", + "version": "0.1.0", + "description": "Config-driven dashboard to manage multiple SimAdmin instances from one page", + "type": "module", + "scripts": { + "start": "node server/index.js", + "dev": "node --watch server/index.js", + "test": "node --test" + }, + "dependencies": { + "@fastify/static": "^8.3.0", + "fastify": "^5.6.2" + }, + "devDependencies": {} +} diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..13fd4e0 --- /dev/null +++ b/public/app.js @@ -0,0 +1,388 @@ +let instances = [] +let statuses = new Map() +let catalog = [] +let activeId = null +let activeFilter = 'all' +let activeModule = 'all' +let configPath = 'config.json' +let autoRefreshTimer = null + +const $ = (id) => document.getElementById(id) +const $$ = (selector, root = document) => [...root.querySelectorAll(selector)] + +const modules = [ + { id: 'overview', title: '设备总览', desc: '设备、SIM、网络、连接状态与流量统计。适合日常巡检和资产核对。', tone: 'ok', endpoints: ['/api/device', '/api/sim', '/api/network', '/api/stats', '/api/connectivity'] }, + { id: 'sms', title: '短信中心', desc: '短信统计、列表、会话读取与短信发送。发送类接口请先在 API 工作台确认请求体。', tone: 'warn', endpoints: ['/api/sms/stats', '/api/sms/list', '/api/sms/conversation', '/api/sms/send'] }, + { id: 'network', title: '蜂窝网络', desc: '数据开关、漫游、飞行模式、无线制式、频段/小区锁定与 APN。', tone: 'danger', endpoints: ['/api/data', '/api/roaming', '/api/airplane-mode', '/api/radio-mode', '/api/band-lock', '/api/cell-lock', '/api/apn', '/api/cells'] }, + { id: 'device-network', title: '设备网络', desc: 'WLAN、DDNS、连接地址与局域网访问能力。', tone: 'neutral', endpoints: ['/api/device-network/wlan/status', '/api/device-network/wlan/profiles', '/api/device-network/ddns/status', '/api/device-network/ddns/config', '/api/network/connection-addresses'] }, + { id: 'calls', title: '电话能力', desc: '通话列表、历史记录、IMS 与语音信箱状态。', tone: 'neutral', endpoints: ['/api/calls', '/api/call/history', '/api/ims/status', '/api/voicemail/status'] }, + { id: 'esim', title: 'eSIM / 工作模式', desc: 'eUICC、profile、LPAC 状态、工作模式与 eSIM 配置。', tone: 'warn', endpoints: ['/api/work-mode', '/api/esim/config', '/api/esim/lpac/status', '/api/esim/euicc', '/api/esim/profiles'] }, + { id: 'notify-auto-ota', title: '通知 / 自动化 / OTA', desc: '通知配置与日志、自动化任务日志、在线升级状态。', tone: 'neutral', endpoints: ['/api/notifications/config', '/api/notifications/logs', '/api/automation/config', '/api/automation/logs', '/api/ota/status'] }, +] + +function escapeHtml(text) { + return String(text ?? '').replace(/[&<>"']/g, ch => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[ch])) +} +function value(v) { return v === undefined || v === null || v === '' ? '-' : String(v) } +function boolText(v) { + if (v === true) return '开启' + if (v === false) return '关闭' + return value(v) +} +function activeInstance() { return instances.find(x => x.id === activeId) } +function activeStatus() { return statuses.get(activeId) } +function statusKind(s) { + if (!s) return 'unknown' + if (!s.reachable) return 'offline' + if (s.authenticated === false) return 'auth' + return 'online' +} +function statusText(s) { + const kind = statusKind(s) + if (kind === 'online') return `在线${s.latencyMs != null ? ` ${s.latencyMs}ms` : ''}` + if (kind === 'auth') return '需登录' + if (kind === 'offline') return '离线' + return '未知' +} +function pillClass(s) { + const kind = statusKind(s) + if (kind === 'online') return 'ok' + if (kind === 'auth') return 'warn' + if (kind === 'offline') return 'bad' + return '' +} +function showToast(message, timeout = 2600) { + const toast = $('toast') + toast.textContent = message + toast.hidden = false + clearTimeout(showToast._timer) + showToast._timer = setTimeout(() => { toast.hidden = true }, timeout) +} +function flattenEndpoints() { + return [...new Set([ + ...catalog.flatMap(g => g.endpoints || []), + ...modules.flatMap(m => m.endpoints || []), + ])].sort() +} +function inferMethod(endpoint) { + if (/send|config|data$|roaming$|airplane-mode$|radio-mode$|band-lock$|cell-lock$|work-mode|login|logout/i.test(endpoint)) return 'POST' + return 'GET' +} +function proxiedUrl(endpoint) { + if (!activeId) return null + const normalized = endpoint.startsWith('/') ? endpoint : `/${endpoint}` + return `/api/proxy/${encodeURIComponent(activeId)}${normalized}` +} + +function renderFleetStats() { + const total = instances.length + const counts = { online: 0, auth: 0, offline: 0, unknown: 0 } + for (const inst of instances) counts[statusKind(statuses.get(inst.id))] += 1 + $('fleetStats').innerHTML = [ + ['全部', total], ['在线', counts.online], ['需登录', counts.auth], ['离线', counts.offline], + ].map(([label, num]) => `
${num}${label}
`).join('') +} + +function filteredInstances() { + const q = $('instanceSearch')?.value.trim().toLowerCase() || '' + return instances.filter(item => { + const s = statuses.get(item.id) + const kind = statusKind(s) + if (activeFilter !== 'all' && kind !== activeFilter) return false + if (!q) return true + const haystack = [item.id, item.name, item.url, item.description, JSON.stringify(s?.summary || {})].join(' ').toLowerCase() + return haystack.includes(q) + }) +} + +function renderInstances() { + const root = $('instances') + renderFleetStats() + if (!instances.length) { + root.innerHTML = '

未配置实例

请编辑 config.json 添加 instances,并重启服务。

' + return + } + const list = filteredInstances() + if (!list.length) { + root.innerHTML = '

没有匹配设备

调整搜索关键字或状态筛选。

' + return + } + root.innerHTML = list.map(item => { + const s = statuses.get(item.id) + const sum = s?.summary || {} + const network = sum.network || {} + const sim = sum.sim || {} + return `
+
+
+
${escapeHtml(item.name || item.id)}
+
${escapeHtml(item.url)}
+
+ ${escapeHtml(statusText(s))} +
+ ${item.description ? `

${escapeHtml(item.description)}

` : ''} +
+ 认证${item.auth?.hasPassword ? '密码会话' : '无密码'} + 运营商${escapeHtml(value(network.operator || sim.operator))} + 网络${escapeHtml(value(network.accessTechnology || network.registration))} + ICCID${escapeHtml(value(sim.iccid))} +
+
` + }).join('') + $$('.instance-card', root).forEach(card => card.addEventListener('click', () => selectInstance(card.dataset.id))) +} + +function renderHero() { + const item = activeInstance() + const s = activeStatus() + const sum = s?.summary || {} + if (!item) { + $('activeKicker').textContent = 'NO DEVICE SELECTED' + $('activeName').textContent = '选择一个 SimAdmin 实例' + $('activeMeta').textContent = '统一查看设备、SIM、网络、短信、eSIM、OTA 与原始管理页面。' + $('signalPanel').innerHTML = [ + ['状态', '等待选择'], ['当前实例', '-'], ['会话', '-'], ['刷新', '-'], + ].map(([k, v]) => `
${k}${v}
`).join('') + return + } + $('activeKicker').textContent = item.id + $('activeName').textContent = item.name || item.id + $('activeMeta').textContent = item.description ? `${item.description} · ${item.url}` : item.url + $('signalPanel').innerHTML = [ + ['状态', statusText(s)], + ['设备', value(sum.device?.model || sum.device?.name)], + ['运营商', value(sum.network?.operator || sum.sim?.operator)], + ['网络', value(sum.network?.accessTechnology || sum.network?.registration)], + ['短信', sum.sms ? `总 ${value(sum.sms.total)} / 未读 ${value(sum.sms.unread)}` : '-'], + ['版本', value(sum.ota?.currentVersion || sum.device?.firmwareVersion)], + ].map(([k, v]) => `
${escapeHtml(k)}${escapeHtml(v)}
`).join('') +} + +function renderOverview() { + const item = activeInstance() + const s = activeStatus() + if (!item) { + $('overview').innerHTML = '
提示先选择左侧设备
' + renderHero() + return + } + const sum = s?.summary || {} + const metrics = [ + ['状态', s ? (s.reachable ? (s.authenticated === false ? '在线/未登录' : '在线') : `离线`) : '未知'], + ['延迟', s?.latencyMs != null ? `${s.latencyMs}ms` : '-'], + ['型号', sum.device?.model || sum.device?.name], + ['IMEI', sum.device?.imei], + ['ICCID', sum.sim?.iccid], + ['号码', sum.sim?.phoneNumber || sum.sim?.msisdn], + ['运营商', sum.network?.operator || sum.sim?.operator], + ['网络', sum.network?.accessTechnology || sum.network?.registration], + ['数据连接', sum.data ? `${boolText(sum.data.enabled)} / ${boolText(sum.data.connected)}` : '-'], + ['短信', sum.sms ? `总 ${value(sum.sms.total)} / 未读 ${value(sum.sms.unread)}` : '-'], + ] + $('overview').innerHTML = metrics.map(([k, v]) => `
${escapeHtml(k)}${escapeHtml(value(v))}
`).join('') + renderHero() +} + +function renderModuleTabs() { + const tabs = [{ id: 'all', title: '全部' }, ...modules.map(m => ({ id: m.id, title: m.title.split(/[ /]/)[0] }))] + $('moduleTabs').innerHTML = tabs.map(t => ``).join('') + $$('button[data-module]', $('moduleTabs')).forEach(btn => btn.addEventListener('click', () => { + activeModule = btn.dataset.module + renderFeatures() + })) +} + +function renderFeatures() { + renderModuleTabs() + const disabled = activeId ? '' : ' disabled' + const visible = activeModule === 'all' ? modules : modules.filter(m => m.id === activeModule) + $('featureGrid').innerHTML = visible.map(m => `
+
+

${escapeHtml(m.id)}

${escapeHtml(m.title)}

+ ${m.endpoints.length} API +
+

${escapeHtml(m.desc)}

+ ${m.tone === 'danger' ? '
包含写操作/网络切换类接口,执行前确认目标设备。
' : ''} +
${m.endpoints.map(ep => `${escapeHtml(ep)}`).join('')}
+
`).join('') + $$('button[data-ep]', $('featureGrid')).forEach(btn => btn.addEventListener('click', () => { + switchView('api') + $('endpointInput').value = btn.dataset.ep + $('methodSelect').value = inferMethod(btn.dataset.ep) + runEndpoint() + })) +} + +function renderEndpointList() { + const groups = catalog.length ? catalog : modules.map(m => ({ id: m.id, name: m.title, endpoints: m.endpoints })) + $('endpointList').innerHTML = groups.map(group => `
+

${escapeHtml(group.name || group.id)}

+ ${(group.endpoints || []).map(ep => ``).join('')} +
`).join('') + $$('.endpoint-row', $('endpointList')).forEach(row => row.addEventListener('click', () => { + $('endpointInput').value = row.dataset.ep + $('methodSelect').value = inferMethod(row.dataset.ep) + $$('.endpoint-row', $('endpointList')).forEach(x => x.classList.toggle('active', x === row)) + })) +} + +function switchView(view) { + $$('.rail-btn[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) +} + +function selectInstance(id) { + const item = instances.find(x => x.id === id) + if (!item) return + activeId = id + localStorage.setItem('multi-simadmin-active', id) + $('loginBtn').disabled = false + $('openExternal').href = item.url + $('openExternal').classList.remove('disabled') + $('emptyState').hidden = true + $('frame').hidden = false + $('frame').src = item.url + renderInstances() + renderOverview() + renderFeatures() +} + +async function loadConfig() { + 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() + instances = data.instances || [] + configPath = data.configPath || 'config.json' + $('configPath').textContent = configPath + if (catalogRes.ok) catalog = (await catalogRes.json()).groups || [] + const saved = localStorage.getItem('multi-simadmin-active') + if (saved && instances.some(x => x.id === saved)) activeId = saved + renderEndpointList() + renderInstances() + renderOverview() + renderFeatures() + if (activeId) selectInstance(activeId) +} + +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() + statuses = new Map((data.instances || []).map(item => [item.id, item])) + renderInstances() + renderOverview() + renderFeatures() + if (!silent) showToast(`已刷新 ${statuses.size} 个实例`) + } catch (error) { + showToast(error.message || '刷新失败', 4200) + } finally { + $('refreshBtn').disabled = false + } +} + +async function loginActive() { + if (!activeId) return + const item = activeInstance() + if (!item) return + // If config already has a saved password, try direct refresh first; otherwise ask for password. + if (item.auth?.hasPassword) { + 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) + await refreshStatus({ silent: true }) + return + } + $('loginDialog').showModal() + setTimeout(() => $('passwordInput').focus(), 50) +} + +function parseBody() { + const raw = $('requestBody').value.trim() + if (!raw) return undefined + return JSON.parse(raw) +} + +async function runEndpoint() { + if (!activeId) { + showToast('请先选择左侧设备') + return + } + const endpoint = $('endpointInput').value.trim() + const method = $('methodSelect').value + if (!endpoint.startsWith('/')) { + showToast('接口路径必须以 / 开头') + return + } + $('rawOutput').textContent = '调用中…' + try { + const init = { method } + if (method !== 'GET') { + init.headers = { 'content-type': 'application/json' } + const body = parseBody() + if (body !== undefined) init.body = JSON.stringify(body) + } + const started = performance.now() + const res = await fetch(proxiedUrl(endpoint), init) + 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}` + } catch (error) { + $('rawOutput').textContent = error.message || String(error) + showToast('接口调用失败', 4200) + } +} + +function bindEvents() { + $('refreshBtn').addEventListener('click', () => refreshStatus()) + $('loginBtn').addEventListener('click', loginActive) + $('runEndpoint').addEventListener('click', runEndpoint) + $('instanceSearch').addEventListener('input', renderInstances) + $('densityBtn').addEventListener('click', () => { + document.body.classList.toggle('compact') + localStorage.setItem('multi-simadmin-density', document.body.classList.contains('compact') ? 'compact' : 'normal') + }) + $$('.rail-btn[data-view]').forEach(btn => btn.addEventListener('click', () => switchView(btn.dataset.view))) + $$('#statusFilters .chip').forEach(btn => btn.addEventListener('click', () => { + activeFilter = btn.dataset.filter + $$('#statusFilters .chip').forEach(x => x.classList.toggle('active', x === btn)) + renderInstances() + })) + $('loginForm').addEventListener('submit', async (event) => { + event.preventDefault() + if (!activeId) 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('会话已刷新') + await refreshStatus({ silent: true }) + }) + $('endpointInput').addEventListener('keydown', (event) => { + if (event.key === 'Enter') runEndpoint() + }) +} + +function restorePrefs() { + if (localStorage.getItem('multi-simadmin-density') === 'compact') document.body.classList.add('compact') + switchView(localStorage.getItem('multi-simadmin-view') || 'dashboard') +} + +try { + restorePrefs() + bindEvents() + await loadConfig() + await refreshStatus({ silent: true }) + autoRefreshTimer = setInterval(() => refreshStatus({ silent: true }), 30000) + window.addEventListener('beforeunload', () => clearInterval(autoRefreshTimer)) +} catch (error) { + console.error(error) + showToast(error.message || '初始化失败', 8000) + $('rawOutput').textContent = error.stack || String(error) +} diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..a51471b --- /dev/null +++ b/public/index.html @@ -0,0 +1,141 @@ + + + + + + Multi SimAdmin Control + + + + + + +
+ + + + +
+
+
+

NO DEVICE SELECTED

+

选择一个 SimAdmin 实例

+

统一查看设备、SIM、网络、短信、eSIM、OTA 与原始管理页面。

+
+
+ + 原站打开 +
+
+ +
+
+
+

CONTROL SURFACE

+

统一运维所有 SimAdmin 节点

+

无密码实例直接读取;有密码实例由本地服务保存会话。所有敏感凭据只留在本地配置。

+
+
+
+ +
+ +
+
+
+

MODULES

+

集成功能模块

+
+
+
+
+
+
+ +
+ +
+
+ + + +
+ +
选择设备后可调用统一代理接口。
+
+
+ +
+
+ 原始 SimAdmin 页面会在下方显示;若目标站禁止 iframe,请使用“原站打开”。 +
+
+

还没有选择设备

+

在左侧选择一个实例后,这里会加载原 SimAdmin 页面。

+
+ +
+
+
+ + + + + + + + + diff --git a/public/styles.css b/public/styles.css new file mode 100644 index 0000000..315ffac --- /dev/null +++ b/public/styles.css @@ -0,0 +1,13 @@ +:root{ + color-scheme:dark; + --bg:#08090a;--bg2:#0b0d12;--rail:#050607;--panel:#0f1011;--panel2:#14161a;--surface:rgba(255,255,255,.035);--surface2:rgba(255,255,255,.055); + --text:#f7f8f8;--text2:#d0d6e0;--muted:#8a8f98;--dim:#62666d;--line:rgba(255,255,255,.08);--line2:rgba(255,255,255,.05); + --accent:#7170ff;--accent2:#5e6ad2;--ok:#27a644;--warn:#d99a20;--bad:#ef4444;--lime:#c2ef4e; + --font:'Inter',system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;--mono:'JetBrains Mono',ui-monospace,SFMono-Regular,Menlo,monospace; + font-family:var(--font);font-feature-settings:"cv01","ss03"; +} +*{box-sizing:border-box}html,body{height:100%}body{margin:0;background:radial-gradient(900px 520px at 70% -10%,rgba(113,112,255,.18),transparent 55%),radial-gradient(720px 480px at 15% 10%,rgba(194,239,78,.055),transparent 50%),var(--bg);color:var(--text);overflow:hidden}button,input,select,textarea{font:inherit}button{cursor:pointer}button:focus-visible,a:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible{outline:2px solid rgba(113,112,255,.65);outline-offset:2px} +#appShell{height:100vh;display:grid;grid-template-columns:64px 390px minmax(0,1fr)}.rail{background:linear-gradient(180deg,#050607,#08090a);border-right:1px solid var(--line2);padding:14px 10px;display:flex;flex-direction:column;align-items:center;gap:18px}.brand-mark{width:38px;height:38px;border-radius:12px;background:linear-gradient(135deg,var(--accent2),#9b8cff);display:grid;place-items:center;box-shadow:0 0 0 1px rgba(255,255,255,.12),0 12px 34px rgba(94,106,210,.28)}.brand-mark span{font-size:12px;font-weight:600;letter-spacing:-.3px}.rail-nav{display:flex;flex-direction:column;gap:8px}.rail-btn{width:42px;height:42px;border:1px solid var(--line2);border-radius:12px;background:rgba(255,255,255,.02);color:var(--muted);font-family:var(--mono);font-size:13px}.rail-btn:hover,.rail-btn.active{color:var(--text);background:rgba(255,255,255,.06);border-color:rgba(113,112,255,.45)}.rail-btn.bottom{margin-top:auto} +.sidebar{background:rgba(15,16,17,.78);backdrop-filter:blur(24px);border-right:1px solid var(--line);padding:22px;display:flex;flex-direction:column;min-height:0}.sidebar-head,.workspace-head,.section-title,.console-toolbar,.frame-toolbar{display:flex;align-items:center;justify-content:space-between;gap:14px}.eyebrow{margin:0 0 6px;color:var(--dim);font:500 11px/1.2 var(--mono);letter-spacing:.08em;text-transform:uppercase}.eyebrow.accent{color:var(--accent)}h1,h2,h3,p{margin:0}h1{font-size:21px;font-weight:510;letter-spacing:-.46px}h2{font-size:30px;font-weight:510;letter-spacing:-.66px;line-height:1.08}h3{font-size:18px;font-weight:590;letter-spacing:-.22px}.icon-action,.ghost-btn,.primary-btn,.chip{border:1px solid var(--line);border-radius:10px;background:rgba(255,255,255,.025);color:var(--text2);padding:9px 12px;text-decoration:none;transition:.16s ease}.icon-action{width:38px;height:38px;padding:0}.ghost-btn:hover,.icon-action:hover,.chip:hover{background:rgba(255,255,255,.06);border-color:rgba(255,255,255,.14);color:var(--text)}.primary-btn{background:var(--accent2);border-color:rgba(255,255,255,.12);color:#fff;box-shadow:0 0 0 1px rgba(255,255,255,.05) inset,0 10px 30px rgba(94,106,210,.24)}.primary-btn:hover{background:#828fff}.disabled,button:disabled{opacity:.45;pointer-events:none}.fleet-stats{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin:18px 0}.fleet-card{border:1px solid var(--line2);border-radius:13px;background:var(--surface);padding:10px}.fleet-card b{display:block;font-size:20px;font-weight:510;letter-spacing:-.44px}.fleet-card span{display:block;margin-top:3px;color:var(--dim);font-size:11px}.search-wrap{height:42px;border:1px solid var(--line);border-radius:12px;background:rgba(255,255,255,.025);display:flex;align-items:center;gap:8px;padding:0 12px;color:var(--dim)}.search-wrap input{width:100%;border:0;outline:0;background:transparent;color:var(--text);font-size:14px}.filter-row{display:flex;gap:8px;margin:12px 0 16px;flex-wrap:wrap}.chip{padding:6px 10px;border-radius:999px;font-size:12px;color:var(--muted)}.chip.active{background:rgba(113,112,255,.14);border-color:rgba(113,112,255,.38);color:#cfd2ff}.instance-list{overflow:auto;display:flex;flex-direction:column;gap:10px;padding-right:3px}.instance-card{position:relative;border:1px solid var(--line2);border-radius:16px;background:linear-gradient(180deg,rgba(255,255,255,.045),rgba(255,255,255,.025));padding:14px;transition:.16s ease}.instance-card:hover{border-color:rgba(255,255,255,.14);transform:translateY(-1px)}.instance-card.active{border-color:rgba(113,112,255,.58);box-shadow:0 0 0 3px rgba(113,112,255,.12)}.instance-top{display:flex;justify-content:space-between;gap:10px}.instance-name{font-size:15px;font-weight:590}.instance-url{margin-top:4px;color:var(--dim);font:12px var(--mono);word-break:break-all}.instance-desc{margin-top:8px;color:var(--muted);font-size:12px;line-height:1.45}.status-pill{display:inline-flex;align-items:center;gap:6px;white-space:nowrap;border-radius:999px;padding:5px 8px;border:1px solid var(--line2);font-size:11px;color:var(--muted)}.status-pill:before{content:"";width:6px;height:6px;border-radius:50%;background:var(--dim)}.status-pill.ok{color:#b9f6c6}.status-pill.ok:before{background:var(--ok);box-shadow:0 0 12px rgba(39,166,68,.6)}.status-pill.warn{color:#ffd895}.status-pill.warn:before{background:var(--warn)}.status-pill.bad{color:#ffb4b4}.status-pill.bad:before{background:var(--bad)}.mini-grid{display:grid;grid-template-columns:1fr 1fr;gap:7px;margin-top:12px}.mini-grid span{min-width:0;color:var(--dim);font-size:11px}.mini-grid b{display:block;color:var(--text2);font-weight:510;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.sidebar-foot{border-top:1px solid var(--line2);margin-top:16px;padding-top:14px;color:var(--dim);font-size:12px}.sidebar-foot code{display:block;margin-top:6px;color:var(--muted);font-family:var(--mono);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.workspace{min-width:0;display:flex;flex-direction:column;height:100vh}.workspace-head{height:92px;padding:20px 28px;border-bottom:1px solid var(--line);background:rgba(8,9,10,.62);backdrop-filter:blur(18px)}.title-block{min-width:0}.title-block p:last-child{margin-top:6px;color:var(--muted);font-size:14px}.head-actions{display:flex;gap:10px;white-space:nowrap}.view{display:none;min-height:0;flex:1;padding:20px 24px;overflow:auto}.view.active{display:block}.hero-panel{position:relative;overflow:hidden;border:1px solid var(--line);border-radius:22px;background:linear-gradient(135deg,rgba(255,255,255,.055),rgba(255,255,255,.02)),radial-gradient(500px 240px at 80% 0%,rgba(113,112,255,.16),transparent 70%);padding:22px;display:grid;grid-template-columns:minmax(0,1.2fr) minmax(320px,.8fr);gap:18px}.hero-copy h3{font-size:32px;font-weight:510;letter-spacing:-.704px;line-height:1.05}.hero-copy p:last-child{max-width:680px;margin-top:10px;color:var(--muted);line-height:1.6}.signal-panel{border:1px solid var(--line2);border-radius:18px;background:rgba(0,0,0,.18);padding:14px;display:grid;grid-template-columns:repeat(2,1fr);gap:10px}.signal-item{border:1px solid var(--line2);border-radius:13px;background:rgba(255,255,255,.025);padding:12px}.signal-item span{display:block;color:var(--dim);font:11px var(--mono);text-transform:uppercase}.signal-item b{display:block;margin-top:8px;font-size:19px;font-weight:510}.metric-grid{display:grid;grid-template-columns:repeat(5,minmax(140px,1fr));gap:12px;margin:16px 0}.metric{border:1px solid var(--line2);border-radius:16px;background:var(--surface);padding:14px}.metric span{display:block;color:var(--dim);font-size:12px}.metric b{display:block;margin-top:9px;font-size:18px;font-weight:510;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.section-block{border:1px solid var(--line);border-radius:20px;background:rgba(255,255,255,.025);padding:18px}.section-title{margin-bottom:14px}.section-title.small{margin-bottom:10px}.module-tabs{display:flex;gap:8px;flex-wrap:wrap}.module-tabs button{border:1px solid var(--line2);border-radius:999px;background:transparent;color:var(--muted);padding:7px 10px;font-size:12px}.module-tabs button.active{color:#cfd2ff;background:rgba(113,112,255,.12);border-color:rgba(113,112,255,.34)}.feature-grid{display:grid;grid-template-columns:repeat(3,minmax(220px,1fr));gap:12px}.feature{border:1px solid var(--line2);border-radius:17px;background:linear-gradient(180deg,rgba(255,255,255,.045),rgba(255,255,255,.018));padding:15px;min-height:164px}.feature-head{display:flex;align-items:flex-start;justify-content:space-between;gap:10px}.feature h4{margin:0;font-size:16px;font-weight:590}.feature p{margin-top:8px;color:var(--muted);font-size:13px;line-height:1.5}.endpoint-pills{display:flex;flex-wrap:wrap;gap:6px;margin-top:14px}.endpoint-pills button{border:1px solid var(--line2);border-radius:8px;background:rgba(255,255,255,.025);color:var(--muted);font:11px var(--mono);padding:6px 7px}.endpoint-pills button:hover{color:var(--text);border-color:rgba(113,112,255,.4)}.danger-note{margin-top:12px;color:#ffb4b4;font-size:12px}.api-layout.active{display:grid;grid-template-columns:320px minmax(0,1fr);gap:16px}.api-catalog,.api-console{border:1px solid var(--line);border-radius:20px;background:rgba(255,255,255,.025);padding:16px;min-height:0}.api-catalog{overflow:auto}.endpoint-group{margin-bottom:14px}.endpoint-group h4{margin:0 0 8px;font-size:13px;color:var(--text2);font-weight:590}.endpoint-row{display:block;width:100%;text-align:left;border:1px solid var(--line2);border-radius:10px;background:transparent;color:var(--muted);font:12px var(--mono);padding:8px 9px;margin-bottom:6px}.endpoint-row:hover,.endpoint-row.active{color:var(--text);background:rgba(255,255,255,.045);border-color:rgba(113,112,255,.34)}.api-console{display:flex;flex-direction:column;gap:12px}.console-toolbar{justify-content:flex-start}.console-toolbar select{width:110px}.console-toolbar input{flex:1;font-family:var(--mono)}input,select,textarea{border:1px solid var(--line);background:rgba(255,255,255,.025);color:var(--text);border-radius:10px;padding:10px 12px}.request-body{min-height:96px;resize:vertical;font-family:var(--mono)}.raw-output{flex:1;min-height:420px;margin:0;border:1px solid var(--line2);border-radius:16px;background:#050607;color:#dbeafe;padding:16px;overflow:auto;font:12px/1.6 var(--mono)}.rawpage-view.active{display:flex;flex-direction:column;gap:12px}.frame-toolbar{border:1px solid var(--line2);border-radius:14px;background:var(--surface);padding:12px 14px;color:var(--muted);font-size:13px}.frame{width:100%;flex:1;min-height:600px;border:1px solid var(--line);border-radius:18px;background:white}.empty-state{border:1px dashed var(--line);border-radius:20px;background:rgba(255,255,255,.025);padding:42px;color:var(--muted)}.empty-state h3{color:var(--text);margin-bottom:8px}dialog{border:1px solid var(--line);border-radius:20px;background:#101116;color:var(--text);box-shadow:0 28px 80px rgba(0,0,0,.5)}dialog::backdrop{background:rgba(0,0,0,.72);backdrop-filter:blur(6px)}.login-card{width:min(420px,86vw);padding:22px}.login-card p:not(.eyebrow){color:var(--muted);line-height:1.5;margin-top:8px}.login-card input{width:100%;margin:16px 0}.login-card menu{display:flex;justify-content:flex-end;gap:10px;margin:0;padding:0}.toast{position:fixed;right:20px;bottom:20px;border:1px solid var(--line);border-radius:14px;background:#191a1b;color:var(--text);padding:12px 14px;box-shadow:0 18px 60px rgba(0,0,0,.35);z-index:10}.compact .sidebar{padding:16px}.compact .instance-card,.compact .feature,.compact .metric{padding:10px}.compact .workspace-head{height:76px}.compact .view{padding:14px}.compact .metric-grid{gap:8px}.compact .feature-grid{gap:8px} +@media(max-width:1180px){#appShell{grid-template-columns:56px 330px minmax(0,1fr)}.metric-grid{grid-template-columns:repeat(3,1fr)}.feature-grid{grid-template-columns:repeat(2,1fr)}.hero-panel{grid-template-columns:1fr}.api-layout.active{grid-template-columns:1fr}.api-catalog{max-height:280px}}@media(max-width:820px){body{overflow:auto}#appShell{height:auto;min-height:100vh;grid-template-columns:1fr}.rail{display:none}.sidebar{height:46vh;border-right:0;border-bottom:1px solid var(--line)}.workspace{height:auto;min-height:54vh}.workspace-head{height:auto;align-items:flex-start;flex-direction:column}.metric-grid,.feature-grid{grid-template-columns:1fr}.signal-panel{grid-template-columns:1fr}} diff --git a/server/core.js b/server/core.js new file mode 100644 index 0000000..8d9728e --- /dev/null +++ b/server/core.js @@ -0,0 +1,293 @@ + +import { readFile } from 'node:fs/promises' +import { existsSync } from 'node:fs' +import path from 'node:path' + +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, + server: { + host: raw.server?.host || process.env.HOST || '0.0.0.0', + port: Number(process.env.PORT || raw.server?.port || 8788), + }, + instances, + } +} + +export function redactInstance(instance) { + return { + id: instance.id, + name: instance.name, + url: instance.url, + description: instance.description, + tags: instance.tags || [], + auth: { + mode: instance.auth?.mode || 'none', + hasPassword: Boolean(instance.auth?.password), + }, + capabilities: instance.capabilities || [], + } +} + +function apiData(payload) { + return payload?.data ?? payload +} + +function pickDevice(data) { + data = apiData(data) + if (!data || typeof data !== 'object') return null + return { + imei: data.imei || data.IMEI || null, + manufacturer: data.manufacturer || data.vendor || null, + model: data.model || data.device_model || null, + firmware: data.firmware || data.firmware_version || data.version || null, + online: data.online ?? data.is_online ?? null, + } +} + +function pickSim(data) { + data = apiData(data) + if (!data || typeof data !== 'object') return null + return { + iccid: data.iccid || data.ICCID || null, + imsi: data.imsi || data.IMSI || null, + phoneNumber: data.phone_number || data.phoneNumber || data.msisdn || null, + operator: data.operator || data.operator_name || null, + signal: data.signal || data.signal_strength || null, + } +} + +function pickNetwork(data) { + data = apiData(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, + accessTechnology: data.access_technology || data.accessTechnology || data.rat || data.mode || null, + signal: data.signal || data.signal_strength || data.rssi || null, + } +} + +function pickSms(data) { + data = apiData(data) + if (!data || typeof data !== 'object') return null + return { + total: data.total ?? data.total_count ?? data.count ?? null, + unread: data.unread ?? data.unread_count ?? null, + conversations: data.conversations ?? data.conversation_count ?? null, + } +} + +function pickDataStatus(data) { + data = apiData(data) + if (!data || typeof data !== 'object') return null + return { + enabled: data.enabled ?? data.data_enabled ?? null, + connected: data.connected ?? data.is_connected ?? null, + roaming: data.roaming ?? data.roaming_allowed ?? null, + } +} + +function pickOta(data) { + data = apiData(data) + if (!data || typeof data !== 'object') return null + return { + currentVersion: data.current_version || data.currentVersion || data.version || null, + latestVersion: data.latest_version || data.latestVersion || null, + updateAvailable: data.update_available ?? data.updateAvailable ?? null, + } +} + +export function summarizeInstanceSnapshot(raw) { + return { + device: pickDevice(raw.device), + sim: pickSim(raw.sim), + network: pickNetwork(raw.network), + sms: pickSms(raw.smsStats), + data: pickDataStatus(raw.data), + ota: pickOta(raw.ota), + stats: apiData(raw.stats) || null, + calls: apiData(raw.calls) || null, + } +} + +function parseSetCookie(value) { + if (!value) return [] + if (Array.isArray(value)) return value + return [String(value)] +} + +class CookieJar { + constructor() { this.map = new Map() } + setFromHeaders(headers) { + const raw = headers.getSetCookie ? headers.getSetCookie() : parseSetCookie(headers.get('set-cookie')) + for (const line of raw) { + const [pair] = String(line).split(';') + const eq = pair.indexOf('=') + if (eq <= 0) continue + const name = pair.slice(0, eq).trim() + const value = pair.slice(eq + 1).trim() + if (value) this.map.set(name, value) + else this.map.delete(name) + } + } + header() { + return [...this.map.entries()].map(([k, v]) => `${k}=${v}`).join('; ') + } + clear() { this.map.clear() } +} + +export function createSimAdminClient(instance, { fetchImpl = fetch, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) { + const jar = new CookieJar() + + async function request(endpoint, options = {}) { + const url = new URL(endpoint, instance.url) + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), options.timeoutMs || timeoutMs) + const headers = new Headers(options.headers || {}) + const cookie = jar.header() + if (cookie && !headers.has('cookie')) headers.set('cookie', cookie) + if (headers.has('cookie')) headers.cookie = headers.get('cookie') + try { + const response = await fetchImpl(url, { ...options, headers, signal: controller.signal, redirect: options.redirect || 'manual' }) + jar.setFromHeaders(response.headers) + return response + } finally { + clearTimeout(timer) + } + } + + async function fetchJson(endpoint, options = {}) { + const startedAt = Date.now() + const response = await request(endpoint, { method: 'GET', ...options, headers: { accept: 'application/json,text/plain,*/*', ...(options.headers || {}) } }) + const text = await response.text() + let data = null + try { data = text ? JSON.parse(text) : null } catch {} + 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() { + 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 login = await request('/api/auth/login', { + method: 'POST', + headers: { 'content-type': 'application/json', accept: 'application/json' }, + body: JSON.stringify({ password: instance.auth.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 } +} + +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())) +} diff --git a/server/index.js b/server/index.js new file mode 100644 index 0000000..ebcaeaa --- /dev/null +++ b/server/index.js @@ -0,0 +1,105 @@ +import Fastify from 'fastify' +import fastifyStatic from '@fastify/static' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { + buildClients, + collectInstanceStatus, + loadConfig, + proxyToInstance, + redactInstance, +} from './core.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 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 +} + +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.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') +}) + +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) +} diff --git a/test/server-core.test.js b/test/server-core.test.js new file mode 100644 index 0000000..db04c67 --- /dev/null +++ b/test/server-core.test.js @@ -0,0 +1,56 @@ + +import test from 'node:test' +import assert from 'node:assert/strict' +import { createSimAdminClient, normalizeInstance, summarizeInstanceSnapshot, redactInstance } from '../server/core.js' + +test('normalizeInstance supports passwordless and password-protected instances without exposing password', () => { + const open = normalizeInstance({ id: 'open-1', name: '开放设备', url: 'http://127.0.0.1:3000/' }, 0) + assert.equal(open.auth.mode, 'none') + assert.equal(open.url, 'http://127.0.0.1:3000') + + const protectedOne = normalizeInstance({ id: 'locked', url: 'http://cpe.local', auth: { password: 'secret' } }, 1) + assert.equal(protectedOne.auth.mode, 'password') + assert.equal(protectedOne.auth.password, 'secret') + assert.equal(redactInstance(protectedOne).auth.hasPassword, true) + assert.equal(redactInstance(protectedOne).auth.password, undefined) +}) + +test('client stores simadmin_session from login and sends it on later proxied requests', async () => { + const calls = [] + const fetchImpl = async (url, options = {}) => { + calls.push({ url: String(url), options }) + if (String(url).endsWith('/api/auth/status') && calls.length === 1) { + return new Response(JSON.stringify({ data: { configured: true, authenticated: false, settings: { password_protection_enabled: true } } }), { status: 200, headers: { 'content-type': 'application/json' } }) + } + if (String(url).endsWith('/api/auth/login')) { + return new Response(JSON.stringify({ success: true }), { status: 200, headers: { 'set-cookie': 'simadmin_session=abc123; HttpOnly; Path=/; Max-Age=86400' } }) + } + if (String(url).endsWith('/api/device')) { + return new Response(JSON.stringify({ data: { model: 'RM500Q' } }), { status: 200, headers: { 'content-type': 'application/json' } }) + } + return new Response('{}', { status: 404 }) + } + const client = createSimAdminClient(normalizeInstance({ id: 'locked', url: 'http://sim.local', auth: { password: 'secret' } }, 0), { fetchImpl }) + const auth = await client.ensureAuthenticated() + assert.equal(auth.authenticated, true) + const proxied = await client.fetchJson('/api/device') + assert.equal(proxied.status, 200) + assert.match(calls.at(-1).options.headers.cookie, /simadmin_session=abc123/) +}) + +test('snapshot summary merges status from device, sim, network, sms and system endpoints', () => { + const snapshot = summarizeInstanceSnapshot({ + device: { data: { model: 'CPE X', imei: '123' } }, + sim: { data: { iccid: '8986', operator: 'CMCC' } }, + network: { data: { accessTechnology: 'LTE', registration: 'registered' } }, + smsStats: { data: { total: 12, unread: 2 } }, + data: { data: { enabled: true, connected: true } }, + ota: { data: { current_version: '1.2.3' } }, + }) + assert.equal(snapshot.device.model, 'CPE X') + assert.equal(snapshot.sim.iccid, '8986') + assert.equal(snapshot.network.accessTechnology, 'LTE') + assert.equal(snapshot.sms.total, 12) + assert.equal(snapshot.data.connected, true) + assert.equal(snapshot.ota.currentVersion, '1.2.3') +})