fix(web): открытие detail-роутов и паритет ReUI DataGrid с EvoBGP/CFDM
Build and Push EvoFirewall Docker Image / build-and-push (push) Successful in 1m52s
Build and Push EvoFirewall Docker Image / create-release (push) Skipped

Sibling routes index+$id, ColumnHeader/PrimaryCell/StatusBadge/ConfirmDialog, tabs и таблица entries списков.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-07-20 22:40:48 +07:00
co-authored by Cursor
parent 0bed4367c7
commit 7f0c1009de
10 changed files with 1040 additions and 360 deletions
+360
View File
@@ -0,0 +1,360 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { Copy, Check, Trash2 } from 'lucide-react'
import { useCallback, useMemo, useState } from 'react'
import type { ColumnDef } from '@tanstack/react-table'
import type { Filter, FilterFieldConfig } from '@/components/reui/filters'
import {
PageHeader,
PageShell,
ResourcePage,
} from '@/components/reui-kit'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import {
DataGridMutedCell,
DataGridPrimaryCell,
} from '@/components/data-grid-cell'
import { StatusBadge } from '@/components/status-badge'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { agentsQueryOptions, installContextQueryOptions } from '@/queries'
import { apiFetch } from '@/lib/api'
import { Button } from '@evofw/ui/components/button'
import { Input } from '@evofw/ui/components/input'
import { Label } from '@evofw/ui/components/label'
import type { Agent } from '@evofw/shared'
export const Route = createFileRoute('/_auth/agents/')({
component: AgentsPage,
})
function AgentsPage() {
const qc = useQueryClient()
const agentsQ = useQuery(agentsQueryOptions())
const installQ = useQuery(installContextQueryOptions())
const [name, setName] = useState('web-01')
const [filters, setFilters] = useState<Filter[]>([])
const [activeTab, setActiveTab] = useState('all')
const [deleteId, setDeleteId] = useState<string | null>(null)
const approve = useMutation({
mutationFn: (id: string) =>
apiFetch(`/api/v1/agents/${id}/approve`, { method: 'POST' }),
onSuccess: () => {
toast.success('Агент одобрен')
void qc.invalidateQueries({ queryKey: ['agents'] })
},
onError: (e: Error) => toast.error(e.message),
})
const revoke = useMutation({
mutationFn: (id: string) =>
apiFetch(`/api/v1/agents/${id}/revoke`, { method: 'POST' }),
onSuccess: () => {
toast.success('Агент отозван')
void qc.invalidateQueries({ queryKey: ['agents'] })
},
})
const remove = useMutation({
mutationFn: (id: string) =>
apiFetch(`/api/v1/agents/${id}`, { method: 'DELETE' }),
onSuccess: () => {
toast.success('Удалён')
setDeleteId(null)
void qc.invalidateQueries({ queryKey: ['agents'] })
},
})
const installCmd = useMemo(() => {
const cp = installQ.data?.suggested_cp_url ?? 'https://fw.example.com'
const seed = installQ.data?.enroll_seed ?? '<seed>'
return `curl -fsSL ${cp}/v1/agent/install.sh | \\\n EVOFW_CP_URL=${cp} \\\n EVOFW_SEED=${seed} \\\n EVOFW_CLIENT_NAME="${name}" \\\n bash`
}, [installQ.data, name])
const items = agentsQ.data?.items ?? []
const pending = items.filter((a) => a.status === 'pending')
const filterFields: FilterFieldConfig[] = useMemo(
() => [
{
key: 'name',
label: 'Имя',
type: 'text',
placeholder: 'Поиск…',
},
{
key: 'status',
label: 'Статус',
type: 'select',
options: [
{ value: 'approved', label: 'approved' },
{ value: 'pending', label: 'pending' },
{ value: 'revoked', label: 'revoked' },
],
},
{
key: 'platform',
label: 'Платформа',
type: 'select',
options: [
{ value: 'linux', label: 'linux' },
{ value: 'mikrotik', label: 'mikrotik' },
],
},
],
[],
)
const getFilterFieldValue = useCallback((item: Agent, field: string) => {
if (field === 'name') return item.name
if (field === 'status') return item.status
if (field === 'platform') return item.platform
return undefined
}, [])
const tabFilter = useCallback((item: Agent, tabId: string) => {
if (tabId === 'all') return true
return item.status === tabId
}, [])
const columns: ColumnDef<Agent>[] = useMemo(
() => [
{
accessorKey: 'name',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Имя" />
),
cell: ({ row }) => (
<Link
to="/agents/$id"
params={{ id: row.original.id }}
className="min-w-0"
>
<DataGridPrimaryCell
accent="primary"
title={row.original.name}
subtitle={row.original.hostname ?? undefined}
/>
</Link>
),
},
{
accessorKey: 'platform',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Платформа" />
),
},
{
accessorKey: 'status',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Статус" />
),
cell: ({ row }) => <StatusBadge status={row.original.status} />,
},
{
accessorKey: 'policy_mode',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Режим" />
),
},
{
accessorKey: 'last_seen_at',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Seen" />
),
cell: ({ row }) => (
<DataGridMutedCell>
{row.original.last_seen_at ?? '—'}
</DataGridMutedCell>
),
},
{
id: 'actions',
enableSorting: false,
header: () => <span className="sr-only">Действия</span>,
cell: ({ row }) => {
const a = row.original
return (
<div className="flex justify-end gap-1">
<Button
size="sm"
variant="outline"
render={<Link to="/agents/$id" params={{ id: a.id }} />}
>
Открыть
</Button>
{a.status === 'approved' ? (
<Button
size="sm"
variant="outline"
onClick={() => revoke.mutate(a.id)}
>
Revoke
</Button>
) : null}
<Button
size="icon-sm"
variant="ghost"
className="text-destructive"
aria-label="Удалить"
onClick={() => setDeleteId(a.id)}
>
<Trash2 className="size-3.5" />
</Button>
</div>
)
},
},
],
[revoke],
)
return (
<PageShell>
<PageHeader
title="Агенты"
description="Linux / MikroTik — enroll, approve, policy mode"
/>
<Frame dense spacing="sm">
<FrameHeader>
<div>
<FrameTitle>Установка Linux</FrameTitle>
<FrameDescription>
One-liner. После enroll одобрите агента ниже.
</FrameDescription>
</div>
</FrameHeader>
<FramePanel>
<div className="mb-3 flex max-w-sm flex-col gap-2">
<Label htmlFor="cname">Имя клиента</Label>
<Input
id="cname"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<pre className="bg-muted overflow-x-auto rounded-lg p-3 text-xs">
{installCmd}
</pre>
<Button
className="mt-2"
variant="outline"
size="sm"
onClick={async () => {
await navigator.clipboard.writeText(
installCmd.replace(/\\\n\s*/g, ' '),
)
toast.success('Скопировано')
}}
>
<Copy data-icon="inline-start" />
Копировать
</Button>
{installQ.data?.mikrotik_url ? (
<p className="text-muted-foreground mt-3 text-sm">
MikroTik:{' '}
<a className="underline" href={installQ.data.mikrotik_url}>
mikrotik-install.rsc
</a>
</p>
) : null}
</FramePanel>
</Frame>
{pending.length > 0 ? (
<Frame dense spacing="sm">
<FrameHeader>
<FrameTitle>Запросы ({pending.length})</FrameTitle>
</FrameHeader>
<FramePanel>
<div className="flex flex-col gap-2">
{pending.map((a) => (
<div
key={a.id}
className="flex flex-wrap items-center justify-between gap-2 border-b py-2 last:border-0"
>
<div>
<DataGridPrimaryCell
accent="primary"
title={a.name}
subtitle={`${a.platform} · ${a.hostname ?? '—'} · ${a.token_prefix}…`}
/>
</div>
<div className="flex gap-2">
<Button
size="sm"
onClick={() => approve.mutate(a.id)}
disabled={approve.isPending}
>
<Check data-icon="inline-start" />
Approve
</Button>
<Button
size="sm"
variant="outline"
onClick={() => setDeleteId(a.id)}
>
Удалить
</Button>
</div>
</div>
))}
</div>
</FramePanel>
</Frame>
) : null}
<ResourcePage
title="Клиенты"
hideHeader
data={items}
columns={columns}
getRowId={(r) => r.id}
filterFields={filterFields}
filters={filters}
onFiltersChange={setFilters}
onClearFilters={() => setFilters([])}
getFilterFieldValue={getFilterFieldValue}
tabs={[
{ id: 'all', label: 'Все' },
{ id: 'approved', label: 'Approved' },
{ id: 'pending', label: 'Pending' },
{ id: 'revoked', label: 'Revoked' },
]}
activeTab={activeTab}
onTabChange={setActiveTab}
tabFilter={tabFilter}
isLoading={agentsQ.isLoading}
isError={agentsQ.isError}
error={agentsQ.error}
onRetry={() => void agentsQ.refetch()}
emptyState={{
title: 'Нет агентов',
description: 'Установите agent на сервер и одобрите запрос.',
}}
/>
<ConfirmDialog
open={deleteId !== null}
onOpenChange={(open) => {
if (!open) setDeleteId(null)
}}
title="Удалить агента?"
description="Агент и связанные назначения будут удалены."
onConfirm={() => {
if (deleteId) remove.mutate(deleteId)
}}
disabled={remove.isPending}
/>
</PageShell>
)
}