Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 11 additions & 7 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
# Copy this file to .env and fill in real values - never commit .env
#
# Docker Compose reads this file to fill in the ${...} placeholders in docker-compose.yml, which
# passes each service only the settings it needs. A variable that is not listed there reaches no
# container, so adding your own name here has no effect unless you map it in the compose file too.

# ── SAIC / MG iSMART ────────────────────────────────────────────────────────
# Must be the vehicle owner account. Shared/secondary accounts lack the write
Expand Down Expand Up @@ -175,15 +179,15 @@ OPENCHARGEMAP_API_KEY=
# OVERPASS__BASEURL=https://overpass-api.de/api/interpreter

# ── API rate limit ─────────────────────────────────────────────────────────────
# Requests per minute the API accepts per client IP. The default suits a handful
# of browser tabs; raise it when several people reach GarageStack through one
# public address, or when a dashboard polls it often.
RATE_LIMIT_GLOBAL_PER_MINUTE=120
# Requests per minute the API accepts per client IP. The default of 120 suits a
# handful of browser tabs; raise it when several people reach GarageStack through
# one public address, or when a dashboard polls it often.
# RATE_LIMIT_GLOBAL_PER_MINUTE=120

# ── Tyre pressure thresholds ───────────────────────────────────────────────────
# Colour-code thresholds (bar) for the Overview diagram's tyre dots and for
# low/high tyre-pressure notifications. Defaults suit ~2.5 bar passenger cars --
# override with your vehicle's placarded pressure, e.g. TYRE_PRESSURE_GOOD_BAR=2.55.
TYRE_PRESSURE_LOW_BAR=2.2
TYRE_PRESSURE_GOOD_BAR=2.6
TYRE_PRESSURE_HIGH_BAR=3.2
# TYRE_PRESSURE_LOW_BAR=2.2
# TYRE_PRESSURE_GOOD_BAR=2.6
# TYRE_PRESSURE_HIGH_BAR=3.2
12 changes: 8 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ jobs:

- uses: pnpm/action-setup@v6
with:
version: 11.1.3
# Version comes from frontend/package.json's packageManager field.
package_json_file: frontend/package.json

- uses: actions/setup-node@v7.0.0
with:
Expand Down Expand Up @@ -56,7 +57,8 @@ jobs:

- uses: pnpm/action-setup@v6
with:
version: 11.1.3
# Version comes from frontend/package.json's packageManager field.
package_json_file: frontend/package.json

- uses: actions/setup-node@v7.0.0
with:
Expand All @@ -82,7 +84,8 @@ jobs:

- uses: pnpm/action-setup@v6
with:
version: 11.1.3
# Version comes from frontend/package.json's packageManager field.
package_json_file: frontend/package.json

- uses: actions/setup-node@v7.0.0
with:
Expand Down Expand Up @@ -111,7 +114,8 @@ jobs:

- uses: pnpm/action-setup@v6
with:
version: 11.1.3
# Version comes from frontend/package.json's packageManager field.
package_json_file: frontend/package.json

- uses: actions/setup-node@v7.0.0
with:
Expand Down
9 changes: 8 additions & 1 deletion .github/workflows/docker-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,14 @@ jobs:
id: minver
shell: bash
run: |
dotnet tool install --global minver-cli --version 7.0.0
# Same MinVer the build uses to stamp the assemblies, read from the central pin so
# the image tag and the version inside it can never come from different majors.
MINVER_VERSION=$(sed -n 's/.*Include="MinVer" Version="\([^"]*\)".*/\1/p' Directory.Packages.props)
if [ -z "$MINVER_VERSION" ]; then
echo "Could not read the MinVer version from Directory.Packages.props" >&2
exit 1
fi
dotnet tool install --global minver-cli --version "$MINVER_VERSION"
VERSION=$(minver . -t v -p preview.0 | tail -n 1 | tr -d '\r')
if [ -z "$VERSION" ]; then
echo "MinVer produced an empty version" >&2
Expand Down
23 changes: 21 additions & 2 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,11 +117,28 @@ dotnet ef migrations has-pending-model-changes --project src/GarageStack.Data --

CI runs the second command, and the pending-model-changes warning is not suppressed anywhere, so a model change without a migration fails before it reaches a real database.

