From 58b9837c1b4c65f5b7c03eaa59445c8484966702 Mon Sep 17 00:00:00 2001 From: Hazem Nureldin Date: Wed, 16 Sep 2026 11:48:00 +0100 Subject: [PATCH 1/6] fix: msg for tasks without a log --- .../graph-proxy/src/graphql/subscription.rs | 106 +++++++++++++++++- .../lib/views/TaskLogViewer.tsx | 106 ++++++++++++++---- 2 files changed, 186 insertions(+), 26 deletions(-) diff --git a/backend/graph-proxy/src/graphql/subscription.rs b/backend/graph-proxy/src/graphql/subscription.rs index 0999f1516..15a0ffc63 100644 --- a/backend/graph-proxy/src/graphql/subscription.rs +++ b/backend/graph-proxy/src/graphql/subscription.rs @@ -1,5 +1,9 @@ use crate::graphql::AuthGuard; -use argo_workflows_openapi::IoArgoprojWorkflowV1alpha1WorkflowWatchEvent; +use argo_workflows_openapi::{ + APIResult, + IoArgoprojWorkflowV1alpha1Workflow, + IoArgoprojWorkflowV1alpha1WorkflowWatchEvent, +}; use async_graphql::{Context, SimpleObject, Subscription}; use async_stream::stream; use eventsource_stream::Eventsource; @@ -66,6 +70,52 @@ pub fn get_auth_token(ctx: &Context<'_>) -> anyhow::Result { .ok_or_else(|| WorkflowParsingError::MissingAuthToken.into()) } +async fn is_workflow_completed( + server_url: &ArgoServerUrl, + auth_token: &str, + namespace: &str, + workflow_name: &str, +) -> anyhow::Result { + let mut url = server_url.deref().clone(); + + url.path_segments_mut() + .expect("Invalid base URL") + .extend([ + "api", + "v1", + "workflows", + namespace, + workflow_name, + ]); + + let workflow = reqwest::Client::new() + .get(url) + .bearer_auth(auth_token) + .send() + .await? + .json::>() + .await? + .into_result()?; + + let Some(status) = workflow.status else { + return Ok(false); + }; + + let phase = status.phase.as_deref(); + + tracing::info!( + "WORKFLOW_STATUS_CHECK namespace={} workflow={} phase={:?}", + namespace, + workflow_name, + phase + ); + + Ok(matches!( + phase, + Some("Succeeded") | Some("Failed") | Some("Error") + )) +} + #[Subscription(guard = "AuthGuard")] impl WorkflowsSubscription { /// Subscribe to logs for a single pod of a workflow. @@ -82,14 +132,15 @@ impl WorkflowsSubscription { ) -> anyhow::Result>> { let auth_token = get_auth_token(ctx)?; + let server_url = ctx.data_unchecked::().clone(); + if task_id.is_empty() || task_id == "__NO_TASK_SELECTED__" { return Err(anyhow::anyhow!( "A valid task ID is required to retrieve task logs" )); } - let server_url = ctx.data_unchecked::().deref().clone(); - let mut url = server_url; + let mut url = server_url.deref().clone(); let namespace = visit.to_string(); @@ -150,9 +201,47 @@ impl WorkflowsSubscription { } }; + // A terminal workflow with no archived log must never fall back to + // the live Argo log endpoint. The pod may already be gone, and there + // is nothing left that can produce additional log data. + let completed_without_log = if initial_archive.is_none() { + match is_workflow_completed( + &server_url, + &auth_token, + &namespace, + &workflow_name, + ) + .await + { + Ok(true) => { + tracing::info!( + "COMPLETED_WORKFLOW_WITHOUT_LOG task={} workflow={}", + task_id, + workflow_name + ); + true + } + + Ok(false) => false, + + Err(err) => { + tracing::warn!( + "FAILED_TO_CHECK_WORKFLOW_STATUS task={} workflow={} error={}", + task_id, + workflow_name, + err + ); + false + } + } + } else { + false + }; + // Only contact the Argo live-log endpoint when the archived - // main.log is not already available in S3. - let live_response = if initial_archive.is_none() { + // main.log is not already available in S3 and the workflow is still + // running. + let live_response = if initial_archive.is_none() && !completed_without_log { tracing::info!( "STARTING_LIVE_STREAM namespace={} workflow={} task={}", namespace, @@ -179,6 +268,11 @@ impl WorkflowsSubscription { let mut byte_stream = live_response.map(|response| response.bytes_stream()); let log_stream = stream! { + if completed_without_log { + yield Err("Log not available".to_string()); + return; + } + if let Some(archive_response) = initial_archive { let archive_bytes = match archive_response.body.collect().await { Ok(bytes) => bytes, @@ -404,7 +498,7 @@ impl WorkflowsSubscription { } Err(_err) => { - yield Err("No logs available".to_string()); + yield Err("Log not available".to_string()); return; } } diff --git a/frontend/relay-workflows-lib/lib/views/TaskLogViewer.tsx b/frontend/relay-workflows-lib/lib/views/TaskLogViewer.tsx index 7bb040a92..4995145bb 100644 --- a/frontend/relay-workflows-lib/lib/views/TaskLogViewer.tsx +++ b/frontend/relay-workflows-lib/lib/views/TaskLogViewer.tsx @@ -1,3 +1,4 @@ + import React, { Dispatch, SetStateAction, @@ -34,8 +35,11 @@ interface TaskLogSubscriptionProps { setLogLines: Dispatch>; setTaskCompleted: Dispatch>; setSubscriptionError: Dispatch>; + setLogUnavailable: Dispatch>; } +const LOG_NOT_AVAILABLE = "Log not available"; + const taskLogViewerSubscription = graphql` subscription TaskLogViewerSubscription( $visit: VisitInput! @@ -56,6 +60,7 @@ const TaskLogSubscription: React.FC = ({ setLogLines, setTaskCompleted, setSubscriptionError, + setLogUnavailable, }) => { const subscriptionConfig = useMemo< GraphQLSubscriptionConfig @@ -67,6 +72,7 @@ const TaskLogSubscription: React.FC = ({ workflowName, taskId, }, + onNext: (payload) => { const line = payload?.logs.content; @@ -74,23 +80,42 @@ const TaskLogSubscription: React.FC = ({ setLogLines((previousLines) => [...previousLines, line]); } }, + onError: (error) => { console.error("Log subscription error:", error); - const message = error instanceof Error ? error.message : String(error); + const message = + error instanceof Error ? error.message : String(error); - if ( + const logUnavailable = + message.includes(LOG_NOT_AVAILABLE) || message.includes("NoSuchKey") || message.includes("No logs") || - message.includes("Failed to retrieve archived log artifact") - ) { - setSubscriptionError("No logs available"); - } else { - setSubscriptionError("Unable to retrieve task logs"); + message.includes("Failed to retrieve archived log artifact"); + + if (logUnavailable) { + /* + * This is a terminal state. + * + * Do not allow the subscription to remain mounted because Relay + * may otherwise reconnect it and repeatedly request the same + * unavailable log. + */ + setSubscriptionError(LOG_NOT_AVAILABLE); + setLogUnavailable(true); + setTaskCompleted(true); + + return; } + /* + * Preserve the existing behaviour for non-terminal errors. + * These can still happen while a workflow is active. + */ + setSubscriptionError("Unable to retrieve task logs"); setTaskCompleted(true); }, + onCompleted: () => { setTaskCompleted(true); }, @@ -102,6 +127,7 @@ const TaskLogSubscription: React.FC = ({ setLogLines, setTaskCompleted, setSubscriptionError, + setLogUnavailable, ], ); @@ -121,10 +147,34 @@ const TaskLogViewerContent: React.FC = ({ const [subscriptionError, setSubscriptionError] = useState( null, ); + + /* + * Once the backend tells us that no archived log exists, this prevents + * the Relay subscription from being mounted again. + */ + const [logUnavailable, setLogUnavailable] = useState(false); + const [expanded, setExpanded] = useState(Boolean(selectedTaskId)); const containerRef = useRef(null); + /* + * Reset all task-specific log state whenever the selected task changes. + * + * The parent component also uses a key based on selectedTaskId, but doing + * this explicitly makes the component safe if that key behaviour changes. + */ + useEffect(() => { + setLogLines([]); + setTaskCompleted(false); + setSubscriptionError(null); + setLogUnavailable(false); + setExpanded(Boolean(selectedTaskId)); + }, [selectedTaskId]); + + /* + * Keep the log view scrolled to the newest line. + */ useEffect(() => { if (containerRef.current) { containerRef.current.scrollTop = containerRef.current.scrollHeight; @@ -133,7 +183,7 @@ const TaskLogViewerContent: React.FC = ({ return ( <> - {selectedTaskId && ( + {selectedTaskId && !logUnavailable && ( = ({ setLogLines={setLogLines} setTaskCompleted={setTaskCompleted} setSubscriptionError={setSubscriptionError} + setLogUnavailable={setLogUnavailable} /> )} @@ -169,17 +220,19 @@ const TaskLogViewerContent: React.FC = ({ Logs: {selectedTaskName ?? selectedTaskId ?? "No task selected"} - {selectedTaskId && !taskCompleted && ( - - )} + {selectedTaskId && + !taskCompleted && + !logUnavailable && ( + + )} - {selectedTaskId && taskCompleted && ( + {selectedTaskId && taskCompleted && !logUnavailable && ( = ({ whiteSpace: "pre-wrap", }} > - {subscriptionError ? ( + {logUnavailable ? ( + + {LOG_NOT_AVAILABLE} + + ) : subscriptionError ? ( = ({ }) => { return ( = ({ /> ); }; + From f5b55d4518cf6e052ecd3d25f2b1af66e18cff46 Mon Sep 17 00:00:00 2001 From: Hazem Nureldin Date: Wed, 16 Sep 2026 12:13:39 +0100 Subject: [PATCH 2/6] style: apply rustfmt formatting --- .../graph-proxy/src/graphql/subscription.rs | 28 ++++++------------- 1 file changed, 9 insertions(+), 19 deletions(-) diff --git a/backend/graph-proxy/src/graphql/subscription.rs b/backend/graph-proxy/src/graphql/subscription.rs index 15a0ffc63..0be7c88d2 100644 --- a/backend/graph-proxy/src/graphql/subscription.rs +++ b/backend/graph-proxy/src/graphql/subscription.rs @@ -1,8 +1,6 @@ use crate::graphql::AuthGuard; use argo_workflows_openapi::{ - APIResult, - IoArgoprojWorkflowV1alpha1Workflow, - IoArgoprojWorkflowV1alpha1WorkflowWatchEvent, + APIResult, IoArgoprojWorkflowV1alpha1Workflow, IoArgoprojWorkflowV1alpha1WorkflowWatchEvent, }; use async_graphql::{Context, SimpleObject, Subscription}; use async_stream::stream; @@ -78,15 +76,13 @@ async fn is_workflow_completed( ) -> anyhow::Result { let mut url = server_url.deref().clone(); - url.path_segments_mut() - .expect("Invalid base URL") - .extend([ - "api", - "v1", - "workflows", - namespace, - workflow_name, - ]); + url.path_segments_mut().expect("Invalid base URL").extend([ + "api", + "v1", + "workflows", + namespace, + workflow_name, + ]); let workflow = reqwest::Client::new() .get(url) @@ -205,13 +201,7 @@ impl WorkflowsSubscription { // the live Argo log endpoint. The pod may already be gone, and there // is nothing left that can produce additional log data. let completed_without_log = if initial_archive.is_none() { - match is_workflow_completed( - &server_url, - &auth_token, - &namespace, - &workflow_name, - ) - .await + match is_workflow_completed(&server_url, &auth_token, &namespace, &workflow_name).await { Ok(true) => { tracing::info!( From 1de784f5fc55a54a4f8131db2750a32b12a35a89 Mon Sep 17 00:00:00 2001 From: Hazem Nureldin Date: Wed, 16 Sep 2026 12:20:07 +0100 Subject: [PATCH 3/6] docs: add documentation for workflow completion helper --- backend/graph-proxy/src/graphql/subscription.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/backend/graph-proxy/src/graphql/subscription.rs b/backend/graph-proxy/src/graphql/subscription.rs index 0be7c88d2..3885d3f5d 100644 --- a/backend/graph-proxy/src/graphql/subscription.rs +++ b/backend/graph-proxy/src/graphql/subscription.rs @@ -68,6 +68,8 @@ pub fn get_auth_token(ctx: &Context<'_>) -> anyhow::Result { .ok_or_else(|| WorkflowParsingError::MissingAuthToken.into()) } +/// Checks whether the specified workflow has completed by querying +/// the Argo Workflows API and inspecting the workflow phase. async fn is_workflow_completed( server_url: &ArgoServerUrl, auth_token: &str, From bd77011dd4d39dd868f9e6caa95a9b8e3bfa4d1a Mon Sep 17 00:00:00 2001 From: Hazem Nureldin Date: Wed, 16 Sep 2026 12:41:25 +0100 Subject: [PATCH 4/6] style: apply prettier formatting --- .../lib/views/TaskLogViewer.tsx | 25 ++++++++----------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/frontend/relay-workflows-lib/lib/views/TaskLogViewer.tsx b/frontend/relay-workflows-lib/lib/views/TaskLogViewer.tsx index 4995145bb..25c120c91 100644 --- a/frontend/relay-workflows-lib/lib/views/TaskLogViewer.tsx +++ b/frontend/relay-workflows-lib/lib/views/TaskLogViewer.tsx @@ -1,4 +1,3 @@ - import React, { Dispatch, SetStateAction, @@ -84,8 +83,7 @@ const TaskLogSubscription: React.FC = ({ onError: (error) => { console.error("Log subscription error:", error); - const message = - error instanceof Error ? error.message : String(error); + const message = error instanceof Error ? error.message : String(error); const logUnavailable = message.includes(LOG_NOT_AVAILABLE) || @@ -220,17 +218,15 @@ const TaskLogViewerContent: React.FC = ({ Logs: {selectedTaskName ?? selectedTaskId ?? "No task selected"} - {selectedTaskId && - !taskCompleted && - !logUnavailable && ( - - )} + {selectedTaskId && !taskCompleted && !logUnavailable && ( + + )} {selectedTaskId && taskCompleted && !logUnavailable && ( = ({ /> ); }; - From ca4641f8f3666c7d2b6171bd71ecc92f1faf4279 Mon Sep 17 00:00:00 2001 From: Hazem Nureldin Date: Wed, 16 Sep 2026 15:26:38 +0100 Subject: [PATCH 5/6] fix(Workflows User Interface): proper handling of task logs that has no archieve --- .../lib/views/TaskLogViewer.tsx | 14 ------- .../components/BaseWorkflowRelay.test.tsx | 2 - .../lib/components/common/StatusIcons.tsx | 40 +++++++++++++++++-- .../lib/components/workflow/TasksFlowNode.tsx | 6 +-- 4 files changed, 38 insertions(+), 24 deletions(-) diff --git a/frontend/relay-workflows-lib/lib/views/TaskLogViewer.tsx b/frontend/relay-workflows-lib/lib/views/TaskLogViewer.tsx index 25c120c91..2328d8921 100644 --- a/frontend/relay-workflows-lib/lib/views/TaskLogViewer.tsx +++ b/frontend/relay-workflows-lib/lib/views/TaskLogViewer.tsx @@ -156,20 +156,6 @@ const TaskLogViewerContent: React.FC = ({ const containerRef = useRef(null); - /* - * Reset all task-specific log state whenever the selected task changes. - * - * The parent component also uses a key based on selectedTaskId, but doing - * this explicitly makes the component safe if that key behaviour changes. - */ - useEffect(() => { - setLogLines([]); - setTaskCompleted(false); - setSubscriptionError(null); - setLogUnavailable(false); - setExpanded(Boolean(selectedTaskId)); - }, [selectedTaskId]); - /* * Keep the log view scrolled to the newest line. */ diff --git a/frontend/relay-workflows-lib/tests/components/BaseWorkflowRelay.test.tsx b/frontend/relay-workflows-lib/tests/components/BaseWorkflowRelay.test.tsx index 0a5916c82..2bb33a108 100644 --- a/frontend/relay-workflows-lib/tests/components/BaseWorkflowRelay.test.tsx +++ b/frontend/relay-workflows-lib/tests/components/BaseWorkflowRelay.test.tsx @@ -68,10 +68,8 @@ describe("BaseWorkflowRelay", () => { expect( await screen.findByText("conditional-steps-first"), ).toBeInTheDocument(); - expect(screen.getByTitle("abc12345")).toBeInTheDocument(); }); - it("should display flow box nodes when expanded", async () => { const accordionButton = await screen.findByRole("button", { name: /conditional-steps-first/i, diff --git a/frontend/workflows-lib/lib/components/common/StatusIcons.tsx b/frontend/workflows-lib/lib/components/common/StatusIcons.tsx index 7212324fd..3c03f5347 100644 --- a/frontend/workflows-lib/lib/components/common/StatusIcons.tsx +++ b/frontend/workflows-lib/lib/components/common/StatusIcons.tsx @@ -6,20 +6,52 @@ import ReportProblemTwoToneIcon from "@mui/icons-material/ReportProblemTwoTone"; import CancelTwoToneIcon from "@mui/icons-material/CancelTwoTone"; import SkipNextTwoToneIcon from "@mui/icons-material/SkipNextTwoTone"; import QuestionMarkIcon from "@mui/icons-material/QuestionMark"; -import { Tooltip } from "@mui/material"; +import { keyframes, Tooltip } from "@mui/material"; import { TaskStatus, WorkflowStatus } from "../../types"; import React from "react"; -export function getTaskStatusIcon(status: TaskStatus, size: number = 25) { +const pulse = keyframes` + 0% { + transform: scale(1); + filter: drop-shadow(0 0 0px currentColor); + } + + 50% { + transform: scale(1.25); + filter: drop-shadow(0 0 5px currentColor); + } + + 100% { + transform: scale(1); + filter: drop-shadow(0 0 0px currentColor); + } +`; + +export function getTaskStatusIcon(status: TaskStatus, size: number = 30) { const TaskStatusIconMap: { [key in TaskStatus]: React.JSX.Element } = { PENDING: ( - + ), + RUNNING: ( - + ), SUCCEEDED: ( diff --git a/frontend/workflows-lib/lib/components/workflow/TasksFlowNode.tsx b/frontend/workflows-lib/lib/components/workflow/TasksFlowNode.tsx index 890aff084..e0255cea9 100644 --- a/frontend/workflows-lib/lib/components/workflow/TasksFlowNode.tsx +++ b/frontend/workflows-lib/lib/components/workflow/TasksFlowNode.tsx @@ -26,7 +26,6 @@ const TaskFlowNode: React.FC = ({ data, onNavigate }) => { const handleOpenTaskPage = useCallback( (event: React.MouseEvent) => { - // Do not let React Flow interpret this click as a canvas interaction. event.preventDefault(); event.stopPropagation(); @@ -56,8 +55,6 @@ const TaskFlowNode: React.FC = ({ data, onNavigate }) => { backgroundColor: data.filled ? "rgba(62, 218, 0, 1)" : undefined, cursor: "pointer", - - // Make sure the node itself receives the pointer event. pointerEvents: "auto", }} > @@ -69,6 +66,7 @@ const TaskFlowNode: React.FC = ({ data, onNavigate }) => { }} data-testid="handle-target" onClick={(event) => { + event.preventDefault(); event.stopPropagation(); }} /> @@ -82,7 +80,6 @@ const TaskFlowNode: React.FC = ({ data, onNavigate }) => { height="100%" width="100%" maxHeight={60} - onClick={handleOpenTaskPage} > = ({ data, onNavigate }) => { }} data-testid="handle-source" onClick={(event) => { + event.preventDefault(); event.stopPropagation(); }} /> From aec043589f3d87171e9ff4f92460fa34e90e8a8b Mon Sep 17 00:00:00 2001 From: Hazem Nureldin Date: Fri, 18 Sep 2026 13:34:37 +0100 Subject: [PATCH 6/6] chore: bump version to 0.1.14 --- .release-please-manifest.json | 4 ++-- frontend/relay-workflows-lib/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index e6d0454eb..57a1143cd 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -7,9 +7,9 @@ "+3": "0.0.0", "frontend/dashboard": "0.1.18", "+4": "0.0.0", - "frontend/relay-workflows-lib": "0.1.13", + "frontend/relay-workflows-lib": "0.1.14", "+5": "0.0.0", - "frontend/workflows-lib": "0.1.9", + "frontend/workflows-lib": "0.1.10", "frontend/workflows-lib-shared": "0.1.1", "+6": "0.0.0", "backend/telemetry": "0.1.2", diff --git a/frontend/relay-workflows-lib/package.json b/frontend/relay-workflows-lib/package.json index b0c7be84a..a77bfad98 100644 --- a/frontend/relay-workflows-lib/package.json +++ b/frontend/relay-workflows-lib/package.json @@ -1,7 +1,7 @@ { "name": "relay-workflows-lib", "private": true, - "version": "0.1.13", + "version": "0.1.14", "type": "module", "main": "lib/main.ts", "scripts": {