mirror of
https://github.com/RayLabsHQ/gitea-mirror.git
synced 2025-12-07 03:56:46 +03:00
343 lines
11 KiB
TypeScript
343 lines
11 KiB
TypeScript
import { StatusCard } from "./StatusCard";
|
|
import { RecentActivity } from "./RecentActivity";
|
|
import { RepositoryList } from "./RepositoryList";
|
|
import { GitFork, Clock, FlipHorizontal, Building2 } from "lucide-react";
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import type { MirrorJob, Organization, Repository } from "@/lib/db/schema";
|
|
import { useAuth } from "@/hooks/useAuth";
|
|
import { apiRequest, showErrorToast } from "@/lib/utils";
|
|
import type { DashboardApiResponse } from "@/types/dashboard";
|
|
import { useSSE } from "@/hooks/useSEE";
|
|
import { toast } from "sonner";
|
|
import { useEffect as useEffectForToasts } from "react";
|
|
import { Skeleton } from "@/components/ui/skeleton";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { useLiveRefresh } from "@/hooks/useLiveRefresh";
|
|
import { usePageVisibility } from "@/hooks/usePageVisibility";
|
|
import { useConfigStatus } from "@/hooks/useConfigStatus";
|
|
import { useNavigation } from "@/components/layout/MainLayout";
|
|
|
|
// Helper function to format last sync time
|
|
function formatLastSyncTime(date: Date | null): string {
|
|
if (!date) return "Never";
|
|
|
|
const now = new Date();
|
|
const syncDate = new Date(date);
|
|
const diffMs = now.getTime() - syncDate.getTime();
|
|
const diffMins = Math.floor(diffMs / 60000);
|
|
const diffHours = Math.floor(diffMs / 3600000);
|
|
const diffDays = Math.floor(diffMs / 86400000);
|
|
|
|
// Show relative time for recent syncs
|
|
if (diffMins < 1) return "Just now";
|
|
if (diffMins < 60) return `${diffMins} min ago`;
|
|
if (diffHours < 24) return `${diffHours} hr${diffHours === 1 ? '' : 's'} ago`;
|
|
if (diffDays < 7) return `${diffDays} day${diffDays === 1 ? '' : 's'} ago`;
|
|
|
|
// For older syncs, show week count
|
|
const diffWeeks = Math.floor(diffDays / 7);
|
|
if (diffWeeks < 4) return `${diffWeeks} week${diffWeeks === 1 ? '' : 's'} ago`;
|
|
|
|
// For even older, show month count
|
|
const diffMonths = Math.floor(diffDays / 30);
|
|
return `${diffMonths} month${diffMonths === 1 ? '' : 's'} ago`;
|
|
}
|
|
|
|
// Helper function to format full timestamp
|
|
function formatFullTimestamp(date: Date | null): string {
|
|
if (!date) return "";
|
|
|
|
return new Date(date).toLocaleString("en-US", {
|
|
month: "2-digit",
|
|
day: "2-digit",
|
|
year: "2-digit",
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
hour12: true
|
|
}).replace(',', '');
|
|
}
|
|
|
|
export function Dashboard() {
|
|
const { user } = useAuth();
|
|
const { registerRefreshCallback } = useLiveRefresh();
|
|
const isPageVisible = usePageVisibility();
|
|
const { isFullyConfigured } = useConfigStatus();
|
|
const { navigationKey } = useNavigation();
|
|
|
|
const [repositories, setRepositories] = useState<Repository[]>([]);
|
|
const [organizations, setOrganizations] = useState<Organization[]>([]);
|
|
const [activities, setActivities] = useState<MirrorJob[]>([]);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [repoCount, setRepoCount] = useState<number>(0);
|
|
const [orgCount, setOrgCount] = useState<number>(0);
|
|
const [mirroredCount, setMirroredCount] = useState<number>(0);
|
|
const [lastSync, setLastSync] = useState<Date | null>(null);
|
|
|
|
// Dashboard auto-refresh timer (30 seconds)
|
|
const dashboardTimerRef = useRef<NodeJS.Timeout | null>(null);
|
|
const DASHBOARD_REFRESH_INTERVAL = 30000; // 30 seconds
|
|
|
|
// Create a stable callback using useCallback
|
|
const handleNewMessage = useCallback((data: MirrorJob) => {
|
|
if (data.repositoryId) {
|
|
setRepositories((prevRepos) =>
|
|
prevRepos.map((repo) =>
|
|
repo.id === data.repositoryId
|
|
? { ...repo, status: data.status, details: data.details }
|
|
: repo
|
|
)
|
|
);
|
|
} else if (data.organizationId) {
|
|
setOrganizations((prevOrgs) =>
|
|
prevOrgs.map((org) =>
|
|
org.id === data.organizationId
|
|
? { ...org, status: data.status, details: data.details }
|
|
: org
|
|
)
|
|
);
|
|
}
|
|
|
|
setActivities((prevActivities) => [data, ...prevActivities]);
|
|
}, []);
|
|
|
|
// Use the SSE hook
|
|
const { connected } = useSSE({
|
|
userId: user?.id,
|
|
onMessage: handleNewMessage,
|
|
});
|
|
|
|
// Setup rate limit event listener for toast notifications
|
|
useEffectForToasts(() => {
|
|
if (!user?.id) return;
|
|
|
|
const eventSource = new EventSource(`/api/events?userId=${user.id}`);
|
|
|
|
eventSource.addEventListener("rate-limit", (event) => {
|
|
try {
|
|
const data = JSON.parse(event.data);
|
|
|
|
switch (data.type) {
|
|
case "warning":
|
|
// 80% threshold warning
|
|
toast.warning("GitHub API Rate Limit Warning", {
|
|
description: data.message,
|
|
duration: 8000,
|
|
});
|
|
break;
|
|
|
|
case "exceeded":
|
|
// 100% rate limit exceeded
|
|
toast.error("GitHub API Rate Limit Exceeded", {
|
|
description: data.message,
|
|
duration: 10000,
|
|
});
|
|
break;
|
|
|
|
case "resumed":
|
|
// Rate limit reset notification
|
|
toast.success("Rate Limit Reset", {
|
|
description: "API operations have resumed.",
|
|
duration: 5000,
|
|
});
|
|
break;
|
|
}
|
|
} catch (error) {
|
|
console.error("Error parsing rate limit event:", error);
|
|
}
|
|
});
|
|
|
|
return () => {
|
|
eventSource.close();
|
|
};
|
|
}, [user?.id]);
|
|
|
|
// Extract fetchDashboardData as a stable callback
|
|
const fetchDashboardData = useCallback(async (showToast = false) => {
|
|
try {
|
|
if (!user?.id) {
|
|
return false;
|
|
}
|
|
|
|
// Don't fetch data if configuration is not complete
|
|
if (!isFullyConfigured) {
|
|
if (showToast) {
|
|
toast.info("Please configure GitHub and Gitea settings first");
|
|
}
|
|
return false;
|
|
}
|
|
|
|
const response = await apiRequest<DashboardApiResponse>(
|
|
`/dashboard?userId=${user.id}`,
|
|
{
|
|
method: "GET",
|
|
}
|
|
);
|
|
|
|
if (response.success) {
|
|
setRepositories(response.repositories);
|
|
setOrganizations(response.organizations);
|
|
setActivities(response.activities);
|
|
setRepoCount(response.repoCount);
|
|
setOrgCount(response.orgCount);
|
|
setMirroredCount(response.mirroredCount);
|
|
setLastSync(response.lastSync);
|
|
|
|
if (showToast) {
|
|
toast.success("Dashboard data refreshed successfully");
|
|
}
|
|
return true;
|
|
} else {
|
|
showErrorToast(response.error || "Error fetching dashboard data", toast);
|
|
return false;
|
|
}
|
|
} catch (error) {
|
|
showErrorToast(error, toast);
|
|
return false;
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}, [user?.id, isFullyConfigured]); // Only depend on user.id, not entire user object
|
|
|
|
// Initial data fetch and reset loading state when component becomes active
|
|
useEffect(() => {
|
|
// Reset loading state when component mounts or becomes active
|
|
setIsLoading(true);
|
|
fetchDashboardData();
|
|
}, [fetchDashboardData, navigationKey]); // Include navigationKey to trigger on navigation
|
|
|
|
// Setup dashboard auto-refresh (30 seconds) and register with live refresh
|
|
useEffect(() => {
|
|
// Clear any existing timer
|
|
if (dashboardTimerRef.current) {
|
|
clearInterval(dashboardTimerRef.current);
|
|
dashboardTimerRef.current = null;
|
|
}
|
|
|
|
// Set up 30-second auto-refresh only when page is visible and configuration is complete
|
|
if (isPageVisible && isFullyConfigured) {
|
|
dashboardTimerRef.current = setInterval(() => {
|
|
fetchDashboardData();
|
|
}, DASHBOARD_REFRESH_INTERVAL);
|
|
}
|
|
|
|
// Cleanup on unmount or when page becomes invisible
|
|
return () => {
|
|
if (dashboardTimerRef.current) {
|
|
clearInterval(dashboardTimerRef.current);
|
|
dashboardTimerRef.current = null;
|
|
}
|
|
};
|
|
}, [isPageVisible, isFullyConfigured, fetchDashboardData]);
|
|
|
|
// Register with global live refresh system
|
|
useEffect(() => {
|
|
// Only register if configuration is complete
|
|
if (!isFullyConfigured) {
|
|
return;
|
|
}
|
|
|
|
const unregister = registerRefreshCallback(() => {
|
|
fetchDashboardData();
|
|
});
|
|
|
|
return unregister;
|
|
}, [registerRefreshCallback, fetchDashboardData, isFullyConfigured]);
|
|
|
|
// Status Card Skeleton component
|
|
function StatusCardSkeleton() {
|
|
return (
|
|
<Card className="overflow-hidden">
|
|
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
|
|
<CardTitle className="text-sm font-medium">
|
|
<Skeleton className="h-4 w-24" />
|
|
</CardTitle>
|
|
<Skeleton className="h-4 w-4 rounded-full" />
|
|
</CardHeader>
|
|
<CardContent>
|
|
<Skeleton className="h-8 w-16 mb-1" />
|
|
<Skeleton className="h-3 w-32" />
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
return isLoading || !connected ? (
|
|
<div className="flex flex-col gap-y-6">
|
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4 md:gap-6">
|
|
<StatusCardSkeleton />
|
|
<StatusCardSkeleton />
|
|
<StatusCardSkeleton />
|
|
<StatusCardSkeleton />
|
|
</div>
|
|
|
|
<div className="flex flex-col lg:flex-row gap-6 items-start">
|
|
{/* Repository List Skeleton */}
|
|
<div className="w-full lg:w-1/2 border rounded-lg p-4">
|
|
<div className="flex justify-between items-center mb-4">
|
|
<Skeleton className="h-6 w-32" />
|
|
<Skeleton className="h-9 w-24" />
|
|
</div>
|
|
<div className="space-y-3">
|
|
{Array.from({ length: 3 }).map((_, i) => (
|
|
<Skeleton key={i} className="h-14 w-full" />
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Recent Activity Skeleton */}
|
|
<div className="w-full lg:w-1/2 border rounded-lg p-4">
|
|
<div className="flex justify-between items-center mb-4">
|
|
<Skeleton className="h-6 w-32" />
|
|
<Skeleton className="h-9 w-24" />
|
|
</div>
|
|
<div className="space-y-3">
|
|
{Array.from({ length: 3 }).map((_, i) => (
|
|
<Skeleton key={i} className="h-14 w-full" />
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="flex flex-col gap-y-6">
|
|
|
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4 md:gap-6">
|
|
<StatusCard
|
|
title="Repositories"
|
|
value={repoCount}
|
|
icon={<GitFork className="h-4 w-4" />}
|
|
description="Total imported repositories"
|
|
/>
|
|
<StatusCard
|
|
title="Mirrored"
|
|
value={mirroredCount}
|
|
icon={<FlipHorizontal className="h-4 w-4" />}
|
|
description="Synced to Gitea"
|
|
/>
|
|
<StatusCard
|
|
title="Organizations"
|
|
value={orgCount}
|
|
icon={<Building2 className="h-4 w-4" />}
|
|
description="From GitHub"
|
|
/>
|
|
<StatusCard
|
|
title="Last Sync"
|
|
value={formatLastSyncTime(lastSync)}
|
|
icon={<Clock className="h-4 w-4" />}
|
|
description={formatFullTimestamp(lastSync)}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex flex-col lg:flex-row gap-6 items-start">
|
|
<div className="w-full lg:w-1/2">
|
|
<RepositoryList repositories={repositories.slice(0, 8)} />
|
|
</div>
|
|
|
|
<div className="w-full lg:w-1/2">
|
|
<RecentActivity activities={activities.slice(0, 8)} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|