feat: add instance management to dashboard
This commit is contained in:
@@ -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
@@ -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()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
File diff suppressed because one or more lines are too long
+57
-1
@@ -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,
|
||||||
|
|||||||
@@ -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)),
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -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, [])
|
||||||
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user