Compare commits

..

10 Commits

14 changed files with 397 additions and 411 deletions

42
CHANGELOG.md Normal file
View File

@@ -0,0 +1,42 @@
# Changelog
All notable changes to the Gitea Mirror project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [2.5.3] - 2025-05-22
### Added
- Enhanced JWT_SECRET handling with auto-generation and persistence for improved security
- Updated Proxmox LXC deployment instructions and replaced deprecated script
## [2.5.2] - 2024-11-22
### Fixed
- Fixed version information in health API for Docker deployments by setting npm_package_version environment variable in entrypoint script
## [2.5.1] - 2024-10-01
### Fixed
- Fixed Docker entrypoint script to prevent unnecessary `bun install` on container startup
- Removed redundant dependency installation in Docker containers for pre-built images
- Fixed "PathAlreadyExists" errors during container initialization
### Changed
- Improved database initialization in Docker entrypoint script
- Added additional checks for TypeScript versions of database management scripts
## [2.5.0] - 2024-09-15
Initial public release with core functionality:
### Added
- GitHub to Gitea repository mirroring
- User authentication and management
- Dashboard with mirroring statistics
- Configuration management for mirroring settings
- Support for organization mirroring
- Automated mirroring with configurable schedules
- Docker multi-architecture support (amd64, arm64)
- LXC container deployment scripts

View File

@@ -2,7 +2,7 @@
FROM oven/bun:1.2.9-alpine AS base FROM oven/bun:1.2.9-alpine AS base
WORKDIR /app WORKDIR /app
RUN apk add --no-cache libc6-compat python3 make g++ gcc wget sqlite RUN apk add --no-cache libc6-compat python3 make g++ gcc wget sqlite openssl
# ---------------------------- # ----------------------------
FROM base AS deps FROM base AS deps

View File

@@ -20,8 +20,8 @@ docker compose --profile production up -d
bun run setup && bun run dev bun run setup && bun run dev
# Using LXC Containers # Using LXC Containers
# For Proxmox VE (online) # For Proxmox VE (online) - Community script by Tobias ([CrazyWolf13](https://github.com/CrazyWolf13))
curl -fsSL https://raw.githubusercontent.com/arunavo4/gitea-mirror/main/scripts/gitea-mirror-lxc-proxmox.sh | bash curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVED/main/install/gitea-mirror-install.sh | bash
# For local testing (offline-friendly) # For local testing (offline-friendly)
sudo LOCAL_REPO_DIR=~/Development/gitea-mirror ./scripts/gitea-mirror-lxc-local.sh sudo LOCAL_REPO_DIR=~/Development/gitea-mirror ./scripts/gitea-mirror-lxc-local.sh
@@ -175,8 +175,9 @@ Gitea Mirror offers two deployment options for LXC containers:
```bash ```bash
# One-command installation on Proxmox VE # One-command installation on Proxmox VE
# Optional env overrides: CTID HOSTNAME STORAGE DISK_SIZE CORES MEMORY BRIDGE IP_CONF # Uses the community-maintained script by Tobias ([CrazyWolf13](https://github.com/CrazyWolf13))
curl -fsSL https://raw.githubusercontent.com/arunavo4/gitea-mirror/main/scripts/gitea-mirror-lxc-proxmox.sh | bash # at [community-scripts/ProxmoxVED](https://github.com/community-scripts/ProxmoxVED)
curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVED/main/install/gitea-mirror-install.sh | bash
``` ```
**2. Local testing (offline-friendly, works on developer laptops)** **2. Local testing (offline-friendly, works on developer laptops)**
@@ -232,8 +233,10 @@ The Docker container can be configured with the following environment variables:
- `DATABASE_URL`: SQLite database URL (default: `file:data/gitea-mirror.db`) - `DATABASE_URL`: SQLite database URL (default: `file:data/gitea-mirror.db`)
- `HOST`: Host to bind to (default: `0.0.0.0`) - `HOST`: Host to bind to (default: `0.0.0.0`)
- `PORT`: Port to listen on (default: `4321`) - `PORT`: Port to listen on (default: `4321`)
- `JWT_SECRET`: Secret key for JWT token generation (important for security) - `JWT_SECRET`: Secret key for JWT token generation (auto-generated if not provided)
> [!TIP]
> For security, Gitea Mirror will automatically generate a secure random JWT secret on first run if one isn't provided or if the default value is used. This generated secret is stored in the data directory for persistence across container restarts.
#### Manual Installation #### Manual Installation

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

