Browse Source

Merge 092b90b989 into af2fac1465

pull/860/merge
Jeremy Gallant 9 months ago
committed by GitHub
parent
commit
da95d88ab4
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 71
      packages/web/src/components/Dialog/FactoryResetDeviceDialog/FactoryResetDeviceDialog.test.tsx
  2. 29
      packages/web/src/components/Dialog/FactoryResetDeviceDialog/FactoryResetDeviceDialog.tsx
  3. 2
      packages/web/src/core/services/dev-overrides.ts
  4. 2
      packages/web/src/core/services/featureFlags.ts
  5. 177
      packages/web/src/core/stores/appStore/appStore.test.ts
  6. 77
      packages/web/src/core/stores/appStore/index.ts
  7. 6
      packages/web/src/core/stores/appStore/types.ts
  8. 10
      packages/web/src/core/stores/deviceStore/deviceStore.mock.ts
  9. 516
      packages/web/src/core/stores/deviceStore/deviceStore.test.ts
  10. 398
      packages/web/src/core/stores/deviceStore/index.ts
  11. 52
      packages/web/src/core/stores/deviceStore/types.ts
  12. 36
      packages/web/src/core/stores/index.ts
  13. 20
      packages/web/src/core/stores/messageStore/index.ts
  14. 16
      packages/web/src/core/stores/nodeDBStore/index.ts
  15. 2
      packages/web/src/core/stores/nodeDBStore/nodeDBStore.mock.ts
  16. 2
      packages/web/src/core/stores/nodeDBStore/nodeDBStore.test.tsx
  17. 24
      packages/web/src/core/stores/utils/evictOldestEntries.ts
  18. 21
      packages/web/src/pages/Dashboard/index.tsx

71
packages/web/src/components/Dialog/FactoryResetDeviceDialog/FactoryResetDeviceDialog.test.tsx

@ -1,29 +1,41 @@
// FactoryResetDeviceDialog.test.tsx
import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { FactoryResetDeviceDialog } from "./FactoryResetDeviceDialog.tsx"; import { FactoryResetDeviceDialog } from "./FactoryResetDeviceDialog.tsx";
const mockFactoryResetDevice = vi.fn(); const mockFactoryResetDevice = vi.fn();
const mockDeleteAllMessages = vi.fn(); const mockRemoveDevice = vi.fn();
const mockRemoveAllNodeErrors = vi.fn(); const mockRemoveMessageStore = vi.fn();
const mockRemoveAllNodes = vi.fn(); const mockRemoveNodeDB = vi.fn();
const mockToast = vi.fn();
vi.mock("@core/stores", () => ({ vi.mock("@core/stores", () => {
// Make each store a callable fn (like a Zustand hook), and attach .getState()
const useDeviceStore = Object.assign(vi.fn(), {
getState: () => ({ removeDevice: mockRemoveDevice }),
});
const useMessageStore = Object.assign(vi.fn(), {
getState: () => ({ removeMessageStore: mockRemoveMessageStore }),
});
const useNodeDBStore = Object.assign(vi.fn(), {
getState: () => ({ removeNodeDB: mockRemoveNodeDB }),
});
return {
CurrentDeviceContext: { CurrentDeviceContext: {
_currentValue: { deviceId: 1234 }, _currentValue: { deviceId: 1234 },
}, },
useDevice: () => ({ useDevice: () => ({
connection: { id: 42,
factoryResetDevice: mockFactoryResetDevice, connection: { factoryResetDevice: mockFactoryResetDevice },
},
}),
useMessages: () => ({
deleteAllMessages: mockDeleteAllMessages,
}),
useNodeDB: () => ({
removeAllNodeErrors: mockRemoveAllNodeErrors,
removeAllNodes: mockRemoveAllNodes,
}), }),
useDeviceStore,
useMessageStore,
useNodeDBStore,
};
});
vi.mock("@core/hooks/useToast.ts", () => ({
toast: (...args: unknown[]) => mockToast(...args),
})); }));
describe("FactoryResetDeviceDialog", () => { describe("FactoryResetDeviceDialog", () => {
@ -31,10 +43,11 @@ describe("FactoryResetDeviceDialog", () => {
beforeEach(() => { beforeEach(() => {
mockOnOpenChange.mockClear(); mockOnOpenChange.mockClear();
mockFactoryResetDevice.mockClear(); mockFactoryResetDevice.mockReset();
mockDeleteAllMessages.mockClear(); mockRemoveDevice.mockClear();
mockRemoveAllNodeErrors.mockClear(); mockRemoveMessageStore.mockClear();
mockRemoveAllNodes.mockClear(); mockRemoveNodeDB.mockClear();
mockToast.mockClear();
}); });
it("calls factoryResetDevice, closes dialog, and after reset resolves clears messages and node DB", async () => { it("calls factoryResetDevice, closes dialog, and after reset resolves clears messages and node DB", async () => {
@ -61,20 +74,12 @@ describe("FactoryResetDeviceDialog", () => {
expect(mockOnOpenChange).toHaveBeenCalledWith(false); expect(mockOnOpenChange).toHaveBeenCalledWith(false);
}); });
// Nothing else should have happened yet (the promise hasn't resolved)
expect(mockDeleteAllMessages).not.toHaveBeenCalled();
expect(mockRemoveAllNodeErrors).not.toHaveBeenCalled();
expect(mockRemoveAllNodes).not.toHaveBeenCalled();
// Resolve the reset // Resolve the reset
resolveReset?.(); resolveReset?.();
// Now the .then() chain should fire expect(mockRemoveDevice).toHaveBeenCalledTimes(1);
await waitFor(() => { expect(mockRemoveMessageStore).toHaveBeenCalledTimes(1);
expect(mockDeleteAllMessages).toHaveBeenCalledTimes(1); expect(mockRemoveNodeDB).toHaveBeenCalledTimes(1);
expect(mockRemoveAllNodeErrors).toHaveBeenCalledTimes(1);
expect(mockRemoveAllNodes).toHaveBeenCalledTimes(1);
});
}); });
it("calls onOpenChange(false) and does not call factoryResetDevice when cancel is clicked", async () => { it("calls onOpenChange(false) and does not call factoryResetDevice when cancel is clicked", async () => {
@ -87,8 +92,8 @@ describe("FactoryResetDeviceDialog", () => {
}); });
expect(mockFactoryResetDevice).not.toHaveBeenCalled(); expect(mockFactoryResetDevice).not.toHaveBeenCalled();
expect(mockDeleteAllMessages).not.toHaveBeenCalled(); expect(mockRemoveDevice).not.toHaveBeenCalled();
expect(mockRemoveAllNodeErrors).not.toHaveBeenCalled(); expect(mockRemoveMessageStore).not.toHaveBeenCalled();
expect(mockRemoveAllNodes).not.toHaveBeenCalled(); expect(mockRemoveNodeDB).not.toHaveBeenCalled();
}); });
}); });

29
packages/web/src/components/Dialog/FactoryResetDeviceDialog/FactoryResetDeviceDialog.tsx

