feat(monorepo): restructure web components and update configurations
CI / changes (push) Successful in 10s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 27s
CI / web (push) Successful in 38s
CI / go (push) Successful in 2m36s
CI / bird2 (push) Successful in 15s
CI / release (push) Failing after 3m7s
CI / changes (push) Successful in 10s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 27s
CI / web (push) Successful in 38s
CI / go (push) Successful in 2m36s
CI / bird2 (push) Successful in 15s
CI / release (push) Failing after 3m7s
Refactored the project structure to support a monorepo setup, moving the web application to `apps/web/` and updating related configurations. Adjusted pre-commit hooks to use `pnpm` for linting and formatting. Updated CI workflows to reflect the new directory structure and dependencies. Removed legacy files and configurations from the previous `web/` directory, streamlining the project for better maintainability and clarity.
This commit is contained in:
@@ -0,0 +1,302 @@
|
||||
<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 '@evobgp/ui/components/button/index.js';
|
||||
import { Checkbox } from '@evobgp/ui/components/checkbox/index.js';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription
|
||||
} from '@evobgp/ui/components/card/index.js';
|
||||
import AppDataTable from '$lib/components/patterns/data-table/app-data-table.svelte';
|
||||
import { confirm } from '$lib/components/patterns/confirm/confirm-state.svelte.js';
|
||||
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
|
||||
import ModuleDomainEntryDialog from '$lib/components/modules/ModuleDomainEntryDialog.svelte';
|
||||
import Plus from '@lucide/svelte/icons/plus';
|
||||
import Pencil from '@lucide/svelte/icons/pencil';
|
||||
import Trash2 from '@lucide/svelte/icons/trash-2';
|
||||
import Upload from '@lucide/svelte/icons/upload';
|
||||
import Download from '@lucide/svelte/icons/download';
|
||||
|
||||
type Props = {
|
||||
moduleId: string;
|
||||
mod: ModuleRow;
|
||||
entries: DomainEntry[];
|
||||
communities: BgpCommunity[];
|
||||
loading?: boolean;
|
||||
onChanged: () => void | Promise<void>;
|
||||
};
|
||||
|
||||
let { moduleId, mod, entries, communities, loading = false, onChanged }: Props = $props();
|
||||
|
||||
let dialogOpen = $state(false);
|
||||
let editTarget = $state<DomainEntry | null>(null);
|
||||
let selectedIds = $state(new Set<string>());
|
||||
let deletingBulk = $state(false);
|
||||
let csvImporting = $state(false);
|
||||
let csvExporting = $state(false);
|
||||
let csvFileInput = $state<HTMLInputElement | null>(null);
|
||||
|
||||
const activeSelected = $derived.by(() => {
|
||||
const allowed = new Set(entries.map((e) => e.id));
|
||||
return [...selectedIds].filter((id) => allowed.has(id));
|
||||
});
|
||||
const selectedCount = $derived(activeSelected.length);
|
||||
const allSelected = $derived(entries.length > 0 && entries.every((e) => selectedIds.has(e.id)));
|
||||
|
||||
const columns = [
|
||||
{ id: 'select', label: '', class: 'w-10' },
|
||||
{ id: 'fqdn', label: 'FQDN', sortable: true, sortValue: (e: DomainEntry) => e.fqdn },
|
||||
{ id: 'community', label: 'Community' },
|
||||
{ id: 'actions', label: '', class: 'w-20' }
|
||||
] as const;
|
||||
|
||||
function toggleSelection(id: string) {
|
||||
const next = new Set(selectedIds);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
selectedIds = next;
|
||||
}
|
||||
|
||||
function toggleAll(checked: boolean) {
|
||||
selectedIds = checked ? new Set(entries.map((e) => e.id)) : new Set<string>();
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editTarget = null;
|
||||
dialogOpen = true;
|
||||
}
|
||||
|
||||
function openEdit(entry: DomainEntry) {
|
||||
editTarget = entry;
|
||||
dialogOpen = true;
|
||||
}
|
||||
|
||||
function requestDelete(entry: DomainEntry) {
|
||||
void confirm({
|
||||
title: 'Удалить домен?',
|
||||
description: entry.fqdn,
|
||||
confirmLabel: 'Удалить',
|
||||
destructive: true,
|
||||
onConfirm: async () => {
|
||||
await apiMutate(`/v1/modules/${moduleId}/domain-entries/${entry.id}`, 'DELETE', undefined, {
|
||||
idempotent: false
|
||||
});
|
||||
notify.success('Удалено');
|
||||
await onChanged();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function requestBulkDelete() {
|
||||
if (selectedCount === 0) return;
|
||||
void confirm({
|
||||
title: 'Удалить выбранные домены?',
|
||||
description: `Будет удалено: ${selectedCount}`,
|
||||
confirmLabel: 'Удалить',
|
||||
destructive: true,
|
||||
onConfirm: bulkDelete
|
||||
});
|
||||
}
|
||||
|
||||
async function bulkDelete() {
|
||||
if (selectedCount === 0) return;
|
||||
deletingBulk = true;
|
||||
let deleted = 0;
|
||||
try {
|
||||
for (const id of activeSelected) {
|
||||
try {
|
||||
await apiMutate(`/v1/modules/${moduleId}/domain-entries/${id}`, 'DELETE', undefined, {
|
||||
idempotent: false
|
||||
});
|
||||
deleted += 1;
|
||||
} catch (e) {
|
||||
notifyApiError(e);
|
||||
}
|
||||
}
|
||||
if (deleted > 0) notify.success(`Удалено доменов: ${deleted}`);
|
||||
await onChanged();
|
||||
} finally {
|
||||
deletingBulk = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function readErrorText(res: Response): Promise<string> {
|
||||
const body = (await res.text()).trim();
|
||||
return body || `HTTP ${res.status}`;
|
||||
}
|
||||
|
||||
async function exportCsv() {
|
||||
if (!supportsCsvIO(mod.type) || csvExporting) return;
|
||||
csvExporting = true;
|
||||
try {
|
||||
const res = await apiFetch(`/v1/modules/${moduleId}/entries.csv`, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'text/csv' }
|
||||
});
|
||||
if (!res.ok) {
|
||||
notify.error(await readErrorText(res));
|
||||
return;
|
||||
}
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${sanitizeFilenamePart(mod.name)}-${mod.type.toLowerCase()}-entries.csv`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (e) {
|
||||
notifyApiError(e);
|
||||
} finally {
|
||||
csvExporting = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openImportPicker() {
|
||||
if (!supportsCsvIO(mod.type) || csvImporting) return;
|
||||
csvFileInput?.click();
|
||||
}
|
||||
|
||||
async function handleImportChange(event: Event) {
|
||||
const input = event.currentTarget as HTMLInputElement | null;
|
||||
const file = input?.files?.[0];
|
||||
if (!file || csvImporting) return;
|
||||
csvImporting = true;
|
||||
try {
|
||||
const fileText = await file.text();
|
||||
const res = await apiFetch(`/v1/modules/${moduleId}/entries.csv`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'text/csv' },
|
||||
body: fileText
|
||||
});
|
||||
if (!res.ok) {
|
||||
notify.error(await readErrorText(res));
|
||||
return;
|
||||
}
|
||||
const payload = (await res.json()) as { imported?: number };
|
||||
notify.success(`Импортировано записей: ${payload.imported ?? 0}`);
|
||||
await onChanged();
|
||||
} catch (e) {
|
||||
notifyApiError(e);
|
||||
} finally {
|
||||
csvImporting = false;
|
||||
if (input) input.value = '';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<input
|
||||
class="hidden"
|
||||
type="file"
|
||||
accept=".csv,text/csv"
|
||||
bind:this={csvFileInput}
|
||||
onchange={handleImportChange}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<CardHeader class="flex flex-col gap-3 pb-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="min-w-0 flex-1">
|
||||
<CardTitle class="text-base">Домены</CardTitle>
|
||||
<CardDescription>FQDN для резолвинга через DoH.</CardDescription>
|
||||
</div>
|
||||
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2 self-start sm:self-auto">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={openImportPicker}
|
||||
disabled={!supportsCsvIO(mod.type) || csvImporting || csvExporting}
|
||||
>
|
||||
<Upload />
|
||||
{csvImporting ? 'Импорт…' : 'Импорт CSV'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={exportCsv}
|
||||
disabled={!supportsCsvIO(mod.type) || csvExporting || csvImporting}
|
||||
>
|
||||
<Download />
|
||||
{csvExporting ? 'Экспорт…' : 'Экспорт CSV'}
|
||||
</Button>
|
||||
<Button size="sm" onclick={openCreate}><Plus />Добавить</Button>
|
||||
{#if selectedCount > 0}
|
||||
<Button variant="destructive" size="sm" onclick={requestBulkDelete} disabled={deletingBulk}>
|
||||
<Trash2 />
|
||||
Удалить ({selectedCount})
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent class="p-4 pt-0">
|
||||
<AppDataTable
|
||||
columns={[...columns]}
|
||||
rows={entries}
|
||||
rowKey={(e) => e.id}
|
||||
{loading}
|
||||
emptyTitle="Нет доменов"
|
||||
emptyDescription="Добавьте FQDN или импортируйте CSV."
|
||||
>
|
||||
{#snippet toolbar()}
|
||||
{#if entries.length > 0}
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={allSelected}
|
||||
onCheckedChange={(v) => toggleAll(v === true)}
|
||||
aria-label="Выбрать все домены"
|
||||
/>
|
||||
<span class="text-sm text-muted-foreground">Выбрать все</span>
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
{#snippet cell({ row: entry, column })}
|
||||
{#if column.id === 'select'}
|
||||
<Checkbox
|
||||
checked={selectedIds.has(entry.id)}
|
||||
aria-label={`Выбрать домен ${entry.fqdn}`}
|
||||
onCheckedChange={() => toggleSelection(entry.id)}
|
||||
/>
|
||||
{:else if column.id === 'fqdn'}
|
||||
<span class="font-mono">{entry.fqdn}</span>
|
||||
{:else if column.id === 'community'}
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{communityLabel(entry.community_id, communities)}
|
||||
</span>
|
||||
{:else if column.id === 'actions'}
|
||||
<div class="flex gap-1">
|
||||
<Button variant="ghost" size="icon-sm" onclick={() => openEdit(entry)}>
|
||||
<Pencil class="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
class="text-destructive"
|
||||
onclick={() => requestDelete(entry)}
|
||||
>
|
||||
<Trash2 class="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</AppDataTable>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<ModuleDomainEntryDialog
|
||||
bind:open={dialogOpen}
|
||||
{moduleId}
|
||||
edit={editTarget}
|
||||
{communities}
|
||||
onSaved={onChanged}
|
||||
onClose={() => {
|
||||
editTarget = null;
|
||||
}}
|
||||
/>
|
||||
Reference in New Issue
Block a user