@@ -5,12 +5,34 @@ set -e
# Ensure data directory exists # Ensure data directory exists
mkdir -p /app/data mkdir -p /app/data
# If bun is available, run setup (for dev images) # Generate a secure JWT secret if one isn't provided or is using the default value
if command -v bun >/dev/null 2>&1; then JWT_SECRET_FILE="/app/data/.jwt_secret"
echo "Running bun setup (if needed)..." if [ "$JWT_SECRET" = "your-secret-key-change-this-in-production" ] || [ -z "$JWT_SECRET" ]; then
bun run setup || true # Check if we have a previously generated secret
if [ -f "$JWT_SECRET_FILE" ]; then
echo "Using previously generated JWT secret"
export JWT_SECRET=$(cat "$JWT_SECRET_FILE")
else
echo "Generating a secure random JWT secret"
# Try to generate a secure random string using OpenSSL
if command -v openssl >/dev/null 2>&1; then
GENERATED_SECRET=$(openssl rand -hex 32)
else
# Fallback to using /dev/urandom if openssl is not available
echo "OpenSSL not found, using fallback method for random generation"
GENERATED_SECRET=$(head -c 32 /dev/urandom | sha256sum | cut -d' ' -f1)
fi
export JWT_SECRET="$GENERATED_SECRET"
# Save the secret to a file for persistence across container restarts
echo "$GENERATED_SECRET" > "$JWT_SECRET_FILE"
chmod 600 "$JWT_SECRET_FILE"
fi
echo "JWT_SECRET has been set to a secure random value"
fi fi
# Skip dependency installation entirely for pre-built images
# Dependencies are already installed during the Docker build process
# Initialize the database if it doesn't exist # Initialize the database if it doesn't exist
if [ ! -f "/app/data/gitea-mirror.db" ]; then if [ ! -f "/app/data/gitea-mirror.db" ]; then
echo "Initializing database..." echo "Initializing database..."
@@ -18,6 +40,8 @@ if [ ! -f "/app/data/gitea-mirror.db" ]; then
bun dist/scripts/init-db.js bun dist/scripts/init-db.js
elif [ -f "dist/scripts/manage-db.js" ]; then elif [ -f "dist/scripts/manage-db.js" ]; then
bun dist/scripts/manage-db.js init bun dist/scripts/manage-db.js init
elif [ -f "scripts/manage-db.ts" ]; then
bun scripts/manage-db.ts init
else else
echo "Warning: Could not find database initialization scripts in dist/scripts." echo "Warning: Could not find database initialization scripts in dist/scripts."
echo "Creating and initializing database manually..." echo "Creating and initializing database manually..."
@@ -155,6 +179,8 @@ else
bun dist/scripts/fix-db-issues.js bun dist/scripts/fix-db-issues.js
elif [ -f "dist/scripts/manage-db.js" ]; then elif [ -f "dist/scripts/manage-db.js" ]; then
bun dist/scripts/manage-db.js fix bun dist/scripts/manage-db.js fix
elif [ -f "scripts/manage-db.ts" ]; then
bun scripts/manage-db.ts fix
fi fi
# Run database migrations # Run database migrations
@@ -172,6 +198,12 @@ else
fi fi
fi fi
# Extract version from package.json and set as environment variable
if [ -f "package.json" ]; then
export npm_package_version=$(grep -o '"version": *"[^"]*"' package.json | cut -d'"' -f4)
echo "Setting application version: $npm_package_version"
fi
# Start the application # Start the application
echo "Starting Gitea Mirror..." echo "Starting Gitea Mirror..."
exec bun ./dist/server/entry.mjs exec bun ./dist/server/entry.mjs

View File

