Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53b3c49612 | ||
|
|
db79820df0 | ||
|
|
a902a4270d | ||
|
|
3a3e6db018 | ||
|
|
144d342c16 | ||
|
|
0af37d55c4 | ||
|
|
78f2ecc246 | ||
|
|
8c97445f7e | ||
|
|
0ea5b3b738 | ||
|
|
66b785f7cb | ||
|
|
cb14194a5f | ||
|
|
9c38e1bc57 | ||
|
|
4ac99e43ae |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"pid": 44608,
|
||||
"pid": 59836,
|
||||
"version": "0.9.9",
|
||||
"socketPath": "\\\\.\\pipe\\codegraph-97b92efdcc5351da",
|
||||
"startedAt": 1781240018712
|
||||
"startedAt": 1783332305171
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ UI-правила репозитория: `.cursor/rules/web-shadcn.mdc` (MCP +
|
||||
1. **Контракт HTTP** — `docs/openapi.yaml` (не Context7).
|
||||
2. **Context7** — синтаксис и API библиотек из таблицы.
|
||||
3. **Локальные docs** — `docs/`, `web/README.md`, `AGENTS.md`.
|
||||
4. **Официальный сайт** — BIRD: https://bird.network.cz/?get_doc (если Context7 не покрыл кейс).
|
||||
4. **Официальный сайт** — BIRD: https://bird.nic.cz/?get_doc (если Context7 не покрыл кейс).
|
||||
|
||||
## Примеры запросов
|
||||
|
||||
|
||||
@@ -207,7 +207,7 @@ alwaysApply: true
|
||||
| OpenAPI / problem+json | `docs/openapi.yaml`, RFC 9457 |
|
||||
| Svelte / Kit | https://svelte.dev/docs , https://kit.svelte.dev/docs |
|
||||
| shadcn-svelte | https://shadcn-svelte.com/docs |
|
||||
| BIRD 2 | https://bird.network.cz/?get_doc |
|
||||
| BIRD 2 | https://bird.nic.cz/?get_doc |
|
||||
| Prometheus Go | https://pkg.go.dev/github.com/prometheus/client_golang |
|
||||
|
||||
**DOC-SYNC-01** | MUST | Новый API библиотеки — сверка версии в `go.mod`/`package.json` с официальной документацией.
|
||||
|
||||
@@ -23,7 +23,7 @@ alwaysApply: false
|
||||
| Параметры BIRD tenant | Global settings: `bird_router_id`, `bird_local_asn`, … (`docs/manual.md`) |
|
||||
| BGP peers | `BGPPeer` + `ParsePeerNeighbor` |
|
||||
|
||||
**BIRD2 docs:** https://bird.network.cz/?get_doc
|
||||
**BIRD2 docs:** https://bird.nic.cz/?get_doc
|
||||
|
||||
---
|
||||
|
||||
@@ -199,7 +199,7 @@ alwaysApply: false
|
||||
|
||||
## Documentation Sync
|
||||
|
||||
**DOC-SYNC-05** | MUST | BIRD — https://bird.network.cz/?get_doc
|
||||
**DOC-SYNC-05** | MUST | BIRD — https://bird.nic.cz/?get_doc
|
||||
**DOC-SYNC-08** | MUST | BGP policy — RFC 4271, 4760, 7454 + BIRD docs + `birdfmt`
|
||||
**DOC-SYNC-09** | MUST | CIDR — https://pkg.go.dev/net/netip ; примеры — RFC 5737, 3849
|
||||
|
||||
|
||||
@@ -167,14 +167,11 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: pnpm
|
||||
cache-dependency-path: pnpm-lock.yaml
|
||||
- name: Enable pnpm via corepack
|
||||
run: corepack enable
|
||||
- name: pnpm install, typecheck, lint, build
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
|
||||
@@ -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 (1–4294967295)')
|
||||
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)
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 '—'
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 : 'Не удалось ротировать'),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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' })
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@evobgp/ui/components/select'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
|
||||
import {
|
||||
@@ -367,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>
|
||||
@@ -377,8 +386,10 @@ 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)}>
|
||||
<SelectTrigger>{a ? a.slice(0, 12) + '…' : 'Выберите'}</SelectTrigger>
|
||||
<Select items={revisionItems} value={a} onValueChange={(v) => v && setA(v)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Выберите" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{revisions.map((r) => (
|
||||
<SelectItem key={r.id} value={r.id}>
|
||||
@@ -390,8 +401,10 @@ 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)}>
|
||||
<SelectTrigger>{b ? b.slice(0, 12) + '…' : 'Выберите'}</SelectTrigger>
|
||||
<Select items={revisionItems} value={b} onValueChange={(v) => v && setB(v)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Выберите" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{revisions.map((r) => (
|
||||
<SelectItem key={r.id} value={r.id}>
|
||||
|
||||
@@ -1,17 +1,25 @@
|
||||
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'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger } from '@evobgp/ui/components/select'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@evobgp/ui/components/select'
|
||||
|
||||
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'
|
||||
|
||||
@@ -19,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('')
|
||||
|
||||
@@ -29,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 (
|
||||
@@ -42,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>
|
||||
@@ -59,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>
|
||||
|
||||
@@ -84,8 +142,14 @@ function SettingsComponent() {
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-2">
|
||||
<Label htmlFor="theme-select">Тема</Label>
|
||||
<Select value={theme ?? 'system'} onValueChange={(v) => v && setTheme(v)}>
|
||||
<SelectTrigger id="theme-select" className="w-full max-w-xs" />
|
||||
<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>
|
||||
<SelectContent>
|
||||
<SelectItem value="light">Светлая</SelectItem>
|
||||
<SelectItem value="dark">Тёмная</SelectItem>
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@evobgp/ui/components/select'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
|
||||
import {
|
||||
@@ -34,6 +35,7 @@ import {
|
||||
RUNTIME_LOGS_SETTING_KEYS,
|
||||
buildPayload,
|
||||
partitionSettings,
|
||||
settingsKeys,
|
||||
settingsQueryOptions,
|
||||
type BirdSettingKey,
|
||||
} from '@/queries/settings'
|
||||
@@ -48,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',
|
||||
@@ -82,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 : 'Не удалось сохранить'),
|
||||
})
|
||||
@@ -241,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 &&
|
||||
@@ -251,7 +264,7 @@ function TenantSettingsComponent() {
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
{runtimeLogsForm.runtime_logs_auto_enabled === 'true' ? 'Вкл' : 'Выкл'}
|
||||
<SelectValue placeholder="Выберите" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="true">Вкл</SelectItem>
|
||||
@@ -298,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 &&
|
||||
@@ -308,11 +322,11 @@ function TenantSettingsComponent() {
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
{runtimeLogsForm.runtime_logs_auto_mode || 'Выберите'}
|
||||
<SelectValue placeholder="Выберите" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="delete">Delete</SelectItem>
|
||||
<SelectItem value="truncate">Truncate</SelectItem>
|
||||
<SelectItem value="truncate">truncate — обнулить</SelectItem>
|
||||
<SelectItem value="delete">delete — удалить файл</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="font-mono text-xs text-muted-foreground">
|
||||
|
||||
@@ -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"}
|
||||
@@ -18,7 +18,7 @@ apt-get install -y --no-install-recommends \
|
||||
|
||||
cd /tmp
|
||||
rm -rf "bird-${BIRD_VERSION}"
|
||||
curl -fsSL "https://bird.network.cz/download/bird-${BIRD_VERSION}.tar.gz" | tar xz
|
||||
curl -fsSL "https://bird.nic.cz/download/bird-${BIRD_VERSION}.tar.gz" | tar xz
|
||||
cd "bird-${BIRD_VERSION}"
|
||||
./configure --prefix=/usr/local --enable-client
|
||||
make -j"$(nproc)"
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
FROM public.ecr.aws/docker/library/node:22-alpine AS deps
|
||||
WORKDIR /repo
|
||||
RUN corepack enable
|
||||
RUN corepack enable && corepack prepare pnpm@10.33.2 --activate
|
||||
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./
|
||||
COPY apps/web/package.json ./apps/web/
|
||||
COPY packages/ui/package.json ./packages/ui/
|
||||
|
||||
@@ -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
@@ -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 == "" {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+40
-4
@@ -184,6 +184,10 @@ type Registry struct {
|
||||
onTerminal func(j *Job)
|
||||
onEnqueued func(j *Job)
|
||||
onRunning func(j *Job)
|
||||
// inflightRefresh counts refresh-kind jobs (module_refresh, tenant_refresh) per tenant that
|
||||
// have been enqueued but not yet finalized in finishModuleRefreshSuccess. Used for deterministic
|
||||
// deploy coalescing under tenantRefreshMu (instead of polling job statuses).
|
||||
inflightRefresh map[string]int
|
||||
}
|
||||
|
||||
type idempoKey struct {
|
||||
@@ -194,10 +198,11 @@ type idempoKey struct {
|
||||
func NewRegistry(workerStart func(j *Job)) *Registry {
|
||||
maxWorkers := registryMaxConcurrentJobs()
|
||||
return &Registry{
|
||||
byID: make(map[string]*Job),
|
||||
byIdempo: make(map[idempoKey]*Job),
|
||||
workerStart: workerStart,
|
||||
workerSem: make(chan struct{}, maxWorkers),
|
||||
byID: make(map[string]*Job),
|
||||
byIdempo: make(map[idempoKey]*Job),
|
||||
workerStart: workerStart,
|
||||
workerSem: make(chan struct{}, maxWorkers),
|
||||
inflightRefresh: make(map[string]int),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -340,6 +345,9 @@ func (r *Registry) Enqueue(tenantID, kind string, idempotencyKey *string, module
|
||||
r.byIdempo[idempoKey{tenant: tenantID, key: *idempotencyKey}] = j
|
||||
}
|
||||
r.byID[j.ID] = j
|
||||
if isRefreshKind(kind) {
|
||||
r.inflightRefresh[tenantID]++
|
||||
}
|
||||
r.pruneTerminalIfOver(maxJobs)
|
||||
enqueuedHook := r.onEnqueued
|
||||
workerStart := r.workerStart
|
||||
@@ -474,6 +482,34 @@ func (r *Registry) CountOtherActiveRefresh(tenantID, excludeJobID string) int {
|
||||
return n
|
||||
}
|
||||
|
||||
// isRefreshKind reports whether a job kind participates in deploy coalescing.
|
||||
func isRefreshKind(kind string) bool {
|
||||
return kind == KindModuleRefresh || kind == KindTenantRefresh
|
||||
}
|
||||
|
||||
// finalizeRefreshCoalesce is called from finishModuleRefreshSuccess under tenantRefreshMu.
|
||||
// It atomically decrements the per-tenant inflight refresh counter and reports whether the
|
||||
// caller is the last outstanding refresh for the tenant (and therefore should render+deploy).
|
||||
//
|
||||
// Unlike CountOtherActiveRefresh (which polls job statuses and races under -race), this counter
|
||||
// is incremented in Enqueue under r.mu and decremented here, so the "last one" decision is
|
||||
// deterministic regardless of how fast each refresh's ingest completes.
|
||||
func (r *Registry) finalizeRefreshCoalesce(tenantID string) bool {
|
||||
if r == nil {
|
||||
return true
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
n := r.inflightRefresh[tenantID]
|
||||
if n <= 1 {
|
||||
// Last (or already-balanced to zero) — clear the slot and let the caller deploy.
|
||||
delete(r.inflightRefresh, tenantID)
|
||||
return true
|
||||
}
|
||||
r.inflightRefresh[tenantID] = n - 1
|
||||
return false
|
||||
}
|
||||
|
||||
func parseCursor(s string, off *int) error {
|
||||
_, err := fmt.Sscanf(s, "%d", off)
|
||||
return err
|
||||
|
||||
+60
-23
@@ -119,26 +119,7 @@ func (w *Worker) Process(j *Job) {
|
||||
|
||||
switch j.Kind {
|
||||
case KindModuleRefresh:
|
||||
mid, _ := j.Meta["module_id"].(string)
|
||||
if strings.TrimSpace(mid) == "" {
|
||||
j.Fail("missing module_id in job meta")
|
||||
return
|
||||
}
|
||||
ctx, cancel := j.workContext()
|
||||
defer cancel()
|
||||
if ctx.Err() != nil {
|
||||
j.MarkCancelled()
|
||||
return
|
||||
}
|
||||
if err := pipeline.RefreshModuleIngest(ctx, w.Store, w.httpClient(), j.TenantID, mid); err != nil {
|
||||
if ctx.Err() != nil {
|
||||
j.MarkCancelled()
|
||||
return
|
||||
}
|
||||
j.Fail(err.Error())
|
||||
return
|
||||
}
|
||||
w.finishModuleRefreshSuccess(j, mid)
|
||||
w.runModuleRefresh(j)
|
||||
case KindTenantRefresh:
|
||||
w.runTenantRefresh(j)
|
||||
case KindPeerReconcile:
|
||||
@@ -295,7 +276,55 @@ func (w *Worker) tenantRefreshMu(tenantID string) *sync.Mutex {
|
||||
|
||||
// finishModuleRefreshSuccess marks the refresh job and, for the last active refresh in tenant,
|
||||
// creates one aggregate revision and enqueues a single deploy_apply.
|
||||
// runModuleRefresh handles a single module_refresh job and guarantees the per-tenant inflight
|
||||
// slot is released exactly once — even on failure/cancellation before finishModuleRefreshSuccess.
|
||||
func (w *Worker) runModuleRefresh(j *Job) {
|
||||
coalesceFinalized := false
|
||||
defer func() {
|
||||
if !coalesceFinalized && w != nil && w.Registry != nil {
|
||||
// Refresh failed/was cancelled before reaching finishModuleRefreshSuccess.
|
||||
// Decrement the counter under the tenant mutex so the "last one" logic stays sound.
|
||||
mu := w.tenantRefreshMu(j.TenantID)
|
||||
mu.Lock()
|
||||
w.Registry.finalizeRefreshCoalesce(j.TenantID)
|
||||
mu.Unlock()
|
||||
}
|
||||
}()
|
||||
|
||||
mid, _ := j.Meta["module_id"].(string)
|
||||
if strings.TrimSpace(mid) == "" {
|
||||
j.Fail("missing module_id in job meta")
|
||||
return
|
||||
}
|
||||
ctx, cancel := j.workContext()
|
||||
defer cancel()
|
||||
if ctx.Err() != nil {
|
||||
j.MarkCancelled()
|
||||
return
|
||||
}
|
||||
if err := pipeline.RefreshModuleIngest(ctx, w.Store, w.httpClient(), j.TenantID, mid); err != nil {
|
||||
if ctx.Err() != nil {
|
||||
j.MarkCancelled()
|
||||
return
|
||||
}
|
||||
j.Fail(err.Error())
|
||||
return
|
||||
}
|
||||
w.finishModuleRefreshSuccess(j, mid)
|
||||
coalesceFinalized = true
|
||||
}
|
||||
|
||||
func (w *Worker) runTenantRefresh(j *Job) {
|
||||
coalesceFinalized := false
|
||||
defer func() {
|
||||
if !coalesceFinalized && w != nil && w.Registry != nil {
|
||||
mu := w.tenantRefreshMu(j.TenantID)
|
||||
mu.Lock()
|
||||
w.Registry.finalizeRefreshCoalesce(j.TenantID)
|
||||
mu.Unlock()
|
||||
}
|
||||
}()
|
||||
|
||||
moduleIDs := moduleIDsFromJobMeta(j.Meta)
|
||||
if len(moduleIDs) == 0 {
|
||||
j.Fail("missing module_ids in job meta")
|
||||
@@ -318,6 +347,7 @@ func (w *Worker) runTenantRefresh(j *Job) {
|
||||
}
|
||||
j.mergeMeta(map[string]any{"module_ids": moduleIDs, "modules_refreshed": len(moduleIDs)})
|
||||
w.finishModuleRefreshSuccess(j, trigger)
|
||||
coalesceFinalized = true
|
||||
}
|
||||
|
||||
func moduleIDsFromJobMeta(meta map[string]any) []string {
|
||||
@@ -346,6 +376,9 @@ func moduleIDsFromJobMeta(meta map[string]any) []string {
|
||||
|
||||
func (w *Worker) finishModuleRefreshSuccess(j *Job, triggerModuleID string) {
|
||||
if w == nil || w.Store == nil {
|
||||
if w != nil && w.Registry != nil {
|
||||
w.Registry.finalizeRefreshCoalesce(j.TenantID)
|
||||
}
|
||||
j.Succeed()
|
||||
return
|
||||
}
|
||||
@@ -354,11 +387,15 @@ func (w *Worker) finishModuleRefreshSuccess(j *Job, triggerModuleID string) {
|
||||
defer mu.Unlock()
|
||||
ctx, cancel := j.workContext()
|
||||
defer cancel()
|
||||
deferDeploy := false
|
||||
// Determine whether this is the last outstanding refresh for the tenant. The counter is
|
||||
// incremented in Enqueue (under r.mu) and decremented here, so the "last one" decision is
|
||||
// deterministic regardless of ingest timing — unlike the previous status-polling approach
|
||||
// (CountOtherActiveRefresh) which could race under -race.
|
||||
isLastRefresh := true
|
||||
if w.Registry != nil {
|
||||
deferDeploy = w.Registry.CountOtherActiveRefresh(j.TenantID, j.ID) > 0
|
||||
isLastRefresh = w.Registry.finalizeRefreshCoalesce(j.TenantID)
|
||||
}
|
||||
if deferDeploy {
|
||||
if !isLastRefresh {
|
||||
j.mergeMeta(map[string]any{
|
||||
"deploy_apply_deferred": true,
|
||||
"deploy_apply_defer_reason": "parallel_module_refresh",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"name": "evobgp-release",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.33.2",
|
||||
"scripts": {
|
||||
"dev": "pnpm --filter @evobgp/web dev",
|
||||
"build": "pnpm --filter @evobgp/web build",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user