feat: enhance DoH profile management and resolver policy in modules
CI / changes (push) Successful in 6s
CI / openapi (push) Successful in 1m2s
CI / go (push) Successful in 27s
CI / docker-web (push) Successful in 1m27s
CI / docker-bird (push) Has been skipped
CI / bird2 (push) Successful in 16s
CI / docker-go (push) Successful in 8m5s

- Added `DohResolverPolicy` schema to OpenAPI documentation, defining policies for domain resolution.
- Updated module handling to support multiple DoH profiles via `doh_profile_ids` and introduced `doh_resolver_policy` in the API.
- Refactored related functions to accommodate the new DoH profile structure, ensuring backward compatibility with existing `doh_profile_id`.
- Enhanced UI components to allow selection and management of DoH profiles and policies in the web interface.
- Updated database interactions to handle new fields and ensure proper data normalization.
This commit is contained in:
Denozordec
2026-05-19 14:57:50 +07:00
parent a536a2d5eb
commit 34ecc5c235
21 changed files with 810 additions and 114 deletions
+7
View File
@@ -8,6 +8,8 @@ export type Page<T> = {
// ---- Modules ----
export type ModuleType = 'AS_PREFIXES' | 'CDN_CIDRS' | 'DOMAINS' | 'IP_RANGES';
export type DohResolverPolicy = 'primary_only' | 'failover' | 'union';
export type ModuleRow = {
id: string;
type: ModuleType;
@@ -17,7 +19,10 @@ export type ModuleRow = {
refresh_interval_sec: number | null;
cron_expr: string | null;
default_community_id: string | null;
/** @deprecated use doh_profile_ids */
doh_profile_id: string | null;
doh_profile_ids: string[];
doh_resolver_policy: DohResolverPolicy;
last_refreshed_at: string | null;
};
export type ModulesResponse = Page<ModuleRow>;
@@ -36,6 +41,8 @@ export type ModuleCreate = {
enabled?: boolean;
priority?: number;
doh_profile_id?: string | null;
doh_profile_ids?: string[];
doh_resolver_policy?: DohResolverPolicy;
refresh_interval_sec?: number | null;
cron_expr?: string | null;
default_community_id?: string | null;
+121 -33
View File
@@ -7,6 +7,7 @@
import type {
ModuleRow,
ModulePatch,
DohResolverPolicy,
AsEntry,
AsEntryCreate,
AsEntryPatch,
@@ -57,6 +58,7 @@
SelectTrigger,
} from '$lib/components/ui/select/index.js';
import { Switch } from '$lib/components/ui/switch/index.js';
import { Checkbox } from '$lib/components/ui/checkbox/index.js';
import {
Table,
TableBody,
@@ -82,6 +84,53 @@
import Network from '@lucide/svelte/icons/network';
import { moduleTypeRu } from '$lib/ui-labels.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).'
}
];
function dohPolicyLabel(policy: DohResolverPolicy | null | undefined): string {
return dohPolicyOptions.find((o) => o.value === policy)?.label ?? 'Только первый';
}
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] : [];
}
function dohProfileLabel(id: string): string {
const p = dohProfiles.find((d) => d.id === id);
return p ? (p.name?.trim() ? `${p.name} (${p.url})` : p.url) : id.slice(0, 8) + '…';
}
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);
}
const moduleId = $derived(page.params.moduleId);
let mod = $state<ModuleRow | null>(null);
@@ -264,7 +313,8 @@
refresh_interval_sec: mod.refresh_interval_sec,
cron_expr: mod.cron_expr,
default_community_id: mod.default_community_id,
doh_profile_id: mod.doh_profile_id
doh_profile_ids: moduleDohProfileIds(mod),
doh_resolver_policy: mod.doh_resolver_policy ?? 'primary_only'
};
editDialog = true;
}
@@ -283,7 +333,8 @@
cron_expr: cron ? cron : null,
refresh_interval_sec: Number.isFinite(interval) ? interval : null,
default_community_id: fromNullableSelect(nullableSelectValue(editForm.default_community_id)),
doh_profile_id: fromNullableSelect(nullableSelectValue(editForm.doh_profile_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);
await loadMod();
@@ -847,8 +898,19 @@
</p>
</Card>
<Card class="bg-chart-4/10 p-3">
<p class="text-muted-foreground flex items-center gap-1.5 text-xs"><Network class="size-3.5" />DoH профиль</p>
<p class="font-semibold text-sm">{mod.doh_profile_id ? mod.doh_profile_id.slice(0, 8) + '…' : '—'}</p>
<p class="text-muted-foreground flex items-center gap-1.5 text-xs"><Network class="size-3.5" />DoH</p>
{#if mod.type === 'DOMAINS'}
<p class="font-semibold text-sm">{dohPolicyLabel(mod.doh_resolver_policy)}</p>
<p class="text-muted-foreground text-xs break-all">
{#if moduleDohProfileIds(mod).length}
{moduleDohProfileIds(mod).map(dohProfileLabel).join('; ')}
{:else}
Системный DNS
{/if}
</p>
{:else}
<p class="font-semibold text-sm"></p>
{/if}
</Card>
<Card class="bg-chart-2/10 p-3">
<p class="text-muted-foreground flex items-center gap-1.5 text-xs"><ShieldCheck class="size-3.5" />Community по умолч.</p>
@@ -1349,37 +1411,63 @@
Сбросить
</Button>
</div>
<div class="space-y-1.5">
<Label for="e-doh">DoH профиль</Label>
<Select
type="single"
value={nullableSelectValue(editForm.doh_profile_id)}
onValueChange={(v) => {
editForm.doh_profile_id = fromNullableSelect(v);
}}
>
<SelectTrigger id="e-doh" class="w-full">
{editForm.doh_profile_id ? (dohProfiles.find(d => d.id === editForm.doh_profile_id)?.url ?? editForm.doh_profile_id) : 'Не выбрано'}
</SelectTrigger>
<SelectContent>
<SelectItem value={NONE_OPTION}>Не выбрано</SelectItem>
{#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">
{dohPolicyLabel(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-muted-foreground text-xs">
{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-muted-foreground text-xs">Порядок выбора = порядок в списке (сверху вниз).</p>
<div class="max-h-40 space-y-2 overflow-y-auto rounded-md border p-3">
{#each dohProfiles as d (d.id)}
<SelectItem value={d.id}>{d.url}</SelectItem>
<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="text-muted-foreground block font-mono text-xs">{d.url}</span>
{/if}
</span>
</label>
{:else}
<p class="text-muted-foreground text-xs">Нет профилей — создайте в справочниках.</p>
{/each}
</SelectContent>
</Select>
<Button
type="button"
variant="ghost"
size="sm"
class="h-7 px-2"
onclick={() => {
editForm.doh_profile_id = null;
}}
>
Сбросить
</Button>
</div>
</div>
<Button
type="button"
variant="ghost"
size="sm"
class="h-7 px-2"
onclick={() => {
editForm = { ...editForm, doh_profile_ids: [] };
}}
>
Сбросить профили
</Button>
</div>
{/if}
<div
class="border-border bg-muted/30 flex flex-row items-center justify-between gap-4 rounded-lg border p-3"
>