feat: initial Racecraft web client v0.1.0

Vue 3 + TypeScript + Vite web client for rusty-telemetry.

- Dashboard with system status, live feeds, quick actions
- Live telemetry view with real-time gauges (speed/RPM/gear)
- Session management (create, run, complete, analyze)
- Shift point analysis with Chart.js visualizations
- Track map with Canvas 2D boundary rendering
- Typed API client for all 19 REST endpoints
- Dark theme, network-accessible via Vite 0.0.0.0 bind
This commit is contained in:
Jan-Peter von Hunnius 2026-06-05 16:12:33 +02:00
commit cf9af73281
27 changed files with 5123 additions and 0 deletions

89
src/stores/telemetry.ts Normal file
View file

@ -0,0 +1,89 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { LiveTelemetryResponse, LiveFeedSnapshot } from '@/types'
import { getLiveTelemetry } from '@/api/client'
export const useTelemetryStore = defineStore('telemetry', () => {
const feeds = ref<LiveFeedSnapshot[]>([])
const activeRecording = ref<string | null>(null)
const connected = ref(false)
const error = ref<string | null>(null)
const lastUpdate = ref<Date | null>(null)
let pollTimer: ReturnType<typeof setInterval> | null = null
const anyFeedAlive = computed(() => feeds.value.some((f) => f.health === 'alive'))
const anyFeedStale = computed(() => feeds.value.some((f) => f.health === 'stale'))
const totalPackets = computed(() => feeds.value.reduce((sum, f) => sum + f.total_packets, 0))
const simConnected = computed(() => anyFeedAlive.value || anyFeedStale.value)
const overallStatus = computed((): 'connected' | 'sim-active' | 'sim-stale' | 'disconnected' => {
if (!connected.value) return 'disconnected'
if (anyFeedAlive.value) return 'sim-active'
if (anyFeedStale.value) return 'sim-stale'
return 'connected'
})
const overallStatusLabel = computed((): string => {
switch (overallStatus.value) {
case 'sim-active': return 'Sim Connected'
case 'sim-stale': return 'Sim Stale'
case 'connected': return 'API Connected — No Sim'
case 'disconnected': return 'API Disconnected'
}
})
const overallStatusClass = computed((): string => {
switch (overallStatus.value) {
case 'sim-active': return 'badge-success'
case 'sim-stale': return 'badge-warning'
case 'connected': return 'badge-primary'
case 'disconnected': return 'badge-error'
}
})
async function fetchLive() {
try {
const data: LiveTelemetryResponse = await getLiveTelemetry()
feeds.value = data.feeds
activeRecording.value = data.active_recording
connected.value = true
error.value = null
lastUpdate.value = new Date()
} catch (e: unknown) {
connected.value = false
error.value = e instanceof Error ? e.message : 'Connection failed'
}
}
function startPolling(intervalMs = 250) {
stopPolling()
fetchLive()
pollTimer = setInterval(fetchLive, intervalMs)
}
function stopPolling() {
if (pollTimer !== null) {
clearInterval(pollTimer)
pollTimer = null
}
}
return {
feeds,
activeRecording,
connected,
error,
lastUpdate,
anyFeedAlive,
anyFeedStale,
totalPackets,
simConnected,
overallStatus,
overallStatusLabel,
overallStatusClass,
fetchLive,
startPolling,
stopPolling,
}
})