@@ -1,7 +1,7 @@
{ {
"name": "gitea-mirror", "name": "gitea-mirror",
"type": "module", "type": "module",
"version": "2.5.0", "version": "2.5.4",
"engines": { "engines": {
"bun": ">=1.2.9" "bun": ">=1.2.9"
}, },

View File

@@ -18,17 +18,18 @@ Run **Gitea Mirror** in an isolated LXC container, either:
### One-command install ### One-command install
```bash ```bash
# optional env overrides: CTID HOSTNAME STORAGE DISK_SIZE CORES MEMORY BRIDGE IP_CONF # Community-maintained script for Proxmox VE by Tobias ([CrazyWolf13](https://github.com/CrazyWolf13))
sudo bash -c "$(curl -fsSL https://raw.githubusercontent.com/arunavo4/gitea-mirror/main/scripts/gitea-mirror-lxc-proxmox.sh)" # at [community-scripts/ProxmoxVED](https://github.com/community-scripts/ProxmoxVED)
sudo bash -c "$(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVED/main/install/gitea-mirror-install.sh)"
``` ```
What it does: What it does:
* Creates **privileged** CT `$CTID` with nesting enabled * Uses the community-maintained script from ProxmoxVED
* Installs curl / git / Bun (official installer) * Installs dependencies and Bun runtime
* Clones & builds `arunavo4/gitea-mirror` * Clones & builds `arunavo4/gitea-mirror`
* Writes a root-run systemd service and starts it * Creates a systemd service and starts it
* Prints the container IP + random `JWT_SECRET` * Sets up a random `JWT_SECRET` for security
Browse to: Browse to:

View File

@@ -107,9 +107,11 @@ bun scripts/make-events-old.ts
### LXC Container Deployment ### LXC Container Deployment
Two scripts are provided for deploying Gitea Mirror in LXC containers: Two deployment options are available for LXC containers:
1. **gitea-mirror-lxc-proxmox.sh**: For online deployment on a Proxmox VE host 1. **Proxmox VE (online)**: Using the community-maintained script by Tobias ([CrazyWolf13](https://github.com/CrazyWolf13))
- Author: Tobias ([CrazyWolf13](https://github.com/CrazyWolf13))
- Available at: [community-scripts/ProxmoxVED](https://github.com/community-scripts/ProxmoxVED/blob/main/install/gitea-mirror-install.sh)
- Pulls everything from GitHub - Pulls everything from GitHub
- Creates a privileged container with the application - Creates a privileged container with the application
- Sets up systemd service - Sets up systemd service

View File

@@ -1,97 +0,0 @@
#!/usr/bin/env bash
# gitea-mirror-lxc-proxmox.sh
# Fully online installer for a Proxmox LXC guest running Gitea Mirror + Bun.
set -euo pipefail
# ────── adjustable defaults ──────────────────────────────────────────────
CTID=${CTID:-106} # container ID
HOSTNAME=${HOSTNAME:-gitea-mirror}
STORAGE=${STORAGE:-local-lvm} # where rootfs lives
DISK_SIZE=${DISK_SIZE:-8G}
CORES=${CORES:-2}
MEMORY=${MEMORY:-2048} # MiB
BRIDGE=${BRIDGE:-vmbr0}
IP_CONF=${IP_CONF:-dhcp} # or "192.168.1.240/24,gw=192.168.1.1"
PORT=4321
JWT_SECRET=$(openssl rand -hex 32)
REPO="https://github.com/arunavo4/gitea-mirror.git"
# ─────────────────────────────────────────────────────────────────────────
TEMPLATE='ubuntu-22.04-standard_22.04-1_amd64.tar.zst'
TEMPLATE_PATH="/var/lib/vz/template/cache/${TEMPLATE}"
echo "▶️ Ensuring template exists…"
if [[ ! -f $TEMPLATE_PATH ]]; then
pveam update >/dev/null
pveam download "$STORAGE" "$TEMPLATE"
fi
echo "▶️ Creating container $CTID (if missing)…"
if ! pct status "$CTID" &>/dev/null; then
pct create "$CTID" "$TEMPLATE_PATH" \
--rootfs "$STORAGE:$DISK_SIZE" \
--hostname "$HOSTNAME" \
--cores "$CORES" --memory "$MEMORY" \
--net0 "name=eth0,bridge=$BRIDGE,ip=$IP_CONF" \
--features nesting=1 \
--unprivileged 0
fi
pct start "$CTID"
echo "▶️ Installing base packages inside CT $CTID"
pct exec "$CTID" -- bash -c 'apt update && apt install -y curl git build-essential openssl sqlite3 unzip'
echo "▶️ Installing Bun runtime…"
pct exec "$CTID" -- bash -c '
export BUN_INSTALL=/opt/bun
curl -fsSL https://bun.sh/install | bash -s -- --yes
ln -sf /opt/bun/bin/bun /usr/local/bin/bun
ln -sf /opt/bun/bin/bun /usr/local/bin/bunx
bun --version
'
echo "▶️ Cloning & building Gitea Mirror…"
pct exec "$CTID" -- bash -c "
git clone --depth=1 '$REPO' /opt/gitea-mirror || (cd /opt/gitea-mirror && git pull)
cd /opt/gitea-mirror
bun install
bun run build
bun run manage-db init
"
echo "▶️ Creating systemd service…"
pct exec "$CTID" -- bash -c "
cat >/etc/systemd/system/gitea-mirror.service <<SERVICE
[Unit]
Description=Gitea Mirror
After=network.target
[Service]
Type=simple
WorkingDirectory=/opt/gitea-mirror
ExecStart=/usr/local/bin/bun dist/server/entry.mjs
Restart=on-failure
RestartSec=10
Environment=NODE_ENV=production
Environment=HOST=0.0.0.0
Environment=PORT=$PORT
Environment=DATABASE_URL=file:data/gitea-mirror.db
Environment=JWT_SECRET=$JWT_SECRET
[Install]
WantedBy=multi-user.target
SERVICE
systemctl daemon-reload
systemctl enable gitea-mirror
systemctl restart gitea-mirror
"
echo -e "\n🔍 Service status:"
pct exec "$CTID" -- systemctl status gitea-mirror --no-pager | head -n15
GUEST_IP=$(pct exec "$CTID" -- hostname -I | awk '{print $1}')
echo -e "\n🌐 Browse to: http://$GUEST_IP:$PORT\n"
echo "🗝️ JWT_SECRET = $JWT_SECRET"
echo -e "\n✅ Done Gitea Mirror is running in CT $CTID."

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

View File

@@ -104,7 +104,6 @@ gitea-mirror/
├── data/ # Database and persistent data ├── data/ # Database and persistent data
├── docker/ # Docker configuration ├── docker/ # Docker configuration
└── scripts/ # Utility scripts for deployment and maintenance └── scripts/ # Utility scripts for deployment and maintenance
├── gitea-mirror-lxc-proxmox.sh # Proxmox LXC deployment script
├── gitea-mirror-lxc-local.sh # Local LXC deployment script ├── gitea-mirror-lxc-local.sh # Local LXC deployment script
└── manage-db.ts # Database management tool └── manage-db.ts # Database management tool
``` ```
@@ -114,7 +113,7 @@ gitea-mirror/
Gitea Mirror supports multiple deployment options: Gitea Mirror supports multiple deployment options:
1. **Docker**: Run as a containerized application using Docker and docker-compose 1. **Docker**: Run as a containerized application using Docker and docker-compose
2. **LXC Containers**: Deploy in Linux Containers (LXC) on Proxmox VE or local workstations 2. **LXC Containers**: Deploy in Linux Containers (LXC) on Proxmox VE (using community script by [Tobias/CrazyWolf13](https://github.com/CrazyWolf13)) or local workstations
3. **Native**: Run directly on the host system using Bun runtime 3. **Native**: Run directly on the host system using Bun runtime
Each deployment method has its own advantages: Each deployment method has its own advantages:

View File

@@ -25,13 +25,15 @@ The following environment variables can be used to configure Gitea Mirror:
|----------|-------------|---------------|---------| |----------|-------------|---------------|---------|
| `NODE_ENV` | Runtime environment (development, production, test) | `development` | `production` | | `NODE_ENV` | Runtime environment (development, production, test) | `development` | `production` |
| `DATABASE_URL` | SQLite database URL | `file:data/gitea-mirror.db` | `file:path/to/your/database.db` | | `DATABASE_URL` | SQLite database URL | `file:data/gitea-mirror.db` | `file:path/to/your/database.db` |
| `JWT_SECRET` | Secret key for JWT authentication | `your-secret-key-change-this-in-production` | `your-secure-random-string` | | `JWT_SECRET` | Secret key for JWT authentication | Auto-generated secure random string | `your-secure-random-string` |
| `HOST` | Server host | `localhost` | `0.0.0.0` | | `HOST` | Server host | `localhost` | `0.0.0.0` |
| `PORT` | Server port | `4321` | `8080` | | `PORT` | Server port | `4321` | `8080` |
### Important Security Note ### Important Security Note
In production environments, you should always set a strong, unique `JWT_SECRET` to ensure secure authentication. The application will automatically generate a secure random `JWT_SECRET` on first run if one isn't provided or if the default value is used. This generated secret is stored in the data directory for persistence across container restarts.
While this auto-generation feature provides good security by default, you can still explicitly set your own `JWT_SECRET` for complete control over your deployment.
## Web UI Configuration ## Web UI Configuration