mirror of
https://github.com/johndoe6345789/metabuilder.git
synced 2026-05-02 01:34:56 +00:00
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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
@@ -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"
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user