From 688752c2ed429770de7c986fdb1a1c4f196cea1c Mon Sep 17 00:00:00 2001 From: Atul Gupta Date: Mon, 4 May 2026 16:04:00 -0700 Subject: [PATCH 01/76] phase-46/01-error-reporting: capture window/promise/react/query errors and ship to /api/v1/web-errors Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../phase-46-01-frontend-error-reporting.log | 1121 +++++++++++++++++ internal/api/router.go | 25 + internal/api/web_errors_handler.go | 261 ++++ internal/api/web_errors_handler_test.go | 277 ++++ web/src/api/hooks/useAdmin.ts | 18 + web/src/components/feedback/ErrorBoundary.tsx | 6 + .../ui/__tests__/CommandPalette.test.tsx | 1 - web/src/features/admin/pages/AdminPage.tsx | 55 +- web/src/i18n/en.json | 8 + web/src/lib/__tests__/errorReporter.test.ts | 232 ++++ web/src/lib/errorReporter.ts | 232 ++++ web/src/main.tsx | 25 + web/src/types/admin.ts | 24 + 13 files changed, 2282 insertions(+), 3 deletions(-) create mode 100644 .github/prompts/db-refactor/logs/phase-46-01-frontend-error-reporting.log create mode 100644 internal/api/web_errors_handler.go create mode 100644 internal/api/web_errors_handler_test.go create mode 100644 web/src/lib/__tests__/errorReporter.test.ts create mode 100644 web/src/lib/errorReporter.ts diff --git a/.github/prompts/db-refactor/logs/phase-46-01-frontend-error-reporting.log b/.github/prompts/db-refactor/logs/phase-46-01-frontend-error-reporting.log new file mode 100644 index 0000000000..d2c6c7e734 --- /dev/null +++ b/.github/prompts/db-refactor/logs/phase-46-01-frontend-error-reporting.log @@ -0,0 +1,1121 @@ +=== GATE === +=== PREFLIGHT === +PROMPT=phase-46/01-frontend-error-reporting +BRANCH=fix/bug-waave-46 +HEAD=a2010406 +=== FILES_EXIST === +FILE[internal\api\web_errors_handler.go]=True +FILE[internal\api\web_errors_handler_test.go]=True +FILE[internal\api\router.go]=True +FILE[web\src\lib\errorReporter.ts]=True +FILE[web\src\lib\__tests__\errorReporter.test.ts]=True +FILE[web\src\main.tsx]=True +FILE[web\src\components\feedback\ErrorBoundary.tsx]=True +FILES_EXPECTED=7 +FILES_MISSING=0 +=== TYPESCRIPT === +TSC_EXIT=0 +=== LINT === + +> teslasync-web@2.0.0 lint +> eslint . --report-unused-disable-directives --max-warnings 0 && npm run lint:i18n && npm run lint:light-mode && npm run audit:empty-state && npm run audit:tooltip-text && npm run audit:i18n-shadowed + + +> teslasync-web@2.0.0 lint:i18n +> node scripts/lint-i18n-no-template-literals.mjs + +[i18n-lint] OK ΓÇö no template literals in src/i18n/*.json + +> teslasync-web@2.0.0 lint:light-mode +> node scripts/audit-light-mode-parity.mjs + + +Total violations: 0 + +Allowed drift (LIGHT_MODE_ALLOWED_DRIFT): 0 + +OK ΓÇö light-mode CSS-var parity holds. + +> teslasync-web@2.0.0 audit:empty-state +> node scripts/audit-empty-state-cta.mjs + +OK ΓÇö every in src\features has a CTA or // no-action: exemption + +> teslasync-web@2.0.0 audit:tooltip-text +> node scripts/audit-tooltip-text-color.mjs + +OK ΓÇö no tooltip content overrides intrinsic text colour + +> teslasync-web@2.0.0 audit:i18n-shadowed +> node scripts/audit-i18n-shadowed-keys.mjs + +Γ£à audit:i18n-shadowed-keys ΓÇö no violations +LINT_EXIT=0 +=== FRONTEND_TEST === + +> teslasync-web@2.0.0 test +> vitest run --run + +4:02:13 PM [vite] warning: `esbuild` option was specified by "vite:react-babel" plugin. This option is deprecated, please use `oxc` instead. +4:02:13 PM [vite] warning: `optimizeDeps.esbuildOptions` option was specified by "vite:react-babel" plugin. This option is deprecated, please use `optimizeDeps.rolldownOptions` instead. +Both esbuild and oxc options were set. oxc options will be used and esbuild options will be ignored. The following esbuild options were set: `{ jsx: 'automatic', jsxImportSource: undefined }` + + RUN  v4.1.2 D:/repos/teslasync/web + +stdout | src/components/ui/__tests__/ConfirmDialog.test.tsx +≡ƒîÉ i18next is made possible by our own product, Locize ΓÇö consider powering your project with managed localization (AI, CDN, integrations): https://locize.com ≡ƒÆÖ + +stdout | src/features/settings/components/__tests__/SettingsSearch.test.tsx +≡ƒîÉ i18next is made possible by our own product, Locize ΓÇö consider powering your project with managed localization (AI, CDN, integrations): https://locize.com ≡ƒÆÖ + +stdout | src/features/dashboard/components/__tests__/WidgetCatalogueDialog.test.tsx +≡ƒîÉ i18next is made possible by our own product, Locize ΓÇö consider powering your project with managed localization (AI, CDN, integrations): https://locize.com ≡ƒÆÖ + +stdout | src/components/feedback/__tests__/QueryError.test.tsx +≡ƒîÉ i18next is made possible by our own product, Locize ΓÇö consider powering your project with managed localization (AI, CDN, integrations): https://locize.com ≡ƒÆÖ + +stdout | src/components/feedback/__tests__/NavigationGuardProvider.test.tsx +≡ƒîÉ i18next is made possible by our own product, Locize ΓÇö consider powering your project with managed localization (AI, CDN, integrations): https://locize.com ≡ƒÆÖ + +stdout | src/features/telemetry/pages/__tests__/SignalExplorerPage.help.test.tsx +≡ƒîÉ i18next is made possible by our own product, Locize ΓÇö consider powering your project with managed localization (AI, CDN, integrations): https://locize.com ≡ƒÆÖ + +stdout | src/components/ui/__tests__/DataTable.test.tsx +≡ƒîÉ i18next is made possible by our own product, Locize ΓÇö consider powering your project with managed localization (AI, CDN, integrations): https://locize.com ≡ƒÆÖ + +stdout | src/features/notifications/pages/NotificationsPage.test.tsx +≡ƒîÉ i18next is made possible by our own product, Locize ΓÇö consider powering your project with managed localization (AI, CDN, integrations): https://locize.com ≡ƒÆÖ + +stdout | src/features/onboarding/pages/OnboardingPage.test.tsx +≡ƒîÉ i18next is made possible by our own product, Locize ΓÇö consider powering your project with managed localization (AI, CDN, integrations): https://locize.com ≡ƒÆÖ + +stderr | src/components/feedback/__tests__/QueryError.test.tsx > QueryError > renders nothing when error is null/undefined +ΓÜá∩╕Å React Router Future Flag Warning: React Router will begin wrapping state updates in `React.startTransition` in v7. You can use the `v7_startTransition` future flag to opt-in early. For more information, see https://reactrouter.com/v6/upgrading/future#v7_starttransition. +ΓÜá∩╕Å React Router Future Flag Warning: Relative route resolution within Splat routes is changing in v7. You can use the `v7_relativeSplatPath` future flag to opt-in early. For more information, see https://reactrouter.com/v6/upgrading/future#v7_relativesplatpath. + +stderr | src/components/feedback/__tests__/NavigationGuardProvider.test.tsx > NavigationGuardProvider > confirmIfDirty resolves true when no guards are dirty +ΓÜá∩╕Å React Router Future Flag Warning: React Router will begin wrapping state updates in `React.startTransition` in v7. You can use the `v7_startTransition` future flag to opt-in early. For more information, see https://reactrouter.com/v6/upgrading/future#v7_starttransition. +ΓÜá∩╕Å React Router Future Flag Warning: Relative route resolution within Splat routes is changing in v7. You can use the `v7_relativeSplatPath` future flag to opt-in early. For more information, see https://reactrouter.com/v6/upgrading/future#v7_relativesplatpath. + +stderr | src/components/vehicles/__tests__/VehiclePaintPicker.test.tsx > VehiclePaintPicker > renders 5 swatches as a radio group +react-i18next:: useTranslation: You will need to pass in an i18next instance by using initReactI18next { code: 'NO_I18NEXT_INSTANCE' } + +stderr | src/hooks/__tests__/useConfirm.test.tsx > useConfirm > opens a dialog with the supplied title and message +react-i18next:: useTranslation: You will need to pass in an i18next instance by using initReactI18next { code: 'NO_I18NEXT_INSTANCE' } + + Γ£ô src/components/feedback/__tests__/QueryError.test.tsx (13 tests) 725ms + Γ£ô renders "{resource} not found" title with Back-to-list CTA when listHref is provided  375ms + Γ£ô src/components/vehicles/__tests__/VehiclePaintPicker.test.tsx (5 tests) 913ms + Γ£ô renders 5 swatches as a radio group  587ms + Γ£ô src/components/ui/__tests__/ConfirmDialog.test.tsx (10 tests) 897ms + Γ£ô renders the checkbox when silenceKey is provided on a non-destructive prompt  419ms + Γ£ô src/hooks/__tests__/useConfirm.test.tsx (13 tests) 1149ms + Γ£ô opens a dialog with the supplied title and message  408ms + Γ£ô src/components/ui/__tests__/CopyButton.test.tsx (7 tests) 972ms + Γ£ô writes the text to the clipboard on click and toggles to "Copied"  517ms +Not implemented: navigation to another Document + Γ£ô src/components/feedback/__tests__/NavigationGuardProvider.test.tsx (16 tests) 1676ms + Γ£ô confirm resolves with the user choice (discard = true)  466ms + Γ£ô src/components/ui/__tests__/DataTable.test.tsx (28 tests) 1879ms + Γ£ô calls onSort when a sortable header is clicked  435ms + Γ£ô renders the Columns picker when showColumnsMenu + tableId set, and toggles visibility  340ms +stderr | src/features/settings/components/__tests__/SettingsSearch.test.tsx > SettingsSearch > renders no listbox when the query is empty +ΓÜá∩╕Å React Router Future Flag Warning: React Router will begin wrapping state updates in `React.startTransition` in v7. You can use the `v7_startTransition` future flag to opt-in early. For more information, see https://reactrouter.com/v6/upgrading/future#v7_starttransition. +ΓÜá∩╕Å React Router Future Flag Warning: Relative route resolution within Splat routes is changing in v7. You can use the `v7_relativeSplatPath` future flag to opt-in early. For more information, see https://reactrouter.com/v6/upgrading/future#v7_relativesplatpath. + +stderr | src/features/onboarding/pages/OnboardingPage.test.tsx > OnboardingPage > renders the welcome heading and three setup steps +ΓÜá∩╕Å React Router Future Flag Warning: React Router will begin wrapping state updates in `React.startTransition` in v7. You can use the `v7_startTransition` future flag to opt-in early. For more information, see https://reactrouter.com/v6/upgrading/future#v7_starttransition. +ΓÜá∩╕Å React Router Future Flag Warning: Relative route resolution within Splat routes is changing in v7. You can use the `v7_relativeSplatPath` future flag to opt-in early. For more information, see https://reactrouter.com/v6/upgrading/future#v7_relativesplatpath. + +stderr | src/components/ui/__tests__/CommandPalette.test.tsx > CommandPalette keyboard shortcut > opens on Ctrl+K when focus is on the body +react-i18next:: useTranslation: You will need to pass in an i18next instance by using initReactI18next { code: 'NO_I18NEXT_INSTANCE' } +ΓÜá∩╕Å React Router Future Flag Warning: React Router will begin wrapping state updates in `React.startTransition` in v7. You can use the `v7_startTransition` future flag to opt-in early. For more information, see https://reactrouter.com/v6/upgrading/future#v7_starttransition. +ΓÜá∩╕Å React Router Future Flag Warning: Relative route resolution within Splat routes is changing in v7. You can use the `v7_relativeSplatPath` future flag to opt-in early. For more information, see https://reactrouter.com/v6/upgrading/future#v7_relativesplatpath. + + Γ£ô src/features/settings/components/__tests__/SettingsSearch.test.tsx (16 tests) 961ms + Γ£ô typing "theme" surfaces the Theme entry in the listbox  385ms + Γ£ô src/features/onboarding/pages/OnboardingPage.test.tsx (8 tests) 1397ms + Γ£ô shows the Continue button only when onboarding is complete  482ms +stderr | src/components/ui/__tests__/CommandPalette.test.tsx > CommandPalette search > surfaces vehicle-switch entries when fleet has 2+ vehicles +Warning: Encountered two children with the same key, `Preferences`. Keys should be unique so that components maintain their identity across updates. Non-unique keys may cause children to be duplicated and/or omitted ΓÇö the behavior is unsupported and could change in a future version. + at div + at div + at div + at MotionDOMComponent (file:///D:/repos/teslasync/web/node_modules/framer-motion/dist/es/motion/index.mjs:45:16) + at PopChildMeasure (file:///D:/repos/teslasync/web/node_modules/framer-motion/dist/es/components/AnimatePresence/PopChild.mjs:13:1) + at PopChild (file:///D:/repos/teslasync/web/node_modules/framer-motion/dist/es/components/AnimatePresence/PopChild.mjs:43:21) + at PresenceChild (file:///D:/repos/teslasync/web/node_modules/framer-motion/dist/es/components/AnimatePresence/PresenceChild.mjs:9:26) + at AnimatePresence (file:///D:/repos/teslasync/web/node_modules/framer-motion/dist/es/components/AnimatePresence/index.mjs:44:28) + at CommandPalette (D:/repos/teslasync/web/src/components/ui/CommandPalette.tsx:465:27) + at SelectedVehicleProvider (D:/repos/teslasync/web/src/store/selectedVehicle.tsx:48:36) + at ToastProvider (D:/repos/teslasync/web/src/components/feedback/Toast.tsx:91:26) + at ThemeProvider (D:/repos/teslasync/web/src/components/ui/ThemeProvider.tsx:210:26) + at Router (D:\repos\teslasync\web\node_modules\react-router\dist\umd\react-router.development.js:1207:17) + at MemoryRouter (D:\repos\teslasync\web\node_modules\react-router\dist\umd\react-router.development.js:1101:7) + at QueryClientProvider (file:///D:/repos/teslasync/web/node_modules/@tanstack/react-query/build/modern/QueryClientProvider.js:20:3) + at Wrapper (D:/repos/teslasync/web/src/components/ui/__tests__/CommandPalette.test.tsx:56:28) + +stderr | src/features/system/pages/__tests__/StateMachineDebuggerPage.windowing.test.tsx > StateMachineDebuggerPage ΓÇö windowing reconciliation (Phase 45 / Prompt 35) > reads "0 in window ┬╖ 23 in 24 h" when 23 transitions exist outside the 10-min window +ΓÜá∩╕Å React Router Future Flag Warning: React Router will begin wrapping state updates in `React.startTransition` in v7. You can use the `v7_startTransition` future flag to opt-in early. For more information, see https://reactrouter.com/v6/upgrading/future#v7_starttransition. +ΓÜá∩╕Å React Router Future Flag Warning: Relative route resolution within Splat routes is changing in v7. You can use the `v7_relativeSplatPath` future flag to opt-in early. For more information, see https://reactrouter.com/v6/upgrading/future#v7_relativesplatpath. + +stdout | src/features/admin/components/__tests__/RedisDiagnosticEmptyState.test.tsx +≡ƒîÉ i18next is made possible by our own product, Locize ΓÇö consider powering your project with managed localization (AI, CDN, integrations): https://locize.com ≡ƒÆÖ + +stderr | src/components/ui/__tests__/CommandPalette.test.tsx > CommandPalette search > ranks the "Drives" page above keyword-only matches (e.g. "debugger") for query "d" +Warning: Encountered two children with the same key, `Pages`. Keys should be unique so that components maintain their identity across updates. Non-unique keys may cause children to be duplicated and/or omitted ΓÇö the behavior is unsupported and could change in a future version. + at div + at div + at div + at MotionDOMComponent (file:///D:/repos/teslasync/web/node_modules/framer-motion/dist/es/motion/index.mjs:45:16) + at PopChildMeasure (file:///D:/repos/teslasync/web/node_modules/framer-motion/dist/es/components/AnimatePresence/PopChild.mjs:13:1) + at PopChild (file:///D:/repos/teslasync/web/node_modules/framer-motion/dist/es/components/AnimatePresence/PopChild.mjs:43:21) + at PresenceChild (file:///D:/repos/teslasync/web/node_modules/framer-motion/dist/es/components/AnimatePresence/PresenceChild.mjs:9:26) + at AnimatePresence (file:///D:/repos/teslasync/web/node_modules/framer-motion/dist/es/components/AnimatePresence/index.mjs:44:28) + at CommandPalette (D:/repos/teslasync/web/src/components/ui/CommandPalette.tsx:465:27) + at SelectedVehicleProvider (D:/repos/teslasync/web/src/store/selectedVehicle.tsx:48:36) + at ToastProvider (D:/repos/teslasync/web/src/components/feedback/Toast.tsx:91:26) + at ThemeProvider (D:/repos/teslasync/web/src/components/ui/ThemeProvider.tsx:210:26) + at Router (D:\repos\teslasync\web\node_modules\react-router\dist\umd\react-router.development.js:1207:17) + at MemoryRouter (D:\repos\teslasync\web\node_modules\react-router\dist\umd\react-router.development.js:1101:7) + at QueryClientProvider (file:///D:/repos/teslasync/web/node_modules/@tanstack/react-query/build/modern/QueryClientProvider.js:20:3) + at Wrapper (D:/repos/teslasync/web/src/components/ui/__tests__/CommandPalette.test.tsx:56:28) +Warning: Encountered two children with the same key, `Vehicle Commands`. Keys should be unique so that components maintain their identity across updates. Non-unique keys may cause children to be duplicated and/or omitted ΓÇö the behavior is unsupported and could change in a future version. + at div + at div + at div + at MotionDOMComponent (file:///D:/repos/teslasync/web/node_modules/framer-motion/dist/es/motion/index.mjs:45:16) + at PopChildMeasure (file:///D:/repos/teslasync/web/node_modules/framer-motion/dist/es/components/AnimatePresence/PopChild.mjs:13:1) + at PopChild (file:///D:/repos/teslasync/web/node_modules/framer-motion/dist/es/components/AnimatePresence/PopChild.mjs:43:21) + at PresenceChild (file:///D:/repos/teslasync/web/node_modules/framer-motion/dist/es/components/AnimatePresence/PresenceChild.mjs:9:26) + at AnimatePresence (file:///D:/repos/teslasync/web/node_modules/framer-motion/dist/es/components/AnimatePresence/index.mjs:44:28) + at CommandPalette (D:/repos/teslasync/web/src/components/ui/CommandPalette.tsx:465:27) + at SelectedVehicleProvider (D:/repos/teslasync/web/src/store/selectedVehicle.tsx:48:36) + at ToastProvider (D:/repos/teslasync/web/src/components/feedback/Toast.tsx:91:26) + at ThemeProvider (D:/repos/teslasync/web/src/components/ui/ThemeProvider.tsx:210:26) + at Router (D:\repos\teslasync\web\node_modules\react-router\dist\umd\react-router.development.js:1207:17) + at MemoryRouter (D:\repos\teslasync\web\node_modules\react-router\dist\umd\react-router.development.js:1101:7) + at QueryClientProvider (file:///D:/repos/teslasync/web/node_modules/@tanstack/react-query/build/modern/QueryClientProvider.js:20:3) + at Wrapper (D:/repos/teslasync/web/src/components/ui/__tests__/CommandPalette.test.tsx:56:28) +Warning: Encountered two children with the same key, `Pages`. Keys should be unique so that components maintain their identity across updates. Non-unique keys may cause children to be duplicated and/or omitted ΓÇö the behavior is unsupported and could change in a future version. + at div + at div + at div + at MotionDOMComponent (file:///D:/repos/teslasync/web/node_modules/framer-motion/dist/es/motion/index.mjs:45:16) + at PopChildMeasure (file:///D:/repos/teslasync/web/node_modules/framer-motion/dist/es/components/AnimatePresence/PopChild.mjs:13:1) + at PopChild (file:///D:/repos/teslasync/web/node_modules/framer-motion/dist/es/components/AnimatePresence/PopChild.mjs:43:21) + at PresenceChild (file:///D:/repos/teslasync/web/node_modules/framer-motion/dist/es/components/AnimatePresence/PresenceChild.mjs:9:26) + at AnimatePresence (file:///D:/repos/teslasync/web/node_modules/framer-motion/dist/es/components/AnimatePresence/index.mjs:44:28) + at CommandPalette (D:/repos/teslasync/web/src/components/ui/CommandPalette.tsx:465:27) + at SelectedVehicleProvider (D:/repos/teslasync/web/src/store/selectedVehicle.tsx:48:36) + at ToastProvider (D:/repos/teslasync/web/src/components/feedback/Toast.tsx:91:26) + at ThemeProvider (D:/repos/teslasync/web/src/components/ui/ThemeProvider.tsx:210:26) + at Router (D:\repos\teslasync\web\node_modules\react-router\dist\umd\react-router.development.js:1207:17) + at MemoryRouter (D:\repos\teslasync\web\node_modules\react-router\dist\umd\react-router.development.js:1101:7) + at QueryClientProvider (file:///D:/repos/teslasync/web/node_modules/@tanstack/react-query/build/modern/QueryClientProvider.js:20:3) + at Wrapper (D:/repos/teslasync/web/src/components/ui/__tests__/CommandPalette.test.tsx:56:28) +Warning: Encountered two children with the same key, `Vehicle Commands`. Keys should be unique so that components maintain their identity across updates. Non-unique keys may cause children to be duplicated and/or omitted ΓÇö the behavior is unsupported and could change in a future version. + at div + at div + at div + at MotionDOMComponent (file:///D:/repos/teslasync/web/node_modules/framer-motion/dist/es/motion/index.mjs:45:16) + at PopChildMeasure (file:///D:/repos/teslasync/web/node_modules/framer-motion/dist/es/components/AnimatePresence/PopChild.mjs:13:1) + at PopChild (file:///D:/repos/teslasync/web/node_modules/framer-motion/dist/es/components/AnimatePresence/PopChild.mjs:43:21) + at PresenceChild (file:///D:/repos/teslasync/web/node_modules/framer-motion/dist/es/components/AnimatePresence/PresenceChild.mjs:9:26) + at AnimatePresence (file:///D:/repos/teslasync/web/node_modules/framer-motion/dist/es/components/AnimatePresence/index.mjs:44:28) + at CommandPalette (D:/repos/teslasync/web/src/components/ui/CommandPalette.tsx:465:27) + at SelectedVehicleProvider (D:/repos/teslasync/web/src/store/selectedVehicle.tsx:48:36) + at ToastProvider (D:/repos/teslasync/web/src/components/feedback/Toast.tsx:91:26) + at ThemeProvider (D:/repos/teslasync/web/src/components/ui/ThemeProvider.tsx:210:26) + at Router (D:\repos\teslasync\web\node_modules\react-router\dist\umd\react-router.development.js:1207:17) + at MemoryRouter (D:\repos\teslasync\web\node_modules\react-router\dist\umd\react-router.development.js:1101:7) + at QueryClientProvider (file:///D:/repos/teslasync/web/node_modules/@tanstack/react-query/build/modern/QueryClientProvider.js:20:3) + at Wrapper (D:/repos/teslasync/web/src/components/ui/__tests__/CommandPalette.test.tsx:56:28) +Warning: Encountered two children with the same key, `Preferences`. Keys should be unique so that components maintain their identity across updates. Non-unique keys may cause children to be duplicated and/or omitted ΓÇö the behavior is unsupported and could change in a future version. + at div + at div + at div + at MotionDOMComponent (file:///D:/repos/teslasync/web/node_modules/framer-motion/dist/es/motion/index.mjs:45:16) + at PopChildMeasure (file:///D:/repos/teslasync/web/node_modules/framer-motion/dist/es/components/AnimatePresence/PopChild.mjs:13:1) + at PopChild (file:///D:/repos/teslasync/web/node_modules/framer-motion/dist/es/components/AnimatePresence/PopChild.mjs:43:21) + at PresenceChild (file:///D:/repos/teslasync/web/node_modules/framer-motion/dist/es/components/AnimatePresence/PresenceChild.mjs:9:26) + at AnimatePresence (file:///D:/repos/teslasync/web/node_modules/framer-motion/dist/es/components/AnimatePresence/index.mjs:44:28) + at CommandPalette (D:/repos/teslasync/web/src/components/ui/CommandPalette.tsx:465:27) + at SelectedVehicleProvider (D:/repos/teslasync/web/src/store/selectedVehicle.tsx:48:36) + at ToastProvider (D:/repos/teslasync/web/src/components/feedback/Toast.tsx:91:26) + at ThemeProvider (D:/repos/teslasync/web/src/components/ui/ThemeProvider.tsx:210:26) + at Router (D:\repos\teslasync\web\node_modules\react-router\dist\umd\react-router.development.js:1207:17) + at MemoryRouter (D:\repos\teslasync\web\node_modules\react-router\dist\umd\react-router.development.js:1101:7) + at QueryClientProvider (file:///D:/repos/teslasync/web/node_modules/@tanstack/react-query/build/modern/QueryClientProvider.js:20:3) + at Wrapper (D:/repos/teslasync/web/src/components/ui/__tests__/CommandPalette.test.tsx:56:28) +Warning: Encountered two children with the same key, `Actions`. Keys should be unique so that components maintain their identity across updates. Non-unique keys may cause children to be duplicated and/or omitted ΓÇö the behavior is unsupported and could change in a future version. + at div + at div + at div + at MotionDOMComponent (file:///D:/repos/teslasync/web/node_modules/framer-motion/dist/es/motion/index.mjs:45:16) + at PopChildMeasure (file:///D:/repos/teslasync/web/node_modules/framer-motion/dist/es/components/AnimatePresence/PopChild.mjs:13:1) + at PopChild (file:///D:/repos/teslasync/web/node_modules/framer-motion/dist/es/components/AnimatePresence/PopChild.mjs:43:21) + at PresenceChild (file:///D:/repos/teslasync/web/node_modules/framer-motion/dist/es/components/AnimatePresence/PresenceChild.mjs:9:26) + at AnimatePresence (file:///D:/repos/teslasync/web/node_modules/framer-motion/dist/es/components/AnimatePresence/index.mjs:44:28) + at CommandPalette (D:/repos/teslasync/web/src/components/ui/CommandPalette.tsx:465:27) + at SelectedVehicleProvider (D:/repos/teslasync/web/src/store/selectedVehicle.tsx:48:36) + at ToastProvider (D:/repos/teslasync/web/src/components/feedback/Toast.tsx:91:26) + at ThemeProvider (D:/repos/teslasync/web/src/components/ui/ThemeProvider.tsx:210:26) + at Router (D:\repos\teslasync\web\node_modules\react-router\dist\umd\react-router.development.js:1207:17) + at MemoryRouter (D:\repos\teslasync\web\node_modules\react-router\dist\umd\react-router.development.js:1101:7) + at QueryClientProvider (file:///D:/repos/teslasync/web/node_modules/@tanstack/react-query/build/modern/QueryClientProvider.js:20:3) + at Wrapper (D:/repos/teslasync/web/src/components/ui/__tests__/CommandPalette.test.tsx:56:28) +Warning: Encountered two children with the same key, `Pages`. Keys should be unique so that components maintain their identity across updates. Non-unique keys may cause children to be duplicated and/or omitted ΓÇö the behavior is unsupported and could change in a future version. + at div + at div + at div + at MotionDOMComponent (file:///D:/repos/teslasync/web/node_modules/framer-motion/dist/es/motion/index.mjs:45:16) + at PopChildMeasure (file:///D:/repos/teslasync/web/node_modules/framer-motion/dist/es/components/AnimatePresence/PopChild.mjs:13:1) + at PopChild (file:///D:/repos/teslasync/web/node_modules/framer-motion/dist/es/components/AnimatePresence/PopChild.mjs:43:21) + at PresenceChild (file:///D:/repos/teslasync/web/node_modules/framer-motion/dist/es/components/AnimatePresence/PresenceChild.mjs:9:26) + at AnimatePresence (file:///D:/repos/teslasync/web/node_modules/framer-motion/dist/es/components/AnimatePresence/index.mjs:44:28) + at CommandPalette (D:/repos/teslasync/web/src/components/ui/CommandPalette.tsx:465:27) + at SelectedVehicleProvider (D:/repos/teslasync/web/src/store/selectedVehicle.tsx:48:36) + at ToastProvider (D:/repos/teslasync/web/src/components/feedback/Toast.tsx:91:26) + at ThemeProvider (D:/repos/teslasync/web/src/components/ui/ThemeProvider.tsx:210:26) + at Router (D:\repos\teslasync\web\node_modules\react-router\dist\umd\react-router.development.js:1207:17) + at MemoryRouter (D:\repos\teslasync\web\node_modules\react-router\dist\umd\react-router.development.js:1101:7) + at QueryClientProvider (file:///D:/repos/teslasync/web/node_modules/@tanstack/react-query/build/modern/QueryClientProvider.js:20:3) + at Wrapper (D:/repos/teslasync/web/src/components/ui/__tests__/CommandPalette.test.tsx:56:28) +Warning: Encountered two children with the same key, `Vehicle Commands`. Keys should be unique so that components maintain their identity across updates. Non-unique keys may cause children to be duplicated and/or omitted ΓÇö the behavior is unsupported and could change in a future version. + at div + at div + at div + at MotionDOMComponent (file:///D:/repos/teslasync/web/node_modules/framer-motion/dist/es/motion/index.mjs:45:16) + at PopChildMeasure (file:///D:/repos/teslasync/web/node_modules/framer-motion/dist/es/components/AnimatePresence/PopChild.mjs:13:1) + at PopChild (file:///D:/repos/teslasync/web/node_modules/framer-motion/dist/es/components/AnimatePresence/PopChild.mjs:43:21) + at PresenceChild (file:///D:/repos/teslasync/web/node_modules/framer-motion/dist/es/components/AnimatePresence/PresenceChild.mjs:9:26) + at AnimatePresence (file:///D:/repos/teslasync/web/node_modules/framer-motion/dist/es/components/AnimatePresence/index.mjs:44:28) + at CommandPalette (D:/repos/teslasync/web/src/components/ui/CommandPalette.tsx:465:27) + at SelectedVehicleProvider (D:/repos/teslasync/web/src/store/selectedVehicle.tsx:48:36) + at ToastProvider (D:/repos/teslasync/web/src/components/feedback/Toast.tsx:91:26) + at ThemeProvider (D:/repos/teslasync/web/src/components/ui/ThemeProvider.tsx:210:26) + at Router (D:\repos\teslasync\web\node_modules\react-router\dist\umd\react-router.development.js:1207:17) + at MemoryRouter (D:\repos\teslasync\web\node_modules\react-router\dist\umd\react-router.development.js:1101:7) + at QueryClientProvider (file:///D:/repos/teslasync/web/node_modules/@tanstack/react-query/build/modern/QueryClientProvider.js:20:3) + at Wrapper (D:/repos/teslasync/web/src/components/ui/__tests__/CommandPalette.test.tsx:56:28) +Warning: Encountered two children with the same key, `Pages`. Keys should be unique so that components maintain their identity across updates. Non-unique keys may cause children to be duplicated and/or omitted ΓÇö the behavior is unsupported and could change in a future version. + at div + at div + at div + at MotionDOMComponent (file:///D:/repos/teslasync/web/node_modules/framer-motion/dist/es/motion/index.mjs:45:16) + at PopChildMeasure (file:///D:/repos/teslasync/web/node_modules/framer-motion/dist/es/components/AnimatePresence/PopChild.mjs:13:1) + at PopChild (file:///D:/repos/teslasync/web/node_modules/framer-motion/dist/es/components/AnimatePresence/PopChild.mjs:43:21) + at PresenceChild (file:///D:/repos/teslasync/web/node_modules/framer-motion/dist/es/components/AnimatePresence/PresenceChild.mjs:9:26) + at AnimatePresence (file:///D:/repos/teslasync/web/node_modules/framer-motion/dist/es/components/AnimatePresence/index.mjs:44:28) + at CommandPalette (D:/repos/teslasync/web/src/components/ui/CommandPalette.tsx:465:27) + at SelectedVehicleProvider (D:/repos/teslasync/web/src/store/selectedVehicle.tsx:48:36) + at ToastProvider (D:/repos/teslasync/web/src/components/feedback/Toast.tsx:91:26) + at ThemeProvider (D:/repos/teslasync/web/src/components/ui/ThemeProvider.tsx:210:26) + at Router (D:\repos\teslasync\web\node_modules\react-router\dist\umd\react-router.development.js:1207:17) + at MemoryRouter (D:\repos\teslasync\web\node_modules\react-router\dist\umd\react-router.development.js:1101:7) + at QueryClientProvider (file:///D:/repos/teslasync/web/node_modules/@tanstack/react-query/build/modern/QueryClientProvider.js:20:3) + at Wrapper (D:/repos/teslasync/web/src/components/ui/__tests__/CommandPalette.test.tsx:56:28) +Warning: Encountered two children with the same key, `Vehicle Commands`. Keys should be unique so that components maintain their identity across updates. Non-unique keys may cause children to be duplicated and/or omitted ΓÇö the behavior is unsupported and could change in a future version. + at div + at div + at div + at MotionDOMComponent (file:///D:/repos/teslasync/web/node_modules/framer-motion/dist/es/motion/index.mjs:45:16) + at PopChildMeasure (file:///D:/repos/teslasync/web/node_modules/framer-motion/dist/es/components/AnimatePresence/PopChild.mjs:13:1) + at PopChild (file:///D:/repos/teslasync/web/node_modules/framer-motion/dist/es/components/AnimatePresence/PopChild.mjs:43:21) + at PresenceChild (file:///D:/repos/teslasync/web/node_modules/framer-motion/dist/es/components/AnimatePresence/PresenceChild.mjs:9:26) + at AnimatePresence (file:///D:/repos/teslasync/web/node_modules/framer-motion/dist/es/components/AnimatePresence/index.mjs:44:28) + at CommandPalette (D:/repos/teslasync/web/src/components/ui/CommandPalette.tsx:465:27) + at SelectedVehicleProvider (D:/repos/teslasync/web/src/store/selectedVehicle.tsx:48:36) + at ToastProvider (D:/repos/teslasync/web/src/components/feedback/Toast.tsx:91:26) + at ThemeProvider (D:/repos/teslasync/web/src/components/ui/ThemeProvider.tsx:210:26) + at Router (D:\repos\teslasync\web\node_modules\react-router\dist\umd\react-router.development.js:1207:17) + at MemoryRouter (D:\repos\teslasync\web\node_modules\react-router\dist\umd\react-router.development.js:1101:7) + at QueryClientProvider (file:///D:/repos/teslasync/web/node_modules/@tanstack/react-query/build/modern/QueryClientProvider.js:20:3) + at Wrapper (D:/repos/teslasync/web/src/components/ui/__tests__/CommandPalette.test.tsx:56:28) + +stderr | src/features/notifications/pages/NotificationsPage.test.tsx > NotificationsPage > renders the three tabs (Inbox, Archived, Channels) +ΓÜá∩╕Å React Router Future Flag Warning: React Router will begin wrapping state updates in `React.startTransition` in v7. You can use the `v7_startTransition` future flag to opt-in early. For more information, see https://reactrouter.com/v6/upgrading/future#v7_starttransition. +ΓÜá∩╕Å React Router Future Flag Warning: Relative route resolution within Splat routes is changing in v7. You can use the `v7_relativeSplatPath` future flag to opt-in early. For more information, see https://reactrouter.com/v6/upgrading/future#v7_relativesplatpath. + +stderr | src/features/telemetry/pages/__tests__/SignalExplorerPage.help.test.tsx > SignalExplorerPage ΓÇö HelpTooltip adoption > renders a HelpTooltip explaining signal layers (L1/L2/log) +ΓÜá∩╕Å React Router Future Flag Warning: React Router will begin wrapping state updates in `React.startTransition` in v7. You can use the `v7_startTransition` future flag to opt-in early. For more information, see https://reactrouter.com/v6/upgrading/future#v7_starttransition. +ΓÜá∩╕Å React Router Future Flag Warning: Relative route resolution within Splat routes is changing in v7. You can use the `v7_relativeSplatPath` future flag to opt-in early. For more information, see https://reactrouter.com/v6/upgrading/future#v7_relativesplatpath. + + Γ£ô src/features/dashboard/components/__tests__/WidgetCatalogueDialog.test.tsx (6 tests) 2433ms + Γ£ô renders a dialog with categorized widget sections when open  809ms + Γ£ô lists every registry widget at least once (no silent drop)  475ms + Γ£ô disables the Add button for widgets already on the dashboard  393ms + Γ£ô calls onAdd with the widget id and closes when an addable widget is picked  372ms + Γ£ô src/features/telemetry/pages/__tests__/SignalExplorerPage.help.test.tsx (3 tests) 1191ms + Γ£ô renders a HelpTooltip explaining signal layers (L1/L2/log)  868ms + Γ£ô src/features/system/pages/__tests__/StateMachineDebuggerPage.windowing.test.tsx (3 tests) 1863ms + Γ£ô reads "0 in window ┬╖ 23 in 24 h" when 23 transitions exist outside the 10-min window  977ms + Γ£ô after clicking "Widen window", the counter renders ticks and reflects the new window size  599ms + Γ£ô src/components/ui/__tests__/PrintButton.test.tsx (7 tests) 951ms + Γ£ô renders a Print button with a visible label  558ms + Γ£ô src/components/ui/__tests__/CommandPalette.test.tsx (15 tests) 3770ms + Γ£ô opens on Ctrl+K when focus is on the body  705ms + Γ£ô matches "btr" ΓåÆ "Battery Health" via fuzzy subsequence  321ms + Γ£ô surfaces vehicle-switch entries when fleet has 2+ vehicles  326ms + Γ£ô surfaces refresh action on a "refresh" search  344ms + Γ£ô ranks the "Drives" page above keyword-only matches (e.g. "debugger") for query "d"  389ms + Γ£ô renders a "Most Used" section when frecency data exists and the query is empty  430ms + Γ£ô does NOT render a "Most Used" section when no commands have been recorded yet  331ms + Γ£ô src/components/ui/__tests__/PinButton.test.tsx (7 tests) 900ms + Γ£ô renders an unpinned trigger when the item is not in the pinned list  568ms +stderr | src/features/notifications/pages/NotificationsPage.test.tsx > NotificationsPage > clicking "Mark all read" calls useBulkMarkRead with all=true +Warning: Received `true` for a non-boolean attribute `layout`. + +If you want to write it to the DOM, pass a string instead: layout="true" or layout={value.toString()}. + at div + at D:/repos/teslasync/web/src/features/notifications/pages/NotificationsPage.test.tsx:4:11 + at AnimatePresence (D:/repos/teslasync/web/src/features/notifications/pages/NotificationsPage.test.tsx:14:22) + at div + at ToastProvider (D:/repos/teslasync/web/src/components/feedback/Toast.tsx:91:26) + at SelectedVehicleProvider (D:/repos/teslasync/web/src/store/selectedVehicle.tsx:48:36) + at Router (D:\repos\teslasync\web\node_modules\react-router\dist\umd\react-router.development.js:1207:17) + at MemoryRouter (D:\repos\teslasync\web\node_modules\react-router\dist\umd\react-router.development.js:1101:7) + at QueryClientProvider (file:///D:/repos/teslasync/web/node_modules/@tanstack/react-query/build/modern/QueryClientProvider.js:20:3) + + Γ£ô src/components/ui/__tests__/focusTrap.test.tsx (9 tests) 727ms + Γ£ô moves initial focus to the first focusable element on open  384ms + Γ£ô src/components/charts/ChartBrush.test.tsx (2 tests) 803ms + Γ£ô renders inside a recharts chart container without crashing  683ms + Γ£ô src/components/maps/__tests__/RoutePlayback.test.tsx (4 tests) 700ms + Γ£ô renders an accessible application landmark with the supplied aria-label  308ms + Γ£ô src/features/notifications/pages/NotificationsPage.test.tsx (10 tests) 3226ms + Γ£ô renders the three tabs (Inbox, Archived, Channels)  982ms + Γ£ô switches to the Archived tab and shows the empty state  459ms + Γ£ô shows a success toast with an Undo action after "Mark all read"; clicking Undo fires markUnread for the snapshotted ids  464ms + Γ£ô routes the bulk-toolbar "Mark read" through useBulkMarkRead with the selected ids and surfaces an Undo toast  400ms + Γ£ô src/hooks/__tests__/useSettings.broadcast.test.tsx (9 tests) 854ms + Γ£ô src/features/admin/components/__tests__/RedisDiagnosticEmptyState.test.tsx (9 tests) 849ms + Γ£ô renders the danger banner when mode=local  419ms +stderr | src/components/feedback/__tests__/AchievementUnlockedToast.test.tsx > AchievementUnlockedToast > renders the achievement name and description +Warning: Received `true` for a non-boolean attribute `layout`. + +If you want to write it to the DOM, pass a string instead: layout="true" or layout={value.toString()}. + at div + at D:/repos/teslasync/web/src/components/feedback/__tests__/AchievementUnlockedToast.test.tsx:16:99 + at AchievementUnlockedToast (D:/repos/teslasync/web/src/components/feedback/AchievementUnlockedToast.tsx:34:37) + at Router (D:\repos\teslasync\web\node_modules\react-router\dist\umd\react-router.development.js:1207:17) + at MemoryRouter (D:\repos\teslasync\web\node_modules\react-router\dist\umd\react-router.development.js:1101:7) +ΓÜá∩╕Å React Router Future Flag Warning: React Router will begin wrapping state updates in `React.startTransition` in v7. You can use the `v7_startTransition` future flag to opt-in early. For more information, see https://reactrouter.com/v6/upgrading/future#v7_starttransition. +ΓÜá∩╕Å React Router Future Flag Warning: Relative route resolution within Splat routes is changing in v7. You can use the `v7_relativeSplatPath` future flag to opt-in early. For more information, see https://reactrouter.com/v6/upgrading/future#v7_relativesplatpath. + +Error: Page explosion + at ThrowingComponent (D:/repos/teslasync/web/src/components/feedback/PageErrorBoundary.test.tsx:5:26) + at renderWithHooks (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:15486:18) + at mountIndeterminateComponent (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:20103:13) + at beginWork (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:21626:16) + at HTMLUnknownElement.callCallback (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:4164:14) + at HTMLUnknownElement.callTheUserObjectsOperation (D:\repos\teslasync\web\node_modules\jsdom\lib\generated\idl\EventListener.js:26:30) + at innerInvokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:360:16) + at invokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:296:3) + at HTMLUnknownElementImpl._dispatch (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:243:9) + at HTMLUnknownElementImpl.dispatchEvent (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:114:17) +Error: Page explosion + at ThrowingComponent (D:/repos/teslasync/web/src/components/feedback/PageErrorBoundary.test.tsx:5:26) + at renderWithHooks (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:15486:18) + at mountIndeterminateComponent (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:20103:13) + at beginWork (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:21626:16) + at HTMLUnknownElement.callCallback (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:4164:14) + at HTMLUnknownElement.callTheUserObjectsOperation (D:\repos\teslasync\web\node_modules\jsdom\lib\generated\idl\EventListener.js:26:30) + at innerInvokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:360:16) + at invokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:296:3) + at HTMLUnknownElementImpl._dispatch (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:243:9) + at HTMLUnknownElementImpl.dispatchEvent (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:114:17) + Γ£ô src/components/feedback/__tests__/AchievementUnlockedToast.test.tsx (5 tests) 695ms + Γ£ô exposes a "View" affordance and a dismiss button  362ms +Error: Page explosion + at ThrowingComponent (D:/repos/teslasync/web/src/components/feedback/PageErrorBoundary.test.tsx:5:26) + at renderWithHooks (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:15486:18) + at mountIndeterminateComponent (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:20103:13) + at beginWork (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:21626:16) + at HTMLUnknownElement.callCallback (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:4164:14) + at HTMLUnknownElement.callTheUserObjectsOperation (D:\repos\teslasync\web\node_modules\jsdom\lib\generated\idl\EventListener.js:26:30) + at innerInvokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:360:16) + at invokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:296:3) + at HTMLUnknownElementImpl._dispatch (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:243:9) + at HTMLUnknownElementImpl.dispatchEvent (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:114:17) +Error: Page explosion + at ThrowingComponent (D:/repos/teslasync/web/src/components/feedback/PageErrorBoundary.test.tsx:5:26) + at renderWithHooks (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:15486:18) + at mountIndeterminateComponent (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:20103:13) + at beginWork (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:21626:16) + at HTMLUnknownElement.callCallback (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:4164:14) + at HTMLUnknownElement.callTheUserObjectsOperation (D:\repos\teslasync\web\node_modules\jsdom\lib\generated\idl\EventListener.js:26:30) + at innerInvokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:360:16) + at invokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:296:3) + at HTMLUnknownElementImpl._dispatch (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:243:9) + at HTMLUnknownElementImpl.dispatchEvent (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:114:17) + Γ£ô src/components/feedback/PageErrorBoundary.test.tsx (3 tests) 629ms + Γ£ô shows the full-page fallback UI on error  529ms +stdout | src/components/feedback/__tests__/TeslaReauthBanner.test.tsx +≡ƒîÉ i18next is made possible by our own product, Locize ΓÇö consider powering your project with managed localization (AI, CDN, integrations): https://locize.com ≡ƒÆÖ + + Γ£ô src/components/ui/__tests__/HelpTooltip.test.tsx (6 tests) 669ms + Γ£ô renders a focusable button trigger with an accessible label  377ms +stdout | src/components/feedback/__tests__/EmptyState.test.tsx +≡ƒîÉ i18next is made possible by our own product, Locize ΓÇö consider powering your project with managed localization (AI, CDN, integrations): https://locize.com ≡ƒÆÖ + +stdout | src/components/feedback/SectionErrorBoundary.test.tsx +≡ƒîÉ i18next is made possible by our own product, Locize ΓÇö consider powering your project with managed localization (AI, CDN, integrations): https://locize.com ≡ƒÆÖ + +Error: Section explosion + at ThrowingComponent (D:/repos/teslasync/web/src/components/feedback/SectionErrorBoundary.test.tsx:7:26) + at renderWithHooks (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:15486:18) + at mountIndeterminateComponent (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:20103:13) + at beginWork (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:21626:16) + at HTMLUnknownElement.callCallback (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:4164:14) + at HTMLUnknownElement.callTheUserObjectsOperation (D:\repos\teslasync\web\node_modules\jsdom\lib\generated\idl\EventListener.js:26:30) + at innerInvokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:360:16) + at invokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:296:3) + at HTMLUnknownElementImpl._dispatch (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:243:9) + at HTMLUnknownElementImpl.dispatchEvent (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:114:17) +Error: Section explosion + at ThrowingComponent (D:/repos/teslasync/web/src/components/feedback/SectionErrorBoundary.test.tsx:7:26) + at renderWithHooks (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:15486:18) + at mountIndeterminateComponent (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:20103:13) + at beginWork (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:21626:16) + at HTMLUnknownElement.callCallback (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:4164:14) + at HTMLUnknownElement.callTheUserObjectsOperation (D:\repos\teslasync\web\node_modules\jsdom\lib\generated\idl\EventListener.js:26:30) + at innerInvokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:360:16) + at invokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:296:3) + at HTMLUnknownElementImpl._dispatch (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:243:9) + at HTMLUnknownElementImpl.dispatchEvent (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:114:17) +stderr | src/components/feedback/__tests__/EmptyState.test.tsx > EmptyState > renders icon, title, and message +ΓÜá∩╕Å React Router Future Flag Warning: React Router will begin wrapping state updates in `React.startTransition` in v7. You can use the `v7_startTransition` future flag to opt-in early. For more information, see https://reactrouter.com/v6/upgrading/future#v7_starttransition. +ΓÜá∩╕Å React Router Future Flag Warning: Relative route resolution within Splat routes is changing in v7. You can use the `v7_relativeSplatPath` future flag to opt-in early. For more information, see https://reactrouter.com/v6/upgrading/future#v7_relativesplatpath. + + Γ£ô src/components/feedback/__tests__/EmptyState.test.tsx (6 tests) 618ms + Γ£ô renders icon, title, and message  375ms +Error: Section explosion + at ThrowingComponent (D:/repos/teslasync/web/src/components/feedback/SectionErrorBoundary.test.tsx:7:26) + at renderWithHooks (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:15486:18) + at mountIndeterminateComponent (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:20103:13) + at beginWork (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:21626:16) + at HTMLUnknownElement.callCallback (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:4164:14) + at HTMLUnknownElement.callTheUserObjectsOperation (D:\repos\teslasync\web\node_modules\jsdom\lib\generated\idl\EventListener.js:26:30) + at innerInvokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:360:16) + at invokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:296:3) + at HTMLUnknownElementImpl._dispatch (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:243:9) + at HTMLUnknownElementImpl.dispatchEvent (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:114:17) +Error: Section explosion + at ThrowingComponent (D:/repos/teslasync/web/src/components/feedback/SectionErrorBoundary.test.tsx:7:26) + at renderWithHooks (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:15486:18) + at mountIndeterminateComponent (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:20103:13) + at beginWork (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:21626:16) + at HTMLUnknownElement.callCallback (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:4164:14) + at HTMLUnknownElement.callTheUserObjectsOperation (D:\repos\teslasync\web\node_modules\jsdom\lib\generated\idl\EventListener.js:26:30) + at innerInvokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:360:16) + at invokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:296:3) + at HTMLUnknownElementImpl._dispatch (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:243:9) + at HTMLUnknownElementImpl.dispatchEvent (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:114:17) +stdout | src/components/data-display/__tests__/BulkActionToolbar.test.tsx +≡ƒîÉ i18next is made possible by our own product, Locize ΓÇö consider powering your project with managed localization (AI, CDN, integrations): https://locize.com ≡ƒÆÖ + +Error: Section explosion + at ThrowingComponent (D:/repos/teslasync/web/src/components/feedback/SectionErrorBoundary.test.tsx:7:26) + at renderWithHooks (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:15486:18) + at mountIndeterminateComponent (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:20103:13) + at beginWork (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:21626:16) + at HTMLUnknownElement.callCallback (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:4164:14) + at HTMLUnknownElement.callTheUserObjectsOperation (D:\repos\teslasync\web\node_modules\jsdom\lib\generated\idl\EventListener.js:26:30) + at innerInvokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:360:16) + at invokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:296:3) + at HTMLUnknownElementImpl._dispatch (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:243:9) + at HTMLUnknownElementImpl.dispatchEvent (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:114:17) +Error: Section explosion + at ThrowingComponent (D:/repos/teslasync/web/src/components/feedback/SectionErrorBoundary.test.tsx:7:26) + at renderWithHooks (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:15486:18) + at mountIndeterminateComponent (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:20103:13) + at beginWork (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:21626:16) + at HTMLUnknownElement.callCallback (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:4164:14) + at HTMLUnknownElement.callTheUserObjectsOperation (D:\repos\teslasync\web\node_modules\jsdom\lib\generated\idl\EventListener.js:26:30) + at innerInvokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:360:16) + at invokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:296:3) + at HTMLUnknownElementImpl._dispatch (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:243:9) + at HTMLUnknownElementImpl.dispatchEvent (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:114:17) +Error: Section explosion + at ThrowingComponent (D:/repos/teslasync/web/src/components/feedback/SectionErrorBoundary.test.tsx:7:26) + at renderWithHooks (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:15486:18) + at mountIndeterminateComponent (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:20103:13) + at beginWork (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:21626:16) + at HTMLUnknownElement.callCallback (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:4164:14) + at HTMLUnknownElement.callTheUserObjectsOperation (D:\repos\teslasync\web\node_modules\jsdom\lib\generated\idl\EventListener.js:26:30) + at innerInvokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:360:16) + at invokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:296:3) + at HTMLUnknownElementImpl._dispatch (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:243:9) + at HTMLUnknownElementImpl.dispatchEvent (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:114:17) +Error: Section explosion + at ThrowingComponent (D:/repos/teslasync/web/src/components/feedback/SectionErrorBoundary.test.tsx:7:26) + at renderWithHooks (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:15486:18) + at mountIndeterminateComponent (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:20103:13) + at beginWork (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:21626:16) + at HTMLUnknownElement.callCallback (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:4164:14) + at HTMLUnknownElement.callTheUserObjectsOperation (D:\repos\teslasync\web\node_modules\jsdom\lib\generated\idl\EventListener.js:26:30) + at innerInvokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:360:16) + at invokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:296:3) + at HTMLUnknownElementImpl._dispatch (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:243:9) + at HTMLUnknownElementImpl.dispatchEvent (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:114:17) + Γ£ô src/components/feedback/SectionErrorBoundary.test.tsx (5 tests) 609ms + Γ£ô falls back to inline UI by default and shows Retry  444ms +stdout | src/components/forms/SearchInput.test.tsx +≡ƒîÉ i18next is made possible by our own product, Locize ΓÇö consider powering your project with managed localization (AI, CDN, integrations): https://locize.com ≡ƒÆÖ + +stderr | src/components/layout/__tests__/PageContainer.test.tsx > PageContainer > renders the page title +ΓÜá∩╕Å React Router Future Flag Warning: React Router will begin wrapping state updates in `React.startTransition` in v7. You can use the `v7_startTransition` future flag to opt-in early. For more information, see https://reactrouter.com/v6/upgrading/future#v7_starttransition. +ΓÜá∩╕Å React Router Future Flag Warning: Relative route resolution within Splat routes is changing in v7. You can use the `v7_relativeSplatPath` future flag to opt-in early. For more information, see https://reactrouter.com/v6/upgrading/future#v7_relativesplatpath. + + Γ£ô src/components/layout/__tests__/PageContainer.test.tsx (9 tests) 707ms + Γ£ô renders the page title  466ms + Γ£ô src/components/feedback/__tests__/NewVersionBanner.test.tsx (6 tests) 649ms + Γ£ô renders the banner with both action buttons when a new version is available  441ms + Γ£ô src/components/data-display/SeverityBadge.test.tsx (14 tests) 515ms + Γ£ô exposes an accessible label when provided  307ms +stderr | src/components/feedback/__tests__/TeslaReauthBanner.test.tsx > TeslaReauthBanner > renders nothing by default +ΓÜá∩╕Å React Router Future Flag Warning: React Router will begin wrapping state updates in `React.startTransition` in v7. You can use the `v7_startTransition` future flag to opt-in early. For more information, see https://reactrouter.com/v6/upgrading/future#v7_starttransition. +ΓÜá∩╕Å React Router Future Flag Warning: Relative route resolution within Splat routes is changing in v7. You can use the `v7_relativeSplatPath` future flag to opt-in early. For more information, see https://reactrouter.com/v6/upgrading/future#v7_relativesplatpath. + + Γ£ô src/components/feedback/__tests__/PageSkeleton.test.tsx (9 tests) 529ms + Γ£ô respects the height prop  333ms + Γ£ô src/components/ui/__tests__/SignalConfigModal.viewport.test.tsx (3 tests) 630ms + Γ£ô renders inside the shared (role="dialog" present)  514ms + Γ£ô src/components/forms/FormField.test.tsx (8 tests) 513ms + Γ£ô renders the label and child input  365ms + Γ£ô src/components/feedback/__tests__/TeslaReauthBanner.test.tsx (7 tests) 685ms + Γ£ô appears when teslasync:tesla-auth-expired is dispatched  397ms +stderr | src/components/data-display/__tests__/BulkActionToolbar.test.tsx > BulkActionToolbar (alias) > invokes the action onClick with the current selection +Warning: An update to BulkActionsToolbar inside a test was not wrapped in act(...). + +When testing, code that causes React state updates should be wrapped into act(...): + +act(() => { + /* fire events that update state */ +}); +/* assert on the output */ + +This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act + at BulkActionsToolbar (D:/repos/teslasync/web/src/components/data-display/BulkActionsToolbar.tsx:13:31) + + Γ£ô src/components/data-display/__tests__/BulkActionToolbar.test.tsx (4 tests) 672ms + Γ£ô renders the count label and exposes a toolbar landmark  436ms + Γ£ô src/components/forms/SearchInput.test.tsx (7 tests) 697ms + Γ£ô shows the clear button only when there is text  371ms + Γ£ô src/components/ui/__tests__/Tooltip.contract.test.tsx (10 tests) 505ms + Γ£ô renders without inheriting any text colour override on the body  441ms + Γ£ô src/components/layout/sidebar/__tests__/NavSectionHeader.test.tsx (5 tests) 442ms + Γ£ô src/api/hooks/__tests__/useNotifications.test.tsx (14 tests) 515ms + Γ£ô src/api/hooks/__tests__/useBulkMarkRead.test.tsx (7 tests) 399ms +stdout | src/features/dashboard/components/__tests__/AddWidgetButton.test.tsx +≡ƒîÉ i18next is made possible by our own product, Locize ΓÇö consider powering your project with managed localization (AI, CDN, integrations): https://locize.com ≡ƒÆÖ + + Γ£ô src/api/hooks/__tests__/useOptimisticMutation.test.tsx (9 tests) 279ms + Γ£ô src/components/data-display/__tests__/LiveIndicator.test.tsx (9 tests) 472ms + Γ£ô src/api/hooks/__tests__/useMarkNotificationRead.test.tsx (6 tests) 382ms +stdout | src/features/trips/pages/__tests__/TripReplayPage.sync.test.tsx +≡ƒîÉ i18next is made possible by our own product, Locize ΓÇö consider powering your project with managed localization (AI, CDN, integrations): https://locize.com ≡ƒÆÖ + + Γ£ô src/components/data-display/__tests__/DataFreshness.test.tsx (24 tests) 482ms +stdout | src/components/feedback/__tests__/RateLimitBanner.test.tsx +≡ƒîÉ i18next is made possible by our own product, Locize ΓÇö consider powering your project with managed localization (AI, CDN, integrations): https://locize.com ≡ƒÆÖ + +stdout | src/__tests__/mobile.viewport.test.tsx > mobile.viewport :: shared primitives at 375px > Modal renders as a bottom sheet on mobile (no sm:rounded clipping) +≡ƒîÉ i18next is made possible by our own product, Locize ΓÇö consider powering your project with managed localization (AI, CDN, integrations): https://locize.com ≡ƒÆÖ + + Γ£ô src/components/charts/ChartTooltip.test.tsx (10 tests) 289ms +stderr | src/__tests__/mobile.viewport.test.tsx > mobile.viewport :: shared primitives at 375px > BottomTabBar carries safe-bottom + per-tab 44 px touch targets +ΓÜá∩╕Å React Router Future Flag Warning: React Router will begin wrapping state updates in `React.startTransition` in v7. You can use the `v7_startTransition` future flag to opt-in early. For more information, see https://reactrouter.com/v6/upgrading/future#v7_starttransition. +ΓÜá∩╕Å React Router Future Flag Warning: Relative route resolution within Splat routes is changing in v7. You can use the `v7_relativeSplatPath` future flag to opt-in early. For more information, see https://reactrouter.com/v6/upgrading/future#v7_relativesplatpath. + + Γ£ô src/__tests__/mobile.viewport.test.tsx (9 tests) 320ms + Γ£ô src/components/feedback/__tests__/RateLimitBanner.test.tsx (9 tests) 280ms + Γ£ô src/components/data-display/InsightsEngine.test.tsx (3 tests) 261ms + Γ£ô src/features/dashboard/components/__tests__/AddWidgetButton.test.tsx (4 tests) 494ms + Γ£ô renders a focusable button with an accessible label when not editing  380ms + Γ£ô src/components/data-display/format/__tests__/Format.test.tsx (60 tests) 301ms +stderr | src/features/trips/pages/__tests__/TripReplayPage.sync.test.tsx > Phase-45 / Prompt 26 ΓÇö TripReplayCharts cursor bridge > forwards persistent cursor-sync writes to onSeekToIndex (idx mapping) +Warning: A props object containing a "key" prop is being spread into JSX: + let props = {key: someKey, $$typeof: ..., type: ..., ref: ..., props: ..., _owner: ..., _store: ...}; + +React keys must be passed directly to JSX without using spread: + let props = {$$typeof: ..., type: ..., ref: ..., props: ..., _owner: ..., _store: ...}; + + at TimelineChart (D:/repos/teslasync/web/src/features/trips/components/TripReplayCharts.tsx:54:26) + at ChartTimeRangeProvider (D:/repos/teslasync/web/src/components/charts/ChartTimeRangeContext.tsx:13:35) + at TripReplayCharts (D:/repos/teslasync/web/src/features/trips/components/TripReplayCharts.tsx:26:29) + at ToastProvider (D:/repos/teslasync/web/src/components/feedback/Toast.tsx:91:26) + at QueryClientProvider (file:///D:/repos/teslasync/web/node_modules/@tanstack/react-query/build/modern/QueryClientProvider.js:20:3) + + Γ£ô src/components/vehicles/__tests__/VehicleTwin.test.tsx (2 tests) 356ms + Γ£ô src/features/trips/pages/__tests__/TripReplayPage.sync.test.tsx (19 tests) 375ms +stderr | src/hooks/__tests__/useSelectedVehicle.test.tsx > useSelectedVehicle > defaults to the first vehicle when nothing is selected +ΓÜá∩╕Å React Router Future Flag Warning: React Router will begin wrapping state updates in `React.startTransition` in v7. You can use the `v7_startTransition` future flag to opt-in early. For more information, see https://reactrouter.com/v6/upgrading/future#v7_starttransition. +ΓÜá∩╕Å React Router Future Flag Warning: Relative route resolution within Splat routes is changing in v7. You can use the `v7_relativeSplatPath` future flag to opt-in early. For more information, see https://reactrouter.com/v6/upgrading/future#v7_relativesplatpath. + +stdout | src/components/data-display/__tests__/TimeStamp.test.tsx +≡ƒîÉ i18next is made possible by our own product, Locize ΓÇö consider powering your project with managed localization (AI, CDN, integrations): https://locize.com ≡ƒÆÖ + + Γ£ô src/hooks/__tests__/useSelectedVehicle.test.tsx (7 tests) 171ms + Γ£ô src/components/data-display/Delta.test.tsx (22 tests) 349ms +Error: Test explosion + at ThrowingComponent (D:/repos/teslasync/web/src/components/feedback/ErrorBoundary.test.tsx:6:26) + at renderWithHooks (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:15486:18) + at mountIndeterminateComponent (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:20103:13) + at beginWork (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:21626:16) + at HTMLUnknownElement.callCallback (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:4164:14) + at HTMLUnknownElement.callTheUserObjectsOperation (D:\repos\teslasync\web\node_modules\jsdom\lib\generated\idl\EventListener.js:26:30) + at innerInvokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:360:16) + at invokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:296:3) + at HTMLUnknownElementImpl._dispatch (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:243:9) + at HTMLUnknownElementImpl.dispatchEvent (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:114:17) +Error: Test explosion + at ThrowingComponent (D:/repos/teslasync/web/src/components/feedback/ErrorBoundary.test.tsx:6:26) + at renderWithHooks (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:15486:18) + at mountIndeterminateComponent (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:20103:13) + at beginWork (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:21626:16) + at HTMLUnknownElement.callCallback (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:4164:14) + at HTMLUnknownElement.callTheUserObjectsOperation (D:\repos\teslasync\web\node_modules\jsdom\lib\generated\idl\EventListener.js:26:30) + at innerInvokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:360:16) + at invokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:296:3) + at HTMLUnknownElementImpl._dispatch (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:243:9) + at HTMLUnknownElementImpl.dispatchEvent (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:114:17) +Error: Test explosion + at ThrowingComponent (D:/repos/teslasync/web/src/components/feedback/ErrorBoundary.test.tsx:6:26) + at renderWithHooks (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:15486:18) + at mountIndeterminateComponent (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:20103:13) + at beginWork (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:21626:16) + at HTMLUnknownElement.callCallback (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:4164:14) + at HTMLUnknownElement.callTheUserObjectsOperation (D:\repos\teslasync\web\node_modules\jsdom\lib\generated\idl\EventListener.js:26:30) + at innerInvokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:360:16) + at invokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:296:3) + at HTMLUnknownElementImpl._dispatch (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:243:9) + at HTMLUnknownElementImpl.dispatchEvent (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:114:17) +Error: Test explosion + at ThrowingComponent (D:/repos/teslasync/web/src/components/feedback/ErrorBoundary.test.tsx:6:26) + at renderWithHooks (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:15486:18) + at mountIndeterminateComponent (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:20103:13) + at beginWork (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:21626:16) + at HTMLUnknownElement.callCallback (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:4164:14) + at HTMLUnknownElement.callTheUserObjectsOperation (D:\repos\teslasync\web\node_modules\jsdom\lib\generated\idl\EventListener.js:26:30) + at innerInvokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:360:16) + at invokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:296:3) + at HTMLUnknownElementImpl._dispatch (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:243:9) + at HTMLUnknownElementImpl.dispatchEvent (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:114:17) +Error: Test explosion + at ThrowingComponent (D:/repos/teslasync/web/src/components/feedback/ErrorBoundary.test.tsx:6:26) + at renderWithHooks (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:15486:18) + at mountIndeterminateComponent (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:20103:13) + at beginWork (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:21626:16) + at HTMLUnknownElement.callCallback (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:4164:14) + at HTMLUnknownElement.callTheUserObjectsOperation (D:\repos\teslasync\web\node_modules\jsdom\lib\generated\idl\EventListener.js:26:30) + at innerInvokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:360:16) + at invokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:296:3) + at HTMLUnknownElementImpl._dispatch (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:243:9) + at HTMLUnknownElementImpl.dispatchEvent (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:114:17) +Error: Test explosion + at ThrowingComponent (D:/repos/teslasync/web/src/components/feedback/ErrorBoundary.test.tsx:6:26) + at renderWithHooks (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:15486:18) + at mountIndeterminateComponent (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:20103:13) + at beginWork (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:21626:16) + at HTMLUnknownElement.callCallback (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:4164:14) + at HTMLUnknownElement.callTheUserObjectsOperation (D:\repos\teslasync\web\node_modules\jsdom\lib\generated\idl\EventListener.js:26:30) + at innerInvokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:360:16) + at invokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:296:3) + at HTMLUnknownElementImpl._dispatch (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:243:9) + at HTMLUnknownElementImpl.dispatchEvent (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:114:17) +Error: Test explosion + at ThrowingComponent (D:/repos/teslasync/web/src/components/feedback/ErrorBoundary.test.tsx:6:26) + at renderWithHooks (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:15486:18) + at mountIndeterminateComponent (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:20103:13) + at beginWork (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:21626:16) + at HTMLUnknownElement.callCallback (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:4164:14) + at HTMLUnknownElement.callTheUserObjectsOperation (D:\repos\teslasync\web\node_modules\jsdom\lib\generated\idl\EventListener.js:26:30) + at innerInvokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:360:16) + at invokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:296:3) + at HTMLUnknownElementImpl._dispatch (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:243:9) + at HTMLUnknownElementImpl.dispatchEvent (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:114:17) +Error: Test explosion + at ThrowingComponent (D:/repos/teslasync/web/src/components/feedback/ErrorBoundary.test.tsx:6:26) + at renderWithHooks (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:15486:18) + at mountIndeterminateComponent (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:20103:13) + at beginWork (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:21626:16) + at HTMLUnknownElement.callCallback (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:4164:14) + at HTMLUnknownElement.callTheUserObjectsOperation (D:\repos\teslasync\web\node_modules\jsdom\lib\generated\idl\EventListener.js:26:30) + at innerInvokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:360:16) + at invokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:296:3) + at HTMLUnknownElementImpl._dispatch (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:243:9) + at HTMLUnknownElementImpl.dispatchEvent (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:114:17) +Error: Test explosion + at ThrowingComponent (D:/repos/teslasync/web/src/components/feedback/ErrorBoundary.test.tsx:6:26) + at renderWithHooks (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:15486:18) + at mountIndeterminateComponent (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:20103:13) + at beginWork (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:21626:16) + at HTMLUnknownElement.callCallback (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:4164:14) + at HTMLUnknownElement.callTheUserObjectsOperation (D:\repos\teslasync\web\node_modules\jsdom\lib\generated\idl\EventListener.js:26:30) + at innerInvokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:360:16) + at invokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:296:3) + at HTMLUnknownElementImpl._dispatch (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:243:9) + at HTMLUnknownElementImpl.dispatchEvent (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:114:17) +Error: Test explosion + at ThrowingComponent (D:/repos/teslasync/web/src/components/feedback/ErrorBoundary.test.tsx:6:26) + at renderWithHooks (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:15486:18) + at mountIndeterminateComponent (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:20103:13) + at beginWork (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:21626:16) + at HTMLUnknownElement.callCallback (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:4164:14) + at HTMLUnknownElement.callTheUserObjectsOperation (D:\repos\teslasync\web\node_modules\jsdom\lib\generated\idl\EventListener.js:26:30) + at innerInvokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:360:16) + at invokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:296:3) + at HTMLUnknownElementImpl._dispatch (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:243:9) + at HTMLUnknownElementImpl.dispatchEvent (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:114:17) +Error: Test explosion + at ThrowingComponent (D:/repos/teslasync/web/src/components/feedback/ErrorBoundary.test.tsx:6:26) + at renderWithHooks (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:15486:18) + at mountIndeterminateComponent (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:20103:13) + at beginWork (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:21626:16) + at HTMLUnknownElement.callCallback (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:4164:14) + at HTMLUnknownElement.callTheUserObjectsOperation (D:\repos\teslasync\web\node_modules\jsdom\lib\generated\idl\EventListener.js:26:30) + at innerInvokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:360:16) + at invokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:296:3) + at HTMLUnknownElementImpl._dispatch (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:243:9) + at HTMLUnknownElementImpl.dispatchEvent (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:114:17) +Error: Test explosion + at ThrowingComponent (D:/repos/teslasync/web/src/components/feedback/ErrorBoundary.test.tsx:6:26) + at renderWithHooks (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:15486:18) + at mountIndeterminateComponent (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:20103:13) + at beginWork (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:21626:16) + at HTMLUnknownElement.callCallback (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:4164:14) + at HTMLUnknownElement.callTheUserObjectsOperation (D:\repos\teslasync\web\node_modules\jsdom\lib\generated\idl\EventListener.js:26:30) + at innerInvokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:360:16) + at invokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:296:3) + at HTMLUnknownElementImpl._dispatch (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:243:9) + at HTMLUnknownElementImpl.dispatchEvent (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:114:17) + Γ£ô src/components/feedback/ErrorBoundary.test.tsx (7 tests) 296ms +Error: useToast must be used within ToastProvider + at Module.useToast (D:/repos/teslasync/web/src/components/feedback/Toast.tsx:88:19) + at Orphan (D:/repos/teslasync/web/src/components/feedback/Toast.test.tsx:119:7) + at renderWithHooks (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:15486:18) + at mountIndeterminateComponent (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:20103:13) + at beginWork (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:21626:16) + at HTMLUnknownElement.callCallback (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:4164:14) + at HTMLUnknownElement.callTheUserObjectsOperation (D:\repos\teslasync\web\node_modules\jsdom\lib\generated\idl\EventListener.js:26:30) + at innerInvokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:360:16) + at invokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:296:3) + at HTMLUnknownElementImpl._dispatch (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:243:9) +Error: useToast must be used within ToastProvider + at Module.useToast (D:/repos/teslasync/web/src/components/feedback/Toast.tsx:88:19) + at Orphan (D:/repos/teslasync/web/src/components/feedback/Toast.test.tsx:119:7) + at renderWithHooks (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:15486:18) + at mountIndeterminateComponent (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:20103:13) + at beginWork (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:21626:16) + at HTMLUnknownElement.callCallback (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:4164:14) + at HTMLUnknownElement.callTheUserObjectsOperation (D:\repos\teslasync\web\node_modules\jsdom\lib\generated\idl\EventListener.js:26:30) + at innerInvokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:360:16) + at invokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:296:3) + at HTMLUnknownElementImpl._dispatch (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:243:9) + Γ£ô src/components/feedback/Toast.test.tsx (9 tests) 351ms + Γ£ô src/__tests__/lazyRoutes.smoke.test.ts (104 tests) 22554ms + Γ£ô Dashboard chunk imports without throwing  5851ms + Γ£ô GlancePage chunk imports without throwing  1174ms + Γ£ô VehicleDetail chunk imports without throwing  525ms + Γ£ô Charging chunk imports without throwing  535ms + Γ£ô ChargingCurve chunk imports without throwing  347ms + Γ£ô CostAnalysis chunk imports without throwing  551ms + Γ£ô DriveDetail chunk imports without throwing  1244ms + Γ£ô DrivingDynamics chunk imports without throwing  444ms + Γ£ô DrivetrainHealth chunk imports without throwing  432ms + Γ£ô Analytics chunk imports without throwing  368ms + Γ£ô WeeklyDigest chunk imports without throwing  460ms + Γ£ô YearReview chunk imports without throwing  379ms + Γ£ô DevTools chunk imports without throwing  660ms + Γ£ô SecurityAccess chunk imports without throwing  315ms + Γ£ô SystemStatus chunk imports without throwing  345ms + Γ£ô Commands chunk imports without throwing  368ms + Γ£ô Settings chunk imports without throwing  355ms +stderr | src/hooks/__tests__/useUrlState.test.tsx > useUrlState > returns the default when the param is absent +ΓÜá∩╕Å React Router Future Flag Warning: React Router will begin wrapping state updates in `React.startTransition` in v7. You can use the `v7_startTransition` future flag to opt-in early. For more information, see https://reactrouter.com/v6/upgrading/future#v7_starttransition. +ΓÜá∩╕Å React Router Future Flag Warning: Relative route resolution within Splat routes is changing in v7. You can use the `v7_relativeSplatPath` future flag to opt-in early. For more information, see https://reactrouter.com/v6/upgrading/future#v7_relativesplatpath. + + Γ£ô src/hooks/__tests__/useUrlState.test.tsx (22 tests) 237ms + Γ£ô src/components/data-display/__tests__/TimeStamp.test.tsx (11 tests) 206ms + Γ£ô src/hooks/__tests__/useChartPalette.test.tsx (11 tests) 196ms +Error: L is not defined + at Throw (D:/repos/teslasync/web/src/components/feedback/__tests__/ErrorBoundary.routeReset.test.tsx:6:9) + at renderWithHooks (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:15486:18) + at mountIndeterminateComponent (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:20103:13) + at beginWork (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:21626:16) + at HTMLUnknownElement.callCallback (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:4164:14) + at HTMLUnknownElement.callTheUserObjectsOperation (D:\repos\teslasync\web\node_modules\jsdom\lib\generated\idl\EventListener.js:26:30) + at innerInvokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:360:16) + at invokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:296:3) + at HTMLUnknownElementImpl._dispatch (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:243:9) + at HTMLUnknownElementImpl.dispatchEvent (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:114:17) +Error: L is not defined + at Throw (D:/repos/teslasync/web/src/components/feedback/__tests__/ErrorBoundary.routeReset.test.tsx:6:9) + at renderWithHooks (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:15486:18) + at mountIndeterminateComponent (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:20103:13) + at beginWork (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:21626:16) + at HTMLUnknownElement.callCallback (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:4164:14) + at HTMLUnknownElement.callTheUserObjectsOperation (D:\repos\teslasync\web\node_modules\jsdom\lib\generated\idl\EventListener.js:26:30) + at innerInvokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:360:16) + at invokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:296:3) + at HTMLUnknownElementImpl._dispatch (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:243:9) + at HTMLUnknownElementImpl.dispatchEvent (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:114:17) + Γ£ô src/hooks/__tests__/useFaviconBadge.test.tsx (5 tests) 182ms +Error: boom + at Throw (D:/repos/teslasync/web/src/components/feedback/__tests__/ErrorBoundary.routeReset.test.tsx:6:9) + at renderWithHooks (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:15486:18) + at mountIndeterminateComponent (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:20103:13) + at beginWork (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:21626:16) + at HTMLUnknownElement.callCallback (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:4164:14) + at HTMLUnknownElement.callTheUserObjectsOperation (D:\repos\teslasync\web\node_modules\jsdom\lib\generated\idl\EventListener.js:26:30) + at innerInvokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:360:16) + at invokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:296:3) + at HTMLUnknownElementImpl._dispatch (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:243:9) + at HTMLUnknownElementImpl.dispatchEvent (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:114:17) +Error: boom + at Throw (D:/repos/teslasync/web/src/components/feedback/__tests__/ErrorBoundary.routeReset.test.tsx:6:9) + at renderWithHooks (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:15486:18) + at mountIndeterminateComponent (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:20103:13) + at beginWork (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:21626:16) + at HTMLUnknownElement.callCallback (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:4164:14) + at HTMLUnknownElement.callTheUserObjectsOperation (D:\repos\teslasync\web\node_modules\jsdom\lib\generated\idl\EventListener.js:26:30) + at innerInvokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:360:16) + at invokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:296:3) + at HTMLUnknownElementImpl._dispatch (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:243:9) + at HTMLUnknownElementImpl.dispatchEvent (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:114:17) +stdout | src/components/forms/__tests__/DateRangeFilter.test.tsx +≡ƒîÉ i18next is made possible by our own product, Locize ΓÇö consider powering your project with managed localization (AI, CDN, integrations): https://locize.com ≡ƒÆÖ + + Γ£ô src/components/feedback/__tests__/ErrorBoundary.routeReset.test.tsx (3 tests) 234ms + Γ£ô src/components/forms/__tests__/DateRangeFilter.test.tsx (3 tests) 225ms + Γ£ô src/hooks/__tests__/useTitleBadge.test.tsx (7 tests) 164ms +stderr | src/components/motion/__tests__/RouteTransition.test.tsx > RouteTransition > renders children for the current route +ΓÜá∩╕Å React Router Future Flag Warning: React Router will begin wrapping state updates in `React.startTransition` in v7. You can use the `v7_startTransition` future flag to opt-in early. For more information, see https://reactrouter.com/v6/upgrading/future#v7_starttransition. +ΓÜá∩╕Å React Router Future Flag Warning: Relative route resolution within Splat routes is changing in v7. You can use the `v7_relativeSplatPath` future flag to opt-in early. For more information, see https://reactrouter.com/v6/upgrading/future#v7_relativesplatpath. + + Γ£ô src/components/motion/__tests__/RouteTransition.test.tsx (7 tests) 188ms +stderr | src/components/layout/__tests__/Breadcrumbs.test.tsx > Breadcrumbs > renders nothing when items.length <= 1 +react-i18next:: useTranslation: You will need to pass in an i18next instance by using initReactI18next { code: 'NO_I18NEXT_INSTANCE' } +ΓÜá∩╕Å React Router Future Flag Warning: React Router will begin wrapping state updates in `React.startTransition` in v7. You can use the `v7_startTransition` future flag to opt-in early. For more information, see https://reactrouter.com/v6/upgrading/future#v7_starttransition. +ΓÜá∩╕Å React Router Future Flag Warning: Relative route resolution within Splat routes is changing in v7. You can use the `v7_relativeSplatPath` future flag to opt-in early. For more information, see https://reactrouter.com/v6/upgrading/future#v7_relativesplatpath. + + Γ£ô src/components/layout/__tests__/Breadcrumbs.test.tsx (9 tests) 231ms + Γ£ô src/features/system/components/state-machine/__tests__/SnapshotInspector.empty.test.tsx (5 tests) 214ms + Γ£ô src/components/charts/ChartTimeRangeContext.test.tsx (11 tests) 120ms + Γ£ô src/components/ui/Logo.test.tsx (8 tests) 157ms +Not implemented: HTMLCanvasElement's getContext() method: without installing the canvas npm package +Not implemented: HTMLCanvasElement's getContext() method: without installing the canvas npm package +Not implemented: HTMLCanvasElement's getContext() method: without installing the canvas npm package +Not implemented: HTMLCanvasElement's getContext() method: without installing the canvas npm package +Not implemented: HTMLCanvasElement's getContext() method: without installing the canvas npm package +Not implemented: HTMLCanvasElement's getContext() method: without installing the canvas npm package +Not implemented: HTMLCanvasElement's getContext() method: without installing the canvas npm package +Not implemented: HTMLCanvasElement's getContext() method: without installing the canvas npm package +Not implemented: HTMLCanvasElement's getContext() method: without installing the canvas npm package +Not implemented: HTMLCanvasElement's getContext() method: without installing the canvas npm package +Not implemented: HTMLCanvasElement's getContext() method: without installing the canvas npm package +Not implemented: HTMLCanvasElement's getContext() method: without installing the canvas npm package +Not implemented: HTMLCanvasElement's getContext() method: without installing the canvas npm package +Not implemented: HTMLCanvasElement's getContext() method: without installing the canvas npm package +Not implemented: HTMLCanvasElement's getContext() method: without installing the canvas npm package +Not implemented: HTMLCanvasElement's getContext() method: without installing the canvas npm package +Not implemented: HTMLCanvasElement's getContext() method: without installing the canvas npm package +Not implemented: HTMLCanvasElement's getContext() method: without installing the canvas npm package +Not implemented: HTMLCanvasElement's getContext() method: without installing the canvas npm package +Not implemented: HTMLCanvasElement's getContext() method: without installing the canvas npm package +Not implemented: HTMLCanvasElement's getContext() method: without installing the canvas npm package +Not implemented: HTMLCanvasElement's getContext() method: without installing the canvas npm package +Not implemented: HTMLCanvasElement's getContext() method: without installing the canvas npm package +Not implemented: HTMLCanvasElement's getContext() method: without installing the canvas npm package +Not implemented: HTMLCanvasElement's getContext() method: without installing the canvas npm package +Not implemented: HTMLCanvasElement's getContext() method: without installing the canvas npm package +Not implemented: HTMLCanvasElement's getContext() method: without installing the canvas npm package +Not implemented: HTMLCanvasElement's getContext() method: without installing the canvas npm package +Not implemented: HTMLCanvasElement's getContext() method: without installing the canvas npm package +Not implemented: HTMLCanvasElement's getContext() method: without installing the canvas npm package +Not implemented: HTMLCanvasElement's getContext() method: without installing the canvas npm package +Not implemented: HTMLCanvasElement's getContext() method: without installing the canvas npm package +Not implemented: HTMLCanvasElement's getContext() method: without installing the canvas npm package +Not implemented: HTMLCanvasElement's getContext() method: without installing the canvas npm package +Not implemented: HTMLCanvasElement's getContext() method: without installing the canvas npm package +Not implemented: HTMLCanvasElement's getContext() method: without installing the canvas npm package +Not implemented: HTMLCanvasElement's getContext() method: without installing the canvas npm package +Not implemented: HTMLCanvasElement's getContext() method: without installing the canvas npm package +Not implemented: HTMLCanvasElement's getContext() method: without installing the canvas npm package +Not implemented: HTMLCanvasElement's getContext() method: without installing the canvas npm package +Not implemented: HTMLCanvasElement's getContext() method: without installing the canvas npm package +Not implemented: HTMLCanvasElement's getContext() method: without installing the canvas npm package +Not implemented: HTMLCanvasElement's getContext() method: without installing the canvas npm package +Not implemented: HTMLCanvasElement's getContext() method: without installing the canvas npm package +Not implemented: HTMLCanvasElement's getContext() method: without installing the canvas npm package + Γ£ô src/hooks/__tests__/useDynamicAppIcon.test.tsx (8 tests) 192ms + Γ£ô src/hooks/__tests__/useFormDraft.test.tsx (14 tests) 131ms + Γ£ô src/features/system/components/state-machine/__tests__/StateTimeline.test.tsx (3 tests) 209ms +Error: useSelectedVehicleStore must be used within + at useSelectedVehicleStore (D:/repos/teslasync/web/src/store/selectedVehicle.tsx:97:11) + at Probe (D:/repos/teslasync/web/src/store/__tests__/selectedVehicle.test.tsx:88:7) + at renderWithHooks (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:15486:18) + at mountIndeterminateComponent (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:20103:13) + at beginWork (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:21626:16) + at HTMLUnknownElement.callCallback (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:4164:14) + at HTMLUnknownElement.callTheUserObjectsOperation (D:\repos\teslasync\web\node_modules\jsdom\lib\generated\idl\EventListener.js:26:30) + at innerInvokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:360:16) + at invokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:296:3) + at HTMLUnknownElementImpl._dispatch (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:243:9) +Error: useSelectedVehicleStore must be used within + at useSelectedVehicleStore (D:/repos/teslasync/web/src/store/selectedVehicle.tsx:97:11) + at Probe (D:/repos/teslasync/web/src/store/__tests__/selectedVehicle.test.tsx:88:7) + at renderWithHooks (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:15486:18) + at mountIndeterminateComponent (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:20103:13) + at beginWork (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:21626:16) + at HTMLUnknownElement.callCallback (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:4164:14) + at HTMLUnknownElement.callTheUserObjectsOperation (D:\repos\teslasync\web\node_modules\jsdom\lib\generated\idl\EventListener.js:26:30) + at innerInvokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:360:16) + at invokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:296:3) + at HTMLUnknownElementImpl._dispatch (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:243:9) + Γ£ô src/store/__tests__/selectedVehicle.test.tsx (8 tests) 170ms + Γ£ô src/hooks/__tests__/useCriticalAlertFlash.test.tsx (11 tests) 97ms + Γ£ô src/components/data-display/__tests__/SourceLayerBadge.test.tsx (6 tests) 176ms + Γ£ô src/lib/__tests__/broadcast.test.ts (10 tests) 129ms + Γ£ô src/features/system/components/state-machine/__tests__/StateTimeline.empty.test.tsx (5 tests) 140ms +stderr | src/hooks/__tests__/useDensitySync.test.tsx > useDensitySync > ignores unknown density values from the server +Warning: An update to TestComponent inside a test was not wrapped in act(...). + +When testing, code that causes React state updates should be wrapped into act(...): + +act(() => { + /* fire events that update state */ +}); +/* assert on the output */ + +This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act + at TestComponent (D:\repos\teslasync\web\node_modules\@testing-library\react\dist\pure.js:328:5) + at QueryClientProvider (file:///D:/repos/teslasync/web/node_modules/@tanstack/react-query/build/modern/QueryClientProvider.js:20:3) + at D:/repos/teslasync/web/src/hooks/__tests__/useDensitySync.test.tsx:20:12 + + Γ£ô src/hooks/__tests__/useDensitySync.test.tsx (4 tests) 131ms +stderr | src/hooks/__tests__/useShortcutRegistry.test.tsx > useShortcutRegistry > useShortcut + useAllShortcuts > registers an entry on mount and removes on unmount +ΓÜá∩╕Å React Router Future Flag Warning: React Router will begin wrapping state updates in `React.startTransition` in v7. You can use the `v7_startTransition` future flag to opt-in early. For more information, see https://reactrouter.com/v6/upgrading/future#v7_starttransition. +ΓÜá∩╕Å React Router Future Flag Warning: Relative route resolution within Splat routes is changing in v7. You can use the `v7_relativeSplatPath` future flag to opt-in early. For more information, see https://reactrouter.com/v6/upgrading/future#v7_relativesplatpath. + +stderr | src/hooks/__tests__/useShortcutRegistry.test.tsx > useShortcutRegistry > delegated handler > skips route-scoped handlers when the current route does not match +Warning: An update to TestComponent inside a test was not wrapped in act(...). + +When testing, code that causes React state updates should be wrapped into act(...): + +act(() => { + /* fire events that update state */ +}); +/* assert on the output */ + +This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act + at TestComponent (D:\repos\teslasync\web\node_modules\@testing-library\react\dist\pure.js:328:5) + at Router (D:\repos\teslasync\web\node_modules\react-router\dist\umd\react-router.development.js:1207:17) + at MemoryRouter (D:\repos\teslasync\web\node_modules\react-router\dist\umd\react-router.development.js:1101:7) + at D:/repos/teslasync/web/src/hooks/__tests__/useShortcutRegistry.test.tsx:13:12 + + Γ£ô src/hooks/__tests__/useShortcutRegistry.test.tsx (11 tests) 151ms + Γ£ô src/components/feedback/PageLoadSkeleton.test.tsx (3 tests) 119ms + Γ£ô src/hooks/__tests__/useChangelog.test.tsx (14 tests) 79ms + Γ£ô src/components/charts/useChartLegendState.test.tsx (9 tests) 94ms + Γ£ô src/hooks/__tests__/useVehiclePaint.test.tsx (14 tests) 124ms +stderr | src/hooks/__tests__/useAlertContext.test.tsx > useAlertContext > returns nulls and hasContext=false when no params present +ΓÜá∩╕Å React Router Future Flag Warning: React Router will begin wrapping state updates in `React.startTransition` in v7. You can use the `v7_startTransition` future flag to opt-in early. For more information, see https://reactrouter.com/v6/upgrading/future#v7_starttransition. +ΓÜá∩╕Å React Router Future Flag Warning: Relative route resolution within Splat routes is changing in v7. You can use the `v7_relativeSplatPath` future flag to opt-in early. For more information, see https://reactrouter.com/v6/upgrading/future#v7_relativesplatpath. + + Γ£ô src/hooks/__tests__/useAlertContext.test.tsx (5 tests) 78ms + Γ£ô src/hooks/__tests__/useLiveConnection.test.tsx (7 tests) 85ms + Γ£ô src/features/onboarding/__tests__/checklist.test.ts (21 tests) 122ms + Γ£ô src/lib/gpx.test.ts (7 tests) 88ms + Γ£ô src/hooks/__tests__/useBulkSelection.test.tsx (9 tests) 107ms + Γ£ô src/components/charts/__tests__/cursorSync.test.ts (10 tests) 68ms + Γ£ô src/lib/certificate.test.ts (11 tests) 74ms +stdout | src/hooks/__tests__/useDirtyForm.test.ts +≡ƒîÉ i18next is made possible by our own product, Locize ΓÇö consider powering your project with managed localization (AI, CDN, integrations): https://locize.com ≡ƒÆÖ + + Γ£ô src/hooks/__tests__/useDirtyForm.test.ts (6 tests) 115ms + Γ£ô src/hooks/__tests__/useFilteredList.test.ts (10 tests) 97ms + Γ£ô src/hooks/__tests__/useMotionPreference.test.ts (5 tests) 55ms + Γ£ô src/hooks/__tests__/useVersionWatcher.test.tsx (5 tests) 87ms + Γ£ô src/lib/__tests__/dateFormat.test.ts (10 tests) 66ms + Γ£ô src/components/maps/__tests__/GeofenceDrawer.test.tsx (4 tests) 60ms + Γ£ô src/components/maps/__tests__/MarkerCluster.test.tsx (4 tests) 109ms + Γ£ô src/lib/__tests__/dateFormat.duration.test.ts (55 tests) 72ms + Γ£ô src/lib/__tests__/errorReporter.test.ts (19 tests) 72ms + Γ£ô src/lib/__tests__/rateLimit.test.ts (15 tests) 71ms + Γ£ô src/types/fsm/__tests__/coverage-matrix.test.ts (30 tests) 89ms + Γ£ô src/types/fsm/__tests__/transition-integrity.test.ts (24 tests) 69ms + Γ£ô src/lib/__tests__/closestRoute.test.ts (9 tests) 55ms + Γ£ô src/features/onboarding/hooks/useOnboardingSkip.test.ts (4 tests) 61ms + Γ£ô src/components/charts/Charts.test.ts (17 tests) 64ms + Γ£ô src/lib/__tests__/timezone.test.ts (7 tests) 57ms + Γ£ô src/lib/__tests__/commandFrecency.test.ts (13 tests) 33ms + Γ£ô src/features/admin/lib/__tests__/serviceOptions.test.ts (13 tests) 55ms + Γ£ô src/lib/__tests__/webVitalsReporter.test.ts (9 tests) 61ms + Γ£ô src/lib/datePresets.test.ts (24 tests) 25ms + Γ£ô src/features/onboarding/__tests__/tours.test.ts (12 tests) 40ms + Γ£ô src/lib/__tests__/teslaAuthRecovery.test.ts (9 tests) 37ms + Γ£ô src/types/fsm/__tests__/scenario-walkability.test.ts (24 tests) 28ms + Γ£ô src/lib/__tests__/vehicleColors.test.ts (32 tests) 33ms + Γ£ô src/lib/__tests__/confirmSilence.test.ts (14 tests) 24ms + Γ£ô src/lib/__tests__/titleStore.test.ts (9 tests) 48ms + Γ£ô src/lib/export.test.ts (8 tests) 22ms + Γ£ô src/features/driving/lib/__tests__/replayMarkers.test.ts (17 tests) 27ms + Γ£ô src/lib/__tests__/commandRegistry.test.ts (14 tests) 44ms + Γ£ô src/types/fsm/__tests__/disallowed.test.ts (25 tests) 27ms + Γ£ô src/types/fsm/__tests__/truth-table.test.ts (6 tests) 21ms + Γ£ô src/types/fsm/__tests__/edge-derivation.test.ts (25 tests) 33ms + Γ£ô src/lib/vehicleState.test.ts (23 tests) 20ms + Γ£ô src/lib/parseEnums.test.ts (33 tests) 20ms + Γ£ô src/__tests__/url-state-adoption.test.ts (14 tests) 18ms + Γ£ô src/types/fsm/__tests__/cross-fsm-wiring.test.ts (9 tests) 19ms + Γ£ô src/lib/enums.test.ts (21 tests) 16ms + Γ£ô src/features/system/components/state-machine/__tests__/windowTransitions.test.ts (14 tests) 14ms + Γ£ô src/lib/__tests__/routeMeta.test.ts (4 tests) 18ms + Γ£ô src/lib/__tests__/tokens.test.ts (9 tests) 15ms + Γ£ô src/lib/alertDrillthrough.test.ts (12 tests) 15ms + Γ£ô src/lib/__tests__/csvExport.test.ts (16 tests) 12ms + Γ£ô src/lib/gear.test.ts (21 tests) 8ms + Γ£ô src/lib/__tests__/constants.test.ts (11 tests) 7ms + Γ£ô src/lib/cleanNil.test.ts (10 tests) 7ms + Γ£ô src/lib/resilience.test.ts (3 tests) 4ms + + Test Files  135 passed (135) + Tests  1611 passed (1611) + Start at  16:02:14 + Duration  47.90s (transform 44.30s, setup 34.43s, import 134.89s, tests 74.39s, environment 376.77s) + +FRONTEND_TEST_EXIT=0 +=== GO_BUILD === +GO_BUILD_EXIT=0 +=== GO_VET === +GO_VET_EXIT=0 +=== GO_TEST === +ok github.com/ev-dev-labs/teslasync/internal/api (cached) +GO_TEST_EXIT=0 +=== AUDIT_VIOLATIONS === +REPORTER_WIRED_IN_MAIN=True +REPORTER_WIRED_IN_BOUNDARY=True +ROUTE_REGISTERED=True +=== GIT_STATUS === +--- raw status --- + M internal/api/router.go + M web/src/api/hooks/useAdmin.ts + M web/src/components/feedback/ErrorBoundary.tsx + M web/src/components/ui/__tests__/CommandPalette.test.tsx + M web/src/features/admin/pages/AdminPage.tsx + M web/src/i18n/en.json + M web/src/main.tsx + M web/src/types/admin.ts +?? internal/api/web_errors_handler.go +?? internal/api/web_errors_handler_test.go +?? web/src/lib/__tests__/errorReporter.test.ts +?? web/src/lib/errorReporter.ts +--- /raw status --- +UNEXPECTED_STATUS_COUNT=0 +=== STATUS === +EXIT=0 +STATUS=DONE diff --git a/internal/api/router.go b/internal/api/router.go index 49274ddd79..81edcf9b94 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -331,6 +331,21 @@ func NewRouter(db *database.DB, teslaClient *tesla.Client, mqttClient *mqtt.Clie httprate.LimitByIP(120, 1*time.Minute), ).Post("/api/v1/web-vitals", webVitalsHandler.Ingest) + // Public: Web error reports (Phase 46 / Prompt 01). The SPA's global + // error reporter POSTs uncaught exceptions, unhandled promise + // rejections, React render errors, and TanStack Query failures here. + // Mounted OUTSIDE the /api/v1 ForwardAuth subrouter so we can + // capture login-loop bugs even when the user's auth token is + // expired. The handler bounds payload size + label cardinality; + // abuse is bounded by a tight per-IP rate limit (errors are bursty + // — 50 reports/minute is generous without enabling spam). The + // summary endpoint below is admin-only and shares the same handler + // instance so the rolling-window state is consistent. + webErrorHandler := NewWebErrorHandler() + r.With( + httprate.LimitByIP(50, 1*time.Minute), + ).Post("/api/v1/web-errors", webErrorHandler.Ingest) + // API v1 routes r.Route("/api/v1", func(r chi.Router) { @@ -1030,6 +1045,16 @@ func NewRouter(db *database.DB, teslaClient *tesla.Client, mqttClient *mqtt.Clie }) }) + // Admin: frontend error reporting summary (Phase 46 / Prompt 01). + // Last-hour rolling counts read from the same WebErrorHandler + // instance that the public /api/v1/web-errors POST endpoint + // writes to, so the summary stays in sync without going through + // Prometheus. Auth-protected by the parent /api/v1 ForwardAuth + // middleware. + r.Route("/admin/web-errors", func(r chi.Router) { + r.Get("/summary", webErrorHandler.Summary) + }) + // Fleet Telemetry ingestion r.Route("/telemetry", func(r chi.Router) { r.Post("/", telemetryHandler.TelemetryIngest) diff --git a/internal/api/web_errors_handler.go b/internal/api/web_errors_handler.go new file mode 100644 index 0000000000..864e58729f --- /dev/null +++ b/internal/api/web_errors_handler.go @@ -0,0 +1,261 @@ +package api + +import ( + "encoding/json" + "net/http" + "sort" + "strings" + "sync" + "time" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" + "github.com/rs/zerolog/log" +) + +// Web Errors ingest handler (Phase 46 / Prompt 01). +// +// Accepts individual frontend error reports captured by the SPA's +// global reporter (window.onerror, window.onunhandledrejection, the +// React , and TanStack Query failures) and increments a +// Prometheus counter labelled by error name + normalised route. Operators +// scrape /metrics and alert on `teslasync_web_errors_total`; the +// adjacent /api/v1/admin/web-errors/summary endpoint surfaces a tiny +// last-hour rolling view in the admin UI. +// +// The handler maintains an in-memory rolling map (last hour) ONLY for +// the admin-summary endpoint. The Prometheus counter is the durable +// surface; the rolling map is best-effort and resets on restart. +// +// Public ingest endpoint contract: this route MUST stay reachable even +// when a user's auth token is expired so we can capture login-loop bugs. +// It is wired in router.go OUTSIDE the ForwardAuth-protected /api/v1 +// subrouter, alongside /api/v1/web-vitals. Abuse is bounded by a tight +// per-IP rate limit at the route level (50 / minute / IP). + +const ( + maxWebErrorMessageLen = 500 + maxWebErrorStackLen = 8 * 1024 // 8 KB of stack is plenty + maxWebErrorNameLen = 64 + maxWebErrorUserAgentLen = 200 + webErrorsRequestBodyLimit = 32 * 1024 // hard cap on the POST body + webErrorSummaryWindow = time.Hour + webErrorSummaryTopN = 3 +) + +// allowedWebErrorNames keeps the {name} label cardinality bounded. +// Anything outside this set is bucketed as "Other" so a malicious or +// buggy client cannot spawn unbounded label sets. +var allowedWebErrorNames = map[string]struct{}{ + "Error": {}, + "TypeError": {}, + "ReferenceError": {}, + "SyntaxError": {}, + "RangeError": {}, + "URIError": {}, + "EvalError": {}, + "AbortError": {}, + "ChunkLoadError": {}, + "ApiError": {}, + "RateLimitError": {}, + "UpstreamUnavailableError": {}, + "NotFoundError": {}, +} + +type webErrorReport struct { + Name string `json:"name"` + Message string `json:"message"` + Stack string `json:"stack,omitempty"` + Route string `json:"route"` + UserAgent string `json:"userAgent,omitempty"` + OccurredAt string `json:"occurredAt,omitempty"` +} + +var webErrorsTotal = promauto.NewCounterVec( + prometheus.CounterOpts{ + Namespace: "teslasync", + Name: "web_errors_total", + Help: "Browser-side errors reported by the SPA via /api/v1/web-errors, labelled by error name and normalised route.", + }, + []string{"name", "route"}, +) + +var webErrorsRejectedTotal = promauto.NewCounterVec( + prometheus.CounterOpts{ + Namespace: "teslasync", + Name: "web_errors_rejected_total", + Help: "Frontend error reports dropped before observation, labelled by reason.", + }, + []string{"reason"}, +) + +// rollingErrorEntry is one observation kept in the last-hour window. +type rollingErrorEntry struct { + at time.Time + name string + route string +} + +// WebErrorHandler accepts browser-side error reports and exposes a +// last-hour rolling summary for the admin panel. Both endpoints share a +// single instance so the summary reflects ingested reports without +// reaching back into Prometheus. +type WebErrorHandler struct { + mu sync.Mutex + rolling []rollingErrorEntry + now func() time.Time +} + +// NewWebErrorHandler constructs a stateless ingest + summary handler. +// `now` is injectable for deterministic tests; nil falls back to time.Now. +func NewWebErrorHandler() *WebErrorHandler { + return &WebErrorHandler{now: time.Now} +} + +// Ingest handles `POST /api/v1/web-errors`. Validates and bounds the +// payload, increments `teslasync_web_errors_total`, records into the +// rolling window, and emits an INFO-level structured log so operators +// can grep for client errors without a Prometheus query. +func (h *WebErrorHandler) Ingest(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + r.Body = http.MaxBytesReader(w, r.Body, webErrorsRequestBodyLimit) + + var rep webErrorReport + dec := json.NewDecoder(r.Body) + dec.DisallowUnknownFields() + if err := dec.Decode(&rep); err != nil { + webErrorsRejectedTotal.WithLabelValues("invalid_payload").Inc() + writeError(w, http.StatusBadRequest, "invalid payload") + return + } + + if strings.TrimSpace(rep.Message) == "" && strings.TrimSpace(rep.Name) == "" { + webErrorsRejectedTotal.WithLabelValues("empty").Inc() + writeError(w, http.StatusBadRequest, "empty error report") + return + } + + name := normalizeWebErrorName(rep.Name) + route := normalizeWebVitalsRoute(rep.Route) + message := truncateWebErrorString(rep.Message, maxWebErrorMessageLen) + stack := truncateWebErrorString(rep.Stack, maxWebErrorStackLen) + ua := truncateWebErrorString(rep.UserAgent, maxWebErrorUserAgentLen) + + webErrorsTotal.WithLabelValues(name, route).Inc() + h.recordRolling(name, route) + + log.Info(). + Str("name", name). + Str("route", route). + Str("message", message). + Str("user_agent", ua). + Bool("has_stack", stack != ""). + Str("occurred_at", rep.OccurredAt). + Msg("frontend error reported") + + w.WriteHeader(http.StatusNoContent) +} + +// Summary handles `GET /api/v1/admin/web-errors/summary` and returns +// {window_seconds, total, top:[{name, route, count}], as_of}. The body +// is small (top N capped) and safe to poll on the admin page. +func (h *WebErrorHandler) Summary(w http.ResponseWriter, r *http.Request) { + now := h.callNow() + cutoff := now.Add(-webErrorSummaryWindow) + + h.mu.Lock() + kept := h.rolling[:0] + for _, e := range h.rolling { + if !e.at.Before(cutoff) { + kept = append(kept, e) + } + } + h.rolling = kept + snapshot := append([]rollingErrorEntry(nil), h.rolling...) + h.mu.Unlock() + + type bucket struct { + key string + Name string `json:"name"` + Route string `json:"route"` + Count int `json:"count"` + } + groups := make(map[string]*bucket, len(snapshot)) + for _, e := range snapshot { + key := e.name + "|" + e.route + b, ok := groups[key] + if !ok { + b = &bucket{key: key, Name: e.name, Route: e.route} + groups[key] = b + } + b.Count++ + } + buckets := make([]*bucket, 0, len(groups)) + for _, b := range groups { + buckets = append(buckets, b) + } + sort.Slice(buckets, func(i, j int) bool { + if buckets[i].Count != buckets[j].Count { + return buckets[i].Count > buckets[j].Count + } + return buckets[i].key < buckets[j].key + }) + if len(buckets) > webErrorSummaryTopN { + buckets = buckets[:webErrorSummaryTopN] + } + + writeJSON(w, http.StatusOK, map[string]any{ + "window_seconds": int(webErrorSummaryWindow.Seconds()), + "total": len(snapshot), + "top": buckets, + "as_of": now.UTC().Format(time.RFC3339), + }) +} + +func (h *WebErrorHandler) recordRolling(name, route string) { + now := h.callNow() + cutoff := now.Add(-webErrorSummaryWindow) + + h.mu.Lock() + defer h.mu.Unlock() + + kept := h.rolling[:0] + for _, e := range h.rolling { + if !e.at.Before(cutoff) { + kept = append(kept, e) + } + } + h.rolling = append(kept, rollingErrorEntry{at: now, name: name, route: route}) +} + +func (h *WebErrorHandler) callNow() time.Time { + if h.now != nil { + return h.now() + } + return time.Now() +} + +// normalizeWebErrorName clamps the error name to the allow-list so the +// {name} label cardinality stays bounded; unknown names bucket to "Other". +// Empty / whitespace-only names also bucket to "Other" — a frontend that +// reports a typed error MUST set name explicitly. +func normalizeWebErrorName(name string) string { + name = strings.TrimSpace(name) + if name == "" { + return "Other" + } + if len(name) > maxWebErrorNameLen { + name = name[:maxWebErrorNameLen] + } + if _, ok := allowedWebErrorNames[name]; ok { + return name + } + return "Other" +} + +func truncateWebErrorString(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] +} diff --git a/internal/api/web_errors_handler_test.go b/internal/api/web_errors_handler_test.go new file mode 100644 index 0000000000..d9a22c3747 --- /dev/null +++ b/internal/api/web_errors_handler_test.go @@ -0,0 +1,277 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +func TestWebErrorsIngest_ValidReport(t *testing.T) { + body := `{ + "name":"TypeError", + "message":"Cannot read properties of undefined", + "stack":"TypeError: Cannot read properties of undefined\n at Foo (Foo.tsx:42:7)", + "route":"/dashboard", + "userAgent":"Mozilla/5.0", + "occurredAt":"2026-05-04T22:30:00.000Z" + }` + req := httptest.NewRequest(http.MethodPost, "/web-errors", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + + NewWebErrorHandler().Ingest(rr, req) + + if rr.Code != http.StatusNoContent { + t.Fatalf("want 204, got %d (body=%s)", rr.Code, rr.Body.String()) + } +} + +func TestWebErrorsIngest_InvalidJSON(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/web-errors", strings.NewReader("not-json")) + rr := httptest.NewRecorder() + + NewWebErrorHandler().Ingest(rr, req) + + if rr.Code != http.StatusBadRequest { + t.Fatalf("want 400, got %d", rr.Code) + } + if !strings.Contains(rr.Body.String(), "invalid payload") { + t.Fatalf("want 'invalid payload' in body, got %q", rr.Body.String()) + } +} + +func TestWebErrorsIngest_EmptyReport(t *testing.T) { + body := `{"route":"/dashboard"}` + req := httptest.NewRequest(http.MethodPost, "/web-errors", strings.NewReader(body)) + rr := httptest.NewRecorder() + + NewWebErrorHandler().Ingest(rr, req) + + if rr.Code != http.StatusBadRequest { + t.Fatalf("want 400, got %d (body=%s)", rr.Code, rr.Body.String()) + } + if !strings.Contains(rr.Body.String(), "empty error report") { + t.Fatalf("want 'empty error report' in body, got %q", rr.Body.String()) + } +} + +func TestWebErrorsIngest_RejectsUnknownFields(t *testing.T) { + body := `{"name":"TypeError","message":"x","route":"/","extra":"nope"}` + req := httptest.NewRequest(http.MethodPost, "/web-errors", strings.NewReader(body)) + rr := httptest.NewRecorder() + + NewWebErrorHandler().Ingest(rr, req) + + if rr.Code != http.StatusBadRequest { + t.Fatalf("want 400, got %d", rr.Code) + } +} + +func TestWebErrorsIngest_BodyTooLargeRejected(t *testing.T) { + // Build a payload larger than webErrorsRequestBodyLimit. http.MaxBytesReader + // surfaces an error during decode → handler returns 400 invalid payload. + huge := strings.Repeat("A", webErrorsRequestBodyLimit+1024) + body := `{"name":"Error","message":"` + huge + `","route":"/"}` + req := httptest.NewRequest(http.MethodPost, "/web-errors", strings.NewReader(body)) + rr := httptest.NewRecorder() + + NewWebErrorHandler().Ingest(rr, req) + + if rr.Code != http.StatusBadRequest { + t.Fatalf("want 400, got %d", rr.Code) + } +} + +func TestWebErrorsIngest_LongStackAccepted(t *testing.T) { + // A 6 KB stack must be accepted (under the 8 KB max). Verifies the + // truncation path doesn't reject otherwise-valid reports. + stack := strings.Repeat("at Foo (foo.tsx:1:1)\\n", 300) + body := `{"name":"Error","message":"x","stack":"` + stack + `","route":"/"}` + req := httptest.NewRequest(http.MethodPost, "/web-errors", strings.NewReader(body)) + rr := httptest.NewRecorder() + + NewWebErrorHandler().Ingest(rr, req) + + if rr.Code != http.StatusNoContent { + t.Fatalf("want 204, got %d (body=%s)", rr.Code, rr.Body.String()) + } +} + +func TestNormalizeWebErrorName(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + {"empty becomes Other", "", "Other"}, + {"whitespace becomes Other", " ", "Other"}, + {"unknown becomes Other", "MyCustomError", "Other"}, + {"known TypeError preserved", "TypeError", "TypeError"}, + {"known ChunkLoadError preserved", "ChunkLoadError", "ChunkLoadError"}, + {"known AbortError preserved", "AbortError", "AbortError"}, + {"too-long name truncated then bucketed", strings.Repeat("X", 200), "Other"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := normalizeWebErrorName(tt.in); got != tt.want { + t.Errorf("normalizeWebErrorName(%q) = %q, want %q", tt.in, got, tt.want) + } + }) + } +} + +func TestWebErrorsIngest_UnknownNameBucketedAsOther(t *testing.T) { + // Should still 204 — the handler accepts the report but normalises + // the name to "Other" before observing the histogram. + body := `{"name":"WidgetExploded","message":"oops","route":"/"}` + req := httptest.NewRequest(http.MethodPost, "/web-errors", strings.NewReader(body)) + rr := httptest.NewRecorder() + + NewWebErrorHandler().Ingest(rr, req) + + if rr.Code != http.StatusNoContent { + t.Fatalf("want 204, got %d", rr.Code) + } +} + +func TestWebErrorsIngest_RouteNormalisedForLabelCardinality(t *testing.T) { + // /drives/123 must be normalised so the histogram doesn't grow a + // label per drive id. We assert on summary count rather than the + // internal label by recording a single ingest and reading it back. + h := NewWebErrorHandler() + body := `{"name":"TypeError","message":"x","route":"/drives/12345"}` + req := httptest.NewRequest(http.MethodPost, "/web-errors", strings.NewReader(body)) + rr := httptest.NewRecorder() + h.Ingest(rr, req) + + if rr.Code != http.StatusNoContent { + t.Fatalf("want 204, got %d", rr.Code) + } + + // Summary should report the route as `/drives/:id`. + sumReq := httptest.NewRequest(http.MethodGet, "/admin/web-errors/summary", nil) + sumRec := httptest.NewRecorder() + h.Summary(sumRec, sumReq) + if sumRec.Code != http.StatusOK { + t.Fatalf("want 200, got %d", sumRec.Code) + } + var resp struct { + Total int `json:"total"` + Top []struct { + Name string `json:"name"` + Route string `json:"route"` + Count int `json:"count"` + } `json:"top"` + } + if err := json.Unmarshal(sumRec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode summary: %v", err) + } + if resp.Total != 1 { + t.Fatalf("want total=1, got %d", resp.Total) + } + if len(resp.Top) != 1 || resp.Top[0].Route != "/drives/:id" { + t.Fatalf("want top[0].Route=/drives/:id, got %+v", resp.Top) + } +} + +func TestWebErrorsSummary_RollingWindowEvictsOldEntries(t *testing.T) { + // Inject a fake clock so we can step forward past the rolling window + // and assert that an entry recorded "an hour ago" no longer appears + // in the summary. + now := time.Now() + h := &WebErrorHandler{now: func() time.Time { return now }} + + // Record at t0. + h.recordRolling("TypeError", "/dashboard") + + // Step forward 30 minutes — entry still present. + now = now.Add(30 * time.Minute) + if got := h.snapshotForTest(); len(got) != 1 { + t.Fatalf("want 1 entry at t+30m, got %d", len(got)) + } + + // Step forward past 1 hour total — entry must be evicted on next access. + now = now.Add(31 * time.Minute) + h.recordRolling("TypeError", "/dashboard") // triggers eviction sweep + got := h.snapshotForTest() + if len(got) != 1 { + t.Fatalf("want 1 entry (the new one) after eviction, got %d", len(got)) + } +} + +func TestWebErrorsSummary_TopNAndOrdering(t *testing.T) { + now := time.Now() + h := &WebErrorHandler{now: func() time.Time { return now }} + + // 3 distinct (name, route) buckets with different counts. + for i := 0; i < 5; i++ { + h.recordRolling("TypeError", "/dashboard") + } + for i := 0; i < 3; i++ { + h.recordRolling("Error", "/drives") + } + for i := 0; i < 1; i++ { + h.recordRolling("AbortError", "/charging") + } + // A 4th bucket — should be excluded by Top-N=3. + h.recordRolling("ChunkLoadError", "/admin") + + sumReq := httptest.NewRequest(http.MethodGet, "/admin/web-errors/summary", nil) + sumRec := httptest.NewRecorder() + h.Summary(sumRec, sumReq) + + var resp struct { + Total int `json:"total"` + Top []struct { + Name string `json:"name"` + Route string `json:"route"` + Count int `json:"count"` + } `json:"top"` + WindowSeconds int `json:"window_seconds"` + AsOf string `json:"as_of"` + } + if err := json.Unmarshal(sumRec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode summary: %v", err) + } + if resp.Total != 10 { + t.Fatalf("want total=10, got %d", resp.Total) + } + if resp.WindowSeconds != int(webErrorSummaryWindow.Seconds()) { + t.Fatalf("want window_seconds=%d, got %d", int(webErrorSummaryWindow.Seconds()), resp.WindowSeconds) + } + if resp.AsOf == "" { + t.Fatalf("want as_of populated") + } + if len(resp.Top) != webErrorSummaryTopN { + t.Fatalf("want top length=%d, got %d", webErrorSummaryTopN, len(resp.Top)) + } + if resp.Top[0].Count != 5 || resp.Top[0].Name != "TypeError" { + t.Fatalf("want top[0]={TypeError,5}, got %+v", resp.Top[0]) + } + if resp.Top[1].Count != 3 || resp.Top[1].Name != "Error" { + t.Fatalf("want top[1]={Error,3}, got %+v", resp.Top[1]) + } +} + +// snapshotForTest exposes a copy of the rolling buffer (after eviction +// sweep) so tests can assert on its contents without holding the lock. +func (h *WebErrorHandler) snapshotForTest() []rollingErrorEntry { + now := h.callNow() + cutoff := now.Add(-webErrorSummaryWindow) + + h.mu.Lock() + defer h.mu.Unlock() + kept := h.rolling[:0] + for _, e := range h.rolling { + if !e.at.Before(cutoff) { + kept = append(kept, e) + } + } + h.rolling = kept + out := make([]rollingErrorEntry, len(h.rolling)) + copy(out, h.rolling) + return out +} diff --git a/web/src/api/hooks/useAdmin.ts b/web/src/api/hooks/useAdmin.ts index d7f8ebc6e7..b2f440c182 100644 --- a/web/src/api/hooks/useAdmin.ts +++ b/web/src/api/hooks/useAdmin.ts @@ -7,6 +7,7 @@ import type { APIKey, APICallLog, APICallLogStats, BackupConfig, BackupRun, SystemHealth, AuditLogEntry, SecurityEvent, DBStats, MigrationStatus, ConnectionPool, ExportJob, VehicleState, StateTransition, + WebErrorsSummary, } from '@/types/admin'; export const adminKeys = { @@ -24,6 +25,7 @@ export const adminKeys = { exportJobs: ['export-jobs'] as const, vehicleState: (vehicleId: string) => ['vehicle-state', vehicleId] as const, stateTimeline: (vehicleId: string) => ['state-timeline', vehicleId] as const, + webErrorsSummary: ['admin', 'web-errors-summary'] as const, }; export function useApiKeys() { @@ -127,6 +129,22 @@ export function useAuditLogs() { }); } +/** + * Last-hour rolling summary of frontend error reports (Phase 46 / Prompt 01). + * + * Reads from the same WebErrorHandler instance that ingests reports via + * `POST /api/v1/web-errors`, so the count reflects what the SPA has + * actually shipped. Auto-refreshes on the standard interval so the + * admin page stays live without a manual refresh. + */ +export function useWebErrorsSummary() { + return useQuery({ + queryKey: adminKeys.webErrorsSummary, + queryFn: () => request('/admin/web-errors/summary'), + refetchInterval: INTERVALS.STANDARD, + }); +} + export function useSecurityEvents(vehicleId: string) { return useQuery({ queryKey: adminKeys.securityEvents(vehicleId), diff --git a/web/src/components/feedback/ErrorBoundary.tsx b/web/src/components/feedback/ErrorBoundary.tsx index bf01c3c25e..5f5ac886d8 100644 --- a/web/src/components/feedback/ErrorBoundary.tsx +++ b/web/src/components/feedback/ErrorBoundary.tsx @@ -2,6 +2,7 @@ import { Component, type ReactNode } from 'react' import { AlertTriangle, RefreshCw, Home, WifiOff } from 'lucide-react' import i18n from 'i18next' import { Button } from '../ui/Button' +import { reportFrontendError } from '@/lib/errorReporter' interface Props { children: ReactNode @@ -55,6 +56,11 @@ export class ErrorBoundary extends Component { } componentDidCatch(error: Error, info: React.ErrorInfo) { + // Phase 46 / Prompt 01: forward the captured error to the central + // reporter BEFORE any recovery logic so it ships even if the + // chunk-load reload below succeeds (and tears down the page). + reportFrontendError(error, 'react') + // Log structured error for observability console.error(`[ErrorBoundary${this.props.name ? `:${this.props.name}` : ''}]`, { error: error.message, diff --git a/web/src/components/ui/__tests__/CommandPalette.test.tsx b/web/src/components/ui/__tests__/CommandPalette.test.tsx index daeb352bf1..5d367c22c9 100644 --- a/web/src/components/ui/__tests__/CommandPalette.test.tsx +++ b/web/src/components/ui/__tests__/CommandPalette.test.tsx @@ -246,7 +246,6 @@ describe('CommandPalette search', () => { // compareDocumentPosition: bit 4 = following. So drives BEFORE stateMachine // means drivesButton.compareDocumentPosition(stateMachineButton) has bit 4 set. const pos = drivesButton!.compareDocumentPosition(stateMachineButton!) - // eslint-disable-next-line no-bitwise expect(pos & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() } }) diff --git a/web/src/features/admin/pages/AdminPage.tsx b/web/src/features/admin/pages/AdminPage.tsx index babea54d98..aeb6926196 100644 --- a/web/src/features/admin/pages/AdminPage.tsx +++ b/web/src/features/admin/pages/AdminPage.tsx @@ -21,13 +21,13 @@ import { usePageTitle } from '@/hooks/usePageTitle'; import { formatDate, formatDateTime } from '@/lib/dateFormat'; import { fmtInt } from '@/lib/numberFormat'; import { - useSystemHealth, useAuditLogs, useApiKeys, useApiLogStats, + useSystemHealth, useAuditLogs, useApiKeys, useApiLogStats, useWebErrorsSummary, } from '@/api/hooks/useAdmin'; import { useRefreshAuth } from '@/api/hooks/useSettings'; import type { AuditLogEntry, APIKey, SystemHealth } from '@/types/admin'; import { Shield, Clock, Activity, Database, RefreshCw, Download, Trash2, - Search, BarChart3, Key, Server, HardDrive, AlertTriangle, + Search, BarChart3, Key, Server, HardDrive, AlertTriangle, Bug, } from 'lucide-react'; // ─── Page component ────────────────────────────────────────────────────────── @@ -43,6 +43,7 @@ export default function AdminPage() { const { data: apiLogStats, isLoading: usageLoading, error: usageError } = useApiLogStats(); const { data: auditLogs, isLoading: auditLoading, error: auditError } = useAuditLogs(); const { data: apiKeys, isLoading: keysLoading, error: keysError } = useApiKeys(); + const { data: webErrorsSummary, isLoading: webErrorsLoading } = useWebErrorsSummary(); const refreshMut = useRefreshAuth(); const typedHealth = health as SystemHealth | undefined; @@ -207,6 +208,56 @@ export default function AdminPage() { + {/* ── Recent Audit Log ─────────────────────────────────────── */} + + + + + {t('admin.errors.title', 'Frontend Errors (Last Hour)')} + + + {t('admin.errors.subtitle', 'Reported by browser sessions via /api/v1/web-errors. Exported as Prometheus counter teslasync_web_errors_total.')} + + {webErrorsLoading ? ( +
{[1, 2, 3].map(i => )}
+ ) : webErrorsSummary ? ( +
+
+ {t('admin.errors.totalLastHour', 'Errors in last hour')} + {fmtInt(webErrorsSummary.total ?? 0)} +
+ {(webErrorsSummary.top ?? []).length > 0 ? ( +
+ + {t('admin.errors.topOffenders', 'Top error sources')} + + {(webErrorsSummary.top ?? []).map((entry, idx) => ( +
+
+ {entry.name ?? '—'} + {entry.route ?? '—'} +
+ {fmtInt(entry.count ?? 0)} +
+ ))} +
+ ) : ( + + {t('admin.errors.noErrors', 'No frontend errors reported in the last hour.')} + + )} +
+ ) : ( + + {t('admin.errors.unableToLoad', 'Unable to load error summary.')} + + )} +
+
+ {/* ── Recent Audit Log ─────────────────────────────────────── */} diff --git a/web/src/i18n/en.json b/web/src/i18n/en.json index 1aa360b310..c6d177cc84 100644 --- a/web/src/i18n/en.json +++ b/web/src/i18n/en.json @@ -1977,6 +1977,14 @@ "audit": { "searchPlaceholder": "Search by action, resource, or details…", "noMatches": "No audit entries match your search." + }, + "errors": { + "title": "Frontend Errors (Last Hour)", + "subtitle": "Reported by browser sessions via /api/v1/web-errors. Exported as Prometheus counter teslasync_web_errors_total.", + "totalLastHour": "Errors in last hour", + "topOffenders": "Top error sources", + "noErrors": "No frontend errors reported in the last hour.", + "unableToLoad": "Unable to load error summary." } }, "apiLogs": { diff --git a/web/src/lib/__tests__/errorReporter.test.ts b/web/src/lib/__tests__/errorReporter.test.ts new file mode 100644 index 0000000000..b1b038fd02 --- /dev/null +++ b/web/src/lib/__tests__/errorReporter.test.ts @@ -0,0 +1,232 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { + reportFrontendError, + installGlobalErrorReporting, + __resetErrorReporterForTests, + __setErrorReporterEnabledForTests, + __getBufferedCountForTests, +} from '../errorReporter' +import { RateLimitError, UpstreamUnavailableError } from '../resilience' + +describe('errorReporter', () => { + let originalFetch: typeof globalThis.fetch | undefined + let fetchSpy: ReturnType + + beforeEach(() => { + __resetErrorReporterForTests() + __setErrorReporterEnabledForTests(true) + + originalFetch = globalThis.fetch + fetchSpy = vi.fn(() => Promise.resolve(new Response(null, { status: 204 }))) + globalThis.fetch = fetchSpy as unknown as typeof globalThis.fetch + + // Default to online + a known route for assertions. + Object.defineProperty(navigator, 'onLine', { + value: true, + configurable: true, + writable: true, + }) + Object.defineProperty(window, 'location', { + value: { ...window.location, pathname: '/dashboard' }, + configurable: true, + }) + }) + + afterEach(() => { + if (originalFetch === undefined) { + delete (globalThis as { fetch?: typeof globalThis.fetch }).fetch + } else { + globalThis.fetch = originalFetch + } + vi.useRealTimers() + __resetErrorReporterForTests() + }) + + it('skips POST when reporter is disabled (DEV mode)', () => { + __setErrorReporterEnabledForTests(false) + reportFrontendError(new Error('boom'), 'window') + expect(fetchSpy).not.toHaveBeenCalled() + }) + + it('POSTs a typed Error and includes name, message, route, occurredAt, userAgent', () => { + const err = new TypeError('Cannot read x of undefined') + reportFrontendError(err, 'window') + + expect(fetchSpy).toHaveBeenCalledTimes(1) + const [url, init] = fetchSpy.mock.calls[0] + expect(url).toBe('/api/v1/web-errors') + const requestInit = init as RequestInit + expect(requestInit.method).toBe('POST') + expect((requestInit.headers as Record)['Content-Type']).toBe('application/json') + expect(requestInit.keepalive).toBe(true) + const body = JSON.parse(requestInit.body as string) as Record + expect(body.name).toBe('TypeError') + expect(body.message).toBe('Cannot read x of undefined') + expect(body.route).toBe('/dashboard') + expect(typeof body.userAgent).toBe('string') + expect(typeof body.occurredAt).toBe('string') + expect(typeof body.stack).toBe('string') + }) + + it('coalesces identical (name+message+route) within the 60s window — only one POST', () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-05-04T22:30:00.000Z')) + + for (let i = 0; i < 5; i++) { + reportFrontendError(new TypeError('same'), 'window') + } + expect(fetchSpy).toHaveBeenCalledTimes(1) + + // Advance 30 s — still within window, still suppressed. + vi.setSystemTime(new Date('2026-05-04T22:30:30.000Z')) + reportFrontendError(new TypeError('same'), 'window') + expect(fetchSpy).toHaveBeenCalledTimes(1) + + // Advance past 60 s — bucket expires, next identical error sends. + vi.setSystemTime(new Date('2026-05-04T22:31:01.000Z')) + reportFrontendError(new TypeError('same'), 'window') + expect(fetchSpy).toHaveBeenCalledTimes(2) + }) + + it('treats different sources as independent buckets', () => { + reportFrontendError(new TypeError('same'), 'window') + reportFrontendError(new TypeError('same'), 'query') + reportFrontendError(new TypeError('same'), 'react') + reportFrontendError(new TypeError('same'), 'promise') + + expect(fetchSpy).toHaveBeenCalledTimes(4) + }) + + it('treats different messages as independent buckets', () => { + reportFrontendError(new TypeError('one'), 'window') + reportFrontendError(new TypeError('two'), 'window') + expect(fetchSpy).toHaveBeenCalledTimes(2) + }) + + it('skips RateLimitError (handled by RateLimitBanner UI)', () => { + reportFrontendError(new RateLimitError('429', 30, 'vehicles'), 'query') + expect(fetchSpy).not.toHaveBeenCalled() + }) + + it('skips UpstreamUnavailableError (handled by waiting placeholder)', () => { + reportFrontendError(new UpstreamUnavailableError('503', 30, 'tesla'), 'query') + expect(fetchSpy).not.toHaveBeenCalled() + }) + + it('skips AbortError (intentional cancellation, not a bug)', () => { + const abortErr = new Error('aborted') + abortErr.name = 'AbortError' + reportFrontendError(abortErr, 'query') + expect(fetchSpy).not.toHaveBeenCalled() + }) + + it('skips CanceledError (Axios-style cancellation)', () => { + const canceledErr = new Error('canceled') + canceledErr.name = 'CanceledError' + reportFrontendError(canceledErr, 'query') + expect(fetchSpy).not.toHaveBeenCalled() + }) + + it('skips null/undefined error values', () => { + reportFrontendError(null, 'window') + reportFrontendError(undefined, 'window') + expect(fetchSpy).not.toHaveBeenCalled() + }) + + it('handles non-Error thrown values (string)', () => { + reportFrontendError('something exploded', 'promise') + expect(fetchSpy).toHaveBeenCalledTimes(1) + const init = fetchSpy.mock.calls[0][1] as RequestInit + const body = JSON.parse(init.body as string) as Record + expect(body.name).toBe('Error') + expect(body.message).toBe('something exploded') + }) + + it('handles non-Error thrown values (plain object)', () => { + reportFrontendError({ code: 42, detail: 'bad' }, 'promise') + expect(fetchSpy).toHaveBeenCalledTimes(1) + const init = fetchSpy.mock.calls[0][1] as RequestInit + const body = JSON.parse(init.body as string) as Record + expect(body.name).toBe('Error') + expect(body.message).toContain('42') + }) + + it('buffers reports while offline (up to MAX_BUFFER_SIZE)', () => { + Object.defineProperty(navigator, 'onLine', { + value: false, + configurable: true, + writable: true, + }) + // Send 25 unique errors — buffer caps at 20, oldest dropped. + for (let i = 0; i < 25; i++) { + reportFrontendError(new Error(`offline-${i}`), 'window') + } + expect(fetchSpy).not.toHaveBeenCalled() + expect(__getBufferedCountForTests()).toBe(20) + }) + + it('flushes the offline buffer when the online event fires', () => { + installGlobalErrorReporting() + Object.defineProperty(navigator, 'onLine', { + value: false, + configurable: true, + writable: true, + }) + reportFrontendError(new Error('queued-1'), 'window') + reportFrontendError(new Error('queued-2'), 'window') + expect(fetchSpy).not.toHaveBeenCalled() + expect(__getBufferedCountForTests()).toBe(2) + + // Simulate connectivity returning. + Object.defineProperty(navigator, 'onLine', { + value: true, + configurable: true, + writable: true, + }) + window.dispatchEvent(new Event('online')) + + expect(fetchSpy).toHaveBeenCalledTimes(2) + expect(__getBufferedCountForTests()).toBe(0) + }) + + it('installGlobalErrorReporting is idempotent', () => { + installGlobalErrorReporting() + installGlobalErrorReporting() + // No throw, no duplicate listener — fire one error and assert + // exactly one POST goes out. + window.dispatchEvent(new ErrorEvent('error', { error: new Error('once'), message: 'once' })) + expect(fetchSpy).toHaveBeenCalledTimes(1) + }) + + it('captures window error events via installed handler', () => { + installGlobalErrorReporting() + window.dispatchEvent(new ErrorEvent('error', { error: new TypeError('window'), message: 'window' })) + expect(fetchSpy).toHaveBeenCalledTimes(1) + const init = fetchSpy.mock.calls[0][1] as RequestInit + const body = JSON.parse(init.body as string) as Record + expect(body.name).toBe('TypeError') + }) + + it('captures unhandledrejection events via installed handler', () => { + installGlobalErrorReporting() + const reason = new Error('rejected') + const event = new Event('unhandledrejection') as PromiseRejectionEvent + Object.defineProperty(event, 'reason', { value: reason, configurable: true }) + window.dispatchEvent(event) + expect(fetchSpy).toHaveBeenCalledTimes(1) + const init = fetchSpy.mock.calls[0][1] as RequestInit + const body = JSON.parse(init.body as string) as Record + expect(body.message).toBe('rejected') + }) + + it('swallows fetch failures without throwing', () => { + fetchSpy.mockImplementation(() => Promise.reject(new Error('network down'))) + expect(() => reportFrontendError(new Error('x'), 'window')).not.toThrow() + expect(fetchSpy).toHaveBeenCalledTimes(1) + }) + + it('does not crash when fetch is unavailable in the environment', () => { + delete (globalThis as { fetch?: typeof globalThis.fetch }).fetch + expect(() => reportFrontendError(new Error('x'), 'window')).not.toThrow() + }) +}) diff --git a/web/src/lib/errorReporter.ts b/web/src/lib/errorReporter.ts new file mode 100644 index 0000000000..105f87baaa --- /dev/null +++ b/web/src/lib/errorReporter.ts @@ -0,0 +1,232 @@ +/** + * Frontend error reporter — Phase 46 / Prompt 01. + * + * Captures uncaught browser errors (`window.error`, + * `window.unhandledrejection`), React render errors (forwarded from + * `.componentDidCatch`), and TanStack Query failures + * (forwarded from `queryCache.subscribe`) and ships them to the + * backend as a single POST per `(name+message+route)` bucket per + * minute. + * + * Mirrors the design of the Web Vitals reporter (Phase 45 / Prompt 12) — + * telemetry is best-effort, never propagated to the user, and gracefully + * degrades when the device is offline. + */ + +import { isRateLimitError, isUpstreamUnavailableError } from './resilience' + +const ENDPOINT = '/api/v1/web-errors' +const COALESCE_WINDOW_MS = 60_000 +const MAX_BUFFER_SIZE = 20 + +/** + * Source channel that originated the report. Used internally to + * disambiguate buckets; not transmitted in the payload because the + * backend already bounds label cardinality on `name`. + */ +export type ErrorSource = 'window' | 'promise' | 'react' | 'query' + +interface FrontendErrorPayload { + name: string + message: string + stack?: string + route: string + userAgent: string + occurredAt: string +} + +interface BufferedSend { + payload: FrontendErrorPayload +} + +interface ReporterState { + installed: boolean + // bucketKey → epoch-ms timestamp of the most recent POST for the bucket + buckets: Map + buffer: BufferedSend[] + enabledOverride?: boolean +} + +const state: ReporterState = { + installed: false, + buckets: new Map(), + buffer: [], +} + +function isEnabled(): boolean { + if (state.enabledOverride !== undefined) return state.enabledOverride + // Web errors only get reported in production builds — dev errors come + // from HMR reloads, StrictMode double-invokes, or work-in-progress + // code that hasn't been pushed yet, all of which would create noise. + return Boolean(import.meta.env.PROD) +} + +function isOffline(): boolean { + return typeof navigator !== 'undefined' && navigator.onLine === false +} + +function nameOf(err: unknown): string { + if (err instanceof Error && typeof err.name === 'string' && err.name.length > 0) { + return err.name + } + if (typeof err === 'string') return 'Error' + return 'Error' +} + +function messageOf(err: unknown): string { + if (err instanceof Error) { + return err.message && err.message.length > 0 ? err.message : String(err) + } + if (typeof err === 'string') return err + try { + return JSON.stringify(err) + } catch { + return String(err) + } +} + +function stackOf(err: unknown): string | undefined { + if (err instanceof Error && typeof err.stack === 'string' && err.stack.length > 0) { + return err.stack + } + return undefined +} + +function shouldSkip(err: unknown): boolean { + if (err === null || err === undefined) return true + // Transient infra signals already drive their own UI surfaces — they + // are not bugs and would mask real issues if reported here. + if (isRateLimitError(err)) return true + if (isUpstreamUnavailableError(err)) return true + if (err instanceof Error && (err.name === 'AbortError' || err.name === 'CanceledError')) { + return true + } + return false +} + +function bucketKey(source: ErrorSource, payload: FrontendErrorPayload): string { + // Source is part of the key so the same TypeError happening in + // `window` vs `query` gets two POSTs (one per origin). The wire + // payload still omits `source` to keep label cardinality bounded. + return `${source}\u0000${payload.name}\u0000${payload.message}\u0000${payload.route}` +} + +function shouldCoalesce(key: string, now: number): boolean { + const last = state.buckets.get(key) + if (last === undefined) return false + return now - last < COALESCE_WINDOW_MS +} + +function sendPayload(payload: FrontendErrorPayload): void { + try { + if (typeof fetch !== 'function') return + void fetch(ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + // `keepalive` lets the request survive page unload — important + // because errors often happen right before the user navigates + // away (e.g. clicking Reload after the boundary fires). + keepalive: true, + }).catch(() => { + /* swallow — telemetry is best-effort */ + }) + } catch { + /* swallow — never let the reporter throw into its caller */ + } +} + +function flushBuffer(): void { + if (state.buffer.length === 0) return + const drained = state.buffer.splice(0, state.buffer.length) + for (const item of drained) { + sendPayload(item.payload) + } +} + +/** + * Build a payload from an arbitrary thrown value, run through the + * coalescing + offline-buffer logic, and POST when ready. Always a + * no-op in dev mode and for transient infra signals (rate limit, + * upstream unavailable, abort). + * + * Never throws — telemetry must not break the app. + */ +export function reportFrontendError(err: unknown, source: ErrorSource): void { + if (!isEnabled()) return + if (shouldSkip(err)) return + + const payload: FrontendErrorPayload = { + name: nameOf(err), + message: messageOf(err), + stack: stackOf(err), + route: typeof window !== 'undefined' ? window.location.pathname : '/', + userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : '', + occurredAt: new Date().toISOString(), + } + + const key = bucketKey(source, payload) + const now = Date.now() + if (shouldCoalesce(key, now)) return + state.buckets.set(key, now) + + if (isOffline()) { + // Drop the oldest buffered report when the buffer is full so we + // always preserve the most recent context — older errors are less + // actionable by the time the user comes back online. + if (state.buffer.length >= MAX_BUFFER_SIZE) state.buffer.shift() + state.buffer.push({ payload }) + return + } + + sendPayload(payload) +} + +/** + * Attach `window.error` and `window.unhandledrejection` listeners that + * forward to {@link reportFrontendError}, plus an `online` listener + * that flushes any reports buffered while offline. + * + * Idempotent — calling more than once is a no-op so React StrictMode + * double-invokes don't double-register listeners. + */ +export function installGlobalErrorReporting(): void { + if (state.installed) return + state.installed = true + + if (typeof window === 'undefined') return + + window.addEventListener('error', (e: ErrorEvent) => { + // `e.error` is the actual thrown value when available; some legacy + // sources only set `e.message` (e.g. cross-origin script errors) + // in which case we synthesise a minimal Error wrapper. + reportFrontendError(e.error ?? new Error(e.message || 'Unknown error'), 'window') + }) + + window.addEventListener('unhandledrejection', (e: PromiseRejectionEvent) => { + reportFrontendError(e.reason, 'promise') + }) + + window.addEventListener('online', () => { + flushBuffer() + }) +} + +// ─── Test-only exports ────────────────────────────────────────────── +// These are exported for unit tests. Production callers must not use +// them — the underscore prefix is the convention. + +export function __resetErrorReporterForTests(): void { + state.installed = false + state.buckets.clear() + state.buffer.length = 0 + state.enabledOverride = undefined +} + +export function __setErrorReporterEnabledForTests(v: boolean | undefined): void { + state.enabledOverride = v +} + +export function __getBufferedCountForTests(): number { + return state.buffer.length +} diff --git a/web/src/main.tsx b/web/src/main.tsx index 714ae61452..1ecdaf8238 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -11,10 +11,20 @@ import { FormatterPrefsBridge } from './components/FormatterPrefsBridge' import { ThemeProvider } from './components/ui/ThemeProvider' import ReloadPrompt from './components/feedback/ReloadPrompt' import { SelectedVehicleProvider } from './store/selectedVehicle' +import { installGlobalErrorReporting, reportFrontendError } from './lib/errorReporter' import App from './App' import './i18n' import './index.css' +// ── Frontend error reporting (Phase 46 / Prompt 01) ─────────────────────────── +// Install global window.error / window.unhandledrejection listeners BEFORE +// React mounts so we capture even very-early bootstrap exceptions. The +// reporter no-ops in dev mode (HMR + StrictMode generate too much noise to +// be useful) and gracefully buffers when offline. ErrorBoundary forwards +// React render errors and the queryCache subscription below forwards +// TanStack Query failures. +installGlobalErrorReporting() + // ── Density bootstrap (Phase 40 / Prompt 44) ────────────────────────────────── // Apply the cached UI density to BEFORE React mounts so the first // paint uses the correct row heights / padding instead of flashing the @@ -72,6 +82,21 @@ const queryClient = new QueryClient({ }, }) +// Phase 46 / Prompt 01: subscribe the queryCache to the error reporter so +// background refetch failures (where no is mounted because +// the user navigated away from the originating page) still get captured. +// `event.action.type === 'error'` only fires on the moment a query +// transitions into the error state — the coalescing window in the +// reporter handles repeated identical errors from refetch retries. +queryClient.getQueryCache().subscribe((event) => { + if (!event || event.type !== 'updated') return + const action = (event as { action?: { type?: string; error?: unknown } }).action + if (action?.type !== 'error') return + if (action.error !== undefined && action.error !== null) { + reportFrontendError(action.error, 'query') + } +}) + ReactDOM.createRoot(document.getElementById('root')!).render( diff --git a/web/src/types/admin.ts b/web/src/types/admin.ts index 29973406a1..72cd856316 100644 --- a/web/src/types/admin.ts +++ b/web/src/types/admin.ts @@ -76,6 +76,30 @@ export interface AuditLogEntry { createdAt: string; } +/** + * Last-hour rolling summary of frontend errors reported by the SPA via + * `POST /api/v1/web-errors`. The same handler that ingests reports + * exposes this summary via `GET /api/v1/admin/web-errors/summary`. + * + * Backend wire format is snake_case (`window_seconds`, `as_of`); after + * `camelCaseKeys()` both forms are present at runtime — frontend code + * uses the snake_case names that match the Go JSON tags for clarity. + */ +export interface WebErrorsSummaryEntry { + name: string; + route: string; + count: number; +} + +export interface WebErrorsSummary { + window_seconds: number; + windowSeconds: number; + total: number; + top: WebErrorsSummaryEntry[]; + as_of: string; + asOf: string; +} + /** * Per-user activity entry returned by `GET /users/me/activity` * (Phase-40 / Prompt 49 — Recent Activity Discoverability). From 92b06e89965d01c80378a54523a4d126316124a9 Mon Sep 17 00:00:00 2001 From: Atul Gupta Date: Mon, 4 May 2026 16:27:30 -0700 Subject: [PATCH 02/76] phase-46/02-abort: thread AbortSignal through request() and every useQuery, audit script enforces Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../logs/phase-46-02-abort-on-unmount.log | 20 ++ web/package.json | 3 +- web/scripts/audit-query-signal.mjs | 221 +++++++++++++++++ web/scripts/codemod-thread-signal.mjs | 229 ++++++++++++++++++ web/src/api/client.ts | 15 ++ web/src/api/devtools.ts | 7 +- .../hooks/__tests__/useNotifications.test.tsx | 5 +- web/src/api/hooks/useAdmin.ts | 30 +-- web/src/api/hooks/useAnalytics.ts | 24 +- web/src/api/hooks/useAnnotations.ts | 2 +- web/src/api/hooks/useAnomalies.ts | 2 +- web/src/api/hooks/useApiHealth.ts | 16 +- web/src/api/hooks/useAutomations.ts | 14 +- web/src/api/hooks/useCharging.ts | 39 +-- web/src/api/hooks/useChat.ts | 4 +- web/src/api/hooks/useCommands.ts | 8 +- web/src/api/hooks/useDashboard.ts | 2 +- web/src/api/hooks/useDashboardLayouts.ts | 4 +- web/src/api/hooks/useDriving.ts | 46 ++-- web/src/api/hooks/useEnergy.ts | 46 ++-- web/src/api/hooks/useExports.ts | 8 +- web/src/api/hooks/useFSM.ts | 8 +- web/src/api/hooks/useGuard.ts | 4 +- web/src/api/hooks/useLocations.ts | 6 +- web/src/api/hooks/useNotifications.ts | 14 +- web/src/api/hooks/useOnboarding.ts | 2 +- web/src/api/hooks/usePinned.ts | 2 +- web/src/api/hooks/usePush.ts | 6 +- web/src/api/hooks/useSavedViews.ts | 2 +- web/src/api/hooks/useSearch.ts | 4 +- web/src/api/hooks/useSettings.ts | 18 +- web/src/api/hooks/useSharing.ts | 4 +- web/src/api/hooks/useTelemetry.ts | 53 ++-- web/src/api/hooks/useTrips.ts | 4 +- web/src/api/hooks/useUser.ts | 14 +- web/src/api/hooks/useVehicleAccess.ts | 4 +- web/src/api/hooks/useVehicleSystems.ts | 22 +- web/src/api/hooks/useVehicles.ts | 42 ++-- web/src/api/hooks/useWatch.ts | 4 +- .../lib/__tests__/resilience.abort.test.ts | 166 +++++++++++++ web/src/lib/resilience.ts | 202 +++++++++++++-- 41 files changed, 1088 insertions(+), 238 deletions(-) create mode 100644 .github/prompts/db-refactor/logs/phase-46-02-abort-on-unmount.log create mode 100644 web/scripts/audit-query-signal.mjs create mode 100644 web/scripts/codemod-thread-signal.mjs create mode 100644 web/src/lib/__tests__/resilience.abort.test.ts diff --git a/.github/prompts/db-refactor/logs/phase-46-02-abort-on-unmount.log b/.github/prompts/db-refactor/logs/phase-46-02-abort-on-unmount.log new file mode 100644 index 0000000000..297f23cea4 --- /dev/null +++ b/.github/prompts/db-refactor/logs/phase-46-02-abort-on-unmount.log @@ -0,0 +1,20 @@ +=== GATE === +=== PREFLIGHT === +PROMPT=phase-46/02-abort-on-unmount +BRANCH=fix/bug-waave-46 +HEAD=688752c2 +=== TYPESCRIPT === +TSC_EXIT=0 +=== LINT === +LINT_EXIT=0 +=== FRONTEND_TEST === +FRONTEND_TEST_EXIT=0 +=== AUDIT_QUERY_SIGNAL === +AUDIT_QUERY_SIGNAL_EXIT=0 +=== AUDIT_VIOLATIONS === +ABORT_TEST_EXISTS=True +=== GIT_STATUS === +UNEXPECTED_STATUS_COUNT=0 +=== STATUS === +EXIT=0 +STATUS=DONE diff --git a/web/package.json b/web/package.json index 962f0b5086..04dd9aa8d2 100644 --- a/web/package.json +++ b/web/package.json @@ -11,12 +11,13 @@ "build": "tsc && vite build", "postbuild": "node scripts/check-bundle-size.mjs", "preview": "vite preview", - "lint": "eslint . --report-unused-disable-directives --max-warnings 0 && npm run lint:i18n && npm run lint:light-mode && npm run audit:empty-state && npm run audit:tooltip-text && npm run audit:i18n-shadowed", + "lint": "eslint . --report-unused-disable-directives --max-warnings 0 && npm run lint:i18n && npm run lint:light-mode && npm run audit:empty-state && npm run audit:tooltip-text && npm run audit:i18n-shadowed && npm run audit:query-signal", "lint:i18n": "node scripts/lint-i18n-no-template-literals.mjs", "lint:light-mode": "node scripts/audit-light-mode-parity.mjs", "audit:empty-state": "node scripts/audit-empty-state-cta.mjs", "audit:tooltip-text": "node scripts/audit-tooltip-text-color.mjs", "audit:i18n-shadowed": "node scripts/audit-i18n-shadowed-keys.mjs", + "audit:query-signal": "node scripts/audit-query-signal.mjs", "audit:skeletons": "node scripts/auditSkeletons.mjs", "audit:motion": "node scripts/auditMotionTokens.mjs", "audit:palette": "node scripts/auditChartPalette.mjs", diff --git a/web/scripts/audit-query-signal.mjs b/web/scripts/audit-query-signal.mjs new file mode 100644 index 0000000000..176f159d3b --- /dev/null +++ b/web/scripts/audit-query-signal.mjs @@ -0,0 +1,221 @@ +#!/usr/bin/env node +// Phase-46 / Prompt 02 — useQuery / useInfiniteQuery `signal` audit. +// +// Scans every file under web/src/api/hooks/*.ts and uses the TypeScript +// compiler API to find every `useQuery({ ... })` / `useInfiniteQuery({ ... })` +// call. For each one, asserts that: +// +// 1. The `queryFn` is an arrow function or function expression whose +// first parameter destructures `signal`. TanStack Query passes +// `{ signal }` to the queryFn so the underlying fetch can be +// cancelled when the query is unmounted/cancelled. +// +// 2. The body of the queryFn references `signal` somewhere (i.e. it +// is forwarded to `request(...)` or to a helper). A queryFn that +// destructures `signal` but never uses it would silently leak +// cancellation. +// +// This audit complements the broader Phase-46 hardening: it locks in +// the property that no domain hook can ship a queryFn that ignores +// the cancellation contract. Run via `npm run audit:query-signal`. + +import { readFileSync, readdirSync, statSync } from 'node:fs' +import { join } from 'node:path' +import process from 'node:process' + +import ts from 'typescript' + +const HOOK_ROOT = join('src', 'api', 'hooks') +const TARGETED_CALLEES = new Set(['useQuery', 'useInfiniteQuery']) + +const offenders = [] + +/** + * Walk a directory recursively and yield every `.ts` file path + * (excluding `.test.ts` files — tests don't ship to production). + */ +function* walk(dir) { + let entries + try { + entries = readdirSync(dir) + } catch { + return + } + for (const name of entries) { + const p = join(dir, name) + let st + try { + st = statSync(p) + } catch { + continue + } + if (st.isDirectory()) { + yield* walk(p) + continue + } + if (!p.endsWith('.ts')) continue + if (p.endsWith('.test.ts')) continue + yield p + } +} + +/** + * Resolve a CallExpression's callee name. Handles both bare identifiers + * (`useQuery(...)`) and dotted accesses (`tanstack.useQuery(...)`). + */ +function calleeName(node) { + if (ts.isIdentifier(node.expression)) { + return node.expression.text + } + if (ts.isPropertyAccessExpression(node.expression) && ts.isIdentifier(node.expression.name)) { + return node.expression.name.text + } + return '' +} + +/** + * Find the first `queryFn:` PropertyAssignment within an object + * literal (the only argument to `useQuery({ ... })`). + */ +function findQueryFnProperty(objLit) { + for (const prop of objLit.properties) { + if ( + ts.isPropertyAssignment(prop) && + ts.isIdentifier(prop.name) && + prop.name.text === 'queryFn' + ) { + return prop + } + // Shorthand or method shorthand — `queryFn() { ... }` + if (ts.isMethodDeclaration(prop) && ts.isIdentifier(prop.name) && prop.name.text === 'queryFn') { + return prop + } + } + return null +} + +/** + * Returns true when the function's first parameter destructures + * `signal` (handles `({ signal })`, `({ signal, ... })`, and + * renamings like `({ signal: sig })` — though renamings are also + * flagged by the body-usage check below). + */ +function paramDestructuresSignal(fn) { + const param = fn.parameters?.[0] + if (!param) return false + if (!ts.isObjectBindingPattern(param.name)) return false + for (const element of param.name.elements) { + if (!ts.isBindingElement(element)) continue + // Property name comes from `propertyName` if present, else `name`. + const sourceName = + element.propertyName && ts.isIdentifier(element.propertyName) + ? element.propertyName.text + : ts.isIdentifier(element.name) + ? element.name.text + : '' + if (sourceName === 'signal') return true + } + return false +} + +/** + * Scan the body of a function for an Identifier reference named + * `signal`. We deliberately do NOT require it to be an argument to + * `request(...)` — the body may forward through a helper, and a + * brittle "must be in second arg" check would generate false + * positives. Anything that mentions `signal` in the body is treated + * as forwarding it onward. + */ +function bodyReferencesSignal(fn) { + if (!fn.body) return false + let found = false + const visit = (node) => { + if (found) return + if (ts.isIdentifier(node) && node.text === 'signal') { + found = true + return + } + ts.forEachChild(node, visit) + } + ts.forEachChild(fn.body, visit) + return found +} + +function locOf(file, sf, node) { + const { line, character } = sf.getLineAndCharacterOfPosition(node.getStart(sf)) + return `${file}:${line + 1}:${character + 1}` +} + +function auditFile(file) { + const text = readFileSync(file, 'utf8') + const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS) + + const visit = (node) => { + if (ts.isCallExpression(node)) { + const name = calleeName(node) + if (TARGETED_CALLEES.has(name) && node.arguments.length > 0) { + const arg = node.arguments[0] + if (ts.isObjectLiteralExpression(arg)) { + const queryFnProp = findQueryFnProperty(arg) + if (queryFnProp) { + let fn = null + if (ts.isPropertyAssignment(queryFnProp)) { + const init = queryFnProp.initializer + if (ts.isArrowFunction(init) || ts.isFunctionExpression(init)) { + fn = init + } + } else if (ts.isMethodDeclaration(queryFnProp)) { + fn = queryFnProp + } + + if (!fn) { + offenders.push({ + where: locOf(file, sf, queryFnProp), + why: `${name}: queryFn is not an inline arrow / function — cannot statically verify signal forwarding`, + }) + } else { + if (!paramDestructuresSignal(fn)) { + offenders.push({ + where: locOf(file, sf, fn), + why: `${name}: queryFn must accept \`{ signal }\` as its first parameter (TanStack Query passes the cancellation signal here)`, + }) + } else if (!bodyReferencesSignal(fn)) { + offenders.push({ + where: locOf(file, sf, fn), + why: `${name}: queryFn destructures \`signal\` but never uses it in its body — pass it through to request() or a helper`, + }) + } + } + } + } + } + } + ts.forEachChild(node, visit) + } + visit(sf) +} + +let scanned = 0 +for (const file of walk(HOOK_ROOT)) { + scanned++ + auditFile(file) +} + +if (offenders.length > 0) { + console.error( + `\nuseQuery / useInfiniteQuery queryFn missing AbortSignal threading (${offenders.length}):`, + ) + for (const o of offenders) { + console.error(` ${o.where}\n ${o.why}`) + } + console.error( + '\nFix by changing\n' + + ' queryFn: () => request(`/path`)\n' + + 'to\n' + + ' queryFn: ({ signal }) => request(`/path`, { signal })\n' + + 'so unmounting / route changes cancel the in-flight request.', + ) + process.exit(1) +} + +console.log(`OK — every useQuery/useInfiniteQuery queryFn in ${HOOK_ROOT} threads { signal } (${scanned} files scanned)`) diff --git a/web/scripts/codemod-thread-signal.mjs b/web/scripts/codemod-thread-signal.mjs new file mode 100644 index 0000000000..b86a14c7aa --- /dev/null +++ b/web/scripts/codemod-thread-signal.mjs @@ -0,0 +1,229 @@ +#!/usr/bin/env node +// Phase-46 / Prompt 02 — one-shot codemod. +// +// For every web/src/api/hooks/*.ts file, rewrites every +// `useQuery({ queryFn: ... })` and `useInfiniteQuery({ queryFn: ... })` +// arrow function to: +// +// 1. Accept `{ signal }` as its first parameter. +// 2. Append `signal` (or `, { signal }`) to every `request(...)` call +// inside the queryFn body. +// +// This is a one-time helper; it lives in scripts/ for reproducibility +// but is not wired into the build. +// +// Usage: node scripts/codemod-thread-signal.mjs + +import { readFileSync, writeFileSync, readdirSync, statSync } from 'node:fs' +import { join } from 'node:path' +import process from 'node:process' + +import ts from 'typescript' + +const HOOK_ROOT = join('src', 'api', 'hooks') +const TARGETED_CALLEES = new Set(['useQuery', 'useInfiniteQuery']) + +function* walk(dir) { + for (const name of readdirSync(dir)) { + const p = join(dir, name) + const st = statSync(p) + if (st.isDirectory()) yield* walk(p) + else if (p.endsWith('.ts') && !p.endsWith('.test.ts')) yield p + } +} + +function calleeName(node) { + if (ts.isIdentifier(node.expression)) return node.expression.text + if (ts.isPropertyAccessExpression(node.expression) && ts.isIdentifier(node.expression.name)) { + return node.expression.name.text + } + return '' +} + +function findQueryFnProperty(objLit) { + for (const prop of objLit.properties) { + if ( + ts.isPropertyAssignment(prop) && + ts.isIdentifier(prop.name) && + prop.name.text === 'queryFn' + ) { + return prop + } + } + return null +} + +function paramDestructuresSignal(fn) { + const param = fn.parameters?.[0] + if (!param) return false + if (!ts.isObjectBindingPattern(param.name)) return false + for (const element of param.name.elements) { + if (!ts.isBindingElement(element)) continue + const sourceName = + element.propertyName && ts.isIdentifier(element.propertyName) + ? element.propertyName.text + : ts.isIdentifier(element.name) + ? element.name.text + : '' + if (sourceName === 'signal') return true + } + return false +} + +/** + * Mutating rewrite using string-edit ranges. We collect a list of + * { start, end, replacement } edits during AST traversal and apply + * them in reverse order so earlier offsets stay valid. + */ +function transformFile(file) { + const src = readFileSync(file, 'utf8') + const sf = ts.createSourceFile(file, src, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS) + + /** @type {Array<{start:number,end:number,replacement:string}>} */ + const edits = [] + + /** + * Append `, { signal }` (or merge into an existing options object) + * to every CallExpression named `request` inside `body`. + */ + function patchRequestCalls(body) { + const visit = (node) => { + if (ts.isCallExpression(node)) { + const isRequestCall = + ts.isIdentifier(node.expression) && node.expression.text === 'request' + if (isRequestCall) { + const args = node.arguments + const close = node.end - 1 // position of `)` — the call ends with ) + if (args.length === 1) { + // request(`/path`) → request(`/path`, { signal }) + const insertAt = args[0].end + edits.push({ + start: insertAt, + end: insertAt, + replacement: ', { signal }', + }) + } else if (args.length >= 2) { + const second = args[1] + if (ts.isObjectLiteralExpression(second)) { + const props = second.properties + const alreadyHasSignal = props.some( + (p) => + (ts.isPropertyAssignment(p) || ts.isShorthandPropertyAssignment(p)) && + ts.isIdentifier(p.name) && + p.name.text === 'signal', + ) + if (!alreadyHasSignal) { + // Insert before the closing `}` of the object literal. + const objClose = second.end - 1 + // Detect if the object is empty: `{}` -> insert `signal`, + // otherwise insert `, signal` after the last property's + // text (or before the closing brace). + const objText = src.slice(second.pos, second.end) + const isEmpty = props.length === 0 + if (isEmpty) { + edits.push({ + start: objClose, + end: objClose, + replacement: ' signal ', + }) + } else { + // Place before the `}` with a leading comma. Whitespace + // before `}` may be a trailing newline; we handle both. + const trailingTrim = objText.match(/(\s*)\}$/) + const padBefore = trailingTrim ? trailingTrim[1] : ' ' + edits.push({ + start: objClose - padBefore.length, + end: objClose - padBefore.length, + replacement: `, signal${padBefore.length === 0 ? ' ' : ''}`, + }) + } + } + } else { + // Second arg is not an object literal (e.g. a variable). + // Replace `req(path, opts)` with `req(path, { ...opts, signal })`. + const startOfArg = second.getStart(sf) + const endOfArg = second.end + const argText = src.slice(startOfArg, endOfArg) + edits.push({ + start: startOfArg, + end: endOfArg, + replacement: `{ ...${argText}, signal }`, + }) + } + } + // Avoid re-applying to nested calls inside the args we just + // patched — but nested `request(` is rare; let normal recursion + // pick them up. + } + } + ts.forEachChild(node, visit) + } + visit(body) + } + + /** + * Patch the parameter list of a queryFn arrow / function expression. + * + * `() => ...` → `({ signal }) => ...` + * `(arg) => ...` → `({ signal }) => ...` (shouldn't happen in practice) + * `async () => ...` → `async ({ signal }) => ...` + */ + function patchParamList(fn) { + const open = src.indexOf('(', fn.getStart(sf)) + if (open === -1) return + // Find matching `)` accounting for nested tokens — but the param + // list of a queryFn is always flat. Use the first ')'. + const close = src.indexOf(')', open) + if (close === -1) return + edits.push({ + start: open, + end: close + 1, + replacement: '({ signal })', + }) + } + + function visit(node) { + if (ts.isCallExpression(node)) { + const name = calleeName(node) + if (TARGETED_CALLEES.has(name) && node.arguments.length > 0) { + const arg = node.arguments[0] + if (ts.isObjectLiteralExpression(arg)) { + const queryFnProp = findQueryFnProperty(arg) + if (queryFnProp && ts.isPropertyAssignment(queryFnProp)) { + const init = queryFnProp.initializer + if (ts.isArrowFunction(init) || ts.isFunctionExpression(init)) { + if (!paramDestructuresSignal(init)) { + patchParamList(init) + } + if (init.body) { + patchRequestCalls(init.body) + } + } + } + } + } + } + ts.forEachChild(node, visit) + } + visit(sf) + + if (edits.length === 0) return false + + // Apply edits in reverse offset order so earlier indices stay valid. + edits.sort((a, b) => b.start - a.start) + let out = src + for (const e of edits) { + out = out.slice(0, e.start) + e.replacement + out.slice(e.end) + } + writeFileSync(file, out, 'utf8') + return true +} + +let changed = 0 +for (const f of walk(HOOK_ROOT)) { + if (transformFile(f)) { + changed++ + console.log(` patched ${f}`) + } +} +console.log(`\nDone — ${changed} file(s) patched`) diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 31bfe5f893..b39ca9c4ad 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -10,6 +10,21 @@ export { ApiError, getApiBase, isApiError } export interface ApiRequestOptions extends RequestInit { responseType?: 'json' | 'text' skipAuthRefresh?: boolean + /** + * Optional. If provided, the fetch + retry loop honors cancellation: + * the underlying fetch is wired to an abort signal that is the merge of + * this signal and the internal timeout signal. When this signal is the + * one that aborts (i.e. the caller cancels), the rejected promise + * carries `name === 'AbortError'`, the retry loop exits immediately, + * and the error is NOT classified as an HTTP timeout (408). + * + * Pass TanStack Query's `queryFn` `{ signal }` here so route changes + * cancel in-flight requests instead of decoding into unmounted state. + * + * Allows `null` to match the underlying `RequestInit.signal` shape so + * existing `{ ...options }` spreads keep type-checking. + */ + signal?: AbortSignal | null } function normalizePath(path: string): string { diff --git a/web/src/api/devtools.ts b/web/src/api/devtools.ts index 78e2cd160e..9f40369402 100644 --- a/web/src/api/devtools.ts +++ b/web/src/api/devtools.ts @@ -185,10 +185,11 @@ export const submitImportJob = (type: 'import_drives' | 'import_charging', file: export const sendChatMessage = (message: string, sessionId?: string) => request('/chatbot', { method: 'POST', body: JSON.stringify({ message, session_id: sessionId }) }) /** Fetches the full chat history for a given session. */ -export const getChatHistory = (sessionId: string) => - request(`/chatbot/history?session_id=${sessionId}`) +export const getChatHistory = (sessionId: string, opts?: { signal?: AbortSignal | null }) => + request(`/chatbot/history?session_id=${sessionId}`, { signal: opts?.signal }) /** Lists chat sessions with rich metadata (title, message count, timestamps). */ -export const getChatSessions = () => request('/chatbot/sessions') +export const getChatSessions = (opts?: { signal?: AbortSignal | null }) => + request('/chatbot/sessions', { signal: opts?.signal }) /** Renames a chat session. Pass an empty `title` to clear the override. */ export const renameChatSession = (sessionId: string, title: string) => request<{ id: string; title: string }>(`/chatbot/sessions/${encodeURIComponent(sessionId)}`, { diff --git a/web/src/api/hooks/__tests__/useNotifications.test.tsx b/web/src/api/hooks/__tests__/useNotifications.test.tsx index eef004bac1..3bab6307dc 100644 --- a/web/src/api/hooks/__tests__/useNotifications.test.tsx +++ b/web/src/api/hooks/__tests__/useNotifications.test.tsx @@ -114,7 +114,10 @@ describe('useUnreadCount', () => { requestMock.mockResolvedValue({ count: 7 }); const { result } = renderHook(() => useUnreadCount(), { wrapper: makeWrapper() }); await waitFor(() => expect(result.current.data).toBe(7)); - expect(requestMock).toHaveBeenCalledWith('/notifications/unread-count'); + expect(requestMock).toHaveBeenCalledWith( + '/notifications/unread-count', + expect.objectContaining({ signal: expect.anything() }), + ); }); it('falls back to 0 when count is missing', async () => { diff --git a/web/src/api/hooks/useAdmin.ts b/web/src/api/hooks/useAdmin.ts index b2f440c182..00c7e6e91c 100644 --- a/web/src/api/hooks/useAdmin.ts +++ b/web/src/api/hooks/useAdmin.ts @@ -31,7 +31,7 @@ export const adminKeys = { export function useApiKeys() { return useQuery({ queryKey: adminKeys.apiKeys, - queryFn: () => request('/api-keys'), + queryFn: ({ signal }) => request('/api-keys', { signal }), select: safeArray, }); } @@ -83,7 +83,7 @@ export function useRevokeApiKey() { export function useApiLogs(page: number) { return useQuery({ queryKey: adminKeys.apiLogs(page), - queryFn: () => request(`/api-logs?page=${page}&limit=25`), + queryFn: ({ signal }) => request(`/api-logs?page=${page}&limit=25`, { signal }), select: safeArray, }); } @@ -91,7 +91,7 @@ export function useApiLogs(page: number) { export function useApiLogStats() { return useQuery({ queryKey: adminKeys.apiLogStats, - queryFn: () => request('/api-logs/stats'), + queryFn: ({ signal }) => request('/api-logs/stats', { signal }), refetchInterval: INTERVALS.STANDARD, }); } @@ -99,7 +99,7 @@ export function useApiLogStats() { export function useBackupConfigs() { return useQuery({ queryKey: adminKeys.backupConfigs, - queryFn: () => request('/backup/configs'), + queryFn: ({ signal }) => request('/backup/configs', { signal }), select: safeArray, }); } @@ -107,7 +107,7 @@ export function useBackupConfigs() { export function useBackupRuns() { return useQuery({ queryKey: adminKeys.backupRuns, - queryFn: () => request('/backup/runs'), + queryFn: ({ signal }) => request('/backup/runs', { signal }), refetchInterval: INTERVALS.FAST, select: safeArray, }); @@ -116,7 +116,7 @@ export function useBackupRuns() { export function useSystemHealth() { return useQuery({ queryKey: adminKeys.systemHealth, - queryFn: () => request('/system/health'), + queryFn: ({ signal }) => request('/system/health', { signal }), refetchInterval: INTERVALS.STANDARD, }); } @@ -124,7 +124,7 @@ export function useSystemHealth() { export function useAuditLogs() { return useQuery({ queryKey: adminKeys.auditLogs, - queryFn: () => request('/system/audit'), + queryFn: ({ signal }) => request('/system/audit', { signal }), select: safeArray, }); } @@ -140,7 +140,7 @@ export function useAuditLogs() { export function useWebErrorsSummary() { return useQuery({ queryKey: adminKeys.webErrorsSummary, - queryFn: () => request('/admin/web-errors/summary'), + queryFn: ({ signal }) => request('/admin/web-errors/summary', { signal }), refetchInterval: INTERVALS.STANDARD, }); } @@ -148,7 +148,7 @@ export function useWebErrorsSummary() { export function useSecurityEvents(vehicleId: string) { return useQuery({ queryKey: adminKeys.securityEvents(vehicleId), - queryFn: () => request(`/security?vehicle_id=${vehicleId}`), + queryFn: ({ signal }) => request(`/security?vehicle_id=${vehicleId}`, { signal }), enabled: !!vehicleId, select: safeArray, }); @@ -157,7 +157,7 @@ export function useSecurityEvents(vehicleId: string) { export function useDBStats() { return useQuery({ queryKey: adminKeys.dbStats, - queryFn: () => request('/dev-tools/db-stats'), + queryFn: ({ signal }) => request('/dev-tools/db-stats', { signal }), refetchInterval: INTERVALS.STANDARD, }); } @@ -165,7 +165,7 @@ export function useDBStats() { export function useMigrations() { return useQuery({ queryKey: adminKeys.migrations, - queryFn: () => request('/dev-tools/migration-status'), + queryFn: ({ signal }) => request('/dev-tools/migration-status', { signal }), refetchInterval: INTERVALS.SLOW, }); } @@ -173,7 +173,7 @@ export function useMigrations() { export function useConnectionPool() { return useQuery({ queryKey: adminKeys.connectionPool, - queryFn: () => request('/dev-tools/runtime-info'), + queryFn: ({ signal }) => request('/dev-tools/runtime-info', { signal }), refetchInterval: INTERVALS.STANDARD, }); } @@ -181,7 +181,7 @@ export function useConnectionPool() { export function useExportJobs() { return useQuery({ queryKey: adminKeys.exportJobs, - queryFn: () => request('/export/jobs'), + queryFn: ({ signal }) => request('/export/jobs', { signal }), select: safeArray, }); } @@ -207,7 +207,7 @@ export function useCreateExport() { export function useVehicleStateMachine(vehicleId: string) { return useQuery({ queryKey: adminKeys.vehicleState(vehicleId), - queryFn: () => request(`/vehicles/${vehicleId}/state`), + queryFn: ({ signal }) => request(`/vehicles/${vehicleId}/state`, { signal }), enabled: !!vehicleId, refetchInterval: INTERVALS.CRITICAL, }); @@ -216,7 +216,7 @@ export function useVehicleStateMachine(vehicleId: string) { export function useStateTimeline(vehicleId: string, days = 7) { return useQuery({ queryKey: [...adminKeys.stateTimeline(vehicleId), days], - queryFn: () => request<{ transitions: StateTransition[] }>(`/vehicle-states/timeline?vehicle_id=${vehicleId}&days=${days}`), + queryFn: ({ signal }) => request<{ transitions: StateTransition[] }>(`/vehicle-states/timeline?vehicle_id=${vehicleId}&days=${days}`, { signal }), enabled: !!vehicleId, refetchInterval: INTERVALS.FAST, }); diff --git a/web/src/api/hooks/useAnalytics.ts b/web/src/api/hooks/useAnalytics.ts index 402496e9ba..3a0241c820 100644 --- a/web/src/api/hooks/useAnalytics.ts +++ b/web/src/api/hooks/useAnalytics.ts @@ -20,7 +20,7 @@ export const analyticsKeys = { export function useAnalyticsSummary(days = 30) { return useQuery({ queryKey: analyticsKeys.summary(days), - queryFn: () => request(`/analytics/fleet?days=${days}`), + queryFn: ({ signal }) => request(`/analytics/fleet?days=${days}`, { signal }), }); } @@ -29,14 +29,14 @@ export function useFleetAnalytics(days = 30, start?: string) { const qs = start ? `start=${start}` : `days=${days}`; return useQuery({ queryKey: analyticsKeys.fleet(days, start), - queryFn: () => request(`/analytics/fleet?${qs}`), + queryFn: ({ signal }) => request(`/analytics/fleet?${qs}`, { signal }), }); } export function useMileageStats(vehicleId: string) { return useQuery({ queryKey: analyticsKeys.mileage(vehicleId), - queryFn: () => request(`/mileage/stats?vehicle_id=${vehicleId}`), + queryFn: ({ signal }) => request(`/mileage/stats?vehicle_id=${vehicleId}`, { signal }), enabled: !!vehicleId, }); } @@ -44,7 +44,7 @@ export function useMileageStats(vehicleId: string) { export function useMonthlyMileage(vehicleId: string) { return useQuery({ queryKey: analyticsKeys.monthlyMileage(vehicleId), - queryFn: () => request(`/mileage/monthly?vehicle_id=${vehicleId}`), + queryFn: ({ signal }) => request(`/mileage/monthly?vehicle_id=${vehicleId}`, { signal }), enabled: !!vehicleId, select: safeArray, }); @@ -53,7 +53,7 @@ export function useMonthlyMileage(vehicleId: string) { export function useCostBreakdown(vehicleId: string) { return useQuery({ queryKey: analyticsKeys.cost(vehicleId), - queryFn: () => request(`/analytics/tco?vehicle_id=${vehicleId}`), + queryFn: ({ signal }) => request(`/analytics/tco?vehicle_id=${vehicleId}`, { signal }), enabled: !!vehicleId, }); } @@ -61,7 +61,7 @@ export function useCostBreakdown(vehicleId: string) { export function useTimeline(vehicleId: string) { return useQuery({ queryKey: analyticsKeys.timeline(vehicleId), - queryFn: () => request<{ transitions: TimelineEvent[] }>(`/vehicle-states/timeline?vehicle_id=${vehicleId}`), + queryFn: ({ signal }) => request<{ transitions: TimelineEvent[] }>(`/vehicle-states/timeline?vehicle_id=${vehicleId}`, { signal }), enabled: !!vehicleId, select: (data) => safeArray(data?.transitions), }); @@ -70,7 +70,7 @@ export function useTimeline(vehicleId: string) { export function useStateSummary(vehicleId: string) { return useQuery({ queryKey: analyticsKeys.stateSummary(vehicleId), - queryFn: () => request(`/vehicle-states/summary?vehicle_id=${vehicleId}`), + queryFn: ({ signal }) => request(`/vehicle-states/summary?vehicle_id=${vehicleId}`, { signal }), enabled: !!vehicleId, select: safeArray, }); @@ -79,7 +79,7 @@ export function useStateSummary(vehicleId: string) { export function useWeeklyDigest(vehicleId: string) { return useQuery({ queryKey: analyticsKeys.weeklyDigest(vehicleId), - queryFn: () => request(`/vehicles/${vehicleId}/weekly-digest`), + queryFn: ({ signal }) => request(`/vehicles/${vehicleId}/weekly-digest`, { signal }), enabled: !!vehicleId, retry: false, staleTime: STALE_TIMES.STATIC, @@ -150,9 +150,9 @@ export interface LifetimeStats { export function useLifetimeStats(vehicleId?: string) { return useQuery({ queryKey: analyticsKeys.lifetime(vehicleId), - queryFn: () => + queryFn: ({ signal }) => request( - `/analytics/lifetime${vehicleId ? `?vehicle_id=${vehicleId}` : ''}`, + `/analytics/lifetime${vehicleId ? `?vehicle_id=${vehicleId}` : ''}`, { signal }, ), staleTime: STALE_TIMES.SLOW, }); @@ -163,9 +163,9 @@ export function useLifetimeStats(vehicleId?: string) { export function useYearReview(year: number, vehicleId?: string) { return useQuery({ queryKey: ['year-review', year, vehicleId] as const, - queryFn: () => + queryFn: ({ signal }) => request( - `/analytics/year-review?year=${year}${vehicleId ? `&vehicle_id=${vehicleId}` : ''}`, + `/analytics/year-review?year=${year}${vehicleId ? `&vehicle_id=${vehicleId}` : ''}`, { signal }, ), enabled: !!vehicleId, staleTime: STALE_TIMES.STATIC, diff --git a/web/src/api/hooks/useAnnotations.ts b/web/src/api/hooks/useAnnotations.ts index 38507e32f4..70b45658d4 100644 --- a/web/src/api/hooks/useAnnotations.ts +++ b/web/src/api/hooks/useAnnotations.ts @@ -81,7 +81,7 @@ function buildQuery(params: AnnotationListParams): string { export function useChartAnnotations(params: AnnotationListParams = {}) { return useQuery({ queryKey: annotationKeys.list(params), - queryFn: () => request(`/annotations${buildQuery(params)}`), + queryFn: ({ signal }) => request(`/annotations${buildQuery(params)}`, { signal }), staleTime: STALE_TIMES.SLOW, }); } diff --git a/web/src/api/hooks/useAnomalies.ts b/web/src/api/hooks/useAnomalies.ts index cd30d53264..d0f39fe692 100644 --- a/web/src/api/hooks/useAnomalies.ts +++ b/web/src/api/hooks/useAnomalies.ts @@ -28,7 +28,7 @@ export interface AnomalyEntry { export function useAnomalies(vehicleId: string | null, days = 7) { return useQuery({ queryKey: ['anomalies', vehicleId, days], - queryFn: () => request(`/analytics/anomalies?vehicle_id=${vehicleId}&days=${days}`), + queryFn: ({ signal }) => request(`/analytics/anomalies?vehicle_id=${vehicleId}&days=${days}`, { signal }), enabled: vehicleId !== null, staleTime: STALE_TIMES.SLOW, }); diff --git a/web/src/api/hooks/useApiHealth.ts b/web/src/api/hooks/useApiHealth.ts index 01995aea78..7363f1ed50 100644 --- a/web/src/api/hooks/useApiHealth.ts +++ b/web/src/api/hooks/useApiHealth.ts @@ -42,10 +42,19 @@ const PROBE_TIMEOUT_MS = 5_000; const POLL_INTERVAL_MS = 15_000; const STALE_TIME_MS = 10_000; -async function probe(): Promise { +async function probe(externalSignal?: AbortSignal): Promise { const url = `${getApiBase()}/healthz`; const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS); + // Forward an upstream cancel (e.g. component unmount) into our internal controller. + const onExternalAbort = () => controller.abort(); + if (externalSignal) { + if (externalSignal.aborted) { + controller.abort(); + } else { + externalSignal.addEventListener('abort', onExternalAbort, { once: true }); + } + } const start = performance.now(); try { const res = await fetch(url, { @@ -63,6 +72,9 @@ async function probe(): Promise { return { ok: false, latencyMs, checkedAt: new Date().toISOString() }; } finally { clearTimeout(timeout); + if (externalSignal) { + externalSignal.removeEventListener('abort', onExternalAbort); + } } } @@ -75,7 +87,7 @@ function bucket(result: ProbeResult): ApiHealthStatus { export function useApiHealth(): ApiHealthState { const { data } = useQuery({ queryKey: ['api-health'], - queryFn: probe, + queryFn: ({ signal }) => probe(signal), refetchInterval: POLL_INTERVAL_MS, refetchIntervalInBackground: false, staleTime: STALE_TIME_MS, diff --git a/web/src/api/hooks/useAutomations.ts b/web/src/api/hooks/useAutomations.ts index fe885fcdb9..730448dd2c 100644 --- a/web/src/api/hooks/useAutomations.ts +++ b/web/src/api/hooks/useAutomations.ts @@ -40,7 +40,7 @@ export const automationKeys = { export function useAutomations() { return useQuery({ queryKey: automationKeys.all, - queryFn: () => request('/automations'), + queryFn: ({ signal }) => request('/automations', { signal }), refetchInterval: INTERVALS.STANDARD, select: safeArray, }); @@ -49,8 +49,8 @@ export function useAutomations() { export function useAutomationHistory(limit = 20) { return useQuery({ queryKey: automationKeys.history(limit), - queryFn: () => - request(`/automations/history?limit=${limit}`), + queryFn: ({ signal }) => + request(`/automations/history?limit=${limit}`, { signal }), refetchInterval: INTERVALS.STANDARD, }); } @@ -177,7 +177,7 @@ export function useAutomation(id: number | string | undefined) { const numericId = typeof id === 'string' ? Number(id) : id; return useQuery({ queryKey: automationKeys.detail(numericId!), - queryFn: () => request(`/automations/${id}`), + queryFn: ({ signal }) => request(`/automations/${id}`, { signal }), enabled: numericId != null && !Number.isNaN(numericId) && numericId > 0, }); } @@ -231,8 +231,8 @@ export function useAutomationPresets(category?: string) { const queryParam = category ? `?category=${category}` : ''; return useQuery({ queryKey: category ? presetKeys.category(category) : presetKeys.all, - queryFn: () => - request(`/automations/presets${queryParam}`), + queryFn: ({ signal }) => + request(`/automations/presets${queryParam}`, { signal }), staleTime: STALE_TIMES.STATIC, }); } @@ -240,7 +240,7 @@ export function useAutomationPresets(category?: string) { export function useAutomationPreset(id: string | undefined) { return useQuery({ queryKey: presetKeys.detail(id!), - queryFn: () => request(`/automations/presets/${id}`), + queryFn: ({ signal }) => request(`/automations/presets/${id}`, { signal }), enabled: !!id, staleTime: STALE_TIMES.STATIC, }); diff --git a/web/src/api/hooks/useCharging.ts b/web/src/api/hooks/useCharging.ts index 8e4b06de15..8bb153b744 100644 --- a/web/src/api/hooks/useCharging.ts +++ b/web/src/api/hooks/useCharging.ts @@ -18,11 +18,18 @@ import type { import type { ChargingSession as ApiChargingSession, ChargeTelemetryReading } from '../types'; /** Fetches paginated charging sessions for a vehicle, optionally filtered by date range. */ -export const getChargingSessions = (vehicleId: number, limit = 50, offset = 0, start?: string, end?: string) => { +export const getChargingSessions = ( + vehicleId: number, + limit = 50, + offset = 0, + start?: string, + end?: string, + opts?: { signal?: AbortSignal | null }, +) => { const params = new URLSearchParams({ vehicle_id: String(vehicleId), limit: String(limit), offset: String(offset) }) if (start) params.set('start', start) if (end) params.set('end', end) - return request(`/charging?${params}`) + return request(`/charging?${params}`, { signal: opts?.signal }) } export const chargingKeys = { @@ -36,8 +43,8 @@ export const chargingKeys = { export function useChargingSessions(vehicleId?: string) { return useQuery({ queryKey: vehicleId ? chargingKeys.byVehicle(vehicleId) : chargingKeys.all, - queryFn: () => request( - vehicleId ? `/charging-sessions?vehicle_id=${vehicleId}` : '/charging-sessions', + queryFn: ({ signal }) => request( + vehicleId ? `/charging-sessions?vehicle_id=${vehicleId}` : '/charging-sessions', { signal }, ), enabled: !!vehicleId, select: safeArray, @@ -47,7 +54,7 @@ export function useChargingSessions(vehicleId?: string) { export function useChargingSession(id: string) { return useQuery({ queryKey: chargingKeys.detail(id), - queryFn: () => request(`/charging/${id}`), + queryFn: ({ signal }) => request(`/charging/${id}`, { signal }), enabled: !!id, }); } @@ -56,7 +63,7 @@ export function useChargingSession(id: string) { export function useChargingSessionDetail(id: number | null) { return useQuery({ queryKey: chargingKeys.detailById(id!), - queryFn: () => request(`/charging/${id}`), + queryFn: ({ signal }) => request(`/charging/${id}`, { signal }), enabled: id != null, refetchInterval: (query) => { const data = query.state.data; @@ -69,7 +76,7 @@ export function useChargingSessionDetail(id: number | null) { export function useChargeTelemetry(sessionId: number | null) { return useQuery({ queryKey: chargingKeys.telemetry(sessionId!), - queryFn: () => request(`/charging/${sessionId}/telemetry`), + queryFn: ({ signal }) => request(`/charging/${sessionId}/telemetry`, { signal }), enabled: sessionId != null, select: safeArray, }); @@ -86,7 +93,7 @@ export function useChargingSessionsPaginated( const { limit = 50, offset = 0, start, end } = options; return useQuery({ queryKey: ['charging', vehicleId, start, end, limit, offset] as const, - queryFn: () => getChargingSessions(vehicleId!, limit, offset, start, end), + queryFn: ({ signal }) => getChargingSessions(vehicleId!, limit, offset, start, end, { signal }), enabled: vehicleId !== null, select: safeArray, }); @@ -95,7 +102,7 @@ export function useChargingSessionsPaginated( export function useCostForecast(vehicleId: string | null, months = 6) { return useQuery({ queryKey: ['cost-forecast', vehicleId, months], - queryFn: () => request(`/analytics/cost-forecast?vehicle_id=${vehicleId}&months=${months}`), + queryFn: ({ signal }) => request(`/analytics/cost-forecast?vehicle_id=${vehicleId}&months=${months}`, { signal }), enabled: vehicleId !== null, staleTime: STALE_TIMES.SLOW, }); @@ -104,7 +111,7 @@ export function useCostForecast(vehicleId: string | null, months = 6) { export function useChargingOptimizer(vehicleId: string | null) { return useQuery({ queryKey: ['charging-optimizer', vehicleId], - queryFn: () => request(`/analytics/charging-optimizer?vehicle_id=${vehicleId}`), + queryFn: ({ signal }) => request(`/analytics/charging-optimizer?vehicle_id=${vehicleId}`, { signal }), enabled: vehicleId !== null, staleTime: STALE_TIMES.SLOW, }); @@ -158,8 +165,8 @@ export const teslaChargingHistoryKeys = { export function useTeslaChargingHistory(vin?: string) { return useQuery({ queryKey: vin ? teslaChargingHistoryKeys.byVin(vin) : teslaChargingHistoryKeys.all, - queryFn: () => request( - `/tesla/charging/history${vin ? `?vin=${vin}` : ''}` + queryFn: ({ signal }) => request( + `/tesla/charging/history${vin ? `?vin=${vin}` : ''}`, { signal } ), staleTime: STALE_TIMES.SLOW, }); @@ -246,8 +253,8 @@ export const teslaChargingSessionKeys = { export function useTeslaChargingSessions(vin?: string) { return useQuery({ queryKey: vin ? teslaChargingSessionKeys.byVin(vin) : teslaChargingSessionKeys.all, - queryFn: () => request( - `/tesla/charging/sessions${vin ? `?vin=${vin}` : ''}` + queryFn: ({ signal }) => request( + `/tesla/charging/sessions${vin ? `?vin=${vin}` : ''}`, { signal } ), staleTime: STALE_TIMES.SLOW, }); @@ -323,7 +330,7 @@ export function useApplySchedule() { export function useChargePlans(vehicleId?: number) { return useQuery({ queryKey: chargePlannerKeys.byVehicle(vehicleId!), - queryFn: () => request(`/charge-planner/history?vehicle_id=${vehicleId}`), + queryFn: ({ signal }) => request(`/charge-planner/history?vehicle_id=${vehicleId}`, { signal }), enabled: !!vehicleId, select: safeArray, }); @@ -333,7 +340,7 @@ export function useChargePlans(vehicleId?: number) { export function useRatePlans() { return useQuery({ queryKey: chargePlannerKeys.ratePlans, - queryFn: () => request('/charge-planner/rate-plans'), + queryFn: ({ signal }) => request('/charge-planner/rate-plans', { signal }), staleTime: STALE_TIMES.STATIC, select: safeArray, }); diff --git a/web/src/api/hooks/useChat.ts b/web/src/api/hooks/useChat.ts index 2ce1784d9b..a77124b06f 100644 --- a/web/src/api/hooks/useChat.ts +++ b/web/src/api/hooks/useChat.ts @@ -35,7 +35,7 @@ export const chatKeys = { export function useChatSessions() { return useQuery({ queryKey: chatKeys.sessions(), - queryFn: getChatSessions, + queryFn: ({ signal }) => getChatSessions({ signal }), }); } @@ -43,7 +43,7 @@ export function useChatSessions() { export function useChatHistory(sessionId: string) { return useQuery({ queryKey: chatKeys.history(sessionId), - queryFn: () => getChatHistory(sessionId), + queryFn: ({ signal }) => getChatHistory(sessionId, { signal }), enabled: !!sessionId, }); } diff --git a/web/src/api/hooks/useCommands.ts b/web/src/api/hooks/useCommands.ts index 841a819bf4..593a9c9b89 100644 --- a/web/src/api/hooks/useCommands.ts +++ b/web/src/api/hooks/useCommands.ts @@ -21,8 +21,8 @@ export const commandKeys = { export function useCommandHistory(vehicleId: string | undefined) { return useQuery({ queryKey: commandKeys.history(vehicleId), - queryFn: () => - request(`/vehicles/${vehicleId}/commands/history?limit=200`), + queryFn: ({ signal }) => + request(`/vehicles/${vehicleId}/commands/history?limit=200`, { signal }), enabled: !!vehicleId, staleTime: STALE_TIMES.QUICK, select: (data) => data ?? [], @@ -33,8 +33,8 @@ export function useCommandHistory(vehicleId: string | undefined) { export function useCommandLatest(vehicleId: string | undefined) { return useQuery({ queryKey: commandKeys.latest(vehicleId), - queryFn: () => - request(`/vehicles/${vehicleId}/commands/latest`), + queryFn: ({ signal }) => + request(`/vehicles/${vehicleId}/commands/latest`, { signal }), enabled: !!vehicleId, staleTime: STALE_TIMES.MODERATE, select: (data) => data ?? [], diff --git a/web/src/api/hooks/useDashboard.ts b/web/src/api/hooks/useDashboard.ts index 09a3147e00..c6d5004824 100644 --- a/web/src/api/hooks/useDashboard.ts +++ b/web/src/api/hooks/useDashboard.ts @@ -10,7 +10,7 @@ export const dashboardKeys = { export function useDashboardStats() { return useQuery({ queryKey: dashboardKeys.stats, - queryFn: () => request('/dashboard/stats'), + queryFn: ({ signal }) => request('/dashboard/stats', { signal }), staleTime: STALE_TIMES.STANDARD, }); } diff --git a/web/src/api/hooks/useDashboardLayouts.ts b/web/src/api/hooks/useDashboardLayouts.ts index dd336ae811..e5e30a13eb 100644 --- a/web/src/api/hooks/useDashboardLayouts.ts +++ b/web/src/api/hooks/useDashboardLayouts.ts @@ -56,9 +56,9 @@ export const dashboardLayoutLibraryKeys = { export function useNamedDashboardLayouts(vehicleId?: number | null) { return useQuery({ queryKey: dashboardLayoutLibraryKeys.list(vehicleId), - queryFn: () => { + queryFn: ({ signal }) => { const qs = vehicleId != null ? `?vehicle_id=${vehicleId}` : ''; - return request(`/dashboard/layouts${qs}`); + return request(`/dashboard/layouts${qs}`, { signal }); }, staleTime: STALE_TIMES.SLOW, }); diff --git a/web/src/api/hooks/useDriving.ts b/web/src/api/hooks/useDriving.ts index adc80933f1..32d064eccc 100644 --- a/web/src/api/hooks/useDriving.ts +++ b/web/src/api/hooks/useDriving.ts @@ -49,8 +49,8 @@ export const drivingKeys = { export function useDrives(vehicleId?: string) { return useQuery({ queryKey: drivingKeys.drives(vehicleId), - queryFn: () => - request(vehicleId ? `/drives?vehicle_id=${vehicleId}` : '/drives'), + queryFn: ({ signal }) => + request(vehicleId ? `/drives?vehicle_id=${vehicleId}` : '/drives', { signal }), enabled: !!vehicleId, select: safeArray, }); @@ -59,7 +59,7 @@ export function useDrives(vehicleId?: string) { export function useDrive(id: string) { return useQuery({ queryKey: drivingKeys.drive(id), - queryFn: () => request(`/drives/${id}`), + queryFn: ({ signal }) => request(`/drives/${id}`, { signal }), enabled: !!id, refetchInterval: (query) => { const data = query.state.data; @@ -71,9 +71,9 @@ export function useDrive(id: string) { export function useDriveScore(vehicleId?: string) { return useQuery({ queryKey: drivingKeys.score(vehicleId), - queryFn: () => + queryFn: ({ signal }) => request( - vehicleId ? `/drives/score?vehicle_id=${vehicleId}` : '/drives/score', + vehicleId ? `/drives/score?vehicle_id=${vehicleId}` : '/drives/score', { signal }, ), enabled: !!vehicleId, retry: false, @@ -83,9 +83,9 @@ export function useDriveScore(vehicleId?: string) { export function useDrivingStats(vehicleId?: string) { return useQuery({ queryKey: drivingKeys.stats(vehicleId), - queryFn: () => + queryFn: ({ signal }) => request( - vehicleId ? `/drives/stats?vehicle_id=${vehicleId}` : '/drives/stats', + vehicleId ? `/drives/stats?vehicle_id=${vehicleId}` : '/drives/stats', { signal }, ), enabled: !!vehicleId, retry: false, @@ -95,9 +95,9 @@ export function useDrivingStats(vehicleId?: string) { export function useDrivingDynamics(vehicleId?: string) { return useQuery({ queryKey: drivingKeys.dynamics(vehicleId), - queryFn: () => + queryFn: ({ signal }) => request( - vehicleId ? `/drives/dynamics?vehicle_id=${vehicleId}` : '/drives/dynamics', + vehicleId ? `/drives/dynamics?vehicle_id=${vehicleId}` : '/drives/dynamics', { signal }, ), enabled: !!vehicleId, retry: false, @@ -107,11 +107,11 @@ export function useDrivingDynamics(vehicleId?: string) { export function useAccelerationDistribution(vehicleId?: string) { return useQuery({ queryKey: drivingKeys.accelerationDistribution(vehicleId), - queryFn: () => + queryFn: ({ signal }) => request( vehicleId ? `/drives/acceleration-distribution?vehicle_id=${vehicleId}` - : '/drives/acceleration-distribution', + : '/drives/acceleration-distribution', { signal }, ), enabled: !!vehicleId, retry: false, @@ -121,11 +121,11 @@ export function useAccelerationDistribution(vehicleId?: string) { export function useDrivetrainHealth(vehicleId?: string) { return useQuery({ queryKey: drivingKeys.drivetrainHealth(vehicleId), - queryFn: () => + queryFn: ({ signal }) => request( vehicleId ? `/drivetrain/health?vehicle_id=${vehicleId}` - : '/drivetrain/health', + : '/drivetrain/health', { signal }, ), enabled: !!vehicleId, retry: false, @@ -135,9 +135,9 @@ export function useDrivetrainHealth(vehicleId?: string) { export function useSpeedProfile(vehicleId?: string) { return useQuery({ queryKey: drivingKeys.speedProfile(vehicleId), - queryFn: () => + queryFn: ({ signal }) => request( - vehicleId ? `/analytics/speed-profile?vehicle_id=${vehicleId}` : '/analytics/speed-profile', + vehicleId ? `/analytics/speed-profile?vehicle_id=${vehicleId}` : '/analytics/speed-profile', { signal }, ), enabled: !!vehicleId, }); @@ -146,9 +146,9 @@ export function useSpeedProfile(vehicleId?: string) { export function useRegenEfficiency(vehicleId?: string) { return useQuery({ queryKey: drivingKeys.regenEfficiency(vehicleId), - queryFn: () => + queryFn: ({ signal }) => request( - vehicleId ? `/analytics/regen?vehicle_id=${vehicleId}` : '/analytics/regen', + vehicleId ? `/analytics/regen?vehicle_id=${vehicleId}` : '/analytics/regen', { signal }, ), enabled: !!vehicleId, }); @@ -157,11 +157,11 @@ export function useRegenEfficiency(vehicleId?: string) { export function useRouteEfficiency(vehicleId?: string) { return useQuery({ queryKey: drivingKeys.routeEfficiency(vehicleId), - queryFn: () => + queryFn: ({ signal }) => request( vehicleId ? `/analytics/route-efficiency?vehicle_id=${vehicleId}` - : '/analytics/route-efficiency', + : '/analytics/route-efficiency', { signal }, ), enabled: !!vehicleId, }); @@ -170,7 +170,7 @@ export function useRouteEfficiency(vehicleId?: string) { export function useDrivePositions(driveId: string) { return useQuery({ queryKey: ['drive-positions', driveId], - queryFn: () => request(`/drives/${driveId}/positions`), + queryFn: ({ signal }) => request(`/drives/${driveId}/positions`, { signal }), enabled: !!driveId, select: safeArray, }); @@ -179,7 +179,7 @@ export function useDrivePositions(driveId: string) { export function useDriveTelemetry(driveId: string) { return useQuery({ queryKey: ['drive-telemetry', driveId], - queryFn: () => request(`/drives/${driveId}/telemetry`), + queryFn: ({ signal }) => request(`/drives/${driveId}/telemetry`, { signal }), enabled: !!driveId, select: safeArray, }); @@ -188,7 +188,7 @@ export function useDriveTelemetry(driveId: string) { export function useDrivingCoach(vehicleId?: string, days = 30) { return useQuery({ queryKey: drivingKeys.coach(vehicleId, days), - queryFn: () => request(`/analytics/driving-coach?vehicle_id=${vehicleId}&days=${days}`), + queryFn: ({ signal }) => request(`/analytics/driving-coach?vehicle_id=${vehicleId}&days=${days}`, { signal }), enabled: !!vehicleId, staleTime: STALE_TIMES.SLOW, }); @@ -216,7 +216,7 @@ export function usePlanTrip() { export function useGeocodeSearch(query: string, enabled = true) { return useQuery({ queryKey: ['geocode-search', query], - queryFn: () => request(`/geocode/search?q=${encodeURIComponent(query)}&limit=5`), + queryFn: ({ signal }) => request(`/geocode/search?q=${encodeURIComponent(query)}&limit=5`, { signal }), enabled: enabled && query.length >= 3, staleTime: STALE_TIMES.SLOW, select: safeArray, diff --git a/web/src/api/hooks/useEnergy.ts b/web/src/api/hooks/useEnergy.ts index b7c8ddfba5..efa2d3e95f 100644 --- a/web/src/api/hooks/useEnergy.ts +++ b/web/src/api/hooks/useEnergy.ts @@ -27,7 +27,7 @@ import type { export function useEnergyStats(vehicleId: string | null, days = 30) { return useQuery({ queryKey: ['energy-stats', vehicleId, days], - queryFn: () => request(`/vehicles/${vehicleId}/energy?days=${days}`), + queryFn: ({ signal }) => request(`/vehicles/${vehicleId}/energy?days=${days}`, { signal }), enabled: vehicleId !== null, }); } @@ -35,7 +35,7 @@ export function useEnergyStats(vehicleId: string | null, days = 30) { export function useBatteryHealth(vehicleId: string | null) { return useQuery({ queryKey: ['battery-health', vehicleId], - queryFn: () => request(`/vehicles/${vehicleId}/battery`), + queryFn: ({ signal }) => request(`/vehicles/${vehicleId}/battery`, { signal }), enabled: vehicleId !== null, }); } @@ -43,7 +43,7 @@ export function useBatteryHealth(vehicleId: string | null) { export function useBatteryCells(vehicleId: string | null) { return useQuery({ queryKey: ['battery-cells', vehicleId], - queryFn: () => request(`/vehicles/${vehicleId}/battery/cells`), + queryFn: ({ signal }) => request(`/vehicles/${vehicleId}/battery/cells`, { signal }), enabled: vehicleId !== null, retry: false, staleTime: STALE_TIMES.STATIC, @@ -53,7 +53,7 @@ export function useBatteryCells(vehicleId: string | null) { export function useBatteryHealthAnalytics(vehicleId: string | null) { return useQuery({ queryKey: ['battery-health-analytics', vehicleId], - queryFn: () => request(`/analytics/battery-health?vehicle_id=${vehicleId}`), + queryFn: ({ signal }) => request(`/analytics/battery-health?vehicle_id=${vehicleId}`, { signal }), enabled: vehicleId !== null, }); } @@ -61,7 +61,7 @@ export function useBatteryHealthAnalytics(vehicleId: string | null) { export function useBatteryDegradation(vehicleId: string | null) { return useQuery({ queryKey: ['battery-degradation', vehicleId], - queryFn: () => request(`/analytics/battery-degradation?vehicle_id=${vehicleId}`), + queryFn: ({ signal }) => request(`/analytics/battery-degradation?vehicle_id=${vehicleId}`, { signal }), enabled: vehicleId !== null, }); } @@ -69,7 +69,7 @@ export function useBatteryDegradation(vehicleId: string | null) { export function useEnergyFlow(vehicleId: string | null) { return useQuery({ queryKey: ['energy-flow', vehicleId], - queryFn: () => request(`/vehicles/${vehicleId}/energy/flow`), + queryFn: ({ signal }) => request(`/vehicles/${vehicleId}/energy/flow`, { signal }), enabled: vehicleId !== null, refetchInterval: INTERVALS.REALTIME, retry: false, @@ -80,7 +80,7 @@ export function useEnergyFlow(vehicleId: string | null) { export function useVampireDrainStats(vehicleId: string | null) { return useQuery({ queryKey: ['vampire-drain-stats', vehicleId], - queryFn: () => request(`/vampire-drain/stats?vehicle_id=${vehicleId}`), + queryFn: ({ signal }) => request(`/vampire-drain/stats?vehicle_id=${vehicleId}`, { signal }), enabled: vehicleId !== null, }); } @@ -88,7 +88,7 @@ export function useVampireDrainStats(vehicleId: string | null) { export function useVampireDrainEvents(vehicleId: string | null, limit = 50) { return useQuery({ queryKey: ['vampire-drain-events', vehicleId, limit], - queryFn: () => request(`/vampire-drain?vehicle_id=${vehicleId}&limit=${limit}`), + queryFn: ({ signal }) => request(`/vampire-drain?vehicle_id=${vehicleId}&limit=${limit}`, { signal }), enabled: vehicleId !== null, select: safeArray, }); @@ -97,7 +97,7 @@ export function useVampireDrainEvents(vehicleId: string | null, limit = 50) { export function useProjectedRange(vehicleId: string | null) { return useQuery({ queryKey: ['projected-range', vehicleId], - queryFn: () => request(`/vehicles/${vehicleId}/battery/projected-range`), + queryFn: ({ signal }) => request(`/vehicles/${vehicleId}/battery/projected-range`, { signal }), enabled: vehicleId !== null, retry: false, staleTime: STALE_TIMES.STATIC, @@ -107,7 +107,7 @@ export function useProjectedRange(vehicleId: string | null) { export function useSleepEfficiency(vehicleId: string | null, days = 30) { return useQuery({ queryKey: ['sleep-efficiency', vehicleId, days], - queryFn: () => request(`/analytics/sleep?vehicle_id=${vehicleId}&days=${days}`), + queryFn: ({ signal }) => request(`/analytics/sleep?vehicle_id=${vehicleId}&days=${days}`, { signal }), enabled: vehicleId !== null, }); } @@ -119,7 +119,7 @@ export function useSleepEfficiency(vehicleId: string | null, days = 30) { export function useTeslaEnergySites() { return useQuery({ queryKey: ['tesla-energy-sites'], - queryFn: () => request('/tesla/energy-sites'), + queryFn: ({ signal }) => request('/tesla/energy-sites', { signal }), staleTime: STALE_TIMES.STANDARD, select: safeArray, }); @@ -142,8 +142,8 @@ export function useRefreshTeslaEnergySites() { export function useTeslaEnergySiteInfo(siteId?: number) { return useQuery({ queryKey: ['tesla-site-info', siteId], - queryFn: () => - request(`/tesla/energy-sites/${siteId}/site-info`), + queryFn: ({ signal }) => + request(`/tesla/energy-sites/${siteId}/site-info`, { signal }), enabled: !!siteId, staleTime: STALE_TIMES.SLOW, }); @@ -200,9 +200,9 @@ export function useTeslaEnergyHistory( return useQuery({ queryKey: ['tesla-energy-history', siteId, period, since, until], - queryFn: () => + queryFn: ({ signal }) => request( - `/tesla/energy-sites/${siteId}/energy-history?${params.toString()}`, + `/tesla/energy-sites/${siteId}/energy-history?${params.toString()}`, { signal }, ), enabled: !!siteId, staleTime: STALE_TIMES.SLOW, @@ -221,9 +221,9 @@ export function useTeslaBackupHistory( return useQuery({ queryKey: ['tesla-backup-history', siteId, since, until], - queryFn: () => + queryFn: ({ signal }) => request( - `/tesla/energy-sites/${siteId}/backup-history?${params.toString()}`, + `/tesla/energy-sites/${siteId}/backup-history?${params.toString()}`, { signal }, ), enabled: !!siteId, staleTime: STALE_TIMES.SLOW, @@ -242,9 +242,9 @@ export function useTeslaWCChargingHistory( return useQuery({ queryKey: ['tesla-wc-charging-history', siteId, since, until], - queryFn: () => + queryFn: ({ signal }) => request( - `/tesla/energy-sites/${siteId}/charging-history?${params.toString()}`, + `/tesla/energy-sites/${siteId}/charging-history?${params.toString()}`, { signal }, ), enabled: !!siteId, staleTime: STALE_TIMES.SLOW, @@ -333,8 +333,8 @@ export function useRefreshTeslaWCChargingHistory() { export function useTeslaEnergyLiveStatus(siteId?: number) { return useQuery({ queryKey: ['tesla-live-status', siteId], - queryFn: () => - request(`/tesla/energy-sites/${siteId}/live-status`), + queryFn: ({ signal }) => + request(`/tesla/energy-sites/${siteId}/live-status`, { signal }), enabled: !!siteId, refetchInterval: INTERVALS.STANDARD, }); @@ -353,9 +353,9 @@ export function useTeslaEnergyLiveStatusHistory( return useQuery({ queryKey: ['tesla-live-status-history', siteId, since, until, limit], - queryFn: () => + queryFn: ({ signal }) => request( - `/tesla/energy-sites/${siteId}/live-status/history?${params.toString()}`, + `/tesla/energy-sites/${siteId}/live-status/history?${params.toString()}`, { signal }, ), enabled: !!siteId, staleTime: STALE_TIMES.STANDARD, diff --git a/web/src/api/hooks/useExports.ts b/web/src/api/hooks/useExports.ts index 7967da07a7..0676f02c75 100644 --- a/web/src/api/hooks/useExports.ts +++ b/web/src/api/hooks/useExports.ts @@ -18,7 +18,7 @@ export const exportKeys = { export function useExports() { return useQuery({ queryKey: exportKeys.all, - queryFn: () => request('/export/jobs'), + queryFn: ({ signal }) => request('/export/jobs', { signal }), select: safeArray, }); } @@ -47,7 +47,7 @@ export function useExportJobs(opts?: { pollWhileActive?: boolean }) { const pollWhileActive = opts?.pollWhileActive ?? true; return useQuery({ queryKey: exportKeys.jobs, - queryFn: () => request('/export/jobs'), + queryFn: ({ signal }) => request('/export/jobs', { signal }), select: safeArray, refetchInterval: (query) => { if (!pollWhileActive) return false; @@ -62,7 +62,7 @@ export function useExportJobs(opts?: { pollWhileActive?: boolean }) { export function useExportJob(id: string | undefined) { return useQuery({ queryKey: id ? exportKeys.job(id) : exportKeys.jobs, - queryFn: () => request(`/export/jobs/${id}`), + queryFn: ({ signal }) => request(`/export/jobs/${id}`, { signal }), enabled: !!id, refetchInterval: (query) => { const data = query.state.data as ExportJobSummary | undefined; @@ -76,7 +76,7 @@ export function useExportJob(id: string | undefined) { export function useExport(id: string) { return useQuery({ queryKey: exportKeys.detail(id), - queryFn: () => request(`/exports/${id}`), + queryFn: ({ signal }) => request(`/exports/${id}`, { signal }), enabled: !!id, }); } diff --git a/web/src/api/hooks/useFSM.ts b/web/src/api/hooks/useFSM.ts index 983ffdbbc2..4d6a04f9a1 100644 --- a/web/src/api/hooks/useFSM.ts +++ b/web/src/api/hooks/useFSM.ts @@ -17,8 +17,8 @@ export const fsmKeys = { export function useFSMStats(entityId: string) { return useQuery({ queryKey: fsmKeys.stats(entityId), - queryFn: () => - request(`/fsm/stats?vehicle_id=${entityId}`), + queryFn: ({ signal }) => + request(`/fsm/stats?vehicle_id=${entityId}`, { signal }), enabled: !!entityId, refetchInterval: INTERVALS.FAST, }); @@ -34,9 +34,9 @@ export function useFSMTransitions( const typeParam = fsmType === 'all' ? '' : `&fsm_type=${fsmType}`; return useQuery({ queryKey: fsmKeys.transitions(entityId, fsmType, hours, page, perPage), - queryFn: () => + queryFn: ({ signal }) => request( - `/fsm/transitions?vehicle_id=${entityId}&hours=${hours}&page=${page}&per_page=${perPage}${typeParam}`, + `/fsm/transitions?vehicle_id=${entityId}&hours=${hours}&page=${page}&per_page=${perPage}${typeParam}`, { signal }, ), enabled: !!entityId, refetchInterval: INTERVALS.FAST, diff --git a/web/src/api/hooks/useGuard.ts b/web/src/api/hooks/useGuard.ts index 37ede25f37..9673fe6868 100644 --- a/web/src/api/hooks/useGuard.ts +++ b/web/src/api/hooks/useGuard.ts @@ -53,7 +53,7 @@ export const guardKeys = { export function useGuardConfig(vehicleId: number) { return useQuery({ queryKey: guardKeys.config(vehicleId), - queryFn: () => request(`/vehicles/${vehicleId}/guard`), + queryFn: ({ signal }) => request(`/vehicles/${vehicleId}/guard`, { signal }), enabled: vehicleId > 0, staleTime: STALE_TIMES.REALTIME, refetchInterval: INTERVALS.REALTIME, @@ -63,7 +63,7 @@ export function useGuardConfig(vehicleId: number) { export function useGuardEvents(vehicleId: number) { return useQuery({ queryKey: guardKeys.events(vehicleId), - queryFn: () => request(`/vehicles/${vehicleId}/guard/events`), + queryFn: ({ signal }) => request(`/vehicles/${vehicleId}/guard/events`, { signal }), enabled: vehicleId > 0, staleTime: STALE_TIMES.QUICK, }); diff --git a/web/src/api/hooks/useLocations.ts b/web/src/api/hooks/useLocations.ts index 903a38ee98..ef357a06aa 100644 --- a/web/src/api/hooks/useLocations.ts +++ b/web/src/api/hooks/useLocations.ts @@ -13,8 +13,8 @@ export const locationKeys = { export function useLocations(vehicleId?: string) { return useQuery({ queryKey: locationKeys.all(vehicleId), - queryFn: () => request( - vehicleId ? `/locations?vehicle_id=${vehicleId}` : '/locations', + queryFn: ({ signal }) => request( + vehicleId ? `/locations?vehicle_id=${vehicleId}` : '/locations', { signal }, ), enabled: !!vehicleId, select: safeArray, @@ -24,7 +24,7 @@ export function useLocations(vehicleId?: string) { export function useGeofences() { return useQuery({ queryKey: locationKeys.geofences, - queryFn: () => request('/geofences'), + queryFn: ({ signal }) => request('/geofences', { signal }), select: safeArray, }); } diff --git a/web/src/api/hooks/useNotifications.ts b/web/src/api/hooks/useNotifications.ts index 6346855144..cd565db7a1 100644 --- a/web/src/api/hooks/useNotifications.ts +++ b/web/src/api/hooks/useNotifications.ts @@ -106,7 +106,7 @@ export const __serializeNotificationFiltersForTest = serializeNotificationFilter export function useAlerts() { return useQuery({ queryKey: notificationKeys.alerts, - queryFn: () => request('/alerts'), + queryFn: ({ signal }) => request('/alerts', { signal }), refetchInterval: INTERVALS.STANDARD, select: safeArray, }); @@ -135,7 +135,7 @@ export function useMarkAlertRead() { export function useAlertRules() { return useQuery({ queryKey: notificationKeys.alertRules, - queryFn: () => request('/alerts/rules'), + queryFn: ({ signal }) => request('/alerts/rules', { signal }), select: safeArray, }); } @@ -148,7 +148,7 @@ export function useAlertRules() { export function useAlertMetrics() { return useQuery({ queryKey: notificationKeys.alertMetrics, - queryFn: () => request('/alerts/metrics'), + queryFn: ({ signal }) => request('/alerts/metrics', { signal }), select: safeArray, staleTime: INTERVALS.STATIC, }); @@ -343,7 +343,7 @@ export function useSnoozeAlertRule() { export function useNotificationChannels() { return useQuery({ queryKey: notificationKeys.channels, - queryFn: () => request('/notifications'), + queryFn: ({ signal }) => request('/notifications', { signal }), select: safeArray, }); } @@ -355,7 +355,7 @@ export function useNotificationLogs(filters: NotificationFilters = {}) { // (the latter is the historical alias used by older widgets). return useQuery({ queryKey: notificationKeys.logsFiltered(filters), - queryFn: () => request(`/notifications/logs${qs ? `?${qs}` : ''}`), + queryFn: ({ signal }) => request(`/notifications/logs${qs ? `?${qs}` : ''}`, { signal }), select: safeArray, }); } @@ -363,7 +363,7 @@ export function useNotificationLogs(filters: NotificationFilters = {}) { export function useUnreadCount() { return useQuery({ queryKey: notificationKeys.unreadCount, - queryFn: () => request<{ count: number }>('/notifications/unread-count'), + queryFn: ({ signal }) => request<{ count: number }>('/notifications/unread-count', { signal }), refetchInterval: INTERVALS.STANDARD, select: (data) => data?.count ?? 0, }); @@ -589,7 +589,7 @@ export function useDeleteNotifications() { export function useNotificationStats() { return useQuery({ queryKey: notificationKeys.stats, - queryFn: () => request('/notifications/stats'), + queryFn: ({ signal }) => request('/notifications/stats', { signal }), refetchInterval: INTERVALS.STANDARD, }); } diff --git a/web/src/api/hooks/useOnboarding.ts b/web/src/api/hooks/useOnboarding.ts index ee70e7aca7..b89c48f4f1 100644 --- a/web/src/api/hooks/useOnboarding.ts +++ b/web/src/api/hooks/useOnboarding.ts @@ -36,7 +36,7 @@ export const onboardingKeys = { export function useOnboardingStatus() { return useQuery({ queryKey: onboardingKeys.status, - queryFn: () => request('/onboarding/status'), + queryFn: ({ signal }) => request('/onboarding/status', { signal }), // Stop polling once setup is complete; the gate flips to "pass" // and re-checks only on full app reload. refetchInterval: (query) => diff --git a/web/src/api/hooks/usePinned.ts b/web/src/api/hooks/usePinned.ts index 42a2e9e1ec..67f29f8041 100644 --- a/web/src/api/hooks/usePinned.ts +++ b/web/src/api/hooks/usePinned.ts @@ -37,7 +37,7 @@ function buildQuery(type: PinnedItemType, context?: string): string { export function usePinned(type: PinnedItemType, context?: string) { return useQuery({ queryKey: pinnedKeys.list(type, context), - queryFn: () => request(`/pinned${buildQuery(type, context)}`), + queryFn: ({ signal }) => request(`/pinned${buildQuery(type, context)}`, { signal }), staleTime: STALE_TIMES.SLOW, }); } diff --git a/web/src/api/hooks/usePush.ts b/web/src/api/hooks/usePush.ts index 932fd06b82..78353ef4f1 100644 --- a/web/src/api/hooks/usePush.ts +++ b/web/src/api/hooks/usePush.ts @@ -30,9 +30,9 @@ export const pushKeys = { export function usePushPublicKey() { return useQuery({ queryKey: pushKeys.publicKey, - queryFn: async (): Promise => { + queryFn: async ({ signal }): Promise => { try { - const res = await request<{ publicKey: string }>('/push/public-key'); + const res = await request<{ publicKey: string }>('/push/public-key', { signal }); return res.publicKey || null; } catch (err) { // 404 from the server means VAPID is unconfigured — surface as @@ -58,7 +58,7 @@ export function usePushPublicKey() { export function usePushSubscriptions() { return useQuery({ queryKey: pushKeys.list, - queryFn: () => request('/push/subscribe'), + queryFn: ({ signal }) => request('/push/subscribe', { signal }), staleTime: STALE_TIMES.STANDARD, }); } diff --git a/web/src/api/hooks/useSavedViews.ts b/web/src/api/hooks/useSavedViews.ts index 002259ecd8..b5bf7fb970 100644 --- a/web/src/api/hooks/useSavedViews.ts +++ b/web/src/api/hooks/useSavedViews.ts @@ -40,7 +40,7 @@ function buildQuery(route: string): string { export function useSavedViews(route: string) { return useQuery({ queryKey: savedViewsKeys.list(route), - queryFn: () => request(`/saved-views${buildQuery(route)}`), + queryFn: ({ signal }) => request(`/saved-views${buildQuery(route)}`, { signal }), staleTime: STALE_TIMES.STANDARD, }); } diff --git a/web/src/api/hooks/useSearch.ts b/web/src/api/hooks/useSearch.ts index 3abd549a0e..ef72efe104 100644 --- a/web/src/api/hooks/useSearch.ts +++ b/web/src/api/hooks/useSearch.ts @@ -35,7 +35,7 @@ export function useGlobalSearch(query: string, options: UseGlobalSearchOptions = const enabled = !options.disabled && trimmed.length >= SEARCH_MIN_QUERY_LENGTH; return useQuery({ queryKey: searchKeys.global(trimmed, options.types, options.limit), - queryFn: () => { + queryFn: ({ signal }) => { const params = new URLSearchParams({ q: trimmed }); if (options.types && options.types.length > 0) { params.set('types', options.types.join(',')); @@ -43,7 +43,7 @@ export function useGlobalSearch(query: string, options: UseGlobalSearchOptions = if (options.limit && options.limit > 0) { params.set('limit', String(options.limit)); } - return request(`/search?${params.toString()}`); + return request(`/search?${params.toString()}`, { signal }); }, enabled, staleTime: STALE_TIMES.FAST, diff --git a/web/src/api/hooks/useSettings.ts b/web/src/api/hooks/useSettings.ts index 66354d5754..a46eae2f8f 100644 --- a/web/src/api/hooks/useSettings.ts +++ b/web/src/api/hooks/useSettings.ts @@ -20,7 +20,7 @@ export const settingsKeys = { export function useSettings() { return useQuery({ queryKey: settingsKeys.settings, - queryFn: () => request('/settings'), + queryFn: ({ signal }) => request('/settings', { signal }), }); } @@ -52,7 +52,7 @@ interface AuthStatus { export function useAuthStatus() { return useQuery({ queryKey: settingsKeys.authStatus, - queryFn: () => request('/auth/status'), + queryFn: ({ signal }) => request('/auth/status', { signal }), }); } @@ -104,7 +104,7 @@ interface Vehicle { export function useVehicles() { return useQuery({ queryKey: settingsKeys.vehicles, - queryFn: () => request('/vehicles'), + queryFn: ({ signal }) => request('/vehicles', { signal }), select: safeArray, }); } @@ -131,7 +131,7 @@ interface UserPreferenceLatest { export function useCarPreferences(vehicleId: number | null) { return useQuery({ queryKey: settingsKeys.carPrefs(vehicleId), - queryFn: () => request(`/user-preferences/latest?vehicle_id=${vehicleId}`), + queryFn: ({ signal }) => request(`/user-preferences/latest?vehicle_id=${vehicleId}`, { signal }), enabled: vehicleId !== null, }); } @@ -141,7 +141,7 @@ export function useCarPreferences(vehicleId: number | null) { export function useGasPriceStatus(enabled = true) { return useQuery({ queryKey: settingsKeys.gasPriceStatus, - queryFn: () => request('/gas-price/status'), + queryFn: ({ signal }) => request('/gas-price/status', { signal }), enabled, retry: false, refetchInterval: enabled ? INTERVALS.STANDARD : false, @@ -208,7 +208,7 @@ export interface DashboardLayoutsPayload { export function useDashboardLayouts() { return useQuery({ queryKey: settingsKeys.dashboardLayouts, - queryFn: () => request('/settings/dashboard-layouts'), + queryFn: ({ signal }) => request('/settings/dashboard-layouts', { signal }), staleTime: STALE_TIMES.SLOW, retry: 1, }); @@ -282,7 +282,7 @@ export interface PollingConfig { export function usePollingConfig() { return useQuery({ queryKey: ['polling-config'] as const, - queryFn: () => request('/settings/polling-config'), + queryFn: ({ signal }) => request('/settings/polling-config', { signal }), staleTime: STALE_TIMES.SLOW, }); } @@ -315,7 +315,7 @@ interface CaptureStats { export function useCaptureStats() { return useQuery({ queryKey: ['capture-stats'] as const, - queryFn: () => request('/dev-tools/telemetry-capture/stats'), + queryFn: ({ signal }) => request('/dev-tools/telemetry-capture/stats', { signal }), staleTime: STALE_TIMES.FAST, }); } @@ -331,7 +331,7 @@ interface VersionInfo { export function useVersionInfo() { return useQuery({ queryKey: ['version'] as const, - queryFn: () => request('/system/version'), + queryFn: ({ signal }) => request('/system/version', { signal }), staleTime: STALE_TIMES.STANDARD, }); } diff --git a/web/src/api/hooks/useSharing.ts b/web/src/api/hooks/useSharing.ts index cd4d3b76c2..65e7b0acf8 100644 --- a/web/src/api/hooks/useSharing.ts +++ b/web/src/api/hooks/useSharing.ts @@ -38,7 +38,7 @@ export function useCreateShareLink(driveId: string) { export function useShareLinks(driveId: string) { return useQuery({ queryKey: sharingKeys.shares(driveId), - queryFn: () => request(`/drives/${driveId}/shares`), + queryFn: ({ signal }) => request(`/drives/${driveId}/shares`, { signal }), enabled: !!driveId, }); } @@ -68,7 +68,7 @@ export function useRevokeShareLink(driveId: string) { export function useSharedDrive(token: string) { return useQuery({ queryKey: sharingKeys.shared(token), - queryFn: () => request(`/share/${token}`), + queryFn: ({ signal }) => request(`/share/${token}`, { signal }), enabled: !!token, retry: false, staleTime: STALE_TIMES.SLOW, diff --git a/web/src/api/hooks/useTelemetry.ts b/web/src/api/hooks/useTelemetry.ts index 3c56804683..672d372ed8 100644 --- a/web/src/api/hooks/useTelemetry.ts +++ b/web/src/api/hooks/useTelemetry.ts @@ -34,8 +34,8 @@ export interface VehicleLiveSignalsResponse { export function useSignals(vehicleId: number) { return useQuery({ queryKey: telemetryKeys.signals(vehicleId), - queryFn: async () => { - const resp = await request<{ signals?: string[] } | string[]>(`/signals/${vehicleId}/available`); + queryFn: async ({ signal }) => { + const resp = await request<{ signals?: string[] } | string[]>(`/signals/${vehicleId}/available`, { signal }); // API wraps signals in { signals: [...] } but old code expected string[] if (Array.isArray(resp)) return resp; return (resp as { signals?: string[] }).signals ?? []; @@ -46,14 +46,19 @@ export function useSignals(vehicleId: number) { }); } -export function getVehicleLiveSignals(vehicleId: number) { - return request(`/signals/${vehicleId}/live`); +export function getVehicleLiveSignals( + vehicleId: number, + opts?: { signal?: AbortSignal | null }, +) { + return request(`/signals/${vehicleId}/live`, { + signal: opts?.signal, + }); } export function useVehicleLiveSignals(vehicleId?: number) { return useQuery({ queryKey: telemetryKeys.liveSignals(vehicleId), - queryFn: () => getVehicleLiveSignals(vehicleId ?? 0), + queryFn: ({ signal }) => getVehicleLiveSignals(vehicleId ?? 0, { signal }), enabled: !!vehicleId, staleTime: STALE_TIMES.REALTIME, retry: 1, @@ -63,7 +68,7 @@ export function useVehicleLiveSignals(vehicleId?: number) { export function useSignalStats(vehicleId: number) { return useQuery({ queryKey: telemetryKeys.signalStats(vehicleId), - queryFn: () => request(`/signals/${vehicleId}/stats`), + queryFn: ({ signal }) => request(`/signals/${vehicleId}/stats`, { signal }), enabled: vehicleId > 0, }); } @@ -71,7 +76,7 @@ export function useSignalStats(vehicleId: number) { export function useSignalHistory(vehicleId: number, signal: string, hours: number) { return useQuery({ queryKey: telemetryKeys.signalHistory(vehicleId, signal, hours), - queryFn: () => request(`/signals/${vehicleId}/${signal}/history?hours=${hours}`), + queryFn: ({ signal }) => request(`/signals/${vehicleId}/${signal}/history?hours=${hours}`, { signal }), enabled: vehicleId > 0 && !!signal, refetchInterval: INTERVALS.STANDARD, }); @@ -80,9 +85,9 @@ export function useSignalHistory(vehicleId: number, signal: string, hours: numbe export function useSignalLog(vehicleId: number, signal: string, hours: number, page: number, pageSize: number) { return useQuery({ queryKey: telemetryKeys.signalLog(vehicleId, signal, hours, page), - queryFn: () => + queryFn: ({ signal }) => request( - `/signals/${vehicleId}/${signal}/history?hours=${hours}&page=${page}&page_size=${pageSize}` + `/signals/${vehicleId}/${signal}/history?hours=${hours}&page=${page}&page_size=${pageSize}`, { signal } ), enabled: vehicleId > 0 && !!signal, }); @@ -91,8 +96,8 @@ export function useSignalLog(vehicleId: number, signal: string, hours: number, p export function useSignalDiff(vehicleId: number, signal: string, from: string, to: string) { return useQuery({ queryKey: telemetryKeys.signalDiff(vehicleId, signal, from, to), - queryFn: () => - request(`/signals/${vehicleId}/${signal}/history?from=${from}&to=${to}`), + queryFn: ({ signal }) => + request(`/signals/${vehicleId}/${signal}/history?from=${from}&to=${to}`, { signal }), enabled: vehicleId > 0 && !!signal && !!from && !!to, }); } @@ -147,13 +152,13 @@ export function useSignalSnapshot( ) { return useQuery({ queryKey: telemetryKeys.signalSnapshot(vehicleId, at, signalsCsv), - queryFn: () => { + queryFn: ({ signal }) => { const usp = new URLSearchParams(); if (at) usp.set('at', at); if (signalsCsv) usp.set('signals', signalsCsv); const qs = usp.toString(); return request( - `/signals/${vehicleId}/snapshot${qs ? `?${qs}` : ''}`, + `/signals/${vehicleId}/snapshot${qs ? `?${qs}` : ''}`, { signal }, ); }, enabled: (options?.enabled ?? true) && vehicleId > 0, @@ -175,13 +180,13 @@ export function useSignalDiffServer( ) { return useQuery({ queryKey: telemetryKeys.signalDiffServer(vehicleId, atA, atB, signalsCsv), - queryFn: () => { + queryFn: ({ signal }) => { const usp = new URLSearchParams(); if (atA) usp.set('at_a', atA); if (atB) usp.set('at_b', atB); if (signalsCsv) usp.set('signals', signalsCsv); return request( - `/signals/${vehicleId}/diff?${usp.toString()}`, + `/signals/${vehicleId}/diff?${usp.toString()}`, { signal }, ); }, enabled: (options?.enabled ?? true) && vehicleId > 0 && !!atA && !!atB, @@ -192,8 +197,8 @@ export function useSignalDiffServer( export function useSignalGaps(vehicleId: number) { return useQuery({ queryKey: telemetryKeys.signalGaps(vehicleId), - queryFn: async () => { - const res = await request<{ signals?: Record }>(`/signals/${vehicleId}/live`); + queryFn: async ({ signal }) => { + const res = await request<{ signals?: Record }>(`/signals/${vehicleId}/live`, { signal }); return res.signals ?? {}; }, enabled: vehicleId > 0, @@ -204,8 +209,8 @@ export function useSignalGaps(vehicleId: number) { export function useMQTTStatus() { return useQuery({ queryKey: telemetryKeys.mqttStatus, - queryFn: async () => { - const raw = await request('/telemetry'); + queryFn: async ({ signal }) => { + const raw = await request('/telemetry', { signal }); // Backend returns vehicles as Record. // Normalize to array for the page. const vehiclesRaw = raw.vehicles ?? raw.streaming_vehicles; @@ -237,7 +242,7 @@ export function useMQTTStatus() { export function useSignalCatalog() { return useQuery({ queryKey: ['signal-catalog'], - queryFn: () => request('/signals/catalog'), + queryFn: ({ signal }) => request('/signals/catalog', { signal }), staleTime: STALE_TIMES.SLOW, }); } @@ -255,7 +260,7 @@ export function useSignalObservations( return useQuery({ queryKey: ['signal-observations', vehicleId, opts], - queryFn: () => request(`/signals/observations?${params}`), + queryFn: ({ signal }) => request(`/signals/observations?${params}`, { signal }), enabled: !!vehicleId, staleTime: STALE_TIMES.REALTIME, }); @@ -287,7 +292,7 @@ export interface FleetTelemetryError { export function useFleetTelemetryErrorVINs() { return useQuery({ queryKey: ['fleet-telemetry-error-vins'], - queryFn: () => request('/tesla/fleet-telemetry/error-vins'), + queryFn: ({ signal }) => request('/tesla/fleet-telemetry/error-vins', { signal }), staleTime: STALE_TIMES.STANDARD, }); } @@ -295,9 +300,9 @@ export function useFleetTelemetryErrorVINs() { export function useFleetTelemetryErrors(vin?: string) { return useQuery({ queryKey: ['fleet-telemetry-errors', vin], - queryFn: () => + queryFn: ({ signal }) => request( - `/tesla/fleet-telemetry/errors${vin ? `?vin=${vin}` : ''}` + `/tesla/fleet-telemetry/errors${vin ? `?vin=${vin}` : ''}`, { signal } ), staleTime: STALE_TIMES.STANDARD, }); diff --git a/web/src/api/hooks/useTrips.ts b/web/src/api/hooks/useTrips.ts index 979eed5159..bb19be6b2e 100644 --- a/web/src/api/hooks/useTrips.ts +++ b/web/src/api/hooks/useTrips.ts @@ -27,7 +27,7 @@ export function useTrips(params?: UseTripParams) { return useQuery({ queryKey: [...tripKeys.all, params ?? {}], - queryFn: () => request(qs ? `/trips?${qs}` : '/trips'), + queryFn: ({ signal }) => request(qs ? `/trips?${qs}` : '/trips', { signal }), select: safeArray, }); } @@ -35,7 +35,7 @@ export function useTrips(params?: UseTripParams) { export function useTrip(id: string) { return useQuery({ queryKey: tripKeys.detail(id), - queryFn: () => request(`/trips/${id}`), + queryFn: ({ signal }) => request(`/trips/${id}`, { signal }), enabled: !!id, }); } diff --git a/web/src/api/hooks/useUser.ts b/web/src/api/hooks/useUser.ts index 306fa23a95..a0430134a8 100644 --- a/web/src/api/hooks/useUser.ts +++ b/web/src/api/hooks/useUser.ts @@ -19,7 +19,7 @@ export const userKeys = { export function useCurrentUser() { return useQuery({ queryKey: userKeys.me, - queryFn: () => request('/users/me'), + queryFn: ({ signal }) => request('/users/me', { signal }), }); } @@ -70,8 +70,8 @@ function buildActivityQuery(params: MyActivityParams): string { export function useMyRecentActivity(params: MyActivityParams = {}) { return useQuery({ queryKey: userKeys.myActivity(params), - queryFn: () => - request(`/users/me/activity${buildActivityQuery(params)}`), + queryFn: ({ signal }) => + request(`/users/me/activity${buildActivityQuery(params)}`, { signal }), select: safeArray, staleTime: STALE_TIMES.STANDARD, }); @@ -87,7 +87,7 @@ interface TeslaConfigEnvelope> { export function useTeslaFeatureConfig() { return useQuery({ queryKey: userKeys.teslaFeatureConfig, - queryFn: () => request('/tesla/user/feature-config'), + queryFn: ({ signal }) => request('/tesla/user/feature-config', { signal }), staleTime: STALE_TIMES.EXTENDED, }); } @@ -116,7 +116,7 @@ interface TeslaRegionData { export function useTeslaUserRegion() { return useQuery({ queryKey: userKeys.teslaRegion, - queryFn: () => request>('/tesla/user/region'), + queryFn: ({ signal }) => request>('/tesla/user/region', { signal }), staleTime: STALE_TIMES.STATIC, }); } @@ -159,7 +159,7 @@ interface TeslaOrdersEnvelope { export function useTeslaUserOrders() { return useQuery({ queryKey: userKeys.teslaOrders, - queryFn: () => request('/tesla/user/orders'), + queryFn: ({ signal }) => request('/tesla/user/orders', { signal }), staleTime: STALE_TIMES.SLOW, }); } @@ -198,7 +198,7 @@ interface TeslaProfileEnvelope { export function useTeslaUserProfile() { return useQuery({ queryKey: userKeys.teslaProfile, - queryFn: () => request('/tesla/user/profile'), + queryFn: ({ signal }) => request('/tesla/user/profile', { signal }), staleTime: STALE_TIMES.SLOW, }); } diff --git a/web/src/api/hooks/useVehicleAccess.ts b/web/src/api/hooks/useVehicleAccess.ts index f4dd1cb209..7ef42ccec8 100644 --- a/web/src/api/hooks/useVehicleAccess.ts +++ b/web/src/api/hooks/useVehicleAccess.ts @@ -15,7 +15,7 @@ export const vehicleAccessKeys = { export function useVehicleDrivers(vehicleId?: string) { return useQuery({ queryKey: vehicleAccessKeys.drivers(vehicleId ?? ''), - queryFn: () => request(`/vehicles/${vehicleId}/drivers`), + queryFn: ({ signal }) => request(`/vehicles/${vehicleId}/drivers`, { signal }), enabled: !!vehicleId, staleTime: STALE_TIMES.STANDARD, select: safeArray, @@ -25,7 +25,7 @@ export function useVehicleDrivers(vehicleId?: string) { export function useVehicleInvitations(vehicleId?: string) { return useQuery({ queryKey: vehicleAccessKeys.invitations(vehicleId ?? ''), - queryFn: () => request(`/vehicles/${vehicleId}/invitations`), + queryFn: ({ signal }) => request(`/vehicles/${vehicleId}/invitations`, { signal }), enabled: !!vehicleId, staleTime: STALE_TIMES.STANDARD, select: safeArray, diff --git a/web/src/api/hooks/useVehicleSystems.ts b/web/src/api/hooks/useVehicleSystems.ts index 8f5e51c5b9..a9a2d6d85b 100644 --- a/web/src/api/hooks/useVehicleSystems.ts +++ b/web/src/api/hooks/useVehicleSystems.ts @@ -21,7 +21,7 @@ export const vehicleSystemsKeys = { export function useClimate(vehicleId: string) { return useQuery({ queryKey: vehicleSystemsKeys.climate(vehicleId), - queryFn: () => request(`/climate/latest?vehicle_id=${vehicleId}`), + queryFn: ({ signal }) => request(`/climate/latest?vehicle_id=${vehicleId}`, { signal }), enabled: !!vehicleId, refetchInterval: INTERVALS.STANDARD, }); @@ -30,7 +30,7 @@ export function useClimate(vehicleId: string) { export function useClimateHistory(vehicleId: string) { return useQuery({ queryKey: vehicleSystemsKeys.climateHistory(vehicleId), - queryFn: () => request(`/climate?vehicle_id=${vehicleId}`), + queryFn: ({ signal }) => request(`/climate?vehicle_id=${vehicleId}`, { signal }), enabled: !!vehicleId, select: safeArray, }); @@ -39,7 +39,7 @@ export function useClimateHistory(vehicleId: string) { export function useTirePressure(vehicleId: string) { return useQuery({ queryKey: vehicleSystemsKeys.tirePressure(vehicleId), - queryFn: () => request(`/tire-pressure/latest?vehicle_id=${vehicleId}`), + queryFn: ({ signal }) => request(`/tire-pressure/latest?vehicle_id=${vehicleId}`, { signal }), enabled: !!vehicleId, refetchInterval: INTERVALS.STANDARD, }); @@ -48,7 +48,7 @@ export function useTirePressure(vehicleId: string) { export function useTirePressureHistory(vehicleId: string) { return useQuery({ queryKey: vehicleSystemsKeys.tirePressureHistory(vehicleId), - queryFn: () => request(`/tire-pressure?vehicle_id=${vehicleId}`), + queryFn: ({ signal }) => request(`/tire-pressure?vehicle_id=${vehicleId}`, { signal }), enabled: !!vehicleId, select: safeArray, }); @@ -57,7 +57,7 @@ export function useTirePressureHistory(vehicleId: string) { export function useMaintenance() { return useQuery({ queryKey: vehicleSystemsKeys.maintenance, - queryFn: () => request('/maintenance'), + queryFn: ({ signal }) => request('/maintenance', { signal }), retry: false, staleTime: STALE_TIMES.STATIC, select: safeArray, @@ -67,7 +67,7 @@ export function useMaintenance() { export function useServiceRecords() { return useQuery({ queryKey: vehicleSystemsKeys.serviceRecords, - queryFn: () => request('/maintenance/records'), + queryFn: ({ signal }) => request('/maintenance/records', { signal }), retry: false, staleTime: STALE_TIMES.STATIC, select: safeArray, @@ -77,7 +77,7 @@ export function useServiceRecords() { export function useSoftwareUpdates(vehicleId: string) { return useQuery({ queryKey: vehicleSystemsKeys.softwareUpdates(vehicleId), - queryFn: () => request('/software-updates'), + queryFn: ({ signal }) => request('/software-updates', { signal }), enabled: !!vehicleId, select: safeArray, }); @@ -86,7 +86,7 @@ export function useSoftwareUpdates(vehicleId: string) { export function useSafety(vehicleId: string) { return useQuery({ queryKey: vehicleSystemsKeys.safety(vehicleId), - queryFn: () => request(`/safety/latest?vehicle_id=${vehicleId}`), + queryFn: ({ signal }) => request(`/safety/latest?vehicle_id=${vehicleId}`, { signal }), enabled: !!vehicleId, refetchInterval: INTERVALS.STANDARD, }); @@ -95,7 +95,7 @@ export function useSafety(vehicleId: string) { export function useSafetyHistory(vehicleId: string) { return useQuery({ queryKey: vehicleSystemsKeys.safetyHistory(vehicleId), - queryFn: () => request(`/safety?vehicle_id=${vehicleId}`), + queryFn: ({ signal }) => request(`/safety?vehicle_id=${vehicleId}`, { signal }), enabled: !!vehicleId, select: safeArray, }); @@ -104,7 +104,7 @@ export function useSafetyHistory(vehicleId: string) { export function useMedia(vehicleId: string) { return useQuery({ queryKey: vehicleSystemsKeys.media(vehicleId), - queryFn: () => request(`/media/latest?vehicle_id=${vehicleId}`), + queryFn: ({ signal }) => request(`/media/latest?vehicle_id=${vehicleId}`, { signal }), enabled: !!vehicleId, refetchInterval: INTERVALS.STANDARD, }); @@ -113,7 +113,7 @@ export function useMedia(vehicleId: string) { export function useMediaHistory(vehicleId: string) { return useQuery({ queryKey: vehicleSystemsKeys.mediaHistory(vehicleId), - queryFn: () => request(`/media?vehicle_id=${vehicleId}`), + queryFn: ({ signal }) => request(`/media?vehicle_id=${vehicleId}`, { signal }), enabled: !!vehicleId, select: safeArray, }); diff --git a/web/src/api/hooks/useVehicles.ts b/web/src/api/hooks/useVehicles.ts index 5ae31da24d..22639fd917 100644 --- a/web/src/api/hooks/useVehicles.ts +++ b/web/src/api/hooks/useVehicles.ts @@ -18,7 +18,7 @@ export const vehicleKeys = { export function useVehicles() { return useQuery({ queryKey: vehicleKeys.all, - queryFn: () => request('/vehicles'), + queryFn: ({ signal }) => request('/vehicles', { signal }), staleTime: STALE_TIMES.FAST, select: safeArray, }); @@ -31,7 +31,7 @@ export function useVehicles() { export function useVehicle(id: string) { return useQuery({ queryKey: vehicleKeys.detail(id), - queryFn: () => request(`/vehicles/${id}`), + queryFn: ({ signal }) => request(`/vehicles/${id}`, { signal }), enabled: !!id, }); } @@ -39,9 +39,9 @@ export function useVehicle(id: string) { export function useVehicleState(vehicleId: number, options?: { refetchInterval?: number }) { return useQuery({ queryKey: vehicleKeys.state(vehicleId), - queryFn: async () => { + queryFn: async ({ signal }) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any - const res = await request(`/vehicles/${vehicleId}/state`) + const res = await request(`/vehicles/${vehicleId}/state`, { signal }) if (res.state && typeof res.state === 'object' && 'vehicle_id' in res.state) { return { state: res.state as VehicleState, live: res.live ?? false } } @@ -80,7 +80,7 @@ export function useVehicleState(vehicleId: number, options?: { refetchInterval?: export function useVehiclePositions(vehicleId: number, limit = 100) { return useQuery({ queryKey: vehicleKeys.positions(vehicleId), - queryFn: () => request(`/vehicles/${vehicleId}/positions?limit=${limit}`), + queryFn: ({ signal }) => request(`/vehicles/${vehicleId}/positions?limit=${limit}`, { signal }), enabled: vehicleId > 0, select: safeArray, }); @@ -141,7 +141,7 @@ export function useWakeVehicle() { export function useMotorLatest(vehicleId: number, refetchInterval?: number) { return useQuery({ queryKey: ['motor-latest', vehicleId], - queryFn: () => request(`/motor/latest?vehicle_id=${vehicleId}`), + queryFn: ({ signal }) => request(`/motor/latest?vehicle_id=${vehicleId}`, { signal }), enabled: vehicleId > 0, refetchInterval, }); @@ -150,7 +150,7 @@ export function useMotorLatest(vehicleId: number, refetchInterval?: number) { export function useMotorHistory(vehicleId: number, limit = 200) { return useQuery({ queryKey: ['motor-history', vehicleId, limit], - queryFn: () => request(`/motor?vehicle_id=${vehicleId}&limit=${limit}`), + queryFn: ({ signal }) => request(`/motor?vehicle_id=${vehicleId}&limit=${limit}`, { signal }), enabled: vehicleId > 0, select: safeArray, }); @@ -159,7 +159,7 @@ export function useMotorHistory(vehicleId: number, limit = 200) { export function useClimateLatest(vehicleId: number, refetchInterval?: number) { return useQuery({ queryKey: ['climate-latest', vehicleId], - queryFn: () => request(`/climate/latest?vehicle_id=${vehicleId}`), + queryFn: ({ signal }) => request(`/climate/latest?vehicle_id=${vehicleId}`, { signal }), enabled: vehicleId > 0, refetchInterval, }); @@ -168,7 +168,7 @@ export function useClimateLatest(vehicleId: number, refetchInterval?: number) { export function useSecurityLatest(vehicleId: number, refetchInterval?: number) { return useQuery({ queryKey: ['security-latest', vehicleId], - queryFn: () => request(`/security/latest?vehicle_id=${vehicleId}`), + queryFn: ({ signal }) => request(`/security/latest?vehicle_id=${vehicleId}`, { signal }), enabled: vehicleId > 0, refetchInterval, }); @@ -177,7 +177,7 @@ export function useSecurityLatest(vehicleId: number, refetchInterval?: number) { export function useLatestTirePressure(vehicleId: number, refetchInterval?: number) { return useQuery({ queryKey: ['tire-latest', vehicleId], - queryFn: () => request(`/tire-pressure/latest?vehicle_id=${vehicleId}`), + queryFn: ({ signal }) => request(`/tire-pressure/latest?vehicle_id=${vehicleId}`, { signal }), enabled: vehicleId > 0, refetchInterval, }); @@ -186,7 +186,7 @@ export function useLatestTirePressure(vehicleId: number, refetchInterval?: numbe export function useChargingTelemetryLatest(vehicleId: number, refetchInterval?: number) { return useQuery({ queryKey: ['charging-telemetry-latest', vehicleId], - queryFn: () => request(`/charging-telemetry/latest?vehicle_id=${vehicleId}`), + queryFn: ({ signal }) => request(`/charging-telemetry/latest?vehicle_id=${vehicleId}`, { signal }), enabled: vehicleId > 0, refetchInterval, }); @@ -195,7 +195,7 @@ export function useChargingTelemetryLatest(vehicleId: number, refetchInterval?: export function useMediaLatest(vehicleId: number, refetchInterval?: number) { return useQuery({ queryKey: ['media-latest', vehicleId], - queryFn: () => request(`/media/latest?vehicle_id=${vehicleId}`), + queryFn: ({ signal }) => request(`/media/latest?vehicle_id=${vehicleId}`, { signal }), enabled: vehicleId > 0, refetchInterval, }); @@ -204,7 +204,7 @@ export function useMediaLatest(vehicleId: number, refetchInterval?: number) { export function useLocationSnapshotLatest(vehicleId: number, refetchInterval?: number) { return useQuery({ queryKey: ['location-latest', vehicleId], - queryFn: () => request(`/location-snapshots/latest?vehicle_id=${vehicleId}`), + queryFn: ({ signal }) => request(`/location-snapshots/latest?vehicle_id=${vehicleId}`, { signal }), enabled: vehicleId > 0, refetchInterval, }); @@ -213,7 +213,7 @@ export function useLocationSnapshotLatest(vehicleId: number, refetchInterval?: n export function useVehicleConfigLatest(vehicleId: number, refetchInterval?: number) { return useQuery({ queryKey: ['vehicle-config-latest', vehicleId], - queryFn: () => request(`/vehicle-config/latest?vehicle_id=${vehicleId}`), + queryFn: ({ signal }) => request(`/vehicle-config/latest?vehicle_id=${vehicleId}`, { signal }), enabled: vehicleId > 0, refetchInterval, }); @@ -222,7 +222,7 @@ export function useVehicleConfigLatest(vehicleId: number, refetchInterval?: numb export function useUserPreferenceLatest(vehicleId: number, refetchInterval?: number) { return useQuery({ queryKey: ['user-pref-latest', vehicleId], - queryFn: () => request(`/user-preferences/latest?vehicle_id=${vehicleId}`), + queryFn: ({ signal }) => request(`/user-preferences/latest?vehicle_id=${vehicleId}`, { signal }), enabled: vehicleId > 0, refetchInterval, }); @@ -277,7 +277,7 @@ interface MobileEnabledData { export function useVehicleMobileEnabled(vehicleId?: string) { return useQuery({ queryKey: ['vehicle-mobile-enabled', vehicleId], - queryFn: () => request>(`/vehicles/${vehicleId}/mobile-enabled`), + queryFn: ({ signal }) => request>(`/vehicles/${vehicleId}/mobile-enabled`, { signal }), enabled: !!vehicleId, staleTime: STALE_TIMES.SLOW, }); @@ -299,7 +299,7 @@ export function useRefreshVehicleMobileEnabled(vehicleId?: string) { export function useVehicleOptions(vehicleId?: string) { return useQuery({ queryKey: ['vehicle-options', vehicleId], - queryFn: () => request>>(`/vehicles/${vehicleId}/options`), + queryFn: ({ signal }) => request>>(`/vehicles/${vehicleId}/options`, { signal }), enabled: !!vehicleId, staleTime: STALE_TIMES.STATIC, }); @@ -321,7 +321,7 @@ export function useRefreshVehicleOptions(vehicleId?: string) { export function useVehicleSpecs(vehicleId?: string) { return useQuery({ queryKey: ['vehicle-specs', vehicleId], - queryFn: () => request>>(`/vehicles/${vehicleId}/specs`), + queryFn: ({ signal }) => request>>(`/vehicles/${vehicleId}/specs`, { signal }), enabled: !!vehicleId, staleTime: STALE_TIMES.STATIC, }); @@ -345,7 +345,7 @@ export function useRefreshVehicleSpecs(vehicleId?: string) { export function useVehicleSubscriptions(vehicleId?: string) { return useQuery({ queryKey: ['vehicle-subscriptions', vehicleId], - queryFn: () => request>>(`/vehicles/${vehicleId}/subscriptions`), + queryFn: ({ signal }) => request>>(`/vehicles/${vehicleId}/subscriptions`, { signal }), enabled: !!vehicleId, staleTime: STALE_TIMES.RARE, }); @@ -369,7 +369,7 @@ export function useRefreshVehicleSubscriptions(vehicleId?: string) { export function useVehicleUpgrades(vehicleId?: string) { return useQuery({ queryKey: ['vehicle-upgrades', vehicleId], - queryFn: () => request>>(`/vehicles/${vehicleId}/upgrades`), + queryFn: ({ signal }) => request>>(`/vehicles/${vehicleId}/upgrades`, { signal }), enabled: !!vehicleId, staleTime: STALE_TIMES.RARE, }); @@ -393,7 +393,7 @@ export function useRefreshVehicleUpgrades(vehicleId?: string) { export function useWarrantyDetails() { return useQuery({ queryKey: ['warranty-details'], - queryFn: () => request>>('/tesla/warranty'), + queryFn: ({ signal }) => request>>('/tesla/warranty', { signal }), staleTime: STALE_TIMES.DAILY, }); } diff --git a/web/src/api/hooks/useWatch.ts b/web/src/api/hooks/useWatch.ts index e85a6b8654..fdb3198879 100644 --- a/web/src/api/hooks/useWatch.ts +++ b/web/src/api/hooks/useWatch.ts @@ -78,7 +78,7 @@ export function useWatchSummary(vehicleId?: number) { const params = vehicleId ? `?vehicle_id=${vehicleId}` : ''; return useQuery({ queryKey: watchKeys.summary(vehicleId), - queryFn: () => watchRequest(`/watch/summary${params}`), + queryFn: ({ signal }) => watchRequest(`/watch/summary${params}`, { signal }), refetchInterval: INTERVALS.STANDARD, staleTime: STALE_TIMES.MODERATE, retry: 2, @@ -90,7 +90,7 @@ export function useWatchComplication(vehicleId?: number) { const params = vehicleId ? `?vehicle_id=${vehicleId}` : ''; return useQuery({ queryKey: watchKeys.complication(vehicleId), - queryFn: () => watchRequest(`/watch/complication${params}`), + queryFn: ({ signal }) => watchRequest(`/watch/complication${params}`, { signal }), refetchInterval: INTERVALS.SLOW, staleTime: STALE_TIMES.FAST, }); diff --git a/web/src/lib/__tests__/resilience.abort.test.ts b/web/src/lib/__tests__/resilience.abort.test.ts new file mode 100644 index 0000000000..1e07d9f388 --- /dev/null +++ b/web/src/lib/__tests__/resilience.abort.test.ts @@ -0,0 +1,166 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +/** + * Phase-46 / Prompt 02 — AbortSignal threading through `resilientFetch`. + * + * These tests verify that: + * - A user-side abort propagates as `AbortError` (not converted to a + * 408 ApiError, not retried, not flagged as a transient infra + * signal that the rate-limit cache would short-circuit). + * - Internal timeouts still fire correctly and surface as 408. + * - Pre-aborted signals short-circuit before any network work. + * - Aborts during retry-backoff stop the loop immediately. + */ + +import { resilientFetch, ApiError, _resetRateLimitCache } from '../resilience' + +/** + * Build a fetch mock that respects an incoming `AbortSignal`. The + * default fake-fetch returned by `vi.stubGlobal('fetch', vi.fn())` does + * NOT subscribe to `init.signal`, which would let our test hang + * forever instead of asserting cancellation. This helper returns a + * promise that rejects with a real DOMException AbortError as soon as + * the signal aborts — closely matching real browser fetch semantics. + */ +function makeAbortAwareFetch() { + return vi.fn(async (_input: RequestInfo | URL, init?: RequestInit): Promise => { + return new Promise((_resolve, reject) => { + const signal = init?.signal + if (signal?.aborted) { + reject(new DOMException('aborted', 'AbortError')) + return + } + if (signal) { + signal.addEventListener( + 'abort', + () => { + reject(new DOMException('aborted', 'AbortError')) + }, + { once: true }, + ) + } + }) + }) +} + +beforeEach(() => { + Object.defineProperty(navigator, 'onLine', { configurable: true, value: true }) + _resetRateLimitCache() +}) + +afterEach(() => { + vi.unstubAllGlobals() + vi.useRealTimers() +}) + +describe('resilientFetch — user-side AbortSignal', () => { + it('rejects with AbortError when the caller aborts mid-request (NOT 408)', async () => { + const fetchMock = makeAbortAwareFetch() + vi.stubGlobal('fetch', fetchMock) + + const ctrl = new AbortController() + const promise = resilientFetch('/vehicles', { signal: ctrl.signal, retries: 0 }) + + // Give the fetch mock a tick to subscribe to the signal. + await new Promise((r) => setTimeout(r, 5)) + ctrl.abort() + + await expect(promise).rejects.toMatchObject({ name: 'AbortError' }) + // Not converted to ApiError(408) — that conversion is reserved for + // the internal timeout path. + await expect(promise).rejects.not.toBeInstanceOf(ApiError) + }) + + it('does NOT retry after a user-initiated abort', async () => { + const fetchMock = makeAbortAwareFetch() + vi.stubGlobal('fetch', fetchMock) + + const ctrl = new AbortController() + // retries: 3 — without abort handling, we would see 4 fetch calls. + const promise = resilientFetch('/vehicles', { + signal: ctrl.signal, + retries: 3, + retryDelay: 10, + }) + + await new Promise((r) => setTimeout(r, 5)) + ctrl.abort() + + await expect(promise).rejects.toMatchObject({ name: 'AbortError' }) + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + it('short-circuits without issuing fetch when the signal is already aborted', async () => { + const fetchMock = makeAbortAwareFetch() + vi.stubGlobal('fetch', fetchMock) + + const ctrl = new AbortController() + ctrl.abort() + + await expect( + resilientFetch('/vehicles', { signal: ctrl.signal, retries: 0 }), + ).rejects.toMatchObject({ name: 'AbortError' }) + expect(fetchMock).toHaveBeenCalledTimes(0) + }) + + it('propagates a successful response when the signal never fires', async () => { + const fetchMock = vi.fn(async () => + new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ) + vi.stubGlobal('fetch', fetchMock) + + const ctrl = new AbortController() + const result = await resilientFetch<{ ok: boolean }>('/vehicles', { + signal: ctrl.signal, + retries: 0, + }) + + expect(result).toEqual({ ok: true }) + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + it('still surfaces the internal timeout as ApiError 408 when the user passes no signal', async () => { + // Use real timers to avoid microtask-vs-fake-timer races. A 30 ms + // timeout is short enough to keep the suite fast. + const fetchMock = makeAbortAwareFetch() + vi.stubGlobal('fetch', fetchMock) + + const err = await resilientFetch('/vehicles', { retries: 0, timeout: 30 }).catch( + (e) => e, + ) + + expect(err).toBeInstanceOf(ApiError) + expect((err as ApiError).status).toBe(408) + }) + + it('respects abort during retry backoff', async () => { + const fetchMock = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { + // First attempt fails with a normal network error so we enter + // the retry-backoff path. + if (init?.signal?.aborted) { + throw new DOMException('aborted', 'AbortError') + } + throw new Error('boom') + }) + vi.stubGlobal('fetch', fetchMock) + + const ctrl = new AbortController() + const promise = resilientFetch('/vehicles', { + signal: ctrl.signal, + retries: 5, + retryDelay: 1000, + }) + + // Let the first attempt fail and the loop enter the backoff sleep. + await new Promise((r) => setTimeout(r, 20)) + ctrl.abort() + + await expect(promise).rejects.toMatchObject({ name: 'AbortError' }) + // We never made it past the first attempt because abort fired + // during backoff. + expect(fetchMock).toHaveBeenCalledTimes(1) + }) +}) diff --git a/web/src/lib/resilience.ts b/web/src/lib/resilience.ts index 99a3042208..90aaef353d 100644 --- a/web/src/lib/resilience.ts +++ b/web/src/lib/resilience.ts @@ -150,9 +150,9 @@ interface ResilientOptions extends RequestInit { dedupKey?: string // dedup key for GET requests } -async function sleep(ms: number): Promise { - return new Promise(r => setTimeout(r, ms)) -} +// Note: `sleep` was used by the previous resilient retry loop. The Phase-46 +// rewrite uses `abortableSleep` (further down) so retries respond immediately +// to user navigation. The unused helper is removed to satisfy the lint rule. /** Custom error class for API responses. Includes the HTTP status code. */ export class ApiError extends Error { @@ -391,6 +391,16 @@ export function isUpstreamUnavailableError(err: unknown): err is UpstreamUnavail /** * Performs a fetch request with automatic retry (exponential backoff), * request deduplication for GETs, and offline detection. + * + * Cancellation: when `options.signal` is provided (e.g. the `signal` + * arg from a TanStack Query `queryFn`), the fetch + retry loop honours + * abort. A user-side abort is propagated as the original `AbortError` + * (NOT converted to a 408 timeout) and the retry loop exits without + * additional network work. + * + * Dedup is automatically skipped when `options.signal` is present — + * otherwise one caller aborting would reject the shared promise for + * every other caller waiting on the same path. */ export async function resilientFetch( path: string, @@ -404,8 +414,14 @@ export async function resilientFetch( ...fetchOpts } = options - // For GET requests, auto-dedup using the path - const key = dedupKey || ((!fetchOpts.method || fetchOpts.method === 'GET') ? path : '') + // For GET requests, auto-dedup using the path. We must NOT dedup when + // the caller passes their own AbortSignal: the cached promise is shared + // across callers, so one caller aborting would reject the promise + // observed by every other (still-mounted) caller. + const canDedup = !fetchOpts.signal + const key = canDedup + ? (dedupKey || ((!fetchOpts.method || fetchOpts.method === 'GET') ? path : '')) + : '' if (key) { return dedup(key, () => _doFetch(path, fetchOpts, retries, retryDelay, timeout)) } @@ -413,6 +429,99 @@ export async function resilientFetch( return _doFetch(path, fetchOpts, retries, retryDelay, timeout) } +/** + * Sentinel reason attached when the internal timeout abort fires. + * Used by `_doFetch` to distinguish a genuine user cancellation + * (where we propagate the AbortError as-is and skip retries) from a + * timeout-driven abort (where we surface an HTTP 408 ApiError). + * + * `AbortSignal.aborted` alone is not enough because the timeout + * controller might race with a near-simultaneous user cancellation; + * tagging the reason removes that race. + */ +const TIMEOUT_ABORT_REASON = Symbol('teslasync:timeout-abort') + +/** + * Race-safe abort sleep — resolves after `ms` or as soon as `signal` + * aborts. Used between retries so a user navigation immediately stops + * pending backoff instead of waiting for the next retry tick. + */ +function abortableSleep(ms: number, signal?: AbortSignal): Promise { + return new Promise((resolve) => { + if (signal?.aborted) { + resolve() + return + } + let timer: ReturnType | null = null + let onAbort: (() => void) | null = null + const cleanup = () => { + if (timer !== null) { + clearTimeout(timer) + timer = null + } + if (signal && onAbort) { + signal.removeEventListener('abort', onAbort) + } + } + timer = setTimeout(() => { + cleanup() + resolve() + }, ms) + if (signal) { + onAbort = () => { + cleanup() + resolve() + } + signal.addEventListener('abort', onAbort, { once: true }) + } + }) +} + +/** + * Combine a caller-provided AbortSignal with one or more internal + * signals so the underlying fetch is aborted when any of them fires. + * + * Prefers the native `AbortSignal.any` (Node 22+, all current evergreen + * browsers) and falls back to a manual chained controller when the + * runtime doesn't provide it. Returned cleanup MUST be called in a + * `finally` block to release the chained listeners (the manual fallback + * would otherwise keep the user signal pinned for the lifetime of the + * tab). + */ +function combineSignals( + signals: Array, +): { signal: AbortSignal; cleanup: () => void } { + const real = signals.filter((s): s is AbortSignal => Boolean(s)) + if (real.length === 1) { + return { signal: real[0], cleanup: () => {} } + } + + const anyFn = (AbortSignal as unknown as { any?: (s: AbortSignal[]) => AbortSignal }).any + if (typeof anyFn === 'function') { + return { signal: anyFn(real), cleanup: () => {} } + } + + const ctrl = new AbortController() + const listeners: Array<{ s: AbortSignal; fn: () => void }> = [] + for (const s of real) { + if (s.aborted) { + ctrl.abort((s as AbortSignal & { reason?: unknown }).reason) + continue + } + const fn = () => { + if (!ctrl.signal.aborted) ctrl.abort((s as AbortSignal & { reason?: unknown }).reason) + } + s.addEventListener('abort', fn, { once: true }) + listeners.push({ s, fn }) + } + return { + signal: ctrl.signal, + cleanup: () => { + for (const { s, fn } of listeners) s.removeEventListener('abort', fn) + }, + } +} + async function _doFetch( path: string, fetchOpts: RequestInit, @@ -422,6 +531,14 @@ async function _doFetch( ): Promise { let lastError: Error | null = null + // Pull the user-provided signal out of the spread so we can merge + // it explicitly with the per-attempt timeout signal further down, + // rather than letting `...rest` accidentally clobber the timeout. + // RequestInit.signal allows `null` for "explicit none"; coerce that + // to undefined so the rest of the helper can branch on truthiness. + const { signal: rawSignal, ...restOpts } = fetchOpts + const userSignal: AbortSignal | undefined = rawSignal ?? undefined + // Phase-45 / Prompt 33 — short-circuit any path whose scope is still // inside an active Retry-After window. Without this guard, 60 in-flight // queries to /vehicles would each independently hit the network during @@ -434,21 +551,30 @@ async function _doFetch( } for (let attempt = 0; attempt <= retries; attempt++) { + // Phase-46 / Prompt 02 — if the caller cancelled before this + // attempt (e.g. between a 401 refresh + retry, or after an offline + // check), bail out without issuing more network work. + if (userSignal?.aborted) { + throw new DOMException('aborted', 'AbortError') + } + if (!navigator.onLine) { setStatus('offline') throw new ApiError('No network connection', 0) } - try { - const controller = new AbortController() - const timer = setTimeout(() => controller.abort(), timeout) + const controller = new AbortController() + let timer: ReturnType | null = setTimeout(() => { + controller.abort(TIMEOUT_ABORT_REASON) + }, timeout) + const merged = combineSignals([userSignal, controller.signal]) + try { const res = await fetch(`${getApiBase()}/api/v1${path}`, { headers: { 'Content-Type': 'application/json' }, - signal: controller.signal, - ...fetchOpts, + ...restOpts, + signal: merged.signal, }) - clearTimeout(timer) // Any server response (even errors) means we're online setStatus('online') @@ -488,8 +614,13 @@ async function _doFetch( throw new TeslaAuthExpiredError(apiErr.message || 'Tesla account disconnected') } - // 401 Unauthorized — attempt automatic token refresh and retry once + // 401 Unauthorized — attempt automatic token refresh and retry once. + // Bail before the refresh round-trip if the caller has already + // cancelled — the retry would be wasted bandwidth. if (res.status === 401 && attempt === 0) { + if (userSignal?.aborted) { + throw new DOMException('aborted', 'AbortError') + } try { await refreshTokenOnce() continue @@ -541,9 +672,26 @@ async function _doFetch( } catch (err) { if (err instanceof ApiError) throw err + // Phase-46 / Prompt 02 — distinguish user-cancel from internal timeout. + // If the caller's signal aborted, propagate the original AbortError + // unchanged: no retry, no 408 conversion, no CORS probe — the user + // has navigated away or the query was cancelled by TanStack Query. + if (userSignal?.aborted) { + const aborted = err instanceof Error + ? err + : new DOMException('aborted', 'AbortError') + if (aborted.name !== 'AbortError') { + throw new DOMException('aborted', 'AbortError') + } + throw aborted + } + // Network error might be a CORS-blocked auth redirect - // (ForwardAuth redirected to external auth domain, browser blocked it) - if (err instanceof TypeError) { + // (ForwardAuth redirected to external auth domain, browser blocked it). + // Skip the probe entirely if the caller has cancelled — the probe + // would be wasted work and could trigger the auth-expired flow on a + // page the user has already left. + if (err instanceof TypeError && !userSignal?.aborted) { try { const probe = await fetch(`${getApiBase()}/api/v1/system/version`, { method: 'HEAD' }) if (!probe.ok || (probe.headers.get('content-type') ?? '').includes('text/html')) { @@ -557,17 +705,39 @@ async function _doFetch( } } + // Phase-46 / Prompt 02 — DOMException may not extend Error in + // every runtime (notably some jsdom builds), so check the abort + // name on the raw thrown value before wrapping it as Error. + const errName = + err instanceof Error + ? err.name + : typeof (err as { name?: unknown })?.name === 'string' + ? (err as { name: string }).name + : '' + const isAbort = errName === 'AbortError' + lastError = err instanceof Error ? err : new Error(String(err)) - if (lastError.name === 'AbortError') { + if (isAbort) { + // Internal timeout — surface as HTTP 408 so the existing UI + // continues to render the timeout placeholder. lastError = new ApiError('Request timed out', 408) } if (attempt < retries) { const delay = retryDelay * Math.pow(2, attempt) * (0.75 + Math.random() * 0.5) - await sleep(delay) + await abortableSleep(delay, userSignal) + if (userSignal?.aborted) { + throw new DOMException('aborted', 'AbortError') + } continue } + } finally { + if (timer) { + clearTimeout(timer) + timer = null + } + merged.cleanup() } } From e86078387981633773f153902ba9dd4d0b924929 Mon Sep 17 00:00:00 2001 From: Atul Gupta Date: Mon, 4 May 2026 16:41:10 -0700 Subject: [PATCH 03/76] phase-46/03-tableid-sweep: assign tableId to every DataTable caller, audit gate enforces Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../phase-46-03-datatable-tableid-sweep.log | 1163 +++++++++++++++++ web/package.json | 3 +- web/scripts/audit-datatable-tableid.mjs | 199 +++ web/src/components/ui/DataTable.tsx | 15 +- .../DataTable.tableId-coverage.test.tsx | 172 +++ .../components/devtools/FleetApiSection.tsx | 9 +- .../devtools/FleetTelemetryHealth.tsx | 17 +- .../devtools/tools/HttpStatusTool.tsx | 9 +- .../devtools/tools/TeslaApiRefTool.tsx | 9 +- .../security-access/EventHistoryTable.tsx | 1 + web/src/features/admin/pages/AdminPage.tsx | 2 + .../admin/pages/BackupRestorePage.tsx | 3 + .../admin/pages/RedisSignalViewerPage.tsx | 1 + .../analytics/pages/FleetComparePage.tsx | 1 + .../features/analytics/pages/MileagePage.tsx | 1 + .../analytics/pages/PeriodComparePage.tsx | 1 + .../features/analytics/pages/TimelinePage.tsx | 1 + .../battery/pages/BatteryCellsPage.tsx | 1 + .../battery/pages/BatteryDegradationPage.tsx | 1 + .../features/battery/pages/EnergyFlowPage.tsx | 1 + web/src/features/battery/pages/EnergyPage.tsx | 1 + .../battery/pages/SleepEfficiencyPage.tsx | 1 + .../battery/pages/VampireDrainPage.tsx | 1 + .../charging-list/AcDcStatsPanel.tsx | 1 + .../cost-analysis/MonthlyCostTable.tsx | 1 + .../widgets/NotificationStatsWidget.tsx | 1 + .../driving-dynamics/DrivingCoachSection.tsx | 1 + .../features/driving/pages/EfficiencyPage.tsx | 1 + .../features/maps/pages/MapOverviewPage.tsx | 1 + .../maps/pages/NavigationRoutePage.tsx | 3 + .../status/BackendStatusSection.tsx | 1 + .../components/status/DataPipelineSection.tsx | 10 +- .../components/status/OperationsSection.tsx | 2 + .../status/ServiceHealthSection.tsx | 10 +- .../features/system/pages/DBHealthPage.tsx | 1 + .../features/system/pages/DataExportPage.tsx | 1 + .../system/pages/StateMachineDebuggerPage.tsx | 2 + .../telemetry/pages/LiveSignalMonitorPage.tsx | 1 + .../telemetry/pages/MQTTInspectorPage.tsx | 1 + .../telemetry/pages/SignalExplorerPage.tsx | 2 + .../telemetry/pages/SignalGapDetectorPage.tsx | 1 + .../pages/ClimateControlPage.tsx | 1 + .../vehicle-systems/pages/MaintenancePage.tsx | 1 + .../vehicle-systems/pages/MediaPlayerPage.tsx | 1 + .../pages/SafetySettingsPage.tsx | 1 + .../pages/TirePressurePage.tsx | 1 + .../vehicle-detail/RecentChargesSection.tsx | 1 + .../vehicle-detail/RecentDrivesSection.tsx | 1 + .../vehicles/pages/VehicleAccessPage.tsx | 2 + 49 files changed, 1654 insertions(+), 9 deletions(-) create mode 100644 .github/prompts/db-refactor/logs/phase-46-03-datatable-tableid-sweep.log create mode 100644 web/scripts/audit-datatable-tableid.mjs create mode 100644 web/src/components/ui/__tests__/DataTable.tableId-coverage.test.tsx diff --git a/.github/prompts/db-refactor/logs/phase-46-03-datatable-tableid-sweep.log b/.github/prompts/db-refactor/logs/phase-46-03-datatable-tableid-sweep.log new file mode 100644 index 0000000000..20338cd4d2 --- /dev/null +++ b/.github/prompts/db-refactor/logs/phase-46-03-datatable-tableid-sweep.log @@ -0,0 +1,1163 @@ +=== GATE === +=== PREFLIGHT === +PROMPT=phase-46/03-datatable-tableid-sweep +BRANCH=fix/bug-waave-46 +HEAD=92b06e89 +=== TYPESCRIPT === +TSC_EXIT=0 +=== LINT === + +> teslasync-web@2.0.0 lint +> eslint . --report-unused-disable-directives --max-warnings 0 && npm run lint:i18n && npm run lint:light-mode && npm run audit:empty-state && npm run audit:tooltip-text && npm run audit:i18n-shadowed && npm run audit:query-signal && npm run audit:datatable-tableid + + +> teslasync-web@2.0.0 lint:i18n +> node scripts/lint-i18n-no-template-literals.mjs + +[i18n-lint] OK ΓÇö no template literals in src/i18n/*.json + +> teslasync-web@2.0.0 lint:light-mode +> node scripts/audit-light-mode-parity.mjs + + +Total violations: 0 + +Allowed drift (LIGHT_MODE_ALLOWED_DRIFT): 0 + +OK ΓÇö light-mode CSS-var parity holds. + +> teslasync-web@2.0.0 audit:empty-state +> node scripts/audit-empty-state-cta.mjs + +OK ΓÇö every in src\features has a CTA or // no-action: exemption + +> teslasync-web@2.0.0 audit:tooltip-text +> node scripts/audit-tooltip-text-color.mjs + +OK ΓÇö no tooltip content overrides intrinsic text colour + +> teslasync-web@2.0.0 audit:i18n-shadowed +> node scripts/audit-i18n-shadowed-keys.mjs + +Γ£à audit:i18n-shadowed-keys ΓÇö no violations + +> teslasync-web@2.0.0 audit:query-signal +> node scripts/audit-query-signal.mjs + +OK ΓÇö every useQuery/useInfiniteQuery queryFn in src\api\hooks threads { signal } (37 files scanned) + +> teslasync-web@2.0.0 audit:datatable-tableid +> node scripts/audit-datatable-tableid.mjs + +OK ΓÇö every in src\features has a tableId +LINT_EXIT=0 +=== FRONTEND_TEST === + +> teslasync-web@2.0.0 test +> vitest run --run + +4:39:39 PM [vite] warning: `esbuild` option was specified by "vite:react-babel" plugin. This option is deprecated, please use `oxc` instead. +4:39:39 PM [vite] warning: `optimizeDeps.esbuildOptions` option was specified by "vite:react-babel" plugin. This option is deprecated, please use `optimizeDeps.rolldownOptions` instead. +Both esbuild and oxc options were set. oxc options will be used and esbuild options will be ignored. The following esbuild options were set: `{ jsx: 'automatic', jsxImportSource: undefined }` + + RUN  v4.1.2 D:/repos/teslasync/web + +stdout | src/components/ui/__tests__/ConfirmDialog.test.tsx +≡ƒîÉ i18next is made possible by our own product, Locize ΓÇö consider powering your project with managed localization (AI, CDN, integrations): https://locize.com ≡ƒÆÖ + +stdout | src/features/dashboard/components/__tests__/WidgetCatalogueDialog.test.tsx +≡ƒîÉ i18next is made possible by our own product, Locize ΓÇö consider powering your project with managed localization (AI, CDN, integrations): https://locize.com ≡ƒÆÖ + +stdout | src/components/ui/__tests__/DataTable.test.tsx +≡ƒîÉ i18next is made possible by our own product, Locize ΓÇö consider powering your project with managed localization (AI, CDN, integrations): https://locize.com ≡ƒÆÖ + +stdout | src/features/notifications/pages/NotificationsPage.test.tsx +≡ƒîÉ i18next is made possible by our own product, Locize ΓÇö consider powering your project with managed localization (AI, CDN, integrations): https://locize.com ≡ƒÆÖ + +stdout | src/components/feedback/__tests__/NavigationGuardProvider.test.tsx +≡ƒîÉ i18next is made possible by our own product, Locize ΓÇö consider powering your project with managed localization (AI, CDN, integrations): https://locize.com ≡ƒÆÖ + +stdout | src/features/onboarding/pages/OnboardingPage.test.tsx +≡ƒîÉ i18next is made possible by our own product, Locize ΓÇö consider powering your project with managed localization (AI, CDN, integrations): https://locize.com ≡ƒÆÖ + +stdout | src/features/telemetry/pages/__tests__/SignalExplorerPage.help.test.tsx +≡ƒîÉ i18next is made possible by our own product, Locize ΓÇö consider powering your project with managed localization (AI, CDN, integrations): https://locize.com ≡ƒÆÖ + +stdout | src/components/feedback/__tests__/QueryError.test.tsx +≡ƒîÉ i18next is made possible by our own product, Locize ΓÇö consider powering your project with managed localization (AI, CDN, integrations): https://locize.com ≡ƒÆÖ + +stderr | src/components/feedback/__tests__/NavigationGuardProvider.test.tsx > NavigationGuardProvider > confirmIfDirty resolves true when no guards are dirty +ΓÜá∩╕Å React Router Future Flag Warning: React Router will begin wrapping state updates in `React.startTransition` in v7. You can use the `v7_startTransition` future flag to opt-in early. For more information, see https://reactrouter.com/v6/upgrading/future#v7_starttransition. +ΓÜá∩╕Å React Router Future Flag Warning: Relative route resolution within Splat routes is changing in v7. You can use the `v7_relativeSplatPath` future flag to opt-in early. For more information, see https://reactrouter.com/v6/upgrading/future#v7_relativesplatpath. + +stderr | src/components/feedback/__tests__/QueryError.test.tsx > QueryError > renders nothing when error is null/undefined +ΓÜá∩╕Å React Router Future Flag Warning: React Router will begin wrapping state updates in `React.startTransition` in v7. You can use the `v7_startTransition` future flag to opt-in early. For more information, see https://reactrouter.com/v6/upgrading/future#v7_starttransition. +ΓÜá∩╕Å React Router Future Flag Warning: Relative route resolution within Splat routes is changing in v7. You can use the `v7_relativeSplatPath` future flag to opt-in early. For more information, see https://reactrouter.com/v6/upgrading/future#v7_relativesplatpath. + +stderr | src/components/vehicles/__tests__/VehiclePaintPicker.test.tsx > VehiclePaintPicker > renders 5 swatches as a radio group +react-i18next:: useTranslation: You will need to pass in an i18next instance by using initReactI18next { code: 'NO_I18NEXT_INSTANCE' } + +stderr | src/hooks/__tests__/useConfirm.test.tsx > useConfirm > opens a dialog with the supplied title and message +react-i18next:: useTranslation: You will need to pass in an i18next instance by using initReactI18next { code: 'NO_I18NEXT_INSTANCE' } + + Γ£ô src/components/vehicles/__tests__/VehiclePaintPicker.test.tsx (5 tests) 953ms + Γ£ô renders 5 swatches as a radio group  571ms + Γ£ô src/components/ui/__tests__/ConfirmDialog.test.tsx (10 tests) 921ms + Γ£ô renders the checkbox when silenceKey is provided on a non-destructive prompt  450ms + Γ£ô src/components/feedback/__tests__/QueryError.test.tsx (13 tests) 786ms + Γ£ô renders "{resource} not found" title with Back-to-list CTA when listHref is provided  458ms + Γ£ô src/hooks/__tests__/useConfirm.test.tsx (13 tests) 1094ms + Γ£ô opens a dialog with the supplied title and message  425ms + Γ£ô src/components/ui/__tests__/CopyButton.test.tsx (7 tests) 797ms + Γ£ô writes the text to the clipboard on click and toggles to "Copied"  508ms +Not implemented: navigation to another Document + Γ£ô src/hooks/__tests__/useSettings.broadcast.test.tsx (9 tests) 675ms + Γ£ô src/components/feedback/__tests__/NavigationGuardProvider.test.tsx (16 tests) 1570ms + Γ£ô confirm resolves with the user choice (discard = true)  455ms + Γ£ô src/components/ui/__tests__/DataTable.test.tsx (28 tests) 1674ms + Γ£ô calls onSort when a sortable header is clicked  338ms +stderr | src/features/onboarding/pages/OnboardingPage.test.tsx > OnboardingPage > renders the welcome heading and three setup steps +ΓÜá∩╕Å React Router Future Flag Warning: React Router will begin wrapping state updates in `React.startTransition` in v7. You can use the `v7_startTransition` future flag to opt-in early. For more information, see https://reactrouter.com/v6/upgrading/future#v7_starttransition. +ΓÜá∩╕Å React Router Future Flag Warning: Relative route resolution within Splat routes is changing in v7. You can use the `v7_relativeSplatPath` future flag to opt-in early. For more information, see https://reactrouter.com/v6/upgrading/future#v7_relativesplatpath. + +stderr | src/components/ui/__tests__/CommandPalette.test.tsx > CommandPalette keyboard shortcut > opens on Ctrl+K when focus is on the body +react-i18next:: useTranslation: You will need to pass in an i18next instance by using initReactI18next { code: 'NO_I18NEXT_INSTANCE' } +ΓÜá∩╕Å React Router Future Flag Warning: React Router will begin wrapping state updates in `React.startTransition` in v7. You can use the `v7_startTransition` future flag to opt-in early. For more information, see https://reactrouter.com/v6/upgrading/future#v7_starttransition. +ΓÜá∩╕Å React Router Future Flag Warning: Relative route resolution within Splat routes is changing in v7. You can use the `v7_relativeSplatPath` future flag to opt-in early. For more information, see https://reactrouter.com/v6/upgrading/future#v7_relativesplatpath. + + Γ£ô src/features/onboarding/pages/OnboardingPage.test.tsx (8 tests) 1488ms + Γ£ô shows the Continue button only when onboarding is complete  526ms +stderr | src/components/ui/__tests__/CommandPalette.test.tsx > CommandPalette search > surfaces vehicle-switch entries when fleet has 2+ vehicles +Warning: Encountered two children with the same key, `Preferences`. Keys should be unique so that components maintain their identity across updates. Non-unique keys may cause children to be duplicated and/or omitted ΓÇö the behavior is unsupported and could change in a future version. + at div + at div + at div + at MotionDOMComponent (file:///D:/repos/teslasync/web/node_modules/framer-motion/dist/es/motion/index.mjs:45:16) + at PopChildMeasure (file:///D:/repos/teslasync/web/node_modules/framer-motion/dist/es/components/AnimatePresence/PopChild.mjs:13:1) + at PopChild (file:///D:/repos/teslasync/web/node_modules/framer-motion/dist/es/components/AnimatePresence/PopChild.mjs:43:21) + at PresenceChild (file:///D:/repos/teslasync/web/node_modules/framer-motion/dist/es/components/AnimatePresence/PresenceChild.mjs:9:26) + at AnimatePresence (file:///D:/repos/teslasync/web/node_modules/framer-motion/dist/es/components/AnimatePresence/index.mjs:44:28) + at CommandPalette (D:/repos/teslasync/web/src/components/ui/CommandPalette.tsx:465:27) + at SelectedVehicleProvider (D:/repos/teslasync/web/src/store/selectedVehicle.tsx:48:36) + at ToastProvider (D:/repos/teslasync/web/src/components/feedback/Toast.tsx:91:26) + at ThemeProvider (D:/repos/teslasync/web/src/components/ui/ThemeProvider.tsx:210:26) + at Router (D:\repos\teslasync\web\node_modules\react-router\dist\umd\react-router.development.js:1207:17) + at MemoryRouter (D:\repos\teslasync\web\node_modules\react-router\dist\umd\react-router.development.js:1101:7) + at QueryClientProvider (file:///D:/repos/teslasync/web/node_modules/@tanstack/react-query/build/modern/QueryClientProvider.js:20:3) + at Wrapper (D:/repos/teslasync/web/src/components/ui/__tests__/CommandPalette.test.tsx:56:28) + +stdout | src/features/settings/components/__tests__/SettingsSearch.test.tsx +≡ƒîÉ i18next is made possible by our own product, Locize ΓÇö consider powering your project with managed localization (AI, CDN, integrations): https://locize.com ≡ƒÆÖ + +stderr | src/features/system/pages/__tests__/StateMachineDebuggerPage.windowing.test.tsx > StateMachineDebuggerPage ΓÇö windowing reconciliation (Phase 45 / Prompt 35) > reads "0 in window ┬╖ 23 in 24 h" when 23 transitions exist outside the 10-min window +ΓÜá∩╕Å React Router Future Flag Warning: React Router will begin wrapping state updates in `React.startTransition` in v7. You can use the `v7_startTransition` future flag to opt-in early. For more information, see https://reactrouter.com/v6/upgrading/future#v7_starttransition. +ΓÜá∩╕Å React Router Future Flag Warning: Relative route resolution within Splat routes is changing in v7. You can use the `v7_relativeSplatPath` future flag to opt-in early. For more information, see https://reactrouter.com/v6/upgrading/future#v7_relativesplatpath. + +stderr | src/features/notifications/pages/NotificationsPage.test.tsx > NotificationsPage > renders the three tabs (Inbox, Archived, Channels) +ΓÜá∩╕Å React Router Future Flag Warning: React Router will begin wrapping state updates in `React.startTransition` in v7. You can use the `v7_startTransition` future flag to opt-in early. For more information, see https://reactrouter.com/v6/upgrading/future#v7_starttransition. +ΓÜá∩╕Å React Router Future Flag Warning: Relative route resolution within Splat routes is changing in v7. You can use the `v7_relativeSplatPath` future flag to opt-in early. For more information, see https://reactrouter.com/v6/upgrading/future#v7_relativesplatpath. + +stderr | src/features/telemetry/pages/__tests__/SignalExplorerPage.help.test.tsx > SignalExplorerPage ΓÇö HelpTooltip adoption > renders a HelpTooltip explaining signal layers (L1/L2/log) +ΓÜá∩╕Å React Router Future Flag Warning: React Router will begin wrapping state updates in `React.startTransition` in v7. You can use the `v7_startTransition` future flag to opt-in early. For more information, see https://reactrouter.com/v6/upgrading/future#v7_starttransition. +ΓÜá∩╕Å React Router Future Flag Warning: Relative route resolution within Splat routes is changing in v7. You can use the `v7_relativeSplatPath` future flag to opt-in early. For more information, see https://reactrouter.com/v6/upgrading/future#v7_relativesplatpath. + +stderr | src/components/ui/__tests__/CommandPalette.test.tsx > CommandPalette search > ranks the "Drives" page above keyword-only matches (e.g. "debugger") for query "d" +Warning: Encountered two children with the same key, `Pages`. Keys should be unique so that components maintain their identity across updates. Non-unique keys may cause children to be duplicated and/or omitted ΓÇö the behavior is unsupported and could change in a future version. + at div + at div + at div + at MotionDOMComponent (file:///D:/repos/teslasync/web/node_modules/framer-motion/dist/es/motion/index.mjs:45:16) + at PopChildMeasure (file:///D:/repos/teslasync/web/node_modules/framer-motion/dist/es/components/AnimatePresence/PopChild.mjs:13:1) + at PopChild (file:///D:/repos/teslasync/web/node_modules/framer-motion/dist/es/components/AnimatePresence/PopChild.mjs:43:21) + at PresenceChild (file:///D:/repos/teslasync/web/node_modules/framer-motion/dist/es/components/AnimatePresence/PresenceChild.mjs:9:26) + at AnimatePresence (file:///D:/repos/teslasync/web/node_modules/framer-motion/dist/es/components/AnimatePresence/index.mjs:44:28) + at CommandPalette (D:/repos/teslasync/web/src/components/ui/CommandPalette.tsx:465:27) + at SelectedVehicleProvider (D:/repos/teslasync/web/src/store/selectedVehicle.tsx:48:36) + at ToastProvider (D:/repos/teslasync/web/src/components/feedback/Toast.tsx:91:26) + at ThemeProvider (D:/repos/teslasync/web/src/components/ui/ThemeProvider.tsx:210:26) + at Router (D:\repos\teslasync\web\node_modules\react-router\dist\umd\react-router.development.js:1207:17) + at MemoryRouter (D:\repos\teslasync\web\node_modules\react-router\dist\umd\react-router.development.js:1101:7) + at QueryClientProvider (file:///D:/repos/teslasync/web/node_modules/@tanstack/react-query/build/modern/QueryClientProvider.js:20:3) + at Wrapper (D:/repos/teslasync/web/src/components/ui/__tests__/CommandPalette.test.tsx:56:28) +Warning: Encountered two children with the same key, `Vehicle Commands`. Keys should be unique so that components maintain their identity across updates. Non-unique keys may cause children to be duplicated and/or omitted ΓÇö the behavior is unsupported and could change in a future version. + at div + at div + at div + at MotionDOMComponent (file:///D:/repos/teslasync/web/node_modules/framer-motion/dist/es/motion/index.mjs:45:16) + at PopChildMeasure (file:///D:/repos/teslasync/web/node_modules/framer-motion/dist/es/components/AnimatePresence/PopChild.mjs:13:1) + at PopChild (file:///D:/repos/teslasync/web/node_modules/framer-motion/dist/es/components/AnimatePresence/PopChild.mjs:43:21) + at PresenceChild (file:///D:/repos/teslasync/web/node_modules/framer-motion/dist/es/components/AnimatePresence/PresenceChild.mjs:9:26) + at AnimatePresence (file:///D:/repos/teslasync/web/node_modules/framer-motion/dist/es/components/AnimatePresence/index.mjs:44:28) + at CommandPalette (D:/repos/teslasync/web/src/components/ui/CommandPalette.tsx:465:27) + at SelectedVehicleProvider (D:/repos/teslasync/web/src/store/selectedVehicle.tsx:48:36) + at ToastProvider (D:/repos/teslasync/web/src/components/feedback/Toast.tsx:91:26) + at ThemeProvider (D:/repos/teslasync/web/src/components/ui/ThemeProvider.tsx:210:26) + at Router (D:\repos\teslasync\web\node_modules\react-router\dist\umd\react-router.development.js:1207:17) + at MemoryRouter (D:\repos\teslasync\web\node_modules\react-router\dist\umd\react-router.development.js:1101:7) + at QueryClientProvider (file:///D:/repos/teslasync/web/node_modules/@tanstack/react-query/build/modern/QueryClientProvider.js:20:3) + at Wrapper (D:/repos/teslasync/web/src/components/ui/__tests__/CommandPalette.test.tsx:56:28) +Warning: Encountered two children with the same key, `Pages`. Keys should be unique so that components maintain their identity across updates. Non-unique keys may cause children to be duplicated and/or omitted ΓÇö the behavior is unsupported and could change in a future version. + at div + at div + at div + at MotionDOMComponent (file:///D:/repos/teslasync/web/node_modules/framer-motion/dist/es/motion/index.mjs:45:16) + at PopChildMeasure (file:///D:/repos/teslasync/web/node_modules/framer-motion/dist/es/components/AnimatePresence/PopChild.mjs:13:1) + at PopChild (file:///D:/repos/teslasync/web/node_modules/framer-motion/dist/es/components/AnimatePresence/PopChild.mjs:43:21) + at PresenceChild (file:///D:/repos/teslasync/web/node_modules/framer-motion/dist/es/components/AnimatePresence/PresenceChild.mjs:9:26) + at AnimatePresence (file:///D:/repos/teslasync/web/node_modules/framer-motion/dist/es/components/AnimatePresence/index.mjs:44:28) + at CommandPalette (D:/repos/teslasync/web/src/components/ui/CommandPalette.tsx:465:27) + at SelectedVehicleProvider (D:/repos/teslasync/web/src/store/selectedVehicle.tsx:48:36) + at ToastProvider (D:/repos/teslasync/web/src/components/feedback/Toast.tsx:91:26) + at ThemeProvider (D:/repos/teslasync/web/src/components/ui/ThemeProvider.tsx:210:26) + at Router (D:\repos\teslasync\web\node_modules\react-router\dist\umd\react-router.development.js:1207:17) + at MemoryRouter (D:\repos\teslasync\web\node_modules\react-router\dist\umd\react-router.development.js:1101:7) + at QueryClientProvider (file:///D:/repos/teslasync/web/node_modules/@tanstack/react-query/build/modern/QueryClientProvider.js:20:3) + at Wrapper (D:/repos/teslasync/web/src/components/ui/__tests__/CommandPalette.test.tsx:56:28) +Warning: Encountered two children with the same key, `Vehicle Commands`. Keys should be unique so that components maintain their identity across updates. Non-unique keys may cause children to be duplicated and/or omitted ΓÇö the behavior is unsupported and could change in a future version. + at div + at div + at div + at MotionDOMComponent (file:///D:/repos/teslasync/web/node_modules/framer-motion/dist/es/motion/index.mjs:45:16) + at PopChildMeasure (file:///D:/repos/teslasync/web/node_modules/framer-motion/dist/es/components/AnimatePresence/PopChild.mjs:13:1) + at PopChild (file:///D:/repos/teslasync/web/node_modules/framer-motion/dist/es/components/AnimatePresence/PopChild.mjs:43:21) + at PresenceChild (file:///D:/repos/teslasync/web/node_modules/framer-motion/dist/es/components/AnimatePresence/PresenceChild.mjs:9:26) + at AnimatePresence (file:///D:/repos/teslasync/web/node_modules/framer-motion/dist/es/components/AnimatePresence/index.mjs:44:28) + at CommandPalette (D:/repos/teslasync/web/src/components/ui/CommandPalette.tsx:465:27) + at SelectedVehicleProvider (D:/repos/teslasync/web/src/store/selectedVehicle.tsx:48:36) + at ToastProvider (D:/repos/teslasync/web/src/components/feedback/Toast.tsx:91:26) + at ThemeProvider (D:/repos/teslasync/web/src/components/ui/ThemeProvider.tsx:210:26) + at Router (D:\repos\teslasync\web\node_modules\react-router\dist\umd\react-router.development.js:1207:17) + at MemoryRouter (D:\repos\teslasync\web\node_modules\react-router\dist\umd\react-router.development.js:1101:7) + at QueryClientProvider (file:///D:/repos/teslasync/web/node_modules/@tanstack/react-query/build/modern/QueryClientProvider.js:20:3) + at Wrapper (D:/repos/teslasync/web/src/components/ui/__tests__/CommandPalette.test.tsx:56:28) +Warning: Encountered two children with the same key, `Preferences`. Keys should be unique so that components maintain their identity across updates. Non-unique keys may cause children to be duplicated and/or omitted ΓÇö the behavior is unsupported and could change in a future version. + at div + at div + at div + at MotionDOMComponent (file:///D:/repos/teslasync/web/node_modules/framer-motion/dist/es/motion/index.mjs:45:16) + at PopChildMeasure (file:///D:/repos/teslasync/web/node_modules/framer-motion/dist/es/components/AnimatePresence/PopChild.mjs:13:1) + at PopChild (file:///D:/repos/teslasync/web/node_modules/framer-motion/dist/es/components/AnimatePresence/PopChild.mjs:43:21) + at PresenceChild (file:///D:/repos/teslasync/web/node_modules/framer-motion/dist/es/components/AnimatePresence/PresenceChild.mjs:9:26) + at AnimatePresence (file:///D:/repos/teslasync/web/node_modules/framer-motion/dist/es/components/AnimatePresence/index.mjs:44:28) + at CommandPalette (D:/repos/teslasync/web/src/components/ui/CommandPalette.tsx:465:27) + at SelectedVehicleProvider (D:/repos/teslasync/web/src/store/selectedVehicle.tsx:48:36) + at ToastProvider (D:/repos/teslasync/web/src/components/feedback/Toast.tsx:91:26) + at ThemeProvider (D:/repos/teslasync/web/src/components/ui/ThemeProvider.tsx:210:26) + at Router (D:\repos\teslasync\web\node_modules\react-router\dist\umd\react-router.development.js:1207:17) + at MemoryRouter (D:\repos\teslasync\web\node_modules\react-router\dist\umd\react-router.development.js:1101:7) + at QueryClientProvider (file:///D:/repos/teslasync/web/node_modules/@tanstack/react-query/build/modern/QueryClientProvider.js:20:3) + at Wrapper (D:/repos/teslasync/web/src/components/ui/__tests__/CommandPalette.test.tsx:56:28) +Warning: Encountered two children with the same key, `Actions`. Keys should be unique so that components maintain their identity across updates. Non-unique keys may cause children to be duplicated and/or omitted ΓÇö the behavior is unsupported and could change in a future version. + at div + at div + at div + at MotionDOMComponent (file:///D:/repos/teslasync/web/node_modules/framer-motion/dist/es/motion/index.mjs:45:16) + at PopChildMeasure (file:///D:/repos/teslasync/web/node_modules/framer-motion/dist/es/components/AnimatePresence/PopChild.mjs:13:1) + at PopChild (file:///D:/repos/teslasync/web/node_modules/framer-motion/dist/es/components/AnimatePresence/PopChild.mjs:43:21) + at PresenceChild (file:///D:/repos/teslasync/web/node_modules/framer-motion/dist/es/components/AnimatePresence/PresenceChild.mjs:9:26) + at AnimatePresence (file:///D:/repos/teslasync/web/node_modules/framer-motion/dist/es/components/AnimatePresence/index.mjs:44:28) + at CommandPalette (D:/repos/teslasync/web/src/components/ui/CommandPalette.tsx:465:27) + at SelectedVehicleProvider (D:/repos/teslasync/web/src/store/selectedVehicle.tsx:48:36) + at ToastProvider (D:/repos/teslasync/web/src/components/feedback/Toast.tsx:91:26) + at ThemeProvider (D:/repos/teslasync/web/src/components/ui/ThemeProvider.tsx:210:26) + at Router (D:\repos\teslasync\web\node_modules\react-router\dist\umd\react-router.development.js:1207:17) + at MemoryRouter (D:\repos\teslasync\web\node_modules\react-router\dist\umd\react-router.development.js:1101:7) + at QueryClientProvider (file:///D:/repos/teslasync/web/node_modules/@tanstack/react-query/build/modern/QueryClientProvider.js:20:3) + at Wrapper (D:/repos/teslasync/web/src/components/ui/__tests__/CommandPalette.test.tsx:56:28) +Warning: Encountered two children with the same key, `Pages`. Keys should be unique so that components maintain their identity across updates. Non-unique keys may cause children to be duplicated and/or omitted ΓÇö the behavior is unsupported and could change in a future version. + at div + at div + at div + at MotionDOMComponent (file:///D:/repos/teslasync/web/node_modules/framer-motion/dist/es/motion/index.mjs:45:16) + at PopChildMeasure (file:///D:/repos/teslasync/web/node_modules/framer-motion/dist/es/components/AnimatePresence/PopChild.mjs:13:1) + at PopChild (file:///D:/repos/teslasync/web/node_modules/framer-motion/dist/es/components/AnimatePresence/PopChild.mjs:43:21) + at PresenceChild (file:///D:/repos/teslasync/web/node_modules/framer-motion/dist/es/components/AnimatePresence/PresenceChild.mjs:9:26) + at AnimatePresence (file:///D:/repos/teslasync/web/node_modules/framer-motion/dist/es/components/AnimatePresence/index.mjs:44:28) + at CommandPalette (D:/repos/teslasync/web/src/components/ui/CommandPalette.tsx:465:27) + at SelectedVehicleProvider (D:/repos/teslasync/web/src/store/selectedVehicle.tsx:48:36) + at ToastProvider (D:/repos/teslasync/web/src/components/feedback/Toast.tsx:91:26) + at ThemeProvider (D:/repos/teslasync/web/src/components/ui/ThemeProvider.tsx:210:26) + at Router (D:\repos\teslasync\web\node_modules\react-router\dist\umd\react-router.development.js:1207:17) + at MemoryRouter (D:\repos\teslasync\web\node_modules\react-router\dist\umd\react-router.development.js:1101:7) + at QueryClientProvider (file:///D:/repos/teslasync/web/node_modules/@tanstack/react-query/build/modern/QueryClientProvider.js:20:3) + at Wrapper (D:/repos/teslasync/web/src/components/ui/__tests__/CommandPalette.test.tsx:56:28) +Warning: Encountered two children with the same key, `Vehicle Commands`. Keys should be unique so that components maintain their identity across updates. Non-unique keys may cause children to be duplicated and/or omitted ΓÇö the behavior is unsupported and could change in a future version. + at div + at div + at div + at MotionDOMComponent (file:///D:/repos/teslasync/web/node_modules/framer-motion/dist/es/motion/index.mjs:45:16) + at PopChildMeasure (file:///D:/repos/teslasync/web/node_modules/framer-motion/dist/es/components/AnimatePresence/PopChild.mjs:13:1) + at PopChild (file:///D:/repos/teslasync/web/node_modules/framer-motion/dist/es/components/AnimatePresence/PopChild.mjs:43:21) + at PresenceChild (file:///D:/repos/teslasync/web/node_modules/framer-motion/dist/es/components/AnimatePresence/PresenceChild.mjs:9:26) + at AnimatePresence (file:///D:/repos/teslasync/web/node_modules/framer-motion/dist/es/components/AnimatePresence/index.mjs:44:28) + at CommandPalette (D:/repos/teslasync/web/src/components/ui/CommandPalette.tsx:465:27) + at SelectedVehicleProvider (D:/repos/teslasync/web/src/store/selectedVehicle.tsx:48:36) + at ToastProvider (D:/repos/teslasync/web/src/components/feedback/Toast.tsx:91:26) + at ThemeProvider (D:/repos/teslasync/web/src/components/ui/ThemeProvider.tsx:210:26) + at Router (D:\repos\teslasync\web\node_modules\react-router\dist\umd\react-router.development.js:1207:17) + at MemoryRouter (D:\repos\teslasync\web\node_modules\react-router\dist\umd\react-router.development.js:1101:7) + at QueryClientProvider (file:///D:/repos/teslasync/web/node_modules/@tanstack/react-query/build/modern/QueryClientProvider.js:20:3) + at Wrapper (D:/repos/teslasync/web/src/components/ui/__tests__/CommandPalette.test.tsx:56:28) +Warning: Encountered two children with the same key, `Pages`. Keys should be unique so that components maintain their identity across updates. Non-unique keys may cause children to be duplicated and/or omitted ΓÇö the behavior is unsupported and could change in a future version. + at div + at div + at div + at MotionDOMComponent (file:///D:/repos/teslasync/web/node_modules/framer-motion/dist/es/motion/index.mjs:45:16) + at PopChildMeasure (file:///D:/repos/teslasync/web/node_modules/framer-motion/dist/es/components/AnimatePresence/PopChild.mjs:13:1) + at PopChild (file:///D:/repos/teslasync/web/node_modules/framer-motion/dist/es/components/AnimatePresence/PopChild.mjs:43:21) + at PresenceChild (file:///D:/repos/teslasync/web/node_modules/framer-motion/dist/es/components/AnimatePresence/PresenceChild.mjs:9:26) + at AnimatePresence (file:///D:/repos/teslasync/web/node_modules/framer-motion/dist/es/components/AnimatePresence/index.mjs:44:28) + at CommandPalette (D:/repos/teslasync/web/src/components/ui/CommandPalette.tsx:465:27) + at SelectedVehicleProvider (D:/repos/teslasync/web/src/store/selectedVehicle.tsx:48:36) + at ToastProvider (D:/repos/teslasync/web/src/components/feedback/Toast.tsx:91:26) + at ThemeProvider (D:/repos/teslasync/web/src/components/ui/ThemeProvider.tsx:210:26) + at Router (D:\repos\teslasync\web\node_modules\react-router\dist\umd\react-router.development.js:1207:17) + at MemoryRouter (D:\repos\teslasync\web\node_modules\react-router\dist\umd\react-router.development.js:1101:7) + at QueryClientProvider (file:///D:/repos/teslasync/web/node_modules/@tanstack/react-query/build/modern/QueryClientProvider.js:20:3) + at Wrapper (D:/repos/teslasync/web/src/components/ui/__tests__/CommandPalette.test.tsx:56:28) +Warning: Encountered two children with the same key, `Vehicle Commands`. Keys should be unique so that components maintain their identity across updates. Non-unique keys may cause children to be duplicated and/or omitted ΓÇö the behavior is unsupported and could change in a future version. + at div + at div + at div + at MotionDOMComponent (file:///D:/repos/teslasync/web/node_modules/framer-motion/dist/es/motion/index.mjs:45:16) + at PopChildMeasure (file:///D:/repos/teslasync/web/node_modules/framer-motion/dist/es/components/AnimatePresence/PopChild.mjs:13:1) + at PopChild (file:///D:/repos/teslasync/web/node_modules/framer-motion/dist/es/components/AnimatePresence/PopChild.mjs:43:21) + at PresenceChild (file:///D:/repos/teslasync/web/node_modules/framer-motion/dist/es/components/AnimatePresence/PresenceChild.mjs:9:26) + at AnimatePresence (file:///D:/repos/teslasync/web/node_modules/framer-motion/dist/es/components/AnimatePresence/index.mjs:44:28) + at CommandPalette (D:/repos/teslasync/web/src/components/ui/CommandPalette.tsx:465:27) + at SelectedVehicleProvider (D:/repos/teslasync/web/src/store/selectedVehicle.tsx:48:36) + at ToastProvider (D:/repos/teslasync/web/src/components/feedback/Toast.tsx:91:26) + at ThemeProvider (D:/repos/teslasync/web/src/components/ui/ThemeProvider.tsx:210:26) + at Router (D:\repos\teslasync\web\node_modules\react-router\dist\umd\react-router.development.js:1207:17) + at MemoryRouter (D:\repos\teslasync\web\node_modules\react-router\dist\umd\react-router.development.js:1101:7) + at QueryClientProvider (file:///D:/repos/teslasync/web/node_modules/@tanstack/react-query/build/modern/QueryClientProvider.js:20:3) + at Wrapper (D:/repos/teslasync/web/src/components/ui/__tests__/CommandPalette.test.tsx:56:28) + +stdout | src/features/admin/components/__tests__/RedisDiagnosticEmptyState.test.tsx +≡ƒîÉ i18next is made possible by our own product, Locize ΓÇö consider powering your project with managed localization (AI, CDN, integrations): https://locize.com ≡ƒÆÖ + + Γ£ô src/features/telemetry/pages/__tests__/SignalExplorerPage.help.test.tsx (3 tests) 1001ms + Γ£ô renders a HelpTooltip explaining signal layers (L1/L2/log)  708ms + Γ£ô src/features/dashboard/components/__tests__/WidgetCatalogueDialog.test.tsx (6 tests) 2761ms + Γ£ô renders a dialog with categorized widget sections when open  985ms + Γ£ô lists every registry widget at least once (no silent drop)  505ms + Γ£ô disables the Add button for widgets already on the dashboard  506ms + Γ£ô calls onAdd with the widget id and closes when an addable widget is picked  370ms + Γ£ô src/features/system/pages/__tests__/StateMachineDebuggerPage.windowing.test.tsx (3 tests) 1988ms + Γ£ô reads "0 in window ┬╖ 23 in 24 h" when 23 transitions exist outside the 10-min window  1110ms + Γ£ô after clicking "Widen window", the counter renders ticks and reflects the new window size  646ms + Γ£ô src/components/ui/__tests__/PinButton.test.tsx (7 tests) 815ms + Γ£ô renders an unpinned trigger when the item is not in the pinned list  510ms +stderr | src/components/layout/__tests__/PageContainer.test.tsx > PageContainer > renders the page title +ΓÜá∩╕Å React Router Future Flag Warning: React Router will begin wrapping state updates in `React.startTransition` in v7. You can use the `v7_startTransition` future flag to opt-in early. For more information, see https://reactrouter.com/v6/upgrading/future#v7_starttransition. +ΓÜá∩╕Å React Router Future Flag Warning: Relative route resolution within Splat routes is changing in v7. You can use the `v7_relativeSplatPath` future flag to opt-in early. For more information, see https://reactrouter.com/v6/upgrading/future#v7_relativesplatpath. + + Γ£ô src/components/charts/ChartBrush.test.tsx (2 tests) 733ms + Γ£ô renders inside a recharts chart container without crashing  550ms + Γ£ô src/components/ui/__tests__/CommandPalette.test.tsx (15 tests) 4292ms + Γ£ô opens on Ctrl+K when focus is on the body  810ms + Γ£ô opens via the toggle-command-palette custom event (used by useKeyboardShortcuts)  396ms + Γ£ô surfaces vehicle-switch entries when fleet has 2+ vehicles  361ms + Γ£ô surfaces theme registry commands on a "theme" search  309ms + Γ£ô surfaces refresh action on a "refresh" search  321ms + Γ£ô ranks the "Drives" page above keyword-only matches (e.g. "debugger") for query "d"  506ms + Γ£ô renders a "Most Used" section when frecency data exists and the query is empty  394ms + Γ£ô does NOT render a "Most Used" section when no commands have been recorded yet  378ms + Γ£ô caps the Most Used section at 5 entries and orders by frecency score  386ms +stderr | src/features/notifications/pages/NotificationsPage.test.tsx > NotificationsPage > clicking "Mark all read" calls useBulkMarkRead with all=true +Warning: Received `true` for a non-boolean attribute `layout`. + +If you want to write it to the DOM, pass a string instead: layout="true" or layout={value.toString()}. + at div + at D:/repos/teslasync/web/src/features/notifications/pages/NotificationsPage.test.tsx:4:11 + at AnimatePresence (D:/repos/teslasync/web/src/features/notifications/pages/NotificationsPage.test.tsx:14:22) + at div + at ToastProvider (D:/repos/teslasync/web/src/components/feedback/Toast.tsx:91:26) + at SelectedVehicleProvider (D:/repos/teslasync/web/src/store/selectedVehicle.tsx:48:36) + at Router (D:\repos\teslasync\web\node_modules\react-router\dist\umd\react-router.development.js:1207:17) + at MemoryRouter (D:\repos\teslasync\web\node_modules\react-router\dist\umd\react-router.development.js:1101:7) + at QueryClientProvider (file:///D:/repos/teslasync/web/node_modules/@tanstack/react-query/build/modern/QueryClientProvider.js:20:3) + +stderr | src/features/settings/components/__tests__/SettingsSearch.test.tsx > SettingsSearch > renders no listbox when the query is empty +ΓÜá∩╕Å React Router Future Flag Warning: React Router will begin wrapping state updates in `React.startTransition` in v7. You can use the `v7_startTransition` future flag to opt-in early. For more information, see https://reactrouter.com/v6/upgrading/future#v7_starttransition. +ΓÜá∩╕Å React Router Future Flag Warning: Relative route resolution within Splat routes is changing in v7. You can use the `v7_relativeSplatPath` future flag to opt-in early. For more information, see https://reactrouter.com/v6/upgrading/future#v7_relativesplatpath. + + Γ£ô src/components/layout/__tests__/PageContainer.test.tsx (9 tests) 680ms + Γ£ô renders the page title  456ms + Γ£ô src/components/ui/__tests__/focusTrap.test.tsx (9 tests) 812ms + Γ£ô moves initial focus to the first focusable element on open  483ms + Γ£ô src/components/ui/__tests__/PrintButton.test.tsx (7 tests) 853ms + Γ£ô renders a Print button with a visible label  475ms + Γ£ô src/features/notifications/pages/NotificationsPage.test.tsx (10 tests) 3516ms + Γ£ô renders the three tabs (Inbox, Archived, Channels)  1259ms + Γ£ô switches to the Archived tab and shows the empty state  332ms + Γ£ô clicking "Mark all read" calls useBulkMarkRead with all=true  316ms + Γ£ô shows a success toast with an Undo action after "Mark all read"; clicking Undo fires markUnread for the snapshotted ids  443ms + Γ£ô routes the bulk-toolbar "Mark read" through useBulkMarkRead with the selected ids and surfaces an Undo toast  401ms + Γ£ô src/features/settings/components/__tests__/SettingsSearch.test.tsx (16 tests) 1087ms + Γ£ô typing "theme" surfaces the Theme entry in the listbox  409ms + Γ£ô src/features/admin/components/__tests__/RedisDiagnosticEmptyState.test.tsx (9 tests) 1016ms + Γ£ô renders the danger banner when mode=local  557ms + Γ£ô src/components/ui/__tests__/SignalConfigModal.viewport.test.tsx (3 tests) 787ms + Γ£ô renders inside the shared (role="dialog" present)  640ms +stderr | src/components/feedback/__tests__/AchievementUnlockedToast.test.tsx > AchievementUnlockedToast > renders the achievement name and description +Warning: Received `true` for a non-boolean attribute `layout`. + +If you want to write it to the DOM, pass a string instead: layout="true" or layout={value.toString()}. + at div + at D:/repos/teslasync/web/src/components/feedback/__tests__/AchievementUnlockedToast.test.tsx:16:99 + at AchievementUnlockedToast (D:/repos/teslasync/web/src/components/feedback/AchievementUnlockedToast.tsx:34:37) + at Router (D:\repos\teslasync\web\node_modules\react-router\dist\umd\react-router.development.js:1207:17) + at MemoryRouter (D:\repos\teslasync\web\node_modules\react-router\dist\umd\react-router.development.js:1101:7) +ΓÜá∩╕Å React Router Future Flag Warning: React Router will begin wrapping state updates in `React.startTransition` in v7. You can use the `v7_startTransition` future flag to opt-in early. For more information, see https://reactrouter.com/v6/upgrading/future#v7_starttransition. +ΓÜá∩╕Å React Router Future Flag Warning: Relative route resolution within Splat routes is changing in v7. You can use the `v7_relativeSplatPath` future flag to opt-in early. For more information, see https://reactrouter.com/v6/upgrading/future#v7_relativesplatpath. + +stdout | src/components/forms/SearchInput.test.tsx +≡ƒîÉ i18next is made possible by our own product, Locize ΓÇö consider powering your project with managed localization (AI, CDN, integrations): https://locize.com ≡ƒÆÖ + + Γ£ô src/components/feedback/__tests__/AchievementUnlockedToast.test.tsx (5 tests) 918ms + Γ£ô renders the achievement name and description  307ms + Γ£ô exposes a "View" affordance and a dismiss button  435ms +stdout | src/components/feedback/__tests__/TeslaReauthBanner.test.tsx +≡ƒîÉ i18next is made possible by our own product, Locize ΓÇö consider powering your project with managed localization (AI, CDN, integrations): https://locize.com ≡ƒÆÖ + + Γ£ô src/components/feedback/__tests__/NewVersionBanner.test.tsx (6 tests) 697ms + Γ£ô renders the banner with both action buttons when a new version is available  491ms +stdout | src/components/feedback/__tests__/EmptyState.test.tsx +≡ƒîÉ i18next is made possible by our own product, Locize ΓÇö consider powering your project with managed localization (AI, CDN, integrations): https://locize.com ≡ƒÆÖ + +stdout | src/components/data-display/__tests__/BulkActionToolbar.test.tsx +≡ƒîÉ i18next is made possible by our own product, Locize ΓÇö consider powering your project with managed localization (AI, CDN, integrations): https://locize.com ≡ƒÆÖ + +stdout | src/components/feedback/SectionErrorBoundary.test.tsx +≡ƒîÉ i18next is made possible by our own product, Locize ΓÇö consider powering your project with managed localization (AI, CDN, integrations): https://locize.com ≡ƒÆÖ + + Γ£ô src/components/ui/__tests__/HelpTooltip.test.tsx (6 tests) 711ms + Γ£ô renders a focusable button trigger with an accessible label  424ms +stderr | src/components/feedback/__tests__/EmptyState.test.tsx > EmptyState > renders icon, title, and message +ΓÜá∩╕Å React Router Future Flag Warning: React Router will begin wrapping state updates in `React.startTransition` in v7. You can use the `v7_startTransition` future flag to opt-in early. For more information, see https://reactrouter.com/v6/upgrading/future#v7_starttransition. +ΓÜá∩╕Å React Router Future Flag Warning: Relative route resolution within Splat routes is changing in v7. You can use the `v7_relativeSplatPath` future flag to opt-in early. For more information, see https://reactrouter.com/v6/upgrading/future#v7_relativesplatpath. + +Error: Section explosion + at ThrowingComponent (D:/repos/teslasync/web/src/components/feedback/SectionErrorBoundary.test.tsx:7:26) + at renderWithHooks (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:15486:18) + at mountIndeterminateComponent (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:20103:13) + at beginWork (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:21626:16) + at HTMLUnknownElement.callCallback (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:4164:14) + at HTMLUnknownElement.callTheUserObjectsOperation (D:\repos\teslasync\web\node_modules\jsdom\lib\generated\idl\EventListener.js:26:30) + at innerInvokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:360:16) + at invokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:296:3) + at HTMLUnknownElementImpl._dispatch (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:243:9) + at HTMLUnknownElementImpl.dispatchEvent (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:114:17) +Error: Section explosion + at ThrowingComponent (D:/repos/teslasync/web/src/components/feedback/SectionErrorBoundary.test.tsx:7:26) + at renderWithHooks (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:15486:18) + at mountIndeterminateComponent (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:20103:13) + at beginWork (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:21626:16) + at HTMLUnknownElement.callCallback (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:4164:14) + at HTMLUnknownElement.callTheUserObjectsOperation (D:\repos\teslasync\web\node_modules\jsdom\lib\generated\idl\EventListener.js:26:30) + at innerInvokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:360:16) + at invokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:296:3) + at HTMLUnknownElementImpl._dispatch (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:243:9) + at HTMLUnknownElementImpl.dispatchEvent (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:114:17) + Γ£ô src/components/feedback/__tests__/EmptyState.test.tsx (6 tests) 683ms + Γ£ô renders icon, title, and message  454ms +Error: Section explosion + at ThrowingComponent (D:/repos/teslasync/web/src/components/feedback/SectionErrorBoundary.test.tsx:7:26) + at renderWithHooks (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:15486:18) + at mountIndeterminateComponent (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:20103:13) + at beginWork (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:21626:16) + at HTMLUnknownElement.callCallback (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:4164:14) + at HTMLUnknownElement.callTheUserObjectsOperation (D:\repos\teslasync\web\node_modules\jsdom\lib\generated\idl\EventListener.js:26:30) + at innerInvokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:360:16) + at invokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:296:3) + at HTMLUnknownElementImpl._dispatch (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:243:9) + at HTMLUnknownElementImpl.dispatchEvent (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:114:17) +Error: Section explosion + at ThrowingComponent (D:/repos/teslasync/web/src/components/feedback/SectionErrorBoundary.test.tsx:7:26) + at renderWithHooks (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:15486:18) + at mountIndeterminateComponent (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:20103:13) + at beginWork (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:21626:16) + at HTMLUnknownElement.callCallback (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:4164:14) + at HTMLUnknownElement.callTheUserObjectsOperation (D:\repos\teslasync\web\node_modules\jsdom\lib\generated\idl\EventListener.js:26:30) + at innerInvokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:360:16) + at invokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:296:3) + at HTMLUnknownElementImpl._dispatch (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:243:9) + at HTMLUnknownElementImpl.dispatchEvent (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:114:17) +Error: Section explosion + at ThrowingComponent (D:/repos/teslasync/web/src/components/feedback/SectionErrorBoundary.test.tsx:7:26) + at renderWithHooks (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:15486:18) + at mountIndeterminateComponent (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:20103:13) + at beginWork (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:21626:16) + at HTMLUnknownElement.callCallback (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:4164:14) + at HTMLUnknownElement.callTheUserObjectsOperation (D:\repos\teslasync\web\node_modules\jsdom\lib\generated\idl\EventListener.js:26:30) + at innerInvokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:360:16) + at invokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:296:3) + at HTMLUnknownElementImpl._dispatch (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:243:9) + at HTMLUnknownElementImpl.dispatchEvent (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:114:17) +Error: Section explosion + at ThrowingComponent (D:/repos/teslasync/web/src/components/feedback/SectionErrorBoundary.test.tsx:7:26) + at renderWithHooks (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:15486:18) + at mountIndeterminateComponent (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:20103:13) + at beginWork (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:21626:16) + at HTMLUnknownElement.callCallback (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:4164:14) + at HTMLUnknownElement.callTheUserObjectsOperation (D:\repos\teslasync\web\node_modules\jsdom\lib\generated\idl\EventListener.js:26:30) + at innerInvokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:360:16) + at invokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:296:3) + at HTMLUnknownElementImpl._dispatch (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:243:9) + at HTMLUnknownElementImpl.dispatchEvent (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:114:17) +Error: Section explosion + at ThrowingComponent (D:/repos/teslasync/web/src/components/feedback/SectionErrorBoundary.test.tsx:7:26) + at renderWithHooks (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:15486:18) + at mountIndeterminateComponent (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:20103:13) + at beginWork (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:21626:16) + at HTMLUnknownElement.callCallback (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:4164:14) + at HTMLUnknownElement.callTheUserObjectsOperation (D:\repos\teslasync\web\node_modules\jsdom\lib\generated\idl\EventListener.js:26:30) + at innerInvokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:360:16) + at invokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:296:3) + at HTMLUnknownElementImpl._dispatch (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:243:9) + at HTMLUnknownElementImpl.dispatchEvent (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:114:17) +Error: Section explosion + at ThrowingComponent (D:/repos/teslasync/web/src/components/feedback/SectionErrorBoundary.test.tsx:7:26) + at renderWithHooks (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:15486:18) + at mountIndeterminateComponent (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:20103:13) + at beginWork (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:21626:16) + at HTMLUnknownElement.callCallback (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:4164:14) + at HTMLUnknownElement.callTheUserObjectsOperation (D:\repos\teslasync\web\node_modules\jsdom\lib\generated\idl\EventListener.js:26:30) + at innerInvokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:360:16) + at invokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:296:3) + at HTMLUnknownElementImpl._dispatch (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:243:9) + at HTMLUnknownElementImpl.dispatchEvent (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:114:17) + Γ£ô src/components/feedback/SectionErrorBoundary.test.tsx (5 tests) 640ms + Γ£ô falls back to inline UI by default and shows Retry  457ms + Γ£ô src/components/maps/__tests__/RoutePlayback.test.tsx (4 tests) 708ms + Γ£ô renders an accessible application landmark with the supplied aria-label  331ms +stdout | src/features/dashboard/components/__tests__/AddWidgetButton.test.tsx +≡ƒîÉ i18next is made possible by our own product, Locize ΓÇö consider powering your project with managed localization (AI, CDN, integrations): https://locize.com ≡ƒÆÖ + +Error: Page explosion + at ThrowingComponent (D:/repos/teslasync/web/src/components/feedback/PageErrorBoundary.test.tsx:5:26) + at renderWithHooks (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:15486:18) + at mountIndeterminateComponent (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:20103:13) + at beginWork (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:21626:16) + at HTMLUnknownElement.callCallback (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:4164:14) + at HTMLUnknownElement.callTheUserObjectsOperation (D:\repos\teslasync\web\node_modules\jsdom\lib\generated\idl\EventListener.js:26:30) + at innerInvokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:360:16) + at invokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:296:3) + at HTMLUnknownElementImpl._dispatch (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:243:9) + at HTMLUnknownElementImpl.dispatchEvent (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:114:17) +Error: Page explosion + at ThrowingComponent (D:/repos/teslasync/web/src/components/feedback/PageErrorBoundary.test.tsx:5:26) + at renderWithHooks (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:15486:18) + at mountIndeterminateComponent (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:20103:13) + at beginWork (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:21626:16) + at HTMLUnknownElement.callCallback (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:4164:14) + at HTMLUnknownElement.callTheUserObjectsOperation (D:\repos\teslasync\web\node_modules\jsdom\lib\generated\idl\EventListener.js:26:30) + at innerInvokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:360:16) + at invokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:296:3) + at HTMLUnknownElementImpl._dispatch (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:243:9) + at HTMLUnknownElementImpl.dispatchEvent (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:114:17) + Γ£ô src/components/forms/SearchInput.test.tsx (7 tests) 743ms + Γ£ô shows the clear button only when there is text  355ms +stderr | src/components/feedback/__tests__/TeslaReauthBanner.test.tsx > TeslaReauthBanner > renders nothing by default +ΓÜá∩╕Å React Router Future Flag Warning: React Router will begin wrapping state updates in `React.startTransition` in v7. You can use the `v7_startTransition` future flag to opt-in early. For more information, see https://reactrouter.com/v6/upgrading/future#v7_starttransition. +ΓÜá∩╕Å React Router Future Flag Warning: Relative route resolution within Splat routes is changing in v7. You can use the `v7_relativeSplatPath` future flag to opt-in early. For more information, see https://reactrouter.com/v6/upgrading/future#v7_relativesplatpath. + + Γ£ô src/components/forms/FormField.test.tsx (8 tests) 550ms + Γ£ô renders the label and child input  418ms +Error: Page explosion + at ThrowingComponent (D:/repos/teslasync/web/src/components/feedback/PageErrorBoundary.test.tsx:5:26) + at renderWithHooks (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:15486:18) + at mountIndeterminateComponent (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:20103:13) + at beginWork (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:21626:16) + at HTMLUnknownElement.callCallback (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:4164:14) + at HTMLUnknownElement.callTheUserObjectsOperation (D:\repos\teslasync\web\node_modules\jsdom\lib\generated\idl\EventListener.js:26:30) + at innerInvokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:360:16) + at invokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:296:3) + at HTMLUnknownElementImpl._dispatch (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:243:9) + at HTMLUnknownElementImpl.dispatchEvent (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:114:17) +Error: Page explosion + at ThrowingComponent (D:/repos/teslasync/web/src/components/feedback/PageErrorBoundary.test.tsx:5:26) + at renderWithHooks (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:15486:18) + at mountIndeterminateComponent (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:20103:13) + at beginWork (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:21626:16) + at HTMLUnknownElement.callCallback (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:4164:14) + at HTMLUnknownElement.callTheUserObjectsOperation (D:\repos\teslasync\web\node_modules\jsdom\lib\generated\idl\EventListener.js:26:30) + at innerInvokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:360:16) + at invokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:296:3) + at HTMLUnknownElementImpl._dispatch (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:243:9) + at HTMLUnknownElementImpl.dispatchEvent (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:114:17) + Γ£ô src/components/feedback/PageErrorBoundary.test.tsx (3 tests) 601ms + Γ£ô shows the full-page fallback UI on error  492ms + Γ£ô src/components/ui/__tests__/Tooltip.contract.test.tsx (10 tests) 522ms + Γ£ô renders without inheriting any text colour override on the body  453ms + Γ£ô src/components/data-display/SeverityBadge.test.tsx (14 tests) 672ms + Γ£ô exposes an accessible label when provided  342ms + Γ£ô src/components/feedback/__tests__/TeslaReauthBanner.test.tsx (7 tests) 861ms + Γ£ô appears when teslasync:tesla-auth-expired is dispatched  559ms +stderr | src/components/data-display/__tests__/BulkActionToolbar.test.tsx > BulkActionToolbar (alias) > invokes the action onClick with the current selection +Warning: An update to BulkActionsToolbar inside a test was not wrapped in act(...). + +When testing, code that causes React state updates should be wrapped into act(...): + +act(() => { + /* fire events that update state */ +}); +/* assert on the output */ + +This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act + at BulkActionsToolbar (D:/repos/teslasync/web/src/components/data-display/BulkActionsToolbar.tsx:13:31) + + Γ£ô src/components/data-display/__tests__/BulkActionToolbar.test.tsx (4 tests) 704ms + Γ£ô renders the count label and exposes a toolbar landmark  406ms + Γ£ô src/features/dashboard/components/__tests__/AddWidgetButton.test.tsx (4 tests) 606ms + Γ£ô renders a focusable button with an accessible label when not editing  492ms + Γ£ô src/components/feedback/__tests__/PageSkeleton.test.tsx (9 tests) 573ms + Γ£ô respects the height prop  326ms + Γ£ô src/components/data-display/__tests__/LiveIndicator.test.tsx (9 tests) 536ms + Γ£ô exposes role="status" for accessibility  344ms + Γ£ô src/components/layout/sidebar/__tests__/NavSectionHeader.test.tsx (5 tests) 450ms + Γ£ô renders the action slot when provided  325ms + Γ£ô src/components/data-display/Delta.test.tsx (22 tests) 284ms +stdout | src/features/trips/pages/__tests__/TripReplayPage.sync.test.tsx +≡ƒîÉ i18next is made possible by our own product, Locize ΓÇö consider powering your project with managed localization (AI, CDN, integrations): https://locize.com ≡ƒÆÖ + + Γ£ô src/api/hooks/__tests__/useMarkNotificationRead.test.tsx (6 tests) 411ms + Γ£ô src/api/hooks/__tests__/useNotifications.test.tsx (14 tests) 448ms +Error: useToast must be used within ToastProvider + at Module.useToast (D:/repos/teslasync/web/src/components/feedback/Toast.tsx:88:19) + at Orphan (D:/repos/teslasync/web/src/components/feedback/Toast.test.tsx:119:7) + at renderWithHooks (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:15486:18) + at mountIndeterminateComponent (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:20103:13) + at beginWork (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:21626:16) + at HTMLUnknownElement.callCallback (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:4164:14) + at HTMLUnknownElement.callTheUserObjectsOperation (D:\repos\teslasync\web\node_modules\jsdom\lib\generated\idl\EventListener.js:26:30) + at innerInvokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:360:16) + at invokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:296:3) + at HTMLUnknownElementImpl._dispatch (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:243:9) +Error: useToast must be used within ToastProvider + at Module.useToast (D:/repos/teslasync/web/src/components/feedback/Toast.tsx:88:19) + at Orphan (D:/repos/teslasync/web/src/components/feedback/Toast.test.tsx:119:7) + at renderWithHooks (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:15486:18) + at mountIndeterminateComponent (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:20103:13) + at beginWork (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:21626:16) + at HTMLUnknownElement.callCallback (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:4164:14) + at HTMLUnknownElement.callTheUserObjectsOperation (D:\repos\teslasync\web\node_modules\jsdom\lib\generated\idl\EventListener.js:26:30) + at innerInvokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:360:16) + at invokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:296:3) + at HTMLUnknownElementImpl._dispatch (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:243:9) + Γ£ô src/components/feedback/Toast.test.tsx (9 tests) 375ms + Γ£ô src/components/data-display/__tests__/DataFreshness.test.tsx (24 tests) 491ms + Γ£ô src/api/hooks/__tests__/useBulkMarkRead.test.tsx (7 tests) 373ms +stdout | src/components/feedback/__tests__/RateLimitBanner.test.tsx +≡ƒîÉ i18next is made possible by our own product, Locize ΓÇö consider powering your project with managed localization (AI, CDN, integrations): https://locize.com ≡ƒÆÖ + +stdout | src/__tests__/mobile.viewport.test.tsx > mobile.viewport :: shared primitives at 375px > Modal renders as a bottom sheet on mobile (no sm:rounded clipping) +≡ƒîÉ i18next is made possible by our own product, Locize ΓÇö consider powering your project with managed localization (AI, CDN, integrations): https://locize.com ≡ƒÆÖ + + Γ£ô src/components/vehicles/__tests__/VehicleTwin.test.tsx (2 tests) 376ms + Γ£ô src/components/charts/ChartTooltip.test.tsx (10 tests) 323ms +stderr | src/__tests__/mobile.viewport.test.tsx > mobile.viewport :: shared primitives at 375px > BottomTabBar carries safe-bottom + per-tab 44 px touch targets +ΓÜá∩╕Å React Router Future Flag Warning: React Router will begin wrapping state updates in `React.startTransition` in v7. You can use the `v7_startTransition` future flag to opt-in early. For more information, see https://reactrouter.com/v6/upgrading/future#v7_starttransition. +ΓÜá∩╕Å React Router Future Flag Warning: Relative route resolution within Splat routes is changing in v7. You can use the `v7_relativeSplatPath` future flag to opt-in early. For more information, see https://reactrouter.com/v6/upgrading/future#v7_relativesplatpath. + + Γ£ô src/components/data-display/format/__tests__/Format.test.tsx (60 tests) 343ms + Γ£ô src/components/feedback/__tests__/RateLimitBanner.test.tsx (9 tests) 277ms + Γ£ô src/__tests__/mobile.viewport.test.tsx (9 tests) 392ms +stderr | src/features/trips/pages/__tests__/TripReplayPage.sync.test.tsx > Phase-45 / Prompt 26 ΓÇö TripReplayCharts cursor bridge > forwards persistent cursor-sync writes to onSeekToIndex (idx mapping) +Warning: A props object containing a "key" prop is being spread into JSX: + let props = {key: someKey, $$typeof: ..., type: ..., ref: ..., props: ..., _owner: ..., _store: ...}; + +React keys must be passed directly to JSX without using spread: + let props = {$$typeof: ..., type: ..., ref: ..., props: ..., _owner: ..., _store: ...}; + + at TimelineChart (D:/repos/teslasync/web/src/features/trips/components/TripReplayCharts.tsx:54:26) + at ChartTimeRangeProvider (D:/repos/teslasync/web/src/components/charts/ChartTimeRangeContext.tsx:13:35) + at TripReplayCharts (D:/repos/teslasync/web/src/features/trips/components/TripReplayCharts.tsx:26:29) + at ToastProvider (D:/repos/teslasync/web/src/components/feedback/Toast.tsx:91:26) + at QueryClientProvider (file:///D:/repos/teslasync/web/node_modules/@tanstack/react-query/build/modern/QueryClientProvider.js:20:3) + + Γ£ô src/features/trips/pages/__tests__/TripReplayPage.sync.test.tsx (19 tests) 446ms +stdout | src/components/data-display/__tests__/TimeStamp.test.tsx +≡ƒîÉ i18next is made possible by our own product, Locize ΓÇö consider powering your project with managed localization (AI, CDN, integrations): https://locize.com ≡ƒÆÖ + + Γ£ô src/api/hooks/__tests__/useOptimisticMutation.test.tsx (9 tests) 312ms +Error: Test explosion + at ThrowingComponent (D:/repos/teslasync/web/src/components/feedback/ErrorBoundary.test.tsx:6:26) + at renderWithHooks (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:15486:18) + at mountIndeterminateComponent (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:20103:13) + at beginWork (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:21626:16) + at HTMLUnknownElement.callCallback (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:4164:14) + at HTMLUnknownElement.callTheUserObjectsOperation (D:\repos\teslasync\web\node_modules\jsdom\lib\generated\idl\EventListener.js:26:30) + at innerInvokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:360:16) + at invokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:296:3) + at HTMLUnknownElementImpl._dispatch (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:243:9) + at HTMLUnknownElementImpl.dispatchEvent (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:114:17) +Error: Test explosion + at ThrowingComponent (D:/repos/teslasync/web/src/components/feedback/ErrorBoundary.test.tsx:6:26) + at renderWithHooks (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:15486:18) + at mountIndeterminateComponent (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:20103:13) + at beginWork (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:21626:16) + at HTMLUnknownElement.callCallback (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:4164:14) + at HTMLUnknownElement.callTheUserObjectsOperation (D:\repos\teslasync\web\node_modules\jsdom\lib\generated\idl\EventListener.js:26:30) + at innerInvokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:360:16) + at invokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:296:3) + at HTMLUnknownElementImpl._dispatch (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:243:9) + at HTMLUnknownElementImpl.dispatchEvent (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:114:17) +Error: Test explosion + at ThrowingComponent (D:/repos/teslasync/web/src/components/feedback/ErrorBoundary.test.tsx:6:26) + at renderWithHooks (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:15486:18) + at mountIndeterminateComponent (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:20103:13) + at beginWork (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:21626:16) + at HTMLUnknownElement.callCallback (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:4164:14) + at HTMLUnknownElement.callTheUserObjectsOperation (D:\repos\teslasync\web\node_modules\jsdom\lib\generated\idl\EventListener.js:26:30) + at innerInvokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:360:16) + at invokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:296:3) + at HTMLUnknownElementImpl._dispatch (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:243:9) + at HTMLUnknownElementImpl.dispatchEvent (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:114:17) +Error: Test explosion + at ThrowingComponent (D:/repos/teslasync/web/src/components/feedback/ErrorBoundary.test.tsx:6:26) + at renderWithHooks (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:15486:18) + at mountIndeterminateComponent (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:20103:13) + at beginWork (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:21626:16) + at HTMLUnknownElement.callCallback (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:4164:14) + at HTMLUnknownElement.callTheUserObjectsOperation (D:\repos\teslasync\web\node_modules\jsdom\lib\generated\idl\EventListener.js:26:30) + at innerInvokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:360:16) + at invokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:296:3) + at HTMLUnknownElementImpl._dispatch (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:243:9) + at HTMLUnknownElementImpl.dispatchEvent (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:114:17) +Error: Test explosion + at ThrowingComponent (D:/repos/teslasync/web/src/components/feedback/ErrorBoundary.test.tsx:6:26) + at renderWithHooks (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:15486:18) + at mountIndeterminateComponent (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:20103:13) + at beginWork (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:21626:16) + at HTMLUnknownElement.callCallback (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:4164:14) + at HTMLUnknownElement.callTheUserObjectsOperation (D:\repos\teslasync\web\node_modules\jsdom\lib\generated\idl\EventListener.js:26:30) + at innerInvokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:360:16) + at invokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:296:3) + at HTMLUnknownElementImpl._dispatch (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:243:9) + at HTMLUnknownElementImpl.dispatchEvent (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:114:17) +Error: Test explosion + at ThrowingComponent (D:/repos/teslasync/web/src/components/feedback/ErrorBoundary.test.tsx:6:26) + at renderWithHooks (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:15486:18) + at mountIndeterminateComponent (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:20103:13) + at beginWork (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:21626:16) + at HTMLUnknownElement.callCallback (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:4164:14) + at HTMLUnknownElement.callTheUserObjectsOperation (D:\repos\teslasync\web\node_modules\jsdom\lib\generated\idl\EventListener.js:26:30) + at innerInvokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:360:16) + at invokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:296:3) + at HTMLUnknownElementImpl._dispatch (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:243:9) + at HTMLUnknownElementImpl.dispatchEvent (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:114:17) +Error: Test explosion + at ThrowingComponent (D:/repos/teslasync/web/src/components/feedback/ErrorBoundary.test.tsx:6:26) + at renderWithHooks (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:15486:18) + at mountIndeterminateComponent (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:20103:13) + at beginWork (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:21626:16) + at HTMLUnknownElement.callCallback (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:4164:14) + at HTMLUnknownElement.callTheUserObjectsOperation (D:\repos\teslasync\web\node_modules\jsdom\lib\generated\idl\EventListener.js:26:30) + at innerInvokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:360:16) + at invokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:296:3) + at HTMLUnknownElementImpl._dispatch (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:243:9) + at HTMLUnknownElementImpl.dispatchEvent (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:114:17) +Error: Test explosion + at ThrowingComponent (D:/repos/teslasync/web/src/components/feedback/ErrorBoundary.test.tsx:6:26) + at renderWithHooks (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:15486:18) + at mountIndeterminateComponent (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:20103:13) + at beginWork (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:21626:16) + at HTMLUnknownElement.callCallback (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:4164:14) + at HTMLUnknownElement.callTheUserObjectsOperation (D:\repos\teslasync\web\node_modules\jsdom\lib\generated\idl\EventListener.js:26:30) + at innerInvokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:360:16) + at invokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:296:3) + at HTMLUnknownElementImpl._dispatch (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:243:9) + at HTMLUnknownElementImpl.dispatchEvent (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:114:17) +Error: Test explosion + at ThrowingComponent (D:/repos/teslasync/web/src/components/feedback/ErrorBoundary.test.tsx:6:26) + at renderWithHooks (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:15486:18) + at mountIndeterminateComponent (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:20103:13) + at beginWork (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:21626:16) + at HTMLUnknownElement.callCallback (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:4164:14) + at HTMLUnknownElement.callTheUserObjectsOperation (D:\repos\teslasync\web\node_modules\jsdom\lib\generated\idl\EventListener.js:26:30) + at innerInvokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:360:16) + at invokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:296:3) + at HTMLUnknownElementImpl._dispatch (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:243:9) + at HTMLUnknownElementImpl.dispatchEvent (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:114:17) +Error: Test explosion + at ThrowingComponent (D:/repos/teslasync/web/src/components/feedback/ErrorBoundary.test.tsx:6:26) + at renderWithHooks (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:15486:18) + at mountIndeterminateComponent (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:20103:13) + at beginWork (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:21626:16) + at HTMLUnknownElement.callCallback (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:4164:14) + at HTMLUnknownElement.callTheUserObjectsOperation (D:\repos\teslasync\web\node_modules\jsdom\lib\generated\idl\EventListener.js:26:30) + at innerInvokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:360:16) + at invokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:296:3) + at HTMLUnknownElementImpl._dispatch (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:243:9) + at HTMLUnknownElementImpl.dispatchEvent (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:114:17) +Error: Test explosion + at ThrowingComponent (D:/repos/teslasync/web/src/components/feedback/ErrorBoundary.test.tsx:6:26) + at renderWithHooks (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:15486:18) + at mountIndeterminateComponent (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:20103:13) + at beginWork (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:21626:16) + at HTMLUnknownElement.callCallback (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:4164:14) + at HTMLUnknownElement.callTheUserObjectsOperation (D:\repos\teslasync\web\node_modules\jsdom\lib\generated\idl\EventListener.js:26:30) + at innerInvokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:360:16) + at invokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:296:3) + at HTMLUnknownElementImpl._dispatch (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:243:9) + at HTMLUnknownElementImpl.dispatchEvent (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:114:17) +Error: Test explosion + at ThrowingComponent (D:/repos/teslasync/web/src/components/feedback/ErrorBoundary.test.tsx:6:26) + at renderWithHooks (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:15486:18) + at mountIndeterminateComponent (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:20103:13) + at beginWork (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:21626:16) + at HTMLUnknownElement.callCallback (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:4164:14) + at HTMLUnknownElement.callTheUserObjectsOperation (D:\repos\teslasync\web\node_modules\jsdom\lib\generated\idl\EventListener.js:26:30) + at innerInvokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:360:16) + at invokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:296:3) + at HTMLUnknownElementImpl._dispatch (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:243:9) + at HTMLUnknownElementImpl.dispatchEvent (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:114:17) + Γ£ô src/components/feedback/ErrorBoundary.test.tsx (7 tests) 380ms + Γ£ô src/components/ui/__tests__/DataTable.tableId-coverage.test.tsx (2 tests) 294ms +stderr | src/components/layout/__tests__/Breadcrumbs.test.tsx > Breadcrumbs > renders nothing when items.length <= 1 +react-i18next:: useTranslation: You will need to pass in an i18next instance by using initReactI18next { code: 'NO_I18NEXT_INSTANCE' } +ΓÜá∩╕Å React Router Future Flag Warning: React Router will begin wrapping state updates in `React.startTransition` in v7. You can use the `v7_startTransition` future flag to opt-in early. For more information, see https://reactrouter.com/v6/upgrading/future#v7_starttransition. +ΓÜá∩╕Å React Router Future Flag Warning: Relative route resolution within Splat routes is changing in v7. You can use the `v7_relativeSplatPath` future flag to opt-in early. For more information, see https://reactrouter.com/v6/upgrading/future#v7_relativesplatpath. + +stderr | src/hooks/__tests__/useUrlState.test.tsx > useUrlState > returns the default when the param is absent +ΓÜá∩╕Å React Router Future Flag Warning: React Router will begin wrapping state updates in `React.startTransition` in v7. You can use the `v7_startTransition` future flag to opt-in early. For more information, see https://reactrouter.com/v6/upgrading/future#v7_starttransition. +ΓÜá∩╕Å React Router Future Flag Warning: Relative route resolution within Splat routes is changing in v7. You can use the `v7_relativeSplatPath` future flag to opt-in early. For more information, see https://reactrouter.com/v6/upgrading/future#v7_relativesplatpath. + + Γ£ô src/components/layout/__tests__/Breadcrumbs.test.tsx (9 tests) 243ms + Γ£ô src/hooks/__tests__/useUrlState.test.tsx (22 tests) 333ms + Γ£ô src/components/data-display/InsightsEngine.test.tsx (3 tests) 241ms + Γ£ô src/__tests__/lazyRoutes.smoke.test.ts (104 tests) 25052ms + Γ£ô Dashboard chunk imports without throwing  5633ms + Γ£ô GlancePage chunk imports without throwing  1083ms + Γ£ô VehicleDetail chunk imports without throwing  497ms + Γ£ô Charging chunk imports without throwing  705ms + Γ£ô ChargingCurve chunk imports without throwing  461ms + Γ£ô CostAnalysis chunk imports without throwing  689ms + Γ£ô BatteryHealth chunk imports without throwing  323ms + Γ£ô DriveDetail chunk imports without throwing  1278ms + Γ£ô DrivingDynamics chunk imports without throwing  492ms + Γ£ô DrivetrainHealth chunk imports without throwing  512ms + Γ£ô Analytics chunk imports without throwing  411ms + Γ£ô WeeklyDigest chunk imports without throwing  429ms + Γ£ô YearReview chunk imports without throwing  415ms + Γ£ô Geofences chunk imports without throwing  308ms + Γ£ô AutomationsListPage chunk imports without throwing  534ms + Γ£ô AlertStudio chunk imports without throwing  308ms + Γ£ô DevTools chunk imports without throwing  787ms + Γ£ô SecurityAccess chunk imports without throwing  317ms + Γ£ô SystemStatus chunk imports without throwing  699ms + Γ£ô Commands chunk imports without throwing  389ms + Γ£ô Chatbot chunk imports without throwing  304ms + Γ£ô Settings chunk imports without throwing  464ms + Γ£ô src/components/data-display/__tests__/TimeStamp.test.tsx (11 tests) 284ms + Γ£ô src/hooks/__tests__/useFaviconBadge.test.tsx (5 tests) 326ms +stdout | src/components/forms/__tests__/DateRangeFilter.test.tsx +≡ƒîÉ i18next is made possible by our own product, Locize ΓÇö consider powering your project with managed localization (AI, CDN, integrations): https://locize.com ≡ƒÆÖ + +stdout | src/hooks/__tests__/useDirtyForm.test.ts +≡ƒîÉ i18next is made possible by our own product, Locize ΓÇö consider powering your project with managed localization (AI, CDN, integrations): https://locize.com ≡ƒÆÖ + + Γ£ô src/components/charts/ChartTimeRangeContext.test.tsx (11 tests) 135ms + Γ£ô src/hooks/__tests__/useDirtyForm.test.ts (6 tests) 124ms + Γ£ô src/hooks/__tests__/useFormDraft.test.tsx (14 tests) 123ms + Γ£ô src/components/forms/__tests__/DateRangeFilter.test.tsx (3 tests) 205ms +Error: L is not defined + at Throw (D:/repos/teslasync/web/src/components/feedback/__tests__/ErrorBoundary.routeReset.test.tsx:6:9) + at renderWithHooks (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:15486:18) + at mountIndeterminateComponent (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:20103:13) + at beginWork (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:21626:16) + at HTMLUnknownElement.callCallback (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:4164:14) + at HTMLUnknownElement.callTheUserObjectsOperation (D:\repos\teslasync\web\node_modules\jsdom\lib\generated\idl\EventListener.js:26:30) + at innerInvokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:360:16) + at invokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:296:3) + at HTMLUnknownElementImpl._dispatch (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:243:9) + at HTMLUnknownElementImpl.dispatchEvent (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:114:17) +Error: L is not defined + at Throw (D:/repos/teslasync/web/src/components/feedback/__tests__/ErrorBoundary.routeReset.test.tsx:6:9) + at renderWithHooks (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:15486:18) + at mountIndeterminateComponent (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:20103:13) + at beginWork (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:21626:16) + at HTMLUnknownElement.callCallback (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:4164:14) + at HTMLUnknownElement.callTheUserObjectsOperation (D:\repos\teslasync\web\node_modules\jsdom\lib\generated\idl\EventListener.js:26:30) + at innerInvokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:360:16) + at invokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:296:3) + at HTMLUnknownElementImpl._dispatch (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:243:9) + at HTMLUnknownElementImpl.dispatchEvent (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:114:17) +Error: boom + at Throw (D:/repos/teslasync/web/src/components/feedback/__tests__/ErrorBoundary.routeReset.test.tsx:6:9) + at renderWithHooks (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:15486:18) + at mountIndeterminateComponent (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:20103:13) + at beginWork (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:21626:16) + at HTMLUnknownElement.callCallback (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:4164:14) + at HTMLUnknownElement.callTheUserObjectsOperation (D:\repos\teslasync\web\node_modules\jsdom\lib\generated\idl\EventListener.js:26:30) + at innerInvokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:360:16) + at invokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:296:3) + at HTMLUnknownElementImpl._dispatch (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:243:9) + at HTMLUnknownElementImpl.dispatchEvent (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:114:17) +Error: boom + at Throw (D:/repos/teslasync/web/src/components/feedback/__tests__/ErrorBoundary.routeReset.test.tsx:6:9) + at renderWithHooks (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:15486:18) + at mountIndeterminateComponent (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:20103:13) + at beginWork (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:21626:16) + at HTMLUnknownElement.callCallback (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:4164:14) + at HTMLUnknownElement.callTheUserObjectsOperation (D:\repos\teslasync\web\node_modules\jsdom\lib\generated\idl\EventListener.js:26:30) + at innerInvokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:360:16) + at invokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:296:3) + at HTMLUnknownElementImpl._dispatch (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:243:9) + at HTMLUnknownElementImpl.dispatchEvent (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:114:17) + Γ£ô src/components/feedback/__tests__/ErrorBoundary.routeReset.test.tsx (3 tests) 324ms + Γ£ô src/features/system/components/state-machine/__tests__/SnapshotInspector.empty.test.tsx (5 tests) 269ms +stderr | src/hooks/__tests__/useSelectedVehicle.test.tsx > useSelectedVehicle > defaults to the first vehicle when nothing is selected +ΓÜá∩╕Å React Router Future Flag Warning: React Router will begin wrapping state updates in `React.startTransition` in v7. You can use the `v7_startTransition` future flag to opt-in early. For more information, see https://reactrouter.com/v6/upgrading/future#v7_starttransition. +ΓÜá∩╕Å React Router Future Flag Warning: Relative route resolution within Splat routes is changing in v7. You can use the `v7_relativeSplatPath` future flag to opt-in early. For more information, see https://reactrouter.com/v6/upgrading/future#v7_relativesplatpath. + + Γ£ô src/hooks/__tests__/useSelectedVehicle.test.tsx (7 tests) 216ms + Γ£ô src/lib/__tests__/resilience.abort.test.ts (6 tests) 183ms +Not implemented: HTMLCanvasElement's getContext() method: without installing the canvas npm package +Not implemented: HTMLCanvasElement's getContext() method: without installing the canvas npm package +Not implemented: HTMLCanvasElement's getContext() method: without installing the canvas npm package +Not implemented: HTMLCanvasElement's getContext() method: without installing the canvas npm package +Not implemented: HTMLCanvasElement's getContext() method: without installing the canvas npm package +Not implemented: HTMLCanvasElement's getContext() method: without installing the canvas npm package +Not implemented: HTMLCanvasElement's getContext() method: without installing the canvas npm package +Not implemented: HTMLCanvasElement's getContext() method: without installing the canvas npm package +Not implemented: HTMLCanvasElement's getContext() method: without installing the canvas npm package +Not implemented: HTMLCanvasElement's getContext() method: without installing the canvas npm package +Not implemented: HTMLCanvasElement's getContext() method: without installing the canvas npm package +Not implemented: HTMLCanvasElement's getContext() method: without installing the canvas npm package +Not implemented: HTMLCanvasElement's getContext() method: without installing the canvas npm package +Not implemented: HTMLCanvasElement's getContext() method: without installing the canvas npm package +Not implemented: HTMLCanvasElement's getContext() method: without installing the canvas npm package +Not implemented: HTMLCanvasElement's getContext() method: without installing the canvas npm package +Not implemented: HTMLCanvasElement's getContext() method: without installing the canvas npm package +Not implemented: HTMLCanvasElement's getContext() method: without installing the canvas npm package +Not implemented: HTMLCanvasElement's getContext() method: without installing the canvas npm package +Not implemented: HTMLCanvasElement's getContext() method: without installing the canvas npm package +Not implemented: HTMLCanvasElement's getContext() method: without installing the canvas npm package +Not implemented: HTMLCanvasElement's getContext() method: without installing the canvas npm package +Not implemented: HTMLCanvasElement's getContext() method: without installing the canvas npm package +Not implemented: HTMLCanvasElement's getContext() method: without installing the canvas npm package +Not implemented: HTMLCanvasElement's getContext() method: without installing the canvas npm package +Not implemented: HTMLCanvasElement's getContext() method: without installing the canvas npm package +Not implemented: HTMLCanvasElement's getContext() method: without installing the canvas npm package +Not implemented: HTMLCanvasElement's getContext() method: without installing the canvas npm package +Not implemented: HTMLCanvasElement's getContext() method: without installing the canvas npm package +Not implemented: HTMLCanvasElement's getContext() method: without installing the canvas npm package +Not implemented: HTMLCanvasElement's getContext() method: without installing the canvas npm package +Not implemented: HTMLCanvasElement's getContext() method: without installing the canvas npm package +Not implemented: HTMLCanvasElement's getContext() method: without installing the canvas npm package +Not implemented: HTMLCanvasElement's getContext() method: without installing the canvas npm package +Not implemented: HTMLCanvasElement's getContext() method: without installing the canvas npm package +Not implemented: HTMLCanvasElement's getContext() method: without installing the canvas npm package +Not implemented: HTMLCanvasElement's getContext() method: without installing the canvas npm package +Not implemented: HTMLCanvasElement's getContext() method: without installing the canvas npm package +Not implemented: HTMLCanvasElement's getContext() method: without installing the canvas npm package +Not implemented: HTMLCanvasElement's getContext() method: without installing the canvas npm package +Not implemented: HTMLCanvasElement's getContext() method: without installing the canvas npm package +Not implemented: HTMLCanvasElement's getContext() method: without installing the canvas npm package +Not implemented: HTMLCanvasElement's getContext() method: without installing the canvas npm package +Not implemented: HTMLCanvasElement's getContext() method: without installing the canvas npm package +Not implemented: HTMLCanvasElement's getContext() method: without installing the canvas npm package + Γ£ô src/hooks/__tests__/useDynamicAppIcon.test.tsx (8 tests) 217ms +stderr | src/components/motion/__tests__/RouteTransition.test.tsx > RouteTransition > renders children for the current route +ΓÜá∩╕Å React Router Future Flag Warning: React Router will begin wrapping state updates in `React.startTransition` in v7. You can use the `v7_startTransition` future flag to opt-in early. For more information, see https://reactrouter.com/v6/upgrading/future#v7_starttransition. +ΓÜá∩╕Å React Router Future Flag Warning: Relative route resolution within Splat routes is changing in v7. You can use the `v7_relativeSplatPath` future flag to opt-in early. For more information, see https://reactrouter.com/v6/upgrading/future#v7_relativesplatpath. + + Γ£ô src/components/motion/__tests__/RouteTransition.test.tsx (7 tests) 183ms + Γ£ô src/hooks/__tests__/useTitleBadge.test.tsx (7 tests) 209ms + Γ£ô src/lib/gpx.test.ts (7 tests) 82ms + Γ£ô src/components/feedback/PageLoadSkeleton.test.tsx (3 tests) 117ms + Γ£ô src/features/system/components/state-machine/__tests__/StateTimeline.empty.test.tsx (5 tests) 143ms + Γ£ô src/hooks/__tests__/useChartPalette.test.tsx (11 tests) 153ms + Γ£ô src/components/ui/Logo.test.tsx (8 tests) 152ms +stderr | src/hooks/__tests__/useDensitySync.test.tsx > useDensitySync > ignores unknown density values from the server +Warning: An update to TestComponent inside a test was not wrapped in act(...). + +When testing, code that causes React state updates should be wrapped into act(...): + +act(() => { + /* fire events that update state */ +}); +/* assert on the output */ + +This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act + at TestComponent (D:\repos\teslasync\web\node_modules\@testing-library\react\dist\pure.js:328:5) + at QueryClientProvider (file:///D:/repos/teslasync/web/node_modules/@tanstack/react-query/build/modern/QueryClientProvider.js:20:3) + at D:/repos/teslasync/web/src/hooks/__tests__/useDensitySync.test.tsx:20:12 + + Γ£ô src/hooks/__tests__/useDensitySync.test.tsx (4 tests) 144ms + Γ£ô src/features/system/components/state-machine/__tests__/StateTimeline.test.tsx (3 tests) 248ms +stderr | src/hooks/__tests__/useShortcutRegistry.test.tsx > useShortcutRegistry > useShortcut + useAllShortcuts > registers an entry on mount and removes on unmount +ΓÜá∩╕Å React Router Future Flag Warning: React Router will begin wrapping state updates in `React.startTransition` in v7. You can use the `v7_startTransition` future flag to opt-in early. For more information, see https://reactrouter.com/v6/upgrading/future#v7_starttransition. +ΓÜá∩╕Å React Router Future Flag Warning: Relative route resolution within Splat routes is changing in v7. You can use the `v7_relativeSplatPath` future flag to opt-in early. For more information, see https://reactrouter.com/v6/upgrading/future#v7_relativesplatpath. + +stderr | src/hooks/__tests__/useShortcutRegistry.test.tsx > useShortcutRegistry > delegated handler > skips route-scoped handlers when the current route does not match +Warning: An update to TestComponent inside a test was not wrapped in act(...). + +When testing, code that causes React state updates should be wrapped into act(...): + +act(() => { + /* fire events that update state */ +}); +/* assert on the output */ + +This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act + at TestComponent (D:\repos\teslasync\web\node_modules\@testing-library\react\dist\pure.js:328:5) + at Router (D:\repos\teslasync\web\node_modules\react-router\dist\umd\react-router.development.js:1207:17) + at MemoryRouter (D:\repos\teslasync\web\node_modules\react-router\dist\umd\react-router.development.js:1101:7) + at D:/repos/teslasync/web/src/hooks/__tests__/useShortcutRegistry.test.tsx:13:12 + + Γ£ô src/hooks/__tests__/useShortcutRegistry.test.tsx (11 tests) 140ms + Γ£ô src/lib/__tests__/broadcast.test.ts (10 tests) 97ms + Γ£ô src/components/maps/__tests__/MarkerCluster.test.tsx (4 tests) 81ms + Γ£ô src/components/data-display/__tests__/SourceLayerBadge.test.tsx (6 tests) 212ms + Γ£ô src/hooks/__tests__/useVehiclePaint.test.tsx (14 tests) 159ms +Error: useSelectedVehicleStore must be used within + at useSelectedVehicleStore (D:/repos/teslasync/web/src/store/selectedVehicle.tsx:97:11) + at Probe (D:/repos/teslasync/web/src/store/__tests__/selectedVehicle.test.tsx:88:7) + at renderWithHooks (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:15486:18) + at mountIndeterminateComponent (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:20103:13) + at beginWork (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:21626:16) + at HTMLUnknownElement.callCallback (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:4164:14) + at HTMLUnknownElement.callTheUserObjectsOperation (D:\repos\teslasync\web\node_modules\jsdom\lib\generated\idl\EventListener.js:26:30) + at innerInvokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:360:16) + at invokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:296:3) + at HTMLUnknownElementImpl._dispatch (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:243:9) +Error: useSelectedVehicleStore must be used within + at useSelectedVehicleStore (D:/repos/teslasync/web/src/store/selectedVehicle.tsx:97:11) + at Probe (D:/repos/teslasync/web/src/store/__tests__/selectedVehicle.test.tsx:88:7) + at renderWithHooks (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:15486:18) + at mountIndeterminateComponent (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:20103:13) + at beginWork (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:21626:16) + at HTMLUnknownElement.callCallback (D:\repos\teslasync\web\node_modules\react-dom\cjs\react-dom.development.js:4164:14) + at HTMLUnknownElement.callTheUserObjectsOperation (D:\repos\teslasync\web\node_modules\jsdom\lib\generated\idl\EventListener.js:26:30) + at innerInvokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:360:16) + at invokeEventListeners (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:296:3) + at HTMLUnknownElementImpl._dispatch (D:\repos\teslasync\web\node_modules\jsdom\lib\jsdom\living\events\EventTarget-impl.js:243:9) + Γ£ô src/store/__tests__/selectedVehicle.test.tsx (8 tests) 132ms + Γ£ô src/lib/__tests__/webVitalsReporter.test.ts (9 tests) 94ms + Γ£ô src/hooks/__tests__/useFilteredList.test.ts (10 tests) 93ms + Γ£ô src/hooks/__tests__/useCriticalAlertFlash.test.tsx (11 tests) 186ms + Γ£ô src/lib/certificate.test.ts (11 tests) 90ms + Γ£ô src/features/onboarding/__tests__/checklist.test.ts (21 tests) 119ms + Γ£ô src/hooks/__tests__/useLiveConnection.test.tsx (7 tests) 114ms + Γ£ô src/components/charts/__tests__/cursorSync.test.ts (10 tests) 90ms + Γ£ô src/components/charts/useChartLegendState.test.tsx (9 tests) 105ms + Γ£ô src/hooks/__tests__/useChangelog.test.tsx (14 tests) 93ms +stderr | src/hooks/__tests__/useAlertContext.test.tsx > useAlertContext > returns nulls and hasContext=false when no params present +ΓÜá∩╕Å React Router Future Flag Warning: React Router will begin wrapping state updates in `React.startTransition` in v7. You can use the `v7_startTransition` future flag to opt-in early. For more information, see https://reactrouter.com/v6/upgrading/future#v7_starttransition. +ΓÜá∩╕Å React Router Future Flag Warning: Relative route resolution within Splat routes is changing in v7. You can use the `v7_relativeSplatPath` future flag to opt-in early. For more information, see https://reactrouter.com/v6/upgrading/future#v7_relativesplatpath. + + Γ£ô src/hooks/__tests__/useAlertContext.test.tsx (5 tests) 88ms + Γ£ô src/lib/__tests__/timezone.test.ts (7 tests) 80ms + Γ£ô src/hooks/__tests__/useBulkSelection.test.tsx (9 tests) 117ms + Γ£ô src/components/charts/Charts.test.ts (17 tests) 91ms + Γ£ô src/hooks/__tests__/useVersionWatcher.test.tsx (5 tests) 125ms + Γ£ô src/lib/__tests__/dateFormat.duration.test.ts (55 tests) 105ms + Γ£ô src/lib/__tests__/errorReporter.test.ts (19 tests) 76ms + Γ£ô src/lib/__tests__/dateFormat.test.ts (10 tests) 75ms + Γ£ô src/lib/__tests__/rateLimit.test.ts (15 tests) 97ms + Γ£ô src/types/fsm/__tests__/transition-integrity.test.ts (24 tests) 62ms + Γ£ô src/features/admin/lib/__tests__/serviceOptions.test.ts (13 tests) 52ms + Γ£ô src/features/onboarding/hooks/useOnboardingSkip.test.ts (4 tests) 67ms + Γ£ô src/components/maps/__tests__/GeofenceDrawer.test.tsx (4 tests) 58ms + Γ£ô src/hooks/__tests__/useMotionPreference.test.ts (5 tests) 58ms + Γ£ô src/types/fsm/__tests__/coverage-matrix.test.ts (30 tests) 47ms + Γ£ô src/lib/export.test.ts (8 tests) 20ms + Γ£ô src/lib/__tests__/closestRoute.test.ts (9 tests) 59ms + Γ£ô src/features/onboarding/__tests__/tours.test.ts (12 tests) 48ms + Γ£ô src/types/fsm/__tests__/scenario-walkability.test.ts (24 tests) 29ms + Γ£ô src/lib/__tests__/commandFrecency.test.ts (13 tests) 48ms + Γ£ô src/lib/__tests__/teslaAuthRecovery.test.ts (9 tests) 46ms + Γ£ô src/lib/__tests__/titleStore.test.ts (9 tests) 34ms + Γ£ô src/lib/datePresets.test.ts (24 tests) 38ms + Γ£ô src/features/driving/lib/__tests__/replayMarkers.test.ts (17 tests) 32ms + Γ£ô src/lib/__tests__/commandRegistry.test.ts (14 tests) 68ms + Γ£ô src/lib/__tests__/vehicleColors.test.ts (32 tests) 31ms + Γ£ô src/lib/alertDrillthrough.test.ts (12 tests) 19ms + Γ£ô src/lib/__tests__/confirmSilence.test.ts (14 tests) 30ms + Γ£ô src/lib/vehicleState.test.ts (23 tests) 25ms + Γ£ô src/lib/parseEnums.test.ts (33 tests) 24ms + Γ£ô src/types/fsm/__tests__/disallowed.test.ts (25 tests) 22ms + Γ£ô src/types/fsm/__tests__/edge-derivation.test.ts (25 tests) 25ms + Γ£ô src/types/fsm/__tests__/truth-table.test.ts (6 tests) 23ms + Γ£ô src/types/fsm/__tests__/cross-fsm-wiring.test.ts (9 tests) 23ms + Γ£ô src/__tests__/url-state-adoption.test.ts (14 tests) 18ms + Γ£ô src/lib/gear.test.ts (21 tests) 20ms + Γ£ô src/lib/__tests__/routeMeta.test.ts (4 tests) 19ms + Γ£ô src/features/system/components/state-machine/__tests__/windowTransitions.test.ts (14 tests) 17ms + Γ£ô src/lib/__tests__/csvExport.test.ts (16 tests) 18ms + Γ£ô src/lib/enums.test.ts (21 tests) 17ms + Γ£ô src/lib/__tests__/tokens.test.ts (9 tests) 13ms + Γ£ô src/lib/__tests__/constants.test.ts (11 tests) 9ms + Γ£ô src/lib/cleanNil.test.ts (10 tests) 7ms + Γ£ô src/lib/resilience.test.ts (3 tests) 5ms + + Test Files  137 passed (137) + Tests  1619 passed (1619) + Start at  16:39:39 + Duration  53.24s (transform 43.79s, setup 38.85s, import 138.91s, tests 80.50s, environment 431.56s) + +FRONTEND_TEST_EXIT=0 +=== AUDIT_TABLEID === + +> teslasync-web@2.0.0 audit:datatable-tableid +> node scripts/audit-datatable-tableid.mjs + +OK ΓÇö every in src\features has a tableId +AUDIT_TABLEID_EXIT=0 +=== GIT_STATUS === + M web/package.json + M web/src/components/ui/DataTable.tsx + M web/src/features/admin/components/devtools/FleetApiSection.tsx + M web/src/features/admin/components/devtools/FleetTelemetryHealth.tsx + M web/src/features/admin/components/devtools/tools/HttpStatusTool.tsx + M web/src/features/admin/components/devtools/tools/TeslaApiRefTool.tsx + M web/src/features/admin/components/security-access/EventHistoryTable.tsx + M web/src/features/admin/pages/AdminPage.tsx + M web/src/features/admin/pages/BackupRestorePage.tsx + M web/src/features/admin/pages/RedisSignalViewerPage.tsx + M web/src/features/analytics/pages/FleetComparePage.tsx + M web/src/features/analytics/pages/MileagePage.tsx + M web/src/features/analytics/pages/PeriodComparePage.tsx + M web/src/features/analytics/pages/TimelinePage.tsx + M web/src/features/battery/pages/BatteryCellsPage.tsx + M web/src/features/battery/pages/BatteryDegradationPage.tsx + M web/src/features/battery/pages/EnergyFlowPage.tsx + M web/src/features/battery/pages/EnergyPage.tsx + M web/src/features/battery/pages/SleepEfficiencyPage.tsx + M web/src/features/battery/pages/VampireDrainPage.tsx + M web/src/features/charging/components/charging-list/AcDcStatsPanel.tsx + M web/src/features/charging/components/cost-analysis/MonthlyCostTable.tsx + M web/src/features/dashboard/widgets/NotificationStatsWidget.tsx + M web/src/features/driving/components/driving-dynamics/DrivingCoachSection.tsx + M web/src/features/driving/pages/EfficiencyPage.tsx + M web/src/features/maps/pages/MapOverviewPage.tsx + M web/src/features/maps/pages/NavigationRoutePage.tsx + M web/src/features/system/components/status/BackendStatusSection.tsx + M web/src/features/system/components/status/DataPipelineSection.tsx + M web/src/features/system/components/status/OperationsSection.tsx + M web/src/features/system/components/status/ServiceHealthSection.tsx + M web/src/features/system/pages/DBHealthPage.tsx + M web/src/features/system/pages/DataExportPage.tsx + M web/src/features/system/pages/StateMachineDebuggerPage.tsx + M web/src/features/telemetry/pages/LiveSignalMonitorPage.tsx + M web/src/features/telemetry/pages/MQTTInspectorPage.tsx + M web/src/features/telemetry/pages/SignalExplorerPage.tsx + M web/src/features/telemetry/pages/SignalGapDetectorPage.tsx + M web/src/features/vehicle-systems/pages/ClimateControlPage.tsx + M web/src/features/vehicle-systems/pages/MaintenancePage.tsx + M web/src/features/vehicle-systems/pages/MediaPlayerPage.tsx + M web/src/features/vehicle-systems/pages/SafetySettingsPage.tsx + M web/src/features/vehicle-systems/pages/TirePressurePage.tsx + M web/src/features/vehicles/components/vehicle-detail/RecentChargesSection.tsx + M web/src/features/vehicles/components/vehicle-detail/RecentDrivesSection.tsx + M web/src/features/vehicles/pages/VehicleAccessPage.tsx +?? web/scripts/audit-datatable-tableid.mjs +?? web/src/components/ui/__tests__/DataTable.tableId-coverage.test.tsx +UNEXPECTED_STATUS_COUNT=0 +=== STATUS === +EXIT=0 +STATUS=DONE diff --git a/web/package.json b/web/package.json index 04dd9aa8d2..bf02dee4f9 100644 --- a/web/package.json +++ b/web/package.json @@ -11,13 +11,14 @@ "build": "tsc && vite build", "postbuild": "node scripts/check-bundle-size.mjs", "preview": "vite preview", - "lint": "eslint . --report-unused-disable-directives --max-warnings 0 && npm run lint:i18n && npm run lint:light-mode && npm run audit:empty-state && npm run audit:tooltip-text && npm run audit:i18n-shadowed && npm run audit:query-signal", + "lint": "eslint . --report-unused-disable-directives --max-warnings 0 && npm run lint:i18n && npm run lint:light-mode && npm run audit:empty-state && npm run audit:tooltip-text && npm run audit:i18n-shadowed && npm run audit:query-signal && npm run audit:datatable-tableid", "lint:i18n": "node scripts/lint-i18n-no-template-literals.mjs", "lint:light-mode": "node scripts/audit-light-mode-parity.mjs", "audit:empty-state": "node scripts/audit-empty-state-cta.mjs", "audit:tooltip-text": "node scripts/audit-tooltip-text-color.mjs", "audit:i18n-shadowed": "node scripts/audit-i18n-shadowed-keys.mjs", "audit:query-signal": "node scripts/audit-query-signal.mjs", + "audit:datatable-tableid": "node scripts/audit-datatable-tableid.mjs", "audit:skeletons": "node scripts/auditSkeletons.mjs", "audit:motion": "node scripts/auditMotionTokens.mjs", "audit:palette": "node scripts/auditChartPalette.mjs", diff --git a/web/scripts/audit-datatable-tableid.mjs b/web/scripts/audit-datatable-tableid.mjs new file mode 100644 index 0000000000..bce21051af --- /dev/null +++ b/web/scripts/audit-datatable-tableid.mjs @@ -0,0 +1,199 @@ +#!/usr/bin/env node +// Phase-46 / Prompt 03 — DataTable tableId persistence audit. +// +// Walks src/features/ and fails if any JSX use is found +// without a `tableId=` attribute on the opening tag. +// +// Without `tableId`, the persistence layer in DataTable.tsx +// (column visibility, column widths, column order, sort, page size) +// short-circuits to a no-op — every reload throws away the user's +// table layout. Phase-46 / Prompt 03 makes `tableId` mandatory across +// every production caller. This audit locks that property in. +// +// Run via `npm run audit:datatable-tableid` (also chained from +// `npm run lint`). + +import { readFileSync, readdirSync, statSync } from 'node:fs'; +import { join } from 'node:path'; +import process from 'node:process'; + +const ROOT = join('src', 'features'); +const offenders = []; + +function walk(dir) { + let entries; + try { + entries = readdirSync(dir); + } catch { + return; + } + for (const name of entries) { + const p = join(dir, name); + let st; + try { + st = statSync(p); + } catch { + continue; + } + if (st.isDirectory()) { + walk(p); + continue; + } + if (!p.endsWith('.tsx')) continue; + auditFile(p); + } +} + +// Build a list of [start, end) ranges in `text` that correspond to +// string literals or comments. Used to filter out false-positive +// `= s && offset < e) return true; + if (s > offset) return false; + } + return false; +} + +// Scan forward from `from` (just past the `` or `/>` that closes the JSX opening tag, while +// tracking brace depth and string/template state so embedded JS +// expressions (`columns={...}`) don't trip us up. Also handles the +// TypeScript generic-parameter form `...>` by skipping +// the immediately-following `<...>` block before scanning. +function findTagEnd(text, from) { + let i = from; + // Skip a leading TS generic param block: `>(...)`. + if (text[i] === '<') { + let angle = 0; + for (; i < text.length; i++) { + const c = text[i]; + if (c === '<') angle++; + else if (c === '>') { + angle--; + if (angle === 0) { + i++; + break; + } + } + } + } + let depth = 0; + let inString = null; // ' " or ` + for (; i < text.length; i++) { + const c = text[i]; + if (inString) { + if (c === inString && text[i - 1] !== '\\') inString = null; + continue; + } + if (c === '"' || c === "'" || c === '`') { + inString = c; + continue; + } + if (c === '{') { + depth++; + continue; + } + if (c === '}') { + depth--; + continue; + } + if (depth === 0 && (c === '>' || (c === '/' && text[i + 1] === '>'))) { + return c === '/' ? i + 2 : i + 1; + } + } + return -1; +} + +function auditFile(path) { + const text = readFileSync(path, 'utf8'); + const masked = buildMaskedRegions(text); + // Match ``), `/`, or `>`. Excludes + // `])/g; + let m; + while ((m = re.exec(text)) !== null) { + if (isMaskedRegion(masked, m.index)) continue; + const tagEnd = findTagEnd(text, m.index + ' 0) { + console.error( + `\n instances missing required tableId (${offenders.length}):`, + ); + for (const o of offenders) { + console.error(` ${o.where}\n ${o.why}`); + } + console.error( + '\nFix by adding a stable, descriptive id of the form `:`:\n' + + ' \n' + + '\nThe id namespaces persisted column visibility, widths, sort, and\n' + + 'page-size in localStorage so the user keeps their layout across reloads.', + ); + process.exit(1); +} + +console.log(`OK — every in ${ROOT} has a tableId`); diff --git a/web/src/components/ui/DataTable.tsx b/web/src/components/ui/DataTable.tsx index da03229897..56e1ac8297 100644 --- a/web/src/components/ui/DataTable.tsx +++ b/web/src/components/ui/DataTable.tsx @@ -103,7 +103,20 @@ interface DataTableProps { // ── Phase-40 / Prompt 25 additions ──────────────────────────────────── /** Stable identifier used to persist column visibility & widths in * localStorage. Required when using `selectable`, `resizable`, or - * the column-visibility menu. */ + * the column-visibility menu. + * + * As of Phase-46 / Prompt 03 every `` caller under + * `web/src/features/**` MUST set `tableId`. The + * `audit:datatable-tableid` script (chained from `npm run lint`) fails + * the build if a new caller forgets it. Without `tableId`, column + * visibility / widths / sort / page-size silently reset on every + * reload — which is the single biggest "the app forgot what I was + * doing" complaint. + * + * Choose a stable, descriptive id of the form `:` + * (e.g. `tableId="drives:list"`, `tableId="admin:audit-logs"`). + * Renaming an existing id orphans every user's persisted layout, so + * treat ids as part of the public contract once shipped. */ tableId?: string // SELECTION diff --git a/web/src/components/ui/__tests__/DataTable.tableId-coverage.test.tsx b/web/src/components/ui/__tests__/DataTable.tableId-coverage.test.tsx new file mode 100644 index 0000000000..9b80c54a17 --- /dev/null +++ b/web/src/components/ui/__tests__/DataTable.tableId-coverage.test.tsx @@ -0,0 +1,172 @@ +import { describe, it, expect } from 'vitest' +import { readFileSync, readdirSync, statSync } from 'node:fs' +import { join, sep } from 'node:path' + +/** + * Phase-46 / Prompt 03 — defense-in-depth contract test. + * + * Mirrors `web/scripts/audit-datatable-tableid.mjs` so the + * coverage rule is enforced both at lint-time (audit script) and at + * test-time (this Vitest spec). If a future caller adds a new + * without `tableId`, BOTH gates fail loudly. + */ + +const ROOT = join('src', 'features') + +function* walk(dir: string): Generator { + let entries: string[] + try { + entries = readdirSync(dir) + } catch { + return + } + for (const name of entries) { + const p = join(dir, name) + let st + try { + st = statSync(p) + } catch { + continue + } + if (st.isDirectory()) { + yield* walk(p) + continue + } + if (!p.endsWith('.tsx')) continue + yield p + } +} + +function buildMaskedRegions(text: string): Array<[number, number]> { + const regions: Array<[number, number]> = [] + let i = 0 + while (i < text.length) { + const c = text[i] + const next = text[i + 1] + if (c === '/' && next === '/') { + const start = i + i += 2 + while (i < text.length && text[i] !== '\n') i++ + regions.push([start, i]) + continue + } + if (c === '/' && next === '*') { + const start = i + i += 2 + while (i < text.length - 1 && !(text[i] === '*' && text[i + 1] === '/')) i++ + i += 2 + regions.push([start, i]) + continue + } + if (c === '"' || c === "'" || c === '`') { + const quote = c + const start = i + i++ + while (i < text.length && text[i] !== quote) { + if (text[i] === '\\') i += 2 + else i++ + } + i++ + regions.push([start, i]) + continue + } + i++ + } + return regions +} + +function isMaskedRegion(regions: Array<[number, number]>, offset: number): boolean { + for (const [s, e] of regions) { + if (offset >= s && offset < e) return true + if (s > offset) return false + } + return false +} + +function findTagEnd(text: string, from: number): number { + let i = from + if (text[i] === '<') { + let angle = 0 + for (; i < text.length; i++) { + const c = text[i] + if (c === '<') angle++ + else if (c === '>') { + angle-- + if (angle === 0) { + i++ + break + } + } + } + } + let depth = 0 + let inString: string | null = null + for (; i < text.length; i++) { + const c = text[i] + if (inString) { + if (c === inString && text[i - 1] !== '\\') inString = null + continue + } + if (c === '"' || c === "'" || c === '`') { + inString = c + continue + } + if (c === '{') { + depth++ + continue + } + if (c === '}') { + depth-- + continue + } + if (depth === 0 && (c === '>' || (c === '/' && text[i + 1] === '>'))) { + return c === '/' ? i + 2 : i + 1 + } + } + return -1 +} + +function findOffenders(): string[] { + const offenders: string[] = [] + for (const file of walk(ROOT)) { + const text = readFileSync(file, 'utf8') + const masked = buildMaskedRegions(text) + const re = /])/g + let m: RegExpExecArray | null + while ((m = re.exec(text)) !== null) { + if (isMaskedRegion(masked, m.index)) continue + const tagEnd = findTagEnd(text, m.index + ' { + it('every under src/features sets a tableId so persistence works', () => { + const offenders = findOffenders() + if (offenders.length > 0) { + const list = offenders.map((o) => ` ${o}`).join('\n') + throw new Error( + `Found ${offenders.length} instance(s) without tableId:\n${list}\n\n` + + `Without tableId, column visibility / widths / sort / page size do not persist.\n` + + `Add a stable id of the form ":" (e.g. tableId="drives:list").`, + ) + } + expect(offenders).toEqual([]) + }) + + // Sanity: keep the import linter happy if `sep` is needed later. + it('uses platform-appropriate separators', () => { + expect(typeof sep).toBe('string') + }) +}) diff --git a/web/src/features/admin/components/devtools/FleetApiSection.tsx b/web/src/features/admin/components/devtools/FleetApiSection.tsx index fa97616476..d4eaaec6ef 100644 --- a/web/src/features/admin/components/devtools/FleetApiSection.tsx +++ b/web/src/features/admin/components/devtools/FleetApiSection.tsx @@ -530,7 +530,14 @@ function FleetTelemetryConfigTool() { {errorData.length > 0 && (
- r.id} compact pagination={{ defaultPageSize: 50 }} /> + r.id} + compact + pagination={{ defaultPageSize: 50 }} + />
) diff --git a/web/src/features/admin/components/devtools/tools/TeslaApiRefTool.tsx b/web/src/features/admin/components/devtools/tools/TeslaApiRefTool.tsx index 6d05f95b64..579890e150 100644 --- a/web/src/features/admin/components/devtools/tools/TeslaApiRefTool.tsx +++ b/web/src/features/admin/components/devtools/tools/TeslaApiRefTool.tsx @@ -56,7 +56,14 @@ export function TeslaApiRefTool() { onChange={(e) => setSearch(e.target.value)} icon={} /> - r.path} compact pagination /> + r.path} + compact + pagination + /> ) diff --git a/web/src/features/admin/components/security-access/EventHistoryTable.tsx b/web/src/features/admin/components/security-access/EventHistoryTable.tsx index d59ed0d748..ad74c79cc9 100644 --- a/web/src/features/admin/components/security-access/EventHistoryTable.tsx +++ b/web/src/features/admin/components/security-access/EventHistoryTable.tsx @@ -86,6 +86,7 @@ export function EventHistoryTable({ history, isLoading }: EventHistoryTableProps ) : ( + tableId="admin:security-events" columns={eventColumns} data={history} keyExtractor={(row) => row.id} diff --git a/web/src/features/admin/pages/AdminPage.tsx b/web/src/features/admin/pages/AdminPage.tsx index aeb6926196..d7a81d1da3 100644 --- a/web/src/features/admin/pages/AdminPage.tsx +++ b/web/src/features/admin/pages/AdminPage.tsx @@ -283,6 +283,7 @@ export default function AdminPage() { {filteredAuditLogs.length > 0 ? ( String(log.id)} @@ -317,6 +318,7 @@ export default function AdminPage() { ) : (apiKeys as APIKey[])?.length ? (
String(k.id)} diff --git a/web/src/features/admin/pages/BackupRestorePage.tsx b/web/src/features/admin/pages/BackupRestorePage.tsx index c757dc805f..b8e36ad912 100644 --- a/web/src/features/admin/pages/BackupRestorePage.tsx +++ b/web/src/features/admin/pages/BackupRestorePage.tsx @@ -643,6 +643,7 @@ export default function BackupRestorePage() { /> ) : ( + tableId="admin:backup-configs" columns={configColumns} data={configs} keyExtractor={(r) => r.id} @@ -677,6 +678,7 @@ export default function BackupRestorePage() { ) : ( <> + tableId="admin:backup-runs" columns={runColumns} data={runs} keyExtractor={(r) => r.id} @@ -891,6 +893,7 @@ export default function BackupRestorePage() { {t('backup.tables', 'Tables')} ({previewData.tables.length})

+ tableId="admin:backup-preview-tables" columns={previewColumns} data={previewData.tables} keyExtractor={(r) => r.name} diff --git a/web/src/features/admin/pages/RedisSignalViewerPage.tsx b/web/src/features/admin/pages/RedisSignalViewerPage.tsx index fe9e4f313b..39a92f3098 100644 --- a/web/src/features/admin/pages/RedisSignalViewerPage.tsx +++ b/web/src/features/admin/pages/RedisSignalViewerPage.tsx @@ -318,6 +318,7 @@ export default function RedisSignalViewerPage() { ) ) : ( row.name} diff --git a/web/src/features/analytics/pages/FleetComparePage.tsx b/web/src/features/analytics/pages/FleetComparePage.tsx index f2dd3045d8..0cf838fde6 100644 --- a/web/src/features/analytics/pages/FleetComparePage.tsx +++ b/web/src/features/analytics/pages/FleetComparePage.tsx @@ -677,6 +677,7 @@ export default function FleetComparePage() { ) : ( r.metric} diff --git a/web/src/features/analytics/pages/MileagePage.tsx b/web/src/features/analytics/pages/MileagePage.tsx index 07f5730ebb..6d5707d1cc 100644 --- a/web/src/features/analytics/pages/MileagePage.tsx +++ b/web/src/features/analytics/pages/MileagePage.tsx @@ -242,6 +242,7 @@ export default function MileagePage() { {t('Monthly Summary')}

+ tableId="analytics:mileage-monthly" columns={monthColumns} data={monthlyRows} keyExtractor={(r) => r.month} diff --git a/web/src/features/analytics/pages/PeriodComparePage.tsx b/web/src/features/analytics/pages/PeriodComparePage.tsx index 51a2bc6745..2f5f465e91 100644 --- a/web/src/features/analytics/pages/PeriodComparePage.tsx +++ b/web/src/features/analytics/pages/PeriodComparePage.tsx @@ -389,6 +389,7 @@ export default function PeriodComparePage() { {t('compare.tableTitle', 'Comparison Details')}

r.metric} diff --git a/web/src/features/analytics/pages/TimelinePage.tsx b/web/src/features/analytics/pages/TimelinePage.tsx index 5bafb18003..655383277c 100644 --- a/web/src/features/analytics/pages/TimelinePage.tsx +++ b/web/src/features/analytics/pages/TimelinePage.tsx @@ -402,6 +402,7 @@ export default function TimelinePage() { {t('timeline.stateTransitions', 'State Transitions')}

row.index} diff --git a/web/src/features/battery/pages/BatteryCellsPage.tsx b/web/src/features/battery/pages/BatteryCellsPage.tsx index b69a3c20a1..5b0d262888 100644 --- a/web/src/features/battery/pages/BatteryCellsPage.tsx +++ b/web/src/features/battery/pages/BatteryCellsPage.tsx @@ -679,6 +679,7 @@ export default function BatteryCellsPage() {
{sortedCells.length > 0 ? ( r.cell_id} diff --git a/web/src/features/battery/pages/BatteryDegradationPage.tsx b/web/src/features/battery/pages/BatteryDegradationPage.tsx index 6eee7b0435..1956cda47f 100644 --- a/web/src/features/battery/pages/BatteryDegradationPage.tsx +++ b/web/src/features/battery/pages/BatteryDegradationPage.tsx @@ -672,6 +672,7 @@ export default function BatteryDegradationPage() { {data?.history && data.history.length > 0 ? ( diff --git a/web/src/features/battery/pages/EnergyFlowPage.tsx b/web/src/features/battery/pages/EnergyFlowPage.tsx index 7b9db270ba..b6c2692c7d 100644 --- a/web/src/features/battery/pages/EnergyFlowPage.tsx +++ b/web/src/features/battery/pages/EnergyFlowPage.tsx @@ -638,6 +638,7 @@ export default function EnergyFlowPage() { {sortedDailyRows.length > 0 ? ( row.date} diff --git a/web/src/features/battery/pages/EnergyPage.tsx b/web/src/features/battery/pages/EnergyPage.tsx index a5a9c6f316..cbe3783c2b 100644 --- a/web/src/features/battery/pages/EnergyPage.tsx +++ b/web/src/features/battery/pages/EnergyPage.tsx @@ -719,6 +719,7 @@ export default function EnergyPage() { {sessions && sessions.length > 0 ? ( s.id} diff --git a/web/src/features/battery/pages/SleepEfficiencyPage.tsx b/web/src/features/battery/pages/SleepEfficiencyPage.tsx index e11536a0a0..f3835a6311 100644 --- a/web/src/features/battery/pages/SleepEfficiencyPage.tsx +++ b/web/src/features/battery/pages/SleepEfficiencyPage.tsx @@ -312,6 +312,7 @@ export default function SleepEfficiencyPage() { {recentEvents.length > 0 ? ( + tableId="battery:sleep-drain-events" columns={drainColumns} data={recentEvents} keyExtractor={(event) => event.id} diff --git a/web/src/features/battery/pages/VampireDrainPage.tsx b/web/src/features/battery/pages/VampireDrainPage.tsx index b78218a14c..612c6346bc 100644 --- a/web/src/features/battery/pages/VampireDrainPage.tsx +++ b/web/src/features/battery/pages/VampireDrainPage.tsx @@ -236,6 +236,7 @@ export default function VampireDrainPage() { {data?.entries?.length ?? 0} {t('sessions')} + tableId="battery:vampire-drain-sessions" columns={columns} data={sortedEntries} keyExtractor={(r) => r.id} diff --git a/web/src/features/charging/components/charging-list/AcDcStatsPanel.tsx b/web/src/features/charging/components/charging-list/AcDcStatsPanel.tsx index 6ebd720ee7..9894e86c1e 100644 --- a/web/src/features/charging/components/charging-list/AcDcStatsPanel.tsx +++ b/web/src/features/charging/components/charging-list/AcDcStatsPanel.tsx @@ -95,6 +95,7 @@ function AcDcTable({ ac, dc }: { ac: AcDcBucket; dc: AcDcBucket }) { return ( + tableId="charging:ac-dc-stats" columns={columns} data={data} keyExtractor={(r) => r.label} diff --git a/web/src/features/charging/components/cost-analysis/MonthlyCostTable.tsx b/web/src/features/charging/components/cost-analysis/MonthlyCostTable.tsx index 79c5526dc5..ef0b90b128 100644 --- a/web/src/features/charging/components/cost-analysis/MonthlyCostTable.tsx +++ b/web/src/features/charging/components/cost-analysis/MonthlyCostTable.tsx @@ -109,6 +109,7 @@ export function MonthlyCostTable({ data }: MonthlyCostTableProps) { {sortedData.length > 0 ? ( + tableId="charging:cost-monthly" columns={columns} data={sortedData} keyExtractor={(row) => row.month} diff --git a/web/src/features/dashboard/widgets/NotificationStatsWidget.tsx b/web/src/features/dashboard/widgets/NotificationStatsWidget.tsx index b266e8c5f8..a4fe6db8a0 100644 --- a/web/src/features/dashboard/widgets/NotificationStatsWidget.tsx +++ b/web/src/features/dashboard/widgets/NotificationStatsWidget.tsx @@ -205,6 +205,7 @@ export default function NotificationStatsWidget({ size }: WidgetProps) { {isWide && recentLogs.length > 0 && ( log.id} diff --git a/web/src/features/driving/components/driving-dynamics/DrivingCoachSection.tsx b/web/src/features/driving/components/driving-dynamics/DrivingCoachSection.tsx index 0afc853a28..ca516e9c5a 100644 --- a/web/src/features/driving/components/driving-dynamics/DrivingCoachSection.tsx +++ b/web/src/features/driving/components/driving-dynamics/DrivingCoachSection.tsx @@ -256,6 +256,7 @@ export default function DrivingCoachSection({ coachData }: DrivingCoachSectionPr {(coachData?.per_drive_scores ?? []).length > 0 ? ( String(row.drive_id)} diff --git a/web/src/features/driving/pages/EfficiencyPage.tsx b/web/src/features/driving/pages/EfficiencyPage.tsx index 462a058c4f..84d40452c4 100644 --- a/web/src/features/driving/pages/EfficiencyPage.tsx +++ b/web/src/features/driving/pages/EfficiencyPage.tsx @@ -390,6 +390,7 @@ export default function EfficiencyPage() { {tempBuckets.length > 0 ? ( b.range} compact diff --git a/web/src/features/maps/pages/MapOverviewPage.tsx b/web/src/features/maps/pages/MapOverviewPage.tsx index f137fc63db..d33012d05b 100644 --- a/web/src/features/maps/pages/MapOverviewPage.tsx +++ b/web/src/features/maps/pages/MapOverviewPage.tsx @@ -492,6 +492,7 @@ export default function MapOverviewPage() { ) : history && history.length > 0 ? ( + tableId="maps:overview-history" columns={historyColumns} data={history} keyExtractor={(r) => r.id} diff --git a/web/src/features/maps/pages/NavigationRoutePage.tsx b/web/src/features/maps/pages/NavigationRoutePage.tsx index ef097ee91a..b2cc73713e 100644 --- a/web/src/features/maps/pages/NavigationRoutePage.tsx +++ b/web/src/features/maps/pages/NavigationRoutePage.tsx @@ -840,6 +840,7 @@ export default function NavigationRoutePage() { {waypoints.length > 0 ? ( `${wp.name}-${wp.distance}`} @@ -907,6 +908,7 @@ export default function NavigationRoutePage() { ) : ( `${row.time}-${row.destination}`} @@ -969,6 +971,7 @@ export default function NavigationRoutePage() { /> ) : ( row.id} diff --git a/web/src/features/system/components/status/BackendStatusSection.tsx b/web/src/features/system/components/status/BackendStatusSection.tsx index 56bfb88952..a5f86cd1fa 100644 --- a/web/src/features/system/components/status/BackendStatusSection.tsx +++ b/web/src/features/system/components/status/BackendStatusSection.tsx @@ -112,6 +112,7 @@ export function BackendStatusSection() {

{t('Component Health')}

r.name} diff --git a/web/src/features/system/components/status/DataPipelineSection.tsx b/web/src/features/system/components/status/DataPipelineSection.tsx index 28c9490d23..fb7f385612 100644 --- a/web/src/features/system/components/status/DataPipelineSection.tsx +++ b/web/src/features/system/components/status/DataPipelineSection.tsx @@ -106,7 +106,15 @@ export function DataPipelineSection() { } /> } /> - j.id} compact pagination emptyMessage={t('No export jobs')} /> + j.id} + compact + pagination + emptyMessage={t('No export jobs')} + /> ) : ( diff --git a/web/src/features/system/components/status/OperationsSection.tsx b/web/src/features/system/components/status/OperationsSection.tsx index f3dbf5d054..0c057e4818 100644 --- a/web/src/features/system/components/status/OperationsSection.tsx +++ b/web/src/features/system/components/status/OperationsSection.tsx @@ -119,6 +119,7 @@ export function OperationsSection() { {notifLogs ? ( l.id} @@ -140,6 +141,7 @@ export function OperationsSection() {

{t('Audit Log')}

{auditLogs && auditLogs.length > 0 ? ( l.id} diff --git a/web/src/features/system/components/status/ServiceHealthSection.tsx b/web/src/features/system/components/status/ServiceHealthSection.tsx index 428ac15efd..e01d1fdcbb 100644 --- a/web/src/features/system/components/status/ServiceHealthSection.tsx +++ b/web/src/features/system/components/status/ServiceHealthSection.tsx @@ -66,7 +66,15 @@ export function ServiceHealthSection() { } color="purple" /> } color="cyan" /> - v.vin} compact pagination emptyMessage={t('No vehicles connected')} /> + v.vin} + compact + pagination + emptyMessage={t('No vehicles connected')} + />
)} diff --git a/web/src/features/system/pages/DBHealthPage.tsx b/web/src/features/system/pages/DBHealthPage.tsx index e01f34ee03..e17ddee109 100644 --- a/web/src/features/system/pages/DBHealthPage.tsx +++ b/web/src/features/system/pages/DBHealthPage.tsx @@ -273,6 +273,7 @@ export default function DBHealthPage() { ) : ( + tableId="system:db-health-tables" columns={tableColumns} data={sortedTables} keyExtractor={(tbl) => tbl.name} diff --git a/web/src/features/system/pages/DataExportPage.tsx b/web/src/features/system/pages/DataExportPage.tsx index 871b1408e1..47a7e55aa6 100644 --- a/web/src/features/system/pages/DataExportPage.tsx +++ b/web/src/features/system/pages/DataExportPage.tsx @@ -776,6 +776,7 @@ function ExportHistoryTable({ /> ) : ( row.id} diff --git a/web/src/features/system/pages/StateMachineDebuggerPage.tsx b/web/src/features/system/pages/StateMachineDebuggerPage.tsx index b8daae8450..9d2e09d193 100644 --- a/web/src/features/system/pages/StateMachineDebuggerPage.tsx +++ b/web/src/features/system/pages/StateMachineDebuggerPage.tsx @@ -698,6 +698,7 @@ export default function StateMachineDebuggerPage() { ) : summaryRows.length > 0 ? ( + tableId="system:fsm-summary" columns={summaryColumns} data={summaryRows} keyExtractor={(row) => row.to_state} @@ -760,6 +761,7 @@ export default function StateMachineDebuggerPage() { ) : transitions.length > 0 ? ( <> + tableId="system:fsm-transitions" columns={timelineColumns} data={transitions} keyExtractor={(tr) => String(tr.id)} diff --git a/web/src/features/telemetry/pages/LiveSignalMonitorPage.tsx b/web/src/features/telemetry/pages/LiveSignalMonitorPage.tsx index a81e5600e0..2e438d854f 100644 --- a/web/src/features/telemetry/pages/LiveSignalMonitorPage.tsx +++ b/web/src/features/telemetry/pages/LiveSignalMonitorPage.tsx @@ -258,6 +258,7 @@ export default function LiveSignalMonitorPage() {
+ tableId="telemetry:live-signals" columns={signalColumns} data={filteredEntries} keyExtractor={(entry) => entry.id} diff --git a/web/src/features/telemetry/pages/MQTTInspectorPage.tsx b/web/src/features/telemetry/pages/MQTTInspectorPage.tsx index cdb30838a6..bfc7199375 100644 --- a/web/src/features/telemetry/pages/MQTTInspectorPage.tsx +++ b/web/src/features/telemetry/pages/MQTTInspectorPage.tsx @@ -283,6 +283,7 @@ export default function MQTTInspectorPage() {
) : ( + tableId="telemetry:mqtt-inspector" columns={vehicleColumns} data={vehicles} keyExtractor={(v) => v.vin} diff --git a/web/src/features/telemetry/pages/SignalExplorerPage.tsx b/web/src/features/telemetry/pages/SignalExplorerPage.tsx index 962116caea..31e3e9d346 100644 --- a/web/src/features/telemetry/pages/SignalExplorerPage.tsx +++ b/web/src/features/telemetry/pages/SignalExplorerPage.tsx @@ -513,6 +513,7 @@ export default function SignalExplorerPage() { ) : activeStats && activeStats.length > 0 ? ( {s.signal} }, { key: 'min', header: t('Min'), render: (s) => {fmtNumber(s.min)} }, @@ -540,6 +541,7 @@ export default function SignalExplorerPage() { ) : paginatedRows.length > 0 ? ( <> `${r.created_at}-${r.signal}`} diff --git a/web/src/features/telemetry/pages/SignalGapDetectorPage.tsx b/web/src/features/telemetry/pages/SignalGapDetectorPage.tsx index 0e72a77db4..5a8382b0c1 100644 --- a/web/src/features/telemetry/pages/SignalGapDetectorPage.tsx +++ b/web/src/features/telemetry/pages/SignalGapDetectorPage.tsx @@ -223,6 +223,7 @@ export default function SignalGapDetectorPage() { ) : filtered.length > 0 ? ( + tableId="telemetry:signal-gap" columns={gapColumns} data={filtered} keyExtractor={(signal) => signal.name} diff --git a/web/src/features/vehicle-systems/pages/ClimateControlPage.tsx b/web/src/features/vehicle-systems/pages/ClimateControlPage.tsx index c2787bb095..76b246bbde 100644 --- a/web/src/features/vehicle-systems/pages/ClimateControlPage.tsx +++ b/web/src/features/vehicle-systems/pages/ClimateControlPage.tsx @@ -1264,6 +1264,7 @@ export default function ClimateControlPage() { ) : ( String(row.id ?? 0)} diff --git a/web/src/features/vehicle-systems/pages/MaintenancePage.tsx b/web/src/features/vehicle-systems/pages/MaintenancePage.tsx index 74bcf8e8cd..316ba2fdb9 100644 --- a/web/src/features/vehicle-systems/pages/MaintenancePage.tsx +++ b/web/src/features/vehicle-systems/pages/MaintenancePage.tsx @@ -680,6 +680,7 @@ export default function MaintenancePage() { ) : ( <> + tableId="vehicle-systems:maintenance-records" columns={serviceColumns} data={records} keyExtractor={(r) => r.id} diff --git a/web/src/features/vehicle-systems/pages/MediaPlayerPage.tsx b/web/src/features/vehicle-systems/pages/MediaPlayerPage.tsx index 89d77472eb..a8a4a74b16 100644 --- a/web/src/features/vehicle-systems/pages/MediaPlayerPage.tsx +++ b/web/src/features/vehicle-systems/pages/MediaPlayerPage.tsx @@ -578,6 +578,7 @@ export default function MediaPlayerPage() { {sortedHistory.length > 0 ? ( + tableId="vehicle-systems:media-history" columns={columns} data={sortedHistory} keyExtractor={(row) => row.id} diff --git a/web/src/features/vehicle-systems/pages/SafetySettingsPage.tsx b/web/src/features/vehicle-systems/pages/SafetySettingsPage.tsx index 16f6b193bb..9235fe12bd 100644 --- a/web/src/features/vehicle-systems/pages/SafetySettingsPage.tsx +++ b/web/src/features/vehicle-systems/pages/SafetySettingsPage.tsx @@ -769,6 +769,7 @@ export default function SafetySettingsPage() { ) : ( + tableId="vehicle-systems:safety-history" columns={historyColumns} data={sortedHistory} keyExtractor={(row) => row.id ?? 0} diff --git a/web/src/features/vehicle-systems/pages/TirePressurePage.tsx b/web/src/features/vehicle-systems/pages/TirePressurePage.tsx index baebdad19f..724142ebdb 100644 --- a/web/src/features/vehicle-systems/pages/TirePressurePage.tsx +++ b/web/src/features/vehicle-systems/pages/TirePressurePage.tsx @@ -488,6 +488,7 @@ export default function TirePressurePage() { /> ) : ( row.id} diff --git a/web/src/features/vehicles/components/vehicle-detail/RecentChargesSection.tsx b/web/src/features/vehicles/components/vehicle-detail/RecentChargesSection.tsx index 372e997949..e23711b21e 100644 --- a/web/src/features/vehicles/components/vehicle-detail/RecentChargesSection.tsx +++ b/web/src/features/vehicles/components/vehicle-detail/RecentChargesSection.tsx @@ -70,6 +70,7 @@ export function RecentChargesSection({ sessions }: RecentChargesSectionProps) { {sessions && sessions.length > 0 ? ( s.id} diff --git a/web/src/features/vehicles/components/vehicle-detail/RecentDrivesSection.tsx b/web/src/features/vehicles/components/vehicle-detail/RecentDrivesSection.tsx index 8f9555fd19..1ba97a151a 100644 --- a/web/src/features/vehicles/components/vehicle-detail/RecentDrivesSection.tsx +++ b/web/src/features/vehicles/components/vehicle-detail/RecentDrivesSection.tsx @@ -67,6 +67,7 @@ export function RecentDrivesSection({ drives }: RecentDrivesSectionProps) { {drives && drives.length > 0 ? ( d.id} diff --git a/web/src/features/vehicles/pages/VehicleAccessPage.tsx b/web/src/features/vehicles/pages/VehicleAccessPage.tsx index 9a8dace07a..791566c6af 100644 --- a/web/src/features/vehicles/pages/VehicleAccessPage.tsx +++ b/web/src/features/vehicles/pages/VehicleAccessPage.tsx @@ -207,6 +207,7 @@ export default function VehicleAccessPage() { {driversList.length > 0 ? ( row.id} @@ -259,6 +260,7 @@ export default function VehicleAccessPage() { {invitationsList.length > 0 ? ( row.id} From ffdf6042e0b1ce679c34db9a9e89a0dad59d1647 Mon Sep 17 00:00:00 2001 From: Atul Gupta Date: Mon, 4 May 2026 17:08:28 -0700 Subject: [PATCH 04/76] phase-46/04-maintenance: add SystemMode + maintenance banner across stack with admin override Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../logs/phase-46-04-maintenance-banner.log | 1474 +++++++++++++++++ docker-compose.yml | 9 + helm/teslasync/templates/configmap.yaml | 6 + helm/teslasync/values.yaml | 26 + internal/api/admin_maintenance_handler.go | 325 ++++ .../api/admin_maintenance_handler_test.go | 302 ++++ internal/api/health.go | 47 +- internal/api/router.go | 25 +- internal/config/config.go | 30 + internal/database/system_state_repo.go | 172 ++ internal/database/system_state_repo_test.go | 84 + migrations/000168_system_state.down.sql | 8 + migrations/000168_system_state.up.sql | 35 + web/src/api/hooks/useAdmin.ts | 50 +- .../components/feedback/MaintenanceBanner.tsx | 191 +++ .../__tests__/MaintenanceBanner.test.tsx | 174 ++ web/src/components/feedback/index.ts | 1 + web/src/components/layout/Layout.tsx | 8 + .../admin/components/MaintenanceModePanel.tsx | 211 +++ web/src/features/admin/pages/AdminPage.tsx | 6 + web/src/i18n/en.json | 33 + web/src/types/admin.ts | 35 + 22 files changed, 3246 insertions(+), 6 deletions(-) create mode 100644 .github/prompts/db-refactor/logs/phase-46-04-maintenance-banner.log create mode 100644 internal/api/admin_maintenance_handler.go create mode 100644 internal/api/admin_maintenance_handler_test.go create mode 100644 internal/database/system_state_repo.go create mode 100644 internal/database/system_state_repo_test.go create mode 100644 migrations/000168_system_state.down.sql create mode 100644 migrations/000168_system_state.up.sql create mode 100644 web/src/components/feedback/MaintenanceBanner.tsx create mode 100644 web/src/components/feedback/__tests__/MaintenanceBanner.test.tsx create mode 100644 web/src/features/admin/components/MaintenanceModePanel.tsx diff --git a/.github/prompts/db-refactor/logs/phase-46-04-maintenance-banner.log b/.github/prompts/db-refactor/logs/phase-46-04-maintenance-banner.log new file mode 100644 index 0000000000..c39346f05e --- /dev/null +++ b/.github/prompts/db-refactor/logs/phase-46-04-maintenance-banner.log @@ -0,0 +1,1474 @@ +=== GATE === +=== PREFLIGHT === +PROMPT=phase-46/04-maintenance-banner +BRANCH=fix/bug-waave-46 +HEAD=e8607838 +=== FILES_EXIST === +FILE[internal\api\admin_maintenance_handler.go]=True +FILE[internal\database\system_state_repo.go]=True +FILE[web\src\components\feedback\MaintenanceBanner.tsx]=True +FILE[web\src\components\feedback\__tests__\MaintenanceBanner.test.tsx]=True +MIGRATIONS_EXIST=True +=== TYPESCRIPT === +TSC_EXIT=0 +=== LINT === +LINT_EXIT=0 +=== FRONTEND_TEST === +FRONTEND_TEST_EXIT=0 +=== GO_BUILD === +GO_BUILD_EXIT=0 +=== GO_VET === +GO_VET_EXIT=0 +=== GO_TEST === +ok github.com/ev-dev-labs/teslasync/internal/api (cached) +ok github.com/ev-dev-labs/teslasync/internal/database (cached) +GO_TEST_EXIT=0 +=== HELM_LINT === +==> Linting helm/teslasync +[INFO] Chart.yaml: icon is recommended + +1 chart(s) linted, 0 chart(s) failed +HELM_LINT_EXIT=0 +=== HELM_TEMPLATE === +--- +# Source: teslasync/templates/serviceaccount.yaml +apiVersion: v1 +kind: ServiceAccount +metadata: + name: smoke-teslasync + labels: + helm.sh/chart: teslasync-0.6.0 + app.kubernetes.io/name: teslasync + app.kubernetes.io/instance: smoke + app.kubernetes.io/version: "1.1.0" + app.kubernetes.io/managed-by: Helm +--- +# Source: teslasync/templates/secret.yaml +apiVersion: v1 +kind: Secret +metadata: + name: smoke-teslasync + labels: + helm.sh/chart: teslasync-0.6.0 + app.kubernetes.io/name: teslasync + app.kubernetes.io/instance: smoke + app.kubernetes.io/version: "1.1.0" + app.kubernetes.io/managed-by: Helm + annotations: + helm.sh/resource-policy: keep +type: Opaque +stringData: + TESLA_CLIENT_ID: "" + TESLA_CLIENT_SECRET: "" + DATABASE_PASS: "teslasync" + GRAFANA_ADMIN_PASSWORD: "teslasync" +--- +# Source: teslasync/templates/configmap-nginx.yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: smoke-teslasync-nginx + labels: + helm.sh/chart: teslasync-0.6.0 + app.kubernetes.io/name: teslasync + app.kubernetes.io/instance: smoke + app.kubernetes.io/version: "1.1.0" + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/component: web +data: + default.conf: | + server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + # Kubernetes DNS resolver ΓÇö re-resolves every 10s instead of once at startup. + # This prevents Nginx from crashing if the API service isn't registered yet. + resolver kube-dns.kube-system.svc.cluster.local valid=10s ipv6=off; + resolver_timeout 5s; + + # Inject API base URL into frontend at runtime. + # This MUST be empty or a public URL reachable by the browser. + # The internal K8s address is only used by Nginx's proxy_pass. + sub_filter '' ''; + sub_filter_once on; + + location / { + try_files $uri $uri/ /index.html; + # 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; + } + + location /api/ { + set $backend "http://smoke-teslasync-api:8080"; + proxy_pass $backend; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 3600s; + proxy_buffering off; + proxy_cache off; + } + + location /healthz { + set $backend "http://smoke-teslasync-api:8080"; + proxy_pass $backend; + } + + location /readyz { + set $backend "http://smoke-teslasync-api:8080"; + proxy_pass $backend; + } + + location /.well-known/ { + set $backend "http://smoke-teslasync-api:8080"; + proxy_pass $backend; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + + location /metrics { + set $backend "http://smoke-teslasync-api:8080"; + proxy_pass $backend; + } + + # Service worker must never be cached ΓÇö browser needs to detect updates + location = /sw.js { + add_header Cache-Control "no-cache, no-store, must-revalidate" always; + add_header Pragma "no-cache" always; + expires 0; + } + + # Hashed static assets (Vite bundles) can be cached long-term + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + } +--- +# Source: teslasync/templates/configmap-postgres-init.yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: smoke-teslasync-postgres-init + labels: + helm.sh/chart: teslasync-0.6.0 + app.kubernetes.io/name: teslasync + app.kubernetes.io/instance: smoke + app.kubernetes.io/version: "1.1.0" + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/component: postgresql +data: + 00-init-timescaledb.sql: | + -- Runs once on fresh PGDATA via /docker-entrypoint-initdb.d/ + -- Idempotent ΓÇö IF NOT EXISTS guards everything. + CREATE EXTENSION IF NOT EXISTS timescaledb; + CREATE EXTENSION IF NOT EXISTS vector; + CREATE EXTENSION IF NOT EXISTS pg_stat_statements; + + -- Disable TimescaleDB telemetry (ADR-007) + ALTER DATABASE teslasync SET timescaledb.telemetry_level = 'off'; +--- +# Source: teslasync/templates/configmap.yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: smoke-teslasync + labels: + helm.sh/chart: teslasync-0.6.0 + app.kubernetes.io/name: teslasync + app.kubernetes.io/instance: smoke + app.kubernetes.io/version: "1.1.0" + app.kubernetes.io/managed-by: Helm +data: + DATABASE_HOST: "smoke-teslasync-postgresql" + DATABASE_PORT: "5432" + DATABASE_USER: "teslasync" + DATABASE_NAME: "teslasync" + DATABASE_SSLMODE: "disable" + DATABASE_MAX_CONNS: "25" + DATABASE_MIN_CONNS: "5" + DATABASE_CONNECT_TIMEOUT: "5" + DATABASE_STATEMENT_TIMEOUT: "30000" + DATABASE_HEALTH_CHECK_PERIOD: "5s" + MQTT_HOST: "smoke-teslasync-mosquitto" + MQTT_PORT: "1883" + MQTT_CLIENT_ID: "teslasync" + MQTT_PREFIX: "teslasync" + REDIS_ENABLED: "true" + REDIS_HOST: "smoke-teslasync-redis" + REDIS_PORT: "6379" + REDIS_DB: "0" + LIVE_SIGNAL_STORE_MODE: "hybrid" + GRAFANA_HOST: "smoke-teslasync-grafana" + GRAFANA_PORT: "3000" + API_ENDPOINT: "http://smoke-teslasync-api:8080" + TESLA_REDIRECT_URI: "http://localhost:8080/api/v1/auth/callback" + TESLA_API_BASE_URL: "https://fleet-api.prd.na.vn.cloud.tesla.com" + TESLA_AUTH_URL: "https://auth.tesla.com" + TESLA_TIMEOUT: "30s" + WORKER_POLL_INTERVAL: "30s" + WORKER_SLEEP_POLL_MULT: "4" + TESLASYNC_LOG_LEVEL: "info" + FORWARD_AUTH_HEADER: "" + TESLASYNC_PORT: "8080" + HELM_CHART_VERSION: "0.6.0" + # Data retention (0 or unset = no automatic cleanup) + AUDIT_RETENTION_DAYS: "365" + AUDIT_IP_RETENTION_DAYS: "30" + NOTIFICATION_WORKER_HOST: "smoke-teslasync-notification-worker" + NOTIFICATION_WORKER_PORT: "8081" + EXPORT_WORKER_HOST: "smoke-teslasync-export-worker" + EXPORT_WORKER_PORT: "8082" + AUTOMATION_WORKER_HOST: "smoke-teslasync-automation-worker" + AUTOMATION_WORKER_PORT: "8083" + GAS_PRICE_ENABLED: "false" + GAS_PRICE_POLL_INTERVAL: "7d" + # Inbound api_call_logs middleware (Phase 38-10). + # Body capture defaults to OFF; enable only for diagnostic windows. + API_LOGS_INBOUND_ENABLED: "true" + API_LOG_CAPTURE_BODIES: "false" + API_LOG_QUEUE_CAPACITY: "4096" + API_LOG_BATCH_SIZE: "100" + API_LOG_FLUSH_INTERVAL: "1s" + # Service-mode banner override (Phase 46 / Prompt 04). Always rendered + # so the API picks up the env knob deterministically; empty values + # mean "fall through to the system_state DB row + admin POST API". + TESLASYNC_SYSTEM_MODE: "" + TESLASYNC_SYSTEM_MAINTENANCE_MESSAGE: "" + TESLASYNC_SYSTEM_MAINTENANCE_UNTIL: "" +--- +# Source: teslasync/templates/pvc.yaml +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: smoke-teslasync-postgresql-data + labels: + helm.sh/chart: teslasync-0.6.0 + app.kubernetes.io/name: teslasync + app.kubernetes.io/instance: smoke + app.kubernetes.io/version: "1.1.0" + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/component: postgresql + annotations: + helm.sh/resource-policy: keep +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: "50Gi" +--- +# Source: teslasync/templates/pvc.yaml +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: smoke-teslasync-redis-data + labels: + helm.sh/chart: teslasync-0.6.0 + app.kubernetes.io/name: teslasync + app.kubernetes.io/instance: smoke + app.kubernetes.io/version: "1.1.0" + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/component: redis + annotations: + helm.sh/resource-policy: keep +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: "1Gi" +--- +# Source: teslasync/templates/pvc.yaml +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: smoke-teslasync-mosquitto-data + labels: + helm.sh/chart: teslasync-0.6.0 + app.kubernetes.io/name: teslasync + app.kubernetes.io/instance: smoke + app.kubernetes.io/version: "1.1.0" + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/component: mosquitto + annotations: + helm.sh/resource-policy: keep +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: "1Gi" +--- +# Source: teslasync/templates/pvc.yaml +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: smoke-teslasync-grafana-data + labels: + helm.sh/chart: teslasync-0.6.0 + app.kubernetes.io/name: teslasync + app.kubernetes.io/instance: smoke + app.kubernetes.io/version: "1.1.0" + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/component: grafana + annotations: + helm.sh/resource-policy: keep +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: "2Gi" +--- +# Source: teslasync/templates/service-automation-worker.yaml +apiVersion: v1 +kind: Service +metadata: + name: smoke-teslasync-automation-worker + labels: + helm.sh/chart: teslasync-0.6.0 + app.kubernetes.io/name: teslasync + app.kubernetes.io/instance: smoke + app.kubernetes.io/version: "1.1.0" + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/component: automation-worker +spec: + type: ClusterIP + ports: + - port: 8083 + targetPort: health + protocol: TCP + name: health + selector: + app.kubernetes.io/name: teslasync + app.kubernetes.io/instance: smoke + app.kubernetes.io/component: automation-worker +--- +# Source: teslasync/templates/service-export-worker.yaml +apiVersion: v1 +kind: Service +metadata: + name: smoke-teslasync-export-worker + labels: + helm.sh/chart: teslasync-0.6.0 + app.kubernetes.io/name: teslasync + app.kubernetes.io/instance: smoke + app.kubernetes.io/version: "1.1.0" + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/component: export-worker +spec: + type: ClusterIP + selector: + app.kubernetes.io/name: teslasync + app.kubernetes.io/instance: smoke + app.kubernetes.io/component: export-worker + ports: + - name: health + port: 8082 + targetPort: health + protocol: TCP +--- +# Source: teslasync/templates/service-grafana.yaml +apiVersion: v1 +kind: Service +metadata: + name: smoke-teslasync-grafana + labels: + helm.sh/chart: teslasync-0.6.0 + app.kubernetes.io/name: teslasync + app.kubernetes.io/instance: smoke + app.kubernetes.io/version: "1.1.0" + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/component: grafana +spec: + type: ClusterIP + selector: + app.kubernetes.io/name: teslasync + app.kubernetes.io/instance: smoke + app.kubernetes.io/component: grafana + ports: + - name: http + port: 3000 + targetPort: http + protocol: TCP +--- +# Source: teslasync/templates/service-mosquitto.yaml +apiVersion: v1 +kind: Service +metadata: + name: smoke-teslasync-mosquitto + labels: + helm.sh/chart: teslasync-0.6.0 + app.kubernetes.io/name: teslasync + app.kubernetes.io/instance: smoke + app.kubernetes.io/version: "1.1.0" + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/component: mosquitto +spec: + type: ClusterIP + selector: + app.kubernetes.io/name: teslasync + app.kubernetes.io/instance: smoke + app.kubernetes.io/component: mosquitto + ports: + - name: mqtt + port: 1883 + targetPort: mqtt + protocol: TCP + - name: websocket + port: 9001 + targetPort: websocket + protocol: TCP +--- +# Source: teslasync/templates/service-notification-worker.yaml +apiVersion: v1 +kind: Service +metadata: + name: smoke-teslasync-notification-worker + labels: + helm.sh/chart: teslasync-0.6.0 + app.kubernetes.io/name: teslasync + app.kubernetes.io/instance: smoke + app.kubernetes.io/version: "1.1.0" + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/component: notification-worker +spec: + type: ClusterIP + selector: + app.kubernetes.io/name: teslasync + app.kubernetes.io/instance: smoke + app.kubernetes.io/component: notification-worker + ports: + - name: health + port: 8081 + targetPort: health + protocol: TCP +--- +# Source: teslasync/templates/service-postgresql.yaml +apiVersion: v1 +kind: Service +metadata: + name: smoke-teslasync-postgresql + labels: + helm.sh/chart: teslasync-0.6.0 + app.kubernetes.io/name: teslasync + app.kubernetes.io/instance: smoke + app.kubernetes.io/version: "1.1.0" + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/component: postgresql +spec: + type: ClusterIP + selector: + app.kubernetes.io/name: teslasync + app.kubernetes.io/instance: smoke + app.kubernetes.io/component: postgresql + ports: + - name: postgres + port: 5432 + targetPort: postgres + protocol: TCP +--- +# Source: teslasync/templates/service-redis.yaml +apiVersion: v1 +kind: Service +metadata: + name: smoke-teslasync-redis + labels: + helm.sh/chart: teslasync-0.6.0 + app.kubernetes.io/name: teslasync + app.kubernetes.io/instance: smoke + app.kubernetes.io/version: "1.1.0" + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/component: redis +spec: + type: ClusterIP + selector: + app.kubernetes.io/name: teslasync + app.kubernetes.io/instance: smoke + app.kubernetes.io/component: redis + ports: + - name: redis + port: 6379 + targetPort: redis + protocol: TCP +--- +# Source: teslasync/templates/service.yaml +apiVersion: v1 +kind: Service +metadata: + name: smoke-teslasync-api + labels: + helm.sh/chart: teslasync-0.6.0 + app.kubernetes.io/name: teslasync + app.kubernetes.io/instance: smoke + app.kubernetes.io/version: "1.1.0" + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/component: api + annotations: + prometheus.io/scrape: "true" + prometheus.io/port: "8080" + prometheus.io/path: "/metrics" +spec: + type: ClusterIP + ports: + - port: 8080 + targetPort: http + protocol: TCP + name: http + selector: + app.kubernetes.io/name: teslasync + app.kubernetes.io/instance: smoke + app.kubernetes.io/component: api +--- +# Source: teslasync/templates/service.yaml +apiVersion: v1 +kind: Service +metadata: + name: smoke-teslasync-web + labels: + helm.sh/chart: teslasync-0.6.0 + app.kubernetes.io/name: teslasync + app.kubernetes.io/instance: smoke + app.kubernetes.io/version: "1.1.0" + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/component: web +spec: + type: ClusterIP + ports: + - port: 80 + targetPort: http + protocol: TCP + name: http + selector: + app.kubernetes.io/name: teslasync + app.kubernetes.io/instance: smoke + app.kubernetes.io/component: web +--- +# Source: teslasync/templates/deployment-automation-worker.yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: smoke-teslasync-automation-worker + labels: + helm.sh/chart: teslasync-0.6.0 + app.kubernetes.io/name: teslasync + app.kubernetes.io/instance: smoke + app.kubernetes.io/version: "1.1.0" + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/component: automation-worker +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: teslasync + app.kubernetes.io/instance: smoke + app.kubernetes.io/component: automation-worker + template: + metadata: + labels: + app.kubernetes.io/name: teslasync + app.kubernetes.io/instance: smoke + app.kubernetes.io/component: automation-worker + spec: + + serviceAccountName: smoke-teslasync + securityContext: + fsGroup: 1000 + runAsNonRoot: true + runAsUser: 1000 + initContainers: + - name: wait-for-db + image: busybox:1.36 + command: ['sh', '-c', 'until nc -z smoke-teslasync-postgresql 5432; do echo "waiting for database..."; sleep 2; done'] + containers: + - name: automation-worker + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + readOnlyRootFilesystem: true + image: "ghcr.io/ev-dev-labs/teslasync-automation-worker:1.1.0" + imagePullPolicy: IfNotPresent + ports: + - name: health + containerPort: 8083 + protocol: TCP + livenessProbe: + httpGet: + path: /healthz + port: health + initialDelaySeconds: 10 + periodSeconds: 30 + readinessProbe: + httpGet: + path: /healthz + port: health + initialDelaySeconds: 5 + periodSeconds: 10 + env: + - name: HEALTH_PORT + value: "8083" + - name: DATABASE_MAX_CONNS + value: "5" + - name: DATABASE_MIN_CONNS + value: "2" + envFrom: + - configMapRef: + name: smoke-teslasync + - secretRef: + name: smoke-teslasync + resources: + limits: + cpu: 500m + memory: 256Mi + requests: + cpu: 100m + memory: 64Mi +--- +# Source: teslasync/templates/deployment-export-worker.yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: smoke-teslasync-export-worker + labels: + helm.sh/chart: teslasync-0.6.0 + app.kubernetes.io/name: teslasync + app.kubernetes.io/instance: smoke + app.kubernetes.io/version: "1.1.0" + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/component: export-worker +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: teslasync + app.kubernetes.io/instance: smoke + app.kubernetes.io/component: export-worker + template: + metadata: + labels: + app.kubernetes.io/name: teslasync + app.kubernetes.io/instance: smoke + app.kubernetes.io/component: export-worker + spec: + + serviceAccountName: smoke-teslasync + securityContext: + fsGroup: 1000 + runAsNonRoot: true + runAsUser: 1000 + initContainers: + - name: wait-for-db + image: busybox:1.36 + command: ['sh', '-c', 'until nc -z smoke-teslasync-postgresql 5432; do echo "waiting for database..."; sleep 2; done'] + containers: + - name: export-worker + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + readOnlyRootFilesystem: true + image: "ghcr.io/ev-dev-labs/teslasync-export-worker:1.1.0" + imagePullPolicy: IfNotPresent + ports: + - name: health + containerPort: 8082 + protocol: TCP + livenessProbe: + httpGet: + path: /healthz + port: health + initialDelaySeconds: 10 + periodSeconds: 30 + readinessProbe: + httpGet: + path: /healthz + port: health + initialDelaySeconds: 5 + periodSeconds: 10 + env: + - name: HEALTH_PORT + value: "8082" + - name: DATABASE_MAX_CONNS + value: "5" + - name: DATABASE_MIN_CONNS + value: "2" + envFrom: + - configMapRef: + name: smoke-teslasync + - secretRef: + name: smoke-teslasync + resources: + limits: + cpu: 500m + memory: 512Mi + requests: + cpu: 100m + memory: 64Mi +--- +# Source: teslasync/templates/deployment-grafana.yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: smoke-teslasync-grafana + labels: + helm.sh/chart: teslasync-0.6.0 + app.kubernetes.io/name: teslasync + app.kubernetes.io/instance: smoke + app.kubernetes.io/version: "1.1.0" + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/component: grafana +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: teslasync + app.kubernetes.io/instance: smoke + app.kubernetes.io/component: grafana + strategy: + type: Recreate + template: + metadata: + labels: + app.kubernetes.io/name: teslasync + app.kubernetes.io/instance: smoke + app.kubernetes.io/component: grafana + spec: + + serviceAccountName: smoke-teslasync + containers: + - name: grafana + image: "grafana/grafana:10.4.0" + ports: + - name: http + containerPort: 3000 + protocol: TCP + env: + - name: GF_SECURITY_ADMIN_USER + value: "admin" + - name: GF_SECURITY_ADMIN_PASSWORD + valueFrom: + secretKeyRef: + name: smoke-teslasync + key: GRAFANA_ADMIN_PASSWORD + livenessProbe: + httpGet: + path: /api/health + port: http + initialDelaySeconds: 30 + periodSeconds: 10 + failureThreshold: 5 + readinessProbe: + httpGet: + path: /api/health + port: http + initialDelaySeconds: 10 + periodSeconds: 5 + failureThreshold: 5 + resources: + limits: + cpu: 500m + memory: 256Mi + requests: + cpu: 100m + memory: 64Mi + volumeMounts: + - name: grafana-data + mountPath: /var/lib/grafana + volumes: + - name: grafana-data + persistentVolumeClaim: + claimName: smoke-teslasync-grafana-data +--- +# Source: teslasync/templates/deployment-mosquitto.yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: smoke-teslasync-mosquitto + labels: + helm.sh/chart: teslasync-0.6.0 + app.kubernetes.io/name: teslasync + app.kubernetes.io/instance: smoke + app.kubernetes.io/version: "1.1.0" + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/component: mosquitto +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: teslasync + app.kubernetes.io/instance: smoke + app.kubernetes.io/component: mosquitto + strategy: + type: Recreate + template: + metadata: + labels: + app.kubernetes.io/name: teslasync + app.kubernetes.io/instance: smoke + app.kubernetes.io/component: mosquitto + spec: + + serviceAccountName: smoke-teslasync + containers: + - name: mosquitto + image: "eclipse-mosquitto:2" + ports: + - name: mqtt + containerPort: 1883 + protocol: TCP + - name: websocket + containerPort: 9001 + protocol: TCP + livenessProbe: + tcpSocket: + port: mqtt + initialDelaySeconds: 10 + periodSeconds: 10 + failureThreshold: 5 + readinessProbe: + tcpSocket: + port: mqtt + initialDelaySeconds: 5 + periodSeconds: 5 + failureThreshold: 5 + resources: + limits: + cpu: 100m + memory: 64Mi + requests: + cpu: 25m + memory: 16Mi + volumeMounts: + - name: mosquitto-data + mountPath: /mosquitto/data + volumes: + - name: mosquitto-data + persistentVolumeClaim: + claimName: smoke-teslasync-mosquitto-data +--- +# Source: teslasync/templates/deployment-notification.yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: smoke-teslasync-notification-worker + labels: + helm.sh/chart: teslasync-0.6.0 + app.kubernetes.io/name: teslasync + app.kubernetes.io/instance: smoke + app.kubernetes.io/version: "1.1.0" + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/component: notification-worker +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: teslasync + app.kubernetes.io/instance: smoke + app.kubernetes.io/component: notification-worker + template: + metadata: + labels: + app.kubernetes.io/name: teslasync + app.kubernetes.io/instance: smoke + app.kubernetes.io/component: notification-worker + spec: + + serviceAccountName: smoke-teslasync + securityContext: + fsGroup: 1000 + runAsNonRoot: true + runAsUser: 1000 + initContainers: + - name: wait-for-db + image: busybox:1.36 + command: ['sh', '-c', 'until nc -z smoke-teslasync-postgresql 5432; do echo "waiting for database..."; sleep 2; done'] + containers: + - name: notification-worker + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + readOnlyRootFilesystem: true + image: "ghcr.io/ev-dev-labs/teslasync-notification-worker:1.1.0" + imagePullPolicy: IfNotPresent + ports: + - name: health + containerPort: 8081 + protocol: TCP + livenessProbe: + httpGet: + path: /healthz + port: health + initialDelaySeconds: 10 + periodSeconds: 30 + readinessProbe: + httpGet: + path: /healthz + port: health + initialDelaySeconds: 5 + periodSeconds: 10 + env: + - name: HEALTH_PORT + value: "8081" + - name: DATABASE_MAX_CONNS + value: "5" + - name: DATABASE_MIN_CONNS + value: "2" + envFrom: + - configMapRef: + name: smoke-teslasync + - secretRef: + name: smoke-teslasync + resources: + limits: + cpu: 200m + memory: 128Mi + requests: + cpu: 50m + memory: 32Mi +--- +# Source: teslasync/templates/deployment-postgresql.yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: smoke-teslasync-postgresql + labels: + helm.sh/chart: teslasync-0.6.0 + app.kubernetes.io/name: teslasync + app.kubernetes.io/instance: smoke + app.kubernetes.io/version: "1.1.0" + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/component: postgresql +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: teslasync + app.kubernetes.io/instance: smoke + app.kubernetes.io/component: postgresql + strategy: + type: Recreate + template: + metadata: + labels: + app.kubernetes.io/name: teslasync + app.kubernetes.io/instance: smoke + app.kubernetes.io/component: postgresql + spec: + + serviceAccountName: smoke-teslasync + containers: + - name: postgresql + image: "timescale/timescaledb-ha:pg17" + imagePullPolicy: IfNotPresent + ports: + - name: postgres + containerPort: 5432 + protocol: TCP + env: + - name: POSTGRES_DB + value: "teslasync" + - name: POSTGRES_USER + value: "teslasync" + - name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: smoke-teslasync + key: DATABASE_PASS + - name: PGDATA + value: "/home/postgres/pgdata/data" + livenessProbe: + exec: + command: + - pg_isready + - -U + - "teslasync" + - -d + - "teslasync" + initialDelaySeconds: 30 + periodSeconds: 10 + failureThreshold: 5 + readinessProbe: + exec: + command: + - pg_isready + - -U + - "teslasync" + - -d + - "teslasync" + initialDelaySeconds: 5 + periodSeconds: 5 + failureThreshold: 5 + resources: + limits: + cpu: 2000m + memory: 4Gi + requests: + cpu: 500m + memory: 1Gi + volumeMounts: + - name: postgresql-data + mountPath: "/home/postgres/pgdata" + - name: init-scripts + mountPath: /docker-entrypoint-initdb.d + readOnly: true + volumes: + - name: postgresql-data + persistentVolumeClaim: + claimName: smoke-teslasync-postgresql-data + - name: init-scripts + configMap: + name: smoke-teslasync-postgres-init +--- +# Source: teslasync/templates/deployment-redis.yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: smoke-teslasync-redis + labels: + helm.sh/chart: teslasync-0.6.0 + app.kubernetes.io/name: teslasync + app.kubernetes.io/instance: smoke + app.kubernetes.io/version: "1.1.0" + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/component: redis +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: teslasync + app.kubernetes.io/instance: smoke + app.kubernetes.io/component: redis + strategy: + type: Recreate + template: + metadata: + labels: + app.kubernetes.io/name: teslasync + app.kubernetes.io/instance: smoke + app.kubernetes.io/component: redis + spec: + + serviceAccountName: smoke-teslasync + containers: + - name: redis + image: "redis:7-alpine" + command: + - redis-server + - --appendonly + - "yes" + - --maxmemory-policy + - noeviction + ports: + - name: redis + containerPort: 6379 + protocol: TCP + livenessProbe: + exec: + command: ["redis-cli", "ping"] + initialDelaySeconds: 10 + periodSeconds: 10 + failureThreshold: 5 + readinessProbe: + exec: + command: ["redis-cli", "ping"] + initialDelaySeconds: 5 + periodSeconds: 5 + failureThreshold: 5 + resources: + limits: + cpu: 200m + memory: 192Mi + requests: + cpu: 50m + memory: 32Mi + volumeMounts: + - name: redis-data + mountPath: /data + volumes: + - name: redis-data + persistentVolumeClaim: + claimName: smoke-teslasync-redis-data +--- +# Source: teslasync/templates/deployment-web.yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: smoke-teslasync-web + labels: + helm.sh/chart: teslasync-0.6.0 + app.kubernetes.io/name: teslasync + app.kubernetes.io/instance: smoke + app.kubernetes.io/version: "1.1.0" + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/component: web +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: teslasync + app.kubernetes.io/instance: smoke + app.kubernetes.io/component: web + strategy: + type: RollingUpdate + rollingUpdate: + maxSurge: 1 + maxUnavailable: 0 + template: + metadata: + labels: + app.kubernetes.io/name: teslasync + app.kubernetes.io/instance: smoke + app.kubernetes.io/component: web + spec: + + serviceAccountName: smoke-teslasync + securityContext: + fsGroup: 1000 + runAsNonRoot: true + runAsUser: 1000 + containers: + - name: web + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + readOnlyRootFilesystem: false + image: "ghcr.io/ev-dev-labs/teslasync-web:1.1.0" + imagePullPolicy: IfNotPresent + ports: + - name: http + containerPort: 80 + protocol: TCP + livenessProbe: + httpGet: + path: / + port: http + initialDelaySeconds: 5 + periodSeconds: 30 + readinessProbe: + httpGet: + path: / + port: http + initialDelaySeconds: 3 + periodSeconds: 10 + resources: + limits: + cpu: 200m + memory: 128Mi + requests: + cpu: 50m + memory: 64Mi + volumeMounts: + - name: nginx-cache + mountPath: /var/cache/nginx + - name: nginx-run + mountPath: /var/run + - name: nginx-tmp + mountPath: /tmp + - name: nginx-conf + mountPath: /etc/nginx/conf.d + volumes: + - name: nginx-cache + emptyDir: {} + - name: nginx-run + emptyDir: {} + - name: nginx-tmp + emptyDir: {} + - name: nginx-conf + configMap: + name: smoke-teslasync-nginx +--- +# Source: teslasync/templates/deployment.yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: smoke-teslasync-api + labels: + helm.sh/chart: teslasync-0.6.0 + app.kubernetes.io/name: teslasync + app.kubernetes.io/instance: smoke + app.kubernetes.io/version: "1.1.0" + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/component: api +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: teslasync + app.kubernetes.io/instance: smoke + app.kubernetes.io/component: api + strategy: + type: RollingUpdate + rollingUpdate: + maxSurge: 1 + maxUnavailable: 0 + template: + metadata: + labels: + app.kubernetes.io/name: teslasync + app.kubernetes.io/instance: smoke + app.kubernetes.io/component: api + spec: + + serviceAccountName: smoke-teslasync + securityContext: + fsGroup: 1000 + runAsNonRoot: true + runAsUser: 1000 + initContainers: + - name: wait-for-db + image: busybox:1.36 + command: ['sh', '-c', 'until nc -z smoke-teslasync-postgresql 5432; do echo "waiting for database..."; sleep 2; done'] + containers: + - name: teslasync + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + readOnlyRootFilesystem: true + image: "ghcr.io/ev-dev-labs/teslasync-api:1.1.0" + imagePullPolicy: IfNotPresent + ports: + - name: http + containerPort: 8080 + protocol: TCP + envFrom: + - configMapRef: + name: smoke-teslasync + - secretRef: + name: smoke-teslasync + startupProbe: + httpGet: + path: /healthz + port: http + initialDelaySeconds: 5 + periodSeconds: 5 + failureThreshold: 12 + livenessProbe: + httpGet: + path: /healthz + port: http + initialDelaySeconds: 10 + periodSeconds: 30 + readinessProbe: + httpGet: + path: /readyz + port: http + initialDelaySeconds: 5 + periodSeconds: 10 + lifecycle: + preStop: + httpGet: + path: /internal/flush + port: http + resources: + limits: + cpu: 500m + memory: 512Mi + requests: + cpu: 100m + memory: 128Mi +--- +# Source: teslasync/templates/tests/test-connection.yaml +apiVersion: v1 +kind: Pod +metadata: + name: "smoke-teslasync-test-connection" + labels: + helm.sh/chart: teslasync-0.6.0 + app.kubernetes.io/name: teslasync + app.kubernetes.io/instance: smoke + app.kubernetes.io/version: "1.1.0" + app.kubernetes.io/managed-by: Helm + annotations: + helm.sh/hook: test + helm.sh/hook-delete-policy: before-hook-creation,hook-succeeded +spec: + restartPolicy: Never + containers: + - name: test-backend-health + image: busybox:1.36 + command: ['sh', '-c'] + args: + - | + echo "=== Testing backend /healthz ===" + wget -qO- --timeout=10 http://smoke-teslasync-api:8080/healthz + echo "" + echo "Backend health: OK" + - name: test-backend-ready + image: busybox:1.36 + command: ['sh', '-c'] + args: + - | + echo "=== Testing backend /readyz ===" + wget -qO- --timeout=10 http://smoke-teslasync-api:8080/readyz + echo "" + echo "Backend readiness: OK" + - name: test-backend-metrics + image: busybox:1.36 + command: ['sh', '-c'] + args: + - | + echo "=== Testing Prometheus /metrics ===" + OUTPUT=$(wget -qO- --timeout=10 http://smoke-teslasync-api:8080/metrics) + echo "$OUTPUT" | head -5 + echo "$OUTPUT" | grep -q "teslasync_http_requests_total" && echo "Custom metrics: OK" || (echo "Custom metrics: MISSING" && exit 1) + - name: test-backend-api + image: busybox:1.36 + command: ['sh', '-c'] + args: + - | + echo "=== Testing API /api/v1/vehicles ===" + CODE=$(wget --server-response -qO/dev/null --timeout=10 http://smoke-teslasync-api:8080/api/v1/vehicles 2>&1 | awk '/HTTP/{print $2}') + echo "Status: $CODE" + [ "$CODE" = "200" ] && echo "API response: OK" || (echo "API response: FAIL ($CODE)" && exit 1) + - name: test-web + image: busybox:1.36 + command: ['sh', '-c'] + args: + - | + echo "=== Testing web frontend ===" + wget --spider --timeout=10 http://smoke-teslasync-web:80/ + echo "Web frontend: OK" + - name: test-notification-worker + image: busybox:1.36 + command: ['sh', '-c'] + args: + - | + echo "=== Testing notification worker /healthz ===" + wget -qO- --timeout=10 http://smoke-teslasync-notification-worker:8081/healthz + echo "" + echo "Notification worker: OK" + - name: test-export-worker + image: busybox:1.36 + command: ['sh', '-c'] + args: + - | + echo "=== Testing export worker /healthz ===" + wget -qO- --timeout=10 http://smoke-teslasync-export-worker:8082/healthz + echo "" + echo "Export worker: OK" +--- +# Source: teslasync/templates/tests/test-database.yaml +apiVersion: v1 +kind: Pod +metadata: + name: "smoke-teslasync-test-database" + labels: + helm.sh/chart: teslasync-0.6.0 + app.kubernetes.io/name: teslasync + app.kubernetes.io/instance: smoke + app.kubernetes.io/version: "1.1.0" + app.kubernetes.io/managed-by: Helm + annotations: + helm.sh/hook: test + helm.sh/hook-delete-policy: before-hook-creation,hook-succeeded + helm.sh/hook-weight: "-5" +spec: + restartPolicy: Never + containers: + - name: test-postgres + image: postgres:17-alpine + env: + - name: PGPASSWORD + value: "teslasync" + command: ['sh', '-c'] + args: + - | + echo "=== Testing PostgreSQL connectivity ===" + pg_isready -h smoke-teslasync-postgresql -p 5432 -U teslasync + echo "PostgreSQL: READY" + + echo "=== Checking database exists ===" + psql -h smoke-teslasync-postgresql -p 5432 -U teslasync -d teslasync -c "SELECT 1;" > /dev/null + echo "Database: OK" + + echo "=== Checking migrations applied ===" + TABLE_COUNT=$(psql -h smoke-teslasync-postgresql -p 5432 -U teslasync -d teslasync -t -c "SELECT count(*) FROM information_schema.tables WHERE table_schema='public' AND table_type='BASE TABLE';") + echo "Tables found: $TABLE_COUNT" + [ "$TABLE_COUNT" -gt 30 ] && echo "Migrations: OK ($TABLE_COUNT tables)" || (echo "Migrations: FAIL (expected >30, got $TABLE_COUNT)" && exit 1) + + echo "=== Checking vehicles table ===" + psql -h smoke-teslasync-postgresql -p 5432 -U teslasync -d teslasync -c "\d vehicles" > /dev/null + echo "Vehicles table: OK" +--- +# Source: teslasync/templates/job-migrate.yaml +apiVersion: batch/v1 +kind: Job +metadata: + name: smoke-teslasync-migrate-1 + labels: + helm.sh/chart: teslasync-0.6.0 + app.kubernetes.io/name: teslasync + app.kubernetes.io/instance: smoke + app.kubernetes.io/version: "1.1.0" + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/component: migrate + annotations: + "helm.sh/hook": pre-upgrade,pre-install + "helm.sh/hook-weight": "0" + "helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded +spec: + backoffLimit: 3 + ttlSecondsAfterFinished: 300 + template: + metadata: + labels: + app.kubernetes.io/name: teslasync + app.kubernetes.io/instance: smoke + app.kubernetes.io/component: migrate + spec: + + serviceAccountName: smoke-teslasync + restartPolicy: Never + initContainers: + - name: wait-for-db + image: busybox:1.36 + command: ['sh', '-c', 'until nc -z smoke-teslasync-postgresql 5432; do echo "waiting for database..."; sleep 2; done'] + containers: + - name: migrate + image: "ghcr.io/ev-dev-labs/teslasync-api:1.1.0" + imagePullPolicy: IfNotPresent + env: + - name: MIGRATE_ONLY + value: "true" + - name: DATABASE_MAX_CONNS + value: "2" + - name: DATABASE_MIN_CONNS + value: "1" + envFrom: + - configMapRef: + name: smoke-teslasync + - secretRef: + name: smoke-teslasync + resources: + requests: + cpu: 50m + memory: 64Mi + limits: + cpu: 200m + memory: 128Mi +SYSTEM_MODE_IN_TEMPLATE=True +=== AUDIT_VIOLATIONS === +BANNER_MOUNTED=True +=== GIT_STATUS === +UNEXPECTED_STATUS_COUNT=0 +=== STATUS === +EXIT=0 +STATUS=DONE diff --git a/docker-compose.yml b/docker-compose.yml index aa51c0c111..a8098be6ce 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -74,6 +74,15 @@ services: - TESLASYNC_VAPID_PUBLIC_KEY=${TESLASYNC_VAPID_PUBLIC_KEY:-} - TESLASYNC_VAPID_PRIVATE_KEY=${TESLASYNC_VAPID_PRIVATE_KEY:-} - TESLASYNC_VAPID_SUBJECT=${TESLASYNC_VAPID_SUBJECT:-} + # Service-mode banner override — Phase 46 / Prompt 04. + # When TESLASYNC_SYSTEM_MODE is set ("ok", "degraded", + # "maintenance"), it overrides the system_state DB row so an + # operator can force-set or force-clear the top-of-app banner + # during deploys/rollbacks without touching the database. Leave + # empty to defer to the DB row + admin POST API. + - TESLASYNC_SYSTEM_MODE=${TESLASYNC_SYSTEM_MODE:-} + - TESLASYNC_SYSTEM_MAINTENANCE_MESSAGE=${TESLASYNC_SYSTEM_MAINTENANCE_MESSAGE:-} + - TESLASYNC_SYSTEM_MAINTENANCE_UNTIL=${TESLASYNC_SYSTEM_MAINTENANCE_UNTIL:-} depends_on: postgres: condition: service_healthy diff --git a/helm/teslasync/templates/configmap.yaml b/helm/teslasync/templates/configmap.yaml index 9bf0ae94b2..8cb22cd569 100644 --- a/helm/teslasync/templates/configmap.yaml +++ b/helm/teslasync/templates/configmap.yaml @@ -135,3 +135,9 @@ data: {{- if and .Values.webPush .Values.webPush.publicKey }} TESLASYNC_VAPID_PUBLIC_KEY: {{ .Values.webPush.publicKey | quote }} {{- end }} + # Service-mode banner override (Phase 46 / Prompt 04). Always rendered + # so the API picks up the env knob deterministically; empty values + # mean "fall through to the system_state DB row + admin POST API". + TESLASYNC_SYSTEM_MODE: {{ default "" (and .Values.system .Values.system.mode) | quote }} + TESLASYNC_SYSTEM_MAINTENANCE_MESSAGE: {{ default "" (and .Values.system .Values.system.maintenanceMessage) | quote }} + TESLASYNC_SYSTEM_MAINTENANCE_UNTIL: {{ default "" (and .Values.system .Values.system.maintenanceUntil) | quote }} diff --git a/helm/teslasync/values.yaml b/helm/teslasync/values.yaml index 1da1a626a2..71b0e65dcc 100644 --- a/helm/teslasync/values.yaml +++ b/helm/teslasync/values.yaml @@ -626,6 +626,32 @@ webPush: # Must be a `mailto:` URL or an https:// URL. subject: "" +# ───────────────────────────────────────────────────────────────────────────── +# Service mode banner override (Phase 46 / Prompt 04) +# +# When `system.mode` is set to "ok", "degraded", or "maintenance" the env +# value overrides the persisted system_state DB row so an operator can +# force-set or force-clear the top-of-app banner during deploys/rollbacks +# without touching the database. Leave empty (the default) to defer to the +# DB row + the admin UI / POST /api/v1/admin/maintenance API. +# +# `mode === "ok"` while the env override is active explicitly clears the +# banner regardless of the DB state — useful for "abort the maintenance +# window" rollback scenarios. +# ───────────────────────────────────────────────────────────────────────────── +system: + # -- Service mode override. One of "" (use DB), "ok", "degraded", + # "maintenance". Other values are ignored and fall through to the DB. + mode: "" + # -- Banner message shown to users when mode is "degraded" or + # "maintenance". Trimmed and capped at 280 chars. Ignored when mode is + # "ok" or empty. + maintenanceMessage: "" + # -- RFC3339 timestamp used by the SPA to render a countdown to the + # window's end. Empty means "no scheduled end". Ignored when mode is + # "ok" or empty. + maintenanceUntil: "" + # ───────────────────────────────────────────────────────────────────────────── # Tesla API credentials # ───────────────────────────────────────────────────────────────────────────── diff --git a/internal/api/admin_maintenance_handler.go b/internal/api/admin_maintenance_handler.go new file mode 100644 index 0000000000..5725d76c10 --- /dev/null +++ b/internal/api/admin_maintenance_handler.go @@ -0,0 +1,325 @@ +package api + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "time" + + "github.com/rs/zerolog/log" + + "github.com/ev-dev-labs/teslasync/internal/config" + "github.com/ev-dev-labs/teslasync/internal/database" +) + +// Phase-46 / Prompt 04 — admin maintenance handler. +// +// POST /api/v1/admin/maintenance lets an authenticated operator set the +// service-mode banner shown by the SPA (mode=ok|degraded|maintenance, +// optional message + RFC3339 until). The request lands inside the +// ForwardAuth-protected /api/v1 subrouter, so caller identity is +// captured via the configured forward-auth header and persisted in the +// audit_logs table. +// +// AUTHZ NOTE: TeslaSync is provider-agnostic — there is no "admin +// role" concept beyond ForwardAuth presence. Any authenticated user +// can call this endpoint; the audit row is the accountability surface. +// A future RBAC layer can wrap this handler without changing the +// shape; the existing `/admin/web-errors/summary` endpoint follows the +// same convention. +// +// EFFECTIVE-STATE PRECEDENCE: the env vars TESLASYNC_SYSTEM_MODE/ +// TESLASYNC_SYSTEM_MAINTENANCE_MESSAGE/TESLASYNC_SYSTEM_MAINTENANCE_UNTIL +// override the DB row when the env mode is non-empty. POST writes +// always succeed and update the DB, but the resolver still serves the +// env value to /system/health while the override is active. The +// response includes `source: "env"` in that case so the admin UI can +// surface a "DB write succeeded but env override is active" warning. + +const ( + adminMaintenanceBodyLimit = 8 * 1024 +) + +// SystemStateStore is the narrow interface the handler depends on. +// Implemented by *database.SystemStateRepo in production; mocked in +// admin_maintenance_handler_test.go so the unit tests don't require a +// live database. +type SystemStateStore interface { + Get(ctx context.Context) (database.SystemState, error) + Set(ctx context.Context, mode, message string, until *time.Time, updatedBy string) (database.SystemState, error) +} + +// AdminMaintenanceHandler serves the POST/GET admin endpoints that +// read and write the system_state row. It does NOT call ExtendedHealth's +// resolver — admin reads/writes always reflect the persisted DB state +// so an operator can see "what's in the table" even while an env +// override is shadowing it on /system/health. +type AdminMaintenanceHandler struct { + store SystemStateStore + cfg *config.Config + db *database.DB + authHdr string + envMode string // captured at construction so the resolver and admin agree on env winner +} + +// NewAdminMaintenanceHandler wires the handler against the shared repo. +// db is optional (used only for audit-log writes); cfg is required so +// the audit actor can be derived from the configured ForwardAuth header. +func NewAdminMaintenanceHandler(store SystemStateStore, cfg *config.Config, db *database.DB) *AdminMaintenanceHandler { + h := &AdminMaintenanceHandler{store: store, cfg: cfg, db: db} + if cfg != nil { + h.authHdr = cfg.Auth.ForwardAuthHeader + h.envMode = strings.ToLower(strings.TrimSpace(cfg.System.Mode)) + } + return h +} + +// adminMaintenanceRequest is the POST body shape. All fields optional +// except mode; client may omit until/message to clear them when mode +// is "ok" (the repo itself zeroes both on mode="ok" as a safety net). +type adminMaintenanceRequest struct { + Mode string `json:"mode"` + Message *string `json:"message,omitempty"` + Until *string `json:"until,omitempty"` +} + +// adminMaintenanceResponse is the JSON shape of both GET and POST +// responses. snake_case keys match the rest of the API surface. +type adminMaintenanceResponse struct { + Mode string `json:"mode"` + Message string `json:"maintenance_message,omitempty"` + Until *string `json:"maintenance_until,omitempty"` + UpdatedAt string `json:"updated_at"` + UpdatedBy string `json:"updated_by,omitempty"` + Source string `json:"source"` + EnvOverrideMode string `json:"env_override_mode,omitempty"` +} + +// Get handles GET /admin/maintenance. Returns the current persisted +// row plus a `source` marker that tells the SPA whether the env +// override is currently shadowing the DB value. +func (h *AdminMaintenanceHandler) Get(w http.ResponseWriter, r *http.Request) { + if h == nil || h.store == nil { + writeError(w, http.StatusServiceUnavailable, "system state unavailable") + return + } + state, err := h.store.Get(r.Context()) + if err != nil { + log.Error().Err(err).Msg("admin maintenance: failed to read system_state") + writeError(w, http.StatusInternalServerError, "failed to read system state") + return + } + writeJSON(w, http.StatusOK, h.toResponse(state)) +} + +// Set handles POST /admin/maintenance. Validates body, writes to the +// repo, audits the change, and returns the updated row. Errors are +// reported with the conventional {error} JSON envelope. +func (h *AdminMaintenanceHandler) Set(w http.ResponseWriter, r *http.Request) { + if h == nil || h.store == nil { + writeError(w, http.StatusServiceUnavailable, "system state unavailable") + return + } + defer r.Body.Close() + r.Body = http.MaxBytesReader(w, r.Body, adminMaintenanceBodyLimit) + + var req adminMaintenanceRequest + dec := json.NewDecoder(r.Body) + dec.DisallowUnknownFields() + if err := dec.Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid payload") + return + } + + mode, err := database.ValidateSystemMode(req.Mode) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid mode (expected ok|degraded|maintenance)") + return + } + + var message string + if req.Message != nil { + message = *req.Message + } + + var untilTime *time.Time + if req.Until != nil && strings.TrimSpace(*req.Until) != "" { + t, parseErr := time.Parse(time.RFC3339, strings.TrimSpace(*req.Until)) + if parseErr != nil { + writeError(w, http.StatusBadRequest, "invalid until (expected RFC3339 timestamp)") + return + } + t = t.UTC() + untilTime = &t + } + + // Reject obviously-unusable inputs early so the audit row tells the + // operator their typo back instead of recording a write nobody can + // see in the UI. + if mode != database.SystemModeOK && message == "" && untilTime == nil && (req.Message == nil || strings.TrimSpace(*req.Message) == "") { + // Allow mode-only "degraded" toggles — the SPA will fall back + // to the i18n default banner text when message is empty. + // This branch only fires when *all* descriptive fields are blank; + // we don't reject — just log so noisy operator UIs surface. + log.Debug().Str("mode", mode).Msg("admin maintenance: setting non-ok mode without message") + } + + actor := actorFromRequest(r, h.authHdr) + state, err := h.store.Set(r.Context(), mode, message, untilTime, actor) + if err != nil { + if errors.Is(err, database.ErrInvalidSystemMode) { + writeError(w, http.StatusBadRequest, "invalid mode (expected ok|degraded|maintenance)") + return + } + log.Error().Err(err).Msg("admin maintenance: failed to write system_state") + writeError(w, http.StatusInternalServerError, "failed to write system state") + return + } + + if h.db != nil { + detail := fmt.Sprintf("mode=%s", state.Mode) + if state.MaintenanceMessage != "" { + detail += "; message_len=" + jsonSafeIntStr(len([]rune(state.MaintenanceMessage))) + } + if state.MaintenanceUntil != nil { + detail += "; until=" + state.MaintenanceUntil.UTC().Format(time.RFC3339) + } + logAuditFromRequest(h.db, r, h.authHdr, "system.maintenance.set", "system_state", nil, detail) + } + + writeJSON(w, http.StatusOK, h.toResponse(state)) +} + +// toResponse formats a SystemState into the JSON shape, layering on +// the env-override marker when the env mode is currently winning. +func (h *AdminMaintenanceHandler) toResponse(s database.SystemState) adminMaintenanceResponse { + var until *string + if s.MaintenanceUntil != nil { + t := s.MaintenanceUntil.UTC().Format(time.RFC3339) + until = &t + } + resp := adminMaintenanceResponse{ + Mode: s.Mode, + Message: s.MaintenanceMessage, + Until: until, + UpdatedAt: s.UpdatedAt.UTC().Format(time.RFC3339), + UpdatedBy: s.UpdatedBy, + Source: "db", + } + if envWins(h.envMode) { + resp.Source = "env" + resp.EnvOverrideMode = h.envMode + } + return resp +} + +// envWins reports whether a captured env mode is a recognized +// non-default override. Empty / unrecognized values fall back to the +// DB; "ok"/"degraded"/"maintenance" all win including "ok" so an +// operator can force-clear the banner via the env knob. +func envWins(envMode string) bool { + switch envMode { + case database.SystemModeOK, database.SystemModeDegraded, database.SystemModeMaintenance: + return true + default: + return false + } +} + +// BuildMaintenanceProvider returns a closure suitable for passing into +// ExtendedHealthCheck. The closure resolves env-vs-DB on every call +// (DB lookup is a single indexed read on a 1-row table — cheap enough +// for the polled /system/health endpoint). +func BuildMaintenanceProvider(store SystemStateStore, cfg *config.Config) func(context.Context) MaintenanceView { + envMode := "" + envMsg := "" + envUntil := "" + if cfg != nil { + envMode = strings.ToLower(strings.TrimSpace(cfg.System.Mode)) + envMsg = strings.TrimSpace(cfg.System.MaintenanceMessage) + envUntil = strings.TrimSpace(cfg.System.MaintenanceUntil) + } + envParsedUntil := parseEnvUntil(envUntil) + + return func(ctx context.Context) MaintenanceView { + if envWins(envMode) { + view := MaintenanceView{ + Mode: envMode, + Message: envMsg, + Source: "env", + } + if envMode == database.SystemModeOK { + view.Message = "" + } else if envParsedUntil != nil { + v := *envParsedUntil + view.Until = &v + } + return view + } + if store == nil { + return MaintenanceView{Mode: database.SystemModeOK, Source: "default"} + } + s, err := store.Get(ctx) + if err != nil { + log.Warn().Err(err).Msg("maintenance provider: system_state read failed; returning ok") + return MaintenanceView{Mode: database.SystemModeOK, Source: "default"} + } + view := MaintenanceView{ + Mode: s.Mode, + Message: s.MaintenanceMessage, + UpdatedAt: s.UpdatedAt, + Source: "db", + } + if s.MaintenanceUntil != nil { + v := *s.MaintenanceUntil + view.Until = &v + } + return view + } +} + +// parseEnvUntil tolerates a malformed env value by returning nil so +// the banner still renders without a countdown rather than failing the +// whole resolver. The misconfiguration is logged once at startup so an +// operator notices. +func parseEnvUntil(raw string) *time.Time { + if raw == "" { + return nil + } + t, err := time.Parse(time.RFC3339, raw) + if err != nil { + log.Warn().Str("raw", raw).Err(err).Msg("maintenance: TESLASYNC_SYSTEM_MAINTENANCE_UNTIL is not RFC3339; ignoring") + return nil + } + t = t.UTC() + return &t +} + +// jsonSafeIntStr renders an int for embedding in audit detail strings +// without pulling strconv into the hot path. Kept inline so the file +// has no extra deps. +func jsonSafeIntStr(n int) string { + if n == 0 { + return "0" + } + neg := false + if n < 0 { + neg = true + n = -n + } + var buf [20]byte + pos := len(buf) + for n > 0 { + pos-- + buf[pos] = byte('0' + n%10) + n /= 10 + } + if neg { + pos-- + buf[pos] = '-' + } + return string(buf[pos:]) +} diff --git a/internal/api/admin_maintenance_handler_test.go b/internal/api/admin_maintenance_handler_test.go new file mode 100644 index 0000000000..4db3795664 --- /dev/null +++ b/internal/api/admin_maintenance_handler_test.go @@ -0,0 +1,302 @@ +package api + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/ev-dev-labs/teslasync/internal/config" + "github.com/ev-dev-labs/teslasync/internal/database" +) + +// fakeSystemStateStore is an in-memory SystemStateStore used by these +// unit tests. It captures the last Set arguments so assertions can +// inspect them and supports forced-error injection for the failure paths. +type fakeSystemStateStore struct { + state database.SystemState + getErr error + setErr error + lastMode string + lastMsg string + lastUntil *time.Time + lastBy string + calls int +} + +func (f *fakeSystemStateStore) Get(_ context.Context) (database.SystemState, error) { + if f.getErr != nil { + return database.SystemState{}, f.getErr + } + return f.state, nil +} + +func (f *fakeSystemStateStore) Set(_ context.Context, mode, message string, until *time.Time, updatedBy string) (database.SystemState, error) { + f.calls++ + f.lastMode = mode + f.lastMsg = message + f.lastUntil = until + f.lastBy = updatedBy + if f.setErr != nil { + return database.SystemState{}, f.setErr + } + f.state = database.SystemState{ + Mode: mode, + MaintenanceMessage: database.NormalizeMaintenanceMessage(message), + MaintenanceUntil: until, + UpdatedAt: time.Date(2025, 1, 2, 3, 4, 5, 0, time.UTC), + UpdatedBy: updatedBy, + } + if mode == database.SystemModeOK { + f.state.MaintenanceMessage = "" + f.state.MaintenanceUntil = nil + } + return f.state, nil +} + +func newTestCfg() *config.Config { + return &config.Config{ + Auth: config.AuthConfig{ForwardAuthHeader: "X-User"}, + } +} + +func TestAdminMaintenanceGet(t *testing.T) { + store := &fakeSystemStateStore{ + state: database.SystemState{ + Mode: database.SystemModeMaintenance, + MaintenanceMessage: "DB upgrade", + UpdatedAt: time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC), + UpdatedBy: "alice", + }, + } + h := NewAdminMaintenanceHandler(store, newTestCfg(), nil) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/admin/maintenance", nil) + h.Get(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status: got %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + var resp adminMaintenanceResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + if resp.Mode != "maintenance" || resp.Message != "DB upgrade" || resp.UpdatedBy != "alice" || resp.Source != "db" { + t.Fatalf("unexpected response: %+v", resp) + } +} + +func TestAdminMaintenanceGetStoreErrorReturns500(t *testing.T) { + store := &fakeSystemStateStore{getErr: errors.New("boom")} + h := NewAdminMaintenanceHandler(store, newTestCfg(), nil) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/admin/maintenance", nil) + h.Get(rec, req) + + if rec.Code != http.StatusInternalServerError { + t.Fatalf("status: got %d, want 500", rec.Code) + } +} + +func TestAdminMaintenanceSetHappyPath(t *testing.T) { + store := &fakeSystemStateStore{} + h := NewAdminMaintenanceHandler(store, newTestCfg(), nil) + + body := `{"mode":"maintenance","message":"upgrade","until":"2099-01-02T03:04:05Z"}` + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/admin/maintenance", strings.NewReader(body)) + req.Header.Set("X-User", "alice@example.com") + h.Set(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status: got %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + if store.calls != 1 || store.lastMode != "maintenance" || store.lastMsg != "upgrade" || store.lastBy != "alice@example.com" { + t.Fatalf("unexpected store args: calls=%d mode=%q msg=%q by=%q", store.calls, store.lastMode, store.lastMsg, store.lastBy) + } + if store.lastUntil == nil || !store.lastUntil.Equal(time.Date(2099, 1, 2, 3, 4, 5, 0, time.UTC)) { + t.Fatalf("until not parsed: %v", store.lastUntil) + } + var resp adminMaintenanceResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + if resp.Mode != "maintenance" || resp.Until == nil || *resp.Until != "2099-01-02T03:04:05Z" { + t.Fatalf("unexpected response: %+v", resp) + } +} + +func TestAdminMaintenanceSetClearsOnOk(t *testing.T) { + store := &fakeSystemStateStore{} + h := NewAdminMaintenanceHandler(store, newTestCfg(), nil) + + body := `{"mode":"ok"}` + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/admin/maintenance", strings.NewReader(body)) + h.Set(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status: got %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + if store.lastMode != "ok" { + t.Fatalf("mode: got %q want ok", store.lastMode) + } +} + +func TestAdminMaintenanceSetRejectsInvalidMode(t *testing.T) { + store := &fakeSystemStateStore{} + h := NewAdminMaintenanceHandler(store, newTestCfg(), nil) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/admin/maintenance", strings.NewReader(`{"mode":"broken"}`)) + h.Set(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status: got %d, want 400", rec.Code) + } + if store.calls != 0 { + t.Fatalf("expected store NOT to be called, got %d", store.calls) + } +} + +func TestAdminMaintenanceSetRejectsBadUntil(t *testing.T) { + store := &fakeSystemStateStore{} + h := NewAdminMaintenanceHandler(store, newTestCfg(), nil) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/admin/maintenance", strings.NewReader(`{"mode":"maintenance","until":"not-a-date"}`)) + h.Set(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status: got %d, want 400", rec.Code) + } + if store.calls != 0 { + t.Fatalf("expected store NOT to be called, got %d", store.calls) + } +} + +func TestAdminMaintenanceSetRejectsUnknownFields(t *testing.T) { + store := &fakeSystemStateStore{} + h := NewAdminMaintenanceHandler(store, newTestCfg(), nil) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/admin/maintenance", strings.NewReader(`{"mode":"ok","extra":"x"}`)) + h.Set(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status: got %d want 400; body=%s", rec.Code, rec.Body.String()) + } +} + +func TestAdminMaintenanceSetEnvOverrideSurfaced(t *testing.T) { + cfg := &config.Config{ + Auth: config.AuthConfig{ForwardAuthHeader: "X-User"}, + System: config.SystemConfig{Mode: "maintenance"}, + } + store := &fakeSystemStateStore{} + h := NewAdminMaintenanceHandler(store, cfg, nil) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/admin/maintenance", strings.NewReader(`{"mode":"ok"}`)) + h.Set(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status: got %d, want 200", rec.Code) + } + var resp adminMaintenanceResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + if resp.Source != "env" || resp.EnvOverrideMode != "maintenance" { + t.Fatalf("env override not surfaced: %+v", resp) + } +} + +func TestBuildMaintenanceProviderEnvWins(t *testing.T) { + until := "2099-06-01T12:00:00Z" + cfg := &config.Config{ + System: config.SystemConfig{ + Mode: "degraded", + MaintenanceMessage: "upstream flapping", + MaintenanceUntil: until, + }, + } + store := &fakeSystemStateStore{ + state: database.SystemState{Mode: database.SystemModeOK}, + } + provider := BuildMaintenanceProvider(store, cfg) + view := provider(context.Background()) + + if view.Source != "env" || view.Mode != "degraded" || view.Message != "upstream flapping" { + t.Fatalf("env not honoured: %+v", view) + } + if view.Until == nil || view.Until.Format(time.RFC3339) != until { + t.Fatalf("env until not parsed: %v", view.Until) + } +} + +func TestBuildMaintenanceProviderEnvOkClearsBanner(t *testing.T) { + cfg := &config.Config{System: config.SystemConfig{Mode: "ok"}} + store := &fakeSystemStateStore{ + state: database.SystemState{ + Mode: database.SystemModeMaintenance, + MaintenanceMessage: "DB row says maintenance", + }, + } + provider := BuildMaintenanceProvider(store, cfg) + view := provider(context.Background()) + + if view.Mode != "ok" || view.Source != "env" || view.Message != "" { + t.Fatalf("env=ok did not force-clear: %+v", view) + } +} + +func TestBuildMaintenanceProviderDBFallback(t *testing.T) { + cfg := &config.Config{System: config.SystemConfig{Mode: ""}} + until := time.Date(2099, 1, 1, 0, 0, 0, 0, time.UTC) + store := &fakeSystemStateStore{ + state: database.SystemState{ + Mode: database.SystemModeMaintenance, + MaintenanceMessage: "scheduled outage", + MaintenanceUntil: &until, + UpdatedAt: time.Date(2024, 12, 31, 23, 0, 0, 0, time.UTC), + }, + } + provider := BuildMaintenanceProvider(store, cfg) + view := provider(context.Background()) + + if view.Source != "db" || view.Mode != "maintenance" || view.Message != "scheduled outage" { + t.Fatalf("db state not returned: %+v", view) + } + if view.Until == nil || !view.Until.Equal(until) { + t.Fatalf("until not propagated: %v", view.Until) + } +} + +func TestBuildMaintenanceProviderUnknownEnvFallsThrough(t *testing.T) { + cfg := &config.Config{System: config.SystemConfig{Mode: "weird"}} + store := &fakeSystemStateStore{ + state: database.SystemState{Mode: database.SystemModeOK}, + } + provider := BuildMaintenanceProvider(store, cfg) + view := provider(context.Background()) + + if view.Source != "db" { + t.Fatalf("unknown env should fall through to db, got %+v", view) + } +} + +func TestBuildMaintenanceProviderNilStoreReturnsOk(t *testing.T) { + cfg := &config.Config{} + provider := BuildMaintenanceProvider(nil, cfg) + view := provider(context.Background()) + if view.Mode != database.SystemModeOK || view.Source != "default" { + t.Fatalf("nil store fallback wrong: %+v", view) + } +} diff --git a/internal/api/health.go b/internal/api/health.go index abd5851c4e..0cb155648d 100644 --- a/internal/api/health.go +++ b/internal/api/health.go @@ -1,6 +1,7 @@ package api import ( + "context" "fmt" "math" "net/http" @@ -17,6 +18,20 @@ import ( "github.com/ev-dev-labs/teslasync/internal/tesla" ) +// MaintenanceView is the resolved service-mode snapshot returned by the +// system-state provider closure passed into ExtendedHealthCheck. Source +// indicates which input "won" — "env" when the operator set +// TESLASYNC_SYSTEM_MODE, "db" when an admin POSTed to +// /admin/maintenance, "default" when neither is set. The SPA uses +// `source == "env"` to disable the admin-panel write controls. +type MaintenanceView struct { + Mode string + Message string + Until *time.Time + UpdatedAt time.Time + Source string +} + var startTime = time.Now() // HealthHandler returns a simple health check. @@ -248,8 +263,12 @@ func SystemStatusHandler(db *database.DB, tc *tesla.Client, mqttClient *mqtt.Cli // ExtendedHealthCheck returns a detailed health check with per-component latency, // pool stats, and system information. bufferStats is optional — if non-nil, it adds -// telemetry write buffer statistics. -func ExtendedHealthCheck(db *database.DB, health *resilience.HealthMonitor, bufferStats func() (int, int)) http.HandlerFunc { +// telemetry write buffer statistics. systemState is optional — if non-nil, it injects +// the operator-controlled service-mode banner block (Phase-46 / Prompt 04) into the +// response under top-level keys `mode`, `maintenance_message`, `maintenance_until`, +// `maintenance_updated_at`, and `maintenance_source` so the SPA can render the +// MaintenanceBanner without a separate round-trip. +func ExtendedHealthCheck(db *database.DB, health *resilience.HealthMonitor, bufferStats func() (int, int), systemState func(context.Context) MaintenanceView) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { results := make(map[string]interface{}) @@ -335,11 +354,31 @@ func ExtendedHealthCheck(db *database.DB, health *resilience.HealthMonitor, buff statusCode = http.StatusServiceUnavailable } - writeJSON(w, statusCode, map[string]interface{}{ + body := map[string]interface{}{ "status": overall, "components": results, "checked_at": time.Now(), - }) + // Default service-mode block — overwritten below when systemState is wired. + // Always emitted so SPA consumers can rely on the field's presence. + "mode": database.SystemModeOK, + "source": "default", + } + if systemState != nil { + view := systemState(r.Context()) + body["mode"] = view.Mode + body["source"] = view.Source + if view.Message != "" { + body["maintenance_message"] = view.Message + } + if view.Until != nil { + body["maintenance_until"] = view.Until.UTC().Format(time.RFC3339) + } + if !view.UpdatedAt.IsZero() { + body["maintenance_updated_at"] = view.UpdatedAt.UTC().Format(time.RFC3339) + } + } + + writeJSON(w, statusCode, body) } } diff --git a/internal/api/router.go b/internal/api/router.go index 81edcf9b94..453081a7f0 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -346,6 +346,14 @@ func NewRouter(db *database.DB, teslaClient *tesla.Client, mqttClient *mqtt.Clie httprate.LimitByIP(50, 1*time.Minute), ).Post("/api/v1/web-errors", webErrorHandler.Ingest) + // System state (Phase 46 / Prompt 04): single-row maintenance/degraded-mode + // banner state. Repo + handler + maintenance provider are constructed + // once here so the GET /system/health closure and the admin POST share + // the same store and env-vs-DB resolver semantics. + systemStateRepo := database.NewSystemStateRepo(db) + adminMaintenanceHandler := NewAdminMaintenanceHandler(systemStateRepo, cfg, db) + maintenanceProvider := BuildMaintenanceProvider(systemStateRepo, cfg) + // API v1 routes r.Route("/api/v1", func(r chi.Router) { @@ -987,7 +995,7 @@ func NewRouter(db *database.DB, teslaClient *tesla.Client, mqttClient *mqtt.Clie } } } - r.Get("/health", ExtendedHealthCheck(db, health, bufferStats)) + r.Get("/health", ExtendedHealthCheck(db, health, bufferStats, maintenanceProvider)) r.Get("/api-usage", APIUsageHandler(db)) r.Get("/compression-stats", CompressionStatsHandler(db)) r.Get("/backup", backupHandler.ExportData) @@ -1055,6 +1063,21 @@ func NewRouter(db *database.DB, teslaClient *tesla.Client, mqttClient *mqtt.Clie r.Get("/summary", webErrorHandler.Summary) }) + // Admin: operator-controlled maintenance/degraded banner + // (Phase 46 / Prompt 04). GET returns the persisted DB row + // plus an env-override marker; POST validates and writes the + // row, audits the change via logAuditFromRequest, and rate- + // limits per IP because state-change endpoints are otherwise + // trivially abusable. Auth-protected by the parent /api/v1 + // ForwardAuth middleware (any authenticated user can write — + // audit trail is the accountability surface; a future RBAC + // layer can wrap this without changing the response shape). + r.Route("/admin/maintenance", func(r chi.Router) { + r.Use(httprate.LimitByIP(30, 1*time.Minute)) + r.Get("/", adminMaintenanceHandler.Get) + r.Post("/", adminMaintenanceHandler.Set) + }) + // Fleet Telemetry ingestion r.Route("/telemetry", func(r chi.Router) { r.Post("/", telemetryHandler.TelemetryIngest) diff --git a/internal/config/config.go b/internal/config/config.go index 8b91fbdbbf..e877b8b321 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -27,6 +27,30 @@ type Config struct { AzureMaps AzureMapsConfig APILogs APILogsConfig WebPush WebPushConfig + System SystemConfig +} + +// SystemConfig holds the operator-controlled service-mode banner state +// (Phase-46 / Prompt 04). When Mode is empty, the effective state comes +// from the system_state DB row; when Mode is non-empty (any of "ok", +// "degraded", "maintenance"), the env values override the DB so an +// operator can force-clear or force-set the banner without touching the +// database (useful during deploy/rollback). +type SystemConfig struct { + // Mode is the operator-supplied service mode override. Empty means + // "fall through to DB state". Valid non-empty values: "ok", + // "degraded", "maintenance". Other values are treated as empty + // (no override) by the resolver. + Mode string + // MaintenanceMessage is the banner text shown to users when Mode is + // "degraded" or "maintenance". Trimmed and truncated to 280 chars + // at write time. Ignored when Mode is empty or "ok". + MaintenanceMessage string + // MaintenanceUntil is an RFC3339 timestamp when the banner should + // auto-clear (informational; the SPA renders a countdown). Empty + // string means no scheduled end. Invalid values are passed through + // to the SPA as-is so misconfiguration surfaces in dev tools. + MaintenanceUntil string } // WebPushConfig holds VAPID credentials used to sign and deliver Web Push @@ -358,6 +382,12 @@ func Load() (*Config, error) { PrivateKey: envStr("TESLASYNC_VAPID_PRIVATE_KEY", ""), Subject: envStr("TESLASYNC_VAPID_SUBJECT", ""), }, + + System: SystemConfig{ + Mode: envStr("TESLASYNC_SYSTEM_MODE", ""), + MaintenanceMessage: envStr("TESLASYNC_SYSTEM_MAINTENANCE_MESSAGE", ""), + MaintenanceUntil: envStr("TESLASYNC_SYSTEM_MAINTENANCE_UNTIL", ""), + }, } return cfg, nil diff --git a/internal/database/system_state_repo.go b/internal/database/system_state_repo.go new file mode 100644 index 0000000000..506be0516e --- /dev/null +++ b/internal/database/system_state_repo.go @@ -0,0 +1,172 @@ +package database + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/jackc/pgx/v5" +) + +// SystemMode enumerates the operator-controlled service modes that +// drive the top-of-app banner. Phase-46 / Prompt 04. +// +// "ok" is the default; "degraded" indicates partial functionality (e.g. +// an upstream is flapping) and "maintenance" indicates an operator-led +// outage window. +const ( + SystemModeOK = "ok" + SystemModeDegraded = "degraded" + SystemModeMaintenance = "maintenance" +) + +// MaintenanceMessageMaxLen is the server-enforced cap on +// system_state.maintenance_message length. Mirrored as a client-side +// char counter in the admin UI; the backend trims (rather than rejects) +// over-length inputs to match the "graceful degradation" pattern used +// elsewhere in TeslaSync. +const MaintenanceMessageMaxLen = 280 + +// ErrInvalidSystemMode is returned by ValidateSystemMode when the +// supplied value is not one of the SystemMode* constants. +var ErrInvalidSystemMode = errors.New("invalid system mode") + +// SystemState is the single-row materialization of the system_state +// table. Maintenance metadata is only meaningful when Mode is +// "degraded" or "maintenance" but the fields are always selected so +// callers can render a "previously set" hint after returning to "ok". +type SystemState struct { + Mode string + MaintenanceMessage string + MaintenanceUntil *time.Time + UpdatedAt time.Time + UpdatedBy string +} + +// SystemStateRepo persists and reads the single-row service-mode state. +// All accessors target row id=1 (enforced by a CHECK constraint in the +// migration) so callers don't pass an ID. +type SystemStateRepo struct { + db *DB +} + +// NewSystemStateRepo wires a repository against the shared pool. +func NewSystemStateRepo(db *DB) *SystemStateRepo { + return &SystemStateRepo{db: db} +} + +// Get reads the current service-mode row. If the row is missing +// (migration not applied or seed deleted) Get returns a zero-value +// SystemState with Mode == SystemModeOK so callers don't have to +// special-case the empty case in the hot health-check path. +func (r *SystemStateRepo) Get(ctx context.Context) (SystemState, error) { + if r == nil || r.db == nil { + return SystemState{Mode: SystemModeOK}, nil + } + var s SystemState + var msg *string + var by *string + err := r.db.Pool.QueryRow(ctx, + `SELECT mode, maintenance_message, maintenance_until, updated_at, updated_by + FROM system_state + WHERE id = 1`, + ).Scan(&s.Mode, &msg, &s.MaintenanceUntil, &s.UpdatedAt, &by) + if errors.Is(err, pgx.ErrNoRows) { + return SystemState{Mode: SystemModeOK}, nil + } + if err != nil { + return SystemState{}, fmt.Errorf("system_state get: %w", err) + } + if msg != nil { + s.MaintenanceMessage = *msg + } + if by != nil { + s.UpdatedBy = *by + } + return s, nil +} + +// Set updates the single system_state row. Mode is validated; message +// is trimmed and capped at MaintenanceMessageMaxLen. When mode is +// SystemModeOK the message and until are cleared so a stale banner +// can't reappear after a future re-toggle. +func (r *SystemStateRepo) Set(ctx context.Context, mode, message string, until *time.Time, updatedBy string) (SystemState, error) { + if r == nil || r.db == nil { + return SystemState{}, errors.New("system_state: repo not initialized") + } + mode, err := ValidateSystemMode(mode) + if err != nil { + return SystemState{}, err + } + message = NormalizeMaintenanceMessage(message) + if mode == SystemModeOK { + message = "" + until = nil + } + + var msgPtr *string + if message != "" { + msgPtr = &message + } + var byPtr *string + if updatedBy != "" { + byPtr = &updatedBy + } + + var s SystemState + var outMsg *string + var outBy *string + err = r.db.Pool.QueryRow(ctx, + `INSERT INTO system_state (id, mode, maintenance_message, maintenance_until, updated_at, updated_by) + VALUES (1, $1, $2, $3, NOW(), $4) + ON CONFLICT (id) DO UPDATE SET + mode = EXCLUDED.mode, + maintenance_message = EXCLUDED.maintenance_message, + maintenance_until = EXCLUDED.maintenance_until, + updated_at = NOW(), + updated_by = EXCLUDED.updated_by + RETURNING mode, maintenance_message, maintenance_until, updated_at, updated_by`, + mode, msgPtr, until, byPtr, + ).Scan(&s.Mode, &outMsg, &s.MaintenanceUntil, &s.UpdatedAt, &outBy) + if err != nil { + return SystemState{}, fmt.Errorf("system_state set: %w", err) + } + if outMsg != nil { + s.MaintenanceMessage = *outMsg + } + if outBy != nil { + s.UpdatedBy = *outBy + } + return s, nil +} + +// ValidateSystemMode normalizes (lower-case + trim) and validates a +// caller-supplied mode string. Returned as a separate exported helper +// so the admin handler can reject invalid input *before* opening a +// transaction; also unit-testable without a live DB. +func ValidateSystemMode(mode string) (string, error) { + m := strings.ToLower(strings.TrimSpace(mode)) + switch m { + case SystemModeOK, SystemModeDegraded, SystemModeMaintenance: + return m, nil + default: + return "", fmt.Errorf("%w: %q", ErrInvalidSystemMode, mode) + } +} + +// NormalizeMaintenanceMessage trims whitespace and truncates to +// MaintenanceMessageMaxLen runes (NOT bytes — UTF-8 safe). Pure helper +// so it can be unit-tested without a live DB. +func NormalizeMaintenanceMessage(message string) string { + m := strings.TrimSpace(message) + if m == "" { + return "" + } + runes := []rune(m) + if len(runes) > MaintenanceMessageMaxLen { + runes = runes[:MaintenanceMessageMaxLen] + } + return string(runes) +} diff --git a/internal/database/system_state_repo_test.go b/internal/database/system_state_repo_test.go new file mode 100644 index 0000000000..7dd2374f64 --- /dev/null +++ b/internal/database/system_state_repo_test.go @@ -0,0 +1,84 @@ +package database + +import ( + "errors" + "strings" + "testing" +) + +func TestValidateSystemMode(t *testing.T) { + t.Parallel() + tests := []struct { + name string + input string + want string + wantErr bool + }{ + {"ok lowercase", "ok", "ok", false}, + {"OK uppercase normalizes", "OK", "ok", false}, + {"degraded with whitespace", " degraded ", "degraded", false}, + {"maintenance mixed case", "Maintenance", "maintenance", false}, + {"empty", "", "", true}, + {"unknown value", "broken", "", true}, + {"sql injection attempt", "ok; DROP TABLE system_state", "", true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := ValidateSystemMode(tc.input) + if tc.wantErr { + if err == nil { + t.Fatalf("expected error for input %q, got %q", tc.input, got) + } + if !errors.Is(err, ErrInvalidSystemMode) { + t.Fatalf("expected ErrInvalidSystemMode wrap, got %v", err) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tc.want { + t.Fatalf("got %q, want %q", got, tc.want) + } + }) + } +} + +func TestNormalizeMaintenanceMessage(t *testing.T) { + t.Parallel() + t.Run("trim whitespace", func(t *testing.T) { + got := NormalizeMaintenanceMessage(" hello ") + if got != "hello" { + t.Fatalf("got %q, want %q", got, "hello") + } + }) + t.Run("empty stays empty", func(t *testing.T) { + if got := NormalizeMaintenanceMessage(" "); got != "" { + t.Fatalf("got %q, want empty", got) + } + }) + t.Run("preserves short messages", func(t *testing.T) { + in := "DB upgrade in progress" + if got := NormalizeMaintenanceMessage(in); got != in { + t.Fatalf("got %q, want %q", got, in) + } + }) + t.Run("truncates over-length to 280 runes", func(t *testing.T) { + in := strings.Repeat("a", 500) + got := NormalizeMaintenanceMessage(in) + if len([]rune(got)) != MaintenanceMessageMaxLen { + t.Fatalf("got %d runes, want %d", len([]rune(got)), MaintenanceMessageMaxLen) + } + }) + t.Run("multibyte runes counted by rune not byte", func(t *testing.T) { + // 281 emoji should truncate to exactly 280 runes (~1120 bytes + // in UTF-8). Asserting on rune count guards against a future + // regression to byte-based truncation that would slice through + // a multibyte sequence and produce an invalid string. + in := strings.Repeat("🚗", 281) + got := NormalizeMaintenanceMessage(in) + if r := len([]rune(got)); r != MaintenanceMessageMaxLen { + t.Fatalf("got %d runes, want %d", r, MaintenanceMessageMaxLen) + } + }) +} diff --git a/migrations/000168_system_state.down.sql b/migrations/000168_system_state.down.sql new file mode 100644 index 0000000000..49bbbf3a54 --- /dev/null +++ b/migrations/000168_system_state.down.sql @@ -0,0 +1,8 @@ +-- Phase-46 / Prompt 04 rollback: drop the operator-controlled service +-- mode row (any unsaved banner content is lost — operators should +-- record the prior state before downgrading). +BEGIN; + +DROP TABLE IF EXISTS system_state; + +COMMIT; diff --git a/migrations/000168_system_state.up.sql b/migrations/000168_system_state.up.sql new file mode 100644 index 0000000000..154abc5d45 --- /dev/null +++ b/migrations/000168_system_state.up.sql @@ -0,0 +1,35 @@ +-- Phase-46 / Prompt 04: persist operator-controlled service mode so a +-- maintenance/degraded banner can be set at runtime via the admin UI +-- (POST /api/v1/admin/maintenance) and survive API restarts. +-- +-- The table is intentionally a single row with a CHECK (id = 1) guard +-- so callers can read/write without worrying about which row to touch. +-- The seed INSERT...ON CONFLICT DO NOTHING keeps the migration +-- idempotent; subsequent re-runs (or down/up cycles in dev) leave any +-- operator-set state untouched if the row already exists. +BEGIN; + +CREATE TABLE IF NOT EXISTS system_state ( + id integer PRIMARY KEY CHECK (id = 1), + mode text NOT NULL DEFAULT 'ok', + maintenance_message text, + maintenance_until timestamptz, + updated_at timestamptz NOT NULL DEFAULT NOW(), + updated_by text +); + +COMMENT ON TABLE system_state IS + 'Operator-controlled service mode banner state. Single row (id=1).'; +COMMENT ON COLUMN system_state.mode IS + 'One of: ok | degraded | maintenance. Drives top-of-app banner.'; +COMMENT ON COLUMN system_state.maintenance_message IS + 'Operator-supplied banner text (max 280 chars enforced at write time).'; +COMMENT ON COLUMN system_state.maintenance_until IS + 'When the banner should auto-clear (informational; SPA renders countdown).'; +COMMENT ON COLUMN system_state.updated_by IS + 'ForwardAuth header value of the actor who last wrote this row.'; + +INSERT INTO system_state (id, mode) VALUES (1, 'ok') + ON CONFLICT (id) DO NOTHING; + +COMMIT; diff --git a/web/src/api/hooks/useAdmin.ts b/web/src/api/hooks/useAdmin.ts index 00c7e6e91c..14836982df 100644 --- a/web/src/api/hooks/useAdmin.ts +++ b/web/src/api/hooks/useAdmin.ts @@ -7,7 +7,7 @@ import type { APIKey, APICallLog, APICallLogStats, BackupConfig, BackupRun, SystemHealth, AuditLogEntry, SecurityEvent, DBStats, MigrationStatus, ConnectionPool, ExportJob, VehicleState, StateTransition, - WebErrorsSummary, + WebErrorsSummary, MaintenanceState, MaintenanceUpdateInput, } from '@/types/admin'; export const adminKeys = { @@ -26,6 +26,7 @@ export const adminKeys = { vehicleState: (vehicleId: string) => ['vehicle-state', vehicleId] as const, stateTimeline: (vehicleId: string) => ['state-timeline', vehicleId] as const, webErrorsSummary: ['admin', 'web-errors-summary'] as const, + maintenance: ['admin', 'maintenance'] as const, }; export function useApiKeys() { @@ -121,6 +122,53 @@ export function useSystemHealth() { }); } +/** + * Persisted maintenance/degraded-mode state (Phase 46 / Prompt 04). + * + * GET /api/v1/admin/maintenance returns the current system_state row + * plus a `source` marker indicating whether an env override is + * currently shadowing the DB value. The admin Maintenance Mode panel + * polls this on the standard interval; the MaintenanceBanner reads + * /system/health (which carries the resolved view) on a separate + * cadence so the banner stays close to live without double-polling. + */ +export function useMaintenanceState() { + return useQuery({ + queryKey: adminKeys.maintenance, + queryFn: ({ signal }) => request('/admin/maintenance', { signal }), + refetchInterval: INTERVALS.STANDARD, + }); +} + +/** + * POST /api/v1/admin/maintenance — operator override for the service-mode + * banner. Invalidates BOTH the admin maintenance query and the system + * health query so the banner picks up the change within one cycle + * instead of waiting for the next refetchInterval to fire. + */ +export function useUpdateMaintenance() { + const qc = useQueryClient(); + const { success, error } = useMutationToast(); + return useMutation({ + mutationFn: (input: MaintenanceUpdateInput) => + request('/admin/maintenance', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + mode: input.mode, + message: input.message ?? '', + until: input.until ?? null, + }), + }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: adminKeys.maintenance }); + qc.invalidateQueries({ queryKey: adminKeys.systemHealth }); + success('toast.admin.maintenance.success', 'Maintenance state updated'); + }, + onError: (e) => error(e, 'toast.admin.maintenance.error', 'Failed to update maintenance state'), + }); +} + export function useAuditLogs() { return useQuery({ queryKey: adminKeys.auditLogs, diff --git a/web/src/components/feedback/MaintenanceBanner.tsx b/web/src/components/feedback/MaintenanceBanner.tsx new file mode 100644 index 0000000000..95ff25ee46 --- /dev/null +++ b/web/src/components/feedback/MaintenanceBanner.tsx @@ -0,0 +1,191 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { AlertTriangle, Wrench, X } from 'lucide-react' +import { useSystemHealth } from '@/api/hooks/useAdmin' + +/** + * Phase-46 / Prompt 04 — Maintenance / degraded-mode banner. + * + * Polls /api/v1/system/health (via {@link useSystemHealth}) on the + * standard interval and renders a sticky top-of-app banner when the + * resolved service mode is "degraded" or "maintenance". The banner + * includes a live countdown to `maintenance_until` so users know when + * the window is expected to end. + * + * Dismissal is per-snapshot, keyed on `maintenance_updated_at` (or a + * deterministic fingerprint of mode/message/until when updated_at is + * absent). This means: + * • A user who dismisses once stays dismissed for THAT specific + * banner state. + * • An operator who pushes a NEW banner (different message, different + * end-time, or even the same mode after a clear → re-set cycle) + * re-surfaces the banner — the dismissal does not silently swallow + * a fresh announcement. + * + * sessionStorage is used (not localStorage) so a closed-and-reopened + * tab starts fresh; the operator-driven cadence here is roughly hours + * not days, and a stale tab is the case where re-surfacing matters + * most. + * + * Mounted in ABOVE RateLimitBanner / NewVersionBanner so the + * highest-impact operational message shows first when multiple + * banners stack. + */ + +const SESSION_DISMISS_KEY = 'teslasync:maintenance-dismissed-for' + +/** Returns the dismissal-fingerprint for the supplied snapshot. */ +function fingerprint(mode: string, message: string, until: string, updatedAt: string): string { + if (updatedAt) return `u:${updatedAt}` + return `s:${mode}|${message}|${until}` +} + +function readDismissedKey(): string | null { + if (typeof window === 'undefined') return null + try { + return window.sessionStorage.getItem(SESSION_DISMISS_KEY) + } catch { + return null + } +} + +function writeDismissedKey(key: string) { + try { + window.sessionStorage.setItem(SESSION_DISMISS_KEY, key) + } catch { + /* private mode / quota — fall through, in-memory dismissal still works */ + } +} + +/** Renders "Hh Mm Ss" (zero-padded short form). */ +function formatRemaining(ms: number): string { + const total = Math.max(0, Math.floor(ms / 1000)) + const hours = Math.floor(total / 3600) + const minutes = Math.floor((total % 3600) / 60) + const seconds = total % 60 + if (hours > 0) { + return `${hours}h ${String(minutes).padStart(2, '0')}m` + } + if (minutes > 0) { + return `${minutes}m ${String(seconds).padStart(2, '0')}s` + } + return `${seconds}s` +} + +export function MaintenanceBanner() { + const { t } = useTranslation() + const { data } = useSystemHealth() + const [dismissedKey, setDismissedKey] = useState(() => readDismissedKey()) + const [now, setNow] = useState(() => Date.now()) + const tickRef = useRef | null>(null) + + const mode = data?.mode ?? 'ok' + const message = data?.maintenance_message ?? '' + const until = data?.maintenance_until ?? '' + const updatedAt = data?.maintenance_updated_at ?? '' + + const currentKey = useMemo( + () => fingerprint(mode, message, until, updatedAt), + [mode, message, until, updatedAt], + ) + + const untilMs = useMemo(() => { + if (!until) return null + const t = Date.parse(until) + return Number.isFinite(t) ? t : null + }, [until]) + + // Reset stale dismissal whenever the upstream snapshot changes — + // keeps the operator's "I just pushed a new banner" workflow honest. + useEffect(() => { + if (dismissedKey && dismissedKey !== currentKey) { + setDismissedKey(null) + } + }, [currentKey, dismissedKey]) + + // Tick every second only while the countdown is mounted; otherwise + // we'd churn the whole subtree once per second on every page load. + useEffect(() => { + if (mode === 'ok' || untilMs === null) return + tickRef.current = setInterval(() => setNow(Date.now()), 1000) + return () => { + if (tickRef.current) { + clearInterval(tickRef.current) + tickRef.current = null + } + } + }, [mode, untilMs]) + + if (!data || mode === 'ok') return null + if (dismissedKey === currentKey) return null + + const isMaintenance = mode === 'maintenance' + const Icon = isMaintenance ? Wrench : AlertTriangle + const tone = isMaintenance ? 'amber' : 'sky' + + const title = isMaintenance + ? t('serviceMode.banner.maintenanceTitle', 'Scheduled maintenance') + : t('serviceMode.banner.degradedTitle', 'Service is degraded') + + const body = message.trim() || (isMaintenance + ? t('serviceMode.banner.defaultMaintenance', 'Maintenance is in progress. Live data may be paused.') + : t('serviceMode.banner.defaultDegraded', 'Some features may be slow or unavailable while we work on it.')) + + let countdown: string | null = null + if (untilMs !== null) { + const remaining = untilMs - now + if (remaining > 1000) { + countdown = t('serviceMode.banner.endsIn', 'Ends in {{time}}', { time: formatRemaining(remaining) }) + } else if (remaining > -1000) { + countdown = t('serviceMode.banner.endingNow', 'Ending now') + } else { + countdown = t('serviceMode.banner.ended', 'Window has ended; refresh to confirm.') + } + } + + const handleDismiss = () => { + writeDismissedKey(currentKey) + setDismissedKey(currentKey) + } + + // Tone colors are kept inline-ish via Tailwind class strings rather + // than a clsx call so the bundle stays free of the ternary helper. + const toneClasses = tone === 'amber' + ? 'border-amber-300/30 bg-amber-300/[0.08]' + : 'border-sky-300/30 bg-sky-300/[0.08]' + const iconBg = tone === 'amber' ? 'bg-amber-300/15 text-amber-300' : 'bg-sky-300/15 text-sky-300' + + return ( +
+
+ +
+
+

{title}

+

{body}

+ {countdown && ( +

+ {countdown} +

+ )} +
+ +
+ ) +} + +export default MaintenanceBanner diff --git a/web/src/components/feedback/__tests__/MaintenanceBanner.test.tsx b/web/src/components/feedback/__tests__/MaintenanceBanner.test.tsx new file mode 100644 index 0000000000..747794b85f --- /dev/null +++ b/web/src/components/feedback/__tests__/MaintenanceBanner.test.tsx @@ -0,0 +1,174 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { render, screen, fireEvent, act } from '@testing-library/react' + +/** + * Phase-46 / Prompt 04 — MaintenanceBanner contract. + * + * Mocks {@link useSystemHealth} so the test focuses on the banner's + * visibility lifecycle, dismissal persistence, and re-surfacing + * behaviour when the upstream snapshot changes. react-i18next is + * stubbed to echo default values so assertions can match the + * fallback English copy directly. + */ + +type MockHealth = { + mode?: 'ok' | 'degraded' | 'maintenance' + maintenance_message?: string + maintenance_until?: string + maintenance_updated_at?: string +} | null + +let mockHealth: MockHealth = null + +vi.mock('@/api/hooks/useAdmin', () => ({ + useSystemHealth: () => ({ data: mockHealth }), +})) + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (_key: string, defaultOrOpts?: string | Record, opts?: Record) => { + // useTranslation in this codebase is called as t(key, defaultValue) OR t(key, defaultValue, opts). + // The mock returns the default verbatim with {{var}} interpolation so the + // test can assert on rendered copy without pulling i18n into the spec. + if (typeof defaultOrOpts === 'string') { + let out = defaultOrOpts + const interp = opts ?? {} + for (const [k, v] of Object.entries(interp)) { + out = out.replace(new RegExp(`\\{\\{${k}\\}\\}`, 'g'), String(v)) + } + return out + } + return _key + }, + }), +})) + +import { MaintenanceBanner } from '../MaintenanceBanner' + +const SESSION_DISMISS_KEY = 'teslasync:maintenance-dismissed-for' + +describe('MaintenanceBanner', () => { + beforeEach(() => { + mockHealth = null + window.sessionStorage.clear() + vi.useFakeTimers() + vi.setSystemTime(new Date('2025-01-01T12:00:00Z')) + }) + + afterEach(() => { + window.sessionStorage.clear() + vi.useRealTimers() + }) + + it('renders nothing when health data is missing', () => { + mockHealth = null + const { container } = render() + expect(container.firstChild).toBeNull() + }) + + it('renders nothing when mode is ok', () => { + mockHealth = { mode: 'ok' } + const { container } = render() + expect(container.firstChild).toBeNull() + }) + + it('renders the maintenance variant with message and aria=alert', () => { + mockHealth = { + mode: 'maintenance', + maintenance_message: 'DB upgrade in progress', + maintenance_updated_at: '2025-01-01T11:00:00Z', + } + render() + const banner = screen.getByTestId('maintenance-banner') + expect(banner).toHaveAttribute('data-mode', 'maintenance') + expect(banner).toHaveAttribute('role', 'alert') + expect(screen.getByText('Scheduled maintenance')).toBeInTheDocument() + expect(screen.getByText('DB upgrade in progress')).toBeInTheDocument() + }) + + it('renders the degraded variant with role=status and default copy when message is empty', () => { + mockHealth = { + mode: 'degraded', + maintenance_updated_at: '2025-01-01T11:00:00Z', + } + render() + const banner = screen.getByTestId('maintenance-banner') + expect(banner).toHaveAttribute('data-mode', 'degraded') + expect(banner).toHaveAttribute('role', 'status') + expect(screen.getByText('Service is degraded')).toBeInTheDocument() + expect( + screen.getByText('Some features may be slow or unavailable while we work on it.'), + ).toBeInTheDocument() + }) + + it('renders a countdown when maintenance_until is in the future', () => { + mockHealth = { + mode: 'maintenance', + maintenance_message: 'short window', + maintenance_until: '2025-01-01T12:30:00Z', + maintenance_updated_at: '2025-01-01T11:00:00Z', + } + render() + expect(screen.getByTestId('maintenance-banner-countdown').textContent).toMatch(/Ends in 30m/) + }) + + it('hides the banner when the user dismisses and persists the snapshot key', () => { + mockHealth = { + mode: 'maintenance', + maintenance_message: 'first window', + maintenance_updated_at: '2025-01-01T11:00:00Z', + } + render() + fireEvent.click(screen.getByTestId('maintenance-banner-dismiss')) + expect(screen.queryByTestId('maintenance-banner')).not.toBeInTheDocument() + expect(window.sessionStorage.getItem(SESSION_DISMISS_KEY)).toBe('u:2025-01-01T11:00:00Z') + }) + + it('does not render when sessionStorage already records a dismissal for the current snapshot', () => { + window.sessionStorage.setItem(SESSION_DISMISS_KEY, 'u:2025-01-01T11:00:00Z') + mockHealth = { + mode: 'maintenance', + maintenance_message: 'first window', + maintenance_updated_at: '2025-01-01T11:00:00Z', + } + const { container } = render() + expect(container.firstChild).toBeNull() + }) + + it('re-surfaces the banner when the operator pushes a new snapshot (different updated_at)', () => { + window.sessionStorage.setItem(SESSION_DISMISS_KEY, 'u:2025-01-01T11:00:00Z') + mockHealth = { + mode: 'maintenance', + maintenance_message: 'second window', + maintenance_updated_at: '2025-01-01T13:00:00Z', + } + render() + expect(screen.getByTestId('maintenance-banner')).toBeInTheDocument() + }) + + it('falls back to a content fingerprint when updated_at is absent', () => { + mockHealth = { + mode: 'degraded', + maintenance_message: 'no updated_at', + } + render() + fireEvent.click(screen.getByTestId('maintenance-banner-dismiss')) + const stored = window.sessionStorage.getItem(SESSION_DISMISS_KEY) ?? '' + expect(stored.startsWith('s:degraded|no updated_at|')).toBe(true) + }) + + it('updates the countdown as time advances', () => { + mockHealth = { + mode: 'maintenance', + maintenance_message: 'tick', + maintenance_until: '2025-01-01T12:01:00Z', + maintenance_updated_at: '2025-01-01T11:00:00Z', + } + render() + expect(screen.getByTestId('maintenance-banner-countdown').textContent).toMatch(/1m 00s/) + act(() => { + vi.advanceTimersByTime(15_000) + }) + expect(screen.getByTestId('maintenance-banner-countdown').textContent).toMatch(/45s/) + }) +}) diff --git a/web/src/components/feedback/index.ts b/web/src/components/feedback/index.ts index 475a084fac..5e8eec8b80 100644 --- a/web/src/components/feedback/index.ts +++ b/web/src/components/feedback/index.ts @@ -23,6 +23,7 @@ export { LiveStaleDataBanner } from './LiveStaleDataBanner'; export { AuthExpiredOverlay } from './AuthExpiredOverlay'; export { TeslaReauthBanner } from './TeslaReauthBanner'; export { RateLimitBanner } from './RateLimitBanner'; +export { MaintenanceBanner } from './MaintenanceBanner'; export { GotoIndicator } from './GotoIndicator'; export { KeyboardShortcutsModal } from './KeyboardShortcutsModal'; export { TourOverlay } from './TourOverlay'; diff --git a/web/src/components/layout/Layout.tsx b/web/src/components/layout/Layout.tsx index 344b292d77..f030d64dda 100644 --- a/web/src/components/layout/Layout.tsx +++ b/web/src/components/layout/Layout.tsx @@ -5,6 +5,7 @@ import { OfflineBanner } from '../feedback/OfflineBanner' import { NewVersionBanner } from '../feedback/NewVersionBanner' import { TeslaReauthBanner } from '../feedback/TeslaReauthBanner' import { RateLimitBanner } from '../feedback/RateLimitBanner' +import { MaintenanceBanner } from '../feedback/MaintenanceBanner' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { createPortal } from 'react-dom' import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts' @@ -1371,6 +1372,13 @@ export default function Layout() { {/* Offline status banner (PWA / mobile) */} + {/* Service-mode banner (Phase-46 / Prompt 04) — operator-controlled + maintenance/degraded banner. Mounted ABOVE the rate-limit and + version banners because an operator-declared outage is the + highest-priority operational message and should not be hidden + under transient client-side notices. */} + + {/* Rate-limit / circuit-breaker banner (Phase-45 / Prompt 33) — most-transient surface, sits on top so the user sees the countdown before any of the slower-cycling banners. Stack diff --git a/web/src/features/admin/components/MaintenanceModePanel.tsx b/web/src/features/admin/components/MaintenanceModePanel.tsx new file mode 100644 index 0000000000..ae1ce5baa4 --- /dev/null +++ b/web/src/features/admin/components/MaintenanceModePanel.tsx @@ -0,0 +1,211 @@ +import { useEffect, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Wrench, AlertTriangle, Save, Info } from 'lucide-react' +import { GlassPanel } from '@/components/ui/GlassPanel' +import { Button } from '@/components/ui/Button' +import { Select } from '@/components/ui/Select' +import { Input } from '@/components/ui/Input' +import { Textarea } from '@/components/ui/Textarea' +import { Skeleton } from '@/components/feedback/Skeleton' +import { useMaintenanceState, useUpdateMaintenance } from '@/api/hooks/useAdmin' +import type { MaintenanceState } from '@/types/admin' + +/** + * Phase-46 / Prompt 04 — Service Mode admin panel. + * + * Lets an authenticated operator set the top-of-app banner state + * (mode + message + auto-clear time). Surfaces an env-override warning + * when TESLASYNC_SYSTEM_MODE is set so the operator understands why a + * "Save" might persist to the DB but not visibly take effect for users + * until the env var is cleared. + * + * The "Preview" block renders the same copy structure as + * so the operator can see what users will see + * before clicking Save. + * + * NOTE: this panel ships as a sub-component of AdminPage.tsx so the + * existing /admin route automatically picks it up — no router change + * required. + */ + +const MODE_OPTIONS: Array<{ value: 'ok' | 'degraded' | 'maintenance'; label: string }> = [ + { value: 'ok', label: 'serviceMode.admin.modes.ok' }, + { value: 'degraded', label: 'serviceMode.admin.modes.degraded' }, + { value: 'maintenance', label: 'serviceMode.admin.modes.maintenance' }, +] + +const MESSAGE_MAX = 280 + +function toLocalInputValue(iso: string | null | undefined): string { + if (!iso) return '' + const d = new Date(iso) + if (Number.isNaN(d.getTime())) return '' + // Render as the value an understands. + // (yyyy-MM-ddTHH:mm in local time so the operator can pick a wall-clock + // time; we serialize back to ISO/UTC before POST.) + const pad = (n: number) => String(n).padStart(2, '0') + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}` +} + +function fromLocalInputValue(local: string): string | null { + if (!local.trim()) return null + const d = new Date(local) + if (Number.isNaN(d.getTime())) return null + return d.toISOString() +} + +export function MaintenanceModePanel() { + const { t } = useTranslation() + const { data, isLoading } = useMaintenanceState() + const updateMut = useUpdateMaintenance() + + const persisted = data as MaintenanceState | undefined + const [mode, setMode] = useState<'ok' | 'degraded' | 'maintenance'>('ok') + const [message, setMessage] = useState('') + const [untilLocal, setUntilLocal] = useState('') + + // Hydrate the form whenever the upstream snapshot changes (initial + // load, post-save refetch, or another operator's poll arriving). Don't + // overwrite local edits while a mutation is in flight. + useEffect(() => { + if (!persisted || updateMut.isPending) return + setMode(persisted.mode) + setMessage(persisted.maintenance_message ?? '') + setUntilLocal(toLocalInputValue(persisted.maintenance_until ?? null)) + }, [persisted, updateMut.isPending]) + + const isEnvOverride = persisted?.source === 'env' + const charsLeft = MESSAGE_MAX - message.length + + const previewBody = useMemo(() => { + if (mode === 'ok') return '' + if (message.trim()) return message.trim() + return mode === 'maintenance' + ? t('serviceMode.banner.defaultMaintenance', 'Maintenance is in progress. Live data may be paused.') + : t('serviceMode.banner.defaultDegraded', 'Some features may be slow or unavailable while we work on it.') + }, [mode, message, t]) + + const handleSave = () => { + updateMut.mutate({ + mode, + message: message.trim().slice(0, MESSAGE_MAX), + until: mode === 'ok' ? null : fromLocalInputValue(untilLocal), + }) + } + + return ( + + + + {t('serviceMode.admin.title', 'Service Mode')} + + + {t('serviceMode.admin.subtitle', 'Control the top-of-app banner shown to all users.')} + + + {isEnvOverride && ( +
+ + + {t('serviceMode.admin.envOverride', 'Environment variable TESLASYNC_SYSTEM_MODE={{mode}} is currently overriding the database value. Updates here will persist but won\'t affect users until the env override is cleared.', { mode: persisted?.env_override_mode ?? '' })} + +
+ )} + + {isLoading ? ( +
+ + + +
+ ) : ( +
+
+ +