Merge pull request #143 from johndoe6345789/codex/refactor-github-components-and-hooks-structure

refactor: modularize github actions viewer
This commit is contained in:
2025-12-27 17:21:07 +00:00
committed by GitHub
7 changed files with 1063 additions and 1010 deletions

View File

@@ -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<number | null>(null)
const [runJobs, setRunJobs] = useState<Job[]>([])
const [runLogs, setRunLogs] = useState<string | null>(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,
}
}

View File

@@ -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<WorkflowRun[] | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [lastFetched, setLastFetched] = useState<Date | null>(null)
const [needsAuth, setNeedsAuth] = useState(false)
const [repoInfo, setRepoInfo] = useState<RepoInfo | null>(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,
}
}

View File

@@ -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
}

View File

@@ -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 (
<Card>
<CardHeader>
<Stack direction="row" spacing={1} alignItems="center">
<RobotIcon sx={{ fontSize: 24 }} />
<CardTitle>AI-Powered Workflow Analysis</CardTitle>
</Stack>
<CardDescription>
{runLogs
? 'Deep analysis of downloaded workflow logs using GPT-4'
: 'Deep analysis of your CI/CD pipeline using GPT-4'}
</CardDescription>
</CardHeader>
<CardContent>
<Stack spacing={3}>
{runLogs ? (
<Button
onClick={onAnalyzeLogs}
disabled={isAnalyzing}
size="lg"
fullWidth
startIcon={<RobotIcon sx={{ fontSize: 20 }} />}
>
{isAnalyzing ? 'Analyzing Logs...' : 'Analyze Downloaded Logs with AI'}
</Button>
) : (
<Button
onClick={onAnalyzeWorkflows}
disabled={isAnalyzing}
size="lg"
fullWidth
startIcon={<RobotIcon sx={{ fontSize: 20 }} />}
>
{isAnalyzing ? 'Analyzing...' : 'Analyze Workflows with AI'}
</Button>
)}
{isAnalyzing && (
<Stack spacing={2}>
<Skeleton sx={{ height: 128 }} />
<Skeleton sx={{ height: 128 }} />
<Skeleton sx={{ height: 128 }} />
</Stack>
)}
{analysis && !isAnalyzing && (
<Box
sx={{
bgcolor: 'action.hover',
p: 3,
borderRadius: 2,
border: 1,
borderColor: 'divider',
whiteSpace: 'pre-wrap',
}}
>
{analysis}
</Box>
)}
{!analysis && !isAnalyzing && (
<Alert>
<Stack direction="row" spacing={1.5} alignItems="flex-start">
<InfoIcon sx={{ color: 'info.main', fontSize: 20 }} />
<Box>
<AlertTitle>No Analysis Yet</AlertTitle>
<AlertDescription>
{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.'}
</AlertDescription>
</Box>
</Stack>
</Alert>
)}
</Stack>
</CardContent>
</Card>
)
}

View File