@ -1,5 +1,10 @@
import { toast } from "@core/hooks/useToast.ts"; import { toast } from "@core/hooks/useToast.ts";
import { useDevice, useMessages, useNodeDB } from "@core/stores"; import {
useDevice,
useDeviceStore,
useMessageStore,
useNodeDBStore,
} from "@core/stores";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { DialogWrapper } from "../DialogWrapper.tsx"; import { DialogWrapper } from "../DialogWrapper.tsx";
@ -13,24 +18,24 @@ export const FactoryResetDeviceDialog = ({
onOpenChange, onOpenChange,
}: FactoryResetDeviceDialogProps) => { }: FactoryResetDeviceDialogProps) => {
const { t } = useTranslation("dialog"); const { t } = useTranslation("dialog");
const { connection } = useDevice(); const { connection, id } = useDevice();
const { removeAllNodeErrors, removeAllNodes } = useNodeDB();
const { deleteAllMessages } = useMessages();
const handleFactoryResetDevice = () => { const handleFactoryResetDevice = () => {
connection connection?.factoryResetDevice().catch((error) => {
?.factoryResetDevice()
.then(() => {
deleteAllMessages();
removeAllNodeErrors();
removeAllNodes();
})
.catch((error) => {
toast({ toast({
title: t("factoryResetDevice.failedTitle"), title: t("factoryResetDevice.failedTitle"),
}); });
console.error("Failed to factory reset device:", error); console.error("Failed to factory reset device:", error);
}); });
// The device will be wiped and disconnected without resolving the promise
// so we proceed to clear all data associated with the device immediately
useDeviceStore.getState().removeDevice(id);
useMessageStore.getState().removeMessageStore(id);
useNodeDBStore.getState().removeNodeDB(id);
// Reload the app to ensure all ephemeral state is cleared
window.location.href = "/";
}; };
return ( return (

2
packages/web/src/core/services/dev-overrides.ts

@ -7,5 +7,7 @@ if (isDev) {
featureFlags.setOverrides({ featureFlags.setOverrides({
persistNodeDB: true, persistNodeDB: true,
persistMessages: true, persistMessages: true,
persistDevices: true,
persistApp: true,
}); });
} }

2
packages/web/src/core/services/featureFlags.ts

@ -4,6 +4,8 @@ import { z } from "zod";
export const FLAG_ENV = { export const FLAG_ENV = {
persistNodeDB: "VITE_PERSIST_NODE_DB", persistNodeDB: "VITE_PERSIST_NODE_DB",
persistMessages: "VITE_PERSIST_MESSAGES", persistMessages: "VITE_PERSIST_MESSAGES",
persistDevices: "VITE_PERSIST_DEVICES",
persistApp: "VITE_PERSIST_APP",
} as const; } as const;
export type FlagKey = keyof typeof FLAG_ENV; export type FlagKey = keyof typeof FLAG_ENV;

177
packages/web/src/core/stores/appStore/appStore.test.ts

@ -0,0 +1,177 @@
import type { RasterSource } from "@core/stores/appStore/types.ts";
import { beforeEach, describe, expect, it, vi } from "vitest";
const idbMem = new Map<string, string>();
vi.mock("idb-keyval", () => ({
get: vi.fn((key: string) => Promise.resolve(idbMem.get(key))),
set: vi.fn((key: string, val: string) => {
idbMem.set(key, val);
return Promise.resolve();
}),
del: vi.fn((key: string) => {
idbMem.delete(key);
return Promise.resolve();
}),
}));
async function freshStore(persistApp = false) {
vi.resetModules();
vi.spyOn(console, "debug").mockImplementation(() => {});
vi.spyOn(console, "log").mockImplementation(() => {});
vi.spyOn(console, "info").mockImplementation(() => {});
vi.doMock("@core/services/featureFlags.ts", () => ({
featureFlags: {
get: vi.fn((key: string) => (key === "persistApp" ? persistApp : false)),
},
}));
const storeMod = await import("./index.ts");
return storeMod as typeof import("./index.ts");
}
function makeRaster(fields: Record<string, any>): RasterSource {
return {
enabled: true,
title: "default",
tiles: `https://default.com/default.json`,
tileSize: 256,
...fields,
};
}
describe("AppStore – basic state & actions", () => {
beforeEach(() => {
idbMem.clear();
vi.clearAllMocks();
});
it("setters flip UI flags and numeric fields", async () => {
const { useAppStore } = await freshStore(false);
const state = useAppStore.getState();
state.setSelectedDevice(42);
expect(useAppStore.getState().selectedDeviceId).toBe(42);
state.setCommandPaletteOpen(true);
expect(useAppStore.getState().commandPaletteOpen).toBe(true);
state.setConnectDialogOpen(true);
expect(useAppStore.getState().connectDialogOpen).toBe(true);
state.setNodeNumToBeRemoved(123);
expect(useAppStore.getState().nodeNumToBeRemoved).toBe(123);
state.setNodeNumDetails(777);
expect(useAppStore.getState().nodeNumDetails).toBe(777);
});
it("setRasterSources replaces; addRasterSource appends; removeRasterSource splices by index", async () => {
const { useAppStore } = await freshStore(false);
const state = useAppStore.getState();
const a = makeRaster({ title: "a" });
const b = makeRaster({ title: "b" });
const c = makeRaster({ title: "c" });
state.setRasterSources([a, b]);
expect(
useAppStore.getState().rasterSources.map((raster) => raster.title),
).toEqual(["a", "b"]);
state.addRasterSource(c);
expect(
useAppStore.getState().rasterSources.map((raster) => raster.title),
).toEqual(["a", "b", "c"]);
// "b"
state.removeRasterSource(1);
expect(
useAppStore.getState().rasterSources.map((raster) => raster.title),
).toEqual(["a", "c"]);
});
});
describe("AppStore – persistence: partialize + rehydrate", () => {
beforeEach(() => {
idbMem.clear();
vi.clearAllMocks();
});
it("persists only rasterSources; methods still work after rehydrate", async () => {
// Write data
{
const { useAppStore } = await freshStore(true);
const state = useAppStore.getState();
state.setRasterSources([
makeRaster({ title: "x" }),
makeRaster({ title: "y" }),
]);
state.setSelectedDevice(99);
state.setCommandPaletteOpen(true);
// Only rasterSources should persist by partialize
expect(useAppStore.getState().rasterSources.length).toBe(2);
}
// Rehydrate from idbMem
{
const { useAppStore } = await freshStore(true);
const state = useAppStore.getState();
// persisted slice:
expect(state.rasterSources.map((raster) => raster.title)).toEqual([
"x",
"y",
]);
// ephemeral fields reset to defaults:
expect(state.selectedDeviceId).toBe(0);
expect(state.commandPaletteOpen).toBe(false);
expect(state.connectDialogOpen).toBe(false);
expect(state.nodeNumToBeRemoved).toBe(0);
expect(state.nodeNumDetails).toBe(0);
// methods still work post-rehydrate:
state.addRasterSource(makeRaster({ title: "z" }));
expect(
useAppStore.getState().rasterSources.map((raster) => raster.title),
).toEqual(["x", "y", "z"]);
state.removeRasterSource(0);
expect(
useAppStore.getState().rasterSources.map((raster) => raster.title),
).toEqual(["y", "z"]);
}
});
it("removing and resetting sources persists across reload", async () => {
{
const { useAppStore } = await freshStore(true);
const state = useAppStore.getState();
state.setRasterSources([
makeRaster({ title: "keep" }),
makeRaster({ title: "drop" }),
]);
state.removeRasterSource(1); // drop "drop"
expect(
useAppStore.getState().rasterSources.map((raster) => raster.title),
).toEqual(["keep"]);
}
{
const { useAppStore } = await freshStore(true);
const state = useAppStore.getState();
expect(state.rasterSources.map((raster) => raster.title)).toEqual([
"keep",
]);
// Now replace entirely
state.setRasterSources([]);
}
{
const { useAppStore } = await freshStore(true);
const state = useAppStore.getState();
expect(state.rasterSources).toEqual([]); // stayed cleared
}
});
});

77
packages/web/src/core/stores/appStore/index.ts

@ -1,41 +1,42 @@
import { featureFlags } from "@core/services/featureFlags.ts";
import { createStorage } from "@core/stores/utils/indexDB.ts";
import { produce } from "immer"; import { produce } from "immer";
import { create } from "zustand"; import { create as createStore, type StateCreator } from "zustand";
import {
type PersistOptions,
persist,
subscribeWithSelector,
} from "zustand/middleware";
import type { RasterSource } from "./types.ts";
export interface RasterSource { const IDB_KEY_NAME = "meshtastic-app-store";
enabled: boolean; const CURRENT_STORE_VERSION = 0;
title: string;
tiles: string;
tileSize: number;
}
interface AppState { type AppData = {
selectedDeviceId: number; // Persisted data
devices: {
id: number;
num: number;
}[];
rasterSources: RasterSource[]; rasterSources: RasterSource[];
commandPaletteOpen: boolean; };
export interface AppState extends AppData {
// Ephemeral state (not persisted)
selectedDeviceId: number;
nodeNumToBeRemoved: number; nodeNumToBeRemoved: number;
connectDialogOpen: boolean; connectDialogOpen: boolean;
nodeNumDetails: number; nodeNumDetails: number;
commandPaletteOpen: boolean;
setRasterSources: (sources: RasterSource[]) => void; setRasterSources: (sources: RasterSource[]) => void;
addRasterSource: (source: RasterSource) => void; addRasterSource: (source: RasterSource) => void;
removeRasterSource: (index: number) => void; removeRasterSource: (index: number) => void;
setSelectedDevice: (deviceId: number) => void; setSelectedDevice: (deviceId: number) => void;
addDevice: (device: { id: number; num: number }) => void;
removeDevice: (deviceId: number) => void;
setCommandPaletteOpen: (open: boolean) => void; setCommandPaletteOpen: (open: boolean) => void;
setNodeNumToBeRemoved: (nodeNum: number) => void; setNodeNumToBeRemoved: (nodeNum: number) => void;
setConnectDialogOpen: (open: boolean) => void; setConnectDialogOpen: (open: boolean) => void;
setNodeNumDetails: (nodeNum: number) => void; setNodeNumDetails: (nodeNum: number) => void;
} }
export const useAppStore = create<AppState>()((set, _get) => ({ export const deviceStoreInitializer: StateCreator<AppState> = (set, _get) => ({
selectedDeviceId: 0, selectedDeviceId: 0,
devices: [],
currentPage: "messages",
rasterSources: [], rasterSources: [],
commandPaletteOpen: false, commandPaletteOpen: false,
connectDialogOpen: false, connectDialogOpen: false,
@ -67,14 +68,6 @@ export const useAppStore = create<AppState>()((set, _get) => ({
set(() => ({ set(() => ({
selectedDeviceId: deviceId, selectedDeviceId: deviceId,
})), })),
addDevice: (device) =>
set((state) => ({
devices: [...state.devices, device],
})),
removeDevice: (deviceId) =>
set((state) => ({
devices: state.devices.filter((device) => device.id !== deviceId),
})),
setCommandPaletteOpen: (open: boolean) => { setCommandPaletteOpen: (open: boolean) => {
set( set(
produce<AppState>((draft) => { produce<AppState>((draft) => {
@ -93,9 +86,35 @@ export const useAppStore = create<AppState>()((set, _get) => ({
}), }),
); );
}, },
setNodeNumDetails: (nodeNum) => setNodeNumDetails: (nodeNum) =>
set(() => ({ set(() => ({
nodeNumDetails: nodeNum, nodeNumDetails: nodeNum,
})), })),
})); });
const persistOptions: PersistOptions<AppState, AppData> = {
name: IDB_KEY_NAME,
storage: createStorage<AppData>(),
version: CURRENT_STORE_VERSION,
partialize: (s): AppData => ({
rasterSources: s.rasterSources,
}),
onRehydrateStorage: () => (state) => {
if (!state) {
return;
}
console.debug("AppStore: Rehydrating state", state);
},
};
// Add persist middleware on the store if the feature flag is enabled
const persistApps = featureFlags.get("persistApp");
console.debug(
`AppStore: Persisting app is ${persistApps ? "enabled" : "disabled"}`,
);
export const useAppStore = persistApps
? createStore(
subscribeWithSelector(persist(deviceStoreInitializer, persistOptions)),
)
: createStore(subscribeWithSelector(deviceStoreInitializer));

6
packages/web/src/core/stores/appStore/types.ts

@ -0,0 +1,6 @@
export interface RasterSource {
enabled: boolean;
title: string;
tiles: string;
tileSize: number;
}

10
packages/web/src/core/stores/deviceStore/deviceStore.mock.ts

@ -14,6 +14,7 @@ import type { Device } from "./index.ts";
*/ */
export const mockDeviceStore: Device = { export const mockDeviceStore: Device = {
id: 0, id: 0,
myNodeNum: 123456,
status: 5 as const, status: 5 as const,
channels: new Map(), channels: new Map(),
config: {} as Protobuf.LocalOnly.LocalConfig, config: {} as Protobuf.LocalOnly.LocalConfig,
@ -44,8 +45,13 @@ export const mockDeviceStore: Device = {
deleteMessages: false, deleteMessages: false,
managedMode: false, managedMode: false,
clientNotification: false, clientNotification: false,
resetNodeDb: false,
clearAllStores: false,
factoryResetConfig: false,
factoryResetDevice: false,
}, },
clientNotifications: [], clientNotifications: [],
neighborInfo: new Map(),
setStatus: vi.fn(), setStatus: vi.fn(),
setConfig: vi.fn(), setConfig: vi.fn(),
@ -66,6 +72,8 @@ export const mockDeviceStore: Device = {
setPendingSettingsChanges: vi.fn(), setPendingSettingsChanges: vi.fn(),
addChannel: vi.fn(), addChannel: vi.fn(),
addWaypoint: vi.fn(), addWaypoint: vi.fn(),
removeWaypoint: vi.fn(),
getWaypoint: vi.fn(),
addConnection: vi.fn(), addConnection: vi.fn(),
addTraceRoute: vi.fn(), addTraceRoute: vi.fn(),
addMetadata: vi.fn(), addMetadata: vi.fn(),
@ -80,4 +88,6 @@ export const mockDeviceStore: Device = {
getClientNotification: vi.fn(), getClientNotification: vi.fn(),
getAllUnreadCount: vi.fn().mockReturnValue(0), getAllUnreadCount: vi.fn().mockReturnValue(0),
getUnreadCount: vi.fn().mockReturnValue(0), getUnreadCount: vi.fn().mockReturnValue(0),
getNeighborInfo: vi.fn(),
addNeighborInfo: vi.fn(),
}; };

516
packages/web/src/core/stores/deviceStore/deviceStore.test.ts

@ -0,0 +1,516 @@
import { create, toBinary } from "@bufbuild/protobuf";
import { Protobuf, type Types } from "@meshtastic/core";
import { beforeEach, describe, expect, it, vi } from "vitest";
const idbMem = new Map<string, string>();
vi.mock("idb-keyval", () => ({
get: vi.fn((key: string) => Promise.resolve(idbMem.get(key))),
set: vi.fn((key: string, val: string) => {
idbMem.set(key, val);
return Promise.resolve();
}),
del: vi.fn((k: string) => {
idbMem.delete(k);
return Promise.resolve();
}),
}));
// Helper to load a fresh copy of the store with persist flag on/off
async function freshStore(persist = false) {
vi.resetModules();
// suppress console output from the store during tests (for github actions)
vi.spyOn(console, "debug").mockImplementation(() => {});
vi.spyOn(console, "log").mockImplementation(() => {});
vi.spyOn(console, "info").mockImplementation(() => {});
vi.doMock("@core/services/featureFlags", () => ({
featureFlags: {
get: vi.fn((key: string) => (key === "persistDevices" ? persist : false)),
},
}));
const storeMod = await import("./index.ts");
const { useNodeDB } = await import("../index.ts");
return { ...storeMod, useNodeDB };
}
function makeHardware(myNodeNum: number) {
return create(Protobuf.Mesh.MyNodeInfoSchema, { myNodeNum });
}
function makeRoute(from: number, time = Date.now() / 1000) {
return {
from,
rxTime: time,
portnum: Protobuf.Portnums.PortNum.ROUTING_APP,
data: create(Protobuf.Mesh.RouteDiscoverySchema, {}),
} as unknown as Types.PacketMetadata<Protobuf.Mesh.RouteDiscovery>;
}
function makeChannel(index: number) {
return create(Protobuf.Channel.ChannelSchema, { index });
}
function makeWaypoint(id: number, expire?: number) {
return create(Protobuf.Mesh.WaypointSchema, { id, expire });
}
function makeConfig(fields: Record<string, any>) {
return create(Protobuf.Config.ConfigSchema, {
payloadVariant: {
case: "device",
value: create(Protobuf.Config.Config_DeviceConfigSchema, fields),
},
});
}
function makeModuleConfig(fields: Record<string, any>) {
return create(Protobuf.ModuleConfig.ModuleConfigSchema, {
payloadVariant: {
case: "mqtt",
value: create(
Protobuf.ModuleConfig.ModuleConfig_MQTTConfigSchema,
fields,
),
},
});
}
function makeAdminMessage(fields: Record<string, any>) {
return create(Protobuf.Admin.AdminMessageSchema, fields);
}
describe("DeviceStore – basic map ops & retention", () => {
beforeEach(() => {
idbMem.clear();
vi.clearAllMocks();
});
it("addDevice returns same instance on repeated calls; getDevice(s) works; retention evicts oldest after cap", async () => {
const { useDeviceStore } = await freshStore(false);
const state = useDeviceStore.getState();
const a = state.addDevice(1);
const b = state.addDevice(1);
expect(a).toBe(b);
expect(state.getDevice(1)).toBe(a);
expect(state.getDevices().length).toBe(1);
// DEVICESTORE_RETENTION_NUM = 10; create 11 to evict #1
for (let i = 2; i <= 11; i++) {
state.addDevice(i);
}
expect(state.getDevice(1)).toBeUndefined();
expect(state.getDevice(11)).toBeDefined();
expect(state.getDevices().length).toBe(10);
});
it("removeDevice deletes only that entry", async () => {
const { useDeviceStore } = await freshStore(false);
const state = useDeviceStore.getState();
state.addDevice(10);
state.addDevice(11);
expect(state.getDevices().length).toBe(2);
state.removeDevice(10);
expect(state.getDevice(10)).toBeUndefined();
expect(state.getDevice(11)).toBeDefined();
expect(state.getDevices().length).toBe(1);
});
});
describe("DeviceStore – working/effective config API", () => {
beforeEach(() => {
idbMem.clear();
vi.clearAllMocks();
});
it("setWorkingConfig/getWorkingConfig replaces by variant and getEffectiveConfig merges base + working", async () => {
const { useDeviceStore } = await freshStore(false);
const state = useDeviceStore.getState();
const device = state.addDevice(42);
// config deviceConfig.role = CLIENT
device.setConfig(
create(Protobuf.Config.ConfigSchema, {
payloadVariant: {
case: "device",
value: create(Protobuf.Config.Config_DeviceConfigSchema, {
role: Protobuf.Config.Config_DeviceConfig_Role.CLIENT,
}),
},
}),
);
// working deviceConfig.role = ROUTER
device.setWorkingConfig(
makeConfig({
role: Protobuf.Config.Config_DeviceConfig_Role.ROUTER,
}),
);
// expect working deviceConfig.role = ROUTER
const working = device.getWorkingConfig("device");
expect(working?.role).toBe(Protobuf.Config.Config_DeviceConfig_Role.ROUTER);
// expect effective deviceConfig.role = ROUTER
const effective = device.getEffectiveConfig("device");
expect(effective?.role).toBe(
Protobuf.Config.Config_DeviceConfig_Role.ROUTER,
);
// remove working, effective should equal base
device.removeWorkingConfig("device");
expect(device.getWorkingConfig("device")).toBeUndefined();
expect(device.getEffectiveConfig("device")?.role).toBe(
Protobuf.Config.Config_DeviceConfig_Role.CLIENT,
);
// add multiple, then clear all
device.setWorkingConfig(makeConfig({}));
device.setWorkingConfig(
makeConfig({
deviceRole: Protobuf.Config.Config_DeviceConfig_Role.ROUTER,
}),
);
device.removeWorkingConfig(); // clears all
expect(device.getWorkingConfig("device")).toBeUndefined();
});
it("setWorkingModuleConfig/getWorkingModuleConfig and getEffectiveModuleConfig", async () => {
const { useDeviceStore } = await freshStore(false);
const state = useDeviceStore.getState();
const device = state.addDevice(7);
// base moduleConfig.mqtt empty; add working mqtt host
device.setModuleConfig(
create(Protobuf.ModuleConfig.ModuleConfigSchema, {
payloadVariant: {
case: "mqtt",
value: create(Protobuf.ModuleConfig.ModuleConfig_MQTTConfigSchema, {
address: "mqtt://base",
}),
},
}),
);
device.setWorkingModuleConfig(
makeModuleConfig({ address: "mqtt://working" }),
);
const mqtt = device.getWorkingModuleConfig("mqtt");
expect(mqtt?.address).toBe("mqtt://working");
expect(mqtt?.address).toBe("mqtt://working");
device.removeWorkingModuleConfig("mqtt");
expect(device.getWorkingModuleConfig("mqtt")).toBeUndefined();
expect(device.getEffectiveModuleConfig("mqtt")?.address).toBe(
"mqtt://base",
);
// Clear all
device.setWorkingModuleConfig(makeModuleConfig({ address: "x" }));
device.setWorkingModuleConfig(makeModuleConfig({ address: "y" }));
device.removeWorkingModuleConfig();
expect(device.getWorkingModuleConfig("mqtt")).toBeUndefined();
});
it("channel working config add/update/remove/get", async () => {
const { useDeviceStore } = await freshStore(false);
const state = useDeviceStore.getState();
const device = state.addDevice(9);
device.setWorkingChannelConfig(makeChannel(0));
device.setWorkingChannelConfig(
create(Protobuf.Channel.ChannelSchema, {
index: 1,
settings: { name: "one" },
}),
);
expect(device.getWorkingChannelConfig(0)?.index).toBe(0);
expect(device.getWorkingChannelConfig(1)?.settings?.name).toBe("one");
// update channel 1
device.setWorkingChannelConfig(
create(Protobuf.Channel.ChannelSchema, {
index: 1,
settings: { name: "uno" },
}),
);
expect(device.getWorkingChannelConfig(1)?.settings?.name).toBe("uno");
// remove specific
device.removeWorkingChannelConfig(1);
expect(device.getWorkingChannelConfig(1)).toBeUndefined();
// remove all
device.removeWorkingChannelConfig();
expect(device.getWorkingChannelConfig(0)).toBeUndefined();
});
});
describe("DeviceStore – metadata, dialogs, unread counts, message draft", () => {
beforeEach(() => {
idbMem.clear();
vi.clearAllMocks();
});
it("addMetadata stores by node id", async () => {
const { useDeviceStore } = await freshStore(false);
const state = useDeviceStore.getState();
const device = state.addDevice(1);
const metadata = create(Protobuf.Mesh.DeviceMetadataSchema, {
firmwareVersion: "1.2.3",
});
device.addMetadata(123, metadata);
expect(useDeviceStore.getState().devices.get(1)?.metadata.get(123)).toEqual(
metadata,
);
});
it("dialogs set/get work and throw if device missing", async () => {
const { useDeviceStore } = await freshStore(false);
const state = useDeviceStore.getState();
const device = state.addDevice(5);
device.setDialogOpen("reboot", true);
expect(device.getDialogOpen("reboot")).toBe(true);
device.setDialogOpen("reboot", false);
expect(device.getDialogOpen("reboot")).toBe(false);
// getDialogOpen uses getDevice or throws if device missing
state.removeDevice(5);
expect(() => device.getDialogOpen("reboot")).toThrow(/Device 5 not found/);
});
it("unread counts: increment/get/getAll/reset", async () => {
const { useDeviceStore } = await freshStore(false);
const state = useDeviceStore.getState();
const device = state.addDevice(2);
expect(device.getUnreadCount(10)).toBe(0);
device.incrementUnread(10);
device.incrementUnread(10);
device.incrementUnread(11);
expect(device.getUnreadCount(10)).toBe(2);
expect(device.getUnreadCount(11)).toBe(1);
expect(device.getAllUnreadCount()).toBe(3);
device.resetUnread(10);
expect(device.getUnreadCount(10)).toBe(0);
expect(device.getAllUnreadCount()).toBe(1);
});
it("setMessageDraft stores the text", async () => {
const { useDeviceStore } = await freshStore(false);
const device = useDeviceStore.getState().addDevice(3);
device.setMessageDraft("hello");
expect(useDeviceStore.getState().devices.get(3)?.messageDraft).toBe(
"hello",
);
});
});
describe("DeviceStore – traceroutes & waypoints retention + merge on setHardware", () => {
beforeEach(() => {
idbMem.clear();
vi.clearAllMocks();
});
it("addTraceRoute appends and enforces per-target and target caps", async () => {
const { useDeviceStore } = await freshStore(false);
const state = useDeviceStore.getState();
const device = state.addDevice(100);
// Per target: cap = 100; push 101 for from=7
for (let i = 0; i < 101; i++) {
device.addTraceRoute(makeRoute(7, i));
}
const routesFor7 = useDeviceStore
.getState()
.devices.get(100)
?.traceroutes.get(7)!;
expect(routesFor7.length).toBe(100);
expect(routesFor7[0]?.rxTime).toBe(1); // first (0) evicted
// Target map cap: 100 keys, add 101 unique "from"
for (let from = 0; from <= 100; from++) {
device.addTraceRoute(makeRoute(1000 + from));
}
const keys = Array.from(
useDeviceStore.getState().devices.get(100)!.traceroutes.keys(),
);
expect(keys.length).toBe(100);
});
it("addWaypoint upserts by id and enforces retention; setHardware moves traceroutes + prunes expired waypoints", async () => {
vi.setSystemTime(new Date("2025-01-01T00:00:00Z"));
const { useDeviceStore } = await freshStore(false);
const state = useDeviceStore.getState();
// Old device with myNodeNum=777 and some waypoints (one expired)
const oldDevice = state.addDevice(1);
oldDevice.connection = { sendWaypoint: vi.fn() } as any;
oldDevice.setHardware(makeHardware(777));
oldDevice.addWaypoint(
makeWaypoint(1, Date.parse("2024-12-31T23:59:59Z")), // This is expired, will not be added
0,
0,
new Date(),
); // expired
oldDevice.addWaypoint(makeWaypoint(2, 0), 0, 0, new Date()); // no expire
oldDevice.addWaypoint(
makeWaypoint(3, Date.parse("2026-01-01T00:00:00Z")),
0,
0,
new Date(),
); // ok
oldDevice.addTraceRoute(makeRoute(55));
oldDevice.addTraceRoute(makeRoute(56));
// Upsert waypoint by id
oldDevice.addWaypoint(
makeWaypoint(2, Date.parse("2027-01-01T00:00:00Z")),
0,
0,
new Date(),
);
const wps = useDeviceStore.getState().devices.get(1)!.waypoints;
expect(wps.length).toBe(2);
expect(wps.find((w) => w.id === 2)?.expire).toBe(
Date.parse("2027-01-01T00:00:00Z"),
);
// Retention: push 102 total waypoints -> capped at 100. Oldest evicted
for (let i = 3; i <= 102; i++) {
oldDevice.addWaypoint(makeWaypoint(i), 0, 0, new Date());
}
expect(useDeviceStore.getState().devices.get(1)!.waypoints.length).toBe(
100,
);
// Remove waypoint
oldDevice.removeWaypoint(102, false);
expect(oldDevice.connection?.sendWaypoint).not.toHaveBeenCalled();
await oldDevice.removeWaypoint(101, true); // toMesh=true
expect(oldDevice.connection?.sendWaypoint).toHaveBeenCalled();
expect(useDeviceStore.getState().devices.get(1)!.waypoints.length).toBe(98);
// New device shares myNodeNum; setHardware should:
// - move traceroutes from old device
// - copy waypoints minus expired
// - delete old device entry
const newDevice = state.addDevice(2);
newDevice.setHardware(makeHardware(777));
expect(state.getDevice(1)).toBeUndefined();
expect(state.getDevice(2)).toBeDefined();
// traceroutes moved:
expect(state.getDevice(2)!.traceroutes.size).toBe(2);
// Getter for waypoint by id works
expect(newDevice.getWaypoint(1)).toBeUndefined();
expect(newDevice.getWaypoint(2)).toBeUndefined();
expect(newDevice.getWaypoint(3)).toBeTruthy();
vi.useRealTimers();
});
});
describe("DeviceStore – persistence partialize & rehydrate", () => {
beforeEach(() => {
idbMem.clear();
vi.clearAllMocks();
});
it("partialize stores only DeviceData; onRehydrateStorage rebuilds only devices with myNodeNum set (orphans dropped)", async () => {
// First run: persist=true
{
const { useDeviceStore } = await freshStore(true);
const state = useDeviceStore.getState();
const orphan = state.addDevice(500); // no myNodeNum -> should be dropped
orphan.addWaypoint(makeWaypoint(123), 0, 0, new Date());
const good = state.addDevice(501);
good.setHardware(makeHardware(42)); // sets myNodeNum
good.addTraceRoute(makeRoute(77));
good.addWaypoint(makeWaypoint(1), 0, 0, new Date());
// ensure some ephemeral fields differ so we can verify methods work after rehydrate
good.setMessageDraft("draft");
}
// Reload: persist=true -> rehydrate from idbMem
{
const { useDeviceStore } = await freshStore(true);
const state = useDeviceStore.getState();
expect(state.getDevice(500)).toBeUndefined(); // orphan dropped
const device = state.getDevice(501)!;
expect(device).toBeDefined();
// methods should work
device.addWaypoint(makeWaypoint(2), 0, 0, new Date());
expect(
useDeviceStore.getState().devices.get(501)!.waypoints.length,
).toBeGreaterThan(0);
// traceroutes survived
expect(
useDeviceStore.getState().devices.get(501)!.traceroutes.size,
).toBeGreaterThan(0);
}
});
it("removing a device persists across reload", async () => {
{
const { useDeviceStore } = await freshStore(true);
const state = useDeviceStore.getState();
const device = state.addDevice(900);
device.setHardware(makeHardware(9)); // ensure it will be rehydrated
expect(state.getDevice(900)).toBeDefined();
state.removeDevice(900);
expect(state.getDevice(900)).toBeUndefined();
}
{
const { useDeviceStore } = await freshStore(true);
expect(useDeviceStore.getState().getDevice(900)).toBeUndefined();
}
});
});
describe("DeviceStore – connection & sendAdminMessage", () => {
beforeEach(() => {
idbMem.clear();
vi.clearAllMocks();
});
it("sendAdminMessage calls through to connection.sendPacket with correct args", async () => {
const { useDeviceStore } = await freshStore(false);
const state = useDeviceStore.getState();
const device = state.addDevice(77);
const sendPacket = vi.fn();
device.addConnection({ sendPacket } as any);
const message = makeAdminMessage({ logVerbosity: 1 });
device.sendAdminMessage(message);
expect(sendPacket).toHaveBeenCalledTimes(1);
const [bytes, port, dest] = sendPacket.mock.calls[0]!;
expect(port).toBe(Protobuf.Portnums.PortNum.ADMIN_APP);
expect(dest).toBe("self");
// sanity: encoded bytes match toBinary on the same schema
const expected = toBinary(Protobuf.Admin.AdminMessageSchema, message);
expect(bytes).toBeInstanceOf(Uint8Array);
// compare content length as minimal assertion (exact byte-for-byte is fine too)
expect((bytes as Uint8Array).length).toBe(expected.length);
});
});

398
packages/web/src/core/stores/deviceStore/index.ts

@ -1,38 +1,43 @@
import { create, toBinary } from "@bufbuild/protobuf"; import { create, toBinary } from "@bufbuild/protobuf";
import { featureFlags } from "@core/services/featureFlags";
import { evictOldestEntries } from "@core/stores/utils/evictOldestEntries.ts";
import { createStorage } from "@core/stores/utils/indexDB.ts";
import { type MeshDevice, Protobuf, Types } from "@meshtastic/core"; import { type MeshDevice, Protobuf, Types } from "@meshtastic/core";
import { produce } from "immer"; import { produce } from "immer";
import { create as createStore } from "zustand"; import { create as createStore, type StateCreator } from "zustand";
import {
export type Page = "messages" | "map" | "config" | "channels" | "nodes"; type PersistOptions,
persist,
export interface ProcessPacketParams { subscribeWithSelector,
from: number; } from "zustand/middleware";
snr: number; import type {
time: number; Dialogs,
} DialogVariant,
ValidConfigType,
export type DialogVariant = keyof Device["dialog"]; ValidModuleConfigType,
WaypointWithMetadata,
export type ValidConfigType = Exclude< } from "./types.ts";
Protobuf.Config.Config["payloadVariant"]["case"],
"deviceUi" | "sessionkey" | undefined const IDB_KEY_NAME = "meshtastic-device-store";
>; const CURRENT_STORE_VERSION = 0;
export type ValidModuleConfigType = Exclude< const DEVICESTORE_RETENTION_NUM = 10;
Protobuf.ModuleConfig.ModuleConfig["payloadVariant"]["case"], const TRACEROUTE_TARGET_RETENTION_NUM = 100; // Number of traceroutes targets to keep
undefined const TRACEROUTE_ROUTE_RETENTION_NUM = 100; // Number of traceroutes to keep per target
const WAYPOINT_RETENTION_NUM = 100;
type DeviceData = {
// Persisted data
id: number;
myNodeNum: number | undefined;
traceroutes: Map<
number,
Types.PacketMetadata<Protobuf.Mesh.RouteDiscovery>[]
>; >;
waypoints: WaypointWithMetadata[];
export type WaypointWithMetadata = Protobuf.Mesh.Waypoint & { neighborInfo: Map<number, Protobuf.Mesh.NeighborInfo>;
metadata: {
channel: number; // Channel on which the waypoint was received
created: Date; // Timestamp when the waypoint was received
updated?: Date; // Timestamp when the waypoint was last updated
from: number; // Node number of the device that sent the waypoint
};
}; };
export interface Device extends DeviceData {
export interface Device { // Ephemeral state (not persisted)
id: number;
status: Types.DeviceStatusEnum; status: Types.DeviceStatusEnum;
channels: Map<Types.ChannelNumber, Protobuf.Channel.Channel>; channels: Map<Types.ChannelNumber, Protobuf.Channel.Channel>;
config: Protobuf.LocalOnly.LocalConfig; config: Protobuf.LocalOnly.LocalConfig;
@ -42,36 +47,12 @@ export interface Device {
workingChannelConfig: Protobuf.Channel.Channel[]; workingChannelConfig: Protobuf.Channel.Channel[];
hardware: Protobuf.Mesh.MyNodeInfo; hardware: Protobuf.Mesh.MyNodeInfo;
metadata: Map<number, Protobuf.Mesh.DeviceMetadata>; metadata: Map<number, Protobuf.Mesh.DeviceMetadata>;
traceroutes: Map<
number,
Types.PacketMetadata<Protobuf.Mesh.RouteDiscovery>[]
>;
connection?: MeshDevice; connection?: MeshDevice;
activeNode: number; activeNode: number;
waypoints: WaypointWithMetadata[];
neighborInfo: Map<number, Protobuf.Mesh.NeighborInfo>;
pendingSettingsChanges: boolean; pendingSettingsChanges: boolean;
messageDraft: string; messageDraft: string;
unreadCounts: Map<number, number>; unreadCounts: Map<number, number>;
dialog: { dialog: Dialogs;
import: boolean;
QR: boolean;
shutdown: boolean;
reboot: boolean;
deviceName: boolean;
nodeRemoval: boolean;
pkiBackup: boolean;
nodeDetails: boolean;
unsafeRoles: boolean;
refreshKeys: boolean;
deleteMessages: boolean;
managedMode: boolean;
clientNotification: boolean;
resetNodeDb: boolean;
clearAllStores: boolean;
factoryResetDevice: boolean;
factoryResetConfig: boolean;
};
clientNotifications: Protobuf.Mesh.ClientNotification[]; clientNotifications: Protobuf.Mesh.ClientNotification[];
setStatus: (status: Types.DeviceStatusEnum) => void; setStatus: (status: Types.DeviceStatusEnum) => void;
@ -79,19 +60,12 @@ export interface Device {
setModuleConfig: (config: Protobuf.ModuleConfig.ModuleConfig) => void; setModuleConfig: (config: Protobuf.ModuleConfig.ModuleConfig) => void;
setWorkingConfig: (config: Protobuf.Config.Config) => void; setWorkingConfig: (config: Protobuf.Config.Config) => void;
setWorkingModuleConfig: (config: Protobuf.ModuleConfig.ModuleConfig) => void; setWorkingModuleConfig: (config: Protobuf.ModuleConfig.ModuleConfig) => void;
getWorkingConfig: ( getWorkingConfig<K extends ValidConfigType>(
payloadVariant: ValidConfigType, payloadVariant: K,
) => ): Protobuf.LocalOnly.LocalConfig[K] | undefined;
| Protobuf.LocalOnly.LocalConfig[Exclude<ValidConfigType, undefined>] getWorkingModuleConfig<K extends ValidModuleConfigType>(
| undefined; payloadVariant: K,
getWorkingModuleConfig: ( ): Protobuf.LocalOnly.LocalModuleConfig[K] | undefined;
payloadVariant: ValidModuleConfigType,
) =>
| Protobuf.LocalOnly.LocalModuleConfig[Exclude<
ValidModuleConfigType,
undefined
>]
| undefined;
removeWorkingConfig: (payloadVariant?: ValidConfigType) => void; removeWorkingConfig: (payloadVariant?: ValidConfigType) => void;
removeWorkingModuleConfig: (payloadVariant?: ValidModuleConfigType) => void; removeWorkingModuleConfig: (payloadVariant?: ValidModuleConfigType) => void;
getEffectiveConfig<K extends ValidConfigType>( getEffectiveConfig<K extends ValidConfigType>(
@ -115,6 +89,8 @@ export interface Device {
from: number, from: number,
rxTime: Date, rxTime: Date,
) => void; ) => void;
removeWaypoint: (waypointId: number, toMesh: boolean) => Promise<void>;
getWaypoint: (waypointId: number) => WaypointWithMetadata | undefined;
addConnection: (connection: MeshDevice) => void; addConnection: (connection: MeshDevice) => void;
addTraceRoute: ( addTraceRoute: (
traceroute: Types.PacketMetadata<Protobuf.Mesh.RouteDiscovery>, traceroute: Types.PacketMetadata<Protobuf.Mesh.RouteDiscovery>,
@ -142,27 +118,41 @@ export interface Device {
getNeighborInfo: (nodeNum: number) => Protobuf.Mesh.NeighborInfo | undefined; getNeighborInfo: (nodeNum: number) => Protobuf.Mesh.NeighborInfo | undefined;
} }
export interface DeviceState { export interface deviceState {
addDevice: (id: number) => Device; addDevice: (id: number) => Device;
removeDevice: (id: number) => void; removeDevice: (id: number) => void;
getDevices: () => Device[]; getDevices: () => Device[];
getDevice: (id: number) => Device | undefined; getDevice: (id: number) => Device | undefined;
} }
interface PrivateDeviceState extends DeviceState { interface PrivateDeviceState extends deviceState {
devices: Map<number, Device>; devices: Map<number, Device>;
remoteDevices: Map<number, undefined>;
} }
export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({ type DevicePersisted = {
devices: new Map(), devices: Map<number, DeviceData>;
remoteDevices: new Map(), };
addDevice: (id: number) => { function deviceFactory(
set( id: number,
produce<PrivateDeviceState>((draft) => { get: () => PrivateDeviceState,
draft.devices.set(id, { set: typeof useDeviceStore.setState,
data?: Partial<DeviceData>,
): Device {
const myNodeNum = data?.myNodeNum;
const traceroutes =
data?.traceroutes ??
new Map<number, Types.PacketMetadata<Protobuf.Mesh.RouteDiscovery>[]>();
const waypoints = data?.waypoints ?? [];
const neighborInfo =
data?.neighborInfo ?? new Map<number, Protobuf.Mesh.NeighborInfo>();
return {
id, id,
myNodeNum,
traceroutes,
waypoints,
neighborInfo,
status: Types.DeviceStatusEnum.DeviceDisconnected, status: Types.DeviceStatusEnum.DeviceDisconnected,
channels: new Map(), channels: new Map(),
config: create(Protobuf.LocalOnly.LocalConfigSchema), config: create(Protobuf.LocalOnly.LocalConfigSchema),
@ -172,11 +162,8 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
workingChannelConfig: [], workingChannelConfig: [],
hardware: create(Protobuf.Mesh.MyNodeInfoSchema), hardware: create(Protobuf.Mesh.MyNodeInfoSchema),
metadata: new Map(), metadata: new Map(),
traceroutes: new Map(),
connection: undefined, connection: undefined,
activeNode: 0, activeNode: 0,
waypoints: [],
neighborInfo: new Map(),
dialog: { dialog: {
import: false, import: false,
QR: false, QR: false,
@ -273,23 +260,19 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
break; break;
} }
case "storeForward": { case "storeForward": {
device.moduleConfig.storeForward = device.moduleConfig.storeForward = config.payloadVariant.value;
config.payloadVariant.value;
break; break;
} }
case "rangeTest": { case "rangeTest": {
device.moduleConfig.rangeTest = device.moduleConfig.rangeTest = config.payloadVariant.value;
config.payloadVariant.value;
break; break;
} }
case "telemetry": { case "telemetry": {
device.moduleConfig.telemetry = device.moduleConfig.telemetry = config.payloadVariant.value;
config.payloadVariant.value;
break; break;
} }
case "cannedMessage": { case "cannedMessage": {
device.moduleConfig.cannedMessage = device.moduleConfig.cannedMessage = config.payloadVariant.value;
config.payloadVariant.value;
break; break;
} }
case "audio": { case "audio": {
@ -297,8 +280,7 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
break; break;
} }
case "neighborInfo": { case "neighborInfo": {
device.moduleConfig.neighborInfo = device.moduleConfig.neighborInfo = config.payloadVariant.value;
config.payloadVariant.value;
break; break;
} }
case "ambientLighting": { case "ambientLighting": {
@ -312,8 +294,7 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
break; break;
} }
case "paxcounter": { case "paxcounter": {
device.moduleConfig.paxcounter = device.moduleConfig.paxcounter = config.payloadVariant.value;
config.payloadVariant.value;
break; break;
} }
} }
@ -351,8 +332,7 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
} }
const index = device.workingModuleConfig.findIndex( const index = device.workingModuleConfig.findIndex(
(wmc) => (wmc) =>
wmc.payloadVariant.case === wmc.payloadVariant.case === moduleConfig.payloadVariant.case,
moduleConfig.payloadVariant.case,
); );
if (index !== -1) { if (index !== -1) {
@ -364,7 +344,7 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
); );
}, },
getWorkingConfig: (payloadVariant: ValidConfigType) => { getWorkingConfig<K extends ValidConfigType>(payloadVariant: K) {
const device = get().devices.get(id); const device = get().devices.get(id);
if (!device) { if (!device) {
return; return;
@ -381,9 +361,12 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
return; return;
} }
return workingConfig?.payloadVariant.value; return workingConfig?.payloadVariant
.value as Protobuf.LocalOnly.LocalConfig[K];
}, },
getWorkingModuleConfig: (payloadVariant: ValidModuleConfigType) => { getWorkingModuleConfig<K extends ValidModuleConfigType>(
payloadVariant: K,
): Protobuf.LocalOnly.LocalModuleConfig[K] | undefined {
const device = get().devices.get(id); const device = get().devices.get(id);
if (!device) { if (!device) {
return; return;
@ -391,7 +374,7 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
return device.workingModuleConfig.find( return device.workingModuleConfig.find(
(c) => c.payloadVariant.case === payloadVariant, (c) => c.payloadVariant.case === payloadVariant,
)?.payloadVariant.value; )?.payloadVariant.value as Protobuf.LocalOnly.LocalModuleConfig[K];
}, },
removeWorkingConfig: (payloadVariant?: ValidConfigType) => { removeWorkingConfig: (payloadVariant?: ValidConfigType) => {
@ -418,9 +401,7 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
}), }),
); );
}, },
removeWorkingModuleConfig: ( removeWorkingModuleConfig: (payloadVariant?: ValidModuleConfigType) => {
payloadVariant?: ValidModuleConfigType,
) => {
set( set(
produce<PrivateDeviceState>((draft) => { produce<PrivateDeviceState>((draft) => {
const device = draft.devices.get(id); const device = draft.devices.get(id);
@ -537,10 +518,29 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
setHardware: (hardware: Protobuf.Mesh.MyNodeInfo) => { setHardware: (hardware: Protobuf.Mesh.MyNodeInfo) => {
set( set(
produce<PrivateDeviceState>((draft) => { produce<PrivateDeviceState>((draft) => {
const device = draft.devices.get(id); const newDevice = draft.devices.get(id);
if (device) { if (!newDevice) {
device.hardware = hardware; throw new Error(`No DeviceStore found for id: ${id}`);
}
newDevice.myNodeNum = hardware.myNodeNum;
for (const [otherId, oldStore] of draft.devices) {
if (otherId === id || oldStore.myNodeNum !== hardware.myNodeNum) {
continue;
}
newDevice.traceroutes = oldStore.traceroutes;
newDevice.neighborInfo = oldStore.neighborInfo;
// Take this opportunity to remove stale waypoints
newDevice.waypoints = oldStore.waypoints.filter(
(waypoint) => !waypoint?.expire || waypoint.expire > Date.now(),
);
// Drop old device
draft.devices.delete(otherId);
} }
newDevice.hardware = hardware; // Always replace hardware with latest
}), }),
); );
}, },
@ -568,10 +568,14 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
set( set(
produce<PrivateDeviceState>((draft) => { produce<PrivateDeviceState>((draft) => {
const device = draft.devices.get(id); const device = draft.devices.get(id);
if (device) { if (!device) {
return undefined;
}
const index = device.waypoints.findIndex( const index = device.waypoints.findIndex(
(wp) => wp.id === waypoint.id, (wp) => wp.id === waypoint.id,
); );
if (index !== -1) { if (index !== -1) {
const created = const created =
device.waypoints[index]?.metadata.created ?? new Date(); device.waypoints[index]?.metadata.created ?? new Date();
@ -580,17 +584,86 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
metadata: { created, updated: rxTime, from, channel }, metadata: { created, updated: rxTime, from, channel },
}; };
device.waypoints[index] = updatedWaypoint; // Remove existing waypoint
} else { device.waypoints.splice(index, 1);
// Push new if no expiry or not expired
if (waypoint.expire === 0 || waypoint.expire > Date.now()) {
device.waypoints.push(updatedWaypoint);
}
} else if (
// only add if set to never expire or not already expired
waypoint.expire === 0 ||
(waypoint.expire !== 0 && waypoint.expire < Date.now())
) {
device.waypoints.push({ device.waypoints.push({
...waypoint, ...waypoint,
metadata: { created: rxTime, from, channel }, metadata: { created: rxTime, from, channel },
}); });
} }
// Enforce retention limit
evictOldestEntries(device.waypoints, WAYPOINT_RETENTION_NUM);
}),
);
},
removeWaypoint: async (waypointId: number, toMesh: boolean) => {
const device = get().devices.get(id);
if (!device) {
return;
}
const waypoint = device.waypoints.find((wp) => wp.id === waypointId);
if (!waypoint) {
return;
}
if (toMesh) {
if (!device.connection) {
return;
}
const waypointToBroadcast = create(Protobuf.Mesh.WaypointSchema, {
id: waypoint.id, // Bare minimum to delete a waypoint
lockedTo: 0,
name: "",
description: "",
icon: 0,
expire: 1,
});
await device.connection.sendWaypoint(
waypointToBroadcast,
"broadcast",
waypoint.metadata.channel,
);
}
// Remove from store
set(
produce<PrivateDeviceState>((draft) => {
const device = draft.devices.get(id);
if (!device) {
return;
}
const idx = device.waypoints.findIndex(
(waypoint) => waypoint.id === waypointId,
);
if (idx >= 0) {
device.waypoints.splice(idx, 1);
} }
}), }),
); );
}, },
getWaypoint: (waypointId: number) => {
const device = get().devices.get(id);
if (!device) {
return;
}
return device.waypoints.find((waypoint) => waypoint.id === waypointId);
},
setActiveNode: (node) => { setActiveNode: (node) => {
set( set(
produce<PrivateDeviceState>((draft) => { produce<PrivateDeviceState>((draft) => {
@ -631,6 +704,13 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
const routes = device.traceroutes.get(traceroute.from) ?? []; const routes = device.traceroutes.get(traceroute.from) ?? [];
routes.push(traceroute); routes.push(traceroute);
device.traceroutes.set(traceroute.from, routes); device.traceroutes.set(traceroute.from, routes);
// Enforce retention limit, both in terms of targets (device.traceroutes) and routes per target (routes)
evictOldestEntries(routes, TRACEROUTE_ROUTE_RETENTION_NUM);
evictOldestEntries(
device.traceroutes,
TRACEROUTE_TARGET_RETENTION_NUM,
);
}), }),
); );
}, },
@ -775,26 +855,108 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
} }
return device.neighborInfo.get(nodeNum); return device.neighborInfo.get(nodeNum);
}, },
}); };
}
export const deviceStoreInitializer: StateCreator<PrivateDeviceState> = (
set,
get,
) => ({
devices: new Map(),
addDevice: (id) => {
const existing = get().devices.get(id);
if (existing) {
return existing;
}
const device = deviceFactory(id, get, set);
set(
produce<PrivateDeviceState>((draft) => {
draft.devices = new Map(draft.devices).set(id, device);
// Enforce retention limit
evictOldestEntries(draft.devices, DEVICESTORE_RETENTION_NUM);
}), }),
); );
const device = get().devices.get(id);
if (!device) {
throw new Error(`Failed to create or retrieve device with ID ${id}`);
}
return device; return device;
}, },
removeDevice: (id) => { removeDevice: (id) => {
set( set(
produce<PrivateDeviceState>((draft) => { produce<PrivateDeviceState>((draft) => {
draft.devices.delete(id); const updated = new Map(draft.devices);
updated.delete(id);
draft.devices = updated;
}), }),
); );
}, },
getDevices: () => Array.from(get().devices.values()), getDevices: () => Array.from(get().devices.values()),
getDevice: (id) => get().devices.get(id), getDevice: (id) => get().devices.get(id),
})); });
const persistOptions: PersistOptions<PrivateDeviceState, DevicePersisted> = {
name: IDB_KEY_NAME,
storage: createStorage<DevicePersisted>(),
version: CURRENT_STORE_VERSION,
partialize: (s): DevicePersisted => ({
devices: new Map(
Array.from(s.devices.entries()).map(([id, db]) => [
id,
{
id: db.id,
myNodeNum: db.myNodeNum,
traceroutes: db.traceroutes,
waypoints: db.waypoints,
neighborInfo: db.neighborInfo,
},
]),
),
}),
onRehydrateStorage: () => (state) => {
if (!state) {
return;
}
console.debug(
"DeviceStore: Rehydrating state with ",
state.devices.size,
" devices -",
state.devices,
);
useDeviceStore.setState(
produce<PrivateDeviceState>((draft) => {
const rebuilt = new Map<number, Device>();
for (const [id, data] of (
draft.devices as unknown as Map<number, DeviceData>
).entries()) {
if (data.myNodeNum !== undefined) {
// Only rebuild if there is a nodenum set otherwise orphan dbs will acumulate
rebuilt.set(
id,
deviceFactory(
id,
useDeviceStore.getState,
useDeviceStore.setState,
data,
),
);
}
}
draft.devices = rebuilt;
}),
);
},
};
// Add persist middleware on the store if the feature flag is enabled
const persistDevices = featureFlags.get("persistDevices");
console.debug(
`DeviceStore: Persisting devices is ${persistDevices ? "enabled" : "disabled"}`,
);
export const useDeviceStore = persistDevices
? createStore(
subscribeWithSelector(persist(deviceStoreInitializer, persistOptions)),
)
: createStore(subscribeWithSelector(deviceStoreInitializer));

52
packages/web/src/core/stores/deviceStore/types.ts

@ -0,0 +1,52 @@
import type { Protobuf } from "@meshtastic/core";
interface Dialogs {
import: boolean;
QR: boolean;
shutdown: boolean;
reboot: boolean;
deviceName: boolean;
nodeRemoval: boolean;
pkiBackup: boolean;
nodeDetails: boolean;
unsafeRoles: boolean;
refreshKeys: boolean;
deleteMessages: boolean;
managedMode: boolean;
clientNotification: boolean;
resetNodeDb: boolean;
clearAllStores: boolean;
factoryResetDevice: boolean;
factoryResetConfig: boolean;
}
type DialogVariant = keyof Dialogs;
type ValidConfigType = Exclude<
Protobuf.Config.Config["payloadVariant"]["case"],
"deviceUi" | "sessionkey" | undefined
>;
type ValidModuleConfigType = Exclude<
Protobuf.ModuleConfig.ModuleConfig["payloadVariant"]["case"],
undefined
>;
type Page = "messages" | "map" | "config" | "channels" | "nodes";
type WaypointWithMetadata = Protobuf.Mesh.Waypoint & {
metadata: {
channel: number; // Channel on which the waypoint was received
created: Date; // Timestamp when the waypoint was received
updated?: Date; // Timestamp when the waypoint was last updated
from: number; // Node number of the device that sent the waypoint
};
};
export type {
Page,
Dialogs,
DialogVariant,
ValidConfigType,
ValidModuleConfigType,
WaypointWithMetadata,
};

36
packages/web/src/core/stores/index.ts

@ -1,35 +1,37 @@
import { useDeviceContext } from "@core/hooks/useDeviceContext"; import { useDeviceContext } from "@core/hooks/useDeviceContext.ts";
import { type Device, useDeviceStore } from "@core/stores/deviceStore"; import { type Device, useDeviceStore } from "@core/stores/deviceStore/index.ts";
import { type MessageStore, useMessageStore } from "@core/stores/messageStore"; import {
import { type NodeDB, useNodeDBStore } from "@core/stores/nodeDBStore"; type MessageStore,
import { bindStoreToDevice } from "@core/stores/utils/bindStoreToDevice"; useMessageStore,
} from "@core/stores/messageStore/index.ts";
import { type NodeDB, useNodeDBStore } from "@core/stores/nodeDBStore/index.ts";
import { bindStoreToDevice } from "@core/stores/utils/bindStoreToDevice.ts";
export { export {
CurrentDeviceContext, CurrentDeviceContext,
type DeviceContext, type DeviceContext,
useDeviceContext, useDeviceContext,
} from "@core/hooks/useDeviceContext"; } from "@core/hooks/useDeviceContext";
export { useAppStore } from "@core/stores/appStore"; export { useAppStore } from "@core/stores/appStore/index.ts";
export { export { type Device, useDeviceStore } from "@core/stores/deviceStore/index.ts";
type Device, export type {
type Page, Page,
useDeviceStore, ValidConfigType,
type ValidConfigType, ValidModuleConfigType,
type ValidModuleConfigType, WaypointWithMetadata,
type WaypointWithMetadata, } from "@core/stores/deviceStore/types.ts";
} from "@core/stores/deviceStore";
export { export {
MessageState, MessageState,
type MessageStore, type MessageStore,
MessageType, MessageType,
useMessageStore, useMessageStore,
} from "@core/stores/messageStore"; } from "@core/stores/messageStore";
export { type NodeDB, useNodeDBStore } from "@core/stores/nodeDBStore"; export { type NodeDB, useNodeDBStore } from "@core/stores/nodeDBStore/index.ts";
export type { NodeErrorType } from "@core/stores/nodeDBStore/types"; export type { NodeErrorType } from "@core/stores/nodeDBStore/types.ts";
export { export {
SidebarProvider, SidebarProvider,
useSidebar, // TODO: Bring hook into this file useSidebar, // TODO: Bring hook into this file
} from "@core/stores/sidebarStore"; } from "@core/stores/sidebarStore/index.tsx";
// Re-export idb-keyval functions for clearing all stores, expand this if we add more local storage types // Re-export idb-keyval functions for clearing all stores, expand this if we add more local storage types
export { clear as clearAllStores } from "idb-keyval"; export { clear as clearAllStores } from "idb-keyval";

20
packages/web/src/core/stores/messageStore/index.ts

@ -17,6 +17,7 @@ import { produce } from "immer";
import { create as createStore, type StateCreator } from "zustand"; import { create as createStore, type StateCreator } from "zustand";
import { type PersistOptions, persist } from "zustand/middleware"; import { type PersistOptions, persist } from "zustand/middleware";
const IDB_KEY_NAME = "meshtastic-message-store";
const CURRENT_STORE_VERSION = 0; const CURRENT_STORE_VERSION = 0;
const MESSAGESTORE_RETENTION_NUM = 10; const MESSAGESTORE_RETENTION_NUM = 10;
const MESSAGELOG_RETENTION_NUM = 1000; // Max messages per conversation/channel const MESSAGELOG_RETENTION_NUM = 1000; // Max messages per conversation/channel
@ -43,14 +44,17 @@ export interface MessageBuckets {
direct: Map<ConversationId, MessageLogMap>; direct: Map<ConversationId, MessageLogMap>;
broadcast: Map<ChannelId, MessageLogMap>; broadcast: Map<ChannelId, MessageLogMap>;
} }
export interface MessageStore {
type MessageStoreData = {
// Persisted data
id: number; id: number;
myNodeNum: number | undefined; myNodeNum: number | undefined;
messages: MessageBuckets; messages: MessageBuckets;
drafts: Map<Types.Destination, string>; drafts: Map<Types.Destination, string>;
};
// Ephemeral UI state (not persisted) export interface MessageStore extends MessageStoreData {
// Ephemeral state (not persisted)
activeChat: number; activeChat: number;
chatType: MessageType; chatType: MessageType;
@ -78,14 +82,6 @@ interface PrivateMessageStoreState extends MessageStoreState {
messageStores: Map<number, MessageStore>; messageStores: Map<number, MessageStore>;
} }
type MessageStoreData = {
id: number;
myNodeNum: number | undefined;
messages: MessageBuckets;
drafts: Map<Types.Destination, string>;
};
type MessageStorePersisted = { type MessageStorePersisted = {
messageStores: Map<number, MessageStoreData>; messageStores: Map<number, MessageStoreData>;
}; };
@ -393,7 +389,7 @@ const persistOptions: PersistOptions<
PrivateMessageStoreState, PrivateMessageStoreState,
MessageStorePersisted MessageStorePersisted
> = { > = {
name: "meshtastic-message-store", name: IDB_KEY_NAME,
storage: createStorage<MessageStorePersisted>(), storage: createStorage<MessageStorePersisted>(),
version: CURRENT_STORE_VERSION, version: CURRENT_STORE_VERSION,
partialize: (s): MessageStorePersisted => ({ partialize: (s): MessageStorePersisted => ({

16
packages/web/src/core/stores/nodeDBStore/index.ts

@ -13,15 +13,20 @@ import {
} from "zustand/middleware"; } from "zustand/middleware";
import type { NodeError, NodeErrorType, ProcessPacketParams } from "./types.ts"; import type { NodeError, NodeErrorType, ProcessPacketParams } from "./types.ts";
const IDB_KEY_NAME = "meshtastic-nodedb-store";
const CURRENT_STORE_VERSION = 0; const CURRENT_STORE_VERSION = 0;
const NODEDB_RETENTION_NUM = 10; const NODEDB_RETENTION_NUM = 10;
export interface NodeDB { type NodeDBData = {
// Persisted data
id: number; id: number;
myNodeNum: number | undefined; myNodeNum: number | undefined;
nodeMap: Map<number, Protobuf.Mesh.NodeInfo>; nodeMap: Map<number, Protobuf.Mesh.NodeInfo>;
nodeErrors: Map<number, NodeError>; nodeErrors: Map<number, NodeError>;
};
export interface NodeDB extends NodeDBData {
// Ephemeral state (not persisted)
addNode: (nodeInfo: Protobuf.Mesh.NodeInfo) => void; addNode: (nodeInfo: Protobuf.Mesh.NodeInfo) => void;
removeNode: (nodeNum: number) => void; removeNode: (nodeNum: number) => void;
removeAllNodes: (keepMyNode?: boolean) => void; removeAllNodes: (keepMyNode?: boolean) => void;
@ -58,13 +63,6 @@ interface PrivateNodeDBState extends nodeDBState {
nodeDBs: Map<number, NodeDB>; nodeDBs: Map<number, NodeDB>;
} }
type NodeDBData = {
id: number;
myNodeNum: number | undefined;
nodeMap: Map<number, Protobuf.Mesh.NodeInfo>;
nodeErrors: Map<number, NodeError>;
};
type NodeDBPersisted = { type NodeDBPersisted = {
nodeDBs: Map<number, NodeDBData>; nodeDBs: Map<number, NodeDBData>;
}; };
@ -442,7 +440,7 @@ export const nodeDBInitializer: StateCreator<PrivateNodeDBState> = (
}); });
const persistOptions: PersistOptions<PrivateNodeDBState, NodeDBPersisted> = { const persistOptions: PersistOptions<PrivateNodeDBState, NodeDBPersisted> = {
name: "meshtastic-nodedb-store", name: IDB_KEY_NAME,
storage: createStorage<NodeDBPersisted>(), storage: createStorage<NodeDBPersisted>(),
version: CURRENT_STORE_VERSION, version: CURRENT_STORE_VERSION,
partialize: (s): NodeDBPersisted => ({ partialize: (s): NodeDBPersisted => ({

2
packages/web/src/core/stores/nodeDBStore/nodeDBStore.mock.ts

@ -27,4 +27,6 @@ export const mockNodeDBStore: NodeDB = {
updateFavorite: vi.fn(), updateFavorite: vi.fn(),
updateIgnore: vi.fn(), updateIgnore: vi.fn(),
setNodeNum: vi.fn(), setNodeNum: vi.fn(),
removeAllNodeErrors: vi.fn(),
removeAllNodes: vi.fn(),
}; };

2
packages/web/src/core/stores/nodeDBStore/nodeDBStore.test.tsx

@ -456,7 +456,7 @@ describe("NodeDB – merge semantics, PKI checks & extras", () => {
const newDB = st.addNodeDB(1101); const newDB = st.addNodeDB(1101);
newDB.setNodeNum(4242); newDB.setNodeNum(4242);
expect(newDB.getMyNode().num).toBe(4242); expect(newDB.getMyNode()?.num).toBe(4242);
}); });
}); });

24
packages/web/src/core/stores/utils/evictOldestEntries.ts

@ -1,14 +1,24 @@
export function evictOldestEntries<K, V>( export function evictOldestEntries<T>(arr: T[], maxSize: number): void;
map: Map<K, V>, export function evictOldestEntries<K, V>(map: Map<K, V>, maxSize: number): void;
export function evictOldestEntries<T, K, V>(
collection: T[] | Map<K, V>,
maxSize: number, maxSize: number,
): void { ): void {
// while loop in case maxSize is ever changed to be lower, to trim all the way down if (Array.isArray(collection)) {
while (map.size > maxSize) { // Trim array from the front (assuming oldest entries are at the start)
const firstKey = map.keys().next().value; // maps keep insertion order, so this is oldest while (collection.length > maxSize) {
collection.shift();
}
} else if (collection instanceof Map) {
// Trim map by insertion order
while (collection.size > maxSize) {
const firstKey = collection.keys().next().value;
if (firstKey !== undefined) { if (firstKey !== undefined) {
map.delete(firstKey); collection.delete(firstKey);
} else { } else {
break; // should not happen, but just in case break;
}
} }
} }
} }

21
packages/web/src/pages/Dashboard/index.tsx

@ -3,18 +3,21 @@ import { Button } from "@components/UI/Button.tsx";
import { Separator } from "@components/UI/Separator.tsx"; import { Separator } from "@components/UI/Separator.tsx";
import { Heading } from "@components/UI/Typography/Heading.tsx"; import { Heading } from "@components/UI/Typography/Heading.tsx";
import { Subtle } from "@components/UI/Typography/Subtle.tsx"; import { Subtle } from "@components/UI/Typography/Subtle.tsx";
import { useAppStore, useDeviceStore, useNodeDBStore } from "@core/stores"; import {
import { ListPlusIcon, PlusIcon, UsersIcon } from "lucide-react"; useAppStore /*, useDeviceStore, useNodeDBStore */,
import { useMemo } from "react"; } from "@core/stores";
import { ListPlusIcon, PlusIcon /*, UsersIcon */ } from "lucide-react";
/* import { useMemo } from "react"; */
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
export const Dashboard = () => { export const Dashboard = () => {
const { t } = useTranslation("dashboard"); const { t } = useTranslation("dashboard");
const { setConnectDialogOpen, setSelectedDevice } = useAppStore(); const { setConnectDialogOpen /*, setSelectedDevice*/ } = useAppStore();
/*
const { getDevices } = useDeviceStore(); const { getDevices } = useDeviceStore();
const { getNodeDB } = useNodeDBStore(); const { getNodeDB } = useNodeDBStore();
const devices = useMemo(() => getDevices(), [getDevices]); const devices = useMemo(() => getDevices(), [getDevices]);
*/
return ( return (
<div className="flex flex-col gap-3 p-3 px-8"> <div className="flex flex-col gap-3 p-3 px-8">
@ -29,7 +32,8 @@ export const Dashboard = () => {
<Separator /> <Separator />
<div className="flex h-[450px] rounded-md border border-dashed border-slate-200 p-3 dark:border-slate-700"> <div className="flex h-[450px] rounded-md border border-dashed border-slate-200 p-3 dark:border-slate-700">
{devices.length ? ( {
/*devices.length ? (
<ul className="grow divide-y divide-slate-200"> <ul className="grow divide-y divide-slate-200">
{devices.map((device) => { {devices.map((device) => {
const nodeDB = getNodeDB(device.id); const nodeDB = getNodeDB(device.id);
@ -69,8 +73,7 @@ export const Dashboard = () => {
); );
})} })}
</ul> </ul>
) : ( ) : */ <div className="m-auto flex flex-col gap-3 text-center">
<div className="m-auto flex flex-col gap-3 text-center">
<ListPlusIcon size={48} className="mx-auto text-text-secondary" /> <ListPlusIcon size={48} className="mx-auto text-text-secondary" />
<Heading as="h3">{t("dashboard.noDevicesTitle")}</Heading> <Heading as="h3">{t("dashboard.noDevicesTitle")}</Heading>
<Subtle>{t("dashboard.noDevicesDescription")}</Subtle> <Subtle>{t("dashboard.noDevicesDescription")}</Subtle>
@ -83,7 +86,7 @@ export const Dashboard = () => {
{t("dashboard.button_newConnection")} {t("dashboard.button_newConnection")}
</Button> </Button>
</div> </div>
)} }
</div> </div>
</div> </div>
); );

Loading…
Cancel
Save