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