### Adding a telemetry field

A new value arriving from the car passes through several layers, in this order:

1. `TelemetrySnapshot` (the model) plus a migration, as above.
2. `TelemetryMapper` in the Worker, which maps the MQTT topic onto the new property.
3. The frontend's `TelemetrySnapshot` interface in `services/vehicleApi.ts`, mirrored by hand.
4. Wherever it should show up: a card in `frontend/src/cards/registry.ts`, and the demo data in
`src/GarageStack.Data/Demo/` so the field is visible without a car.

What it does *not* need is a mention in the repository's merge loops or its "does this row carry
anything" filters, or in `WidgetStatusDto`: the first two are derived from the model itself, and
the widget deliberately serves a curated subset. Charts are the same story in reverse: add the
field to `TelemetryHistoryPoint` and the history query starts returning rows that carry it.

## Frontend

REST calls go through `frontend/src/services/` - `apiCore.ts` centralizes the `fetch` wrapper (cookie-based auth, shared 401 handling, the JSON request helpers), and each domain area (`vehicleApi.ts`, `maintenanceApi.ts`, `notificationsApi.ts`, `mapApi.ts`, etc.) builds on it. Real-time updates use `useSignalR.ts` as described above, not polling.

The vehicle store owns `effectiveVehicleType` (the user's manual override, else the type detected from the gateway's `hw_version`); views derive their `isHev`/`isBev` style flags from it rather than repeating the override logic. The TypeScript interfaces in `services/` mirror the API's DTOs by hand; the history endpoint returns `TelemetryHistoryPoint` (the chart fields only), not full snapshots.
The vehicle store owns `activeVehicle`/`activeVin` (the one car this instance follows) and `effectiveVehicleType` (the user's manual override, else the drivetrain the API detected from the gateway's `hw_version`); views read those rather than indexing into the vehicle list or repeating the override logic. The TypeScript interfaces in `services/` mirror the API's DTOs by hand; the history endpoint returns `TelemetryHistoryPoint` (the chart fields only), not full snapshots.

Dashboard cards are described once, in `frontend/src/cards/registry.ts`: each entry carries the card's icon, whether it is visible by default for a given drivetrain, and whether the current telemetry has anything to show. The card ids, the default layout and the "does this card have data" checks are all derived from that list, so a new card is one entry plus its markup in `DashboardCardContent.vue`. The map's point-of-interest layers work the same way: `composables/poiTileLayer.ts` holds the fetch-by-tile, cache and cluster logic, and charging stations, fuel stations and service areas are three configurations of it.

## Tests

Expand All @@ -138,4 +155,6 @@ rotated map marker. CI runs them in the `e2e` job; see CONTRIBUTING.md for runni

## Build conventions

Backend projects share `Directory.Build.props` (analyzers on, warnings are errors, `.editorconfig` style rules enforced in the build) and `Directory.Packages.props` (central package versions). `dotnet format GarageStack.slnx` applies the formatting rules; CI verifies them. The frontend is linted by oxlint and ESLint and formatted by Prettier, also verified in CI.
Backend projects share `Directory.Build.props` (analyzers on, warnings are errors, `.editorconfig` style rules enforced in the build) and `Directory.Packages.props` (central package versions). `dotnet format GarageStack.slnx` applies the formatting rules; CI verifies them. The frontend is linted by oxlint and ESLint, its stylesheets by Stylelint, and formatted by Prettier, all verified in CI.

