diff --git a/web/src/routes/modules/+page.svelte b/web/src/routes/modules/+page.svelte index f8107db..12795e3 100644 --- a/web/src/routes/modules/+page.svelte +++ b/web/src/routes/modules/+page.svelte @@ -22,6 +22,16 @@ SelectTrigger, } from '$lib/components/ui/select/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 { Table, TableBody, @@ -35,11 +45,19 @@ import Plus from '@lucide/svelte/icons/plus'; import RefreshCw from '@lucide/svelte/icons/refresh-cw'; import ExternalLink from '@lucide/svelte/icons/external-link'; + import Trash2 from '@lucide/svelte/icons/trash-2'; let rows = $state([]); let loading = $state(false); let dialogOpen = $state(false); let saving = $state(false); + let selectedModuleIds = $state(new Set()); + 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({ type: 'AS_PREFIXES', @@ -60,6 +78,8 @@ try { const m = await apiJSON('/v1/modules?limit=200'); rows = m.items ?? []; + const validIds = new Set(rows.map((item) => item.id)); + selectedModuleIds = new Set([...selectedModuleIds].filter((id) => validIds.has(id))); } catch (e) { toast.error(e instanceof Error ? e.message : String(e)); } finally { @@ -97,15 +117,49 @@ 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(); + } + + 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; + } + }
-
+

Модули префиксов

Управление модулями — AS, CDN, домены, IP-диапазоны.

