artifacts!
This commit is contained in:
@@ -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 />} />
|
||||
|
||||
@@ -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,
|
||||
|
||||
109
web/src/components/executions/ExecutionProgressBar.tsx
Normal file
109
web/src/components/executions/ExecutionProgressBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
136
web/src/hooks/useArtifactStream.ts
Normal file
136
web/src/hooks/useArtifactStream.ts
Normal 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;
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
705
web/src/pages/artifacts/ArtifactDetailPage.tsx
Normal file
705
web/src/pages/artifacts/ArtifactDetailPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
583
web/src/pages/artifacts/ArtifactsPage.tsx
Normal file
583
web/src/pages/artifacts/ArtifactsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
190
web/src/pages/artifacts/artifactHelpers.tsx
Normal file
190
web/src/pages/artifacts/artifactHelpers.tsx
Normal 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"
|
||||
);
|
||||
}
|
||||
@@ -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 */}
|
||||
|
||||
Reference in New Issue
Block a user