feat: implement bulk selection and deletion for modules and entries. Add functionality for selecting multiple modules, AS entries, CDN sources, domain entries, and IP ranges, along with a confirmation dialog for bulk deletion. Enhance UI to reflect selected counts and improve user experience in managing entries.
CI / changes (push) Successful in 5s
CI / openapi (push) Has been skipped
CI / go (push) Has been skipped
CI / docker-web (deploy/docker/evobgp-web/Dockerfile, , evobgp-web) (push) Successful in 1m2s
CI / docker-web (deploy/docker/evobgp-web/Dockerfile, evobgp-all, evobgp-web-all) (push) Successful in 1m4s
CI / docker-bird (push) Has been skipped
CI / bird2 (push) Has been skipped
CI / docker-go-prime (push) Has been skipped
CI / docker-go (deploy/docker/evobgp-agent/Dockerfile, , evobgp-agent) (push) Has been skipped
CI / docker-go (evobgp-all, 1, deploy/docker/gobinary/Dockerfile, , evobgp-all) (push) Has been skipped
CI / docker-go (evobgp-api, 1, deploy/docker/gobinary/Dockerfile, , evobgp-api) (push) Has been skipped
CI / docker-go (evobgp-deploy, 0, deploy/docker/gobinary/Dockerfile, , evobgp-deploy) (push) Has been skipped
CI / docker-go (evobgp-ingest, 0, deploy/docker/gobinary/Dockerfile, , evobgp-ingest) (push) Has been skipped
CI / docker-go (evobgp-node, 0, deploy/docker/gobinary/Dockerfile, , evobgp-node) (push) Has been skipped
CI / docker-go (evobgp-render, 0, deploy/docker/gobinary/Dockerfile, , evobgp-render) (push) Has been skipped
CI / docker-go (evobgp-scheduler, 0, deploy/docker/gobinary/Dockerfile, , evobgp-scheduler) (push) Has been skipped
CI / changes (push) Successful in 5s
CI / openapi (push) Has been skipped
CI / go (push) Has been skipped
CI / docker-web (deploy/docker/evobgp-web/Dockerfile, , evobgp-web) (push) Successful in 1m2s
CI / docker-web (deploy/docker/evobgp-web/Dockerfile, evobgp-all, evobgp-web-all) (push) Successful in 1m4s
CI / docker-bird (push) Has been skipped
CI / bird2 (push) Has been skipped
CI / docker-go-prime (push) Has been skipped
CI / docker-go (deploy/docker/evobgp-agent/Dockerfile, , evobgp-agent) (push) Has been skipped
CI / docker-go (evobgp-all, 1, deploy/docker/gobinary/Dockerfile, , evobgp-all) (push) Has been skipped
CI / docker-go (evobgp-api, 1, deploy/docker/gobinary/Dockerfile, , evobgp-api) (push) Has been skipped
CI / docker-go (evobgp-deploy, 0, deploy/docker/gobinary/Dockerfile, , evobgp-deploy) (push) Has been skipped
CI / docker-go (evobgp-ingest, 0, deploy/docker/gobinary/Dockerfile, , evobgp-ingest) (push) Has been skipped
CI / docker-go (evobgp-node, 0, deploy/docker/gobinary/Dockerfile, , evobgp-node) (push) Has been skipped
CI / docker-go (evobgp-render, 0, deploy/docker/gobinary/Dockerfile, , evobgp-render) (push) Has been skipped
CI / docker-go (evobgp-scheduler, 0, deploy/docker/gobinary/Dockerfile, , evobgp-scheduler) (push) Has been skipped
This commit is contained in:
@@ -22,6 +22,16 @@
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
} from '$lib/components/ui/select/index.js';
|
} from '$lib/components/ui/select/index.js';
|
||||||
import { Switch } from '$lib/components/ui/switch/index.js';
|
import { Switch } from '$lib/components/ui/switch/index.js';
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle
|
||||||
|
} from '$lib/components/ui/alert-dialog/index.js';
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -35,11 +45,19 @@
|
|||||||
import Plus from '@lucide/svelte/icons/plus';
|
import Plus from '@lucide/svelte/icons/plus';
|
||||||
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
|
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
|
||||||
import ExternalLink from '@lucide/svelte/icons/external-link';
|
import ExternalLink from '@lucide/svelte/icons/external-link';
|
||||||
|
import Trash2 from '@lucide/svelte/icons/trash-2';
|
||||||
|
|
||||||
let rows = $state<ModuleRow[]>([]);
|
let rows = $state<ModuleRow[]>([]);
|
||||||
let loading = $state(false);
|
let loading = $state(false);
|
||||||
let dialogOpen = $state(false);
|
let dialogOpen = $state(false);
|
||||||
let saving = $state(false);
|
let saving = $state(false);
|
||||||
|
let selectedModuleIds = $state(new Set<string>());
|
||||||
|
let deletingBulkModules = $state(false);
|
||||||
|
let bulkDeleteModulesDialog = $state(false);
|
||||||
|
|
||||||
|
const selectedModulesCount = $derived(selectedModuleIds.size);
|
||||||
|
const allModulesSelected = $derived(rows.length > 0 && selectedModuleIds.size === rows.length);
|
||||||
|
const someModulesSelected = $derived(selectedModuleIds.size > 0 && !allModulesSelected);
|
||||||
|
|
||||||
let form = $state<ModuleCreate>({
|
let form = $state<ModuleCreate>({
|
||||||
type: 'AS_PREFIXES',
|
type: 'AS_PREFIXES',
|
||||||
@@ -60,6 +78,8 @@
|
|||||||
try {
|
try {
|
||||||
const m = await apiJSON<ModulesResponse>('/v1/modules?limit=200');
|
const m = await apiJSON<ModulesResponse>('/v1/modules?limit=200');
|
||||||
rows = m.items ?? [];
|
rows = m.items ?? [];
|
||||||
|
const validIds = new Set(rows.map((item) => item.id));
|
||||||
|
selectedModuleIds = new Set([...selectedModuleIds].filter((id) => validIds.has(id)));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(e instanceof Error ? e.message : String(e));
|
toast.error(e instanceof Error ? e.message : String(e));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -97,15 +117,49 @@
|
|||||||
default: return 'outline';
|
default: return 'outline';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleModuleSelection(id: string) {
|
||||||
|
const next = new Set(selectedModuleIds);
|
||||||
|
if (next.has(id)) next.delete(id);
|
||||||
|
else next.add(id);
|
||||||
|
selectedModuleIds = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleAllModules(checked: boolean) {
|
||||||
|
selectedModuleIds = checked ? new Set(rows.map((item) => item.id)) : new Set<string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bulkDeleteModules() {
|
||||||
|
if (selectedModuleIds.size === 0) return;
|
||||||
|
deletingBulkModules = true;
|
||||||
|
let deleted = 0;
|
||||||
|
try {
|
||||||
|
for (const id of selectedModuleIds) {
|
||||||
|
try {
|
||||||
|
await apiMutate(`/v1/modules/${id}`, 'DELETE', undefined, { idempotent: false });
|
||||||
|
deleted += 1;
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : String(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (deleted > 0) {
|
||||||
|
toast.success(`Удалено модулей: ${deleted}`);
|
||||||
|
}
|
||||||
|
bulkDeleteModulesDialog = false;
|
||||||
|
await load();
|
||||||
|
} finally {
|
||||||
|
deletingBulkModules = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<div class="flex items-start justify-between">
|
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl font-semibold tracking-tight">Модули префиксов</h1>
|
<h1 class="text-2xl font-semibold tracking-tight">Модули префиксов</h1>
|
||||||
<p class="text-muted-foreground mt-1 text-sm">Управление модулями — AS, CDN, домены, IP-диапазоны.</p>
|
<p class="text-muted-foreground mt-1 text-sm">Управление модулями — AS, CDN, домены, IP-диапазоны.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
<Button variant="outline" size="sm" onclick={load} disabled={loading}>
|
<Button variant="outline" size="sm" onclick={load} disabled={loading}>
|
||||||
<RefreshCw class={loading ? 'animate-spin' : ''} />
|
<RefreshCw class={loading ? 'animate-spin' : ''} />
|
||||||
Обновить
|
Обновить
|
||||||
@@ -118,10 +172,36 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
|
<CardHeader class="flex flex-wrap items-center justify-between gap-2 border-b py-3">
|
||||||
|
<CardTitle class="text-base">Список модулей</CardTitle>
|
||||||
|
{#if selectedModulesCount > 0}
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<span class="text-muted-foreground text-sm">Выбрано: {selectedModulesCount}</span>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
disabled={deletingBulkModules}
|
||||||
|
onclick={() => (bulkDeleteModulesDialog = true)}
|
||||||
|
>
|
||||||
|
<Trash2 class="size-4" />
|
||||||
|
Удалить выбранные
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</CardHeader>
|
||||||
<CardContent class="p-0">
|
<CardContent class="p-0">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
|
<TableHead class="w-10">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={allModulesSelected}
|
||||||
|
indeterminate={someModulesSelected}
|
||||||
|
aria-label="Выбрать все модули"
|
||||||
|
onchange={(event) => toggleAllModules((event.currentTarget as HTMLInputElement).checked)}
|
||||||
|
/>
|
||||||
|
</TableHead>
|
||||||
<TableHead>Название</TableHead>
|
<TableHead>Название</TableHead>
|
||||||
<TableHead>Тип</TableHead>
|
<TableHead>Тип</TableHead>
|
||||||
<TableHead>Приоритет</TableHead>
|
<TableHead>Приоритет</TableHead>
|
||||||
@@ -133,6 +213,14 @@
|
|||||||
<TableBody>
|
<TableBody>
|
||||||
{#each rows as m (m.id)}
|
{#each rows as m (m.id)}
|
||||||
<TableRow>
|
<TableRow>
|
||||||
|
<TableCell class="w-10">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedModuleIds.has(m.id)}
|
||||||
|
aria-label={`Выбрать модуль ${m.name}`}
|
||||||
|
onchange={() => toggleModuleSelection(m.id)}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
<TableCell class="font-medium">{m.name}</TableCell>
|
<TableCell class="font-medium">{m.name}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge variant={typeBadgeVariant(m.type)}>{m.type}</Badge>
|
<Badge variant={typeBadgeVariant(m.type)}>{m.type}</Badge>
|
||||||
@@ -156,7 +244,7 @@
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
{:else}
|
{:else}
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colspan={6} class="text-muted-foreground text-center py-8">
|
<TableCell colspan={7} class="text-muted-foreground text-center py-8">
|
||||||
{loading ? 'Загрузка…' : 'Нет модулей. Создайте первый.'}
|
{loading ? 'Загрузка…' : 'Нет модулей. Создайте первый.'}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -227,3 +315,20 @@
|
|||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
<AlertDialog bind:open={bulkDeleteModulesDialog}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Удалить выбранные модули?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Будет удалено: {selectedModulesCount}. Это действие необратимо.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Отмена</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onclick={bulkDeleteModules} disabled={deletingBulkModules}>
|
||||||
|
{deletingBulkModules ? 'Удаление…' : 'Удалить'}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
|||||||
@@ -89,6 +89,9 @@
|
|||||||
let asForm = $state<AsEntryCreate>({ asn: 0, community_id: null });
|
let asForm = $state<AsEntryCreate>({ asn: 0, community_id: null });
|
||||||
let asSaving = $state(false);
|
let asSaving = $state(false);
|
||||||
let asDeleteTarget = $state<AsEntry | null>(null);
|
let asDeleteTarget = $state<AsEntry | null>(null);
|
||||||
|
let selectedAsIds = $state(new Set<string>());
|
||||||
|
let asBulkDeleteDialog = $state(false);
|
||||||
|
let deletingAsBulk = $state(false);
|
||||||
|
|
||||||
// CDN sources
|
// CDN sources
|
||||||
let cdnSources = $state<CdnSource[]>([]);
|
let cdnSources = $state<CdnSource[]>([]);
|
||||||
@@ -108,6 +111,9 @@
|
|||||||
let cdnPreviewTruncated = $state(false);
|
let cdnPreviewTruncated = $state(false);
|
||||||
let cdnPreviewError = $state<string | null>(null);
|
let cdnPreviewError = $state<string | null>(null);
|
||||||
let cdnPreviewOk = $state(false);
|
let cdnPreviewOk = $state(false);
|
||||||
|
let selectedCdnIds = $state(new Set<string>());
|
||||||
|
let cdnBulkDeleteDialog = $state(false);
|
||||||
|
let deletingCdnBulk = $state(false);
|
||||||
|
|
||||||
// Domain entries
|
// Domain entries
|
||||||
let domainEntries = $state<DomainEntry[]>([]);
|
let domainEntries = $state<DomainEntry[]>([]);
|
||||||
@@ -116,6 +122,9 @@
|
|||||||
let domainForm = $state<DomainEntryCreate>({ fqdn: '', community_id: null });
|
let domainForm = $state<DomainEntryCreate>({ fqdn: '', community_id: null });
|
||||||
let domainSaving = $state(false);
|
let domainSaving = $state(false);
|
||||||
let domainDeleteTarget = $state<DomainEntry | null>(null);
|
let domainDeleteTarget = $state<DomainEntry | null>(null);
|
||||||
|
let selectedDomainIds = $state(new Set<string>());
|
||||||
|
let domainBulkDeleteDialog = $state(false);
|
||||||
|
let deletingDomainBulk = $state(false);
|
||||||
|
|
||||||
// IP Range entries
|
// IP Range entries
|
||||||
let ipEntries = $state<IpRangeEntry[]>([]);
|
let ipEntries = $state<IpRangeEntry[]>([]);
|
||||||
@@ -124,6 +133,9 @@
|
|||||||
let ipForm = $state<IpRangeEntryCreate>({ prefix: '', community_id: '' });
|
let ipForm = $state<IpRangeEntryCreate>({ prefix: '', community_id: '' });
|
||||||
let ipSaving = $state(false);
|
let ipSaving = $state(false);
|
||||||
let ipDeleteTarget = $state<IpRangeEntry | null>(null);
|
let ipDeleteTarget = $state<IpRangeEntry | null>(null);
|
||||||
|
let selectedIpIds = $state(new Set<string>());
|
||||||
|
let ipBulkDeleteDialog = $state(false);
|
||||||
|
let deletingIpBulk = $state(false);
|
||||||
|
|
||||||
// Module edit
|
// Module edit
|
||||||
let editDialog = $state(false);
|
let editDialog = $state(false);
|
||||||
@@ -137,6 +149,23 @@
|
|||||||
let csvImporting = $state(false);
|
let csvImporting = $state(false);
|
||||||
let csvExporting = $state(false);
|
let csvExporting = $state(false);
|
||||||
let csvFileInput = $state<HTMLInputElement | null>(null);
|
let csvFileInput = $state<HTMLInputElement | null>(null);
|
||||||
|
const selectedAsCount = $derived(selectedAsIds.size);
|
||||||
|
const selectedCdnCount = $derived(selectedCdnIds.size);
|
||||||
|
const selectedDomainCount = $derived(selectedDomainIds.size);
|
||||||
|
const selectedIpCount = $derived(selectedIpIds.size);
|
||||||
|
const allAsSelected = $derived(asEntries.length > 0 && selectedAsIds.size === asEntries.length);
|
||||||
|
const allCdnSelected = $derived(cdnSources.length > 0 && selectedCdnIds.size === cdnSources.length);
|
||||||
|
const allDomainSelected = $derived(domainEntries.length > 0 && selectedDomainIds.size === domainEntries.length);
|
||||||
|
const allIpSelected = $derived(ipEntries.length > 0 && selectedIpIds.size === ipEntries.length);
|
||||||
|
const someAsSelected = $derived(selectedAsIds.size > 0 && !allAsSelected);
|
||||||
|
const someCdnSelected = $derived(selectedCdnIds.size > 0 && !allCdnSelected);
|
||||||
|
const someDomainSelected = $derived(selectedDomainIds.size > 0 && !allDomainSelected);
|
||||||
|
const someIpSelected = $derived(selectedIpIds.size > 0 && !allIpSelected);
|
||||||
|
|
||||||
|
function syncSelection(selected: Set<string>, existingIds: string[]): Set<string> {
|
||||||
|
const validIds = new Set(existingIds);
|
||||||
|
return new Set([...selected].filter((id) => validIds.has(id)));
|
||||||
|
}
|
||||||
|
|
||||||
function supportsCsvIO(type: ModuleRow['type'] | null | undefined): boolean {
|
function supportsCsvIO(type: ModuleRow['type'] | null | undefined): boolean {
|
||||||
return type === 'AS_PREFIXES' || type === 'DOMAINS' || type === 'IP_RANGES';
|
return type === 'AS_PREFIXES' || type === 'DOMAINS' || type === 'IP_RANGES';
|
||||||
@@ -183,15 +212,19 @@
|
|||||||
if (type === 'AS_PREFIXES') {
|
if (type === 'AS_PREFIXES') {
|
||||||
const r = await apiJSON<AsEntriesResponse>(`/v1/modules/${moduleId}/as-entries?limit=500`);
|
const r = await apiJSON<AsEntriesResponse>(`/v1/modules/${moduleId}/as-entries?limit=500`);
|
||||||
asEntries = r.items;
|
asEntries = r.items;
|
||||||
|
selectedAsIds = syncSelection(selectedAsIds, asEntries.map((entry) => entry.id));
|
||||||
} else if (type === 'CDN_CIDRS') {
|
} else if (type === 'CDN_CIDRS') {
|
||||||
const r = await apiJSON<CdnSourcesResponse>(`/v1/modules/${moduleId}/cdn-sources?limit=200`);
|
const r = await apiJSON<CdnSourcesResponse>(`/v1/modules/${moduleId}/cdn-sources?limit=200`);
|
||||||
cdnSources = r.items;
|
cdnSources = r.items;
|
||||||
|
selectedCdnIds = syncSelection(selectedCdnIds, cdnSources.map((entry) => entry.id));
|
||||||
} else if (type === 'DOMAINS') {
|
} else if (type === 'DOMAINS') {
|
||||||
const r = await apiJSON<DomainEntriesResponse>(`/v1/modules/${moduleId}/domain-entries?limit=500`);
|
const r = await apiJSON<DomainEntriesResponse>(`/v1/modules/${moduleId}/domain-entries?limit=500`);
|
||||||
domainEntries = r.items;
|
domainEntries = r.items;
|
||||||
|
selectedDomainIds = syncSelection(selectedDomainIds, domainEntries.map((entry) => entry.id));
|
||||||
} else if (type === 'IP_RANGES') {
|
} else if (type === 'IP_RANGES') {
|
||||||
const r = await apiJSON<IpRangeEntriesResponse>(`/v1/modules/${moduleId}/ip-range-entries?limit=500`);
|
const r = await apiJSON<IpRangeEntriesResponse>(`/v1/modules/${moduleId}/ip-range-entries?limit=500`);
|
||||||
ipEntries = r.items;
|
ipEntries = r.items;
|
||||||
|
selectedIpIds = syncSelection(selectedIpIds, ipEntries.map((entry) => entry.id));
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(e instanceof Error ? e.message : String(e));
|
toast.error(e instanceof Error ? e.message : String(e));
|
||||||
@@ -376,6 +409,35 @@
|
|||||||
toast.error(e instanceof Error ? e.message : String(e));
|
toast.error(e instanceof Error ? e.message : String(e));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
function toggleAsSelection(id: string) {
|
||||||
|
const next = new Set(selectedAsIds);
|
||||||
|
if (next.has(id)) next.delete(id);
|
||||||
|
else next.add(id);
|
||||||
|
selectedAsIds = next;
|
||||||
|
}
|
||||||
|
function toggleAllAs(checked: boolean) {
|
||||||
|
selectedAsIds = checked ? new Set(asEntries.map((entry) => entry.id)) : new Set<string>();
|
||||||
|
}
|
||||||
|
async function bulkDeleteAs() {
|
||||||
|
if (selectedAsIds.size === 0) return;
|
||||||
|
deletingAsBulk = true;
|
||||||
|
let deleted = 0;
|
||||||
|
try {
|
||||||
|
for (const id of selectedAsIds) {
|
||||||
|
try {
|
||||||
|
await apiMutate(`/v1/modules/${moduleId}/as-entries/${id}`, 'DELETE', undefined, { idempotent: false });
|
||||||
|
deleted += 1;
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : String(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
toast.success(`Удалено AS-записей: ${deleted}`);
|
||||||
|
asBulkDeleteDialog = false;
|
||||||
|
await loadEntries();
|
||||||
|
} finally {
|
||||||
|
deletingAsBulk = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- CDN Sources ---
|
// --- CDN Sources ---
|
||||||
function openCdnCreate() {
|
function openCdnCreate() {
|
||||||
@@ -470,6 +532,35 @@
|
|||||||
toast.error(e instanceof Error ? e.message : String(e));
|
toast.error(e instanceof Error ? e.message : String(e));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
function toggleCdnSelection(id: string) {
|
||||||
|
const next = new Set(selectedCdnIds);
|
||||||
|
if (next.has(id)) next.delete(id);
|
||||||
|
else next.add(id);
|
||||||
|
selectedCdnIds = next;
|
||||||
|
}
|
||||||
|
function toggleAllCdn(checked: boolean) {
|
||||||
|
selectedCdnIds = checked ? new Set(cdnSources.map((entry) => entry.id)) : new Set<string>();
|
||||||
|
}
|
||||||
|
async function bulkDeleteCdn() {
|
||||||
|
if (selectedCdnIds.size === 0) return;
|
||||||
|
deletingCdnBulk = true;
|
||||||
|
let deleted = 0;
|
||||||
|
try {
|
||||||
|
for (const id of selectedCdnIds) {
|
||||||
|
try {
|
||||||
|
await apiMutate(`/v1/modules/${moduleId}/cdn-sources/${id}`, 'DELETE', undefined, { idempotent: false });
|
||||||
|
deleted += 1;
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : String(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
toast.success(`Удалено CDN-источников: ${deleted}`);
|
||||||
|
cdnBulkDeleteDialog = false;
|
||||||
|
await loadEntries();
|
||||||
|
} finally {
|
||||||
|
deletingCdnBulk = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- Domain Entries ---
|
// --- Domain Entries ---
|
||||||
function openDomainCreate() {
|
function openDomainCreate() {
|
||||||
@@ -511,6 +602,35 @@
|
|||||||
toast.error(e instanceof Error ? e.message : String(e));
|
toast.error(e instanceof Error ? e.message : String(e));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
function toggleDomainSelection(id: string) {
|
||||||
|
const next = new Set(selectedDomainIds);
|
||||||
|
if (next.has(id)) next.delete(id);
|
||||||
|
else next.add(id);
|
||||||
|
selectedDomainIds = next;
|
||||||
|
}
|
||||||
|
function toggleAllDomain(checked: boolean) {
|
||||||
|
selectedDomainIds = checked ? new Set(domainEntries.map((entry) => entry.id)) : new Set<string>();
|
||||||
|
}
|
||||||
|
async function bulkDeleteDomain() {
|
||||||
|
if (selectedDomainIds.size === 0) return;
|
||||||
|
deletingDomainBulk = true;
|
||||||
|
let deleted = 0;
|
||||||
|
try {
|
||||||
|
for (const id of selectedDomainIds) {
|
||||||
|
try {
|
||||||
|
await apiMutate(`/v1/modules/${moduleId}/domain-entries/${id}`, 'DELETE', undefined, { idempotent: false });
|
||||||
|
deleted += 1;
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : String(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
toast.success(`Удалено доменов: ${deleted}`);
|
||||||
|
domainBulkDeleteDialog = false;
|
||||||
|
await loadEntries();
|
||||||
|
} finally {
|
||||||
|
deletingDomainBulk = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- IP Range Entries ---
|
// --- IP Range Entries ---
|
||||||
function openIpCreate() {
|
function openIpCreate() {
|
||||||
@@ -552,6 +672,35 @@
|
|||||||
toast.error(e instanceof Error ? e.message : String(e));
|
toast.error(e instanceof Error ? e.message : String(e));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
function toggleIpSelection(id: string) {
|
||||||
|
const next = new Set(selectedIpIds);
|
||||||
|
if (next.has(id)) next.delete(id);
|
||||||
|
else next.add(id);
|
||||||
|
selectedIpIds = next;
|
||||||
|
}
|
||||||
|
function toggleAllIp(checked: boolean) {
|
||||||
|
selectedIpIds = checked ? new Set(ipEntries.map((entry) => entry.id)) : new Set<string>();
|
||||||
|
}
|
||||||
|
async function bulkDeleteIp() {
|
||||||
|
if (selectedIpIds.size === 0) return;
|
||||||
|
deletingIpBulk = true;
|
||||||
|
let deleted = 0;
|
||||||
|
try {
|
||||||
|
for (const id of selectedIpIds) {
|
||||||
|
try {
|
||||||
|
await apiMutate(`/v1/modules/${moduleId}/ip-range-entries/${id}`, 'DELETE', undefined, { idempotent: false });
|
||||||
|
deleted += 1;
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : String(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
toast.success(`Удалено диапазонов: ${deleted}`);
|
||||||
|
ipBulkDeleteDialog = false;
|
||||||
|
await loadEntries();
|
||||||
|
} finally {
|
||||||
|
deletingIpBulk = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function communityLabel(id: string | null) {
|
function communityLabel(id: string | null) {
|
||||||
if (!id) return '—';
|
if (!id) return '—';
|
||||||
@@ -676,12 +825,30 @@
|
|||||||
{csvExporting ? 'Экспорт…' : 'Экспорт CSV'}
|
{csvExporting ? 'Экспорт…' : 'Экспорт CSV'}
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" onclick={openAsCreate}><Plus />Добавить</Button>
|
<Button size="sm" onclick={openAsCreate}><Plus />Добавить</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onclick={() => (asBulkDeleteDialog = true)}
|
||||||
|
disabled={selectedAsCount === 0 || deletingAsBulk}
|
||||||
|
>
|
||||||
|
<Trash2 />
|
||||||
|
Удалить выбранные ({selectedAsCount})
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent class="p-0">
|
<CardContent class="p-0">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
|
<TableHead class="w-10">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={allAsSelected}
|
||||||
|
indeterminate={someAsSelected}
|
||||||
|
aria-label="Выбрать все AS-записи"
|
||||||
|
onchange={(event) => toggleAllAs((event.currentTarget as HTMLInputElement).checked)}
|
||||||
|
/>
|
||||||
|
</TableHead>
|
||||||
<TableHead>ASN</TableHead>
|
<TableHead>ASN</TableHead>
|
||||||
<TableHead>Название AS</TableHead>
|
<TableHead>Название AS</TableHead>
|
||||||
<TableHead class="text-right">Префиксов</TableHead>
|
<TableHead class="text-right">Префиксов</TableHead>
|
||||||
@@ -693,6 +860,14 @@
|
|||||||
<TableBody>
|
<TableBody>
|
||||||
{#each asEntries as entry (entry.id)}
|
{#each asEntries as entry (entry.id)}
|
||||||
<TableRow>
|
<TableRow>
|
||||||
|
<TableCell class="w-10">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedAsIds.has(entry.id)}
|
||||||
|
aria-label={`Выбрать AS ${entry.asn}`}
|
||||||
|
onchange={() => toggleAsSelection(entry.id)}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
<TableCell class="font-mono">{entry.asn}</TableCell>
|
<TableCell class="font-mono">{entry.asn}</TableCell>
|
||||||
<TableCell
|
<TableCell
|
||||||
class="text-muted-foreground text-sm max-w-[14rem] truncate"
|
class="text-muted-foreground text-sm max-w-[14rem] truncate"
|
||||||
@@ -718,7 +893,7 @@
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
{:else}
|
{:else}
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colspan={6} class="text-muted-foreground text-center py-6">Нет записей</TableCell>
|
<TableCell colspan={7} class="text-muted-foreground text-center py-6">Нет записей</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
{/each}
|
{/each}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
@@ -732,12 +907,32 @@
|
|||||||
<CardTitle class="text-base">CDN-источники</CardTitle>
|
<CardTitle class="text-base">CDN-источники</CardTitle>
|
||||||
<CardDescription>URL источников для скачивания списков CIDR</CardDescription>
|
<CardDescription>URL источников для скачивания списков CIDR</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<Button size="sm" class="shrink-0 self-start sm:self-auto" onclick={openCdnCreate}><Plus />Добавить</Button>
|
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2 self-start sm:self-auto">
|
||||||
|
<Button size="sm" onclick={openCdnCreate}><Plus />Добавить</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onclick={() => (cdnBulkDeleteDialog = true)}
|
||||||
|
disabled={selectedCdnCount === 0 || deletingCdnBulk}
|
||||||
|
>
|
||||||
|
<Trash2 />
|
||||||
|
Удалить выбранные ({selectedCdnCount})
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent class="p-0">
|
<CardContent class="p-0">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
|
<TableHead class="w-10">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={allCdnSelected}
|
||||||
|
indeterminate={someCdnSelected}
|
||||||
|
aria-label="Выбрать все CDN-источники"
|
||||||
|
onchange={(event) => toggleAllCdn((event.currentTarget as HTMLInputElement).checked)}
|
||||||
|
/>
|
||||||
|
</TableHead>
|
||||||
<TableHead>URL</TableHead>
|
<TableHead>URL</TableHead>
|
||||||
<TableHead>Тип</TableHead>
|
<TableHead>Тип</TableHead>
|
||||||
<TableHead>Community</TableHead>
|
<TableHead>Community</TableHead>
|
||||||
@@ -748,6 +943,14 @@
|
|||||||
<TableBody>
|
<TableBody>
|
||||||
{#each cdnSources as src (src.id)}
|
{#each cdnSources as src (src.id)}
|
||||||
<TableRow>
|
<TableRow>
|
||||||
|
<TableCell class="w-10">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedCdnIds.has(src.id)}
|
||||||
|
aria-label="Выбрать CDN-источник"
|
||||||
|
onchange={() => toggleCdnSelection(src.id)}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
<TableCell class="font-mono text-xs max-w-xs truncate">{src.url}</TableCell>
|
<TableCell class="font-mono text-xs max-w-xs truncate">{src.url}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div class="flex flex-col gap-0.5">
|
<div class="flex flex-col gap-0.5">
|
||||||
@@ -770,7 +973,7 @@
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
{:else}
|
{:else}
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colspan={5} class="text-muted-foreground text-center py-6">Нет источников</TableCell>
|
<TableCell colspan={6} class="text-muted-foreground text-center py-6">Нет источников</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
{/each}
|
{/each}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
@@ -804,12 +1007,30 @@
|
|||||||
{csvExporting ? 'Экспорт…' : 'Экспорт CSV'}
|
{csvExporting ? 'Экспорт…' : 'Экспорт CSV'}
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" onclick={openDomainCreate}><Plus />Добавить</Button>
|
<Button size="sm" onclick={openDomainCreate}><Plus />Добавить</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onclick={() => (domainBulkDeleteDialog = true)}
|
||||||
|
disabled={selectedDomainCount === 0 || deletingDomainBulk}
|
||||||
|
>
|
||||||
|
<Trash2 />
|
||||||
|
Удалить выбранные ({selectedDomainCount})
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent class="p-0">
|
<CardContent class="p-0">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
|
<TableHead class="w-10">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={allDomainSelected}
|
||||||
|
indeterminate={someDomainSelected}
|
||||||
|
aria-label="Выбрать все домены"
|
||||||
|
onchange={(event) => toggleAllDomain((event.currentTarget as HTMLInputElement).checked)}
|
||||||
|
/>
|
||||||
|
</TableHead>
|
||||||
<TableHead>FQDN</TableHead>
|
<TableHead>FQDN</TableHead>
|
||||||
<TableHead>Community</TableHead>
|
<TableHead>Community</TableHead>
|
||||||
<TableHead class="w-20"></TableHead>
|
<TableHead class="w-20"></TableHead>
|
||||||
@@ -818,6 +1039,14 @@
|
|||||||
<TableBody>
|
<TableBody>
|
||||||
{#each domainEntries as entry (entry.id)}
|
{#each domainEntries as entry (entry.id)}
|
||||||
<TableRow>
|
<TableRow>
|
||||||
|
<TableCell class="w-10">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedDomainIds.has(entry.id)}
|
||||||
|
aria-label={`Выбрать домен ${entry.fqdn}`}
|
||||||
|
onchange={() => toggleDomainSelection(entry.id)}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
<TableCell class="font-mono">{entry.fqdn}</TableCell>
|
<TableCell class="font-mono">{entry.fqdn}</TableCell>
|
||||||
<TableCell class="text-muted-foreground text-sm">{communityLabel(entry.community_id)}</TableCell>
|
<TableCell class="text-muted-foreground text-sm">{communityLabel(entry.community_id)}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
@@ -829,7 +1058,7 @@
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
{:else}
|
{:else}
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colspan={3} class="text-muted-foreground text-center py-6">Нет доменов</TableCell>
|
<TableCell colspan={4} class="text-muted-foreground text-center py-6">Нет доменов</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
{/each}
|
{/each}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
@@ -863,12 +1092,30 @@
|
|||||||
{csvExporting ? 'Экспорт…' : 'Экспорт CSV'}
|
{csvExporting ? 'Экспорт…' : 'Экспорт CSV'}
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" onclick={openIpCreate}><Plus />Добавить</Button>
|
<Button size="sm" onclick={openIpCreate}><Plus />Добавить</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onclick={() => (ipBulkDeleteDialog = true)}
|
||||||
|
disabled={selectedIpCount === 0 || deletingIpBulk}
|
||||||
|
>
|
||||||
|
<Trash2 />
|
||||||
|
Удалить выбранные ({selectedIpCount})
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent class="p-0">
|
<CardContent class="p-0">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
|
<TableHead class="w-10">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={allIpSelected}
|
||||||
|
indeterminate={someIpSelected}
|
||||||
|
aria-label="Выбрать все диапазоны"
|
||||||
|
onchange={(event) => toggleAllIp((event.currentTarget as HTMLInputElement).checked)}
|
||||||
|
/>
|
||||||
|
</TableHead>
|
||||||
<TableHead>Префикс (CIDR)</TableHead>
|
<TableHead>Префикс (CIDR)</TableHead>
|
||||||
<TableHead>Community</TableHead>
|
<TableHead>Community</TableHead>
|
||||||
<TableHead class="w-20"></TableHead>
|
<TableHead class="w-20"></TableHead>
|
||||||
@@ -877,6 +1124,14 @@
|
|||||||
<TableBody>
|
<TableBody>
|
||||||
{#each ipEntries as entry (entry.id)}
|
{#each ipEntries as entry (entry.id)}
|
||||||
<TableRow>
|
<TableRow>
|
||||||
|
<TableCell class="w-10">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedIpIds.has(entry.id)}
|
||||||
|
aria-label={`Выбрать диапазон ${entry.prefix}`}
|
||||||
|
onchange={() => toggleIpSelection(entry.id)}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
<TableCell class="font-mono">{entry.prefix}</TableCell>
|
<TableCell class="font-mono">{entry.prefix}</TableCell>
|
||||||
<TableCell class="text-muted-foreground text-sm">{communityLabel(entry.community_id)}</TableCell>
|
<TableCell class="text-muted-foreground text-sm">{communityLabel(entry.community_id)}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
@@ -888,7 +1143,7 @@
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
{:else}
|
{:else}
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colspan={3} class="text-muted-foreground text-center py-6">Нет диапазонов</TableCell>
|
<TableCell colspan={4} class="text-muted-foreground text-center py-6">Нет диапазонов</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
{/each}
|
{/each}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
@@ -1043,6 +1298,21 @@
|
|||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
|
||||||
|
<AlertDialog bind:open={asBulkDeleteDialog}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Удалить выбранные AS-записи?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>Будет удалено: {selectedAsCount}</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Отмена</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onclick={bulkDeleteAs} disabled={deletingAsBulk}>
|
||||||
|
{deletingAsBulk ? 'Удаление…' : 'Удалить'}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
|
||||||
<!-- ================== CDN Source Dialog ================== -->
|
<!-- ================== CDN Source Dialog ================== -->
|
||||||
<Dialog
|
<Dialog
|
||||||
bind:open={cdnDialog}
|
bind:open={cdnDialog}
|
||||||
@@ -1151,6 +1421,21 @@
|
|||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
|
||||||
|
<AlertDialog bind:open={cdnBulkDeleteDialog}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Удалить выбранные CDN-источники?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>Будет удалено: {selectedCdnCount}</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Отмена</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onclick={bulkDeleteCdn} disabled={deletingCdnBulk}>
|
||||||
|
{deletingCdnBulk ? 'Удаление…' : 'Удалить'}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
|
||||||
<!-- ================== Domain Entry Dialog ================== -->
|
<!-- ================== Domain Entry Dialog ================== -->
|
||||||
<Dialog bind:open={domainDialog}>
|
<Dialog bind:open={domainDialog}>
|
||||||
<DialogContent class="sm:max-w-sm">
|
<DialogContent class="sm:max-w-sm">
|
||||||
@@ -1197,6 +1482,21 @@
|
|||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
|
||||||
|
<AlertDialog bind:open={domainBulkDeleteDialog}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Удалить выбранные домены?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>Будет удалено: {selectedDomainCount}</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Отмена</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onclick={bulkDeleteDomain} disabled={deletingDomainBulk}>
|
||||||
|
{deletingDomainBulk ? 'Удаление…' : 'Удалить'}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
|
||||||
<!-- ================== IP Range Dialog ================== -->
|
<!-- ================== IP Range Dialog ================== -->
|
||||||
<Dialog bind:open={ipDialog}>
|
<Dialog bind:open={ipDialog}>
|
||||||
<DialogContent class="sm:max-w-sm">
|
<DialogContent class="sm:max-w-sm">
|
||||||
@@ -1241,3 +1541,18 @@
|
|||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
|
||||||
|
<AlertDialog bind:open={ipBulkDeleteDialog}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Удалить выбранные диапазоны?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>Будет удалено: {selectedIpCount}</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Отмена</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onclick={bulkDeleteIp} disabled={deletingIpBulk}>
|
||||||
|
{deletingIpBulk ? 'Удаление…' : 'Удалить'}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
|||||||
Reference in New Issue
Block a user