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 () => ({
items: repos.listRecentStats(app.db).map((s) => ({
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 dropped = formatPackets(agent.last_apply_packets_dropped, 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 defaultAction =
agent.default_action === 'drop' ? 'Drop' : 'Accept'
@@ -68,19 +72,28 @@ export function AgentCard({ agent, selected, onSelect }: AgentCardProps) {
const stats = [
{
label: 'Dropped',
value: dropped,
valueClass: dropped === '—' ? undefined : 'text-warning',
},
{
label: 'Accepted',
value: accepted,
valueClass: accepted === '—' ? undefined : 'text-success',
label: 'Traffic',
value: traffic,
valueClass:
traffic === '—'
? undefined
: '[&]:text-foreground [&>span]:tabular-nums',
valueNode:
traffic === '—' ? (
'—'
) : (
<>
<span className="text-warning">{dropped}</span>
<span className="text-muted-foreground"> · </span>
<span className="text-success">{accepted}</span>
</>
),
},
{
label: 'Seen',
value: seen,
valueClass: 'text-muted-foreground',
valueNode: seen,
},
] as const
@@ -129,7 +142,7 @@ export function AgentCard({ agent, selected, onSelect }: AgentCardProps) {
</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) => (
<div key={stat.label} className="contents">
<Item
@@ -144,7 +157,7 @@ export function AgentCard({ agent, selected, onSelect }: AgentCardProps) {
stat.valueClass,
)}
>
{stat.value}
{stat.valueNode}
</ItemTitle>
<ItemDescription className="line-clamp-1 text-xs leading-tight">
{stat.label}
@@ -13,6 +13,7 @@ import {
SheetHeader,
SheetTitle,
} from '@evofw/ui/components/sheet'
import { cn } from '@evofw/ui/lib/utils'
/**
* Wide agent detail Sheet — inventory-9 / CRM-4 shell + SA3 body.
@@ -45,7 +46,13 @@ export function AgentDetailSheet({
<SheetContent
side="right"
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">
<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 { useRef, useState } from 'react'
import {
BanIcon,
CheckCircle2Icon,
ActivityIcon,
CircleAlertIcon,
ClockIcon,
Copy,
@@ -88,6 +87,20 @@ export function AgentDetailView({ agentId }: AgentDetailViewProps) {
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
if (agentQ.isLoading || !a) {
@@ -229,21 +242,24 @@ export function AgentDetailView({ agentId }: AgentDetailViewProps) {
<DetailPanel.Metrics
cards={[
{
id: 'dropped',
icon: <BanIcon aria-hidden />,
id: 'traffic',
icon: <ActivityIcon aria-hidden />,
iconClassName: 'text-warning',
label: 'Dropped',
description: String(a.last_apply_packets_dropped ?? 0),
label: 'Traffic',
description: `${a.last_apply_packets_dropped ?? 0} · ↑${a.last_apply_packets_accepted ?? 0}`,
hint: 'сумма counters',
variant: 'warning',
},
{
id: 'accepted',
icon: <CheckCircle2Icon aria-hidden />,
iconClassName: 'text-success',
label: 'Accepted',
description: String(a.last_apply_packets_accepted ?? 0),
hint: 'сумма counters',
footer: (
<Button
type="button"
size="sm"
variant="outline"
disabled={resetStats.isPending}
onClick={() => resetStats.mutate()}
>
Сбросить
</Button>
),
},
{
id: 'kernel',
@@ -212,47 +212,35 @@ export function AgentFleetDataGrid({
},
},
{
id: 'dropped',
size: 90,
minSize: 80,
maxSize: 110,
accessorFn: (row) => row.last_apply_packets_dropped ?? -1,
id: 'traffic',
size: 130,
minSize: 110,
maxSize: 160,
accessorFn: (row) =>
(row.last_apply_packets_dropped ?? 0) +
(row.last_apply_packets_accepted ?? 0),
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Dropped" />
<DataGridColumnHeader column={column} title="Traffic" />
),
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 (
<span className="text-warning text-sm font-medium tabular-nums">
{text}
</span>
const dropped = formatPackets(
a.last_apply_packets_dropped,
hasApply,
)
},
},
{
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 === '—') {
const accepted = formatPackets(
a.last_apply_packets_accepted,
hasApply,
)
if (dropped === '—' && accepted === '—') {
return <DataGridMutedCell></DataGridMutedCell>
}
return (
<span className="text-success text-sm font-medium tabular-nums">
{text}
<span className="text-sm font-medium tabular-nums">
<span className="text-warning">{dropped}</span>
<span className="text-muted-foreground"> · </span>
<span className="text-success">{accepted}</span>
</span>
)
},
@@ -368,7 +356,6 @@ export function AgentFleetDataGrid({
searchPlaceholder="Поиск агентов…"
getSearchText={getSearchText}
tableLayout={{ width: 'fixed', columnsResizable: true }}
columnPinning={{ right: ['actions'] }}
onRowClick={(row) => onSelect(row.id)}
tabs={tabs}
activeTab={activeTab}
+7
View File
@@ -431,6 +431,12 @@ export function listRecentStats(db: Db, limit = 500) {
.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 {
const row = db.select().from(settings).where(eq(settings.key, key)).get()
return row?.value ?? ''
@@ -607,6 +613,7 @@ export const repos = {
insertStatsSample,
listStatsSamples,
listRecentStats,
deleteStatsSamplesForAgent,
getSetting,
setSetting,
listSettings,