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
26
.gitignore
vendored
Normal file
26
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
|
||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Build output
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
|
||||||
|
# TypeScript build info
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Environment files with secrets
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
57
AGENTS.md
Normal file
57
AGENTS.md
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
# AGENTS.md — Racecraft Project Context
|
||||||
|
|
||||||
|
## Mandatory Reference
|
||||||
|
|
||||||
|
**Before starting any work on this project, always read the Joplin note that contains the full project plan and architecture.**
|
||||||
|
|
||||||
|
- **Joplin Note ID**: `6c0dcb2a567348fd9796f50c790082e4`
|
||||||
|
- **Title**: "Sim Racing Telemetry Analysis Platform — Project Plan"
|
||||||
|
- **Tags**: sim-racing, lua, telemetry, rust, project-plan, assetto-corsa
|
||||||
|
|
||||||
|
Use the `joplin_get_note` tool with note ID `6c0dcb2a567348fd9796f50c790082e4` to retrieve the full project plan.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
`racecraft` is a Vue.js 3 + TypeScript web client for the [rusty-telemetry](https://codeberg.org/jhunnius/rusty-telemetry) sim racing telemetry platform. It provides live telemetry visualization, session management, and data analysis through a browser-based interface.
|
||||||
|
|
||||||
|
## Companion Repository
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|-------|-------|
|
||||||
|
| **Repo** | `rusty-telemetry` |
|
||||||
|
| **Path** | `~/RustroverProjects/rusty-telemetry` |
|
||||||
|
| **Language** | Rust |
|
||||||
|
| **Purpose** | UDP listener, REST API backend, analysis engine |
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- Vue 3.5 (Composition API, `<script setup>`)
|
||||||
|
- TypeScript 5.7
|
||||||
|
- Vite 6 (dev server with API proxy)
|
||||||
|
- Pinia 3 (state management)
|
||||||
|
- Vue Router 4 (client-side routing)
|
||||||
|
- Axios (HTTP client)
|
||||||
|
- Chart.js 4 + vue-chartjs 5 (data visualization)
|
||||||
|
- HTML Canvas 2D (track map rendering)
|
||||||
|
|
||||||
|
## Key Ports
|
||||||
|
|
||||||
|
| Port | Component | Purpose |
|
||||||
|
|------|-----------|---------|
|
||||||
|
| 8080 | rusty-telemetry API | REST API backend |
|
||||||
|
| 5173 | racecraft (Vite dev) | Frontend dev server |
|
||||||
|
| 9996 | rusty-telemetry UDP | AC KSUDP handshake |
|
||||||
|
| 10101 | rusty-telemetry UDP | AC Telemetry Tool |
|
||||||
|
| 5606 | rusty-telemetry UDP | PCARS telemetry |
|
||||||
|
|
||||||
|
## API Communication
|
||||||
|
|
||||||
|
Racecraft communicates exclusively with the rusty-telemetry REST API. In development, Vite proxies `/api/*` to `http://127.0.0.1:8080`. See the rusty-telemetry README for full endpoint documentation.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev # Start dev server (0.0.0.0:5173)
|
||||||
|
npm run build # TypeScript check + Vite production build
|
||||||
|
npm run preview # Preview production build
|
||||||
|
```
|
||||||
174
README.md
Normal file
174
README.md
Normal file
|
|
@ -0,0 +1,174 @@
|
||||||
|
# Racecraft
|
||||||
|
|
||||||
|
Web-based sim racing telemetry analysis client. Connects to [rusty-telemetry](https://codeberg.org/jhunnius/rusty-telemetry) via its REST API and provides live telemetry visualization, session management, and data analysis.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────┐
|
||||||
|
│ Sim Machine │
|
||||||
|
│ │
|
||||||
|
│ ┌──────────────┐ ┌───────────────┐ │
|
||||||
|
│ │ Assetto │ │ rusty- │ │
|
||||||
|
│ │ Corsa │──│ telemetry │ │
|
||||||
|
│ │ (UDP out) │ │ :8080 (API) │ │
|
||||||
|
│ └──────────────┘ └───────────────┘ │
|
||||||
|
│ ┌───────────────┐ │
|
||||||
|
│ │ racecraft │ │
|
||||||
|
│ │ :5173 (Vite) │ │
|
||||||
|
│ └───────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
LAN / WiFi
|
||||||
|
│
|
||||||
|
┌──────────────────┐
|
||||||
|
│ Other machine │
|
||||||
|
│ Browser │
|
||||||
|
└──────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- **rusty-telemetry** listens for UDP telemetry from sim games on ports 9996, 10101, 5606
|
||||||
|
- **racecraft** is a Vue.js SPA that talks to the rusty-telemetry REST API via a Vite dev proxy
|
||||||
|
- The Vite dev server binds to `0.0.0.0`, making it accessible from other machines on the LAN
|
||||||
|
- API calls (`/api/*`) are proxied to `http://127.0.0.1:8080`
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### Dashboard
|
||||||
|
- System status overview: API server connectivity, sim connection state, active recording
|
||||||
|
- Live feed cards with health indicators, frequency, and telemetry snapshot
|
||||||
|
- Quick access to live telemetry and session management
|
||||||
|
- Recent sessions list
|
||||||
|
|
||||||
|
### Live Telemetry
|
||||||
|
- Real-time gauges: speed (km/h), RPM, gear indicator
|
||||||
|
- Throttle and brake input bars
|
||||||
|
- Lap time and track position (spline)
|
||||||
|
- Per-feed status cards with health, frequency, and packet stats
|
||||||
|
- Polling at 250ms for responsive updates
|
||||||
|
|
||||||
|
### Session Management
|
||||||
|
- Create analysis sessions with use case selection (Shift Points, Track Map, etc.)
|
||||||
|
- Start/stop runs within a session to capture telemetry data
|
||||||
|
- Complete sessions to trigger automatic analysis
|
||||||
|
- Session detail view with run history and status
|
||||||
|
|
||||||
|
### Shift Point Analysis
|
||||||
|
- Gear RPM range visualization (min/avg/max bar chart)
|
||||||
|
- Shift event timeline (RPM before/after each upshift)
|
||||||
|
- Recommended shift RPM from aggregated data
|
||||||
|
- Detailed shift event table
|
||||||
|
|
||||||
|
### Track Map
|
||||||
|
- Canvas-rendered track boundaries from coordinate data
|
||||||
|
- Center line computation from left/right boundary runs
|
||||||
|
- Boundary legend with point counts
|
||||||
|
- Responsive canvas that redraws on window resize
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
| Layer | Technology |
|
||||||
|
|-------|-----------|
|
||||||
|
| Framework | Vue 3.5 (Composition API, `<script setup>`) |
|
||||||
|
| Language | TypeScript 5.7 |
|
||||||
|
| Build | Vite 6 |
|
||||||
|
| Routing | Vue Router 4 |
|
||||||
|
| State | Pinia 3 |
|
||||||
|
| HTTP | Axios |
|
||||||
|
| Charts | Chart.js 4 + vue-chartjs 5 |
|
||||||
|
| Track Map | HTML Canvas 2D |
|
||||||
|
| Styling | CSS with custom properties (dark theme) |
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Node.js 18+ and npm
|
||||||
|
- [rusty-telemetry](https://codeberg.org/jhunnius/rusty-telemetry) running on the same machine
|
||||||
|
|
||||||
|
### Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/WebstormProjects/racecraft
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run (Development)
|
||||||
|
|
||||||
|
Start both the backend and frontend:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Terminal 1 — Backend (rusty-telemetry)
|
||||||
|
cd ~/RustroverProjects/rusty-telemetry
|
||||||
|
RUSTY_TELEMETRY_API_HOST=0.0.0.0 cargo run --release
|
||||||
|
|
||||||
|
# Terminal 2 — Frontend (racecraft)
|
||||||
|
cd ~/WebstormProjects/racecraft
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Vite will output:
|
||||||
|
```
|
||||||
|
➜ Local: http://localhost:5173/
|
||||||
|
➜ Network: http://192.168.1.x:5173/
|
||||||
|
```
|
||||||
|
|
||||||
|
- **From the same machine**: open `http://localhost:5173`
|
||||||
|
- **From another machine**: open `http://192.168.1.x:5173` (the Network URL)
|
||||||
|
|
||||||
|
The `RUSTY_TELEMETRY_API_HOST=0.0.0.0` env var makes the backend API accessible from all network interfaces. In dev mode, the Vite proxy handles routing `/api/*` to the backend.
|
||||||
|
|
||||||
|
### Production Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Output is in `dist/`. Serve with any static file server, or configure axum in rusty-telemetry to serve the built files.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
racecraft/
|
||||||
|
├── index.html
|
||||||
|
├── package.json
|
||||||
|
├── vite.config.ts # host: 0.0.0.0, proxy /api → localhost:8080
|
||||||
|
├── tsconfig.json
|
||||||
|
├── src/
|
||||||
|
│ ├── main.ts # App bootstrap (Vue + Pinia + Router)
|
||||||
|
│ ├── App.vue # Root layout with navigation
|
||||||
|
│ ├── router/index.ts # 6 routes with lazy loading
|
||||||
|
│ ├── types/index.ts # TypeScript interfaces matching API JSON shapes
|
||||||
|
│ ├── api/client.ts # Typed Axios client (all API endpoints)
|
||||||
|
│ ├── stores/
|
||||||
|
│ │ ├── telemetry.ts # Live telemetry with polling + derived status
|
||||||
|
│ │ └── sessions.ts # Session CRUD, run management, analysis
|
||||||
|
│ ├── views/
|
||||||
|
│ │ ├── DashboardView.vue # System status, feeds, quick actions
|
||||||
|
│ │ ├── LiveView.vue # Real-time gauges and telemetry
|
||||||
|
│ │ ├── SessionsView.vue # Session list with create/delete
|
||||||
|
│ │ ├── SessionDetailView.vue # Run workflow, complete, analyze
|
||||||
|
│ │ ├── ShiftPointsView.vue # RPM charts and shift event table
|
||||||
|
│ │ └── TrackMapView.vue # Canvas track map renderer
|
||||||
|
│ ├── components/
|
||||||
|
│ │ └── FeedStatus.vue # Reusable feed card with health + telemetry
|
||||||
|
│ └── assets/
|
||||||
|
│ └── main.css # Dark theme, CSS variables, utilities
|
||||||
|
└── public/
|
||||||
|
└── favicon.ico
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Contract
|
||||||
|
|
||||||
|
Racecraft consumes the rusty-telemetry REST API (19 endpoints). See the [rusty-telemetry README](https://codeberg.org/jhunnius/rusty-telemetry) for full endpoint documentation.
|
||||||
|
|
||||||
|
Key endpoints used:
|
||||||
|
- `GET /api/live` — polled every 250ms–1000ms for live telemetry
|
||||||
|
- `GET /api/sessions` / `POST /api/sessions` — session management
|
||||||
|
- `POST /api/sessions/{id}/runs` / `POST /api/sessions/{id}/runs/{run_id}/stop` — run capture
|
||||||
|
- `POST /api/sessions/{id}/complete` — complete session and trigger analysis
|
||||||
|
- `GET /api/sessions/{id}/analysis` — retrieve analysis results
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Private project — not yet licensed for distribution.
|
||||||
6
env.d.ts
vendored
Normal file
6
env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
/// <reference types="vite/client" />
|
||||||
|
declare module '*.vue' {
|
||||||
|
import type { DefineComponent } from 'vue'
|
||||||
|
const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, unknown>
|
||||||
|
export default component
|
||||||
|
}
|
||||||
13
index.html
Normal file
13
index.html
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<link rel="icon" href="/favicon.ico">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Racecraft</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1892
package-lock.json
generated
Normal file
1892
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
26
package.json
Normal file
26
package.json
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
{
|
||||||
|
"name": "racecraft",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vue-tsc -b && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"vue": "^3.5.13",
|
||||||
|
"vue-router": "^4.5.0",
|
||||||
|
"pinia": "^3.0.0",
|
||||||
|
"axios": "^1.9.0",
|
||||||
|
"chart.js": "^4.4.9",
|
||||||
|
"vue-chartjs": "^5.3.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.0.0",
|
||||||
|
"@vitejs/plugin-vue": "^5.2.3",
|
||||||
|
"typescript": "~5.7.0",
|
||||||
|
"vite": "^6.3.0",
|
||||||
|
"vue-tsc": "^2.2.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
0
public/favicon.ico
Normal file
0
public/favicon.ico
Normal file
115
src/App.vue
Normal file
115
src/App.vue
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { RouterLink, RouterView, useRoute } from 'vue-router'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const currentPath = computed(() => route.path)
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ path: '/', label: 'Dashboard', icon: '🏠' },
|
||||||
|
{ path: '/live', label: 'Live', icon: '📡' },
|
||||||
|
{ path: '/sessions', label: 'Sessions', icon: '📊' },
|
||||||
|
]
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="app-layout">
|
||||||
|
<header class="app-header">
|
||||||
|
<div class="logo">
|
||||||
|
<span class="logo-icon">🏁</span>
|
||||||
|
<h1>Racecraft</h1>
|
||||||
|
</div>
|
||||||
|
<nav class="main-nav">
|
||||||
|
<RouterLink
|
||||||
|
v-for="item in navItems"
|
||||||
|
:key="item.path"
|
||||||
|
:to="item.path"
|
||||||
|
:class="{ active: currentPath === item.path || (item.path !== '/' && currentPath.startsWith(item.path)) }"
|
||||||
|
>
|
||||||
|
<span class="nav-icon">{{ item.icon }}</span>
|
||||||
|
<span class="nav-label">{{ item.label }}</span>
|
||||||
|
</RouterLink>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
<main class="app-main">
|
||||||
|
<RouterView />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.app-layout {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2rem;
|
||||||
|
padding: 0 1.5rem;
|
||||||
|
height: 56px;
|
||||||
|
background: var(--color-header);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-icon {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo h1 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-nav {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-nav a {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-nav a:hover {
|
||||||
|
background: var(--color-hover);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-nav a.active {
|
||||||
|
background: var(--color-active);
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-icon {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-main {
|
||||||
|
flex: 1;
|
||||||
|
padding: 1.5rem;
|
||||||
|
max-width: 1400px;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
118
src/api/client.ts
Normal file
118
src/api/client.ts
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
import axios from 'axios'
|
||||||
|
import type {
|
||||||
|
LiveTelemetryResponse,
|
||||||
|
RecordingManifest,
|
||||||
|
StartRecordingRequest,
|
||||||
|
UseCaseSession,
|
||||||
|
CreateSessionRequest,
|
||||||
|
StartRunRequest,
|
||||||
|
SessionRun,
|
||||||
|
UseCase,
|
||||||
|
Game,
|
||||||
|
} from '@/types'
|
||||||
|
|
||||||
|
const api = axios.create({
|
||||||
|
baseURL: '/api',
|
||||||
|
timeout: 10000,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Live Telemetry ──
|
||||||
|
|
||||||
|
export async function getLiveTelemetry(): Promise<LiveTelemetryResponse> {
|
||||||
|
const res = await api.get<LiveTelemetryResponse>('/live')
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Recordings ──
|
||||||
|
|
||||||
|
export async function listRecordings(): Promise<RecordingManifest[]> {
|
||||||
|
const res = await api.get<RecordingManifest[]>('/recordings')
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRecording(id: string): Promise<RecordingManifest> {
|
||||||
|
const res = await api.get<RecordingManifest>(`/recordings/${id}`)
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startRecording(req?: StartRecordingRequest): Promise<RecordingManifest> {
|
||||||
|
const res = await api.post<RecordingManifest>('/recordings', req ?? {})
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function stopActiveRecording(): Promise<RecordingManifest> {
|
||||||
|
const res = await api.post<RecordingManifest>('/recordings/stop')
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function stopRecording(id: string): Promise<RecordingManifest> {
|
||||||
|
const res = await api.post<RecordingManifest>(`/recordings/${id}/stop`)
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteRecording(id: string): Promise<void> {
|
||||||
|
await api.delete(`/recordings/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Sessions ──
|
||||||
|
|
||||||
|
export async function listSessions(): Promise<UseCaseSession[]> {
|
||||||
|
const res = await api.get<UseCaseSession[]>('/sessions')
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSession(id: string): Promise<UseCaseSession> {
|
||||||
|
const res = await api.get<UseCaseSession>(`/sessions/${id}`)
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createSession(req: CreateSessionRequest): Promise<UseCaseSession> {
|
||||||
|
const res = await api.post<UseCaseSession>('/sessions', req)
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteSession(id: string): Promise<void> {
|
||||||
|
await api.delete(`/sessions/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startRun(sessionId: string, req: StartRunRequest): Promise<SessionRun> {
|
||||||
|
const res = await api.post<SessionRun>(`/sessions/${sessionId}/runs`, req)
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function stopRun(sessionId: string, runId: string): Promise<SessionRun> {
|
||||||
|
const res = await api.post<SessionRun>(`/sessions/${sessionId}/runs/${runId}/stop`)
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function completeSession(id: string): Promise<UseCaseSession> {
|
||||||
|
const res = await api.post<UseCaseSession>(`/sessions/${id}/complete`)
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAnalysis(id: string): Promise<Record<string, unknown>> {
|
||||||
|
const res = await api.get<Record<string, unknown>>(`/sessions/${id}/analysis`)
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function reAnalyze(id: string): Promise<Record<string, unknown>> {
|
||||||
|
const res = await api.post<Record<string, unknown>>(`/sessions/${id}/analyze`)
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Metadata ──
|
||||||
|
|
||||||
|
export async function getUseCases(): Promise<UseCase[]> {
|
||||||
|
const res = await api.get<UseCase[]>('/use-cases')
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getGames(): Promise<Game[]> {
|
||||||
|
const res = await api.get<Game[]>('/games')
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export default api
|
||||||
268
src/assets/main.css
Normal file
268
src/assets/main.css
Normal file
|
|
@ -0,0 +1,268 @@
|
||||||
|
:root {
|
||||||
|
--color-background: #0f1117;
|
||||||
|
--color-surface: #1a1d27;
|
||||||
|
--color-surface-hover: #22263a;
|
||||||
|
--color-header: #161924;
|
||||||
|
--color-border: #2a2e3f;
|
||||||
|
--color-text: #e2e4ea;
|
||||||
|
--color-text-secondary: #8b8fa3;
|
||||||
|
--color-text-muted: #5c607a;
|
||||||
|
--color-primary: #4f8ff7;
|
||||||
|
--color-primary-dim: #3a6fd4;
|
||||||
|
--color-success: #34d399;
|
||||||
|
--color-warning: #fbbf24;
|
||||||
|
--color-error: #f87171;
|
||||||
|
--color-hover: rgba(79, 143, 247, 0.08);
|
||||||
|
--color-active: rgba(79, 143, 247, 0.15);
|
||||||
|
|
||||||
|
--font-mono: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace;
|
||||||
|
--radius: 8px;
|
||||||
|
--radius-sm: 4px;
|
||||||
|
--shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
font-size: 16px;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: var(--color-background);
|
||||||
|
color: var(--color-text);
|
||||||
|
line-height: 1.5;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--color-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2, h3 {
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
input, select, textarea {
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Utility classes */
|
||||||
|
.card {
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 1.25rem;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding-bottom: 0.75rem;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header h2 {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: var(--color-primary-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: var(--color-surface-hover);
|
||||||
|
color: var(--color-text);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: rgba(248, 113, 113, 0.15);
|
||||||
|
color: var(--color-error);
|
||||||
|
border: 1px solid rgba(248, 113, 113, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover {
|
||||||
|
background: rgba(248, 113, 113, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-sm {
|
||||||
|
padding: 0.3rem 0.75rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mono {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.15rem 0.5rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-success {
|
||||||
|
background: rgba(52, 211, 153, 0.15);
|
||||||
|
color: var(--color-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-warning {
|
||||||
|
background: rgba(251, 191, 36, 0.15);
|
||||||
|
color: var(--color-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-error {
|
||||||
|
background: rgba(248, 113, 113, 0.15);
|
||||||
|
color: var(--color-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-muted {
|
||||||
|
background: rgba(139, 143, 163, 0.15);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-primary {
|
||||||
|
background: rgba(79, 143, 247, 0.15);
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-2 {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-3 {
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.grid-2, .grid-3 {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-muted {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-secondary {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-success {
|
||||||
|
color: var(--color-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-warning {
|
||||||
|
color: var(--color-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-error {
|
||||||
|
color: var(--color-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mt-1 { margin-top: 0.5rem; }
|
||||||
|
.mt-2 { margin-top: 1rem; }
|
||||||
|
.mt-3 { margin-top: 1.5rem; }
|
||||||
|
.mb-1 { margin-bottom: 0.5rem; }
|
||||||
|
.mb-2 { margin-bottom: 1rem; }
|
||||||
|
.mb-3 { margin-bottom: 1.5rem; }
|
||||||
|
|
||||||
|
.flex { display: flex; }
|
||||||
|
.flex-col { flex-direction: column; }
|
||||||
|
.items-center { align-items: center; }
|
||||||
|
.justify-between { justify-content: space-between; }
|
||||||
|
.gap-1 { gap: 0.5rem; }
|
||||||
|
.gap-2 { gap: 1rem; }
|
||||||
|
.gap-3 { gap: 1.5rem; }
|
||||||
|
|
||||||
|
/* Fade transition */
|
||||||
|
.fade-enter-active,
|
||||||
|
.fade-leave-active {
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-enter-from,
|
||||||
|
.fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbar styling */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: var(--color-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--color-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--color-text-muted);
|
||||||
|
}
|
||||||
169
src/components/FeedStatus.vue
Normal file
169
src/components/FeedStatus.vue
Normal file
|
|
@ -0,0 +1,169 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { LiveFeedSnapshot } from '@/types'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
feed: LiveFeedSnapshot
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const healthClass = computed(() => {
|
||||||
|
switch (props.feed.health) {
|
||||||
|
case 'alive': return 'badge-success'
|
||||||
|
case 'stale': return 'badge-warning'
|
||||||
|
case 'error': return 'badge-error'
|
||||||
|
default: return 'badge-muted'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const healthIcon = computed(() => {
|
||||||
|
switch (props.feed.health) {
|
||||||
|
case 'alive': return '●'
|
||||||
|
case 'stale': return '◐'
|
||||||
|
case 'error': return '✕'
|
||||||
|
default: return '○'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const statusMessage = computed(() => {
|
||||||
|
switch (props.feed.health) {
|
||||||
|
case 'alive':
|
||||||
|
return null // telemetry section handles this
|
||||||
|
case 'stale':
|
||||||
|
return 'Data stream stalled — sim may have paused or exited'
|
||||||
|
case 'error':
|
||||||
|
return 'Feed error — check network configuration'
|
||||||
|
default: // waiting
|
||||||
|
if (props.feed.port === 9996) {
|
||||||
|
return 'Waiting for AC handshake — start a session in Assetto Corsa'
|
||||||
|
}
|
||||||
|
return 'Waiting for incoming data on port ' + props.feed.port
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="feed-card">
|
||||||
|
<div class="feed-header">
|
||||||
|
<span class="feed-name">{{ feed.label }}</span>
|
||||||
|
<span :class="['badge', healthClass]">
|
||||||
|
{{ healthIcon }} {{ feed.health }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="feed-stats">
|
||||||
|
<div class="stat">
|
||||||
|
<span class="stat-label">Port</span>
|
||||||
|
<span class="stat-value mono">{{ feed.port }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<span class="stat-label">Freq</span>
|
||||||
|
<span class="stat-value mono">{{ feed.frequency_hz.toFixed(1) }} Hz</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<span class="stat-label">Packets</span>
|
||||||
|
<span class="stat-value mono">{{ feed.total_packets.toLocaleString() }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<span class="stat-label">Rate</span>
|
||||||
|
<span class="stat-value mono">{{ feed.data_rate_kb_s.toFixed(1) }} KB/s</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Live telemetry (only when alive) -->
|
||||||
|
<div v-if="feed.health === 'alive'" class="feed-telemetry">
|
||||||
|
<div class="telem-row">
|
||||||
|
<span class="telem-label">Speed</span>
|
||||||
|
<span class="telem-value mono">{{ feed.speed_kmh !== null ? Math.round(feed.speed_kmh) + ' km/h' : '---' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="telem-row">
|
||||||
|
<span class="telem-label">Gear</span>
|
||||||
|
<span class="telem-value mono">{{ feed.gear ?? '-' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="telem-row">
|
||||||
|
<span class="telem-label">RPM</span>
|
||||||
|
<span class="telem-value mono">{{ feed.engine_rpm !== null ? Math.round(feed.engine_rpm) : '---' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="telem-row">
|
||||||
|
<span class="telem-label">Lap</span>
|
||||||
|
<span class="telem-value mono">{{ feed.lap_time ?? '-:--.---' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Status message for non-alive states -->
|
||||||
|
<div v-if="statusMessage" class="feed-message">
|
||||||
|
{{ statusMessage }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.feed-card {
|
||||||
|
background: var(--color-surface-hover);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-name {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 0.25rem 0.75rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-telemetry {
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.telem-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.telem-label {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.telem-value {
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-message {
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
10
src/main.ts
Normal file
10
src/main.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { createApp } from 'vue'
|
||||||
|
import { createPinia } from 'pinia'
|
||||||
|
import App from './App.vue'
|
||||||
|
import router from './router'
|
||||||
|
import './assets/main.css'
|
||||||
|
|
||||||
|
const app = createApp(App)
|
||||||
|
app.use(createPinia())
|
||||||
|
app.use(router)
|
||||||
|
app.mount('#app')
|
||||||
42
src/router/index.ts
Normal file
42
src/router/index.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(import.meta.env.BASE_URL),
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
name: 'dashboard',
|
||||||
|
component: () => import('@/views/DashboardView.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/live',
|
||||||
|
name: 'live',
|
||||||
|
component: () => import('@/views/LiveView.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/sessions',
|
||||||
|
name: 'sessions',
|
||||||
|
component: () => import('@/views/SessionsView.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/sessions/:id',
|
||||||
|
name: 'session-detail',
|
||||||
|
component: () => import('@/views/SessionDetailView.vue'),
|
||||||
|
props: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/analysis/shift-points/:id',
|
||||||
|
name: 'shift-points',
|
||||||
|
component: () => import('@/views/ShiftPointsView.vue'),
|
||||||
|
props: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/analysis/track-map/:id',
|
||||||
|
name: 'track-map',
|
||||||
|
component: () => import('@/views/TrackMapView.vue'),
|
||||||
|
props: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
136
src/stores/sessions.ts
Normal file
136
src/stores/sessions.ts
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import type { UseCaseSession, CreateSessionRequest, StartRunRequest, SessionRun } from '@/types'
|
||||||
|
import * as api from '@/api/client'
|
||||||
|
|
||||||
|
export const useSessionsStore = defineStore('sessions', () => {
|
||||||
|
const sessions = ref<UseCaseSession[]>([])
|
||||||
|
const currentSession = ref<UseCaseSession | null>(null)
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
async function fetchSessions() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
sessions.value = await api.listSessions()
|
||||||
|
} catch (e: unknown) {
|
||||||
|
error.value = e instanceof Error ? e.message : 'Failed to load sessions'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchSession(id: string) {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
currentSession.value = await api.getSession(id)
|
||||||
|
} catch (e: unknown) {
|
||||||
|
error.value = e instanceof Error ? e.message : 'Failed to load session'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createSession(req: CreateSessionRequest): Promise<UseCaseSession | null> {
|
||||||
|
try {
|
||||||
|
const session = await api.createSession(req)
|
||||||
|
sessions.value.unshift(session)
|
||||||
|
return session
|
||||||
|
} catch (e: unknown) {
|
||||||
|
error.value = e instanceof Error ? e.message : 'Failed to create session'
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteSession(id: string) {
|
||||||
|
try {
|
||||||
|
await api.deleteSession(id)
|
||||||
|
sessions.value = sessions.value.filter((s) => s.id !== id)
|
||||||
|
if (currentSession.value?.id === id) {
|
||||||
|
currentSession.value = null
|
||||||
|
}
|
||||||
|
} catch (e: unknown) {
|
||||||
|
error.value = e instanceof Error ? e.message : 'Failed to delete session'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startRun(sessionId: string, req: StartRunRequest): Promise<SessionRun | null> {
|
||||||
|
try {
|
||||||
|
const run = await api.startRun(sessionId, req)
|
||||||
|
if (currentSession.value?.id === sessionId) {
|
||||||
|
currentSession.value.runs.push(run)
|
||||||
|
const sIdx = sessions.value.findIndex((s) => s.id === sessionId)
|
||||||
|
if (sIdx !== -1) {
|
||||||
|
sessions.value[sIdx] = { ...sessions.value[sIdx], runs: [...currentSession.value!.runs] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return run
|
||||||
|
} catch (e: unknown) {
|
||||||
|
error.value = e instanceof Error ? e.message : 'Failed to start run'
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopRun(sessionId: string, runId: string): Promise<SessionRun | null> {
|
||||||
|
try {
|
||||||
|
const run = await api.stopRun(sessionId, runId)
|
||||||
|
if (currentSession.value?.id === sessionId) {
|
||||||
|
const idx = currentSession.value.runs.findIndex((r) => r.id === runId)
|
||||||
|
if (idx !== -1) {
|
||||||
|
currentSession.value.runs[idx] = run
|
||||||
|
}
|
||||||
|
const sIdx = sessions.value.findIndex((s) => s.id === sessionId)
|
||||||
|
if (sIdx !== -1) {
|
||||||
|
sessions.value[sIdx] = { ...sessions.value[sIdx], runs: [...currentSession.value!.runs] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return run
|
||||||
|
} catch (e: unknown) {
|
||||||
|
error.value = e instanceof Error ? e.message : 'Failed to stop run'
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function completeSession(id: string): Promise<UseCaseSession | null> {
|
||||||
|
try {
|
||||||
|
const session = await api.completeSession(id)
|
||||||
|
const idx = sessions.value.findIndex((s) => s.id === id)
|
||||||
|
if (idx !== -1) {
|
||||||
|
sessions.value[idx] = session
|
||||||
|
}
|
||||||
|
if (currentSession.value?.id === id) {
|
||||||
|
currentSession.value = session
|
||||||
|
}
|
||||||
|
return session
|
||||||
|
} catch (e: unknown) {
|
||||||
|
error.value = e instanceof Error ? e.message : 'Failed to complete session'
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reAnalyze(id: string): Promise<Record<string, unknown> | null> {
|
||||||
|
try {
|
||||||
|
return await api.reAnalyze(id)
|
||||||
|
} catch (e: unknown) {
|
||||||
|
error.value = e instanceof Error ? e.message : 'Failed to analyze'
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessions,
|
||||||
|
currentSession,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
fetchSessions,
|
||||||
|
fetchSession,
|
||||||
|
createSession,
|
||||||
|
deleteSession,
|
||||||
|
startRun,
|
||||||
|
stopRun,
|
||||||
|
completeSession,
|
||||||
|
reAnalyze,
|
||||||
|
}
|
||||||
|
})
|
||||||
89
src/stores/telemetry.ts
Normal file
89
src/stores/telemetry.ts
Normal 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,
|
||||||
|
}
|
||||||
|
})
|
||||||
170
src/types/index.ts
Normal file
170
src/types/index.ts
Normal file
|
|
@ -0,0 +1,170 @@
|
||||||
|
// TypeScript interfaces matching the rusty-telemetry REST API JSON responses.
|
||||||
|
// Field names use snake_case to match Rust's serde serialization.
|
||||||
|
|
||||||
|
// ── Enums ──
|
||||||
|
|
||||||
|
export type UseCase =
|
||||||
|
| 'shift_points'
|
||||||
|
| 'racing_line'
|
||||||
|
| 'braking_analysis'
|
||||||
|
| 'lap_times'
|
||||||
|
| 'sector_analysis'
|
||||||
|
| 'track_map'
|
||||||
|
| 'general'
|
||||||
|
|
||||||
|
export type Game =
|
||||||
|
| 'assetto_corsa'
|
||||||
|
| 'assetto_corsa_competizione'
|
||||||
|
| 'project_cars1'
|
||||||
|
| 'project_cars2'
|
||||||
|
|
||||||
|
export type RecordingStatus = 'recording' | 'stopped'
|
||||||
|
export type SessionStatus = 'active' | 'completed'
|
||||||
|
export type RunStatus = 'recording' | 'stopped'
|
||||||
|
export type FeedSource = 'Ksudp' | 'TelemetryTool' | 'Pcars'
|
||||||
|
|
||||||
|
// ── API Responses ──
|
||||||
|
|
||||||
|
export interface LiveFeedSnapshot {
|
||||||
|
label: string
|
||||||
|
port: number
|
||||||
|
health: string
|
||||||
|
frequency_hz: number
|
||||||
|
total_packets: number
|
||||||
|
data_rate_kb_s: number
|
||||||
|
speed_kmh: number | null
|
||||||
|
gear: string | null
|
||||||
|
engine_rpm: number | null
|
||||||
|
throttle: number | null
|
||||||
|
brake: number | null
|
||||||
|
lap_time: string | null
|
||||||
|
spline_position: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LiveTelemetryResponse {
|
||||||
|
feeds: LiveFeedSnapshot[]
|
||||||
|
active_recording: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Recordings ──
|
||||||
|
|
||||||
|
export interface FeedStats {
|
||||||
|
port: number
|
||||||
|
name: string
|
||||||
|
bytes: number
|
||||||
|
file: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecordingManifest {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
game: string
|
||||||
|
track: string
|
||||||
|
car: string
|
||||||
|
use_case: string
|
||||||
|
status: RecordingStatus
|
||||||
|
created_at: string
|
||||||
|
stopped_at: string | null
|
||||||
|
feeds: FeedStats[]
|
||||||
|
notes: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StartRecordingRequest {
|
||||||
|
name?: string | null
|
||||||
|
game?: Game | null
|
||||||
|
track?: string | null
|
||||||
|
car?: string | null
|
||||||
|
use_case?: UseCase | null
|
||||||
|
notes?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Sessions ──
|
||||||
|
|
||||||
|
export interface SessionRun {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
feed_name: string
|
||||||
|
status: RunStatus
|
||||||
|
start_marker: number
|
||||||
|
end_marker: number | null
|
||||||
|
frame_count: number
|
||||||
|
data_truncated: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseCaseSession {
|
||||||
|
id: string
|
||||||
|
use_case: UseCase
|
||||||
|
label: string
|
||||||
|
status: SessionStatus
|
||||||
|
created_at: string
|
||||||
|
completed_at: string | null
|
||||||
|
runs: SessionRun[]
|
||||||
|
analysis: AnalysisResult | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateSessionRequest {
|
||||||
|
use_case: UseCase
|
||||||
|
label?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StartRunRequest {
|
||||||
|
feed_name: string
|
||||||
|
label?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Analysis Results ──
|
||||||
|
|
||||||
|
// Shift Point Analysis (use_case = "shift_points")
|
||||||
|
export interface GearRpmRange {
|
||||||
|
gear: number
|
||||||
|
gear_display: string
|
||||||
|
min_rpm: number
|
||||||
|
max_rpm: number
|
||||||
|
avg_rpm: number
|
||||||
|
sample_count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShiftEvent {
|
||||||
|
from_gear: number
|
||||||
|
to_gear: number
|
||||||
|
rpm_before_shift: number
|
||||||
|
rpm_after_shift: number
|
||||||
|
rpm_drop: number
|
||||||
|
speed_kmh: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShiftPointAnalysis {
|
||||||
|
gear_ranges: GearRpmRange[]
|
||||||
|
shift_events: ShiftEvent[]
|
||||||
|
recommended_shift_rpm: number | null
|
||||||
|
total_frames_analyzed: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track Map Analysis (use_case = "track_map")
|
||||||
|
export interface TrackPoint {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
z: number
|
||||||
|
spline: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TrackBoundary {
|
||||||
|
label: string
|
||||||
|
point_count: number
|
||||||
|
points: TrackPoint[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TrackMapAnalysis {
|
||||||
|
boundaries: TrackBoundary[]
|
||||||
|
center_line: TrackBoundary | null
|
||||||
|
total_points: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not implemented response
|
||||||
|
export interface NotImplementedResponse {
|
||||||
|
status: 'not_implemented'
|
||||||
|
use_case: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Union of all analysis result types — matches backend discriminant responses
|
||||||
|
export type AnalysisResult = ShiftPointAnalysis | TrackMapAnalysis | NotImplementedResponse
|
||||||
279
src/views/DashboardView.vue
Normal file
279
src/views/DashboardView.vue
Normal file
|
|
@ -0,0 +1,279 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { RouterLink } from 'vue-router'
|
||||||
|
import { useTelemetryStore } from '@/stores/telemetry'
|
||||||
|
import { useSessionsStore } from '@/stores/sessions'
|
||||||
|
import FeedStatus from '@/components/FeedStatus.vue'
|
||||||
|
|
||||||
|
const telemetry = useTelemetryStore()
|
||||||
|
const sessions = useSessionsStore()
|
||||||
|
const apiPort = ref(8080)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
telemetry.startPolling(1000)
|
||||||
|
sessions.fetchSessions()
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
telemetry.stopPolling()
|
||||||
|
})
|
||||||
|
|
||||||
|
function formatTime(iso: string): string {
|
||||||
|
return new Date(iso).toLocaleString()
|
||||||
|
}
|
||||||
|
|
||||||
|
function useCaseLabel(uc: string): string {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
shift_points: 'Shift Points',
|
||||||
|
racing_line: 'Racing Line',
|
||||||
|
braking_analysis: 'Braking Analysis',
|
||||||
|
lap_times: 'Lap Times',
|
||||||
|
sector_analysis: 'Sector Analysis',
|
||||||
|
track_map: 'Track Map',
|
||||||
|
general: 'General',
|
||||||
|
}
|
||||||
|
return labels[uc] ?? uc
|
||||||
|
}
|
||||||
|
|
||||||
|
const simStatusClass = computed(() => {
|
||||||
|
if (!telemetry.connected) return 'status-warn'
|
||||||
|
if (telemetry.anyFeedAlive) return 'status-ok'
|
||||||
|
if (telemetry.anyFeedStale) return 'status-warn'
|
||||||
|
return 'status-idle'
|
||||||
|
})
|
||||||
|
|
||||||
|
const simStatusIcon = computed(() => {
|
||||||
|
if (!telemetry.connected) return '○'
|
||||||
|
if (telemetry.anyFeedAlive) return '●'
|
||||||
|
if (telemetry.anyFeedStale) return '◐'
|
||||||
|
return '○'
|
||||||
|
})
|
||||||
|
|
||||||
|
const aliveCount = computed(() => telemetry.feeds.filter((f) => f.health === 'alive').length)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="dashboard">
|
||||||
|
<h2 class="page-title">Dashboard</h2>
|
||||||
|
|
||||||
|
<!-- System Status -->
|
||||||
|
<section class="card mb-3">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2>System Status</h2>
|
||||||
|
<span :class="['badge', telemetry.overallStatusClass]">
|
||||||
|
{{ telemetry.overallStatusLabel }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="status-grid">
|
||||||
|
<!-- API Server -->
|
||||||
|
<div class="status-item">
|
||||||
|
<span class="status-icon" :class="telemetry.connected ? 'status-ok' : 'status-err'">
|
||||||
|
{{ telemetry.connected ? '●' : '✕' }}
|
||||||
|
</span>
|
||||||
|
<div class="status-detail">
|
||||||
|
<span class="status-name">API Server</span>
|
||||||
|
<span class="status-info">
|
||||||
|
<template v-if="telemetry.connected">
|
||||||
|
<span class="mono">localhost:{{ apiPort }}</span>
|
||||||
|
<span class="text-muted">·</span>
|
||||||
|
<span class="text-secondary">{{ telemetry.feeds.length }} feed(s) registered</span>
|
||||||
|
</template>
|
||||||
|
<span v-else class="text-error">{{ telemetry.error || 'Not reachable' }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sim Connection -->
|
||||||
|
<div class="status-item">
|
||||||
|
<span class="status-icon" :class="simStatusClass">
|
||||||
|
{{ simStatusIcon }}
|
||||||
|
</span>
|
||||||
|
<div class="status-detail">
|
||||||
|
<span class="status-name">Sim Connection</span>
|
||||||
|
<span class="status-info">
|
||||||
|
<template v-if="!telemetry.connected">
|
||||||
|
<span class="text-muted">Waiting for API server</span>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="telemetry.anyFeedAlive">
|
||||||
|
<span class="text-success">{{ aliveCount }} feed(s) receiving data</span>
|
||||||
|
<span class="text-muted">·</span>
|
||||||
|
<span class="text-secondary">{{ telemetry.totalPackets.toLocaleString() }} packets total</span>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="telemetry.anyFeedStale">
|
||||||
|
<span class="text-warning">Data stream stalled</span>
|
||||||
|
<span class="text-muted">·</span>
|
||||||
|
<span class="text-secondary">{{ telemetry.totalPackets.toLocaleString() }} packets received</span>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<span class="text-muted">No sim data detected</span>
|
||||||
|
<span class="text-muted">·</span>
|
||||||
|
<span class="text-secondary">Start a sim session to begin</span>
|
||||||
|
</template>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Active Recording -->
|
||||||
|
<div class="status-item">
|
||||||
|
<span class="status-icon" :class="telemetry.activeRecording ? 'status-ok' : 'status-warn'">
|
||||||
|
{{ telemetry.activeRecording ? '●' : '○' }}
|
||||||
|
</span>
|
||||||
|
<div class="status-detail">
|
||||||
|
<span class="status-name">Recording</span>
|
||||||
|
<span class="status-info">
|
||||||
|
<template v-if="telemetry.activeRecording">
|
||||||
|
<span class="badge badge-success" style="font-size: 0.7rem">ACTIVE</span>
|
||||||
|
<span class="text-muted">·</span>
|
||||||
|
<span class="text-secondary mono" style="font-size: 0.75rem">{{ telemetry.activeRecording.substring(0, 8) }}…</span>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<span class="text-muted">No active recording</span>
|
||||||
|
</template>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="telemetry.lastUpdate && telemetry.connected" class="update-info">
|
||||||
|
Last update: {{ telemetry.lastUpdate.toLocaleTimeString() }}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Live Feeds -->
|
||||||
|
<section class="card mb-3">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2>Live Feeds</h2>
|
||||||
|
<span class="text-secondary">{{ telemetry.feeds.length }} feed(s)</span>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-3">
|
||||||
|
<FeedStatus
|
||||||
|
v-for="feed in telemetry.feeds"
|
||||||
|
:key="feed.port"
|
||||||
|
:feed="feed"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-if="telemetry.feeds.length === 0" class="text-secondary text-muted mt-2">
|
||||||
|
No feeds detected. Start a sim session to see live data.
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Quick Actions -->
|
||||||
|
<section class="card mb-3">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2>Quick Actions</h2>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<RouterLink to="/live" class="btn btn-primary">📡 Live Telemetry</RouterLink>
|
||||||
|
<RouterLink to="/sessions" class="btn btn-secondary">📊 Sessions</RouterLink>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Recent Sessions -->
|
||||||
|
<section class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2>Recent Sessions</h2>
|
||||||
|
<RouterLink to="/sessions" class="text-secondary" style="font-size: 0.85rem">View all →</RouterLink>
|
||||||
|
</div>
|
||||||
|
<div v-if="sessions.sessions.length === 0" class="text-secondary text-muted">
|
||||||
|
No sessions yet. Create one to start analyzing your driving.
|
||||||
|
</div>
|
||||||
|
<div v-else class="session-list">
|
||||||
|
<div
|
||||||
|
v-for="session in sessions.sessions.slice(0, 5)"
|
||||||
|
:key="session.id"
|
||||||
|
class="session-item"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span :class="['badge', session.status === 'active' ? 'badge-primary' : 'badge-muted']">
|
||||||
|
{{ session.status }}
|
||||||
|
</span>
|
||||||
|
<span class="text-secondary">{{ useCaseLabel(session.use_case) }}</span>
|
||||||
|
<span class="text-muted">·</span>
|
||||||
|
<span>{{ session.label }}</span>
|
||||||
|
</div>
|
||||||
|
<span class="text-muted" style="font-size: 0.8rem">{{ formatTime(session.created_at) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page-title {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.6rem 0.75rem;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--color-surface-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-item + .status-item {
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
padding-top: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-icon {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
line-height: 1.3;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-ok { color: var(--color-success); }
|
||||||
|
.status-warn { color: var(--color-warning); }
|
||||||
|
.status-err { color: var(--color-error); }
|
||||||
|
.status-idle { color: var(--color-text-muted); }
|
||||||
|
|
||||||
|
.status-detail {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.15rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-name {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-info {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-info {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
304
src/views/LiveView.vue
Normal file
304
src/views/LiveView.vue
Normal file
|
|
@ -0,0 +1,304 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, onUnmounted, computed } from 'vue'
|
||||||
|
import { useTelemetryStore } from '@/stores/telemetry'
|
||||||
|
import FeedStatus from '@/components/FeedStatus.vue'
|
||||||
|
|
||||||
|
const telemetry = useTelemetryStore()
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
telemetry.startPolling(250)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
telemetry.stopPolling()
|
||||||
|
})
|
||||||
|
|
||||||
|
const aliveCount = computed(() => telemetry.feeds.filter((f) => f.health === 'alive').length)
|
||||||
|
|
||||||
|
const primaryFeed = computed(() => {
|
||||||
|
const alive = telemetry.feeds.find((f) => f.health === 'alive')
|
||||||
|
return alive ?? telemetry.feeds[0] ?? null
|
||||||
|
})
|
||||||
|
|
||||||
|
function formatSpeed(kmh: number | null): string {
|
||||||
|
if (kmh === null) return '---'
|
||||||
|
return Math.round(kmh).toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRpm(rpm: number | null): string {
|
||||||
|
if (rpm === null) return '-----'
|
||||||
|
return Math.round(rpm).toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
function throttleWidth(val: number | null): string {
|
||||||
|
if (val === null) return '0%'
|
||||||
|
return `${Math.round(val * 100)}%`
|
||||||
|
}
|
||||||
|
|
||||||
|
function brakeWidth(val: number | null): string {
|
||||||
|
if (val === null) return '0%'
|
||||||
|
return `${Math.round(val * 100)}%`
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="live-view">
|
||||||
|
<h2 class="page-title">Live Telemetry</h2>
|
||||||
|
|
||||||
|
<!-- Status Bar -->
|
||||||
|
<div :class="['status-bar', telemetry.connected ? '' : 'status-bar-error']">
|
||||||
|
<div class="status-bar-left">
|
||||||
|
<span :class="['badge', telemetry.overallStatusClass]">
|
||||||
|
{{ telemetry.overallStatusLabel }}
|
||||||
|
</span>
|
||||||
|
<template v-if="telemetry.connected">
|
||||||
|
<span class="text-muted">·</span>
|
||||||
|
<span class="text-secondary" style="font-size: 0.8rem">
|
||||||
|
{{ aliveCount }}/{{ telemetry.feeds.length }} feeds active
|
||||||
|
</span>
|
||||||
|
<span v-if="telemetry.activeRecording" class="text-muted">·</span>
|
||||||
|
<span v-if="telemetry.activeRecording" class="text-secondary" style="font-size: 0.8rem">
|
||||||
|
Recording
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<span v-if="telemetry.lastUpdate" class="text-muted" style="font-size: 0.75rem">
|
||||||
|
{{ telemetry.lastUpdate.toLocaleTimeString() }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Primary Display -->
|
||||||
|
<div v-if="primaryFeed" class="card primary-display mb-3">
|
||||||
|
<div class="primary-grid">
|
||||||
|
<!-- Speed -->
|
||||||
|
<div class="gauge gauge-speed">
|
||||||
|
<div class="gauge-value mono">{{ formatSpeed(primaryFeed.speed_kmh) }}</div>
|
||||||
|
<div class="gauge-unit">km/h</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- RPM -->
|
||||||
|
<div class="gauge gauge-rpm">
|
||||||
|
<div class="gauge-value mono">{{ formatRpm(primaryFeed.engine_rpm) }}</div>
|
||||||
|
<div class="gauge-unit">RPM</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Gear -->
|
||||||
|
<div class="gauge gauge-gear">
|
||||||
|
<div class="gauge-value mono">{{ primaryFeed.gear ?? '-' }}</div>
|
||||||
|
<div class="gauge-unit">GEAR</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Input Bars -->
|
||||||
|
<div class="input-bars">
|
||||||
|
<div class="input-bar-row">
|
||||||
|
<span class="input-label">THR</span>
|
||||||
|
<div class="bar-track">
|
||||||
|
<div
|
||||||
|
class="bar-fill bar-throttle"
|
||||||
|
:style="{ width: throttleWidth(primaryFeed.throttle) }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span class="input-value mono">{{ Math.round((primaryFeed.throttle ?? 0) * 100) }}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="input-bar-row">
|
||||||
|
<span class="input-label">BRK</span>
|
||||||
|
<div class="bar-track">
|
||||||
|
<div
|
||||||
|
class="bar-fill bar-brake"
|
||||||
|
:style="{ width: brakeWidth(primaryFeed.brake) }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span class="input-value mono">{{ Math.round((primaryFeed.brake ?? 0) * 100) }}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Lap info -->
|
||||||
|
<div class="lap-info">
|
||||||
|
<div class="lap-item">
|
||||||
|
<span class="lap-label">Lap Time</span>
|
||||||
|
<span class="mono">{{ primaryFeed.lap_time ?? '-:--.---' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="lap-item">
|
||||||
|
<span class="lap-label">Spline</span>
|
||||||
|
<span class="mono">{{ (primaryFeed.spline_position ?? 0).toFixed(3) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Feed Details -->
|
||||||
|
<section class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2>All Feeds</h2>
|
||||||
|
<span v-if="telemetry.lastUpdate" class="text-muted" style="font-size: 0.8rem">
|
||||||
|
Updated {{ telemetry.lastUpdate.toLocaleTimeString() }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-3">
|
||||||
|
<FeedStatus
|
||||||
|
v-for="feed in telemetry.feeds"
|
||||||
|
:key="feed.port"
|
||||||
|
:feed="feed"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-if="telemetry.feeds.length === 0" class="text-secondary text-muted mt-2">
|
||||||
|
Waiting for feeds...
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page-title {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-bar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.6rem 1rem;
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-bar-error {
|
||||||
|
border-color: rgba(248, 113, 113, 0.3);
|
||||||
|
background: rgba(248, 113, 113, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-bar-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-display {
|
||||||
|
background: linear-gradient(135deg, var(--color-surface) 0%, #1e2235 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1.5fr 0.8fr;
|
||||||
|
gap: 1.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gauge {
|
||||||
|
text-align: center;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gauge-value {
|
||||||
|
font-size: 3rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gauge-speed .gauge-value {
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gauge-rpm .gauge-value {
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gauge-gear .gauge-value {
|
||||||
|
color: var(--color-success);
|
||||||
|
font-size: 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gauge-unit {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-bars {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-bar-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-label {
|
||||||
|
width: 2rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-track {
|
||||||
|
flex: 1;
|
||||||
|
height: 12px;
|
||||||
|
background: var(--color-background);
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-fill {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: width 0.1s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-throttle {
|
||||||
|
background: linear-gradient(90deg, #22c55e, #4ade80);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-brake {
|
||||||
|
background: linear-gradient(90deg, #ef4444, #f87171);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-value {
|
||||||
|
width: 3rem;
|
||||||
|
text-align: right;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lap-info {
|
||||||
|
display: flex;
|
||||||
|
gap: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lap-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lap-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.primary-grid {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
.gauge-gear {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
.gauge-value {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
}
|
||||||
|
.gauge-gear .gauge-value {
|
||||||
|
font-size: 3rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
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>
|
||||||
179
src/views/SessionsView.vue
Normal file
179
src/views/SessionsView.vue
Normal file
|
|
@ -0,0 +1,179 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { RouterLink } from 'vue-router'
|
||||||
|
import { useSessionsStore } from '@/stores/sessions'
|
||||||
|
import type { UseCase } from '@/types'
|
||||||
|
|
||||||
|
const store = useSessionsStore()
|
||||||
|
|
||||||
|
const showCreate = ref(false)
|
||||||
|
const newUseCase = ref<UseCase>('shift_points')
|
||||||
|
const newLabel = ref('')
|
||||||
|
|
||||||
|
const useCaseOptions: { value: UseCase; label: string }[] = [
|
||||||
|
{ value: 'shift_points', label: 'Shift Points' },
|
||||||
|
{ value: 'track_map', label: 'Track Map' },
|
||||||
|
{ value: 'racing_line', label: 'Racing Line' },
|
||||||
|
{ value: 'braking_analysis', label: 'Braking Analysis' },
|
||||||
|
{ value: 'lap_times', label: 'Lap Times' },
|
||||||
|
{ value: 'sector_analysis', label: 'Sector Analysis' },
|
||||||
|
{ value: 'general', label: 'General' },
|
||||||
|
]
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
store.fetchSessions()
|
||||||
|
})
|
||||||
|
|
||||||
|
function formatTime(iso: string): string {
|
||||||
|
return new Date(iso).toLocaleString()
|
||||||
|
}
|
||||||
|
|
||||||
|
function useCaseLabel(uc: string): string {
|
||||||
|
return useCaseOptions.find((o) => o.value === uc)?.label ?? uc
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreate() {
|
||||||
|
const session = await store.createSession({
|
||||||
|
use_case: newUseCase.value,
|
||||||
|
label: newLabel.value || undefined,
|
||||||
|
})
|
||||||
|
if (session) {
|
||||||
|
showCreate.value = false
|
||||||
|
newLabel.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(id: string) {
|
||||||
|
if (confirm('Delete this session?')) {
|
||||||
|
await store.deleteSession(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="sessions-view">
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<h2 class="page-title">Sessions</h2>
|
||||||
|
<button class="btn btn-primary" @click="showCreate = !showCreate">
|
||||||
|
{{ showCreate ? '✕ Cancel' : '+ New Session' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create Form -->
|
||||||
|
<div v-if="showCreate" class="card mb-3">
|
||||||
|
<h3 class="mb-2">Create New Session</h3>
|
||||||
|
<div class="flex gap-2 items-center">
|
||||||
|
<select v-model="newUseCase" class="form-select">
|
||||||
|
<option v-for="opt in useCaseOptions" :key="opt.value" :value="opt.value">
|
||||||
|
{{ opt.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
v-model="newLabel"
|
||||||
|
placeholder="Label (optional)"
|
||||||
|
class="form-input"
|
||||||
|
@keyup.enter="handleCreate"
|
||||||
|
/>
|
||||||
|
<button class="btn btn-primary" :disabled="store.loading" @click="handleCreate">
|
||||||
|
Create
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error -->
|
||||||
|
<div v-if="store.error" class="card mb-3 text-error">
|
||||||
|
{{ store.error }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading -->
|
||||||
|
<div v-if="store.loading && store.sessions.length === 0" class="card text-secondary">
|
||||||
|
Loading sessions...
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Session List -->
|
||||||
|
<div v-if="store.sessions.length === 0 && !store.loading" class="card text-secondary">
|
||||||
|
No sessions yet. Create one to start analyzing your driving.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="session-cards">
|
||||||
|
<div v-for="session in store.sessions" :key="session.id" class="card session-card">
|
||||||
|
<div class="flex items-center justify-between mb-1">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span :class="['badge', session.status === 'active' ? 'badge-primary' : 'badge-muted']">
|
||||||
|
{{ session.status }}
|
||||||
|
</span>
|
||||||
|
<span class="badge badge-primary">{{ useCaseLabel(session.use_case) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-1">
|
||||||
|
<RouterLink :to="`/sessions/${session.id}`" class="btn btn-secondary btn-sm">
|
||||||
|
Open
|
||||||
|
</RouterLink>
|
||||||
|
<button class="btn btn-danger btn-sm" @click="handleDelete(session.id)">
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="session-meta">
|
||||||
|
<span class="session-label">{{ session.label || 'Untitled' }}</span>
|
||||||
|
<span class="text-muted">·</span>
|
||||||
|
<span class="text-secondary">{{ session.runs.length }} run(s)</span>
|
||||||
|
<span class="text-muted">·</span>
|
||||||
|
<span class="text-muted" style="font-size: 0.8rem">{{ formatTime(session.created_at) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page-title {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-cards {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-card {
|
||||||
|
transition: border-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-card:hover {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-label {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-select,
|
||||||
|
.form-input {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
background: var(--color-background);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-select:focus,
|
||||||
|
.form-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
262
src/views/ShiftPointsView.vue
Normal file
262
src/views/ShiftPointsView.vue
Normal file
|
|
@ -0,0 +1,262 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, computed } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { Bar, Line } from 'vue-chartjs'
|
||||||
|
import {
|
||||||
|
Chart as ChartJS,
|
||||||
|
CategoryScale,
|
||||||
|
LinearScale,
|
||||||
|
BarElement,
|
||||||
|
LineElement,
|
||||||
|
PointElement,
|
||||||
|
Title,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
Filler,
|
||||||
|
} from 'chart.js'
|
||||||
|
import type { ShiftPointAnalysis } from '@/types'
|
||||||
|
import * as api from '@/api/client'
|
||||||
|
|
||||||
|
ChartJS.register(CategoryScale, LinearScale, BarElement, LineElement, PointElement, Title, Tooltip, Legend, Filler)
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const sessionId = computed(() => route.params.id as string)
|
||||||
|
const analysis = ref<ShiftPointAnalysis | null>(null)
|
||||||
|
const loading = ref(true)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const data = await api.getAnalysis(sessionId.value)
|
||||||
|
if (data && 'gear_ranges' in data) {
|
||||||
|
analysis.value = data as unknown as ShiftPointAnalysis
|
||||||
|
} else {
|
||||||
|
error.value = 'Analysis data not available or wrong type'
|
||||||
|
}
|
||||||
|
} catch (e: unknown) {
|
||||||
|
error.value = e instanceof Error ? e.message : 'Failed to load analysis'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Gear RPM range chart data
|
||||||
|
const gearRangeChartData = computed(() => {
|
||||||
|
if (!analysis.value) return null
|
||||||
|
const ranges = analysis.value.gear_ranges.filter((g) => g.gear >= 1)
|
||||||
|
return {
|
||||||
|
labels: ranges.map((g) => g.gear_display),
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'Min RPM',
|
||||||
|
data: ranges.map((g) => g.min_rpm),
|
||||||
|
backgroundColor: 'rgba(79, 143, 247, 0.3)',
|
||||||
|
borderColor: 'rgba(79, 143, 247, 0.8)',
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Avg RPM',
|
||||||
|
data: ranges.map((g) => g.avg_rpm),
|
||||||
|
backgroundColor: 'rgba(52, 211, 153, 0.3)',
|
||||||
|
borderColor: 'rgba(52, 211, 153, 0.8)',
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Max RPM',
|
||||||
|
data: ranges.map((g) => g.max_rpm),
|
||||||
|
backgroundColor: 'rgba(248, 113, 113, 0.3)',
|
||||||
|
borderColor: 'rgba(248, 113, 113, 0.8)',
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const gearRangeOptions = {
|
||||||
|
responsive: true,
|
||||||
|
plugins: {
|
||||||
|
legend: { labels: { color: '#8b8fa3' } },
|
||||||
|
title: { display: true, text: 'Gear RPM Ranges', color: '#e2e4ea' },
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: { ticks: { color: '#8b8fa3' }, grid: { color: '#2a2e3f' } },
|
||||||
|
y: { ticks: { color: '#8b8fa3' }, grid: { color: '#2a2e3f' }, title: { display: true, text: 'RPM', color: '#8b8fa3' } },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shift events timeline data
|
||||||
|
const shiftEventsChartData = computed(() => {
|
||||||
|
if (!analysis.value || analysis.value.shift_events.length === 0) return null
|
||||||
|
const events = analysis.value.shift_events
|
||||||
|
return {
|
||||||
|
labels: events.map((_, i) => `#${i + 1}`),
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'RPM Before Shift',
|
||||||
|
data: events.map((e) => e.rpm_before_shift),
|
||||||
|
borderColor: 'rgba(248, 113, 113, 1)',
|
||||||
|
backgroundColor: 'rgba(248, 113, 113, 0.1)',
|
||||||
|
fill: false,
|
||||||
|
tension: 0.2,
|
||||||
|
pointRadius: 4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'RPM After Shift',
|
||||||
|
data: events.map((e) => e.rpm_after_shift),
|
||||||
|
borderColor: 'rgba(52, 211, 153, 1)',
|
||||||
|
backgroundColor: 'rgba(52, 211, 153, 0.1)',
|
||||||
|
fill: false,
|
||||||
|
tension: 0.2,
|
||||||
|
pointRadius: 4,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const shiftEventsOptions = {
|
||||||
|
responsive: true,
|
||||||
|
plugins: {
|
||||||
|
legend: { labels: { color: '#8b8fa3' } },
|
||||||
|
title: { display: true, text: 'Shift Events — RPM Before/After', color: '#e2e4ea' },
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: { ticks: { color: '#8b8fa3' }, grid: { color: '#2a2e3f' } },
|
||||||
|
y: { ticks: { color: '#8b8fa3' }, grid: { color: '#2a2e3f' }, title: { display: true, text: 'RPM', color: '#8b8fa3' } },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
function gearLabel(gear: number): string {
|
||||||
|
if (gear === -1) return 'R'
|
||||||
|
if (gear === 0) return 'N'
|
||||||
|
return gear.toString()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="shift-points-view">
|
||||||
|
<div class="flex items-center gap-2 mb-3">
|
||||||
|
<button class="btn btn-secondary btn-sm" @click="router.back()">← Back</button>
|
||||||
|
<h2 class="page-title">Shift Point Analysis</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="card text-secondary">Loading analysis...</div>
|
||||||
|
<div v-else-if="error" class="card text-error">{{ error }}</div>
|
||||||
|
|
||||||
|
<template v-else-if="analysis">
|
||||||
|
<!-- Summary Stats -->
|
||||||
|
<div class="grid grid-3 mb-3">
|
||||||
|
<div class="card stat-card">
|
||||||
|
<span class="stat-label">Recommended Shift RPM</span>
|
||||||
|
<span class="stat-value mono">{{ Math.round(analysis.recommended_shift_rpm ?? 0) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="card stat-card">
|
||||||
|
<span class="stat-label">Shift Events</span>
|
||||||
|
<span class="stat-value mono">{{ analysis.shift_events.length }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="card stat-card">
|
||||||
|
<span class="stat-label">Frames Analyzed</span>
|
||||||
|
<span class="stat-value mono">{{ analysis.total_frames_analyzed.toLocaleString() }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Gear RPM Ranges Chart -->
|
||||||
|
<div v-if="gearRangeChartData" class="card mb-3">
|
||||||
|
<Bar :data="gearRangeChartData" :options="gearRangeOptions" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Shift Events Chart -->
|
||||||
|
<div v-if="shiftEventsChartData" class="card mb-3">
|
||||||
|
<Line :data="shiftEventsChartData" :options="shiftEventsOptions" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Shift Events Table -->
|
||||||
|
<div v-if="analysis.shift_events.length > 0" class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2>Shift Events Detail</h2>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
<th>From → To</th>
|
||||||
|
<th>RPM Before</th>
|
||||||
|
<th>RPM After</th>
|
||||||
|
<th>RPM Drop</th>
|
||||||
|
<th>Speed (km/h)</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="(event, i) in analysis.shift_events" :key="i">
|
||||||
|
<td class="mono">{{ i + 1 }}</td>
|
||||||
|
<td>{{ gearLabel(event.from_gear) }} → {{ gearLabel(event.to_gear) }}</td>
|
||||||
|
<td class="mono">{{ Math.round(event.rpm_before_shift) }}</td>
|
||||||
|
<td class="mono">{{ Math.round(event.rpm_after_shift) }}</td>
|
||||||
|
<td class="mono">{{ Math.round(event.rpm_drop) }}</td>
|
||||||
|
<td class="mono">{{ Math.round(event.speed_kmh) }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page-title {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-primary);
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border-bottom: 2px solid var(--color-border);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
padding: 0.4rem 0.75rem;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
tr:hover td {
|
||||||
|
background: var(--color-surface-hover);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
330
src/views/TrackMapView.vue
Normal file
330
src/views/TrackMapView.vue
Normal file
|
|
@ -0,0 +1,330 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import type { TrackMapAnalysis, TrackPoint } from '@/types'
|
||||||
|
import * as api from '@/api/client'
|
||||||
|
|
||||||
|
const BOUNDARY_COLORS = ['#f87171', '#60a5fa', '#fbbf24', '#34d399', '#a78bfa']
|
||||||
|
let resizeRafId: number | null = null
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const sessionId = computed(() => route.params.id as string)
|
||||||
|
const analysis = ref<TrackMapAnalysis | null>(null)
|
||||||
|
const loading = ref(true)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
const canvas = ref<HTMLCanvasElement | null>(null)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const data = await api.getAnalysis(sessionId.value)
|
||||||
|
if (data && 'boundaries' in data) {
|
||||||
|
analysis.value = data as unknown as TrackMapAnalysis
|
||||||
|
} else {
|
||||||
|
error.value = 'Track map analysis not available'
|
||||||
|
}
|
||||||
|
} catch (e: unknown) {
|
||||||
|
error.value = e instanceof Error ? e.message : 'Failed to load analysis'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch([analysis, canvas], () => {
|
||||||
|
if (analysis.value && canvas.value) {
|
||||||
|
drawTrackMap()
|
||||||
|
}
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
function drawTrackMap() {
|
||||||
|
if (!canvas.value || !analysis.value) return
|
||||||
|
|
||||||
|
const ctx = canvas.value.getContext('2d')
|
||||||
|
if (!ctx) return
|
||||||
|
|
||||||
|
const allPoints = analysis.value.boundaries.flatMap((b) => b.points)
|
||||||
|
if (analysis.value.center_line) {
|
||||||
|
allPoints.push(...analysis.value.center_line.points)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allPoints.length === 0) return
|
||||||
|
|
||||||
|
const minX = allPoints.reduce((m, p) => p.x < m ? p.x : m, Infinity)
|
||||||
|
const maxX = allPoints.reduce((m, p) => p.x > m ? p.x : m, -Infinity)
|
||||||
|
const minY = allPoints.reduce((m, p) => p.y < m ? p.y : m, Infinity)
|
||||||
|
const maxY = allPoints.reduce((m, p) => p.y > m ? p.y : m, -Infinity)
|
||||||
|
|
||||||
|
const width = canvas.value.width
|
||||||
|
const height = canvas.value.height
|
||||||
|
const padding = 40
|
||||||
|
const drawWidth = width - padding * 2
|
||||||
|
const drawHeight = height - padding * 2
|
||||||
|
|
||||||
|
const rangeX = maxX - minX || 1
|
||||||
|
const rangeY = maxY - minY || 1
|
||||||
|
const scale = Math.min(drawWidth / rangeX, drawHeight / rangeY)
|
||||||
|
|
||||||
|
const offsetX = padding + (drawWidth - rangeX * scale) / 2
|
||||||
|
const offsetY = padding + (drawHeight - rangeY * scale) / 2
|
||||||
|
|
||||||
|
function toCanvas(point: TrackPoint): [number, number] {
|
||||||
|
return [
|
||||||
|
offsetX + (point.x - minX) * scale,
|
||||||
|
offsetY + (maxY - point.y) * scale,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.fillStyle = '#0f1117'
|
||||||
|
ctx.fillRect(0, 0, width, height)
|
||||||
|
|
||||||
|
ctx.strokeStyle = '#1a1d27'
|
||||||
|
ctx.lineWidth = 1
|
||||||
|
for (let i = 0; i <= 10; i++) {
|
||||||
|
const x = padding + (drawWidth / 10) * i
|
||||||
|
const y = padding + (drawHeight / 10) * i
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(x, padding)
|
||||||
|
ctx.lineTo(x, height - padding)
|
||||||
|
ctx.stroke()
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(padding, y)
|
||||||
|
ctx.lineTo(width - padding, y)
|
||||||
|
ctx.stroke()
|
||||||
|
}
|
||||||
|
|
||||||
|
analysis.value.boundaries.forEach((boundary, idx) => {
|
||||||
|
if (boundary.points.length < 2) return
|
||||||
|
const color = BOUNDARY_COLORS[idx % BOUNDARY_COLORS.length]
|
||||||
|
ctx.strokeStyle = color
|
||||||
|
ctx.lineWidth = 2
|
||||||
|
ctx.globalAlpha = 0.7
|
||||||
|
ctx.beginPath()
|
||||||
|
const [sx, sy] = toCanvas(boundary.points[0])
|
||||||
|
ctx.moveTo(sx, sy)
|
||||||
|
for (let i = 1; i < boundary.points.length; i++) {
|
||||||
|
const [px, py] = toCanvas(boundary.points[i])
|
||||||
|
ctx.lineTo(px, py)
|
||||||
|
}
|
||||||
|
ctx.stroke()
|
||||||
|
ctx.globalAlpha = 1
|
||||||
|
|
||||||
|
const labelPoint = boundary.points[Math.floor(boundary.points.length / 2)]
|
||||||
|
const [lx, ly] = toCanvas(labelPoint)
|
||||||
|
ctx.fillStyle = color
|
||||||
|
ctx.font = '12px sans-serif'
|
||||||
|
ctx.fillText(boundary.label, lx + 5, ly - 5)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (analysis.value.center_line && analysis.value.center_line.points.length >= 2) {
|
||||||
|
ctx.strokeStyle = '#34d399'
|
||||||
|
ctx.lineWidth = 3
|
||||||
|
ctx.globalAlpha = 0.9
|
||||||
|
ctx.beginPath()
|
||||||
|
const [sx, sy] = toCanvas(analysis.value.center_line.points[0])
|
||||||
|
ctx.moveTo(sx, sy)
|
||||||
|
for (let i = 1; i < analysis.value.center_line.points.length; i++) {
|
||||||
|
const [px, py] = toCanvas(analysis.value.center_line.points[i])
|
||||||
|
ctx.lineTo(px, py)
|
||||||
|
}
|
||||||
|
ctx.stroke()
|
||||||
|
ctx.globalAlpha = 1
|
||||||
|
|
||||||
|
const [startX, startY] = toCanvas(analysis.value.center_line.points[0])
|
||||||
|
const [endX, endY] = toCanvas(analysis.value.center_line.points[analysis.value.center_line.points.length - 1])
|
||||||
|
ctx.fillStyle = '#34d399'
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.arc(startX, startY, 5, 0, Math.PI * 2)
|
||||||
|
ctx.fill()
|
||||||
|
ctx.fillStyle = '#f87171'
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.arc(endX, endY, 5, 0, Math.PI * 2)
|
||||||
|
ctx.fill()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleResize() {
|
||||||
|
if (canvas.value) {
|
||||||
|
const container = canvas.value.parentElement
|
||||||
|
if (container) {
|
||||||
|
canvas.value.width = container.clientWidth
|
||||||
|
canvas.value.height = Math.max(500, container.clientWidth * 0.6)
|
||||||
|
drawTrackMap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const debouncedResize = () => {
|
||||||
|
if (resizeRafId !== null) cancelAnimationFrame(resizeRafId)
|
||||||
|
resizeRafId = requestAnimationFrame(() => {
|
||||||
|
handleResize()
|
||||||
|
resizeRafId = null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
handleResize()
|
||||||
|
window.addEventListener('resize', debouncedResize)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (resizeRafId !== null) cancelAnimationFrame(resizeRafId)
|
||||||
|
window.removeEventListener('resize', debouncedResize)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="track-map-view">
|
||||||
|
<div class="flex items-center gap-2 mb-3">
|
||||||
|
<button class="btn btn-secondary btn-sm" @click="router.back()">← Back</button>
|
||||||
|
<h2 class="page-title">Track Map</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="card text-secondary">Loading analysis...</div>
|
||||||
|
<div v-else-if="error" class="card text-error">{{ error }}</div>
|
||||||
|
|
||||||
|
<template v-else-if="analysis">
|
||||||
|
<div class="grid grid-3 mb-3">
|
||||||
|
<div class="card stat-card">
|
||||||
|
<span class="stat-label">Boundaries</span>
|
||||||
|
<span class="stat-value mono">{{ analysis.boundaries.length }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="card stat-card">
|
||||||
|
<span class="stat-label">Total Points</span>
|
||||||
|
<span class="stat-value mono">{{ analysis.total_points.toLocaleString() }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="card stat-card">
|
||||||
|
<span class="stat-label">Center Line</span>
|
||||||
|
<span class="stat-value" :class="analysis.center_line ? 'text-success' : 'text-muted'">
|
||||||
|
{{ analysis.center_line ? '✓ Computed' : '✗ N/A' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2>Track Map</h2>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<span v-for="(boundary, idx) in analysis.boundaries" :key="boundary.label" class="legend-item">
|
||||||
|
<span class="legend-dot" :style="{ background: BOUNDARY_COLORS[idx % BOUNDARY_COLORS.length] }" />
|
||||||
|
{{ boundary.label }} ({{ boundary.point_count }})
|
||||||
|
</span>
|
||||||
|
<span v-if="analysis.center_line" class="legend-item">
|
||||||
|
<span class="legend-dot" style="background: #34d399" />
|
||||||
|
Center Line
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="canvas-container">
|
||||||
|
<canvas ref="canvas" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2>Boundaries</h2>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Label</th>
|
||||||
|
<th>Points</th>
|
||||||
|
<th>Spline Range</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="boundary in analysis.boundaries" :key="boundary.label">
|
||||||
|
<td>{{ boundary.label }}</td>
|
||||||
|
<td class="mono">{{ boundary.point_count.toLocaleString() }}</td>
|
||||||
|
<td class="mono">
|
||||||
|
{{ boundary.points.length > 0 ? boundary.points[0].spline.toFixed(3) : '-' }}
|
||||||
|
–
|
||||||
|
{{ boundary.points.length > 0 ? boundary.points[boundary.points.length - 1].spline.toFixed(3) : '-' }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page-title {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas-container {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 500px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-item {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-dot {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border-bottom: 2px solid var(--color-border);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
padding: 0.4rem 0.75rem;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
26
tsconfig.app.json
Normal file
26
tsconfig.app.json
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "env.d.ts"]
|
||||||
|
}
|
||||||
7
tsconfig.json
Normal file
7
tsconfig.json
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
21
tsconfig.node.json
Normal file
21
tsconfig.node.json
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true,
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["vite.config.*"]
|
||||||
|
}
|
||||||
22
vite.config.ts
Normal file
22
vite.config.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
import { fileURLToPath, URL } from 'node:url'
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
host: '0.0.0.0',
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://127.0.0.1:8080',
|
||||||
|
changeOrigin: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
Loading…
Add table
Add a link
Reference in a new issue