"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 = { 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 ( {isRunning && ( )} {status.toLowerCase()} ); } // ─── Main Page Component ────────────────────────────────────────────── export default function LogsPage(): ReactElement { const workspaceId = useWorkspaceId(); // Data state const [jobs, setJobs] = useState([]); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); // Expanded job and steps const [expandedJobId, setExpandedJobId] = useState(null); const [jobStepsMap, setJobStepsMap] = useState>({}); const [stepsLoading, setStepsLoading] = useState>(new Set()); // Filters const [statusFilter, setStatusFilter] = useState("all"); const [dateRange, setDateRange] = useState("7d"); const [searchQuery, setSearchQuery] = useState(""); // Auto-refresh const [autoRefresh, setAutoRefresh] = useState(false); const intervalRef = useRef | null>(null); // Hover state const [hoveredRowId, setHoveredRowId] = useState(null); // ─── Data Loading ───────────────────────────────────────────────── const loadJobs = useCallback(async (): Promise => { try { const statusEnums = STATUS_FILTER_TO_ENUM[statusFilter]; const filters: Parameters[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 (
{/* Pulse animation for running status */} {/* ─── Header ─────────────────────────────────────────────── */}

Logs & Telemetry

Runner job history and step-level detail

{/* Auto-refresh toggle */} {/* Manual refresh */}
{/* ─── Filter Bar ─────────────────────────────────────────── */}
{/* Status filter */} {/* Date range tabs */}
{DATE_RANGES.map((range) => ( ))}
{/* Search input */} { 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, }} />
{/* ─── Content ────────────────────────────────────────────── */} {isLoading && jobs.length === 0 ? (
) : error !== null ? (

{error}

) : filteredJobs.length === 0 ? (

No jobs found

) : ( /* ─── Job Table ──────────────────────────────────────────── */
{["Job Type", "Status", "Started", "Duration", "Steps"].map((header) => ( ))} {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 ( { toggleExpand(job.id); }} onMouseEnter={() => { setHoveredRowId(job.id); }} onMouseLeave={() => { setHoveredRowId(null); }} /> ); })}
{header}
)}
); } // ─── 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 ( <> {job.type} {formatRelativeTime(job.startedAt ?? job.createdAt)} {formatDuration(job.startedAt, job.completedAt)} {steps ? String(steps.length) : "\u2014"} {/* Expanded Steps Section */} {isExpanded && (
{isStepsLoading ? (
) : !steps || steps.length === 0 ? (

No steps recorded for this job

) : ( {["#", "Name", "Phase", "Status", "Duration"].map((header) => ( ))} {steps .sort((a, b) => a.ordinal - b.ordinal) .map((step) => ( ))}
{header}
)} {/* Job error message if failed */} {job.error && (
{job.error}
)}
)} ); } // ─── Step Row Component ─────────────────────────────────────────────── function StepRow({ step }: { step: JobStep }): ReactElement { const [hovered, setHovered] = useState(false); return ( { 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", }} > {String(step.ordinal)} {step.name} {step.phase} {formatStepDuration(step.durationMs)} ); }