Compare commits

...

3 Commits

5 changed files with 294 additions and 292 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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"
}, },

View File

@@ -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>
)} )}

View File

@@ -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>
); );
} }