feat(web): refine spacing and layout in frame component and agents page
- Enhanced vertical rhythm in the frame component by adjusting padding values for header, content, and footer, ensuring consistent alignment and improved readability. - Updated the agents page to incorporate a new tab structure for better organization of agent statuses, along with a refined search and filter functionality to enhance user experience. - Removed unused imports and streamlined the code for better maintainability and performance. These changes contribute to a more cohesive and user-friendly interface across the application.
This commit is contained in:
@@ -1,29 +1,28 @@
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
Check,
|
||||
CheckCircle2,
|
||||
CircleAlertIcon,
|
||||
Copy,
|
||||
FilterIcon,
|
||||
Inbox,
|
||||
ListIcon,
|
||||
Pencil,
|
||||
Plus,
|
||||
SearchIcon,
|
||||
ShieldIcon,
|
||||
Trash2,
|
||||
UserPlus,
|
||||
WifiOff,
|
||||
} from 'lucide-react'
|
||||
import { useCallback, useMemo, useState, type MouseEvent } from 'react'
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import type { Filter, FilterFieldConfig } from '@/components/reui/filters'
|
||||
import { Filters } from '@/components/reui/filters'
|
||||
import {
|
||||
applyFiltersToData,
|
||||
KpiStatGrid,
|
||||
PageHeader,
|
||||
PageShell,
|
||||
QuickActionGrid,
|
||||
ResourcePage,
|
||||
} from '@/components/reui-kit'
|
||||
import {
|
||||
Frame,
|
||||
@@ -32,94 +31,56 @@ import {
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
} from '@/components/reui/alert'
|
||||
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 { CountedLineTabs } from '@/components/counted-line-tabs'
|
||||
import { AddAgentSheet } from '@/components/agents/add-agent-sheet'
|
||||
import { AgentsFleetChart } from '@/components/agents/agents-fleet-chart'
|
||||
import {
|
||||
AgentPlatformIcon,
|
||||
platformLabel,
|
||||
} from '@/components/agents/agent-platform-icon'
|
||||
import { AgentCardsGrid } from '@/components/agents/agent-cards-grid'
|
||||
import { AgentDetailSheet } from '@/components/agents/agent-detail-sheet'
|
||||
import {
|
||||
computeFleetCounts,
|
||||
fleetKpiCards,
|
||||
} from '@/components/agents/agents-fleet-kpis'
|
||||
import { agentsQueryOptions } from '@/queries'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
|
||||
import { Button } from '@evofw/ui/components/button'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@evofw/ui/components/tooltip'
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupInput,
|
||||
} from '@evofw/ui/components/input-group'
|
||||
import { Separator } from '@evofw/ui/components/separator'
|
||||
import type { Agent } from '@evofw/shared'
|
||||
|
||||
const packetFmt = new Intl.NumberFormat('ru-RU', {
|
||||
notation: 'compact',
|
||||
maximumFractionDigits: 1,
|
||||
})
|
||||
|
||||
function formatPackets(n: number | undefined, hasApply: boolean): string {
|
||||
if (!hasApply || n === undefined) return '—'
|
||||
return packetFmt.format(n)
|
||||
}
|
||||
|
||||
const seenFmt = new Intl.DateTimeFormat('ru-RU', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
|
||||
function formatAgentSeen(iso: string | null | undefined): string {
|
||||
if (!iso) return '—'
|
||||
const t = Date.parse(iso)
|
||||
if (Number.isNaN(t)) return '—'
|
||||
return seenFmt.format(t)
|
||||
}
|
||||
|
||||
/**
|
||||
* Agents ops console — Solutions Agents DNA.
|
||||
* Preview: https://reui.io/preview/base/solution-agents-1 · stats-12 · data-grid-filtering-2
|
||||
* Pending inbox DNA: https://reui.io/preview/base/solution-agents-5 (Frame adapt)
|
||||
* Agents ops console — card catalog + detail Sheet.
|
||||
* Preview: https://reui.io/preview/base/solution-agents-1 · card-3 · stats-12 · c-sheet-1
|
||||
*/
|
||||
|
||||
export const Route = createFileRoute('/_auth/agents/')({
|
||||
component: AgentsPage,
|
||||
})
|
||||
|
||||
const AGENT_TABS = [
|
||||
{ id: 'all', label: 'Все' },
|
||||
{ id: 'invited', label: 'Invited' },
|
||||
{ id: 'pending', label: 'Pending' },
|
||||
{ id: 'approved', label: 'Approved' },
|
||||
{ id: 'revoked', label: 'Revoked' },
|
||||
] as const
|
||||
|
||||
function AgentsPage() {
|
||||
const navigate = useNavigate()
|
||||
const qc = useQueryClient()
|
||||
const agentsQ = useQuery(agentsQueryOptions())
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [filters, setFilters] = useState<Filter[]>([])
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
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 [detailAgentId, setDetailAgentId] = useState<string | null>(null)
|
||||
const [detailOpen, setDetailOpen] = useState(false)
|
||||
|
||||
const approveAllPending = useMutation({
|
||||
mutationFn: async (ids: string[]) => {
|
||||
@@ -136,17 +97,6 @@ function AgentsPage() {
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
})
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
apiFetch(`/api/v1/agents/${id}`, { method: 'DELETE' }),
|
||||
onSuccess: () => {
|
||||
toast.success('Удалён')
|
||||
setDeleteId(null)
|
||||
void qc.invalidateQueries({ queryKey: ['agents'] })
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
})
|
||||
|
||||
const items = agentsQ.data?.items ?? []
|
||||
const counts = useMemo(() => computeFleetCounts(items), [items])
|
||||
const pendingIds = useMemo(
|
||||
@@ -212,12 +162,19 @@ function AgentsPage() {
|
||||
return undefined
|
||||
}, [])
|
||||
|
||||
const getSearchText = useCallback(
|
||||
(item: Agent) =>
|
||||
[item.name, item.hostname ?? '', item.last_seen_ip ?? '']
|
||||
.filter(Boolean)
|
||||
.join(' '),
|
||||
[],
|
||||
const applySearch = useCallback(
|
||||
(data: Agent[]) => {
|
||||
const q = searchQuery.trim().toLowerCase()
|
||||
if (!q) return data
|
||||
return data.filter((item) =>
|
||||
[item.name, item.hostname ?? '', item.last_seen_ip ?? '']
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
.includes(q),
|
||||
)
|
||||
},
|
||||
[searchQuery],
|
||||
)
|
||||
|
||||
const tabFilter = useCallback((item: Agent, tabId: string) => {
|
||||
@@ -225,6 +182,41 @@ function AgentsPage() {
|
||||
return item.status === tabId
|
||||
}, [])
|
||||
|
||||
const filteredItems = useMemo(() => {
|
||||
let result = applySearch(items)
|
||||
result = applyFiltersToData(result, filters, getFilterFieldValue)
|
||||
if (activeTab !== 'all') {
|
||||
result = result.filter((item) => tabFilter(item, activeTab))
|
||||
}
|
||||
return result
|
||||
}, [items, applySearch, filters, getFilterFieldValue, activeTab, tabFilter])
|
||||
|
||||
const tabCounts = useMemo(() => {
|
||||
const base = applyFiltersToData(
|
||||
applySearch(items),
|
||||
filters,
|
||||
getFilterFieldValue,
|
||||
)
|
||||
const next: Record<string, number> = {}
|
||||
for (const tab of AGENT_TABS) {
|
||||
next[tab.id] =
|
||||
tab.id === 'all'
|
||||
? base.length
|
||||
: base.filter((item) => tabFilter(item, tab.id)).length
|
||||
}
|
||||
return next
|
||||
}, [items, applySearch, filters, getFilterFieldValue, tabFilter])
|
||||
|
||||
const countedTabs = useMemo(
|
||||
() =>
|
||||
AGENT_TABS.map((tab) => ({
|
||||
id: tab.id,
|
||||
label: tab.label,
|
||||
count: tabCounts[tab.id] ?? 0,
|
||||
})),
|
||||
[tabCounts],
|
||||
)
|
||||
|
||||
const quickActions = useMemo(
|
||||
() => [
|
||||
{
|
||||
@@ -265,267 +257,15 @@ function AgentsPage() {
|
||||
[counts.pending],
|
||||
)
|
||||
|
||||
const handleCopyCurl = useCallback(
|
||||
(curl: string, e?: MouseEvent) => {
|
||||
e?.stopPropagation()
|
||||
if (!curl) return
|
||||
copyToClipboard(curl)
|
||||
toast.success('Скопировано')
|
||||
},
|
||||
[copyToClipboard],
|
||||
)
|
||||
const handleSelectAgent = useCallback((id: string) => {
|
||||
setDetailAgentId(id)
|
||||
setDetailOpen(true)
|
||||
}, [])
|
||||
|
||||
const columns: ColumnDef<Agent>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'name',
|
||||
size: 280,
|
||||
minSize: 180,
|
||||
maxSize: 480,
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Имя" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const a = row.original
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<AgentPlatformIcon platform={a.platform} />
|
||||
<DataGridPrimaryCell
|
||||
accent="primary"
|
||||
title={a.name}
|
||||
subtitle={a.hostname ?? platformLabel(a.platform)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'status',
|
||||
size: 110,
|
||||
minSize: 100,
|
||||
maxSize: 130,
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Статус" />
|
||||
),
|
||||
cell: ({ row }) => <StatusBadge status={row.original.status} />,
|
||||
},
|
||||
{
|
||||
id: 'apply',
|
||||
size: 90,
|
||||
minSize: 80,
|
||||
maxSize: 110,
|
||||
accessorFn: (row) => row.last_apply_status ?? '',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Apply" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const a = row.original
|
||||
if (a.last_apply_error) {
|
||||
return (
|
||||
<Badge variant="destructive-light" size="sm">
|
||||
error
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
if (!a.last_apply_status && !a.last_apply_at) {
|
||||
return <DataGridMutedCell>—</DataGridMutedCell>
|
||||
}
|
||||
return (
|
||||
<Badge variant="secondary" size="sm">
|
||||
{a.last_apply_status ?? 'ok'}
|
||||
</Badge>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'dropped',
|
||||
size: 90,
|
||||
minSize: 80,
|
||||
maxSize: 110,
|
||||
accessorFn: (row) => row.last_apply_packets_dropped ?? -1,
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Dropped" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const a = row.original
|
||||
const hasApply = Boolean(a.last_apply_at || a.last_apply_status)
|
||||
const text = formatPackets(a.last_apply_packets_dropped, hasApply)
|
||||
if (text === '—') {
|
||||
return <DataGridMutedCell>—</DataGridMutedCell>
|
||||
}
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<span className="text-warning cursor-default tabular-nums text-sm font-medium" />
|
||||
}
|
||||
>
|
||||
{text}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Dropped с последнего apply
|
||||
{a.last_apply_at ? ` · ${a.last_apply_at}` : ''}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'accepted',
|
||||
size: 90,
|
||||
minSize: 80,
|
||||
maxSize: 110,
|
||||
accessorFn: (row) => row.last_apply_packets_accepted ?? -1,
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Accepted" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const a = row.original
|
||||
const hasApply = Boolean(a.last_apply_at || a.last_apply_status)
|
||||
const text = formatPackets(a.last_apply_packets_accepted, hasApply)
|
||||
if (text === '—') {
|
||||
return <DataGridMutedCell>—</DataGridMutedCell>
|
||||
}
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<span className="text-success cursor-default tabular-nums text-sm font-medium" />
|
||||
}
|
||||
>
|
||||
{text}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Accepted с последнего apply
|
||||
{a.last_apply_at ? ` · ${a.last_apply_at}` : ''}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'install',
|
||||
size: 100,
|
||||
minSize: 90,
|
||||
maxSize: 120,
|
||||
enableSorting: false,
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Install" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const curl = row.original.install_curl
|
||||
if (!curl) {
|
||||
return <DataGridMutedCell>—</DataGridMutedCell>
|
||||
}
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="font-mono text-xs"
|
||||
onClick={(e) => handleCopyCurl(curl, e)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Copy data-icon="inline-start" className="size-3.5" />
|
||||
curl
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-sm break-all font-mono text-xs">
|
||||
{curl}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'last_seen_at',
|
||||
size: 130,
|
||||
minSize: 110,
|
||||
maxSize: 160,
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Seen" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const iso = row.original.last_seen_at
|
||||
const short = formatAgentSeen(iso)
|
||||
if (!iso || short === '—') {
|
||||
return <DataGridMutedCell>—</DataGridMutedCell>
|
||||
}
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<span className="text-muted-foreground cursor-default text-xs tabular-nums" />
|
||||
}
|
||||
>
|
||||
{short}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="font-mono text-xs">{iso}</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
size: 108,
|
||||
minSize: 108,
|
||||
maxSize: 108,
|
||||
enableSorting: false,
|
||||
enableResizing: false,
|
||||
header: () => <span className="sr-only">Действия</span>,
|
||||
cell: ({ row }) => {
|
||||
const a = row.original
|
||||
return (
|
||||
<div className="flex justify-end gap-1">
|
||||
{a.status === 'pending' ? (
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
aria-label="Approve"
|
||||
disabled={approve.isPending}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
approve.mutate(a.id)
|
||||
}}
|
||||
>
|
||||
<Check className="size-3.5" />
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
aria-label="Открыть"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
void navigate({
|
||||
to: '/agents/$id',
|
||||
params: { id: a.id },
|
||||
})
|
||||
}}
|
||||
>
|
||||
<Pencil className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
className="text-destructive"
|
||||
aria-label="Удалить"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setDeleteId(a.id)
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
],
|
||||
[approve, handleCopyCurl, navigate],
|
||||
)
|
||||
const handleClearFilters = useCallback(() => {
|
||||
setFilters([])
|
||||
setSearchQuery('')
|
||||
}, [])
|
||||
|
||||
const addButton = (
|
||||
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
||||
@@ -538,7 +278,7 @@ function AgentsPage() {
|
||||
<PageShell>
|
||||
<PageHeader
|
||||
title="Агенты"
|
||||
description="Ops console: invite, install, approve, apply throughput"
|
||||
description="Ops console: invite, install, approve"
|
||||
actions={addButton}
|
||||
/>
|
||||
|
||||
@@ -589,62 +329,100 @@ function AgentsPage() {
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<AgentsFleetChart />
|
||||
|
||||
<ResourcePage
|
||||
title="Очередь агентов"
|
||||
hideHeader
|
||||
data={items}
|
||||
columns={columns}
|
||||
getRowId={(r) => r.id}
|
||||
filterFields={filterFields}
|
||||
filters={filters}
|
||||
onFiltersChange={setFilters}
|
||||
onClearFilters={() => setFilters([])}
|
||||
getFilterFieldValue={getFilterFieldValue}
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
searchPlaceholder="Поиск агентов…"
|
||||
getSearchText={getSearchText}
|
||||
tableLayout={{ width: 'fixed', columnsResizable: true }}
|
||||
onRowClick={(row) =>
|
||||
void navigate({ to: '/agents/$id', params: { id: row.id } })
|
||||
}
|
||||
tabs={[
|
||||
{ id: 'all', label: 'Все' },
|
||||
{ id: 'invited', label: 'Invited' },
|
||||
{ id: 'pending', label: 'Pending' },
|
||||
{ id: 'approved', label: 'Approved' },
|
||||
{ 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:
|
||||
'Создайте агента — он появится в списке как Invited с командой установки.',
|
||||
action: addButton,
|
||||
}}
|
||||
/>
|
||||
{agentsQ.isError ? (
|
||||
<Alert variant="destructive">
|
||||
<CircleAlertIcon />
|
||||
<AlertTitle>Ошибка загрузки</AlertTitle>
|
||||
<AlertDescription className="flex flex-col gap-2">
|
||||
<span>{agentsQ.error?.message ?? 'Не удалось загрузить данные'}</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void agentsQ.refetch()}
|
||||
>
|
||||
Повторить
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<Frame dense variant="default" spacing="sm" className="w-full">
|
||||
<FramePanel className="p-0 shadow-none!">
|
||||
<div className="px-(--frame-panel-header-px) pt-(--frame-panel-header-py)">
|
||||
<CountedLineTabs
|
||||
tabs={countedTabs}
|
||||
value={activeTab}
|
||||
onValueChange={setActiveTab}
|
||||
/>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 px-(--frame-panel-header-px) py-(--frame-panel-header-py)">
|
||||
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
|
||||
<Filters
|
||||
filters={filters}
|
||||
fields={filterFields}
|
||||
onChange={setFilters}
|
||||
size="default"
|
||||
trigger={
|
||||
<Button type="button" variant="outline" aria-label="Фильтры">
|
||||
<FilterIcon className="size-4" aria-hidden="true" />
|
||||
Фильтры
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<InputGroup className="h-8 max-w-xs min-w-[12rem] flex-1">
|
||||
<InputGroupAddon align="inline-start">
|
||||
<SearchIcon aria-hidden="true" />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Поиск агентов…"
|
||||
aria-label="Поиск агентов…"
|
||||
/>
|
||||
</InputGroup>
|
||||
</div>
|
||||
{filters.length > 0 || searchQuery ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleClearFilters}
|
||||
>
|
||||
Сбросить
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="px-(--frame-panel-header-px) pb-(--frame-panel-header-py)">
|
||||
<AgentCardsGrid
|
||||
agents={filteredItems}
|
||||
selectedId={detailOpen ? detailAgentId : null}
|
||||
onSelect={handleSelectAgent}
|
||||
isLoading={agentsQ.isLoading}
|
||||
emptyTitle={
|
||||
items.length === 0 ? 'Нет агентов' : 'Нет записей по фильтрам'
|
||||
}
|
||||
emptyDescription={
|
||||
items.length === 0
|
||||
? 'Создайте агента — он появится в каталоге как Invited с командой установки.'
|
||||
: undefined
|
||||
}
|
||||
emptyAction={items.length === 0 ? addButton : undefined}
|
||||
/>
|
||||
</div>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)}
|
||||
|
||||
<AddAgentSheet open={createOpen} onOpenChange={setCreateOpen} />
|
||||
|
||||
<ConfirmDialog
|
||||
open={deleteId !== null}
|
||||
<AgentDetailSheet
|
||||
agentId={detailAgentId}
|
||||
open={detailOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setDeleteId(null)
|
||||
setDetailOpen(open)
|
||||
if (!open) setDetailAgentId(null)
|
||||
}}
|
||||
title="Удалить агента?"
|
||||
description="Агент, install-ссылка и связанные назначения будут удалены."
|
||||
onConfirm={() => {
|
||||
if (deleteId) remove.mutate(deleteId)
|
||||
}}
|
||||
disabled={remove.isPending}
|
||||
/>
|
||||
</PageShell>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user