Merge pull request #340 from johndoe6345789/codex/refactor-workflow-files-and-components-rxe21p

Refactor GitHub Actions fetcher hooks and run list layout
This commit is contained in:
2025-12-28 04:12:08 +00:00
committed by GitHub
9 changed files with 350 additions and 239 deletions

View File

@@ -1,101 +1,40 @@
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 { toast } from 'sonner'
import { useWorkflowRuns } from './hooks/useWorkflowRuns'
import { useWorkflowLogAnalysis } from './hooks/useWorkflowLogAnalysis'
import { useActionsFetcher } from './workflows/useActionsFetcher'
import { AnalysisPanel } from './views/AnalysisPanel'
import { RunDetails } from './views/RunDetails'
import { RunList } from './views/RunList'
export function GitHubActionsFetcher() {
const [analysis, setAnalysis] = useState<string | null>(null)
const [isAnalyzing, setIsAnalyzing] = useState(false)
const [activeTab, setActiveTab] = useState<'runs' | 'logs' | 'analysis'>(
'runs',
)
const {
runs,
isLoading,
error,
needsAuth,
repoInfo,
repoLabel,
lastFetched,
secondsUntilRefresh,
autoRefreshEnabled,
secondsUntilRefresh,
toggleAutoRefresh,
downloadWorkflowData,
fetchRuns,
getStatusColor,
isLoadingLogs,
conclusion,
summaryTone,
} = useWorkflowRuns()
const {
analyzeRunLogs,
downloadRunLogs,
isLoadingLogs,
runJobs,
runLogs,
selectedRunId,
} = useWorkflowLogAnalysis({
repoInfo,
onAnalysisStart: () => setIsAnalyzing(true),
onAnalysisComplete: (report) => {
if (report) {
setAnalysis(report)
}
setIsAnalyzing(false)
},
})
const downloadWorkflowData = () => {
if (!runs) return
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')
anchor.href = url
anchor.download = `github-actions-${new Date().toISOString()}.json`
document.body.appendChild(anchor)
anchor.click()
document.body.removeChild(anchor)
URL.revokeObjectURL(url)
toast.success('Downloaded workflow data')
}
const analyzeWorkflows = async () => {
if (!runs || runs.length === 0) {
toast.error('No data to analyze')
return
}
setIsAnalyzing(true)
try {
const summary = summarizeWorkflowRuns(runs)
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 handleAnalyzeLogs = () => analyzeRunLogs(runs)
useEffect(() => {
if (runLogs && activeTab === 'runs') {
setActiveTab('logs')
}
}, [activeTab, runLogs])
runLogs,
runJobs,
analyzeLogs,
analyzeWorkflows,
downloadRunLogs,
analysis,
isAnalyzing,
activeTab,
setActiveTab,
} = useActionsFetcher()
return (
<Stack spacing={3}>
@@ -134,7 +73,7 @@ export function GitHubActionsFetcher() {
runLogs={runLogs}
runJobs={runJobs}
selectedRunId={selectedRunId}
onAnalyzeLogs={handleAnalyzeLogs}
onAnalyzeLogs={analyzeLogs}
isAnalyzing={isAnalyzing}
/>
</TabsContent>
@@ -145,7 +84,7 @@ export function GitHubActionsFetcher() {
analysis={analysis}
isAnalyzing={isAnalyzing}
runLogs={runLogs}
onAnalyzeLogs={handleAnalyzeLogs}
onAnalyzeLogs={analyzeLogs}
onAnalyzeWorkflows={analyzeWorkflows}
/>
</TabsContent>

View File

@@ -1,14 +1,11 @@
import { Box, Stack, Typography } from '@mui/material'
import { Stack } from '@mui/material'
import { CheckCircle as SuccessIcon } from '@mui/icons-material'
import { Card, CardContent, CardHeader } from '@/components/ui'
import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle, Skeleton } from '@/components/ui'
import type { WorkflowRun } from '../types'
import { Filters } from './run-list/Filters'
import { RefreshControls } from './run-list/RefreshControls'
import { RunItemCard } from './run-list/RunItemCard'
import { RunListAlerts } from './run-list/RunListAlerts'
import { RunListEmptyState } from './run-list/RunListEmptyState'
import { RunTable } from './run-list/Table'
import type { RunListProps } from './run-list/run-list.types'
export function RunList({
@@ -39,32 +36,7 @@ export function RunList({
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>
<Filters repoLabel={repoLabel} lastFetched={lastFetched} />
<RefreshControls
autoRefreshEnabled={autoRefreshEnabled}
@@ -86,57 +58,14 @@ export function RunList({
summaryTone={summaryTone}
/>
<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: WorkflowRun) => (
<RunItemCard
key={run.id}
run={run}
getStatusColor={getStatusColor}
onDownloadLogs={onDownloadLogs}
isLoadingLogs={isLoadingLogs}
selectedRunId={selectedRunId}
/>
))}
<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>
) : (
<RunListEmptyState isLoading={isLoading} />
)}
</CardContent>
</Card>
<RunTable
runs={runs}
isLoading={isLoading}
getStatusColor={getStatusColor}
onDownloadLogs={onDownloadLogs}
isLoadingLogs={isLoadingLogs}
selectedRunId={selectedRunId}
/>
</CardContent>
</Card>
)

View File

@@ -0,0 +1,34 @@
import { Box, Stack, Typography } from '@mui/material'
import type { RunListProps } from './run-list.types'
type FiltersProps = Pick<RunListProps, 'repoLabel' | 'lastFetched'>
export const Filters = ({ repoLabel, lastFetched }: FiltersProps) => (
<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>
)

View File

@@ -7,21 +7,22 @@ import type { WorkflowRun } from '../types'
import type { RunListProps } from './run-list.types'
import { spinSx } from './run-list.types'
type RunItemCardProps = Pick<
type RunRowProps = Pick<
RunListProps,
'getStatusColor' | 'onDownloadLogs' | 'isLoadingLogs' | 'selectedRunId'
> & {
run: WorkflowRun
}
export const RunItemCard = ({
export const RunRow = ({
run,
getStatusColor,
onDownloadLogs,
isLoadingLogs,
selectedRunId,
}: RunItemCardProps) => {
}: RunRowProps) => {
const statusIcon = getStatusColor(run.status, run.conclusion)
const isSelectedRun = isLoadingLogs && selectedRunId === run.id
return (
<Card variant="outlined" sx={{ borderColor: 'divider' }}>
@@ -81,14 +82,14 @@ export const RunItemCard = ({
variant="outline"
size="sm"
onClick={() => onDownloadLogs(run.id, run.name)}
disabled={isLoadingLogs && selectedRunId === run.id}
disabled={isSelectedRun}
startIcon={
isLoadingLogs && selectedRunId === run.id
isSelectedRun
? <RunningIcon sx={{ fontSize: 16, ...spinSx }} />
: <DownloadIcon sx={{ fontSize: 16 }} />
}
>
{isLoadingLogs && selectedRunId === run.id ? 'Loading...' : 'Download Logs'}
{isSelectedRun ? 'Loading...' : 'Download Logs'}
</Button>
<Button
variant="outline"

View File

@@ -0,0 +1,81 @@
import { Box, Stack } from '@mui/material'
import { CheckCircle as SuccessIcon } from '@mui/icons-material'
import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle, Skeleton } from '@/components/ui'
import type { RunListProps } from './run-list.types'
import { RunListEmptyState } from './RunListEmptyState'
import { RunRow } from './RunRow'
type RunTableProps = Pick<
RunListProps,
| 'runs'
| 'isLoading'
| 'getStatusColor'
| 'onDownloadLogs'
| 'isLoadingLogs'
| 'selectedRunId'
>
export const RunTable = ({
runs,
isLoading,
getStatusColor,
onDownloadLogs,
isLoadingLogs,
selectedRunId,
}: RunTableProps) => {
const copyRunsToClipboard = () => {
if (!runs) return
const jsonData = JSON.stringify(runs, null, 2)
navigator.clipboard.writeText(jsonData)
}
return (
<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) => (
<RunRow
key={run.id}
run={run}
getStatusColor={getStatusColor}
onDownloadLogs={onDownloadLogs}
isLoadingLogs={isLoadingLogs}
selectedRunId={selectedRunId}
/>
))}
<Box sx={{ textAlign: 'center', pt: 2 }}>
<Button variant="outline" onClick={copyRunsToClipboard}>
Copy All as JSON
</Button>
</Box>
</Stack>
) : (
<RunListEmptyState isLoading={isLoading} />
)}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,12 @@
import { useWorkflowRunsApi } from './useWorkflowRunsApi'
import { useWorkflowRunsSelectors } from './useWorkflowRunsSelectors'
export function useWorkflowRuns() {
const api = useWorkflowRunsApi()
const selectors = useWorkflowRunsSelectors(api.runs)
return {
...api,
...selectors,
}
}

View File

@@ -1,11 +1,11 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner'
import { WorkflowRun, RepoInfo } from '../types'
import { RepoInfo, WorkflowRun } from '../../types'
const DEFAULT_REPO_LABEL = 'johndoe6345789/metabuilder'
export function useWorkflowRuns() {
export function useWorkflowRunsApi() {
const [runs, setRuns] = useState<WorkflowRun[] | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
@@ -15,7 +15,10 @@ export function useWorkflowRuns() {
const [secondsUntilRefresh, setSecondsUntilRefresh] = useState(30)
const [autoRefreshEnabled, setAutoRefreshEnabled] = useState(true)
const repoLabel = repoInfo ? `${repoInfo.owner}/${repoInfo.repo}` : DEFAULT_REPO_LABEL
const repoLabel = useMemo(
() => (repoInfo ? `${repoInfo.owner}/${repoInfo.repo}` : DEFAULT_REPO_LABEL),
[repoInfo],
)
const fetchRuns = useCallback(async () => {
setIsLoading(true)
@@ -86,72 +89,6 @@ export function useWorkflowRuns() {
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,
@@ -164,8 +101,5 @@ export function useWorkflowRuns() {
autoRefreshEnabled,
toggleAutoRefresh,
fetchRuns,
getStatusColor,
conclusion,
summaryTone,
}
}

View File

@@ -0,0 +1,97 @@
import { useCallback, useMemo } from 'react'
import { WorkflowRun } from '../../types'
const TIME_THRESHOLD_MS = 5 * 60 * 1000
type PipelineHealth = 'healthy' | 'warning' | 'critical'
type PipelineSummary = {
total: number
completed: number
successful: number
failed: number
cancelled: number
inProgress: number
successRate: number
health: PipelineHealth
recentWorkflows: WorkflowRun[]
mostRecentPassed: boolean
mostRecentFailed: boolean
mostRecentRunning: boolean
}
type SummaryTone = 'success' | 'error' | 'warning'
export const useWorkflowRunsSelectors = (runs: WorkflowRun[] | null) => {
const getStatusColor = useCallback((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<PipelineSummary | null>(() => {
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 recentWorkflows = runs.filter((run) => {
const runTimestamp = new Date(run.updated_at).getTime()
return mostRecentTimestamp - runTimestamp <= TIME_THRESHOLD_MS
})
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: PipelineHealth = '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<SummaryTone>(() => {
if (!conclusion) return 'warning'
if (conclusion.mostRecentPassed) return 'success'
if (conclusion.mostRecentFailed) return 'error'
return 'warning'
}, [conclusion])
return {
getStatusColor,
conclusion,
summaryTone,
}
}

View File

@@ -0,0 +1,84 @@
import { useCallback, useEffect, useState } from 'react'
import { toast } from 'sonner'
import { formatWorkflowRunAnalysis, summarizeWorkflowRuns } from '@/lib/github/analyze-workflow-runs'
import { useWorkflowLogAnalysis } from '../hooks/useWorkflowLogAnalysis'
import { useWorkflowRuns } from './hooks/useWorkflowRuns'
export function useActionsFetcher() {
const [analysis, setAnalysis] = useState<string | null>(null)
const [isAnalyzing, setIsAnalyzing] = useState(false)
const [activeTab, setActiveTab] = useState<'runs' | 'logs' | 'analysis'>('runs')
const workflowRuns = useWorkflowRuns()
const workflowLogAnalysis = useWorkflowLogAnalysis({
repoInfo: workflowRuns.repoInfo,
onAnalysisStart: () => setIsAnalyzing(true),
onAnalysisComplete: (report) => {
if (report) {
setAnalysis(report)
}
setIsAnalyzing(false)
},
})
const downloadWorkflowData = useCallback(() => {
if (!workflowRuns.runs) return
const jsonData = JSON.stringify(workflowRuns.runs, null, 2)
const blob = new Blob([jsonData], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = `github-actions-${new Date().toISOString()}.json`
document.body.appendChild(anchor)
anchor.click()
document.body.removeChild(anchor)
URL.revokeObjectURL(url)
toast.success('Downloaded workflow data')
}, [workflowRuns.runs])
const analyzeWorkflows = useCallback(async () => {
if (!workflowRuns.runs || workflowRuns.runs.length === 0) {
toast.error('No data to analyze')
return
}
setIsAnalyzing(true)
try {
const summary = summarizeWorkflowRuns(workflowRuns.runs)
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)
}
}, [workflowRuns.runs])
const analyzeLogs = useCallback(() => {
workflowLogAnalysis.analyzeRunLogs(workflowRuns.runs)
}, [workflowLogAnalysis, workflowRuns.runs])
useEffect(() => {
if (workflowLogAnalysis.runLogs && activeTab === 'runs') {
setActiveTab('logs')
}
}, [activeTab, workflowLogAnalysis.runLogs])
return {
...workflowRuns,
...workflowLogAnalysis,
analysis,
isAnalyzing,
activeTab,
setActiveTab,
analyzeLogs,
analyzeWorkflows,
downloadWorkflowData,
}
}