Files
flipstone-radar/src/components/Dashboard.tsx

290 lines
12 KiB
TypeScript

import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { RefreshCw, AlertCircle, Settings, Activity } from 'lucide-react';
import { WorkflowRun } from '../types/github';
import { ApiService } from '../services/api';
import { CompactWorkflowCard } from './CompactWorkflowCard';
import { WorkflowRunsModal } from './WorkflowRunsModal';
import { ThemeSwitcher } from './ThemeSwitcher';
import { SettingsModal } from './SettingsModal';
import { ApiStatusModal } from './ApiStatusModal';
import { useSettings } from '../contexts/SettingsContext';
import { NotificationService } from '../services/notifications';
interface DashboardProps {}
export const Dashboard: React.FC<DashboardProps> = () => {
const [workflowRuns, setWorkflowRuns] = useState<WorkflowRun[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
const [repositoryCount, setRepositoryCount] = useState(0);
const [selectedRepository, setSelectedRepository] = useState<{owner: string, name: string} | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
const [isApiStatusOpen, setIsApiStatusOpen] = useState(false);
const { settings } = useSettings();
const previousWorkflowRuns = useRef<WorkflowRun[]>([]);
const notificationService = useMemo(() => NotificationService.getInstance(), []);
const apiService = useMemo(() => new ApiService(), []);
const fetchWorkflowRuns = useCallback(async (isAutoRefresh = false) => {
setLoading(true);
setError(null);
try {
const runs = await apiService.getWorkflowRuns();
// Check for notifications if this is an auto-refresh and notifications are enabled
if (isAutoRefresh && settings.notifications.enabled && previousWorkflowRuns.current.length > 0) {
const previousRuns = previousWorkflowRuns.current;
const currentRuns = runs;
// Find new failures
if (settings.notifications.showFailures) {
const newFailures = currentRuns.filter(currentRun =>
currentRun.conclusion === 'failure' &&
!previousRuns.some(prevRun =>
prevRun.id === currentRun.id &&
prevRun.conclusion === 'failure'
)
);
if (newFailures.length > 0) {
notificationService.showFailureNotification(newFailures);
}
}
// Find new recoveries (previously failed runs that are now successful)
if (settings.notifications.showRecoveries) {
const newRecoveries = currentRuns.filter(currentRun =>
currentRun.conclusion === 'success' &&
previousRuns.some(prevRun =>
prevRun.repository.full_name === currentRun.repository.full_name &&
prevRun.conclusion === 'failure'
) &&
!previousRuns.some(prevRun =>
prevRun.id === currentRun.id &&
prevRun.conclusion === 'success'
)
);
if (newRecoveries.length > 0) {
notificationService.showSuccessNotification(newRecoveries);
}
}
// Find new waiting runs
if (settings.notifications.showWaiting) {
const newWaitingRuns = currentRuns.filter(currentRun =>
currentRun.status === 'waiting' &&
!previousRuns.some(prevRun =>
prevRun.id === currentRun.id &&
prevRun.status === 'waiting'
)
);
if (newWaitingRuns.length > 0) {
notificationService.showWaitingNotification(newWaitingRuns);
}
}
}
previousWorkflowRuns.current = runs;
setWorkflowRuns(runs);
setLastUpdated(new Date());
} catch (error) {
console.error('Error fetching workflow runs:', error);
setError('Failed to fetch workflow runs. Please check your configuration.');
} finally {
setLoading(false);
}
}, [apiService, settings.notifications, notificationService]);
const fetchConfig = useCallback(async () => {
try {
const config = await apiService.getConfig();
setRepositoryCount(config.repositories.length);
} catch (error) {
console.error('Error fetching config:', error);
}
}, [apiService]);
useEffect(() => {
fetchConfig();
fetchWorkflowRuns();
}, [fetchConfig, fetchWorkflowRuns]);
// Request notification permission when notifications are enabled
useEffect(() => {
if (settings.notifications.enabled) {
notificationService.requestPermission();
}
}, [settings.notifications.enabled, notificationService]);
// Auto-refresh functionality
useEffect(() => {
if (!settings.autoRefreshInterval) return;
const interval = setInterval(() => {
fetchWorkflowRuns(true);
}, settings.autoRefreshInterval * 1000);
return () => clearInterval(interval);
}, [settings.autoRefreshInterval, fetchWorkflowRuns]);
// Sort workflow runs by created_at date (newest first)
const sortedWorkflowRuns = useMemo(() => {
return [...workflowRuns].sort((a, b) => {
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime();
});
}, [workflowRuns]);
const statusCounts = {
total: workflowRuns.length,
success: workflowRuns.filter(r => r.conclusion === 'success').length,
failure: workflowRuns.filter(r => r.conclusion === 'failure').length,
active: workflowRuns.filter(r =>
r.status === 'in_progress' ||
r.status === 'queued'
).length,
waiting: workflowRuns.filter(r => r.status === 'queued').length,
};
return (
<div className="min-h-screen bg-ctp-base">
<div className="bg-ctp-mantle shadow-sm border-b border-ctp-surface0">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between py-4">
<div className="flex-1 min-w-0">
<h1 className="text-xl font-semibold text-ctp-text">GitHub Actions Radar</h1>
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 mt-1">
<p className="text-sm text-ctp-subtext0">Latest main branch runs</p>
{settings.showLastUpdateTime && lastUpdated && (
<p className="text-xs text-ctp-subtext1">
Last updated: {lastUpdated.toLocaleTimeString()}
</p>
)}
{settings.githubUsername && (
<p className="text-xs text-ctp-subtext1 truncate">
Welcome, {settings.githubUsername}!
</p>
)}
</div>
</div>
<div className="flex items-center space-x-2 md:space-x-4 ml-4">
<ThemeSwitcher />
<button
onClick={() => setIsApiStatusOpen(true)}
className="flex items-center space-x-2 px-2 md:px-3 py-2 bg-ctp-mantle border border-ctp-surface0 rounded-lg hover:bg-ctp-surface0 transition-colors"
>
<Activity className="w-4 h-4 text-ctp-text" />
<span className="text-sm text-ctp-text hidden sm:inline">API Status</span>
</button>
<button
onClick={() => setIsSettingsOpen(true)}
className="flex items-center space-x-2 px-2 md:px-3 py-2 bg-ctp-mantle border border-ctp-surface0 rounded-lg hover:bg-ctp-surface0 transition-colors"
>
<Settings className="w-4 h-4 text-ctp-text" />
<span className="text-sm text-ctp-text hidden sm:inline">Settings</span>
</button>
<button
onClick={() => fetchWorkflowRuns()}
disabled={loading}
className="flex items-center space-x-2 px-3 md:px-4 py-2 bg-ctp-blue text-ctp-base rounded-lg hover:bg-ctp-lavender disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
<span className="hidden sm:inline">Refresh</span>
</button>
<span className="text-sm text-ctp-subtext0 hidden md:inline">
{repositoryCount} repositories
</span>
</div>
</div>
</div>
</div>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-6 mb-8">
<div className="bg-ctp-mantle rounded-lg shadow-sm border border-ctp-surface0 p-6">
<div className="text-2xl font-bold text-ctp-text">{statusCounts.total}</div>
<div className="text-sm text-ctp-subtext0">Total Runs</div>
</div>
<div className="bg-ctp-mantle rounded-lg shadow-sm border border-ctp-surface0 p-6">
<div className="text-2xl font-bold text-ctp-green">{statusCounts.success}</div>
<div className="text-sm text-ctp-subtext0">Successful</div>
</div>
<div className="bg-ctp-mantle rounded-lg shadow-sm border border-ctp-surface0 p-6">
<div className="text-2xl font-bold text-ctp-red">{statusCounts.failure}</div>
<div className="text-sm text-ctp-subtext0">Failed</div>
</div>
<div className="bg-ctp-mantle rounded-lg shadow-sm border border-ctp-surface0 p-6">
<div className="text-2xl font-bold text-ctp-blue">{statusCounts.active}</div>
<div className="text-sm text-ctp-subtext0">Active</div>
</div>
<div className="bg-ctp-mantle rounded-lg shadow-sm border border-ctp-surface0 p-6">
<div className="text-2xl font-bold text-ctp-rosewater">{statusCounts.waiting}</div>
<div className="text-sm text-ctp-subtext0">Waiting</div>
</div>
</div>
{error && (
<div className="bg-ctp-mantle border border-ctp-red/30 rounded-lg p-4 mb-6">
<div className="flex items-center space-x-2">
<AlertCircle className="w-5 h-5 text-ctp-red" />
<p className="text-ctp-red">{error}</p>
</div>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{loading ? (
<div className="col-span-full flex justify-center py-12">
<RefreshCw className="w-8 h-8 animate-spin text-ctp-blue" />
</div>
) : sortedWorkflowRuns.length === 0 ? (
<div className="col-span-full text-center py-12">
<p className="text-ctp-subtext0">No workflow runs found.</p>
<p className="text-sm text-ctp-subtext1 mt-2">Check your config.json file</p>
</div>
) : (
sortedWorkflowRuns.map(run => (
<CompactWorkflowCard
key={run.id}
run={run}
onClick={() => {
setSelectedRepository({
owner: run.repository.owner.login,
name: run.repository.name
});
setIsModalOpen(true);
}}
/>
))
)}
</div>
</div>
<WorkflowRunsModal
isOpen={isModalOpen}
onClose={() => {
setIsModalOpen(false);
setSelectedRepository(null);
}}
repository={selectedRepository}
/>
<SettingsModal
isOpen={isSettingsOpen}
onClose={() => setIsSettingsOpen(false)}
/>
<ApiStatusModal
isOpen={isApiStatusOpen}
onClose={() => setIsApiStatusOpen(false)}
/>
</div>
);
};