From 8c77875067cc4a6fbc330b1ca1921f01f287cacf Mon Sep 17 00:00:00 2001 From: Morgan Wowk Date: Wed, 29 Apr 2026 15:31:17 -0700 Subject: [PATCH] fix: show contextual artifact unavailability message in preview dialog --- README.md | 16 ++++----- .../IOSection/ArtifactRetentionNotice.tsx | 27 ++++++++++++++ .../ArtifactPreviewError.tsx | 34 ++++++++++++++++++ .../ArtifactVisualizer/ArtifactVisualizer.tsx | 32 ++++++++++++++++- .../ArtifactVisualizer/useArtifactFetch.tsx | 8 ++++- .../TaskOverview/IOSection/IOSection.tsx | 35 ++++++++++++++----- .../IOSection/artifactRetentionUtils.ts | 9 +++++ src/services/executionService.ts | 18 +++++++++- src/utils/date.ts | 9 +++++ 9 files changed, 168 insertions(+), 20 deletions(-) create mode 100644 src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskOverview/IOSection/ArtifactRetentionNotice.tsx create mode 100644 src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskOverview/IOSection/IOCell/ArtifactVisualizer/ArtifactPreviewError.tsx create mode 100644 src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskOverview/IOSection/artifactRetentionUtils.ts diff --git a/README.md b/README.md index a037e33ad..68b5b439b 100644 --- a/README.md +++ b/README.md @@ -54,16 +54,16 @@ If you find you are blocked by CORS, you will, for now, need to use the manual s If you complete these steps the app will launch on `127.0.0.1:8000` with the latest build you've created on the frontend. -### Reporting errors to Bugsnag (Optional) +### Environment Variables -To enable error reporting, add the following to your `.env` file: +Add these to a `.env` file at the root of `tangle-ui`. -```bash -VITE_BUGSNAG_API_KEY=your-api-key -VITE_TANGLE_ENV=production -``` - -Both variables are required for Bugsnag to initialize. `VITE_TANGLE_ENV` sets the Bugsnag `releaseStage` (e.g. `development`, `staging`, `production`). If either variable is missing, error reporting will be disabled. +| Variable | Required | Description | +| ------------------------------ | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `VITE_BACKEND_API_URL` | For backend features | URL of your Tangle backend (e.g. `http://127.0.0.1:8000`). | +| `VITE_ARTIFACT_RETENTION_DAYS` | No | Number of days artifacts are stored by your backend. When set, the UI shows artifact expiry dates and warns when artifacts may no longer be available. | +| `VITE_BUGSNAG_API_KEY` | No | Bugsnag API key. Required alongside `VITE_TANGLE_ENV` to enable error reporting. | +| `VITE_TANGLE_ENV` | No | Release stage passed to Bugsnag (e.g. `development`, `staging`, `production`). | ## App features: diff --git a/src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskOverview/IOSection/ArtifactRetentionNotice.tsx b/src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskOverview/IOSection/ArtifactRetentionNotice.tsx new file mode 100644 index 000000000..fa8e7b2a5 --- /dev/null +++ b/src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskOverview/IOSection/ArtifactRetentionNotice.tsx @@ -0,0 +1,27 @@ +import { InfoBox } from "@/components/shared/InfoBox"; + +import { getArtifactRetentionDays } from "./artifactRetentionUtils"; + +interface ArtifactRetentionNoticeProps { + title: string; +} + +/** + * Section-level banner shown when artifacts from a pipeline run may have expired. + * Only renders retention context when VITE_ARTIFACT_RETENTION_DAYS is configured. + */ +export const ArtifactRetentionNotice = ({ + title, +}: ArtifactRetentionNoticeProps) => { + const retentionDays = getArtifactRetentionDays(); + const note = + retentionDays !== null + ? `Artifacts are expected to expire after ${retentionDays} days and may no longer be available in remote storage.` + : ""; + + return ( + + {note} + + ); +}; diff --git a/src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskOverview/IOSection/IOCell/ArtifactVisualizer/ArtifactPreviewError.tsx b/src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskOverview/IOSection/IOCell/ArtifactVisualizer/ArtifactPreviewError.tsx new file mode 100644 index 000000000..83908710b --- /dev/null +++ b/src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskOverview/IOSection/IOCell/ArtifactVisualizer/ArtifactPreviewError.tsx @@ -0,0 +1,34 @@ +import type { ComponentProps } from "react"; + +import { InfoBox } from "@/components/shared/InfoBox"; + +import { getArtifactRetentionDays } from "../../artifactRetentionUtils"; + +interface ArtifactPreviewErrorProps { + title: string; + preamble: string; + variant?: ComponentProps["variant"]; +} + +/** + * Error state shown inside the artifact preview dialog. + * Appends a retention hint when VITE_ARTIFACT_RETENTION_DAYS is configured. + */ +export const ArtifactPreviewError = ({ + title, + preamble, + variant = "error", +}: ArtifactPreviewErrorProps) => { + const retentionDays = getArtifactRetentionDays(); + const retentionNote = + retentionDays !== null + ? ` It may have expired — artifacts are expected to be available for up to ${retentionDays} days.` + : ""; + + return ( + + {preamble} + {retentionNote} + + ); +}; diff --git a/src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskOverview/IOSection/IOCell/ArtifactVisualizer/ArtifactVisualizer.tsx b/src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskOverview/IOSection/IOCell/ArtifactVisualizer/ArtifactVisualizer.tsx index 0d33f4f90..995d79150 100644 --- a/src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskOverview/IOSection/IOCell/ArtifactVisualizer/ArtifactVisualizer.tsx +++ b/src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskOverview/IOSection/IOCell/ArtifactVisualizer/ArtifactVisualizer.tsx @@ -19,10 +19,12 @@ import { Text } from "@/components/ui/typography"; import { cn } from "@/lib/utils"; import { useAnalytics } from "@/providers/AnalyticsProvider"; import { useBackend } from "@/providers/BackendProvider"; +import { ArtifactFetchError } from "@/services/executionService"; import { getArtifactSignedUrl } from "@/services/executionService"; import { HOURS } from "@/utils/constants"; import ArtifactURI from "../ArtifactURI"; +import { ArtifactPreviewError } from "./ArtifactPreviewError"; import { CsvVisualizerRemote, CsvVisualizerValue } from "./CsvVisualizer"; import ImageVisualizer from "./ImageVisualizer"; import { JsonVisualizerRemote, JsonVisualizerValue } from "./JsonVisualizer"; @@ -155,7 +157,35 @@ const ArtifactVisualizer = ({ isFullscreen={isFullscreen} /> ) : ( - }> + } + errorFallback={({ error }) => { + if ( + error instanceof ArtifactFetchError && + error.status === 404 && + import.meta.env.VITE_ARTIFACT_RETENTION_DAYS + ) { + return ( + + ); + } + + const statusDetail = + error instanceof ArtifactFetchError + ? ` (${error.status}${error.statusText ? ` ${error.statusText}` : ""})` + : ""; + return ( + + ); + }} + > ( queryKey: string, @@ -16,7 +18,11 @@ export function useArtifactFetch( queryFn: async () => { const response = await fetch(signedUrl); if (!response.ok) { - throw new Error(`(${response.status}) Failed to fetch artifact.`); + throw new ArtifactFetchError( + response.status, + response.statusText, + "Failed to fetch artifact.", + ); } return transform(response); diff --git a/src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskOverview/IOSection/IOSection.tsx b/src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskOverview/IOSection/IOSection.tsx index 09a8bf967..7f3e2ea3f 100644 --- a/src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskOverview/IOSection/IOSection.tsx +++ b/src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskOverview/IOSection/IOSection.tsx @@ -9,8 +9,10 @@ import { useExecutionData } from "@/providers/ExecutionDataProvider"; import { getExecutionArtifacts } from "@/services/executionService"; import { getBackendStatusString } from "@/utils/backend"; import type { TaskSpec } from "@/utils/componentSpec"; -import { isOlderThanDays } from "@/utils/date"; +import { addDays, formatDate, isOlderThanDays } from "@/utils/date"; +import { ArtifactRetentionNotice } from "./ArtifactRetentionNotice"; +import { getArtifactRetentionDays } from "./artifactRetentionUtils"; import IOExtras from "./IOExtras"; import IOInputs from "./IOInputs"; import IOOutputs from "./IOOutputs"; @@ -77,18 +79,33 @@ const IOSection = ({ taskSpec, executionId, readOnly }: IOSectionProps) => { ? ["inputs", "outputs", "other"] : ["outputs", "inputs", "other"]; - const isOlderThan30Days = - metadata?.created_at && isOlderThanDays(metadata.created_at, 30); + const retentionDays = getArtifactRetentionDays(); + + const isOlderThanRetentionPeriod = + retentionDays !== null && + metadata?.created_at && + isOlderThanDays(metadata.created_at, retentionDays); + + const expiryDate = + retentionDays !== null && + metadata?.created_at && + !isOlderThanRetentionPeriod + ? formatDate(addDays(metadata.created_at, retentionDays), { + month: "long", + day: "numeric", + year: "numeric", + }) + : null; return ( - {isOlderThan30Days && ( - - Remote artifacts may be unavailable for runs older than 30 days. To - keep an artifact, download it using the provided link before it - expires. + {isOlderThanRetentionPeriod ? ( + + ) : expiryDate ? ( + + Artifacts from this run expire on {expiryDate}. - )} + ) : null} {order.map((section) => { if (section === "inputs") { return ( diff --git a/src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskOverview/IOSection/artifactRetentionUtils.ts b/src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskOverview/IOSection/artifactRetentionUtils.ts new file mode 100644 index 000000000..9fac6b2b7 --- /dev/null +++ b/src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskOverview/IOSection/artifactRetentionUtils.ts @@ -0,0 +1,9 @@ +/** + * Returns the configured artifact retention period in days, or null if unset. + * When null, retention-related UI should be suppressed. + */ +export function getArtifactRetentionDays(): number | null { + return import.meta.env.VITE_ARTIFACT_RETENTION_DAYS + ? Number(import.meta.env.VITE_ARTIFACT_RETENTION_DAYS) + : null; +} diff --git a/src/services/executionService.ts b/src/services/executionService.ts index f3b493f6d..9d0d23757 100644 --- a/src/services/executionService.ts +++ b/src/services/executionService.ts @@ -119,6 +119,18 @@ export const fetchExecutionStatusLight = rateLimit( }, ); +export class ArtifactFetchError extends Error { + constructor( + public readonly status: number, + public readonly statusText: string, + message: string, + ) { + super(message); + this.name = "ArtifactFetchError"; + Object.setPrototypeOf(this, ArtifactFetchError.prototype); + } +} + export const getArtifactSignedUrl = async ( artifactId: string, backendUrl: string, @@ -127,7 +139,11 @@ export const getArtifactSignedUrl = async ( `${backendUrl}/api/artifacts/${artifactId}/signed_artifact_url`, ); if (!response.ok) { - throw new Error(`(${response.status}) Failed to get signed URL.`); + throw new ArtifactFetchError( + response.status, + response.statusText, + "Failed to get signed URL.", + ); } return response.json(); }; diff --git a/src/utils/date.ts b/src/utils/date.ts index 68a3420ab..38d8783c5 100644 --- a/src/utils/date.ts +++ b/src/utils/date.ts @@ -76,6 +76,15 @@ export const formatDuration = (startTime: string, endTime: string): string => { } }; +/** + * Return a new Date offset by the given number of days. + */ +export const addDays = (date: string | Date, days: number): Date => { + const result = new Date(date); + result.setDate(result.getDate() + days); + return result; +}; + /** * Check whether a date is older than a given number of calendar days ago. * @param date - Date string or object to check