feat: add instance management to dashboard
This commit is contained in:
+106
-5
@@ -31,6 +31,31 @@ function boolText(v) {
|
||||
}
|
||||
function activeInstance() { return instances.find(x => x.id === 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) {
|
||||
if (!s) return 'unknown'
|
||||
if (!s.reachable) return 'offline'
|
||||
@@ -99,7 +124,7 @@ function renderInstances() {
|
||||
const root = $('instances')
|
||||
renderFleetStats()
|
||||
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
|
||||
}
|
||||
const list = filteredInstances()
|
||||
@@ -120,6 +145,10 @@ function renderInstances() {
|
||||
</div>
|
||||
<span class="status-pill ${pillClass(s)}">${escapeHtml(statusText(s))}</span>
|
||||
</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>` : ''}
|
||||
<div class="mini-grid">
|
||||
<span>认证<b>${item.auth?.hasPassword ? '密码会话' : '无密码'}</b></span>
|
||||
@@ -129,7 +158,12 @@ function renderInstances() {
|
||||
</div>
|
||||
</article>`
|
||||
}).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() {
|
||||
@@ -248,7 +282,63 @@ function selectInstance(id) {
|
||||
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')])
|
||||
if (!configRes.ok) throw new Error(`配置读取失败:HTTP ${configRes.status}`)
|
||||
const data = await configRes.json()
|
||||
@@ -256,8 +346,8 @@ async function loadConfig() {
|
||||
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
|
||||
const saved = preserveSelection || localStorage.getItem('multi-simadmin-active')
|
||||
activeId = saved && instances.some(x => x.id === saved) ? saved : null
|
||||
renderEndpointList()
|
||||
renderInstances()
|
||||
renderOverview()
|
||||
@@ -340,6 +430,8 @@ async function runEndpoint() {
|
||||
|
||||
function bindEvents() {
|
||||
$('refreshBtn').addEventListener('click', () => refreshStatus())
|
||||
$('homeBtn').addEventListener('click', setActiveHome)
|
||||
$('addInstanceBtn').addEventListener('click', () => openInstanceDialog())
|
||||
$('loginBtn').addEventListener('click', loginActive)
|
||||
$('runEndpoint').addEventListener('click', runEndpoint)
|
||||
$('instanceSearch').addEventListener('input', renderInstances)
|
||||
@@ -364,6 +456,15 @@ function bindEvents() {
|
||||
else showToast('会话已刷新')
|
||||
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) => {
|
||||
if (event.key === 'Enter') runEndpoint()
|
||||
})
|
||||
|
||||
+24
-1
@@ -29,7 +29,11 @@
|
||||
<span class="ribbon-text">SimAdmin 岛屿</span>
|
||||
</div>
|
||||
<p class="sidebar-subtitle">像 NookPhone 一样管理每个 CPE 小岛</p>
|
||||
<button id="refreshBtn" class="animal-btn primary-btn" type="button">刷新状态</button>
|
||||
<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>
|
||||
</div>
|
||||
<button id="addInstanceBtn" class="animal-btn primary-btn block-btn" type="button">+ 添加设备地址</button>
|
||||
</header>
|
||||
|
||||
<section class="wallet-row" id="fleetStats"></section>
|
||||
@@ -137,6 +141,25 @@
|
||||
</form>
|
||||
</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>
|
||||
<script src="/app.js" type="module"></script>
|
||||
</body>
|
||||
|
||||
+4
-4
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user