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:
commit
cf9af73281
27 changed files with 5123 additions and 0 deletions
382
src/views/SessionDetailView.vue
Normal file
382
src/views/SessionDetailView.vue
Normal file
|
|
@ -0,0 +1,382 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useSessionsStore } from '@/stores/sessions'
|
||||
import type { ShiftPointAnalysis, TrackMapAnalysis } from '@/types'
|
||||
|
||||
const props = defineProps<{
|
||||
id: string
|
||||
}>()
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const store = useSessionsStore()
|
||||
|
||||
const showStartRun = ref(false)
|
||||
const runFeedName = ref('ksudp')
|
||||
const runLabel = ref('')
|
||||
|
||||
const sessionId = computed(() => props.id ?? (route.params.id as string))
|
||||
const session = computed(() => store.currentSession)
|
||||
|
||||
const feedOptions = ['ksudp', 'telemetry_tool', 'pcars']
|
||||
|
||||
const hasActiveRun = computed(() => {
|
||||
return session.value?.runs.some((r) => r.status === 'recording') ?? false
|
||||
})
|
||||
|
||||
const isCompleted = computed(() => session.value?.status === 'completed')
|
||||
|
||||
const analysisData = computed(() => {
|
||||
if (!session.value?.analysis) return null
|
||||
return session.value.analysis as Record<string, unknown>
|
||||
})
|
||||
|
||||
const shiftAnalysis = computed((): ShiftPointAnalysis | null => {
|
||||
if (!analysisData.value) return null
|
||||
// The analysis field is the full analysis object
|
||||
const data = analysisData.value
|
||||
if (data && typeof data === 'object' && 'gear_ranges' in data) {
|
||||
return data as unknown as ShiftPointAnalysis
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
const trackAnalysis = computed((): TrackMapAnalysis | null => {
|
||||
if (!analysisData.value) return null
|
||||
const data = analysisData.value
|
||||
if (data && typeof data === 'object' && 'boundaries' in data) {
|
||||
return data as unknown as TrackMapAnalysis
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await store.fetchSession(sessionId.value)
|
||||
})
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
return new Date(iso).toLocaleString()
|
||||
}
|
||||
|
||||
async function handleStartRun() {
|
||||
const run = await store.startRun(sessionId.value, {
|
||||
feed_name: runFeedName.value,
|
||||
label: runLabel.value || undefined,
|
||||
})
|
||||
if (run) {
|
||||
showStartRun.value = false
|
||||
runLabel.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStopRun(runId: string) {
|
||||
await store.stopRun(sessionId.value, runId)
|
||||
}
|
||||
|
||||
async function handleComplete() {
|
||||
const result = await store.completeSession(sessionId.value)
|
||||
if (result) {
|
||||
// Refresh to get analysis
|
||||
await store.fetchSession(sessionId.value)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReAnalyze() {
|
||||
const result = await store.reAnalyze(sessionId.value)
|
||||
if (result !== null) {
|
||||
await store.fetchSession(sessionId.value)
|
||||
}
|
||||
}
|
||||
|
||||
function viewAnalysis() {
|
||||
if (!session.value) return
|
||||
if (session.value.use_case === 'shift_points') {
|
||||
router.push({ name: 'shift-points', params: { id: session.value.id } })
|
||||
} else if (session.value.use_case === 'track_map') {
|
||||
router.push({ name: 'track-map', params: { id: session.value.id } })
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (confirm('Delete this session?')) {
|
||||
await store.deleteSession(sessionId.value)
|
||||
router.push('/sessions')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="session-detail">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<button class="btn btn-secondary btn-sm" @click="router.push('/sessions')">← Back</button>
|
||||
<h2 class="page-title">
|
||||
{{ session?.label || 'Session' }}
|
||||
</h2>
|
||||
<span v-if="session" :class="['badge', session.status === 'active' ? 'badge-primary' : 'badge-muted']">
|
||||
{{ session.status }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="store.error" class="card mb-3 text-error">{{ store.error }}</div>
|
||||
<div v-if="store.loading && !session" class="card text-secondary">Loading...</div>
|
||||
|
||||
<template v-if="session">
|
||||
<!-- Session Info -->
|
||||
<section class="card mb-3">
|
||||
<div class="card-header">
|
||||
<h2>Session Info</h2>
|
||||
<div class="flex gap-1">
|
||||
<button v-if="!isCompleted" class="btn btn-danger btn-sm" @click="handleDelete">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-grid">
|
||||
<div class="info-item">
|
||||
<span class="info-label">Use Case</span>
|
||||
<span>{{ session.use_case.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()) }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">Created</span>
|
||||
<span>{{ formatTime(session.created_at) }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">Runs</span>
|
||||
<span>{{ session.runs.length }}</span>
|
||||
</div>
|
||||
<div v-if="session.completed_at" class="info-item">
|
||||
<span class="info-label">Completed</span>
|
||||
<span>{{ formatTime(session.completed_at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Runs -->
|
||||
<section class="card mb-3">
|
||||
<div class="card-header">
|
||||
<h2>Runs</h2>
|
||||
<div class="flex gap-1">
|
||||
<button
|
||||
v-if="!isCompleted && !hasActiveRun"
|
||||
class="btn btn-primary btn-sm"
|
||||
@click="showStartRun = !showStartRun"
|
||||
>
|
||||
{{ showStartRun ? 'Cancel' : '+ Start Run' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Start Run Form -->
|
||||
<div v-if="showStartRun" class="run-form mb-2">
|
||||
<div class="flex gap-2 items-center">
|
||||
<select v-model="runFeedName" class="form-select">
|
||||
<option v-for="f in feedOptions" :key="f" :value="f">{{ f }}</option>
|
||||
</select>
|
||||
<input
|
||||
v-model="runLabel"
|
||||
placeholder="Label (e.g. 'left', 'right')"
|
||||
class="form-input"
|
||||
@keyup.enter="handleStartRun"
|
||||
/>
|
||||
<button class="btn btn-primary btn-sm" @click="handleStartRun">Start</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="session.runs.length === 0" class="text-secondary text-muted">
|
||||
No runs yet. Start a run to capture telemetry data.
|
||||
</div>
|
||||
|
||||
<div v-else class="run-list">
|
||||
<div v-for="run in session.runs" :key="run.id" class="run-item">
|
||||
<div class="flex items-center gap-2">
|
||||
<span :class="['badge', run.status === 'recording' ? 'badge-warning' : 'badge-muted']">
|
||||
{{ run.status }}
|
||||
</span>
|
||||
<span class="run-label">{{ run.label || 'Untitled Run' }}</span>
|
||||
<span class="text-muted">·</span>
|
||||
<span class="text-secondary mono" style="font-size: 0.8rem">{{ run.feed_name }}</span>
|
||||
<span class="text-muted">·</span>
|
||||
<span class="text-secondary" style="font-size: 0.8rem">{{ run.frame_count }} frames</span>
|
||||
<span v-if="run.data_truncated" class="badge badge-warning" style="font-size: 0.65rem">TRUNCATED</span>
|
||||
</div>
|
||||
<div class="flex gap-1">
|
||||
<button
|
||||
v-if="run.status === 'recording'"
|
||||
class="btn btn-secondary btn-sm"
|
||||
@click="handleStopRun(run.id)"
|
||||
>
|
||||
■ Stop
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Complete / Analysis -->
|
||||
<section class="card">
|
||||
<div class="card-header">
|
||||
<h2>Analysis</h2>
|
||||
</div>
|
||||
|
||||
<div v-if="!isCompleted" class="text-secondary">
|
||||
<p class="mb-2">Complete the session to trigger automatic analysis.</p>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
:disabled="session.runs.length === 0 || hasActiveRun"
|
||||
@click="handleComplete"
|
||||
>
|
||||
✓ Complete Session
|
||||
</button>
|
||||
<p v-if="hasActiveRun" class="text-warning mt-1" style="font-size: 0.85rem">
|
||||
Stop all active runs before completing.
|
||||
</p>
|
||||
<p v-if="session.runs.length === 0" class="text-muted mt-1" style="font-size: 0.85rem">
|
||||
Add at least one run before completing.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="analysisData">
|
||||
<div class="analysis-summary mb-2">
|
||||
<span class="badge badge-success">Analyzed</span>
|
||||
<span class="text-secondary" style="font-size: 0.85rem; margin-left: 0.5rem">
|
||||
{{ session.use_case.replace(/_/g, ' ') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Quick stats for shift points -->
|
||||
<div v-if="shiftAnalysis" class="analysis-stats grid grid-2 mb-2">
|
||||
<div class="stat-card">
|
||||
<span class="stat-label">Recommended Shift RPM</span>
|
||||
<span class="stat-value mono">{{ Math.round(shiftAnalysis.recommended_shift_rpm ?? 0) }}</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-label">Shift Events Detected</span>
|
||||
<span class="stat-value mono">{{ shiftAnalysis.shift_events.length }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick stats for track map -->
|
||||
<div v-if="trackAnalysis" class="analysis-stats grid grid-2 mb-2">
|
||||
<div class="stat-card">
|
||||
<span class="stat-label">Boundaries</span>
|
||||
<span class="stat-value mono">{{ trackAnalysis.boundaries.length }}</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-label">Total Points</span>
|
||||
<span class="stat-value mono">{{ trackAnalysis.total_points.toLocaleString() }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<button class="btn btn-primary" @click="viewAnalysis">
|
||||
📊 View Full Analysis
|
||||
</button>
|
||||
<button class="btn btn-secondary" @click="handleReAnalyze">
|
||||
🔄 Re-analyze
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="text-secondary">
|
||||
<p>Analysis not available.</p>
|
||||
<button class="btn btn-secondary btn-sm mt-1" @click="handleReAnalyze">
|
||||
Run Analysis
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-title {
|
||||
font-size: 1.5rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.run-form {
|
||||
padding: 0.75rem;
|
||||
background: var(--color-background);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.run-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.run-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.6rem 0.75rem;
|
||||
background: var(--color-surface-hover);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.run-label {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
padding: 0.75rem;
|
||||
background: var(--color-background);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.stat-card .stat-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.stat-card .stat-value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.form-select,
|
||||
.form-input {
|
||||
padding: 0.4rem 0.75rem;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.form-select:focus,
|
||||
.form-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.form-input {
|
||||
flex: 1;
|
||||
min-width: 150px;
|
||||
}
|
||||
</style>
|
||||
Loading…
Add table
Add a link
Reference in a new issue