Add realtime gateway and simulator bridge

This commit is contained in:
2026-03-27 21:06:17 +08:00
parent 0703fd47a2
commit 2c0fd4c549
36 changed files with 6852 additions and 1 deletions

View File

@@ -0,0 +1,228 @@
package gateway
import (
"embed"
"encoding/json"
"fmt"
"io/fs"
"net/http"
"sort"
"strings"
"time"
"realtime-gateway/internal/model"
)
//go:embed adminui/*
var adminUIFiles embed.FS
type adminOverview struct {
Status string `json:"status"`
StartedAt time.Time `json:"startedAt"`
Now time.Time `json:"now"`
UptimeSeconds int64 `json:"uptimeSeconds"`
HTTPListen string `json:"httpListen"`
Anonymous bool `json:"anonymousConsumers"`
Metrics map[string]any `json:"metrics"`
Auth map[string]any `json:"auth"`
Endpoints map[string]any `json:"endpoints"`
}
func (s *Server) registerAdminRoutes(mux *http.ServeMux) error {
sub, err := fs.Sub(adminUIFiles, "adminui")
if err != nil {
return err
}
fileServer := http.FileServer(http.FS(sub))
mux.Handle("/assets/", http.StripPrefix("/assets/", noStoreHandler(fileServer)))
mux.HandleFunc("/", s.handleAdminIndex)
mux.HandleFunc("/admin", s.handleAdminIndex)
mux.HandleFunc("/api/admin/overview", s.handleAdminOverview)
mux.HandleFunc("/api/admin/sessions", s.handleAdminSessions)
mux.HandleFunc("/api/admin/latest", s.handleAdminLatest)
mux.HandleFunc("/api/admin/traffic", s.handleAdminTraffic)
mux.HandleFunc("/api/admin/live", s.handleAdminLive)
return nil
}
func (s *Server) handleAdminIndex(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" && r.URL.Path != "/admin" {
http.NotFound(w, r)
return
}
data, err := adminUIFiles.ReadFile("adminui/index.html")
if err != nil {
http.Error(w, "admin ui unavailable", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(data)
}
func noStoreHandler(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
next.ServeHTTP(w, r)
})
}
func (s *Server) handleAdminOverview(w http.ResponseWriter, _ *http.Request) {
subscriberCount, latestStateCount := s.hub.Stats()
traffic := s.hub.TrafficSnapshot()
now := time.Now()
writeJSON(w, http.StatusOK, adminOverview{
Status: "ok",
StartedAt: s.startedAt,
Now: now,
UptimeSeconds: int64(now.Sub(s.startedAt).Seconds()),
HTTPListen: s.cfg.Server.HTTPListen,
Anonymous: s.cfg.Auth.AllowAnonymousConsumers,
Metrics: map[string]any{
"sessions": s.sessions.Count(),
"subscribers": subscriberCount,
"latestState": latestStateCount,
"channels": len(s.channels.List()),
"pluginHandlers": s.plugins.HandlerCount(),
"published": traffic.Published,
"dropped": traffic.Dropped,
"fanout": traffic.Fanout,
},
Auth: map[string]any{
"producerTokens": len(s.cfg.Auth.ProducerTokens),
"consumerTokens": len(s.cfg.Auth.ConsumerTokens),
"controllerTokens": len(s.cfg.Auth.ControllerTokens),
},
Endpoints: map[string]any{
"websocket": "/ws",
"health": "/healthz",
"metrics": "/metrics",
"createChannel": "/api/channel/create",
"channels": "/api/admin/channels",
"traffic": "/api/admin/traffic",
"admin": "/admin",
},
})
}
func (s *Server) handleAdminSessions(w http.ResponseWriter, _ *http.Request) {
snapshots := s.sessions.List()
sort.Slice(snapshots, func(i int, j int) bool {
return snapshots[i].CreatedAt.After(snapshots[j].CreatedAt)
})
writeJSON(w, http.StatusOK, map[string]any{
"items": snapshots,
"count": len(snapshots),
})
}
func (s *Server) handleAdminLatest(w http.ResponseWriter, r *http.Request) {
envelopes := s.hub.LatestStates()
sort.Slice(envelopes, func(i int, j int) bool {
return envelopes[i].Timestamp > envelopes[j].Timestamp
})
query := strings.TrimSpace(r.URL.Query().Get("topic"))
if query != "" {
filtered := make([]any, 0, len(envelopes))
for _, envelope := range envelopes {
if envelope.Topic != query {
continue
}
filtered = append(filtered, adminLatestItem(envelope))
}
writeJSON(w, http.StatusOK, map[string]any{
"items": filtered,
"count": len(filtered),
})
return
}
items := make([]any, 0, len(envelopes))
for _, envelope := range envelopes {
items = append(items, adminLatestItem(envelope))
}
writeJSON(w, http.StatusOK, map[string]any{
"items": items,
"count": len(items),
})
}
func (s *Server) handleAdminLive(w http.ResponseWriter, r *http.Request) {
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
return
}
topic := strings.TrimSpace(r.URL.Query().Get("topic"))
channelID := strings.TrimSpace(r.URL.Query().Get("channelId"))
deviceID := strings.TrimSpace(r.URL.Query().Get("deviceId"))
w.Header().Set("Content-Type", "text/event-stream; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no")
id, stream := s.hub.SubscribeLive(64)
defer s.hub.UnsubscribeLive(id)
fmt.Fprint(w, ": live stream ready\n\n")
flusher.Flush()
ping := time.NewTicker(15 * time.Second)
defer ping.Stop()
ctx := r.Context()
for {
select {
case <-ctx.Done():
return
case <-ping.C:
fmt.Fprint(w, ": ping\n\n")
flusher.Flush()
case envelope, ok := <-stream:
if !ok {
return
}
if topic != "" && envelope.Topic != topic {
continue
}
if channelID != "" && envelope.Target.ChannelID != channelID {
continue
}
if deviceID != "" && envelope.Target.DeviceID != deviceID {
continue
}
data, err := json.Marshal(adminLatestItem(envelope))
if err != nil {
continue
}
fmt.Fprintf(w, "event: envelope\ndata: %s\n\n", data)
flusher.Flush()
}
}
}
func (s *Server) handleAdminTraffic(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, s.hub.TrafficSnapshot())
}
func adminLatestItem(envelope model.Envelope) map[string]any {
payload := map[string]any{}
_ = json.Unmarshal(envelope.Payload, &payload)
return map[string]any{
"timestamp": envelope.Timestamp,
"topic": envelope.Topic,
"channelId": envelope.Target.ChannelID,
"deviceId": envelope.Target.DeviceID,
"groupId": envelope.Target.GroupID,
"sourceId": envelope.Source.ID,
"mode": envelope.Source.Mode,
"payload": payload,
}
}

