(function () { const elements = { serviceBadge: document.getElementById('serviceBadge'), listenText: document.getElementById('listenText'), uptimeText: document.getElementById('uptimeText'), anonymousText: document.getElementById('anonymousText'), heroText: document.getElementById('heroText'), sessionsCount: document.getElementById('sessionsCount'), subscribersCount: document.getElementById('subscribersCount'), latestCount: document.getElementById('latestCount'), channelsCount: document.getElementById('channelsCount'), publishedCount: document.getElementById('publishedCount'), droppedCount: document.getElementById('droppedCount'), fanoutCount: document.getElementById('fanoutCount'), pluginsCount: document.getElementById('pluginsCount'), channelsTable: document.getElementById('channelsTable'), channelLabelInput: document.getElementById('channelLabelInput'), channelModeSelect: document.getElementById('channelModeSelect'), channelTTLInput: document.getElementById('channelTTLInput'), createChannelBtn: document.getElementById('createChannelBtn'), createChannelResult: document.getElementById('createChannelResult'), sessionsTable: document.getElementById('sessionsTable'), latestTable: document.getElementById('latestTable'), topicTrafficTable: document.getElementById('topicTrafficTable'), channelTrafficTable: document.getElementById('channelTrafficTable'), topicFilter: document.getElementById('topicFilter'), liveTopicFilter: document.getElementById('liveTopicFilter'), liveChannelFilter: document.getElementById('liveChannelFilter'), liveDeviceFilter: document.getElementById('liveDeviceFilter'), liveReconnectBtn: document.getElementById('liveReconnectBtn'), liveClearBtn: document.getElementById('liveClearBtn'), liveStatus: document.getElementById('liveStatus'), liveSummary: document.getElementById('liveSummary'), liveLocationCount: document.getElementById('liveLocationCount'), liveHeartRateCount: document.getElementById('liveHeartRateCount'), liveLastDevice: document.getElementById('liveLastDevice'), liveLastTopic: document.getElementById('liveLastTopic'), liveTrack: document.getElementById('liveTrack'), liveTrackLegend: document.getElementById('liveTrackLegend'), liveFeed: document.getElementById('liveFeed'), refreshBtn: document.getElementById('refreshBtn'), autoRefreshInput: document.getElementById('autoRefreshInput'), } let timer = 0 let liveSource = null let liveCount = 0 const maxLiveLines = 120 const maxTrackPoints = 80 const liveTrackSeries = new Map() const liveStats = { location: 0, heartRate: 0, lastDevice: '--', lastTopic: '--', } const liveTrackPalette = ['#0f7a68', '#d57a1f', '#2878c8', '#8a4bd6', '#b24f6a', '#2c9f5e'] function setBadge(status) { elements.serviceBadge.textContent = status === 'ok' ? 'Online' : 'Unavailable' elements.serviceBadge.className = status === 'ok' ? 'badge is-ok' : 'badge' } function formatDuration(seconds) { const hours = Math.floor(seconds / 3600) const minutes = Math.floor((seconds % 3600) / 60) const secs = Math.floor(seconds % 60) return `${hours}h ${minutes}m ${secs}s` } function formatTime(value) { if (!value) { return '--' } return new Date(value).toLocaleString() } async function loadJSON(url) { const response = await fetch(url, { cache: 'no-store' }) if (!response.ok) { throw new Error(`HTTP ${response.status}`) } return response.json() } function renderSessions(payload) { const items = Array.isArray(payload.items) ? payload.items : [] if (!items.length) { elements.sessionsTable.innerHTML = '
当前没有活跃会话。
' return } const rows = items.map((item) => { const subscriptions = Array.isArray(item.subscriptions) && item.subscriptions.length ? item.subscriptions.map((entry) => { const scope = entry.deviceId || `group:${entry.groupId || '--'}` return `${scope}${entry.topic ? ` / ${entry.topic}` : ''}` }).join('
') : '--' return ` ${item.id || '--'} ${item.channelId || '--'} ${item.role || '--'} ${item.authenticated ? 'yes' : 'no'} ${formatTime(item.createdAt)}
${subscriptions}
` }).join('') elements.sessionsTable.innerHTML = ` ${rows}
Session Channel Role Auth Created Subscriptions
` } function renderChannels(payload) { const items = Array.isArray(payload.items) ? payload.items : [] if (!items.length) { elements.channelsTable.innerHTML = '
当前没有 channel。
' return } const rows = items.map((item) => ` ${item.id || '--'} ${item.label || '--'} ${item.deliveryMode || '--'} ${item.activeProducers || 0} / ${item.activeConsumers || 0} / ${item.activeControllers || 0} ${formatTime(item.expiresAt)} `).join('') elements.channelsTable.innerHTML = ` ${rows}
Channel Label Mode P / C / Ctrl Expires
` } function renderLatest(payload) { const items = Array.isArray(payload.items) ? payload.items : [] if (!items.length) { elements.latestTable.innerHTML = '
当前没有 latest state。
' return } const rows = items.map((item) => ` ${item.deviceId || '--'} ${item.channelId || '--'} ${item.topic || '--'} ${item.sourceId || '--'}${item.mode ? ` / ${item.mode}` : ''} ${formatTime(item.timestamp)}
${escapeHTML(JSON.stringify(item.payload || {}))}
`).join('') elements.latestTable.innerHTML = ` ${rows}
Device Channel Topic Source Timestamp Payload
` } function renderTrafficTable(container, columns, rows, emptyText) { if (!rows.length) { container.innerHTML = `
${emptyText}
` return } const header = columns.map((column) => `${column.label}`).join('') const body = rows.map((row) => ` ${columns.map((column) => `${column.render(row)}`).join('')} `).join('') container.innerHTML = ` ${header}${body}
` } function renderTraffic(payload) { const topicItems = Array.isArray(payload.topics) ? payload.topics.slice() : [] topicItems.sort((left, right) => Number(right.published || 0) - Number(left.published || 0)) renderTrafficTable( elements.topicTrafficTable, [ { label: 'Topic', render: (row) => `${escapeHTML(row.topic || '--')}` }, { label: 'Published', render: (row) => String(row.published || 0) }, { label: 'Dropped', render: (row) => String(row.dropped || 0) }, { label: 'Fanout', render: (row) => String(row.fanout || 0) }, ], topicItems, '当前没有 topic 流量。', ) const channelItems = Array.isArray(payload.channels) ? payload.channels.slice() : [] channelItems.sort((left, right) => Number(right.published || 0) - Number(left.published || 0)) renderTrafficTable( elements.channelTrafficTable, [ { label: 'Channel', render: (row) => `${escapeHTML(row.channelId || '--')}` }, { label: 'Published', render: (row) => String(row.published || 0) }, { label: 'Dropped', render: (row) => String(row.dropped || 0) }, { label: 'Fanout', render: (row) => String(row.fanout || 0) }, ], channelItems, '当前没有 channel 流量。', ) } function escapeHTML(text) { return String(text) .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') } function setLiveStatus(status, summary) { elements.liveStatus.textContent = status elements.liveStatus.className = status === 'Online' ? 'badge is-ok' : 'badge' elements.liveSummary.textContent = summary } function updateLiveStats() { elements.liveLocationCount.textContent = String(liveStats.location) elements.liveHeartRateCount.textContent = String(liveStats.heartRate) elements.liveLastDevice.textContent = liveStats.lastDevice elements.liveLastTopic.textContent = liveStats.lastTopic } function formatNumber(value, digits) { const num = Number(value) if (!Number.isFinite(num)) { return '--' } return num.toFixed(digits) } function formatLiveSummary(item) { if (item.topic === 'telemetry.location') { const payload = item.payload || {} return `定位 ${formatNumber(payload.lat, 6)}, ${formatNumber(payload.lng, 6)} | 速度 ${formatNumber(payload.speed, 1)} m/s | 航向 ${formatNumber(payload.bearing, 0)}° | 精度 ${formatNumber(payload.accuracy, 1)} m` } if (item.topic === 'telemetry.heart_rate') { const payload = item.payload || {} return `心率 ${formatNumber(payload.bpm, 0)} bpm` } return '原始数据' } function trackKey(item) { return `${item.channelId || '--'} / ${item.deviceId || '--'}` } function ensureTrackSeries(item) { const key = trackKey(item) if (!liveTrackSeries.has(key)) { liveTrackSeries.set(key, { key, color: liveTrackPalette[liveTrackSeries.size % liveTrackPalette.length], points: [], lastTopic: item.topic || '--', }) } return liveTrackSeries.get(key) } function updateTrack(item) { if (item.topic !== 'telemetry.location') { return } const payload = item.payload || {} const lat = Number(payload.lat) const lng = Number(payload.lng) if (!Number.isFinite(lat) || !Number.isFinite(lng)) { return } const series = ensureTrackSeries(item) series.lastTopic = item.topic || '--' series.points.push({ lat, lng, timestamp: item.timestamp }) if (series.points.length > maxTrackPoints) { series.points.shift() } renderLiveTrack() } function renderLiveTrack() { const activeSeries = Array.from(liveTrackSeries.values()).filter((entry) => entry.points.length > 0) if (!activeSeries.length) { elements.liveTrack.innerHTML = '
等待 GPS 数据...
' elements.liveTrackLegend.innerHTML = '
暂无轨迹。
' return } let minLat = Infinity let maxLat = -Infinity let minLng = Infinity let maxLng = -Infinity activeSeries.forEach((series) => { series.points.forEach((point) => { minLat = Math.min(minLat, point.lat) maxLat = Math.max(maxLat, point.lat) minLng = Math.min(minLng, point.lng) maxLng = Math.max(maxLng, point.lng) }) }) const width = 340 const height = 320 const padding = 18 const lngSpan = Math.max(maxLng - minLng, 0.0001) const latSpan = Math.max(maxLat - minLat, 0.0001) const polylines = activeSeries.map((series) => { const points = series.points.map((point) => { const x = padding + ((point.lng - minLng) / lngSpan) * (width - padding * 2) const y = height - padding - ((point.lat - minLat) / latSpan) * (height - padding * 2) return `${x.toFixed(1)},${y.toFixed(1)}` }).join(' ') const last = series.points[series.points.length - 1] const lastX = padding + ((last.lng - minLng) / lngSpan) * (width - padding * 2) const lastY = height - padding - ((last.lat - minLat) / latSpan) * (height - padding * 2) return ` ` }).join('') const grid = [25, 50, 75].map((ratio) => { const x = (width * ratio) / 100 const y = (height * ratio) / 100 return ` ` }).join('') elements.liveTrack.innerHTML = ` ${grid} ${polylines} ` elements.liveTrackLegend.innerHTML = activeSeries.map((series) => { const last = series.points[series.points.length - 1] return `
${escapeHTML(series.key)} | ${formatNumber(last.lat, 6)}, ${formatNumber(last.lng, 6)} | ${series.points.length} 点
` }).join('') } function renderLiveEntry(item) { const line = document.createElement('div') line.className = 'live-line' const meta = document.createElement('div') meta.className = 'live-line__meta' meta.innerHTML = [ `${escapeHTML(formatTime(item.timestamp))}`, `${escapeHTML(item.topic || '--')}`, `ch=${escapeHTML(item.channelId || '--')}`, `device=${escapeHTML(item.deviceId || '--')}`, `source=${escapeHTML(item.sourceId || '--')}${item.mode ? ` / ${escapeHTML(item.mode)}` : ''}`, ].join('') const summary = document.createElement('div') summary.className = 'live-line__summary' summary.textContent = formatLiveSummary(item) const payload = document.createElement('div') payload.className = 'live-line__payload' payload.textContent = JSON.stringify(item.payload || {}, null, 2) line.appendChild(meta) line.appendChild(summary) line.appendChild(payload) if (elements.liveFeed.firstChild && elements.liveFeed.firstChild.classList && elements.liveFeed.firstChild.classList.contains('live-feed__empty')) { elements.liveFeed.innerHTML = '' } elements.liveFeed.prepend(line) liveCount += 1 liveStats.lastDevice = item.deviceId || '--' liveStats.lastTopic = item.topic || '--' if (item.topic === 'telemetry.location') { liveStats.location += 1 } else if (item.topic === 'telemetry.heart_rate') { liveStats.heartRate += 1 } updateLiveStats() updateTrack(item) while (elements.liveFeed.childElementCount > maxLiveLines) { elements.liveFeed.removeChild(elements.liveFeed.lastElementChild) } setLiveStatus('Online', `实时流已连接,已接收 ${liveCount} 条数据。`) } function clearLiveFeed() { liveCount = 0 liveStats.location = 0 liveStats.heartRate = 0 liveStats.lastDevice = '--' liveStats.lastTopic = '--' liveTrackSeries.clear() elements.liveFeed.innerHTML = '
等待实时数据...
' elements.liveTrack.innerHTML = '
等待 GPS 数据...
' elements.liveTrackLegend.innerHTML = '
暂无轨迹。
' updateLiveStats() setLiveStatus(liveSource ? 'Online' : 'Connecting', liveSource ? '实时流已连接,等待数据...' : '正在连接实时流...') } function closeLiveStream() { if (!liveSource) { return } liveSource.close() liveSource = null } function connectLiveStream() { closeLiveStream() const params = new URLSearchParams() if (elements.liveTopicFilter.value) { params.set('topic', elements.liveTopicFilter.value) } if (elements.liveChannelFilter.value.trim()) { params.set('channelId', elements.liveChannelFilter.value.trim()) } if (elements.liveDeviceFilter.value.trim()) { params.set('deviceId', elements.liveDeviceFilter.value.trim()) } clearLiveFeed() setLiveStatus('Connecting', '正在连接实时流...') const url = `/api/admin/live${params.toString() ? `?${params.toString()}` : ''}` liveSource = new EventSource(url) liveSource.addEventListener('open', () => { setLiveStatus('Online', liveCount > 0 ? `实时流已连接,已接收 ${liveCount} 条数据。` : '实时流已连接,等待数据...') }) liveSource.addEventListener('envelope', (event) => { try { const payload = JSON.parse(event.data) renderLiveEntry(payload) } catch (_error) { setLiveStatus('Error', '实时流收到不可解析数据。') } }) liveSource.addEventListener('error', () => { setLiveStatus('Error', '实时流已断开,可手动重连。') }) } async function refreshDashboard() { try { const topic = elements.topicFilter.value const [overview, sessions, latest, channels, traffic] = await Promise.all([ loadJSON('/api/admin/overview'), loadJSON('/api/admin/sessions'), loadJSON(`/api/admin/latest${topic ? `?topic=${encodeURIComponent(topic)}` : ''}`), loadJSON('/api/admin/channels'), loadJSON('/api/admin/traffic'), ]) setBadge(overview.status) elements.listenText.textContent = overview.httpListen || '--' elements.uptimeText.textContent = formatDuration(overview.uptimeSeconds || 0) elements.anonymousText.textContent = overview.anonymousConsumers ? 'enabled' : 'disabled' elements.sessionsCount.textContent = String(overview.metrics.sessions || 0) elements.subscribersCount.textContent = String(overview.metrics.subscribers || 0) elements.latestCount.textContent = String(overview.metrics.latestState || 0) elements.channelsCount.textContent = String(overview.metrics.channels || 0) elements.publishedCount.textContent = String(overview.metrics.published || 0) elements.droppedCount.textContent = String(overview.metrics.dropped || 0) elements.fanoutCount.textContent = String(overview.metrics.fanout || 0) elements.pluginsCount.textContent = String(overview.metrics.pluginHandlers || 0) elements.heroText.textContent = `运行中,启动于 ${formatTime(overview.startedAt)},当前时间 ${formatTime(overview.now)}。` renderSessions(sessions) renderLatest(latest) renderChannels(channels) renderTraffic(traffic) } catch (error) { setBadge('error') elements.heroText.textContent = error && error.message ? error.message : '加载失败' elements.channelsTable.innerHTML = '
无法加载 channel 信息。
' elements.sessionsTable.innerHTML = '
无法加载会话信息。
' elements.latestTable.innerHTML = '
无法加载 latest state。
' elements.topicTrafficTable.innerHTML = '
无法加载 topic 流量。
' elements.channelTrafficTable.innerHTML = '
无法加载 channel 流量。
' } } async function createChannel() { const payload = { label: elements.channelLabelInput.value.trim(), deliveryMode: elements.channelModeSelect.value, ttlSeconds: Number(elements.channelTTLInput.value) || 28800, } try { const response = await fetch('/api/channel/create', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(payload), }) const data = await response.json() if (!response.ok) { throw new Error(data && data.error ? data.error : `HTTP ${response.status}`) } elements.createChannelResult.textContent = [ `channelId: ${data.snapshot.id}`, `label: ${data.snapshot.label || '--'}`, `deliveryMode: ${data.snapshot.deliveryMode || '--'}`, `producerToken: ${data.producerToken}`, `consumerToken: ${data.consumerToken}`, `controllerToken: ${data.controllerToken}`, ].join('\n') await refreshDashboard() } catch (error) { elements.createChannelResult.textContent = error && error.message ? error.message : '创建失败' } } function resetAutoRefresh() { if (timer) { window.clearInterval(timer) timer = 0 } if (elements.autoRefreshInput.checked) { timer = window.setInterval(refreshDashboard, 3000) } } elements.refreshBtn.addEventListener('click', refreshDashboard) elements.createChannelBtn.addEventListener('click', createChannel) elements.topicFilter.addEventListener('change', refreshDashboard) elements.liveReconnectBtn.addEventListener('click', connectLiveStream) elements.liveClearBtn.addEventListener('click', clearLiveFeed) elements.liveTopicFilter.addEventListener('change', connectLiveStream) elements.liveChannelFilter.addEventListener('change', connectLiveStream) elements.liveDeviceFilter.addEventListener('change', connectLiveStream) elements.autoRefreshInput.addEventListener('change', resetAutoRefresh) clearLiveFeed() connectLiveStream() refreshDashboard() resetAutoRefresh() })()