artifacts!

This commit is contained in:
2026-03-03 13:42:41 -06:00
parent 5da940639a
commit 8299e5efcb
50 changed files with 4779 additions and 341 deletions

View File

@@ -26,6 +26,10 @@ const ExecutionsPage = lazy(() => import("@/pages/executions/ExecutionsPage"));
const ExecutionDetailPage = lazy(
() => import("@/pages/executions/ExecutionDetailPage"),
);
const ArtifactsPage = lazy(() => import("@/pages/artifacts/ArtifactsPage"));
const ArtifactDetailPage = lazy(
() => import("@/pages/artifacts/ArtifactDetailPage"),
);
const EventsPage = lazy(() => import("@/pages/events/EventsPage"));
const EventDetailPage = lazy(() => import("@/pages/events/EventDetailPage"));
const EnforcementsPage = lazy(
@@ -99,6 +103,11 @@ function App() {
path="executions/:id"
element={<ExecutionDetailPage />}
/>
<Route path="artifacts" element={<ArtifactsPage />} />
<Route
path="artifacts/:id"
element={<ArtifactDetailPage />}
/>
<Route path="events" element={<EventsPage />} />
<Route path="events/:id" element={<EventDetailPage />} />
<Route path="enforcements" element={<EnforcementsPage />} />

View File

@@ -21,6 +21,7 @@ import {
type ArtifactSummary,
type ArtifactType,
} from "@/hooks/useArtifacts";
import { useArtifactStream } from "@/hooks/useArtifactStream";
import { OpenAPI } from "@/api/core/OpenAPI";
interface ExecutionArtifactsPanelProps {
@@ -349,6 +350,11 @@ export default function ExecutionArtifactsPanel({
null,
);
// Subscribe to real-time artifact notifications for this execution.
// WebSocket-driven cache invalidation replaces most of the polling need,
// but we keep polling as a fallback (staleTime/refetchInterval in the hook).
useArtifactStream({ executionId, enabled: isRunning });
const { data, isLoading, error } = useExecutionArtifacts(
executionId,
isRunning,

View File

@@ -0,0 +1,109 @@
import { useMemo } from "react";
import { BarChart3 } from "lucide-react";
import {
useExecutionArtifacts,
type ArtifactSummary,
} from "@/hooks/useArtifacts";
import { useArtifactStream, useArtifactProgress } from "@/hooks/useArtifactStream";
interface ExecutionProgressBarProps {
executionId: number;
/** Whether the execution is still running (enables real-time updates) */
isRunning: boolean;
}
/**
* Inline progress bar for executions that have progress-type artifacts.
*
* Combines two data sources for responsiveness:
* 1. **Polling**: `useExecutionArtifacts` fetches the artifact list periodically
* so we can detect when a progress artifact first appears and read its initial state.
* 2. **WebSocket**: `useArtifactStream` subscribes to real-time `artifact_updated`
* notifications, which include the latest `progress_percent` and `progress_message`
* extracted by the database trigger — providing instant updates between polls.
*
* The WebSocket-pushed summary takes precedence when available (it's newer), with
* the polled data as a fallback for the initial render before any WS message arrives.
*
* Renders nothing if no progress artifact exists for this execution.
*/
export default function ExecutionProgressBar({
executionId,
isRunning,
}: ExecutionProgressBarProps) {
// Subscribe to real-time artifact updates for this execution
useArtifactStream({ executionId, enabled: isRunning });
// Read the latest progress pushed via WebSocket (no API call)
const wsSummary = useArtifactProgress(executionId);
// Poll-based artifact list (fallback + initial detection)
const { data } = useExecutionArtifacts(
executionId,
isRunning,
);
// Find progress artifacts from the polled data
const progressArtifact = useMemo<ArtifactSummary | null>(() => {
const artifacts = data?.data ?? [];
return artifacts.find((a) => a.type === "progress") ?? null;
}, [data]);
// If there's no progress artifact at all, render nothing
if (!progressArtifact && !wsSummary) {
return null;
}
// Prefer the WS-pushed summary (more current), fall back to indicating
// that a progress artifact exists but we haven't received detail yet.
const percent = wsSummary?.percent ?? null;
const message = wsSummary?.message ?? null;
const name = wsSummary?.name ?? progressArtifact?.name ?? "Progress";
// If we have a progress artifact but no percent yet (first poll, no WS yet),
// show an indeterminate state
const hasPercent = percent != null;
const clampedPercent = hasPercent ? Math.min(Math.max(percent, 0), 100) : 0;
const isComplete = hasPercent && clampedPercent >= 100;
return (
<div className="mt-4 pt-4 border-t border-gray-100">
<div className="flex items-center gap-2 mb-1.5">
<BarChart3 className="h-4 w-4 text-amber-500 flex-shrink-0" />
<span className="text-sm font-medium text-gray-700 truncate">
{name}
</span>
{hasPercent && (
<span className="text-xs font-mono text-gray-500 ml-auto flex-shrink-0">
{Math.round(clampedPercent)}%
</span>
)}
</div>
{/* Progress bar */}
<div className="w-full bg-gray-200 rounded-full h-2">
{hasPercent ? (
<div
className={`h-2 rounded-full transition-all duration-500 ease-out ${
isComplete
? "bg-green-500"
: "bg-amber-500"
}`}
style={{ width: `${clampedPercent}%` }}
/>
) : (
/* Indeterminate shimmer when we know a progress artifact exists
but haven't received a percent value yet */
<div className="h-2 rounded-full bg-amber-300 animate-pulse w-full opacity-40" />
)}
</div>
{/* Message */}
{message && (
<p className="text-xs text-gray-500 mt-1 truncate" title={message}>
{message}
</p>
)}
</div>
);
}

View File

@@ -16,6 +16,9 @@ import {
SquareAsterisk,
KeyRound,
Home,
Paperclip,
FolderOpenDot,
FolderArchive,
} from "lucide-react";
// Color mappings for navigation items — defined outside component for stable reference
@@ -113,6 +116,12 @@ const navSections = [
{
items: [
{ to: "/keys", label: "Keys & Secrets", icon: KeyRound, color: "gray" },
{
to: "/artifacts",
label: "Artifacts",
icon: FolderArchive,
color: "gray",
},
{
to: "/packs",
label: "Pack Management",

View File

@@ -0,0 +1,136 @@
import { useCallback } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { useEntityNotifications } from "@/contexts/WebSocketContext";
interface UseArtifactStreamOptions {
/**
* Optional execution ID to filter artifact updates for a specific execution.
* If not provided, receives updates for all artifacts.
*/
executionId?: number;
/**
* Whether the stream should be active.
* Defaults to true.
*/
enabled?: boolean;
}
/**
* Hook to subscribe to real-time artifact updates via WebSocket.
*
* Listens to `artifact_created` and `artifact_updated` notifications from the
* PostgreSQL LISTEN/NOTIFY system, and invalidates relevant React Query caches
* so that artifact lists and detail views update in real time.
*
* For progress-type artifacts, the notification payload includes a progress
* summary (`progress_percent`, `progress_message`, `progress_entries`) extracted
* by the database trigger so that the UI can update inline progress indicators
* without a separate API call.
*
* @example
* ```tsx
* // Listen to all artifact updates
* useArtifactStream();
*
* // Listen to artifacts for a specific execution
* useArtifactStream({ executionId: 123 });
* ```
*/
export function useArtifactStream(options: UseArtifactStreamOptions = {}) {
const { executionId, enabled = true } = options;
const queryClient = useQueryClient();
const handleNotification = useCallback(
(notification: any) => {
const payload = notification.payload as any;
// If we're filtering by execution ID, only process matching artifacts
if (executionId && payload?.execution !== executionId) {
return;
}
const artifactId = notification.entity_id;
const artifactExecution = payload?.execution;
// Invalidate the specific artifact query (used by ProgressDetail, TextFileDetail)
queryClient.invalidateQueries({
queryKey: ["artifacts", artifactId],
});
// Invalidate the execution artifacts list query
if (artifactExecution) {
queryClient.invalidateQueries({
queryKey: ["artifacts", "execution", artifactExecution],
});
}
// For progress artifacts, also update cached data directly with the
// summary from the notification payload to provide instant feedback
// before the invalidation refetch completes.
if (payload?.type === "progress" && payload?.progress_percent != null) {
queryClient.setQueryData(
["artifact_progress", artifactExecution],
(old: any) => ({
...old,
artifactId,
name: payload.name,
percent: payload.progress_percent,
message: payload.progress_message ?? null,
entries: payload.progress_entries ?? 0,
timestamp: notification.timestamp,
}),
);
}
},
[executionId, queryClient],
);
const { connected } = useEntityNotifications(
"artifact",
handleNotification,
enabled,
);
return {
isConnected: connected,
};
}
/**
* Lightweight progress summary extracted from artifact WebSocket notifications.
* Available immediately via the `artifact_progress` query key without an API call.
*/
export interface ArtifactProgressSummary {
artifactId: number;
name: string | null;
percent: number;
message: string | null;
entries: number;
timestamp: string;
}
/**
* Hook to read the latest progress summary pushed by WebSocket notifications.
*
* This does NOT make any API calls — it only reads from the React Query cache
* which is populated by `useArtifactStream`. Returns `null` if no progress
* notification has been received yet for the given execution.
*
* For the initial load (before any WebSocket message arrives), the component
* should fall back to the polling-based `useExecutionArtifacts` data.
*/
export function useArtifactProgress(
executionId: number | undefined,
): ArtifactProgressSummary | null {
const queryClient = useQueryClient();
if (!executionId) return null;
const data = queryClient.getQueryData<ArtifactProgressSummary>([
"artifact_progress",
executionId,
]);
return data ?? null;
}

View File

@@ -1,4 +1,4 @@
import { useQuery } from "@tanstack/react-query";
import { useQuery, keepPreviousData } from "@tanstack/react-query";
import { OpenAPI } from "@/api/core/OpenAPI";
import { request as __request } from "@/api/core/request";
@@ -12,6 +12,8 @@ export type ArtifactType =
| "progress"
| "url";
export type ArtifactVisibility = "public" | "private";
export type OwnerType = "system" | "pack" | "action" | "sensor" | "rule";
export type RetentionPolicyType = "versions" | "days" | "hours" | "minutes";
@@ -20,6 +22,7 @@ export interface ArtifactSummary {
id: number;
ref: string;
type: ArtifactType;
visibility: ArtifactVisibility;
name: string | null;
content_type: string | null;
size_bytes: number | null;
@@ -36,6 +39,7 @@ export interface ArtifactResponse {
scope: OwnerType;
owner: string;
type: ArtifactType;
visibility: ArtifactVisibility;
retention_policy: RetentionPolicyType;
retention_limit: number;
name: string | null;
@@ -57,6 +61,70 @@ export interface ArtifactVersionSummary {
created: string;
}
// ============================================================================
// Search / List params
// ============================================================================
export interface ArtifactsListParams {
page?: number;
perPage?: number;
scope?: OwnerType;
owner?: string;
type?: ArtifactType;
visibility?: ArtifactVisibility;
execution?: number;
name?: string;
}
// ============================================================================
// Paginated list response shape
// ============================================================================
export interface PaginatedArtifacts {
data: ArtifactSummary[];
pagination: {
page: number;
page_size: number;
total_items: number;
total_pages: number;
};
}
// ============================================================================
// Hooks
// ============================================================================
/**
* Fetch a paginated, filterable list of all artifacts.
*
* Uses GET /api/v1/artifacts with query params for server-side filtering.
*/
export function useArtifactsList(params: ArtifactsListParams = {}) {
return useQuery({
queryKey: ["artifacts", "list", params],
queryFn: async () => {
const query: Record<string, string> = {};
if (params.page) query.page = String(params.page);
if (params.perPage) query.per_page = String(params.perPage);
if (params.scope) query.scope = params.scope;
if (params.owner) query.owner = params.owner;
if (params.type) query.type = params.type;
if (params.visibility) query.visibility = params.visibility;
if (params.execution) query.execution = String(params.execution);
if (params.name) query.name = params.name;
const response = await __request<PaginatedArtifacts>(OpenAPI, {
method: "GET",
url: "/api/v1/artifacts",
query,
});
return response;
},
staleTime: 10000,
placeholderData: keepPreviousData,
});
}
/**
* Fetch all artifacts for a given execution ID.
*

View File

@@ -0,0 +1,705 @@
import { useState, useMemo, useCallback, useEffect } from "react";
import { useParams, Link } from "react-router-dom";
import {
ArrowLeft,
Download,
Eye,
EyeOff,
Loader2,
FileText,
Clock,
Hash,
X,
} from "lucide-react";
import {
useArtifact,
useArtifactVersions,
type ArtifactResponse,
type ArtifactVersionSummary,
} from "@/hooks/useArtifacts";
import { useArtifactStream } from "@/hooks/useArtifactStream";
import { OpenAPI } from "@/api/core/OpenAPI";
import {
getArtifactTypeIcon,
getArtifactTypeBadge,
getScopeBadge,
formatBytes,
formatDate,
downloadArtifact,
isDownloadable,
} from "./artifactHelpers";
// ============================================================================
// Text content viewer
// ============================================================================
function TextContentViewer({
artifactId,
versionId,
label,
}: {
artifactId: number;
versionId?: number;
label: string;
}) {
// Track a fetch key so that when deps change we re-derive initial state
// instead of calling setState synchronously inside useEffect.
const fetchKey = `${artifactId}:${versionId ?? "latest"}`;
const [settledKey, setSettledKey] = useState<string | null>(null);
const [content, setContent] = useState<string | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const isLoading = settledKey !== fetchKey;
useEffect(() => {
let cancelled = false;
const token = localStorage.getItem("access_token");
const url = versionId
? `${OpenAPI.BASE}/api/v1/artifacts/${artifactId}/versions/${versionId}/download`
: `${OpenAPI.BASE}/api/v1/artifacts/${artifactId}/download`;
fetch(url, { headers: { Authorization: `Bearer ${token}` } })
.then(async (response) => {
if (cancelled) return;
if (!response.ok) {
setLoadError(`HTTP ${response.status}: ${response.statusText}`);
setContent(null);
return;
}
const text = await response.text();
setContent(text);
setLoadError(null);
})
.catch((e) => {
if (!cancelled) {
setLoadError(e instanceof Error ? e.message : "Unknown error");
setContent(null);
}
})
.finally(() => {
if (!cancelled) setSettledKey(fetchKey);
});
return () => {
cancelled = true;
};
}, [artifactId, versionId, fetchKey]);
if (isLoading) {
return (
<div className="flex items-center gap-2 py-4 text-sm text-gray-500">
<Loader2 className="h-4 w-4 animate-spin" />
Loading {label}...
</div>
);
}
if (loadError) {
return <div className="py-4 text-sm text-red-600">Error: {loadError}</div>;
}
return (
<pre className="max-h-96 overflow-y-auto bg-gray-900 text-gray-100 rounded-lg p-4 text-xs font-mono whitespace-pre-wrap break-all">
{content || <span className="text-gray-500 italic">(empty)</span>}
</pre>
);
}
// ============================================================================
// Progress viewer
// ============================================================================
function ProgressViewer({ data }: { data: unknown }) {
const entries = useMemo(() => {
if (!data || !Array.isArray(data)) return [];
return data as Array<Record<string, unknown>>;
}, [data]);
const latestEntry = entries.length > 0 ? entries[entries.length - 1] : null;
const latestPercent =
latestEntry && typeof latestEntry.percent === "number"
? latestEntry.percent
: null;
if (entries.length === 0) {
return (
<p className="text-sm text-gray-500 italic">No progress entries yet.</p>
);
}
return (
<div>
{latestPercent != null && (
<div className="mb-4">
<div className="flex items-center justify-between text-sm text-gray-600 mb-1">
<span>
{latestEntry?.message
? String(latestEntry.message)
: `${latestPercent}%`}
</span>
<span className="font-mono">{latestPercent}%</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-3">
<div
className="bg-amber-500 h-3 rounded-full transition-all duration-300"
style={{ width: `${Math.min(latestPercent, 100)}%` }}
/>
</div>
</div>
)}
<div className="max-h-64 overflow-y-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-gray-500 border-b border-gray-200">
<th className="pb-2 pr-3">#</th>
<th className="pb-2 pr-3">%</th>
<th className="pb-2 pr-3">Message</th>
<th className="pb-2">Time</th>
</tr>
</thead>
<tbody>
{entries.map((entry, idx) => (
<tr key={idx} className="border-b border-gray-100 last:border-0">
<td className="py-1.5 pr-3 text-gray-400 font-mono">
{typeof entry.iteration === "number"
? entry.iteration
: idx + 1}
</td>
<td className="py-1.5 pr-3 font-mono">
{typeof entry.percent === "number"
? `${entry.percent}%`
: "\u2014"}
</td>
<td className="py-1.5 pr-3 text-gray-700 truncate max-w-[300px]">
{entry.message ? String(entry.message) : "\u2014"}
</td>
<td className="py-1.5 text-gray-400 whitespace-nowrap">
{entry.timestamp
? new Date(String(entry.timestamp)).toLocaleTimeString()
: "\u2014"}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
// ============================================================================
// Version row
// ============================================================================
function VersionRow({
version,
artifactId,
artifactRef,
artifactType,
}: {
version: ArtifactVersionSummary;
artifactId: number;
artifactRef: string;
artifactType: string;
}) {
const [showPreview, setShowPreview] = useState(false);
const canPreview = artifactType === "file_text";
const canDownload =
artifactType === "file_text" ||
artifactType === "file_image" ||
artifactType === "file_binary" ||
artifactType === "file_datatable";
const handleDownload = useCallback(async () => {
const token = localStorage.getItem("access_token");
const url = `${OpenAPI.BASE}/api/v1/artifacts/${artifactId}/versions/${version.id}/download`;
const response = await fetch(url, {
headers: { Authorization: `Bearer ${token}` },
});
if (!response.ok) {
console.error(
`Download failed: ${response.status} ${response.statusText}`,
);
return;
}
const disposition = response.headers.get("Content-Disposition");
let filename = `${artifactRef.replace(/\./g, "_")}_v${version.version}.bin`;
if (disposition) {
const match = disposition.match(/filename="?([^"]+)"?/);
if (match) filename = match[1];
}
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = blobUrl;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(blobUrl);
}, [artifactId, artifactRef, version]);
return (
<>
<tr className="hover:bg-gray-50">
<td className="px-4 py-3 whitespace-nowrap text-sm font-mono text-gray-900">
v{version.version}
</td>
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-600">
{version.content_type || "\u2014"}
</td>
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-600">
{formatBytes(version.size_bytes)}
</td>
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-600">
{version.created_by || "\u2014"}
</td>
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-600">
{formatDate(version.created)}
</td>
<td className="px-4 py-3 whitespace-nowrap text-right">
<div className="flex items-center justify-end gap-2">
{canPreview && (
<button
onClick={() => setShowPreview(!showPreview)}
className="text-gray-500 hover:text-blue-600"
title={showPreview ? "Hide preview" : "Preview content"}
>
{showPreview ? (
<X className="h-4 w-4" />
) : (
<FileText className="h-4 w-4" />
)}
</button>
)}
{canDownload && (
<button
onClick={handleDownload}
className="text-gray-500 hover:text-blue-600"
title="Download this version"
>
<Download className="h-4 w-4" />
</button>
)}
</div>
</td>
</tr>
{showPreview && (
<tr>
<td colSpan={6} className="px-4 py-3">
<TextContentViewer
artifactId={artifactId}
versionId={version.id}
label={`v${version.version}`}
/>
</td>
</tr>
)}
</>
);
}
// ============================================================================
// Detail card
// ============================================================================
function MetadataField({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) {
return (
<div>
<dt className="text-sm font-medium text-gray-500">{label}</dt>
<dd className="mt-1 text-sm text-gray-900">{children}</dd>
</div>
);
}
function ArtifactMetadata({ artifact }: { artifact: ArtifactResponse }) {
const typeBadge = getArtifactTypeBadge(artifact.type);
const scopeBadge = getScopeBadge(artifact.scope);
return (
<div className="bg-white shadow rounded-lg overflow-hidden">
<div className="px-6 py-4 border-b border-gray-200">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
{getArtifactTypeIcon(artifact.type)}
<div>
<h2 className="text-xl font-bold text-gray-900">
{artifact.name || artifact.ref}
</h2>
{artifact.name && (
<p className="text-sm text-gray-500 font-mono">
{artifact.ref}
</p>
)}
</div>
</div>
<div className="flex items-center gap-3">
{isDownloadable(artifact.type) && (
<button
onClick={() => downloadArtifact(artifact.id, artifact.ref)}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm"
>
<Download className="h-4 w-4" />
Download Latest
</button>
)}
</div>
</div>
</div>
<div className="px-6 py-5">
<dl className="grid grid-cols-2 md:grid-cols-4 gap-x-6 gap-y-4">
<MetadataField label="Type">
<span
className={`px-2 py-0.5 inline-flex text-xs leading-5 font-semibold rounded-full ${typeBadge.classes}`}
>
{typeBadge.label}
</span>
</MetadataField>
<MetadataField label="Visibility">
<div className="flex items-center gap-1.5">
{artifact.visibility === "public" ? (
<>
<Eye className="h-4 w-4 text-green-600" />
<span className="text-green-700">Public</span>
</>
) : (
<>
<EyeOff className="h-4 w-4 text-gray-400" />
<span className="text-gray-600">Private</span>
</>
)}
</div>
</MetadataField>
<MetadataField label="Scope">
<span
className={`px-2 py-0.5 inline-flex text-xs leading-5 font-semibold rounded-full ${scopeBadge.classes}`}
>
{scopeBadge.label}
</span>
</MetadataField>
<MetadataField label="Owner">
<span className="font-mono text-sm">
{artifact.owner || "\u2014"}
</span>
</MetadataField>
<MetadataField label="Execution">
{artifact.execution ? (
<Link
to={`/executions/${artifact.execution}`}
className="text-blue-600 hover:text-blue-800 font-mono"
>
#{artifact.execution}
</Link>
) : (
<span className="text-gray-400">{"\u2014"}</span>
)}
</MetadataField>
<MetadataField label="Content Type">
<span className="font-mono text-xs">
{artifact.content_type || "\u2014"}
</span>
</MetadataField>
<MetadataField label="Size">
{formatBytes(artifact.size_bytes)}
</MetadataField>
<MetadataField label="Retention">
{artifact.retention_limit} {artifact.retention_policy}
</MetadataField>
<MetadataField label="Created">
<div className="flex items-center gap-1.5">
<Clock className="h-3.5 w-3.5 text-gray-400" />
{formatDate(artifact.created)}
</div>
</MetadataField>
<MetadataField label="Updated">
<div className="flex items-center gap-1.5">
<Clock className="h-3.5 w-3.5 text-gray-400" />
{formatDate(artifact.updated)}
</div>
</MetadataField>
{artifact.description && (
<div className="col-span-2">
<MetadataField label="Description">
{artifact.description}
</MetadataField>
</div>
)}
</dl>
</div>
</div>
);
}
// ============================================================================
// Versions list
// ============================================================================
function ArtifactVersionsList({ artifact }: { artifact: ArtifactResponse }) {
const { data, isLoading, error } = useArtifactVersions(artifact.id);
const versions = useMemo(() => data?.data || [], [data]);
return (
<div className="bg-white shadow rounded-lg overflow-hidden">
<div className="px-6 py-4 border-b border-gray-200">
<div className="flex items-center gap-2">
<Hash className="h-5 w-5 text-gray-400" />
<h3 className="text-lg font-semibold text-gray-900">
Versions
{versions.length > 0 && (
<span className="ml-2 text-sm font-normal text-gray-500">
({versions.length})
</span>
)}
</h3>
</div>
</div>
{isLoading ? (
<div className="p-8 text-center">
<Loader2 className="h-6 w-6 animate-spin mx-auto text-blue-600" />
<p className="mt-2 text-sm text-gray-600">Loading versions...</p>
</div>
) : error ? (
<div className="p-8 text-center">
<p className="text-red-600">Failed to load versions</p>
<p className="text-sm text-gray-600 mt-1">
{error instanceof Error ? error.message : "Unknown error"}
</p>
</div>
) : versions.length === 0 ? (
<div className="p-8 text-center">
<p className="text-gray-500">No versions yet</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Version
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Content Type
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Size
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Created By
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Created
</th>
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{versions.map((version) => (
<VersionRow
key={version.id}
version={version}
artifactId={artifact.id}
artifactRef={artifact.ref}
artifactType={artifact.type}
/>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
// ============================================================================
// Inline content preview (progress / text for latest)
// ============================================================================
function InlineContentPreview({ artifact }: { artifact: ArtifactResponse }) {
if (artifact.type === "progress") {
return (
<div className="bg-white shadow rounded-lg overflow-hidden">
<div className="px-6 py-4 border-b border-gray-200">
<h3 className="text-lg font-semibold text-gray-900">
Progress Details
</h3>
</div>
<div className="px-6 py-5">
<ProgressViewer data={artifact.data} />
</div>
</div>
);
}
if (artifact.type === "file_text") {
return (
<div className="bg-white shadow rounded-lg overflow-hidden">
<div className="px-6 py-4 border-b border-gray-200">
<h3 className="text-lg font-semibold text-gray-900">
Content Preview (Latest)
</h3>
</div>
<div className="px-6 py-5">
<TextContentViewer artifactId={artifact.id} label="content" />
</div>
</div>
);
}
if (artifact.type === "url" && artifact.data) {
const urlValue =
typeof artifact.data === "string"
? artifact.data
: typeof artifact.data === "object" &&
artifact.data !== null &&
"url" in (artifact.data as Record<string, unknown>)
? String((artifact.data as Record<string, unknown>).url)
: null;
if (urlValue) {
return (
<div className="bg-white shadow rounded-lg overflow-hidden">
<div className="px-6 py-4 border-b border-gray-200">
<h3 className="text-lg font-semibold text-gray-900">URL</h3>
</div>
<div className="px-6 py-5">
<a
href={urlValue}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-800 underline break-all"
>
{urlValue}
</a>
</div>
</div>
);
}
}
// JSON data preview for other types that have data
if (artifact.data != null) {
return (
<div className="bg-white shadow rounded-lg overflow-hidden">
<div className="px-6 py-4 border-b border-gray-200">
<h3 className="text-lg font-semibold text-gray-900">Data</h3>
</div>
<div className="px-6 py-5">
<pre className="max-h-96 overflow-y-auto bg-gray-900 text-gray-100 rounded-lg p-4 text-xs font-mono whitespace-pre-wrap break-all">
{JSON.stringify(artifact.data, null, 2)}
</pre>
</div>
</div>
);
}
return null;
}
// ============================================================================
// Main page
// ============================================================================
export default function ArtifactDetailPage() {
const { id } = useParams<{ id: string }>();
const artifactId = id ? Number(id) : undefined;
const { data, isLoading, error } = useArtifact(artifactId);
const artifact = data?.data;
// Subscribe to real-time updates for this artifact
useArtifactStream({
executionId: artifact?.execution ?? undefined,
enabled: true,
});
if (isLoading) {
return (
<div className="p-6">
<div className="flex items-center justify-center h-64">
<Loader2 className="h-8 w-8 animate-spin text-blue-600" />
<p className="ml-3 text-gray-600">Loading artifact...</p>
</div>
</div>
);
}
if (error || !artifact) {
return (
<div className="p-6">
<div className="mb-6">
<Link
to="/artifacts"
className="flex items-center gap-2 text-gray-600 hover:text-gray-900"
>
<ArrowLeft className="h-4 w-4" />
Back to Artifacts
</Link>
</div>
<div className="bg-white shadow rounded-lg p-12 text-center">
<p className="text-red-600 text-lg">
{error ? "Failed to load artifact" : "Artifact not found"}
</p>
{error && (
<p className="text-sm text-gray-600 mt-2">
{error instanceof Error ? error.message : "Unknown error"}
</p>
)}
</div>
</div>
);
}
return (
<div className="p-6">
{/* Back link */}
<div className="mb-6">
<Link
to="/artifacts"
className="flex items-center gap-2 text-gray-600 hover:text-gray-900 text-sm"
>
<ArrowLeft className="h-4 w-4" />
Back to Artifacts
</Link>
</div>
{/* Metadata card */}
<ArtifactMetadata artifact={artifact} />
{/* Inline content preview */}
<div className="mt-6">
<InlineContentPreview artifact={artifact} />
</div>
{/* Versions list */}
<div className="mt-6">
<ArtifactVersionsList artifact={artifact} />
</div>
</div>
);
}

View File

@@ -0,0 +1,583 @@
import { useState, useCallback, useMemo, useEffect, memo } from "react";
import { Link, useSearchParams } from "react-router-dom";
import { Search, X, Eye, EyeOff, Download, Package } from "lucide-react";
import {
useArtifactsList,
type ArtifactSummary,
type ArtifactType,
type ArtifactVisibility,
type OwnerType,
} from "@/hooks/useArtifacts";
import { useArtifactStream } from "@/hooks/useArtifactStream";
import {
TYPE_OPTIONS,
VISIBILITY_OPTIONS,
SCOPE_OPTIONS,
getArtifactTypeIcon,
getArtifactTypeBadge,
getScopeBadge,
formatBytes,
formatDate,
formatTime,
downloadArtifact,
isDownloadable,
} from "./artifactHelpers";
// ============================================================================
// Results Table (memoized so filter typing doesn't re-render rows)
// ============================================================================
const ArtifactsResultsTable = memo(
({
artifacts,
isLoading,
isFetching,
error,
hasActiveFilters,
clearFilters,
page,
setPage,
pageSize,
total,
}: {
artifacts: ArtifactSummary[];
isLoading: boolean;
isFetching: boolean;
error: Error | null;
hasActiveFilters: boolean;
clearFilters: () => void;
page: number;
setPage: (page: number) => void;
pageSize: number;
total: number;
}) => {
const totalPages = total ? Math.ceil(total / pageSize) : 0;
if (isLoading && artifacts.length === 0) {
return (
<div className="bg-white shadow rounded-lg">
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600" />
<p className="ml-4 text-gray-600">Loading artifacts...</p>
</div>
</div>
);
}
if (error && artifacts.length === 0) {
return (
<div className="bg-white shadow rounded-lg p-12 text-center">
<p className="text-red-600">Failed to load artifacts</p>
<p className="text-sm text-gray-600 mt-2">{error.message}</p>
</div>
);
}
if (artifacts.length === 0) {
return (
<div className="bg-white shadow rounded-lg p-12 text-center">
<Package className="mx-auto h-12 w-12 text-gray-400" />
<p className="mt-4 text-gray-600">No artifacts found</p>
<p className="text-sm text-gray-500 mt-1">
{hasActiveFilters
? "Try adjusting your filters"
: "Artifacts will appear here when executions produce output"}
</p>
{hasActiveFilters && (
<button
onClick={clearFilters}
className="mt-3 text-sm text-blue-600 hover:text-blue-800"
>
Clear filters
</button>
)}
</div>
);
}
return (
<div className="relative">
{isFetching && (
<div className="absolute inset-0 bg-white/60 z-10 flex items-center justify-center rounded-lg">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
</div>
)}
{error && (
<div className="mb-4 bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
<p>Error refreshing: {error.message}</p>
</div>
)}
<div className="bg-white shadow rounded-lg overflow-hidden">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Artifact
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Type
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Visibility
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Scope / Owner
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Execution
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Size
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Created
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{artifacts.map((artifact) => {
const typeBadge = getArtifactTypeBadge(artifact.type);
const scopeBadge = getScopeBadge(artifact.scope);
return (
<tr key={artifact.id} className="hover:bg-gray-50">
<td className="px-6 py-4">
<div className="flex items-center gap-2">
{getArtifactTypeIcon(artifact.type)}
<div className="min-w-0">
<Link
to={`/artifacts/${artifact.id}`}
className="text-sm font-medium text-blue-600 hover:text-blue-800 truncate block"
>
{artifact.name || artifact.ref}
</Link>
{artifact.name && (
<div className="text-xs text-gray-500 font-mono truncate">
{artifact.ref}
</div>
)}
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span
className={`px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${typeBadge.classes}`}
>
{typeBadge.label}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center gap-1.5 text-sm">
{artifact.visibility === "public" ? (
<>
<Eye className="h-3.5 w-3.5 text-green-600" />
<span className="text-green-700">Public</span>
</>
) : (
<>
<EyeOff className="h-3.5 w-3.5 text-gray-400" />
<span className="text-gray-600">Private</span>
</>
)}
</div>
</td>
<td className="px-6 py-4">
<div>
<span
className={`px-2 py-0.5 inline-flex text-xs leading-5 font-semibold rounded-full ${scopeBadge.classes}`}
>
{scopeBadge.label}
</span>
{artifact.owner && (
<div className="text-xs text-gray-500 mt-0.5 font-mono truncate max-w-[160px]">
{artifact.owner}
</div>
)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
{artifact.execution ? (
<Link
to={`/executions/${artifact.execution}`}
className="text-sm font-mono text-blue-600 hover:text-blue-800"
>
#{artifact.execution}
</Link>
) : (
<span className="text-sm text-gray-400 italic">
{"\u2014"}
</span>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-700">
{formatBytes(artifact.size_bytes)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">
{formatTime(artifact.created)}
</div>
<div className="text-xs text-gray-500">
{formatDate(artifact.created)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right">
<div className="flex items-center justify-end gap-2">
<Link
to={`/artifacts/${artifact.id}`}
className="text-gray-500 hover:text-blue-600"
title="View details"
>
<Eye className="h-4 w-4" />
</Link>
{isDownloadable(artifact.type) && (
<button
onClick={() =>
downloadArtifact(artifact.id, artifact.ref)
}
className="text-gray-500 hover:text-blue-600"
title="Download latest version"
>
<Download className="h-4 w-4" />
</button>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="bg-gray-50 px-6 py-4 flex items-center justify-between border-t border-gray-200 rounded-b-lg">
<div className="flex-1 flex justify-between sm:hidden">
<button
onClick={() => setPage(page - 1)}
disabled={page === 1}
className="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Previous
</button>
<button
onClick={() => setPage(page + 1)}
disabled={page === totalPages}
className="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Next
</button>
</div>
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
<div>
<p className="text-sm text-gray-700">
Page <span className="font-medium">{page}</span> of{" "}
<span className="font-medium">{totalPages}</span>
</p>
</div>
<div>
<nav className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px">
<button
onClick={() => setPage(page - 1)}
disabled={page === 1}
className="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Previous
</button>
<button
onClick={() => setPage(page + 1)}
disabled={page === totalPages}
className="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Next
</button>
</nav>
</div>
</div>
</div>
)}
</div>
);
},
);
ArtifactsResultsTable.displayName = "ArtifactsResultsTable";
// ============================================================================
// Main Page
// ============================================================================
export default function ArtifactsPage() {
const [searchParams] = useSearchParams();
const [page, setPage] = useState(1);
const pageSize = 20;
const [nameFilter, setNameFilter] = useState(searchParams.get("name") || "");
const [typeFilter, setTypeFilter] = useState<ArtifactType | "">(
(searchParams.get("type") as ArtifactType) || "",
);
const [visibilityFilter, setVisibilityFilter] = useState<
ArtifactVisibility | ""
>((searchParams.get("visibility") as ArtifactVisibility) || "");
const [scopeFilter, setScopeFilter] = useState<OwnerType | "">(
(searchParams.get("scope") as OwnerType) || "",
);
const [ownerFilter, setOwnerFilter] = useState(
searchParams.get("owner") || "",
);
const [executionFilter, setExecutionFilter] = useState(
searchParams.get("execution") || "",
);
// Debounce text inputs
const [debouncedName, setDebouncedName] = useState(nameFilter);
const [debouncedOwner, setDebouncedOwner] = useState(ownerFilter);
const [debouncedExecution, setDebouncedExecution] = useState(executionFilter);
useEffect(() => {
const t = setTimeout(() => setDebouncedName(nameFilter), 400);
return () => clearTimeout(t);
}, [nameFilter]);
useEffect(() => {
const t = setTimeout(() => setDebouncedOwner(ownerFilter), 400);
return () => clearTimeout(t);
}, [ownerFilter]);
useEffect(() => {
const t = setTimeout(() => setDebouncedExecution(executionFilter), 400);
return () => clearTimeout(t);
}, [executionFilter]);
// Build query params
const queryParams = useMemo(() => {
const params: Record<string, unknown> = { page, perPage: pageSize };
if (debouncedName) params.name = debouncedName;
if (typeFilter) params.type = typeFilter;
if (visibilityFilter) params.visibility = visibilityFilter;
if (scopeFilter) params.scope = scopeFilter;
if (debouncedOwner) params.owner = debouncedOwner;
if (debouncedExecution) {
const n = Number(debouncedExecution);
if (!isNaN(n)) params.execution = n;
}
return params;
}, [
page,
pageSize,
debouncedName,
typeFilter,
visibilityFilter,
scopeFilter,
debouncedOwner,
debouncedExecution,
]);
const { data, isLoading, isFetching, error } = useArtifactsList(queryParams);
// Subscribe to real-time artifact updates
useArtifactStream({ enabled: true });
const artifacts = useMemo(() => data?.data || [], [data]);
const total = data?.pagination?.total_items || 0;
const hasActiveFilters =
!!nameFilter ||
!!typeFilter ||
!!visibilityFilter ||
!!scopeFilter ||
!!ownerFilter ||
!!executionFilter;
const clearFilters = useCallback(() => {
setNameFilter("");
setTypeFilter("");
setVisibilityFilter("");
setScopeFilter("");
setOwnerFilter("");
setExecutionFilter("");
setPage(1);
}, []);
return (
<div className="p-6">
{/* Header */}
<div className="mb-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900">Artifacts</h1>
<p className="mt-2 text-gray-600">
Files, progress indicators, and data produced by executions
</p>
</div>
</div>
</div>
{/* Filters */}
<div className="bg-white shadow rounded-lg p-4 mb-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Search className="h-5 w-5 text-gray-400" />
<h2 className="text-lg font-semibold">Filter Artifacts</h2>
</div>
{hasActiveFilters && (
<button
onClick={clearFilters}
className="flex items-center gap-1 text-sm text-gray-600 hover:text-gray-900"
>
<X className="h-4 w-4" />
Clear Filters
</button>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-6 gap-4">
{/* Name search */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Name
</label>
<input
type="text"
value={nameFilter}
onChange={(e) => {
setNameFilter(e.target.value);
setPage(1);
}}
placeholder="Search by name..."
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm"
/>
</div>
{/* Type */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Type
</label>
<select
value={typeFilter}
onChange={(e) => {
setTypeFilter(e.target.value as ArtifactType | "");
setPage(1);
}}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm"
>
<option value="">All Types</option>
{TYPE_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</div>
{/* Visibility */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Visibility
</label>
<select
value={visibilityFilter}
onChange={(e) => {
setVisibilityFilter(e.target.value as ArtifactVisibility | "");
setPage(1);
}}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm"
>
<option value="">All</option>
{VISIBILITY_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</div>
{/* Scope */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Scope
</label>
<select
value={scopeFilter}
onChange={(e) => {
setScopeFilter(e.target.value as OwnerType | "");
setPage(1);
}}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm"
>
<option value="">All Scopes</option>
{SCOPE_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</div>
{/* Owner */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Owner
</label>
<input
type="text"
value={ownerFilter}
onChange={(e) => {
setOwnerFilter(e.target.value);
setPage(1);
}}
placeholder="e.g. mypack.deploy"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm"
/>
</div>
{/* Execution ID */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Execution
</label>
<input
type="text"
value={executionFilter}
onChange={(e) => {
setExecutionFilter(e.target.value);
setPage(1);
}}
placeholder="Execution ID"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm"
/>
</div>
</div>
{data && (
<div className="mt-3 text-sm text-gray-600">
Showing {artifacts.length} of {total} artifacts
{hasActiveFilters && " (filtered)"}
</div>
)}
</div>
{/* Results */}
<ArtifactsResultsTable
artifacts={artifacts}
isLoading={isLoading}
isFetching={isFetching}
error={error as Error | null}
hasActiveFilters={hasActiveFilters}
clearFilters={clearFilters}
page={page}
setPage={setPage}
pageSize={pageSize}
total={total}
/>
</div>
);
}

View File

@@ -0,0 +1,190 @@
import {
FileText,
FileImage,
File,
BarChart3,
Link as LinkIcon,
Table2,
Package,
} from "lucide-react";
import type { ArtifactType, OwnerType } from "@/hooks/useArtifacts";
import { OpenAPI } from "@/api/core/OpenAPI";
// ============================================================================
// Filter option constants
// ============================================================================
export const TYPE_OPTIONS: { value: ArtifactType; label: string }[] = [
{ value: "file_text", label: "Text File" },
{ value: "file_image", label: "Image" },
{ value: "file_binary", label: "Binary" },
{ value: "file_datatable", label: "Data Table" },
{ value: "progress", label: "Progress" },
{ value: "url", label: "URL" },
{ value: "other", label: "Other" },
];
export const VISIBILITY_OPTIONS: { value: string; label: string }[] = [
{ value: "public", label: "Public" },
{ value: "private", label: "Private" },
];
export const SCOPE_OPTIONS: { value: OwnerType; label: string }[] = [
{ value: "system", label: "System" },
{ value: "pack", label: "Pack" },
{ value: "action", label: "Action" },
{ value: "sensor", label: "Sensor" },
{ value: "rule", label: "Rule" },
];
// ============================================================================
// Icon / badge helpers
// ============================================================================
export function getArtifactTypeIcon(type: ArtifactType) {
switch (type) {
case "file_text":
return <FileText className="h-4 w-4 text-blue-500" />;
case "file_image":
return <FileImage className="h-4 w-4 text-purple-500" />;
case "file_binary":
return <File className="h-4 w-4 text-gray-500" />;
case "file_datatable":
return <Table2 className="h-4 w-4 text-green-500" />;
case "progress":
return <BarChart3 className="h-4 w-4 text-amber-500" />;
case "url":
return <LinkIcon className="h-4 w-4 text-cyan-500" />;
case "other":
default:
return <Package className="h-4 w-4 text-gray-400" />;
}
}
export function getArtifactTypeBadge(type: ArtifactType): {
label: string;
classes: string;
} {
switch (type) {
case "file_text":
return { label: "Text File", classes: "bg-blue-100 text-blue-800" };
case "file_image":
return { label: "Image", classes: "bg-purple-100 text-purple-800" };
case "file_binary":
return { label: "Binary", classes: "bg-gray-100 text-gray-800" };
case "file_datatable":
return { label: "Data Table", classes: "bg-green-100 text-green-800" };
case "progress":
return { label: "Progress", classes: "bg-amber-100 text-amber-800" };
case "url":
return { label: "URL", classes: "bg-cyan-100 text-cyan-800" };
case "other":
default:
return { label: "Other", classes: "bg-gray-100 text-gray-700" };
}
}
export function getScopeBadge(scope: OwnerType): {
label: string;
classes: string;
} {
switch (scope) {
case "system":
return { label: "System", classes: "bg-purple-100 text-purple-800" };
case "pack":
return { label: "Pack", classes: "bg-green-100 text-green-800" };
case "action":
return { label: "Action", classes: "bg-yellow-100 text-yellow-800" };
case "sensor":
return { label: "Sensor", classes: "bg-indigo-100 text-indigo-800" };
case "rule":
return { label: "Rule", classes: "bg-blue-100 text-blue-800" };
default:
return { label: scope, classes: "bg-gray-100 text-gray-700" };
}
}
export function getVisibilityBadge(visibility: string): {
label: string;
classes: string;
} {
if (visibility === "public") {
return { label: "Public", classes: "text-green-700" };
}
return { label: "Private", classes: "text-gray-600" };
}
// ============================================================================
// Formatting helpers
// ============================================================================
export function formatBytes(bytes: number | null): string {
if (bytes == null || bytes === 0) return "\u2014";
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
export function formatDate(dateString: string) {
return new Date(dateString).toLocaleString();
}
export function formatTime(timestamp: string) {
const date = new Date(timestamp);
const now = new Date();
const diff = now.getTime() - date.getTime();
if (diff < 60000) return "just now";
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`;
return date.toLocaleDateString();
}
// ============================================================================
// Download helper
// ============================================================================
export async function downloadArtifact(
artifactId: number,
artifactRef: string,
) {
const token = localStorage.getItem("access_token");
const url = `${OpenAPI.BASE}/api/v1/artifacts/${artifactId}/download`;
const response = await fetch(url, {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!response.ok) {
console.error(`Download failed: ${response.status} ${response.statusText}`);
return;
}
const disposition = response.headers.get("Content-Disposition");
let filename = artifactRef.replace(/\./g, "_") + ".bin";
if (disposition) {
const match = disposition.match(/filename="?([^"]+)"?/);
if (match) filename = match[1];
}
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = blobUrl;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(blobUrl);
}
export function isDownloadable(type: ArtifactType): boolean {
return (
type === "file_text" ||
type === "file_image" ||
type === "file_binary" ||
type === "file_datatable"
);
}

View File

@@ -24,6 +24,7 @@ import ExecuteActionModal from "@/components/common/ExecuteActionModal";
import EntityHistoryPanel from "@/components/common/EntityHistoryPanel";
import WorkflowTasksPanel from "@/components/common/WorkflowTasksPanel";
import ExecutionArtifactsPanel from "@/components/executions/ExecutionArtifactsPanel";
import ExecutionProgressBar from "@/components/executions/ExecutionProgressBar";
const getStatusColor = (status: string) => {
switch (status) {
@@ -360,6 +361,14 @@ export default function ExecutionDetailPage() {
</div>
)}
</dl>
{/* Inline progress bar (visible when execution has progress artifacts) */}
{isRunning && (
<ExecutionProgressBar
executionId={execution.id}
isRunning={isRunning}
/>
)}
</div>
{/* Config/Parameters */}