diff --git a/frontends/nextjs/src/components/misc/github/GitHubActionsFetcher.tsx b/frontends/nextjs/src/components/misc/github/GitHubActionsFetcher.tsx index 441bd2d0e..51a09ac13 100644 --- a/frontends/nextjs/src/components/misc/github/GitHubActionsFetcher.tsx +++ b/frontends/nextjs/src/components/misc/github/GitHubActionsFetcher.tsx @@ -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(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 ( @@ -134,7 +73,7 @@ export function GitHubActionsFetcher() { runLogs={runLogs} runJobs={runJobs} selectedRunId={selectedRunId} - onAnalyzeLogs={handleAnalyzeLogs} + onAnalyzeLogs={analyzeLogs} isAnalyzing={isAnalyzing} /> @@ -145,7 +84,7 @@ export function GitHubActionsFetcher() { analysis={analysis} isAnalyzing={isAnalyzing} runLogs={runLogs} - onAnalyzeLogs={handleAnalyzeLogs} + onAnalyzeLogs={analyzeLogs} onAnalyzeWorkflows={analyzeWorkflows} /> diff --git a/frontends/nextjs/src/components/misc/github/views/RunList.tsx b/frontends/nextjs/src/components/misc/github/views/RunList.tsx index 25b511d03..675400f34 100644 --- a/frontends/nextjs/src/components/misc/github/views/RunList.tsx +++ b/frontends/nextjs/src/components/misc/github/views/RunList.tsx @@ -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" > - - - GitHub Actions Monitor - - - Repository:{' '} - - {repoLabel} - - - {lastFetched && ( - - Last fetched: {lastFetched.toLocaleString()} - - )} - + - - - - - - Recent Workflow Runs - - {isLoading && } - - Latest GitHub Actions runs with status and controls - - - - {isLoading && !runs && ( - - - - - - )} - - {runs && runs.length > 0 ? ( - - {runs.map((run: WorkflowRun) => ( - - ))} - - - - - ) : ( - - )} - - + ) diff --git a/frontends/nextjs/src/components/misc/github/views/run-list/Filters.tsx b/frontends/nextjs/src/components/misc/github/views/run-list/Filters.tsx new file mode 100644 index 000000000..40fa01bd1 --- /dev/null +++ b/frontends/nextjs/src/components/misc/github/views/run-list/Filters.tsx @@ -0,0 +1,34 @@ +import { Box, Stack, Typography } from '@mui/material' + +import type { RunListProps } from './run-list.types' + +type FiltersProps = Pick + +export const Filters = ({ repoLabel, lastFetched }: FiltersProps) => ( + + + GitHub Actions Monitor + + + Repository:{' '} + + {repoLabel} + + + {lastFetched && ( + + Last fetched: {lastFetched.toLocaleString()} + + )} + +) diff --git a/frontends/nextjs/src/components/misc/github/views/run-list/RunItemCard.tsx b/frontends/nextjs/src/components/misc/github/views/run-list/RunRow.tsx similarity index 92% rename from frontends/nextjs/src/components/misc/github/views/run-list/RunItemCard.tsx rename to frontends/nextjs/src/components/misc/github/views/run-list/RunRow.tsx index 798877a59..6f53fc8fe 100644 --- a/frontends/nextjs/src/components/misc/github/views/run-list/RunItemCard.tsx +++ b/frontends/nextjs/src/components/misc/github/views/run-list/RunRow.tsx @@ -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 ( @@ -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 ? : } > - {isLoadingLogs && selectedRunId === run.id ? 'Loading...' : 'Download Logs'} + {isSelectedRun ? 'Loading...' : 'Download Logs'} + + + ) : ( + + )} + + + ) +} diff --git a/frontends/nextjs/src/components/misc/github/workflows/hooks/useWorkflowRuns.ts b/frontends/nextjs/src/components/misc/github/workflows/hooks/useWorkflowRuns.ts new file mode 100644 index 000000000..8513558ed --- /dev/null +++ b/frontends/nextjs/src/components/misc/github/workflows/hooks/useWorkflowRuns.ts @@ -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, + } +} diff --git a/frontends/nextjs/src/components/misc/github/hooks/useWorkflowRuns.ts b/frontends/nextjs/src/components/misc/github/workflows/hooks/useWorkflowRunsApi.ts similarity index 51% rename from frontends/nextjs/src/components/misc/github/hooks/useWorkflowRuns.ts rename to frontends/nextjs/src/components/misc/github/workflows/hooks/useWorkflowRunsApi.ts index 5cf9ede25..a6c7c9568 100644 --- a/frontends/nextjs/src/components/misc/github/hooks/useWorkflowRuns.ts +++ b/frontends/nextjs/src/components/misc/github/workflows/hooks/useWorkflowRunsApi.ts @@ -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(null) const [isLoading, setIsLoading] = useState(false) const [error, setError] = useState(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, } } diff --git a/frontends/nextjs/src/components/misc/github/workflows/hooks/useWorkflowRunsSelectors.ts b/frontends/nextjs/src/components/misc/github/workflows/hooks/useWorkflowRunsSelectors.ts new file mode 100644 index 000000000..388caf9cd --- /dev/null +++ b/frontends/nextjs/src/components/misc/github/workflows/hooks/useWorkflowRunsSelectors.ts @@ -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(() => { + 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(() => { + if (!conclusion) return 'warning' + if (conclusion.mostRecentPassed) return 'success' + if (conclusion.mostRecentFailed) return 'error' + return 'warning' + }, [conclusion]) + + return { + getStatusColor, + conclusion, + summaryTone, + } +} diff --git a/frontends/nextjs/src/components/misc/github/workflows/useActionsFetcher.ts b/frontends/nextjs/src/components/misc/github/workflows/useActionsFetcher.ts new file mode 100644 index 000000000..2edae04af --- /dev/null +++ b/frontends/nextjs/src/components/misc/github/workflows/useActionsFetcher.ts @@ -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(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, + } +}