feat(api, web): enhance agent management and linting capabilities
- 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:
@@ -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} />
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user