From 1c1b063c72da55e0db012a114482ab2f6767140d Mon Sep 17 00:00:00 2001 From: TerrifiedBug Date: Sat, 7 Mar 2026 13:13:55 +0000 Subject: [PATCH 1/6] feat: add DashboardView model for custom dashboard views Add Prisma schema model and migration for user-scoped custom dashboard views with panel selection, optional filters, and sort ordering. --- .../migration.sql | 19 +++++++++++++++++++ prisma/schema.prisma | 15 +++++++++++++++ 2 files changed, 34 insertions(+) create mode 100644 prisma/migrations/20260307100000_add_dashboard_views/migration.sql diff --git a/prisma/migrations/20260307100000_add_dashboard_views/migration.sql b/prisma/migrations/20260307100000_add_dashboard_views/migration.sql new file mode 100644 index 00000000..08c11448 --- /dev/null +++ b/prisma/migrations/20260307100000_add_dashboard_views/migration.sql @@ -0,0 +1,19 @@ +-- CreateTable +CREATE TABLE "DashboardView" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "panels" JSONB NOT NULL, + "filters" JSONB, + "sortOrder" INTEGER NOT NULL DEFAULT 0, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "DashboardView_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "DashboardView_userId_idx" ON "DashboardView"("userId"); + +-- AddForeignKey +ALTER TABLE "DashboardView" ADD CONSTRAINT "DashboardView_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f0db424c..abb9fc94 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -27,6 +27,7 @@ model User { pipelinesUpdated Pipeline[] @relation("PipelineUpdatedBy") pipelinesCreated Pipeline[] @relation("PipelineCreatedBy") vrlSnippets VrlSnippet[] + dashboardViews DashboardView[] createdAt DateTime @default(now()) } @@ -541,3 +542,17 @@ model AlertEvent { @@index([nodeId]) @@index([firedAt]) } + +model DashboardView { + id String @id @default(cuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + name String + panels Json // string[] of panel names + filters Json? // { pipelineIds?: string[], nodeIds?: string[] } + sortOrder Int @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([userId]) +} From 5ae4acd2ab4cdb9c93254a46271c7ce4a48193ee Mon Sep 17 00:00:00 2001 From: TerrifiedBug Date: Sat, 7 Mar 2026 13:14:58 +0000 Subject: [PATCH 2/6] feat: add dashboard view CRUD procedures Add listViews, createView, updateView, and deleteView tRPC procedures to the dashboard router for managing user-scoped custom dashboard views. --- src/server/routers/dashboard.ts | 80 +++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/src/server/routers/dashboard.ts b/src/server/routers/dashboard.ts index 8fc05e0c..655398c2 100644 --- a/src/server/routers/dashboard.ts +++ b/src/server/routers/dashboard.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { TRPCError } from "@trpc/server"; import { router, protectedProcedure, withTeamAccess } from "@/trpc/init"; import { prisma } from "@/lib/prisma"; import { metricStore } from "@/server/services/metric-store"; @@ -808,4 +809,83 @@ export const dashboardRouter = router({ }, }; }), + + /* ── Custom Dashboard Views CRUD ───────────────────────────────── */ + + listViews: protectedProcedure.query(async ({ ctx }) => { + return prisma.dashboardView.findMany({ + where: { userId: ctx.session.user!.id! }, + orderBy: { sortOrder: "asc" }, + }); + }), + + createView: protectedProcedure + .input( + z.object({ + name: z.string().min(1).max(50), + panels: z.array(z.string()).min(1), + filters: z + .object({ + pipelineIds: z.array(z.string()).optional(), + nodeIds: z.array(z.string()).optional(), + }) + .optional(), + }) + ) + .mutation(async ({ input, ctx }) => { + const userId = ctx.session.user!.id!; + const count = await prisma.dashboardView.count({ + where: { userId }, + }); + return prisma.dashboardView.create({ + data: { + userId, + name: input.name, + panels: input.panels, + filters: input.filters ?? {}, + sortOrder: count, + }, + }); + }), + + updateView: protectedProcedure + .input( + z.object({ + id: z.string(), + name: z.string().min(1).max(50).optional(), + panels: z.array(z.string()).min(1).optional(), + filters: z + .object({ + pipelineIds: z.array(z.string()).optional(), + nodeIds: z.array(z.string()).optional(), + }) + .optional(), + sortOrder: z.number().int().optional(), + }) + ) + .mutation(async ({ input, ctx }) => { + const userId = ctx.session.user!.id!; + const view = await prisma.dashboardView.findUnique({ + where: { id: input.id }, + }); + if (!view || view.userId !== userId) { + throw new TRPCError({ code: "NOT_FOUND" }); + } + const { id, ...data } = input; + return prisma.dashboardView.update({ where: { id }, data }); + }), + + deleteView: protectedProcedure + .input(z.object({ id: z.string() })) + .mutation(async ({ input, ctx }) => { + const userId = ctx.session.user!.id!; + const view = await prisma.dashboardView.findUnique({ + where: { id: input.id }, + }); + if (!view || view.userId !== userId) { + throw new TRPCError({ code: "NOT_FOUND" }); + } + await prisma.dashboardView.delete({ where: { id: input.id } }); + return { deleted: true }; + }), }); From 474230439b6779e024d4cbfa86e529665f85ce58 Mon Sep 17 00:00:00 2001 From: TerrifiedBug Date: Sat, 7 Mar 2026 13:21:34 +0000 Subject: [PATCH 3/6] feat: add dashboard tab bar, view builder, and custom view rendering Add a tab bar at the top of the dashboard for switching between the default view and user-created custom views. Includes a view builder dialog for selecting panels and a custom view component that renders the chosen charts and summary cards in a responsive grid. --- src/app/(dashboard)/page.tsx | 514 +++++++++++------- src/components/dashboard/custom-view.tsx | 388 +++++++++++++ .../dashboard/view-builder-dialog.tsx | 212 ++++++++ 3 files changed, 923 insertions(+), 191 deletions(-) create mode 100644 src/components/dashboard/custom-view.tsx create mode 100644 src/components/dashboard/view-builder-dialog.tsx diff --git a/src/app/(dashboard)/page.tsx b/src/app/(dashboard)/page.tsx index 947c8120..7a312a17 100644 --- a/src/app/(dashboard)/page.tsx +++ b/src/app/(dashboard)/page.tsx @@ -1,7 +1,7 @@ "use client"; import { useState, useMemo } from "react"; -import { useQuery } from "@tanstack/react-query"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useTRPC } from "@/trpc/client"; import { Server, @@ -12,6 +12,9 @@ import { MemoryStick, HardDrive, Network, + Plus, + Pencil, + Trash2, } from "lucide-react"; import { Card, CardContent } from "@/components/ui/card"; import { StatusBadge } from "@/components/ui/status-badge"; @@ -23,6 +26,8 @@ import { } from "@/components/dashboard/metrics-filter-bar"; import { MetricsSection } from "@/components/dashboard/metrics-section"; import { MetricChart } from "@/components/dashboard/metric-chart"; +import { ViewBuilderDialog } from "@/components/dashboard/view-builder-dialog"; +import { CustomView } from "@/components/dashboard/custom-view"; import { formatSI, formatBytesRate, formatEventsRate } from "@/lib/format"; import { cn } from "@/lib/utils"; @@ -40,17 +45,47 @@ function derivePipelineStatus( export default function DashboardPage() { const trpc = useTRPC(); + const queryClient = useQueryClient(); const { selectedEnvironmentId } = useEnvironmentStore(); + // ── Custom Views ────────────────────────────────────────────── + const viewsQuery = useQuery(trpc.dashboard.listViews.queryOptions()); + const [activeView, setActiveView] = useState(null); // null = default + const [createDialogOpen, setCreateDialogOpen] = useState(false); + const [editView, setEditView] = useState<{ + id: string; + name: string; + panels: string[]; + } | null>(null); + + const deleteMutation = useMutation( + trpc.dashboard.deleteView.mutationOptions({ + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: [["dashboard", "listViews"]], + }); + // If the deleted view was active, switch back to default + setActiveView(null); + }, + }) + ); + + // Find the active custom view data + const activeViewData = useMemo( + () => viewsQuery.data?.find((v) => v.id === activeView) ?? null, + [viewsQuery.data, activeView] + ); + + // ── Default View Data ───────────────────────────────────────── const stats = useQuery({ ...trpc.dashboard.stats.queryOptions({ environmentId: selectedEnvironmentId ?? "" }), - enabled: !!selectedEnvironmentId, + enabled: !!selectedEnvironmentId && activeView === null, }); const pipelineCards = useQuery({ ...trpc.dashboard.pipelineCards.queryOptions({ environmentId: selectedEnvironmentId ?? "" }), refetchInterval: 15_000, - enabled: !!selectedEnvironmentId, + enabled: !!selectedEnvironmentId && activeView === null, }); // Compute pipeline status counts for summary bar @@ -89,207 +124,304 @@ export default function DashboardPage() { groupBy, }), refetchInterval: refreshInterval[timeRange], - enabled: !!selectedEnvironmentId, + enabled: !!selectedEnvironmentId && activeView === null, }); return (
- {/* KPI Summary Cards */} -
- {/* Total Nodes */} - - -
-

Total Nodes

- -
-

{stats.data?.nodes ?? 0}

-
-
- - {/* Node Health */} - - -
-

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 + {/* ── Tab Bar ────────────────────────────────────────────── */} +
+ + {viewsQuery.data?.map((view) => ( + + + + + ))} + +
- {/* Total Pipelines */} - - -
-

Pipelines

- -
-

{stats.data?.pipelines ?? 0}

-
-
+ {/* ── View Content ───────────────────────────────────────── */} + {activeView !== null && activeViewData ? ( + + ) : ( + <> + {/* KPI Summary Cards */} +
+ {/* Total Nodes */} + + +
+

Total Nodes

+ +
+

{stats.data?.nodes ?? 0}

+
+
- {/* Pipeline Status */} - - -
-

Pipeline Status

- -
-
- {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 - )} -
-
-
+ {/* Node Health */} + + +
+

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 + )} +
+
+
- {/* Log Reduction */} - - -
-

Log 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

- - )} -
-
-
+ {/* Total Pipelines */} + + +
+

