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 (
+
+
+ {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 (
+
+
+ Add Snapshot
+
+
+
+
+
+
+ );
+}
+
+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
+
+
+ )}
+
+ >
+ ) : (
+
+
No snapshot available yet.
+
+ In Grafana: Share → Snapshot → Publish , then add
+ the URL below.
+
+
+ )}
+
setSnapshotFormExpanded((prev) => !prev)}
+ />
+
+ );
+}
+
function AnalysisTab() {
const [value, setValue] = useState(0);
const { resolvedTheme } = useTheme();
@@ -60,20 +384,9 @@ function AnalysisTab() {
{/* NAVBAR */}
- {/*
-
- Insert Name
-
*/}
-
+
+ {grafanaLinks.length > 0 && (
+
+ {grafanaLinks.map(({ label, url }) => (
+
+ ))}
+
+ )}
{/* MAIN */}
- {/*
- {filters.map((filter) => (
-
-
-
-
- {filter}
-
-
-
- ))}
-
*/}
-
+
+
+
diff --git a/packages/client/src/constants/apiRoutes.ts b/packages/client/src/constants/apiRoutes.ts
index 94d22f37..a4c0b144 100644
--- a/packages/client/src/constants/apiRoutes.ts
+++ b/packages/client/src/constants/apiRoutes.ts
@@ -82,6 +82,16 @@ export const BACKEND_ROUTES = {
/** Get packets between time range */
packetsBetween: "/packetsBetween",
},
+
+ /**
+ * Grafana snapshot endpoints
+ */
+ snapshots: {
+ /** Create new snapshot */
+ base: "/snapshots",
+ /** Get the most recent snapshot */
+ recent: "/snapshots/recent",
+ },
} as const;
/**
diff --git a/packages/client/src/hooks/useSnapshots.ts b/packages/client/src/hooks/useSnapshots.ts
new file mode 100644
index 00000000..10d4a6b9
--- /dev/null
+++ b/packages/client/src/hooks/useSnapshots.ts
@@ -0,0 +1,89 @@
+import { useEffect } from "react";
+
+import { BACKEND_ROUTES } from "@/constants/apiRoutes";
+import { backendApi } from "@/lib/api";
+import { notifications } from "@mantine/notifications";
+import type {
+ CreateSnapshotRequestDTO,
+ CreateSnapshotResponseDTO,
+ IGrafanaSnapshot,
+ RecentSnapshotResponseDTO,
+} from "@shared/helios-types";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import axios from "axios";
+
+export const SNAPSHOTS_QUERY_KEY = "grafana-snapshots";
+
+async function fetchRecentSnapshot(): Promise {
+ const response = await backendApi.get(
+ BACKEND_ROUTES.snapshots.recent,
+ );
+ return response.data.data;
+}
+
+async function postSnapshot(
+ data: CreateSnapshotRequestDTO,
+): Promise {
+ const response = await backendApi.post(
+ BACKEND_ROUTES.snapshots.base,
+ data,
+ );
+ return response.data;
+}
+
+export function useRecentSnapshot() {
+ const query = useQuery({
+ gcTime: 1000 * 60 * 5,
+ placeholderData: null,
+ queryFn: fetchRecentSnapshot,
+ queryKey: [SNAPSHOTS_QUERY_KEY] as const,
+ refetchOnWindowFocus: false,
+ staleTime: 1000 * 60 * 2,
+ throwOnError: false,
+ });
+
+ useEffect(() => {
+ if (query.error) {
+ notifications.show({
+ color: "red",
+ message: "Failed to load Grafana snapshots.",
+ title: "Error",
+ });
+ }
+ }, [query.error]);
+
+ return query;
+}
+
+interface UseCreateSnapshotOptions {
+ onSuccess?: () => void;
+}
+
+export function useCreateSnapshot(options?: UseCreateSnapshotOptions) {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: postSnapshot,
+ onError: (error) => {
+ const serverMessage =
+ axios.isAxiosError(error) &&
+ (error.response?.data as { error?: string } | undefined)?.error;
+ notifications.show({
+ color: "red",
+ message:
+ serverMessage ||
+ "Failed to save snapshot. Check the URL and try again.",
+ title: "Error",
+ });
+ },
+ onSuccess: (data) => {
+ notifications.show({
+ color: "green",
+ message: data.message,
+ title: "Snapshot saved",
+ });
+ void queryClient.invalidateQueries({ queryKey: [SNAPSHOTS_QUERY_KEY] });
+ options?.onSuccess?.();
+ },
+ });
+}
diff --git a/packages/client/src/objects/TabRoutes.tsx b/packages/client/src/objects/TabRoutes.tsx
index 467865f4..b82b177e 100644
--- a/packages/client/src/objects/TabRoutes.tsx
+++ b/packages/client/src/objects/TabRoutes.tsx
@@ -23,11 +23,14 @@ export const tabs: TabProps[] = [
id: 0,
name: "Graphs",
},
-
{
id: 1,
name: "Stats",
},
+ {
+ id: 2,
+ name: "Grafana",
+ },
];
export const routes: SolarCarRoutes[] = [
diff --git a/packages/client/tailwind.config.ts b/packages/client/tailwind.config.ts
index bc8c6660..6060281e 100644
--- a/packages/client/tailwind.config.ts
+++ b/packages/client/tailwind.config.ts
@@ -35,6 +35,7 @@ const config: Config = {
helios: "#B94A6C",
pink: "#9C0534",
gray: "#807D7D",
+ grafana: "#F05A28",
},
backgroundColor: {
light: "#D2D2D2",
diff --git a/packages/db/prisma/migrations/0_init/migration.sql b/packages/db/prisma/migrations/0_init/migration.sql
new file mode 100644
index 00000000..4e799cd3
--- /dev/null
+++ b/packages/db/prisma/migrations/0_init/migration.sql
@@ -0,0 +1,363 @@
+-- CreateSchema
+CREATE SCHEMA IF NOT EXISTS "public";
+
+-- CreateTable
+CREATE TABLE "driver" (
+ "rfid" TEXT NOT NULL,
+ "Name" TEXT NOT NULL,
+ "CreatedAt" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "UpdatedAt" TIMESTAMPTZ(6) NOT NULL,
+
+ CONSTRAINT "PK_5ec5aa7279436b862c308d63f5b" PRIMARY KEY ("rfid")
+);
+
+-- CreateTable
+CREATE TABLE "lap" (
+ "Id" UUID NOT NULL DEFAULT gen_random_uuid(),
+ "timestamp" TIMESTAMPTZ(6) NOT NULL,
+ "rfid" TEXT NOT NULL,
+ "LapTime" DOUBLE PRECISION NOT NULL,
+ "TotalPowerIn" DOUBLE PRECISION NOT NULL,
+ "TotalPowerOut" DOUBLE PRECISION NOT NULL,
+ "NetPowerOut" DOUBLE PRECISION NOT NULL,
+ "Distance" DOUBLE PRECISION NOT NULL,
+ "EnergyConsumed" DOUBLE PRECISION NOT NULL,
+ "AmpHours" DOUBLE PRECISION NOT NULL,
+ "AveragePackCurrent" DOUBLE PRECISION NOT NULL,
+ "BatterySecondsRemaining" DOUBLE PRECISION NOT NULL,
+ "AverageSpeed" DOUBLE PRECISION NOT NULL,
+ "AverageMotorWattage" DOUBLE PRECISION NOT NULL,
+
+ CONSTRAINT "PK_bde83ce36fb9632ffd544022604" PRIMARY KEY ("Id")
+);
+
+-- CreateTable
+CREATE TABLE "telemetry_packet" (
+ "timestamp" TIMESTAMPTZ(6) NOT NULL,
+ "rfid" TEXT NOT NULL,
+ "RaceName" TEXT,
+ "Title" TEXT,
+ "Acceleration" DOUBLE PRECISION,
+ "B3Heartbeat" BOOLEAN,
+ "BrakeLightSignalStatus" BOOLEAN,
+ "BrakeSwitchDigital" BOOLEAN,
+ "DaytimeRunningLightSignalStatus" BOOLEAN,
+ "ForwardDigital" BOOLEAN,
+ "HandbrakeSwitchDigital" BOOLEAN,
+ "HazardLightsInput" BOOLEAN,
+ "HeadlightsSwitchInput" BOOLEAN,
+ "HeadlightSignalStatus" BOOLEAN,
+ "HornSignalStatus" BOOLEAN,
+ "HornSwitchDigital" BOOLEAN,
+ "LapDigital" BOOLEAN,
+ "LeftSignalInput" BOOLEAN,
+ "LeftSignalStatus" BOOLEAN,
+ "MotorResetDigital" BOOLEAN,
+ "NeutralDigital" BOOLEAN,
+ "RaceModeDigital" BOOLEAN,
+ "RegenBraking" DOUBLE PRECISION,
+ "ReverseDigital" BOOLEAN,
+ "RightSignalInput" BOOLEAN,
+ "RightSignalStatus" BOOLEAN,
+ "AlwaysOnSignalStatus" BOOLEAN,
+ "AverageCellVoltage" DOUBLE PRECISION,
+ "AverageTemperature" DOUBLE PRECISION,
+ "BmuAlive" INTEGER,
+ "ChargeRelayEnabled" BOOLEAN,
+ "ChargerSafetyEnabled" BOOLEAN,
+ "DischargeRelayEnabled" BOOLEAN,
+ "FanSpeed" DOUBLE PRECISION,
+ "FanVoltage" DOUBLE PRECISION,
+ "HighCellVoltage" DOUBLE PRECISION,
+ "HighCellVoltageId" INTEGER,
+ "HighTemperature" DOUBLE PRECISION,
+ "HighThermistorId" INTEGER,
+ "InternalTemperature" DOUBLE PRECISION,
+ "IsChargingSignalStatus" BOOLEAN,
+ "IsReadySignalStatus" BOOLEAN,
+ "LowCellVoltage" DOUBLE PRECISION,
+ "LowCellVoltageId" INTEGER,
+ "LowTemperature" DOUBLE PRECISION,
+ "LowThermistorId" INTEGER,
+ "MalfunctionIndicatorActive" BOOLEAN,
+ "MaximumCellVoltage" DOUBLE PRECISION,
+ "MaximumPackVoltage" DOUBLE PRECISION,
+ "MinimumCellVoltage" DOUBLE PRECISION,
+ "MinimumPackVoltage" DOUBLE PRECISION,
+ "MultiPurposeInputSignalStatus" BOOLEAN,
+ "PackAmphours" DOUBLE PRECISION,
+ "PackCurrent" DOUBLE PRECISION,
+ "PackDepthOfDischarge" DOUBLE PRECISION,
+ "PackStateOfCharge" DOUBLE PRECISION,
+ "PackVoltage" DOUBLE PRECISION,
+ "PopulatedCells" INTEGER,
+ "RequestedFanSpeed" DOUBLE PRECISION,
+ "ArrayChargeCurrent" DOUBLE PRECISION,
+ "ArrayContactorClosed" BOOLEAN,
+ "ArrayContactorClosing" BOOLEAN,
+ "ArrayContactorError" BOOLEAN,
+ "ArrayHeartbeat" BOOLEAN,
+ "ArrayLineCurrent" DOUBLE PRECISION,
+ "ArrayPrechargerClosed" BOOLEAN,
+ "ArrayPrechargerClosing" BOOLEAN,
+ "ArrayPrechargerError" BOOLEAN,
+ "ChargeChargeCurrent" DOUBLE PRECISION,
+ "ChargeContactorClosed" BOOLEAN,
+ "ChargeContactorClosing" BOOLEAN,
+ "ChargeContactorError" BOOLEAN,
+ "ChargeHeartbeat" BOOLEAN,
+ "ChargeLineCurrent" DOUBLE PRECISION,
+ "ChargePrechargerClosed" BOOLEAN,
+ "ChargePrechargerClosing" BOOLEAN,
+ "ChargePrechargerError" BOOLEAN,
+ "CommonChargeCurrent" DOUBLE PRECISION,
+ "CommonContactorClosed" BOOLEAN,
+ "CommonContactorClosing" BOOLEAN,
+ "CommonContactorError" BOOLEAN,
+ "CommonContactorOpeningError" BOOLEAN,
+ "CommonHeartbeat" BOOLEAN,
+ "CommonLineCurrent" DOUBLE PRECISION,
+ "CommonPrechargerClosed" BOOLEAN,
+ "CommonPrechargerClosing" BOOLEAN,
+ "CommonPrechargerError" BOOLEAN,
+ "LvBpsError" BOOLEAN,
+ "LvChargeCurrent" DOUBLE PRECISION,
+ "LvContactorClosed" BOOLEAN,
+ "LvContactorClosing" BOOLEAN,
+ "LvContactorError" BOOLEAN,
+ "LvHeartbeat" BOOLEAN,
+ "LvLineCurrent" DOUBLE PRECISION,
+ "LvPrechargerClosed" BOOLEAN,
+ "LvPrechargerClosing" BOOLEAN,
+ "LvPrechargerError" BOOLEAN,
+ "MotorChargeCurrent" DOUBLE PRECISION,
+ "MotorContactorClosed" BOOLEAN,
+ "MotorContactorClosing" BOOLEAN,
+ "MotorContactorError" BOOLEAN,
+ "MotorHeartbeat" BOOLEAN,
+ "MotorLineCurrent" DOUBLE PRECISION,
+ "MotorPrechargerClosed" BOOLEAN,
+ "MotorPrechargerClosing" BOOLEAN,
+ "MotorPrechargerError" BOOLEAN,
+ "BusCurrentOut" DOUBLE PRECISION,
+ "KeyMotorVelocity" DOUBLE PRECISION,
+ "MotorCurrent" DOUBLE PRECISION,
+ "AbattDisable" BOOLEAN,
+ "ArrayContactorCommand" BOOLEAN,
+ "ArrayHeartbeatDeadTrip" BOOLEAN,
+ "ArrayHighCurrentTrip" BOOLEAN,
+ "ArrayHighCurrentWarning" BOOLEAN,
+ "AuxiliaryBatteryVoltage" DOUBLE PRECISION,
+ "ChargeContactorCommand" BOOLEAN,
+ "ChargeEnable" BOOLEAN,
+ "ChargeHeartbeatDeadTrip" BOOLEAN,
+ "ChargeHighCurrentTrip" BOOLEAN,
+ "ChargeHighCurrentWarning" BOOLEAN,
+ "ChargeSafety" BOOLEAN,
+ "ChargeShouldTrip" BOOLEAN,
+ "ChgFault" BOOLEAN,
+ "ChgLvEn" BOOLEAN,
+ "ChgOn" BOOLEAN,
+ "CommonContactorCommand" BOOLEAN,
+ "CommonHeartbeatDeadTrip" BOOLEAN,
+ "CommonHighCurrentTrip" BOOLEAN,
+ "CommonHighCurrentWarning" BOOLEAN,
+ "ContactorConnectedUnexpectedlyTrip" BOOLEAN,
+ "ContactorDisconnectedUnexpectedlyTrip" BOOLEAN,
+ "DcdcFault" BOOLEAN,
+ "DcdcOn" BOOLEAN,
+ "DischargeEnable" BOOLEAN,
+ "DischargeShouldTrip" BOOLEAN,
+ "En1" BOOLEAN,
+ "EsdEnabledTrip" BOOLEAN,
+ "ExternalShutdown" BOOLEAN,
+ "Heartbeat" BOOLEAN,
+ "HighCellVoltageTrip" BOOLEAN,
+ "HighCellVoltageWarning" BOOLEAN,
+ "HighTemperatureTrip" BOOLEAN,
+ "HighTemperatureWarning" BOOLEAN,
+ "Key" BOOLEAN,
+ "LowCellVoltageTrip" BOOLEAN,
+ "LowCellVoltageWarning" BOOLEAN,
+ "LowTemperatureTrip" BOOLEAN,
+ "LowTemperatureWarning" BOOLEAN,
+ "LvContactorCommand" BOOLEAN,
+ "LvHeartbeatDeadTrip" BOOLEAN,
+ "LvHighCurrentTrip" BOOLEAN,
+ "LvHighCurrentWarning" BOOLEAN,
+ "MainPowerSwitch" BOOLEAN,
+ "MotorContactorCommand" BOOLEAN,
+ "MotorHeartbeatDeadTrip" BOOLEAN,
+ "MotorHighCurrentTrip" BOOLEAN,
+ "MotorHighCurrentWarning" BOOLEAN,
+ "MpsDisabledTrip" BOOLEAN,
+ "OrionCanReceivedRecently" BOOLEAN,
+ "OrionMessageTimeoutTrip" BOOLEAN,
+ "ProtectionTrip" BOOLEAN,
+ "StartupState" DOUBLE PRECISION,
+ "StrobeBmsLight" BOOLEAN,
+ "SystemState" DOUBLE PRECISION,
+ "ThreeAOc" BOOLEAN,
+ "Motor0ActiveMotor" DOUBLE PRECISION,
+ "Motor0BemfD" DOUBLE PRECISION,
+ "Motor0BemfQ" DOUBLE PRECISION,
+ "Motor0BusCurrent" DOUBLE PRECISION,
+ "Motor0BusVoltage" DOUBLE PRECISION,
+ "Motor0DcBusAh" DOUBLE PRECISION,
+ "Motor0DspBoardTemperature" DOUBLE PRECISION,
+ "Motor0ErrorFlags" DOUBLE PRECISION,
+ "Motor0HeatsinkTemperature" DOUBLE PRECISION,
+ "Motor0Id" DOUBLE PRECISION,
+ "Motor0Iq" DOUBLE PRECISION,
+ "Motor0LimitFlags" DOUBLE PRECISION,
+ "Motor0MotorTemperature" DOUBLE PRECISION,
+ "Motor0MotorVelocity" DOUBLE PRECISION,
+ "Motor0Odometer" DOUBLE PRECISION,
+ "Motor0PhaseCurrentB" DOUBLE PRECISION,
+ "Motor0PhaseCurrentC" DOUBLE PRECISION,
+ "Motor0RxErrorCount" DOUBLE PRECISION,
+ "Motor0SerialNumber" DOUBLE PRECISION,
+ "Motor0SlipSpeed" DOUBLE PRECISION,
+ "Motor0Supply15v" DOUBLE PRECISION,
+ "Motor0Supply1v9" DOUBLE PRECISION,
+ "Motor0Supply3v3" DOUBLE PRECISION,
+ "Motor0TritiumId" DOUBLE PRECISION,
+ "Motor0TxErrorCount" DOUBLE PRECISION,
+ "Motor0Vd" DOUBLE PRECISION,
+ "Motor0VehicleVelocity" DOUBLE PRECISION,
+ "Motor0Vq" DOUBLE PRECISION,
+ "Motor1ActiveMotor" DOUBLE PRECISION,
+ "Motor1BemfD" DOUBLE PRECISION,
+ "Motor1BemfQ" DOUBLE PRECISION,
+ "Motor1BusCurrent" DOUBLE PRECISION,
+ "Motor1BusVoltage" DOUBLE PRECISION,
+ "Motor1DcBusAh" DOUBLE PRECISION,
+ "Motor1DspBoardTemperature" DOUBLE PRECISION,
+ "Motor1ErrorFlags" DOUBLE PRECISION,
+ "Motor1HeatsinkTemperature" DOUBLE PRECISION,
+ "Motor1Id" DOUBLE PRECISION,
+ "Motor1Iq" DOUBLE PRECISION,
+ "Motor1LimitFlags" DOUBLE PRECISION,
+ "Motor1MotorTemperature" DOUBLE PRECISION,
+ "Motor1MotorVelocity" DOUBLE PRECISION,
+ "Motor1Odometer" DOUBLE PRECISION,
+ "Motor1PhaseCurrentB" DOUBLE PRECISION,
+ "Motor1PhaseCurrentC" DOUBLE PRECISION,
+ "Motor1RxErrorCount" DOUBLE PRECISION,
+ "Motor1SerialNumber" DOUBLE PRECISION,
+ "Motor1SlipSpeed" DOUBLE PRECISION,
+ "Motor1Supply15v" DOUBLE PRECISION,
+ "Motor1Supply1v9" DOUBLE PRECISION,
+ "Motor1Supply3v3" DOUBLE PRECISION,
+ "Motor1TritiumId" DOUBLE PRECISION,
+ "Motor1TxErrorCount" DOUBLE PRECISION,
+ "Motor1Vd" DOUBLE PRECISION,
+ "Motor1VehicleVelocity" DOUBLE PRECISION,
+ "Motor1Vq" DOUBLE PRECISION,
+ "Mppt0Ch0ArrayCurrent" DOUBLE PRECISION,
+ "Mppt0Ch0ArrayVoltage" DOUBLE PRECISION,
+ "Mppt0Ch0BatteryVoltage" DOUBLE PRECISION,
+ "Mppt0Ch0UnitTemperature" DOUBLE PRECISION,
+ "Mppt0Ch1ArrayCurrent" DOUBLE PRECISION,
+ "Mppt0Ch1ArrayVoltage" DOUBLE PRECISION,
+ "Mppt0Ch1BatteryVoltage" DOUBLE PRECISION,
+ "Mppt0Ch1UnitTemperature" DOUBLE PRECISION,
+ "Mppt1Ch0ArrayCurrent" DOUBLE PRECISION,
+ "Mppt1Ch0ArrayVoltage" DOUBLE PRECISION,
+ "Mppt1Ch0BatteryVoltage" DOUBLE PRECISION,
+ "Mppt1Ch0UnitTemperature" DOUBLE PRECISION,
+ "Mppt1Ch1ArrayCurrent" DOUBLE PRECISION,
+ "Mppt1Ch1ArrayVoltage" DOUBLE PRECISION,
+ "Mppt1Ch1BatteryVoltage" DOUBLE PRECISION,
+ "Mppt1Ch1UnitTemperature" DOUBLE PRECISION,
+ "Mppt2Ch0ArrayCurrent" DOUBLE PRECISION,
+ "Mppt2Ch0ArrayVoltage" DOUBLE PRECISION,
+ "Mppt2Ch0BatteryVoltage" DOUBLE PRECISION,
+ "Mppt2Ch0UnitTemperature" DOUBLE PRECISION,
+ "Mppt2Ch1ArrayCurrent" DOUBLE PRECISION,
+ "Mppt2Ch1ArrayVoltage" DOUBLE PRECISION,
+ "Mppt2Ch1BatteryVoltage" DOUBLE PRECISION,
+ "Mppt2Ch1UnitTemperature" DOUBLE PRECISION,
+ "Mppt3Ch0ArrayCurrent" DOUBLE PRECISION,
+ "Mppt3Ch0ArrayVoltage" DOUBLE PRECISION,
+ "Mppt3Ch0BatteryVoltage" DOUBLE PRECISION,
+ "Mppt3Ch0UnitTemperature" DOUBLE PRECISION,
+ "Mppt3Ch1ArrayCurrent" DOUBLE PRECISION,
+ "Mppt3Ch1ArrayVoltage" DOUBLE PRECISION,
+ "Mppt3Ch1BatteryVoltage" DOUBLE PRECISION,
+ "Mppt3Ch1UnitTemperature" DOUBLE PRECISION,
+ "ProximitySensor1" DOUBLE PRECISION,
+ "ProximitySensor2" DOUBLE PRECISION,
+ "ProximitySensor3" DOUBLE PRECISION,
+ "ProximitySensor4" DOUBLE PRECISION,
+ "GpsAdditionalFlags" DOUBLE PRECISION,
+ "GpsDay" DOUBLE PRECISION,
+ "GpsFixStatusFlags" DOUBLE PRECISION,
+ "GpsHour" DOUBLE PRECISION,
+ "GpsLatitude" DOUBLE PRECISION,
+ "GpsLongitude" DOUBLE PRECISION,
+ "GpsMinute" DOUBLE PRECISION,
+ "GpsMonth" DOUBLE PRECISION,
+ "GpsSecond" DOUBLE PRECISION,
+ "GpsValidityFlags" DOUBLE PRECISION,
+ "GpsYear" DOUBLE PRECISION,
+ "MpuAccelerationX" DOUBLE PRECISION,
+ "MpuAccelerationY" DOUBLE PRECISION,
+ "MpuAccelerationZ" DOUBLE PRECISION,
+ "MpuRotationX" DOUBLE PRECISION,
+ "MpuRotationY" DOUBLE PRECISION,
+ "MpuRotationZ" DOUBLE PRECISION,
+ "MpuTemperature" DOUBLE PRECISION,
+ "Input12V" DOUBLE PRECISION,
+ "AlwaysOnSupplyFault" BOOLEAN,
+ "ArrayBPSError" BOOLEAN,
+ "CanOc12VWarning" BOOLEAN,
+ "CanbusCommunicationFault" BOOLEAN,
+ "CclReducedDueToAlternateCurrentLimit" BOOLEAN,
+ "CclReducedDueToChargerLatch" BOOLEAN,
+ "CclReducedDueToHighCellResistance" BOOLEAN,
+ "CclReducedDueToHighCellVoltage" BOOLEAN,
+ "CclReducedDueToHighPackVoltage" BOOLEAN,
+ "CclReducedDueToHighSoc" BOOLEAN,
+ "CclReducedDueToTemperature" BOOLEAN,
+ "ChargeBPSError" BOOLEAN,
+ "ChargeLimitEnforcementFault" BOOLEAN,
+ "ChargerSafetyRelayFault" BOOLEAN,
+ "CurrentSensorFault" BOOLEAN,
+ "DclAndCclReducedDueToCommunicationFailsafe" BOOLEAN,
+ "DclAndCclReducedDueToVoltageFailsafe" BOOLEAN,
+ "DclReducedDueToHighCellResistance" BOOLEAN,
+ "DclReducedDueToLowCellVoltage" BOOLEAN,
+ "DclReducedDueToLowPackVoltage" BOOLEAN,
+ "DclReducedDueToLowSoc" BOOLEAN,
+ "DclReducedDueToTemperature" BOOLEAN,
+ "DischargeLimitEnforcementFault" BOOLEAN,
+ "FanMonitorFault" BOOLEAN,
+ "HighVoltageIsolationFault" BOOLEAN,
+ "InternalCommunicationFault" BOOLEAN,
+ "InternalConversionFault" BOOLEAN,
+ "InternalLogicFault" BOOLEAN,
+ "InternalMemoryFault" BOOLEAN,
+ "InternalThermistorFault" BOOLEAN,
+ "LowCellVoltageFault" BOOLEAN,
+ "MotorBPSError" BOOLEAN,
+ "OpenWiringFault" BOOLEAN,
+ "PackVoltageSensorFault" BOOLEAN,
+ "PowerSupply12VFault" BOOLEAN,
+ "ThermistorFault" BOOLEAN,
+ "VoltageRedundancyFault" BOOLEAN,
+ "WeakCellFault" BOOLEAN,
+ "WeakPackFault" BOOLEAN,
+
+ CONSTRAINT "PK_9a0efd28da0c9c4ddb0a002345c" PRIMARY KEY ("timestamp","rfid")
+);
+
+-- CreateIndex
+CREATE INDEX "IDX_847b73ee1f3faf017fe4e9a6cb" ON "lap"("rfid", "timestamp");
+
+-- CreateIndex
+CREATE INDEX "IDX_667eee2eb4f507ee0f5f9552a6" ON "telemetry_packet"("RaceName", "timestamp");
+
+-- CreateIndex
+CREATE INDEX "IDX_9a0efd28da0c9c4ddb0a002345" ON "telemetry_packet"("rfid", "timestamp");
+
diff --git a/packages/db/prisma/migrations/20260526021109_add_grafana_snapshot/migration.sql b/packages/db/prisma/migrations/20260526021109_add_grafana_snapshot/migration.sql
new file mode 100644
index 00000000..470171be
--- /dev/null
+++ b/packages/db/prisma/migrations/20260526021109_add_grafana_snapshot/migration.sql
@@ -0,0 +1,12 @@
+-- CreateTable
+CREATE TABLE "grafana_snapshot" (
+ "id" UUID NOT NULL DEFAULT gen_random_uuid(),
+ "url" TEXT NOT NULL,
+ "label" TEXT NOT NULL,
+ "created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "grafana_snapshot_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE UNIQUE INDEX "grafana_snapshot_url_key" ON "grafana_snapshot"("url");
diff --git a/packages/db/prisma/migrations/20260613000000_add_snapshot_time_range/migration.sql b/packages/db/prisma/migrations/20260613000000_add_snapshot_time_range/migration.sql
new file mode 100644
index 00000000..7836f184
--- /dev/null
+++ b/packages/db/prisma/migrations/20260613000000_add_snapshot_time_range/migration.sql
@@ -0,0 +1,4 @@
+-- AlterTable
+ALTER TABLE "grafana_snapshot"
+ ADD COLUMN "snapshot_from" TIMESTAMPTZ(6) NOT NULL DEFAULT '-infinity',
+ ADD COLUMN "snapshot_to" TIMESTAMPTZ(6) NOT NULL DEFAULT 'infinity';
diff --git a/packages/db/prisma/migrations/migration_lock.toml b/packages/db/prisma/migrations/migration_lock.toml
new file mode 100644
index 00000000..044d57cd
--- /dev/null
+++ b/packages/db/prisma/migrations/migration_lock.toml
@@ -0,0 +1,3 @@
+# Please do not edit this file manually
+# It should be added in your version-control system (e.g., Git)
+provider = "postgresql"
diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma
index 1fb1e1a0..890849ba 100644
--- a/packages/db/prisma/schema.prisma
+++ b/packages/db/prisma/schema.prisma
@@ -37,6 +37,15 @@ model lap {
@@index([rfid, timestamp], map: "IDX_847b73ee1f3faf017fe4e9a6cb")
}
+model grafana_snapshot {
+ id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
+ url String @unique
+ label String
+ snapshot_from DateTime @db.Timestamptz(6)
+ snapshot_to DateTime @db.Timestamptz(6)
+ created_at DateTime @default(now()) @db.Timestamptz(6)
+}
+
model telemetry_packet {
timestamp DateTime @db.Timestamptz(6)
rfid String
diff --git a/packages/db/src/services/DatabaseService.ts b/packages/db/src/services/DatabaseService.ts
index ce81b266..dc27f4dd 100644
--- a/packages/db/src/services/DatabaseService.ts
+++ b/packages/db/src/services/DatabaseService.ts
@@ -221,6 +221,27 @@ export class DatabaseService {
});
}
+ public async getRecentSnapshot() {
+ this.assertConnected();
+
+ return prisma.grafana_snapshot.findFirst({
+ orderBy: { created_at: "desc" },
+ });
+ }
+
+ public async createSnapshot(
+ url: string,
+ label: string,
+ snapshot_from: Date,
+ snapshot_to: Date,
+ ) {
+ this.assertConnected();
+
+ return prisma.grafana_snapshot.create({
+ data: { url, label, snapshot_from, snapshot_to },
+ });
+ }
+
private assertConnected() {
if (!this.isConnected) {
throw new Error("Database not connected");
diff --git a/packages/server/.env.example b/packages/server/.env.example
index 66854096..58d0247b 100644
--- a/packages/server/.env.example
+++ b/packages/server/.env.example
@@ -24,6 +24,7 @@ MQTT_PASSWORD=changeme
########################
DRIVER_NAME_UPDATE_PASSWORD=changeme
FINISH_LINE_UPDATE_PASSWORD=changeme
+SNAPSHOT_PASSWORD=changeme
########################
# Machine Learning
diff --git a/packages/server/src/controllers/routeControllers/snapshot.controller.ts b/packages/server/src/controllers/routeControllers/snapshot.controller.ts
new file mode 100644
index 00000000..6bd71f37
--- /dev/null
+++ b/packages/server/src/controllers/routeControllers/snapshot.controller.ts
@@ -0,0 +1,138 @@
+import type { BackendController } from "../BackendController/BackendController";
+
+import { type Request, type Response } from "express";
+
+import { createApplicationLogger } from "@/utils/logger";
+import { validateSnapshotPassword } from "@/utils/validatePassword";
+
+import type {
+ CreateSnapshotRequestDTO,
+ CreateSnapshotResponseDTO,
+ IGrafanaSnapshot,
+ IGrafanaSnapshotRow,
+ RecentSnapshotResponseDTO,
+} from "@shared/helios-types";
+
+/**
+ * Maps a Prisma grafana_snapshot row to its serializable DTO shape,
+ * converting the Date columns to ISO strings.
+ */
+function serializeSnapshot(snapshot: IGrafanaSnapshotRow): IGrafanaSnapshot {
+ return {
+ ...snapshot,
+ snapshot_from: snapshot.snapshot_from.toISOString(),
+ snapshot_to: snapshot.snapshot_to.toISOString(),
+ created_at: snapshot.created_at.toISOString(),
+ };
+}
+
+export const getRecentSnapshot = async (
+ request: Request,
+ response: Response,
+) => {
+ const backendController = request.app.locals
+ .backendController as BackendController;
+
+ const logger = createApplicationLogger(
+ "snapshot.controller.ts",
+ request,
+ response,
+ );
+
+ logger.info(`ENTRY - ${request.method} ${request.url}`);
+ const snapshot =
+ await backendController.databaseService.getRecentSnapshot();
+
+ const data: RecentSnapshotResponseDTO = {
+ data: snapshot ? serializeSnapshot(snapshot) : null,
+ message: "OK",
+ uptime: process.uptime() + " seconds",
+ };
+ logger.info(`EXIT - ${request.method} ${request.url} - 200`);
+
+ return response.status(200).json(data);
+};
+
+export const createSnapshot = async (
+ request: Request, unknown, CreateSnapshotRequestDTO>,
+ response: Response,
+) => {
+ const { url, label, password, snapshot_from, snapshot_to } = request.body;
+
+ const logger = createApplicationLogger(
+ "snapshot.controller.ts",
+ request,
+ response,
+ );
+
+ logger.info(`ENTRY - ${request.method} ${request.url}`);
+
+ if (!url || !label || !password || !snapshot_from || !snapshot_to) {
+ logger.warn("Missing required fields");
+ return response
+ .status(400)
+ .json({ error: "url, label, snapshot_from, snapshot_to, and password are required" });
+ }
+
+ const fromDate = new Date(snapshot_from);
+ const toDate = new Date(snapshot_to);
+ if (isNaN(fromDate.getTime()) || isNaN(toDate.getTime())) {
+ return response
+ .status(400)
+ .json({ error: "snapshot_from and snapshot_to must be valid ISO date strings" });
+ }
+
+ if (!validateSnapshotPassword(password)) {
+ logger.warn("Invalid password attempt for snapshot creation");
+ return response.status(401).json({ error: "Invalid password" });
+ }
+
+ let parsed: URL;
+ try {
+ parsed = new URL(url);
+ } catch {
+ return response.status(400).json({ error: "Invalid URL format" });
+ }
+ if (parsed.protocol !== "https:") {
+ return response.status(400).json({ error: "URL must use HTTPS" });
+ }
+
+ if (parsed.hostname !== "snapshots.raintank.io") {
+ return response
+ .status(400)
+ .json({ error: "URL must be a Grafana public snapshot (snapshots.raintank.io)" });
+ }
+
+ const backendController = request.app.locals
+ .backendController as BackendController;
+
+ let snapshot: Awaited<
+ ReturnType
+ >;
+ try {
+ snapshot = await backendController.databaseService.createSnapshot(
+ url,
+ label,
+ fromDate,
+ toDate,
+ );
+ } catch (error) {
+ // Prisma P2002 = unique constraint violation (url is @unique).
+ if ((error as { code?: string }).code === "P2002") {
+ logger.warn(`Snapshot URL already exists: ${url}`);
+ return response
+ .status(409)
+ .json({ error: "A snapshot with this URL already exists" });
+ }
+ throw error;
+ }
+
+ const data: CreateSnapshotResponseDTO = {
+ data: serializeSnapshot(snapshot),
+ message: "Snapshot created successfully",
+ uptime: process.uptime() + " seconds",
+ };
+ logger.info(`EXIT - ${request.method} ${request.url} - 201`);
+
+ return response.status(201).json(data);
+};
diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts
index 5e230790..3a81319e 100644
--- a/packages/server/src/index.ts
+++ b/packages/server/src/index.ts
@@ -1,5 +1,6 @@
import { BackendController } from "./controllers/BackendController/BackendController";
import lapRouter from "./routes/lap.route";
+import snapshotRouter from "./routes/snapshot.route";
import axios from "axios";
import axiosRetry from "axios-retry";
@@ -48,6 +49,7 @@ app.use("/", playbackRouter);
app.use("/", lapRouter);
app.use("/", driverRouter);
app.use("/", machineLearningRouter);
+app.use("/", snapshotRouter);
app.use(errorHandler);
diff --git a/packages/server/src/routes/snapshot.route.ts b/packages/server/src/routes/snapshot.route.ts
new file mode 100644
index 00000000..b232385a
--- /dev/null
+++ b/packages/server/src/routes/snapshot.route.ts
@@ -0,0 +1,15 @@
+import express from "express";
+
+import * as controllers from "@/controllers/routeControllers/snapshot.controller";
+
+import { asyncHandler } from "@/middleware/errorHandler";
+
+const snapshotRouter = express.Router();
+
+snapshotRouter.get(
+ "/snapshots/recent",
+ asyncHandler(controllers.getRecentSnapshot),
+);
+snapshotRouter.post("/snapshots", asyncHandler(controllers.createSnapshot));
+
+export default snapshotRouter;
diff --git a/packages/server/src/utils/validatePassword.ts b/packages/server/src/utils/validatePassword.ts
index 0c63c39f..556d4ab0 100644
--- a/packages/server/src/utils/validatePassword.ts
+++ b/packages/server/src/utils/validatePassword.ts
@@ -30,3 +30,14 @@ if (!validPassword) {
export function validateDriverUpdatePassword(password: string): boolean {
return password === validPassword;
}
+
+/**
+ * Validates the snapshot management password against SNAPSHOT_PASSWORD env var.
+ */
+export function validateSnapshotPassword(password: string): boolean {
+ const snapshotPassword = process.env.SNAPSHOT_PASSWORD;
+ if (!snapshotPassword) {
+ throw new Error("SNAPSHOT_PASSWORD environment variable is not configured");
+ }
+ return password === snapshotPassword;
+}
diff --git a/packages/shared/src/dto/index.ts b/packages/shared/src/dto/index.ts
index bde43871..8a8f9491 100644
--- a/packages/shared/src/dto/index.ts
+++ b/packages/shared/src/dto/index.ts
@@ -1,3 +1,4 @@
export * from "./driver";
export * from "./lap";
export * from "./playback";
+export * from "./snapshot";
diff --git a/packages/shared/src/dto/snapshot.ts b/packages/shared/src/dto/snapshot.ts
new file mode 100644
index 00000000..b315fe92
--- /dev/null
+++ b/packages/shared/src/dto/snapshot.ts
@@ -0,0 +1,42 @@
+export interface IGrafanaSnapshot {
+ id: string;
+ url: string;
+ label: string;
+ snapshot_from: string;
+ snapshot_to: string;
+ created_at: string;
+}
+
+/**
+ * The raw DB row shape, before serialization: the date columns are still
+ * `Date` objects rather than the ISO strings exposed on `IGrafanaSnapshot`.
+ */
+export interface IGrafanaSnapshotRow
+ extends Omit<
+ IGrafanaSnapshot,
+ "created_at" | "snapshot_from" | "snapshot_to"
+ > {
+ snapshot_from: Date;
+ snapshot_to: Date;
+ created_at: Date;
+}
+
+export interface RecentSnapshotResponseDTO {
+ data: IGrafanaSnapshot | null;
+ message: string;
+ uptime: string;
+}
+
+export interface CreateSnapshotRequestDTO {
+ url: string;
+ label: string;
+ snapshot_from: string;
+ snapshot_to: string;
+ password: string;
+}
+
+export interface CreateSnapshotResponseDTO {
+ data: IGrafanaSnapshot;
+ message: string;
+ uptime: string;
+}