diff --git a/apps/desktop/package.json b/apps/desktop/package.json index c91df0e0065..f55c71883f1 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -149,6 +149,7 @@ "@xterm/headless": "6.1.0-beta.195", "@xterm/xterm": "6.1.0-beta.195", "ai": "^6.0.0", + "ansi_up": "^6.0.6", "better-auth": "1.4.18", "better-sqlite3": "12.6.2", "bindings": "^1.5.0", diff --git a/apps/desktop/src/lib/trpc/routers/ui-state/index.ts b/apps/desktop/src/lib/trpc/routers/ui-state/index.ts index 4710ed60be8..996470cfa37 100644 --- a/apps/desktop/src/lib/trpc/routers/ui-state/index.ts +++ b/apps/desktop/src/lib/trpc/routers/ui-state/index.ts @@ -51,6 +51,7 @@ const paneSchema = z.object({ "devtools", "git-graph", "database-explorer", + "action-logs", ]), name: z.string(), isNew: z.boolean().optional(), @@ -99,6 +100,24 @@ const paneSchema = z.object({ connectionId: z.string().nullable(), }) .optional(), + actionLogs: z + .object({ + jobs: z.array( + z.object({ + detailsUrl: z.string(), + name: z.string(), + status: z.enum([ + "success", + "failure", + "pending", + "skipped", + "cancelled", + ]), + }), + ), + initialJobIndex: z.number().optional(), + }) + .optional(), workspaceRun: z .object({ workspaceId: z.string(), diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/git-status.ts b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/git-status.ts index 7cb48317fdf..2433a458e59 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/git-status.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/git-status.ts @@ -28,6 +28,7 @@ import { fetchCheckJobSteps, fetchGitHubPRComments, fetchGitHubPRStatus, + fetchStructuredJobLogs, getRepoContext, type PullRequestCommentsTarget, } from "../utils/github"; @@ -1570,5 +1571,34 @@ export const createGitStatusProcedures = () => { return fetchCheckJobSteps(repoPath, input.detailsUrl); }), + + getJobLogs: publicProcedure + .input( + z.object({ + workspaceId: z.string(), + detailsUrl: z.string(), + }), + ) + .query(async ({ input }) => { + const workspace = getWorkspace(input.workspaceId); + if (!workspace) { + return []; + } + + const worktree = workspace.worktreeId + ? getWorktree(workspace.worktreeId) + : null; + + let repoPath: string | null = worktree?.path ?? null; + if (!repoPath && workspace.type === "branch") { + const project = getProject(workspace.projectId); + repoPath = project?.mainRepoPath ?? null; + } + if (!repoPath) { + return []; + } + + return fetchStructuredJobLogs(repoPath, input.detailsUrl); + }), }); }; diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/github.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/github.ts index c95d0e6c840..dd030478e62 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/github.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/github.ts @@ -491,3 +491,106 @@ export async function fetchCheckJobSteps( return []; } } + +export interface StructuredJobStep { + name: string; + number: number; + status: "queued" | "in_progress" | "completed"; + conclusion: string | null; + durationSeconds: number | null; + logs: string; +} + +/** + * Fetches job step metadata and logs, returning structured per-step data. + */ +export async function fetchStructuredJobLogs( + worktreePath: string, + detailsUrl: string, +): Promise { + const jobId = parseJobIdFromUrl(detailsUrl); + const nwo = parseNwoFromActionsUrl(detailsUrl); + if (!jobId || !nwo) { + return []; + } + + try { + const [jobResult, logsResult] = await Promise.all([ + execWithShellEnv("gh", ["api", `repos/${nwo}/actions/jobs/${jobId}`], { + cwd: worktreePath, + }), + execWithShellEnv( + "gh", + ["api", `repos/${nwo}/actions/jobs/${jobId}/logs`], + { cwd: worktreePath, maxBuffer: 10 * 1024 * 1024 }, + ), + ]); + + const raw: unknown = JSON.parse(jobResult.stdout.trim()); + const result = GHJobResponseSchema.safeParse(raw); + if (!result.success || !result.data.steps) { + return []; + } + + const steps = result.data.steps; + const rawLogs = logsResult.stdout; + + // Parse raw logs into per-step sections. + // GitHub log format: each line starts with a timestamp like "2024-01-01T00:00:00.0000000Z " + // Steps are separated by ##[group] / ##[endgroup] markers, but these aren't always reliable. + // Instead, match by step started_at/completed_at time ranges. + const logLines = rawLogs.split("\n"); + const stepLogs: Map = new Map(); + + // Build time ranges for each step + const stepRanges = steps.map((step) => ({ + number: step.number, + start: step.started_at ? new Date(step.started_at).getTime() : 0, + end: step.completed_at + ? new Date(step.completed_at).getTime() + : Number.POSITIVE_INFINITY, + })); + + for (const line of logLines) { + const tsMatch = line.match( + /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z)\s/, + ); + if (!tsMatch) continue; + const lineTime = new Date(tsMatch[1]).getTime(); + const lineContent = line.slice(tsMatch[0].length); + + // Find which step this line belongs to + for (const range of stepRanges) { + if (lineTime >= range.start && lineTime <= range.end + 1000) { + if (!stepLogs.has(range.number)) { + stepLogs.set(range.number, []); + } + stepLogs.get(range.number)?.push(lineContent); + break; + } + } + } + + return steps.map((step) => { + let durationSeconds: number | null = null; + if (step.started_at && step.completed_at) { + durationSeconds = Math.round( + (new Date(step.completed_at).getTime() - + new Date(step.started_at).getTime()) / + 1000, + ); + } + return { + name: step.name, + number: step.number, + status: step.status, + conclusion: step.conclusion ?? null, + durationSeconds, + logs: stepLogs.get(step.number)?.join("\n") ?? "", + }; + }); + } catch (err) { + console.error("[fetchStructuredJobLogs] Failed:", err); + return []; + } +} diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/index.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/index.ts index 6589eec35bb..4485f51e72d 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/index.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/index.ts @@ -4,6 +4,7 @@ export { fetchCheckJobSteps, fetchGitHubPRComments, fetchGitHubPRStatus, + fetchStructuredJobLogs, resolveReviewThread, } from "./github"; export { getPRForBranch } from "./pr-resolution"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ActionLogsPane/ActionLogsPane.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ActionLogsPane/ActionLogsPane.tsx new file mode 100644 index 00000000000..d5e17bf61cf --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ActionLogsPane/ActionLogsPane.tsx @@ -0,0 +1,566 @@ +import { Button } from "@superset/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@superset/ui/dropdown-menu"; +import { toast } from "@superset/ui/sonner"; +import { cn } from "@superset/ui/utils"; +import { AnsiUp } from "ansi_up"; +import { useCallback, useRef, useState } from "react"; +import { + LuCheck, + LuChevronRight, + LuCircleSlash, + LuLoaderCircle, + LuMinus, + LuRefreshCw, + LuSearch, + LuSettings, + LuX, +} from "react-icons/lu"; +import type { MosaicBranch } from "react-mosaic-component"; +import { electronTrpc } from "renderer/lib/electron-trpc"; +import { useTabsStore } from "renderer/stores/tabs/store"; +import type { ActionLogsJob } from "shared/tabs-types"; +import { BasePaneWindow, PaneToolbarActions } from "../components"; + +// ── Status icon configs ── + +const statusIcon = { + success: { icon: LuCheck, className: "text-emerald-500" }, + failure: { icon: LuX, className: "text-red-500" }, + pending: { icon: LuLoaderCircle, className: "text-amber-500 animate-spin" }, + skipped: { icon: LuMinus, className: "text-muted-foreground" }, + cancelled: { icon: LuCircleSlash, className: "text-muted-foreground" }, +} as const; + +const stepStatusIcon = { + success: statusIcon.success, + failure: statusIcon.failure, + cancelled: statusIcon.cancelled, + skipped: statusIcon.skipped, + in_progress: statusIcon.pending, + queued: { icon: LuLoaderCircle, className: "text-muted-foreground" }, +} as const; + +function getStepIcon(status: string, conclusion: string | null) { + if (status === "completed" && conclusion) { + return ( + stepStatusIcon[conclusion as keyof typeof stepStatusIcon] ?? + stepStatusIcon.success + ); + } + return ( + stepStatusIcon[status as keyof typeof stepStatusIcon] ?? + stepStatusIcon.queued + ); +} + +function formatDuration(seconds: number | null): string { + if (seconds === null) return ""; + if (seconds < 60) return `${seconds}s`; + const m = Math.floor(seconds / 60); + const s = seconds % 60; + return s > 0 ? `${m}m ${s}s` : `${m}m`; +} + +// ── ANSI rendering ── + +const ansiUp = new AnsiUp(); +ansiUp.use_classes = false; + +function renderAnsiLine(line: string): string { + // Strip ##[group], ##[endgroup], ##[command] etc. markers + const cleaned = line.replace( + /##\[(group|endgroup|command|error|warning|notice|debug)\]/g, + "", + ); + return ansiUp.ansi_to_html(cleaned); +} + +function AnsiLine({ html, className }: { html: string; className?: string }) { + return ( + // biome-ignore lint/security/noDangerouslySetInnerHtml: ansi_up escapes HTML entities + + ); +} + +// ── Job steps component ── + +interface JobStepsProps { + workspaceId: string; + detailsUrl: string; + jobName: string; + jobStatus: string; + showTimestamps: boolean; + searchQuery: string; +} + +function JobSteps({ + workspaceId, + detailsUrl, + jobName, + jobStatus, + showTimestamps, + searchQuery, +}: JobStepsProps) { + const [expandedSteps, setExpandedSteps] = useState>(new Set()); + const rerunMutation = + electronTrpc.workspaces.rerunPullRequestChecks.useMutation(); + const trpcUtils = electronTrpc.useUtils(); + + const { data: steps, isLoading } = + electronTrpc.workspaces.getJobLogs.useQuery( + { workspaceId, detailsUrl }, + { staleTime: 30_000 }, + ); + + const toggleStep = (n: number) => { + setExpandedSteps((prev) => { + const next = new Set(prev); + next.has(n) ? next.delete(n) : next.add(n); + return next; + }); + }; + + const handleRerun = async (mode: "all" | "failed") => { + try { + const result = await rerunMutation.mutateAsync({ + workspaceId, + mode, + }); + toast.success( + `Re-running ${mode === "failed" ? "failed" : "all"} jobs (${result.rerunCount})`, + ); + void trpcUtils.workspaces.getReviewStatus.invalidate(); + } catch { + toast.error("Failed to re-run jobs"); + } + }; + + if (isLoading) { + return ( +
+ + Loading logs... +
+ ); + } + + if (!steps || steps.length === 0) { + return ( +
+ No logs available +
+ ); + } + + const totalSeconds = steps.reduce( + (sum, s) => sum + (s.durationSeconds ?? 0), + 0, + ); + const { icon: JobIcon, className: jobIconClass } = + statusIcon[jobStatus as keyof typeof statusIcon] ?? statusIcon.pending; + + const lowerQuery = searchQuery.toLowerCase(); + + return ( +
+ {/* Job header */} +
+
+
+ + {jobName} +
+ {totalSeconds > 0 && ( +

+ {formatDuration(totalSeconds)} +

+ )} +
+
+ + +
+
+ + {/* Steps */} + {steps.map((step) => { + const isExpanded = expandedSteps.has(step.number); + const { icon: StepIcon, className: iconClass } = getStepIcon( + step.status, + step.conclusion, + ); + const rawLines = step.logs ? step.logs.split("\n") : []; + + // Filter lines by search query + const filteredLines = + lowerQuery && rawLines.length > 0 + ? rawLines.filter((l) => l.toLowerCase().includes(lowerQuery)) + : rawLines; + + const matchesSearch = + !lowerQuery || + step.name.toLowerCase().includes(lowerQuery) || + filteredLines.length > 0; + + if (!matchesSearch) return null; + + return ( +
+ + {isExpanded && filteredLines.length > 0 && ( +
+
+									{filteredLines.map((line, i) => {
+										// Parse timestamp from original line if showTimestamps
+										const tsMatch = showTimestamps
+											? line.match(
+													/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z)\s/,
+												)
+											: null;
+										const displayLine = tsMatch
+											? line
+											: line.replace(
+													/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z\s/,
+													"",
+												);
+
+										return (
+											// biome-ignore lint/suspicious/noArrayIndexKey: log lines are static read-only content
+											
+ + {i + 1} + + +
+ ); + })} +
+
+ )} + {isExpanded && filteredLines.length === 0 && ( +
+ {lowerQuery ? "No matching lines" : "No log output"} +
+ )} +
+ ); + })} +
+ ); +} + +// ── Main pane ── + +interface ActionLogsPaneProps { + paneId: string; + path: MosaicBranch[]; + tabId: string; + workspaceId: string; + splitPaneAuto: ( + tabId: string, + sourcePaneId: string, + dimensions: { width: number; height: number }, + path?: MosaicBranch[], + ) => void; + removePane: (paneId: string) => void; + setFocusedPane: (tabId: string, paneId: string) => void; + onPopOut?: () => void; +} + +export function ActionLogsPane({ + paneId, + path, + tabId, + workspaceId, + splitPaneAuto, + removePane, + setFocusedPane, + onPopOut, +}: ActionLogsPaneProps) { + const pane = useTabsStore((s) => s.panes[paneId]); + const jobs: ActionLogsJob[] = pane?.actionLogs?.jobs ?? []; + const initialIndex = pane?.actionLogs?.initialJobIndex ?? 0; + const [selectedIndex, setSelectedIndex] = useState(initialIndex); + const [showTimestamps, setShowTimestamps] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + const [searchOpen, setSearchOpen] = useState(false); + const searchInputRef = useRef(null); + const [sidebarWidth, setSidebarWidth] = useState(208); + const isDragging = useRef(false); + const selectedJob = jobs[selectedIndex]; + + const browserUrl = selectedJob?.detailsUrl?.match( + /(https:\/\/github\.com\/[^/]+\/[^/]+\/actions\/runs\/\d+\/job\/\d+)/, + )?.[1]; + + const _runUrl = selectedJob?.detailsUrl?.match( + /(https:\/\/github\.com\/[^/]+\/[^/]+\/actions\/runs\/\d+)/, + )?.[1]; + + const handleResizeStart = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + isDragging.current = true; + const startX = e.clientX; + const startWidth = sidebarWidth; + + const onMouseMove = (ev: MouseEvent) => { + const newWidth = Math.min( + 400, + Math.max(120, startWidth + ev.clientX - startX), + ); + setSidebarWidth(newWidth); + }; + const onMouseUp = () => { + isDragging.current = false; + document.removeEventListener("mousemove", onMouseMove); + document.removeEventListener("mouseup", onMouseUp); + }; + document.addEventListener("mousemove", onMouseMove); + document.addEventListener("mouseup", onMouseUp); + }, + [sidebarWidth], + ); + + const handleToggleSearch = useCallback(() => { + setSearchOpen((prev) => { + if (!prev) { + setTimeout(() => searchInputRef.current?.focus(), 0); + } else { + setSearchQuery(""); + } + return !prev; + }); + }, []); + + // Get raw logs URL for "View raw logs" + const rawLogsUrl = browserUrl ? `${browserUrl}?pr=` : undefined; + + return ( + ( +
+ + Action Logs + + +
+ {/* Search */} + {searchOpen && ( +
+ + setSearchQuery(e.target.value)} + className="h-5 w-36 bg-transparent text-xs outline-none placeholder:text-muted-foreground/60" + onKeyDown={(e) => { + if (e.key === "Escape") { + handleToggleSearch(); + } + }} + /> + {searchQuery && ( + + )} +
+ )} + + + {/* Settings dropdown */} + + + + + + setShowTimestamps((p) => !p)}> + {showTimestamps ? "Hide" : "Show"} timestamps + + + {browserUrl && ( + + + View on GitHub + + + )} + {rawLogsUrl && ( + + + View raw logs + + + )} + + + + +
+
+ )} + > + {jobs.length === 0 ? ( +
+ No jobs configured +
+ ) : ( +
+ {/* Job sidebar */} +
+
+ All jobs +
+ {jobs.map((job, i) => { + const { icon: JobIcon, className: jobIconClass } = + statusIcon[job.status]; + return ( + + ); + })} + {/* Resize handle */} +
+ {/* Step detail */} +
+ {selectedJob && ( + + )} +
+
+ )} +
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ActionLogsPane/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ActionLogsPane/index.ts new file mode 100644 index 00000000000..e4242bf980e --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ActionLogsPane/index.ts @@ -0,0 +1 @@ +export { ActionLogsPane } from "./ActionLogsPane"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/index.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/index.tsx index bab84ee818a..d77d4f83fe1 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/index.tsx @@ -20,6 +20,7 @@ import { getPaneIdSetForTab, } from "renderer/stores/tabs/utils"; import { useTheme } from "renderer/stores/theme"; +import { ActionLogsPane } from "./ActionLogsPane"; import { BrowserPane } from "./BrowserPane"; import { ChatPane } from "./ChatPane"; import { MosaicSplitOverlay } from "./components"; @@ -314,6 +315,22 @@ export function TabView({ tab }: TabViewProps) { ); } + // Route action-logs panes + if (paneInfo.type === "action-logs") { + return ( + handlePopOut(paneId)} + /> + ); + } + // Route git-graph panes if (paneInfo.type === "git-graph") { return ( diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/ReviewPanel/ReviewPanel.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/ReviewPanel/ReviewPanel.tsx index 3b7e1c5ca0f..591c07d78f2 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/ReviewPanel/ReviewPanel.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/ReviewPanel/ReviewPanel.tsx @@ -26,6 +26,7 @@ import { LuChevronDown, LuCode, LuCopy, + LuExternalLink, LuLoaderCircle, LuRefreshCw, LuX, @@ -136,6 +137,7 @@ export function ReviewPanel({ const resolvedWorkspaceId = useWorkspaceId(); const trpcUtils = electronTrpc.useUtils(); const addBrowserTab = useTabsStore((s) => s.addBrowserTab); + const addActionLogsTab = useTabsStore((s) => s.addActionLogsTab); const handleOpenUrl = useCallback( (url: string, e: React.MouseEvent) => { e.preventDefault(); @@ -1094,6 +1096,34 @@ export function ReviewPanel({ {actionChecks.length > 0 ? (
+ {resolvedWorkspaceId && ( + + )}