feat(web): refactor agent detail and agents page for improved navigation and layout
Build and Push EvoFirewall Docker Image / build-and-push (push) Successful in 1m53s
Build and Push EvoFirewall Docker Image / create-release (push) Skipped

- Updated the AgentDetailSheet component to enhance layout and integrate a new detail view for agents, improving user experience.
- Refactored the agents page to support a toggle between card and table views, allowing for better organization and accessibility of agent information.
- Adjusted routing for agent links to utilize search parameters, streamlining navigation to specific agent details.
- Removed unused imports and optimized component structure for better maintainability.

These changes contribute to a more intuitive and user-friendly interface across the application.
This commit is contained in:
Denozordec
2026-07-23 12:14:46 +07:00
parent 3058704585
commit 19a9540555
41 changed files with 5901 additions and 601 deletions
+162 -26
View File
@@ -1,4 +1,4 @@
import { createFileRoute } from '@tanstack/react-router'
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import {
@@ -7,10 +7,12 @@ import {
CircleAlertIcon,
FilterIcon,
Inbox,
LayoutGridIcon,
ListIcon,
Plus,
SearchIcon,
ShieldIcon,
TableIcon,
UserPlus,
WifiOff,
} from 'lucide-react'
@@ -40,12 +42,14 @@ import { CountedLineTabs } from '@/components/counted-line-tabs'
import { AddAgentSheet } from '@/components/agents/add-agent-sheet'
import { AgentCardsGrid } from '@/components/agents/agent-cards-grid'
import { AgentDetailSheet } from '@/components/agents/agent-detail-sheet'
import { AgentFleetDataGrid } from '@/components/agents/agent-fleet-data-grid'
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 {
InputGroup,
@@ -53,14 +57,36 @@ import {
InputGroupInput,
} from '@evofw/ui/components/input-group'
import { Separator } from '@evofw/ui/components/separator'
import {
ToggleGroup,
ToggleGroupItem,
} from '@evofw/ui/components/toggle-group'
import type { Agent } from '@evofw/shared'
/**
* Agents ops console — card catalog + detail Sheet.
* Preview: https://reui.io/preview/base/solution-agents-1 · card-3 · stats-12 · c-sheet-1
* Agents ops console — cards/table toggle + full detail Sheet.
* Preview: https://reui.io/preview/base/solution-agents-1 · card-3 · stats-12
* · https://reui.io/preview/base/data-grid-filtering-2 · solution-agents-2
* · https://reui.io/preview/base/solution-inventory-9 · solution-agents-3
*/
type AgentsSearch = {
agent?: string
view: 'cards' | 'table'
}
function parseAgentsSearch(search: Record<string, unknown>): AgentsSearch {
const view = search.view === 'table' ? 'table' : 'cards'
const agent =
typeof search.agent === 'string' && search.agent.length > 0
? search.agent
: undefined
return { view, agent }
}
export const Route = createFileRoute('/_auth/agents/')({
validateSearch: (search: Record<string, unknown>): AgentsSearch =>
parseAgentsSearch(search),
component: AgentsPage,
})
@@ -73,14 +99,46 @@ const AGENT_TABS = [
] as const
function AgentsPage() {
const navigate = useNavigate({ from: Route.fullPath })
const { agent: detailAgentId, view } = Route.useSearch()
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 [detailAgentId, setDetailAgentId] = useState<string | null>(null)
const [detailOpen, setDetailOpen] = useState(false)
const detailOpen = Boolean(detailAgentId)
const setSearch = useCallback(
(next: Partial<AgentsSearch>) => {
void navigate({
search: (prev) => {
const base = parseAgentsSearch(prev as Record<string, unknown>)
return {
view: next.view ?? base.view,
agent:
next.agent !== undefined
? next.agent || undefined
: base.agent,
} satisfies AgentsSearch
},
replace: true,
})
},
[navigate],
)
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 approveAllPending = useMutation({
mutationFn: async (ids: string[]) => {
@@ -177,6 +235,14 @@ function AgentsPage() {
[searchQuery],
)
const getSearchText = useCallback(
(item: Agent) =>
[item.name, item.hostname ?? '', item.last_seen_ip ?? '']
.filter(Boolean)
.join(' '),
[],
)
const tabFilter = useCallback((item: Agent, tabId: string) => {
if (tabId === 'all') return true
return item.status === tabId
@@ -257,16 +323,50 @@ function AgentsPage() {
[counts.pending],
)
const handleSelectAgent = useCallback((id: string) => {
setDetailAgentId(id)
setDetailOpen(true)
}, [])
const handleSelectAgent = useCallback(
(id: string) => {
setSearch({ agent: id })
},
[setSearch],
)
const handleClearFilters = useCallback(() => {
setFilters([])
setSearchQuery('')
}, [])
const handleCopyInstall = useCallback(
(curl: string) => {
copyToClipboard(curl)
toast.success('Скопировано')
},
[copyToClipboard],
)
const viewToggle = (
<ToggleGroup
multiple={false}
value={[view]}
onValueChange={(next) => {
const v = next[0]
if (v === 'cards' || v === 'table') {
setSearch({ view: v })
}
}}
variant="outline"
size="sm"
spacing={0}
aria-label="Вид списка"
>
<ToggleGroupItem value="cards" aria-label="Карточки">
<LayoutGridIcon className="size-4" />
</ToggleGroupItem>
<ToggleGroupItem value="table" aria-label="Таблица">
<TableIcon className="size-4" />
</ToggleGroupItem>
</ToggleGroup>
)
const addButton = (
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Plus data-icon="inline-start" />
@@ -329,12 +429,40 @@ function AgentsPage() {
</Alert>
) : null}
{agentsQ.isError ? (
{view === 'table' ? (
<AgentFleetDataGrid
data={items}
filterFields={filterFields}
filters={filters}
onFiltersChange={setFilters}
onClearFilters={handleClearFilters}
getFilterFieldValue={getFilterFieldValue}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
getSearchText={getSearchText}
tabs={[...AGENT_TABS]}
activeTab={activeTab}
onTabChange={setActiveTab}
tabFilter={tabFilter}
isLoading={agentsQ.isLoading}
isError={agentsQ.isError}
error={agentsQ.error}
onRetry={() => void agentsQ.refetch()}
emptyAction={addButton}
toolbarExtra={viewToggle}
onSelect={handleSelectAgent}
onApprove={(id) => approve.mutate(id)}
approvePending={approve.isPending}
onCopyInstall={handleCopyInstall}
/>
) : agentsQ.isError ? (
<Alert variant="destructive">
<CircleAlertIcon />
<AlertTitle>Ошибка загрузки</AlertTitle>
<AlertDescription className="flex flex-col gap-2">
<span>{agentsQ.error?.message ?? 'Не удалось загрузить данные'}</span>
<span>
{agentsQ.error?.message ?? 'Не удалось загрузить данные'}
</span>
<Button
type="button"
variant="outline"
@@ -364,7 +492,11 @@ function AgentsPage() {
onChange={setFilters}
size="default"
trigger={
<Button type="button" variant="outline" aria-label="Фильтры">
<Button
type="button"
variant="outline"
aria-label="Фильтры"
>
<FilterIcon className="size-4" aria-hidden="true" />
Фильтры
</Button>
@@ -382,16 +514,19 @@ function AgentsPage() {
/>
</InputGroup>
</div>
{filters.length > 0 || searchQuery ? (
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleClearFilters}
>
Сбросить
</Button>
) : null}
<div className="flex flex-wrap items-center gap-2">
{viewToggle}
{filters.length > 0 || searchQuery ? (
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleClearFilters}
>
Сбросить
</Button>
) : null}
</div>
</div>
<div className="px-(--frame-panel-header-px) pb-(--frame-panel-header-py)">
<AgentCardsGrid
@@ -400,7 +535,9 @@ function AgentsPage() {
onSelect={handleSelectAgent}
isLoading={agentsQ.isLoading}
emptyTitle={
items.length === 0 ? 'Нет агентов' : 'Нет записей по фильтрам'
items.length === 0
? 'Нет агентов'
: 'Нет записей по фильтрам'
}
emptyDescription={
items.length === 0
@@ -417,11 +554,10 @@ function AgentsPage() {
<AddAgentSheet open={createOpen} onOpenChange={setCreateOpen} />
<AgentDetailSheet
agentId={detailAgentId}
agentId={detailAgentId ?? null}
open={detailOpen}
onOpenChange={(open) => {
setDetailOpen(open)
if (!open) setDetailAgentId(null)
if (!open) setSearch({ agent: '' })
}}
/>
</PageShell>