@@ -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 (
<Card>
<CardHeader>
<Stack direction="row" spacing={1} alignItems="center">
<FileTextIcon sx={{ fontSize: 24 }} />
<CardTitle>Workflow Logs</CardTitle>
{selectedRunId && (
<Badge variant="secondary" sx={{ fontSize: '0.75rem' }}>
Run #{selectedRunId}
</Badge>
)}
</Stack>
<CardDescription>Complete logs from workflow run including all jobs and steps</CardDescription>
</CardHeader>
<CardContent>
<Stack spacing={3}>
{runJobs.length > 0 && (
<Stack spacing={1.5}>
<Typography variant="subtitle2">Jobs Summary</Typography>
<Stack direction="row" spacing={1} flexWrap="wrap">
{runJobs.map((job) => (
<Badge
key={job.id}
variant={
job.conclusion === 'success'
? 'default'
: job.conclusion === 'failure'
? 'destructive'
: 'outline'
}
sx={{ fontSize: '0.75rem' }}
>
{job.name}: {job.conclusion || job.status}
</Badge>
))}
</Stack>
</Stack>
)}
<ScrollArea
sx={{
height: 600,
width: '100%',
border: 1,
borderColor: 'divider',
borderRadius: 1,
}}
>
<Box
component="pre"
sx={{
m: 0,
p: 2,
fontSize: '0.75rem',
fontFamily: 'monospace',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}
>
{runLogs}
</Box>
</ScrollArea>
<Stack direction="row" spacing={2} flexWrap="wrap">
<Button
onClick={() => {
if (!runLogs) return
navigator.clipboard.writeText(runLogs)
}}
variant="outline"
>
Copy to Clipboard
</Button>
<Button onClick={onAnalyzeLogs} disabled={isAnalyzing} startIcon={<RobotIcon sx={{ fontSize: 20 }} />}>
{isAnalyzing ? 'Analyzing Logs...' : 'Analyze Logs with AI'}
</Button>
</Stack>
</Stack>
</CardContent>
</Card>
)
}

View File

