From a19cc17cf57907001841abd655c1285e808f42a5 Mon Sep 17 00:00:00 2001 From: TerrifiedBug Date: Sat, 7 Mar 2026 17:11:52 +0000 Subject: [PATCH 01/29] feat: UI improvements across fleet, dashboard, pipelines, and profile - Fleet: collapse node labels into a compact count button with popover - Fleet: replace native confirm() with styled ConfirmDialog for maintenance mode - Dashboard: add drag, resize, and reorder support for custom view panels using react-grid-layout - Pipelines: center the SLI health dot in the Health column - Profile: display user role and super admin status in personal info --- package.json | 2 + pnpm-lock.yaml | 70 +++ src/app/(dashboard)/fleet/page.tsx | 86 +++- src/app/(dashboard)/pipelines/page.tsx | 4 +- src/app/(dashboard)/profile/page.tsx | 30 ++ src/app/globals.css | 1 + src/components/dashboard/custom-view.tsx | 527 +++++++++++++++------- src/components/dashboard/metric-chart.tsx | 8 +- src/server/routers/dashboard.ts | 22 + 9 files changed, 561 insertions(+), 189 deletions(-) diff --git a/package.json b/package.json index b25d16ef..8c05548b 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "radix-ui": "^1.4.3", "react": "19.2.3", "react-dom": "19.2.3", + "react-grid-layout": "^2.2.2", "react-hook-form": "^7.71.2", "recharts": "2.15.4", "simple-git": "^3.32.3", @@ -71,6 +72,7 @@ "@types/nodemailer": "^7.0.11", "@types/react": "^19", "@types/react-dom": "^19", + "@types/react-grid-layout": "^2.1.0", "eslint": "^9", "eslint-config-next": "16.1.6", "prisma": "^7.4.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ccf5a90b..42e28f91 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -105,6 +105,9 @@ importers: react-dom: specifier: 19.2.3 version: 19.2.3(react@19.2.3) + react-grid-layout: + specifier: ^2.2.2 + version: 2.2.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3) react-hook-form: specifier: ^7.71.2 version: 7.71.2(react@19.2.3) @@ -157,6 +160,9 @@ importers: '@types/react-dom': specifier: ^19 version: 19.2.3(@types/react@19.2.14) + '@types/react-grid-layout': + specifier: ^2.1.0 + version: 2.1.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) eslint: specifier: ^9 version: 9.39.3(jiti@2.6.1) @@ -1932,6 +1938,10 @@ packages: peerDependencies: '@types/react': ^19.2.0 + '@types/react-grid-layout@2.1.0': + resolution: {integrity: sha512-pHEjVg9ert6BDFHFQ1IEdLUkd2gasJvyti5lV2kE46N/R07ZiaSZpAXeXJAA1MXy/Qby23fZmiuEgZkITxPXug==} + deprecated: This is a stub types definition. react-grid-layout provides its own type definitions, so you do not need this installed. + '@types/react@19.2.14': resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} @@ -2894,6 +2904,9 @@ packages: fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-equals@4.0.3: + resolution: {integrity: sha512-G3BSX9cfKttjr+2o1O22tYMLq0DPluZnYtq1rXumE1SpL/F/SLIfHx08WYQoWSIpeMYf8sRbJ8++71+v6Pnxfg==} + fast-equals@5.4.0: resolution: {integrity: sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==} engines: {node: '>=6.0.0'} @@ -4102,6 +4115,18 @@ packages: peerDependencies: react: ^19.2.3 + react-draggable@4.5.0: + resolution: {integrity: sha512-VC+HBLEZ0XJxnOxVAZsdRi8rD04Iz3SiiKOoYzamjylUcju/hP9np/aZdLHf/7WOD268WMoNJMvYfB5yAK45cw==} + peerDependencies: + react: '>= 16.3.0' + react-dom: '>= 16.3.0' + + react-grid-layout@2.2.2: + resolution: {integrity: sha512-yNo9pxQWoxHWRAwHGSVT4DEGELYPyQ7+q9lFclb5jcqeFzva63/2F72CryS/jiTIr/SBIlTaDdyjqH+ODg8oBw==} + peerDependencies: + react: '>= 16.3.0' + react-dom: '>= 16.3.0' + react-hook-form@7.71.2: resolution: {integrity: sha512-1CHvcDYzuRUNOflt4MOq3ZM46AronNJtQ1S7tnX6YN4y72qhgiUItpacZUAQ0TyWYci3yz1X+rXaSxiuEm86PA==} engines: {node: '>=18.0.0'} @@ -4134,6 +4159,12 @@ packages: '@types/react': optional: true + react-resizable@3.1.3: + resolution: {integrity: sha512-liJBNayhX7qA4tBJiBD321FDhJxgGTJ07uzH5zSORXoE8h7PyEZ8mLqmosST7ppf6C4zUsbd2gzDMmBCfFp9Lw==} + peerDependencies: + react: '>= 16.3' + react-dom: '>= 16.3' + react-smooth@4.0.4: resolution: {integrity: sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==} peerDependencies: @@ -4203,6 +4234,9 @@ packages: require-main-filename@2.0.0: resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} + resize-observer-polyfill@1.5.1: + resolution: {integrity: sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -6562,6 +6596,13 @@ snapshots: dependencies: '@types/react': 19.2.14 + '@types/react-grid-layout@2.1.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + react-grid-layout: 2.2.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + transitivePeerDependencies: + - react + - react-dom + '@types/react@19.2.14': dependencies: csstype: 3.2.3 @@ -7722,6 +7763,8 @@ snapshots: fast-deep-equal@3.1.3: {} + fast-equals@4.0.3: {} + fast-equals@5.4.0: {} fast-glob@3.3.1: @@ -8898,6 +8941,24 @@ snapshots: react: 19.2.3 scheduler: 0.27.0 + react-draggable@4.5.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + dependencies: + clsx: 2.1.1 + prop-types: 15.8.1 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + + react-grid-layout@2.2.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + dependencies: + clsx: 2.1.1 + fast-equals: 4.0.3 + prop-types: 15.8.1 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + react-draggable: 4.5.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + react-resizable: 3.1.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + resize-observer-polyfill: 1.5.1 + react-hook-form@7.71.2(react@19.2.3): dependencies: react: 19.2.3 @@ -8925,6 +8986,13 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + react-resizable@3.1.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + dependencies: + prop-types: 15.8.1 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + react-draggable: 4.5.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + react-smooth@4.0.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: fast-equals: 5.4.0 @@ -9009,6 +9077,8 @@ snapshots: require-main-filename@2.0.0: {} + resize-observer-polyfill@1.5.1: {} + resolve-from@4.0.0: {} resolve-pkg-maps@1.0.0: {} diff --git a/src/app/(dashboard)/fleet/page.tsx b/src/app/(dashboard)/fleet/page.tsx index e40fb7da..627abb24 100644 --- a/src/app/(dashboard)/fleet/page.tsx +++ b/src/app/(dashboard)/fleet/page.tsx @@ -1,5 +1,6 @@ "use client"; +import { useState } from "react"; import Link from "next/link"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useTRPC } from "@/trpc/client"; @@ -22,8 +23,14 @@ import { TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Tag, Wrench } from "lucide-react"; import { Skeleton } from "@/components/ui/skeleton"; -import { Wrench } from "lucide-react"; +import { ConfirmDialog } from "@/components/confirm-dialog"; import { DeploymentMatrix } from "@/components/fleet/deployment-matrix"; import { formatLastSeen } from "@/lib/format"; import { nodeStatusVariant, nodeStatusLabel } from "@/lib/status"; @@ -97,6 +104,11 @@ export default function FleetPage() { }), ); + const [maintenanceTarget, setMaintenanceTarget] = useState<{ + id: string; + name: string; + } | null>(null); + return (
{isLoading ? ( @@ -145,15 +157,34 @@ export default function FleetPage() { {node.environment.name} -
- {Object.entries( + {(() => { + const entries = Object.entries( (node.labels as Record) ?? {}, - ).map(([k, v]) => ( - - {k}={v} - - ))} -
+ ); + if (entries.length === 0) return null; + return ( + + + + + +
+ {entries.map(([k, v]) => ( + + {k}={v} + + ))} +
+
+
+ ); + })()}
{node.vectorVersion?.split(" ")[1] ?? "—"} @@ -206,14 +237,13 @@ export default function FleetPage() { onClick={(e) => { e.preventDefault(); if (!node.maintenanceMode) { - if (!confirm( - `Enter maintenance mode for "${node.name}"?\n\nThis will stop all running pipelines on this node. Pipelines will automatically resume when maintenance mode is turned off.` - )) return; + setMaintenanceTarget({ id: node.id, name: node.name }); + } else { + setMaintenance.mutate({ + nodeId: node.id, + enabled: false, + }); } - setMaintenance.mutate({ - nodeId: node.id, - enabled: !node.maintenanceMode, - }); }} > @@ -273,6 +303,30 @@ export default function FleetPage() {
)} + + { if (!open) setMaintenanceTarget(null); }} + title="Enter maintenance mode?" + description={ + <> + This will stop all running pipelines on "{maintenanceTarget?.name}". + Pipelines will automatically resume when maintenance mode is turned off. + + } + confirmLabel="Enter Maintenance" + variant="default" + isPending={setMaintenance.isPending} + pendingLabel="Entering..." + onConfirm={() => { + if (maintenanceTarget) { + setMaintenance.mutate( + { nodeId: maintenanceTarget.id, enabled: true }, + { onSuccess: () => setMaintenanceTarget(null) }, + ); + } + }} + /> ); } diff --git a/src/app/(dashboard)/pipelines/page.tsx b/src/app/(dashboard)/pipelines/page.tsx index c62f1a86..9fed8086 100644 --- a/src/app/(dashboard)/pipelines/page.tsx +++ b/src/app/(dashboard)/pipelines/page.tsx @@ -242,7 +242,7 @@ export default function PipelinesPage() { Name Status - Health + Health Events/sec In Bytes/sec In Reduction @@ -302,7 +302,7 @@ export default function PipelinesPage() { {/* Health */} - + {pipeline.isDraft ? ( -- ) : ( diff --git a/src/app/(dashboard)/profile/page.tsx b/src/app/(dashboard)/profile/page.tsx index 9f3c321a..8ee9e704 100644 --- a/src/app/(dashboard)/profile/page.tsx +++ b/src/app/(dashboard)/profile/page.tsx @@ -10,6 +10,7 @@ import { Loader2, AlertTriangle, Info } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { Badge } from "@/components/ui/badge"; import { Card, CardContent, @@ -18,6 +19,7 @@ import { CardTitle, } from "@/components/ui/card"; import { TotpSetupCard } from "@/components/totp-setup-card"; +import { useTeamStore } from "@/stores/team-store"; export default function ProfilePage() { const trpc = useTRPC(); @@ -25,6 +27,14 @@ export default function ProfilePage() { const { data: me } = useQuery(trpc.user.me.queryOptions()); const isLocalUser = me?.authMethod !== "OIDC"; + const selectedTeamId = useTeamStore((s) => s.selectedTeamId); + const roleQuery = useQuery( + trpc.team.teamRole.queryOptions( + { teamId: selectedTeamId! }, + { enabled: !!selectedTeamId }, + ), + ); + // --- Personal Info --- const [name, setName] = useState(""); const hasLoadedRef = useRef(false); @@ -138,6 +148,26 @@ export default function ProfilePage() { )} + {/* Role */} +
+ +
+ {roleQuery.data ? ( + <> + + {roleQuery.data.role} + + {roleQuery.data.isSuperAdmin && ( + + Super Admin + + )} + + ) : ( + + )} +
+
diff --git a/src/app/globals.css b/src/app/globals.css index d4d929ea..6fabe042 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,6 +1,7 @@ @import "tailwindcss"; @import "tw-animate-css"; @import "shadcn/tailwind.css"; +@import "react-grid-layout/css/styles.css"; @custom-variant dark (&:is(.dark *)); diff --git a/src/components/dashboard/custom-view.tsx b/src/components/dashboard/custom-view.tsx index 1f1c73ff..c9aa9059 100644 --- a/src/components/dashboard/custom-view.tsx +++ b/src/components/dashboard/custom-view.tsx @@ -1,7 +1,7 @@ "use client"; -import { useState, useMemo } from "react"; -import { useQuery } from "@tanstack/react-query"; +import { useState, useMemo, useCallback, useRef } from "react"; +import { useQuery, useMutation } from "@tanstack/react-query"; import { useTRPC } from "@/trpc/client"; import { Cpu, @@ -11,9 +11,17 @@ import { Server, Activity, BarChart3, + Lock, + Unlock, } from "lucide-react"; import { Card, CardContent } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; import { StatusBadge } from "@/components/ui/status-badge"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; import { useEnvironmentStore } from "@/stores/environment-store"; import { MetricsFilterBar, @@ -25,6 +33,13 @@ import { formatSI, formatBytesRate, formatEventsRate } from "@/lib/format"; import { cn } from "@/lib/utils"; import type { PanelId } from "@/components/dashboard/view-builder-dialog"; +import { + ResponsiveGridLayout, + useContainerWidth, + verticalCompactor, +} from "react-grid-layout"; +import type { LayoutItem, Layout } from "react-grid-layout"; + /** Derive an overall status for a pipeline from its node statuses */ function derivePipelineStatus( nodes: Array<{ pipelineStatus: string }> @@ -37,11 +52,53 @@ function derivePipelineStatus( return nodes[0].pipelineStatus; } +const SUMMARY_PANELS: PanelId[] = [ + "node-health-summary", + "pipeline-health-summary", + "data-reduction", +]; + +/** Generate a default layout for panels when none is saved */ +function generateDefaultLayout(panels: PanelId[]): LayoutItem[] { + const layout: LayoutItem[] = []; + const summaryPanels = panels.filter((p) => SUMMARY_PANELS.includes(p)); + const chartPanels = panels.filter((p) => !SUMMARY_PANELS.includes(p)); + + // Summary panels: 1 row, each 4 cols wide (12/3) + summaryPanels.forEach((p, i) => { + layout.push({ + i: p, + x: (i * 4) % 12, + y: 0, + w: 4, + h: 2, + minW: 3, + minH: 2, + }); + }); + + const chartStartY = summaryPanels.length > 0 ? 2 : 0; + // Chart panels: 2 columns, 6 cols wide each + chartPanels.forEach((p, i) => { + layout.push({ + i: p, + x: (i % 2) * 6, + y: chartStartY + Math.floor(i / 2) * 4, + w: 6, + h: 4, + minW: 4, + minH: 3, + }); + }); + + return layout; +} + interface DashboardViewData { id: string; name: string; panels: unknown; // Json field — string[] at runtime - filters: unknown; // Json field — { pipelineIds?: string[], nodeIds?: string[] } + filters: unknown; // Json field — { pipelineIds?, nodeIds?, layout? } } interface CustomViewProps { @@ -51,12 +108,14 @@ interface CustomViewProps { export function CustomView({ view }: CustomViewProps) { const trpc = useTRPC(); const { selectedEnvironmentId } = useEnvironmentStore(); + const { width, containerRef, mounted } = useContainerWidth(); // Parse view data const panels = (view.panels ?? []) as PanelId[]; const savedFilters = (view.filters ?? {}) as { pipelineIds?: string[]; nodeIds?: string[]; + layout?: LayoutItem[]; }; const [selectedNodeIds, setSelectedNodeIds] = useState( @@ -67,6 +126,65 @@ export function CustomView({ view }: CustomViewProps) { ); const [timeRange, setTimeRange] = useState("1h"); const [groupBy, setGroupBy] = useState("pipeline"); + const [layoutLocked, setLayoutLocked] = useState(true); + + // Layout state — use saved layout or generate a default + const defaultLayout = useMemo( + () => generateDefaultLayout(panels), + [panels] + ); + const [currentLayout, setCurrentLayout] = useState( + savedFilters.layout ?? defaultLayout + ); + + // Debounce layout save to avoid excessive mutations + const saveTimerRef = useRef | null>(null); + + const updateMutation = useMutation( + trpc.dashboard.updateView.mutationOptions() + ); + + const persistLayout = useCallback( + (layout: Layout) => { + if (!selectedEnvironmentId) return; + const cleanLayout = layout.map(({ i, x, y, w, h }) => ({ + i, + x, + y, + w, + h, + })); + if (saveTimerRef.current) clearTimeout(saveTimerRef.current); + saveTimerRef.current = setTimeout(() => { + updateMutation.mutate({ + environmentId: selectedEnvironmentId, + id: view.id, + filters: { + pipelineIds: selectedPipelineIds, + nodeIds: selectedNodeIds, + layout: cleanLayout, + }, + }); + }, 800); + }, + [ + selectedEnvironmentId, + view.id, + selectedPipelineIds, + selectedNodeIds, + updateMutation, + ] + ); + + const handleLayoutChange = useCallback( + (layout: Layout) => { + setCurrentLayout([...layout]); + if (!layoutLocked) { + persistLayout(layout); + } + }, + [layoutLocked, persistLayout] + ); const refreshInterval: Record = { "1h": 15_000, @@ -133,165 +251,144 @@ export function CustomView({ view }: CustomViewProps) { return { running, stopped, crashed }; }, [pipelineCards.data]); - // Show filter bar if we have any chart panels const showFilterBar = needsChartData; - return ( -
- {/* Filter bar (if chart panels are present) */} - {showFilterBar && ( - - )} - - {/* Summary panels (rendered at the top if present) */} - {(panels.includes("node-health-summary") || - panels.includes("pipeline-health-summary") || - panels.includes("data-reduction")) && ( -
- {panels.includes("node-health-summary") && ( - - -
-

- Node Health -

- -
-
- {stats.data?.fleet.healthy != null && - stats.data.fleet.healthy > 0 && ( - - {stats.data.fleet.healthy} Healthy - - )} - {stats.data?.fleet.degraded != null && - stats.data.fleet.degraded > 0 && ( - - {stats.data.fleet.degraded} Degraded - - )} - {stats.data?.fleet.unreachable != null && - stats.data.fleet.unreachable > 0 && ( - - {stats.data.fleet.unreachable} Unreachable - - )} - {stats.data && stats.data.nodes === 0 && ( - - No nodes - - )} -
-

- {stats.data?.nodes ?? 0}{" "} - - total - + /** Render a single panel by its ID */ + function renderPanel(panelId: PanelId) { + switch (panelId) { + case "node-health-summary": + return ( + + +

+

+ Node Health

- - - )} - - {panels.includes("pipeline-health-summary") && ( - - -
-

- Pipeline Health -

- -
-
- {pipelineStatusCounts.running > 0 && ( + +
+
+ {stats.data?.fleet.healthy != null && + stats.data.fleet.healthy > 0 && ( - {pipelineStatusCounts.running} Running + {stats.data.fleet.healthy} Healthy )} - {pipelineStatusCounts.stopped > 0 && ( - - {pipelineStatusCounts.stopped} Stopped + {stats.data?.fleet.degraded != null && + stats.data.fleet.degraded > 0 && ( + + {stats.data.fleet.degraded} Degraded )} - {pipelineStatusCounts.crashed > 0 && ( + {stats.data?.fleet.unreachable != null && + stats.data.fleet.unreachable > 0 && ( - {pipelineStatusCounts.crashed} Crashed + {stats.data.fleet.unreachable} Unreachable )} - {stats.data && stats.data.pipelines === 0 && ( - - No pipelines - - )} -
-

- {stats.data?.pipelines ?? 0}{" "} - - deployed + {stats.data && stats.data.nodes === 0 && ( + + No nodes + )} +

+

+ {stats.data?.nodes ?? 0}{" "} + + total + +

+
+
+ ); + + case "pipeline-health-summary": + return ( + + +
+

+ Pipeline Health

- - - )} - - {panels.includes("data-reduction") && ( - - -
-

- Data Reduction -

- -
- {stats.data?.reduction?.percent != null ? ( - <> -

50 - ? "text-green-600 dark:text-green-400" - : stats.data.reduction.percent > 10 - ? "text-amber-600 dark:text-amber-400" - : "text-muted-foreground" - )} - > - {stats.data.reduction.percent.toFixed(0)}% -

-

- {formatEventsRate(stats.data.reduction.eventsIn / 3600)}{" "} - -{"> "} - {formatEventsRate(stats.data.reduction.eventsOut / 3600)} -

- - ) : ( - <> -

- -- -

-

- No traffic data -

- + +
+
+ {pipelineStatusCounts.running > 0 && ( + + {pipelineStatusCounts.running} Running + + )} + {pipelineStatusCounts.stopped > 0 && ( + + {pipelineStatusCounts.stopped} Stopped + + )} + {pipelineStatusCounts.crashed > 0 && ( + + {pipelineStatusCounts.crashed} Crashed + + )} + {stats.data && stats.data.pipelines === 0 && ( + + No pipelines + )} - - - )} -
- )} - - {/* Chart panels in a 2-column responsive grid */} -
- {panels.includes("events-in-out") && ( +
+

+ {stats.data?.pipelines ?? 0}{" "} + + deployed + +

+
+
+ ); + + case "data-reduction": + return ( + + +
+

+ Data Reduction +

+ +
+ {stats.data?.reduction?.percent != null ? ( + <> +

50 + ? "text-green-600 dark:text-green-400" + : stats.data.reduction.percent > 10 + ? "text-amber-600 dark:text-amber-400" + : "text-muted-foreground" + )} + > + {stats.data.reduction.percent.toFixed(0)}% +

+

+ {formatEventsRate(stats.data.reduction.eventsIn / 3600)}{" "} + -{"> "} + {formatEventsRate(stats.data.reduction.eventsOut / 3600)} +

+ + ) : ( + <> +

+ -- +

+

+ No traffic data +

+ + )} +
+
+ ); + + case "events-in-out": + return ( - )} + ); - {panels.includes("bytes-in-out") && ( + case "bytes-in-out": + return ( formatBytesRate(v)} timeRange={timeRange} - height={250} + height="100%" /> - )} + ); - {panels.includes("error-rate") && ( + case "error-rate": + return ( - )} + ); - {panels.includes("cpu-usage") && ( + case "cpu-usage": + return ( } @@ -339,11 +439,12 @@ export function CustomView({ view }: CustomViewProps) { yFormatter={(v) => `${v.toFixed(0)}%`} yDomain={[0, 100]} timeRange={timeRange} - height={220} + height="100%" /> - )} + ); - {panels.includes("memory-usage") && ( + case "memory-usage": + return ( } @@ -351,11 +452,12 @@ export function CustomView({ view }: CustomViewProps) { yFormatter={(v) => `${v.toFixed(0)}%`} yDomain={[0, 100]} timeRange={timeRange} - height={220} + height="100%" /> - )} + ); - {panels.includes("disk-io") && ( + case "disk-io": + return ( } @@ -365,11 +467,12 @@ export function CustomView({ view }: CustomViewProps) { secondaryLabel=" Write" yFormatter={(v) => formatBytesRate(v)} timeRange={timeRange} - height={220} + height="100%" /> - )} + ); - {panels.includes("network-io") && ( + case "network-io": + return ( } @@ -379,8 +482,98 @@ export function CustomView({ view }: CustomViewProps) { secondaryLabel=" Tx" yFormatter={(v) => formatBytesRate(v)} timeRange={timeRange} - height={220} + height="100%" /> + ); + + default: + return null; + } + } + + return ( +
+ {/* Toolbar: filter bar + layout lock toggle */} +
+ {showFilterBar ? ( +
+ +
+ ) : ( +
+ )} + + + + + + {layoutLocked + ? "Unlock to drag, resize, and rearrange panels" + : "Lock layout to prevent accidental changes"} + + +
+ + {/* Grid layout */} +
+ {mounted && ( + + {panels.map((panelId) => ( +
+ {renderPanel(panelId)} +
+ ))} +
)}
diff --git a/src/components/dashboard/metric-chart.tsx b/src/components/dashboard/metric-chart.tsx index 2455d3db..050f6223 100644 --- a/src/components/dashboard/metric-chart.tsx +++ b/src/components/dashboard/metric-chart.tsx @@ -54,8 +54,8 @@ interface MetricChartProps { dataSecondary?: TSMap; /** "line" (default) or "area" */ variant?: "line" | "area"; - /** Chart height in pixels */ - height?: number; + /** Chart height in pixels or CSS value */ + height?: number | string; /** Y-axis formatter */ yFormatter?: (v: number) => string; /** Fixed Y-axis domain [min, max], or undefined for auto */ @@ -187,14 +187,14 @@ export function MetricChart({ : []; return ( - + {icon} {title} - + Date: Sat, 7 Mar 2026 17:21:10 +0000 Subject: [PATCH 02/29] fix: invalidate listViews cache on layout save and remove deprecated types stub - Add queryClient.invalidateQueries for dashboard.listViews on updateView mutation success so remounting loads the persisted layout - Remove @types/react-grid-layout (deprecated stub, v2 ships own types) --- package.json | 1 - pnpm-lock.yaml | 14 -------------- src/components/dashboard/custom-view.tsx | 11 +++++++++-- 3 files changed, 9 insertions(+), 17 deletions(-) diff --git a/package.json b/package.json index 8c05548b..2522b82b 100644 --- a/package.json +++ b/package.json @@ -72,7 +72,6 @@ "@types/nodemailer": "^7.0.11", "@types/react": "^19", "@types/react-dom": "^19", - "@types/react-grid-layout": "^2.1.0", "eslint": "^9", "eslint-config-next": "16.1.6", "prisma": "^7.4.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 42e28f91..5d77a2c2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -160,9 +160,6 @@ importers: '@types/react-dom': specifier: ^19 version: 19.2.3(@types/react@19.2.14) - '@types/react-grid-layout': - specifier: ^2.1.0 - version: 2.1.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) eslint: specifier: ^9 version: 9.39.3(jiti@2.6.1) @@ -1938,10 +1935,6 @@ packages: peerDependencies: '@types/react': ^19.2.0 - '@types/react-grid-layout@2.1.0': - resolution: {integrity: sha512-pHEjVg9ert6BDFHFQ1IEdLUkd2gasJvyti5lV2kE46N/R07ZiaSZpAXeXJAA1MXy/Qby23fZmiuEgZkITxPXug==} - deprecated: This is a stub types definition. react-grid-layout provides its own type definitions, so you do not need this installed. - '@types/react@19.2.14': resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} @@ -6596,13 +6589,6 @@ snapshots: dependencies: '@types/react': 19.2.14 - '@types/react-grid-layout@2.1.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': - dependencies: - react-grid-layout: 2.2.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - transitivePeerDependencies: - - react - - react-dom - '@types/react@19.2.14': dependencies: csstype: 3.2.3 diff --git a/src/components/dashboard/custom-view.tsx b/src/components/dashboard/custom-view.tsx index c9aa9059..e687ef55 100644 --- a/src/components/dashboard/custom-view.tsx +++ b/src/components/dashboard/custom-view.tsx @@ -1,7 +1,7 @@ "use client"; import { useState, useMemo, useCallback, useRef } from "react"; -import { useQuery, useMutation } from "@tanstack/react-query"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useTRPC } from "@/trpc/client"; import { Cpu, @@ -107,6 +107,7 @@ interface CustomViewProps { export function CustomView({ view }: CustomViewProps) { const trpc = useTRPC(); + const queryClient = useQueryClient(); const { selectedEnvironmentId } = useEnvironmentStore(); const { width, containerRef, mounted } = useContainerWidth(); @@ -141,7 +142,13 @@ export function CustomView({ view }: CustomViewProps) { const saveTimerRef = useRef | null>(null); const updateMutation = useMutation( - trpc.dashboard.updateView.mutationOptions() + trpc.dashboard.updateView.mutationOptions({ + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: [["dashboard", "listViews"]], + }); + }, + }) ); const persistLayout = useCallback( From 53665c9ef4f234fdc352258c7bffc2d4987ea887 Mon Sep 17 00:00:00 2001 From: TerrifiedBug Date: Sat, 7 Mar 2026 17:37:00 +0000 Subject: [PATCH 03/29] fix: resolve stale closure and missing cleanup in layout debounce - Use a ref (filtersRef) to hold latest filter values so the debounce timer reads current pipelineIds/nodeIds when it fires, not the values captured at scheduling time - Clear pending debounce timer on component unmount to prevent stale mutations after navigation --- src/components/dashboard/custom-view.tsx | 26 +++++++++++++++--------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/src/components/dashboard/custom-view.tsx b/src/components/dashboard/custom-view.tsx index e687ef55..4f3c0840 100644 --- a/src/components/dashboard/custom-view.tsx +++ b/src/components/dashboard/custom-view.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useMemo, useCallback, useRef } from "react"; +import { useState, useMemo, useCallback, useRef, useEffect } from "react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useTRPC } from "@/trpc/client"; import { @@ -138,9 +138,20 @@ export function CustomView({ view }: CustomViewProps) { savedFilters.layout ?? defaultLayout ); + // Refs for latest filter values so the debounce timer never captures stale state + const filtersRef = useRef({ pipelineIds: selectedPipelineIds, nodeIds: selectedNodeIds }); + filtersRef.current = { pipelineIds: selectedPipelineIds, nodeIds: selectedNodeIds }; + // Debounce layout save to avoid excessive mutations const saveTimerRef = useRef | null>(null); + // Clear pending timer on unmount + useEffect(() => { + return () => { + if (saveTimerRef.current) clearTimeout(saveTimerRef.current); + }; + }, []); + const updateMutation = useMutation( trpc.dashboard.updateView.mutationOptions({ onSuccess: () => { @@ -163,24 +174,19 @@ export function CustomView({ view }: CustomViewProps) { })); if (saveTimerRef.current) clearTimeout(saveTimerRef.current); saveTimerRef.current = setTimeout(() => { + const { pipelineIds, nodeIds } = filtersRef.current; updateMutation.mutate({ environmentId: selectedEnvironmentId, id: view.id, filters: { - pipelineIds: selectedPipelineIds, - nodeIds: selectedNodeIds, + pipelineIds, + nodeIds, layout: cleanLayout, }, }); }, 800); }, - [ - selectedEnvironmentId, - view.id, - selectedPipelineIds, - selectedNodeIds, - updateMutation, - ] + [selectedEnvironmentId, view.id, updateMutation] ); const handleLayoutChange = useCallback( From 7d1919a99060d5756400bb1987a7e45d8a8e8772 Mon Sep 17 00:00:00 2001 From: TerrifiedBug Date: Sat, 7 Mar 2026 17:39:43 +0000 Subject: [PATCH 04/29] fix: move ref update into useEffect and memoize panels cast - Move filtersRef.current assignment into a useEffect to satisfy react-hooks/refs lint rule (no ref writes during render) - Wrap panels cast in useMemo to stabilize the dependency for downstream useMemo hooks --- src/components/dashboard/custom-view.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/components/dashboard/custom-view.tsx b/src/components/dashboard/custom-view.tsx index 4f3c0840..d55008de 100644 --- a/src/components/dashboard/custom-view.tsx +++ b/src/components/dashboard/custom-view.tsx @@ -112,7 +112,7 @@ export function CustomView({ view }: CustomViewProps) { const { width, containerRef, mounted } = useContainerWidth(); // Parse view data - const panels = (view.panels ?? []) as PanelId[]; + const panels = useMemo(() => (view.panels ?? []) as PanelId[], [view.panels]); const savedFilters = (view.filters ?? {}) as { pipelineIds?: string[]; nodeIds?: string[]; @@ -140,7 +140,9 @@ export function CustomView({ view }: CustomViewProps) { // Refs for latest filter values so the debounce timer never captures stale state const filtersRef = useRef({ pipelineIds: selectedPipelineIds, nodeIds: selectedNodeIds }); - filtersRef.current = { pipelineIds: selectedPipelineIds, nodeIds: selectedNodeIds }; + useEffect(() => { + filtersRef.current = { pipelineIds: selectedPipelineIds, nodeIds: selectedNodeIds }; + }, [selectedPipelineIds, selectedNodeIds]); // Debounce layout save to avoid excessive mutations const saveTimerRef = useRef | null>(null); From d3082dfc130beb8529ac7ab2affc0218b148cb0f Mon Sep 17 00:00:00 2001 From: TerrifiedBug Date: Sat, 7 Mar 2026 17:49:29 +0000 Subject: [PATCH 05/29] feat: move service accounts to dedicated settings tab Replace the standalone "Service Accounts & API Keys" button above the settings tabs with an inline "API Keys" tab visible to team admins. Extract ServiceAccountsSettings as a named export so it can be rendered both as a tab and as the existing standalone page route. --- src/app/(dashboard)/settings/page.tsx | 22 ++++++----- .../settings/service-accounts/page.tsx | 38 ++++++++++++------- 2 files changed, 36 insertions(+), 24 deletions(-) diff --git a/src/app/(dashboard)/settings/page.tsx b/src/app/(dashboard)/settings/page.tsx index b0d96544..985be5ba 100644 --- a/src/app/(dashboard)/settings/page.tsx +++ b/src/app/(dashboard)/settings/page.tsx @@ -77,6 +77,7 @@ import { PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; +import { ServiceAccountsSettings } from "@/app/(dashboard)/settings/service-accounts/page"; // ─── Relative Time Helper ─────────────────────────────────────────────────────── @@ -3278,16 +3279,6 @@ export default function SettingsPage() { return (
- {isTeamAdmin && ( -
- - - -
- )} {isTeamAdmin && ( @@ -3296,6 +3287,12 @@ export default function SettingsPage() { Team )} + {isTeamAdmin && ( + + + API Keys + + )} {isSuperAdmin && ( <> @@ -3339,6 +3336,11 @@ export default function SettingsPage() { )} + {isTeamAdmin && ( + + + + )} {isSuperAdmin && ( <> diff --git a/src/app/(dashboard)/settings/service-accounts/page.tsx b/src/app/(dashboard)/settings/service-accounts/page.tsx index c9549979..330871a3 100644 --- a/src/app/(dashboard)/settings/service-accounts/page.tsx +++ b/src/app/(dashboard)/settings/service-accounts/page.tsx @@ -127,7 +127,7 @@ type PermissionValue = (typeof PERMISSION_GROUPS)[number]["permissions"][number] // ─── Main Page ────────────────────────────────────────────────────────────────── -export default function ServiceAccountsPage() { +export function ServiceAccountsSettings() { const trpc = useTRPC(); const queryClient = useQueryClient(); const { selectedTeamId } = useTeamStore(); @@ -255,20 +255,12 @@ export default function ServiceAccountsPage() { const isLoading = serviceAccountsQuery.isLoading || environmentsQuery.isLoading; return ( -
+
{/* Header */} -
- - - -
-

Service Accounts

-

- Manage API keys for programmatic access to the REST API -

-
+
+

+ Manage API keys for programmatic access to the REST API +

); } + +// ─── Page Wrapper ──────────────────────────────────────────────────────────────── + +export default function ServiceAccountsPage() { + return ( +
+
+ + + +

Service Accounts

+
+ +
+ ); +} From 3fa3341d899a8a89f89394da1ed40413e5595030 Mon Sep 17 00:00:00 2001 From: TerrifiedBug Date: Sun, 8 Mar 2026 11:38:04 +0000 Subject: [PATCH 06/29] chore: add diagnostic logging for OIDC group reconciliation Temporary debug logs at each decision point in the reconciliation chain: - auth.ts: log final group list and scimEnabled flag - group-mappings.ts: log loaded mappings, desired state, existing members --- src/auth.ts | 1 + src/server/services/group-mappings.ts | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/src/auth.ts b/src/auth.ts index df643464..06f35a76 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -271,6 +271,7 @@ async function getAuthInstance() { userGroupNames = tokenGroups; } + console.log(`[oidc] User ${user.email} scimEnabled=${settings.scimEnabled}, final groups:`, userGroupNames); const { reconcileUserTeamMemberships } = await import("@/server/services/group-mappings"); await prisma.$transaction(async (tx) => { await reconcileUserTeamMemberships(tx, dbUser.id, userGroupNames); diff --git a/src/server/services/group-mappings.ts b/src/server/services/group-mappings.ts index e358fc1b..d5ba8284 100644 --- a/src/server/services/group-mappings.ts +++ b/src/server/services/group-mappings.ts @@ -56,6 +56,7 @@ export async function reconcileUserTeamMemberships( userGroupNames: string[], ): Promise { const allMappings = await loadGroupMappings(); + console.log(`[reconcile] userId=${userId}, userGroups=${JSON.stringify(userGroupNames)}, mappings=${JSON.stringify(allMappings)}`); // Compute desired state: for each team, the highest role from any matching group const desiredTeamRoles = new Map(); @@ -69,10 +70,13 @@ export async function reconcileUserTeamMemberships( } } + console.log(`[reconcile] desiredTeamRoles=${JSON.stringify([...desiredTeamRoles.entries()])}`); + // Fetch current group_mapping TeamMembers for this user const existing = await tx.teamMember.findMany({ where: { userId, source: "group_mapping" }, }); + console.log(`[reconcile] existing group_mapping members=${JSON.stringify(existing.map(m => ({ teamId: m.teamId, role: m.role })))}`); const existingByTeam = new Map(existing.map((m) => [m.teamId, m])); From b7375f155856db4a26de025286887ad0e87c8b8a Mon Sep 17 00:00:00 2001 From: TerrifiedBug Date: Sun, 8 Mar 2026 12:02:35 +0000 Subject: [PATCH 07/29] feat: add debug logger gated behind VF_LOG_LEVEL env var Replace raw console.log debug statements with a shared debugLog() helper that only outputs when VF_LOG_LEVEL or LOG_LEVEL is set to debug or trace. Applies to OIDC group sync, SCIM reconciliation, agent enrollment, and system Vector process logs. Operational and error logs remain unchanged. --- src/app/api/agent/enroll/route.ts | 7 ++++--- src/auth.ts | 5 +++-- src/lib/logger.ts | 11 +++++++++++ src/server/services/group-mappings.ts | 7 ++++--- src/server/services/system-vector.ts | 7 +++---- 5 files changed, 25 insertions(+), 12 deletions(-) create mode 100644 src/lib/logger.ts diff --git a/src/app/api/agent/enroll/route.ts b/src/app/api/agent/enroll/route.ts index d2e2ab92..c0bd9ac0 100644 --- a/src/app/api/agent/enroll/route.ts +++ b/src/app/api/agent/enroll/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from "next/server"; import { z } from "zod"; import { prisma } from "@/lib/prisma"; import { verifyEnrollmentToken, generateNodeToken } from "@/server/services/agent-token"; +import { debugLog } from "@/lib/logger"; const enrollSchema = z.object({ token: z.string().min(1), @@ -26,7 +27,7 @@ export async function POST(request: Request) { const { token, hostname, os, agentVersion, vectorVersion } = parsed.data; const safeHostname = hostname.replace(/[\r\n\t"]/g, " "); const safeVersion = (agentVersion ?? "unknown").replace(/[\r\n\t"]/g, " "); - console.log(`[enroll] attempt from hostname="${safeHostname}" agentVersion="${safeVersion}"`); + debugLog("enroll", `attempt from hostname="${safeHostname}" agentVersion="${safeVersion}"`); // Find all environments that have an enrollment token const environments = await prisma.environment.findMany({ @@ -41,7 +42,7 @@ export async function POST(request: Request) { team: { select: { id: true } }, }, }); - console.log(`[enroll] found ${environments.length} candidate environment(s)`); + debugLog("enroll", `found ${environments.length} candidate environment(s)`); // Try each environment's enrollment token let matchedEnv: (typeof environments)[0] | null = null; @@ -79,7 +80,7 @@ export async function POST(request: Request) { metadata: { enrolledVia: "agent" }, }, }); - console.log(`[enroll] SUCCESS -- node ${node.id} enrolled in "${matchedEnv.name}"`); + debugLog("enroll", `SUCCESS -- node ${node.id} enrolled in "${matchedEnv.name}"`); return NextResponse.json({ nodeId: node.id, diff --git a/src/auth.ts b/src/auth.ts index 06f35a76..bf88d8a6 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -8,6 +8,7 @@ import { encrypt, decrypt } from "@/server/services/crypto"; import { verifyTotpCode, verifyBackupCode } from "@/server/services/totp"; import { authConfig } from "@/auth.config"; import { writeAuditLog } from "@/server/services/audit"; +import { debugLog } from "@/lib/logger"; import { headers } from "next/headers"; async function getClientIp(): Promise { @@ -253,7 +254,7 @@ async function getAuthInstance() { if (settings?.oidcGroupSyncEnabled) { const groupsClaim = settings.oidcGroupsClaim ?? "groups"; const tokenGroups = (profileData?.[groupsClaim] as string[] | undefined) ?? []; - console.log(`[oidc] User ${user.email} groups (claim "${groupsClaim}"):`, tokenGroups); + debugLog("oidc", `User ${user.email} groups (claim "${groupsClaim}"):`, tokenGroups); let userGroupNames: string[]; @@ -271,7 +272,7 @@ async function getAuthInstance() { userGroupNames = tokenGroups; } - console.log(`[oidc] User ${user.email} scimEnabled=${settings.scimEnabled}, final groups:`, userGroupNames); + debugLog("oidc", `User ${user.email} scimEnabled=${settings.scimEnabled}, final groups:`, userGroupNames); const { reconcileUserTeamMemberships } = await import("@/server/services/group-mappings"); await prisma.$transaction(async (tx) => { await reconcileUserTeamMemberships(tx, dbUser.id, userGroupNames); diff --git a/src/lib/logger.ts b/src/lib/logger.ts new file mode 100644 index 00000000..ff276202 --- /dev/null +++ b/src/lib/logger.ts @@ -0,0 +1,11 @@ +const level = (process.env.VF_LOG_LEVEL ?? process.env.LOG_LEVEL ?? "info").toLowerCase(); +const isDebug = level === "debug" || level === "trace"; + +export function debugLog(tag: string, message: string, data?: unknown): void { + if (!isDebug) return; + if (data !== undefined) { + console.log(`[${tag}] ${message}`, data); + } else { + console.log(`[${tag}] ${message}`); + } +} diff --git a/src/server/services/group-mappings.ts b/src/server/services/group-mappings.ts index d5ba8284..ac7e7df0 100644 --- a/src/server/services/group-mappings.ts +++ b/src/server/services/group-mappings.ts @@ -1,4 +1,5 @@ import { prisma } from "@/lib/prisma"; +import { debugLog } from "@/lib/logger"; export interface GroupMapping { group: string; @@ -56,7 +57,7 @@ export async function reconcileUserTeamMemberships( userGroupNames: string[], ): Promise { const allMappings = await loadGroupMappings(); - console.log(`[reconcile] userId=${userId}, userGroups=${JSON.stringify(userGroupNames)}, mappings=${JSON.stringify(allMappings)}`); + debugLog("reconcile", `userId=${userId}, userGroups=${JSON.stringify(userGroupNames)}, mappings=${JSON.stringify(allMappings)}`); // Compute desired state: for each team, the highest role from any matching group const desiredTeamRoles = new Map(); @@ -70,13 +71,13 @@ export async function reconcileUserTeamMemberships( } } - console.log(`[reconcile] desiredTeamRoles=${JSON.stringify([...desiredTeamRoles.entries()])}`); + debugLog("reconcile", `desiredTeamRoles=${JSON.stringify([...desiredTeamRoles.entries()])}`); // Fetch current group_mapping TeamMembers for this user const existing = await tx.teamMember.findMany({ where: { userId, source: "group_mapping" }, }); - console.log(`[reconcile] existing group_mapping members=${JSON.stringify(existing.map(m => ({ teamId: m.teamId, role: m.role })))}`); + debugLog("reconcile", `existing group_mapping members=${JSON.stringify(existing.map(m => ({ teamId: m.teamId, role: m.role })))}`); const existingByTeam = new Map(existing.map((m) => [m.teamId, m])); diff --git a/src/server/services/system-vector.ts b/src/server/services/system-vector.ts index dcbcac45..2afb83f0 100644 --- a/src/server/services/system-vector.ts +++ b/src/server/services/system-vector.ts @@ -3,6 +3,7 @@ import { writeFile, mkdir } from "fs/promises"; import { dirname, join } from "path"; import yaml from "js-yaml"; import { AUDIT_LOG_PATH } from "@/server/services/audit"; +import { debugLog } from "@/lib/logger"; const VECTOR_BIN = process.env.VF_VECTOR_BIN ?? "vector"; const VECTORFLOW_DATA_DIR = join(process.cwd(), ".vectorflow"); @@ -49,7 +50,7 @@ export async function startSystemVector(configYaml: string): Promise { vectorProcess = proc; proc.stdout?.on("data", (data: Buffer) => { - console.log(`[system-vector stdout] ${data.toString().trimEnd()}`); + debugLog("system-vector", data.toString().trimEnd()); }); proc.stderr?.on("data", (data: Buffer) => { @@ -57,9 +58,7 @@ export async function startSystemVector(configYaml: string): Promise { }); proc.on("exit", (code, signal) => { - console.log( - `System Vector process exited with code ${code}, signal ${signal}`, - ); + debugLog("system-vector", `process exited with code ${code}, signal ${signal}`); // Only nullify if this is still the current process (not replaced by a restart) if (vectorProcess === proc) { vectorProcess = null; From d134798a42a4c13481fa7c6972368537bb022d0f Mon Sep 17 00:00:00 2001 From: TerrifiedBug Date: Sun, 8 Mar 2026 12:05:25 +0000 Subject: [PATCH 08/29] ui: adjust padding on dashboard --- src/app/(dashboard)/layout.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/(dashboard)/layout.tsx b/src/app/(dashboard)/layout.tsx index 3aab0dff..ef0eea75 100644 --- a/src/app/(dashboard)/layout.tsx +++ b/src/app/(dashboard)/layout.tsx @@ -113,7 +113,7 @@ export default function DashboardLayout({ -
+
{children} From b5e14a12b508ad4e08af7c6a327f2293c9bf66e9 Mon Sep 17 00:00:00 2001 From: TerrifiedBug Date: Sun, 8 Mar 2026 12:08:21 +0000 Subject: [PATCH 09/29] ui: move view create button to the far right --- src/app/(dashboard)/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/(dashboard)/page.tsx b/src/app/(dashboard)/page.tsx index 600854e2..dd0dc0dc 100644 --- a/src/app/(dashboard)/page.tsx +++ b/src/app/(dashboard)/page.tsx @@ -201,7 +201,7 @@ export default function DashboardPage() { - {isDeployed ? ( - - ) : ( - - )} + /> + + {isToggling ? (isDeployed ? "Disabling..." : "Enabling...") : (isDeployed ? "Active" : "Disabled")} + +
) : (
From 490c24c89484f1a32bbad8aa7ed1774ec18ecc0f Mon Sep 17 00:00:00 2001 From: TerrifiedBug Date: Sun, 8 Mar 2026 12:10:58 +0000 Subject: [PATCH 11/29] feat: abbreviate large error and discarded counts with K/M suffixes --- src/components/metrics/summary-cards.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/components/metrics/summary-cards.tsx b/src/components/metrics/summary-cards.tsx index d23b55f6..f97f15af 100644 --- a/src/components/metrics/summary-cards.tsx +++ b/src/components/metrics/summary-cards.tsx @@ -31,6 +31,12 @@ function formatBytes(perMin: number): string { return `${Math.round(perSec)} B/s`; } +function formatCount(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`; + return String(n); +} + export function SummaryCards({ rows }: SummaryCardsProps) { // Use the latest row for "current" rates const latest = rows.length > 0 ? rows[rows.length - 1] : null; @@ -63,13 +69,13 @@ export function SummaryCards({ rows }: SummaryCardsProps) {

Errors

-

{errorsTotal}

+

{formatCount(errorsTotal)}

Discarded

-

{discardedTotal}

+

{formatCount(discardedTotal)}

From ae4fdd05a6fe65abb31070ac137e28c44f2ef362 Mon Sep 17 00:00:00 2001 From: TerrifiedBug Date: Sun, 8 Mar 2026 12:11:04 +0000 Subject: [PATCH 12/29] fix: replace jargon 'pp' with clear '% vs last period' with tooltip --- src/app/(dashboard)/analytics/page.tsx | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/src/app/(dashboard)/analytics/page.tsx b/src/app/(dashboard)/analytics/page.tsx index b1fc39c4..80d4bba8 100644 --- a/src/app/(dashboard)/analytics/page.tsx +++ b/src/app/(dashboard)/analytics/page.tsx @@ -3,7 +3,7 @@ import { useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { useTRPC } from "@/trpc/client"; -import { ArrowUp, ArrowDown, Minus, BarChart3 } from "lucide-react"; +import { ArrowUp, ArrowDown, Minus, BarChart3, Info } from "lucide-react"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Table, @@ -23,6 +23,7 @@ import { AreaChart, Area, XAxis, YAxis, CartesianGrid } from "recharts"; import { useEnvironmentStore } from "@/stores/environment-store"; import { formatBytes, formatTimeAxis } from "@/lib/format"; import { cn } from "@/lib/utils"; +import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"; type VolumeRange = "1h" | "6h" | "1d" | "7d" | "30d"; @@ -227,10 +228,17 @@ export default function AnalyticsPage() { {reductionPercent != null ? `${reductionPercent.toFixed(1)}%` : "--"}

{reductionDelta != null && ( -

- {reductionDelta >= 0 ? "+" : ""} - {reductionDelta.toFixed(1)} pp vs previous period -

+ + +

+ {reductionDelta >= 0 ? "+" : ""} + {reductionDelta.toFixed(1)}% vs last period +

+
+ + Change in reduction percentage compared to previous period + +
)} From ccf8afea19fbc2f499b5004d666e946087bcfa90 Mon Sep 17 00:00:00 2001 From: TerrifiedBug Date: Sun, 8 Mar 2026 12:11:09 +0000 Subject: [PATCH 13/29] fix: prevent version history actions column from clipping on small screens --- src/components/pipeline/version-history-dialog.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/pipeline/version-history-dialog.tsx b/src/components/pipeline/version-history-dialog.tsx index 17b780af..890acaa4 100644 --- a/src/components/pipeline/version-history-dialog.tsx +++ b/src/components/pipeline/version-history-dialog.tsx @@ -144,7 +144,7 @@ export function VersionHistoryDialog({ Version - Changelog + Changelog Created Actions @@ -171,8 +171,8 @@ export function VersionHistoryDialog({ )}
- - + + {version.changelog || "No changelog"} From 96909d2ed059135fd747ba58d02068a76c4ee076 Mon Sep 17 00:00:00 2001 From: TerrifiedBug Date: Sun, 8 Mar 2026 12:11:16 +0000 Subject: [PATCH 14/29] feat: replace SLI health dot with informative badge showing breach count --- src/components/flow/flow-toolbar.tsx | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/src/components/flow/flow-toolbar.tsx b/src/components/flow/flow-toolbar.tsx index 085048a5..394d91c8 100644 --- a/src/components/flow/flow-toolbar.tsx +++ b/src/components/flow/flow-toolbar.tsx @@ -128,6 +128,8 @@ export function FlowToolbar({ ), ); const healthStatus = healthQuery.data?.status ?? null; + const sliTotal = healthQuery.data?.slis?.length ?? 0; + const slisBreached = healthQuery.data?.slis?.filter((s: { status: string }) => s.status === "breached").length ?? 0; // Query pending deploy requests for this pipeline const pendingRequestsQuery = useQuery({ @@ -466,22 +468,18 @@ export function FlowToolbar({ {processStatus === "CRASHED" && "Crashed"} {processStatus === "PENDING" && "Pending..."} - {/* Health SLI indicator dot */} + {/* Health SLI badge */} {healthStatus === "healthy" && ( - - - - - All SLIs met - + + + SLIs: OK + )} {healthStatus === "degraded" && ( - - - - - One or more SLIs breached - + + + SLIs: {slisBreached}/{sliTotal} breached + )}
)} From c03754ecc5440c5d812ad449f34eed413332d429 Mon Sep 17 00:00:00 2001 From: TerrifiedBug Date: Sun, 8 Mar 2026 12:11:23 +0000 Subject: [PATCH 15/29] feat: guard manual team assignment for IdP-managed users across all routes --- src/server/routers/admin.ts | 3 +++ src/server/routers/team.ts | 39 ++++++++++++++++++++++++++++++------- 2 files changed, 35 insertions(+), 7 deletions(-) diff --git a/src/server/routers/admin.ts b/src/server/routers/admin.ts index 5056bbcb..c7a8c8c8 100644 --- a/src/server/routers/admin.ts +++ b/src/server/routers/admin.ts @@ -6,6 +6,7 @@ import { router, protectedProcedure, requireSuperAdmin } from "@/trpc/init"; import { prisma } from "@/lib/prisma"; import { withAudit } from "@/server/middleware/audit"; import { writeAuditLog } from "@/server/services/audit"; +import { assertManualAssignmentAllowed } from "@/server/routers/team"; export const adminRouter = router({ /** List all platform users with their team memberships */ @@ -44,6 +45,8 @@ export const adminRouter = router({ role: z.enum(["VIEWER", "EDITOR", "ADMIN"]), })) .mutation(async ({ input }) => { + await assertManualAssignmentAllowed(input.userId); + const existing = await prisma.teamMember.findUnique({ where: { userId_teamId: { userId: input.userId, teamId: input.teamId } }, }); diff --git a/src/server/routers/team.ts b/src/server/routers/team.ts index e98294f9..f70c441d 100644 --- a/src/server/routers/team.ts +++ b/src/server/routers/team.ts @@ -6,6 +6,35 @@ import bcrypt from "bcryptjs"; import crypto from "crypto"; import { withAudit } from "@/server/middleware/audit"; +/** + * Block manual team assignment/role changes for OIDC users when their + * memberships are managed by an identity provider (SCIM or OIDC group sync). + * Flat SSO deployments (OIDC without group sync) allow manual assignment. + */ +export async function assertManualAssignmentAllowed(userId: string): Promise { + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { authMethod: true }, + }); + if (!user) { + throw new TRPCError({ code: "NOT_FOUND", message: "User not found" }); + } + if (user.authMethod !== "OIDC") return; + + const settings = await prisma.systemSettings.findUnique({ + where: { id: "singleton" }, + select: { scimEnabled: true, oidcGroupSyncEnabled: true }, + }); + if (settings?.scimEnabled || settings?.oidcGroupSyncEnabled) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: + "This user's team membership is managed by your identity provider. " + + "Update their group assignments in your IdP instead.", + }); + } +} + export const teamRouter = router({ /** Get the current user's highest role across all teams */ myRole: protectedProcedure.query(async ({ ctx }) => { @@ -169,6 +198,8 @@ export const teamRouter = router({ }); } + await assertManualAssignmentAllowed(user.id); + const existing = await prisma.teamMember.findUnique({ where: { userId_teamId: { userId: user.id, teamId: input.teamId } }, }); @@ -228,7 +259,6 @@ export const teamRouter = router({ .mutation(async ({ input }) => { const member = await prisma.teamMember.findUnique({ where: { userId_teamId: { userId: input.userId, teamId: input.teamId } }, - include: { user: { select: { authMethod: true, scimExternalId: true } } }, }); if (!member) { throw new TRPCError({ @@ -236,12 +266,7 @@ export const teamRouter = router({ message: "Team member not found", }); } - if (member.user.authMethod === "OIDC" || member.user.scimExternalId) { - throw new TRPCError({ - code: "FORBIDDEN", - message: "Role is managed by identity provider and cannot be changed manually", - }); - } + await assertManualAssignmentAllowed(input.userId); return prisma.teamMember.update({ where: { id: member.id }, data: { role: input.role }, From df11cd7152cc88423741282b1f4344287d677a42 Mon Sep 17 00:00:00 2001 From: TerrifiedBug Date: Sun, 8 Mar 2026 12:48:19 +0000 Subject: [PATCH 16/29] feat: split analytics reduction into Events Reduced and Bytes Saved --- src/app/(dashboard)/analytics/page.tsx | 115 +++++++++++++++++++------ 1 file changed, 90 insertions(+), 25 deletions(-) diff --git a/src/app/(dashboard)/analytics/page.tsx b/src/app/(dashboard)/analytics/page.tsx index 80d4bba8..2f6b0760 100644 --- a/src/app/(dashboard)/analytics/page.tsx +++ b/src/app/(dashboard)/analytics/page.tsx @@ -42,9 +42,10 @@ interface PipelineRow { eventsIn: number; eventsOut: number; reduction: number; + eventsReduced: number; } -type SortKey = "pipelineName" | "bytesIn" | "bytesOut" | "reduction"; +type SortKey = "pipelineName" | "bytesIn" | "bytesOut" | "reduction" | "eventsReduced"; type SortDir = "asc" | "desc"; export default function AnalyticsPage() { @@ -77,6 +78,23 @@ export default function AnalyticsPage() { ? reductionPercent - prevReductionPercent : null; + // Event-based reduction (matches pipelines table formula, clamped at 0%) + const totalEventsIn = Number(data?.current._sum.eventsIn ?? 0); + const totalEventsOut = Number(data?.current._sum.eventsOut ?? 0); + const eventsReducedPercent = totalEventsIn > 0 ? Math.max(0, (1 - totalEventsOut / totalEventsIn) * 100) : null; + + const prevEventsIn = Number(data?.previous._sum.eventsIn ?? 0); + const prevEventsOut = Number(data?.previous._sum.eventsOut ?? 0); + const prevEventsReducedPercent = prevEventsIn > 0 ? Math.max(0, (1 - prevEventsOut / prevEventsIn) * 100) : null; + const eventsReducedDelta = + eventsReducedPercent != null && prevEventsReducedPercent != null + ? eventsReducedPercent - prevEventsReducedPercent + : null; + + // Rename bytes vars for clarity + const bytesSavedPercent = reductionPercent; + const bytesSavedDelta = reductionDelta; + const bytesInTrend = trendPercent(totalBytesIn, prevBytesIn); const bytesOutTrend = trendPercent(totalBytesOut, prevBytesOut); @@ -97,9 +115,10 @@ export default function AnalyticsPage() { // Per-pipeline table with sorting const sortedPipelines = (() => { if (!data?.perPipeline) return []; - const rows: PipelineRow[] = data.perPipeline.map((p: Omit) => ({ + const rows: PipelineRow[] = data.perPipeline.map((p: Omit) => ({ ...p, reduction: p.bytesIn > 0 ? (1 - p.bytesOut / p.bytesIn) * 100 : 0, + eventsReduced: p.eventsIn > 0 ? Math.max(0, (1 - p.eventsOut / p.eventsIn) * 100) : 0, })); return rows.sort((a: PipelineRow, b: PipelineRow) => { const aVal = a[sortKey]; @@ -167,7 +186,7 @@ export default function AnalyticsPage() {
{/* KPI Cards */} -
+
{/* Total In */} @@ -206,39 +225,59 @@ export default function AnalyticsPage() { - {/* Reduction % */} + {/* Events Reduced */}
-

Reduction

- +

Events Reduced

+

= 50 + eventsReducedPercent != null && eventsReducedPercent > 50 ? "text-green-600 dark:text-green-400" - : reductionPercent != null && reductionPercent >= 20 + : eventsReducedPercent != null && eventsReducedPercent > 10 ? "text-amber-600 dark:text-amber-400" - : reductionPercent != null - ? "text-red-600 dark:text-red-400" - : "text-muted-foreground", + : "text-muted-foreground", )} > - {reductionPercent != null ? `${reductionPercent.toFixed(1)}%` : "--"} + {eventsReducedPercent != null ? `${eventsReducedPercent.toFixed(1)}%` : "--"} +

+ {eventsReducedDelta != null && ( +

+ {eventsReducedDelta >= 0 ? "+" : ""} + {eventsReducedDelta.toFixed(1)}% vs last period +

+ )} +
+
+ + {/* Bytes Saved */} + + +
+
+

Bytes Saved

+ + + + + + Total bytes saved including sink compression and encoding + + +
+ +
+

+ {bytesSavedPercent != null ? `${bytesSavedPercent.toFixed(1)}%` : "--"}

- {reductionDelta != null && ( - - -

- {reductionDelta >= 0 ? "+" : ""} - {reductionDelta.toFixed(1)}% vs last period -

-
- - Change in reduction percentage compared to previous period - -
+ {bytesSavedDelta != null && ( +

+ {bytesSavedDelta >= 0 ? "+" : ""} + {bytesSavedDelta.toFixed(1)}% vs last period +

)}
@@ -357,11 +396,17 @@ export default function AnalyticsPage() { > Bytes Out{sortIndicator("bytesOut")} + toggleSort("eventsReduced")} + > + Events Reduced{sortIndicator("eventsReduced")} + toggleSort("reduction")} > - Reduction %{sortIndicator("reduction")} + Bytes Saved{sortIndicator("reduction")} @@ -375,6 +420,26 @@ export default function AnalyticsPage() { {formatBytes(p.bytesOut)} + +
+
+
50 + ? "bg-green-500" + : p.eventsReduced > 10 + ? "bg-amber-500" + : "bg-muted-foreground/30", + )} + style={{ width: `${Math.max(0, Math.min(100, p.eventsReduced))}%` }} + /> +
+ + {p.eventsReduced.toFixed(1)}% + +
+
From 5cb64e779f34f6d502068be54cb20ace63640c8f Mon Sep 17 00:00:00 2001 From: TerrifiedBug Date: Sun, 8 Mar 2026 12:55:34 +0000 Subject: [PATCH 17/29] feat: add nodesSnapshot/edgesSnapshot to PipelineVersion schema --- .../migration.sql | 3 +++ prisma/schema.prisma | 2 ++ 2 files changed, 5 insertions(+) create mode 100644 prisma/migrations/20260308000000_add_pipeline_version_snapshots/migration.sql diff --git a/prisma/migrations/20260308000000_add_pipeline_version_snapshots/migration.sql b/prisma/migrations/20260308000000_add_pipeline_version_snapshots/migration.sql new file mode 100644 index 00000000..96a87ec8 --- /dev/null +++ b/prisma/migrations/20260308000000_add_pipeline_version_snapshots/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "PipelineVersion" ADD COLUMN "edgesSnapshot" JSONB, +ADD COLUMN "nodesSnapshot" JSONB; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 67381d5c..716e4c2c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -356,6 +356,8 @@ model PipelineVersion { configToml String? logLevel String? globalConfig Json? + nodesSnapshot Json? + edgesSnapshot Json? createdById String changelog String? createdAt DateTime @default(now()) From 079a2ed634d22dc924d8570bbb586e10518ce3a5 Mon Sep 17 00:00:00 2001 From: TerrifiedBug Date: Sun, 8 Mar 2026 12:57:33 +0000 Subject: [PATCH 18/29] feat: add discard changes mutation and UI --- src/app/(dashboard)/pipelines/[id]/page.tsx | 38 +++++++ src/components/flow/flow-toolbar.tsx | 18 ++++ src/server/routers/pipeline.ts | 113 +++++++++++++++++++- src/server/services/pipeline-version.ts | 49 ++++++++- 4 files changed, 214 insertions(+), 4 deletions(-) diff --git a/src/app/(dashboard)/pipelines/[id]/page.tsx b/src/app/(dashboard)/pipelines/[id]/page.tsx index b99d6468..37a03c72 100644 --- a/src/app/(dashboard)/pipelines/[id]/page.tsx +++ b/src/app/(dashboard)/pipelines/[id]/page.tsx @@ -114,6 +114,7 @@ function PipelineBuilderInner({ pipelineId }: { pipelineId: string }) { const [templateOpen, setTemplateOpen] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false); const [undeployOpen, setUndeployOpen] = useState(false); + const [discardOpen, setDiscardOpen] = useState(false); const [metricsOpen, setMetricsOpen] = useState(false); const [logsOpen, setLogsOpen] = useState(false); @@ -239,6 +240,20 @@ function PipelineBuilderInner({ pipelineId }: { pipelineId: string }) { }) ); + // Discard changes mutation + const discardMutation = useMutation( + trpc.pipeline.discardChanges.mutationOptions({ + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: trpc.pipeline.get.queryKey() }); + toast.success("Changes discarded — restored to last deployed state"); + setDiscardOpen(false); + }, + onError: (err) => { + toast.error("Failed to discard changes", { description: err.message }); + }, + }) + ); + // Rename state const [isRenaming, setIsRenaming] = useState(false); const [renameValue, setRenameValue] = useState(""); @@ -390,6 +405,7 @@ function PipelineBuilderInner({ pipelineId }: { pipelineId: string }) { : null } gitOpsMode={pipelineQuery.data?.gitOpsMode} + onDiscardChanges={() => setDiscardOpen(true)} />
@@ -459,6 +475,28 @@ function PipelineBuilderInner({ pipelineId }: { pipelineId: string }) { setUndeployOpen(false); }} /> + + + + Discard unsaved changes? + + This will revert the pipeline to its last deployed state. Any saved changes that haven't been deployed will be lost. + + + + + + + +
); } diff --git a/src/components/flow/flow-toolbar.tsx b/src/components/flow/flow-toolbar.tsx index 873b8ade..f6e27ee0 100644 --- a/src/components/flow/flow-toolbar.tsx +++ b/src/components/flow/flow-toolbar.tsx @@ -72,6 +72,7 @@ interface FlowToolbarProps { hasRecentErrors?: boolean; processStatus?: ProcessStatusValue | null; gitOpsMode?: string; + onDiscardChanges?: () => void; } function downloadFile(content: string, filename: string) { @@ -102,6 +103,7 @@ export function FlowToolbar({ hasRecentErrors = false, processStatus, gitOpsMode, + onDiscardChanges, }: FlowToolbarProps) { const globalConfig = useFlowStore((s) => s.globalConfig); const canUndo = useFlowStore((s) => s.canUndo); @@ -533,6 +535,22 @@ export function FlowToolbar({ Changes detected — deploy to update + {onDiscardChanges && ( + + + + + Revert to last deployed state + + )}