feat(sdk,web): live connect-progress overlay (terminal-style log)

Surfaces what the connection handshake is doing in real time. Per DDD,
the predicate + counters live in the SDK; the web overlay is a thin
consumer of the signal.

SDK
- New `MeshClient.progress: ReadonlySignal<ConnectionProgress>` with the
  state machine `idle → configuring → configured`. Counters
  (config / modules / channels / nodes + boolean myInfo / metadata)
  tally inbound packets each `configure()` cycle.
- `configure()` resets to `configuring` with empty counters.
  `onConfigComplete` flips to `configured`.
- `ConnectionProgress` + `ConnectionProgressCounters` exported from the
  package surface.
- 6 new tests in `MeshClient.progress.test.ts`.

sdk-react
- `useConnectionProgress()` hook reads the active client's signal,
  returns `{ phase: "idle" }` when there is no active client so the
  caller can render unconditionally.

apps/web
- `ConnectingOverlay` redesigned as a terminal-style streaming log:
  - macOS-window-style header (red/yellow/green dots) with a live
    `phase` indicator pulse.
  - Monospace event lines with `→` (active) / `✓` (done) / `•` (info)
    glyphs and a relative-seconds timestamp column.
  - Auto-scrolls to the latest entry; blinking caret on the trailing
    active line.
  - Lines derive from `device.connectionPhase` transitions and per-
    counter bumps on `MeshClient.progress`. Resets on each fresh
    connect cycle.
- Mounted at the top of the device tree (outside the device-conditional
  branch) so it shows during a first-time connect from the Connections
  screen as well as reconnects from inside the app.
- i18n strings under `connections.overlay.log.*`.
This commit is contained in:
Dan Ditomaso
2026-05-02 08:26:18 -04:00
parent 523ee60b71
commit f0eaf4da8b
8 changed files with 429 additions and 1 deletions

View File

@@ -54,5 +54,20 @@
"renamedByName": "Connection renamed to {{name}}.",
"updated": "Updated",
"updatedByName": "{{name}} has been updated."
},
"overlay": {
"log": {
"header": "meshtastic — connect",
"transportOpening": "Opening transport…",
"transportReady": "Transport ready",
"requestingConfig": "Requesting configuration",
"configReceived": "Received config section ({{n}})",
"moduleReceived": "Received module config ({{n}})",
"channelReceived": "Received channel ({{n}})",
"nodeReceived": "Received node info ({{n}})",
"myInfo": "Identity received",
"metadata": "Metadata received",
"configured": "Configuration complete"
}
}
}

View File