View File

@@ -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('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
}
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()
})()

View File

@@ -0,0 +1,214 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Realtime Gateway Console</title>
<link rel="stylesheet" href="/assets/style.css?v=20260327e">
</head>
<body>
<div class="shell">
<aside class="sidebar">
<div class="brand">
<div class="brand__eyebrow">REALTIME GATEWAY</div>
<h1>控制台</h1>
<div id="serviceBadge" class="badge">Loading</div>
</div>
<div class="sidebar__section">
<div class="sidebar__label">运行信息</div>
<div class="meta-list">
<div class="meta-row"><span>监听</span><strong id="listenText">--</strong></div>
<div class="meta-row"><span>运行时长</span><strong id="uptimeText">--</strong></div>
<div class="meta-row"><span>匿名订阅</span><strong id="anonymousText">--</strong></div>
</div>
</div>
<div class="sidebar__section">
<div class="sidebar__label">接口</div>
<div class="endpoint-list">
<code>/ws</code>
<code>/healthz</code>
<code>/metrics</code>
<code>/api/admin/overview</code>
<code>/api/admin/sessions</code>
<code>/api/admin/latest</code>
<code>/api/admin/traffic</code>
<code>/api/admin/live</code>
</div>
</div>
<div class="sidebar__section">
<button id="refreshBtn" class="action-btn">立即刷新</button>
<label class="toggle">
<input id="autoRefreshInput" type="checkbox" checked>
<span>自动刷新 (3s)</span>
</label>
</div>
</aside>
<main class="main">
<section class="hero">
<div class="hero__title">
<span class="hero__tag">Router Style</span>
<h2>实时设备网关管理台</h2>
</div>
<p id="heroText" class="hero__text">正在加载运行状态...</p>
</section>
<section class="grid stats-grid">
<article class="card metric-card">
<div class="card__label">Sessions</div>
<div id="sessionsCount" class="metric-card__value">0</div>
</article>
<article class="card metric-card">
<div class="card__label">Subscribers</div>
<div id="subscribersCount" class="metric-card__value">0</div>
</article>
<article class="card metric-card">
<div class="card__label">Latest State</div>
<div id="latestCount" class="metric-card__value">0</div>
</article>
<article class="card metric-card">
<div class="card__label">Channels</div>
<div id="channelsCount" class="metric-card__value">0</div>
</article>
<article class="card metric-card">
<div class="card__label">Published</div>
<div id="publishedCount" class="metric-card__value">0</div>
</article>
<article class="card metric-card">
<div class="card__label">Dropped</div>
<div id="droppedCount" class="metric-card__value">0</div>
</article>
<article class="card metric-card">
<div class="card__label">Fanout</div>
<div id="fanoutCount" class="metric-card__value">0</div>
</article>
<article class="card metric-card">
<div class="card__label">Plugins</div>
<div id="pluginsCount" class="metric-card__value">0</div>
</article>
</section>
<section class="grid detail-grid">
<article class="card">
<div class="card__header">
<div>
<div class="card__title">Channel 管理</div>
<div class="card__hint">创建临时通道并查看当前在线角色</div>
</div>
</div>
<div class="channel-form">
<input id="channelLabelInput" class="filter-select" type="text" placeholder="可选标签,例如: debug-a">
<select id="channelModeSelect" class="filter-select">
<option value="cache_latest">cache_latest</option>
<option value="drop_if_no_consumer">drop_if_no_consumer</option>
</select>
<input id="channelTTLInput" class="filter-select" type="number" min="60" value="28800" placeholder="TTL 秒">
<button id="createChannelBtn" class="action-btn">创建 Channel</button>
</div>
<div class="card__hint">创建成功后,这里会显示 `channelId` 与三种 token。</div>
<pre id="createChannelResult" class="result-box">创建结果会显示在这里。</pre>
<div id="channelsTable" class="table-wrap"></div>
</article>
<article class="card">
<div class="card__header">
<div>
<div class="card__title">会话列表</div>
<div id="sessionsHint" class="card__hint">当前连接与订阅</div>
</div>
</div>
<div id="sessionsTable" class="table-wrap"></div>
</article>
<article class="card">
<div class="card__header">
<div>
<div class="card__title">Latest State</div>
<div class="card__hint">每个设备最近一条消息</div>
</div>
<select id="topicFilter" class="filter-select">
<option value="">全部 Topic</option>
<option value="telemetry.location">telemetry.location</option>
<option value="telemetry.heart_rate">telemetry.heart_rate</option>
</select>
</div>
<div id="latestTable" class="table-wrap"></div>
</article>
<article class="card traffic-card">
<div class="card__header">
<div>
<div class="card__title">流量统计</div>
<div class="card__hint">按 topic 和 channel 看累计发布、丢弃和扇出</div>
</div>
</div>
<div class="traffic-grid">
<section>
<div class="card__hint">Topic 统计</div>
<div id="topicTrafficTable" class="table-wrap"></div>
</section>
<section>
<div class="card__hint">Channel 统计</div>
<div id="channelTrafficTable" class="table-wrap"></div>
</section>
</div>
</article>
<article class="card live-card">
<div class="card__header">
<div>
<div class="card__title">实时数据窗口</div>
<div class="card__hint">直接查看网关收到的实时 GPS / 心率数据</div>
</div>
<div class="live-controls">
<select id="liveTopicFilter" class="filter-select">
<option value="">全部 Topic</option>
<option value="telemetry.location">telemetry.location</option>
<option value="telemetry.heart_rate">telemetry.heart_rate</option>
</select>
<input id="liveChannelFilter" class="filter-select" type="text" placeholder="channelId 可选">
<input id="liveDeviceFilter" class="filter-select" type="text" placeholder="deviceId 可选">
<button id="liveReconnectBtn" class="action-btn action-btn--inline">重连实时流</button>
<button id="liveClearBtn" class="action-btn action-btn--inline action-btn--muted">清空窗口</button>
</div>
</div>
<div class="live-meta">
<div id="liveStatus" class="badge">Connecting</div>
<div id="liveSummary" class="card__hint">等待实时流...</div>
</div>
<div class="live-stats">
<div class="live-stat">
<span class="live-stat__label">定位消息</span>
<strong id="liveLocationCount">0</strong>
</div>
<div class="live-stat">
<span class="live-stat__label">心率消息</span>
<strong id="liveHeartRateCount">0</strong>
</div>
<div class="live-stat">
<span class="live-stat__label">最后设备</span>
<strong id="liveLastDevice">--</strong>
</div>
<div class="live-stat">
<span class="live-stat__label">最后主题</span>
<strong id="liveLastTopic">--</strong>
</div>
</div>
<div class="live-panel-grid">
<section class="live-track-panel">
<div class="card__hint">轨迹预览,建议配合 `channelId / deviceId` 过滤使用</div>
<div id="liveTrack" class="live-track"></div>
<div id="liveTrackLegend" class="live-track-legend"></div>
</section>
<section>
<div id="liveFeed" class="live-feed"></div>
</section>
</div>
</article>
</section>
</main>
</div>
<script src="/assets/app.js?v=20260327e"></script>
</body>
</html>

