CI / changes (push) Successful in 6s
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 3m42s
- Updated imports to utilize core UI components for better maintainability. - Refactored state management for communities and DoH profiles, enhancing loading and error handling. - Introduced derived states for KPI cards, providing better insights into community and DoH profile counts. - Streamlined loading functions to improve data fetching efficiency and user experience. - Enhanced UI layout with new icons and improved component structure for clarity.
179 lines
5.4 KiB
Svelte
179 lines
5.4 KiB
Svelte
<script lang="ts">
|
|
import { apiMutate } from '$lib/api/client.js';
|
|
import type { DohProfile, DohProfileCreate } 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';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogFooter
|
|
} from '$lib/ui/core/dialog/index.js';
|
|
import FormField from '$lib/ui/patterns/form/form-field.svelte';
|
|
import AppInput from '$lib/ui/patterns/form/app-input.svelte';
|
|
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 Plus from '@lucide/svelte/icons/plus';
|
|
import Pencil from '@lucide/svelte/icons/pencil';
|
|
import Trash2 from '@lucide/svelte/icons/trash-2';
|
|
|
|
type Props = {
|
|
items: DohProfile[];
|
|
loading?: boolean;
|
|
initialLoading?: boolean;
|
|
error?: string | null;
|
|
onRefresh: () => void | Promise<void>;
|
|
};
|
|
|
|
let { items, loading = false, initialLoading = false, error = null, onRefresh }: Props = $props();
|
|
|
|
let dialogOpen = $state(false);
|
|
let editTarget = $state<DohProfile | null>(null);
|
|
let form = $state<DohProfileCreate & { timeout_ms?: number | null }>({
|
|
url: '',
|
|
timeout_ms: null,
|
|
vault_secret_ref: null
|
|
});
|
|
let saving = $state(false);
|
|
|
|
const columns = [
|
|
{ id: 'url', label: 'URL', sortable: true, sortValue: (d: DohProfile) => d.url },
|
|
{
|
|
id: 'timeout_ms',
|
|
label: 'Таймаут (мс)',
|
|
sortable: true,
|
|
sortValue: (d: DohProfile) => d.timeout_ms ?? 0
|
|
},
|
|
{ id: 'id', label: 'ID' },
|
|
{ id: 'actions', label: '', class: 'w-20' }
|
|
] as const;
|
|
|
|
function openCreate() {
|
|
editTarget = null;
|
|
form = { url: '', timeout_ms: null, vault_secret_ref: null };
|
|
dialogOpen = true;
|
|
}
|
|
|
|
function openEdit(d: DohProfile) {
|
|
editTarget = d;
|
|
form = { url: d.url, timeout_ms: d.timeout_ms, vault_secret_ref: d.vault_secret_ref };
|
|
dialogOpen = true;
|
|
}
|
|
|
|
function requestDelete(d: DohProfile) {
|
|
void confirm({
|
|
title: 'Удалить DoH профиль?',
|
|
description: d.url,
|
|
confirmLabel: 'Удалить',
|
|
destructive: true,
|
|
onConfirm: async () => {
|
|
await apiMutate(`/v1/doh-profiles/${d.id}`, 'DELETE', undefined, { idempotent: false });
|
|
notify.success('Удалено');
|
|
await onRefresh();
|
|
}
|
|
});
|
|
}
|
|
|
|
async function save() {
|
|
if (!form.url.trim()) {
|
|
notify.error('Укажите URL');
|
|
return;
|
|
}
|
|
saving = true;
|
|
try {
|
|
if (editTarget) {
|
|
await apiMutate(`/v1/doh-profiles/${editTarget.id}`, 'PATCH', form);
|
|
notify.success('DoH профиль обновлён');
|
|
} else {
|
|
await apiMutate('/v1/doh-profiles', 'POST', form);
|
|
notify.success('DoH профиль создан');
|
|
}
|
|
dialogOpen = false;
|
|
await onRefresh();
|
|
} catch (e) {
|
|
notifyApiError(e);
|
|
} finally {
|
|
saving = false;
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<Card>
|
|
<CardHeader
|
|
class="flex flex-col gap-3 border-b py-3 sm:flex-row sm:items-center sm:justify-between"
|
|
>
|
|
<div class="min-w-0 flex-1">
|
|
<CardTitle class="text-base">DoH профили</CardTitle>
|
|
<CardDescription>DNS-over-HTTPS серверы для резолвинга доменных модулей</CardDescription>
|
|
</div>
|
|
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2">
|
|
<Button size="sm" onclick={openCreate}><Plus />Добавить</Button>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent class="p-4 pt-0">
|
|
<AppDataTable
|
|
columns={[...columns]}
|
|
rows={items}
|
|
rowKey={(d) => d.id}
|
|
loading={initialLoading || loading}
|
|
{error}
|
|
emptyTitle="Нет DoH профилей"
|
|
emptyDescription="Добавьте DNS-over-HTTPS сервер для доменных модулей."
|
|
>
|
|
{#snippet cell({ row: d, column })}
|
|
{#if column.id === 'url'}
|
|
<span class="font-mono text-sm">{d.url}</span>
|
|
{:else if column.id === 'timeout_ms'}
|
|
<span class="text-muted-foreground">{d.timeout_ms ?? '—'}</span>
|
|
{:else if column.id === 'id'}
|
|
<span class="font-mono text-xs text-muted-foreground">{d.id}</span>
|
|
{:else if column.id === 'actions'}
|
|
<div class="flex gap-1">
|
|
<Button variant="ghost" size="icon-sm" onclick={() => openEdit(d)}>
|
|
<Pencil class="size-3.5" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
class="text-destructive"
|
|
onclick={() => requestDelete(d)}
|
|
>
|
|
<Trash2 class="size-3.5" />
|
|
</Button>
|
|
</div>
|
|
{/if}
|
|
{/snippet}
|
|
</AppDataTable>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Dialog bind:open={dialogOpen}>
|
|
<DialogContent class="sm:max-w-sm">
|
|
<DialogHeader>
|
|
<DialogTitle>{editTarget ? 'Редактировать' : 'Новый'} DoH профиль</DialogTitle>
|
|
</DialogHeader>
|
|
<div class="flex flex-col gap-4 py-2">
|
|
<FormField label="URL" id="doh-url" required>
|
|
<AppInput id="doh-url" bind:value={form.url} placeholder="https://dns.google/dns-query" />
|
|
</FormField>
|
|
<FormField label="Таймаут (мс)" id="doh-timeout">
|
|
<AppInput id="doh-timeout" type="number" bind:value={form.timeout_ms} placeholder="5000" />
|
|
</FormField>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="outline" onclick={() => (dialogOpen = false)}>Отмена</Button>
|
|
<Button onclick={save} disabled={saving}>
|
|
{saving ? 'Сохранение…' : editTarget ? 'Сохранить' : 'Создать'}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|