@@ -1,5 +1,6 @@
import { DeviceWrapper } from "@app/DeviceWrapper.tsx";
import { CommandPalette } from "@components/CommandPalette/index.tsx";
import { ConnectingOverlay } from "@components/ConnectingOverlay.tsx";
import { DialogManager } from "@components/Dialog/DialogManager.tsx";
import { KeyBackupReminder } from "@components/KeyBackupReminder.tsx";
import { RegionSetupReminder } from "@components/RegionSetupReminder.tsx";
@@ -27,6 +28,10 @@ export function App() {
<Toaster />
<TanStackRouterDevtools position="bottom-right" />
<DeviceWrapper deviceId={selectedDeviceId}>
{/* Overlay sits outside the device-conditional branch so it shows
during a first-time connect from the Connections screen as
well as reconnects from inside the app. */}
<ConnectingOverlay />
<div
className="flex h-screen flex-col bg-background-primary text-text-primary"
style={{ scrollbarWidth: "thin" }}

View File

@@ -0,0 +1,219 @@
import { Dialog, DialogContent, DialogTitle } from "@components/UI/Dialog.tsx";
import { useAppStore, useDeviceStore } from "@core/stores";
import type { ConnectionProgressCounters } from "@meshtastic/sdk";
import { useConnectionProgress } from "@meshtastic/sdk-react";
import type { ReactElement } from "react";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
type LineKind = "ok" | "active" | "info";
interface LogLine {
id: number;
ts: number;
kind: LineKind;
text: string;
}
const SYMBOL: Record<LineKind, string> = {
ok: "✓",
active: "→",
info: "•",
};
const COLOR: Record<LineKind, string> = {
ok: "text-emerald-400",
active: "text-sky-300",
info: "text-zinc-400",
};
/**
* Full-screen terminal-style log overlay shown while the active device's
* connection phase is "connecting" or "configuring". Lines are derived
* from `device.connectionPhase` transitions and `MeshClient.progress`
* counter bumps — pure consumer of domain signals.
*
* Auto-scrolls to the latest line. Auto-dismisses once phase flips to
* "configured" / "disconnected" / "error".
*/
export const ConnectingOverlay = (): ReactElement | null => {
const { selectedDeviceId } = useAppStore();
const { getDevice } = useDeviceStore();
const device = getDevice(selectedDeviceId);
const phase = device?.connectionPhase ?? "disconnected";
const progress = useConnectionProgress();
const { t } = useTranslation("connections");
const [lines, setLines] = useState<LogLine[]>([]);
const idRef = useRef(0);
const lastPhaseRef = useRef<typeof phase>("disconnected");
const lastCountersRef = useRef<ConnectionProgressCounters | undefined>(undefined);
const scrollRef = useRef<HTMLDivElement>(null);
const visible = phase === "connecting" || phase === "configuring";
// Reset the log when a fresh connect cycle starts (idle → connecting).
useEffect(() => {
if (phase === "connecting" && lastPhaseRef.current !== "connecting") {
setLines([]);
idRef.current = 0;
lastCountersRef.current = undefined;
}
lastPhaseRef.current = phase;
}, [phase]);
// High-level phase transitions → log lines.
useEffect(() => {
if (phase === "connecting") {
append("active", t("overlay.log.transportOpening", { defaultValue: "Opening transport…" }));
} else if (phase === "configuring") {
append("ok", t("overlay.log.transportReady", { defaultValue: "Transport ready" }));
append(
"active",
t("overlay.log.requestingConfig", { defaultValue: "Requesting configuration" }),
);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [phase]);
// Counter bumps → per-event lines.
useEffect(() => {
if (progress.phase !== "configuring") {
if (progress.phase === "configured") {
append("ok", t("overlay.log.configured", { defaultValue: "Configuration complete" }));
}
return;
}
const cur = progress.received;
const prev = lastCountersRef.current ?? {
config: 0,
modules: 0,
channels: 0,
nodes: 0,
myInfo: false,
metadata: false,
};
if (cur.config > prev.config) {
append(
"info",
t("overlay.log.configReceived", {
n: cur.config,
defaultValue: "Received config section ({{n}})",
}),
);
}
if (cur.modules > prev.modules) {
append(
"info",
t("overlay.log.moduleReceived", {
n: cur.modules,
defaultValue: "Received module config ({{n}})",
}),
);
}
if (cur.channels > prev.channels) {
append(
"info",
t("overlay.log.channelReceived", {
n: cur.channels,
defaultValue: "Received channel ({{n}})",
}),
);
}
if (cur.nodes > prev.nodes) {
append(
"info",
t("overlay.log.nodeReceived", {
n: cur.nodes,
defaultValue: "Received node info ({{n}})",
}),
);
}
if (cur.myInfo && !prev.myInfo) {
append("ok", t("overlay.log.myInfo", { defaultValue: "Identity received" }));
}
if (cur.metadata && !prev.metadata) {
append("ok", t("overlay.log.metadata", { defaultValue: "Metadata received" }));
}
lastCountersRef.current = cur;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [progress]);
// Auto-scroll to bottom on new line.
useEffect(() => {
const el = scrollRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, [lines]);
function append(kind: LineKind, text: string): void {
setLines((prev) => [...prev, { id: ++idRef.current, ts: Date.now(), kind, text }]);
}
if (!visible) return null;
const start = lines[0]?.ts ?? Date.now();
return (
<Dialog open>
<DialogContent
className="max-w-xl border-zinc-800 bg-zinc-950 p-0 text-zinc-100 shadow-2xl"
onInteractOutside={(e) => e.preventDefault()}
onEscapeKeyDown={(e) => e.preventDefault()}
>
<div className="flex items-center gap-2 border-b border-zinc-800 px-4 py-2">
<div className="flex gap-1.5">
<span className="h-3 w-3 rounded-full bg-red-500/80" />
<span className="h-3 w-3 rounded-full bg-yellow-500/80" />
<span className="h-3 w-3 rounded-full bg-green-500/80" />
</div>
<DialogTitle className="ml-2 font-mono text-xs uppercase tracking-wider text-zinc-400">
{t("overlay.log.header", { defaultValue: "meshtastic — connect" })}
</DialogTitle>
<span className="ml-auto inline-flex items-center gap-1.5 font-mono text-[10px] text-zinc-500">
<span
className={`inline-block h-2 w-2 rounded-full ${
phase === "configuring" ? "animate-pulse bg-sky-400" : "bg-amber-400"
}`}
/>
{phase}
</span>
</div>
<div
ref={scrollRef}
className="max-h-[360px] min-h-[200px] overflow-y-auto bg-zinc-950 px-4 py-3 font-mono text-[12px] leading-relaxed"
>
{lines.length === 0 ? (
<div className="text-zinc-600"></div>
) : (
lines.map((line, i) => {
const isLast = i === lines.length - 1;
const symbol = isLast && line.kind === "active" ? "→" : SYMBOL[line.kind];
return (
<div key={line.id} className="flex items-start gap-3">
<span className="shrink-0 select-none text-zinc-600">
{String(((line.ts - start) / 1000).toFixed(2)).padStart(6, " ")}s
</span>
<span className={`shrink-0 select-none ${COLOR[line.kind]}`}>{symbol}</span>
<span
className={
line.kind === "info"
? "text-zinc-300"
: line.kind === "ok"
? "text-zinc-100"
: "text-zinc-100"
}
>
{line.text}
{isLast && line.kind === "active" && (
<span className="ml-1 inline-block h-3 w-2 animate-pulse bg-zinc-300" />
)}
</span>
</div>
);
})
)}
</div>
</DialogContent>
</Dialog>
);
};

View File

@@ -28,6 +28,7 @@ export { useNode } from "./src/hooks/useNode.ts";
export { useChannels, useChannel } from "./src/hooks/useChannels.ts";
export { useConfig, useIsRegionUnset, useModuleConfig } from "./src/hooks/useConfig.ts";
export { useConfigEditor } from "./src/hooks/useConfigEditor.ts";
export { useConnectionProgress } from "./src/hooks/useConnectionProgress.ts";
export { useTotalUnread, useUnreadByKey, useUnreadCount } from "./src/hooks/useUnread.ts";
export { useTelemetry } from "./src/hooks/useTelemetry.ts";
export type { UseTelemetryResult } from "./src/hooks/useTelemetry.ts";

View File

@@ -0,0 +1,23 @@
import type { ConnectionProgress, ReadonlySignal } from "@meshtastic/sdk";
import { useActiveClient } from "../adapters/useActiveClient.ts";
import { useSignal } from "../adapters/useSignal.ts";
const IDLE_PROGRESS: ConnectionProgress = { phase: "idle" };
const IDLE_SIGNAL: ReadonlySignal<ConnectionProgress> = {
value: IDLE_PROGRESS,
peek: () => IDLE_PROGRESS,
subscribe: () => () => {},
};
/**
* Live view of `MeshClient.progress` for the active registry client.
* Returns `{ phase: "idle" }` when no client is active or before
* `configure()` runs; flips through `configuring` (with per-section
* counters) and lands on `configured` once the device finishes streaming
* its config bundle. UI surfaces use this to drive a connecting overlay
* with live "received N nodes / X channels" feedback.
*/
export function useConnectionProgress(): ConnectionProgress {
const client = useActiveClient();
return useSignal(client?.progress ?? IDLE_SIGNAL);
}

View File

@@ -1,6 +1,10 @@
// Main entry
export { MeshClient } from "./src/core/client/MeshClient.ts";
export type { MeshClientOptions } from "./src/core/client/MeshClient.ts";
export type {
ConnectionProgress,
ConnectionProgressCounters,
MeshClientOptions,
} from "./src/core/client/MeshClient.ts";
export { MeshRegistry } from "./src/core/registry/MeshRegistry.ts";
export type { ConnectionId, RegistryEntry } from "./src/core/registry/MeshRegistry.ts";

View File

@@ -0,0 +1,78 @@
import { create } from "@bufbuild/protobuf";
import * as Protobuf from "@meshtastic/protobufs";
import { describe, expect, it } from "vitest";
import { createFakeTransport } from "../testing/createFakeTransport.ts";
import { MeshClient } from "./MeshClient.ts";
describe("MeshClient.progress", () => {
it("starts in the idle phase before configure() is called", () => {
const { transport } = createFakeTransport();
const client = new MeshClient({ transport });
expect(client.progress.value.phase).toBe("idle");
});
it("flips to configuring with empty counters when configure() runs", async () => {
const { transport } = createFakeTransport();
const client = new MeshClient({ transport });
void client.configure();
expect(client.progress.value.phase).toBe("configuring");
expect(client.progress.value).toEqual({
phase: "configuring",
received: { config: 0, modules: 0, channels: 0, nodes: 0, myInfo: false, metadata: false },
});
});
it("tallies inbound packets onto the configuring counters", () => {
const { transport } = createFakeTransport();
const client = new MeshClient({ transport });
void client.configure();
client.events.onConfigPacket.dispatch(create(Protobuf.Config.ConfigSchema, {}));
client.events.onConfigPacket.dispatch(create(Protobuf.Config.ConfigSchema, {}));
client.events.onChannelPacket.dispatch(create(Protobuf.Channel.ChannelSchema, {}));
client.events.onNodeInfoPacket.dispatch(create(Protobuf.Mesh.NodeInfoSchema, {}));
client.events.onMyNodeInfo.dispatch(create(Protobuf.Mesh.MyNodeInfoSchema, {}));
const cur = client.progress.value;
if (cur.phase !== "configuring") throw new Error("expected configuring phase");
expect(cur.received.config).toBe(2);
expect(cur.received.channels).toBe(1);
expect(cur.received.nodes).toBe(1);
expect(cur.received.myInfo).toBe(true);
expect(cur.received.modules).toBe(0);
});
it("flips to configured when onConfigComplete fires", () => {
const { transport } = createFakeTransport();
const client = new MeshClient({ transport });
void client.configure();
client.events.onConfigPacket.dispatch(create(Protobuf.Config.ConfigSchema, {}));
client.events.onConfigComplete.dispatch(0);
const cur = client.progress.value;
expect(cur.phase).toBe("configured");
if (cur.phase !== "configured") throw new Error("unreachable");
expect(cur.received.config).toBe(1);
});
it("ignores packets that arrive while idle (post-completion)", () => {
const { transport } = createFakeTransport();
const client = new MeshClient({ transport });
// not calling configure() — phase is idle
client.events.onConfigPacket.dispatch(create(Protobuf.Config.ConfigSchema, {}));
expect(client.progress.value.phase).toBe("idle");
});
it("resets counters when configure() runs again", () => {
const { transport } = createFakeTransport();
const client = new MeshClient({ transport });
void client.configure();
client.events.onConfigPacket.dispatch(create(Protobuf.Config.ConfigSchema, {}));
client.events.onConfigComplete.dispatch(0);
void client.configure();
expect(client.progress.value).toEqual({
phase: "configuring",
received: { config: 0, modules: 0, channels: 0, nodes: 0, myInfo: false, metadata: false },
});
});
});

View File

@@ -8,6 +8,7 @@ import { generatePacketId } from "../identifiers/PacketId.ts";
import { createLogger } from "../logging/logger.ts";
import { decodePacket } from "../packet-codec/decodePacket.ts";
import { Queue } from "../queue/Queue.ts";
import { createStore, type ReadonlySignal } from "../signals/createStore.ts";
import type { Transport } from "../transport/Transport.ts";
import { DeviceStatusEnum } from "../transport/Transport.ts";
import { ChannelNumber, type Destination, Emitter, type PacketMetadata } from "../types.ts";
@@ -35,6 +36,39 @@ export interface MeshClientOptions {
telemetry?: TelemetryClientOptions;
}
/**
* Per-section / per-event tally of what has streamed in since the most
* recent `configure()` call. UI surfaces use this for live "received X
* channels, Y nodes" feedback during the handshake.
*/
export interface ConnectionProgressCounters {
config: number;
modules: number;
channels: number;
nodes: number;
myInfo: boolean;
metadata: boolean;
}
/**
* Connection progress signal shape. The state machine moves
* `idle → configuring → configured` once `configure()` runs and the
* device finishes streaming its config bundle.
*/
export type ConnectionProgress =
| { phase: "idle" }
| { phase: "configuring"; received: ConnectionProgressCounters }
| { phase: "configured"; received: ConnectionProgressCounters };
const EMPTY_COUNTERS: ConnectionProgressCounters = {
config: 0,
modules: 0,
channels: 0,
nodes: 0,
myInfo: false,
metadata: false,
};
/**
* Orchestrator for a single connected Meshtastic device.
*
@@ -60,6 +94,15 @@ export class MeshClient {
public readonly traceroute: TraceRouteClient;
public readonly files: FilesClient;
/**
* Live connection-handshake progress. Resets to `configuring` with empty
* counters when `configure()` is called; tallies each inbound config /
* module / channel / node packet; flips to `configured` when the device
* sends `configCompleteId`.
*/
public readonly progress: ReadonlySignal<ConnectionProgress>;
private readonly progressStore = createStore<ConnectionProgress>({ phase: "idle" });
private _heartbeatIntervalId: ReturnType<typeof setInterval> | undefined;
constructor(options: MeshClientOptions) {
@@ -80,6 +123,9 @@ export class MeshClient {
this.traceroute = new TraceRouteClient(this);
this.files = new FilesClient(this);
this.progress = this.progressStore.read;
this.wireProgressTracking();
this.events.onDeviceStatus.subscribe((status) => {
if (status === DeviceStatusEnum.DeviceDisconnected) {
if (this._heartbeatIntervalId !== undefined) {
@@ -109,6 +155,10 @@ export class MeshClient {
public configure(): Promise<number> {
this.log.debug(Emitter[Emitter.Configure], "⚙️ Requesting device configuration");
this.updateDeviceStatus(DeviceStatusEnum.DeviceConfiguring);
this.progressStore.write.value = {
phase: "configuring",
received: { ...EMPTY_COUNTERS },
};
const toRadio = create(Protobuf.Mesh.ToRadioSchema, {
payloadVariant: { case: "wantConfigId", value: this.configId },
@@ -122,6 +172,39 @@ export class MeshClient {
});
}
/**
* Subscribe progress counters to the relevant inbound events. Each
* subscriber bumps the matching field on the current `configuring`
* snapshot; `onConfigComplete` flips the phase to `configured`.
*
* Outside of an active handshake (phase=idle) inbound packets are
* ignored — they belong to a later session that re-runs configure().
*/
private wireProgressTracking(): void {
const bump = (field: keyof ConnectionProgressCounters): void => {
const cur = this.progressStore.read.value;
if (cur.phase !== "configuring") return;
const next: ConnectionProgressCounters = {
...cur.received,
[field]: typeof cur.received[field] === "number" ? cur.received[field] + 1 : true,
} as ConnectionProgressCounters;
this.progressStore.write.value = { phase: "configuring", received: next };
};
this.events.onConfigPacket.subscribe(() => bump("config"));
this.events.onModuleConfigPacket.subscribe(() => bump("modules"));
this.events.onChannelPacket.subscribe(() => bump("channels"));
this.events.onNodeInfoPacket.subscribe(() => bump("nodes"));
this.events.onMyNodeInfo.subscribe(() => bump("myInfo"));
this.events.onDeviceMetadataPacket.subscribe(() => bump("metadata"));
this.events.onConfigComplete.subscribe(() => {
const cur = this.progressStore.read.value;
const received = cur.phase === "configuring" ? cur.received : { ...EMPTY_COUNTERS };
this.progressStore.write.value = { phase: "configured", received };
});
}
public heartbeat(): Promise<number> {
this.log.debug(Emitter[Emitter.Ping], "❤️ Send heartbeat ping to radio");
const toRadio = create(Protobuf.Mesh.ToRadioSchema, {