View File

@@ -0,0 +1,508 @@
:root {
--bg: #d9dfd3;
--panel: rgba(247, 249, 243, 0.94);
--card: rgba(255, 255, 255, 0.94);
--line: rgba(28, 43, 34, 0.12);
--text: #15261f;
--muted: #5e6f66;
--accent: #0f7a68;
--accent-2: #d57a1f;
--ok: #13754c;
--warn: #9a5a11;
--shadow: 0 18px 40px rgba(28, 43, 34, 0.12);
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
min-height: 100%;
background:
radial-gradient(circle at top left, rgba(213, 122, 31, 0.22), transparent 28%),
radial-gradient(circle at bottom right, rgba(15, 122, 104, 0.22), transparent 24%),
var(--bg);
color: var(--text);
font-family: "Bahnschrift", "Segoe UI Variable Text", "PingFang SC", sans-serif;
}
.shell {
display: grid;
grid-template-columns: 320px 1fr;
min-height: 100vh;
}
.sidebar {
padding: 24px;
background: rgba(22, 35, 29, 0.92);
color: #eef4ed;
border-right: 1px solid rgba(255, 255, 255, 0.08);
}
.brand__eyebrow {
font-size: 12px;
letter-spacing: 0.18em;
text-transform: uppercase;
color: rgba(238, 244, 237, 0.68);
}
.brand h1 {
margin: 10px 0 12px;
font-size: 34px;
line-height: 1;
}
.badge {
display: inline-flex;
min-height: 30px;
align-items: center;
padding: 0 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.08);
color: rgba(238, 244, 237, 0.82);
font-size: 13px;
font-weight: 700;
}
.badge.is-ok {
background: rgba(19, 117, 76, 0.24);
color: #9ef4c7;
}
.sidebar__section {
margin-top: 26px;
}
.sidebar__label {
margin-bottom: 12px;
font-size: 12px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: rgba(238, 244, 237, 0.58);
}
.meta-list,
.endpoint-list {
display: grid;
gap: 10px;
}
.meta-row {
display: flex;
justify-content: space-between;
gap: 12px;
padding-bottom: 10px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.meta-row span {
color: rgba(238, 244, 237, 0.66);
}
.endpoint-list code {
display: block;
padding: 10px 12px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.06);
color: #dbe8e1;
font-family: "Cascadia Code", "Consolas", monospace;
}
.action-btn,
.filter-select {
min-height: 42px;
border: 0;
border-radius: 14px;
font: inherit;
}
.action-btn {
width: 100%;
font-weight: 800;
color: #113128;
background: linear-gradient(135deg, #f0d96b, #d57a1f);
cursor: pointer;
}
.action-btn--inline {
width: auto;
padding: 0 16px;
}
.action-btn--muted {
color: #eef4ed;
background: linear-gradient(135deg, #466055, #2f473d);
}
.toggle {
display: flex;
align-items: center;
gap: 10px;
margin-top: 14px;
color: rgba(238, 244, 237, 0.82);
}
.main {
padding: 28px;
}
.hero {
padding: 24px 26px;
border-radius: 24px;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.9), rgba(233, 239, 232, 0.9));
box-shadow: var(--shadow);
}
.hero__tag {
display: inline-block;
margin-bottom: 10px;
padding: 6px 12px;
border-radius: 999px;
background: rgba(15, 122, 104, 0.12);
color: var(--accent);
font-size: 12px;
letter-spacing: 0.12em;
text-transform: uppercase;
font-weight: 800;
}
.hero h2 {
margin: 0;
font-size: 32px;
}
.hero__text {
margin: 10px 0 0;
color: var(--muted);
font-size: 15px;
}
.grid {
display: grid;
gap: 18px;
margin-top: 20px;
}
.stats-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.detail-grid {
grid-template-columns: 1.15fr 1fr;
}
.traffic-card {
grid-column: 1 / -1;
}
.live-card {
grid-column: 1 / -1;
}
.channel-form {
display: grid;
grid-template-columns: 1.3fr 1fr 0.8fr 0.9fr;
gap: 12px;
margin-bottom: 14px;
}
.card {
padding: 20px;
border: 1px solid var(--line);
border-radius: 22px;
background: var(--card);
box-shadow: var(--shadow);
}
.metric-card__value {
margin-top: 10px;
font-size: 42px;
line-height: 1;
font-weight: 900;
letter-spacing: -0.04em;
}
.card__label {
color: var(--muted);
font-size: 13px;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.card__header {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: flex-start;
margin-bottom: 16px;
}
.card__title {
font-size: 20px;
font-weight: 900;
}
.card__hint {
margin-top: 6px;
color: var(--muted);
font-size: 13px;
}
.filter-select {
min-width: 220px;
padding: 0 14px;
border: 1px solid var(--line);
background: #f6f8f2;
}
.live-controls {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
justify-content: flex-end;
}
.live-meta {
display: flex;
gap: 12px;
align-items: center;
margin-bottom: 14px;
}
.live-stats {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
margin-bottom: 14px;
}
.live-stat {
padding: 14px 16px;
border: 1px solid var(--line);
border-radius: 16px;
background: rgba(244, 247, 240, 0.88);
}
.live-stat__label {
display: block;
margin-bottom: 8px;
color: var(--muted);
font-size: 12px;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.live-stat strong {
display: block;
font-size: 22px;
line-height: 1.1;
}
.live-panel-grid {
display: grid;
grid-template-columns: 360px 1fr;
gap: 14px;
}
.traffic-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
}
.live-track-panel {
display: flex;
flex-direction: column;
gap: 12px;
}
.live-track {
min-height: 320px;
border: 1px solid var(--line);
border-radius: 16px;
background:
radial-gradient(circle at top left, rgba(15, 122, 104, 0.15), transparent 30%),
linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(237, 242, 234, 0.96));
overflow: hidden;
}
.live-track svg {
display: block;
width: 100%;
height: 320px;
}
.live-track__empty {
display: flex;
align-items: center;
justify-content: center;
min-height: 320px;
color: var(--muted);
font-size: 13px;
}
.live-track-legend {
display: grid;
gap: 8px;
}
.live-track-legend__item {
display: flex;
gap: 10px;
align-items: center;
padding: 8px 10px;
border-radius: 12px;
background: rgba(244, 247, 240, 0.88);
border: 1px solid var(--line);
font-size: 12px;
}
.live-track-legend__swatch {
width: 10px;
height: 10px;
border-radius: 999px;
flex: 0 0 auto;
}
.live-feed {
min-height: 320px;
max-height: 420px;
overflow: auto;
padding: 14px;
border: 1px solid var(--line);
border-radius: 16px;
background:
linear-gradient(180deg, rgba(19, 29, 24, 0.98), rgba(14, 24, 20, 0.98));
color: #dceee7;
font-family: "Cascadia Code", "Consolas", monospace;
font-size: 12px;
line-height: 1.6;
}
.live-feed__empty {
color: rgba(220, 238, 231, 0.68);
}
.live-line {
padding: 10px 12px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.04);
}
.live-line + .live-line {
margin-top: 10px;
}
.live-line__meta {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 8px;
color: #8dd9c7;
}
.live-line__summary {
margin-bottom: 8px;
color: #f1d88e;
font-weight: 700;
}
.live-line__payload {
color: rgba(241, 246, 244, 0.8);
white-space: pre-wrap;
word-break: break-word;
}
.table-wrap {
overflow: auto;
border-radius: 16px;
border: 1px solid var(--line);
background: rgba(244, 247, 240, 0.8);
}
.result-box {
min-height: 88px;
margin: 10px 0 16px;
padding: 12px 14px;
border-radius: 16px;
border: 1px solid var(--line);
background: #f4f7ef;
color: var(--text);
font-family: "Cascadia Code", "Consolas", monospace;
font-size: 12px;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
}
table {
width: 100%;
border-collapse: collapse;
}
th,
td {
padding: 12px 14px;
border-bottom: 1px solid var(--line);
text-align: left;
vertical-align: top;
}
th {
position: sticky;
top: 0;
background: #edf2ea;
font-size: 12px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--muted);
}
td code,
.json-chip {
font-family: "Cascadia Code", "Consolas", monospace;
font-size: 12px;
}
.json-chip {
display: inline-block;
max-width: 100%;
padding: 8px 10px;
border-radius: 12px;
background: #f2f5ef;
white-space: pre-wrap;
word-break: break-word;
}
.empty {
padding: 18px;
color: var(--muted);
}
@media (max-width: 1180px) {
.shell {
grid-template-columns: 1fr;
}
.stats-grid,
.detail-grid {
grid-template-columns: 1fr;
}
.channel-form {
grid-template-columns: 1fr;
}
.live-stats,
.traffic-grid,
.live-panel-grid {
grid-template-columns: 1fr;
}
.live-controls {
justify-content: stretch;
}
}

