feat(web): enhance module management UI with new localization and state handling
CI / changes (push) Successful in 7s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 31s
CI / go (push) Has been skipped
CI / bird2 (push) Has been skipped
CI / release (push) Successful in 3m30s
CI / changes (push) Successful in 7s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 31s
CI / go (push) Has been skipped
CI / bird2 (push) Has been skipped
CI / release (push) Successful in 3m30s
- Added new utility functions for module state representation in Russian, improving localization. - Introduced derived states for counting enabled and disabled modules, enhancing user insights. - Updated module management components to display last updated timestamps and improved descriptions. - Refactored imports to utilize core UI components for better maintainability and consistency.
This commit is contained in:
@@ -0,0 +1,342 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { apiFetch, apiMutate } from '$lib/api/client.js';
|
||||||
|
import type { AsEntry, BgpCommunity, ModuleRow } from '$lib/api/types.js';
|
||||||
|
import { formatDateTime } from '$lib/modules/display.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 ModuleAsEntryDialog from '$lib/components/modules/ModuleAsEntryDialog.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: AsEntry[];
|
||||||
|
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<AsEntry | 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 selectedCount = $derived(selectedIds.size);
|
||||||
|
const allSelected = $derived(entries.length > 0 && selectedIds.size === entries.length);
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ id: 'select', label: '', class: 'w-10' },
|
||||||
|
{ id: 'asn', label: 'ASN', sortable: true, sortValue: (e: AsEntry) => e.asn },
|
||||||
|
{
|
||||||
|
id: 'name',
|
||||||
|
label: 'Название AS',
|
||||||
|
sortable: true,
|
||||||
|
sortValue: (e: AsEntry) => e.asn_name ?? ''
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'prefixes',
|
||||||
|
label: 'Префиксов',
|
||||||
|
sortable: true,
|
||||||
|
sortValue: (e: AsEntry) => e.prefix_count ?? 0,
|
||||||
|
class: 'text-right'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'updated',
|
||||||
|
label: 'Обновлено',
|
||||||
|
sortable: true,
|
||||||
|
sortValue: (e: AsEntry) => e.asn_resolved_at ?? ''
|
||||||
|
},
|
||||||
|
{ id: 'community', label: 'Community' },
|
||||||
|
{ id: 'actions', label: '', class: 'w-20' }
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const validIds = new Set(entries.map((e) => e.id));
|
||||||
|
selectedIds = new Set([...selectedIds].filter((id) => validIds.has(id)));
|
||||||
|
});
|
||||||
|
|
||||||
|
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: AsEntry) {
|
||||||
|
editTarget = entry;
|
||||||
|
dialogOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestDelete(entry: AsEntry) {
|
||||||
|
void confirm({
|
||||||
|
title: 'Удалить запись?',
|
||||||
|
description: `ASN: ${entry.asn}`,
|
||||||
|
confirmLabel: 'Удалить',
|
||||||
|
destructive: true,
|
||||||
|
onConfirm: async () => {
|
||||||
|
await apiMutate(`/v1/modules/${moduleId}/as-entries/${entry.id}`, 'DELETE', undefined, {
|
||||||
|
idempotent: false
|
||||||
|
});
|
||||||
|
notify.success('Удалено');
|
||||||
|
await onChanged();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestBulkDelete() {
|
||||||
|
if (selectedCount === 0) return;
|
||||||
|
void confirm({
|
||||||
|
title: 'Удалить выбранные AS-записи?',
|
||||||
|
description: `Будет удалено: ${selectedCount}`,
|
||||||
|
confirmLabel: 'Удалить',
|
||||||
|
destructive: true,
|
||||||
|
onConfirm: bulkDelete
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bulkDelete() {
|
||||||
|
if (selectedCount === 0) return;
|
||||||
|
deletingBulk = true;
|
||||||
|
let deleted = 0;
|
||||||
|
try {
|
||||||
|
for (const id of selectedIds) {
|
||||||
|
try {
|
||||||
|
await apiMutate(`/v1/modules/${moduleId}/as-entries/${id}`, 'DELETE', undefined, {
|
||||||
|
idempotent: false
|
||||||
|
});
|
||||||
|
deleted += 1;
|
||||||
|
} catch (e) {
|
||||||
|
notifyApiError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (deleted > 0) notify.success(`Удалено AS-записей: ${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">AS-записи</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Номер AS и community; имя, число префиксов и дата обновляются при успешном refresh
|
||||||
|
(RIPEstat).
|
||||||
|
</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="Нет AS-записей"
|
||||||
|
emptyDescription="Добавьте ASN или импортируйте CSV."
|
||||||
|
>
|
||||||
|
{#snippet toolbar()}
|
||||||
|
{#if entries.length > 0}
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
checked={allSelected}
|
||||||
|
onCheckedChange={(v) => toggleAll(v === true)}
|
||||||
|
aria-label="Выбрать все AS-записи"
|
||||||
|
/>
|
||||||
|
<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={`Выбрать AS ${entry.asn}`}
|
||||||
|
onCheckedChange={() => toggleSelection(entry.id)}
|
||||||
|
/>
|
||||||
|
{:else if column.id === 'asn'}
|
||||||
|
<span class="font-mono">{entry.asn}</span>
|
||||||
|
{:else if column.id === 'name'}
|
||||||
|
<span
|
||||||
|
class="max-w-[14rem] truncate text-sm text-muted-foreground"
|
||||||
|
title={entry.asn_name ?? ''}
|
||||||
|
>
|
||||||
|
{entry.asn_name?.trim() ? entry.asn_name : '—'}
|
||||||
|
</span>
|
||||||
|
{:else if column.id === 'prefixes'}
|
||||||
|
<span class="font-mono text-sm">
|
||||||
|
{entry.prefix_count != null ? entry.prefix_count : '—'}
|
||||||
|
</span>
|
||||||
|
{:else if column.id === 'updated'}
|
||||||
|
<span class="text-sm whitespace-nowrap text-muted-foreground">
|
||||||
|
{formatDateTime(entry.asn_resolved_at)}
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<ModuleAsEntryDialog
|
||||||
|
bind:open={dialogOpen}
|
||||||
|
{moduleId}
|
||||||
|
edit={editTarget}
|
||||||
|
{communities}
|
||||||
|
onSaved={onChanged}
|
||||||
|
onClose={() => {
|
||||||
|
editTarget = null;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { apiMutate } from '$lib/api/client.js';
|
||||||
|
import type { AsEntry, AsEntryCreate, AsEntryPatch, BgpCommunity } from '$lib/api/types.js';
|
||||||
|
import {
|
||||||
|
communityLabel,
|
||||||
|
communityOptionLabel,
|
||||||
|
fromNullableSelect,
|
||||||
|
NONE_OPTION,
|
||||||
|
nullableSelectValue
|
||||||
|
} from '$lib/components/modules/module-helpers.js';
|
||||||
|
import { Button } from '$lib/ui/core/button/index.js';
|
||||||
|
import { Input } from '$lib/ui/core/input/index.js';
|
||||||
|
import { Label } from '$lib/ui/core/label/index.js';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
DialogDescription
|
||||||
|
} from '$lib/ui/core/dialog/index.js';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/ui/core/select/index.js';
|
||||||
|
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
open: boolean;
|
||||||
|
moduleId: string;
|
||||||
|
edit: AsEntry | null;
|
||||||
|
communities: BgpCommunity[];
|
||||||
|
onSaved: () => void | Promise<void>;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { open = $bindable(), moduleId, edit, communities, onSaved, onClose }: Props = $props();
|
||||||
|
|
||||||
|
let saving = $state(false);
|
||||||
|
let form = $state<AsEntryCreate>({ asn: 0, community_id: null });
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (open) {
|
||||||
|
form = edit
|
||||||
|
? { asn: edit.asn, community_id: edit.community_id }
|
||||||
|
: { asn: 0, community_id: null };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
const asn = Number(form.asn);
|
||||||
|
if (!Number.isFinite(asn) || asn < 1 || asn > 4294967295) {
|
||||||
|
notify.error('Укажите корректный ASN (1–4294967295)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
saving = true;
|
||||||
|
try {
|
||||||
|
const body: AsEntryCreate | AsEntryPatch = { asn, community_id: form.community_id };
|
||||||
|
if (edit) {
|
||||||
|
await apiMutate(`/v1/modules/${moduleId}/as-entries/${edit.id}`, 'PATCH', body);
|
||||||
|
notify.success('Запись обновлена');
|
||||||
|
} else {
|
||||||
|
await apiMutate(`/v1/modules/${moduleId}/as-entries`, 'POST', body as AsEntryCreate);
|
||||||
|
notify.success('Запись добавлена');
|
||||||
|
}
|
||||||
|
open = false;
|
||||||
|
await onSaved();
|
||||||
|
} catch (e) {
|
||||||
|
notifyApiError(e);
|
||||||
|
} finally {
|
||||||
|
saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOpenChange(next: boolean) {
|
||||||
|
open = next;
|
||||||
|
if (!next) onClose();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Dialog {open} onOpenChange={handleOpenChange}>
|
||||||
|
<DialogContent class="sm:max-w-sm">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{edit ? 'Редактировать запись' : 'Новая AS-запись'}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Номер автономной системы и community для политики анонса.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div class="space-y-4 py-2">
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label for="as-asn">ASN</Label>
|
||||||
|
<Input
|
||||||
|
id="as-asn"
|
||||||
|
type="number"
|
||||||
|
placeholder="12345"
|
||||||
|
bind:value={form.asn}
|
||||||
|
min={1}
|
||||||
|
max={4294967295}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label for="as-comm">Community</Label>
|
||||||
|
<Select
|
||||||
|
type="single"
|
||||||
|
value={nullableSelectValue(form.community_id)}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
form.community_id = fromNullableSelect(v);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="as-comm" class="w-full">
|
||||||
|
{form.community_id ? communityLabel(form.community_id, communities) : 'Не выбрано'}
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={NONE_OPTION}>Не выбрано</SelectItem>
|
||||||
|
{#each communities as c (c.id)}
|
||||||
|
<SelectItem value={c.id}>{communityOptionLabel(c)}</SelectItem>
|
||||||
|
{/each}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onclick={() => handleOpenChange(false)}>Отмена</Button>
|
||||||
|
<Button onclick={save} disabled={saving}>
|
||||||
|
{saving ? 'Сохранение…' : edit ? 'Сохранить' : 'Добавить'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { apiMutate } from '$lib/api/client.js';
|
||||||
|
import type {
|
||||||
|
BgpCommunity,
|
||||||
|
CdnPreviewResponse,
|
||||||
|
CdnSource,
|
||||||
|
CdnSourceCreate
|
||||||
|
} from '$lib/api/types.js';
|
||||||
|
import {
|
||||||
|
communityLabel,
|
||||||
|
communityOptionLabel,
|
||||||
|
fromNullableSelect,
|
||||||
|
normalizeCdnSourceKind,
|
||||||
|
NONE_OPTION,
|
||||||
|
nullableSelectValue
|
||||||
|
} from '$lib/components/modules/module-helpers.js';
|
||||||
|
import { Button } from '$lib/ui/core/button/index.js';
|
||||||
|
import { Input } from '$lib/ui/core/input/index.js';
|
||||||
|
import { Label } from '$lib/ui/core/label/index.js';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter
|
||||||
|
} from '$lib/ui/core/dialog/index.js';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/ui/core/select/index.js';
|
||||||
|
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
open: boolean;
|
||||||
|
moduleId: string;
|
||||||
|
edit: CdnSource | null;
|
||||||
|
communities: BgpCommunity[];
|
||||||
|
onSaved: () => void | Promise<void>;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { open = $bindable(), moduleId, edit, communities, onSaved, onClose }: Props = $props();
|
||||||
|
|
||||||
|
let saving = $state(false);
|
||||||
|
let previewLoading = $state(false);
|
||||||
|
let previewItems = $state<string[]>([]);
|
||||||
|
let previewTotal = $state(0);
|
||||||
|
let previewTruncated = $state(false);
|
||||||
|
let previewError = $state<string | null>(null);
|
||||||
|
let previewOk = $state(false);
|
||||||
|
let form = $state<CdnSourceCreate & { refresh_interval_sec?: number | null }>({
|
||||||
|
url: '',
|
||||||
|
source_kind: 'plaintext',
|
||||||
|
prefix_path: '',
|
||||||
|
community_id: null
|
||||||
|
});
|
||||||
|
|
||||||
|
function clearPreview() {
|
||||||
|
previewLoading = false;
|
||||||
|
previewItems = [];
|
||||||
|
previewTotal = 0;
|
||||||
|
previewTruncated = false;
|
||||||
|
previewError = null;
|
||||||
|
previewOk = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (open) {
|
||||||
|
clearPreview();
|
||||||
|
form = edit
|
||||||
|
? {
|
||||||
|
url: edit.url,
|
||||||
|
source_kind: normalizeCdnSourceKind(edit.source_kind),
|
||||||
|
prefix_path: edit.prefix_path ?? '',
|
||||||
|
community_id: edit.community_id,
|
||||||
|
refresh_interval_sec: edit.refresh_interval_sec
|
||||||
|
}
|
||||||
|
: { url: '', source_kind: 'plaintext', prefix_path: '', community_id: null };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function previewCdn() {
|
||||||
|
const urlTrim = form.url.trim();
|
||||||
|
if (!urlTrim) {
|
||||||
|
notify.error('Укажите URL');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
previewLoading = true;
|
||||||
|
previewError = null;
|
||||||
|
previewOk = false;
|
||||||
|
try {
|
||||||
|
const res = await apiMutate<CdnPreviewResponse>(
|
||||||
|
`/v1/modules/${moduleId}/cdn-sources/preview`,
|
||||||
|
'POST',
|
||||||
|
{
|
||||||
|
url: urlTrim,
|
||||||
|
source_kind: form.source_kind,
|
||||||
|
prefix_path: form.prefix_path?.trim() ?? ''
|
||||||
|
}
|
||||||
|
);
|
||||||
|
previewItems = res.items;
|
||||||
|
previewTotal = res.total;
|
||||||
|
previewTruncated = res.truncated;
|
||||||
|
previewOk = true;
|
||||||
|
} catch (e) {
|
||||||
|
previewError = e instanceof Error ? e.message : String(e);
|
||||||
|
previewItems = [];
|
||||||
|
previewTotal = 0;
|
||||||
|
previewTruncated = false;
|
||||||
|
previewOk = false;
|
||||||
|
} finally {
|
||||||
|
previewLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
const urlTrim = form.url.trim();
|
||||||
|
if (!urlTrim) {
|
||||||
|
notify.error('Укажите URL');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
saving = true;
|
||||||
|
try {
|
||||||
|
const body = {
|
||||||
|
...form,
|
||||||
|
url: urlTrim,
|
||||||
|
source_kind: form.source_kind,
|
||||||
|
prefix_path: form.prefix_path?.trim() ?? ''
|
||||||
|
};
|
||||||
|
if (edit) {
|
||||||
|
await apiMutate(`/v1/modules/${moduleId}/cdn-sources/${edit.id}`, 'PATCH', body);
|
||||||
|
notify.success('Источник обновлён');
|
||||||
|
} else {
|
||||||
|
await apiMutate(`/v1/modules/${moduleId}/cdn-sources`, 'POST', body);
|
||||||
|
notify.success('Источник добавлен');
|
||||||
|
}
|
||||||
|
clearPreview();
|
||||||
|
open = false;
|
||||||
|
await onSaved();
|
||||||
|
} catch (e) {
|
||||||
|
notifyApiError(e);
|
||||||
|
} finally {
|
||||||
|
saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOpenChange(next: boolean) {
|
||||||
|
open = next;
|
||||||
|
if (!next) {
|
||||||
|
clearPreview();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Dialog {open} onOpenChange={handleOpenChange}>
|
||||||
|
<DialogContent class="sm:max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{edit ? 'Редактировать источник' : 'Новый CDN-источник'}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div class="space-y-4 py-2">
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label for="cdn-url">URL</Label>
|
||||||
|
<Input id="cdn-url" placeholder="https://example.com/list.txt" bind:value={form.url} />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label for="cdn-kind">Тип источника</Label>
|
||||||
|
<Select
|
||||||
|
type="single"
|
||||||
|
value={form.source_kind}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
form.source_kind = v || 'plaintext';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="cdn-kind" class="w-full">
|
||||||
|
{form.source_kind === 'json' ? 'json' : 'plaintext'}
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="plaintext">plaintext</SelectItem>
|
||||||
|
<SelectItem value="json">json</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label for="cdn-prefix-path">JSON path (prefix_path)</Label>
|
||||||
|
<Input
|
||||||
|
id="cdn-prefix-path"
|
||||||
|
placeholder="напр. prefixes[] или data.items[].cidr"
|
||||||
|
bind:value={form.prefix_path}
|
||||||
|
/>
|
||||||
|
{#if form.source_kind === 'json' && !form.prefix_path?.trim()}
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
Для JSON укажите путь к полям с CIDR; пустой путь может не дать префиксов.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label for="cdn-comm">Community</Label>
|
||||||
|
<Select
|
||||||
|
type="single"
|
||||||
|
value={nullableSelectValue(form.community_id)}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
form.community_id = fromNullableSelect(v);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="cdn-comm" class="w-full">
|
||||||
|
{form.community_id ? communityLabel(form.community_id, communities) : 'Не выбрано'}
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={NONE_OPTION}>Не выбрано</SelectItem>
|
||||||
|
{#each communities as c (c.id)}
|
||||||
|
<SelectItem value={c.id}>{communityOptionLabel(c)}</SelectItem>
|
||||||
|
{/each}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label for="cdn-interval">Интервал обновления (сек)</Label>
|
||||||
|
<Input
|
||||||
|
id="cdn-interval"
|
||||||
|
type="number"
|
||||||
|
placeholder="3600"
|
||||||
|
bind:value={form.refresh_interval_sec}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-2 rounded-lg border border-border p-3">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onclick={previewCdn}
|
||||||
|
disabled={previewLoading}
|
||||||
|
>
|
||||||
|
{previewLoading ? 'Загрузка…' : 'Предпросмотр'}
|
||||||
|
</Button>
|
||||||
|
{#if previewError}
|
||||||
|
<span class="text-sm text-destructive">{previewError}</span>
|
||||||
|
{:else if previewOk}
|
||||||
|
<span class="text-sm text-muted-foreground">
|
||||||
|
Всего: {previewTotal}{#if previewTruncated}
|
||||||
|
<span class="text-amber-600 dark:text-amber-500"> (обрезано)</span>{/if}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if previewItems.length}
|
||||||
|
<ul class="max-h-48 overflow-y-auto rounded-md border bg-muted/40 p-2 font-mono text-xs">
|
||||||
|
{#each previewItems as item, i (`${i}-${item}`)}
|
||||||
|
<li class="py-0.5">{item}</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onclick={() => handleOpenChange(false)}>Отмена</Button>
|
||||||
|
<Button onclick={save} disabled={saving}>
|
||||||
|
{saving ? 'Сохранение…' : edit ? 'Сохранить' : 'Добавить'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { apiMutate } from '$lib/api/client.js';
|
||||||
|
import type { BgpCommunity, CdnSource } from '$lib/api/types.js';
|
||||||
|
import { formatDateTime } from '$lib/modules/display.js';
|
||||||
|
import {
|
||||||
|
communityLabel,
|
||||||
|
normalizeCdnSourceKind
|
||||||
|
} from '$lib/components/modules/module-helpers.js';
|
||||||
|
import { Badge } from '$lib/ui/core/badge/index.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 ModuleCdnSourceDialog from '$lib/components/modules/ModuleCdnSourceDialog.svelte';
|
||||||
|
import Plus from '@lucide/svelte/icons/plus';
|
||||||
|
import Pencil from '@lucide/svelte/icons/pencil';
|
||||||
|
import Trash2 from '@lucide/svelte/icons/trash-2';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
moduleId: string;
|
||||||
|
sources: CdnSource[];
|
||||||
|
communities: BgpCommunity[];
|
||||||
|
loading?: boolean;
|
||||||
|
onChanged: () => void | Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { moduleId, sources, communities, loading = false, onChanged }: Props = $props();
|
||||||
|
|
||||||
|
let dialogOpen = $state(false);
|
||||||
|
let editTarget = $state<CdnSource | null>(null);
|
||||||
|
let selectedIds = $state(new Set<string>());
|
||||||
|
let deletingBulk = $state(false);
|
||||||
|
|
||||||
|
const selectedCount = $derived(selectedIds.size);
|
||||||
|
const allSelected = $derived(sources.length > 0 && selectedIds.size === sources.length);
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ id: 'select', label: '', class: 'w-10' },
|
||||||
|
{ id: 'url', label: 'URL', sortable: true, sortValue: (s: CdnSource) => s.url },
|
||||||
|
{ id: 'kind', label: 'Тип', sortable: true, sortValue: (s: CdnSource) => s.source_kind },
|
||||||
|
{ id: 'community', label: 'Community' },
|
||||||
|
{
|
||||||
|
id: 'interval',
|
||||||
|
label: 'Интервал',
|
||||||
|
sortable: true,
|
||||||
|
sortValue: (s: CdnSource) => s.refresh_interval_sec ?? 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'refreshed',
|
||||||
|
label: 'Последнее обновление',
|
||||||
|
sortable: true,
|
||||||
|
sortValue: (s: CdnSource) => s.last_refreshed_at ?? ''
|
||||||
|
},
|
||||||
|
{ id: 'actions', label: '', class: 'w-20' }
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const validIds = new Set(sources.map((s) => s.id));
|
||||||
|
selectedIds = new Set([...selectedIds].filter((id) => validIds.has(id)));
|
||||||
|
});
|
||||||
|
|
||||||
|
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(sources.map((s) => s.id)) : new Set<string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
editTarget = null;
|
||||||
|
dialogOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(src: CdnSource) {
|
||||||
|
editTarget = src;
|
||||||
|
dialogOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestDelete(src: CdnSource) {
|
||||||
|
void confirm({
|
||||||
|
title: 'Удалить CDN-источник?',
|
||||||
|
description: src.url,
|
||||||
|
confirmLabel: 'Удалить',
|
||||||
|
destructive: true,
|
||||||
|
onConfirm: async () => {
|
||||||
|
await apiMutate(`/v1/modules/${moduleId}/cdn-sources/${src.id}`, 'DELETE', undefined, {
|
||||||
|
idempotent: false
|
||||||
|
});
|
||||||
|
notify.success('Удалено');
|
||||||
|
await onChanged();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestBulkDelete() {
|
||||||
|
if (selectedCount === 0) return;
|
||||||
|
void confirm({
|
||||||
|
title: 'Удалить выбранные CDN-источники?',
|
||||||
|
description: `Будет удалено: ${selectedCount}`,
|
||||||
|
confirmLabel: 'Удалить',
|
||||||
|
destructive: true,
|
||||||
|
onConfirm: bulkDelete
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bulkDelete() {
|
||||||
|
if (selectedCount === 0) return;
|
||||||
|
deletingBulk = true;
|
||||||
|
let deleted = 0;
|
||||||
|
try {
|
||||||
|
for (const id of selectedIds) {
|
||||||
|
try {
|
||||||
|
await apiMutate(`/v1/modules/${moduleId}/cdn-sources/${id}`, 'DELETE', undefined, {
|
||||||
|
idempotent: false
|
||||||
|
});
|
||||||
|
deleted += 1;
|
||||||
|
} catch (e) {
|
||||||
|
notifyApiError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (deleted > 0) notify.success(`Удалено CDN-источников: ${deleted}`);
|
||||||
|
await onChanged();
|
||||||
|
} finally {
|
||||||
|
deletingBulk = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<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">CDN-источники</CardTitle>
|
||||||
|
<CardDescription>URL источников для скачивания списков CIDR.</CardDescription>
|
||||||
|
</div>
|
||||||
|
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2 self-start sm:self-auto">
|
||||||
|
<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={sources}
|
||||||
|
rowKey={(s) => s.id}
|
||||||
|
{loading}
|
||||||
|
emptyTitle="Нет CDN-источников"
|
||||||
|
emptyDescription="Добавьте URL для загрузки списков CIDR."
|
||||||
|
>
|
||||||
|
{#snippet toolbar()}
|
||||||
|
{#if sources.length > 0}
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
checked={allSelected}
|
||||||
|
onCheckedChange={(v) => toggleAll(v === true)}
|
||||||
|
aria-label="Выбрать все CDN-источники"
|
||||||
|
/>
|
||||||
|
<span class="text-sm text-muted-foreground">Выбрать все</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
{#snippet cell({ row: src, column })}
|
||||||
|
{#if column.id === 'select'}
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedIds.has(src.id)}
|
||||||
|
aria-label="Выбрать CDN-источник"
|
||||||
|
onCheckedChange={() => toggleSelection(src.id)}
|
||||||
|
/>
|
||||||
|
{:else if column.id === 'url'}
|
||||||
|
<span class="max-w-xs truncate font-mono text-xs" title={src.url}>{src.url}</span>
|
||||||
|
{:else if column.id === 'kind'}
|
||||||
|
<div class="flex flex-col gap-0.5">
|
||||||
|
<Badge variant="outline">{normalizeCdnSourceKind(src.source_kind)}</Badge>
|
||||||
|
{#if src.prefix_path?.trim()}
|
||||||
|
<span
|
||||||
|
class="font-mono text-xs break-all text-muted-foreground"
|
||||||
|
title={src.prefix_path}>{src.prefix_path}</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else if column.id === 'community'}
|
||||||
|
<span class="text-sm text-muted-foreground">
|
||||||
|
{communityLabel(src.community_id, communities)}
|
||||||
|
</span>
|
||||||
|
{:else if column.id === 'interval'}
|
||||||
|
<span class="text-sm text-muted-foreground">
|
||||||
|
{src.refresh_interval_sec != null ? `${src.refresh_interval_sec}с` : '—'}
|
||||||
|
</span>
|
||||||
|
{:else if column.id === 'refreshed'}
|
||||||
|
<span class="text-sm whitespace-nowrap text-muted-foreground">
|
||||||
|
{formatDateTime(src.last_refreshed_at)}
|
||||||
|
</span>
|
||||||
|
{:else if column.id === 'actions'}
|
||||||
|
<div class="flex gap-1">
|
||||||
|
<Button variant="ghost" size="icon-sm" onclick={() => openEdit(src)}>
|
||||||
|
<Pencil class="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
class="text-destructive"
|
||||||
|
onclick={() => requestDelete(src)}
|
||||||
|
>
|
||||||
|
<Trash2 class="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
</AppDataTable>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<ModuleCdnSourceDialog
|
||||||
|
bind:open={dialogOpen}
|
||||||
|
{moduleId}
|
||||||
|
edit={editTarget}
|
||||||
|
{communities}
|
||||||
|
onSaved={onChanged}
|
||||||
|
onClose={() => {
|
||||||
|
editTarget = null;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { apiMutate } from '$lib/api/client.js';
|
||||||
|
import type { ModuleCreate } from '$lib/api/types.js';
|
||||||
|
import { moduleTypeRu } from '$lib/ui-labels.js';
|
||||||
|
import { Button } from '$lib/ui/core/button/index.js';
|
||||||
|
import { Input } from '$lib/ui/core/input/index.js';
|
||||||
|
import { Label } from '$lib/ui/core/label/index.js';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
DialogDescription
|
||||||
|
} from '$lib/ui/core/dialog/index.js';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/ui/core/select/index.js';
|
||||||
|
import { Switch } from '$lib/ui/core/switch/index.js';
|
||||||
|
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onCreated: () => void | Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { open = $bindable(), onClose, onCreated }: Props = $props();
|
||||||
|
|
||||||
|
let saving = $state(false);
|
||||||
|
let form = $state<ModuleCreate>({
|
||||||
|
type: 'AS_PREFIXES',
|
||||||
|
name: '',
|
||||||
|
enabled: true,
|
||||||
|
priority: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
const moduleTypes = [
|
||||||
|
{ value: 'AS_PREFIXES', label: moduleTypeRu('AS_PREFIXES') },
|
||||||
|
{ value: 'CDN_CIDRS', label: moduleTypeRu('CDN_CIDRS') },
|
||||||
|
{ value: 'DOMAINS', label: moduleTypeRu('DOMAINS') },
|
||||||
|
{ value: 'IP_RANGES', label: moduleTypeRu('IP_RANGES') }
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
form = { type: 'AS_PREFIXES', name: '', enabled: true, priority: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function create() {
|
||||||
|
if (!form.name.trim()) {
|
||||||
|
notify.error('Укажите название модуля');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
saving = true;
|
||||||
|
try {
|
||||||
|
await apiMutate('/v1/modules', 'POST', form);
|
||||||
|
notify.success('Модуль создан');
|
||||||
|
open = false;
|
||||||
|
resetForm();
|
||||||
|
await onCreated();
|
||||||
|
} catch (e) {
|
||||||
|
notifyApiError(e);
|
||||||
|
} finally {
|
||||||
|
saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOpenChange(next: boolean) {
|
||||||
|
open = next;
|
||||||
|
if (!next) {
|
||||||
|
onClose();
|
||||||
|
resetForm();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Dialog {open} onOpenChange={handleOpenChange}>
|
||||||
|
<DialogContent class="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Новый модуль</DialogTitle>
|
||||||
|
<DialogDescription>Создание нового модуля префиксов.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div class="space-y-4 py-2">
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label for="m-name">Название</Label>
|
||||||
|
<Input id="m-name" bind:value={form.name} placeholder="my-asn-module" />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label for="m-type">Тип</Label>
|
||||||
|
<Select
|
||||||
|
type="single"
|
||||||
|
value={form.type}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
if (v) form.type = v as typeof form.type;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="m-type" class="w-full">
|
||||||
|
{moduleTypes.find((t) => t.value === form.type)?.label ?? 'Выберите тип'}
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{#each moduleTypes as t (t.value)}
|
||||||
|
<SelectItem value={t.value}>{t.label}</SelectItem>
|
||||||
|
{/each}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label for="m-priority">Приоритет</Label>
|
||||||
|
<Input id="m-priority" type="number" bind:value={form.priority} />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label for="m-interval">Интервал (сек)</Label>
|
||||||
|
<Input
|
||||||
|
id="m-interval"
|
||||||
|
type="number"
|
||||||
|
placeholder="3600"
|
||||||
|
bind:value={form.refresh_interval_sec}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="flex flex-row items-center justify-between gap-4 rounded-lg border border-border bg-muted/30 p-3"
|
||||||
|
>
|
||||||
|
<div class="grid min-w-0 flex-1 gap-1 pr-2">
|
||||||
|
<Label for="m-enabled" class="leading-snug text-foreground">Включён</Label>
|
||||||
|
<p class="text-xs leading-snug text-muted-foreground">
|
||||||
|
Модуль участвует в сборке ревизий, если включён.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id="m-enabled"
|
||||||
|
class="shrink-0"
|
||||||
|
checked={form.enabled !== false}
|
||||||
|
onCheckedChange={(v) => {
|
||||||
|
form = { ...form, enabled: v };
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onclick={() => handleOpenChange(false)}>Отмена</Button>
|
||||||
|
<Button onclick={create} disabled={saving}>{saving ? 'Создание…' : 'Создать'}</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
|
import type { ModuleRow } from '$lib/api/types.js';
|
||||||
|
import { moduleEnabledRu, moduleEnabledBadgeVariant, moduleTypeRu } from '$lib/ui-labels.js';
|
||||||
|
import { Badge } from '$lib/ui/core/badge/index.js';
|
||||||
|
import { Button } from '$lib/ui/core/button/index.js';
|
||||||
|
import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
|
||||||
|
import ArrowLeft from '@lucide/svelte/icons/arrow-left';
|
||||||
|
import Pencil from '@lucide/svelte/icons/pencil';
|
||||||
|
import Trash2 from '@lucide/svelte/icons/trash-2';
|
||||||
|
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
|
||||||
|
import Blocks from '@lucide/svelte/icons/blocks';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
mod: ModuleRow;
|
||||||
|
refreshing: boolean;
|
||||||
|
onRefresh: () => void;
|
||||||
|
onEdit: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { mod, refreshing, onRefresh, onEdit, onDelete }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex min-w-0 items-start gap-2">
|
||||||
|
<Button variant="ghost" size="icon-sm" class="mt-1 shrink-0" href={resolve('/modules')}>
|
||||||
|
<ArrowLeft class="size-4" />
|
||||||
|
</Button>
|
||||||
|
<PageHeader
|
||||||
|
class="min-w-0 flex-1"
|
||||||
|
title={mod.name}
|
||||||
|
description={mod.id}
|
||||||
|
icon={Blocks}
|
||||||
|
iconClass="bg-chart-2/15 text-chart-2"
|
||||||
|
>
|
||||||
|
{#snippet actions()}
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<Badge variant="outline">{moduleTypeRu(mod.type)}</Badge>
|
||||||
|
<Badge variant={moduleEnabledBadgeVariant(!!mod.enabled)} class="text-xs">
|
||||||
|
{moduleEnabledRu(!!mod.enabled)}
|
||||||
|
</Badge>
|
||||||
|
<Button variant="outline" size="sm" onclick={onRefresh} disabled={refreshing}>
|
||||||
|
<RefreshCw class={refreshing ? 'animate-spin' : ''} />
|
||||||
|
Обновить
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onclick={onEdit}>
|
||||||
|
<Pencil />
|
||||||
|
Редактировать
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" size="sm" onclick={onDelete}>
|
||||||
|
<Trash2 />
|
||||||
|
Удалить
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
</PageHeader>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
<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 selectedCount = $derived(selectedIds.size);
|
||||||
|
const allSelected = $derived(entries.length > 0 && selectedIds.size === entries.length);
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const validIds = new Set(entries.map((e) => e.id));
|
||||||
|
selectedIds = new Set([...selectedIds].filter((id) => validIds.has(id)));
|
||||||
|
});
|
||||||
|
|
||||||
|
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 selectedIds) {
|
||||||
|
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;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { apiMutate } from '$lib/api/client.js';
|
||||||
|
import type { BgpCommunity, DomainEntry, DomainEntryCreate } from '$lib/api/types.js';
|
||||||
|
import {
|
||||||
|
communityLabel,
|
||||||
|
communityOptionLabel,
|
||||||
|
fromNullableSelect,
|
||||||
|
NONE_OPTION,
|
||||||
|
nullableSelectValue
|
||||||
|
} from '$lib/components/modules/module-helpers.js';
|
||||||
|
import { Button } from '$lib/ui/core/button/index.js';
|
||||||
|
import { Input } from '$lib/ui/core/input/index.js';
|
||||||
|
import { Label } from '$lib/ui/core/label/index.js';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter
|
||||||
|
} from '$lib/ui/core/dialog/index.js';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/ui/core/select/index.js';
|
||||||
|
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
open: boolean;
|
||||||
|
moduleId: string;
|
||||||
|
edit: DomainEntry | null;
|
||||||
|
communities: BgpCommunity[];
|
||||||
|
onSaved: () => void | Promise<void>;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { open = $bindable(), moduleId, edit, communities, onSaved, onClose }: Props = $props();
|
||||||
|
|
||||||
|
let saving = $state(false);
|
||||||
|
let form = $state<DomainEntryCreate>({ fqdn: '', community_id: null });
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (open) {
|
||||||
|
form = edit
|
||||||
|
? { fqdn: edit.fqdn, community_id: edit.community_id }
|
||||||
|
: { fqdn: '', community_id: null };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
saving = true;
|
||||||
|
try {
|
||||||
|
if (edit) {
|
||||||
|
await apiMutate(`/v1/modules/${moduleId}/domain-entries/${edit.id}`, 'PATCH', form);
|
||||||
|
notify.success('Домен обновлён');
|
||||||
|
} else {
|
||||||
|
await apiMutate(`/v1/modules/${moduleId}/domain-entries`, 'POST', form);
|
||||||
|
notify.success('Домен добавлен');
|
||||||
|
}
|
||||||
|
open = false;
|
||||||
|
await onSaved();
|
||||||
|
} catch (e) {
|
||||||
|
notifyApiError(e);
|
||||||
|
} finally {
|
||||||
|
saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOpenChange(next: boolean) {
|
||||||
|
open = next;
|
||||||
|
if (!next) onClose();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Dialog {open} onOpenChange={handleOpenChange}>
|
||||||
|
<DialogContent class="sm:max-w-sm">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{edit ? 'Редактировать домен' : 'Новый домен'}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div class="space-y-4 py-2">
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label for="dom-fqdn">FQDN</Label>
|
||||||
|
<Input id="dom-fqdn" placeholder="example.com" bind:value={form.fqdn} />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label for="dom-comm">Community</Label>
|
||||||
|
<Select
|
||||||
|
type="single"
|
||||||
|
value={nullableSelectValue(form.community_id)}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
form.community_id = fromNullableSelect(v);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="dom-comm" class="w-full">
|
||||||
|
{form.community_id ? communityLabel(form.community_id, communities) : 'Не выбрано'}
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={NONE_OPTION}>Не выбрано</SelectItem>
|
||||||
|
{#each communities as c (c.id)}
|
||||||
|
<SelectItem value={c.id}>{communityOptionLabel(c)}</SelectItem>
|
||||||
|
{/each}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onclick={() => handleOpenChange(false)}>Отмена</Button>
|
||||||
|
<Button onclick={save} disabled={saving}>
|
||||||
|
{saving ? 'Сохранение…' : edit ? 'Сохранить' : 'Добавить'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { apiMutate } from '$lib/api/client.js';
|
||||||
|
import type {
|
||||||
|
BgpCommunity,
|
||||||
|
DohProfile,
|
||||||
|
DohResolverPolicy,
|
||||||
|
ModulePatch,
|
||||||
|
ModuleRow
|
||||||
|
} from '$lib/api/types.js';
|
||||||
|
import { dohPolicyRu } from '$lib/ui-labels.js';
|
||||||
|
import {
|
||||||
|
communityLabel,
|
||||||
|
communityOptionLabel,
|
||||||
|
fromNullableSelect,
|
||||||
|
moduleDohProfileIds,
|
||||||
|
NONE_OPTION,
|
||||||
|
nullableSelectValue
|
||||||
|
} from '$lib/components/modules/module-helpers.js';
|
||||||
|
import { Button } from '$lib/ui/core/button/index.js';
|
||||||
|
import { Input } from '$lib/ui/core/input/index.js';
|
||||||
|
import { Label } from '$lib/ui/core/label/index.js';
|
||||||
|
import { Checkbox } from '$lib/ui/core/checkbox/index.js';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter
|
||||||
|
} from '$lib/ui/core/dialog/index.js';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/ui/core/select/index.js';
|
||||||
|
import { Switch } from '$lib/ui/core/switch/index.js';
|
||||||
|
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
|
||||||
|
|
||||||
|
const dohPolicyOptions: { value: DohResolverPolicy; label: string; hint: string }[] = [
|
||||||
|
{
|
||||||
|
value: 'primary_only',
|
||||||
|
label: 'Только первый',
|
||||||
|
hint: 'Используется первый выбранный DoH-профиль.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'failover',
|
||||||
|
label: 'Резервирование',
|
||||||
|
hint: 'Профили по порядку до первого успешного ответа.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'union',
|
||||||
|
label: 'Объединение',
|
||||||
|
hint: 'Все A/AAAA со всех профилей (geo-split DNS).'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
open: boolean;
|
||||||
|
mod: ModuleRow;
|
||||||
|
moduleId: string;
|
||||||
|
communities: BgpCommunity[];
|
||||||
|
dohProfiles: DohProfile[];
|
||||||
|
onSaved: () => void | Promise<void>;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
let {
|
||||||
|
open = $bindable(),
|
||||||
|
mod,
|
||||||
|
moduleId,
|
||||||
|
communities,
|
||||||
|
dohProfiles,
|
||||||
|
onSaved,
|
||||||
|
onClose
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let editForm = $state<ModulePatch>({});
|
||||||
|
let editSaving = $state(false);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (open && mod) {
|
||||||
|
editForm = {
|
||||||
|
name: mod.name,
|
||||||
|
enabled: mod.enabled,
|
||||||
|
priority: mod.priority,
|
||||||
|
refresh_interval_sec: mod.refresh_interval_sec,
|
||||||
|
cron_expr: mod.cron_expr,
|
||||||
|
default_community_id: mod.default_community_id,
|
||||||
|
doh_profile_ids: moduleDohProfileIds(mod),
|
||||||
|
doh_resolver_policy: mod.doh_resolver_policy ?? 'primary_only'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function toggleEditDohProfile(id: string, checked: boolean) {
|
||||||
|
let ids = [...(editForm.doh_profile_ids ?? [])];
|
||||||
|
if (checked) {
|
||||||
|
if (!ids.includes(id)) ids.push(id);
|
||||||
|
} else {
|
||||||
|
ids = ids.filter((x) => x !== id);
|
||||||
|
}
|
||||||
|
editForm = { ...editForm, doh_profile_ids: ids };
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEditDohProfileSelected(id: string): boolean {
|
||||||
|
return (editForm.doh_profile_ids ?? []).includes(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveMod() {
|
||||||
|
editSaving = true;
|
||||||
|
try {
|
||||||
|
const cron =
|
||||||
|
typeof editForm.cron_expr === 'string' ? editForm.cron_expr.trim() : editForm.cron_expr;
|
||||||
|
const intervalRaw = editForm.refresh_interval_sec;
|
||||||
|
const interval =
|
||||||
|
intervalRaw === null || intervalRaw === undefined ? null : Number(intervalRaw);
|
||||||
|
const payload: ModulePatch = {
|
||||||
|
...editForm,
|
||||||
|
cron_expr: cron ? cron : null,
|
||||||
|
refresh_interval_sec: Number.isFinite(interval) ? interval : null,
|
||||||
|
default_community_id: fromNullableSelect(
|
||||||
|
nullableSelectValue(editForm.default_community_id)
|
||||||
|
),
|
||||||
|
doh_profile_ids: editForm.doh_profile_ids ?? [],
|
||||||
|
doh_resolver_policy: editForm.doh_resolver_policy ?? 'primary_only'
|
||||||
|
};
|
||||||
|
await apiMutate<ModuleRow>(`/v1/modules/${moduleId}`, 'PATCH', payload);
|
||||||
|
notify.success('Модуль обновлён');
|
||||||
|
open = false;
|
||||||
|
await onSaved();
|
||||||
|
} catch (e) {
|
||||||
|
notifyApiError(e);
|
||||||
|
} finally {
|
||||||
|
editSaving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOpenChange(next: boolean) {
|
||||||
|
open = next;
|
||||||
|
if (!next) onClose();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Dialog {open} onOpenChange={handleOpenChange}>
|
||||||
|
<DialogContent class="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Редактировать модуль</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div class="space-y-4 py-2">
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label for="e-name">Название</Label>
|
||||||
|
<Input id="e-name" bind:value={editForm.name} />
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label for="e-priority">Приоритет</Label>
|
||||||
|
<Input id="e-priority" type="number" bind:value={editForm.priority} />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label for="e-interval">Интервал (сек)</Label>
|
||||||
|
<Input id="e-interval" type="number" bind:value={editForm.refresh_interval_sec} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label for="e-cron">Cron-выражение</Label>
|
||||||
|
<Input id="e-cron" placeholder="0 */6 * * *" bind:value={editForm.cron_expr} />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label for="e-comm">Community по умолчанию</Label>
|
||||||
|
<Select
|
||||||
|
type="single"
|
||||||
|
value={nullableSelectValue(editForm.default_community_id)}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
editForm.default_community_id = fromNullableSelect(v);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="e-comm" class="w-full">
|
||||||
|
{editForm.default_community_id
|
||||||
|
? communityLabel(editForm.default_community_id, communities)
|
||||||
|
: 'Не выбрано'}
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={NONE_OPTION}>Не выбрано</SelectItem>
|
||||||
|
{#each communities as c (c.id)}
|
||||||
|
<SelectItem value={c.id}>{communityOptionLabel(c)}</SelectItem>
|
||||||
|
{/each}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="h-7 px-2"
|
||||||
|
onclick={() => {
|
||||||
|
editForm.default_community_id = null;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Сбросить
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{#if mod.type === 'DOMAINS'}
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label for="e-doh-policy">Политика DoH</Label>
|
||||||
|
<Select
|
||||||
|
type="single"
|
||||||
|
value={editForm.doh_resolver_policy ?? 'primary_only'}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
if (v) editForm.doh_resolver_policy = v as DohResolverPolicy;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="e-doh-policy" class="w-full">
|
||||||
|
{dohPolicyRu(editForm.doh_resolver_policy)}
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{#each dohPolicyOptions as opt (opt.value)}
|
||||||
|
<SelectItem value={opt.value}>{opt.label}</SelectItem>
|
||||||
|
{/each}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
{dohPolicyOptions.find(
|
||||||
|
(o) => o.value === (editForm.doh_resolver_policy ?? 'primary_only')
|
||||||
|
)?.hint}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>DoH-профили</Label>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
Порядок выбора = порядок в списке (сверху вниз).
|
||||||
|
</p>
|
||||||
|
<div class="max-h-40 space-y-2 overflow-y-auto rounded-md border p-3">
|
||||||
|
{#each dohProfiles as d (d.id)}
|
||||||
|
<label class="flex items-start gap-2 text-sm">
|
||||||
|
<Checkbox
|
||||||
|
checked={isEditDohProfileSelected(d.id)}
|
||||||
|
onCheckedChange={(v) => toggleEditDohProfile(d.id, v === true)}
|
||||||
|
/>
|
||||||
|
<span class="min-w-0 break-all">
|
||||||
|
<span class="font-medium">{d.name?.trim() ? d.name : d.url}</span>
|
||||||
|
{#if d.name?.trim()}
|
||||||
|
<span class="block font-mono text-xs text-muted-foreground">{d.url}</span>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
{:else}
|
||||||
|
<p class="text-xs text-muted-foreground">Нет профилей — создайте в справочниках.</p>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="h-7 px-2"
|
||||||
|
onclick={() => {
|
||||||
|
editForm = { ...editForm, doh_profile_ids: [] };
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Сбросить профили
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div
|
||||||
|
class="flex flex-row items-center justify-between gap-4 rounded-lg border border-border bg-muted/30 p-3"
|
||||||
|
>
|
||||||
|
<div class="grid min-w-0 flex-1 gap-1 pr-2">
|
||||||
|
<Label for="e-enabled" class="leading-snug text-foreground">Включён</Label>
|
||||||
|
<p class="text-xs leading-snug text-muted-foreground">
|
||||||
|
Отключённые модули не участвуют в обновлении конфигурации.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id="e-enabled"
|
||||||
|
class="shrink-0"
|
||||||
|
checked={editForm.enabled !== false}
|
||||||
|
onCheckedChange={(v) => {
|
||||||
|
editForm = { ...editForm, enabled: v };
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onclick={() => handleOpenChange(false)}>Отмена</Button>
|
||||||
|
<Button onclick={saveMod} disabled={editSaving}>
|
||||||
|
{editSaving ? 'Сохранение…' : 'Сохранить'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { apiMutate } from '$lib/api/client.js';
|
||||||
|
import type { BgpCommunity, IpRangeEntry, IpRangeEntryCreate } from '$lib/api/types.js';
|
||||||
|
import { communityLabel, communityOptionLabel } from '$lib/components/modules/module-helpers.js';
|
||||||
|
import { Button } from '$lib/ui/core/button/index.js';
|
||||||
|
import { Input } from '$lib/ui/core/input/index.js';
|
||||||
|
import { Label } from '$lib/ui/core/label/index.js';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter
|
||||||
|
} from '$lib/ui/core/dialog/index.js';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/ui/core/select/index.js';
|
||||||
|
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
open: boolean;
|
||||||
|
moduleId: string;
|
||||||
|
edit: IpRangeEntry | null;
|
||||||
|
communities: BgpCommunity[];
|
||||||
|
onSaved: () => void | Promise<void>;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { open = $bindable(), moduleId, edit, communities, onSaved, onClose }: Props = $props();
|
||||||
|
|
||||||
|
let saving = $state(false);
|
||||||
|
let form = $state<IpRangeEntryCreate>({ prefix: '', community_id: '' });
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (open) {
|
||||||
|
form = edit
|
||||||
|
? { prefix: edit.prefix, community_id: edit.community_id }
|
||||||
|
: { prefix: '', community_id: '' };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
saving = true;
|
||||||
|
try {
|
||||||
|
if (edit) {
|
||||||
|
await apiMutate(`/v1/modules/${moduleId}/ip-range-entries/${edit.id}`, 'PATCH', form);
|
||||||
|
notify.success('Диапазон обновлён');
|
||||||
|
} else {
|
||||||
|
await apiMutate(`/v1/modules/${moduleId}/ip-range-entries`, 'POST', form);
|
||||||
|
notify.success('Диапазон добавлен');
|
||||||
|
}
|
||||||
|
open = false;
|
||||||
|
await onSaved();
|
||||||
|
} catch (e) {
|
||||||
|
notifyApiError(e);
|
||||||
|
} finally {
|
||||||
|
saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOpenChange(next: boolean) {
|
||||||
|
open = next;
|
||||||
|
if (!next) onClose();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Dialog {open} onOpenChange={handleOpenChange}>
|
||||||
|
<DialogContent class="sm:max-w-sm">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{edit ? 'Редактировать диапазон' : 'Новый IP-диапазон'}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div class="space-y-4 py-2">
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label for="ip-prefix">Префикс (CIDR)</Label>
|
||||||
|
<Input id="ip-prefix" placeholder="203.0.113.0/24" bind:value={form.prefix} />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label for="ip-comm">Community (обязательно)</Label>
|
||||||
|
<Select
|
||||||
|
type="single"
|
||||||
|
value={form.community_id}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
form.community_id = v;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="ip-comm" class="w-full">
|
||||||
|
{form.community_id
|
||||||
|
? communityLabel(form.community_id, communities)
|
||||||
|
: 'Выберите community'}
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{#each communities as c (c.id)}
|
||||||
|
<SelectItem value={c.id}>{communityOptionLabel(c)}</SelectItem>
|
||||||
|
{/each}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onclick={() => handleOpenChange(false)}>Отмена</Button>
|
||||||
|
<Button onclick={save} disabled={saving}>
|
||||||
|
{saving ? 'Сохранение…' : edit ? 'Сохранить' : 'Добавить'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { apiFetch, apiMutate } from '$lib/api/client.js';
|
||||||
|
import type { BgpCommunity, IpRangeEntry, 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 ModuleIpRangeEntryDialog from '$lib/components/modules/ModuleIpRangeEntryDialog.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: IpRangeEntry[];
|
||||||
|
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<IpRangeEntry | 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 selectedCount = $derived(selectedIds.size);
|
||||||
|
const allSelected = $derived(entries.length > 0 && selectedIds.size === entries.length);
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ id: 'select', label: '', class: 'w-10' },
|
||||||
|
{
|
||||||
|
id: 'prefix',
|
||||||
|
label: 'Префикс (CIDR)',
|
||||||
|
sortable: true,
|
||||||
|
sortValue: (e: IpRangeEntry) => e.prefix
|
||||||
|
},
|
||||||
|
{ id: 'community', label: 'Community' },
|
||||||
|
{ id: 'actions', label: '', class: 'w-20' }
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const validIds = new Set(entries.map((e) => e.id));
|
||||||
|
selectedIds = new Set([...selectedIds].filter((id) => validIds.has(id)));
|
||||||
|
});
|
||||||
|
|
||||||
|
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: IpRangeEntry) {
|
||||||
|
editTarget = entry;
|
||||||
|
dialogOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestDelete(entry: IpRangeEntry) {
|
||||||
|
void confirm({
|
||||||
|
title: 'Удалить диапазон?',
|
||||||
|
description: entry.prefix,
|
||||||
|
confirmLabel: 'Удалить',
|
||||||
|
destructive: true,
|
||||||
|
onConfirm: async () => {
|
||||||
|
await apiMutate(
|
||||||
|
`/v1/modules/${moduleId}/ip-range-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 selectedIds) {
|
||||||
|
try {
|
||||||
|
await apiMutate(`/v1/modules/${moduleId}/ip-range-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">IP-диапазоны</CardTitle>
|
||||||
|
<CardDescription>Статические CIDR для анонса.</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="Добавьте CIDR или импортируйте 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.prefix}`}
|
||||||
|
onCheckedChange={() => toggleSelection(entry.id)}
|
||||||
|
/>
|
||||||
|
{:else if column.id === 'prefix'}
|
||||||
|
<span class="font-mono">{entry.prefix}</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>
|
||||||
|
|
||||||
|
<ModuleIpRangeEntryDialog
|
||||||
|
bind:open={dialogOpen}
|
||||||
|
{moduleId}
|
||||||
|
edit={editTarget}
|
||||||
|
{communities}
|
||||||
|
onSaved={onChanged}
|
||||||
|
onClose={() => {
|
||||||
|
editTarget = null;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { AsEntry, BgpCommunity, DohProfile, ModuleRow } from '$lib/api/types.js';
|
||||||
|
import { formatDateTime, moduleIntervalLabel } from '$lib/modules/display.js';
|
||||||
|
import { dohPolicyRu } from '$lib/ui-labels.js';
|
||||||
|
import {
|
||||||
|
communityLabel,
|
||||||
|
dohProfileLabel,
|
||||||
|
moduleDohProfileIds
|
||||||
|
} from '$lib/components/modules/module-helpers.js';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '$lib/ui/core/card/index.js';
|
||||||
|
import CardSkeleton from '$lib/ui/patterns/feedback/card-skeleton.svelte';
|
||||||
|
import { cn } from '$lib/utils.js';
|
||||||
|
import ArrowDownUp from '@lucide/svelte/icons/arrow-down-up';
|
||||||
|
import Timer from '@lucide/svelte/icons/timer';
|
||||||
|
import ShieldCheck from '@lucide/svelte/icons/shield-check';
|
||||||
|
import Network from '@lucide/svelte/icons/network';
|
||||||
|
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
mod: ModuleRow | null;
|
||||||
|
communities: BgpCommunity[];
|
||||||
|
dohProfiles: DohProfile[];
|
||||||
|
asEntries: AsEntry[];
|
||||||
|
loading?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { mod, communities, dohProfiles, asEntries, loading = false }: Props = $props();
|
||||||
|
|
||||||
|
const asPrefixTotal = $derived(
|
||||||
|
asEntries.reduce((acc, entry) => acc + (entry.prefix_count ?? 0), 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
const statAccents = [
|
||||||
|
{
|
||||||
|
border: 'border-l-chart-3',
|
||||||
|
bg: 'bg-chart-3/5',
|
||||||
|
iconBg: 'bg-chart-3/15',
|
||||||
|
iconText: 'text-chart-3'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
border: 'border-l-chart-5',
|
||||||
|
bg: 'bg-chart-5/5',
|
||||||
|
iconBg: 'bg-chart-5/15',
|
||||||
|
iconText: 'text-chart-5'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
border: 'border-l-chart-4',
|
||||||
|
bg: 'bg-chart-4/5',
|
||||||
|
iconBg: 'bg-chart-4/15',
|
||||||
|
iconText: 'text-chart-4'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
border: 'border-l-chart-2',
|
||||||
|
bg: 'bg-chart-2/5',
|
||||||
|
iconBg: 'bg-chart-2/15',
|
||||||
|
iconText: 'text-chart-2'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
border: 'border-l-muted-foreground',
|
||||||
|
bg: 'bg-muted/30',
|
||||||
|
iconBg: 'bg-muted',
|
||||||
|
iconText: 'text-muted-foreground'
|
||||||
|
}
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const kpiCards = $derived.by(() => {
|
||||||
|
if (!mod) return [];
|
||||||
|
const dohIds = moduleDohProfileIds(mod);
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'priority',
|
||||||
|
label: 'Приоритет',
|
||||||
|
value: String(mod.priority ?? 0),
|
||||||
|
description: 'порядок в сборке ревизии',
|
||||||
|
icon: ArrowDownUp,
|
||||||
|
accent: statAccents[0]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'interval',
|
||||||
|
label: 'Интервал',
|
||||||
|
value: moduleIntervalLabel(mod),
|
||||||
|
description: 'refresh_interval_sec / cron',
|
||||||
|
icon: Timer,
|
||||||
|
accent: statAccents[1],
|
||||||
|
mono: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'doh',
|
||||||
|
label: 'DoH',
|
||||||
|
value: mod.type === 'DOMAINS' ? dohPolicyRu(mod.doh_resolver_policy) : '—',
|
||||||
|
description:
|
||||||
|
mod.type === 'DOMAINS'
|
||||||
|
? dohIds.length
|
||||||
|
? dohIds.map((id) => dohProfileLabel(id, dohProfiles)).join('; ')
|
||||||
|
: 'Системный DNS'
|
||||||
|
: 'не применимо',
|
||||||
|
icon: Network,
|
||||||
|
accent: statAccents[2]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'community',
|
||||||
|
label: 'Community по умолч.',
|
||||||
|
value: communityLabel(mod.default_community_id, communities),
|
||||||
|
description: 'для записей без своего community',
|
||||||
|
icon: ShieldCheck,
|
||||||
|
accent: statAccents[3]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'refreshed',
|
||||||
|
label: 'Последнее обновление',
|
||||||
|
value: formatDateTime(mod.last_refreshed_at),
|
||||||
|
description:
|
||||||
|
mod.type === 'AS_PREFIXES'
|
||||||
|
? `ASN: ${asEntries.length}, префиксов: ${asPrefixTotal}`
|
||||||
|
: 'время последнего refresh',
|
||||||
|
icon: RefreshCw,
|
||||||
|
accent: statAccents[4]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-5">
|
||||||
|
{#if loading}
|
||||||
|
{#each Array(5) as _, i (i)}
|
||||||
|
<CardSkeleton />
|
||||||
|
{/each}
|
||||||
|
{:else}
|
||||||
|
{#each kpiCards as card (card.id)}
|
||||||
|
{@const Icon = card.icon}
|
||||||
|
{@const a = card.accent}
|
||||||
|
<Card class={cn('overflow-hidden border-l-4 shadow-sm', a.border, a.bg)}>
|
||||||
|
<CardHeader class="pb-2">
|
||||||
|
<p class="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
|
<span
|
||||||
|
class={cn('flex size-7 shrink-0 items-center justify-center rounded-md', a.iconBg)}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<Icon class={cn('size-3.5', a.iconText)} />
|
||||||
|
</span>
|
||||||
|
{card.label}
|
||||||
|
</p>
|
||||||
|
<CardTitle
|
||||||
|
class={cn('text-base font-semibold break-all', card.mono ? 'font-mono text-sm' : '')}
|
||||||
|
>
|
||||||
|
{card.value}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p class="line-clamp-2 text-xs text-muted-foreground">{card.description}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
|
import type { AsEntry, CdnSource, DomainEntry, IpRangeEntry, ModuleRow } from '$lib/api/types.js';
|
||||||
|
import { Button } from '$lib/ui/core/button/index.js';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
CardDescription
|
||||||
|
} from '$lib/ui/core/card/index.js';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
mod: ModuleRow;
|
||||||
|
asEntries: AsEntry[];
|
||||||
|
cdnSources: CdnSource[];
|
||||||
|
domainEntries: DomainEntry[];
|
||||||
|
ipEntries: IpRangeEntry[];
|
||||||
|
};
|
||||||
|
|
||||||
|
let { mod, asEntries, cdnSources, domainEntries, ipEntries }: Props = $props();
|
||||||
|
|
||||||
|
const asPrefixTotal = $derived(
|
||||||
|
asEntries.reduce((acc, entry) => acc + (entry.prefix_count ?? 0), 0)
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle class="text-base">Операционный отчёт модуля</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Читаемая сводка по данным модуля: источники, объёмы и ожидаемый результат для
|
||||||
|
refresh/агрегации.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="grid gap-3 md:grid-cols-2">
|
||||||
|
{#if mod.type === 'DOMAINS'}
|
||||||
|
<div class="rounded-lg border p-3">
|
||||||
|
<p class="text-sm font-medium">Домены и ожидаемые IP</p>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
После refresh домены резолвятся в IP и конвертируются в префиксы.
|
||||||
|
</p>
|
||||||
|
<div class="mt-2 space-y-1">
|
||||||
|
{#each domainEntries.slice(0, 8) as entry (entry.id)}
|
||||||
|
<p class="font-mono text-xs break-all">{entry.fqdn}</p>
|
||||||
|
{:else}
|
||||||
|
<p class="text-xs text-muted-foreground">Нет доменов</p>
|
||||||
|
{/each}
|
||||||
|
{#if domainEntries.length > 8}
|
||||||
|
<p class="text-xs text-muted-foreground">…и ещё {domainEntries.length - 8}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if mod.type === 'AS_PREFIXES'}
|
||||||
|
<div class="rounded-lg border p-3">
|
||||||
|
<p class="text-sm font-medium">ASN и число полученных префиксов</p>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
Счётчик префиксов обновляется после успешного refresh (RIPEstat).
|
||||||
|
</p>
|
||||||
|
<div class="mt-2 space-y-1">
|
||||||
|
<p class="text-xs">
|
||||||
|
Всего ASN: <span class="font-semibold">{asEntries.length}</span>
|
||||||
|
</p>
|
||||||
|
<p class="text-xs">
|
||||||
|
Сумма префиксов: <span class="font-semibold">{asPrefixTotal}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if mod.type === 'CDN_CIDRS'}
|
||||||
|
<div class="rounded-lg border p-3">
|
||||||
|
<p class="text-sm font-medium">CDN ссылки и импортируемые префиксы</p>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
Каждый URL поставляет список CIDR для агрегации.
|
||||||
|
</p>
|
||||||
|
<div class="mt-2 space-y-1">
|
||||||
|
{#each cdnSources.slice(0, 6) as src (src.id)}
|
||||||
|
<p class="font-mono text-xs break-all">{src.url}</p>
|
||||||
|
{:else}
|
||||||
|
<p class="text-xs text-muted-foreground">Нет CDN источников</p>
|
||||||
|
{/each}
|
||||||
|
{#if cdnSources.length > 6}
|
||||||
|
<p class="text-xs text-muted-foreground">…и ещё {cdnSources.length - 6}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if mod.type === 'IP_RANGES'}
|
||||||
|
<div class="rounded-lg border p-3">
|
||||||
|
<p class="text-sm font-medium">IP ranges для агрегации</p>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
Статические CIDR, которые попадают в итоговую ревизию.
|
||||||
|
</p>
|
||||||
|
<div class="mt-2 space-y-1">
|
||||||
|
{#each ipEntries.slice(0, 8) as entry (entry.id)}
|
||||||
|
<p class="font-mono text-xs">{entry.prefix}</p>
|
||||||
|
{:else}
|
||||||
|
<p class="text-xs text-muted-foreground">Нет диапазонов</p>
|
||||||
|
{/each}
|
||||||
|
{#if ipEntries.length > 8}
|
||||||
|
<p class="text-xs text-muted-foreground">…и ещё {ipEntries.length - 8}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="rounded-lg border p-3">
|
||||||
|
<p class="text-sm font-medium">Результат операции</p>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
Подробный результат по конкретному запуску refresh смотрите в Операции → Задачи →
|
||||||
|
module_refresh: там отображаются источники, количество префиксов и итог агрегации.
|
||||||
|
</p>
|
||||||
|
<Button variant="link" class="mt-2 h-auto p-0" href={resolve('/operations?tab=jobs')}>
|
||||||
|
Открыть задачи
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import type { BgpCommunity, DohProfile, ModuleRow } from '$lib/api/types.js';
|
||||||
|
|
||||||
|
export const NONE_OPTION = '__none__';
|
||||||
|
|
||||||
|
export function supportsCsvIO(type: ModuleRow['type'] | null | undefined): boolean {
|
||||||
|
return type === 'AS_PREFIXES' || type === 'DOMAINS' || type === 'IP_RANGES';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizeFilenamePart(v: string): string {
|
||||||
|
const cleaned = v
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9._-]+/g, '-')
|
||||||
|
.replace(/-+/g, '-')
|
||||||
|
.replace(/^[-_.]+|[-_.]+$/g, '');
|
||||||
|
return cleaned || 'module';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function communityLabel(id: string | null, communities: BgpCommunity[]): string {
|
||||||
|
if (!id) return '—';
|
||||||
|
const c = communities.find((x) => x.id === id);
|
||||||
|
if (!c) return id.slice(0, 8) + '…';
|
||||||
|
const t = c.title?.trim();
|
||||||
|
return t || c.community;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function communityOptionLabel(c: BgpCommunity): string {
|
||||||
|
const t = c.title?.trim();
|
||||||
|
return t || c.community;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nullableSelectValue(value: string | null | undefined): string {
|
||||||
|
if (value === null || value === undefined || value === '') return NONE_OPTION;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fromNullableSelect(value: string): string | null {
|
||||||
|
if (value === NONE_OPTION || value === '') return null;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function moduleDohProfileIds(modRow: ModuleRow | null): string[] {
|
||||||
|
if (!modRow) return [];
|
||||||
|
if (modRow.doh_profile_ids?.length) return modRow.doh_profile_ids;
|
||||||
|
return modRow.doh_profile_id ? [modRow.doh_profile_id] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dohProfileLabel(id: string, dohProfiles: DohProfile[]): string {
|
||||||
|
const p = dohProfiles.find((d) => d.id === id);
|
||||||
|
return p ? (p.name?.trim() ? `${p.name} (${p.url})` : p.url) : id.slice(0, 8) + '…';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeCdnSourceKind(k: string): 'plaintext' | 'json' {
|
||||||
|
return k.trim().toLowerCase() === 'json' ? 'json' : 'plaintext';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function syncSelection(selected: Set<string>, existingIds: string[]): Set<string> {
|
||||||
|
const validIds = new Set(existingIds);
|
||||||
|
return new Set([...selected].filter((id) => validIds.has(id)));
|
||||||
|
}
|
||||||
@@ -43,6 +43,32 @@ export function jobKindFilterRu(kind: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Включён / выключен модуль (поле enabled). */
|
||||||
|
export function moduleEnabledRu(enabled: boolean): string {
|
||||||
|
return enabled ? 'Вкл' : 'Выкл';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Variant Badge для enabled модуля. */
|
||||||
|
export function moduleEnabledBadgeVariant(
|
||||||
|
enabled: boolean
|
||||||
|
): 'default' | 'secondary' | 'outline' | 'destructive' {
|
||||||
|
return enabled ? 'default' : 'secondary';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Политика DoH-резолвера для модулей DOMAINS. */
|
||||||
|
export function dohPolicyRu(policy: string | null | undefined): string {
|
||||||
|
switch (policy) {
|
||||||
|
case 'primary_only':
|
||||||
|
return 'Только первый';
|
||||||
|
case 'failover':
|
||||||
|
return 'Резервирование';
|
||||||
|
case 'union':
|
||||||
|
return 'Объединение';
|
||||||
|
default:
|
||||||
|
return 'Только первый';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function moduleTypeRu(type: string): string {
|
export function moduleTypeRu(type: string): string {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'AS_PREFIXES':
|
case 'AS_PREFIXES':
|
||||||
|
|||||||
+158
-147
@@ -1,70 +1,105 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { apiJSON, apiMutate } from '$lib/api/client.js';
|
|
||||||
import type { ModuleRow, ModulesResponse, ModuleCreate } from '$lib/api/types.js';
|
|
||||||
import { Badge } from '$lib/components/ui/badge/index.js';
|
|
||||||
import { Button } from '$lib/components/ui/button/index.js';
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card/index.js';
|
|
||||||
import { Input } from '$lib/components/ui/input/index.js';
|
|
||||||
import { Label } from '$lib/components/ui/label/index.js';
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogFooter,
|
|
||||||
DialogDescription
|
|
||||||
} from '$lib/components/ui/dialog/index.js';
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger
|
|
||||||
} from '$lib/components/ui/select/index.js';
|
|
||||||
import { Switch } from '$lib/components/ui/switch/index.js';
|
|
||||||
import { Checkbox } from '$lib/ui/core/checkbox/index.js';
|
|
||||||
import AppDataTable from '$lib/ui/patterns/data-table/app-data-table.svelte';
|
|
||||||
import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
|
|
||||||
import { confirm } from '$lib/ui/patterns/confirm/confirm-state.svelte.js';
|
|
||||||
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
|
|
||||||
import { resolve } from '$app/paths';
|
import { resolve } from '$app/paths';
|
||||||
import Plus from '@lucide/svelte/icons/plus';
|
import { apiJSON, apiMutate } from '$lib/api/client.js';
|
||||||
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
|
import type { ModuleRow, ModulesResponse } from '$lib/api/types.js';
|
||||||
import ExternalLink from '@lucide/svelte/icons/external-link';
|
|
||||||
import Trash2 from '@lucide/svelte/icons/trash-2';
|
|
||||||
import { moduleTypeRu } from '$lib/ui-labels.js';
|
|
||||||
import {
|
import {
|
||||||
formatDateTime,
|
formatDateTime,
|
||||||
moduleIntervalLabel,
|
moduleIntervalLabel,
|
||||||
moduleTypeBadgeVariant
|
moduleTypeBadgeVariant
|
||||||
} from '$lib/modules/display.js';
|
} from '$lib/modules/display.js';
|
||||||
|
import { moduleEnabledRu, moduleEnabledBadgeVariant, moduleTypeRu } from '$lib/ui-labels.js';
|
||||||
|
import { Badge } from '$lib/ui/core/badge/index.js';
|
||||||
|
import { Button } from '$lib/ui/core/button/index.js';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
CardDescription
|
||||||
|
} from '$lib/ui/core/card/index.js';
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '$lib/ui/core/alert/index.js';
|
||||||
|
import { Checkbox } from '$lib/ui/core/checkbox/index.js';
|
||||||
|
import AppDataTable from '$lib/ui/patterns/data-table/app-data-table.svelte';
|
||||||
|
import CardSkeleton from '$lib/ui/patterns/feedback/card-skeleton.svelte';
|
||||||
|
import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
|
||||||
|
import { confirm } from '$lib/ui/patterns/confirm/confirm-state.svelte.js';
|
||||||
|
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
|
||||||
|
import ModuleCreateDialog from '$lib/components/modules/ModuleCreateDialog.svelte';
|
||||||
|
import { cn } from '$lib/utils.js';
|
||||||
|
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';
|
||||||
import Boxes from '@lucide/svelte/icons/boxes';
|
import Boxes from '@lucide/svelte/icons/boxes';
|
||||||
|
import Info from '@lucide/svelte/icons/info';
|
||||||
|
import CheckCircle2 from '@lucide/svelte/icons/check-circle-2';
|
||||||
|
import CircleOff from '@lucide/svelte/icons/circle-off';
|
||||||
|
|
||||||
let rows = $state<ModuleRow[]>([]);
|
let rows = $state<ModuleRow[]>([]);
|
||||||
let loading = $state(false);
|
let loading = $state(false);
|
||||||
|
let initialLoading = $state(true);
|
||||||
let dialogOpen = $state(false);
|
let dialogOpen = $state(false);
|
||||||
let saving = $state(false);
|
|
||||||
let selectedModuleIds = $state(new Set<string>());
|
|
||||||
let deletingBulkModules = $state(false);
|
let deletingBulkModules = $state(false);
|
||||||
|
let selectedModuleIds = $state(new Set<string>());
|
||||||
|
let lastUpdated = $state<Date | null>(null);
|
||||||
|
|
||||||
const selectedModulesCount = $derived(selectedModuleIds.size);
|
const selectedModulesCount = $derived(selectedModuleIds.size);
|
||||||
const allModulesSelected = $derived(rows.length > 0 && selectedModuleIds.size === rows.length);
|
const allModulesSelected = $derived(rows.length > 0 && selectedModuleIds.size === rows.length);
|
||||||
const someModulesSelected = $derived(selectedModuleIds.size > 0 && !allModulesSelected);
|
const enabledCount = $derived(rows.filter((m) => m.enabled).length);
|
||||||
|
const disabledCount = $derived(rows.filter((m) => !m.enabled).length);
|
||||||
|
|
||||||
let form = $state<ModuleCreate>({
|
const statAccents = [
|
||||||
type: 'AS_PREFIXES',
|
{
|
||||||
name: '',
|
border: 'border-l-chart-1',
|
||||||
enabled: true,
|
bg: 'bg-chart-1/5',
|
||||||
priority: 0
|
iconBg: 'bg-chart-1/15',
|
||||||
});
|
iconText: 'text-chart-1'
|
||||||
|
},
|
||||||
const moduleTypes = [
|
{
|
||||||
{ value: 'AS_PREFIXES', label: 'AS (номера)' },
|
border: 'border-l-chart-2',
|
||||||
{ value: 'CDN_CIDRS', label: 'CDN CIDRs' },
|
bg: 'bg-chart-2/5',
|
||||||
{ value: 'DOMAINS', label: 'Домены' },
|
iconBg: 'bg-chart-2/15',
|
||||||
{ value: 'IP_RANGES', label: 'IP Ranges' }
|
iconText: 'text-chart-2'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
border: 'border-l-chart-4',
|
||||||
|
bg: 'bg-chart-4/5',
|
||||||
|
iconBg: 'bg-chart-4/15',
|
||||||
|
iconText: 'text-chart-4'
|
||||||
|
}
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
const kpiCards = $derived.by(() => [
|
||||||
|
{
|
||||||
|
id: 'total',
|
||||||
|
label: 'Всего модулей',
|
||||||
|
value: initialLoading ? '—' : String(rows.length),
|
||||||
|
description: 'AS, CDN, домены, IP',
|
||||||
|
icon: Boxes,
|
||||||
|
accent: statAccents[0],
|
||||||
|
badge: 'в системе'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'enabled',
|
||||||
|
label: 'Включено',
|
||||||
|
value: initialLoading ? '—' : String(enabledCount),
|
||||||
|
description: 'участвуют в ревизиях',
|
||||||
|
icon: CheckCircle2,
|
||||||
|
accent: statAccents[1],
|
||||||
|
badge: 'активных'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'disabled',
|
||||||
|
label: 'Выключено',
|
||||||
|
value: initialLoading ? '—' : String(disabledCount),
|
||||||
|
description: disabledCount > 0 ? 'не участвуют в сборке' : 'все модули включены',
|
||||||
|
icon: CircleOff,
|
||||||
|
accent: statAccents[2],
|
||||||
|
badge: disabledCount > 0 ? 'отключены' : 'нет отключённых'
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
const moduleColumns = [
|
const moduleColumns = [
|
||||||
{ id: 'select', label: '', class: 'w-10' },
|
{ id: 'select', label: '', class: 'w-10' },
|
||||||
{ id: 'name', label: 'Название', sortable: true, sortValue: (m: ModuleRow) => m.name },
|
{ id: 'name', label: 'Название', sortable: true, sortValue: (m: ModuleRow) => m.name },
|
||||||
@@ -87,40 +122,23 @@
|
|||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading = true;
|
if (!initialLoading) loading = true;
|
||||||
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));
|
const validIds = new Set(rows.map((item) => item.id));
|
||||||
selectedModuleIds = new Set([...selectedModuleIds].filter((id) => validIds.has(id)));
|
selectedModuleIds = new Set([...selectedModuleIds].filter((id) => validIds.has(id)));
|
||||||
|
lastUpdated = new Date();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
notifyApiError(e);
|
notifyApiError(e);
|
||||||
} finally {
|
} finally {
|
||||||
loading = false;
|
loading = false;
|
||||||
|
initialLoading = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(load);
|
onMount(load);
|
||||||
|
|
||||||
async function create() {
|
|
||||||
if (!form.name.trim()) {
|
|
||||||
notify.error('Укажите название модуля');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
saving = true;
|
|
||||||
try {
|
|
||||||
await apiMutate('/v1/modules', 'POST', form);
|
|
||||||
notify.success('Модуль создан');
|
|
||||||
dialogOpen = false;
|
|
||||||
form = { type: 'AS_PREFIXES', name: '', enabled: true, priority: 0 };
|
|
||||||
await load();
|
|
||||||
} catch (e) {
|
|
||||||
notifyApiError(e);
|
|
||||||
} finally {
|
|
||||||
saving = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleModuleSelection(id: string) {
|
function toggleModuleSelection(id: string) {
|
||||||
const next = new Set(selectedModuleIds);
|
const next = new Set(selectedModuleIds);
|
||||||
if (next.has(id)) next.delete(id);
|
if (next.has(id)) next.delete(id);
|
||||||
@@ -169,7 +187,9 @@
|
|||||||
<div class="flex flex-col gap-6">
|
<div class="flex flex-col gap-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Модули префиксов"
|
title="Модули префиксов"
|
||||||
description="Управление модулями — AS, CDN, домены, IP-диапазоны."
|
description={lastUpdated
|
||||||
|
? `Управление модулями — AS, CDN, домены, IP-диапазоны. Обновлено: ${lastUpdated.toLocaleTimeString('ru-RU')}`
|
||||||
|
: 'Управление модулями — AS, CDN, домены, IP-диапазоны.'}
|
||||||
icon={Boxes}
|
icon={Boxes}
|
||||||
iconClass="bg-chart-1/15 text-chart-1"
|
iconClass="bg-chart-1/15 text-chart-1"
|
||||||
>
|
>
|
||||||
@@ -185,9 +205,55 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
|
<Alert class="border-info/30 bg-info/5">
|
||||||
|
<Info class="text-info" />
|
||||||
|
<AlertTitle>О модулях префиксов</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
Модули собирают префиксы из AS (RIPEstat), CDN URL, доменов (DoH) и статических CIDR.
|
||||||
|
Расписание и ручной refresh — на странице
|
||||||
|
<Button variant="link" class="h-auto p-0" href={resolve('/schedule')}>Расписание</Button>.
|
||||||
|
Результаты обновления и ревизии — в
|
||||||
|
<Button variant="link" class="h-auto p-0" href={resolve('/operations')}>Операциях</Button>.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<div class="grid gap-4 sm:grid-cols-3">
|
||||||
|
{#if initialLoading}
|
||||||
|
{#each Array(3) as _, i (i)}
|
||||||
|
<CardSkeleton />
|
||||||
|
{/each}
|
||||||
|
{:else}
|
||||||
|
{#each kpiCards as card (card.id)}
|
||||||
|
{@const Icon = card.icon}
|
||||||
|
{@const a = card.accent}
|
||||||
|
<Card class={cn('overflow-hidden border-l-4 shadow-sm', a.border, a.bg)}>
|
||||||
|
<CardHeader class="pb-2">
|
||||||
|
<CardDescription class="flex min-w-0 items-center gap-2">
|
||||||
|
<span
|
||||||
|
class={cn('flex size-9 shrink-0 items-center justify-center rounded-lg', a.iconBg)}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<Icon class={cn('size-4', a.iconText)} />
|
||||||
|
</span>
|
||||||
|
<span class="truncate">{card.label}</span>
|
||||||
|
</CardDescription>
|
||||||
|
<CardTitle class="text-3xl font-bold tabular-nums">{card.value}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="space-y-2">
|
||||||
|
<Badge variant="outline">{card.badge}</Badge>
|
||||||
|
<p class="text-xs text-muted-foreground">{card.description}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader class="flex flex-wrap items-center justify-between gap-2 border-b py-3">
|
<CardHeader class="flex flex-wrap items-center justify-between gap-2 border-b py-3">
|
||||||
<CardTitle class="text-base">Список модулей</CardTitle>
|
<div>
|
||||||
|
<CardTitle class="text-base">Список модулей</CardTitle>
|
||||||
|
<CardDescription>Клик по названию открывает карточку модуля и его записи.</CardDescription>
|
||||||
|
</div>
|
||||||
{#if selectedModulesCount > 0}
|
{#if selectedModulesCount > 0}
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<span class="text-sm text-muted-foreground">Выбрано: {selectedModulesCount}</span>
|
<span class="text-sm text-muted-foreground">Выбрано: {selectedModulesCount}</span>
|
||||||
@@ -208,10 +274,22 @@
|
|||||||
columns={[...moduleColumns]}
|
columns={[...moduleColumns]}
|
||||||
{rows}
|
{rows}
|
||||||
rowKey={(m) => m.id}
|
rowKey={(m) => m.id}
|
||||||
{loading}
|
loading={initialLoading || loading}
|
||||||
emptyTitle="Нет модулей"
|
emptyTitle="Нет модулей"
|
||||||
emptyDescription="Создайте первый модуль."
|
emptyDescription="Создайте первый модуль."
|
||||||
>
|
>
|
||||||
|
{#snippet toolbar()}
|
||||||
|
{#if rows.length > 0}
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
checked={allModulesSelected}
|
||||||
|
onCheckedChange={(v) => toggleAllModules(v === true)}
|
||||||
|
aria-label="Выбрать все модули"
|
||||||
|
/>
|
||||||
|
<span class="text-sm text-muted-foreground">Выбрать все</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
{#snippet cell({ row: m, column })}
|
{#snippet cell({ row: m, column })}
|
||||||
{#if column.id === 'select'}
|
{#if column.id === 'select'}
|
||||||
<Checkbox
|
<Checkbox
|
||||||
@@ -220,7 +298,12 @@
|
|||||||
onCheckedChange={() => toggleModuleSelection(m.id)}
|
onCheckedChange={() => toggleModuleSelection(m.id)}
|
||||||
/>
|
/>
|
||||||
{:else if column.id === 'name'}
|
{:else if column.id === 'name'}
|
||||||
<span class="font-medium">{m.name}</span>
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="font-medium">{m.name}</span>
|
||||||
|
<Button variant="ghost" size="icon-sm" href={resolve(`/modules/${m.id}`)}>
|
||||||
|
<ExternalLink class="size-3.5" aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
{:else if column.id === 'type'}
|
{:else if column.id === 'type'}
|
||||||
<Badge variant={moduleTypeBadgeVariant(m.type)}>{moduleTypeRu(m.type)}</Badge>
|
<Badge variant={moduleTypeBadgeVariant(m.type)}>{moduleTypeRu(m.type)}</Badge>
|
||||||
{:else if column.id === 'priority'}
|
{:else if column.id === 'priority'}
|
||||||
@@ -232,11 +315,9 @@
|
|||||||
>{formatDateTime(m.last_refreshed_at)}</span
|
>{formatDateTime(m.last_refreshed_at)}</span
|
||||||
>
|
>
|
||||||
{:else if column.id === 'status'}
|
{:else if column.id === 'status'}
|
||||||
{#if m.enabled}
|
<Badge variant={moduleEnabledBadgeVariant(!!m.enabled)} class="text-xs">
|
||||||
<Badge variant="default" class="text-xs">вкл</Badge>
|
{moduleEnabledRu(!!m.enabled)}
|
||||||
{:else}
|
</Badge>
|
||||||
<Badge variant="secondary" class="text-xs">выкл</Badge>
|
|
||||||
{/if}
|
|
||||||
{:else if column.id === 'actions'}
|
{:else if column.id === 'actions'}
|
||||||
<Button variant="ghost" size="icon-sm" href={resolve(`/modules/${m.id}`)}>
|
<Button variant="ghost" size="icon-sm" href={resolve(`/modules/${m.id}`)}>
|
||||||
<ExternalLink class="size-3.5" />
|
<ExternalLink class="size-3.5" />
|
||||||
@@ -248,74 +329,4 @@
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Create Dialog -->
|
<ModuleCreateDialog bind:open={dialogOpen} onClose={() => {}} onCreated={load} />
|
||||||
<Dialog bind:open={dialogOpen}>
|
|
||||||
<DialogContent class="sm:max-w-md">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Новый модуль</DialogTitle>
|
|
||||||
<DialogDescription>Создание нового модуля префиксов.</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<div class="space-y-4 py-2">
|
|
||||||
<div class="space-y-1.5">
|
|
||||||
<Label for="m-name">Название</Label>
|
|
||||||
<Input id="m-name" bind:value={form.name} placeholder="my-asn-module" />
|
|
||||||
</div>
|
|
||||||
<div class="space-y-1.5">
|
|
||||||
<Label for="m-type">Тип</Label>
|
|
||||||
<Select
|
|
||||||
type="single"
|
|
||||||
value={form.type}
|
|
||||||
onValueChange={(v) => {
|
|
||||||
if (v) form.type = v as typeof form.type;
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SelectTrigger id="m-type" class="w-full">
|
|
||||||
{moduleTypes.find((t) => t.value === form.type)?.label ?? 'Выберите тип'}
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{#each moduleTypes as t (t.value)}
|
|
||||||
<SelectItem value={t.value}>{t.label}</SelectItem>
|
|
||||||
{/each}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div class="grid grid-cols-2 gap-4">
|
|
||||||
<div class="space-y-1.5">
|
|
||||||
<Label for="m-priority">Приоритет</Label>
|
|
||||||
<Input id="m-priority" type="number" bind:value={form.priority} />
|
|
||||||
</div>
|
|
||||||
<div class="space-y-1.5">
|
|
||||||
<Label for="m-interval">Интервал (сек)</Label>
|
|
||||||
<Input
|
|
||||||
id="m-interval"
|
|
||||||
type="number"
|
|
||||||
placeholder="3600"
|
|
||||||
bind:value={form.refresh_interval_sec}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="flex flex-row items-center justify-between gap-4 rounded-lg border border-border bg-muted/30 p-3"
|
|
||||||
>
|
|
||||||
<div class="grid min-w-0 flex-1 gap-1 pr-2">
|
|
||||||
<Label for="m-enabled" class="leading-snug text-foreground">Включён</Label>
|
|
||||||
<p class="text-xs leading-snug text-muted-foreground">
|
|
||||||
Модуль участвует в сборке ревизий, если включён.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
id="m-enabled"
|
|
||||||
class="shrink-0"
|
|
||||||
checked={form.enabled !== false}
|
|
||||||
onCheckedChange={(v) => {
|
|
||||||
form = { ...form, enabled: v };
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onclick={() => (dialogOpen = false)}>Отмена</Button>
|
|
||||||
<Button onclick={create} disabled={saving}>{saving ? 'Создание…' : 'Создать'}</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user