Initial Multi SimAdmin dashboard
This commit is contained in:
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules/
|
||||||
|
config.json
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
.DS_Store
|
||||||
|
npm-debug.log*
|
||||||
|
coverage/
|
||||||
|
.hermes/
|
||||||
@@ -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
|
||||||
|
```
|
||||||
|
|
||||||
|
默认访问:<http://localhost:8788>
|
||||||
|
|
||||||
|
## 配置
|
||||||
|
|
||||||
|
编辑 `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` 中排除。
|
||||||
|
- 部分写操作接口(短信发送、网络模式、频段/小区锁定等)会改变设备状态,执行前请确认目标实例。
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Generated
+1047
File diff suppressed because it is too large
Load Diff
@@ -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": {}
|
||||||
|
}
|
||||||
+388
@@ -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]) => `<div class="fleet-card"><b>${num}</b><span>${label}</span></div>`).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 = '<section class="empty-state"><h3>未配置实例</h3><p>请编辑 config.json 添加 instances,并重启服务。</p></section>'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const list = filteredInstances()
|
||||||
|
if (!list.length) {
|
||||||
|
root.innerHTML = '<section class="empty-state"><h3>没有匹配设备</h3><p>调整搜索关键字或状态筛选。</p></section>'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
root.innerHTML = list.map(item => {
|
||||||
|
const s = statuses.get(item.id)
|
||||||
|
const sum = s?.summary || {}
|
||||||
|
const network = sum.network || {}
|
||||||
|
const sim = sum.sim || {}
|
||||||
|
return `<article class="instance-card ${item.id === activeId ? 'active' : ''}" data-id="${escapeHtml(item.id)}">
|
||||||
|
<div class="instance-top">
|
||||||
|
<div>
|
||||||
|
<div class="instance-name">${escapeHtml(item.name || item.id)}</div>
|
||||||
|
<div class="instance-url">${escapeHtml(item.url)}</div>
|
||||||
|
</div>
|
||||||
|
<span class="status-pill ${pillClass(s)}">${escapeHtml(statusText(s))}</span>
|
||||||
|
</div>
|
||||||
|
${item.description ? `<p class="instance-desc">${escapeHtml(item.description)}</p>` : ''}
|
||||||
|
<div class="mini-grid">
|
||||||
|
<span>认证<b>${item.auth?.hasPassword ? '密码会话' : '无密码'}</b></span>
|
||||||
|
<span>运营商<b>${escapeHtml(value(network.operator || sim.operator))}</b></span>
|
||||||
|
<span>网络<b>${escapeHtml(value(network.accessTechnology || network.registration))}</b></span>
|
||||||
|
<span>ICCID<b>${escapeHtml(value(sim.iccid))}</b></span>
|
||||||
|
</div>
|
||||||
|
</article>`
|
||||||
|
}).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]) => `<div class="signal-item"><span>${k}</span><b>${v}</b></div>`).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]) => `<div class="signal-item"><span>${escapeHtml(k)}</span><b>${escapeHtml(v)}</b></div>`).join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderOverview() {
|
||||||
|
const item = activeInstance()
|
||||||
|
const s = activeStatus()
|
||||||
|
if (!item) {
|
||||||
|
$('overview').innerHTML = '<div class="metric"><span>提示</span><b>先选择左侧设备</b></div>'
|
||||||
|
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]) => `<div class="metric"><span>${escapeHtml(k)}</span><b title="${escapeHtml(value(v))}">${escapeHtml(value(v))}</b></div>`).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 => `<button class="${t.id === activeModule ? 'active' : ''}" data-module="${escapeHtml(t.id)}">${escapeHtml(t.title)}</button>`).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 => `<article class="feature">
|
||||||
|
<div class="feature-head">
|
||||||
|
<div><p class="eyebrow ${m.tone === 'danger' ? '' : 'accent'}">${escapeHtml(m.id)}</p><h4>${escapeHtml(m.title)}</h4></div>
|
||||||
|
<span class="status-pill ${m.tone === 'danger' ? 'bad' : m.tone === 'warn' ? 'warn' : m.tone === 'ok' ? 'ok' : ''}">${m.endpoints.length} API</span>
|
||||||
|
</div>
|
||||||
|
<p>${escapeHtml(m.desc)}</p>
|
||||||
|
${m.tone === 'danger' ? '<div class="danger-note">包含写操作/网络切换类接口,执行前确认目标设备。</div>' : ''}
|
||||||
|
<div class="endpoint-pills">${m.endpoints.map(ep => `<button${disabled} data-ep="${escapeHtml(ep)}">${escapeHtml(ep)}</button>`).join('')}</div>
|
||||||
|
</article>`).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 => `<section class="endpoint-group">
|
||||||
|
<h4>${escapeHtml(group.name || group.id)}</h4>
|
||||||
|
${(group.endpoints || []).map(ep => `<button class="endpoint-row" data-ep="${escapeHtml(ep)}"><span>${escapeHtml(inferMethod(ep))}</span> ${escapeHtml(ep)}</button>`).join('')}
|
||||||
|
</section>`).join('')
|
||||||
|
$$('.endpoint-row', $('endpointList')).forEach(row => row.addEventListener('click', () => {
|
||||||
|
$('endpointInput').value = row.dataset.ep
|
||||||
|
$('methodSelect').value = inferMethod(row.dataset.ep)
|
||||||
|
$$('.endpoint-row', $('endpointList')).forEach(x => x.classList.toggle('active', x === row))
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchView(view) {
|
||||||
|
$$('.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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Multi SimAdmin Control</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="/styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="appShell">
|
||||||
|
<aside class="rail" aria-label="主导航">
|
||||||
|
<div class="brand-mark"><span>MS</span></div>
|
||||||
|
<nav class="rail-nav">
|
||||||
|
<button class="rail-btn active" data-view="dashboard" title="控制台">⌘</button>
|
||||||
|
<button class="rail-btn" data-view="api" title="API 工作台">{}</button>
|
||||||
|
<button class="rail-btn" data-view="rawpage" title="原页面">↗</button>
|
||||||
|
</nav>
|
||||||
|
<button id="densityBtn" class="rail-btn bottom" title="切换紧凑密度">↕</button>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<aside class="sidebar">
|
||||||
|
<header class="sidebar-head">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">SIM FLEET</p>
|
||||||
|
<h1>Multi SimAdmin</h1>
|
||||||
|
</div>
|
||||||
|
<button id="refreshBtn" class="icon-action" type="button" title="刷新全部">⟳</button>
|
||||||
|
</header>
|
||||||
|
<div class="fleet-stats" id="fleetStats"></div>
|
||||||
|
<div class="search-wrap">
|
||||||
|
<span>⌕</span>
|
||||||
|
<input id="instanceSearch" type="search" placeholder="搜索设备、标签、地址…" autocomplete="off" />
|
||||||
|
</div>
|
||||||
|
<div class="filter-row" id="statusFilters">
|
||||||
|
<button class="chip active" data-filter="all">全部</button>
|
||||||
|
<button class="chip" data-filter="online">在线</button>
|
||||||
|
<button class="chip" data-filter="auth">需登录</button>
|
||||||
|
<button class="chip" data-filter="offline">离线</button>
|
||||||
|
</div>
|
||||||
|
<div id="instances" class="instance-list"></div>
|
||||||
|
<footer class="sidebar-foot">
|
||||||
|
<span>配置</span>
|
||||||
|
<code id="configPath">config.json</code>
|
||||||
|
</footer>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="workspace">
|
||||||
|
<header class="workspace-head">
|
||||||
|
<div class="title-block">
|
||||||
|
<p id="activeKicker" class="eyebrow">NO DEVICE SELECTED</p>
|
||||||
|
<h2 id="activeName">选择一个 SimAdmin 实例</h2>
|
||||||
|
<p id="activeMeta">统一查看设备、SIM、网络、短信、eSIM、OTA 与原始管理页面。</p>
|
||||||
|
</div>
|
||||||
|
<div class="head-actions">
|
||||||
|
<button id="loginBtn" type="button" class="ghost-btn" disabled>登录/刷新会话</button>
|
||||||
|
<a id="openExternal" href="#" target="_blank" rel="noreferrer" class="ghost-btn disabled">原站打开</a>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section id="dashboardView" class="view active">
|
||||||
|
<section id="heroPanel" class="hero-panel">
|
||||||
|
<div class="hero-copy">
|
||||||
|
<p class="eyebrow accent">CONTROL SURFACE</p>
|
||||||
|
<h3>统一运维所有 SimAdmin 节点</h3>
|
||||||
|
<p>无密码实例直接读取;有密码实例由本地服务保存会话。所有敏感凭据只留在本地配置。</p>
|
||||||
|
</div>
|
||||||
|
<div id="signalPanel" class="signal-panel"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="overview" class="metric-grid"></section>
|
||||||
|
|
||||||
|
<section class="section-block">
|
||||||
|
<div class="section-title">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">MODULES</p>
|
||||||
|
<h3>集成功能模块</h3>
|
||||||
|
</div>
|
||||||
|
<div id="moduleTabs" class="module-tabs"></div>
|
||||||
|
</div>
|
||||||
|
<div id="featureGrid" class="feature-grid"></div>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="apiView" class="view api-layout">
|
||||||
|
<aside class="api-catalog">
|
||||||
|
<div class="section-title small">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">API MAP</p>
|
||||||
|
<h3>接口目录</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="endpointList" class="endpoint-list"></div>
|
||||||
|
</aside>
|
||||||
|
<section class="api-console">
|
||||||
|
<div class="console-toolbar">
|
||||||
|
<select id="methodSelect">
|
||||||
|
<option>GET</option>
|
||||||
|
<option>POST</option>
|
||||||
|
<option>DELETE</option>
|
||||||
|
</select>
|
||||||
|
<input id="endpointInput" value="/api/device" spellcheck="false" />
|
||||||
|
<button id="runEndpoint" class="primary-btn" type="button">发送</button>
|
||||||
|
</div>
|
||||||
|
<textarea id="requestBody" class="request-body" placeholder='POST/DELETE 请求体 JSON,例如 {"enabled":true}'></textarea>
|
||||||
|
<pre id="rawOutput" class="raw-output">选择设备后可调用统一代理接口。</pre>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="rawpageView" class="view rawpage-view">
|
||||||
|
<div class="frame-toolbar">
|
||||||
|
<span id="frameHint">原始 SimAdmin 页面会在下方显示;若目标站禁止 iframe,请使用“原站打开”。</span>
|
||||||
|
</div>
|
||||||
|
<section id="emptyState" class="empty-state">
|
||||||
|
<h3>还没有选择设备</h3>
|
||||||
|
<p>在左侧选择一个实例后,这里会加载原 SimAdmin 页面。</p>
|
||||||
|
</section>
|
||||||
|
<iframe id="frame" class="frame" title="SimAdmin instance" hidden></iframe>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dialog id="loginDialog">
|
||||||
|
<form method="dialog" id="loginForm" class="login-card">
|
||||||
|
<p class="eyebrow accent">SESSION</p>
|
||||||
|
<h3>登录 SimAdmin</h3>
|
||||||
|
<p>输入该实例管理员密码;只发送到本地聚合服务,不写入浏览器存储。</p>
|
||||||
|
<input id="passwordInput" type="password" placeholder="管理员密码" autocomplete="current-password" />
|
||||||
|
<menu>
|
||||||
|
<button value="cancel" class="ghost-btn">取消</button>
|
||||||
|
<button id="submitLogin" value="default" class="primary-btn">登录</button>
|
||||||
|
</menu>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<div id="toast" class="toast" hidden></div>
|
||||||
|
<script src="/app.js" type="module"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
File diff suppressed because one or more lines are too long
+293
@@ -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()))
|
||||||
|
}
|
||||||
+105
@@ -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)
|
||||||
|
}
|
||||||
@@ -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')
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user