mirror of
https://github.com/RayLabsHQ/gitea-mirror.git
synced 2025-12-06 19:46:44 +03:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e63fd2278 | ||
|
|
daf4ab6a93 | ||
|
|
4404af7d40 | ||
|
|
97ff8d190d | ||
|
|
3ff86de67d | ||
|
|
3d8bdff9af | ||
|
|
a28a766f8b | ||
|
|
7afe364a24 | ||
|
|
a4e771d3bd | ||
|
|
703156b15c | ||
|
|
20a771f340 | ||
|
|
d925b3c155 | ||
|
|
47e1c7b493 | ||
|
|
d7ce2a6908 | ||
|
|
4efe741c64 | ||
|
|
773842fa72 | ||
|
|
90944a40c6 |
@@ -30,3 +30,9 @@ JWT_SECRET=change-this-to-a-secure-random-string-in-production
|
||||
# GITEA_ORGANIZATION=github-mirrors
|
||||
# GITEA_ORG_VISIBILITY=public
|
||||
# DELAY=3600
|
||||
|
||||
# Optional Database Cleanup Configuration (configured via web UI)
|
||||
# These environment variables are optional and only used as defaults
|
||||
# Users can configure cleanup settings through the web interface
|
||||
# CLEANUP_ENABLED=false
|
||||
# CLEANUP_RETENTION_DAYS=7
|
||||
|
||||
30
README.md
30
README.md
@@ -481,7 +481,7 @@ Try the following steps:
|
||||
> docker compose up -d
|
||||
> ```
|
||||
>
|
||||
> This setup includes automatic database maintenance that runs daily to clean up old events and mirror jobs, preventing the database from growing too large. You can customize the retention periods by setting the `EVENTS_RETENTION_DAYS` and `JOBS_RETENTION_DAYS` environment variables.
|
||||
> This setup provides a complete containerized deployment for the Gitea Mirror application.
|
||||
|
||||
|
||||
#### Database Maintenance
|
||||
@@ -498,35 +498,9 @@ Try the following steps:
|
||||
>
|
||||
> # Reset user accounts (for development)
|
||||
> bun run reset-users
|
||||
>
|
||||
> # Clean up old events (keeps last 7 days by default)
|
||||
> bun run cleanup-events
|
||||
>
|
||||
> # Clean up old events with custom retention period (e.g., 30 days)
|
||||
> bun run cleanup-events 30
|
||||
>
|
||||
> # Clean up old mirror jobs (keeps last 7 days by default)
|
||||
> bun run cleanup-jobs
|
||||
>
|
||||
> # Clean up old mirror jobs with custom retention period (e.g., 30 days)
|
||||
> bun run cleanup-jobs 30
|
||||
>
|
||||
> # Clean up both events and mirror jobs
|
||||
> bun run cleanup-all
|
||||
> ```
|
||||
>
|
||||
> For automated maintenance, consider setting up cron jobs to run the cleanup scripts periodically:
|
||||
>
|
||||
> ```bash
|
||||
> # Add these to your crontab
|
||||
> # Clean up events daily at 2 AM
|
||||
> 0 2 * * * cd /path/to/gitea-mirror && bun run cleanup-events
|
||||
>
|
||||
> # Clean up mirror jobs daily at 3 AM
|
||||
> 0 3 * * * cd /path/to/gitea-mirror && bun run cleanup-jobs
|
||||
> ```
|
||||
>
|
||||
> **Note:** When using Docker, these cleanup jobs are automatically scheduled inside the container with the default retention period of 7 days. You can customize the retention periods by setting the `EVENTS_RETENTION_DAYS` and `JOBS_RETENTION_DAYS` environment variables in your docker-compose file.
|
||||
> **Note:** For cleaning up old activities and events, use the cleanup button in the Activity Log page of the web interface.
|
||||
|
||||
|
||||
> [!NOTE]
|
||||
|
||||
@@ -41,9 +41,6 @@ services:
|
||||
- GITEA_ORGANIZATION=${GITEA_ORGANIZATION:-github-mirrors}
|
||||
- GITEA_ORG_VISIBILITY=${GITEA_ORG_VISIBILITY:-public}
|
||||
- DELAY=${DELAY:-3600}
|
||||
# Database maintenance settings
|
||||
- EVENTS_RETENTION_DAYS=${EVENTS_RETENTION_DAYS:-7}
|
||||
- JOBS_RETENTION_DAYS=${JOBS_RETENTION_DAYS:-7}
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=3", "--spider", "http://localhost:4321/api/health"]
|
||||
interval: 30s
|
||||
|
||||
@@ -30,24 +30,7 @@ if [ "$JWT_SECRET" = "your-secret-key-change-this-in-production" ] || [ -z "$JWT
|
||||
echo "JWT_SECRET has been set to a secure random value"
|
||||
fi
|
||||
|
||||
# Set up automatic database cleanup cron job
|
||||
# Default to 7 days retention for events and mirror jobs unless specified by environment variables
|
||||
EVENTS_RETENTION_DAYS=${EVENTS_RETENTION_DAYS:-7}
|
||||
JOBS_RETENTION_DAYS=${JOBS_RETENTION_DAYS:-7}
|
||||
|
||||
# Create cron directory if it doesn't exist
|
||||
mkdir -p /app/data/cron
|
||||
|
||||
# Create the cron job file
|
||||
cat > /app/data/cron/cleanup-cron <<EOF
|
||||
# Run event cleanup daily at 2 AM
|
||||
0 2 * * * cd /app && bun dist/scripts/cleanup-events.js ${EVENTS_RETENTION_DAYS} >> /app/data/cleanup-events.log 2>&1
|
||||
|
||||
# Run mirror jobs cleanup daily at 3 AM
|
||||
0 3 * * * cd /app && bun dist/scripts/cleanup-mirror-jobs.js ${JOBS_RETENTION_DAYS} >> /app/data/cleanup-mirror-jobs.log 2>&1
|
||||
|
||||
# Empty line at the end is required for cron to work properly
|
||||
EOF
|
||||
|
||||
# Skip dependency installation entirely for pre-built images
|
||||
# Dependencies are already installed during the Docker build process
|
||||
@@ -223,32 +206,7 @@ if [ -f "package.json" ]; then
|
||||
echo "Setting application version: $npm_package_version"
|
||||
fi
|
||||
|
||||
# Set up cron if it's available
|
||||
if command -v crontab >/dev/null 2>&1; then
|
||||
echo "Setting up automatic database cleanup cron jobs..."
|
||||
# Install cron if not already installed
|
||||
if ! command -v cron >/dev/null 2>&1; then
|
||||
echo "Installing cron..."
|
||||
apt-get update && apt-get install -y cron
|
||||
fi
|
||||
|
||||
# Install the cron job
|
||||
crontab /app/data/cron/cleanup-cron
|
||||
|
||||
# Start cron service
|
||||
if command -v service >/dev/null 2>&1; then
|
||||
service cron start
|
||||
echo "Cron service started"
|
||||
elif command -v cron >/dev/null 2>&1; then
|
||||
cron
|
||||
echo "Cron daemon started"
|
||||
else
|
||||
echo "Warning: Could not start cron service. Automatic database cleanup will not run."
|
||||
fi
|
||||
else
|
||||
echo "Warning: crontab command not found. Automatic database cleanup will not be set up."
|
||||
echo "Consider setting up external scheduled tasks to run cleanup scripts."
|
||||
fi
|
||||
|
||||
# Run startup recovery to handle any interrupted jobs
|
||||
echo "Running startup recovery..."
|
||||
@@ -274,6 +232,23 @@ else
|
||||
echo "❌ Startup recovery failed with exit code $RECOVERY_EXIT_CODE"
|
||||
fi
|
||||
|
||||
# Function to handle shutdown signals
|
||||
shutdown_handler() {
|
||||
echo "🛑 Received shutdown signal, forwarding to application..."
|
||||
if [ ! -z "$APP_PID" ]; then
|
||||
kill -TERM "$APP_PID"
|
||||
wait "$APP_PID"
|
||||
fi
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Set up signal handlers
|
||||
trap 'shutdown_handler' TERM INT HUP
|
||||
|
||||
# Start the application
|
||||
echo "Starting Gitea Mirror..."
|
||||
exec bun ./dist/server/entry.mjs
|
||||
bun ./dist/server/entry.mjs &
|
||||
APP_PID=$!
|
||||
|
||||
# Wait for the application to finish
|
||||
wait "$APP_PID"
|
||||
|
||||
249
docs/GRACEFUL_SHUTDOWN.md
Normal file
249
docs/GRACEFUL_SHUTDOWN.md
Normal file
@@ -0,0 +1,249 @@
|
||||
# Graceful Shutdown and Enhanced Job Recovery
|
||||
|
||||
This document describes the graceful shutdown and enhanced job recovery capabilities implemented in gitea-mirror v2.8.0+.
|
||||
|
||||
## Overview
|
||||
|
||||
The gitea-mirror application now includes comprehensive graceful shutdown handling and enhanced job recovery mechanisms designed specifically for containerized environments. These features ensure:
|
||||
|
||||
- **No data loss** during container restarts or shutdowns
|
||||
- **Automatic job resumption** after application restarts
|
||||
- **Clean termination** of all active processes and connections
|
||||
- **Container-aware design** optimized for Docker/LXC deployments
|
||||
|
||||
## Features
|
||||
|
||||
### 1. Graceful Shutdown Manager
|
||||
|
||||
The shutdown manager (`src/lib/shutdown-manager.ts`) provides centralized coordination of application termination:
|
||||
|
||||
#### Key Capabilities:
|
||||
- **Active Job Tracking**: Monitors all running mirroring/sync jobs
|
||||
- **State Persistence**: Saves job progress to database before shutdown
|
||||
- **Callback System**: Allows services to register cleanup functions
|
||||
- **Timeout Protection**: Prevents hanging shutdowns with configurable timeouts
|
||||
- **Signal Coordination**: Works with signal handlers for proper container lifecycle
|
||||
|
||||
#### Configuration:
|
||||
- **Shutdown Timeout**: 30 seconds maximum (configurable)
|
||||
- **Job Save Timeout**: 10 seconds per job (configurable)
|
||||
|
||||
### 2. Signal Handlers
|
||||
|
||||
The signal handler system (`src/lib/signal-handlers.ts`) ensures proper response to container lifecycle events:
|
||||
|
||||
#### Supported Signals:
|
||||
- **SIGTERM**: Docker stop, Kubernetes pod termination
|
||||
- **SIGINT**: Ctrl+C, manual interruption
|
||||
- **SIGHUP**: Terminal hangup, service reload
|
||||
- **Uncaught Exceptions**: Emergency shutdown on critical errors
|
||||
- **Unhandled Rejections**: Graceful handling of promise failures
|
||||
|
||||
### 3. Enhanced Job Recovery
|
||||
|
||||
Building on the existing recovery system, new enhancements include:
|
||||
|
||||
#### Shutdown-Aware Processing:
|
||||
- Jobs check for shutdown signals during execution
|
||||
- Automatic state saving when shutdown is detected
|
||||
- Proper job status management (interrupted vs failed)
|
||||
|
||||
#### Container Integration:
|
||||
- Docker entrypoint script forwards signals correctly
|
||||
- Startup recovery runs before main application
|
||||
- Recovery timeouts prevent startup delays
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Operation
|
||||
|
||||
The graceful shutdown system is automatically initialized when the application starts. No manual configuration is required for basic operation.
|
||||
|
||||
### Testing
|
||||
|
||||
Test the graceful shutdown functionality:
|
||||
|
||||
```bash
|
||||
# Run the integration test
|
||||
bun run test-shutdown
|
||||
|
||||
# Clean up test data
|
||||
bun run test-shutdown-cleanup
|
||||
|
||||
# Run unit tests
|
||||
bun test src/lib/shutdown-manager.test.ts
|
||||
bun test src/lib/signal-handlers.test.ts
|
||||
```
|
||||
|
||||
### Manual Testing
|
||||
|
||||
1. **Start the application**:
|
||||
```bash
|
||||
bun run dev
|
||||
# or in production
|
||||
bun run start
|
||||
```
|
||||
|
||||
2. **Start a mirroring job** through the web interface
|
||||
|
||||
3. **Send shutdown signal**:
|
||||
```bash
|
||||
# Send SIGTERM (recommended)
|
||||
kill -TERM <process_id>
|
||||
|
||||
# Or use Ctrl+C for SIGINT
|
||||
```
|
||||
|
||||
4. **Verify job state** is saved and can be resumed on restart
|
||||
|
||||
### Container Testing
|
||||
|
||||
Test with Docker:
|
||||
|
||||
```bash
|
||||
# Build and run container
|
||||
docker build -t gitea-mirror .
|
||||
docker run -d --name test-shutdown gitea-mirror
|
||||
|
||||
# Start a job, then stop container
|
||||
docker stop test-shutdown
|
||||
|
||||
# Restart and verify recovery
|
||||
docker start test-shutdown
|
||||
docker logs test-shutdown
|
||||
```
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Shutdown Flow
|
||||
|
||||
1. **Signal Reception**: Signal handlers detect termination request
|
||||
2. **Shutdown Initiation**: Shutdown manager begins graceful termination
|
||||
3. **Job State Saving**: All active jobs save current progress to database
|
||||
4. **Service Cleanup**: Registered callbacks stop background services
|
||||
5. **Connection Cleanup**: Database connections and resources are released
|
||||
6. **Process Termination**: Application exits with appropriate code
|
||||
|
||||
### Job State Management
|
||||
|
||||
During shutdown, active jobs are updated with:
|
||||
- `inProgress: false` - Mark as not currently running
|
||||
- `lastCheckpoint: <timestamp>` - Record shutdown time
|
||||
- `message: "Job interrupted by application shutdown - will resume on restart"`
|
||||
- Status remains as `"imported"` (not `"failed"`) to enable recovery
|
||||
|
||||
### Recovery Integration
|
||||
|
||||
The existing recovery system automatically detects and resumes interrupted jobs:
|
||||
- Jobs with `inProgress: false` and incomplete status are candidates for recovery
|
||||
- Recovery runs during application startup (before serving requests)
|
||||
- Jobs resume from their last checkpoint with remaining items
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
```bash
|
||||
# Optional: Adjust shutdown timeout (default: 30000ms)
|
||||
SHUTDOWN_TIMEOUT=30000
|
||||
|
||||
# Optional: Adjust job save timeout (default: 10000ms)
|
||||
JOB_SAVE_TIMEOUT=10000
|
||||
```
|
||||
|
||||
### Docker Configuration
|
||||
|
||||
The Docker entrypoint script includes proper signal handling:
|
||||
|
||||
```dockerfile
|
||||
# Signals are forwarded to the application process
|
||||
# SIGTERM is handled gracefully with 30-second timeout
|
||||
# Container stops cleanly without force-killing processes
|
||||
```
|
||||
|
||||
### Kubernetes Configuration
|
||||
|
||||
For Kubernetes deployments, configure appropriate termination grace period:
|
||||
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: Pod
|
||||
spec:
|
||||
terminationGracePeriodSeconds: 45 # Allow time for graceful shutdown
|
||||
containers:
|
||||
- name: gitea-mirror
|
||||
# ... other configuration
|
||||
```
|
||||
|
||||
## Monitoring and Debugging
|
||||
|
||||
### Logs
|
||||
|
||||
The application provides detailed logging during shutdown:
|
||||
|
||||
```
|
||||
🛑 Graceful shutdown initiated by signal: SIGTERM
|
||||
📊 Shutdown status: 2 active jobs, 1 callbacks
|
||||
📝 Step 1: Saving active job states...
|
||||
Saving state for job abc-123...
|
||||
✅ Saved state for job abc-123
|
||||
🔧 Step 2: Executing shutdown callbacks...
|
||||
✅ Shutdown callback 1 completed
|
||||
💾 Step 3: Closing database connections...
|
||||
✅ Graceful shutdown completed successfully
|
||||
```
|
||||
|
||||
### Status Endpoints
|
||||
|
||||
Check shutdown manager status via API:
|
||||
|
||||
```bash
|
||||
# Get current status (if application is running)
|
||||
curl http://localhost:4321/api/health
|
||||
```
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
**Problem**: Jobs not resuming after restart
|
||||
- **Check**: Startup recovery logs for errors
|
||||
- **Verify**: Database contains interrupted jobs with correct status
|
||||
- **Test**: Run `bun run startup-recovery` manually
|
||||
|
||||
**Problem**: Shutdown timeout reached
|
||||
- **Check**: Job complexity and database performance
|
||||
- **Adjust**: Increase `SHUTDOWN_TIMEOUT` environment variable
|
||||
- **Monitor**: Database connection and disk I/O during shutdown
|
||||
|
||||
**Problem**: Container force-killed
|
||||
- **Check**: Container orchestrator termination grace period
|
||||
- **Adjust**: Increase grace period to allow shutdown completion
|
||||
- **Monitor**: Application shutdown logs for timing issues
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Development
|
||||
- Always test graceful shutdown during development
|
||||
- Use the provided test scripts to verify functionality
|
||||
- Monitor logs for shutdown timing and job state persistence
|
||||
|
||||
### Production
|
||||
- Set appropriate container termination grace periods
|
||||
- Monitor shutdown logs for performance issues
|
||||
- Use health checks to verify application readiness after restart
|
||||
- Consider job complexity when planning maintenance windows
|
||||
|
||||
### Monitoring
|
||||
- Track job recovery success rates
|
||||
- Monitor shutdown duration metrics
|
||||
- Alert on forced terminations or recovery failures
|
||||
- Log analysis for shutdown pattern optimization
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
Planned improvements for future versions:
|
||||
|
||||
1. **Configurable Timeouts**: Environment variable configuration for all timeouts
|
||||
2. **Shutdown Metrics**: Prometheus metrics for shutdown performance
|
||||
3. **Progressive Shutdown**: Graceful degradation of service capabilities
|
||||
4. **Job Prioritization**: Priority-based job saving during shutdown
|
||||
5. **Health Check Integration**: Readiness probes during shutdown process
|
||||
236
docs/SHUTDOWN_PROCESS.md
Normal file
236
docs/SHUTDOWN_PROCESS.md
Normal file
@@ -0,0 +1,236 @@
|
||||
# Graceful Shutdown Process
|
||||
|
||||
This document details how the gitea-mirror application handles graceful shutdown during active mirroring operations, with specific focus on job interruption and recovery.
|
||||
|
||||
## Overview
|
||||
|
||||
The graceful shutdown system is designed for **fast, clean termination** without waiting for long-running jobs to complete. It prioritizes **quick shutdown times** (under 30 seconds) while **preserving all progress** for seamless recovery.
|
||||
|
||||
## Key Principle
|
||||
|
||||
**The application does NOT wait for jobs to finish before shutting down.** Instead, it saves the current state and resumes after restart.
|
||||
|
||||
## Shutdown Scenario Example
|
||||
|
||||
### Initial State
|
||||
- **Job**: Mirror 500 repositories
|
||||
- **Progress**: 200 repositories completed
|
||||
- **Remaining**: 300 repositories pending
|
||||
- **Action**: User initiates shutdown (SIGTERM, Ctrl+C, Docker stop)
|
||||
|
||||
### Shutdown Process (Under 30 seconds)
|
||||
|
||||
#### Step 1: Signal Detection (Immediate)
|
||||
```
|
||||
📡 Received SIGTERM signal
|
||||
🛑 Graceful shutdown initiated by signal: SIGTERM
|
||||
📊 Shutdown status: 1 active jobs, 2 callbacks
|
||||
```
|
||||
|
||||
#### Step 2: Job State Saving (1-10 seconds)
|
||||
```
|
||||
📝 Step 1: Saving active job states...
|
||||
Saving state for job abc-123...
|
||||
✅ Saved state for job abc-123
|
||||
```
|
||||
|
||||
**What gets saved:**
|
||||
- `inProgress: false` - Mark job as not currently running
|
||||
- `completedItems: 200` - Number of repos successfully mirrored
|
||||
- `totalItems: 500` - Total repos in the job
|
||||
- `completedItemIds: [repo1, repo2, ..., repo200]` - List of completed repos
|
||||
- `itemIds: [repo1, repo2, ..., repo500]` - Full list of repos
|
||||
- `lastCheckpoint: 2025-05-24T17:30:00Z` - Exact shutdown time
|
||||
- `message: "Job interrupted by application shutdown - will resume on restart"`
|
||||
- `status: "imported"` - Keeps status as resumable (not "failed")
|
||||
|
||||
#### Step 3: Service Cleanup (1-5 seconds)
|
||||
```
|
||||
🔧 Step 2: Executing shutdown callbacks...
|
||||
🛑 Shutting down cleanup service...
|
||||
✅ Cleanup service stopped
|
||||
✅ Shutdown callback 1 completed
|
||||
```
|
||||
|
||||
#### Step 4: Clean Exit (Immediate)
|
||||
```
|
||||
💾 Step 3: Closing database connections...
|
||||
✅ Graceful shutdown completed successfully
|
||||
```
|
||||
|
||||
**Total shutdown time: ~15 seconds** (well under the 30-second limit)
|
||||
|
||||
## What Happens to the Remaining 300 Repos?
|
||||
|
||||
### During Shutdown
|
||||
- **NOT processed** - The remaining 300 repos are not mirrored
|
||||
- **NOT lost** - Their IDs are preserved in the job state
|
||||
- **NOT marked as failed** - Job status remains "imported" for recovery
|
||||
|
||||
### After Restart
|
||||
The recovery system automatically:
|
||||
|
||||
1. **Detects interrupted job** during startup
|
||||
2. **Calculates remaining work**: 500 - 200 = 300 repos
|
||||
3. **Extracts remaining repo IDs**: repos 201-500 from the original list
|
||||
4. **Resumes processing** from exactly where it left off
|
||||
5. **Continues until completion** of all 500 repos
|
||||
|
||||
## Timeout Configuration
|
||||
|
||||
### Shutdown Timeouts
|
||||
```typescript
|
||||
const SHUTDOWN_TIMEOUT = 30000; // 30 seconds max shutdown time
|
||||
const JOB_SAVE_TIMEOUT = 10000; // 10 seconds to save job state
|
||||
```
|
||||
|
||||
### Timeout Behavior
|
||||
- **Normal case**: Shutdown completes in 10-20 seconds
|
||||
- **Slow database**: Up to 30 seconds allowed
|
||||
- **Timeout exceeded**: Force exit with code 1
|
||||
- **Container kill**: Orchestrator should allow 45+ seconds grace period
|
||||
|
||||
## Job State Persistence
|
||||
|
||||
### Database Schema
|
||||
The `mirror_jobs` table stores complete job state:
|
||||
|
||||
```sql
|
||||
-- Job identification
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
job_type TEXT NOT NULL DEFAULT 'mirror',
|
||||
|
||||
-- Progress tracking
|
||||
total_items INTEGER,
|
||||
completed_items INTEGER DEFAULT 0,
|
||||
item_ids TEXT, -- JSON array of all repo IDs
|
||||
completed_item_ids TEXT DEFAULT '[]', -- JSON array of completed repo IDs
|
||||
|
||||
-- State management
|
||||
in_progress INTEGER NOT NULL DEFAULT 0, -- Boolean: currently running
|
||||
started_at TIMESTAMP,
|
||||
completed_at TIMESTAMP,
|
||||
last_checkpoint TIMESTAMP, -- Last progress save
|
||||
|
||||
-- Status and messaging
|
||||
status TEXT NOT NULL DEFAULT 'imported',
|
||||
message TEXT NOT NULL
|
||||
```
|
||||
|
||||
### Recovery Query
|
||||
The recovery system finds interrupted jobs:
|
||||
|
||||
```sql
|
||||
SELECT * FROM mirror_jobs
|
||||
WHERE in_progress = 0
|
||||
AND status = 'imported'
|
||||
AND completed_at IS NULL
|
||||
AND total_items > completed_items;
|
||||
```
|
||||
|
||||
## Shutdown-Aware Processing
|
||||
|
||||
### Concurrency Check
|
||||
During job execution, each repo processing checks for shutdown:
|
||||
|
||||
```typescript
|
||||
// Before processing each repository
|
||||
if (isShuttingDown()) {
|
||||
throw new Error('Processing interrupted by application shutdown');
|
||||
}
|
||||
```
|
||||
|
||||
### Checkpoint Intervals
|
||||
Jobs save progress periodically (every 10 repos by default):
|
||||
|
||||
```typescript
|
||||
checkpointInterval: 10, // Save progress every 10 repositories
|
||||
```
|
||||
|
||||
This ensures minimal work loss even if shutdown occurs between checkpoints.
|
||||
|
||||
## Container Integration
|
||||
|
||||
### Docker Entrypoint
|
||||
The Docker entrypoint properly forwards signals:
|
||||
|
||||
```bash
|
||||
# Set up signal handlers
|
||||
trap 'shutdown_handler' TERM INT HUP
|
||||
|
||||
# Start application in background
|
||||
bun ./dist/server/entry.mjs &
|
||||
APP_PID=$!
|
||||
|
||||
# Wait for application to finish
|
||||
wait "$APP_PID"
|
||||
```
|
||||
|
||||
### Kubernetes Configuration
|
||||
Recommended pod configuration:
|
||||
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: Pod
|
||||
spec:
|
||||
terminationGracePeriodSeconds: 45 # Allow time for graceful shutdown
|
||||
containers:
|
||||
- name: gitea-mirror
|
||||
# ... other configuration
|
||||
```
|
||||
|
||||
## Monitoring and Logging
|
||||
|
||||
### Shutdown Logs
|
||||
```
|
||||
🛑 Graceful shutdown initiated by signal: SIGTERM
|
||||
📊 Shutdown status: 1 active jobs, 2 callbacks
|
||||
📝 Step 1: Saving active job states...
|
||||
Saving state for 1 active jobs...
|
||||
✅ Completed saving all active jobs
|
||||
🔧 Step 2: Executing shutdown callbacks...
|
||||
✅ Completed all shutdown callbacks
|
||||
💾 Step 3: Closing database connections...
|
||||
✅ Graceful shutdown completed successfully
|
||||
```
|
||||
|
||||
### Recovery Logs
|
||||
```
|
||||
⚠️ Jobs found that need recovery. Starting recovery process...
|
||||
Resuming job abc-123 with 300 remaining items...
|
||||
✅ Recovery completed successfully
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### For Operations
|
||||
1. **Monitor shutdown times** - Should complete under 30 seconds
|
||||
2. **Check recovery logs** - Verify jobs resume correctly after restart
|
||||
3. **Set appropriate grace periods** - Allow 45+ seconds in orchestrators
|
||||
4. **Plan maintenance windows** - Jobs will resume but may take time to complete
|
||||
|
||||
### For Development
|
||||
1. **Test shutdown scenarios** - Use `bun run test-shutdown`
|
||||
2. **Monitor job progress** - Check checkpoint frequency and timing
|
||||
3. **Verify recovery** - Ensure interrupted jobs resume correctly
|
||||
4. **Handle edge cases** - Test shutdown during different job phases
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Shutdown Takes Too Long
|
||||
- **Check**: Database performance during job state saving
|
||||
- **Solution**: Increase `SHUTDOWN_TIMEOUT` environment variable
|
||||
- **Monitor**: Job complexity and checkpoint frequency
|
||||
|
||||
### Jobs Don't Resume
|
||||
- **Check**: Recovery logs for errors during startup
|
||||
- **Verify**: Database contains interrupted jobs with correct status
|
||||
- **Test**: Run `bun run startup-recovery` manually
|
||||
|
||||
### Container Force-Killed
|
||||
- **Check**: Container orchestrator termination grace period
|
||||
- **Increase**: Grace period to 45+ seconds
|
||||
- **Monitor**: Application shutdown completion time
|
||||
|
||||
This design ensures **production-ready graceful shutdown** with **zero data loss** and **fast recovery times** suitable for modern containerized deployments.
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "gitea-mirror",
|
||||
"type": "module",
|
||||
"version": "2.6.0",
|
||||
"version": "2.9.0",
|
||||
"engines": {
|
||||
"bun": ">=1.2.9"
|
||||
},
|
||||
@@ -17,13 +17,13 @@
|
||||
"check-db": "bun scripts/manage-db.ts check",
|
||||
"fix-db": "bun scripts/manage-db.ts fix",
|
||||
"reset-users": "bun scripts/manage-db.ts reset-users",
|
||||
"cleanup-events": "bun scripts/cleanup-events.ts",
|
||||
"cleanup-jobs": "bun scripts/cleanup-mirror-jobs.ts",
|
||||
"cleanup-all": "bun scripts/cleanup-events.ts && bun scripts/cleanup-mirror-jobs.ts",
|
||||
|
||||
"startup-recovery": "bun scripts/startup-recovery.ts",
|
||||
"startup-recovery-force": "bun scripts/startup-recovery.ts --force",
|
||||
"test-recovery": "bun scripts/test-recovery.ts",
|
||||
"test-recovery-cleanup": "bun scripts/test-recovery.ts --cleanup",
|
||||
"test-shutdown": "bun scripts/test-graceful-shutdown.ts",
|
||||
"test-shutdown-cleanup": "bun scripts/test-graceful-shutdown.ts --cleanup",
|
||||
"preview": "bunx --bun astro preview",
|
||||
"start": "bun dist/server/entry.mjs",
|
||||
"start:fresh": "bun run cleanup-db && bun run manage-db init && bun run update-db && bun dist/server/entry.mjs",
|
||||
|
||||
@@ -64,19 +64,7 @@ The following scripts help manage events in the SQLite database:
|
||||
|
||||
|
||||
|
||||
### Event Cleanup (cleanup-events.ts)
|
||||
|
||||
Removes old events and duplicate events from the database to prevent it from growing too large.
|
||||
|
||||
```bash
|
||||
# Remove events older than 7 days (default) and duplicates
|
||||
bun scripts/cleanup-events.ts
|
||||
|
||||
# Remove events older than X days and duplicates
|
||||
bun scripts/cleanup-events.ts 14
|
||||
```
|
||||
|
||||
This script can be scheduled to run periodically (e.g., daily) using cron or another scheduler. When using Docker, this is automatically scheduled to run daily.
|
||||
|
||||
### Remove Duplicate Events (remove-duplicate-events.ts)
|
||||
|
||||
@@ -90,19 +78,7 @@ bun scripts/remove-duplicate-events.ts
|
||||
bun scripts/remove-duplicate-events.ts <userId>
|
||||
```
|
||||
|
||||
### Mirror Jobs Cleanup (cleanup-mirror-jobs.ts)
|
||||
|
||||
Removes old mirror jobs from the database to prevent it from growing too large.
|
||||
|
||||
```bash
|
||||
# Remove mirror jobs older than 7 days (default)
|
||||
bun scripts/cleanup-mirror-jobs.ts
|
||||
|
||||
# Remove mirror jobs older than X days
|
||||
bun scripts/cleanup-mirror-jobs.ts 14
|
||||
```
|
||||
|
||||
This script can be scheduled to run periodically (e.g., daily) using cron or another scheduler. When using Docker, this is automatically scheduled to run daily.
|
||||
|
||||
### Fix Interrupted Jobs (fix-interrupted-jobs.ts)
|
||||
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Script to clean up old events from the database
|
||||
* This script should be run periodically (e.g., daily) to prevent the events table from growing too large
|
||||
*
|
||||
* Usage:
|
||||
* bun scripts/cleanup-events.ts [days]
|
||||
*
|
||||
* Where [days] is the number of days to keep events (default: 7)
|
||||
*/
|
||||
|
||||
import { cleanupOldEvents, removeDuplicateEvents } from "../src/lib/events";
|
||||
|
||||
// Parse command line arguments
|
||||
const args = process.argv.slice(2);
|
||||
const daysToKeep = args.length > 0 ? parseInt(args[0], 10) : 7;
|
||||
|
||||
if (isNaN(daysToKeep) || daysToKeep < 1) {
|
||||
console.error("Error: Days to keep must be a positive number");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
async function runCleanup() {
|
||||
try {
|
||||
console.log(`Starting event cleanup (retention: ${daysToKeep} days)...`);
|
||||
|
||||
// First, remove duplicate events
|
||||
console.log("Step 1: Removing duplicate events...");
|
||||
const duplicateResult = await removeDuplicateEvents();
|
||||
console.log(`- Duplicate events removed: ${duplicateResult.duplicatesRemoved}`);
|
||||
|
||||
// Then, clean up old events
|
||||
console.log("Step 2: Cleaning up old events...");
|
||||
const result = await cleanupOldEvents(daysToKeep);
|
||||
|
||||
console.log(`Cleanup summary:`);
|
||||
console.log(`- Duplicate events removed: ${duplicateResult.duplicatesRemoved}`);
|
||||
console.log(`- Read events deleted: ${result.readEventsDeleted}`);
|
||||
console.log(`- Unread events deleted: ${result.unreadEventsDeleted}`);
|
||||
console.log(`- Total events deleted: ${result.readEventsDeleted + result.unreadEventsDeleted + duplicateResult.duplicatesRemoved}`);
|
||||
|
||||
console.log("Event cleanup completed successfully");
|
||||
} catch (error) {
|
||||
console.error("Error running event cleanup:", error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Run the cleanup
|
||||
runCleanup();
|
||||
@@ -1,102 +0,0 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Script to clean up old mirror jobs from the database
|
||||
* This script should be run periodically (e.g., daily) to prevent the mirror_jobs table from growing too large
|
||||
*
|
||||
* Usage:
|
||||
* bun scripts/cleanup-mirror-jobs.ts [days]
|
||||
*
|
||||
* Where [days] is the number of days to keep mirror jobs (default: 7)
|
||||
*/
|
||||
|
||||
import { db, mirrorJobs } from "../src/lib/db";
|
||||
import { lt, and, eq } from "drizzle-orm";
|
||||
|
||||
// Parse command line arguments
|
||||
const args = process.argv.slice(2);
|
||||
const daysToKeep = args.length > 0 ? parseInt(args[0], 10) : 7;
|
||||
|
||||
if (isNaN(daysToKeep) || daysToKeep < 1) {
|
||||
console.error("Error: Days to keep must be a positive number");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up old mirror jobs to prevent the database from growing too large
|
||||
* Should be called periodically (e.g., daily via a cron job)
|
||||
*
|
||||
* @param maxAgeInDays Number of days to keep mirror jobs (default: 7)
|
||||
* @returns Object containing the number of completed and in-progress jobs deleted
|
||||
*/
|
||||
async function cleanupOldMirrorJobs(
|
||||
maxAgeInDays: number = 7
|
||||
): Promise<{ completedJobsDeleted: number; inProgressJobsDeleted: number }> {
|
||||
try {
|
||||
console.log(`Cleaning up mirror jobs older than ${maxAgeInDays} days...`);
|
||||
|
||||
// Calculate the cutoff date for completed jobs
|
||||
const cutoffDate = new Date();
|
||||
cutoffDate.setDate(cutoffDate.getDate() - maxAgeInDays);
|
||||
|
||||
// Delete completed jobs older than the cutoff date
|
||||
// Only delete jobs that are not in progress (inProgress = false)
|
||||
const completedResult = await db
|
||||
.delete(mirrorJobs)
|
||||
.where(
|
||||
and(
|
||||
eq(mirrorJobs.inProgress, false),
|
||||
lt(mirrorJobs.timestamp, cutoffDate)
|
||||
)
|
||||
);
|
||||
|
||||
const completedJobsDeleted = completedResult.changes || 0;
|
||||
console.log(`Deleted ${completedJobsDeleted} completed mirror jobs`);
|
||||
|
||||
// Calculate a much older cutoff date for in-progress jobs (3x the retention period)
|
||||
// This is to handle jobs that might have been abandoned or crashed
|
||||
const inProgressCutoffDate = new Date();
|
||||
inProgressCutoffDate.setDate(inProgressCutoffDate.getDate() - (maxAgeInDays * 3));
|
||||
|
||||
// Delete in-progress jobs that are significantly older
|
||||
// This helps clean up jobs that might have been abandoned due to crashes
|
||||
const inProgressResult = await db
|
||||
.delete(mirrorJobs)
|
||||
.where(
|
||||
and(
|
||||
eq(mirrorJobs.inProgress, true),
|
||||
lt(mirrorJobs.timestamp, inProgressCutoffDate)
|
||||
)
|
||||
);
|
||||
|
||||
const inProgressJobsDeleted = inProgressResult.changes || 0;
|
||||
console.log(`Deleted ${inProgressJobsDeleted} abandoned in-progress mirror jobs`);
|
||||
|
||||
return { completedJobsDeleted, inProgressJobsDeleted };
|
||||
} catch (error) {
|
||||
console.error("Error cleaning up old mirror jobs:", error);
|
||||
return { completedJobsDeleted: 0, inProgressJobsDeleted: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
// Run the cleanup
|
||||
async function runCleanup() {
|
||||
try {
|
||||
console.log(`Starting mirror jobs cleanup (retention: ${daysToKeep} days)...`);
|
||||
|
||||
// Call the cleanupOldMirrorJobs function
|
||||
const result = await cleanupOldMirrorJobs(daysToKeep);
|
||||
|
||||
console.log(`Cleanup summary:`);
|
||||
console.log(`- Completed jobs deleted: ${result.completedJobsDeleted}`);
|
||||
console.log(`- Abandoned in-progress jobs deleted: ${result.inProgressJobsDeleted}`);
|
||||
console.log(`- Total jobs deleted: ${result.completedJobsDeleted + result.inProgressJobsDeleted}`);
|
||||
|
||||
console.log("Mirror jobs cleanup completed successfully");
|
||||
} catch (error) {
|
||||
console.error("Error running mirror jobs cleanup:", error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Run the cleanup
|
||||
runCleanup();
|
||||
@@ -197,6 +197,33 @@ async function ensureTablesExist() {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Migration: Add cleanup_config column to existing configs table
|
||||
try {
|
||||
const db = new Database(dbPath);
|
||||
|
||||
// Check if cleanup_config column exists
|
||||
const tableInfo = db.query(`PRAGMA table_info(configs)`).all();
|
||||
const hasCleanupConfig = tableInfo.some((column: any) => column.name === 'cleanup_config');
|
||||
|
||||
if (!hasCleanupConfig) {
|
||||
console.log("Adding cleanup_config column to configs table...");
|
||||
|
||||
// Add the column with a default value
|
||||
const defaultCleanupConfig = JSON.stringify({
|
||||
enabled: false,
|
||||
retentionDays: 7,
|
||||
lastRun: null,
|
||||
nextRun: null,
|
||||
});
|
||||
|
||||
db.exec(`ALTER TABLE configs ADD COLUMN cleanup_config TEXT NOT NULL DEFAULT '${defaultCleanupConfig}'`);
|
||||
console.log("✅ cleanup_config column added successfully.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ Error during cleanup_config migration:", error);
|
||||
// Don't exit here as this is not critical for basic functionality
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -328,6 +355,7 @@ async function initializeDatabase() {
|
||||
include TEXT NOT NULL DEFAULT '["*"]',
|
||||
exclude TEXT NOT NULL DEFAULT '[]',
|
||||
schedule_config TEXT NOT NULL,
|
||||
cleanup_config TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
|
||||
updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
@@ -459,10 +487,16 @@ async function initializeDatabase() {
|
||||
lastRun: null,
|
||||
nextRun: null,
|
||||
});
|
||||
const cleanupConfig = JSON.stringify({
|
||||
enabled: false,
|
||||
retentionDays: 7,
|
||||
lastRun: null,
|
||||
nextRun: null,
|
||||
});
|
||||
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO configs (id, user_id, name, is_active, github_config, gitea_config, include, exclude, schedule_config, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO configs (id, user_id, name, is_active, github_config, gitea_config, include, exclude, schedule_config, cleanup_config, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
stmt.run(
|
||||
@@ -475,6 +509,7 @@ async function initializeDatabase() {
|
||||
include,
|
||||
exclude,
|
||||
scheduleConfig,
|
||||
cleanupConfig,
|
||||
Date.now(),
|
||||
Date.now()
|
||||
);
|
||||
|
||||
238
scripts/test-graceful-shutdown.ts
Normal file
238
scripts/test-graceful-shutdown.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Integration test for graceful shutdown functionality
|
||||
*
|
||||
* This script tests the complete graceful shutdown flow:
|
||||
* 1. Starts a mock job
|
||||
* 2. Initiates shutdown
|
||||
* 3. Verifies job state is saved correctly
|
||||
* 4. Tests recovery after restart
|
||||
*
|
||||
* Usage:
|
||||
* bun scripts/test-graceful-shutdown.ts [--cleanup]
|
||||
*/
|
||||
|
||||
import { db, mirrorJobs } from "../src/lib/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import {
|
||||
initializeShutdownManager,
|
||||
registerActiveJob,
|
||||
unregisterActiveJob,
|
||||
gracefulShutdown,
|
||||
getShutdownStatus,
|
||||
registerShutdownCallback
|
||||
} from "../src/lib/shutdown-manager";
|
||||
import { setupSignalHandlers, removeSignalHandlers } from "../src/lib/signal-handlers";
|
||||
import { createMirrorJob } from "../src/lib/helpers";
|
||||
|
||||
// Test configuration
|
||||
const TEST_USER_ID = "test-user-shutdown";
|
||||
const TEST_JOB_PREFIX = "test-shutdown-job";
|
||||
|
||||
// Parse command line arguments
|
||||
const args = process.argv.slice(2);
|
||||
const shouldCleanup = args.includes('--cleanup');
|
||||
|
||||
/**
|
||||
* Create a test job for shutdown testing
|
||||
*/
|
||||
async function createTestJob(): Promise<string> {
|
||||
console.log('📝 Creating test job...');
|
||||
|
||||
const jobId = await createMirrorJob({
|
||||
userId: TEST_USER_ID,
|
||||
message: 'Test job for graceful shutdown testing',
|
||||
details: 'This job simulates a long-running mirroring operation',
|
||||
status: "mirroring",
|
||||
jobType: "mirror",
|
||||
totalItems: 10,
|
||||
itemIds: ['item-1', 'item-2', 'item-3', 'item-4', 'item-5'],
|
||||
completedItemIds: ['item-1', 'item-2'], // Simulate partial completion
|
||||
inProgress: true,
|
||||
});
|
||||
|
||||
console.log(`✅ Created test job: ${jobId}`);
|
||||
return jobId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify that job state was saved correctly during shutdown
|
||||
*/
|
||||
async function verifyJobState(jobId: string): Promise<boolean> {
|
||||
console.log(`🔍 Verifying job state for ${jobId}...`);
|
||||
|
||||
const jobs = await db
|
||||
.select()
|
||||
.from(mirrorJobs)
|
||||
.where(eq(mirrorJobs.id, jobId));
|
||||
|
||||
if (jobs.length === 0) {
|
||||
console.error(`❌ Job ${jobId} not found in database`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const job = jobs[0];
|
||||
|
||||
// Check that the job was marked as interrupted
|
||||
if (job.inProgress) {
|
||||
console.error(`❌ Job ${jobId} is still marked as in progress`);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!job.message?.includes('interrupted by application shutdown')) {
|
||||
console.error(`❌ Job ${jobId} does not have shutdown message. Message: ${job.message}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!job.lastCheckpoint) {
|
||||
console.error(`❌ Job ${jobId} does not have a checkpoint timestamp`);
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log(`✅ Job ${jobId} state verified correctly`);
|
||||
console.log(` - In Progress: ${job.inProgress}`);
|
||||
console.log(` - Message: ${job.message}`);
|
||||
console.log(` - Last Checkpoint: ${job.lastCheckpoint}`);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test the graceful shutdown process
|
||||
*/
|
||||
async function testGracefulShutdown(): Promise<void> {
|
||||
console.log('\n🧪 Testing Graceful Shutdown Process');
|
||||
console.log('=====================================\n');
|
||||
|
||||
try {
|
||||
// Step 1: Initialize shutdown manager
|
||||
console.log('Step 1: Initializing shutdown manager...');
|
||||
initializeShutdownManager();
|
||||
setupSignalHandlers();
|
||||
|
||||
// Step 2: Create and register a test job
|
||||
console.log('\nStep 2: Creating and registering test job...');
|
||||
const jobId = await createTestJob();
|
||||
registerActiveJob(jobId);
|
||||
|
||||
// Step 3: Register a test shutdown callback
|
||||
console.log('\nStep 3: Registering shutdown callback...');
|
||||
let callbackExecuted = false;
|
||||
registerShutdownCallback(async () => {
|
||||
console.log('🔧 Test shutdown callback executed');
|
||||
callbackExecuted = true;
|
||||
});
|
||||
|
||||
// Step 4: Check initial status
|
||||
console.log('\nStep 4: Checking initial status...');
|
||||
const initialStatus = getShutdownStatus();
|
||||
console.log(` - Active jobs: ${initialStatus.activeJobs.length}`);
|
||||
console.log(` - Registered callbacks: ${initialStatus.registeredCallbacks}`);
|
||||
console.log(` - Shutdown in progress: ${initialStatus.inProgress}`);
|
||||
|
||||
// Step 5: Simulate graceful shutdown
|
||||
console.log('\nStep 5: Simulating graceful shutdown...');
|
||||
|
||||
// Override process.exit to prevent actual exit during test
|
||||
const originalExit = process.exit;
|
||||
let exitCode: number | undefined;
|
||||
process.exit = ((code?: number) => {
|
||||
exitCode = code;
|
||||
console.log(`🚪 Process.exit called with code: ${code}`);
|
||||
// Don't actually exit during test
|
||||
}) as any;
|
||||
|
||||
try {
|
||||
// This should save job state and execute callbacks
|
||||
await gracefulShutdown('TEST_SIGNAL');
|
||||
} catch (error) {
|
||||
// Expected since we're not actually exiting
|
||||
console.log(`⚠️ Graceful shutdown completed (exit intercepted)`);
|
||||
}
|
||||
|
||||
// Restore original process.exit
|
||||
process.exit = originalExit;
|
||||
|
||||
// Step 6: Verify job state was saved
|
||||
console.log('\nStep 6: Verifying job state was saved...');
|
||||
const jobStateValid = await verifyJobState(jobId);
|
||||
|
||||
// Step 7: Verify callback was executed
|
||||
console.log('\nStep 7: Verifying callback execution...');
|
||||
if (callbackExecuted) {
|
||||
console.log('✅ Shutdown callback was executed');
|
||||
} else {
|
||||
console.error('❌ Shutdown callback was not executed');
|
||||
}
|
||||
|
||||
// Step 8: Test results
|
||||
console.log('\n📊 Test Results:');
|
||||
console.log(` - Job state saved correctly: ${jobStateValid ? '✅' : '❌'}`);
|
||||
console.log(` - Shutdown callback executed: ${callbackExecuted ? '✅' : '❌'}`);
|
||||
console.log(` - Exit code: ${exitCode}`);
|
||||
|
||||
if (jobStateValid && callbackExecuted) {
|
||||
console.log('\n🎉 All tests passed! Graceful shutdown is working correctly.');
|
||||
} else {
|
||||
console.error('\n❌ Some tests failed. Please check the implementation.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('\n💥 Test failed with error:', error);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
// Clean up signal handlers
|
||||
removeSignalHandlers();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up test data
|
||||
*/
|
||||
async function cleanupTestData(): Promise<void> {
|
||||
console.log('🧹 Cleaning up test data...');
|
||||
|
||||
const result = await db
|
||||
.delete(mirrorJobs)
|
||||
.where(eq(mirrorJobs.userId, TEST_USER_ID));
|
||||
|
||||
console.log('✅ Test data cleaned up');
|
||||
}
|
||||
|
||||
/**
|
||||
* Main test runner
|
||||
*/
|
||||
async function runTest(): Promise<void> {
|
||||
console.log('🧪 Graceful Shutdown Integration Test');
|
||||
console.log('====================================\n');
|
||||
|
||||
if (shouldCleanup) {
|
||||
await cleanupTestData();
|
||||
console.log('✅ Cleanup completed');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await testGracefulShutdown();
|
||||
} finally {
|
||||
// Always clean up test data
|
||||
await cleanupTestData();
|
||||
}
|
||||
}
|
||||
|
||||
// Handle process signals gracefully during testing
|
||||
process.on('SIGINT', async () => {
|
||||
console.log('\n⚠️ Test interrupted by SIGINT');
|
||||
await cleanupTestData();
|
||||
process.exit(130);
|
||||
});
|
||||
|
||||
process.on('SIGTERM', async () => {
|
||||
console.log('\n⚠️ Test interrupted by SIGTERM');
|
||||
await cleanupTestData();
|
||||
process.exit(143);
|
||||
});
|
||||
|
||||
// Run the test
|
||||
runTest();
|
||||
@@ -14,6 +14,7 @@ type MirrorJobWithKey = MirrorJob & { _rowKey: string };
|
||||
interface ActivityListProps {
|
||||
activities: MirrorJobWithKey[];
|
||||
isLoading: boolean;
|
||||
isLiveActive?: boolean;
|
||||
filter: FilterParams;
|
||||
setFilter: (filter: FilterParams) => void;
|
||||
}
|
||||
@@ -21,6 +22,7 @@ interface ActivityListProps {
|
||||
export default function ActivityList({
|
||||
activities,
|
||||
isLoading,
|
||||
isLiveActive = false,
|
||||
filter,
|
||||
setFilter,
|
||||
}: ActivityListProps) {
|
||||
@@ -120,18 +122,19 @@ export default function ActivityList({
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
ref={parentRef}
|
||||
className='relative max-h-[calc(100dvh-191px)] overflow-y-auto rounded-md border'
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: virtualizer.getTotalSize(),
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
}}
|
||||
<div className="flex flex-col border rounded-md">
|
||||
<Card
|
||||
ref={parentRef}
|
||||
className='relative max-h-[calc(100dvh-231px)] overflow-y-auto rounded-none border-0'
|
||||
>
|
||||
{virtualizer.getVirtualItems().map((vRow) => {
|
||||
<div
|
||||
style={{
|
||||
height: virtualizer.getTotalSize(),
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
{virtualizer.getVirtualItems().map((vRow) => {
|
||||
const activity = filteredActivities[vRow.index];
|
||||
const isExpanded = expandedItems.has(activity._rowKey);
|
||||
|
||||
@@ -213,5 +216,44 @@ export default function ActivityList({
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Status Bar */}
|
||||
<div className="h-[40px] flex items-center justify-between border-t bg-muted/30 px-3 relative">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`h-1.5 w-1.5 rounded-full ${isLiveActive ? 'bg-emerald-500' : 'bg-primary'}`} />
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{filteredActivities.length} {filteredActivities.length === 1 ? 'activity' : 'activities'} total
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Center - Live active indicator */}
|
||||
{isLiveActive && (
|
||||
<div className="flex items-center gap-1.5 absolute left-1/2 transform -translate-x-1/2">
|
||||
<div
|
||||
className="h-1 w-1 rounded-full bg-emerald-500"
|
||||
style={{
|
||||
animation: 'pulse 2s ease-in-out infinite'
|
||||
}}
|
||||
/>
|
||||
<span className="text-xs text-emerald-600 dark:text-emerald-400 font-medium">
|
||||
Live active
|
||||
</span>
|
||||
<div
|
||||
className="h-1 w-1 rounded-full bg-emerald-500"
|
||||
style={{
|
||||
animation: 'pulse 2s ease-in-out infinite',
|
||||
animationDelay: '1s'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(filter.searchTerm || filter.status || filter.type || filter.name) && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Filters applied
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -67,12 +67,12 @@ function deepClone<T>(obj: T): T {
|
||||
|
||||
export function ActivityLog() {
|
||||
const { user } = useAuth();
|
||||
const { registerRefreshCallback } = useLiveRefresh();
|
||||
const { registerRefreshCallback, isLiveEnabled } = useLiveRefresh();
|
||||
const { isFullyConfigured } = useConfigStatus();
|
||||
const { navigationKey } = useNavigation();
|
||||
|
||||
const [activities, setActivities] = useState<MirrorJobWithKey[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isInitialLoading, setIsInitialLoading] = useState(false);
|
||||
const [showCleanupDialog, setShowCleanupDialog] = useState(false);
|
||||
|
||||
// Ref to track if component is mounted to prevent state updates after unmount
|
||||
@@ -138,11 +138,14 @@ export function ActivityLog() {
|
||||
|
||||
/* ------------------------- initial fetch --------------------------- */
|
||||
|
||||
const fetchActivities = useCallback(async () => {
|
||||
const fetchActivities = useCallback(async (isLiveRefresh = false) => {
|
||||
if (!user?.id) return false;
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
// Set appropriate loading state based on refresh type
|
||||
if (!isLiveRefresh) {
|
||||
setIsInitialLoading(true);
|
||||
}
|
||||
|
||||
const res = await apiRequest<ActivityApiResponse>(
|
||||
`/activities?userId=${user.id}`,
|
||||
@@ -150,7 +153,10 @@ export function ActivityLog() {
|
||||
);
|
||||
|
||||
if (!res.success) {
|
||||
toast.error(res.message ?? 'Failed to fetch activities.');
|
||||
// Only show error toast for manual refreshes to avoid spam during live updates
|
||||
if (!isLiveRefresh) {
|
||||
toast.error(res.message ?? 'Failed to fetch activities.');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -176,22 +182,25 @@ export function ActivityLog() {
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (isMountedRef.current) {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : 'Failed to fetch activities.',
|
||||
);
|
||||
// Only show error toast for manual refreshes to avoid spam during live updates
|
||||
if (!isLiveRefresh) {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : 'Failed to fetch activities.',
|
||||
);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
if (isMountedRef.current) {
|
||||
setIsLoading(false);
|
||||
if (isMountedRef.current && !isLiveRefresh) {
|
||||
setIsInitialLoading(false);
|
||||
}
|
||||
}
|
||||
}, [user?.id]); // Only depend on user.id, not entire user object
|
||||
|
||||
useEffect(() => {
|
||||
// Reset loading state when component becomes active
|
||||
setIsLoading(true);
|
||||
fetchActivities();
|
||||
setIsInitialLoading(true);
|
||||
fetchActivities(false); // Manual refresh, not live
|
||||
}, [fetchActivities, navigationKey]); // Include navigationKey to trigger on navigation
|
||||
|
||||
// Register with global live refresh system
|
||||
@@ -203,7 +212,7 @@ export function ActivityLog() {
|
||||
}
|
||||
|
||||
const unregister = registerRefreshCallback(() => {
|
||||
fetchActivities();
|
||||
fetchActivities(true); // Live refresh
|
||||
});
|
||||
|
||||
return unregister;
|
||||
@@ -301,7 +310,7 @@ export function ActivityLog() {
|
||||
if (!user?.id) return;
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setIsInitialLoading(true);
|
||||
setShowCleanupDialog(false);
|
||||
|
||||
// Use fetch directly to avoid potential axios issues
|
||||
@@ -329,7 +338,7 @@ export function ActivityLog() {
|
||||
console.error('Error cleaning up activities:', error);
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to cleanup activities.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setIsInitialLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -430,7 +439,7 @@ export function ActivityLog() {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => fetchActivities()}
|
||||
onClick={() => fetchActivities(false)} // Manual refresh, show loading skeleton
|
||||
title="Refresh activity log"
|
||||
>
|
||||
<RefreshCw className='h-4 w-4' />
|
||||
@@ -451,7 +460,8 @@ export function ActivityLog() {
|
||||
{/* activity list */}
|
||||
<ActivityList
|
||||
activities={applyLightFilter(activities)}
|
||||
isLoading={isLoading || !connected}
|
||||
isLoading={isInitialLoading || !connected}
|
||||
isLiveActive={isLiveEnabled && isFullyConfigured}
|
||||
filter={filter}
|
||||
setFilter={setFilter}
|
||||
/>
|
||||
@@ -472,9 +482,9 @@ export function ActivityLog() {
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={confirmCleanup}
|
||||
disabled={isLoading}
|
||||
disabled={isInitialLoading}
|
||||
>
|
||||
{isLoading ? 'Deleting...' : 'Delete All Activities'}
|
||||
{isInitialLoading ? 'Deleting...' : 'Delete All Activities'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import { GitHubConfigForm } from './GitHubConfigForm';
|
||||
import { GiteaConfigForm } from './GiteaConfigForm';
|
||||
import { ScheduleConfigForm } from './ScheduleConfigForm';
|
||||
import { DatabaseCleanupConfigForm } from './DatabaseCleanupConfigForm';
|
||||
import type {
|
||||
ConfigApiResponse,
|
||||
GiteaConfig,
|
||||
@@ -9,6 +10,7 @@ import type {
|
||||
SaveConfigApiRequest,
|
||||
SaveConfigApiResponse,
|
||||
ScheduleConfig,
|
||||
DatabaseCleanupConfig,
|
||||
} from '@/types/config';
|
||||
import { Button } from '../ui/button';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
@@ -22,6 +24,7 @@ type ConfigState = {
|
||||
githubConfig: GitHubConfig;
|
||||
giteaConfig: GiteaConfig;
|
||||
scheduleConfig: ScheduleConfig;
|
||||
cleanupConfig: DatabaseCleanupConfig;
|
||||
};
|
||||
|
||||
export function ConfigTabs() {
|
||||
@@ -48,13 +51,19 @@ export function ConfigTabs() {
|
||||
enabled: false,
|
||||
interval: 3600,
|
||||
},
|
||||
cleanupConfig: {
|
||||
enabled: false,
|
||||
retentionDays: 604800, // 7 days in seconds
|
||||
},
|
||||
});
|
||||
const { user, refreshUser } = useAuth();
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSyncing, setIsSyncing] = useState<boolean>(false);
|
||||
const [isConfigSaved, setIsConfigSaved] = useState<boolean>(false);
|
||||
const [isAutoSaving, setIsAutoSaving] = useState<boolean>(false);
|
||||
const autoSaveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const [isAutoSavingSchedule, setIsAutoSavingSchedule] = useState<boolean>(false);
|
||||
const [isAutoSavingCleanup, setIsAutoSavingCleanup] = useState<boolean>(false);
|
||||
const autoSaveScheduleTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const autoSaveCleanupTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const isConfigFormValid = (): boolean => {
|
||||
const { githubConfig, giteaConfig } = config;
|
||||
@@ -82,7 +91,7 @@ export function ConfigTabs() {
|
||||
);
|
||||
result.success
|
||||
? toast.success(
|
||||
'GitHub data imported successfully! Head to the Dashboard to start mirroring repositories.',
|
||||
'GitHub data imported successfully! Head to the Repositories page to start mirroring.',
|
||||
)
|
||||
: toast.error(
|
||||
`Failed to import GitHub data: ${
|
||||
@@ -107,6 +116,7 @@ export function ConfigTabs() {
|
||||
githubConfig: config.githubConfig,
|
||||
giteaConfig: config.giteaConfig,
|
||||
scheduleConfig: config.scheduleConfig,
|
||||
cleanupConfig: config.cleanupConfig,
|
||||
};
|
||||
try {
|
||||
const response = await fetch('/api/config', {
|
||||
@@ -142,19 +152,20 @@ export function ConfigTabs() {
|
||||
if (!user?.id || !isConfigSaved) return; // Only auto-save if config was previously saved
|
||||
|
||||
// Clear any existing timeout
|
||||
if (autoSaveTimeoutRef.current) {
|
||||
clearTimeout(autoSaveTimeoutRef.current);
|
||||
if (autoSaveScheduleTimeoutRef.current) {
|
||||
clearTimeout(autoSaveScheduleTimeoutRef.current);
|
||||
}
|
||||
|
||||
// Debounce the auto-save to prevent excessive API calls
|
||||
autoSaveTimeoutRef.current = setTimeout(async () => {
|
||||
setIsAutoSaving(true);
|
||||
autoSaveScheduleTimeoutRef.current = setTimeout(async () => {
|
||||
setIsAutoSavingSchedule(true);
|
||||
|
||||
const reqPayload: SaveConfigApiRequest = {
|
||||
userId: user.id!,
|
||||
githubConfig: config.githubConfig,
|
||||
giteaConfig: config.giteaConfig,
|
||||
scheduleConfig: scheduleConfig,
|
||||
cleanupConfig: config.cleanupConfig,
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -170,6 +181,22 @@ export function ConfigTabs() {
|
||||
// Removed refreshUser() call to prevent page reload
|
||||
// Invalidate config cache so other components get fresh data
|
||||
invalidateConfigCache();
|
||||
|
||||
// Fetch updated config to get the recalculated nextRun time
|
||||
try {
|
||||
const updatedResponse = await apiRequest<ConfigApiResponse>(
|
||||
`/config?userId=${user.id}`,
|
||||
{ method: 'GET' },
|
||||
);
|
||||
if (updatedResponse && !updatedResponse.error) {
|
||||
setConfig(prev => ({
|
||||
...prev,
|
||||
scheduleConfig: updatedResponse.scheduleConfig || prev.scheduleConfig,
|
||||
}));
|
||||
}
|
||||
} catch (fetchError) {
|
||||
console.warn('Failed to fetch updated config after auto-save:', fetchError);
|
||||
}
|
||||
} else {
|
||||
toast.error(
|
||||
`Auto-save failed: ${result.message || 'Unknown error'}`,
|
||||
@@ -184,16 +211,87 @@ export function ConfigTabs() {
|
||||
{ duration: 3000 }
|
||||
);
|
||||
} finally {
|
||||
setIsAutoSaving(false);
|
||||
setIsAutoSavingSchedule(false);
|
||||
}
|
||||
}, 500); // 500ms debounce
|
||||
}, [user?.id, isConfigSaved, config.githubConfig, config.giteaConfig]);
|
||||
}, [user?.id, isConfigSaved, config.githubConfig, config.giteaConfig, config.cleanupConfig]);
|
||||
|
||||
// Cleanup timeout on unmount
|
||||
// Auto-save function specifically for cleanup config changes
|
||||
const autoSaveCleanupConfig = useCallback(async (cleanupConfig: DatabaseCleanupConfig) => {
|
||||
if (!user?.id || !isConfigSaved) return; // Only auto-save if config was previously saved
|
||||
|
||||
// Clear any existing timeout
|
||||
if (autoSaveCleanupTimeoutRef.current) {
|
||||
clearTimeout(autoSaveCleanupTimeoutRef.current);
|
||||
}
|
||||
|
||||
// Debounce the auto-save to prevent excessive API calls
|
||||
autoSaveCleanupTimeoutRef.current = setTimeout(async () => {
|
||||
setIsAutoSavingCleanup(true);
|
||||
|
||||
const reqPayload: SaveConfigApiRequest = {
|
||||
userId: user.id!,
|
||||
githubConfig: config.githubConfig,
|
||||
giteaConfig: config.giteaConfig,
|
||||
scheduleConfig: config.scheduleConfig,
|
||||
cleanupConfig: cleanupConfig,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/config', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(reqPayload),
|
||||
});
|
||||
const result: SaveConfigApiResponse = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
// Silent success - no toast for auto-save
|
||||
// Invalidate config cache so other components get fresh data
|
||||
invalidateConfigCache();
|
||||
|
||||
// Fetch updated config to get the recalculated nextRun time
|
||||
try {
|
||||
const updatedResponse = await apiRequest<ConfigApiResponse>(
|
||||
`/config?userId=${user.id}`,
|
||||
{ method: 'GET' },
|
||||
);
|
||||
if (updatedResponse && !updatedResponse.error) {
|
||||
setConfig(prev => ({
|
||||
...prev,
|
||||
cleanupConfig: updatedResponse.cleanupConfig || prev.cleanupConfig,
|
||||
}));
|
||||
}
|
||||
} catch (fetchError) {
|
||||
console.warn('Failed to fetch updated config after auto-save:', fetchError);
|
||||
}
|
||||
} else {
|
||||
toast.error(
|
||||
`Auto-save failed: ${result.message || 'Unknown error'}`,
|
||||
{ duration: 3000 }
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
`Auto-save error: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
{ duration: 3000 }
|
||||
);
|
||||
} finally {
|
||||
setIsAutoSavingCleanup(false);
|
||||
}
|
||||
}, 500); // 500ms debounce
|
||||
}, [user?.id, isConfigSaved, config.githubConfig, config.giteaConfig, config.scheduleConfig]);
|
||||
|
||||
// Cleanup timeouts on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (autoSaveTimeoutRef.current) {
|
||||
clearTimeout(autoSaveTimeoutRef.current);
|
||||
if (autoSaveScheduleTimeoutRef.current) {
|
||||
clearTimeout(autoSaveScheduleTimeoutRef.current);
|
||||
}
|
||||
if (autoSaveCleanupTimeoutRef.current) {
|
||||
clearTimeout(autoSaveCleanupTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
@@ -216,6 +314,8 @@ export function ConfigTabs() {
|
||||
response.giteaConfig || config.giteaConfig,
|
||||
scheduleConfig:
|
||||
response.scheduleConfig || config.scheduleConfig,
|
||||
cleanupConfig:
|
||||
response.cleanupConfig || config.cleanupConfig,
|
||||
});
|
||||
if (response.id) setIsConfigSaved(true);
|
||||
}
|
||||
@@ -273,11 +373,20 @@ export function ConfigTabs() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border rounded-lg p-4">
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-16 w-full" />
|
||||
<Skeleton className="h-8 w-32" />
|
||||
<div className="flex gap-x-4">
|
||||
<div className="w-1/2 border rounded-lg p-4">
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-16 w-full" />
|
||||
<Skeleton className="h-8 w-32" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-1/2 border rounded-lg p-4">
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-16 w-full" />
|
||||
<Skeleton className="h-8 w-32" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -368,20 +477,40 @@ export function ConfigTabs() {
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<ScheduleConfigForm
|
||||
config={config.scheduleConfig}
|
||||
setConfig={update =>
|
||||
setConfig(prev => ({
|
||||
...prev,
|
||||
scheduleConfig:
|
||||
typeof update === 'function'
|
||||
? update(prev.scheduleConfig)
|
||||
: update,
|
||||
}))
|
||||
}
|
||||
onAutoSave={autoSaveScheduleConfig}
|
||||
isAutoSaving={isAutoSaving}
|
||||
/>
|
||||
<div className="flex gap-x-4">
|
||||
<div className="w-1/2">
|
||||
<ScheduleConfigForm
|
||||
config={config.scheduleConfig}
|
||||
setConfig={update =>
|
||||
setConfig(prev => ({
|
||||
...prev,
|
||||
scheduleConfig:
|
||||
typeof update === 'function'
|
||||
? update(prev.scheduleConfig)
|
||||
: update,
|
||||
}))
|
||||
}
|
||||
onAutoSave={autoSaveScheduleConfig}
|
||||
isAutoSaving={isAutoSavingSchedule}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-1/2">
|
||||
<DatabaseCleanupConfigForm
|
||||
config={config.cleanupConfig}
|
||||
setConfig={update =>
|
||||
setConfig(prev => ({
|
||||
...prev,
|
||||
cleanupConfig:
|
||||
typeof update === 'function'
|
||||
? update(prev.cleanupConfig)
|
||||
: update,
|
||||
}))
|
||||
}
|
||||
onAutoSave={autoSaveCleanupConfig}
|
||||
isAutoSaving={isAutoSavingCleanup}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
201
src/components/config/DatabaseCleanupConfigForm.tsx
Normal file
201
src/components/config/DatabaseCleanupConfigForm.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Checkbox } from "../ui/checkbox";
|
||||
import type { DatabaseCleanupConfig } from "@/types/config";
|
||||
import { formatDate } from "@/lib/utils";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "../ui/select";
|
||||
import { RefreshCw, Database } from "lucide-react";
|
||||
|
||||
interface DatabaseCleanupConfigFormProps {
|
||||
config: DatabaseCleanupConfig;
|
||||
setConfig: React.Dispatch<React.SetStateAction<DatabaseCleanupConfig>>;
|
||||
onAutoSave?: (config: DatabaseCleanupConfig) => void;
|
||||
isAutoSaving?: boolean;
|
||||
}
|
||||
|
||||
|
||||
// Helper to calculate cleanup interval in hours (should match backend logic)
|
||||
function calculateCleanupInterval(retentionSeconds: number): number {
|
||||
const retentionDays = retentionSeconds / (24 * 60 * 60);
|
||||
if (retentionDays <= 1) {
|
||||
return 6;
|
||||
} else if (retentionDays <= 3) {
|
||||
return 12;
|
||||
} else if (retentionDays <= 7) {
|
||||
return 24;
|
||||
} else if (retentionDays <= 30) {
|
||||
return 48;
|
||||
} else {
|
||||
return 168;
|
||||
}
|
||||
}
|
||||
|
||||
export function DatabaseCleanupConfigForm({
|
||||
config,
|
||||
setConfig,
|
||||
onAutoSave,
|
||||
isAutoSaving = false,
|
||||
}: DatabaseCleanupConfigFormProps) {
|
||||
// Optimistically update nextRun when enabled or retention changes
|
||||
const handleChange = (
|
||||
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
|
||||
) => {
|
||||
const { name, value, type } = e.target;
|
||||
let newConfig = {
|
||||
...config,
|
||||
[name]: type === "checkbox" ? (e.target as HTMLInputElement).checked : value,
|
||||
};
|
||||
|
||||
// If enabling or changing retention, recalculate nextRun
|
||||
if (
|
||||
(name === "enabled" && (e.target as HTMLInputElement).checked) ||
|
||||
(name === "retentionDays" && config.enabled)
|
||||
) {
|
||||
const now = new Date();
|
||||
const retentionSeconds =
|
||||
name === "retentionDays"
|
||||
? Number(value)
|
||||
: Number(newConfig.retentionDays);
|
||||
const intervalHours = calculateCleanupInterval(retentionSeconds);
|
||||
const nextRun = new Date(now.getTime() + intervalHours * 60 * 60 * 1000);
|
||||
newConfig = {
|
||||
...newConfig,
|
||||
nextRun,
|
||||
};
|
||||
}
|
||||
// If disabling, clear nextRun
|
||||
if (name === "enabled" && !(e.target as HTMLInputElement).checked) {
|
||||
newConfig = {
|
||||
...newConfig,
|
||||
nextRun: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
setConfig(newConfig);
|
||||
if (onAutoSave) {
|
||||
onAutoSave(newConfig);
|
||||
}
|
||||
};
|
||||
|
||||
// Predefined retention periods (in seconds, like schedule intervals)
|
||||
const retentionOptions: { value: number; label: string }[] = [
|
||||
{ value: 86400, label: "1 day" }, // 24 * 60 * 60
|
||||
{ value: 259200, label: "3 days" }, // 3 * 24 * 60 * 60
|
||||
{ value: 604800, label: "7 days" }, // 7 * 24 * 60 * 60
|
||||
{ value: 1209600, label: "14 days" }, // 14 * 24 * 60 * 60
|
||||
{ value: 2592000, label: "30 days" }, // 30 * 24 * 60 * 60
|
||||
{ value: 5184000, label: "60 days" }, // 60 * 24 * 60 * 60
|
||||
{ value: 7776000, label: "90 days" }, // 90 * 24 * 60 * 60
|
||||
];
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="pt-6 relative">
|
||||
{isAutoSaving && (
|
||||
<div className="absolute top-4 right-4 flex items-center text-sm text-muted-foreground">
|
||||
<RefreshCw className="h-3 w-3 animate-spin mr-1" />
|
||||
<span className="text-xs">Auto-saving...</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-y-4">
|
||||
<div className="flex items-center">
|
||||
<Checkbox
|
||||
id="cleanup-enabled"
|
||||
name="enabled"
|
||||
checked={config.enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
handleChange({
|
||||
target: {
|
||||
name: "enabled",
|
||||
type: "checkbox",
|
||||
checked: Boolean(checked),
|
||||
value: "",
|
||||
},
|
||||
} as React.ChangeEvent<HTMLInputElement>)
|
||||
}
|
||||
/>
|
||||
<label
|
||||
htmlFor="cleanup-enabled"
|
||||
className="select-none ml-2 block text-sm font-medium"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Database className="h-4 w-4" />
|
||||
Enable Automatic Database Cleanup
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{config.enabled && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">
|
||||
Data Retention Period
|
||||
</label>
|
||||
|
||||
<Select
|
||||
name="retentionDays"
|
||||
value={String(config.retentionDays)}
|
||||
onValueChange={(value) =>
|
||||
handleChange({
|
||||
target: { name: "retentionDays", value },
|
||||
} as React.ChangeEvent<HTMLInputElement>)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full border border-input dark:bg-background dark:hover:bg-background">
|
||||
<SelectValue placeholder="Select retention period" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-background text-foreground border border-input shadow-sm">
|
||||
{retentionOptions.map((option) => (
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={option.value.toString()}
|
||||
className="cursor-pointer text-sm px-3 py-2 hover:bg-accent focus:bg-accent focus:text-accent-foreground"
|
||||
>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Activities and events older than this period will be automatically deleted.
|
||||
</p>
|
||||
<div className="mt-2 p-2 bg-muted/50 rounded-md">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<strong>Cleanup Frequency:</strong> The cleanup process runs automatically at optimal intervals:
|
||||
shorter retention periods trigger more frequent cleanups, longer periods trigger less frequent cleanups.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-x-4">
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm font-medium mb-1">Last Cleanup</label>
|
||||
<div className="text-sm">
|
||||
{config.lastRun ? formatDate(config.lastRun) : "Never"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{config.enabled && (
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm font-medium mb-1">Next Cleanup</label>
|
||||
<div className="text-sm">
|
||||
{config.nextRun
|
||||
? formatDate(config.nextRun)
|
||||
: config.enabled
|
||||
? "Calculating..."
|
||||
: "Never"}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -43,9 +43,6 @@ export function ScheduleConfigForm({
|
||||
|
||||
// Predefined intervals
|
||||
const intervals: { value: number; label: string }[] = [
|
||||
// { value: 120, label: "2 minutes" }, //for testing
|
||||
{ value: 900, label: "15 minutes" },
|
||||
{ value: 1800, label: "30 minutes" },
|
||||
{ value: 3600, label: "1 hour" },
|
||||
{ value: 7200, label: "2 hours" },
|
||||
{ value: 14400, label: "4 hours" },
|
||||
@@ -90,57 +87,69 @@ export function ScheduleConfigForm({
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="interval"
|
||||
className="block text-sm font-medium mb-1.5"
|
||||
>
|
||||
Mirroring Interval
|
||||
</label>
|
||||
{config.enabled && (
|
||||
<div>
|
||||
<label
|
||||
htmlFor="interval"
|
||||
className="block text-sm font-medium mb-1.5"
|
||||
>
|
||||
Mirroring Interval
|
||||
</label>
|
||||
|
||||
<Select
|
||||
name="interval"
|
||||
value={String(config.interval)}
|
||||
onValueChange={(value) =>
|
||||
handleChange({
|
||||
target: { name: "interval", value },
|
||||
} as React.ChangeEvent<HTMLInputElement>)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full border border-input dark:bg-background dark:hover:bg-background">
|
||||
<SelectValue placeholder="Select interval" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-background text-foreground border border-input shadow-sm">
|
||||
{intervals.map((interval) => (
|
||||
<SelectItem
|
||||
key={interval.value}
|
||||
value={interval.value.toString()}
|
||||
className="cursor-pointer text-sm px-3 py-2 hover:bg-accent focus:bg-accent focus:text-accent-foreground"
|
||||
>
|
||||
{interval.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
name="interval"
|
||||
value={String(config.interval)}
|
||||
onValueChange={(value) =>
|
||||
handleChange({
|
||||
target: { name: "interval", value },
|
||||
} as React.ChangeEvent<HTMLInputElement>)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full border border-input dark:bg-background dark:hover:bg-background">
|
||||
<SelectValue placeholder="Select interval" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-background text-foreground border border-input shadow-sm">
|
||||
{intervals.map((interval) => (
|
||||
<SelectItem
|
||||
key={interval.value}
|
||||
value={interval.value.toString()}
|
||||
className="cursor-pointer text-sm px-3 py-2 hover:bg-accent focus:bg-accent focus:text-accent-foreground"
|
||||
>
|
||||
{interval.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
How often the mirroring process should run.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
How often the mirroring process should run.
|
||||
</p>
|
||||
<div className="mt-2 p-2 bg-muted/50 rounded-md">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<strong>Sync Schedule:</strong> Repositories will be synchronized at the specified interval.
|
||||
Choose shorter intervals for frequently updated repositories, longer intervals for stable ones.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-x-4">
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm font-medium mb-1">Last Sync</label>
|
||||
<div className="text-sm">
|
||||
{config.lastRun ? formatDate(config.lastRun) : "Never"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{config.enabled && (
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm font-medium mb-1">Next Sync</label>
|
||||
<div className="text-sm">
|
||||
{config.nextRun ? formatDate(config.nextRun) : "Never"}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{config.lastRun && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Last Run</label>
|
||||
<div className="text-sm">{formatDate(config.lastRun)}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{config.nextRun && config.enabled && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Next Run</label>
|
||||
<div className="text-sm">{formatDate(config.nextRun)}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -24,8 +24,13 @@ export function Header({ currentPage, onNavigate }: HeaderProps) {
|
||||
// Determine button state and tooltip
|
||||
const isLiveActive = isLiveEnabled && isFullyConfigured;
|
||||
const getTooltip = () => {
|
||||
if (!isFullyConfigured && !configLoading) {
|
||||
return 'Configure GitHub and Gitea settings to enable live refresh';
|
||||
if (configLoading) {
|
||||
return 'Loading configuration...';
|
||||
}
|
||||
if (!isFullyConfigured) {
|
||||
return isLiveEnabled
|
||||
? 'Live refresh enabled but requires GitHub and Gitea configuration to function'
|
||||
: 'Enable live refresh (requires GitHub and Gitea configuration)';
|
||||
}
|
||||
return isLiveEnabled ? 'Disable live refresh' : 'Enable live refresh';
|
||||
};
|
||||
@@ -68,17 +73,18 @@ export function Header({ currentPage, onNavigate }: HeaderProps) {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className={`flex items-center gap-2 ${!isFullyConfigured && !configLoading ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
onClick={isFullyConfigured || configLoading ? toggleLive : undefined}
|
||||
className="flex items-center gap-2"
|
||||
onClick={toggleLive}
|
||||
title={getTooltip()}
|
||||
disabled={!isFullyConfigured && !configLoading}
|
||||
>
|
||||
<div className={`w-3 h-3 rounded-full ${
|
||||
configLoading
|
||||
? 'bg-yellow-400 animate-pulse'
|
||||
: isLiveActive
|
||||
? 'bg-emerald-400 animate-pulse'
|
||||
: 'bg-gray-500'
|
||||
: isLiveEnabled
|
||||
? 'bg-orange-400'
|
||||
: 'bg-gray-500'
|
||||
}`} />
|
||||
<span>LIVE</span>
|
||||
</Button>
|
||||
|
||||
@@ -24,7 +24,6 @@ import type { MirrorOrgRequest, MirrorOrgResponse } from "@/types/mirror";
|
||||
import { useSSE } from "@/hooks/useSEE";
|
||||
import { useFilterParams } from "@/hooks/useFilterParams";
|
||||
import { toast } from "sonner";
|
||||
import { useLiveRefresh } from "@/hooks/useLiveRefresh";
|
||||
import { useConfigStatus } from "@/hooks/useConfigStatus";
|
||||
import { useNavigation } from "@/components/layout/MainLayout";
|
||||
|
||||
@@ -33,7 +32,6 @@ export function Organization() {
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [isDialogOpen, setIsDialogOpen] = useState<boolean>(false);
|
||||
const { user } = useAuth();
|
||||
const { registerRefreshCallback } = useLiveRefresh();
|
||||
const { isGitHubConfigured } = useConfigStatus();
|
||||
const { navigationKey } = useNavigation();
|
||||
const { filter, setFilter } = useFilterParams({
|
||||
@@ -108,20 +106,6 @@ export function Organization() {
|
||||
fetchOrganizations();
|
||||
}, [fetchOrganizations, navigationKey]); // Include navigationKey to trigger on navigation
|
||||
|
||||
// Register with global live refresh system
|
||||
useEffect(() => {
|
||||
// Only register for live refresh if GitHub is configured
|
||||
if (!isGitHubConfigured) {
|
||||
return;
|
||||
}
|
||||
|
||||
const unregister = registerRefreshCallback(() => {
|
||||
fetchOrganizations();
|
||||
});
|
||||
|
||||
return unregister;
|
||||
}, [registerRefreshCallback, fetchOrganizations, isGitHubConfigured]);
|
||||
|
||||
const handleRefresh = async () => {
|
||||
const success = await fetchOrganizations();
|
||||
if (success) {
|
||||
|
||||
@@ -34,10 +34,10 @@ import { useNavigation } from "@/components/layout/MainLayout";
|
||||
|
||||
export default function Repository() {
|
||||
const [repositories, setRepositories] = useState<Repository[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isInitialLoading, setIsInitialLoading] = useState(true);
|
||||
const { user } = useAuth();
|
||||
const { registerRefreshCallback } = useLiveRefresh();
|
||||
const { isGitHubConfigured } = useConfigStatus();
|
||||
const { registerRefreshCallback, isLiveEnabled } = useLiveRefresh();
|
||||
const { isGitHubConfigured, isFullyConfigured } = useConfigStatus();
|
||||
const { navigationKey } = useNavigation();
|
||||
const { filter, setFilter } = useFilterParams({
|
||||
searchTerm: "",
|
||||
@@ -80,17 +80,20 @@ export default function Repository() {
|
||||
onMessage: handleNewMessage,
|
||||
});
|
||||
|
||||
const fetchRepositories = useCallback(async () => {
|
||||
const fetchRepositories = useCallback(async (isLiveRefresh = false) => {
|
||||
if (!user?.id) return;
|
||||
|
||||
// Don't fetch repositories if GitHub is not configured or still loading config
|
||||
if (!isGitHubConfigured) {
|
||||
setIsLoading(false);
|
||||
setIsInitialLoading(false);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
// Set appropriate loading state based on refresh type
|
||||
if (!isLiveRefresh) {
|
||||
setIsInitialLoading(true);
|
||||
}
|
||||
|
||||
const response = await apiRequest<RepositoryApiResponse>(
|
||||
`/github/repositories?userId=${user.id}`,
|
||||
@@ -103,23 +106,31 @@ export default function Repository() {
|
||||
setRepositories(response.repositories);
|
||||
return true;
|
||||
} else {
|
||||
toast.error(response.error || "Error fetching repositories");
|
||||
// Only show error toast for manual refreshes to avoid spam during live updates
|
||||
if (!isLiveRefresh) {
|
||||
toast.error(response.error || "Error fetching repositories");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Error fetching repositories"
|
||||
);
|
||||
// Only show error toast for manual refreshes to avoid spam during live updates
|
||||
if (!isLiveRefresh) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Error fetching repositories"
|
||||
);
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
if (!isLiveRefresh) {
|
||||
setIsInitialLoading(false);
|
||||
}
|
||||
}
|
||||
}, [user?.id, isGitHubConfigured]); // Only depend on user.id, not entire user object
|
||||
|
||||
useEffect(() => {
|
||||
// Reset loading state when component becomes active
|
||||
setIsLoading(true);
|
||||
fetchRepositories();
|
||||
setIsInitialLoading(true);
|
||||
fetchRepositories(false); // Manual refresh, not live
|
||||
}, [fetchRepositories, navigationKey]); // Include navigationKey to trigger on navigation
|
||||
|
||||
// Register with global live refresh system
|
||||
@@ -130,14 +141,14 @@ export default function Repository() {
|
||||
}
|
||||
|
||||
const unregister = registerRefreshCallback(() => {
|
||||
fetchRepositories();
|
||||
fetchRepositories(true); // Live refresh
|
||||
});
|
||||
|
||||
return unregister;
|
||||
}, [registerRefreshCallback, fetchRepositories, isGitHubConfigured]);
|
||||
|
||||
const handleRefresh = async () => {
|
||||
const success = await fetchRepositories();
|
||||
const success = await fetchRepositories(false); // Manual refresh, show loading skeleton
|
||||
if (success) {
|
||||
toast.success("Repositories refreshed successfully.");
|
||||
}
|
||||
@@ -363,7 +374,7 @@ export default function Repository() {
|
||||
toast.success(`Repository added successfully`);
|
||||
setRepositories((prevRepos) => [...prevRepos, response.repository]);
|
||||
|
||||
await fetchRepositories();
|
||||
await fetchRepositories(false); // Manual refresh after adding repository
|
||||
|
||||
setFilter((prev) => ({
|
||||
...prev,
|
||||
@@ -463,7 +474,7 @@ export default function Repository() {
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={handleMirrorAllRepos}
|
||||
disabled={isLoading || loadingRepoIds.size > 0}
|
||||
disabled={isInitialLoading || loadingRepoIds.size > 0}
|
||||
>
|
||||
<FlipHorizontal className="h-4 w-4 mr-2" />
|
||||
Mirror All
|
||||
@@ -490,7 +501,8 @@ export default function Repository() {
|
||||
) : (
|
||||
<RepositoryTable
|
||||
repositories={repositories}
|
||||
isLoading={isLoading || !connected}
|
||||
isLoading={isInitialLoading || !connected}
|
||||
isLiveActive={isLiveEnabled && isFullyConfigured}
|
||||
filter={filter}
|
||||
setFilter={setFilter}
|
||||
onMirror={handleMirrorRepo}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useMemo, useRef } from "react";
|
||||
import Fuse from "fuse.js";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { GitFork, RefreshCw, RotateCcw } from "lucide-react";
|
||||
import { FlipHorizontal, GitFork, RefreshCw, RotateCcw } from "lucide-react";
|
||||
import { SiGithub, SiGitea } from "react-icons/si";
|
||||
import type { Repository } from "@/lib/db/schema";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -13,6 +13,7 @@ import { useGiteaConfig } from "@/hooks/useGiteaConfig";
|
||||
interface RepositoryTableProps {
|
||||
repositories: Repository[];
|
||||
isLoading: boolean;
|
||||
isLiveActive?: boolean;
|
||||
filter: FilterParams;
|
||||
setFilter: (filter: FilterParams) => void;
|
||||
onMirror: ({ repoId }: { repoId: string }) => Promise<void>;
|
||||
@@ -24,6 +25,7 @@ interface RepositoryTableProps {
|
||||
export default function RepositoryTable({
|
||||
repositories,
|
||||
isLoading,
|
||||
isLiveActive = false,
|
||||
filter,
|
||||
setFilter,
|
||||
onMirror,
|
||||
@@ -345,15 +347,38 @@ export default function RepositoryTable({
|
||||
</div>
|
||||
|
||||
{/* Status Bar */}
|
||||
<div className="h-[40px] flex items-center justify-between border-t bg-muted/30 px-3">
|
||||
<div className="h-[40px] flex items-center justify-between border-t bg-muted/30 px-3 relative">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-primary" />
|
||||
<div className={`h-1.5 w-1.5 rounded-full ${isLiveActive ? 'bg-emerald-500' : 'bg-primary'}`} />
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{hasAnyFilter
|
||||
? `Showing ${filteredRepositories.length} of ${repositories.length} repositories`
|
||||
: `${repositories.length} ${repositories.length === 1 ? 'repository' : 'repositories'} total`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Center - Live active indicator */}
|
||||
{isLiveActive && (
|
||||
<div className="flex items-center gap-1.5 absolute left-1/2 transform -translate-x-1/2">
|
||||
<div
|
||||
className="h-1 w-1 rounded-full bg-emerald-500"
|
||||
style={{
|
||||
animation: 'pulse 2s ease-in-out infinite'
|
||||
}}
|
||||
/>
|
||||
<span className="text-xs text-emerald-600 dark:text-emerald-400 font-medium">
|
||||
Live active
|
||||
</span>
|
||||
<div
|
||||
className="h-1 w-1 rounded-full bg-emerald-500"
|
||||
style={{
|
||||
animation: 'pulse 2s ease-in-out infinite',
|
||||
animationDelay: '1s'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasAnyFilter && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Filters applied
|
||||
@@ -393,7 +418,7 @@ function RepoActionButton({
|
||||
disabled ||= repo.status === "syncing";
|
||||
} else if (["imported", "mirroring"].includes(repo.status)) {
|
||||
label = "Mirror";
|
||||
icon = <GitFork className="h-4 w-4 mr-1" />;
|
||||
icon = <FlipHorizontal className="h-4 w-4 mr-1" />; // Don't change this icon to GitFork.
|
||||
onClick = onMirror;
|
||||
disabled ||= repo.status === "mirroring";
|
||||
} else {
|
||||
|
||||
@@ -150,20 +150,11 @@ Events in Gitea Mirror (such as repository mirroring operations) are stored in t
|
||||
# View all events in the database
|
||||
bun scripts/check-events.ts
|
||||
|
||||
# Clean up old events (default: older than 7 days)
|
||||
bun scripts/cleanup-events.ts
|
||||
|
||||
# Clean up old mirror jobs (default: older than 7 days)
|
||||
bun scripts/cleanup-mirror-jobs.ts
|
||||
|
||||
# Clean up both events and mirror jobs
|
||||
bun run cleanup-all
|
||||
|
||||
# Mark all events as read
|
||||
bun scripts/mark-events-read.ts
|
||||
```
|
||||
|
||||
When using Docker, database cleanup is automatically scheduled to run daily. You can customize the retention periods by setting the `EVENTS_RETENTION_DAYS` and `JOBS_RETENTION_DAYS` environment variables in your docker-compose file.
|
||||
For cleaning up old activities and events, use the cleanup button in the Activity Log page of the web interface.
|
||||
|
||||
### Health Check Endpoint
|
||||
|
||||
|
||||
@@ -179,4 +179,4 @@ After your initial setup:
|
||||
- Check out the [Configuration Guide](/configuration) for advanced settings
|
||||
- Review the [Architecture Documentation](/architecture) to understand the system
|
||||
- For server deployments, set up monitoring using the health check endpoint
|
||||
- Consider setting up a cron job to clean up old events: `bun scripts/cleanup-events.ts`
|
||||
- Use the cleanup button in the Activity Log page to manage old events and activities
|
||||
|
||||
258
src/lib/cleanup-service.ts
Normal file
258
src/lib/cleanup-service.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* Background cleanup service for automatic database maintenance
|
||||
* This service runs periodically to clean up old events and mirror jobs
|
||||
* based on user configuration settings
|
||||
*/
|
||||
|
||||
import { db, configs, events, mirrorJobs } from "@/lib/db";
|
||||
import { eq, lt, and } from "drizzle-orm";
|
||||
|
||||
interface CleanupResult {
|
||||
userId: string;
|
||||
eventsDeleted: number;
|
||||
mirrorJobsDeleted: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate cleanup interval in hours based on retention period
|
||||
* For shorter retention periods, run more frequently
|
||||
* For longer retention periods, run less frequently
|
||||
* @param retentionSeconds - Retention period in seconds
|
||||
*/
|
||||
export function calculateCleanupInterval(retentionSeconds: number): number {
|
||||
const retentionDays = retentionSeconds / (24 * 60 * 60); // Convert seconds to days
|
||||
|
||||
if (retentionDays <= 1) {
|
||||
return 6; // Every 6 hours for 1 day retention
|
||||
} else if (retentionDays <= 3) {
|
||||
return 12; // Every 12 hours for 1-3 days retention
|
||||
} else if (retentionDays <= 7) {
|
||||
return 24; // Daily for 4-7 days retention
|
||||
} else if (retentionDays <= 30) {
|
||||
return 48; // Every 2 days for 8-30 days retention
|
||||
} else {
|
||||
return 168; // Weekly for 30+ days retention
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up old events and mirror jobs for a specific user
|
||||
* @param retentionSeconds - Retention period in seconds
|
||||
*/
|
||||
async function cleanupForUser(userId: string, retentionSeconds: number): Promise<CleanupResult> {
|
||||
try {
|
||||
const retentionDays = retentionSeconds / (24 * 60 * 60); // Convert to days for logging
|
||||
console.log(`Running cleanup for user ${userId} with ${retentionDays} days retention (${retentionSeconds} seconds)`);
|
||||
|
||||
// Calculate cutoff date using seconds
|
||||
const cutoffDate = new Date();
|
||||
cutoffDate.setTime(cutoffDate.getTime() - retentionSeconds * 1000);
|
||||
|
||||
let eventsDeleted = 0;
|
||||
let mirrorJobsDeleted = 0;
|
||||
|
||||
// Clean up old events
|
||||
const eventsResult = await db
|
||||
.delete(events)
|
||||
.where(
|
||||
and(
|
||||
eq(events.userId, userId),
|
||||
lt(events.createdAt, cutoffDate)
|
||||
)
|
||||
);
|
||||
eventsDeleted = eventsResult.changes || 0;
|
||||
|
||||
// Clean up old mirror jobs (only completed ones)
|
||||
const jobsResult = await db
|
||||
.delete(mirrorJobs)
|
||||
.where(
|
||||
and(
|
||||
eq(mirrorJobs.userId, userId),
|
||||
eq(mirrorJobs.inProgress, false),
|
||||
lt(mirrorJobs.timestamp, cutoffDate)
|
||||
)
|
||||
);
|
||||
mirrorJobsDeleted = jobsResult.changes || 0;
|
||||
|
||||
console.log(`Cleanup completed for user ${userId}: ${eventsDeleted} events, ${mirrorJobsDeleted} jobs deleted`);
|
||||
|
||||
return {
|
||||
userId,
|
||||
eventsDeleted,
|
||||
mirrorJobsDeleted,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Error during cleanup for user ${userId}:`, error);
|
||||
return {
|
||||
userId,
|
||||
eventsDeleted: 0,
|
||||
mirrorJobsDeleted: 0,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the cleanup configuration with last run time and calculate next run
|
||||
*/
|
||||
async function updateCleanupConfig(userId: string, cleanupConfig: any) {
|
||||
try {
|
||||
const now = new Date();
|
||||
const retentionSeconds = cleanupConfig.retentionDays || 604800; // Default 7 days in seconds
|
||||
const cleanupIntervalHours = calculateCleanupInterval(retentionSeconds);
|
||||
const nextRun = new Date(now.getTime() + cleanupIntervalHours * 60 * 60 * 1000);
|
||||
|
||||
const updatedConfig = {
|
||||
...cleanupConfig,
|
||||
lastRun: now,
|
||||
nextRun: nextRun,
|
||||
};
|
||||
|
||||
await db
|
||||
.update(configs)
|
||||
.set({
|
||||
cleanupConfig: updatedConfig,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(configs.userId, userId));
|
||||
|
||||
const retentionDays = retentionSeconds / (24 * 60 * 60);
|
||||
console.log(`Updated cleanup config for user ${userId}, next run: ${nextRun.toISOString()} (${cleanupIntervalHours}h interval for ${retentionDays}d retention)`);
|
||||
} catch (error) {
|
||||
console.error(`Error updating cleanup config for user ${userId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run automatic cleanup for all users with cleanup enabled
|
||||
*/
|
||||
export async function runAutomaticCleanup(): Promise<CleanupResult[]> {
|
||||
try {
|
||||
console.log('Starting automatic cleanup service...');
|
||||
|
||||
// Get all users with cleanup enabled
|
||||
const userConfigs = await db
|
||||
.select()
|
||||
.from(configs)
|
||||
.where(eq(configs.isActive, true));
|
||||
|
||||
const results: CleanupResult[] = [];
|
||||
const now = new Date();
|
||||
|
||||
for (const config of userConfigs) {
|
||||
try {
|
||||
const cleanupConfig = config.cleanupConfig;
|
||||
|
||||
// Skip if cleanup is not enabled
|
||||
if (!cleanupConfig?.enabled) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if it's time to run cleanup
|
||||
const nextRun = cleanupConfig.nextRun ? new Date(cleanupConfig.nextRun) : null;
|
||||
|
||||
// If nextRun is null or in the past, run cleanup
|
||||
if (!nextRun || now >= nextRun) {
|
||||
const result = await cleanupForUser(config.userId, cleanupConfig.retentionDays || 604800);
|
||||
results.push(result);
|
||||
|
||||
// Update the cleanup config with new run times
|
||||
await updateCleanupConfig(config.userId, cleanupConfig);
|
||||
} else {
|
||||
console.log(`Skipping cleanup for user ${config.userId}, next run: ${nextRun.toISOString()}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error processing cleanup for user ${config.userId}:`, error);
|
||||
results.push({
|
||||
userId: config.userId,
|
||||
eventsDeleted: 0,
|
||||
mirrorJobsDeleted: 0,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Automatic cleanup completed. Processed ${results.length} users.`);
|
||||
return results;
|
||||
} catch (error) {
|
||||
console.error('Error in automatic cleanup service:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Service state tracking
|
||||
let cleanupIntervalId: NodeJS.Timeout | null = null;
|
||||
let initialCleanupTimeoutId: NodeJS.Timeout | null = null;
|
||||
let cleanupServiceRunning = false;
|
||||
|
||||
/**
|
||||
* Start the cleanup service with periodic execution
|
||||
* This should be called when the application starts
|
||||
*/
|
||||
export function startCleanupService() {
|
||||
if (cleanupServiceRunning) {
|
||||
console.log('⚠️ Cleanup service already running, skipping start');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Starting background cleanup service...');
|
||||
|
||||
// Run cleanup every hour
|
||||
const CLEANUP_INTERVAL = 60 * 60 * 1000; // 1 hour in milliseconds
|
||||
|
||||
// Run initial cleanup after 5 minutes to allow app to fully start
|
||||
initialCleanupTimeoutId = setTimeout(() => {
|
||||
runAutomaticCleanup().catch(error => {
|
||||
console.error('Error in initial cleanup run:', error);
|
||||
});
|
||||
}, 5 * 60 * 1000); // 5 minutes
|
||||
|
||||
// Set up periodic cleanup
|
||||
cleanupIntervalId = setInterval(() => {
|
||||
runAutomaticCleanup().catch(error => {
|
||||
console.error('Error in periodic cleanup run:', error);
|
||||
});
|
||||
}, CLEANUP_INTERVAL);
|
||||
|
||||
cleanupServiceRunning = true;
|
||||
console.log(`✅ Cleanup service started. Will run every ${CLEANUP_INTERVAL / 1000 / 60} minutes.`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the cleanup service (for testing or shutdown)
|
||||
*/
|
||||
export function stopCleanupService() {
|
||||
if (!cleanupServiceRunning) {
|
||||
console.log('Cleanup service is not running');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('🛑 Stopping cleanup service...');
|
||||
|
||||
// Clear the periodic interval
|
||||
if (cleanupIntervalId) {
|
||||
clearInterval(cleanupIntervalId);
|
||||
cleanupIntervalId = null;
|
||||
}
|
||||
|
||||
// Clear the initial timeout
|
||||
if (initialCleanupTimeoutId) {
|
||||
clearTimeout(initialCleanupTimeoutId);
|
||||
initialCleanupTimeoutId = null;
|
||||
}
|
||||
|
||||
cleanupServiceRunning = false;
|
||||
console.log('✅ Cleanup service stopped');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cleanup service status
|
||||
*/
|
||||
export function getCleanupServiceStatus() {
|
||||
return {
|
||||
running: cleanupServiceRunning,
|
||||
hasInterval: cleanupIntervalId !== null,
|
||||
hasInitialTimeout: initialCleanupTimeoutId !== null,
|
||||
};
|
||||
}
|
||||
@@ -81,6 +81,7 @@ export const events = sqliteTable("events", {
|
||||
const githubSchema = configSchema.shape.githubConfig;
|
||||
const giteaSchema = configSchema.shape.giteaConfig;
|
||||
const scheduleSchema = configSchema.shape.scheduleConfig;
|
||||
const cleanupSchema = configSchema.shape.cleanupConfig;
|
||||
|
||||
export const configs = sqliteTable("configs", {
|
||||
id: text("id").primaryKey(),
|
||||
@@ -112,6 +113,10 @@ export const configs = sqliteTable("configs", {
|
||||
.$type<z.infer<typeof scheduleSchema>>()
|
||||
.notNull(),
|
||||
|
||||
cleanupConfig: text("cleanup_config", { mode: "json" })
|
||||
.$type<z.infer<typeof cleanupSchema>>()
|
||||
.notNull(),
|
||||
|
||||
createdAt: integer("created_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(new Date()),
|
||||
|
||||
@@ -52,6 +52,12 @@ export const configSchema = z.object({
|
||||
lastRun: z.date().optional(),
|
||||
nextRun: z.date().optional(),
|
||||
}),
|
||||
cleanupConfig: z.object({
|
||||
enabled: z.boolean().default(false),
|
||||
retentionDays: z.number().min(1).default(604800), // in seconds (default: 7 days)
|
||||
lastRun: z.date().optional(),
|
||||
nextRun: z.date().optional(),
|
||||
}),
|
||||
createdAt: z.date().default(() => new Date()),
|
||||
updatedAt: z.date().default(() => new Date()),
|
||||
});
|
||||
|
||||
@@ -87,36 +87,27 @@ export async function getNewEvents({
|
||||
lastEventTime?: Date;
|
||||
}): Promise<any[]> {
|
||||
try {
|
||||
console.log(`Getting new events for user ${userId} in channel ${channel}`);
|
||||
if (lastEventTime) {
|
||||
console.log(`Looking for events after ${lastEventTime.toISOString()}`);
|
||||
}
|
||||
|
||||
// Build the query
|
||||
let query = db
|
||||
.select()
|
||||
.from(events)
|
||||
.where(
|
||||
and(
|
||||
eq(events.userId, userId),
|
||||
eq(events.channel, channel),
|
||||
eq(events.read, false)
|
||||
)
|
||||
)
|
||||
.orderBy(events.createdAt);
|
||||
// Build the query conditions
|
||||
const conditions = [
|
||||
eq(events.userId, userId),
|
||||
eq(events.channel, channel),
|
||||
eq(events.read, false)
|
||||
];
|
||||
|
||||
// Add time filter if provided
|
||||
if (lastEventTime) {
|
||||
query = query.where(gt(events.createdAt, lastEventTime));
|
||||
conditions.push(gt(events.createdAt, lastEventTime));
|
||||
}
|
||||
|
||||
// Execute the query
|
||||
const newEvents = await query;
|
||||
console.log(`Found ${newEvents.length} new events`);
|
||||
const newEvents = await db
|
||||
.select()
|
||||
.from(events)
|
||||
.where(and(...conditions))
|
||||
.orderBy(events.createdAt);
|
||||
|
||||
// Mark events as read
|
||||
if (newEvents.length > 0) {
|
||||
console.log(`Marking ${newEvents.length} events as read`);
|
||||
await db
|
||||
.update(events)
|
||||
.set({ read: true })
|
||||
@@ -149,14 +140,11 @@ export async function removeDuplicateEvents(userId?: string): Promise<{ duplicat
|
||||
console.log("Removing duplicate events...");
|
||||
|
||||
// Build the base query
|
||||
let query = db.select().from(events);
|
||||
if (userId) {
|
||||
query = query.where(eq(events.userId, userId));
|
||||
}
|
||||
const allEvents = userId
|
||||
? await db.select().from(events).where(eq(events.userId, userId))
|
||||
: await db.select().from(events);
|
||||
|
||||
const allEvents = await query;
|
||||
const duplicateIds: string[] = [];
|
||||
const seenKeys = new Set<string>();
|
||||
|
||||
// Group events by user and channel, then check for duplicates
|
||||
const eventsByUserChannel = new Map<string, typeof allEvents>();
|
||||
@@ -214,7 +202,7 @@ export async function removeDuplicateEvents(userId?: string): Promise<{ duplicat
|
||||
|
||||
/**
|
||||
* Cleans up old events to prevent the database from growing too large
|
||||
* Should be called periodically (e.g., daily via a cron job)
|
||||
* This function is used by the cleanup button in the Activity Log page
|
||||
*
|
||||
* @param maxAgeInDays Number of days to keep events (default: 7)
|
||||
* @param cleanupUnreadAfterDays Number of days after which to clean up unread events (default: 2x maxAgeInDays)
|
||||
@@ -241,7 +229,7 @@ export async function cleanupOldEvents(
|
||||
)
|
||||
);
|
||||
|
||||
const readEventsDeleted = readResult.changes || 0;
|
||||
const readEventsDeleted = (readResult as any).changes || 0;
|
||||
console.log(`Deleted ${readEventsDeleted} read events`);
|
||||
|
||||
// Calculate the cutoff date for unread events (default to 2x the retention period)
|
||||
@@ -259,7 +247,7 @@ export async function cleanupOldEvents(
|
||||
)
|
||||
);
|
||||
|
||||
const unreadEventsDeleted = unreadResult.changes || 0;
|
||||
const unreadEventsDeleted = (unreadResult as any).changes || 0;
|
||||
console.log(`Deleted ${unreadEventsDeleted} unread events`);
|
||||
|
||||
return { readEventsDeleted, unreadEventsDeleted };
|
||||
|
||||
240
src/lib/shutdown-manager.ts
Normal file
240
src/lib/shutdown-manager.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* Shutdown Manager for Graceful Application Termination
|
||||
*
|
||||
* This module provides centralized shutdown coordination for the gitea-mirror application.
|
||||
* It ensures that:
|
||||
* - In-progress jobs are properly saved to the database
|
||||
* - Database connections are closed cleanly
|
||||
* - Background services are stopped gracefully
|
||||
* - No data loss occurs during container restarts
|
||||
*/
|
||||
|
||||
import { db, mirrorJobs } from './db';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import type { MirrorJob } from './db/schema';
|
||||
|
||||
// Shutdown state tracking
|
||||
let shutdownInProgress = false;
|
||||
let shutdownStartTime: Date | null = null;
|
||||
let shutdownCallbacks: Array<() => Promise<void>> = [];
|
||||
let activeJobs = new Set<string>();
|
||||
let shutdownTimeout: NodeJS.Timeout | null = null;
|
||||
|
||||
// Configuration
|
||||
const SHUTDOWN_TIMEOUT = 30000; // 30 seconds max shutdown time
|
||||
const JOB_SAVE_TIMEOUT = 10000; // 10 seconds to save job state
|
||||
|
||||
/**
|
||||
* Register a callback to be executed during shutdown
|
||||
*/
|
||||
export function registerShutdownCallback(callback: () => Promise<void>): void {
|
||||
shutdownCallbacks.push(callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register an active job that needs to be tracked during shutdown
|
||||
*/
|
||||
export function registerActiveJob(jobId: string): void {
|
||||
activeJobs.add(jobId);
|
||||
console.log(`Registered active job: ${jobId} (${activeJobs.size} total active jobs)`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister a job when it completes normally
|
||||
*/
|
||||
export function unregisterActiveJob(jobId: string): void {
|
||||
activeJobs.delete(jobId);
|
||||
console.log(`Unregistered job: ${jobId} (${activeJobs.size} remaining active jobs)`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if shutdown is currently in progress
|
||||
*/
|
||||
export function isShuttingDown(): boolean {
|
||||
return shutdownInProgress;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get shutdown status information
|
||||
*/
|
||||
export function getShutdownStatus() {
|
||||
return {
|
||||
inProgress: shutdownInProgress,
|
||||
startTime: shutdownStartTime,
|
||||
activeJobs: Array.from(activeJobs),
|
||||
registeredCallbacks: shutdownCallbacks.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the current state of an active job to the database
|
||||
*/
|
||||
async function saveJobState(jobId: string): Promise<void> {
|
||||
try {
|
||||
console.log(`Saving state for job ${jobId}...`);
|
||||
|
||||
// Update the job to mark it as interrupted but not failed
|
||||
await db
|
||||
.update(mirrorJobs)
|
||||
.set({
|
||||
inProgress: false,
|
||||
lastCheckpoint: new Date(),
|
||||
message: 'Job interrupted by application shutdown - will resume on restart',
|
||||
})
|
||||
.where(eq(mirrorJobs.id, jobId));
|
||||
|
||||
console.log(`✅ Saved state for job ${jobId}`);
|
||||
} catch (error) {
|
||||
console.error(`❌ Failed to save state for job ${jobId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save all active jobs to the database
|
||||
*/
|
||||
async function saveAllActiveJobs(): Promise<void> {
|
||||
if (activeJobs.size === 0) {
|
||||
console.log('No active jobs to save');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Saving state for ${activeJobs.size} active jobs...`);
|
||||
|
||||
const savePromises = Array.from(activeJobs).map(async (jobId) => {
|
||||
try {
|
||||
await Promise.race([
|
||||
saveJobState(jobId),
|
||||
new Promise<never>((_, reject) => {
|
||||
setTimeout(() => reject(new Error(`Timeout saving job ${jobId}`)), JOB_SAVE_TIMEOUT);
|
||||
})
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error(`Failed to save job ${jobId} within timeout:`, error);
|
||||
// Continue with other jobs even if one fails
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.allSettled(savePromises);
|
||||
console.log('✅ Completed saving all active jobs');
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute all registered shutdown callbacks
|
||||
*/
|
||||
async function executeShutdownCallbacks(): Promise<void> {
|
||||
if (shutdownCallbacks.length === 0) {
|
||||
console.log('No shutdown callbacks to execute');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Executing ${shutdownCallbacks.length} shutdown callbacks...`);
|
||||
|
||||
const callbackPromises = shutdownCallbacks.map(async (callback, index) => {
|
||||
try {
|
||||
await callback();
|
||||
console.log(`✅ Shutdown callback ${index + 1} completed`);
|
||||
} catch (error) {
|
||||
console.error(`❌ Shutdown callback ${index + 1} failed:`, error);
|
||||
// Continue with other callbacks even if one fails
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.allSettled(callbackPromises);
|
||||
console.log('✅ Completed all shutdown callbacks');
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform graceful shutdown of the application
|
||||
*/
|
||||
export async function gracefulShutdown(signal: string = 'UNKNOWN'): Promise<void> {
|
||||
if (shutdownInProgress) {
|
||||
console.log('⚠️ Shutdown already in progress, ignoring additional signal');
|
||||
return;
|
||||
}
|
||||
|
||||
shutdownInProgress = true;
|
||||
shutdownStartTime = new Date();
|
||||
|
||||
console.log(`\n🛑 Graceful shutdown initiated by signal: ${signal}`);
|
||||
console.log(`📊 Shutdown status: ${activeJobs.size} active jobs, ${shutdownCallbacks.length} callbacks`);
|
||||
|
||||
// Set up shutdown timeout
|
||||
shutdownTimeout = setTimeout(() => {
|
||||
console.error(`❌ Shutdown timeout reached (${SHUTDOWN_TIMEOUT}ms), forcing exit`);
|
||||
process.exit(1);
|
||||
}, SHUTDOWN_TIMEOUT);
|
||||
|
||||
try {
|
||||
// Step 1: Save all active job states
|
||||
console.log('\n📝 Step 1: Saving active job states...');
|
||||
await saveAllActiveJobs();
|
||||
|
||||
// Step 2: Execute shutdown callbacks (stop services, close connections, etc.)
|
||||
console.log('\n🔧 Step 2: Executing shutdown callbacks...');
|
||||
await executeShutdownCallbacks();
|
||||
|
||||
// Step 3: Close database connections
|
||||
console.log('\n💾 Step 3: Closing database connections...');
|
||||
// Note: Drizzle with bun:sqlite doesn't require explicit connection closing
|
||||
// but we'll add this for completeness and future database changes
|
||||
|
||||
console.log('\n✅ Graceful shutdown completed successfully');
|
||||
|
||||
// Clear the timeout since we completed successfully
|
||||
if (shutdownTimeout) {
|
||||
clearTimeout(shutdownTimeout);
|
||||
shutdownTimeout = null;
|
||||
}
|
||||
|
||||
// Exit with success code
|
||||
process.exit(0);
|
||||
|
||||
} catch (error) {
|
||||
console.error('\n❌ Error during graceful shutdown:', error);
|
||||
|
||||
// Clear the timeout
|
||||
if (shutdownTimeout) {
|
||||
clearTimeout(shutdownTimeout);
|
||||
shutdownTimeout = null;
|
||||
}
|
||||
|
||||
// Exit with error code
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the shutdown manager
|
||||
* This should be called early in the application lifecycle
|
||||
*/
|
||||
export function initializeShutdownManager(): void {
|
||||
console.log('🔧 Initializing shutdown manager...');
|
||||
|
||||
// Reset state in case of re-initialization
|
||||
shutdownInProgress = false;
|
||||
shutdownStartTime = null;
|
||||
activeJobs.clear();
|
||||
shutdownCallbacks = []; // Reset callbacks too
|
||||
|
||||
// Clear any existing timeout
|
||||
if (shutdownTimeout) {
|
||||
clearTimeout(shutdownTimeout);
|
||||
shutdownTimeout = null;
|
||||
}
|
||||
|
||||
console.log('✅ Shutdown manager initialized');
|
||||
}
|
||||
|
||||
/**
|
||||
* Force immediate shutdown (for emergencies)
|
||||
*/
|
||||
export function forceShutdown(exitCode: number = 1): void {
|
||||
console.error('🚨 Force shutdown requested');
|
||||
|
||||
if (shutdownTimeout) {
|
||||
clearTimeout(shutdownTimeout);
|
||||
}
|
||||
|
||||
process.exit(exitCode);
|
||||
}
|
||||
141
src/lib/signal-handlers.ts
Normal file
141
src/lib/signal-handlers.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Signal Handlers for Graceful Shutdown
|
||||
*
|
||||
* This module sets up proper signal handling for container environments.
|
||||
* It ensures the application responds correctly to SIGTERM, SIGINT, and other signals.
|
||||
*/
|
||||
|
||||
import { gracefulShutdown, isShuttingDown } from './shutdown-manager';
|
||||
|
||||
// Track if signal handlers have been registered
|
||||
let signalHandlersRegistered = false;
|
||||
|
||||
/**
|
||||
* Setup signal handlers for graceful shutdown
|
||||
* This should be called early in the application lifecycle
|
||||
*/
|
||||
export function setupSignalHandlers(): void {
|
||||
if (signalHandlersRegistered) {
|
||||
console.log('⚠️ Signal handlers already registered, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('🔧 Setting up signal handlers for graceful shutdown...');
|
||||
|
||||
// Handle SIGTERM (Docker stop, Kubernetes termination)
|
||||
process.on('SIGTERM', () => {
|
||||
console.log('\n📡 Received SIGTERM signal');
|
||||
if (!isShuttingDown()) {
|
||||
gracefulShutdown('SIGTERM').catch((error) => {
|
||||
console.error('Error during SIGTERM shutdown:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Handle SIGINT (Ctrl+C)
|
||||
process.on('SIGINT', () => {
|
||||
console.log('\n📡 Received SIGINT signal');
|
||||
if (!isShuttingDown()) {
|
||||
gracefulShutdown('SIGINT').catch((error) => {
|
||||
console.error('Error during SIGINT shutdown:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Handle SIGHUP (terminal hangup)
|
||||
process.on('SIGHUP', () => {
|
||||
console.log('\n📡 Received SIGHUP signal');
|
||||
if (!isShuttingDown()) {
|
||||
gracefulShutdown('SIGHUP').catch((error) => {
|
||||
console.error('Error during SIGHUP shutdown:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Handle uncaught exceptions
|
||||
process.on('uncaughtException', (error) => {
|
||||
console.error('\n💥 Uncaught Exception:', error);
|
||||
console.error('Stack trace:', error.stack);
|
||||
|
||||
if (!isShuttingDown()) {
|
||||
console.log('Initiating emergency shutdown due to uncaught exception...');
|
||||
gracefulShutdown('UNCAUGHT_EXCEPTION').catch((shutdownError) => {
|
||||
console.error('Error during emergency shutdown:', shutdownError);
|
||||
process.exit(1);
|
||||
});
|
||||
} else {
|
||||
// If already shutting down, force exit
|
||||
console.error('Uncaught exception during shutdown, forcing exit');
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle unhandled promise rejections
|
||||
process.on('unhandledRejection', (reason, promise) => {
|
||||
console.error('\n💥 Unhandled Promise Rejection at:', promise);
|
||||
console.error('Reason:', reason);
|
||||
|
||||
if (!isShuttingDown()) {
|
||||
console.log('Initiating emergency shutdown due to unhandled rejection...');
|
||||
gracefulShutdown('UNHANDLED_REJECTION').catch((shutdownError) => {
|
||||
console.error('Error during emergency shutdown:', shutdownError);
|
||||
process.exit(1);
|
||||
});
|
||||
} else {
|
||||
// If already shutting down, force exit
|
||||
console.error('Unhandled rejection during shutdown, forcing exit');
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle process warnings (for debugging)
|
||||
process.on('warning', (warning) => {
|
||||
console.warn('⚠️ Process Warning:', warning.name);
|
||||
console.warn('Message:', warning.message);
|
||||
if (warning.stack) {
|
||||
console.warn('Stack:', warning.stack);
|
||||
}
|
||||
});
|
||||
|
||||
signalHandlersRegistered = true;
|
||||
console.log('✅ Signal handlers registered successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove signal handlers (for testing)
|
||||
*/
|
||||
export function removeSignalHandlers(): void {
|
||||
if (!signalHandlersRegistered) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('🔧 Removing signal handlers...');
|
||||
|
||||
process.removeAllListeners('SIGTERM');
|
||||
process.removeAllListeners('SIGINT');
|
||||
process.removeAllListeners('SIGHUP');
|
||||
process.removeAllListeners('uncaughtException');
|
||||
process.removeAllListeners('unhandledRejection');
|
||||
process.removeAllListeners('warning');
|
||||
|
||||
signalHandlersRegistered = false;
|
||||
console.log('✅ Signal handlers removed');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if signal handlers are registered
|
||||
*/
|
||||
export function areSignalHandlersRegistered(): boolean {
|
||||
return signalHandlersRegistered;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a test signal to the current process (for testing)
|
||||
*/
|
||||
export function sendTestSignal(signal: NodeJS.Signals = 'SIGTERM'): void {
|
||||
console.log(`🧪 Sending test signal: ${signal}`);
|
||||
process.kill(process.pid, signal);
|
||||
}
|
||||
@@ -102,6 +102,16 @@ export async function processWithRetry<T, R>(
|
||||
|
||||
for (let attempt = 1; attempt <= maxRetries + 1; attempt++) {
|
||||
try {
|
||||
// Check for shutdown before processing each item (only in production)
|
||||
try {
|
||||
const { isShuttingDown } = await import('@/lib/shutdown-manager');
|
||||
if (isShuttingDown()) {
|
||||
throw new Error('Processing interrupted by application shutdown');
|
||||
}
|
||||
} catch (importError) {
|
||||
// Ignore import errors during testing
|
||||
}
|
||||
|
||||
const result = await processItem(item);
|
||||
|
||||
// Handle checkpointing if enabled
|
||||
@@ -185,9 +195,24 @@ export async function processWithResilience<T, R>(
|
||||
...otherOptions
|
||||
} = options;
|
||||
|
||||
// Import helpers for job management
|
||||
// Import helpers for job management and shutdown handling
|
||||
const { createMirrorJob, updateMirrorJobProgress } = await import('@/lib/helpers');
|
||||
|
||||
// Import shutdown manager (with fallback for testing)
|
||||
let registerActiveJob: (jobId: string) => void = () => {};
|
||||
let unregisterActiveJob: (jobId: string) => void = () => {};
|
||||
let isShuttingDown: () => boolean = () => false;
|
||||
|
||||
try {
|
||||
const shutdownManager = await import('@/lib/shutdown-manager');
|
||||
registerActiveJob = shutdownManager.registerActiveJob;
|
||||
unregisterActiveJob = shutdownManager.unregisterActiveJob;
|
||||
isShuttingDown = shutdownManager.isShuttingDown;
|
||||
} catch (importError) {
|
||||
// Use fallback functions during testing
|
||||
console.log('Using fallback shutdown manager functions (testing mode)');
|
||||
}
|
||||
|
||||
// Get item IDs for all items
|
||||
const allItemIds = items.map(getItemId);
|
||||
|
||||
@@ -240,6 +265,9 @@ export async function processWithResilience<T, R>(
|
||||
console.log(`Created new job ${jobId} with ${items.length} items`);
|
||||
}
|
||||
|
||||
// Register the job with the shutdown manager
|
||||
registerActiveJob(jobId);
|
||||
|
||||
// Define the checkpoint function
|
||||
const onCheckpoint = async (jobId: string, completedItemId: string) => {
|
||||
const itemName = items.find(item => getItemId(item) === completedItemId)
|
||||
@@ -254,6 +282,12 @@ export async function processWithResilience<T, R>(
|
||||
};
|
||||
|
||||
try {
|
||||
// Check if shutdown is in progress before starting
|
||||
if (isShuttingDown()) {
|
||||
console.log(`⚠️ Shutdown in progress, aborting job ${jobId}`);
|
||||
throw new Error('Job aborted due to application shutdown');
|
||||
}
|
||||
|
||||
// Process the items with checkpointing
|
||||
const results = await processWithRetry(
|
||||
itemsToProcess,
|
||||
@@ -276,17 +310,27 @@ export async function processWithResilience<T, R>(
|
||||
isCompleted: true,
|
||||
});
|
||||
|
||||
// Unregister the job from shutdown manager
|
||||
unregisterActiveJob(jobId);
|
||||
|
||||
return results;
|
||||
} catch (error) {
|
||||
// Mark the job as failed
|
||||
// Mark the job as failed (unless it was interrupted by shutdown)
|
||||
const isShutdownError = error instanceof Error && error.message.includes('shutdown');
|
||||
|
||||
await updateMirrorJobProgress({
|
||||
jobId,
|
||||
status: "failed",
|
||||
message: `Failed ${jobType} job: ${error instanceof Error ? error.message : String(error)}`,
|
||||
status: isShutdownError ? "imported" : "failed", // Keep as imported if shutdown interrupted
|
||||
message: isShutdownError
|
||||
? 'Job interrupted by application shutdown - will resume on restart'
|
||||
: `Failed ${jobType} job: ${error instanceof Error ? error.message : String(error)}`,
|
||||
inProgress: false,
|
||||
isCompleted: true,
|
||||
isCompleted: !isShutdownError, // Don't mark as completed if shutdown interrupted
|
||||
});
|
||||
|
||||
// Unregister the job from shutdown manager
|
||||
unregisterActiveJob(jobId);
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,30 @@
|
||||
import { defineMiddleware } from 'astro:middleware';
|
||||
import { initializeRecovery, hasJobsNeedingRecovery, getRecoveryStatus } from './lib/recovery';
|
||||
import { startCleanupService, stopCleanupService } from './lib/cleanup-service';
|
||||
import { initializeShutdownManager, registerShutdownCallback } from './lib/shutdown-manager';
|
||||
import { setupSignalHandlers } from './lib/signal-handlers';
|
||||
|
||||
// Flag to track if recovery has been initialized
|
||||
let recoveryInitialized = false;
|
||||
let recoveryAttempted = false;
|
||||
let cleanupServiceStarted = false;
|
||||
let shutdownManagerInitialized = false;
|
||||
|
||||
export const onRequest = defineMiddleware(async (context, next) => {
|
||||
// Initialize shutdown manager and signal handlers first
|
||||
if (!shutdownManagerInitialized) {
|
||||
try {
|
||||
console.log('🔧 Initializing shutdown manager and signal handlers...');
|
||||
initializeShutdownManager();
|
||||
setupSignalHandlers();
|
||||
shutdownManagerInitialized = true;
|
||||
console.log('✅ Shutdown manager and signal handlers initialized');
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to initialize shutdown manager:', error);
|
||||
// Continue anyway - this shouldn't block the application
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize recovery system only once when the server starts
|
||||
// This is a fallback in case the startup script didn't run
|
||||
if (!recoveryInitialized && !recoveryAttempted) {
|
||||
@@ -53,6 +72,25 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Start cleanup service only once after recovery is complete
|
||||
if (recoveryInitialized && !cleanupServiceStarted) {
|
||||
try {
|
||||
console.log('Starting automatic database cleanup service...');
|
||||
startCleanupService();
|
||||
|
||||
// Register cleanup service shutdown callback
|
||||
registerShutdownCallback(async () => {
|
||||
console.log('🛑 Shutting down cleanup service...');
|
||||
stopCleanupService();
|
||||
});
|
||||
|
||||
cleanupServiceStarted = true;
|
||||
} catch (error) {
|
||||
console.error('Failed to start cleanup service:', error);
|
||||
// Don't fail the request if cleanup service fails to start
|
||||
}
|
||||
}
|
||||
|
||||
// Continue with the request
|
||||
return next();
|
||||
});
|
||||
|
||||
72
src/pages/api/cleanup/auto.ts
Normal file
72
src/pages/api/cleanup/auto.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* API endpoint to manually trigger automatic cleanup
|
||||
* This is useful for testing and debugging the cleanup service
|
||||
*/
|
||||
|
||||
import type { APIRoute } from 'astro';
|
||||
import { runAutomaticCleanup } from '@/lib/cleanup-service';
|
||||
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
try {
|
||||
console.log('Manual cleanup trigger requested');
|
||||
|
||||
// Run the automatic cleanup
|
||||
const results = await runAutomaticCleanup();
|
||||
|
||||
// Calculate totals
|
||||
const totalEventsDeleted = results.reduce((sum, result) => sum + result.eventsDeleted, 0);
|
||||
const totalJobsDeleted = results.reduce((sum, result) => sum + result.mirrorJobsDeleted, 0);
|
||||
const errors = results.filter(result => result.error);
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
message: 'Automatic cleanup completed',
|
||||
results: {
|
||||
usersProcessed: results.length,
|
||||
totalEventsDeleted,
|
||||
totalJobsDeleted,
|
||||
errors: errors.length,
|
||||
details: results,
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error in manual cleanup trigger:', error);
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: false,
|
||||
message: 'Failed to run automatic cleanup',
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
}),
|
||||
{
|
||||
status: 500,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const GET: APIRoute = async () => {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: false,
|
||||
message: 'Use POST method to trigger cleanup',
|
||||
}),
|
||||
{
|
||||
status: 405,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
@@ -2,18 +2,19 @@ import type { APIRoute } from "astro";
|
||||
import { db, configs, users } from "@/lib/db";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { calculateCleanupInterval } from "@/lib/cleanup-service";
|
||||
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { userId, githubConfig, giteaConfig, scheduleConfig } = body;
|
||||
const { userId, githubConfig, giteaConfig, scheduleConfig, cleanupConfig } = body;
|
||||
|
||||
if (!userId || !githubConfig || !giteaConfig || !scheduleConfig) {
|
||||
if (!userId || !githubConfig || !giteaConfig || !scheduleConfig || !cleanupConfig) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: false,
|
||||
message:
|
||||
"userId, githubConfig, giteaConfig, and scheduleConfig are required.",
|
||||
"userId, githubConfig, giteaConfig, scheduleConfig, and cleanupConfig are required.",
|
||||
}),
|
||||
{
|
||||
status: 400,
|
||||
@@ -56,6 +57,63 @@ export const POST: APIRoute = async ({ request }) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Process schedule config - set/update nextRun if enabled, clear if disabled
|
||||
const processedScheduleConfig = { ...scheduleConfig };
|
||||
if (scheduleConfig.enabled) {
|
||||
const now = new Date();
|
||||
const interval = scheduleConfig.interval || 3600; // Default to 1 hour
|
||||
|
||||
// Check if we need to recalculate nextRun
|
||||
// Recalculate if: no nextRun exists, or interval changed from existing config
|
||||
let shouldRecalculate = !scheduleConfig.nextRun;
|
||||
|
||||
if (existingConfig && existingConfig.scheduleConfig) {
|
||||
const existingScheduleConfig = existingConfig.scheduleConfig;
|
||||
const existingInterval = existingScheduleConfig.interval || 3600;
|
||||
|
||||
// If interval changed, recalculate nextRun
|
||||
if (interval !== existingInterval) {
|
||||
shouldRecalculate = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldRecalculate) {
|
||||
processedScheduleConfig.nextRun = new Date(now.getTime() + interval * 1000);
|
||||
}
|
||||
} else {
|
||||
// Clear nextRun when disabled
|
||||
processedScheduleConfig.nextRun = null;
|
||||
}
|
||||
|
||||
// Process cleanup config - set/update nextRun if enabled, clear if disabled
|
||||
const processedCleanupConfig = { ...cleanupConfig };
|
||||
if (cleanupConfig.enabled) {
|
||||
const now = new Date();
|
||||
const retentionSeconds = cleanupConfig.retentionDays || 604800; // Default 7 days in seconds
|
||||
const cleanupIntervalHours = calculateCleanupInterval(retentionSeconds);
|
||||
|
||||
// Check if we need to recalculate nextRun
|
||||
// Recalculate if: no nextRun exists, or retention period changed from existing config
|
||||
let shouldRecalculate = !cleanupConfig.nextRun;
|
||||
|
||||
if (existingConfig && existingConfig.cleanupConfig) {
|
||||
const existingCleanupConfig = existingConfig.cleanupConfig;
|
||||
const existingRetentionSeconds = existingCleanupConfig.retentionDays || 604800;
|
||||
|
||||
// If retention period changed, recalculate nextRun
|
||||
if (retentionSeconds !== existingRetentionSeconds) {
|
||||
shouldRecalculate = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldRecalculate) {
|
||||
processedCleanupConfig.nextRun = new Date(now.getTime() + cleanupIntervalHours * 60 * 60 * 1000);
|
||||
}
|
||||
} else {
|
||||
// Clear nextRun when disabled
|
||||
processedCleanupConfig.nextRun = null;
|
||||
}
|
||||
|
||||
if (existingConfig) {
|
||||
// Update path
|
||||
await db
|
||||
@@ -63,7 +121,8 @@ export const POST: APIRoute = async ({ request }) => {
|
||||
.set({
|
||||
githubConfig,
|
||||
giteaConfig,
|
||||
scheduleConfig,
|
||||
scheduleConfig: processedScheduleConfig,
|
||||
cleanupConfig: processedCleanupConfig,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(configs.id, existingConfig.id));
|
||||
@@ -112,7 +171,8 @@ export const POST: APIRoute = async ({ request }) => {
|
||||
giteaConfig,
|
||||
include: [],
|
||||
exclude: [],
|
||||
scheduleConfig,
|
||||
scheduleConfig: processedScheduleConfig,
|
||||
cleanupConfig: processedCleanupConfig,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
@@ -197,6 +257,12 @@ export const GET: APIRoute = async ({ request }) => {
|
||||
lastRun: null,
|
||||
nextRun: null,
|
||||
},
|
||||
cleanupConfig: {
|
||||
enabled: false,
|
||||
retentionDays: 604800, // 7 days in seconds
|
||||
lastRun: null,
|
||||
nextRun: null,
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
|
||||
@@ -1,154 +0,0 @@
|
||||
import { describe, test, expect, mock, beforeEach, afterEach } from "bun:test";
|
||||
import { GET } from "./health";
|
||||
import * as dbModule from "@/lib/db";
|
||||
import os from "os";
|
||||
|
||||
// Mock the database module
|
||||
mock.module("@/lib/db", () => {
|
||||
return {
|
||||
db: {
|
||||
select: () => ({
|
||||
from: () => ({
|
||||
limit: () => Promise.resolve([{ test: 1 }])
|
||||
})
|
||||
})
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
// Mock the os functions individually
|
||||
const originalPlatform = os.platform;
|
||||
const originalVersion = os.version;
|
||||
const originalArch = os.arch;
|
||||
const originalTotalmem = os.totalmem;
|
||||
const originalFreemem = os.freemem;
|
||||
|
||||
describe("Health API Endpoint", () => {
|
||||
beforeEach(() => {
|
||||
// Mock os functions
|
||||
os.platform = mock(() => "test-platform");
|
||||
os.version = mock(() => "test-version");
|
||||
os.arch = mock(() => "test-arch");
|
||||
os.totalmem = mock(() => 16 * 1024 * 1024 * 1024); // 16GB
|
||||
os.freemem = mock(() => 8 * 1024 * 1024 * 1024); // 8GB
|
||||
|
||||
// Mock process.memoryUsage
|
||||
process.memoryUsage = mock(() => ({
|
||||
rss: 100 * 1024 * 1024, // 100MB
|
||||
heapTotal: 50 * 1024 * 1024, // 50MB
|
||||
heapUsed: 30 * 1024 * 1024, // 30MB
|
||||
external: 10 * 1024 * 1024, // 10MB
|
||||
arrayBuffers: 5 * 1024 * 1024, // 5MB
|
||||
}));
|
||||
|
||||
// Mock process.env
|
||||
process.env.npm_package_version = "2.1.0";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore original os functions
|
||||
os.platform = originalPlatform;
|
||||
os.version = originalVersion;
|
||||
os.arch = originalArch;
|
||||
os.totalmem = originalTotalmem;
|
||||
os.freemem = originalFreemem;
|
||||
});
|
||||
|
||||
test("returns a successful health check response", async () => {
|
||||
const response = await GET({ request: new Request("http://localhost/api/health") } as any);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Check the structure of the response
|
||||
expect(data.status).toBe("ok");
|
||||
expect(data.timestamp).toBeDefined();
|
||||
expect(data.version).toBe("2.1.0");
|
||||
|
||||
// Check database status
|
||||
expect(data.database.connected).toBe(true);
|
||||
|
||||
// Check system info
|
||||
expect(data.system.os.platform).toBe("test-platform");
|
||||
expect(data.system.os.version).toBe("test-version");
|
||||
expect(data.system.os.arch).toBe("test-arch");
|
||||
|
||||
// Check memory info
|
||||
expect(data.system.memory.rss).toBe("100 MB");
|
||||
expect(data.system.memory.heapTotal).toBe("50 MB");
|
||||
expect(data.system.memory.heapUsed).toBe("30 MB");
|
||||
expect(data.system.memory.systemTotal).toBe("16 GB");
|
||||
expect(data.system.memory.systemFree).toBe("8 GB");
|
||||
|
||||
// Check uptime
|
||||
expect(data.system.uptime.startTime).toBeDefined();
|
||||
expect(data.system.uptime.uptimeMs).toBeGreaterThanOrEqual(0);
|
||||
expect(data.system.uptime.formatted).toBeDefined();
|
||||
});
|
||||
|
||||
test("handles database connection failures", async () => {
|
||||
// Mock database failure
|
||||
mock.module("@/lib/db", () => {
|
||||
return {
|
||||
db: {
|
||||
select: () => ({
|
||||
from: () => ({
|
||||
limit: () => Promise.reject(new Error("Database connection error"))
|
||||
})
|
||||
})
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
// Mock console.error to prevent test output noise
|
||||
const originalConsoleError = console.error;
|
||||
console.error = mock(() => {});
|
||||
|
||||
try {
|
||||
const response = await GET({ request: new Request("http://localhost/api/health") } as any);
|
||||
|
||||
// Should still return 200 even with DB error, as the service itself is running
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Status should still be ok since the service is running
|
||||
expect(data.status).toBe("ok");
|
||||
|
||||
// Database should show as disconnected
|
||||
expect(data.database.connected).toBe(false);
|
||||
expect(data.database.message).toBe("Database connection error");
|
||||
} finally {
|
||||
// Restore console.error
|
||||
console.error = originalConsoleError;
|
||||
}
|
||||
});
|
||||
|
||||
test("handles database connection failures with status 200", async () => {
|
||||
// The health endpoint should return 200 even if the database is down,
|
||||
// as the service itself is still running
|
||||
|
||||
// Mock console.error to prevent test output noise
|
||||
const originalConsoleError = console.error;
|
||||
console.error = mock(() => {});
|
||||
|
||||
try {
|
||||
const response = await GET({ request: new Request("http://localhost/api/health") } as any);
|
||||
|
||||
// Should return 200 as the service is running
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Status should be ok
|
||||
expect(data.status).toBe("ok");
|
||||
|
||||
// Database should show as disconnected
|
||||
expect(data.database.connected).toBe(false);
|
||||
} finally {
|
||||
// Restore console.error
|
||||
console.error = originalConsoleError;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -34,8 +34,6 @@ export const GET: APIRoute = async ({ request }) => {
|
||||
if (isClosed) return;
|
||||
|
||||
try {
|
||||
console.log(`Polling for events for user ${userId} in channel ${channel}`);
|
||||
|
||||
// Get new events from SQLite
|
||||
const events = await getNewEvents({
|
||||
userId,
|
||||
@@ -43,8 +41,6 @@ export const GET: APIRoute = async ({ request }) => {
|
||||
lastEventTime,
|
||||
});
|
||||
|
||||
console.log(`Found ${events.length} new events`);
|
||||
|
||||
// Send events to client
|
||||
if (events.length > 0) {
|
||||
// Update last event time
|
||||
@@ -52,7 +48,6 @@ export const GET: APIRoute = async ({ request }) => {
|
||||
|
||||
// Send each event to the client
|
||||
for (const event of events) {
|
||||
console.log(`Sending event: ${JSON.stringify(event.payload)}`);
|
||||
sendMessage(`data: ${JSON.stringify(event.payload)}\n\n`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,13 @@ export interface ScheduleConfig {
|
||||
nextRun?: Date;
|
||||
}
|
||||
|
||||
export interface DatabaseCleanupConfig {
|
||||
enabled: boolean;
|
||||
retentionDays: number; // Actually stores seconds, but keeping the name for compatibility
|
||||
lastRun?: Date;
|
||||
nextRun?: Date;
|
||||
}
|
||||
|
||||
export interface GitHubConfig {
|
||||
username: string;
|
||||
token: string;
|
||||
@@ -34,6 +41,7 @@ export interface SaveConfigApiRequest {
|
||||
githubConfig: GitHubConfig;
|
||||
giteaConfig: GiteaConfig;
|
||||
scheduleConfig: ScheduleConfig;
|
||||
cleanupConfig: DatabaseCleanupConfig;
|
||||
}
|
||||
|
||||
export interface SaveConfigApiResponse {
|
||||
@@ -55,6 +63,7 @@ export interface ConfigApiResponse {
|
||||
githubConfig: GitHubConfig;
|
||||
giteaConfig: GiteaConfig;
|
||||
scheduleConfig: ScheduleConfig;
|
||||
cleanupConfig: DatabaseCleanupConfig;
|
||||
include: string[];
|
||||
exclude: string[];
|
||||
createdAt: Date;
|
||||
|
||||
Reference in New Issue
Block a user