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.
300 lines
8.8 KiB
Svelte
300 lines
8.8 KiB
Svelte
<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 '@evobgp/ui/components/button/index.js';
|
|
import { Input } from '@evobgp/ui/components/input/index.js';
|
|
import { Label } from '@evobgp/ui/components/label/index.js';
|
|
import { Checkbox } from '@evobgp/ui/components/checkbox/index.js';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogFooter
|
|
} from '@evobgp/ui/components/dialog/index.js';
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger
|
|
} from '@evobgp/ui/components/select/index.js';
|
|
import { Switch } from '@evobgp/ui/components/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);
|
|
let initKey = $state('');
|
|
|
|
function resetEditForm() {
|
|
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'
|
|
};
|
|
}
|
|
|
|
$effect(() => {
|
|
if (!open) {
|
|
initKey = '';
|
|
return;
|
|
}
|
|
const nextKey = mod.id;
|
|
if (nextKey !== initKey) {
|
|
initKey = nextKey;
|
|
resetEditForm();
|
|
}
|
|
});
|
|
|
|
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 bind: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>
|