mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-05-07 22:05:14 +02:00
478 lines
18 KiB
TypeScript
478 lines
18 KiB
TypeScript
import { useState, useEffect, useRef } from 'react';
|
|
import { Input } from '../ui/input';
|
|
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 { BulkDeleteContactsModal } from './BulkDeleteContactsModal';
|
|
import type {
|
|
AppSettings,
|
|
AppSettingsUpdate,
|
|
Contact,
|
|
HealthStatus,
|
|
TelemetryHistoryEntry,
|
|
} from '../../types';
|
|
|
|
export function SettingsDatabaseSection({
|
|
appSettings,
|
|
health,
|
|
onSaveAppSettings,
|
|
onHealthRefresh,
|
|
blockedKeys = [],
|
|
blockedNames = [],
|
|
onToggleBlockedKey,
|
|
onToggleBlockedName,
|
|
contacts = [],
|
|
onBulkDeleteContacts,
|
|
trackedTelemetryRepeaters = [],
|
|
onToggleTrackedTelemetry,
|
|
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>;
|
|
className?: string;
|
|
}) {
|
|
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 [busy, setBusy] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const [latestTelemetry, setLatestTelemetry] = useState<
|
|
Record<string, TelemetryHistoryEntry | null>
|
|
>({});
|
|
const telemetryFetchedRef = useRef(false);
|
|
|
|
useEffect(() => {
|
|
setAutoDecryptOnAdvert(appSettings.auto_decrypt_dm_on_advert);
|
|
setDiscoveryBlockedTypes(appSettings.discovery_blocked_types ?? []);
|
|
}, [appSettings]);
|
|
|
|
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]);
|
|
|
|
const handleCleanup = async () => {
|
|
const days = parseInt(retentionDays, 10);
|
|
if (isNaN(days) || days < 1) {
|
|
toast.error('Invalid retention days', {
|
|
description: 'Retention days must be at least 1',
|
|
});
|
|
return;
|
|
}
|
|
|
|
setCleaning(true);
|
|
|
|
try {
|
|
const result = await api.runMaintenance({ pruneUndecryptedDays: days });
|
|
toast.success('Database cleanup complete', {
|
|
description: `Deleted ${result.packets_deleted} old packet${result.packets_deleted === 1 ? '' : 's'}`,
|
|
});
|
|
await onHealthRefresh();
|
|
} catch (err) {
|
|
console.error('Failed to run maintenance:', err);
|
|
toast.error('Database cleanup failed', {
|
|
description: err instanceof Error ? err.message : 'Unknown error',
|
|
});
|
|
} finally {
|
|
setCleaning(false);
|
|
}
|
|
};
|
|
|
|
const handlePurgeDecryptedRawPackets = async () => {
|
|
setPurgingDecryptedRaw(true);
|
|
|
|
try {
|
|
const result = await api.runMaintenance({ purgeLinkedRawPackets: true });
|
|
toast.success('Decrypted raw packets purged', {
|
|
description: `Deleted ${result.packets_deleted} raw packet${result.packets_deleted === 1 ? '' : 's'}`,
|
|
});
|
|
await onHealthRefresh();
|
|
} catch (err) {
|
|
console.error('Failed to purge decrypted raw packets:', err);
|
|
toast.error('Failed to purge decrypted raw packets', {
|
|
description: err instanceof Error ? err.message : 'Unknown error',
|
|
});
|
|
} finally {
|
|
setPurgingDecryptedRaw(false);
|
|
}
|
|
};
|
|
|
|
const handleSave = async () => {
|
|
setBusy(true);
|
|
setError(null);
|
|
|
|
try {
|
|
const update: AppSettingsUpdate = { auto_decrypt_dm_on_advert: autoDecryptOnAdvert };
|
|
const currentBlocked = appSettings.discovery_blocked_types ?? [];
|
|
if (
|
|
discoveryBlockedTypes.length !== currentBlocked.length ||
|
|
discoveryBlockedTypes.some((t) => !currentBlocked.includes(t))
|
|
) {
|
|
update.discovery_blocked_types = discoveryBlockedTypes;
|
|
}
|
|
await onSaveAppSettings(update);
|
|
toast.success('Database settings saved');
|
|
} catch (err) {
|
|
console.error('Failed to save database settings:', err);
|
|
setError(err instanceof Error ? err.message : 'Failed to save');
|
|
toast.error('Failed to save settings');
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className={className}>
|
|
{/* ── Database Overview ── */}
|
|
<div className="space-y-3">
|
|
<Label className="text-base">Database Overview</Label>
|
|
<div className="rounded-md border border-border bg-muted/30 p-3 space-y-2">
|
|
<div className="flex justify-between items-center">
|
|
<span className="text-sm">Database size</span>
|
|
<span className="text-sm font-semibold">{health?.database_size_mb ?? '?'} MB</span>
|
|
</div>
|
|
<div className="flex justify-between items-center">
|
|
<span className="text-sm">Oldest undecrypted packet</span>
|
|
{health?.oldest_undecrypted_timestamp ? (
|
|
<span className="text-sm font-semibold">
|
|
{formatTime(health.oldest_undecrypted_timestamp)}
|
|
<span className="font-normal text-muted-foreground ml-1">
|
|
({Math.floor((Date.now() / 1000 - health.oldest_undecrypted_timestamp) / 86400)}{' '}
|
|
days)
|
|
</span>
|
|
</span>
|
|
) : (
|
|
<span className="text-sm text-muted-foreground">None</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<Separator />
|
|
|
|
{/* ── Storage Cleanup ── */}
|
|
<div className="space-y-4">
|
|
<Label className="text-base">Storage Cleanup</Label>
|
|
|
|
<div className="rounded-md border border-border p-3 space-y-2">
|
|
<Label className="text-sm">Delete Undecrypted Packets</Label>
|
|
<p className="text-xs text-muted-foreground">
|
|
Permanently deletes stored raw packets that have not yet been decrypted. These are
|
|
retained in case you later obtain the correct key — once deleted, these messages can
|
|
never be recovered.
|
|
</p>
|
|
<div className="flex gap-2 items-end">
|
|
<div className="space-y-1">
|
|
<Label htmlFor="retention-days" className="text-xs text-muted-foreground">
|
|
Older than (days)
|
|
</Label>
|
|
<Input
|
|
id="retention-days"
|
|
type="number"
|
|
min="1"
|
|
max="365"
|
|
value={retentionDays}
|
|
onChange={(e) => setRetentionDays(e.target.value)}
|
|
className="w-24"
|
|
/>
|
|
</div>
|
|
<Button
|
|
variant="outline"
|
|
onClick={handleCleanup}
|
|
disabled={cleaning}
|
|
className="border-destructive/50 text-destructive hover:bg-destructive/10"
|
|
>
|
|
{cleaning ? 'Deleting...' : 'Delete'}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="rounded-md border border-border p-3 space-y-2">
|
|
<Label className="text-sm">Purge Archival Raw Packets</Label>
|
|
<p className="text-xs text-muted-foreground">
|
|
Deletes the raw packet bytes behind messages that are already decrypted and visible in
|
|
chat. This frees space but removes packet-analysis availability for those messages. It
|
|
does not affect displayed messages or future decryption.
|
|
</p>
|
|
<Button
|
|
variant="outline"
|
|
onClick={handlePurgeDecryptedRawPackets}
|
|
disabled={purgingDecryptedRaw}
|
|
className="w-full border-warning/50 text-warning hover:bg-warning/10"
|
|
>
|
|
{purgingDecryptedRaw ? 'Purging...' : 'Purge Archival Packets'}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<Separator />
|
|
|
|
{/* ── DM Decryption ── */}
|
|
<div className="space-y-3">
|
|
<Label className="text-base">DM Decryption</Label>
|
|
<label className="flex items-center gap-3 cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={autoDecryptOnAdvert}
|
|
onChange={(e) => setAutoDecryptOnAdvert(e.target.checked)}
|
|
className="w-4 h-4 rounded border-input accent-primary"
|
|
/>
|
|
<span className="text-sm">Auto-decrypt historical DMs when new contact advertises</span>
|
|
</label>
|
|
<p className="text-xs text-muted-foreground">
|
|
When enabled, the server will automatically try to decrypt stored DM packets when a new
|
|
contact sends an advertisement. This may cause brief delays on large packet backlogs.
|
|
</p>
|
|
</div>
|
|
|
|
<Separator />
|
|
|
|
{/* ── Tracked Repeater Telemetry ── */}
|
|
<div className="space-y-3">
|
|
<Label className="text-base">Tracked Repeater Telemetry</Label>
|
|
<p className="text-xs text-muted-foreground">
|
|
Repeaters opted into automatic telemetry collection are polled every 8 hours. Up to 8
|
|
repeaters may be tracked at a time ({trackedTelemetryRepeaters.length} / 8 slots used).
|
|
</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 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>
|
|
<span className="text-[0.625rem] text-muted-foreground font-mono">
|
|
{key.slice(0, 12)}
|
|
</span>
|
|
</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>
|
|
<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>
|
|
|
|
{error && (
|
|
<div className="text-sm text-destructive" role="alert">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
<Button onClick={handleSave} disabled={busy} className="w-full">
|
|
{busy ? 'Saving...' : 'Save Settings'}
|
|
</Button>
|
|
|
|
<Separator />
|
|
|
|
{/* ── Contact Management ── */}
|
|
<div className="space-y-2">
|
|
<Label className="text-base">Contact Management</Label>
|
|
</div>
|
|
|
|
{/* Block discovery of new node types */}
|
|
<div className="space-y-3">
|
|
<Label>Block Discovery of New Node Types</Label>
|
|
<p className="text-xs 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={() =>
|
|
setDiscoveryBlockedTypes((prev) =>
|
|
checked ? prev.filter((t) => t !== typeCode) : [...prev, typeCode]
|
|
)
|
|
}
|
|
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>
|
|
|
|
<Separator />
|
|
|
|
{/* Blocked contacts list */}
|
|
<div className="space-y-3">
|
|
<Label>Blocked Contacts</Label>
|
|
<p className="text-xs 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>
|
|
|
|
<Separator />
|
|
|
|
{/* Bulk delete */}
|
|
<div className="space-y-3">
|
|
<Label>Bulk Delete Contacts</Label>
|
|
<p className="text-xs 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>
|
|
);
|
|
}
|