feat: 补齐第一批已知缺口(关闭握手 / 定时备份 / Origin 白名单 / DTO 收敛)
- httpapi:新增 POST /api/shutdown(admin 会话或 X-Shutdown-Token 常量时间比对) 与 GET /api/rules/switches(recorder 可读,只回布尔开关);settings/standings 的 重复 DTO 收敛到 internal/dto;standings.showIndividualMedals 贯通到设置读写与前端 - export:抽出 export.Service,新增自动备份定时任务(backup.intervalMinutes/keep), 自动、手动、发布前三类备份共用同一把互斥锁与滚动清理 - realtime:/ws 去掉 InsecureSkipVerify,改用 OriginPatterns(同源与无 Origin 放行, 跨站需命中 server.allowedOrigins) - console:停止服务改走 POST /api/shutdown 优雅关闭(5 秒宽限后回退强杀), 服务管理器新增「已连接的大屏实例」卡片 - deploy:build.bat 改用 for /f 读内部署名——set /p 在 chcp 65001 下返回空, 导致最近这次构建的 exe 里根本没注入署名 - docs:备份策略、关闭超时、Origin 配置的说明与实现对齐
This commit is contained in:
1 parent
4d56c73ae6
commit
2c9ff65fc4
42 files changed
+1271
-275
No files matched your search
+8
-5
@@ -1,12 +1,13 @@
|
||||
# Scoreboard Console
|
||||
|
||||
赛事积分榜系统的桌面宿主(Electron)。它只做五件事:
|
||||
赛事积分榜系统的桌面宿主(Electron)。它只做六件事:
|
||||
|
||||
1. 拉起并守护同目录(或配置指定)的 `scoreboard.exe`,把它的 stdout/stderr 接管到管理窗口与日志文件;
|
||||
2. 用 `screen.getAllDisplays()` 枚举显示器,把大屏页面投到扩展屏;
|
||||
3. `powerSaveBlocker.start('prevent-display-sleep')` 防熄屏;
|
||||
4. 提供启动/停止/状态/日志的管理 GUI 和托盘菜单;
|
||||
5. 打包后随系统启动,现场断电重启后无需人工干预。
|
||||
5. 打包后随系统启动,现场断电重启后无需人工干预;
|
||||
6. 在服务管理窗口展示服务端在线的大屏实例(`GET /api/display/instances`)。
|
||||
|
||||
规范来源:`devdocs/06-大屏端设计.md` 第 6 节、`devdocs/10-部署与现场方案.md` 第 4 节。
|
||||
|
||||
@@ -20,6 +21,7 @@ console/
|
||||
│ ├── index.js # 应用入口:窗口 / 托盘 / IPC / 生命周期
|
||||
│ ├── config.js # 配置加载与合并、exe 路径解析
|
||||
│ ├── service.js # 服务端子进程守护:spawn / 日志转发 / 健康检查 / 自动重启
|
||||
│ ├── instances.js # 已连接大屏实例看板:复用管理面板登录态读 /api/display/instances
|
||||
│ ├── account.js # 管理员账号重置:参数校验 + 调 scoreboard.exe passwd
|
||||
│ ├── displays.js # 大屏窗口管理:选屏、kiosk、排布、24h 重载
|
||||
│ ├── log-bus.js # 日志环形缓冲 + 落文件 + 广播
|
||||
@@ -77,7 +79,8 @@ scoreboard-windows-amd64/
|
||||
| 防熄屏 | Console 启动即生效,退出时释放;前台后台都算 |
|
||||
| 服务端就绪 | 首次 `/api/health` 通过后开大屏窗口;等待超过 `display.waitForServerMs` 也先开,避免现场只见桌面 |
|
||||
| 服务端崩溃 | 按 `restartDelayMs` 递增退避自动重启(`maxRestarts=0` 为无限);持续健康 `healthyResetMs` 后崩溃计数清零;恢复健康后自动重载大屏 |
|
||||
| 停止服务 | 先 `POST server.shutdownPath` 请求服务端优雅关闭,`shutdownTimeoutMs` 内未退出再强杀;接口不可用时回退进程信号 |
|
||||
| 停止服务 | 先 `POST server.shutdownPath` 并带 `X-Shutdown-Token`(启动时用 `crypto.randomBytes(32)` 生成,经环境变量 `SCOREBOARD_SHUTDOWN_TOKEN` 只交给该子进程)请求服务端优雅关闭;受理后最多等 `shutdownTimeoutMs`,超时或接口不可用(404/连接失败)再强杀 |
|
||||
| 已连接大屏实例 | 管理窗口打开时每 5 秒读一次服务端 `GET /api/display/instances`(复用管理面板的 `scoreboard_session` Cookie,需记录员及以上),显示实例数、页面名与在线状态;窗口隐藏即停表,未登录/服务未启动时显示占位文案 |
|
||||
| 关管理窗口 | 只隐藏到托盘,服务端继续运行;退出必须走托盘「退出」 |
|
||||
| 开机自启 | 打包后在 Windows/macOS 上注册(`startup.openAtLogin`,默认开),开发态不注册 |
|
||||
| 窗口外观 | 管理窗口与服务端管理面板窗口都是 `frame: false` 无边框,标题栏与最小化/最大化/关闭三键由页面自绘(拖拽区 `-webkit-app-region: drag`,按钮 `no-drag`,双击标题栏切最大化,关闭键 hover 变红) |
|
||||
@@ -96,8 +99,8 @@ scoreboard-windows-amd64/
|
||||
| `server.healthIntervalMs` | `5000` | 健康检查间隔 |
|
||||
| `server.restartDelayMs` | `3000` | 重启基础退避,每次翻倍,最多 5 倍 |
|
||||
| `server.maxRestarts` | `0` | 0 = 无限重启 |
|
||||
| `server.shutdownPath` | `/api/shutdown` | 优雅关闭握手接口(需服务端实现),不可用时回退进程信号 |
|
||||
| `server.shutdownTimeoutMs` | `12000` | 等待优雅关闭的上限,超过则强杀 |
|
||||
| `server.shutdownPath` | `/api/shutdown` | 优雅关闭握手接口(需服务端实现并校验 `X-Shutdown-Token`),不可用时回退强杀 |
|
||||
| `server.shutdownTimeoutMs` | `5000` | 服务端受理关闭请求后等待其自行退出的上限,超过则强杀 |
|
||||
| `server.healthyResetMs` | `60000` | 连续健康多久后清零崩溃计数 |
|
||||
| `display.selection` | `all-secondary` | `all-secondary` / `all` / `specified` |
|
||||
| `display.displays` | `[]` | `specified` 时生效,填 display id 或 0 起序号 |
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@
|
||||
"restartDelayMs": 3000,
|
||||
"maxRestarts": 0,
|
||||
"shutdownPath": "/api/shutdown",
|
||||
"shutdownTimeoutMs": 12000,
|
||||
"shutdownTimeoutMs": 5000,
|
||||
"healthyResetMs": 60000
|
||||
},
|
||||
"display": {
|
||||
|
||||
@@ -16,9 +16,11 @@ const DEFAULTS = {
|
||||
restartDelayMs: 3000,
|
||||
// 0 表示无限重启;服务端是现场唯一进程,崩了必须拉起来
|
||||
maxRestarts: 0,
|
||||
// 优雅关闭握手:服务端需实现该回环接口,未实现时回退到进程信号
|
||||
// 优雅关闭握手:服务端需实现该回环接口并校验 X-Shutdown-Token,
|
||||
// 未实现(404/连接失败)时回退到进程信号
|
||||
shutdownPath: '/api/shutdown',
|
||||
shutdownTimeoutMs: 12000,
|
||||
// 服务端受理关闭请求后等待其自行退出的上限,超过则强杀
|
||||
shutdownTimeoutMs: 5000,
|
||||
// 连续健康这么久后清零崩溃计数,避免历史崩溃耗尽 maxRestarts
|
||||
healthyResetMs: 60000
|
||||
},
|
||||
|
||||
@@ -15,6 +15,7 @@ const { loadConfig, resolveExecutable } = require('./config')
|
||||
const { LogBus } = require('./log-bus')
|
||||
const { ServiceSupervisor } = require('./service')
|
||||
const { DisplayManager } = require('./displays')
|
||||
const { fetchDisplayInstances } = require('./instances')
|
||||
const { createTrayIcon } = require('./tray-icon')
|
||||
const { validateAccountField, buildPasswdArgs, runPasswd } = require('./account')
|
||||
|
||||
@@ -39,6 +40,9 @@ function main() {
|
||||
let quitting = false
|
||||
let shutdownDone = false
|
||||
let exePath = null
|
||||
// 大屏实例看板只在管理窗口可见时轮询,窗口隐藏即停表
|
||||
let instancesTimer = null
|
||||
let instancesLastReason = null
|
||||
// passwd 子进程同一时刻只允许一个:并发写同一个 sqlite 库没有意义,也会让输出互相串
|
||||
let accountBusy = false
|
||||
|
||||
@@ -118,7 +122,12 @@ function main() {
|
||||
log.push('console', '管理窗口已最小化到托盘(服务端继续运行)')
|
||||
})
|
||||
|
||||
// 实例看板只在窗口可见时轮询,隐藏/关闭即停表
|
||||
adminWindow.on('show', startInstancesPolling)
|
||||
adminWindow.on('hide', stopInstancesPolling)
|
||||
|
||||
adminWindow.on('closed', () => {
|
||||
stopInstancesPolling()
|
||||
adminWindow = null
|
||||
})
|
||||
}
|
||||
@@ -130,6 +139,48 @@ function main() {
|
||||
return `http://${host || '127.0.0.1'}:${cfg.values.server.port}/admin`
|
||||
}
|
||||
|
||||
function serverHost() {
|
||||
if (service) return service.healthHost()
|
||||
const host = cfg.values.server.host || '127.0.0.1'
|
||||
return host === '0.0.0.0' || host === '::' ? '127.0.0.1' : host
|
||||
}
|
||||
|
||||
// 大屏实例看板:读服务端 GET /api/display/instances 并推给管理窗口。
|
||||
// 失败只按状态变化记一次日志,避免未登录/服务未启动时刷屏。
|
||||
async function pollDisplayInstances() {
|
||||
let result
|
||||
try {
|
||||
result = await fetchDisplayInstances({ host: serverHost(), port: cfg.values.server.port })
|
||||
} catch (err) {
|
||||
result = { ok: false, reason: 'error', message: `读取大屏实例失败:${err.message}` }
|
||||
}
|
||||
|
||||
if (result.ok) {
|
||||
if (instancesLastReason) {
|
||||
instancesLastReason = null
|
||||
log.push('console', `大屏实例列表恢复读取(${result.instances.length} 个实例)`)
|
||||
}
|
||||
} else if (result.reason !== instancesLastReason) {
|
||||
instancesLastReason = result.reason
|
||||
log.push('console', `读取大屏实例失败:${result.message}`)
|
||||
}
|
||||
|
||||
sendToAdmin('display:instances', result)
|
||||
return result
|
||||
}
|
||||
|
||||
function startInstancesPolling() {
|
||||
if (instancesTimer) return
|
||||
pollDisplayInstances()
|
||||
instancesTimer = setInterval(pollDisplayInstances, 5000)
|
||||
}
|
||||
|
||||
function stopInstancesPolling() {
|
||||
if (!instancesTimer) return
|
||||
clearInterval(instancesTimer)
|
||||
instancesTimer = null
|
||||
}
|
||||
|
||||
function showServerAdmin() {
|
||||
if (!serverAdminWindow || serverAdminWindow.isDestroyed()) {
|
||||
createServerAdminWindow()
|
||||
@@ -352,6 +403,7 @@ function main() {
|
||||
showServerAdmin()
|
||||
return true
|
||||
})
|
||||
ipcMain.handle('display:instances-get', () => pollDisplayInstances())
|
||||
|
||||
// 窗口控制统一从 event.sender 反查窗口,多个窗口共用同一套通道
|
||||
ipcMain.handle('window:minimize', (event) => {
|
||||
@@ -476,6 +528,7 @@ function main() {
|
||||
event.preventDefault()
|
||||
|
||||
const finish = () => {
|
||||
stopInstancesPolling()
|
||||
if (displays) displays.closeAll()
|
||||
if (powerBlockerId !== null) powerSaveBlocker.stop(powerBlockerId)
|
||||
if (log) log.close()
|
||||
@@ -491,6 +544,7 @@ function main() {
|
||||
})
|
||||
|
||||
app.on('quit', () => {
|
||||
stopInstancesPolling()
|
||||
if (service) service.dispose()
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
'use strict'
|
||||
|
||||
const http = require('http')
|
||||
const { session } = require('electron')
|
||||
|
||||
// 大屏实例看板:复用服务端管理面板的登录态,不另造一套登录逻辑。
|
||||
// 管理面板窗口与 Console 同属默认 session,登录后 scoreboard_session 就存在这里,
|
||||
// 主进程取同一张 Cookie 访问 GET /api/display/instances(记录员及以上可读)。
|
||||
// Cookie 名与服务端 server/internal/auth/auth.go 的 CookieSession 一致。
|
||||
const SESSION_COOKIE = 'scoreboard_session'
|
||||
const REQUEST_TIMEOUT_MS = 3000
|
||||
|
||||
function requestJSON({ host, port, path, cookie }) {
|
||||
return new Promise((resolve) => {
|
||||
const request = http.get(
|
||||
{
|
||||
host,
|
||||
port,
|
||||
path,
|
||||
timeout: REQUEST_TIMEOUT_MS,
|
||||
headers: {
|
||||
Cookie: cookie,
|
||||
Accept: 'application/json',
|
||||
Connection: 'close'
|
||||
}
|
||||
},
|
||||
(response) => {
|
||||
const chunks = []
|
||||
response.on('data', (chunk) => chunks.push(chunk))
|
||||
response.on('end', () => {
|
||||
resolve({ status: response.statusCode, body: Buffer.concat(chunks).toString('utf8') })
|
||||
})
|
||||
}
|
||||
)
|
||||
request.on('timeout', () => request.destroy(new Error('请求超时')))
|
||||
request.on('error', (err) => resolve({ error: err }))
|
||||
})
|
||||
}
|
||||
|
||||
async function readSessionCookie(url) {
|
||||
try {
|
||||
const cookies = await session.defaultSession.cookies.get({ url })
|
||||
const hit = cookies.find((item) => item.name === SESSION_COOKIE)
|
||||
return hit ? `${hit.name}=${hit.value}` : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// 成功返回 { ok: true, instances };失败返回 { ok: false, reason, message },
|
||||
// message 直接作为界面占位文案,reason 供调用方决定要不要记日志(避免失败时刷屏)。
|
||||
async function fetchDisplayInstances({ host, port }) {
|
||||
const cookie = await readSessionCookie(`http://${host}:${port}/`)
|
||||
if (!cookie) {
|
||||
return { ok: false, reason: 'unauthenticated', message: '未登录,请先在服务端管理面板登录(记录员及以上)' }
|
||||
}
|
||||
|
||||
const result = await requestJSON({ host, port, path: '/api/display/instances', cookie })
|
||||
|
||||
if (result.error) {
|
||||
if (result.error.code === 'ECONNREFUSED' || result.error.code === 'ECONNRESET') {
|
||||
return { ok: false, reason: 'offline', message: '服务端未运行,暂时无法读取大屏实例' }
|
||||
}
|
||||
return { ok: false, reason: 'error', message: `读取大屏实例失败:${result.error.message}` }
|
||||
}
|
||||
if (result.status === 401) {
|
||||
return { ok: false, reason: 'unauthenticated', message: '登录已失效,请重新登录服务端管理面板' }
|
||||
}
|
||||
if (result.status === 403) {
|
||||
return { ok: false, reason: 'forbidden', message: '当前账号权限不足(需要记录员及以上)' }
|
||||
}
|
||||
if (result.status === 404) {
|
||||
return { ok: false, reason: 'unsupported', message: '服务端版本较旧,未提供大屏实例接口' }
|
||||
}
|
||||
if (result.status !== 200) {
|
||||
return { ok: false, reason: 'error', message: `读取大屏实例失败:HTTP ${result.status}` }
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(result.body)
|
||||
return { ok: true, instances: Array.isArray(payload.data) ? payload.data : [] }
|
||||
} catch (err) {
|
||||
return { ok: false, reason: 'error', message: `大屏实例响应无法解析:${err.message}` }
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { fetchDisplayInstances }
|
||||
+39
-19
@@ -1,5 +1,6 @@
|
||||
'use strict'
|
||||
|
||||
const crypto = require('crypto')
|
||||
const fs = require('fs')
|
||||
const http = require('http')
|
||||
const path = require('path')
|
||||
@@ -14,12 +15,14 @@ const STATE_LABEL = {
|
||||
crashed: '异常退出'
|
||||
}
|
||||
|
||||
// 优雅关闭握手:POST server.shutdownPath,让服务端自己走 signal.NotifyContext
|
||||
// 的关闭流程;请求失败或接口未实现时回退到进程信号。
|
||||
// 优雅关闭握手:POST server.shutdownPath 并带上启动时下发的 token,
|
||||
// 让服务端自己走 signal.NotifyContext 的关闭流程;请求失败或接口未实现时回退到强杀。
|
||||
const DEFAULT_SHUTDOWN_PATH = '/api/shutdown'
|
||||
const SHUTDOWN_REQUEST_TIMEOUT_MS = 2000
|
||||
// 服务端 srv.Shutdown 的超时是 10s,客户端必须留足余量再强杀
|
||||
const DEFAULT_SHUTDOWN_TIMEOUT_MS = 12000
|
||||
const SHUTDOWN_TOKEN_ENV = 'SCOREBOARD_SHUTDOWN_TOKEN'
|
||||
const SHUTDOWN_TOKEN_HEADER = 'X-Shutdown-Token'
|
||||
const SHUTDOWN_REQUEST_TIMEOUT_MS = 3000
|
||||
// 服务端收到关闭请求后承诺 3 秒内退出,客户端留 5 秒余量再强杀
|
||||
const DEFAULT_SHUTDOWN_TIMEOUT_MS = 5000
|
||||
// 握手不可用时信号已能结束进程,不必再等满超时
|
||||
const FALLBACK_FORCE_KILL_MS = 1500
|
||||
// 连续健康这么久才认为崩溃计数可以清零
|
||||
@@ -48,6 +51,10 @@ class ServiceSupervisor extends EventEmitter {
|
||||
this.healthTimer = null
|
||||
this.restartTimer = null
|
||||
this.forceKillTimer = null
|
||||
// 本次进程的关闭握手 token 与握手结果,仅用于日志区分「优雅退出 / 被强杀」
|
||||
this.shutdownToken = null
|
||||
this.gracefulAccepted = false
|
||||
this.forcedKill = false
|
||||
}
|
||||
|
||||
get status() {
|
||||
@@ -103,16 +110,23 @@ class ServiceSupervisor extends EventEmitter {
|
||||
|
||||
this.lastError = null
|
||||
this.healthySince = null
|
||||
this.gracefulAccepted = false
|
||||
this.forcedKill = false
|
||||
this.startedAt = new Date().toISOString()
|
||||
this.setState('starting')
|
||||
this.log.push('console', `启动服务端:${this.exePath} ${args.join(' ')}(cwd=${cwd})`)
|
||||
|
||||
// 每次拉起都换一把新 token,只通过环境变量交给这一个子进程,
|
||||
// 停止时凭它调用服务端的 /api/shutdown
|
||||
this.shutdownToken = crypto.randomBytes(32).toString('hex')
|
||||
|
||||
let child
|
||||
try {
|
||||
child = spawn(this.exePath, args, {
|
||||
cwd,
|
||||
windowsHide: true,
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: { ...process.env, [SHUTDOWN_TOKEN_ENV]: this.shutdownToken }
|
||||
})
|
||||
} catch (err) {
|
||||
this.setState('crashed', { lastError: `启动失败:${err.message}` })
|
||||
@@ -157,7 +171,10 @@ class ServiceSupervisor extends EventEmitter {
|
||||
|
||||
const reason = signal ? `信号 ${signal}` : `退出码 ${code}`
|
||||
if (this.stopRequested) {
|
||||
this.log.push('console', `服务端已停止(${reason})`)
|
||||
const how = this.forcedKill ? '已被强制结束' : this.gracefulAccepted ? '已优雅退出' : '已停止'
|
||||
this.log.push('console', `服务端${how}(${reason})`)
|
||||
this.gracefulAccepted = false
|
||||
this.forcedKill = false
|
||||
this.setState('stopped', { lastError: null })
|
||||
return
|
||||
}
|
||||
@@ -198,24 +215,25 @@ class ServiceSupervisor extends EventEmitter {
|
||||
|
||||
this.stopRequested = true
|
||||
this.setState('stopping')
|
||||
this.log.push('console', '正在停止服务端…')
|
||||
this.log.push('console', '正在优雅关闭服务端…')
|
||||
|
||||
child.once('exit', () => resolve())
|
||||
|
||||
const timeoutMs = Number(this.server.shutdownTimeoutMs) || DEFAULT_SHUTDOWN_TIMEOUT_MS
|
||||
// 先按最坏情况挂强杀兜底,握手成功后也不撤:服务端可能卡在关闭流程里
|
||||
this.armForceKill(timeoutMs)
|
||||
const graceMs = Number(this.server.shutdownTimeoutMs) || DEFAULT_SHUTDOWN_TIMEOUT_MS
|
||||
|
||||
this.requestShutdown().then((accepted) => {
|
||||
// 请求期间进程可能已经退了,handleExit 会清掉兜底定时器
|
||||
if (this.child !== child) return
|
||||
if (accepted) {
|
||||
this.log.push('console', `服务端已接受关闭请求,最多等待 ${Math.round(timeoutMs / 1000)} 秒`)
|
||||
this.gracefulAccepted = true
|
||||
this.log.push('console', `服务端已接受优雅关闭请求,最多等待 ${Math.round(graceMs / 1000)} 秒自行退出`)
|
||||
this.armForceKill(graceMs)
|
||||
return
|
||||
}
|
||||
// 握手不可用:服务端还没实现该接口,或进程已无响应。
|
||||
// 握手不可用:服务端还是老版本没有该接口,或进程已无响应。
|
||||
// 退回进程信号;Windows 上 Node 的 kill() 等同强杀,所以这条路只是兜底,
|
||||
// 真正的优雅关闭依赖服务端补上 shutdownPath 接口。
|
||||
this.log.push('console', '关闭接口不可用,退回进程信号')
|
||||
// 真正的优雅关闭依赖服务端实现 shutdownPath 接口。
|
||||
this.log.push('console', '优雅关闭接口不可用,退回强制结束')
|
||||
this.armForceKill(FALLBACK_FORCE_KILL_MS)
|
||||
try {
|
||||
child.kill()
|
||||
@@ -226,9 +244,9 @@ class ServiceSupervisor extends EventEmitter {
|
||||
})
|
||||
}
|
||||
|
||||
// 优雅关闭握手:让服务端自己走 signal.NotifyContext → srv.Shutdown 的流程,
|
||||
// 10s 内落盘退出。接口按回环地址访问、不鉴权,只在本机有意义。
|
||||
// 依赖:服务端实现 POST <server.shutdownPath>(2xx 表示已受理)。
|
||||
// 优雅关闭握手:带启动时下发的 X-Shutdown-Token 请求服务端退出,
|
||||
// 2xx 表示已受理。老版本服务端没有该接口(404)或连不上时返回 false,
|
||||
// 由调用方回退到强杀。
|
||||
requestShutdown() {
|
||||
return new Promise((resolve) => {
|
||||
const body = Buffer.from('{}')
|
||||
@@ -242,13 +260,14 @@ class ServiceSupervisor extends EventEmitter {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': body.length,
|
||||
[SHUTDOWN_TOKEN_HEADER]: this.shutdownToken || '',
|
||||
Connection: 'close'
|
||||
}
|
||||
},
|
||||
(response) => {
|
||||
response.resume()
|
||||
const accepted = response.statusCode >= 200 && response.statusCode < 300
|
||||
if (!accepted) this.log.push('console', `关闭接口返回 HTTP ${response.statusCode}`)
|
||||
if (!accepted) this.log.push('console', `关闭接口返回 HTTP ${response.statusCode}(按老版本处理)`)
|
||||
resolve(accepted)
|
||||
}
|
||||
)
|
||||
@@ -266,6 +285,7 @@ class ServiceSupervisor extends EventEmitter {
|
||||
this.forceKillTimer = setTimeout(() => {
|
||||
this.forceKillTimer = null
|
||||
if (!this.child || !this.pid) return
|
||||
this.forcedKill = true
|
||||
this.log.push('console', `服务端未在 ${Math.round(ms / 1000)} 秒内退出,强制结束`)
|
||||
try {
|
||||
this.child.kill('SIGKILL')
|
||||
|
||||
@@ -13,6 +13,8 @@ const el = {
|
||||
config: document.getElementById('k-config'),
|
||||
configWarning: document.getElementById('config-warning'),
|
||||
displayList: document.getElementById('display-list'),
|
||||
instanceList: document.getElementById('instance-list'),
|
||||
instancesSummary: document.getElementById('instances-summary'),
|
||||
log: document.getElementById('log'),
|
||||
autoscroll: document.getElementById('autoscroll'),
|
||||
btnStart: document.getElementById('btn-start'),
|
||||
@@ -94,6 +96,45 @@ function renderDisplays(displays) {
|
||||
}
|
||||
}
|
||||
|
||||
function renderInstances(payload) {
|
||||
el.instanceList.textContent = ''
|
||||
|
||||
if (!payload || !payload.ok) {
|
||||
el.instancesSummary.textContent = ''
|
||||
const li = document.createElement('li')
|
||||
li.className = 'muted'
|
||||
li.textContent = (payload && payload.message) || '暂时无法读取大屏实例'
|
||||
el.instanceList.append(li)
|
||||
return
|
||||
}
|
||||
|
||||
const list = payload.instances || []
|
||||
el.instancesSummary.textContent = `共 ${list.length} 个 · ${list.filter((item) => item.online).length} 在线`
|
||||
|
||||
if (!list.length) {
|
||||
const li = document.createElement('li')
|
||||
li.className = 'muted'
|
||||
li.textContent = '当前没有大屏实例连接'
|
||||
el.instanceList.append(li)
|
||||
return
|
||||
}
|
||||
|
||||
for (const item of list) {
|
||||
const li = document.createElement('li')
|
||||
const page = document.createElement('span')
|
||||
page.className = 'page'
|
||||
page.textContent = item.page || '未知页面'
|
||||
const id = document.createElement('span')
|
||||
id.className = 'url'
|
||||
id.textContent = item.id || '—'
|
||||
const state = document.createElement('span')
|
||||
state.className = `state ${item.online ? 'online' : 'offline'}`
|
||||
state.textContent = item.online ? '在线' : '离线'
|
||||
li.append(page, id, state)
|
||||
el.instanceList.append(li)
|
||||
}
|
||||
}
|
||||
|
||||
function appendLog(line) {
|
||||
const atBottom = el.log.scrollTop + el.log.clientHeight >= el.log.scrollHeight - 8
|
||||
el.log.textContent += `${line}\n`
|
||||
@@ -233,8 +274,11 @@ async function boot() {
|
||||
window.consoleApi.onStatus(renderStatus)
|
||||
window.consoleApi.onLog(appendLog)
|
||||
window.consoleApi.onAccountOutput(handleAccountOutput)
|
||||
window.consoleApi.onDisplayInstances(renderInstances)
|
||||
setLog(await window.consoleApi.getLog())
|
||||
await refresh()
|
||||
// 主进程只在窗口可见时轮询;这里补一次,保证窗口刚打开就有数据
|
||||
renderInstances(await window.consoleApi.getDisplayInstances())
|
||||
}
|
||||
|
||||
boot()
|
||||
@@ -108,6 +108,20 @@
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card-head">
|
||||
<h2>已连接的大屏实例</h2>
|
||||
<span id="instances-summary" class="muted"></span>
|
||||
</div>
|
||||
<p class="hint">
|
||||
来自服务端 <span class="mono">GET /api/display/instances</span>,每 5 秒刷新一次;
|
||||
沿用服务端管理面板的登录态,需要记录员及以上权限。
|
||||
</p>
|
||||
<ul id="instance-list" class="display-list">
|
||||
<li class="muted">正在读取…</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card-head">
|
||||
<h2>管理员账号</h2>
|
||||
|
||||
@@ -17,6 +17,7 @@ contextBridge.exposeInMainWorld('consoleApi', {
|
||||
getLog: () => ipcRenderer.invoke('log:tail'),
|
||||
clearLog: () => ipcRenderer.invoke('log:clear'),
|
||||
relayoutDisplays: () => ipcRenderer.invoke('display:relayout'),
|
||||
getDisplayInstances: () => ipcRenderer.invoke('display:instances-get'),
|
||||
openServerAdmin: () => ipcRenderer.invoke('app:open-server-admin'),
|
||||
resetAdminAccount: (payload) => ipcRenderer.invoke('account:reset', payload),
|
||||
minimize: () => ipcRenderer.invoke('window:minimize'),
|
||||
@@ -25,6 +26,7 @@ contextBridge.exposeInMainWorld('consoleApi', {
|
||||
isMaximized: () => ipcRenderer.invoke('window:is-maximized'),
|
||||
onMaximizeChange: (callback) => subscribe('window:maximize-changed', callback),
|
||||
onAccountOutput: (callback) => subscribe('account:output', callback),
|
||||
onDisplayInstances: (callback) => subscribe('display:instances', callback),
|
||||
onLog: (callback) => subscribe('log:line', callback),
|
||||
onStatus: (callback) => subscribe('service:status', callback)
|
||||
})
|
||||
@@ -152,8 +152,8 @@ main {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
/* 四张卡片各占一整行;最后一行(运行日志)吃掉剩余高度 */
|
||||
grid-template-rows: auto auto auto minmax(160px, 1fr);
|
||||
/* 五张卡片各占一整行;最后一行(运行日志)吃掉剩余高度 */
|
||||
grid-template-rows: auto auto auto auto minmax(160px, 1fr);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
@@ -326,6 +326,20 @@ h2 {
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.display-list .state {
|
||||
margin-left: auto;
|
||||
flex: none;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.display-list .state.online {
|
||||
color: var(--ok);
|
||||
}
|
||||
|
||||
.display-list .state.offline {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
Reference in new issue
Block a user