feat(api, web): add agent stats reset functionality and enhance UI components
Build and Push EvoFirewall Docker Image / build-and-push (push) Successful in 1m57s
Build and Push EvoFirewall Docker Image / create-release (push) Skipped

- Implemented a new API endpoint to reset agent statistics, allowing for better management of agent performance data.
- Updated the AgentCard component to display traffic statistics in a consolidated format, improving clarity for users.
- Enhanced the AgentDetailView to include a button for resetting agent stats, providing a direct action for users.
- Refactored the AgentFleetDataGrid to show combined traffic metrics, streamlining data presentation.
- Added a utility function to delete stats samples for agents in the database, ensuring data integrity.

These changes improve the user experience by providing more intuitive controls and clearer data representation for agent statistics.
This commit is contained in:
Denozordec
2026-07-23 19:20:15 +07:00
parent 19a9540555
commit 1b7d301153
6 changed files with 110 additions and 58 deletions
+22
View File
@@ -968,6 +968,28 @@ export const controlRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
})), })),
})) }))
app.post<{ Params: { id: string } }>(
'/agents/:id/stats/reset',
async (req) => {
const agent = repos.getAgent(app.db, req.params.id)
if (!agent) throw new AppError('NOT_FOUND', 'Agent not found', 404)
const updated = repos.updateAgent(app.db, agent.id, {
lastApplyPacketsDropped: 0,
lastApplyPacketsAccepted: 0,
})
repos.deleteStatsSamplesForAgent(app.db, agent.id)
auditMutation(app, config, req, {
action: 'agent.stats_reset',
severity: 'info',
targetType: 'app_resource',
targetId: agent.id,
summary: `Сброшена статистика counters агента ${agent.name}`,
details: { agent_id: agent.id },
})
return mapAgent(updated!)
},
)
app.get('/stats/recent', async () => ({ app.get('/stats/recent', async () => ({
items: repos.listRecentStats(app.db).map((s) => ({ items: repos.listRecentStats(app.db).map((s) => ({
id: s.id, id: s.id,
+23 -10
View File
@@ -55,6 +55,10 @@ export function AgentCard({ agent, selected, onSelect }: AgentCardProps) {
const hasApply = Boolean(agent.last_apply_at || agent.last_apply_status) const hasApply = Boolean(agent.last_apply_at || agent.last_apply_status)
const dropped = formatPackets(agent.last_apply_packets_dropped, hasApply) const dropped = formatPackets(agent.last_apply_packets_dropped, hasApply)
const accepted = formatPackets(agent.last_apply_packets_accepted, hasApply) const accepted = formatPackets(agent.last_apply_packets_accepted, hasApply)
const traffic =
dropped === '—' && accepted === '—'
? '—'
: `${dropped} · ↑${accepted}`
const seen = formatShort(agent.last_seen_at ?? agent.last_apply_at) const seen = formatShort(agent.last_seen_at ?? agent.last_apply_at)
const defaultAction = const defaultAction =
agent.default_action === 'drop' ? 'Drop' : 'Accept' agent.default_action === 'drop' ? 'Drop' : 'Accept'
@@ -68,19 +72,28 @@ export function AgentCard({ agent, selected, onSelect }: AgentCardProps) {
const stats = [ const stats = [
{ {
label: 'Dropped', label: 'Traffic',
value: dropped, value: traffic,
valueClass: dropped === '—' ? undefined : 'text-warning', valueClass:
}, traffic === '—'
{ ? undefined
label: 'Accepted', : '[&]:text-foreground [&>span]:tabular-nums',
value: accepted, valueNode:
valueClass: accepted === '—' ? undefined : 'text-success', traffic === '—' ? (
'—'
) : (
<>
<span className="text-warning">{dropped}</span>
<span className="text-muted-foreground"> · </span>
<span className="text-success">{accepted}</span>
</>
),
}, },
{ {
label: 'Seen', label: 'Seen',
value: seen, value: seen,
valueClass: 'text-muted-foreground', valueClass: 'text-muted-foreground',
valueNode: seen,
}, },
] as const ] as const
@@ -129,7 +142,7 @@ export function AgentCard({ agent, selected, onSelect }: AgentCardProps) {
</div> </div>
</div> </div>
<div className="bg-muted/60 grid grid-cols-[1fr_auto_1fr_auto_1fr] overflow-hidden rounded-lg border"> <div className="bg-muted/60 grid grid-cols-[1fr_auto_1fr] overflow-hidden rounded-lg border">
{stats.map((stat, index) => ( {stats.map((stat, index) => (
<div key={stat.label} className="contents"> <div key={stat.label} className="contents">
<Item <Item
@@ -144,7 +157,7 @@ export function AgentCard({ agent, selected, onSelect }: AgentCardProps) {
stat.valueClass, stat.valueClass,
)} )}
> >
{stat.value} {stat.valueNode}
</ItemTitle> </ItemTitle>
<ItemDescription className="line-clamp-1 text-xs leading-tight"> <ItemDescription className="line-clamp-1 text-xs leading-tight">
{stat.label} {stat.label}
@@ -13,6 +13,7 @@ import {
SheetHeader, SheetHeader,
SheetTitle, SheetTitle,
} from '@evofw/ui/components/sheet' } from '@evofw/ui/components/sheet'
import { cn } from '@evofw/ui/lib/utils'
/** /**
* Wide agent detail Sheet — inventory-9 / CRM-4 shell + SA3 body. * Wide agent detail Sheet — inventory-9 / CRM-4 shell + SA3 body.
@@ -45,7 +46,13 @@ export function AgentDetailSheet({
<SheetContent <SheetContent
side="right" side="right"
showCloseButton={false} showCloseButton={false}
className="inset-y-4 right-4 left-auto flex h-[calc(100svh-2rem)] w-[min(72rem,calc(100vw-2rem))] max-w-none flex-col gap-0 overflow-hidden rounded-xl p-0 outline-none" className={cn(
// Override default data-[side=right]:sm:max-w-sm from @evofw/ui sheet
'sm:max-w-none!',
'inset-y-2 right-2 left-auto flex h-[calc(100svh-1rem)] max-w-none',
'w-[calc(100vw-1rem)] md:w-[calc(100vw-var(--sidebar-width,240px)-1rem)]',
'flex-col gap-0 overflow-hidden rounded-xl p-0 outline-none',
)}
> >
<SheetHeader className="shrink-0 gap-0 border-b p-0"> <SheetHeader className="shrink-0 gap-0 border-b p-0">
<div className="flex min-h-11 items-center justify-between gap-2 px-4"> <div className="flex min-h-11 items-center justify-between gap-2 px-4">
@@ -2,8 +2,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner' import { toast } from 'sonner'
import { useRef, useState } from 'react' import { useRef, useState } from 'react'
import { import {
BanIcon, ActivityIcon,
CheckCircle2Icon,
CircleAlertIcon, CircleAlertIcon,
ClockIcon, ClockIcon,
Copy, Copy,
@@ -88,6 +87,20 @@ export function AgentDetailView({ agentId }: AgentDetailViewProps) {
onError: (e: Error) => toast.error(e.message), onError: (e: Error) => toast.error(e.message),
}) })
const resetStats = useMutation({
mutationFn: () =>
apiFetch(`/api/v1/agents/${agentId}/stats/reset`, { method: 'POST' }),
onSuccess: () => {
toast.success('Статистика сброшена')
void qc.invalidateQueries({ queryKey: ['agents'] })
void qc.invalidateQueries({ queryKey: ['agents', agentId] })
void qc.invalidateQueries({ queryKey: ['agents', agentId, 'stats'] })
void qc.invalidateQueries({ queryKey: ['stats'] })
void qc.invalidateQueries({ queryKey: ['dashboard'] })
},
onError: (e: Error) => toast.error(e.message),
})
const a = agentQ.data const a = agentQ.data
if (agentQ.isLoading || !a) { if (agentQ.isLoading || !a) {
@@ -229,21 +242,24 @@ export function AgentDetailView({ agentId }: AgentDetailViewProps) {
<DetailPanel.Metrics <DetailPanel.Metrics
cards={[ cards={[
{ {
id: 'dropped', id: 'traffic',
icon: <BanIcon aria-hidden />, icon: <ActivityIcon aria-hidden />,
iconClassName: 'text-warning', iconClassName: 'text-warning',
label: 'Dropped', label: 'Traffic',
description: String(a.last_apply_packets_dropped ?? 0), description: `${a.last_apply_packets_dropped ?? 0} · ↑${a.last_apply_packets_accepted ?? 0}`,
hint: 'сумма counters', hint: 'сумма counters',
variant: 'warning', variant: 'warning',
}, footer: (
{ <Button
id: 'accepted', type="button"
icon: <CheckCircle2Icon aria-hidden />, size="sm"
iconClassName: 'text-success', variant="outline"
label: 'Accepted', disabled={resetStats.isPending}
description: String(a.last_apply_packets_accepted ?? 0), onClick={() => resetStats.mutate()}
hint: 'сумма counters', >
Сбросить
</Button>
),
}, },
{ {
id: 'kernel', id: 'kernel',
@@ -212,47 +212,35 @@ export function AgentFleetDataGrid({
}, },
}, },
{ {
id: 'dropped', id: 'traffic',
size: 90, size: 130,
minSize: 80, minSize: 110,
maxSize: 110, maxSize: 160,
accessorFn: (row) => row.last_apply_packets_dropped ?? -1, accessorFn: (row) =>
(row.last_apply_packets_dropped ?? 0) +
(row.last_apply_packets_accepted ?? 0),
header: ({ column }) => ( header: ({ column }) => (
<DataGridColumnHeader column={column} title="Dropped" /> <DataGridColumnHeader column={column} title="Traffic" />
), ),
cell: ({ row }) => { cell: ({ row }) => {
const a = row.original const a = row.original
const hasApply = Boolean(a.last_apply_at || a.last_apply_status) const hasApply = Boolean(a.last_apply_at || a.last_apply_status)
const text = formatPackets(a.last_apply_packets_dropped, hasApply) const dropped = formatPackets(
if (text === '—') { a.last_apply_packets_dropped,
return <DataGridMutedCell></DataGridMutedCell> hasApply,
}
return (
<span className="text-warning text-sm font-medium tabular-nums">
{text}
</span>
) )
}, const accepted = formatPackets(
}, a.last_apply_packets_accepted,
{ hasApply,
id: 'accepted', )
size: 90, if (dropped === '—' && accepted === '—') {
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 <DataGridMutedCell></DataGridMutedCell>
} }
return ( return (
<span className="text-success text-sm font-medium tabular-nums"> <span className="text-sm font-medium tabular-nums">
{text} <span className="text-warning">{dropped}</span>
<span className="text-muted-foreground"> · </span>
<span className="text-success">{accepted}</span>
</span> </span>
) )
}, },
@@ -368,7 +356,6 @@ export function AgentFleetDataGrid({
searchPlaceholder="Поиск агентов…" searchPlaceholder="Поиск агентов…"
getSearchText={getSearchText} getSearchText={getSearchText}
tableLayout={{ width: 'fixed', columnsResizable: true }} tableLayout={{ width: 'fixed', columnsResizable: true }}
columnPinning={{ right: ['actions'] }}
onRowClick={(row) => onSelect(row.id)} onRowClick={(row) => onSelect(row.id)}
tabs={tabs} tabs={tabs}
activeTab={activeTab} activeTab={activeTab}
+7
View File
@@ -431,6 +431,12 @@ export function listRecentStats(db: Db, limit = 500) {
.all() .all()
} }
export function deleteStatsSamplesForAgent(db: Db, agentId: string) {
db.delete(agentStatsSamples)
.where(eq(agentStatsSamples.agentId, agentId))
.run()
}
export function getSetting(db: Db, key: string): string { export function getSetting(db: Db, key: string): string {
const row = db.select().from(settings).where(eq(settings.key, key)).get() const row = db.select().from(settings).where(eq(settings.key, key)).get()
return row?.value ?? '' return row?.value ?? ''
@@ -607,6 +613,7 @@ export const repos = {
insertStatsSample, insertStatsSample,
listStatsSamples, listStatsSamples,
listRecentStats, listRecentStats,
deleteStatsSamplesForAgent,
getSetting, getSetting,
setSetting, setSetting,
listSettings, listSettings,