From 072506a637fed942c4f9455f013c196ce66bedbe Mon Sep 17 00:00:00 2001 From: johndoe6345789 Date: Sat, 27 Dec 2025 17:20:36 +0000 Subject: [PATCH] refactor: modularize github actions viewer --- .../misc/github/GitHubActionsFetcher.tsx | 1106 ++--------------- .../github/hooks/useWorkflowLogAnalysis.ts | 134 ++ .../misc/github/hooks/useWorkflowRuns.ts | 171 +++ .../src/components/misc/github/types.ts | 36 + .../misc/github/views/AnalysisPanel.tsx | 94 ++ .../misc/github/views/RunDetails.tsx | 100 ++ .../components/misc/github/views/RunList.tsx | 432 +++++++ 7 files changed, 1063 insertions(+), 1010 deletions(-) create mode 100644 frontends/nextjs/src/components/misc/github/hooks/useWorkflowLogAnalysis.ts create mode 100644 frontends/nextjs/src/components/misc/github/hooks/useWorkflowRuns.ts create mode 100644 frontends/nextjs/src/components/misc/github/types.ts create mode 100644 frontends/nextjs/src/components/misc/github/views/AnalysisPanel.tsx create mode 100644 frontends/nextjs/src/components/misc/github/views/RunDetails.tsx create mode 100644 frontends/nextjs/src/components/misc/github/views/RunList.tsx diff --git a/frontends/nextjs/src/components/misc/github/GitHubActionsFetcher.tsx b/frontends/nextjs/src/components/misc/github/GitHubActionsFetcher.tsx index 6ca2b7afd..441bd2d0e 100644 --- a/frontends/nextjs/src/components/misc/github/GitHubActionsFetcher.tsx +++ b/frontends/nextjs/src/components/misc/github/GitHubActionsFetcher.tsx @@ -1,223 +1,62 @@ -import { useEffect, useMemo, useState } from 'react' -import { Box, Stack, Typography } from '@mui/material' -import { alpha } from '@mui/material/styles' -import { - Autorenew as RunningIcon, - Cancel as FailureIcon, - CheckCircle as SuccessIcon, - Description as FileTextIcon, - Download as DownloadIcon, - Info as InfoIcon, - OpenInNew as OpenInNewIcon, - Refresh as RefreshIcon, - SmartToy as RobotIcon, - TrendingDown as TrendDownIcon, - TrendingUp as TrendUpIcon, - Warning as WarningIcon, -} from '@mui/icons-material' -import { - Alert, - AlertDescription, - AlertTitle, - Badge, - Button, - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, - ScrollArea, - Skeleton, - Tabs, - TabsContent, - TabsList, - TabsTrigger, -} from '@/components/ui' -import { toast } from 'sonner' +import { useEffect, useState } from 'react' +import { Stack } from '@mui/material' + +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui' import { formatWorkflowRunAnalysis, summarizeWorkflowRuns } from '@/lib/github/analyze-workflow-runs' -import { formatWorkflowLogAnalysis, summarizeWorkflowLogs } from '@/lib/github/analyze-workflow-logs' +import { toast } from 'sonner' -interface WorkflowRun { - id: number - name: string - status: string - conclusion: string | null - created_at: string - updated_at: string - html_url: string - head_branch: string - event: string - jobs_url?: string -} - -interface Job { - id: number - name: string - status: string - conclusion: string | null - started_at: string - completed_at: string | null - steps: JobStep[] -} - -interface JobStep { - name: string - status: string - conclusion: string | null - number: number - started_at?: string | null - completed_at?: string | null -} - -const spinSx = { - animation: 'spin 1s linear infinite', - '@keyframes spin': { - from: { transform: 'rotate(0deg)' }, - to: { transform: 'rotate(360deg)' }, - }, -} - -const pulseSx = { - animation: 'pulse 1.4s ease-in-out infinite', - '@keyframes pulse': { - '0%, 100%': { opacity: 0.6 }, - '50%': { opacity: 1 }, - }, -} +import { useWorkflowRuns } from './hooks/useWorkflowRuns' +import { useWorkflowLogAnalysis } from './hooks/useWorkflowLogAnalysis' +import { AnalysisPanel } from './views/AnalysisPanel' +import { RunDetails } from './views/RunDetails' +import { RunList } from './views/RunList' export function GitHubActionsFetcher() { - const [data, setData] = useState(null) - const [isLoading, setIsLoading] = useState(false) - const [error, setError] = useState(null) - const [lastFetched, setLastFetched] = useState(null) - const [needsAuth, setNeedsAuth] = useState(false) - const [repoInfo, setRepoInfo] = useState<{ owner: string; repo: string } | null>(null) - const [secondsUntilRefresh, setSecondsUntilRefresh] = useState(30) - const [autoRefreshEnabled, setAutoRefreshEnabled] = useState(true) const [analysis, setAnalysis] = useState(null) const [isAnalyzing, setIsAnalyzing] = useState(false) - const [selectedRunId, setSelectedRunId] = useState(null) - const [runJobs, setRunJobs] = useState([]) - const [runLogs, setRunLogs] = useState(null) - const [isLoadingLogs, setIsLoadingLogs] = useState(false) - const repoLabel = repoInfo ? `${repoInfo.owner}/${repoInfo.repo}` : 'johndoe6345789/metabuilder' + const [activeTab, setActiveTab] = useState<'runs' | 'logs' | 'analysis'>( + 'runs', + ) - const fetchGitHubActions = async () => { - setIsLoading(true) - setError(null) - setNeedsAuth(false) + const { + runs, + isLoading, + error, + needsAuth, + repoInfo, + repoLabel, + lastFetched, + secondsUntilRefresh, + autoRefreshEnabled, + toggleAutoRefresh, + fetchRuns, + getStatusColor, + conclusion, + summaryTone, + } = useWorkflowRuns() - try { - const response = await fetch('/api/github/actions/runs', { cache: 'no-store' }) - let payload: { - owner?: string - repo?: string - runs?: WorkflowRun[] - fetchedAt?: string - requiresAuth?: boolean - error?: string - } | null = null - - try { - payload = await response.json() - } catch { - payload = null + const { + analyzeRunLogs, + downloadRunLogs, + isLoadingLogs, + runJobs, + runLogs, + selectedRunId, + } = useWorkflowLogAnalysis({ + repoInfo, + onAnalysisStart: () => setIsAnalyzing(true), + onAnalysisComplete: (report) => { + if (report) { + setAnalysis(report) } - - if (!response.ok) { - if (payload?.requiresAuth) { - setNeedsAuth(true) - } - const message = payload?.error || `Failed to fetch workflow runs (${response.status})` - throw new Error(message) - } - - const runs = payload?.runs || [] - setData(runs) - if (payload?.owner && payload?.repo) { - setRepoInfo({ owner: payload.owner, repo: payload.repo }) - } - setLastFetched(payload?.fetchedAt ? new Date(payload.fetchedAt) : new Date()) - setSecondsUntilRefresh(30) - toast.success(`Fetched ${runs.length} workflow runs`) - } catch (err) { - const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred' - setError(errorMessage) - toast.error(`Failed to fetch: ${errorMessage}`) - } finally { - setIsLoading(false) - } - } - - useEffect(() => { - fetchGitHubActions() - }, []) - - useEffect(() => { - if (!autoRefreshEnabled) return - - const countdownInterval = setInterval(() => { - setSecondsUntilRefresh((prev) => { - if (prev <= 1) { - fetchGitHubActions() - return 30 - } - return prev - 1 - }) - }, 1000) - - return () => clearInterval(countdownInterval) - }, [autoRefreshEnabled]) - - const getStatusColor = (status: string, conclusion: string | null) => { - if (status === 'completed') { - if (conclusion === 'success') return 'success.main' - if (conclusion === 'failure') return 'error.main' - if (conclusion === 'cancelled') return 'text.secondary' - } - return 'warning.main' - } - - const getStatusIcon = (status: string, conclusion: string | null) => { - if (status === 'completed') { - if (conclusion === 'success') { - return - } - if (conclusion === 'failure') { - return - } - if (conclusion === 'cancelled') { - return - } - } - - return - } - - const analyzeWorkflows = async () => { - if (!data || data.length === 0) { - toast.error('No data to analyze') - return - } - - setIsAnalyzing(true) - try { - const summary = summarizeWorkflowRuns(data) - const report = formatWorkflowRunAnalysis(summary) - setAnalysis(report) - toast.success('Analysis complete') - } catch (err) { - const errorMessage = err instanceof Error ? err.message : 'Analysis failed' - toast.error(errorMessage) - } finally { setIsAnalyzing(false) - } - } + }, + }) const downloadWorkflowData = () => { - if (!data) return + if (!runs) return - const jsonData = JSON.stringify(data, null, 2) + const jsonData = JSON.stringify(runs, null, 2) const blob = new Blob([jsonData], { type: 'application/json' }) const url = URL.createObjectURL(blob) const anchor = document.createElement('a') @@ -230,95 +69,18 @@ export function GitHubActionsFetcher() { toast.success('Downloaded workflow data') } - const downloadRunLogs = async (runId: number, runName: string) => { - setIsLoadingLogs(true) - setSelectedRunId(runId) - setRunLogs(null) - setRunJobs([]) - - try { - const query = new URLSearchParams({ - runName, - includeLogs: 'true', - jobLimit: '20', - }) - if (repoInfo) { - query.set('owner', repoInfo.owner) - query.set('repo', repoInfo.repo) - } - - const response = await fetch(`/api/github/actions/runs/${runId}/logs?${query.toString()}`, { - cache: 'no-store', - }) - - let payload: { - jobs?: Job[] - logsText?: string | null - truncated?: boolean - requiresAuth?: boolean - error?: string - } | null = null - - try { - payload = await response.json() - } catch { - payload = null - } - - if (!response.ok) { - if (payload?.requiresAuth) { - toast.error('GitHub API requires authentication for logs') - } - const message = payload?.error || `Failed to download logs (${response.status})` - throw new Error(message) - } - - const logsText = payload?.logsText ?? null - setRunJobs(payload?.jobs ?? []) - setRunLogs(logsText) - - if (logsText) { - const blob = new Blob([logsText], { type: 'text/plain' }) - const url = URL.createObjectURL(blob) - const anchor = document.createElement('a') - anchor.href = url - anchor.download = `workflow-logs-${runId}-${new Date().toISOString()}.txt` - document.body.appendChild(anchor) - anchor.click() - document.body.removeChild(anchor) - URL.revokeObjectURL(url) - } - - if (payload?.truncated) { - toast.info('Downloaded logs are truncated. Increase the job limit for more.') - } - - toast.success('Workflow logs downloaded successfully') - } catch (err) { - const errorMessage = err instanceof Error ? err.message : 'Failed to download logs' - toast.error(errorMessage) - setRunLogs(`Error fetching logs: ${errorMessage}`) - } finally { - setIsLoadingLogs(false) - } - } - - const analyzeRunLogs = async () => { - if (!runLogs || !selectedRunId) { - toast.error('No logs to analyze') + const analyzeWorkflows = async () => { + if (!runs || runs.length === 0) { + toast.error('No data to analyze') return } setIsAnalyzing(true) try { - const selectedRun = data?.find(r => r.id === selectedRunId) - const summary = summarizeWorkflowLogs(runLogs) - const report = formatWorkflowLogAnalysis(summary, { - runName: selectedRun?.name, - runId: selectedRunId, - }) + const summary = summarizeWorkflowRuns(runs) + const report = formatWorkflowRunAnalysis(summary) setAnalysis(report) - toast.success('Log analysis complete') + toast.success('Analysis complete') } catch (err) { const errorMessage = err instanceof Error ? err.message : 'Analysis failed' toast.error(errorMessage) @@ -327,741 +89,65 @@ export function GitHubActionsFetcher() { } } - const conclusion = useMemo(() => { - if (!data || data.length === 0) return null + const handleAnalyzeLogs = () => analyzeRunLogs(runs) - const total = data.length - const completed = data.filter(r => r.status === 'completed').length - const successful = data.filter(r => r.status === 'completed' && r.conclusion === 'success').length - const failed = data.filter(r => r.status === 'completed' && r.conclusion === 'failure').length - const cancelled = data.filter(r => r.status === 'completed' && r.conclusion === 'cancelled').length - const inProgress = data.filter(r => r.status !== 'completed').length - - const mostRecent = data[0] - const mostRecentTimestamp = new Date(mostRecent.updated_at).getTime() - const timeThreshold = 5 * 60 * 1000 - - const recentWorkflows = data.filter(r => { - const runTime = new Date(r.updated_at).getTime() - return Math.abs(runTime - mostRecentTimestamp) < timeThreshold - }) - - const hasAnyFailed = recentWorkflows.some(r => r.status === 'completed' && r.conclusion === 'failure') - const hasAnyRunning = recentWorkflows.some(r => r.status !== 'completed') - const allPassed = recentWorkflows.every(r => r.status === 'completed' && r.conclusion === 'success') - - const mostRecentPassed = allPassed && recentWorkflows.length > 0 - const mostRecentFailed = hasAnyFailed - const mostRecentRunning = hasAnyRunning && !hasAnyFailed - - const successRate = completed > 0 ? Math.round((successful / completed) * 100) : 0 - const recentRuns = data.slice(0, 5) - const recentCompleted = recentRuns.filter(r => r.status === 'completed') - const recentSuccessful = recentCompleted.filter(r => r.conclusion === 'success').length - const recentFailed = recentCompleted.filter(r => r.conclusion === 'failure').length - - const health = successRate >= 80 ? 'healthy' : successRate >= 60 ? 'warning' : 'critical' - const trend = recentSuccessful >= recentFailed ? 'up' : 'down' - - return { - total, - completed, - successful, - failed, - cancelled, - inProgress, - successRate, - health, - trend, - recentSuccessful, - recentFailed, - mostRecent, - mostRecentPassed, - mostRecentFailed, - mostRecentRunning, - recentWorkflows, + useEffect(() => { + if (runLogs && activeTab === 'runs') { + setActiveTab('logs') } - }, [data]) - - const summaryTone = conclusion - ? conclusion.mostRecentPassed - ? 'success' - : conclusion.mostRecentFailed - ? 'error' - : 'warning' - : 'warning' + }, [activeTab, runLogs]) return ( - - - - - GitHub Actions Monitor - - - Repository:{' '} - - {repoLabel} - - - - - - - - - - - Auto-refresh {autoRefreshEnabled ? 'ON' : 'OFF'} - - {autoRefreshEnabled && ( - - Next refresh: {secondsUntilRefresh}s - - )} - - - - - - - - - {conclusion && ( - <> - ({ - borderWidth: 2, - borderColor: theme.palette[summaryTone].main, - bgcolor: alpha(theme.palette[summaryTone].main, 0.08), - alignItems: 'flex-start', - })} - > - - {summaryTone === 'success' && ( - - )} - {summaryTone === 'error' && ( - - )} - {summaryTone === 'warning' && ( - - )} - - - - {conclusion.mostRecentPassed && 'Most Recent Builds: ALL PASSED'} - {conclusion.mostRecentFailed && 'Most Recent Builds: FAILURES DETECTED'} - {conclusion.mostRecentRunning && 'Most Recent Builds: RUNNING'} - - - - - - {conclusion.recentWorkflows.length > 1 - ? `Showing ${conclusion.recentWorkflows.length} workflows from the most recent run:` - : 'Most recent workflow:'} - - - {conclusion.recentWorkflows.map((workflow) => { - const statusLabel = workflow.status === 'completed' - ? workflow.conclusion - : workflow.status - const badgeVariant = workflow.conclusion === 'success' - ? 'default' - : workflow.conclusion === 'failure' - ? 'destructive' - : 'outline' - - return ( - - - - {workflow.status === 'completed' && workflow.conclusion === 'success' && ( - - )} - {workflow.status === 'completed' && workflow.conclusion === 'failure' && ( - - )} - {workflow.status !== 'completed' && ( - - )} - {workflow.name} - - {statusLabel} - - - - - Branch: - - {workflow.head_branch} - - - - Updated: - {new Date(workflow.updated_at).toLocaleString()} - - - - - ) - })} - - - - - - - - - - - - - - {conclusion.health === 'healthy' && ( - - )} - {conclusion.health === 'warning' && ( - - )} - {conclusion.health === 'critical' && ( - - )} - Pipeline Health Summary - - Analysis of recent workflow runs - - - - - - {conclusion.successRate}% Success Rate - - - - - {conclusion.successful} Passed - - - {conclusion.failed > 0 && ( - - - {conclusion.failed} Failed - - )} - - {conclusion.inProgress > 0 && ( - - - {conclusion.inProgress} Running - - )} - - {conclusion.cancelled > 0 && ( - - {conclusion.cancelled} Cancelled - - )} - - - {conclusion.trend === 'up' ? ( - - ) : ( - - )} - Recent: {conclusion.recentSuccessful}/{conclusion.recentSuccessful + conclusion.recentFailed} - - - - - {conclusion.health === 'healthy' && ( - - - - Pipeline is healthy. Most recent runs are passing consistently. - - - )} - {conclusion.health === 'warning' && ( - - - - Pipeline health is moderate. Some failures detected in recent runs. - - - )} - {conclusion.health === 'critical' && ( - - - - Pipeline health is critical. High failure rate detected. - - - )} - - - - - - )} - - {needsAuth && ( - - - - - Authentication Note - - This app uses the GitHub API to fetch workflow data. The public API allows anonymous access with rate - limits. - - - - - )} - - {lastFetched && ( - - - - - Last Fetched - {lastFetched.toLocaleString()} - - - - )} - - {error && ( - - - - - Error - {error} - - - - )} - - + + setActiveTab(value as typeof activeTab)}> - Workflow Runs - {runLogs && Downloaded Logs} - AI Analysis + Workflow Runs + {runLogs && Logs} + Analysis - - - - - Workflow Runs - - - Recent workflow runs via GitHub REST API - - - {isLoading ? ( - - - - - - - ) : data && data.length > 0 ? ( - - {data.map((run) => { - const isRunLoading = isLoadingLogs && selectedRunId === run.id - const borderColor = run.conclusion === 'success' - ? 'success.main' - : run.conclusion === 'failure' - ? 'error.main' - : 'warning.main' - - return ( - - - - - - {getStatusIcon(run.status, run.conclusion)} - - {run.name} - - - - - Branch: - - {run.head_branch} - - - - Event: - {run.event} - - - Status: - - {run.status === 'completed' ? run.conclusion : run.status} - - - - - Updated: {new Date(run.updated_at).toLocaleString()} - - - - - - - - - - - ) - })} - - - - - ) : ( - - No workflow runs found. Click refresh to fetch data. - - )} - - + + {runLogs && ( - - - - - Workflow Logs - {selectedRunId && ( - - Run #{selectedRunId} - - )} - - - Complete logs from workflow run including all jobs and steps - - - - - {runJobs.length > 0 && ( - - Jobs Summary - - {runJobs.map((job) => ( - - {job.name}: {job.conclusion || job.status} - - ))} - - - )} - - - - {runLogs} - - - - - - - - - - + )} - - - - - AI-Powered Workflow Analysis - - - {runLogs - ? 'Deep analysis of downloaded workflow logs using GPT-4' - : 'Deep analysis of your CI/CD pipeline using GPT-4'} - - - - - {runLogs ? ( - - ) : ( - - )} - - {isAnalyzing && ( - - - - - - )} - - {analysis && !isAnalyzing && ( - - {analysis} - - )} - - {!analysis && !isAnalyzing && ( - - - - - No Analysis Yet - - {runLogs - ? 'Click the button above to run an AI analysis of the downloaded logs. The AI will identify errors, provide root cause analysis, and suggest fixes.' - : 'Download logs from a specific workflow run using the "Download Logs" button, or click above to analyze overall workflow patterns.'} - - - - - )} - - - + diff --git a/frontends/nextjs/src/components/misc/github/hooks/useWorkflowLogAnalysis.ts b/frontends/nextjs/src/components/misc/github/hooks/useWorkflowLogAnalysis.ts new file mode 100644 index 000000000..897b4653c --- /dev/null +++ b/frontends/nextjs/src/components/misc/github/hooks/useWorkflowLogAnalysis.ts @@ -0,0 +1,134 @@ +import { useCallback, useState } from 'react' +import { toast } from 'sonner' + +import { formatWorkflowLogAnalysis, summarizeWorkflowLogs } from '@/lib/github/analyze-workflow-logs' + +import { Job, RepoInfo, WorkflowRun } from '../types' + +interface UseWorkflowLogAnalysisOptions { + repoInfo: RepoInfo | null + onAnalysisStart?: () => void + onAnalysisComplete?: (report: string | null) => void +} + +export function useWorkflowLogAnalysis({ + repoInfo, + onAnalysisStart, + onAnalysisComplete, +}: UseWorkflowLogAnalysisOptions) { + const [selectedRunId, setSelectedRunId] = useState(null) + const [runJobs, setRunJobs] = useState([]) + const [runLogs, setRunLogs] = useState(null) + const [isLoadingLogs, setIsLoadingLogs] = useState(false) + + const downloadRunLogs = useCallback( + async (runId: number, runName: string) => { + setIsLoadingLogs(true) + setSelectedRunId(runId) + setRunLogs(null) + setRunJobs([]) + + try { + const query = new URLSearchParams({ + runName, + includeLogs: 'true', + jobLimit: '20', + }) + if (repoInfo) { + query.set('owner', repoInfo.owner) + query.set('repo', repoInfo.repo) + } + + const response = await fetch(`/api/github/actions/runs/${runId}/logs?${query.toString()}`, { + cache: 'no-store', + }) + + let payload: { + jobs?: Job[] + logsText?: string | null + truncated?: boolean + requiresAuth?: boolean + error?: string + } | null = null + + try { + payload = await response.json() + } catch { + payload = null + } + + if (!response.ok) { + if (payload?.requiresAuth) { + toast.error('GitHub API requires authentication for logs') + } + const message = payload?.error || `Failed to download logs (${response.status})` + throw new Error(message) + } + + const logsText = payload?.logsText ?? null + setRunJobs(payload?.jobs ?? []) + setRunLogs(logsText) + + if (logsText) { + const blob = new Blob([logsText], { type: 'text/plain' }) + const url = URL.createObjectURL(blob) + const anchor = document.createElement('a') + anchor.href = url + anchor.download = `workflow-logs-${runId}-${new Date().toISOString()}.txt` + document.body.appendChild(anchor) + anchor.click() + document.body.removeChild(anchor) + URL.revokeObjectURL(url) + } + + if (payload?.truncated) { + toast.info('Downloaded logs are truncated. Increase the job limit for more.') + } + + toast.success('Workflow logs downloaded successfully') + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Failed to download logs' + toast.error(errorMessage) + setRunLogs(`Error fetching logs: ${errorMessage}`) + } finally { + setIsLoadingLogs(false) + } + }, + [repoInfo], + ) + + const analyzeRunLogs = useCallback( + async (runs: WorkflowRun[] | null) => { + if (!runLogs || !selectedRunId) { + toast.error('No logs to analyze') + return + } + + onAnalysisStart?.() + try { + const selectedRun = runs?.find(r => r.id === selectedRunId) + const summary = summarizeWorkflowLogs(runLogs) + const report = formatWorkflowLogAnalysis(summary, { + runName: selectedRun?.name, + runId: selectedRunId, + }) + onAnalysisComplete?.(report) + toast.success('Log analysis complete') + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Analysis failed' + toast.error(errorMessage) + onAnalysisComplete?.(null) + } + }, + [onAnalysisComplete, onAnalysisStart, runLogs, selectedRunId], + ) + + return { + analyzeRunLogs, + downloadRunLogs, + isLoadingLogs, + runJobs, + runLogs, + selectedRunId, + } +} diff --git a/frontends/nextjs/src/components/misc/github/hooks/useWorkflowRuns.ts b/frontends/nextjs/src/components/misc/github/hooks/useWorkflowRuns.ts new file mode 100644 index 000000000..5cf9ede25 --- /dev/null +++ b/frontends/nextjs/src/components/misc/github/hooks/useWorkflowRuns.ts @@ -0,0 +1,171 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { toast } from 'sonner' + +import { WorkflowRun, RepoInfo } from '../types' + +const DEFAULT_REPO_LABEL = 'johndoe6345789/metabuilder' + +export function useWorkflowRuns() { + const [runs, setRuns] = useState(null) + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState(null) + const [lastFetched, setLastFetched] = useState(null) + const [needsAuth, setNeedsAuth] = useState(false) + const [repoInfo, setRepoInfo] = useState(null) + const [secondsUntilRefresh, setSecondsUntilRefresh] = useState(30) + const [autoRefreshEnabled, setAutoRefreshEnabled] = useState(true) + + const repoLabel = repoInfo ? `${repoInfo.owner}/${repoInfo.repo}` : DEFAULT_REPO_LABEL + + const fetchRuns = useCallback(async () => { + setIsLoading(true) + setError(null) + setNeedsAuth(false) + + try { + const response = await fetch('/api/github/actions/runs', { cache: 'no-store' }) + let payload: { + owner?: string + repo?: string + runs?: WorkflowRun[] + fetchedAt?: string + requiresAuth?: boolean + error?: string + } | null = null + + try { + payload = await response.json() + } catch { + payload = null + } + + if (!response.ok) { + if (payload?.requiresAuth) { + setNeedsAuth(true) + } + const message = payload?.error || `Failed to fetch workflow runs (${response.status})` + throw new Error(message) + } + + const retrievedRuns = payload?.runs || [] + setRuns(retrievedRuns) + if (payload?.owner && payload?.repo) { + setRepoInfo({ owner: payload.owner, repo: payload.repo }) + } + setLastFetched(payload?.fetchedAt ? new Date(payload.fetchedAt) : new Date()) + setSecondsUntilRefresh(30) + toast.success(`Fetched ${retrievedRuns.length} workflow runs`) + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred' + setError(errorMessage) + toast.error(`Failed to fetch: ${errorMessage}`) + } finally { + setIsLoading(false) + } + }, []) + + useEffect(() => { + fetchRuns() + }, [fetchRuns]) + + useEffect(() => { + if (!autoRefreshEnabled) return + + const countdownInterval = setInterval(() => { + setSecondsUntilRefresh((prev) => { + if (prev <= 1) { + fetchRuns() + return 30 + } + return prev - 1 + }) + }, 1000) + + return () => clearInterval(countdownInterval) + }, [autoRefreshEnabled, fetchRuns]) + + const toggleAutoRefresh = () => setAutoRefreshEnabled((prev) => !prev) + + const getStatusColor = (status: string, conclusion: string | null) => { + if (status === 'completed') { + if (conclusion === 'success') return 'success.main' + if (conclusion === 'failure') return 'error.main' + if (conclusion === 'cancelled') return 'text.secondary' + } + return 'warning.main' + } + + const conclusion = useMemo(() => { + if (!runs || runs.length === 0) return null + + const total = runs.length + const completed = runs.filter(r => r.status === 'completed').length + const successful = runs.filter(r => r.status === 'completed' && r.conclusion === 'success').length + const failed = runs.filter(r => r.status === 'completed' && r.conclusion === 'failure').length + const cancelled = runs.filter(r => r.status === 'completed' && r.conclusion === 'cancelled').length + const inProgress = runs.filter(r => r.status !== 'completed').length + + const mostRecent = runs[0] + const mostRecentTimestamp = new Date(mostRecent.updated_at).getTime() + const timeThreshold = 5 * 60 * 1000 + const recentWorkflows = runs.filter((run) => { + const runTimestamp = new Date(run.updated_at).getTime() + return mostRecentTimestamp - runTimestamp <= timeThreshold + }) + + const mostRecentPassed = recentWorkflows.every( + (run) => run.status === 'completed' && run.conclusion === 'success', + ) + const mostRecentFailed = recentWorkflows.some( + (run) => run.status === 'completed' && run.conclusion === 'failure', + ) + const mostRecentRunning = recentWorkflows.some((run) => run.status !== 'completed') + + const successRate = total > 0 ? Math.round((successful / total) * 100) : 0 + let health: 'healthy' | 'warning' | 'critical' = 'healthy' + if (failed / total > 0.3 || successRate < 60) { + health = 'critical' + } else if (failed > 0 || inProgress > 0) { + health = 'warning' + } + + return { + total, + completed, + successful, + failed, + cancelled, + inProgress, + successRate, + health, + recentWorkflows, + mostRecentPassed, + mostRecentFailed, + mostRecentRunning, + } + }, [runs]) + + const summaryTone = useMemo(() => { + if (!conclusion) return 'warning' + if (conclusion.mostRecentPassed) return 'success' + if (conclusion.mostRecentFailed) return 'error' + return 'warning' + }, [conclusion]) + + return { + runs, + isLoading, + error, + lastFetched, + needsAuth, + repoInfo, + repoLabel, + secondsUntilRefresh, + autoRefreshEnabled, + toggleAutoRefresh, + fetchRuns, + getStatusColor, + conclusion, + summaryTone, + } +} diff --git a/frontends/nextjs/src/components/misc/github/types.ts b/frontends/nextjs/src/components/misc/github/types.ts new file mode 100644 index 000000000..fabe1e152 --- /dev/null +++ b/frontends/nextjs/src/components/misc/github/types.ts @@ -0,0 +1,36 @@ +export interface WorkflowRun { + id: number + name: string + status: string + conclusion: string | null + created_at: string + updated_at: string + html_url: string + head_branch: string + event: string + jobs_url?: string +} + +export interface JobStep { + name: string + status: string + conclusion: string | null + number: number + started_at?: string | null + completed_at?: string | null +} + +export interface Job { + id: number + name: string + status: string + conclusion: string | null + started_at: string + completed_at: string | null + steps: JobStep[] +} + +export interface RepoInfo { + owner: string + repo: string +} diff --git a/frontends/nextjs/src/components/misc/github/views/AnalysisPanel.tsx b/frontends/nextjs/src/components/misc/github/views/AnalysisPanel.tsx new file mode 100644 index 000000000..3d5ed7d0a --- /dev/null +++ b/frontends/nextjs/src/components/misc/github/views/AnalysisPanel.tsx @@ -0,0 +1,94 @@ +import { Box, Stack } from '@mui/material' +import { Info as InfoIcon, SmartToy as RobotIcon } from '@mui/icons-material' + +import { Alert, AlertDescription, AlertTitle, Button, Card, CardContent, CardDescription, CardHeader, CardTitle, Skeleton } from '@/components/ui' + +interface AnalysisPanelProps { + analysis: string | null + isAnalyzing: boolean + runLogs: string | null + onAnalyzeWorkflows: () => void + onAnalyzeLogs?: () => void +} + +export function AnalysisPanel({ analysis, isAnalyzing, runLogs, onAnalyzeLogs, onAnalyzeWorkflows }: AnalysisPanelProps) { + return ( + + + + + AI-Powered Workflow Analysis + + + {runLogs + ? 'Deep analysis of downloaded workflow logs using GPT-4' + : 'Deep analysis of your CI/CD pipeline using GPT-4'} + + + + + {runLogs ? ( + + ) : ( + + )} + + {isAnalyzing && ( + + + + + + )} + + {analysis && !isAnalyzing && ( + + {analysis} + + )} + + {!analysis && !isAnalyzing && ( + + + + + No Analysis Yet + + {runLogs + ? 'Click the button above to run an AI analysis of the downloaded logs. The AI will identify errors, provide root cause analysis, and suggest fixes.' + : 'Download logs from a specific workflow run using the "Download Logs" button, or click above to analyze overall workflow patterns.'} + + + + + )} + + + + ) +} diff --git a/frontends/nextjs/src/components/misc/github/views/RunDetails.tsx b/frontends/nextjs/src/components/misc/github/views/RunDetails.tsx new file mode 100644 index 000000000..8a90b2ec5 --- /dev/null +++ b/frontends/nextjs/src/components/misc/github/views/RunDetails.tsx @@ -0,0 +1,100 @@ +import { Box, Stack, Typography } from '@mui/material' +import { Description as FileTextIcon, SmartToy as RobotIcon } from '@mui/icons-material' + +import { Badge, Button, Card, CardContent, CardDescription, CardHeader, CardTitle, ScrollArea } from '@/components/ui' + +import { Job } from '../types' + +interface RunDetailsProps { + runLogs: string | null + runJobs: Job[] + selectedRunId: number | null + onAnalyzeLogs: () => void + isAnalyzing: boolean +} + +export function RunDetails({ runLogs, runJobs, selectedRunId, onAnalyzeLogs, isAnalyzing }: RunDetailsProps) { + if (!runLogs) return null + + return ( + + + + + Workflow Logs + {selectedRunId && ( + + Run #{selectedRunId} + + )} + + Complete logs from workflow run including all jobs and steps + + + + {runJobs.length > 0 && ( + + Jobs Summary + + {runJobs.map((job) => ( + + {job.name}: {job.conclusion || job.status} + + ))} + + + )} + + + + {runLogs} + + + + + + + + + + + ) +} diff --git a/frontends/nextjs/src/components/misc/github/views/RunList.tsx b/frontends/nextjs/src/components/misc/github/views/RunList.tsx new file mode 100644 index 000000000..d508ed6da --- /dev/null +++ b/frontends/nextjs/src/components/misc/github/views/RunList.tsx @@ -0,0 +1,432 @@ +import { Box, Stack, Typography } from '@mui/material' +import { alpha } from '@mui/material/styles' +import { + Autorenew as RunningIcon, + Cancel as FailureIcon, + CheckCircle as SuccessIcon, + Download as DownloadIcon, + OpenInNew as OpenInNewIcon, + Refresh as RefreshIcon, +} from '@mui/icons-material' + +import { Alert, AlertDescription, AlertTitle, Badge, Button, Card, CardContent, CardDescription, CardHeader, CardTitle, Skeleton } from '@/components/ui' + +import { WorkflowRun } from '../types' + +const spinSx = { + animation: 'spin 1s linear infinite', + '@keyframes spin': { + from: { transform: 'rotate(0deg)' }, + to: { transform: 'rotate(360deg)' }, + }, +} + +interface PipelineSummary { + cancelled: number + completed: number + failed: number + health: 'healthy' | 'warning' | 'critical' + inProgress: number + mostRecentFailed: boolean + mostRecentPassed: boolean + mostRecentRunning: boolean + recentWorkflows: WorkflowRun[] + successRate: number + successful: number + total: number +} + +interface RunListProps { + runs: WorkflowRun[] | null + isLoading: boolean + error: string | null + needsAuth: boolean + repoLabel: string + lastFetched: Date | null + autoRefreshEnabled: boolean + secondsUntilRefresh: number + onToggleAutoRefresh: () => void + onRefresh: () => void + getStatusColor: (status: string, conclusion: string | null) => string + onDownloadLogs: (runId: number, runName: string) => void + onDownloadJson: () => void + isLoadingLogs: boolean + conclusion: PipelineSummary | null + summaryTone: 'success' | 'error' | 'warning' + selectedRunId: number | null +} + +export function RunList({ + runs, + isLoading, + error, + needsAuth, + repoLabel, + lastFetched, + autoRefreshEnabled, + secondsUntilRefresh, + onToggleAutoRefresh, + onRefresh, + getStatusColor, + onDownloadLogs, + onDownloadJson, + isLoadingLogs, + conclusion, + summaryTone, + selectedRunId, +}: RunListProps) { + return ( + + + + + + GitHub Actions Monitor + + + Repository:{' '} + + {repoLabel} + + + {lastFetched && ( + + Last fetched: {lastFetched.toLocaleString()} + + )} + + + + + + + Auto-refresh {autoRefreshEnabled ? 'ON' : 'OFF'} + + {autoRefreshEnabled && ( + + Next refresh: {secondsUntilRefresh}s + + )} + + + + + + + + + + + + + {error && ( + + Error + {error} + + )} + + {needsAuth && ( + + Authentication Required + + GitHub API requires authentication for this request. Please configure credentials and retry. + + + )} + + {conclusion && ( + ({ + borderWidth: 2, + borderColor: theme.palette[summaryTone].main, + bgcolor: alpha(theme.palette[summaryTone].main, 0.08), + alignItems: 'flex-start', + mb: 2, + })} + > + + {summaryTone === 'success' && ( + + )} + {summaryTone === 'error' && ( + + )} + {summaryTone === 'warning' && ( + + )} + + + + {conclusion.mostRecentPassed && 'Most Recent Builds: ALL PASSED'} + {conclusion.mostRecentFailed && 'Most Recent Builds: FAILURES DETECTED'} + {conclusion.mostRecentRunning && 'Most Recent Builds: RUNNING'} + + + + + + {conclusion.recentWorkflows.length > 1 + ? `Showing ${conclusion.recentWorkflows.length} workflows from the most recent run:` + : 'Most recent workflow:'} + + + {conclusion.recentWorkflows.map((workflow: WorkflowRun) => { + const statusLabel = workflow.status === 'completed' + ? workflow.conclusion + : workflow.status + const badgeVariant = workflow.conclusion === 'success' + ? 'default' + : workflow.conclusion === 'failure' + ? 'destructive' + : 'outline' + + return ( + + + + {workflow.status === 'completed' && workflow.conclusion === 'success' && ( + + )} + {workflow.status === 'completed' && workflow.conclusion === 'failure' && ( + + )} + {workflow.status !== 'completed' && ( + + )} + {workflow.name} + + {statusLabel} + + + + + Branch: + + {workflow.head_branch} + + + + Updated: + {new Date(workflow.updated_at).toLocaleString()} + + + + + ) + })} + + + + + + + + + + )} + + + + + + + Recent Workflow Runs + + {isLoading && } + + Latest GitHub Actions runs with status and controls + + + + {isLoading && !runs && ( + + + + + + )} + + {runs && runs.length > 0 ? ( + + {runs.map((run) => { + const statusIcon = getStatusColor(run.status, run.conclusion) + return ( + + + + + + + {run.name} + + {run.event} + + + + + + Branch: + + {run.head_branch} + + + + Event: + {run.event} + + + Status: + + {run.status === 'completed' ? run.conclusion : run.status} + + + + + Updated: {new Date(run.updated_at).toLocaleString()} + + + + + + + + + + + ) + })} + + + + + ) : ( + + {isLoading ? 'Loading workflow runs...' : 'No workflow runs found. Click refresh to fetch data.'} + + )} + + + + + ) +}