From f0b92ec594cf738e9dabd5a479f991b899f8e1e0 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 22 Feb 2026 22:36:40 -0600 Subject: [PATCH] feat(web): add logs and telemetry page with filtering and auto-refresh Refs #468 Co-Authored-By: Claude Opus 4.6 --- .../web/src/app/(authenticated)/logs/page.tsx | 851 ++++++++++++++++++ apps/web/src/lib/api/runner-jobs.ts | 63 ++ 2 files changed, 914 insertions(+) create mode 100644 apps/web/src/app/(authenticated)/logs/page.tsx diff --git a/apps/web/src/app/(authenticated)/logs/page.tsx b/apps/web/src/app/(authenticated)/logs/page.tsx new file mode 100644 index 0000000..ee6e9e5 --- /dev/null +++ b/apps/web/src/app/(authenticated)/logs/page.tsx @@ -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 = { + 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)} + + + ); +} diff --git a/apps/web/src/lib/api/runner-jobs.ts b/apps/web/src/lib/api/runner-jobs.ts index 7415bc5..35e1b46 100644 --- a/apps/web/src/lib/api/runner-jobs.ts +++ b/apps/web/src/lib/api/runner-jobs.ts @@ -98,3 +98,66 @@ export async function fetchRunnerJobs(filters?: RunnerJobFilters): Promise { return apiGet(`/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 { + const response = await apiGet>( + `/api/runner-jobs/${jobId}/steps`, + workspaceId + ); + return response.data; +} -- 2.49.1