feat(web): add logs and telemetry page with filtering and auto-refresh (#480)
Some checks failed
ci/woodpecker/push/web Pipeline failed
Some checks failed
ci/woodpecker/push/web Pipeline failed
Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
This commit was merged in pull request #480.
This commit is contained in:
851
apps/web/src/app/(authenticated)/logs/page.tsx
Normal file
851
apps/web/src/app/(authenticated)/logs/page.tsx
Normal file
@@ -0,0 +1,851 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
|
import type { ReactElement } from "react";
|
||||||
|
|
||||||
|
import { MosaicSpinner } from "@/components/ui/MosaicSpinner";
|
||||||
|
import { fetchRunnerJobs, fetchJobSteps, RunnerJobStatus } from "@/lib/api/runner-jobs";
|
||||||
|
import type { RunnerJob, JobStep } from "@/lib/api/runner-jobs";
|
||||||
|
import { useWorkspaceId } from "@/lib/hooks";
|
||||||
|
|
||||||
|
// ─── Constants ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type StatusFilter = "all" | "running" | "completed" | "failed" | "queued";
|
||||||
|
type DateRange = "24h" | "7d" | "30d" | "all";
|
||||||
|
|
||||||
|
const STATUS_OPTIONS: { value: StatusFilter; label: string }[] = [
|
||||||
|
{ value: "all", label: "All statuses" },
|
||||||
|
{ value: "running", label: "Running" },
|
||||||
|
{ value: "completed", label: "Completed" },
|
||||||
|
{ value: "failed", label: "Failed" },
|
||||||
|
{ value: "queued", label: "Queued" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const DATE_RANGES: { value: DateRange; label: string }[] = [
|
||||||
|
{ value: "24h", label: "Last 24h" },
|
||||||
|
{ value: "7d", label: "7d" },
|
||||||
|
{ value: "30d", label: "30d" },
|
||||||
|
{ value: "all", label: "All" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const STATUS_FILTER_TO_ENUM: Record<StatusFilter, RunnerJobStatus[] | undefined> = {
|
||||||
|
all: undefined,
|
||||||
|
running: [RunnerJobStatus.RUNNING],
|
||||||
|
completed: [RunnerJobStatus.COMPLETED],
|
||||||
|
failed: [RunnerJobStatus.FAILED],
|
||||||
|
queued: [RunnerJobStatus.QUEUED, RunnerJobStatus.PENDING],
|
||||||
|
};
|
||||||
|
|
||||||
|
const POLL_INTERVAL_MS = 5_000;
|
||||||
|
|
||||||
|
// ─── Helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function getStatusColor(status: string): string {
|
||||||
|
switch (status) {
|
||||||
|
case "RUNNING":
|
||||||
|
return "var(--ms-amber-400)";
|
||||||
|
case "COMPLETED":
|
||||||
|
return "var(--ms-teal-400)";
|
||||||
|
case "FAILED":
|
||||||
|
case "CANCELLED":
|
||||||
|
return "var(--danger)";
|
||||||
|
case "QUEUED":
|
||||||
|
case "PENDING":
|
||||||
|
return "var(--ms-blue-400)";
|
||||||
|
default:
|
||||||
|
return "var(--muted)";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRelativeTime(dateStr: string | null): string {
|
||||||
|
if (!dateStr) return "\u2014";
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
const now = Date.now();
|
||||||
|
const diffMs = now - date.getTime();
|
||||||
|
const diffSec = Math.floor(diffMs / 1_000);
|
||||||
|
const diffMin = Math.floor(diffSec / 60);
|
||||||
|
const diffHr = Math.floor(diffMin / 60);
|
||||||
|
const diffDay = Math.floor(diffHr / 24);
|
||||||
|
|
||||||
|
if (diffSec < 60) return "just now";
|
||||||
|
if (diffMin < 60) return `${String(diffMin)}m ago`;
|
||||||
|
if (diffHr < 24) return `${String(diffHr)}h ago`;
|
||||||
|
if (diffDay < 30) return `${String(diffDay)}d ago`;
|
||||||
|
return date.toLocaleDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(startedAt: string | null, completedAt: string | null): string {
|
||||||
|
if (!startedAt) return "\u2014";
|
||||||
|
const start = new Date(startedAt).getTime();
|
||||||
|
const end = completedAt ? new Date(completedAt).getTime() : Date.now();
|
||||||
|
const ms = end - start;
|
||||||
|
if (ms < 1_000) return `${String(ms)}ms`;
|
||||||
|
const sec = Math.floor(ms / 1_000);
|
||||||
|
if (sec < 60) return `${String(sec)}s`;
|
||||||
|
const min = Math.floor(sec / 60);
|
||||||
|
const remainSec = sec % 60;
|
||||||
|
return `${String(min)}m ${String(remainSec)}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatStepDuration(durationMs: number | null): string {
|
||||||
|
if (durationMs === null) return "\u2014";
|
||||||
|
if (durationMs < 1_000) return `${String(durationMs)}ms`;
|
||||||
|
const sec = Math.floor(durationMs / 1_000);
|
||||||
|
if (sec < 60) return `${String(sec)}s`;
|
||||||
|
const min = Math.floor(sec / 60);
|
||||||
|
const remainSec = sec % 60;
|
||||||
|
return `${String(min)}m ${String(remainSec)}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWithinDateRange(dateStr: string, range: DateRange): boolean {
|
||||||
|
if (range === "all") return true;
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
const now = Date.now();
|
||||||
|
const hours = range === "24h" ? 24 : range === "7d" ? 168 : 720;
|
||||||
|
return now - date.getTime() < hours * 60 * 60 * 1_000;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Status Badge ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function StatusBadge({ status }: { status: string }): ReactElement {
|
||||||
|
const color = getStatusColor(status);
|
||||||
|
const isRunning = status === "RUNNING";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 6,
|
||||||
|
padding: "2px 10px",
|
||||||
|
borderRadius: 9999,
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
color,
|
||||||
|
background: `color-mix(in srgb, ${color} 15%, transparent)`,
|
||||||
|
border: `1px solid color-mix(in srgb, ${color} 30%, transparent)`,
|
||||||
|
textTransform: "capitalize",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isRunning && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: 6,
|
||||||
|
height: 6,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: color,
|
||||||
|
animation: "pulse 1.5s ease-in-out infinite",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{status.toLowerCase()}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main Page Component ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function LogsPage(): ReactElement {
|
||||||
|
const workspaceId = useWorkspaceId();
|
||||||
|
|
||||||
|
// Data state
|
||||||
|
const [jobs, setJobs] = useState<RunnerJob[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Expanded job and steps
|
||||||
|
const [expandedJobId, setExpandedJobId] = useState<string | null>(null);
|
||||||
|
const [jobStepsMap, setJobStepsMap] = useState<Record<string, JobStep[]>>({});
|
||||||
|
const [stepsLoading, setStepsLoading] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
// Filters
|
||||||
|
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
|
||||||
|
const [dateRange, setDateRange] = useState<DateRange>("7d");
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
|
||||||
|
// Auto-refresh
|
||||||
|
const [autoRefresh, setAutoRefresh] = useState(false);
|
||||||
|
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
|
// Hover state
|
||||||
|
const [hoveredRowId, setHoveredRowId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// ─── Data Loading ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const loadJobs = useCallback(async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const statusEnums = STATUS_FILTER_TO_ENUM[statusFilter];
|
||||||
|
const filters: Parameters<typeof fetchRunnerJobs>[0] = {};
|
||||||
|
if (workspaceId) {
|
||||||
|
filters.workspaceId = workspaceId;
|
||||||
|
}
|
||||||
|
if (statusEnums) {
|
||||||
|
filters.status = statusEnums;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await fetchRunnerJobs(filters);
|
||||||
|
setJobs(data);
|
||||||
|
setError(null);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.error("[Logs] Failed to fetch runner jobs:", err);
|
||||||
|
setError(
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: "We had trouble loading jobs. Please try again when you're ready."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [workspaceId, statusFilter]);
|
||||||
|
|
||||||
|
// Initial load
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
loadJobs()
|
||||||
|
.then(() => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (): void => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [loadJobs]);
|
||||||
|
|
||||||
|
// Auto-refresh polling
|
||||||
|
useEffect(() => {
|
||||||
|
if (autoRefresh) {
|
||||||
|
intervalRef.current = setInterval(() => {
|
||||||
|
void loadJobs();
|
||||||
|
}, POLL_INTERVAL_MS);
|
||||||
|
} else if (intervalRef.current) {
|
||||||
|
clearInterval(intervalRef.current);
|
||||||
|
intervalRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (): void => {
|
||||||
|
if (intervalRef.current) {
|
||||||
|
clearInterval(intervalRef.current);
|
||||||
|
intervalRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [autoRefresh, loadJobs]);
|
||||||
|
|
||||||
|
// ─── Steps Loading ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const toggleExpand = useCallback(
|
||||||
|
(jobId: string) => {
|
||||||
|
if (expandedJobId === jobId) {
|
||||||
|
setExpandedJobId(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setExpandedJobId(jobId);
|
||||||
|
|
||||||
|
// Load steps if not already loaded
|
||||||
|
if (!jobStepsMap[jobId] && !stepsLoading.has(jobId)) {
|
||||||
|
setStepsLoading((prev) => new Set(prev).add(jobId));
|
||||||
|
|
||||||
|
fetchJobSteps(jobId, workspaceId ?? undefined)
|
||||||
|
.then((steps) => {
|
||||||
|
setJobStepsMap((prev) => ({ ...prev, [jobId]: steps }));
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
console.error("[Logs] Failed to fetch steps for job:", jobId, err);
|
||||||
|
setJobStepsMap((prev) => ({ ...prev, [jobId]: [] }));
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setStepsLoading((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(jobId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[expandedJobId, jobStepsMap, stepsLoading, workspaceId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── Filtering ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const filteredJobs = jobs.filter((job) => {
|
||||||
|
// Date range filter
|
||||||
|
if (!isWithinDateRange(job.createdAt, dateRange)) return false;
|
||||||
|
|
||||||
|
// Search filter
|
||||||
|
if (searchQuery.trim()) {
|
||||||
|
const q = searchQuery.toLowerCase();
|
||||||
|
const matchesType = job.type.toLowerCase().includes(q);
|
||||||
|
const matchesId = job.id.toLowerCase().includes(q);
|
||||||
|
if (!matchesType && !matchesId) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Manual Refresh ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
const handleManualRefresh = (): void => {
|
||||||
|
setIsLoading(true);
|
||||||
|
void loadJobs().finally(() => {
|
||||||
|
setIsLoading(false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRetry = (): void => {
|
||||||
|
setError(null);
|
||||||
|
handleManualRefresh();
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Render ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="container mx-auto px-4 py-8">
|
||||||
|
{/* Pulse animation for running status */}
|
||||||
|
<style>{`
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.4; }
|
||||||
|
}
|
||||||
|
@keyframes auto-refresh-spin {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
|
||||||
|
{/* ─── Header ─────────────────────────────────────────────── */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: 16,
|
||||||
|
marginBottom: 32,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold" style={{ color: "var(--text)" }}>
|
||||||
|
Logs & Telemetry
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1" style={{ color: "var(--text-muted)" }}>
|
||||||
|
Runner job history and step-level detail
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||||
|
{/* Auto-refresh toggle */}
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setAutoRefresh((prev) => !prev);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
padding: "8px 14px",
|
||||||
|
borderRadius: 8,
|
||||||
|
fontSize: "0.82rem",
|
||||||
|
fontWeight: 500,
|
||||||
|
cursor: "pointer",
|
||||||
|
border: `1px solid ${autoRefresh ? "var(--ms-teal-400)" : "var(--border)"}`,
|
||||||
|
background: autoRefresh
|
||||||
|
? "color-mix(in srgb, var(--ms-teal-400) 12%, transparent)"
|
||||||
|
: "var(--surface)",
|
||||||
|
color: autoRefresh ? "var(--ms-teal-400)" : "var(--text-muted)",
|
||||||
|
transition: "all 150ms ease",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{autoRefresh && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: "var(--ms-teal-400)",
|
||||||
|
animation: "pulse 1.5s ease-in-out infinite",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
Auto-refresh {autoRefresh ? "on" : "off"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Manual refresh */}
|
||||||
|
<button
|
||||||
|
onClick={handleManualRefresh}
|
||||||
|
disabled={isLoading}
|
||||||
|
style={{
|
||||||
|
padding: "8px 14px",
|
||||||
|
borderRadius: 8,
|
||||||
|
fontSize: "0.82rem",
|
||||||
|
fontWeight: 500,
|
||||||
|
cursor: isLoading ? "not-allowed" : "pointer",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
background: "var(--surface)",
|
||||||
|
color: "var(--text-muted)",
|
||||||
|
opacity: isLoading ? 0.5 : 1,
|
||||||
|
transition: "all 150ms ease",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ─── Filter Bar ─────────────────────────────────────────── */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 12,
|
||||||
|
marginBottom: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Status filter */}
|
||||||
|
<select
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(e) => {
|
||||||
|
setStatusFilter(e.target.value as StatusFilter);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: "8px 12px",
|
||||||
|
borderRadius: 8,
|
||||||
|
fontSize: "0.82rem",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
background: "var(--surface)",
|
||||||
|
color: "var(--text)",
|
||||||
|
cursor: "pointer",
|
||||||
|
minWidth: 140,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{STATUS_OPTIONS.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* Date range tabs */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
borderRadius: 8,
|
||||||
|
overflow: "hidden",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{DATE_RANGES.map((range) => (
|
||||||
|
<button
|
||||||
|
key={range.value}
|
||||||
|
onClick={() => {
|
||||||
|
setDateRange(range.value);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: "8px 14px",
|
||||||
|
fontSize: "0.82rem",
|
||||||
|
fontWeight: 500,
|
||||||
|
cursor: "pointer",
|
||||||
|
border: "none",
|
||||||
|
borderRight: "1px solid var(--border)",
|
||||||
|
background: dateRange === range.value ? "var(--primary)" : "var(--surface)",
|
||||||
|
color: dateRange === range.value ? "#fff" : "var(--text-muted)",
|
||||||
|
transition: "all 150ms ease",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{range.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search input */}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search by job type..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSearchQuery(e.target.value);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: "8px 12px",
|
||||||
|
borderRadius: 8,
|
||||||
|
fontSize: "0.82rem",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
background: "var(--surface)",
|
||||||
|
color: "var(--text)",
|
||||||
|
minWidth: 200,
|
||||||
|
flex: "1 1 200px",
|
||||||
|
maxWidth: 320,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ─── Content ────────────────────────────────────────────── */}
|
||||||
|
{isLoading && jobs.length === 0 ? (
|
||||||
|
<div className="flex justify-center py-16">
|
||||||
|
<MosaicSpinner label="Loading jobs..." />
|
||||||
|
</div>
|
||||||
|
) : error !== null ? (
|
||||||
|
<div
|
||||||
|
className="rounded-lg p-6 text-center"
|
||||||
|
style={{
|
||||||
|
background: "var(--surface)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p style={{ color: "var(--danger)" }}>{error}</p>
|
||||||
|
<button
|
||||||
|
onClick={handleRetry}
|
||||||
|
className="mt-4 rounded-md px-4 py-2 text-sm font-medium text-white transition-colors"
|
||||||
|
style={{ background: "var(--danger)", cursor: "pointer", border: "none" }}
|
||||||
|
>
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : filteredJobs.length === 0 ? (
|
||||||
|
<div
|
||||||
|
className="rounded-lg p-8 text-center"
|
||||||
|
style={{
|
||||||
|
background: "var(--surface)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p style={{ color: "var(--text-muted)" }}>No jobs found</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* ─── Job Table ──────────────────────────────────────────── */
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
borderRadius: 12,
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ overflowX: "auto" }}>
|
||||||
|
<table style={{ width: "100%", borderCollapse: "collapse" }}>
|
||||||
|
<thead>
|
||||||
|
<tr
|
||||||
|
style={{
|
||||||
|
background: "var(--bg-mid)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{["Job Type", "Status", "Started", "Duration", "Steps"].map((header) => (
|
||||||
|
<th
|
||||||
|
key={header}
|
||||||
|
style={{
|
||||||
|
padding: "10px 16px",
|
||||||
|
textAlign: "left",
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: "0.05em",
|
||||||
|
color: "var(--muted)",
|
||||||
|
fontFamily: "var(--mono)",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{header}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filteredJobs.map((job) => {
|
||||||
|
const isExpanded = expandedJobId === job.id;
|
||||||
|
const isHovered = hoveredRowId === job.id;
|
||||||
|
const steps = jobStepsMap[job.id];
|
||||||
|
const isStepsLoading = stepsLoading.has(job.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<JobRow
|
||||||
|
key={job.id}
|
||||||
|
job={job}
|
||||||
|
isExpanded={isExpanded}
|
||||||
|
isHovered={isHovered}
|
||||||
|
steps={steps}
|
||||||
|
isStepsLoading={isStepsLoading}
|
||||||
|
onToggle={() => {
|
||||||
|
toggleExpand(job.id);
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => {
|
||||||
|
setHoveredRowId(job.id);
|
||||||
|
}}
|
||||||
|
onMouseLeave={() => {
|
||||||
|
setHoveredRowId(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Job Row Component ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function JobRow({
|
||||||
|
job,
|
||||||
|
isExpanded,
|
||||||
|
isHovered,
|
||||||
|
steps,
|
||||||
|
isStepsLoading,
|
||||||
|
onToggle,
|
||||||
|
onMouseEnter,
|
||||||
|
onMouseLeave,
|
||||||
|
}: {
|
||||||
|
job: RunnerJob;
|
||||||
|
isExpanded: boolean;
|
||||||
|
isHovered: boolean;
|
||||||
|
steps: JobStep[] | undefined;
|
||||||
|
isStepsLoading: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
onMouseEnter: () => void;
|
||||||
|
onMouseLeave: () => void;
|
||||||
|
}): ReactElement {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<tr
|
||||||
|
onClick={onToggle}
|
||||||
|
onMouseEnter={onMouseEnter}
|
||||||
|
onMouseLeave={onMouseLeave}
|
||||||
|
style={{
|
||||||
|
background: isExpanded
|
||||||
|
? "var(--surface-2)"
|
||||||
|
: isHovered
|
||||||
|
? "var(--surface-2)"
|
||||||
|
: "var(--surface)",
|
||||||
|
cursor: "pointer",
|
||||||
|
borderBottom: isExpanded ? "none" : "1px solid var(--border)",
|
||||||
|
transition: "background 100ms ease",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<td
|
||||||
|
style={{
|
||||||
|
padding: "12px 16px",
|
||||||
|
fontSize: "0.85rem",
|
||||||
|
fontWeight: 500,
|
||||||
|
color: "var(--text)",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ display: "inline-flex", alignItems: "center", gap: 8 }}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: "inline-block",
|
||||||
|
width: 16,
|
||||||
|
textAlign: "center",
|
||||||
|
fontSize: "0.7rem",
|
||||||
|
color: "var(--muted)",
|
||||||
|
transition: "transform 150ms ease",
|
||||||
|
transform: isExpanded ? "rotate(90deg)" : "rotate(0deg)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
▶
|
||||||
|
</span>
|
||||||
|
{job.type}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: "12px 16px" }}>
|
||||||
|
<StatusBadge status={job.status} />
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
style={{
|
||||||
|
padding: "12px 16px",
|
||||||
|
fontSize: "0.82rem",
|
||||||
|
fontFamily: "var(--mono)",
|
||||||
|
color: "var(--text-muted)",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formatRelativeTime(job.startedAt ?? job.createdAt)}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
style={{
|
||||||
|
padding: "12px 16px",
|
||||||
|
fontSize: "0.82rem",
|
||||||
|
fontFamily: "var(--mono)",
|
||||||
|
color: "var(--text-muted)",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formatDuration(job.startedAt, job.completedAt)}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
style={{
|
||||||
|
padding: "12px 16px",
|
||||||
|
fontSize: "0.82rem",
|
||||||
|
fontFamily: "var(--mono)",
|
||||||
|
color: "var(--text-muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{steps ? String(steps.length) : "\u2014"}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
{/* Expanded Steps Section */}
|
||||||
|
{isExpanded && (
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
colSpan={5}
|
||||||
|
style={{
|
||||||
|
padding: 0,
|
||||||
|
borderBottom: "1px solid var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: "var(--bg-mid)",
|
||||||
|
padding: "12px 16px 12px 48px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isStepsLoading ? (
|
||||||
|
<div style={{ display: "flex", justifyContent: "center", padding: 16 }}>
|
||||||
|
<MosaicSpinner size={24} label="Loading steps..." />
|
||||||
|
</div>
|
||||||
|
) : !steps || steps.length === 0 ? (
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontSize: "0.82rem",
|
||||||
|
color: "var(--text-muted)",
|
||||||
|
padding: "8px 0",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
No steps recorded for this job
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<table style={{ width: "100%", borderCollapse: "collapse" }}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{["#", "Name", "Phase", "Status", "Duration"].map((header) => (
|
||||||
|
<th
|
||||||
|
key={header}
|
||||||
|
style={{
|
||||||
|
padding: "6px 12px",
|
||||||
|
textAlign: "left",
|
||||||
|
fontSize: "0.7rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: "0.05em",
|
||||||
|
color: "var(--muted)",
|
||||||
|
fontFamily: "var(--mono)",
|
||||||
|
borderBottom: "1px solid var(--border)",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{header}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{steps
|
||||||
|
.sort((a, b) => a.ordinal - b.ordinal)
|
||||||
|
.map((step) => (
|
||||||
|
<StepRow key={step.id} step={step} />
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Job error message if failed */}
|
||||||
|
{job.error && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: 12,
|
||||||
|
padding: "8px 12px",
|
||||||
|
borderRadius: 6,
|
||||||
|
fontSize: "0.78rem",
|
||||||
|
fontFamily: "var(--mono)",
|
||||||
|
color: "var(--danger)",
|
||||||
|
background: "color-mix(in srgb, var(--danger) 8%, transparent)",
|
||||||
|
border: "1px solid color-mix(in srgb, var(--danger) 20%, transparent)",
|
||||||
|
wordBreak: "break-all",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{job.error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Step Row Component ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
function StepRow({ step }: { step: JobStep }): ReactElement {
|
||||||
|
const [hovered, setHovered] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
onMouseEnter={() => {
|
||||||
|
setHovered(true);
|
||||||
|
}}
|
||||||
|
onMouseLeave={() => {
|
||||||
|
setHovered(false);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
background: hovered ? "color-mix(in srgb, var(--surface) 50%, transparent)" : "transparent",
|
||||||
|
borderBottom: "1px solid color-mix(in srgb, var(--border) 50%, transparent)",
|
||||||
|
transition: "background 100ms ease",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<td
|
||||||
|
style={{
|
||||||
|
padding: "6px 12px",
|
||||||
|
fontSize: "0.78rem",
|
||||||
|
fontFamily: "var(--mono)",
|
||||||
|
color: "var(--muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{String(step.ordinal)}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
style={{
|
||||||
|
padding: "6px 12px",
|
||||||
|
fontSize: "0.8rem",
|
||||||
|
color: "var(--text)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{step.name}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
style={{
|
||||||
|
padding: "6px 12px",
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
fontFamily: "var(--mono)",
|
||||||
|
color: "var(--text-muted)",
|
||||||
|
textTransform: "lowercase",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{step.phase}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: "6px 12px" }}>
|
||||||
|
<StatusBadge status={step.status} />
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
style={{
|
||||||
|
padding: "6px 12px",
|
||||||
|
fontSize: "0.78rem",
|
||||||
|
fontFamily: "var(--mono)",
|
||||||
|
color: "var(--text-muted)",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formatStepDuration(step.durationMs)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -98,3 +98,66 @@ export async function fetchRunnerJobs(filters?: RunnerJobFilters): Promise<Runne
|
|||||||
export async function fetchRunnerJob(id: string, workspaceId?: string): Promise<RunnerJob> {
|
export async function fetchRunnerJob(id: string, workspaceId?: string): Promise<RunnerJob> {
|
||||||
return apiGet<RunnerJob>(`/api/runner-jobs/${id}`, workspaceId);
|
return apiGet<RunnerJob>(`/api/runner-jobs/${id}`, workspaceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Job Steps ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Job step phase enum (matches backend JobStepPhase)
|
||||||
|
*/
|
||||||
|
export enum JobStepPhase {
|
||||||
|
SETUP = "SETUP",
|
||||||
|
EXECUTION = "EXECUTION",
|
||||||
|
VALIDATION = "VALIDATION",
|
||||||
|
CLEANUP = "CLEANUP",
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Job step type enum (matches backend JobStepType)
|
||||||
|
*/
|
||||||
|
export enum JobStepType {
|
||||||
|
COMMAND = "COMMAND",
|
||||||
|
AI_ACTION = "AI_ACTION",
|
||||||
|
GATE = "GATE",
|
||||||
|
ARTIFACT = "ARTIFACT",
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Job step status enum (matches backend JobStepStatus)
|
||||||
|
*/
|
||||||
|
export enum JobStepStatus {
|
||||||
|
PENDING = "PENDING",
|
||||||
|
RUNNING = "RUNNING",
|
||||||
|
COMPLETED = "COMPLETED",
|
||||||
|
FAILED = "FAILED",
|
||||||
|
SKIPPED = "SKIPPED",
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Job step response interface (matches Prisma JobStep model)
|
||||||
|
*/
|
||||||
|
export interface JobStep {
|
||||||
|
id: string;
|
||||||
|
jobId: string;
|
||||||
|
ordinal: number;
|
||||||
|
phase: JobStepPhase;
|
||||||
|
name: string;
|
||||||
|
type: JobStepType;
|
||||||
|
status: JobStepStatus;
|
||||||
|
output: string | null;
|
||||||
|
tokensInput: number | null;
|
||||||
|
tokensOutput: number | null;
|
||||||
|
startedAt: string | null;
|
||||||
|
completedAt: string | null;
|
||||||
|
durationMs: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch job steps for a specific runner job
|
||||||
|
*/
|
||||||
|
export async function fetchJobSteps(jobId: string, workspaceId?: string): Promise<JobStep[]> {
|
||||||
|
const response = await apiGet<ApiResponse<JobStep[]>>(
|
||||||
|
`/api/runner-jobs/${jobId}/steps`,
|
||||||
|
workspaceId
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user