Pipelines

+ +
+

{stats.data?.pipelines ?? 0}

+
+
- {/* Metrics Filter Bar */} - + {/* Pipeline Status */} + + +
+

Pipeline Status

+ +
+
+ {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 + )} +
+
+
- {/* Pipeline Metrics */} - - - formatBytesRate(v)} - timeRange={timeRange} - height={250} - /> - - + {/* Log Reduction */} + + +
+

Log 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

+ + )} +
+
+
- {/* System Metrics */} - -
- } - data={chartData.data?.system.cpu ?? {}} - yFormatter={(v) => `${v.toFixed(0)}%`} - yDomain={[0, 100]} - timeRange={timeRange} - height={220} - /> - } - data={chartData.data?.system.memory ?? {}} - yFormatter={(v) => `${v.toFixed(0)}%`} - yDomain={[0, 100]} - timeRange={timeRange} - height={220} - /> - } - data={chartData.data?.system.diskRead ?? {}} - dataSecondary={chartData.data?.system.diskWrite ?? {}} - primaryLabel=" Read" - secondaryLabel=" Write" - yFormatter={(v) => formatBytesRate(v)} - timeRange={timeRange} - height={220} - /> - } - data={chartData.data?.system.netRx ?? {}} - dataSecondary={chartData.data?.system.netTx ?? {}} - primaryLabel=" Rx" - secondaryLabel=" Tx" - yFormatter={(v) => formatBytesRate(v)} + {/* Metrics Filter Bar */} + -
-
+ + {/* Pipeline Metrics */} + + + formatBytesRate(v)} + timeRange={timeRange} + height={250} + /> + + + + {/* System Metrics */} + +
+ } + data={chartData.data?.system.cpu ?? {}} + yFormatter={(v) => `${v.toFixed(0)}%`} + yDomain={[0, 100]} + timeRange={timeRange} + height={220} + /> + } + data={chartData.data?.system.memory ?? {}} + yFormatter={(v) => `${v.toFixed(0)}%`} + yDomain={[0, 100]} + timeRange={timeRange} + height={220} + /> + } + data={chartData.data?.system.diskRead ?? {}} + dataSecondary={chartData.data?.system.diskWrite ?? {}} + primaryLabel=" Read" + secondaryLabel=" Write" + yFormatter={(v) => formatBytesRate(v)} + timeRange={timeRange} + height={220} + /> + } + data={chartData.data?.system.netRx ?? {}} + dataSecondary={chartData.data?.system.netTx ?? {}} + primaryLabel=" Rx" + secondaryLabel=" Tx" + yFormatter={(v) => formatBytesRate(v)} + timeRange={timeRange} + height={220} + /> +
+
+ + )} + + {/* ── Dialogs ────────────────────────────────────────────── */} + + { + if (!open) setEditView(null); + }} + editView={editView ?? undefined} + />
); } diff --git a/src/components/dashboard/custom-view.tsx b/src/components/dashboard/custom-view.tsx new file mode 100644 index 00000000..1f1c73ff --- /dev/null +++ b/src/components/dashboard/custom-view.tsx @@ -0,0 +1,388 @@ +"use client"; + +import { useState, useMemo } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { useTRPC } from "@/trpc/client"; +import { + Cpu, + MemoryStick, + HardDrive, + Network, + Server, + Activity, + BarChart3, +} from "lucide-react"; +import { Card, CardContent } from "@/components/ui/card"; +import { StatusBadge } from "@/components/ui/status-badge"; +import { useEnvironmentStore } from "@/stores/environment-store"; +import { + MetricsFilterBar, + type TimeRange, + type GroupBy, +} from "@/components/dashboard/metrics-filter-bar"; +import { MetricChart } from "@/components/dashboard/metric-chart"; +import { formatSI, formatBytesRate, formatEventsRate } from "@/lib/format"; +import { cn } from "@/lib/utils"; +import type { PanelId } from "@/components/dashboard/view-builder-dialog"; + +/** Derive an overall status for a pipeline from its node statuses */ +function derivePipelineStatus( + nodes: Array<{ pipelineStatus: string }> +): string { + if (nodes.length === 0) return "PENDING"; + if (nodes.some((n) => n.pipelineStatus === "CRASHED")) return "CRASHED"; + if (nodes.some((n) => n.pipelineStatus === "RUNNING")) return "RUNNING"; + if (nodes.some((n) => n.pipelineStatus === "STARTING")) return "STARTING"; + if (nodes.every((n) => n.pipelineStatus === "STOPPED")) return "STOPPED"; + return nodes[0].pipelineStatus; +} + +interface DashboardViewData { + id: string; + name: string; + panels: unknown; // Json field — string[] at runtime + filters: unknown; // Json field — { pipelineIds?: string[], nodeIds?: string[] } +} + +interface CustomViewProps { + view: DashboardViewData; +} + +export function CustomView({ view }: CustomViewProps) { + const trpc = useTRPC(); + const { selectedEnvironmentId } = useEnvironmentStore(); + + // Parse view data + const panels = (view.panels ?? []) as PanelId[]; + const savedFilters = (view.filters ?? {}) as { + pipelineIds?: string[]; + nodeIds?: string[]; + }; + + const [selectedNodeIds, setSelectedNodeIds] = useState( + savedFilters.nodeIds ?? [] + ); + const [selectedPipelineIds, setSelectedPipelineIds] = useState( + savedFilters.pipelineIds ?? [] + ); + const [timeRange, setTimeRange] = useState("1h"); + const [groupBy, setGroupBy] = useState("pipeline"); + + const refreshInterval: Record = { + "1h": 15_000, + "6h": 60_000, + "1d": 60_000, + "7d": 300_000, + }; + + // Determine which data to fetch based on selected panels + const needsChartData = panels.some((p) => + [ + "events-in-out", + "bytes-in-out", + "error-rate", + "cpu-usage", + "memory-usage", + "disk-io", + "network-io", + ].includes(p) + ); + const needsStats = panels.some((p) => + ["data-reduction", "node-health-summary", "pipeline-health-summary"].includes(p) + ); + const needsPipelineCards = panels.includes("pipeline-health-summary"); + + const chartData = useQuery({ + ...trpc.dashboard.chartMetrics.queryOptions({ + environmentId: selectedEnvironmentId ?? "", + nodeIds: selectedNodeIds, + pipelineIds: selectedPipelineIds, + range: timeRange, + groupBy, + }), + refetchInterval: refreshInterval[timeRange], + enabled: !!selectedEnvironmentId && needsChartData, + }); + + const stats = useQuery({ + ...trpc.dashboard.stats.queryOptions({ + environmentId: selectedEnvironmentId ?? "", + }), + enabled: !!selectedEnvironmentId && needsStats, + }); + + const pipelineCards = useQuery({ + ...trpc.dashboard.pipelineCards.queryOptions({ + environmentId: selectedEnvironmentId ?? "", + }), + refetchInterval: 15_000, + enabled: !!selectedEnvironmentId && needsPipelineCards, + }); + + const pipelineStatusCounts = useMemo(() => { + if (!pipelineCards.data) return { running: 0, stopped: 0, crashed: 0 }; + let running = 0; + let stopped = 0; + let crashed = 0; + for (const p of pipelineCards.data) { + const status = derivePipelineStatus(p.nodes); + if (status === "RUNNING" || status === "STARTING") running++; + else if (status === "STOPPED") stopped++; + else if (status === "CRASHED") crashed++; + } + 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 + +

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

+ Pipeline Health +

+ +
+
+ {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 + + )} +
+

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