View File

@@ -0,0 +1,24 @@
package gateway
import (
"slices"
"realtime-gateway/internal/config"
"realtime-gateway/internal/model"
)
func authorize(cfg config.AuthConfig, role model.Role, token string) bool {
switch role {
case model.RoleProducer:
return slices.Contains(cfg.ProducerTokens, token)
case model.RoleController:
return slices.Contains(cfg.ControllerTokens, token)
case model.RoleConsumer:
if cfg.AllowAnonymousConsumers && token == "" {
return true
}
return slices.Contains(cfg.ConsumerTokens, token)
default:
return false
}
}

View File

@@ -0,0 +1,58 @@
package gateway
import (
"encoding/json"
"net/http"
"realtime-gateway/internal/channel"
)
type createChannelRequest struct {
Label string `json:"label"`
DeliveryMode string `json:"deliveryMode"`
TTLSeconds int `json:"ttlSeconds"`
}
func (s *Server) registerChannelRoutes(mux *http.ServeMux) {
mux.HandleFunc("/api/channel/create", s.handleCreateChannel)
mux.HandleFunc("/api/admin/channels", s.handleAdminChannels)
}
func (s *Server) handleCreateChannel(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeJSON(w, http.StatusMethodNotAllowed, map[string]any{
"error": "method not allowed",
})
return
}
var request createChannelRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{
"error": "invalid json body",
})
return
}
created, err := s.channels.Create(channel.CreateRequest{
Label: request.Label,
DeliveryMode: request.DeliveryMode,
TTLSeconds: request.TTLSeconds,
})
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]any{
"error": err.Error(),
})
return
}
writeJSON(w, http.StatusOK, created)
}
func (s *Server) handleAdminChannels(w http.ResponseWriter, _ *http.Request) {
items := s.channels.List()
writeJSON(w, http.StatusOK, map[string]any{
"items": items,
"count": len(items),
})
}