-
+
+ + Список модулей + {#if selectedModulesCount > 0} +
+ Выбрано: {selectedModulesCount} + +
+ {/if} +
+ + toggleAllModules((event.currentTarget as HTMLInputElement).checked)} + /> + Название Тип Приоритет @@ -133,6 +213,14 @@ {#each rows as m (m.id)} + + toggleModuleSelection(m.id)} + /> + {m.name} {m.type} @@ -156,7 +244,7 @@ {:else} - + {loading ? 'Загрузка…' : 'Нет модулей. Создайте первый.'} @@ -227,3 +315,20 @@ + + + + + Удалить выбранные модули? + + Будет удалено: {selectedModulesCount}. Это действие необратимо. + + + + Отмена + + {deletingBulkModules ? 'Удаление…' : 'Удалить'} + + + + diff --git a/web/src/routes/modules/[moduleId]/+page.svelte b/web/src/routes/modules/[moduleId]/+page.svelte index 3c179e2..e9f134b 100644 --- a/web/src/routes/modules/[moduleId]/+page.svelte +++ b/web/src/routes/modules/[moduleId]/+page.svelte @@ -89,6 +89,9 @@ let asForm = $state({ asn: 0, community_id: null }); let asSaving = $state(false); let asDeleteTarget = $state(null); + let selectedAsIds = $state(new Set()); + let asBulkDeleteDialog = $state(false); + let deletingAsBulk = $state(false); // CDN sources let cdnSources = $state([]); @@ -108,6 +111,9 @@ let cdnPreviewTruncated = $state(false); let cdnPreviewError = $state(null); let cdnPreviewOk = $state(false); + let selectedCdnIds = $state(new Set()); + let cdnBulkDeleteDialog = $state(false); + let deletingCdnBulk = $state(false); // Domain entries let domainEntries = $state([]); @@ -116,6 +122,9 @@ let domainForm = $state({ fqdn: '', community_id: null }); let domainSaving = $state(false); let domainDeleteTarget = $state(null); + let selectedDomainIds = $state(new Set()); + let domainBulkDeleteDialog = $state(false); + let deletingDomainBulk = $state(false); // IP Range entries let ipEntries = $state([]); @@ -124,6 +133,9 @@ let ipForm = $state({ prefix: '', community_id: '' }); let ipSaving = $state(false); let ipDeleteTarget = $state(null); + let selectedIpIds = $state(new Set()); + let ipBulkDeleteDialog = $state(false); + let deletingIpBulk = $state(false); // Module edit let editDialog = $state(false); @@ -137,6 +149,23 @@ let csvImporting = $state(false); let csvExporting = $state(false); let csvFileInput = $state(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, existingIds: string[]): Set { + const validIds = new Set(existingIds); + return new Set([...selected].filter((id) => validIds.has(id))); + } function supportsCsvIO(type: ModuleRow['type'] | null | undefined): boolean { return type === 'AS_PREFIXES' || type === 'DOMAINS' || type === 'IP_RANGES'; @@ -183,15 +212,19 @@ if (type === 'AS_PREFIXES') { const r = await apiJSON(`/v1/modules/${moduleId}/as-entries?limit=500`); asEntries = r.items; + selectedAsIds = syncSelection(selectedAsIds, asEntries.map((entry) => entry.id)); } else if (type === 'CDN_CIDRS') { const r = await apiJSON(`/v1/modules/${moduleId}/cdn-sources?limit=200`); cdnSources = r.items; + selectedCdnIds = syncSelection(selectedCdnIds, cdnSources.map((entry) => entry.id)); } else if (type === 'DOMAINS') { const r = await apiJSON(`/v1/modules/${moduleId}/domain-entries?limit=500`); domainEntries = r.items; + selectedDomainIds = syncSelection(selectedDomainIds, domainEntries.map((entry) => entry.id)); } else if (type === 'IP_RANGES') { const r = await apiJSON(`/v1/modules/${moduleId}/ip-range-entries?limit=500`); ipEntries = r.items; + selectedIpIds = syncSelection(selectedIpIds, ipEntries.map((entry) => entry.id)); } } catch (e) { toast.error(e instanceof Error ? e.message : String(e)); @@ -376,6 +409,35 @@ 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(); + } + 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 --- function openCdnCreate() { @@ -470,6 +532,35 @@ 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(); + } + 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 --- function openDomainCreate() { @@ -511,6 +602,35 @@ 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(); + } + 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 --- function openIpCreate() { @@ -552,6 +672,35 @@ 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(); + } + 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) { if (!id) return '—'; @@ -676,12 +825,30 @@ {csvExporting ? 'Экспорт…' : 'Экспорт CSV'} +
+ + toggleAllAs((event.currentTarget as HTMLInputElement).checked)} + /> + ASN Название AS Префиксов @@ -693,6 +860,14 @@ {#each asEntries as entry (entry.id)} + + toggleAsSelection(entry.id)} + /> + {entry.asn} {:else} - Нет записей + Нет записей {/each} @@ -732,12 +907,32 @@ CDN-источники URL источников для скачивания списков CIDR - +
+ + +
+ + toggleAllCdn((event.currentTarget as HTMLInputElement).checked)} + /> + URL Тип Community @@ -748,6 +943,14 @@ {#each cdnSources as src (src.id)} + + toggleCdnSelection(src.id)} + /> + {src.url}
@@ -770,7 +973,7 @@ {:else} - Нет источников + Нет источников {/each} @@ -804,12 +1007,30 @@ {csvExporting ? 'Экспорт…' : 'Экспорт CSV'} +
+ + toggleAllDomain((event.currentTarget as HTMLInputElement).checked)} + /> + FQDN Community @@ -818,6 +1039,14 @@ {#each domainEntries as entry (entry.id)} + + toggleDomainSelection(entry.id)} + /> + {entry.fqdn} {communityLabel(entry.community_id)} @@ -829,7 +1058,7 @@ {:else} - Нет доменов + Нет доменов {/each} @@ -863,12 +1092,30 @@ {csvExporting ? 'Экспорт…' : 'Экспорт CSV'} +
+ + toggleAllIp((event.currentTarget as HTMLInputElement).checked)} + /> + Префикс (CIDR) Community @@ -877,6 +1124,14 @@ {#each ipEntries as entry (entry.id)} + + toggleIpSelection(entry.id)} + /> + {entry.prefix} {communityLabel(entry.community_id)} @@ -888,7 +1143,7 @@ {:else} - Нет диапазонов + Нет диапазонов {/each} @@ -1043,6 +1298,21 @@ + + + + Удалить выбранные AS-записи? + Будет удалено: {selectedAsCount} + + + Отмена + + {deletingAsBulk ? 'Удаление…' : 'Удалить'} + + + + + + + + + Удалить выбранные CDN-источники? + Будет удалено: {selectedCdnCount} + + + Отмена + + {deletingCdnBulk ? 'Удаление…' : 'Удалить'} + + + + + @@ -1197,6 +1482,21 @@ + + + + Удалить выбранные домены? + Будет удалено: {selectedDomainCount} + + + Отмена + + {deletingDomainBulk ? 'Удаление…' : 'Удалить'} + + + + + @@ -1241,3 +1541,18 @@ + + + + + Удалить выбранные диапазоны? + Будет удалено: {selectedIpCount} + + + Отмена + + {deletingIpBulk ? 'Удаление…' : 'Удалить'} + + + +