mirror of
https://github.com/RayLabsHQ/gitea-mirror.git
synced 2025-12-07 20:16:46 +03:00
- Add parseErrorMessage() utility to parse JSON error responses - Add showErrorToast() helper for consistent error display - Update all toast.error calls to use structured error parsing - Support multiple error formats: error+troubleshooting, title+description, message+details - Enhance apiRequest() to support both 'body' and 'data' properties - Add comprehensive unit tests for error parsing functionality - Improve user experience with clear, actionable error messages Fixes structured error messages from Gitea API responses that were showing as raw JSON
266 lines
8.8 KiB
TypeScript
266 lines
8.8 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 { 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";
|
|
|
|
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]);
|
|
|
|
console.log("Received new log:", data);
|
|
}, []);
|
|
|
|
// Use the SSE hook
|
|
const { connected } = useSSE({
|
|
userId: user?.id,
|
|
onMessage: handleNewMessage,
|
|
});
|
|
|
|
// 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-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
|
<StatusCardSkeleton />
|
|
<StatusCardSkeleton />
|
|
<StatusCardSkeleton />
|
|
<StatusCardSkeleton />
|
|
</div>
|
|
|
|
<div className="flex gap-x-6 items-start">
|
|
{/* Repository List Skeleton */}
|
|
<div className="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-16 w-full" />
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Recent Activity Skeleton */}
|
|
<div className="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-16 w-full" />
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="flex flex-col gap-y-6">
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
|
<StatusCard
|
|
title="Total Repositories"
|
|
value={repoCount}
|
|
icon={<GitFork className="h-4 w-4" />}
|
|
description="Repositories being mirrored"
|
|
/>
|
|
<StatusCard
|
|
title="Mirrored"
|
|
value={mirroredCount}
|
|
icon={<FlipHorizontal className="h-4 w-4" />}
|
|
description="Successfully mirrored"
|
|
/>
|
|
<StatusCard
|
|
title="Organizations"
|
|
value={orgCount}
|
|
icon={<Building2 className="h-4 w-4" />}
|
|
description="GitHub organizations"
|
|
/>
|
|
<StatusCard
|
|
title="Last Sync"
|
|
value={
|
|
lastSync
|
|
? new Date(lastSync).toLocaleString("en-US", {
|
|
month: "2-digit",
|
|
day: "2-digit",
|
|
year: "2-digit",
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
})
|
|
: "N/A"
|
|
}
|
|
icon={<Clock className="h-4 w-4" />}
|
|
description="Last successful sync"
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex gap-x-6 items-start">
|
|
<RepositoryList repositories={repositories} />
|
|
|
|
{/* the api already sends 10 activities only but slicing in case of realtime updates */}
|
|
<RecentActivity activities={activities.slice(0, 10)} />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|