diff --git a/apps/web/package.json b/apps/web/package.json index 5267b81..0ca8d72 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -32,7 +32,7 @@ "react-day-picker": "^9.14.0", "react-dom": "^19.1.0", "react-hook-form": "^7.56.0", - "recharts": "^2.15.0", + "recharts": "^3.8.0", "sonner": "^1.7.0", "tailwindcss": "^4.1.0", "zod": "^4.0.0" diff --git a/apps/web/src/components/agents/add-agent-sheet.tsx b/apps/web/src/components/agents/add-agent-sheet.tsx index 428502f..fa592d3 100644 --- a/apps/web/src/components/agents/add-agent-sheet.tsx +++ b/apps/web/src/components/agents/add-agent-sheet.tsx @@ -23,14 +23,24 @@ import { SheetHeader, SheetTitle, } from '@evofw/ui/components/sheet' +import { + Stepper, + StepperContent, + StepperDescription, + StepperIndicator, + StepperItem, + StepperNav, + StepperPanel, + StepperSeparator, + StepperTitle, + StepperTrigger, +} from '@/components/reui/stepper' import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard' /** - * Create agent install invite — Sheet. - * Preview: https://reui.io/preview/base/sheet-1 · https://reui.io/preview/base/sheet-8 - * Hub: https://reui.io/components/sheet · https://reui.io/preview/base/components/c-sheet-1 - * Copy pattern: https://reui.io/preview/base/settings-14 - * Primitive API: https://ui.shadcn.com/docs/components/base/sheet + * Add agent wizard — Stepper in Sheet. + * Preview: https://reui.io/preview/base/solution-agents-6 · sheet-8 + * Docs: https://reui.io/docs/components/base/stepper */ type Platform = 'linux' | 'mikrotik' @@ -48,6 +58,7 @@ interface AddAgentSheetProps { export function AddAgentSheet({ open, onOpenChange }: AddAgentSheetProps) { const qc = useQueryClient() const { copyToClipboard } = useCopyToClipboard() + const [step, setStep] = useState(1) const [name, setName] = useState('web-01') const [platform, setPlatform] = useState('linux') const [created, setCreated] = useState(null) @@ -57,6 +68,7 @@ export function AddAgentSheet({ open, onOpenChange }: AddAgentSheetProps) { setCreated(null) setName('web-01') setPlatform('linux') + setStep(1) } }, [open]) @@ -68,6 +80,7 @@ export function AddAgentSheet({ open, onOpenChange }: AddAgentSheetProps) { }), onSuccess: (link) => { setCreated(link) + setStep(4) toast.success('Агент создан') void qc.invalidateQueries({ queryKey: ['agents'] }) }, @@ -82,107 +95,177 @@ export function AddAgentSheet({ open, onOpenChange }: AddAgentSheetProps) { toast.success('Скопировано') } + function resetWizard() { + setCreated(null) + setName('web-01') + setPlatform('linux') + setStep(1) + } + return ( - + {created ? 'Команда установки' : 'Добавить агента'} {created - ? 'Агент уже в списке (Invited). Скопируйте one-liner и выполните на хосте.' - : 'Создайте агента и короткую install-ссылку.'} + ? 'Агент в списке (Invited). Скопируйте one-liner на хост.' + : 'Платформа → имя → подтверждение → install.'} -
- {!created ? ( - <> - - Имя клиента - setName(e.target.value)} - placeholder="web-01" - /> - - - Платформа - - - - ) : ( - <> - - По id -
-
-                      {created.curl?.by_id}
-                    
- -
-
- - )} + + + + {created ? ( + <> + + По id +
+
+                            {created.curl?.by_id}
+                          
+ +
+
+ + Короткий slug +
+
+                            {created.curl?.by_slug}
+                          
+ +
+
+ + ) : ( +

+ Сначала создайте агента на шаге «Обзор». +

+ )} +
+ +
{created ? ( <> - @@ -192,9 +275,29 @@ export function AddAgentSheet({ open, onOpenChange }: AddAgentSheetProps) { - + {step > 1 ? ( + + ) : null} + {step < 3 ? ( + + ) : ( + + )} )} diff --git a/apps/web/src/components/agents/agent-lifecycle-timeline.tsx b/apps/web/src/components/agents/agent-lifecycle-timeline.tsx new file mode 100644 index 0000000..74e8309 --- /dev/null +++ b/apps/web/src/components/agents/agent-lifecycle-timeline.tsx @@ -0,0 +1,123 @@ +import type { Agent } from '@evofw/shared' +import { + Timeline, + TimelineContent, + TimelineDate, + TimelineHeader, + TimelineIndicator, + TimelineItem, + TimelineSeparator, + TimelineTitle, +} from '@/components/reui/timeline' +import { + Frame, + FrameDescription, + FrameHeader, + FramePanel, + FrameTitle, +} from '@/components/reui/frame' + +/** + * Agent lifecycle timeline. + * Preview: https://reui.io/preview/base/solution-agents-3 + * Docs: https://reui.io/docs/components/base/timeline + */ + +type Step = { + title: string + date?: string | null + detail?: string + done: boolean +} + +function formatWhen(iso?: string | null): string | undefined { + if (!iso) return undefined + const d = new Date(iso) + if (Number.isNaN(d.getTime())) return iso + return d.toLocaleString('ru-RU') +} + +export function AgentLifecycleTimeline({ agent }: { agent: Agent }) { + const steps: Step[] = [ + { + title: 'Создан (Invited)', + date: agent.created_at, + detail: 'Install-ссылка выдана', + done: true, + }, + { + title: 'Первый контакт', + date: agent.last_seen_at, + detail: agent.last_seen_ip + ? `IP ${agent.last_seen_ip}` + : agent.hostname + ? agent.hostname + : 'Ещё не подключался', + done: Boolean(agent.last_seen_at), + }, + { + title: 'Approved', + date: agent.approved_at, + detail: agent.status === 'pending' ? 'Ожидает approve' : undefined, + done: Boolean(agent.approved_at) || agent.status === 'approved', + }, + { + title: 'Last apply', + date: agent.last_apply_at, + detail: agent.last_apply_error + ? agent.last_apply_error + : (agent.last_apply_status ?? + (agent.last_apply_prefix_count != null + ? `${agent.last_apply_prefix_count} prefixes` + : undefined)), + done: Boolean(agent.last_apply_at), + }, + ] + + if (agent.revoked_at || agent.status === 'revoked') { + steps.push({ + title: 'Revoked', + date: agent.revoked_at, + done: true, + }) + } + + const activeStep = Math.max( + 1, + steps.reduce((acc, s, i) => (s.done ? i + 1 : acc), 1), + ) + + return ( + + + Жизненный цикл + + Invite → enroll → approve → apply + + + + + {steps.map((s, i) => ( + + + + + {s.title} + {s.date ? ( + + {formatWhen(s.date)} + + ) : ( + + )} + + {s.detail ? ( + {s.detail} + ) : null} + + ))} + + + + ) +} diff --git a/apps/web/src/components/agents/agents-fleet-chart.tsx b/apps/web/src/components/agents/agents-fleet-chart.tsx new file mode 100644 index 0000000..ce2b725 --- /dev/null +++ b/apps/web/src/components/agents/agents-fleet-chart.tsx @@ -0,0 +1,96 @@ +import { useMemo } from 'react' +import { useQuery } from '@tanstack/react-query' +import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts' +import { + Frame, + FrameDescription, + FrameHeader, + FramePanel, + FrameTitle, +} from '@/components/reui/frame' +import { recentStatsQueryOptions } from '@/queries' +import { + ChartContainer, + ChartTooltip, + ChartTooltipContent, + type ChartConfig, +} from '@evofw/ui/components/chart' +import { Skeleton } from '@evofw/ui/components/skeleton' + +/** + * Fleet apply throughput — live time-series from /api/v1/stats/recent. + * DNA: https://reui.io/preview/base/solution-agents-1 + */ + +const chartConfig = { + dropped: { label: 'Dropped', color: 'var(--chart-1)' }, + accepted: { label: 'Accepted', color: 'var(--chart-2)' }, +} satisfies ChartConfig + +export function AgentsFleetChart() { + const stats = useQuery(recentStatsQueryOptions()) + + const series = useMemo(() => { + const items = [...(stats.data?.items ?? [])].reverse().slice(-40) + return items.map((s) => ({ + t: s.recorded_at.slice(11, 19), + dropped: s.packets_dropped, + accepted: s.packets_accepted, + })) + }, [stats.data?.items]) + + return ( + + + Throughput + + Dropped / accepted по последним apply-снимкам флота + + + + {stats.isLoading ? ( + + ) : series.length === 0 ? ( +

+ Пока нет статистики apply — появится после sync агентов. +

+ ) : ( + + + + + + } /> + + + + + )} +
+ + ) +} diff --git a/apps/web/src/components/agents/agents-fleet-kpis.ts b/apps/web/src/components/agents/agents-fleet-kpis.ts new file mode 100644 index 0000000..9d94a54 --- /dev/null +++ b/apps/web/src/components/agents/agents-fleet-kpis.ts @@ -0,0 +1,88 @@ +import type { ReactNode } from 'react' +import type { Agent } from '@evofw/shared' +import type { KpiStatCard } from '@/components/reui-kit' + +const STALE_MS = 24 * 60 * 60 * 1000 + +export function isAgentStale(agent: Agent, now = Date.now()): boolean { + if (agent.status !== 'approved') return false + if (!agent.last_seen_at) return true + const t = Date.parse(agent.last_seen_at) + if (Number.isNaN(t)) return true + return now - t > STALE_MS +} + +export type FleetCounts = { + pending: number + invited: number + approved: number + revoked: number + stale: number + applyErrors: number +} + +export function computeFleetCounts(agents: Agent[]): FleetCounts { + const now = Date.now() + let pending = 0 + let invited = 0 + let approved = 0 + let revoked = 0 + let stale = 0 + let applyErrors = 0 + for (const a of agents) { + if (a.status === 'pending') pending += 1 + else if (a.status === 'invited') invited += 1 + else if (a.status === 'approved') approved += 1 + else if (a.status === 'revoked') revoked += 1 + if (isAgentStale(a, now)) stale += 1 + if (a.last_apply_error) applyErrors += 1 + } + return { pending, invited, approved, revoked, stale, applyErrors } +} + +export function fleetKpiCards( + counts: FleetCounts, + icons: { + pending: ReactNode + invited: ReactNode + approved: ReactNode + stale: ReactNode + }, +): KpiStatCard[] { + return [ + { + id: 'pending', + label: 'Pending', + value: counts.pending, + hint: 'approve backlog', + icon: icons.pending, + iconClassName: 'text-warning', + variant: counts.pending > 0 ? 'warning' : 'default', + }, + { + id: 'invited', + label: 'Invited', + value: counts.invited, + hint: 'ожидают install', + icon: icons.invited, + iconClassName: 'text-info', + }, + { + id: 'approved', + label: 'Approved', + value: counts.approved, + hint: 'в парке', + icon: icons.approved, + iconClassName: 'text-success', + }, + { + id: 'stale', + label: 'Offline / stale', + value: counts.stale, + hint: '>24ч без seen', + icon: icons.stale, + iconClassName: 'text-muted-foreground', + variant: counts.stale > 0 ? 'warning' : 'default', + }, + ] +} diff --git a/apps/web/src/components/reui/stepper.tsx b/apps/web/src/components/reui/stepper.tsx new file mode 100644 index 0000000..8080e43 --- /dev/null +++ b/apps/web/src/components/reui/stepper.tsx @@ -0,0 +1,477 @@ +import { + Children, + createContext, + HTMLAttributes, + isValidElement, + ReactElement, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from "react" +import { mergeProps } from "@base-ui/react/merge-props" +import { useRender } from "@base-ui/react/use-render" + +import { cn } from "@evofw/ui/lib/utils" + +// Types +type StepperOrientation = "horizontal" | "vertical" +type StepState = "active" | "completed" | "inactive" | "loading" +type StepIndicators = { + active?: React.ReactNode + completed?: React.ReactNode + inactive?: React.ReactNode + loading?: React.ReactNode +} + +interface StepperContextValue { + activeStep: number + setActiveStep: (step: number) => void + stepsCount: number + orientation: StepperOrientation + registerTrigger: (node: HTMLButtonElement | null) => void + triggerNodes: HTMLButtonElement[] + focusNext: (currentIdx: number) => void + focusPrev: (currentIdx: number) => void + focusFirst: () => void + focusLast: () => void + indicators: StepIndicators +} + +interface StepItemContextValue { + step: number + state: StepState + isDisabled: boolean + isLoading: boolean +} + +const StepperContext = createContext(undefined) +const StepItemContext = createContext( + undefined +) + +function useStepper() { + const ctx = useContext(StepperContext) + if (!ctx) throw new Error("useStepper must be used within a Stepper") + return ctx +} + +function useStepItem() { + const ctx = useContext(StepItemContext) + if (!ctx) throw new Error("useStepItem must be used within a StepperItem") + return ctx +} + +interface StepperProps extends HTMLAttributes { + defaultValue?: number + value?: number + onValueChange?: (value: number) => void + orientation?: StepperOrientation + indicators?: StepIndicators +} + +function Stepper({ + defaultValue = 1, + value, + onValueChange, + orientation = "horizontal", + className, + children, + indicators = {}, + ...props +}: StepperProps) { + const [activeStep, setActiveStep] = useState(defaultValue) + const [triggerNodes, setTriggerNodes] = useState([]) + + // Register/unregister triggers + const registerTrigger = useCallback((node: HTMLButtonElement | null) => { + setTriggerNodes((prev) => { + if (node && !prev.includes(node)) { + return [...prev, node] + } else if (!node && prev.includes(node!)) { + return prev.filter((n) => n !== node) + } else { + return prev + } + }) + }, []) + + const handleSetActiveStep = useCallback( + (step: number) => { + if (value === undefined) { + setActiveStep(step) + } + onValueChange?.(step) + }, + [value, onValueChange] + ) + + const currentStep = value ?? activeStep + + // Keyboard navigation logic + const focusTrigger = (idx: number) => { + if (triggerNodes[idx]) triggerNodes[idx].focus() + } + const focusNext = (currentIdx: number) => + focusTrigger((currentIdx + 1) % triggerNodes.length) + const focusPrev = (currentIdx: number) => + focusTrigger((currentIdx - 1 + triggerNodes.length) % triggerNodes.length) + const focusFirst = () => focusTrigger(0) + const focusLast = () => focusTrigger(triggerNodes.length - 1) + + // Context value + const contextValue = useMemo( + () => ({ + activeStep: currentStep, + setActiveStep: handleSetActiveStep, + stepsCount: Children.toArray(children).filter( + (child): child is ReactElement => + isValidElement(child) && + (child.type as { displayName?: string }).displayName === "StepperItem" + ).length, + orientation, + registerTrigger, + focusNext, + focusPrev, + focusFirst, + focusLast, + triggerNodes, + indicators, + }), + [ + currentStep, + handleSetActiveStep, + children, + orientation, + registerTrigger, + triggerNodes, + ] + ) + + return ( + +
+ {children} +
+
+ ) +} + +interface StepperItemProps extends React.HTMLAttributes { + step: number + completed?: boolean + disabled?: boolean + loading?: boolean +} + +function StepperItem({ + step, + completed = false, + disabled = false, + loading = false, + className, + children, + ...props +}: StepperItemProps) { + const { activeStep } = useStepper() + + const state: StepState = + completed || step < activeStep + ? "completed" + : activeStep === step + ? "active" + : "inactive" + + const isLoading = loading && step === activeStep + + return ( + +
+ {children} +
+
+ ) +} + +type StepperTriggerProps = useRender.ComponentProps<"button"> + +function StepperTrigger({ + className, + children, + tabIndex, + render, + ...props +}: StepperTriggerProps) { + const { state, isLoading } = useStepItem() + const stepperCtx = useStepper() + const { + setActiveStep, + activeStep, + registerTrigger, + triggerNodes, + focusNext, + focusPrev, + focusFirst, + focusLast, + } = stepperCtx + const { step, isDisabled } = useStepItem() + const isSelected = activeStep === step + const id = `stepper-tab-${step}` + const panelId = `stepper-panel-${step}` + + // Register this trigger for keyboard navigation + const btnRef = useRef(null) + useEffect(() => { + if (btnRef.current) { + registerTrigger(btnRef.current) + } + }, [btnRef.current]) + + // Find our index among triggers for navigation + const myIdx = useMemo( + () => + triggerNodes.findIndex((n: HTMLButtonElement) => n === btnRef.current), + [triggerNodes, btnRef.current] + ) + + const handleKeyDown = (e: React.KeyboardEvent) => { + switch (e.key) { + case "ArrowRight": + case "ArrowDown": + e.preventDefault() + if (myIdx !== -1 && focusNext) focusNext(myIdx) + break + case "ArrowLeft": + case "ArrowUp": + e.preventDefault() + if (myIdx !== -1 && focusPrev) focusPrev(myIdx) + break + case "Home": + e.preventDefault() + if (focusFirst) focusFirst() + break + case "End": + e.preventDefault() + if (focusLast) focusLast() + break + case "Enter": + case " ": + e.preventDefault() + setActiveStep(step) + break + } + } + + const defaultProps = { + role: "tab", + id, + "aria-selected": isSelected, + "aria-controls": panelId, + tabIndex: typeof tabIndex === "number" ? tabIndex : isSelected ? 0 : -1, + "data-slot": "stepper-trigger", + "data-state": state, + "data-loading": isLoading, + className: cn( + "focus-visible:border-ring focus-visible:ring-ring/50 inline-flex cursor-pointer items-center outline-none focus-visible:z-10 focus-visible:ring-3 disabled:pointer-events-none disabled:opacity-60", + "gap-2.5 rounded-full", + className + ), + onClick: () => setActiveStep(step), + onKeyDown: handleKeyDown, + disabled: isDisabled, + children, + } + + return useRender({ + defaultTagName: "button", + render, + ref: btnRef, + props: mergeProps<"button">(defaultProps, props), + }) +} + +function StepperIndicator({ + children, + className, +}: React.ComponentProps<"div">) { + const { state, isLoading } = useStepItem() + const { indicators } = useStepper() + + return ( +
+
+ {indicators && + ((isLoading && indicators.loading) || + (state === "completed" && indicators.completed) || + (state === "active" && indicators.active) || + (state === "inactive" && indicators.inactive)) + ? (isLoading && indicators.loading) || + (state === "completed" && indicators.completed) || + (state === "active" && indicators.active) || + (state === "inactive" && indicators.inactive) + : children} +
+
+ ) +} + +function StepperSeparator({ className }: React.ComponentProps<"div">) { + const { state } = useStepItem() + + return ( +
+ ) +} + +function StepperTitle({ children, className }: React.ComponentProps<"h3">) { + const { state } = useStepItem() + + return ( +

+ {children} +

+ ) +} + +function StepperDescription({ + children, + className, +}: React.ComponentProps<"div">) { + const { state } = useStepItem() + + return ( +
+ {children} +
+ ) +} + +function StepperNav({ children, className }: React.ComponentProps<"nav">) { + const { activeStep, orientation } = useStepper() + + return ( + + ) +} + +function StepperPanel({ children, className }: React.ComponentProps<"div">) { + const { activeStep } = useStepper() + + return ( +
+ {children} +
+ ) +} + +interface StepperContentProps extends React.ComponentProps<"div"> { + value: number + forceMount?: boolean +} + +function StepperContent({ + value, + forceMount, + children, + className, +}: StepperContentProps) { + const { activeStep } = useStepper() + const isActive = value === activeStep + + if (!forceMount && !isActive) { + return null + } + + return ( + + ) +} + +export { + useStepper, + useStepItem, + Stepper, + StepperItem, + StepperTrigger, + StepperIndicator, + StepperSeparator, + StepperTitle, + StepperDescription, + StepperPanel, + StepperContent, + StepperNav, + type StepperProps, + type StepperItemProps, + type StepperTriggerProps, + type StepperContentProps, +} \ No newline at end of file diff --git a/apps/web/src/components/reui/timeline.tsx b/apps/web/src/components/reui/timeline.tsx new file mode 100644 index 0000000..493579e --- /dev/null +++ b/apps/web/src/components/reui/timeline.tsx @@ -0,0 +1,258 @@ +"use client" + +import { createContext, useCallback, useContext, useState } from "react" +import { mergeProps } from "@base-ui/react/merge-props" +import { useRender } from "@base-ui/react/use-render" + +import { cn } from "@evofw/ui/lib/utils" + +// Types +type TimelineContextValue = { + activeStep: number + setActiveStep: (step: number) => void +} + +// Context +const TimelineContext = createContext( + undefined +) + +const useTimeline = () => { + const context = useContext(TimelineContext) + if (!context) { + throw new Error("useTimeline must be used within a Timeline") + } + return context +} + +// Components +interface TimelineProps extends useRender.ComponentProps<"div"> { + defaultValue?: number + value?: number + onValueChange?: (value: number) => void + orientation?: "horizontal" | "vertical" +} + +function Timeline({ + defaultValue = 1, + value, + onValueChange, + orientation = "vertical", + className, + render, + children, + ...props +}: TimelineProps) { + const [activeStep, setInternalStep] = useState(defaultValue) + + const setActiveStep = useCallback( + (step: number) => { + if (value === undefined) { + setInternalStep(step) + } + onValueChange?.(step) + }, + [value, onValueChange] + ) + + const currentStep = value ?? activeStep + + const defaultProps = { + className: cn( + "group/timeline flex data-[orientation=horizontal]:w-full data-[orientation=horizontal]:flex-row data-[orientation=vertical]:flex-col", + className + ), + "data-orientation": orientation, + "data-slot": "timeline", + children, + } + + return ( + + {useRender({ + defaultTagName: "div", + render, + props: mergeProps<"div">(defaultProps, props), + })} + + ) +} + +// TimelineContent +function TimelineContent({ + className, + render, + children, + ...props +}: useRender.ComponentProps<"div">) { + const defaultProps = { + className: cn("text-muted-foreground text-sm", className), + "data-slot": "timeline-content", + children, + } + + return useRender({ + defaultTagName: "div", + render, + props: mergeProps<"div">(defaultProps, props), + }) +} + +// TimelineDate +type TimelineDateProps = useRender.ComponentProps<"time"> + +function TimelineDate({ + className, + render, + children, + ...props +}: TimelineDateProps) { + const defaultProps = { + className: cn( + "mb-1 block font-medium text-muted-foreground text-xs group-data-[orientation=vertical]/timeline:max-sm:h-4", + className + ), + "data-slot": "timeline-date", + children, + } + + return useRender({ + defaultTagName: "time", + render, + props: mergeProps<"time">(defaultProps, props), + }) +} + +// TimelineHeader +function TimelineHeader({ + className, + render, + children, + ...props +}: useRender.ComponentProps<"div">) { + const defaultProps = { + className: cn(className), + "data-slot": "timeline-header", + children, + } + + return useRender({ + defaultTagName: "div", + render, + props: mergeProps<"div">(defaultProps, props), + }) +} + +// TimelineIndicator +type TimelineIndicatorProps = useRender.ComponentProps<"div"> + +function TimelineIndicator({ + className, + children, + render, + ...props +}: TimelineIndicatorProps) { + const defaultProps = { + "aria-hidden": true, + className: cn( + "group-data-[orientation=horizontal]/timeline:-top-6 group-data-[orientation=horizontal]/timeline:-translate-y-1/2 group-data-[orientation=vertical]/timeline:-left-6 group-data-[orientation=vertical]/timeline:-translate-x-1/2 absolute size-4 rounded-full border-2 border-primary/20 group-data-[orientation=vertical]/timeline:top-0 group-data-[orientation=horizontal]/timeline:left-0 group-data-completed/timeline-item:border-primary", + className + ), + "data-slot": "timeline-indicator", + children, + } + + return useRender({ + defaultTagName: "div", + render, + props: mergeProps<"div">(defaultProps, props), + }) +} + +// TimelineItem +interface TimelineItemProps extends useRender.ComponentProps<"div"> { + step: number +} + +function TimelineItem({ + step, + className, + render, + children, + ...props +}: TimelineItemProps) { + const { activeStep } = useTimeline() + + const defaultProps = { + className: cn( + "group/timeline-item relative flex flex-1 flex-col gap-0.5 group-data-[orientation=vertical]/timeline:ms-8 group-data-[orientation=horizontal]/timeline:mt-8 group-data-[orientation=horizontal]/timeline:not-last:pe-8 group-data-[orientation=vertical]/timeline:not-last:pb-6 has-[+[data-completed]]:**:data-[slot=timeline-separator]:bg-primary", + className + ), + "data-completed": step <= activeStep || undefined, + "data-slot": "timeline-item", + children, + } + + return useRender({ + defaultTagName: "div", + render, + props: mergeProps<"div">(defaultProps, props), + }) +} + +// TimelineSeparator +function TimelineSeparator({ + className, + render, + children, + ...props +}: useRender.ComponentProps<"div">) { + const defaultProps = { + "aria-hidden": true, + className: cn( + "group-data-[orientation=horizontal]/timeline:-top-6 group-data-[orientation=horizontal]/timeline:-translate-y-1/2 group-data-[orientation=vertical]/timeline:-left-6 group-data-[orientation=vertical]/timeline:-translate-x-1/2 absolute self-start bg-primary/10 group-last/timeline-item:hidden group-data-[orientation=horizontal]/timeline:h-0.5 group-data-[orientation=vertical]/timeline:h-[calc(100%-1rem-0.25rem)] group-data-[orientation=horizontal]/timeline:w-[calc(100%-1rem-0.25rem)] group-data-[orientation=vertical]/timeline:w-0.5 group-data-[orientation=horizontal]/timeline:translate-x-4.5 group-data-[orientation=vertical]/timeline:translate-y-4.5", + className + ), + "data-slot": "timeline-separator", + children, + } + + return useRender({ + defaultTagName: "div", + render, + props: mergeProps<"div">(defaultProps, props), + }) +} + +// TimelineTitle +function TimelineTitle({ + className, + render, + children, + ...props +}: useRender.ComponentProps<"h3">) { + const defaultProps = { + className: cn("font-medium text-sm", className), + "data-slot": "timeline-title", + children, + } + + return useRender({ + defaultTagName: "h3", + render, + props: mergeProps<"h3">(defaultProps, props), + }) +} + +export { + Timeline, + TimelineContent, + TimelineDate, + TimelineHeader, + TimelineIndicator, + TimelineItem, + TimelineSeparator, + TimelineTitle, +} \ No newline at end of file diff --git a/apps/web/src/routes/_auth/agents/$id.tsx b/apps/web/src/routes/_auth/agents/$id.tsx index 2b03410..77817fb 100644 --- a/apps/web/src/routes/_auth/agents/$id.tsx +++ b/apps/web/src/routes/_auth/agents/$id.tsx @@ -6,8 +6,9 @@ import type { ColumnDef } from '@tanstack/react-table' import { BanIcon, CheckCircle2Icon, - CpuIcon, ClockIcon, + Copy, + CpuIcon, } from 'lucide-react' import { PageShell, DetailPanel } from '@/components/reui-kit' import { @@ -17,12 +18,18 @@ import { FramePanel, FrameTitle, } from '@/components/reui/frame' +import { + Alert, + AlertDescription, + AlertTitle, +} from '@/components/reui/alert' import { StatusBadge } from '@/components/status-badge' import { Badge } from '@/components/reui/badge' import { AgentPlatformIcon, platformLabel, } from '@/components/agents/agent-platform-icon' +import { AgentLifecycleTimeline } from '@/components/agents/agent-lifecycle-timeline' import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header' import { DataGridPrimaryCell } from '@/components/data-grid-cell' import { DataGrid } from '@/components/reui/data-grid/data-grid' @@ -35,6 +42,7 @@ import { policySetsQueryOptions, } from '@/queries' import { apiFetch } from '@/lib/api' +import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard' import { Button } from '@evofw/ui/components/button' import { Checkbox } from '@evofw/ui/components/checkbox' import { Input } from '@evofw/ui/components/input' @@ -48,14 +56,27 @@ import { } from '@evofw/ui/components/select' import { Skeleton } from '@evofw/ui/components/skeleton' import type { PolicySet } from '@evofw/shared' +import { CircleAlertIcon } from 'lucide-react' + +/** + * Agent detail — Solutions Agents DNA. + * Preview: https://reui.io/preview/base/solution-agents-3 · stats-12 · settings-14 + */ export const Route = createFileRoute('/_auth/agents/$id')({ + loader: async ({ context: { queryClient }, params }) => { + const agent = await queryClient.ensureQueryData( + agentQueryOptions(params.id), + ) + return { breadcrumb: agent.name } + }, component: AgentDetailPage, }) function AgentDetailPage() { const { id } = Route.useParams() const qc = useQueryClient() + const { copyToClipboard } = useCopyToClipboard() const agentQ = useQuery(agentQueryOptions(id)) const setsQ = useQuery(policySetsQueryOptions()) const assignedQ = useQuery(agentPolicySetsQueryOptions(id)) @@ -82,6 +103,16 @@ function AgentDetailPage() { onError: (e: Error) => toast.error(e.message), }) + const approve = useMutation({ + mutationFn: () => + apiFetch(`/api/v1/agents/${id}/approve`, { method: 'POST' }), + onSuccess: () => { + toast.success('Агент одобрен') + void qc.invalidateQueries({ queryKey: ['agents'] }) + }, + onError: (e: Error) => toast.error(e.message), + }) + const addOverride = useMutation({ mutationFn: () => apiFetch(`/api/v1/agents/${id}/overrides`, { @@ -200,21 +231,41 @@ function AgentDetailPage() { if (agentQ.isLoading || !a) { return ( + + + ) } + const headerDesc = [ + a.hostname, + platformLabel(a.platform), + `gen ${a.policy_generation}`, + ] + .filter(Boolean) + .join(' · ') + return ( + <> + {a.status === 'pending' ? ( + + ) : null} {a.status === 'approved' ? ( + ) : null} -
+ } /> + + {a.last_apply_error ? ( + + + Ошибка apply + {a.last_apply_error} + + ) : null} + {a.status === 'pending' ? ( + + + Ожидает approve + + Агент записался, но политика не выдаётся до одобрения. + + + ) : null} + {a.status === 'invited' ? ( + + + Invited + + Скопируйте install-команду и выполните на хосте. + + + ) : null} +
+ + + + + Install / identity + + Copy one-liner · hostname · token + + + + {a.install_curl ? ( +
+
+                      {a.install_curl}
+                    
+ +
+ ) : ( +

+ Install curl недоступен +

+ )} +
+
+ Hostname:{' '} + + {a.hostname ?? '—'} + +
+
+ Last seen IP:{' '} + + {a.last_seen_ip ?? '—'} + +
+
+ Client:{' '} + + {a.client_version ?? '—'} + +
+
+ Token prefix:{' '} + + {a.token_prefix} + +
+
+
+ + Режим фильтра diff --git a/apps/web/src/routes/_auth/agents/index.tsx b/apps/web/src/routes/_auth/agents/index.tsx index 08de1eb..fbc119a 100644 --- a/apps/web/src/routes/_auth/agents/index.tsx +++ b/apps/web/src/routes/_auth/agents/index.tsx @@ -1,15 +1,40 @@ import { createFileRoute, useNavigate } from '@tanstack/react-router' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { toast } from 'sonner' -import { Check, Copy, Pencil, Plus, Trash2 } from 'lucide-react' +import { + Check, + CheckCircle2, + CircleAlertIcon, + Copy, + Inbox, + Pencil, + Plus, + Trash2, + UserPlus, + WifiOff, +} from 'lucide-react' import { useCallback, useMemo, useState, type MouseEvent } from 'react' import type { ColumnDef } from '@tanstack/react-table' import type { Filter, FilterFieldConfig } from '@/components/reui/filters' import { + KpiStatGrid, PageHeader, PageShell, ResourcePage, } from '@/components/reui-kit' +import { + Frame, + FrameDescription, + FrameHeader, + FramePanel, + FrameTitle, +} from '@/components/reui/frame' +import { Badge } from '@/components/reui/badge' +import { + Alert, + AlertDescription, + AlertTitle, +} from '@/components/reui/alert' import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header' import { DataGridMutedCell, @@ -18,10 +43,15 @@ import { import { StatusBadge } from '@/components/status-badge' import { ConfirmDialog } from '@/components/confirm-dialog' import { AddAgentSheet } from '@/components/agents/add-agent-sheet' +import { AgentsFleetChart } from '@/components/agents/agents-fleet-chart' import { AgentPlatformIcon, platformLabel, } from '@/components/agents/agent-platform-icon' +import { + computeFleetCounts, + fleetKpiCards, +} from '@/components/agents/agents-fleet-kpis' import { agentsQueryOptions } from '@/queries' import { apiFetch } from '@/lib/api' import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard' @@ -34,11 +64,11 @@ import { import type { Agent } from '@evofw/shared' /** - * Agents list — ResourcePage (Frame + tabs + Filters + DataGrid). - * Preview: https://reui.io/preview/base/data-grid-filtering-2 - * Row icon + copyable install: https://reui.io/preview/base/settings-14 · stats-12 - * Empty: https://reui.io/preview/base/empty-state-7 + * Agents ops console — Solutions Agents DNA. + * Preview: https://reui.io/preview/base/solution-agents-1 · stats-12 · data-grid-filtering-2 + * Pending inbox DNA: https://reui.io/preview/base/solution-agents-5 (Frame adapt) */ + export const Route = createFileRoute('/_auth/agents/')({ component: AgentsPage, }) @@ -63,6 +93,21 @@ function AgentsPage() { onError: (e: Error) => toast.error(e.message), }) + const approveAllPending = useMutation({ + mutationFn: async (ids: string[]) => { + await Promise.all( + ids.map((id) => + apiFetch(`/api/v1/agents/${id}/approve`, { method: 'POST' }), + ), + ) + }, + onSuccess: () => { + toast.success('Все pending одобрены') + void qc.invalidateQueries({ queryKey: ['agents'] }) + }, + onError: (e: Error) => toast.error(e.message), + }) + const remove = useMutation({ mutationFn: (id: string) => apiFetch(`/api/v1/agents/${id}`, { method: 'DELETE' }), @@ -75,6 +120,30 @@ function AgentsPage() { }) const items = agentsQ.data?.items ?? [] + const counts = useMemo(() => computeFleetCounts(items), [items]) + const pendingIds = useMemo( + () => items.filter((a) => a.status === 'pending').map((a) => a.id), + [items], + ) + + const kpiCards = useMemo( + () => + fleetKpiCards(counts, { + pending: , + invited: , + approved: , + stale: , + }).map((card) => ({ + ...card, + onSelect: () => { + if (card.id === 'pending') setActiveTab('pending') + else if (card.id === 'invited') setActiveTab('invited') + else if (card.id === 'approved') setActiveTab('approved') + else if (card.id === 'stale') setActiveTab('approved') + }, + })), + [counts], + ) const filterFields: FilterFieldConfig[] = useMemo( () => [ @@ -158,6 +227,31 @@ function AgentsPage() { ), cell: ({ row }) => , }, + { + id: 'apply', + accessorFn: (row) => row.last_apply_status ?? '', + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const a = row.original + if (a.last_apply_error) { + return ( + + error + + ) + } + if (!a.last_apply_status && !a.last_apply_at) { + return + } + return ( + + {a.last_apply_status ?? 'ok'} + + ) + }, + }, { id: 'install', enableSorting: false, @@ -269,12 +363,59 @@ function AgentsPage() { + + + {counts.pending > 0 ? ( + + +
+ Attention — pending approve + + {counts.pending} агент(ов) ждут одобрения (DNA approval inbox) + +
+
+ + +
+
+ + ) : null} + + {counts.applyErrors > 0 ? ( + + + Ошибки apply + + У {counts.applyErrors} агент(ов) есть last_apply_error — откройте + карточку для деталей. + + + ) : null} + + + =16.0.0} @@ -2307,6 +2324,9 @@ packages: es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-toolkit@1.49.0: + resolution: {integrity: sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g==} + esbuild@0.18.20: resolution: {integrity: sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==} engines: {node: '>=12'} @@ -2340,6 +2360,9 @@ packages: eventemitter3@4.0.7: resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + expand-template@2.0.3: resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} engines: {node: '>=6'} @@ -2465,6 +2488,12 @@ packages: ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + immer@10.2.0: + resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==} + + immer@11.1.15: + resolution: {integrity: sha512-VrNANlmnWQnh5COXIIOQXM9oOJw7naGKlBT74ZOOR6lpVXc3gFEu9FJLDFcpCJ2j+NWr8TIwtWD//T6ZX6TKiQ==} + inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} @@ -2839,6 +2868,18 @@ packages: react-is@18.3.1: resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + react-redux@9.3.0: + resolution: {integrity: sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==} + peerDependencies: + '@types/react': ^18.2.25 || ^19 + react: ^18.0 || ^19 + redux: ^5.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + redux: + optional: true + react-refresh@0.17.0: resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} engines: {node: '>=0.10.0'} @@ -2919,10 +2960,29 @@ packages: react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + recharts@3.8.0: + resolution: {integrity: sha512-Z/m38DX3L73ExO4Tpc9/iZWHmHnlzWG4njQbxsF5aSjwqmHNDDIm0rdEBArkwsBvR8U6EirlEHiQNYWCVh9sGQ==} + engines: {node: '>=18'} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + redux-thunk@3.1.0: + resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==} + peerDependencies: + redux: ^5.0.0 + + redux@5.0.1: + resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==} + require-from-string@2.0.2: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + reselect@5.1.1: + resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==} + reselect@5.2.0: resolution: {integrity: sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==} @@ -3268,6 +3328,9 @@ packages: victory-vendor@36.9.2: resolution: {integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==} + victory-vendor@37.3.6: + resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==} + vite-node@3.2.4: resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -4341,6 +4404,18 @@ snapshots: '@radix-ui/rect@1.1.2': {} + '@reduxjs/toolkit@2.12.0(react-redux@9.3.0(@types/react@19.2.17)(react@19.2.7)(redux@5.0.1))(react@19.2.7)': + dependencies: + '@standard-schema/spec': 1.1.0 + '@standard-schema/utils': 0.3.0 + immer: 11.1.15 + redux: 5.0.1 + redux-thunk: 3.1.0(redux@5.0.1) + reselect: 5.2.0 + optionalDependencies: + react: 19.2.7 + react-redux: 9.3.0(@types/react@19.2.17)(react@19.2.7)(redux@5.0.1) + '@rolldown/pluginutils@1.0.0-beta.27': {} '@rollup/rollup-android-arm-eabi@4.62.2': @@ -4418,6 +4493,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.62.2': optional: true + '@standard-schema/spec@1.1.0': {} + '@standard-schema/utils@0.3.0': {} '@tabby_ai/hijri-converter@1.0.5': {} @@ -4680,6 +4757,8 @@ snapshots: dependencies: csstype: 3.2.3 + '@types/use-sync-external-store@0.0.6': {} + '@vitejs/plugin-react@4.7.0(vite@6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.1)(yaml@2.9.0))': dependencies: '@babel/core': 7.29.7 @@ -4987,6 +5066,8 @@ snapshots: es-module-lexer@1.7.0: {} + es-toolkit@1.49.0: {} + esbuild@0.18.20: optionalDependencies: '@esbuild/android-arm': 0.18.20 @@ -5109,6 +5190,8 @@ snapshots: eventemitter3@4.0.7: {} + eventemitter3@5.0.4: {} + expand-template@2.0.3: {} expect-type@1.4.0: {} @@ -5246,6 +5329,10 @@ snapshots: ieee754@1.2.1: {} + immer@10.2.0: {} + + immer@11.1.15: {} + inherits@2.0.4: {} ini@1.3.8: {} @@ -5563,6 +5650,15 @@ snapshots: react-is@18.3.1: {} + react-redux@9.3.0(@types/react@19.2.17)(react@19.2.7)(redux@5.0.1): + dependencies: + '@types/use-sync-external-store': 0.0.6 + react: 19.2.7 + use-sync-external-store: 1.6.0(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + redux: 5.0.1 + react-refresh@0.17.0: {} react-remove-scroll-bar@2.3.8(@types/react@19.2.17)(react@19.2.7): @@ -5642,8 +5738,36 @@ snapshots: tiny-invariant: 1.3.3 victory-vendor: 36.9.2 + recharts@3.8.0(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react-is@18.3.1)(react@19.2.7)(redux@5.0.1): + dependencies: + '@reduxjs/toolkit': 2.12.0(react-redux@9.3.0(@types/react@19.2.17)(react@19.2.7)(redux@5.0.1))(react@19.2.7) + clsx: 2.1.1 + decimal.js-light: 2.5.1 + es-toolkit: 1.49.0 + eventemitter3: 5.0.4 + immer: 10.2.0 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + react-is: 18.3.1 + react-redux: 9.3.0(@types/react@19.2.17)(react@19.2.7)(redux@5.0.1) + reselect: 5.1.1 + tiny-invariant: 1.3.3 + use-sync-external-store: 1.6.0(react@19.2.7) + victory-vendor: 37.3.6 + transitivePeerDependencies: + - '@types/react' + - redux + + redux-thunk@3.1.0(redux@5.0.1): + dependencies: + redux: 5.0.1 + + redux@5.0.1: {} + require-from-string@2.0.2: {} + reselect@5.1.1: {} + reselect@5.2.0: {} resolve-from@5.0.0: {} @@ -5970,6 +6094,23 @@ snapshots: d3-time: 3.1.0 d3-timer: 3.0.1 + victory-vendor@37.3.6: + dependencies: + '@types/d3-array': 3.2.2 + '@types/d3-ease': 3.0.2 + '@types/d3-interpolate': 3.0.4 + '@types/d3-scale': 4.0.9 + '@types/d3-shape': 3.1.8 + '@types/d3-time': 3.0.4 + '@types/d3-timer': 3.0.2 + d3-array: 3.2.4 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-scale: 4.0.2 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-timer: 3.0.1 + vite-node@3.2.4(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.1)(yaml@2.9.0): dependencies: cac: 6.7.14