change capture
This commit is contained in:
@@ -1,35 +1,113 @@
|
||||
import { useParams, Link } from "react-router-dom";
|
||||
|
||||
/** Format a duration in ms to a human-readable string. */
|
||||
function formatDuration(ms: number): string {
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
const secs = ms / 1000;
|
||||
if (secs < 60) return `${secs.toFixed(1)}s`;
|
||||
const mins = Math.floor(secs / 60);
|
||||
const remainSecs = Math.round(secs % 60);
|
||||
if (mins < 60) return `${mins}m ${remainSecs}s`;
|
||||
const hrs = Math.floor(mins / 60);
|
||||
const remainMins = mins % 60;
|
||||
return `${hrs}h ${remainMins}m`;
|
||||
}
|
||||
import { useExecution } from "@/hooks/useExecutions";
|
||||
import { useAction } from "@/hooks/useActions";
|
||||
import { useExecutionStream } from "@/hooks/useExecutionStream";
|
||||
import { useExecutionHistory } from "@/hooks/useHistory";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { ExecutionStatus } from "@/api";
|
||||
import { useState } from "react";
|
||||
import { RotateCcw } from "lucide-react";
|
||||
import { useState, useMemo } from "react";
|
||||
import { RotateCcw, Loader2 } from "lucide-react";
|
||||
import ExecuteActionModal from "@/components/common/ExecuteActionModal";
|
||||
import EntityHistoryPanel from "@/components/common/EntityHistoryPanel";
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case "succeeded":
|
||||
case "completed":
|
||||
return "bg-green-100 text-green-800";
|
||||
case "failed":
|
||||
return "bg-red-100 text-red-800";
|
||||
case "running":
|
||||
return "bg-blue-100 text-blue-800";
|
||||
case "pending":
|
||||
case "requested":
|
||||
case "scheduling":
|
||||
case "scheduled":
|
||||
return "bg-yellow-100 text-yellow-800";
|
||||
case "timeout":
|
||||
return "bg-orange-100 text-orange-800";
|
||||
case "canceled":
|
||||
case "canceling":
|
||||
case "cancelled":
|
||||
return "bg-gray-100 text-gray-800";
|
||||
case "paused":
|
||||
return "bg-purple-100 text-purple-800";
|
||||
case "abandoned":
|
||||
return "bg-red-100 text-red-600";
|
||||
default:
|
||||
return "bg-gray-100 text-gray-800";
|
||||
}
|
||||
};
|
||||
|
||||
/** Map status to a dot color for the timeline. */
|
||||
const getTimelineDotColor = (status: string) => {
|
||||
switch (status) {
|
||||
case "completed":
|
||||
return "bg-green-500";
|
||||
case "failed":
|
||||
return "bg-red-500";
|
||||
case "running":
|
||||
return "bg-blue-500";
|
||||
case "requested":
|
||||
case "scheduling":
|
||||
case "scheduled":
|
||||
return "bg-yellow-500";
|
||||
case "timeout":
|
||||
return "bg-orange-500";
|
||||
case "canceling":
|
||||
case "cancelled":
|
||||
return "bg-gray-400";
|
||||
case "abandoned":
|
||||
return "bg-red-400";
|
||||
default:
|
||||
return "bg-gray-400";
|
||||
}
|
||||
};
|
||||
|
||||
/** Human-readable label for a status value. */
|
||||
const getStatusLabel = (status: string) => {
|
||||
switch (status) {
|
||||
case "requested":
|
||||
return "Requested";
|
||||
case "scheduling":
|
||||
return "Scheduling";
|
||||
case "scheduled":
|
||||
return "Scheduled";
|
||||
case "running":
|
||||
return "Running";
|
||||
case "completed":
|
||||
return "Completed";
|
||||
case "failed":
|
||||
return "Failed";
|
||||
case "canceling":
|
||||
return "Canceling";
|
||||
case "cancelled":
|
||||
return "Cancelled";
|
||||
case "timeout":
|
||||
return "Timed Out";
|
||||
case "abandoned":
|
||||
return "Abandoned";
|
||||
default:
|
||||
return status.charAt(0).toUpperCase() + status.slice(1);
|
||||
}
|
||||
};
|
||||
|
||||
interface TimelineEntry {
|
||||
status: string;
|
||||
time: string;
|
||||
isInitial: boolean;
|
||||
}
|
||||
|
||||
export default function ExecutionDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { data: executionData, isLoading, error } = useExecution(Number(id));
|
||||
@@ -40,6 +118,42 @@ export default function ExecutionDetailPage() {
|
||||
|
||||
const [showRerunModal, setShowRerunModal] = useState(false);
|
||||
|
||||
// Fetch status history for the timeline
|
||||
const { data: historyData, isLoading: historyLoading } = useExecutionHistory(
|
||||
Number(id),
|
||||
{ page_size: 100 },
|
||||
);
|
||||
|
||||
// Build timeline entries from history records
|
||||
const timelineEntries = useMemo<TimelineEntry[]>(() => {
|
||||
const records = historyData?.data ?? [];
|
||||
const entries: TimelineEntry[] = [];
|
||||
|
||||
for (const record of records) {
|
||||
if (record.operation === "INSERT" && record.new_values?.status) {
|
||||
entries.push({
|
||||
status: String(record.new_values.status),
|
||||
time: record.time,
|
||||
isInitial: true,
|
||||
});
|
||||
} else if (
|
||||
record.operation === "UPDATE" &&
|
||||
record.changed_fields.includes("status") &&
|
||||
record.new_values?.status
|
||||
) {
|
||||
entries.push({
|
||||
status: String(record.new_values.status),
|
||||
time: record.time,
|
||||
isInitial: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// History comes newest-first; reverse to chronological order
|
||||
entries.reverse();
|
||||
return entries;
|
||||
}, [historyData]);
|
||||
|
||||
// Subscribe to real-time updates for this execution
|
||||
const { isConnected } = useExecutionStream({
|
||||
executionId: Number(id),
|
||||
@@ -242,59 +356,99 @@ export default function ExecutionDetailPage() {
|
||||
{/* Timeline */}
|
||||
<div className="bg-white shadow rounded-lg p-6">
|
||||
<h2 className="text-xl font-semibold mb-4">Timeline</h2>
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-4">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="w-3 h-3 rounded-full bg-blue-500" />
|
||||
{!isRunning && <div className="w-0.5 h-full bg-gray-300" />}
|
||||
</div>
|
||||
<div className="flex-1 pb-4">
|
||||
<p className="font-medium">Execution Created</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{new Date(execution.created).toLocaleString()}
|
||||
</p>
|
||||
|
||||
{historyLoading && (
|
||||
<div className="flex items-center justify-center py-6">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-gray-400" />
|
||||
<span className="ml-2 text-sm text-gray-500">
|
||||
Loading timeline…
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!historyLoading && timelineEntries.length === 0 && (
|
||||
/* Fallback: no history data yet — show basic created/current status */
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-4">
|
||||
<div className="flex flex-col items-center">
|
||||
<div
|
||||
className={`w-3 h-3 rounded-full ${getTimelineDotColor(execution.status)}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">
|
||||
{getStatusLabel(execution.status)}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{new Date(execution.created).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{execution.status === ExecutionStatus.COMPLETED && (
|
||||
<div className="flex gap-4">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="w-3 h-3 rounded-full bg-green-500" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">Execution Completed</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{new Date(execution.updated).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!historyLoading && timelineEntries.length > 0 && (
|
||||
<div className="space-y-0">
|
||||
{timelineEntries.map((entry, idx) => {
|
||||
const isLast = idx === timelineEntries.length - 1;
|
||||
const time = new Date(entry.time);
|
||||
const prevTime =
|
||||
idx > 0 ? new Date(timelineEntries[idx - 1].time) : null;
|
||||
const durationMs = prevTime
|
||||
? time.getTime() - prevTime.getTime()
|
||||
: null;
|
||||
|
||||
{execution.status === ExecutionStatus.FAILED && (
|
||||
<div className="flex gap-4">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="w-3 h-3 rounded-full bg-red-500" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">Execution Failed</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{new Date(execution.updated).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
return (
|
||||
<div key={`${entry.status}-${idx}`} className="flex gap-4">
|
||||
<div className="flex flex-col items-center">
|
||||
<div
|
||||
className={`w-3 h-3 rounded-full flex-shrink-0 ${getTimelineDotColor(entry.status)}${
|
||||
isLast && isRunning ? " animate-pulse" : ""
|
||||
}`}
|
||||
/>
|
||||
{!isLast && (
|
||||
<div className="w-0.5 flex-1 min-h-[24px] bg-gray-200" />
|
||||
)}
|
||||
</div>
|
||||
<div className={`flex-1 ${!isLast ? "pb-4" : ""}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-medium">
|
||||
{getStatusLabel(entry.status)}
|
||||
</p>
|
||||
<span
|
||||
className={`px-1.5 py-0.5 text-[10px] font-medium rounded ${getStatusColor(entry.status)}`}
|
||||
>
|
||||
{entry.status}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500">
|
||||
{time.toLocaleString()}
|
||||
<span className="text-gray-400 ml-2 text-xs">
|
||||
({formatDistanceToNow(time, { addSuffix: true })})
|
||||
</span>
|
||||
</p>
|
||||
{durationMs !== null && durationMs > 0 && (
|
||||
<p className="text-xs text-gray-400 mt-0.5">
|
||||
+{formatDuration(durationMs)} since previous
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{isRunning && (
|
||||
<div className="flex gap-4">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="w-3 h-3 rounded-full bg-blue-500 animate-pulse" />
|
||||
{isRunning && (
|
||||
<div className="flex gap-4 pt-4">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="w-3 h-3 rounded-full bg-blue-500 animate-pulse" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-blue-600">In Progress…</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-blue-600">In Progress...</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -349,6 +503,15 @@ export default function ExecutionDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Change History */}
|
||||
<div className="mt-6">
|
||||
<EntityHistoryPanel
|
||||
entityType="execution"
|
||||
entityId={execution.id}
|
||||
title="Execution History"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user