feat(api, web): enhance agent management and linting capabilities
Build and Push EvoFirewall Docker Image / build-and-push (push) Successful in 2m17s
Build and Push EvoFirewall Docker Image / create-release (push) Skipped

- Added a new linting command for OpenAPI specifications in the package.json, improving code quality checks.
- Updated frontend documentation to clarify component usage and structure, including detailed descriptions for `SettingsShell` and `Auth callback`.
- Refactored agent-related API routes to streamline control-plane functionalities, consolidating multiple routes for better organization.
- Improved error handling in the API to provide more informative responses for validation errors, enhancing user feedback during interactions.

These changes enhance the overall development experience and improve the management of agents within the application.
This commit is contained in:
Denozordec
2026-07-30 14:13:05 +07:00
parent fb95ef22b3
commit f160992d94
68 changed files with 3155 additions and 6899 deletions
+45 -109
View File
@@ -1,24 +1,21 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { useCallback, useMemo, useState } from 'react'
import type { Filter, FilterFieldConfig } from '@/components/reui/filters'
import {
Check,
CheckCircle2,
CircleAlertIcon,
FilterIcon,
Inbox,
LayoutGridIcon,
ListIcon,
Plus,
SearchIcon,
ShieldIcon,
TableIcon,
UserPlus,
WifiOff,
} from 'lucide-react'
import { useCallback, useMemo, useState } from 'react'
import type { Filter, FilterFieldConfig } from '@/components/reui/filters'
import { Filters } from '@/components/reui/filters'
import {
applyFiltersToData,
KpiStatGrid,
@@ -30,7 +27,6 @@ import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import {
@@ -38,26 +34,21 @@ import {
AlertDescription,
AlertTitle,
} from '@/components/reui/alert'
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 { AgentsFleetChrome } from '@/components/agents/agents-fleet-chrome'
import {
computeFleetCounts,
fleetKpiCards,
} from '@/components/agents/agents-fleet-kpis'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { agentsQueryOptions } from '@/queries'
import { QueryState } from '@/components/query-state'
import { agentsQueryOptions, settingsQueryOptions } from '@/queries'
import { apiFetch } from '@/lib/api'
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
import { Button } from '@evofw/ui/components/button'
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from '@evofw/ui/components/input-group'
import { Separator } from '@evofw/ui/components/separator'
import {
ToggleGroup,
ToggleGroupItem,
@@ -104,6 +95,7 @@ function AgentsPage() {
const { agent: detailAgentId, view } = Route.useSearch()
const qc = useQueryClient()
const agentsQ = useQuery(agentsQueryOptions())
const settingsQ = useQuery(settingsQueryOptions())
const { copyToClipboard } = useCopyToClipboard()
const [createOpen, setCreateOpen] = useState(false)
const [deleteAgentId, setDeleteAgentId] = useState<string | null>(null)
@@ -112,6 +104,7 @@ function AgentsPage() {
const [activeTab, setActiveTab] = useState('all')
const detailOpen = Boolean(detailAgentId)
const showQuickActions = settingsQ.data?.show_quick_actions !== 'false'
const setSearch = useCallback(
(next: Partial<AgentsSearch>) => {
@@ -410,7 +403,7 @@ function AgentsPage() {
<KpiStatGrid cards={kpiCards} isLoading={agentsQ.isLoading} />
<QuickActionGrid actions={quickActions} />
{showQuickActions ? <QuickActionGrid actions={quickActions} /> : null}
{counts.pending > 0 ? (
<Frame dense spacing="sm">
@@ -482,101 +475,44 @@ function AgentsPage() {
onCopyInstall={handleCopyInstall}
onDelete={handleDeleteAgent}
/>
) : 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>
<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
agents={filteredItems}
selectedId={detailOpen ? detailAgentId : null}
onSelect={handleSelectAgent}
onDelete={handleDeleteAgent}
isLoading={agentsQ.isLoading}
emptyTitle={
items.length === 0
? 'Нет агентов'
: 'Нет записей по фильтрам'
}
emptyDescription={
items.length === 0
? 'Создайте агента — он появится в каталоге как Invited с командой установки.'
: undefined
}
emptyAction={items.length === 0 ? addButton : undefined}
/>
</div>
</FramePanel>
</Frame>
<QueryState
isError={agentsQ.isError}
error={agentsQ.error}
onRetry={() => void agentsQ.refetch()}
>
<AgentsFleetChrome
tabs={countedTabs}
activeTab={activeTab}
onTabChange={setActiveTab}
filterFields={filterFields}
filters={filters}
onFiltersChange={setFilters}
onClearFilters={handleClearFilters}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
toolbarExtra={viewToggle}
>
<AgentCardsGrid
agents={filteredItems}
selectedId={detailOpen ? detailAgentId : null}
onSelect={handleSelectAgent}
onDelete={handleDeleteAgent}
isLoading={agentsQ.isLoading}
emptyTitle={
items.length === 0
? 'Нет агентов'
: 'Нет записей по фильтрам'
}
emptyDescription={
items.length === 0
? 'Создайте агента — он появится в каталоге как Invited с командой установки.'
: undefined
}
emptyAction={items.length === 0 ? addButton : undefined}
/>
</AgentsFleetChrome>
</QueryState>
)}
<AddAgentSheet open={createOpen} onOpenChange={setCreateOpen} />
+10 -33
View File
@@ -31,8 +31,9 @@ import {
import {
dashboardQueryOptions,
agentsQueryOptions,
recentStatsQueryOptions,
settingsQueryOptions,
} from '@/queries'
import { AgentsFleetChart } from '@/components/agents/agents-fleet-chart'
import { agentTrafficDropped } from '@/components/agents/agent-traffic'
import type { Agent } from '@evofw/shared'
@@ -45,10 +46,11 @@ const EMPTY_AGENTS: Agent[] = []
function DashboardPage() {
const dash = useQuery(dashboardQueryOptions())
const agents = useQuery(agentsQueryOptions())
const stats = useQuery(recentStatsQueryOptions())
const settingsQ = useQuery(settingsQueryOptions())
const d = dash.data
const items = agents.data?.items ?? EMPTY_AGENTS
const showQuickActions = settingsQ.data?.show_quick_actions !== 'false'
const pending = useMemo(
() => items.filter((a) => a.status === 'pending'),
@@ -59,10 +61,6 @@ function DashboardPage() {
[items],
)
const previewAgents = useMemo(() => items.slice(0, 8), [items])
const samples = useMemo(
() => (stats.data?.items ?? []).slice(0, 10),
[stats.data?.items],
)
const kpiCards: KpiStatCard[] = useMemo(
() => [
@@ -145,9 +143,14 @@ function DashboardPage() {
<OpsDashboard
isLoading={dash.isLoading}
kpiCards={kpiCards}
afterKpi={<QuickActionGrid actions={quickActions} />}
afterKpi={
showQuickActions ? (
<QuickActionGrid actions={quickActions} />
) : undefined
}
charts={
<>
<AgentsFleetChart />
<Frame dense spacing="sm">
<FrameHeader>
<FrameTitle>Агенты</FrameTitle>
@@ -193,32 +196,6 @@ function DashboardPage() {
)}
</FramePanel>
</Frame>
<Frame dense spacing="sm">
<FrameHeader>
<FrameTitle>Последние samples</FrameTitle>
</FrameHeader>
<FramePanel>
<div className="flex flex-col gap-2">
{samples.length === 0 ? (
<p className="text-muted-foreground text-sm">Нет данных</p>
) : (
samples.map((s, i) => (
<div
key={`${s.agent_id}-${s.recorded_at}-${i}`}
className="flex items-center justify-between gap-2 text-sm"
>
<span className="text-muted-foreground text-xs">
{s.recorded_at}
</span>
<span className="tabular-nums">
{s.packets_dropped} / {s.packets_accepted}
</span>
</div>
))
)}
</div>
</FramePanel>
</Frame>
</>
}
queue={
+5 -3
View File
@@ -23,6 +23,7 @@ import {
AutocompleteItem,
AutocompleteList,
} from '@/components/reui/autocomplete'
import { LoadingButton } from '@/components/loading-button'
import { Button } from '@evofw/ui/components/button'
import { Field, FieldLabel } from '@evofw/ui/components/field'
import { Input } from '@evofw/ui/components/input'
@@ -364,12 +365,13 @@ function ListsPage() {
<Button variant="outline" onClick={() => setCreateOpen(false)}>
Отмена
</Button>
<Button
disabled={!canCreate || create.isPending}
<LoadingButton
disabled={!canCreate}
isLoading={create.isPending}
onClick={() => create.mutate()}
>
Создать
</Button>
</LoadingButton>
</SheetFooter>
</SheetContent>
</Sheet>
+32 -4
View File
@@ -10,11 +10,17 @@ import {
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { LoadingButton } from '@/components/loading-button'
import { SettingRow } from '@/components/setting-row'
import { settingsQueryOptions } from '@/queries'
import { apiFetch } from '@/lib/api'
import { Button } from '@evofw/ui/components/button'
import { Input } from '@evofw/ui/components/input'
import { Switch } from '@evofw/ui/components/switch'
/**
* Control plane settings — Frame + SettingRow.
* Preview: https://reui.io/preview/base/settings-16 · settings-3
*/
export const Route = createFileRoute('/_auth/settings')({
component: SettingsPage,
@@ -43,6 +49,8 @@ function SettingsPage() {
onError: (e: Error) => toast.error(e.message),
})
const showQuickActions = form.show_quick_actions !== 'false'
const fields = [
{
key: 'enroll_seed',
@@ -89,7 +97,7 @@ function SettingsPage() {
title={f.label}
description={f.hint}
labelFor={f.key}
last={i === fields.length - 1}
last={false}
>
<Input
id={f.key}
@@ -101,13 +109,33 @@ function SettingsPage() {
/>
</SettingRow>
))}
<SettingRow
title="Быстрые действия"
description="Показывать Quick Actions на панели и у агентов"
last
>
<Switch
checked={showQuickActions}
onCheckedChange={(checked) =>
setForm((prev) => ({
...prev,
show_quick_actions: checked ? 'true' : 'false',
}))
}
aria-label="Быстрые действия"
/>
</SettingRow>
</div>
</FramePanel>
</Frame>
<div className="flex justify-end">
<Button onClick={() => save.mutate()} disabled={save.isPending}>
<LoadingButton
onClick={() => save.mutate()}
isLoading={save.isPending}
loadingLabel="Сохранение…"
>
Сохранить
</Button>
</LoadingButton>
</div>
</PageShell>
)
+30 -7
View File
@@ -1,5 +1,13 @@
import { createFileRoute } from '@tanstack/react-router'
import { createFileRoute, Link } from '@tanstack/react-router'
import { KeyRoundIcon } from 'lucide-react'
import { EmptyState } from '@/components/empty-state'
import { setToken } from '@/lib/auth'
import { Button } from '@evofw/ui/components/button'
/**
* Portal SSO callback — token from hash fragment.
* Preview: https://reui.io/preview/base/auth-13 · https://reui.io/preview/base/empty-state-12
*/
export const Route = createFileRoute('/auth/callback')({
component: AuthCallback,
@@ -9,17 +17,32 @@ function AuthCallback() {
const hash = typeof window !== 'undefined' ? window.location.hash : ''
const params = new URLSearchParams(hash.replace(/^#/, ''))
const token = params.get('access_token')
if (token) {
setToken(token)
window.location.replace('/')
} else {
return (
<div className="flex min-h-svh items-center justify-center p-6">
<p className="text-muted-foreground text-sm">
Нет access_token в URL. Войдите через auth-portal.
</p>
<div className="bg-background flex min-h-svh items-center justify-center p-6">
<EmptyState
icon={KeyRoundIcon}
title="Вход…"
description="Перенаправляем в EvoFirewall"
stackedIcon
/>
</div>
)
}
return null
return (
<div className="bg-background flex min-h-svh items-center justify-center p-6">
<EmptyState
icon={KeyRoundIcon}
title="Нет access_token"
description="Войдите через auth-portal и вернитесь с токеном в URL."
action={
<Button render={<Link to="/" />}>На главную</Button>
}
/>
</div>
)
}