mirror of
https://github.com/RayLabsHQ/gitea-mirror.git
synced 2025-12-10 21:46:45 +03:00
🎉 Gitea Mirror: Added
This commit is contained in:
202
src/components/activity/ActivityList.tsx
Normal file
202
src/components/activity/ActivityList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
313
src/components/activity/ActivityLog.tsx
Normal file
313
src/components/activity/ActivityLog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
86
src/components/activity/ActivityNameCombobox.tsx
Normal file
86
src/components/activity/ActivityNameCombobox.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user