+
+
+ )} + + {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 +

+ + )} +
+
+ )} +
+ )} + + {/* Chart panels in a 2-column responsive grid */} +
+ {panels.includes("events-in-out") && ( + + )} + + {panels.includes("bytes-in-out") && ( + formatBytesRate(v)} + timeRange={timeRange} + height={250} + /> + )} + + {panels.includes("error-rate") && ( + + )} + + {panels.includes("cpu-usage") && ( + } + data={chartData.data?.system.cpu ?? {}} + yFormatter={(v) => `${v.toFixed(0)}%`} + yDomain={[0, 100]} + timeRange={timeRange} + height={220} + /> + )} + + {panels.includes("memory-usage") && ( + } + data={chartData.data?.system.memory ?? {}} + yFormatter={(v) => `${v.toFixed(0)}%`} + yDomain={[0, 100]} + timeRange={timeRange} + height={220} + /> + )} + + {panels.includes("disk-io") && ( + } + data={chartData.data?.system.diskRead ?? {}} + dataSecondary={chartData.data?.system.diskWrite ?? {}} + primaryLabel=" Read" + secondaryLabel=" Write" + yFormatter={(v) => formatBytesRate(v)} + timeRange={timeRange} + height={220} + /> + )} + + {panels.includes("network-io") && ( + } + data={chartData.data?.system.netRx ?? {}} + dataSecondary={chartData.data?.system.netTx ?? {}} + primaryLabel=" Rx" + secondaryLabel=" Tx" + yFormatter={(v) => formatBytesRate(v)} + timeRange={timeRange} + height={220} + /> + )} +
+
+ ); +} diff --git a/src/components/dashboard/view-builder-dialog.tsx b/src/components/dashboard/view-builder-dialog.tsx new file mode 100644 index 00000000..ffa73f4b --- /dev/null +++ b/src/components/dashboard/view-builder-dialog.tsx @@ -0,0 +1,212 @@ +"use client"; + +import { useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useTRPC } from "@/trpc/client"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { cn } from "@/lib/utils"; +import { Check } from "lucide-react"; + +/** All available panels that can be added to a custom view */ +export const AVAILABLE_PANELS = [ + { id: "events-in-out", label: "Events In/Out", category: "Pipeline" }, + { id: "bytes-in-out", label: "Bytes In/Out", category: "Pipeline" }, + { id: "error-rate", label: "Error Rate", category: "Pipeline" }, + { id: "data-reduction", label: "Data Reduction %", category: "Pipeline" }, + { id: "cpu-usage", label: "CPU Usage", category: "System" }, + { id: "memory-usage", label: "Memory Usage", category: "System" }, + { id: "disk-io", label: "Disk I/O", category: "System" }, + { id: "network-io", label: "Network I/O", category: "System" }, + { id: "node-health-summary", label: "Node Health Summary", category: "Summary" }, + { id: "pipeline-health-summary", label: "Pipeline Health Summary", category: "Summary" }, +] as const; + +export type PanelId = (typeof AVAILABLE_PANELS)[number]["id"]; + +interface ViewBuilderDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + /** If provided, the dialog operates in edit mode */ + editView?: { + id: string; + name: string; + panels: string[]; + }; +} + +export function ViewBuilderDialog({ + open, + onOpenChange, + editView, +}: ViewBuilderDialogProps) { + return ( + + + {/* Render form content only when open so state resets on each open */} + {open && ( + onOpenChange(false)} + /> + )} + + + ); +} + +function ViewBuilderForm({ + editView, + onClose, +}: { + editView?: ViewBuilderDialogProps["editView"]; + onClose: () => void; +}) { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + const [name, setName] = useState(editView?.name ?? ""); + const [selectedPanels, setSelectedPanels] = useState( + editView?.panels ? [...editView.panels] : [] + ); + + const createMutation = useMutation( + trpc.dashboard.createView.mutationOptions({ + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [["dashboard", "listViews"]] }); + onClose(); + }, + }) + ); + + const updateMutation = useMutation( + trpc.dashboard.updateView.mutationOptions({ + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [["dashboard", "listViews"]] }); + onClose(); + }, + }) + ); + + const togglePanel = (panelId: string) => { + setSelectedPanels((prev) => + prev.includes(panelId) + ? prev.filter((p) => p !== panelId) + : [...prev, panelId] + ); + }; + + const handleSave = () => { + if (!name.trim() || selectedPanels.length === 0) return; + + if (editView) { + updateMutation.mutate({ + id: editView.id, + name: name.trim(), + panels: selectedPanels, + }); + } else { + createMutation.mutate({ + name: name.trim(), + panels: selectedPanels, + }); + } + }; + + const isPending = createMutation.isPending || updateMutation.isPending; + const categories = [...new Set(AVAILABLE_PANELS.map((p) => p.category))]; + + return ( + <> + + {editView ? "Edit View" : "New Custom View"} + + Choose a name and select which panels to include. + + + +
+ {/* Name input */} +
+ + setName(e.target.value)} + placeholder="My custom view" + maxLength={50} + autoFocus + /> +
+ + {/* Panel selection */} +
+ + {categories.map((category) => ( +
+

+ {category} +

+
+ {AVAILABLE_PANELS.filter((p) => p.category === category).map( + (panel) => { + const isSelected = selectedPanels.includes(panel.id); + return ( + + ); + } + )} +
+
+ ))} +
+
+ + + + + + + ); +} From f372d7863f982dfb1786a78874157794dfb0c918 Mon Sep 17 00:00:00 2001 From: TerrifiedBug Date: Sat, 7 Mar 2026 13:22:02 +0000 Subject: [PATCH 4/6] docs: add custom dashboard views section to dashboard docs Document the custom view workflow: creating, switching, editing, and deleting views, with a stepper guide and panel reference table. --- docs/public/user-guide/dashboard.md | 43 +++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/docs/public/user-guide/dashboard.md b/docs/public/user-guide/dashboard.md index 8833ec27..261974c7 100644 --- a/docs/public/user-guide/dashboard.md +++ b/docs/public/user-guide/dashboard.md @@ -80,3 +80,46 @@ Dashboard data refreshes automatically based on the selected time range: | 7 days | 5 minutes | Pipeline status cards also poll every 15 seconds regardless of the selected time range, so you will see status changes (Running, Stopped, Crashed) promptly. + +## Custom dashboard views + +You can create personalized dashboard views that display only the panels you care about. Custom views are saved per-user and persist across sessions. + +### Creating a view + +{% stepper %} +{% step %} +### Open the view builder +Click the **+ New View** button in the tab bar at the top of the dashboard. +{% endstep %} +{% step %} +### Name your view +Enter a short, descriptive name (up to 50 characters). +{% endstep %} +{% step %} +### Select panels +Check the panels you want to include. Panels are grouped into three categories: + +| Category | Available panels | +|----------|-----------------| +| **Pipeline** | Events In/Out, Bytes In/Out, Error Rate, Data Reduction % | +| **System** | CPU Usage, Memory Usage, Disk I/O, Network I/O | +| **Summary** | Node Health Summary, Pipeline Health Summary | +{% endstep %} +{% step %} +### Save +Click **Create**. Your new view will appear as a tab in the tab bar. +{% endstep %} +{% endstepper %} + +### Switching views + +The tab bar at the top of the dashboard shows all your custom views alongside the **Default** view. Click any tab to switch to that view. Each custom view has its own time range picker and filter bar for any chart panels it includes. + +### Editing and deleting views + +Hover over a custom view tab to reveal the edit and delete icons. Click the pencil icon to update the view name or panel selection, or click the trash icon to remove the view. + +{% hint style="info" %} +Custom views are scoped to your user account. Other team members will not see your custom views, and you will not see theirs. +{% endhint %} From ed9198e0dda0a6bc100e7b84964ca8c39190bada Mon Sep 17 00:00:00 2001 From: TerrifiedBug Date: Sat, 7 Mar 2026 13:28:10 +0000 Subject: [PATCH 5/6] fix: only reset active view when deleting the currently viewed tab Previously, deleting any custom view would unconditionally switch back to the Default tab. Now only resets if the deleted view was active. --- src/app/(dashboard)/page.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/app/(dashboard)/page.tsx b/src/app/(dashboard)/page.tsx index 7a312a17..e39c0a3e 100644 --- a/src/app/(dashboard)/page.tsx +++ b/src/app/(dashboard)/page.tsx @@ -61,12 +61,14 @@ export default function DashboardPage() { const deleteMutation = useMutation( trpc.dashboard.deleteView.mutationOptions({ - onSuccess: () => { + onSuccess: (_data, variables) => { queryClient.invalidateQueries({ queryKey: [["dashboard", "listViews"]], }); - // If the deleted view was active, switch back to default - setActiveView(null); + // Only reset to default if the deleted view was the one being viewed + if (activeView === variables.id) { + setActiveView(null); + } }, }) ); From 7dd2331e58f933620194ff3fa1a20cfa56234c08 Mon Sep 17 00:00:00 2001 From: TerrifiedBug Date: Sat, 7 Mar 2026 13:59:05 +0000 Subject: [PATCH 6/6] fix: address custom dashboards review findings - Replace count-based sortOrder with aggregate max to avoid TOCTOU race - Add withTeamAccess("VIEWER") to createView, updateView, deleteView - Add withAudit middleware to all three view mutations - Pass environmentId through frontend for team context resolution --- src/app/(dashboard)/page.tsx | 4 +++- .../dashboard/view-builder-dialog.tsx | 8 ++++++++ src/server/routers/dashboard.ts | 20 +++++++++++++++---- 3 files changed, 27 insertions(+), 5 deletions(-) diff --git a/src/app/(dashboard)/page.tsx b/src/app/(dashboard)/page.tsx index e39c0a3e..600854e2 100644 --- a/src/app/(dashboard)/page.tsx +++ b/src/app/(dashboard)/page.tsx @@ -187,7 +187,7 @@ export default function DashboardPage() { onClick={(e) => { e.stopPropagation(); if (confirm(`Delete "${view.name}"?`)) { - deleteMutation.mutate({ id: view.id }); + deleteMutation.mutate({ environmentId: selectedEnvironmentId!, id: view.id }); } }} className="rounded p-0.5 hover:bg-muted text-destructive" @@ -416,12 +416,14 @@ export default function DashboardPage() { { if (!open) setEditView(null); }} + environmentId={selectedEnvironmentId ?? ""} editView={editView ?? undefined} />
diff --git a/src/components/dashboard/view-builder-dialog.tsx b/src/components/dashboard/view-builder-dialog.tsx index ffa73f4b..374aebc6 100644 --- a/src/components/dashboard/view-builder-dialog.tsx +++ b/src/components/dashboard/view-builder-dialog.tsx @@ -36,6 +36,8 @@ export type PanelId = (typeof AVAILABLE_PANELS)[number]["id"]; interface ViewBuilderDialogProps { open: boolean; onOpenChange: (open: boolean) => void; + /** Current environment ID for team-scoped access */ + environmentId: string; /** If provided, the dialog operates in edit mode */ editView?: { id: string; @@ -47,6 +49,7 @@ interface ViewBuilderDialogProps { export function ViewBuilderDialog({ open, onOpenChange, + environmentId, editView, }: ViewBuilderDialogProps) { return ( @@ -56,6 +59,7 @@ export function ViewBuilderDialog({ {open && ( onOpenChange(false)} /> )} @@ -66,9 +70,11 @@ export function ViewBuilderDialog({ function ViewBuilderForm({ editView, + environmentId, onClose, }: { editView?: ViewBuilderDialogProps["editView"]; + environmentId: string; onClose: () => void; }) { const trpc = useTRPC(); @@ -109,12 +115,14 @@ function ViewBuilderForm({ if (editView) { updateMutation.mutate({ + environmentId, id: editView.id, name: name.trim(), panels: selectedPanels, }); } else { createMutation.mutate({ + environmentId, name: name.trim(), panels: selectedPanels, }); diff --git a/src/server/routers/dashboard.ts b/src/server/routers/dashboard.ts index 655398c2..ec9fdace 100644 --- a/src/server/routers/dashboard.ts +++ b/src/server/routers/dashboard.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { TRPCError } from "@trpc/server"; import { router, protectedProcedure, withTeamAccess } from "@/trpc/init"; +import { withAudit } from "@/server/middleware/audit"; import { prisma } from "@/lib/prisma"; import { metricStore } from "@/server/services/metric-store"; import { generateVectorYaml } from "@/lib/config-generator"; @@ -822,6 +823,7 @@ export const dashboardRouter = router({ createView: protectedProcedure .input( z.object({ + environmentId: z.string(), name: z.string().min(1).max(50), panels: z.array(z.string()).min(1), filters: z @@ -832,18 +834,22 @@ export const dashboardRouter = router({ .optional(), }) ) + .use(withTeamAccess("VIEWER")) + .use(withAudit("dashboard.create_view", "DashboardView")) .mutation(async ({ input, ctx }) => { const userId = ctx.session.user!.id!; - const count = await prisma.dashboardView.count({ + const maxOrder = await prisma.dashboardView.aggregate({ where: { userId }, + _max: { sortOrder: true }, }); + const nextOrder = (maxOrder._max.sortOrder ?? -1) + 1; return prisma.dashboardView.create({ data: { userId, name: input.name, panels: input.panels, filters: input.filters ?? {}, - sortOrder: count, + sortOrder: nextOrder, }, }); }), @@ -851,6 +857,7 @@ export const dashboardRouter = router({ updateView: protectedProcedure .input( z.object({ + environmentId: z.string(), id: z.string(), name: z.string().min(1).max(50).optional(), panels: z.array(z.string()).min(1).optional(), @@ -863,6 +870,8 @@ export const dashboardRouter = router({ sortOrder: z.number().int().optional(), }) ) + .use(withTeamAccess("VIEWER")) + .use(withAudit("dashboard.update_view", "DashboardView")) .mutation(async ({ input, ctx }) => { const userId = ctx.session.user!.id!; const view = await prisma.dashboardView.findUnique({ @@ -871,12 +880,15 @@ export const dashboardRouter = router({ if (!view || view.userId !== userId) { throw new TRPCError({ code: "NOT_FOUND" }); } - const { id, ...data } = input; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { id, environmentId, ...data } = input; return prisma.dashboardView.update({ where: { id }, data }); }), deleteView: protectedProcedure - .input(z.object({ id: z.string() })) + .input(z.object({ environmentId: z.string(), id: z.string() })) + .use(withTeamAccess("VIEWER")) + .use(withAudit("dashboard.delete_view", "DashboardView")) .mutation(async ({ input, ctx }) => { const userId = ctx.session.user!.id!; const view = await prisma.dashboardView.findUnique({