feat(api, web): add agent stats reset functionality and enhance UI components
- 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:
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user