feat: add instance management to dashboard

This commit is contained in:
chick
2026-07-07 23:04:32 +08:00
parent 182bd19c95
commit 2505ee561a
7 changed files with 267 additions and 12 deletions
+1
View File
@@ -6,6 +6,7 @@
- 只需要在 `config.json` 配置多个 SimAdmin 地址。 - 只需要在 `config.json` 配置多个 SimAdmin 地址。
- Animal Island / 动森风格 Dashboard:暖米色背景、棕色文字、Ribbon 飘带标题、NookPhone 应用格、胶囊按钮与波点卡片。 - Animal Island / 动森风格 Dashboard:暖米色背景、棕色文字、Ribbon 飘带标题、NookPhone 应用格、胶囊按钮与波点卡片。
- UI 内直接新增、编辑、删除设备地址,配置实时写入本地 `config.json`,并提供返回首页入口。
- 实例舰队列表、状态筛选、搜索、当前设备摘要。 - 实例舰队列表、状态筛选、搜索、当前设备摘要。
- 支持无密码实例状态读取;有密码实例可通过本地服务代登录并保持会话。 - 支持无密码实例状态读取;有密码实例可通过本地服务代登录并保持会话。
- 统一代理 SimAdmin API,内置 API 工作台,便于读取/调试设备、SIM、网络、短信、eSIM、OTA 等接口。 - 统一代理 SimAdmin API,内置 API 工作台,便于读取/调试设备、SIM、网络、短信、eSIM、OTA 等接口。
+106 -5
View File
@@ -31,6 +31,31 @@ function boolText(v) {
} }
function activeInstance() { return instances.find(x => x.id === activeId) } function activeInstance() { return instances.find(x => x.id === activeId) }
function activeStatus() { return statuses.get(activeId) } function activeStatus() { return statuses.get(activeId) }
function tagsFromInput(text) { return String(text || '').split(',').map(x => x.trim()).filter(Boolean) }
function instancePayloadFromForm() {
return {
id: $('instanceIdInput').value.trim(),
name: $('instanceNameInput').value.trim(),
url: $('instanceUrlInput').value.trim(),
description: $('instanceDescInput').value.trim(),
tags: tagsFromInput($('instanceTagsInput').value),
auth: { password: $('instancePasswordInput').value },
}
}
function setActiveHome() {
activeId = null
localStorage.removeItem('multi-simadmin-active')
$('loginBtn').disabled = true
$('openExternal').href = '#'
$('openExternal').classList.add('disabled')
$('emptyState').hidden = false
$('frame').hidden = true
$('frame').removeAttribute('src')
switchView('dashboard')
renderInstances()
renderOverview()
renderFeatures()
}
function statusKind(s) { function statusKind(s) {
if (!s) return 'unknown' if (!s) return 'unknown'
if (!s.reachable) return 'offline' if (!s.reachable) return 'offline'
@@ -99,7 +124,7 @@ function renderInstances() {
const root = $('instances') const root = $('instances')
renderFleetStats() renderFleetStats()
if (!instances.length) { if (!instances.length) {
root.innerHTML = '<section class="empty-state"><h3>未配置实例</h3><p>请编辑 config.json 添加 instances,并重启服务。</p></section>' root.innerHTML = '<section class="empty-state mini-empty"><h3>未配置设备</h3><p>点击上方“添加设备地址”即可新增 CPE / SimAdmin 地址。</p></section>'
return return
} }
const list = filteredInstances() const list = filteredInstances()
@@ -120,6 +145,10 @@ function renderInstances() {
</div> </div>
<span class="status-pill ${pillClass(s)}">${escapeHtml(statusText(s))}</span> <span class="status-pill ${pillClass(s)}">${escapeHtml(statusText(s))}</span>
</div> </div>
<div class="instance-card-actions">
<button type="button" class="mini-action" data-edit-id="${escapeHtml(item.id)}">编辑</button>
<button type="button" class="mini-action danger" data-delete-id="${escapeHtml(item.id)}">删除</button>
</div>
${item.description ? `<p class="instance-desc">${escapeHtml(item.description)}</p>` : ''} ${item.description ? `<p class="instance-desc">${escapeHtml(item.description)}</p>` : ''}
<div class="mini-grid"> <div class="mini-grid">
<span>认证<b>${item.auth?.hasPassword ? '密码会话' : '无密码'}</b></span> <span>认证<b>${item.auth?.hasPassword ? '密码会话' : '无密码'}</b></span>
@@ -129,7 +158,12 @@ function renderInstances() {
</div> </div>
</article>` </article>`
}).join('') }).join('')
$$('.instance-card', root).forEach(card => card.addEventListener('click', () => selectInstance(card.dataset.id))) $$('.instance-card', root).forEach(card => card.addEventListener('click', (event) => {
if (event.target.closest('button')) return
selectInstance(card.dataset.id)
}))
$$('[data-edit-id]', root).forEach(btn => btn.addEventListener('click', () => openInstanceDialog(btn.dataset.editId)))
$$('[data-delete-id]', root).forEach(btn => btn.addEventListener('click', () => deleteInstance(btn.dataset.deleteId)))
} }
function renderHero() { function renderHero() {
@@ -248,7 +282,63 @@ function selectInstance(id) {
renderFeatures() renderFeatures()
} }
async function loadConfig() { function openInstanceDialog(id = '') {
const item = id ? instances.find(x => x.id === id) : null
$('instanceOriginalId').value = item?.id || ''
$('instanceFormRibbon').textContent = item ? '编辑设备地址' : '添加设备地址'
$('instanceIdInput').value = item?.id || ''
$('instanceNameInput').value = item?.name || ''
$('instanceUrlInput').value = item?.url || ''
$('instanceDescInput').value = item?.description || ''
$('instanceTagsInput').value = (item?.tags || []).join(', ')
$('instancePasswordInput').value = ''
$('instancePasswordInput').placeholder = item?.auth?.hasPassword ? '留空将清除已保存密码;输入新密码可替换' : '留空表示无密码'
$('deleteInstanceBtn').hidden = !item
$('instanceDialog').showModal()
setTimeout(() => (item ? $('instanceNameInput') : $('instanceIdInput')).focus(), 50)
}
async function reloadConfigAndRefresh({ selectId = activeId, toast = '' } = {}) {
await loadConfig({ preserveSelection: selectId })
await refreshStatus({ silent: true })
if (selectId && instances.some(x => x.id === selectId)) selectInstance(selectId)
else setActiveHome()
if (toast) showToast(toast)
}
async function saveInstanceFromForm() {
const originalId = $('instanceOriginalId').value
const payload = instancePayloadFromForm()
const method = originalId ? 'PUT' : 'POST'
const url = originalId ? `/api/instances/${encodeURIComponent(originalId)}` : '/api/instances'
const res = await fetch(url, { method, headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload) })
const data = await res.json().catch(() => ({}))
if (!res.ok) throw new Error(data.error || `保存失败:HTTP ${res.status}`)
$('instanceDialog').close()
await reloadConfigAndRefresh({ selectId: data.instance?.id || payload.id, toast: originalId ? '设备地址已更新' : '设备地址已添加' })
}
async function deleteInstance(id) {
const item = instances.find(x => x.id === id)
if (!item) return
if (!confirm(`确定删除设备“${item.name || item.id}”?\n配置会立即写入 config.json。`)) return
const res = await fetch(`/api/instances/${encodeURIComponent(id)}`, { method: 'DELETE' })
const data = await res.json().catch(() => ({}))
if (!res.ok) {
showToast(data.error || `删除失败:HTTP ${res.status}`, 4200)
return
}
await reloadConfigAndRefresh({ selectId: null, toast: '设备地址已删除' })
}
async function deleteCurrentInstanceFromDialog() {
const id = $('instanceOriginalId').value
if (!id) return
$('instanceDialog').close()
await deleteInstance(id)
}
async function loadConfig({ preserveSelection = activeId } = {}) {
const [configRes, catalogRes] = await Promise.all([fetch('/api/config'), fetch('/api/catalog')]) const [configRes, catalogRes] = await Promise.all([fetch('/api/config'), fetch('/api/catalog')])
if (!configRes.ok) throw new Error(`配置读取失败:HTTP ${configRes.status}`) if (!configRes.ok) throw new Error(`配置读取失败:HTTP ${configRes.status}`)
const data = await configRes.json() const data = await configRes.json()
@@ -256,8 +346,8 @@ async function loadConfig() {
configPath = data.configPath || 'config.json' configPath = data.configPath || 'config.json'
$('configPath').textContent = configPath $('configPath').textContent = configPath
if (catalogRes.ok) catalog = (await catalogRes.json()).groups || [] if (catalogRes.ok) catalog = (await catalogRes.json()).groups || []
const saved = localStorage.getItem('multi-simadmin-active') const saved = preserveSelection || localStorage.getItem('multi-simadmin-active')
if (saved && instances.some(x => x.id === saved)) activeId = saved activeId = saved && instances.some(x => x.id === saved) ? saved : null
renderEndpointList() renderEndpointList()
renderInstances() renderInstances()
renderOverview() renderOverview()
@@ -340,6 +430,8 @@ async function runEndpoint() {
function bindEvents() { function bindEvents() {
$('refreshBtn').addEventListener('click', () => refreshStatus()) $('refreshBtn').addEventListener('click', () => refreshStatus())
$('homeBtn').addEventListener('click', setActiveHome)
$('addInstanceBtn').addEventListener('click', () => openInstanceDialog())
$('loginBtn').addEventListener('click', loginActive) $('loginBtn').addEventListener('click', loginActive)
$('runEndpoint').addEventListener('click', runEndpoint) $('runEndpoint').addEventListener('click', runEndpoint)
$('instanceSearch').addEventListener('input', renderInstances) $('instanceSearch').addEventListener('input', renderInstances)
@@ -364,6 +456,15 @@ function bindEvents() {
else showToast('会话已刷新') else showToast('会话已刷新')
await refreshStatus({ silent: true }) await refreshStatus({ silent: true })
}) })
$('instanceForm').addEventListener('submit', async (event) => {
event.preventDefault()
try {
await saveInstanceFromForm()
} catch (error) {
showToast(error.message || '保存失败', 5200)
}
})
$('deleteInstanceBtn').addEventListener('click', deleteCurrentInstanceFromDialog)
$('endpointInput').addEventListener('keydown', (event) => { $('endpointInput').addEventListener('keydown', (event) => {
if (event.key === 'Enter') runEndpoint() if (event.key === 'Enter') runEndpoint()
}) })
+23
View File
@@ -29,7 +29,11 @@
<span class="ribbon-text">SimAdmin 岛屿</span> <span class="ribbon-text">SimAdmin 岛屿</span>
</div> </div>
<p class="sidebar-subtitle">像 NookPhone 一样管理每个 CPE 小岛</p> <p class="sidebar-subtitle">像 NookPhone 一样管理每个 CPE 小岛</p>
<div class="sidebar-actions">
<button id="homeBtn" class="animal-btn default-btn" type="button">返回首页</button>
<button id="refreshBtn" class="animal-btn primary-btn" type="button">刷新状态</button> <button id="refreshBtn" class="animal-btn primary-btn" type="button">刷新状态</button>
</div>
<button id="addInstanceBtn" class="animal-btn primary-btn block-btn" type="button"> 添加设备地址</button>
</header> </header>
<section class="wallet-row" id="fleetStats"></section> <section class="wallet-row" id="fleetStats"></section>
@@ -137,6 +141,25 @@
</form> </form>
</dialog> </dialog>
<dialog id="instanceDialog">
<form method="dialog" id="instanceForm" class="instance-form island-card pattern-default">
<div id="instanceFormRibbon" class="section-ribbon color-app-green">添加设备地址</div>
<p class="form-help">配置会立即写入本地 config.json,并更新左侧设备列表。</p>
<input type="hidden" id="instanceOriginalId" />
<label>设备 ID <input id="instanceIdInput" required pattern="[a-zA-Z0-9_.-]+" placeholder="home-cpe" /></label>
<label>显示名称 <input id="instanceNameInput" required placeholder="客厅 CPE" /></label>
<label>设备地址 <input id="instanceUrlInput" required type="url" placeholder="http://192.168.1.1" /></label>
<label>描述 <textarea id="instanceDescInput" placeholder="位置、用途或备注"></textarea></label>
<label>标签 <input id="instanceTagsInput" placeholder="home, 5g, backup" /></label>
<label>管理员密码 <input id="instancePasswordInput" type="password" placeholder="留空表示无密码或清除已保存密码" autocomplete="new-password" /></label>
<menu class="form-menu">
<button value="cancel" class="animal-btn default-btn">取消</button>
<button id="deleteInstanceBtn" type="button" class="animal-btn danger-btn" hidden>删除</button>
<button id="saveInstanceBtn" value="default" class="animal-btn primary-btn">保存</button>
</menu>
</form>
</dialog>
<div id="toast" class="toast" hidden></div> <div id="toast" class="toast" hidden></div>
<script src="/app.js" type="module"></script> <script src="/app.js" type="module"></script>
</body> </body>
+4 -4
View File
File diff suppressed because one or more lines are too long
+57 -1
View File
@@ -1,5 +1,5 @@
import { readFile } from 'node:fs/promises' import { readFile, writeFile } from 'node:fs/promises'
import { existsSync } from 'node:fs' import { existsSync } from 'node:fs'
import path from 'node:path' import path from 'node:path'
@@ -47,6 +47,7 @@ export async function loadConfig({ configPath, defaultConfigPath }) {
} }
return { return {
configPath: resolvedPath, configPath: resolvedPath,
raw: { ...raw, instances },
server: { server: {
host: raw.server?.host || process.env.HOST || '0.0.0.0', host: raw.server?.host || process.env.HOST || '0.0.0.0',
port: Number(process.env.PORT || raw.server?.port || 8788), port: Number(process.env.PORT || raw.server?.port || 8788),
@@ -55,6 +56,61 @@ export async function loadConfig({ configPath, defaultConfigPath }) {
} }
} }
function serializableInstance(instance) {
return {
id: instance.id,
name: instance.name,
url: instance.url,
description: instance.description || '',
tags: instance.tags || [],
auth: instance.auth?.password ? { mode: 'password', password: instance.auth.password } : { mode: 'none' },
capabilities: instance.capabilities || [],
}
}
async function persistConfig(config) {
const raw = {
...(config.raw || {}),
server: config.raw?.server || config.server,
instances: config.instances.map(serializableInstance),
}
await writeFile(config.configPath, `${JSON.stringify(raw, null, 2)}\n`)
config.raw = raw
}
export async function addInstanceToConfig(config, payload) {
const next = normalizeInstance(payload, config.instances.length)
if (config.instances.some(item => item.id === next.id)) throw new Error(`duplicate instance id: ${next.id}`)
config.instances.push(next)
await persistConfig(config)
return next
}
export async function updateInstanceInConfig(config, id, payload) {
const index = config.instances.findIndex(item => item.id === id)
if (index < 0) throw new Error(`instance not found: ${id}`)
const current = config.instances[index]
const merged = {
...current,
...payload,
id: payload.id ? String(payload.id).trim() : current.id,
auth: payload.auth !== undefined || payload.password !== undefined ? payload.auth || { password: payload.password || '' } : current.auth,
}
const next = normalizeInstance(merged, index)
if (next.id !== id && config.instances.some(item => item.id === next.id)) throw new Error(`duplicate instance id: ${next.id}`)
config.instances[index] = next
await persistConfig(config)
return next
}
export async function deleteInstanceFromConfig(config, id) {
const index = config.instances.findIndex(item => item.id === id)
if (index < 0) throw new Error(`instance not found: ${id}`)
const [removed] = config.instances.splice(index, 1)
await persistConfig(config)
return removed
}
export function redactInstance(instance) { export function redactInstance(instance) {
return { return {
id: instance.id, id: instance.id,
+46
View File
@@ -3,11 +3,15 @@ import fastifyStatic from '@fastify/static'
import path from 'node:path' import path from 'node:path'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import { import {
addInstanceToConfig,
buildClients, buildClients,
collectInstanceStatus, collectInstanceStatus,
createSimAdminClient,
deleteInstanceFromConfig,
loadConfig, loadConfig,
proxyToInstance, proxyToInstance,
redactInstance, redactInstance,
updateInstanceInConfig,
} from './core.js' } from './core.js'
const __filename = fileURLToPath(import.meta.url) const __filename = fileURLToPath(import.meta.url)
@@ -29,6 +33,16 @@ function clientById(id, reply) {
return client return client
} }
function sendConfigError(reply, error) {
const message = String(error?.message || error)
const status = /not found/.test(message) ? 404 : /duplicate|required|invalid|URL/.test(message) ? 400 : 500
return reply.code(status).send({ error: message })
}
function refreshClientFor(instance) {
clients.set(instance.id, createSimAdminClient(instance))
}
app.get('/api/config', async () => ({ app.get('/api/config', async () => ({
configPath: config.configPath, configPath: config.configPath,
instances: config.instances.map(redactInstance), instances: config.instances.map(redactInstance),
@@ -43,6 +57,38 @@ app.get('/api/config', async () => ({
app.get('/api/instances', async () => ({ instances: config.instances.map(redactInstance) })) app.get('/api/instances', async () => ({ instances: config.instances.map(redactInstance) }))
app.post('/api/instances', async (request, reply) => {
try {
const instance = await addInstanceToConfig(config, request.body || {})
refreshClientFor(instance)
return { instance: redactInstance(instance), configPath: config.configPath }
} catch (error) {
return sendConfigError(reply, error)
}
})
app.put('/api/instances/:id', async (request, reply) => {
try {
const oldId = request.params.id
const instance = await updateInstanceInConfig(config, oldId, request.body || {})
if (instance.id !== oldId) clients.delete(oldId)
refreshClientFor(instance)
return { instance: redactInstance(instance), configPath: config.configPath }
} catch (error) {
return sendConfigError(reply, error)
}
})
app.delete('/api/instances/:id', async (request, reply) => {
try {
const removed = await deleteInstanceFromConfig(config, request.params.id)
clients.delete(removed.id)
return { removed: redactInstance(removed), configPath: config.configPath }
} catch (error) {
return sendConfigError(reply, error)
}
})
app.get('/api/status', async () => ({ app.get('/api/status', async () => ({
instances: await Promise.all([...clients.values()].map(collectInstanceStatus)), instances: await Promise.all([...clients.values()].map(collectInstanceStatus)),
})) }))
+29 -1
View File
@@ -1,7 +1,10 @@
import test from 'node:test' import test from 'node:test'
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import { createSimAdminClient, normalizeInstance, summarizeInstanceSnapshot, redactInstance } from '../server/core.js' import { mkdtemp, readFile, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { createSimAdminClient, normalizeInstance, summarizeInstanceSnapshot, redactInstance, loadConfig, addInstanceToConfig, updateInstanceInConfig, deleteInstanceFromConfig } from '../server/core.js'
test('normalizeInstance supports passwordless and password-protected instances without exposing password', () => { test('normalizeInstance supports passwordless and password-protected instances without exposing password', () => {
const open = normalizeInstance({ id: 'open-1', name: '开放设备', url: 'http://127.0.0.1:3000/' }, 0) const open = normalizeInstance({ id: 'open-1', name: '开放设备', url: 'http://127.0.0.1:3000/' }, 0)
@@ -54,3 +57,28 @@ test('snapshot summary merges status from device, sim, network, sms and system e
assert.equal(snapshot.data.connected, true) assert.equal(snapshot.data.connected, true)
assert.equal(snapshot.ota.currentVersion, '1.2.3') assert.equal(snapshot.ota.currentVersion, '1.2.3')
}) })
test('config store can add update and delete instances while persisting json', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'multi-simadmin-'))
const configPath = path.join(dir, 'config.json')
await writeFile(configPath, JSON.stringify({ server: { host: '127.0.0.1', port: 9999 }, instances: [] }, null, 2))
const config = await loadConfig({ configPath, defaultConfigPath: configPath })
const added = await addInstanceToConfig(config, { id: 'home-cpe', name: '家里 CPE', url: 'http://192.168.1.1/', auth: { password: 'pw' }, tags: ['home'] })
assert.equal(added.id, 'home-cpe')
assert.equal(added.url, 'http://192.168.1.1')
assert.equal(config.instances.length, 1)
const updated = await updateInstanceInConfig(config, 'home-cpe', { name: '客厅 CPE', url: 'http://192.168.1.2', auth: { mode: 'none' }, description: 'main router' })
assert.equal(updated.name, '客厅 CPE')
assert.equal(updated.auth.password, '')
const persisted = JSON.parse(await readFile(configPath, 'utf8'))
assert.equal(persisted.instances[0].name, '客厅 CPE')
assert.equal(persisted.instances[0].url, 'http://192.168.1.2')
const removed = await deleteInstanceFromConfig(config, 'home-cpe')
assert.equal(removed.id, 'home-cpe')
assert.equal(config.instances.length, 0)
assert.deepEqual(JSON.parse(await readFile(configPath, 'utf8')).instances, [])
})