From 617df7c2c32fc185392d828a4b7e0d9a5cac8b43 Mon Sep 17 00:00:00 2001 From: Nadav Erell Date: Sat, 4 Jul 2026 02:14:47 +0300 Subject: [PATCH 1/7] Add run-aware logs for jobs and workflows --- docs/mcp.md | 2 +- internal/k8s/workload.go | 23 + internal/mcp/tools.go | 2 +- internal/mcp/tools_filter_test.go | 6 + internal/mcp/tools_workloads.go | 153 ++++++- internal/mcp/tools_workloads_test.go | 84 ++++ internal/server/server.go | 1 + internal/server/workload_logs.go | 432 +++++++++++++++++- internal/server/workload_logs_test.go | 290 ++++++++++++ .../components/logs/WorkloadLogsViewer.tsx | 31 +- .../src/components/logs/useLogStream.ts | 9 +- .../components/resources/ResourcesView.tsx | 4 +- .../components/shared/ResourceActionsBar.tsx | 49 +- .../src/components/workload/WorkloadView.tsx | 5 +- packages/k8s-ui/src/types/core.ts | 3 + packages/k8s-ui/src/utils/navigation.ts | 3 + web/src/api/client.ts | 28 ++ web/src/components/dock/WorkloadLogsTab.tsx | 23 +- web/src/components/home/mcpToolCatalog.ts | 4 +- .../logs/ScheduledWorkloadLogsViewer.tsx | 125 +++++ web/src/components/workload/WorkloadView.tsx | 27 +- 21 files changed, 1254 insertions(+), 50 deletions(-) create mode 100644 internal/mcp/tools_workloads_test.go create mode 100644 internal/server/workload_logs_test.go create mode 100644 web/src/components/logs/ScheduledWorkloadLogsViewer.tsx diff --git a/docs/mcp.md b/docs/mcp.md index dbaea7540..fbe52d872 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -194,7 +194,7 @@ Add to `~/.gemini/settings.json`: | `get_events` | Recent Kubernetes Warning events, deduplicated and sorted by recency. Filter by resource kind/name to scope. | `namespace` (optional), `limit` (optional, default 20, max 100), `kind` (optional), `name` (optional) | | `get_changes` | Recent meaningful changes from the Kubernetes cluster timeline plus native Helm release deployment/operation history (`source: helm`). Use to investigate what changed before an incident, including failed upgrades, rollbacks, and current Helm revisions. If the response includes `sourcesErrored`, treat it as partial data for those sources. Use `get_helm_release include=history,operations` for the full Helm revision trail. | `namespace` (optional), `kind` (optional), `name` (optional), `since` (optional, e.g. `1h`, `30m`; default `1h`), `limit` (optional, default 20, max 50) | | `get_pod_logs` | Filtered pod logs prioritizing errors/warnings, with secret redaction. Set `grep` for server-side filtering. | `namespace` (required), `name` (required), `container` (optional), `tail_lines` (optional, default 200), `grep` (optional) | -| `get_workload_logs` | Aggregated, AI-filtered logs from all pods of a workload (Deployment, StatefulSet, DaemonSet) | `kind` (required), `namespace` (required), `name` (required), `container` (optional), `tail_lines` (optional, default 100 per pod), `grep` (optional) | +| `get_workload_logs` | Aggregated, AI-filtered logs from all pods of a workload (Deployment, StatefulSet, DaemonSet, Job, Argo Workflow) | `kind` (required), `namespace` (required), `name` (required), `container` (optional), `tail_lines` (optional, default 100 per pod), `grep` (optional) | | `get_cluster_audit` | Static config posture — best-practice findings (Security / Reliability / Efficiency) with remediation. INDEPENDENT of operational health; for "what's broken right now?" use `issues`. | `namespace` (optional), `category` (optional), `severity` (optional) | | `list_packages` | Installed packages (Helm releases, label-managed workloads, CRDs, Argo Applications, Flux HelmReleases + Kustomizations) with source provenance, versions, and health, in one call. Response includes `sourceLegend` for the stable source codes. | `namespace` (optional), `source` (optional: `H`/`helm`, `L`/`labels`, `C`/`crds`, `A`/`argocd`, `F`/`fluxcd`), `chart` (optional substring) | | `list_helm_releases` | List Helm releases with status, resource health, storage namespace, Flux ownership, current `lastOperation`, and a capped `operations` trail when Helm history indicates failed upgrades, rollback-after-failure, rollbacks, or stuck pending operations. Use this first for Helm deployment debugging. | `namespace` (optional) | diff --git a/internal/k8s/workload.go b/internal/k8s/workload.go index b5ff27af4..d6ec05cda 100644 --- a/internal/k8s/workload.go +++ b/internal/k8s/workload.go @@ -1,6 +1,7 @@ package k8s import ( + "context" "fmt" corev1 "k8s.io/api/core/v1" @@ -45,6 +46,28 @@ func GetWorkloadSelector(cache *ResourceCache, kind, namespace, name string) (*m } return ds.Spec.Selector, nil + case "job", "jobs": + lister := cache.Jobs() + if lister == nil { + return nil, fmt.Errorf("insufficient permissions to list jobs") + } + job, err := lister.Jobs(namespace).Get(name) + if err != nil { + return nil, fmt.Errorf("job %s/%s not found: %w", namespace, name, err) + } + if job.Spec.Selector == nil { + return nil, fmt.Errorf("job %s/%s has no pod selector", namespace, name) + } + return job.Spec.Selector, nil + + case "workflow", "workflows": + if _, err := cache.GetDynamicWithGroup(context.Background(), "Workflow", namespace, name, "argoproj.io"); err != nil { + return nil, fmt.Errorf("workflow %s/%s not found: %w", namespace, name, err) + } + return &metav1.LabelSelector{ + MatchLabels: map[string]string{"workflows.argoproj.io/workflow": name}, + }, nil + default: return nil, fmt.Errorf("unsupported workload kind: %s", kind) } diff --git a/internal/mcp/tools.go b/internal/mcp/tools.go index e82c221f0..963a5e0e5 100644 --- a/internal/mcp/tools.go +++ b/internal/mcp/tools.go @@ -450,7 +450,7 @@ func registerTools(server *mcp.Server) { mcp.AddTool(server, &mcp.Tool{ Name: "get_workload_logs", Description: "Get aggregated logs from all pods of a workload (Deployment, StatefulSet, " + - "or DaemonSet). Logs are collected from all matching pods concurrently, then " + + "DaemonSet, Job, or Argo Workflow). Logs are collected from all matching pods concurrently, then " + "server-side filtered to errors, warnings, panics, and stack traces using " + "deterministic regex patterns and deduplicated. Set grep for additional " + "server-side filtering before that summary stage, like `kubectl logs | grep PATTERN`. " + diff --git a/internal/mcp/tools_filter_test.go b/internal/mcp/tools_filter_test.go index 0eb102536..d51a80ab9 100644 --- a/internal/mcp/tools_filter_test.go +++ b/internal/mcp/tools_filter_test.go @@ -699,6 +699,12 @@ func TestNormalizeWorkloadLogsKind_DefaultsToDeployment(t *testing.T) { if got := normalizeWorkloadLogsKind("statefulset"); got != "statefulsets" { t.Fatalf("statefulset workload-log kind = %q, want statefulsets", got) } + if got := normalizeWorkloadLogsKind("job"); got != "jobs" { + t.Fatalf("job workload-log kind = %q, want jobs", got) + } + if got := normalizeWorkloadLogsKind("Workflow"); got != "workflows" { + t.Fatalf("Workflow workload-log kind = %q, want workflows", got) + } } func TestParseLogsSince(t *testing.T) { diff --git a/internal/mcp/tools_workloads.go b/internal/mcp/tools_workloads.go index 5b11d4e97..9530dfc36 100644 --- a/internal/mcp/tools_workloads.go +++ b/internal/mcp/tools_workloads.go @@ -12,7 +12,9 @@ import ( "time" "github.com/modelcontextprotocol/go-sdk/mcp" + batchv1 "k8s.io/api/batch/v1" corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "github.com/skyhook-io/radar/internal/k8s" aicontext "github.com/skyhook-io/radar/pkg/ai/context" @@ -36,7 +38,7 @@ type manageCronJobInput struct { } type getWorkloadLogsInput struct { - Kind string `json:"kind,omitempty" jsonschema:"workload kind: deployment, statefulset, or daemonset. Defaults to deployment when omitted."` + Kind string `json:"kind,omitempty" jsonschema:"workload kind: deployment, statefulset, daemonset, job, or workflow. Defaults to deployment when omitted."` Namespace string `json:"namespace" jsonschema:"workload namespace"` Name string `json:"name" jsonschema:"workload name"` Container string `json:"container,omitempty" jsonschema:"specific container name, defaults to all containers"` @@ -175,7 +177,7 @@ func handleManageCronJob(ctx context.Context, req *mcp.CallToolRequest, input ma func handleGetWorkloadLogs(ctx context.Context, req *mcp.CallToolRequest, input getWorkloadLogsInput) (*mcp.CallToolResult, any, error) { kind := normalizeWorkloadLogsKind(input.Kind) if kind == "" { - return nil, nil, fmt.Errorf("invalid kind %q: must be deployment, statefulset, or daemonset", input.Kind) + return nil, nil, fmt.Errorf("invalid kind %q: must be deployment, statefulset, daemonset, job, or workflow", input.Kind) } if !checkNamespaceAccess(ctx, input.Namespace) { @@ -201,11 +203,14 @@ func handleGetWorkloadLogs(ctx context.Context, req *mcp.CallToolRequest, input // Get pods matching the workload pods := cache.GetPodsForWorkload(input.Namespace, selector) if len(pods) == 0 { - return toJSONResult(map[string]any{ + empty := describeMCPWorkloadLogEmpty(ctx, kind, input.Namespace, input.Name) + response := map[string]any{ "workload": fmt.Sprintf("%s/%s/%s", kind, input.Namespace, input.Name), "pods": 0, - "logs": "no pods found for this workload", - }) + "logs": empty.Message, + } + addMCPWorkloadLogEmptyMetadata(response, empty) + return toJSONResult(response) } tailLines := int64(100) @@ -277,6 +282,135 @@ func handleGetWorkloadLogs(ctx context.Context, req *mcp.CallToolRequest, input return toJSONResult(resp) } +type mcpWorkloadLogEmptyMetadata struct { + Reason string + Message string + Command string +} + +func describeMCPWorkloadLogEmpty(ctx context.Context, kind, namespace, name string) mcpWorkloadLogEmptyMetadata { + switch kind { + case "jobs": + return describeMCPJobLogEmpty(namespace, name) + case "workflows": + return describeMCPWorkflowLogEmpty(ctx, namespace, name) + default: + return mcpWorkloadLogEmptyMetadata{ + Reason: "no-pods", + Message: "no pods found for this workload", + } + } +} + +func describeMCPJobLogEmpty(namespace, name string) mcpWorkloadLogEmptyMetadata { + metadata := mcpWorkloadLogEmptyMetadata{ + Reason: "no-pods", + Message: "No pods found for this Job yet. Check scheduling, admission, or controller events.", + Command: "kubectl logs job/" + name + " -n " + namespace, + } + cache := k8s.GetResourceCache() + if cache == nil || cache.Jobs() == nil { + return metadata + } + job, err := cache.Jobs().Jobs(namespace).Get(name) + if err != nil { + return metadata + } + applyMCPTerminalJobEmptyState(&metadata, job, namespace, name) + return metadata +} + +func applyMCPTerminalJobEmptyState(metadata *mcpWorkloadLogEmptyMetadata, job *batchv1.Job, namespace, name string) { + if _, complete := mcpJobCondition(job, batchv1.JobComplete); !complete { + if _, failed := mcpJobCondition(job, batchv1.JobFailed); !failed { + return + } + } + metadata.Reason = "pods-gone" + metadata.Message = "This Job has finished, but its pods are no longer present in Kubernetes. If logs were retained externally, use your logging system or try kubectl logs job/" + name + " -n " + namespace + "." +} + +func mcpJobCondition(job *batchv1.Job, conditionType batchv1.JobConditionType) (batchv1.JobCondition, bool) { + for _, condition := range job.Status.Conditions { + if condition.Type == conditionType && condition.Status == corev1.ConditionTrue { + return condition, true + } + } + return batchv1.JobCondition{}, false +} + +func describeMCPWorkflowLogEmpty(ctx context.Context, namespace, name string) mcpWorkloadLogEmptyMetadata { + metadata := mcpWorkloadLogEmptyMetadata{ + Reason: "no-pods", + Message: "No Workflow pods found yet. Check scheduling, admission, or controller events.", + Command: "argo logs " + name + " -n " + namespace, + } + cache := k8s.GetResourceCache() + if cache == nil { + return metadata + } + workflow, err := cache.GetDynamicWithGroup(ctx, "Workflow", namespace, name, "argoproj.io") + if err != nil { + return metadata + } + applyMCPTerminalWorkflowEmptyState(&metadata, workflow.Object, namespace, name) + return metadata +} + +func applyMCPTerminalWorkflowEmptyState(metadata *mcpWorkloadLogEmptyMetadata, workflow map[string]any, namespace, name string) { + phase, _, _ := unstructured.NestedString(workflow, "status", "phase") + if !mcpWorkflowTerminal(phase) { + return + } + metadata.Reason = "pods-gone" + if mcpWorkflowArchiveLogsConfigured(workflow) { + metadata.Message = "This Workflow has finished and its pods are no longer present. Archived logs appear to be enabled; use the configured Argo or logging UI, or try argo logs " + name + " -n " + namespace + "." + } else { + metadata.Message = "This Workflow has finished and its pods are no longer present. Argo may have garbage-collected them; Kubernetes pod logs are no longer available here." + } +} + +func mcpWorkflowTerminal(phase string) bool { + switch phase { + case "Succeeded", "Failed", "Error": + return true + default: + return false + } +} + +func mcpWorkflowArchiveLogsConfigured(obj map[string]any) bool { + if archiveLogs, ok, _ := unstructured.NestedBool(obj, "spec", "archiveLogs"); ok && archiveLogs { + return true + } + templates, ok, _ := unstructured.NestedSlice(obj, "spec", "templates") + if !ok { + return false + } + for _, template := range templates { + templateMap, ok := template.(map[string]any) + if !ok { + continue + } + if archiveLogs, ok, _ := unstructured.NestedBool(templateMap, "archiveLocation", "archiveLogs"); ok && archiveLogs { + return true + } + } + return false +} + +func addMCPWorkloadLogEmptyMetadata(response map[string]any, metadata mcpWorkloadLogEmptyMetadata) { + if metadata.Reason != "" { + response["emptyReason"] = metadata.Reason + } + if metadata.Message != "" { + response["emptyMessage"] = metadata.Message + } + if metadata.Command != "" { + response["command"] = metadata.Command + } +} + // schedulingBlockerWarnings detects when a restart won't accomplish what the // agent likely wants: if the workload currently has Pending pods blocked on // scheduling or post-bind (CNI/volume) issues, a rolling restart just creates @@ -568,5 +702,12 @@ func normalizeWorkloadLogsKind(kind string) string { if strings.TrimSpace(kind) == "" { return "deployments" } - return normalizeWorkloadKind(kind) + switch strings.ToLower(kind) { + case "job", "jobs": + return "jobs" + case "workflow", "workflows": + return "workflows" + default: + return normalizeWorkloadKind(kind) + } } diff --git a/internal/mcp/tools_workloads_test.go b/internal/mcp/tools_workloads_test.go new file mode 100644 index 000000000..0c4248103 --- /dev/null +++ b/internal/mcp/tools_workloads_test.go @@ -0,0 +1,84 @@ +package mcp + +import ( + "strings" + "testing" + + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestApplyMCPTerminalJobEmptyState(t *testing.T) { + metadata := mcpWorkloadLogEmptyMetadata{ + Reason: "no-pods", + Message: "No pods found for this Job yet.", + Command: "kubectl logs job/nightly -n ci", + } + job := &batchv1.Job{ + Status: batchv1.JobStatus{ + Conditions: []batchv1.JobCondition{ + { + Type: batchv1.JobComplete, + Status: corev1.ConditionTrue, + }, + }, + }, + } + + applyMCPTerminalJobEmptyState(&metadata, job, "ci", "nightly") + + if metadata.Reason != "pods-gone" { + t.Fatalf("Reason = %q, want pods-gone", metadata.Reason) + } + if !strings.Contains(metadata.Message, "finished") || !strings.Contains(metadata.Message, "kubectl logs job/nightly -n ci") { + t.Fatalf("Message = %q, want terminal kubectl guidance", metadata.Message) + } + if metadata.Command != "kubectl logs job/nightly -n ci" { + t.Fatalf("Command = %q", metadata.Command) + } +} + +func TestApplyMCPTerminalWorkflowEmptyStateWithArchiveLogs(t *testing.T) { + metadata := mcpWorkloadLogEmptyMetadata{ + Reason: "no-pods", + Message: "No Workflow pods found yet.", + Command: "argo logs nightly -n ci", + } + workflow := map[string]any{ + "status": map[string]any{ + "phase": "Succeeded", + }, + "spec": map[string]any{ + "archiveLogs": true, + }, + } + + applyMCPTerminalWorkflowEmptyState(&metadata, workflow, "ci", "nightly") + + if metadata.Reason != "pods-gone" { + t.Fatalf("Reason = %q, want pods-gone", metadata.Reason) + } + if !strings.Contains(metadata.Message, "Archived logs") || !strings.Contains(metadata.Message, "argo logs nightly -n ci") { + t.Fatalf("Message = %q, want archived-log guidance", metadata.Message) + } + if metadata.Command != "argo logs nightly -n ci" { + t.Fatalf("Command = %q", metadata.Command) + } +} + +func TestMCPJobConditionIgnoresRetryCounters(t *testing.T) { + job := &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: "retrying", + Namespace: "ci", + }, + Status: batchv1.JobStatus{ + Failed: 1, + }, + } + + if _, ok := mcpJobCondition(job, batchv1.JobFailed); ok { + t.Fatal("mcpJobCondition treated failed counter as terminal condition") + } +} diff --git a/internal/server/server.go b/internal/server/server.go index b5c00091d..357eaf43e 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -406,6 +406,7 @@ func (s *Server) setupRoutes() { // Workload logs (non-streaming) r.Get("/workloads/{kind}/{namespace}/{name}/logs", s.handleWorkloadLogs) + r.Get("/workloads/{kind}/{namespace}/{name}/runs", s.handleWorkloadRuns) r.Get("/workloads/{kind}/{namespace}/{name}/pods", s.handleWorkloadPods) // Helm routes diff --git a/internal/server/workload_logs.go b/internal/server/workload_logs.go index 28b7386be..0a41d4134 100644 --- a/internal/server/workload_logs.go +++ b/internal/server/workload_logs.go @@ -13,7 +13,12 @@ import ( "time" "github.com/go-chi/chi/v5" + batchv1 "k8s.io/api/batch/v1" corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/labels" "k8s.io/client-go/kubernetes" "github.com/skyhook-io/radar/internal/k8s" @@ -25,6 +30,9 @@ type WorkloadPodInfo struct { Name string `json:"name"` Containers []string `json:"containers"` Ready bool `json:"ready"` + StepID string `json:"stepID,omitempty"` + StepName string `json:"stepName,omitempty"` + Phase string `json:"phase,omitempty"` } // workloadLogEntry is an internal structure for log lines from pods @@ -35,6 +43,24 @@ type workloadLogEntry struct { Content string `json:"content"` } +type workloadLogMetadata struct { + EmptyReason string `json:"emptyReason,omitempty"` + EmptyMessage string `json:"emptyMessage,omitempty"` + Command string `json:"command,omitempty"` +} + +type WorkloadRun struct { + Kind string `json:"kind"` + Namespace string `json:"namespace"` + Name string `json:"name"` + Phase string `json:"phase"` + Active bool `json:"active"` + StartedAt string `json:"startedAt,omitempty"` + FinishedAt string `json:"finishedAt,omitempty"` + ScheduledAt string `json:"scheduledAt,omitempty"` + Message string `json:"message,omitempty"` +} + // validWorkloadKinds defines which resource types support workload logs. // Accepts both singular and plural forms so the frontend can send K8s canonical // Kind names ("Deployment") without additional pluralization. @@ -45,6 +71,10 @@ var validWorkloadKinds = map[string]bool{ "statefulsets": true, "daemonset": true, "daemonsets": true, + "job": true, + "jobs": true, + "workflow": true, + "workflows": true, } // handleWorkloadPods returns the list of pods for a workload @@ -69,6 +99,48 @@ func (s *Server) handleWorkloadPods(w http.ResponseWriter, r *http.Request) { }) } +// handleWorkloadRuns returns retained child runs for scheduled workload kinds. +func (s *Server) handleWorkloadRuns(w http.ResponseWriter, r *http.Request) { + kind := strings.ToLower(chi.URLParam(r, "kind")) + namespace := chi.URLParam(r, "namespace") + name := chi.URLParam(r, "name") + + if allowed := s.getUserNamespaces(r, []string{namespace}); noNamespaceAccess(allowed) { + s.writeError(w, http.StatusForbidden, "no access to namespace "+namespace) + return + } + switch kind { + case "cronjob", "cronjobs": + if !s.canRead(r, "batch", "cronjobs", namespace, "get") { + s.writeError(w, http.StatusForbidden, "no access to cronjobs in namespace "+namespace) + return + } + if !s.canRead(r, "batch", "jobs", namespace, "list") { + s.writeError(w, http.StatusForbidden, "no access to jobs in namespace "+namespace) + return + } + case "cronworkflow", "cronworkflows": + if !s.canRead(r, "argoproj.io", "cronworkflows", namespace, "get") { + s.writeError(w, http.StatusForbidden, "no access to cronworkflows in namespace "+namespace) + return + } + if !s.canRead(r, "argoproj.io", "workflows", namespace, "list") { + s.writeError(w, http.StatusForbidden, "no access to workflows in namespace "+namespace) + return + } + } + + runs, err := s.getWorkloadRuns(r.Context(), kind, namespace, name) + if err != nil { + s.writeWorkloadError(w, err) + return + } + + s.writeJSON(w, map[string]any{ + "runs": runs, + }) +} + // handleWorkloadLogs fetches and merges logs from all pods (non-streaming) func (s *Server) handleWorkloadLogs(w http.ResponseWriter, r *http.Request) { kind := strings.ToLower(chi.URLParam(r, "kind")) @@ -92,10 +164,13 @@ func (s *Server) handleWorkloadLogs(w http.ResponseWriter, r *http.Request) { } if len(pods) == 0 { - s.writeJSON(w, map[string]any{ + metadata := s.describeWorkloadLogEmpty(r.Context(), kind, namespace, name) + response := map[string]any{ "pods": []WorkloadPodInfo{}, "logs": []workloadLogEntry{}, - }) + } + addWorkloadLogMetadata(response, metadata) + s.writeJSON(w, response) return } @@ -134,7 +209,7 @@ func (s *Server) handleWorkloadLogsStream(w http.ResponseWriter, r *http.Request sinceSeconds := parseSinceSeconds(r.URL.Query().Get("sinceSeconds")) if !validWorkloadKinds[kind] { - s.writeError(w, http.StatusBadRequest, "only deployments, statefulsets, and daemonsets are supported") + s.writeError(w, http.StatusBadRequest, "only deployments, statefulsets, daemonsets, jobs, and workflows are supported") return } @@ -173,15 +248,21 @@ func (s *Server) handleWorkloadLogsStream(w http.ResponseWriter, r *http.Request podInfos := buildPodInfos(pods) // Send connected event with pod list - sendSSEEvent(w, flusher, "connected", map[string]any{ + connected := map[string]any{ "workload": name, "namespace": namespace, "kind": kind, "pods": podInfos, - }) - + } + var emptyMetadata workloadLogMetadata if len(pods) == 0 { - sendSSEEvent(w, flusher, "end", map[string]string{"reason": "no pods found"}) + emptyMetadata = s.describeWorkloadLogEmpty(r.Context(), kind, namespace, name) + addWorkloadLogMetadata(connected, emptyMetadata) + } + sendSSEEvent(w, flusher, "connected", connected) + + if len(pods) == 0 && !shouldWaitForPodsInLogStream(kind, emptyMetadata) { + sendSSEEvent(w, flusher, "end", workloadLogEndPayload(emptyMetadata)) return } @@ -246,6 +327,13 @@ func (s *Server) handleWorkloadLogsStream(w http.ResponseWriter, r *http.Request case <-discoveryTicker.C: // Re-discover pods currentPods := cache.GetPodsForWorkload(namespace, selector) + if len(currentPods) == 0 { + metadata := s.describeWorkloadLogEmpty(ctx, kind, namespace, name) + if !shouldWaitForPodsInLogStream(kind, metadata) { + sendSSEEvent(w, flusher, "end", workloadLogEndPayload(metadata)) + return + } + } currentPodNames := make(map[string]bool) for _, p := range currentPods { currentPodNames[p.Name] = true @@ -292,6 +380,36 @@ func (s *Server) handleWorkloadLogsStream(w http.ResponseWriter, r *http.Request } } +func workloadLogEndPayload(metadata workloadLogMetadata) map[string]string { + reason := metadata.EmptyReason + if reason == "" { + reason = "no pods found" + } + payload := map[string]string{"reason": reason} + if metadata.EmptyReason != "" { + payload["emptyReason"] = metadata.EmptyReason + } + if metadata.EmptyMessage != "" { + payload["emptyMessage"] = metadata.EmptyMessage + } + if metadata.Command != "" { + payload["command"] = metadata.Command + } + return payload +} + +func shouldWaitForPodsInLogStream(kind string, metadata workloadLogMetadata) bool { + if metadata.EmptyReason != "no-pods" { + return false + } + switch kind { + case "job", "jobs", "workflow", "workflows": + return true + default: + return false + } +} + // streamPodLogs streams logs from a single pod/container to the log channel func streamPodLogs(ctx context.Context, client kubernetes.Interface, namespace, podName, containerName string, tailLines int64, sinceSeconds *int64, logCh chan<- workloadLogEntry) { stream, err := k8score.GetContainerLogs(ctx, client, namespace, podName, containerName, k8score.LogOptions{ @@ -372,10 +490,15 @@ func buildPodInfo(pod *corev1.Pod) WorkloadPodInfo { for _, c := range pod.Spec.Containers { containers = append(containers, c.Name) } + annotations := pod.GetAnnotations() + labels := pod.GetLabels() return WorkloadPodInfo{ Name: pod.Name, Containers: containers, Ready: isPodReady(pod), + StepID: annotations["workflows.argoproj.io/node-id"], + StepName: annotations["workflows.argoproj.io/node-name"], + Phase: labels["workflows.argoproj.io/phase"], } } @@ -397,7 +520,7 @@ func (e *workloadError) Error() string { return e.message } // getWorkloadPods validates the kind, retrieves cache, and returns pods for a workload func (s *Server) getWorkloadPods(kind, namespace, name string) ([]*corev1.Pod, *workloadError) { if !validWorkloadKinds[kind] { - return nil, &workloadError{http.StatusBadRequest, "only deployments, statefulsets, and daemonsets are supported"} + return nil, &workloadError{http.StatusBadRequest, "only deployments, statefulsets, daemonsets, jobs, and workflows are supported"} } cache := k8s.GetResourceCache() @@ -419,6 +542,299 @@ func (s *Server) getWorkloadPods(kind, namespace, name string) ([]*corev1.Pod, * return cache.GetPodsForWorkload(namespace, selector), nil } +func (s *Server) getWorkloadRuns(ctx context.Context, kind, namespace, name string) ([]WorkloadRun, *workloadError) { + cache := k8s.GetResourceCache() + if cache == nil { + return nil, &workloadError{http.StatusServiceUnavailable, "resource cache not available"} + } + + var runs []WorkloadRun + switch kind { + case "cronjob", "cronjobs": + if cache.CronJobs() == nil { + return nil, &workloadError{http.StatusForbidden, "insufficient permissions to list cronjobs"} + } + if _, err := cache.CronJobs().CronJobs(namespace).Get(name); err != nil { + return nil, workloadParentGetError("cronjob", namespace, name, err) + } + if cache.Jobs() == nil { + return nil, &workloadError{http.StatusForbidden, "insufficient permissions to list jobs"} + } + jobs, err := cache.Jobs().Jobs(namespace).List(labels.Everything()) + if err != nil { + return nil, &workloadError{http.StatusInternalServerError, err.Error()} + } + for _, job := range jobs { + if controllerOwns(job.OwnerReferences, "CronJob", name) { + runs = append(runs, jobRunInfo(job)) + } + } + case "cronworkflow", "cronworkflows": + if _, err := cache.GetDynamicWithGroup(ctx, "CronWorkflow", namespace, name, "argoproj.io"); err != nil { + return nil, workloadParentGetError("cronworkflow", namespace, name, err) + } + workflows, err := cache.ListDynamicWithGroup(ctx, "Workflow", namespace, "argoproj.io") + if err != nil { + return nil, &workloadError{http.StatusInternalServerError, err.Error()} + } + for _, workflow := range workflows { + if workflow.GetLabels()["workflows.argoproj.io/cron-workflow"] == name { + runs = append(runs, workflowRunInfo(workflow)) + } + } + default: + return nil, &workloadError{http.StatusBadRequest, "only cronjobs and cronworkflows have runs"} + } + + sortRuns(runs) + return runs, nil +} + +func workloadParentGetError(kind, namespace, name string, err error) *workloadError { + if apierrors.IsForbidden(err) || apierrors.IsUnauthorized(err) { + return &workloadError{http.StatusForbidden, "insufficient permissions to get " + kind + " " + namespace + "/" + name} + } + return &workloadError{http.StatusNotFound, kind + " " + namespace + "/" + name + " not found"} +} + +func controllerOwns(refs []metav1.OwnerReference, kind, name string) bool { + for _, ref := range refs { + if ref.Kind == kind && ref.Name == name && ref.Controller != nil && *ref.Controller { + return true + } + } + return false +} + +func jobRunInfo(job *batchv1.Job) WorkloadRun { + completeCondition, complete := jobCondition(job, batchv1.JobComplete) + failedCondition, failed := jobCondition(job, batchv1.JobFailed) + + phase := "Pending" + switch { + case job.Status.Active > 0: + phase = "Running" + case complete: + phase = "Succeeded" + case failed: + phase = "Failed" + case job.Status.Succeeded > 0: + phase = "Succeeded" + case job.Status.Failed > 0: + phase = "Failed" + } + + run := WorkloadRun{ + Kind: "jobs", + Namespace: job.Namespace, + Name: job.Name, + Phase: phase, + Active: phase == "Running" || phase == "Pending", + StartedAt: formatMetaTime(job.Status.StartTime), + } + if complete { + applyJobCondition(&run, completeCondition) + } else if failed { + applyJobCondition(&run, failedCondition) + } + return run +} + +func jobCondition(job *batchv1.Job, conditionType batchv1.JobConditionType) (batchv1.JobCondition, bool) { + for _, condition := range job.Status.Conditions { + if condition.Type == conditionType && condition.Status == corev1.ConditionTrue { + return condition, true + } + } + return batchv1.JobCondition{}, false +} + +func applyJobCondition(run *WorkloadRun, condition batchv1.JobCondition) { + run.FinishedAt = condition.LastTransitionTime.Format(time.RFC3339) + if condition.Message != "" { + run.Message = condition.Message + } +} + +func workflowRunInfo(workflow *unstructured.Unstructured) WorkloadRun { + phase, _, _ := unstructured.NestedString(workflow.Object, "status", "phase") + startedAt, _, _ := unstructured.NestedString(workflow.Object, "status", "startedAt") + finishedAt, _, _ := unstructured.NestedString(workflow.Object, "status", "finishedAt") + message, _, _ := unstructured.NestedString(workflow.Object, "status", "message") + if phase == "" { + phase = "Pending" + } + return WorkloadRun{ + Kind: "workflows", + Namespace: workflow.GetNamespace(), + Name: workflow.GetName(), + Phase: phase, + Active: phase == "Running" || phase == "Pending", + StartedAt: startedAt, + FinishedAt: finishedAt, + ScheduledAt: workflow.GetAnnotations()["workflows.argoproj.io/scheduled-time"], + Message: message, + } +} + +func sortRuns(runs []WorkloadRun) { + sort.SliceStable(runs, func(i, j int) bool { + if runs[i].Active != runs[j].Active { + return runs[i].Active + } + if runPhaseRank(runs[i].Phase) != runPhaseRank(runs[j].Phase) { + return runPhaseRank(runs[i].Phase) < runPhaseRank(runs[j].Phase) + } + return runSortTime(runs[i]).After(runSortTime(runs[j])) + }) +} + +func runPhaseRank(phase string) int { + switch phase { + case "Failed", "Error": + return 0 + case "Running", "Pending": + return 1 + case "Succeeded": + return 2 + default: + return 3 + } +} + +func runSortTime(run WorkloadRun) time.Time { + for _, value := range []string{run.StartedAt, run.ScheduledAt, run.FinishedAt} { + if value == "" { + continue + } + if t, err := time.Parse(time.RFC3339, value); err == nil { + return t + } + } + return time.Time{} +} + +func formatMetaTime(t *metav1.Time) string { + if t == nil { + return "" + } + return t.Format(time.RFC3339) +} + +func addWorkloadLogMetadata(response map[string]any, metadata workloadLogMetadata) { + if metadata.EmptyReason != "" { + response["emptyReason"] = metadata.EmptyReason + } + if metadata.EmptyMessage != "" { + response["emptyMessage"] = metadata.EmptyMessage + } + if metadata.Command != "" { + response["command"] = metadata.Command + } +} + +func (s *Server) describeWorkloadLogEmpty(ctx context.Context, kind, namespace, name string) workloadLogMetadata { + switch kind { + case "job", "jobs": + return s.describeJobLogEmpty(namespace, name) + case "workflow", "workflows": + return s.describeWorkflowLogEmpty(ctx, namespace, name) + default: + return workloadLogMetadata{ + EmptyReason: "no-pods", + EmptyMessage: "No pods found for this workload.", + } + } +} + +func (s *Server) describeJobLogEmpty(namespace, name string) workloadLogMetadata { + metadata := workloadLogMetadata{ + EmptyReason: "no-pods", + EmptyMessage: "No pods found for this Job yet. Check the Timeline tab for scheduling or admission events.", + Command: "kubectl logs job/" + name + " -n " + namespace, + } + cache := k8s.GetResourceCache() + if cache == nil || cache.Jobs() == nil { + return metadata + } + job, err := cache.Jobs().Jobs(namespace).Get(name) + if err != nil { + return metadata + } + applyTerminalJobEmptyState(&metadata, job, namespace, name) + return metadata +} + +func applyTerminalJobEmptyState(metadata *workloadLogMetadata, job *batchv1.Job, namespace, name string) { + if _, complete := jobCondition(job, batchv1.JobComplete); !complete { + if _, failed := jobCondition(job, batchv1.JobFailed); !failed { + return + } + } + metadata.EmptyReason = "pods-gone" + metadata.EmptyMessage = "This Job has finished, but its pods are no longer present in Kubernetes. If logs were retained externally, use your logging system or try kubectl logs job/" + name + " -n " + namespace + "." +} + +func (s *Server) describeWorkflowLogEmpty(ctx context.Context, namespace, name string) workloadLogMetadata { + metadata := workloadLogMetadata{ + EmptyReason: "no-pods", + EmptyMessage: "No Workflow pods found yet. Check the Timeline tab for scheduling or admission events.", + Command: "argo logs " + name + " -n " + namespace, + } + cache := k8s.GetResourceCache() + if cache == nil { + return metadata + } + workflow, err := cache.GetDynamicWithGroup(ctx, "Workflow", namespace, name, "argoproj.io") + if err != nil { + return metadata + } + applyTerminalWorkflowEmptyState(&metadata, workflow.Object, namespace, name) + return metadata +} + +func applyTerminalWorkflowEmptyState(metadata *workloadLogMetadata, workflow map[string]any, namespace, name string) { + phase, _, _ := unstructured.NestedString(workflow, "status", "phase") + if !isWorkflowTerminal(phase) { + return + } + metadata.EmptyReason = "pods-gone" + if workflowArchiveLogsConfigured(workflow) { + metadata.EmptyMessage = "This Workflow has finished and its pods are no longer present. Archived logs appear to be enabled; use the configured Argo or logging UI, or try argo logs " + name + " -n " + namespace + "." + } else { + metadata.EmptyMessage = "This Workflow has finished and its pods are no longer present. Argo may have garbage-collected them; Kubernetes pod logs are no longer available here." + } +} + +func isWorkflowTerminal(phase string) bool { + switch phase { + case "Succeeded", "Failed", "Error": + return true + default: + return false + } +} + +func workflowArchiveLogsConfigured(obj map[string]any) bool { + if archiveLogs, ok, _ := unstructured.NestedBool(obj, "spec", "archiveLogs"); ok && archiveLogs { + return true + } + templates, ok, _ := unstructured.NestedSlice(obj, "spec", "templates") + if !ok { + return false + } + for _, template := range templates { + templateMap, ok := template.(map[string]any) + if !ok { + continue + } + if archiveLogs, ok, _ := unstructured.NestedBool(templateMap, "archiveLocation", "archiveLogs"); ok && archiveLogs { + return true + } + } + return false +} + // writeWorkloadError writes an error response based on workloadError func (s *Server) writeWorkloadError(w http.ResponseWriter, err *workloadError) { s.writeError(w, err.statusCode, err.message) diff --git a/internal/server/workload_logs_test.go b/internal/server/workload_logs_test.go new file mode 100644 index 000000000..bb95ad642 --- /dev/null +++ b/internal/server/workload_logs_test.go @@ -0,0 +1,290 @@ +package server + +import ( + "strings" + "testing" + "time" + + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +func TestSortRunsPrefersActiveThenFailedThenNewest(t *testing.T) { + runs := []WorkloadRun{ + {Name: "success-new", Phase: "Succeeded", StartedAt: "2026-01-03T00:00:00Z"}, + {Name: "failed-old", Phase: "Failed", StartedAt: "2026-01-01T00:00:00Z"}, + {Name: "active-old", Phase: "Running", Active: true, StartedAt: "2025-12-31T00:00:00Z"}, + {Name: "failed-new", Phase: "Failed", StartedAt: "2026-01-02T00:00:00Z"}, + } + + sortRuns(runs) + + got := []string{runs[0].Name, runs[1].Name, runs[2].Name, runs[3].Name} + want := []string{"active-old", "failed-new", "failed-old", "success-new"} + for i := range want { + if got[i] != want[i] { + t.Fatalf("order[%d] = %q, want %q; full order %v", i, got[i], want[i], got) + } + } +} + +func TestWorkflowArchiveLogsConfigured(t *testing.T) { + cases := []struct { + name string + obj map[string]any + want bool + }{ + { + name: "workflow spec", + obj: map[string]any{ + "spec": map[string]any{"archiveLogs": true}, + }, + want: true, + }, + { + name: "template archive location", + obj: map[string]any{ + "spec": map[string]any{ + "templates": []any{ + map[string]any{"name": "main"}, + map[string]any{"archiveLocation": map[string]any{"archiveLogs": true}}, + }, + }, + }, + want: true, + }, + { + name: "off", + obj: map[string]any{ + "spec": map[string]any{"archiveLogs": false}, + }, + want: false, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := workflowArchiveLogsConfigured(tc.obj); got != tc.want { + t.Fatalf("workflowArchiveLogsConfigured() = %v, want %v", got, tc.want) + } + }) + } +} + +func TestWorkflowRunInfo(t *testing.T) { + workflow := &unstructured.Unstructured{Object: map[string]any{ + "metadata": map[string]any{ + "name": "nightly-abc", + "namespace": "ci", + "annotations": map[string]any{ + "workflows.argoproj.io/scheduled-time": "2026-01-02T03:04:05Z", + }, + }, + "status": map[string]any{ + "phase": "Failed", + "startedAt": "2026-01-02T03:04:06Z", + "finishedAt": "2026-01-02T03:05:06Z", + "message": "template failed", + }, + }} + + run := workflowRunInfo(workflow) + if run.Kind != "workflows" || run.Namespace != "ci" || run.Name != "nightly-abc" { + t.Fatalf("unexpected identity: %#v", run) + } + if run.Phase != "Failed" || run.Active { + t.Fatalf("unexpected phase/active: %#v", run) + } + if run.ScheduledAt != "2026-01-02T03:04:05Z" || run.Message != "template failed" { + t.Fatalf("unexpected schedule/message: %#v", run) + } +} + +func TestApplyTerminalWorkflowEmptyStateWithoutNodes(t *testing.T) { + metadata := workloadLogMetadata{ + EmptyReason: "no-pods", + EmptyMessage: "No Workflow pods found yet.", + Command: "argo logs finished -n ci", + } + + applyTerminalWorkflowEmptyState(&metadata, map[string]any{ + "status": map[string]any{ + "phase": "Succeeded", + }, + }, "ci", "finished") + + if metadata.EmptyReason != "pods-gone" { + t.Fatalf("EmptyReason = %q, want pods-gone", metadata.EmptyReason) + } + if strings.Contains(metadata.EmptyMessage, "yet") { + t.Fatalf("terminal workflow kept not-started message: %q", metadata.EmptyMessage) + } +} + +func TestApplyTerminalJobEmptyStateIgnoresRetryCounters(t *testing.T) { + metadata := workloadLogMetadata{ + EmptyReason: "no-pods", + EmptyMessage: "No pods found for this Job yet.", + Command: "kubectl logs job/retrying -n ci", + } + job := &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: "retrying", + Namespace: "ci", + }, + Status: batchv1.JobStatus{ + Failed: 1, + }, + } + + applyTerminalJobEmptyState(&metadata, job, "ci", "retrying") + + if metadata.EmptyReason != "no-pods" { + t.Fatalf("EmptyReason = %q, want no-pods", metadata.EmptyReason) + } + if strings.Contains(metadata.EmptyMessage, "finished") { + t.Fatalf("retrying job got terminal message: %q", metadata.EmptyMessage) + } +} + +func TestJobRunInfoUsesTerminalConditions(t *testing.T) { + startedAt := metav1.NewTime(time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)) + finishedAt := metav1.NewTime(time.Date(2026, 1, 2, 3, 5, 5, 0, time.UTC)) + job := &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: "retry-then-pass", + Namespace: "ci", + }, + Status: batchv1.JobStatus{ + StartTime: &startedAt, + Failed: 1, + Succeeded: 1, + Conditions: []batchv1.JobCondition{ + { + Type: batchv1.JobComplete, + Status: corev1.ConditionTrue, + LastTransitionTime: finishedAt, + Message: "completed after retry", + }, + }, + }, + } + + run := jobRunInfo(job) + if run.Phase != "Succeeded" || run.Active { + t.Fatalf("unexpected phase/active: %#v", run) + } + if run.FinishedAt != "2026-01-02T03:05:05Z" || run.Message != "completed after retry" { + t.Fatalf("unexpected finished/message: %#v", run) + } +} + +func TestJobRunInfoTreatsPendingAsActive(t *testing.T) { + job := &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: "not-started", + Namespace: "ci", + }, + } + + run := jobRunInfo(job) + + if run.Phase != "Pending" || !run.Active { + t.Fatalf("unexpected phase/active: %#v", run) + } +} + +func TestFormatMetaTime(t *testing.T) { + timestamp := metav1.NewTime(time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)) + if got := formatMetaTime(×tamp); got != "2026-01-02T03:04:05Z" { + t.Fatalf("formatMetaTime() = %q", got) + } + if got := formatMetaTime(nil); got != "" { + t.Fatalf("formatMetaTime(nil) = %q", got) + } +} + +func TestWorkloadParentGetErrorPreservesForbidden(t *testing.T) { + err := apierrors.NewForbidden(schema.GroupResource{Group: "batch", Resource: "cronjobs"}, "nightly", nil) + + got := workloadParentGetError("cronjob", "ci", "nightly", err) + + if got.statusCode != 403 { + t.Fatalf("statusCode = %d, want 403", got.statusCode) + } + if !strings.Contains(got.message, "insufficient permissions") { + t.Fatalf("message = %q, want insufficient permissions", got.message) + } +} + +func TestShouldWaitForPodsInLogStream(t *testing.T) { + cases := []struct { + name string + kind string + metadata workloadLogMetadata + want bool + }{ + { + name: "pending job", + kind: "jobs", + metadata: workloadLogMetadata{ + EmptyReason: "no-pods", + }, + want: true, + }, + { + name: "pending workflow", + kind: "workflow", + metadata: workloadLogMetadata{ + EmptyReason: "no-pods", + }, + want: true, + }, + { + name: "terminal job pods gone", + kind: "jobs", + metadata: workloadLogMetadata{ + EmptyReason: "pods-gone", + }, + want: false, + }, + { + name: "deployment keeps existing behavior", + kind: "deployments", + metadata: workloadLogMetadata{ + EmptyReason: "no-pods", + }, + want: false, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := shouldWaitForPodsInLogStream(tc.kind, tc.metadata); got != tc.want { + t.Fatalf("shouldWaitForPodsInLogStream() = %v, want %v", got, tc.want) + } + }) + } +} + +func TestWorkloadLogEndPayloadIncludesEmptyMetadata(t *testing.T) { + got := workloadLogEndPayload(workloadLogMetadata{ + EmptyReason: "pods-gone", + EmptyMessage: "finished and pods were removed", + Command: "kubectl logs job/nightly -n ci", + }) + + if got["reason"] != "pods-gone" || got["emptyReason"] != "pods-gone" { + t.Fatalf("reason fields = %#v, want pods-gone", got) + } + if got["emptyMessage"] != "finished and pods were removed" { + t.Fatalf("emptyMessage = %q", got["emptyMessage"]) + } + if got["command"] != "kubectl logs job/nightly -n ci" { + t.Fatalf("command = %q", got["command"]) + } +} diff --git a/packages/k8s-ui/src/components/logs/WorkloadLogsViewer.tsx b/packages/k8s-ui/src/components/logs/WorkloadLogsViewer.tsx index 701da8427..58f0dd855 100644 --- a/packages/k8s-ui/src/components/logs/WorkloadLogsViewer.tsx +++ b/packages/k8s-ui/src/components/logs/WorkloadLogsViewer.tsx @@ -27,6 +27,9 @@ export interface WorkloadLogsFetchParams { export interface WorkloadLogsResult { pods: WorkloadPodInfo[] logs: WorkloadRawLog[] + emptyReason?: string + emptyMessage?: string + command?: string } export interface WorkloadLogsViewerProps { @@ -61,6 +64,7 @@ export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownl const [selectedPods, setSelectedPods] = useState>(new Set()) const [isLoading, setIsLoading] = useState(false) const [fetchError, setFetchError] = useState(null) + const [emptyMessage, setEmptyMessage] = useState(null) const [showPodFilter, setShowPodFilter] = useState(false) const [logRange, setLogRange] = useState('100') const { showError, showSuccess } = useToast() @@ -95,9 +99,9 @@ export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownl setFetchError(null) try { const result = await fetchAll({ container: selectedContainer || undefined, tailLines, sinceSeconds }) - // Older backends marshal empty results as null rather than []. const resultPods = result.pods ?? [] const resultLogs = result.logs ?? [] + setEmptyMessage(result.emptyMessage || null) podColorIndexRef.current = new Map(resultPods.map((pod, i) => [pod.name, i])) setPods(resultPods) @@ -157,6 +161,7 @@ export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownl const nextPods = data.pods as WorkloadPodInfo[] podColorIndexRef.current = new Map(nextPods.map((pod, i) => [pod.name, i])) setPods(nextPods) + setEmptyMessage(data.emptyMessage || null) setSelectedPods(prev => ( prev.size === 0 ? new Set(nextPods.map((p: WorkloadPodInfo) => p.name)) : prev )) @@ -180,8 +185,11 @@ export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownl const existing = new Set(prev.map(p => p.name)) const toAdd = newPods.filter(p => !existing.has(p.name)) if (toAdd.length === 0) return prev - return [...prev, ...toAdd] + const next = [...prev, ...toAdd] + podColorIndexRef.current = new Map(next.map((pod, i) => [pod.name, i])) + return next }) + setEmptyMessage(null) setSelectedPods(prev => { const next = new Set(prev) newPods.forEach(p => next.add(p.name)) @@ -197,6 +205,9 @@ export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownl // while new pod logs start flowing in } }, + onEnd: (data: any) => { + if (data?.emptyMessage) setEmptyMessage(data.emptyMessage) + }, }, 'Workload log stream connection failed', ) @@ -299,8 +310,13 @@ export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownl {pods.map(pod => { const dotBg = palette.podColors[(podColorIndex.get(pod.name) ?? 0) % palette.podColors.length].bg + const primaryLabel = pod.stepName || pod.name + const secondaryLabel = pod.stepName ? pod.name : '' + const stateLabel = pod.phase || (pod.ready ? 'Ready' : 'Not Ready') let readyColor: string - if (pod.ready) { + if (pod.phase === 'Failed' || pod.phase === 'Error') { + readyColor = isDark ? 'text-red-400' : 'text-red-700' + } else if (pod.ready || pod.phase === 'Succeeded') { readyColor = isDark ? 'text-emerald-400' : 'text-emerald-700' } else { readyColor = isDark ? 'text-amber-400' : 'text-amber-700' @@ -314,9 +330,12 @@ export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownl className={`w-3 h-3 rounded ${palette.borderLight} ${palette.elevatedBg} text-blue-500 focus:ring-blue-500 focus:ring-offset-0`} /> - {pod.name} + + {primaryLabel} + {secondaryLabel && {secondaryLabel}} + - {pod.ready ? 'Ready' : 'Not Ready'} + {stateLabel} ) @@ -360,7 +379,7 @@ export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownl onClear={clear} toolbarExtra={renderToolbarExtra} showPodName - emptyMessage={pods.length === 0 ? 'No pods found' : 'No logs available'} + emptyMessage={emptyMessage || (pods.length === 0 ? 'No pods found' : 'No logs available')} errorMessage={fetchError || (entries.length === 0 ? streamError : null)} forceDark={forceDark} /> diff --git a/packages/k8s-ui/src/components/logs/useLogStream.ts b/packages/k8s-ui/src/components/logs/useLogStream.ts index 9269e9746..4863ec981 100644 --- a/packages/k8s-ui/src/components/logs/useLogStream.ts +++ b/packages/k8s-ui/src/components/logs/useLogStream.ts @@ -10,6 +10,8 @@ export interface LogStreamHandlers { onPodAdded?: (data: unknown) => void /** Called when pods are terminated during streaming (workload logs only) */ onPodRemoved?: (data: unknown) => void + /** Called when the server ends the stream cleanly */ + onEnd?: (data: unknown) => void } /** @@ -90,11 +92,16 @@ export function useLogStream() { } }) - es.addEventListener('end', () => { + es.addEventListener('end', (event) => { if (!isCurrent()) return endedRef.current = true setIsStreaming(false) setConnecting(false) + if (handlers.onEnd) { + try { handlers.onEnd(JSON.parse((event as MessageEvent).data)) } catch (e) { + console.error('Failed to parse end event:', e) + } + } }) es.addEventListener('error', (event) => { diff --git a/packages/k8s-ui/src/components/resources/ResourcesView.tsx b/packages/k8s-ui/src/components/resources/ResourcesView.tsx index 4ad24b2a3..8ef80c4f1 100644 --- a/packages/k8s-ui/src/components/resources/ResourcesView.tsx +++ b/packages/k8s-ui/src/components/resources/ResourcesView.tsx @@ -2687,11 +2687,11 @@ export function ResourcesView({ if (containers.length > 0) { openLogs?.({ namespace: ns, podName: name, containers }) } - } else if (['deployments', 'statefulsets', 'daemonsets', 'replicasets', 'jobs'].includes(kindLower)) { + } else if (['deployments', 'statefulsets', 'daemonsets', 'replicasets', 'jobs', 'workflows', 'cronjobs', 'cronworkflows'].includes(kindLower)) { openWorkloadLogs?.({ namespace: ns, workloadKind: selectedKind.kind, workloadName: name }) } }, - enabled: highlightedIndex >= 0 && !compareMode && ['pods', 'deployments', 'statefulsets', 'daemonsets', 'replicasets', 'jobs'].includes(selectedKind.name.toLowerCase()), + enabled: highlightedIndex >= 0 && !compareMode && ['pods', 'deployments', 'statefulsets', 'daemonsets', 'replicasets', 'jobs', 'workflows', 'cronjobs', 'cronworkflows'].includes(selectedKind.name.toLowerCase()), }, { id: 'resources-sort-name', diff --git a/packages/k8s-ui/src/components/shared/ResourceActionsBar.tsx b/packages/k8s-ui/src/components/shared/ResourceActionsBar.tsx index fae1b9e65..f28a47d2a 100644 --- a/packages/k8s-ui/src/components/shared/ResourceActionsBar.tsx +++ b/packages/k8s-ui/src/components/shared/ResourceActionsBar.tsx @@ -150,6 +150,12 @@ export function ResourceActionsBar({ onDrainNode, isDrainingNode, }: ResourceActionsBarProps) { const kind = resource.kind.toLowerCase() + const canOpenWorkloadLogs = Boolean( + canViewLogs && + !hideLogs && + openWorkloadLogs && + ['deployments', 'statefulsets', 'daemonsets', 'jobs', 'workflows', 'cronjobs', 'cronworkflows'].includes(kind) + ) // Delete confirmation state const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) @@ -377,19 +383,6 @@ export function ResourceActionsBar({ Rollback )} - {canViewLogs && !hideLogs && ['deployments', 'statefulsets', 'daemonsets'].includes(kind) && openWorkloadLogs && ( - - )} )} @@ -473,8 +466,22 @@ export function ResourceActionsBar({ /> )} + {canOpenWorkloadLogs && ( + + )} + {/* Job logs */} - {kind === 'jobs' && onCopyCommand && ( + {kind === 'jobs' && onCopyCommand && (!canViewLogs || !openWorkloadLogs) && ( )} + {kind === 'workflows' && onCopyCommand && (!canViewLogs || !openWorkloadLogs) && ( + + )} + {/* Spacer pushes universal actions to the right */}
diff --git a/packages/k8s-ui/src/components/workload/WorkloadView.tsx b/packages/k8s-ui/src/components/workload/WorkloadView.tsx index a6ad07650..24baa2197 100644 --- a/packages/k8s-ui/src/components/workload/WorkloadView.tsx +++ b/packages/k8s-ui/src/components/workload/WorkloadView.tsx @@ -247,6 +247,8 @@ interface WorkloadViewProps { resolvedEnvFrom?: ResolvedEnvFrom } +const LOGS_TAB_WITHOUT_PODS_KINDS = new Set(['Job', 'CronJob', 'Workflow', 'CronWorkflow']) + export function WorkloadView({ kind: kindProp, namespace, @@ -564,6 +566,7 @@ export function WorkloadView({ const status = getResourceStatus(apiKind, resource) const showMetricsTab = isMetricsAvailable ? isMetricsAvailable(kind, resource) : false + const showLogsTab = Boolean(renderLogsTab) && (allPods.length > 0 || LOGS_TAB_WITHOUT_PODS_KINDS.has(kind)) const tabs: DetailShellTab[] = [ { id: 'overview', label: 'Overview', icon: }, { id: 'topology', label: 'Topology', icon: , hidden: topologyTabHidden }, @@ -573,7 +576,7 @@ export function WorkloadView({ icon: , badge: resourceEvents.length > 0 ? {resourceEvents.length} : undefined, }, - { id: 'logs', label: 'Logs', icon: , hidden: !(allPods.length > 0 && renderLogsTab) }, + { id: 'logs', label: 'Logs', icon: , hidden: !showLogsTab }, { id: 'metrics', label: 'Metrics', icon: , hidden: !(showMetricsTab && renderMetricsTab) }, { id: 'yaml', label: 'YAML', icon: }, ] diff --git a/packages/k8s-ui/src/types/core.ts b/packages/k8s-ui/src/types/core.ts index 55e189903..7075eef35 100644 --- a/packages/k8s-ui/src/types/core.ts +++ b/packages/k8s-ui/src/types/core.ts @@ -1238,6 +1238,9 @@ export interface WorkloadPodInfo { name: string containers: string[] ready: boolean + stepID?: string + stepName?: string + phase?: string } // SSE event types for workload log streaming diff --git a/packages/k8s-ui/src/utils/navigation.ts b/packages/k8s-ui/src/utils/navigation.ts index 3e681bfa4..09c3adf5b 100644 --- a/packages/k8s-ui/src/utils/navigation.ts +++ b/packages/k8s-ui/src/utils/navigation.ts @@ -25,6 +25,9 @@ const BUILTIN_PLURAL_TO_KIND: Record = { nodes: 'Node', jobs: 'Job', cronjobs: 'CronJob', + workflows: 'Workflow', + cronworkflows: 'CronWorkflow', + workflowtemplates: 'WorkflowTemplate', horizontalpodautoscalers: 'HorizontalPodAutoscaler', persistentvolumeclaims: 'PersistentVolumeClaim', persistentvolumes: 'PersistentVolume', diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 2c11778af..d63330297 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -3544,6 +3544,25 @@ export interface WorkloadLogsResponse { timestamp: string content: string }[] + emptyReason?: string + emptyMessage?: string + command?: string +} + +export interface WorkloadRun { + kind: string + namespace: string + name: string + phase: string + active: boolean + startedAt?: string + finishedAt?: string + scheduledAt?: string + message?: string +} + +export interface WorkloadRunsResponse { + runs: WorkloadRun[] } // Fetch pods for a workload @@ -3556,6 +3575,15 @@ export function useWorkloadPods(kind: string, namespace: string, name: string) { }) } +export function useWorkloadRuns(kind: string, namespace: string, name: string, enabled = true) { + return useQuery({ + queryKey: ['workload-runs', kind, namespace, name], + queryFn: () => fetchJSON(`/workloads/${kind}/${namespace}/${name}/runs`), + enabled: enabled && Boolean(kind && namespace && name), + staleTime: 10000, + }) +} + // Fetch logs for a workload (non-streaming) export function useWorkloadLogs( kind: string, diff --git a/web/src/components/dock/WorkloadLogsTab.tsx b/web/src/components/dock/WorkloadLogsTab.tsx index 0ecb50c69..f92537280 100644 --- a/web/src/components/dock/WorkloadLogsTab.tsx +++ b/web/src/components/dock/WorkloadLogsTab.tsx @@ -1,4 +1,5 @@ import { WorkloadLogsViewer } from '../logs/WorkloadLogsViewer' +import { ScheduledWorkloadLogsViewer } from '../logs/ScheduledWorkloadLogsViewer' interface WorkloadLogsTabProps { namespace: string @@ -11,13 +12,25 @@ export function WorkloadLogsTab({ workloadKind, workloadName, }: WorkloadLogsTabProps) { + const normalizedKind = workloadKind.toLowerCase() + const isScheduled = normalizedKind === 'cronjob' || normalizedKind === 'cronjobs' || + normalizedKind === 'cronworkflow' || normalizedKind === 'cronworkflows' + return (
- + {isScheduled ? ( + + ) : ( + + )}
) } diff --git a/web/src/components/home/mcpToolCatalog.ts b/web/src/components/home/mcpToolCatalog.ts index e1be31ab4..d5e106290 100644 --- a/web/src/components/home/mcpToolCatalog.ts +++ b/web/src/components/home/mcpToolCatalog.ts @@ -237,9 +237,9 @@ export const MCP_TOOL_CATALOG: MCPToolInfo[] = [ }, { name: 'get_workload_logs', - desc: 'Aggregated, filtered logs across all pods of a workload (Deployment, StatefulSet, or DaemonSet) — collected concurrently, filtered for errors/warnings, and deduplicated.', + desc: 'Aggregated, filtered logs across all pods of a workload (Deployment, StatefulSet, DaemonSet, Job, or Argo Workflow) — collected concurrently, filtered for errors/warnings, and deduplicated.', params: [ - { arg: 'kind', desc: 'deployment (default), statefulset, or daemonset' }, + { arg: 'kind', desc: 'deployment (default), statefulset, daemonset, job, or workflow' }, { arg: 'namespace', required: true, desc: 'workload namespace' }, { arg: 'name', required: true, desc: 'workload name' }, { arg: 'container', desc: 'specific container (defaults to all)' }, diff --git a/web/src/components/logs/ScheduledWorkloadLogsViewer.tsx b/web/src/components/logs/ScheduledWorkloadLogsViewer.tsx new file mode 100644 index 000000000..266865fcd --- /dev/null +++ b/web/src/components/logs/ScheduledWorkloadLogsViewer.tsx @@ -0,0 +1,125 @@ +import { useEffect, useMemo, useState } from 'react' +import { Loader2, Terminal } from 'lucide-react' +import { clsx } from 'clsx' +import { useWorkloadRuns, type WorkloadRun } from '../../api/client' +import { WorkloadLogsViewer } from './WorkloadLogsViewer' + +interface ScheduledWorkloadLogsViewerProps { + kind: string + namespace: string + name: string +} + +const EMPTY_RUNS: WorkloadRun[] = [] + +export function ScheduledWorkloadLogsViewer({ kind, namespace, name }: ScheduledWorkloadLogsViewerProps) { + const runsQuery = useWorkloadRuns(kind, namespace, name) + const runs = runsQuery.data?.runs ?? EMPTY_RUNS + const defaultRun = useMemo(() => pickDefaultRun(runs), [runs]) + const [selectedRunName, setSelectedRunName] = useState('') + + useEffect(() => { + if (runs.length === 0) { + setSelectedRunName('') + return + } + if (!runs.some(run => run.name === selectedRunName)) { + setSelectedRunName(defaultRun?.name ?? runs[0].name) + } + }, [runs, selectedRunName, defaultRun]) + + const selectedRun = runs.find(run => run.name === selectedRunName) ?? defaultRun + + if (runsQuery.isLoading) { + return ( +
+
+ + Loading runs... +
+
+ ) + } + + if (runsQuery.error) { + return ( +
+ + {runsQuery.error instanceof Error ? runsQuery.error.message : 'Failed to load runs'} +
+ ) + } + + if (!selectedRun) { + return ( +
+ + No retained runs found +
+ ) + } + + return ( +
+
+
+ Run + + + {selectedRun.phase} + + + {formatRunTime(selectedRun)} + +
+
+
+ +
+
+ ) +} + +function pickDefaultRun(runs: WorkloadRun[]): WorkloadRun | undefined { + return runs.find(run => run.active) + ?? runs.find(run => run.phase === 'Failed' || run.phase === 'Error') + ?? runs[0] +} + +function phaseBadgeClass(phase: string): string { + switch (phase) { + case 'Succeeded': + return 'status-healthy' + case 'Running': + return 'status-neutral' + case 'Failed': + case 'Error': + return 'status-unhealthy' + case 'Pending': + return 'status-degraded' + default: + return 'status-unknown' + } +} + +function formatRunTime(run: WorkloadRun): string { + const raw = run.startedAt || run.scheduledAt || run.finishedAt + if (!raw) return '' + return new Date(raw).toLocaleString() +} diff --git a/web/src/components/workload/WorkloadView.tsx b/web/src/components/workload/WorkloadView.tsx index 98da0dec8..4e2b9497b 100644 --- a/web/src/components/workload/WorkloadView.tsx +++ b/web/src/components/workload/WorkloadView.tsx @@ -38,6 +38,7 @@ import { RightsizingStrip } from '../resource/RightsizingStrip' import { useResourceAudit, useResourceIssues, useResources } from '../../api/client' import { AuditAlerts, ResourceIssuesSection } from '@skyhook-io/k8s-ui' import { WorkloadLogsViewer } from '../logs/WorkloadLogsViewer' +import { ScheduledWorkloadLogsViewer } from '../logs/ScheduledWorkloadLogsViewer' import { LogsViewer } from '../logs/LogsViewer' import { useCanUpdateSecrets, useCanNodeWrite, useNamespacedCapabilities, useIsLocalDeployment } from '../../contexts/CapabilitiesContext' import { useOpenTerminal, useOpenLogs, useOpenWorkloadLogs, useOpenNodeTerminal } from '../dock' @@ -699,7 +700,8 @@ function hasGitOpsStatusPayload(owner: GitOpsOwnerRef, resource: any): boolean { // LOGS TAB — platform-specific (uses data-fetching hooks) // ============================================================================ -const WORKLOAD_LOG_KINDS = new Set(['Deployment', 'StatefulSet', 'DaemonSet']) +const WORKLOAD_LOG_KINDS = new Set(['Deployment', 'StatefulSet', 'DaemonSet', 'Job', 'Workflow']) +const SCHEDULED_LOG_KINDS = new Set(['CronJob', 'CronWorkflow']) function LogsTabContent({ kind, @@ -724,11 +726,19 @@ function LogsTabContent({ initialContainer: string | null onConsumeInitialContainer: () => void }) { - // Workload kinds (Deployment, StatefulSet, DaemonSet) use the aggregated workload logs viewer + if (SCHEDULED_LOG_KINDS.has(kind)) { + return ( +
+ +
+ ) + } + + // Workload kinds with stable pod selectors use the aggregated workload logs viewer if (WORKLOAD_LOG_KINDS.has(kind)) { return (
- +
) } @@ -750,6 +760,17 @@ function LogsTabContent({ ) } +function shouldAutoStreamWorkloadLogs(kind: string, resource: any): boolean { + if (kind === 'Job') { + return (resource?.status?.active ?? 0) > 0 + } + if (kind === 'Workflow') { + const phase = resource?.status?.phase + return phase === 'Running' || phase === 'Pending' + } + return true +} + function PodLogsTab({ namespace, name, resource, initialContainer, onConsumeInitialContainer }: { namespace: string name: string From c353074ff4f286793fe0fa568234564fe84e2402 Mon Sep 17 00:00:00 2001 From: Nadav Erell Date: Sat, 4 Jul 2026 17:32:57 +0300 Subject: [PATCH 2/7] Make batch workloads first-class --- internal/server/applications.go | 301 +++++++- internal/server/workload_logs.go | 108 ++- .../src/components/applications/AppChips.tsx | 10 + .../applications/ApplicationDetail.tsx | 5 +- .../applications/ApplicationsView.tsx | 29 +- .../k8s-ui/src/components/logs/LogCore.tsx | 6 +- .../components/resources/ResourcesView.tsx | 42 ++ .../renderers/CronWorkflowRenderer.tsx | 88 +++ .../components/resources/renderers/index.ts | 1 + .../components/resources/resource-utils.ts | 31 + .../shared/ResourceRendererDispatch.tsx | 6 +- .../components/topology/K8sResourceNode.tsx | 4 +- .../topology/TopologyFilterSidebar.tsx | 2 + .../k8s-ui/src/components/topology/layout.ts | 6 +- packages/k8s-ui/src/components/ui/Badge.tsx | 3 + .../src/components/workload/WorkloadView.tsx | 33 +- packages/k8s-ui/src/utils/applications.ts | 91 ++- .../k8s-ui/src/utils/resource-hierarchy.ts | 2 +- packages/k8s-ui/src/utils/resource-icons.ts | 1 + pkg/topology/builder.go | 169 ++++- pkg/topology/neighborhood.go | 2 +- pkg/topology/types.go | 2 + web/src/api/client.ts | 17 + .../execution/BatchExecutionView.tsx | 676 ++++++++++++++++++ .../logs/ScheduledWorkloadLogsViewer.tsx | 18 +- .../renderers/CronWorkflowRenderer.tsx | 1 + .../components/resources/renderers/index.ts | 1 + web/src/components/workload/WorkloadView.tsx | 12 + 28 files changed, 1617 insertions(+), 50 deletions(-) create mode 100644 packages/k8s-ui/src/components/resources/renderers/CronWorkflowRenderer.tsx create mode 100644 web/src/components/execution/BatchExecutionView.tsx create mode 100644 web/src/components/resources/renderers/CronWorkflowRenderer.tsx diff --git a/internal/server/applications.go b/internal/server/applications.go index ee516fa24..68f410dc9 100644 --- a/internal/server/applications.go +++ b/internal/server/applications.go @@ -15,6 +15,7 @@ import ( batchv1 "k8s.io/api/batch/v1" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/labels" "github.com/skyhook-io/radar/internal/k8s" @@ -139,18 +140,19 @@ type appEvent struct { // appWorkload is one concrete workload belonging to an app, with its primary // container image as the version anchor when the workload has a pod template. type appWorkload struct { - Kind string `json:"kind"` - Namespace string `json:"namespace"` - Name string `json:"name"` - WorkloadClass string `json:"workload_class,omitempty"` // service | worker | job | unknown - Image string `json:"image,omitempty"` // full primary-container image ref - Version string `json:"version,omitempty"` // image tag (digest-only → empty) - AppVersion string `json:"appVersion,omitempty"` // app.kubernetes.io/version label (upstream release, e.g. v2.49.1) - Health string `json:"health"` - Ready int `json:"ready"` // ready/available replicas - Desired int `json:"desired"` // desired replicas - Restarts int `json:"restarts"` // total container restarts across the workload's pods - Reason string `json:"reason,omitempty"` // last-terminated reason of the worst pod (CrashLoopBackOff/OOMKilled/…) + Kind string `json:"kind"` + Namespace string `json:"namespace"` + Name string `json:"name"` + WorkloadClass string `json:"workload_class,omitempty"` // service | worker | job | unknown + Image string `json:"image,omitempty"` // full primary-container image ref + Version string `json:"version,omitempty"` // image tag (digest-only → empty) + AppVersion string `json:"appVersion,omitempty"` // app.kubernetes.io/version label (upstream release, e.g. v2.49.1) + Health string `json:"health"` + Ready int `json:"ready"` // ready/available replicas + Desired int `json:"desired"` // desired replicas + Restarts int `json:"restarts"` // total container restarts across the workload's pods + Reason string `json:"reason,omitempty"` // last-terminated reason of the worst pod (CrashLoopBackOff/OOMKilled/…) + Batch *appBatchSummary `json:"batch,omitempty"` // envLabel is the explicit environment label, when the workload carries // one (see envLabelOf) — app-identity resolver input, not on the wire. @@ -164,6 +166,22 @@ type appWorkload struct { appAnnotation string } +type appBatchSummary struct { + Schedule string `json:"schedule,omitempty"` + Suspended bool `json:"suspended,omitempty"` + ActiveRuns int `json:"activeRuns,omitempty"` + RetainedRuns int `json:"retainedRuns,omitempty"` + FailedRuns int `json:"failedRuns,omitempty"` + SucceededRuns int `json:"succeededRuns,omitempty"` + LatestRunName string `json:"latestRunName,omitempty"` + LatestRunPhase string `json:"latestRunPhase,omitempty"` + LatestStartedAt string `json:"latestStartedAt,omitempty"` + LatestFinishedAt string `json:"latestFinishedAt,omitempty"` + LastScheduledAt string `json:"lastScheduledAt,omitempty"` + LastSuccessfulAt string `json:"lastSuccessfulAt,omitempty"` + Message string `json:"message,omitempty"` +} + // handleListApplications serves GET /api/applications. // // ?namespaces=a,b,c | ?namespace=a — limit to workloads in the namespace set. @@ -215,7 +233,7 @@ func ListApplications(ctx context.Context, namespaces []string) (*applicationsRe } g := buildAppGraph(cache, namespaces) - wls := collectAppWorkloads(cache, namespaces, g) + wls := collectAppWorkloads(ctx, cache, namespaces, g) rows := groupApplications(wls) sourcePaths, appSetChildren, argoItems := argoApplicationFacts(ctx, cache) appSetByKey := appSetFanouts(appSetChildren) @@ -420,13 +438,15 @@ type appWorkloadInput struct { // each to its structural root and label overlay, and classifies add-on // machinery. Pods and Warning events are indexed once per namespace and joined, // not re-listed per workload. -func collectAppWorkloads(cache *k8s.ResourceCache, namespaces []string, g *appGraph) []appWorkloadInput { +func collectAppWorkloads(ctx context.Context, cache *k8s.ResourceCache, namespaces []string, g *appGraph) []appWorkloadInput { var out []appWorkloadInput podsByNS := indexPodsByNamespace(cache, namespaces) eventsByObj := indexWarningEventsByObject(cache, namespaces) + cronJobBatches := cronJobBatchSummaries(cache, namespaces) + cronWorkflowBatches := cronWorkflowBatchSummaries(ctx, cache, namespaces) - add := func(kind, ns, name string, lbls, anns map[string]string, image string, health packages.Health, ready, desired int, selector *metav1.LabelSelector) { + add := func(kind, ns, name string, lbls, anns map[string]string, image string, health packages.Health, ready, desired int, selector *metav1.LabelSelector, batch *appBatchSummary) { pods := podsForSelector(podsByNS[ns], selector) restarts, reason := podsRestarts(pods) meta := metav1.ObjectMeta{Namespace: ns, Name: name, Labels: lbls, Annotations: anns} @@ -447,6 +467,7 @@ func collectAppWorkloads(cache *k8s.ResourceCache, namespaces []string, g *appGr Desired: desired, Restarts: restarts, Reason: reason, + Batch: batch, envLabel: envLabelOf(lbls), nameLabel: lbls["app.kubernetes.io/name"], appAnnotation: strings.TrimSpace(anns[appIdentityAnnotation]), @@ -483,7 +504,7 @@ func collectAppWorkloads(cache *k8s.ResourceCache, namespaces []string, g *appGr add("Deployment", d.Namespace, d.Name, d.Labels, d.Annotations, primaryImage(d.Spec.Template.Spec.Containers), levelToPackagesHealth(health.Workload(d, time.Now()).Level), - int(d.Status.AvailableReplicas), int(d.Status.Replicas), d.Spec.Selector) + int(d.Status.AvailableReplicas), int(d.Status.Replicas), d.Spec.Selector, nil) } }) } @@ -499,7 +520,7 @@ func collectAppWorkloads(cache *k8s.ResourceCache, namespaces []string, g *appGr add("DaemonSet", d.Namespace, d.Name, d.Labels, d.Annotations, primaryImage(d.Spec.Template.Spec.Containers), levelToPackagesHealth(health.Workload(d, time.Now()).Level), - int(d.Status.NumberReady), int(d.Status.DesiredNumberScheduled), d.Spec.Selector) + int(d.Status.NumberReady), int(d.Status.DesiredNumberScheduled), d.Spec.Selector, nil) } }) } @@ -515,7 +536,7 @@ func collectAppWorkloads(cache *k8s.ResourceCache, namespaces []string, g *appGr add("StatefulSet", d.Namespace, d.Name, d.Labels, d.Annotations, primaryImage(d.Spec.Template.Spec.Containers), levelToPackagesHealth(health.Workload(d, time.Now()).Level), - int(d.Status.ReadyReplicas), int(d.Status.Replicas), d.Spec.Selector) + int(d.Status.ReadyReplicas), int(d.Status.Replicas), d.Spec.Selector, nil) } }) } @@ -534,7 +555,7 @@ func collectAppWorkloads(cache *k8s.ResourceCache, namespaces []string, g *appGr add("Job", j.Namespace, j.Name, j.Labels, j.Annotations, primaryImage(j.Spec.Template.Spec.Containers), levelToPackagesHealth(health.Workload(j, time.Now()).Level), - int(j.Status.Succeeded), jobDesired(j), j.Spec.Selector) + int(j.Status.Succeeded), jobDesired(j), j.Spec.Selector, jobBatchSummary(j)) } }) } @@ -547,16 +568,252 @@ func collectAppWorkloads(cache *k8s.ResourceCache, namespaces []string, g *appGr items, _ = cjLister.CronJobs(ns).List(labels.Everything()) } for _, cj := range items { + batch := cronJobBatches[cj.Namespace+"/"+cj.Name] + if batch == nil { + batch = &appBatchSummary{} + } + batch.Schedule = cj.Spec.Schedule + batch.Suspended = cj.Spec.Suspend != nil && *cj.Spec.Suspend + batch.LastScheduledAt = formatMetaTime(cj.Status.LastScheduleTime) + batch.LastSuccessfulAt = formatMetaTime(cj.Status.LastSuccessfulTime) add("CronJob", cj.Namespace, cj.Name, cj.Labels, cj.Annotations, primaryImage(cj.Spec.JobTemplate.Spec.Template.Spec.Containers), - levelToPackagesHealth(health.Workload(cj, time.Now()).Level), - 0, 0, nil) + batchHealth(batch, levelToPackagesHealth(health.Workload(cj, time.Now()).Level)), + 0, 0, nil, batch) } }) } + addArgoBatchWorkloads(ctx, cache, namespaces, add, cronWorkflowBatches) + return out +} + +type addAppWorkloadFunc func(kind, ns, name string, lbls, anns map[string]string, image string, health packages.Health, ready, desired int, selector *metav1.LabelSelector, batch *appBatchSummary) + +func jobBatchSummary(job *batchv1.Job) *appBatchSummary { + b := &appBatchSummary{} + applyRunToBatch(b, jobRunInfo(job)) + return b +} + +func cronJobBatchSummaries(cache *k8s.ResourceCache, namespaces []string) map[string]*appBatchSummary { + out := map[string]*appBatchSummary{} + jobLister := cache.Jobs() + if jobLister == nil { + return out + } + forEachWorkloadNamespace(namespaces, func(ns string) { + var jobs []*batchv1.Job + if ns == "" { + jobs, _ = jobLister.List(labels.Everything()) + } else { + jobs, _ = jobLister.Jobs(ns).List(labels.Everything()) + } + for _, job := range jobs { + for _, owner := range job.OwnerReferences { + if owner.Kind != "CronJob" || owner.Name == "" { + continue + } + key := job.Namespace + "/" + owner.Name + if out[key] == nil { + out[key] = &appBatchSummary{} + } + applyRunToBatch(out[key], jobRunInfo(job)) + } + } + }) + return out +} + +func cronWorkflowBatchSummaries(ctx context.Context, cache *k8s.ResourceCache, namespaces []string) map[string]*appBatchSummary { + out := map[string]*appBatchSummary{} + workflows := listArgoWorkflows(ctx, cache, namespaces) + for _, wf := range workflows { + owner := wf.GetLabels()["workflows.argoproj.io/cron-workflow"] + if owner == "" { + continue + } + key := wf.GetNamespace() + "/" + owner + if out[key] == nil { + out[key] = &appBatchSummary{} + } + applyRunToBatch(out[key], workflowRunInfo(wf)) + } + return out +} + +func addArgoBatchWorkloads(ctx context.Context, cache *k8s.ResourceCache, namespaces []string, add addAppWorkloadFunc, cronWorkflowBatches map[string]*appBatchSummary) { + for _, wf := range listArgoWorkflows(ctx, cache, namespaces) { + if wf.GetLabels()["workflows.argoproj.io/cron-workflow"] != "" { + continue + } + run := workflowRunInfo(wf) + batch := &appBatchSummary{} + applyRunToBatch(batch, run) + add("Workflow", wf.GetNamespace(), wf.GetName(), wf.GetLabels(), wf.GetAnnotations(), + workflowPrimaryImage(wf), workflowHealth(run.Phase), run.StepSucceeded, maxInt(run.StepTotal, run.PodTotal), nil, batch) + } + + for _, cwf := range listArgoCronWorkflows(ctx, cache, namespaces) { + batch := cronWorkflowBatches[cwf.GetNamespace()+"/"+cwf.GetName()] + if batch == nil { + batch = &appBatchSummary{} + } + batch.Schedule = cronWorkflowSchedule(cwf) + suspended, _, _ := unstructured.NestedBool(cwf.Object, "spec", "suspend") + batch.Suspended = suspended + lastScheduled, _, _ := unstructured.NestedString(cwf.Object, "status", "lastScheduledTime") + if batch.LastScheduledAt == "" { + batch.LastScheduledAt = lastScheduled + } + add("CronWorkflow", cwf.GetNamespace(), cwf.GetName(), cwf.GetLabels(), cwf.GetAnnotations(), + cronWorkflowPrimaryImage(cwf), batchHealth(batch, packages.HealthNeutral), 0, 0, nil, batch) + } +} + +func listArgoWorkflows(ctx context.Context, cache *k8s.ResourceCache, namespaces []string) []*unstructured.Unstructured { + return listDynamicByNamespaces(ctx, cache, namespaces, "Workflow") +} + +func listArgoCronWorkflows(ctx context.Context, cache *k8s.ResourceCache, namespaces []string) []*unstructured.Unstructured { + return listDynamicByNamespaces(ctx, cache, namespaces, "CronWorkflow") +} + +func listDynamicByNamespaces(ctx context.Context, cache *k8s.ResourceCache, namespaces []string, kind string) []*unstructured.Unstructured { + var out []*unstructured.Unstructured + forEachWorkloadNamespace(namespaces, func(ns string) { + items, err := cache.ListDynamicWithGroup(ctx, kind, ns, "argoproj.io") + if err != nil { + return + } + out = append(out, items...) + }) return out } +func applyRunToBatch(b *appBatchSummary, run WorkloadRun) { + b.RetainedRuns++ + if run.Active { + b.ActiveRuns++ + } + switch run.Phase { + case "Succeeded": + b.SucceededRuns++ + case "Failed", "Error": + b.FailedRuns++ + } + if b.LatestRunName == "" || runSortTime(run).After(parseBatchTime(b.LatestStartedAt, b.LastScheduledAt, b.LatestFinishedAt)) { + b.LatestRunName = run.Name + b.LatestRunPhase = run.Phase + b.LatestStartedAt = run.StartedAt + b.LatestFinishedAt = run.FinishedAt + if run.ScheduledAt != "" { + b.LastScheduledAt = run.ScheduledAt + } + b.Message = run.Message + } +} + +func parseBatchTime(values ...string) time.Time { + for _, value := range values { + if value == "" { + continue + } + if t, err := time.Parse(time.RFC3339, value); err == nil { + return t + } + } + return time.Time{} +} + +func batchHealth(batch *appBatchSummary, fallback packages.Health) packages.Health { + if batch == nil { + return fallback + } + if batch.LatestRunPhase == "Failed" || batch.LatestRunPhase == "Error" { + return packages.HealthUnhealthy + } + if batch.ActiveRuns > 0 { + return packages.HealthNeutral + } + if batch.Suspended { + return packages.HealthNeutral + } + if batch.LatestRunPhase == "Succeeded" { + return packages.HealthHealthy + } + return fallback +} + +func workflowHealth(phase string) packages.Health { + switch phase { + case "Succeeded": + return packages.HealthHealthy + case "Running": + return packages.HealthNeutral + case "Failed", "Error": + return packages.HealthUnhealthy + case "Pending": + return packages.HealthDegraded + default: + return packages.HealthUnknown + } +} + +func workflowPrimaryImage(wf *unstructured.Unstructured) string { + return templateImage(wf.Object, "spec", "templates") +} + +func cronWorkflowPrimaryImage(cwf *unstructured.Unstructured) string { + if image := templateImage(cwf.Object, "spec", "workflowSpec", "templates"); image != "" { + return image + } + return "" +} + +func templateImage(obj map[string]any, path ...string) string { + templates, found, _ := unstructured.NestedSlice(obj, path...) + if !found { + return "" + } + for _, raw := range templates { + tpl, ok := raw.(map[string]any) + if !ok { + continue + } + container, _, _ := unstructured.NestedMap(tpl, "container") + if image, _ := container["image"].(string); image != "" { + return image + } + } + return "" +} + +func cronWorkflowSchedule(cwf *unstructured.Unstructured) string { + schedules, found, _ := unstructured.NestedStringSlice(cwf.Object, "spec", "schedules") + if found && len(schedules) > 0 { + return strings.Join(schedules, ", ") + } + schedule, _, _ := unstructured.NestedString(cwf.Object, "spec", "schedule") + return schedule +} + +func forEachWorkloadNamespace(namespaces []string, fn func(ns string)) { + if namespaces == nil { + fn("") + return + } + for _, ns := range namespaces { + fn(ns) + } +} + +func maxInt(a, b int) int { + if a > b { + return a + } + return b +} + // --- grouping ------------------------------------------------------------ // groupApplications partitions workloads into logical apps. Each workload @@ -886,7 +1143,7 @@ func refKey(r topology.ResourceRef) string { func classifyWorkload(kind string, rels *appRelationships) string { switch kind { - case "Job", "CronJob": + case "Job", "CronJob", "Workflow", "CronWorkflow": return "job" case "Deployment", "StatefulSet", "DaemonSet": if rels != nil && (len(rels.Services) > 0 || len(rels.Ingresses) > 0 || len(rels.Routes) > 0) { diff --git a/internal/server/workload_logs.go b/internal/server/workload_logs.go index 0a41d4134..57955aee0 100644 --- a/internal/server/workload_logs.go +++ b/internal/server/workload_logs.go @@ -3,6 +3,7 @@ package server import ( "bufio" "context" + "fmt" "io" "log" "net/http" @@ -59,6 +60,25 @@ type WorkloadRun struct { FinishedAt string `json:"finishedAt,omitempty"` ScheduledAt string `json:"scheduledAt,omitempty"` Message string `json:"message,omitempty"` + + Succeeded int32 `json:"succeeded,omitempty"` + Failed int32 `json:"failed,omitempty"` + Running int32 `json:"running,omitempty"` + Desired int32 `json:"desired,omitempty"` + Parallelism int32 `json:"parallelism,omitempty"` + Progress string `json:"progress,omitempty"` + Template string `json:"template,omitempty"` + + PodTotal int `json:"podTotal,omitempty"` + PodSucceeded int `json:"podSucceeded,omitempty"` + PodFailed int `json:"podFailed,omitempty"` + PodRunning int `json:"podRunning,omitempty"` + PodPending int `json:"podPending,omitempty"` + StepTotal int `json:"stepTotal,omitempty"` + StepSucceeded int `json:"stepSucceeded,omitempty"` + StepFailed int `json:"stepFailed,omitempty"` + StepRunning int `json:"stepRunning,omitempty"` + StepSkipped int `json:"stepSkipped,omitempty"` } // validWorkloadKinds defines which resource types support workload logs. @@ -625,12 +645,25 @@ func jobRunInfo(job *batchv1.Job) WorkloadRun { } run := WorkloadRun{ - Kind: "jobs", - Namespace: job.Namespace, - Name: job.Name, - Phase: phase, - Active: phase == "Running" || phase == "Pending", - StartedAt: formatMetaTime(job.Status.StartTime), + Kind: "jobs", + Namespace: job.Namespace, + Name: job.Name, + Phase: phase, + Active: phase == "Running" || phase == "Pending", + StartedAt: formatMetaTime(job.Status.StartTime), + ScheduledAt: job.Annotations["batch.kubernetes.io/cronjob-scheduled-timestamp"], + Succeeded: job.Status.Succeeded, + Failed: job.Status.Failed, + Running: job.Status.Active, + Desired: jobDesiredCount(job), + Parallelism: jobParallelismCount(job), + PodSucceeded: int(job.Status.Succeeded), + PodFailed: int(job.Status.Failed), + PodRunning: int(job.Status.Active), + } + run.PodTotal = run.PodSucceeded + run.PodFailed + run.PodRunning + if run.Desired > 0 { + run.Progress = fmt.Sprintf("%d/%d", run.Succeeded, run.Desired) } if complete { applyJobCondition(&run, completeCondition) @@ -656,15 +689,31 @@ func applyJobCondition(run *WorkloadRun, condition batchv1.JobCondition) { } } +func jobDesiredCount(job *batchv1.Job) int32 { + if job.Spec.Completions != nil { + return *job.Spec.Completions + } + return 1 +} + +func jobParallelismCount(job *batchv1.Job) int32 { + if job.Spec.Parallelism != nil { + return *job.Spec.Parallelism + } + return 1 +} + func workflowRunInfo(workflow *unstructured.Unstructured) WorkloadRun { phase, _, _ := unstructured.NestedString(workflow.Object, "status", "phase") startedAt, _, _ := unstructured.NestedString(workflow.Object, "status", "startedAt") finishedAt, _, _ := unstructured.NestedString(workflow.Object, "status", "finishedAt") message, _, _ := unstructured.NestedString(workflow.Object, "status", "message") + progress, _, _ := unstructured.NestedString(workflow.Object, "status", "progress") + template, _, _ := unstructured.NestedString(workflow.Object, "spec", "workflowTemplateRef", "name") if phase == "" { phase = "Pending" } - return WorkloadRun{ + run := WorkloadRun{ Kind: "workflows", Namespace: workflow.GetNamespace(), Name: workflow.GetName(), @@ -674,6 +723,51 @@ func workflowRunInfo(workflow *unstructured.Unstructured) WorkloadRun { FinishedAt: finishedAt, ScheduledAt: workflow.GetAnnotations()["workflows.argoproj.io/scheduled-time"], Message: message, + Progress: progress, + Template: template, + } + applyWorkflowNodeCounts(&run, workflow) + return run +} + +func applyWorkflowNodeCounts(run *WorkloadRun, workflow *unstructured.Unstructured) { + nodes, found, _ := unstructured.NestedMap(workflow.Object, "status", "nodes") + if !found { + return + } + for _, raw := range nodes { + node, ok := raw.(map[string]any) + if !ok { + continue + } + nodeType, _ := node["type"].(string) + nodePhase, _ := node["phase"].(string) + if nodeType == "Pod" { + run.PodTotal++ + switch nodePhase { + case "Succeeded": + run.PodSucceeded++ + case "Failed", "Error": + run.PodFailed++ + case "Running": + run.PodRunning++ + case "Pending": + run.PodPending++ + } + } + if nodeType == "Pod" || nodeType == "Steps" || nodeType == "StepGroup" || nodeType == "DAG" || nodeType == "TaskGroup" || nodeType == "Suspend" || nodeType == "Skipped" { + run.StepTotal++ + switch nodePhase { + case "Succeeded": + run.StepSucceeded++ + case "Failed", "Error": + run.StepFailed++ + case "Running": + run.StepRunning++ + case "Skipped", "Omitted": + run.StepSkipped++ + } + } } } diff --git a/packages/k8s-ui/src/components/applications/AppChips.tsx b/packages/k8s-ui/src/components/applications/AppChips.tsx index c694672b4..aee434396 100644 --- a/packages/k8s-ui/src/components/applications/AppChips.tsx +++ b/packages/k8s-ui/src/components/applications/AppChips.tsx @@ -2,6 +2,7 @@ import { Tooltip } from '../ui/Tooltip' import { type AppRow, type AppWorkloadClass, + type BatchSignal, CHIP, CHIP_TONE, CLASS_META, @@ -69,6 +70,15 @@ export function CategoryChip({ category, addonReason }: { category?: string; add ) } +export function BatchSignalChip({ signal }: { signal: BatchSignal | null }) { + if (!signal) return null + return ( + + {signal.label} + + ) +} + /** The version display: appVersion when the workloads agree on one upstream * version, the single image tag, or "N versions" (amber only on real skew). * `cell` = table chip styling, `fact` = the detail context strip. */ diff --git a/packages/k8s-ui/src/components/applications/ApplicationDetail.tsx b/packages/k8s-ui/src/components/applications/ApplicationDetail.tsx index 22ae4b3eb..537821cf3 100644 --- a/packages/k8s-ui/src/components/applications/ApplicationDetail.tsx +++ b/packages/k8s-ui/src/components/applications/ApplicationDetail.tsx @@ -23,6 +23,7 @@ import { identityEnvInferred, workloadClassOf, classCompositionOf, + batchSignalForApp, worstHealth, appGroupLagMessage, compareVersions, @@ -30,7 +31,7 @@ import { import { PaneLoader } from '../ui/PaneLoader' import { midTruncate } from '../../utils/format' import { VersionTooltip, AppIdentityTooltip } from './AppTooltips' -import { ProvenanceBadge, ClassBadge, CategoryChip, VersionInfo } from './AppChips' +import { ProvenanceBadge, ClassBadge, CategoryChip, VersionInfo, BatchSignalChip } from './AppChips' import { ReadyBar } from './ReadyBar' // ApplicationDetail — pure single-cluster detail shell. Owns the title row @@ -158,6 +159,7 @@ export function ApplicationDetail({ app, onBack, renderWorkload, topology, topol const ready = workloads.reduce((n, w) => n + (w.ready ?? 0), 0) const desired = workloads.reduce((n, w) => n + (w.desired ?? 0), 0) const restartSignal = restartWarning(workloads) + const batchSignal = batchSignalForApp(app) // Resolve namespace the same way the list does (the workloads' shared // namespace) so env/namespace match across list and detail. Multi-namespace // apps get the count, never an arbitrary pick. @@ -263,6 +265,7 @@ export function ApplicationDetail({ app, onBack, renderWorkload, topology, topol +
diff --git a/packages/k8s-ui/src/components/applications/ApplicationsView.tsx b/packages/k8s-ui/src/components/applications/ApplicationsView.tsx index 15498fe1f..093cad78c 100644 --- a/packages/k8s-ui/src/components/applications/ApplicationsView.tsx +++ b/packages/k8s-ui/src/components/applications/ApplicationsView.tsx @@ -17,6 +17,7 @@ import { useRegisterShortcuts } from '../../hooks/useKeyboardShortcuts' import { pluralize } from '../../utils/pluralize' import { type AppEntry, + type AppRow, type AppHealth, type AppWorkloadClass, type AppSource, @@ -36,10 +37,11 @@ import { isSystemNamespace, searchTextForEntry, foldAppGroups, + batchSignalForApp, type FoldedRow, } from '../../utils/applications' import { ReadyBar } from './ReadyBar' -import { ProvenanceBadge, ClassBadge, CategoryChip, VersionInfo } from './AppChips' +import { ProvenanceBadge, ClassBadge, CategoryChip, VersionInfo, BatchSignalChip } from './AppChips' import { AppIdentityTooltip, EnvHint } from './AppTooltips' // ApplicationsView — the shared, variant-agnostic core behind the Applications @@ -457,6 +459,7 @@ export function ApplicationsView({ entries: allEntries, variant, onSelect, title {r.label} + m.row))} /> ({ name: m.row.name, env: m.row.identity!.env, confidence: m.row.identity!.confidence, evidence: m.row.identity!.evidence }))} />} delay={150} @@ -535,6 +538,7 @@ export function ApplicationsView({ entries: allEntries, variant, onSelect, title {e.row.name} + @@ -640,3 +644,26 @@ export function ApplicationsView({ entries: allEntries, variant, onSelect, title
) } + +function firstBatchSignal(rows: AppRow[]) { + let best = null as ReturnType + const rank = (tone: string | undefined) => { + switch (tone) { + case 'rose': + return 5 + case 'amber': + return 4 + case 'sky': + return 3 + case 'emerald': + return 2 + default: + return 0 + } + } + for (const row of rows) { + const signal = batchSignalForApp(row) + if (signal && rank(signal.tone) > rank(best?.tone)) best = signal + } + return best +} diff --git a/packages/k8s-ui/src/components/logs/LogCore.tsx b/packages/k8s-ui/src/components/logs/LogCore.tsx index 86b372148..ddd8d00a4 100644 --- a/packages/k8s-ui/src/components/logs/LogCore.tsx +++ b/packages/k8s-ui/src/components/logs/LogCore.tsx @@ -88,6 +88,8 @@ const STRUCTURED_MODE_DESCRIPTIONS: Record = { raw: 'Original log line, unparsed', } +const EMPTY_STATE_CLASS = 'flex-1 flex flex-col items-center justify-center gap-2 px-4 text-center' + const TIMESTAMP_FORMAT_SHORT_LABELS: Record = { 'time-local': 'Local time', 'time-utc': 'UTC time', @@ -800,12 +802,12 @@ export function LogCore({
) : errorMessage ? ( -
+
{errorMessage}
) : groupedEntries.length === 0 ? ( -
+
{emptyMessage}
diff --git a/packages/k8s-ui/src/components/resources/ResourcesView.tsx b/packages/k8s-ui/src/components/resources/ResourcesView.tsx index 8ef80c4f1..2ecf79b79 100644 --- a/packages/k8s-ui/src/components/resources/ResourcesView.tsx +++ b/packages/k8s-ui/src/components/resources/ResourcesView.tsx @@ -84,6 +84,10 @@ import { getWorkflowDuration, getWorkflowProgress, getWorkflowTemplate, + getCronWorkflowStatus, + getCronWorkflowSchedule, + getCronWorkflowLastRun, + getCronWorkflowTemplate, getPVStatus, getPVAccessModes, getPVClaim, @@ -460,6 +464,15 @@ const KNOWN_COLUMNS: Record = { { key: 'template', label: 'Template', width: 'w-40', hideOnMobile: true }, { key: 'age', label: 'Age', width: 'w-24' }, ], + cronworkflows: [ + { key: 'name', label: 'Name' }, + { key: 'namespace', label: 'Namespace', width: 'w-48' }, + { key: 'schedule', label: 'Schedule', width: 'w-40' }, + { key: 'status', label: 'Status', width: 'w-28' }, + { key: 'lastRun', label: 'Last Run', width: 'w-28', hideOnMobile: true }, + { key: 'template', label: 'Template', width: 'w-40', hideOnMobile: true }, + { key: 'age', label: 'Age', width: 'w-24' }, + ], certificates: [ { key: 'name', label: 'Name' }, { key: 'namespace', label: 'Namespace', width: 'w-48' }, @@ -5218,6 +5231,8 @@ function CellContent({ resource, kind, column, group, majorityNodeMinorVersion, return case 'workflows': return + case 'cronworkflows': + return case 'certificates': return case 'persistentvolumes': @@ -6584,6 +6599,33 @@ function WorkflowCell({ resource, column }: { resource: any; column: string }) { } } +function CronWorkflowCell({ resource, column }: { resource: any; column: string }) { + switch (column) { + case 'status': { + const status = getCronWorkflowStatus(resource) + return ( + + {status.text} + + ) + } + case 'schedule': + return {getCronWorkflowSchedule(resource)} + case 'lastRun': + return {getCronWorkflowLastRun(resource)} + case 'template': { + const template = getCronWorkflowTemplate(resource) + return ( + + {template} + + ) + } + default: + return - + } +} + function PersistentVolumeCell({ resource, column }: { resource: any; column: string }) { switch (column) { diff --git a/packages/k8s-ui/src/components/resources/renderers/CronWorkflowRenderer.tsx b/packages/k8s-ui/src/components/resources/renderers/CronWorkflowRenderer.tsx new file mode 100644 index 000000000..78b0382ec --- /dev/null +++ b/packages/k8s-ui/src/components/resources/renderers/CronWorkflowRenderer.tsx @@ -0,0 +1,88 @@ +import { Clock, Pause } from 'lucide-react' +import { Section, PropertyList, Property, AlertBanner, ResourceLink, ConditionsSection } from '../../ui/drawer-components' +import { formatAge, cronToHuman } from '../resource-utils' + +interface CronWorkflowRendererProps { + data: any + onNavigate?: (ref: { kind: string; namespace: string; name: string }) => void +} + +export function CronWorkflowRenderer({ data, onNavigate }: CronWorkflowRendererProps) { + const spec = data.spec || {} + const status = data.status || {} + const workflowSpec = spec.workflowSpec || {} + const schedules = Array.isArray(spec.schedules) ? spec.schedules : spec.schedule ? [spec.schedule] : [] + const isSuspended = spec.suspend === true + const active = Array.isArray(status.active) ? status.active : [] + const templateName = workflowSpec.workflowTemplateRef?.name || workflowSpec.workflowTemplateRef?.template || '' + const hasNeverRun = !status.lastScheduledTime && active.length === 0 + + return ( + <> + {isSuspended && ( + + )} + + {hasNeverRun && !isSuspended && ( + + )} + +
+ + + {schedules.length === 1 && } + + + + + + +
+ +
+ + {templateName && ( + } + /> + )} + + + + +
+ +
+ + + + + +
+ + {active.length > 0 && ( +
+
+ {active.map((wf: any) => ( +
+ +
+ ))} +
+
+ )} + + + + ) +} diff --git a/packages/k8s-ui/src/components/resources/renderers/index.ts b/packages/k8s-ui/src/components/resources/renderers/index.ts index e416539b8..d80085bd5 100644 --- a/packages/k8s-ui/src/components/resources/renderers/index.ts +++ b/packages/k8s-ui/src/components/resources/renderers/index.ts @@ -23,6 +23,7 @@ export * from './CNPGScheduledBackupRenderer' export * from './ConfigAuditReportRenderer' export * from './ConfigMapRenderer' export * from './CronJobRenderer' +export * from './CronWorkflowRenderer' export * from './eso-cells' export * from './EventRenderer' export * from './EndpointSliceRenderer' diff --git a/packages/k8s-ui/src/components/resources/resource-utils.ts b/packages/k8s-ui/src/components/resources/resource-utils.ts index 205b97281..0a4483e2e 100644 --- a/packages/k8s-ui/src/components/resources/resource-utils.ts +++ b/packages/k8s-ui/src/components/resources/resource-utils.ts @@ -1406,6 +1406,36 @@ export function getWorkflowTemplate(workflow: any): string | null { return workflow.spec?.workflowTemplateRef?.name || null } +export function getCronWorkflowStatus(cwf: any): StatusBadge { + const spec = cwf.spec || {} + const status = cwf.status || {} + const active = Array.isArray(status.active) ? status.active.length : 0 + if (spec.suspend === true) { + return { text: 'Suspended', color: healthColors.degraded, level: 'degraded' } + } + if (active > 0) { + return { text: `${active} active`, color: healthColors.neutral, level: 'neutral' } + } + if (status.lastScheduledTime) { + return { text: 'Idle', color: healthColors.neutral, level: 'neutral' } + } + return { text: 'Never run', color: healthColors.unknown, level: 'unknown' } +} + +export function getCronWorkflowSchedule(cwf: any): string { + const schedules = cwf.spec?.schedules + if (Array.isArray(schedules) && schedules.length > 0) return schedules.join(', ') + return cwf.spec?.schedule || '-' +} + +export function getCronWorkflowLastRun(cwf: any): string { + return cwf.status?.lastScheduledTime ? formatAge(cwf.status.lastScheduledTime) : 'Never' +} + +export function getCronWorkflowTemplate(cwf: any): string { + return cwf.spec?.workflowSpec?.workflowTemplateRef?.name || cwf.spec?.workflowSpec?.entrypoint || '-' +} + // ============================================================================ // CERT-MANAGER UTILITIES — re-exported from resource-utils-certmanager.ts // ============================================================================ @@ -1866,6 +1896,7 @@ export function getCellFilterValue(resource: any, column: string, kind: string): if (kindLower === 'persistentvolumes') return getPVStatus(resource).text if (kindLower === 'rollouts') return getRolloutStatus(resource).text if (kindLower === 'workflows') return getWorkflowStatus(resource).text + if (kindLower === 'cronworkflows') return getCronWorkflowStatus(resource).text if (kindLower === 'hpas' || kindLower === 'horizontalpodautoscalers') return getHPAStatus(resource).text if (kindLower === 'gateways') return getGatewayStatus(resource).text if (kindLower === 'gatewayclasses') return getGatewayClassStatus(resource).text diff --git a/packages/k8s-ui/src/components/shared/ResourceRendererDispatch.tsx b/packages/k8s-ui/src/components/shared/ResourceRendererDispatch.tsx index 8674dad9f..80b9a67ad 100644 --- a/packages/k8s-ui/src/components/shared/ResourceRendererDispatch.tsx +++ b/packages/k8s-ui/src/components/shared/ResourceRendererDispatch.tsx @@ -11,6 +11,7 @@ import { getPVCStatus, getRolloutStatus, getWorkflowStatus, + getCronWorkflowStatus, getCertificateStatus, getPVStatus, getClusterIssuerStatus, @@ -82,6 +83,7 @@ import { SecretRenderer, JobRenderer, CronJobRenderer, + CronWorkflowRenderer, HPARenderer, NodeRenderer, PVCRenderer, @@ -316,7 +318,7 @@ export interface RendererOverrides { // Known resource types with specific renderers (module-level to avoid re-allocation) const KNOWN_KINDS = new Set([ 'pods', 'deployments', 'statefulsets', 'daemonsets', 'replicasets', - 'services', 'endpointslices', 'ingresses', 'configmaps', 'secrets', 'jobs', 'cronjobs', + 'services', 'endpointslices', 'ingresses', 'configmaps', 'secrets', 'jobs', 'cronjobs', 'cronworkflows', 'hpas', 'horizontalpodautoscalers', 'nodes', 'persistentvolumeclaims', 'rollouts', 'certificates', 'workflows', 'persistentvolumes', 'storageclasses', 'certificaterequests', 'clusterissuers', 'issuers', @@ -527,6 +529,7 @@ export function ResourceRendererDispatch({ {kind === 'secrets' && } {kind === 'jobs' && } {kind === 'cronjobs' && } + {kind === 'cronworkflows' && } {(kind === 'hpas' || kind === 'horizontalpodautoscalers') && } {kind === 'nodes' && } {kind === 'persistentvolumeclaims' && } @@ -752,6 +755,7 @@ export function getResourceStatus(kind: string, data: any): { text: string; colo if (k === 'persistentvolumeclaims') return getPVCStatus(data) if (k === 'rollouts') return getRolloutStatus(data) if (k === 'workflows') return getWorkflowStatus(data) + if (k === 'cronworkflows') return getCronWorkflowStatus(data) if (k === 'certificates') { if (data.apiVersion?.includes('networking.internal.knative.dev')) { const status = getKnativeConditionStatus(data) diff --git a/packages/k8s-ui/src/components/topology/K8sResourceNode.tsx b/packages/k8s-ui/src/components/topology/K8sResourceNode.tsx index 299e3c53f..4eca6e548 100644 --- a/packages/k8s-ui/src/components/topology/K8sResourceNode.tsx +++ b/packages/k8s-ui/src/components/topology/K8sResourceNode.tsx @@ -162,6 +162,8 @@ export const NODE_DIMENSIONS: Record): string // Kinds that own pods and therefore carry a podSummary in summary mode. const SUMMARY_POD_KINDS = new Set([ - 'Deployment', 'StatefulSet', 'DaemonSet', 'Rollout', 'Job', 'Service', + 'Deployment', 'StatefulSet', 'DaemonSet', 'Rollout', 'Job', 'CronJob', 'Workflow', 'CronWorkflow', 'Service', ]) function baseSubtitle(kind: NodeKind, nodeData: Record): string { diff --git a/packages/k8s-ui/src/components/topology/TopologyFilterSidebar.tsx b/packages/k8s-ui/src/components/topology/TopologyFilterSidebar.tsx index f70ec85e0..387e3341e 100644 --- a/packages/k8s-ui/src/components/topology/TopologyFilterSidebar.tsx +++ b/packages/k8s-ui/src/components/topology/TopologyFilterSidebar.tsx @@ -45,6 +45,8 @@ const RESOURCE_KINDS: { { kind: 'PodGroup', label: 'Pod Group', icon: getTopologyIcon('PodGroup'), color: 'text-lime-400', category: 'workloads' }, { kind: 'Job', label: 'Job', icon: getTopologyIcon('Job'), color: 'text-orange-400', category: 'workloads' }, { kind: 'CronJob', label: 'CronJob', icon: getTopologyIcon('CronJob'), color: 'text-orange-300', category: 'workloads' }, + { kind: 'Workflow', label: 'Workflow', icon: getTopologyIcon('Workflow'), color: 'text-purple-400', category: 'workloads' }, + { kind: 'CronWorkflow', label: 'CronWorkflow', icon: getTopologyIcon('CronWorkflow'), color: 'text-purple-300', category: 'workloads' }, // Config { kind: 'ConfigMap', label: 'ConfigMap', icon: getTopologyIcon('ConfigMap'), color: 'text-amber-400', category: 'config' }, diff --git a/packages/k8s-ui/src/components/topology/layout.ts b/packages/k8s-ui/src/components/topology/layout.ts index f85d8db3d..0fa1b4e95 100644 --- a/packages/k8s-ui/src/components/topology/layout.ts +++ b/packages/k8s-ui/src/components/topology/layout.ts @@ -827,8 +827,12 @@ function computeWorkloadCards( subtitle = (d.type as string) || 'ClusterIP' } else if (primary.kind === 'CronJob') { subtitle = (d.schedule as string) || '' + } else if (primary.kind === 'CronWorkflow') { + subtitle = (d.schedule as string) || '' } else if (primary.kind === 'Job') { subtitle = (d.phase as string) || '' + } else if (primary.kind === 'Workflow') { + subtitle = (d.progress as string) || (d.phase as string) || '' } else if (primary.kind === 'Application') { const sync = d.syncStatus as string const health = d.healthStatus as string @@ -855,7 +859,7 @@ function computeWorkloadCards( // Shared kind priority map — used by both pickGroupName and computeWorkloadCards const KIND_PRIORITY: Record = { 'Deployment': 1, 'Rollout': 1, 'StatefulSet': 2, 'DaemonSet': 3, - 'CronJob': 4, 'Job': 5, 'Service': 6, 'Gateway': 7, + 'CronJob': 4, 'CronWorkflow': 4, 'Job': 5, 'Workflow': 5, 'Service': 6, 'Gateway': 7, 'HTTPRoute': 6, 'GRPCRoute': 6, 'TCPRoute': 6, 'TLSRoute': 6, 'Ingress': 7, 'ReplicaSet': 8, 'Pod': 9, 'PodGroup': 9, 'ConfigMap': 10, 'Secret': 10, 'PersistentVolumeClaim': 10, 'HorizontalPodAutoscaler': 10, diff --git a/packages/k8s-ui/src/components/ui/Badge.tsx b/packages/k8s-ui/src/components/ui/Badge.tsx index 6b72b98d6..ec2027b6d 100644 --- a/packages/k8s-ui/src/components/ui/Badge.tsx +++ b/packages/k8s-ui/src/components/ui/Badge.tsx @@ -91,6 +91,9 @@ const KIND: Record = { // Jobs Job: 'bg-purple-100 text-purple-700 border-purple-300 dark:bg-purple-950/50 dark:text-purple-400 dark:border-purple-700/40', CronJob: 'bg-purple-100 text-purple-700 border-purple-300 dark:bg-purple-950/50 dark:text-purple-400 dark:border-purple-700/40', + Workflow: 'bg-purple-100 text-purple-700 border-purple-300 dark:bg-purple-950/50 dark:text-purple-400 dark:border-purple-700/40', + CronWorkflow: 'bg-purple-100 text-purple-700 border-purple-300 dark:bg-purple-950/50 dark:text-purple-400 dark:border-purple-700/40', + WorkflowTemplate: 'bg-purple-100 text-purple-700 border-purple-300 dark:bg-purple-950/50 dark:text-purple-400 dark:border-purple-700/40', // Scaling & Storage HorizontalPodAutoscaler: 'bg-pink-100 text-pink-800 border-pink-300 dark:bg-pink-950/50 dark:text-pink-300 dark:border-pink-700/40', diff --git a/packages/k8s-ui/src/components/workload/WorkloadView.tsx b/packages/k8s-ui/src/components/workload/WorkloadView.tsx index 24baa2197..9cec3506f 100644 --- a/packages/k8s-ui/src/components/workload/WorkloadView.tsx +++ b/packages/k8s-ui/src/components/workload/WorkloadView.tsx @@ -208,6 +208,15 @@ interface WorkloadViewProps { }) => ReactNode /** Render the metrics tab content */ renderMetricsTab?: (props: { kind: string; namespace: string; name: string }) => ReactNode + /** Fullscreen-only overview replacement for resources that need a richer, + * dedicated layout than the drawer renderer stretched across the page. */ + renderExpandedOverview?: (props: { + kind: string + apiKind: string + namespace: string + name: string + resource: any + }) => ReactNode /** Render a read-only YAML view for a related object from the workload's * neighborhood. Providing this turns the YAML tab into an object explorer * (rail of the workload + its Services/config/policies/pods); omitting it @@ -218,6 +227,9 @@ interface WorkloadViewProps { isMetricsAvailable?: (kind: string, resource: any) => boolean /** Render extra content at the bottom of the overview tab (e.g. audit findings) */ renderOverviewExtra?: (props: { kind: string; namespace: string; name: string }) => ReactNode + /** Render content near the top of the overview tab, after urgent issue banners + * and before the kind-specific renderer. */ + renderOverviewIntro?: (props: { kind: string; namespace: string; name: string }) => ReactNode /** Render content at the TOP of the overview tab, above the renderer (e.g. live * Operational Issues). Optional + additive — consumers that don't pass it are * unaffected. Only rendered when `hasOperationalIssues` is true: the lead @@ -300,11 +312,13 @@ export function WorkloadView({ renderLogsTab, renderRelatedYaml, renderMetricsTab, + renderExpandedOverview, isMetricsAvailable, // Duplicate onDuplicate, onDownload, renderOverviewExtra, + renderOverviewIntro, renderOverviewLead, hasOperationalIssues, // Actions bar @@ -328,6 +342,7 @@ export function WorkloadView({ // Normalize kind: URL has plural lowercase, internal logic uses singular PascalCase const kind = pluralToKind(kindProp) const apiKind = kindProp + const overviewIntro = renderOverviewIntro?.({ kind, namespace, name }) // Tab state — controlled or uncontrolled const [internalTab, setInternalTab] = useState('overview') @@ -580,6 +595,7 @@ export function WorkloadView({ { id: 'metrics', label: 'Metrics', icon: , hidden: !(showMetricsTab && renderMetricsTab) }, { id: 'yaml', label: 'YAML', icon: }, ] + const expandedOverview = renderExpandedOverview?.({ kind, apiKind, namespace, name, resource }) // ── Collapsed (drawer) mode ────────────────────────────────────────────── if (!expanded) { @@ -694,6 +710,11 @@ export function WorkloadView({ {renderOverviewLead({ kind, namespace, name })}
)} + {overviewIntro && ( +
+ {overviewIntro} +
+ )} : null} compactHeader={compactHeader} > - {effectiveTab === 'overview' && ( + {effectiveTab === 'overview' && expandedOverview ? ( + expandedOverview + ) : effectiveTab === 'overview' && ( )} @@ -1379,6 +1403,7 @@ function InfoTab({ eventsError, updatesError, extraContent, + introContent, leadContent, }: { resource: any @@ -1403,6 +1428,7 @@ function InfoTab({ eventsError?: Error | null updatesError?: Error | null extraContent?: ReactNode + introContent?: ReactNode leadContent?: ReactNode }) { if (!resource) { @@ -1416,6 +1442,11 @@ function InfoTab({ {leadContent}
)} + {introContent && ( +
+ {introContent} +
+ )} = { service: { label: 'Service', pill: CHIP_TONE.blue, tooltip: 'Long-running, request-serving (a Deployment/StatefulSet behind a Service/Ingress/route). Inferred from the workload shape + routing.' }, worker: { label: 'Worker', pill: CHIP_TONE.violet, tooltip: 'Long-running background processor (no serving edge). Inferred from the workload shape.' }, - job: { label: 'Job', pill: CHIP_TONE.amber, tooltip: 'Finite or scheduled work (Job/CronJob).' }, + job: { label: 'Job', pill: CHIP_TONE.amber, tooltip: 'Finite or scheduled work (Job/CronJob/Workflow/CronWorkflow).' }, mixed: { label: 'Mixed', pill: CHIP_TONE.neutral, tooltip: 'Contains workloads of more than one class (e.g. a service plus its scheduled jobs).' }, unknown: { label: 'Unknown', pill: CHIP_TONE.muted, tooltip: "Couldn't infer a runtime class from the workload." }, } @@ -730,6 +747,78 @@ export function classSetOf(app: AppRow): AppWorkloadClass[] { return [workloadClassOf(app.workload_class)] } +export interface BatchSignal { + tone: 'rose' | 'amber' | 'sky' | 'emerald' | 'muted' + label: string + detail: string + workload: AppWorkload +} + +export function batchSignalForApp(app: AppRow): BatchSignal | null { + let best: BatchSignal | null = null + for (const workload of app.workloads || []) { + const signal = batchSignalForWorkload(workload) + if (!signal) continue + if (!best || batchSignalRank(signal) > batchSignalRank(best)) best = signal + } + return best +} + +export function batchSignalForWorkload(workload: AppWorkload): BatchSignal | null { + const batch = workload.batch + if (!batch) return null + const latestPhase = batch.latestRunPhase || '' + const name = `${workload.kind}/${workload.name}` + if (latestPhase === 'Failed' || latestPhase === 'Error') { + return { + tone: 'rose', + label: 'batch failed', + detail: batch.message || `${name} latest run ${batch.latestRunName || ''} ended ${latestPhase}.`, + workload, + } + } + if ((batch.failedRuns ?? 0) > 1) { + return { + tone: 'amber', + label: `${batch.failedRuns} failed runs`, + detail: `${name} has ${batch.failedRuns} retained failed runs.`, + workload, + } + } + if (batch.suspended) { + return { + tone: 'sky', + label: 'schedule suspended', + detail: `${name} will not create new runs until resumed.`, + workload, + } + } + if ((batch.activeRuns ?? 0) > 0) { + return { + tone: 'sky', + label: `${batch.activeRuns} running`, + detail: `${name} currently has ${batch.activeRuns} active run${batch.activeRuns === 1 ? '' : 's'}.`, + workload, + } + } + return null +} + +function batchSignalRank(signal: BatchSignal): number { + switch (signal.tone) { + case 'rose': + return 5 + case 'amber': + return 4 + case 'sky': + return 3 + case 'emerald': + return 2 + default: + return 1 + } +} + export function workloadClassOf(value?: AppWorkloadClass): AppWorkloadClass { switch (value) { case 'service': diff --git a/packages/k8s-ui/src/utils/resource-hierarchy.ts b/packages/k8s-ui/src/utils/resource-hierarchy.ts index e5618b45d..4c94febd2 100644 --- a/packages/k8s-ui/src/utils/resource-hierarchy.ts +++ b/packages/k8s-ui/src/utils/resource-hierarchy.ts @@ -489,7 +489,7 @@ export function buildResourceHierarchy(options: HierarchyOptions): ResourceLane[ Service: 1, Ingress: 2, Gateway: 2, HTTPRoute: 2, GRPCRoute: 2, TCPRoute: 2, TLSRoute: 2, Deployment: 3, Rollout: 3, StatefulSet: 3, DaemonSet: 3, - Job: 4, CronJob: 4, + Job: 4, CronJob: 4, Workflow: 4, CronWorkflow: 4, ConfigMap: 5, Secret: 5, ReplicaSet: 6, Pod: 7, KnativeService: 1, KnativeRoute: 2, Broker: 2, Channel: 2, diff --git a/packages/k8s-ui/src/utils/resource-icons.ts b/packages/k8s-ui/src/utils/resource-icons.ts index c38124903..4ce048638 100644 --- a/packages/k8s-ui/src/utils/resource-icons.ts +++ b/packages/k8s-ui/src/utils/resource-icons.ts @@ -129,6 +129,7 @@ const KIND_ICON_MAP: Record = { // Argo workflow: Activity, + cronworkflow: Activity, workflowtemplate: Activity, application: GitBranch, // ArgoCD Application applicationset: GitBranch, // ArgoCD ApplicationSet diff --git a/pkg/topology/builder.go b/pkg/topology/builder.go index c0c9ebee3..9d6ad4b1a 100644 --- a/pkg/topology/builder.go +++ b/pkg/topology/builder.go @@ -271,6 +271,9 @@ func (b *Builder) buildResourcesTopology(opts BuildOptions) (*Topology, error) { jobIDs := make(map[string]string) cronJobIDs := make(map[string]string) jobToCronJob := make(map[string]string) // jobKey -> cronJobID (for shortcut edges) + workflowIDs := make(map[string]string) + cronWorkflowIDs := make(map[string]string) + workflowToCronWorkflow := make(map[string]string) // workflowKey -> cronWorkflowID (for shortcut edges) // Track ConfigMap/Secret/PVC references from workloads // Maps workloadID -> set of resource names @@ -2546,6 +2549,96 @@ func (b *Builder) buildResourcesTopology(opts BuildOptions) (*Topology, error) { } } + // 4b. Add Argo Workflow/CronWorkflow nodes + var cronWorkflowGVR schema.GroupVersionResource + hasCronWorkflows := false + var workflowGVR schema.GroupVersionResource + hasWorkflows := false + if resourceDiscovery != nil { + cronWorkflowGVR, hasCronWorkflows = resourceDiscovery.GetGVRWithGroup("CronWorkflow", "argoproj.io") + workflowGVR, hasWorkflows = resourceDiscovery.GetGVRWithGroup("Workflow", "argoproj.io") + } + if hasCronWorkflows && dynamicCache != nil { + cronWorkflows, err := dynamicCache.ListNamespaces(cronWorkflowGVR, opts.Namespaces) + if err != nil { + log.Printf("WARNING [topology] Failed to list Argo CronWorkflows: %v", err) + warnings = append(warnings, fmt.Sprintf("Failed to list Argo CronWorkflows: %v", err)) + } + for _, cwf := range cronWorkflows { + ns := cwf.GetNamespace() + if !opts.MatchesNamespaceFilter(ns) { + continue + } + name := cwf.GetName() + cwfID := fmt.Sprintf("cronworkflow/%s/%s", ns, name) + cronWorkflowIDs[ns+"/"+name] = cwfID + suspended, _, _ := unstructured.NestedBool(cwf.Object, "spec", "suspend") + lastScheduled, _, _ := unstructured.NestedString(cwf.Object, "status", "lastScheduledTime") + nodes = append(nodes, Node{ + ID: cwfID, + Kind: KindCronWorkflow, + Name: name, + Status: cronWorkflowTopologyStatus(cwf), + Data: map[string]any{ + "namespace": ns, + "schedule": cronWorkflowScheduleString(cwf), + "suspend": suspended, + "lastScheduledTime": lastScheduled, + "labels": cwf.GetLabels(), + "apiVersion": cwf.GetAPIVersion(), + }, + }) + } + } + if hasWorkflows && dynamicCache != nil { + workflows, err := dynamicCache.ListNamespaces(workflowGVR, opts.Namespaces) + if err != nil { + log.Printf("WARNING [topology] Failed to list Argo Workflows: %v", err) + warnings = append(warnings, fmt.Sprintf("Failed to list Argo Workflows: %v", err)) + } + for _, wf := range workflows { + ns := wf.GetNamespace() + if !opts.MatchesNamespaceFilter(ns) { + continue + } + name := wf.GetName() + wfID := fmt.Sprintf("workflow/%s/%s", ns, name) + workflowIDs[ns+"/"+name] = wfID + phase, _, _ := unstructured.NestedString(wf.Object, "status", "phase") + progress, _, _ := unstructured.NestedString(wf.Object, "status", "progress") + startedAt, _, _ := unstructured.NestedString(wf.Object, "status", "startedAt") + finishedAt, _, _ := unstructured.NestedString(wf.Object, "status", "finishedAt") + template, _, _ := unstructured.NestedString(wf.Object, "spec", "workflowTemplateRef", "name") + nodes = append(nodes, Node{ + ID: wfID, + Kind: KindWorkflow, + Name: name, + Status: workflowTopologyStatus(phase), + Data: map[string]any{ + "namespace": ns, + "phase": phase, + "progress": progress, + "startedAt": startedAt, + "finishedAt": finishedAt, + "template": template, + "labels": wf.GetLabels(), + "apiVersion": wf.GetAPIVersion(), + }, + }) + if owner := wf.GetLabels()["workflows.argoproj.io/cron-workflow"]; owner != "" { + if cwfID, ok := cronWorkflowIDs[ns+"/"+owner]; ok { + edges = append(edges, Edge{ + ID: fmt.Sprintf("%s-to-%s", cwfID, wfID), + Source: cwfID, + Target: wfID, + Type: EdgeManages, + }) + workflowToCronWorkflow[ns+"/"+name] = cwfID + } + } + } + } + // 5. Add Job nodes var jobs []*batchv1.Job { @@ -2730,7 +2823,7 @@ func (b *Builder) buildResourcesTopology(opts BuildOptions) (*Topology, error) { if !opts.MatchesNamespaceFilter(pod.Namespace) { continue } - workloadID := b.resolvePodWorkloadID(pod, existingNodeIDs, replicaSetToDeployment, replicaSetToRollout, jobIDs) + workloadID := b.resolvePodWorkloadID(pod, existingNodeIDs, replicaSetToDeployment, replicaSetToRollout, jobIDs, workflowIDs) if workloadID == "" { addPodHealth(orphanByNS, pod.Namespace, pod) orphanRestarts[pod.Namespace] += ComputePodRestarts(pod) @@ -2762,7 +2855,7 @@ func (b *Builder) buildResourcesTopology(opts BuildOptions) (*Topology, error) { nodes = append(nodes, CreatePodNode(pod, b.provider, true)) // includeNodeName=true for resources view // Connect to owner (resources view specific) - edges = append(edges, b.createPodOwnerEdges(pod, podID, opts, replicaSetIDs, replicaSetToDeployment, replicaSetToRollout, jobIDs, jobToCronJob)...) + edges = append(edges, b.createPodOwnerEdges(pod, podID, opts, replicaSetIDs, replicaSetToDeployment, replicaSetToRollout, jobIDs, jobToCronJob, workflowIDs, workflowToCronWorkflow)...) } } else { // Large group - create PodGroup @@ -2771,7 +2864,7 @@ func (b *Builder) buildResourcesTopology(opts BuildOptions) (*Topology, error) { // Connect to owner using first pod's owner (resources view specific) firstPod := group.Pods[0] - edges = append(edges, b.createPodOwnerEdges(firstPod, podGroupID, opts, replicaSetIDs, replicaSetToDeployment, replicaSetToRollout, jobIDs, jobToCronJob)...) + edges = append(edges, b.createPodOwnerEdges(firstPod, podGroupID, opts, replicaSetIDs, replicaSetToDeployment, replicaSetToRollout, jobIDs, jobToCronJob, workflowIDs, workflowToCronWorkflow)...) } } } @@ -6793,7 +6886,13 @@ func (b *Builder) resolvePodWorkloadID( replicaSetToDeployment map[string]string, replicaSetToRollout map[string]string, jobIDs map[string]string, + workflowIDs map[string]string, ) string { + if workflowName := pod.Labels["workflows.argoproj.io/workflow"]; workflowName != "" { + if workflowID, ok := workflowIDs[pod.Namespace+"/"+workflowName]; ok { + return workflowID + } + } for _, ownerRef := range pod.OwnerReferences { if ownerRef.Controller == nil || !*ownerRef.Controller { continue @@ -6890,8 +6989,31 @@ func (b *Builder) createPodOwnerEdges( replicaSetToRollout map[string]string, jobIDs map[string]string, jobToCronJob map[string]string, + workflowIDs map[string]string, + workflowToCronWorkflow map[string]string, ) []Edge { var edges []Edge + if workflowName := pod.Labels["workflows.argoproj.io/workflow"]; workflowName != "" { + workflowKey := pod.Namespace + "/" + workflowName + if ownerID, ok := workflowIDs[workflowKey]; ok { + edges = append(edges, Edge{ + ID: fmt.Sprintf("%s-to-%s", ownerID, targetID), + Source: ownerID, + Target: targetID, + Type: EdgeManages, + }) + if cronWorkflowID, ok := workflowToCronWorkflow[workflowKey]; ok { + edges = append(edges, Edge{ + ID: fmt.Sprintf("%s-to-%s-shortcut", cronWorkflowID, targetID), + Source: cronWorkflowID, + Target: targetID, + Type: EdgeManages, + SkipIfKindVisible: string(KindWorkflow), + }) + } + return edges + } + } for _, ownerRef := range pod.OwnerReferences { ownerKey := pod.Namespace + "/" + ownerRef.Name @@ -6991,6 +7113,45 @@ func getJobStatus(job *batchv1.Job) HealthStatus { return healthLevelToStatus(health.Workload(job, time.Now()).Level) } +func workflowTopologyStatus(phase string) HealthStatus { + switch phase { + case "Succeeded": + return StatusHealthy + case "Running": + return StatusNeutral + case "Failed", "Error": + return StatusUnhealthy + case "Pending": + return StatusDegraded + default: + return StatusUnknown + } +} + +func cronWorkflowTopologyStatus(cwf *unstructured.Unstructured) HealthStatus { + suspended, _, _ := unstructured.NestedBool(cwf.Object, "spec", "suspend") + if suspended { + return StatusNeutral + } + active, found, _ := unstructured.NestedSlice(cwf.Object, "status", "active") + if found && len(active) > 0 { + return StatusNeutral + } + if lastScheduled, _, _ := unstructured.NestedString(cwf.Object, "status", "lastScheduledTime"); lastScheduled != "" { + return StatusNeutral + } + return StatusUnknown +} + +func cronWorkflowScheduleString(cwf *unstructured.Unstructured) string { + schedules, found, _ := unstructured.NestedStringSlice(cwf.Object, "spec", "schedules") + if found && len(schedules) > 0 { + return strings.Join(schedules, ", ") + } + schedule, _, _ := unstructured.NestedString(cwf.Object, "spec", "schedule") + return schedule +} + func getPVCStatus(pvc *corev1.PersistentVolumeClaim) HealthStatus { return healthLevelToStatus(health.Workload(pvc, time.Now()).Level) } @@ -7686,6 +7847,8 @@ func (b *Builder) addGenericCRDNodes(nodes []Node, edges []Edge, opts BuildOptio "replicaset": true, "pod": true, "service": true, "ingress": true, "job": true, "cronjob": true, "configmap": true, "secret": true, "persistentvolumeclaim": true, "horizontalpodautoscaler": true, + // Argo Workflows handled explicitly above + "workflow": true, "cronworkflow": true, // Also skip namespace (not typically owned) "namespace": true, } diff --git a/pkg/topology/neighborhood.go b/pkg/topology/neighborhood.go index f7339a3ff..99e8c0d82 100644 --- a/pkg/topology/neighborhood.go +++ b/pkg/topology/neighborhood.go @@ -375,7 +375,7 @@ func edgeTypesForAuto(rootKind NodeKind) map[EdgeType]bool { switch rootKind { // Workloads / pods: management chain + network exposure + protection. case KindPod, KindPodGroup, KindDeployment, KindStatefulSet, KindDaemonSet, - KindReplicaSet, KindRollout, KindJob, KindCronJob, + KindReplicaSet, KindRollout, KindJob, KindCronJob, KindWorkflow, KindCronWorkflow, KindKnativeService, KindKnativeRevision, KindKnativeConfiguration: return map[EdgeType]bool{ EdgeManages: true, diff --git a/pkg/topology/types.go b/pkg/topology/types.go index df312439b..39959f99e 100644 --- a/pkg/topology/types.go +++ b/pkg/topology/types.go @@ -94,6 +94,8 @@ const ( KindHPA NodeKind = "HorizontalPodAutoscaler" KindJob NodeKind = "Job" KindCronJob NodeKind = "CronJob" + KindWorkflow NodeKind = "Workflow" + KindCronWorkflow NodeKind = "CronWorkflow" KindPVC NodeKind = "PersistentVolumeClaim" KindPV NodeKind = "PersistentVolume" KindStorageClass NodeKind = "StorageClass" diff --git a/web/src/api/client.ts b/web/src/api/client.ts index d63330297..c660a596c 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -3559,6 +3559,23 @@ export interface WorkloadRun { finishedAt?: string scheduledAt?: string message?: string + succeeded?: number + failed?: number + running?: number + desired?: number + parallelism?: number + progress?: string + template?: string + podTotal?: number + podSucceeded?: number + podFailed?: number + podRunning?: number + podPending?: number + stepTotal?: number + stepSucceeded?: number + stepFailed?: number + stepRunning?: number + stepSkipped?: number } export interface WorkloadRunsResponse { diff --git a/web/src/components/execution/BatchExecutionView.tsx b/web/src/components/execution/BatchExecutionView.tsx new file mode 100644 index 000000000..251d64c1f --- /dev/null +++ b/web/src/components/execution/BatchExecutionView.tsx @@ -0,0 +1,676 @@ +import { useEffect, useMemo, useState } from 'react' +import { AlertTriangle, Loader2, Terminal } from 'lucide-react' +import { clsx } from 'clsx' +import { EmptyState, FetchResult, StatusDot, mapHealthToTone } from '@skyhook-io/k8s-ui' +import { useWorkloadRuns, type WorkloadRun } from '../../api/client' +import { WorkloadLogsViewer } from '../logs/WorkloadLogsViewer' + +const EMPTY_RUNS: WorkloadRun[] = [] +const SCHEDULED_KINDS = new Set(['CronJob', 'CronWorkflow']) +const RUN_KIND_LABEL: Record = { + jobs: 'Job', + workflows: 'Workflow', +} + +interface BatchExecutionProps { + kind: string + apiKind: string + namespace: string + name: string + resource: any +} + +export function BatchDrawerExecutionSummary({ kind, apiKind, namespace, name, resource }: BatchExecutionProps) { + const runsQuery = useWorkloadRuns(apiKind, namespace, name, SCHEDULED_KINDS.has(kind)) + const runs = scheduledRunsFor(kind, runsQuery.data?.runs, resource, apiKind, namespace, name) + const current = pickDefaultRun(runs) + const source = sourceFacts(kind, resource, runs) + const issue = batchIssue(kind, resource, runs) + + return ( +
+ {issue && ( +
+
+ + {issue.message} +
+
+ )} +
+ + + {source.schedule && } + {source.concurrency && } + {current?.progress && } + {current && } +
+ {runsQuery.isLoading ? ( +
+ + Loading retained runs +
+ ) : SCHEDULED_KINDS.has(kind) && runs.length === 0 ? ( +
+ No retained runs are present in Kubernetes. History may not exist yet, or old Jobs/Workflows may have been garbage-collected. +
+ ) : current ? ( + + ) : null} +
+ ) +} + +export function BatchExecutionFullscreen({ kind, apiKind, namespace, name, resource }: BatchExecutionProps) { + const scheduled = SCHEDULED_KINDS.has(kind) + const runsQuery = useWorkloadRuns(apiKind, namespace, name, scheduled) + const runs = scheduledRunsFor(kind, runsQuery.data?.runs, resource, apiKind, namespace, name) + const defaultRun = useMemo(() => pickDefaultRun(runs), [runs]) + const [selectedRunName, setSelectedRunName] = useState('') + + useEffect(() => { + if (runs.length === 0) { + setSelectedRunName('') + return + } + if (!runs.some((run) => run.name === selectedRunName)) { + setSelectedRunName(defaultRun?.name ?? runs[0].name) + } + }, [runs, selectedRunName, defaultRun]) + + const selectedRun = runs.find((run) => run.name === selectedRunName) ?? defaultRun + const source = sourceFacts(kind, resource, runs) + const issue = batchIssue(kind, resource, runs) + const phaseCounts = countPhases(runs) + + if (runsQuery.isLoading && scheduled) { + return + } + + return ( +
+ + +
+
+
+
+ {issue && ( +
+
+ +
+
{issue.title}
+
{issue.message}
+
+
+
+ )} + +
+ + + + +
+ + {selectedRun ? ( +
+
+
+
+ +

{selectedRun.name}

+
+
+ {RUN_KIND_LABEL[selectedRun.kind] ?? selectedRun.kind} + {selectedRun.startedAt && started {formatAge(selectedRun.startedAt)}} + {selectedRun.scheduledAt && scheduled {formatAge(selectedRun.scheduledAt)}} + {selectedRun.template && template {selectedRun.template}} +
+
+ {selectedRun.phase} +
+
+ +
+

Retention

+

+ Radar OSS reads live Kubernetes objects and pod logs. If this run's pods have been removed by TTL or controller history limits, Radar can show the retained run object but not reconstruct deleted logs. +

+
+
+
+ ) : ( + + )} +
+ +
+
+

Source

+

{kind} · {namespace || 'cluster'}/{name}

+
+
+ +
+
+
+ + {selectedRun && ( +
+
+
+ + Logs +
+ {selectedRun.namespace}/{selectedRun.name} +
+
+ +
+
+ )} +
+
+
+ ) +} + +function scheduledRunsFor(kind: string, runs: WorkloadRun[] | undefined, resource: any, apiKind: string, namespace: string, name: string): WorkloadRun[] { + if (SCHEDULED_KINDS.has(kind)) return runs ?? EMPTY_RUNS + const run = runFromResource(kind, resource, apiKind, namespace, name) + return run ? [run] : EMPTY_RUNS +} + +function runFromResource(kind: string, resource: any, apiKind: string, namespace: string, name: string): WorkloadRun | null { + if (!resource) return null + if (kind === 'Job') { + const status = resource.status ?? {} + const spec = resource.spec ?? {} + const phase = jobPhase(resource) + const desired = Number(spec.completions ?? 1) + return { + kind: apiKind, + namespace, + name, + phase, + active: phase === 'Running' || phase === 'Pending', + startedAt: status.startTime, + finishedAt: status.completionTime, + message: conditionMessage(status.conditions), + succeeded: Number(status.succeeded ?? 0), + failed: Number(status.failed ?? 0), + running: Number(status.active ?? 0), + desired, + parallelism: Number(spec.parallelism ?? 1), + progress: `${Number(status.succeeded ?? 0)}/${desired}`, + podSucceeded: Number(status.succeeded ?? 0), + podFailed: Number(status.failed ?? 0), + podRunning: Number(status.active ?? 0), + podTotal: Number(status.succeeded ?? 0) + Number(status.failed ?? 0) + Number(status.active ?? 0), + } + } + if (kind === 'Workflow') { + const status = resource.status ?? {} + const run: WorkloadRun = { + kind: apiKind, + namespace, + name, + phase: status.phase || 'Pending', + active: status.phase === 'Running' || status.phase === 'Pending', + startedAt: status.startedAt, + finishedAt: status.finishedAt, + message: status.message, + progress: status.progress, + template: resource.spec?.workflowTemplateRef?.name, + } + applyWorkflowCounts(run, resource) + return run + } + return null +} + +export function pickDefaultRun(runs: WorkloadRun[]): WorkloadRun | undefined { + return runs.find((run) => run.active) ?? newestRun(runs) +} + +function newestRun(runs: WorkloadRun[]): WorkloadRun | undefined { + return [...runs].sort((a, b) => runTime(b) - runTime(a))[0] +} + +function runTime(run: WorkloadRun): number { + for (const value of [run.startedAt, run.scheduledAt, run.finishedAt]) { + if (!value) continue + const t = Date.parse(value) + if (!Number.isNaN(t)) return t + } + return 0 +} + +function jobPhase(job: any): string { + if (!job) return 'Pending' + const conditions = job.status?.conditions ?? [] + if ((job.status?.active ?? 0) > 0) return 'Running' + if (conditions.some((c: any) => c.type === 'Complete' && c.status === 'True')) return 'Succeeded' + if (conditions.some((c: any) => c.type === 'Failed' && c.status === 'True')) return 'Failed' + if ((job.status?.succeeded ?? 0) > 0) return 'Succeeded' + if ((job.status?.failed ?? 0) > 0) return 'Failed' + return 'Pending' +} + +function conditionMessage(conditions: any[] | undefined): string | undefined { + const c = conditions?.find((cond) => (cond.type === 'Failed' || cond.type === 'Complete') && cond.status === 'True') + return c?.message || c?.reason +} + +function applyWorkflowCounts(run: WorkloadRun, workflow: any) { + const nodes = workflow.status?.nodes ?? {} + for (const node of Object.values(nodes) as any[]) { + const phase = node.phase + if (node.type === 'Pod') { + run.podTotal = (run.podTotal ?? 0) + 1 + if (phase === 'Succeeded') run.podSucceeded = (run.podSucceeded ?? 0) + 1 + else if (phase === 'Failed' || phase === 'Error') run.podFailed = (run.podFailed ?? 0) + 1 + else if (phase === 'Running') run.podRunning = (run.podRunning ?? 0) + 1 + else if (phase === 'Pending') run.podPending = (run.podPending ?? 0) + 1 + } + if (['Pod', 'Steps', 'StepGroup', 'DAG', 'TaskGroup', 'Suspend', 'Skipped'].includes(node.type)) { + run.stepTotal = (run.stepTotal ?? 0) + 1 + if (phase === 'Succeeded') run.stepSucceeded = (run.stepSucceeded ?? 0) + 1 + else if (phase === 'Failed' || phase === 'Error') run.stepFailed = (run.stepFailed ?? 0) + 1 + else if (phase === 'Running') run.stepRunning = (run.stepRunning ?? 0) + 1 + else if (phase === 'Skipped' || phase === 'Omitted') run.stepSkipped = (run.stepSkipped ?? 0) + 1 + } + } +} + +function sourceFacts(kind: string, resource: any, runs: WorkloadRun[]) { + const spec = resource?.spec ?? {} + const status = resource?.status ?? {} + const latest = newestRun(runs) + if (kind === 'CronJob') { + return { + state: spec.suspend ? 'Suspended' : latest?.phase ?? 'Idle', + stateTone: spec.suspend ? 'warning' : latest ? phaseTone(latest.phase) : 'info', + schedule: spec.schedule, + concurrency: spec.concurrencyPolicy || 'Allow', + progress: latest?.progress, + duration: latest ? formatRunDuration(latest) : '', + work: `${status.active?.length ?? 0} active`, + facts: [ + ['Last schedule', status.lastScheduleTime ? formatAge(status.lastScheduleTime) : 'Never'], + ['Last success', status.lastSuccessfulTime ? formatAge(status.lastSuccessfulTime) : 'Never'], + ['Starting deadline', spec.startingDeadlineSeconds ? `${spec.startingDeadlineSeconds}s` : 'None'], + ['Retention limits', `${spec.successfulJobsHistoryLimit ?? 3} successful / ${spec.failedJobsHistoryLimit ?? 1} failed`], + ], + } + } + if (kind === 'CronWorkflow') { + const schedules = Array.isArray(spec.schedules) ? spec.schedules.join(', ') : spec.schedule + const template = spec.workflowSpec?.workflowTemplateRef?.name || spec.workflowSpec?.entrypoint + return { + state: spec.suspend ? 'Suspended' : latest?.phase ?? 'Idle', + stateTone: spec.suspend ? 'warning' : latest ? phaseTone(latest.phase) : 'info', + schedule: schedules, + concurrency: spec.concurrencyPolicy || 'Allow', + progress: latest?.progress, + duration: latest ? formatRunDuration(latest) : '', + work: `${runs.filter((run) => run.active).length} active`, + facts: [ + ['Timezone', spec.timezone || 'Cluster default'], + ['Template', template || '-'], + ['Starting deadline', spec.startingDeadlineSeconds ? `${spec.startingDeadlineSeconds}s` : 'None'], + ['Retention limits', `${spec.successfulJobsHistoryLimit ?? 3} successful / ${spec.failedJobsHistoryLimit ?? 1} failed`], + ], + } + } + if (kind === 'Job') { + return { + state: jobPhase(resource), + stateTone: phaseTone(jobPhase(resource)), + progress: latest?.progress, + duration: latest ? formatRunDuration(latest) : '', + work: latest ? workCount(latest) : '', + facts: [ + ['Parallelism', String(spec.parallelism ?? 1)], + ['Completions', String(spec.completions ?? 1)], + ['Completion mode', spec.completionMode || 'NonIndexed'], + ['Backoff limit', String(spec.backoffLimit ?? 6)], + ['TTL after finish', spec.ttlSecondsAfterFinished != null ? `${spec.ttlSecondsAfterFinished}s` : 'None'], + ], + } + } + return { + state: status.phase || 'Pending', + stateTone: phaseTone(status.phase || 'Pending'), + progress: status.progress, + duration: latest ? formatRunDuration(latest) : '', + work: latest ? workCount(latest) : '', + facts: [ + ['Entrypoint', spec.entrypoint || '-'], + ['Template', spec.workflowTemplateRef?.name || '-'], + ['Priority', spec.priority != null ? String(spec.priority) : '-'], + ['Service account', spec.serviceAccountName || '-'], + ], + } +} + +function batchIssue(kind: string, resource: any, runs: WorkloadRun[]): { tone: 'error' | 'warning'; title: string; message: string } | null { + const failedRuns = runs.filter((run) => run.phase === 'Failed' || run.phase === 'Error') + const latest = newestRun(runs) + if (latest && (latest.phase === 'Failed' || latest.phase === 'Error')) { + return { tone: 'error', title: 'Latest run failed', message: latest.message || `${RUN_KIND_LABEL[latest.kind] ?? latest.kind} ${latest.name} ended ${latest.phase}.` } + } + if (failedRuns.length >= 2) { + return { tone: 'warning', title: 'Recent failures retained', message: `${failedRuns.length} retained runs failed. The latest run is ${latest?.phase ?? 'unknown'}.` } + } + if (kind === 'CronJob' || kind === 'CronWorkflow') { + if (resource?.spec?.suspend === true) { + return { tone: 'warning', title: 'Schedule suspended', message: 'No new runs will be created until the schedule is resumed.' } + } + if (runs.length === 0) { + return { tone: 'warning', title: 'No retained runs', message: 'This schedule has no Jobs or Workflows currently retained in Kubernetes.' } + } + } + return null +} + +function SourceFacts({ source }: { source: ReturnType }) { + return ( + <> +
+ {source.schedule && } + {source.concurrency && } +
+
+ {source.facts.map(([label, value]) => ( +
+ {label} + {value} +
+ ))} +
+ + ) +} + +function RunDetailList({ run }: { run: WorkloadRun }) { + const rows = [ + ['Started', run.startedAt ? formatAge(run.startedAt) : '-'], + ['Finished', run.finishedAt ? formatAge(run.finishedAt) : run.active ? 'Running' : '-'], + ['Scheduled', run.scheduledAt ? formatAge(run.scheduledAt) : '-'], + ['Parallelism', run.parallelism ? String(run.parallelism) : '-'], + ['Pods', podBreakdown(run) || '-'], + ['Steps', stepBreakdown(run) || '-'], + ['Message', run.message || '-'], + ] + return ( +
+ {rows.map(([label, value]) => ( +
+ {label} + {value} +
+ ))} +
+ ) +} + +function RunDigest({ run, compact }: { run: WorkloadRun; compact?: boolean }) { + return ( +
+
+
+
{run.name}
+
+ {formatRunDuration(run) || 'duration unknown'} + {workCount(run)} +
+
+ {run.phase} +
+ {!compact && run.message &&
{run.message}
} +
+ ) +} + +function RunRailButton({ run, selected, onClick }: { run: WorkloadRun; selected: boolean; onClick: () => void }) { + return ( + + ) +} + +function FactTile({ label, value, tone, mono }: { label: string; value: string | number; tone?: string; mono?: boolean }) { + return ( +
+
{label}
+
{value}
+
+ ) +} + +function phaseBadgeClass(phase: string): string { + switch (phase) { + case 'Succeeded': + case 'Complete': + return 'status-healthy' + case 'Running': + return 'status-neutral' + case 'Failed': + case 'Error': + return 'status-unhealthy' + case 'Pending': + case 'Suspended': + return 'status-degraded' + default: + return 'status-unknown' + } +} + +function phaseTone(phase: string): string { + switch (phase) { + case 'Succeeded': + case 'Complete': + return 'success' + case 'Running': + case 'Idle': + return 'info' + case 'Failed': + case 'Error': + return 'error' + case 'Pending': + case 'Suspended': + return 'warning' + default: + return 'neutral' + } +} + +function phaseTextClass(tone: string): string { + switch (tone) { + case 'success': + return 'text-emerald-600 dark:text-emerald-400' + case 'warning': + return 'text-amber-600 dark:text-amber-400' + case 'error': + return 'text-red-600 dark:text-red-400' + case 'info': + return 'text-sky-600 dark:text-sky-400' + default: + return '' + } +} + +function phaseHealth(phase: string): 'healthy' | 'degraded' | 'unhealthy' | 'neutral' | 'unknown' { + switch (phase) { + case 'Succeeded': + case 'Complete': + return 'healthy' + case 'Running': + return 'neutral' + case 'Failed': + case 'Error': + return 'unhealthy' + case 'Pending': + case 'Suspended': + return 'degraded' + default: + return 'unknown' + } +} + +function shortPhase(phase: string): string { + if (phase === 'Succeeded') return 'OK' + if (phase === 'Running') return 'Run' + if (phase === 'Pending') return 'Pend' + return phase +} + +function countPhases(runs: WorkloadRun[]) { + return { + running: runs.filter((run) => run.active).length, + failed: runs.filter((run) => run.phase === 'Failed' || run.phase === 'Error').length, + succeeded: runs.filter((run) => run.phase === 'Succeeded').length, + } +} + +function workCount(run: WorkloadRun): string { + if (run.stepTotal) return `${run.stepSucceeded ?? 0}/${run.stepTotal} steps` + if (run.podTotal) { + if (run.podRunning) return `${run.podRunning} running ${pluralize('pod', run.podRunning)}` + if (run.podPending && !run.podSucceeded && !run.podFailed) return `${run.podPending} pending ${pluralize('pod', run.podPending)}` + if (run.podFailed && run.phase !== 'Succeeded') return `${run.podFailed}/${run.podTotal} pods failed` + return `${run.podSucceeded ?? 0}/${run.podTotal} pods` + } + if (run.desired) return `${run.succeeded ?? 0}/${run.desired} completions` + return 'work unknown' +} + +function podBreakdown(run: WorkloadRun): string | null { + if (!run.podTotal) return null + const parts = [ + run.podRunning ? `${run.podRunning} running` : '', + run.podSucceeded ? `${run.podSucceeded} succeeded` : '', + run.podFailed ? `${run.podFailed} failed` : '', + run.podPending ? `${run.podPending} pending` : '', + ].filter(Boolean) + return parts.join(' · ') || `${run.podTotal} total` +} + +function stepBreakdown(run: WorkloadRun): string | null { + if (!run.stepTotal) return null + const parts = [ + run.stepRunning ? `${run.stepRunning} running` : '', + run.stepSucceeded ? `${run.stepSucceeded} succeeded` : '', + run.stepFailed ? `${run.stepFailed} failed` : '', + run.stepSkipped ? `${run.stepSkipped} skipped` : '', + ].filter(Boolean) + return parts.join(' · ') || `${run.stepTotal} total` +} + +function formatRunDuration(run: WorkloadRun): string { + if (!run.startedAt) return '' + const start = Date.parse(run.startedAt) + const end = run.finishedAt ? Date.parse(run.finishedAt) : Date.now() + if (Number.isNaN(start) || Number.isNaN(end) || end < start) return '' + return formatDuration(end - start) +} + +function formatRunTime(run: WorkloadRun): string { + const raw = run.startedAt || run.scheduledAt || run.finishedAt + return raw ? formatAge(raw) : '' +} + +function formatAge(value: string): string { + const t = Date.parse(value) + if (Number.isNaN(t)) return value + const diff = Date.now() - t + if (diff < 60_000) return 'just now' + return `${formatDuration(diff)} ago` +} + +function formatDuration(ms: number): string { + const seconds = Math.max(0, Math.floor(ms / 1000)) + if (seconds < 60) return `${seconds}s` + const minutes = Math.floor(seconds / 60) + if (minutes < 60) return `${minutes}m ${seconds % 60}s` + const hours = Math.floor(minutes / 60) + if (hours < 48) return `${hours}h ${minutes % 60}m` + const days = Math.floor(hours / 24) + return `${days}d ${hours % 24}h` +} + +function pluralizeRuns(count: number): string { + return count === 1 ? '1 retained run' : `${count} retained runs` +} + +function pluralize(word: string, count: number): string { + return count === 1 ? word : `${word}s` +} diff --git a/web/src/components/logs/ScheduledWorkloadLogsViewer.tsx b/web/src/components/logs/ScheduledWorkloadLogsViewer.tsx index 266865fcd..e14e6297e 100644 --- a/web/src/components/logs/ScheduledWorkloadLogsViewer.tsx +++ b/web/src/components/logs/ScheduledWorkloadLogsViewer.tsx @@ -3,6 +3,7 @@ import { Loader2, Terminal } from 'lucide-react' import { clsx } from 'clsx' import { useWorkloadRuns, type WorkloadRun } from '../../api/client' import { WorkloadLogsViewer } from './WorkloadLogsViewer' +import { pickDefaultRun } from '../execution/BatchExecutionView' interface ScheduledWorkloadLogsViewerProps { kind: string @@ -71,7 +72,7 @@ export function ScheduledWorkloadLogsViewer({ kind, namespace, name }: Scheduled > {runs.map(run => ( ))} @@ -96,12 +97,6 @@ export function ScheduledWorkloadLogsViewer({ kind, namespace, name }: Scheduled ) } -function pickDefaultRun(runs: WorkloadRun[]): WorkloadRun | undefined { - return runs.find(run => run.active) - ?? runs.find(run => run.phase === 'Failed' || run.phase === 'Error') - ?? runs[0] -} - function phaseBadgeClass(phase: string): string { switch (phase) { case 'Succeeded': @@ -123,3 +118,12 @@ function formatRunTime(run: WorkloadRun): string { if (!raw) return '' return new Date(raw).toLocaleString() } + +function formatRunOption(run: WorkloadRun): string { + const bits = [run.name, run.phase] + if (run.progress) bits.push(run.progress) + else if (run.desired) bits.push(`${run.succeeded ?? 0}/${run.desired}`) + const work = run.stepTotal ? `${run.stepSucceeded ?? 0}/${run.stepTotal} steps` : run.podTotal ? `${run.podSucceeded ?? 0}/${run.podTotal} pods` : '' + if (work) bits.push(work) + return bits.join(' · ') +} diff --git a/web/src/components/resources/renderers/CronWorkflowRenderer.tsx b/web/src/components/resources/renderers/CronWorkflowRenderer.tsx new file mode 100644 index 000000000..d18e7ec91 --- /dev/null +++ b/web/src/components/resources/renderers/CronWorkflowRenderer.tsx @@ -0,0 +1 @@ +export * from '@skyhook-io/k8s-ui/components/resources/renderers/CronWorkflowRenderer' diff --git a/web/src/components/resources/renderers/index.ts b/web/src/components/resources/renderers/index.ts index 108eb47f9..d9620dd5c 100644 --- a/web/src/components/resources/renderers/index.ts +++ b/web/src/components/resources/renderers/index.ts @@ -7,6 +7,7 @@ export { ConfigMapRenderer } from './ConfigMapRenderer' export { SecretRenderer } from './SecretRenderer' export { JobRenderer } from './JobRenderer' export { CronJobRenderer } from './CronJobRenderer' +export { CronWorkflowRenderer } from './CronWorkflowRenderer' export { HPARenderer } from './HPARenderer' export { NodeRenderer } from './NodeRenderer' export { PVCRenderer } from './PVCRenderer' diff --git a/web/src/components/workload/WorkloadView.tsx b/web/src/components/workload/WorkloadView.tsx index 4e2b9497b..f7f42c37b 100644 --- a/web/src/components/workload/WorkloadView.tsx +++ b/web/src/components/workload/WorkloadView.tsx @@ -40,6 +40,7 @@ import { AuditAlerts, ResourceIssuesSection } from '@skyhook-io/k8s-ui' import { WorkloadLogsViewer } from '../logs/WorkloadLogsViewer' import { ScheduledWorkloadLogsViewer } from '../logs/ScheduledWorkloadLogsViewer' import { LogsViewer } from '../logs/LogsViewer' +import { BatchDrawerExecutionSummary, BatchExecutionFullscreen } from '../execution/BatchExecutionView' import { useCanUpdateSecrets, useCanNodeWrite, useNamespacedCapabilities, useIsLocalDeployment } from '../../contexts/CapabilitiesContext' import { useOpenTerminal, useOpenLogs, useOpenWorkloadLogs, useOpenNodeTerminal } from '../dock' import { PortForwardButton } from '../portforward/PortForwardButton' @@ -63,6 +64,7 @@ import { useCompareLauncher } from '../compare/useCompareLauncher' import { apiVersionToGroup } from '../../utils/navigation' type TabType = WorkloadTabType +const BATCH_EXECUTION_KINDS = new Set(['Job', 'CronJob', 'Workflow', 'CronWorkflow']) // Stable reference — web renderer wrappers inject platform hooks internally const rendererOverrides: RendererOverrides = { @@ -538,6 +540,11 @@ export function WorkloadView({ onTabChange={handleTabChange} // Render props renderLogsTab={(props) => } + renderExpandedOverview={({ kind: k, apiKind, namespace: ns, name: n, resource: res }) => + BATCH_EXECUTION_KINDS.has(k) && res ? ( + + ) : null + } renderRelatedYaml={(ref) => } renderMetricsTab={({ kind, namespace: ns, name: n }) => ( @@ -550,6 +557,11 @@ export function WorkloadView({ actionsBarProps={actionsBarProps} rendererOverrides={rendererOverrides} resolvedEnvFrom={resolvedEnvFrom} + renderOverviewIntro={({ kind: k, namespace: ns, name: n }) => + BATCH_EXECUTION_KINDS.has(k) ? ( + + ) : null + } renderOverviewExtra={({ kind: k, namespace: ns, name: n }) => ( <> From edc8755689a4223aaf9b7c7c4b25e4988cdfa955 Mon Sep 17 00:00:00 2001 From: Nadav Erell Date: Sun, 5 Jul 2026 12:45:11 +0300 Subject: [PATCH 3/7] Deepen batch execution UX --- internal/k8s/dynamic_cache.go | 2 + .../applications/ApplicationDetail.tsx | 78 +++- .../components/resources/ResourcesView.tsx | 7 + .../renderers/CronWorkflowRenderer.tsx | 19 +- .../resources/renderers/WorkflowRenderer.tsx | 99 ++-- .../shared/ResourceRendererDispatch.tsx | 6 +- .../components/topology/K8sResourceNode.tsx | 10 + .../topology/TopologyFilterSidebar.tsx | 2 + .../k8s-ui/src/components/topology/layout.ts | 6 +- packages/k8s-ui/src/types/core.ts | 4 + .../k8s-ui/src/utils/applications.test.ts | 42 +- packages/k8s-ui/src/utils/applications.ts | 116 +++++ packages/k8s-ui/src/utils/index.ts | 1 + packages/k8s-ui/src/utils/navigation.ts | 1 + packages/k8s-ui/src/utils/resource-icons.ts | 1 + .../src/utils/workflow-execution.test.ts | 102 +++++ .../k8s-ui/src/utils/workflow-execution.ts | 380 ++++++++++++++++ pkg/topology/builder.go | 167 +++++++ pkg/topology/builder_test.go | 66 +++ pkg/topology/cluster_scoped_kinds.go | 2 +- pkg/topology/types.go | 2 + web/src/api/client.ts | 10 +- .../execution/BatchExecutionView.tsx | 421 +++++++++++++++--- web/src/components/workload/WorkloadView.tsx | 2 +- 24 files changed, 1424 insertions(+), 122 deletions(-) create mode 100644 packages/k8s-ui/src/utils/workflow-execution.test.ts create mode 100644 packages/k8s-ui/src/utils/workflow-execution.ts diff --git a/internal/k8s/dynamic_cache.go b/internal/k8s/dynamic_cache.go index e776a40cc..83e69bcb4 100644 --- a/internal/k8s/dynamic_cache.go +++ b/internal/k8s/dynamic_cache.go @@ -162,6 +162,8 @@ var supportedCRDFallbacks = []supportedCRDResource{ {Group: "argoproj.io", Versions: []string{"v1alpha1"}, Resource: "rollouts", Kind: "Rollout", Namespaced: true}, {Group: "argoproj.io", Versions: []string{"v1alpha1"}, Resource: "workflows", Kind: "Workflow", Namespaced: true}, {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: "cert-manager.io", Versions: []string{"v1"}, Resource: "certificates", Kind: "Certificate", Namespaced: true}, {Group: "cert-manager.io", Versions: []string{"v1"}, Resource: "certificaterequests", Kind: "CertificateRequest", Namespaced: true}, {Group: "acme.cert-manager.io", Versions: []string{"v1"}, Resource: "orders", Kind: "Order", Namespaced: true}, diff --git a/packages/k8s-ui/src/components/applications/ApplicationDetail.tsx b/packages/k8s-ui/src/components/applications/ApplicationDetail.tsx index 537821cf3..adee260a2 100644 --- a/packages/k8s-ui/src/components/applications/ApplicationDetail.tsx +++ b/packages/k8s-ui/src/components/applications/ApplicationDetail.tsx @@ -1,5 +1,5 @@ import { useMemo, useState, useCallback, useEffect, useRef, type ReactNode } from 'react' -import { ArrowLeft, Boxes, Network, Layers, ChevronDown } from 'lucide-react' +import { ArrowLeft, Boxes, CalendarClock, Network, Layers, ChevronDown } from 'lucide-react' import { clsx } from 'clsx' import type { Topology, TopologyNode } from '../../types' import { StatusDot, mapHealthToTone } from '../ui/status-tone' @@ -12,6 +12,7 @@ import { workloadHue, NEUTRAL_OWNER, type WorkloadFocus } from '../../utils/work import { type AppRow, type AppWorkload, + type AppBatchActivity, type AppHealth, CHIP, CHIP_TONE, @@ -24,6 +25,7 @@ import { workloadClassOf, classCompositionOf, batchSignalForApp, + batchActivityForApp, worstHealth, appGroupLagMessage, compareVersions, @@ -112,6 +114,71 @@ function ContextFact({ label, children }: { label: string; children: ReactNode } ) } +function BatchActivityPanel({ activity, onSelect, onNavigateToResource }: { activity: AppBatchActivity[]; onSelect: (workload: AppWorkload) => void; onNavigateToResource?: ApplicationDetailProps['onNavigateToResource'] }) { + return ( +
+
+
+ + Batch activity +
+ {activity.length} {activity.length === 1 ? 'batch workload' : 'batch workloads'} +
+
+ {activity.slice(0, 6).map((item) => ( +
+ + {onNavigateToResource && ( + + )} +
+ ))} +
+
+ ) +} + +function batchWorkloadGroup(kind: string): string | undefined { + return kind === 'Workflow' || kind === 'CronWorkflow' ? 'argoproj.io' : undefined +} + +function relativeTime(value: string): string { + const t = Date.parse(value) + if (Number.isNaN(t)) return value + const diff = Date.now() - t + if (diff < 60_000) return 'just now' + const minutes = Math.floor(diff / 60_000) + if (minutes < 60) return `${minutes}m ago` + const hours = Math.floor(minutes / 60) + if (hours < 48) return `${hours}h ago` + return `${Math.floor(hours / 24)}d ago` +} + function collapseIdentityInstances(instances: AppIdentityInstance[], activeKey: string): AppIdentityInstance[] { const byEnv = new Map() for (const inst of instances) { @@ -160,6 +227,7 @@ export function ApplicationDetail({ app, onBack, renderWorkload, topology, topol const desired = workloads.reduce((n, w) => n + (w.desired ?? 0), 0) const restartSignal = restartWarning(workloads) const batchSignal = batchSignalForApp(app) + const batchActivity = useMemo(() => batchActivityForApp(app), [app]) // Resolve namespace the same way the list does (the workloads' shared // namespace) so env/namespace match across list and detail. Multi-namespace // apps get the count, never an arbitrary pick. @@ -330,6 +398,14 @@ export function ApplicationDetail({ app, onBack, renderWorkload, topology, topol )} + {batchActivity.length > 0 && ( + setSelected(workloadKey(workload))} + onNavigateToResource={onNavigateToResource} + /> + )} + {/* Body: for a multi-workload app, a workload rail (navigation + color legend + hover-focus) beside the app graph or the selected workload's runtime. A single-workload app skips straight to its runtime. */} diff --git a/packages/k8s-ui/src/components/resources/ResourcesView.tsx b/packages/k8s-ui/src/components/resources/ResourcesView.tsx index 2ecf79b79..3797bf4ec 100644 --- a/packages/k8s-ui/src/components/resources/ResourcesView.tsx +++ b/packages/k8s-ui/src/components/resources/ResourcesView.tsx @@ -775,6 +775,12 @@ const KNOWN_COLUMNS: Record = { { key: 'templates', label: 'Templates', width: 'w-24' }, { key: 'age', label: 'Age', width: 'w-24' }, ], + clusterworkflowtemplates: [ + { key: 'name', label: 'Name' }, + { key: 'entrypoint', label: 'Entrypoint', width: 'w-36' }, + { key: 'templates', label: 'Templates', width: 'w-24' }, + { key: 'age', label: 'Age', width: 'w-24' }, + ], networkpolicies: [ { key: 'name', label: 'Name' }, { key: 'namespace', label: 'Namespace', width: 'w-48' }, @@ -5265,6 +5271,7 @@ function CellContent({ resource, kind, column, group, majorityNodeMinorVersion, case 'sealedsecrets': return case 'workflowtemplates': + case 'clusterworkflowtemplates': return case 'networkpolicies': return diff --git a/packages/k8s-ui/src/components/resources/renderers/CronWorkflowRenderer.tsx b/packages/k8s-ui/src/components/resources/renderers/CronWorkflowRenderer.tsx index 78b0382ec..9379bf8e6 100644 --- a/packages/k8s-ui/src/components/resources/renderers/CronWorkflowRenderer.tsx +++ b/packages/k8s-ui/src/components/resources/renderers/CronWorkflowRenderer.tsx @@ -4,7 +4,7 @@ import { formatAge, cronToHuman } from '../resource-utils' interface CronWorkflowRendererProps { data: any - onNavigate?: (ref: { kind: string; namespace: string; name: string }) => void + onNavigate?: (ref: { kind: string; namespace: string; name: string; group?: string }) => void } export function CronWorkflowRenderer({ data, onNavigate }: CronWorkflowRendererProps) { @@ -14,7 +14,9 @@ export function CronWorkflowRenderer({ data, onNavigate }: CronWorkflowRendererP const schedules = Array.isArray(spec.schedules) ? spec.schedules : spec.schedule ? [spec.schedule] : [] const isSuspended = spec.suspend === true const active = Array.isArray(status.active) ? status.active : [] - const templateName = workflowSpec.workflowTemplateRef?.name || workflowSpec.workflowTemplateRef?.template || '' + const workflowTemplateRef = workflowSpec.workflowTemplateRef || null + const templateName = workflowTemplateRef?.name || workflowTemplateRef?.template || '' + const templateClusterScope = workflowTemplateRef?.clusterScope === true const hasNeverRun = !status.lastScheduledTime && active.length === 0 return ( @@ -53,7 +55,16 @@ export function CronWorkflowRenderer({ data, onNavigate }: CronWorkflowRendererP {templateName && ( } + value={ + + } /> )} @@ -75,7 +86,7 @@ export function CronWorkflowRenderer({ data, onNavigate }: CronWorkflowRendererP
{active.map((wf: any) => (
- +
))}
diff --git a/packages/k8s-ui/src/components/resources/renderers/WorkflowRenderer.tsx b/packages/k8s-ui/src/components/resources/renderers/WorkflowRenderer.tsx index 9060b1acb..ac24a29c3 100644 --- a/packages/k8s-ui/src/components/resources/renderers/WorkflowRenderer.tsx +++ b/packages/k8s-ui/src/components/resources/renderers/WorkflowRenderer.tsx @@ -1,23 +1,15 @@ import { Play, Clock, CheckCircle, XCircle, Loader2, SkipForward, PauseCircle } from 'lucide-react' import { clsx } from 'clsx' -import { Section, PropertyList, Property, ConditionsSection, AlertBanner } from '../../ui/drawer-components' +import { Section, PropertyList, Property, ConditionsSection, AlertBanner, ResourceLink } from '../../ui/drawer-components' import { formatAge, formatDuration } from '../resource-utils' +import { buildWorkflowExecutionModel, WorkflowExecutionNode, WorkflowTemplateReference } from '../../../utils/workflow-execution' interface WorkflowRendererProps { data: any + onNavigate?: (ref: { kind: string; namespace: string; name: string; group?: string }) => void } -interface WorkflowStep { - id: string - displayName: string - phase: string - startedAt: string | null - finishedAt: string | null - message: string | null - nodeType: string -} - -function getStepDuration(step: WorkflowStep): string | null { +function getStepDuration(step: WorkflowExecutionNode): string | null { if (!step.startedAt) return null const start = new Date(step.startedAt) const end = step.finishedAt ? new Date(step.finishedAt) : new Date() @@ -74,7 +66,7 @@ function formatEstimatedDuration(seconds: number): string { return remainingMinutes > 0 ? `${hours}h ${remainingMinutes}m` : `${hours}h` } -function getWorkflowProblems(data: any): string[] { +function getWorkflowProblems(data: any, steps: WorkflowExecutionNode[]): string[] { const problems: string[] = [] const status = data.status || {} const phase = status.phase @@ -85,10 +77,7 @@ function getWorkflowProblems(data: any): string[] { problems.push(status.message || 'Workflow error') } - // Check for failed nodes — include error messages when available - const nodes = status.nodes || {} - const failedNodes = Object.values(nodes) - .filter((node: any) => (node.type === 'Pod' || node.type === 'Suspend' || node.type === 'Skipped') && (node.phase === 'Failed' || node.phase === 'Error')) + const failedNodes = steps.filter((node) => (node.type === 'Pod' || node.type === 'Suspend' || node.type === 'Skipped') && (node.phase === 'Failed' || node.phase === 'Error')) if (failedNodes.length > 0) { const withMessages = failedNodes.filter((node: any) => node.message) @@ -104,36 +93,12 @@ function getWorkflowProblems(data: any): string[] { return problems } -const VISIBLE_NODE_TYPES = new Set(['Pod', 'Skipped', 'Suspend']) - -function extractSteps(data: any): WorkflowStep[] { - const nodes = data.status?.nodes || {} - const steps: WorkflowStep[] = Object.entries(nodes) - .filter(([, node]: [string, any]) => VISIBLE_NODE_TYPES.has(node.type)) - .map(([id, node]: [string, any]) => ({ - id, - displayName: node.displayName || id, - phase: node.phase || (node.type === 'Skipped' ? 'Skipped' : 'Pending'), - startedAt: node.startedAt || null, - finishedAt: node.finishedAt || null, - message: node.message || null, - nodeType: node.type, - })) - - steps.sort((a, b) => { - if (!a.startedAt && !b.startedAt) return 0 - if (!a.startedAt) return 1 - if (!b.startedAt) return -1 - return new Date(a.startedAt).getTime() - new Date(b.startedAt).getTime() - }) - - return steps -} - -export function WorkflowRenderer({ data }: WorkflowRendererProps) { +export function WorkflowRenderer({ data, onNavigate }: WorkflowRendererProps) { const status = data.status || {} const spec = data.spec || {} const phase = status.phase || 'Unknown' + const execution = buildWorkflowExecutionModel(data) + const steps = execution.visibleSteps // Compute duration const startedAt = status.startedAt ? new Date(status.startedAt) : null @@ -144,18 +109,13 @@ export function WorkflowRenderer({ data }: WorkflowRendererProps) { ? formatDuration(Date.now() - startedAt.getTime(), true) + ' (running)' : null - // Extract problems - const problems = getWorkflowProblems(data) + const problems = getWorkflowProblems(data, steps) const hasProblems = problems.length > 0 - // Extract steps - const steps = extractSteps(data) - // Arguments const parameters = spec.arguments?.parameters || [] - // Template reference - const templateName = spec.workflowTemplateRef?.name || null + const workflowTemplateRef = execution.templateRefs.find((ref) => ref.source === 'workflow') // Estimated duration const estimatedDuration = status.estimatedDuration @@ -183,7 +143,7 @@ export function WorkflowRenderer({ data }: WorkflowRendererProps) { {duration && } {status.startedAt && } - {templateName && } + {workflowTemplateRef && } />} {status.progress && ( )} @@ -209,15 +169,15 @@ export function WorkflowRenderer({ data }: WorkflowRendererProps) {
{steps.map(step => { const isFailed = step.phase === 'Failed' || step.phase === 'Error' - const isSkipped = step.nodeType === 'Skipped' || step.phase === 'Skipped' - const isSuspend = step.nodeType === 'Suspend' + const isSkipped = step.type === 'Skipped' || step.phase === 'Skipped' + const isSuspend = step.type === 'Suspend' return (
- + )} + {execution.templateRefs.length > 1 && ( +
+
+ {execution.templateRefs.map((ref) => ( +
+
+ + {ref.template && template {ref.template}} +
+ {ref.source === 'workflow' ? 'workflow' : ref.taskName || 'task'} +
+ ))} +
+
+ )} + {/* Arguments section */} {parameters.length > 0 && (
@@ -254,3 +230,16 @@ export function WorkflowRenderer({ data }: WorkflowRendererProps) { ) } + +function TemplateRefLink({ refInfo, onNavigate }: { refInfo: WorkflowTemplateReference; onNavigate?: WorkflowRendererProps['onNavigate'] }) { + return ( + + ) +} diff --git a/packages/k8s-ui/src/components/shared/ResourceRendererDispatch.tsx b/packages/k8s-ui/src/components/shared/ResourceRendererDispatch.tsx index 80b9a67ad..46d666ad5 100644 --- a/packages/k8s-ui/src/components/shared/ResourceRendererDispatch.tsx +++ b/packages/k8s-ui/src/components/shared/ResourceRendererDispatch.tsx @@ -323,7 +323,7 @@ const KNOWN_KINDS = new Set([ 'rollouts', 'certificates', 'workflows', 'persistentvolumes', 'storageclasses', 'certificaterequests', 'clusterissuers', 'issuers', 'orders', 'challenges', - 'gateways', 'gatewayclasses', 'httproutes', 'grpcroutes', 'tcproutes', 'tlsroutes', 'sealedsecrets', 'workflowtemplates', + 'gateways', 'gatewayclasses', 'httproutes', 'grpcroutes', 'tcproutes', 'tlsroutes', 'sealedsecrets', 'workflowtemplates', 'clusterworkflowtemplates', 'networkpolicies', 'networkpolicy', 'ciliumnetworkpolicies', 'ciliumnetworkpolicy', 'ciliumclusterwidenetworkpolicies', 'ciliumclusterwidenetworkpolicy', 'clusternetworkpolicies', 'clusternetworkpolicy', @@ -535,7 +535,7 @@ export function ResourceRendererDispatch({ {kind === 'persistentvolumeclaims' && } {kind === 'rollouts' && } {kind === 'certificates' && !data?.apiVersion?.includes('networking.internal.knative.dev') && } - {kind === 'workflows' && } + {kind === 'workflows' && } {kind === 'persistentvolumes' && } {kind === 'storageclasses' && } {kind === 'certificaterequests' && } @@ -550,7 +550,7 @@ export function ResourceRendererDispatch({ {kind === 'tcproutes' && } {kind === 'tlsroutes' && } {kind === 'sealedsecrets' && } - {kind === 'workflowtemplates' && } + {(kind === 'workflowtemplates' || kind === 'clusterworkflowtemplates') && } {(kind === 'networkpolicies' || kind === 'networkpolicy') && } {(kind === 'ciliumnetworkpolicies' || kind === 'ciliumnetworkpolicy' || kind === 'ciliumclusterwidenetworkpolicies' || kind === 'ciliumclusterwidenetworkpolicy') && } {(kind === 'clusternetworkpolicies' || kind === 'clusternetworkpolicy') && } diff --git a/packages/k8s-ui/src/components/topology/K8sResourceNode.tsx b/packages/k8s-ui/src/components/topology/K8sResourceNode.tsx index 4eca6e548..d404e363b 100644 --- a/packages/k8s-ui/src/components/topology/K8sResourceNode.tsx +++ b/packages/k8s-ui/src/components/topology/K8sResourceNode.tsx @@ -164,6 +164,8 @@ export const NODE_DIMENSIONS: Record): string return `${nodeData.keys ?? 0} keys` case 'Secret': return `${nodeData.keys ?? 0} keys` + case 'WorkflowTemplate': + case 'ClusterWorkflowTemplate': { + const entrypoint = nodeData.entrypoint as string + const templateCount = nodeData.templateCount as number + if (entrypoint && templateCount) return `${entrypoint} • ${templateCount} templates` + if (entrypoint) return entrypoint + return templateCount ? `${templateCount} templates` : '' + } case 'PersistentVolumeClaim': { const storage = (nodeData.storage as string) || '' const phase = (nodeData.phase as string) || '' diff --git a/packages/k8s-ui/src/components/topology/TopologyFilterSidebar.tsx b/packages/k8s-ui/src/components/topology/TopologyFilterSidebar.tsx index 387e3341e..3b3a58878 100644 --- a/packages/k8s-ui/src/components/topology/TopologyFilterSidebar.tsx +++ b/packages/k8s-ui/src/components/topology/TopologyFilterSidebar.tsx @@ -51,6 +51,8 @@ const RESOURCE_KINDS: { // Config { kind: 'ConfigMap', label: 'ConfigMap', icon: getTopologyIcon('ConfigMap'), color: 'text-amber-400', category: 'config' }, { kind: 'Secret', label: 'Secret', icon: getTopologyIcon('Secret'), color: 'text-red-400', category: 'config' }, + { kind: 'WorkflowTemplate', label: 'WorkflowTemplate', icon: getTopologyIcon('WorkflowTemplate'), color: 'text-purple-300', category: 'config' }, + { kind: 'ClusterWorkflowTemplate', label: 'ClusterWorkflowTemplate', icon: getTopologyIcon('ClusterWorkflowTemplate'), color: 'text-purple-300', category: 'config' }, // Scaling { kind: 'HorizontalPodAutoscaler', label: 'HPA', icon: getTopologyIcon('HorizontalPodAutoscaler'), color: 'text-pink-400', category: 'scaling' }, diff --git a/packages/k8s-ui/src/components/topology/layout.ts b/packages/k8s-ui/src/components/topology/layout.ts index 0fa1b4e95..8e57fdc94 100644 --- a/packages/k8s-ui/src/components/topology/layout.ts +++ b/packages/k8s-ui/src/components/topology/layout.ts @@ -833,6 +833,10 @@ function computeWorkloadCards( subtitle = (d.phase as string) || '' } else if (primary.kind === 'Workflow') { subtitle = (d.progress as string) || (d.phase as string) || '' + } else if (primary.kind === 'WorkflowTemplate' || primary.kind === 'ClusterWorkflowTemplate') { + const entrypoint = d.entrypoint as string + const templateCount = d.templateCount as number + subtitle = entrypoint || (templateCount ? `${templateCount} templates` : '') } else if (primary.kind === 'Application') { const sync = d.syncStatus as string const health = d.healthStatus as string @@ -862,7 +866,7 @@ const KIND_PRIORITY: Record = { 'CronJob': 4, 'CronWorkflow': 4, 'Job': 5, 'Workflow': 5, 'Service': 6, 'Gateway': 7, 'HTTPRoute': 6, 'GRPCRoute': 6, 'TCPRoute': 6, 'TLSRoute': 6, 'Ingress': 7, 'ReplicaSet': 8, 'Pod': 9, 'PodGroup': 9, - 'ConfigMap': 10, 'Secret': 10, 'PersistentVolumeClaim': 10, 'HorizontalPodAutoscaler': 10, + 'ConfigMap': 10, 'Secret': 10, 'WorkflowTemplate': 10, 'ClusterWorkflowTemplate': 10, 'PersistentVolumeClaim': 10, 'HorizontalPodAutoscaler': 10, 'KnativeService': 1, 'KnativeConfiguration': 3, 'KnativeRevision': 4, 'KnativeRoute': 2, 'Broker': 2, 'Channel': 2, 'Trigger': 3, 'PingSource': 3, 'ApiServerSource': 3, 'ContainerSource': 3, 'SinkBinding': 3, diff --git a/packages/k8s-ui/src/types/core.ts b/packages/k8s-ui/src/types/core.ts index 7075eef35..64497bfea 100644 --- a/packages/k8s-ui/src/types/core.ts +++ b/packages/k8s-ui/src/types/core.ts @@ -126,6 +126,10 @@ export type CoreNodeKind = | 'HorizontalPodAutoscaler' | 'Job' | 'CronJob' + | 'Workflow' + | 'CronWorkflow' + | 'WorkflowTemplate' + | 'ClusterWorkflowTemplate' | 'PersistentVolumeClaim' | 'Node' | 'Namespace' diff --git a/packages/k8s-ui/src/utils/applications.test.ts b/packages/k8s-ui/src/utils/applications.test.ts index 5b398b77d..64d6f09b2 100644 --- a/packages/k8s-ui/src/utils/applications.test.ts +++ b/packages/k8s-ui/src/utils/applications.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { compareVersions, appGroupingExplainer, APP_IDENTITY_ANNOTATION, appGroupLagMessage, matchWorkloadAcrossInstances, foldAppGroups, identityEnvInferred, worstHealth, type AppGroupFoldEntry } from './applications' +import { compareVersions, appGroupingExplainer, APP_IDENTITY_ANNOTATION, appGroupLagMessage, matchWorkloadAcrossInstances, foldAppGroups, identityEnvInferred, worstHealth, batchActivityForApp, type AppGroupFoldEntry, type AppRow, type AppWorkload } from './applications' describe('compareVersions', () => { it('orders semver', () => { @@ -87,6 +87,46 @@ describe('identityEnvInferred', () => { }) }) +describe('batchActivityForApp', () => { + const workload = (kind: string, name: string, batch?: AppWorkload['batch']): AppWorkload => ({ + kind, + namespace: 'prod', + name, + health: 'neutral', + ready: 0, + desired: 0, + restarts: 0, + workload_class: kind === 'Deployment' ? 'service' : 'job', + batch, + }) + const app = (workloads: AppWorkload[]): AppRow => ({ + key: 'billing', + name: 'billing', + health: 'neutral', + workloads, + }) + + it('ranks failed, active, suspended, and quiet batch workloads without inventing retained history', () => { + const activity = batchActivityForApp(app([ + workload('Deployment', 'api'), + workload('CronJob', 'nightly-success', { retainedRuns: 2, latestRunPhase: 'Succeeded', latestRunName: 'nightly-success-1' }), + workload('CronWorkflow', 'hourly-suspended', { suspended: true, retainedRuns: 0, schedule: '0 * * * *' }), + workload('Job', 'active-reindex', { activeRuns: 1, retainedRuns: 1, latestRunPhase: 'Running' }), + workload('Workflow', 'failed-migration', { failedRuns: 1, retainedRuns: 1, latestRunPhase: 'Failed', latestRunName: 'failed-migration', message: 'pod crashed' }), + ])) + + expect(activity.map((item) => item.workload.name)).toEqual([ + 'failed-migration', + 'active-reindex', + 'hourly-suspended', + 'nightly-success', + ]) + expect(activity[0]).toMatchObject({ tone: 'rose', label: 'Latest run failed', failedRuns: 1, detail: 'pod crashed' }) + expect(activity[1]).toMatchObject({ tone: 'sky', label: '1 active run', activeRuns: 1 }) + expect(activity[3]).toMatchObject({ tone: 'muted', label: 'Latest run succeeded', retainedRuns: 2 }) + }) +}) + // Position-preserving env switch: exact match, stem fallback (suffix, prefix, // discovered tokens), and the explicit no-counterpart null. describe('matchWorkloadAcrossInstances', () => { diff --git a/packages/k8s-ui/src/utils/applications.ts b/packages/k8s-ui/src/utils/applications.ts index 7aa7b7d38..3a0d56240 100644 --- a/packages/k8s-ui/src/utils/applications.ts +++ b/packages/k8s-ui/src/utils/applications.ts @@ -754,6 +754,29 @@ export interface BatchSignal { workload: AppWorkload } +export interface AppBatchActivity { + workload: AppWorkload + tone: BatchSignal['tone'] + rank: number + label: string + detail: string + latestRunName?: string + latestRunPhase?: string + schedule?: string + activeRuns: number + retainedRuns: number + failedRuns: number + lastScheduledAt?: string + lastSuccessfulAt?: string +} + +export function batchActivityForApp(app: AppRow): AppBatchActivity[] { + return (app.workloads || []) + .map(batchActivityForWorkload) + .filter((activity): activity is AppBatchActivity => Boolean(activity)) + .sort((a, b) => b.rank - a.rank || a.workload.name.localeCompare(b.workload.name)) +} + export function batchSignalForApp(app: AppRow): BatchSignal | null { let best: BatchSignal | null = null for (const workload of app.workloads || []) { @@ -804,6 +827,99 @@ export function batchSignalForWorkload(workload: AppWorkload): BatchSignal | nul return null } +function batchActivityForWorkload(workload: AppWorkload): AppBatchActivity | null { + const batch = workload.batch + if (!batch) return null + const latestPhase = batch.latestRunPhase || '' + const name = `${workload.kind}/${workload.name}` + const activeRuns = batch.activeRuns ?? 0 + const retainedRuns = batch.retainedRuns ?? 0 + const failedRuns = batch.failedRuns ?? 0 + if (latestPhase === 'Failed' || latestPhase === 'Error') { + return { + workload, + tone: 'rose', + rank: 50, + label: 'Latest run failed', + detail: batch.message || `${name} latest run ${batch.latestRunName || ''} ended ${latestPhase}.`, + latestRunName: batch.latestRunName, + latestRunPhase: latestPhase, + schedule: batch.schedule, + activeRuns, + retainedRuns, + failedRuns, + lastScheduledAt: batch.lastScheduledAt, + lastSuccessfulAt: batch.lastSuccessfulAt, + } + } + if (failedRuns > 0) { + return { + workload, + tone: 'amber', + rank: 40, + label: `${failedRuns} retained failed ${failedRuns === 1 ? 'run' : 'runs'}`, + detail: `${name} has retained failed executions in Kubernetes.`, + latestRunName: batch.latestRunName, + latestRunPhase: latestPhase, + schedule: batch.schedule, + activeRuns, + retainedRuns, + failedRuns, + lastScheduledAt: batch.lastScheduledAt, + lastSuccessfulAt: batch.lastSuccessfulAt, + } + } + if (activeRuns > 0) { + return { + workload, + tone: 'sky', + rank: 35, + label: `${activeRuns} active ${activeRuns === 1 ? 'run' : 'runs'}`, + detail: `${name} is executing now.`, + latestRunName: batch.latestRunName, + latestRunPhase: latestPhase, + schedule: batch.schedule, + activeRuns, + retainedRuns, + failedRuns, + lastScheduledAt: batch.lastScheduledAt, + lastSuccessfulAt: batch.lastSuccessfulAt, + } + } + if (batch.suspended) { + return { + workload, + tone: 'sky', + rank: 30, + label: 'Schedule suspended', + detail: `${name} will not create new runs until resumed.`, + latestRunName: batch.latestRunName, + latestRunPhase: latestPhase, + schedule: batch.schedule, + activeRuns, + retainedRuns, + failedRuns, + lastScheduledAt: batch.lastScheduledAt, + lastSuccessfulAt: batch.lastSuccessfulAt, + } + } + return { + workload, + tone: 'muted', + rank: latestPhase === 'Succeeded' ? 20 : 10, + label: latestPhase === 'Succeeded' ? 'Latest run succeeded' : 'No active runs', + detail: `${name} has ${retainedRuns} retained ${retainedRuns === 1 ? 'run' : 'runs'}.`, + latestRunName: batch.latestRunName, + latestRunPhase: latestPhase, + schedule: batch.schedule, + activeRuns, + retainedRuns, + failedRuns, + lastScheduledAt: batch.lastScheduledAt, + lastSuccessfulAt: batch.lastSuccessfulAt, + } +} + function batchSignalRank(signal: BatchSignal): number { switch (signal.tone) { case 'rose': diff --git a/packages/k8s-ui/src/utils/index.ts b/packages/k8s-ui/src/utils/index.ts index c44d6e055..5162ab95a 100644 --- a/packages/k8s-ui/src/utils/index.ts +++ b/packages/k8s-ui/src/utils/index.ts @@ -23,3 +23,4 @@ export * from './git-provider-urls' export * from './applications' export * from './topology-neighborhood' export * from './bulk-workload-actions' +export * from './workflow-execution' diff --git a/packages/k8s-ui/src/utils/navigation.ts b/packages/k8s-ui/src/utils/navigation.ts index 09c3adf5b..f928f52b4 100644 --- a/packages/k8s-ui/src/utils/navigation.ts +++ b/packages/k8s-ui/src/utils/navigation.ts @@ -28,6 +28,7 @@ const BUILTIN_PLURAL_TO_KIND: Record = { workflows: 'Workflow', cronworkflows: 'CronWorkflow', workflowtemplates: 'WorkflowTemplate', + clusterworkflowtemplates: 'ClusterWorkflowTemplate', horizontalpodautoscalers: 'HorizontalPodAutoscaler', persistentvolumeclaims: 'PersistentVolumeClaim', persistentvolumes: 'PersistentVolume', diff --git a/packages/k8s-ui/src/utils/resource-icons.ts b/packages/k8s-ui/src/utils/resource-icons.ts index 4ce048638..ac32f75b2 100644 --- a/packages/k8s-ui/src/utils/resource-icons.ts +++ b/packages/k8s-ui/src/utils/resource-icons.ts @@ -131,6 +131,7 @@ const KIND_ICON_MAP: Record = { workflow: Activity, cronworkflow: Activity, workflowtemplate: Activity, + clusterworkflowtemplate: Activity, application: GitBranch, // ArgoCD Application applicationset: GitBranch, // ArgoCD ApplicationSet diff --git a/packages/k8s-ui/src/utils/workflow-execution.test.ts b/packages/k8s-ui/src/utils/workflow-execution.test.ts new file mode 100644 index 000000000..37960b420 --- /dev/null +++ b/packages/k8s-ui/src/utils/workflow-execution.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from 'vitest' +import { buildWorkflowExecutionModel, collectWorkflowTemplateRefs } from './workflow-execution' + +describe('workflow execution model', () => { + it('builds lineage from Argo child edges and counts pods/steps', () => { + const model = buildWorkflowExecutionModel({ + metadata: { + namespace: 'demo', + annotations: { 'workflows.argoproj.io/scheduled-time': '2026-07-05T10:00:00Z' }, + }, + spec: { + workflowTemplateRef: { name: 'main-template' }, + }, + status: { + phase: 'Failed', + startedAt: '2026-07-05T10:00:05Z', + finishedAt: '2026-07-05T10:01:00Z', + nodes: { + root: { + id: 'root', + displayName: 'root', + type: 'DAG', + phase: 'Failed', + startedAt: '2026-07-05T10:00:05Z', + finishedAt: '2026-07-05T10:01:00Z', + children: ['step-a', 'step-b'], + }, + 'step-a': { + id: 'step-a', + displayName: 'step-a', + type: 'Pod', + phase: 'Succeeded', + startedAt: '2026-07-05T10:00:10Z', + finishedAt: '2026-07-05T10:00:20Z', + }, + 'step-b': { + id: 'step-b', + displayName: 'step-b', + type: 'Pod', + phase: 'Failed', + message: 'exit code 1', + startedAt: '2026-07-05T10:00:30Z', + finishedAt: '2026-07-05T10:00:40Z', + }, + }, + }, + }) + + expect(model.counts.podTotal).toBe(2) + expect(model.counts.podSucceeded).toBe(1) + expect(model.counts.podFailed).toBe(1) + expect(model.counts.stepTotal).toBe(3) + expect(model.focusPaths[0].nodes.map((node) => node.id)).toEqual(['root', 'step-b']) + expect(model.templateRefs).toMatchObject([{ name: 'main-template', resourceKind: 'workflowtemplates', namespace: 'demo' }]) + expect(model.activity.map((item) => item.id)).toContain('workflow-scheduled') + }) + + it('uses boundaryID as a fallback parent when children are missing', () => { + const model = buildWorkflowExecutionModel({ + status: { + nodes: { + group: { displayName: 'group', type: 'Steps', phase: 'Failed' }, + child: { displayName: 'child', type: 'Pod', phase: 'Failed', boundaryID: 'group' }, + }, + }, + }) + + expect(model.edges).toEqual([{ source: 'group', target: 'child' }]) + expect(model.focusPaths[0].nodes.map((node) => node.id)).toEqual(['group', 'child']) + }) + + it('collects task-level ClusterWorkflowTemplate refs', () => { + const refs = collectWorkflowTemplateRefs({ + metadata: { namespace: 'demo' }, + spec: { + templates: [ + { + name: 'main', + dag: { + tasks: [ + { name: 'one', templateRef: { name: 'cluster-lib', template: 'worker', clusterScope: true } }, + ], + }, + }, + ], + }, + }) + + expect(refs).toEqual([ + { + name: 'cluster-lib', + kind: 'ClusterWorkflowTemplate', + resourceKind: 'clusterworkflowtemplates', + namespace: '', + clusterScope: true, + source: 'task', + template: 'worker', + taskName: 'one', + }, + ]) + }) +}) diff --git a/packages/k8s-ui/src/utils/workflow-execution.ts b/packages/k8s-ui/src/utils/workflow-execution.ts new file mode 100644 index 000000000..227a73167 --- /dev/null +++ b/packages/k8s-ui/src/utils/workflow-execution.ts @@ -0,0 +1,380 @@ +export type WorkflowExecutionTone = 'success' | 'danger' | 'warning' | 'info' | 'muted' + +export interface WorkflowExecutionNode { + id: string + name: string + displayName: string + type: string + phase: string + templateName?: string + templateScope?: string + boundaryId?: string + podName?: string + message?: string + startedAt?: string + finishedAt?: string + parentIds: string[] + childIds: string[] +} + +export interface WorkflowExecutionEdge { + source: string + target: string +} + +export interface WorkflowExecutionCounts { + podTotal: number + podSucceeded: number + podFailed: number + podRunning: number + podPending: number + stepTotal: number + stepSucceeded: number + stepFailed: number + stepRunning: number + stepSkipped: number +} + +export interface WorkflowExecutionPath { + terminal: WorkflowExecutionNode + nodes: WorkflowExecutionNode[] + tone: WorkflowExecutionTone +} + +export interface WorkflowExecutionActivity { + id: string + at: string + label: string + detail?: string + tone: WorkflowExecutionTone + nodeId?: string +} + +export interface WorkflowTemplateReference { + name: string + kind: 'WorkflowTemplate' | 'ClusterWorkflowTemplate' + resourceKind: 'workflowtemplates' | 'clusterworkflowtemplates' + namespace: string + clusterScope: boolean + source: 'workflow' | 'task' + template?: string + taskName?: string +} + +export interface WorkflowExecutionModel { + nodes: WorkflowExecutionNode[] + edges: WorkflowExecutionEdge[] + roots: WorkflowExecutionNode[] + visibleSteps: WorkflowExecutionNode[] + focusPaths: WorkflowExecutionPath[] + activity: WorkflowExecutionActivity[] + counts: WorkflowExecutionCounts + templateRefs: WorkflowTemplateReference[] + resourcesDuration?: Record + isLarge: boolean +} + +const STEP_NODE_TYPES = new Set(['Pod', 'Steps', 'StepGroup', 'DAG', 'TaskGroup', 'Suspend', 'Skipped']) +const LEAF_NODE_TYPES = new Set(['Pod', 'Suspend', 'Skipped']) +const LARGE_WORKFLOW_NODE_COUNT = 80 + +export function buildWorkflowExecutionModel(workflow: any): WorkflowExecutionModel { + const rawNodes = asRecord(workflow?.status?.nodes) + const nodeById = new Map() + + for (const [id, raw] of Object.entries(rawNodes)) { + const node = asRecord(raw) + const displayName = asString(node.displayName) || asString(node.name) || id + nodeById.set(id, { + id, + name: asString(node.name) || displayName, + displayName, + type: asString(node.type) || 'Unknown', + phase: asString(node.phase) || (asString(node.type) === 'Skipped' ? 'Skipped' : 'Pending'), + templateName: asString(node.templateName), + templateScope: asString(node.templateScope), + boundaryId: asString(node.boundaryID), + podName: asString(node.podName), + message: asString(node.message), + startedAt: asString(node.startedAt), + finishedAt: asString(node.finishedAt), + parentIds: [], + childIds: [], + }) + } + + const edgeKeys = new Set() + for (const [id, raw] of Object.entries(rawNodes)) { + const node = nodeById.get(id) + if (!node) continue + const rawNode = asRecord(raw) + const childIds = [...asStringArray(rawNode.children), ...asStringArray(rawNode.outboundNodes)] + for (const childId of childIds) { + if (!nodeById.has(childId)) continue + addWorkflowEdge(nodeById, edgeKeys, id, childId) + } + } + + for (const node of nodeById.values()) { + if (node.parentIds.length > 0 || !node.boundaryId || node.boundaryId === node.id || !nodeById.has(node.boundaryId)) continue + addWorkflowEdge(nodeById, edgeKeys, node.boundaryId, node.id) + } + + const nodes = [...nodeById.values()].sort(compareExecutionNodes) + const roots = nodes.filter((node) => node.parentIds.length === 0) + const visibleSteps = nodes.filter((node) => STEP_NODE_TYPES.has(node.type)) + const counts = countWorkflowExecution(nodes) + const focusNodes = pickFocusNodes(nodes) + + return { + nodes, + edges: [...edgeKeys].map((key) => { + const [source, target] = key.split('\u0000') + return { source, target } + }), + roots, + visibleSteps, + focusPaths: focusNodes.map((node) => ({ + terminal: node, + nodes: lineagePath(nodeById, node), + tone: phaseTone(node.phase), + })), + activity: workflowActivity(workflow, visibleSteps), + counts, + templateRefs: collectWorkflowTemplateRefs(workflow), + resourcesDuration: asNumberRecord(workflow?.status?.resourcesDuration), + isLarge: nodes.length > LARGE_WORKFLOW_NODE_COUNT, + } +} + +export function collectWorkflowTemplateRefs(workflow: any): WorkflowTemplateReference[] { + const namespace = asString(workflow?.metadata?.namespace) + const refs: WorkflowTemplateReference[] = [] + const workflowRef = templateReferenceFromObject(workflow?.spec?.workflowTemplateRef, namespace, 'workflow') + if (workflowRef) refs.push(workflowRef) + + for (const template of asArray(workflow?.spec?.templates)) { + const templateMap = asRecord(template) + const templateName = asString(templateMap.name) + for (const task of taskLikeObjects(templateMap)) { + const taskMap = asRecord(task) + const ref = templateReferenceFromObject(taskMap.templateRef, namespace, 'task', templateName, asString(taskMap.name)) + if (ref) refs.push(ref) + } + } + + return dedupeTemplateRefs(refs) +} + +export function phaseTone(phase: string): WorkflowExecutionTone { + switch (phase) { + case 'Succeeded': + return 'success' + case 'Failed': + case 'Error': + return 'danger' + case 'Running': + case 'Pending': + return 'warning' + case 'Skipped': + case 'Omitted': + return 'muted' + default: + return 'info' + } +} + +export function isWorkflowProblemPhase(phase: string): boolean { + return phase === 'Failed' || phase === 'Error' +} + +function addWorkflowEdge(nodeById: Map, edgeKeys: Set, source: string, target: string) { + const key = `${source}\u0000${target}` + if (edgeKeys.has(key)) return + const parent = nodeById.get(source) + const child = nodeById.get(target) + if (!parent || !child) return + edgeKeys.add(key) + parent.childIds = [...parent.childIds, target] + child.parentIds = [...child.parentIds, source] +} + +function countWorkflowExecution(nodes: WorkflowExecutionNode[]): WorkflowExecutionCounts { + const counts: WorkflowExecutionCounts = { + podTotal: 0, + podSucceeded: 0, + podFailed: 0, + podRunning: 0, + podPending: 0, + stepTotal: 0, + stepSucceeded: 0, + stepFailed: 0, + stepRunning: 0, + stepSkipped: 0, + } + for (const node of nodes) { + if (node.type === 'Pod') { + counts.podTotal++ + if (node.phase === 'Succeeded') counts.podSucceeded++ + else if (isWorkflowProblemPhase(node.phase)) counts.podFailed++ + else if (node.phase === 'Running') counts.podRunning++ + else if (node.phase === 'Pending') counts.podPending++ + } + if (STEP_NODE_TYPES.has(node.type)) { + counts.stepTotal++ + if (node.phase === 'Succeeded') counts.stepSucceeded++ + else if (isWorkflowProblemPhase(node.phase)) counts.stepFailed++ + else if (node.phase === 'Running') counts.stepRunning++ + else if (node.phase === 'Skipped' || node.phase === 'Omitted') counts.stepSkipped++ + } + } + return counts +} + +function pickFocusNodes(nodes: WorkflowExecutionNode[]): WorkflowExecutionNode[] { + const failed = nodes.filter((node) => isWorkflowProblemPhase(node.phase) && (LEAF_NODE_TYPES.has(node.type) || node.message)) + if (failed.length > 0) return failed.slice(0, 12) + const active = nodes.filter((node) => (node.phase === 'Running' || node.phase === 'Pending') && STEP_NODE_TYPES.has(node.type)) + return active.slice(0, 12) +} + +function lineagePath(nodeById: Map, terminal: WorkflowExecutionNode): WorkflowExecutionNode[] { + const path: WorkflowExecutionNode[] = [] + const seen = new Set() + let current: WorkflowExecutionNode | undefined = terminal + while (current && !seen.has(current.id)) { + seen.add(current.id) + path.unshift(current) + const parentId: string | undefined = [...current.parentIds].sort((a, b) => compareExecutionNodes(nodeById.get(a), nodeById.get(b)))[0] + current = parentId ? nodeById.get(parentId) : undefined + } + return path +} + +function workflowActivity(workflow: any, nodes: WorkflowExecutionNode[]): WorkflowExecutionActivity[] { + const items: WorkflowExecutionActivity[] = [] + const scheduledAt = asString(workflow?.metadata?.annotations?.['workflows.argoproj.io/scheduled-time']) + if (scheduledAt) { + items.push({ id: 'workflow-scheduled', at: scheduledAt, label: 'Scheduled', tone: 'info' }) + } + const startedAt = asString(workflow?.status?.startedAt) + if (startedAt) { + items.push({ id: 'workflow-started', at: startedAt, label: 'Workflow started', tone: 'info' }) + } + for (const node of nodes) { + if (node.startedAt) { + items.push({ + id: `${node.id}-started`, + at: node.startedAt, + label: `${node.displayName} started`, + detail: node.type, + tone: phaseTone(node.phase), + nodeId: node.id, + }) + } + if (node.finishedAt) { + items.push({ + id: `${node.id}-finished`, + at: node.finishedAt, + label: `${node.displayName} ${activityVerb(node.phase)}`, + detail: node.message || node.type, + tone: phaseTone(node.phase), + nodeId: node.id, + }) + } + } + const finishedAt = asString(workflow?.status?.finishedAt) + if (finishedAt) { + items.push({ id: 'workflow-finished', at: finishedAt, label: `Workflow ${activityVerb(asString(workflow?.status?.phase) || 'finished')}`, tone: phaseTone(asString(workflow?.status?.phase)) }) + } + return items.sort((a, b) => Date.parse(a.at) - Date.parse(b.at)) +} + +function activityVerb(phase: string): string { + switch (phase) { + case 'Succeeded': + return 'succeeded' + case 'Failed': + return 'failed' + case 'Error': + return 'errored' + case 'Skipped': + case 'Omitted': + return 'skipped' + default: + return 'finished' + } +} + +function templateReferenceFromObject(raw: any, namespace: string, source: 'workflow' | 'task', template?: string, taskName?: string): WorkflowTemplateReference | null { + const ref = asRecord(raw) + const name = asString(ref.name) + if (!name) return null + const clusterScope = ref.clusterScope === true + return { + name, + kind: clusterScope ? 'ClusterWorkflowTemplate' : 'WorkflowTemplate', + resourceKind: clusterScope ? 'clusterworkflowtemplates' : 'workflowtemplates', + namespace: clusterScope ? '' : namespace, + clusterScope, + source, + template: asString(ref.template) || template, + taskName, + } +} + +function taskLikeObjects(template: Record): any[] { + const tasks: any[] = [] + for (const task of asArray(template?.dag?.tasks)) tasks.push(task) + for (const group of asArray(template?.steps)) { + for (const step of asArray(group)) tasks.push(step) + } + return tasks +} + +function dedupeTemplateRefs(refs: WorkflowTemplateReference[]): WorkflowTemplateReference[] { + const seen = new Set() + const out: WorkflowTemplateReference[] = [] + for (const ref of refs) { + const key = [ref.resourceKind, ref.namespace, ref.name, ref.source, ref.template || '', ref.taskName || ''].join('\u0000') + if (seen.has(key)) continue + seen.add(key) + out.push(ref) + } + return out +} + +function compareExecutionNodes(a?: WorkflowExecutionNode, b?: WorkflowExecutionNode): number { + if (!a && !b) return 0 + if (!a) return 1 + if (!b) return -1 + const aTime = a.startedAt ? Date.parse(a.startedAt) : Number.POSITIVE_INFINITY + const bTime = b.startedAt ? Date.parse(b.startedAt) : Number.POSITIVE_INFINITY + if (aTime !== bTime) return aTime - bTime + return a.displayName.localeCompare(b.displayName) || a.id.localeCompare(b.id) +} + +function asRecord(value: any): Record { + return value && typeof value === 'object' && !Array.isArray(value) ? value : {} +} + +function asArray(value: any): any[] { + return Array.isArray(value) ? value : [] +} + +function asString(value: any): string { + return typeof value === 'string' ? value : '' +} + +function asStringArray(value: any): string[] { + return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : [] +} + +function asNumberRecord(value: any): Record | undefined { + const record = asRecord(value) + const out: Record = {} + for (const [key, raw] of Object.entries(record)) { + if (typeof raw === 'number') out[key] = raw + } + return Object.keys(out).length > 0 ? out : undefined +} diff --git a/pkg/topology/builder.go b/pkg/topology/builder.go index 9d6ad4b1a..846972a78 100644 --- a/pkg/topology/builder.go +++ b/pkg/topology/builder.go @@ -3,6 +3,7 @@ package topology import ( "fmt" "log" + "sort" "strings" "time" @@ -274,6 +275,10 @@ func (b *Builder) buildResourcesTopology(opts BuildOptions) (*Topology, error) { workflowIDs := make(map[string]string) cronWorkflowIDs := make(map[string]string) workflowToCronWorkflow := make(map[string]string) // workflowKey -> cronWorkflowID (for shortcut edges) + workflowTemplateIDs := make(map[string]string) + clusterWorkflowTemplateIDs := make(map[string]string) + workflowTemplateNodes := make(map[string]Node) + referencedWorkflowTemplateIDs := make(map[string]bool) // Track ConfigMap/Secret/PVC references from workloads // Maps workloadID -> set of resource names @@ -2554,9 +2559,72 @@ func (b *Builder) buildResourcesTopology(opts BuildOptions) (*Topology, error) { hasCronWorkflows := false var workflowGVR schema.GroupVersionResource hasWorkflows := false + var workflowTemplateGVR schema.GroupVersionResource + hasWorkflowTemplates := false + var clusterWorkflowTemplateGVR schema.GroupVersionResource + hasClusterWorkflowTemplates := false if resourceDiscovery != nil { cronWorkflowGVR, hasCronWorkflows = resourceDiscovery.GetGVRWithGroup("CronWorkflow", "argoproj.io") workflowGVR, hasWorkflows = resourceDiscovery.GetGVRWithGroup("Workflow", "argoproj.io") + workflowTemplateGVR, hasWorkflowTemplates = resourceDiscovery.GetGVRWithGroup("WorkflowTemplate", "argoproj.io") + clusterWorkflowTemplateGVR, hasClusterWorkflowTemplates = resourceDiscovery.GetGVRWithGroup("ClusterWorkflowTemplate", "argoproj.io") + } + if hasWorkflowTemplates && dynamicCache != nil { + workflowTemplates, err := dynamicCache.ListNamespaces(workflowTemplateGVR, opts.Namespaces) + if err != nil { + log.Printf("WARNING [topology] Failed to list Argo WorkflowTemplates: %v", err) + warnings = append(warnings, fmt.Sprintf("Failed to list Argo WorkflowTemplates: %v", err)) + } + for _, wt := range workflowTemplates { + ns := wt.GetNamespace() + if !opts.MatchesNamespaceFilter(ns) { + continue + } + name := wt.GetName() + entrypoint, _, _ := unstructured.NestedString(wt.Object, "spec", "entrypoint") + templates, _, _ := unstructured.NestedSlice(wt.Object, "spec", "templates") + wtID := fmt.Sprintf("workflowtemplate/%s/%s", ns, name) + workflowTemplateIDs[ns+"/"+name] = wtID + workflowTemplateNodes[wtID] = Node{ + ID: wtID, + Kind: KindWorkflowTemplate, + Name: name, + Status: StatusHealthy, + Data: map[string]any{ + "namespace": ns, + "entrypoint": entrypoint, + "templateCount": len(templates), + "labels": wt.GetLabels(), + "apiVersion": wt.GetAPIVersion(), + }, + } + } + } + if hasClusterWorkflowTemplates && dynamicCache != nil { + clusterWorkflowTemplates, err := dynamicCache.ListNamespaces(clusterWorkflowTemplateGVR, opts.Namespaces) + if err != nil { + log.Printf("WARNING [topology] Failed to list Argo ClusterWorkflowTemplates: %v", err) + warnings = append(warnings, fmt.Sprintf("Failed to list Argo ClusterWorkflowTemplates: %v", err)) + } + for _, cwt := range clusterWorkflowTemplates { + name := cwt.GetName() + entrypoint, _, _ := unstructured.NestedString(cwt.Object, "spec", "entrypoint") + templates, _, _ := unstructured.NestedSlice(cwt.Object, "spec", "templates") + cwtID := fmt.Sprintf("clusterworkflowtemplate//%s", name) + clusterWorkflowTemplateIDs[name] = cwtID + workflowTemplateNodes[cwtID] = Node{ + ID: cwtID, + Kind: KindClusterWorkflowTemplate, + Name: name, + Status: StatusHealthy, + Data: map[string]any{ + "entrypoint": entrypoint, + "templateCount": len(templates), + "labels": cwt.GetLabels(), + "apiVersion": cwt.GetAPIVersion(), + }, + } + } } if hasCronWorkflows && dynamicCache != nil { cronWorkflows, err := dynamicCache.ListNamespaces(cronWorkflowGVR, opts.Namespaces) @@ -2588,6 +2656,7 @@ func (b *Builder) buildResourcesTopology(opts BuildOptions) (*Topology, error) { "apiVersion": cwf.GetAPIVersion(), }, }) + edges = addArgoWorkflowTemplateEdges(edges, cwfID, ns, argoWorkflowTemplateRefsFromWorkflowSpec(cwf.Object, "spec", "workflowSpec"), workflowTemplateIDs, clusterWorkflowTemplateIDs, referencedWorkflowTemplateIDs) } } if hasWorkflows && dynamicCache != nil { @@ -2625,6 +2694,7 @@ func (b *Builder) buildResourcesTopology(opts BuildOptions) (*Topology, error) { "apiVersion": wf.GetAPIVersion(), }, }) + edges = addArgoWorkflowTemplateEdges(edges, wfID, ns, argoWorkflowTemplateRefsFromWorkflowSpec(wf.Object, "spec"), workflowTemplateIDs, clusterWorkflowTemplateIDs, referencedWorkflowTemplateIDs) if owner := wf.GetLabels()["workflows.argoproj.io/cron-workflow"]; owner != "" { if cwfID, ok := cronWorkflowIDs[ns+"/"+owner]; ok { edges = append(edges, Edge{ @@ -2638,6 +2708,18 @@ func (b *Builder) buildResourcesTopology(opts BuildOptions) (*Topology, error) { } } } + if len(referencedWorkflowTemplateIDs) > 0 { + templateIDs := make([]string, 0, len(referencedWorkflowTemplateIDs)) + for id := range referencedWorkflowTemplateIDs { + templateIDs = append(templateIDs, id) + } + sort.Strings(templateIDs) + for _, id := range templateIDs { + if node, ok := workflowTemplateNodes[id]; ok { + nodes = append(nodes, node) + } + } + } // 5. Add Job nodes var jobs []*batchv1.Job @@ -7152,6 +7234,90 @@ func cronWorkflowScheduleString(cwf *unstructured.Unstructured) string { return schedule } +type argoWorkflowTemplateRef struct { + name string + clusterScope bool +} + +func argoWorkflowTemplateRefsFromWorkflowSpec(obj map[string]any, fields ...string) []argoWorkflowTemplateRef { + spec, found, _ := unstructured.NestedMap(obj, fields...) + if !found { + return nil + } + refs := make([]argoWorkflowTemplateRef, 0) + seen := make(map[string]bool) + addRef := func(raw map[string]any) { + name, _, _ := unstructured.NestedString(raw, "name") + if name == "" { + return + } + clusterScope, _, _ := unstructured.NestedBool(raw, "clusterScope") + key := fmt.Sprintf("%t/%s", clusterScope, name) + if seen[key] { + return + } + seen[key] = true + refs = append(refs, argoWorkflowTemplateRef{name: name, clusterScope: clusterScope}) + } + + if workflowRef, found, _ := unstructured.NestedMap(spec, "workflowTemplateRef"); found { + addRef(workflowRef) + } + templates, _, _ := unstructured.NestedSlice(spec, "templates") + for _, rawTemplate := range templates { + template, ok := rawTemplate.(map[string]any) + if !ok { + continue + } + for _, rawTask := range argoTemplateTasks(template) { + task, ok := rawTask.(map[string]any) + if !ok { + continue + } + if templateRef, found, _ := unstructured.NestedMap(task, "templateRef"); found { + addRef(templateRef) + } + } + } + return refs +} + +func argoTemplateTasks(template map[string]any) []any { + tasks := make([]any, 0) + if dagTasks, found, _ := unstructured.NestedSlice(template, "dag", "tasks"); found { + tasks = append(tasks, dagTasks...) + } + stepGroups, _, _ := unstructured.NestedSlice(template, "steps") + for _, rawGroup := range stepGroups { + if group, ok := rawGroup.([]any); ok { + tasks = append(tasks, group...) + } + } + return tasks +} + +func addArgoWorkflowTemplateEdges(edges []Edge, sourceID, sourceNamespace string, refs []argoWorkflowTemplateRef, workflowTemplateIDs, clusterWorkflowTemplateIDs map[string]string, referencedTemplateIDs map[string]bool) []Edge { + for _, ref := range refs { + targetID := "" + if ref.clusterScope { + targetID = clusterWorkflowTemplateIDs[ref.name] + } else { + targetID = workflowTemplateIDs[sourceNamespace+"/"+ref.name] + } + if targetID == "" { + continue + } + referencedTemplateIDs[targetID] = true + edges = append(edges, Edge{ + ID: fmt.Sprintf("%s-to-%s", targetID, sourceID), + Source: targetID, + Target: sourceID, + Type: EdgeConfigures, + }) + } + return edges +} + func getPVCStatus(pvc *corev1.PersistentVolumeClaim) HealthStatus { return healthLevelToStatus(health.Workload(pvc, time.Now()).Level) } @@ -7817,6 +7983,7 @@ func (b *Builder) addGenericCRDNodes(nodes []Node, edges []Edge, opts BuildOptio "nodepool": true, "nodeclaim": true, // Karpenter "ec2nodeclass": true, "aksnodeclass": true, "gcenodeclass": true, // Karpenter NodeClass "scaledobject": true, "scaledjob": true, // KEDA + "workflowtemplate": true, "clusterworkflowtemplate": true, // Argo Workflows "gatewayclass": true, // Gateway API "virtualservice": true, "destinationrule": true, "serviceentry": true, // Istio networking "peerauthentication": true, "authorizationpolicy": true, // Istio security diff --git a/pkg/topology/builder_test.go b/pkg/topology/builder_test.go index e33988543..245dcee76 100644 --- a/pkg/topology/builder_test.go +++ b/pkg/topology/builder_test.go @@ -119,6 +119,72 @@ func (m *rolloutDynamicProvider) IsCRD(kind string) bool { return kind == "Rollout" } +func TestArgoWorkflowTemplateRefsFromWorkflowSpec(t *testing.T) { + obj := map[string]any{ + "spec": map[string]any{ + "workflowTemplateRef": map[string]any{"name": "main"}, + "templates": []any{ + map[string]any{ + "name": "dag", + "dag": map[string]any{ + "tasks": []any{ + map[string]any{"name": "build", "templateRef": map[string]any{"name": "shared", "clusterScope": true}}, + map[string]any{"name": "test", "templateRef": map[string]any{"name": "shared", "clusterScope": true}}, + }, + }, + }, + map[string]any{ + "name": "steps", + "steps": []any{ + []any{map[string]any{"name": "cleanup", "templateRef": map[string]any{"name": "cleanup"}}}, + }, + }, + }, + }, + } + + refs := argoWorkflowTemplateRefsFromWorkflowSpec(obj, "spec") + if len(refs) != 3 { + t.Fatalf("expected 3 deduped refs, got %#v", refs) + } + want := []argoWorkflowTemplateRef{ + {name: "main"}, + {name: "shared", clusterScope: true}, + {name: "cleanup"}, + } + for i := range want { + if refs[i] != want[i] { + t.Fatalf("ref %d = %#v, want %#v", i, refs[i], want[i]) + } + } +} + +func TestAddArgoWorkflowTemplateEdges(t *testing.T) { + referenced := make(map[string]bool) + edges := addArgoWorkflowTemplateEdges(nil, "workflow/demo/run", "demo", []argoWorkflowTemplateRef{ + {name: "local"}, + {name: "global", clusterScope: true}, + {name: "missing"}, + }, map[string]string{ + "demo/local": "workflowtemplate/demo/local", + }, map[string]string{ + "global": "clusterworkflowtemplate//global", + }, referenced) + + if len(edges) != 2 { + t.Fatalf("expected 2 edges, got %#v", edges) + } + if edges[0].Source != "workflowtemplate/demo/local" || edges[0].Target != "workflow/demo/run" || edges[0].Type != EdgeConfigures { + t.Fatalf("local edge = %#v", edges[0]) + } + if edges[1].Source != "clusterworkflowtemplate//global" || edges[1].Target != "workflow/demo/run" || edges[1].Type != EdgeConfigures { + t.Fatalf("cluster edge = %#v", edges[1]) + } + if !referenced["workflowtemplate/demo/local"] || !referenced["clusterworkflowtemplate//global"] || referenced["missing"] { + t.Fatalf("referenced template IDs = %#v", referenced) + } +} + // --- Generators --- func makePods(n int, ns string) []*corev1.Pod { diff --git a/pkg/topology/cluster_scoped_kinds.go b/pkg/topology/cluster_scoped_kinds.go index 47286eba3..821b1e7a3 100644 --- a/pkg/topology/cluster_scoped_kinds.go +++ b/pkg/topology/cluster_scoped_kinds.go @@ -36,6 +36,7 @@ var ClusterScopedKinds = []ClusterScopedKindEntry{ {KindGatewayClass, "gateway.networking.k8s.io", "gatewayclasses"}, {KindPV, "", "persistentvolumes"}, {KindStorageClass, "storage.k8s.io", "storageclasses"}, + {KindClusterWorkflowTemplate, "argoproj.io", "clusterworkflowtemplates"}, {KindCiliumClusterwideNetworkPolicy, "cilium.io", "ciliumclusterwidenetworkpolicies"}, {KindClusterNetworkPolicy, "policy.networking.k8s.io", "clusternetworkpolicies"}, } @@ -263,4 +264,3 @@ func RBACTuplesForNode(n *Node, disc PseudoKindDiscoveryLookup) (decision NodeRB } return NodeRBACDeny, nil } - diff --git a/pkg/topology/types.go b/pkg/topology/types.go index 39959f99e..4ec276533 100644 --- a/pkg/topology/types.go +++ b/pkg/topology/types.go @@ -96,6 +96,8 @@ const ( KindCronJob NodeKind = "CronJob" KindWorkflow NodeKind = "Workflow" KindCronWorkflow NodeKind = "CronWorkflow" + KindWorkflowTemplate NodeKind = "WorkflowTemplate" + KindClusterWorkflowTemplate NodeKind = "ClusterWorkflowTemplate" KindPVC NodeKind = "PersistentVolumeClaim" KindPV NodeKind = "PersistentVolume" KindStorageClass NodeKind = "StorageClass" diff --git a/web/src/api/client.ts b/web/src/api/client.ts index c660a596c..1401aa07d 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -1051,7 +1051,7 @@ export function useGitOpsInsights(kind: string, namespace: string, name: string, // Generic resource fetching - returns resource with relationships // Uses '_' as placeholder for cluster-scoped resources (empty namespace) -export function useResource(kind: string, namespace: string, name: string, group?: string) { +export function useResource(kind: string, namespace: string, name: string, group?: string, options?: { enabled?: boolean; refetchInterval?: number | false }) { // For cluster-scoped resources, use '_' as namespace placeholder const ns = namespace || '_' const params = new URLSearchParams() @@ -1061,7 +1061,8 @@ export function useResource(kind: string, namespace: string, name: string, gr const query = useQuery>({ queryKey: ['resource', kind, namespace, name, group], queryFn: () => fetchJSON(`/resources/${kind}/${ns}/${name}${queryString ? `?${queryString}` : ''}`), - enabled: Boolean(kind && name), // namespace can be empty for cluster-scoped resources + enabled: (options?.enabled ?? true) && Boolean(kind && name), // namespace can be empty for cluster-scoped resources + refetchInterval: options?.refetchInterval, }) // Extract resource and relationships from the response @@ -3592,12 +3593,15 @@ export function useWorkloadPods(kind: string, namespace: string, name: string) { }) } -export function useWorkloadRuns(kind: string, namespace: string, name: string, enabled = true) { +export function useWorkloadRuns(kind: string, namespace: string, name: string, enabled = true, options?: { refetchActive?: boolean }) { return useQuery({ queryKey: ['workload-runs', kind, namespace, name], queryFn: () => fetchJSON(`/workloads/${kind}/${namespace}/${name}/runs`), enabled: enabled && Boolean(kind && namespace && name), staleTime: 10000, + refetchInterval: options?.refetchActive + ? (query) => query.state.data?.runs?.some((run) => run.active) ? 5000 : false + : false, }) } diff --git a/web/src/components/execution/BatchExecutionView.tsx b/web/src/components/execution/BatchExecutionView.tsx index 251d64c1f..6fc5a2a15 100644 --- a/web/src/components/execution/BatchExecutionView.tsx +++ b/web/src/components/execution/BatchExecutionView.tsx @@ -1,8 +1,9 @@ -import { useEffect, useMemo, useState } from 'react' -import { AlertTriangle, Loader2, Terminal } from 'lucide-react' +import { useEffect, useMemo, useState, type ComponentType, type ReactNode } from 'react' +import { Activity, AlertTriangle, GitBranch, Layers3, Loader2, Terminal } from 'lucide-react' import { clsx } from 'clsx' import { EmptyState, FetchResult, StatusDot, mapHealthToTone } from '@skyhook-io/k8s-ui' -import { useWorkloadRuns, type WorkloadRun } from '../../api/client' +import { buildWorkflowExecutionModel, type WorkflowExecutionActivity, type WorkflowExecutionModel, type WorkflowExecutionNode, type WorkflowTemplateReference } from '@skyhook-io/k8s-ui/utils/workflow-execution' +import { useResource, useWorkloadRuns, type WorkloadRun } from '../../api/client' import { WorkloadLogsViewer } from '../logs/WorkloadLogsViewer' const EMPTY_RUNS: WorkloadRun[] = [] @@ -18,10 +19,11 @@ interface BatchExecutionProps { namespace: string name: string resource: any + onNavigateToResource?: (resource: { kind: string; namespace: string; name: string; group?: string }) => void } export function BatchDrawerExecutionSummary({ kind, apiKind, namespace, name, resource }: BatchExecutionProps) { - const runsQuery = useWorkloadRuns(apiKind, namespace, name, SCHEDULED_KINDS.has(kind)) + const runsQuery = useWorkloadRuns(apiKind, namespace, name, SCHEDULED_KINDS.has(kind), { refetchActive: true }) const runs = scheduledRunsFor(kind, runsQuery.data?.runs, resource, apiKind, namespace, name) const current = pickDefaultRun(runs) const source = sourceFacts(kind, resource, runs) @@ -61,9 +63,9 @@ export function BatchDrawerExecutionSummary({ kind, apiKind, namespace, name, re ) } -export function BatchExecutionFullscreen({ kind, apiKind, namespace, name, resource }: BatchExecutionProps) { +export function BatchExecutionFullscreen({ kind, apiKind, namespace, name, resource, onNavigateToResource }: BatchExecutionProps) { const scheduled = SCHEDULED_KINDS.has(kind) - const runsQuery = useWorkloadRuns(apiKind, namespace, name, scheduled) + const runsQuery = useWorkloadRuns(apiKind, namespace, name, scheduled, { refetchActive: true }) const runs = scheduledRunsFor(kind, runsQuery.data?.runs, resource, apiKind, namespace, name) const defaultRun = useMemo(() => pickDefaultRun(runs), [runs]) const [selectedRunName, setSelectedRunName] = useState('') @@ -82,6 +84,16 @@ export function BatchExecutionFullscreen({ kind, apiKind, namespace, name, resou const source = sourceFacts(kind, resource, runs) const issue = batchIssue(kind, resource, runs) const phaseCounts = countPhases(runs) + const fetchTarget = selectedRun && scheduled ? resourceTargetForRun(selectedRun) : null + const selectedResourceQuery = useResource( + fetchTarget?.kind ?? '', + fetchTarget?.namespace ?? '', + fetchTarget?.name ?? '', + fetchTarget?.group, + { enabled: Boolean(fetchTarget), refetchInterval: selectedRun?.active ? 5000 : false }, + ) + const selectedResource = scheduled ? selectedResourceQuery.data : resource + const workflowExecution = selectedResource && selectedRun?.kind === 'workflows' ? buildWorkflowExecutionModel(selectedResource) : null if (runsQuery.isLoading && scheduled) { return @@ -114,7 +126,7 @@ export function BatchExecutionFullscreen({ kind, apiKind, namespace, name, resou tone="neutral" variant="card" headline="No retained runs" - body="Kubernetes does not currently have Jobs or Workflows owned by this schedule." + body={`Kubernetes does not currently have ${runKindPluralForSchedule(kind)} owned by this schedule.`} />
) : ( @@ -168,17 +180,15 @@ export function BatchExecutionFullscreen({ kind, apiKind, namespace, name, resou {selectedRun.startedAt && started {formatAge(selectedRun.startedAt)}} {selectedRun.scheduledAt && scheduled {formatAge(selectedRun.scheduledAt)}} {selectedRun.template && template {selectedRun.template}} + {selectedResourceQuery.isFetching && refreshing}
{selectedRun.phase}
- +
-

Retention

-

- Radar OSS reads live Kubernetes objects and pod logs. If this run's pods have been removed by TTL or controller history limits, Radar can show the retained run object but not reconstruct deleted logs. -

+
@@ -187,7 +197,7 @@ export function BatchExecutionFullscreen({ kind, apiKind, namespace, name, resou tone="neutral" variant="card" headline="No selected run" - body="There are no retained Jobs or Workflows to inspect." + body={`There are no retained ${runKindPluralForSchedule(kind)} to inspect.`} /> )} @@ -203,6 +213,17 @@ export function BatchExecutionFullscreen({ kind, apiKind, namespace, name, resou + {selectedRun && ( +
+ + +
+ )} + + {workflowExecution && workflowExecution.templateRefs.length > 0 && ( + + )} + {selectedRun && (
@@ -265,6 +286,7 @@ function runFromResource(kind: string, resource: any, apiKind: string, namespace } if (kind === 'Workflow') { const status = resource.status ?? {} + const execution = buildWorkflowExecutionModel(resource) const run: WorkloadRun = { kind: apiKind, namespace, @@ -276,8 +298,8 @@ function runFromResource(kind: string, resource: any, apiKind: string, namespace message: status.message, progress: status.progress, template: resource.spec?.workflowTemplateRef?.name, + ...execution.counts, } - applyWorkflowCounts(run, resource) return run } return null @@ -316,27 +338,6 @@ function conditionMessage(conditions: any[] | undefined): string | undefined { return c?.message || c?.reason } -function applyWorkflowCounts(run: WorkloadRun, workflow: any) { - const nodes = workflow.status?.nodes ?? {} - for (const node of Object.values(nodes) as any[]) { - const phase = node.phase - if (node.type === 'Pod') { - run.podTotal = (run.podTotal ?? 0) + 1 - if (phase === 'Succeeded') run.podSucceeded = (run.podSucceeded ?? 0) + 1 - else if (phase === 'Failed' || phase === 'Error') run.podFailed = (run.podFailed ?? 0) + 1 - else if (phase === 'Running') run.podRunning = (run.podRunning ?? 0) + 1 - else if (phase === 'Pending') run.podPending = (run.podPending ?? 0) + 1 - } - if (['Pod', 'Steps', 'StepGroup', 'DAG', 'TaskGroup', 'Suspend', 'Skipped'].includes(node.type)) { - run.stepTotal = (run.stepTotal ?? 0) + 1 - if (phase === 'Succeeded') run.stepSucceeded = (run.stepSucceeded ?? 0) + 1 - else if (phase === 'Failed' || phase === 'Error') run.stepFailed = (run.stepFailed ?? 0) + 1 - else if (phase === 'Running') run.stepRunning = (run.stepRunning ?? 0) + 1 - else if (phase === 'Skipped' || phase === 'Omitted') run.stepSkipped = (run.stepSkipped ?? 0) + 1 - } - } -} - function sourceFacts(kind: string, resource: any, runs: WorkloadRun[]) { const spec = resource?.spec ?? {} const status = resource?.status ?? {} @@ -447,14 +448,16 @@ function SourceFacts({ source }: { source: ReturnType }) { ) } -function RunDetailList({ run }: { run: WorkloadRun }) { +function RunDetailList({ run, resource, workflowExecution }: { run: WorkloadRun; resource: any; workflowExecution: WorkflowExecutionModel | null }) { const rows = [ ['Started', run.startedAt ? formatAge(run.startedAt) : '-'], ['Finished', run.finishedAt ? formatAge(run.finishedAt) : run.active ? 'Running' : '-'], ['Scheduled', run.scheduledAt ? formatAge(run.scheduledAt) : '-'], + ['Schedule delay', formatScheduleDelay(run) || '-'], ['Parallelism', run.parallelism ? String(run.parallelism) : '-'], - ['Pods', podBreakdown(run) || '-'], - ['Steps', stepBreakdown(run) || '-'], + ['Pods', podBreakdown(run, workflowExecution) || '-'], + ['Steps', stepBreakdown(run, workflowExecution) || '-'], + ['Backoff limit', resource?.spec?.backoffLimit != null ? String(resource.spec.backoffLimit) : '-'], ['Message', run.message || '-'], ] return ( @@ -469,6 +472,199 @@ function RunDetailList({ run }: { run: WorkloadRun }) { ) } +function RunMetrics({ run, resource, workflowExecution, retainedRuns }: { run: WorkloadRun; resource: any; workflowExecution: WorkflowExecutionModel | null; retainedRuns: WorkloadRun[] }) { + const failedRuns = retainedRuns.filter((r) => r.phase === 'Failed' || r.phase === 'Error').length + const activeRuns = retainedRuns.filter((r) => r.active).length + const resourceDuration = workflowExecution?.resourcesDuration ? formatResourceDuration(workflowExecution.resourcesDuration) : '' + const jobSpec = resource?.spec ?? {} + const rows = [ + ['Retained runs', retainedRuns.length ? String(retainedRuns.length) : '-'], + ['Active retained', activeRuns ? String(activeRuns) : '0'], + ['Failed retained', failedRuns ? String(failedRuns) : '0'], + ['Current duration', formatRunDuration(run) || '-'], + ['Schedule delay', formatScheduleDelay(run) || '-'], + ['Completions', jobSpec.completions != null ? String(jobSpec.completions) : run.desired ? String(run.desired) : '-'], + ['Resource duration', resourceDuration || '-'], + ] + return ( +
+

Live metrics

+
+ {rows.map(([label, value]) => ( +
+ {label} + {value} +
+ ))} +
+

+ Retained counts reflect objects still present in Kubernetes, not all-time history. +

+
+ ) +} + +function ExecutionLineage({ run, resource, workflowExecution, loading }: { run: WorkloadRun; resource: any; workflowExecution: WorkflowExecutionModel | null; loading: boolean }) { + if (run.kind === 'workflows') { + if (loading && !workflowExecution) { + return + } + if (!workflowExecution || workflowExecution.nodes.length === 0) { + return ( + + + + ) + } + const paths = workflowExecution.focusPaths + const fallbackSteps = workflowExecution.visibleSteps.slice(0, workflowExecution.isLarge ? 40 : 18) + return ( + + {paths.length > 0 ? ( +
+ {paths.map((path) => ( +
+
+
{path.terminal.displayName}
+ {path.terminal.phase} +
+
+ {path.nodes.map((node, index) => ( + + {index > 0 && /} + 5} /> + + ))} +
+ {path.terminal.message &&
{path.terminal.message}
} +
+ ))} +
+ ) : ( + + )} +
+ ) + } + + return ( + +
+ + + +
+
+ +
+
+ ) +} + +function RunActivityPanel({ run, resource, workflowExecution }: { run: WorkloadRun; resource: any; workflowExecution: WorkflowExecutionModel | null }) { + const activity = run.kind === 'workflows' ? workflowExecution?.activity ?? [] : jobActivity(run, resource) + const visible = activity.slice(-28) + return ( + + {visible.length === 0 ? ( + + ) : ( +
+ {visible.map((item) => ( +
+ +
+
+
{item.label}
+
{formatAge(item.at)}
+
+ {item.detail &&
{item.detail}
} +
+
+ ))} +
+ )} +
+ ) +} + +function TemplateReferencePanel({ refs, onNavigateToResource }: { refs: WorkflowTemplateReference[]; onNavigateToResource?: BatchExecutionProps['onNavigateToResource'] }) { + return ( + +
+ {refs.map((ref) => ( +
+ +
+ {ref.clusterScope ? 'ClusterWorkflowTemplate' : ref.namespace || 'WorkflowTemplate'} + {ref.template && · template {ref.template}} + {ref.taskName && · task {ref.taskName}} +
+
+ ))} +
+
+ ) +} + +function Panel({ title, icon: Icon, detail, children }: { title: string; icon: ComponentType<{ className?: string }>; detail?: string; children: ReactNode }) { + return ( +
+
+
+ + {title} +
+ {detail && {detail}} +
+
{children}
+
+ ) +} + +function LineageNode({ node, compact }: { node: WorkflowExecutionNode; compact?: boolean }) { + return ( + + {compact ? node.displayName.replace(/^.*\./, '') : node.displayName} + {node.type} + + ) +} + +function StepList({ steps }: { steps: Array[number]> }) { + return ( +
+ {steps.map((step) => ( +
+
+
{step.displayName}
+
{step.type}{step.message ? ` · ${step.message}` : ''}
+
+ {step.phase} +
+ ))} +
+ ) +} + +function ActivityDot({ tone }: { tone: string }) { + return +} + +function ResourceButton({ refInfo, onNavigateToResource }: { refInfo: WorkflowTemplateReference; onNavigateToResource?: BatchExecutionProps['onNavigateToResource'] }) { + const label = refInfo.clusterScope ? `${refInfo.name} (cluster)` : refInfo.name + if (!onNavigateToResource) return {label} + return ( + + ) +} + function RunDigest({ run, compact }: { run: WorkloadRun; compact?: boolean }) { return (
@@ -601,6 +797,15 @@ function countPhases(runs: WorkloadRun[]) { } } +function resourceTargetForRun(run: WorkloadRun) { + return { + kind: run.kind, + namespace: run.namespace, + name: run.name, + group: run.kind === 'workflows' ? 'argoproj.io' : undefined, + } +} + function workCount(run: WorkloadRun): string { if (run.stepTotal) return `${run.stepSucceeded ?? 0}/${run.stepTotal} steps` if (run.podTotal) { @@ -613,26 +818,132 @@ function workCount(run: WorkloadRun): string { return 'work unknown' } -function podBreakdown(run: WorkloadRun): string | null { - if (!run.podTotal) return null +function podBreakdown(run: WorkloadRun, workflowExecution?: WorkflowExecutionModel | null): string | null { + const counts = workflowExecution?.counts + const podTotal = counts?.podTotal ?? run.podTotal + if (!podTotal) return null const parts = [ - run.podRunning ? `${run.podRunning} running` : '', - run.podSucceeded ? `${run.podSucceeded} succeeded` : '', - run.podFailed ? `${run.podFailed} failed` : '', - run.podPending ? `${run.podPending} pending` : '', + (counts?.podRunning ?? run.podRunning) ? `${counts?.podRunning ?? run.podRunning} running` : '', + (counts?.podSucceeded ?? run.podSucceeded) ? `${counts?.podSucceeded ?? run.podSucceeded} succeeded` : '', + (counts?.podFailed ?? run.podFailed) ? `${counts?.podFailed ?? run.podFailed} failed` : '', + (counts?.podPending ?? run.podPending) ? `${counts?.podPending ?? run.podPending} pending` : '', ].filter(Boolean) - return parts.join(' · ') || `${run.podTotal} total` + return parts.join(' · ') || `${podTotal} total` } -function stepBreakdown(run: WorkloadRun): string | null { - if (!run.stepTotal) return null +function stepBreakdown(run: WorkloadRun, workflowExecution?: WorkflowExecutionModel | null): string | null { + const counts = workflowExecution?.counts + const stepTotal = counts?.stepTotal ?? run.stepTotal + if (!stepTotal) return null const parts = [ - run.stepRunning ? `${run.stepRunning} running` : '', - run.stepSucceeded ? `${run.stepSucceeded} succeeded` : '', - run.stepFailed ? `${run.stepFailed} failed` : '', - run.stepSkipped ? `${run.stepSkipped} skipped` : '', + (counts?.stepRunning ?? run.stepRunning) ? `${counts?.stepRunning ?? run.stepRunning} running` : '', + (counts?.stepSucceeded ?? run.stepSucceeded) ? `${counts?.stepSucceeded ?? run.stepSucceeded} succeeded` : '', + (counts?.stepFailed ?? run.stepFailed) ? `${counts?.stepFailed ?? run.stepFailed} failed` : '', + (counts?.stepSkipped ?? run.stepSkipped) ? `${counts?.stepSkipped ?? run.stepSkipped} skipped` : '', ].filter(Boolean) - return parts.join(' · ') || `${run.stepTotal} total` + return parts.join(' · ') || `${stepTotal} total` +} + +function formatScheduleDelay(run: WorkloadRun): string { + if (!run.scheduledAt || !run.startedAt) return '' + const scheduled = Date.parse(run.scheduledAt) + const started = Date.parse(run.startedAt) + if (Number.isNaN(scheduled) || Number.isNaN(started) || started < scheduled) return '' + return formatDuration(started - scheduled) +} + +function formatResourceDuration(resources: Record): string { + return Object.entries(resources) + .map(([resource, seconds]) => `${resource}: ${formatDuration(seconds * 1000)}`) + .join(' · ') +} + +function jobPseudoSteps(run: WorkloadRun, resource: any): WorkflowExecutionNode[] { + const conditions = Array.isArray(resource?.status?.conditions) ? resource.status.conditions : [] + const conditionSteps = conditions + .filter((condition: any) => condition?.status === 'True') + .map((condition: any, index: number) => ({ + id: `condition-${condition.type || index}`, + name: condition.type || `condition-${index}`, + displayName: condition.type || `Condition ${index + 1}`, + type: 'JobCondition', + phase: condition.type === 'Failed' ? 'Failed' : condition.type === 'Complete' ? 'Succeeded' : 'Info', + message: condition.message || condition.reason, + startedAt: condition.lastProbeTime || condition.lastTransitionTime, + finishedAt: condition.lastTransitionTime, + parentIds: [], + childIds: [], + })) + if (conditionSteps.length > 0) return conditionSteps + return [ + { + id: 'pods', + name: 'pods', + displayName: podBreakdown(run) || workCount(run), + type: 'Pods', + phase: run.phase, + message: run.message, + startedAt: run.startedAt, + finishedAt: run.finishedAt, + parentIds: [], + childIds: [], + }, + ] +} + +function jobActivity(run: WorkloadRun, resource: any): WorkflowExecutionActivity[] { + const items: WorkflowExecutionActivity[] = [] + if (run.scheduledAt) items.push({ id: 'scheduled', at: run.scheduledAt, label: 'Scheduled', tone: 'info' }) + if (run.startedAt) items.push({ id: 'started', at: run.startedAt, label: 'Job started', tone: 'info' }) + const conditions = Array.isArray(resource?.status?.conditions) ? resource.status.conditions : [] + for (const condition of conditions) { + const at = condition.lastTransitionTime || condition.lastProbeTime + if (!at || condition.status !== 'True') continue + items.push({ + id: `condition-${condition.type}`, + at, + label: condition.type === 'Complete' ? 'Job completed' : condition.type === 'Failed' ? 'Job failed' : condition.type, + detail: condition.message || condition.reason, + tone: condition.type === 'Failed' ? 'danger' : condition.type === 'Complete' ? 'success' : 'info', + }) + } + if (items.length === 0 && run.finishedAt) { + items.push({ id: 'finished', at: run.finishedAt, label: `Job ${run.phase.toLowerCase()}`, detail: run.message, tone: run.phase === 'Failed' ? 'danger' : 'success' }) + } + return items.sort((a, b) => Date.parse(a.at) - Date.parse(b.at)) +} + +function lineageNodeClass(phase: string): string { + switch (phase) { + case 'Succeeded': + return 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300' + case 'Failed': + case 'Error': + return 'border-red-500/40 bg-red-500/10 text-red-700 dark:text-red-300' + case 'Running': + case 'Pending': + return 'border-amber-500/40 bg-amber-500/10 text-amber-700 dark:text-amber-300' + case 'Skipped': + case 'Omitted': + return 'border-theme-border bg-theme-hover text-theme-text-tertiary' + default: + return 'border-theme-border bg-theme-elevated text-theme-text-secondary' + } +} + +function toneDotClass(tone: string): string { + switch (tone) { + case 'success': + return 'bg-emerald-500' + case 'danger': + return 'bg-red-500' + case 'warning': + return 'bg-amber-500' + case 'info': + return 'bg-accent' + default: + return 'bg-theme-border' + } } function formatRunDuration(run: WorkloadRun): string { @@ -671,6 +982,12 @@ function pluralizeRuns(count: number): string { return count === 1 ? '1 retained run' : `${count} retained runs` } +function runKindPluralForSchedule(kind: string): string { + if (kind === 'CronJob') return 'Jobs' + if (kind === 'CronWorkflow') return 'Workflows' + return 'runs' +} + function pluralize(word: string, count: number): string { return count === 1 ? word : `${word}s` } diff --git a/web/src/components/workload/WorkloadView.tsx b/web/src/components/workload/WorkloadView.tsx index f7f42c37b..64ea83aee 100644 --- a/web/src/components/workload/WorkloadView.tsx +++ b/web/src/components/workload/WorkloadView.tsx @@ -542,7 +542,7 @@ export function WorkloadView({ renderLogsTab={(props) => } renderExpandedOverview={({ kind: k, apiKind, namespace: ns, name: n, resource: res }) => BATCH_EXECUTION_KINDS.has(k) && res ? ( - + ) : null } renderRelatedYaml={(ref) => } From 45ba20e2b2d6418801f431aa7dc6016beffcd77b Mon Sep 17 00:00:00 2001 From: Nadav Erell Date: Sun, 5 Jul 2026 13:06:52 +0300 Subject: [PATCH 4/7] Clarify batch run hierarchy --- internal/server/workload_logs.go | 14 +++++++- internal/server/workload_logs_test.go | 32 ++++++++++++++++++ .../applications/ApplicationDetail.tsx | 3 +- web/src/api/client.ts | 28 ++++++++++------ .../execution/BatchExecutionView.tsx | 33 ++++++++++++++----- 5 files changed, 89 insertions(+), 21 deletions(-) diff --git a/internal/server/workload_logs.go b/internal/server/workload_logs.go index 57955aee0..29d86e55a 100644 --- a/internal/server/workload_logs.go +++ b/internal/server/workload_logs.go @@ -59,6 +59,7 @@ type WorkloadRun struct { StartedAt string `json:"startedAt,omitempty"` FinishedAt string `json:"finishedAt,omitempty"` ScheduledAt string `json:"scheduledAt,omitempty"` + Trigger string `json:"trigger,omitempty"` Message string `json:"message,omitempty"` Succeeded int32 `json:"succeeded,omitempty"` @@ -644,6 +645,16 @@ func jobRunInfo(job *batchv1.Job) WorkloadRun { phase = "Failed" } + annotations := job.Annotations + scheduledAt := "" + trigger := "" + if annotations["cronjob.kubernetes.io/instantiate"] == "manual" { + trigger = "manual" + } else if v := annotations["batch.kubernetes.io/cronjob-scheduled-timestamp"]; v != "" { + scheduledAt = v + trigger = "schedule" + } + run := WorkloadRun{ Kind: "jobs", Namespace: job.Namespace, @@ -651,7 +662,8 @@ func jobRunInfo(job *batchv1.Job) WorkloadRun { Phase: phase, Active: phase == "Running" || phase == "Pending", StartedAt: formatMetaTime(job.Status.StartTime), - ScheduledAt: job.Annotations["batch.kubernetes.io/cronjob-scheduled-timestamp"], + ScheduledAt: scheduledAt, + Trigger: trigger, Succeeded: job.Status.Succeeded, Failed: job.Status.Failed, Running: job.Status.Active, diff --git a/internal/server/workload_logs_test.go b/internal/server/workload_logs_test.go index bb95ad642..469e28692 100644 --- a/internal/server/workload_logs_test.go +++ b/internal/server/workload_logs_test.go @@ -198,6 +198,38 @@ func TestJobRunInfoTreatsPendingAsActive(t *testing.T) { } } +func TestJobRunInfoDistinguishesManualAndScheduledCronRuns(t *testing.T) { + startedAt := metav1.NewTime(time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)) + manual := jobRunInfo(&batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: "nightly-manual-1", + Namespace: "ci", + Annotations: map[string]string{ + "cronjob.kubernetes.io/instantiate": "manual", + "batch.kubernetes.io/cronjob-scheduled-timestamp": "2026-01-02T02:00:00Z", + }, + }, + Status: batchv1.JobStatus{StartTime: &startedAt}, + }) + if manual.Trigger != "manual" || manual.ScheduledAt != "" { + t.Fatalf("manual run trigger/schedule = %q/%q, want manual/empty", manual.Trigger, manual.ScheduledAt) + } + + scheduled := jobRunInfo(&batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: "nightly-123", + Namespace: "ci", + Annotations: map[string]string{ + "batch.kubernetes.io/cronjob-scheduled-timestamp": "2026-01-02T02:00:00Z", + }, + }, + Status: batchv1.JobStatus{StartTime: &startedAt}, + }) + if scheduled.Trigger != "schedule" || scheduled.ScheduledAt != "2026-01-02T02:00:00Z" { + t.Fatalf("scheduled run trigger/schedule = %q/%q, want schedule/timestamp", scheduled.Trigger, scheduled.ScheduledAt) + } +} + func TestFormatMetaTime(t *testing.T) { timestamp := metav1.NewTime(time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)) if got := formatMetaTime(×tamp); got != "2026-01-02T03:04:05Z" { diff --git a/packages/k8s-ui/src/components/applications/ApplicationDetail.tsx b/packages/k8s-ui/src/components/applications/ApplicationDetail.tsx index adee260a2..f4f1f0bbd 100644 --- a/packages/k8s-ui/src/components/applications/ApplicationDetail.tsx +++ b/packages/k8s-ui/src/components/applications/ApplicationDetail.tsx @@ -256,6 +256,7 @@ export function ApplicationDetail({ app, onBack, renderWorkload, topology, topol // the app graph rather than silently rendering a different workload under a // URL that names the missing one. const selected = rawSelected !== null && !selectedWorkload ? null : rawSelected + const showBatchActivityPanel = batchActivity.length > 0 && (workloads.length === 1 || (appGraphAvailable && selected === null)) useEffect(() => { if (selectedWorkloadKey !== undefined && selectedWorkloadKey !== null && !selectedWorkload) { onSelectWorkload?.(null) @@ -398,7 +399,7 @@ export function ApplicationDetail({ app, onBack, renderWorkload, topology, topol )}
- {batchActivity.length > 0 && ( + {showBatchActivityPanel && ( setSelected(workloadKey(workload))} diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 1401aa07d..c1b53f921 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -2156,6 +2156,17 @@ export function useApplyResource() { // CronJob operations // ============================================================================ +function invalidateCronJobOperationQueries(queryClient: ReturnType, namespace: string, name: string) { + queryClient.invalidateQueries({ queryKey: ['resources', 'cronjobs'] }) + queryClient.invalidateQueries({ queryKey: ['resources', 'jobs'] }) + queryClient.invalidateQueries({ queryKey: ['resource', 'cronjobs', namespace, name] }) + queryClient.invalidateQueries({ queryKey: ['workload-runs', 'cronjobs', namespace, name] }) + queryClient.invalidateQueries({ queryKey: ['applications'] }) + queryClient.invalidateQueries({ queryKey: ['dashboard'] }) + queryClient.invalidateQueries({ queryKey: ['resource-counts'] }) + queryClient.invalidateQueries({ queryKey: ['topology'] }) +} + // Trigger a CronJob (create a Job from it) export function useTriggerCronJob() { const queryClient = useQueryClient() @@ -2175,10 +2186,8 @@ export function useTriggerCronJob() { errorMessage: 'Failed to trigger CronJob', successMessage: 'CronJob triggered', }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['resources', 'cronjobs'] }) - queryClient.invalidateQueries({ queryKey: ['resources', 'jobs'] }) - queryClient.invalidateQueries({ queryKey: ['topology'] }) + onSuccess: (_, variables) => { + invalidateCronJobOperationQueries(queryClient, variables.namespace, variables.name) }, }) } @@ -2202,9 +2211,8 @@ export function useSuspendCronJob() { errorMessage: 'Failed to suspend CronJob', successMessage: 'CronJob suspended', }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['resources', 'cronjobs'] }) - queryClient.invalidateQueries({ queryKey: ['topology'] }) + onSuccess: (_, variables) => { + invalidateCronJobOperationQueries(queryClient, variables.namespace, variables.name) }, }) } @@ -2228,9 +2236,8 @@ export function useResumeCronJob() { errorMessage: 'Failed to resume CronJob', successMessage: 'CronJob resumed', }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['resources', 'cronjobs'] }) - queryClient.invalidateQueries({ queryKey: ['topology'] }) + onSuccess: (_, variables) => { + invalidateCronJobOperationQueries(queryClient, variables.namespace, variables.name) }, }) } @@ -3559,6 +3566,7 @@ export interface WorkloadRun { startedAt?: string finishedAt?: string scheduledAt?: string + trigger?: 'manual' | 'schedule' | string message?: string succeeded?: number failed?: number diff --git a/web/src/components/execution/BatchExecutionView.tsx b/web/src/components/execution/BatchExecutionView.tsx index 6fc5a2a15..44eb2dffe 100644 --- a/web/src/components/execution/BatchExecutionView.tsx +++ b/web/src/components/execution/BatchExecutionView.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState, type ComponentType, type ReactNode } from 'react' +import { useEffect, useMemo, useRef, useState, type ComponentType, type ReactNode } from 'react' import { Activity, AlertTriangle, GitBranch, Layers3, Loader2, Terminal } from 'lucide-react' import { clsx } from 'clsx' import { EmptyState, FetchResult, StatusDot, mapHealthToTone } from '@skyhook-io/k8s-ui' @@ -69,14 +69,22 @@ export function BatchExecutionFullscreen({ kind, apiKind, namespace, name, resou const runs = scheduledRunsFor(kind, runsQuery.data?.runs, resource, apiKind, namespace, name) const defaultRun = useMemo(() => pickDefaultRun(runs), [runs]) const [selectedRunName, setSelectedRunName] = useState('') + const userSelectedRunRef = useRef(false) useEffect(() => { if (runs.length === 0) { + userSelectedRunRef.current = false setSelectedRunName('') return } + const nextDefaultRunName = defaultRun?.name ?? runs[0].name if (!runs.some((run) => run.name === selectedRunName)) { - setSelectedRunName(defaultRun?.name ?? runs[0].name) + userSelectedRunRef.current = false + setSelectedRunName(nextDefaultRunName) + return + } + if (!userSelectedRunRef.current && selectedRunName !== nextDefaultRunName) { + setSelectedRunName(nextDefaultRunName) } }, [runs, selectedRunName, defaultRun]) @@ -136,7 +144,10 @@ export function BatchExecutionFullscreen({ kind, apiKind, namespace, name, resou key={`${run.kind}/${run.namespace}/${run.name}`} run={run} selected={selectedRun?.name === run.name} - onClick={() => setSelectedRunName(run.name)} + onClick={() => { + userSelectedRunRef.current = true + setSelectedRunName(run.name) + }} /> ))}
@@ -178,7 +189,8 @@ export function BatchExecutionFullscreen({ kind, apiKind, namespace, name, resou
{RUN_KIND_LABEL[selectedRun.kind] ?? selectedRun.kind} {selectedRun.startedAt && started {formatAge(selectedRun.startedAt)}} - {selectedRun.scheduledAt && scheduled {formatAge(selectedRun.scheduledAt)}} + {selectedRun.trigger === 'manual' && manual trigger} + {selectedRun.scheduledAt && cron scheduled {formatAge(selectedRun.scheduledAt)}} {selectedRun.template && template {selectedRun.template}} {selectedResourceQuery.isFetching && refreshing}
@@ -449,11 +461,14 @@ function SourceFacts({ source }: { source: ReturnType }) { } function RunDetailList({ run, resource, workflowExecution }: { run: WorkloadRun; resource: any; workflowExecution: WorkflowExecutionModel | null }) { - const rows = [ + const rows: Array<[string, string]> = [ ['Started', run.startedAt ? formatAge(run.startedAt) : '-'], ['Finished', run.finishedAt ? formatAge(run.finishedAt) : run.active ? 'Running' : '-'], - ['Scheduled', run.scheduledAt ? formatAge(run.scheduledAt) : '-'], - ['Schedule delay', formatScheduleDelay(run) || '-'], + ['Trigger', run.trigger === 'manual' ? 'Manual' : run.scheduledAt ? 'Cron schedule' : '-'], + ...(run.scheduledAt ? [ + ['Cron scheduled', formatAge(run.scheduledAt)], + ['Start delay', formatScheduleDelay(run) || '-'], + ] as Array<[string, string]> : []), ['Parallelism', run.parallelism ? String(run.parallelism) : '-'], ['Pods', podBreakdown(run, workflowExecution) || '-'], ['Steps', stepBreakdown(run, workflowExecution) || '-'], @@ -482,7 +497,7 @@ function RunMetrics({ run, resource, workflowExecution, retainedRuns }: { run: W ['Active retained', activeRuns ? String(activeRuns) : '0'], ['Failed retained', failedRuns ? String(failedRuns) : '0'], ['Current duration', formatRunDuration(run) || '-'], - ['Schedule delay', formatScheduleDelay(run) || '-'], + ['Start delay', run.scheduledAt ? formatScheduleDelay(run) || '-' : '-'], ['Completions', jobSpec.completions != null ? String(jobSpec.completions) : run.desired ? String(run.desired) : '-'], ['Resource duration', resourceDuration || '-'], ] @@ -893,7 +908,7 @@ function jobPseudoSteps(run: WorkloadRun, resource: any): WorkflowExecutionNode[ function jobActivity(run: WorkloadRun, resource: any): WorkflowExecutionActivity[] { const items: WorkflowExecutionActivity[] = [] - if (run.scheduledAt) items.push({ id: 'scheduled', at: run.scheduledAt, label: 'Scheduled', tone: 'info' }) + if (run.scheduledAt) items.push({ id: 'scheduled', at: run.scheduledAt, label: 'Cron scheduled', tone: 'info' }) if (run.startedAt) items.push({ id: 'started', at: run.startedAt, label: 'Job started', tone: 'info' }) const conditions = Array.isArray(resource?.status?.conditions) ? resource.status.conditions : [] for (const condition of conditions) { From 1844cfa076976e9f80982b3745b53ced579a8b61 Mon Sep 17 00:00:00 2001 From: Nadav Erell Date: Sun, 5 Jul 2026 13:19:47 +0300 Subject: [PATCH 5/7] Clarify direct batch run views --- .../execution/BatchExecutionView.tsx | 106 ++++++++++-------- 1 file changed, 57 insertions(+), 49 deletions(-) diff --git a/web/src/components/execution/BatchExecutionView.tsx b/web/src/components/execution/BatchExecutionView.tsx index 44eb2dffe..62c29b9fe 100644 --- a/web/src/components/execution/BatchExecutionView.tsx +++ b/web/src/components/execution/BatchExecutionView.tsx @@ -109,51 +109,51 @@ export function BatchExecutionFullscreen({ kind, apiKind, namespace, name, resou return (
- +
+ ) : ( +
+ {runs.map((run) => ( + { + userSelectedRunRef.current = true + setSelectedRunName(run.name) + }} + /> + ))} +
+ )} + + + )}
@@ -198,9 +198,9 @@ export function BatchExecutionFullscreen({ kind, apiKind, namespace, name, resou {selectedRun.phase}
- +
- +
@@ -460,11 +460,13 @@ function SourceFacts({ source }: { source: ReturnType }) { ) } -function RunDetailList({ run, resource, workflowExecution }: { run: WorkloadRun; resource: any; workflowExecution: WorkflowExecutionModel | null }) { +function RunDetailList({ run, resource, workflowExecution, scheduledParent }: { run: WorkloadRun; resource: any; workflowExecution: WorkflowExecutionModel | null; scheduledParent: boolean }) { const rows: Array<[string, string]> = [ ['Started', run.startedAt ? formatAge(run.startedAt) : '-'], ['Finished', run.finishedAt ? formatAge(run.finishedAt) : run.active ? 'Running' : '-'], - ['Trigger', run.trigger === 'manual' ? 'Manual' : run.scheduledAt ? 'Cron schedule' : '-'], + ...(scheduledParent || run.trigger || run.scheduledAt ? [ + ['Trigger', run.trigger === 'manual' ? 'Manual' : run.scheduledAt ? 'Cron schedule' : '-'], + ] as Array<[string, string]> : []), ...(run.scheduledAt ? [ ['Cron scheduled', formatAge(run.scheduledAt)], ['Start delay', formatScheduleDelay(run) || '-'], @@ -487,17 +489,21 @@ function RunDetailList({ run, resource, workflowExecution }: { run: WorkloadRun; ) } -function RunMetrics({ run, resource, workflowExecution, retainedRuns }: { run: WorkloadRun; resource: any; workflowExecution: WorkflowExecutionModel | null; retainedRuns: WorkloadRun[] }) { +function RunMetrics({ run, resource, workflowExecution, retainedRuns, scheduledParent }: { run: WorkloadRun; resource: any; workflowExecution: WorkflowExecutionModel | null; retainedRuns: WorkloadRun[]; scheduledParent: boolean }) { const failedRuns = retainedRuns.filter((r) => r.phase === 'Failed' || r.phase === 'Error').length const activeRuns = retainedRuns.filter((r) => r.active).length const resourceDuration = workflowExecution?.resourcesDuration ? formatResourceDuration(workflowExecution.resourcesDuration) : '' const jobSpec = resource?.spec ?? {} const rows = [ - ['Retained runs', retainedRuns.length ? String(retainedRuns.length) : '-'], - ['Active retained', activeRuns ? String(activeRuns) : '0'], - ['Failed retained', failedRuns ? String(failedRuns) : '0'], + ...(scheduledParent ? [ + ['Retained runs', retainedRuns.length ? String(retainedRuns.length) : '-'], + ['Active retained', activeRuns ? String(activeRuns) : '0'], + ['Failed retained', failedRuns ? String(failedRuns) : '0'], + ] as Array<[string, string]> : []), ['Current duration', formatRunDuration(run) || '-'], - ['Start delay', run.scheduledAt ? formatScheduleDelay(run) || '-' : '-'], + ...(run.scheduledAt ? [ + ['Start delay', formatScheduleDelay(run) || '-'], + ] as Array<[string, string]> : []), ['Completions', jobSpec.completions != null ? String(jobSpec.completions) : run.desired ? String(run.desired) : '-'], ['Resource duration', resourceDuration || '-'], ] @@ -512,9 +518,11 @@ function RunMetrics({ run, resource, workflowExecution, retainedRuns }: { run: W ))} -

- Retained counts reflect objects still present in Kubernetes, not all-time history. -

+ {scheduledParent && ( +

+ Retained counts reflect objects still present in Kubernetes, not all-time history. +

+ )} ) } From 1b109f0c009185d9a785535e405029c4a14b0127 Mon Sep 17 00:00:00 2001 From: Nadav Erell Date: Sun, 5 Jul 2026 13:26:28 +0300 Subject: [PATCH 6/7] Keep batch logs in logs tab --- .../execution/BatchExecutionView.tsx | 23 +------------------ 1 file changed, 1 insertion(+), 22 deletions(-) diff --git a/web/src/components/execution/BatchExecutionView.tsx b/web/src/components/execution/BatchExecutionView.tsx index 62c29b9fe..11e420ac3 100644 --- a/web/src/components/execution/BatchExecutionView.tsx +++ b/web/src/components/execution/BatchExecutionView.tsx @@ -1,10 +1,9 @@ import { useEffect, useMemo, useRef, useState, type ComponentType, type ReactNode } from 'react' -import { Activity, AlertTriangle, GitBranch, Layers3, Loader2, Terminal } from 'lucide-react' +import { Activity, AlertTriangle, GitBranch, Layers3, Loader2 } from 'lucide-react' import { clsx } from 'clsx' import { EmptyState, FetchResult, StatusDot, mapHealthToTone } from '@skyhook-io/k8s-ui' import { buildWorkflowExecutionModel, type WorkflowExecutionActivity, type WorkflowExecutionModel, type WorkflowExecutionNode, type WorkflowTemplateReference } from '@skyhook-io/k8s-ui/utils/workflow-execution' import { useResource, useWorkloadRuns, type WorkloadRun } from '../../api/client' -import { WorkloadLogsViewer } from '../logs/WorkloadLogsViewer' const EMPTY_RUNS: WorkloadRun[] = [] const SCHEDULED_KINDS = new Set(['CronJob', 'CronWorkflow']) @@ -236,26 +235,6 @@ export function BatchExecutionFullscreen({ kind, apiKind, namespace, name, resou )} - {selectedRun && ( -
-
-
- - Logs -
- {selectedRun.namespace}/{selectedRun.name} -
-
- -
-
- )} From 0b76739946d392ec4d3b736aca5ae1057a1078a1 Mon Sep 17 00:00:00 2001 From: Nadav Erell Date: Sun, 5 Jul 2026 13:40:21 +0300 Subject: [PATCH 7/7] Focus application workload drill-in --- .../applications/ApplicationDetail.tsx | 210 +++++++++++------- 1 file changed, 134 insertions(+), 76 deletions(-) diff --git a/packages/k8s-ui/src/components/applications/ApplicationDetail.tsx b/packages/k8s-ui/src/components/applications/ApplicationDetail.tsx index f4f1f0bbd..a1937f350 100644 --- a/packages/k8s-ui/src/components/applications/ApplicationDetail.tsx +++ b/packages/k8s-ui/src/components/applications/ApplicationDetail.tsx @@ -219,8 +219,6 @@ export function ApplicationDetail({ app, onBack, renderWorkload, topology, topol [app.workloads], ) const overall = worstHealth([app.health, ...workloads.map((w) => w.health)]) - const verdictTone = HEALTH_META[overall].pill - const verdictLabel = HEALTH_META[overall].label const workloadClass = workloadClassOf(app.workload_class) const versions = useMemo(() => Array.from(new Set((app.versions || []).filter(Boolean))), [app.versions]) const ready = workloads.reduce((n, w) => n + (w.ready ?? 0), 0) @@ -256,6 +254,14 @@ export function ApplicationDetail({ app, onBack, renderWorkload, topology, topol // the app graph rather than silently rendering a different workload under a // URL that names the missing one. const selected = rawSelected !== null && !selectedWorkload ? null : rawSelected + const focusedWorkload = appGraphAvailable && selectedWorkload ? selectedWorkload : null + const focusedWorkloadClass = focusedWorkload ? workloadClassOf(focusedWorkload.workload_class) : null + const headerHealth = focusedWorkload ? healthOf(focusedWorkload.health) : overall + const verdictTone = HEALTH_META[headerHealth].pill + const verdictLabel = HEALTH_META[headerHealth].label + const headerReady = focusedWorkload ? focusedWorkload.ready : ready + const headerDesired = focusedWorkload ? focusedWorkload.desired : desired + const focusedVersion = focusedWorkload?.appVersion || focusedWorkload?.version const showBatchActivityPanel = batchActivity.length > 0 && (workloads.length === 1 || (appGraphAvailable && selected === null)) useEffect(() => { if (selectedWorkloadKey !== undefined && selectedWorkloadKey !== null && !selectedWorkload) { @@ -330,14 +336,33 @@ export function ApplicationDetail({ app, onBack, renderWorkload, topology, topol -

{app.name}

- - - - + {focusedWorkload ? ( + <> +
+
+ + / + {focusedWorkload.kind} +
+

{focusedWorkload.name}

+
+ {focusedWorkload.kind} + {focusedWorkloadClass && } + + ) : ( + <> +

{app.name}

+ + + + + + )}
- + {verdictLabel} {restartSignal && ( @@ -359,43 +384,68 @@ export function ApplicationDetail({ app, onBack, renderWorkload, topology, topol {/* Context strip */}
- {identityInstances && identityInstances.length > 1 ? ( - // The Environment fact IS the switcher when this app runs in several - // envs — prominent, in existing header space, no extra row. Inline - // pills for a handful; a picker beyond that (scales to ~any count). -
- Environment - -
- ) : env ? ( - - {inferred ? ( - - ~{env} - - ) : ( - env + {focusedWorkload ? ( + <> + + + + {focusedWorkload.namespace && ( + + {focusedWorkload.namespace} + )} - - ) : null} - {namespace ? ( - - {namespace} - - ) : namespaces.length > 1 ? ( - - - {namespaces.length} namespaces - - - ) : null} - - - - {(app.appVersion || versions.length > 0) && ( - - - + + + + {focusedVersion && ( + + {midTruncate(focusedVersion, 32)} + + )} + + ) : ( + <> + {identityInstances && identityInstances.length > 1 ? ( + // The Environment fact IS the switcher when this app runs in several + // envs — prominent, in existing header space, no extra row. Inline + // pills for a handful; a picker beyond that (scales to ~any count). +
+ Environment + +
+ ) : env ? ( + + {inferred ? ( + + ~{env} + + ) : ( + env + )} + + ) : null} + {namespace ? ( + + {namespace} + + ) : namespaces.length > 1 ? ( + + + {namespaces.length} namespaces + + + ) : null} + + + + {(app.appVersion || versions.length > 0) && ( + + + + )} + )}
@@ -412,41 +462,49 @@ export function ApplicationDetail({ app, onBack, renderWorkload, topology, topol runtime. A single-workload app skips straight to its runtime. */} {workloads.length > 1 ? (
- -
- {appGraphAvailable && selected === null ? ( - + {renderWorkload(focusedWorkload)} +
+ ) : ( + <> + - ) : selected === null && topologyLoading ? ( - // The graph is this view's landing — hold a loader while the - // topology fetch is in flight instead of flashing the first - // workload's runtime (which would fire its data fetches too). - - ) : ( -
- {renderWorkload(selectedWorkload ?? workloads[0])} +
+ {appGraphAvailable && selected === null ? ( + + ) : selected === null && topologyLoading ? ( + // The graph is this view's landing — hold a loader while the + // topology fetch is in flight instead of flashing the first + // workload's runtime (which would fire its data fetches too). + + ) : ( +
+ {renderWorkload(selectedWorkload ?? workloads[0])} +
+ )}
- )} -
+ + )}
) : ( renderRuntime(workloads[0], renderWorkload)