Skip to content

Telemetry UI - #1361

Open
Hunter275 wants to merge 3 commits into
meshtastic:mainfrom
Hunter275:telemetry-ui
Open

Telemetry UI#1361
Hunter275 wants to merge 3 commits into
meshtastic:mainfrom
Hunter275:telemetry-ui

Conversation

@Hunter275

@Hunter275 Hunter275 commented Jul 28, 2026

Copy link
Copy Markdown
Member

Description

Adds Telemetry UI (#1244)

Related Issues

Changes Made

  • Added Telemetry page
  • Added D3 graph with node selection dropdown and stat selection dropdown
  • Added ability to see raw JSON telemetry as an expandable div

Testing Done

Screenshots (if applicable)

image

Checklist

  • Code follows project style guidelines
  • Documentation has been updated or added
  • Tests have been added or updated
  • All i18n translation labels have been added (read
    CONTRIBUTING_I18N_DEVELOPER_GUIDE.md for more details)

Summary by CodeRabbit

  • New Features
    • Added a Telemetry page with node and metric selection, interactive time-series charts, tooltips, and expandable raw data tables.
    • Added navigation to Telemetry from the sidebar.
    • Added Bluetooth configuration controls, including enablement, pairing mode, and PIN settings.
    • Added collection and local storage of node metrics for historical analysis.
    • Added readable labels for telemetry statistics.
  • Chores
    • Added development and production build commands for the web application.

@vercel

vercel Bot commented Jul 28, 2026

Copy link
Copy Markdown

Someone is attempting to deploy a commit to the Meshtastic Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds node-metric persistence and recording, a telemetry page with D3 charts and raw readings, telemetry navigation and routing, plus a Bluetooth configuration form.

Changes

Telemetry metrics flow

Layer / File(s) Summary
Node metrics storage
packages/sdk-storage-sqlocal/src/schema/*, packages/sdk-storage-sqlocal/src/nodeMetrics/*, packages/sdk-storage-sqlocal/mod.ts, packages/sdk-storage-sqlocal/package.json
Adds the node_metrics schema, migration, retention-aware repository operations, migration coverage, and public exports.
Mesh metric recording
apps/web/src/core/connections/nodeMetricsRecorder.ts, apps/web/src/core/connections/sdkClient.ts
Records packet and node-info metrics, periodically prunes stored samples, and attaches cleanup to device disconnection.
Telemetry page and navigation
apps/web/src/pages/Telemetry/*, apps/web/src/routes.tsx, apps/web/src/components/Sidebar.tsx, apps/web/src/core/stores/deviceStore/types.ts, apps/web/package.json, package.json
Adds D3-based charts, metric selection, raw telemetry display, stat labels, the /telemetry route, sidebar navigation, and development scripts.

Bluetooth configuration

Layer / File(s) Summary
Bluetooth configuration form
apps/web/src/components/PageComponents/Telemetry/Battery.tsx
Adds a Bluetooth form for enabled state, pairing mode, and fixed PIN values, submitting through the radio configuration editor.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant TelemetryPage
  participant MeshClient
  participant SqlocalNodeMetricsRepository
  participant TelemetryChart
  TelemetryPage->>MeshClient: read telemetry history
  TelemetryPage->>SqlocalNodeMetricsRepository: load recent node metrics
  TelemetryPage->>TelemetryChart: provide selected series
  TelemetryChart-->>TelemetryPage: render chart and tooltip
Loading

Suggested labels: dependencies, javascript

Suggested reviewers: danditomaso

Poem

I’m a rabbit with charts in my burrow tonight,
Metrics hop neatly from signal to light.
Bluetooth buttons now wiggle with cheer,
And telemetry trails grow crisp and clear.
Fluffy commits, neatly done—
Every data point joins the fun!

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 60.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check ❓ Inconclusive The title is generic and doesn't clearly identify the main change beyond a broad Telemetry UI update. Use a more specific title like "Add Telemetry page with node/stat charts and raw JSON view".
✅ Passed checks (3 passed)
Check name Status Explanation
Description check ✅ Passed The PR description matches the template and covers the feature, changes, and screenshots, though the testing section is empty.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🔧 Fix failing CI
  • Fix failing CI in branch telemetry-ui
⚔️ Resolve merge conflicts
  • Resolve merge conflict in branch telemetry-ui

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@vercel

vercel Bot commented Jul 28, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
web-test Ready Ready Preview, Comment Jul 28, 2026 10:01pm

Request Review

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (7)
apps/web/package.json (1)

68-68: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Move @types/d3 to devDependencies.

Type-only package; it ships nothing at runtime and inflates the production dependency set.

♻️ Suggested change
-    "`@types/d3`": "^7.4.3",

Then add it under devDependencies:

"`@types/d3`": "^7.4.3",
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/package.json` at line 68, Move the `@types/d3` entry from dependencies
to devDependencies in the package manifest, preserving its version constraint
and ensuring it appears only once.
apps/web/src/pages/Telemetry/statNames.ts (1)

135-137: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

Object-literal lookup can return inherited members.

Keys come from DB metric values / decoded payload fields; a key like constructor or toString resolves through the prototype chain and ?? won't catch it, so a non-string is returned. Use Object.hasOwn or a Map.

🛡️ Suggested change
 export function translateStatName(name: string): string {
-  return STAT_DISPLAY_NAMES[name] ?? name;
+  return Object.hasOwn(STAT_DISPLAY_NAMES, name)
+    ? STAT_DISPLAY_NAMES[name]
+    : name;
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/pages/Telemetry/statNames.ts` around lines 135 - 137, Update
translateStatName to restrict STAT_DISPLAY_NAMES lookups to own properties,
using Object.hasOwn before returning the mapped value; otherwise return the
original name. Preserve the existing fallback behavior for unknown metric names,
including prototype keys such as constructor and toString.
apps/web/src/pages/Telemetry/index.tsx (4)

529-542: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Raw-row string is built on every render even while the <details> is collapsed.

readings can hold thousands of rows; the map/JSON.stringify/join runs on each render regardless of whether the section is open. Memoize it and gate on the open state.

♻️ Suggested change
+  const [rawOpen, setRawOpen] = useState(false);
+  const rawText = useMemo(
+    () =>
+      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"),
+    [readings],
+  );
-        <details className="mt-6">
+        <details
+          className="mt-6"
+          open={rawOpen}
+          onToggle={(e) => setRawOpen(e.currentTarget.open)}
+        >
           <pre className="text-xs bg-slate-900 text-slate-100 p-4 rounded-lg overflow-auto max-h-96 mt-2">
-            {readings.length === 0
-              ? "No telemetry stored for this node yet."
-              : readings
-                  .map(...)
-                  .join("\n\n")}
+            {rawOpen ? rawText : null}
           </pre>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/pages/Telemetry/index.tsx` around lines 529 - 542, Update the
telemetry details rendering around the readings display to memoize the raw-row
string and only compute it when the `<details>` section is open. Reuse the
memoized value in the `<pre>` output while preserving the existing empty-state
message and formatting for non-empty readings.

474-528: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

New Telemetry UI strings bypass i18n. Both new surfaces hardcode English while the surrounding app resolves copy through useTranslation, so the Telemetry feature stays untranslated in every locale.

  • apps/web/src/pages/Telemetry/index.tsx#L474-L528: route "Telemetry Data", "Select Node", "Select Stat", the two select placeholders, and the raw-rows header through t(...) with new translation keys.
  • apps/web/src/components/Sidebar.tsx#L135-L139: replace the literal "Telemetry" with t("navigation.telemetry") and add the key to the ui namespace resources.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/pages/Telemetry/index.tsx` around lines 474 - 528, Update
apps/web/src/pages/Telemetry/index.tsx lines 474-528 to obtain useTranslation
and route “Telemetry Data,” “Select Node,” “Select Stat,” both select
placeholders, and the raw-rows header through t(...) using new translation keys.
Update apps/web/src/components/Sidebar.tsx lines 135-139 to use
t("navigation.telemetry") instead of the literal label, and add that key to the
ui namespace resources.

264-268: 🔒 Security & Privacy | 🔵 Trivial | 💤 Low value

Prefer composed elements over .html() for the tooltip.

label originates from node_metrics.metric / decoded payload keys, so it isn't a trusted literal in all paths. Building nodes with .text() removes the injection surface entirely without changing appearance.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/pages/Telemetry/index.tsx` around lines 264 - 268, Replace the
tooltip construction in the chart callback using .html() with composed DOM
elements and .text() for label, valueFmt(point.value), and dateFmt(new
Date(point.time)). Preserve the existing styling, element order, and visual
appearance while ensuring decoded metric labels are rendered as text rather than
interpreted as HTML.

44-59: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Chart won't re-measure when the container resizes without a window resize.

Sidebar collapse/expand and layout changes resize the container without firing resize, so the SVG keeps drawing at stale width (no viewBox, so content is clipped or leaves dead space). A ResizeObserver covers both cases.

♻️ Suggested change
-    handleResize();
-    window.addEventListener("resize", handleResize);
-    return () => window.removeEventListener("resize", handleResize);
+    handleResize();
+    const el = containerRef.current;
+    if (!el) return;
+    const observer = new ResizeObserver(handleResize);
+    observer.observe(el);
+    return () => observer.disconnect();
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/pages/Telemetry/index.tsx` around lines 44 - 59, Update the
dimensions measurement effect in the Telemetry page to observe
containerRef.current with a ResizeObserver, invoking the existing handleResize
whenever the container changes size. Keep the initial measurement and cleanup
behavior, and disconnect the observer during effect cleanup while retaining the
window resize listener if needed.
apps/web/src/routes.tsx (1)

7-7: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Lazy-load the telemetry route.

TelemetryPage imports d3 directly, but the route component is eagerly referenced in apps/web/src/routes.tsx:151. Use a code-split loader here (lazyRouteComponent(...), lazy: ..., or React.lazy) so the chart library isn’t included until /telemetry is opened.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/routes.tsx` at line 7, Update the telemetry route configuration
and its TelemetryPage import so the page is loaded through the project’s lazy
route mechanism (such as lazyRouteComponent, a lazy route loader, or React.lazy)
rather than eagerly referencing the component. Preserve the existing /telemetry
behavior while deferring the d3-containing TelemetryPage module until that route
is opened.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/web/src/components/PageComponents/Telemetry/Battery.tsx`:
- Around line 51-81: Update BluetoothValidationSchema so fixedPin is optional
for RANDOM_PIN and NO_PIN but required as a six-digit PIN when mode is
FIXED_PIN, while preserving the existing Bluetooth validation rules. Apply the
same conditional schema and UI behavior to the Bluetooth Settings form, using
the mode field and fixedPin configuration symbols already shared by the
Bluetooth forms.

In `@apps/web/src/pages/Telemetry/index.tsx`:
- Around line 342-350: Update nodeDisplayName to format node.num as exactly 8
lowercase hexadecimal characters, padding leading zeros as needed; use this
canonical value for both the fallback name’s hex suffix and the returned !node
ID while preserving the existing uppercase presentation of the fallback suffix.

In
`@packages/sdk-storage-sqlocal/src/nodeMetrics/SqlocalNodeMetricsRepository.ts`:
- Around line 102-128: Validate policy.maxPerMetric before using it to derive
the query offset in the retention logic: accept only a finite, non-negative
integer, or explicitly implement and preserve the documented behavior that zero
clears each bucket. Reject invalid values such as negatives, fractions, and
Infinity before the overCap query and pruning loop run.

---

Nitpick comments:
In `@apps/web/package.json`:
- Line 68: Move the `@types/d3` entry from dependencies to devDependencies in the
package manifest, preserving its version constraint and ensuring it appears only
once.

In `@apps/web/src/pages/Telemetry/index.tsx`:
- Around line 529-542: Update the telemetry details rendering around the
readings display to memoize the raw-row string and only compute it when the
`<details>` section is open. Reuse the memoized value in the `<pre>` output
while preserving the existing empty-state message and formatting for non-empty
readings.
- Around line 474-528: Update apps/web/src/pages/Telemetry/index.tsx lines
474-528 to obtain useTranslation and route “Telemetry Data,” “Select Node,”
“Select Stat,” both select placeholders, and the raw-rows header through t(...)
using new translation keys. Update apps/web/src/components/Sidebar.tsx lines
135-139 to use t("navigation.telemetry") instead of the literal label, and add
that key to the ui namespace resources.
- Around line 264-268: Replace the tooltip construction in the chart callback
using .html() with composed DOM elements and .text() for label,
valueFmt(point.value), and dateFmt(new Date(point.time)). Preserve the existing
styling, element order, and visual appearance while ensuring decoded metric
labels are rendered as text rather than interpreted as HTML.
- Around line 44-59: Update the dimensions measurement effect in the Telemetry
page to observe containerRef.current with a ResizeObserver, invoking the
existing handleResize whenever the container changes size. Keep the initial
measurement and cleanup behavior, and disconnect the observer during effect
cleanup while retaining the window resize listener if needed.

In `@apps/web/src/pages/Telemetry/statNames.ts`:
- Around line 135-137: Update translateStatName to restrict STAT_DISPLAY_NAMES
lookups to own properties, using Object.hasOwn before returning the mapped
value; otherwise return the original name. Preserve the existing fallback
behavior for unknown metric names, including prototype keys such as constructor
and toString.

In `@apps/web/src/routes.tsx`:
- Line 7: Update the telemetry route configuration and its TelemetryPage import
so the page is loaded through the project’s lazy route mechanism (such as
lazyRouteComponent, a lazy route loader, or React.lazy) rather than eagerly
referencing the component. Preserve the existing /telemetry behavior while
deferring the d3-containing TelemetryPage module until that route is opened.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 3a90460c-07aa-46cb-84a4-db43ee7296fe

📥 Commits

Reviewing files that changed from the base of the PR and between 5b10d39 and 5aadccd.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (18)
  • apps/web/package.json
  • apps/web/src/components/PageComponents/Telemetry/Battery.tsx
  • apps/web/src/components/Sidebar.tsx
  • apps/web/src/core/connections/nodeMetricsRecorder.ts
  • apps/web/src/core/connections/sdkClient.ts
  • apps/web/src/core/stores/deviceStore/types.ts
  • apps/web/src/pages/Telemetry/index.tsx
  • apps/web/src/pages/Telemetry/statNames.ts
  • apps/web/src/routes.tsx
  • package.json
  • packages/sdk-storage-sqlocal/mod.ts
  • packages/sdk-storage-sqlocal/package.json
  • packages/sdk-storage-sqlocal/src/nodeMetrics/SqlocalNodeMetricsRepository.ts
  • packages/sdk-storage-sqlocal/src/nodeMetrics/index.ts
  • packages/sdk-storage-sqlocal/src/schema/index.ts
  • packages/sdk-storage-sqlocal/src/schema/migrations.test.ts
  • packages/sdk-storage-sqlocal/src/schema/migrations.ts
  • packages/sdk-storage-sqlocal/src/schema/nodeMetrics.ts

Comment on lines +51 to +81
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"),
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Files matching Battery.tsx/config Bluetooth:"
fd -a 'Battery\.tsx|config\.proto|Bluetooth' . | sed 's#^\./##' | head -200

echo
echo "Battery outline:"
file=$(fd 'Battery\.tsx' . | head -1)
if [ -n "${file:-}" ]; then
  echo "FILE=$file"
  ast-grep outline "$file" --view expanded || true
  echo
  wc -l "$file"
  sed -n '1,140p' "$file" | cat -n
fi

echo
echo "Search BluetoothValidationSchema and pairing mode usages:"
rg -n "BluetoothValidationSchema|fixedPin|pairingMode|PairingMode|NO_PIN|RANDOM_PIN|FIXED_PIN" .

Repository: meshtastic/web

Length of output: 9816


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Bluetooth validation source:"
sed -n '1,80p' apps/web/src/validation/config/bluetooth.ts | cat -n
echo

echo "Bluetooth component source:"
sed -n '45,85p' apps/web/src/components/PageComponents/Telemetry/Battery.tsx | cat -n
echo

echo "Protobuf BluetoothConfig section:"
sed -n '1250,1290p' packages/protobufs/meshtastic/config.proto | cat -n
echo

echo "DynamicForm field handling:"
fd 'DynamicForm\.(ts|tsx)$' . -x sh -c 'echo "FILE=$1"; sed -n "1,220p" "$1" | cat -n' sh {}

Repository: meshtastic/web

Length of output: 10314


🌐 Web query:

Zod conditional required field requiredIf exact match mode conditional optional based on another field value

💡 Result:

Zod does not have a native "requiredIf" method [1][2]. To enforce that a field is required based on the value of another field, you should use either superRefine (or .check in newer versions) for cross-field validation, or a discriminatedUnion for stronger type inference [3][4][5]. 1. Using superRefine/check (Recommended for general conditional logic) This approach allows you to inspect the entire object and add custom issues to specific fields when conditions are not met [4][6]. const schema = z.object({ conditionField: z.boolean, dependentField: z.string.optional, }).superRefine((data, ctx) => { if (data.conditionField &&!data.dependentField) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "dependentField is required when conditionField is true", path: ["dependentField"], }); } }); 2. Using Discriminated Union (Recommended for stronger types) If your conditional logic relies on literal values, a discriminated union is preferred because it creates distinct types based on the discriminator field [3][5]. const schema = z.discriminatedUnion("type", [ z.object({ type: z.literal("A"), field: z.string }), z.object({ type: z.literal("B"), field: z.string.optional }), ]); 3. Controlling when validation runs By default, refinements may only run if the base schema is valid [2][7]. If you need your custom validation to run even when base fields fail (e.g., to report all errors at once), you can use the when parameter in superRefine or .refine, which accepts a callback to determine if the refinement should execute [8][7].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "read-only Zod v4 behavioral probe:"
python3 - <<'PY'
from pathlib import Path
p = Path("apps/web/src/validation/config/bluetooth.ts").read_text()
print("fixedPin base requirement present:", "fixedPin: z.coerce.number().int().min(100000).max(999999)" in p)
print("fixedPin conditional refs:", any(s in p for s in ("FIXED_PIN", "fixed_pin", "superRefine", "refine", "discriminatedUnion", ".when(")))
print("mode enum values:", "'RANDOM_PIN'" in p, "'FIXED_PIN'" in p, "'NO_PIN'" in p)
PY

echo
echo "check same Bluetooth form in Settings:"
sed -n '1,90p' apps/web/src/components/PageComponents/Settings/Bluetooth.tsx | cat -n

Repository: meshtastic/web

Length of output: 3656


Require fixedPin only for FIXED_PIN.

BluetoothValidationSchema requires a six-digit PIN for every Bluetooth mode, but the protobuf reserves fixedPin for FIXED_PIN; RANDOM_PIN/NO_PIN cannot be submitted without an ignored value. Make the PIN optional except when mode === FIXED_PIN, and apply the same schema UI behavior in the Bluetooth Settings form.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/components/PageComponents/Telemetry/Battery.tsx` around lines 51
- 81, Update BluetoothValidationSchema so fixedPin is optional for RANDOM_PIN
and NO_PIN but required as a six-digit PIN when mode is FIXED_PIN, while
preserving the existing Bluetooth validation rules. Apply the same conditional
schema and UI behavior to the Bluetooth Settings form, using the mode field and
fixedPin configuration symbols already shared by the Bluetooth forms.

Comment on lines +342 to +350
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})`;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# How does the rest of the app format node IDs? Compare for consistency.
rg -nP --type=ts --type=tsx -C2 'numberToHexUnpadded|padStart\(8, ?"0"\)|toString\(16\)' apps/web/src | head -80

Repository: meshtastic/web

Length of output: 184


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate target file =="
fd -a 'index.tsx$' apps/web/src/pages/Telemetry 2>/dev/null || true

echo "== target snippet =="
if [ -f apps/web/src/pages/Telemetry/index.tsx ]; then
  sed -n '1,80p;320,360p' apps/web/src/pages/Telemetry/index.tsx | cat -n
fi

echo "== search node ID formatting in apps/web/src =="
rg -n -C2 'numberToHexUnpadded|padStart\(8, ?"0"\)|toString\(16\)|!0[a-fA-F0-9]{8}|num' apps/web/src --glob '*.ts' --glob '*.tsx' | head -120

Repository: meshtastic/web

Length of output: 12499


🌐 Web query:

Meshtastic NodeID hex ID 8 digits zero pad example

💡 Result:

In Meshtastic, the "Node ID" displayed in many client applications is a user-friendly string representation of the underlying 32-bit Node Number (NodeNum) [1][2]. This string format consists of an exclamation mark (!) followed by 8 hexadecimal characters, which are zero-padded to ensure a fixed length [1][3][4]. Example format:!xxxxxxxx For instance, if a node has a NodeNum of 0x12345 (decimal 74565), its short-form Node ID would be:!00012345 Technical implementation notes: - Generation: In the firmware, this is typically generated using a format string such as "!%08x" [3][5]. - Purpose: This 8-character hex string is a convenience for UI display and is not used on the mesh wire protocol itself, which relies on the raw 32-bit integer [1]. - Standardization: Recent updates to the firmware have centralized this formatting logic into a method often called getNodeId to ensure consistency across different components of the system [6].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
def unpadded(n):
    return format(n & 0xFFFFFFFF, "x") or "0"

def padded(n):
    return format(n & 0xFFFFFFFF, "08x")

cases = [
    12345,
    0x00012345,
    0x05abc123,
    0xffffffff,
]
for n in cases:
    h = unpadded(n)
    print(f"{n:010d} 0x{n:08x}: unpadded={h!r} padded={padded(n)} fallback_short={h[-4:].upper()} padded_fallback={padded(n)[-4:].upper()}")
PY

Repository: meshtastic/web

Length of output: 544


Format node IDs with 8 lowercase hex digits.

numberToHexUnpadded omits leading zeros, so IDs below 0x10000000 render non-canonical strings like !5abc123; Meshtastic node ID display uses !xxxxxxxx. Use a fixed 8-char hex value for both the displayed ID and the !XXXX suffix.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/pages/Telemetry/index.tsx` around lines 342 - 350, Update
nodeDisplayName to format node.num as exactly 8 lowercase hexadecimal
characters, padding leading zeros as needed; use this canonical value for both
the fallback name’s hex suffix and the returned !node ID while preserving the
existing uppercase presentation of the fallback suffix.

Comment on lines +102 to +128
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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate file =="
git ls-files | rg 'SqlocalNodeMetricsRepository\.ts$' || true

echo "== file excerpt =="
file="$(git ls-files | rg 'packages/sdk-storage-sqlocal/src/nodeMetrics/SqlocalNodeMetricsRepository\.ts$' | head -n1)"
if [ -n "${file:-}" ]; then
  wc -l "$file"
  sed -n '1,180p' "$file" | cat -n
fi

echo "== search maxPerMetric usages =="
rg -n "maxPerMetric|nodeMetrics" packages/sdk-storage-sqlocal/src packages -g '*.ts' | head -n 200

Repository: meshtastic/web

Length of output: 16783


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== nodeMetrics schema =="
sed -n '1,60p' packages/sdk-storage-sqlocal/src/schema/nodeMetrics.ts.ts 2>/dev/null || sed -n '1,60p' packages/sdk-storage-sqlocal/src/schema/nodeMetrics.ts | cat -n

echo "== Sqlocal DB definition/Driver usages =="
rg -n "class SqlocalDb|type SqlocalDb|SqlocalDb|Drizzle|offset\\(|limit\\(" packages/sdk-storage-sqlocal/src -g '*.ts' | head -n 200

echo "== drizzle package versions if present =="
(fd -a 'package.json$' . | while read -r f; do
  echo "--- $f"
  jq -r '.dependencies.drizzle-orm // .devDependencies.drizzle-orm // "none"' "$f" 2>/dev/null || true
done)

echo "== static JS/SQL semantics probe for pruned offset expression =="
node - <<'JS'
const bad = [undefined, null, -1, 0, 1, 1.5, Infinity, 2e308, "0", 0n];
for (const raw of bad) {
  let max;
  try {
    max = raw == null ? undefined : Number(raw);
  } catch (e) {
    max = "ERR";
  }
  let rowsToKeep = undefined;
  let deleteCutoffTimestamp = undefined;
  if (max !== undefined) {
    const overCapCondition = Number(42) > max;
    if (overCapCondition) {
      rowsToKeep = max;
      deleteCutoffTimestamp = max - 1;
    }
  }
  console.log(JSON.stringify({ raw, parsed: max, overCapWith42: rowsToKeep !== undefined, deleteIfAboveFourtytwo: rowsToKeep, deleteCutoffTimestamp }));
}
JS

Repository: meshtastic/web

Length of output: 9073


🌐 Web query:

Drizzle sql offset invalid value SQLite behavior offset parameter

💡 Result:

In SQLite, using an OFFSET clause without a preceding LIMIT clause is a syntax error [1][2][3]. The SQLite language specification requires that if an OFFSET clause is present, it must follow a LIMIT clause [4][5]. When using Drizzle ORM with SQLite, this requirement means you cannot simply omit the LIMIT parameter if you wish to use an OFFSET [2][6]. Attempting to generate a query with only an OFFSET in SQLite will cause the database driver to throw a syntax error (e.g., "near OFFSET: syntax error") [2][3][7]. To use an OFFSET in SQLite without actually limiting the number of results, the standard workaround is to provide a negative value for the LIMIT clause [1][2]. In SQLite, a negative LIMIT indicates that there is no upper bound on the number of rows returned, effectively allowing the query to function as an "offset-only" operation [1][6][4]. For Drizzle ORM users, if you need this behavior, you can implement it by explicitly setting the limit to -1: // Example of offset without functional limit in SQLite await db.select.from(users).orderBy(users.id).limit(-1) // Required for valid SQLite syntax when using offset.offset(50); Some versions or configurations of Drizzle ORM have included fixes to automatically handle this requirement [1][6], but explicitly including.limit(-1) remains a reliable way to ensure the generated SQL remains valid across SQLite environments [2].

Citations:


🌐 Web query:

SQLocalDrizzle sqlocal offset limit SQLite

💡 Result:

When using Drizzle ORM with SQLocal (which serves as a driver for SQLite), you apply limit and offset pagination using the standard Drizzle ORM APIs [1][2][3]. Because SQLocal facilitates standard SQLite execution, you must adhere to SQLite's specific requirements regarding these clauses [4][5][6]. Core Considerations for SQLite Pagination: 1. Mandatory LIMIT with OFFSET: In SQLite, you cannot use an OFFSET clause without also including a LIMIT clause [4][5]. Attempting a query with only OFFSET will result in a syntax error [5]. 2. Handling OFFSET without a capped result set: If you need to skip a number of rows but want all subsequent rows (effectively no limit), you must provide a negative value for the LIMIT clause [5]. The standard practice in SQLite is to use LIMIT -1 [4][5]. Example Implementation: If you are using Drizzle's query builder (select syntax): await db.select.from(users).limit(10) // Number of rows to return [7].offset(20); // Number of rows to skip [8][7] For scenarios where you only need an offset (no upper bound), use -1 for the limit: await db.select.from(users).limit(-1) // Required for valid SQLite syntax when using offset [4][5].offset(50); If you are using Drizzle's relational query API (findMany): await db.query.users.findMany({ limit: 10, offset: 20, }); If you encounter issues where Drizzle's abstraction prevents a negative limit, you can use a placeholder to bypass validation [5]: await db.query.users.findMany({ offset: 20, limit: sql.placeholder('limit'), }).prepare.execute({ limit: -1 }); [5]

Citations:


Validate maxPerMetric before deriving the offset.

maxPerMetric can currently be any number, so values like 0, -1, 1.5, or infinity can produce incorrect/invalid retention SQL. Reject invalid values before pruning, or explicitly document that 0 clears each bucket.

Proposed guard
     if (policy.maxPerMetric !== undefined) {
       const max = policy.maxPerMetric;
+      if (!Number.isSafeInteger(max) || max < 1) {
+        throw new RangeError("maxPerMetric must be a positive integer");
+      }
       // Trim each over-cap (node, metric) bucket down to the newest `max` rows.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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);
if (policy.maxPerMetric !== undefined) {
const max = policy.maxPerMetric;
if (!Number.isSafeInteger(max) || max < 1) {
throw new RangeError("maxPerMetric must be a positive integer");
}
// 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);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/sdk-storage-sqlocal/src/nodeMetrics/SqlocalNodeMetricsRepository.ts`
around lines 102 - 128, Validate policy.maxPerMetric before using it to derive
the query offset in the retention logic: accept only a finite, non-negative
integer, or explicitly implement and preserve the documented behavior that zero
clears each bucket. Reject invalid values such as negatives, fractions, and
Infinity before the overCap query and pruning loop run.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant