From 590a54f18433db9075cc0afab942e64d07469711 Mon Sep 17 00:00:00 2001 From: Hunter275 Date: Sat, 11 Jul 2026 13:46:08 -0400 Subject: [PATCH 1/3] add the link in the sidebar and fix pnpm commands --- .../PageComponents/Telemetry/Battery.tsx | 87 ++++++ apps/web/src/components/Sidebar.tsx | 18 +- apps/web/src/core/stores/deviceStore/types.ts | 8 +- apps/web/src/pages/Telemetry/index.tsx | 266 ++++++++++++++++++ package.json | 4 +- 5 files changed, 377 insertions(+), 6 deletions(-) create mode 100644 apps/web/src/components/PageComponents/Telemetry/Battery.tsx create mode 100644 apps/web/src/pages/Telemetry/index.tsx diff --git a/apps/web/src/components/PageComponents/Telemetry/Battery.tsx b/apps/web/src/components/PageComponents/Telemetry/Battery.tsx new file mode 100644 index 000000000..1b0182810 --- /dev/null +++ b/apps/web/src/components/PageComponents/Telemetry/Battery.tsx @@ -0,0 +1,87 @@ +import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig"; +import { + type BluetoothValidation, + BluetoothValidationSchema, +} from "@app/validation/config/bluetooth.ts"; +import { + DynamicForm, + type DynamicFormFormInit, +} from "@components/Form/DynamicForm.tsx"; +import { useDevice } from "@core/stores"; +import { Protobuf } from "@meshtastic/sdk"; +import { useConfigEditor, useSignal } from "@meshtastic/sdk-react"; +import { useTranslation } from "react-i18next"; + +interface BluetoothConfigProps { + onFormInit: DynamicFormFormInit; +} + +const EMPTY_RADIO_SIGNAL = { + value: {} as { bluetooth?: Protobuf.Config.Config_BluetoothConfig }, + peek: () => ({}) as { bluetooth?: Protobuf.Config.Config_BluetoothConfig }, + subscribe: () => () => {}, +} as const; + +export const Bluetooth = ({ onFormInit }: BluetoothConfigProps) => { + useWaitForConfig({ configCase: "bluetooth" }); + + const { config, getEffectiveConfig } = useDevice(); + const editor = useConfigEditor(); + const radio = useSignal(editor?.radio ?? EMPTY_RADIO_SIGNAL); + const effective = + radio.bluetooth ?? + (getEffectiveConfig("bluetooth") as + | Protobuf.Config.Config_BluetoothConfig + | undefined); + + const { t } = useTranslation("config"); + + const onSubmit = (data: BluetoothValidation) => { + if (!editor) return; + editor.setRadioSection( + "bluetooth", + data as unknown as Protobuf.Config.Config_BluetoothConfig, + ); + }; + + return ( + + onSubmit={onSubmit} + onFormInit={onFormInit} + validationSchema={BluetoothValidationSchema} + defaultValues={config.bluetooth} + values={effective} + fieldGroups={[ + { + label: t("bluetooth.bluetoothConfig.label"), + description: t("bluetooth.bluetoothConfig.description"), + notes: t("bluetooth.note"), + fields: [ + { + type: "toggle", + name: "enabled", + label: t("bluetooth.enabled.label"), + description: t("bluetooth.enabled.description"), + }, + { + type: "select", + name: "mode", + label: t("bluetooth.pairingMode.label"), + description: t("bluetooth.pairingMode.description"), + properties: { + enumValue: Protobuf.Config.Config_BluetoothConfig_PairingMode, + formatEnumName: true, + }, + }, + { + type: "number", + name: "fixedPin", + label: t("bluetooth.pin.label"), + description: t("bluetooth.pin.description"), + }, + ], + }, + ]} + /> + ); +}; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index d7f3d6035..4692170e6 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -25,6 +25,7 @@ import { MessageSquareIcon, SettingsIcon, UsersIcon, + ChartNoAxesCombinedIcon, } from "lucide-react"; import type React from "react"; import { useEffect, useState, useTransition } from "react"; @@ -121,17 +122,26 @@ export const Sidebar = ({ children }: SidebarProps) => { page: "messages", count: numUnread ? numUnread : undefined, }, - { name: t("navigation.map"), icon: MapIcon, page: "map" }, { - name: t("navigation.settings"), - icon: SettingsIcon, - page: "settings", + name: t("navigation.map"), + icon: MapIcon, + page: "map", }, { name: `${t("navigation.nodes")} (${displayedNodeCount})`, icon: UsersIcon, page: "nodes", }, + { + name: "Telemetry", + icon: ChartNoAxesCombinedIcon, + page: "telemetry", + }, + { + name: t("navigation.settings"), + icon: SettingsIcon, + page: "settings", + }, ]; return ( diff --git a/apps/web/src/core/stores/deviceStore/types.ts b/apps/web/src/core/stores/deviceStore/types.ts index d965b0e8a..bd96d2cd0 100644 --- a/apps/web/src/core/stores/deviceStore/types.ts +++ b/apps/web/src/core/stores/deviceStore/types.ts @@ -31,7 +31,13 @@ interface Dialogs { type DialogVariant = keyof Dialogs; -type Page = "messages" | "map" | "settings" | "channels" | "nodes"; +type Page = + | "messages" + | "map" + | "settings" + | "channels" + | "nodes" + | "telemetry"; export type ConnectionId = number; export type ConnectionType = "http" | "bluetooth" | "serial"; diff --git a/apps/web/src/pages/Telemetry/index.tsx b/apps/web/src/pages/Telemetry/index.tsx new file mode 100644 index 000000000..b99cb6d73 --- /dev/null +++ b/apps/web/src/pages/Telemetry/index.tsx @@ -0,0 +1,266 @@ +import { deviceRoute, moduleRoute, radioRoute } from "@app/routes"; +import { PageLayout } from "@components/PageLayout.tsx"; +import { Sidebar } from "@components/Sidebar.tsx"; +import { SidebarButton } from "@components/UI/Sidebar/SidebarButton.tsx"; +import { SidebarSection } from "@components/UI/Sidebar/SidebarSection.tsx"; +import { useToast } from "@core/hooks/useToast.ts"; +import { cn } from "@core/utils/cn.ts"; +import { useConfigEditor, useSignal } from "@meshtastic/sdk-react"; +import { DeviceConfig } from "@pages/Settings/DeviceConfig.tsx"; +import { ModuleConfig } from "@pages/Settings/ModuleConfig.tsx"; +import { useNavigate, useRouterState } from "@tanstack/react-router"; +import { + LayersIcon, + RadioTowerIcon, + RefreshCwIcon, + RouterIcon, + SaveIcon, + SaveOff, +} from "lucide-react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { FieldValues, UseFormReturn } from "react-hook-form"; +import { useTranslation } from "react-i18next"; +import { RadioConfig } from "./RadioConfig.tsx"; + +const EMPTY_DIRTY_STRING_SIGNAL = { + value: [] as readonly string[], + peek: () => [] as readonly string[], + subscribe: () => () => {}, +} as const; +const EMPTY_DIRTY_NUMBER_SIGNAL = { + value: [] as readonly number[], + peek: () => [] as readonly number[], + subscribe: () => () => {}, +} as const; + +const TelemetryPage = () => { + const editor = useConfigEditor(); + const editorIsDirty = useSignal( + editor?.isDirty ?? { + value: false, + peek: () => false, + subscribe: () => () => {}, + }, + ); + const dirtyRadio = useSignal( + editor?.dirtyRadioSections ?? EMPTY_DIRTY_STRING_SIGNAL, + ); + const dirtyModule = useSignal( + editor?.dirtyModuleSections ?? EMPTY_DIRTY_STRING_SIGNAL, + ); + const dirtyChannels = useSignal( + editor?.dirtyChannels ?? EMPTY_DIRTY_NUMBER_SIGNAL, + ); + + const [isSaving, setIsSaving] = useState(false); + const [rhfState, setRhfState] = useState({ isDirty: false, isValid: true }); + const unsubRef = useRef<(() => void) | null>(null); + const [formMethods, setFormMethods] = useState(null); + const { toast } = useToast(); + const navigate = useNavigate(); + const routerState = useRouterState(); + const { t } = useTranslation("config"); + + const configChangeCount = dirtyRadio.length; + const moduleConfigChangeCount = dirtyModule.length; + const channelChangeCount = dirtyChannels.length; + + const sections = useMemo( + () => [ + { + key: "radio", + route: radioRoute, + label: t("navigation.radioConfig"), + icon: RadioTowerIcon, + changeCount: configChangeCount, + component: RadioConfig, + }, + { + key: "device", + route: deviceRoute, + label: t("navigation.deviceConfig"), + icon: RouterIcon, + changeCount: moduleConfigChangeCount, + component: DeviceConfig, + }, + { + key: "module", + route: moduleRoute, + label: t("navigation.moduleConfig"), + icon: LayersIcon, + changeCount: channelChangeCount, + component: ModuleConfig, + }, + ], + [t, configChangeCount, moduleConfigChangeCount, channelChangeCount], + ); + + const activeSection = + sections.find((section) => + routerState.location.pathname.includes(`/settings/${section.key}`), + ) ?? sections[0]; + + const onFormInit = useCallback( + (methods: UseFormReturn) => { + setFormMethods(methods as UseFormReturn); + + setRhfState({ + // Assume defailt on init, changes will be caught by subscription + isDirty: false, + isValid: true, + }); + + // Unsubscribe from previous subscriptions & subscribe to form changes + unsubRef.current?.(); + unsubRef.current = methods.subscribe({ + formState: { isDirty: true, isValid: true }, + callback: ({ isValid, isDirty }) => { + setRhfState({ + isDirty: isDirty ?? false, + isValid: isValid ?? true, + }); + }, + }); + }, + [], + ); + + useEffect(() => { + return () => unsubRef.current?.(); + }, []); + + const handleSave = useCallback(async () => { + if (!editor) return; + setIsSaving(true); + + try { + const result = await editor.commit(); + if (result.status === "error") { + throw result.error; + } + toast({ + title: t("toast.saveAllSuccess.title"), + description: t("toast.saveAllSuccess.description"), + }); + + if (formMethods) { + formMethods.reset(formMethods.getValues(), { + keepDirty: false, + keepErrors: false, + keepTouched: false, + keepValues: true, + }); + formMethods.trigger(); + } + } catch { + toast({ + title: t("toast.configSaveError.title"), + description: t("toast.configSaveError.description"), + }); + } finally { + setIsSaving(false); + } + }, [toast, t, formMethods, editor]); + + const handleReset = useCallback(() => { + if (formMethods) { + formMethods.reset(); + } + editor?.reset(); + }, [formMethods, editor]); + + const leftSidebar = useMemo( + () => ( + + + {sections.map((section) => ( + navigate({ to: section.route.to })} + Icon={section.icon} + isDirty={section.changeCount > 0} + count={section.changeCount} + /> + ))} + + + ), + [sections, activeSection?.key, navigate, t], + ); + + const hasDrafts = editorIsDirty; + const hasPending = hasDrafts || rhfState.isDirty; + const buttonOpacity = hasPending ? "opacity-100" : "opacity-0"; + const saveDisabled = isSaving || !rhfState.isValid || !hasPending; + + const actions = useMemo( + () => [ + { + key: "unsavedChanges", + label: t("common:formValidation.unsavedChanges"), + onClick: () => {}, + className: cn([ + "bg-blue-500 text-slate-900 hover:bg-initial", + "transition-colors duration-200", + buttonOpacity, + "transition-opacity", + ]), + }, + { + key: "reset", + icon: RefreshCwIcon, + label: t("common:button.reset"), + onClick: handleReset, + className: cn([ + buttonOpacity, + "transition-opacity hover:bg-slate-200 disabled:hover:bg-white", + "hover:dark:bg-slate-300 hover:dark:text-black cursor-pointer", + ]), + }, + { + key: "save", + icon: !hasPending ? SaveOff : SaveIcon, + isLoading: isSaving, + disabled: saveDisabled, + iconClasses: + !rhfState.isValid && hasPending + ? "text-red-400 cursor-not-allowed" + : "cursor-pointer", + className: cn([ + "transition-opacity hover:bg-slate-200 disabled:hover:bg-white", + "hover:dark:bg-slate-300 hover:dark:text-black", + "disabled:hover:cursor-not-allowed cursor-pointer", + ]), + onClick: handleSave, + label: t("common:button.save"), + }, + ], + [ + isSaving, + hasPending, + rhfState.isValid, + saveDisabled, + buttonOpacity, + handleReset, + handleSave, + t, + ], + ); + + const ActiveComponent = activeSection?.component; + + return ( + + {ActiveComponent && } + + ); +}; + +export default TelemetryPage; diff --git a/package.json b/package.json index a11584cb5..891ad5bf1 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,8 @@ }, "type": "module", "scripts": { + "dev": "pnpm --filter meshtastic-web dev", + "build": "pnpm --filter meshtastic-web build", "preinstall": "npx only-allow pnpm", "lint": "oxlint", "lint:fix": "oxlint --fix", @@ -55,4 +57,4 @@ ] }, "packageManager": "pnpm@11.9.0" -} +} \ No newline at end of file From c974bce6b5572a42e941790690cd0d89c6c73a7f Mon Sep 17 00:00:00 2001 From: Hunter275 Date: Fri, 17 Jul 2026 21:25:54 -0400 Subject: [PATCH 2/3] wip --- apps/web/package.json | 2 + apps/web/src/pages/Telemetry/index.tsx | 386 +++++++----------- apps/web/src/routes.tsx | 8 + pnpm-lock.yaml | 527 +++++++++++++++++++++++++ 4 files changed, 674 insertions(+), 249 deletions(-) diff --git a/apps/web/package.json b/apps/web/package.json index 34e8dd59f..9167164ad 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -65,6 +65,7 @@ "@tanstack/router-cli": "^1.167.18", "@tanstack/router-devtools": "^1.167.0", "@turf/turf": "^7.3.5", + "@types/d3": "^7.4.3", "@types/node": "^26.1.0", "@types/web-bluetooth": "^0.0.21", "base64-js": "^1.5.1", @@ -73,6 +74,7 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "crypto-random-string": "^5.0.0", + "d3": "^7.9.0", "i18next": "^26.3.4", "i18next-browser-languagedetector": "^8.2.0", "i18next-http-backend": "^4.0.0", diff --git a/apps/web/src/pages/Telemetry/index.tsx b/apps/web/src/pages/Telemetry/index.tsx index b99cb6d73..0172509d5 100644 --- a/apps/web/src/pages/Telemetry/index.tsx +++ b/apps/web/src/pages/Telemetry/index.tsx @@ -1,264 +1,152 @@ -import { deviceRoute, moduleRoute, radioRoute } from "@app/routes"; +"use client"; + import { PageLayout } from "@components/PageLayout.tsx"; import { Sidebar } from "@components/Sidebar.tsx"; -import { SidebarButton } from "@components/UI/Sidebar/SidebarButton.tsx"; -import { SidebarSection } from "@components/UI/Sidebar/SidebarSection.tsx"; -import { useToast } from "@core/hooks/useToast.ts"; -import { cn } from "@core/utils/cn.ts"; -import { useConfigEditor, useSignal } from "@meshtastic/sdk-react"; -import { DeviceConfig } from "@pages/Settings/DeviceConfig.tsx"; -import { ModuleConfig } from "@pages/Settings/ModuleConfig.tsx"; -import { useNavigate, useRouterState } from "@tanstack/react-router"; +import { useEffect, useRef, useState } from "react"; +import * as d3 from "d3"; +import { useNodesAsProto } from "@core/hooks/useNodesAsProto"; import { - LayersIcon, - RadioTowerIcon, - RefreshCwIcon, - RouterIcon, - SaveIcon, - SaveOff, -} from "lucide-react"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import type { FieldValues, UseFormReturn } from "react-hook-form"; -import { useTranslation } from "react-i18next"; -import { RadioConfig } from "./RadioConfig.tsx"; - -const EMPTY_DIRTY_STRING_SIGNAL = { - value: [] as readonly string[], - peek: () => [] as readonly string[], - subscribe: () => () => {}, -} as const; -const EMPTY_DIRTY_NUMBER_SIGNAL = { - value: [] as readonly number[], - peek: () => [] as readonly number[], - subscribe: () => () => {}, -} as const; - -const TelemetryPage = () => { - const editor = useConfigEditor(); - const editorIsDirty = useSignal( - editor?.isDirty ?? { - value: false, - peek: () => false, - subscribe: () => () => {}, - }, - ); - const dirtyRadio = useSignal( - editor?.dirtyRadioSections ?? EMPTY_DIRTY_STRING_SIGNAL, - ); - const dirtyModule = useSignal( - editor?.dirtyModuleSections ?? EMPTY_DIRTY_STRING_SIGNAL, - ); - const dirtyChannels = useSignal( - editor?.dirtyChannels ?? EMPTY_DIRTY_NUMBER_SIGNAL, - ); - - const [isSaving, setIsSaving] = useState(false); - const [rhfState, setRhfState] = useState({ isDirty: false, isValid: true }); - const unsubRef = useRef<(() => void) | null>(null); - const [formMethods, setFormMethods] = useState(null); - const { toast } = useToast(); - const navigate = useNavigate(); - const routerState = useRouterState(); - const { t } = useTranslation("config"); - - const configChangeCount = dirtyRadio.length; - const moduleConfigChangeCount = dirtyModule.length; - const channelChangeCount = dirtyChannels.length; - - const sections = useMemo( - () => [ - { - key: "radio", - route: radioRoute, - label: t("navigation.radioConfig"), - icon: RadioTowerIcon, - changeCount: configChangeCount, - component: RadioConfig, - }, - { - key: "device", - route: deviceRoute, - label: t("navigation.deviceConfig"), - icon: RouterIcon, - changeCount: moduleConfigChangeCount, - component: DeviceConfig, - }, - { - key: "module", - route: moduleRoute, - label: t("navigation.moduleConfig"), - icon: LayersIcon, - changeCount: channelChangeCount, - component: ModuleConfig, - }, - ], - [t, configChangeCount, moduleConfigChangeCount, channelChangeCount], - ); - - const activeSection = - sections.find((section) => - routerState.location.pathname.includes(`/settings/${section.key}`), - ) ?? sections[0]; - - const onFormInit = useCallback( - (methods: UseFormReturn) => { - setFormMethods(methods as UseFormReturn); - - setRhfState({ - // Assume defailt on init, changes will be caught by subscription - isDirty: false, - isValid: true, - }); - - // Unsubscribe from previous subscriptions & subscribe to form changes - unsubRef.current?.(); - unsubRef.current = methods.subscribe({ - formState: { isDirty: true, isValid: true }, - callback: ({ isValid, isDirty }) => { - setRhfState({ - isDirty: isDirty ?? false, - isValid: isValid ?? true, - }); - }, - }); - }, - [], - ); + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@components/UI/Select"; +import { numberToHexUnpadded } from "@noble/curves/utils.js"; + +interface DataPoint { + time: number; + value: number; +} + +const TelemetryChart = () => { + const svgRef = useRef(null); useEffect(() => { - return () => unsubRef.current?.(); + if (!svgRef.current) return; + + // Sample telemetry data + const data: DataPoint[] = Array.from({ length: 20 }, (_, i) => ({ + time: i, + value: Math.sin(i * 0.3) * 50 + 50 + Math.random() * 10, + })); + + const width = 800; + const height = 400; + const margin = { top: 20, right: 30, bottom: 30, left: 60 }; + + // Create scales + const xScale = d3 + .scaleLinear() + .domain(d3.extent(data, (d) => d.time) as [number, number]) + .range([margin.left, width - margin.right]); + + const yScale = d3 + .scaleLinear() + .domain([0, d3.max(data, (d) => d.value) as number]) + .range([height - margin.bottom, margin.top]); + + // Create line generator + const line = d3 + .line() + .x((d) => xScale(d.time)) + .y((d) => yScale(d.value)); + + // Clear previous content + d3.select(svgRef.current).selectAll("*").remove(); + + const svg = d3 + .select(svgRef.current) + .attr("width", width) + .attr("height", height); + + // Add X axis + svg + .append("g") + .attr("transform", `translate(0,${height - margin.bottom})`) + .call(d3.axisBottom(xScale)); + + // Add Y axis + svg + .append("g") + .attr("transform", `translate(${margin.left},0)`) + .call(d3.axisLeft(yScale)); + + // Add line path + svg + .append("path") + .datum(data) + .attr("fill", "none") + .attr("stroke", "steelblue") + .attr("stroke-width", 2) + .attr("d", line); + + // Add dots + svg + .selectAll(".dot") + .data(data) + .enter() + .append("circle") + .attr("class", "dot") + .attr("cx", (d) => xScale(d.time)) + .attr("cy", (d) => yScale(d.value)) + .attr("r", 4) + .attr("fill", "steelblue"); + + // Add X axis label + svg + .append("text") + .attr("x", width / 2) + .attr("y", height - 5) + .attr("text-anchor", "middle") + .text("Time"); + + // Add Y axis label + svg + .append("text") + .attr("transform", "rotate(-90)") + .attr("x", -height / 2) + .attr("y", 15) + .attr("text-anchor", "middle") + .text("Value"); }, []); - const handleSave = useCallback(async () => { - if (!editor) return; - setIsSaving(true); - - try { - const result = await editor.commit(); - if (result.status === "error") { - throw result.error; - } - toast({ - title: t("toast.saveAllSuccess.title"), - description: t("toast.saveAllSuccess.description"), - }); + return ; +}; - if (formMethods) { - formMethods.reset(formMethods.getValues(), { - keepDirty: false, - keepErrors: false, - keepTouched: false, - keepValues: true, - }); - formMethods.trigger(); - } - } catch { - toast({ - title: t("toast.configSaveError.title"), - description: t("toast.configSaveError.description"), - }); - } finally { - setIsSaving(false); - } - }, [toast, t, formMethods, editor]); +const TelemetryPage = () => { + const nodes = useNodesAsProto(); + const [selectedNode, setSelectedNode] = useState(""); - const handleReset = useCallback(() => { - if (formMethods) { - formMethods.reset(); + useEffect(() => { + if (nodes.length > 0 && !selectedNode) { + setSelectedNode(String(nodes[0].num)); } - editor?.reset(); - }, [formMethods, editor]); - - const leftSidebar = useMemo( - () => ( - - - {sections.map((section) => ( - navigate({ to: section.route.to })} - Icon={section.icon} - isDirty={section.changeCount > 0} - count={section.changeCount} - /> - ))} - - - ), - [sections, activeSection?.key, navigate, t], - ); - - const hasDrafts = editorIsDirty; - const hasPending = hasDrafts || rhfState.isDirty; - const buttonOpacity = hasPending ? "opacity-100" : "opacity-0"; - const saveDisabled = isSaving || !rhfState.isValid || !hasPending; - - const actions = useMemo( - () => [ - { - key: "unsavedChanges", - label: t("common:formValidation.unsavedChanges"), - onClick: () => {}, - className: cn([ - "bg-blue-500 text-slate-900 hover:bg-initial", - "transition-colors duration-200", - buttonOpacity, - "transition-opacity", - ]), - }, - { - key: "reset", - icon: RefreshCwIcon, - label: t("common:button.reset"), - onClick: handleReset, - className: cn([ - buttonOpacity, - "transition-opacity hover:bg-slate-200 disabled:hover:bg-white", - "hover:dark:bg-slate-300 hover:dark:text-black cursor-pointer", - ]), - }, - { - key: "save", - icon: !hasPending ? SaveOff : SaveIcon, - isLoading: isSaving, - disabled: saveDisabled, - iconClasses: - !rhfState.isValid && hasPending - ? "text-red-400 cursor-not-allowed" - : "cursor-pointer", - className: cn([ - "transition-opacity hover:bg-slate-200 disabled:hover:bg-white", - "hover:dark:bg-slate-300 hover:dark:text-black", - "disabled:hover:cursor-not-allowed cursor-pointer", - ]), - onClick: handleSave, - label: t("common:button.save"), - }, - ], - [ - isSaving, - hasPending, - rhfState.isValid, - saveDisabled, - buttonOpacity, - handleReset, - handleSave, - t, - ], - ); - - const ActiveComponent = activeSection?.component; + }, [nodes, selectedNode]); return ( - - {ActiveComponent && } + }> +
+

Telemetry Data

+
+ + +
+ +
); }; diff --git a/apps/web/src/routes.tsx b/apps/web/src/routes.tsx index 565f83f42..0612e8c07 100644 --- a/apps/web/src/routes.tsx +++ b/apps/web/src/routes.tsx @@ -4,6 +4,7 @@ import { Connections } from "@pages/Connections/index.tsx"; import MapPage from "@pages/Map/index.tsx"; import MessagesPage from "@pages/Messages.tsx"; import NodesPage from "@pages/Nodes/index.tsx"; +import TelemetryPage from "@pages/Telemetry/index.tsx"; import ConfigPage from "@pages/Settings/index.tsx"; import { createRootRouteWithContext, @@ -144,6 +145,12 @@ const nodesRoute = createRoute({ component: NodesPage, }); +const telemetryRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/telemetry", + component: TelemetryPage, +}); + const dialogWithParamsRoute = createRoute({ getParentRoute: () => rootRoute, path: "/dialog/$dialogId", @@ -166,6 +173,7 @@ export const routeTree = rootRoute.addChildren([ nodesRoute, dialogWithParamsRoute, connectionsRoute, + telemetryRoute, ]); const router = createRouter({ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f61e2affc..b543cac2f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -156,6 +156,9 @@ importers: '@turf/turf': specifier: ^7.3.5 version: 7.3.5 + '@types/d3': + specifier: ^7.4.3 + version: 7.4.3 '@types/node': specifier: ^26.1.0 version: 26.1.1 @@ -180,6 +183,9 @@ importers: crypto-random-string: specifier: ^5.0.0 version: 5.0.0 + d3: + specifier: ^7.9.0 + version: 7.9.0 i18next: specifier: ^26.3.4 version: 26.3.4(typescript@6.0.3) @@ -3361,9 +3367,102 @@ packages: '@types/chrome@0.2.2': resolution: {integrity: sha512-8rSMZ4cvo2xmaSyQg0sN5yRL7oiDkntLoiHxUhfwQnv1mvnkrdoZ25SlNrKWmYKaeP50WvrfWj1pmc02+U9KKw==} + '@types/d3-array@3.2.2': + resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} + + '@types/d3-axis@3.0.6': + resolution: {integrity: sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==} + + '@types/d3-brush@3.0.6': + resolution: {integrity: sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==} + + '@types/d3-chord@3.0.6': + resolution: {integrity: sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==} + + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-contour@3.0.6': + resolution: {integrity: sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==} + + '@types/d3-delaunay@6.0.4': + resolution: {integrity: sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==} + + '@types/d3-dispatch@3.0.7': + resolution: {integrity: sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==} + + '@types/d3-drag@3.0.7': + resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==} + + '@types/d3-dsv@3.0.7': + resolution: {integrity: sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==} + + '@types/d3-ease@3.0.2': + resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} + + '@types/d3-fetch@3.0.7': + resolution: {integrity: sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==} + + '@types/d3-force@3.0.10': + resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==} + + '@types/d3-format@3.0.4': + resolution: {integrity: sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==} + + '@types/d3-geo@3.1.0': + resolution: {integrity: sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==} + + '@types/d3-hierarchy@3.1.7': + resolution: {integrity: sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-path@3.1.1': + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} + + '@types/d3-polygon@3.0.2': + resolution: {integrity: sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==} + + '@types/d3-quadtree@3.0.6': + resolution: {integrity: sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==} + + '@types/d3-random@3.0.4': + resolution: {integrity: sha512-UHYId5WTCx4L4YNel7NU00XUXXgvgpgZOvp10PuvsQENjMDXhh2RyFc0KBjO7B45ne4Ha1yVH7ii0vnzKkuzWA==} + + '@types/d3-scale-chromatic@3.1.0': + resolution: {integrity: sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==} + + '@types/d3-scale@4.0.9': + resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} + + '@types/d3-selection@3.0.11': + resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==} + + '@types/d3-shape@3.1.8': + resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==} + + '@types/d3-time-format@4.0.3': + resolution: {integrity: sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==} + + '@types/d3-time@3.0.4': + resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} + + '@types/d3-timer@3.0.2': + resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + + '@types/d3-transition@3.0.9': + resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==} + '@types/d3-voronoi@1.1.12': resolution: {integrity: sha512-DauBl25PKZZ0WVJr42a6CNvI6efsdzofl9sajqZr2Gf5Gu733WkDdUGiPkUHXiUvYGzNNlFQde2wdZdfQPG+yw==} + '@types/d3-zoom@3.0.8': + resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==} + + '@types/d3@7.4.3': + resolution: {integrity: sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==} + '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} @@ -3831,6 +3930,10 @@ packages: commander@2.20.3: resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} + commander@7.2.0: + resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} + engines: {node: '>= 10'} + commander@8.3.0: resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} engines: {node: '>= 12'} @@ -3908,12 +4011,139 @@ packages: d3-array@1.2.4: resolution: {integrity: sha512-KHW6M86R+FUPYGb3R5XiYjXPq7VzwxZ22buHhAEVG5ztoEcZZMLov530mmccaqA1GghZArjQV46fuc8kUqhhHw==} + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + + d3-axis@3.0.0: + resolution: {integrity: sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==} + engines: {node: '>=12'} + + d3-brush@3.0.0: + resolution: {integrity: sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==} + engines: {node: '>=12'} + + d3-chord@3.0.1: + resolution: {integrity: sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==} + engines: {node: '>=12'} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-contour@4.0.2: + resolution: {integrity: sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==} + engines: {node: '>=12'} + + d3-delaunay@6.0.4: + resolution: {integrity: sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==} + engines: {node: '>=12'} + + d3-dispatch@3.0.1: + resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} + engines: {node: '>=12'} + + d3-drag@3.0.0: + resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==} + engines: {node: '>=12'} + + d3-dsv@3.0.1: + resolution: {integrity: sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==} + engines: {node: '>=12'} + hasBin: true + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-fetch@3.0.1: + resolution: {integrity: sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==} + engines: {node: '>=12'} + + d3-force@3.0.0: + resolution: {integrity: sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==} + engines: {node: '>=12'} + + d3-format@3.1.2: + resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==} + engines: {node: '>=12'} + d3-geo@1.7.1: resolution: {integrity: sha512-O4AempWAr+P5qbk2bC2FuN/sDW4z+dN2wDf9QV3bxQt4M5HfOEeXLgJ/UKQW0+o1Dj8BE+L5kiDbdWUMjsmQpw==} + d3-geo@3.1.1: + resolution: {integrity: sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==} + engines: {node: '>=12'} + + d3-hierarchy@3.1.2: + resolution: {integrity: sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + + d3-polygon@3.0.1: + resolution: {integrity: sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==} + engines: {node: '>=12'} + + d3-quadtree@3.0.1: + resolution: {integrity: sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==} + engines: {node: '>=12'} + + d3-random@3.0.1: + resolution: {integrity: sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==} + engines: {node: '>=12'} + + d3-scale-chromatic@3.1.0: + resolution: {integrity: sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==} + engines: {node: '>=12'} + + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + + d3-selection@3.0.0: + resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==} + engines: {node: '>=12'} + + d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + + d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + + d3-transition@3.0.1: + resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==} + engines: {node: '>=12'} + peerDependencies: + d3-selection: 2 - 3 + d3-voronoi@1.1.2: resolution: {integrity: sha512-RhGS1u2vavcO7ay7ZNAPo4xeDh/VYeGof3x5ZLJBQgYhLegxr3s5IykvWmJ94FTU6mcbtp4sloqZ54mP6R4Utw==} + d3-zoom@3.0.0: + resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==} + engines: {node: '>=12'} + + d3@7.9.0: + resolution: {integrity: sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==} + engines: {node: '>=12'} + data-urls@7.0.0: resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -3966,6 +4196,9 @@ packages: defu@6.1.7: resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + delaunator@5.1.0: + resolution: {integrity: sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==} + dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} @@ -4471,6 +4704,10 @@ packages: typescript: optional: true + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + idb-keyval@6.2.6: resolution: {integrity: sha512-FY64UEhw+5liMzMQ1R9Mw6AF0+wyBrg1CIA1z4CjI/EvT5ty/SvQcWZgd8s9sgaNhX10Y8UzScTh89tEAls5nA==} @@ -4496,6 +4733,10 @@ packages: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + is-array-buffer@3.0.5: resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} engines: {node: '>= 0.4'} @@ -5414,6 +5655,9 @@ packages: resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} engines: {node: '>= 0.4'} + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + saxes@6.0.0: resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} engines: {node: '>=v12.22.7'} @@ -9869,8 +10113,125 @@ snapshots: '@types/filesystem': 0.0.36 '@types/har-format': 1.2.16 + '@types/d3-array@3.2.2': {} + + '@types/d3-axis@3.0.6': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-brush@3.0.6': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-chord@3.0.6': {} + + '@types/d3-color@3.1.3': {} + + '@types/d3-contour@3.0.6': + dependencies: + '@types/d3-array': 3.2.2 + '@types/geojson': 7946.0.16 + + '@types/d3-delaunay@6.0.4': {} + + '@types/d3-dispatch@3.0.7': {} + + '@types/d3-drag@3.0.7': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-dsv@3.0.7': {} + + '@types/d3-ease@3.0.2': {} + + '@types/d3-fetch@3.0.7': + dependencies: + '@types/d3-dsv': 3.0.7 + + '@types/d3-force@3.0.10': {} + + '@types/d3-format@3.0.4': {} + + '@types/d3-geo@3.1.0': + dependencies: + '@types/geojson': 7946.0.16 + + '@types/d3-hierarchy@3.1.7': {} + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-path@3.1.1': {} + + '@types/d3-polygon@3.0.2': {} + + '@types/d3-quadtree@3.0.6': {} + + '@types/d3-random@3.0.4': {} + + '@types/d3-scale-chromatic@3.1.0': {} + + '@types/d3-scale@4.0.9': + dependencies: + '@types/d3-time': 3.0.4 + + '@types/d3-selection@3.0.11': {} + + '@types/d3-shape@3.1.8': + dependencies: + '@types/d3-path': 3.1.1 + + '@types/d3-time-format@4.0.3': {} + + '@types/d3-time@3.0.4': {} + + '@types/d3-timer@3.0.2': {} + + '@types/d3-transition@3.0.9': + dependencies: + '@types/d3-selection': 3.0.11 + '@types/d3-voronoi@1.1.12': {} + '@types/d3-zoom@3.0.8': + dependencies: + '@types/d3-interpolate': 3.0.4 + '@types/d3-selection': 3.0.11 + + '@types/d3@7.4.3': + dependencies: + '@types/d3-array': 3.2.2 + '@types/d3-axis': 3.0.6 + '@types/d3-brush': 3.0.6 + '@types/d3-chord': 3.0.6 + '@types/d3-color': 3.1.3 + '@types/d3-contour': 3.0.6 + '@types/d3-delaunay': 6.0.4 + '@types/d3-dispatch': 3.0.7 + '@types/d3-drag': 3.0.7 + '@types/d3-dsv': 3.0.7 + '@types/d3-ease': 3.0.2 + '@types/d3-fetch': 3.0.7 + '@types/d3-force': 3.0.10 + '@types/d3-format': 3.0.4 + '@types/d3-geo': 3.1.0 + '@types/d3-hierarchy': 3.1.7 + '@types/d3-interpolate': 3.0.4 + '@types/d3-path': 3.1.1 + '@types/d3-polygon': 3.0.2 + '@types/d3-quadtree': 3.0.6 + '@types/d3-random': 3.0.4 + '@types/d3-scale': 4.0.9 + '@types/d3-scale-chromatic': 3.1.0 + '@types/d3-selection': 3.0.11 + '@types/d3-shape': 3.1.8 + '@types/d3-time': 3.0.4 + '@types/d3-time-format': 4.0.3 + '@types/d3-timer': 3.0.2 + '@types/d3-transition': 3.0.9 + '@types/d3-zoom': 3.0.8 + '@types/deep-eql@4.0.2': {} '@types/emscripten@1.41.5': {} @@ -10350,6 +10711,8 @@ snapshots: commander@2.20.3: {} + commander@7.2.0: {} + commander@8.3.0: {} common-tags@1.8.2: {} @@ -10414,12 +10777,164 @@ snapshots: d3-array@1.2.4: {} + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-axis@3.0.0: {} + + d3-brush@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + d3-chord@3.0.1: + dependencies: + d3-path: 3.1.0 + + d3-color@3.1.0: {} + + d3-contour@4.0.2: + dependencies: + d3-array: 3.2.4 + + d3-delaunay@6.0.4: + dependencies: + delaunator: 5.1.0 + + d3-dispatch@3.0.1: {} + + d3-drag@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-selection: 3.0.0 + + d3-dsv@3.0.1: + dependencies: + commander: 7.2.0 + iconv-lite: 0.6.3 + rw: 1.3.3 + + d3-ease@3.0.1: {} + + d3-fetch@3.0.1: + dependencies: + d3-dsv: 3.0.1 + + d3-force@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-quadtree: 3.0.1 + d3-timer: 3.0.1 + + d3-format@3.1.2: {} + d3-geo@1.7.1: dependencies: d3-array: 1.2.4 + d3-geo@3.1.1: + dependencies: + d3-array: 3.2.4 + + d3-hierarchy@3.1.2: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@3.1.0: {} + + d3-polygon@3.0.1: {} + + d3-quadtree@3.0.1: {} + + d3-random@3.0.1: {} + + d3-scale-chromatic@3.1.0: + dependencies: + d3-color: 3.1.0 + d3-interpolate: 3.0.1 + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.2 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-selection@3.0.0: {} + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-timer@3.0.1: {} + + d3-transition@3.0.1(d3-selection@3.0.0): + dependencies: + d3-color: 3.1.0 + d3-dispatch: 3.0.1 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-timer: 3.0.1 + d3-voronoi@1.1.2: {} + d3-zoom@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + d3@7.9.0: + dependencies: + d3-array: 3.2.4 + d3-axis: 3.0.0 + d3-brush: 3.0.0 + d3-chord: 3.0.1 + d3-color: 3.1.0 + d3-contour: 4.0.2 + d3-delaunay: 6.0.4 + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-dsv: 3.0.1 + d3-ease: 3.0.1 + d3-fetch: 3.0.1 + d3-force: 3.0.0 + d3-format: 3.1.2 + d3-geo: 3.1.1 + d3-hierarchy: 3.1.2 + d3-interpolate: 3.0.1 + d3-path: 3.1.0 + d3-polygon: 3.0.1 + d3-quadtree: 3.0.1 + d3-random: 3.0.1 + d3-scale: 4.0.2 + d3-scale-chromatic: 3.1.0 + d3-selection: 3.0.0 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + d3-timer: 3.0.1 + d3-transition: 3.0.1(d3-selection@3.0.0) + d3-zoom: 3.0.0 + data-urls@7.0.0(@noble/hashes@2.2.0): dependencies: whatwg-mimetype: 5.0.0 @@ -10471,6 +10986,10 @@ snapshots: defu@6.1.7: {} + delaunator@5.1.0: + dependencies: + robust-predicates: 3.0.3 + dequal@2.0.3: {} detect-libc@2.1.2: {} @@ -10955,6 +11474,10 @@ snapshots: optionalDependencies: typescript: 6.0.3 + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + idb-keyval@6.2.6: {} idb@7.1.1: {} @@ -10974,6 +11497,8 @@ snapshots: hasown: 2.0.4 side-channel: 1.1.1 + internmap@2.0.3: {} + is-array-buffer@3.0.5: dependencies: call-bind: 1.0.9 @@ -11920,6 +12445,8 @@ snapshots: es-errors: 1.3.0 is-regex: 1.2.1 + safer-buffer@2.1.2: {} + saxes@6.0.0: dependencies: xmlchars: 2.2.0 From 5aadccd9e6b0bb9bec1b9b4a2eb61a6f907f5528 Mon Sep 17 00:00:00 2001 From: Hunter275 Date: Tue, 28 Jul 2026 17:51:32 -0400 Subject: [PATCH 3/3] add telemetry graph --- .../core/connections/nodeMetricsRecorder.ts | 95 ++++ apps/web/src/core/connections/sdkClient.ts | 29 +- apps/web/src/pages/Telemetry/index.tsx | 485 ++++++++++++++++-- apps/web/src/pages/Telemetry/statNames.ts | 137 +++++ packages/sdk-storage-sqlocal/mod.ts | 6 + packages/sdk-storage-sqlocal/package.json | 1 + .../SqlocalNodeMetricsRepository.ts | 175 +++++++ .../src/nodeMetrics/index.ts | 6 + .../sdk-storage-sqlocal/src/schema/index.ts | 1 + .../src/schema/migrations.test.ts | 23 + .../src/schema/migrations.ts | 14 + .../src/schema/nodeMetrics.ts | 40 ++ 12 files changed, 965 insertions(+), 47 deletions(-) create mode 100644 apps/web/src/core/connections/nodeMetricsRecorder.ts create mode 100644 apps/web/src/pages/Telemetry/statNames.ts create mode 100644 packages/sdk-storage-sqlocal/src/nodeMetrics/SqlocalNodeMetricsRepository.ts create mode 100644 packages/sdk-storage-sqlocal/src/nodeMetrics/index.ts create mode 100644 packages/sdk-storage-sqlocal/src/schema/nodeMetrics.ts diff --git a/apps/web/src/core/connections/nodeMetricsRecorder.ts b/apps/web/src/core/connections/nodeMetricsRecorder.ts new file mode 100644 index 000000000..cdb561fbd --- /dev/null +++ b/apps/web/src/core/connections/nodeMetricsRecorder.ts @@ -0,0 +1,95 @@ +import { createLogger, type MeshClient } from "@meshtastic/sdk"; +import type { + NodeMetricSample, + NodeMetricsRetentionPolicy, + SqlocalNodeMetricsRepository, +} from "@meshtastic/sdk-storage-sqlocal/nodeMetrics"; + +const log = createLogger("nodeMetricsRecorder"); + +export interface NodeMetricsRecorderOptions { + retention?: NodeMetricsRetentionPolicy; + /** Run a prune pass after roughly this many appended samples. */ + pruneEvery?: number; +} + +/** + * Records per-node metrics (SNR, hops away, last heard) — the node-level + * values shown on the Nodes page that Telemetry packets don't carry — into the + * given repository, so the Telemetry page can chart them for every node. + * + * SNR is sampled from every inbound mesh packet (dense signal history); hops + * away / last heard come from NodeInfo broadcasts. Returns a teardown that + * detaches the subscriptions. + */ +export function attachNodeMetricsRecorder( + client: MeshClient, + repo: SqlocalNodeMetricsRepository, + options: NodeMetricsRecorderOptions = {}, +): () => void { + const retention = options.retention; + const pruneEvery = options.pruneEvery ?? 128; + let sincePrune = 0; + + const record = (samples: NodeMetricSample[]): void => { + if (samples.length === 0) return; + repo + .appendBatch(samples) + .then(() => { + sincePrune += samples.length; + if (retention && sincePrune >= pruneEvery) { + sincePrune = 0; + return repo.prune(retention); + } + }) + .catch((e: unknown) => { + log.warn("node metric persist failed", { + error: (e as Error)?.message, + }); + }); + }; + + const unsubMesh = client.events.onMeshPacket.subscribe((packet) => { + if (!packet.from) return; + const time = + packet.rxTime > 0 ? new Date(packet.rxTime * 1000) : new Date(); + if (Number.isFinite(packet.rxSnr)) { + record([ + { nodeNum: packet.from, metric: "snr", time, value: packet.rxSnr }, + ]); + } + }); + + const unsubNodeInfo = client.events.onNodeInfoPacket.subscribe((info) => { + if (!info.num) return; + const time = + info.lastHeard > 0 ? new Date(info.lastHeard * 1000) : new Date(); + const samples: NodeMetricSample[] = []; + // NodeInfo.snr is 0 when unset; only record a genuine measurement. + if (Number.isFinite(info.snr) && info.snr !== 0) { + samples.push({ nodeNum: info.num, metric: "snr", time, value: info.snr }); + } + if (typeof info.hopsAway === "number") { + samples.push({ + nodeNum: info.num, + metric: "hopsAway", + time, + value: info.hopsAway, + }); + } + if (info.lastHeard > 0) { + samples.push({ + nodeNum: info.num, + metric: "lastHeard", + time, + value: info.lastHeard, + }); + } + record(samples); + }); + + return () => { + unsubMesh(); + unsubNodeInfo(); + }; +} diff --git a/apps/web/src/core/connections/sdkClient.ts b/apps/web/src/core/connections/sdkClient.ts index b76e0a80c..1d4f419dd 100644 --- a/apps/web/src/core/connections/sdkClient.ts +++ b/apps/web/src/core/connections/sdkClient.ts @@ -1,12 +1,14 @@ import { coordinator, getStorageDb } from "@core/sdkStorage.ts"; import type { ConnectionId } from "@core/stores/deviceStore/types"; -import { createLogger, MeshDevice } from "@meshtastic/sdk"; +import { createLogger, DeviceStatusEnum, MeshDevice } from "@meshtastic/sdk"; import { SqlocalDraftRepository, SqlocalMessageRepository, } from "@meshtastic/sdk-storage-sqlocal/chat"; import { SqlocalNodesRepository } from "@meshtastic/sdk-storage-sqlocal/nodes"; import { SqlocalTelemetryRepository } from "@meshtastic/sdk-storage-sqlocal/telemetry"; +import { SqlocalNodeMetricsRepository } from "@meshtastic/sdk-storage-sqlocal/nodeMetrics"; +import { attachNodeMetricsRecorder } from "./nodeMetricsRecorder.ts"; import type { TransportHTTP } from "@meshtastic/transport-http"; import type { TransportWebBluetooth } from "@meshtastic/transport-web-bluetooth"; import type { TransportWebSerial } from "@meshtastic/transport-web-serial"; @@ -26,6 +28,10 @@ const TELEMETRY_RETENTION = { maxPerNode: 500, olderThanMs: 1000 * 60 * 60 * 24 * 30, } as const; +const NODE_METRICS_RETENTION = { + maxPerMetric: 1000, + olderThanMs: 1000 * 60 * 60 * 24 * 30, +} as const; const STORAGE_OPEN_TIMEOUT_MS = 5000; /** @@ -45,6 +51,7 @@ export async function buildMeshDevice( let draftRepository: SqlocalDraftRepository | undefined; let nodesRepository: SqlocalNodesRepository | undefined; let telemetryRepository: SqlocalTelemetryRepository | undefined; + let nodeMetricsRepository: SqlocalNodeMetricsRepository | undefined; try { const t0 = Date.now(); const db = await Promise.race([ @@ -75,6 +82,9 @@ export async function buildMeshDevice( telemetryRepository = new SqlocalTelemetryRepository(db, { deviceId: connectionId, }); + nodeMetricsRepository = new SqlocalNodeMetricsRepository(db, { + deviceId: connectionId, + }); log.debug("buildMeshDevice: repositories opened"); } catch (err) { const e = err as Error; @@ -87,7 +97,7 @@ export async function buildMeshDevice( ); } - return new MeshDevice(transport, { + const meshDevice = new MeshDevice(transport, { configId: deviceId, chat: chatRepository || draftRepository @@ -102,4 +112,19 @@ export async function buildMeshDevice( ? { repository: telemetryRepository, retention: TELEMETRY_RETENTION } : undefined, }); + + // The node-metrics recorder has no SDK client of its own — it subscribes to + // the mesh client's event bus and persists directly. Detached on disconnect. + if (nodeMetricsRepository) { + const detach = attachNodeMetricsRecorder( + meshDevice.meshClient, + nodeMetricsRepository, + { retention: NODE_METRICS_RETENTION }, + ); + meshDevice.meshClient.events.onDeviceStatus.subscribe((status) => { + if (status === DeviceStatusEnum.DeviceDisconnected) detach(); + }); + } + + return meshDevice; } diff --git a/apps/web/src/pages/Telemetry/index.tsx b/apps/web/src/pages/Telemetry/index.tsx index 0172509d5..5b17174b1 100644 --- a/apps/web/src/pages/Telemetry/index.tsx +++ b/apps/web/src/pages/Telemetry/index.tsx @@ -2,9 +2,11 @@ import { PageLayout } from "@components/PageLayout.tsx"; import { Sidebar } from "@components/Sidebar.tsx"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import * as d3 from "d3"; import { useNodesAsProto } from "@core/hooks/useNodesAsProto"; +import type { ReadonlySignal, TelemetryReading } from "@meshtastic/sdk"; +import { useActiveClient, useSignal } from "@meshtastic/sdk-react"; import { Select, SelectContent, @@ -13,64 +15,143 @@ import { SelectValue, } from "@components/UI/Select"; import { numberToHexUnpadded } from "@noble/curves/utils.js"; +import { translateStatName } from "./statNames.ts"; +import { getStorageDb } from "@core/sdkStorage.ts"; +import { useActiveConnectionId } from "@core/stores/deviceStore/selectors.ts"; +import { + type NodeMetricSample, + SqlocalNodeMetricsRepository, +} from "@meshtastic/sdk-storage-sqlocal/nodeMetrics"; interface DataPoint { + /** Epoch milliseconds. */ time: number; value: number; } -const TelemetryChart = () => { +const TelemetryChart = ({ + data, + label, +}: { + data: DataPoint[]; + label: string; +}) => { const svgRef = useRef(null); + const containerRef = useRef(null); + const tooltipRef = useRef(null); + const [dimensions, setDimensions] = useState({ width: 1000, height: 600 }); + + useEffect(() => { + const handleResize = () => { + if (containerRef.current) { + setDimensions({ + width: containerRef.current.clientWidth, + height: + containerRef.current.clientHeight - + containerRef.current.clientHeight * 0.1, + }); + } + }; + + handleResize(); + window.addEventListener("resize", handleResize); + return () => window.removeEventListener("resize", handleResize); + }, []); useEffect(() => { if (!svgRef.current) return; - // Sample telemetry data - const data: DataPoint[] = Array.from({ length: 20 }, (_, i) => ({ - time: i, - value: Math.sin(i * 0.3) * 50 + 50 + Math.random() * 10, - })); + const width = dimensions.width; + const height = dimensions.height; + const margin = { top: 40, right: 30, bottom: 80, left: 100 }; - const width = 800; - const height = 400; - const margin = { top: 20, right: 30, bottom: 30, left: 60 }; + // Clear previous content + d3.select(svgRef.current).selectAll("*").remove(); + + const svg = d3 + .select(svgRef.current) + .attr("width", width) + .attr("height", height); + + // Chart title + svg + .append("text") + .attr("x", width / 2) + .attr("y", 25) + .attr("text-anchor", "middle") + .attr("font-size", "18px") + .attr("font-weight", "bold") + .attr("fill", "white") + .text(label); + + if (data.length === 0) { + svg + .append("text") + .attr("x", width / 2) + .attr("y", height / 2) + .attr("text-anchor", "middle") + .attr("font-size", "16px") + .attr("fill", "white") + .text("No data for the selected node and stat"); + return; + } - // Create scales + // Create scales — x is time, y is the stat value. const xScale = d3 - .scaleLinear() + .scaleTime() .domain(d3.extent(data, (d) => d.time) as [number, number]) .range([margin.left, width - margin.right]); const yScale = d3 .scaleLinear() - .domain([0, d3.max(data, (d) => d.value) as number]) + .domain(d3.extent(data, (d) => d.value) as [number, number]) + .nice() .range([height - margin.bottom, margin.top]); - // Create line generator + // Create line + area generators. The area fills down to the x-axis. const line = d3 .line() .x((d) => xScale(d.time)) .y((d) => yScale(d.value)); - // Clear previous content - d3.select(svgRef.current).selectAll("*").remove(); - - const svg = d3 - .select(svgRef.current) - .attr("width", width) - .attr("height", height); + const area = d3 + .area() + .x((d) => xScale(d.time)) + .y0(height - margin.bottom) + .y1((d) => yScale(d.value)); - // Add X axis + // Add X axis — labels include the date alongside the time. svg .append("g") .attr("transform", `translate(0,${height - margin.bottom})`) - .call(d3.axisBottom(xScale)); + .call( + d3 + .axisBottom(xScale) + .ticks(6) + .tickPadding(10) + .tickFormat((d) => d3.timeFormat("%b %d %H:%M")(d as Date)), + ) + .attr("color", "white") + .selectAll("text") + .attr("fill", "white"); // Add Y axis svg .append("g") .attr("transform", `translate(${margin.left},0)`) - .call(d3.axisLeft(yScale)); + .call(d3.axisLeft(yScale).tickPadding(10)) + .attr("color", "white") + .selectAll("text") + .attr("fill", "white"); + + // Add area fill (drawn first so the line renders on top) + svg + .append("path") + .datum(data) + .attr("fill", "steelblue") + .attr("fill-opacity", 0.3) + .attr("stroke", "none") + .attr("d", area); // Add line path svg @@ -97,8 +178,11 @@ const TelemetryChart = () => { svg .append("text") .attr("x", width / 2) - .attr("y", height - 5) + .attr("y", height - 10) .attr("text-anchor", "middle") + .attr("font-size", "14px") + .attr("font-weight", "500") + .attr("fill", "white") .text("Time"); // Add Y axis label @@ -108,15 +192,168 @@ const TelemetryChart = () => { .attr("x", -height / 2) .attr("y", 15) .attr("text-anchor", "middle") - .text("Value"); - }, []); + .attr("font-size", "14px") + .attr("font-weight", "500") + .attr("fill", "white") + .text(label); + + // Hover interaction: a highlighted focus dot plus an HTML tooltip that + // snaps to the nearest data point under the cursor. + const tooltip = d3.select(tooltipRef.current); + tooltip.style("opacity", "0"); + + const dateFmt = d3.timeFormat("%b %d, %Y %H:%M:%S"); + const valueFmt = (v: number) => + v.toLocaleString(undefined, { maximumFractionDigits: 4 }); + const bisectTime = d3.bisector((d: DataPoint) => d.time).left; + + const focus = svg + .append("circle") + .attr("r", 6) + .attr("fill", "steelblue") + .attr("stroke", "white") + .attr("stroke-width", 2) + .style("opacity", 0) + .style("pointer-events", "none"); + + svg + .append("rect") + .attr("x", margin.left) + .attr("y", margin.top) + .attr("width", Math.max(0, width - margin.left - margin.right)) + .attr("height", Math.max(0, height - margin.top - margin.bottom)) + .attr("fill", "transparent") + .style("cursor", "crosshair") + .on("mouseenter", () => { + focus.style("opacity", 1); + tooltip.style("opacity", "1"); + }) + .on("mouseleave", () => { + focus.style("opacity", 0); + tooltip.style("opacity", "0"); + }) + .on("mousemove", (event: MouseEvent) => { + const [mx] = d3.pointer(event); + const x0 = +xScale.invert(mx); + const i = bisectTime(data, x0); + const dLeft = data[i - 1]; + const dRight = data[i]; + const point = !dLeft + ? dRight + : !dRight + ? dLeft + : x0 - dLeft.time < dRight.time - x0 + ? dLeft + : dRight; + if (!point) return; + + const px = xScale(point.time); + const py = yScale(point.value); + focus.attr("cx", px).attr("cy", py); + + // Flip the tooltip to the left of the point near the right edge so it + // doesn't get clipped by the container's overflow. + const flip = px > width * 0.6; + tooltip + .style("left", `${px + (flip ? -14 : 14)}px`) + .style("top", `${py}px`) + .style( + "transform", + flip ? "translate(-100%, -50%)" : "translateY(-50%)", + ) + .html( + `
${label}
` + + `
${valueFmt(point.value)}
` + + `
${dateFmt(new Date(point.time))}
`, + ); + }); + }, [data, label, dimensions]); - return ; + return ( +
+ +
+
+ ); }; +// Stable no-op signal used while no client is active or no node is selected, +// so the hook order stays constant across renders. +const EMPTY_TELEMETRY_SIGNAL: ReadonlySignal = { + value: [], + peek: () => [], + subscribe: () => () => {}, +}; + +// Telemetry payloads are decoded protobufs that can carry bigint fields, +// which JSON.stringify cannot serialize on its own. +const jsonReplacer = (_key: string, value: unknown) => + typeof value === "bigint" ? value.toString() : value; + +// A stat is a single numeric field within a telemetry payload, keyed by its +// field name (e.g. "numRxDupe"). Fields that appear in more than one telemetry +// kind (e.g. uptimeSeconds in both deviceMetrics and localStats) collapse to a +// single stat so the dropdown never lists the same stat twice. + +function isNumericLike(v: unknown): v is number | bigint { + return (typeof v === "number" && Number.isFinite(v)) || typeof v === "bigint"; +} + +function toNumber(v: unknown): number { + return typeof v === "bigint" ? Number(v) : (v as number); +} + +// Display name for a node: its long name (or a fallback) plus the "!hex" id, +// so the node dropdown and the raw-rows header refer to a node the same way. +function nodeDisplayName(node: { + num: number; + user?: { longName?: string }; +}): string { + const hex = numberToHexUnpadded(node.num); + const name = + node.user?.longName || `Meshtastic ${hex.slice(-4).toUpperCase()}`; + return `${name} (!${hex})`; +} + const TelemetryPage = () => { const nodes = useNodesAsProto(); const [selectedNode, setSelectedNode] = useState(""); + const [selectedStat, setSelectedStat] = useState(""); + const client = useActiveClient(); useEffect(() => { if (nodes.length > 0 && !selectedNode) { @@ -124,28 +361,186 @@ const TelemetryPage = () => { } }, [nodes, selectedNode]); + // Read the telemetry rows for the selected node. `history()` hydrates from + // the OPFS-backed SQLite `telemetry` table on first access and returns a + // signal that stays in sync as new packets arrive. + const historySignal = useMemo(() => { + if (!client || !selectedNode) return EMPTY_TELEMETRY_SIGNAL; + return client.telemetry.history(Number(selectedNode)); + }, [client, selectedNode]); + const readings = useSignal(historySignal); + + const connectionId = useActiveConnectionId(); + + const selectedNodeInfo = useMemo( + () => nodes.find((n) => String(n.num) === selectedNode), + [nodes, selectedNode], + ); + + // Node-level metrics (SNR, hops away, last heard) recorded for every node in + // the `node_metrics` table — the Nodes-page metrics that Telemetry packets + // don't carry. Reloaded whenever the node is heard again (lastHeard ticks), + // which is when a fresh sample was most likely just written. + const [nodeSamples, setNodeSamples] = useState([]); + const nodeLastHeard = selectedNodeInfo?.lastHeard; + useEffect(() => { + let cancelled = false; + if (connectionId == null || !selectedNode) { + setNodeSamples([]); + return; + } + getStorageDb() + .then((db) => { + const repo = new SqlocalNodeMetricsRepository(db, { + deviceId: connectionId, + }); + return repo.loadRecent(Number(selectedNode), 2000); + }) + .then((samples) => { + if (!cancelled) setNodeSamples(samples); + }) + .catch(() => { + if (!cancelled) setNodeSamples([]); + }); + return () => { + cancelled = true; + }; + }, [connectionId, selectedNode, nodeLastHeard]); + + // Telemetry-packet stats: the numeric fields present on each reading payload + // (a field shared across kinds collapses to one entry). + const telemetryStatKeys = useMemo(() => { + const set = new Set(); + for (const r of readings) { + const value = r.value as Record | undefined; + if (!value) continue; + for (const [field, v] of Object.entries(value)) { + if (isNumericLike(v)) set.add(field); + } + } + return set; + }, [readings]); + + // Node-metric stats recorded in node_metrics for this node. + const nodeStatKeys = useMemo( + () => new Set(nodeSamples.map((s) => s.metric)), + [nodeSamples], + ); + + // Combined, de-duplicated stat list shown in the dropdown. + const statKeys = useMemo( + () => Array.from(new Set([...telemetryStatKeys, ...nodeStatKeys])).sort(), + [telemetryStatKeys, nodeStatKeys], + ); + + // Keep the selected stat valid as the node (and thus available stats) change. + useEffect(() => { + if (statKeys.length === 0) { + if (selectedStat) setSelectedStat(""); + return; + } + if (!statKeys.includes(selectedStat)) { + setSelectedStat(statKeys[0] ?? ""); + } + }, [statKeys, selectedStat]); + + // Build the time series for the selected stat. Telemetry-packet fields come + // from the telemetry readings; anything else is a recorded node metric. + const series = useMemo(() => { + if (!selectedStat) return []; + if (telemetryStatKeys.has(selectedStat)) { + return readings + .filter((r) => + isNumericLike((r.value as Record)?.[selectedStat]), + ) + .map((r) => ({ + time: r.time.getTime(), + value: toNumber((r.value as Record)[selectedStat]), + })) + .sort((a, b) => a.time - b.time); + } + return nodeSamples + .filter((s) => s.metric === selectedStat) + .map((s) => ({ time: s.time.getTime(), value: s.value })) + .sort((a, b) => a.time - b.time); + }, [readings, nodeSamples, selectedStat, telemetryStatKeys]); + + const statLabel = selectedStat + ? translateStatName(selectedStat) + : "Telemetry"; + return ( }>

Telemetry Data

-
- - +
+
+ + +
+
+ + +
- + +
+ + Raw telemetry rows for{" "} + {selectedNodeInfo ? nodeDisplayName(selectedNodeInfo) : "—"} ( + {readings.length}) + +
+            {readings.length === 0
+              ? "No telemetry stored for this node yet."
+              : readings
+                  .map(
+                    (r) =>
+                      `${r.time.toISOString()}  ${r.kind}\n${JSON.stringify(
+                        r.value,
+                        jsonReplacer,
+                        2,
+                      )}`,
+                  )
+                  .join("\n\n")}
+          
+
); diff --git a/apps/web/src/pages/Telemetry/statNames.ts b/apps/web/src/pages/Telemetry/statNames.ts new file mode 100644 index 000000000..aad34f37a --- /dev/null +++ b/apps/web/src/pages/Telemetry/statNames.ts @@ -0,0 +1,137 @@ +/** + * Human-readable labels for telemetry stat fields. + * + * Keys are the camelCase protobuf field names as they appear on decoded + * telemetry payloads (e.g. `numRxDupe`, `uptimeSeconds`). Fields that are + * shared across several telemetry kinds (uptimeSeconds, channelUtilization, + * airUtilTx, temperature, voltage, current) map to a single label since the + * meaning is the same in every kind. + * + * Sourced from meshtastic/telemetry.proto. + */ +const STAT_DISPLAY_NAMES: Record = { + // DeviceMetrics + batteryLevel: "Battery Level (%)", + voltage: "Voltage (V)", + channelUtilization: "Channel Utilization (%)", + airUtilTx: "Air Utilization TX (%)", + uptimeSeconds: "Uptime (s)", + + // EnvironmentMetrics + temperature: "Temperature (°C)", + relativeHumidity: "Relative Humidity (%)", + barometricPressure: "Barometric Pressure (hPa)", + gasResistance: "Gas Resistance (MΩ)", + current: "Current (A)", + iaq: "IAQ", + distance: "Distance (mm)", + lux: "Illuminance (lux)", + whiteLux: "White Light (lux)", + irLux: "Infrared (lux)", + uvLux: "Ultraviolet (lux)", + windDirection: "Wind Direction (°)", + windSpeed: "Wind Speed (m/s)", + weight: "Weight (kg)", + windGust: "Wind Gust (m/s)", + windLull: "Wind Lull (m/s)", + radiation: "Radiation (µR/h)", + rainfall1h: "Rainfall — 1h (mm)", + rainfall24h: "Rainfall — 24h (mm)", + soilMoisture: "Soil Moisture (%)", + soilTemperature: "Soil Temperature (°C)", + oneWireTemperature: "One-Wire Temperature (°C)", + + // PowerMetrics + ch1Voltage: "Channel 1 Voltage (V)", + ch1Current: "Channel 1 Current (A)", + ch2Voltage: "Channel 2 Voltage (V)", + ch2Current: "Channel 2 Current (A)", + ch3Voltage: "Channel 3 Voltage (V)", + ch3Current: "Channel 3 Current (A)", + ch4Voltage: "Channel 4 Voltage (V)", + ch4Current: "Channel 4 Current (A)", + ch5Voltage: "Channel 5 Voltage (V)", + ch5Current: "Channel 5 Current (A)", + ch6Voltage: "Channel 6 Voltage (V)", + ch6Current: "Channel 6 Current (A)", + ch7Voltage: "Channel 7 Voltage (V)", + ch7Current: "Channel 7 Current (A)", + ch8Voltage: "Channel 8 Voltage (V)", + ch8Current: "Channel 8 Current (A)", + + // AirQualityMetrics + pm10Standard: "PM1.0 Standard (µg/m³)", + pm25Standard: "PM2.5 Standard (µg/m³)", + pm100Standard: "PM10.0 Standard (µg/m³)", + pm10Environmental: "PM1.0 Environmental (µg/m³)", + pm25Environmental: "PM2.5 Environmental (µg/m³)", + pm100Environmental: "PM10.0 Environmental (µg/m³)", + particles03um: "Particles >0.3µm (#/0.1L)", + particles05um: "Particles >0.5µm (#/0.1L)", + particles10um: "Particles >1.0µm (#/0.1L)", + particles25um: "Particles >2.5µm (#/0.1L)", + particles50um: "Particles >5.0µm (#/0.1L)", + particles100um: "Particles >10.0µm (#/0.1L)", + co2: "CO₂ (ppm)", + co2Temperature: "CO₂ Sensor Temp (°C)", + co2Humidity: "CO₂ Sensor Humidity (%)", + formFormaldehyde: "Formaldehyde (ppb)", + formHumidity: "Formaldehyde Sensor Humidity (%)", + formTemperature: "Formaldehyde Sensor Temp (°C)", + pm40Standard: "PM4.0 Standard (µg/m³)", + particles40um: "Particles >4.0µm (#/0.1L)", + pmTemperature: "PM Sensor Temp (°C)", + pmHumidity: "PM Sensor Humidity (%)", + pmVocIdx: "PM VOC Index", + pmNoxIdx: "PM NOx Index", + particlesTps: "Typical Particle Size (µm)", + + // LocalStats + numPacketsTx: "Packets Sent", + numPacketsRx: "Packets Received", + numPacketsRxBad: "Bad Packets Received", + numOnlineNodes: "Nodes Online", + numTotalNodes: "Total Nodes", + numRxDupe: "Duplicate Packets Received", + numTxRelay: "Packets Relayed", + numTxRelayCanceled: "Relays Canceled", + heapTotalBytes: "Heap Total (bytes)", + heapFreeBytes: "Heap Free (bytes)", + numTxDropped: "Packets Dropped — TX Queue Full", + noiseFloor: "Noise Floor (dBm)", + + // TrafficManagementStats + packetsInspected: "Packets Inspected", + positionDedupDrops: "Position Dedup Drops", + nodeinfoCacheHits: "NodeInfo Cache Hits", + rateLimitDrops: "Rate Limit Drops", + unknownPacketDrops: "Unknown Packet Drops", + hopExhaustedPackets: "Hop-Exhausted Packets", + routerHopsPreserved: "Router Hops Preserved", + + // HealthMetrics + heartBpm: "Heart Rate (bpm)", + spO2: "SpO₂ (%)", + + // HostMetrics + freememBytes: "Free Memory (bytes)", + diskfree1Bytes: "Disk Free — / (bytes)", + diskfree2Bytes: "Disk Free — 2 (bytes)", + diskfree3Bytes: "Disk Free — 3 (bytes)", + load1: "Load Average (1m)", + load5: "Load Average (5m)", + load15: "Load Average (15m)", + + // Node-level metrics (from the Nodes page, recorded in node_metrics) + snr: "SNR (dB)", + hopsAway: "Hops Away", + lastHeard: "Last Heard (epoch s)", +}; + +/** + * Translates a telemetry stat field name (e.g. `numRxDupe`) to a human-readable + * label. Falls back to the original name when no translation is available. + */ +export function translateStatName(name: string): string { + return STAT_DISPLAY_NAMES[name] ?? name; +} diff --git a/packages/sdk-storage-sqlocal/mod.ts b/packages/sdk-storage-sqlocal/mod.ts index 795cb29c5..ef2fb7592 100644 --- a/packages/sdk-storage-sqlocal/mod.ts +++ b/packages/sdk-storage-sqlocal/mod.ts @@ -10,3 +10,9 @@ export { SqlocalNodesRepository } from "./src/nodes/index.ts"; export type { SqlocalNodesRepositoryOptions } from "./src/nodes/index.ts"; export { SqlocalTelemetryRepository } from "./src/telemetry/index.ts"; export type { SqlocalTelemetryRepositoryOptions } from "./src/telemetry/index.ts"; +export { SqlocalNodeMetricsRepository } from "./src/nodeMetrics/index.ts"; +export type { + SqlocalNodeMetricsRepositoryOptions, + NodeMetricSample, + NodeMetricsRetentionPolicy, +} from "./src/nodeMetrics/index.ts"; diff --git a/packages/sdk-storage-sqlocal/package.json b/packages/sdk-storage-sqlocal/package.json index c741cb1e4..92c93fb09 100644 --- a/packages/sdk-storage-sqlocal/package.json +++ b/packages/sdk-storage-sqlocal/package.json @@ -23,6 +23,7 @@ "./chat": "./src/chat/index.ts", "./nodes": "./src/nodes/index.ts", "./telemetry": "./src/telemetry/index.ts", + "./nodeMetrics": "./src/nodeMetrics/index.ts", "./schema": "./src/schema/index.ts", "./testing": "./src/testing/index.ts" }, diff --git a/packages/sdk-storage-sqlocal/src/nodeMetrics/SqlocalNodeMetricsRepository.ts b/packages/sdk-storage-sqlocal/src/nodeMetrics/SqlocalNodeMetricsRepository.ts new file mode 100644 index 000000000..450ad8cd0 --- /dev/null +++ b/packages/sdk-storage-sqlocal/src/nodeMetrics/SqlocalNodeMetricsRepository.ts @@ -0,0 +1,175 @@ +import { and, count, desc, eq, lt, sql } from "drizzle-orm"; +import type { SqlocalDb } from "../db.ts"; +import { nodeMetrics } from "../schema/nodeMetrics.ts"; + +export interface SqlocalNodeMetricsRepositoryOptions { + /** Identifies the connection (matches MeshRegistry ConnectionId). */ + deviceId: number; +} + +/** A single per-node metric sample. */ +export interface NodeMetricSample { + nodeNum: number; + /** camelCase metric name, e.g. "snr", "hopsAway", "lastHeard". */ + metric: string; + time: Date; + value: number; +} + +/** Retention policy for node-metric samples. */ +export interface NodeMetricsRetentionPolicy { + /** Drop samples older than this many milliseconds. */ + olderThanMs?: number; + /** Keep at most this many samples per (node, metric). */ + maxPerMetric?: number; +} + +/** + * Persists per-node metric time series (SNR, hops away, last heard, …) that + * are not carried by Telemetry packets. Scoped by `deviceId` so history from + * different connections never mixes. + */ +export class SqlocalNodeMetricsRepository { + private readonly db: SqlocalDb; + private readonly deviceId: number; + + constructor(db: SqlocalDb, options: SqlocalNodeMetricsRepositoryOptions) { + this.db = db; + this.deviceId = options.deviceId; + } + + async append(sample: NodeMetricSample): Promise { + await this.appendBatch([sample]); + } + + async appendBatch(samples: ReadonlyArray): Promise { + if (samples.length === 0) return; + const rows = samples.map((s) => ({ + deviceId: this.deviceId, + nodeNum: s.nodeNum, + metric: s.metric, + ts: s.time.getTime(), + value: s.value, + })); + await this.db.insert(nodeMetrics).values(rows); + } + + /** Most recent `limit` samples for a node, across all metrics, ascending. */ + async loadRecent( + nodeNum: number, + limit: number, + ): Promise { + const rows = await this.db + .select() + .from(nodeMetrics) + .where( + and( + eq(nodeMetrics.deviceId, this.deviceId), + eq(nodeMetrics.nodeNum, nodeNum), + )!, + ) + .orderBy(desc(nodeMetrics.ts)) + .limit(limit); + return rows.map(rowToSample).reverse(); + } + + /** The distinct metric names recorded for a node. */ + async metricsFor(nodeNum: number): Promise { + const rows = await this.db + .selectDistinct({ metric: nodeMetrics.metric }) + .from(nodeMetrics) + .where( + and( + eq(nodeMetrics.deviceId, this.deviceId), + eq(nodeMetrics.nodeNum, nodeNum), + )!, + ); + return rows.map((r) => r.metric).sort(); + } + + async prune(policy: NodeMetricsRetentionPolicy): Promise { + if (policy.olderThanMs !== undefined) { + const cutoff = Date.now() - policy.olderThanMs; + await this.db + .delete(nodeMetrics) + .where( + and( + eq(nodeMetrics.deviceId, this.deviceId), + lt(nodeMetrics.ts, cutoff), + )!, + ); + } + if (policy.maxPerMetric !== undefined) { + const max = policy.maxPerMetric; + // Trim each over-cap (node, metric) bucket down to the newest `max` rows. + const overCap = await this.db + .select({ + nodeNum: nodeMetrics.nodeNum, + metric: nodeMetrics.metric, + c: count(), + }) + .from(nodeMetrics) + .where(eq(nodeMetrics.deviceId, this.deviceId)) + .groupBy(nodeMetrics.nodeNum, nodeMetrics.metric) + .having(sql`count(*) > ${max}`); + for (const row of overCap) { + const cutoffRows = await this.db + .select({ ts: nodeMetrics.ts }) + .from(nodeMetrics) + .where( + and( + eq(nodeMetrics.deviceId, this.deviceId), + eq(nodeMetrics.nodeNum, row.nodeNum), + eq(nodeMetrics.metric, row.metric), + )!, + ) + .orderBy(desc(nodeMetrics.ts)) + .limit(1) + .offset(max - 1); + const cutoff = cutoffRows[0]?.ts; + if (cutoff === undefined) continue; + await this.db + .delete(nodeMetrics) + .where( + and( + eq(nodeMetrics.deviceId, this.deviceId), + eq(nodeMetrics.nodeNum, row.nodeNum), + eq(nodeMetrics.metric, row.metric), + lt(nodeMetrics.ts, cutoff), + )!, + ); + } + } + } + + async clearNode(nodeNum: number): Promise { + await this.db + .delete(nodeMetrics) + .where( + and( + eq(nodeMetrics.deviceId, this.deviceId), + eq(nodeMetrics.nodeNum, nodeNum), + )!, + ); + } + + async clear(): Promise { + await this.db + .delete(nodeMetrics) + .where(eq(nodeMetrics.deviceId, this.deviceId)); + } +} + +function rowToSample(row: { + nodeNum: number; + metric: string; + ts: number; + value: number; +}): NodeMetricSample { + return { + nodeNum: row.nodeNum, + metric: row.metric, + time: new Date(row.ts), + value: row.value, + }; +} diff --git a/packages/sdk-storage-sqlocal/src/nodeMetrics/index.ts b/packages/sdk-storage-sqlocal/src/nodeMetrics/index.ts new file mode 100644 index 000000000..b38fdaa64 --- /dev/null +++ b/packages/sdk-storage-sqlocal/src/nodeMetrics/index.ts @@ -0,0 +1,6 @@ +export { + SqlocalNodeMetricsRepository, + type SqlocalNodeMetricsRepositoryOptions, + type NodeMetricSample, + type NodeMetricsRetentionPolicy, +} from "./SqlocalNodeMetricsRepository.ts"; diff --git a/packages/sdk-storage-sqlocal/src/schema/index.ts b/packages/sdk-storage-sqlocal/src/schema/index.ts index e0d60a346..151490837 100644 --- a/packages/sdk-storage-sqlocal/src/schema/index.ts +++ b/packages/sdk-storage-sqlocal/src/schema/index.ts @@ -2,4 +2,5 @@ export * from "./chat.ts"; export * from "./drafts.ts"; export * from "./nodes.ts"; export * from "./telemetry.ts"; +export * from "./nodeMetrics.ts"; export * from "./migrations.ts"; diff --git a/packages/sdk-storage-sqlocal/src/schema/migrations.test.ts b/packages/sdk-storage-sqlocal/src/schema/migrations.test.ts index b69f212b8..ede9835b7 100644 --- a/packages/sdk-storage-sqlocal/src/schema/migrations.test.ts +++ b/packages/sdk-storage-sqlocal/src/schema/migrations.test.ts @@ -53,4 +53,27 @@ describe("MIGRATIONS", () => { for (const stmt of MIGRATIONS[0]!.sql) db.run(stmt); }).not.toThrow(); }); + + it("applying the full chain creates the node_metrics table + index", async () => { + const db = await freshSqlite(); + for (const migration of MIGRATIONS) { + for (const stmt of migration.sql) db.run(stmt); + } + + const tables = db + .exec( + "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name", + )[0] + ?.values.flat() as string[]; + expect(tables).toEqual(expect.arrayContaining(["node_metrics"])); + + const indexes = db + .exec( + "SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='node_metrics'", + )[0] + ?.values.flat() as string[]; + expect(indexes).toEqual( + expect.arrayContaining(["idx_node_metrics_node_metric_ts"]), + ); + }); }); diff --git a/packages/sdk-storage-sqlocal/src/schema/migrations.ts b/packages/sdk-storage-sqlocal/src/schema/migrations.ts index 7bb6343ea..9aa2b6687 100644 --- a/packages/sdk-storage-sqlocal/src/schema/migrations.ts +++ b/packages/sdk-storage-sqlocal/src/schema/migrations.ts @@ -65,4 +65,18 @@ export const MIGRATIONS: ReadonlyArray<{ version: number; sql: string[] }> = [ )`, ], }, + { + version: 3, + sql: [ + `CREATE TABLE IF NOT EXISTS node_metrics ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + device_id INTEGER NOT NULL, + node_num INTEGER NOT NULL, + metric TEXT NOT NULL, + ts INTEGER NOT NULL, + value REAL NOT NULL + )`, + `CREATE INDEX IF NOT EXISTS idx_node_metrics_node_metric_ts ON node_metrics(device_id, node_num, metric, ts)`, + ], + }, ]; diff --git a/packages/sdk-storage-sqlocal/src/schema/nodeMetrics.ts b/packages/sdk-storage-sqlocal/src/schema/nodeMetrics.ts new file mode 100644 index 000000000..c286d67b2 --- /dev/null +++ b/packages/sdk-storage-sqlocal/src/schema/nodeMetrics.ts @@ -0,0 +1,40 @@ +import { + index, + integer, + real, + sqliteTable, + text, +} from "drizzle-orm/sqlite-core"; + +/** + * Time series of per-node metrics that are NOT carried by Telemetry packets — + * the node-level values surfaced on the Nodes page (SNR, hops away, last + * heard). Telemetry-packet metrics (battery, voltage, etc.) live in the + * `telemetry` table; this table complements it so the Telemetry page can chart + * every metric a node exposes. + * + * `metric` is the camelCase field name (e.g. "snr", "hopsAway", "lastHeard"). + * `value` is stored as a REAL since these span integers and floats. + */ +export const nodeMetrics = sqliteTable( + "node_metrics", + { + id: integer("id").primaryKey({ autoIncrement: true }), + deviceId: integer("device_id").notNull(), + nodeNum: integer("node_num").notNull(), + metric: text("metric").notNull(), + ts: integer("ts").notNull(), + value: real("value").notNull(), + }, + (t) => ({ + nodeMetricTs: index("idx_node_metrics_node_metric_ts").on( + t.deviceId, + t.nodeNum, + t.metric, + t.ts, + ), + }), +); + +export type NodeMetricRow = typeof nodeMetrics.$inferSelect; +export type NodeMetricInsert = typeof nodeMetrics.$inferInsert;