Initial tracke telemetry for contacts

This commit is contained in:
Jack Kingsman
2026-04-27 12:10:49 -07:00
parent 2c1279eb9e
commit 53f701938b
22 changed files with 1125 additions and 31 deletions
+2 -1
View File
@@ -361,7 +361,7 @@ Distance/validation helpers used by path + map UI.
- `last_advert_time`
- `flood_scope`
- `blocked_keys`, `blocked_names`, `discovery_blocked_types`
- `tracked_telemetry_repeaters`
- `tracked_telemetry_repeaters`, `tracked_telemetry_contacts`
- `auto_resend_channel`
- `telemetry_interval_hours`
@@ -382,6 +382,7 @@ Clicking a contact's avatar in `ChatHeader` or `MessageList` opens a `ContactInf
- Header: avatar, name, public key, type badge, on-radio badge
- Info grid: last seen, first heard, last contacted, distance, hops
- GPS location (clickable → map)
- On-demand LPP telemetry: "Request" button fetches `POST /contacts/{key}/telemetry`, displays sensor readings via `LppSensorRow`, optional GPS mini-map (Leaflet), and history chart (Recharts). Opt-in tracking toggle uses `POST /settings/tracked-telemetry-contacts/toggle`.
- Favorite toggle
- Name history ("Also Known As") — shown only when the contact has used multiple names
- Message stats: DM count, channel message count
+3
View File
@@ -166,6 +166,7 @@ export function App() {
handleToggleBlockedKey,
handleToggleBlockedName,
handleToggleTrackedTelemetry,
handleToggleTrackedTelemetryContact,
} = useAppSettings();
// Keep user's name in ref for mention detection in WebSocket callback
@@ -715,6 +716,8 @@ export function App() {
},
trackedTelemetryRepeaters: appSettings?.tracked_telemetry_repeaters ?? [],
onToggleTrackedTelemetry: handleToggleTrackedTelemetry,
trackedTelemetryContacts: appSettings?.tracked_telemetry_contacts ?? [],
onToggleTrackedTelemetryContact: handleToggleTrackedTelemetryContact,
};
const crackerProps = {
packets: rawPackets,
+19
View File
@@ -8,6 +8,7 @@ import type {
Contact,
ContactAnalytics,
ContactAdvertPathSummary,
ContactTelemetryResponse,
FanoutConfig,
HealthStatus,
MaintenanceResult,
@@ -35,6 +36,7 @@ import type {
RepeaterStatusResponse,
TelemetryHistoryEntry,
TelemetrySchedule,
TrackedTelemetryContactsResponse,
TrackedTelemetryResponse,
StatisticsResponse,
TraceResponse,
@@ -337,6 +339,16 @@ export const api = {
getTelemetrySchedule: () => fetchJson<TelemetrySchedule>('/settings/tracked-telemetry/schedule'),
// Tracked contact telemetry
toggleTrackedTelemetryContact: (publicKey: string) =>
fetchJson<TrackedTelemetryContactsResponse>('/settings/tracked-telemetry-contacts/toggle', {
method: 'POST',
body: JSON.stringify({ public_key: publicKey }),
}),
getContactTelemetrySchedule: () =>
fetchJson<TelemetrySchedule>('/settings/tracked-telemetry-contacts/schedule'),
// Favorites
toggleFavorite: (type: 'channel' | 'contact', id: string) =>
fetchJson<{ type: string; id: string; favorite: boolean }>('/settings/favorites/toggle', {
@@ -432,6 +444,13 @@ export const api = {
}),
repeaterTelemetryHistory: (publicKey: string) =>
fetchJson<TelemetryHistoryEntry[]>(`/contacts/${publicKey}/repeater/telemetry-history`),
// Contact telemetry (universal, any contact type)
requestContactTelemetry: (publicKey: string) =>
fetchJson<ContactTelemetryResponse>(`/contacts/${publicKey}/telemetry`, {
method: 'POST',
}),
contactTelemetryHistory: (publicKey: string) =>
fetchJson<TelemetryHistoryEntry[]>(`/contacts/${publicKey}/telemetry-history`),
roomLogin: (publicKey: string, password: string) =>
fetchJson<RepeaterLoginResponse>(`/contacts/${publicKey}/room/login`, {
method: 'POST',
+328 -2
View File
@@ -1,6 +1,8 @@
import { type ReactNode, useEffect, useMemo, useState } from 'react';
import { Ban, Search, Star } from 'lucide-react';
import { type ReactNode, useCallback, useEffect, useMemo, useState } from 'react';
import { Activity, Ban, ChevronDown, ChevronRight, Search, Star } from 'lucide-react';
import {
AreaChart,
Area,
LineChart,
Line,
XAxis,
@@ -10,6 +12,8 @@ import {
ResponsiveContainer,
Legend,
} from 'recharts';
import { MapContainer, TileLayer, CircleMarker, Popup } from 'react-leaflet';
import 'leaflet/dist/leaflet.css';
import { api, isAbortError } from '../api';
import { formatTime } from '../utils/messageParser';
import {
@@ -31,6 +35,7 @@ import { isPublicChannelKey } from '../utils/publicChannel';
import { getMapFocusHash } from '../utils/urlHash';
import { handleKeyboardActivate } from '../utils/a11y';
import { ContactAvatar } from './ContactAvatar';
import { LppSensorRow, formatLppLabel } from './repeater/repeaterPaneShared';
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './ui/sheet';
import { toast } from './ui/sonner';
import { useDistanceUnit } from '../contexts/DistanceUnitContext';
@@ -41,7 +46,10 @@ import type {
ContactAnalytics,
ContactAnalyticsHourlyBucket,
ContactAnalyticsWeeklyBucket,
LppSensor,
RadioConfig,
TelemetryHistoryEntry,
TelemetryLppSensor,
} from '../types';
const CONTACT_TYPE_LABELS: Record<number, string> = {
@@ -96,6 +104,8 @@ export function ContactInfoPane({
const [analytics, setAnalytics] = useState<ContactAnalytics | null>(null);
const [loading, setLoading] = useState(false);
const [telemetryLoading, setTelemetryLoading] = useState(false);
const [telemetryHistory, setTelemetryHistory] = useState<TelemetryHistoryEntry[]>([]);
// Get live contact data from contacts array (real-time via WS)
const liveContact =
@@ -133,6 +143,41 @@ export function ContactInfoPane({
};
}, [contactKey, isNameOnly, nameOnlyValue]);
// Load telemetry history when pane opens for a contact
useEffect(() => {
if (!contactKey || isNameOnly) {
setTelemetryHistory([]);
return;
}
let cancelled = false;
api
.contactTelemetryHistory(contactKey)
.then((data) => {
if (!cancelled) setTelemetryHistory(data);
})
.catch(() => {
if (!cancelled) setTelemetryHistory([]);
});
return () => {
cancelled = true;
};
}, [contactKey, isNameOnly]);
const handleFetchTelemetry = useCallback(async () => {
if (!contactKey || isNameOnly) return;
setTelemetryLoading(true);
try {
const result = await api.requestContactTelemetry(contactKey);
setTelemetryHistory(result.telemetry_history);
} catch (err) {
if (!isAbortError(err)) {
toast.error(err instanceof Error ? err.message : 'Failed to fetch telemetry');
}
} finally {
setTelemetryLoading(false);
}
}, [contactKey, isNameOnly]);
// Use live contact data where available, fall back to analytics snapshot
const contact = liveContact ?? analytics?.contact ?? null;
@@ -371,6 +416,14 @@ export function ContactInfoPane({
</div>
)}
{/* Contact Telemetry */}
<ContactTelemetrySection
contact={contact}
loading={telemetryLoading}
onFetch={handleFetchTelemetry}
telemetryHistory={telemetryHistory}
/>
{/* Favorite toggle */}
<div className="px-5 py-3 border-b border-border">
<button
@@ -909,3 +962,276 @@ function InfoItem({ label, value }: { label: string; value: ReactNode }) {
</div>
);
}
// Stable color rotation for dynamic LPP sensors in the history chart
const LPP_CHART_COLORS = ['#22c55e', '#8b5cf6', '#0ea5e9', '#ef4444', '#f59e0b', '#ec4899'];
function ContactTelemetrySection({
contact,
loading,
onFetch,
telemetryHistory,
}: {
contact: Contact;
loading: boolean;
onFetch: () => void;
telemetryHistory: TelemetryHistoryEntry[];
}) {
const { distanceUnit } = useDistanceUnit();
const [expanded, setExpanded] = useState(true);
const [mapExpanded, setMapExpanded] = useState(false);
const [chartExpanded, setChartExpanded] = useState(false);
// Latest telemetry snapshot from history
const latestEntry =
telemetryHistory.length > 0 ? telemetryHistory[telemetryHistory.length - 1] : null;
const sensors: LppSensor[] = useMemo(() => {
if (!latestEntry?.data?.lpp_sensors) return [];
return latestEntry.data.lpp_sensors.map((s: TelemetryLppSensor) => ({
channel: s.channel,
type_name: s.type_name,
value: s.value,
}));
}, [latestEntry]);
const fetchedAt = latestEntry?.timestamp ?? null;
// Extract GPS from sensors
const gpsSensor = sensors.find(
(s) => s.type_name === 'gps' && typeof s.value === 'object' && s.value !== null
);
const gpsValue = gpsSensor?.value as Record<string, number> | undefined;
const hasGps =
gpsValue != null &&
typeof gpsValue.latitude === 'number' &&
typeof gpsValue.longitude === 'number';
// Non-GPS sensors for display
const displaySensors = sensors.filter((s) => s.type_name !== 'gps');
// Build disambiguated labels
const labels = useMemo(() => {
const counts = new Map<string, number>();
return displaySensors.map((s) => {
const base = `${s.type_name}_${s.channel}`;
const n = (counts.get(base) ?? 0) + 1;
counts.set(base, n);
return formatLppLabel(s.type_name) + (n > 1 ? ` (${n})` : '');
});
}, [displaySensors]);
// Discover unique LPP sensor series from history for charting
const sensorSeries = useMemo(() => {
const seen = new Map<string, { type_name: string; channel: number }>();
for (const entry of telemetryHistory) {
for (const s of entry.data?.lpp_sensors ?? []) {
if (typeof s.value !== 'number') continue;
const key = `${s.type_name}_ch${s.channel}`;
if (!seen.has(key)) seen.set(key, { type_name: s.type_name, channel: s.channel });
}
}
return Array.from(seen.entries()).map(([key, info], i) => ({
key,
label: formatLppLabel(info.type_name),
color: LPP_CHART_COLORS[i % LPP_CHART_COLORS.length],
...info,
}));
}, [telemetryHistory]);
const [selectedMetric, setSelectedMetric] = useState<string | null>(null);
const activeMetric = selectedMetric ?? (sensorSeries.length > 0 ? sensorSeries[0].key : null);
// Build chart data for selected metric
const chartData = useMemo(() => {
if (!activeMetric) return [];
const series = sensorSeries.find((s) => s.key === activeMetric);
if (!series) return [];
return telemetryHistory
.filter((e) => e.data?.lpp_sensors)
.map((e) => {
const sensor = (e.data.lpp_sensors ?? []).find(
(s: TelemetryLppSensor) =>
s.type_name === series.type_name && s.channel === series.channel
);
return {
time: e.timestamp,
value: sensor && typeof sensor.value === 'number' ? sensor.value : null,
};
})
.filter((d) => d.value !== null);
}, [telemetryHistory, activeMetric, sensorSeries]);
const activeSeries = sensorSeries.find((s) => s.key === activeMetric);
return (
<div className="px-5 py-3 border-b border-border">
<div className="flex items-center justify-between">
<button
type="button"
className="flex items-center gap-1.5 text-[0.625rem] uppercase tracking-wider text-muted-foreground font-medium"
onClick={() => setExpanded(!expanded)}
>
{expanded ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
Telemetry
</button>
<button
type="button"
onClick={onFetch}
disabled={loading}
className="text-xs px-2 py-0.5 rounded border border-border hover:bg-accent disabled:opacity-50 transition-colors flex items-center gap-1"
>
<Activity className="h-3 w-3" />
{loading ? 'Fetching...' : 'Request'}
</button>
</div>
{expanded && (
<div className="mt-2">
{sensors.length === 0 ? (
<p className="text-sm text-muted-foreground italic">
{fetchedAt ? 'No sensor data in last response' : 'Not yet fetched'}
</p>
) : (
<>
<div className="space-y-0.5">
{displaySensors.map((sensor, i) => (
<LppSensorRow
key={`${sensor.type_name}-${sensor.channel}-${i}`}
sensor={sensor}
unitPref={distanceUnit}
label={labels[i]}
/>
))}
</div>
{hasGps && (
<div className="mt-2">
<button
type="button"
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-primary transition-colors"
onClick={() => setMapExpanded(!mapExpanded)}
>
{mapExpanded ? (
<ChevronDown className="h-3 w-3" />
) : (
<ChevronRight className="h-3 w-3" />
)}
GPS: {gpsValue!.latitude.toFixed(5)}, {gpsValue!.longitude.toFixed(5)}
</button>
{mapExpanded && (
<div className="mt-1 h-48 rounded border border-border overflow-hidden">
<MapContainer
center={[gpsValue!.latitude, gpsValue!.longitude]}
zoom={13}
className="h-full w-full"
style={{ background: '#1a1a2e' }}
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<CircleMarker
center={[gpsValue!.latitude, gpsValue!.longitude]}
radius={7}
pathOptions={{
color: '#1d4ed8',
fillColor: '#3b82f6',
fillOpacity: 1,
weight: 2,
}}
>
<Popup>
<span className="text-sm">
{contact.name ?? contact.public_key.slice(0, 12)}
</span>
</Popup>
</CircleMarker>
</MapContainer>
</div>
)}
</div>
)}
{fetchedAt && (
<p className="text-[0.6875rem] text-muted-foreground mt-1.5">
Fetched {formatTime(fetchedAt)}
</p>
)}
</>
)}
{/* History chart */}
{telemetryHistory.length > 1 && sensorSeries.length > 0 && (
<div className="mt-2">
<button
type="button"
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-primary transition-colors"
onClick={() => setChartExpanded(!chartExpanded)}
>
{chartExpanded ? (
<ChevronDown className="h-3 w-3" />
) : (
<ChevronRight className="h-3 w-3" />
)}
History ({telemetryHistory.length} samples)
</button>
{chartExpanded && (
<div className="mt-1">
<div className="flex flex-wrap gap-1 mb-2">
{sensorSeries.map((s) => (
<button
key={s.key}
type="button"
onClick={() => setSelectedMetric(s.key)}
className={`text-[0.625rem] uppercase tracking-wider px-1.5 py-0.5 rounded transition-colors ${
activeMetric === s.key
? 'bg-primary/10 text-primary'
: 'bg-muted text-muted-foreground hover:text-foreground'
}`}
>
{s.label}
</button>
))}
</div>
{chartData.length > 1 && activeSeries && (
<ResponsiveContainer width="100%" height={120}>
<AreaChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
<XAxis
dataKey="time"
tickFormatter={(t: number) => {
const d = new Date(t * 1000);
return `${d.getMonth() + 1}/${d.getDate()} ${d.getHours()}:${d.getMinutes().toString().padStart(2, '0')}`;
}}
fontSize={9}
tick={{ fill: 'var(--muted-foreground)' }}
/>
<YAxis fontSize={9} tick={{ fill: 'var(--muted-foreground)' }} width={40} />
<RechartsTooltip
labelFormatter={(t) => new Date(Number(t) * 1000).toLocaleString()}
contentStyle={{
backgroundColor: 'var(--popover)',
border: '1px solid var(--border)',
fontSize: '0.75rem',
}}
/>
<Area
type="monotone"
dataKey="value"
name={activeSeries.label}
stroke={activeSeries.color}
fill={activeSeries.color}
fillOpacity={0.15}
dot={false}
/>
</AreaChart>
</ResponsiveContainer>
)}
</div>
)}
</div>
)}
</div>
)}
</div>
);
}
@@ -54,6 +54,8 @@ interface SettingsModalBaseProps {
onBulkDeleteContacts?: (deletedKeys: string[]) => void;
trackedTelemetryRepeaters?: string[];
onToggleTrackedTelemetry?: (publicKey: string) => Promise<void>;
trackedTelemetryContacts?: string[];
onToggleTrackedTelemetryContact?: (publicKey: string) => Promise<void>;
}
export type SettingsModalProps = SettingsModalBaseProps &
@@ -92,6 +94,8 @@ export function SettingsModal(props: SettingsModalProps) {
onBulkDeleteContacts,
trackedTelemetryRepeaters,
onToggleTrackedTelemetry,
trackedTelemetryContacts,
onToggleTrackedTelemetryContact,
} = props;
const externalSidebarNav = props.externalSidebarNav === true;
const desktopSection = props.externalSidebarNav ? props.desktopSection : undefined;
@@ -257,6 +261,8 @@ export function SettingsModal(props: SettingsModalProps) {
onBulkDeleteContacts={onBulkDeleteContacts}
trackedTelemetryRepeaters={trackedTelemetryRepeaters}
onToggleTrackedTelemetry={onToggleTrackedTelemetry}
trackedTelemetryContacts={trackedTelemetryContacts}
onToggleTrackedTelemetryContact={onToggleTrackedTelemetryContact}
className={sectionContentClass}
/>
) : (
@@ -31,6 +31,8 @@ export function SettingsDatabaseSection({
onBulkDeleteContacts,
trackedTelemetryRepeaters = [],
onToggleTrackedTelemetry,
trackedTelemetryContacts = [],
onToggleTrackedTelemetryContact,
className,
}: {
appSettings: AppSettings;
@@ -45,6 +47,8 @@ export function SettingsDatabaseSection({
onBulkDeleteContacts?: (deletedKeys: string[]) => void;
trackedTelemetryRepeaters?: string[];
onToggleTrackedTelemetry?: (publicKey: string) => Promise<void>;
trackedTelemetryContacts?: string[];
onToggleTrackedTelemetryContact?: (publicKey: string) => Promise<void>;
className?: string;
}) {
const { distanceUnit } = useDistanceUnit();
@@ -60,6 +64,11 @@ export function SettingsDatabaseSection({
>({});
const telemetryFetchedRef = useRef(false);
const [latestContactTelemetry, setLatestContactTelemetry] = useState<
Record<string, TelemetryHistoryEntry | null>
>({});
const contactTelemetryFetchedRef = useRef(false);
const [schedule, setSchedule] = useState<TelemetrySchedule | null>(null);
const [intervalDraft, setIntervalDraft] = useState<number>(appSettings.telemetry_interval_hours);
@@ -94,6 +103,7 @@ export function SettingsDatabaseSection({
};
}, [
trackedTelemetryRepeaters.length,
trackedTelemetryContacts.length,
appSettings.telemetry_interval_hours,
appSettings.telemetry_routed_hourly,
]);
@@ -117,6 +127,25 @@ export function SettingsDatabaseSection({
};
}, [trackedTelemetryRepeaters]);
useEffect(() => {
if (trackedTelemetryContacts.length === 0 || contactTelemetryFetchedRef.current) return;
contactTelemetryFetchedRef.current = true;
let cancelled = false;
const fetches = trackedTelemetryContacts.map((key) =>
api.contactTelemetryHistory(key).then(
(history) => [key, history.length > 0 ? history[history.length - 1] : null] as const,
() => [key, null] as const
)
);
Promise.all(fetches).then((entries) => {
if (cancelled) return;
setLatestContactTelemetry(Object.fromEntries(entries));
});
return () => {
cancelled = true;
};
}, [trackedTelemetryContacts]);
const handleCleanup = async () => {
const days = parseInt(retentionDays, 10);
if (isNaN(days) || days < 1) {
@@ -480,6 +509,102 @@ export function SettingsDatabaseSection({
<Separator />
{/* ── Tracked Contact Telemetry ── */}
<div className="space-y-3">
<h3 className="text-base font-semibold tracking-tight">Tracked Contact Telemetry</h3>
<p className="text-[0.8125rem] text-muted-foreground">
Non-repeater contacts (companions, rooms, sensors) can also be tracked for periodic LPP
telemetry collection (battery, sensors, GPS). Up to 8 contacts may be tracked. The daily
check ceiling is shared with tracked repeaters — adding contacts may clamp the interval
upward.
</p>
{trackedTelemetryContacts.length === 0 ? (
<p className="text-sm text-muted-foreground italic">
No contacts are being tracked. Enable tracking from a contact&apos;s info pane.
</p>
) : (
<div className="space-y-2">
{trackedTelemetryContacts.map((key) => {
const contact = contacts.find((c) => c.public_key === key);
const displayName = contact?.name ?? key.slice(0, 12);
const routeSource = contact?.effective_route_source ?? 'flood';
const hasRealPath =
contact?.effective_route != null && contact.effective_route.path_len >= 0;
const routeLabel = !hasRealPath
? 'flood'
: routeSource === 'override'
? 'routed'
: routeSource === 'direct'
? 'direct'
: 'flood';
const routeColor = hasRealPath
? 'text-primary bg-primary/10'
: 'text-muted-foreground bg-muted';
const snap = latestContactTelemetry[key];
const d = snap?.data;
return (
<div key={key} className="rounded-md border border-border px-3 py-2">
<div className="flex items-center justify-between gap-2">
<div className="flex-1 min-w-0">
<span className="text-sm truncate block">{displayName}</span>
<div className="flex items-center gap-1.5">
<span className="text-[0.625rem] text-muted-foreground font-mono">
{key.slice(0, 12)}
</span>
<span
className={`text-[0.625rem] uppercase tracking-wider px-1.5 py-0.5 rounded font-medium ${routeColor}`}
>
{routeLabel}
</span>
</div>
</div>
{onToggleTrackedTelemetryContact && (
<Button
variant="ghost"
size="sm"
onClick={() => onToggleTrackedTelemetryContact(key)}
className="h-7 text-xs flex-shrink-0 text-destructive hover:text-destructive"
>
Remove
</Button>
)}
</div>
{d ? (
<div className="mt-1.5 flex flex-wrap gap-x-3 gap-y-0.5 text-[0.625rem] text-muted-foreground">
{d.lpp_sensors?.map((s) => {
if (typeof s.value !== 'number') return null;
const display = lppDisplayUnit(s.type_name, s.value, distanceUnit);
const val =
typeof display.value === 'number'
? display.value % 1 === 0
? display.value
: display.value.toFixed(1)
: display.value;
const label = s.type_name.charAt(0).toUpperCase() + s.type_name.slice(1);
return (
<span key={`${s.type_name}-${s.channel}`}>
{label} {val}
{display.unit ? ` ${display.unit}` : ''}
</span>
);
})}
<span className="ml-auto">checked {formatTime(snap.timestamp)}</span>
</div>
) : snap === null ? (
<div className="mt-1 text-[0.625rem] text-muted-foreground italic">
No telemetry recorded yet
</div>
) : null}
</div>
);
})}
</div>
)}
</div>
<Separator />
{/* ── Contact Management ── */}
<div className="space-y-5">
<h3 className="text-base font-semibold tracking-tight">Contact Management</h3>
+34
View File
@@ -113,6 +113,39 @@ export function useAppSettings() {
}
}, []);
const handleToggleTrackedTelemetryContact = useCallback(async (publicKey: string) => {
const key = publicKey.toLowerCase();
setAppSettings((prev) => {
if (!prev) return prev;
const current = prev.tracked_telemetry_contacts ?? [];
const wasTracked = current.includes(key);
const optimistic = wasTracked ? current.filter((k) => k !== key) : [...current, key];
return { ...prev, tracked_telemetry_contacts: optimistic };
});
try {
const result = await api.toggleTrackedTelemetryContact(publicKey);
setAppSettings((prev) =>
prev ? { ...prev, tracked_telemetry_contacts: result.tracked_telemetry_contacts } : prev
);
} catch (err) {
console.error('Failed to toggle tracked contact telemetry:', err);
try {
const settings = await api.getSettings();
setAppSettings(settings);
} catch {
// If refetch also fails, leave optimistic state
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const detail = (err as any)?.body?.detail;
if (typeof detail === 'object' && detail?.message) {
toast.error(detail.message);
} else {
toast.error('Failed to update tracked contact telemetry');
}
}
}, []);
// Legacy favorites migration: if pre-server-side favorites exist in
// localStorage, toggle each one via the existing API and clear the key.
useEffect(() => {
@@ -153,5 +186,6 @@ export function useAppSettings() {
handleToggleBlockedKey,
handleToggleBlockedName,
handleToggleTrackedTelemetry,
handleToggleTrackedTelemetryContact,
};
}
+13 -1
View File
@@ -4,14 +4,17 @@ import { describe, expect, it, vi, beforeEach } from 'vitest';
import { ContactInfoPane } from '../components/ContactInfoPane';
import type { Contact, ContactAnalytics } from '../types';
const { getContactAnalytics } = vi.hoisted(() => ({
const { getContactAnalytics, contactTelemetryHistory } = vi.hoisted(() => ({
getContactAnalytics: vi.fn(),
contactTelemetryHistory: vi.fn(),
}));
vi.mock('../api', () => ({
api: {
getContactAnalytics,
contactTelemetryHistory,
},
isAbortError: () => false,
}));
vi.mock('../components/ui/sheet', () => ({
@@ -26,6 +29,13 @@ vi.mock('../components/ContactAvatar', () => ({
ContactAvatar: () => <div data-testid="contact-avatar" />,
}));
vi.mock('react-leaflet', () => ({
MapContainer: () => null,
TileLayer: () => null,
CircleMarker: () => null,
Popup: () => null,
}));
vi.mock('../components/ui/sonner', () => ({
toast: {
error: vi.fn(),
@@ -99,6 +109,8 @@ const baseProps = {
describe('ContactInfoPane', () => {
beforeEach(() => {
getContactAnalytics.mockReset();
contactTelemetryHistory.mockReset();
contactTelemetryHistory.mockResolvedValue([]);
baseProps.onSearchMessagesByKey = vi.fn();
baseProps.onSearchMessagesByName = vi.fn();
});
+2
View File
@@ -109,6 +109,7 @@ beforeEach(() => {
blocked_names: [],
discovery_blocked_types: [],
tracked_telemetry_repeaters: [],
tracked_telemetry_contacts: [],
auto_resend_channel: false,
telemetry_interval_hours: 8,
telemetry_routed_hourly: false,
@@ -1049,6 +1050,7 @@ describe('SettingsFanoutSection', () => {
blocked_names: [],
discovery_blocked_types: [],
tracked_telemetry_repeaters: ['cc'.repeat(32)],
tracked_telemetry_contacts: [],
auto_resend_channel: false,
telemetry_interval_hours: 8,
telemetry_routed_hourly: false,
+1
View File
@@ -70,6 +70,7 @@ const baseSettings: AppSettings = {
blocked_names: [],
discovery_blocked_types: [],
tracked_telemetry_repeaters: [],
tracked_telemetry_contacts: [],
auto_resend_channel: false,
telemetry_interval_hours: 8,
telemetry_routed_hourly: false,
+13
View File
@@ -357,6 +357,7 @@ export interface AppSettings {
blocked_names: string[];
discovery_blocked_types: number[];
tracked_telemetry_repeaters: string[];
tracked_telemetry_contacts: string[];
auto_resend_channel: boolean;
telemetry_interval_hours: number;
telemetry_routed_hourly: boolean;
@@ -490,6 +491,18 @@ export interface RepeaterLppTelemetryResponse {
sensors: LppSensor[];
}
export interface ContactTelemetryResponse {
sensors: LppSensor[];
fetched_at: number;
telemetry_history: TelemetryHistoryEntry[];
}
export interface TrackedTelemetryContactsResponse {
tracked_telemetry_contacts: string[];
names: Record<string, string>;
schedule: TelemetrySchedule;
}
export type PaneName =
| 'status'
| 'nodeInfo'