View File

@@ -0,0 +1,277 @@
package gateway
import (
"context"
"encoding/json"
"errors"
"log/slog"
"net/http"
"strings"
"sync"
"time"
"github.com/coder/websocket"
"github.com/coder/websocket/wsjson"
"realtime-gateway/internal/channel"
"realtime-gateway/internal/config"
"realtime-gateway/internal/model"
"realtime-gateway/internal/plugin"
"realtime-gateway/internal/router"
"realtime-gateway/internal/session"
)
type client struct {
conn *websocket.Conn
logger *slog.Logger
cfg config.GatewayConfig
hub *router.Hub
channels *channel.Manager
plugins *plugin.Bus
session *session.Session
auth config.AuthConfig
writeMu sync.Mutex
}
func serveClient(
w http.ResponseWriter,
r *http.Request,
logger *slog.Logger,
cfg config.Config,
hub *router.Hub,
channels *channel.Manager,
plugins *plugin.Bus,
sessions *session.Manager,
) {
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
InsecureSkipVerify: true,
})
if err != nil {
logger.Error("websocket accept failed", "error", err)
return
}
sess := sessions.Create()
c := &client{
conn: conn,
logger: logger.With("sessionId", sess.ID),
cfg: cfg.Gateway,
hub: hub,
channels: channels,
plugins: plugins,
session: sess,
auth: cfg.Auth,
}
hub.Register(c, nil)
defer func() {
if sess.ChannelID != "" {
channels.Unbind(sess.ChannelID, sess.Role)
}
hub.Unregister(sess.ID)
sessions.Delete(sess.ID)
_ = conn.Close(websocket.StatusNormalClosure, "session closed")
}()
if err := c.run(r.Context()); err != nil && !errors.Is(err, context.Canceled) {
c.logger.Warn("client closed", "error", err)
}
}
func (c *client) ID() string {
return c.session.ID
}
func (c *client) Send(message model.ServerMessage) error {
c.writeMu.Lock()
defer c.writeMu.Unlock()
ctx, cancel := context.WithTimeout(context.Background(), c.cfg.WriteWait())
defer cancel()
return wsjson.Write(ctx, c.conn, message)
}
func (c *client) run(ctx context.Context) error {
if err := c.Send(model.ServerMessage{
Type: "welcome",
SessionID: c.session.ID,
}); err != nil {
return err
}
pingCtx, cancelPing := context.WithCancel(ctx)
defer cancelPing()
go c.pingLoop(pingCtx)
for {
readCtx, cancel := context.WithTimeout(ctx, c.cfg.PongWait())
var message model.ClientMessage
err := wsjson.Read(readCtx, c.conn, &message)
cancel()
if err != nil {
return err
}
if err := c.handleMessage(message); err != nil {
_ = c.Send(model.ServerMessage{
Type: "error",
Error: err.Error(),
})
}
}
}
func (c *client) handleMessage(message model.ClientMessage) error {
switch message.Type {
case "authenticate":
return c.handleAuthenticate(message)
case "join_channel":
return c.handleJoinChannel(message)
case "subscribe":
return c.handleSubscribe(message)
case "publish":
return c.handlePublish(message)
case "snapshot":
return c.handleSnapshot(message)
default:
return errors.New("unsupported message type")
}
}
func (c *client) handleJoinChannel(message model.ClientMessage) error {
if strings.TrimSpace(message.ChannelID) == "" {
return errors.New("channelId is required")
}
snapshot, err := c.channels.Join(message.ChannelID, message.Token, message.Role)
if err != nil {
return err
}
if c.session.ChannelID != "" {
c.channels.Unbind(c.session.ChannelID, c.session.Role)
}
if err := c.channels.Bind(snapshot.ID, message.Role); err != nil {
return err
}
c.session.Role = message.Role
c.session.Authenticated = true
c.session.ChannelID = snapshot.ID
c.session.Subscriptions = nil
c.hub.UpdateSubscriptions(c.session.ID, nil)
return c.Send(model.ServerMessage{
Type: "joined_channel",
SessionID: c.session.ID,
State: json.RawMessage([]byte(
`{"channelId":"` + snapshot.ID + `","deliveryMode":"` + snapshot.DeliveryMode + `"}`,
)),
})
}
func (c *client) handleAuthenticate(message model.ClientMessage) error {
if !authorize(c.auth, message.Role, message.Token) {
return errors.New("authentication failed")
}
c.session.Role = message.Role
c.session.Authenticated = true
return c.Send(model.ServerMessage{
Type: "authenticated",
SessionID: c.session.ID,
})
}
func (c *client) handleSubscribe(message model.ClientMessage) error {
if !c.session.Authenticated && !c.auth.AllowAnonymousConsumers {
return errors.New("consumer must authenticate before subscribe")
}
subscriptions := normalizeSubscriptions(c.session.ChannelID, message.Subscriptions)
c.session.Subscriptions = subscriptions
c.hub.UpdateSubscriptions(c.session.ID, subscriptions)
return c.Send(model.ServerMessage{
Type: "subscribed",
SessionID: c.session.ID,
})
}
func (c *client) handlePublish(message model.ClientMessage) error {
if !c.session.Authenticated {
return errors.New("authentication required")
}
if c.session.Role != model.RoleProducer && c.session.Role != model.RoleController {
return errors.New("publish is only allowed for producer or controller")
}
if message.Envelope == nil {
return errors.New("envelope is required")
}
envelope := *message.Envelope
if envelope.Source.Kind == "" {
envelope.Source.Kind = c.session.Role
}
if c.session.ChannelID != "" {
envelope.Target.ChannelID = c.session.ChannelID
}
deliveryMode := channel.DeliveryModeCacheLatest
if envelope.Target.ChannelID != "" {
deliveryMode = c.channels.DeliveryMode(envelope.Target.ChannelID)
}
result := c.hub.Publish(envelope, deliveryMode)
if !result.Dropped {
c.plugins.Publish(envelope)
}
return c.Send(model.ServerMessage{
Type: "published",
SessionID: c.session.ID,
})
}
func (c *client) handleSnapshot(message model.ClientMessage) error {
if len(message.Subscriptions) == 0 || message.Subscriptions[0].DeviceID == "" {
return errors.New("snapshot requires deviceId in first subscription")
}
channelID := message.Subscriptions[0].ChannelID
if channelID == "" {
channelID = c.session.ChannelID
}
state, ok := c.hub.Snapshot(channelID, message.Subscriptions[0].DeviceID)
if !ok {
return errors.New("snapshot not found")
}
return c.Send(model.ServerMessage{
Type: "snapshot",
SessionID: c.session.ID,
State: json.RawMessage(state),
})
}
func (c *client) pingLoop(ctx context.Context) {
ticker := time.NewTicker(c.cfg.PingInterval())
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
pingCtx, cancel := context.WithTimeout(ctx, c.cfg.WriteWait())
_ = c.conn.Ping(pingCtx)
cancel()
}
}
}
func normalizeSubscriptions(channelID string, subscriptions []model.Subscription) []model.Subscription {
items := make([]model.Subscription, 0, len(subscriptions))
for _, entry := range subscriptions {
if channelID != "" && strings.TrimSpace(entry.ChannelID) == "" {
entry.ChannelID = channelID
}
items = append(items, entry)
}
return items
}

