const fs = require('fs'); const http = require('http'); const path = require('path'); const CONTENT_TYPES = { '.html': 'text/html; charset=utf-8', '.css': 'text/css; charset=utf-8', '.js': 'application/javascript; charset=utf-8', '.json': 'application/json; charset=utf-8', }; function getDashboardDir(options = {}) { if (options.dashboardDir) { return options.dashboardDir; } if (process.pkg) { return path.join(path.dirname(process.execPath), 'runtime', 'dashboard'); } return path.join(__dirname, 'dashboard'); } function nowIso() { return new Date().toISOString(); } function buildDashboardSnapshot({ devices = [], cache, tcpService, config = {} }) { const connectionMap = new Map(); const cacheMap = cache && typeof cache.getSnapshotMap === 'function' ? cache.getSnapshotMap() : new Map(); const staleDataMs = Number(config.staleDataMs) > 0 ? Number(config.staleDataMs) : 180000; const now = Date.now(); if (tcpService && typeof tcpService.getConnectionSnapshot === 'function') { for (const item of tcpService.getConnectionSnapshot()) { connectionMap.set(item.deviceId, item); } } const deviceSnapshots = devices.map((device) => { const connection = connectionMap.get(device.deviceId) || null; const cacheEntry = cacheMap.get(device.deviceId) || null; const lastUpdateAt = cacheEntry ? cacheEntry.lastUpdateAt : 0; const ageMs = lastUpdateAt > 0 ? now - lastUpdateAt : null; const dataStatus = !lastUpdateAt ? 'waiting' : (ageMs <= staleDataMs ? 'active' : 'stale'); return { deviceId: device.deviceId, name: device.name || device.deviceId, ip: device.ip, online: Boolean(connection), dataStatus, dataAgeMs: ageMs, connectedAt: connection ? connection.connectedAt : 0, lastSocketDataAt: connection ? connection.lastDataAt : 0, lastUpdateAt, lastRealtimeAt: cacheEntry ? cacheEntry.lastRealtimeAt : 0, lastBloodPressureAt: cacheEntry ? cacheEntry.lastBloodPressureAt : 0, payload: cacheEntry ? cacheEntry.payload : {}, }; }); const onlineCount = deviceSnapshots.filter((item) => item.online).length; const activeCount = deviceSnapshots.filter((item) => item.dataStatus === 'active').length; const waitingCount = deviceSnapshots.filter((item) => item.dataStatus === 'waiting').length; const staleCount = deviceSnapshots.filter((item) => item.dataStatus === 'stale').length; return { title: config.title || 'JH2028 设备中央监测大屏', generatedAt: now, generatedAtText: nowIso(), totals: { devices: deviceSnapshots.length, online: onlineCount, offline: deviceSnapshots.length - onlineCount, active: activeCount, waiting: waitingCount, stale: staleCount, }, devices: deviceSnapshots, }; } class DashboardService { constructor(options = {}) { this.config = options.config || {}; this.logger = options.logger || console; this.getSnapshot = typeof options.getSnapshot === 'function' ? options.getSnapshot : () => ({ devices: [] }); this.dashboardDir = getDashboardDir(options); this.server = null; this.clients = new Set(); } async start() { if (this.config.enabled === false) { this.logger.info('[DASHBOARD] 大屏服务未启用'); return; } this.server = http.createServer((request, response) => { this.handleRequest(request, response); }); await new Promise((resolve, reject) => { this.server.once('listening', resolve); this.server.once('error', reject); this.server.listen({ host: this.config.host || '0.0.0.0', port: this.config.port || 9100, }); }); const host = this.config.host || '0.0.0.0'; const port = this.config.port || 9100; const localUrl = `http://127.0.0.1:${port}`; const listenText = host === '0.0.0.0' ? '监听所有网卡,局域网访问请使用服务器真实 IP' : `监听地址=${host}`; this.logger.info(`[DASHBOARD] 大屏服务已启动 ${listenText} 本机访问=${localUrl}`); } async stop() { for (const client of this.clients) { client.end(); } this.clients.clear(); if (!this.server) { return; } await new Promise((resolve) => { this.server.close(resolve); }); this.server = null; this.logger.info('[DASHBOARD] 大屏服务已停止'); } handleRequest(request, response) { const url = new URL(request.url, 'http://localhost'); if (url.pathname === '/api/snapshot') { this.sendJson(response, this.getSnapshot()); return; } if (url.pathname === '/events') { this.handleEvents(response); return; } const pathname = url.pathname === '/' ? '/index.html' : url.pathname; this.serveStatic(pathname, response); } handleEvents(response) { response.writeHead(200, { 'Content-Type': 'text/event-stream; charset=utf-8', 'Cache-Control': 'no-cache', Connection: 'keep-alive', 'Access-Control-Allow-Origin': '*', }); response.write('\n'); this.clients.add(response); this.writeEvent(response, this.getSnapshot()); response.on('close', () => { this.clients.delete(response); }); } broadcastSnapshot() { const snapshot = this.getSnapshot(); for (const client of this.clients) { this.writeEvent(client, snapshot); } } writeEvent(response, snapshot) { response.write(`event: snapshot\n`); response.write(`data: ${JSON.stringify(snapshot)}\n\n`); } sendJson(response, payload) { response.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-cache', }); response.end(JSON.stringify(payload)); } serveStatic(pathname, response) { const normalizedPath = path.normalize(pathname.replace(/^\/+/, '')); if (path.isAbsolute(normalizedPath) || normalizedPath.startsWith('..')) { response.writeHead(403); response.end('Forbidden'); return; } const filePath = path.join(this.dashboardDir, normalizedPath); if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) { response.writeHead(404); response.end('Not Found'); return; } const extension = path.extname(filePath).toLowerCase(); response.writeHead(200, { 'Content-Type': CONTENT_TYPES[extension] || 'application/octet-stream', }); fs.createReadStream(filePath).pipe(response); } } module.exports = { DashboardService, buildDashboardSnapshot, getDashboardDir, };