@@ -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 (
<Card sx={{ borderWidth: 2, borderColor: 'divider' }}>
<CardHeader>
<Stack
direction={{ xs: 'column', lg: 'row' }}
spacing={2}
alignItems={{ xs: 'flex-start', lg: 'center' }}
justifyContent="space-between"
>
<Stack spacing={1}>
<Typography variant="h4" fontWeight={700}>
GitHub Actions Monitor
</Typography>
<Typography color="text.secondary">
Repository:{' '}
<Box
component="code"
sx={{
ml: 1,
px: 1,
py: 0.5,
borderRadius: 1,
bgcolor: 'action.hover',
fontSize: '0.875rem',
}}
>
{repoLabel}
</Box>
</Typography>
{lastFetched && (
<Typography variant="caption" color="text.secondary">
Last fetched: {lastFetched.toLocaleString()}
</Typography>
)}
</Stack>
<Stack
direction={{ xs: 'column', md: 'row' }}
spacing={2}
alignItems={{ xs: 'flex-start', md: 'center' }}
>
<Stack spacing={1} alignItems={{ xs: 'flex-start', md: 'flex-end' }}>
<Stack direction="row" spacing={1} alignItems="center">
<Badge
variant={autoRefreshEnabled ? 'default' : 'outline'}
sx={{ fontSize: '0.75rem' }}
>
Auto-refresh {autoRefreshEnabled ? 'ON' : 'OFF'}
</Badge>
{autoRefreshEnabled && (
<Typography variant="caption" color="text.secondary" sx={{ fontFamily: 'monospace' }}>
Next refresh: {secondsUntilRefresh}s
</Typography>
)}
</Stack>
<Button onClick={onToggleAutoRefresh} variant="outline" size="sm">
{autoRefreshEnabled ? 'Disable' : 'Enable'} Auto-refresh
</Button>
</Stack>
<Button
onClick={onDownloadJson}
disabled={!runs || runs.length === 0}
variant="outline"
size="sm"
startIcon={<DownloadIcon sx={{ fontSize: 18 }} />}
>
Download JSON
</Button>
<Button
onClick={onRefresh}
disabled={isLoading}
size="lg"
startIcon={<RefreshIcon sx={isLoading ? spinSx : undefined} />}
>
{isLoading ? 'Fetching...' : 'Refresh'}
</Button>
</Stack>
</Stack>
</CardHeader>
<CardContent>
{error && (
<Alert variant="destructive" sx={{ mb: 2 }}>
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{needsAuth && (
<Alert variant="warning" sx={{ mb: 2 }}>
<AlertTitle>Authentication Required</AlertTitle>
<AlertDescription>
GitHub API requires authentication for this request. Please configure credentials and retry.
</AlertDescription>
</Alert>
)}
{conclusion && (
<Alert
sx={(theme) => ({
borderWidth: 2,
borderColor: theme.palette[summaryTone].main,
bgcolor: alpha(theme.palette[summaryTone].main, 0.08),
alignItems: 'flex-start',
mb: 2,
})}
>
<Stack direction="row" spacing={2} alignItems="flex-start">
{summaryTone === 'success' && (
<SuccessIcon sx={{ color: 'success.main', fontSize: 48 }} />
)}
{summaryTone === 'error' && (
<FailureIcon sx={{ color: 'error.main', fontSize: 48 }} />
)}
{summaryTone === 'warning' && (
<RunningIcon sx={{ color: 'warning.main', fontSize: 48, ...spinSx }} />
)}
<Box flex={1}>
<AlertTitle>
<Box sx={{ fontSize: '1.25rem', fontWeight: 700, mb: 1 }}>
{conclusion.mostRecentPassed && 'Most Recent Builds: ALL PASSED'}
{conclusion.mostRecentFailed && 'Most Recent Builds: FAILURES DETECTED'}
{conclusion.mostRecentRunning && 'Most Recent Builds: RUNNING'}
</Box>
</AlertTitle>
<AlertDescription>
<Stack spacing={2}>
<Typography variant="body2">
{conclusion.recentWorkflows.length > 1
? `Showing ${conclusion.recentWorkflows.length} workflows from the most recent run:`
: 'Most recent workflow:'}
</Typography>
<Stack spacing={1.5}>
{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 (
<Box
key={workflow.id}
sx={{
bgcolor: 'background.paper',
borderRadius: 2,
p: 2,
boxShadow: 1,
}}
>
<Stack spacing={1}>
<Stack direction="row" spacing={1} alignItems="center">
{workflow.status === 'completed' && workflow.conclusion === 'success' && (
<SuccessIcon sx={{ color: 'success.main', fontSize: 20 }} />
)}
{workflow.status === 'completed' && workflow.conclusion === 'failure' && (
<FailureIcon sx={{ color: 'error.main', fontSize: 20 }} />
)}
{workflow.status !== 'completed' && (
<RunningIcon sx={{ color: 'warning.main', fontSize: 20, ...spinSx }} />
)}
<Typography fontWeight={600}>{workflow.name}</Typography>
<Badge variant={badgeVariant} sx={{ fontSize: '0.75rem' }}>
{statusLabel}
</Badge>
</Stack>
<Stack
direction="row"
spacing={2}
flexWrap="wrap"
sx={{ color: 'text.secondary', fontSize: '0.75rem' }}
>
<Stack direction="row" spacing={0.5} alignItems="center">
<Typography fontWeight={600}>Branch:</Typography>
<Box
component="code"
sx={{
px: 0.75,
py: 0.25,
bgcolor: 'action.hover',
borderRadius: 1,
fontFamily: 'monospace',
}}
>
{workflow.head_branch}
</Box>
</Stack>
<Stack direction="row" spacing={0.5} alignItems="center">
<Typography fontWeight={600}>Updated:</Typography>
<Typography>{new Date(workflow.updated_at).toLocaleString()}</Typography>
</Stack>
</Stack>
</Stack>
</Box>
)
})}
</Stack>
<Box>
<Button
variant={conclusion.mostRecentPassed ? 'default' : 'destructive'}
size="sm"
component="a"
href="https://github.com/johndoe6345789/metabuilder/actions"
target="_blank"
rel="noopener noreferrer"
endIcon={<OpenInNewIcon sx={{ fontSize: 18 }} />}
>
View All Workflows on GitHub
</Button>
</Box>
</Stack>
</AlertDescription>
</Box>
</Stack>
</Alert>
)}
<Card sx={{ borderWidth: 2, borderColor: 'divider' }}>
<CardHeader>
<Stack direction="row" spacing={1} alignItems="center" justifyContent="space-between">
<Stack direction="row" spacing={1} alignItems="center">
<SuccessIcon sx={{ color: 'success.main', fontSize: 24 }} />
<CardTitle>Recent Workflow Runs</CardTitle>
</Stack>
{isLoading && <Skeleton sx={{ width: 120, height: 12 }} />}
</Stack>
<CardDescription>Latest GitHub Actions runs with status and controls</CardDescription>
</CardHeader>
<CardContent>
{isLoading && !runs && (
<Stack spacing={2}>
<Skeleton sx={{ height: 96 }} />
<Skeleton sx={{ height: 96 }} />
<Skeleton sx={{ height: 96 }} />
</Stack>
)}
{runs && runs.length > 0 ? (
<Stack spacing={2}>
{runs.map((run) => {
const statusIcon = getStatusColor(run.status, run.conclusion)
return (
<Card key={run.id} variant="outlined" sx={{ borderColor: 'divider' }}>
<CardContent>
<Stack direction={{ xs: 'column', md: 'row' }} spacing={2} justifyContent="space-between">
<Stack spacing={1}>
<Stack direction="row" spacing={1} alignItems="center">
<Box
sx={{
width: 10,
height: 10,
borderRadius: '50%',
bgcolor: statusIcon,
}}
/>
<Typography fontWeight={600}>{run.name}</Typography>
<Badge variant="outline" sx={{ textTransform: 'capitalize' }}>
{run.event}
</Badge>
</Stack>
<Stack direction="row" spacing={2} flexWrap="wrap" sx={{ color: 'text.secondary' }}>
<Stack direction="row" spacing={0.5} alignItems="center">
<Typography fontWeight={600}>Branch:</Typography>
<Box
component="code"
sx={{
px: 0.75,
py: 0.25,
bgcolor: 'action.hover',
borderRadius: 1,
fontFamily: 'monospace',
fontSize: '0.75rem',
}}
>
{run.head_branch}
</Box>
</Stack>
<Stack direction="row" spacing={0.5} alignItems="center">
<Typography fontWeight={600}>Event:</Typography>
<Typography>{run.event}</Typography>
</Stack>
<Stack direction="row" spacing={0.5} alignItems="center">
<Typography fontWeight={600}>Status:</Typography>
<Typography sx={{ color: getStatusColor(run.status, run.conclusion) }}>
{run.status === 'completed' ? run.conclusion : run.status}
</Typography>
</Stack>
</Stack>
<Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: 'block' }}>
Updated: {new Date(run.updated_at).toLocaleString()}
</Typography>
</Stack>
<Stack spacing={1} alignItems={{ xs: 'flex-start', md: 'flex-end' }}>
<Button
variant="outline"
size="sm"
onClick={() => onDownloadLogs(run.id, run.name)}
disabled={isLoadingLogs && selectedRunId === run.id}
startIcon={
isLoadingLogs && selectedRunId === run.id
? <RunningIcon sx={{ fontSize: 16, ...spinSx }} />
: <DownloadIcon sx={{ fontSize: 16 }} />
}
>
{isLoadingLogs && selectedRunId === run.id ? 'Loading...' : 'Download Logs'}
</Button>
<Button
variant="outline"
size="sm"
component="a"
href={run.html_url}
target="_blank"
rel="noopener noreferrer"
endIcon={<OpenInNewIcon sx={{ fontSize: 16 }} />}
>
View
</Button>
</Stack>
</Stack>
</CardContent>
</Card>
)
})}
<Box sx={{ textAlign: 'center', pt: 2 }}>
<Button
variant="outline"
onClick={() => {
if (!runs) return
const jsonData = JSON.stringify(runs, null, 2)
navigator.clipboard.writeText(jsonData)
}}
>
Copy All as JSON
</Button>
</Box>
</Stack>
) : (
<Box sx={{ textAlign: 'center', py: 6, color: 'text.secondary' }}>
{isLoading ? 'Loading workflow runs...' : 'No workflow runs found. Click refresh to fetch data.'}
</Box>
)}
</CardContent>
</Card>
</CardContent>
</Card>
)
}