mirror of
https://github.com/RayLabsHQ/gitea-mirror.git
synced 2025-12-07 03:56: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
492 lines
15 KiB
TypeScript
492 lines
15 KiB
TypeScript
import { useCallback, useEffect, useState, useRef } from 'react';
|
|
import { Button } from '@/components/ui/button';
|
|
import { ChevronDown, Download, RefreshCw, Search, Trash2 } from 'lucide-react';
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
} from '../ui/dropdown-menu';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogTrigger,
|
|
} from '../ui/dialog';
|
|
import { apiRequest, formatDate, showErrorToast } from '@/lib/utils';
|
|
import { useAuth } from '@/hooks/useAuth';
|
|
import type { MirrorJob } from '@/lib/db/schema';
|
|
import type { ActivityApiResponse } from '@/types/activities';
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '../ui/select';
|
|
import { repoStatusEnum, type RepoStatus } from '@/types/Repository';
|
|
import ActivityList from './ActivityList';
|
|
import { ActivityNameCombobox } from './ActivityNameCombobox';
|
|
import { useSSE } from '@/hooks/useSEE';
|
|
import { useFilterParams } from '@/hooks/useFilterParams';
|
|
import { toast } from 'sonner';
|
|
import { useLiveRefresh } from '@/hooks/useLiveRefresh';
|
|
import { useConfigStatus } from '@/hooks/useConfigStatus';
|
|
import { useNavigation } from '@/components/layout/MainLayout';
|
|
|
|
type MirrorJobWithKey = MirrorJob & { _rowKey: string };
|
|
|
|
// Maximum number of activities to keep in memory to prevent performance issues
|
|
const MAX_ACTIVITIES = 1000;
|
|
|
|
// More robust key generation to prevent collisions
|
|
function genKey(job: MirrorJob, index?: number): string {
|
|
const baseId = job.id || `temp-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
const timestamp = job.timestamp instanceof Date ? job.timestamp.getTime() : new Date(job.timestamp).getTime();
|
|
const indexSuffix = index !== undefined ? `-${index}` : '';
|
|
return `${baseId}-${timestamp}${indexSuffix}`;
|
|
}
|
|
|
|
// Create a deep clone without structuredClone for better browser compatibility
|
|
function deepClone<T>(obj: T): T {
|
|
if (obj === null || typeof obj !== 'object') return obj;
|
|
if (obj instanceof Date) return new Date(obj.getTime()) as T;
|
|
if (Array.isArray(obj)) return obj.map(item => deepClone(item)) as T;
|
|
|
|
const cloned = {} as T;
|
|
for (const key in obj) {
|
|
if (Object.prototype.hasOwnProperty.call(obj, key)) {
|
|
cloned[key] = deepClone(obj[key]);
|
|
}
|
|
}
|
|
return cloned;
|
|
}
|
|
|
|
export function ActivityLog() {
|
|
const { user } = useAuth();
|
|
const { registerRefreshCallback, isLiveEnabled } = useLiveRefresh();
|
|
const { isFullyConfigured } = useConfigStatus();
|
|
const { navigationKey } = useNavigation();
|
|
|
|
const [activities, setActivities] = useState<MirrorJobWithKey[]>([]);
|
|
const [isInitialLoading, setIsInitialLoading] = useState(false);
|
|
const [showCleanupDialog, setShowCleanupDialog] = useState(false);
|
|
|
|
// Ref to track if component is mounted to prevent state updates after unmount
|
|
const isMountedRef = useRef(true);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
isMountedRef.current = false;
|
|
};
|
|
}, []);
|
|
|
|
const { filter, setFilter } = useFilterParams({
|
|
searchTerm: '',
|
|
status: '',
|
|
type: '',
|
|
name: '',
|
|
});
|
|
|
|
/* ----------------------------- SSE hook ----------------------------- */
|
|
|
|
const handleNewMessage = useCallback((data: MirrorJob) => {
|
|
if (!isMountedRef.current) return;
|
|
|
|
setActivities((prev) => {
|
|
// Create a deep clone of the new activity
|
|
const clonedData = deepClone(data);
|
|
|
|
// Check if this activity already exists to prevent duplicates
|
|
const existingIndex = prev.findIndex(activity =>
|
|
activity.id === clonedData.id ||
|
|
(activity.repositoryId === clonedData.repositoryId &&
|
|
activity.organizationId === clonedData.organizationId &&
|
|
activity.message === clonedData.message &&
|
|
Math.abs(new Date(activity.timestamp).getTime() - new Date(clonedData.timestamp).getTime()) < 1000)
|
|
);
|
|
|
|
if (existingIndex !== -1) {
|
|
// Update existing activity instead of adding duplicate
|
|
const updated = [...prev];
|
|
updated[existingIndex] = {
|
|
...clonedData,
|
|
_rowKey: prev[existingIndex]._rowKey, // Keep the same key
|
|
};
|
|
return updated;
|
|
}
|
|
|
|
// Add new activity with unique key
|
|
const withKey: MirrorJobWithKey = {
|
|
...clonedData,
|
|
_rowKey: genKey(clonedData, prev.length),
|
|
};
|
|
|
|
// Limit the number of activities to prevent memory issues
|
|
const newActivities = [withKey, ...prev];
|
|
return newActivities.slice(0, MAX_ACTIVITIES);
|
|
});
|
|
}, []);
|
|
|
|
const { connected } = useSSE({
|
|
userId: user?.id,
|
|
onMessage: handleNewMessage,
|
|
});
|
|
|
|
/* ------------------------- initial fetch --------------------------- */
|
|
|
|
const fetchActivities = useCallback(async (isLiveRefresh = false) => {
|
|
if (!user?.id) return false;
|
|
|
|
try {
|
|
// Set appropriate loading state based on refresh type
|
|
if (!isLiveRefresh) {
|
|
setIsInitialLoading(true);
|
|
}
|
|
|
|
const res = await apiRequest<ActivityApiResponse>(
|
|
`/activities?userId=${user.id}`,
|
|
{ method: 'GET' },
|
|
);
|
|
|
|
if (!res.success) {
|
|
// Only show error toast for manual refreshes to avoid spam during live updates
|
|
if (!isLiveRefresh) {
|
|
showErrorToast(res.message ?? 'Failed to fetch activities.', toast);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// Process activities with robust cloning and unique keys
|
|
const data: MirrorJobWithKey[] = res.activities.map((activity, index) => {
|
|
const clonedActivity = deepClone(activity);
|
|
return {
|
|
...clonedActivity,
|
|
_rowKey: genKey(clonedActivity, index),
|
|
};
|
|
});
|
|
|
|
// Sort by timestamp (newest first) to ensure consistent ordering
|
|
data.sort((a, b) => {
|
|
const timeA = new Date(a.timestamp).getTime();
|
|
const timeB = new Date(b.timestamp).getTime();
|
|
return timeB - timeA;
|
|
});
|
|
|
|
if (isMountedRef.current) {
|
|
setActivities(data);
|
|
}
|
|
return true;
|
|
} catch (err) {
|
|
if (isMountedRef.current) {
|
|
// Only show error toast for manual refreshes to avoid spam during live updates
|
|
if (!isLiveRefresh) {
|
|
showErrorToast(err, toast);
|
|
}
|
|
}
|
|
return false;
|
|
} finally {
|
|
if (isMountedRef.current && !isLiveRefresh) {
|
|
setIsInitialLoading(false);
|
|
}
|
|
}
|
|
}, [user?.id]); // Only depend on user.id, not entire user object
|
|
|
|
useEffect(() => {
|
|
// Reset loading state when component becomes active
|
|
setIsInitialLoading(true);
|
|
fetchActivities(false); // Manual refresh, not live
|
|
}, [fetchActivities, navigationKey]); // Include navigationKey to trigger on navigation
|
|
|
|
// Register with global live refresh system
|
|
useEffect(() => {
|
|
// Only register for live refresh if configuration is complete
|
|
// Activity logs can exist from previous runs, but new activities won't be generated without config
|
|
if (!isFullyConfigured) {
|
|
return;
|
|
}
|
|
|
|
const unregister = registerRefreshCallback(() => {
|
|
fetchActivities(true); // Live refresh
|
|
});
|
|
|
|
return unregister;
|
|
}, [registerRefreshCallback, fetchActivities, isFullyConfigured]);
|
|
|
|
/* ---------------------- filtering + exporting ---------------------- */
|
|
|
|
const applyLightFilter = (list: MirrorJobWithKey[]) => {
|
|
return list.filter((a) => {
|
|
if (filter.status && a.status !== filter.status) return false;
|
|
|
|
if (filter.type === 'repository' && !a.repositoryId) return false;
|
|
if (filter.type === 'organization' && !a.organizationId) return false;
|
|
|
|
if (
|
|
filter.name &&
|
|
a.repositoryName !== filter.name &&
|
|
a.organizationName !== filter.name
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
});
|
|
};
|
|
|
|
const exportAsCSV = () => {
|
|
const rows = applyLightFilter(activities);
|
|
if (!rows.length) return toast.error('No activities to export.');
|
|
|
|
const headers = [
|
|
'Timestamp',
|
|
'Message',
|
|
'Status',
|
|
'Repository',
|
|
'Organization',
|
|
'Details',
|
|
];
|
|
|
|
const escape = (v: string | null | undefined) =>
|
|
v && /[,\"\n]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v ?? '';
|
|
|
|
const csv = [
|
|
headers.join(','),
|
|
...rows.map((a) =>
|
|
[
|
|
formatDate(a.timestamp),
|
|
escape(a.message),
|
|
a.status,
|
|
escape(a.repositoryName),
|
|
escape(a.organizationName),
|
|
escape(a.details),
|
|
].join(','),
|
|
),
|
|
].join('\n');
|
|
|
|
downloadFile(csv, 'text/csv;charset=utf-8;', 'activity_log_export.csv');
|
|
toast.success('CSV exported.');
|
|
};
|
|
|
|
const exportAsJSON = () => {
|
|
const rows = applyLightFilter(activities);
|
|
if (!rows.length) return toast.error('No activities to export.');
|
|
|
|
const json = JSON.stringify(
|
|
rows.map((a) => ({
|
|
...a,
|
|
formattedTime: formatDate(a.timestamp),
|
|
})),
|
|
null,
|
|
2,
|
|
);
|
|
|
|
downloadFile(json, 'application/json', 'activity_log_export.json');
|
|
toast.success('JSON exported.');
|
|
};
|
|
|
|
const downloadFile = (
|
|
content: string,
|
|
mime: string,
|
|
filename: string,
|
|
): void => {
|
|
const date = new Date().toISOString().slice(0, 10); // yyyy-mm-dd
|
|
const link = document.createElement('a');
|
|
link.href = URL.createObjectURL(new Blob([content], { type: mime }));
|
|
link.download = filename.replace('.', `_${date}.`);
|
|
link.click();
|
|
};
|
|
|
|
const handleCleanupClick = () => {
|
|
setShowCleanupDialog(true);
|
|
};
|
|
|
|
const confirmCleanup = async () => {
|
|
if (!user?.id) return;
|
|
|
|
try {
|
|
setIsInitialLoading(true);
|
|
setShowCleanupDialog(false);
|
|
|
|
const response = await fetch('/api/activities/cleanup', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ userId: user.id }),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorData = await response.json().catch(() => ({ error: 'Unknown error occurred' }));
|
|
throw new Error(errorData.error || `HTTP ${response.status}: ${response.statusText}`);
|
|
}
|
|
|
|
const res = await response.json();
|
|
|
|
if (res.success) {
|
|
// Clear the activities from the UI
|
|
setActivities([]);
|
|
toast.success(`All activities cleaned up successfully. Deleted ${res.result.mirrorJobsDeleted} mirror jobs and ${res.result.eventsDeleted} events.`);
|
|
} else {
|
|
showErrorToast(res.error || 'Failed to cleanup activities.', toast);
|
|
}
|
|
} catch (error) {
|
|
console.error('Error cleaning up activities:', error);
|
|
showErrorToast(error, toast);
|
|
} finally {
|
|
setIsInitialLoading(false);
|
|
}
|
|
};
|
|
|
|
const cancelCleanup = () => {
|
|
setShowCleanupDialog(false);
|
|
};
|
|
|
|
/* ------------------------------ UI ------------------------------ */
|
|
|
|
return (
|
|
<div className='flex flex-col gap-y-8'>
|
|
<div className='flex w-full flex-row items-center gap-4'>
|
|
{/* search input */}
|
|
<div className='relative flex-1'>
|
|
<Search className='absolute left-2 top-2.5 h-4 w-4 text-muted-foreground' />
|
|
<input
|
|
type='text'
|
|
placeholder='Search activities...'
|
|
className='h-9 w-full rounded-md border border-input bg-background px-3 py-1 pl-8 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring'
|
|
value={filter.searchTerm}
|
|
onChange={(e) =>
|
|
setFilter((prev) => ({
|
|
...prev,
|
|
searchTerm: e.target.value,
|
|
}))
|
|
}
|
|
/>
|
|
</div>
|
|
|
|
{/* status select */}
|
|
<Select
|
|
value={filter.status || 'all'}
|
|
onValueChange={(v) =>
|
|
setFilter((p) => ({
|
|
...p,
|
|
status: v === 'all' ? '' : (v as RepoStatus),
|
|
}))
|
|
}
|
|
>
|
|
<SelectTrigger className='h-9 w-[140px] max-h-9'>
|
|
<SelectValue placeholder='All Status' />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{['all', ...repoStatusEnum.options].map((s) => (
|
|
<SelectItem key={s} value={s}>
|
|
{s === 'all' ? 'All Status' : s[0].toUpperCase() + s.slice(1)}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
{/* repo/org name combobox */}
|
|
<ActivityNameCombobox
|
|
activities={activities}
|
|
value={filter.name || ''}
|
|
onChange={(name) => setFilter((p) => ({ ...p, name }))}
|
|
/>
|
|
|
|
{/* type select */}
|
|
<Select
|
|
value={filter.type || 'all'}
|
|
onValueChange={(v) =>
|
|
setFilter((p) => ({ ...p, type: v === 'all' ? '' : v }))
|
|
}
|
|
>
|
|
<SelectTrigger className='h-9 w-[140px] max-h-9'>
|
|
<SelectValue placeholder='All Types' />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{['all', 'repository', 'organization'].map((t) => (
|
|
<SelectItem key={t} value={t}>
|
|
{t === 'all' ? 'All Types' : t[0].toUpperCase() + t.slice(1)}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
{/* export dropdown */}
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button variant='outline' className='flex items-center gap-1'>
|
|
<Download className='mr-1 h-4 w-4' />
|
|
Export
|
|
<ChevronDown className='ml-1 h-4 w-4' />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent>
|
|
<DropdownMenuItem onClick={exportAsCSV}>
|
|
Export as CSV
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={exportAsJSON}>
|
|
Export as JSON
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
|
|
{/* refresh */}
|
|
<Button
|
|
variant="outline"
|
|
size="icon"
|
|
onClick={() => fetchActivities(false)} // Manual refresh, show loading skeleton
|
|
title="Refresh activity log"
|
|
>
|
|
<RefreshCw className='h-4 w-4' />
|
|
</Button>
|
|
|
|
{/* cleanup all activities */}
|
|
<Button
|
|
variant="outline"
|
|
size="icon"
|
|
onClick={handleCleanupClick}
|
|
title="Delete all activities"
|
|
className="text-destructive hover:text-destructive"
|
|
>
|
|
<Trash2 className='h-4 w-4' />
|
|
</Button>
|
|
</div>
|
|
|
|
{/* activity list */}
|
|
<ActivityList
|
|
activities={applyLightFilter(activities)}
|
|
isLoading={isInitialLoading || !connected}
|
|
isLiveActive={isLiveEnabled && isFullyConfigured}
|
|
filter={filter}
|
|
setFilter={setFilter}
|
|
/>
|
|
|
|
{/* cleanup confirmation dialog */}
|
|
<Dialog open={showCleanupDialog} onOpenChange={setShowCleanupDialog}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Delete All Activities</DialogTitle>
|
|
<DialogDescription>
|
|
Are you sure you want to delete ALL activities? This action cannot be undone and will remove all mirror jobs and events from the database.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={cancelCleanup}>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
variant="destructive"
|
|
onClick={confirmCleanup}
|
|
disabled={isInitialLoading}
|
|
>
|
|
{isInitialLoading ? 'Deleting...' : 'Delete All Activities'}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
}
|