diff --git a/packages/amplify/amplify/backend.ts b/packages/amplify/amplify/backend.ts index 8e1c93ca..0eeee7cf 100644 --- a/packages/amplify/amplify/backend.ts +++ b/packages/amplify/amplify/backend.ts @@ -225,6 +225,10 @@ TelemetryECSTaskDefinition.addContainer("TheContainer", { HeliosPasswords, "FINISH_LINE_UPDATE_PASSWORD", ), + SNAPSHOT_PASSWORD: ecs.Secret.fromSecretsManager( + HeliosPasswords, + "SNAPSHOT_PASSWORD", + ), }, }); diff --git a/packages/client/.env.example b/packages/client/.env.example index 4991ae47..28e55986 100644 --- a/packages/client/.env.example +++ b/packages/client/.env.example @@ -1,2 +1,7 @@ NEXT_PUBLIC_MAPSAPIKEY='' SOCKET_URL=localhost:3001 + +# Grafana dashboard links (opens in new tab from the Analysis page) +# Use the browser address bar URL of each dashboard +NEXT_PUBLIC_GRAFANA_LIVE_OPEN_URL='' +NEXT_PUBLIC_GRAFANA_HISTORY_OPEN_URL='' diff --git a/packages/client/public/assets/grafana_icon.svg b/packages/client/public/assets/grafana_icon.svg new file mode 100644 index 00000000..e91f3abd --- /dev/null +++ b/packages/client/public/assets/grafana_icon.svg @@ -0,0 +1,57 @@ + + + + + + + + + + + + diff --git a/packages/client/src/components/containers/TabsContainer.tsx b/packages/client/src/components/containers/TabsContainer.tsx index 02a2a03d..bdeab46e 100644 --- a/packages/client/src/components/containers/TabsContainer.tsx +++ b/packages/client/src/components/containers/TabsContainer.tsx @@ -10,27 +10,29 @@ function TabsContainer() { const { slug } = router.query; return ( -
+
- {routes.map((route: SolarCarRoutes) => { - if (route.path === "/" + slug?.toString()) { - const isRaceOrAnalysisTab = - route.path === "/race" || route.path === "/analysis"; +
+ {routes.map((route: SolarCarRoutes) => { + if (route.path === "/" + slug?.toString()) { + const isRaceOrAnalysisTab = + route.path === "/race" || route.path === "/analysis"; - return ( - - {isRaceOrAnalysisTab ? ( - route.element - ) : ( - - {route.element} - - )} - - ); - } - return null; - })} + return ( + + {isRaceOrAnalysisTab ? ( + route.element + ) : ( + + {route.element} + + )} + + ); + } + return null; + })} +
); } diff --git a/packages/client/src/components/tabs/AnalysisTab.tsx b/packages/client/src/components/tabs/AnalysisTab.tsx index 9017ba55..73fe1fbd 100644 --- a/packages/client/src/components/tabs/AnalysisTab.tsx +++ b/packages/client/src/components/tabs/AnalysisTab.tsx @@ -3,10 +3,28 @@ import Image from "next/image"; import React, { useState } from "react"; import { twMerge } from "tailwind-merge"; +import { useCreateSnapshot, useRecentSnapshot } from "@/hooks/useSnapshots"; import { tabs } from "@/objects/TabRoutes"; -import { helios, lightGray, mediumGray } from "@/styles/colors"; +import { useAppState } from "@/stores/useAppState"; +import { usePacketStore } from "@/stores/usePacket"; +import { + helios, + heliosCompliment, + lightGray, + mediumGray, +} from "@/styles/colors"; import { ThemeProvider } from "@emotion/react"; -import { Tab, Tabs, createTheme } from "@mui/material"; +import { notifications } from "@mantine/notifications"; +import { ExpandMore, OpenInNew } from "@mui/icons-material"; +import { + Button, + CircularProgress, + Collapse, + Tab, + Tabs, + TextField, + createTheme, +} from "@mui/material"; import MLContainer from "../containers/MLContainer"; import StatsContainer from "../molecules/AnalysisMolecules/StatsContainer"; @@ -17,16 +35,6 @@ type TabContentProps = React.PropsWithChildren<{ className?: string; }>; -const filters: string[] = [ - "Motor Temperature", - "Battery Consumption", - "Average Speed", - "Brake Time", - "Power Out", - "Battery Voltage", - "Battery Current", -] as const; - const color = createTheme({ palette: { primary: { @@ -52,6 +60,322 @@ export function TabContent({ ); } +const grafanaLinks = [ + { + label: "Grafana Live", + url: process.env.NEXT_PUBLIC_GRAFANA_LIVE_OPEN_URL, + }, + { + label: "Grafana History", + url: process.env.NEXT_PUBLIC_GRAFANA_HISTORY_OPEN_URL, + }, +] + // Normalize once: trim here so the stored url (used as href) is clean. + .map((link) => ({ ...link, url: link.url?.trim() })) + .filter((link): link is { url: string; label: string } => Boolean(link.url)); + +function GrafanaLink({ href, label }: { href: string; label: string }) { + return ( + + Grafana + {label} + + + ); +} + +function parseSnapshotTimeRange( + rawUrl: string, +): { from: string; to: string } | null { + try { + const parsed = new URL(rawUrl.trim()); + const from = parsed.searchParams.get("from"); + const to = parsed.searchParams.get("to"); + if (!from || !to) return null; + if (isNaN(new Date(from).getTime()) || isNaN(new Date(to).getTime())) + return null; + return { from, to }; + } catch { + return null; + } +} + +function AddSnapshotForm({ + expanded, + onToggle, +}: { + expanded: boolean; + onToggle: () => void; +}) { + const [urlValue, setUrlValue] = useState(""); + const [labelValue, setLabelValue] = useState(""); + const [passwordValue, setPasswordValue] = useState(""); + const { resolvedTheme } = useTheme(); + const { isPending, mutate } = useCreateSnapshot({ + onSuccess: () => { + setUrlValue(""); + setLabelValue(""); + setPasswordValue(""); + }, + }); + + const timeRange = parseSnapshotTimeRange(urlValue); + const urlHasValue = urlValue.trim().length > 0; + const urlError = urlHasValue && timeRange === null; + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + const url = urlValue.trim(); + const label = labelValue.trim(); + const password = passwordValue.trim(); + + if (!url || !label || !password) { + notifications.show({ + color: "red", + message: "Please fill in all fields before saving.", + title: "Missing fields", + }); + return; + } + + const range = parseSnapshotTimeRange(url); + if (!range) { + notifications.show({ + color: "red", + message: + "The URL must contain valid `from` and `to` query parameters (e.g. ?from=2026-01-01T00:00:00Z&to=2026-12-31T23:59:59Z).", + title: "Cannot parse time range", + }); + return; + } + + mutate({ + label, + password, + snapshot_from: range.from, + snapshot_to: range.to, + url, + }); + } + + // Outlined-field styling matched to the Lap Data filters (ColumnFilters / + // DriverFilter): helios borders, heliosCompliment floating label. + const fieldSx = { + "& .MuiInputLabel-root": { + "&.Mui-focused": { color: heliosCompliment }, + "&.MuiInputLabel-shrink": { color: heliosCompliment }, + color: heliosCompliment, + }, + "& .MuiOutlinedInput-input": { + color: resolvedTheme === "dark" ? lightGray : "black", + }, + "& .MuiOutlinedInput-notchedOutline": { borderColor: helios }, + "&.Mui-focused .MuiOutlinedInput-notchedOutline": { + borderColor: heliosCompliment, + }, + "&:hover .MuiOutlinedInput-notchedOutline": { + borderColor: resolvedTheme === "dark" ? "white" : helios, + }, + }; + + return ( +
+ + +
+ setUrlValue(e.target.value)} + size="small" + sx={fieldSx} + value={urlValue} + /> +
+ setLabelValue(e.target.value)} + size="small" + sx={fieldSx} + value={labelValue} + /> + setPasswordValue(e.target.value)} + size="small" + sx={{ ...fieldSx, maxWidth: 140 }} + type="password" + value={passwordValue} + /> + +
+ +
+
+ ); +} + +function GrafanaHistoryTabContent() { + const playbackSwitch = useAppState((s) => s.currentAppState.playbackSwitch); + const timestamp = usePacketStore((s) => s.currentPacket.TimeStamp); + const { resolvedTheme } = useTheme(); + const { data: latestSnapshot = null, isLoading: snapshotsLoading } = + useRecentSnapshot(); + + // Collapsed: give the iframe the full height. Expanded: shrink it so the + // Add Snapshot form has room without spilling past the tab into the rows + // below. CSS-only height change — the iframe resizes (Grafana reflows its + // panels) and is never re-fetched. + const [snapshotFormExpanded, setSnapshotFormExpanded] = useState(false); + const baseUrl = latestSnapshot?.url ?? null; + + // The server only stores embeddable snapshots.raintank.io URLs, so we can + // build the iframe src directly and just append the theme query param. + let iframeUrl: string | null = null; + if (baseUrl) { + try { + const url = new URL(baseUrl); + url.searchParams.set( + "theme", + resolvedTheme === "dark" ? "dark" : "light", + ); + iframeUrl = url.toString(); + } catch { + iframeUrl = null; + } + } + + // next-themes returns resolvedTheme === undefined on the server and the first + // client render; gating on it avoids loading the iframe with the wrong theme + // and flickering once the real theme resolves. + if (snapshotsLoading || !resolvedTheme) { + return ( +
+ +
+ ); + } + + return ( +
+ {iframeUrl ? ( + <> +
+

+ Showing snapshot:{" "} + + {latestSnapshot?.label} + + {" · "} + {latestSnapshot && + new Date(latestSnapshot.created_at).toLocaleDateString()} +

+
+ {playbackSwitch && ( +
+ + Playback position: + + + {new Date(timestamp * 1000).toLocaleString()} + + + ← locate this timestamp on the chart + +
+ )} +