diff --git a/docs/public/user-guide/dashboard.md b/docs/public/user-guide/dashboard.md index 6d46d7c8..261974c7 100644 --- a/docs/public/user-guide/dashboard.md +++ b/docs/public/user-guide/dashboard.md @@ -81,41 +81,45 @@ Dashboard data refreshes automatically based on the selected time range: Pipeline status cards also poll every 15 seconds regardless of the selected time range, so you will see status changes (Running, Stopped, Crashed) promptly. -## Data Volume Analytics +## Custom dashboard views -The **Analytics** page (accessible from the sidebar) provides a dedicated view of data volume across your pipelines. It is designed to help you understand how much data is flowing through your environment and how effectively your pipelines are reducing it over time. +You can create personalized dashboard views that display only the panels you care about. Custom views are saved per-user and persist across sessions. -### Time range selector +### Creating a view -At the top of the page, choose from **1h**, **6h**, **1d**, **7d**, or **30d**. The selected window determines the data shown in the KPI cards, chart, and per-pipeline table. It also sets the previous comparison period -- for example, selecting **1d** compares the last 24 hours against the 24 hours before that. +{% 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: -### KPI cards +| 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 %} -Three summary cards appear at the top: +### Switching views -| Card | What it shows | -|------|--------------| -| **Total In** | The total bytes received across all pipelines in the selected period. Includes a trend arrow and percentage change compared to the previous period. | -| **Total Out** | The total bytes sent by all sinks. Also shows trend vs. the previous period. | -| **Reduction %** | The data reduction percentage: `(1 - bytesOut / bytesIn) * 100`. Shows the percentage-point change compared to the previous period. Color-coded green (≥50%), amber (20–50%), or red (<20%). | - -### Volume over time chart - -An area chart shows **Bytes In** (blue) and **Bytes Out** (green) over the selected time window. The X-axis adapts its label format to the time range -- times for shorter windows, dates for longer ones. Hover over data points to see exact values. - -### Per-pipeline breakdown table - -Below the chart, a table lists every pipeline that processed data during the selected period: +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. -| Column | Description | -|--------|------------| -| **Pipeline Name** | The name of the pipeline. | -| **Bytes In** | Total bytes received by that pipeline's sources. | -| **Bytes Out** | Total bytes sent by that pipeline's sinks. | -| **Reduction %** | Per-pipeline reduction percentage, shown with a colored bar. Higher reduction values display a longer, greener bar; lower values show shorter, red-tinted bars. | +### Editing and deleting views -Click any column header to sort the table by that column. Click again to toggle between ascending and descending order. +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" %} -The Analytics page auto-refreshes at the same intervals as the dashboard: every 15 seconds for 1h, 60 seconds for 6h, and 120 seconds for longer ranges. +Custom views are scoped to your user account. Other team members will not see your custom views, and you will not see theirs. {% endhint %} 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 a2cd7f49..c30c9b3a 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()) } @@ -561,3 +562,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]) +} diff --git a/src/app/(dashboard)/page.tsx b/src/app/(dashboard)/page.tsx index 947c8120..600854e2 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,49 @@ 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: (_data, variables) => { + queryClient.invalidateQueries({ + queryKey: [["dashboard", "listViews"]], + }); + // Only reset to default if the deleted view was the one being viewed + if (activeView === variables.id) { + 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 +126,306 @@ 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); + }} + environmentId={selectedEnvironmentId ?? ""} + 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..374aebc6 --- /dev/null +++ b/src/components/dashboard/view-builder-dialog.tsx @@ -0,0 +1,220 @@ +"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; + /** Current environment ID for team-scoped access */ + environmentId: string; + /** If provided, the dialog operates in edit mode */ + editView?: { + id: string; + name: string; + panels: string[]; + }; +} + +export function ViewBuilderDialog({ + open, + onOpenChange, + environmentId, + editView, +}: ViewBuilderDialogProps) { + return ( + + + {/* Render form content only when open so state resets on each open */} + {open && ( + onOpenChange(false)} + /> + )} + + + ); +} + +function ViewBuilderForm({ + editView, + environmentId, + onClose, +}: { + editView?: ViewBuilderDialogProps["editView"]; + environmentId: string; + 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({ + environmentId, + id: editView.id, + name: name.trim(), + panels: selectedPanels, + }); + } else { + createMutation.mutate({ + environmentId, + 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 ( + + ); + } + )} +
+
+ ))} +
+
+ + + + + + + ); +} diff --git a/src/server/routers/dashboard.ts b/src/server/routers/dashboard.ts index 05a7ca02..6bdd1535 100644 --- a/src/server/routers/dashboard.ts +++ b/src/server/routers/dashboard.ts @@ -1,5 +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"; @@ -916,4 +918,94 @@ 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({ + environmentId: z.string(), + 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(), + }) + ) + .use(withTeamAccess("VIEWER")) + .use(withAudit("dashboard.create_view", "DashboardView")) + .mutation(async ({ input, ctx }) => { + const userId = ctx.session.user!.id!; + 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: nextOrder, + }, + }); + }), + + 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(), + filters: z + .object({ + pipelineIds: z.array(z.string()).optional(), + nodeIds: z.array(z.string()).optional(), + }) + .optional(), + 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({ + where: { id: input.id }, + }); + if (!view || view.userId !== userId) { + throw new TRPCError({ code: "NOT_FOUND" }); + } + // 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({ 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({ + 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 }; + }), });