Compare commits

...
6 Commits
Author SHA1 Message Date
Denozordec 53b3c49612 refactor(settings): simplify settings query options and remove tenant dependency
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.
2026-07-06 23:28:14 +07:00
Denozordec db79820df0 feat(auth): introduce demo token support and enhance API token handling
CI / changes (push) Successful in 10s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 28s
CI / web (push) Successful in 57s
CI / go (push) Successful in 1m9s
CI / bird2 (push) Successful in 17s
CI / release (push) Successful in 3m57s
Added a local demo token for development purposes and improved the API token management by normalizing input tokens. Updated the authentication flow to utilize the new token handling, allowing for better session management and user experience. Enhanced the settings component to support the demo token and provide clear instructions for its use in local development.
2026-07-06 22:55:30 +07:00
Denozordec a902a4270d fix(api-keys): enhance API key management with new mutations and UI updates
CI / changes (push) Successful in 14s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 59s
CI / go (push) Has been skipped
CI / bird2 (push) Has been skipped
CI / release (push) Successful in 3m47s
Added new mutations for creating, revoking, and rotating API keys in the api-keys query file. Updated the Access component to utilize these mutations, improving the user interface with better feedback and session management. Introduced a new AccessApiKeysCard for displaying API key information and enhanced the overall layout and user experience in the access route.
2026-07-06 20:32:17 +07:00
Denozordec 3a3e6db018 fix(tabs): update Tabs component for orientation handling and improve class names
CI / changes (push) Successful in 10s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 45s
CI / go (push) Successful in 57s
CI / bird2 (push) Successful in 15s
CI / release (push) Successful in 4m15s
Enhanced the Tabs component to support orientation prop, ensuring proper layout for both horizontal and vertical orientations. Updated class names for better clarity and consistency in styling. Additionally, modified the daemon PID and timestamps in .codegraph/daemon.pid for synchronization.
2026-07-06 18:45:44 +07:00
Denozordec 144d342c16 fix(modules): enhance ModuleDetailComponent with additional queries and UI updates
CI / changes (push) Successful in 11s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 1m3s
CI / go (push) Has been skipped
CI / bird2 (push) Has been skipped
CI / release (push) Successful in 4m4s
Updated the ModuleDetailComponent to include new queries for communities and DoH profiles, improving data handling. Added a module type alert function for better user guidance and refined the UI to display module type in a more user-friendly manner. Removed unused components and streamlined the refresh functionality for better performance.
2026-07-03 16:50:21 +07:00
Denozordec 0af37d55c4 fix(pagination): enhance DataGridPagination to dynamically populate Select items
CI / changes (push) Successful in 9s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 1m0s
CI / go (push) Has been skipped
CI / bird2 (push) Has been skipped
CI / release (push) Successful in 4m37s
Updated the DataGridPagination component to map available sizes into Select items for better user experience. This change allows for dynamic selection of page sizes based on the provided props.
2026-07-03 14:43:46 +07:00
31 changed files with 2210 additions and 379 deletions
+2 -2
View File
@@ -1,6 +1,6 @@
{
"pid": 48400,
"pid": 59836,
"version": "0.9.9",
"socketPath": "\\\\.\\pipe\\codegraph-97b92efdcc5351da",
"startedAt": 1783060842523
"startedAt": 1783332305171
}
@@ -0,0 +1,190 @@
import { useState } from 'react'
import { Plus, RefreshCw, Trash2 } from 'lucide-react'
import { Button } from '@evobgp/ui/components/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@evobgp/ui/components/table'
import { ApiKeyCreateDialog } from '@/components/access/api-key-create-dialog'
import { ApiKeyTokenDialog } from '@/components/access/api-key-token-dialog'
import { Badge } from '@/components/reui/badge'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { QueryState } from '@/components/query-state'
import { StatusBadge } from '@/components/status-badge'
import { TableSkeleton } from '@/components/skeletons'
import { formatApiKeyDate } from '@/lib/access/api-key-labels'
import { useRevokeApiKeyMutation, useRotateApiKeyMutation } from '@/queries/api-keys'
import type { ApiKey, ApiKeyCreated } from '@/types/api'
interface AccessApiKeysCardProps {
items: ApiKey[]
isLoading: boolean
isError: boolean
error: unknown
onRetry: () => void
}
export function AccessApiKeysCard({
items,
isLoading,
isError,
error,
onRetry,
}: AccessApiKeysCardProps) {
const [createOpen, setCreateOpen] = useState(false)
const [tokenDialogOpen, setTokenDialogOpen] = useState(false)
const [revealedToken, setRevealedToken] = useState('')
const revoke = useRevokeApiKeyMutation()
const rotate = useRotateApiKeyMutation()
function showToken(created: ApiKeyCreated) {
setRevealedToken(created.token)
setTokenDialogOpen(true)
}
function handleRotated(id: string) {
rotate.mutate(id, {
onSuccess: (created) => showToken(created),
})
}
return (
<>
<Card>
<CardHeader className="flex flex-col gap-3 border-b py-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0 flex-1">
<CardTitle className="text-base">API-ключи</CardTitle>
<CardDescription>
Управление ключами tenant. Полный токен показывается только при создании и ротации.
</CardDescription>
</div>
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
<Button size="sm" variant="outline" onClick={onRetry} disabled={isLoading}>
<RefreshCw className={isLoading ? 'animate-spin' : ''} />
Обновить
</Button>
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Plus />
Создать
</Button>
</div>
</CardHeader>
<CardContent className="p-0">
<QueryState
data={items}
isLoading={isLoading}
isError={isError}
error={error}
empty={items.length === 0}
emptyTitle="Нет ключей"
emptyDescription="Создайте API-ключ для автоматизации или отдельного доступа."
skeleton={<TableSkeleton rows={4} cols={6} />}
onRetry={onRetry}
>
{(data) => (
<Table>
<TableHeader>
<TableRow>
<TableHead>Имя</TableHead>
<TableHead>Роль</TableHead>
<TableHead>Префикс</TableHead>
<TableHead>Статус</TableHead>
<TableHead>Истекает</TableHead>
<TableHead>Последнее использование</TableHead>
<TableHead className="w-24" />
</TableRow>
</TableHeader>
<TableBody>
{data.map((k) => (
<TableRow key={k.id}>
<TableCell className="font-medium">{k.name}</TableCell>
<TableCell>
<Badge variant="outline" className="font-mono text-xs">
{k.role}
</Badge>
</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground">
{k.prefix}
</TableCell>
<TableCell>
{k.revoked_at ? (
<StatusBadge status="error" label="отозван" />
) : (
<StatusBadge status="active" label="активен" />
)}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{formatApiKeyDate(k.expires_at)}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{formatApiKeyDate(k.last_used_at)}
</TableCell>
<TableCell>
<div className="flex gap-1">
<ConfirmDialog
trigger={
<Button
variant="ghost"
size="icon-sm"
title="Ротировать"
disabled={!!k.revoked_at || rotate.isPending}
>
<RefreshCw className="size-3.5" />
</Button>
}
title="Ротировать ключ?"
description="Старый токен перестанет работать сразу."
confirmLabel="Ротировать"
onConfirm={() => handleRotated(k.id)}
/>
<ConfirmDialog
trigger={
<Button
variant="ghost"
size="icon-sm"
className="text-destructive"
disabled={!!k.revoked_at || revoke.isPending}
title="Отозвать"
>
<Trash2 className="size-3.5" />
</Button>
}
title="Отозвать API-ключ?"
description={`${k.name} (${k.prefix}…)`}
confirmLabel="Отозвать"
destructive
onConfirm={() => revoke.mutate(k.id)}
/>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</QueryState>
</CardContent>
</Card>
<ApiKeyCreateDialog
open={createOpen}
onOpenChange={setCreateOpen}
onCreated={showToken}
/>
<ApiKeyTokenDialog
open={tokenDialogOpen}
token={revealedToken}
onOpenChange={setTokenDialogOpen}
/>
</>
)
}
@@ -0,0 +1,132 @@
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import { Button } from '@evobgp/ui/components/button'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@evobgp/ui/components/dialog'
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 { LoadingButton } from '@/components/loading-button'
import { API_KEY_ROLE_ITEMS } from '@/lib/access/api-key-labels'
import { useCreateApiKeyMutation } from '@/queries/api-keys'
import type { ApiKeyCreate, ApiKeyCreated, ApiKeyRole } from '@/types/api'
interface ApiKeyCreateDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
onCreated: (created: ApiKeyCreated) => void
}
export function ApiKeyCreateDialog({ open, onOpenChange, onCreated }: ApiKeyCreateDialogProps) {
const createMutation = useCreateApiKeyMutation()
const [name, setName] = useState('')
const [role, setRole] = useState<ApiKeyRole>('editor')
const [expiresLocal, setExpiresLocal] = useState('')
useEffect(() => {
if (!open) return
setName('')
setRole('editor')
setExpiresLocal('')
}, [open])
function handleOpenChange(next: boolean) {
onOpenChange(next)
}
async function save() {
if (!name.trim()) {
toast.error('Укажите имя')
return
}
const body: ApiKeyCreate = {
name: name.trim(),
role,
}
if (expiresLocal.trim()) {
const d = new Date(expiresLocal)
if (Number.isNaN(d.getTime())) {
toast.error('Некорректная дата истечения')
return
}
body.expires_at = d.toISOString()
}
try {
const created = await createMutation.mutateAsync(body)
onOpenChange(false)
onCreated(created)
} catch {
// toast handled in mutation
}
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>Новый API-ключ</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4 py-2">
<div className="flex flex-col gap-2">
<Label htmlFor="key-name">Имя</Label>
<Input
id="key-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="CI / оператор UI"
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="key-role">Роль</Label>
<Select
items={[...API_KEY_ROLE_ITEMS]}
value={role}
onValueChange={(v) => v && setRole(v as ApiKeyRole)}
>
<SelectTrigger id="key-role" className="w-full">
<SelectValue placeholder="Выберите роль" />
</SelectTrigger>
<SelectContent>
{API_KEY_ROLE_ITEMS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="key-expires">Истекает (опционально)</Label>
<Input
id="key-expires"
type="datetime-local"
value={expiresLocal}
onChange={(e) => setExpiresLocal(e.target.value)}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => handleOpenChange(false)}>
Отмена
</Button>
<LoadingButton onClick={save} loading={createMutation.isPending}>
Создать
</LoadingButton>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,51 @@
import { Copy } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@evobgp/ui/components/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@evobgp/ui/components/dialog'
interface ApiKeyTokenDialogProps {
open: boolean
token: string
onOpenChange: (open: boolean) => void
}
export function ApiKeyTokenDialog({ open, token, onOpenChange }: ApiKeyTokenDialogProps) {
async function copyToken() {
if (!token) return
try {
await navigator.clipboard.writeText(token)
toast.success('Скопировано')
} catch {
toast.error('Не удалось скопировать')
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Сохраните токен</DialogTitle>
<DialogDescription>
Он больше не будет показан. Скопируйте в безопасное хранилище.
</DialogDescription>
</DialogHeader>
<div className="break-all rounded-md border bg-muted/40 p-3 font-mono text-xs">{token}</div>
<DialogFooter>
<Button variant="outline" onClick={copyToken}>
<Copy />
Копировать
</Button>
<Button onClick={() => onOpenChange(false)}>Готово</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,76 @@
import { useMemo } from 'react'
import { Label } from '@evobgp/ui/components/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@evobgp/ui/components/select'
import {
NONE_OPTION,
communityOptionLabel,
fromNullableSelect,
nullableSelectValue,
} from '@/lib/modules/helpers'
import type { BgpCommunity } from '@/types/api'
interface CommunitySelectProps {
id?: string
label?: string
value: string | null
onValueChange: (value: string | null) => void
communities: BgpCommunity[]
nullable?: boolean
placeholder?: string
}
export function CommunitySelect({
id,
label,
value,
onValueChange,
communities,
nullable = false,
placeholder = 'Выберите community',
}: CommunitySelectProps) {
const items = useMemo(() => {
const communityItems = communities.map((c) => ({
value: c.id,
label: communityOptionLabel(c),
}))
if (nullable) {
return [{ value: NONE_OPTION, label: 'Не выбрано' }, ...communityItems]
}
return communityItems
}, [communities, nullable])
const selectValue = nullable ? nullableSelectValue(value) : (value ?? '')
return (
<div className="flex flex-col gap-1.5">
{label ? <Label htmlFor={id}>{label}</Label> : null}
<Select
items={items}
value={selectValue}
onValueChange={(v) => {
if (!v) return
onValueChange(nullable ? fromNullableSelect(v) : v)
}}
>
<SelectTrigger id={id} className="w-full">
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
{items.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)
}
@@ -0,0 +1,116 @@
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@evobgp/ui/components/dialog'
import { Input } from '@evobgp/ui/components/input'
import { Label } from '@evobgp/ui/components/label'
import { LoadingButton } from '@/components/loading-button'
import { CommunitySelect } from '@/components/modules/community-select'
import { ApiError, apiMutate } from '@/lib/api-client'
import type { AsEntry, AsEntryCreate, AsEntryPatch, BgpCommunity } from '@/types/api'
interface ModuleAsEntryDialogProps {
open: boolean
moduleId: string
edit: AsEntry | null
communities: BgpCommunity[]
onOpenChange: (open: boolean) => void
onSaved: () => void | Promise<void>
}
export function ModuleAsEntryDialog({
open,
moduleId,
edit,
communities,
onOpenChange,
onSaved,
}: ModuleAsEntryDialogProps) {
const [saving, setSaving] = useState(false)
const [form, setForm] = useState<AsEntryCreate>({ asn: 0, community_id: null })
useEffect(() => {
if (!open) return
setForm(
edit
? { asn: edit.asn, community_id: edit.community_id }
: { asn: 0, community_id: null },
)
}, [open, edit])
async function save() {
const asn = Number(form.asn)
if (!Number.isFinite(asn) || asn < 1 || asn > 4294967295) {
toast.error('Укажите корректный ASN (14294967295)')
return
}
setSaving(true)
try {
const body: AsEntryCreate | AsEntryPatch = { asn, community_id: form.community_id }
if (edit) {
await apiMutate(`/v1/modules/${moduleId}/as-entries/${edit.id}`, 'PATCH', body)
toast.success('Запись обновлена')
} else {
await apiMutate(`/v1/modules/${moduleId}/as-entries`, 'POST', body as AsEntryCreate)
toast.success('Запись добавлена')
}
onOpenChange(false)
await onSaved()
} catch (e) {
toast.error(e instanceof ApiError ? e.message : String(e))
} finally {
setSaving(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>{edit ? 'Редактировать запись' : 'Новая AS-запись'}</DialogTitle>
<DialogDescription>
Номер автономной системы и community для политики анонса.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="flex flex-col gap-1.5">
<Label htmlFor="as-asn">ASN</Label>
<Input
id="as-asn"
type="number"
placeholder="12345"
value={form.asn || ''}
min={1}
max={4294967295}
onChange={(e) => setForm((s) => ({ ...s, asn: Number(e.target.value) }))}
/>
</div>
<CommunitySelect
id="as-comm"
label="Community"
value={form.community_id ?? null}
onValueChange={(v) => setForm((s) => ({ ...s, community_id: v }))}
communities={communities}
nullable
/>
</div>
<DialogFooter>
<LoadingButton variant="outline" onClick={() => onOpenChange(false)}>
Отмена
</LoadingButton>
<LoadingButton loading={saving} onClick={() => void save()}>
{edit ? 'Сохранить' : 'Добавить'}
</LoadingButton>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,277 @@
import { useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@evobgp/ui/components/dialog'
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 { Button } from '@evobgp/ui/components/button'
import { LoadingButton } from '@/components/loading-button'
import { CommunitySelect } from '@/components/modules/community-select'
import { ApiError, apiMutate } from '@/lib/api-client'
import { normalizeCdnSourceKind } from '@/lib/modules/helpers'
import type { BgpCommunity, CdnPreviewResponse, CdnSource, CdnSourceCreate } from '@/types/api'
const CDN_KIND_ITEMS = [
{ value: 'plaintext', label: 'plaintext' },
{ value: 'json', label: 'json' },
] as const
interface ModuleCdnSourceDialogProps {
open: boolean
moduleId: string
edit: CdnSource | null
communities: BgpCommunity[]
onOpenChange: (open: boolean) => void
onSaved: () => void | Promise<void>
}
type CdnForm = CdnSourceCreate & { refresh_interval_sec?: number | null }
export function ModuleCdnSourceDialog({
open,
moduleId,
edit,
communities,
onOpenChange,
onSaved,
}: ModuleCdnSourceDialogProps) {
const [saving, setSaving] = useState(false)
const [previewLoading, setPreviewLoading] = useState(false)
const [previewItems, setPreviewItems] = useState<string[]>([])
const [previewTotal, setPreviewTotal] = useState(0)
const [previewTruncated, setPreviewTruncated] = useState(false)
const [previewError, setPreviewError] = useState<string | null>(null)
const [previewOk, setPreviewOk] = useState(false)
const [form, setForm] = useState<CdnForm>({
url: '',
source_kind: 'plaintext',
prefix_path: '',
community_id: null,
})
const kindItems = useMemo(() => [...CDN_KIND_ITEMS], [])
function clearPreview() {
setPreviewLoading(false)
setPreviewItems([])
setPreviewTotal(0)
setPreviewTruncated(false)
setPreviewError(null)
setPreviewOk(false)
}
useEffect(() => {
if (!open) {
clearPreview()
return
}
setForm(
edit
? {
url: edit.url,
source_kind: normalizeCdnSourceKind(edit.source_kind),
prefix_path: edit.prefix_path ?? '',
community_id: edit.community_id,
refresh_interval_sec: edit.refresh_interval_sec,
}
: { url: '', source_kind: 'plaintext', prefix_path: '', community_id: null },
)
clearPreview()
}, [open, edit])
async function previewCdn() {
const urlTrim = form.url.trim()
if (!urlTrim) {
toast.error('Укажите URL')
return
}
setPreviewLoading(true)
setPreviewError(null)
setPreviewOk(false)
try {
const res = await apiMutate<CdnPreviewResponse>(
`/v1/modules/${moduleId}/cdn-sources/preview`,
'POST',
{
url: urlTrim,
source_kind: form.source_kind,
prefix_path: form.prefix_path?.trim() ?? '',
},
)
setPreviewItems(res.items)
setPreviewTotal(res.total)
setPreviewTruncated(res.truncated)
setPreviewOk(true)
} catch (e) {
setPreviewError(e instanceof ApiError ? e.message : String(e))
setPreviewItems([])
setPreviewTotal(0)
setPreviewTruncated(false)
setPreviewOk(false)
} finally {
setPreviewLoading(false)
}
}
async function save() {
const urlTrim = form.url.trim()
if (!urlTrim) {
toast.error('Укажите URL')
return
}
setSaving(true)
try {
const body = {
...form,
url: urlTrim,
source_kind: form.source_kind,
prefix_path: form.prefix_path?.trim() ?? '',
}
if (edit) {
await apiMutate(`/v1/modules/${moduleId}/cdn-sources/${edit.id}`, 'PATCH', body)
toast.success('Источник обновлён')
} else {
await apiMutate(`/v1/modules/${moduleId}/cdn-sources`, 'POST', body)
toast.success('Источник добавлен')
}
clearPreview()
onOpenChange(false)
await onSaved()
} catch (e) {
toast.error(e instanceof ApiError ? e.message : String(e))
} finally {
setSaving(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{edit ? 'Редактировать источник' : 'Новый CDN-источник'}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="flex flex-col gap-1.5">
<Label htmlFor="cdn-url">URL</Label>
<Input
id="cdn-url"
placeholder="https://example.com/list.txt"
value={form.url}
onChange={(e) => setForm((s) => ({ ...s, url: e.target.value }))}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="cdn-kind">Тип источника</Label>
<Select
items={kindItems}
value={form.source_kind}
onValueChange={(v) => v && setForm((s) => ({ ...s, source_kind: v }))}
>
<SelectTrigger id="cdn-kind" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{kindItems.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="cdn-prefix-path">JSON path (prefix_path)</Label>
<Input
id="cdn-prefix-path"
placeholder="напр. prefixes[] или data.items[].cidr"
value={form.prefix_path ?? ''}
onChange={(e) => setForm((s) => ({ ...s, prefix_path: e.target.value }))}
/>
{form.source_kind === 'json' && !form.prefix_path?.trim() ? (
<p className="text-xs text-muted-foreground">
Для JSON укажите путь к полям с CIDR; пустой путь может не дать префиксов.
</p>
) : null}
</div>
<CommunitySelect
id="cdn-comm"
label="Community"
value={form.community_id ?? null}
onValueChange={(v) => setForm((s) => ({ ...s, community_id: v }))}
communities={communities}
nullable
/>
<div className="flex flex-col gap-1.5">
<Label htmlFor="cdn-interval">Интервал обновления (сек)</Label>
<Input
id="cdn-interval"
type="number"
placeholder="3600"
value={form.refresh_interval_sec ?? ''}
onChange={(e) =>
setForm((s) => ({
...s,
refresh_interval_sec: e.target.value ? Number(e.target.value) : null,
}))
}
/>
</div>
<div className="flex flex-col gap-2 rounded-lg border border-border p-3">
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
variant="secondary"
size="sm"
onClick={() => void previewCdn()}
disabled={previewLoading}
>
{previewLoading ? 'Загрузка…' : 'Предпросмотр'}
</Button>
{previewError ? (
<span className="text-sm text-destructive">{previewError}</span>
) : previewOk ? (
<span className="text-sm text-muted-foreground">
Всего: {previewTotal}
{previewTruncated ? (
<span className="text-amber-600 dark:text-amber-500"> (обрезано)</span>
) : null}
</span>
) : null}
</div>
{previewItems.length > 0 ? (
<ul className="max-h-48 overflow-y-auto rounded-md border bg-muted/40 p-2 font-mono text-xs">
{previewItems.map((item, i) => (
<li key={`${i}-${item}`} className="py-0.5">
{item}
</li>
))}
</ul>
) : null}
</div>
</div>
<DialogFooter>
<LoadingButton variant="outline" onClick={() => onOpenChange(false)}>
Отмена
</LoadingButton>
<LoadingButton loading={saving} onClick={() => void save()}>
{edit ? 'Сохранить' : 'Добавить'}
</LoadingButton>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,108 @@
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@evobgp/ui/components/dialog'
import { Input } from '@evobgp/ui/components/input'
import { Label } from '@evobgp/ui/components/label'
import { LoadingButton } from '@/components/loading-button'
import { CommunitySelect } from '@/components/modules/community-select'
import { ApiError, apiMutate } from '@/lib/api-client'
import type { BgpCommunity, DomainEntry, DomainEntryCreate } from '@/types/api'
interface ModuleDomainEntryDialogProps {
open: boolean
moduleId: string
edit: DomainEntry | null
communities: BgpCommunity[]
onOpenChange: (open: boolean) => void
onSaved: () => void | Promise<void>
}
export function ModuleDomainEntryDialog({
open,
moduleId,
edit,
communities,
onOpenChange,
onSaved,
}: ModuleDomainEntryDialogProps) {
const [saving, setSaving] = useState(false)
const [form, setForm] = useState<DomainEntryCreate>({ fqdn: '', community_id: null })
useEffect(() => {
if (!open) return
setForm(
edit
? { fqdn: edit.fqdn, community_id: edit.community_id }
: { fqdn: '', community_id: null },
)
}, [open, edit])
async function save() {
if (!form.fqdn.trim()) {
toast.error('Укажите FQDN')
return
}
setSaving(true)
try {
const body = { fqdn: form.fqdn.trim(), community_id: form.community_id }
if (edit) {
await apiMutate(`/v1/modules/${moduleId}/domain-entries/${edit.id}`, 'PATCH', body)
toast.success('Домен обновлён')
} else {
await apiMutate(`/v1/modules/${moduleId}/domain-entries`, 'POST', body)
toast.success('Домен добавлен')
}
onOpenChange(false)
await onSaved()
} catch (e) {
toast.error(e instanceof ApiError ? e.message : String(e))
} finally {
setSaving(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>{edit ? 'Редактировать домен' : 'Новый домен'}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="flex flex-col gap-1.5">
<Label htmlFor="dom-fqdn">FQDN</Label>
<Input
id="dom-fqdn"
placeholder="example.com"
value={form.fqdn}
onChange={(e) => setForm((s) => ({ ...s, fqdn: e.target.value }))}
/>
</div>
<CommunitySelect
id="dom-comm"
label="Community"
value={form.community_id ?? null}
onValueChange={(v) => setForm((s) => ({ ...s, community_id: v }))}
communities={communities}
nullable
/>
</div>
<DialogFooter>
<LoadingButton variant="outline" onClick={() => onOpenChange(false)}>
Отмена
</LoadingButton>
<LoadingButton loading={saving} onClick={() => void save()}>
{edit ? 'Сохранить' : 'Добавить'}
</LoadingButton>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,437 @@
import { useState } from 'react'
import { Pencil, Plus, Trash2 } from 'lucide-react'
import { toast } from 'sonner'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@evobgp/ui/components/alert-dialog'
import { Button } from '@evobgp/ui/components/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@evobgp/ui/components/table'
import { QueryState } from '@/components/query-state'
import { TableSkeleton } from '@/components/skeletons'
import { ModuleAsEntryDialog } from '@/components/modules/module-as-entry-dialog'
import { ModuleCdnSourceDialog } from '@/components/modules/module-cdn-source-dialog'
import { ModuleDomainEntryDialog } from '@/components/modules/module-domain-entry-dialog'
import { ModuleIpRangeEntryDialog } from '@/components/modules/module-ip-range-entry-dialog'
import { ApiError, apiMutate } from '@/lib/api-client'
import { communityLabel } from '@/lib/modules/helpers'
import { formatDateTime } from '@/lib/modules/display'
import type {
AsEntry,
BgpCommunity,
CdnSource,
DomainEntry,
IpRangeEntry,
ModuleRow,
} from '@/types/api'
interface ModuleEntriesSectionProps {
moduleId: string
mod: ModuleRow
items: Record<string, unknown>[]
communities: BgpCommunity[]
isLoading: boolean
isError: boolean
error: Error | null
onRetry: () => void
onChanged: () => void | Promise<void>
}
type DeleteTarget =
| { kind: 'domain'; entry: DomainEntry }
| { kind: 'ip-range'; entry: IpRangeEntry }
| { kind: 'cdn'; entry: CdnSource }
| { kind: 'as'; entry: AsEntry }
const CARD_META: Record<
ModuleRow['type'],
{ title: string; description: string; emptyTitle: string; emptyDescription: string }
> = {
DOMAINS: {
title: 'Домены',
description: 'FQDN для резолвинга через DoH.',
emptyTitle: 'Нет доменов',
emptyDescription: 'Добавьте FQDN для резолвинга.',
},
IP_RANGES: {
title: 'IP-диапазоны',
description: 'Статические CIDR для анонса.',
emptyTitle: 'Нет диапазонов',
emptyDescription: 'Добавьте CIDR.',
},
CDN_CIDRS: {
title: 'CDN-источники',
description: 'URL источников для скачивания списков CIDR.',
emptyTitle: 'Нет источников',
emptyDescription: 'Добавьте CDN-источник.',
},
AS_PREFIXES: {
title: 'AS-записи',
description: 'ASN для получения префиксов через RIPEstat.',
emptyTitle: 'Нет записей',
emptyDescription: 'Добавьте ASN.',
},
}
export function ModuleEntriesSection({
moduleId,
mod,
items,
communities,
isLoading,
isError,
error,
onRetry,
onChanged,
}: ModuleEntriesSectionProps) {
const meta = CARD_META[mod.type]
const [dialogOpen, setDialogOpen] = useState(false)
const [deleteTarget, setDeleteTarget] = useState<DeleteTarget | null>(null)
const [deleting, setDeleting] = useState(false)
const [editDomain, setEditDomain] = useState<DomainEntry | null>(null)
const [editIpRange, setEditIpRange] = useState<IpRangeEntry | null>(null)
const [editCdn, setEditCdn] = useState<CdnSource | null>(null)
const [editAs, setEditAs] = useState<AsEntry | null>(null)
function openCreate() {
setEditDomain(null)
setEditIpRange(null)
setEditCdn(null)
setEditAs(null)
setDialogOpen(true)
}
function closeDialog() {
setDialogOpen(false)
setEditDomain(null)
setEditIpRange(null)
setEditCdn(null)
setEditAs(null)
}
async function confirmDelete() {
if (!deleteTarget) return
setDeleting(true)
try {
const { kind, entry } = deleteTarget
const pathByKind = {
domain: `/v1/modules/${moduleId}/domain-entries/${entry.id}`,
'ip-range': `/v1/modules/${moduleId}/ip-range-entries/${entry.id}`,
cdn: `/v1/modules/${moduleId}/cdn-sources/${entry.id}`,
as: `/v1/modules/${moduleId}/as-entries/${entry.id}`,
} as const
await apiMutate(pathByKind[kind], 'DELETE', undefined, { idempotent: false })
toast.success('Удалено')
setDeleteTarget(null)
await onChanged()
} catch (e) {
toast.error(e instanceof ApiError ? e.message : String(e))
} finally {
setDeleting(false)
}
}
return (
<>
<Card>
<CardHeader className="flex flex-col gap-3 border-b py-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0 flex-1">
<CardTitle className="text-base">{meta.title}</CardTitle>
<CardDescription>{meta.description}</CardDescription>
</div>
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2 self-start sm:self-auto">
<Button size="sm" onClick={openCreate}>
<Plus />
Добавить
</Button>
</div>
</CardHeader>
<CardContent className="p-0">
<QueryState
data={items}
isLoading={isLoading}
isError={isError}
error={error}
empty={items.length === 0}
emptyTitle={meta.emptyTitle}
emptyDescription={meta.emptyDescription}
skeleton={<TableSkeleton rows={5} cols={3} />}
onRetry={onRetry}
>
{(rows) => (
<EntriesTable
mod={mod}
rows={rows}
communities={communities}
onEdit={(target) => {
if (target.kind === 'domain') setEditDomain(target.entry)
if (target.kind === 'ip-range') setEditIpRange(target.entry)
if (target.kind === 'cdn') setEditCdn(target.entry)
if (target.kind === 'as') setEditAs(target.entry)
setDialogOpen(true)
}}
onDelete={setDeleteTarget}
/>
)}
</QueryState>
</CardContent>
</Card>
{mod.type === 'DOMAINS' ? (
<ModuleDomainEntryDialog
open={dialogOpen}
moduleId={moduleId}
edit={editDomain}
communities={communities}
onOpenChange={(open) => (open ? setDialogOpen(true) : closeDialog())}
onSaved={onChanged}
/>
) : null}
{mod.type === 'IP_RANGES' ? (
<ModuleIpRangeEntryDialog
open={dialogOpen}
moduleId={moduleId}
edit={editIpRange}
communities={communities}
onOpenChange={(open) => (open ? setDialogOpen(true) : closeDialog())}
onSaved={onChanged}
/>
) : null}
{mod.type === 'CDN_CIDRS' ? (
<ModuleCdnSourceDialog
open={dialogOpen}
moduleId={moduleId}
edit={editCdn}
communities={communities}
onOpenChange={(open) => (open ? setDialogOpen(true) : closeDialog())}
onSaved={onChanged}
/>
) : null}
{mod.type === 'AS_PREFIXES' ? (
<ModuleAsEntryDialog
open={dialogOpen}
moduleId={moduleId}
edit={editAs}
communities={communities}
onOpenChange={(open) => (open ? setDialogOpen(true) : closeDialog())}
onSaved={onChanged}
/>
) : null}
<AlertDialog open={deleteTarget !== null} onOpenChange={(open) => !open && setDeleteTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Удалить запись?</AlertDialogTitle>
<AlertDialogDescription>{deleteDescription(deleteTarget)}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleting}>Отмена</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
disabled={deleting}
onClick={() => void confirmDelete()}
>
{deleting ? 'Удаление…' : 'Удалить'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}
function deleteDescription(target: DeleteTarget | null): string {
if (!target) return ''
switch (target.kind) {
case 'domain':
return target.entry.fqdn
case 'ip-range':
return target.entry.prefix
case 'cdn':
return target.entry.url
case 'as':
return `AS${target.entry.asn}`
}
}
function EntriesTable({
mod,
rows,
communities,
onEdit,
onDelete,
}: {
mod: ModuleRow
rows: Record<string, unknown>[]
communities: BgpCommunity[]
onEdit: (target: DeleteTarget) => void
onDelete: (target: DeleteTarget) => void
}) {
if (mod.type === 'DOMAINS') {
const entries = rows as unknown as DomainEntry[]
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>FQDN</TableHead>
<TableHead>Community</TableHead>
<TableHead className="w-20" />
</TableRow>
</TableHeader>
<TableBody>
{entries.map((entry) => (
<TableRow key={entry.id}>
<TableCell className="font-mono text-sm">{entry.fqdn}</TableCell>
<TableCell className="text-sm text-muted-foreground">
{communityLabel(entry.community_id, communities)}
</TableCell>
<TableCell>
<RowActions
onEdit={() => onEdit({ kind: 'domain', entry })}
onDelete={() => onDelete({ kind: 'domain', entry })}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)
}
if (mod.type === 'IP_RANGES') {
const entries = rows as unknown as IpRangeEntry[]
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>Префикс (CIDR)</TableHead>
<TableHead>Community</TableHead>
<TableHead className="w-20" />
</TableRow>
</TableHeader>
<TableBody>
{entries.map((entry) => (
<TableRow key={entry.id}>
<TableCell className="font-mono text-sm">{entry.prefix}</TableCell>
<TableCell className="text-sm text-muted-foreground">
{communityLabel(entry.community_id, communities)}
</TableCell>
<TableCell>
<RowActions
onEdit={() => onEdit({ kind: 'ip-range', entry })}
onDelete={() => onDelete({ kind: 'ip-range', entry })}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)
}
if (mod.type === 'CDN_CIDRS') {
const entries = rows as unknown as CdnSource[]
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>URL</TableHead>
<TableHead>Тип</TableHead>
<TableHead>Community</TableHead>
<TableHead>Обновлено</TableHead>
<TableHead className="w-20" />
</TableRow>
</TableHeader>
<TableBody>
{entries.map((entry) => (
<TableRow key={entry.id}>
<TableCell className="max-w-xs truncate font-mono text-xs">{entry.url}</TableCell>
<TableCell className="text-sm">{entry.source_kind}</TableCell>
<TableCell className="text-sm text-muted-foreground">
{communityLabel(entry.community_id, communities)}
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{formatDateTime(entry.last_refreshed_at)}
</TableCell>
<TableCell>
<RowActions
onEdit={() => onEdit({ kind: 'cdn', entry })}
onDelete={() => onDelete({ kind: 'cdn', entry })}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)
}
const entries = rows as unknown as AsEntry[]
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>ASN</TableHead>
<TableHead>Имя</TableHead>
<TableHead>Префиксов</TableHead>
<TableHead>Community</TableHead>
<TableHead className="w-20" />
</TableRow>
</TableHeader>
<TableBody>
{entries.map((entry) => (
<TableRow key={entry.id}>
<TableCell className="font-mono text-sm">{entry.asn}</TableCell>
<TableCell className="text-sm">{entry.asn_name ?? '—'}</TableCell>
<TableCell className="font-mono text-sm">{entry.prefix_count ?? '—'}</TableCell>
<TableCell className="text-sm text-muted-foreground">
{communityLabel(entry.community_id, communities)}
</TableCell>
<TableCell>
<RowActions
onEdit={() => onEdit({ kind: 'as', entry })}
onDelete={() => onDelete({ kind: 'as', entry })}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)
}
function RowActions({ onEdit, onDelete }: { onEdit: () => void; onDelete: () => void }) {
return (
<div className="flex justify-end gap-1">
<Button variant="ghost" size="icon-sm" onClick={onEdit} aria-label="Редактировать">
<Pencil className="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
className="text-destructive"
onClick={onDelete}
aria-label="Удалить"
>
<Trash2 className="size-3.5" />
</Button>
</div>
)
}
@@ -0,0 +1,108 @@
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@evobgp/ui/components/dialog'
import { Input } from '@evobgp/ui/components/input'
import { Label } from '@evobgp/ui/components/label'
import { LoadingButton } from '@/components/loading-button'
import { CommunitySelect } from '@/components/modules/community-select'
import { ApiError, apiMutate } from '@/lib/api-client'
import type { BgpCommunity, IpRangeEntry, IpRangeEntryCreate } from '@/types/api'
interface ModuleIpRangeEntryDialogProps {
open: boolean
moduleId: string
edit: IpRangeEntry | null
communities: BgpCommunity[]
onOpenChange: (open: boolean) => void
onSaved: () => void | Promise<void>
}
export function ModuleIpRangeEntryDialog({
open,
moduleId,
edit,
communities,
onOpenChange,
onSaved,
}: ModuleIpRangeEntryDialogProps) {
const [saving, setSaving] = useState(false)
const [form, setForm] = useState<IpRangeEntryCreate>({ prefix: '', community_id: '' })
useEffect(() => {
if (!open) return
setForm(
edit
? { prefix: edit.prefix, community_id: edit.community_id }
: { prefix: '', community_id: '' },
)
}, [open, edit])
async function save() {
if (!form.prefix.trim() || !form.community_id) {
toast.error('Укажите префикс и community')
return
}
setSaving(true)
try {
const body = { prefix: form.prefix.trim(), community_id: form.community_id }
if (edit) {
await apiMutate(`/v1/modules/${moduleId}/ip-range-entries/${edit.id}`, 'PATCH', body)
toast.success('Диапазон обновлён')
} else {
await apiMutate(`/v1/modules/${moduleId}/ip-range-entries`, 'POST', body)
toast.success('Диапазон добавлен')
}
onOpenChange(false)
await onSaved()
} catch (e) {
toast.error(e instanceof ApiError ? e.message : String(e))
} finally {
setSaving(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>{edit ? 'Редактировать диапазон' : 'Новый IP-диапазон'}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="flex flex-col gap-1.5">
<Label htmlFor="ip-prefix">Префикс (CIDR)</Label>
<Input
id="ip-prefix"
placeholder="203.0.113.0/24"
value={form.prefix}
onChange={(e) => setForm((s) => ({ ...s, prefix: e.target.value }))}
/>
</div>
<CommunitySelect
id="ip-comm"
label="Community (обязательно)"
value={form.community_id || null}
onValueChange={(v) => setForm((s) => ({ ...s, community_id: v ?? '' }))}
communities={communities}
placeholder="Выберите community"
/>
</div>
<DialogFooter>
<LoadingButton variant="outline" onClick={() => onOpenChange(false)}>
Отмена
</LoadingButton>
<LoadingButton loading={saving} onClick={() => void save()}>
{edit ? 'Сохранить' : 'Добавить'}
</LoadingButton>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,84 @@
import {
ArrowDownUp,
Network,
RefreshCw,
ShieldCheck,
Timer,
} from 'lucide-react'
import { SectionCards, type SectionCardItem } from '@/components/section-cards'
import { SectionCardsSkeleton } from '@/components/skeletons'
import { formatDateTime, moduleIntervalLabel } from '@/lib/modules/display'
import {
communityLabel,
dohProfileLabel,
moduleDohProfileIds,
} from '@/lib/modules/helpers'
import { dohPolicyRu } from '@/lib/ui-labels'
import type { AsEntry, BgpCommunity, DohProfile, ModuleRow } from '@/types/api'
interface ModuleKpiCardsProps {
mod: ModuleRow | null
communities: BgpCommunity[]
dohProfiles: DohProfile[]
asEntries: AsEntry[]
loading?: boolean
}
export function ModuleKpiCards({
mod,
communities,
dohProfiles,
asEntries,
loading = false,
}: ModuleKpiCardsProps) {
if (loading || !mod) {
return <SectionCardsSkeleton count={5} />
}
const asPrefixTotal = asEntries.reduce((acc, entry) => acc + (entry.prefix_count ?? 0), 0)
const dohIds = moduleDohProfileIds(mod)
const items: SectionCardItem[] = [
{
label: 'Приоритет',
value: String(mod.priority ?? 0),
hint: 'порядок в сборке ревизии',
icon: <ArrowDownUp className="size-3.5" />,
},
{
label: 'Интервал',
value: moduleIntervalLabel(mod),
hint: 'refresh_interval_sec / cron',
icon: <Timer className="size-3.5" />,
},
{
label: 'DoH',
value: mod.type === 'DOMAINS' ? dohPolicyRu(mod.doh_resolver_policy) : '—',
hint:
mod.type === 'DOMAINS'
? dohIds.length
? dohIds.map((id) => dohProfileLabel(id, dohProfiles)).join('; ')
: 'Системный DNS'
: 'не применимо',
icon: <Network className="size-3.5" />,
},
{
label: 'Community по умолч.',
value: communityLabel(mod.default_community_id, communities),
hint: 'для записей без своего community',
icon: <ShieldCheck className="size-3.5" />,
},
{
label: 'Последнее обновление',
value: formatDateTime(mod.last_refreshed_at),
hint:
mod.type === 'AS_PREFIXES'
? `ASN: ${asEntries.length}, префиксов: ${asPrefixTotal}`
: 'время последнего refresh',
icon: <RefreshCw className="size-3.5" />,
},
]
return <SectionCards items={items} />
}
@@ -153,6 +153,10 @@ function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element {
{mergedProps.rowsPerPageLabel}
</div>
<Select
items={mergedProps?.sizes?.map((size: number) => ({
value: `${size}`,
label: `${size}`,
}))}
value={`${pageSize}`}
onValueChange={(value) => {
const newPageSize = Number(value)
+19
View File
@@ -0,0 +1,19 @@
import type { ApiKeyRole } from '@/types/api'
export const API_KEY_ROLE_ITEMS: ReadonlyArray<{ value: ApiKeyRole; label: string }> = [
{ value: 'viewer', label: 'viewer — только чтение' },
{ value: 'editor', label: 'editor — CRUD без apply' },
{ value: 'operator', label: 'operator — полный доступ' },
{ value: 'node', label: 'node — только API ноды' },
]
export function apiKeyRoleLabel(role: ApiKeyRole): string {
return API_KEY_ROLE_ITEMS.find((o) => o.value === role)?.label ?? role
}
export function formatApiKeyDate(iso: string | null | undefined): string {
if (!iso) return '—'
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return '—'
return d.toLocaleString('ru-RU')
}
+22 -2
View File
@@ -11,6 +11,9 @@ import type {
export const TOKEN_STORAGE_KEY = 'evobgp_api_token'
/** Локальный demo-токен (operator) при включённом demo-seed — см. docs/access.md */
export const DEV_API_TOKEN = 'dev'
export type Problem = {
type?: string
title?: string
@@ -18,14 +21,31 @@ export type Problem = {
detail?: string
}
/** Убирает пробелы и опциональный префикс Bearer (UI часто вставляет «Bearer dev»). */
export function normalizeApiToken(raw: string): string {
let t = raw.trim()
if (/^bearer\s+/i.test(t)) {
t = t.replace(/^bearer\s+/i, '').trim()
}
return t
}
function getToken(): string | null {
if (typeof window === 'undefined') return null
return window.localStorage.getItem(TOKEN_STORAGE_KEY)
const raw = window.localStorage.getItem(TOKEN_STORAGE_KEY)
if (!raw) return null
const normalized = normalizeApiToken(raw)
return normalized || null
}
export function setToken(token: string | null): void {
if (typeof window === 'undefined') return
if (token) window.localStorage.setItem(TOKEN_STORAGE_KEY, token)
if (!token) {
window.localStorage.removeItem(TOKEN_STORAGE_KEY)
return
}
const normalized = normalizeApiToken(token)
if (normalized) window.localStorage.setItem(TOKEN_STORAGE_KEY, normalized)
else window.localStorage.removeItem(TOKEN_STORAGE_KEY)
}
+24
View File
@@ -0,0 +1,24 @@
import type { ModuleRow } from '@/types/api'
export function formatDateTime(value: string | null | undefined): string {
if (typeof value !== 'string' || value.trim().length === 0) return '—'
const parsed = new Date(value)
if (Number.isNaN(parsed.getTime())) return '—'
return parsed.toLocaleString('ru-RU')
}
export function moduleIntervalLabel(moduleRow: ModuleRow): string {
const cron = typeof moduleRow.cron_expr === 'string' ? moduleRow.cron_expr.trim() : ''
const raw = moduleRow.refresh_interval_sec as unknown
const interval =
typeof raw === 'number'
? raw
: typeof raw === 'string' && raw.trim().length > 0
? Number(raw)
: null
const intervalLabel = interval !== null && Number.isFinite(interval) ? `${interval}с` : ''
if (cron && intervalLabel) return `${intervalLabel} (${cron})`
if (cron) return cron
if (intervalLabel) return intervalLabel
return '—'
}
+41
View File
@@ -0,0 +1,41 @@
import type { BgpCommunity, DohProfile, ModuleRow } from '@/types/api'
export const NONE_OPTION = '__none__'
export function communityLabel(id: string | null | undefined, communities: BgpCommunity[]): string {
if (!id) return '—'
const c = communities.find((x) => x.id === id)
if (!c) return `${id.slice(0, 8)}`
const t = c.title?.trim()
return t || c.community
}
export function communityOptionLabel(c: BgpCommunity): string {
const t = c.title?.trim()
return t || c.community
}
export function nullableSelectValue(value: string | null | undefined): string {
if (value === null || value === undefined || value === '') return NONE_OPTION
return value
}
export function fromNullableSelect(value: string): string | null {
if (value === NONE_OPTION || value === '') return null
return value
}
export 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] : []
}
export function dohProfileLabel(id: string, dohProfiles: DohProfile[]): string {
const p = dohProfiles.find((d) => d.id === id)
return p ? (p.name?.trim() ? `${p.name} (${p.url})` : p.url) : `${id.slice(0, 8)}`
}
export function normalizeCdnSourceKind(k: string): 'plaintext' | 'json' {
return k.trim().toLowerCase() === 'json' ? 'json' : 'plaintext'
}
+29
View File
@@ -0,0 +1,29 @@
import type { DohResolverPolicy } from '@/types/api'
export function dohPolicyRu(policy: DohResolverPolicy | string | null | undefined): string {
switch (policy) {
case 'primary_only':
return 'Только первый'
case 'failover':
return 'Резервирование'
case 'union':
return 'Объединение'
default:
return 'Только первый'
}
}
export function moduleTypeRu(type: string): string {
switch (type) {
case 'AS_PREFIXES':
return 'AS (номера)'
case 'CDN_CIDRS':
return 'CDN CIDR'
case 'DOMAINS':
return 'Домены'
case 'IP_RANGES':
return 'IP-диапазоны'
default:
return type
}
}
+50 -3
View File
@@ -1,6 +1,8 @@
import { queryOptions } from '@tanstack/react-query'
import { apiJSON } from '@/lib/api-client'
import type { ApiKey, ApiKeysResponse } from '@/types/api'
import { queryOptions, useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { apiJSON, apiMutate } from '@/lib/api-client'
import type { ApiKey, ApiKeyCreate, ApiKeyCreated, ApiKeysResponse } from '@/types/api'
export const apiKeysKeys = {
all: ['api-keys'] as const,
@@ -17,3 +19,48 @@ export function apiKeysQueryOptions() {
staleTime: 60_000,
})
}
function invalidateApiKeysList(qc: ReturnType<typeof useQueryClient>) {
void qc.invalidateQueries({ queryKey: apiKeysKeys.list() })
}
export function useCreateApiKeyMutation() {
const qc = useQueryClient()
return useMutation({
mutationFn: (body: ApiKeyCreate) =>
apiMutate<ApiKeyCreated>('/v1/api-keys', 'POST', body, { idempotent: false }),
onSuccess: () => {
toast.success('Ключ создан')
invalidateApiKeysList(qc)
},
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось создать ключ'),
})
}
export function useRevokeApiKeyMutation() {
const qc = useQueryClient()
return useMutation({
mutationFn: (id: string) =>
apiMutate(`/v1/api-keys/${id}`, 'DELETE', undefined, { idempotent: false }),
onSuccess: () => {
toast.success('Ключ отозван')
invalidateApiKeysList(qc)
},
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось отозвать'),
})
}
export function useRotateApiKeyMutation() {
const qc = useQueryClient()
return useMutation({
mutationFn: (id: string) =>
apiMutate<ApiKeyCreated>(`/v1/api-keys/${id}/rotate`, 'POST', undefined, {
idempotent: false,
}),
onSuccess: () => {
toast.success('Ключ ротирован')
invalidateApiKeysList(qc)
},
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось ротировать'),
})
}
+8 -4
View File
@@ -1,10 +1,14 @@
import { createFileRoute, Outlet, redirect } from '@tanstack/react-router'
import { normalizeApiToken, TOKEN_STORAGE_KEY } from '@/lib/api-client'
export const Route = createFileRoute('/_auth')({
beforeLoad: () => {
const token =
typeof window !== 'undefined' ? window.localStorage.getItem('evobgp_api_token') : null
if (!token) {
beforeLoad: ({ location }) => {
// Настройки доступны без токена — сюда попадают при первом входе (в т.ч. для `dev`).
if (location.pathname === '/settings') return
const raw =
typeof window !== 'undefined' ? window.localStorage.getItem(TOKEN_STORAGE_KEY) : null
if (!raw || !normalizeApiToken(raw)) {
throw redirect({ to: '/settings' })
}
},
+102 -190
View File
@@ -1,29 +1,18 @@
import { createFileRoute } from '@tanstack/react-router'
import { createFileRoute, Link } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { Info, KeyRound, RefreshCw, ShieldCheck, ShieldOff } from 'lucide-react'
import { useMemo } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
import { Button } from '@evobgp/ui/components/button'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@evobgp/ui/components/table'
import { RefreshCw } from 'lucide-react'
import { toast } from 'sonner'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
import { AccessApiKeysCard } from '@/components/access/access-api-keys-card'
import { PageHeader } from '@/components/page-header'
import { QueryState } from '@/components/query-state'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { SectionCards, type SectionCardItem } from '@/components/section-cards'
import { SectionCardsSkeleton } from '@/components/skeletons'
import { authSessionQueryOptions } from '@/queries/auth'
import { apiKeysQueryOptions } from '@/queries/api-keys'
import { apiMutate } from '@/lib/api-client'
import { useMutation } from '@tanstack/react-query'
import type { ApiKeyCreated } from '@/types/api'
import { useState } from 'react'
import { Copy } from 'lucide-react'
export const Route = createFileRoute('/_auth/access')({
component: AccessComponent,
@@ -39,20 +28,80 @@ function AccessComponent() {
enabled: isOperator,
})
const keys = keysQuery.data ?? []
const activeCount = keys.filter((k) => !k.revoked_at).length
const revokedCount = keys.filter((k) => k.revoked_at).length
const refreshing = sessionQuery.isFetching || keysQuery.isFetching
const kpiItems: SectionCardItem[] = useMemo(
() => [
{
label: 'Всего ключей',
value: keys.length,
icon: <KeyRound className="size-4" />,
hint: 'в tenant',
},
{
label: 'Активных',
value: activeCount,
icon: <ShieldCheck className="size-4" />,
hint: 'не отозваны',
},
{
label: 'Отозванных',
value: revokedCount,
icon: <ShieldOff className="size-4" />,
hint: 'revoked',
variant: revokedCount > 0 ? 'warning' : 'default',
},
],
[keys.length, activeCount, revokedCount],
)
function refetchAll() {
void sessionQuery.refetch()
if (isOperator) void keysQuery.refetch()
}
return (
<div className="mx-auto flex max-w-4xl flex-col gap-6">
<div className="flex flex-col gap-6">
<PageHeader
title="Права доступа"
description="API-ключи control plane и текущая сессия Bearer-токена."
actions={
isOperator ? (
<Button variant="outline" size="sm" onClick={refetchAll} disabled={refreshing}>
<RefreshCw className={refreshing ? 'animate-spin' : ''} />
Обновить
</Button>
) : undefined
}
/>
<Alert className="border-info/30 bg-info/5">
<Info className="text-info" />
<AlertTitle>О API-ключах</AlertTitle>
<AlertDescription>
Роли: <code className="text-xs">viewer</code> (чтение),{' '}
<code className="text-xs">editor</code> (CRUD), <code className="text-xs">operator</code>{' '}
(apply и настройки), <code className="text-xs">node</code> (API ноды). Полный токен
показывается один раз при создании и ротации. Токен браузера в{' '}
<Link to="/settings" className="text-primary underline-offset-4 hover:underline">
настройках
</Link>
; для локальной разработки с demo-seed подойдёт <code className="text-xs">dev</code>{' '}
(роль operator).
</AlertDescription>
</Alert>
{session ? (
<Card>
<CardHeader>
<CardHeader className="border-b py-3">
<CardTitle className="text-base">Текущая сессия</CardTitle>
<CardDescription>Tenant и роль ключа, с которым открыта панель.</CardDescription>
</CardHeader>
<CardContent className="grid gap-3 text-sm sm:grid-cols-2">
<CardContent className="grid gap-3 p-4 text-sm sm:grid-cols-2">
<div>
<p className="text-muted-foreground">Tenant</p>
<p className="break-all font-mono text-xs">{session.tenant_id}</p>
@@ -63,183 +112,46 @@ function AccessComponent() {
</div>
</CardContent>
</Card>
) : null}
) : (
<Card>
<CardContent className="py-6 text-sm text-muted-foreground">
Не удалось определить сессию. Укажите токен в{' '}
<Link to="/settings" className="text-primary underline-offset-4 hover:underline">
настройках
</Link>{' '}
(для dev-окружения <code className="text-xs">dev</code> при включённом demo-seed).
{sessionQuery.isError && sessionQuery.error instanceof Error ? (
<span className="mt-2 block text-destructive">{sessionQuery.error.message}</span>
) : null}
</CardContent>
</Card>
)}
{isOperator ? (
<ApiKeysCard
items={keysQuery.data ?? []}
isLoading={keysQuery.isLoading}
isError={keysQuery.isError}
error={keysQuery.error}
onRetry={() => keysQuery.refetch()}
/>
<>
{keysQuery.isLoading ? (
<SectionCardsSkeleton count={3} />
) : (
<SectionCards items={kpiItems} />
)}
<AccessApiKeysCard
items={keys}
isLoading={keysQuery.isLoading}
isError={keysQuery.isError}
error={keysQuery.error}
onRetry={() => keysQuery.refetch()}
/>
</>
) : session ? (
<Card>
<CardContent className="py-6 text-sm text-muted-foreground">
Управление API-ключами доступно только роли <strong>operator</strong>. Текущая роль:{' '}
<span className="font-mono">{session.role}</span>.
<span className="font-mono">{session.role}</span>. Для выдачи ключей войдите с
operator-ключом или создайте ключ через API / переменную{' '}
<code className="text-xs">EVOBGP_API_KEYS</code>.
</CardContent>
</Card>
) : null}
</div>
)
}
function ApiKeysCard({
items,
isLoading,
isError,
error,
onRetry,
}: {
items: import('@/types/api').ApiKey[]
isLoading: boolean
isError: boolean
error: unknown
onRetry: () => void
}) {
const [revealedToken, setRevealedToken] = useState<string | null>(null)
const revoke = useMutation({
mutationFn: (id: string) =>
apiMutate(`/v1/api-keys/${id}`, 'DELETE', undefined, { idempotent: false }),
onSuccess: () => toast.success('Ключ отозван'),
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось отозвать'),
})
const rotate = useMutation({
mutationFn: (id: string) =>
apiMutate<ApiKeyCreated>(`/v1/api-keys/${id}/rotate`, 'POST', undefined, {
idempotent: false,
}),
onSuccess: (created) => {
toast.success('Ключ ротирован')
setRevealedToken(created.token)
},
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось ротировать'),
})
async function copyToken() {
if (!revealedToken) return
try {
await navigator.clipboard.writeText(revealedToken)
toast.success('Скопировано')
} catch {
toast.error('Не удалось скопировать')
}
}
return (
<Card>
<CardHeader className="flex flex-col gap-3 border-b py-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0 flex-1">
<CardTitle className="text-base">API-ключи</CardTitle>
<CardDescription>
Управление ключами tenant. Полный токен показывается только при создании и ротации.
</CardDescription>
</div>
<Button size="sm" variant="outline" onClick={onRetry} disabled={isLoading}>
<RefreshCw className={isLoading ? 'animate-spin' : ''} />
Обновить
</Button>
</CardHeader>
<CardContent className="p-0">
<QueryState
data={items}
isLoading={isLoading}
isError={isError}
error={error}
empty={items.length === 0}
emptyTitle="Нет ключей"
emptyDescription="Ключи можно создать через API."
onRetry={onRetry}
>
{(data) => (
<Table>
<TableHeader>
<TableRow>
<TableHead>Имя</TableHead>
<TableHead>Роль</TableHead>
<TableHead>Префикс</TableHead>
<TableHead>Статус</TableHead>
<TableHead className="w-24" />
</TableRow>
</TableHeader>
<TableBody>
{data.map((k) => (
<TableRow key={k.id}>
<TableCell className="font-medium">{k.name}</TableCell>
<TableCell className="font-mono text-sm">{k.role}</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground">{k.prefix}</TableCell>
<TableCell>
{k.revoked_at ? (
<span className="text-sm text-destructive">отозван</span>
) : (
<span className="text-sm text-muted-foreground">активен</span>
)}
</TableCell>
<TableCell>
<div className="flex gap-1">
<ConfirmDialog
trigger={
<Button
variant="ghost"
size="icon-sm"
title="Ротировать"
disabled={!!k.revoked_at}
>
<RefreshCw className="size-3.5" />
</Button>
}
title="Ротировать ключ?"
description="Старый токен перестанет работать сразу."
confirmLabel="Ротировать"
onConfirm={() => rotate.mutate(k.id)}
/>
<ConfirmDialog
trigger={
<Button
variant="ghost"
size="icon-sm"
className="text-destructive"
disabled={!!k.revoked_at}
title="Отозвать"
>
<Copy className="size-3.5" />
</Button>
}
title="Отозвать API-ключ?"
description={`${k.name} (${k.prefix}…)`}
confirmLabel="Отозвать"
destructive
onConfirm={() => revoke.mutate(k.id)}
/>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</QueryState>
</CardContent>
{revealedToken ? (
<div className="flex flex-col gap-3 border-t p-4">
<div className="text-sm font-medium">Новый токен (сохраните сейчас):</div>
<div className="break-all rounded-md border bg-muted/40 p-3 font-mono text-xs">
{revealedToken}
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={copyToken}>
<Copy /> Копировать
</Button>
<Button size="sm" onClick={() => setRevealedToken(null)}>
Готово
</Button>
</div>
</div>
) : null}
</Card>
)
}
+87 -142
View File
@@ -1,44 +1,82 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { ArrowLeft, RefreshCw } from 'lucide-react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { ArrowLeft, Info, RefreshCw } from 'lucide-react'
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
import { Button } from '@evobgp/ui/components/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@evobgp/ui/components/table'
import { Badge } from '@/components/reui/badge'
import { PageHeader } from '@/components/page-header'
import { QueryState } from '@/components/query-state'
import { TableSkeleton } from '@/components/skeletons'
import { StatusBadge } from '@/components/status-badge'
import { moduleDetailQueryOptions, moduleEntriesQueryOptions } from '@/queries/modules'
import { ModuleEntriesSection } from '@/components/modules/module-entries-section'
import { ModuleKpiCards } from '@/components/modules/module-kpi-cards'
import { moduleTypeRu } from '@/lib/ui-labels'
import {
directoriesCommunitiesQueryOptions,
directoriesDohQueryOptions,
} from '@/queries/directories'
import { moduleDetailQueryOptions, moduleEntriesQueryOptions, modulesKeys } from '@/queries/modules'
import type { AsEntry, ModuleRow } from '@/types/api'
export const Route = createFileRoute('/_auth/modules/$moduleId')({
component: ModuleDetailComponent,
})
function moduleTypeAlert(type: ModuleRow['type']): string {
switch (type) {
case 'AS_PREFIXES':
return 'Модуль AS получает префиксы через RIPEstat по указанным ASN. После refresh счётчики префиксов обновляются в таблице записей.'
case 'CDN_CIDRS':
return 'Модуль CDN скачивает списки CIDR по URL (plaintext или JSON). Используйте предпросмотр при добавлении источника.'
case 'DOMAINS':
return 'Модуль доменов резолвит FQDN через DoH-профили и конвертирует IP в префиксы. Политика и профили настраиваются в редактировании модуля.'
case 'IP_RANGES':
return 'Модуль IP-диапазонов использует статические CIDR без внешнего refresh (сервер может вернуть 204). Записи участвуют в агрегации напрямую.'
}
}
function ModuleDetailComponent() {
const { moduleId } = Route.useParams()
const queryClient = useQueryClient()
const detail = useQuery(moduleDetailQueryOptions(moduleId))
const mod = detail.data
const communitiesQ = useQuery(directoriesCommunitiesQueryOptions())
const dohQ = useQuery(directoriesDohQueryOptions())
const entriesQuery = useQuery({
...moduleEntriesQueryOptions(moduleId, mod?.type ?? 'DOMAINS'),
enabled: !!mod,
})
const asEntriesQ = useQuery({
...moduleEntriesQueryOptions(moduleId, 'AS_PREFIXES'),
enabled: mod?.type === 'AS_PREFIXES',
select: (data) => data.items as unknown as AsEntry[],
})
async function refreshAll() {
await Promise.all([detail.refetch(), entriesQuery.refetch(), communitiesQ.refetch(), dohQ.refetch()])
}
async function onEntriesChanged() {
await entriesQuery.refetch()
if (mod?.type === 'AS_PREFIXES') {
await queryClient.invalidateQueries({ queryKey: modulesKeys.asEntries(moduleId) })
}
await detail.refetch()
}
const communities = communitiesQ.data?.items ?? []
const dohProfiles = dohQ.data?.items ?? []
const asEntries = mod?.type === 'AS_PREFIXES' ? (asEntriesQ.data ?? []) : []
return (
<div className="flex flex-col gap-6">
<PageHeader
title={mod?.name ?? moduleId}
description={mod ? `Тип: ${mod.type}` : 'Загрузка модуля…'}
description={mod ? `Тип: ${moduleTypeRu(mod.type)} (${mod.type})` : 'Загрузка модуля…'}
actions={
<>
<Button variant="outline" size="sm" render={<Link to="/modules" />}>
@@ -48,10 +86,7 @@ function ModuleDetailComponent() {
<Button
variant="outline"
size="sm"
onClick={() => {
void detail.refetch()
void entriesQuery.refetch()
}}
onClick={() => void refreshAll()}
disabled={detail.isFetching}
>
<RefreshCw className={detail.isFetching ? 'animate-spin' : ''} />
@@ -70,133 +105,43 @@ function ModuleDetailComponent() {
onRetry={() => detail.refetch()}
>
{(m) => (
<>
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader className="border-b py-3">
<CardTitle className="text-base">Параметры</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-2 gap-3 p-4 text-sm">
<Field label="ID" value={<code className="font-mono text-xs">{m.id}</code>} />
<Field label="Тип" value={<Badge variant="outline">{m.type}</Badge>} />
<Field label="Приоритет" value={<span className="font-mono">{m.priority}</span>} />
<Field
label="Состояние"
value={
m.enabled ? (
<StatusBadge status="active" label="включён" />
) : (
<StatusBadge status="paused" label="выключен" />
)
}
/>
<Field
label="Интервал"
value={
m.refresh_interval_sec ? `${m.refresh_interval_sec}s` : '—'
}
/>
<Field
label="Cron"
value={m.cron_expr ? <code className="font-mono text-xs">{m.cron_expr}</code> : '—'}
/>
<Field
label="Последний рефреш"
value={
m.last_refreshed_at
? new Date(m.last_refreshed_at).toLocaleString('ru-RU')
: '—'
}
/>
</CardContent>
</Card>
<Card>
<CardHeader className="border-b py-3">
<CardTitle className="text-base">Маршрутные списки</CardTitle>
<CardDescription>Источник префиксов для модуля</CardDescription>
</CardHeader>
<CardContent className="p-0">
<QueryState
data={entriesQuery.data?.items}
isLoading={entriesQuery.isLoading}
isError={entriesQuery.isError}
error={entriesQuery.error}
empty={(entriesQuery.data?.items?.length ?? 0) === 0}
emptyTitle="Записей нет"
emptyDescription="Добавьте записи через API или создание ревизии."
skeleton={<TableSkeleton rows={5} cols={2} />}
onRetry={() => entriesQuery.refetch()}
>
{(items) => <EntriesTable items={items} moduleType={m.type} />}
</QueryState>
</CardContent>
</Card>
<div className="flex flex-col gap-6">
<div className="flex flex-wrap items-center gap-2">
{m.enabled ? (
<StatusBadge status="active" label="включён" />
) : (
<StatusBadge status="paused" label="выключен" />
)}
</div>
</>
<Alert>
<Info />
<AlertTitle>О модуле</AlertTitle>
<AlertDescription>{moduleTypeAlert(m.type)}</AlertDescription>
</Alert>
<ModuleKpiCards
mod={m}
communities={communities}
dohProfiles={dohProfiles}
asEntries={asEntries}
loading={detail.isLoading || communitiesQ.isLoading}
/>
<ModuleEntriesSection
moduleId={moduleId}
mod={m}
items={entriesQuery.data?.items ?? []}
communities={communities}
isLoading={entriesQuery.isLoading}
isError={entriesQuery.isError}
error={entriesQuery.error}
onRetry={() => entriesQuery.refetch()}
onChanged={onEntriesChanged}
/>
</div>
)}
</QueryState>
</div>
)
}
function Field({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground">{label}</span>
<span>{value}</span>
</div>
)
}
function EntriesTable({
items,
moduleType,
}: {
items: Record<string, unknown>[]
moduleType: string
}) {
const primary = ENTRY_PRIMARY_KEY[moduleType as keyof typeof ENTRY_PRIMARY_KEY] ?? 'id'
const secondary = ENTRY_SECONDARY_KEY[moduleType as keyof typeof ENTRY_SECONDARY_KEY] ?? null
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>{primary}</TableHead>
{secondary ? <TableHead>{secondary}</TableHead> : null}
</TableRow>
</TableHeader>
<TableBody>
{items.map((item, idx) => {
const id = String(item.id ?? idx)
const primaryVal = String(item[primary] ?? '—')
return (
<TableRow key={id}>
<TableCell className="font-mono text-sm">{primaryVal}</TableCell>
{secondary ? (
<TableCell className="font-mono text-xs text-muted-foreground">
{String(item[secondary] ?? '—')}
</TableCell>
) : null}
</TableRow>
)
})}
</TableBody>
</Table>
)
}
const ENTRY_PRIMARY_KEY = {
DOMAINS: 'fqdn',
AS_PREFIXES: 'asn',
CDN_CIDRS: 'url',
IP_RANGES: 'prefix',
} as const
const ENTRY_SECONDARY_KEY = {
DOMAINS: 'community_id',
AS_PREFIXES: 'community_id',
CDN_CIDRS: 'community_id',
IP_RANGES: 'community_id',
} as const
+11 -3
View File
@@ -2,7 +2,7 @@ import { createFileRoute, useSearch } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { AlertTriangle, Clock, Activity, Info, RefreshCw } from 'lucide-react'
import { toast } from 'sonner'
import { useState } from 'react'
import { useState, useMemo } from 'react'
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
import { Button } from '@evobgp/ui/components/button'
@@ -368,6 +368,14 @@ function DiffTab({ revisions }: { revisions: import('@/types/api').RevisionRow[]
const [a, setA] = useState('')
const [b, setB] = useState('')
const diffQ = useQuery(operationsDiffQueryOptions(a, b))
const revisionItems = useMemo(
() =>
revisions.map((r) => ({
value: r.id,
label: `${r.id.slice(0, 12)}`,
})),
[revisions],
)
return (
<Card>
@@ -378,7 +386,7 @@ function DiffTab({ revisions }: { revisions: import('@/types/api').RevisionRow[]
<div className="flex flex-wrap items-end gap-3">
<div className="flex w-full max-w-xs flex-col gap-1">
<span className="text-xs text-muted-foreground">Ревизия A</span>
<Select value={a} onValueChange={(v) => v && setA(v)}>
<Select items={revisionItems} value={a} onValueChange={(v) => v && setA(v)}>
<SelectTrigger>
<SelectValue placeholder="Выберите" />
</SelectTrigger>
@@ -393,7 +401,7 @@ function DiffTab({ revisions }: { revisions: import('@/types/api').RevisionRow[]
</div>
<div className="flex w-full max-w-xs flex-col gap-1">
<span className="text-xs text-muted-foreground">Ревизия B</span>
<Select value={b} onValueChange={(v) => v && setB(v)}>
<Select items={revisionItems} value={b} onValueChange={(v) => v && setB(v)}>
<SelectTrigger>
<SelectValue placeholder="Выберите" />
</SelectTrigger>
+72 -16
View File
@@ -1,6 +1,8 @@
import { createFileRoute } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
import { Button } from '@evobgp/ui/components/button'
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'
@@ -14,10 +16,10 @@ import {
import { PageHeader } from '@/components/page-header'
import { LoadingButton } from '@/components/loading-button'
import { setToken, TOKEN_STORAGE_KEY } from '@/lib/api-client'
import { authSessionQueryOptions } from '@/queries/auth'
import { DEV_API_TOKEN, normalizeApiToken, setToken, TOKEN_STORAGE_KEY } from '@/lib/api-client'
import { authKeys, authSessionQueryOptions } from '@/queries/auth'
import { toast } from 'sonner'
import { Save } from 'lucide-react'
import { Info, Save } from 'lucide-react'
import { useTheme } from 'next-themes'
import { useEffect, useState } from 'react'
@@ -25,8 +27,21 @@ export const Route = createFileRoute('/_auth/settings')({
component: SettingsComponent,
})
const THEME_SELECT_ITEMS = [
{ value: 'light', label: 'Светлая' },
{ value: 'dark', label: 'Тёмная' },
{ value: 'system', label: 'Как в системе' },
] as const
function SettingsComponent() {
const { data: session } = useQuery(authSessionQueryOptions())
const navigate = useNavigate()
const qc = useQueryClient()
const { data: session, isError: sessionError, error: sessionQueryError } = useQuery({
...authSessionQueryOptions(),
enabled: Boolean(
typeof window !== 'undefined' && window.localStorage.getItem(TOKEN_STORAGE_KEY)?.trim(),
),
})
const { theme, setTheme } = useTheme()
const [token, setTokenValue] = useState('')
@@ -35,10 +50,23 @@ function SettingsComponent() {
setTokenValue(t)
}, [])
function saveTokenHandler() {
const t = token.trim()
setToken(t || null)
async function applyToken(raw: string) {
const normalized = normalizeApiToken(raw)
setToken(normalized || null)
setTokenValue(normalized)
await qc.invalidateQueries({ queryKey: authKeys.all })
toast.success('Токен сохранён')
if (normalized) {
void navigate({ to: '/dashboard' })
}
}
function saveTokenHandler() {
void applyToken(token)
}
function useDevToken() {
void applyToken(DEV_API_TOKEN)
}
return (
@@ -48,11 +76,21 @@ function SettingsComponent() {
description="Параметры интерфейса и подключения браузера к API."
/>
<Alert className="border-info/30 bg-info/5">
<Info className="text-info" />
<AlertTitle>Локальная разработка</AlertTitle>
<AlertDescription>
При включённом demo-seed API принимает токен <code className="text-xs">dev</code> (роль{' '}
<code className="text-xs">operator</code>). Вводите только значение токена, без префикса{' '}
<code className="text-xs">Bearer</code> он добавляется автоматически.
</AlertDescription>
</Alert>
<Card>
<CardHeader>
<CardTitle>Подключение к API</CardTitle>
<CardDescription>
Bearer-токен хранится только в этом браузере (localStorage). Управление ключами tenant в
Токен хранится только в этом браузере (localStorage). Управление ключами tenant в
разделе «Права доступа».
</CardDescription>
</CardHeader>
@@ -65,19 +103,33 @@ function SettingsComponent() {
autoComplete="off"
value={token}
onChange={(e) => setTokenValue(e.target.value)}
placeholder="Bearer …"
placeholder="dev или API-ключ"
/>
</div>
<LoadingButton onClick={saveTokenHandler}>
<Save />
Сохранить токен
</LoadingButton>
<div className="flex flex-wrap gap-2">
<LoadingButton onClick={saveTokenHandler}>
<Save />
Сохранить токен
</LoadingButton>
<Button type="button" variant="outline" onClick={useDevToken}>
Использовать dev
</Button>
</div>
{session ? (
<p className="text-xs text-muted-foreground">
Активная сессия: tenant <code className="font-mono">{session.tenant_id}</code>, роль{' '}
<code className="font-mono">{session.role}</code>.
</p>
) : null}
{sessionError ? (
<p className="text-xs text-destructive">
{sessionQueryError instanceof Error
? sessionQueryError.message
: 'Не удалось проверить сессию'}
. Для токена <code className="font-mono">dev</code> нужен demo-seed (
<code className="text-xs">EVOBGP_SEED_DEMO</code> 0) и запущенный API.
</p>
) : null}
</CardContent>
</Card>
@@ -90,7 +142,11 @@ function SettingsComponent() {
</CardHeader>
<CardContent className="flex flex-col gap-2">
<Label htmlFor="theme-select">Тема</Label>
<Select value={theme ?? 'system'} onValueChange={(v) => v && setTheme(v)}>
<Select
items={[...THEME_SELECT_ITEMS]}
value={theme ?? 'system'}
onValueChange={(v) => v && setTheme(v)}
>
<SelectTrigger id="theme-select" className="w-full max-w-xs">
<SelectValue placeholder="Выберите тему" />
</SelectTrigger>
+14 -1
View File
@@ -35,6 +35,7 @@ import {
RUNTIME_LOGS_SETTING_KEYS,
buildPayload,
partitionSettings,
settingsKeys,
settingsQueryOptions,
type BirdSettingKey,
} from '@/queries/settings'
@@ -49,6 +50,16 @@ export const Route = createFileRoute('/_auth/tenant-settings')({
}),
})
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',
@@ -83,7 +94,7 @@ function TenantSettingsComponent() {
apiMutate('/v1/settings', 'PATCH', payload),
onSuccess: () => {
toast.success('Параметры сохранены')
void qc.invalidateQueries({ queryKey: ['settings'] })
void qc.invalidateQueries({ queryKey: settingsKeys.all })
},
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось сохранить'),
})
@@ -242,6 +253,7 @@ function TenantSettingsComponent() {
<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 &&
@@ -299,6 +311,7 @@ function TenantSettingsComponent() {
<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 &&
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/main.tsx","./src/routetree.gen.ts","./src/components/confirm-dialog.tsx","./src/components/empty-state.tsx","./src/components/loading-button.tsx","./src/components/mode-toggle.tsx","./src/components/page-header.tsx","./src/components/page-shell.tsx","./src/components/query-state.tsx","./src/components/section-cards.tsx","./src/components/skeletons.tsx","./src/components/status-badge.tsx","./src/components/theme-provider.tsx","./src/components/truncated-text.tsx","./src/components/layout/app-shell.tsx","./src/components/reui/autocomplete.tsx","./src/components/reui/badge.tsx","./src/components/reui/date-selector.tsx","./src/components/reui/filters.tsx","./src/components/reui/number-field.tsx","./src/components/reui/data-grid/data-grid-column-filter.tsx","./src/components/reui/data-grid/data-grid-column-header.tsx","./src/components/reui/data-grid/data-grid-column-visibility.tsx","./src/components/reui/data-grid/data-grid-pagination.tsx","./src/components/reui/data-grid/data-grid-scroll-area.tsx","./src/components/reui/data-grid/data-grid-table-dnd-rows.tsx","./src/components/reui/data-grid/data-grid-table-dnd.tsx","./src/components/reui/data-grid/data-grid-table-virtual.tsx","./src/components/reui/data-grid/data-grid-table.tsx","./src/components/reui/data-grid/data-grid.tsx","./src/lib/api-client.ts","./src/lib/queryclient.ts","./src/lib/router.ts","./src/queries/api-keys.ts","./src/queries/auth.ts","./src/queries/directories.ts","./src/queries/modules.ts","./src/queries/monitoring.ts","./src/queries/network.ts","./src/queries/operations.ts","./src/queries/overview.ts","./src/queries/settings.ts","./src/routes/__root.tsx","./src/routes/_auth.tsx","./src/routes/index.tsx","./src/routes/_auth/access.tsx","./src/routes/_auth/dashboard.tsx","./src/routes/_auth/directories.tsx","./src/routes/_auth/monitoring.tsx","./src/routes/_auth/network.tsx","./src/routes/_auth/operations.tsx","./src/routes/_auth/schedule.tsx","./src/routes/_auth/settings.tsx","./src/routes/_auth/tenant-settings.tsx","./src/routes/_auth/modules/$moduleid.tsx","./src/routes/_auth/modules/index.tsx","./src/routes/_auth/modules/new.tsx","./src/types/api.ts","./vite.config.ts"],"version":"5.9.3"}
{"root":["./src/main.tsx","./src/routetree.gen.ts","./src/components/confirm-dialog.tsx","./src/components/empty-state.tsx","./src/components/loading-button.tsx","./src/components/mode-toggle.tsx","./src/components/page-header.tsx","./src/components/page-shell.tsx","./src/components/query-state.tsx","./src/components/section-cards.tsx","./src/components/skeletons.tsx","./src/components/status-badge.tsx","./src/components/theme-provider.tsx","./src/components/truncated-text.tsx","./src/components/access/access-api-keys-card.tsx","./src/components/access/api-key-create-dialog.tsx","./src/components/access/api-key-token-dialog.tsx","./src/components/layout/app-shell.tsx","./src/components/modules/community-select.tsx","./src/components/modules/module-as-entry-dialog.tsx","./src/components/modules/module-cdn-source-dialog.tsx","./src/components/modules/module-domain-entry-dialog.tsx","./src/components/modules/module-entries-section.tsx","./src/components/modules/module-ip-range-entry-dialog.tsx","./src/components/modules/module-kpi-cards.tsx","./src/components/reui/autocomplete.tsx","./src/components/reui/badge.tsx","./src/components/reui/date-selector.tsx","./src/components/reui/filters.tsx","./src/components/reui/number-field.tsx","./src/components/reui/data-grid/data-grid-column-filter.tsx","./src/components/reui/data-grid/data-grid-column-header.tsx","./src/components/reui/data-grid/data-grid-column-visibility.tsx","./src/components/reui/data-grid/data-grid-pagination.tsx","./src/components/reui/data-grid/data-grid-scroll-area.tsx","./src/components/reui/data-grid/data-grid-table-dnd-rows.tsx","./src/components/reui/data-grid/data-grid-table-dnd.tsx","./src/components/reui/data-grid/data-grid-table-virtual.tsx","./src/components/reui/data-grid/data-grid-table.tsx","./src/components/reui/data-grid/data-grid.tsx","./src/lib/api-client.ts","./src/lib/queryclient.ts","./src/lib/router.ts","./src/lib/ui-labels.ts","./src/lib/access/api-key-labels.ts","./src/lib/modules/display.ts","./src/lib/modules/helpers.ts","./src/queries/api-keys.ts","./src/queries/auth.ts","./src/queries/directories.ts","./src/queries/modules.ts","./src/queries/monitoring.ts","./src/queries/network.ts","./src/queries/operations.ts","./src/queries/overview.ts","./src/queries/settings.ts","./src/routes/__root.tsx","./src/routes/_auth.tsx","./src/routes/index.tsx","./src/routes/_auth/access.tsx","./src/routes/_auth/dashboard.tsx","./src/routes/_auth/directories.tsx","./src/routes/_auth/monitoring.tsx","./src/routes/_auth/network.tsx","./src/routes/_auth/operations.tsx","./src/routes/_auth/schedule.tsx","./src/routes/_auth/settings.tsx","./src/routes/_auth/tenant-settings.tsx","./src/routes/_auth/modules/$moduleid.tsx","./src/routes/_auth/modules/index.tsx","./src/routes/_auth/modules/new.tsx","./src/types/api.ts","./vite.config.ts"],"version":"5.9.3"}
+2
View File
@@ -50,6 +50,8 @@ opkey|01ARZ3NDEKTSV4RRFFQ69G5FAV|operator,nodekey|01ARZ3NDEKTSV4RRFFQ69G5FAV|nod
Если в store доступен демо-tenant (`DemoIDs`, обычно `EVOBGP_SEED_DEMO` не равен `0`), заголовок **`Authorization: Bearer dev`** даёт роль **`operator`** для этого tenant. **Не зависит** от `EVOBGP_DEV_INSECURE`.
Без demo-tenant токен `dev` может быть задан в `EVOBGP_API_KEYS` (break-glass).
**Запрещено** в продакшене: не оставляйте demo-seed с известным токеном `dev` на боевых данных. Переменная `EVOBGP_DEV_INSECURE` в текущей версии **не влияет** на аутентификацию (оставлена в compose для совместимости; не включайте в production — см. SEC-02 в инженерных правилах).
### PostgreSQL monitoring и maintenance (control plane)
+27 -11
View File
@@ -63,27 +63,43 @@ func (s *Server) authMiddleware(next http.Handler) http.Handler {
return
}
raw := strings.TrimSpace(strings.TrimPrefix(h, p))
if raw == "dev" {
if a, ok := s.devAuth(); ok {
r = r.WithContext(context.WithValue(r.Context(), authCtxKey, a))
next.ServeHTTP(w, r)
return
}
}
matched, ok := s.keyResolver.Lookup(raw)
a, ok := s.resolveAuth(raw)
if !ok {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "unknown api key")
return
}
a := Auth{TenantID: matched.tenantID, Role: matched.role, Token: raw, APIKeyID: matched.keyID}
if matched.keyID != "" {
go func(id string) { _ = s.store.TouchAPIKeyLastUsed(id) }(matched.keyID)
if a.APIKeyID != "" {
go func(id string) { _ = s.store.TouchAPIKeyLastUsed(id) }(a.APIKeyID)
}
r = r.WithContext(context.WithValue(r.Context(), authCtxKey, a))
next.ServeHTTP(w, r)
})
}
func authFromKeyRecord(raw string, rec apiKeyRecord) Auth {
return Auth{TenantID: rec.tenantID, Role: rec.role, Token: raw, APIKeyID: rec.keyID}
}
// resolveAuth maps a bearer token to tenant identity.
// For the literal token "dev", the demo shortcut (devAuth) takes precedence when demo-seed
// is available; env/DB mapping is used only when demo tenant is absent.
func (s *Server) resolveAuth(raw string) (Auth, bool) {
if raw == "dev" {
if a, ok := s.devAuth(); ok {
return a, true
}
if rec, ok := s.keyResolver.Lookup(raw); ok {
return authFromKeyRecord(raw, rec), true
}
return Auth{}, false
}
rec, ok := s.keyResolver.Lookup(raw)
if !ok {
return Auth{}, false
}
return authFromKeyRecord(raw, rec), true
}
func (s *Server) devAuth() (Auth, bool) {
tid, _, _, _, _ := s.store.DemoIDs()
if tid == "" {
+101
View File
@@ -0,0 +1,101 @@
package httpapi
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
)
func TestBearerDevGetSettings(t *testing.T) {
srv, err := New(Options{SeedDemo: true, BundleSeedHex: testBundleSeed})
if err != nil {
t.Fatal(err)
}
defer srv.Close()
ts := httptest.NewServer(srv.Handler())
defer ts.Close()
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/settings", nil)
req.Header.Set("Authorization", "Bearer dev")
resp, err := ts.Client().Do(req)
if err != nil {
t.Fatal(err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("status=%d body=%s", resp.StatusCode, b)
}
}
func TestBearerDevPrefersDemoTenantOverEnvKey(t *testing.T) {
srv, err := New(Options{SeedDemo: true, BundleSeedHex: testBundleSeed})
if err != nil {
t.Fatal(err)
}
defer srv.Close()
demoTenant, _, _, _, _ := srv.Store().DemoIDs()
otherTenant := "00000000-0000-4000-8000-000000000001"
mustSetTestAPIKeys(t, srv, "dev|"+otherTenant+"|operator")
ts := httptest.NewServer(srv.Handler())
defer ts.Close()
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/auth/session", nil)
req.Header.Set("Authorization", "Bearer dev")
resp, err := ts.Client().Do(req)
if err != nil {
t.Fatal(err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("session status=%d body=%s", resp.StatusCode, b)
}
var body map[string]any
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
t.Fatal(err)
}
got, _ := body["tenant_id"].(string)
if got != demoTenant {
t.Fatalf("tenant_id=%q want demo tenant %q (env=%q)", got, demoTenant, otherTenant)
}
}
func TestBearerDevFallsBackToEnvKeyWithoutDemo(t *testing.T) {
srv, err := New(Options{SeedDemo: false, BundleSeedHex: testBundleSeed})
if err != nil {
t.Fatal(err)
}
defer srv.Close()
otherTenant := "00000000-0000-4000-8000-000000000001"
mustSetTestAPIKeys(t, srv, "dev|"+otherTenant+"|operator")
ts := httptest.NewServer(srv.Handler())
defer ts.Close()
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/auth/session", nil)
req.Header.Set("Authorization", "Bearer dev")
resp, err := ts.Client().Do(req)
if err != nil {
t.Fatal(err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("session status=%d body=%s", resp.StatusCode, b)
}
var body map[string]any
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
t.Fatal(err)
}
got, _ := body["tenant_id"].(string)
if got != otherTenant {
t.Fatalf("tenant_id=%q want env key tenant %q", got, otherTenant)
}
}
+6
View File
@@ -589,6 +589,9 @@ func (p *Postgres) DeleteIPRangeEntry(tenantID, moduleID, entryID string) error
}
func (p *Postgres) ListGlobalSettings(tenantID string) (map[string]any, error) {
if _, err := uuid.Parse(tenantID); err != nil {
return nil, store.ErrInvalidInput
}
ctx := context.Background()
rows, err := p.pool.Query(ctx, `SELECT key, value_json FROM global_settings WHERE tenant_id=$1`, tenantID)
if err != nil {
@@ -610,6 +613,9 @@ func (p *Postgres) ListGlobalSettings(tenantID string) (map[string]any, error) {
}
func (p *Postgres) PatchGlobalSettings(tenantID string, patch map[string]any) error {
if _, err := uuid.Parse(tenantID); err != nil {
return store.ErrInvalidInput
}
if patch == nil {
return nil
}
+4
View File
@@ -111,11 +111,15 @@ function SelectLabel({
function SelectItem({
className,
children,
label,
...props
}: SelectPrimitive.Item.Props) {
const resolvedLabel = label ?? (typeof children === "string" ? children : undefined)
return (
<SelectPrimitive.Item
data-slot="select-item"
label={resolvedLabel}
className={cn(
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
+5 -4
View File
@@ -13,9 +13,10 @@ function Tabs({
return (
<TabsPrimitive.Root
data-slot="tabs"
orientation={orientation}
data-orientation={orientation}
className={cn(
"group/tabs flex gap-2 data-horizontal:flex-col",
"group/tabs flex gap-2 data-[orientation=horizontal]:flex-col",
className
)}
{...props}
@@ -24,7 +25,7 @@ function Tabs({
}
const tabsListVariants = cva(
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-[orientation=horizontal]/tabs:h-8 group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col data-[variant=line]:rounded-none",
{
variants: {
variant: {
@@ -58,10 +59,10 @@ function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
<TabsPrimitive.Tab
data-slot="tabs-trigger"
className={cn(
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
className
)}
{...props}