CI / changes (push) Successful in 10s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 28s
CI / web (push) Successful in 1m0s
CI / go (push) Successful in 1m11s
CI / bird2 (push) Successful in 16s
CI / release (push) Successful in 4m5s
Updated the settings query options to eliminate the tenant ID parameter, streamlining the settings retrieval process. Adjusted the TenantSettingsComponent to reflect this change, ensuring it now queries settings without relying on tenant-specific data. This refactor enhances code clarity and reduces complexity in the settings management flow.
394 lines
16 KiB
TypeScript
394 lines
16 KiB
TypeScript
import { createFileRoute, useSearch } from '@tanstack/react-router'
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
|
import { Info, Save } from 'lucide-react'
|
|
import { useEffect, useState } from 'react'
|
|
import { toast } from 'sonner'
|
|
|
|
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
|
|
import { Input } from '@evobgp/ui/components/input'
|
|
import { Label } from '@evobgp/ui/components/label'
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '@evobgp/ui/components/select'
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from '@evobgp/ui/components/table'
|
|
|
|
import { PageHeader } from '@/components/page-header'
|
|
import { QueryState } from '@/components/query-state'
|
|
import { LoadingButton } from '@/components/loading-button'
|
|
|
|
import {
|
|
BIRD_SETTING_KEYS,
|
|
REVISION_SETTING_KEYS,
|
|
RUNTIME_LOGS_SETTING_KEYS,
|
|
buildPayload,
|
|
partitionSettings,
|
|
settingsKeys,
|
|
settingsQueryOptions,
|
|
type BirdSettingKey,
|
|
} from '@/queries/settings'
|
|
import { apiMutate } from '@/lib/api-client'
|
|
|
|
export const Route = createFileRoute('/_auth/tenant-settings')({
|
|
component: TenantSettingsComponent,
|
|
validateSearch: (search: Record<string, unknown>) => ({
|
|
tab: (search.tab === 'revision' || search.tab === 'runtime-logs' || search.tab === 'additional'
|
|
? search.tab
|
|
: 'bird') as 'bird' | 'revision' | 'runtime-logs' | 'additional',
|
|
}),
|
|
})
|
|
|
|
const RUNTIME_LOGS_ENABLED_ITEMS = [
|
|
{ value: 'true', label: 'Вкл' },
|
|
{ value: 'false', label: 'Выкл' },
|
|
] as const
|
|
|
|
const RUNTIME_LOGS_MODE_ITEMS = [
|
|
{ value: 'truncate', label: 'truncate — обнулить' },
|
|
{ value: 'delete', label: 'delete — удалить файл' },
|
|
] as const
|
|
|
|
const BIRD_LABELS: Record<BirdSettingKey, string> = {
|
|
bird_router_id: 'Router ID',
|
|
bird_local_ipv4: 'Локальный IPv4',
|
|
bird_local_ipv6: 'Локальный IPv6',
|
|
bird_local_asn: 'Локальный ASN',
|
|
bird_bgp_source_ipv4: 'BGP source IPv4',
|
|
bird_bgp_source_ipv6: 'BGP source IPv6',
|
|
}
|
|
|
|
function TenantSettingsComponent() {
|
|
const search = useSearch({ from: '/_auth/tenant-settings' })
|
|
const settingsQ = useQuery(settingsQueryOptions())
|
|
const qc = useQueryClient()
|
|
|
|
const partitioned = settingsQ.data ? partitionSettings(settingsQ.data) : null
|
|
|
|
const [birdForm, setBirdForm] = useState<Record<string, string>>({})
|
|
const [revisionForm, setRevisionForm] = useState<Record<string, string>>({})
|
|
const [runtimeLogsForm, setRuntimeLogsForm] = useState<Record<string, string>>({})
|
|
|
|
useEffect(() => {
|
|
if (partitioned) {
|
|
setBirdForm({ ...partitioned.bird })
|
|
setRevisionForm({ ...partitioned.revision })
|
|
setRuntimeLogsForm({ ...partitioned.runtimeLogs })
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [settingsQ.data])
|
|
|
|
const patchMutation = useMutation({
|
|
mutationFn: (payload: Record<string, string | number | boolean>) =>
|
|
apiMutate('/v1/settings', 'PATCH', payload),
|
|
onSuccess: () => {
|
|
toast.success('Параметры сохранены')
|
|
void qc.invalidateQueries({ queryKey: settingsKeys.all })
|
|
},
|
|
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось сохранить'),
|
|
})
|
|
|
|
function saveBird() {
|
|
patchMutation.mutate(buildPayload(BIRD_SETTING_KEYS, birdForm))
|
|
}
|
|
function saveRevision() {
|
|
patchMutation.mutate(buildPayload(REVISION_SETTING_KEYS, revisionForm))
|
|
}
|
|
function saveRuntimeLogs() {
|
|
patchMutation.mutate(buildPayload(RUNTIME_LOGS_SETTING_KEYS, runtimeLogsForm))
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col gap-6">
|
|
<PageHeader
|
|
title="Параметры tenant"
|
|
description="Параметры control plane для текущего tenant (API /v1/settings)"
|
|
/>
|
|
|
|
<Alert className="border-info/30 bg-info/5">
|
|
<Info className="text-info" />
|
|
<AlertTitle>Operator-only</AlertTitle>
|
|
<AlertDescription>
|
|
Изменение значений через <code className="text-xs">PATCH /v1/settings</code> требует роли
|
|
operator. При отсутствии прав API вернёт 403.
|
|
</AlertDescription>
|
|
</Alert>
|
|
|
|
<Tabs defaultValue={search.tab}>
|
|
<TabsList>
|
|
<TabsTrigger value="bird">BIRD</TabsTrigger>
|
|
<TabsTrigger value="revision">Ревизии</TabsTrigger>
|
|
<TabsTrigger value="runtime-logs">Файловые логи</TabsTrigger>
|
|
<TabsTrigger value="additional">Дополнительно</TabsTrigger>
|
|
</TabsList>
|
|
|
|
<TabsContent value="bird" className="mt-4">
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>BIRD control plane</CardTitle>
|
|
<CardDescription>
|
|
Глобальные параметры BIRD для pipeline refresh/apply. Сохранение через{' '}
|
|
<code className="text-xs">PATCH /v1/settings</code> (роль operator).
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<Alert className="border-info/30 bg-info/5">
|
|
<Info className="text-info" />
|
|
<AlertTitle>Подстановка в конфиг</AlertTitle>
|
|
<AlertDescription>
|
|
Значения используются при генерации BIRD-конфигурации (router id, local AS, адреса).
|
|
Пиры и спикеры настраиваются в разделе «Сеть».
|
|
</AlertDescription>
|
|
</Alert>
|
|
<QueryState
|
|
data={partitioned}
|
|
isLoading={settingsQ.isLoading}
|
|
isError={settingsQ.isError}
|
|
error={settingsQ.error}
|
|
skeleton={<div className="h-64" />}
|
|
onRetry={() => settingsQ.refetch()}
|
|
>
|
|
{() => (
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
{BIRD_SETTING_KEYS.map((key) => (
|
|
<div key={key} className="flex flex-col gap-1.5">
|
|
<Label htmlFor={key}>{BIRD_LABELS[key]}</Label>
|
|
<Input
|
|
id={key}
|
|
value={birdForm[key] ?? ''}
|
|
onChange={(e) => setBirdForm((s) => ({ ...s, [key]: e.target.value }))}
|
|
placeholder={BIRD_LABELS[key]}
|
|
/>
|
|
<p className="font-mono text-xs text-muted-foreground">{key}</p>
|
|
</div>
|
|
))}
|
|
<div className="md:col-span-2">
|
|
<LoadingButton onClick={saveBird} loading={patchMutation.isPending}>
|
|
<Save />
|
|
Сохранить
|
|
</LoadingButton>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</QueryState>
|
|
</CardContent>
|
|
</Card>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="revision" className="mt-4">
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Ревизии</CardTitle>
|
|
<CardDescription>Время хранения ревизий в БД</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<QueryState
|
|
data={partitioned}
|
|
isLoading={settingsQ.isLoading}
|
|
isError={settingsQ.isError}
|
|
error={settingsQ.error}
|
|
skeleton={<div className="h-32" />}
|
|
onRetry={() => settingsQ.refetch()}
|
|
>
|
|
{() => (
|
|
<div className="flex max-w-sm flex-col gap-1.5">
|
|
<Label htmlFor="revision_retention_minutes">Retention (минуты)</Label>
|
|
<Input
|
|
id="revision_retention_minutes"
|
|
type="number"
|
|
value={revisionForm.revision_retention_minutes ?? ''}
|
|
onChange={(e) =>
|
|
setRevisionForm((s) => ({
|
|
...s,
|
|
revision_retention_minutes: e.target.value,
|
|
}))
|
|
}
|
|
/>
|
|
<p className="font-mono text-xs text-muted-foreground">
|
|
revision_retention_minutes
|
|
</p>
|
|
<LoadingButton
|
|
className="mt-2 w-fit"
|
|
onClick={saveRevision}
|
|
loading={patchMutation.isPending}
|
|
>
|
|
<Save />
|
|
Сохранить
|
|
</LoadingButton>
|
|
</div>
|
|
)}
|
|
</QueryState>
|
|
</CardContent>
|
|
</Card>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="runtime-logs" className="mt-4">
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Файловые логи</CardTitle>
|
|
<CardDescription>Автоматическая очистка логов</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<QueryState
|
|
data={partitioned}
|
|
isLoading={settingsQ.isLoading}
|
|
isError={settingsQ.isError}
|
|
error={settingsQ.error}
|
|
skeleton={<div className="h-48" />}
|
|
onRetry={() => settingsQ.refetch()}
|
|
>
|
|
{() => (
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
<div className="flex flex-col gap-1.5">
|
|
<Label>Авто-очистка включена</Label>
|
|
<Select
|
|
items={[...RUNTIME_LOGS_ENABLED_ITEMS]}
|
|
value={runtimeLogsForm.runtime_logs_auto_enabled ?? 'false'}
|
|
onValueChange={(v) =>
|
|
v &&
|
|
setRuntimeLogsForm((s) => ({
|
|
...s,
|
|
runtime_logs_auto_enabled: v,
|
|
}))
|
|
}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Выберите" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="true">Вкл</SelectItem>
|
|
<SelectItem value="false">Выкл</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
<p className="font-mono text-xs text-muted-foreground">
|
|
runtime_logs_auto_enabled
|
|
</p>
|
|
</div>
|
|
<div className="flex flex-col gap-1.5">
|
|
<Label htmlFor="runtime_logs_max_file_mb">Макс. размер файла (MB)</Label>
|
|
<Input
|
|
id="runtime_logs_max_file_mb"
|
|
type="number"
|
|
value={runtimeLogsForm.runtime_logs_max_file_mb ?? ''}
|
|
onChange={(e) =>
|
|
setRuntimeLogsForm((s) => ({
|
|
...s,
|
|
runtime_logs_max_file_mb: e.target.value,
|
|
}))
|
|
}
|
|
/>
|
|
<p className="font-mono text-xs text-muted-foreground">
|
|
runtime_logs_max_file_mb
|
|
</p>
|
|
</div>
|
|
<div className="flex flex-col gap-1.5">
|
|
<Label htmlFor="runtime_logs_auto_schedule">Расписание (cron)</Label>
|
|
<Input
|
|
id="runtime_logs_auto_schedule"
|
|
value={runtimeLogsForm.runtime_logs_auto_schedule ?? ''}
|
|
onChange={(e) =>
|
|
setRuntimeLogsForm((s) => ({
|
|
...s,
|
|
runtime_logs_auto_schedule: e.target.value,
|
|
}))
|
|
}
|
|
/>
|
|
<p className="font-mono text-xs text-muted-foreground">
|
|
runtime_logs_auto_schedule
|
|
</p>
|
|
</div>
|
|
<div className="flex flex-col gap-1.5">
|
|
<Label>Режим очистки</Label>
|
|
<Select
|
|
items={[...RUNTIME_LOGS_MODE_ITEMS]}
|
|
value={runtimeLogsForm.runtime_logs_auto_mode ?? ''}
|
|
onValueChange={(v) =>
|
|
v &&
|
|
setRuntimeLogsForm((s) => ({
|
|
...s,
|
|
runtime_logs_auto_mode: v,
|
|
}))
|
|
}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Выберите" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="truncate">truncate — обнулить</SelectItem>
|
|
<SelectItem value="delete">delete — удалить файл</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
<p className="font-mono text-xs text-muted-foreground">
|
|
runtime_logs_auto_mode
|
|
</p>
|
|
</div>
|
|
<div className="md:col-span-2">
|
|
<LoadingButton onClick={saveRuntimeLogs} loading={patchMutation.isPending}>
|
|
<Save />
|
|
Сохранить
|
|
</LoadingButton>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</QueryState>
|
|
</CardContent>
|
|
</Card>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="additional" className="mt-4">
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Дополнительные параметры</CardTitle>
|
|
<CardDescription>
|
|
Параметры вне стандартных групп (readonly — изменяются только через API)
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="p-0">
|
|
<QueryState
|
|
data={partitioned?.additional ?? []}
|
|
isLoading={settingsQ.isLoading}
|
|
isError={settingsQ.isError}
|
|
error={settingsQ.error}
|
|
empty={(partitioned?.additional ?? []).length === 0}
|
|
emptyTitle="Нет дополнительных параметров"
|
|
skeleton={<div className="h-32" />}
|
|
onRetry={() => settingsQ.refetch()}
|
|
>
|
|
{(items) => (
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>Ключ</TableHead>
|
|
<TableHead>Значение</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{items.map((row) => (
|
|
<TableRow key={row.id}>
|
|
<TableCell className="font-mono text-xs">{row.key}</TableCell>
|
|
<TableCell className="font-mono text-xs">{row.value}</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
)}
|
|
</QueryState>
|
|
</CardContent>
|
|
</Card>
|
|
</TabsContent>
|
|
</Tabs>
|
|
</div>
|
|
)
|
|
}
|