Add monorepo support to project (#682)

* feat: add monorepo support

* fix: correct relatedProjects structure in vercel.json

* update github actions with monorepo support

* removed legacy dep

* moved jsr config into web package
This commit is contained in:
Dan Ditomaso
2025-06-25 21:56:55 -04:00
committed by GitHub
parent 85ee0747d1
commit 1c13343e8a
463 changed files with 7568 additions and 2196 deletions
@@ -0,0 +1,8 @@
export const Blur = () => {
return (
<div
className="fixed inset-0 backdrop-blur-md backdrop-brightness-press"
aria-hidden="true"
/>
);
};
@@ -0,0 +1,53 @@
export interface DeviceImageProps {
deviceType: string;
className?: React.HTMLAttributes<HTMLImageElement>["className"];
}
const hardwareModelToFilename: { [key: string]: string } = {
DIY_V1: "diy.svg",
NANO_G2_ULTRA: "nano-g2-ultra.svg",
TBEAM: "tbeam.svg",
HELTEC_HT62: "heltec-ht62-esp32c3-sx1262.svg",
RPI_PICO: "pico.svg",
T_DECK: "t-deck.svg",
HELTEC_MESH_NODE_T114: "heltec-mesh-node-t114.svg",
HELTEC_MESH_NODE_T114_CASE: "heltec-mesh-node-t114-case.svg",
HELTEC_V3: "heltec-v3.svg",
HELTEC_V3_CASE: "heltec-v3-case.svg",
HELTEC_VISION_MASTER_E213: "heltec-vision-master-e213.svg",
HELTEC_VISION_MASTER_E290: "heltec-vision-master-e290.svg",
HELTEC_VISION_MASTER_T190: "heltec-vision-master-t190.svg",
HELTEC_WIRELESS_PAPER: "heltec-wireless-paper.svg",
HELTEC_WIRELESS_PAPER_V1_0: "heltec-wireless-paper-V1_0.svg",
HELTEC_WIRELESS_TRACKER: "heltec-wireless-tracker.svg",
HELTEC_WIRELESS_TRACKER_V1_0: "heltec-wireless-tracker-V1-0.svg",
HELTEC_WSL_V3: "heltec-wsl-v3.svg",
TLORA_C6: "tlora-c6.svg",
TLORA_T3_S3: "tlora-t3s3-v1.svg",
TLORA_T3_S3_EPAPER: "tlora-t3s3-epaper.svg",
TLORA_V2: "tlora-v2-1-1_6.svg",
TLORA_V2_1_1P6: "tlora-v2-1-1_6.svg",
TLORA_V2_1_1P8: "tlora-v2-1-1_8.svg",
RAK11310: "rak11310.svg",
RAK2560: "rak2560.svg",
RAK4631: "rak4631.svg",
RAK4631_CASE: "rak4631_case.svg",
WIO_WM1110: "wio-tracker-wm1110.svg",
WM1110_DEV_KIT: "wm1110_dev_kit.svg",
STATION_G2: "station-g2.svg",
TBEAM_V0P7: "tbeam-s3-core.svg",
T_ECHO: "t-echo.svg",
TRACKER_T1000_E: "tracker-t1000-e.svg",
T_WATCH_S3: "t-watch-s3.svg",
SEEED_XIAO_S3: "seeed-xiao-s3.svg",
SENSECAP_INDICATOR: "seeed-sensecap-indicator.svg",
PROMICRO: "promicro.svg",
RPIPICOW: "rpipicow.svg",
UNKNOWN: "unknown.svg",
};
export const DeviceImage = ({ deviceType, className }: DeviceImageProps) => {
const getPath = (device: string) => `/${device}`;
const device = hardwareModelToFilename[deviceType] || "unknown.svg";
return <img className={className} src={getPath(device)} alt={device} />;
};
@@ -0,0 +1,218 @@
import {
AccordionContent,
AccordionHeader,
AccordionItem,
AccordionTrigger,
} from "@components/UI/Accordion.tsx";
import { Checkbox } from "@components/UI/Checkbox/index.tsx";
import { Slider } from "@components/UI/Slider.tsx";
import { ScrollArea } from "@components/UI/ScrollArea.tsx";
import { ToggleGroup, ToggleGroupItem } from "@components/UI/ToggleGroup.tsx";
import { ReactNode } from "react";
import type { FilterState } from "@components/generic/Filter/useFilterNode.ts";
import { cn } from "@core/utils/cn.ts";
interface FilterAccordionItemProps {
label: string;
children?: ReactNode;
}
type RangeKeys<T> = {
[K in keyof T]: T[K] extends [number, number] ? K : never;
}[keyof T];
interface FilterSliderProps<K extends RangeKeys<FilterState>> {
filterKey: K;
filterState: FilterState;
defaultFilterValues: FilterState;
onChange: (key: K) => (value: number[]) => void;
labelContent?: React.ReactNode;
label?: string;
step?: number;
}
type EnumArrayKeys<T> = {
[K in keyof T]: T[K] extends number[] ? K : never;
}[keyof T];
interface FilterMultiProps<K extends EnumArrayKeys<FilterState>> {
filterKey: K;
options: number[];
filterState: FilterState;
setFilterState: React.Dispatch<React.SetStateAction<FilterState>>;
getLabel?: (value: number) => string;
}
interface FilterToggleProps<K extends keyof FilterState> {
label: string;
alternativeLabels: [string, string];
filterKey: K;
filterState: FilterState;
onChange: (key: K, value: string) => void;
}
export const FilterAccordionItem = ({
label,
children,
}: FilterAccordionItemProps) => {
return (
<AccordionItem value={label}>
<AccordionHeader>
<AccordionTrigger
className={cn(
"w-full text-left font-bold text-sm px-1 py-2 dark:border-slate-700 text-slate-800 dark:text-slate-200",
)}
>
{label}
</AccordionTrigger>
</AccordionHeader>
<AccordionContent
className={cn(
"px-1 pb-4 pt-2 space-y-3 dark:border-slate-700",
)}
>
{children}
</AccordionContent>
</AccordionItem>
);
};
export const FilterSlider = <K extends RangeKeys<FilterState>>({
filterKey,
filterState,
defaultFilterValues,
onChange,
labelContent,
label,
step,
}: FilterSliderProps<K>) => {
const value: [number, number] = filterState[filterKey];
const defaultValue: [number, number] = defaultFilterValues[filterKey];
const showRange = value[0] !== value[1];
const defaultLabel = (
<>
{label}: {value[0]}
{showRange ? ` — ${value[1]}` : ""}
</>
);
return (
<div className="space-y-2">
<label className="block text-sm font-medium">
{labelContent ?? defaultLabel}
</label>
<Slider
value={value}
min={defaultValue[0]}
max={defaultValue[1]}
step={step ?? 1}
onValueChange={onChange(filterKey)}
className="w-full pb-3"
trackClassName="h-1 bg-slate-200 dark:bg-slate-700"
rangeClassName="bg-blue-500 dark:bg-blue-600"
thumbClassName="w-3 h-3 bg-white dark:bg-slate-300 border border-slate-400 dark:border-slate-100"
aria-label={label ?? String(filterKey)}
/>
</div>
);
};
function getNumberArray<T extends FilterState, K extends EnumArrayKeys<T>>(
state: T,
key: K,
): number[] {
return state[key] as unknown as number[];
}
export const FilterMulti = <K extends EnumArrayKeys<FilterState>>({
filterKey,
options,
filterState,
setFilterState,
getLabel = (v) => String(v),
}: FilterMultiProps<K>) => {
const selected = getNumberArray(filterState, filterKey);
const allSelected = options.length > 0 &&
options.every((opt) => selected.includes(opt));
const toggleAll = () => {
setFilterState((prev) => ({
...prev,
[filterKey]: allSelected ? [] : [...options],
}));
};
const toggleValue = (val: number, checked: boolean) => {
setFilterState((prev) => {
const current = getNumberArray(prev, filterKey);
return {
...prev,
[filterKey]: checked
? [...current, val]
: current.filter((v) => v !== val),
};
});
};
return (
<div className="space-y-2">
<ScrollArea className="h-64 border rounded-md dark:border-slate-700">
<div className="space-y-2 px-2 py-3">
<button
type="button"
className="w-full py-1 shadow-sm hover:shadow-md bg-slate-600 dark:bg-slate-900 text-white rounded text-sm hover:text-slate-100 hover:bg-slate-700 active:bg-slate-950"
onClick={toggleAll}
>
{allSelected ? "Uncheck All" : "Check All"}
</button>
{options.map((val) => (
<Checkbox
key={val}
checked={selected.includes(val)}
onChange={(checked) => toggleValue(val, checked)}
>
<span className="dark:text-slate-200">{getLabel(val)}</span>
</Checkbox>
))}
</div>
</ScrollArea>
</div>
);
};
export const FilterToggle = <K extends keyof FilterState>({
label,
alternativeLabels,
filterKey,
filterState,
onChange,
}: FilterToggleProps<K>) => (
<div className="space-y-1 pb-1">
<label className="block text-sm font-medium">
{label}
</label>
<ToggleGroup
type="single"
aria-label={label}
onValueChange={(value) => onChange(filterKey, value)}
value={typeof filterState[filterKey] === "undefined"
? ""
: filterState[filterKey].toString()}
>
<ToggleGroupItem
value="false"
aria-label={alternativeLabels[0]}
className="text-sm h-7 dark:bg-slate-900"
>
{alternativeLabels[0]}
</ToggleGroupItem>
<ToggleGroupItem
value="true"
aria-label={alternativeLabels[1]}
className="text-sm h-7 dark:bg-slate-900"
>
{alternativeLabels[1]}
</ToggleGroupItem>
</ToggleGroup>
</div>
);
@@ -0,0 +1,426 @@
import {
type ComponentProps,
ReactNode,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import { Protobuf } from "@meshtastic/core";
import { FunnelIcon } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { debounce } from "@core/utils/debounce.ts";
import { cn } from "@core/utils/cn.ts";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@components/UI/Popover.tsx";
import { Input } from "@components/UI/Input.tsx";
import { Accordion } from "@components/UI/Accordion.tsx";
import { TimeAgo } from "@components/generic/TimeAgo.tsx";
import {
FilterAccordionItem,
FilterMulti,
FilterSlider,
FilterToggle,
} from "@components/generic/Filter/FilterComponents.tsx";
import type { FilterState } from "@components/generic/Filter/useFilterNode.ts";
const DEBOUNCE_DELAY_MS = 250;
const BATTERY_STATUS_PLUGGED_IN_VALUE = 101;
type PopoverContentProps = ComponentProps<typeof PopoverContent>;
interface FilterControlProps {
filterState: FilterState;
defaultFilterValues: FilterState;
setFilterState: React.Dispatch<React.SetStateAction<FilterState>>;
isDirty?: boolean;
parameters?: {
popoverContentProps?: Partial<PopoverContentProps>;
triggerIcon?: ReactNode;
popoverTriggerClassName?: string;
showTextSearch?: boolean;
};
children?: ReactNode;
}
interface HopsLabelProps {
hopsAway: number[];
t: TFunction<"ui", undefined>;
}
function HopsLabelContent({ hopsAway, t }: HopsLabelProps) {
const startHops = hopsAway[0];
const endHops = hopsAway[1];
return (
<>
{t("hops.text", {
value: startHops === 0 ? t("hops.direct") : startHops,
})}
{startHops !== endHops ? ` — ${endHops}` : ""}
</>
);
}
interface LastHeardLabelProps {
lastHeardRange: number[];
defaultMaxLastHeard: number;
formatTS: (ts: number) => ReactNode;
t: TFunction<"ui", undefined>;
}
function LastHeardLabelContent(
{ lastHeardRange, defaultMaxLastHeard, formatTS, t }: LastHeardLabelProps,
) {
const [start, end] = lastHeardRange;
return (
<>
{t("lastHeard.labelText", { value: "" })}
<br />
{start === 0
? (
t("lastHeard.nowLabel")
)
: (
<>
{start === defaultMaxLastHeard && ">"}
{formatTS(start)}
</>
)}
{start !== end && (
<>
{" — "}
{end === defaultMaxLastHeard && ">"}
{formatTS(end)}
</>
)}
</>
);
}
interface BatteryLevelLabelProps {
batteryLevelRange: (number | undefined)[];
t: TFunction<"ui", undefined>;
}
function BatteryLevelLabelContent(
{ batteryLevelRange, t }: BatteryLevelLabelProps,
) {
const [start, end] = batteryLevelRange;
const formatBatteryValue = (value: number | undefined) => {
if (value === undefined) return "";
return value === BATTERY_STATUS_PLUGGED_IN_VALUE
? t("batteryStatus.pluggedIn")
: `${value}%`;
};
return (
<>
{t("batteryLevel.labelText", {
value: formatBatteryValue(start),
})}
{start !== end && typeof end !== "undefined" && (
<>
{" – "}
{formatBatteryValue(end)}
</>
)}
</>
);
}
export function FilterControl({
filterState,
defaultFilterValues,
setFilterState,
isDirty,
parameters,
children,
}: FilterControlProps) {
const { t } = useTranslation("ui");
const [localFilterState, setLocalFilterState] = useState(filterState);
const skipNextFilterStateSync = useRef(false);
useEffect(() => {
if (skipNextFilterStateSync.current) {
skipNextFilterStateSync.current = false;
return;
}
setLocalFilterState(filterState);
}, [filterState]);
const handleTextChange = useCallback(
<K extends keyof FilterState>(key: K) =>
(e: React.ChangeEvent<HTMLInputElement>) => {
setFilterState((prev) => ({
...prev,
[key]: e.target.value,
}));
},
[setFilterState],
);
const debouncedSetFilterState = useCallback(
debounce(<K extends keyof FilterState>(key: K, value: number[]) => {
skipNextFilterStateSync.current = true;
setFilterState((prev) => ({
...prev,
[key]: value,
}));
}, DEBOUNCE_DELAY_MS),
[setFilterState],
);
const handleRangeChange = useCallback(
<K extends keyof FilterState>(key: K) => (value: number[]) => {
setLocalFilterState((prev) => ({
...prev,
[key]: value,
}));
debouncedSetFilterState(key, value);
},
[debouncedSetFilterState],
);
const handleBoolChange = useCallback(
<K extends keyof FilterState>(key: K, value: string | boolean) => {
const typedValue = value === true || value === "true"
? true
: value === false || value === "false"
? false
: undefined;
setFilterState((prev) => ({
...prev,
[key]: typedValue,
}));
},
[setFilterState],
);
const resetFilters = useCallback(() => {
setFilterState(defaultFilterValues);
}, [defaultFilterValues, setFilterState]);
const formatTS = useCallback(
(ts: number): ReactNode => <TimeAgo timestamp={Date.now() - ts * 1000} />,
[],
);
const formatEnumLabel = useCallback(
(label: string): string => label.replace(/_/g, " "),
[],
);
return (
<Popover>
<PopoverTrigger asChild>
<button
type="button"
className={cn(
"rounded",
"text-slate-600 hover:text-slate-700 bg-slate-100 hover:bg-slate-200 active:bg-slate-300",
"dark:text-slate-400 hover:dark:text-slate-400 dark:bg-slate-700 hover:dark:bg-slate-800 dark:active:bg-slate-950",
isDirty
? "text-slate-100 dark:text-slate-300 bg-green-600 dark:bg-green-700 hover:bg-green-700 dark:hover:bg-green-800 hover:text-slate-200 dark:hover:text-slate-300 active:bg-green-800 dark:active:bg-green-900"
: "",
parameters?.popoverTriggerClassName,
)}
aria-label={t("filter.label")}
>
{parameters?.triggerIcon ?? <FunnelIcon />}
</button>
</PopoverTrigger>
<PopoverContent
{...parameters?.popoverContentProps}
className={cn(
"dark:text-slate-300",
parameters?.popoverContentProps?.className,
)}
>
<form className="space-y-4">
<Accordion
type="single"
defaultValue={t("general.label")}
collapsible
>
<FilterAccordionItem label={t("general.label")}>
{(parameters?.showTextSearch ?? true) && (
<div className="flex flex-col space-y-1 pb-2">
<label htmlFor="nodeName" className="font-medium text-sm">
{t("nodeName.label")}
</label>
<Input
type="text"
id="nodeName"
value={filterState.nodeName}
onChange={handleTextChange("nodeName")}
showClearButton
placeholder={t("nodeName.placeholder")}
/>
</div>
)}
<FilterSlider
label={t("hops.label")}
filterKey="hopsAway"
filterState={localFilterState}
defaultFilterValues={defaultFilterValues}
onChange={handleRangeChange}
labelContent={
<HopsLabelContent
hopsAway={localFilterState.hopsAway}
t={t}
/>
}
/>
<FilterSlider
label={t("lastHeard.label")}
filterKey="lastHeard"
filterState={localFilterState}
defaultFilterValues={defaultFilterValues}
onChange={handleRangeChange}
labelContent={
<LastHeardLabelContent
lastHeardRange={localFilterState.lastHeard}
defaultMaxLastHeard={defaultFilterValues.lastHeard[1]}
formatTS={formatTS}
t={t}
/>
}
/>
<FilterToggle
label={t("favorites.label")}
filterKey="isFavorite"
alternativeLabels={[
t("hide.label"),
t("showOnly.label"),
]}
filterState={filterState}
onChange={handleBoolChange}
/>
<FilterToggle
label={t("viaMqtt.label")}
filterKey="viaMqtt"
alternativeLabels={[
t("hide.label"),
t("showOnly.label"),
]}
filterState={filterState}
onChange={handleBoolChange}
/>
</FilterAccordionItem>
<FilterAccordionItem label={t("metrics.label")}>
<FilterSlider
label={t("snr.label")}
filterKey="snr"
filterState={localFilterState}
defaultFilterValues={defaultFilterValues}
onChange={handleRangeChange}
/>
<FilterSlider
label={t("channelUtilization.label")}
filterKey="channelUtilization"
filterState={localFilterState}
defaultFilterValues={defaultFilterValues}
onChange={handleRangeChange}
/>
<FilterSlider
label={t("airtimeUtilization.label")}
filterKey="airUtilTx"
filterState={localFilterState}
defaultFilterValues={defaultFilterValues}
onChange={handleRangeChange}
/>
<FilterSlider
label={t("batteryLevel.label")}
filterKey="batteryLevel"
filterState={localFilterState}
defaultFilterValues={defaultFilterValues}
onChange={handleRangeChange}
labelContent={
<BatteryLevelLabelContent
batteryLevelRange={localFilterState.batteryLevel}
t={t}
/>
}
/>
<FilterSlider
label={t("batteryVoltage.label")}
filterKey="voltage"
filterState={localFilterState}
defaultFilterValues={defaultFilterValues}
onChange={handleRangeChange}
/>
</FilterAccordionItem>
<FilterAccordionItem label={t("role.label")}>
<FilterMulti
filterKey="role"
filterState={filterState}
setFilterState={setFilterState}
options={Object.values(
Protobuf.Config.Config_DeviceConfig_Role,
).filter((v): v is number => typeof v === "number")}
getLabel={(val) =>
formatEnumLabel(
Protobuf.Config.Config_DeviceConfig_Role[val],
)}
/>
</FilterAccordionItem>
<FilterAccordionItem label={t("hardware.label")}>
<FilterMulti
filterKey="hwModel"
filterState={filterState}
setFilterState={setFilterState}
options={Object.values(Protobuf.Mesh.HardwareModel).filter(
(v): v is number => typeof v === "number",
)}
getLabel={(val) =>
formatEnumLabel(Protobuf.Mesh.HardwareModel[val])}
/>
</FilterAccordionItem>
<FilterAccordionItem label={t("advanced.label")}>
<FilterToggle
label={t("hopsUnknown.label")}
filterKey="hopsUnknown"
alternativeLabels={[
t("hide.label"),
t("showOnly.label"),
]}
filterState={filterState}
onChange={handleBoolChange}
/>
<FilterToggle
label={t("showUnheard.label")}
filterKey="showUnheard"
alternativeLabels={[
t("hide.label"),
t("showOnly.label"),
]}
filterState={filterState}
onChange={handleBoolChange}
/>
</FilterAccordionItem>
</Accordion>
<button
type="button"
onClick={resetFilters}
className="w-full py-1 shadow-sm hover:shadow-md bg-slate-600 dark:bg-slate-900 text-white rounded text-sm hover:text-slate-100 hover:bg-slate-700 active:bg-slate-950"
>
{t("button.reset")}
</button>
{children && <div className="mt-4 border-t pt-4">{children}</div>}
</form>
</PopoverContent>
</Popover>
);
}
@@ -0,0 +1,182 @@
import { describe, expect, it } from "vitest";
import { useFilterNode } from "@components/generic/Filter/useFilterNode.ts";
import { Protobuf } from "@meshtastic/core";
import { renderHook } from "@testing-library/react";
export function createMockNode(): Protobuf.Mesh.NodeInfo {
return {
$typeName: "meshtastic.NodeInfo",
num: 1234567890,
snr: -10.2,
lastHeard: 1747519674,
channel: 0,
viaMqtt: false,
isFavorite: true,
isIgnored: false,
hopsAway: 2,
user: {
$typeName: "meshtastic.User",
id: "!12345678",
longName: "longName",
shortName: "lN",
macaddr: new Uint8Array(0),
hwModel: Protobuf.Mesh.HardwareModel.TLORA_T3_S3,
isLicensed: false,
role: Protobuf.Config.Config_DeviceConfig_Role.CLIENT,
publicKey: new Uint8Array(32),
},
deviceMetrics: {
$typeName: "meshtastic.DeviceMetrics",
batteryLevel: 101,
voltage: 4.21,
channelUtilization: 7.50,
airUtilTx: 2.57,
uptimeSeconds: 528092,
},
};
}
describe("useFilterNode", () => {
const { result } = renderHook(() => useFilterNode());
const { nodeFilter, defaultFilterValues, isFilterDirty } = result.current;
describe("nodeFilter", () => {
const node = createMockNode();
it("filters by nodeName", () => {
expect(nodeFilter(node, { nodeName: "lon" })).toBe(true);
expect(nodeFilter(node, { nodeName: "xxx" })).toBe(false);
});
it("filters by hopsAway", () => {
expect(nodeFilter(node, { hopsAway: [0, 1] })).toBe(false);
expect(nodeFilter(node, { hopsAway: [2, 2] })).toBe(true);
});
it("filters by snr", () => {
expect(nodeFilter(node, { snr: [-12, -10] })).toBe(true);
expect(nodeFilter(node, { snr: [-17, -16] })).toBe(false);
});
it("filters by batteryLevel", () => {
expect(nodeFilter(node, { batteryLevel: [0, 100] })).toBe(false);
expect(nodeFilter(node, { batteryLevel: [100, 101] })).toBe(true);
});
it("filters by isFavorite", () => {
expect(nodeFilter(node, { isFavorite: true })).toBe(true);
expect(nodeFilter(node, { isFavorite: false })).toBe(false);
expect(nodeFilter(node, { isFavorite: undefined })).toBe(true);
});
it("filters by viaMqtt", () => {
expect(nodeFilter(node, { viaMqtt: true })).toBe(false);
expect(nodeFilter(node, { viaMqtt: false })).toBe(true);
expect(nodeFilter(node, { viaMqtt: undefined })).toBe(true);
});
it("filters by airUtilTx", () => {
expect(nodeFilter(node, { airUtilTx: [2, 3] })).toBe(true);
expect(nodeFilter(node, { airUtilTx: [3, 4] })).toBe(false);
});
it("filters by channelUtilization", () => {
expect(nodeFilter(node, { channelUtilization: [7, 8] })).toBe(true);
expect(nodeFilter(node, { channelUtilization: [8, 9] })).toBe(false);
});
it("filters by voltage", () => {
expect(nodeFilter(node, { voltage: [4, 4.3] })).toBe(true);
expect(nodeFilter(node, { voltage: [4.3, 5] })).toBe(false);
});
it("filters by role", () => {
expect(
nodeFilter(node, {
role: [Protobuf.Config.Config_DeviceConfig_Role.CLIENT],
}),
).toBe(true);
expect(
nodeFilter(node, {
role: [Protobuf.Config.Config_DeviceConfig_Role.REPEATER],
}),
).toBe(false);
});
it("filters by hwModel", () => {
expect(
nodeFilter(node, {
hwModel: [Protobuf.Mesh.HardwareModel.TLORA_T3_S3],
}),
).toBe(true);
expect(
nodeFilter(node, { hwModel: [Protobuf.Mesh.HardwareModel.HELTEC_V3] }),
).toBe(false);
});
it("filters by hopsUnknown", () => {
expect(nodeFilter(node, { hopsUnknown: true })).toBe(false);
expect(nodeFilter(node, { hopsUnknown: false })).toBe(true);
expect(nodeFilter(node, { hopsUnknown: undefined })).toBe(true);
});
it("filters by showUnheard", () => {
expect(nodeFilter(node, { showUnheard: true })).toBe(false);
expect(nodeFilter(node, { showUnheard: false })).toBe(true);
expect(nodeFilter(node, { showUnheard: undefined })).toBe(true);
});
it("returns false when current matches defaults", () => {
expect(isFilterDirty(defaultFilterValues)).toBe(false);
});
it("detects dirty string field", () => {
const modified = { ...defaultFilterValues, nodeName: "abc" };
expect(isFilterDirty(modified)).toBe(true);
});
it("detects dirty numeric tuple field", () => {
const modified = {
...defaultFilterValues,
snr: [-10, 5] as [number, number],
};
expect(isFilterDirty(modified)).toBe(true);
});
it("detects dirty boolean field (isFavorite)", () => {
const modified = { ...defaultFilterValues, isFavorite: true };
expect(isFilterDirty(modified)).toBe(true);
});
it("detects dirty boolean field (viaMqtt)", () => {
const modified = { ...defaultFilterValues, viaMqtt: true };
expect(isFilterDirty(modified)).toBe(true);
});
it("detects dirty enum array field (role)", () => {
const modified = {
...defaultFilterValues,
role: [Protobuf.Config.Config_DeviceConfig_Role.REPEATER],
};
expect(isFilterDirty(modified)).toBe(true);
});
it("detects dirty enum array field (hwModel)", () => {
const modified = {
...defaultFilterValues,
hwModel: [Protobuf.Mesh.HardwareModel.HELTEC_V3],
};
expect(isFilterDirty(modified)).toBe(true);
});
it("detects dirty boolean field (hopsUnknown)", () => {
const modified = { ...defaultFilterValues, hopsUnknown: true };
expect(isFilterDirty(modified)).toBe(true);
});
it("detects dirty boolean field (showUnheard)", () => {
const modified = { ...defaultFilterValues, showUnheard: true };
expect(isFilterDirty(modified)).toBe(true);
});
});
});
@@ -0,0 +1,203 @@
import { Protobuf } from "@meshtastic/core";
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
import { useCallback, useMemo } from "react";
export type FilterState = {
nodeName: string;
hopsAway: [number, number];
lastHeard: [number, number];
isFavorite: boolean | undefined;
viaMqtt: boolean | undefined;
snr: [number, number];
channelUtilization: [number, number];
airUtilTx: [number, number];
batteryLevel: [number, number];
voltage: [number, number];
role: Protobuf.Config.Config_DeviceConfig_Role[];
hwModel: Protobuf.Mesh.HardwareModel[];
showUnheard: boolean | undefined;
hopsUnknown: boolean | undefined;
};
const shallowEqualArray = <T>(a: T[], b: T[]): boolean => {
if (a.length !== b.length) {
return false;
}
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) {
return false;
}
}
return true;
};
export function useFilterNode() {
const defaultFilterValues = useMemo<FilterState>(
() => ({
nodeName: "",
hopsAway: [0, 7],
lastHeard: [0, 864000], // 0-10 days
isFavorite: undefined,
viaMqtt: undefined,
snr: [-20, 10],
channelUtilization: [0, 100],
airUtilTx: [0, 100],
batteryLevel: [0, 101],
voltage: [0, 5],
role: Object.values(Protobuf.Config.Config_DeviceConfig_Role).filter(
(v): v is Protobuf.Config.Config_DeviceConfig_Role =>
typeof v === "number",
),
hwModel: Object.values(Protobuf.Mesh.HardwareModel).filter(
(v): v is Protobuf.Mesh.HardwareModel => typeof v === "number",
),
hopsUnknown: undefined,
showUnheard: undefined,
}),
[],
);
const nodeFilter = useCallback(
(
node: Protobuf.Mesh.NodeInfo,
filterOverrides?: Partial<FilterState>,
): boolean => {
const filterState: FilterState = {
...defaultFilterValues,
...filterOverrides,
};
if (!node.user) return false;
const nodeName = filterState.nodeName.toLowerCase();
if (
nodeName &&
!(
node.user?.shortName.toLowerCase().includes(nodeName) ||
node.user?.longName.toLowerCase().includes(nodeName) ||
node.num.toString().includes(nodeName) ||
numberToHexUnpadded(node.num).includes(nodeName.replace(/!/g, ""))
)
) {
return false;
}
const hops = node.hopsAway ?? 7;
if (hops < filterState.hopsAway[0] || hops > filterState.hopsAway[1]) {
return false;
}
if (
(filterState.hopsUnknown === true && node.hopsAway !== undefined) ||
filterState.hopsUnknown === false && node.hopsAway === undefined
) {
return false;
}
const secondsAgo = Date.now() / 1000 - (node.lastHeard ?? 0);
if (
secondsAgo < filterState.lastHeard[0] ||
(secondsAgo > filterState.lastHeard[1] &&
filterState.lastHeard[1] !== defaultFilterValues.lastHeard[1])
) {
return false;
}
if (
(filterState.showUnheard === true && (node.lastHeard ?? 0) !== 0) ||
filterState.showUnheard === false && (node.lastHeard ?? 0) === 0
) {
return false;
}
if (
typeof filterState.isFavorite !== "undefined" &&
node.isFavorite !== filterState.isFavorite
) {
return false;
}
if (
typeof filterState.viaMqtt !== "undefined" &&
node.viaMqtt !== filterState.viaMqtt
) {
return false;
}
const snr = node.snr ?? -20;
if (snr < filterState.snr[0] || snr > filterState.snr[1]) return false;
const channelUtilization = node.deviceMetrics?.channelUtilization ?? 0;
if (
channelUtilization < filterState.channelUtilization[0] ||
channelUtilization > filterState.channelUtilization[1]
) {
return false;
}
const airUtilTx = node.deviceMetrics?.airUtilTx ?? 0;
if (
airUtilTx < filterState.airUtilTx[0] ||
airUtilTx > filterState.airUtilTx[1]
) {
return false;
}
const batt = node.deviceMetrics?.batteryLevel ?? 101;
if (
batt < filterState.batteryLevel[0] ||
batt > filterState.batteryLevel[1]
) {
return false;
}
const voltage = node.deviceMetrics?.voltage ?? 0;
if (
voltage < filterState.voltage[0] ||
voltage > filterState.voltage[1]
) {
return false;
}
const role: Protobuf.Config.Config_DeviceConfig_Role = node.user.role ??
Protobuf.Config.Config_DeviceConfig_Role.CLIENT;
if (!filterState.role.includes(role)) return false;
const hwModel: Protobuf.Mesh.HardwareModel = node.user.hwModel ??
Protobuf.Mesh.HardwareModel.UNSET;
if (!filterState.hwModel.includes(hwModel)) return false;
return true;
},
[defaultFilterValues],
);
const isFilterDirty = useCallback(
(
current: FilterState,
overrides?: Partial<FilterState>,
): boolean => {
const base: FilterState = overrides
? { ...defaultFilterValues, ...overrides }
: defaultFilterValues;
for (const key of Object.keys(base) as (keyof FilterState)[]) {
const currentValue = current[key];
const defaultValue = base[key];
if (Array.isArray(defaultValue) && Array.isArray(currentValue)) {
if (!shallowEqualArray(currentValue, defaultValue)) {
return true;
}
} else if (currentValue !== defaultValue) {
return true;
}
}
return false;
},
[defaultFilterValues],
);
return { nodeFilter, defaultFilterValues, isFilterDirty };
}
@@ -0,0 +1,20 @@
import { cn } from "@core/utils/cn.ts";
interface MonoProps extends React.HTMLAttributes<HTMLSpanElement> {
children: React.ReactNode;
className?: string;
}
export const Mono = ({
children,
className,
...rest
}: MonoProps) => {
return (
<span
className={cn("font-mono text-sm text-text-secondary", className)}
{...rest}
>
{children}
</span>
);
};
@@ -0,0 +1,128 @@
import { describe, expect, it } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { DataRow, Heading, Table } from "@components/generic/Table/index.tsx";
import { TimeAgo } from "@components/generic/TimeAgo.tsx";
import { Mono } from "@components/generic/Mono.tsx";
// @ts-types="react"
describe("Generic Table", () => {
it("Can render an empty table.", () => {
render(<Table headings={[]} rows={[]} />);
expect(screen.getByRole("table")).toBeInTheDocument();
});
it("Can render a table with headers and no rows.", async () => {
const headings: Heading[] = [
{ title: "Short Name", sortable: true },
{ title: "Last Heard", sortable: true },
{ title: "Connection", sortable: true },
];
render(<Table headings={headings} rows={[]} />);
await screen.findByRole("table");
expect(screen.getAllByRole("columnheader")).toHaveLength(3);
});
// Mock data representing devices
const mockDevices = [
{
id: "TST1",
shortName: "TST1",
hopsAway: 1,
lastHeard: Date.now() - 3000,
viaMqtt: false,
},
{
id: "TST2",
shortName: "TST2",
hopsAway: 0,
lastHeard: Date.now() - 1000,
viaMqtt: true,
isFavorite: true, // Favorite device
},
{
id: "TST3",
shortName: "TST3",
hopsAway: 4,
lastHeard: Date.now() - 5000,
viaMqtt: false,
},
{
id: "TST4",
shortName: "TST4",
hopsAway: 3,
lastHeard: Date.now() - 2000,
viaMqtt: true,
},
];
// Transform mock data into the format expected by the Table component
const mockRows: DataRow[] = mockDevices.map((node) => ({
id: node.id,
isFavorite: node.isFavorite,
cells: [
{
content: <b data-testid="short-name">{node.shortName}</b>,
sortValue: node.shortName,
},
{
content: (
<Mono>
<TimeAgo timestamp={node.lastHeard} />
</Mono>
),
sortValue: node.lastHeard,
},
{
content: (
<Mono>
{node.lastHeard !== 0
? node.viaMqtt === false && node.hopsAway === 0
? "Direct"
: `${node.hopsAway} ${node.hopsAway > 1 ? "hops" : "hop"} away`
: "-"}
{node.viaMqtt ? ", via MQTT" : ""}
</Mono>
),
sortValue: node.hopsAway,
},
],
}));
const headings: Heading[] = [
{ title: "Short Name", sortable: true },
{ title: "Last Heard", sortable: true },
{ title: "Connection", sortable: true },
];
it("Can sort rows, keeping favorites at the top", async () => {
render(<Table headings={headings} rows={mockRows} />);
const renderedTable = await screen.findByRole("table");
const columnHeaders = screen.getAllByRole("columnheader");
expect(columnHeaders).toHaveLength(3);
const getRenderedOrder = () =>
[...renderedTable.querySelectorAll("[data-testid='short-name']")].map(
(el) => el.textContent?.trim(),
);
// Default sort: "Last Heard" desc. TST2 is favorite, so it's first.
// Then the rest are sorted by lastHeard timestamp (most recent first).
// Order of timestamps: TST2 (latest, but favorite), TST4, TST1, TST3 (oldest).
expect(getRenderedOrder()).toEqual(["TST2", "TST4", "TST1", "TST3"]);
// Click "Short Name" to sort asc
fireEvent.click(columnHeaders[0]);
// TST2 is favorite, so it's first. Then TST1, TST3, TST4 alphabetically.
expect(getRenderedOrder()).toEqual(["TST2", "TST1", "TST3", "TST4"]);
// Click "Short Name" again to sort desc
fireEvent.click(columnHeaders[0]);
// TST2 is favorite, so it's first. Then TST4, TST3, TST1 reverse alphabetically.
expect(getRenderedOrder()).toEqual(["TST2", "TST4", "TST3", "TST1"]);
// Click "Connection" to sort by hops asc
fireEvent.click(columnHeaders[2]);
// TST2 is favorite (and also has 0 hops). Then sorted by hops: TST1 (1), TST4 (3), TST3 (4).
expect(getRenderedOrder()).toEqual(["TST2", "TST1", "TST4", "TST3"]);
});
});
+154
View File
@@ -0,0 +1,154 @@
import { ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import { useMemo, useState } from "react";
import React from "react";
import { cn } from "@core/utils/cn.ts";
export interface Heading {
title: string;
sortable: boolean;
}
interface Cell {
content: React.ReactNode;
sortValue: string | number;
}
export interface DataRow {
id: string | number;
isFavorite?: boolean;
cells: Cell[];
}
export interface TableProps {
headings: Heading[];
rows: DataRow[];
}
function numericHops(hopsAway: string | number): number {
if (typeof hopsAway === "number") {
return hopsAway;
}
if (hopsAway.match(/direct/i)) {
return 0;
}
const match = hopsAway.match(/(\d+)\s+hop/i);
return Number(match?.[1] ?? Number.MAX_SAFE_INTEGER);
}
export const Table = ({ headings, rows }: TableProps) => {
const [sortColumn, setSortColumn] = useState<string | null>("Last Heard");
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc");
const handleSort = (title: string) => {
if (sortColumn === title) {
setSortOrder(sortOrder === "asc" ? "desc" : "asc");
} else {
setSortColumn(title);
setSortOrder("asc");
}
};
const sortedRows = useMemo(() => {
if (!sortColumn) return rows;
const columnIndex = headings.findIndex((h) => h.title === sortColumn);
if (columnIndex === -1) return rows;
return [...rows].sort((a, b) => {
if (a.isFavorite !== b.isFavorite) {
return a.isFavorite ? -1 : 1;
}
const aCell = a.cells[columnIndex];
const bCell = b.cells[columnIndex];
let aValue: string | number;
let bValue: string | number;
if (sortColumn === "Connection") {
aValue = numericHops(aCell.sortValue);
bValue = numericHops(bCell.sortValue);
} else {
aValue = aCell.sortValue;
bValue = bCell.sortValue;
}
if (aValue < bValue) return sortOrder === "asc" ? -1 : 1;
if (aValue > bValue) return sortOrder === "asc" ? 1 : -1;
return 0;
});
}, [rows, sortColumn, sortOrder, headings]);
return (
<table className="min-w-full" style={{ contentVisibility: "auto" }}>
<thead className="text-xs font-semibold">
<tr>
{headings.map((heading) => (
<th
key={heading.title}
scope="col"
className={cn(
"py-2 pr-3 text-left",
heading.sortable &&
"cursor-pointer hover:brightness-hover active:brightness-press",
)}
onClick={() => heading.sortable && handleSort(heading.title)}
onKeyUp={(e) => {
if (heading.sortable && (e.key === "Enter" || e.key === " ")) {
handleSort(heading.title);
}
}}
tabIndex={heading.sortable ? 0 : -1}
role="columnheader"
aria-sort={sortColumn === heading.title
? sortOrder === "asc" ? "ascending" : "descending"
: "none"}
>
<div className="flex items-center gap-2">
{heading.title}
{heading.sortable && sortColumn === heading.title && (
sortOrder === "asc"
? <ChevronUpIcon size={16} aria-hidden="true" />
: <ChevronDownIcon size={16} aria-hidden="true" />
)}
</div>
</th>
))}
</tr>
</thead>
<tbody className="max-w-fit">
{sortedRows.map((row) => (
<tr
key={row.id}
className={cn(
row.isFavorite
? "bg-yellow-100/30 dark:bg-slate-800 odd:bg-yellow-200/30 dark:odd:bg-slate-600/40"
: "bg-white dark:bg-slate-900 odd:bg-slate-200/40 dark:odd:bg-slate-800/40",
)}
>
{row.cells.map((cell, cellIndex) =>
cellIndex === 0
? (
<th
key={`${row.id}_${cellIndex}`}
className="whitespace-nowrap px-3 py-2 text-sm text-left text-text-secondary"
scope="row"
>
{cell.content}
</th>
)
: (
<td
key={`${row.id}_${cellIndex}`}
className="whitespace-nowrap px-3 py-2 text-sm text-text-secondary"
>
{cell.content}
</td>
)
)}
</tr>
))}
</tbody>
</table>
);
};
+131
View File
@@ -0,0 +1,131 @@
import {
Tooltip,
TooltipContent,
TooltipPortal,
TooltipProvider,
TooltipTrigger,
} from "@radix-ui/react-tooltip";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
export interface TimeAgoProps {
timestamp: number | Date;
locale?: string;
tooltipOptions?: Intl.DateTimeFormatOptions;
className?: string;
}
const TIME_UNITS: Array<[Intl.RelativeTimeFormatUnit, number]> = [
["year", 31536000],
["month", 2592000],
["day", 86400],
["hour", 3600],
["minute", 60],
["second", 1],
];
const getRelativeTimeParts = (
date: Date | number,
): { value: number; unit: Intl.RelativeTimeFormatUnit } => {
const diffInSeconds = (new Date(date).getTime() - Date.now()) / 1000;
for (const [unit, secondsInUnit] of TIME_UNITS) {
if (Math.abs(diffInSeconds) >= secondsInUnit) {
const value = Math.round(diffInSeconds / secondsInUnit);
return { value, unit };
}
}
return { value: Math.round(diffInSeconds), unit: "second" };
};
const UPDATE_INTERVALS: Partial<Record<Intl.RelativeTimeFormatUnit, number>> = {
// For long-term units, an hourly update is more than sufficient.
year: 1000 * 60 * 60,
month: 1000 * 60 * 60,
// When the unit is 'day', check hourly to catch the change to the next day.
day: 1000 * 60 * 60,
// When the unit is 'hour', check every thiry seconds to catch the change to the next hour.
hour: 1000 * 30,
// When the unit is 'minute', a 15-second check is a good balance.
minute: 1000 * 15,
// For 'second', a 3-second check keeps it feeling "live" without being excessive.
second: 1000 * 3,
};
export const TimeAgo = ({
timestamp,
locale: localeProp,
tooltipOptions,
className,
}: TimeAgoProps) => {
const { i18n } = useTranslation();
const [timeAgo, setTimeAgo] = useState<string>("");
const locale = useMemo(
() =>
localeProp ||
i18n.language ||
(typeof navigator !== "undefined" ? navigator.language : "en-US"),
[localeProp, i18n.language],
);
const date = useMemo(() => new Date(timestamp), [timestamp]);
const fullDate = useMemo(() => {
const defaultOptions: Intl.DateTimeFormatOptions = {
dateStyle: "full",
timeStyle: "medium",
};
const formatter = new Intl.DateTimeFormat(locale, {
...defaultOptions,
...tooltipOptions,
});
return formatter.format(date);
}, [date, locale, tooltipOptions]);
useEffect(() => {
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
let timerId: number;
const update = () => {
const { value, unit } = getRelativeTimeParts(date);
setTimeAgo(rtf.format(value, unit));
const interval = UPDATE_INTERVALS[unit] || 60000;
timerId = globalThis.setTimeout(update, interval);
};
update();
return () => {
clearTimeout(timerId);
};
}, [date, locale]);
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<time dateTime={date.toISOString()} className={className}>
{timeAgo}
</time>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent
className="rounded-md bg-slate-800 px-3 py-1.5 text-sm text-white shadow-md animate-in fade-in-0 zoom-in-95"
side="top"
align="center"
sideOffset={5}
>
{fullDate}
</TooltipContent>
</TooltipPortal>
</Tooltip>
</TooltipProvider>
);
};
@@ -0,0 +1,15 @@
export interface UptimeProps {
seconds: number;
}
const getUptime = (seconds: number): string => {
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor(((seconds % 86400) % 3600) / 60);
const secondsLeft = Math.floor(((seconds % 86400) % 3600) % 60);
return `${days}d ${hours}h ${minutes}m ${secondsLeft}s`;
};
export const Uptime = ({ seconds }: UptimeProps) => {
return <span>{getUptime(seconds)}</span>;
};