feat(protobufs): sync to firmware-current and consume workspace package (#1097)

* feat(protobufs): sync to firmware-current and consume workspace package

Sync the vendored .proto sources to firmware-current (v2.7.25+48), regenerate the v2 TS bindings, and consume the workspace @meshtastic/protobufs (workspace:*) in place of the stale JSR 2.7.20 — finishing the monorepo migration (core was already workspace:*).

Includes the one required breaking-change fix: admin nodedb_reset changed int32 to bool, so resetNodes() now sends value: true.

* build(protobufs): vendor generated bindings for workspace consumers

The package is consumed via workspace:* — its exports point at the TS source, which imports ./dist/meshtastic/*_pb.ts — so the generated output must exist at build time. CI builds web/core with no codegen step and the runners have no buf CLI, so the bindings are vendored here (kept gitignored; lint/format skip them). Regenerate with: pnpm --filter @meshtastic/protobufs gen

* fix(protobufs): clean script removes the actual generated output dir

buf writes bindings to packages/ts/dist, but clean was removing a non-existent root dist — so it never cleaned stale output. Addresses Copilot review feedback.

* refactor: move web app packages/web -> apps/web

Aligns the web app with the apps/web layout (matching the Vercel web-test Root Directory and the SDK-migration direction). Pure directory move plus root config: pnpm-workspace (adds apps/*), vitest projects, root tsconfig reference, and the pr/release-web/nightly workflows. vercel.json moved with the app. Build + 36 validation tests green.

* feat: config fields, module pages, key verification, telemetry capture

Incorporates the firmware-current feature work onto the protobuf foundation: new config fields (Display message bubbles; LoRa fem_lna_mode + serial_hal_only; Telemetry air_quality_screen_enabled); 4 new ModuleConfig pages (TrafficManagement, StatusMessage, TAK, RemoteHardware); the manual Key Verification flow (sendKeyVerification + ClientNotificationDialog stages + Verify Key button); live telemetry capture (nodeDB addDeviceMetrics) and admin hardening (toggleMutedNode, graceful PortNum default); plus the sdk-preview ConfigEditor demo and store/config tests. Build + lint + format + 131 tests green.

* chore: drop #1062 (unsaved-change-detection) to match upstream revert

#1062 was merged to main by accident (per @danditomaso) and is being reverted. Reverse-applied its diff here via 3-way so #1097 stays consistent with where main is headed, while keeping the feature changes layered on the same files (deviceStore/changeRegistry). Build + 131 tests + lint + format green.

* fix(nodes): clean up SNR display in node table and map popup

SNR is a ratio measured in dB, not dBm (which is absolute power); the
node table and map popup both mislabeled it and crammed three values
together: '0dBm/50%/50raw'. The trailing '%/raw' pair was the same
heuristic ((snr+10)*5) shown twice — once clamped, once not.

Render SNR in dB rounded to one decimal, color-coded by a 0-100%
signal-quality heuristic (green/yellow/red), with the quality percentage
as a muted secondary. Drop the redundant raw value. Adds unit.db; this
matches the existing SNRTooltip, which already renders dB.
This commit is contained in:
Ben Meadors
2026-06-15 13:23:28 -05:00
committed by GitHub
parent 7a8529b841
commit bbe9a0d5cd
695 changed files with 27038 additions and 786 deletions
+5
View File
@@ -0,0 +1,5 @@
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) => `/devices/${device}`;
const device = hardwareModelToFilename[deviceType] || "unknown.svg";
return <img className={className} src={getPath(device)} alt={device} />;
};
@@ -0,0 +1,210 @@
import type { FilterState } from "@components/generic/Filter/useFilterNode.ts";
import {
AccordionContent,
AccordionHeader,
AccordionItem,
AccordionTrigger,
} from "@components/UI/Accordion.tsx";
import { Checkbox } from "@components/UI/Checkbox/index.tsx";
import { ScrollArea } from "@components/UI/ScrollArea.tsx";
import { Slider } from "@components/UI/Slider.tsx";
import { ToggleGroup, ToggleGroupItem } from "@components/UI/ToggleGroup.tsx";
import { cn } from "@core/utils/cn.ts";
import type { ReactNode } from "react";
import { useId } from "react";
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 sliderId = useId();
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 htmlFor={sliderId} 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)}
id={sliderId}
/>
</div>
);
};
function getNumberArray<T extends FilterState, K extends EnumArrayKeys<T>>(
state: T,
key: K,
): number[] {
return state[key] 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)}
className="flex items-center gap-2"
>
<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 htmlFor={label} className="block text-sm font-medium">
{label}
</label>
<ToggleGroup
type="single"
aria-label={label}
id={label}
onValueChange={(value) => onChange(filterKey, value)}
value={typeof filterState[filterKey] === "undefined" ? "" : String(filterState[filterKey])}
>
<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,393 @@
import {
FilterAccordionItem,
FilterMulti,
FilterSlider,
FilterToggle,
} from "@components/generic/Filter/FilterComponents.tsx";
import type { FilterState } from "@components/generic/Filter/useFilterNode.ts";
import { TimeAgo } from "@components/generic/TimeAgo.tsx";
import { Accordion } from "@components/UI/Accordion.tsx";
import { Input } from "@components/UI/Input.tsx";
import { Popover, PopoverContent, PopoverTrigger } from "@components/UI/Popover.tsx";
import { cn } from "@core/utils/cn.ts";
import { debounce } from "@core/utils/debounce.ts";
import { Protobuf } from "@meshtastic/core";
import { FunnelIcon } from "lucide-react";
import {
type ComponentProps,
type ReactNode,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next";
const DEBOUNCE_DELAY_MS = 250;
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 RangeLabelContentProps {
range: [number, number];
defaultRange: [number, number];
format?: (ts: number) => ReactNode;
initialLabel?: ReactNode;
customLabel?: { start?: string; end?: string };
}
function RangeLabelContent({
range,
defaultRange,
format,
initialLabel,
customLabel,
}: RangeLabelContentProps) {
const [start, end] = range;
const [min, max] = defaultRange;
const unequal = start !== end;
const fmtStart = format ? format(start) : start;
const fmtEnd = format ? format(end) : end;
return (
<>
{initialLabel}
{start === min
? (customLabel?.start ?? (
<>
{"<"}
{fmtStart}
</>
))
: start === max
? (customLabel?.end ?? (
<>
{">"}
{fmtEnd}
</>
))
: fmtStart}
{unequal && (
<>
{" — "}
{end === max
? (customLabel?.end ?? (
<>
{">"}
{fmtEnd}
</>
))
: fmtEnd}
</>
)}
</>
);
}
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],
);
// eslint-disable-next-line react-hooks/exhaustive-deps
const debouncedSetFilterState = useCallback(
debounce(<K extends keyof FilterState>(key: K, value: number[]) => {
skipNextFilterStateSync.current = true;
setFilterState((prev) => ({
...prev,
[key]: value,
}));
}, DEBOUNCE_DELAY_MS),
[],
);
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 | undefined) => {
let typedValue: boolean | undefined;
if (value === true || value === "true") {
typedValue = true;
} else if (value === false || value === "false") {
typedValue = false;
} else {
typedValue = 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, " "), []);
const roleOptions = useMemo(
() =>
Object.values(Protobuf.Config.Config_DeviceConfig_Role).filter(
(v): v is number => typeof v === "number",
),
[],
);
const hwModelOptions = useMemo(
() =>
Object.values(Protobuf.Mesh.HardwareModel).filter((v): v is number => typeof v === "number"),
[],
);
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"
value={filterState.nodeName}
onChange={handleTextChange("nodeName")}
showClearButton
placeholder={t("nodeName.placeholder")}
/>
</div>
)}
<FilterSlider
filterKey="hopsAway"
filterState={localFilterState}
defaultFilterValues={defaultFilterValues}
onChange={handleRangeChange}
labelContent={
<RangeLabelContent
range={localFilterState.hopsAway}
defaultRange={defaultFilterValues.hopsAway}
initialLabel={`${t("hops.label")}: `}
customLabel={{ start: "0", end: "7" }}
/>
}
/>
<FilterSlider
filterKey="lastHeard"
filterState={localFilterState}
defaultFilterValues={defaultFilterValues}
onChange={handleRangeChange}
labelContent={
<RangeLabelContent
range={localFilterState.lastHeard}
defaultRange={defaultFilterValues.lastHeard}
format={formatTS}
initialLabel={`${t("lastHeard.label")}: `}
customLabel={{ start: t("lastHeard.nowLabel") }}
/>
}
/>
<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
filterKey="snr"
filterState={localFilterState}
defaultFilterValues={defaultFilterValues}
onChange={handleRangeChange}
labelContent={
<RangeLabelContent
range={localFilterState.snr}
defaultRange={defaultFilterValues.snr}
initialLabel={`${t("snr.label")}: `}
/>
}
/>
<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
filterKey="batteryLevel"
filterState={localFilterState}
defaultFilterValues={defaultFilterValues}
onChange={handleRangeChange}
labelContent={
<RangeLabelContent
range={localFilterState.batteryLevel}
defaultRange={defaultFilterValues.batteryLevel}
initialLabel={`${t("batteryLevel.label")}: `}
customLabel={{
start: "0",
end: t("batteryStatus.pluggedIn"),
}}
/>
}
/>
<FilterSlider
filterKey="voltage"
filterState={localFilterState}
defaultFilterValues={defaultFilterValues}
onChange={handleRangeChange}
step={0.1}
labelContent={
<RangeLabelContent
range={localFilterState.voltage}
defaultRange={defaultFilterValues.voltage}
initialLabel={`${t("batteryVoltage.label")}: `}
customLabel={{ start: "0" }}
/>
}
/>
</FilterAccordionItem>
<FilterAccordionItem label={t("role.label")}>
<FilterMulti
filterKey="role"
filterState={filterState}
setFilterState={setFilterState}
options={roleOptions}
getLabel={(val) =>
formatEnumLabel(Protobuf.Config.Config_DeviceConfig_Role[val] ?? "UNSET")
}
/>
</FilterAccordionItem>
<FilterAccordionItem label={t("hardware.label")}>
<FilterMulti
filterKey="hwModel"
filterState={filterState}
setFilterState={setFilterState}
options={hwModelOptions}
getLabel={(val) => formatEnumLabel(Protobuf.Mesh.HardwareModel[val] ?? "UNKNOWN")}
/>
</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,227 @@
import { useFilterNode } from "@components/generic/Filter/useFilterNode.ts";
import { Protobuf } from "@meshtastic/core";
import { renderHook } from "@testing-library/react";
import { describe, expect, it } from "vitest";
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,
isKeyManuallyVerified: false,
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.5,
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);
});
});
describe("default-boundary semantics", () => {
const node = createMockNode();
it("lastHeard: end at default max means 'any age'", () => {
const {
lastHeard: [lastHeardMin, lastHeardMax],
} = defaultFilterValues;
const veryOld = { ...node, lastHeard: lastHeardMax + 1 }; // older than slider max
expect(nodeFilter(veryOld, { lastHeard: [lastHeardMin, lastHeardMax] })).toBe(true); // open upper
expect(nodeFilter(veryOld, { lastHeard: [lastHeardMin, lastHeardMax - 1] })).toBe(false); // now bounded
});
it("snr: max at default means no upper bound", () => {
const {
snr: [snrMin, snrMax],
} = defaultFilterValues;
const hiSnr = { ...node, snr: snrMax + 1 }; // above slider max
expect(nodeFilter(hiSnr, { snr: [snrMin, snrMax] })).toBe(true); // open upper
expect(nodeFilter(hiSnr, { snr: [snrMin, snrMax - 1] })).toBe(false); // bounded
});
it("snr: min at default means no lower bound", () => {
const {
snr: [snrMin, snrMax],
} = defaultFilterValues;
const loSnr = { ...node, snr: snrMin - 1 }; // below slider min
expect(nodeFilter(loSnr, { snr: [snrMin, snrMax] })).toBe(true); // open lower
expect(nodeFilter(loSnr, { snr: [snrMin + 1, snrMax] })).toBe(false); // bounded
});
it("voltage: max at default means no upper bound", () => {
const {
voltage: [voltageMin, voltageMax],
} = defaultFilterValues;
const hiV = {
...node,
deviceMetrics: {
...node.deviceMetrics!,
voltage: voltageMax + 1,
},
} satisfies Protobuf.Mesh.NodeInfo;
expect(nodeFilter(hiV, { voltage: [voltageMin, voltageMax] })).toBe(true); // open upper
expect(nodeFilter(hiV, { voltage: [voltageMin, voltageMax - 0.01] })).toBe(false); // bounded
});
});
});
@@ -0,0 +1,232 @@
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,
};
const nodeName = filterState.nodeName.toLowerCase();
if (nodeName) {
const short = node.user?.shortName?.toLowerCase() ?? "";
const long = node.user?.longName?.toLowerCase() ?? "";
const numStr = node.num.toString();
const hex = numberToHexUnpadded(node.num);
if (
!short.includes(nodeName) &&
!long.includes(nodeName) &&
!numStr.includes(nodeName) &&
!hex.includes(nodeName.replace(/!/g, ""))
) {
return false;
}
}
const hops = node.hopsAway ?? 7;
if (
(node.hopsAway === undefined &&
!shallowEqualArray(filterState.hopsAway, defaultFilterValues.hopsAway)) || // If hops are unknown, hide node if state is not default
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 = Math.max(0, Date.now() / 1000 - (node.lastHeard ?? 0));
if (
(node.lastHeard === 0 &&
!shallowEqualArray(filterState.lastHeard, defaultFilterValues.lastHeard)) || // If lastHeard is unknown (0), hide node if state is not default
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 (
(node.snr === undefined && !shallowEqualArray(filterState.snr, defaultFilterValues.snr)) ||
(snr < filterState.snr[0] && filterState.snr[0] !== defaultFilterValues.snr[0]) ||
(snr > filterState.snr[1] && filterState.snr[1] !== defaultFilterValues.snr[1])
) {
return false;
}
const channelUtilization = node.deviceMetrics?.channelUtilization ?? 0;
if (
(node.deviceMetrics?.channelUtilization === undefined &&
!shallowEqualArray(
filterState.channelUtilization,
defaultFilterValues.channelUtilization,
)) ||
channelUtilization < filterState.channelUtilization[0] ||
channelUtilization > filterState.channelUtilization[1]
) {
return false;
}
const airUtilTx = node.deviceMetrics?.airUtilTx ?? 0;
if (
(node.deviceMetrics?.airUtilTx === undefined &&
!shallowEqualArray(filterState.airUtilTx, defaultFilterValues.airUtilTx)) ||
airUtilTx < filterState.airUtilTx[0] ||
airUtilTx > filterState.airUtilTx[1]
) {
return false;
}
const batt = node.deviceMetrics?.batteryLevel ?? 101;
if (
(node.deviceMetrics?.batteryLevel === undefined &&
!shallowEqualArray(filterState.batteryLevel, defaultFilterValues.batteryLevel)) ||
batt < filterState.batteryLevel[0] ||
batt > filterState.batteryLevel[1]
) {
return false;
}
const voltage = Math.abs(node.deviceMetrics?.voltage ?? 0);
if (
(node.deviceMetrics?.voltage === undefined &&
!shallowEqualArray(filterState.voltage, defaultFilterValues.voltage)) ||
voltage < filterState.voltage[0] ||
(voltage > filterState.voltage[1] &&
filterState.voltage[1] !== defaultFilterValues.voltage[1])
) {
return false;
}
const role: Protobuf.Config.Config_DeviceConfig_Role =
node.user?.role ?? Protobuf.Config.Config_DeviceConfig_Role.CLIENT;
if (
(node.user?.role === undefined &&
!shallowEqualArray(filterState.role, defaultFilterValues.role)) ||
!filterState.role.includes(role)
) {
return false;
}
const hwModel: Protobuf.Mesh.HardwareModel =
node.user?.hwModel ?? Protobuf.Mesh.HardwareModel.UNSET;
if (
(node.user?.hwModel === undefined &&
!shallowEqualArray(filterState.hwModel, defaultFilterValues.hwModel)) ||
!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 };
}
+13
View File
@@ -0,0 +1,13 @@
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,129 @@
import { Mono } from "@components/generic/Mono.tsx";
import { type DataRow, type Heading, Table } from "@components/generic/Table/index.tsx";
import { TimeAgo } from "@components/generic/TimeAgo.tsx";
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
// @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"]);
});
});
+164
View File
@@ -0,0 +1,164 @@
import { cn } from "@core/utils/cn.ts";
import { ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import React, { useMemo, useState } from "react";
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}
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) => {
const key = `${row.id}_${cellIndex}`;
const isFirstCell = cellIndex === 0;
const cellElement = isFirstCell ? (
<th
className="whitespace-nowrap px-3 py-2 text-sm text-left text-text-secondary"
scope="row"
>
{cell.content}
</th>
) : (
<td className="whitespace-nowrap px-3 py-2 text-sm text-text-secondary">
{cell.content}
</td>
);
return React.cloneElement(cellElement, { key });
})}
</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: ReturnType<typeof setTimeout>;
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>;
};