|
|
@@ -0,0 +1,604 @@
|
|
|
+(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()
|
|
|
+})()
|