mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-05-01 02:53:00 +02:00
Add bulk deletion interface
This commit is contained in:
@@ -5,6 +5,7 @@ from contextlib import suppress
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, HTTPException, Query
|
||||
from meshcore import EventType
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.dependencies import require_connected
|
||||
from app.models import (
|
||||
@@ -347,6 +348,41 @@ async def mark_contact_read(public_key: str) -> dict:
|
||||
return {"status": "ok", "public_key": contact.public_key}
|
||||
|
||||
|
||||
class BulkDeleteRequest(BaseModel):
|
||||
public_keys: list[str] = Field(description="Public keys to delete")
|
||||
|
||||
|
||||
@router.post("/bulk-delete")
|
||||
async def bulk_delete_contacts(request: BulkDeleteRequest) -> dict:
|
||||
"""Delete multiple contacts from the database (and radio if present)."""
|
||||
from app.websocket import broadcast_event
|
||||
|
||||
deleted = 0
|
||||
for key in request.public_keys:
|
||||
normalized = key.lower()
|
||||
contact = await ContactRepository.get_by_key(normalized)
|
||||
if not contact:
|
||||
continue
|
||||
|
||||
if radio_manager.is_connected:
|
||||
try:
|
||||
async with radio_manager.radio_operation(
|
||||
"bulk_delete_contact_from_radio", blocking=False
|
||||
) as mc:
|
||||
radio_contact = mc.get_contact_by_key_prefix(contact.public_key[:12])
|
||||
if radio_contact:
|
||||
await mc.commands.remove_contact(radio_contact)
|
||||
except Exception:
|
||||
pass # Best-effort radio removal during bulk delete
|
||||
|
||||
await ContactRepository.delete(contact.public_key)
|
||||
broadcast_event("contact_deleted", {"public_key": contact.public_key})
|
||||
deleted += 1
|
||||
|
||||
logger.info("Bulk deleted %d/%d contacts", deleted, len(request.public_keys))
|
||||
return {"deleted": deleted}
|
||||
|
||||
|
||||
@router.delete("/{public_key}")
|
||||
async def delete_contact(public_key: str) -> dict:
|
||||
"""Delete a contact from the database (and radio if present)."""
|
||||
|
||||
@@ -557,6 +557,7 @@ export function App() {
|
||||
blockedNames: appSettings?.blocked_names,
|
||||
onToggleBlockedKey: handleBlockKey,
|
||||
onToggleBlockedName: handleBlockName,
|
||||
contacts,
|
||||
};
|
||||
const crackerProps = {
|
||||
packets: rawPackets,
|
||||
|
||||
@@ -149,6 +149,12 @@ export const api = {
|
||||
fetchJson<{ status: string }>(`/contacts/${publicKey}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
bulkDeleteContacts: (publicKeys: string[]) =>
|
||||
fetchJson<{ deleted: number }>('/contacts/bulk-delete', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ public_keys: publicKeys }),
|
||||
}),
|
||||
createContact: (publicKey: string, name?: string, tryHistorical?: boolean) =>
|
||||
fetchJson<Contact>('/contacts', {
|
||||
method: 'POST',
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useEffect, type ReactNode } from 'react';
|
||||
import type {
|
||||
AppSettings,
|
||||
AppSettingsUpdate,
|
||||
Contact,
|
||||
HealthStatus,
|
||||
RadioAdvertMode,
|
||||
RadioConfig,
|
||||
@@ -47,6 +48,7 @@ interface SettingsModalBaseProps {
|
||||
blockedNames?: string[];
|
||||
onToggleBlockedKey?: (key: string) => void;
|
||||
onToggleBlockedName?: (name: string) => void;
|
||||
contacts?: Contact[];
|
||||
}
|
||||
|
||||
export type SettingsModalProps = SettingsModalBaseProps &
|
||||
@@ -80,6 +82,7 @@ export function SettingsModal(props: SettingsModalProps) {
|
||||
blockedNames,
|
||||
onToggleBlockedKey,
|
||||
onToggleBlockedName,
|
||||
contacts,
|
||||
} = props;
|
||||
const externalSidebarNav = props.externalSidebarNav === true;
|
||||
const desktopSection = props.externalSidebarNav ? props.desktopSection : undefined;
|
||||
@@ -239,6 +242,7 @@ export function SettingsModal(props: SettingsModalProps) {
|
||||
blockedNames={blockedNames}
|
||||
onToggleBlockedKey={onToggleBlockedKey}
|
||||
onToggleBlockedName={onToggleBlockedName}
|
||||
contacts={contacts}
|
||||
className={sectionContentClass}
|
||||
/>
|
||||
) : (
|
||||
|
||||
341
frontend/src/components/settings/BulkDeleteContactsModal.tsx
Normal file
341
frontend/src/components/settings/BulkDeleteContactsModal.tsx
Normal file
@@ -0,0 +1,341 @@
|
||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { api } from '../../api';
|
||||
import { getContactDisplayName } from '../../utils/pubkey';
|
||||
import { Button } from '../ui/button';
|
||||
import { Input } from '../ui/input';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '../ui/dialog';
|
||||
import { toast } from '../ui/sonner';
|
||||
import type { Contact } from '../../types';
|
||||
|
||||
const CONTACT_TYPE_LABELS: Record<number, string> = {
|
||||
0: 'Unknown',
|
||||
1: 'Client',
|
||||
2: 'Repeater',
|
||||
3: 'Room',
|
||||
4: 'Sensor',
|
||||
};
|
||||
|
||||
function formatDate(ts: number): string {
|
||||
return new Date(ts * 1000).toLocaleDateString([], {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function formatDateISO(ts: number): string {
|
||||
return new Date(ts * 1000).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function datetimeToUnix(datetimeStr: string): number {
|
||||
const d = new Date(datetimeStr);
|
||||
return Math.floor(d.getTime() / 1000);
|
||||
}
|
||||
|
||||
interface BulkDeleteContactsModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
contacts: Contact[];
|
||||
onDeleted: () => void;
|
||||
}
|
||||
|
||||
export function BulkDeleteContactsModal({
|
||||
open,
|
||||
onClose,
|
||||
contacts,
|
||||
onDeleted,
|
||||
}: BulkDeleteContactsModalProps) {
|
||||
const [step, setStep] = useState<'select' | 'confirm'>('select');
|
||||
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set());
|
||||
const [startDate, setStartDate] = useState('');
|
||||
const [endDate, setEndDate] = useState('');
|
||||
const [typeFilter, setTypeFilter] = useState<number | 'all'>('all');
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const lastClickedKeyRef = useRef<string | null>(null);
|
||||
|
||||
const resetAndClose = useCallback(() => {
|
||||
setStep('select');
|
||||
setSelectedKeys(new Set());
|
||||
setStartDate('');
|
||||
setEndDate('');
|
||||
setTypeFilter('all');
|
||||
lastClickedKeyRef.current = null;
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
const filteredContacts = useMemo(() => {
|
||||
let list = [...contacts].sort((a, b) => (b.first_seen ?? 0) - (a.first_seen ?? 0));
|
||||
if (typeFilter !== 'all') {
|
||||
list = list.filter((c) => c.type === typeFilter);
|
||||
}
|
||||
if (startDate) {
|
||||
const start = datetimeToUnix(startDate);
|
||||
list = list.filter((c) => (c.first_seen ?? 0) >= start);
|
||||
}
|
||||
if (endDate) {
|
||||
const end = datetimeToUnix(endDate);
|
||||
list = list.filter((c) => (c.first_seen ?? 0) <= end);
|
||||
}
|
||||
return list;
|
||||
}, [contacts, typeFilter, startDate, endDate]);
|
||||
|
||||
const handleToggle = (key: string, shiftKey: boolean) => {
|
||||
if (shiftKey && lastClickedKeyRef.current && lastClickedKeyRef.current !== key) {
|
||||
const keys = filteredContacts.map((c) => c.public_key);
|
||||
const lastIdx = keys.indexOf(lastClickedKeyRef.current);
|
||||
const curIdx = keys.indexOf(key);
|
||||
if (lastIdx >= 0 && curIdx >= 0) {
|
||||
const from = Math.min(lastIdx, curIdx);
|
||||
const to = Math.max(lastIdx, curIdx);
|
||||
const rangeKeys = keys.slice(from, to + 1);
|
||||
setSelectedKeys((prev) => {
|
||||
const next = new Set(prev);
|
||||
for (const k of rangeKeys) next.add(k);
|
||||
return next;
|
||||
});
|
||||
lastClickedKeyRef.current = key;
|
||||
return;
|
||||
}
|
||||
}
|
||||
setSelectedKeys((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
return next;
|
||||
});
|
||||
lastClickedKeyRef.current = key;
|
||||
};
|
||||
|
||||
const handleSelectAll = () => {
|
||||
setSelectedKeys(new Set(filteredContacts.map((c) => c.public_key)));
|
||||
};
|
||||
|
||||
const handleSelectNone = () => {
|
||||
setSelectedKeys(new Set());
|
||||
};
|
||||
|
||||
const selectedContacts = useMemo(
|
||||
() => contacts.filter((c) => selectedKeys.has(c.public_key)),
|
||||
[contacts, selectedKeys]
|
||||
);
|
||||
|
||||
const contactCount = selectedContacts.filter((c) => c.type !== 2).length;
|
||||
const repeaterCount = selectedContacts.filter((c) => c.type === 2).length;
|
||||
|
||||
const firstSeenDates = selectedContacts.map((c) => c.first_seen ?? 0).filter((t) => t > 0);
|
||||
const minDate =
|
||||
firstSeenDates.length > 0 ? formatDateISO(Math.min(...firstSeenDates)) : 'unknown';
|
||||
const maxDate =
|
||||
firstSeenDates.length > 0 ? formatDateISO(Math.max(...firstSeenDates)) : 'unknown';
|
||||
|
||||
const handleDelete = async () => {
|
||||
setDeleting(true);
|
||||
try {
|
||||
const result = await api.bulkDeleteContacts([...selectedKeys]);
|
||||
toast.success(`Deleted ${result.deleted} contact${result.deleted === 1 ? '' : 's'}`);
|
||||
onDeleted();
|
||||
resetAndClose();
|
||||
} catch (err) {
|
||||
console.error('Bulk delete failed:', err);
|
||||
toast.error('Bulk delete failed', {
|
||||
description: err instanceof Error ? err.message : undefined,
|
||||
});
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && resetAndClose()}>
|
||||
<DialogContent className="sm:max-w-2xl max-h-[85dvh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{step === 'select' ? 'Bulk Delete Contacts' : 'Confirm Deletion'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{step === 'select'
|
||||
? 'Select contacts to delete. Message history will be preserved.'
|
||||
: 'Review the contacts that will be permanently deleted.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{step === 'select' && (
|
||||
<>
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs text-muted-foreground">Show</label>
|
||||
<select
|
||||
value={typeFilter === 'all' ? 'all' : String(typeFilter)}
|
||||
onChange={(e) =>
|
||||
setTypeFilter(e.target.value === 'all' ? 'all' : Number(e.target.value))
|
||||
}
|
||||
className="h-8 rounded-md border border-input bg-background px-2 text-sm"
|
||||
>
|
||||
<option value="all">All</option>
|
||||
<option value="1">Clients</option>
|
||||
<option value="2">Repeaters</option>
|
||||
<option value="3">Room Servers</option>
|
||||
<option value="4">Sensors</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs text-muted-foreground">Created after</label>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
className="w-48 h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs text-muted-foreground">Created before</label>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={endDate}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
className="w-48 h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<Button type="button" variant="outline" size="sm" onClick={handleSelectAll}>
|
||||
Select all
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="sm" onClick={handleSelectNone}>
|
||||
Select none
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{filteredContacts.length} contact{filteredContacts.length === 1 ? '' : 's'} shown
|
||||
{(startDate || endDate) && ' (filtered)'}
|
||||
{' · '}
|
||||
{selectedKeys.size} selected
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto min-h-0 border border-border rounded-md">
|
||||
{filteredContacts.length === 0 ? (
|
||||
<div className="p-4 text-center text-sm text-muted-foreground">
|
||||
No contacts match the selected date range.
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead className="sticky top-0 bg-muted/90 backdrop-blur-sm">
|
||||
<tr className="text-left text-xs text-muted-foreground">
|
||||
<th className="px-3 py-1.5 w-8" />
|
||||
<th className="px-3 py-1.5">Name</th>
|
||||
<th className="px-3 py-1.5 hidden sm:table-cell">Type</th>
|
||||
<th className="px-3 py-1.5">Key</th>
|
||||
<th className="px-3 py-1.5 hidden sm:table-cell">Created</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredContacts.map((c) => (
|
||||
<tr
|
||||
key={c.public_key}
|
||||
className="border-t border-border hover:bg-accent/50 cursor-pointer"
|
||||
onClick={(e) => handleToggle(c.public_key, e.shiftKey)}
|
||||
>
|
||||
<td className="px-3 py-1.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedKeys.has(c.public_key)}
|
||||
onChange={(e) =>
|
||||
handleToggle(
|
||||
c.public_key,
|
||||
e.nativeEvent instanceof MouseEvent && e.nativeEvent.shiftKey
|
||||
)
|
||||
}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="rounded border-input"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-1.5 truncate max-w-[10rem]">
|
||||
{getContactDisplayName(c.name, c.public_key, c.last_advert)}
|
||||
</td>
|
||||
<td className="px-3 py-1.5 hidden sm:table-cell text-xs text-muted-foreground">
|
||||
{CONTACT_TYPE_LABELS[c.type] ?? 'Unknown'}
|
||||
</td>
|
||||
<td className="px-3 py-1.5 font-mono text-xs text-muted-foreground truncate max-w-[8rem]">
|
||||
{c.public_key.slice(0, 12)}
|
||||
</td>
|
||||
<td className="px-3 py-1.5 hidden sm:table-cell text-xs text-muted-foreground">
|
||||
{c.first_seen ? formatDate(c.first_seen) : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button variant="secondary" onClick={resetAndClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-warning text-warning hover:bg-warning/10 hover:text-warning"
|
||||
disabled={selectedKeys.size === 0}
|
||||
onClick={() => setStep('confirm')}
|
||||
>
|
||||
Proceed to confirmation ({selectedKeys.size})
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === 'confirm' && (
|
||||
<>
|
||||
<div className="flex-1 overflow-y-auto min-h-0 border border-border rounded-md">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="sticky top-0 bg-muted/90 backdrop-blur-sm">
|
||||
<tr className="text-left text-xs text-muted-foreground">
|
||||
<th className="px-3 py-1.5">Name</th>
|
||||
<th className="px-3 py-1.5">Type</th>
|
||||
<th className="px-3 py-1.5">Key</th>
|
||||
<th className="px-3 py-1.5 hidden sm:table-cell">Created</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{selectedContacts.map((c) => (
|
||||
<tr key={c.public_key} className="border-t border-border">
|
||||
<td className="px-3 py-1.5 truncate max-w-[12rem]">
|
||||
{getContactDisplayName(c.name, c.public_key, c.last_advert)}
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-xs text-muted-foreground">
|
||||
{CONTACT_TYPE_LABELS[c.type] ?? 'Unknown'}
|
||||
</td>
|
||||
<td className="px-3 py-1.5 font-mono text-xs text-muted-foreground truncate max-w-[8rem]">
|
||||
{c.public_key.slice(0, 12)}
|
||||
</td>
|
||||
<td className="px-3 py-1.5 hidden sm:table-cell text-xs text-muted-foreground">
|
||||
{c.first_seen ? formatDate(c.first_seen) : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 pt-2">
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="w-full h-auto py-3 text-wrap"
|
||||
disabled={deleting}
|
||||
onClick={handleDelete}
|
||||
>
|
||||
{deleting
|
||||
? 'Deleting...'
|
||||
: `I confirm deletion of all listed contacts above, totalling ${contactCount} contact${contactCount === 1 ? '' : 's'} & ${repeaterCount} repeater${repeaterCount === 1 ? '' : 's'}, spanning creation dates from ${minDate} to ${maxDate}`}
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => setStep('select')} disabled={deleting}>
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,8 @@ import { Separator } from '../ui/separator';
|
||||
import { toast } from '../ui/sonner';
|
||||
import { api } from '../../api';
|
||||
import { formatTime } from '../../utils/messageParser';
|
||||
import type { AppSettings, AppSettingsUpdate, HealthStatus } from '../../types';
|
||||
import { BulkDeleteContactsModal } from './BulkDeleteContactsModal';
|
||||
import type { AppSettings, AppSettingsUpdate, Contact, HealthStatus } from '../../types';
|
||||
|
||||
export function SettingsDatabaseSection({
|
||||
appSettings,
|
||||
@@ -17,6 +18,7 @@ export function SettingsDatabaseSection({
|
||||
blockedNames = [],
|
||||
onToggleBlockedKey,
|
||||
onToggleBlockedName,
|
||||
contacts = [],
|
||||
className,
|
||||
}: {
|
||||
appSettings: AppSettings;
|
||||
@@ -27,6 +29,7 @@ export function SettingsDatabaseSection({
|
||||
blockedNames?: string[];
|
||||
onToggleBlockedKey?: (key: string) => void;
|
||||
onToggleBlockedName?: (name: string) => void;
|
||||
contacts?: Contact[];
|
||||
className?: string;
|
||||
}) {
|
||||
const [retentionDays, setRetentionDays] = useState('14');
|
||||
@@ -34,6 +37,7 @@ export function SettingsDatabaseSection({
|
||||
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);
|
||||
@@ -280,6 +284,25 @@ export function SettingsDatabaseSection({
|
||||
|
||||
<Separator />
|
||||
|
||||
<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={() => {}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label>Block Discovery of New Node Types</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
|
||||
Reference in New Issue
Block a user