mirror of
https://github.com/meshtastic/web.git
synced 2025-12-23 15:51:28 -05:00
Persists device and app stores across sessions (#860)
* Persistence for device and app data * esphemeral -> ephemeral Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * devices -> app Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Additional waypoint methods, update mock, update tests --------- Co-authored-by: philon- <philon-@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -1,29 +1,41 @@
|
||||
// FactoryResetDeviceDialog.test.tsx
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { FactoryResetDeviceDialog } from "./FactoryResetDeviceDialog.tsx";
|
||||
|
||||
const mockFactoryResetDevice = vi.fn();
|
||||
const mockDeleteAllMessages = vi.fn();
|
||||
const mockRemoveAllNodeErrors = vi.fn();
|
||||
const mockRemoveAllNodes = vi.fn();
|
||||
const mockRemoveDevice = vi.fn();
|
||||
const mockRemoveMessageStore = vi.fn();
|
||||
const mockRemoveNodeDB = vi.fn();
|
||||
const mockToast = vi.fn();
|
||||
|
||||
vi.mock("@core/stores", () => ({
|
||||
CurrentDeviceContext: {
|
||||
_currentValue: { deviceId: 1234 },
|
||||
},
|
||||
useDevice: () => ({
|
||||
connection: {
|
||||
factoryResetDevice: mockFactoryResetDevice,
|
||||
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: {
|
||||
_currentValue: { deviceId: 1234 },
|
||||
},
|
||||
}),
|
||||
useMessages: () => ({
|
||||
deleteAllMessages: mockDeleteAllMessages,
|
||||
}),
|
||||
useNodeDB: () => ({
|
||||
removeAllNodeErrors: mockRemoveAllNodeErrors,
|
||||
removeAllNodes: mockRemoveAllNodes,
|
||||
}),
|
||||
useDevice: () => ({
|
||||
id: 42,
|
||||
connection: { factoryResetDevice: mockFactoryResetDevice },
|
||||
}),
|
||||
useDeviceStore,
|
||||
useMessageStore,
|
||||
useNodeDBStore,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@core/hooks/useToast.ts", () => ({
|
||||
toast: (...args: unknown[]) => mockToast(...args),
|
||||
}));
|
||||
|
||||
describe("FactoryResetDeviceDialog", () => {
|
||||
@@ -31,10 +43,11 @@ describe("FactoryResetDeviceDialog", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
mockOnOpenChange.mockClear();
|
||||
mockFactoryResetDevice.mockClear();
|
||||
mockDeleteAllMessages.mockClear();
|
||||
mockRemoveAllNodeErrors.mockClear();
|
||||
mockRemoveAllNodes.mockClear();
|
||||
mockFactoryResetDevice.mockReset();
|
||||
mockRemoveDevice.mockClear();
|
||||
mockRemoveMessageStore.mockClear();
|
||||
mockRemoveNodeDB.mockClear();
|
||||
mockToast.mockClear();
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
// 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
|
||||
resolveReset?.();
|
||||
|
||||
// Now the .then() chain should fire
|
||||
await waitFor(() => {
|
||||
expect(mockDeleteAllMessages).toHaveBeenCalledTimes(1);
|
||||
expect(mockRemoveAllNodeErrors).toHaveBeenCalledTimes(1);
|
||||
expect(mockRemoveAllNodes).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(mockRemoveDevice).toHaveBeenCalledTimes(1);
|
||||
expect(mockRemoveMessageStore).toHaveBeenCalledTimes(1);
|
||||
expect(mockRemoveNodeDB).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
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(mockDeleteAllMessages).not.toHaveBeenCalled();
|
||||
expect(mockRemoveAllNodeErrors).not.toHaveBeenCalled();
|
||||
expect(mockRemoveAllNodes).not.toHaveBeenCalled();
|
||||
expect(mockRemoveDevice).not.toHaveBeenCalled();
|
||||
expect(mockRemoveMessageStore).not.toHaveBeenCalled();
|
||||
expect(mockRemoveNodeDB).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
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 { DialogWrapper } from "../DialogWrapper.tsx";
|
||||
|
||||
@@ -13,24 +18,24 @@ export const FactoryResetDeviceDialog = ({
|
||||
onOpenChange,
|
||||
}: FactoryResetDeviceDialogProps) => {
|
||||
const { t } = useTranslation("dialog");
|
||||
const { connection } = useDevice();
|
||||
const { removeAllNodeErrors, removeAllNodes } = useNodeDB();
|
||||
const { deleteAllMessages } = useMessages();
|
||||
const { connection, id } = useDevice();
|
||||
|
||||
const handleFactoryResetDevice = () => {
|
||||
connection
|
||||
?.factoryResetDevice()
|
||||
.then(() => {
|
||||
deleteAllMessages();
|
||||
removeAllNodeErrors();
|
||||
removeAllNodes();
|
||||
})
|
||||
.catch((error) => {
|
||||
toast({
|
||||
title: t("factoryResetDevice.failedTitle"),
|
||||
});
|
||||
console.error("Failed to factory reset device:", error);
|
||||
connection?.factoryResetDevice().catch((error) => {
|
||||
toast({
|
||||
title: t("factoryResetDevice.failedTitle"),
|
||||
});
|
||||
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 (
|
||||
|
||||
@@ -7,5 +7,7 @@ if (isDev) {
|
||||
featureFlags.setOverrides({
|
||||
persistNodeDB: true,
|
||||
persistMessages: true,
|
||||
persistDevices: true,
|
||||
persistApp: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import { z } from "zod";
|
||||
export const FLAG_ENV = {
|
||||
persistNodeDB: "VITE_PERSIST_NODE_DB",
|
||||
persistMessages: "VITE_PERSIST_MESSAGES",
|
||||
persistDevices: "VITE_PERSIST_DEVICES",
|
||||
persistApp: "VITE_PERSIST_APP",
|
||||
} as const;
|
||||
|
||||
export type FlagKey = keyof typeof FLAG_ENV;
|
||||
|
||||
177
packages/web/src/core/stores/appStore/appStore.test.ts
Normal file
177
packages/web/src/core/stores/appStore/appStore.test.ts
Normal file
@@ -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
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,41 +1,42 @@
|
||||
import { featureFlags } from "@core/services/featureFlags.ts";
|
||||
import { createStorage } from "@core/stores/utils/indexDB.ts";
|
||||
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 {
|
||||
enabled: boolean;
|
||||
title: string;
|
||||
tiles: string;
|
||||
tileSize: number;
|
||||
}
|
||||
const IDB_KEY_NAME = "meshtastic-app-store";
|
||||
const CURRENT_STORE_VERSION = 0;
|
||||
|
||||
interface AppState {
|
||||
selectedDeviceId: number;
|
||||
devices: {
|
||||
id: number;
|
||||
num: number;
|
||||
}[];
|
||||
type AppData = {
|
||||
// Persisted data
|
||||
rasterSources: RasterSource[];
|
||||
commandPaletteOpen: boolean;
|
||||
};
|
||||
|
||||
export interface AppState extends AppData {
|
||||
// Ephemeral state (not persisted)
|
||||
selectedDeviceId: number;
|
||||
nodeNumToBeRemoved: number;
|
||||
connectDialogOpen: boolean;
|
||||
nodeNumDetails: number;
|
||||
commandPaletteOpen: boolean;
|
||||
|
||||
setRasterSources: (sources: RasterSource[]) => void;
|
||||
addRasterSource: (source: RasterSource) => void;
|
||||
removeRasterSource: (index: number) => void;
|
||||
setSelectedDevice: (deviceId: number) => void;
|
||||
addDevice: (device: { id: number; num: number }) => void;
|
||||
removeDevice: (deviceId: number) => void;
|
||||
setCommandPaletteOpen: (open: boolean) => void;
|
||||
setNodeNumToBeRemoved: (nodeNum: number) => void;
|
||||
setConnectDialogOpen: (open: boolean) => void;
|
||||
setNodeNumDetails: (nodeNum: number) => void;
|
||||
}
|
||||
|
||||
export const useAppStore = create<AppState>()((set, _get) => ({
|
||||
export const deviceStoreInitializer: StateCreator<AppState> = (set, _get) => ({
|
||||
selectedDeviceId: 0,
|
||||
devices: [],
|
||||
currentPage: "messages",
|
||||
rasterSources: [],
|
||||
commandPaletteOpen: false,
|
||||
connectDialogOpen: false,
|
||||
@@ -67,14 +68,6 @@ export const useAppStore = create<AppState>()((set, _get) => ({
|
||||
set(() => ({
|
||||
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) => {
|
||||
set(
|
||||
produce<AppState>((draft) => {
|
||||
@@ -93,9 +86,35 @@ export const useAppStore = create<AppState>()((set, _get) => ({
|
||||
}),
|
||||
);
|
||||
},
|
||||
|
||||
setNodeNumDetails: (nodeNum) =>
|
||||
set(() => ({
|
||||
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
Normal file
6
packages/web/src/core/stores/appStore/types.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export interface RasterSource {
|
||||
enabled: boolean;
|
||||
title: string;
|
||||
tiles: string;
|
||||
tileSize: number;
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import type { Device } from "./index.ts";
|
||||
*/
|
||||
export const mockDeviceStore: Device = {
|
||||
id: 0,
|
||||
myNodeNum: 123456,
|
||||
status: 5 as const,
|
||||
channels: new Map(),
|
||||
config: {} as Protobuf.LocalOnly.LocalConfig,
|
||||
@@ -44,8 +45,13 @@ export const mockDeviceStore: Device = {
|
||||
deleteMessages: false,
|
||||
managedMode: false,
|
||||
clientNotification: false,
|
||||
resetNodeDb: false,
|
||||
clearAllStores: false,
|
||||
factoryResetConfig: false,
|
||||
factoryResetDevice: false,
|
||||
},
|
||||
clientNotifications: [],
|
||||
neighborInfo: new Map(),
|
||||
|
||||
setStatus: vi.fn(),
|
||||
setConfig: vi.fn(),
|
||||
@@ -66,6 +72,8 @@ export const mockDeviceStore: Device = {
|
||||
setPendingSettingsChanges: vi.fn(),
|
||||
addChannel: vi.fn(),
|
||||
addWaypoint: vi.fn(),
|
||||
removeWaypoint: vi.fn(),
|
||||
getWaypoint: vi.fn(),
|
||||
addConnection: vi.fn(),
|
||||
addTraceRoute: vi.fn(),
|
||||
addMetadata: vi.fn(),
|
||||
@@ -80,4 +88,6 @@ export const mockDeviceStore: Device = {
|
||||
getClientNotification: vi.fn(),
|
||||
getAllUnreadCount: 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
Normal file
516
packages/web/src/core/stores/deviceStore/deviceStore.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
52
packages/web/src/core/stores/deviceStore/types.ts
Normal file
52
packages/web/src/core/stores/deviceStore/types.ts
Normal file
@@ -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,
|
||||
};
|
||||
@@ -1,35 +1,37 @@
|
||||
import { useDeviceContext } from "@core/hooks/useDeviceContext";
|
||||
import { type Device, useDeviceStore } from "@core/stores/deviceStore";
|
||||
import { type MessageStore, useMessageStore } from "@core/stores/messageStore";
|
||||
import { type NodeDB, useNodeDBStore } from "@core/stores/nodeDBStore";
|
||||
import { bindStoreToDevice } from "@core/stores/utils/bindStoreToDevice";
|
||||
import { useDeviceContext } from "@core/hooks/useDeviceContext.ts";
|
||||
import { type Device, useDeviceStore } from "@core/stores/deviceStore/index.ts";
|
||||
import {
|
||||
type MessageStore,
|
||||
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 {
|
||||
CurrentDeviceContext,
|
||||
type DeviceContext,
|
||||
useDeviceContext,
|
||||
} from "@core/hooks/useDeviceContext";
|
||||
export { useAppStore } from "@core/stores/appStore";
|
||||
export {
|
||||
type Device,
|
||||
type Page,
|
||||
useDeviceStore,
|
||||
type ValidConfigType,
|
||||
type ValidModuleConfigType,
|
||||
type WaypointWithMetadata,
|
||||
} from "@core/stores/deviceStore";
|
||||
export { useAppStore } from "@core/stores/appStore/index.ts";
|
||||
export { type Device, useDeviceStore } from "@core/stores/deviceStore/index.ts";
|
||||
export type {
|
||||
Page,
|
||||
ValidConfigType,
|
||||
ValidModuleConfigType,
|
||||
WaypointWithMetadata,
|
||||
} from "@core/stores/deviceStore/types.ts";
|
||||
export {
|
||||
MessageState,
|
||||
type MessageStore,
|
||||
MessageType,
|
||||
useMessageStore,
|
||||
} from "@core/stores/messageStore";
|
||||
export { type NodeDB, useNodeDBStore } from "@core/stores/nodeDBStore";
|
||||
export type { NodeErrorType } from "@core/stores/nodeDBStore/types";
|
||||
export { type NodeDB, useNodeDBStore } from "@core/stores/nodeDBStore/index.ts";
|
||||
export type { NodeErrorType } from "@core/stores/nodeDBStore/types.ts";
|
||||
export {
|
||||
SidebarProvider,
|
||||
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
|
||||
export { clear as clearAllStores } from "idb-keyval";
|
||||
|
||||
@@ -17,6 +17,7 @@ import { produce } from "immer";
|
||||
import { create as createStore, type StateCreator } from "zustand";
|
||||
import { type PersistOptions, persist } from "zustand/middleware";
|
||||
|
||||
const IDB_KEY_NAME = "meshtastic-message-store";
|
||||
const CURRENT_STORE_VERSION = 0;
|
||||
const MESSAGESTORE_RETENTION_NUM = 10;
|
||||
const MESSAGELOG_RETENTION_NUM = 1000; // Max messages per conversation/channel
|
||||
@@ -43,14 +44,17 @@ export interface MessageBuckets {
|
||||
direct: Map<ConversationId, MessageLogMap>;
|
||||
broadcast: Map<ChannelId, MessageLogMap>;
|
||||
}
|
||||
export interface MessageStore {
|
||||
|
||||
type MessageStoreData = {
|
||||
// Persisted data
|
||||
id: number;
|
||||
myNodeNum: number | undefined;
|
||||
|
||||
messages: MessageBuckets;
|
||||
drafts: Map<Types.Destination, string>;
|
||||
};
|
||||
|
||||
// Ephemeral UI state (not persisted)
|
||||
export interface MessageStore extends MessageStoreData {
|
||||
// Ephemeral state (not persisted)
|
||||
activeChat: number;
|
||||
chatType: MessageType;
|
||||
|
||||
@@ -78,14 +82,6 @@ interface PrivateMessageStoreState extends MessageStoreState {
|
||||
messageStores: Map<number, MessageStore>;
|
||||
}
|
||||
|
||||
type MessageStoreData = {
|
||||
id: number;
|
||||
myNodeNum: number | undefined;
|
||||
|
||||
messages: MessageBuckets;
|
||||
drafts: Map<Types.Destination, string>;
|
||||
};
|
||||
|
||||
type MessageStorePersisted = {
|
||||
messageStores: Map<number, MessageStoreData>;
|
||||
};
|
||||
@@ -393,7 +389,7 @@ const persistOptions: PersistOptions<
|
||||
PrivateMessageStoreState,
|
||||
MessageStorePersisted
|
||||
> = {
|
||||
name: "meshtastic-message-store",
|
||||
name: IDB_KEY_NAME,
|
||||
storage: createStorage<MessageStorePersisted>(),
|
||||
version: CURRENT_STORE_VERSION,
|
||||
partialize: (s): MessageStorePersisted => ({
|
||||
|
||||
@@ -13,15 +13,20 @@ import {
|
||||
} from "zustand/middleware";
|
||||
import type { NodeError, NodeErrorType, ProcessPacketParams } from "./types.ts";
|
||||
|
||||
const IDB_KEY_NAME = "meshtastic-nodedb-store";
|
||||
const CURRENT_STORE_VERSION = 0;
|
||||
const NODEDB_RETENTION_NUM = 10;
|
||||
|
||||
export interface NodeDB {
|
||||
type NodeDBData = {
|
||||
// Persisted data
|
||||
id: number;
|
||||
myNodeNum: number | undefined;
|
||||
nodeMap: Map<number, Protobuf.Mesh.NodeInfo>;
|
||||
nodeErrors: Map<number, NodeError>;
|
||||
};
|
||||
|
||||
export interface NodeDB extends NodeDBData {
|
||||
// Ephemeral state (not persisted)
|
||||
addNode: (nodeInfo: Protobuf.Mesh.NodeInfo) => void;
|
||||
removeNode: (nodeNum: number) => void;
|
||||
removeAllNodes: (keepMyNode?: boolean) => void;
|
||||
@@ -58,13 +63,6 @@ interface PrivateNodeDBState extends nodeDBState {
|
||||
nodeDBs: Map<number, NodeDB>;
|
||||
}
|
||||
|
||||
type NodeDBData = {
|
||||
id: number;
|
||||
myNodeNum: number | undefined;
|
||||
nodeMap: Map<number, Protobuf.Mesh.NodeInfo>;
|
||||
nodeErrors: Map<number, NodeError>;
|
||||
};
|
||||
|
||||
type NodeDBPersisted = {
|
||||
nodeDBs: Map<number, NodeDBData>;
|
||||
};
|
||||
@@ -442,7 +440,7 @@ export const nodeDBInitializer: StateCreator<PrivateNodeDBState> = (
|
||||
});
|
||||
|
||||
const persistOptions: PersistOptions<PrivateNodeDBState, NodeDBPersisted> = {
|
||||
name: "meshtastic-nodedb-store",
|
||||
name: IDB_KEY_NAME,
|
||||
storage: createStorage<NodeDBPersisted>(),
|
||||
version: CURRENT_STORE_VERSION,
|
||||
partialize: (s): NodeDBPersisted => ({
|
||||
|
||||
@@ -27,4 +27,6 @@ export const mockNodeDBStore: NodeDB = {
|
||||
updateFavorite: vi.fn(),
|
||||
updateIgnore: vi.fn(),
|
||||
setNodeNum: vi.fn(),
|
||||
removeAllNodeErrors: vi.fn(),
|
||||
removeAllNodes: vi.fn(),
|
||||
};
|
||||
|
||||
@@ -456,7 +456,7 @@ describe("NodeDB – merge semantics, PKI checks & extras", () => {
|
||||
const newDB = st.addNodeDB(1101);
|
||||
newDB.setNodeNum(4242);
|
||||
|
||||
expect(newDB.getMyNode().num).toBe(4242);
|
||||
expect(newDB.getMyNode()?.num).toBe(4242);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,14 +1,24 @@
|
||||
export function evictOldestEntries<K, V>(
|
||||
map: Map<K, V>,
|
||||
export function evictOldestEntries<T>(arr: T[], maxSize: number): void;
|
||||
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,
|
||||
): void {
|
||||
// while loop in case maxSize is ever changed to be lower, to trim all the way down
|
||||
while (map.size > maxSize) {
|
||||
const firstKey = map.keys().next().value; // maps keep insertion order, so this is oldest
|
||||
if (firstKey !== undefined) {
|
||||
map.delete(firstKey);
|
||||
} else {
|
||||
break; // should not happen, but just in case
|
||||
if (Array.isArray(collection)) {
|
||||
// Trim array from the front (assuming oldest entries are at the start)
|
||||
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) {
|
||||
collection.delete(firstKey);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,18 +3,21 @@ import { Button } from "@components/UI/Button.tsx";
|
||||
import { Separator } from "@components/UI/Separator.tsx";
|
||||
import { Heading } from "@components/UI/Typography/Heading.tsx";
|
||||
import { Subtle } from "@components/UI/Typography/Subtle.tsx";
|
||||
import { useAppStore, useDeviceStore, useNodeDBStore } from "@core/stores";
|
||||
import { ListPlusIcon, PlusIcon, UsersIcon } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
useAppStore /*, useDeviceStore, useNodeDBStore */,
|
||||
} from "@core/stores";
|
||||
import { ListPlusIcon, PlusIcon /*, UsersIcon */ } from "lucide-react";
|
||||
/* import { useMemo } from "react"; */
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export const Dashboard = () => {
|
||||
const { t } = useTranslation("dashboard");
|
||||
const { setConnectDialogOpen, setSelectedDevice } = useAppStore();
|
||||
const { setConnectDialogOpen /*, setSelectedDevice*/ } = useAppStore();
|
||||
/*
|
||||
const { getDevices } = useDeviceStore();
|
||||
const { getNodeDB } = useNodeDBStore();
|
||||
|
||||
const devices = useMemo(() => getDevices(), [getDevices]);
|
||||
*/
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 p-3 px-8">
|
||||
@@ -29,7 +32,8 @@ export const Dashboard = () => {
|
||||
<Separator />
|
||||
|
||||
<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">
|
||||
{devices.map((device) => {
|
||||
const nodeDB = getNodeDB(device.id);
|
||||
@@ -69,8 +73,7 @@ export const Dashboard = () => {
|
||||
);
|
||||
})}
|
||||
</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" />
|
||||
<Heading as="h3">{t("dashboard.noDevicesTitle")}</Heading>
|
||||
<Subtle>{t("dashboard.noDevicesDescription")}</Subtle>
|
||||
@@ -83,7 +86,7 @@ export const Dashboard = () => {
|
||||
{t("dashboard.button_newConnection")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user