Versions are pinned once: package versions in `Directory.Packages.props` (the release workflow reads MinVer's version from there too, so the tag and the stamped assemblies cannot come from different MinVer majors), and pnpm in `frontend/package.json`'s `packageManager` field, which corepack and the CI action both read. Runtime defaults belong to the app rather than to the deployment files: the compose files and the all-in-one entrypoint pass a setting through only when the operator sets one, so numbers like the tyre pressure thresholds exist in exactly one place.
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ To work against the full stack (real or self-provided credentials):

- Make sure linting and formatting pass:
- Backend: `dotnet build` (analyzers run as part of the build, warnings are errors) and `dotnet format GarageStack.slnx`
- Frontend: `pnpm lint` and `pnpm format`
- Frontend: `pnpm lint` (oxlint, ESLint and Stylelint, each with `--fix`) and `pnpm format`
- Changed an EF Core entity? Add a migration from the repository root (no Api configuration needed):
`dotnet ef migrations add <Name> --project src/GarageStack.Data --startup-project src/GarageStack.Data`

Expand Down
1 change: 1 addition & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
Microsoft.AspNetCore.OpenApi at both compile time (XmlCommentGenerator) and runtime. -->
<PackageVersion Include="Microsoft.OpenApi" Version="2.12.2" />
<PackageVersion Include="Microsoft.IdentityModel.Tokens" Version="8.22.0" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.12" />
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.12" />
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.12" />
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.12" />
Expand Down
5 changes: 3 additions & 2 deletions docker-compose.demo.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,12 @@ services:
dockerfile: src/GarageStack.Api/Dockerfile
container_name: garagestack-api-demo
restart: unless-stopped
env_file:
- .env.demo
environment:
# Everything the container gets is listed here; compose reads .env.demo for the ${...}
# placeholders on its own, as the production compose file does.
ASPNETCORE_ENVIRONMENT: "Production"
DEMO_MODE: "true"
DEBUG_LOGS: "${DEBUG_LOGS:-false}"
Cors__Origins__0: "${CORS_ORIGIN:-http://localhost:8080}"
Auth__Username: "${AUTH_USERNAME:-demo}"
Auth__Password: "${AUTH_PASSWORD:-demo}"
Expand Down
56 changes: 27 additions & 29 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,3 +1,25 @@
# Settings both .NET services need. Compose reads .env for ${...} interpolation on its own, so
# every value a container gets is listed explicitly here: no env_file, and no secret reaching a
# container that has no use for it (the Worker never sees the OIDC or widget credentials, the API
# never sees the notification language).
x-dotnet-environment: &dotnet-environment
ASPNETCORE_ENVIRONMENT: "Production"
DEBUG_LOGS: "${DEBUG_LOGS:-false}"
ConnectionStrings__DefaultConnection: "Host=${POSTGRES_HOST:-postgres};Port=${POSTGRES_PORT:-5432};Database=${POSTGRES_DB:-garagestack};Username=${POSTGRES_USER:-garagestack};Password=${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in your .env file, see .env.example}"
Mqtt__Host: "${MQTT_HOST:-mosquitto}"
Mqtt__Port: "${MQTT_PORT:-1883}"
Mqtt__Username: "${MQTT_BROKER_USERNAME:-garagestack}"
Mqtt__Password: "${MQTT_BROKER_PASSWORD:?Set MQTT_BROKER_PASSWORD in your .env file, see .env.example}"
Vapid__PublicKey: "${VAPID_PUBLIC_KEY}"
Vapid__PrivateKey: "${VAPID_PRIVATE_KEY}"
Vapid__Subject: "mailto:${SAIC_USER}"
# Colour-coding and notification thresholds (bar); the API paints them, the Worker alerts on
# them. Left unset here on purpose: the app holds the defaults, so they are not restated (and
# cannot drift) in the deployment files.
TyrePressure__LowBar: "${TYRE_PRESSURE_LOW_BAR:-}"
TyrePressure__GoodBar: "${TYRE_PRESSURE_GOOD_BAR:-}"
TyrePressure__HighBar: "${TYRE_PRESSURE_HIGH_BAR:-}"

services:
mosquitto:
image: eclipse-mosquitto:2
Expand Down Expand Up @@ -83,23 +105,10 @@ services:
condition: service_started
api:
condition: service_healthy
env_file:
- .env
environment:
ASPNETCORE_ENVIRONMENT: "Production"
ConnectionStrings__DefaultConnection: "Host=${POSTGRES_HOST:-postgres};Port=${POSTGRES_PORT:-5432};Database=${POSTGRES_DB:-garagestack};Username=${POSTGRES_USER:-garagestack};Password=${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in your .env file, see .env.example}"
Mqtt__Host: "${MQTT_HOST:-mosquitto}"
Mqtt__Port: "${MQTT_PORT:-1883}"
Mqtt__Username: "${MQTT_BROKER_USERNAME:-garagestack}"
Mqtt__Password: "${MQTT_BROKER_PASSWORD:?Set MQTT_BROKER_PASSWORD in your .env file, see .env.example}"
Vapid__PublicKey: "${VAPID_PUBLIC_KEY}"
Vapid__PrivateKey: "${VAPID_PRIVATE_KEY}"
Vapid__Subject: "mailto:${SAIC_USER}"
<<: *dotnet-environment
# Language of push notification texts (en or nl); the Worker has no browser to ask.
Notifications__Culture: "${NOTIFICATION_LANGUAGE:-en}"
TyrePressure__LowBar: "${TYRE_PRESSURE_LOW_BAR:-2.2}"
TyrePressure__GoodBar: "${TYRE_PRESSURE_GOOD_BAR:-2.6}"
TyrePressure__HighBar: "${TYRE_PRESSURE_HIGH_BAR:-3.2}"
volumes:
- worker_logs:/app/logs

Expand All @@ -121,11 +130,8 @@ services:
required: false
mosquitto:
condition: service_started
env_file:
- .env
environment:
ASPNETCORE_ENVIRONMENT: "Production"
ConnectionStrings__DefaultConnection: "Host=${POSTGRES_HOST:-postgres};Port=${POSTGRES_PORT:-5432};Database=${POSTGRES_DB:-garagestack};Username=${POSTGRES_USER:-garagestack};Password=${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in your .env file, see .env.example}"
<<: *dotnet-environment
Cors__Origins__0: "${CORS_ORIGIN:-http://localhost:8080}"
SAIC_USER: "${SAIC_USER}"
SAIC_PASSWORD: "${SAIC_PASSWORD}"
Expand All @@ -149,21 +155,13 @@ services:
Oidc__GroupsClaim: "${OIDC_GROUPS_CLAIM:-groups}"
Oidc__RedirectUri: "${OIDC_REDIRECT_URI:-}"
Oidc__RequireHttpsMetadata: "${OIDC_REQUIRE_HTTPS_METADATA:-true}"
Mqtt__Host: "${MQTT_HOST:-mosquitto}"
Mqtt__Port: "${MQTT_PORT:-1883}"
Mqtt__Username: "${MQTT_BROKER_USERNAME:-garagestack}"
Mqtt__Password: "${MQTT_BROKER_PASSWORD:?Set MQTT_BROKER_PASSWORD in your .env file, see .env.example}"
Vapid__PublicKey: "${VAPID_PUBLIC_KEY}"
Vapid__PrivateKey: "${VAPID_PRIVATE_KEY}"
Vapid__Subject: "mailto:${SAIC_USER}"
Widget__ApiKey: "${WIDGET_API_KEY:-}"
OpenChargeMap__ApiKey: "${OPENCHARGEMAP_API_KEY:-}"
# Only worth setting when you self-host Overpass; the public endpoint needs no key.
Overpass__BaseUrl: "${OVERPASS__BASEURL:-https://overpass-api.de/api/interpreter}"
Auth__CookieSecure: "${AUTH_COOKIE_SECURE:-false}"
Auth__SessionLifetimeHours: "${AUTH_SESSION_LIFETIME_HOURS:-168}"
RateLimits__GlobalPerMinute: "${RATE_LIMIT_GLOBAL_PER_MINUTE:-120}"
TyrePressure__LowBar: "${TYRE_PRESSURE_LOW_BAR:-2.2}"
TyrePressure__GoodBar: "${TYRE_PRESSURE_GOOD_BAR:-2.6}"
TyrePressure__HighBar: "${TYRE_PRESSURE_HIGH_BAR:-3.2}"
RateLimits__GlobalPerMinute: "${RATE_LIMIT_GLOBAL_PER_MINUTE:-}"
ports:
- "127.0.0.1:${API_PORT:-5000}:8080"
volumes:
Expand Down
5 changes: 4 additions & 1 deletion docker/all-in-one/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,11 @@ WORKDIR /app
# OS_PATCH_DATE busts the layer cache so apk upgrade actually re-runs instead of
# reusing a stale cached layer from before an OS package fix was published.
ARG OS_PATCH_DATE=0
RUN echo "os patch date: ${OS_PATCH_DATE}" && apk upgrade --no-cache && corepack enable && corepack prepare pnpm@11.1.3 --activate
RUN echo "os patch date: ${OS_PATCH_DATE}" && apk upgrade --no-cache && corepack enable
COPY frontend/package.json frontend/pnpm-lock.yaml frontend/pnpm-workspace.yaml ./
# No version here: corepack takes it from package.json's packageManager field, so the pnpm
# version is pinned in one place for local installs, CI and every image.
RUN corepack prepare --activate
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \
pnpm config set store-dir /pnpm/store && \
pnpm config set minimum-release-age 0 && \
Expand Down
12 changes: 6 additions & 6 deletions docker/all-in-one/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -87,13 +87,13 @@ export Widget__ApiKey="${WIDGET_API_KEY:-}"

# API requests per minute per client IP. Optional -- raise it when several people share one
# public address, lower it to tighten the budget.
export RateLimits__GlobalPerMinute="${RATE_LIMIT_GLOBAL_PER_MINUTE:-120}"
export RateLimits__GlobalPerMinute="${RATE_LIMIT_GLOBAL_PER_MINUTE:-}"

# Tyre pressure colour-coding / notification thresholds (bar). Optional -- defaults to
# 2.2 / 2.6 / 3.2 (the app's built-in values) when unset.
export TyrePressure__LowBar="${TYRE_PRESSURE_LOW_BAR:-2.2}"
export TyrePressure__GoodBar="${TYRE_PRESSURE_GOOD_BAR:-2.6}"
export TyrePressure__HighBar="${TYRE_PRESSURE_HIGH_BAR:-3.2}"
# Tyre pressure colour-coding / notification thresholds (bar). Optional. Empty means "use the
# app's own defaults", so those numbers live in one place instead of being restated here.
export TyrePressure__LowBar="${TYRE_PRESSURE_LOW_BAR:-}"
export TyrePressure__GoodBar="${TYRE_PRESSURE_GOOD_BAR:-}"
export TyrePressure__HighBar="${TYRE_PRESSURE_HIGH_BAR:-}"

# .NET API listens on an internal port; nginx proxies port 80 to it
export ASPNETCORE_URLS="http://127.0.0.1:9000"
Expand Down
5 changes: 4 additions & 1 deletion frontend/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,11 @@ ENV VITE_DEMO_MODE=$VITE_DEMO_MODE
# OS_PATCH_DATE busts the layer cache so apk upgrade actually re-runs instead of
# reusing a stale cached layer from before an OS package fix was published.
ARG OS_PATCH_DATE=0
RUN echo "os patch date: ${OS_PATCH_DATE}" && apk upgrade --no-cache && corepack enable && corepack prepare pnpm@11.1.3 --activate
RUN echo "os patch date: ${OS_PATCH_DATE}" && apk upgrade --no-cache && corepack enable
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
# No version here: corepack takes it from package.json's packageManager field, so the pnpm
# version is pinned in one place for local installs, CI and every image.
RUN corepack prepare --activate
RUN pnpm config set minimum-release-age 0 && pnpm install --frozen-lockfile --ignore-scripts
COPY . .
# Declared after install so a new version does not bust the dependency layer cache.
Expand Down
6 changes: 5 additions & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,10 @@
"test:e2e": "playwright test",
"build-only": "vite build",
"type-check": "vue-tsc --build",
"lint": "pnpm run lint:oxlint && pnpm run lint:eslint",
"lint": "pnpm run lint:oxlint && pnpm run lint:eslint && pnpm run lint:css",
"lint:oxlint": "oxlint . --fix",
"lint:eslint": "eslint . --fix --cache",
"lint:css": "stylelint \"src/**/*.{css,vue}\" --fix",
"format": "prettier --write --experimental-cli src/ e2e/"
},
"dependencies": {
Expand Down Expand Up @@ -62,7 +63,10 @@
"jsdom": "^30.0.1",
"npm-run-all2": "^9.0.3",
"oxlint": "~1.82.0",
"postcss-html": "^2.0.0",
"prettier": "3.9.6",
"stylelint": "^17.15.0",
"stylelint-config-standard": "^40.0.0",
"typescript": "~6.0.3",
"vite": "^8.3.0",
"vite-plugin-vue-devtools": "^8.2.1",
Expand Down
Loading
Loading