mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-05-10 07:15:09 +02:00
Split up setting to be a bit neater
This commit is contained in:
+3
-2
@@ -141,7 +141,8 @@ frontend/src/
|
||||
│ │ ├── SettingsRadioSection.tsx # Name, keys, advert interval, max contacts, radio preset, freq/bw/sf/cr, txPower, lat/lon, reboot, mesh discovery
|
||||
│ │ ├── SettingsLocalSection.tsx # Browser-local settings: theme, relative font scale, local label, reopen last conversation
|
||||
│ │ ├── SettingsFanoutSection.tsx # Fanout integrations: MQTT, bots, config CRUD
|
||||
│ │ ├── SettingsDatabaseSection.tsx # DB size, cleanup, auto-decrypt, local label
|
||||
│ │ ├── SettingsRadioAppSection.tsx # Radio-App Management: tracked telemetry, contact management, blocked lists
|
||||
│ │ ├── SettingsDatabaseSection.tsx # Database: DB size, storage cleanup, auto-decrypt
|
||||
│ │ ├── SettingsStatisticsSection.tsx # Read-only mesh network stats
|
||||
│ │ ├── SettingsAboutSection.tsx # Version, author, license, links
|
||||
│ │ ├── ThemeSelector.tsx # Color theme picker
|
||||
@@ -323,7 +324,7 @@ Supported routes:
|
||||
- `#contact/{publicKey}`
|
||||
- `#contact/{publicKey}/{label}`
|
||||
|
||||
Where `{section}` is one of `radio`, `local`, `fanout`, `database`, `statistics`, or `about`.
|
||||
Where `{section}` is one of `radio`, `local`, `radio-app`, `database`, `fanout`, `statistics`, or `about`.
|
||||
|
||||
Legacy name-based channel/contact hashes are still accepted for compatibility.
|
||||
|
||||
|
||||
@@ -751,6 +751,8 @@ export function App() {
|
||||
onToggleBlockedName: handleBlockName,
|
||||
blockedKeys: appSettings?.blocked_keys ?? [],
|
||||
blockedNames: appSettings?.blocked_names ?? [],
|
||||
trackedTelemetryContacts: appSettings?.tracked_telemetry_contacts ?? [],
|
||||
onToggleTrackedTelemetryContact: handleToggleTrackedTelemetryContact,
|
||||
};
|
||||
const channelInfoPaneProps = {
|
||||
channelKey: infoPaneChannelKey,
|
||||
|
||||
@@ -81,6 +81,8 @@ interface ContactInfoPaneProps {
|
||||
blockedNames?: string[];
|
||||
onToggleBlockedKey?: (key: string) => void;
|
||||
onToggleBlockedName?: (name: string) => void;
|
||||
trackedTelemetryContacts?: string[];
|
||||
onToggleTrackedTelemetryContact?: (publicKey: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function ContactInfoPane({
|
||||
@@ -97,6 +99,8 @@ export function ContactInfoPane({
|
||||
blockedNames = [],
|
||||
onToggleBlockedKey,
|
||||
onToggleBlockedName,
|
||||
trackedTelemetryContacts = [],
|
||||
onToggleTrackedTelemetryContact,
|
||||
}: ContactInfoPaneProps) {
|
||||
const { distanceUnit } = useDistanceUnit();
|
||||
const isNameOnly = contactKey?.startsWith('name:') ?? false;
|
||||
@@ -422,6 +426,8 @@ export function ContactInfoPane({
|
||||
loading={telemetryLoading}
|
||||
onFetch={handleFetchTelemetry}
|
||||
telemetryHistory={telemetryHistory}
|
||||
isTracked={trackedTelemetryContacts.includes(contact.public_key)}
|
||||
onToggleTracked={onToggleTrackedTelemetryContact}
|
||||
/>
|
||||
|
||||
{/* Favorite toggle */}
|
||||
@@ -971,16 +977,21 @@ function ContactTelemetrySection({
|
||||
loading,
|
||||
onFetch,
|
||||
telemetryHistory,
|
||||
isTracked,
|
||||
onToggleTracked,
|
||||
}: {
|
||||
contact: Contact;
|
||||
loading: boolean;
|
||||
onFetch: () => void;
|
||||
telemetryHistory: TelemetryHistoryEntry[];
|
||||
isTracked: boolean;
|
||||
onToggleTracked?: (publicKey: string) => Promise<void>;
|
||||
}) {
|
||||
const { distanceUnit } = useDistanceUnit();
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
const [mapExpanded, setMapExpanded] = useState(false);
|
||||
const [chartExpanded, setChartExpanded] = useState(false);
|
||||
const [toggling, setToggling] = useState(false);
|
||||
|
||||
// Latest telemetry snapshot from history
|
||||
const latestEntry =
|
||||
@@ -1230,6 +1241,35 @@ function ContactTelemetrySection({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tracking toggle */}
|
||||
{onToggleTracked && (
|
||||
<div className="mt-2 pt-2 border-t border-border/50">
|
||||
<button
|
||||
type="button"
|
||||
disabled={toggling}
|
||||
onClick={async () => {
|
||||
setToggling(true);
|
||||
try {
|
||||
await onToggleTracked(contact.public_key);
|
||||
} finally {
|
||||
setToggling(false);
|
||||
}
|
||||
}}
|
||||
className={`text-xs px-2 py-1 rounded border transition-colors w-full ${
|
||||
isTracked
|
||||
? 'border-destructive/50 text-destructive hover:bg-destructive/10'
|
||||
: 'border-green-600/50 text-green-600 hover:bg-green-600/10'
|
||||
} disabled:opacity-50`}
|
||||
>
|
||||
{toggling
|
||||
? 'Updating...'
|
||||
: isTracked
|
||||
? 'Stop Tracking Telemetry'
|
||||
: 'Track Telemetry on Interval'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
|
||||
import { SettingsRadioSection } from './settings/SettingsRadioSection';
|
||||
import { SettingsLocalSection } from './settings/SettingsLocalSection';
|
||||
import { SettingsRadioAppSection } from './settings/SettingsRadioAppSection';
|
||||
import { SettingsFanoutSection } from './settings/SettingsFanoutSection';
|
||||
import { SettingsDatabaseSection } from './settings/SettingsDatabaseSection';
|
||||
import { SettingsStatisticsSection } from './settings/SettingsStatisticsSection';
|
||||
@@ -110,6 +111,7 @@ export function SettingsModal(props: SettingsModalProps) {
|
||||
const [expandedSections, setExpandedSections] = useState<Record<SettingsSection, boolean>>({
|
||||
radio: false,
|
||||
local: false,
|
||||
'radio-app': false,
|
||||
fanout: false,
|
||||
database: false,
|
||||
statistics: false,
|
||||
@@ -243,16 +245,14 @@ export function SettingsModal(props: SettingsModalProps) {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{shouldRenderSection('database') && (
|
||||
{shouldRenderSection('radio-app') && (
|
||||
<section className={sectionWrapperClass}>
|
||||
{renderSectionHeader('database')}
|
||||
{isSectionVisible('database') &&
|
||||
{renderSectionHeader('radio-app')}
|
||||
{isSectionVisible('radio-app') &&
|
||||
(appSettings ? (
|
||||
<SettingsDatabaseSection
|
||||
<SettingsRadioAppSection
|
||||
appSettings={appSettings}
|
||||
health={health}
|
||||
onSaveAppSettings={onSaveAppSettings}
|
||||
onHealthRefresh={onHealthRefresh}
|
||||
blockedKeys={blockedKeys}
|
||||
blockedNames={blockedNames}
|
||||
onToggleBlockedKey={onToggleBlockedKey}
|
||||
@@ -275,6 +275,28 @@ export function SettingsModal(props: SettingsModalProps) {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{shouldRenderSection('database') && (
|
||||
<section className={sectionWrapperClass}>
|
||||
{renderSectionHeader('database')}
|
||||
{isSectionVisible('database') &&
|
||||
(appSettings ? (
|
||||
<SettingsDatabaseSection
|
||||
appSettings={appSettings}
|
||||
health={health}
|
||||
onSaveAppSettings={onSaveAppSettings}
|
||||
onHealthRefresh={onHealthRefresh}
|
||||
className={sectionContentClass}
|
||||
/>
|
||||
) : (
|
||||
<div className={sectionContentClass}>
|
||||
<div className="rounded-md border border-input bg-muted/20 px-4 py-3 text-sm text-muted-foreground">
|
||||
Loading app settings...
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{shouldRenderSection('fanout') && (
|
||||
<section className={sectionWrapperClass}>
|
||||
{renderSectionHeader('fanout')}
|
||||
|
||||
@@ -247,10 +247,10 @@ export function TelemetryHistoryPane({
|
||||
), or when the repeater is opted into interval telemetry polling, in which case the
|
||||
repeater will be polled for metrics automatically. Fetch frequency can be configured in{' '}
|
||||
<a
|
||||
href="#settings/database"
|
||||
href="#settings/radio-app"
|
||||
className="underline text-primary hover:text-primary/80 transition-colors"
|
||||
>
|
||||
Settings → Database & Messaging
|
||||
Settings → Radio-App Management
|
||||
</a>
|
||||
, where you can also see which repeaters are currently opted in. A maximum of{' '}
|
||||
{MAX_TRACKED} repeaters may be opted into this for the sake of keeping mesh congestion
|
||||
|
||||
@@ -6,146 +6,32 @@ import { Separator } from '../ui/separator';
|
||||
import { toast } from '../ui/sonner';
|
||||
import { api } from '../../api';
|
||||
import { formatTime } from '../../utils/messageParser';
|
||||
import { lppDisplayUnit } from '../repeater/repeaterPaneShared';
|
||||
import { useDistanceUnit } from '../../contexts/DistanceUnitContext';
|
||||
import { BulkDeleteContactsModal } from './BulkDeleteContactsModal';
|
||||
import type {
|
||||
AppSettings,
|
||||
AppSettingsUpdate,
|
||||
Contact,
|
||||
HealthStatus,
|
||||
TelemetryHistoryEntry,
|
||||
TelemetrySchedule,
|
||||
} from '../../types';
|
||||
import type { AppSettings, AppSettingsUpdate, HealthStatus } from '../../types';
|
||||
|
||||
export function SettingsDatabaseSection({
|
||||
appSettings,
|
||||
health,
|
||||
onSaveAppSettings,
|
||||
onHealthRefresh,
|
||||
blockedKeys = [],
|
||||
blockedNames = [],
|
||||
onToggleBlockedKey,
|
||||
onToggleBlockedName,
|
||||
contacts = [],
|
||||
onBulkDeleteContacts,
|
||||
trackedTelemetryRepeaters = [],
|
||||
onToggleTrackedTelemetry,
|
||||
trackedTelemetryContacts = [],
|
||||
onToggleTrackedTelemetryContact,
|
||||
className,
|
||||
}: {
|
||||
appSettings: AppSettings;
|
||||
health: HealthStatus | null;
|
||||
onSaveAppSettings: (update: AppSettingsUpdate) => Promise<void>;
|
||||
onHealthRefresh: () => Promise<void>;
|
||||
blockedKeys?: string[];
|
||||
blockedNames?: string[];
|
||||
onToggleBlockedKey?: (key: string) => void;
|
||||
onToggleBlockedName?: (name: string) => void;
|
||||
contacts?: Contact[];
|
||||
onBulkDeleteContacts?: (deletedKeys: string[]) => void;
|
||||
trackedTelemetryRepeaters?: string[];
|
||||
onToggleTrackedTelemetry?: (publicKey: string) => Promise<void>;
|
||||
trackedTelemetryContacts?: string[];
|
||||
onToggleTrackedTelemetryContact?: (publicKey: string) => Promise<void>;
|
||||
className?: string;
|
||||
}) {
|
||||
const { distanceUnit } = useDistanceUnit();
|
||||
const [retentionDays, setRetentionDays] = useState('14');
|
||||
const [cleaning, setCleaning] = useState(false);
|
||||
const [purgingDecryptedRaw, setPurgingDecryptedRaw] = useState(false);
|
||||
const [autoDecryptOnAdvert, setAutoDecryptOnAdvert] = useState(false);
|
||||
const [discoveryBlockedTypes, setDiscoveryBlockedTypes] = useState<number[]>([]);
|
||||
const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false);
|
||||
|
||||
const [latestTelemetry, setLatestTelemetry] = useState<
|
||||
Record<string, TelemetryHistoryEntry | null>
|
||||
>({});
|
||||
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);
|
||||
|
||||
// Serialization chain for every auto-persisted control on this page.
|
||||
// Without this, rapid successive toggles (or mixed dropdown + checkbox
|
||||
// interactions) can dispatch overlapping PATCHes that land out of order
|
||||
// on HTTP/2 — a stale write then wins, reverting the user's last click.
|
||||
// Each call awaits the previous one before sending its request, so the
|
||||
// server sees updates in the order the user made them.
|
||||
const saveChainRef = useRef<Promise<void>>(Promise.resolve());
|
||||
|
||||
useEffect(() => {
|
||||
setAutoDecryptOnAdvert(appSettings.auto_decrypt_dm_on_advert);
|
||||
setDiscoveryBlockedTypes(appSettings.discovery_blocked_types ?? []);
|
||||
setIntervalDraft(appSettings.telemetry_interval_hours);
|
||||
}, [appSettings]);
|
||||
|
||||
// Re-fetch the scheduler derivation whenever the tracked list changes or
|
||||
// the stored preference changes. Cheap: single GET, no radio lock.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
api
|
||||
.getTelemetrySchedule()
|
||||
.then((s) => {
|
||||
if (!cancelled) setSchedule(s);
|
||||
})
|
||||
.catch(() => {
|
||||
// Non-critical: dropdown falls back to the unfiltered menu.
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [
|
||||
trackedTelemetryRepeaters.length,
|
||||
trackedTelemetryContacts.length,
|
||||
appSettings.telemetry_interval_hours,
|
||||
appSettings.telemetry_routed_hourly,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (trackedTelemetryRepeaters.length === 0 || telemetryFetchedRef.current) return;
|
||||
telemetryFetchedRef.current = true;
|
||||
let cancelled = false;
|
||||
const fetches = trackedTelemetryRepeaters.map((key) =>
|
||||
api.repeaterTelemetryHistory(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;
|
||||
setLatestTelemetry(Object.fromEntries(entries));
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [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) {
|
||||
@@ -192,12 +78,6 @@ export function SettingsDatabaseSection({
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Apply an AppSettings PATCH after any already-queued saves finish, and
|
||||
* revert local state if the save fails. Every auto-persist control on
|
||||
* this page routes through here so the user-visible order of clicks is
|
||||
* the order the backend sees, regardless of network reordering.
|
||||
*/
|
||||
const persistAppSettings = (update: AppSettingsUpdate, revert: () => void): Promise<void> => {
|
||||
const chained = saveChainRef.current.then(async () => {
|
||||
try {
|
||||
@@ -324,426 +204,6 @@ export function SettingsDatabaseSection({
|
||||
contact sends an advertisement. This may cause brief delays on large packet backlogs.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* ── Tracked Repeater Telemetry ── */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-base font-semibold tracking-tight">Tracked Repeater Telemetry</h3>
|
||||
<p className="text-[0.8125rem] text-muted-foreground">
|
||||
Repeaters opted into automatic telemetry collection are polled on a scheduled interval. To
|
||||
limit mesh traffic, the app caps telemetry at 24 checks per day across all tracked
|
||||
repeaters — so fewer tracked repeaters allows shorter intervals, and more tracked
|
||||
repeaters forces longer ones. Up to {schedule?.max_tracked ?? 8} repeaters may be tracked
|
||||
at once ({trackedTelemetryRepeaters.length} / {schedule?.max_tracked ?? 8} slots used).
|
||||
</p>
|
||||
|
||||
{/* Interval picker. Legal options depend on current tracked count;
|
||||
we list only those. If the saved preference is no longer legal,
|
||||
the effective interval is shown below so the user knows what the
|
||||
scheduler is actually using. */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="telemetry-interval" className="text-sm">
|
||||
Collection interval
|
||||
</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
id="telemetry-interval"
|
||||
value={intervalDraft}
|
||||
onChange={(e) => {
|
||||
const nextValue = Number(e.target.value);
|
||||
if (!Number.isFinite(nextValue) || nextValue === intervalDraft) return;
|
||||
const prevValue = intervalDraft;
|
||||
setIntervalDraft(nextValue);
|
||||
void persistAppSettings({ telemetry_interval_hours: nextValue }, () =>
|
||||
setIntervalDraft(prevValue)
|
||||
);
|
||||
}}
|
||||
className="h-9 px-3 rounded-md border border-input bg-background text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
||||
>
|
||||
{(schedule?.options ?? [1, 2, 3, 4, 6, 8, 12, 24]).map((hrs) => (
|
||||
<option key={hrs} value={hrs}>
|
||||
Every {hrs} hour{hrs === 1 ? '' : 's'} ({Math.floor(24 / hrs)} check
|
||||
{Math.floor(24 / hrs) === 1 ? '' : 's'}/day)
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{schedule && schedule.effective_hours !== schedule.preferred_hours && (
|
||||
<p className="text-xs text-warning">
|
||||
Saved preference is {schedule.preferred_hours} hour
|
||||
{schedule.preferred_hours === 1 ? '' : 's'}, but the scheduler is using{' '}
|
||||
{schedule.effective_hours} hours because {schedule.tracked_count} repeater
|
||||
{schedule.tracked_count === 1 ? '' : 's'}{' '}
|
||||
{schedule.tracked_count === 1 ? 'is' : 'are'} tracked. Your preference will be
|
||||
restored if you drop back to a supported count.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Routed hourly toggle */}
|
||||
<label className="flex items-start gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={appSettings.telemetry_routed_hourly}
|
||||
onChange={() => {
|
||||
const next = !appSettings.telemetry_routed_hourly;
|
||||
void persistAppSettings({ telemetry_routed_hourly: next }, () => {});
|
||||
}}
|
||||
className="w-4 h-4 rounded border-input accent-primary mt-0.5"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm">Poll direct/routed-path repeaters hourly</span>
|
||||
<p className="text-[0.8125rem] text-muted-foreground">
|
||||
When enabled, tracked repeaters with a direct or routed path (not flood) are polled
|
||||
every hour instead of on the scheduled interval above. Flood-only repeaters still
|
||||
follow the normal schedule.
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{schedule?.next_run_at != null && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{schedule.routed_hourly ? 'Next flood run at' : 'Next run at'}{' '}
|
||||
{formatTime(schedule.next_run_at)} (UTC top of hour).
|
||||
</p>
|
||||
)}
|
||||
{schedule?.next_routed_run_at != null && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Next direct/routed run at {formatTime(schedule.next_routed_run_at)} (UTC top of hour).
|
||||
</p>
|
||||
)}
|
||||
|
||||
{trackedTelemetryRepeaters.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground italic">
|
||||
No repeaters are being tracked. Enable tracking from a repeater's dashboard.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{trackedTelemetryRepeaters.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';
|
||||
// A forced-flood override (path_len < 0) still reports source
|
||||
// "override", but the actual route is flood. Check the real path.
|
||||
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 = latestTelemetry[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>
|
||||
{onToggleTrackedTelemetry && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onToggleTrackedTelemetry(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">
|
||||
<span>{d.battery_volts?.toFixed(2)}V</span>
|
||||
<span>noise {d.noise_floor_dbm} dBm</span>
|
||||
<span>
|
||||
rx {d.packets_received != null ? d.packets_received.toLocaleString() : '?'}
|
||||
</span>
|
||||
<span>
|
||||
tx {d.packets_sent != null ? d.packets_sent.toLocaleString() : '?'}
|
||||
</span>
|
||||
{d.lpp_sensors?.map((s) => {
|
||||
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 />
|
||||
|
||||
{/* ── 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'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>
|
||||
|
||||
{/* Block discovery of new node types */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-semibold">Block Discovery of New Node Types</h4>
|
||||
<p className="text-[0.8125rem] text-muted-foreground">
|
||||
Checked types will be ignored when heard via advertisement. Existing contacts of these
|
||||
types are still updated. This does not affect contacts added manually or via DM.
|
||||
</p>
|
||||
<div className="space-y-1.5">
|
||||
{(
|
||||
[
|
||||
[1, 'Block clients'],
|
||||
[2, 'Block repeaters'],
|
||||
[3, 'Block room servers'],
|
||||
[4, 'Block sensors'],
|
||||
] as const
|
||||
).map(([typeCode, label]) => {
|
||||
const checked = discoveryBlockedTypes.includes(typeCode);
|
||||
return (
|
||||
<label key={typeCode} className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => {
|
||||
const prev = discoveryBlockedTypes;
|
||||
const next = checked
|
||||
? prev.filter((t) => t !== typeCode)
|
||||
: [...prev, typeCode];
|
||||
setDiscoveryBlockedTypes(next);
|
||||
void persistAppSettings({ discovery_blocked_types: next }, () =>
|
||||
setDiscoveryBlockedTypes(prev)
|
||||
);
|
||||
}}
|
||||
className="rounded border-input"
|
||||
/>
|
||||
{label}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{discoveryBlockedTypes.length > 0 && (
|
||||
<p className="text-xs text-warning">
|
||||
New{' '}
|
||||
{discoveryBlockedTypes
|
||||
.map((t) =>
|
||||
t === 1 ? 'clients' : t === 2 ? 'repeaters' : t === 3 ? 'room servers' : 'sensors'
|
||||
)
|
||||
.join(', ')}{' '}
|
||||
heard via advertisement will not be added to your contact list.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Blocked contacts list */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-semibold">Blocked Contacts</h4>
|
||||
<p className="text-[0.8125rem] text-muted-foreground">
|
||||
Blocked contacts are hidden from the sidebar. Blocking only hides messages from the UI —
|
||||
MQTT forwarding and bot responses are not affected. Messages are still stored and will
|
||||
reappear if unblocked.
|
||||
</p>
|
||||
|
||||
{blockedKeys.length === 0 && blockedNames.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground italic">
|
||||
No blocked contacts. Block contacts from their info pane, viewed by clicking their
|
||||
avatar in any channel, or their name within the top status bar with the conversation
|
||||
open.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{blockedKeys.length > 0 && (
|
||||
<div>
|
||||
<span className="text-xs text-muted-foreground font-medium">Blocked Keys</span>
|
||||
<div className="mt-1 space-y-1">
|
||||
{blockedKeys.map((key) => (
|
||||
<div key={key} className="flex items-center justify-between gap-2">
|
||||
<span className="text-xs font-mono truncate flex-1">{key}</span>
|
||||
{onToggleBlockedKey && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onToggleBlockedKey(key)}
|
||||
className="h-7 text-xs flex-shrink-0"
|
||||
>
|
||||
Unblock
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{blockedNames.length > 0 && (
|
||||
<div>
|
||||
<span className="text-xs text-muted-foreground font-medium">Blocked Names</span>
|
||||
<div className="mt-1 space-y-1">
|
||||
{blockedNames.map((name) => (
|
||||
<div key={name} className="flex items-center justify-between gap-2">
|
||||
<span className="text-sm truncate flex-1">{name}</span>
|
||||
{onToggleBlockedName && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onToggleBlockedName(name)}
|
||||
className="h-7 text-xs flex-shrink-0"
|
||||
>
|
||||
Unblock
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bulk delete */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-semibold">Bulk Delete Contacts</h4>
|
||||
<p className="text-[0.8125rem] text-muted-foreground">
|
||||
Remove multiple contacts or repeaters at once. Useful for cleaning up spam or unwanted
|
||||
nodes. Message history will be preserved.
|
||||
</p>
|
||||
<Button variant="outline" className="w-full" onClick={() => setBulkDeleteOpen(true)}>
|
||||
Open Bulk Delete
|
||||
</Button>
|
||||
<BulkDeleteContactsModal
|
||||
open={bulkDeleteOpen}
|
||||
onClose={() => setBulkDeleteOpen(false)}
|
||||
contacts={contacts}
|
||||
onDeleted={(keys) => onBulkDeleteContacts?.(keys)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,555 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { Label } from '../ui/label';
|
||||
import { Button } from '../ui/button';
|
||||
import { Separator } from '../ui/separator';
|
||||
import { toast } from '../ui/sonner';
|
||||
import { api } from '../../api';
|
||||
import { formatTime } from '../../utils/messageParser';
|
||||
import { lppDisplayUnit } from '../repeater/repeaterPaneShared';
|
||||
import { useDistanceUnit } from '../../contexts/DistanceUnitContext';
|
||||
import { BulkDeleteContactsModal } from './BulkDeleteContactsModal';
|
||||
import type {
|
||||
AppSettings,
|
||||
AppSettingsUpdate,
|
||||
Contact,
|
||||
TelemetryHistoryEntry,
|
||||
TelemetrySchedule,
|
||||
} from '../../types';
|
||||
|
||||
export function SettingsRadioAppSection({
|
||||
appSettings,
|
||||
onSaveAppSettings,
|
||||
blockedKeys = [],
|
||||
blockedNames = [],
|
||||
onToggleBlockedKey,
|
||||
onToggleBlockedName,
|
||||
contacts = [],
|
||||
onBulkDeleteContacts,
|
||||
trackedTelemetryRepeaters = [],
|
||||
onToggleTrackedTelemetry,
|
||||
trackedTelemetryContacts = [],
|
||||
onToggleTrackedTelemetryContact,
|
||||
className,
|
||||
}: {
|
||||
appSettings: AppSettings;
|
||||
onSaveAppSettings: (update: AppSettingsUpdate) => Promise<void>;
|
||||
blockedKeys?: string[];
|
||||
blockedNames?: string[];
|
||||
onToggleBlockedKey?: (key: string) => void;
|
||||
onToggleBlockedName?: (name: string) => void;
|
||||
contacts?: Contact[];
|
||||
onBulkDeleteContacts?: (deletedKeys: string[]) => void;
|
||||
trackedTelemetryRepeaters?: string[];
|
||||
onToggleTrackedTelemetry?: (publicKey: string) => Promise<void>;
|
||||
trackedTelemetryContacts?: string[];
|
||||
onToggleTrackedTelemetryContact?: (publicKey: string) => Promise<void>;
|
||||
className?: string;
|
||||
}) {
|
||||
const { distanceUnit } = useDistanceUnit();
|
||||
const [discoveryBlockedTypes, setDiscoveryBlockedTypes] = useState<number[]>([]);
|
||||
const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false);
|
||||
|
||||
const [latestTelemetry, setLatestTelemetry] = useState<
|
||||
Record<string, TelemetryHistoryEntry | null>
|
||||
>({});
|
||||
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);
|
||||
|
||||
const saveChainRef = useRef<Promise<void>>(Promise.resolve());
|
||||
|
||||
useEffect(() => {
|
||||
setDiscoveryBlockedTypes(appSettings.discovery_blocked_types ?? []);
|
||||
setIntervalDraft(appSettings.telemetry_interval_hours);
|
||||
}, [appSettings]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
api
|
||||
.getTelemetrySchedule()
|
||||
.then((s) => {
|
||||
if (!cancelled) setSchedule(s);
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [
|
||||
trackedTelemetryRepeaters.length,
|
||||
trackedTelemetryContacts.length,
|
||||
appSettings.telemetry_interval_hours,
|
||||
appSettings.telemetry_routed_hourly,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (trackedTelemetryRepeaters.length === 0 || telemetryFetchedRef.current) return;
|
||||
telemetryFetchedRef.current = true;
|
||||
let cancelled = false;
|
||||
const fetches = trackedTelemetryRepeaters.map((key) =>
|
||||
api.repeaterTelemetryHistory(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;
|
||||
setLatestTelemetry(Object.fromEntries(entries));
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [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 persistAppSettings = (update: AppSettingsUpdate, revert: () => void): Promise<void> => {
|
||||
const chained = saveChainRef.current.then(async () => {
|
||||
try {
|
||||
await onSaveAppSettings(update);
|
||||
} catch (err) {
|
||||
console.error('Failed to save radio-app settings:', err);
|
||||
revert();
|
||||
toast.error('Failed to save setting', {
|
||||
description: err instanceof Error ? err.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
});
|
||||
saveChainRef.current = chained;
|
||||
return chained;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{/* ── Tracked Repeater Telemetry ── */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-base font-semibold tracking-tight">Tracked Repeater Telemetry</h3>
|
||||
<p className="text-[0.8125rem] text-muted-foreground">
|
||||
Repeaters opted into automatic telemetry collection are polled on a scheduled interval. To
|
||||
limit mesh traffic, the app caps telemetry at 24 checks per day across all tracked
|
||||
repeaters — so fewer tracked repeaters allows shorter intervals, and more tracked
|
||||
repeaters forces longer ones. Up to {schedule?.max_tracked ?? 8} repeaters may be tracked
|
||||
at once ({trackedTelemetryRepeaters.length} / {schedule?.max_tracked ?? 8} slots used).
|
||||
</p>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="telemetry-interval" className="text-sm">
|
||||
Collection interval
|
||||
</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
id="telemetry-interval"
|
||||
value={intervalDraft}
|
||||
onChange={(e) => {
|
||||
const nextValue = Number(e.target.value);
|
||||
if (!Number.isFinite(nextValue) || nextValue === intervalDraft) return;
|
||||
const prevValue = intervalDraft;
|
||||
setIntervalDraft(nextValue);
|
||||
void persistAppSettings({ telemetry_interval_hours: nextValue }, () =>
|
||||
setIntervalDraft(prevValue)
|
||||
);
|
||||
}}
|
||||
className="h-9 px-3 rounded-md border border-input bg-background text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
||||
>
|
||||
{(schedule?.options ?? [1, 2, 3, 4, 6, 8, 12, 24]).map((hrs) => (
|
||||
<option key={hrs} value={hrs}>
|
||||
Every {hrs} hour{hrs === 1 ? '' : 's'} ({Math.floor(24 / hrs)} check
|
||||
{Math.floor(24 / hrs) === 1 ? '' : 's'}/day)
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{schedule && schedule.effective_hours !== schedule.preferred_hours && (
|
||||
<p className="text-xs text-warning">
|
||||
Saved preference is {schedule.preferred_hours} hour
|
||||
{schedule.preferred_hours === 1 ? '' : 's'}, but the scheduler is using{' '}
|
||||
{schedule.effective_hours} hours because {schedule.tracked_count} repeater
|
||||
{schedule.tracked_count === 1 ? '' : 's'}{' '}
|
||||
{schedule.tracked_count === 1 ? 'is' : 'are'} tracked. Your preference will be
|
||||
restored if you drop back to a supported count.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<label className="flex items-start gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={appSettings.telemetry_routed_hourly}
|
||||
onChange={() => {
|
||||
const next = !appSettings.telemetry_routed_hourly;
|
||||
void persistAppSettings({ telemetry_routed_hourly: next }, () => {});
|
||||
}}
|
||||
className="w-4 h-4 rounded border-input accent-primary mt-0.5"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm">Poll direct/routed-path repeaters hourly</span>
|
||||
<p className="text-[0.8125rem] text-muted-foreground">
|
||||
When enabled, tracked repeaters with a direct or routed path (not flood) are polled
|
||||
every hour instead of on the scheduled interval above. Flood-only repeaters still
|
||||
follow the normal schedule.
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{schedule?.next_run_at != null && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{schedule.routed_hourly ? 'Next flood run at' : 'Next run at'}{' '}
|
||||
{formatTime(schedule.next_run_at)} (UTC top of hour).
|
||||
</p>
|
||||
)}
|
||||
{schedule?.next_routed_run_at != null && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Next direct/routed run at {formatTime(schedule.next_routed_run_at)} (UTC top of hour).
|
||||
</p>
|
||||
)}
|
||||
|
||||
{trackedTelemetryRepeaters.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground italic">
|
||||
No repeaters are being tracked. Enable tracking from a repeater's dashboard.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{trackedTelemetryRepeaters.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 = latestTelemetry[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>
|
||||
{onToggleTrackedTelemetry && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onToggleTrackedTelemetry(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">
|
||||
<span>{d.battery_volts?.toFixed(2)}V</span>
|
||||
<span>noise {d.noise_floor_dbm} dBm</span>
|
||||
<span>
|
||||
rx {d.packets_received != null ? d.packets_received.toLocaleString() : '?'}
|
||||
</span>
|
||||
<span>
|
||||
tx {d.packets_sent != null ? d.packets_sent.toLocaleString() : '?'}
|
||||
</span>
|
||||
{d.lpp_sensors?.map((s) => {
|
||||
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 />
|
||||
|
||||
{/* ── 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'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>
|
||||
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-semibold">Block Discovery of New Node Types</h4>
|
||||
<p className="text-[0.8125rem] text-muted-foreground">
|
||||
Checked types will be ignored when heard via advertisement. Existing contacts of these
|
||||
types are still updated. This does not affect contacts added manually or via DM.
|
||||
</p>
|
||||
<div className="space-y-1.5">
|
||||
{(
|
||||
[
|
||||
[1, 'Block clients'],
|
||||
[2, 'Block repeaters'],
|
||||
[3, 'Block room servers'],
|
||||
[4, 'Block sensors'],
|
||||
] as const
|
||||
).map(([typeCode, label]) => {
|
||||
const checked = discoveryBlockedTypes.includes(typeCode);
|
||||
return (
|
||||
<label key={typeCode} className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => {
|
||||
const prev = discoveryBlockedTypes;
|
||||
const next = checked
|
||||
? prev.filter((t) => t !== typeCode)
|
||||
: [...prev, typeCode];
|
||||
setDiscoveryBlockedTypes(next);
|
||||
void persistAppSettings({ discovery_blocked_types: next }, () =>
|
||||
setDiscoveryBlockedTypes(prev)
|
||||
);
|
||||
}}
|
||||
className="rounded border-input"
|
||||
/>
|
||||
{label}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{discoveryBlockedTypes.length > 0 && (
|
||||
<p className="text-xs text-warning">
|
||||
New{' '}
|
||||
{discoveryBlockedTypes
|
||||
.map((t) =>
|
||||
t === 1 ? 'clients' : t === 2 ? 'repeaters' : t === 3 ? 'room servers' : 'sensors'
|
||||
)
|
||||
.join(', ')}{' '}
|
||||
heard via advertisement will not be added to your contact list.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-semibold">Blocked Contacts</h4>
|
||||
<p className="text-[0.8125rem] text-muted-foreground">
|
||||
Blocked contacts are hidden from the sidebar. Blocking only hides messages from the UI —
|
||||
MQTT forwarding and bot responses are not affected. Messages are still stored and will
|
||||
reappear if unblocked.
|
||||
</p>
|
||||
|
||||
{blockedKeys.length === 0 && blockedNames.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground italic">
|
||||
No blocked contacts. Block contacts from their info pane, viewed by clicking their
|
||||
avatar in any channel, or their name within the top status bar with the conversation
|
||||
open.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{blockedKeys.length > 0 && (
|
||||
<div>
|
||||
<span className="text-xs text-muted-foreground font-medium">Blocked Keys</span>
|
||||
<div className="mt-1 space-y-1">
|
||||
{blockedKeys.map((key) => (
|
||||
<div key={key} className="flex items-center justify-between gap-2">
|
||||
<span className="text-xs font-mono truncate flex-1">{key}</span>
|
||||
{onToggleBlockedKey && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onToggleBlockedKey(key)}
|
||||
className="h-7 text-xs flex-shrink-0"
|
||||
>
|
||||
Unblock
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{blockedNames.length > 0 && (
|
||||
<div>
|
||||
<span className="text-xs text-muted-foreground font-medium">Blocked Names</span>
|
||||
<div className="mt-1 space-y-1">
|
||||
{blockedNames.map((name) => (
|
||||
<div key={name} className="flex items-center justify-between gap-2">
|
||||
<span className="text-sm truncate flex-1">{name}</span>
|
||||
{onToggleBlockedName && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onToggleBlockedName(name)}
|
||||
className="h-7 text-xs flex-shrink-0"
|
||||
>
|
||||
Unblock
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-semibold">Bulk Delete Contacts</h4>
|
||||
<p className="text-[0.8125rem] text-muted-foreground">
|
||||
Remove multiple contacts or repeaters at once. Useful for cleaning up spam or unwanted
|
||||
nodes. Message history will be preserved.
|
||||
</p>
|
||||
<Button variant="outline" className="w-full" onClick={() => setBulkDeleteOpen(true)}>
|
||||
Open Bulk Delete
|
||||
</Button>
|
||||
<BulkDeleteContactsModal
|
||||
open={bulkDeleteOpen}
|
||||
onClose={() => setBulkDeleteOpen(false)}
|
||||
contacts={contacts}
|
||||
onDeleted={(keys) => onBulkDeleteContacts?.(keys)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,16 +5,25 @@ import {
|
||||
MonitorCog,
|
||||
RadioTower,
|
||||
Share2,
|
||||
SlidersHorizontal,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
|
||||
export type SettingsSection = 'radio' | 'local' | 'database' | 'fanout' | 'statistics' | 'about';
|
||||
export type SettingsSection =
|
||||
| 'radio'
|
||||
| 'local'
|
||||
| 'radio-app'
|
||||
| 'database'
|
||||
| 'fanout'
|
||||
| 'statistics'
|
||||
| 'about';
|
||||
|
||||
export const SETTINGS_SECTION_ORDER: SettingsSection[] = [
|
||||
'radio',
|
||||
'local',
|
||||
'database',
|
||||
'fanout',
|
||||
'radio-app',
|
||||
'database',
|
||||
'statistics',
|
||||
'about',
|
||||
];
|
||||
@@ -22,7 +31,8 @@ export const SETTINGS_SECTION_ORDER: SettingsSection[] = [
|
||||
export const SETTINGS_SECTION_LABELS: Record<SettingsSection, string> = {
|
||||
radio: 'Radio',
|
||||
local: 'Local Configuration',
|
||||
database: 'Database & Messaging',
|
||||
'radio-app': 'Radio-App Management',
|
||||
database: 'Database',
|
||||
fanout: 'MQTT & Automation',
|
||||
statistics: 'Statistics',
|
||||
about: 'About',
|
||||
@@ -31,6 +41,7 @@ export const SETTINGS_SECTION_LABELS: Record<SettingsSection, string> = {
|
||||
export const SETTINGS_SECTION_ICONS: Record<SettingsSection, LucideIcon> = {
|
||||
radio: RadioTower,
|
||||
local: MonitorCog,
|
||||
'radio-app': SlidersHorizontal,
|
||||
database: Database,
|
||||
fanout: Share2,
|
||||
statistics: BarChart3,
|
||||
|
||||
@@ -149,11 +149,12 @@ vi.mock('../components/SettingsModal', () => ({
|
||||
SettingsModal: ({ desktopSection }: { desktopSection?: string }) => (
|
||||
<div data-testid="settings-modal-section">{desktopSection ?? 'none'}</div>
|
||||
),
|
||||
SETTINGS_SECTION_ORDER: ['radio', 'local', 'database', 'bot'],
|
||||
SETTINGS_SECTION_ORDER: ['radio', 'local', 'radio-app', 'database', 'bot'],
|
||||
SETTINGS_SECTION_LABELS: {
|
||||
radio: '📻 Radio',
|
||||
local: '🖥️ Local Configuration',
|
||||
database: '🗄️ Database & Messaging',
|
||||
'radio-app': '🗄️ Radio-App Management',
|
||||
database: '🗄️ Database',
|
||||
bot: '🤖 Bot',
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -92,11 +92,12 @@ vi.mock('../components/SettingsModal', () => ({
|
||||
SettingsModal: ({ desktopSection }: { desktopSection?: string }) => (
|
||||
<div data-testid="settings-modal-section">{desktopSection ?? 'none'}</div>
|
||||
),
|
||||
SETTINGS_SECTION_ORDER: ['radio', 'local', 'database', 'bot'],
|
||||
SETTINGS_SECTION_ORDER: ['radio', 'local', 'radio-app', 'database', 'bot'],
|
||||
SETTINGS_SECTION_LABELS: {
|
||||
radio: 'Radio',
|
||||
local: 'Local Configuration',
|
||||
database: 'Database & Messaging',
|
||||
'radio-app': 'Radio-App Management',
|
||||
database: 'Database',
|
||||
bot: 'Bot',
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -178,7 +178,7 @@ function setMatchMedia(matches: boolean) {
|
||||
}
|
||||
|
||||
function openRadioSection() {
|
||||
const radioToggle = screen.getByRole('button', { name: /Radio/i });
|
||||
const radioToggle = screen.getByRole('button', { name: /^Radio$/i });
|
||||
fireEvent.click(radioToggle);
|
||||
}
|
||||
|
||||
@@ -251,7 +251,7 @@ describe('SettingsModal', () => {
|
||||
it('shows radio-unavailable message when config is null', () => {
|
||||
renderModal({ config: null });
|
||||
|
||||
const radioToggle = screen.getByRole('button', { name: /Radio/i });
|
||||
const radioToggle = screen.getByRole('button', { name: /^Radio$/i });
|
||||
expect(radioToggle).not.toBeDisabled();
|
||||
|
||||
fireEvent.click(radioToggle);
|
||||
@@ -500,7 +500,7 @@ describe('SettingsModal', () => {
|
||||
|
||||
renderModal({
|
||||
externalSidebarNav: true,
|
||||
desktopSection: 'database',
|
||||
desktopSection: 'radio-app',
|
||||
onSaveAppSettings,
|
||||
});
|
||||
|
||||
@@ -807,7 +807,7 @@ describe('SettingsModal', () => {
|
||||
|
||||
renderModal({
|
||||
externalSidebarNav: true,
|
||||
desktopSection: 'database',
|
||||
desktopSection: 'radio-app',
|
||||
onSaveAppSettings,
|
||||
});
|
||||
|
||||
@@ -832,7 +832,7 @@ describe('SettingsModal', () => {
|
||||
|
||||
renderModal({
|
||||
externalSidebarNav: true,
|
||||
desktopSection: 'database',
|
||||
desktopSection: 'radio-app',
|
||||
appSettings: {
|
||||
...baseSettings,
|
||||
tracked_telemetry_repeaters: [directKey],
|
||||
|
||||
@@ -16,6 +16,7 @@ interface ParsedHashConversation {
|
||||
const SETTINGS_SECTIONS: SettingsSection[] = [
|
||||
'radio',
|
||||
'local',
|
||||
'radio-app',
|
||||
'fanout',
|
||||
'database',
|
||||
'statistics',
|
||||
|
||||
Reference in New Issue
Block a user