From e1fd3c2575d477fc79a1e28eb8d66f6e3a5fbb8c Mon Sep 17 00:00:00 2001 From: TerrifiedBug Date: Sun, 8 Mar 2026 14:12:50 +0000 Subject: [PATCH 1/3] fix: discard without snapshots, move SLI to settings, fix version table columns - Make discard changes work for versions without snapshots (restores globalConfig only, skips node/edge restore gracefully) - Remove SLI health badge from toolbar to reduce clutter; add per-SLI health status indicators (green/red dot) in Settings > Health SLIs - Fix version history table header overlap caused by max-w-0 on the Changelog column; move truncation to cell content instead --- src/components/flow/flow-toolbar.tsx | 23 ------ src/components/flow/pipeline-settings.tsx | 35 ++++++-- .../pipeline/version-history-dialog.tsx | 8 +- src/server/routers/pipeline.ts | 79 +++++++++---------- 4 files changed, 69 insertions(+), 76 deletions(-) diff --git a/src/components/flow/flow-toolbar.tsx b/src/components/flow/flow-toolbar.tsx index 4297e2af..ddf71228 100644 --- a/src/components/flow/flow-toolbar.tsx +++ b/src/components/flow/flow-toolbar.tsx @@ -123,16 +123,6 @@ export function FlowToolbar({ const queryClient = useQueryClient(); const { data: session } = useSession(); - const healthQuery = useQuery( - trpc.pipeline.health.queryOptions( - { pipelineId: pipelineId! }, - { enabled: !!pipelineId && !isDraft && !!deployedAt, refetchInterval: 30_000 }, - ), - ); - 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({ ...trpc.deploy.listPendingRequests.queryOptions({ pipelineId: pipelineId! }), @@ -470,19 +460,6 @@ export function FlowToolbar({ {processStatus === "CRASHED" && "Crashed"} {processStatus === "PENDING" && "Pending..."} - {/* Health SLI badge */} - {healthStatus === "healthy" && ( - - - SLIs: OK - - )} - {healthStatus === "degraded" && ( - - - SLIs: {slisBreached}/{sliTotal} breached - - )} )} diff --git a/src/components/flow/pipeline-settings.tsx b/src/components/flow/pipeline-settings.tsx index 8b874c3c..40d8f09e 100644 --- a/src/components/flow/pipeline-settings.tsx +++ b/src/components/flow/pipeline-settings.tsx @@ -331,6 +331,16 @@ function SliSettings({ pipelineId }: { pipelineId: string }) { ); const slis = slisQuery.data ?? []; + const healthQuery = useQuery( + trpc.pipeline.health.queryOptions( + { pipelineId }, + { enabled: slis.length > 0, refetchInterval: 30_000 }, + ), + ); + const sliStatuses = new Map( + (healthQuery.data?.slis ?? []).map((s: { metric: string; status: string }) => [s.metric, s.status]), + ); + const [sliOpen, setSliOpen] = useState(false); const [newMetric, setNewMetric] = useState("error_rate"); const [newCondition, setNewCondition] = useState("lt"); @@ -412,14 +422,23 @@ function SliSettings({ pipelineId }: { pipelineId: string }) { key={sli.id} className="flex items-center justify-between rounded-md border px-3 py-2 text-xs" > -
- {metricLabel(sli.metric)}{" "} - - {sli.condition === "lt" ? "<" : ">"} {sli.threshold} - {" "} - - ({sli.windowMinutes}m) - +
+ {sliStatuses.has(sli.metric) && ( + + )} +
+ {metricLabel(sli.metric)}{" "} + + {sli.condition === "lt" ? "<" : ">"} {sli.threshold} + {" "} + + ({sli.windowMinutes}m) + +
- - + + {version.changelog || "No changelog"} diff --git a/src/server/routers/pipeline.ts b/src/server/routers/pipeline.ts index a0a1d874..63faa10b 100644 --- a/src/server/routers/pipeline.ts +++ b/src/server/routers/pipeline.ts @@ -785,15 +785,7 @@ export const pipelineRouter = router({ if (!latestVersion) { throw new TRPCError({ code: "PRECONDITION_FAILED", message: "No deployed version found" }); } - if (!latestVersion.nodesSnapshot || !latestVersion.edgesSnapshot) { - throw new TRPCError({ - code: "PRECONDITION_FAILED", - message: "Deployed version has no snapshot — deploy once more to enable discard", - }); - } - - const nodes = latestVersion.nodesSnapshot as Array>; - const edges = latestVersion.edgesSnapshot as Array>; + const hasSnapshots = !!latestVersion.nodesSnapshot && !!latestVersion.edgesSnapshot; await prisma.$transaction(async (tx) => { await tx.pipeline.update({ @@ -803,40 +795,45 @@ export const pipelineRouter = router({ }, }); - await tx.pipelineEdge.deleteMany({ where: { pipelineId: input.pipelineId } }); - await tx.pipelineNode.deleteMany({ where: { pipelineId: input.pipelineId } }); + if (hasSnapshots) { + const nodes = latestVersion.nodesSnapshot as Array>; + const edges = latestVersion.edgesSnapshot as Array>; - await Promise.all( - nodes.map((node) => - tx.pipelineNode.create({ - data: { - id: node.id as string, - pipelineId: input.pipelineId, - componentKey: node.componentKey as string, - componentType: node.componentType as string, - kind: node.kind as ComponentKind, - config: node.config as Prisma.InputJsonValue, - positionX: node.positionX as number, - positionY: node.positionY as number, - disabled: (node.disabled as boolean) ?? false, - }, - }) - ) - ); + await tx.pipelineEdge.deleteMany({ where: { pipelineId: input.pipelineId } }); + await tx.pipelineNode.deleteMany({ where: { pipelineId: input.pipelineId } }); - await Promise.all( - edges.map((edge) => - tx.pipelineEdge.create({ - data: { - id: edge.id as string, - pipelineId: input.pipelineId, - sourceNodeId: edge.sourceNodeId as string, - targetNodeId: edge.targetNodeId as string, - sourcePort: (edge.sourcePort as string) ?? null, - }, - }) - ) - ); + await Promise.all( + nodes.map((node) => + tx.pipelineNode.create({ + data: { + id: node.id as string, + pipelineId: input.pipelineId, + componentKey: node.componentKey as string, + componentType: node.componentType as string, + kind: node.kind as ComponentKind, + config: node.config as Prisma.InputJsonValue, + positionX: node.positionX as number, + positionY: node.positionY as number, + disabled: (node.disabled as boolean) ?? false, + }, + }) + ) + ); + + await Promise.all( + edges.map((edge) => + tx.pipelineEdge.create({ + data: { + id: edge.id as string, + pipelineId: input.pipelineId, + sourceNodeId: edge.sourceNodeId as string, + targetNodeId: edge.targetNodeId as string, + sourcePort: (edge.sourcePort as string) ?? null, + }, + }) + ) + ); + } }); return { discarded: true }; From 3b1c611e54624a681d9a38c1ff11de84d86045f0 Mon Sep 17 00:00:00 2001 From: TerrifiedBug Date: Sun, 8 Mar 2026 14:18:26 +0000 Subject: [PATCH 2/3] fix: warn user when discard only restores global settings (no snapshots) Return partial flag from discardChanges so the frontend can distinguish full restore (with snapshots) from partial (globalConfig only). Shows a toast explaining node configs weren't restored and to redeploy for full discard support. --- src/app/(dashboard)/pipelines/[id]/page.tsx | 10 ++++++++-- src/server/routers/pipeline.ts | 2 +- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/app/(dashboard)/pipelines/[id]/page.tsx b/src/app/(dashboard)/pipelines/[id]/page.tsx index 1de4c329..95fe71f9 100644 --- a/src/app/(dashboard)/pipelines/[id]/page.tsx +++ b/src/app/(dashboard)/pipelines/[id]/page.tsx @@ -243,10 +243,16 @@ function PipelineBuilderInner({ pipelineId }: { pipelineId: string }) { // Discard changes mutation const discardMutation = useMutation( trpc.pipeline.discardChanges.mutationOptions({ - onSuccess: () => { + onSuccess: (result) => { markClean(); queryClient.invalidateQueries({ queryKey: trpc.pipeline.get.queryKey() }); - toast.success("Changes discarded — restored to last deployed state"); + if (result.partial) { + toast.success("Global settings restored", { + description: "Node configs were not restored — redeploy to enable full discard.", + }); + } else { + toast.success("Changes discarded — restored to last deployed state"); + } setDiscardOpen(false); }, onError: (err) => { diff --git a/src/server/routers/pipeline.ts b/src/server/routers/pipeline.ts index 63faa10b..18cd0ac2 100644 --- a/src/server/routers/pipeline.ts +++ b/src/server/routers/pipeline.ts @@ -836,7 +836,7 @@ export const pipelineRouter = router({ } }); - return { discarded: true }; + return { discarded: true, partial: !hasSnapshots }; }), versions: protectedProcedure From 63d86b6488503e292b8e97ba616d22c9983bc47a Mon Sep 17 00:00:00 2001 From: TerrifiedBug Date: Sun, 8 Mar 2026 14:19:31 +0000 Subject: [PATCH 3/3] revert: keep original discard behavior, improve error message for pre-snapshot versions --- src/app/(dashboard)/pipelines/[id]/page.tsx | 10 +-- src/server/routers/pipeline.ts | 81 +++++++++++---------- 2 files changed, 44 insertions(+), 47 deletions(-) diff --git a/src/app/(dashboard)/pipelines/[id]/page.tsx b/src/app/(dashboard)/pipelines/[id]/page.tsx index 95fe71f9..1de4c329 100644 --- a/src/app/(dashboard)/pipelines/[id]/page.tsx +++ b/src/app/(dashboard)/pipelines/[id]/page.tsx @@ -243,16 +243,10 @@ function PipelineBuilderInner({ pipelineId }: { pipelineId: string }) { // Discard changes mutation const discardMutation = useMutation( trpc.pipeline.discardChanges.mutationOptions({ - onSuccess: (result) => { + onSuccess: () => { markClean(); queryClient.invalidateQueries({ queryKey: trpc.pipeline.get.queryKey() }); - if (result.partial) { - toast.success("Global settings restored", { - description: "Node configs were not restored — redeploy to enable full discard.", - }); - } else { - toast.success("Changes discarded — restored to last deployed state"); - } + toast.success("Changes discarded — restored to last deployed state"); setDiscardOpen(false); }, onError: (err) => { diff --git a/src/server/routers/pipeline.ts b/src/server/routers/pipeline.ts index 18cd0ac2..c1b9457d 100644 --- a/src/server/routers/pipeline.ts +++ b/src/server/routers/pipeline.ts @@ -785,7 +785,15 @@ export const pipelineRouter = router({ if (!latestVersion) { throw new TRPCError({ code: "PRECONDITION_FAILED", message: "No deployed version found" }); } - const hasSnapshots = !!latestVersion.nodesSnapshot && !!latestVersion.edgesSnapshot; + if (!latestVersion.nodesSnapshot || !latestVersion.edgesSnapshot) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: "Deploy once more to enable discard — this version predates snapshot support", + }); + } + + const nodes = latestVersion.nodesSnapshot as Array>; + const edges = latestVersion.edgesSnapshot as Array>; await prisma.$transaction(async (tx) => { await tx.pipeline.update({ @@ -795,48 +803,43 @@ export const pipelineRouter = router({ }, }); - if (hasSnapshots) { - const nodes = latestVersion.nodesSnapshot as Array>; - const edges = latestVersion.edgesSnapshot as Array>; - - await tx.pipelineEdge.deleteMany({ where: { pipelineId: input.pipelineId } }); - await tx.pipelineNode.deleteMany({ where: { pipelineId: input.pipelineId } }); + await tx.pipelineEdge.deleteMany({ where: { pipelineId: input.pipelineId } }); + await tx.pipelineNode.deleteMany({ where: { pipelineId: input.pipelineId } }); - await Promise.all( - nodes.map((node) => - tx.pipelineNode.create({ - data: { - id: node.id as string, - pipelineId: input.pipelineId, - componentKey: node.componentKey as string, - componentType: node.componentType as string, - kind: node.kind as ComponentKind, - config: node.config as Prisma.InputJsonValue, - positionX: node.positionX as number, - positionY: node.positionY as number, - disabled: (node.disabled as boolean) ?? false, - }, - }) - ) - ); + await Promise.all( + nodes.map((node) => + tx.pipelineNode.create({ + data: { + id: node.id as string, + pipelineId: input.pipelineId, + componentKey: node.componentKey as string, + componentType: node.componentType as string, + kind: node.kind as ComponentKind, + config: node.config as Prisma.InputJsonValue, + positionX: node.positionX as number, + positionY: node.positionY as number, + disabled: (node.disabled as boolean) ?? false, + }, + }) + ) + ); - await Promise.all( - edges.map((edge) => - tx.pipelineEdge.create({ - data: { - id: edge.id as string, - pipelineId: input.pipelineId, - sourceNodeId: edge.sourceNodeId as string, - targetNodeId: edge.targetNodeId as string, - sourcePort: (edge.sourcePort as string) ?? null, - }, - }) - ) - ); - } + await Promise.all( + edges.map((edge) => + tx.pipelineEdge.create({ + data: { + id: edge.id as string, + pipelineId: input.pipelineId, + sourceNodeId: edge.sourceNodeId as string, + targetNodeId: edge.targetNodeId as string, + sourcePort: (edge.sourcePort as string) ?? null, + }, + }) + ) + ); }); - return { discarded: true, partial: !hasSnapshots }; + return { discarded: true }; }), versions: protectedProcedure