🎉 Gitea Mirror: Added

This commit is contained in:
Arunavo Ray
2025-05-18 09:31:23 +05:30
commit 5d40023de0
139 changed files with 22033 additions and 0 deletions

View File

@@ -0,0 +1,202 @@
import { useMemo, useRef, useState, useEffect } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
import type { MirrorJob } from "@/lib/db/schema";
import Fuse from "fuse.js";
import { Button } from "../ui/button";
import { RefreshCw } from "lucide-react";
import { Card } from "../ui/card";
import { formatDate, getStatusColor } from "@/lib/utils";
import { Skeleton } from "../ui/skeleton";
import type { FilterParams } from "@/types/filter";
interface ActivityListProps {
activities: MirrorJob[];
isLoading: boolean;
filter: FilterParams;
setFilter: (filter: FilterParams) => void;
}
export default function ActivityList({
activities,
isLoading,
filter,
setFilter,
}: ActivityListProps) {
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set());
const parentRef = useRef<HTMLDivElement>(null);
const rowRefs = useRef<Map<string, HTMLDivElement | null>>(new Map());
const filteredActivities = useMemo(() => {
let result = activities;
if (filter.status) {
result = result.filter((activity) => activity.status === filter.status);
}
if (filter.type) {
if (filter.type === 'repository') {
result = result.filter((activity) => !!activity.repositoryId);
} else if (filter.type === 'organization') {
result = result.filter((activity) => !!activity.organizationId);
}
}
if (filter.name) {
result = result.filter((activity) =>
activity.repositoryName === filter.name ||
activity.organizationName === filter.name
);
}
if (filter.searchTerm) {
const fuse = new Fuse(result, {
keys: ["message", "details", "organizationName", "repositoryName"],
threshold: 0.3,
});
result = fuse.search(filter.searchTerm).map((res) => res.item);
}
return result;
}, [activities, filter]);
const virtualizer = useVirtualizer({
count: filteredActivities.length,
getScrollElement: () => parentRef.current,
estimateSize: (index) => {
const activity = filteredActivities[index];
return expandedItems.has(activity.id || "") ? 217 : 120;
},
overscan: 5,
measureElement: (el) => el.getBoundingClientRect().height + 8,
});
useEffect(() => {
virtualizer.measure();
}, [expandedItems, virtualizer]);
return isLoading ? (
<div className="flex flex-col gap-y-4">
{Array.from({ length: 5 }, (_, index) => (
<Skeleton key={index} className="h-28 w-full rounded-md" />
))}
</div>
) : filteredActivities.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<RefreshCw className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium">No activities found</h3>
<p className="text-sm text-muted-foreground mt-1 mb-4 max-w-md">
{filter.searchTerm || filter.status || filter.type || filter.name
? "Try adjusting your search or filter criteria."
: "No mirroring activities have been recorded yet."}
</p>
{filter.searchTerm || filter.status || filter.type || filter.name ? (
<Button
variant="outline"
onClick={() => {
setFilter({ searchTerm: "", status: "", type: "", name: "" });
}}
>
Clear Filters
</Button>
) : (
<Button>
<RefreshCw className="h-4 w-4 mr-2" />
Refresh
</Button>
)}
</div>
) : (
<Card
className="border rounded-md max-h-[calc(100dvh-191px)] overflow-y-auto relative"
ref={parentRef}
>
<div
style={{
height: virtualizer.getTotalSize(),
position: "relative",
width: "100%",
}}
>
{virtualizer.getVirtualItems().map((virtualRow) => {
const activity = filteredActivities[virtualRow.index];
const isExpanded = expandedItems.has(activity.id || "");
const key = activity.id || String(virtualRow.index);
return (
<div
key={key}
ref={(node) => {
if (node) {
rowRefs.current.set(key, node);
virtualizer.measureElement(node);
}
}}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
transform: `translateY(${virtualRow.start}px)`,
paddingBottom: "8px",
}}
className="border-b px-4 pt-4"
>
<div className="flex items-start gap-4">
<div className="relative mt-2">
<div
className={`h-2 w-2 rounded-full ${getStatusColor(
activity.status
)}`}
/>
</div>
<div className="flex-1">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between mb-1">
<p className="font-medium">{activity.message}</p>
<p className="text-sm text-muted-foreground">
{formatDate(activity.timestamp)}
</p>
</div>
{activity.repositoryName && (
<p className="text-sm text-muted-foreground mb-2">
Repository: {activity.repositoryName}
</p>
)}
{activity.organizationName && (
<p className="text-sm text-muted-foreground mb-2">
Organization: {activity.organizationName}
</p>
)}
{activity.details && (
<div className="mt-2">
<Button
variant="ghost"
onClick={() => {
const newSet = new Set(expandedItems);
const id = activity.id || "";
newSet.has(id) ? newSet.delete(id) : newSet.add(id);
setExpandedItems(newSet);
}}
className="text-xs h-7 px-2"
>
{isExpanded ? "Hide Details" : "Show Details"}
</Button>
{isExpanded && (
<pre className="mt-2 p-3 bg-muted rounded-md text-xs overflow-auto whitespace-pre-wrap min-h-[100px]">
{activity.details}
</pre>
)}
</div>
)}
</div>
</div>
</div>
);
})}
</div>
</Card>
);
}

