mirror of
https://github.com/RayLabsHQ/gitea-mirror.git
synced 2025-12-06 11:36:44 +03:00
296 lines
8.4 KiB
TypeScript
296 lines
8.4 KiB
TypeScript
import { useCallback, useEffect, useState } from 'react';
|
|
import { Button } from '@/components/ui/button';
|
|
import { ChevronDown, Download, RefreshCw, Search } from 'lucide-react';
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
} from '../ui/dropdown-menu';
|
|
import { apiRequest, formatDate } 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';
|
|
|
|
type MirrorJobWithKey = MirrorJob & { _rowKey: string };
|
|
|
|
function genKey(job: MirrorJob): string {
|
|
return `${
|
|
job.id ?? (typeof crypto !== 'undefined'
|
|
? crypto.randomUUID()
|
|
: Math.random().toString(36).slice(2))
|
|
}-${job.timestamp}`;
|
|
}
|
|
|
|
export function ActivityLog() {
|
|
const { user } = useAuth();
|
|
|
|
const [activities, setActivities] = useState<MirrorJobWithKey[]>([]);
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
|
|
const { filter, setFilter } = useFilterParams({
|
|
searchTerm: '',
|
|
status: '',
|
|
type: '',
|
|
name: '',
|
|
});
|
|
|
|
/* ----------------------------- SSE hook ----------------------------- */
|
|
|
|
const handleNewMessage = useCallback((data: MirrorJob) => {
|
|
const withKey: MirrorJobWithKey = {
|
|
...structuredClone(data),
|
|
_rowKey: genKey(data),
|
|
};
|
|
|
|
setActivities((prev) => [withKey, ...prev]);
|
|
}, []);
|
|
|
|
const { connected } = useSSE({
|
|
userId: user?.id,
|
|
onMessage: handleNewMessage,
|
|
});
|
|
|
|
/* ------------------------- initial fetch --------------------------- */
|
|
|
|
const fetchActivities = useCallback(async () => {
|
|
if (!user) return false;
|
|
|
|
try {
|
|
setIsLoading(true);
|
|
|
|
const res = await apiRequest<ActivityApiResponse>(
|
|
`/activities?userId=${user.id}`,
|
|
{ method: 'GET' },
|
|
);
|
|
|
|
if (!res.success) {
|
|
toast.error(res.message ?? 'Failed to fetch activities.');
|
|
return false;
|
|
}
|
|
|
|
const data: MirrorJobWithKey[] = res.activities.map((a) => ({
|
|
...structuredClone(a),
|
|
_rowKey: genKey(a),
|
|
}));
|
|
|
|
setActivities(data);
|
|
return true;
|
|
} catch (err) {
|
|
toast.error(
|
|
err instanceof Error ? err.message : 'Failed to fetch activities.',
|
|
);
|
|
return false;
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}, [user]);
|
|
|
|
useEffect(() => {
|
|
fetchActivities();
|
|
}, [fetchActivities]);
|
|
|
|
/* ---------------------- 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();
|
|
};
|
|
|
|
/* ------------------------------ 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 onClick={() => fetchActivities()}>
|
|
<RefreshCw className='mr-2 h-4 w-4' />
|
|
Refresh
|
|
</Button>
|
|
</div>
|
|
|
|
{/* activity list */}
|
|
<ActivityList
|
|
activities={applyLightFilter(activities)}
|
|
isLoading={isLoading || !connected}
|
|
filter={filter}
|
|
setFilter={setFilter}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|