mirror of
https://github.com/johndoe6345789/metabuilder.git
synced 2026-04-26 23:04:57 +00:00
Merge pull request #143 from johndoe6345789/codex/refactor-github-components-and-hooks-structure
refactor: modularize github actions viewer
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
36
frontends/nextjs/src/components/misc/github/types.ts
Normal file
36
frontends/nextjs/src/components/misc/github/types.ts
Normal 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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
100
frontends/nextjs/src/components/misc/github/views/RunDetails.tsx
Normal file
100
frontends/nextjs/src/components/misc/github/views/RunDetails.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
432
frontends/nextjs/src/components/misc/github/views/RunList.tsx
Normal file
432
frontends/nextjs/src/components/misc/github/views/RunList.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user