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
5 changes: 5 additions & 0 deletions .github/workflows/copilot-setup-steps.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,17 @@ on:
- ubuntu-latest
default: ubuntu-latest
push:
branches: [main]
paths:
- .github/workflows/copilot-setup-steps.yml
pull_request:
paths:
- .github/workflows/copilot-setup-steps.yml

concurrency:
group: copilot-setup-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

jobs:
copilot-setup-steps:
runs-on: ${{ inputs.runner || 'ubuntu-latest' }}
Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/helm-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ on:
branches: [main]
paths:
- 'helm/**'
- '.github/workflows/helm-ci.yml'
workflow_dispatch:
inputs:
runner:
Expand All @@ -20,6 +21,10 @@ on:
- ubuntu-latest
default: ubuntu-latest

concurrency:
group: helm-ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

permissions:
contents: read

Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/security.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ on:
- ubuntu-latest
default: ubuntu-latest

concurrency:
group: security-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

permissions:
contents: read
security-events: write
Expand Down
6 changes: 6 additions & 0 deletions docs/features/catalogue-settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ and `/settings#appearance` continue to work. Switching categories preserves
unsaved form edits and does not save automatically. Each section retains its own
save or instant-apply behavior.

**Fonts & readability** at `/settings#typography` offers a searchable UI and
monospace font library, live font samples, and a text field for trying your own
copy. Font selection, reading presets, scale, line height, letter spacing, and
heading weight apply across the app immediately. Selected fonts are persisted
with the rest of the settings and restored before the first page paint.

Reset controls live in **Reset & recovery**, separate from everyday preferences.
The overview retains the current-preference summary, export link, guided-tour
launcher, and setup-checklist restart.
Expand Down
2 changes: 1 addition & 1 deletion docs/features/dashboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ Widgets are self-rendering: each one declares which signals it depends on, and i

## Multiple dashboards

Use **Layout packs** in the dashboard header to preview and install a ready-made dashboard without replacing your current one, or choose **Blank Dashboard** to start with no widgets. The **Personal workspace** controls let you switch, rename, duplicate, and create dashboards; **Customize** reveals the drag handles and always-visible **Arrange**, settings, and remove controls. **Auto Arrange** packs widgets into rows without resetting their chosen widths or heights. Each dashboard has its own widgets and layout. Common patterns:
Use **New Layout** in the dashboard header or the Personal workspace strip to create a dashboard without replacing your current one. Choose **Blank Dashboard** to start with no widgets, or preview a populated template such as Operations Desk, Battery Care, Winter Ready, Daily Commuter, or Fleet Manager. The template creates its widgets and responsive grid positions together; use **Add Widget** under **Customize** only to add individual widgets to the current layout. The **Personal workspace** controls let you switch, rename, and duplicate layouts; **Customize** reveals the drag handles and always-visible **Arrange**, settings, and remove controls. **Auto Arrange** packs widgets into rows without resetting their chosen widths or heights. Each dashboard has its own widgets and layout. Common patterns:

