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

@@ -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 */}