(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 = `
| Session |
Channel |
Role |
Auth |
Created |
Subscriptions |
${rows}
`
}
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 = `
| Channel |
Label |
Mode |
P / C / Ctrl |
Expires |
${rows}
`
}
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 = `
| Device |
Channel |
Topic |
Source |
Timestamp |
Payload |
${rows}
`
}
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 = `
`
}
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 = `
`
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()
})()