mirror of
https://github.com/RayLabsHQ/gitea-mirror.git
synced 2025-12-08 20:46:44 +03:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3bb85a4cdb | ||
|
|
30182544ba | ||
|
|
fb73f33aeb |
@@ -28,7 +28,7 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- gitea-network
|
- gitea-network
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/"]
|
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/api/healthz"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
@@ -75,7 +75,7 @@ services:
|
|||||||
- GITEA_ORG_VISIBILITY=${GITEA_ORG_VISIBILITY:-public}
|
- GITEA_ORG_VISIBILITY=${GITEA_ORG_VISIBILITY:-public}
|
||||||
- DELAY=${DELAY:-3600}
|
- DELAY=${DELAY:-3600}
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:4321/"]
|
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:4321/api/health"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ services:
|
|||||||
- GITEA_ORG_VISIBILITY=${GITEA_ORG_VISIBILITY:-public}
|
- GITEA_ORG_VISIBILITY=${GITEA_ORG_VISIBILITY:-public}
|
||||||
- DELAY=${DELAY:-3600}
|
- DELAY=${DELAY:-3600}
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "wget", "--no-verbose", "--tries=3", "--spider", "http://localhost:4321/"]
|
test: ["CMD", "wget", "--no-verbose", "--tries=3", "--spider", "http://localhost:4321/api/health"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "gitea-mirror",
|
"name": "gitea-mirror",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"version": "2.5.3",
|
"version": "2.5.4",
|
||||||
"engines": {
|
"engines": {
|
||||||
"bun": ">=1.2.9"
|
"bun": ">=1.2.9"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
import { useMemo, useRef, useState, useEffect } from "react";
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
import type { MirrorJob } from "@/lib/db/schema";
|
import type { MirrorJob } from '@/lib/db/schema';
|
||||||
import Fuse from "fuse.js";
|
import Fuse from 'fuse.js';
|
||||||
import { Button } from "../ui/button";
|
import { Button } from '../ui/button';
|
||||||
import { RefreshCw } from "lucide-react";
|
import { RefreshCw } from 'lucide-react';
|
||||||
import { Card } from "../ui/card";
|
import { Card } from '../ui/card';
|
||||||
import { formatDate, getStatusColor } from "@/lib/utils";
|
import { formatDate, getStatusColor } from '@/lib/utils';
|
||||||
import { Skeleton } from "../ui/skeleton";
|
import { Skeleton } from '../ui/skeleton';
|
||||||
import type { FilterParams } from "@/types/filter";
|
import type { FilterParams } from '@/types/filter';
|
||||||
|
|
||||||
|
type MirrorJobWithKey = MirrorJob & { _rowKey: string };
|
||||||
|
|
||||||
interface ActivityListProps {
|
interface ActivityListProps {
|
||||||
activities: MirrorJob[];
|
activities: MirrorJobWithKey[];
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
filter: FilterParams;
|
filter: FilterParams;
|
||||||
setFilter: (filter: FilterParams) => void;
|
setFilter: (filter: FilterParams) => void;
|
||||||
@@ -22,38 +24,44 @@ export default function ActivityList({
|
|||||||
filter,
|
filter,
|
||||||
setFilter,
|
setFilter,
|
||||||
}: ActivityListProps) {
|
}: ActivityListProps) {
|
||||||
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set());
|
const [expandedItems, setExpandedItems] = useState<Set<string>>(
|
||||||
|
() => new Set(),
|
||||||
|
);
|
||||||
|
|
||||||
const parentRef = useRef<HTMLDivElement>(null);
|
const parentRef = useRef<HTMLDivElement>(null);
|
||||||
const rowRefs = useRef<Map<string, HTMLDivElement | null>>(new Map());
|
// We keep the ref only for possible future scroll-to-row logic.
|
||||||
|
const rowRefs = useRef<Map<string, HTMLDivElement | null>>(new Map()); // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||||
|
|
||||||
const filteredActivities = useMemo(() => {
|
const filteredActivities = useMemo(() => {
|
||||||
let result = activities;
|
let result = activities;
|
||||||
|
|
||||||
if (filter.status) {
|
if (filter.status) {
|
||||||
result = result.filter((activity) => activity.status === filter.status);
|
result = result.filter((a) => a.status === filter.status);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filter.type) {
|
if (filter.type) {
|
||||||
if (filter.type === 'repository') {
|
result =
|
||||||
result = result.filter((activity) => !!activity.repositoryId);
|
filter.type === 'repository'
|
||||||
} else if (filter.type === 'organization') {
|
? result.filter((a) => !!a.repositoryId)
|
||||||
result = result.filter((activity) => !!activity.organizationId);
|
: filter.type === 'organization'
|
||||||
}
|
? result.filter((a) => !!a.organizationId)
|
||||||
|
: result;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filter.name) {
|
if (filter.name) {
|
||||||
result = result.filter((activity) =>
|
result = result.filter(
|
||||||
activity.repositoryName === filter.name ||
|
(a) =>
|
||||||
activity.organizationName === filter.name
|
a.repositoryName === filter.name ||
|
||||||
|
a.organizationName === filter.name,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filter.searchTerm) {
|
if (filter.searchTerm) {
|
||||||
const fuse = new Fuse(result, {
|
const fuse = new Fuse(result, {
|
||||||
keys: ["message", "details", "organizationName", "repositoryName"],
|
keys: ['message', 'details', 'organizationName', 'repositoryName'],
|
||||||
threshold: 0.3,
|
threshold: 0.3,
|
||||||
});
|
});
|
||||||
result = fuse.search(filter.searchTerm).map((res) => res.item);
|
result = fuse.search(filter.searchTerm).map((r) => r.item);
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
@@ -62,10 +70,8 @@ export default function ActivityList({
|
|||||||
const virtualizer = useVirtualizer({
|
const virtualizer = useVirtualizer({
|
||||||
count: filteredActivities.length,
|
count: filteredActivities.length,
|
||||||
getScrollElement: () => parentRef.current,
|
getScrollElement: () => parentRef.current,
|
||||||
estimateSize: (index) => {
|
estimateSize: (idx) =>
|
||||||
const activity = filteredActivities[index];
|
expandedItems.has(filteredActivities[idx]._rowKey) ? 217 : 120,
|
||||||
return expandedItems.has(activity.id || "") ? 217 : 120;
|
|
||||||
},
|
|
||||||
overscan: 5,
|
overscan: 5,
|
||||||
measureElement: (el) => el.getBoundingClientRect().height + 8,
|
measureElement: (el) => el.getBoundingClientRect().height + 8,
|
||||||
});
|
});
|
||||||
@@ -74,118 +80,132 @@ export default function ActivityList({
|
|||||||
virtualizer.measure();
|
virtualizer.measure();
|
||||||
}, [expandedItems, virtualizer]);
|
}, [expandedItems, virtualizer]);
|
||||||
|
|
||||||
return isLoading ? (
|
/* ------------------------------ render ------------------------------ */
|
||||||
<div className="flex flex-col gap-y-4">
|
|
||||||
{Array.from({ length: 5 }, (_, index) => (
|
if (isLoading) {
|
||||||
<Skeleton key={index} className="h-28 w-full rounded-md" />
|
return (
|
||||||
))}
|
<div className='flex flex-col gap-y-4'>
|
||||||
</div>
|
{Array.from({ length: 5 }, (_, i) => (
|
||||||
) : filteredActivities.length === 0 ? (
|
<Skeleton key={i} className='h-28 w-full rounded-md' />
|
||||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
))}
|
||||||
<RefreshCw className="h-12 w-12 text-muted-foreground mb-4" />
|
</div>
|
||||||
<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."
|
if (filteredActivities.length === 0) {
|
||||||
: "No mirroring activities have been recorded yet."}
|
const hasFilter =
|
||||||
</p>
|
filter.searchTerm || filter.status || filter.type || filter.name;
|
||||||
{filter.searchTerm || filter.status || filter.type || filter.name ? (
|
|
||||||
<Button
|
return (
|
||||||
variant="outline"
|
<div className='flex flex-col items-center justify-center py-12 text-center'>
|
||||||
onClick={() => {
|
<RefreshCw className='mb-4 h-12 w-12 text-muted-foreground' />
|
||||||
setFilter({ searchTerm: "", status: "", type: "", name: "" });
|
<h3 className='text-lg font-medium'>No activities found</h3>
|
||||||
}}
|
<p className='mt-1 mb-4 max-w-md text-sm text-muted-foreground'>
|
||||||
>
|
{hasFilter
|
||||||
Clear Filters
|
? 'Try adjusting your search or filter criteria.'
|
||||||
</Button>
|
: 'No mirroring activities have been recorded yet.'}
|
||||||
) : (
|
</p>
|
||||||
<Button>
|
{hasFilter ? (
|
||||||
<RefreshCw className="h-4 w-4 mr-2" />
|
<Button
|
||||||
Refresh
|
variant='outline'
|
||||||
</Button>
|
onClick={() =>
|
||||||
)}
|
setFilter({ searchTerm: '', status: '', type: '', name: '' })
|
||||||
</div>
|
}
|
||||||
) : (
|
>
|
||||||
|
Clear Filters
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button>
|
||||||
|
<RefreshCw className='mr-2 h-4 w-4' />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
<Card
|
<Card
|
||||||
className="border rounded-md max-h-[calc(100dvh-191px)] overflow-y-auto relative"
|
|
||||||
ref={parentRef}
|
ref={parentRef}
|
||||||
|
className='relative max-h-[calc(100dvh-191px)] overflow-y-auto rounded-md border'
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
height: virtualizer.getTotalSize(),
|
height: virtualizer.getTotalSize(),
|
||||||
position: "relative",
|
position: 'relative',
|
||||||
width: "100%",
|
width: '100%',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{virtualizer.getVirtualItems().map((virtualRow) => {
|
{virtualizer.getVirtualItems().map((vRow) => {
|
||||||
const activity = filteredActivities[virtualRow.index];
|
const activity = filteredActivities[vRow.index];
|
||||||
const isExpanded = expandedItems.has(activity.id || "");
|
const isExpanded = expandedItems.has(activity._rowKey);
|
||||||
const key = activity.id || String(virtualRow.index);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={key}
|
key={activity._rowKey}
|
||||||
ref={(node) => {
|
ref={(node) => {
|
||||||
if (node) {
|
rowRefs.current.set(activity._rowKey, node);
|
||||||
rowRefs.current.set(key, node);
|
if (node) virtualizer.measureElement(node);
|
||||||
virtualizer.measureElement(node);
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: 'absolute',
|
||||||
top: 0,
|
top: 0,
|
||||||
left: 0,
|
left: 0,
|
||||||
width: "100%",
|
width: '100%',
|
||||||
transform: `translateY(${virtualRow.start}px)`,
|
transform: `translateY(${vRow.start}px)`,
|
||||||
paddingBottom: "8px",
|
paddingBottom: '8px',
|
||||||
}}
|
}}
|
||||||
className="border-b px-4 pt-4"
|
className='border-b px-4 pt-4'
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-4">
|
<div className='flex items-start gap-4'>
|
||||||
<div className="relative mt-2">
|
<div className='relative mt-2'>
|
||||||
<div
|
<div
|
||||||
className={`h-2 w-2 rounded-full ${getStatusColor(
|
className={`h-2 w-2 rounded-full ${getStatusColor(
|
||||||
activity.status
|
activity.status,
|
||||||
)}`}
|
)}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between mb-1">
|
<div className='flex-1'>
|
||||||
<p className="font-medium">{activity.message}</p>
|
<div className='mb-1 flex flex-col sm:flex-row sm:items-center sm:justify-between'>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className='font-medium'>{activity.message}</p>
|
||||||
|
<p className='text-sm text-muted-foreground'>
|
||||||
{formatDate(activity.timestamp)}
|
{formatDate(activity.timestamp)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{activity.repositoryName && (
|
{activity.repositoryName && (
|
||||||
<p className="text-sm text-muted-foreground mb-2">
|
<p className='mb-2 text-sm text-muted-foreground'>
|
||||||
Repository: {activity.repositoryName}
|
Repository: {activity.repositoryName}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activity.organizationName && (
|
{activity.organizationName && (
|
||||||
<p className="text-sm text-muted-foreground mb-2">
|
<p className='mb-2 text-sm text-muted-foreground'>
|
||||||
Organization: {activity.organizationName}
|
Organization: {activity.organizationName}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activity.details && (
|
{activity.details && (
|
||||||
<div className="mt-2">
|
<div className='mt-2'>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant='ghost'
|
||||||
onClick={() => {
|
className='h-7 px-2 text-xs'
|
||||||
const newSet = new Set(expandedItems);
|
onClick={() =>
|
||||||
const id = activity.id || "";
|
setExpandedItems((prev) => {
|
||||||
newSet.has(id) ? newSet.delete(id) : newSet.add(id);
|
const next = new Set(prev);
|
||||||
setExpandedItems(newSet);
|
next.has(activity._rowKey)
|
||||||
}}
|
? next.delete(activity._rowKey)
|
||||||
className="text-xs h-7 px-2"
|
: next.add(activity._rowKey);
|
||||||
|
return next;
|
||||||
|
})
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{isExpanded ? "Hide Details" : "Show Details"}
|
{isExpanded ? 'Hide Details' : 'Show Details'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<pre className="mt-2 p-3 bg-muted rounded-md text-xs overflow-auto whitespace-pre-wrap min-h-[100px]">
|
<pre className='mt-2 min-h-[100px] whitespace-pre-wrap overflow-auto rounded-md bg-muted p-3 text-xs'>
|
||||||
{activity.details}
|
{activity.details}
|
||||||
</pre>
|
</pre>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,76 +1,97 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from '@/components/ui/button';
|
||||||
import { Search, Download, RefreshCw, ChevronDown } from "lucide-react";
|
import { ChevronDown, Download, RefreshCw, Search } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "../ui/dropdown-menu";
|
} from '../ui/dropdown-menu';
|
||||||
import { apiRequest, formatDate } from "@/lib/utils";
|
import { apiRequest, formatDate } from '@/lib/utils';
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
import type { MirrorJob } from "@/lib/db/schema";
|
import type { MirrorJob } from '@/lib/db/schema';
|
||||||
import type { ActivityApiResponse } from "@/types/activities";
|
import type { ActivityApiResponse } from '@/types/activities';
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
SelectItem,
|
SelectItem,
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "../ui/select";
|
} from '../ui/select';
|
||||||
import { repoStatusEnum, type RepoStatus } from "@/types/Repository";
|
import { repoStatusEnum, type RepoStatus } from '@/types/Repository';
|
||||||
import ActivityList from "./ActivityList";
|
import ActivityList from './ActivityList';
|
||||||
import { ActivityNameCombobox } from "./ActivityNameCombobox";
|
import { ActivityNameCombobox } from './ActivityNameCombobox';
|
||||||
import { useSSE } from "@/hooks/useSEE";
|
import { useSSE } from '@/hooks/useSEE';
|
||||||
import { useFilterParams } from "@/hooks/useFilterParams";
|
import { useFilterParams } from '@/hooks/useFilterParams';
|
||||||
import { toast } from "sonner";
|
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() {
|
export function ActivityLog() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const [activities, setActivities] = useState<MirrorJob[]>([]);
|
|
||||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
const [activities, setActivities] = useState<MirrorJobWithKey[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
const { filter, setFilter } = useFilterParams({
|
const { filter, setFilter } = useFilterParams({
|
||||||
searchTerm: "",
|
searchTerm: '',
|
||||||
status: "",
|
status: '',
|
||||||
type: "",
|
type: '',
|
||||||
name: "",
|
name: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleNewMessage = useCallback((data: MirrorJob) => {
|
/* ----------------------------- SSE hook ----------------------------- */
|
||||||
setActivities((prevActivities) => [data, ...prevActivities]);
|
|
||||||
|
|
||||||
console.log("Received new log:", data);
|
const handleNewMessage = useCallback((data: MirrorJob) => {
|
||||||
|
const withKey: MirrorJobWithKey = {
|
||||||
|
...structuredClone(data),
|
||||||
|
_rowKey: genKey(data),
|
||||||
|
};
|
||||||
|
|
||||||
|
setActivities((prev) => [withKey, ...prev]);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Use the SSE hook
|
|
||||||
const { connected } = useSSE({
|
const { connected } = useSSE({
|
||||||
userId: user?.id,
|
userId: user?.id,
|
||||||
onMessage: handleNewMessage,
|
onMessage: handleNewMessage,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/* ------------------------- initial fetch --------------------------- */
|
||||||
|
|
||||||
const fetchActivities = useCallback(async () => {
|
const fetchActivities = useCallback(async () => {
|
||||||
if (!user) return false;
|
if (!user) return false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
const response = await apiRequest<ActivityApiResponse>(
|
const res = await apiRequest<ActivityApiResponse>(
|
||||||
`/activities?userId=${user.id}`,
|
`/activities?userId=${user.id}`,
|
||||||
{
|
{ method: 'GET' },
|
||||||
method: "GET",
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response.success) {
|
if (!res.success) {
|
||||||
setActivities(response.activities);
|
toast.error(res.message ?? 'Failed to fetch activities.');
|
||||||
return true;
|
|
||||||
} else {
|
|
||||||
toast.error(response.message || "Failed to fetch activities.");
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
|
const data: MirrorJobWithKey[] = res.activities.map((a) => ({
|
||||||
|
...structuredClone(a),
|
||||||
|
_rowKey: genKey(a),
|
||||||
|
}));
|
||||||
|
|
||||||
|
setActivities(data);
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
toast.error(
|
toast.error(
|
||||||
error instanceof Error ? error.message : "Failed to fetch activities."
|
err instanceof Error ? err.message : 'Failed to fetch activities.',
|
||||||
);
|
);
|
||||||
return false;
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -82,208 +103,167 @@ export function ActivityLog() {
|
|||||||
fetchActivities();
|
fetchActivities();
|
||||||
}, [fetchActivities]);
|
}, [fetchActivities]);
|
||||||
|
|
||||||
const handleRefreshActivities = async () => {
|
/* ---------------------- filtering + exporting ---------------------- */
|
||||||
const success = await fetchActivities();
|
|
||||||
if (success) {
|
|
||||||
toast.success("Activities refreshed successfully.");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get the currently filtered activities
|
const applyLightFilter = (list: MirrorJobWithKey[]) => {
|
||||||
const getFilteredActivities = () => {
|
return list.filter((a) => {
|
||||||
return activities.filter(activity => {
|
if (filter.status && a.status !== filter.status) return false;
|
||||||
let isIncluded = true;
|
|
||||||
|
|
||||||
if (filter.status) {
|
if (filter.type === 'repository' && !a.repositoryId) return false;
|
||||||
isIncluded = isIncluded && activity.status === filter.status;
|
if (filter.type === 'organization' && !a.organizationId) return false;
|
||||||
|
|
||||||
|
if (
|
||||||
|
filter.name &&
|
||||||
|
a.repositoryName !== filter.name &&
|
||||||
|
a.organizationName !== filter.name
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filter.type) {
|
return true;
|
||||||
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 exportAsCSV = () => {
|
||||||
const filteredActivities = getFilteredActivities();
|
const rows = applyLightFilter(activities);
|
||||||
|
if (!rows.length) return toast.error('No activities to export.');
|
||||||
|
|
||||||
if (filteredActivities.length === 0) {
|
const headers = [
|
||||||
toast.error("No activities to export.");
|
'Timestamp',
|
||||||
return;
|
'Message',
|
||||||
}
|
'Status',
|
||||||
|
'Repository',
|
||||||
// Create CSV content
|
'Organization',
|
||||||
const headers = ["Timestamp", "Message", "Status", "Repository", "Organization", "Details"];
|
'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');
|
const escape = (v: string | null | undefined) =>
|
||||||
|
v && /[,\"\n]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v ?? '';
|
||||||
|
|
||||||
// Download the CSV file
|
const csv = [
|
||||||
downloadFile(csvContent, 'text/csv;charset=utf-8;', 'activity_log_export.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');
|
||||||
|
|
||||||
toast.success("Activity log exported as CSV successfully.");
|
downloadFile(csv, 'text/csv;charset=utf-8;', 'activity_log_export.csv');
|
||||||
|
toast.success('CSV exported.');
|
||||||
};
|
};
|
||||||
|
|
||||||
// Function to export activities as JSON
|
|
||||||
const exportAsJSON = () => {
|
const exportAsJSON = () => {
|
||||||
const filteredActivities = getFilteredActivities();
|
const rows = applyLightFilter(activities);
|
||||||
|
if (!rows.length) return toast.error('No activities to export.');
|
||||||
|
|
||||||
if (filteredActivities.length === 0) {
|
const json = JSON.stringify(
|
||||||
toast.error("No activities to export.");
|
rows.map((a) => ({
|
||||||
return;
|
...a,
|
||||||
}
|
formattedTime: formatDate(a.timestamp),
|
||||||
|
})),
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
|
||||||
// Format the activities for export (removing any sensitive or unnecessary fields if needed)
|
downloadFile(json, 'application/json', 'activity_log_export.json');
|
||||||
const activitiesForExport = filteredActivities.map(activity => ({
|
toast.success('JSON exported.');
|
||||||
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 = (
|
||||||
const downloadFile = (content: string, mimeType: string, filename: string) => {
|
content: string,
|
||||||
// Add date to filename
|
mime: string,
|
||||||
const date = new Date();
|
filename: string,
|
||||||
const dateStr = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
): void => {
|
||||||
const filenameWithDate = filename.replace('.', `_${dateStr}.`);
|
const date = new Date().toISOString().slice(0, 10); // yyyy-mm-dd
|
||||||
|
|
||||||
// Create a download link
|
|
||||||
const blob = new Blob([content], { type: mimeType });
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const link = document.createElement('a');
|
const link = document.createElement('a');
|
||||||
|
link.href = URL.createObjectURL(new Blob([content], { type: mime }));
|
||||||
link.href = url;
|
link.download = filename.replace('.', `_${date}.`);
|
||||||
link.setAttribute('download', filenameWithDate);
|
|
||||||
document.body.appendChild(link);
|
|
||||||
link.click();
|
link.click();
|
||||||
document.body.removeChild(link);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* ------------------------------ UI ------------------------------ */
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-y-8">
|
<div className='flex flex-col gap-y-8'>
|
||||||
<div className="flex flex-row items-center gap-4 w-full">
|
<div className='flex w-full flex-row items-center gap-4'>
|
||||||
<div className="relative flex-1">
|
{/* search input */}
|
||||||
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
<div className='relative flex-1'>
|
||||||
|
<Search className='absolute left-2 top-2.5 h-4 w-4 text-muted-foreground' />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type='text'
|
||||||
placeholder="Search activities..."
|
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"
|
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}
|
value={filter.searchTerm}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setFilter((prev) => ({ ...prev, searchTerm: e.target.value }))
|
setFilter((prev) => ({
|
||||||
|
...prev,
|
||||||
|
searchTerm: e.target.value,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* status select */}
|
||||||
<Select
|
<Select
|
||||||
value={filter.status || "all"}
|
value={filter.status || 'all'}
|
||||||
onValueChange={(value) =>
|
onValueChange={(v) =>
|
||||||
setFilter((prev) => ({
|
setFilter((p) => ({
|
||||||
...prev,
|
...p,
|
||||||
status: value === "all" ? "" : (value as RepoStatus),
|
status: v === 'all' ? '' : (v as RepoStatus),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-[140px] h-9 max-h-9">
|
<SelectTrigger className='h-9 w-[140px] max-h-9'>
|
||||||
<SelectValue placeholder="All Status" />
|
<SelectValue placeholder='All Status' />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{["all", ...repoStatusEnum.options].map((status) => (
|
{['all', ...repoStatusEnum.options].map((s) => (
|
||||||
<SelectItem key={status} value={status}>
|
<SelectItem key={s} value={s}>
|
||||||
{status === "all"
|
{s === 'all' ? 'All Status' : s[0].toUpperCase() + s.slice(1)}
|
||||||
? "All Status"
|
|
||||||
: status.charAt(0).toUpperCase() + status.slice(1)}
|
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
{/* Repository/Organization Name Combobox */}
|
{/* repo/org name combobox */}
|
||||||
<ActivityNameCombobox
|
<ActivityNameCombobox
|
||||||
activities={activities}
|
activities={activities}
|
||||||
value={filter.name || ""}
|
value={filter.name || ''}
|
||||||
onChange={(name: string) => setFilter((prev) => ({ ...prev, name }))}
|
onChange={(name) => setFilter((p) => ({ ...p, name }))}
|
||||||
/>
|
/>
|
||||||
{/* Filter by type: repository/org/all */}
|
|
||||||
|
{/* type select */}
|
||||||
<Select
|
<Select
|
||||||
value={filter.type || "all"}
|
value={filter.type || 'all'}
|
||||||
onValueChange={(value) =>
|
onValueChange={(v) =>
|
||||||
setFilter((prev) => ({
|
setFilter((p) => ({ ...p, type: v === 'all' ? '' : v }))
|
||||||
...prev,
|
|
||||||
type: value === "all" ? "" : value,
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-[140px] h-9 max-h-9">
|
<SelectTrigger className='h-9 w-[140px] max-h-9'>
|
||||||
<SelectValue placeholder="All Types" />
|
<SelectValue placeholder='All Types' />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{['all', 'repository', 'organization'].map((type) => (
|
{['all', 'repository', 'organization'].map((t) => (
|
||||||
<SelectItem key={type} value={type}>
|
<SelectItem key={t} value={t}>
|
||||||
{type === 'all' ? 'All Types' : type.charAt(0).toUpperCase() + type.slice(1)}
|
{t === 'all' ? 'All Types' : t[0].toUpperCase() + t.slice(1)}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
|
{/* export dropdown */}
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="outline" className="flex items-center gap-1">
|
<Button variant='outline' className='flex items-center gap-1'>
|
||||||
<Download className="h-4 w-4 mr-1" />
|
<Download className='mr-1 h-4 w-4' />
|
||||||
Export
|
Export
|
||||||
<ChevronDown className="h-4 w-4 ml-1" />
|
<ChevronDown className='ml-1 h-4 w-4' />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent>
|
<DropdownMenuContent>
|
||||||
@@ -295,19 +275,21 @@ export function ActivityLog() {
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
<Button onClick={handleRefreshActivities}>
|
|
||||||
<RefreshCw className="h-4 w-4 mr-2" />
|
{/* refresh */}
|
||||||
|
<Button onClick={() => fetchActivities()}>
|
||||||
|
<RefreshCw className='mr-2 h-4 w-4' />
|
||||||
Refresh
|
Refresh
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-y-6">
|
|
||||||
<ActivityList
|
{/* activity list */}
|
||||||
activities={activities}
|
<ActivityList
|
||||||
isLoading={isLoading || !connected}
|
activities={applyLightFilter(activities)}
|
||||||
filter={filter}
|
isLoading={isLoading || !connected}
|
||||||
setFilter={setFilter}
|
filter={filter}
|
||||||
/>
|
setFilter={setFilter}
|
||||||
</div>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user