Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<InfoBox title={title} variant="warning" width="full">
{note}
</InfoBox>
);
};
Original file line number Diff line number Diff line change
@@ -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<typeof InfoBox>["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 (
<InfoBox title={title} variant={variant} width="full">
{preamble}
{retentionNote}
</InfoBox>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -155,7 +157,35 @@ const ArtifactVisualizer = ({
isFullscreen={isFullscreen}
/>
) : (
<SuspenseWrapper fallback={<PreviewSkeleton />}>
<SuspenseWrapper
fallback={<PreviewSkeleton />}
errorFallback={({ error }) => {
if (
error instanceof ArtifactFetchError &&
error.status === 404 &&
import.meta.env.VITE_ARTIFACT_RETENTION_DAYS
) {
return (
<ArtifactPreviewError
title="Artifact unavailable"
preamble="This artifact could not be found."
variant="warning"
/>
);
}

const statusDetail =
error instanceof ArtifactFetchError
? ` (${error.status}${error.statusText ? ` ${error.statusText}` : ""})`
: "";
return (
<ArtifactPreviewError
title="Failed to load artifact"
preamble={`An unexpected error occurred${statusDetail}.`}
/>
);
}}
>
<PreviewContent
name={name}
artifactId={artifact.id}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { useSuspenseQuery } from "@tanstack/react-query";

import { ArtifactFetchError } from "@/services/executionService";
import { HOURS } from "@/utils/constants";

/**
* Fetches artifact content from a signed URL using suspense mode.
* Loading and error states are handled by the nearest SuspenseWrapper.
* Throws ArtifactFetchError on non-2xx responses so callers can branch on status.
*/
export function useArtifactFetch<T>(
queryKey: string,
Expand All @@ -16,7 +18,11 @@ export function useArtifactFetch<T>(
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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 (
<BlockStack gap="4" className="w-full">
{isOlderThan30Days && (
<InfoBox title="Artifact Storage" variant="warning">
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 ? (
<ArtifactRetentionNotice title="Artifact Storage" />
) : expiryDate ? (
<InfoBox title="Artifact Storage" variant="info" width="full">
Artifacts from this run expire on {expiryDate}.
</InfoBox>
)}
) : null}
{order.map((section) => {
if (section === "inputs") {
return (
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
18 changes: 17 additions & 1 deletion src/services/executionService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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();
};
Expand Down
9 changes: 9 additions & 0 deletions src/utils/date.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading