CI / changes (push) Successful in 8s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 38s
CI / go (push) Has been skipped
CI / bird2 (push) Has been skipped
CI / release (push) Successful in 4m1s
- Refactored module entry dialogs to reset forms based on edit state and improve state management. - Updated selection logic in various components to utilize derived states for active selections, enhancing bulk operations. - Improved dialog bindings for better state synchronization and user experience. - Streamlined component structure for maintainability and clarity.
303 lines
8.5 KiB
Svelte
303 lines
8.5 KiB
Svelte
<script lang="ts">
|
|
import { apiFetch, apiMutate } from '$lib/api/client.js';
|
|
import type { BgpCommunity, DomainEntry, ModuleRow } from '$lib/api/types.js';
|
|
import {
|
|
communityLabel,
|
|
sanitizeFilenamePart,
|
|
supportsCsvIO
|
|
} from '$lib/components/modules/module-helpers.js';
|
|
import { Button } from '$lib/ui/core/button/index.js';
|
|
import { Checkbox } from '$lib/ui/core/checkbox/index.js';
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardHeader,
|
|
CardTitle,
|
|
CardDescription
|
|
} from '$lib/ui/core/card/index.js';
|
|
import AppDataTable from '$lib/ui/patterns/data-table/app-data-table.svelte';
|
|
import { confirm } from '$lib/ui/patterns/confirm/confirm-state.svelte.js';
|
|
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
|
|
import ModuleDomainEntryDialog from '$lib/components/modules/ModuleDomainEntryDialog.svelte';
|
|
import Plus from '@lucide/svelte/icons/plus';
|
|
import Pencil from '@lucide/svelte/icons/pencil';
|
|
import Trash2 from '@lucide/svelte/icons/trash-2';
|
|
import Upload from '@lucide/svelte/icons/upload';
|
|
import Download from '@lucide/svelte/icons/download';
|
|
|
|
type Props = {
|
|
moduleId: string;
|
|
mod: ModuleRow;
|
|
entries: DomainEntry[];
|
|
communities: BgpCommunity[];
|
|
loading?: boolean;
|
|
onChanged: () => void | Promise<void>;
|
|
};
|
|
|
|
let { moduleId, mod, entries, communities, loading = false, onChanged }: Props = $props();
|
|
|
|
let dialogOpen = $state(false);
|
|
let editTarget = $state<DomainEntry | null>(null);
|
|
let selectedIds = $state(new Set<string>());
|
|
let deletingBulk = $state(false);
|
|
let csvImporting = $state(false);
|
|
let csvExporting = $state(false);
|
|
let csvFileInput = $state<HTMLInputElement | null>(null);
|
|
|
|
const activeSelected = $derived.by(() => {
|
|
const allowed = new Set(entries.map((e) => e.id));
|
|
return [...selectedIds].filter((id) => allowed.has(id));
|
|
});
|
|
const selectedCount = $derived(activeSelected.length);
|
|
const allSelected = $derived(entries.length > 0 && entries.every((e) => selectedIds.has(e.id)));
|
|
|
|
const columns = [
|
|
{ id: 'select', label: '', class: 'w-10' },
|
|
{ id: 'fqdn', label: 'FQDN', sortable: true, sortValue: (e: DomainEntry) => e.fqdn },
|
|
{ id: 'community', label: 'Community' },
|
|
{ id: 'actions', label: '', class: 'w-20' }
|
|
] as const;
|
|
|
|
function toggleSelection(id: string) {
|
|
const next = new Set(selectedIds);
|
|
if (next.has(id)) next.delete(id);
|
|
else next.add(id);
|
|
selectedIds = next;
|
|
}
|
|
|
|
function toggleAll(checked: boolean) {
|
|
selectedIds = checked ? new Set(entries.map((e) => e.id)) : new Set<string>();
|
|
}
|
|
|
|
function openCreate() {
|
|
editTarget = null;
|
|
dialogOpen = true;
|
|
}
|
|
|
|
function openEdit(entry: DomainEntry) {
|
|
editTarget = entry;
|
|
dialogOpen = true;
|
|
}
|
|
|
|
function requestDelete(entry: DomainEntry) {
|
|
void confirm({
|
|
title: 'Удалить домен?',
|
|
description: entry.fqdn,
|
|
confirmLabel: 'Удалить',
|
|
destructive: true,
|
|
onConfirm: async () => {
|
|
await apiMutate(`/v1/modules/${moduleId}/domain-entries/${entry.id}`, 'DELETE', undefined, {
|
|
idempotent: false
|
|
});
|
|
notify.success('Удалено');
|
|
await onChanged();
|
|
}
|
|
});
|
|
}
|
|
|
|
function requestBulkDelete() {
|
|
if (selectedCount === 0) return;
|
|
void confirm({
|
|
title: 'Удалить выбранные домены?',
|
|
description: `Будет удалено: ${selectedCount}`,
|
|
confirmLabel: 'Удалить',
|
|
destructive: true,
|
|
onConfirm: bulkDelete
|
|
});
|
|
}
|
|
|
|
async function bulkDelete() {
|
|
if (selectedCount === 0) return;
|
|
deletingBulk = true;
|
|
let deleted = 0;
|
|
try {
|
|
for (const id of activeSelected) {
|
|
try {
|
|
await apiMutate(`/v1/modules/${moduleId}/domain-entries/${id}`, 'DELETE', undefined, {
|
|
idempotent: false
|
|
});
|
|
deleted += 1;
|
|
} catch (e) {
|
|
notifyApiError(e);
|
|
}
|
|
}
|
|
if (deleted > 0) notify.success(`Удалено доменов: ${deleted}`);
|
|
await onChanged();
|
|
} finally {
|
|
deletingBulk = false;
|
|
}
|
|
}
|
|
|
|
async function readErrorText(res: Response): Promise<string> {
|
|
const body = (await res.text()).trim();
|
|
return body || `HTTP ${res.status}`;
|
|
}
|
|
|
|
async function exportCsv() {
|
|
if (!supportsCsvIO(mod.type) || csvExporting) return;
|
|
csvExporting = true;
|
|
try {
|
|
const res = await apiFetch(`/v1/modules/${moduleId}/entries.csv`, {
|
|
method: 'GET',
|
|
headers: { Accept: 'text/csv' }
|
|
});
|
|
if (!res.ok) {
|
|
notify.error(await readErrorText(res));
|
|
return;
|
|
}
|
|
const blob = await res.blob();
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `${sanitizeFilenamePart(mod.name)}-${mod.type.toLowerCase()}-entries.csv`;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
a.remove();
|
|
URL.revokeObjectURL(url);
|
|
} catch (e) {
|
|
notifyApiError(e);
|
|
} finally {
|
|
csvExporting = false;
|
|
}
|
|
}
|
|
|
|
function openImportPicker() {
|
|
if (!supportsCsvIO(mod.type) || csvImporting) return;
|
|
csvFileInput?.click();
|
|
}
|
|
|
|
async function handleImportChange(event: Event) {
|
|
const input = event.currentTarget as HTMLInputElement | null;
|
|
const file = input?.files?.[0];
|
|
if (!file || csvImporting) return;
|
|
csvImporting = true;
|
|
try {
|
|
const fileText = await file.text();
|
|
const res = await apiFetch(`/v1/modules/${moduleId}/entries.csv`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'text/csv' },
|
|
body: fileText
|
|
});
|
|
if (!res.ok) {
|
|
notify.error(await readErrorText(res));
|
|
return;
|
|
}
|
|
const payload = (await res.json()) as { imported?: number };
|
|
notify.success(`Импортировано записей: ${payload.imported ?? 0}`);
|
|
await onChanged();
|
|
} catch (e) {
|
|
notifyApiError(e);
|
|
} finally {
|
|
csvImporting = false;
|
|
if (input) input.value = '';
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<input
|
|
class="hidden"
|
|
type="file"
|
|
accept=".csv,text/csv"
|
|
bind:this={csvFileInput}
|
|
onchange={handleImportChange}
|
|
/>
|
|
|
|
<Card>
|
|
<CardHeader class="flex flex-col gap-3 pb-2 sm:flex-row sm:items-center sm:justify-between">
|
|
<div class="min-w-0 flex-1">
|
|
<CardTitle class="text-base">Домены</CardTitle>
|
|
<CardDescription>FQDN для резолвинга через DoH.</CardDescription>
|
|
</div>
|
|
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2 self-start sm:self-auto">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onclick={openImportPicker}
|
|
disabled={!supportsCsvIO(mod.type) || csvImporting || csvExporting}
|
|
>
|
|
<Upload />
|
|
{csvImporting ? 'Импорт…' : 'Импорт CSV'}
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onclick={exportCsv}
|
|
disabled={!supportsCsvIO(mod.type) || csvExporting || csvImporting}
|
|
>
|
|
<Download />
|
|
{csvExporting ? 'Экспорт…' : 'Экспорт CSV'}
|
|
</Button>
|
|
<Button size="sm" onclick={openCreate}><Plus />Добавить</Button>
|
|
{#if selectedCount > 0}
|
|
<Button variant="destructive" size="sm" onclick={requestBulkDelete} disabled={deletingBulk}>
|
|
<Trash2 />
|
|
Удалить ({selectedCount})
|
|
</Button>
|
|
{/if}
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent class="p-4 pt-0">
|
|
<AppDataTable
|
|
columns={[...columns]}
|
|
rows={entries}
|
|
rowKey={(e) => e.id}
|
|
{loading}
|
|
emptyTitle="Нет доменов"
|
|
emptyDescription="Добавьте FQDN или импортируйте CSV."
|
|
>
|
|
{#snippet toolbar()}
|
|
{#if entries.length > 0}
|
|
<div class="flex items-center gap-2">
|
|
<Checkbox
|
|
checked={allSelected}
|
|
onCheckedChange={(v) => toggleAll(v === true)}
|
|
aria-label="Выбрать все домены"
|
|
/>
|
|
<span class="text-sm text-muted-foreground">Выбрать все</span>
|
|
</div>
|
|
{/if}
|
|
{/snippet}
|
|
{#snippet cell({ row: entry, column })}
|
|
{#if column.id === 'select'}
|
|
<Checkbox
|
|
checked={selectedIds.has(entry.id)}
|
|
aria-label={`Выбрать домен ${entry.fqdn}`}
|
|
onCheckedChange={() => toggleSelection(entry.id)}
|
|
/>
|
|
{:else if column.id === 'fqdn'}
|
|
<span class="font-mono">{entry.fqdn}</span>
|
|
{:else if column.id === 'community'}
|
|
<span class="text-sm text-muted-foreground">
|
|
{communityLabel(entry.community_id, communities)}
|
|
</span>
|
|
{:else if column.id === 'actions'}
|
|
<div class="flex gap-1">
|
|
<Button variant="ghost" size="icon-sm" onclick={() => openEdit(entry)}>
|
|
<Pencil class="size-3.5" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
class="text-destructive"
|
|
onclick={() => requestDelete(entry)}
|
|
>
|
|
<Trash2 class="size-3.5" />
|
|
</Button>
|
|
</div>
|
|
{/if}
|
|
{/snippet}
|
|
</AppDataTable>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<ModuleDomainEntryDialog
|
|
bind:open={dialogOpen}
|
|
{moduleId}
|
|
edit={editTarget}
|
|
{communities}
|
|
onSaved={onChanged}
|
|
onClose={() => {
|
|
editTarget = null;
|
|
}}
|
|
/>
|