- **Daily** — Vehicle Hero, Battery Level, Quick Actions, Recent Alerts
- **Long trip** — Live Drive, Charger Map, Range Bar, Tire Pressure, Energy Flow
Expand Down
8 changes: 4 additions & 4 deletions helm/teslasync/templates/configmap-nginx.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ data:
# index.html must not be cached so new JS bundle references are picked up
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
add_header Pragma "no-cache" always;
add_header Content-Security-Policy "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; script-src 'self' 'sha256-ue3mo3bS289RnivI3dUkb2fS/Z4Y2IXWPEYuKHmUdY0='; style-src 'self' https://fonts.googleapis.com; style-src-attr 'unsafe-inline'; style-src-elem 'self' https://fonts.googleapis.com 'sha256-eC/LYFbFX0VPdH107k2WfWKmpi4owBWgEs9oH9HVTeI='; img-src 'self' data: blob: https:; media-src 'self' blob:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https: wss:; worker-src 'self' blob:; manifest-src 'self'" always;
add_header Content-Security-Policy "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; script-src 'self' 'sha256-IzFKe1vs4CMasxgOlkfXzI6L8l6kEOwm5HOY9MlNrfQ='; style-src 'self' https://fonts.googleapis.com; style-src-attr 'unsafe-inline'; style-src-elem 'self' https://fonts.googleapis.com 'sha256-eC/LYFbFX0VPdH107k2WfWKmpi4owBWgEs9oH9HVTeI='; img-src 'self' data: blob: https:; media-src 'self' blob:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https: wss:; worker-src 'self' blob:; manifest-src 'self'" always;
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
Expand Down Expand Up @@ -99,7 +99,7 @@ data:
location = /sw.js {
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
add_header Pragma "no-cache" always;
add_header Content-Security-Policy "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; script-src 'self' 'sha256-ue3mo3bS289RnivI3dUkb2fS/Z4Y2IXWPEYuKHmUdY0='; style-src 'self' https://fonts.googleapis.com; style-src-attr 'unsafe-inline'; style-src-elem 'self' https://fonts.googleapis.com 'sha256-eC/LYFbFX0VPdH107k2WfWKmpi4owBWgEs9oH9HVTeI='; img-src 'self' data: blob: https:; media-src 'self' blob:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https: wss:; worker-src 'self' blob:; manifest-src 'self'" always;
add_header Content-Security-Policy "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; script-src 'self' 'sha256-IzFKe1vs4CMasxgOlkfXzI6L8l6kEOwm5HOY9MlNrfQ='; style-src 'self' https://fonts.googleapis.com; style-src-attr 'unsafe-inline'; style-src-elem 'self' https://fonts.googleapis.com 'sha256-eC/LYFbFX0VPdH107k2WfWKmpi4owBWgEs9oH9HVTeI='; img-src 'self' data: blob: https:; media-src 'self' blob:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https: wss:; worker-src 'self' blob:; manifest-src 'self'" always;
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
Expand All @@ -111,7 +111,7 @@ data:
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
add_header Content-Security-Policy "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; script-src 'self' 'sha256-ue3mo3bS289RnivI3dUkb2fS/Z4Y2IXWPEYuKHmUdY0='; style-src 'self' https://fonts.googleapis.com; style-src-attr 'unsafe-inline'; style-src-elem 'self' https://fonts.googleapis.com 'sha256-eC/LYFbFX0VPdH107k2WfWKmpi4owBWgEs9oH9HVTeI='; img-src 'self' data: blob: https:; media-src 'self' blob:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https: wss:; worker-src 'self' blob:; manifest-src 'self'" always;
add_header Content-Security-Policy "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; script-src 'self' 'sha256-IzFKe1vs4CMasxgOlkfXzI6L8l6kEOwm5HOY9MlNrfQ='; style-src 'self' https://fonts.googleapis.com; style-src-attr 'unsafe-inline'; style-src-elem 'self' https://fonts.googleapis.com 'sha256-eC/LYFbFX0VPdH107k2WfWKmpi4owBWgEs9oH9HVTeI='; img-src 'self' data: blob: https:; media-src 'self' blob:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https: wss:; worker-src 'self' blob:; manifest-src 'self'" always;
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
Expand All @@ -120,7 +120,7 @@ data:

# HSTS is emitted at the TLS ingress only; this pod intentionally
# remains HTTP on the cluster network.
add_header Content-Security-Policy "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; script-src 'self' 'sha256-ue3mo3bS289RnivI3dUkb2fS/Z4Y2IXWPEYuKHmUdY0='; style-src 'self' https://fonts.googleapis.com; style-src-attr 'unsafe-inline'; style-src-elem 'self' https://fonts.googleapis.com 'sha256-eC/LYFbFX0VPdH107k2WfWKmpi4owBWgEs9oH9HVTeI='; img-src 'self' data: blob: https:; media-src 'self' blob:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https: wss:; worker-src 'self' blob:; manifest-src 'self'" always;
add_header Content-Security-Policy "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; script-src 'self' 'sha256-IzFKe1vs4CMasxgOlkfXzI6L8l6kEOwm5HOY9MlNrfQ='; style-src 'self' https://fonts.googleapis.com; style-src-attr 'unsafe-inline'; style-src-elem 'self' https://fonts.googleapis.com 'sha256-eC/LYFbFX0VPdH107k2WfWKmpi4owBWgEs9oH9HVTeI='; img-src 'self' data: blob: https:; media-src 'self' blob:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https: wss:; worker-src 'self' blob:; manifest-src 'self'" always;
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
Expand Down
2 changes: 1 addition & 1 deletion helm/teslasync/templates/middleware-security-headers.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ metadata:
{{- include "teslasync.labels" . | nindent 4 }}
spec:
headers:
contentSecurityPolicy: "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; script-src 'self' 'sha256-ue3mo3bS289RnivI3dUkb2fS/Z4Y2IXWPEYuKHmUdY0='; style-src 'self' https://fonts.googleapis.com; style-src-attr 'unsafe-inline'; style-src-elem 'self' https://fonts.googleapis.com 'sha256-eC/LYFbFX0VPdH107k2WfWKmpi4owBWgEs9oH9HVTeI='; img-src 'self' data: blob: https:; media-src 'self' blob:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https: wss:; worker-src 'self' blob:; manifest-src 'self'"
contentSecurityPolicy: "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; script-src 'self' 'sha256-IzFKe1vs4CMasxgOlkfXzI6L8l6kEOwm5HOY9MlNrfQ='; style-src 'self' https://fonts.googleapis.com; style-src-attr 'unsafe-inline'; style-src-elem 'self' https://fonts.googleapis.com 'sha256-eC/LYFbFX0VPdH107k2WfWKmpi4owBWgEs9oH9HVTeI='; img-src 'self' data: blob: https:; media-src 'self' blob:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https: wss:; worker-src 'self' blob:; manifest-src 'self'"
contentTypeNosniff: true
frameDeny: true
referrerPolicy: "strict-origin-when-cross-origin"
Expand Down
17 changes: 7 additions & 10 deletions internal/api/adminlogstream/handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -318,19 +318,16 @@ func TestAdminLogStreamHandler_DropTickEmitsAfterBufferOverflow(t *testing.T) {
reg.WriteLevel(zerolog.InfoLevel, []byte(`{"i":1}`))
}

stop := make(chan struct{})
defer close(stop)
events := drainSSEEvents(t, resp.Body, 6, stop)
sawDrop := false
for _, e := range events {
if e.event == "drop" {
sawDrop = true
break
scanner := bufio.NewScanner(resp.Body)
for scanner.Scan() {
if scanner.Text() == "event: drop" {
return
}
}
if !sawDrop {
t.Fatalf("no drop event emitted in %d events: %+v", len(events), events)
if err := scanner.Err(); err != nil {
t.Fatalf("read drop event: %v", err)
}
t.Fatal("stream ended before emitting a drop event")
}

func TestParseLogStreamLevel(t *testing.T) {
Expand Down
36 changes: 28 additions & 8 deletions internal/api/middleware/security_template_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,20 +121,40 @@ func TestProductionCSPHashesMatchInlineBootstrap(t *testing.T) {
t.Fatalf("read index.html: %v", err)
}

expected := map[string]string{
"script": "sha256-ue3mo3bS289RnivI3dUkb2fS/Z4Y2IXWPEYuKHmUdY0=",
"style": "sha256-eC/LYFbFX0VPdH107k2WfWKmpi4owBWgEs9oH9HVTeI=",
}
for tag, want := range expected {
hashes := make(map[string]string)
for _, tag := range []string{"script", "style"} {
pattern := regexp.MustCompile(`(?s)<` + tag + `[^>]*>(.*?)</` + tag + `>`)
match := pattern.FindSubmatch(html)
if len(match) != 2 {
t.Fatalf("find inline %s block", tag)
}
sum := sha256.Sum256(match[1])
got := "sha256-" + base64.StdEncoding.EncodeToString(sum[:])
if got != want {
t.Errorf("%s CSP hash = %q, want %q", tag, got, want)
hashes[tag] = "sha256-" + base64.StdEncoding.EncodeToString(sum[:])
}

for _, path := range []string{
filepath.Join(repositoryRoot(t), "web", "nginx.conf"),
filepath.Join(repositoryRoot(t), "helm", "teslasync", "templates", "configmap-nginx.yaml"),
filepath.Join(repositoryRoot(t), "helm", "teslasync", "templates", "middleware-security-headers.yaml"),
} {
contents, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read CSP template %s: %v", path, err)
}
policies := 0
for _, line := range strings.Split(string(contents), "\n") {
if !strings.Contains(line, "script-src 'self'") {
continue
}
policies++
for tag, hash := range hashes {
if !strings.Contains(line, "'"+hash+"'") {
t.Errorf("%s: policy %d has a %s CSP hash that does not match index.html (%s)", path, policies, tag, hash)
}
}
}
if policies == 0 {
t.Errorf("%s: no script CSP policies found", path)
}
}
}
26 changes: 26 additions & 0 deletions internal/api/settings/font_validation_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package settings

import "testing"

func TestFontPresetsAcceptedBySettings(t *testing.T) {
for _, id := range []string{
"inter", "system", "roboto", "source", "plex", "atkinson", "nunito",
"dm-sans", "manrope", "outfit", "poppins", "work-sans", "public-sans",
"lato", "open-sans", "noto-sans", "custom",
} {
if !validFontFamily[id] {
t.Errorf("UI font %q must be accepted by settings", id)
}
}
for _, id := range []string{
"jetbrains", "fira", "plex-mono", "source-code-pro", "roboto-mono",
"inconsolata", "space-mono", "ubuntu-mono", "system", "custom",
} {
if !validFontMono[id] {
t.Errorf("monospace font %q must be accepted by settings", id)
}
}
if validFontFamily["unknown"] || validFontMono["unknown"] {
t.Fatal("unrecognized font IDs must be rejected")
}
}
18 changes: 14 additions & 4 deletions internal/api/settings/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,18 @@ type SettingsHandler struct {
telemetryHandler any
}

var validFontFamily = map[string]bool{
"inter": true, "system": true, "roboto": true, "source": true, "plex": true, "atkinson": true,
"nunito": true, "dm-sans": true, "manrope": true, "outfit": true, "poppins": true,
"work-sans": true, "public-sans": true, "lato": true, "open-sans": true, "noto-sans": true, "custom": true,
}

var validFontMono = map[string]bool{
"jetbrains": true, "fira": true, "plex-mono": true, "source-code-pro": true,
"roboto-mono": true, "inconsolata": true, "space-mono": true, "ubuntu-mono": true,
"system": true, "custom": true,
}

type endpointControlsStore interface {
GetEndpointControls(context.Context) (settingsmodel.LegacyPollingConfig, error)
UpsertEndpointControls(context.Context, settingsmodel.LegacyPollingConfig) error
Expand Down Expand Up @@ -130,14 +142,12 @@ func (h *SettingsHandler) Update(w http.ResponseWriter, r *http.Request) {
// ── Typography validation (Typography Unit 0) ──
// Mirror the union types + slider bounds in FontProvider.tsx. Empty values
// are tolerated (a partial round-trip keeps the stored default).
validFontFamily := map[string]bool{"inter": true, "system": true, "roboto": true, "source": true, "plex": true, "atkinson": true, "custom": true}
if s.FontFamily != "" && !validFontFamily[s.FontFamily] {
httpx.WriteError(w, http.StatusBadRequest, "font_family must be one of inter/system/roboto/source/plex/atkinson/custom")
httpx.WriteError(w, http.StatusBadRequest, "font_family must be a supported UI font")
return
}
validFontMono := map[string]bool{"jetbrains": true, "fira": true, "plex-mono": true, "system": true, "custom": true}
if s.FontMono != "" && !validFontMono[s.FontMono] {
httpx.WriteError(w, http.StatusBadRequest, "font_mono must be one of jetbrains/fira/plex-mono/system/custom")
httpx.WriteError(w, http.StatusBadRequest, "font_mono must be a supported monospace font")
return
}
if len(s.FontCustomSans) > 200 || len(s.FontCustomMono) > 200 {
Expand Down
Binary file modified web/e2e/.snapshots/visual-1440-dark/visual.spec.ts/dashboard.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified web/e2e/.snapshots/visual-1440-dark/visual.spec.ts/settings.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified web/e2e/.snapshots/visual-1440-light/visual.spec.ts/dashboard.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified web/e2e/.snapshots/visual-1440-light/visual.spec.ts/settings.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified web/e2e/.snapshots/visual-degraded/visual.spec.ts/dashboard.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
37 changes: 37 additions & 0 deletions web/e2e/appearance-consolidation.smoke.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { expect, test } from '@playwright/test'
import { assertMockApiComplete, installApiMocks, seedBrowserState, waitForHarnessReady } from './mockApi'
import { expectNoHorizontalOverflow } from './qualityAssertions'

for (const width of [390, 1920]) {
for (const theme of ['dark', 'light'] as const) {
test(`appearance keeps everyday controls concise at ${width}px in ${theme}`, async ({ page }, testInfo) => {
await page.setViewportSize({ width, height: 900 })
await seedBrowserState(page, theme, '/settings')
const api = await installApiMocks(page, 'populated', theme)
await page.goto('/settings#appearance', { waitUntil: 'domcontentloaded' })
await waitForHarnessReady(page, api)

const appearance = page.locator('#appearance')
await expect(appearance).toBeVisible()
await expect(appearance.getByText('Display Mode', { exact: true })).toBeVisible()
await expect(page.getByText('Information density', { exact: true })).toBeVisible()
const browse = appearance.getByRole('button', { name: /Browse all \d+ modes/ })
await expect(browse).toHaveAttribute('aria-expanded', 'false')
await expect(appearance.getByRole('searchbox', { name: 'Search display modes…' })).toHaveCount(0)
const more = page.getByRole('button', { name: 'More appearance options' })
await expect(more).toHaveAttribute('aria-expanded', 'false')
await expect(page.getByRole('radiogroup', { name: 'Chart palette' })).toHaveCount(0)
await expectNoHorizontalOverflow(page)
await page.screenshot({ path: testInfo.outputPath(`appearance-${theme}-${width}.png`) })

await browse.click()
await expect(appearance.getByRole('searchbox', { name: 'Search display modes…' })).toBeVisible()
await appearance.getByRole('button', { name: 'Show essential modes' }).click()
await more.click()
await expect(page.getByRole('radiogroup', { name: 'Chart palette' })).toBeVisible()
await expect(page.getByTestId('statusbar-toggle-enabled')).toBeVisible()
await expectNoHorizontalOverflow(page)
await assertMockApiComplete(page, api)
})
}
}
Loading
Loading