From ce9754941d9ee08100e2e35816f27a34dab9aa4b Mon Sep 17 00:00:00 2001 From: jfillman Date: Sat, 29 Aug 2026 18:53:12 -0700 Subject: [PATCH 01/37] feat(tekton): Pipeline/PipelineRun/TaskRun with embedded DAG + live progress MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tekton had zero integration despite the Helm chart already granting tekton.dev read RBAC by default. Adds detail-drawer support for Pipeline, PipelineRun, and TaskRun (topology-graph integration deliberately deferred to a later PR — this is detail-view only): - Pipeline: renders its declared task-dependency graph (parsed from spec.tasks[].runAfter plus implicit ordering inferred from $(tasks..results.*) references), reusing @xyflow/react at a small, static-diagram scale rather than a new hand-rolled SVG. - PipelineRun: the same graph with live per-task coloring, sourced from tekton.dev/v1's status.childReferences (a PipelineRun only names its child TaskRuns, not their outcome, so the host wrapper fans out one fetch per child — same pattern as CompositeRenderer's composed-resource status resolution). Deliberately DAG-only, not DAG-plus-linear-list: unlike Argo Rollouts' single sequential promotion path, Tekton tasks form a genuine DAG with parallel branches that a list would misrepresent. - TaskRun: per-step status, parameters, results. Table columns, resource icons, and docs (README + integrations.md) updated per the CRD Integration Guide's non-topology steps. --- README.md | 1 + .../files/integration-read-baseline.yaml | 7 + docs/integrations.md | 26 +++ internal/k8s/dynamic_cache.go | 3 + .../components/resources/ResourcesView.tsx | 82 +++++++++ .../curated-column-ownership.test.ts | 6 +- .../k8s-ui/src/components/resources/index.ts | 1 + .../resources/renderers/PipelineDagView.tsx | 155 +++++++++++++++++ .../resources/renderers/PipelineRenderer.tsx | 57 ++++++ .../renderers/PipelineRunRenderer.tsx | 71 ++++++++ .../resources/renderers/TaskRunRenderer.tsx | 106 ++++++++++++ .../components/resources/renderers/index.ts | 4 + .../resources/resource-utils-tekton.ts | 162 ++++++++++++++++++ .../shared/ResourceRendererDispatch.tsx | 23 +++ packages/k8s-ui/src/utils/resource-icons.ts | 6 + .../renderers/PipelineRunRenderer.tsx | 53 ++++++ web/src/components/workload/WorkloadView.tsx | 2 + 17 files changed, 762 insertions(+), 3 deletions(-) create mode 100644 packages/k8s-ui/src/components/resources/renderers/PipelineDagView.tsx create mode 100644 packages/k8s-ui/src/components/resources/renderers/PipelineRenderer.tsx create mode 100644 packages/k8s-ui/src/components/resources/renderers/PipelineRunRenderer.tsx create mode 100644 packages/k8s-ui/src/components/resources/renderers/TaskRunRenderer.tsx create mode 100644 packages/k8s-ui/src/components/resources/resource-utils-tekton.ts create mode 100644 web/src/components/resources/renderers/PipelineRunRenderer.tsx diff --git a/README.md b/README.md index 4ebe791242..e7d48bf24c 100644 --- a/README.md +++ b/README.md @@ -530,6 +530,7 @@ Upgrade impact also gets list-only access to CSIStorageCapacities, FlowSchemas, | **GitOps (ArgoCD)** | Application, ApplicationSet, AppProject | | **Argo Rollouts** | Rollout | | **Argo Workflows** | Workflow, WorkflowTemplate | +| **Tekton Pipelines** | Pipeline, PipelineRun, TaskRun | | **[Reflector](docs/integrations.md#configmap-and-secret-reflection-reflector)** | ConfigMap/Secret reflection details, source and mirror relationships, recorded copy evidence | | **cert-manager** | Certificate, CertificateRequest, Order, Challenge, Issuer, ClusterIssuer | | **Gateway API** | Gateway, GatewayClass, HTTPRoute, GRPCRoute, TCPRoute, TLSRoute | diff --git a/deploy/helm/radar/files/integration-read-baseline.yaml b/deploy/helm/radar/files/integration-read-baseline.yaml index 1bd404b76e..59b7dbc6ae 100644 --- a/deploy/helm/radar/files/integration-read-baseline.yaml +++ b/deploy/helm/radar/files/integration-read-baseline.yaml @@ -759,6 +759,13 @@ entries: decision: existing reason: "Covered by the existing cluster-read add-on and its existing collection/tier gates." source: "pkg/topology/cluster_scoped_kinds.go; existing cloud-rbac-cluster-read.yaml" + - group: "tekton.dev" + resources: ["pipelines","pipelineruns","taskruns"] + scope: Namespaced + collection: "tekton" + decision: withhold + reason: "Pending review: PipelineRun and TaskRun specs carry parameter values and inline workspace or pod-template data that have not been assessed for credentials." + source: "internal/k8s/dynamic_cache.go (supportedCRDFallbacks)" - group: "traefik.containo.us" resources: ["ingressroutes","ingressroutetcps","ingressrouteudps","middlewaretcps","serverstransports","tlsoptions","tlsstores","traefikservices"] scope: Namespaced diff --git a/docs/integrations.md b/docs/integrations.md index 8de832bf33..74b2de5a02 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -660,6 +660,32 @@ See the main [README](../README.md#gitops) for the user-facing overview. This se --- +## Tekton Pipelines + +[Tekton Pipelines](https://tekton.dev/) is a Kubernetes-native CI/CD building block: Pipelines declare a DAG of Tasks, PipelineRuns execute them, and TaskRuns carry out each individual Task. + +### What Radar Shows + +**Pipeline Detail View:** An embedded task-dependency graph — same visual language as the Topology view, scaled down to fit the drawer — built from `spec.tasks[].runAfter` plus any implicit ordering inferred from `$(tasks..results.*)` references in params or `when` expressions. Parameters and workspaces are listed alongside. + +**PipelineRun Detail View:** The same task graph, live — each node colored by its TaskRun's outcome (succeeded / failed / running / pending / skipped), read from `status.childReferences` (targets `tekton.dev/v1`; a task absent from `childReferences` hasn't been reached yet, not an error). The graph is deliberately the only progress indicator — a Pipeline's tasks form a real DAG with parallel branches, so a linear step list would misrepresent the concurrency the graph already shows. + +**TaskRun Detail View:** Per-step status (running / completed / waiting, with exit codes), parameters, and results. + +**Resource Browser:** Pipeline rows show task count; PipelineRun/TaskRun rows show status, the referenced Pipeline/Task (resolver-based refs included), and duration. + +Topology-graph integration (Pipeline/PipelineRun/TaskRun nodes in the main cluster Topology view) isn't wired up yet — this is detail-view only for now. + +### Supported CRDs + +| CRD | Group | Topology | Detail View | AI Summary | +|-----|-------|----------|-------------|------------| +| Pipeline | `tekton.dev/v1` | — | Yes | — | +| PipelineRun | `tekton.dev/v1` | — | Yes | — | +| TaskRun | `tekton.dev/v1` | — | Yes | — | + +--- + ## Istio [Istio](https://istio.io/) is the most widely adopted service mesh, providing traffic management, security (mTLS), and observability for microservices. diff --git a/internal/k8s/dynamic_cache.go b/internal/k8s/dynamic_cache.go index f0bcaf56c6..1b49c1624a 100644 --- a/internal/k8s/dynamic_cache.go +++ b/internal/k8s/dynamic_cache.go @@ -191,6 +191,9 @@ var supportedCRDFallbacks = []supportedCRDResource{ {Group: "argoproj.io", Versions: []string{"v1alpha1"}, Resource: "cronworkflows", Kind: "CronWorkflow", Namespaced: true}, {Group: "argoproj.io", Versions: []string{"v1alpha1"}, Resource: "workflowtemplates", Kind: "WorkflowTemplate", Namespaced: true}, {Group: "argoproj.io", Versions: []string{"v1alpha1"}, Resource: "clusterworkflowtemplates", Kind: "ClusterWorkflowTemplate", Namespaced: false}, + {Group: "tekton.dev", Versions: []string{"v1", "v1beta1"}, Resource: "pipelines", Kind: "Pipeline", Namespaced: true}, + {Group: "tekton.dev", Versions: []string{"v1", "v1beta1"}, Resource: "pipelineruns", Kind: "PipelineRun", Namespaced: true}, + {Group: "tekton.dev", Versions: []string{"v1", "v1beta1"}, Resource: "taskruns", Kind: "TaskRun", Namespaced: true}, {Group: "cert-manager.io", Versions: []string{"v1"}, Resource: "certificates", Kind: "Certificate", Namespaced: true}, {Group: "cert-manager.io", Versions: []string{"v1"}, Resource: "issuers", Kind: "Issuer", Namespaced: true}, {Group: "cert-manager.io", Versions: []string{"v1"}, Resource: "clusterissuers", Kind: "ClusterIssuer", Namespaced: false}, diff --git a/packages/k8s-ui/src/components/resources/ResourcesView.tsx b/packages/k8s-ui/src/components/resources/ResourcesView.tsx index b4cfafed10..d1386fac7c 100644 --- a/packages/k8s-ui/src/components/resources/ResourcesView.tsx +++ b/packages/k8s-ui/src/components/resources/ResourcesView.tsx @@ -141,6 +141,7 @@ import { SEVERITY_DOT_COLOR, healthColors, } from './resource-utils' +import { getTektonPipelineRunStatus, getTektonTaskRunStatus, tektonRefName } from './resource-utils-tekton' import { getGenericResourceStatus } from './generic-status' import { getIstioGatewayServerCount, getIstioGatewaySelectorString } from './resource-utils-istio' import { @@ -235,6 +236,7 @@ export const SKIP_FILTER_COLUMNS = new Set([ // status.summary. Listing them here stops the header reserving room for a // button that can never render. 'critical', 'high', 'medium', 'low', 'pass', 'fail', 'warn', 'error', 'skip', + 'tasks', 'pipeline', 'task', ]) // Namespace/node cardinality scales with cluster size, not kind enum size; @@ -747,6 +749,28 @@ const KNOWN_COLUMNS: Record = { { key: 'template', label: 'Template', width: 'w-40' }, { key: 'age', label: 'Age', width: 'w-24' }, ], + pipelines: [ + { key: 'name', label: 'Name' }, + { key: 'namespace', label: 'Namespace', width: 'w-48' }, + { key: 'tasks', label: 'Tasks', width: 'w-20', tooltip: 'Number of declared tasks' }, + { key: 'age', label: 'Age', width: 'w-24' }, + ], + pipelineruns: [ + { key: 'name', label: 'Name' }, + { key: 'namespace', label: 'Namespace', width: 'w-48' }, + { key: 'status', label: 'Status', width: 'w-32' }, + { key: 'pipeline', label: 'Pipeline', width: 'w-40' }, + { key: 'duration', label: 'Duration', width: 'w-24' }, + { key: 'age', label: 'Age', width: 'w-24' }, + ], + taskruns: [ + { key: 'name', label: 'Name' }, + { key: 'namespace', label: 'Namespace', width: 'w-48' }, + { key: 'status', label: 'Status', width: 'w-32' }, + { key: 'task', label: 'Task', width: 'w-40' }, + { key: 'duration', label: 'Duration', width: 'w-24' }, + { key: 'age', label: 'Age', width: 'w-24' }, + ], certificates: [ { key: 'name', label: 'Name' }, { key: 'namespace', label: 'Namespace', width: 'w-48' }, @@ -2975,6 +2999,9 @@ const CURATED_COLUMN_GROUPS: Record = { containersources: ['sources.knative.dev'], pingsources: ['sources.knative.dev'], sinkbindings: ['sources.knative.dev'], + pipelines: ['tekton.dev'], + pipelineruns: ['tekton.dev'], + taskruns: ['tekton.dev'], ingressroutes: ['traefik.io', 'traefik.containo.us'], ingressroutetcps: ['traefik.io', 'traefik.containo.us'], ingressrouteudps: ['traefik.io', 'traefik.containo.us'], @@ -7201,6 +7228,12 @@ function CellContent({ resource, kind, column, group, majorityNodeMinorVersion, return case 'cronworkflows': return + case 'pipelines': + return + case 'pipelineruns': + return + case 'taskruns': + return case 'certificates': return case 'persistentvolumes': @@ -8986,6 +9019,55 @@ function CronWorkflowCell({ resource, column }: { resource: any; column: string } } +function PipelineCell({ resource, column }: { resource: any; column: string }) { + switch (column) { + case 'tasks': + return {(resource.spec?.tasks ?? []).length} + default: + return - + } +} + +function tektonDuration(resource: any): number | null { + const start = resource.status?.startTime + if (!start) return null + const end = resource.status?.completionTime + return (end ? new Date(end).getTime() : Date.now()) - new Date(start).getTime() +} + +function PipelineRunCell({ resource, column }: { resource: any; column: string }) { + switch (column) { + case 'status': { + const status = getTektonPipelineRunStatus(resource) + return {status.text} + } + case 'pipeline': + return {tektonRefName(resource.spec?.pipelineRef)} + case 'duration': { + const ms = tektonDuration(resource) + return {ms !== null ? formatDuration(ms) : '-'} + } + default: + return - + } +} + +function TaskRunCell({ resource, column }: { resource: any; column: string }) { + switch (column) { + case 'status': { + const status = getTektonTaskRunStatus(resource) + return {status.text} + } + case 'task': + return {tektonRefName(resource.spec?.taskRef)} + case 'duration': { + const ms = tektonDuration(resource) + return {ms !== null ? formatDuration(ms) : '-'} + } + default: + return - + } +} function PersistentVolumeCell({ resource, column }: { resource: any; column: string }) { switch (column) { diff --git a/packages/k8s-ui/src/components/resources/curated-column-ownership.test.ts b/packages/k8s-ui/src/components/resources/curated-column-ownership.test.ts index 4b8b182adf..c41b464306 100644 --- a/packages/k8s-ui/src/components/resources/curated-column-ownership.test.ts +++ b/packages/k8s-ui/src/components/resources/curated-column-ownership.test.ts @@ -108,8 +108,8 @@ describe('curated column ownership', () => { // Brace-counting, not parsing: assert the extraction actually saw the whole // table, or a silently-missed key would let the guard above pass vacuously. it('extracted every curated key', () => { - expect(CURATED.length).toBe(239) - expect(OWNED.size).toBe(201) + expect(CURATED.length).toBe(242) + expect(OWNED.size).toBe(204) expect(CURATED).toContain('pods') expect(CURATED).toContain('crossplanemanagedresources') expect([...OWNED]).toContain('awsmanagedcontrolplanes') @@ -166,7 +166,7 @@ describe('every declaration resolves', () => { .map(m => [m[1], m[2].split(',').map(g => g.trim().replace(/'/g, ''))] as const) it('covers the whole ownership table', () => { - expect(DECLARED.length).toBe(201) + expect(DECLARED.length).toBe(204) }) it.each(DECLARED)('%s is curated under each group it claims', (key, groups) => { diff --git a/packages/k8s-ui/src/components/resources/index.ts b/packages/k8s-ui/src/components/resources/index.ts index f872236ff1..dd495aba74 100644 --- a/packages/k8s-ui/src/components/resources/index.ts +++ b/packages/k8s-ui/src/components/resources/index.ts @@ -51,6 +51,7 @@ export * from './resource-utils-prometheus' export * from './resource-utils-trivy' export * from './resource-utils-traefik' export * from './resource-utils-velero' +export * from './resource-utils-tekton' export { ResourcesView, ResourcesViewDataContext, hasCuratedColumns } from './ResourcesView' export type { ResourceQueryResult, ExtraColumn, LargeListGuardState } from './ResourcesView' export { ResourcesSidebar } from './ResourcesSidebar' diff --git a/packages/k8s-ui/src/components/resources/renderers/PipelineDagView.tsx b/packages/k8s-ui/src/components/resources/renderers/PipelineDagView.tsx new file mode 100644 index 0000000000..6a785f37b8 --- /dev/null +++ b/packages/k8s-ui/src/components/resources/renderers/PipelineDagView.tsx @@ -0,0 +1,155 @@ +// Embedded task-dependency DAG for a Tekton Pipeline or PipelineRun, shown +// inside the resource drawer rather than as a full-page topology. Reuses +// @xyflow/react (already loaded for the main Topology / GitOps tree views) +// at a small, fixed-height scale — a static diagram, not a pannable/zoomable +// canvas, since it lives inside a scrolling drawer rather than its own page. +// +// Layout is a hand-rolled longest-path rank/row placement (Kahn's algorithm), +// not ELK: Pipeline DAGs are shallow (a handful of ranks, rarely more than a +// dozen tasks), so pulling in ELK's async/worker layout engine for a widget +// this small would add complexity — nested-provider timing, a second layout +// pass — for no visible benefit over a synchronous longest-path placement. + +import { memo, useMemo } from 'react' +import { Handle, Position, ReactFlow, ReactFlowProvider, type Edge, type Node, type NodeProps, type NodeTypes } from '@xyflow/react' +import '@xyflow/react/dist/style.css' +import { CheckCircle2, CircleDashed, Loader2, MinusCircle, XCircle } from 'lucide-react' +import { clsx } from 'clsx' +import type { TektonTaskNode, TektonTaskNodeStatus } from '../resource-utils-tekton' + +const RANK_GAP = 190 +const ROW_GAP = 64 +const NODE_WIDTH = 152 + +const STATUS_ICON: Record = { + succeeded: CheckCircle2, + failed: XCircle, + running: Loader2, + pending: CircleDashed, + skipped: MinusCircle, + unknown: CircleDashed, +} + +const STATUS_CLASS: Record = { + succeeded: 'border-emerald-500/50 text-emerald-500', + failed: 'border-red-500/50 text-red-500', + running: 'border-sky-500/50 text-sky-500', + pending: 'border-theme-border text-theme-text-tertiary', + skipped: 'border-amber-500/50 text-amber-500', + unknown: 'border-theme-border text-theme-text-tertiary', +} + +// Longest-path ranking: a task's rank is one past the deepest dependency it +// has within this task set. Dependencies naming a task outside the set +// (shouldn't happen for a valid Pipeline, but the data is user-authored +// YAML) are ignored rather than crashing the layout. +function computeRanks(tasks: TektonTaskNode[]): Map { + const byName = new Map(tasks.map((t) => [t.name, t])) + const rank = new Map() + const visiting = new Set() + + function rankOf(name: string): number { + const cached = rank.get(name) + if (cached !== undefined) return cached + const task = byName.get(name) + if (!task) return 0 + if (visiting.has(name)) return 0 // cycle guard — Tekton rejects cyclic pipelines, but don't hang on bad data + visiting.add(name) + const deps = task.dependsOn.filter((d) => byName.has(d)) + const r = deps.length === 0 ? 0 : 1 + Math.max(...deps.map(rankOf)) + visiting.delete(name) + rank.set(name, r) + return r + } + + for (const t of tasks) rankOf(t.name) + return rank +} + +function layoutNodes(tasks: TektonTaskNode[]): { nodes: Node[]; edges: Edge[] } { + const ranks = computeRanks(tasks) + const rowByRank = new Map() + const nodes: Node[] = tasks.map((task) => { + const r = ranks.get(task.name) ?? 0 + const row = rowByRank.get(r) ?? 0 + rowByRank.set(r, row + 1) + return { + id: task.name, + type: 'tektonTask', + position: { x: r * RANK_GAP, y: row * ROW_GAP }, + data: { task }, + draggable: false, + selectable: false, + } + }) + const edges: Edge[] = tasks.flatMap((task) => + task.dependsOn + .filter((dep) => ranks.has(dep)) + .map((dep) => ({ + id: `${dep}->${task.name}`, + source: dep, + target: task.name, + type: 'smoothstep', + style: { stroke: '#64748b' }, + })), + ) + return { nodes, edges } +} + +const TektonTaskNodeView = memo(function TektonTaskNodeView({ data }: NodeProps>) { + const { task } = data + const status = task.status ?? 'unknown' + const Icon = STATUS_ICON[status] + return ( +
+ +
+ + {task.name} +
+ +
+ ) +}) + +const NODE_TYPES: NodeTypes = { tektonTask: TektonTaskNodeView } + +export interface PipelineDagViewProps { + tasks: TektonTaskNode[] + height?: number +} + +export function PipelineDagView({ tasks, height = 280 }: PipelineDagViewProps) { + const { nodes, edges } = useMemo(() => layoutNodes(tasks), [tasks]) + + if (tasks.length === 0) return null + + return ( +
+ + + +
+ ) +} diff --git a/packages/k8s-ui/src/components/resources/renderers/PipelineRenderer.tsx b/packages/k8s-ui/src/components/resources/renderers/PipelineRenderer.tsx new file mode 100644 index 0000000000..b4d2957026 --- /dev/null +++ b/packages/k8s-ui/src/components/resources/renderers/PipelineRenderer.tsx @@ -0,0 +1,57 @@ +import { GitBranch, ListChecks, Package } from 'lucide-react' +import { Section, PropertyList, Property } from '../../ui/drawer-components' +import { buildPipelineTaskGraph } from '../resource-utils-tekton' +import { PipelineDagView } from './PipelineDagView' + +interface PipelineRendererProps { + data: any +} + +// A Pipeline is a template — no run to be "in progress," no conditions of +// its own. The DAG here is the declared task graph only (every node renders +// with unknown/pending styling); see PipelineRunRenderer for the live, +// per-task-status version of the same graph. +export function PipelineRenderer({ data }: PipelineRendererProps) { + const spec = data?.spec ?? {} + const tasks = buildPipelineTaskGraph(spec) + const params = spec.params ?? [] + const workspaces = spec.workspaces ?? [] + + return ( + <> +
+ +
+ + {params.length > 0 && ( +
+ + {params.map((p: any) => ( + + ))} + +
+ )} + + {workspaces.length > 0 && ( +
+ + {workspaces.map((w: any) => ( + + ))} + +
+ )} + + {spec.description && ( +
+

{spec.description}

+
+ )} + + ) +} diff --git a/packages/k8s-ui/src/components/resources/renderers/PipelineRunRenderer.tsx b/packages/k8s-ui/src/components/resources/renderers/PipelineRunRenderer.tsx new file mode 100644 index 0000000000..74aeea039a --- /dev/null +++ b/packages/k8s-ui/src/components/resources/renderers/PipelineRunRenderer.tsx @@ -0,0 +1,71 @@ +import { GitBranch, ListChecks } from 'lucide-react' +import { Section, PropertyList, Property, AlertBanner, useOperationalIssuesShown } from '../../ui/drawer-components' +import { formatAge, formatDuration } from '../resource-utils' +import { + applyTaskRunStatuses, + buildPipelineTaskGraph, + tektonRefName, + type TektonTaskNodeStatus, +} from '../resource-utils-tekton' +import { PipelineDagView } from './PipelineDagView' + +interface PipelineRunRendererProps { + data: any + // Keyed by pipelineTaskName (not TaskRun name) — injected by the host, + // which fans out one fetch per status.childReferences entry (a + // PipelineRun's status only names its child TaskRuns, not their outcome; + // tekton.dev/v1 dropped the inline per-task status map v1beta1 had). + // Absent entirely (undefined) on the DAG-only Pipeline view; present here + // once the host resolves it, even if individual entries are still loading. + taskStatuses?: Map +} + +// Renders the DAG only — not the DAG plus a separate linear step list. +// Unlike Argo Rollouts' canary steps (one sequential promotion path, where a +// list reads naturally), Tekton tasks form a genuine DAG with parallel +// branches; a linear list would misrepresent the concurrency the graph +// already shows. Node color is the progress indicator. +export function PipelineRunRenderer({ data, taskStatuses }: PipelineRunRendererProps) { + const status = data?.status ?? {} + const conditions = status.conditions ?? [] + const succeededCond = conditions.find((c: any) => c?.type === 'Succeeded') + const operationalIssuesShown = useOperationalIssuesShown() + + // v1 embeds the Pipeline's spec.tasks into status.pipelineSpec at run + // time — no second fetch of the Pipeline object needed to draw the graph. + const pipelineSpec = status.pipelineSpec ?? {} + const declaredTasks = buildPipelineTaskGraph(pipelineSpec) + const tasks = taskStatuses ? applyTaskRunStatuses(declaredTasks, taskStatuses) : declaredTasks + + const isFailed = succeededCond?.status === 'False' + const startTime = status.startTime + const completionTime = status.completionTime + const durationMs = startTime + ? (completionTime ? new Date(completionTime).getTime() : Date.now()) - new Date(startTime).getTime() + : null + + return ( + <> + {isFailed && !operationalIssuesShown && ( + + )} + +
+ +
+ +
+ + + + {completionTime && } + {durationMs !== null && } + +
+ + ) +} diff --git a/packages/k8s-ui/src/components/resources/renderers/TaskRunRenderer.tsx b/packages/k8s-ui/src/components/resources/renderers/TaskRunRenderer.tsx new file mode 100644 index 0000000000..cbc9fbb0c6 --- /dev/null +++ b/packages/k8s-ui/src/components/resources/renderers/TaskRunRenderer.tsx @@ -0,0 +1,106 @@ +import { CheckCircle2, CircleDashed, ListChecks, Loader2, Terminal, XCircle } from 'lucide-react' +import { clsx } from 'clsx' +import { AlertBanner, PropertyList, Property, Section, useOperationalIssuesShown } from '../../ui/drawer-components' +import { formatAge, formatDuration } from '../resource-utils' +import { tektonRefName } from '../resource-utils-tekton' + +interface TaskRunRendererProps { + data: any +} + +function StepRow({ step }: { step: any }) { + const terminated = step.terminated + const running = 'running' in step + const waiting = step.waiting + const Icon = terminated ? (terminated.exitCode === 0 ? CheckCircle2 : XCircle) : running ? Loader2 : CircleDashed + const tone = terminated + ? terminated.exitCode === 0 + ? 'text-emerald-500' + : 'text-red-500' + : running + ? 'text-sky-500' + : 'text-theme-text-tertiary' + return ( +
+ +
+
{step.name}
+ {terminated && ( +
+ {terminated.reason}{terminated.exitCode !== undefined ? ` · exit ${terminated.exitCode}` : ''} +
+ )} + {waiting?.reason &&
Waiting: {waiting.reason}
} +
+
+ ) +} + +export function TaskRunRenderer({ data }: TaskRunRendererProps) { + const spec = data?.spec ?? {} + const status = data?.status ?? {} + const conditions = status.conditions ?? [] + const succeededCond = conditions.find((c: any) => c?.type === 'Succeeded') + const isFailed = succeededCond?.status === 'False' + const operationalIssuesShown = useOperationalIssuesShown() + + const steps = status.steps ?? [] + const results = status.results ?? [] + const params = spec.params ?? [] + + const startTime = status.startTime + const completionTime = status.completionTime + const durationMs = startTime + ? (completionTime ? new Date(completionTime).getTime() : Date.now()) - new Date(startTime).getTime() + : null + + return ( + <> + {isFailed && !operationalIssuesShown && ( + + )} + + {steps.length > 0 && ( +
+
+ {steps.map((step: any) => )} +
+
+ )} + +
+ + + + + {completionTime && } + {durationMs !== null && } + +
+ + {params.length > 0 && ( +
+ + {params.map((p: any) => ( + + ))} + +
+ )} + + {results.length > 0 && ( +
+ + {results.map((r: any) => ( + + ))} + +
+ )} + + ) +} diff --git a/packages/k8s-ui/src/components/resources/renderers/index.ts b/packages/k8s-ui/src/components/resources/renderers/index.ts index eb3c12c3a5..343051f0eb 100644 --- a/packages/k8s-ui/src/components/resources/renderers/index.ts +++ b/packages/k8s-ui/src/components/resources/renderers/index.ts @@ -95,6 +95,10 @@ export * from './PodDisruptionBudgetRenderer' export * from './PodRenderer' export * from './ContainerEnvironmentSection' export * from './PodMonitorRenderer' +export * from './PipelineDagView' +export * from './PipelineRenderer' +export * from './PipelineRunRenderer' +export * from './TaskRunRenderer' export * from './PriorityClassRenderer' export * from './prometheus-cells' export * from './PrometheusRuleRenderer' diff --git a/packages/k8s-ui/src/components/resources/resource-utils-tekton.ts b/packages/k8s-ui/src/components/resources/resource-utils-tekton.ts new file mode 100644 index 0000000000..9d32b58d59 --- /dev/null +++ b/packages/k8s-ui/src/components/resources/resource-utils-tekton.ts @@ -0,0 +1,162 @@ +// Tekton Pipelines CRD utility functions. +// +// Targets tekton.dev/v1 status shapes: +// - PipelineRun.status.childReferences[] (name + pipelineTaskName), not the +// older v1beta1 inline `status.taskRuns` map — a TaskRun's own status +// must be fetched separately. +// - PipelineRun.status.pipelineSpec carries the Pipeline's spec.tasks at +// run time, so a PipelineRun's DAG never needs a second fetch for the +// Pipeline object itself. + +import type { StatusBadge } from './resource-utils' +import { healthColors } from './resource-utils' + +// ============================================================================ +// STATUS UTILITIES +// ============================================================================ + +// Every Tekton run object (PipelineRun, TaskRun) reports progress through a +// single `status.conditions[].type === 'Succeeded'` condition: True/False/ +// Unknown, plus a `reason` that names *why* (Running, Failed, TaskRunTimeout, +// PipelineRunTimeout, Cancelled, ...). Unlike Flux's Ready condition, an +// Unknown status is the normal in-progress state, not an unhealthy one. +function succeededCondition(conditions: any[] | undefined): { status?: string; reason?: string; message?: string } | undefined { + return (conditions ?? []).find((c: any) => c?.type === 'Succeeded') +} + +function runStatus(conditions: any[] | undefined, noRunsYetText: string): StatusBadge { + const cond = succeededCondition(conditions) + if (!cond) return { text: noRunsYetText, color: healthColors.unknown, level: 'unknown' } + const reason = cond.reason || '' + if (cond.status === 'True') return { text: reason || 'Succeeded', color: healthColors.healthy, level: 'healthy' } + if (cond.status === 'False') { + if (reason === 'Cancelled' || reason.endsWith('Cancelled')) { + return { text: reason, color: healthColors.degraded, level: 'degraded' } + } + return { text: reason || 'Failed', color: healthColors.unhealthy, level: 'unhealthy' } + } + // Unknown: actively running unless the reason says otherwise (e.g. a + // PipelineRun/TaskRun that's been created but whose controller hasn't + // picked it up yet still reports Unknown/Started or Unknown/Pending). + return { text: reason || 'Running', color: healthColors.neutral, level: 'neutral' } +} + +// A Pipeline is a template, not a run — it carries no status.conditions of +// its own. "status" here just means "is this a well-formed, usable +// template," which in practice means it has at least one task. +export function getTektonPipelineStatus(pipeline: any): StatusBadge { + const taskCount = (pipeline?.spec?.tasks ?? []).length + if (taskCount === 0) return { text: 'Empty', color: healthColors.degraded, level: 'degraded' } + return { text: `${taskCount} task${taskCount === 1 ? '' : 's'}`, color: healthColors.healthy, level: 'healthy' } +} + +export function getTektonPipelineRunStatus(pipelineRun: any): StatusBadge { + return runStatus(pipelineRun?.status?.conditions, 'Pending') +} + +export function getTektonTaskRunStatus(taskRun: any): StatusBadge { + return runStatus(taskRun?.status?.conditions, 'Pending') +} + +// A Pipeline/Task reference is either a plain `.name`, or resolver-based +// (cluster/git/bundle resolvers), which carries the target name as a +// resolver param instead — common once a cluster moves Pipelines/Tasks out +// of raw YAML into a catalog resolved at run time. +export function tektonRefName(ref: any): string { + if (!ref) return '(inline)' + if (ref.name) return ref.name + const nameParam = (ref.params ?? []).find((p: any) => p?.name === 'name') + return nameParam?.value ? `${nameParam.value} (via ${ref.resolver} resolver)` : `(${ref.resolver ?? 'unknown'} resolver)` +} + +// ============================================================================ +// TASK DAG +// ============================================================================ + +export type TektonTaskNodeStatus = 'succeeded' | 'failed' | 'running' | 'pending' | 'skipped' | 'unknown' + +export interface TektonTaskNode { + name: string + dependsOn: string[] + status?: TektonTaskNodeStatus + reason?: string +} + +// Tekton infers task ordering two ways: an explicit `runAfter` list, and +// implicitly whenever a task consumes another task's results via +// `$(tasks..results.*)` (in params, `when` expressions, or +// `matrix.params`) — the second form is common enough (result piping without +// a redundant explicit runAfter) that reading `runAfter` alone under-counts +// real edges in the DAG. +const RESULT_REF_PATTERN = /\$\(tasks\.([a-zA-Z0-9_.-]+)\.results\./g + +function resultRefsIn(value: unknown, out: Set): void { + if (typeof value === 'string') { + for (const m of value.matchAll(RESULT_REF_PATTERN)) out.add(m[1]) + return + } + if (Array.isArray(value)) { + for (const v of value) resultRefsIn(v, out) + return + } + if (value && typeof value === 'object') { + for (const v of Object.values(value)) resultRefsIn(v, out) + } +} + +export function buildPipelineTaskGraph(pipelineSpec: any): TektonTaskNode[] { + const tasks: any[] = pipelineSpec?.tasks ?? [] + return tasks.map((task) => { + const deps = new Set(task.runAfter ?? []) + resultRefsIn(task.params, deps) + resultRefsIn(task.when, deps) + resultRefsIn(task.matrix, deps) + deps.delete(task.name) + return { name: task.name, dependsOn: [...deps] } + }) +} + +// A PipelineRun's status.childReferences names each TaskRun it created, +// keyed by pipelineTaskName — but a task the run hasn't reached yet (still +// pending, or skipped by a `when` guard) has no childReference at all, so +// absence from the map means "not started," not an error. +export interface TektonChildTaskRun { + taskRunName: string + status?: TektonTaskNodeStatus + reason?: string +} + +export function buildChildTaskRunRefs(pipelineRunStatus: any): Map { + const refs = new Map() + for (const child of pipelineRunStatus?.childReferences ?? []) { + if (child?.kind === 'TaskRun' && child?.pipelineTaskName && child?.name) { + refs.set(child.pipelineTaskName, { taskRunName: child.name }) + } + } + return refs +} + +export function tektonNodeStatusFromConditions(conditions: any[] | undefined): { status: TektonTaskNodeStatus; reason?: string } { + const cond = succeededCondition(conditions) + if (!cond) return { status: 'unknown' } + if (cond.status === 'True') return { status: 'succeeded', reason: cond.reason } + if (cond.status === 'False') { + if (cond.reason === 'ConditionCheckFailed' || cond.reason === 'Skipped') { + return { status: 'skipped', reason: cond.reason } + } + return { status: 'failed', reason: cond.reason } + } + return { status: 'running', reason: cond.reason } +} + +// Merges the Pipeline's declared task graph with live per-task status. Tasks +// with no matching child (not yet reached) render as 'pending'. +export function applyTaskRunStatuses( + tasks: TektonTaskNode[], + statusByTaskName: Map, +): TektonTaskNode[] { + return tasks.map((task) => { + const live = statusByTaskName.get(task.name) + return live ? { ...task, status: live.status, reason: live.reason } : { ...task, status: 'pending' } + }) +} diff --git a/packages/k8s-ui/src/components/shared/ResourceRendererDispatch.tsx b/packages/k8s-ui/src/components/shared/ResourceRendererDispatch.tsx index 68969915d1..361f2ee034 100644 --- a/packages/k8s-ui/src/components/shared/ResourceRendererDispatch.tsx +++ b/packages/k8s-ui/src/components/shared/ResourceRendererDispatch.tsx @@ -105,6 +105,7 @@ import { getClusterStatus as getCAPIClusterStatus, getMachineStatus, getMachineD import { getAWSMCPStatus, getAWSMMPStatus, getAWSMachineStatus, getAWSManagedClusterStatus } from '../resources/resource-utils-aws-capi' import { getGCPMCPStatus, getGCPMMPStatus, getGCPMachineStatus, getGCPManagedClusterStatus } from '../resources/resource-utils-gcp-capi' import { getAzureMCPStatus, getAzureMMPStatus, getAzureMachineStatus, getAzureManagedClusterStatus } from '../resources/resource-utils-azure-capi' +import { getTektonPipelineStatus, getTektonPipelineRunStatus, getTektonTaskRunStatus } from '../resources/resource-utils-tekton' import { PodRenderer, WorkloadRenderer, @@ -134,6 +135,9 @@ import { ExperimentRenderer, CertificateRenderer, WorkflowRenderer, + PipelineRenderer, + PipelineRunRenderer, + TaskRunRenderer, PersistentVolumeRenderer, StorageClassRenderer, CertificateRequestRenderer, @@ -290,6 +294,7 @@ import { CalicoTierRenderer, } from '../resources/renderers' import type { ComposedRefStatus } from '../resources/renderers/CompositeRenderer' +import type { TektonTaskNodeStatus } from '../resources/resource-utils-tekton' import { getCrossplaneStatus, getProviderStatus, @@ -351,6 +356,15 @@ export interface RendererOverrides { onNavigate?: (ref: ResourceRef) => void composedRefStatuses?: Map }> + // Optional override for Tekton PipelineRun — host wraps the package + // renderer to fan out one fetch per status.childReferences entry (a + // PipelineRun only names its child TaskRuns; their outcome lives on the + // TaskRun object itself, not inline). Package renderer degrades to a + // pending-styled DAG (no live status) when this isn't provided. + PipelineRunRenderer?: React.ComponentType<{ + data: any + taskStatuses?: Map + }> // CNPG Publication / Subscription: the host resolves the PostgreSQL-side // names in their spec back to the CRs that declare them. Database and // ImageCatalog get the reverse lookup, which the API only models one way. @@ -526,6 +540,8 @@ const KNOWN_KINDS = new Set([ 'providers', 'providerconfigs', 'compositeresourcedefinitions', 'compositions', 'compositionrevisions', 'functions', 'configurations', + // Tekton Pipelines + 'pipelines', 'pipelineruns', 'taskruns', ]) // Cluster topology owns resources across the core, bootstrap, control-plane, @@ -792,6 +808,7 @@ export function ResourceRendererDispatch({ const reflectionRefs = showsReflection ? [relationships?.reflection?.source, ...(relationships?.reflection?.mirrors ?? [])].filter((ref): ref is ResourceRef => !!ref) : [] const withoutReflection = (refs: ResourceRef[] | undefined) => refs?.filter(ref => !reflectionRefs.some(mirror => mirror.kind === ref.kind && mirror.namespace === ref.namespace && mirror.name === ref.name && (mirror.group ?? '') === (ref.group ?? ''))) const sidebarRelationships = showsReflection && relationships ? { ...relationships, configRefs: withoutReflection(relationships.configRefs), consumers: withoutReflection(relationships.consumers) } : relationships + const PipelineRunComp = rendererOverrides?.PipelineRunRenderer ?? PipelineRunRenderer const scaleBlockedBy = replicaScalers(relationships?.scalers) const sidebarContent = showCommonSections && ( @@ -847,6 +864,9 @@ export function ResourceRendererDispatch({ {kind === 'experiments' && isApiGroup(data?.apiVersion, 'argoproj.io') && } {kind === 'certificates' && !data?.apiVersion?.includes('networking.internal.knative.dev') && } {kind === 'workflows' && } + {kind === 'pipelines' && } + {kind === 'pipelineruns' && } + {kind === 'taskruns' && } {kind === 'persistentvolumes' && } {kind === 'storageclasses' && } {kind === 'certificaterequests' && } @@ -1172,6 +1192,9 @@ export function getResourceStatus(kind: string, data: any): { text: string; colo if (k === 'experiments' && isApiGroup(data?.apiVersion, 'argoproj.io')) return getAnalysisRunStatus(data) if (k === 'workflows') return getWorkflowStatus(data) if (k === 'cronworkflows') return getCronWorkflowStatus(data) + if (k === 'pipelines') return getTektonPipelineStatus(data) + if (k === 'pipelineruns') return getTektonPipelineRunStatus(data) + if (k === 'taskruns') return getTektonTaskRunStatus(data) if (k === 'certificates') { if (data.apiVersion?.includes('networking.internal.knative.dev')) { const status = getKnativeConditionStatus(data) diff --git a/packages/k8s-ui/src/utils/resource-icons.ts b/packages/k8s-ui/src/utils/resource-icons.ts index 96f8553099..b9dbbdfef0 100644 --- a/packages/k8s-ui/src/utils/resource-icons.ts +++ b/packages/k8s-ui/src/utils/resource-icons.ts @@ -11,6 +11,7 @@ import { Play, Timer, Boxes, + Workflow, // Networking Plug, @@ -153,6 +154,11 @@ const KIND_ICON_MAP: Record = { application: GitBranch, // ArgoCD Application applicationset: GitBranch, // ArgoCD ApplicationSet + // Tekton + pipeline: Workflow, + pipelinerun: Workflow, + taskrun: Play, + // FluxCD kustomization: Layers, // FluxCD Kustomization helmrelease: Anchor, // FluxCD HelmRelease diff --git a/web/src/components/resources/renderers/PipelineRunRenderer.tsx b/web/src/components/resources/renderers/PipelineRunRenderer.tsx new file mode 100644 index 0000000000..eb36479882 --- /dev/null +++ b/web/src/components/resources/renderers/PipelineRunRenderer.tsx @@ -0,0 +1,53 @@ +import { useMemo } from 'react' +import { useQueries } from '@tanstack/react-query' +import { PipelineRunRenderer as BasePipelineRunRenderer } from '@skyhook-io/k8s-ui/components/resources/renderers/PipelineRunRenderer' +import { + buildChildTaskRunRefs, + tektonNodeStatusFromConditions, + type TektonTaskNodeStatus, +} from '@skyhook-io/k8s-ui/components/resources/resource-utils-tekton' +import { fetchJSON } from '../../../api/client' + +interface PipelineRunRendererProps { + data: any +} + +/** + * Host wrapper for the package's PipelineRunRenderer: a PipelineRun's + * status.childReferences names each TaskRun it created but not the TaskRun's + * outcome (tekton.dev/v1 dropped v1beta1's inline per-task status map), so + * the live per-task coloring in the DAG needs one fetch per child — fanned + * out here the same way CompositeRenderer resolves composed-resource status. + */ +export function PipelineRunRenderer({ data }: PipelineRunRendererProps) { + const childRefs = useMemo(() => buildChildTaskRunRefs(data?.status), [data]) + const entries = useMemo(() => [...childRefs.entries()], [childRefs]) + + const queries = useQueries({ + queries: entries.map(([, ref]) => ({ + queryKey: ['resource', 'taskruns', data?.metadata?.namespace ?? '', ref.taskRunName, 'tekton.dev'], + queryFn: async () => + fetchJSON<{ resource: any }>( + `/resources/taskruns/${data?.metadata?.namespace ?? '_'}/${ref.taskRunName}?group=tekton.dev`, + ), + staleTime: 5000, + retry: false, + enabled: Boolean(ref.taskRunName && data?.metadata?.namespace), + })), + }) + + const taskStatuses = useMemo(() => { + const map = new Map() + entries.forEach(([pipelineTaskName], i) => { + const q = queries[i] + if (q.isLoading || !q.data) { + map.set(pipelineTaskName, { status: 'unknown' }) + return + } + map.set(pipelineTaskName, tektonNodeStatusFromConditions(q.data.resource?.status?.conditions)) + }) + return map + }, [entries, queries]) + + return +} diff --git a/web/src/components/workload/WorkloadView.tsx b/web/src/components/workload/WorkloadView.tsx index d41d80418c..9c62690fbd 100644 --- a/web/src/components/workload/WorkloadView.tsx +++ b/web/src/components/workload/WorkloadView.tsx @@ -134,6 +134,7 @@ import { CAPIClusterRenderer } from '../resources/renderers/CAPIClusterRenderer' import { HPARenderer } from '../resources/renderers/HPARenderer' import { PVCRenderer } from '../resources/renderers/PVCRenderer' import { RolloutRenderer } from '../resources/renderers/RolloutRenderer' +import { PipelineRunRenderer } from '../resources/renderers/PipelineRunRenderer' import { KyvernoPolicyCoverage } from '../resources/renderers/KyvernoPolicyCoverage' import { KyvernoPolicyQueued } from '../resources/renderers/KyvernoPolicyQueued' import { CNPGObjectStoreRenderer } from '../resources/renderers/CNPGObjectStoreRenderer' @@ -196,6 +197,7 @@ const rendererOverrides: RendererOverrides = { HPARenderer, PVCRenderer, RolloutRenderer, + PipelineRunRenderer, KyvernoPolicyCoverage, KyvernoPolicyQueued, CNPGObjectStoreRenderer, From 0bc5201a567be024a9faf140c8f3bd5e4febd102 Mon Sep 17 00:00:00 2001 From: jfillman Date: Sat, 29 Aug 2026 21:36:57 -0700 Subject: [PATCH 02/37] fix(tekton): move task DAG to full view with real topology-style nodes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Feedback from live testing on the first cut: the embedded drawer DAG had overlapping, hard-to-follow lines, tasks weren't clickable, and the graph had no business being in the compact drawer in the first place. - PipelineDagView now lays out with ELK.js (same engine + options as the main Topology view: layered/RIGHT/ORTHOGONAL/NETWORK_SIMPLEX) instead of a hand-rolled rank/row placement — NETWORK_SIMPLEX minimizes edge crossings, which is what was actually missing. Task nodes mirror K8sResourceNode's visual language (topology-node-card, icon + kind-label header, status dot) so a task reads as the same kind of thing as a real topology node, without extending the shared NodeKind enum or wiring Tekton into pkg/topology/builder.go. - The DAG moved out of the compact drawer entirely into the resource's fullscreen "full view" (TektonPipelineFullscreen, wired via renderExpandedOverview — the same seam Argo Workflows/Jobs already use for their own execution views). The compact PipelineRenderer / PipelineRunRenderer now just point at the full view instead of trying to cram a graph into a few hundred pixels. - Task nodes are clickable when they have a live TaskRun (taskRunName now flows through TektonTaskNode) and open that TaskRun's own page via onNavigateToResource — reusing the existing resource-navigation pattern instead of a bespoke panel. - TaskRun's Steps section gained per-step "Logs" buttons wired to the dock's useOpenLogs (Tekton names each step's container `step-`) — real streaming step logs, not just exit codes. - Running tasks keep the spinning Loader2 + blue coloring from the first cut (confirmed against a live run) plus a pulse on the card itself. Live-verified end-to-end against a throwaway 5-task Pipeline with a real in-flight task: clean non-overlapping layout, correct running/pending/ succeeded coloring, click-through to the TaskRun page, and real log streaming from its pod. --- .../resources/renderers/PipelineDagView.tsx | 297 +++++++++++------- .../resources/renderers/PipelineRenderer.tsx | 20 +- .../renderers/PipelineRunRenderer.tsx | 44 +-- .../resources/renderers/TaskRunRenderer.tsx | 30 +- .../resources/resource-utils-tekton.ts | 14 +- .../shared/ResourceRendererDispatch.tsx | 22 +- .../execution/TektonPipelineFullscreen.tsx | 96 ++++++ .../renderers/PipelineRunRenderer.tsx | 53 ---- .../resources/renderers/TaskRunRenderer.tsx | 29 ++ web/src/components/workload/WorkloadView.tsx | 13 +- 10 files changed, 396 insertions(+), 222 deletions(-) create mode 100644 web/src/components/execution/TektonPipelineFullscreen.tsx delete mode 100644 web/src/components/resources/renderers/PipelineRunRenderer.tsx create mode 100644 web/src/components/resources/renderers/TaskRunRenderer.tsx diff --git a/packages/k8s-ui/src/components/resources/renderers/PipelineDagView.tsx b/packages/k8s-ui/src/components/resources/renderers/PipelineDagView.tsx index 6a785f37b8..332637c13b 100644 --- a/packages/k8s-ui/src/components/resources/renderers/PipelineDagView.tsx +++ b/packages/k8s-ui/src/components/resources/renderers/PipelineDagView.tsx @@ -1,25 +1,42 @@ -// Embedded task-dependency DAG for a Tekton Pipeline or PipelineRun, shown -// inside the resource drawer rather than as a full-page topology. Reuses -// @xyflow/react (already loaded for the main Topology / GitOps tree views) -// at a small, fixed-height scale — a static diagram, not a pannable/zoomable -// canvas, since it lives inside a scrolling drawer rather than its own page. +// Task-dependency DAG for a Tekton Pipeline or PipelineRun. Lives only in +// the resource's fullscreen "full view" (wired via renderExpandedOverview in +// web/src/components/workload/WorkloadView.tsx) — never in the compact +// drawer, which has no room to render a DAG legibly. // -// Layout is a hand-rolled longest-path rank/row placement (Kahn's algorithm), -// not ELK: Pipeline DAGs are shallow (a handful of ranks, rarely more than a -// dozen tasks), so pulling in ELK's async/worker layout engine for a widget -// this small would add complexity — nested-provider timing, a second layout -// pass — for no visible benefit over a synchronous longest-path placement. - -import { memo, useMemo } from 'react' -import { Handle, Position, ReactFlow, ReactFlowProvider, type Edge, type Node, type NodeProps, type NodeTypes } from '@xyflow/react' +// Layout uses the same ELK.js engine and options as the main Topology view +// (packages/k8s-ui/src/components/topology/layout.ts: layered, RIGHT, +// ORTHOGONAL edge routing, NETWORK_SIMPLEX placement) rather than a hand- +// rolled rank/row placement — NETWORK_SIMPLEX minimizes edge crossings, +// which a same-order placement does not, and that crossing-minimization is +// exactly what was missing (overlapping, hard-to-follow lines). +// +// Node cards intentionally mirror K8sResourceNode's visual language +// (topology-node-card CSS, icon + kind-label header row, status dot) so a +// task in this DAG reads as "the same kind of thing" as a node in the real +// Topology view, without extending the shared NodeKind enum or wiring +// Tekton into pkg/topology/builder.go — that stays a separate, larger PR. + +import { memo, useEffect, useMemo, useState } from 'react' +import { + Handle, + MarkerType, + Position, + ReactFlow, + ReactFlowProvider, + type Edge, + type Node, + type NodeProps, + type NodeTypes, +} from '@xyflow/react' import '@xyflow/react/dist/style.css' -import { CheckCircle2, CircleDashed, Loader2, MinusCircle, XCircle } from 'lucide-react' +import '../../topology/topology.css' +import { CheckCircle2, CircleDashed, ListTodo, Loader2, MinusCircle, XCircle } from 'lucide-react' import { clsx } from 'clsx' +import { healthToSeverity, SEVERITY_DOT } from '../../../utils/badge-colors' import type { TektonTaskNode, TektonTaskNodeStatus } from '../resource-utils-tekton' -const RANK_GAP = 190 -const ROW_GAP = 64 -const NODE_WIDTH = 152 +const NODE_WIDTH = 220 +const NODE_HEIGHT = 66 const STATUS_ICON: Record = { succeeded: CheckCircle2, @@ -30,126 +47,186 @@ const STATUS_ICON: Record = { unknown: CircleDashed, } -const STATUS_CLASS: Record = { - succeeded: 'border-emerald-500/50 text-emerald-500', - failed: 'border-red-500/50 text-red-500', - running: 'border-sky-500/50 text-sky-500', - pending: 'border-theme-border text-theme-text-tertiary', - skipped: 'border-amber-500/50 text-amber-500', - unknown: 'border-theme-border text-theme-text-tertiary', +// TektonTaskNodeStatus -> the same HealthStatus vocabulary K8sResourceNode's +// status dot uses, so a task's dot color means the same thing a topology +// node's dot color means. +const STATUS_HEALTH: Record = { + succeeded: 'healthy', + failed: 'unhealthy', + running: 'neutral', + pending: 'unknown', + skipped: 'degraded', + unknown: 'unknown', } -// Longest-path ranking: a task's rank is one past the deepest dependency it -// has within this task set. Dependencies naming a task outside the set -// (shouldn't happen for a valid Pipeline, but the data is user-authored -// YAML) are ignored rather than crashing the layout. -function computeRanks(tasks: TektonTaskNode[]): Map { - const byName = new Map(tasks.map((t) => [t.name, t])) - const rank = new Map() - const visiting = new Set() - - function rankOf(name: string): number { - const cached = rank.get(name) - if (cached !== undefined) return cached - const task = byName.get(name) - if (!task) return 0 - if (visiting.has(name)) return 0 // cycle guard — Tekton rejects cyclic pipelines, but don't hang on bad data - visiting.add(name) - const deps = task.dependsOn.filter((d) => byName.has(d)) - const r = deps.length === 0 ? 0 : 1 + Math.max(...deps.map(rankOf)) - visiting.delete(name) - rank.set(name, r) - return r - } - - for (const t of tasks) rankOf(t.name) - return rank +const elkOptions = { + 'elk.algorithm': 'layered', + 'elk.direction': 'RIGHT', + 'elk.layered.considerModelOrder.strategy': 'NODES_AND_EDGES', + 'elk.spacing.nodeNode': '32', + 'elk.layered.spacing.nodeNodeBetweenLayers': '70', + 'elk.layered.spacing.edgeNodeBetweenLayers': '20', + 'elk.edgeRouting': 'ORTHOGONAL', + 'elk.layered.nodePlacement.strategy': 'NETWORK_SIMPLEX', } -function layoutNodes(tasks: TektonTaskNode[]): { nodes: Node[]; edges: Edge[] } { - const ranks = computeRanks(tasks) - const rowByRank = new Map() - const nodes: Node[] = tasks.map((task) => { - const r = ranks.get(task.name) ?? 0 - const row = rowByRank.get(r) ?? 0 - rowByRank.set(r, row + 1) - return { - id: task.name, - type: 'tektonTask', - position: { x: r * RANK_GAP, y: row * ROW_GAP }, - data: { task }, - draggable: false, - selectable: false, - } - }) - const edges: Edge[] = tasks.flatMap((task) => - task.dependsOn - .filter((dep) => ranks.has(dep)) - .map((dep) => ({ - id: `${dep}->${task.name}`, - source: dep, - target: task.name, - type: 'smoothstep', - style: { stroke: '#64748b' }, - })), +async function layoutNodes(tasks: TektonTaskNode[]): Promise<{ nodes: Node[]; edges: Edge[] }> { + const ELK = (await import('elkjs/lib/elk.bundled.js')).default + const elk = new ELK() + + const byName = new Set(tasks.map((t) => t.name)) + const elkEdges = tasks.flatMap((task) => + task.dependsOn.filter((dep) => byName.has(dep)).map((dep) => ({ + id: `${dep}->${task.name}`, + sources: [dep], + targets: [task.name], + })), + ) + + const result = await elk.layout({ + id: 'root', + layoutOptions: elkOptions, + children: tasks.map((t) => ({ id: t.name, width: NODE_WIDTH, height: NODE_HEIGHT })), + edges: elkEdges, + } as any) as any + + const positionByName = new Map( + (result.children ?? []).map((c: any) => [c.id, { x: c.x ?? 0, y: c.y ?? 0 }]), ) + + const nodes: Node[] = tasks.map((task) => ({ + id: task.name, + type: 'tektonTask', + position: positionByName.get(task.name) ?? { x: 0, y: 0 }, + data: { task }, + draggable: false, + selectable: true, + })) + + const edges: Edge[] = elkEdges.map((e) => ({ + id: e.id, + source: e.sources[0], + target: e.targets[0], + type: 'smoothstep', + style: { stroke: '#64748b', strokeWidth: 1.5 }, + markerEnd: { type: MarkerType.ArrowClosed, color: '#64748b', width: 16, height: 16 }, + })) + return { nodes, edges } } -const TektonTaskNodeView = memo(function TektonTaskNodeView({ data }: NodeProps>) { - const { task } = data +interface TektonTaskCardData extends Record { + task: TektonTaskNode + onClick?: (task: TektonTaskNode) => void +} + +const TektonTaskCard = memo(function TektonTaskCard({ data }: NodeProps>) { + const { task, onClick } = data const status = task.status ?? 'unknown' const Icon = STATUS_ICON[status] + const severity = healthToSeverity(STATUS_HEALTH[status]) + const clickable = Boolean(onClick && task.taskRunName) return ( -
+ <> -
- - {task.name} +
onClick?.(task) : undefined} + title={task.reason ? `${task.name} — ${task.reason}` : task.name} + > +
+
+ + Task + +
+
+ + {task.name} +
+
-
+ ) }) -const NODE_TYPES: NodeTypes = { tektonTask: TektonTaskNodeView } +const NODE_TYPES: NodeTypes = { tektonTask: TektonTaskCard } export interface PipelineDagViewProps { tasks: TektonTaskNode[] height?: number + onTaskClick?: (task: TektonTaskNode) => void } -export function PipelineDagView({ tasks, height = 280 }: PipelineDagViewProps) { - const { nodes, edges } = useMemo(() => layoutNodes(tasks), [tasks]) +export function PipelineDagView({ tasks, height, onTaskClick }: PipelineDagViewProps) { + const [layout, setLayout] = useState<{ nodes: Node[]; edges: Edge[] } | null>(null) + const taskKey = useMemo(() => tasks.map((t) => `${t.name}:${t.dependsOn.join(',')}`).join('|'), [tasks]) + + useEffect(() => { + let cancelled = false + layoutNodes(tasks).then((result) => { + if (!cancelled) setLayout(result) + }) + return () => { cancelled = true } + // Re-layout when the task set or its dependency shape changes — not on + // every status update, which would re-run ELK on every poll tick. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [taskKey]) + + // Status/taskRunName changes (live polling) update node data in place + // without re-running layout — cheap, and keeps node positions stable + // while a run progresses instead of jittering the graph every few seconds. + const nodes = useMemo(() => { + if (!layout) return [] + const byName = new Map(tasks.map((t) => [t.name, t])) + return layout.nodes.map((n) => ({ + ...n, + data: { task: byName.get(n.id) ?? (n.data as TektonTaskCardData).task, onClick: onTaskClick }, + })) + }, [layout, tasks, onTaskClick]) if (tasks.length === 0) return null return ( -
- - - +
+ {!layout ? ( +
+ Laying out task graph… +
+ ) : ( + + + + )}
) } diff --git a/packages/k8s-ui/src/components/resources/renderers/PipelineRenderer.tsx b/packages/k8s-ui/src/components/resources/renderers/PipelineRenderer.tsx index b4d2957026..4b051a9f41 100644 --- a/packages/k8s-ui/src/components/resources/renderers/PipelineRenderer.tsx +++ b/packages/k8s-ui/src/components/resources/renderers/PipelineRenderer.tsx @@ -1,26 +1,26 @@ -import { GitBranch, ListChecks, Package } from 'lucide-react' +import { ListChecks, Package } from 'lucide-react' import { Section, PropertyList, Property } from '../../ui/drawer-components' -import { buildPipelineTaskGraph } from '../resource-utils-tekton' -import { PipelineDagView } from './PipelineDagView' interface PipelineRendererProps { data: any } -// A Pipeline is a template — no run to be "in progress," no conditions of -// its own. The DAG here is the declared task graph only (every node renders -// with unknown/pending styling); see PipelineRunRenderer for the live, -// per-task-status version of the same graph. +// The task-dependency DAG lives only in the fullscreen "full view" (see +// PipelineDagView + web/src/components/execution/TektonPipelineFullscreen.tsx, +// wired via renderExpandedOverview) — the compact drawer has no room to +// render it legibly, so this overview stays graph-free. export function PipelineRenderer({ data }: PipelineRendererProps) { const spec = data?.spec ?? {} - const tasks = buildPipelineTaskGraph(spec) const params = spec.params ?? [] const workspaces = spec.workspaces ?? [] + const taskCount = (spec.tasks ?? []).length return ( <> -
- +
+ + +
{params.length > 0 && ( diff --git a/packages/k8s-ui/src/components/resources/renderers/PipelineRunRenderer.tsx b/packages/k8s-ui/src/components/resources/renderers/PipelineRunRenderer.tsx index 74aeea039a..7103d012db 100644 --- a/packages/k8s-ui/src/components/resources/renderers/PipelineRunRenderer.tsx +++ b/packages/k8s-ui/src/components/resources/renderers/PipelineRunRenderer.tsx @@ -1,41 +1,25 @@ import { GitBranch, ListChecks } from 'lucide-react' import { Section, PropertyList, Property, AlertBanner, useOperationalIssuesShown } from '../../ui/drawer-components' import { formatAge, formatDuration } from '../resource-utils' -import { - applyTaskRunStatuses, - buildPipelineTaskGraph, - tektonRefName, - type TektonTaskNodeStatus, -} from '../resource-utils-tekton' -import { PipelineDagView } from './PipelineDagView' +import { buildPipelineTaskGraph, tektonRefName } from '../resource-utils-tekton' interface PipelineRunRendererProps { data: any - // Keyed by pipelineTaskName (not TaskRun name) — injected by the host, - // which fans out one fetch per status.childReferences entry (a - // PipelineRun's status only names its child TaskRuns, not their outcome; - // tekton.dev/v1 dropped the inline per-task status map v1beta1 had). - // Absent entirely (undefined) on the DAG-only Pipeline view; present here - // once the host resolves it, even if individual entries are still loading. - taskStatuses?: Map } -// Renders the DAG only — not the DAG plus a separate linear step list. -// Unlike Argo Rollouts' canary steps (one sequential promotion path, where a -// list reads naturally), Tekton tasks form a genuine DAG with parallel -// branches; a linear list would misrepresent the concurrency the graph -// already shows. Node color is the progress indicator. -export function PipelineRunRenderer({ data, taskStatuses }: PipelineRunRendererProps) { +// The live task-progress DAG lives only in the fullscreen "full view" (see +// PipelineDagView + web/src/components/execution/TektonPipelineFullscreen.tsx, +// wired via renderExpandedOverview) — the compact drawer has no room to +// render a DAG legibly, and clicking a task there needs to open that task's +// own drawer, which the compact view can't do without stacking drawers. +export function PipelineRunRenderer({ data }: PipelineRunRendererProps) { const status = data?.status ?? {} const conditions = status.conditions ?? [] const succeededCond = conditions.find((c: any) => c?.type === 'Succeeded') const operationalIssuesShown = useOperationalIssuesShown() - // v1 embeds the Pipeline's spec.tasks into status.pipelineSpec at run - // time — no second fetch of the Pipeline object needed to draw the graph. const pipelineSpec = status.pipelineSpec ?? {} - const declaredTasks = buildPipelineTaskGraph(pipelineSpec) - const tasks = taskStatuses ? applyTaskRunStatuses(declaredTasks, taskStatuses) : declaredTasks + const taskCount = buildPipelineTaskGraph(pipelineSpec).length const isFailed = succeededCond?.status === 'False' const startTime = status.startTime @@ -54,10 +38,6 @@ export function PipelineRunRenderer({ data, taskStatuses }: PipelineRunRendererP /> )} -
- -
-
@@ -66,6 +46,14 @@ export function PipelineRunRenderer({ data, taskStatuses }: PipelineRunRendererP {durationMs !== null && }
+ + {taskCount > 0 && ( +
+

+ {taskCount} task{taskCount === 1 ? '' : 's'} — open the full view (expand icon above) for the live task graph. +

+
+ )} ) } diff --git a/packages/k8s-ui/src/components/resources/renderers/TaskRunRenderer.tsx b/packages/k8s-ui/src/components/resources/renderers/TaskRunRenderer.tsx index cbc9fbb0c6..d1c0a6f3cf 100644 --- a/packages/k8s-ui/src/components/resources/renderers/TaskRunRenderer.tsx +++ b/packages/k8s-ui/src/components/resources/renderers/TaskRunRenderer.tsx @@ -6,9 +6,19 @@ import { tektonRefName } from '../resource-utils-tekton' interface TaskRunRendererProps { data: any + // Absent when the TaskRun's pod is already gone (GC'd after completion — + // common by the time someone opens an old TaskRun) or the host doesn't + // wire log viewing for this kind. + onViewLogs?: (podName: string, containerName: string) => void } -function StepRow({ step }: { step: any }) { +// Tekton names each step's container `step-` inside the TaskRun's +// pod — stable across Tekton versions, not something the API reports back. +function stepContainerName(stepName: string): string { + return `step-${stepName}` +} + +function StepRow({ step, podName, onViewLogs }: { step: any; podName?: string; onViewLogs?: (podName: string, containerName: string) => void }) { const terminated = step.terminated const running = 'running' in step const waiting = step.waiting @@ -20,6 +30,7 @@ function StepRow({ step }: { step: any }) { : running ? 'text-sky-500' : 'text-theme-text-tertiary' + const canViewLogs = Boolean(onViewLogs && podName) return (
@@ -32,11 +43,22 @@ function StepRow({ step }: { step: any }) { )} {waiting?.reason &&
Waiting: {waiting.reason}
}
+ {canViewLogs && ( + + )}
) } -export function TaskRunRenderer({ data }: TaskRunRendererProps) { +export function TaskRunRenderer({ data, onViewLogs }: TaskRunRendererProps) { const spec = data?.spec ?? {} const status = data?.status ?? {} const conditions = status.conditions ?? [] @@ -67,7 +89,9 @@ export function TaskRunRenderer({ data }: TaskRunRendererProps) { {steps.length > 0 && (
- {steps.map((step: any) => )} + {steps.map((step: any) => ( + + ))}
)} diff --git a/packages/k8s-ui/src/components/resources/resource-utils-tekton.ts b/packages/k8s-ui/src/components/resources/resource-utils-tekton.ts index 9d32b58d59..31625d3d19 100644 --- a/packages/k8s-ui/src/components/resources/resource-utils-tekton.ts +++ b/packages/k8s-ui/src/components/resources/resource-utils-tekton.ts @@ -80,6 +80,11 @@ export interface TektonTaskNode { dependsOn: string[] status?: TektonTaskNodeStatus reason?: string + // Set once a live PipelineRun has actually created this task's TaskRun — + // absent for a Pipeline's static template graph, and for a task the run + // hasn't reached yet. Presence is what makes a DAG node clickable: nothing + // to open for a task that never ran. + taskRunName?: string } // Tekton infers task ordering two ways: an explicit `runAfter` list, and @@ -150,13 +155,16 @@ export function tektonNodeStatusFromConditions(conditions: any[] | undefined): { } // Merges the Pipeline's declared task graph with live per-task status. Tasks -// with no matching child (not yet reached) render as 'pending'. +// with no matching child (not yet reached) render as 'pending' and stay +// non-clickable (no taskRunName — there's genuinely nothing to open yet). export function applyTaskRunStatuses( tasks: TektonTaskNode[], - statusByTaskName: Map, + statusByTaskName: Map, ): TektonTaskNode[] { return tasks.map((task) => { const live = statusByTaskName.get(task.name) - return live ? { ...task, status: live.status, reason: live.reason } : { ...task, status: 'pending' } + return live + ? { ...task, status: live.status, reason: live.reason, taskRunName: live.taskRunName } + : { ...task, status: 'pending' } }) } diff --git a/packages/k8s-ui/src/components/shared/ResourceRendererDispatch.tsx b/packages/k8s-ui/src/components/shared/ResourceRendererDispatch.tsx index 361f2ee034..cf45f46414 100644 --- a/packages/k8s-ui/src/components/shared/ResourceRendererDispatch.tsx +++ b/packages/k8s-ui/src/components/shared/ResourceRendererDispatch.tsx @@ -294,7 +294,6 @@ import { CalicoTierRenderer, } from '../resources/renderers' import type { ComposedRefStatus } from '../resources/renderers/CompositeRenderer' -import type { TektonTaskNodeStatus } from '../resources/resource-utils-tekton' import { getCrossplaneStatus, getProviderStatus, @@ -356,15 +355,12 @@ export interface RendererOverrides { onNavigate?: (ref: ResourceRef) => void composedRefStatuses?: Map }> - // Optional override for Tekton PipelineRun — host wraps the package - // renderer to fan out one fetch per status.childReferences entry (a - // PipelineRun only names its child TaskRuns; their outcome lives on the - // TaskRun object itself, not inline). Package renderer degrades to a - // pending-styled DAG (no live status) when this isn't provided. - PipelineRunRenderer?: React.ComponentType<{ - data: any - taskStatuses?: Map - }> + // Optional override for Tekton TaskRun — host wires step "View Logs" + // buttons to a floating dock log tab (useOpenLogs), independent of the + // resource's own Logs tab (that tab's pod-discovery is built around + // BatchExecutionFullscreen's multi-run model, which a single TaskRun with + // exactly one pod and no run history doesn't need). + TaskRunRenderer?: React.ComponentType<{ data: any }> // CNPG Publication / Subscription: the host resolves the PostgreSQL-side // names in their spec back to the CRs that declare them. Database and // ImageCatalog get the reverse lookup, which the API only models one way. @@ -808,7 +804,7 @@ export function ResourceRendererDispatch({ const reflectionRefs = showsReflection ? [relationships?.reflection?.source, ...(relationships?.reflection?.mirrors ?? [])].filter((ref): ref is ResourceRef => !!ref) : [] const withoutReflection = (refs: ResourceRef[] | undefined) => refs?.filter(ref => !reflectionRefs.some(mirror => mirror.kind === ref.kind && mirror.namespace === ref.namespace && mirror.name === ref.name && (mirror.group ?? '') === (ref.group ?? ''))) const sidebarRelationships = showsReflection && relationships ? { ...relationships, configRefs: withoutReflection(relationships.configRefs), consumers: withoutReflection(relationships.consumers) } : relationships - const PipelineRunComp = rendererOverrides?.PipelineRunRenderer ?? PipelineRunRenderer + const TaskRunComp = rendererOverrides?.TaskRunRenderer ?? TaskRunRenderer const scaleBlockedBy = replicaScalers(relationships?.scalers) const sidebarContent = showCommonSections && ( @@ -865,8 +861,8 @@ export function ResourceRendererDispatch({ {kind === 'certificates' && !data?.apiVersion?.includes('networking.internal.knative.dev') && } {kind === 'workflows' && } {kind === 'pipelines' && } - {kind === 'pipelineruns' && } - {kind === 'taskruns' && } + {kind === 'pipelineruns' && } + {kind === 'taskruns' && } {kind === 'persistentvolumes' && } {kind === 'storageclasses' && } {kind === 'certificaterequests' && } diff --git a/web/src/components/execution/TektonPipelineFullscreen.tsx b/web/src/components/execution/TektonPipelineFullscreen.tsx new file mode 100644 index 0000000000..67b113e91a --- /dev/null +++ b/web/src/components/execution/TektonPipelineFullscreen.tsx @@ -0,0 +1,96 @@ +import { useMemo } from 'react' +import { useQueries } from '@tanstack/react-query' +import { PipelineDagView } from '@skyhook-io/k8s-ui/components/resources/renderers/PipelineDagView' +import { + applyTaskRunStatuses, + buildChildTaskRunRefs, + buildPipelineTaskGraph, + tektonNodeStatusFromConditions, + tektonRefName, + type TektonTaskNode, + type TektonTaskNodeStatus, +} from '@skyhook-io/k8s-ui/components/resources/resource-utils-tekton' +import { fetchJSON } from '../../api/client' + +interface TektonPipelineFullscreenProps { + kind: string + namespace: string + name: string + resource: any + onNavigateToResource?: (resource: { kind: string; namespace: string; name: string; group?: string }) => void +} + +// Fullscreen "full view" overview for Pipeline/PipelineRun — the DAG's real +// home (see PipelineDagView's header comment for why it's not in the compact +// drawer). Pipeline gets the static declared graph; PipelineRun fans out one +// fetch per status.childReferences entry for live per-task coloring, the +// same pattern CompositeRenderer uses for composed-resource status. +export function TektonPipelineFullscreen({ kind, namespace, name, resource, onNavigateToResource }: TektonPipelineFullscreenProps) { + const isRun = kind === 'pipelineruns' + const status = useMemo(() => resource?.status ?? {}, [resource?.status]) + const pipelineSpec = useMemo( + () => (isRun ? (status.pipelineSpec ?? {}) : (resource?.spec ?? {})), + [isRun, status, resource?.spec], + ) + const declaredTasks = useMemo(() => buildPipelineTaskGraph(pipelineSpec), [pipelineSpec]) + + const childRefs = useMemo( + () => (isRun ? buildChildTaskRunRefs(status) : new Map()), + [isRun, status], + ) + const entries = useMemo(() => [...childRefs.entries()], [childRefs]) + + const queries = useQueries({ + queries: entries.map(([, ref]) => ({ + queryKey: ['resource', 'taskruns', namespace, ref.taskRunName, 'tekton.dev'], + queryFn: async () => fetchJSON<{ resource: any }>(`/resources/taskruns/${namespace || '_'}/${ref.taskRunName}?group=tekton.dev`), + staleTime: 5000, + refetchInterval: 5000, + retry: false, + enabled: Boolean(ref.taskRunName && namespace), + })), + }) + + const tasks: TektonTaskNode[] = useMemo(() => { + if (!isRun) return declaredTasks + const statusByTaskName = new Map() + entries.forEach(([pipelineTaskName, ref], i) => { + const q = queries[i] + if (q.isLoading || !q.data) { + statusByTaskName.set(pipelineTaskName, { status: 'unknown', taskRunName: ref.taskRunName }) + return + } + const live = tektonNodeStatusFromConditions(q.data.resource?.status?.conditions) + statusByTaskName.set(pipelineTaskName, { ...live, taskRunName: ref.taskRunName }) + }) + return applyTaskRunStatuses(declaredTasks, statusByTaskName) + // queries is a fresh array each render from useQueries; entries + isLoading/data + // per-entry is what actually needs to retrigger this, which the queries objects + // capture already — omitting `queries` itself from deps would be wrong since its + // contents (not identity) are what we read, so keep it. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isRun, declaredTasks, entries, queries]) + + const pipelineRefLabel = isRun ? tektonRefName(resource?.spec?.pipelineRef) : name + + return ( +
+
+ {isRun ? ( + <>Pipeline {pipelineRefLabel} · {tasks.length} task{tasks.length === 1 ? '' : 's'} + ) : ( + <>{tasks.length} task{tasks.length === 1 ? '' : 's'} declared + )} +
+
+ { + if (!task.taskRunName || !onNavigateToResource) return + onNavigateToResource({ kind: 'taskruns', namespace, name: task.taskRunName, group: 'tekton.dev' }) + }} + /> +
+
+ ) +} diff --git a/web/src/components/resources/renderers/PipelineRunRenderer.tsx b/web/src/components/resources/renderers/PipelineRunRenderer.tsx deleted file mode 100644 index eb36479882..0000000000 --- a/web/src/components/resources/renderers/PipelineRunRenderer.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { useMemo } from 'react' -import { useQueries } from '@tanstack/react-query' -import { PipelineRunRenderer as BasePipelineRunRenderer } from '@skyhook-io/k8s-ui/components/resources/renderers/PipelineRunRenderer' -import { - buildChildTaskRunRefs, - tektonNodeStatusFromConditions, - type TektonTaskNodeStatus, -} from '@skyhook-io/k8s-ui/components/resources/resource-utils-tekton' -import { fetchJSON } from '../../../api/client' - -interface PipelineRunRendererProps { - data: any -} - -/** - * Host wrapper for the package's PipelineRunRenderer: a PipelineRun's - * status.childReferences names each TaskRun it created but not the TaskRun's - * outcome (tekton.dev/v1 dropped v1beta1's inline per-task status map), so - * the live per-task coloring in the DAG needs one fetch per child — fanned - * out here the same way CompositeRenderer resolves composed-resource status. - */ -export function PipelineRunRenderer({ data }: PipelineRunRendererProps) { - const childRefs = useMemo(() => buildChildTaskRunRefs(data?.status), [data]) - const entries = useMemo(() => [...childRefs.entries()], [childRefs]) - - const queries = useQueries({ - queries: entries.map(([, ref]) => ({ - queryKey: ['resource', 'taskruns', data?.metadata?.namespace ?? '', ref.taskRunName, 'tekton.dev'], - queryFn: async () => - fetchJSON<{ resource: any }>( - `/resources/taskruns/${data?.metadata?.namespace ?? '_'}/${ref.taskRunName}?group=tekton.dev`, - ), - staleTime: 5000, - retry: false, - enabled: Boolean(ref.taskRunName && data?.metadata?.namespace), - })), - }) - - const taskStatuses = useMemo(() => { - const map = new Map() - entries.forEach(([pipelineTaskName], i) => { - const q = queries[i] - if (q.isLoading || !q.data) { - map.set(pipelineTaskName, { status: 'unknown' }) - return - } - map.set(pipelineTaskName, tektonNodeStatusFromConditions(q.data.resource?.status?.conditions)) - }) - return map - }, [entries, queries]) - - return -} diff --git a/web/src/components/resources/renderers/TaskRunRenderer.tsx b/web/src/components/resources/renderers/TaskRunRenderer.tsx new file mode 100644 index 0000000000..c8b91835c1 --- /dev/null +++ b/web/src/components/resources/renderers/TaskRunRenderer.tsx @@ -0,0 +1,29 @@ +import { TaskRunRenderer as BaseTaskRunRenderer } from '@skyhook-io/k8s-ui/components/resources/renderers/TaskRunRenderer' +import { useOpenLogs } from '../../dock' + +interface TaskRunRendererProps { + data: any +} + +// Step logs open in a floating dock log tab (useOpenLogs) rather than the +// resource's own "Logs" tab — that tab's pod-discovery is built around +// BatchExecutionFullscreen's multi-run model (useWorkloadRuns), which a +// single TaskRun (exactly one pod, no run history) doesn't need. +export function TaskRunRenderer({ data }: TaskRunRendererProps) { + const openLogs = useOpenLogs() + const namespace = data?.metadata?.namespace ?? '' + const podName = data?.status?.podName as string | undefined + const steps = (data?.status?.steps ?? []) as Array<{ name: string }> + const containers = steps.map((s) => `step-${s.name}`) + + return ( + openLogs({ namespace, podName: pod, containers, containerName }) + : undefined + } + /> + ) +} diff --git a/web/src/components/workload/WorkloadView.tsx b/web/src/components/workload/WorkloadView.tsx index 9c62690fbd..44599a49f4 100644 --- a/web/src/components/workload/WorkloadView.tsx +++ b/web/src/components/workload/WorkloadView.tsx @@ -101,6 +101,7 @@ import { WorkloadLogsViewer } from '../logs/WorkloadLogsViewer' import { ScheduledWorkloadLogsViewer } from '../logs/ScheduledWorkloadLogsViewer' import { LogsViewer } from '../logs/LogsViewer' import { BatchExecutionFullscreen } from '../execution/BatchExecutionView' +import { TektonPipelineFullscreen } from '../execution/TektonPipelineFullscreen' import { workloadRunTimelineEvents } from '../execution/batch-timeline' import { useCanUpdateSecrets, @@ -134,7 +135,7 @@ import { CAPIClusterRenderer } from '../resources/renderers/CAPIClusterRenderer' import { HPARenderer } from '../resources/renderers/HPARenderer' import { PVCRenderer } from '../resources/renderers/PVCRenderer' import { RolloutRenderer } from '../resources/renderers/RolloutRenderer' -import { PipelineRunRenderer } from '../resources/renderers/PipelineRunRenderer' +import { TaskRunRenderer } from '../resources/renderers/TaskRunRenderer' import { KyvernoPolicyCoverage } from '../resources/renderers/KyvernoPolicyCoverage' import { KyvernoPolicyQueued } from '../resources/renderers/KyvernoPolicyQueued' import { CNPGObjectStoreRenderer } from '../resources/renderers/CNPGObjectStoreRenderer' @@ -197,7 +198,7 @@ const rendererOverrides: RendererOverrides = { HPARenderer, PVCRenderer, RolloutRenderer, - PipelineRunRenderer, + TaskRunRenderer, KyvernoPolicyCoverage, KyvernoPolicyQueued, CNPGObjectStoreRenderer, @@ -1282,6 +1283,14 @@ export function WorkloadView({ onSwitchToTimeline={() => handleTabChange('timeline')} onNavigateToResource={rest.onNavigateToResource} /> + ) : (k === 'Pipeline' || k === 'PipelineRun') && res ? ( + ) : null } renderRelatedYaml={(ref) => ( From 1e423fa9b7239ef51491df694e719f09d4563ea3 Mon Sep 17 00:00:00 2001 From: jfillman Date: Sat, 29 Aug 2026 21:37:28 -0700 Subject: [PATCH 03/37] docs(tekton): update integration docs for the full-view DAG redesign --- docs/integrations.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/integrations.md b/docs/integrations.md index 74b2de5a02..6e14527461 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -666,15 +666,15 @@ See the main [README](../README.md#gitops) for the user-facing overview. This se ### What Radar Shows -**Pipeline Detail View:** An embedded task-dependency graph — same visual language as the Topology view, scaled down to fit the drawer — built from `spec.tasks[].runAfter` plus any implicit ordering inferred from `$(tasks..results.*)` references in params or `when` expressions. Parameters and workspaces are listed alongside. +**Pipeline / PipelineRun full view:** The task-dependency graph — built from `spec.tasks[].runAfter` plus any implicit ordering inferred from `$(tasks..results.*)` references in params or `when` expressions — lives in the resource's fullscreen "full view" (the expand icon on the drawer), not the compact drawer. Layout uses the same ELK.js engine as the main Topology view, and task nodes mirror the Topology view's node cards (icon, kind label, status dot). For a PipelineRun, each node is colored live by its TaskRun's outcome (succeeded / failed / running / pending / skipped), read from `status.childReferences` (targets `tekton.dev/v1`; a task absent from `childReferences` hasn't been reached yet, not an error) — running tasks get a spinning icon and a pulsing card. The graph is deliberately the only progress indicator — a Pipeline's tasks form a real DAG with parallel branches, so a linear step list would misrepresent the concurrency the graph already shows. A task with a live TaskRun is clickable and opens that TaskRun's own page. -**PipelineRun Detail View:** The same task graph, live — each node colored by its TaskRun's outcome (succeeded / failed / running / pending / skipped), read from `status.childReferences` (targets `tekton.dev/v1`; a task absent from `childReferences` hasn't been reached yet, not an error). The graph is deliberately the only progress indicator — a Pipeline's tasks form a real DAG with parallel branches, so a linear step list would misrepresent the concurrency the graph already shows. +**Pipeline / PipelineRun compact drawer:** Run info, parameters, and workspaces only — no graph; the drawer has no room to render one legibly. A hint points to the full view for the task graph. -**TaskRun Detail View:** Per-step status (running / completed / waiting, with exit codes), parameters, and results. +**TaskRun Detail View:** Per-step status (running / completed / waiting, with exit codes) with a **Logs** button per step that streams that step's container logs (Tekton names each step's container `step-`), plus parameters and results. **Resource Browser:** Pipeline rows show task count; PipelineRun/TaskRun rows show status, the referenced Pipeline/Task (resolver-based refs included), and duration. -Topology-graph integration (Pipeline/PipelineRun/TaskRun nodes in the main cluster Topology view) isn't wired up yet — this is detail-view only for now. +Topology-graph integration (Pipeline/PipelineRun/TaskRun nodes in the main cluster Topology view) isn't wired up yet — the fullscreen task graph is self-contained and doesn't depend on it. ### Supported CRDs From cb6f302977fcccb16a09cc71caea79db9e4af327 Mon Sep 17 00:00:00 2001 From: jfillman Date: Sat, 29 Aug 2026 22:16:21 -0700 Subject: [PATCH 04/37] fix(tekton): add explicit back-link from TaskRun to its PipelineRun MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clicking a task node opens its TaskRun via the app's generic "drill into a related resource while expanded" navigation, which — by design, for every kind, not just Tekton — points the backdrop route at the new resource's own list rather than back at the resource you came from. So "Go back"/collapse lands on the bare TaskRun list, not the PipelineRun you clicked from. Rather than change that shared, heavily-used mechanism, surface an explicit way back — the same idea as GitOps's parent-lineage breadcrumb. TaskRuns always carry an ownerReference to their PipelineRun, so this needs no click-time breadcrumb param and works from any entry point (direct link, search, not just the DAG). A small "↑ Back to PipelineRun " link now sits at the top of the TaskRun view and navigates there directly. Live-verified: click a task in a PipelineRun's full view → TaskRun page → click the new link → back on the same PipelineRun's full view. --- .../resources/renderers/TaskRunRenderer.tsx | 31 +++++++++++++++++-- .../shared/ResourceRendererDispatch.tsx | 4 +-- .../resources/renderers/TaskRunRenderer.tsx | 5 ++- 3 files changed, 35 insertions(+), 5 deletions(-) diff --git a/packages/k8s-ui/src/components/resources/renderers/TaskRunRenderer.tsx b/packages/k8s-ui/src/components/resources/renderers/TaskRunRenderer.tsx index d1c0a6f3cf..b5ced42175 100644 --- a/packages/k8s-ui/src/components/resources/renderers/TaskRunRenderer.tsx +++ b/packages/k8s-ui/src/components/resources/renderers/TaskRunRenderer.tsx @@ -1,8 +1,9 @@ -import { CheckCircle2, CircleDashed, ListChecks, Loader2, Terminal, XCircle } from 'lucide-react' +import { ArrowUp, CheckCircle2, CircleDashed, ListChecks, Loader2, Terminal, XCircle } from 'lucide-react' import { clsx } from 'clsx' import { AlertBanner, PropertyList, Property, Section, useOperationalIssuesShown } from '../../ui/drawer-components' import { formatAge, formatDuration } from '../resource-utils' import { tektonRefName } from '../resource-utils-tekton' +import type { ResourceRef } from '../../../types/core' interface TaskRunRendererProps { data: any @@ -10,6 +11,20 @@ interface TaskRunRendererProps { // common by the time someone opens an old TaskRun) or the host doesn't // wire log viewing for this kind. onViewLogs?: (podName: string, containerName: string) => void + onNavigate?: (ref: ResourceRef) => void +} + +// Clicking a task node in a PipelineRun's DAG opens its TaskRun via the +// app's generic "drill into a related resource while expanded" navigation, +// which (by design, for every kind, not just Tekton) points the backdrop +// route at the TaskRun's own list rather than back at the PipelineRun — so +// browser/drawer "Go back" lands on the TaskRun list, not the run you came +// from. Rather than fight that shared mechanism, surface an explicit way +// back — same idea as GitOps's parent-lineage breadcrumb: TaskRuns always +// carry an ownerReference to their PipelineRun, so this needs no click-time +// breadcrumb param and works from any entry point (direct link, search, …). +function parentPipelineRunName(data: any): string | undefined { + return (data?.metadata?.ownerReferences ?? []).find((o: any) => o?.kind === 'PipelineRun')?.name } // Tekton names each step's container `step-` inside the TaskRun's @@ -58,13 +73,14 @@ function StepRow({ step, podName, onViewLogs }: { step: any; podName?: string; o ) } -export function TaskRunRenderer({ data, onViewLogs }: TaskRunRendererProps) { +export function TaskRunRenderer({ data, onViewLogs, onNavigate }: TaskRunRendererProps) { const spec = data?.spec ?? {} const status = data?.status ?? {} const conditions = status.conditions ?? [] const succeededCond = conditions.find((c: any) => c?.type === 'Succeeded') const isFailed = succeededCond?.status === 'False' const operationalIssuesShown = useOperationalIssuesShown() + const pipelineRunName = parentPipelineRunName(data) const steps = status.steps ?? [] const results = status.results ?? [] @@ -78,6 +94,17 @@ export function TaskRunRenderer({ data, onViewLogs }: TaskRunRendererProps) { return ( <> + {pipelineRunName && onNavigate && ( + + )} + {isFailed && !operationalIssuesShown && ( + TaskRunRenderer?: React.ComponentType<{ data: any; onNavigate?: (ref: ResourceRef) => void }> // CNPG Publication / Subscription: the host resolves the PostgreSQL-side // names in their spec back to the CRs that declare them. Database and // ImageCatalog get the reverse lookup, which the API only models one way. @@ -862,7 +862,7 @@ export function ResourceRendererDispatch({ {kind === 'workflows' && } {kind === 'pipelines' && } {kind === 'pipelineruns' && } - {kind === 'taskruns' && } + {kind === 'taskruns' && } {kind === 'persistentvolumes' && } {kind === 'storageclasses' && } {kind === 'certificaterequests' && } diff --git a/web/src/components/resources/renderers/TaskRunRenderer.tsx b/web/src/components/resources/renderers/TaskRunRenderer.tsx index c8b91835c1..47a99e7989 100644 --- a/web/src/components/resources/renderers/TaskRunRenderer.tsx +++ b/web/src/components/resources/renderers/TaskRunRenderer.tsx @@ -1,15 +1,17 @@ import { TaskRunRenderer as BaseTaskRunRenderer } from '@skyhook-io/k8s-ui/components/resources/renderers/TaskRunRenderer' import { useOpenLogs } from '../../dock' +import type { ResourceRef } from '../../../types' interface TaskRunRendererProps { data: any + onNavigate?: (ref: ResourceRef) => void } // Step logs open in a floating dock log tab (useOpenLogs) rather than the // resource's own "Logs" tab — that tab's pod-discovery is built around // BatchExecutionFullscreen's multi-run model (useWorkloadRuns), which a // single TaskRun (exactly one pod, no run history) doesn't need. -export function TaskRunRenderer({ data }: TaskRunRendererProps) { +export function TaskRunRenderer({ data, onNavigate }: TaskRunRendererProps) { const openLogs = useOpenLogs() const namespace = data?.metadata?.namespace ?? '' const podName = data?.status?.podName as string | undefined @@ -19,6 +21,7 @@ export function TaskRunRenderer({ data }: TaskRunRendererProps) { return ( openLogs({ namespace, podName: pod, containers, containerName }) From c3bee3aea1269d399f53938ca9c88b2b077fad88 Mon Sep 17 00:00:00 2001 From: jfillman Date: Sat, 29 Aug 2026 22:29:14 -0700 Subject: [PATCH 05/37] fix(tekton): sequential combined step logs + pod link on TaskRun MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two issues from live testing: 1. The TaskRun's Logs tab fell through to the generic MultiPodLogsTab (a pod-picker built for N-pod workloads), rendering a row of pod bubbles for a resource that only ever has one pod. A TaskRun's real shape is one pod with one container per step (Tekton names them `step-`), running sequentially — the opposite of what that picker is for. Added TaskRunLogsTab: fetches every step's container in one call to the existing /api/pods/{ns}/{pod}/logs endpoint (already returns all containers when no `container` param is given), splits each into per-line entries client-side, and combines them into one sequential LogCore view in declared step order. Each line is labeled by step via a new `showContainerName` prop on LogCore, mirroring the existing `showPodName` used by the multi-pod viewer — same pattern, other axis. 2. Run Info's "Pod" field was plain text. It's now a ResourceLink (the same component GitOps/RBAC use for cross-resource links) into the pod itself. Live-verified against a real 3-step TaskRun: Logs tab shows all three steps' output concatenated in order with real timestamps, no pod-picker; Pod field navigates to the Pod's own page. --- .../k8s-ui/src/components/logs/LogCore.tsx | 15 ++++ .../resources/renderers/TaskRunRenderer.tsx | 9 +- web/src/components/logs/TaskRunLogsTab.tsx | 88 +++++++++++++++++++ web/src/components/workload/WorkloadView.tsx | 9 ++ 4 files changed, 119 insertions(+), 2 deletions(-) create mode 100644 web/src/components/logs/TaskRunLogsTab.tsx diff --git a/packages/k8s-ui/src/components/logs/LogCore.tsx b/packages/k8s-ui/src/components/logs/LogCore.tsx index 38b753b29a..516839faa8 100644 --- a/packages/k8s-ui/src/components/logs/LogCore.tsx +++ b/packages/k8s-ui/src/components/logs/LogCore.tsx @@ -58,6 +58,8 @@ interface LogCoreProps { onClear?: () => void toolbarExtra?: ToolbarExtraRenderer showPodName?: boolean + /** Like showPodName, but labels each line with its source container instead — for a single-pod, multi-container combined view (e.g. a Tekton TaskRun's steps). */ + showContainerName?: boolean emptyMessage?: string emptyCommand?: string | null errorMessage?: string | null @@ -145,6 +147,7 @@ export function LogCore({ onClear, toolbarExtra, showPodName = false, + showContainerName = false, emptyMessage = 'No logs available', emptyCommand, errorMessage, @@ -1032,6 +1035,7 @@ export function LogCore({ searchIsRegex={search.isRegex} searchIsCaseSensitive={search.isCaseSensitive} showPodName={showPodName} + showContainerName={showContainerName} showTimestamp={showTimestamps} tsFormat={tsFormat} ansiEnabled={ansiEnabled} @@ -1144,6 +1148,7 @@ interface LogLineProps { searchIsRegex: boolean searchIsCaseSensitive: boolean showPodName: boolean + showContainerName: boolean showTimestamp: boolean tsFormat: TimestampFormat ansiEnabled: boolean @@ -1165,6 +1170,7 @@ function LogLine({ searchIsRegex, searchIsCaseSensitive, showPodName, + showContainerName, showTimestamp, tsFormat, ansiEnabled, @@ -1244,6 +1250,14 @@ function LogLine({ [{entry.sourceLabel || entry.pod.split('-').slice(-2).join('-')}] )} + {showContainerName && entry.container && ( + + [{entry.container}] + + )} {contentElement} - )} - {isFailed && !operationalIssuesShown && ( gitOpsRouteForResource(resource), [resource]) + // A Tekton TaskRun always carries an ownerReference to its PipelineRun — + // surface it as a header chip (same idea as "Open in GitOps") rather than + // relying on browser/drawer "Go back", which (for every kind, not just + // this one) points back at the new resource's own list, not the resource + // you navigated from. + const tektonParentPipelineRun = useMemo(() => { + // `kind` (URL-derived, not resource.kind — list items often arrive with + // TypeMeta stripped) is the reliable signal here. + if (kind !== 'TaskRun') return null + const owner = (resource?.metadata?.ownerReferences ?? []).find((o: any) => o?.kind === 'PipelineRun') + return owner?.name ? { name: owner.name as string, namespace: resource?.metadata?.namespace ?? '' } : null + }, [kind, resource]) // Copy to clipboard const copyToClipboard = useCallback((text: string, key: string) => { @@ -890,13 +902,19 @@ export function WorkloadView({

{namespace}

- {(gitopsOwner || helmOwner || (gitOpsResourcePath && onNavigateGitOpsPath)) && ( + {(gitopsOwner || helmOwner || (gitOpsResourcePath && onNavigateGitOpsPath) || tektonParentPipelineRun) && (
{gitopsOwner && } {helmOwner && } {gitOpsResourcePath && onNavigateGitOpsPath && ( onNavigateGitOpsPath(gitOpsResourcePath)} /> )} + {tektonParentPipelineRun && onNavigateToResource && ( + onNavigateToResource({ kind: 'pipelineruns', namespace: tektonParentPipelineRun.namespace, name: tektonParentPipelineRun.name, group: 'tekton.dev' })} + /> + )}
)} @@ -1047,6 +1065,12 @@ export function WorkloadView({ {gitOpsResourcePath && onNavigateGitOpsPath && ( onNavigateGitOpsPath(gitOpsResourcePath)} /> )} + {tektonParentPipelineRun && onNavigateToResource && ( + onNavigateToResource({ kind: 'pipelineruns', namespace: tektonParentPipelineRun.namespace, name: tektonParentPipelineRun.name, group: 'tekton.dev' })} + /> + )} {relationships?.owner && !showOwnershipHeading && ( Owner: )} @@ -1350,6 +1374,21 @@ function OpenInGitOpsChip({ onClick }: { onClick: () => void }) { ) } +function BackToPipelineRunChip({ name, onClick }: { name: string; onClick: () => void }) { + return ( + + + + ) +} + function OwnershipHeading({ podName, context, From be2eb261d56688b52332b1f67376f203bbc8391e Mon Sep 17 00:00:00 2001 From: jfillman Date: Sat, 29 Aug 2026 23:26:59 -0700 Subject: [PATCH 07/37] fix(tekton): widen the step-name label in combined TaskRun logs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step names were truncating at 140px — too tight for real pipelines whose step names run 20+ characters (e.g. "step-start-build-stage-span"). --- packages/k8s-ui/src/components/logs/LogCore.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/k8s-ui/src/components/logs/LogCore.tsx b/packages/k8s-ui/src/components/logs/LogCore.tsx index 516839faa8..77fafbd422 100644 --- a/packages/k8s-ui/src/components/logs/LogCore.tsx +++ b/packages/k8s-ui/src/components/logs/LogCore.tsx @@ -1252,7 +1252,7 @@ function LogLine({ )} {showContainerName && entry.container && ( [{entry.container}] From 9ee91ea702e6ff3f00875321b464b266e3029abf Mon Sep 17 00:00:00 2001 From: jfillman Date: Sat, 29 Aug 2026 23:27:32 -0700 Subject: [PATCH 08/37] =?UTF-8?q?feat(tekton):=20add=20a=20CI/CD=20tab=20?= =?UTF-8?q?=E2=80=94=20PipelineRun=20fleet=20view?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A dedicated top-level view (new left-rail entry, /cicd) for operating on Tekton PipelineRuns as a fleet, rather than only reachable one at a time through the generic Resources browser: - Header stat tiles (Total/Running/Succeeded/Failed/Success Rate, plus a Tasks Running count from TaskRuns) — each status tile doubles as a one-click filter, same pattern as GitOps's fleet header. - Status and Pipeline facets in a filter rail, plus a search box, all computed client-side from the already-cached PipelineRun list (no new backend endpoints). - Rich table rows: status badge, referenced Pipeline, a progress bar keyed off status.childReferences vs declared task count (colored by outcome, pulsing while running), duration, and age. The bar's denominator uses whichever of {declared, started} is larger so a `matrix`-strategy task (one declared task expanding into several childReferences) never renders as a backwards fraction like "12/7". - Clicking a row opens the PipelineRun directly in the expanded full view (skipping the collapsed drawer, since the DAG only renders there) via the same navigateToResource-records-the-peek-owner mechanism GitOps and Applications already use — so the header's "Go back"/collapse control returns to the CI/CD tab instead of the generic PipelineRuns list. Live-verified end-to-end: stat tiles and facets against 5 real PipelineRuns, row click opening a running PipelineRun straight into its live DAG, and back-navigation returning to /cicd. --- web/src/App.tsx | 27 +- web/src/components/cicd/CicdView.tsx | 323 ++++++++++++++++++++++ web/src/components/nav/PrimaryNavRail.tsx | 5 +- web/src/components/ui/command-items.ts | 3 +- 4 files changed, 353 insertions(+), 5 deletions(-) create mode 100644 web/src/components/cicd/CicdView.tsx diff --git a/web/src/App.tsx b/web/src/App.tsx index 449ad2451c..23ebe8fd69 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -27,6 +27,7 @@ import { CapacityView } from './components/capacity/CapacityView' import { AuditView } from './components/audit/AuditView' import { IssuesPane } from './components/issues/IssuesPane' import { GitOpsView } from './components/gitops/GitOpsView' +import { CicdView } from './components/cicd/CicdView' import { ApplicationsView } from './components/applications/ApplicationsView' import { HelmReleaseDrawer } from './components/helm/HelmReleaseDrawer' import { PortForwardProvider, PortForwardIndicator, PortForwardPanel } from './components/portforward/PortForwardManager' @@ -125,7 +126,7 @@ const FLEET_MODE_KINDS = new Set([ // Convert API resource name back to topology node ID prefix // Extended MainView type that includes traffic and cost -type ExtendedMainView = MainView | 'traffic' | 'cost' | 'capacity' | 'workload' | 'checks' | 'gitops' | 'compare' | 'helmCompare' | 'issues' | 'applications' | 'investigations' +type ExtendedMainView = MainView | 'traffic' | 'cost' | 'capacity' | 'workload' | 'checks' | 'gitops' | 'compare' | 'helmCompare' | 'issues' | 'applications' | 'investigations' | 'cicd' // Extract view from URL path function getViewFromPath(pathname: string): ExtendedMainView { @@ -142,6 +143,7 @@ function getViewFromPath(pathname: string): ExtendedMainView { if (path === 'workload') return 'workload' if (path === 'checks' || path === 'audit') return 'checks' // /audit = legacy → checks if (path === 'gitops') return 'gitops' + if (path === 'cicd') return 'cicd' if (path === 'applications') return 'applications' if (path === 'compare') return 'compare' if (path === 'issues') return 'issues' @@ -167,7 +169,7 @@ const CRASH_LABELS: Record = { home: 'Home', topology: 'Topology', resources: 'Resources', timeline: 'Timeline', issues: 'Issues', helm: 'Helm', helmCompare: 'HelmCompare', traffic: 'Traffic', cost: 'Cost', capacity: 'Capacity', checks: 'Checks', gitops: 'GitOps', - applications: 'Applications', workload: 'Workload', compare: 'Compare', + applications: 'Applications', cicd: 'CI/CD', workload: 'Workload', compare: 'Compare', investigations: 'Investigations', } @@ -283,6 +285,7 @@ function radarPageTitle(pathname: string, search = '', apiResources?: APIResourc if (pathSegments[1] === 'activity') return 'Capacity Activity' } + if (view === 'cicd') return 'CI/CD' if (view === 'home') return 'Overview' // Every other view's label is its id capitalized — getViewFromPath has already // normalized aliases (e.g. /audit → 'checks'), so no lookup table is needed. @@ -873,7 +876,7 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL const VIEW_SHORTCUT_KEYS: Record = { home: 'g h', resources: 'g r', issues: 'g i', topology: 'g t', applications: 'g a', timeline: 'g l', traffic: 'g f', helm: 'g m', - gitops: 'g o', checks: 'g u', cost: 'g c', capacity: 'g p', + gitops: 'g o', checks: 'g u', cost: 'g c', capacity: 'g p', cicd: 'g d', // Non-rail views (reachable via deep links / actions, not the rail) get no // dedicated mnemonic — listed for exhaustiveness so the type stays total. workload: '', compare: '', helmCompare: '', investigations: '', @@ -2290,6 +2293,24 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL /> )} + {/* CI/CD view — Tekton PipelineRun fleet: stats, filters, and a row + click that opens straight into the expanded drawer (the DAG only + renders there, not the compact drawer — see PipelineDagView). */} + {mainView === 'cicd' && ( + { + // Same navigateToResource-records-the-peek-owner mechanism GitOps + // uses above, so the header's "Go back"/collapse returns here + // instead of orphaning on /resources/pipelineruns. + navigateToResource({ kind: 'pipelineruns', namespace, name, group: 'tekton.dev' }) + const params = new URLSearchParams(window.location.search) + params.set('full', '1') + setSearchParams(params) + }} + /> + )} + {/* Traffic view */} {!viewsSyncGated && mainView === 'traffic' && ( diff --git a/web/src/components/cicd/CicdView.tsx b/web/src/components/cicd/CicdView.tsx new file mode 100644 index 0000000000..2ce9f403e9 --- /dev/null +++ b/web/src/components/cicd/CicdView.tsx @@ -0,0 +1,323 @@ +import { useMemo, useState } from 'react' +import { useQuery } from '@tanstack/react-query' +import { Search, Workflow } from 'lucide-react' +import { clsx } from 'clsx' +import { + SummaryTile, + Facet, + Input, + getTektonPipelineRunStatus, + tektonRefName, + formatAge, + formatDuration, + type SummaryTone, + Tooltip, +} from '@skyhook-io/k8s-ui' +import { fetchJSON } from '../../api/client' + +interface CicdViewProps { + namespaces: string[] + onOpenPipelineRun: (ref: { namespace: string; name: string }) => void +} + +type StatusFacet = 'running' | 'succeeded' | 'failed' | 'cancelled' | 'pending' + +// Same bucketing as getTektonPipelineRunStatus's `level`, but named for the +// filter UI rather than the badge — cancelled reads as `degraded` there, +// which is correct as a health signal but a confusing filter label. +function statusFacet(run: any): StatusFacet { + const cond = (run?.status?.conditions ?? []).find((c: any) => c?.type === 'Succeeded') + if (!cond) return 'pending' + if (cond.status === 'True') return 'succeeded' + if (cond.status === 'False') { + const reason = cond.reason || '' + return reason === 'Cancelled' || reason.endsWith('Cancelled') ? 'cancelled' : 'failed' + } + return 'running' +} + +const STATUS_LABEL: Record = { + running: 'Running', + succeeded: 'Succeeded', + failed: 'Failed', + cancelled: 'Cancelled', + pending: 'Pending', +} +const STATUS_TONE: Record = { + running: 'info', + succeeded: 'success', + failed: 'error', + cancelled: 'warning', + pending: 'neutral', +} + +function runStartTime(run: any): number { + const t = run?.status?.startTime + return t ? new Date(t).getTime() : 0 +} + +function runDurationMs(run: any): number | null { + const start = run?.status?.startTime + if (!start) return null + const end = run?.status?.completionTime + return (end ? new Date(end).getTime() : Date.now()) - new Date(start).getTime() +} + +// Fraction of declared tasks that have at least started (status.childReferences +// only tells us "a TaskRun was created for this task," not its outcome — an +// honest "how far did it get" proxy, not "how much succeeded"). +function taskProgress(run: any): { started: number; total: number } { + const total = (run?.status?.pipelineSpec?.tasks ?? []).length + const started = (run?.status?.childReferences ?? []).length + return { started, total } +} + +function ProgressBar({ run, facet }: { run: any; facet: StatusFacet }) { + const { started, total } = taskProgress(run) + // A `matrix`-strategy task expands into several childReferences from one + // declared pipeline task, so `started` can legitimately exceed `total` — + // display against whichever is larger so the fraction never reads backwards + // (e.g. "12/7"), without pretending the declared count was actually 12. + const displayTotal = Math.max(total, started) + const fraction = displayTotal > 0 ? Math.min(1, started / displayTotal) : facet === 'succeeded' ? 1 : 0 + const fillClass = { + running: 'bg-sky-500', + succeeded: 'bg-emerald-500', + failed: 'bg-red-500', + cancelled: 'bg-amber-500', + pending: 'bg-theme-text-tertiary', + }[facet] + return ( +
+
+
0 ? 4 : 0)}%` }} + /> +
+ {displayTotal > 0 && ( + total + ? `${total} declared task${total === 1 ? '' : 's'} expanded into ${started} runs (matrix strategy) — tasks started, not necessarily succeeded` + : 'Tasks started, not necessarily succeeded' + } + delay={400} + > + + {started}/{displayTotal} + + + )} +
+ ) +} + +export function CicdView({ namespaces, onOpenPipelineRun }: CicdViewProps) { + const namespacesParam = namespaces.join(',') + const [search, setSearch] = useState('') + const [statusFilter, setStatusFilter] = useState>(new Set()) + const [pipelineFilter, setPipelineFilter] = useState>(new Set()) + + const runsQuery = useQuery({ + queryKey: ['cicd-pipelineruns', namespacesParam], + queryFn: async () => { + const params = new URLSearchParams({ group: 'tekton.dev' }) + if (namespacesParam) params.set('namespaces', namespacesParam) + return fetchJSON(`/resources/pipelineruns?${params}`) + }, + staleTime: 10_000, + refetchInterval: 15_000, + }) + + const tasksRunningQuery = useQuery({ + queryKey: ['cicd-taskruns-running', namespacesParam], + queryFn: async () => { + const params = new URLSearchParams({ group: 'tekton.dev' }) + if (namespacesParam) params.set('namespaces', namespacesParam) + const all = await fetchJSON(`/resources/taskruns?${params}`) + return all.filter((t) => { + const cond = (t?.status?.conditions ?? []).find((c: any) => c?.type === 'Succeeded') + return cond?.status === 'Unknown' + }).length + }, + staleTime: 10_000, + refetchInterval: 15_000, + }) + + const runs = useMemo(() => runsQuery.data ?? [], [runsQuery.data]) + + const stats = useMemo(() => { + const counts: Record = { running: 0, succeeded: 0, failed: 0, cancelled: 0, pending: 0 } + for (const r of runs) counts[statusFacet(r)]++ + const finished = counts.succeeded + counts.failed + const successRate = finished > 0 ? Math.round((counts.succeeded / finished) * 100) : null + return { counts, total: runs.length, successRate } + }, [runs]) + + const pipelineCounts = useMemo(() => { + const m = new Map() + for (const r of runs) { + const name = tektonRefName(r?.spec?.pipelineRef) + m.set(name, (m.get(name) ?? 0) + 1) + } + return m + }, [runs]) + + const filteredRuns = useMemo(() => { + const q = search.trim().toLowerCase() + const rows = runs.filter((r) => { + if (statusFilter.size > 0 && !statusFilter.has(statusFacet(r))) return false + const pipelineName = tektonRefName(r?.spec?.pipelineRef) + if (pipelineFilter.size > 0 && !pipelineFilter.has(pipelineName)) return false + if (q) { + const haystack = `${r?.metadata?.name ?? ''} ${r?.metadata?.namespace ?? ''} ${pipelineName}`.toLowerCase() + if (!haystack.includes(q)) return false + } + return true + }) + return [...rows].sort((a, b) => runStartTime(b) - runStartTime(a)) + }, [runs, search, statusFilter, pipelineFilter]) + + const toggleStatus = (v: StatusFacet) => setStatusFilter((prev) => { + const next = new Set(prev) + if (next.has(v)) next.delete(v) + else next.add(v) + return next + }) + const togglePipeline = (v: string) => setPipelineFilter((prev) => { + const next = new Set(prev) + if (next.has(v)) next.delete(v) + else next.add(v) + return next + }) + + return ( +
+
+
+ +

CI/CD

+
+
+ + toggleStatus('running')} + /> + toggleStatus('succeeded')} + /> + toggleStatus('failed')} + /> + = 90 ? 'success' : stats.successRate >= 70 ? 'warning' : 'error') as SummaryTone} + loading={runsQuery.isLoading} + /> + +
+
+ +
+ + +
+
+
+ + setSearch(e.target.value)} + placeholder="Search PipelineRuns, namespaces, pipelines..." + className="h-8 w-full rounded-md border border-theme-border bg-theme-base pl-8 pr-3 text-sm text-theme-text-primary placeholder:text-theme-text-tertiary focus:outline-none focus:ring-1 focus:ring-blue-500/50" + /> +
+
+ +
+ {runsQuery.isLoading ? ( +
Loading PipelineRuns…
+ ) : filteredRuns.length === 0 ? ( +
+ {runs.length === 0 ? 'No PipelineRuns found.' : 'No PipelineRuns match the current filters.'} +
+ ) : ( +
+ {filteredRuns.map((run) => { + const facet = statusFacet(run) + const status = getTektonPipelineRunStatus(run) + const durationMs = runDurationMs(run) + const name = run.metadata?.name ?? '' + const namespace = run.metadata?.namespace ?? '' + return ( + + ) + })} +
+ )} +
+
+
+
+ ) +} diff --git a/web/src/components/nav/PrimaryNavRail.tsx b/web/src/components/nav/PrimaryNavRail.tsx index 81bcac037f..3a8121e2ff 100644 --- a/web/src/components/nav/PrimaryNavRail.tsx +++ b/web/src/components/nav/PrimaryNavRail.tsx @@ -8,6 +8,7 @@ import { AlertTriangle, Package, GitBranch, + Workflow, Boxes, Activity, Coins, @@ -36,7 +37,8 @@ type NavRailView = | "applications" | "cost" | "capacity" - | "checks"; + | "checks" + | "cicd"; // Primary left nav rail for standalone (non-embedded) Radar. // @@ -76,6 +78,7 @@ const NAV_ITEMS: NavItemDef[] = [ { view: "traffic", icon: Activity, label: "Live Traffic" }, { view: "helm", icon: Package, label: "Helm" }, { view: "gitops", icon: GitBranch, label: "GitOps" }, + { view: "cicd", icon: Workflow, label: "CI/CD" }, { view: "checks", icon: ShieldCheck, label: "Checks" }, { view: "capacity", icon: Gauge, label: "Capacity" }, { view: "cost", icon: Coins, label: "Cost" }, diff --git a/web/src/components/ui/command-items.ts b/web/src/components/ui/command-items.ts index 9ded5d4e16..9f9a53abf0 100644 --- a/web/src/components/ui/command-items.ts +++ b/web/src/components/ui/command-items.ts @@ -34,7 +34,8 @@ export type MainView = | "capacity" | "checks" | "gitops" - | "applications"; + | "applications" + | "cicd"; export interface CommandItem { id: string; From 3b4224c433318b63dd5a7b4dc9a9d67329aa72b9 Mon Sep 17 00:00:00 2001 From: jfillman Date: Sun, 30 Aug 2026 00:01:24 -0700 Subject: [PATCH 09/37] fix(tekton): stop the DAG canvas from panning off-screen on scroll MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit panOnScroll + zoomOnScroll were both enabled with no translateExtent bound, so an ordinary wheel/trackpad scroll over the panel (which has no page scroll of its own) silently panned the graph's nodes off- screen — reported as "the diagram appears, then blanks out". Drop panOnScroll to match the other two ReactFlow views in this codebase (TopologyGraph, GitOpsTreeGraph), which rely on the zoom-on-scroll default with no pan-on-scroll, and bound min/max zoom like GitOpsTreeGraph. Also adds the Controls (zoom in/out/fit-to-screen). --- .../components/resources/renderers/PipelineDagView.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/k8s-ui/src/components/resources/renderers/PipelineDagView.tsx b/packages/k8s-ui/src/components/resources/renderers/PipelineDagView.tsx index 332637c13b..f112646124 100644 --- a/packages/k8s-ui/src/components/resources/renderers/PipelineDagView.tsx +++ b/packages/k8s-ui/src/components/resources/renderers/PipelineDagView.tsx @@ -18,6 +18,7 @@ import { memo, useEffect, useMemo, useState } from 'react' import { + Controls, Handle, MarkerType, Position, @@ -219,12 +220,15 @@ export function PipelineDagView({ tasks, height, onTaskClick }: PipelineDagViewP nodesDraggable={false} nodesConnectable={false} elementsSelectable={false} - panOnScroll + minZoom={0.15} + maxZoom={1.5} zoomOnScroll zoomOnPinch zoomOnDoubleClick={false} proOptions={{ hideAttribution: true }} - /> + > + + )}
From b36b303bee4d3942599175cbf3c8ad543e57dfc7 Mon Sep 17 00:00:00 2001 From: jfillman Date: Sun, 30 Aug 2026 00:01:24 -0700 Subject: [PATCH 10/37] feat(tekton): sortable CI/CD table + GitOps-style header - Column headers are now clickable and sort the PipelineRun table (name, status, pipeline, progress, duration, started). - Header restyled to match GitOps: PageHeader (left-aligned title + description, right-aligned FreshnessControl + stat tiles) instead of a hand-rolled title row. - Filter sidebar gets a "Filters" header with a Clear link, matching GitOpsFilterSidebar. - Root layout gets flex-1 min-w-0, fixing a right-edge gap where the view stopped short of the window width instead of filling it. --- web/src/components/cicd/CicdView.tsx | 263 ++++++++++++++++++++------- 1 file changed, 195 insertions(+), 68 deletions(-) diff --git a/web/src/components/cicd/CicdView.tsx b/web/src/components/cicd/CicdView.tsx index 2ce9f403e9..f23c8e3125 100644 --- a/web/src/components/cicd/CicdView.tsx +++ b/web/src/components/cicd/CicdView.tsx @@ -1,11 +1,13 @@ import { useMemo, useState } from 'react' import { useQuery } from '@tanstack/react-query' -import { Search, Workflow } from 'lucide-react' +import { ArrowDown, ArrowUp, ArrowUpDown, Search, Workflow } from 'lucide-react' import { clsx } from 'clsx' import { SummaryTile, Facet, Input, + PageHeader, + FreshnessControl, getTektonPipelineRunStatus, tektonRefName, formatAge, @@ -50,6 +52,16 @@ const STATUS_TONE: Record = { + running: 0, + failed: 1, + pending: 2, + cancelled: 3, + succeeded: 4, +} function runStartTime(run: any): number { const t = run?.status?.startTime @@ -72,14 +84,21 @@ function taskProgress(run: any): { started: number; total: number } { return { started, total } } +// A `matrix`-strategy task expands into several childReferences from one +// declared pipeline task, so `started` can legitimately exceed `total` — +// display/sort against whichever is larger so the fraction never reads +// backwards (e.g. "12/7"), without pretending the declared count was +// actually 12. +function progressFraction(run: any, facet: StatusFacet): number { + const { started, total } = taskProgress(run) + const displayTotal = Math.max(total, started) + return displayTotal > 0 ? Math.min(1, started / displayTotal) : facet === 'succeeded' ? 1 : 0 +} + function ProgressBar({ run, facet }: { run: any; facet: StatusFacet }) { const { started, total } = taskProgress(run) - // A `matrix`-strategy task expands into several childReferences from one - // declared pipeline task, so `started` can legitimately exceed `total` — - // display against whichever is larger so the fraction never reads backwards - // (e.g. "12/7"), without pretending the declared count was actually 12. const displayTotal = Math.max(total, started) - const fraction = displayTotal > 0 ? Math.min(1, started / displayTotal) : facet === 'succeeded' ? 1 : 0 + const fraction = progressFraction(run, facet) const fillClass = { running: 'bg-sky-500', succeeded: 'bg-emerald-500', @@ -113,11 +132,77 @@ function ProgressBar({ run, facet }: { run: any; facet: StatusFacet }) { ) } +type SortKey = 'name' | 'status' | 'pipeline' | 'progress' | 'duration' | 'started' +type SortDir = 'asc' | 'desc' + +const SORT_COLUMNS: { key: SortKey; label: string; className: string }[] = [ + { key: 'name', label: 'Name', className: 'w-64 min-w-0 shrink-0' }, + { key: 'status', label: 'Status', className: 'w-40 shrink-0' }, + { key: 'pipeline', label: 'Pipeline', className: 'w-40 shrink-0' }, + { key: 'progress', label: 'Progress', className: 'min-w-[140px] flex-1' }, + { key: 'duration', label: 'Duration', className: 'w-28 shrink-0 text-right' }, + { key: 'started', label: 'Started', className: 'w-24 shrink-0 text-right' }, +] + +function sortValue(run: any, key: SortKey): number | string { + const facet = statusFacet(run) + switch (key) { + case 'name': + return (run?.metadata?.name ?? '').toLowerCase() + case 'status': + return STATUS_SORT_RANK[facet] + case 'pipeline': + return tektonRefName(run?.spec?.pipelineRef).toLowerCase() + case 'progress': + return progressFraction(run, facet) + case 'duration': + // Still-running/unstarted rows sort as smallest — reads as "shortest so + // far" ascending, "longest-running" descending, without a separate + // null bucket. + return runDurationMs(run) ?? -1 + case 'started': + return runStartTime(run) + } +} + +function SortHeaderCell({ + column, + sort, + onSort, +}: { + column: (typeof SORT_COLUMNS)[number] + sort: { key: SortKey; dir: SortDir } + onSort: (key: SortKey) => void +}) { + const active = sort.key === column.key + const Icon = active ? (sort.dir === 'asc' ? ArrowUp : ArrowDown) : ArrowUpDown + return ( + + ) +} + export function CicdView({ namespaces, onOpenPipelineRun }: CicdViewProps) { const namespacesParam = namespaces.join(',') const [search, setSearch] = useState('') const [statusFilter, setStatusFilter] = useState>(new Set()) const [pipelineFilter, setPipelineFilter] = useState>(new Set()) + const [sort, setSort] = useState<{ key: SortKey; dir: SortDir }>({ key: 'started', dir: 'desc' }) + const handleSort = (key: SortKey) => + setSort((prev) => + prev.key === key ? { key, dir: prev.dir === 'asc' ? 'desc' : 'asc' } : { key, dir: key === 'name' || key === 'pipeline' ? 'asc' : 'desc' }, + ) const runsQuery = useQuery({ queryKey: ['cicd-pipelineruns', namespacesParam], @@ -176,8 +261,15 @@ export function CicdView({ namespaces, onOpenPipelineRun }: CicdViewProps) { } return true }) - return [...rows].sort((a, b) => runStartTime(b) - runStartTime(a)) - }, [runs, search, statusFilter, pipelineFilter]) + const dirMul = sort.dir === 'asc' ? 1 : -1 + return [...rows].sort((a, b) => { + const av = sortValue(a, sort.key) + const bv = sortValue(b, sort.key) + if (av < bv) return -1 * dirMul + if (av > bv) return 1 * dirMul + return 0 + }) + }, [runs, search, statusFilter, pipelineFilter, sort]) const toggleStatus = (v: StatusFacet) => setStatusFilter((prev) => { const next = new Set(prev) @@ -191,72 +283,99 @@ export function CicdView({ namespaces, onOpenPipelineRun }: CicdViewProps) { else next.add(v) return next }) + const hasFacetFilters = statusFilter.size > 0 || pipelineFilter.size > 0 + const clearFacetFilters = () => { + setStatusFilter(new Set()) + setPipelineFilter(new Set()) + } return ( -
-
-
- -

CI/CD

-
-
- - toggleStatus('running')} - /> - toggleStatus('succeeded')} - /> - toggleStatus('failed')} - /> - = 90 ? 'success' : stats.successRate >= 70 ? 'warning' : 'error') as SummaryTone} - loading={runsQuery.isLoading} - /> - -
+
+
+ + runsQuery.refetch()} + /> + + toggleStatus('running')} + /> + toggleStatus('succeeded')} + /> + toggleStatus('failed')} + /> + = 90 ? 'success' : stats.successRate >= 70 ? 'warning' : 'error') as SummaryTone} + loading={runsQuery.isLoading} + /> + + + } + />
-
-
+ + setDeleteTarget(null)} + onConfirm={() => { + if (!deleteTarget) return + deleteRun.mutate( + { kind: 'pipelineruns', group: 'tekton.dev', namespace: deleteTarget.namespace, name: deleteTarget.name }, + { onSuccess: () => runsQuery.refetch() }, + ) + setDeleteTarget(null) + }} + title="Delete PipelineRun" + message={deleteTarget ? `Delete "${deleteTarget.name}" in ${deleteTarget.namespace}?` : ''} + confirmLabel="Delete" + variant="danger" + />
) } From 97743cfdc794fec27a859311e7c2935f1add6c74 Mon Sep 17 00:00:00 2001 From: jfillman Date: Sun, 30 Aug 2026 01:38:59 -0700 Subject: [PATCH 14/37] fix(tekton): fix the DAG blanking (nodes hidden, edges missing) on live pipelines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root-caused via a real platform-cicd pipeline with 11 tasks/28 edges, comparing DOM state (node visibility, edge count, ReactFlow viewport transform) between a healthy render and a blanked one — the data layer (task statuses, ELK layout, taskKey) was identical in both; only the DOM differed. @xyflow/system's adoptUserNodes rebuilds a node's internal record from scratch whenever the incoming node object isn't the exact same reference as last render — true on every poll here, since the task array was rebuilt fresh each time (useQueries returns a new array every render; the outer tasks/onTaskClick were unstabilized on top of that). The rebuild resets `measured` to undefined and clears cached handle bounds, so the node goes visibility:hidden and its edges drop out until a ResizeObserver fires to remeasure it — which never happens once the card's on-screen size stops changing, since there's nothing new to report. Denser real pipelines hit this far more (more polling, more edges converging on shared "hub" tasks like tracing spans) than the sparse synthetic pipelines used earlier, which is why it didn't reproduce there. Two changes close it: - PipelineDagView: give every node explicit `measured` (width/height) and `handles` (position/x/y for its target+source connection points) up front, so both node visibility and edge connection points are answered from data instead of a live DOM measurement, independent of object identity. Also wraps the ReactFlow render in an error boundary so a future bad state renders a message instead of a silent blank. - TektonPipelineFullscreen: stabilize the tasks array by a content signature (only produces a new reference when a task's status/reason/ taskRunName actually changed) and memoize the task-click handler — removes the poll-driven churn that was the recurring trigger. --- .../resources/renderers/PipelineDagView.tsx | 87 ++++++++++++++----- .../execution/TektonPipelineFullscreen.tsx | 41 +++++++-- 2 files changed, 98 insertions(+), 30 deletions(-) diff --git a/packages/k8s-ui/src/components/resources/renderers/PipelineDagView.tsx b/packages/k8s-ui/src/components/resources/renderers/PipelineDagView.tsx index f112646124..abd77590da 100644 --- a/packages/k8s-ui/src/components/resources/renderers/PipelineDagView.tsx +++ b/packages/k8s-ui/src/components/resources/renderers/PipelineDagView.tsx @@ -16,7 +16,7 @@ // Topology view, without extending the shared NodeKind enum or wiring // Tekton into pkg/topology/builder.go — that stays a separate, larger PR. -import { memo, useEffect, useMemo, useState } from 'react' +import { Component, memo, useEffect, useMemo, useState, type ReactNode } from 'react' import { Controls, Handle, @@ -102,6 +102,27 @@ async function layoutNodes(tasks: TektonTaskNode[]): Promise<{ nodes: Node[]; ed data: { task }, draggable: false, selectable: true, + // Every card is the same fixed size, declared up front instead of + // discovered via ResizeObserver. Two reasons this matters, both from + // @xyflow/system's adoptUserNodes: (1) it rebuilds a node's internal + // record from scratch whenever the incoming node object isn't the exact + // same reference as last time (true on every poll here, since task + // status is rebuilt fresh each time) — the rebuild resets `measured` to + // undefined and leaves the node visibility:hidden until a resize fires; + // since the card's on-screen size never actually changes, no resize + // event ever comes and it stays hidden for good. (2) edge connection + // points normally come from parseHandles() reading each Handle's live + // DOM position — also reset on the same rebuild — so edges vanish too. + // Setting `measured` and `handles` directly here answers both from data + // instead of a DOM measurement, so neither ever depends on catching a + // ResizeObserver callback that has nothing new to report. + width: NODE_WIDTH, + height: NODE_HEIGHT, + measured: { width: NODE_WIDTH, height: NODE_HEIGHT }, + handles: [ + { type: 'target' as const, position: Position.Left, x: 0, y: NODE_HEIGHT / 2 }, + { type: 'source' as const, position: Position.Right, x: NODE_WIDTH, y: NODE_HEIGHT / 2 }, + ], })) const edges: Edge[] = elkEdges.map((e) => ({ @@ -165,6 +186,28 @@ const TektonTaskCard = memo(function TektonTaskCard({ data }: NodeProps { + state = { error: null as Error | null } + static getDerivedStateFromError(error: Error) { + return { error } + } + componentDidCatch(error: Error, info: { componentStack?: string | null }) { + console.error('[PipelineDagView] render error', error, info.componentStack) + } + render() { + if (this.state.error) { + return ( +
+ DAG render error: {this.state.error.message} +
+ ) + } + return this.props.children + } +} + export interface PipelineDagViewProps { tasks: TektonTaskNode[] height?: number @@ -210,26 +253,28 @@ export function PipelineDagView({ tasks, height, onTaskClick }: PipelineDagViewP Laying out task graph…
) : ( - - - - - + + + + + + + )} ) diff --git a/web/src/components/execution/TektonPipelineFullscreen.tsx b/web/src/components/execution/TektonPipelineFullscreen.tsx index 67b113e91a..e828b99dfa 100644 --- a/web/src/components/execution/TektonPipelineFullscreen.tsx +++ b/web/src/components/execution/TektonPipelineFullscreen.tsx @@ -1,4 +1,4 @@ -import { useMemo } from 'react' +import { useCallback, useMemo, useRef } from 'react' import { useQueries } from '@tanstack/react-query' import { PipelineDagView } from '@skyhook-io/k8s-ui/components/resources/renderers/PipelineDagView' import { @@ -51,7 +51,7 @@ export function TektonPipelineFullscreen({ kind, namespace, name, resource, onNa })), }) - const tasks: TektonTaskNode[] = useMemo(() => { + const rawTasks: TektonTaskNode[] = useMemo(() => { if (!isRun) return declaredTasks const statusByTaskName = new Map() entries.forEach(([pipelineTaskName, ref], i) => { @@ -71,8 +71,37 @@ export function TektonPipelineFullscreen({ kind, namespace, name, resource, onNa // eslint-disable-next-line react-hooks/exhaustive-deps }, [isRun, declaredTasks, entries, queries]) + // Stabilize the array reference by content, not just recompute on every + // render. `queries` is a fresh array from useQueries every render (even + // when no query's data actually changed), so rawTasks above is a brand-new + // array on every single poll tick. Feeding ReactFlow a "new" nodes array + // that frequently — even when every task's status is byte-identical to + // before — makes it re-enter its measure-via-ResizeObserver path, which + // can get stuck hiding nodes/edges indefinitely (the on-screen size never + // actually changes, so the observer has nothing to report back). + const tasksSigRef = useRef('') + const tasksRef = useRef(rawTasks) + const sig = rawTasks.map((t) => `${t.name}:${t.status ?? ''}:${t.reason ?? ''}:${t.taskRunName ?? ''}`).join('|') + if (sig !== tasksSigRef.current) { + tasksSigRef.current = sig + tasksRef.current = rawTasks + } + const tasks = tasksRef.current + const pipelineRefLabel = isRun ? tektonRefName(resource?.spec?.pipelineRef) : name + // Stable across renders (as long as namespace/onNavigateToResource don't + // change) — an inline arrow prop here would, like the unstabilized tasks + // array above, hand ReactFlow a "new" nodes array on every poll tick even + // when nothing visible changed. + const handleTaskClick = useCallback( + (task: TektonTaskNode) => { + if (!task.taskRunName || !onNavigateToResource) return + onNavigateToResource({ kind: 'taskruns', namespace, name: task.taskRunName, group: 'tekton.dev' }) + }, + [namespace, onNavigateToResource], + ) + return (
@@ -83,13 +112,7 @@ export function TektonPipelineFullscreen({ kind, namespace, name, resource, onNa )}
- { - if (!task.taskRunName || !onNavigateToResource) return - onNavigateToResource({ kind: 'taskruns', namespace, name: task.taskRunName, group: 'tekton.dev' }) - }} - /> +
) From 4149c4d1951a11ead4455b48385e6e554e2cdfc0 Mon Sep 17 00:00:00 2001 From: jfillman Date: Sun, 30 Aug 2026 01:46:10 -0700 Subject: [PATCH 15/37] feat(tekton): bulk multi-select delete for the CI/CD table Matches the "Select multiple resources" pattern from the main Resources table: a toggle button enters select mode (checkboxes per row + a select-all header checkbox, row clicks toggle selection instead of opening the drawer, the per-row actions menu hides), a selection bar shows the count with Delete/Cancel, and Delete goes through the existing useBulkDeleteResources mutation (parallel, partial-failure-safe) behind a confirmation dialog listing the targets. --- web/src/components/cicd/CicdView.tsx | 148 +++++++++++++++++++++++++-- 1 file changed, 138 insertions(+), 10 deletions(-) diff --git a/web/src/components/cicd/CicdView.tsx b/web/src/components/cicd/CicdView.tsx index 68a5f4f582..10c64eeddd 100644 --- a/web/src/components/cicd/CicdView.tsx +++ b/web/src/components/cicd/CicdView.tsx @@ -1,6 +1,6 @@ -import { useMemo, useState } from 'react' +import { useCallback, useMemo, useState } from 'react' import { useQuery } from '@tanstack/react-query' -import { Ban, ArrowDown, ArrowUp, ArrowUpDown, Search, Trash2, Workflow } from 'lucide-react' +import { Ban, ArrowDown, ArrowUp, ArrowUpDown, ListChecks, Search, Trash2, Workflow } from 'lucide-react' import { clsx } from 'clsx' import yamlLib from 'yaml' import { @@ -11,15 +11,15 @@ import { FreshnessControl, RowActionMenu, ConfirmDialog, + Tooltip, getTektonPipelineRunStatus, tektonRefName, formatAge, formatDuration, type SummaryTone, - Tooltip, type RowActionItem, } from '@skyhook-io/k8s-ui' -import { fetchJSON, useDeleteResource, useUpdateResource } from '../../api/client' +import { fetchJSON, useBulkDeleteResources, useDeleteResource, useUpdateResource } from '../../api/client' interface CicdViewProps { namespaces: string[] @@ -211,6 +211,23 @@ export function CicdView({ namespaces, onOpenPipelineRun }: CicdViewProps) { const cancelRun = useUpdateResource() const deleteRun = useDeleteResource() + const [bulkMode, setBulkMode] = useState(false) + const [checkedKeys, setCheckedKeys] = useState>(new Set()) + const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false) + const bulkDeleteRuns = useBulkDeleteResources() + const exitBulkMode = useCallback(() => { + setBulkMode(false) + setCheckedKeys(new Set()) + }, []) + const toggleChecked = useCallback((key: string) => { + setCheckedKeys((prev) => { + const next = new Set(prev) + if (next.has(key)) next.delete(key) + else next.add(key) + return next + }) + }, []) + const runsQuery = useQuery({ queryKey: ['cicd-pipelineruns', namespacesParam], queryFn: async () => { @@ -278,6 +295,19 @@ export function CicdView({ namespaces, onOpenPipelineRun }: CicdViewProps) { }) }, [runs, search, statusFilter, pipelineFilter, sort]) + const allVisibleChecked = filteredRuns.length > 0 && filteredRuns.every((r) => checkedKeys.has(`${r.metadata?.namespace ?? ''}/${r.metadata?.name ?? ''}`)) + const toggleCheckAll = useCallback(() => { + setCheckedKeys( + allVisibleChecked + ? new Set() + : new Set(filteredRuns.map((r) => `${r.metadata?.namespace ?? ''}/${r.metadata?.name ?? ''}`)), + ) + }, [allVisibleChecked, filteredRuns]) + const checkedRuns = useMemo( + () => filteredRuns.filter((r) => checkedKeys.has(`${r.metadata?.namespace ?? ''}/${r.metadata?.name ?? ''}`)), + [filteredRuns, checkedKeys], + ) + const toggleStatus = (v: StatusFacet) => setStatusFilter((prev) => { const next = new Set(prev) if (next.has(v)) next.delete(v) @@ -383,8 +413,8 @@ export function CicdView({ namespaces, onOpenPipelineRun }: CicdViewProps) {
-
-
+
+
+ + +
+ {bulkMode && ( +
+ {checkedRuns.length} selected + + +
+ )} + {filteredRuns.length > 0 && (
+ {bulkMode && ( + + + { if (el) el.indeterminate = checkedRuns.length > 0 && !allVisibleChecked }} + onChange={toggleCheckAll} + aria-label={allVisibleChecked ? 'Deselect all' : 'Select all'} + className="h-3.5 w-3.5 cursor-pointer rounded border-theme-border accent-skyhook-500" + /> + + + )} {SORT_COLUMNS.map((column) => ( ))} @@ -420,6 +501,7 @@ export function CicdView({ namespaces, onOpenPipelineRun }: CicdViewProps) { const name = run.metadata?.name ?? '' const namespace = run.metadata?.namespace ?? '' const rowKey = `${namespace}/${name}` + const checked = checkedKeys.has(rowKey) const actionItems: RowActionItem[] = [ { key: 'cancel', @@ -453,15 +535,30 @@ export function CicdView({ namespaces, onOpenPipelineRun }: CicdViewProps) { key={rowKey} role="button" tabIndex={0} - onClick={() => onOpenPipelineRun({ namespace, name })} + onClick={() => (bulkMode ? toggleChecked(rowKey) : onOpenPipelineRun({ namespace, name }))} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() - onOpenPipelineRun({ namespace, name }) + if (bulkMode) toggleChecked(rowKey) + else onOpenPipelineRun({ namespace, name }) } }} - className="flex w-full cursor-pointer items-center gap-4 px-4 py-3 text-left hover:bg-theme-hover" + className={clsx( + 'flex w-full cursor-pointer items-center gap-4 px-4 py-3 text-left hover:bg-theme-hover', + checked && 'bg-skyhook-500/5', + )} > + {bulkMode && ( + + toggleChecked(rowKey)} + onClick={(e) => e.stopPropagation()} + className="h-3.5 w-3.5 cursor-pointer rounded border-theme-border accent-skyhook-500" + /> + + )}
{name}
{namespace}
@@ -482,7 +579,7 @@ export function CicdView({ namespaces, onOpenPipelineRun }: CicdViewProps) { {run?.status?.startTime ? formatAge(run.status.startTime) : '—'}
- + {!bulkMode && }
) @@ -509,6 +606,37 @@ export function CicdView({ namespaces, onOpenPipelineRun }: CicdViewProps) { confirmLabel="Delete" variant="danger" /> + + setShowBulkDeleteConfirm(false)} + onConfirm={() => { + bulkDeleteRuns.mutate( + { + items: checkedRuns.map((r) => ({ + kind: 'pipelineruns', + group: 'tekton.dev', + namespace: r.metadata?.namespace ?? '', + name: r.metadata?.name ?? '', + })), + }, + { + onSettled: () => { + runsQuery.refetch() + exitBulkMode() + setShowBulkDeleteConfirm(false) + }, + }, + ) + }} + title={`Delete ${checkedRuns.length} PipelineRun${checkedRuns.length === 1 ? '' : 's'}?`} + message={`You are about to delete ${checkedRuns.length} PipelineRun${checkedRuns.length === 1 ? '' : 's'}. This action cannot be undone.`} + details={checkedRuns.map((r) => `${r.metadata?.namespace}/${r.metadata?.name}`).join('\n')} + confirmLabel={`Delete ${checkedRuns.length} PipelineRun${checkedRuns.length === 1 ? '' : 's'}`} + variant="danger" + isLoading={bulkDeleteRuns.isPending} + isClosable + />
) } From 5ffe14c780ede0d1a8a3deda2ce02ae81f19e9f3 Mon Sep 17 00:00:00 2001 From: jfillman Date: Sun, 30 Aug 2026 01:52:26 -0700 Subject: [PATCH 16/37] feat(tekton): make the CI/CD table's Pipeline column clickable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves the PipelineRun's pipelineRef to a real, navigable Pipeline resource and opens it in the drawer on click — a direct name ref lives in the run's own namespace, a cluster resolver ref carries its own namespace param (platform-cicd's catalog pipelines live in a shared namespace, not the run's, confirmed live). Other resolvers (git/http/bundle) fetch the spec from outside the cluster, so there's no live Pipeline object to open — those stay plain text. Opens as a normal small drawer (not straight-to-fullscreen like a PipelineRun row) since a Pipeline is a static template, not something actively progressing. --- web/src/App.tsx | 6 ++++ web/src/components/cicd/CicdView.tsx | 47 ++++++++++++++++++++++++++-- 2 files changed, 50 insertions(+), 3 deletions(-) diff --git a/web/src/App.tsx b/web/src/App.tsx index 5ae7cb8511..4d88719010 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -2319,6 +2319,12 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL params.set('full', '1') setSearchParams(params) }} + onOpenPipeline={({ namespace, name }) => { + // Small drawer, not straight-to-fullscreen: a Pipeline is a + // template (not progressing), so the compact view's summary is + // enough on open — the full DAG is still one expand away. + navigateToResource({ kind: 'pipelines', namespace, name, group: 'tekton.dev' }) + }} /> )} diff --git a/web/src/components/cicd/CicdView.tsx b/web/src/components/cicd/CicdView.tsx index 10c64eeddd..4f92fa27dc 100644 --- a/web/src/components/cicd/CicdView.tsx +++ b/web/src/components/cicd/CicdView.tsx @@ -24,6 +24,26 @@ import { fetchJSON, useBulkDeleteResources, useDeleteResource, useUpdateResource interface CicdViewProps { namespaces: string[] onOpenPipelineRun: (ref: { namespace: string; name: string }) => void + onOpenPipeline: (ref: { namespace: string; name: string }) => void +} + +// Resolves a PipelineRun's pipelineRef to a real, navigable Pipeline object — +// null when there's nothing to navigate to. A direct name ref lives in the +// PipelineRun's own namespace; a cluster resolver ref carries its own +// namespace param (platform-cicd's catalog pipelines live in a shared +// namespace, not the run's). Other resolvers (git/http/bundle) fetch the +// spec from outside the cluster — there's no live Pipeline object for them. +function resolvePipelineTarget(run: any): { namespace: string; name: string } | null { + const ref = run?.spec?.pipelineRef + if (!ref) return null + if (ref.name) return { namespace: run?.metadata?.namespace ?? '', name: ref.name } + if (ref.resolver === 'cluster') { + const params = ref.params ?? [] + const name = params.find((p: any) => p?.name === 'name')?.value + const namespace = params.find((p: any) => p?.name === 'namespace')?.value + if (name && namespace) return { namespace, name } + } + return null } type StatusFacet = 'running' | 'succeeded' | 'failed' | 'cancelled' | 'pending' @@ -197,7 +217,7 @@ function SortHeaderCell({ ) } -export function CicdView({ namespaces, onOpenPipelineRun }: CicdViewProps) { +export function CicdView({ namespaces, onOpenPipelineRun, onOpenPipeline }: CicdViewProps) { const namespacesParam = namespaces.join(',') const [search, setSearch] = useState('') const [statusFilter, setStatusFilter] = useState>(new Set()) @@ -502,6 +522,8 @@ export function CicdView({ namespaces, onOpenPipelineRun }: CicdViewProps) { const namespace = run.metadata?.namespace ?? '' const rowKey = `${namespace}/${name}` const checked = checkedKeys.has(rowKey) + const pipelineTarget = resolvePipelineTarget(run) + const pipelineLabel = tektonRefName(run?.spec?.pipelineRef) const actionItems: RowActionItem[] = [ { key: 'cancel', @@ -566,8 +588,27 @@ export function CicdView({ namespaces, onOpenPipelineRun }: CicdViewProps) {
{status.text}
-
- {tektonRefName(run?.spec?.pipelineRef)} +
+ {pipelineTarget ? ( + + + + ) : ( + + + {pipelineLabel} + + + )}
From 5f2144a075b6b6e556354bfb1818e264736bc9f4 Mon Sep 17 00:00:00 2001 From: jfillman Date: Mon, 31 Aug 2026 19:13:29 -0700 Subject: [PATCH 17/37] feat(tekton): widen ELK spacing + merge edges to fix overlapping DAG arrows Pipeline task graphs fan out/in much more densely than the topology view's own ownership hierarchy (several governance-check tasks depending on one upstream task, converging on one downstream task) - the default spacing (carried over from Topology's own numbers, and even tighter in edge/edge and edge/node spacing, which wasn't set at all) let parallel edges visually run together. Widens node/edge spacing, adds edge-edge/edge-node spacing that wasn't set before, turns on mergeEdges so edges sharing an endpoint bundle into one trunk instead of fanning out the whole way, and raises crossing- minimization thoroughness (cheap at this task-graph size). --- .../resources/renderers/PipelineDagView.tsx | 31 ++++++++++++++++--- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/packages/k8s-ui/src/components/resources/renderers/PipelineDagView.tsx b/packages/k8s-ui/src/components/resources/renderers/PipelineDagView.tsx index abd77590da..79f3a836f3 100644 --- a/packages/k8s-ui/src/components/resources/renderers/PipelineDagView.tsx +++ b/packages/k8s-ui/src/components/resources/renderers/PipelineDagView.tsx @@ -3,13 +3,21 @@ // web/src/components/workload/WorkloadView.tsx) — never in the compact // drawer, which has no room to render a DAG legibly. // -// Layout uses the same ELK.js engine and options as the main Topology view +// Layout uses the same ELK.js engine as the main Topology view // (packages/k8s-ui/src/components/topology/layout.ts: layered, RIGHT, // ORTHOGONAL edge routing, NETWORK_SIMPLEX placement) rather than a hand- // rolled rank/row placement — NETWORK_SIMPLEX minimizes edge crossings, // which a same-order placement does not, and that crossing-minimization is // exactly what was missing (overlapping, hard-to-follow lines). // +// Spacing/merge options below deliberately go further than Topology's own +// (40/85/25): a Pipeline's task graph is a much denser fan-out/fan-in shape +// than a typical ownership hierarchy — several governance-check tasks (sast, +// sbom, image-scan, policy-check, ...) commonly all depend on one upstream +// task and converge on one downstream task, which is exactly the shape that +// reads as "overlapping arrows" without extra edge/node breathing room and +// edge merging at the shared endpoints. +// // Node cards intentionally mirror K8sResourceNode's visual language // (topology-node-card CSS, icon + kind-label header row, status dot) so a // task in this DAG reads as "the same kind of thing" as a node in the real @@ -64,11 +72,26 @@ const elkOptions = { 'elk.algorithm': 'layered', 'elk.direction': 'RIGHT', 'elk.layered.considerModelOrder.strategy': 'NODES_AND_EDGES', - 'elk.spacing.nodeNode': '32', - 'elk.layered.spacing.nodeNodeBetweenLayers': '70', - 'elk.layered.spacing.edgeNodeBetweenLayers': '20', + 'elk.spacing.nodeNode': '48', + 'elk.layered.spacing.nodeNodeBetweenLayers': '100', + 'elk.layered.spacing.edgeNodeBetweenLayers': '32', + // Not set at all before - default edge-edge/edge-node spacing is tight + // enough that several parallel edges between the same two layers (a + // build task with 4-5 direct dependents, say) visually run together. + 'elk.spacing.edgeEdge': '24', + 'elk.spacing.edgeNode': '24', 'elk.edgeRouting': 'ORTHOGONAL', 'elk.layered.nodePlacement.strategy': 'NETWORK_SIMPLEX', + // Edges sharing a source or target bundle into one trunk near that shared + // endpoint instead of fanning out as separate parallel lines the whole + // way - the single most direct fix for a fan-out/fan-in task depended on + // by (or converging from) several siblings at once. + 'elk.layered.mergeEdges': 'true', + // Default (7) is tuned for large graphs where more iterations cost real + // time; a pipeline's task count is small enough that a much more thorough + // crossing-minimization pass is still instant, and crossing minimization + // is exactly the lever for "hard to see what goes where". + 'elk.layered.thoroughness': '30', } async function layoutNodes(tasks: TektonTaskNode[]): Promise<{ nodes: Node[]; edges: Edge[] }> { From 9f6cec9e0cd81fffd4c3865051b2ab960c9ab6b6 Mon Sep 17 00:00:00 2001 From: jfillman Date: Mon, 31 Aug 2026 19:22:20 -0700 Subject: [PATCH 18/37] feat(tekton): draggable DAG nodes with browser-local saved layout nodesDraggable was hardcoded off with no persistence path. Now nodes are draggable, and dropping one persists every node's current position to localStorage keyed by the task set's own shape (names + dependency edges) - so a saved layout is naturally shared across every PipelineRun of the same underlying Pipeline, not tied to one specific run. A "Reset layout" panel button (shown only once a manual layout exists) clears the override and restores ELK's auto-computed positions. Structural re-layout (ELK) still only runs when the task set/dependency shape changes; live status polling updates node data in place without touching positions, whether auto-laid-out or hand-placed - unchanged from before, just applied to a mutable node-position state (useNodesState) instead of a derived-every-render array. --- .../resources/renderers/PipelineDagView.tsx | 132 +++++++++++++++--- 1 file changed, 114 insertions(+), 18 deletions(-) diff --git a/packages/k8s-ui/src/components/resources/renderers/PipelineDagView.tsx b/packages/k8s-ui/src/components/resources/renderers/PipelineDagView.tsx index 79f3a836f3..b0eee3d8b8 100644 --- a/packages/k8s-ui/src/components/resources/renderers/PipelineDagView.tsx +++ b/packages/k8s-ui/src/components/resources/renderers/PipelineDagView.tsx @@ -24,14 +24,16 @@ // Topology view, without extending the shared NodeKind enum or wiring // Tekton into pkg/topology/builder.go — that stays a separate, larger PR. -import { Component, memo, useEffect, useMemo, useState, type ReactNode } from 'react' +import { Component, memo, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react' import { Controls, Handle, MarkerType, + Panel, Position, ReactFlow, ReactFlowProvider, + useNodesState, type Edge, type Node, type NodeProps, @@ -39,7 +41,7 @@ import { } from '@xyflow/react' import '@xyflow/react/dist/style.css' import '../../topology/topology.css' -import { CheckCircle2, CircleDashed, ListTodo, Loader2, MinusCircle, XCircle } from 'lucide-react' +import { CheckCircle2, CircleDashed, ListTodo, Loader2, MinusCircle, RotateCcw, XCircle } from 'lucide-react' import { clsx } from 'clsx' import { healthToSeverity, SEVERITY_DOT } from '../../../utils/badge-colors' import type { TektonTaskNode, TektonTaskNodeStatus } from '../resource-utils-tekton' @@ -123,7 +125,7 @@ async function layoutNodes(tasks: TektonTaskNode[]): Promise<{ nodes: Node[]; ed type: 'tektonTask', position: positionByName.get(task.name) ?? { x: 0, y: 0 }, data: { task }, - draggable: false, + draggable: true, selectable: true, // Every card is the same fixed size, declared up front instead of // discovered via ResizeObserver. Two reasons this matters, both from @@ -231,6 +233,42 @@ class DagErrorBoundary extends Component<{ children: ReactNode }, { error: Error } } +// Manual layout persistence: browser-local, keyed by the task set's own +// structure (names + dependency shape, the same `taskKey` the layout effect +// already keys off) rather than a pipeline name/namespace — a saved layout +// is really about "this DAG shape", so it's naturally shared across every +// PipelineRun of the same underlying Pipeline, not just the one the user +// happened to drag. Never load-bearing: any localStorage failure (quota, +// private browsing) just means the auto layout is used instead. +const LAYOUT_STORAGE_PREFIX = 'radar:pipeline-dag-layout:v1:' + +type SavedPositions = Record + +function loadSavedPositions(taskKey: string): SavedPositions | null { + try { + const raw = localStorage.getItem(LAYOUT_STORAGE_PREFIX + taskKey) + return raw ? (JSON.parse(raw) as SavedPositions) : null + } catch { + return null + } +} + +function saveSavedPositions(taskKey: string, positions: SavedPositions): void { + try { + localStorage.setItem(LAYOUT_STORAGE_PREFIX + taskKey, JSON.stringify(positions)) + } catch { + // best-effort — see comment above + } +} + +function clearSavedPositions(taskKey: string): void { + try { + localStorage.removeItem(LAYOUT_STORAGE_PREFIX + taskKey) + } catch { + // best-effort — see comment above + } +} + export interface PipelineDagViewProps { tasks: TektonTaskNode[] height?: number @@ -238,31 +276,74 @@ export interface PipelineDagViewProps { } export function PipelineDagView({ tasks, height, onTaskClick }: PipelineDagViewProps) { - const [layout, setLayout] = useState<{ nodes: Node[]; edges: Edge[] } | null>(null) + // The pure ELK result — always the "reset" target, and the source of truth + // for edges (edges never move, only node positions do). + const [elkResult, setElkResult] = useState<{ nodes: Node[]; edges: Edge[] } | null>(null) + const [rfNodes, setRfNodes, onNodesChangeBase] = useNodesState([]) + const [hasOverride, setHasOverride] = useState(false) + // Read inside onNodeDragStop without needing rfNodes in its deps — a plain + // closure over state would capture whatever rfNodes was when the callback + // was LAST created, not the latest positions from the drag that just ended. + const rfNodesRef = useRef([]) + rfNodesRef.current = rfNodes + const taskKey = useMemo(() => tasks.map((t) => `${t.name}:${t.dependsOn.join(',')}`).join('|'), [tasks]) + // Structural layout: ELK, then any saved manual override applied on top. + // Re-runs only when the task set or its dependency shape changes — not on + // every status update, which would re-run ELK (and blow away a manual + // layout) on every poll tick. useEffect(() => { let cancelled = false layoutNodes(tasks).then((result) => { - if (!cancelled) setLayout(result) + if (cancelled) return + setElkResult(result) + const saved = loadSavedPositions(taskKey) + const byName = new Map(tasks.map((t) => [t.name, t])) + setRfNodes(result.nodes.map((n) => ({ + ...n, + position: saved?.[n.id] ?? n.position, + data: { task: byName.get(n.id) ?? (n.data as TektonTaskCardData).task, onClick: onTaskClick }, + }))) + setHasOverride(saved !== null) }) return () => { cancelled = true } - // Re-layout when the task set or its dependency shape changes — not on - // every status update, which would re-run ELK on every poll tick. // eslint-disable-next-line react-hooks/exhaustive-deps }, [taskKey]) // Status/taskRunName changes (live polling) update node data in place - // without re-running layout — cheap, and keeps node positions stable - // while a run progresses instead of jittering the graph every few seconds. - const nodes = useMemo(() => { - if (!layout) return [] + // without touching positions — keeps the graph stable, whether auto-laid- + // out or hand-placed, while a run progresses instead of jittering it every + // few seconds. A no-op before the structural effect above has populated + // rfNodes for the first time. + useEffect(() => { const byName = new Map(tasks.map((t) => [t.name, t])) - return layout.nodes.map((n) => ({ + setRfNodes((prev) => prev.map((n) => ({ ...n, data: { task: byName.get(n.id) ?? (n.data as TektonTaskCardData).task, onClick: onTaskClick }, - })) - }, [layout, tasks, onTaskClick]) + }))) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [tasks, onTaskClick]) + + // Fires once when a drag ends (not on every intermediate move) — persists + // every node's current position, not just the one that moved, so a saved + // layout is always a complete map rather than a diff against ELK's. + const persistPositions = useCallback(() => { + const positions: SavedPositions = {} + for (const n of rfNodesRef.current) positions[n.id] = n.position + saveSavedPositions(taskKey, positions) + setHasOverride(true) + }, [taskKey]) + + const resetLayout = useCallback(() => { + clearSavedPositions(taskKey) + setRfNodes((prev) => { + if (!elkResult) return prev + const basePosition = new Map(elkResult.nodes.map((n) => [n.id, n.position])) + return prev.map((n) => ({ ...n, position: basePosition.get(n.id) ?? n.position })) + }) + setHasOverride(false) + }, [taskKey, elkResult, setRfNodes]) if (tasks.length === 0) return null @@ -271,7 +352,7 @@ export function PipelineDagView({ tasks, height, onTaskClick }: PipelineDagViewP className="h-full overflow-hidden rounded-md border border-theme-border bg-theme-base" style={height !== undefined ? { height } : undefined} > - {!layout ? ( + {!elkResult ? (
Laying out task graph…
@@ -279,12 +360,14 @@ export function PipelineDagView({ tasks, height, onTaskClick }: PipelineDagViewP + {hasOverride && ( + + + + )} From 8070a776a6e19e2e3b6c49a93f5f991a3425b3be Mon Sep 17 00:00:00 2001 From: jfillman Date: Mon, 31 Aug 2026 21:11:09 -0700 Subject: [PATCH 19/37] feat(tekton): background wash + ring on running DAG task cards The pulsing opacity + spinning icon alone were easy to miss at a glance across a busy pipeline DAG. Adds a sky-hued background wash and ring, matching the icon's existing sky-500 running color, so a running task is readable without having to spot the animation. --- .../src/components/resources/renderers/PipelineDagView.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/k8s-ui/src/components/resources/renderers/PipelineDagView.tsx b/packages/k8s-ui/src/components/resources/renderers/PipelineDagView.tsx index b0eee3d8b8..e64bbd734f 100644 --- a/packages/k8s-ui/src/components/resources/renderers/PipelineDagView.tsx +++ b/packages/k8s-ui/src/components/resources/renderers/PipelineDagView.tsx @@ -178,7 +178,12 @@ const TektonTaskCard = memo(function TektonTaskCard({ data }: NodeProps
Date: Mon, 31 Aug 2026 23:45:12 -0700 Subject: [PATCH 20/37] fix(tekton): solid running-task background, drop the opacity pulse animate-pulse animates the whole card element's opacity, not just its color - combined with the alpha-blended sky background from the previous change, the card became translucent at the pulse's low point and let the DAG's edge lines show straight through it (user-reported live testing). Switched to a solid (non-alpha) background wash and removed animate-pulse from the card entirely; the icon's own animate-spin still carries the "in progress" motion cue. --- .../resources/renderers/PipelineDagView.tsx | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/packages/k8s-ui/src/components/resources/renderers/PipelineDagView.tsx b/packages/k8s-ui/src/components/resources/renderers/PipelineDagView.tsx index e64bbd734f..a83f75e5ce 100644 --- a/packages/k8s-ui/src/components/resources/renderers/PipelineDagView.tsx +++ b/packages/k8s-ui/src/components/resources/renderers/PipelineDagView.tsx @@ -178,14 +178,17 @@ const TektonTaskCard = memo(function TektonTaskCard({ data }: NodeProps
onClick?.(task) : undefined} From e8444df6f125d7c7301f262778e8f5626ab73fa6 Mon Sep 17 00:00:00 2001 From: jfillman Date: Thu, 3 Sep 2026 22:24:34 -0700 Subject: [PATCH 21/37] fix(tekton): compute transitive reduction of the pipeline task DAG MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit buildPipelineTaskGraph correctly infers a task dependency from a $(tasks.X.results.Y) param reference in addition to explicit runAfter — but real pipelines commonly pipe a value (a trace ID, a shared config blob) from an early task into nearly every later task's params. Each of those is a genuine Tekton ordering constraint, but when it's already implied by a longer runAfter chain, drawing it as a direct edge too puts a fan of redundant arrows into one node instead of the single "runs right after X" edge a reader expects. Found live on the real "build" Pipeline (platform-catalog namespace): build-source's only runAfter is start-build-stage-span, but it also references start-flow's and validate-config's results for tracing/ config — both already ancestors of start-build-stage-span — producing 3 arrows into build-source instead of 1. Added a standard transitive-reduction pass: drop a dependency only when it's also reachable through one of the task's other direct dependencies, so genuinely independent deps (a real fan-in) are left untouched. New tests pin both the real-pipeline scenario and a diamond fan-out/fan-in case that must survive unreduced. Co-Authored-By: Claude Sonnet 5 --- .../resources/resource-utils-tekton.test.ts | 94 +++++++++++++++++++ .../resources/resource-utils-tekton.ts | 37 +++++++- 2 files changed, 130 insertions(+), 1 deletion(-) create mode 100644 packages/k8s-ui/src/components/resources/resource-utils-tekton.test.ts diff --git a/packages/k8s-ui/src/components/resources/resource-utils-tekton.test.ts b/packages/k8s-ui/src/components/resources/resource-utils-tekton.test.ts new file mode 100644 index 0000000000..b7c7c653de --- /dev/null +++ b/packages/k8s-ui/src/components/resources/resource-utils-tekton.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'vitest' +import { buildPipelineTaskGraph, type TektonTaskNode } from './resource-utils-tekton' + +function depsOf(nodes: TektonTaskNode[], name: string): string[] { + return nodes.find((n) => n.name === name)?.dependsOn ?? [] +} + +describe('buildPipelineTaskGraph', () => { + it('reads explicit runAfter as a direct dependency', () => { + const nodes = buildPipelineTaskGraph({ + tasks: [ + { name: 'a', params: [] }, + { name: 'b', runAfter: ['a'], params: [] }, + ], + }) + expect(depsOf(nodes, 'b')).toEqual(['a']) + expect(depsOf(nodes, 'a')).toEqual([]) + }) + + it('infers a dependency from a $(tasks.X.results.Y) param reference', () => { + const nodes = buildPipelineTaskGraph({ + tasks: [ + { name: 'a', params: [] }, + { name: 'b', params: [{ name: 'in', value: '$(tasks.a.results.out)' }] }, + ], + }) + expect(depsOf(nodes, 'b')).toEqual(['a']) + }) + + // Pins the real bug found live on the "build" Pipeline (platform-catalog + // namespace): build-source's only runAfter is start-build-stage-span, but + // it also pipes a trace ID from start-flow and a config blob from + // validate-config into its params — both genuine result-ref dependencies, + // and both already implied by the runAfter chain + // (start-build-stage-span <- start-flow <- validate-config). Drawing all + // three as direct edges put 3 arrows into build-source in the DAG instead + // of the single "runs right after start-build-stage-span" edge a reader + // expects. + it('drops a result-ref dependency already implied by another dependency (transitive reduction)', () => { + const nodes = buildPipelineTaskGraph({ + tasks: [ + { name: 'clone-repo', params: [] }, + { name: 'validate-config', runAfter: ['clone-repo'], params: [] }, + { name: 'start-flow', runAfter: ['validate-config'], params: [] }, + { + name: 'start-build-stage-span', + runAfter: ['start-flow'], + params: [{ name: 'flow-traceparent', value: '$(tasks.start-flow.results.traceparent)' }], + }, + { + name: 'build-source', + runAfter: ['start-build-stage-span'], + params: [ + { name: 'config-json', value: '$(tasks.validate-config.results.config-json)' }, + { name: 'flow-traceparent', value: '$(tasks.start-flow.results.traceparent)' }, + { name: 'stage-span-id', value: '$(tasks.start-build-stage-span.results.span-id)' }, + ], + }, + ], + }) + expect(depsOf(nodes, 'build-source')).toEqual(['start-build-stage-span']) + }) + + it('keeps a result-ref dependency that is NOT reachable through another dependency', () => { + // c depends on both a and b, and b is unrelated to a — neither implies + // the other, so both direct edges are real and must survive reduction. + const nodes = buildPipelineTaskGraph({ + tasks: [ + { name: 'a', params: [] }, + { name: 'b', params: [] }, + { + name: 'c', + params: [ + { name: 'x', value: '$(tasks.a.results.out)' }, + { name: 'y', value: '$(tasks.b.results.out)' }, + ], + }, + ], + }) + expect(depsOf(nodes, 'c').sort()).toEqual(['a', 'b']) + }) + + it('preserves a genuine diamond (fan-out then fan-in) without over-pruning', () => { + const nodes = buildPipelineTaskGraph({ + tasks: [ + { name: 'start', params: [] }, + { name: 'left', runAfter: ['start'], params: [] }, + { name: 'right', runAfter: ['start'], params: [] }, + { name: 'join', runAfter: ['left', 'right'], params: [] }, + ], + }) + expect(depsOf(nodes, 'join').sort()).toEqual(['left', 'right']) + }) +}) diff --git a/packages/k8s-ui/src/components/resources/resource-utils-tekton.ts b/packages/k8s-ui/src/components/resources/resource-utils-tekton.ts index 31625d3d19..633ba4b235 100644 --- a/packages/k8s-ui/src/components/resources/resource-utils-tekton.ts +++ b/packages/k8s-ui/src/components/resources/resource-utils-tekton.ts @@ -111,7 +111,7 @@ function resultRefsIn(value: unknown, out: Set): void { export function buildPipelineTaskGraph(pipelineSpec: any): TektonTaskNode[] { const tasks: any[] = pipelineSpec?.tasks ?? [] - return tasks.map((task) => { + const nodes = tasks.map((task) => { const deps = new Set(task.runAfter ?? []) resultRefsIn(task.params, deps) resultRefsIn(task.when, deps) @@ -119,6 +119,41 @@ export function buildPipelineTaskGraph(pipelineSpec: any): TektonTaskNode[] { deps.delete(task.name) return { name: task.name, dependsOn: [...deps] } }) + return reduceToDirectDeps(nodes) +} + +// reduceToDirectDeps computes the transitive reduction of the dependency +// graph: drops a dep `d` from a task whenever `d` is already an ancestor of +// one of that task's *other* deps, since the ordering it implies is already +// guaranteed by that longer path. Real Tekton pipelines commonly pipe a +// value (a trace ID, a shared config blob) from an early task like +// start-flow into nearly every later task's params — each of those is a +// genuine result-ref dependency (correct per Tekton's own scheduling), but +// drawing it as a direct edge alongside the task's actual immediate +// runAfter parent produces a fan of redundant arrows into one node instead +// of the single "what does this run right after" edge a reader expects. +function reduceToDirectDeps(nodes: TektonTaskNode[]): TektonTaskNode[] { + const depsByName = new Map(nodes.map((n) => [n.name, n.dependsOn])) + const ancestorsCache = new Map>() + function ancestorsOf(name: string, seen: Set = new Set()): Set { + const cached = ancestorsCache.get(name) + if (cached) return cached + if (seen.has(name)) return new Set() + seen.add(name) + const result = new Set() + for (const dep of depsByName.get(name) ?? []) { + result.add(dep) + for (const anc of ancestorsOf(dep, seen)) result.add(anc) + } + ancestorsCache.set(name, result) + return result + } + return nodes.map((node) => { + const direct = node.dependsOn.filter((dep) => + !node.dependsOn.some((other) => other !== dep && ancestorsOf(other).has(dep)), + ) + return { ...node, dependsOn: direct } + }) } // A PipelineRun's status.childReferences names each TaskRun it created, From 83267593331584bbc3be1625826e052d809f75ff Mon Sep 17 00:00:00 2001 From: jfillman Date: Thu, 3 Sep 2026 22:44:53 -0700 Subject: [PATCH 22/37] fix(tekton): include spec.finally tasks in the DAG and task counts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit buildPipelineTaskGraph only read pipelineSpec.tasks, so a Pipeline's finally tasks — cleanup/notify steps Tekton always runs after every regular task settles, success or failure — were invisible in Pipeline and PipelineRun DAGs, and undercounted in every "N tasks" readout (PipelineRenderer, the Resources table's Pipeline column, the CI/CD fleet's progress fraction). finally tasks don't support runAfter (Tekton rejects the field there), and the real ordering constraint is absolute — every finally task waits for the WHOLE regular task graph to settle, not just the tasks it happens to read a result from. Modeled that as a dependency on every regular task with nothing else depending on it (the graph's own terminal/leaf tasks), plus each finally task's own explicit $(tasks.X.results.Y) refs; the existing transitive-reduction pass then drops any of those that are already implied by a barrier edge. Verified against the real "build" Pipeline (platform-catalog namespace, 5 finally tasks including end-flow, notify, and send-cdevent) — every finally task now correctly depends on the 3 true terminal regular tasks (pipelinerun-started, image-scan, generate-sbom), matching Tekton's actual scheduling. Found independently by both the qodo-code-review and cursor[bot] automated PR reviews ("Finally tasks omitted"). Co-Authored-By: Claude Sonnet 5 --- .../components/resources/ResourcesView.tsx | 7 ++- .../resources/renderers/PipelineRenderer.tsx | 6 ++- .../resources/resource-utils-tekton.test.ts | 51 +++++++++++++++++++ .../resources/resource-utils-tekton.ts | 32 +++++++++++- web/src/components/cicd/CicdView.tsx | 6 ++- 5 files changed, 96 insertions(+), 6 deletions(-) diff --git a/packages/k8s-ui/src/components/resources/ResourcesView.tsx b/packages/k8s-ui/src/components/resources/ResourcesView.tsx index d1386fac7c..6c35fa0893 100644 --- a/packages/k8s-ui/src/components/resources/ResourcesView.tsx +++ b/packages/k8s-ui/src/components/resources/ResourcesView.tsx @@ -141,7 +141,7 @@ import { SEVERITY_DOT_COLOR, healthColors, } from './resource-utils' -import { getTektonPipelineRunStatus, getTektonTaskRunStatus, tektonRefName } from './resource-utils-tekton' +import { buildPipelineTaskGraph, getTektonPipelineRunStatus, getTektonTaskRunStatus, tektonRefName } from './resource-utils-tekton' import { getGenericResourceStatus } from './generic-status' import { getIstioGatewayServerCount, getIstioGatewaySelectorString } from './resource-utils-istio' import { @@ -9022,7 +9022,10 @@ function CronWorkflowCell({ resource, column }: { resource: any; column: string function PipelineCell({ resource, column }: { resource: any; column: string }) { switch (column) { case 'tasks': - return {(resource.spec?.tasks ?? []).length} + // buildPipelineTaskGraph, not a bare spec.tasks.length, so finally + // tasks count toward the total — Tekton runs them and includes their + // outcome in the PipelineRun result same as any regular task. + return {buildPipelineTaskGraph(resource.spec).length} default: return - } diff --git a/packages/k8s-ui/src/components/resources/renderers/PipelineRenderer.tsx b/packages/k8s-ui/src/components/resources/renderers/PipelineRenderer.tsx index 4b051a9f41..1ac6be0c50 100644 --- a/packages/k8s-ui/src/components/resources/renderers/PipelineRenderer.tsx +++ b/packages/k8s-ui/src/components/resources/renderers/PipelineRenderer.tsx @@ -1,5 +1,6 @@ import { ListChecks, Package } from 'lucide-react' import { Section, PropertyList, Property } from '../../ui/drawer-components' +import { buildPipelineTaskGraph } from '../resource-utils-tekton' interface PipelineRendererProps { data: any @@ -13,7 +14,10 @@ export function PipelineRenderer({ data }: PipelineRendererProps) { const spec = data?.spec ?? {} const params = spec.params ?? [] const workspaces = spec.workspaces ?? [] - const taskCount = (spec.tasks ?? []).length + // buildPipelineTaskGraph (not a bare spec.tasks.length) so finally tasks + // count toward the total — Tekton runs them and includes their outcome in + // the PipelineRun result same as any regular task. + const taskCount = buildPipelineTaskGraph(spec).length return ( <> diff --git a/packages/k8s-ui/src/components/resources/resource-utils-tekton.test.ts b/packages/k8s-ui/src/components/resources/resource-utils-tekton.test.ts index b7c7c653de..e76640c3af 100644 --- a/packages/k8s-ui/src/components/resources/resource-utils-tekton.test.ts +++ b/packages/k8s-ui/src/components/resources/resource-utils-tekton.test.ts @@ -91,4 +91,55 @@ describe('buildPipelineTaskGraph', () => { }) expect(depsOf(nodes, 'join').sort()).toEqual(['left', 'right']) }) + + describe('finally tasks', () => { + it('includes a finally task as a node, tagged isFinally', () => { + const nodes = buildPipelineTaskGraph({ + tasks: [{ name: 'build', params: [] }], + finally: [{ name: 'notify', params: [] }], + }) + expect(nodes.map((n) => n.name).sort()).toEqual(['build', 'notify']) + expect(nodes.find((n) => n.name === 'notify')?.isFinally).toBe(true) + expect(nodes.find((n) => n.name === 'build')?.isFinally).toBeUndefined() + }) + + it('depends on every terminal regular task, since Tekton waits for all of them regardless of result-refs', () => { + // build and lint both run after start and don't feed each other — + // notify has to wait for both, even though it only reads build's result. + const nodes = buildPipelineTaskGraph({ + tasks: [ + { name: 'start', params: [] }, + { name: 'build', runAfter: ['start'], params: [] }, + { name: 'lint', runAfter: ['start'], params: [] }, + ], + finally: [ + { name: 'notify', params: [{ name: 'status', value: '$(tasks.build.results.outcome)' }] }, + ], + }) + expect(depsOf(nodes, 'notify').sort()).toEqual(['build', 'lint']) + }) + + it('depends only on the single terminal task in a linear pipeline (no fan-out to prune)', () => { + const nodes = buildPipelineTaskGraph({ + tasks: [ + { name: 'clone', params: [] }, + { name: 'build', runAfter: ['clone'], params: [] }, + ], + finally: [{ name: 'cleanup', params: [] }], + }) + expect(depsOf(nodes, 'cleanup')).toEqual(['build']) + }) + + it('never depends on another finally task', () => { + const nodes = buildPipelineTaskGraph({ + tasks: [{ name: 'build', params: [] }], + finally: [ + { name: 'notify-slack', params: [] }, + { name: 'notify-email', params: [] }, + ], + }) + expect(depsOf(nodes, 'notify-slack')).toEqual(['build']) + expect(depsOf(nodes, 'notify-email')).toEqual(['build']) + }) + }) }) diff --git a/packages/k8s-ui/src/components/resources/resource-utils-tekton.ts b/packages/k8s-ui/src/components/resources/resource-utils-tekton.ts index 633ba4b235..0d409a0e59 100644 --- a/packages/k8s-ui/src/components/resources/resource-utils-tekton.ts +++ b/packages/k8s-ui/src/components/resources/resource-utils-tekton.ts @@ -85,6 +85,11 @@ export interface TektonTaskNode { // hasn't reached yet. Presence is what makes a DAG node clickable: nothing // to open for a task that never ran. taskRunName?: string + // True for a spec.finally task. Tekton runs these only after every regular + // PipelineTask has settled (success or failure) — consumers that want to + // set them apart visually (a cleanup/notify row after the main DAG) can key + // off this instead of re-deriving it from dependsOn. + isFinally?: boolean } // Tekton infers task ordering two ways: an explicit `runAfter` list, and @@ -111,7 +116,8 @@ function resultRefsIn(value: unknown, out: Set): void { export function buildPipelineTaskGraph(pipelineSpec: any): TektonTaskNode[] { const tasks: any[] = pipelineSpec?.tasks ?? [] - const nodes = tasks.map((task) => { + const regularNames = new Set(tasks.map((t) => t.name)) + const regularNodes = tasks.map((task) => { const deps = new Set(task.runAfter ?? []) resultRefsIn(task.params, deps) resultRefsIn(task.when, deps) @@ -119,7 +125,29 @@ export function buildPipelineTaskGraph(pipelineSpec: any): TektonTaskNode[] { deps.delete(task.name) return { name: task.name, dependsOn: [...deps] } }) - return reduceToDirectDeps(nodes) + // finally tasks don't support runAfter (Tekton rejects the field there) — + // the ordering constraint is implicit and absolute: every finally task + // starts only once ALL regular PipelineTasks have settled, success or + // failure, not just the ones it happens to read a result from. Model that + // as a dependency on every regular task with no other regular task + // depending on it (the DAG's own terminal/leaf tasks) — reduceToDirectDeps + // below then drops any of a finally task's own result-ref deps that are + // already implied by one of those barrier edges. + const dependedOn = new Set(regularNodes.flatMap((n) => n.dependsOn)) + const terminalRegularTasks = regularNodes.filter((n) => !dependedOn.has(n.name)).map((n) => n.name) + const finallyTasks: any[] = pipelineSpec?.finally ?? [] + const finallyNodes = finallyTasks.map((task) => { + const deps = new Set(terminalRegularTasks) + resultRefsIn(task.params, deps) + resultRefsIn(task.when, deps) + // A finally task's result-refs only ever point at regular tasks, never at + // other finally tasks (Tekton doesn't allow it) — but guard anyway so a + // malformed spec can't produce a dep on an unknown/finally name. + for (const d of deps) if (!regularNames.has(d)) deps.delete(d) + deps.delete(task.name) + return { name: task.name, dependsOn: [...deps], isFinally: true } + }) + return reduceToDirectDeps([...regularNodes, ...finallyNodes]) } // reduceToDirectDeps computes the transitive reduction of the dependency diff --git a/web/src/components/cicd/CicdView.tsx b/web/src/components/cicd/CicdView.tsx index 4f92fa27dc..109af8ea36 100644 --- a/web/src/components/cicd/CicdView.tsx +++ b/web/src/components/cicd/CicdView.tsx @@ -12,6 +12,7 @@ import { RowActionMenu, ConfirmDialog, Tooltip, + buildPipelineTaskGraph, getTektonPipelineRunStatus, tektonRefName, formatAge, @@ -103,7 +104,10 @@ function runDurationMs(run: any): number | null { // only tells us "a TaskRun was created for this task," not its outcome — an // honest "how far did it get" proxy, not "how much succeeded"). function taskProgress(run: any): { started: number; total: number } { - const total = (run?.status?.pipelineSpec?.tasks ?? []).length + // buildPipelineTaskGraph, not a bare pipelineSpec.tasks.length, so finally + // tasks count toward the total — Tekton runs them and includes their + // outcome in the PipelineRun result same as any regular task. + const total = buildPipelineTaskGraph(run?.status?.pipelineSpec ?? {}).length const started = (run?.status?.childReferences ?? []).length return { started, total } } From b0d40b8f5b77e9da0727ff8dd4f4511d235d78f3 Mon Sep 17 00:00:00 2001 From: jfillman Date: Thu, 3 Sep 2026 22:55:32 -0700 Subject: [PATCH 23/37] fix(tekton): aggregate matrix TaskRun statuses instead of keeping the last one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit buildChildTaskRunRefs keyed its map by pipelineTaskName alone, so a matrix-strategy task's several childReferences (one per parameter combination, all sharing that name) overwrote each other — only the last-listed sibling's status ever reached the DAG, and a click could land on an arbitrary matrix child instead of an actionable one. Return every childReference per task name instead of one, fan out a query per actual TaskRun (not per declared task), and collapse the results with worst-status-wins ranking (failed > running > unknown/ pending > skipped > succeeded) — a single failure among nine successes now surfaces as failed, and the DAG node's navigation target is the failed sibling, not whichever happened to be listed last. Also fixed a second, related staleness bug in the same component: the fullscreen DAG's array-stabilization signature covered name/status/ reason/taskRunName but not dependsOn, so a Pipeline edit that changed runAfter or a result-ref dependency without renaming any task left the DAG showing the old topology until something else (a status change) happened to bust the cache. Co-Authored-By: Claude Sonnet 5 --- .../resources/resource-utils-tekton.test.ts | 60 ++++++++++++++++++- .../resources/resource-utils-tekton.ts | 41 ++++++++++++- .../execution/TektonPipelineFullscreen.tsx | 43 ++++++++----- 3 files changed, 126 insertions(+), 18 deletions(-) diff --git a/packages/k8s-ui/src/components/resources/resource-utils-tekton.test.ts b/packages/k8s-ui/src/components/resources/resource-utils-tekton.test.ts index e76640c3af..bfc5f78582 100644 --- a/packages/k8s-ui/src/components/resources/resource-utils-tekton.test.ts +++ b/packages/k8s-ui/src/components/resources/resource-utils-tekton.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { buildPipelineTaskGraph, type TektonTaskNode } from './resource-utils-tekton' +import { aggregateMatrixStatuses, buildChildTaskRunRefs, buildPipelineTaskGraph, type TektonTaskNode } from './resource-utils-tekton' function depsOf(nodes: TektonTaskNode[], name: string): string[] { return nodes.find((n) => n.name === name)?.dependsOn ?? [] @@ -143,3 +143,61 @@ describe('buildPipelineTaskGraph', () => { }) }) }) + +describe('buildChildTaskRunRefs', () => { + it('returns one entry for a non-matrix task', () => { + const refs = buildChildTaskRunRefs({ + childReferences: [{ kind: 'TaskRun', pipelineTaskName: 'build', name: 'run-build' }], + }) + expect(refs.get('build')).toEqual([{ taskRunName: 'run-build' }]) + }) + + it('keeps every childReference for a matrix task instead of overwriting to the last one', () => { + const refs = buildChildTaskRunRefs({ + childReferences: [ + { kind: 'TaskRun', pipelineTaskName: 'test', name: 'run-test-0' }, + { kind: 'TaskRun', pipelineTaskName: 'test', name: 'run-test-1' }, + { kind: 'TaskRun', pipelineTaskName: 'test', name: 'run-test-2' }, + ], + }) + expect(refs.get('test')).toEqual([ + { taskRunName: 'run-test-0' }, + { taskRunName: 'run-test-1' }, + { taskRunName: 'run-test-2' }, + ]) + }) +}) + +describe('aggregateMatrixStatuses', () => { + it('is a no-op for a single entry', () => { + const got = aggregateMatrixStatuses([{ status: 'succeeded', taskRunName: 'a' }]) + expect(got).toEqual({ status: 'succeeded', taskRunName: 'a' }) + }) + + it('a failure wins over succeeded and running siblings', () => { + const got = aggregateMatrixStatuses([ + { status: 'succeeded', taskRunName: 'a' }, + { status: 'failed', reason: 'TaskRunTimeout', taskRunName: 'b' }, + { status: 'running', taskRunName: 'c' }, + ]) + expect(got).toEqual({ status: 'failed', reason: 'TaskRunTimeout', taskRunName: 'b' }) + }) + + it('running wins over pending/unknown/skipped/succeeded when nothing failed', () => { + const got = aggregateMatrixStatuses([ + { status: 'succeeded', taskRunName: 'a' }, + { status: 'skipped', taskRunName: 'b' }, + { status: 'running', taskRunName: 'c' }, + { status: 'pending', taskRunName: 'd' }, + ]) + expect(got.status).toBe('running') + }) + + it('succeeded only wins when every sibling succeeded', () => { + const got = aggregateMatrixStatuses([ + { status: 'succeeded', taskRunName: 'a' }, + { status: 'succeeded', taskRunName: 'b' }, + ]) + expect(got.status).toBe('succeeded') + }) +}) diff --git a/packages/k8s-ui/src/components/resources/resource-utils-tekton.ts b/packages/k8s-ui/src/components/resources/resource-utils-tekton.ts index 0d409a0e59..e391726043 100644 --- a/packages/k8s-ui/src/components/resources/resource-utils-tekton.ts +++ b/packages/k8s-ui/src/components/resources/resource-utils-tekton.ts @@ -188,22 +188,57 @@ function reduceToDirectDeps(nodes: TektonTaskNode[]): TektonTaskNode[] { // keyed by pipelineTaskName — but a task the run hasn't reached yet (still // pending, or skipped by a `when` guard) has no childReference at all, so // absence from the map means "not started," not an error. +// +// A `matrix`-strategy task expands into several childReferences that all +// share the same pipelineTaskName (one per parameter combination) — return +// every one of them, in order, rather than keeping only the last. A caller +// that only wants a single representative (a non-matrix task always has +// exactly one) can just read index 0. export interface TektonChildTaskRun { taskRunName: string status?: TektonTaskNodeStatus reason?: string } -export function buildChildTaskRunRefs(pipelineRunStatus: any): Map { - const refs = new Map() +export function buildChildTaskRunRefs(pipelineRunStatus: any): Map { + const refs = new Map() for (const child of pipelineRunStatus?.childReferences ?? []) { if (child?.kind === 'TaskRun' && child?.pipelineTaskName && child?.name) { - refs.set(child.pipelineTaskName, { taskRunName: child.name }) + const existing = refs.get(child.pipelineTaskName) + const entry = { taskRunName: child.name } + if (existing) existing.push(entry) + else refs.set(child.pipelineTaskName, [entry]) } } return refs } +// STATUS_SEVERITY ranks how "worth surfacing" a status is when a single DAG +// node has to summarize several TaskRuns at once (a matrix task's parallel +// expansions) — the worst outcome wins, matching how any CI dashboard reads +// a fan-out: one failure is the headline even if the other nine succeeded. +const STATUS_SEVERITY: Record = { + failed: 0, + running: 1, + unknown: 2, + pending: 3, + skipped: 4, + succeeded: 5, +} + +// aggregateMatrixStatuses collapses a matrix task's several live statuses +// (one per childReferences entry sharing its pipelineTaskName) into the one +// the DAG node shows, plus the TaskRun navigation should land on — the +// worst-ranked entry in both cases, so a click always reaches an actionable +// (e.g. failed) run instead of an arbitrary sibling. +export function aggregateMatrixStatuses( + entries: Array<{ status: TektonTaskNodeStatus; reason?: string; taskRunName: string }>, +): { status: TektonTaskNodeStatus; reason?: string; taskRunName: string } { + return entries.reduce((worst, entry) => + STATUS_SEVERITY[entry.status] < STATUS_SEVERITY[worst.status] ? entry : worst, + ) +} + export function tektonNodeStatusFromConditions(conditions: any[] | undefined): { status: TektonTaskNodeStatus; reason?: string } { const cond = succeededCondition(conditions) if (!cond) return { status: 'unknown' } diff --git a/web/src/components/execution/TektonPipelineFullscreen.tsx b/web/src/components/execution/TektonPipelineFullscreen.tsx index e828b99dfa..3152a3576c 100644 --- a/web/src/components/execution/TektonPipelineFullscreen.tsx +++ b/web/src/components/execution/TektonPipelineFullscreen.tsx @@ -2,6 +2,7 @@ import { useCallback, useMemo, useRef } from 'react' import { useQueries } from '@tanstack/react-query' import { PipelineDagView } from '@skyhook-io/k8s-ui/components/resources/renderers/PipelineDagView' import { + aggregateMatrixStatuses, applyTaskRunStatuses, buildChildTaskRunRefs, buildPipelineTaskGraph, @@ -35,13 +36,19 @@ export function TektonPipelineFullscreen({ kind, namespace, name, resource, onNa const declaredTasks = useMemo(() => buildPipelineTaskGraph(pipelineSpec), [pipelineSpec]) const childRefs = useMemo( - () => (isRun ? buildChildTaskRunRefs(status) : new Map()), + () => (isRun ? buildChildTaskRunRefs(status) : new Map()), [isRun, status], ) - const entries = useMemo(() => [...childRefs.entries()], [childRefs]) + // Flattened one entry per actual TaskRun — a matrix task contributes + // several entries sharing the same pipelineTaskName, each needing its own + // fetch, not just the first/last one. + const flatChildren = useMemo( + () => [...childRefs.entries()].flatMap(([pipelineTaskName, refs]) => refs.map((ref) => [pipelineTaskName, ref] as const)), + [childRefs], + ) const queries = useQueries({ - queries: entries.map(([, ref]) => ({ + queries: flatChildren.map(([, ref]) => ({ queryKey: ['resource', 'taskruns', namespace, ref.taskRunName, 'tekton.dev'], queryFn: async () => fetchJSON<{ resource: any }>(`/resources/taskruns/${namespace || '_'}/${ref.taskRunName}?group=tekton.dev`), staleTime: 5000, @@ -53,23 +60,31 @@ export function TektonPipelineFullscreen({ kind, namespace, name, resource, onNa const rawTasks: TektonTaskNode[] = useMemo(() => { if (!isRun) return declaredTasks - const statusByTaskName = new Map() - entries.forEach(([pipelineTaskName, ref], i) => { + const liveByTaskName = new Map>() + flatChildren.forEach(([pipelineTaskName, ref], i) => { const q = queries[i] - if (q.isLoading || !q.data) { - statusByTaskName.set(pipelineTaskName, { status: 'unknown', taskRunName: ref.taskRunName }) - return - } - const live = tektonNodeStatusFromConditions(q.data.resource?.status?.conditions) - statusByTaskName.set(pipelineTaskName, { ...live, taskRunName: ref.taskRunName }) + const live = q.isLoading || !q.data + ? { status: 'unknown' as const, taskRunName: ref.taskRunName } + : { ...tektonNodeStatusFromConditions(q.data.resource?.status?.conditions), taskRunName: ref.taskRunName } + const existing = liveByTaskName.get(pipelineTaskName) + if (existing) existing.push(live) + else liveByTaskName.set(pipelineTaskName, [live]) }) + // A non-matrix task's array always has exactly one entry, so aggregation + // is a no-op there — only a matrix task's several parallel expansions + // actually get collapsed, worst-status-wins, into the one node the DAG + // shows. + const statusByTaskName = new Map() + for (const [pipelineTaskName, live] of liveByTaskName) { + statusByTaskName.set(pipelineTaskName, aggregateMatrixStatuses(live)) + } return applyTaskRunStatuses(declaredTasks, statusByTaskName) - // queries is a fresh array each render from useQueries; entries + isLoading/data + // queries is a fresh array each render from useQueries; flatChildren + isLoading/data // per-entry is what actually needs to retrigger this, which the queries objects // capture already — omitting `queries` itself from deps would be wrong since its // contents (not identity) are what we read, so keep it. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isRun, declaredTasks, entries, queries]) + }, [isRun, declaredTasks, flatChildren, queries]) // Stabilize the array reference by content, not just recompute on every // render. `queries` is a fresh array from useQueries every render (even @@ -81,7 +96,7 @@ export function TektonPipelineFullscreen({ kind, namespace, name, resource, onNa // actually changes, so the observer has nothing to report back). const tasksSigRef = useRef('') const tasksRef = useRef(rawTasks) - const sig = rawTasks.map((t) => `${t.name}:${t.status ?? ''}:${t.reason ?? ''}:${t.taskRunName ?? ''}`).join('|') + const sig = rawTasks.map((t) => `${t.name}:${t.dependsOn.join(',')}:${t.status ?? ''}:${t.reason ?? ''}:${t.taskRunName ?? ''}`).join('|') if (sig !== tasksSigRef.current) { tasksSigRef.current = sig tasksRef.current = rawTasks From 3850536bf07f3a662349f11d0cd7059b76a8e5a3 Mon Sep 17 00:00:00 2001 From: jfillman Date: Thu, 3 Sep 2026 22:57:24 -0700 Subject: [PATCH 24/37] fix(tekton): show a skipped task as skipped, not pending forever MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A task Tekton decides not to run (a false when guard, or a parent that failed/was itself skipped) never gets a childReference — the same absence-from-the-map signal applyTaskRunStatuses used for "not reached yet." A completed PipelineRun with a skipped task therefore showed it as permanently pending, with no way to tell "hasn't started" from "was never going to run." Tekton already records the distinction in status.skippedTasks[], with the reason it decided to skip each one. Read that and mark a matching task 'skipped' (surfacing the real reason) before falling back to 'pending' — PipelineDagView already has skipped styling, it just never got exercised for this case. Co-Authored-By: Claude Sonnet 5 --- .../resources/resource-utils-tekton.test.ts | 54 ++++++++++++++++++- .../resources/resource-utils-tekton.ts | 28 ++++++++-- .../execution/TektonPipelineFullscreen.tsx | 5 +- 3 files changed, 79 insertions(+), 8 deletions(-) diff --git a/packages/k8s-ui/src/components/resources/resource-utils-tekton.test.ts b/packages/k8s-ui/src/components/resources/resource-utils-tekton.test.ts index bfc5f78582..4a6d764737 100644 --- a/packages/k8s-ui/src/components/resources/resource-utils-tekton.test.ts +++ b/packages/k8s-ui/src/components/resources/resource-utils-tekton.test.ts @@ -1,5 +1,12 @@ import { describe, expect, it } from 'vitest' -import { aggregateMatrixStatuses, buildChildTaskRunRefs, buildPipelineTaskGraph, type TektonTaskNode } from './resource-utils-tekton' +import { + aggregateMatrixStatuses, + applyTaskRunStatuses, + buildChildTaskRunRefs, + buildPipelineTaskGraph, + buildSkippedTaskReasons, + type TektonTaskNode, +} from './resource-utils-tekton' function depsOf(nodes: TektonTaskNode[], name: string): string[] { return nodes.find((n) => n.name === name)?.dependsOn ?? [] @@ -201,3 +208,48 @@ describe('aggregateMatrixStatuses', () => { expect(got.status).toBe('succeeded') }) }) + +describe('buildSkippedTaskReasons', () => { + it('reads name + reason off status.skippedTasks', () => { + const reasons = buildSkippedTaskReasons({ + skippedTasks: [{ name: 'deploy-canary', reason: 'When Expressions evaluated to false' }], + }) + expect(reasons.get('deploy-canary')).toBe('When Expressions evaluated to false') + }) + + it('is empty for a status with no skippedTasks', () => { + expect(buildSkippedTaskReasons({}).size).toBe(0) + }) +}) + +describe('applyTaskRunStatuses', () => { + const tasks: TektonTaskNode[] = [ + { name: 'build', dependsOn: [] }, + { name: 'deploy-canary', dependsOn: ['build'] }, + ] + + it('a task named in skippedTaskReasons is skipped, not pending, when it has no live status', () => { + const got = applyTaskRunStatuses(tasks, new Map(), buildSkippedTaskReasons({ + skippedTasks: [{ name: 'deploy-canary', reason: 'When Expressions evaluated to false' }], + })) + const deploy = got.find((t) => t.name === 'deploy-canary') + expect(deploy?.status).toBe('skipped') + expect(deploy?.reason).toBe('When Expressions evaluated to false') + }) + + it('falls back to pending when a task is neither live nor listed as skipped', () => { + const got = applyTaskRunStatuses(tasks, new Map()) + expect(got.find((t) => t.name === 'deploy-canary')?.status).toBe('pending') + }) + + it('a live status always wins over a skipped-tasks listing for the same task', () => { + // Shouldn't happen in practice (Tekton never creates a childReference for + // a task it also lists in skippedTasks), but live data must win if it did. + const got = applyTaskRunStatuses( + tasks, + new Map([['deploy-canary', { status: 'succeeded', taskRunName: 'run-deploy-canary' }]]), + buildSkippedTaskReasons({ skippedTasks: [{ name: 'deploy-canary', reason: 'stale' }] }), + ) + expect(got.find((t) => t.name === 'deploy-canary')?.status).toBe('succeeded') + }) +}) diff --git a/packages/k8s-ui/src/components/resources/resource-utils-tekton.ts b/packages/k8s-ui/src/components/resources/resource-utils-tekton.ts index e391726043..defeb6fb9c 100644 --- a/packages/k8s-ui/src/components/resources/resource-utils-tekton.ts +++ b/packages/k8s-ui/src/components/resources/resource-utils-tekton.ts @@ -252,17 +252,35 @@ export function tektonNodeStatusFromConditions(conditions: any[] | undefined): { return { status: 'running', reason: cond.reason } } +// A task Tekton skips (a false `when` guard, or a parent that failed/was +// itself skipped) never gets a childReference — same absence-from-the-map +// signal as a task that simply hasn't been reached yet. The two cases are +// told apart only by status.skippedTasks[], which names every task the +// controller decided not to run, with the reason it gave for each. +export function buildSkippedTaskReasons(pipelineRunStatus: any): Map { + const reasons = new Map() + for (const skipped of pipelineRunStatus?.skippedTasks ?? []) { + if (skipped?.name) reasons.set(skipped.name, skipped.reason) + } + return reasons +} + // Merges the Pipeline's declared task graph with live per-task status. Tasks -// with no matching child (not yet reached) render as 'pending' and stay -// non-clickable (no taskRunName — there's genuinely nothing to open yet). +// with no matching child are 'skipped' when status.skippedTasks names them +// (a completed run's when-guard/parent-skip outcome), else 'pending' (not +// yet reached) — either way non-clickable, since there's genuinely nothing +// to open. export function applyTaskRunStatuses( tasks: TektonTaskNode[], statusByTaskName: Map, + skippedTaskReasons: Map = new Map(), ): TektonTaskNode[] { return tasks.map((task) => { const live = statusByTaskName.get(task.name) - return live - ? { ...task, status: live.status, reason: live.reason, taskRunName: live.taskRunName } - : { ...task, status: 'pending' } + if (live) return { ...task, status: live.status, reason: live.reason, taskRunName: live.taskRunName } + if (skippedTaskReasons.has(task.name)) { + return { ...task, status: 'skipped', reason: skippedTaskReasons.get(task.name) } + } + return { ...task, status: 'pending' } }) } diff --git a/web/src/components/execution/TektonPipelineFullscreen.tsx b/web/src/components/execution/TektonPipelineFullscreen.tsx index 3152a3576c..4d19c647a5 100644 --- a/web/src/components/execution/TektonPipelineFullscreen.tsx +++ b/web/src/components/execution/TektonPipelineFullscreen.tsx @@ -6,6 +6,7 @@ import { applyTaskRunStatuses, buildChildTaskRunRefs, buildPipelineTaskGraph, + buildSkippedTaskReasons, tektonNodeStatusFromConditions, tektonRefName, type TektonTaskNode, @@ -78,13 +79,13 @@ export function TektonPipelineFullscreen({ kind, namespace, name, resource, onNa for (const [pipelineTaskName, live] of liveByTaskName) { statusByTaskName.set(pipelineTaskName, aggregateMatrixStatuses(live)) } - return applyTaskRunStatuses(declaredTasks, statusByTaskName) + return applyTaskRunStatuses(declaredTasks, statusByTaskName, buildSkippedTaskReasons(status)) // queries is a fresh array each render from useQueries; flatChildren + isLoading/data // per-entry is what actually needs to retrigger this, which the queries objects // capture already — omitting `queries` itself from deps would be wrong since its // contents (not identity) are what we read, so keep it. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isRun, declaredTasks, flatChildren, queries]) + }, [isRun, declaredTasks, flatChildren, queries, status]) // Stabilize the array reference by content, not just recompute on every // render. `queries` is a fresh array from useQueries every render (even From 72d68db13e9c8b902953d191564ca5cd656ad039 Mon Sep 17 00:00:00 2001 From: jfillman Date: Thu, 3 Sep 2026 22:59:59 -0700 Subject: [PATCH 25/37] fix(nav): seed Tekton kinds into the pre-discovery plural-to-kind map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit qodo/cursor flagged the TaskRun-to-parent-PipelineRun chip as always null, blaming a plural/singular mismatch — that specific claim doesn't hold: WorkloadView's kind prop is a plural ("taskruns"), but the component already derives a singular `kind = pluralToKind(kindProp)` before the comparison, and once API discovery has populated the dynamic map (effectively always, by the time a user can navigate to a TaskRun), pluralToKind('taskruns') correctly resolves to 'TaskRun'. There's a real, narrower gap behind the same finding though: BUILTIN_PLURAL_TO_KIND (the fallback used before discovery completes) never had Tekton's kinds, even though it already seeds Argo Workflow's for exactly this reason. Tekton's "Run" suffix doesn't naively de-pluralize to the right casing either — the heuristic fallback would produce "Taskrun", not "TaskRun" — so a very early render, or a restricted-RBAC cluster where Tekton CRD discovery is delayed, could still show the chip as missing. Seeded pipelines/pipelineruns/taskruns the same way workflows already are. Co-Authored-By: Claude Sonnet 5 --- packages/k8s-ui/src/utils/navigation.test.ts | 13 +++++++++++++ packages/k8s-ui/src/utils/navigation.ts | 3 +++ 2 files changed, 16 insertions(+) diff --git a/packages/k8s-ui/src/utils/navigation.test.ts b/packages/k8s-ui/src/utils/navigation.test.ts index b1b413edb6..e2df1e76ff 100644 --- a/packages/k8s-ui/src/utils/navigation.test.ts +++ b/packages/k8s-ui/src/utils/navigation.test.ts @@ -190,6 +190,19 @@ describe('pluralToKind', () => { // of the heuristic fallback. Known kinds use the PLURAL_TO_KIND map instead. expect(pluralToKind('databases')).toBe('Databas') }) + + // Tekton's "Run" suffix (TaskRun, PipelineRun) doesn't naively de-pluralize + // to the right casing — the fallback heuristic would strip the trailing + // "s" and capitalize only the first letter, producing "Taskrun" — so these + // need the same seeded-before-discovery treatment Workflow/CronWorkflow + // already get, or a component that compares against the exact PascalCase + // kind (e.g. "kind === 'TaskRun'") silently never matches before API + // discovery has populated the real map. + test('Tekton kinds resolve correctly even before the discovery map arrives', () => { + expect(pluralToKind('taskruns')).toBe('TaskRun') + expect(pluralToKind('pipelineruns')).toBe('PipelineRun') + expect(pluralToKind('pipelines')).toBe('Pipeline') + }) }) describe('initNavigationMap', () => { diff --git a/packages/k8s-ui/src/utils/navigation.ts b/packages/k8s-ui/src/utils/navigation.ts index b355930142..dc9cf1adee 100644 --- a/packages/k8s-ui/src/utils/navigation.ts +++ b/packages/k8s-ui/src/utils/navigation.ts @@ -30,6 +30,9 @@ const BUILTIN_PLURAL_TO_KIND: Record = { cronworkflows: 'CronWorkflow', workflowtemplates: 'WorkflowTemplate', clusterworkflowtemplates: 'ClusterWorkflowTemplate', + pipelines: 'Pipeline', + pipelineruns: 'PipelineRun', + taskruns: 'TaskRun', horizontalpodautoscalers: 'HorizontalPodAutoscaler', persistentvolumeclaims: 'PersistentVolumeClaim', persistentvolumes: 'PersistentVolume', From 35d8aa0eb1cc825254fb9a7b943fca3377b29fa7 Mon Sep 17 00:00:00 2001 From: jfillman Date: Thu, 3 Sep 2026 23:03:07 -0700 Subject: [PATCH 26/37] fix(tekton): show an error state in the CI/CD fleet, not a false empty list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit runsQuery.data falling back to `?? []` on a failed fetch meant an RBAC denial, a backend error, or a discovery failure all rendered as "No PipelineRuns found" — a healthy-looking empty fleet indistinguishable from an actual problem. Added an explicit error branch (special-cased for 403) with a Retry button. The Tasks Running tile now also treats its own query error the same as still-loading — a 0 there was a false zero, not a real count, same reasoning the component's own loading prop already documents. Co-Authored-By: Claude Sonnet 5 --- web/src/components/cicd/CicdView.tsx | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/web/src/components/cicd/CicdView.tsx b/web/src/components/cicd/CicdView.tsx index 109af8ea36..f1bf8986ce 100644 --- a/web/src/components/cicd/CicdView.tsx +++ b/web/src/components/cicd/CicdView.tsx @@ -1,6 +1,6 @@ import { useCallback, useMemo, useState } from 'react' import { useQuery } from '@tanstack/react-query' -import { Ban, ArrowDown, ArrowUp, ArrowUpDown, ListChecks, Search, Trash2, Workflow } from 'lucide-react' +import { AlertTriangle, Ban, ArrowDown, ArrowUp, ArrowUpDown, ListChecks, RefreshCw, Search, Trash2, Workflow } from 'lucide-react' import { clsx } from 'clsx' import yamlLib from 'yaml' import { @@ -20,7 +20,7 @@ import { type SummaryTone, type RowActionItem, } from '@skyhook-io/k8s-ui' -import { fetchJSON, useBulkDeleteResources, useDeleteResource, useUpdateResource } from '../../api/client' +import { fetchJSON, isForbiddenError, useBulkDeleteResources, useDeleteResource, useUpdateResource } from '../../api/client' interface CicdViewProps { namespaces: string[] @@ -396,7 +396,7 @@ export function CicdView({ namespaces, onOpenPipelineRun, onOpenPipeline }: Cicd tone={(stats.successRate == null ? 'neutral' : stats.successRate >= 90 ? 'success' : stats.successRate >= 70 ? 'warning' : 'error') as SummaryTone} loading={runsQuery.isLoading} /> - + } /> @@ -512,6 +512,23 @@ export function CicdView({ namespaces, onOpenPipelineRun, onOpenPipeline }: Cicd
{runsQuery.isLoading ? (
Loading PipelineRuns…
+ ) : runsQuery.isError ? ( +
+ + + {isForbiddenError(runsQuery.error) + ? "You don't have permission to list PipelineRuns." + : `Failed to load PipelineRuns: ${runsQuery.error instanceof Error ? runsQuery.error.message : 'unknown error'}`} + + +
) : filteredRuns.length === 0 ? (
{runs.length === 0 ? 'No PipelineRuns found.' : 'No PipelineRuns match the current filters.'} From bb86f8f3d918e0e7fc8b1906d73bd8e6038e3d6b Mon Sep 17 00:00:00 2001 From: jfillman Date: Thu, 3 Sep 2026 23:07:22 -0700 Subject: [PATCH 27/37] fix(tekton): read the real step container name; poll running logs; fix a log race MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three related TaskRun log bugs, found by qodo-code-review: - Container names were synthesized as step- in three places instead of reading status.steps[].container, the field Tekton actually reports. Every live example checked matches the synthesized form today, but Tekton doesn't guarantee it once a step name gets sanitized or truncated to fit Kubernetes' container-name limit — the authoritative field is one field access away and was sitting unused (one comment even claimed it wasn't reported back, which isn't true). Kept the synthesized form only as a fallback for an older/stripped status shape. - TaskRunLogsTab fetched one snapshot on mount and never again unless the user manually refreshed — output from a currently running step stayed invisible. Added polling every 5s while the TaskRun's Succeeded condition hasn't settled, stopping as soon as it does (or the component unmounts). - The same fetch had no guard against out-of-order responses: if podName changed (a different TaskRun) while a previous fetch was still in flight, a late-arriving stale response could overwrite the fresh one. Added a request-generation guard, and clear the buffer when the pod actually changes (not on a plain manual refresh of the same pod, which intentionally leaves it alone so the view doesn't flash empty). Co-Authored-By: Claude Sonnet 5 --- .../resources/renderers/TaskRunRenderer.tsx | 13 ++++--- web/src/components/logs/TaskRunLogsTab.tsx | 39 +++++++++++++++++-- .../resources/renderers/TaskRunRenderer.tsx | 6 ++- 3 files changed, 47 insertions(+), 11 deletions(-) diff --git a/packages/k8s-ui/src/components/resources/renderers/TaskRunRenderer.tsx b/packages/k8s-ui/src/components/resources/renderers/TaskRunRenderer.tsx index 48f3f2e5ac..9212a95e8f 100644 --- a/packages/k8s-ui/src/components/resources/renderers/TaskRunRenderer.tsx +++ b/packages/k8s-ui/src/components/resources/renderers/TaskRunRenderer.tsx @@ -14,10 +14,13 @@ interface TaskRunRendererProps { onNavigate?: (ref: ResourceRef) => void } -// Tekton names each step's container `step-` inside the TaskRun's -// pod — stable across Tekton versions, not something the API reports back. -function stepContainerName(stepName: string): string { - return `step-${stepName}` +// Tekton reports the real container name at status.steps[].container — read +// that first. The `step-` convention is only a fallback for a +// status shape that's missing the field (an older/stripped object); Tekton +// doesn't guarantee the naive prefix once a step name gets sanitized or +// truncated to fit Kubernetes' container-name limits. +function stepContainerName(step: { name: string; container?: string }): string { + return step.container ?? `step-${step.name}` } function StepRow({ step, podName, onViewLogs }: { step: any; podName?: string; onViewLogs?: (podName: string, containerName: string) => void }) { @@ -48,7 +51,7 @@ function StepRow({ step, podName, onViewLogs }: { step: any; podName?: string; o {canViewLogs && (