View File

@@ -0,0 +1,109 @@
package gateway
import (
"context"
"encoding/json"
"log/slog"
"net/http"
"time"
"realtime-gateway/internal/channel"
"realtime-gateway/internal/config"
"realtime-gateway/internal/plugin"
"realtime-gateway/internal/router"
"realtime-gateway/internal/session"
)
type Server struct {
cfg config.Config
logger *slog.Logger
httpSrv *http.Server
channels *channel.Manager
hub *router.Hub
plugins *plugin.Bus
sessions *session.Manager
startedAt time.Time
}
func NewServer(cfg config.Config, logger *slog.Logger) (*Server, error) {
channels := channel.NewManager(8 * time.Hour)
hub := router.NewHub(cfg.Gateway.MaxLatestStateEntries)
plugins := plugin.NewBus(logger.With("component", "plugin-bus"))
sessions := session.NewManager()
mux := http.NewServeMux()
server := &Server{
cfg: cfg,
logger: logger,
channels: channels,
hub: hub,
plugins: plugins,
sessions: sessions,
startedAt: time.Now(),
httpSrv: &http.Server{
Addr: cfg.Server.HTTPListen,
ReadTimeout: cfg.Server.ReadTimeout(),
WriteTimeout: cfg.Server.WriteTimeout(),
IdleTimeout: cfg.Server.IdleTimeout(),
},
}
mux.HandleFunc("/healthz", server.handleHealth)
mux.HandleFunc("/metrics", server.handleMetrics)
mux.HandleFunc("/ws", server.handleWS)
server.registerChannelRoutes(mux)
if err := server.registerAdminRoutes(mux); err != nil {
return nil, err
}
server.httpSrv.Handler = mux
return server, nil
}
func (s *Server) Run(ctx context.Context) error {
errCh := make(chan error, 1)
go func() {
s.logger.Info("gateway listening", "addr", s.cfg.Server.HTTPListen)
errCh <- s.httpSrv.ListenAndServe()
}()
select {
case <-ctx.Done():
shutdownCtx, cancel := context.WithTimeout(context.Background(), s.cfg.Server.ShutdownTimeout())
defer cancel()
s.logger.Info("shutting down gateway")
return s.httpSrv.Shutdown(shutdownCtx)
case err := <-errCh:
if err == http.ErrServerClosed {
return nil
}
return err
}
}
func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{
"status": "ok",
})
}
func (s *Server) handleMetrics(w http.ResponseWriter, _ *http.Request) {
subscriberCount, latestStateCount := s.hub.Stats()
writeJSON(w, http.StatusOK, map[string]any{
"sessions": s.sessions.Count(),
"subscribers": subscriberCount,
"latestState": latestStateCount,
"pluginHandlers": s.plugins.HandlerCount(),
"httpListen": s.cfg.Server.HTTPListen,
"anonymousClient": s.cfg.Auth.AllowAnonymousConsumers,
})
}
func (s *Server) handleWS(w http.ResponseWriter, r *http.Request) {
serveClient(w, r, s.logger, s.cfg, s.hub, s.channels, s.plugins, s.sessions)
}
func writeJSON(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(value)
}