| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604 |
- (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 = '<div class="empty">当前没有活跃会话。</div>'
- 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('<br>')
- : '--'
- return `
- <tr>
- <td><code>${item.id || '--'}</code></td>
- <td><code>${item.channelId || '--'}</code></td>
- <td>${item.role || '--'}</td>
- <td>${item.authenticated ? 'yes' : 'no'}</td>
- <td>${formatTime(item.createdAt)}</td>
- <td><div class="json-chip">${subscriptions}</div></td>
- </tr>
- `
- }).join('')
- elements.sessionsTable.innerHTML = `
- <table>
- <thead>
- <tr>
- <th>Session</th>
- <th>Channel</th>
- <th>Role</th>
- <th>Auth</th>
- <th>Created</th>
- <th>Subscriptions</th>
- </tr>
- </thead>
- <tbody>${rows}</tbody>
- </table>
- `
- }
- function renderChannels(payload) {
- const items = Array.isArray(payload.items) ? payload.items : []
- if (!items.length) {
- elements.channelsTable.innerHTML = '<div class="empty">当前没有 channel。</div>'
- return
- }
- const rows = items.map((item) => `
- <tr>
- <td><code>${item.id || '--'}</code></td>
- <td>${item.label || '--'}</td>
- <td>${item.deliveryMode || '--'}</td>
- <td>${item.activeProducers || 0} / ${item.activeConsumers || 0} / ${item.activeControllers || 0}</td>
- <td>${formatTime(item.expiresAt)}</td>
- </tr>
- `).join('')
- elements.channelsTable.innerHTML = `
- <table>
- <thead>
- <tr>
- <th>Channel</th>
- <th>Label</th>
- <th>Mode</th>
- <th>P / C / Ctrl</th>
- <th>Expires</th>
- </tr>
- </thead>
- <tbody>${rows}</tbody>
- </table>
- `
- }
- function renderLatest(payload) {
- const items = Array.isArray(payload.items) ? payload.items : []
- if (!items.length) {
- elements.latestTable.innerHTML = '<div class="empty">当前没有 latest state。</div>'
- return
- }
- const rows = items.map((item) => `
- <tr>
- <td>${item.deviceId || '--'}</td>
- <td>${item.channelId || '--'}</td>
- <td>${item.topic || '--'}</td>
- <td>${item.sourceId || '--'}${item.mode ? ` / ${item.mode}` : ''}</td>
- <td>${formatTime(item.timestamp)}</td>
- <td><div class="json-chip">${escapeHTML(JSON.stringify(item.payload || {}))}</div></td>
- </tr>
- `).join('')
- elements.latestTable.innerHTML = `
- <table>
- <thead>
- <tr>
- <th>Device</th>
- <th>Channel</th>
- <th>Topic</th>
- <th>Source</th>
- <th>Timestamp</th>
- <th>Payload</th>
- </tr>
- </thead>
- <tbody>${rows}</tbody>
- </table>
- `
- }
- function renderTrafficTable(container, columns, rows, emptyText) {
- if (!rows.length) {
- container.innerHTML = `<div class="empty">${emptyText}</div>`
- return
- }
- const header = columns.map((column) => `<th>${column.label}</th>`).join('')
- const body = rows.map((row) => `
- <tr>${columns.map((column) => `<td>${column.render(row)}</td>`).join('')}</tr>
- `).join('')
- container.innerHTML = `
- <table>
- <thead>
- <tr>${header}</tr>
- </thead>
- <tbody>${body}</tbody>
- </table>
- `
- }
- 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) => `<code>${escapeHTML(row.topic || '--')}</code>` },
- { 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) => `<code>${escapeHTML(row.channelId || '--')}</code>` },
- { 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 = '<div class="live-track__empty">等待 GPS 数据...</div>'
- elements.liveTrackLegend.innerHTML = '<div class="empty">暂无轨迹。</div>'
- 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 `
- <polyline fill="none" stroke="${series.color}" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" points="${points}" />
- <circle cx="${lastX.toFixed(1)}" cy="${lastY.toFixed(1)}" r="4.5" fill="${series.color}" stroke="rgba(255,255,255,0.95)" stroke-width="2" />
- `
- }).join('')
- const grid = [25, 50, 75].map((ratio) => {
- const x = (width * ratio) / 100
- const y = (height * ratio) / 100
- return `
- <line x1="${x}" y1="0" x2="${x}" y2="${height}" stroke="rgba(21,38,31,0.08)" stroke-width="1" />
- <line x1="0" y1="${y}" x2="${width}" y2="${y}" stroke="rgba(21,38,31,0.08)" stroke-width="1" />
- `
- }).join('')
- elements.liveTrack.innerHTML = `
- <svg viewBox="0 0 ${width} ${height}" preserveAspectRatio="none" aria-label="live track preview">
- <rect x="0" y="0" width="${width}" height="${height}" fill="transparent" />
- ${grid}
- ${polylines}
- </svg>
- `
- elements.liveTrackLegend.innerHTML = activeSeries.map((series) => {
- const last = series.points[series.points.length - 1]
- return `
- <div class="live-track-legend__item">
- <span class="live-track-legend__swatch" style="background:${series.color}"></span>
- <span>${escapeHTML(series.key)} | ${formatNumber(last.lat, 6)}, ${formatNumber(last.lng, 6)} | ${series.points.length} 点</span>
- </div>
- `
- }).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 = [
- `<span>${escapeHTML(formatTime(item.timestamp))}</span>`,
- `<span>${escapeHTML(item.topic || '--')}</span>`,
- `<span>ch=${escapeHTML(item.channelId || '--')}</span>`,
- `<span>device=${escapeHTML(item.deviceId || '--')}</span>`,
- `<span>source=${escapeHTML(item.sourceId || '--')}${item.mode ? ` / ${escapeHTML(item.mode)}` : ''}</span>`,
- ].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 = '<div class="live-feed__empty">等待实时数据...</div>'
- elements.liveTrack.innerHTML = '<div class="live-track__empty">等待 GPS 数据...</div>'
- elements.liveTrackLegend.innerHTML = '<div class="empty">暂无轨迹。</div>'
- 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 = '<div class="empty">无法加载 channel 信息。</div>'
- elements.sessionsTable.innerHTML = '<div class="empty">无法加载会话信息。</div>'
- elements.latestTable.innerHTML = '<div class="empty">无法加载 latest state。</div>'
- elements.topicTrafficTable.innerHTML = '<div class="empty">无法加载 topic 流量。</div>'
- elements.channelTrafficTable.innerHTML = '<div class="empty">无法加载 channel 流量。</div>'
- }
- }
- 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()
- })()
|