View File

@@ -0,0 +1,313 @@
import { useCallback, useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { Search, Download, RefreshCw, ChevronDown } 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";
export function ActivityLog() {
const { user } = useAuth();
const [activities, setActivities] = useState<MirrorJob[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(false);
const { filter, setFilter } = useFilterParams({
searchTerm: "",
status: "",
type: "",
name: "",
});
const handleNewMessage = useCallback((data: MirrorJob) => {
setActivities((prevActivities) => [data, ...prevActivities]);
console.log("Received new log:", data);
}, []);
// Use the SSE hook
const { connected } = useSSE({
userId: user?.id,
onMessage: handleNewMessage,
});
const fetchActivities = useCallback(async () => {
if (!user) return false;
try {
setIsLoading(true);
const response = await apiRequest<ActivityApiResponse>(
`/activities?userId=${user.id}`,
{
method: "GET",
}
);
if (response.success) {
setActivities(response.activities);
return true;
} else {
toast.error(response.message || "Failed to fetch activities.");
return false;
}
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Failed to fetch activities."
);
return false;
} finally {
setIsLoading(false);
}
}, [user]);
useEffect(() => {
fetchActivities();
}, [fetchActivities]);
const handleRefreshActivities = async () => {
const success = await fetchActivities();
if (success) {
toast.success("Activities refreshed successfully.");
}
};
// Get the currently filtered activities
const getFilteredActivities = () => {
return activities.filter(activity => {
let isIncluded = true;
if (filter.status) {
isIncluded = isIncluded && activity.status === filter.status;
}
if (filter.type) {
if (filter.type === 'repository') {
isIncluded = isIncluded && !!activity.repositoryId;
} else if (filter.type === 'organization') {
isIncluded = isIncluded && !!activity.organizationId;
}
}
if (filter.name) {
isIncluded = isIncluded && (
activity.repositoryName === filter.name ||
activity.organizationName === filter.name
);
}
// Note: We're not applying the search term filter here as that would require
// re-implementing the Fuse.js search logic
return isIncluded;
});
};
// Function to export activities as CSV
const exportAsCSV = () => {
const filteredActivities = getFilteredActivities();
if (filteredActivities.length === 0) {
toast.error("No activities to export.");
return;
}
// Create CSV content
const headers = ["Timestamp", "Message", "Status", "Repository", "Organization", "Details"];
const csvRows = [
headers.join(","),
...filteredActivities.map(activity => {
const formattedDate = formatDate(activity.timestamp);
// Escape fields that might contain commas or quotes
const escapeCsvField = (field: string | null | undefined) => {
if (!field) return '';
if (field.includes(',') || field.includes('"') || field.includes('\n')) {
return `"${field.replace(/"/g, '""')}"`;
}
return field;
};
return [
formattedDate,
escapeCsvField(activity.message),
activity.status,
escapeCsvField(activity.repositoryName || ''),
escapeCsvField(activity.organizationName || ''),
escapeCsvField(activity.details || '')
].join(',');
})
];
const csvContent = csvRows.join('\n');
// Download the CSV file
downloadFile(csvContent, 'text/csv;charset=utf-8;', 'activity_log_export.csv');
toast.success("Activity log exported as CSV successfully.");
};
// Function to export activities as JSON
const exportAsJSON = () => {
const filteredActivities = getFilteredActivities();
if (filteredActivities.length === 0) {
toast.error("No activities to export.");
return;
}
// Format the activities for export (removing any sensitive or unnecessary fields if needed)
const activitiesForExport = filteredActivities.map(activity => ({
id: activity.id,
timestamp: activity.timestamp,
formattedTime: formatDate(activity.timestamp),
message: activity.message,
status: activity.status,
repositoryId: activity.repositoryId,
repositoryName: activity.repositoryName,
organizationId: activity.organizationId,
organizationName: activity.organizationName,
details: activity.details
}));
const jsonContent = JSON.stringify(activitiesForExport, null, 2);
// Download the JSON file
downloadFile(jsonContent, 'application/json', 'activity_log_export.json');
toast.success("Activity log exported as JSON successfully.");
};
// Generic function to download a file
const downloadFile = (content: string, mimeType: string, filename: string) => {
// Add date to filename
const date = new Date();
const dateStr = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
const filenameWithDate = filename.replace('.', `_${dateStr}.`);
// Create a download link
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', filenameWithDate);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
return (
<div className="flex flex-col gap-y-8">
<div className="flex flex-row items-center gap-4 w-full">
<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="pl-8 h-9 w-full rounded-md border border-input bg-background px-3 py-1 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>
<Select
value={filter.status || "all"}
onValueChange={(value) =>
setFilter((prev) => ({
...prev,
status: value === "all" ? "" : (value as RepoStatus),
}))
}
>
<SelectTrigger className="w-[140px] h-9 max-h-9">
<SelectValue placeholder="All Status" />
</SelectTrigger>
<SelectContent>
{["all", ...repoStatusEnum.options].map((status) => (
<SelectItem key={status} value={status}>
{status === "all"
? "All Status"
: status.charAt(0).toUpperCase() + status.slice(1)}
</SelectItem>
))}
</SelectContent>
</Select>
{/* Repository/Organization Name Combobox */}
<ActivityNameCombobox
activities={activities}
value={filter.name || ""}
onChange={(name: string) => setFilter((prev) => ({ ...prev, name }))}
/>
{/* Filter by type: repository/org/all */}
<Select
value={filter.type || "all"}
onValueChange={(value) =>
setFilter((prev) => ({
...prev,
type: value === "all" ? "" : value,
}))
}
>
<SelectTrigger className="w-[140px] h-9 max-h-9">
<SelectValue placeholder="All Types" />
</SelectTrigger>
<SelectContent>
{['all', 'repository', 'organization'].map((type) => (
<SelectItem key={type} value={type}>
{type === 'all' ? 'All Types' : type.charAt(0).toUpperCase() + type.slice(1)}
</SelectItem>
))}
</SelectContent>
</Select>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="flex items-center gap-1">
<Download className="h-4 w-4 mr-1" />
Export
<ChevronDown className="h-4 w-4 ml-1" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onClick={exportAsCSV}>
Export as CSV
</DropdownMenuItem>
<DropdownMenuItem onClick={exportAsJSON}>
Export as JSON
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button onClick={handleRefreshActivities}>
<RefreshCw className="h-4 w-4 mr-2" />
Refresh
</Button>
</div>
<div className="flex flex-col gap-y-6">
<ActivityList
activities={activities}
isLoading={isLoading || !connected}
filter={filter}
setFilter={setFilter}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,86 @@
import * as React from "react";
import { ChevronsUpDown, Check } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { cn } from "@/lib/utils";
type ActivityNameComboboxProps = {
activities: any[];
value: string;
onChange: (value: string) => void;
};
export function ActivityNameCombobox({ activities, value, onChange }: ActivityNameComboboxProps) {
// Collect unique names from repositoryName and organizationName
const names = React.useMemo(() => {
const set = new Set<string>();
activities.forEach((a) => {
if (a.repositoryName) set.add(a.repositoryName);
if (a.organizationName) set.add(a.organizationName);
});
return Array.from(set).sort();
}, [activities]);
const [open, setOpen] = React.useState(false);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-[180px] justify-between"
>
{value ? value : "All Names"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[180px] p-0">
<Command>
<CommandInput placeholder="Search name..." />
<CommandList>
<CommandEmpty>No name found.</CommandEmpty>
<CommandGroup>
<CommandItem
key="all"
value=""
onSelect={() => {
onChange("");
setOpen(false);
}}
>
<Check className={cn("mr-2 h-4 w-4", value === "" ? "opacity-100" : "opacity-0")} />
All Names
</CommandItem>
{names.map((name) => (
<CommandItem
key={name}
value={name}
onSelect={() => {
onChange(name);
setOpen(false);
}}
>
<Check className={cn("mr-2 h-4 w-4", value === name ? "opacity-100" : "opacity-0")} />
{name}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}