Compare commits
148 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b604cad2fd | ||
|
|
983b47fa76 | ||
|
|
ea065d22a5 | ||
|
|
577d198e1a | ||
|
|
9ead32e9e6 | ||
|
|
993e7c1bc0 | ||
|
|
5b275a17e3 | ||
|
|
0caa327142 | ||
|
|
00d516a59d | ||
|
|
d68b822c76 | ||
|
|
b660d2dd9a | ||
|
|
4d8d75c8a6 | ||
|
|
e6c4ca0731 | ||
|
|
f03405b87a | ||
|
|
ce367e3761 | ||
|
|
cfe65cadca | ||
|
|
68108b8383 | ||
|
|
d2bec1d56e | ||
|
|
c9404f2674 | ||
|
|
80ef19c634 | ||
|
|
83c924566c | ||
|
|
7b58df375e | ||
|
|
1d27bd31d8 | ||
|
|
13d4257c4f | ||
|
|
818ba77693 | ||
|
|
056970e577 | ||
|
|
65ea73e238 | ||
|
|
398f00aceb | ||
|
|
50972713a3 | ||
|
|
fbf3033455 | ||
|
|
cc4d8dabbc | ||
|
|
8bba3d3521 | ||
|
|
be63555e5c | ||
|
|
32a906369f | ||
|
|
064474fd13 | ||
|
|
2ac933b599 | ||
|
|
403fe08bae | ||
|
|
23c7ff7349 | ||
|
|
3169af44cb | ||
|
|
c1d93dbbc6 | ||
|
|
047719cde9 | ||
|
|
13d4b03541 | ||
|
|
f07ae220b0 | ||
|
|
01647445f2 | ||
|
|
13cbf86309 | ||
|
|
792096d209 | ||
|
|
e94de5c9ca | ||
|
|
b3f42624d8 | ||
|
|
d79e4fecf4 | ||
|
|
eb78f959c7 | ||
|
|
51e536c317 | ||
|
|
7af1f6da17 | ||
|
|
c7e310b340 | ||
|
|
23cfa45d89 | ||
|
|
b1346e8c77 | ||
|
|
6e673249dc | ||
|
|
ee801f5d0e | ||
|
|
caf680d999 | ||
|
|
214599a5fd | ||
|
|
9e2285d614 | ||
|
|
7f7e510400 | ||
|
|
d1aa8810f7 | ||
|
|
bfa4b4034c | ||
|
|
8fbde95f92 | ||
|
|
00fb66baa7 | ||
|
|
5fec1e6a58 | ||
|
|
2ec55c6070 | ||
|
|
546bda8514 | ||
|
|
d05847dfe8 | ||
|
|
6551ea719c | ||
|
|
ae57b1b320 | ||
|
|
4d3ad2a337 | ||
|
|
e9c12bb9ff | ||
|
|
42314ab0e3 | ||
|
|
1be53bfa87 | ||
|
|
e8d48376a0 | ||
|
|
0cdb386f56 | ||
|
|
7456fe3fae | ||
|
|
f4df7c3d19 | ||
|
|
544b60f881 | ||
|
|
2eda800a7c | ||
|
|
51de51baa0 | ||
|
|
0d60c2fdf1 | ||
|
|
df8dac0e9b | ||
|
|
8e0c31fbb9 | ||
|
|
2c815b13f0 | ||
|
|
bbd49d7d52 | ||
|
|
8f62da4572 | ||
|
|
0f671a4088 | ||
|
|
108408be81 | ||
|
|
e24b856416 | ||
|
|
612805f030 | ||
|
|
7705dffee0 | ||
|
|
3dceb34174 | ||
|
|
6b747ba891 | ||
|
|
ddd67faeab | ||
|
|
832b57538d | ||
|
|
415bff8e41 | ||
|
|
13c3ddea04 | ||
|
|
b917b30830 | ||
|
|
b34ed5595b | ||
|
|
cbc11155ef | ||
|
|
941f61830f | ||
|
|
5b60cffaae | ||
|
|
ede5b4dbe8 | ||
|
|
99336e2607 | ||
|
|
cba421d606 | ||
|
|
c4b9a82806 | ||
|
|
38e0fb33b9 | ||
|
|
22a4b71653 | ||
|
|
52568eda36 | ||
|
|
a84191f0a5 | ||
|
|
33829eda20 | ||
|
|
1e63fd2278 | ||
|
|
daf4ab6a93 | ||
|
|
4404af7d40 | ||
|
|
97ff8d190d | ||
|
|
3ff86de67d | ||
|
|
3d8bdff9af | ||
|
|
a28a766f8b | ||
|
|
7afe364a24 | ||
|
|
a4e771d3bd | ||
|
|
703156b15c | ||
|
|
20a771f340 | ||
|
|
d925b3c155 | ||
|
|
47e1c7b493 | ||
|
|
d7ce2a6908 | ||
|
|
4efe741c64 | ||
|
|
773842fa72 | ||
|
|
90944a40c6 | ||
|
|
f436737efb | ||
|
|
a4e4afdaaf | ||
|
|
cbef04d4b4 | ||
|
|
a988be1028 | ||
|
|
98610482ae | ||
|
|
546db472e5 | ||
|
|
70b3e412ad | ||
|
|
a3ac31795c | ||
|
|
f41fb9b91f | ||
|
|
0b568a3b37 | ||
|
|
a1da82a718 | ||
|
|
645d495e80 | ||
|
|
0890ed0bb8 | ||
|
|
fc985f29df | ||
|
|
7d32112369 | ||
|
|
3bb85a4cdb | ||
|
|
30182544ba | ||
|
|
fb73f33aeb |
4
.claude/commands/new_release.md
Normal file
@@ -0,0 +1,4 @@
|
||||
Evaluate all the updates being made.
|
||||
Update CHANGELOG.md
|
||||
Use the chnages in the git log to determine if its a major, minor or a patch release.
|
||||
Update the package.json first before you push the tag.
|
||||
@@ -19,6 +19,7 @@ JWT_SECRET=change-this-to-a-secure-random-string-in-production
|
||||
# SKIP_FORKS=false
|
||||
# PRIVATE_REPOSITORIES=false
|
||||
# MIRROR_ISSUES=false
|
||||
# MIRROR_WIKI=false
|
||||
# MIRROR_STARRED=false
|
||||
# MIRROR_ORGANIZATIONS=false
|
||||
# PRESERVE_ORG_STRUCTURE=false
|
||||
@@ -30,3 +31,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
|
||||
|
||||
BIN
.github/assets/activity.png
vendored
|
Before Width: | Height: | Size: 140 KiB After Width: | Height: | Size: 816 KiB |
BIN
.github/assets/configuration.png
vendored
|
Before Width: | Height: | Size: 164 KiB After Width: | Height: | Size: 891 KiB |
BIN
.github/assets/dashboard.png
vendored
|
Before Width: | Height: | Size: 194 KiB After Width: | Height: | Size: 943 KiB |
BIN
.github/assets/logo-no-bg.png
vendored
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
BIN
.github/assets/organisations.png
vendored
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 784 KiB |
BIN
.github/assets/repositories.png
vendored
|
Before Width: | Height: | Size: 170 KiB After Width: | Height: | Size: 970 KiB |
4
.github/workflows/astro-build-test.yml
vendored
@@ -12,6 +12,10 @@ on:
|
||||
- 'README.md'
|
||||
- 'docs/**'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
|
||||
jobs:
|
||||
build-and-test:
|
||||
name: Build and Test Astro Project
|
||||
|
||||
129
.github/workflows/docker-build.yml
vendored
@@ -1,14 +1,29 @@
|
||||
name: Build and Push Docker Images
|
||||
name: Docker Build, Push & Security Scan
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ['v*']
|
||||
paths:
|
||||
- 'Dockerfile'
|
||||
- '.dockerignore'
|
||||
- 'package.json'
|
||||
- 'bun.lock*'
|
||||
- '.github/workflows/docker-build.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'Dockerfile'
|
||||
- '.dockerignore'
|
||||
- 'package.json'
|
||||
- 'bun.lock*'
|
||||
- '.github/workflows/docker-build.yml'
|
||||
schedule:
|
||||
- cron: '0 0 * * 0' # Weekly security scan on Sunday at midnight
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE: ${{ github.repository }}
|
||||
SHA: ${{ github.event.pull_request.head.sha || github.event.after }}
|
||||
|
||||
jobs:
|
||||
docker:
|
||||
@@ -17,19 +32,37 @@ jobs:
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
security-events: write
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ env.SHA }}
|
||||
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
driver-opts: network=host
|
||||
|
||||
- uses: docker/login-action@v3
|
||||
- name: Log into registry
|
||||
uses: docker/login-action@v3
|
||||
if: github.event_name != 'pull_request'
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Login to Docker Hub for Docker Scout (optional - provides better vulnerability data)
|
||||
# Add DOCKERHUB_USERNAME and DOCKERHUB_TOKEN secrets to enable this
|
||||
- name: Log into Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
continue-on-error: true
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
# Extract version from tag if present
|
||||
- name: Extract version from tag
|
||||
id: tag_version
|
||||
@@ -42,12 +75,88 @@ jobs:
|
||||
echo "No version tag, using 'latest'"
|
||||
fi
|
||||
|
||||
- uses: docker/build-push-action@v5
|
||||
# Extract metadata for Docker
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE }}
|
||||
labels: |
|
||||
org.opencontainers.image.revision=${{ env.SHA }}
|
||||
tags: |
|
||||
type=edge,branch=$repo.default_branch
|
||||
type=semver,pattern=v{{version}}
|
||||
type=sha,prefix=,suffix=,format=short
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=raw,value=${{ steps.tag_version.outputs.VERSION }}
|
||||
|
||||
# Build and push Docker image
|
||||
- name: Build and push Docker image
|
||||
id: build-and-push
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
platforms: ${{ github.event_name == 'pull_request' && 'linux/amd64' || 'linux/amd64,linux/arm64' }}
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE }}:latest
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE }}:${{ github.sha }}
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE }}:${{ steps.tag_version.outputs.VERSION }}
|
||||
load: ${{ github.event_name == 'pull_request' }}
|
||||
tags: ${{ github.event_name == 'pull_request' && 'gitea-mirror:scan' || steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
# Wait for image to be available in registry
|
||||
- name: Wait for image availability
|
||||
if: github.event_name != 'pull_request'
|
||||
run: |
|
||||
echo "Waiting for image to be available in registry..."
|
||||
sleep 5
|
||||
|
||||
# Docker Scout comprehensive security analysis
|
||||
- name: Docker Scout - Vulnerability Analysis & Recommendations
|
||||
uses: docker/scout-action@v1
|
||||
if: github.event_name != 'pull_request'
|
||||
with:
|
||||
command: cves,recommendations
|
||||
image: ${{ env.REGISTRY }}/${{ env.IMAGE }}:latest
|
||||
sarif-file: scout-results.sarif
|
||||
summary: true
|
||||
exit-code: false
|
||||
only-severities: critical,high
|
||||
write-comment: true
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Docker Scout for Pull Requests (using local image)
|
||||
- name: Docker Scout - Vulnerability Analysis (PR)
|
||||
uses: docker/scout-action@v1
|
||||
if: github.event_name == 'pull_request'
|
||||
with:
|
||||
command: cves,recommendations
|
||||
image: local://gitea-mirror:scan
|
||||
sarif-file: scout-results.sarif
|
||||
summary: true
|
||||
exit-code: false
|
||||
only-severities: critical,high
|
||||
write-comment: true
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Compare to latest for PRs and pushes
|
||||
- name: Docker Scout - Compare to Latest
|
||||
uses: docker/scout-action@v1
|
||||
if: github.event_name == 'pull_request'
|
||||
with:
|
||||
command: compare
|
||||
image: local://gitea-mirror:scan
|
||||
to: ${{ env.REGISTRY }}/${{ env.IMAGE }}:latest
|
||||
ignore-unchanged: true
|
||||
only-severities: critical,high
|
||||
write-comment: true
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Upload security scan results to GitHub Security tab
|
||||
- name: Upload Docker Scout scan results to GitHub Security tab
|
||||
uses: github/codeql-action/upload-sarif@v3
|
||||
if: always()
|
||||
continue-on-error: true
|
||||
with:
|
||||
sarif_file: scout-results.sarif
|
||||
|
||||
|
||||
53
.github/workflows/docker-scan.yml
vendored
@@ -1,53 +0,0 @@
|
||||
name: Docker Security Scan
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
paths:
|
||||
- 'Dockerfile'
|
||||
- '.dockerignore'
|
||||
- 'package.json'
|
||||
- 'bun.lock*'
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
paths:
|
||||
- 'Dockerfile'
|
||||
- '.dockerignore'
|
||||
- 'package.json'
|
||||
- 'bun.lock*'
|
||||
schedule:
|
||||
- cron: '0 0 * * 0' # Run weekly on Sunday at midnight
|
||||
|
||||
jobs:
|
||||
scan:
|
||||
name: Scan Docker Image
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
driver-opts: network=host
|
||||
|
||||
- name: Build Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: false
|
||||
load: true
|
||||
tags: gitea-mirror:scan
|
||||
# Disable GitHub Actions cache for this workflow
|
||||
no-cache: true
|
||||
|
||||
- name: Run Trivy vulnerability scanner
|
||||
uses: aquasecurity/trivy-action@master
|
||||
with:
|
||||
image-ref: gitea-mirror:scan
|
||||
format: 'table'
|
||||
exit-code: '1'
|
||||
ignore-unfixed: true
|
||||
vuln-type: 'os,library'
|
||||
severity: 'CRITICAL,HIGH'
|
||||
202
CHANGELOG.md
@@ -5,6 +5,208 @@ All notable changes to the Gitea Mirror project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [2.20.0] - 2025-07-07
|
||||
|
||||
### Changed
|
||||
- **BREAKING**: Repository moved from `arunavo4/gitea-mirror` to `RayLabsHQ/gitea-mirror`
|
||||
- Docker images now hosted at `ghcr.io/raylabshq/gitea-mirror`
|
||||
- Updated all repository references and links to new organization
|
||||
- License changed from MIT to GNU General Public License v3.0
|
||||
|
||||
### Fixed
|
||||
- Updated GitHub API endpoint for version checking to use new repository location
|
||||
- Corrected all documentation references to point to RayLabsHQ organization
|
||||
|
||||
### Security
|
||||
- Removed test security script after confirming vulnerability resolution
|
||||
- Updated base Docker image to version 1.2.18-alpine
|
||||
|
||||
### Documentation
|
||||
- Added repository migration notice in README
|
||||
- Updated quickstart guide with new repository URLs
|
||||
- Updated LXC deployment documentation with new repository location
|
||||
|
||||
## [2.18.0] - 2025-06-24
|
||||
|
||||
### Added
|
||||
- Fourth organization strategy "Mixed Mode" that combines aspects of existing strategies
|
||||
- Personal repositories go to a single configurable organization
|
||||
- Organization repositories preserve their GitHub organization structure
|
||||
- "Override Options" info button in Organization Strategy component explaining customization features
|
||||
- Organization overrides via edit buttons on organization cards
|
||||
- Repository overrides via inline destination editor
|
||||
- Starred repositories behavior and priority hierarchy
|
||||
|
||||
### Improved
|
||||
- Simplified mixed strategy implementation to reuse existing database fields
|
||||
- Enhanced organization strategy UI with comprehensive override documentation
|
||||
- Better visual indicators for the new mixed strategy with orange color theme
|
||||
|
||||
## [2.17.0] - 2025-06-24
|
||||
|
||||
### Added
|
||||
- Custom destination control for individual repositories with inline editing
|
||||
- Organization-level destination overrides with visual destination editor
|
||||
- Personal repositories organization override configuration option
|
||||
- Visual indicators for starred repositories (⭐ icon) in repository list
|
||||
- Repository-level destination override API endpoint
|
||||
- Destination customization priority hierarchy system
|
||||
- "View on Gitea" buttons for organizations with smart tooltip states
|
||||
|
||||
### Changed
|
||||
- Enhanced repository table with destination column showing both GitHub org and Gitea destination
|
||||
- Updated organization cards to display custom destinations with visual indicators
|
||||
- Improved getGiteaRepoOwnerAsync to support repository-level destination overrides
|
||||
|
||||
### Improved
|
||||
- Better visual feedback for custom destinations with badges and inline editing
|
||||
- Enhanced user experience with hover-based edit buttons
|
||||
- Comprehensive destination customization documentation in README
|
||||
|
||||
## [2.16.3] - 2025-06-20
|
||||
|
||||
### Added
|
||||
- Custom 404 error page with helpful navigation links
|
||||
- HoverCard components for better UX in configuration forms
|
||||
|
||||
### Improved
|
||||
- Replaced popover components with hover cards for information tooltips
|
||||
- Enhanced user experience with responsive hover interactions
|
||||
|
||||
## [2.16.2] - 2025-06-17
|
||||
|
||||
### Added
|
||||
- Bulk actions for repository management with selection support
|
||||
|
||||
### Improved
|
||||
- Enhanced organization card display with status badges and improved layout
|
||||
|
||||
## [2.16.1] - 2025-06-17
|
||||
|
||||
### Improved
|
||||
- Improved repository owner handling and mirror strategy in Gitea integration
|
||||
- Updated label for starred repositories organization for consistency
|
||||
|
||||
## [2.16.0] - 2025-06-17
|
||||
|
||||
### Added
|
||||
- Enhanced OrganizationConfiguration component with improved layout and metadata options
|
||||
- New GitHubMirrorSettings component with better organization and flexibility
|
||||
- Enhanced starred repositories content selection and improved layout
|
||||
|
||||
### Improved
|
||||
- Enhanced configuration interface layout and spacing across multiple components
|
||||
- Streamlined OrganizationStrategy component with cleaner imports and better organization
|
||||
- Improved responsive layout for larger screens in configuration forms
|
||||
- Better icon usage and clarity in configuration components
|
||||
- Enhanced tooltip descriptions and component organization
|
||||
- Improved version comparison logic in health API
|
||||
- Enhanced issue mirroring logic for starred repositories
|
||||
|
||||
### Fixed
|
||||
- Fixed mirror to single organization functionality
|
||||
- Resolved organization strategy layout issues
|
||||
- Cleaned up unused imports across multiple components
|
||||
|
||||
### Refactored
|
||||
- Simplified component structures by removing unused imports and dependencies
|
||||
- Enhanced layout flexibility in GitHubConfigForm and GiteaConfigForm components
|
||||
- Improved component organization and code clarity
|
||||
- Removed ConnectionsForm and useMirror hook for better code organization
|
||||
|
||||
## [2.14.0] - 2025-06-17
|
||||
|
||||
### Added
|
||||
- Enhanced UI components with @radix-ui/react-accordion dependency for improved configuration interface
|
||||
|
||||
### Fixed
|
||||
- Mirror strategies now properly route repositories based on selected strategy
|
||||
- Starred repositories now correctly go to the designated starred repos organization
|
||||
- Organization routing for single-org and flat-user strategies
|
||||
|
||||
### Improved
|
||||
- Documentation now explains all three mirror strategies (preserve, single-org, flat-user)
|
||||
- Added detailed mirror strategy configuration guide
|
||||
- Updated CLAUDE.md with mirror strategy architecture information
|
||||
- Enhanced Docker Compose development configuration
|
||||
|
||||
## [2.13.2] - 2025-06-15
|
||||
|
||||
### Improved
|
||||
- Enhanced documentation design and layout
|
||||
- Updated README with improved formatting and content
|
||||
|
||||
## [2.13.1] - 2025-06-15
|
||||
|
||||
### Added
|
||||
- Docker Hub authentication for Docker Scout security scanning
|
||||
- Comprehensive Docker workflow consolidation with build, push & security scan
|
||||
|
||||
### Improved
|
||||
- Enhanced CI/CD pipeline reliability with better error handling
|
||||
- Updated Bun base image to latest version for improved security
|
||||
- Migrated from Trivy to Docker Scout for more comprehensive security scanning
|
||||
- Enhanced Docker workflow with wait steps for image availability
|
||||
|
||||
### Fixed
|
||||
- Docker Scout action integration issues and image reference problems
|
||||
- Workflow reliability improvements with proper error handling
|
||||
- Security scanning workflow now continues on security issues without failing the build
|
||||
|
||||
### Changed
|
||||
- Updated package dependencies to latest versions
|
||||
- Consolidated multiple Docker workflows into single comprehensive workflow
|
||||
- Enhanced security scanning with Docker Scout integration
|
||||
|
||||
## [2.13.0] - 2025-06-15
|
||||
|
||||
### Added
|
||||
- Enhanced Configuration Interface with collapsible components and improved organization strategy UI
|
||||
- Wiki Mirroring Support in configuration settings
|
||||
- Auto-Save Functionality for all config forms, eliminating manual save buttons
|
||||
- Live Refresh functionality with configuration status hooks and enhanced UI components
|
||||
- Enhanced API Config Handling with mapping functions for UI and database structures
|
||||
- Secure Error Responses with createSecureErrorResponse for consistent error handling
|
||||
- Automatic Database Cleanup feature with configuration options and API support
|
||||
- Enhanced Job Recovery with improved database schema and recovery mechanisms
|
||||
- Fork tags to repository UI and enhanced organization cards with repository breakdown
|
||||
- Skeleton loaders and better loading state management across the application
|
||||
|
||||
### Improved
|
||||
- Navigation context and component loading states across the application
|
||||
- Card components alignment and styling consistency
|
||||
- Error logging and structured error message parsing
|
||||
- HTTP client standardization across the application
|
||||
- Database initialization and management processes
|
||||
- Visual consistency with updated icons and custom logo integration
|
||||
|
||||
### Fixed
|
||||
- Repository mirroring status inconsistencies
|
||||
- Organizations getting stuck on mirroring status when empty
|
||||
- JSON parsing errors and improved error handling
|
||||
- Broken documentation links in README
|
||||
- Various UI contrast and alignment issues
|
||||
|
||||
### Changed
|
||||
- Migrated testing framework to Bun and updated test configurations
|
||||
- Implemented graceful shutdown and enhanced job recovery capabilities
|
||||
- Replaced SiGitea icons with custom logo
|
||||
- Updated various dependencies for improved stability and performance
|
||||
|
||||
## [2.12.0] - 2025-01-27
|
||||
|
||||
### Fixed
|
||||
- Fixed SQLite "no such table: mirror_jobs" error during application startup
|
||||
- Implemented automatic database table creation during database initialization
|
||||
- Resolved database schema inconsistencies between development and production environments
|
||||
|
||||
### Improved
|
||||
- Enhanced database initialization process with automatic table creation and indexing
|
||||
- Added comprehensive error handling for database table creation
|
||||
- Integrated database repair functionality into application startup for better reliability
|
||||
|
||||
## [2.5.3] - 2025-05-22
|
||||
|
||||
### Added
|
||||
|
||||
128
CLAUDE.md
Normal file
@@ -0,0 +1,128 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Gitea Mirror is a web application that automatically mirrors repositories from GitHub to self-hosted Gitea instances. It uses Astro for SSR, React for UI, SQLite for data storage, and Bun as the JavaScript runtime.
|
||||
|
||||
## Essential Commands
|
||||
|
||||
### Development
|
||||
```bash
|
||||
bun run dev # Start development server (port 3000)
|
||||
bun run build # Build for production
|
||||
bun run preview # Preview production build
|
||||
```
|
||||
|
||||
### Testing
|
||||
```bash
|
||||
bun test # Run all tests
|
||||
bun test:watch # Run tests in watch mode
|
||||
bun test:coverage # Run tests with coverage
|
||||
```
|
||||
|
||||
### Database Management
|
||||
```bash
|
||||
bun run init-db # Initialize database
|
||||
bun run reset-users # Reset user accounts (development)
|
||||
bun run cleanup-db # Remove database files
|
||||
```
|
||||
|
||||
### Production
|
||||
```bash
|
||||
bun run start # Start production server
|
||||
```
|
||||
|
||||
## Architecture & Key Concepts
|
||||
|
||||
### Technology Stack
|
||||
- **Frontend**: Astro (SSR) + React + Tailwind CSS v4 + Shadcn UI
|
||||
- **Backend**: Bun runtime + SQLite + Drizzle ORM
|
||||
- **APIs**: GitHub (Octokit) and Gitea APIs
|
||||
- **Auth**: JWT tokens with bcryptjs password hashing
|
||||
|
||||
### Project Structure
|
||||
- `/src/pages/api/` - API endpoints (Astro API routes)
|
||||
- `/src/components/` - React components organized by feature
|
||||
- `/src/lib/db/` - Database queries and schema (Drizzle ORM)
|
||||
- `/src/hooks/` - Custom React hooks for data fetching
|
||||
- `/data/` - SQLite database storage location
|
||||
|
||||
### Key Architectural Patterns
|
||||
|
||||
1. **API Routes**: All API endpoints follow the pattern `/api/[resource]/[action]` and use `createSecureErrorResponse` for consistent error handling:
|
||||
```typescript
|
||||
import { createSecureErrorResponse } from '@/lib/utils/error-handler';
|
||||
|
||||
export async function POST({ request }: APIContext) {
|
||||
try {
|
||||
// Implementation
|
||||
} catch (error) {
|
||||
return createSecureErrorResponse(error);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2. **Database Queries**: Located in `/src/lib/db/queries/` organized by domain (users, repositories, etc.)
|
||||
|
||||
3. **Real-time Updates**: Server-Sent Events (SSE) endpoint at `/api/events` for live dashboard updates
|
||||
|
||||
4. **Authentication Flow**:
|
||||
- First user signup creates admin account
|
||||
- JWT tokens stored in cookies
|
||||
- Protected routes check auth via `getUserFromCookie()`
|
||||
|
||||
5. **Mirror Process**:
|
||||
- Discovers repos from GitHub (user/org)
|
||||
- Creates/updates mirror in Gitea
|
||||
- Tracks status in database
|
||||
- Supports scheduled automatic mirroring
|
||||
|
||||
6. **Mirror Strategies**: Three ways to organize repositories in Gitea:
|
||||
- **preserve**: Maintains GitHub structure (default)
|
||||
- **single-org**: All repos go to one organization
|
||||
- **flat-user**: All repos go under user account
|
||||
- Starred repos always go to separate organization (starredReposOrg)
|
||||
- Routing logic in `getGiteaRepoOwner()` function
|
||||
|
||||
### Database Schema (SQLite)
|
||||
- `users` - User accounts and authentication
|
||||
- `configs` - GitHub/Gitea connection settings
|
||||
- `repositories` - Repository mirror status and metadata
|
||||
- `organizations` - Organization structure preservation
|
||||
- `mirror_jobs` - Scheduled mirror operations
|
||||
- `events` - Activity log and notifications
|
||||
|
||||
### Testing Approach
|
||||
- Uses Bun's native test runner (`bun:test`)
|
||||
- Test files use `.test.ts` or `.test.tsx` extension
|
||||
- Setup file at `/src/tests/setup.bun.ts`
|
||||
- Mock utilities available for API testing.
|
||||
|
||||
### Development Tips
|
||||
- Environment variables in `.env` (copy from `.env.example`)
|
||||
- JWT_SECRET auto-generated if not provided
|
||||
- Database auto-initializes on first run
|
||||
- Use `bun run dev:clean` for fresh database start
|
||||
- Tailwind CSS v4 configured with Vite plugin
|
||||
|
||||
### Common Tasks
|
||||
|
||||
**Adding a new API endpoint:**
|
||||
1. Create file in `/src/pages/api/[resource]/[action].ts`
|
||||
2. Use `createSecureErrorResponse` for error handling
|
||||
3. Add corresponding database query in `/src/lib/db/queries/`
|
||||
4. Update types in `/src/types/` if needed
|
||||
|
||||
**Adding a new component:**
|
||||
1. Create in appropriate `/src/components/[feature]/` directory
|
||||
2. Use Shadcn UI components from `/src/components/ui/`
|
||||
3. Follow existing naming patterns (e.g., `RepositoryCard`, `ConfigTabs`)
|
||||
|
||||
**Modifying database schema:**
|
||||
1. Update schema in `/src/lib/db/schema.ts`
|
||||
2. Run `bun run init-db` to recreate database
|
||||
3. Update related queries in `/src/lib/db/queries/`
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# syntax=docker/dockerfile:1.4
|
||||
|
||||
FROM oven/bun:1.2.9-alpine AS base
|
||||
FROM oven/bun:1.2.18-alpine AS base
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache libc6-compat python3 make g++ gcc wget sqlite openssl
|
||||
|
||||
|
||||
632
LICENSE
@@ -1,21 +1,619 @@
|
||||
MIT License
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (c) 2025 ARUNAVO RAY
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
Preamble
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
229
README.md
@@ -1,27 +1,37 @@
|
||||
<p align="center">
|
||||
<img src=".github/assets/logo.png" alt="Gitea Mirror Logo" width="120" />
|
||||
<img src=".github/assets/logo-no-bg.png" alt="Gitea Mirror Logo" width="120" />
|
||||
<h1>Gitea Mirror</h1>
|
||||
<p><i>A modern web app for automatically mirroring repositories from GitHub to your self-hosted Gitea.</i></p>
|
||||
<p align="center">
|
||||
<a href="https://github.com/arunavo4/gitea-mirror/releases/latest"><img src="https://img.shields.io/github/v/tag/arunavo4/gitea-mirror?label=release" alt="release"/></a>
|
||||
<a href="https://github.com/arunavo4/gitea-mirror/actions/workflows/astro-build-test.yml"><img src="https://img.shields.io/github/actions/workflow/status/arunavo4/gitea-mirror/astro-build-test.yml?branch=main" alt="build"/></a>
|
||||
<a href="https://github.com/arunavo4/gitea-mirror/pkgs/container/gitea-mirror"><img src="https://img.shields.io/badge/ghcr.io-container-blue?logo=github" alt="container"/></a>
|
||||
<a href="https://github.com/arunavo4/gitea-mirror/blob/main/LICENSE"><img src="https://img.shields.io/github/license/arunavo4/gitea-mirror" alt="license"/></a>
|
||||
<a href="https://github.com/RayLabsHQ/gitea-mirror/releases/latest"><img src="https://img.shields.io/github/v/tag/RayLabsHQ/gitea-mirror?label=release" alt="release"/></a>
|
||||
<a href="https://github.com/RayLabsHQ/gitea-mirror/actions/workflows/astro-build-test.yml"><img src="https://img.shields.io/github/actions/workflow/status/RayLabsHQ/gitea-mirror/astro-build-test.yml?branch=main" alt="build"/></a>
|
||||
<a href="https://github.com/RayLabsHQ/gitea-mirror/pkgs/container/gitea-mirror"><img src="https://img.shields.io/badge/ghcr.io-container-blue?logo=github" alt="container"/></a>
|
||||
<a href="https://github.com/RayLabsHQ/gitea-mirror/blob/main/LICENSE"><img src="https://img.shields.io/github/license/RayLabsHQ/gitea-mirror" alt="license"/></a>
|
||||
</p>
|
||||
</p>
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **Repository Migration Notice**: Starting from version 2.20, this project has moved from `arunavo4/gitea-mirror` to `RayLabsHQ/gitea-mirror`. Please update your Docker images and Git remotes to use the new location:
|
||||
> ```bash
|
||||
> # Docker
|
||||
> docker pull ghcr.io/raylabshq/gitea-mirror:latest
|
||||
>
|
||||
> # Git remote
|
||||
> git remote set-url origin https://github.com/RayLabsHQ/gitea-mirror.git
|
||||
> ```
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
```bash
|
||||
# Using Docker (recommended)
|
||||
docker compose --profile production up -d
|
||||
docker compose up -d
|
||||
|
||||
# Using Bun
|
||||
bun run setup && bun run dev
|
||||
|
||||
# Using LXC Containers
|
||||
# For Proxmox VE (online) - Community script by Tobias ([CrazyWolf13](https://github.com/CrazyWolf13))
|
||||
curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVED/main/install/gitea-mirror-install.sh | bash
|
||||
bash -c "$(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/ct/gitea-mirror.sh)"
|
||||
|
||||
# For local testing (offline-friendly)
|
||||
sudo LOCAL_REPO_DIR=~/Development/gitea-mirror ./scripts/gitea-mirror-lxc-local.sh
|
||||
@@ -30,15 +40,16 @@ sudo LOCAL_REPO_DIR=~/Development/gitea-mirror ./scripts/gitea-mirror-lxc-local.
|
||||
See the [LXC Container Deployment Guide](scripts/README-lxc.md).
|
||||
|
||||
<p align="center">
|
||||
<img src=".github/assets/dashboard.png" alt="Dashboard" width="80%"/>
|
||||
<img src=".github/assets/dashboard.png" alt="Dashboard" width="full"/>
|
||||
</p>
|
||||
|
||||
## ✨ Features
|
||||
|
||||
- 🔁 Sync public, private, or starred GitHub repos to Gitea
|
||||
- 🏢 Mirror entire organizations with structure preservation
|
||||
- 🏢 Mirror entire organizations with flexible organization strategies
|
||||
- 🎯 Custom destination control for both organizations and individual repositories
|
||||
- 🐞 Optional mirroring of issues and labels
|
||||
- 🌟 Mirror your starred repositories
|
||||
- 🌟 Mirror your starred repositories to a dedicated organization
|
||||
- 🕹️ Modern user interface with toast notifications and smooth experience
|
||||
- 🧠 Smart filtering and job queue with detailed logs
|
||||
- 🛠️ Works with personal access tokens (GitHub + Gitea)
|
||||
@@ -50,12 +61,12 @@ See the [LXC Container Deployment Guide](scripts/README-lxc.md).
|
||||
## 📸 Screenshots
|
||||
|
||||
<p align="center">
|
||||
<img src=".github/assets/repositories.png" width="45%"/>
|
||||
<img src=".github/assets/organisations.png" width="45%"/>
|
||||
<img src=".github/assets/repositories.png" width="49%"/>
|
||||
<img src=".github/assets/organisations.png" width="49%"/>
|
||||
</p>
|
||||
<p align="center">
|
||||
<img src=".github/assets/configuration.png" width="45%"/>
|
||||
<img src=".github/assets/activity.png" width="45%"/>
|
||||
<img src=".github/assets/configuration.png" width="49%"/>
|
||||
<img src=".github/assets/activity.png" width="49%"/>
|
||||
</p>
|
||||
|
||||
### Dashboard
|
||||
@@ -69,7 +80,7 @@ Easily configure your GitHub and Gitea connections, set up automatic mirroring s
|
||||
|
||||
## Getting Started
|
||||
|
||||
See the [Quick Start Guide](docs/quickstart.md) for detailed instructions on getting up and running quickly.
|
||||
See the [Quick Start Guide](src/content/docs/quickstart.md) for detailed instructions on getting up and running quickly.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
@@ -115,7 +126,7 @@ Gitea Mirror provides multi-architecture Docker images that work on both ARM64 (
|
||||
|
||||
```bash
|
||||
# Start the application using Docker Compose
|
||||
docker compose --profile production up -d
|
||||
docker compose up -d
|
||||
|
||||
# For development mode (requires configuration)
|
||||
# Ensure you have run bun run setup first
|
||||
@@ -136,12 +147,12 @@ If you want to run the container directly without Docker Compose:
|
||||
|
||||
```bash
|
||||
# Pull the latest multi-architecture image
|
||||
docker pull ghcr.io/arunavo4/gitea-mirror:latest
|
||||
docker pull ghcr.io/RayLabsHQ/gitea-mirror:latest
|
||||
|
||||
# Run the application with a volume for persistent data
|
||||
docker run -d -p 4321:4321 \
|
||||
-v gitea-mirror-data:/app/data \
|
||||
ghcr.io/arunavo4/gitea-mirror:latest
|
||||
ghcr.io/RayLabsHQ/gitea-mirror:latest
|
||||
```
|
||||
|
||||
##### Building Docker Images Manually
|
||||
@@ -162,41 +173,32 @@ cp .env.example .env
|
||||
./scripts/build-docker.sh --push
|
||||
|
||||
# Then run with Docker Compose
|
||||
docker compose --profile production up -d
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
See [Docker build documentation](./scripts/README-docker.md) for more details.
|
||||
|
||||
##### Using LXC Containers
|
||||
##### Using LXC Containers (Proxmox VE)
|
||||
|
||||
Gitea Mirror offers two deployment options for LXC containers:
|
||||
|
||||
**1. Proxmox VE (online, recommended for production)**
|
||||
For Proxmox VE users, Gitea Mirror can be deployed using the community-maintained script:
|
||||
|
||||
```bash
|
||||
# One-command installation on Proxmox VE
|
||||
# Uses the community-maintained script by Tobias ([CrazyWolf13](https://github.com/CrazyWolf13))
|
||||
# at [community-scripts/ProxmoxVED](https://github.com/community-scripts/ProxmoxVED)
|
||||
curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVED/main/install/gitea-mirror-install.sh | bash
|
||||
bash -c "$(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/ct/gitea-mirror.sh)"
|
||||
```
|
||||
|
||||
**2. Local testing (offline-friendly, works on developer laptops)**
|
||||
This community script:
|
||||
- Creates a privileged Alpine Linux LXC container
|
||||
- Installs Bun runtime environment
|
||||
- Clones and builds Gitea Mirror
|
||||
- Configures a systemd service for automatic startup
|
||||
- Sets up the application to run on port 4321
|
||||
|
||||
```bash
|
||||
# Download the script
|
||||
curl -fsSL https://raw.githubusercontent.com/arunavo4/gitea-mirror/main/scripts/gitea-mirror-lxc-local.sh -o gitea-mirror-lxc-local.sh
|
||||
chmod +x gitea-mirror-lxc-local.sh
|
||||
> [!NOTE]
|
||||
> The script is maintained by the [Community Scripts for Proxmox VE](https://community-scripts.github.io/ProxmoxVE/) project.
|
||||
> For more information, visit the [Gitea Mirror script documentation](https://community-scripts.github.io/ProxmoxVE/scripts?id=gitea-mirror).
|
||||
|
||||
# Run with your local repo directory
|
||||
sudo LOCAL_REPO_DIR=~/Development/gitea-mirror ./gitea-mirror-lxc-local.sh
|
||||
```
|
||||
|
||||
Both scripts:
|
||||
- Set up a privileged Ubuntu 22.04 LXC container
|
||||
- Install Bun runtime environment
|
||||
- Build the application
|
||||
- Configure a systemd service
|
||||
- Start the service automatically
|
||||
After installation, access Gitea Mirror at `http://<container-ip>:4321`
|
||||
|
||||
The application includes a health check endpoint at `/api/health` for monitoring.
|
||||
|
||||
@@ -242,7 +244,7 @@ The Docker container can be configured with the following environment variables:
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/arunavo4/gitea-mirror.git
|
||||
git clone https://github.com/RayLabsHQ/gitea-mirror.git
|
||||
cd gitea-mirror
|
||||
|
||||
# Quick setup (installs dependencies and initializes the database)
|
||||
@@ -282,9 +284,39 @@ bun run reset-users
|
||||
bun run check-db
|
||||
```
|
||||
|
||||
##### Database Permissions for Direct Installation
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **If you're running the application directly** (not using Docker), you may encounter SQLite permission errors. This is because SQLite requires both read/write access to the database file and write access to the directory containing the database.
|
||||
|
||||
**Common Error:**
|
||||
```
|
||||
Error: [ERROR] SQLiteError: attempt to write a readonly database
|
||||
```
|
||||
|
||||
**Solution:**
|
||||
```bash
|
||||
# Ensure the data directory exists and has proper permissions
|
||||
mkdir -p data
|
||||
chmod 755 data
|
||||
|
||||
# If the database file already exists, ensure it's writable
|
||||
chmod 644 data/gitea-mirror.db
|
||||
|
||||
# Make sure the user running the application owns the data directory
|
||||
chown -R $(whoami) data/
|
||||
```
|
||||
|
||||
**Why Docker doesn't have this issue:**
|
||||
- Docker containers run with a dedicated user (`gitea-mirror`) that owns the `/app/data` directory
|
||||
- The container setup ensures proper permissions are set during image build
|
||||
- Volume mounts are handled by Docker with appropriate permissions
|
||||
|
||||
**Recommended approach:** Use Docker or Docker Compose for deployment to avoid permission issues entirely.
|
||||
|
||||
### Configuration
|
||||
|
||||
Gitea Mirror can be configured through environment variables or through the web UI. See the [Configuration Guide](docs/configuration.md) for more details.
|
||||
Gitea Mirror can be configured through environment variables or through the web UI. See the [Configuration Guide](src/content/docs/configuration.md) for more details.
|
||||
|
||||
Key configuration options include:
|
||||
|
||||
@@ -296,6 +328,57 @@ Key configuration options include:
|
||||
> [!IMPORTANT]
|
||||
> **SQLite is the only database required for Gitea Mirror**, handling both data storage and real-time event notifications.
|
||||
|
||||
### Mirror Strategies & Destination Customization
|
||||
|
||||
Gitea Mirror offers three flexible strategies for organizing your repositories in Gitea, with fine-grained control over destinations:
|
||||
|
||||
#### 1. **Preserve GitHub Structure** (Default)
|
||||
- Personal repositories → Your Gitea username (or custom organization)
|
||||
- Organization repositories → Same organization name in Gitea (with individual overrides)
|
||||
- Maintains the exact structure from GitHub with optional customization
|
||||
|
||||
#### 2. **Single Organization**
|
||||
- All repositories → One designated organization
|
||||
- Simplifies management by consolidating everything
|
||||
- Requires specifying a destination organization name
|
||||
|
||||
#### 3. **Flat User Structure**
|
||||
- All repositories → Your Gitea user account
|
||||
- No organizations needed
|
||||
- Simplest approach for personal use
|
||||
|
||||
#### Destination Customization
|
||||
|
||||
**Organization-Level Overrides:**
|
||||
- Click the edit button on any organization card to set a custom destination
|
||||
- All repositories from that GitHub organization will be mirrored to your specified Gitea organization
|
||||
- Visual indicators show when custom destinations are active
|
||||
|
||||
**Repository-Level Overrides:**
|
||||
- Fine-tune individual repository destinations in the repository table
|
||||
- Click the edit button in the "Destination" column to customize where a specific repo is mirrored
|
||||
- Overrides organization-level settings for maximum flexibility
|
||||
- Starred repositories display a ⭐ icon and always go to the configured starred repos organization
|
||||
|
||||
**Priority Hierarchy:**
|
||||
1. Starred repositories → Always go to `starredReposOrg` (not editable)
|
||||
2. Repository-level custom destination (highest priority for non-starred)
|
||||
3. Organization-level custom destination
|
||||
4. Personal repos override (for non-organization repos)
|
||||
5. Default strategy rules (lowest priority)
|
||||
|
||||
> [!NOTE]
|
||||
> **Starred Repositories**: Repositories you've starred on GitHub are automatically organized into a separate organization (default: "starred") and cannot have custom destinations. They're marked with a ⭐ icon for easy identification.
|
||||
|
||||
> [!TIP]
|
||||
> **Example Use Cases**:
|
||||
> - Mirror personal repos to `personal-archive` organization
|
||||
> - Redirect `work-org` repos to `company-mirror` in Gitea
|
||||
> - Override a single important repo to go to a special organization
|
||||
> - Keep `company-org` repos in their own `company-org` organization
|
||||
> - Override `community-scripts` to go to `community-mirrors` organization
|
||||
> - This gives you complete control while maintaining GitHub's structure as the default
|
||||
|
||||
## 🚀 Development
|
||||
|
||||
### Local Development Setup
|
||||
@@ -333,13 +416,13 @@ docker run -d \
|
||||
-e USER_UID=1000 \
|
||||
-e USER_GID=1000 \
|
||||
-e GITEA__database__DB_TYPE=sqlite3 \
|
||||
-e GITEA__database__PATH=/data/gitea.db \
|
||||
-e GITEA__database__PATH=/data/gitea/gitea.db \
|
||||
-e GITEA__server__DOMAIN=localhost \
|
||||
-e GITEA__server__ROOT_URL=http://localhost:3001/ \
|
||||
-e GITEA__server__SSH_DOMAIN=localhost \
|
||||
-e GITEA__server__SSH_PORT=2222 \
|
||||
-e GITEA__server__START_SSH_SERVER=true \
|
||||
-e GITEA__security__INSTALL_LOCK=true \
|
||||
-e GITEA__security__INSTALL_LOCK=false \
|
||||
-e GITEA__service__DISABLE_REGISTRATION=false \
|
||||
gitea/gitea:latest
|
||||
```
|
||||
@@ -366,7 +449,7 @@ docker run -d \
|
||||
-e GITEA_URL=http://gitea:3000 \
|
||||
-e GITEA_TOKEN=your-local-gitea-token \
|
||||
-e GITEA_USERNAME=your-local-gitea-username \
|
||||
arunavo4/gitea-mirror:latest
|
||||
RayLabsHQ/gitea-mirror:latest
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
@@ -467,21 +550,51 @@ Try the following steps:
|
||||
> # Run with a volume for persistent data storage
|
||||
> docker run -d -p 4321:4321 \
|
||||
> -v gitea-mirror-data:/app/data \
|
||||
> ghcr.io/arunavo4/gitea-mirror:latest
|
||||
> ghcr.io/RayLabsHQ/gitea-mirror:latest
|
||||
> ```
|
||||
>
|
||||
> For homelab/self-hosted setups, you can use the provided Docker Compose file with automatic event cleanup:
|
||||
> For homelab/self-hosted setups, you can use the standard Docker Compose file which includes automatic database cleanup:
|
||||
>
|
||||
> ```bash
|
||||
> # Clone the repository
|
||||
> git clone https://github.com/arunavo4/gitea-mirror.git
|
||||
> git clone https://github.com/RayLabsHQ/gitea-mirror.git
|
||||
> cd gitea-mirror
|
||||
>
|
||||
> # Start the application with Docker Compose
|
||||
> docker-compose -f docker-compose.homelab.yml up -d
|
||||
> docker compose up -d
|
||||
> ```
|
||||
>
|
||||
> This setup includes a cron job that runs daily to clean up old events and prevent the database from growing too large.
|
||||
> This setup provides a complete containerized deployment for the Gitea Mirror application.
|
||||
|
||||
#### Docker Volume Types and Permissions
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **Named Volumes vs Bind Mounts**: If you encounter SQLite permission errors even when using Docker, check your volume configuration:
|
||||
|
||||
**✅ Named Volumes (Recommended):**
|
||||
```yaml
|
||||
volumes:
|
||||
- gitea-mirror-data:/app/data # Docker manages permissions automatically
|
||||
```
|
||||
|
||||
**⚠️ Bind Mounts (Requires Manual Permission Setup):**
|
||||
```yaml
|
||||
volumes:
|
||||
- /host/path/to/data:/app/data # Host filesystem permissions apply
|
||||
```
|
||||
|
||||
**If using bind mounts**, ensure the host directory is owned by UID 1001 (the `gitea-mirror` user):
|
||||
```bash
|
||||
# Set correct ownership for bind mount
|
||||
sudo chown -R 1001:1001 /host/path/to/data
|
||||
sudo chmod -R 755 /host/path/to/data
|
||||
```
|
||||
|
||||
**Why named volumes work better:**
|
||||
- Docker automatically handles permissions
|
||||
- Better portability across different hosts
|
||||
- No manual permission setup required
|
||||
- Used by our official docker-compose.yml
|
||||
|
||||
|
||||
#### Database Maintenance
|
||||
@@ -498,20 +611,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
|
||||
> ```
|
||||
>
|
||||
> For automated maintenance, consider setting up a cron job to run the cleanup script periodically:
|
||||
>
|
||||
> ```bash
|
||||
> # Add this to your crontab (runs daily at 2 AM)
|
||||
> 0 2 * * * cd /path/to/gitea-mirror && bun run cleanup-events
|
||||
> ```
|
||||
> **Note:** For cleaning up old activities and events, use the cleanup button in the Activity Log page of the web interface.
|
||||
|
||||
|
||||
> [!NOTE]
|
||||
@@ -542,3 +644,4 @@ Try the following steps:
|
||||
- [Octokit](https://github.com/octokit/rest.js/) - GitHub REST API client for JavaScript
|
||||
- [Shadcn UI](https://ui.shadcn.com/) - For the beautiful UI components
|
||||
- [Astro](https://astro.build/) - For the excellent web framework
|
||||
- [Community Scripts](https://community-scripts.github.io/ProxmoxVE/) - For the Proxmox VE installation script maintained by [CrazyWolf13](https://github.com/CrazyWolf13)
|
||||
|
||||
4
crontab
@@ -1,4 +0,0 @@
|
||||
# Run event cleanup daily at 2 AM
|
||||
0 2 * * * cd /app && bun run cleanup-events 30 >> /app/data/cleanup-events.log 2>&1
|
||||
|
||||
# Empty line at the end is required for cron to work properly
|
||||
@@ -7,11 +7,12 @@ services:
|
||||
image: gitea/gitea:latest
|
||||
container_name: gitea
|
||||
restart: unless-stopped
|
||||
entrypoint: ["/tmp/gitea-dev-init.sh"]
|
||||
environment:
|
||||
- USER_UID=1000
|
||||
- USER_GID=1000
|
||||
- GITEA__database__DB_TYPE=sqlite3
|
||||
- GITEA__database__PATH=/data/gitea.db
|
||||
- GITEA__database__PATH=/data/gitea/gitea.db
|
||||
- GITEA__server__DOMAIN=localhost
|
||||
- GITEA__server__ROOT_URL=http://localhost:3001/
|
||||
- GITEA__server__SSH_DOMAIN=localhost
|
||||
@@ -19,20 +20,24 @@ services:
|
||||
- GITEA__server__START_SSH_SERVER=true
|
||||
- GITEA__security__INSTALL_LOCK=true
|
||||
- GITEA__service__DISABLE_REGISTRATION=false
|
||||
- GITEA__log__MODE=console
|
||||
- GITEA__log__LEVEL=Info
|
||||
ports:
|
||||
- "3001:3000"
|
||||
- "2222:22"
|
||||
volumes:
|
||||
- gitea-data:/data
|
||||
- gitea-config:/etc/gitea
|
||||
- ./scripts/gitea-app.ini:/tmp/app.ini:ro
|
||||
- ./scripts/gitea-dev-init.sh:/tmp/gitea-dev-init.sh:ro
|
||||
networks:
|
||||
- gitea-network
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 60s
|
||||
|
||||
# Development service connected to local Gitea
|
||||
gitea-mirror-dev:
|
||||
@@ -63,6 +68,7 @@ services:
|
||||
- SKIP_FORKS=${SKIP_FORKS:-false}
|
||||
- PRIVATE_REPOSITORIES=${PRIVATE_REPOSITORIES:-false}
|
||||
- MIRROR_ISSUES=${MIRROR_ISSUES:-false}
|
||||
- MIRROR_WIKI=${MIRROR_WIKI:-false}
|
||||
- MIRROR_STARRED=${MIRROR_STARRED:-false}
|
||||
- MIRROR_ORGANIZATIONS=${MIRROR_ORGANIZATIONS:-false}
|
||||
- PRESERVE_ORG_STRUCTURE=${PRESERVE_ORG_STRUCTURE:-false}
|
||||
@@ -75,7 +81,7 @@ services:
|
||||
- GITEA_ORG_VISIBILITY=${GITEA_ORG_VISIBILITY:-public}
|
||||
- DELAY=${DELAY:-3600}
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:4321/"]
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:4321/api/health"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
gitea-mirror:
|
||||
image: ghcr.io/arunavo4/gitea-mirror:latest
|
||||
container_name: gitea-mirror
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "4321:4321"
|
||||
volumes:
|
||||
- gitea-mirror-data:/app/data
|
||||
# Mount the crontab file
|
||||
- ./crontab:/etc/cron.d/gitea-mirror-cron
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- HOST=0.0.0.0
|
||||
- PORT=4321
|
||||
- DATABASE_URL=sqlite://data/gitea-mirror.db
|
||||
- DELAY=${DELAY:-3600}
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:4321/api/health"]
|
||||
interval: 1m
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
# Install cron in the container and set up the cron job
|
||||
command: >
|
||||
sh -c "
|
||||
apt-get update && apt-get install -y cron curl &&
|
||||
chmod 0644 /etc/cron.d/gitea-mirror-cron &&
|
||||
crontab /etc/cron.d/gitea-mirror-cron &&
|
||||
service cron start &&
|
||||
bun dist/server/entry.mjs
|
||||
"
|
||||
|
||||
# Define named volumes for database persistence
|
||||
volumes:
|
||||
gitea-mirror-data: # Database volume
|
||||
@@ -1,10 +1,9 @@
|
||||
# Gitea Mirror deployment configuration
|
||||
# - production: Standard deployment with real data
|
||||
# Standard deployment with automatic database maintenance
|
||||
|
||||
services:
|
||||
# Production service with real data
|
||||
gitea-mirror:
|
||||
image: ${DOCKER_REGISTRY:-ghcr.io}/${DOCKER_IMAGE:-arunavo4/gitea-mirror}:${DOCKER_TAG:-latest}
|
||||
image: ${DOCKER_REGISTRY:-ghcr.io}/${DOCKER_IMAGE:-raylabshq/gitea-mirror}:${DOCKER_TAG:-latest}
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
@@ -12,7 +11,7 @@ services:
|
||||
- linux/amd64
|
||||
- linux/arm64
|
||||
cache_from:
|
||||
- ${DOCKER_REGISTRY:-ghcr.io}/${DOCKER_IMAGE:-arunavo4/gitea-mirror}:${DOCKER_TAG:-latest}
|
||||
- ${DOCKER_REGISTRY:-ghcr.io}/${DOCKER_IMAGE:-raylabshq/gitea-mirror}:${DOCKER_TAG:-latest}
|
||||
container_name: gitea-mirror
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
@@ -31,6 +30,7 @@ services:
|
||||
- SKIP_FORKS=${SKIP_FORKS:-false}
|
||||
- PRIVATE_REPOSITORIES=${PRIVATE_REPOSITORIES:-false}
|
||||
- MIRROR_ISSUES=${MIRROR_ISSUES:-false}
|
||||
- MIRROR_WIKI=${MIRROR_WIKI:-false}
|
||||
- MIRROR_STARRED=${MIRROR_STARRED:-false}
|
||||
- MIRROR_ORGANIZATIONS=${MIRROR_ORGANIZATIONS:-false}
|
||||
- PRESERVE_ORG_STRUCTURE=${PRESERVE_ORG_STRUCTURE:-false}
|
||||
@@ -43,12 +43,11 @@ services:
|
||||
- GITEA_ORG_VISIBILITY=${GITEA_ORG_VISIBILITY:-public}
|
||||
- DELAY=${DELAY:-3600}
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=3", "--spider", "http://localhost:4321/"]
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=3", "--spider", "http://localhost:4321/api/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 15s
|
||||
profiles: ["production"]
|
||||
|
||||
# Define named volumes for database persistence
|
||||
volumes:
|
||||
|
||||
@@ -30,6 +30,8 @@ if [ "$JWT_SECRET" = "your-secret-key-change-this-in-production" ] || [ -z "$JWT
|
||||
echo "JWT_SECRET has been set to a secure random value"
|
||||
fi
|
||||
|
||||
|
||||
|
||||
# Skip dependency installation entirely for pre-built images
|
||||
# Dependencies are already installed during the Docker build process
|
||||
|
||||
@@ -204,6 +206,71 @@ if [ -f "package.json" ]; then
|
||||
echo "Setting application version: $npm_package_version"
|
||||
fi
|
||||
|
||||
|
||||
|
||||
# Run startup recovery to handle any interrupted jobs
|
||||
echo "Running startup recovery..."
|
||||
if [ -f "dist/scripts/startup-recovery.js" ]; then
|
||||
echo "Running startup recovery using compiled script..."
|
||||
bun dist/scripts/startup-recovery.js --timeout=30000
|
||||
RECOVERY_EXIT_CODE=$?
|
||||
elif [ -f "scripts/startup-recovery.ts" ]; then
|
||||
echo "Running startup recovery using TypeScript script..."
|
||||
bun scripts/startup-recovery.ts --timeout=30000
|
||||
RECOVERY_EXIT_CODE=$?
|
||||
else
|
||||
echo "Warning: Startup recovery script not found. Skipping recovery."
|
||||
RECOVERY_EXIT_CODE=0
|
||||
fi
|
||||
|
||||
# Log recovery result
|
||||
if [ $RECOVERY_EXIT_CODE -eq 0 ]; then
|
||||
echo "✅ Startup recovery completed successfully"
|
||||
elif [ $RECOVERY_EXIT_CODE -eq 1 ]; then
|
||||
echo "⚠️ Startup recovery completed with warnings"
|
||||
else
|
||||
echo "❌ Startup recovery failed with exit code $RECOVERY_EXIT_CODE"
|
||||
fi
|
||||
|
||||
# Run repository status repair to fix any inconsistent mirroring states
|
||||
echo "Running repository status repair..."
|
||||
if [ -f "dist/scripts/repair-mirrored-repos.js" ]; then
|
||||
echo "Running repository repair using compiled script..."
|
||||
bun dist/scripts/repair-mirrored-repos.js --startup
|
||||
REPAIR_EXIT_CODE=$?
|
||||
elif [ -f "scripts/repair-mirrored-repos.ts" ]; then
|
||||
echo "Running repository repair using TypeScript script..."
|
||||
bun scripts/repair-mirrored-repos.ts --startup
|
||||
REPAIR_EXIT_CODE=$?
|
||||
else
|
||||
echo "Warning: Repository repair script not found. Skipping repair."
|
||||
REPAIR_EXIT_CODE=0
|
||||
fi
|
||||
|
||||
# Log repair result
|
||||
if [ $REPAIR_EXIT_CODE -eq 0 ]; then
|
||||
echo "✅ Repository status repair completed successfully"
|
||||
else
|
||||
echo "⚠️ Repository status repair completed with warnings (exit code $REPAIR_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
@@ -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
|
||||
170
docs/RECOVERY_IMPROVEMENTS.md
Normal file
@@ -0,0 +1,170 @@
|
||||
# Job Recovery and Resume Process Improvements
|
||||
|
||||
This document outlines the comprehensive improvements made to the job recovery and resume process to make it more robust to application restarts, container restarts, and application crashes.
|
||||
|
||||
## Problems Addressed
|
||||
|
||||
The original recovery system had several critical issues:
|
||||
|
||||
1. **Middleware-based initialization**: Recovery only ran when the first request came in
|
||||
2. **Database connection issues**: No validation of database connectivity before recovery attempts
|
||||
3. **Limited error handling**: Insufficient error handling for various failure scenarios
|
||||
4. **No startup recovery**: No mechanism to handle recovery before serving requests
|
||||
5. **Incomplete job state management**: Jobs could remain in inconsistent states
|
||||
6. **No retry mechanisms**: Single-attempt recovery with no fallback strategies
|
||||
|
||||
## Improvements Implemented
|
||||
|
||||
### 1. Enhanced Recovery System (`src/lib/recovery.ts`)
|
||||
|
||||
#### New Features:
|
||||
- **Database connection validation** before attempting recovery
|
||||
- **Stale job cleanup** for jobs older than 24 hours
|
||||
- **Retry mechanisms** with configurable attempts and delays
|
||||
- **Individual job error handling** to prevent one failed job from stopping recovery
|
||||
- **Recovery state tracking** to prevent concurrent recovery attempts
|
||||
- **Enhanced logging** with detailed job information
|
||||
|
||||
#### Key Functions:
|
||||
- `initializeRecovery()` - Main recovery function with enhanced error handling
|
||||
- `validateDatabaseConnection()` - Ensures database is accessible
|
||||
- `cleanupStaleJobs()` - Removes jobs that are too old to recover
|
||||
- `getRecoveryStatus()` - Returns current recovery system status
|
||||
- `forceRecovery()` - Bypasses recent attempt checks
|
||||
- `hasJobsNeedingRecovery()` - Checks if recovery is needed
|
||||
|
||||
### 2. Startup Recovery Script (`scripts/startup-recovery.ts`)
|
||||
|
||||
A dedicated script that runs recovery before the application starts serving requests:
|
||||
|
||||
#### Features:
|
||||
- **Timeout protection** (default: 30 seconds)
|
||||
- **Force recovery option** to bypass recent attempt checks
|
||||
- **Graceful signal handling** (SIGINT, SIGTERM)
|
||||
- **Detailed logging** with progress indicators
|
||||
- **Exit codes** for different scenarios (success, warnings, errors)
|
||||
|
||||
#### Usage:
|
||||
```bash
|
||||
bun scripts/startup-recovery.ts [--force] [--timeout=30000]
|
||||
```
|
||||
|
||||
### 3. Improved Middleware (`src/middleware.ts`)
|
||||
|
||||
The middleware now serves as a fallback recovery mechanism:
|
||||
|
||||
#### Changes:
|
||||
- **Checks if recovery is needed** before attempting
|
||||
- **Shorter timeout** (15 seconds) for request-time recovery
|
||||
- **Better error handling** with status logging
|
||||
- **Prevents multiple attempts** with proper state tracking
|
||||
|
||||
### 4. Enhanced Database Queries (`src/lib/helpers.ts`)
|
||||
|
||||
#### Improvements:
|
||||
- **Proper Drizzle ORM syntax** for all database queries
|
||||
- **Enhanced interrupted job detection** with multiple criteria:
|
||||
- Jobs with no recent checkpoint (10+ minutes)
|
||||
- Jobs running too long (2+ hours)
|
||||
- **Detailed logging** of found interrupted jobs
|
||||
- **Better error handling** for database operations
|
||||
|
||||
### 5. Docker Integration (`docker-entrypoint.sh`)
|
||||
|
||||
#### Changes:
|
||||
- **Automatic startup recovery** runs before application start
|
||||
- **Exit code handling** with appropriate logging
|
||||
- **Fallback mechanisms** if recovery script is not found
|
||||
- **Non-blocking execution** - application starts even if recovery fails
|
||||
|
||||
### 6. Health Check Integration (`src/pages/api/health.ts`)
|
||||
|
||||
#### New Features:
|
||||
- **Recovery system status** in health endpoint
|
||||
- **Job recovery metrics** (jobs needing recovery, recovery in progress)
|
||||
- **Overall health status** considers recovery state
|
||||
- **Detailed recovery information** for monitoring
|
||||
|
||||
### 7. Testing Infrastructure (`scripts/test-recovery.ts`)
|
||||
|
||||
A comprehensive test script to verify recovery functionality:
|
||||
|
||||
#### Features:
|
||||
- **Creates test interrupted jobs** with realistic scenarios
|
||||
- **Verifies recovery detection** and execution
|
||||
- **Checks final job states** after recovery
|
||||
- **Cleanup functionality** for test data
|
||||
- **Comprehensive logging** of test progress
|
||||
|
||||
## Configuration Options
|
||||
|
||||
### Recovery System Options:
|
||||
- `maxRetries`: Number of recovery attempts (default: 3)
|
||||
- `retryDelay`: Delay between attempts in ms (default: 5000)
|
||||
- `skipIfRecentAttempt`: Skip if recent attempt made (default: true)
|
||||
|
||||
### Startup Recovery Options:
|
||||
- `--force`: Force recovery even if recent attempt was made
|
||||
- `--timeout`: Maximum time to wait for recovery (default: 30000ms)
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Manual Recovery:
|
||||
```bash
|
||||
# Run startup recovery
|
||||
bun run startup-recovery
|
||||
|
||||
# Force recovery
|
||||
bun run startup-recovery-force
|
||||
|
||||
# Test recovery system
|
||||
bun run test-recovery
|
||||
|
||||
# Clean up test data
|
||||
bun run test-recovery-cleanup
|
||||
```
|
||||
|
||||
### Programmatic Usage:
|
||||
```typescript
|
||||
import { initializeRecovery, hasJobsNeedingRecovery } from '@/lib/recovery';
|
||||
|
||||
// Check if recovery is needed
|
||||
const needsRecovery = await hasJobsNeedingRecovery();
|
||||
|
||||
// Run recovery with custom options
|
||||
const success = await initializeRecovery({
|
||||
maxRetries: 5,
|
||||
retryDelay: 3000,
|
||||
skipIfRecentAttempt: false
|
||||
});
|
||||
```
|
||||
|
||||
## Monitoring and Observability
|
||||
|
||||
### Health Check Endpoint:
|
||||
- **URL**: `/api/health`
|
||||
- **Recovery Status**: Included in response
|
||||
- **Monitoring**: Can be used with external monitoring systems
|
||||
|
||||
### Log Messages:
|
||||
- **Startup**: Clear indicators of recovery attempts and results
|
||||
- **Progress**: Detailed logging of recovery steps
|
||||
- **Errors**: Comprehensive error information for debugging
|
||||
|
||||
## Benefits
|
||||
|
||||
1. **Reliability**: Jobs are automatically recovered after application restarts
|
||||
2. **Resilience**: Multiple retry mechanisms and fallback strategies
|
||||
3. **Observability**: Comprehensive logging and health check integration
|
||||
4. **Performance**: Efficient detection and processing of interrupted jobs
|
||||
5. **Maintainability**: Clear separation of concerns and modular design
|
||||
6. **Testing**: Built-in testing infrastructure for verification
|
||||
|
||||
## Migration Notes
|
||||
|
||||
- **Backward Compatible**: All existing functionality is preserved
|
||||
- **Automatic**: Recovery runs automatically on startup
|
||||
- **Configurable**: All timeouts and retry counts can be adjusted
|
||||
- **Monitoring**: Health checks now include recovery status
|
||||
|
||||
This comprehensive improvement ensures that the gitea-mirror application can reliably handle job recovery in all deployment scenarios, from development to production container environments.
|
||||
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.
|
||||
89
package.json
@@ -1,86 +1,95 @@
|
||||
{
|
||||
"name": "gitea-mirror",
|
||||
"type": "module",
|
||||
"version": "2.5.3",
|
||||
"version": "2.20.0",
|
||||
"engines": {
|
||||
"bun": ">=1.2.9"
|
||||
},
|
||||
"scripts": {
|
||||
"setup": "bun install && bun run manage-db init && bun run update-db",
|
||||
"setup": "bun install && bun run manage-db init",
|
||||
"dev": "bunx --bun astro dev",
|
||||
"dev:clean": "bun run cleanup-db && bun run manage-db init && bun run update-db && bunx --bun astro dev",
|
||||
"dev:clean": "bun run cleanup-db && bun run manage-db init && bunx --bun astro dev",
|
||||
"build": "bunx --bun astro build",
|
||||
"cleanup-db": "rm -f gitea-mirror.db data/gitea-mirror.db",
|
||||
"manage-db": "bun scripts/manage-db.ts",
|
||||
"init-db": "bun scripts/manage-db.ts init",
|
||||
"update-db": "bun scripts/update-mirror-jobs-table.ts",
|
||||
"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",
|
||||
"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",
|
||||
"start:fresh": "bun run cleanup-db && bun run manage-db init && bun dist/server/entry.mjs",
|
||||
"test": "bun test",
|
||||
"test:watch": "bun test --watch",
|
||||
"test:coverage": "bun test --coverage",
|
||||
"astro": "bunx --bun astro"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/mdx": "^4.2.6",
|
||||
"@astrojs/node": "^9.2.1",
|
||||
"@astrojs/react": "^4.2.7",
|
||||
"@octokit/rest": "^21.1.1",
|
||||
"@radix-ui/react-avatar": "^1.1.9",
|
||||
"@radix-ui/react-checkbox": "^1.3.1",
|
||||
"@radix-ui/react-dialog": "^1.1.13",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.14",
|
||||
"@radix-ui/react-label": "^2.1.6",
|
||||
"@radix-ui/react-popover": "^1.1.13",
|
||||
"@radix-ui/react-radio-group": "^1.3.6",
|
||||
"@radix-ui/react-select": "^2.2.4",
|
||||
"@radix-ui/react-slot": "^1.2.2",
|
||||
"@radix-ui/react-tabs": "^1.1.11",
|
||||
"@radix-ui/react-tooltip": "^1.2.6",
|
||||
"@tailwindcss/vite": "^4.1.7",
|
||||
"@tanstack/react-virtual": "^3.13.8",
|
||||
"@astrojs/check": "^0.9.4",
|
||||
"@astrojs/mdx": "^4.3.0",
|
||||
"@astrojs/node": "^9.2.2",
|
||||
"@astrojs/react": "^4.3.0",
|
||||
"@octokit/rest": "^22.0.0",
|
||||
"@radix-ui/react-accordion": "^1.2.11",
|
||||
"@radix-ui/react-avatar": "^1.1.10",
|
||||
"@radix-ui/react-checkbox": "^1.3.2",
|
||||
"@radix-ui/react-collapsible": "^1.1.11",
|
||||
"@radix-ui/react-dialog": "^1.1.14",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||
"@radix-ui/react-hover-card": "^1.1.14",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-popover": "^1.1.14",
|
||||
"@radix-ui/react-radio-group": "^1.3.7",
|
||||
"@radix-ui/react-scroll-area": "^1.2.9",
|
||||
"@radix-ui/react-select": "^2.2.5",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-switch": "^1.2.5",
|
||||
"@radix-ui/react-tabs": "^1.1.12",
|
||||
"@radix-ui/react-tooltip": "^1.2.7",
|
||||
"@tailwindcss/vite": "^4.1.10",
|
||||
"@tanstack/react-virtual": "^3.13.10",
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
"@types/react": "^19.1.4",
|
||||
"@types/react-dom": "^19.1.5",
|
||||
"astro": "^5.7.13",
|
||||
"axios": "^1.9.0",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"astro": "^5.9.3",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"canvas-confetti": "^1.9.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"drizzle-orm": "^0.43.1",
|
||||
"drizzle-orm": "^0.44.2",
|
||||
"fuse.js": "^7.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"lucide-react": "^0.511.0",
|
||||
"lucide-react": "^0.515.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-icons": "^5.5.0",
|
||||
"sonner": "^2.0.3",
|
||||
"superagent": "^10.2.1",
|
||||
"tailwind-merge": "^3.3.0",
|
||||
"tailwindcss": "^4.1.7",
|
||||
"tw-animate-css": "^1.3.0",
|
||||
"sonner": "^2.0.5",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss": "^4.1.10",
|
||||
"tw-animate-css": "^1.3.4",
|
||||
"typescript": "^5.8.3",
|
||||
"uuid": "^11.1.0",
|
||||
"zod": "^3.25.7"
|
||||
"zod": "^3.25.64"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@types/bcryptjs": "^3.0.0",
|
||||
"@types/jsonwebtoken": "^9.0.9",
|
||||
"@types/superagent": "^8.1.9",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@vitejs/plugin-react": "^4.4.1",
|
||||
"@vitejs/plugin-react": "^4.5.2",
|
||||
"jsdom": "^26.1.0",
|
||||
"tsx": "^4.19.4",
|
||||
"vitest": "^3.1.4"
|
||||
"tsx": "^4.20.3",
|
||||
"vitest": "^3.2.3"
|
||||
},
|
||||
"packageManager": "bun@1.2.9"
|
||||
"packageManager": "bun@1.2.16"
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 749 B After Width: | Height: | Size: 21 KiB |
16
public/logo-dark.svg
Normal file
|
After Width: | Height: | Size: 13 KiB |
16
public/logo-light.svg
Normal file
|
After Width: | Height: | Size: 13 KiB |
16
public/logo.svg
Normal file
|
After Width: | Height: | Size: 13 KiB |
56
scripts/README-dev.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# Development Environment Setup
|
||||
|
||||
This directory contains scripts to help set up a development environment with a pre-configured Gitea instance.
|
||||
|
||||
## Default Credentials
|
||||
|
||||
For development convenience, the Gitea instance is pre-configured with:
|
||||
|
||||
- **Admin Username**: `admin`
|
||||
- **Admin Password**: `admin123`
|
||||
- **Gitea URL**: http://localhost:3001
|
||||
|
||||
## Files
|
||||
|
||||
- `gitea-app.ini` - Pre-configured Gitea settings for development
|
||||
- `gitea-dev-init.sh` - Initialization script that copies the config on first run
|
||||
- `gitea-init.sql` - SQL script to create default admin user (not currently used)
|
||||
|
||||
## Usage
|
||||
|
||||
1. Start the development environment:
|
||||
```bash
|
||||
docker compose -f docker-compose.dev.yml down
|
||||
docker volume rm gitea-mirror_gitea-data gitea-mirror_gitea-config
|
||||
docker compose -f docker-compose.dev.yml up -d
|
||||
```
|
||||
|
||||
2. Wait for Gitea to start (check logs):
|
||||
```bash
|
||||
docker logs -f gitea
|
||||
```
|
||||
|
||||
3. Access Gitea at http://localhost:3001 and login with:
|
||||
- Username: `admin`
|
||||
- Password: `admin123`
|
||||
|
||||
4. Generate an API token:
|
||||
- Go to Settings → Applications
|
||||
- Generate New Token
|
||||
- Give it a name like "gitea-mirror"
|
||||
- Select all permissions (for development)
|
||||
- Copy the token
|
||||
|
||||
5. Configure gitea-mirror with the token in your `.env` file or through the web UI.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
If Gitea doesn't start properly:
|
||||
|
||||
1. Check logs: `docker logs gitea`
|
||||
2. Ensure volumes are clean: `docker volume rm gitea-mirror_gitea-data gitea-mirror_gitea-config`
|
||||
3. Restart: `docker compose -f docker-compose.dev.yml up -d`
|
||||
|
||||
## Security Note
|
||||
|
||||
⚠️ **These credentials are for development only!** Never use these settings in production.
|
||||
@@ -47,12 +47,12 @@ The script uses environment variables from the `.env` file in the project root:
|
||||
|
||||
# First build the image
|
||||
./scripts/build-docker.sh --load
|
||||
|
||||
|
||||
# Then run using docker-compose for development
|
||||
docker-compose -f ../docker-compose.dev.yml up -d
|
||||
|
||||
# Or for production
|
||||
docker-compose --profile production up -d
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
## Diagnostics Script
|
||||
|
||||
@@ -1,42 +1,47 @@
|
||||
# LXC Container Deployment Guide
|
||||
|
||||
## Overview
|
||||
Run **Gitea Mirror** in an isolated LXC container, either:
|
||||
Run **Gitea Mirror** in an isolated LXC container:
|
||||
|
||||
1. **Online, on a Proxmox VE host** – script pulls everything from GitHub
|
||||
2. **Offline / LAN-only, on a developer laptop** – script pushes your local checkout + Bun ZIP
|
||||
1. **Proxmox VE (Recommended)** – Using the community-maintained script
|
||||
2. **Local Development** – Using the local LXC script for testing
|
||||
|
||||
---
|
||||
|
||||
## 1. Proxmox VE (online, recommended for prod)
|
||||
## 1. Proxmox VE Installation (Recommended)
|
||||
|
||||
### Prerequisites
|
||||
* Proxmox VE node with the default `vmbr0` bridge
|
||||
* Root shell on the node
|
||||
* Ubuntu 22.04 LXC template present (`pveam update && pveam download ...`)
|
||||
* Proxmox VE host with internet access
|
||||
* Root shell access on the Proxmox node
|
||||
|
||||
### One-command install
|
||||
|
||||
```bash
|
||||
# Community-maintained script for Proxmox VE by Tobias ([CrazyWolf13](https://github.com/CrazyWolf13))
|
||||
# at [community-scripts/ProxmoxVED](https://github.com/community-scripts/ProxmoxVED)
|
||||
sudo bash -c "$(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVED/main/install/gitea-mirror-install.sh)"
|
||||
# Community-maintained script from the Proxmox VE Community Scripts project
|
||||
bash -c "$(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/ct/gitea-mirror.sh)"
|
||||
```
|
||||
|
||||
What it does:
|
||||
### What the script does:
|
||||
|
||||
* Uses the community-maintained script from ProxmoxVED
|
||||
* Installs dependencies and Bun runtime
|
||||
* Clones & builds `arunavo4/gitea-mirror`
|
||||
* Creates a systemd service and starts it
|
||||
* Sets up a random `JWT_SECRET` for security
|
||||
* Creates a privileged Alpine Linux LXC container
|
||||
* Installs Bun runtime environment
|
||||
* Clones the Gitea Mirror repository
|
||||
* Builds the application
|
||||
* Configures a systemd service for automatic startup
|
||||
* Sets up the application to run on port 4321
|
||||
* Generates a secure `JWT_SECRET` automatically
|
||||
|
||||
Browse to:
|
||||
### Accessing Gitea Mirror:
|
||||
|
||||
```
|
||||
http://<container-ip>:4321
|
||||
```
|
||||
|
||||
### Additional Information:
|
||||
* **Script Source**: [Community Scripts for Proxmox VE](https://github.com/community-scripts/ProxmoxVE)
|
||||
* **Documentation**: [Gitea Mirror Script Documentation](https://community-scripts.github.io/ProxmoxVE/scripts?id=gitea-mirror)
|
||||
* **Support**: [Community Scripts Discord](https://discord.gg/fiXVvSHnBU)
|
||||
|
||||
---
|
||||
|
||||
## 2. Local testing (LXD on a workstation, works offline)
|
||||
@@ -51,7 +56,7 @@ http://<container-ip>:4321
|
||||
### Offline installer script
|
||||
|
||||
```bash
|
||||
git clone https://github.com/arunavo4/gitea-mirror.git # if not already
|
||||
git clone https://github.com/RayLabsHQ/gitea-mirror.git # if not already
|
||||
curl -fsSL https://raw.githubusercontent.com/arunavo4/gitea-mirror/main/scripts/gitea-mirror-lxc-local.sh -o gitea-mirror-lxc-local.sh
|
||||
chmod +x gitea-mirror-lxc-local.sh
|
||||
|
||||
|
||||
@@ -60,43 +60,60 @@ The database file should be located in the `./data/gitea-mirror.db` directory. I
|
||||
|
||||
The following scripts help manage events in the SQLite database:
|
||||
|
||||
### Event Inspection (check-events.ts)
|
||||
> **Note**: For a more user-friendly approach, you can use the cleanup button in the Activity Log page of the web interface to delete all activities with a single click.
|
||||
|
||||
Displays all events currently stored in the database.
|
||||
|
||||
|
||||
|
||||
|
||||
### Remove Duplicate Events (remove-duplicate-events.ts)
|
||||
|
||||
Specifically removes duplicate events based on deduplication keys without affecting old events.
|
||||
|
||||
```bash
|
||||
bun scripts/check-events.ts
|
||||
# Remove duplicate events for all users
|
||||
bun scripts/remove-duplicate-events.ts
|
||||
|
||||
# Remove duplicate events for a specific user
|
||||
bun scripts/remove-duplicate-events.ts <userId>
|
||||
```
|
||||
|
||||
### Event Cleanup (cleanup-events.ts)
|
||||
|
||||
Removes old events from the database to prevent it from growing too large.
|
||||
|
||||
### Fix Interrupted Jobs (fix-interrupted-jobs.ts)
|
||||
|
||||
Fixes interrupted jobs that might be preventing cleanup by marking them as failed.
|
||||
|
||||
```bash
|
||||
# Remove events older than 7 days (default)
|
||||
bun scripts/cleanup-events.ts
|
||||
# Fix all interrupted jobs
|
||||
bun scripts/fix-interrupted-jobs.ts
|
||||
|
||||
# Remove events older than X days
|
||||
bun scripts/cleanup-events.ts 14
|
||||
# Fix interrupted jobs for a specific user
|
||||
bun scripts/fix-interrupted-jobs.ts <userId>
|
||||
```
|
||||
|
||||
This script can be scheduled to run periodically (e.g., daily) using cron or another scheduler.
|
||||
Use this script if you're having trouble cleaning up activities due to "interrupted" jobs that won't delete.
|
||||
|
||||
### Mark Events as Read (mark-events-read.ts)
|
||||
### Startup Recovery (startup-recovery.ts)
|
||||
|
||||
Marks all unread events as read.
|
||||
Runs job recovery during application startup to handle any interrupted jobs from previous runs.
|
||||
|
||||
```bash
|
||||
bun scripts/mark-events-read.ts
|
||||
# Run startup recovery (normal mode)
|
||||
bun scripts/startup-recovery.ts
|
||||
|
||||
# Force recovery even if recent attempt was made
|
||||
bun scripts/startup-recovery.ts --force
|
||||
|
||||
# Set custom timeout (default: 30000ms)
|
||||
bun scripts/startup-recovery.ts --timeout=60000
|
||||
|
||||
# Using npm scripts
|
||||
bun run startup-recovery
|
||||
bun run startup-recovery-force
|
||||
```
|
||||
|
||||
### Make Events Appear Older (make-events-old.ts)
|
||||
|
||||
For testing purposes, this script modifies event timestamps to make them appear older.
|
||||
|
||||
```bash
|
||||
bun scripts/make-events-old.ts
|
||||
```
|
||||
This script is automatically run by the Docker entrypoint during container startup. It ensures that any jobs interrupted by container restarts or application crashes are properly recovered or marked as failed.
|
||||
|
||||
## Deployment Scripts
|
||||
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Script to check events in the database
|
||||
*/
|
||||
|
||||
import { Database } from "bun:sqlite";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
|
||||
// Define the database path
|
||||
const dataDir = path.join(process.cwd(), "data");
|
||||
if (!fs.existsSync(dataDir)) {
|
||||
console.error("Data directory not found:", dataDir);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const dbPath = path.join(dataDir, "gitea-mirror.db");
|
||||
if (!fs.existsSync(dbPath)) {
|
||||
console.error("Database file not found:", dbPath);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Open the database
|
||||
const db = new Database(dbPath);
|
||||
|
||||
// Check if the events table exists
|
||||
const tableExists = db.query("SELECT name FROM sqlite_master WHERE type='table' AND name='events'").get();
|
||||
|
||||
if (!tableExists) {
|
||||
console.error("Events table does not exist");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Get all events
|
||||
const events = db.query("SELECT * FROM events").all();
|
||||
|
||||
console.log("Events in the database:");
|
||||
console.log(JSON.stringify(events, null, 2));
|
||||
129
scripts/cleanup-duplicate-repos.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
/**
|
||||
* Script to find and clean up duplicate repositories in the database
|
||||
* Keeps the most recent entry and removes older duplicates
|
||||
*
|
||||
* Usage: bun scripts/cleanup-duplicate-repos.ts [--dry-run] [--repo-name=<name>]
|
||||
*/
|
||||
|
||||
import { db, repositories, mirrorJobs } from "@/lib/db";
|
||||
import { eq, and, desc } from "drizzle-orm";
|
||||
|
||||
const isDryRun = process.argv.includes("--dry-run");
|
||||
const specificRepo = process.argv.find(arg => arg.startsWith("--repo-name="))?.split("=")[1];
|
||||
|
||||
async function findDuplicateRepositories() {
|
||||
console.log("🔍 Finding duplicate repositories");
|
||||
console.log("=" .repeat(40));
|
||||
|
||||
if (isDryRun) {
|
||||
console.log("🔍 DRY RUN MODE - No changes will be made");
|
||||
console.log("");
|
||||
}
|
||||
|
||||
if (specificRepo) {
|
||||
console.log(`🎯 Targeting specific repository: ${specificRepo}`);
|
||||
console.log("");
|
||||
}
|
||||
|
||||
try {
|
||||
// Find all repositories, grouped by name and fullName
|
||||
let allRepos = await db.select().from(repositories);
|
||||
|
||||
if (specificRepo) {
|
||||
allRepos = allRepos.filter(repo => repo.name === specificRepo);
|
||||
}
|
||||
|
||||
// Group repositories by name and fullName
|
||||
const repoGroups = new Map<string, typeof allRepos>();
|
||||
|
||||
for (const repo of allRepos) {
|
||||
const key = `${repo.name}|${repo.fullName}`;
|
||||
if (!repoGroups.has(key)) {
|
||||
repoGroups.set(key, []);
|
||||
}
|
||||
repoGroups.get(key)!.push(repo);
|
||||
}
|
||||
|
||||
// Find groups with duplicates
|
||||
const duplicateGroups = Array.from(repoGroups.entries())
|
||||
.filter(([_, repos]) => repos.length > 1);
|
||||
|
||||
if (duplicateGroups.length === 0) {
|
||||
console.log("✅ No duplicate repositories found");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`📋 Found ${duplicateGroups.length} sets of duplicate repositories:`);
|
||||
console.log("");
|
||||
|
||||
let totalDuplicates = 0;
|
||||
let totalRemoved = 0;
|
||||
|
||||
for (const [key, repos] of duplicateGroups) {
|
||||
const [name, fullName] = key.split("|");
|
||||
console.log(`🔄 Processing duplicates for: ${name} (${fullName})`);
|
||||
console.log(` Found ${repos.length} entries:`);
|
||||
|
||||
// Sort by updatedAt descending to keep the most recent
|
||||
repos.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
|
||||
|
||||
const keepRepo = repos[0];
|
||||
const removeRepos = repos.slice(1);
|
||||
|
||||
console.log(` ✅ Keeping: ID ${keepRepo.id} (Status: ${keepRepo.status}, Updated: ${new Date(keepRepo.updatedAt).toISOString()})`);
|
||||
|
||||
for (const repo of removeRepos) {
|
||||
console.log(` ❌ Removing: ID ${repo.id} (Status: ${repo.status}, Updated: ${new Date(repo.updatedAt).toISOString()})`);
|
||||
|
||||
if (!isDryRun) {
|
||||
try {
|
||||
// First, delete related mirror jobs
|
||||
await db
|
||||
.delete(mirrorJobs)
|
||||
.where(eq(mirrorJobs.repositoryId, repo.id!));
|
||||
|
||||
// Then delete the repository
|
||||
await db
|
||||
.delete(repositories)
|
||||
.where(eq(repositories.id, repo.id!));
|
||||
|
||||
console.log(` 🗑️ Deleted repository and related mirror jobs`);
|
||||
totalRemoved++;
|
||||
} catch (error) {
|
||||
console.log(` ❌ Error deleting repository: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
} else {
|
||||
console.log(` 🗑️ Would delete repository and related mirror jobs`);
|
||||
totalRemoved++;
|
||||
}
|
||||
}
|
||||
|
||||
totalDuplicates += removeRepos.length;
|
||||
console.log("");
|
||||
}
|
||||
|
||||
console.log("📊 Cleanup Summary:");
|
||||
console.log(` Duplicate sets found: ${duplicateGroups.length}`);
|
||||
console.log(` Total duplicates: ${totalDuplicates}`);
|
||||
console.log(` ${isDryRun ? 'Would remove' : 'Removed'}: ${totalRemoved}`);
|
||||
|
||||
if (isDryRun && totalRemoved > 0) {
|
||||
console.log("");
|
||||
console.log("💡 To apply these changes, run the script without --dry-run");
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error("❌ Error during cleanup process:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Run the cleanup
|
||||
findDuplicateRepositories().then(() => {
|
||||
console.log("Cleanup process complete.");
|
||||
process.exit(0);
|
||||
}).catch((error) => {
|
||||
console.error("Fatal error:", error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,43 +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 } 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)...`);
|
||||
|
||||
// Call the cleanupOldEvents function from the events module
|
||||
const result = await cleanupOldEvents(daysToKeep);
|
||||
|
||||
console.log(`Cleanup summary:`);
|
||||
console.log(`- Read events deleted: ${result.readEventsDeleted}`);
|
||||
console.log(`- Unread events deleted: ${result.unreadEventsDeleted}`);
|
||||
console.log(`- Total events deleted: ${result.readEventsDeleted + result.unreadEventsDeleted}`);
|
||||
|
||||
console.log("Event cleanup completed successfully");
|
||||
} catch (error) {
|
||||
console.error("Error running event cleanup:", error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Run the cleanup
|
||||
runCleanup();
|
||||
74
scripts/fix-interrupted-jobs.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Script to fix interrupted jobs that might be preventing cleanup
|
||||
* This script marks all in-progress jobs as failed to allow them to be deleted
|
||||
*
|
||||
* Usage:
|
||||
* bun scripts/fix-interrupted-jobs.ts [userId]
|
||||
*
|
||||
* Where [userId] is optional - if provided, only fixes jobs for that user
|
||||
*/
|
||||
|
||||
import { db, mirrorJobs } from "../src/lib/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
// Parse command line arguments
|
||||
const args = process.argv.slice(2);
|
||||
const userId = args.length > 0 ? args[0] : undefined;
|
||||
|
||||
async function fixInterruptedJobs() {
|
||||
try {
|
||||
console.log("Checking for interrupted jobs...");
|
||||
|
||||
// Build the query
|
||||
let query = db
|
||||
.select()
|
||||
.from(mirrorJobs)
|
||||
.where(eq(mirrorJobs.inProgress, true));
|
||||
|
||||
if (userId) {
|
||||
console.log(`Filtering for user: ${userId}`);
|
||||
query = query.where(eq(mirrorJobs.userId, userId));
|
||||
}
|
||||
|
||||
// Find all in-progress jobs
|
||||
const inProgressJobs = await query;
|
||||
|
||||
if (inProgressJobs.length === 0) {
|
||||
console.log("No interrupted jobs found.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Found ${inProgressJobs.length} interrupted jobs:`);
|
||||
inProgressJobs.forEach(job => {
|
||||
console.log(`- Job ${job.id}: ${job.message} (${job.repositoryName || job.organizationName || 'Unknown'})`);
|
||||
});
|
||||
|
||||
// Mark all in-progress jobs as failed
|
||||
let updateQuery = db
|
||||
.update(mirrorJobs)
|
||||
.set({
|
||||
inProgress: false,
|
||||
completedAt: new Date(),
|
||||
status: "failed",
|
||||
message: "Job interrupted and marked as failed by cleanup script"
|
||||
})
|
||||
.where(eq(mirrorJobs.inProgress, true));
|
||||
|
||||
if (userId) {
|
||||
updateQuery = updateQuery.where(eq(mirrorJobs.userId, userId));
|
||||
}
|
||||
|
||||
await updateQuery;
|
||||
|
||||
console.log(`✅ Successfully marked ${inProgressJobs.length} interrupted jobs as failed.`);
|
||||
console.log("These jobs can now be deleted through the normal cleanup process.");
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error fixing interrupted jobs:", error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Run the fix
|
||||
fixInterruptedJobs();
|
||||
68
scripts/gitea-app.ini
Normal file
@@ -0,0 +1,68 @@
|
||||
APP_NAME = Gitea: Git with a cup of tea
|
||||
RUN_MODE = prod
|
||||
|
||||
[database]
|
||||
DB_TYPE = sqlite3
|
||||
PATH = /data/gitea/gitea.db
|
||||
|
||||
[repository]
|
||||
ROOT = /data/git/repositories
|
||||
|
||||
[server]
|
||||
SSH_DOMAIN = localhost
|
||||
DOMAIN = localhost
|
||||
HTTP_PORT = 3000
|
||||
ROOT_URL = http://localhost:3001/
|
||||
DISABLE_SSH = false
|
||||
SSH_PORT = 22
|
||||
LFS_START_SERVER = true
|
||||
LFS_JWT_SECRET = _oaWNP5sCH5cSECa-K_HvCXeXhg-zN5H0cU5vVQAZr4
|
||||
OFFLINE_MODE = false
|
||||
|
||||
[security]
|
||||
INSTALL_LOCK = true
|
||||
SECRET_KEY = vLu5OuX0EweZjDNxKPQ5V9DXXXX8cJiKpJyQylKkMVTrNdFAzlUlNdYLYfiCybu
|
||||
INTERNAL_TOKEN = eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYmYiOjE3MjgzMTk1MDB9.Lz0cJB_DCLmJFh8FqDX0z9IUcxfY9jPftHEGvz_WeHo
|
||||
PASSWORD_HASH_ALGO = pbkdf2
|
||||
|
||||
[service]
|
||||
DISABLE_REGISTRATION = false
|
||||
REQUIRE_SIGNIN_VIEW = false
|
||||
REGISTER_EMAIL_CONFIRM = false
|
||||
ENABLE_NOTIFY_MAIL = false
|
||||
ALLOW_ONLY_EXTERNAL_REGISTRATION = false
|
||||
ENABLE_CAPTCHA = false
|
||||
DEFAULT_KEEP_EMAIL_PRIVATE = false
|
||||
DEFAULT_ALLOW_CREATE_ORGANIZATION = true
|
||||
DEFAULT_ENABLE_TIMETRACKING = true
|
||||
NO_REPLY_ADDRESS = noreply.localhost
|
||||
|
||||
[oauth2]
|
||||
JWT_SECRET = gQXt_D8B-VJGCvFfJ9xEj5yp8mOd6fAza8TKc9rJJYw
|
||||
|
||||
[lfs]
|
||||
PATH = /data/git/lfs
|
||||
|
||||
[mailer]
|
||||
ENABLED = false
|
||||
|
||||
[openid]
|
||||
ENABLE_OPENID_SIGNIN = true
|
||||
ENABLE_OPENID_SIGNUP = true
|
||||
|
||||
[session]
|
||||
PROVIDER = file
|
||||
|
||||
[log]
|
||||
MODE = console
|
||||
LEVEL = Info
|
||||
ROOT_PATH = /data/gitea/log
|
||||
|
||||
[repository.pull-request]
|
||||
DEFAULT_MERGE_STYLE = merge
|
||||
|
||||
[repository.signing]
|
||||
DEFAULT_TRUST_MODEL = committer
|
||||
|
||||
[actions]
|
||||
ENABLED = false
|
||||
14
scripts/gitea-create-admin.sh
Executable file
@@ -0,0 +1,14 @@
|
||||
#!/bin/sh
|
||||
# Create admin user for Gitea development instance
|
||||
|
||||
echo "Creating admin user for Gitea..."
|
||||
docker exec -u git gitea gitea admin user create \
|
||||
--username admin \
|
||||
--password admin123 \
|
||||
--email admin@localhost \
|
||||
--admin \
|
||||
--must-change-password=false
|
||||
|
||||
echo "Admin user created!"
|
||||
echo "Username: admin"
|
||||
echo "Password: admin123"
|
||||
32
scripts/gitea-dev-init.sh
Executable file
@@ -0,0 +1,32 @@
|
||||
#!/bin/sh
|
||||
# Initialize Gitea for development with pre-configured settings
|
||||
|
||||
# Create necessary directories
|
||||
mkdir -p /data/gitea/conf
|
||||
|
||||
# Copy pre-configured app.ini if it doesn't exist
|
||||
if [ ! -f /data/gitea/conf/app.ini ]; then
|
||||
echo "Initializing Gitea with development configuration..."
|
||||
cp /tmp/app.ini /data/gitea/conf/app.ini
|
||||
chown 1000:1000 /data/gitea/conf/app.ini
|
||||
fi
|
||||
|
||||
# Start Gitea in background
|
||||
/usr/bin/entrypoint "$@" &
|
||||
GITEA_PID=$!
|
||||
|
||||
# Wait for Gitea to be ready
|
||||
echo "Waiting for Gitea to start..."
|
||||
until wget --no-verbose --tries=1 --spider http://localhost:3000/ 2>/dev/null; do
|
||||
sleep 2
|
||||
done
|
||||
|
||||
# Create admin user if it doesn't exist
|
||||
if [ ! -f /data/.admin_created ]; then
|
||||
echo "Creating default admin user..."
|
||||
su git -c "gitea admin user create --username admin --password admin123 --email admin@localhost --admin --must-change-password=false" && \
|
||||
touch /data/.admin_created
|
||||
fi
|
||||
|
||||
# Keep Gitea running in foreground
|
||||
wait $GITEA_PID
|
||||
178
scripts/investigate-repo.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
/**
|
||||
* Script to investigate a specific repository's mirroring status
|
||||
* Usage: bun scripts/investigate-repo.ts [repository-name]
|
||||
*/
|
||||
|
||||
import { db, repositories, mirrorJobs, configs } from "@/lib/db";
|
||||
import { eq, desc, and } from "drizzle-orm";
|
||||
|
||||
const repoName = process.argv[2] || "EruditionPaper";
|
||||
|
||||
async function investigateRepository() {
|
||||
console.log(`🔍 Investigating repository: ${repoName}`);
|
||||
console.log("=" .repeat(50));
|
||||
|
||||
try {
|
||||
// Find the repository in the database
|
||||
const repos = await db
|
||||
.select()
|
||||
.from(repositories)
|
||||
.where(eq(repositories.name, repoName));
|
||||
|
||||
if (repos.length === 0) {
|
||||
console.log(`❌ Repository "${repoName}" not found in database`);
|
||||
return;
|
||||
}
|
||||
|
||||
const repo = repos[0];
|
||||
console.log(`✅ Found repository: ${repo.name}`);
|
||||
console.log(` ID: ${repo.id}`);
|
||||
console.log(` Full Name: ${repo.fullName}`);
|
||||
console.log(` Owner: ${repo.owner}`);
|
||||
console.log(` Organization: ${repo.organization || "None"}`);
|
||||
console.log(` Status: ${repo.status}`);
|
||||
console.log(` Is Private: ${repo.isPrivate}`);
|
||||
console.log(` Is Forked: ${repo.isForked}`);
|
||||
console.log(` Mirrored Location: ${repo.mirroredLocation || "Not set"}`);
|
||||
console.log(` Last Mirrored: ${repo.lastMirrored ? new Date(repo.lastMirrored).toISOString() : "Never"}`);
|
||||
console.log(` Error Message: ${repo.errorMessage || "None"}`);
|
||||
console.log(` Created At: ${new Date(repo.createdAt).toISOString()}`);
|
||||
console.log(` Updated At: ${new Date(repo.updatedAt).toISOString()}`);
|
||||
|
||||
console.log("\n📋 Recent Mirror Jobs:");
|
||||
console.log("-".repeat(30));
|
||||
|
||||
// Find recent mirror jobs for this repository
|
||||
const jobs = await db
|
||||
.select()
|
||||
.from(mirrorJobs)
|
||||
.where(eq(mirrorJobs.repositoryId, repo.id))
|
||||
.orderBy(desc(mirrorJobs.timestamp))
|
||||
.limit(10);
|
||||
|
||||
if (jobs.length === 0) {
|
||||
console.log(" No mirror jobs found for this repository");
|
||||
} else {
|
||||
jobs.forEach((job, index) => {
|
||||
console.log(` ${index + 1}. ${new Date(job.timestamp).toISOString()}`);
|
||||
console.log(` Status: ${job.status}`);
|
||||
console.log(` Message: ${job.message}`);
|
||||
if (job.details) {
|
||||
console.log(` Details: ${job.details}`);
|
||||
}
|
||||
console.log("");
|
||||
});
|
||||
}
|
||||
|
||||
// Get user configuration
|
||||
console.log("⚙️ User Configuration:");
|
||||
console.log("-".repeat(20));
|
||||
|
||||
const config = await db
|
||||
.select()
|
||||
.from(configs)
|
||||
.where(eq(configs.id, repo.configId))
|
||||
.limit(1);
|
||||
|
||||
if (config.length > 0) {
|
||||
const userConfig = config[0];
|
||||
console.log(` User ID: ${userConfig.userId}`);
|
||||
console.log(` GitHub Username: ${userConfig.githubConfig?.username || "Not set"}`);
|
||||
console.log(` Gitea URL: ${userConfig.giteaConfig?.url || "Not set"}`);
|
||||
console.log(` Gitea Username: ${userConfig.giteaConfig?.username || "Not set"}`);
|
||||
console.log(` Preserve Org Structure: ${userConfig.githubConfig?.preserveOrgStructure || false}`);
|
||||
console.log(` Mirror Issues: ${userConfig.githubConfig?.mirrorIssues || false}`);
|
||||
}
|
||||
|
||||
// Check for any active jobs
|
||||
console.log("\n🔄 Active Jobs:");
|
||||
console.log("-".repeat(15));
|
||||
|
||||
const activeJobs = await db
|
||||
.select()
|
||||
.from(mirrorJobs)
|
||||
.where(
|
||||
and(
|
||||
eq(mirrorJobs.repositoryId, repo.id),
|
||||
eq(mirrorJobs.inProgress, true)
|
||||
)
|
||||
);
|
||||
|
||||
if (activeJobs.length === 0) {
|
||||
console.log(" No active jobs found");
|
||||
} else {
|
||||
activeJobs.forEach((job, index) => {
|
||||
console.log(` ${index + 1}. Job ID: ${job.id}`);
|
||||
console.log(` Type: ${job.jobType || "mirror"}`);
|
||||
console.log(` Batch ID: ${job.batchId || "None"}`);
|
||||
console.log(` Started: ${job.startedAt ? new Date(job.startedAt).toISOString() : "Unknown"}`);
|
||||
console.log(` Last Checkpoint: ${job.lastCheckpoint ? new Date(job.lastCheckpoint).toISOString() : "None"}`);
|
||||
console.log(` Progress: ${job.completedItems || 0}/${job.totalItems || 0}`);
|
||||
console.log("");
|
||||
});
|
||||
}
|
||||
|
||||
// Check if repository exists in Gitea
|
||||
if (config.length > 0) {
|
||||
const userConfig = config[0];
|
||||
console.log("\n🔗 Gitea Repository Check:");
|
||||
console.log("-".repeat(25));
|
||||
|
||||
try {
|
||||
const giteaUrl = userConfig.giteaConfig?.url;
|
||||
const giteaToken = userConfig.giteaConfig?.token;
|
||||
const giteaUsername = userConfig.giteaConfig?.username;
|
||||
|
||||
if (giteaUrl && giteaToken && giteaUsername) {
|
||||
const checkUrl = `${giteaUrl}/api/v1/repos/${giteaUsername}/${repo.name}`;
|
||||
console.log(` Checking: ${checkUrl}`);
|
||||
|
||||
const response = await fetch(checkUrl, {
|
||||
headers: {
|
||||
Authorization: `token ${giteaToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
console.log(` Response Status: ${response.status} ${response.statusText}`);
|
||||
|
||||
if (response.ok) {
|
||||
const repoData = await response.json();
|
||||
console.log(` ✅ Repository exists in Gitea`);
|
||||
console.log(` Name: ${repoData.name}`);
|
||||
console.log(` Full Name: ${repoData.full_name}`);
|
||||
console.log(` Private: ${repoData.private}`);
|
||||
console.log(` Mirror: ${repoData.mirror}`);
|
||||
console.log(` Clone URL: ${repoData.clone_url}`);
|
||||
console.log(` Created: ${new Date(repoData.created_at).toISOString()}`);
|
||||
console.log(` Updated: ${new Date(repoData.updated_at).toISOString()}`);
|
||||
if (repoData.mirror_updated) {
|
||||
console.log(` Mirror Updated: ${new Date(repoData.mirror_updated).toISOString()}`);
|
||||
}
|
||||
} else {
|
||||
console.log(` ❌ Repository not found in Gitea`);
|
||||
const errorText = await response.text();
|
||||
console.log(` Error: ${errorText}`);
|
||||
}
|
||||
} else {
|
||||
console.log(" ⚠️ Missing Gitea configuration");
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(` ❌ Error checking Gitea: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error("❌ Error investigating repository:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Run the investigation
|
||||
investigateRepository().then(() => {
|
||||
console.log("Investigation complete.");
|
||||
process.exit(0);
|
||||
}).catch((error) => {
|
||||
console.error("Fatal error:", error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,29 +0,0 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Script to make events appear older for testing cleanup
|
||||
*/
|
||||
|
||||
import { db, events } from "../src/lib/db";
|
||||
|
||||
async function makeEventsOld() {
|
||||
try {
|
||||
console.log("Making events appear older...");
|
||||
|
||||
// Calculate a timestamp from 2 days ago
|
||||
const oldDate = new Date();
|
||||
oldDate.setDate(oldDate.getDate() - 2);
|
||||
|
||||
// Update all events to have an older timestamp
|
||||
const result = await db
|
||||
.update(events)
|
||||
.set({ createdAt: oldDate });
|
||||
|
||||
console.log(`Updated ${result.changes || 0} events to appear older`);
|
||||
} catch (error) {
|
||||
console.error("Error updating event timestamps:", error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Run the function
|
||||
makeEventsOld();
|
||||
@@ -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()
|
||||
);
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Script to mark all events as read
|
||||
*/
|
||||
|
||||
import { db, events } from "../src/lib/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
async function markEventsAsRead() {
|
||||
try {
|
||||
console.log("Marking all events as read...");
|
||||
|
||||
// Update all events to mark them as read
|
||||
const result = await db
|
||||
.update(events)
|
||||
.set({ read: true })
|
||||
.where(eq(events.read, false));
|
||||
|
||||
console.log(`Marked ${result.changes || 0} events as read`);
|
||||
} catch (error) {
|
||||
console.error("Error marking events as read:", error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Run the function
|
||||
markEventsAsRead();
|
||||
44
scripts/remove-duplicate-events.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Script to remove duplicate events from the database
|
||||
* This script identifies and removes events with duplicate deduplication keys
|
||||
*
|
||||
* Usage:
|
||||
* bun scripts/remove-duplicate-events.ts [userId]
|
||||
*
|
||||
* Where [userId] is optional - if provided, only removes duplicates for that user
|
||||
*/
|
||||
|
||||
import { removeDuplicateEvents } from "../src/lib/events";
|
||||
|
||||
// Parse command line arguments
|
||||
const args = process.argv.slice(2);
|
||||
const userId = args.length > 0 ? args[0] : undefined;
|
||||
|
||||
async function runDuplicateRemoval() {
|
||||
try {
|
||||
if (userId) {
|
||||
console.log(`Starting duplicate event removal for user: ${userId}...`);
|
||||
} else {
|
||||
console.log("Starting duplicate event removal for all users...");
|
||||
}
|
||||
|
||||
// Call the removeDuplicateEvents function
|
||||
const result = await removeDuplicateEvents(userId);
|
||||
|
||||
console.log(`Duplicate removal summary:`);
|
||||
console.log(`- Duplicate events removed: ${result.duplicatesRemoved}`);
|
||||
|
||||
if (result.duplicatesRemoved > 0) {
|
||||
console.log("Duplicate event removal completed successfully");
|
||||
} else {
|
||||
console.log("No duplicate events found");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error running duplicate event removal:", error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Run the duplicate removal
|
||||
runDuplicateRemoval();
|
||||
277
scripts/repair-mirrored-repos.ts
Normal file
@@ -0,0 +1,277 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
/**
|
||||
* Script to repair repositories that exist in Gitea but have incorrect status in the database
|
||||
* This fixes the issue where repositories show as "imported" but are actually mirrored in Gitea
|
||||
*
|
||||
* Usage: bun scripts/repair-mirrored-repos.ts [--dry-run] [--repo-name=<name>]
|
||||
*/
|
||||
|
||||
import { db, repositories, configs } from "@/lib/db";
|
||||
import { eq, and, or } from "drizzle-orm";
|
||||
import { createMirrorJob } from "@/lib/helpers";
|
||||
import { repoStatusEnum } from "@/types/Repository";
|
||||
|
||||
const isDryRun = process.argv.includes("--dry-run");
|
||||
const specificRepo = process.argv.find(arg => arg.startsWith("--repo-name="))?.split("=")[1];
|
||||
const isStartupMode = process.argv.includes("--startup");
|
||||
|
||||
async function checkRepoInGitea(config: any, owner: string, repoName: string): Promise<boolean> {
|
||||
try {
|
||||
if (!config.giteaConfig?.url || !config.giteaConfig?.token) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`${config.giteaConfig.url}/api/v1/repos/${owner}/${repoName}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `token ${config.giteaConfig.token}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return response.ok;
|
||||
} catch (error) {
|
||||
console.error(`Error checking repo ${owner}/${repoName} in Gitea:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function getRepoDetailsFromGitea(config: any, owner: string, repoName: string): Promise<any> {
|
||||
try {
|
||||
if (!config.giteaConfig?.url || !config.giteaConfig?.token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`${config.giteaConfig.url}/api/v1/repos/${owner}/${repoName}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `token ${config.giteaConfig.token}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
return await response.json();
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error(`Error getting repo details for ${owner}/${repoName}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function repairMirroredRepositories() {
|
||||
if (!isStartupMode) {
|
||||
console.log("🔧 Repairing mirrored repositories database status");
|
||||
console.log("=" .repeat(60));
|
||||
|
||||
if (isDryRun) {
|
||||
console.log("🔍 DRY RUN MODE - No changes will be made");
|
||||
console.log("");
|
||||
}
|
||||
|
||||
if (specificRepo) {
|
||||
console.log(`🎯 Targeting specific repository: ${specificRepo}`);
|
||||
console.log("");
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Find repositories that might need repair
|
||||
let query = db
|
||||
.select()
|
||||
.from(repositories)
|
||||
.where(
|
||||
or(
|
||||
eq(repositories.status, "imported"),
|
||||
eq(repositories.status, "failed")
|
||||
)
|
||||
);
|
||||
|
||||
if (specificRepo) {
|
||||
query = query.where(eq(repositories.name, specificRepo));
|
||||
}
|
||||
|
||||
const repos = await query;
|
||||
|
||||
if (repos.length === 0) {
|
||||
if (!isStartupMode) {
|
||||
console.log("✅ No repositories found that need repair");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isStartupMode) {
|
||||
console.log(`📋 Found ${repos.length} repositories to check:`);
|
||||
console.log("");
|
||||
}
|
||||
|
||||
let repairedCount = 0;
|
||||
let skippedCount = 0;
|
||||
let errorCount = 0;
|
||||
|
||||
for (const repo of repos) {
|
||||
if (!isStartupMode) {
|
||||
console.log(`🔍 Checking repository: ${repo.name}`);
|
||||
console.log(` Current status: ${repo.status}`);
|
||||
console.log(` Mirrored location: ${repo.mirroredLocation || "Not set"}`);
|
||||
}
|
||||
|
||||
try {
|
||||
// Get user configuration
|
||||
const config = await db
|
||||
.select()
|
||||
.from(configs)
|
||||
.where(eq(configs.id, repo.configId))
|
||||
.limit(1);
|
||||
|
||||
if (config.length === 0) {
|
||||
if (!isStartupMode) {
|
||||
console.log(` ❌ No configuration found for repository`);
|
||||
}
|
||||
errorCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const userConfig = config[0];
|
||||
const giteaUsername = userConfig.giteaConfig?.username;
|
||||
|
||||
if (!giteaUsername) {
|
||||
if (!isStartupMode) {
|
||||
console.log(` ❌ No Gitea username in configuration`);
|
||||
}
|
||||
errorCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if repository exists in Gitea (try both user and organization)
|
||||
let existsInGitea = false;
|
||||
let actualOwner = giteaUsername;
|
||||
let giteaRepoDetails = null;
|
||||
|
||||
// First check user location
|
||||
existsInGitea = await checkRepoInGitea(userConfig, giteaUsername, repo.name);
|
||||
if (existsInGitea) {
|
||||
giteaRepoDetails = await getRepoDetailsFromGitea(userConfig, giteaUsername, repo.name);
|
||||
}
|
||||
|
||||
// If not found in user location and repo has organization, check organization
|
||||
if (!existsInGitea && repo.organization) {
|
||||
existsInGitea = await checkRepoInGitea(userConfig, repo.organization, repo.name);
|
||||
if (existsInGitea) {
|
||||
actualOwner = repo.organization;
|
||||
giteaRepoDetails = await getRepoDetailsFromGitea(userConfig, repo.organization, repo.name);
|
||||
}
|
||||
}
|
||||
|
||||
if (!existsInGitea) {
|
||||
if (!isStartupMode) {
|
||||
console.log(` ⏭️ Repository not found in Gitea - skipping`);
|
||||
}
|
||||
skippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isStartupMode) {
|
||||
console.log(` ✅ Repository found in Gitea at: ${actualOwner}/${repo.name}`);
|
||||
|
||||
if (giteaRepoDetails) {
|
||||
console.log(` 📊 Gitea details:`);
|
||||
console.log(` Mirror: ${giteaRepoDetails.mirror}`);
|
||||
console.log(` Created: ${new Date(giteaRepoDetails.created_at).toISOString()}`);
|
||||
console.log(` Updated: ${new Date(giteaRepoDetails.updated_at).toISOString()}`);
|
||||
if (giteaRepoDetails.mirror_updated) {
|
||||
console.log(` Mirror Updated: ${new Date(giteaRepoDetails.mirror_updated).toISOString()}`);
|
||||
}
|
||||
}
|
||||
} else if (repairedCount === 0) {
|
||||
// In startup mode, only log the first repair to indicate activity
|
||||
console.log(`Repairing repository status inconsistencies...`);
|
||||
}
|
||||
|
||||
if (!isDryRun) {
|
||||
// Update repository status in database
|
||||
const mirrorUpdated = giteaRepoDetails?.mirror_updated
|
||||
? new Date(giteaRepoDetails.mirror_updated)
|
||||
: new Date();
|
||||
|
||||
await db
|
||||
.update(repositories)
|
||||
.set({
|
||||
status: repoStatusEnum.parse("mirrored"),
|
||||
updatedAt: new Date(),
|
||||
lastMirrored: mirrorUpdated,
|
||||
errorMessage: null,
|
||||
mirroredLocation: `${actualOwner}/${repo.name}`,
|
||||
})
|
||||
.where(eq(repositories.id, repo.id!));
|
||||
|
||||
// Create a mirror job log entry
|
||||
await createMirrorJob({
|
||||
userId: userConfig.userId || "",
|
||||
repositoryId: repo.id,
|
||||
repositoryName: repo.name,
|
||||
message: `Repository status repaired - found existing mirror in Gitea`,
|
||||
details: `Repository ${repo.name} was found to already exist in Gitea at ${actualOwner}/${repo.name} and database status was updated from ${repo.status} to mirrored.`,
|
||||
status: "mirrored",
|
||||
});
|
||||
|
||||
if (!isStartupMode) {
|
||||
console.log(` 🔧 Repaired: Updated status to 'mirrored'`);
|
||||
}
|
||||
} else {
|
||||
if (!isStartupMode) {
|
||||
console.log(` 🔧 Would repair: Update status from '${repo.status}' to 'mirrored'`);
|
||||
}
|
||||
}
|
||||
|
||||
repairedCount++;
|
||||
|
||||
} catch (error) {
|
||||
if (!isStartupMode) {
|
||||
console.log(` ❌ Error processing repository: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
errorCount++;
|
||||
}
|
||||
|
||||
if (!isStartupMode) {
|
||||
console.log("");
|
||||
}
|
||||
}
|
||||
|
||||
if (isStartupMode) {
|
||||
// In startup mode, only log if there were repairs or errors
|
||||
if (repairedCount > 0) {
|
||||
console.log(`Repaired ${repairedCount} repository status inconsistencies`);
|
||||
}
|
||||
if (errorCount > 0) {
|
||||
console.log(`Warning: ${errorCount} repositories had errors during repair`);
|
||||
}
|
||||
} else {
|
||||
console.log("📊 Repair Summary:");
|
||||
console.log(` Repaired: ${repairedCount}`);
|
||||
console.log(` Skipped: ${skippedCount}`);
|
||||
console.log(` Errors: ${errorCount}`);
|
||||
|
||||
if (isDryRun && repairedCount > 0) {
|
||||
console.log("");
|
||||
console.log("💡 To apply these changes, run the script without --dry-run");
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error("❌ Error during repair process:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Run the repair
|
||||
repairMirroredRepositories().then(() => {
|
||||
console.log("Repair process complete.");
|
||||
process.exit(0);
|
||||
}).catch((error) => {
|
||||
console.error("Fatal error:", error);
|
||||
process.exit(1);
|
||||
});
|
||||
113
scripts/startup-recovery.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Startup recovery script
|
||||
* This script runs job recovery before the application starts serving requests
|
||||
* It ensures that any interrupted jobs from previous runs are properly handled
|
||||
*
|
||||
* Usage:
|
||||
* bun scripts/startup-recovery.ts [--force] [--timeout=30000]
|
||||
*
|
||||
* Options:
|
||||
* --force: Force recovery even if a recent attempt was made
|
||||
* --timeout: Maximum time to wait for recovery (in milliseconds, default: 30000)
|
||||
*/
|
||||
|
||||
import { initializeRecovery, hasJobsNeedingRecovery, getRecoveryStatus } from "../src/lib/recovery";
|
||||
|
||||
// Parse command line arguments
|
||||
const args = process.argv.slice(2);
|
||||
const forceRecovery = args.includes('--force');
|
||||
const timeoutArg = args.find(arg => arg.startsWith('--timeout='));
|
||||
const timeout = timeoutArg ? parseInt(timeoutArg.split('=')[1], 10) : 30000;
|
||||
|
||||
if (isNaN(timeout) || timeout < 1000) {
|
||||
console.error("Error: Timeout must be at least 1000ms");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
async function runStartupRecovery() {
|
||||
console.log('=== Gitea Mirror Startup Recovery ===');
|
||||
console.log(`Timeout: ${timeout}ms`);
|
||||
console.log(`Force recovery: ${forceRecovery}`);
|
||||
console.log('');
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
// Set up timeout
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
setTimeout(() => {
|
||||
reject(new Error(`Recovery timeout after ${timeout}ms`));
|
||||
}, timeout);
|
||||
});
|
||||
|
||||
// Check if recovery is needed first
|
||||
console.log('Checking if recovery is needed...');
|
||||
const needsRecovery = await hasJobsNeedingRecovery();
|
||||
|
||||
if (!needsRecovery) {
|
||||
console.log('✅ No jobs need recovery. Startup can proceed.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log('⚠️ Jobs found that need recovery. Starting recovery process...');
|
||||
|
||||
// Run recovery with timeout
|
||||
const recoveryPromise = initializeRecovery({
|
||||
skipIfRecentAttempt: !forceRecovery,
|
||||
maxRetries: 3,
|
||||
retryDelay: 5000,
|
||||
});
|
||||
|
||||
const recoveryResult = await Promise.race([recoveryPromise, timeoutPromise]);
|
||||
|
||||
const endTime = Date.now();
|
||||
const duration = endTime - startTime;
|
||||
|
||||
if (recoveryResult) {
|
||||
console.log(`✅ Recovery completed successfully in ${duration}ms`);
|
||||
console.log('Application startup can proceed.');
|
||||
process.exit(0);
|
||||
} else {
|
||||
console.log(`⚠️ Recovery completed with some failures in ${duration}ms`);
|
||||
console.log('Application startup can proceed, but some jobs may have failed.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
const endTime = Date.now();
|
||||
const duration = endTime - startTime;
|
||||
|
||||
if (error instanceof Error && error.message.includes('timeout')) {
|
||||
console.error(`❌ Recovery timed out after ${duration}ms`);
|
||||
console.error('Application will start anyway, but some jobs may remain interrupted.');
|
||||
|
||||
// Get current recovery status
|
||||
const status = getRecoveryStatus();
|
||||
console.log('Recovery status:', status);
|
||||
|
||||
// Exit with warning code but allow startup to continue
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.error(`❌ Recovery failed after ${duration}ms:`, error);
|
||||
console.error('Application will start anyway, but recovery was unsuccessful.');
|
||||
|
||||
// Exit with error code but allow startup to continue
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle process signals gracefully
|
||||
process.on('SIGINT', () => {
|
||||
console.log('\n⚠️ Recovery interrupted by SIGINT');
|
||||
process.exit(130);
|
||||
});
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
console.log('\n⚠️ Recovery interrupted by SIGTERM');
|
||||
process.exit(143);
|
||||
});
|
||||
|
||||
// Run the startup recovery
|
||||
runStartupRecovery();
|
||||
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();
|
||||
183
scripts/test-recovery.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Test script for the recovery system
|
||||
* This script creates test jobs and verifies that the recovery system can handle them
|
||||
*
|
||||
* Usage:
|
||||
* bun scripts/test-recovery.ts [--cleanup]
|
||||
*
|
||||
* Options:
|
||||
* --cleanup: Clean up test jobs after testing
|
||||
*/
|
||||
|
||||
import { db, mirrorJobs } from "../src/lib/db";
|
||||
import { createMirrorJob } from "../src/lib/helpers";
|
||||
import { initializeRecovery, hasJobsNeedingRecovery, getRecoveryStatus } from "../src/lib/recovery";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
// Parse command line arguments
|
||||
const args = process.argv.slice(2);
|
||||
const cleanup = args.includes('--cleanup');
|
||||
|
||||
// Test configuration
|
||||
const TEST_USER_ID = "test-user-recovery";
|
||||
const TEST_BATCH_ID = "test-batch-recovery";
|
||||
|
||||
async function runRecoveryTest() {
|
||||
console.log('=== Recovery System Test ===');
|
||||
console.log(`Cleanup mode: ${cleanup}`);
|
||||
console.log('');
|
||||
|
||||
try {
|
||||
if (cleanup) {
|
||||
await cleanupTestJobs();
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 1: Create test jobs that simulate interrupted state
|
||||
console.log('Step 1: Creating test interrupted jobs...');
|
||||
await createTestInterruptedJobs();
|
||||
|
||||
// Step 2: Check if recovery system detects them
|
||||
console.log('Step 2: Checking if recovery system detects interrupted jobs...');
|
||||
const needsRecovery = await hasJobsNeedingRecovery();
|
||||
console.log(`Jobs needing recovery: ${needsRecovery}`);
|
||||
|
||||
if (!needsRecovery) {
|
||||
console.log('❌ Recovery system did not detect interrupted jobs');
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 3: Get recovery status
|
||||
console.log('Step 3: Getting recovery status...');
|
||||
const status = getRecoveryStatus();
|
||||
console.log('Recovery status:', status);
|
||||
|
||||
// Step 4: Run recovery
|
||||
console.log('Step 4: Running recovery...');
|
||||
const recoveryResult = await initializeRecovery({
|
||||
skipIfRecentAttempt: false,
|
||||
maxRetries: 2,
|
||||
retryDelay: 2000,
|
||||
});
|
||||
|
||||
console.log(`Recovery result: ${recoveryResult}`);
|
||||
|
||||
// Step 5: Verify recovery completed
|
||||
console.log('Step 5: Verifying recovery completed...');
|
||||
const stillNeedsRecovery = await hasJobsNeedingRecovery();
|
||||
console.log(`Jobs still needing recovery: ${stillNeedsRecovery}`);
|
||||
|
||||
// Step 6: Check final job states
|
||||
console.log('Step 6: Checking final job states...');
|
||||
await checkTestJobStates();
|
||||
|
||||
console.log('');
|
||||
console.log('✅ Recovery test completed successfully!');
|
||||
console.log('Run with --cleanup to remove test jobs');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Recovery test failed:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create test jobs that simulate interrupted state
|
||||
*/
|
||||
async function createTestInterruptedJobs() {
|
||||
const testJobs = [
|
||||
{
|
||||
repositoryId: uuidv4(),
|
||||
repositoryName: "test-repo-1",
|
||||
message: "Test mirror job 1",
|
||||
status: "mirroring" as const,
|
||||
jobType: "mirror" as const,
|
||||
},
|
||||
{
|
||||
repositoryId: uuidv4(),
|
||||
repositoryName: "test-repo-2",
|
||||
message: "Test sync job 2",
|
||||
status: "syncing" as const,
|
||||
jobType: "sync" as const,
|
||||
},
|
||||
];
|
||||
|
||||
for (const job of testJobs) {
|
||||
const jobId = await createMirrorJob({
|
||||
userId: TEST_USER_ID,
|
||||
repositoryId: job.repositoryId,
|
||||
repositoryName: job.repositoryName,
|
||||
message: job.message,
|
||||
status: job.status,
|
||||
jobType: job.jobType,
|
||||
batchId: TEST_BATCH_ID,
|
||||
totalItems: 5,
|
||||
itemIds: [job.repositoryId, uuidv4(), uuidv4(), uuidv4(), uuidv4()],
|
||||
inProgress: true,
|
||||
skipDuplicateEvent: true,
|
||||
});
|
||||
|
||||
// Manually set the job to look interrupted (old timestamp)
|
||||
const oldTimestamp = new Date();
|
||||
oldTimestamp.setMinutes(oldTimestamp.getMinutes() - 15); // 15 minutes ago
|
||||
|
||||
await db
|
||||
.update(mirrorJobs)
|
||||
.set({
|
||||
startedAt: oldTimestamp,
|
||||
lastCheckpoint: oldTimestamp,
|
||||
})
|
||||
.where(eq(mirrorJobs.id, jobId));
|
||||
|
||||
console.log(`Created test job: ${jobId} (${job.repositoryName})`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check the final states of test jobs
|
||||
*/
|
||||
async function checkTestJobStates() {
|
||||
const testJobs = await db
|
||||
.select()
|
||||
.from(mirrorJobs)
|
||||
.where(eq(mirrorJobs.userId, TEST_USER_ID));
|
||||
|
||||
console.log(`Found ${testJobs.length} test jobs:`);
|
||||
|
||||
for (const job of testJobs) {
|
||||
console.log(`- Job ${job.id}: ${job.status} (inProgress: ${job.inProgress})`);
|
||||
console.log(` Message: ${job.message}`);
|
||||
console.log(` Started: ${job.startedAt ? new Date(job.startedAt).toISOString() : 'never'}`);
|
||||
console.log(` Completed: ${job.completedAt ? new Date(job.completedAt).toISOString() : 'never'}`);
|
||||
console.log('');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up test jobs
|
||||
*/
|
||||
async function cleanupTestJobs() {
|
||||
console.log('Cleaning up test jobs...');
|
||||
|
||||
const result = await db
|
||||
.delete(mirrorJobs)
|
||||
.where(eq(mirrorJobs.userId, TEST_USER_ID));
|
||||
|
||||
console.log('✅ Test jobs cleaned up successfully');
|
||||
}
|
||||
|
||||
// Handle process signals gracefully
|
||||
process.on('SIGINT', () => {
|
||||
console.log('\n⚠️ Test interrupted by SIGINT');
|
||||
process.exit(130);
|
||||
});
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
console.log('\n⚠️ Test interrupted by SIGTERM');
|
||||
process.exit(143);
|
||||
});
|
||||
|
||||
// Run the test
|
||||
runRecoveryTest();
|
||||
@@ -1,133 +0,0 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Script to update the mirror_jobs table with new columns for resilience
|
||||
*/
|
||||
|
||||
import { Database } from "bun:sqlite";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
// Define the database paths
|
||||
const dataDir = path.join(process.cwd(), "data");
|
||||
const dbPath = path.join(dataDir, "gitea-mirror.db");
|
||||
|
||||
// Ensure data directory exists
|
||||
if (!fs.existsSync(dataDir)) {
|
||||
fs.mkdirSync(dataDir, { recursive: true });
|
||||
console.log(`Created data directory at ${dataDir}`);
|
||||
}
|
||||
|
||||
// Check if database exists
|
||||
if (!fs.existsSync(dbPath)) {
|
||||
console.error(`Database file not found at ${dbPath}`);
|
||||
console.error("Please run 'bun run init-db' first to create the database.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Connect to the database
|
||||
const db = new Database(dbPath);
|
||||
|
||||
// Enable foreign keys
|
||||
db.exec("PRAGMA foreign_keys = ON;");
|
||||
|
||||
// Function to check if a column exists in a table
|
||||
function columnExists(tableName: string, columnName: string): boolean {
|
||||
const result = db.query(
|
||||
`PRAGMA table_info(${tableName})`
|
||||
).all() as { name: string }[];
|
||||
|
||||
return result.some(column => column.name === columnName);
|
||||
}
|
||||
|
||||
// Main function to update the mirror_jobs table
|
||||
async function updateMirrorJobsTable() {
|
||||
console.log("Checking mirror_jobs table for missing columns...");
|
||||
|
||||
// Start a transaction
|
||||
db.exec("BEGIN TRANSACTION;");
|
||||
|
||||
try {
|
||||
// Check and add each new column if it doesn't exist
|
||||
const columnsToAdd = [
|
||||
{ name: "job_type", definition: "TEXT NOT NULL DEFAULT 'mirror'" },
|
||||
{ name: "batch_id", definition: "TEXT" },
|
||||
{ name: "total_items", definition: "INTEGER" },
|
||||
{ name: "completed_items", definition: "INTEGER DEFAULT 0" },
|
||||
{ name: "item_ids", definition: "TEXT" }, // JSON array as text
|
||||
{ name: "completed_item_ids", definition: "TEXT DEFAULT '[]'" }, // JSON array as text
|
||||
{ name: "in_progress", definition: "INTEGER NOT NULL DEFAULT 0" }, // Boolean as integer
|
||||
{ name: "started_at", definition: "TIMESTAMP" },
|
||||
{ name: "completed_at", definition: "TIMESTAMP" },
|
||||
{ name: "last_checkpoint", definition: "TIMESTAMP" }
|
||||
];
|
||||
|
||||
let columnsAdded = 0;
|
||||
|
||||
for (const column of columnsToAdd) {
|
||||
if (!columnExists("mirror_jobs", column.name)) {
|
||||
console.log(`Adding column '${column.name}' to mirror_jobs table...`);
|
||||
db.exec(`ALTER TABLE mirror_jobs ADD COLUMN ${column.name} ${column.definition};`);
|
||||
columnsAdded++;
|
||||
}
|
||||
}
|
||||
|
||||
// Commit the transaction
|
||||
db.exec("COMMIT;");
|
||||
|
||||
if (columnsAdded > 0) {
|
||||
console.log(`✅ Added ${columnsAdded} new columns to mirror_jobs table.`);
|
||||
} else {
|
||||
console.log("✅ All required columns already exist in mirror_jobs table.");
|
||||
}
|
||||
|
||||
// Create indexes for better performance
|
||||
console.log("Creating indexes for mirror_jobs table...");
|
||||
|
||||
// Only create indexes if they don't exist
|
||||
const indexesResult = db.query(
|
||||
`SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='mirror_jobs'`
|
||||
).all() as { name: string }[];
|
||||
|
||||
const existingIndexes = indexesResult.map(idx => idx.name);
|
||||
|
||||
const indexesToCreate = [
|
||||
{ name: "idx_mirror_jobs_user_id", columns: "user_id" },
|
||||
{ name: "idx_mirror_jobs_batch_id", columns: "batch_id" },
|
||||
{ name: "idx_mirror_jobs_in_progress", columns: "in_progress" },
|
||||
{ name: "idx_mirror_jobs_job_type", columns: "job_type" },
|
||||
{ name: "idx_mirror_jobs_timestamp", columns: "timestamp" }
|
||||
];
|
||||
|
||||
let indexesCreated = 0;
|
||||
|
||||
for (const index of indexesToCreate) {
|
||||
if (!existingIndexes.includes(index.name)) {
|
||||
console.log(`Creating index '${index.name}'...`);
|
||||
db.exec(`CREATE INDEX ${index.name} ON mirror_jobs(${index.columns});`);
|
||||
indexesCreated++;
|
||||
}
|
||||
}
|
||||
|
||||
if (indexesCreated > 0) {
|
||||
console.log(`✅ Created ${indexesCreated} new indexes for mirror_jobs table.`);
|
||||
} else {
|
||||
console.log("✅ All required indexes already exist for mirror_jobs table.");
|
||||
}
|
||||
|
||||
console.log("Mirror jobs table update completed successfully.");
|
||||
} catch (error) {
|
||||
// Rollback the transaction in case of error
|
||||
db.exec("ROLLBACK;");
|
||||
console.error("❌ Error updating mirror_jobs table:", error);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
// Close the database connection
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
// Run the update function
|
||||
updateMirrorJobsTable().catch(error => {
|
||||
console.error("Unhandled error:", error);
|
||||
process.exit(1);
|
||||
});
|
||||
81
src/components/NotFound.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import React from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { Home, ArrowLeft, GitBranch, BookOpen, Settings, FileQuestion } from "lucide-react";
|
||||
|
||||
export function NotFound() {
|
||||
return (
|
||||
<div className="h-dvh bg-muted/30 flex items-center justify-center p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="text-center pb-4">
|
||||
<div className="mx-auto mb-4 h-16 w-16 rounded-full bg-muted flex items-center justify-center">
|
||||
<FileQuestion className="h-8 w-8 text-muted-foreground" />
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold">404</h1>
|
||||
<h2 className="text-xl font-semibold mt-2">Page Not Found</h2>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
The page you're looking for doesn't exist or has been moved.
|
||||
</p>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-6">
|
||||
{/* Action Buttons */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<Button asChild className="w-full">
|
||||
<a href="/">
|
||||
<Home className="mr-2 h-4 w-4" />
|
||||
Go to Dashboard
|
||||
</a>
|
||||
</Button>
|
||||
<Button variant="outline" className="w-full" onClick={() => window.history.back()}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Go Back
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<span className="w-full border-t" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-card px-2 text-muted-foreground">or visit</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Links */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<a
|
||||
href="/repositories"
|
||||
className="flex flex-col items-center gap-2 p-3 rounded-md hover:bg-muted transition-colors"
|
||||
>
|
||||
<GitBranch className="h-5 w-5 text-muted-foreground" />
|
||||
<span className="text-xs">Repositories</span>
|
||||
</a>
|
||||
<a
|
||||
href="/config"
|
||||
className="flex flex-col items-center gap-2 p-3 rounded-md hover:bg-muted transition-colors"
|
||||
>
|
||||
<Settings className="h-5 w-5 text-muted-foreground" />
|
||||
<span className="text-xs">Config</span>
|
||||
</a>
|
||||
<a
|
||||
href="/docs"
|
||||
className="flex flex-col items-center gap-2 p-3 rounded-md hover:bg-muted transition-colors"
|
||||
>
|
||||
<BookOpen className="h-5 w-5 text-muted-foreground" />
|
||||
<span className="text-xs">Docs</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Error Code */}
|
||||
<div className="text-center pt-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Error Code: <code className="font-mono">404_NOT_FOUND</code>
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +1,20 @@
|
||||
import { useMemo, useRef, useState, useEffect } from "react";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import type { MirrorJob } from "@/lib/db/schema";
|
||||
import Fuse from "fuse.js";
|
||||
import { Button } from "../ui/button";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { Card } from "../ui/card";
|
||||
import { formatDate, getStatusColor } from "@/lib/utils";
|
||||
import { Skeleton } from "../ui/skeleton";
|
||||
import type { FilterParams } from "@/types/filter";
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import type { MirrorJob } from '@/lib/db/schema';
|
||||
import Fuse from 'fuse.js';
|
||||
import { Button } from '../ui/button';
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
import { Card } from '../ui/card';
|
||||
import { formatDate, getStatusColor } from '@/lib/utils';
|
||||
import { Skeleton } from '../ui/skeleton';
|
||||
import type { FilterParams } from '@/types/filter';
|
||||
|
||||
type MirrorJobWithKey = MirrorJob & { _rowKey: string };
|
||||
|
||||
interface ActivityListProps {
|
||||
activities: MirrorJob[];
|
||||
activities: MirrorJobWithKey[];
|
||||
isLoading: boolean;
|
||||
isLiveActive?: boolean;
|
||||
filter: FilterParams;
|
||||
setFilter: (filter: FilterParams) => void;
|
||||
}
|
||||
@@ -19,41 +22,48 @@ interface ActivityListProps {
|
||||
export default function ActivityList({
|
||||
activities,
|
||||
isLoading,
|
||||
isLiveActive = false,
|
||||
filter,
|
||||
setFilter,
|
||||
}: ActivityListProps) {
|
||||
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set());
|
||||
const [expandedItems, setExpandedItems] = useState<Set<string>>(
|
||||
() => new Set(),
|
||||
);
|
||||
|
||||
const parentRef = useRef<HTMLDivElement>(null);
|
||||
const rowRefs = useRef<Map<string, HTMLDivElement | null>>(new Map());
|
||||
// We keep the ref only for possible future scroll-to-row logic.
|
||||
const rowRefs = useRef<Map<string, HTMLDivElement | null>>(new Map()); // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||
|
||||
const filteredActivities = useMemo(() => {
|
||||
let result = activities;
|
||||
|
||||
if (filter.status) {
|
||||
result = result.filter((activity) => activity.status === filter.status);
|
||||
result = result.filter((a) => a.status === filter.status);
|
||||
}
|
||||
|
||||
if (filter.type) {
|
||||
if (filter.type === 'repository') {
|
||||
result = result.filter((activity) => !!activity.repositoryId);
|
||||
} else if (filter.type === 'organization') {
|
||||
result = result.filter((activity) => !!activity.organizationId);
|
||||
}
|
||||
result =
|
||||
filter.type === 'repository'
|
||||
? result.filter((a) => !!a.repositoryId)
|
||||
: filter.type === 'organization'
|
||||
? result.filter((a) => !!a.organizationId)
|
||||
: result;
|
||||
}
|
||||
|
||||
if (filter.name) {
|
||||
result = result.filter((activity) =>
|
||||
activity.repositoryName === filter.name ||
|
||||
activity.organizationName === filter.name
|
||||
result = result.filter(
|
||||
(a) =>
|
||||
a.repositoryName === filter.name ||
|
||||
a.organizationName === filter.name,
|
||||
);
|
||||
}
|
||||
|
||||
if (filter.searchTerm) {
|
||||
const fuse = new Fuse(result, {
|
||||
keys: ["message", "details", "organizationName", "repositoryName"],
|
||||
keys: ['message', 'details', 'organizationName', 'repositoryName'],
|
||||
threshold: 0.3,
|
||||
});
|
||||
result = fuse.search(filter.searchTerm).map((res) => res.item);
|
||||
result = fuse.search(filter.searchTerm).map((r) => r.item);
|
||||
}
|
||||
|
||||
return result;
|
||||
@@ -62,10 +72,8 @@ export default function ActivityList({
|
||||
const virtualizer = useVirtualizer({
|
||||
count: filteredActivities.length,
|
||||
getScrollElement: () => parentRef.current,
|
||||
estimateSize: (index) => {
|
||||
const activity = filteredActivities[index];
|
||||
return expandedItems.has(activity.id || "") ? 217 : 120;
|
||||
},
|
||||
estimateSize: (idx) =>
|
||||
expandedItems.has(filteredActivities[idx]._rowKey) ? 217 : 120,
|
||||
overscan: 5,
|
||||
measureElement: (el) => el.getBoundingClientRect().height + 8,
|
||||
});
|
||||
@@ -74,118 +82,128 @@ export default function ActivityList({
|
||||
virtualizer.measure();
|
||||
}, [expandedItems, virtualizer]);
|
||||
|
||||
return isLoading ? (
|
||||
<div className="flex flex-col gap-y-4">
|
||||
{Array.from({ length: 5 }, (_, index) => (
|
||||
<Skeleton key={index} className="h-28 w-full rounded-md" />
|
||||
))}
|
||||
</div>
|
||||
) : filteredActivities.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<RefreshCw className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-medium">No activities found</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1 mb-4 max-w-md">
|
||||
{filter.searchTerm || filter.status || filter.type || filter.name
|
||||
? "Try adjusting your search or filter criteria."
|
||||
: "No mirroring activities have been recorded yet."}
|
||||
</p>
|
||||
{filter.searchTerm || filter.status || filter.type || filter.name ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setFilter({ searchTerm: "", status: "", type: "", name: "" });
|
||||
/* ------------------------------ render ------------------------------ */
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className='flex flex-col gap-y-4'>
|
||||
{Array.from({ length: 5 }, (_, i) => (
|
||||
<Skeleton key={i} className='h-28 w-full rounded-md' />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (filteredActivities.length === 0) {
|
||||
const hasFilter =
|
||||
filter.searchTerm || filter.status || filter.type || filter.name;
|
||||
|
||||
return (
|
||||
<div className='flex flex-col items-center justify-center py-12 text-center'>
|
||||
<RefreshCw className='mb-4 h-12 w-12 text-muted-foreground' />
|
||||
<h3 className='text-lg font-medium'>No activities found</h3>
|
||||
<p className='mt-1 mb-4 max-w-md text-sm text-muted-foreground'>
|
||||
{hasFilter
|
||||
? 'Try adjusting your search or filter criteria.'
|
||||
: 'No mirroring activities have been recorded yet.'}
|
||||
</p>
|
||||
{hasFilter && (
|
||||
<Button
|
||||
variant='outline'
|
||||
onClick={() =>
|
||||
setFilter({ searchTerm: '', status: '', type: '', name: '' })
|
||||
}
|
||||
>
|
||||
Clear Filters
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<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'
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: virtualizer.getTotalSize(),
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
Clear Filters
|
||||
</Button>
|
||||
) : (
|
||||
<Button>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Refresh
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Card
|
||||
className="border rounded-md max-h-[calc(100dvh-191px)] overflow-y-auto relative"
|
||||
ref={parentRef}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: virtualizer.getTotalSize(),
|
||||
position: "relative",
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
{virtualizer.getVirtualItems().map((virtualRow) => {
|
||||
const activity = filteredActivities[virtualRow.index];
|
||||
const isExpanded = expandedItems.has(activity.id || "");
|
||||
const key = activity.id || String(virtualRow.index);
|
||||
{virtualizer.getVirtualItems().map((vRow) => {
|
||||
const activity = filteredActivities[vRow.index];
|
||||
const isExpanded = expandedItems.has(activity._rowKey);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
key={activity._rowKey}
|
||||
ref={(node) => {
|
||||
if (node) {
|
||||
rowRefs.current.set(key, node);
|
||||
virtualizer.measureElement(node);
|
||||
}
|
||||
rowRefs.current.set(activity._rowKey, node);
|
||||
if (node) virtualizer.measureElement(node);
|
||||
}}
|
||||
style={{
|
||||
position: "absolute",
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
paddingBottom: "8px",
|
||||
width: '100%',
|
||||
transform: `translateY(${vRow.start}px)`,
|
||||
paddingBottom: '8px',
|
||||
}}
|
||||
className="border-b px-4 pt-4"
|
||||
className='border-b px-4 pt-4'
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="relative mt-2">
|
||||
<div className='flex items-start gap-4'>
|
||||
<div className='relative mt-2'>
|
||||
<div
|
||||
className={`h-2 w-2 rounded-full ${getStatusColor(
|
||||
activity.status
|
||||
activity.status,
|
||||
)}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between mb-1">
|
||||
<p className="font-medium">{activity.message}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
|
||||
<div className='flex-1'>
|
||||
<div className='mb-1 flex flex-col sm:flex-row sm:items-center sm:justify-between'>
|
||||
<p className='font-medium'>{activity.message}</p>
|
||||
<p className='text-sm text-muted-foreground'>
|
||||
{formatDate(activity.timestamp)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{activity.repositoryName && (
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
<p className='mb-2 text-sm text-muted-foreground'>
|
||||
Repository: {activity.repositoryName}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{activity.organizationName && (
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
<p className='mb-2 text-sm text-muted-foreground'>
|
||||
Organization: {activity.organizationName}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{activity.details && (
|
||||
<div className="mt-2">
|
||||
<div className='mt-2'>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
const newSet = new Set(expandedItems);
|
||||
const id = activity.id || "";
|
||||
newSet.has(id) ? newSet.delete(id) : newSet.add(id);
|
||||
setExpandedItems(newSet);
|
||||
}}
|
||||
className="text-xs h-7 px-2"
|
||||
variant='ghost'
|
||||
className='h-7 px-2 text-xs'
|
||||
onClick={() =>
|
||||
setExpandedItems((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.has(activity._rowKey)
|
||||
? next.delete(activity._rowKey)
|
||||
: next.add(activity._rowKey);
|
||||
return next;
|
||||
})
|
||||
}
|
||||
>
|
||||
{isExpanded ? "Hide Details" : "Show Details"}
|
||||
{isExpanded ? 'Hide Details' : 'Show Details'}
|
||||
</Button>
|
||||
|
||||
{isExpanded && (
|
||||
<pre className="mt-2 p-3 bg-muted rounded-md text-xs overflow-auto whitespace-pre-wrap min-h-[100px]">
|
||||
<pre className='mt-2 min-h-[100px] whitespace-pre-wrap overflow-auto rounded-md bg-muted p-3 text-xs'>
|
||||
{activity.details}
|
||||
</pre>
|
||||
)}
|
||||
@@ -198,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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,289 +1,425 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Search, Download, RefreshCw, ChevronDown } from "lucide-react";
|
||||
import { useCallback, useEffect, useState, useRef } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ChevronDown, Download, RefreshCw, Search, Trash2 } from 'lucide-react';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "../ui/dropdown-menu";
|
||||
import { apiRequest, formatDate } from "@/lib/utils";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import type { MirrorJob } from "@/lib/db/schema";
|
||||
import type { ActivityApiResponse } from "@/types/activities";
|
||||
} from '../ui/dropdown-menu';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '../ui/dialog';
|
||||
import { apiRequest, formatDate, showErrorToast } from '@/lib/utils';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import type { MirrorJob } from '@/lib/db/schema';
|
||||
import type { ActivityApiResponse } from '@/types/activities';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "../ui/select";
|
||||
import { repoStatusEnum, type RepoStatus } from "@/types/Repository";
|
||||
import ActivityList from "./ActivityList";
|
||||
import { ActivityNameCombobox } from "./ActivityNameCombobox";
|
||||
import { useSSE } from "@/hooks/useSEE";
|
||||
import { useFilterParams } from "@/hooks/useFilterParams";
|
||||
import { toast } from "sonner";
|
||||
} from '../ui/select';
|
||||
import { repoStatusEnum, type RepoStatus } from '@/types/Repository';
|
||||
import ActivityList from './ActivityList';
|
||||
import { ActivityNameCombobox } from './ActivityNameCombobox';
|
||||
import { useSSE } from '@/hooks/useSEE';
|
||||
import { useFilterParams } from '@/hooks/useFilterParams';
|
||||
import { toast } from 'sonner';
|
||||
import { useLiveRefresh } from '@/hooks/useLiveRefresh';
|
||||
import { useConfigStatus } from '@/hooks/useConfigStatus';
|
||||
import { useNavigation } from '@/components/layout/MainLayout';
|
||||
|
||||
type MirrorJobWithKey = MirrorJob & { _rowKey: string };
|
||||
|
||||
// Maximum number of activities to keep in memory to prevent performance issues
|
||||
const MAX_ACTIVITIES = 1000;
|
||||
|
||||
// More robust key generation to prevent collisions
|
||||
function genKey(job: MirrorJob, index?: number): string {
|
||||
const baseId = job.id || `temp-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
const timestamp = job.timestamp instanceof Date ? job.timestamp.getTime() : new Date(job.timestamp).getTime();
|
||||
const indexSuffix = index !== undefined ? `-${index}` : '';
|
||||
return `${baseId}-${timestamp}${indexSuffix}`;
|
||||
}
|
||||
|
||||
// Create a deep clone without structuredClone for better browser compatibility
|
||||
function deepClone<T>(obj: T): T {
|
||||
if (obj === null || typeof obj !== 'object') return obj;
|
||||
if (obj instanceof Date) return new Date(obj.getTime()) as T;
|
||||
if (Array.isArray(obj)) return obj.map(item => deepClone(item)) as T;
|
||||
|
||||
const cloned = {} as T;
|
||||
for (const key in obj) {
|
||||
if (Object.prototype.hasOwnProperty.call(obj, key)) {
|
||||
cloned[key] = deepClone(obj[key]);
|
||||
}
|
||||
}
|
||||
return cloned;
|
||||
}
|
||||
|
||||
export function ActivityLog() {
|
||||
const { user } = useAuth();
|
||||
const [activities, setActivities] = useState<MirrorJob[]>([]);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const { filter, setFilter } = useFilterParams({
|
||||
searchTerm: "",
|
||||
status: "",
|
||||
type: "",
|
||||
name: "",
|
||||
});
|
||||
const { registerRefreshCallback, isLiveEnabled } = useLiveRefresh();
|
||||
const { isFullyConfigured } = useConfigStatus();
|
||||
const { navigationKey } = useNavigation();
|
||||
|
||||
const handleNewMessage = useCallback((data: MirrorJob) => {
|
||||
setActivities((prevActivities) => [data, ...prevActivities]);
|
||||
const [activities, setActivities] = useState<MirrorJobWithKey[]>([]);
|
||||
const [isInitialLoading, setIsInitialLoading] = useState(false);
|
||||
const [showCleanupDialog, setShowCleanupDialog] = useState(false);
|
||||
|
||||
console.log("Received new log:", data);
|
||||
// Ref to track if component is mounted to prevent state updates after unmount
|
||||
const isMountedRef = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const { filter, setFilter } = useFilterParams({
|
||||
searchTerm: '',
|
||||
status: '',
|
||||
type: '',
|
||||
name: '',
|
||||
});
|
||||
|
||||
/* ----------------------------- SSE hook ----------------------------- */
|
||||
|
||||
const handleNewMessage = useCallback((data: MirrorJob) => {
|
||||
if (!isMountedRef.current) return;
|
||||
|
||||
setActivities((prev) => {
|
||||
// Create a deep clone of the new activity
|
||||
const clonedData = deepClone(data);
|
||||
|
||||
// Check if this activity already exists to prevent duplicates
|
||||
const existingIndex = prev.findIndex(activity =>
|
||||
activity.id === clonedData.id ||
|
||||
(activity.repositoryId === clonedData.repositoryId &&
|
||||
activity.organizationId === clonedData.organizationId &&
|
||||
activity.message === clonedData.message &&
|
||||
Math.abs(new Date(activity.timestamp).getTime() - new Date(clonedData.timestamp).getTime()) < 1000)
|
||||
);
|
||||
|
||||
if (existingIndex !== -1) {
|
||||
// Update existing activity instead of adding duplicate
|
||||
const updated = [...prev];
|
||||
updated[existingIndex] = {
|
||||
...clonedData,
|
||||
_rowKey: prev[existingIndex]._rowKey, // Keep the same key
|
||||
};
|
||||
return updated;
|
||||
}
|
||||
|
||||
// Add new activity with unique key
|
||||
const withKey: MirrorJobWithKey = {
|
||||
...clonedData,
|
||||
_rowKey: genKey(clonedData, prev.length),
|
||||
};
|
||||
|
||||
// Limit the number of activities to prevent memory issues
|
||||
const newActivities = [withKey, ...prev];
|
||||
return newActivities.slice(0, MAX_ACTIVITIES);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Use the SSE hook
|
||||
const { connected } = useSSE({
|
||||
userId: user?.id,
|
||||
onMessage: handleNewMessage,
|
||||
});
|
||||
|
||||
const fetchActivities = useCallback(async () => {
|
||||
if (!user) return false;
|
||||
/* ------------------------- initial fetch --------------------------- */
|
||||
|
||||
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 response = await apiRequest<ActivityApiResponse>(
|
||||
const res = await apiRequest<ActivityApiResponse>(
|
||||
`/activities?userId=${user.id}`,
|
||||
{
|
||||
method: "GET",
|
||||
}
|
||||
{ method: 'GET' },
|
||||
);
|
||||
|
||||
if (response.success) {
|
||||
setActivities(response.activities);
|
||||
return true;
|
||||
} else {
|
||||
toast.error(response.message || "Failed to fetch activities.");
|
||||
if (!res.success) {
|
||||
// Only show error toast for manual refreshes to avoid spam during live updates
|
||||
if (!isLiveRefresh) {
|
||||
showErrorToast(res.message ?? 'Failed to fetch activities.', toast);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Failed to fetch activities."
|
||||
);
|
||||
return false;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchActivities();
|
||||
}, [fetchActivities]);
|
||||
// Process activities with robust cloning and unique keys
|
||||
const data: MirrorJobWithKey[] = res.activities.map((activity, index) => {
|
||||
const clonedActivity = deepClone(activity);
|
||||
return {
|
||||
...clonedActivity,
|
||||
_rowKey: genKey(clonedActivity, index),
|
||||
};
|
||||
});
|
||||
|
||||
const handleRefreshActivities = async () => {
|
||||
const success = await fetchActivities();
|
||||
if (success) {
|
||||
toast.success("Activities refreshed successfully.");
|
||||
}
|
||||
};
|
||||
// Sort by timestamp (newest first) to ensure consistent ordering
|
||||
data.sort((a, b) => {
|
||||
const timeA = new Date(a.timestamp).getTime();
|
||||
const timeB = new Date(b.timestamp).getTime();
|
||||
return timeB - timeA;
|
||||
});
|
||||
|
||||
// Get the currently filtered activities
|
||||
const getFilteredActivities = () => {
|
||||
return activities.filter(activity => {
|
||||
let isIncluded = true;
|
||||
|
||||
if (filter.status) {
|
||||
isIncluded = isIncluded && activity.status === filter.status;
|
||||
if (isMountedRef.current) {
|
||||
setActivities(data);
|
||||
}
|
||||
|
||||
if (filter.type) {
|
||||
if (filter.type === 'repository') {
|
||||
isIncluded = isIncluded && !!activity.repositoryId;
|
||||
} else if (filter.type === 'organization') {
|
||||
isIncluded = isIncluded && !!activity.organizationId;
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (isMountedRef.current) {
|
||||
// Only show error toast for manual refreshes to avoid spam during live updates
|
||||
if (!isLiveRefresh) {
|
||||
showErrorToast(err, toast);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
if (isMountedRef.current && !isLiveRefresh) {
|
||||
setIsInitialLoading(false);
|
||||
}
|
||||
}
|
||||
}, [user?.id]); // Only depend on user.id, not entire user object
|
||||
|
||||
if (filter.name) {
|
||||
isIncluded = isIncluded && (
|
||||
activity.repositoryName === filter.name ||
|
||||
activity.organizationName === filter.name
|
||||
);
|
||||
useEffect(() => {
|
||||
// Reset loading state when component becomes active
|
||||
setIsInitialLoading(true);
|
||||
fetchActivities(false); // Manual refresh, not live
|
||||
}, [fetchActivities, navigationKey]); // Include navigationKey to trigger on navigation
|
||||
|
||||
// Register with global live refresh system
|
||||
useEffect(() => {
|
||||
// Only register for live refresh if configuration is complete
|
||||
// Activity logs can exist from previous runs, but new activities won't be generated without config
|
||||
if (!isFullyConfigured) {
|
||||
return;
|
||||
}
|
||||
|
||||
const unregister = registerRefreshCallback(() => {
|
||||
fetchActivities(true); // Live refresh
|
||||
});
|
||||
|
||||
return unregister;
|
||||
}, [registerRefreshCallback, fetchActivities, isFullyConfigured]);
|
||||
|
||||
/* ---------------------- filtering + exporting ---------------------- */
|
||||
|
||||
const applyLightFilter = (list: MirrorJobWithKey[]) => {
|
||||
return list.filter((a) => {
|
||||
if (filter.status && a.status !== filter.status) return false;
|
||||
|
||||
if (filter.type === 'repository' && !a.repositoryId) return false;
|
||||
if (filter.type === 'organization' && !a.organizationId) return false;
|
||||
|
||||
if (
|
||||
filter.name &&
|
||||
a.repositoryName !== filter.name &&
|
||||
a.organizationName !== filter.name
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Note: We're not applying the search term filter here as that would require
|
||||
// re-implementing the Fuse.js search logic
|
||||
|
||||
return isIncluded;
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
// Function to export activities as CSV
|
||||
const exportAsCSV = () => {
|
||||
const filteredActivities = getFilteredActivities();
|
||||
const rows = applyLightFilter(activities);
|
||||
if (!rows.length) return toast.error('No activities to export.');
|
||||
|
||||
if (filteredActivities.length === 0) {
|
||||
toast.error("No activities to export.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Create CSV content
|
||||
const headers = ["Timestamp", "Message", "Status", "Repository", "Organization", "Details"];
|
||||
const csvRows = [
|
||||
headers.join(","),
|
||||
...filteredActivities.map(activity => {
|
||||
const formattedDate = formatDate(activity.timestamp);
|
||||
// Escape fields that might contain commas or quotes
|
||||
const escapeCsvField = (field: string | null | undefined) => {
|
||||
if (!field) return '';
|
||||
if (field.includes(',') || field.includes('"') || field.includes('\n')) {
|
||||
return `"${field.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return field;
|
||||
};
|
||||
|
||||
return [
|
||||
formattedDate,
|
||||
escapeCsvField(activity.message),
|
||||
activity.status,
|
||||
escapeCsvField(activity.repositoryName || ''),
|
||||
escapeCsvField(activity.organizationName || ''),
|
||||
escapeCsvField(activity.details || '')
|
||||
].join(',');
|
||||
})
|
||||
const headers = [
|
||||
'Timestamp',
|
||||
'Message',
|
||||
'Status',
|
||||
'Repository',
|
||||
'Organization',
|
||||
'Details',
|
||||
];
|
||||
|
||||
const csvContent = csvRows.join('\n');
|
||||
const escape = (v: string | null | undefined) =>
|
||||
v && /[,\"\n]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v ?? '';
|
||||
|
||||
// Download the CSV file
|
||||
downloadFile(csvContent, 'text/csv;charset=utf-8;', 'activity_log_export.csv');
|
||||
const csv = [
|
||||
headers.join(','),
|
||||
...rows.map((a) =>
|
||||
[
|
||||
formatDate(a.timestamp),
|
||||
escape(a.message),
|
||||
a.status,
|
||||
escape(a.repositoryName),
|
||||
escape(a.organizationName),
|
||||
escape(a.details),
|
||||
].join(','),
|
||||
),
|
||||
].join('\n');
|
||||
|
||||
toast.success("Activity log exported as CSV successfully.");
|
||||
downloadFile(csv, 'text/csv;charset=utf-8;', 'activity_log_export.csv');
|
||||
toast.success('CSV exported.');
|
||||
};
|
||||
|
||||
// Function to export activities as JSON
|
||||
const exportAsJSON = () => {
|
||||
const filteredActivities = getFilteredActivities();
|
||||
const rows = applyLightFilter(activities);
|
||||
if (!rows.length) return toast.error('No activities to export.');
|
||||
|
||||
if (filteredActivities.length === 0) {
|
||||
toast.error("No activities to export.");
|
||||
return;
|
||||
}
|
||||
const json = JSON.stringify(
|
||||
rows.map((a) => ({
|
||||
...a,
|
||||
formattedTime: formatDate(a.timestamp),
|
||||
})),
|
||||
null,
|
||||
2,
|
||||
);
|
||||
|
||||
// Format the activities for export (removing any sensitive or unnecessary fields if needed)
|
||||
const activitiesForExport = filteredActivities.map(activity => ({
|
||||
id: activity.id,
|
||||
timestamp: activity.timestamp,
|
||||
formattedTime: formatDate(activity.timestamp),
|
||||
message: activity.message,
|
||||
status: activity.status,
|
||||
repositoryId: activity.repositoryId,
|
||||
repositoryName: activity.repositoryName,
|
||||
organizationId: activity.organizationId,
|
||||
organizationName: activity.organizationName,
|
||||
details: activity.details
|
||||
}));
|
||||
|
||||
const jsonContent = JSON.stringify(activitiesForExport, null, 2);
|
||||
|
||||
// Download the JSON file
|
||||
downloadFile(jsonContent, 'application/json', 'activity_log_export.json');
|
||||
|
||||
toast.success("Activity log exported as JSON successfully.");
|
||||
downloadFile(json, 'application/json', 'activity_log_export.json');
|
||||
toast.success('JSON exported.');
|
||||
};
|
||||
|
||||
// Generic function to download a file
|
||||
const downloadFile = (content: string, mimeType: string, filename: string) => {
|
||||
// Add date to filename
|
||||
const date = new Date();
|
||||
const dateStr = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
||||
const filenameWithDate = filename.replace('.', `_${dateStr}.`);
|
||||
|
||||
// Create a download link
|
||||
const blob = new Blob([content], { type: mimeType });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const downloadFile = (
|
||||
content: string,
|
||||
mime: string,
|
||||
filename: string,
|
||||
): void => {
|
||||
const date = new Date().toISOString().slice(0, 10); // yyyy-mm-dd
|
||||
const link = document.createElement('a');
|
||||
|
||||
link.href = url;
|
||||
link.setAttribute('download', filenameWithDate);
|
||||
document.body.appendChild(link);
|
||||
link.href = URL.createObjectURL(new Blob([content], { type: mime }));
|
||||
link.download = filename.replace('.', `_${date}.`);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
};
|
||||
|
||||
const handleCleanupClick = () => {
|
||||
setShowCleanupDialog(true);
|
||||
};
|
||||
|
||||
const confirmCleanup = async () => {
|
||||
if (!user?.id) return;
|
||||
|
||||
try {
|
||||
setIsInitialLoading(true);
|
||||
setShowCleanupDialog(false);
|
||||
|
||||
const response = await fetch('/api/activities/cleanup', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ userId: user.id }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ error: 'Unknown error occurred' }));
|
||||
throw new Error(errorData.error || `HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const res = await response.json();
|
||||
|
||||
if (res.success) {
|
||||
// Clear the activities from the UI
|
||||
setActivities([]);
|
||||
toast.success(`All activities cleaned up successfully. Deleted ${res.result.mirrorJobsDeleted} mirror jobs and ${res.result.eventsDeleted} events.`);
|
||||
} else {
|
||||
showErrorToast(res.error || 'Failed to cleanup activities.', toast);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error cleaning up activities:', error);
|
||||
showErrorToast(error, toast);
|
||||
} finally {
|
||||
setIsInitialLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const cancelCleanup = () => {
|
||||
setShowCleanupDialog(false);
|
||||
};
|
||||
|
||||
/* ------------------------------ UI ------------------------------ */
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-y-8">
|
||||
<div className="flex flex-row items-center gap-4 w-full">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<div className='flex flex-col gap-y-8'>
|
||||
<div className='flex w-full flex-row items-center gap-4'>
|
||||
{/* search input */}
|
||||
<div className='relative flex-1'>
|
||||
<Search className='absolute left-2 top-2.5 h-4 w-4 text-muted-foreground' />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search activities..."
|
||||
className="pl-8 h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
type='text'
|
||||
placeholder='Search activities...'
|
||||
className='h-9 w-full rounded-md border border-input bg-background px-3 py-1 pl-8 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring'
|
||||
value={filter.searchTerm}
|
||||
onChange={(e) =>
|
||||
setFilter((prev) => ({ ...prev, searchTerm: e.target.value }))
|
||||
setFilter((prev) => ({
|
||||
...prev,
|
||||
searchTerm: e.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* status select */}
|
||||
<Select
|
||||
value={filter.status || "all"}
|
||||
onValueChange={(value) =>
|
||||
setFilter((prev) => ({
|
||||
...prev,
|
||||
status: value === "all" ? "" : (value as RepoStatus),
|
||||
value={filter.status || 'all'}
|
||||
onValueChange={(v) =>
|
||||
setFilter((p) => ({
|
||||
...p,
|
||||
status: v === 'all' ? '' : (v as RepoStatus),
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-[140px] h-9 max-h-9">
|
||||
<SelectValue placeholder="All Status" />
|
||||
<SelectTrigger className='h-9 w-[140px] max-h-9'>
|
||||
<SelectValue placeholder='All Status' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{["all", ...repoStatusEnum.options].map((status) => (
|
||||
<SelectItem key={status} value={status}>
|
||||
{status === "all"
|
||||
? "All Status"
|
||||
: status.charAt(0).toUpperCase() + status.slice(1)}
|
||||
{['all', ...repoStatusEnum.options].map((s) => (
|
||||
<SelectItem key={s} value={s}>
|
||||
{s === 'all' ? 'All Status' : s[0].toUpperCase() + s.slice(1)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* Repository/Organization Name Combobox */}
|
||||
{/* repo/org name combobox */}
|
||||
<ActivityNameCombobox
|
||||
activities={activities}
|
||||
value={filter.name || ""}
|
||||
onChange={(name: string) => setFilter((prev) => ({ ...prev, name }))}
|
||||
value={filter.name || ''}
|
||||
onChange={(name) => setFilter((p) => ({ ...p, name }))}
|
||||
/>
|
||||
{/* Filter by type: repository/org/all */}
|
||||
|
||||
{/* type select */}
|
||||
<Select
|
||||
value={filter.type || "all"}
|
||||
onValueChange={(value) =>
|
||||
setFilter((prev) => ({
|
||||
...prev,
|
||||
type: value === "all" ? "" : value,
|
||||
}))
|
||||
value={filter.type || 'all'}
|
||||
onValueChange={(v) =>
|
||||
setFilter((p) => ({ ...p, type: v === 'all' ? '' : v }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-[140px] h-9 max-h-9">
|
||||
<SelectValue placeholder="All Types" />
|
||||
<SelectTrigger className='h-9 w-[140px] max-h-9'>
|
||||
<SelectValue placeholder='All Types' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{['all', 'repository', 'organization'].map((type) => (
|
||||
<SelectItem key={type} value={type}>
|
||||
{type === 'all' ? 'All Types' : type.charAt(0).toUpperCase() + type.slice(1)}
|
||||
{['all', 'repository', 'organization'].map((t) => (
|
||||
<SelectItem key={t} value={t}>
|
||||
{t === 'all' ? 'All Types' : t[0].toUpperCase() + t.slice(1)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* export dropdown */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" className="flex items-center gap-1">
|
||||
<Download className="h-4 w-4 mr-1" />
|
||||
<Button variant='outline' className='flex items-center gap-1'>
|
||||
<Download className='mr-1 h-4 w-4' />
|
||||
Export
|
||||
<ChevronDown className="h-4 w-4 ml-1" />
|
||||
<ChevronDown className='ml-1 h-4 w-4' />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
@@ -295,19 +431,61 @@ export function ActivityLog() {
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button onClick={handleRefreshActivities}>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Refresh
|
||||
|
||||
{/* refresh */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => fetchActivities(false)} // Manual refresh, show loading skeleton
|
||||
title="Refresh activity log"
|
||||
>
|
||||
<RefreshCw className='h-4 w-4' />
|
||||
</Button>
|
||||
|
||||
{/* cleanup all activities */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleCleanupClick}
|
||||
title="Delete all activities"
|
||||
className="text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-y-6">
|
||||
<ActivityList
|
||||
activities={activities}
|
||||
isLoading={isLoading || !connected}
|
||||
filter={filter}
|
||||
setFilter={setFilter}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* activity list */}
|
||||
<ActivityList
|
||||
activities={applyLightFilter(activities)}
|
||||
isLoading={isInitialLoading || !connected}
|
||||
isLiveActive={isLiveEnabled && isFullyConfigured}
|
||||
filter={filter}
|
||||
setFilter={setFilter}
|
||||
/>
|
||||
|
||||
{/* cleanup confirmation dialog */}
|
||||
<Dialog open={showCleanupDialog} onOpenChange={setShowCleanupDialog}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete All Activities</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete ALL activities? This action cannot be undone and will remove all mirror jobs and events from the database.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={cancelCleanup}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={confirmCleanup}
|
||||
disabled={isInitialLoading}
|
||||
>
|
||||
{isInitialLoading ? 'Deleting...' : 'Delete All Activities'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { SiGitea } from 'react-icons/si';
|
||||
|
||||
import { toast, Toaster } from 'sonner';
|
||||
import { FlipHorizontal } from 'lucide-react';
|
||||
import { showErrorToast } from '@/lib/utils';
|
||||
|
||||
|
||||
export function LoginForm() {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@@ -45,10 +46,10 @@ export function LoginForm() {
|
||||
window.location.href = '/';
|
||||
}, 1000);
|
||||
} else {
|
||||
toast.error(data.error || 'Login failed. Please try again.');
|
||||
showErrorToast(data.error || 'Login failed. Please try again.', toast);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('An error occurred while logging in. Please try again.');
|
||||
showErrorToast(error, toast);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -59,7 +60,16 @@ export function LoginForm() {
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="text-center">
|
||||
<div className="flex justify-center mb-4">
|
||||
<SiGitea className="h-10 w-10" />
|
||||
<img
|
||||
src="/logo-light.svg"
|
||||
alt="Gitea Mirror Logo"
|
||||
className="h-10 w-10 dark:hidden"
|
||||
/>
|
||||
<img
|
||||
src="/logo-dark.svg"
|
||||
alt="Gitea Mirror Logo"
|
||||
className="h-10 w-10 hidden dark:block"
|
||||
/>
|
||||
</div>
|
||||
<CardTitle className="text-2xl">Gitea Mirror</CardTitle>
|
||||
<CardDescription>
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { GitMerge } from 'lucide-react';
|
||||
import { toast, Toaster } from 'sonner';
|
||||
import { showErrorToast } from '@/lib/utils';
|
||||
|
||||
export function SignupForm() {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@@ -51,10 +51,10 @@ export function SignupForm() {
|
||||
window.location.href = '/';
|
||||
}, 1500);
|
||||
} else {
|
||||
toast.error(data.error || 'Failed to create account. Please try again.');
|
||||
showErrorToast(data.error || 'Failed to create account. Please try again.', toast);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('An error occurred while creating your account. Please try again.');
|
||||
showErrorToast(error, toast);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -65,7 +65,16 @@ export function SignupForm() {
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="text-center">
|
||||
<div className="flex justify-center mb-4">
|
||||
<GitMerge className="h-10 w-10" />
|
||||
<img
|
||||
src="/logo-light.svg"
|
||||
alt="Gitea Mirror Logo"
|
||||
className="h-10 w-10 dark:hidden"
|
||||
/>
|
||||
<img
|
||||
src="/logo-dark.svg"
|
||||
alt="Gitea Mirror Logo"
|
||||
className="h-10 w-10 hidden dark:block"
|
||||
/>
|
||||
</div>
|
||||
<CardTitle className="text-2xl">Create Admin Account</CardTitle>
|
||||
<CardDescription>
|
||||
|
||||
90
src/components/config/AdvancedOptionsForm.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import React from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Checkbox } from "../ui/checkbox";
|
||||
import type { AdvancedOptions } from "@/types/config";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
|
||||
interface AdvancedOptionsFormProps {
|
||||
config: AdvancedOptions;
|
||||
setConfig: React.Dispatch<React.SetStateAction<AdvancedOptions>>;
|
||||
onAutoSave?: (config: AdvancedOptions) => Promise<void>;
|
||||
isAutoSaving?: boolean;
|
||||
}
|
||||
|
||||
export function AdvancedOptionsForm({
|
||||
config,
|
||||
setConfig,
|
||||
onAutoSave,
|
||||
isAutoSaving = false,
|
||||
}: AdvancedOptionsFormProps) {
|
||||
const handleChange = (name: string, checked: boolean) => {
|
||||
const newConfig = {
|
||||
...config,
|
||||
[name]: checked,
|
||||
};
|
||||
|
||||
setConfig(newConfig);
|
||||
|
||||
// Auto-save
|
||||
if (onAutoSave) {
|
||||
onAutoSave(newConfig);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="self-start">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg font-semibold flex items-center justify-between">
|
||||
Advanced Options
|
||||
{isAutoSaving && (
|
||||
<div className="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>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center">
|
||||
<Checkbox
|
||||
id="skip-forks"
|
||||
checked={config.skipForks}
|
||||
onCheckedChange={(checked) =>
|
||||
handleChange("skipForks", Boolean(checked))
|
||||
}
|
||||
/>
|
||||
<label
|
||||
htmlFor="skip-forks"
|
||||
className="ml-2 text-sm select-none"
|
||||
>
|
||||
Skip forks
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground ml-6">
|
||||
Don't mirror repositories that are forks of other repositories
|
||||
</p>
|
||||
|
||||
<div className="flex items-center">
|
||||
<Checkbox
|
||||
id="skip-starred-issues"
|
||||
checked={config.skipStarredIssues}
|
||||
onCheckedChange={(checked) =>
|
||||
handleChange("skipStarredIssues", Boolean(checked))
|
||||
}
|
||||
/>
|
||||
<label
|
||||
htmlFor="skip-starred-issues"
|
||||
className="ml-2 text-sm select-none"
|
||||
>
|
||||
Don't fetch issues for starred repos
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground ml-6">
|
||||
Skip mirroring issues and pull requests for starred repositories
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
332
src/components/config/AutomationSettings.tsx
Normal file
@@ -0,0 +1,332 @@
|
||||
import { useEffect } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Clock,
|
||||
Database,
|
||||
RefreshCw,
|
||||
Calendar,
|
||||
Activity,
|
||||
Zap,
|
||||
Info
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import type { ScheduleConfig, DatabaseCleanupConfig } from "@/types/config";
|
||||
import { formatDate } from "@/lib/utils";
|
||||
|
||||
interface AutomationSettingsProps {
|
||||
scheduleConfig: ScheduleConfig;
|
||||
cleanupConfig: DatabaseCleanupConfig;
|
||||
onScheduleChange: (config: ScheduleConfig) => void;
|
||||
onCleanupChange: (config: DatabaseCleanupConfig) => void;
|
||||
isAutoSavingSchedule?: boolean;
|
||||
isAutoSavingCleanup?: boolean;
|
||||
}
|
||||
|
||||
const scheduleIntervals = [
|
||||
{ label: "Every hour", value: 3600 },
|
||||
{ label: "Every 2 hours", value: 7200 },
|
||||
{ label: "Every 4 hours", value: 14400 },
|
||||
{ label: "Every 8 hours", value: 28800 },
|
||||
{ label: "Every 12 hours", value: 43200 },
|
||||
{ label: "Daily", value: 86400 },
|
||||
{ label: "Every 2 days", value: 172800 },
|
||||
{ label: "Weekly", value: 604800 },
|
||||
];
|
||||
|
||||
const retentionPeriods = [
|
||||
{ label: "1 day", value: 86400 },
|
||||
{ label: "3 days", value: 259200 },
|
||||
{ label: "1 week", value: 604800 },
|
||||
{ label: "2 weeks", value: 1209600 },
|
||||
{ label: "1 month", value: 2592000 },
|
||||
{ label: "2 months", value: 5184000 },
|
||||
{ label: "3 months", value: 7776000 },
|
||||
];
|
||||
|
||||
function getCleanupInterval(retentionSeconds: number): number {
|
||||
const days = retentionSeconds / 86400;
|
||||
if (days <= 1) return 21600; // 6 hours
|
||||
if (days <= 3) return 43200; // 12 hours
|
||||
if (days <= 7) return 86400; // 24 hours
|
||||
if (days <= 30) return 172800; // 48 hours
|
||||
return 604800; // 1 week
|
||||
}
|
||||
|
||||
function getCleanupFrequencyText(retentionSeconds: number): string {
|
||||
const days = retentionSeconds / 86400;
|
||||
if (days <= 1) return "every 6 hours";
|
||||
if (days <= 3) return "every 12 hours";
|
||||
if (days <= 7) return "daily";
|
||||
if (days <= 30) return "every 2 days";
|
||||
return "weekly";
|
||||
}
|
||||
|
||||
export function AutomationSettings({
|
||||
scheduleConfig,
|
||||
cleanupConfig,
|
||||
onScheduleChange,
|
||||
onCleanupChange,
|
||||
isAutoSavingSchedule,
|
||||
isAutoSavingCleanup,
|
||||
}: AutomationSettingsProps) {
|
||||
// Update nextRun for cleanup when settings change
|
||||
useEffect(() => {
|
||||
if (cleanupConfig.enabled && !cleanupConfig.nextRun) {
|
||||
const cleanupInterval = getCleanupInterval(cleanupConfig.retentionDays);
|
||||
const nextRun = new Date(Date.now() + cleanupInterval * 1000);
|
||||
onCleanupChange({ ...cleanupConfig, nextRun });
|
||||
}
|
||||
}, [cleanupConfig.enabled, cleanupConfig.retentionDays]);
|
||||
|
||||
return (
|
||||
<Card className="w-full">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg font-semibold flex items-center gap-2">
|
||||
<Zap className="h-5 w-5" />
|
||||
Automation & Maintenance
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Automatic Mirroring Section */}
|
||||
<div className="space-y-4 p-4 border border-border rounded-lg bg-card/50">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium flex items-center gap-2">
|
||||
<RefreshCw className="h-4 w-4 text-primary" />
|
||||
Automatic Mirroring
|
||||
</h3>
|
||||
{isAutoSavingSchedule && (
|
||||
<Activity className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start space-x-3">
|
||||
<Checkbox
|
||||
id="enable-auto-mirror"
|
||||
checked={scheduleConfig.enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
onScheduleChange({ ...scheduleConfig, enabled: !!checked })
|
||||
}
|
||||
/>
|
||||
<div className="space-y-0.5 flex-1">
|
||||
<Label
|
||||
htmlFor="enable-auto-mirror"
|
||||
className="text-sm font-normal cursor-pointer"
|
||||
>
|
||||
Enable automatic repository syncing
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Periodically check GitHub for changes and mirror them to Gitea
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{scheduleConfig.enabled && (
|
||||
<div className="ml-6 space-y-3">
|
||||
<div>
|
||||
<Label htmlFor="mirror-interval" className="text-sm">
|
||||
Sync frequency
|
||||
</Label>
|
||||
<Select
|
||||
value={scheduleConfig.interval.toString()}
|
||||
onValueChange={(value) =>
|
||||
onScheduleChange({
|
||||
...scheduleConfig,
|
||||
interval: parseInt(value, 10),
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="mirror-interval" className="mt-1.5">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{scheduleIntervals.map((option) => (
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={option.value.toString()}
|
||||
>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2 p-3 bg-muted/30 dark:bg-muted/10 rounded-md border border-border/50">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
Last sync
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{scheduleConfig.lastRun
|
||||
? formatDate(scheduleConfig.lastRun)
|
||||
: "Never"}
|
||||
</span>
|
||||
</div>
|
||||
{scheduleConfig.enabled && scheduleConfig.nextRun && (
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Calendar className="h-3.5 w-3.5" />
|
||||
Next sync
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{formatDate(scheduleConfig.nextRun)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Database Cleanup Section */}
|
||||
<div className="space-y-4 p-4 border border-border rounded-lg bg-card/50">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium flex items-center gap-2">
|
||||
<Database className="h-4 w-4 text-primary" />
|
||||
Database Maintenance
|
||||
</h3>
|
||||
{isAutoSavingCleanup && (
|
||||
<Activity className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start space-x-3">
|
||||
<Checkbox
|
||||
id="enable-auto-cleanup"
|
||||
checked={cleanupConfig.enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
onCleanupChange({ ...cleanupConfig, enabled: !!checked })
|
||||
}
|
||||
/>
|
||||
<div className="space-y-0.5 flex-1">
|
||||
<Label
|
||||
htmlFor="enable-auto-cleanup"
|
||||
className="text-sm font-normal cursor-pointer"
|
||||
>
|
||||
Enable automatic database cleanup
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Remove old activity logs and events to optimize storage
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{cleanupConfig.enabled && (
|
||||
<div className="ml-6 space-y-3">
|
||||
<div>
|
||||
<Label htmlFor="retention-period" className="text-sm flex items-center gap-2">
|
||||
Data retention period
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Info className="h-3 w-3 text-muted-foreground" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-xs">
|
||||
<p className="text-xs">
|
||||
Activity logs and events older than this will be removed.
|
||||
Cleanup frequency is automatically optimized based on your retention period.
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</Label>
|
||||
<Select
|
||||
value={cleanupConfig.retentionDays.toString()}
|
||||
onValueChange={(value) =>
|
||||
onCleanupChange({
|
||||
...cleanupConfig,
|
||||
retentionDays: parseInt(value, 10),
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="retention-period" className="mt-1.5">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{retentionPeriods.map((option) => (
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={option.value.toString()}
|
||||
>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{cleanupConfig.enabled && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Cleanup runs {getCleanupFrequencyText(cleanupConfig.retentionDays)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2 p-3 bg-muted/30 dark:bg-muted/10 rounded-md border border-border/50">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
Last cleanup
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{cleanupConfig.lastRun
|
||||
? formatDate(cleanupConfig.lastRun)
|
||||
: "Never"}
|
||||
</span>
|
||||
</div>
|
||||
{cleanupConfig.enabled && cleanupConfig.nextRun && (
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Calendar className="h-3.5 w-3.5" />
|
||||
Next cleanup
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{cleanupConfig.nextRun
|
||||
? formatDate(cleanupConfig.nextRun)
|
||||
: "Calculating..."}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 p-4 bg-blue-50/50 dark:bg-blue-950/20 rounded-lg border border-blue-200 dark:border-blue-900">
|
||||
<div className="flex gap-3">
|
||||
<Info className="h-4 w-4 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-blue-900 dark:text-blue-100">
|
||||
Background Operations
|
||||
</p>
|
||||
<p className="text-xs text-blue-800 dark:text-blue-200/80">
|
||||
These automated tasks run in the background to keep your mirrors up-to-date and maintain optimal database performance.
|
||||
Choose intervals that match your workflow and repository update frequency.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import { GitHubConfigForm } from './GitHubConfigForm';
|
||||
import { GiteaConfigForm } from './GiteaConfigForm';
|
||||
import { ScheduleConfigForm } from './ScheduleConfigForm';
|
||||
import { AutomationSettings } from './AutomationSettings';
|
||||
import type {
|
||||
ConfigApiResponse,
|
||||
GiteaConfig,
|
||||
@@ -16,18 +9,25 @@ import type {
|
||||
SaveConfigApiRequest,
|
||||
SaveConfigApiResponse,
|
||||
ScheduleConfig,
|
||||
DatabaseCleanupConfig,
|
||||
MirrorOptions,
|
||||
AdvancedOptions,
|
||||
} from '@/types/config';
|
||||
import { Button } from '../ui/button';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { apiRequest } from '@/lib/utils';
|
||||
import { Copy, CopyCheck, RefreshCw } from 'lucide-react';
|
||||
import { apiRequest, showErrorToast } from '@/lib/utils';
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { invalidateConfigCache } from '@/hooks/useConfigStatus';
|
||||
|
||||
type ConfigState = {
|
||||
githubConfig: GitHubConfig;
|
||||
giteaConfig: GiteaConfig;
|
||||
scheduleConfig: ScheduleConfig;
|
||||
cleanupConfig: DatabaseCleanupConfig;
|
||||
mirrorOptions: MirrorOptions;
|
||||
advancedOptions: AdvancedOptions;
|
||||
};
|
||||
|
||||
export function ConfigTabs() {
|
||||
@@ -35,12 +35,8 @@ export function ConfigTabs() {
|
||||
githubConfig: {
|
||||
username: '',
|
||||
token: '',
|
||||
skipForks: false,
|
||||
privateRepositories: false,
|
||||
mirrorIssues: false,
|
||||
mirrorStarred: false,
|
||||
preserveOrgStructure: false,
|
||||
skipStarredIssues: false,
|
||||
},
|
||||
giteaConfig: {
|
||||
url: '',
|
||||
@@ -49,18 +45,44 @@ export function ConfigTabs() {
|
||||
organization: 'github-mirrors',
|
||||
visibility: 'public',
|
||||
starredReposOrg: 'github',
|
||||
preserveOrgStructure: false,
|
||||
},
|
||||
scheduleConfig: {
|
||||
enabled: false,
|
||||
interval: 3600,
|
||||
},
|
||||
cleanupConfig: {
|
||||
enabled: false,
|
||||
retentionDays: 604800, // 7 days in seconds
|
||||
},
|
||||
mirrorOptions: {
|
||||
mirrorReleases: false,
|
||||
mirrorMetadata: false,
|
||||
metadataComponents: {
|
||||
issues: false,
|
||||
pullRequests: false,
|
||||
labels: false,
|
||||
milestones: false,
|
||||
wiki: false,
|
||||
},
|
||||
},
|
||||
advancedOptions: {
|
||||
skipForks: false,
|
||||
skipStarredIssues: false,
|
||||
},
|
||||
});
|
||||
const { user, refreshUser } = useAuth();
|
||||
const { user } = useAuth();
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [dockerCode, setDockerCode] = useState<string>('');
|
||||
const [isCopied, setIsCopied] = useState<boolean>(false);
|
||||
const [isSyncing, setIsSyncing] = useState<boolean>(false);
|
||||
const [isConfigSaved, setIsConfigSaved] = useState<boolean>(false);
|
||||
|
||||
const [isAutoSavingSchedule, setIsAutoSavingSchedule] = useState<boolean>(false);
|
||||
const [isAutoSavingCleanup, setIsAutoSavingCleanup] = useState<boolean>(false);
|
||||
const [isAutoSavingGitHub, setIsAutoSavingGitHub] = useState<boolean>(false);
|
||||
const [isAutoSavingGitea, setIsAutoSavingGitea] = useState<boolean>(false);
|
||||
const autoSaveScheduleTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const autoSaveCleanupTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const autoSaveGitHubTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const autoSaveGiteaTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const isConfigFormValid = (): boolean => {
|
||||
const { githubConfig, giteaConfig } = config;
|
||||
@@ -75,26 +97,13 @@ export function ConfigTabs() {
|
||||
return isGitHubValid && isGiteaValid;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const updateLastAndNextRun = () => {
|
||||
const lastRun = config.scheduleConfig.lastRun
|
||||
? new Date(config.scheduleConfig.lastRun)
|
||||
: new Date();
|
||||
const intervalInSeconds = config.scheduleConfig.interval;
|
||||
const nextRun = new Date(
|
||||
lastRun.getTime() + intervalInSeconds * 1000,
|
||||
);
|
||||
setConfig(prev => ({
|
||||
...prev,
|
||||
scheduleConfig: {
|
||||
...prev.scheduleConfig,
|
||||
lastRun,
|
||||
nextRun,
|
||||
},
|
||||
}));
|
||||
};
|
||||
updateLastAndNextRun();
|
||||
}, [config.scheduleConfig.interval]);
|
||||
const isGitHubConfigValid = (): boolean => {
|
||||
const { githubConfig } = config;
|
||||
return !!(githubConfig.username.trim() && githubConfig.token.trim());
|
||||
};
|
||||
|
||||
// Removed the problematic useEffect that was causing circular dependencies
|
||||
// The lastRun and nextRun should be managed by the backend and fetched via API
|
||||
|
||||
const handleImportGitHubData = async () => {
|
||||
if (!user?.id) return;
|
||||
@@ -106,7 +115,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: ${
|
||||
@@ -124,14 +133,249 @@ export function ConfigTabs() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveConfig = async () => {
|
||||
// Auto-save function specifically for schedule config changes
|
||||
const autoSaveScheduleConfig = useCallback(async (scheduleConfig: ScheduleConfig) => {
|
||||
if (!user?.id) return;
|
||||
|
||||
// Clear any existing timeout
|
||||
if (autoSaveScheduleTimeoutRef.current) {
|
||||
clearTimeout(autoSaveScheduleTimeoutRef.current);
|
||||
}
|
||||
|
||||
// Debounce the auto-save to prevent excessive API calls
|
||||
autoSaveScheduleTimeoutRef.current = setTimeout(async () => {
|
||||
setIsAutoSavingSchedule(true);
|
||||
|
||||
const reqPayload: SaveConfigApiRequest = {
|
||||
userId: user.id!,
|
||||
githubConfig: config.githubConfig,
|
||||
giteaConfig: config.giteaConfig,
|
||||
scheduleConfig: scheduleConfig,
|
||||
cleanupConfig: config.cleanupConfig,
|
||||
mirrorOptions: config.mirrorOptions,
|
||||
advancedOptions: config.advancedOptions,
|
||||
};
|
||||
|
||||
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
|
||||
// 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 {
|
||||
showErrorToast(
|
||||
`Auto-save failed: ${result.message || 'Unknown error'}`,
|
||||
toast
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
showErrorToast(error, toast);
|
||||
} finally {
|
||||
setIsAutoSavingSchedule(false);
|
||||
}
|
||||
}, 500); // 500ms debounce
|
||||
}, [user?.id, config.githubConfig, config.giteaConfig, config.cleanupConfig]);
|
||||
|
||||
// Auto-save function specifically for cleanup config changes
|
||||
const autoSaveCleanupConfig = useCallback(async (cleanupConfig: DatabaseCleanupConfig) => {
|
||||
if (!user?.id) return;
|
||||
|
||||
// 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,
|
||||
mirrorOptions: config.mirrorOptions,
|
||||
advancedOptions: config.advancedOptions,
|
||||
};
|
||||
|
||||
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 {
|
||||
showErrorToast(
|
||||
`Auto-save failed: ${result.message || 'Unknown error'}`,
|
||||
toast
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
showErrorToast(error, toast);
|
||||
} finally {
|
||||
setIsAutoSavingCleanup(false);
|
||||
}
|
||||
}, 500); // 500ms debounce
|
||||
}, [user?.id, config.githubConfig, config.giteaConfig, config.scheduleConfig]);
|
||||
|
||||
// Auto-save function specifically for GitHub config changes
|
||||
const autoSaveGitHubConfig = useCallback(async (githubConfig: GitHubConfig) => {
|
||||
if (!user?.id) return;
|
||||
|
||||
// Clear any existing timeout
|
||||
if (autoSaveGitHubTimeoutRef.current) {
|
||||
clearTimeout(autoSaveGitHubTimeoutRef.current);
|
||||
}
|
||||
|
||||
// Debounce the auto-save to prevent excessive API calls
|
||||
autoSaveGitHubTimeoutRef.current = setTimeout(async () => {
|
||||
setIsAutoSavingGitHub(true);
|
||||
|
||||
const reqPayload: SaveConfigApiRequest = {
|
||||
userId: user.id!,
|
||||
githubConfig: githubConfig,
|
||||
giteaConfig: config.giteaConfig,
|
||||
scheduleConfig: config.scheduleConfig,
|
||||
cleanupConfig: config.cleanupConfig,
|
||||
mirrorOptions: config.mirrorOptions,
|
||||
advancedOptions: config.advancedOptions,
|
||||
};
|
||||
|
||||
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();
|
||||
} else {
|
||||
showErrorToast(
|
||||
`Auto-save failed: ${result.message || 'Unknown error'}`,
|
||||
toast
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
showErrorToast(error, toast);
|
||||
} finally {
|
||||
setIsAutoSavingGitHub(false);
|
||||
}
|
||||
}, 500); // 500ms debounce
|
||||
}, [user?.id, config.giteaConfig, config.scheduleConfig, config.cleanupConfig]);
|
||||
|
||||
// Auto-save function specifically for Gitea config changes
|
||||
const autoSaveGiteaConfig = useCallback(async (giteaConfig: GiteaConfig) => {
|
||||
if (!user?.id) return;
|
||||
|
||||
// Clear any existing timeout
|
||||
if (autoSaveGiteaTimeoutRef.current) {
|
||||
clearTimeout(autoSaveGiteaTimeoutRef.current);
|
||||
}
|
||||
|
||||
// Debounce the auto-save to prevent excessive API calls
|
||||
autoSaveGiteaTimeoutRef.current = setTimeout(async () => {
|
||||
setIsAutoSavingGitea(true);
|
||||
|
||||
const reqPayload: SaveConfigApiRequest = {
|
||||
userId: user.id!,
|
||||
githubConfig: config.githubConfig,
|
||||
giteaConfig: giteaConfig,
|
||||
scheduleConfig: config.scheduleConfig,
|
||||
cleanupConfig: config.cleanupConfig,
|
||||
mirrorOptions: config.mirrorOptions,
|
||||
advancedOptions: config.advancedOptions,
|
||||
};
|
||||
|
||||
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();
|
||||
} else {
|
||||
showErrorToast(
|
||||
`Auto-save failed: ${result.message || 'Unknown error'}`,
|
||||
toast
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
showErrorToast(error, toast);
|
||||
} finally {
|
||||
setIsAutoSavingGitea(false);
|
||||
}
|
||||
}, 500); // 500ms debounce
|
||||
}, [user?.id, config.githubConfig, config.scheduleConfig, config.cleanupConfig]);
|
||||
|
||||
// Auto-save function for mirror options (handled within GitHub config)
|
||||
const autoSaveMirrorOptions = useCallback(async (mirrorOptions: MirrorOptions) => {
|
||||
if (!user?.id) return;
|
||||
|
||||
const reqPayload: SaveConfigApiRequest = {
|
||||
userId: user.id,
|
||||
userId: user.id!,
|
||||
githubConfig: config.githubConfig,
|
||||
giteaConfig: config.giteaConfig,
|
||||
scheduleConfig: config.scheduleConfig,
|
||||
cleanupConfig: config.cleanupConfig,
|
||||
mirrorOptions: mirrorOptions,
|
||||
advancedOptions: config.advancedOptions,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/config', {
|
||||
method: 'POST',
|
||||
@@ -139,28 +383,75 @@ export function ConfigTabs() {
|
||||
body: JSON.stringify(reqPayload),
|
||||
});
|
||||
const result: SaveConfigApiResponse = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
await refreshUser();
|
||||
setIsConfigSaved(true);
|
||||
toast.success(
|
||||
'Configuration saved successfully! Now import your GitHub data to begin.',
|
||||
);
|
||||
invalidateConfigCache();
|
||||
} else {
|
||||
toast.error(
|
||||
`Failed to save configuration: ${result.message || 'Unknown error'}`,
|
||||
showErrorToast(
|
||||
`Auto-save failed: ${result.message || 'Unknown error'}`,
|
||||
toast
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
`An error occurred while saving the configuration: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
showErrorToast(error, toast);
|
||||
}
|
||||
};
|
||||
}, [user?.id, config.githubConfig, config.giteaConfig, config.scheduleConfig, config.cleanupConfig, config.advancedOptions]);
|
||||
|
||||
// Auto-save function for advanced options (handled within GitHub config)
|
||||
const autoSaveAdvancedOptions = useCallback(async (advancedOptions: AdvancedOptions) => {
|
||||
if (!user?.id) return;
|
||||
|
||||
const reqPayload: SaveConfigApiRequest = {
|
||||
userId: user.id!,
|
||||
githubConfig: config.githubConfig,
|
||||
giteaConfig: config.giteaConfig,
|
||||
scheduleConfig: config.scheduleConfig,
|
||||
cleanupConfig: config.cleanupConfig,
|
||||
mirrorOptions: config.mirrorOptions,
|
||||
advancedOptions: advancedOptions,
|
||||
};
|
||||
|
||||
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) {
|
||||
invalidateConfigCache();
|
||||
} else {
|
||||
showErrorToast(
|
||||
`Auto-save failed: ${result.message || 'Unknown error'}`,
|
||||
toast
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
showErrorToast(error, toast);
|
||||
}
|
||||
}, [user?.id, config.githubConfig, config.giteaConfig, config.scheduleConfig, config.cleanupConfig, config.mirrorOptions]);
|
||||
|
||||
// Cleanup timeouts on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (autoSaveScheduleTimeoutRef.current) {
|
||||
clearTimeout(autoSaveScheduleTimeoutRef.current);
|
||||
}
|
||||
if (autoSaveCleanupTimeoutRef.current) {
|
||||
clearTimeout(autoSaveCleanupTimeoutRef.current);
|
||||
}
|
||||
if (autoSaveGitHubTimeoutRef.current) {
|
||||
clearTimeout(autoSaveGitHubTimeoutRef.current);
|
||||
}
|
||||
if (autoSaveGiteaTimeoutRef.current) {
|
||||
clearTimeout(autoSaveGiteaTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
if (!user?.id) return;
|
||||
|
||||
const fetchConfig = async () => {
|
||||
setIsLoading(true);
|
||||
@@ -177,8 +468,14 @@ export function ConfigTabs() {
|
||||
response.giteaConfig || config.giteaConfig,
|
||||
scheduleConfig:
|
||||
response.scheduleConfig || config.scheduleConfig,
|
||||
cleanupConfig:
|
||||
response.cleanupConfig || config.cleanupConfig,
|
||||
mirrorOptions:
|
||||
response.mirrorOptions || config.mirrorOptions,
|
||||
advancedOptions:
|
||||
response.advancedOptions || config.advancedOptions,
|
||||
});
|
||||
if (response.id) setIsConfigSaved(true);
|
||||
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
@@ -190,232 +487,194 @@ export function ConfigTabs() {
|
||||
};
|
||||
|
||||
fetchConfig();
|
||||
}, [user]);
|
||||
|
||||
useEffect(() => {
|
||||
const generateDockerCode = () => `
|
||||
services:
|
||||
gitea-mirror:
|
||||
image: arunavo4/gitea-mirror:latest
|
||||
restart: unless-stopped
|
||||
container_name: gitea-mirror
|
||||
environment:
|
||||
- GITHUB_USERNAME=${config.githubConfig.username}
|
||||
- GITEA_URL=${config.giteaConfig.url}
|
||||
- GITEA_TOKEN=${config.giteaConfig.token}
|
||||
- GITHUB_TOKEN=${config.githubConfig.token}
|
||||
- SKIP_FORKS=${config.githubConfig.skipForks}
|
||||
- PRIVATE_REPOSITORIES=${config.githubConfig.privateRepositories}
|
||||
- MIRROR_ISSUES=${config.githubConfig.mirrorIssues}
|
||||
- MIRROR_STARRED=${config.githubConfig.mirrorStarred}
|
||||
- PRESERVE_ORG_STRUCTURE=${config.githubConfig.preserveOrgStructure}
|
||||
- SKIP_STARRED_ISSUES=${config.githubConfig.skipStarredIssues}
|
||||
- GITEA_ORGANIZATION=${config.giteaConfig.organization}
|
||||
- GITEA_ORG_VISIBILITY=${config.giteaConfig.visibility}
|
||||
- DELAY=${config.scheduleConfig.interval}`;
|
||||
setDockerCode(generateDockerCode());
|
||||
}, [config]);
|
||||
|
||||
const handleCopyToClipboard = (text: string) => {
|
||||
navigator.clipboard.writeText(text).then(
|
||||
() => {
|
||||
setIsCopied(true);
|
||||
toast.success('Docker configuration copied to clipboard!');
|
||||
setTimeout(() => setIsCopied(false), 2000);
|
||||
},
|
||||
() => toast.error('Could not copy text to clipboard.'),
|
||||
);
|
||||
};
|
||||
}, [user?.id]); // Only depend on user.id, not the entire user object
|
||||
|
||||
function ConfigCardSkeleton() {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex-row justify-between">
|
||||
<div className="flex flex-col gap-y-1.5 m-0">
|
||||
<Skeleton className="h-6 w-48" />
|
||||
<div className="space-y-6">
|
||||
{/* Header section */}
|
||||
<div className="flex flex-row justify-between items-start">
|
||||
<div className="flex flex-col gap-y-1.5">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-4 w-72" />
|
||||
</div>
|
||||
<div className="flex gap-x-4">
|
||||
<Skeleton className="h-10 w-36" />
|
||||
<Skeleton className="h-10 w-36" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col gap-y-4">
|
||||
<div className="flex gap-x-4">
|
||||
<div className="w-1/2 border rounded-lg p-4">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<Skeleton className="h-6 w-40" />
|
||||
<Skeleton className="h-9 w-32" />
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-20 w-full" />
|
||||
<Skeleton className="h-20 w-full" />
|
||||
<Skeleton className="h-32 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content section - Grid layout */}
|
||||
<div className="space-y-4">
|
||||
{/* GitHub & Gitea connections - Side by side */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="border rounded-lg p-4">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<Skeleton className="h-6 w-40" />
|
||||
<Skeleton className="h-9 w-32" />
|
||||
</div>
|
||||
<div className="w-1/2 border rounded-lg p-4">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<Skeleton className="h-6 w-40" />
|
||||
<Skeleton className="h-9 w-32" />
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-20 w-full" />
|
||||
<Skeleton className="h-20 w-full" />
|
||||
<Skeleton className="h-20 w-full" />
|
||||
<Skeleton className="h-20 w-full" />
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-20 w-full" />
|
||||
<Skeleton className="h-20 w-full" />
|
||||
<Skeleton className="h-1 w-full" />
|
||||
<Skeleton className="h-32 w-full" />
|
||||
<Skeleton className="h-48 w-full" />
|
||||
<Skeleton className="h-32 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="border rounded-lg p-4">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<Skeleton className="h-6 w-40" />
|
||||
<Skeleton className="h-9 w-32" />
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-16 w-full" />
|
||||
<Skeleton className="h-8 w-32" />
|
||||
<Skeleton className="h-20 w-full" />
|
||||
<Skeleton className="h-20 w-full" />
|
||||
<Skeleton className="h-20 w-full" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function DockerConfigSkeleton() {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-6 w-40" />
|
||||
<Skeleton className="h-4 w-64" />
|
||||
</CardHeader>
|
||||
<CardContent className="relative">
|
||||
<Skeleton className="h-8 w-8 absolute top-4 right-10 rounded-md" />
|
||||
<Skeleton className="h-48 w-full rounded-md" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* Automation & Maintenance - Full width */}
|
||||
<div className="border rounded-lg p-4">
|
||||
<Skeleton className="h-8 w-48 mb-4" />
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-6 w-40" />
|
||||
<Skeleton className="h-16 w-full" />
|
||||
<Skeleton className="h-24 w-full" />
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-6 w-40" />
|
||||
<Skeleton className="h-16 w-full" />
|
||||
<Skeleton className="h-24 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return isLoading ? (
|
||||
<div className="flex flex-col gap-y-6">
|
||||
<div className="space-y-6">
|
||||
<ConfigCardSkeleton />
|
||||
<DockerConfigSkeleton />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-y-6">
|
||||
<Card>
|
||||
<CardHeader className="flex-row justify-between">
|
||||
<div className="flex flex-col gap-y-1.5 m-0">
|
||||
<CardTitle>Configuration Settings</CardTitle>
|
||||
<CardDescription>
|
||||
Configure your GitHub and Gitea connections, and set up automatic
|
||||
mirroring.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex gap-x-4">
|
||||
<Button
|
||||
onClick={handleImportGitHubData}
|
||||
disabled={isSyncing || !isConfigSaved}
|
||||
title={
|
||||
!isConfigSaved
|
||||
? 'Save configuration first'
|
||||
: isSyncing
|
||||
? 'Import in progress'
|
||||
: 'Import GitHub Data'
|
||||
}
|
||||
>
|
||||
{isSyncing ? (
|
||||
<>
|
||||
<RefreshCw className="h-4 w-4 animate-spin mr-1" />
|
||||
Import GitHub Data
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RefreshCw className="h-4 w-4 mr-1" />
|
||||
Import GitHub Data
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSaveConfig}
|
||||
disabled={!isConfigFormValid()}
|
||||
title={
|
||||
!isConfigFormValid()
|
||||
? 'Please fill all required fields'
|
||||
: 'Save Configuration'
|
||||
}
|
||||
>
|
||||
Save Configuration
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col gap-y-4">
|
||||
<div className="flex gap-x-4">
|
||||
<GitHubConfigForm
|
||||
config={config.githubConfig}
|
||||
setConfig={update =>
|
||||
setConfig(prev => ({
|
||||
...prev,
|
||||
githubConfig:
|
||||
typeof update === 'function'
|
||||
? update(prev.githubConfig)
|
||||
: update,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<GiteaConfigForm
|
||||
config={config.giteaConfig}
|
||||
setConfig={update =>
|
||||
setConfig(prev => ({
|
||||
...prev,
|
||||
giteaConfig:
|
||||
typeof update === 'function'
|
||||
? update(prev.giteaConfig)
|
||||
: update,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<ScheduleConfigForm
|
||||
config={config.scheduleConfig}
|
||||
setConfig={update =>
|
||||
setConfig(prev => ({
|
||||
...prev,
|
||||
scheduleConfig:
|
||||
typeof update === 'function'
|
||||
? update(prev.scheduleConfig)
|
||||
: update,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Docker Configuration</CardTitle>
|
||||
<CardDescription>
|
||||
Equivalent Docker configuration for your current settings.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="relative">
|
||||
<div className="space-y-6">
|
||||
{/* Header section */}
|
||||
<div className="flex flex-row justify-between items-start">
|
||||
<div className="flex flex-col gap-y-1.5">
|
||||
<h1 className="text-2xl font-semibold leading-none tracking-tight">
|
||||
Configuration Settings
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Configure your GitHub and Gitea connections, and set up automatic
|
||||
mirroring.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-x-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="absolute top-4 right-10"
|
||||
onClick={() => handleCopyToClipboard(dockerCode)}
|
||||
onClick={handleImportGitHubData}
|
||||
disabled={isSyncing || !isGitHubConfigValid()}
|
||||
title={
|
||||
!isGitHubConfigValid()
|
||||
? 'Please fill GitHub username and token fields'
|
||||
: isSyncing
|
||||
? 'Import in progress'
|
||||
: 'Import GitHub Data'
|
||||
}
|
||||
>
|
||||
{isCopied ? (
|
||||
<CopyCheck className="text-green-500" />
|
||||
{isSyncing ? (
|
||||
<>
|
||||
<RefreshCw className="h-4 w-4 animate-spin mr-1" />
|
||||
Import GitHub Data
|
||||
</>
|
||||
) : (
|
||||
<Copy className="text-muted-foreground" />
|
||||
<>
|
||||
<RefreshCw className="h-4 w-4 mr-1" />
|
||||
Import GitHub Data
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<pre className="bg-muted p-4 rounded-md overflow-auto text-sm">
|
||||
{dockerCode}
|
||||
</pre>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content section - Grid layout */}
|
||||
<div className="space-y-6">
|
||||
{/* GitHub & Gitea connections - Side by side */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 md:items-stretch">
|
||||
<GitHubConfigForm
|
||||
config={config.githubConfig}
|
||||
setConfig={update =>
|
||||
setConfig(prev => ({
|
||||
...prev,
|
||||
githubConfig:
|
||||
typeof update === 'function'
|
||||
? update(prev.githubConfig)
|
||||
: update,
|
||||
}))
|
||||
}
|
||||
mirrorOptions={config.mirrorOptions}
|
||||
setMirrorOptions={update =>
|
||||
setConfig(prev => ({
|
||||
...prev,
|
||||
mirrorOptions:
|
||||
typeof update === 'function'
|
||||
? update(prev.mirrorOptions)
|
||||
: update,
|
||||
}))
|
||||
}
|
||||
advancedOptions={config.advancedOptions}
|
||||
setAdvancedOptions={update =>
|
||||
setConfig(prev => ({
|
||||
...prev,
|
||||
advancedOptions:
|
||||
typeof update === 'function'
|
||||
? update(prev.advancedOptions)
|
||||
: update,
|
||||
}))
|
||||
}
|
||||
onAutoSave={autoSaveGitHubConfig}
|
||||
onMirrorOptionsAutoSave={autoSaveMirrorOptions}
|
||||
onAdvancedOptionsAutoSave={autoSaveAdvancedOptions}
|
||||
isAutoSaving={isAutoSavingGitHub}
|
||||
/>
|
||||
<GiteaConfigForm
|
||||
config={config.giteaConfig}
|
||||
setConfig={update =>
|
||||
setConfig(prev => ({
|
||||
...prev,
|
||||
giteaConfig:
|
||||
typeof update === 'function'
|
||||
? update(prev.giteaConfig)
|
||||
: update,
|
||||
}))
|
||||
}
|
||||
onAutoSave={autoSaveGiteaConfig}
|
||||
isAutoSaving={isAutoSavingGitea}
|
||||
githubUsername={config.githubConfig.username}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Automation & Maintenance - Full width */}
|
||||
<div>
|
||||
<AutomationSettings
|
||||
scheduleConfig={config.scheduleConfig}
|
||||
cleanupConfig={config.cleanupConfig}
|
||||
onScheduleChange={(newConfig) => {
|
||||
setConfig(prev => ({ ...prev, scheduleConfig: newConfig }));
|
||||
autoSaveScheduleConfig(newConfig);
|
||||
}}
|
||||
onCleanupChange={(newConfig) => {
|
||||
setConfig(prev => ({ ...prev, cleanupConfig: newConfig }));
|
||||
autoSaveCleanupConfig(newConfig);
|
||||
}}
|
||||
isAutoSavingSchedule={isAutoSavingSchedule}
|
||||
isAutoSavingCleanup={isAutoSavingCleanup}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 className="self-start">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -1,52 +1,65 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React, { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { githubApi } from "@/lib/api";
|
||||
import type { GitHubConfig } from "@/types/config";
|
||||
import type { GitHubConfig, MirrorOptions, AdvancedOptions } from "@/types/config";
|
||||
import { Input } from "../ui/input";
|
||||
import { Checkbox } from "../ui/checkbox";
|
||||
import { toast } from "sonner";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { Alert, AlertDescription } from "../ui/alert";
|
||||
import { Info } from "lucide-react";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
||||
import { GitHubMirrorSettings } from "./GitHubMirrorSettings";
|
||||
import { Separator } from "../ui/separator";
|
||||
import {
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from "@/components/ui/hover-card";
|
||||
|
||||
interface GitHubConfigFormProps {
|
||||
config: GitHubConfig;
|
||||
setConfig: React.Dispatch<React.SetStateAction<GitHubConfig>>;
|
||||
mirrorOptions: MirrorOptions;
|
||||
setMirrorOptions: React.Dispatch<React.SetStateAction<MirrorOptions>>;
|
||||
advancedOptions: AdvancedOptions;
|
||||
setAdvancedOptions: React.Dispatch<React.SetStateAction<AdvancedOptions>>;
|
||||
onAutoSave?: (githubConfig: GitHubConfig) => Promise<void>;
|
||||
onMirrorOptionsAutoSave?: (mirrorOptions: MirrorOptions) => Promise<void>;
|
||||
onAdvancedOptionsAutoSave?: (advancedOptions: AdvancedOptions) => Promise<void>;
|
||||
isAutoSaving?: boolean;
|
||||
}
|
||||
|
||||
export function GitHubConfigForm({ config, setConfig }: GitHubConfigFormProps) {
|
||||
export function GitHubConfigForm({
|
||||
config,
|
||||
setConfig,
|
||||
mirrorOptions,
|
||||
setMirrorOptions,
|
||||
advancedOptions,
|
||||
setAdvancedOptions,
|
||||
onAutoSave,
|
||||
onMirrorOptionsAutoSave,
|
||||
onAdvancedOptionsAutoSave,
|
||||
isAutoSaving
|
||||
}: GitHubConfigFormProps) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value, type, checked } = e.target;
|
||||
|
||||
// Special handling for preserveOrgStructure changes
|
||||
if (
|
||||
name === "preserveOrgStructure" &&
|
||||
config.preserveOrgStructure !== checked
|
||||
) {
|
||||
toast.info(
|
||||
"Changing this setting may affect how repositories are accessed in Gitea. " +
|
||||
"Existing mirrored repositories will still be accessible during sync operations.",
|
||||
{
|
||||
duration: 6000,
|
||||
position: "top-center",
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
setConfig({
|
||||
const newConfig = {
|
||||
...config,
|
||||
[name]: type === "checkbox" ? checked : value,
|
||||
});
|
||||
};
|
||||
|
||||
setConfig(newConfig);
|
||||
|
||||
// Auto-save for all field changes
|
||||
if (onAutoSave) {
|
||||
onAutoSave(newConfig);
|
||||
}
|
||||
};
|
||||
|
||||
const testConnection = async () => {
|
||||
@@ -74,7 +87,7 @@ export function GitHubConfigForm({ config, setConfig }: GitHubConfigFormProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="w-full">
|
||||
<Card className="w-full h-full flex flex-col">
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-4">
|
||||
<CardTitle className="text-lg font-semibold">
|
||||
GitHub Configuration
|
||||
@@ -89,7 +102,7 @@ export function GitHubConfigForm({ config, setConfig }: GitHubConfigFormProps) {
|
||||
</Button>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex flex-col gap-y-6">
|
||||
<CardContent className="flex flex-col gap-y-6 flex-1">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="github-username"
|
||||
@@ -110,12 +123,49 @@ export function GitHubConfigForm({ config, setConfig }: GitHubConfigFormProps) {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="github-token"
|
||||
className="block text-sm font-medium mb-1.5"
|
||||
>
|
||||
GitHub Token
|
||||
</label>
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<label
|
||||
htmlFor="github-token"
|
||||
className="block text-sm font-medium"
|
||||
>
|
||||
GitHub Token
|
||||
</label>
|
||||
<HoverCard openDelay={200}>
|
||||
<HoverCardTrigger asChild>
|
||||
<span className="inline-flex p-0.5 hover:bg-muted rounded-sm transition-colors cursor-help">
|
||||
<Info className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</span>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent side="right" align="start" className="w-80">
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-medium text-sm">GitHub Token Requirements</h4>
|
||||
<div className="text-sm space-y-2">
|
||||
<p>
|
||||
You need to create a <span className="font-medium">Classic GitHub PAT Token</span> with the following scopes:
|
||||
</p>
|
||||
<ul className="ml-4 space-y-1 list-disc">
|
||||
<li><code className="text-xs bg-muted px-1 py-0.5 rounded">repo</code></li>
|
||||
<li><code className="text-xs bg-muted px-1 py-0.5 rounded">admin:org</code></li>
|
||||
</ul>
|
||||
<p className="text-muted-foreground">
|
||||
The organization access is required for mirroring organization repositories.
|
||||
</p>
|
||||
<p>
|
||||
Generate tokens at{" "}
|
||||
<a
|
||||
href="https://github.com/settings/tokens"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline font-medium"
|
||||
>
|
||||
github.com/settings/tokens
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
</div>
|
||||
<Input
|
||||
id="github-token"
|
||||
name="token"
|
||||
@@ -123,7 +173,7 @@ export function GitHubConfigForm({ config, setConfig }: GitHubConfigFormProps) {
|
||||
value={config.token}
|
||||
onChange={handleChange}
|
||||
className="bg-background"
|
||||
placeholder="Your GitHub personal access token"
|
||||
placeholder="Your GitHub token (classic) with repo and admin:org scopes"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Required for private repositories, organizations, and starred
|
||||
@@ -131,210 +181,27 @@ export function GitHubConfigForm({ config, setConfig }: GitHubConfigFormProps) {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center">
|
||||
<Checkbox
|
||||
id="skip-forks"
|
||||
name="skipForks"
|
||||
checked={config.skipForks}
|
||||
onCheckedChange={(checked) =>
|
||||
handleChange({
|
||||
target: {
|
||||
name: "skipForks",
|
||||
type: "checkbox",
|
||||
checked: Boolean(checked),
|
||||
value: "",
|
||||
},
|
||||
} as React.ChangeEvent<HTMLInputElement>)
|
||||
}
|
||||
/>
|
||||
<label
|
||||
htmlFor="skip-forks"
|
||||
className="ml-2 block text-sm select-none"
|
||||
>
|
||||
Skip Forks
|
||||
</label>
|
||||
</div>
|
||||
<Separator />
|
||||
|
||||
<div className="flex items-center">
|
||||
<Checkbox
|
||||
id="private-repositories"
|
||||
name="privateRepositories"
|
||||
checked={config.privateRepositories}
|
||||
onCheckedChange={(checked) =>
|
||||
handleChange({
|
||||
target: {
|
||||
name: "privateRepositories",
|
||||
type: "checkbox",
|
||||
checked: Boolean(checked),
|
||||
value: "",
|
||||
},
|
||||
} as React.ChangeEvent<HTMLInputElement>)
|
||||
}
|
||||
/>
|
||||
<label
|
||||
htmlFor="private-repositories"
|
||||
className="ml-2 block text-sm select-none"
|
||||
>
|
||||
Mirror Private Repos
|
||||
</label>
|
||||
</div>
|
||||
<GitHubMirrorSettings
|
||||
githubConfig={config}
|
||||
mirrorOptions={mirrorOptions}
|
||||
advancedOptions={advancedOptions}
|
||||
onGitHubConfigChange={(newConfig) => {
|
||||
setConfig(newConfig);
|
||||
if (onAutoSave) onAutoSave(newConfig);
|
||||
}}
|
||||
onMirrorOptionsChange={(newOptions) => {
|
||||
setMirrorOptions(newOptions);
|
||||
if (onMirrorOptionsAutoSave) onMirrorOptionsAutoSave(newOptions);
|
||||
}}
|
||||
onAdvancedOptionsChange={(newOptions) => {
|
||||
setAdvancedOptions(newOptions);
|
||||
if (onAdvancedOptionsAutoSave) onAdvancedOptionsAutoSave(newOptions);
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
|
||||
<div className="flex items-center">
|
||||
<Checkbox
|
||||
id="mirror-starred"
|
||||
name="mirrorStarred"
|
||||
checked={config.mirrorStarred}
|
||||
onCheckedChange={(checked) =>
|
||||
handleChange({
|
||||
target: {
|
||||
name: "mirrorStarred",
|
||||
type: "checkbox",
|
||||
checked: Boolean(checked),
|
||||
value: "",
|
||||
},
|
||||
} as React.ChangeEvent<HTMLInputElement>)
|
||||
}
|
||||
/>
|
||||
<label
|
||||
htmlFor="mirror-starred"
|
||||
className="ml-2 block text-sm select-none"
|
||||
>
|
||||
Mirror Starred Repos
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center">
|
||||
<Checkbox
|
||||
id="mirror-issues"
|
||||
name="mirrorIssues"
|
||||
checked={config.mirrorIssues}
|
||||
onCheckedChange={(checked) =>
|
||||
handleChange({
|
||||
target: {
|
||||
name: "mirrorIssues",
|
||||
type: "checkbox",
|
||||
checked: Boolean(checked),
|
||||
value: "",
|
||||
},
|
||||
} as React.ChangeEvent<HTMLInputElement>)
|
||||
}
|
||||
/>
|
||||
<label
|
||||
htmlFor="mirror-issues"
|
||||
className="ml-2 block text-sm select-none"
|
||||
>
|
||||
Mirror Issues
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<Checkbox
|
||||
id="preserve-org-structure"
|
||||
name="preserveOrgStructure"
|
||||
checked={config.preserveOrgStructure}
|
||||
onCheckedChange={(checked) =>
|
||||
handleChange({
|
||||
target: {
|
||||
name: "preserveOrgStructure",
|
||||
type: "checkbox",
|
||||
checked: Boolean(checked),
|
||||
value: "",
|
||||
},
|
||||
} as React.ChangeEvent<HTMLInputElement>)
|
||||
}
|
||||
/>
|
||||
<label
|
||||
htmlFor="preserve-org-structure"
|
||||
className="ml-2 text-sm select-none flex items-center"
|
||||
>
|
||||
Preserve Org Structure
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
className="ml-1 cursor-pointer align-middle text-muted-foreground"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<Info size={16} />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" className="max-w-xs text-xs">
|
||||
When enabled, organization repositories will be mirrored to
|
||||
the same organization structure in Gitea. When disabled, all
|
||||
repositories will be mirrored under your Gitea username.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<Checkbox
|
||||
id="skip-starred-issues"
|
||||
name="skipStarredIssues"
|
||||
checked={config.skipStarredIssues}
|
||||
onCheckedChange={(checked) =>
|
||||
handleChange({
|
||||
target: {
|
||||
name: "skipStarredIssues",
|
||||
type: "checkbox",
|
||||
checked: Boolean(checked),
|
||||
value: "",
|
||||
},
|
||||
} as React.ChangeEvent<HTMLInputElement>)
|
||||
}
|
||||
/>
|
||||
<label
|
||||
htmlFor="skip-starred-issues"
|
||||
className="ml-2 block text-sm select-none"
|
||||
>
|
||||
Skip Issues for Starred Repos
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="flex-col items-start">
|
||||
<Alert variant="note" className="w-full">
|
||||
<AlertTriangle className="h-4 w-4 text-blue-600 dark:text-blue-400 mr-2" />
|
||||
<AlertDescription className="text-sm">
|
||||
<div className="font-semibold mb-1">Note:</div>
|
||||
<div className="mb-1">
|
||||
You need to create a{" "}
|
||||
<span className="font-semibold">Classic GitHub PAT Token</span>{" "}
|
||||
with following scopes:
|
||||
</div>
|
||||
<ul className="ml-4 mb-1 list-disc">
|
||||
<li>
|
||||
<code>repo</code>
|
||||
</li>
|
||||
<li>
|
||||
<code>admin:org</code>
|
||||
</li>
|
||||
</ul>
|
||||
<div className="mb-1">
|
||||
The organization access is required for mirroring organization
|
||||
repositories.
|
||||
</div>
|
||||
<div>
|
||||
You can generate tokens at{" "}
|
||||
<a
|
||||
href="https://github.com/settings/tokens"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline font-medium hover:text-blue-900 dark:hover:text-blue-200"
|
||||
>
|
||||
github.com/settings/tokens
|
||||
</a>
|
||||
.
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
527
src/components/config/GitHubMirrorSettings.tsx
Normal file
@@ -0,0 +1,527 @@
|
||||
import React from "react";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
Info,
|
||||
GitBranch,
|
||||
Star,
|
||||
Lock,
|
||||
Archive,
|
||||
GitPullRequest,
|
||||
Tag,
|
||||
FileText,
|
||||
MessageSquare,
|
||||
Target,
|
||||
BookOpen,
|
||||
GitFork,
|
||||
ChevronDown,
|
||||
Funnel
|
||||
} from "lucide-react";
|
||||
import type { GitHubConfig, MirrorOptions, AdvancedOptions } from "@/types/config";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface GitHubMirrorSettingsProps {
|
||||
githubConfig: GitHubConfig;
|
||||
mirrorOptions: MirrorOptions;
|
||||
advancedOptions: AdvancedOptions;
|
||||
onGitHubConfigChange: (config: GitHubConfig) => void;
|
||||
onMirrorOptionsChange: (options: MirrorOptions) => void;
|
||||
onAdvancedOptionsChange: (options: AdvancedOptions) => void;
|
||||
}
|
||||
|
||||
export function GitHubMirrorSettings({
|
||||
githubConfig,
|
||||
mirrorOptions,
|
||||
advancedOptions,
|
||||
onGitHubConfigChange,
|
||||
onMirrorOptionsChange,
|
||||
onAdvancedOptionsChange,
|
||||
}: GitHubMirrorSettingsProps) {
|
||||
|
||||
const handleGitHubChange = (field: keyof GitHubConfig, value: boolean) => {
|
||||
onGitHubConfigChange({ ...githubConfig, [field]: value });
|
||||
};
|
||||
|
||||
const handleMirrorChange = (field: keyof MirrorOptions, value: boolean) => {
|
||||
onMirrorOptionsChange({ ...mirrorOptions, [field]: value });
|
||||
};
|
||||
|
||||
const handleMetadataComponentChange = (component: keyof MirrorOptions['metadataComponents'], value: boolean) => {
|
||||
onMirrorOptionsChange({
|
||||
...mirrorOptions,
|
||||
metadataComponents: {
|
||||
...mirrorOptions.metadataComponents,
|
||||
[component]: value,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleAdvancedChange = (field: keyof AdvancedOptions, value: boolean) => {
|
||||
onAdvancedOptionsChange({ ...advancedOptions, [field]: value });
|
||||
};
|
||||
|
||||
// When metadata is disabled, all components should be disabled
|
||||
const isMetadataEnabled = mirrorOptions.mirrorMetadata;
|
||||
|
||||
// Calculate what content is included for starred repos
|
||||
const starredRepoContent = {
|
||||
code: true, // Always included
|
||||
releases: !advancedOptions.skipStarredIssues && mirrorOptions.mirrorReleases,
|
||||
issues: !advancedOptions.skipStarredIssues && mirrorOptions.mirrorMetadata && mirrorOptions.metadataComponents.issues,
|
||||
pullRequests: !advancedOptions.skipStarredIssues && mirrorOptions.mirrorMetadata && mirrorOptions.metadataComponents.pullRequests,
|
||||
wiki: !advancedOptions.skipStarredIssues && mirrorOptions.mirrorMetadata && mirrorOptions.metadataComponents.wiki,
|
||||
};
|
||||
|
||||
const starredContentCount = Object.entries(starredRepoContent).filter(([key, value]) => key !== 'code' && value).length;
|
||||
const totalStarredOptions = 4; // releases, issues, PRs, wiki
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Repository Selection Section */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-3 flex items-center gap-2">
|
||||
<GitBranch className="h-4 w-4" />
|
||||
Repository Selection
|
||||
</h4>
|
||||
<p className="text-xs text-muted-foreground mb-4">
|
||||
Choose which repositories to include in mirroring
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start space-x-3">
|
||||
<Checkbox
|
||||
id="private-repos"
|
||||
checked={githubConfig.privateRepositories}
|
||||
onCheckedChange={(checked) => handleGitHubChange('privateRepositories', !!checked)}
|
||||
/>
|
||||
<div className="space-y-0.5 flex-1">
|
||||
<Label
|
||||
htmlFor="private-repos"
|
||||
className="text-sm font-normal cursor-pointer flex items-center gap-2"
|
||||
>
|
||||
<Lock className="h-3.5 w-3.5" />
|
||||
Include private repositories
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Mirror your private repositories
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-start space-x-3">
|
||||
<Checkbox
|
||||
id="starred-repos"
|
||||
checked={githubConfig.mirrorStarred}
|
||||
onCheckedChange={(checked) => handleGitHubChange('mirrorStarred', !!checked)}
|
||||
/>
|
||||
<div className="space-y-0.5 flex-1">
|
||||
<Label
|
||||
htmlFor="starred-repos"
|
||||
className="text-sm font-normal cursor-pointer flex items-center gap-2"
|
||||
>
|
||||
<Star className="h-3.5 w-3.5" />
|
||||
Mirror starred repositories
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Include repositories you've starred on GitHub
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Starred repos content selection - inline to prevent layout shift */}
|
||||
<div className={cn(
|
||||
"flex items-center justify-end transition-opacity duration-200",
|
||||
githubConfig.mirrorStarred ? "opacity-100" : "opacity-0 pointer-events-none"
|
||||
)}>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!githubConfig.mirrorStarred}
|
||||
className="h-8 text-xs font-normal min-w-[140px] justify-between"
|
||||
>
|
||||
<span>
|
||||
{advancedOptions.skipStarredIssues ? (
|
||||
"Code only"
|
||||
) : starredContentCount === 0 ? (
|
||||
"Code only"
|
||||
) : starredContentCount === totalStarredOptions ? (
|
||||
"Full content"
|
||||
) : (
|
||||
`${starredContentCount + 1} of ${totalStarredOptions + 1} selected`
|
||||
)}
|
||||
</span>
|
||||
<ChevronDown className="ml-2 h-3 w-3 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-72">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm font-medium">Starred repos content</div>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Info className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left" className="max-w-xs">
|
||||
<p className="text-xs">
|
||||
Choose what content to mirror from starred repositories.
|
||||
Selecting "Lightweight mode" will only mirror code for better performance.
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
|
||||
<Separator className="my-2" />
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center space-x-3 py-1 px-1 rounded hover:bg-accent">
|
||||
<Checkbox
|
||||
id="starred-lightweight"
|
||||
checked={advancedOptions.skipStarredIssues}
|
||||
onCheckedChange={(checked) => handleAdvancedChange('skipStarredIssues', !!checked)}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="starred-lightweight"
|
||||
className="text-sm font-normal cursor-pointer flex-1"
|
||||
>
|
||||
<div className="space-y-0.5">
|
||||
<div className="font-medium">Lightweight mode</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Only mirror code, skip all metadata
|
||||
</div>
|
||||
</div>
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{!advancedOptions.skipStarredIssues && (
|
||||
<>
|
||||
<Separator className="my-2" />
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
Content included for starred repos:
|
||||
</p>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2 text-xs pl-2">
|
||||
<GitBranch className="h-3 w-3 text-muted-foreground" />
|
||||
<span>Source code</span>
|
||||
<Badge variant="secondary" className="ml-auto text-[10px] px-2 h-4">Always</Badge>
|
||||
</div>
|
||||
|
||||
<div className={cn(
|
||||
"flex items-center gap-2 text-xs pl-2",
|
||||
starredRepoContent.releases ? "" : "opacity-50"
|
||||
)}>
|
||||
<Tag className="h-3 w-3 text-muted-foreground" />
|
||||
<span>Releases & Tags</span>
|
||||
{starredRepoContent.releases && <Badge variant="outline" className="ml-auto text-[10px] px-2 h-4">Included</Badge>}
|
||||
</div>
|
||||
|
||||
<div className={cn(
|
||||
"flex items-center gap-2 text-xs pl-2",
|
||||
starredRepoContent.issues ? "" : "opacity-50"
|
||||
)}>
|
||||
<MessageSquare className="h-3 w-3 text-muted-foreground" />
|
||||
<span>Issues</span>
|
||||
{starredRepoContent.issues && <Badge variant="outline" className="ml-auto text-[10px] px-2 h-4">Included</Badge>}
|
||||
</div>
|
||||
|
||||
<div className={cn(
|
||||
"flex items-center gap-2 text-xs pl-2",
|
||||
starredRepoContent.pullRequests ? "" : "opacity-50"
|
||||
)}>
|
||||
<GitPullRequest className="h-3 w-3 text-muted-foreground" />
|
||||
<span>Pull Requests</span>
|
||||
{starredRepoContent.pullRequests && <Badge variant="outline" className="ml-auto text-[10px] px-2 h-4">Included</Badge>}
|
||||
</div>
|
||||
|
||||
<div className={cn(
|
||||
"flex items-center gap-2 text-xs pl-2",
|
||||
starredRepoContent.wiki ? "" : "opacity-50"
|
||||
)}>
|
||||
<BookOpen className="h-3 w-3 text-muted-foreground" />
|
||||
<span>Wiki</span>
|
||||
{starredRepoContent.wiki && <Badge variant="outline" className="ml-auto text-[10px] px-2 h-4">Included</Badge>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-[10px] text-muted-foreground mt-2">
|
||||
To include more content, enable them in the Content & Data section below
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Content & Data Section */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-3 flex items-center gap-2">
|
||||
<Archive className="h-4 w-4" />
|
||||
Content & Data
|
||||
</h4>
|
||||
<p className="text-xs text-muted-foreground mb-4">
|
||||
Select what content to mirror from each repository
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{/* Code is always mirrored - shown as info */}
|
||||
<div className="flex items-center gap-3 p-3 bg-muted/50 dark:bg-muted/20 rounded-md">
|
||||
<GitBranch className="h-4 w-4 text-muted-foreground" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm">Source code & branches</p>
|
||||
<p className="text-xs text-muted-foreground">Always included</p>
|
||||
</div>
|
||||
<Badge variant="secondary" className="text-xs">Default</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start space-x-3">
|
||||
<Checkbox
|
||||
id="mirror-releases"
|
||||
checked={mirrorOptions.mirrorReleases}
|
||||
onCheckedChange={(checked) => handleMirrorChange('mirrorReleases', !!checked)}
|
||||
/>
|
||||
<div className="space-y-0.5 flex-1">
|
||||
<Label
|
||||
htmlFor="mirror-releases"
|
||||
className="text-sm font-normal cursor-pointer flex items-center gap-2"
|
||||
>
|
||||
<Tag className="h-3.5 w-3.5" />
|
||||
Releases & Tags
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Include GitHub releases, tags, and associated assets
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<div className="flex items-start space-x-3">
|
||||
<Checkbox
|
||||
id="mirror-metadata"
|
||||
checked={mirrorOptions.mirrorMetadata}
|
||||
onCheckedChange={(checked) => handleMirrorChange('mirrorMetadata', !!checked)}
|
||||
/>
|
||||
<div className="space-y-0.5 flex-1">
|
||||
<Label
|
||||
htmlFor="mirror-metadata"
|
||||
className="text-sm font-normal cursor-pointer flex items-center gap-2"
|
||||
>
|
||||
<FileText className="h-3.5 w-3.5" />
|
||||
Repository Metadata
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Mirror issues, pull requests, and other repository data
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metadata multi-select - inline to prevent layout shift */}
|
||||
<div className={cn(
|
||||
"flex items-center justify-end transition-opacity duration-200",
|
||||
mirrorOptions.mirrorMetadata ? "opacity-100" : "opacity-0 pointer-events-none"
|
||||
)}>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!mirrorOptions.mirrorMetadata}
|
||||
className="h-8 text-xs font-normal min-w-[140px] justify-between"
|
||||
>
|
||||
<span>
|
||||
{(() => {
|
||||
const selectedCount = Object.values(mirrorOptions.metadataComponents).filter(Boolean).length;
|
||||
const totalCount = Object.keys(mirrorOptions.metadataComponents).length;
|
||||
if (selectedCount === 0) return "No items selected";
|
||||
if (selectedCount === totalCount) return "All items selected";
|
||||
return `${selectedCount} of ${totalCount} selected`;
|
||||
})()}
|
||||
</span>
|
||||
<ChevronDown className="ml-2 h-3 w-3 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-64">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm font-medium">Metadata to mirror</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-auto px-2 py-1 text-xs font-normal text-primary hover:text-primary/80"
|
||||
onClick={() => {
|
||||
const allSelected = Object.values(mirrorOptions.metadataComponents).every(Boolean);
|
||||
const newValue = !allSelected;
|
||||
|
||||
// Update all metadata components at once
|
||||
onMirrorOptionsChange({
|
||||
...mirrorOptions,
|
||||
metadataComponents: {
|
||||
issues: newValue,
|
||||
pullRequests: newValue,
|
||||
labels: newValue,
|
||||
milestones: newValue,
|
||||
wiki: newValue,
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
{Object.values(mirrorOptions.metadataComponents).every(Boolean) ? 'Deselect all' : 'Select all'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Separator className="my-2" />
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center space-x-3 py-1 px-1 rounded hover:bg-accent">
|
||||
<Checkbox
|
||||
id="metadata-issues-popup"
|
||||
checked={mirrorOptions.metadataComponents.issues}
|
||||
onCheckedChange={(checked) => handleMetadataComponentChange('issues', !!checked)}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="metadata-issues-popup"
|
||||
className="text-sm font-normal cursor-pointer flex items-center gap-2 flex-1"
|
||||
>
|
||||
<MessageSquare className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
Issues
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3 py-1 px-1 rounded hover:bg-accent">
|
||||
<Checkbox
|
||||
id="metadata-prs-popup"
|
||||
checked={mirrorOptions.metadataComponents.pullRequests}
|
||||
onCheckedChange={(checked) => handleMetadataComponentChange('pullRequests', !!checked)}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="metadata-prs-popup"
|
||||
className="text-sm font-normal cursor-pointer flex items-center gap-2 flex-1"
|
||||
>
|
||||
<GitPullRequest className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
Pull Requests
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3 py-1 px-1 rounded hover:bg-accent">
|
||||
<Checkbox
|
||||
id="metadata-labels-popup"
|
||||
checked={mirrorOptions.metadataComponents.labels}
|
||||
onCheckedChange={(checked) => handleMetadataComponentChange('labels', !!checked)}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="metadata-labels-popup"
|
||||
className="text-sm font-normal cursor-pointer flex items-center gap-2 flex-1"
|
||||
>
|
||||
<Tag className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
Labels
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3 py-1 px-1 rounded hover:bg-accent">
|
||||
<Checkbox
|
||||
id="metadata-milestones-popup"
|
||||
checked={mirrorOptions.metadataComponents.milestones}
|
||||
onCheckedChange={(checked) => handleMetadataComponentChange('milestones', !!checked)}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="metadata-milestones-popup"
|
||||
className="text-sm font-normal cursor-pointer flex items-center gap-2 flex-1"
|
||||
>
|
||||
<Target className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
Milestones
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3 py-1 px-1 rounded hover:bg-accent">
|
||||
<Checkbox
|
||||
id="metadata-wiki-popup"
|
||||
checked={mirrorOptions.metadataComponents.wiki}
|
||||
onCheckedChange={(checked) => handleMetadataComponentChange('wiki', !!checked)}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="metadata-wiki-popup"
|
||||
className="text-sm font-normal cursor-pointer flex items-center gap-2 flex-1"
|
||||
>
|
||||
<BookOpen className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
Wiki
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Filtering & Behavior Section */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-3 flex items-center gap-2">
|
||||
<Funnel className="h-4 w-4" />
|
||||
Filtering & Behavior
|
||||
</h4>
|
||||
<p className="text-xs text-muted-foreground mb-4">
|
||||
Fine-tune what gets excluded from mirroring
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start space-x-3">
|
||||
<Checkbox
|
||||
id="skip-forks"
|
||||
checked={advancedOptions.skipForks}
|
||||
onCheckedChange={(checked) => handleAdvancedChange('skipForks', !!checked)}
|
||||
/>
|
||||
<div className="space-y-0.5 flex-1">
|
||||
<Label
|
||||
htmlFor="skip-forks"
|
||||
className="text-sm font-normal cursor-pointer flex items-center gap-2"
|
||||
>
|
||||
<GitFork className="h-3.5 w-3.5" />
|
||||
Skip forked repositories
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Exclude repositories that are forks of other projects
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,39 +1,99 @@
|
||||
import React, { useState } from "react";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "../ui/select";
|
||||
import { giteaApi } from "@/lib/api";
|
||||
import type { GiteaConfig, GiteaOrgVisibility } from "@/types/config";
|
||||
import type { GiteaConfig, MirrorStrategy } from "@/types/config";
|
||||
import { toast } from "sonner";
|
||||
import { OrganizationStrategy } from "./OrganizationStrategy";
|
||||
import { OrganizationConfiguration } from "./OrganizationConfiguration";
|
||||
import { Separator } from "../ui/separator";
|
||||
|
||||
interface GiteaConfigFormProps {
|
||||
config: GiteaConfig;
|
||||
setConfig: React.Dispatch<React.SetStateAction<GiteaConfig>>;
|
||||
onAutoSave?: (giteaConfig: GiteaConfig) => Promise<void>;
|
||||
isAutoSaving?: boolean;
|
||||
githubUsername?: string;
|
||||
}
|
||||
|
||||
export function GiteaConfigForm({ config, setConfig }: GiteaConfigFormProps) {
|
||||
export function GiteaConfigForm({ config, setConfig, onAutoSave, isAutoSaving, githubUsername }: GiteaConfigFormProps) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// Derive the mirror strategy from existing config for backward compatibility
|
||||
const getMirrorStrategy = (): MirrorStrategy => {
|
||||
if (config.mirrorStrategy) return config.mirrorStrategy;
|
||||
if (config.preserveOrgStructure) return "preserve";
|
||||
if (config.organization && config.organization !== config.username) return "single-org";
|
||||
return "flat-user";
|
||||
};
|
||||
|
||||
const [mirrorStrategy, setMirrorStrategy] = useState<MirrorStrategy>(getMirrorStrategy());
|
||||
|
||||
// Update config when strategy changes
|
||||
useEffect(() => {
|
||||
const newConfig = { ...config };
|
||||
|
||||
switch (mirrorStrategy) {
|
||||
case "preserve":
|
||||
newConfig.preserveOrgStructure = true;
|
||||
newConfig.mirrorStrategy = "preserve";
|
||||
break;
|
||||
case "single-org":
|
||||
newConfig.preserveOrgStructure = false;
|
||||
newConfig.mirrorStrategy = "single-org";
|
||||
if (!newConfig.organization) {
|
||||
newConfig.organization = "github-mirrors";
|
||||
}
|
||||
break;
|
||||
case "flat-user":
|
||||
newConfig.preserveOrgStructure = false;
|
||||
newConfig.mirrorStrategy = "flat-user";
|
||||
newConfig.organization = "";
|
||||
break;
|
||||
}
|
||||
|
||||
setConfig(newConfig);
|
||||
if (onAutoSave) {
|
||||
onAutoSave(newConfig);
|
||||
}
|
||||
}, [mirrorStrategy]);
|
||||
|
||||
const handleChange = (
|
||||
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
|
||||
) => {
|
||||
const { name, value } = e.target;
|
||||
setConfig({
|
||||
const { name, value, type } = e.target;
|
||||
const checked = type === "checkbox" ? (e.target as HTMLInputElement).checked : undefined;
|
||||
|
||||
// Special handling for preserveOrgStructure changes
|
||||
if (
|
||||
name === "preserveOrgStructure" &&
|
||||
config.preserveOrgStructure !== checked
|
||||
) {
|
||||
toast.info(
|
||||
"Changing this setting may affect how repositories are accessed in Gitea. " +
|
||||
"Existing mirrored repositories will still be accessible during sync operations.",
|
||||
{
|
||||
duration: 6000,
|
||||
position: "top-center",
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const newConfig = {
|
||||
...config,
|
||||
[name]: value,
|
||||
});
|
||||
[name]: type === "checkbox" ? checked : value,
|
||||
};
|
||||
setConfig(newConfig);
|
||||
|
||||
// Auto-save for all field changes
|
||||
if (onAutoSave) {
|
||||
onAutoSave(newConfig);
|
||||
}
|
||||
};
|
||||
|
||||
const testConnection = async () => {
|
||||
@@ -63,7 +123,7 @@ export function GiteaConfigForm({ config, setConfig }: GiteaConfigFormProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="w-full">
|
||||
<Card className="w-full h-full flex flex-col">
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-4">
|
||||
<CardTitle className="text-lg font-semibold">
|
||||
Gitea Configuration
|
||||
@@ -78,7 +138,7 @@ export function GiteaConfigForm({ config, setConfig }: GiteaConfigFormProps) {
|
||||
</Button>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex flex-col gap-y-6">
|
||||
<CardContent className="flex flex-col gap-y-6 flex-1">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="gitea-username"
|
||||
@@ -140,89 +200,47 @@ export function GiteaConfigForm({ config, setConfig }: GiteaConfigFormProps) {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="organization"
|
||||
className="block text-sm font-medium mb-1.5"
|
||||
>
|
||||
Default Organization (Optional)
|
||||
</label>
|
||||
<input
|
||||
id="organization"
|
||||
name="organization"
|
||||
type="text"
|
||||
value={config.organization}
|
||||
onChange={handleChange}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
placeholder="Organization name"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
If specified, repositories will be mirrored to this organization.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="visibility"
|
||||
className="block text-sm font-medium mb-1.5"
|
||||
>
|
||||
Organization Visibility
|
||||
</label>
|
||||
<Select
|
||||
name="visibility"
|
||||
value={config.visibility}
|
||||
onValueChange={(value) =>
|
||||
handleChange({
|
||||
target: { name: "visibility", value },
|
||||
} as React.ChangeEvent<HTMLInputElement>)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full border border-input dark:bg-background dark:hover:bg-background">
|
||||
<SelectValue placeholder="Select visibility" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-background text-foreground border border-input shadow-sm">
|
||||
{(["public", "private", "limited"] as GiteaOrgVisibility[]).map(
|
||||
(option) => (
|
||||
<SelectItem
|
||||
key={option}
|
||||
value={option}
|
||||
className="cursor-pointer text-sm px-3 py-2 hover:bg-accent focus:bg-accent focus:text-accent-foreground"
|
||||
>
|
||||
{option.charAt(0).toUpperCase() + option.slice(1)}
|
||||
</SelectItem>
|
||||
)
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="starred-repos-org"
|
||||
className="block text-sm font-medium mb-1.5"
|
||||
>
|
||||
Starred Repositories Organization
|
||||
</label>
|
||||
<input
|
||||
id="starred-repos-org"
|
||||
name="starredReposOrg"
|
||||
type="text"
|
||||
value={config.starredReposOrg}
|
||||
onChange={handleChange}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
placeholder="github"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Organization for starred repositories (default: github)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
|
||||
<OrganizationStrategy
|
||||
strategy={mirrorStrategy}
|
||||
destinationOrg={config.organization}
|
||||
starredReposOrg={config.starredReposOrg}
|
||||
onStrategyChange={setMirrorStrategy}
|
||||
githubUsername={githubUsername}
|
||||
giteaUsername={config.username}
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
|
||||
<OrganizationConfiguration
|
||||
strategy={mirrorStrategy}
|
||||
destinationOrg={config.organization}
|
||||
starredReposOrg={config.starredReposOrg}
|
||||
personalReposOrg={config.personalReposOrg}
|
||||
visibility={config.visibility}
|
||||
onDestinationOrgChange={(org) => {
|
||||
const newConfig = { ...config, organization: org };
|
||||
setConfig(newConfig);
|
||||
if (onAutoSave) onAutoSave(newConfig);
|
||||
}}
|
||||
onStarredReposOrgChange={(org) => {
|
||||
const newConfig = { ...config, starredReposOrg: org };
|
||||
setConfig(newConfig);
|
||||
if (onAutoSave) onAutoSave(newConfig);
|
||||
}}
|
||||
onPersonalReposOrgChange={(org) => {
|
||||
const newConfig = { ...config, personalReposOrg: org };
|
||||
setConfig(newConfig);
|
||||
if (onAutoSave) onAutoSave(newConfig);
|
||||
}}
|
||||
onVisibilityChange={(visibility) => {
|
||||
const newConfig = { ...config, visibility };
|
||||
setConfig(newConfig);
|
||||
if (onAutoSave) onAutoSave(newConfig);
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="">
|
||||
{/* Footer content can be added here if needed */}
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
226
src/components/config/MirrorOptionsForm.tsx
Normal file
@@ -0,0 +1,226 @@
|
||||
import React from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Checkbox } from "../ui/checkbox";
|
||||
import type { MirrorOptions } from "@/types/config";
|
||||
import { RefreshCw, Info } from "lucide-react";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
||||
|
||||
interface MirrorOptionsFormProps {
|
||||
config: MirrorOptions;
|
||||
setConfig: React.Dispatch<React.SetStateAction<MirrorOptions>>;
|
||||
onAutoSave?: (config: MirrorOptions) => Promise<void>;
|
||||
isAutoSaving?: boolean;
|
||||
}
|
||||
|
||||
export function MirrorOptionsForm({
|
||||
config,
|
||||
setConfig,
|
||||
onAutoSave,
|
||||
isAutoSaving = false,
|
||||
}: MirrorOptionsFormProps) {
|
||||
const handleChange = (name: string, checked: boolean) => {
|
||||
let newConfig = { ...config };
|
||||
|
||||
if (name === "mirrorMetadata") {
|
||||
newConfig.mirrorMetadata = checked;
|
||||
// If disabling metadata, also disable all components
|
||||
if (!checked) {
|
||||
newConfig.metadataComponents = {
|
||||
issues: false,
|
||||
pullRequests: false,
|
||||
labels: false,
|
||||
milestones: false,
|
||||
wiki: false,
|
||||
};
|
||||
}
|
||||
} else if (name.startsWith("metadataComponents.")) {
|
||||
const componentName = name.split(".")[1] as keyof typeof config.metadataComponents;
|
||||
newConfig.metadataComponents = {
|
||||
...config.metadataComponents,
|
||||
[componentName]: checked,
|
||||
};
|
||||
} else {
|
||||
newConfig = {
|
||||
...config,
|
||||
[name]: checked,
|
||||
};
|
||||
}
|
||||
|
||||
setConfig(newConfig);
|
||||
|
||||
// Auto-save
|
||||
if (onAutoSave) {
|
||||
onAutoSave(newConfig);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="self-start">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg font-semibold flex items-center justify-between">
|
||||
Mirror Options
|
||||
{isAutoSaving && (
|
||||
<div className="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>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Repository Content */}
|
||||
<div className="space-y-4">
|
||||
<h4 className="text-sm font-medium text-foreground">Repository Content</h4>
|
||||
|
||||
<div className="flex items-center">
|
||||
<Checkbox
|
||||
id="mirror-releases"
|
||||
checked={config.mirrorReleases}
|
||||
onCheckedChange={(checked) =>
|
||||
handleChange("mirrorReleases", Boolean(checked))
|
||||
}
|
||||
/>
|
||||
<label
|
||||
htmlFor="mirror-releases"
|
||||
className="ml-2 text-sm select-none flex items-center"
|
||||
>
|
||||
Mirror releases
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="ml-1 cursor-pointer text-muted-foreground">
|
||||
<Info size={14} />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" className="max-w-xs text-xs">
|
||||
Include GitHub releases and tags in the mirror
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<Checkbox
|
||||
id="mirror-metadata"
|
||||
checked={config.mirrorMetadata}
|
||||
onCheckedChange={(checked) =>
|
||||
handleChange("mirrorMetadata", Boolean(checked))
|
||||
}
|
||||
/>
|
||||
<label
|
||||
htmlFor="mirror-metadata"
|
||||
className="ml-2 text-sm select-none flex items-center"
|
||||
>
|
||||
Mirror metadata
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="ml-1 cursor-pointer text-muted-foreground">
|
||||
<Info size={14} />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" className="max-w-xs text-xs">
|
||||
Include issues, pull requests, labels, milestones, and wiki
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Metadata Components */}
|
||||
{config.mirrorMetadata && (
|
||||
<div className="ml-6 space-y-3 border-l-2 border-muted pl-4">
|
||||
<h5 className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Metadata Components
|
||||
</h5>
|
||||
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
<div className="flex items-center">
|
||||
<Checkbox
|
||||
id="metadata-issues"
|
||||
checked={config.metadataComponents.issues}
|
||||
onCheckedChange={(checked) =>
|
||||
handleChange("metadataComponents.issues", Boolean(checked))
|
||||
}
|
||||
disabled={!config.mirrorMetadata}
|
||||
/>
|
||||
<label
|
||||
htmlFor="metadata-issues"
|
||||
className="ml-2 text-sm select-none"
|
||||
>
|
||||
Issues
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<Checkbox
|
||||
id="metadata-pullRequests"
|
||||
checked={config.metadataComponents.pullRequests}
|
||||
onCheckedChange={(checked) =>
|
||||
handleChange("metadataComponents.pullRequests", Boolean(checked))
|
||||
}
|
||||
disabled={!config.mirrorMetadata}
|
||||
/>
|
||||
<label
|
||||
htmlFor="metadata-pullRequests"
|
||||
className="ml-2 text-sm select-none"
|
||||
>
|
||||
Pull requests
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<Checkbox
|
||||
id="metadata-labels"
|
||||
checked={config.metadataComponents.labels}
|
||||
onCheckedChange={(checked) =>
|
||||
handleChange("metadataComponents.labels", Boolean(checked))
|
||||
}
|
||||
disabled={!config.mirrorMetadata}
|
||||
/>
|
||||
<label
|
||||
htmlFor="metadata-labels"
|
||||
className="ml-2 text-sm select-none"
|
||||
>
|
||||
Labels
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<Checkbox
|
||||
id="metadata-milestones"
|
||||
checked={config.metadataComponents.milestones}
|
||||
onCheckedChange={(checked) =>
|
||||
handleChange("metadataComponents.milestones", Boolean(checked))
|
||||
}
|
||||
disabled={!config.mirrorMetadata}
|
||||
/>
|
||||
<label
|
||||
htmlFor="metadata-milestones"
|
||||
className="ml-2 text-sm select-none"
|
||||
>
|
||||
Milestones
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<Checkbox
|
||||
id="metadata-wiki"
|
||||
checked={config.metadataComponents.wiki}
|
||||
onCheckedChange={(checked) =>
|
||||
handleChange("metadataComponents.wiki", Boolean(checked))
|
||||
}
|
||||
disabled={!config.mirrorMetadata}
|
||||
/>
|
||||
<label
|
||||
htmlFor="metadata-wiki"
|
||||
className="ml-2 text-sm select-none"
|
||||
>
|
||||
Wiki
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
201
src/components/config/OrganizationConfiguration.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
import React from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Star, Globe, Lock, Shield, Info, MonitorCog } from "lucide-react";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { MirrorStrategy, GiteaOrgVisibility } from "@/types/config";
|
||||
|
||||
interface OrganizationConfigurationProps {
|
||||
strategy: MirrorStrategy;
|
||||
destinationOrg?: string;
|
||||
starredReposOrg?: string;
|
||||
personalReposOrg?: string;
|
||||
visibility: GiteaOrgVisibility;
|
||||
onDestinationOrgChange: (org: string) => void;
|
||||
onStarredReposOrgChange: (org: string) => void;
|
||||
onPersonalReposOrgChange: (org: string) => void;
|
||||
onVisibilityChange: (visibility: GiteaOrgVisibility) => void;
|
||||
}
|
||||
|
||||
const visibilityOptions = [
|
||||
{ value: "public" as GiteaOrgVisibility, label: "Public", icon: Globe, description: "Visible to everyone" },
|
||||
{ value: "private" as GiteaOrgVisibility, label: "Private", icon: Lock, description: "Visible to members only" },
|
||||
{ value: "limited" as GiteaOrgVisibility, label: "Limited", icon: Shield, description: "Visible to logged-in users" },
|
||||
];
|
||||
|
||||
export const OrganizationConfiguration: React.FC<OrganizationConfigurationProps> = ({
|
||||
strategy,
|
||||
destinationOrg,
|
||||
starredReposOrg,
|
||||
personalReposOrg,
|
||||
visibility,
|
||||
onDestinationOrgChange,
|
||||
onStarredReposOrgChange,
|
||||
onPersonalReposOrgChange,
|
||||
onVisibilityChange,
|
||||
}) => {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-3 flex items-center gap-2">
|
||||
<MonitorCog className="h-4 w-4" />
|
||||
Organization Configuration
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
{/* First row - Organization inputs with consistent layout */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Left column - always shows starred repos org */}
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="starredReposOrg" className="text-sm font-normal flex items-center gap-2">
|
||||
<Star className="h-3.5 w-3.5" />
|
||||
Starred Repos Organization
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Info className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Starred repositories will be organized separately in this organization</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</Label>
|
||||
<Input
|
||||
id="starredReposOrg"
|
||||
value={starredReposOrg || ""}
|
||||
onChange={(e) => onStarredReposOrgChange(e.target.value)}
|
||||
placeholder="starred"
|
||||
className=""
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Keep starred repos organized separately
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Right column - shows destination org for single-org/mixed, personal repos org for preserve, empty div for others */}
|
||||
{strategy === "single-org" || strategy === "mixed" ? (
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="destinationOrg" className="text-sm font-normal flex items-center gap-2">
|
||||
{strategy === "mixed" ? "Personal Repos Organization" : "Destination Organization"}
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Info className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
{strategy === "mixed"
|
||||
? "Personal repositories will be mirrored to this organization, while organization repos preserve their structure"
|
||||
: "All repositories will be mirrored to this organization"
|
||||
}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</Label>
|
||||
<Input
|
||||
id="destinationOrg"
|
||||
value={destinationOrg || ""}
|
||||
onChange={(e) => onDestinationOrgChange(e.target.value)}
|
||||
placeholder="github-mirrors"
|
||||
className=""
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{strategy === "mixed"
|
||||
? "All personal repos will go to this organization"
|
||||
: "Organization for consolidated repositories"
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
) : strategy === "preserve" ? (
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="personalReposOrg" className="text-sm font-normal flex items-center gap-2">
|
||||
Personal Repos Organization
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Info className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Override where your personal repositories are mirrored (leave empty to use your username)</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</Label>
|
||||
<Input
|
||||
id="personalReposOrg"
|
||||
value={personalReposOrg || ""}
|
||||
onChange={(e) => onPersonalReposOrgChange(e.target.value)}
|
||||
placeholder="my-personal-mirrors"
|
||||
className=""
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Override destination for your personal repos
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="hidden md:block" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Second row - Organization Visibility (always shown) */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-normal flex items-center gap-2">
|
||||
Organization Visibility
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Info className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Default visibility for newly created organizations</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</Label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{visibilityOptions.map((option) => {
|
||||
const Icon = option.icon;
|
||||
const isSelected = visibility === option.value;
|
||||
return (
|
||||
<TooltipProvider key={option.value}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onVisibilityChange(option.value)}
|
||||
className={cn(
|
||||
"flex items-center justify-between px-3 py-2 rounded-md text-sm transition-all",
|
||||
"border group",
|
||||
isSelected
|
||||
? "bg-accent border-accent-foreground/20"
|
||||
: "bg-background hover:bg-accent/50 border-input"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
<span>{option.label}</span>
|
||||
</div>
|
||||
<Info className="h-3 w-3 text-muted-foreground opacity-50 group-hover:opacity-100 transition-opacity" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className="text-xs">{option.description}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
434
src/components/config/OrganizationStrategy.tsx
Normal file
@@ -0,0 +1,434 @@
|
||||
import React from "react";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { Info, GitBranch, FolderTree, Star, Building2, User, Building } from "lucide-react";
|
||||
import {
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from "@/components/ui/hover-card";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type MirrorStrategy = "preserve" | "single-org" | "flat-user" | "mixed";
|
||||
|
||||
interface OrganizationStrategyProps {
|
||||
strategy: MirrorStrategy;
|
||||
destinationOrg?: string;
|
||||
starredReposOrg?: string;
|
||||
onStrategyChange: (strategy: MirrorStrategy) => void;
|
||||
githubUsername?: string;
|
||||
giteaUsername?: string;
|
||||
}
|
||||
|
||||
const strategyConfig = {
|
||||
preserve: {
|
||||
title: "Preserve Structure",
|
||||
icon: FolderTree,
|
||||
description: "Keep the exact same organization structure as GitHub",
|
||||
color: "text-blue-600 dark:text-blue-400",
|
||||
bgColor: "bg-blue-50 dark:bg-blue-950/20",
|
||||
borderColor: "border-blue-200 dark:border-blue-900",
|
||||
repoColors: {
|
||||
bg: "bg-blue-50 dark:bg-blue-950/30",
|
||||
icon: "text-blue-600 dark:text-blue-400"
|
||||
}
|
||||
},
|
||||
"single-org": {
|
||||
title: "Single Organization",
|
||||
icon: Building2,
|
||||
description: "Consolidate all repositories into one Gitea organization",
|
||||
color: "text-purple-600 dark:text-purple-400",
|
||||
bgColor: "bg-purple-50 dark:bg-purple-950/20",
|
||||
borderColor: "border-purple-200 dark:border-purple-900",
|
||||
repoColors: {
|
||||
bg: "bg-purple-50 dark:bg-purple-950/30",
|
||||
icon: "text-purple-600 dark:text-purple-400"
|
||||
}
|
||||
},
|
||||
"flat-user": {
|
||||
title: "User Repositories",
|
||||
icon: User,
|
||||
description: "Place all repositories directly under your user account",
|
||||
color: "text-green-600 dark:text-green-400",
|
||||
bgColor: "bg-green-50 dark:bg-green-950/20",
|
||||
borderColor: "border-green-200 dark:border-green-900",
|
||||
repoColors: {
|
||||
bg: "bg-green-50 dark:bg-green-950/30",
|
||||
icon: "text-green-600 dark:text-green-400"
|
||||
}
|
||||
},
|
||||
"mixed": {
|
||||
title: "Mixed Mode",
|
||||
icon: GitBranch,
|
||||
description: "user repos in single org, org repos preserve structure",
|
||||
color: "text-orange-600 dark:text-orange-400",
|
||||
bgColor: "bg-orange-50 dark:bg-orange-950/20",
|
||||
borderColor: "border-orange-200 dark:border-orange-900",
|
||||
repoColors: {
|
||||
bg: "bg-orange-50 dark:bg-orange-950/30",
|
||||
icon: "text-orange-600 dark:text-orange-400"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const MappingPreview: React.FC<{
|
||||
strategy: MirrorStrategy;
|
||||
config: typeof strategyConfig.preserve;
|
||||
destinationOrg?: string;
|
||||
starredReposOrg?: string;
|
||||
githubUsername?: string;
|
||||
giteaUsername?: string;
|
||||
}> = ({ strategy, config, destinationOrg, starredReposOrg, githubUsername, giteaUsername }) => {
|
||||
const displayGithubUsername = githubUsername || "<username>";
|
||||
const displayGiteaUsername = giteaUsername || "<username>";
|
||||
const isGithubPlaceholder = !githubUsername;
|
||||
const isGiteaPlaceholder = !giteaUsername;
|
||||
|
||||
if (strategy === "preserve") {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-6">
|
||||
<div className="flex-1">
|
||||
<div className="text-xs font-medium text-muted-foreground mb-2">GitHub</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2 p-1.5 bg-gray-50 dark:bg-gray-800 rounded text-xs">
|
||||
<User className="h-3 w-3" />
|
||||
<span className={cn(isGithubPlaceholder && "text-muted-foreground italic")}>{displayGithubUsername}/my-repo</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 p-1.5 bg-gray-50 dark:bg-gray-800 rounded text-xs">
|
||||
<Building2 className="h-3 w-3" />
|
||||
<span>my-org/team-repo</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 p-1.5 bg-gray-50 dark:bg-gray-800 rounded text-xs">
|
||||
<Star className="h-3 w-3" />
|
||||
<span>awesome/starred-repo</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<GitBranch className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="text-xs font-medium text-muted-foreground mb-2">Gitea</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className={cn("flex items-center gap-2 p-1.5 rounded text-xs", config.repoColors.bg)}>
|
||||
<User className={cn("h-3 w-3", config.repoColors.icon)} />
|
||||
<span className={cn(isGiteaPlaceholder && "text-muted-foreground italic")}>{displayGiteaUsername}/my-repo</span>
|
||||
</div>
|
||||
<div className={cn("flex items-center gap-2 p-1.5 rounded text-xs", config.repoColors.bg)}>
|
||||
<Building2 className={cn("h-3 w-3", config.repoColors.icon)} />
|
||||
<span>my-org/team-repo</span>
|
||||
</div>
|
||||
<div className={cn("flex items-center gap-2 p-1.5 rounded text-xs", config.repoColors.bg)}>
|
||||
<Building2 className={cn("h-3 w-3", config.repoColors.icon)} />
|
||||
<span>{starredReposOrg || "starred"}/starred-repo</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (strategy === "single-org") {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-6">
|
||||
<div className="flex-1">
|
||||
<div className="text-xs font-medium text-muted-foreground mb-2">GitHub</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2 p-1.5 bg-gray-50 dark:bg-gray-800 rounded text-xs">
|
||||
<User className="h-3 w-3" />
|
||||
<span className={cn(isGithubPlaceholder && "text-muted-foreground italic")}>{displayGithubUsername}/my-repo</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 p-1.5 bg-gray-50 dark:bg-gray-800 rounded text-xs">
|
||||
<Building2 className="h-3 w-3" />
|
||||
<span>my-org/team-repo</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 p-1.5 bg-gray-50 dark:bg-gray-800 rounded text-xs">
|
||||
<Star className="h-3 w-3" />
|
||||
<span>awesome/starred-repo</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<GitBranch className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="text-xs font-medium text-muted-foreground mb-2">Gitea</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className={cn("flex items-center gap-2 p-1.5 rounded text-xs", config.repoColors.bg)}>
|
||||
<Building2 className={cn("h-3 w-3", config.repoColors.icon)} />
|
||||
<span>{destinationOrg || "github-mirrors"}/my-repo</span>
|
||||
</div>
|
||||
<div className={cn("flex items-center gap-2 p-1.5 rounded text-xs", config.repoColors.bg)}>
|
||||
<Building2 className={cn("h-3 w-3", config.repoColors.icon)} />
|
||||
<span>{destinationOrg || "github-mirrors"}/team-repo</span>
|
||||
</div>
|
||||
<div className={cn("flex items-center gap-2 p-1.5 rounded text-xs", config.repoColors.bg)}>
|
||||
<Building2 className={cn("h-3 w-3", config.repoColors.icon)} />
|
||||
<span>{starredReposOrg || "starred"}/starred-repo</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (strategy === "flat-user") {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-6">
|
||||
<div className="flex-1">
|
||||
<div className="text-xs font-medium text-muted-foreground mb-2">GitHub</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2 p-1.5 bg-gray-50 dark:bg-gray-800 rounded text-xs">
|
||||
<User className="h-3 w-3" />
|
||||
<span className={cn(isGithubPlaceholder && "text-muted-foreground italic")}>{displayGithubUsername}/my-repo</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 p-1.5 bg-gray-50 dark:bg-gray-800 rounded text-xs">
|
||||
<Building2 className="h-3 w-3" />
|
||||
<span>my-org/team-repo</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 p-1.5 bg-gray-50 dark:bg-gray-800 rounded text-xs">
|
||||
<Star className="h-3 w-3" />
|
||||
<span>awesome/starred-repo</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<GitBranch className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="text-xs font-medium text-muted-foreground mb-2">Gitea</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className={cn("flex items-center gap-2 p-1.5 rounded text-xs", config.repoColors.bg)}>
|
||||
<User className={cn("h-3 w-3", config.repoColors.icon)} />
|
||||
<span className={cn(isGiteaPlaceholder && "text-muted-foreground italic")}>{displayGiteaUsername}/my-repo</span>
|
||||
</div>
|
||||
<div className={cn("flex items-center gap-2 p-1.5 rounded text-xs", config.repoColors.bg)}>
|
||||
<User className={cn("h-3 w-3", config.repoColors.icon)} />
|
||||
<span className={cn(isGiteaPlaceholder && "text-muted-foreground italic")}>{displayGiteaUsername}/team-repo</span>
|
||||
</div>
|
||||
<div className={cn("flex items-center gap-2 p-1.5 rounded text-xs", config.repoColors.bg)}>
|
||||
<Building2 className={cn("h-3 w-3", config.repoColors.icon)} />
|
||||
<span>{starredReposOrg || "starred"}/starred-repo</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (strategy === "mixed") {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-6">
|
||||
<div className="flex-1">
|
||||
<div className="text-xs font-medium text-muted-foreground mb-2">GitHub</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2 p-1.5 bg-gray-50 dark:bg-gray-800 rounded text-xs">
|
||||
<User className="h-3 w-3" />
|
||||
<span className={cn(isGithubPlaceholder && "text-muted-foreground italic")}>{displayGithubUsername}/my-repo</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 p-1.5 bg-gray-50 dark:bg-gray-800 rounded text-xs">
|
||||
<Building2 className="h-3 w-3" />
|
||||
<span>my-org/team-repo</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 p-1.5 bg-gray-50 dark:bg-gray-800 rounded text-xs">
|
||||
<Star className="h-3 w-3" />
|
||||
<span>awesome/starred-repo</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<GitBranch className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="text-xs font-medium text-muted-foreground mb-2">Gitea</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className={cn("flex items-center gap-2 p-1.5 rounded text-xs", config.repoColors.bg)}>
|
||||
<Building2 className={cn("h-3 w-3", config.repoColors.icon)} />
|
||||
<span>{destinationOrg || "github-mirrors"}/my-repo</span>
|
||||
</div>
|
||||
<div className={cn("flex items-center gap-2 p-1.5 rounded text-xs", config.repoColors.bg)}>
|
||||
<Building2 className={cn("h-3 w-3", config.repoColors.icon)} />
|
||||
<span>my-org/team-repo</span>
|
||||
</div>
|
||||
<div className={cn("flex items-center gap-2 p-1.5 rounded text-xs", config.repoColors.bg)}>
|
||||
<Building2 className={cn("h-3 w-3", config.repoColors.icon)} />
|
||||
<span>{starredReposOrg || "starred"}/starred-repo</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const OrganizationStrategy: React.FC<OrganizationStrategyProps> = ({
|
||||
strategy,
|
||||
destinationOrg,
|
||||
starredReposOrg,
|
||||
onStrategyChange,
|
||||
githubUsername,
|
||||
giteaUsername,
|
||||
}) => {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<h4 className="text-sm font-medium mb-3 flex items-center gap-2">
|
||||
<Building className="h-4 w-4" />
|
||||
Organization Strategy
|
||||
</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Choose how your repositories will be organized in Gitea
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex-shrink-0">
|
||||
<HoverCard openDelay={200}>
|
||||
<HoverCardTrigger asChild>
|
||||
<button
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-muted rounded-md transition-colors"
|
||||
type="button"
|
||||
>
|
||||
<Info className="h-3.5 w-3.5" />
|
||||
<span>Override Options</span>
|
||||
</button>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent side="left" align="start" className="w-[380px]">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<h4 className="font-medium text-sm mb-1.5">Fine-tune Your Mirror Destinations</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
After selecting a strategy, you can customize destinations for specific organizations and repositories.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2.5 pt-2 border-t">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Building2 className="h-3.5 w-3.5 text-primary" />
|
||||
<span className="text-xs font-medium">Organization Overrides</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground pl-5">
|
||||
Click the edit button on any organization card to redirect all its repositories to a different Gitea organization.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<GitBranch className="h-3.5 w-3.5 text-primary" />
|
||||
<span className="text-xs font-medium">Repository Overrides</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground pl-5">
|
||||
Use the inline editor in the repository table's "Destination" column to set custom destinations for individual repositories.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Star className="h-3.5 w-3.5 text-yellow-500" />
|
||||
<span className="text-xs font-medium">Starred Repositories</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground pl-5">
|
||||
Always go to the configured starred repos organization and cannot be overridden.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-2 border-t">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<span className="font-medium">Priority:</span> Repository override → Organization override → Strategy default
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RadioGroup value={strategy} onValueChange={onStrategyChange}>
|
||||
<div className="grid grid-cols-1 2xl:grid-cols-2 gap-4">
|
||||
{(Object.entries(strategyConfig) as [MirrorStrategy, typeof strategyConfig.preserve][]).map(([key, config]) => {
|
||||
const isSelected = strategy === key;
|
||||
const Icon = config.icon;
|
||||
|
||||
return (
|
||||
<div key={key}>
|
||||
<label htmlFor={key} className="cursor-pointer">
|
||||
<Card
|
||||
className={cn(
|
||||
"relative",
|
||||
isSelected && `${config.borderColor} border-2`,
|
||||
!isSelected && "border-muted"
|
||||
)}
|
||||
>
|
||||
<div className="p-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<RadioGroupItem
|
||||
value={key}
|
||||
id={key}
|
||||
/>
|
||||
|
||||
<div className={cn(
|
||||
"rounded-lg p-2",
|
||||
isSelected ? config.bgColor : "bg-muted dark:bg-muted/50"
|
||||
)}>
|
||||
<Icon className={cn(
|
||||
"h-4 w-4",
|
||||
isSelected ? config.color : "text-muted-foreground dark:text-muted-foreground/70"
|
||||
)} />
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="font-medium text-sm">{config.title}</h4>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{config.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<HoverCard openDelay={200}>
|
||||
<HoverCardTrigger asChild>
|
||||
<span
|
||||
className="inline-flex p-1.5 hover:bg-muted rounded-md transition-colors cursor-help"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Info className="h-4 w-4 text-muted-foreground" />
|
||||
</span>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent side="left" align="center" className="w-[500px]">
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-medium text-sm">Repository Mapping Preview</h4>
|
||||
<MappingPreview
|
||||
strategy={key}
|
||||
config={config}
|
||||
destinationOrg={destinationOrg}
|
||||
starredReposOrg={starredReposOrg}
|
||||
githubUsername={githubUsername}
|
||||
giteaUsername={giteaUsername}
|
||||
/>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
47
src/components/config/ScheduleAndCleanupForm.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import React from 'react';
|
||||
import { ScheduleConfigForm } from './ScheduleConfigForm';
|
||||
import { DatabaseCleanupConfigForm } from './DatabaseCleanupConfigForm';
|
||||
import { Separator } from '../ui/separator';
|
||||
import type { ScheduleConfig, DatabaseCleanupConfig } from '@/types/config';
|
||||
|
||||
interface ScheduleAndCleanupFormProps {
|
||||
scheduleConfig: ScheduleConfig;
|
||||
cleanupConfig: DatabaseCleanupConfig;
|
||||
setScheduleConfig: (update: ScheduleConfig | ((prev: ScheduleConfig) => ScheduleConfig)) => void;
|
||||
setCleanupConfig: (update: DatabaseCleanupConfig | ((prev: DatabaseCleanupConfig) => DatabaseCleanupConfig)) => void;
|
||||
onAutoSaveSchedule?: (config: ScheduleConfig) => Promise<void>;
|
||||
onAutoSaveCleanup?: (config: DatabaseCleanupConfig) => Promise<void>;
|
||||
isAutoSavingSchedule?: boolean;
|
||||
isAutoSavingCleanup?: boolean;
|
||||
}
|
||||
|
||||
export function ScheduleAndCleanupForm({
|
||||
scheduleConfig,
|
||||
cleanupConfig,
|
||||
setScheduleConfig,
|
||||
setCleanupConfig,
|
||||
onAutoSaveSchedule,
|
||||
onAutoSaveCleanup,
|
||||
isAutoSavingSchedule,
|
||||
isAutoSavingCleanup,
|
||||
}: ScheduleAndCleanupFormProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<ScheduleConfigForm
|
||||
config={scheduleConfig}
|
||||
setConfig={setScheduleConfig}
|
||||
onAutoSave={onAutoSaveSchedule}
|
||||
isAutoSaving={isAutoSavingSchedule}
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
|
||||
<DatabaseCleanupConfigForm
|
||||
config={cleanupConfig}
|
||||
setConfig={setCleanupConfig}
|
||||
onAutoSave={onAutoSaveCleanup}
|
||||
isAutoSaving={isAutoSavingCleanup}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,40 +9,40 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "../ui/select";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
|
||||
interface ScheduleConfigFormProps {
|
||||
config: ScheduleConfig;
|
||||
setConfig: React.Dispatch<React.SetStateAction<ScheduleConfig>>;
|
||||
onAutoSave?: (config: ScheduleConfig) => void;
|
||||
isAutoSaving?: boolean;
|
||||
}
|
||||
|
||||
export function ScheduleConfigForm({
|
||||
config,
|
||||
setConfig,
|
||||
onAutoSave,
|
||||
isAutoSaving = false,
|
||||
}: ScheduleConfigFormProps) {
|
||||
const handleChange = (
|
||||
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
|
||||
) => {
|
||||
const { name, value, type } = e.target;
|
||||
setConfig({
|
||||
const newConfig = {
|
||||
...config,
|
||||
[name]:
|
||||
type === "checkbox" ? (e.target as HTMLInputElement).checked : value,
|
||||
});
|
||||
};
|
||||
};
|
||||
setConfig(newConfig);
|
||||
|
||||
// Convert seconds to human-readable format
|
||||
const formatInterval = (seconds: number): string => {
|
||||
if (seconds < 60) return `${seconds} seconds`;
|
||||
if (seconds < 3600) return `${Math.floor(seconds / 60)} minutes`;
|
||||
if (seconds < 86400) return `${Math.floor(seconds / 3600)} hours`;
|
||||
return `${Math.floor(seconds / 86400)} days`;
|
||||
// Trigger auto-save for schedule config changes
|
||||
if (onAutoSave) {
|
||||
onAutoSave(newConfig);
|
||||
}
|
||||
};
|
||||
|
||||
// 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" },
|
||||
@@ -54,8 +54,14 @@ export function ScheduleConfigForm({
|
||||
];
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<Card className="self-start">
|
||||
<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
|
||||
@@ -81,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>
|
||||
|
||||
@@ -2,18 +2,27 @@ import { StatusCard } from "./StatusCard";
|
||||
import { RecentActivity } from "./RecentActivity";
|
||||
import { RepositoryList } from "./RepositoryList";
|
||||
import { GitFork, Clock, FlipHorizontal, Building2 } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { MirrorJob, Organization, Repository } from "@/lib/db/schema";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiRequest } from "@/lib/utils";
|
||||
import { apiRequest, showErrorToast } from "@/lib/utils";
|
||||
import type { DashboardApiResponse } from "@/types/dashboard";
|
||||
import { useSSE } from "@/hooks/useSEE";
|
||||
import { toast } from "sonner";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { useLiveRefresh } from "@/hooks/useLiveRefresh";
|
||||
import { usePageVisibility } from "@/hooks/usePageVisibility";
|
||||
import { useConfigStatus } from "@/hooks/useConfigStatus";
|
||||
import { useNavigation } from "@/components/layout/MainLayout";
|
||||
|
||||
export function Dashboard() {
|
||||
const { user } = useAuth();
|
||||
const { registerRefreshCallback } = useLiveRefresh();
|
||||
const isPageVisible = usePageVisibility();
|
||||
const { isFullyConfigured } = useConfigStatus();
|
||||
const { navigationKey } = useNavigation();
|
||||
|
||||
const [repositories, setRepositories] = useState<Repository[]>([]);
|
||||
const [organizations, setOrganizations] = useState<Organization[]>([]);
|
||||
const [activities, setActivities] = useState<MirrorJob[]>([]);
|
||||
@@ -23,6 +32,10 @@ export function Dashboard() {
|
||||
const [mirroredCount, setMirroredCount] = useState<number>(0);
|
||||
const [lastSync, setLastSync] = useState<Date | null>(null);
|
||||
|
||||
// Dashboard auto-refresh timer (30 seconds)
|
||||
const dashboardTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const DASHBOARD_REFRESH_INTERVAL = 30000; // 30 seconds
|
||||
|
||||
// Create a stable callback using useCallback
|
||||
const handleNewMessage = useCallback((data: MirrorJob) => {
|
||||
if (data.repositoryId) {
|
||||
@@ -54,44 +67,97 @@ export function Dashboard() {
|
||||
onMessage: handleNewMessage,
|
||||
});
|
||||
|
||||
// Extract fetchDashboardData as a stable callback
|
||||
const fetchDashboardData = useCallback(async (showToast = false) => {
|
||||
try {
|
||||
if (!user?.id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Don't fetch data if configuration is not complete
|
||||
if (!isFullyConfigured) {
|
||||
if (showToast) {
|
||||
toast.info("Please configure GitHub and Gitea settings first");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const response = await apiRequest<DashboardApiResponse>(
|
||||
`/dashboard?userId=${user.id}`,
|
||||
{
|
||||
method: "GET",
|
||||
}
|
||||
);
|
||||
|
||||
if (response.success) {
|
||||
setRepositories(response.repositories);
|
||||
setOrganizations(response.organizations);
|
||||
setActivities(response.activities);
|
||||
setRepoCount(response.repoCount);
|
||||
setOrgCount(response.orgCount);
|
||||
setMirroredCount(response.mirroredCount);
|
||||
setLastSync(response.lastSync);
|
||||
|
||||
if (showToast) {
|
||||
toast.success("Dashboard data refreshed successfully");
|
||||
}
|
||||
return true;
|
||||
} else {
|
||||
showErrorToast(response.error || "Error fetching dashboard data", toast);
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
showErrorToast(error, toast);
|
||||
return false;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [user?.id, isFullyConfigured]); // Only depend on user.id, not entire user object
|
||||
|
||||
// Initial data fetch and reset loading state when component becomes active
|
||||
useEffect(() => {
|
||||
const fetchDashboardData = async () => {
|
||||
try {
|
||||
if (!user || !user.id) {
|
||||
return;
|
||||
}
|
||||
// Reset loading state when component mounts or becomes active
|
||||
setIsLoading(true);
|
||||
fetchDashboardData();
|
||||
}, [fetchDashboardData, navigationKey]); // Include navigationKey to trigger on navigation
|
||||
|
||||
const response = await apiRequest<DashboardApiResponse>(
|
||||
`/dashboard?userId=${user.id}`,
|
||||
{
|
||||
method: "GET",
|
||||
}
|
||||
);
|
||||
// Setup dashboard auto-refresh (30 seconds) and register with live refresh
|
||||
useEffect(() => {
|
||||
// Clear any existing timer
|
||||
if (dashboardTimerRef.current) {
|
||||
clearInterval(dashboardTimerRef.current);
|
||||
dashboardTimerRef.current = null;
|
||||
}
|
||||
|
||||
if (response.success) {
|
||||
setRepositories(response.repositories);
|
||||
setOrganizations(response.organizations);
|
||||
setActivities(response.activities);
|
||||
setRepoCount(response.repoCount);
|
||||
setOrgCount(response.orgCount);
|
||||
setMirroredCount(response.mirroredCount);
|
||||
setLastSync(response.lastSync);
|
||||
} else {
|
||||
toast.error(response.error || "Error fetching dashboard data");
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Error fetching dashboard data"
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
// Set up 30-second auto-refresh only when page is visible and configuration is complete
|
||||
if (isPageVisible && isFullyConfigured) {
|
||||
dashboardTimerRef.current = setInterval(() => {
|
||||
fetchDashboardData();
|
||||
}, DASHBOARD_REFRESH_INTERVAL);
|
||||
}
|
||||
|
||||
// Cleanup on unmount or when page becomes invisible
|
||||
return () => {
|
||||
if (dashboardTimerRef.current) {
|
||||
clearInterval(dashboardTimerRef.current);
|
||||
dashboardTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [isPageVisible, isFullyConfigured, fetchDashboardData]);
|
||||
|
||||
fetchDashboardData();
|
||||
}, [user]);
|
||||
// Register with global live refresh system
|
||||
useEffect(() => {
|
||||
// Only register if configuration is complete
|
||||
if (!isFullyConfigured) {
|
||||
return;
|
||||
}
|
||||
|
||||
const unregister = registerRefreshCallback(() => {
|
||||
fetchDashboardData();
|
||||
});
|
||||
|
||||
return unregister;
|
||||
}, [registerRefreshCallback, fetchDashboardData, isFullyConfigured]);
|
||||
|
||||
// Status Card Skeleton component
|
||||
function StatusCardSkeleton() {
|
||||
@@ -150,6 +216,7 @@ export function Dashboard() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-y-6">
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<StatusCard
|
||||
title="Total Repositories"
|
||||
|
||||
@@ -1,15 +1,50 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { GitFork } from "lucide-react";
|
||||
import { SiGithub } from "react-icons/si";
|
||||
import { SiGithub, SiGitea } from "react-icons/si";
|
||||
import type { Repository } from "@/lib/db/schema";
|
||||
import { getStatusColor } from "@/lib/utils";
|
||||
import { useGiteaConfig } from "@/hooks/useGiteaConfig";
|
||||
|
||||
interface RepositoryListProps {
|
||||
repositories: Repository[];
|
||||
}
|
||||
|
||||
export function RepositoryList({ repositories }: RepositoryListProps) {
|
||||
const { giteaConfig } = useGiteaConfig();
|
||||
|
||||
// Helper function to construct Gitea repository URL
|
||||
const getGiteaRepoUrl = (repository: Repository): string | null => {
|
||||
if (!giteaConfig?.url) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Only provide Gitea links for repositories that have been or are being mirrored
|
||||
const validStatuses = ['mirroring', 'mirrored', 'syncing', 'synced'];
|
||||
if (!validStatuses.includes(repository.status)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Use mirroredLocation if available, otherwise construct from repository data
|
||||
let repoPath: string;
|
||||
if (repository.mirroredLocation) {
|
||||
repoPath = repository.mirroredLocation;
|
||||
} else {
|
||||
// Fallback: construct the path based on repository data
|
||||
// If repository has organization and preserveOrgStructure would be true, use org
|
||||
// Otherwise use the repository owner
|
||||
const owner = repository.organization || repository.owner;
|
||||
repoPath = `${owner}/${repository.name}`;
|
||||
}
|
||||
|
||||
// Ensure the base URL doesn't have a trailing slash
|
||||
const baseUrl = giteaConfig.url.endsWith('/')
|
||||
? giteaConfig.url.slice(0, -1)
|
||||
: giteaConfig.url;
|
||||
|
||||
return `${baseUrl}/${repoPath}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="w-full">
|
||||
{/* calculating the max height based non the other elements and sizing styles */}
|
||||
@@ -46,6 +81,11 @@ export function RepositoryList({ repositories }: RepositoryListProps) {
|
||||
Private
|
||||
</span>
|
||||
)}
|
||||
{repo.isForked && (
|
||||
<span className="rounded-full bg-muted px-2 py-0.5 text-xs">
|
||||
Fork
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
@@ -69,14 +109,48 @@ export function RepositoryList({ repositories }: RepositoryListProps) {
|
||||
{/* setting the minimum width to 3rem corresponding to the largest status (mirrored) so that all are left alligned */}
|
||||
{repo.status}
|
||||
</span>
|
||||
<Button variant="ghost" size="icon">
|
||||
<GitFork className="h-4 w-4" />
|
||||
</Button>
|
||||
{(() => {
|
||||
const giteaUrl = getGiteaRepoUrl(repo);
|
||||
|
||||
// Determine tooltip based on status and configuration
|
||||
let tooltip: string;
|
||||
if (!giteaConfig?.url) {
|
||||
tooltip = "Gitea not configured";
|
||||
} else if (repo.status === 'imported') {
|
||||
tooltip = "Repository not yet mirrored to Gitea";
|
||||
} else if (repo.status === 'failed') {
|
||||
tooltip = "Repository mirroring failed";
|
||||
} else if (repo.status === 'mirroring') {
|
||||
tooltip = "Repository is being mirrored to Gitea";
|
||||
} else if (giteaUrl) {
|
||||
tooltip = "View on Gitea";
|
||||
} else {
|
||||
tooltip = "Gitea repository not available";
|
||||
}
|
||||
|
||||
return giteaUrl ? (
|
||||
<Button variant="ghost" size="icon" asChild>
|
||||
<a
|
||||
href={giteaUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title={tooltip}
|
||||
>
|
||||
<SiGitea className="h-4 w-4" />
|
||||
</a>
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="ghost" size="icon" disabled title={tooltip}>
|
||||
<SiGitea className="h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
})()}
|
||||
<Button variant="ghost" size="icon" asChild>
|
||||
<a
|
||||
href={repo.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title="View on GitHub"
|
||||
>
|
||||
<SiGithub className="h-4 w-4" />
|
||||
</a>
|
||||
|
||||
@@ -1,13 +1,39 @@
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SiGitea } from "react-icons/si";
|
||||
|
||||
import { ModeToggle } from "@/components/theme/ModeToggle";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar";
|
||||
import { toast } from "sonner";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useLiveRefresh } from "@/hooks/useLiveRefresh";
|
||||
import { useConfigStatus } from "@/hooks/useConfigStatus";
|
||||
|
||||
export function Header() {
|
||||
interface HeaderProps {
|
||||
currentPage?: "dashboard" | "repositories" | "organizations" | "configuration" | "activity-log";
|
||||
onNavigate?: (page: string) => void;
|
||||
}
|
||||
|
||||
export function Header({ currentPage, onNavigate }: HeaderProps) {
|
||||
const { user, logout, isLoading } = useAuth();
|
||||
const { isLiveEnabled, toggleLive } = useLiveRefresh();
|
||||
const { isFullyConfigured, isLoading: configLoading } = useConfigStatus();
|
||||
|
||||
// Show Live button on all pages except configuration
|
||||
const showLiveButton = currentPage && currentPage !== "configuration";
|
||||
|
||||
// Determine button state and tooltip
|
||||
const isLiveActive = isLiveEnabled && isFullyConfigured;
|
||||
const getTooltip = () => {
|
||||
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';
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
toast.success("Logged out successfully");
|
||||
@@ -29,12 +55,50 @@ export function Header() {
|
||||
return (
|
||||
<header className="border-b bg-background">
|
||||
<div className="flex h-[4.5rem] items-center justify-between px-6">
|
||||
<a href="/" className="flex items-center gap-2 py-1">
|
||||
<SiGitea className="h-6 w-6" />
|
||||
<button
|
||||
onClick={() => {
|
||||
if (currentPage !== 'dashboard') {
|
||||
window.history.pushState({}, '', '/');
|
||||
onNavigate?.('dashboard');
|
||||
}
|
||||
}}
|
||||
className="flex items-center gap-2 py-1 hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<img
|
||||
src="/logo-light.svg"
|
||||
alt="Gitea Mirror Logo"
|
||||
className="h-6 w-6 dark:hidden"
|
||||
/>
|
||||
<img
|
||||
src="/logo-dark.svg"
|
||||
alt="Gitea Mirror Logo"
|
||||
className="h-6 w-6 hidden dark:block"
|
||||
/>
|
||||
<span className="text-xl font-bold">Gitea Mirror</span>
|
||||
</a>
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
{showLiveButton && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="flex items-center gap-2"
|
||||
onClick={toggleLive}
|
||||
title={getTooltip()}
|
||||
>
|
||||
<div className={`w-3 h-3 rounded-full ${
|
||||
configLoading
|
||||
? 'bg-yellow-400 animate-pulse'
|
||||
: isLiveActive
|
||||
? 'bg-emerald-400 animate-pulse'
|
||||
: isLiveEnabled
|
||||
? 'bg-orange-400'
|
||||
: 'bg-gray-500'
|
||||
}`} />
|
||||
<span>LIVE</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<ModeToggle />
|
||||
|
||||
{isLoading ? (
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useState, useEffect, createContext, useContext } from "react";
|
||||
import { Header } from "./Header";
|
||||
import { Sidebar } from "./Sidebar";
|
||||
import { Dashboard } from "@/components/dashboard/Dashboard";
|
||||
@@ -9,6 +10,12 @@ import { Organization } from "../organizations/Organization";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRepoSync } from "@/hooks/useSyncRepo";
|
||||
import { useConfigStatus } from "@/hooks/useConfigStatus";
|
||||
|
||||
// Navigation context to signal when navigation happens
|
||||
const NavigationContext = createContext<{ navigationKey: number }>({ navigationKey: 0 });
|
||||
|
||||
export const useNavigation = () => useContext(NavigationContext);
|
||||
|
||||
interface AppProps {
|
||||
page:
|
||||
@@ -32,8 +39,12 @@ export default function App({ page }: AppProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function AppWithProviders({ page }: AppProps) {
|
||||
const { user } = useAuth();
|
||||
function AppWithProviders({ page: initialPage }: AppProps) {
|
||||
const { user, isLoading: authLoading } = useAuth();
|
||||
const { isLoading: configLoading } = useConfigStatus();
|
||||
const [currentPage, setCurrentPage] = useState<AppProps['page']>(initialPage);
|
||||
const [navigationKey, setNavigationKey] = useState(0);
|
||||
|
||||
useRepoSync({
|
||||
userId: user?.id,
|
||||
enabled: user?.syncEnabled,
|
||||
@@ -42,20 +53,65 @@ function AppWithProviders({ page }: AppProps) {
|
||||
nextSync: user?.nextSync,
|
||||
});
|
||||
|
||||
// Handle navigation from sidebar
|
||||
const handleNavigation = (pageName: string) => {
|
||||
setCurrentPage(pageName as AppProps['page']);
|
||||
// Increment navigation key to force components to refresh their loading state
|
||||
setNavigationKey(prev => prev + 1);
|
||||
};
|
||||
|
||||
// Handle browser back/forward navigation
|
||||
useEffect(() => {
|
||||
const handlePopState = () => {
|
||||
const path = window.location.pathname;
|
||||
const pageMap: Record<string, AppProps['page']> = {
|
||||
'/': 'dashboard',
|
||||
'/repositories': 'repositories',
|
||||
'/organizations': 'organizations',
|
||||
'/config': 'configuration',
|
||||
'/activity': 'activity-log'
|
||||
};
|
||||
|
||||
const pageName = pageMap[path] || 'dashboard';
|
||||
setCurrentPage(pageName);
|
||||
// Also increment navigation key for browser navigation to trigger loading states
|
||||
setNavigationKey(prev => prev + 1);
|
||||
};
|
||||
|
||||
window.addEventListener('popstate', handlePopState);
|
||||
return () => window.removeEventListener('popstate', handlePopState);
|
||||
}, []);
|
||||
|
||||
// Show loading state only during initial auth/config loading
|
||||
const isInitialLoading = authLoading || (configLoading && !user);
|
||||
|
||||
if (isInitialLoading) {
|
||||
return (
|
||||
<main className="flex min-h-screen flex-col items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
<p className="text-sm text-muted-foreground">Loading...</p>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen flex-col">
|
||||
<Header />
|
||||
<div className="flex flex-1">
|
||||
<Sidebar />
|
||||
<section className="flex-1 p-6 overflow-y-auto h-[calc(100dvh-4.55rem)]">
|
||||
{page === "dashboard" && <Dashboard />}
|
||||
{page === "repositories" && <Repository />}
|
||||
{page === "organizations" && <Organization />}
|
||||
{page === "configuration" && <ConfigTabs />}
|
||||
{page === "activity-log" && <ActivityLog />}
|
||||
</section>
|
||||
</div>
|
||||
<Toaster />
|
||||
</main>
|
||||
<NavigationContext.Provider value={{ navigationKey }}>
|
||||
<main className="flex min-h-screen flex-col">
|
||||
<Header currentPage={currentPage} onNavigate={handleNavigation} />
|
||||
<div className="flex flex-1">
|
||||
<Sidebar onNavigate={handleNavigation} />
|
||||
<section className="flex-1 p-6 overflow-y-auto h-[calc(100dvh-4.55rem)]">
|
||||
{currentPage === "dashboard" && <Dashboard />}
|
||||
{currentPage === "repositories" && <Repository />}
|
||||
{currentPage === "organizations" && <Organization />}
|
||||
{currentPage === "configuration" && <ConfigTabs />}
|
||||
{currentPage === "activity-log" && <ActivityLog />}
|
||||
</section>
|
||||
</div>
|
||||
<Toaster />
|
||||
</main>
|
||||
</NavigationContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import * as React from "react";
|
||||
import { AuthProvider } from "@/hooks/useAuth";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { LiveRefreshProvider } from "@/hooks/useLiveRefresh";
|
||||
|
||||
export default function Providers({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<TooltipProvider>
|
||||
{children}
|
||||
</TooltipProvider>
|
||||
<LiveRefreshProvider>
|
||||
<TooltipProvider>
|
||||
{children}
|
||||
</TooltipProvider>
|
||||
</LiveRefreshProvider>
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,9 +6,10 @@ import { VersionInfo } from "./VersionInfo";
|
||||
|
||||
interface SidebarProps {
|
||||
className?: string;
|
||||
onNavigate?: (page: string) => void;
|
||||
}
|
||||
|
||||
export function Sidebar({ className }: SidebarProps) {
|
||||
export function Sidebar({ className, onNavigate }: SidebarProps) {
|
||||
const [currentPath, setCurrentPath] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
@@ -18,6 +19,39 @@ export function Sidebar({ className }: SidebarProps) {
|
||||
console.log("Hydrated path:", path); // Should log now
|
||||
}, []);
|
||||
|
||||
// Listen for URL changes (browser back/forward)
|
||||
useEffect(() => {
|
||||
const handlePopState = () => {
|
||||
setCurrentPath(window.location.pathname);
|
||||
};
|
||||
|
||||
window.addEventListener('popstate', handlePopState);
|
||||
return () => window.removeEventListener('popstate', handlePopState);
|
||||
}, []);
|
||||
|
||||
const handleNavigation = (href: string, event: React.MouseEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
// Don't navigate if already on the same page
|
||||
if (currentPath === href) return;
|
||||
|
||||
// Update URL without page reload
|
||||
window.history.pushState({}, '', href);
|
||||
setCurrentPath(href);
|
||||
|
||||
// Map href to page name for the parent component
|
||||
const pageMap: Record<string, string> = {
|
||||
'/': 'dashboard',
|
||||
'/repositories': 'repositories',
|
||||
'/organizations': 'organizations',
|
||||
'/config': 'configuration',
|
||||
'/activity': 'activity-log'
|
||||
};
|
||||
|
||||
const pageName = pageMap[href] || 'dashboard';
|
||||
onNavigate?.(pageName);
|
||||
};
|
||||
|
||||
return (
|
||||
<aside className={cn("w-64 border-r bg-background", className)}>
|
||||
<div className="flex flex-col h-full pt-4">
|
||||
@@ -27,11 +61,11 @@ export function Sidebar({ className }: SidebarProps) {
|
||||
const Icon = link.icon;
|
||||
|
||||
return (
|
||||
<a
|
||||
<button
|
||||
key={index}
|
||||
href={link.href}
|
||||
onClick={(e) => handleNavigation(link.href, e)}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors",
|
||||
"flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors w-full text-left",
|
||||
isActive
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
@@ -39,7 +73,7 @@ export function Sidebar({ className }: SidebarProps) {
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
{link.label}
|
||||
</a>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
@@ -37,7 +37,7 @@ export function VersionInfo() {
|
||||
return (
|
||||
<div className="text-xs text-muted-foreground text-center pt-2 pb-3 border-t border-border mt-2">
|
||||
{versionInfo.updateAvailable ? (
|
||||
<div className="flex flex-col">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span>v{versionInfo.current}</span>
|
||||
<span className="text-primary">v{versionInfo.latest} available</span>
|
||||
</div>
|
||||
|
||||
193
src/components/organizations/MirrorDestinationEditor.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
import { useState } from "react";
|
||||
import { ArrowRight, Edit3, RotateCcw, CheckCircle2, Building2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface MirrorDestinationEditorProps {
|
||||
organizationId: string;
|
||||
organizationName: string;
|
||||
currentDestination?: string;
|
||||
onUpdate: (newDestination: string | null) => Promise<void>;
|
||||
isUpdating?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function MirrorDestinationEditor({
|
||||
organizationId,
|
||||
organizationName,
|
||||
currentDestination,
|
||||
onUpdate,
|
||||
isUpdating = false,
|
||||
className,
|
||||
}: MirrorDestinationEditorProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [editValue, setEditValue] = useState(currentDestination || "");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const hasOverride = currentDestination && currentDestination !== organizationName;
|
||||
const effectiveDestination = currentDestination || organizationName;
|
||||
|
||||
const handleSave = async () => {
|
||||
const trimmedValue = editValue.trim();
|
||||
const newDestination = trimmedValue === "" || trimmedValue === organizationName
|
||||
? null
|
||||
: trimmedValue;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await onUpdate(newDestination);
|
||||
setIsOpen(false);
|
||||
toast.success(
|
||||
newDestination
|
||||
? `Destination updated to: ${newDestination}`
|
||||
: "Destination reset to default"
|
||||
);
|
||||
} catch (error) {
|
||||
toast.error("Failed to update destination");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = async () => {
|
||||
setEditValue("");
|
||||
await handleSave();
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setEditValue(currentDestination || "");
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-center gap-2", className)}>
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Building2 className="h-3 w-3" />
|
||||
<span className="font-medium">{organizationName}</span>
|
||||
<ArrowRight className="h-3 w-3" />
|
||||
<span className={cn(
|
||||
"font-medium",
|
||||
hasOverride && "text-orange-600 dark:text-orange-400"
|
||||
)}>
|
||||
{effectiveDestination}
|
||||
</span>
|
||||
{hasOverride && (
|
||||
<Badge variant="outline" className="h-4 px-1 text-[10px] border-orange-600 text-orange-600 dark:border-orange-400 dark:text-orange-400">
|
||||
custom
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 w-6 p-0 opacity-60 hover:opacity-100"
|
||||
title="Edit mirror destination"
|
||||
disabled={isUpdating || isLoading}
|
||||
>
|
||||
<Edit3 className="h-3 w-3" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80" align="end">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="font-medium text-sm mb-1">Mirror Destination</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Customize where this organization's repositories are mirrored to in Gitea.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{/* Visual Preview */}
|
||||
<div className="rounded-md bg-muted/50 p-3 space-y-2">
|
||||
<div className="text-xs font-medium text-muted-foreground">Preview</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Building2 className="h-4 w-4 text-muted-foreground" />
|
||||
<span>{organizationName}</span>
|
||||
</div>
|
||||
<ArrowRight className="h-4 w-4 text-muted-foreground" />
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Building2 className="h-4 w-4 text-primary" />
|
||||
<span className="font-medium text-primary">
|
||||
{editValue.trim() || organizationName}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Input Field */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="destination" className="text-xs">
|
||||
Destination Organization
|
||||
</Label>
|
||||
<Input
|
||||
id="destination"
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
placeholder={organizationName}
|
||||
className="h-8"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Leave empty to use the default GitHub organization name
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
{hasOverride && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleReset}
|
||||
disabled={isLoading}
|
||||
className="w-full h-8 text-xs"
|
||||
>
|
||||
<RotateCcw className="h-3 w-3 mr-2" />
|
||||
Reset to Default ({organizationName})
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleCancel}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={isLoading || (editValue.trim() === (currentDestination || ""))}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<CheckCircle2 className="h-3 w-3 mr-2 animate-spin" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
"Save"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Search, RefreshCw, FlipHorizontal, Plus } from "lucide-react";
|
||||
import { Search, RefreshCw, FlipHorizontal } from "lucide-react";
|
||||
import type { MirrorJob, Organization } from "@/lib/db/schema";
|
||||
import { OrganizationList } from "./OrganizationsList";
|
||||
import AddOrganizationDialog from "./AddOrganizationDialog";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiRequest } from "@/lib/utils";
|
||||
import { apiRequest, showErrorToast } from "@/lib/utils";
|
||||
import {
|
||||
membershipRoleEnum,
|
||||
type AddOrganizationApiRequest,
|
||||
@@ -24,12 +24,18 @@ import type { MirrorOrgRequest, MirrorOrgResponse } from "@/types/mirror";
|
||||
import { useSSE } from "@/hooks/useSEE";
|
||||
import { useFilterParams } from "@/hooks/useFilterParams";
|
||||
import { toast } from "sonner";
|
||||
import { useConfigStatus } from "@/hooks/useConfigStatus";
|
||||
import { useNavigation } from "@/components/layout/MainLayout";
|
||||
import { useLiveRefresh } from "@/hooks/useLiveRefresh";
|
||||
|
||||
export function Organization() {
|
||||
const [organizations, setOrganizations] = useState<Organization[]>([]);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [isDialogOpen, setIsDialogOpen] = useState<boolean>(false);
|
||||
const { user } = useAuth();
|
||||
const { isGitHubConfigured } = useConfigStatus();
|
||||
const { navigationKey } = useNavigation();
|
||||
const { registerRefreshCallback } = useLiveRefresh();
|
||||
const { filter, setFilter } = useFilterParams({
|
||||
searchTerm: "",
|
||||
membershipRole: "",
|
||||
@@ -58,13 +64,23 @@ export function Organization() {
|
||||
onMessage: handleNewMessage,
|
||||
});
|
||||
|
||||
const fetchOrganizations = useCallback(async () => {
|
||||
if (!user || !user.id) {
|
||||
const fetchOrganizations = useCallback(async (isLiveRefresh = false) => {
|
||||
if (!user?.id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Don't fetch organizations if GitHub is not configured
|
||||
if (!isGitHubConfigured) {
|
||||
if (!isLiveRefresh) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
if (!isLiveRefresh) {
|
||||
setIsLoading(true);
|
||||
}
|
||||
|
||||
const response = await apiRequest<OrganizationsApiResponse>(
|
||||
`/github/organizations?userId=${user.id}`,
|
||||
@@ -77,25 +93,47 @@ export function Organization() {
|
||||
setOrganizations(response.organizations);
|
||||
return true;
|
||||
} else {
|
||||
toast.error(response.error || "Error fetching organizations");
|
||||
if (!isLiveRefresh) {
|
||||
toast.error(response.error || "Error fetching organizations");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Error fetching organizations"
|
||||
);
|
||||
if (!isLiveRefresh) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Error fetching organizations"
|
||||
);
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
if (!isLiveRefresh) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
}, [user]);
|
||||
}, [user?.id, isGitHubConfigured]); // Only depend on user.id, not entire user object
|
||||
|
||||
useEffect(() => {
|
||||
fetchOrganizations();
|
||||
}, [fetchOrganizations]);
|
||||
// Reset loading state when component becomes active
|
||||
setIsLoading(true);
|
||||
fetchOrganizations(false); // Manual refresh, not live
|
||||
}, [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(true); // Live refresh
|
||||
});
|
||||
|
||||
return unregister;
|
||||
}, [registerRefreshCallback, fetchOrganizations, isGitHubConfigured]);
|
||||
|
||||
const handleRefresh = async () => {
|
||||
const success = await fetchOrganizations();
|
||||
const success = await fetchOrganizations(false);
|
||||
if (success) {
|
||||
toast.success("Organizations refreshed successfully.");
|
||||
}
|
||||
@@ -128,6 +166,12 @@ export function Organization() {
|
||||
return updated ? updated : org;
|
||||
})
|
||||
);
|
||||
|
||||
// Refresh organization data to get updated repository breakdown
|
||||
// Use a small delay to allow the backend to process the mirroring request
|
||||
setTimeout(() => {
|
||||
fetchOrganizations(true);
|
||||
}, 1000);
|
||||
} else {
|
||||
toast.error(response.error || "Error starting mirror job");
|
||||
}
|
||||
@@ -181,12 +225,10 @@ export function Organization() {
|
||||
searchTerm: org,
|
||||
}));
|
||||
} else {
|
||||
toast.error(response.error || "Error adding organization");
|
||||
showErrorToast(response.error || "Error adding organization", toast);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Error adding organization"
|
||||
);
|
||||
showErrorToast(error, toast);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -238,24 +280,17 @@ export function Organization() {
|
||||
})
|
||||
);
|
||||
} else {
|
||||
toast.error(response.error || "Error starting mirror jobs");
|
||||
showErrorToast(response.error || "Error starting mirror jobs", toast);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Error starting mirror jobs"
|
||||
);
|
||||
showErrorToast(error, toast);
|
||||
} finally {
|
||||
// Reset loading states - we'll let the SSE updates handle status changes
|
||||
setLoadingOrgIds(new Set());
|
||||
}
|
||||
};
|
||||
|
||||
// Get unique organization names for combobox (since Organization has no owner field)
|
||||
const ownerOptions = Array.from(
|
||||
new Set(
|
||||
organizations.map((org) => org.name).filter((v): v is string => !!v)
|
||||
)
|
||||
).sort();
|
||||
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-y-8">
|
||||
@@ -342,9 +377,13 @@ export function Organization() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button variant="default" onClick={handleRefresh}>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Refresh
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleRefresh}
|
||||
title="Refresh organizations"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
@@ -365,6 +404,7 @@ export function Organization() {
|
||||
loadingOrgIds={loadingOrgIds}
|
||||
onMirror={handleMirrorOrg}
|
||||
onAddOrganization={() => setIsDialogOpen(true)}
|
||||
onRefresh={() => fetchOrganizations(false)}
|
||||
/>
|
||||
|
||||
<AddOrganizationDialog
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { useMemo } from "react";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Plus, RefreshCw, Building2 } from "lucide-react";
|
||||
import { SiGithub } from "react-icons/si";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Plus, RefreshCw, Building2, Check, AlertCircle, Clock } from "lucide-react";
|
||||
import { SiGithub, SiGitea } from "react-icons/si";
|
||||
import type { Organization } from "@/lib/db/schema";
|
||||
import type { FilterParams } from "@/types/filter";
|
||||
import Fuse from "fuse.js";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { getStatusColor } from "@/lib/utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MirrorDestinationEditor } from "./MirrorDestinationEditor";
|
||||
import { useGiteaConfig } from "@/hooks/useGiteaConfig";
|
||||
|
||||
interface OrganizationListProps {
|
||||
organizations: Organization[];
|
||||
@@ -18,8 +20,25 @@ interface OrganizationListProps {
|
||||
onMirror: ({ orgId }: { orgId: string }) => Promise<void>;
|
||||
loadingOrgIds: Set<string>;
|
||||
onAddOrganization?: () => void;
|
||||
onRefresh?: () => Promise<void>;
|
||||
}
|
||||
|
||||
// Helper function to get status badge variant and icon
|
||||
const getStatusBadge = (status: string | null) => {
|
||||
switch (status) {
|
||||
case "imported":
|
||||
return { variant: "secondary" as const, label: "Not Mirrored", icon: null };
|
||||
case "mirroring":
|
||||
return { variant: "outline" as const, label: "Mirroring", icon: Clock };
|
||||
case "mirrored":
|
||||
return { variant: "default" as const, label: "Mirrored", icon: Check };
|
||||
case "failed":
|
||||
return { variant: "destructive" as const, label: "Failed", icon: AlertCircle };
|
||||
default:
|
||||
return { variant: "secondary" as const, label: "Unknown", icon: null };
|
||||
}
|
||||
};
|
||||
|
||||
export function OrganizationList({
|
||||
organizations,
|
||||
isLoading,
|
||||
@@ -28,7 +47,59 @@ export function OrganizationList({
|
||||
onMirror,
|
||||
loadingOrgIds,
|
||||
onAddOrganization,
|
||||
onRefresh,
|
||||
}: OrganizationListProps) {
|
||||
const { giteaConfig } = useGiteaConfig();
|
||||
|
||||
// Helper function to construct Gitea organization URL
|
||||
const getGiteaOrgUrl = (organization: Organization): string | null => {
|
||||
if (!giteaConfig?.url) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Only provide Gitea links for organizations that have been mirrored
|
||||
const validStatuses = ['mirroring', 'mirrored'];
|
||||
if (!validStatuses.includes(organization.status || '')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Use destinationOrg if available, otherwise use the organization name
|
||||
const orgName = organization.destinationOrg || organization.name;
|
||||
if (!orgName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Ensure the base URL doesn't have a trailing slash
|
||||
const baseUrl = giteaConfig.url.endsWith('/')
|
||||
? giteaConfig.url.slice(0, -1)
|
||||
: giteaConfig.url;
|
||||
|
||||
return `${baseUrl}/${orgName}`;
|
||||
};
|
||||
|
||||
const handleUpdateDestination = async (orgId: string, newDestination: string | null) => {
|
||||
// Call API to update organization destination
|
||||
const response = await fetch(`/api/organizations/${orgId}`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
destinationOrg: newDestination,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || "Failed to update organization");
|
||||
}
|
||||
|
||||
// Refresh organizations data
|
||||
if (onRefresh) {
|
||||
await onRefresh();
|
||||
}
|
||||
};
|
||||
|
||||
const hasAnyFilter = Object.values(filter).some(
|
||||
(val) => val?.toString().trim() !== ""
|
||||
);
|
||||
@@ -93,82 +164,206 @@ export function OrganizationList({
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{filteredOrganizations.map((org, index) => {
|
||||
const isLoading = loadingOrgIds.has(org.id ?? "");
|
||||
const statusBadge = getStatusBadge(org.status);
|
||||
const StatusIcon = statusBadge.icon;
|
||||
|
||||
return (
|
||||
<Card key={index} className="overflow-hidden p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Building2 className="h-5 w-5 text-muted-foreground" />
|
||||
<a
|
||||
href={`/repositories?organization=${encodeURIComponent(org.name || '')}`}
|
||||
className="font-medium hover:underline cursor-pointer"
|
||||
>
|
||||
{org.name}
|
||||
</a>
|
||||
<Card
|
||||
key={index}
|
||||
className={cn(
|
||||
"overflow-hidden p-4 transition-all hover:shadow-md min-h-[160px]",
|
||||
isLoading && "opacity-75"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Building2 className="h-5 w-5 text-muted-foreground" />
|
||||
<a
|
||||
href={`/repositories?organization=${encodeURIComponent(org.name || '')}`}
|
||||
className="font-medium hover:underline cursor-pointer"
|
||||
>
|
||||
{org.name}
|
||||
</a>
|
||||
<span
|
||||
className={`text-xs px-2 py-0.5 rounded-full capitalize ${
|
||||
org.membershipRole === "member"
|
||||
? "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200"
|
||||
: "bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200"
|
||||
}`}
|
||||
>
|
||||
{org.membershipRole}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Destination override section */}
|
||||
<div className="mt-2">
|
||||
<MirrorDestinationEditor
|
||||
organizationId={org.id!}
|
||||
organizationName={org.name!}
|
||||
currentDestination={org.destinationOrg}
|
||||
onUpdate={(newDestination) => handleUpdateDestination(org.id!, newDestination)}
|
||||
isUpdating={isLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={`text-xs px-2 py-1 rounded-full capitalize ${
|
||||
org.membershipRole === "member"
|
||||
? "bg-blue-100 text-blue-800"
|
||||
: "bg-purple-100 text-purple-800"
|
||||
}`}
|
||||
>
|
||||
{org.membershipRole}
|
||||
{/* needs to be updated */}
|
||||
</span>
|
||||
<Badge variant={statusBadge.variant} className="ml-2">
|
||||
{StatusIcon && <StatusIcon className={cn(
|
||||
"h-3 w-3",
|
||||
org.status === "mirroring" && "animate-pulse"
|
||||
)} />}
|
||||
{statusBadge.label}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
{org.repositoryCount}{" "}
|
||||
{org.repositoryCount === 1 ? "repository" : "repositories"}
|
||||
</p>
|
||||
<div className="text-sm text-muted-foreground mb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">
|
||||
{org.repositoryCount}{" "}
|
||||
{org.repositoryCount === 1 ? "repository" : "repositories"}
|
||||
</span>
|
||||
</div>
|
||||
{/* Always render this section to prevent layout shift */}
|
||||
<div className="flex gap-4 mt-2 text-xs min-h-[20px]">
|
||||
{isLoading || (org.status === "mirroring" && org.publicRepositoryCount === undefined) ? (
|
||||
<>
|
||||
<Skeleton className="h-3 w-16" />
|
||||
<Skeleton className="h-3 w-16" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{org.publicRepositoryCount !== undefined ? (
|
||||
<span className="flex items-center gap-1">
|
||||
<div className="h-2 w-2 rounded-full bg-green-500" />
|
||||
{org.publicRepositoryCount} public
|
||||
</span>
|
||||
) : null}
|
||||
{org.privateRepositoryCount !== undefined && org.privateRepositoryCount > 0 ? (
|
||||
<span className="flex items-center gap-1">
|
||||
<div className="h-2 w-2 rounded-full bg-orange-500" />
|
||||
{org.privateRepositoryCount} private
|
||||
</span>
|
||||
) : null}
|
||||
{org.forkRepositoryCount !== undefined && org.forkRepositoryCount > 0 ? (
|
||||
<span className="flex items-center gap-1">
|
||||
<div className="h-2 w-2 rounded-full bg-blue-500" />
|
||||
{org.forkRepositoryCount} fork{org.forkRepositoryCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
) : null}
|
||||
{/* Show a placeholder if no counts are available to maintain height */}
|
||||
{org.publicRepositoryCount === undefined &&
|
||||
org.privateRepositoryCount === undefined &&
|
||||
org.forkRepositoryCount === undefined && (
|
||||
<span className="invisible">Loading counts...</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<Checkbox
|
||||
id={`include-${org.id}`}
|
||||
name={`include-${org.id}`}
|
||||
checked={org.status === "mirrored"}
|
||||
disabled={
|
||||
loadingOrgIds.has(org.id ?? "") ||
|
||||
org.status === "mirrored" ||
|
||||
org.status === "mirroring"
|
||||
}
|
||||
onCheckedChange={async (checked) => {
|
||||
if (checked && !org.isIncluded && org.id) {
|
||||
onMirror({ orgId: org.id });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<label
|
||||
htmlFor={`include-${org.id}`}
|
||||
className="ml-2 text-sm select-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
Include in mirroring
|
||||
</label>
|
||||
|
||||
{isLoading && (
|
||||
<RefreshCw className="opacity-50 h-4 w-4 animate-spin ml-4" />
|
||||
<div className="flex items-center gap-2">
|
||||
{org.status === "imported" && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => org.id && onMirror({ orgId: org.id })}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<RefreshCw className="h-3 w-3 animate-spin mr-2" />
|
||||
Starting...
|
||||
</>
|
||||
) : (
|
||||
"Mirror"
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{org.status === "mirroring" && (
|
||||
<Button size="sm" disabled variant="outline">
|
||||
<RefreshCw className="h-3 w-3 animate-spin mr-2" />
|
||||
Mirroring...
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{org.status === "mirrored" && (
|
||||
<Button size="sm" disabled variant="secondary">
|
||||
<Check className="h-3 w-3 mr-2" />
|
||||
Mirrored
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{org.status === "failed" && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => org.id && onMirror({ orgId: org.id })}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<RefreshCw className="h-3 w-3 animate-spin mr-2" />
|
||||
Retrying...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<AlertCircle className="h-3 w-3 mr-2" />
|
||||
Retry
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button variant="ghost" size="icon" asChild>
|
||||
<a
|
||||
href={`https://github.com/${org.name}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<SiGithub className="h-4 w-4" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{(() => {
|
||||
const giteaUrl = getGiteaOrgUrl(org);
|
||||
|
||||
{/* dont know if this looks good. maybe revised */}
|
||||
<div className="flex items-center gap-2 justify-end mt-4">
|
||||
<div
|
||||
className={`h-2 w-2 rounded-full ${getStatusColor(org.status)}`}
|
||||
/>
|
||||
<span className="text-sm capitalize">{org.status}</span>
|
||||
// Determine tooltip based on status and configuration
|
||||
let tooltip: string;
|
||||
if (!giteaConfig?.url) {
|
||||
tooltip = "Gitea not configured";
|
||||
} else if (org.status === 'imported') {
|
||||
tooltip = "Organization not yet mirrored to Gitea";
|
||||
} else if (org.status === 'failed') {
|
||||
tooltip = "Organization mirroring failed";
|
||||
} else if (org.status === 'mirroring') {
|
||||
tooltip = "Organization is being mirrored to Gitea";
|
||||
} else if (giteaUrl) {
|
||||
tooltip = "View on Gitea";
|
||||
} else {
|
||||
tooltip = "Gitea organization not available";
|
||||
}
|
||||
|
||||
return giteaUrl ? (
|
||||
<Button variant="ghost" size="icon" asChild>
|
||||
<a
|
||||
href={giteaUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title={tooltip}
|
||||
>
|
||||
<SiGitea className="h-4 w-4" />
|
||||
</a>
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="ghost" size="icon" disabled title={tooltip}>
|
||||
<SiGitea className="h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
})()}
|
||||
<Button variant="ghost" size="icon" asChild>
|
||||
<a
|
||||
href={`https://github.com/${org.name}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title="View on GitHub"
|
||||
>
|
||||
<SiGithub className="h-4 w-4" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
|
||||
187
src/components/repositories/InlineDestinationEditor.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { Edit3, Check, X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Repository } from "@/lib/db/schema";
|
||||
|
||||
interface InlineDestinationEditorProps {
|
||||
repository: Repository;
|
||||
giteaConfig: any;
|
||||
onUpdate: (repoId: string, newDestination: string | null) => Promise<void>;
|
||||
isUpdating?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function InlineDestinationEditor({
|
||||
repository,
|
||||
giteaConfig,
|
||||
onUpdate,
|
||||
isUpdating = false,
|
||||
className,
|
||||
}: InlineDestinationEditorProps) {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editValue, setEditValue] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Determine the default destination based on repository properties and config
|
||||
const getDefaultDestination = () => {
|
||||
// Starred repos always go to the configured starredReposOrg
|
||||
if (repository.isStarred && giteaConfig?.starredReposOrg) {
|
||||
return giteaConfig.starredReposOrg;
|
||||
}
|
||||
|
||||
// Check mirror strategy
|
||||
const strategy = giteaConfig?.mirrorStrategy || 'preserve';
|
||||
|
||||
if (strategy === 'single-org' && giteaConfig?.organization) {
|
||||
// All repos go to a single organization
|
||||
return giteaConfig.organization;
|
||||
} else if (strategy === 'flat-user') {
|
||||
// All repos go under the user account
|
||||
return giteaConfig?.username || repository.owner;
|
||||
} else {
|
||||
// 'preserve' strategy or default
|
||||
// For organization repos, use the organization name
|
||||
if (repository.organization) {
|
||||
return repository.organization;
|
||||
}
|
||||
// For personal repos, check if personalReposOrg is configured
|
||||
if (!repository.organization && giteaConfig?.personalReposOrg) {
|
||||
return giteaConfig.personalReposOrg;
|
||||
}
|
||||
// Default to the gitea username or owner
|
||||
return giteaConfig?.username || repository.owner;
|
||||
}
|
||||
};
|
||||
|
||||
const defaultDestination = getDefaultDestination();
|
||||
const currentDestination = repository.destinationOrg || defaultDestination;
|
||||
const hasOverride = repository.destinationOrg && repository.destinationOrg !== defaultDestination;
|
||||
const isStarredRepo = repository.isStarred && giteaConfig?.starredReposOrg;
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditing && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
inputRef.current.select();
|
||||
}
|
||||
}, [isEditing]);
|
||||
|
||||
const handleStartEdit = () => {
|
||||
if (isStarredRepo) return; // Don't allow editing starred repos
|
||||
setEditValue(currentDestination);
|
||||
setIsEditing(true);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
const trimmedValue = editValue.trim();
|
||||
const newDestination = trimmedValue === defaultDestination ? null : trimmedValue;
|
||||
|
||||
if (trimmedValue === currentDestination) {
|
||||
setIsEditing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await onUpdate(repository.id!, newDestination);
|
||||
setIsEditing(false);
|
||||
} catch (error) {
|
||||
// Revert on error
|
||||
setEditValue(currentDestination);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setEditValue(currentDestination);
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleSave();
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
handleCancel();
|
||||
}
|
||||
};
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<div className={cn("flex items-center gap-1", className)}>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={handleCancel}
|
||||
className="h-6 text-sm px-2 py-0 w-24"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-5 w-5 p-0"
|
||||
onClick={handleSave}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Check className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-5 w-5 p-0"
|
||||
onClick={handleCancel}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-0.5", className)}>
|
||||
{/* Show GitHub org if exists */}
|
||||
{repository.organization && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{repository.organization}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Show Gitea destination */}
|
||||
<div className="flex items-center gap-1 group">
|
||||
<span className="text-sm">
|
||||
{currentDestination || "-"}
|
||||
</span>
|
||||
{hasOverride && (
|
||||
<Badge variant="outline" className="h-4 px-1 text-[10px] ml-1">
|
||||
custom
|
||||
</Badge>
|
||||
)}
|
||||
{isStarredRepo && (
|
||||
<Badge variant="secondary" className="h-4 px-1 text-[10px] ml-1">
|
||||
starred
|
||||
</Badge>
|
||||
)}
|
||||
{!isStarredRepo && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-4 w-4 p-0 opacity-0 group-hover:opacity-60 hover:opacity-100 ml-1"
|
||||
onClick={handleStartEdit}
|
||||
disabled={isUpdating || isLoading}
|
||||
title="Edit destination"
|
||||
>
|
||||
<Edit3 className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
type RepositoryApiResponse,
|
||||
type RepoStatus,
|
||||
} from "@/types/Repository";
|
||||
import { apiRequest } from "@/lib/utils";
|
||||
import { apiRequest, showErrorToast } from "@/lib/utils";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
SelectValue,
|
||||
} from "../ui/select";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Search, RefreshCw, FlipHorizontal } from "lucide-react";
|
||||
import { Search, RefreshCw, FlipHorizontal, RotateCcw, X } from "lucide-react";
|
||||
import type { MirrorRepoRequest, MirrorRepoResponse } from "@/types/mirror";
|
||||
import { useSSE } from "@/hooks/useSEE";
|
||||
import { useFilterParams } from "@/hooks/useFilterParams";
|
||||
@@ -27,13 +27,18 @@ import type { SyncRepoRequest, SyncRepoResponse } from "@/types/sync";
|
||||
import { OwnerCombobox, OrganizationCombobox } from "./RepositoryComboboxes";
|
||||
import type { RetryRepoRequest, RetryRepoResponse } from "@/types/retry";
|
||||
import AddRepositoryDialog from "./AddRepositoryDialog";
|
||||
import type { ConfigApiResponse } from "@/types/config";
|
||||
|
||||
import { useLiveRefresh } from "@/hooks/useLiveRefresh";
|
||||
import { useConfigStatus } from "@/hooks/useConfigStatus";
|
||||
import { useNavigation } from "@/components/layout/MainLayout";
|
||||
|
||||
export default function Repository() {
|
||||
const [repositories, setRepositories] = useState<Repository[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isGitHubConfigured, setIsGitHubConfigured] = useState<boolean>(true);
|
||||
const [isInitialLoading, setIsInitialLoading] = useState(true);
|
||||
const { user } = useAuth();
|
||||
const { registerRefreshCallback, isLiveEnabled } = useLiveRefresh();
|
||||
const { isGitHubConfigured, isFullyConfigured } = useConfigStatus();
|
||||
const { navigationKey } = useNavigation();
|
||||
const { filter, setFilter } = useFilterParams({
|
||||
searchTerm: "",
|
||||
status: "",
|
||||
@@ -41,6 +46,7 @@ export default function Repository() {
|
||||
owner: "",
|
||||
});
|
||||
const [isDialogOpen, setIsDialogOpen] = useState<boolean>(false);
|
||||
const [selectedRepoIds, setSelectedRepoIds] = useState<Set<string>>(new Set());
|
||||
|
||||
// Read organization filter from URL when component mounts
|
||||
useEffect(() => {
|
||||
@@ -75,30 +81,21 @@ export default function Repository() {
|
||||
onMessage: handleNewMessage,
|
||||
});
|
||||
|
||||
const fetchRepositories = useCallback(async () => {
|
||||
if (!user) return;
|
||||
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) {
|
||||
setIsInitialLoading(false);
|
||||
return false;
|
||||
}
|
||||
|
||||
// First, check if GitHub is configured by fetching the user's config
|
||||
try {
|
||||
const configResponse = await apiRequest<ConfigApiResponse>(
|
||||
`/config?userId=${user.id}`,
|
||||
{
|
||||
method: "GET",
|
||||
}
|
||||
);
|
||||
|
||||
// Check if GitHub credentials are configured
|
||||
if (!configResponse?.githubConfig?.username || !configResponse?.githubConfig?.token) {
|
||||
setIsLoading(false);
|
||||
setIsGitHubConfigured(false);
|
||||
// Don't show error toast for unconfigured GitHub - just return silently
|
||||
return false;
|
||||
// Set appropriate loading state based on refresh type
|
||||
if (!isLiveRefresh) {
|
||||
setIsInitialLoading(true);
|
||||
}
|
||||
|
||||
// GitHub is configured
|
||||
setIsGitHubConfigured(true);
|
||||
setIsLoading(true);
|
||||
|
||||
const response = await apiRequest<RepositoryApiResponse>(
|
||||
`/github/repositories?userId=${user.id}`,
|
||||
{
|
||||
@@ -110,25 +107,47 @@ 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) {
|
||||
showErrorToast(response.error || "Error fetching repositories", toast);
|
||||
}
|
||||
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) {
|
||||
showErrorToast(error, toast);
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
if (!isLiveRefresh) {
|
||||
setIsInitialLoading(false);
|
||||
}
|
||||
}
|
||||
}, [user]);
|
||||
}, [user?.id, isGitHubConfigured]); // Only depend on user.id, not entire user object
|
||||
|
||||
useEffect(() => {
|
||||
fetchRepositories();
|
||||
}, [fetchRepositories]);
|
||||
// Reset loading state when component becomes active
|
||||
setIsInitialLoading(true);
|
||||
fetchRepositories(false); // Manual refresh, not live
|
||||
}, [fetchRepositories, 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(() => {
|
||||
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.");
|
||||
}
|
||||
@@ -164,12 +183,10 @@ export default function Repository() {
|
||||
})
|
||||
);
|
||||
} else {
|
||||
toast.error(response.error || "Error starting mirror job");
|
||||
showErrorToast(response.error || "Error starting mirror job", toast);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Error starting mirror job"
|
||||
);
|
||||
showErrorToast(error, toast);
|
||||
} finally {
|
||||
setLoadingRepoIds((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
@@ -228,18 +245,153 @@ export default function Repository() {
|
||||
})
|
||||
);
|
||||
} else {
|
||||
toast.error(response.error || "Error starting mirror jobs");
|
||||
showErrorToast(response.error || "Error starting mirror jobs", toast);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Error starting mirror jobs"
|
||||
);
|
||||
showErrorToast(error, toast);
|
||||
} finally {
|
||||
// Reset loading states - we'll let the SSE updates handle status changes
|
||||
setLoadingRepoIds(new Set());
|
||||
}
|
||||
};
|
||||
|
||||
// Bulk action handlers
|
||||
const handleBulkMirror = async () => {
|
||||
if (selectedRepoIds.size === 0) return;
|
||||
|
||||
const selectedRepos = repositories.filter(repo => repo.id && selectedRepoIds.has(repo.id));
|
||||
const eligibleRepos = selectedRepos.filter(
|
||||
repo => repo.status === "imported" || repo.status === "failed"
|
||||
);
|
||||
|
||||
if (eligibleRepos.length === 0) {
|
||||
toast.info("No eligible repositories to mirror in selection");
|
||||
return;
|
||||
}
|
||||
|
||||
const repoIds = eligibleRepos.map(repo => repo.id as string);
|
||||
|
||||
setLoadingRepoIds(prev => {
|
||||
const newSet = new Set(prev);
|
||||
repoIds.forEach(id => newSet.add(id));
|
||||
return newSet;
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await apiRequest<MirrorRepoResponse>("/job/mirror-repo", {
|
||||
method: "POST",
|
||||
data: { userId: user?.id, repositoryIds: repoIds }
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
toast.success(`Mirroring started for ${repoIds.length} repositories`);
|
||||
setRepositories(prevRepos =>
|
||||
prevRepos.map(repo => {
|
||||
const updated = response.repositories.find(r => r.id === repo.id);
|
||||
return updated ? updated : repo;
|
||||
})
|
||||
);
|
||||
setSelectedRepoIds(new Set());
|
||||
} else {
|
||||
showErrorToast(response.error || "Error starting mirror jobs", toast);
|
||||
}
|
||||
} catch (error) {
|
||||
showErrorToast(error, toast);
|
||||
} finally {
|
||||
setLoadingRepoIds(new Set());
|
||||
}
|
||||
};
|
||||
|
||||
const handleBulkSync = async () => {
|
||||
if (selectedRepoIds.size === 0) return;
|
||||
|
||||
const selectedRepos = repositories.filter(repo => repo.id && selectedRepoIds.has(repo.id));
|
||||
const eligibleRepos = selectedRepos.filter(
|
||||
repo => repo.status === "mirrored" || repo.status === "synced"
|
||||
);
|
||||
|
||||
if (eligibleRepos.length === 0) {
|
||||
toast.info("No eligible repositories to sync in selection");
|
||||
return;
|
||||
}
|
||||
|
||||
const repoIds = eligibleRepos.map(repo => repo.id as string);
|
||||
|
||||
setLoadingRepoIds(prev => {
|
||||
const newSet = new Set(prev);
|
||||
repoIds.forEach(id => newSet.add(id));
|
||||
return newSet;
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await apiRequest<SyncRepoResponse>("/job/sync-repo", {
|
||||
method: "POST",
|
||||
data: { userId: user?.id, repositoryIds: repoIds }
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
toast.success(`Syncing started for ${repoIds.length} repositories`);
|
||||
setRepositories(prevRepos =>
|
||||
prevRepos.map(repo => {
|
||||
const updated = response.repositories.find(r => r.id === repo.id);
|
||||
return updated ? updated : repo;
|
||||
})
|
||||
);
|
||||
setSelectedRepoIds(new Set());
|
||||
} else {
|
||||
showErrorToast(response.error || "Error starting sync jobs", toast);
|
||||
}
|
||||
} catch (error) {
|
||||
showErrorToast(error, toast);
|
||||
} finally {
|
||||
setLoadingRepoIds(new Set());
|
||||
}
|
||||
};
|
||||
|
||||
const handleBulkRetry = async () => {
|
||||
if (selectedRepoIds.size === 0) return;
|
||||
|
||||
const selectedRepos = repositories.filter(repo => repo.id && selectedRepoIds.has(repo.id));
|
||||
const eligibleRepos = selectedRepos.filter(repo => repo.status === "failed");
|
||||
|
||||
if (eligibleRepos.length === 0) {
|
||||
toast.info("No failed repositories in selection to retry");
|
||||
return;
|
||||
}
|
||||
|
||||
const repoIds = eligibleRepos.map(repo => repo.id as string);
|
||||
|
||||
setLoadingRepoIds(prev => {
|
||||
const newSet = new Set(prev);
|
||||
repoIds.forEach(id => newSet.add(id));
|
||||
return newSet;
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await apiRequest<RetryRepoResponse>("/job/retry-repo", {
|
||||
method: "POST",
|
||||
data: { userId: user?.id, repositoryIds: repoIds }
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
toast.success(`Retrying ${repoIds.length} repositories`);
|
||||
setRepositories(prevRepos =>
|
||||
prevRepos.map(repo => {
|
||||
const updated = response.repositories.find(r => r.id === repo.id);
|
||||
return updated ? updated : repo;
|
||||
})
|
||||
);
|
||||
setSelectedRepoIds(new Set());
|
||||
} else {
|
||||
showErrorToast(response.error || "Error retrying jobs", toast);
|
||||
}
|
||||
} catch (error) {
|
||||
showErrorToast(error, toast);
|
||||
} finally {
|
||||
setLoadingRepoIds(new Set());
|
||||
}
|
||||
};
|
||||
|
||||
const handleSyncRepo = async ({ repoId }: { repoId: string }) => {
|
||||
try {
|
||||
if (!user || !user.id) {
|
||||
@@ -267,12 +419,10 @@ export default function Repository() {
|
||||
})
|
||||
);
|
||||
} else {
|
||||
toast.error(response.error || "Error starting sync job");
|
||||
showErrorToast(response.error || "Error starting sync job", toast);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Error starting sync job"
|
||||
);
|
||||
showErrorToast(error, toast);
|
||||
} finally {
|
||||
setLoadingRepoIds((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
@@ -309,12 +459,10 @@ export default function Repository() {
|
||||
})
|
||||
);
|
||||
} else {
|
||||
toast.error(response.error || "Error retrying job");
|
||||
showErrorToast(response.error || "Error retrying job", toast);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Error retrying job"
|
||||
);
|
||||
showErrorToast(error, toast);
|
||||
} finally {
|
||||
setLoadingRepoIds((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
@@ -354,19 +502,17 @@ 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,
|
||||
searchTerm: repo,
|
||||
}));
|
||||
} else {
|
||||
toast.error(response.error || "Error adding repository");
|
||||
showErrorToast(response.error || "Error adding repository", toast);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Error adding repository"
|
||||
);
|
||||
showErrorToast(error, toast);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -384,6 +530,35 @@ export default function Repository() {
|
||||
)
|
||||
).sort();
|
||||
|
||||
// Determine what actions are available for selected repositories
|
||||
const getAvailableActions = () => {
|
||||
if (selectedRepoIds.size === 0) return [];
|
||||
|
||||
const selectedRepos = repositories.filter(repo => repo.id && selectedRepoIds.has(repo.id));
|
||||
const statuses = new Set(selectedRepos.map(repo => repo.status));
|
||||
|
||||
const actions = [];
|
||||
|
||||
// Check if any selected repos can be mirrored
|
||||
if (selectedRepos.some(repo => repo.status === "imported" || repo.status === "failed")) {
|
||||
actions.push('mirror');
|
||||
}
|
||||
|
||||
// Check if any selected repos can be synced
|
||||
if (selectedRepos.some(repo => repo.status === "mirrored" || repo.status === "synced")) {
|
||||
actions.push('sync');
|
||||
}
|
||||
|
||||
// Check if any selected repos are failed
|
||||
if (selectedRepos.some(repo => repo.status === "failed")) {
|
||||
actions.push('retry');
|
||||
}
|
||||
|
||||
return actions;
|
||||
};
|
||||
|
||||
const availableActions = getAvailableActions();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-y-8">
|
||||
{/* Combine search and actions into a single flex row */}
|
||||
@@ -442,19 +617,78 @@ export default function Repository() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button variant="default" onClick={handleRefresh}>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Refresh
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleRefresh}
|
||||
title="Refresh repositories"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={handleMirrorAllRepos}
|
||||
disabled={isLoading || loadingRepoIds.size > 0}
|
||||
>
|
||||
<FlipHorizontal className="h-4 w-4 mr-2" />
|
||||
Mirror All
|
||||
</Button>
|
||||
{/* Context-aware action buttons */}
|
||||
{selectedRepoIds.size === 0 ? (
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={handleMirrorAllRepos}
|
||||
disabled={isInitialLoading || loadingRepoIds.size > 0}
|
||||
>
|
||||
<FlipHorizontal className="h-4 w-4 mr-2" />
|
||||
Mirror All
|
||||
</Button>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 px-3 py-1 bg-muted/50 rounded-md">
|
||||
<span className="text-sm font-medium">
|
||||
{selectedRepoIds.size} selected
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => setSelectedRepoIds(new Set())}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{availableActions.includes('mirror') && (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={handleBulkMirror}
|
||||
disabled={loadingRepoIds.size > 0}
|
||||
>
|
||||
<FlipHorizontal className="h-4 w-4 mr-2" />
|
||||
Mirror ({selectedRepoIds.size})
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{availableActions.includes('sync') && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleBulkSync}
|
||||
disabled={loadingRepoIds.size > 0}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Sync ({selectedRepoIds.size})
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{availableActions.includes('retry') && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleBulkRetry}
|
||||
disabled={loadingRepoIds.size > 0}
|
||||
>
|
||||
<RotateCcw className="h-4 w-4 mr-2" />
|
||||
Retry
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isGitHubConfigured ? (
|
||||
@@ -465,7 +699,11 @@ export default function Repository() {
|
||||
</p>
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => window.location.href = "/config"}
|
||||
onClick={() => {
|
||||
window.history.pushState({}, '', '/config');
|
||||
// We need to trigger a page change event for the navigation system
|
||||
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||
}}
|
||||
>
|
||||
Go to Configuration
|
||||
</Button>
|
||||
@@ -473,13 +711,17 @@ export default function Repository() {
|
||||
) : (
|
||||
<RepositoryTable
|
||||
repositories={repositories}
|
||||
isLoading={isLoading || !connected}
|
||||
isLoading={isInitialLoading || !connected}
|
||||
isLiveActive={isLiveEnabled && isFullyConfigured}
|
||||
filter={filter}
|
||||
setFilter={setFilter}
|
||||
onMirror={handleMirrorRepo}
|
||||
onSync={handleSyncRepo}
|
||||
onRetry={handleRetryRepoAction}
|
||||
loadingRepoIds={loadingRepoIds}
|
||||
selectedRepoIds={selectedRepoIds}
|
||||
onSelectionChange={setSelectedRepoIds}
|
||||
onRefresh={() => fetchRepositories(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,36 +1,107 @@
|
||||
import { useMemo, useRef } from "react";
|
||||
import Fuse from "fuse.js";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { GitFork, RefreshCw, RotateCcw } from "lucide-react";
|
||||
import { SiGithub } from "react-icons/si";
|
||||
import { FlipHorizontal, GitFork, RefreshCw, RotateCcw, Star } from "lucide-react";
|
||||
import { SiGithub, SiGitea } from "react-icons/si";
|
||||
import type { Repository } from "@/lib/db/schema";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { formatDate, getStatusColor } from "@/lib/utils";
|
||||
import type { FilterParams } from "@/types/filter";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useGiteaConfig } from "@/hooks/useGiteaConfig";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { InlineDestinationEditor } from "./InlineDestinationEditor";
|
||||
|
||||
interface RepositoryTableProps {
|
||||
repositories: Repository[];
|
||||
isLoading: boolean;
|
||||
isLiveActive?: boolean;
|
||||
filter: FilterParams;
|
||||
setFilter: (filter: FilterParams) => void;
|
||||
onMirror: ({ repoId }: { repoId: string }) => Promise<void>;
|
||||
onSync: ({ repoId }: { repoId: string }) => Promise<void>;
|
||||
onRetry: ({ repoId }: { repoId: string }) => Promise<void>;
|
||||
loadingRepoIds: Set<string>;
|
||||
selectedRepoIds: Set<string>;
|
||||
onSelectionChange: (selectedIds: Set<string>) => void;
|
||||
onRefresh?: () => Promise<void>;
|
||||
}
|
||||
|
||||
export default function RepositoryTable({
|
||||
repositories,
|
||||
isLoading,
|
||||
isLiveActive = false,
|
||||
filter,
|
||||
setFilter,
|
||||
onMirror,
|
||||
onSync,
|
||||
onRetry,
|
||||
loadingRepoIds,
|
||||
selectedRepoIds,
|
||||
onSelectionChange,
|
||||
onRefresh,
|
||||
}: RepositoryTableProps) {
|
||||
const tableParentRef = useRef<HTMLDivElement>(null);
|
||||
const { giteaConfig } = useGiteaConfig();
|
||||
|
||||
const handleUpdateDestination = async (repoId: string, newDestination: string | null) => {
|
||||
// Call API to update repository destination
|
||||
const response = await fetch(`/api/repositories/${repoId}`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
destinationOrg: newDestination,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || "Failed to update repository");
|
||||
}
|
||||
|
||||
// Refresh repositories data
|
||||
if (onRefresh) {
|
||||
await onRefresh();
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to construct Gitea repository URL
|
||||
const getGiteaRepoUrl = (repository: Repository): string | null => {
|
||||
if (!giteaConfig?.url) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Only provide Gitea links for repositories that have been or are being mirrored
|
||||
const validStatuses = ['mirroring', 'mirrored', 'syncing', 'synced'];
|
||||
if (!validStatuses.includes(repository.status)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Use mirroredLocation if available, otherwise construct from repository data
|
||||
let repoPath: string;
|
||||
if (repository.mirroredLocation) {
|
||||
repoPath = repository.mirroredLocation;
|
||||
} else {
|
||||
// Fallback: construct the path based on repository data
|
||||
const owner = repository.organization || repository.owner;
|
||||
repoPath = `${owner}/${repository.name}`;
|
||||
}
|
||||
|
||||
// Ensure the base URL doesn't have a trailing slash
|
||||
const baseUrl = giteaConfig.url.endsWith('/')
|
||||
? giteaConfig.url.slice(0, -1)
|
||||
: giteaConfig.url;
|
||||
|
||||
return `${baseUrl}/${repoPath}`;
|
||||
};
|
||||
|
||||
const hasAnyFilter = Object.values(filter).some(
|
||||
(val) => val?.toString().trim() !== ""
|
||||
@@ -71,9 +142,36 @@ export default function RepositoryTable({
|
||||
overscan: 5,
|
||||
});
|
||||
|
||||
// Selection handlers
|
||||
const handleSelectAll = (checked: boolean) => {
|
||||
if (checked) {
|
||||
const allIds = new Set(filteredRepositories.map(repo => repo.id).filter((id): id is string => !!id));
|
||||
onSelectionChange(allIds);
|
||||
} else {
|
||||
onSelectionChange(new Set());
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectRepo = (repoId: string, checked: boolean) => {
|
||||
const newSelection = new Set(selectedRepoIds);
|
||||
if (checked) {
|
||||
newSelection.add(repoId);
|
||||
} else {
|
||||
newSelection.delete(repoId);
|
||||
}
|
||||
onSelectionChange(newSelection);
|
||||
};
|
||||
|
||||
const isAllSelected = filteredRepositories.length > 0 &&
|
||||
filteredRepositories.every(repo => repo.id && selectedRepoIds.has(repo.id));
|
||||
const isPartiallySelected = selectedRepoIds.size > 0 && !isAllSelected;
|
||||
|
||||
return isLoading ? (
|
||||
<div className="border rounded-md">
|
||||
<div className="h-[45px] flex items-center justify-between border-b bg-muted/50">
|
||||
<div className="h-full p-3 flex items-center justify-center flex-[0.3]">
|
||||
<Skeleton className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="h-full p-3 text-sm font-medium flex-[2.5]">
|
||||
Repository
|
||||
</div>
|
||||
@@ -85,9 +183,12 @@ export default function RepositoryTable({
|
||||
Last Mirrored
|
||||
</div>
|
||||
<div className="h-full p-3 text-sm font-medium flex-[1]">Status</div>
|
||||
<div className="h-full p-3 text-sm font-medium flex-[1] text-right">
|
||||
<div className="h-full p-3 text-sm font-medium flex-[1]">
|
||||
Actions
|
||||
</div>
|
||||
<div className="h-full p-3 text-sm font-medium flex-[0.8] text-center">
|
||||
Links
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
@@ -95,6 +196,9 @@ export default function RepositoryTable({
|
||||
key={i}
|
||||
className="h-[65px] flex items-center justify-between border-b bg-transparent"
|
||||
>
|
||||
<div className="h-full p-3 flex items-center justify-center flex-[0.3]">
|
||||
<Skeleton className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="h-full p-3 text-sm font-medium flex-[2.5]">
|
||||
<Skeleton className="h-full w-full" />
|
||||
</div>
|
||||
@@ -110,7 +214,10 @@ export default function RepositoryTable({
|
||||
<div className="h-full p-3 text-sm font-medium flex-[1]">
|
||||
<Skeleton className="h-full w-full" />
|
||||
</div>
|
||||
<div className="h-full p-3 text-sm font-medium flex-[1] text-right">
|
||||
<div className="h-full p-3 text-sm font-medium flex-[1]">
|
||||
<Skeleton className="h-full w-full" />
|
||||
</div>
|
||||
<div className="h-full p-3 text-sm font-medium flex-[0.8] text-center">
|
||||
<Skeleton className="h-full w-full" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -147,6 +254,14 @@ export default function RepositoryTable({
|
||||
<div className="flex flex-col border rounded-md">
|
||||
{/* table header */}
|
||||
<div className="h-[45px] flex items-center justify-between border-b bg-muted/50">
|
||||
<div className="h-full p-3 flex items-center justify-center flex-[0.3]">
|
||||
<Checkbox
|
||||
checked={isAllSelected}
|
||||
indeterminate={isPartiallySelected}
|
||||
onCheckedChange={handleSelectAll}
|
||||
aria-label="Select all repositories"
|
||||
/>
|
||||
</div>
|
||||
<div className="h-full p-3 text-sm font-medium flex-[2.5]">
|
||||
Repository
|
||||
</div>
|
||||
@@ -158,15 +273,18 @@ export default function RepositoryTable({
|
||||
Last Mirrored
|
||||
</div>
|
||||
<div className="h-full p-3 text-sm font-medium flex-[1]">Status</div>
|
||||
<div className="h-full p-3 text-sm font-medium flex-[1] text-right">
|
||||
<div className="h-full p-3 text-sm font-medium flex-[1]">
|
||||
Actions
|
||||
</div>
|
||||
<div className="h-full p-3 text-sm font-medium flex-[0.8] text-center">
|
||||
Links
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* table body wrapper (for a parent in virtualization) */}
|
||||
<div
|
||||
ref={tableParentRef}
|
||||
className="flex flex-col max-h-[calc(100dvh-236px)] overflow-y-auto" //the height is set according to the other contents
|
||||
className="flex flex-col max-h-[calc(100dvh-276px)] overflow-y-auto" //adjusted height to account for status bar
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
@@ -192,11 +310,25 @@ export default function RepositoryTable({
|
||||
data-index={virtualRow.index}
|
||||
className="h-[65px] flex items-center justify-between bg-transparent border-b hover:bg-muted/50" //the height is set according to the row content. right now the highest row is in the repo column which is arround 64.99px
|
||||
>
|
||||
{/* Checkbox */}
|
||||
<div className="h-full p-3 flex items-center justify-center flex-[0.3]">
|
||||
<Checkbox
|
||||
checked={repo.id ? selectedRepoIds.has(repo.id) : false}
|
||||
onCheckedChange={(checked) => repo.id && handleSelectRepo(repo.id, !!checked)}
|
||||
aria-label={`Select ${repo.name}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Repository */}
|
||||
<div className="h-full p-3 flex items-center gap-2 flex-[2.5]">
|
||||
<GitFork className="h-4 w-4 text-muted-foreground" />
|
||||
<div>
|
||||
<div className="font-medium">{repo.name}</div>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium flex items-center gap-1">
|
||||
{repo.name}
|
||||
{repo.isStarred && (
|
||||
<Star className="h-3 w-3 fill-yellow-500 text-yellow-500" />
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{repo.fullName}
|
||||
</div>
|
||||
@@ -206,6 +338,11 @@ export default function RepositoryTable({
|
||||
Private
|
||||
</span>
|
||||
)}
|
||||
{repo.isForked && (
|
||||
<span className="ml-2 rounded-full bg-muted px-2 py-0.5 text-xs">
|
||||
Fork
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Owner */}
|
||||
@@ -215,7 +352,12 @@ export default function RepositoryTable({
|
||||
|
||||
{/* Organization */}
|
||||
<div className="h-full p-3 flex items-center flex-[1]">
|
||||
<p className="text-sm"> {repo.organization || "-"}</p>
|
||||
<InlineDestinationEditor
|
||||
repository={repo}
|
||||
giteaConfig={giteaConfig}
|
||||
onUpdate={handleUpdateDestination}
|
||||
isUpdating={loadingRepoIds.has(repo.id ?? "")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Last Mirrored */}
|
||||
@@ -229,70 +371,83 @@ export default function RepositoryTable({
|
||||
|
||||
{/* Status */}
|
||||
<div className="h-full p-3 flex items-center gap-x-2 flex-[1]">
|
||||
<div
|
||||
className={`h-2 w-2 rounded-full ${getStatusColor(
|
||||
repo.status
|
||||
)}`}
|
||||
/>
|
||||
<span className="text-sm capitalize">{repo.status}</span>
|
||||
{repo.status === "failed" && repo.errorMessage ? (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center gap-x-2 cursor-help">
|
||||
<div className={`h-2 w-2 rounded-full ${getStatusColor(repo.status)}`} />
|
||||
<span className="text-sm capitalize underline decoration-dotted">{repo.status}</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs">
|
||||
<p className="text-sm">{repo.errorMessage}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
<>
|
||||
<div className={`h-2 w-2 rounded-full ${getStatusColor(repo.status)}`} />
|
||||
<span className="text-sm capitalize">{repo.status}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="h-full p-3 flex items-center justify-end gap-x-2 flex-[1]">
|
||||
{/* {repo.status === "mirrored" ||
|
||||
repo.status === "syncing" ||
|
||||
repo.status === "synced" ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
disabled={repo.status === "syncing" || isLoading}
|
||||
onClick={() => onSync({ repoId: repo.id ?? "" })}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<RefreshCw className="h-4 w-4 animate-spin mr-1" />
|
||||
Sync
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RefreshCw className="h-4 w-4 mr-1" />
|
||||
Sync
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
disabled={repo.status === "mirroring" || isLoading}
|
||||
onClick={() => onMirror({ repoId: repo.id ?? "" })}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<RefreshCw className="h-4 w-4 animate-spin mr-1" />
|
||||
Mirror
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RefreshCw className="h-4 w-4 mr-1" />
|
||||
Mirror
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)} */}
|
||||
|
||||
<div className="h-full p-3 flex items-center justify-start flex-[1]">
|
||||
<RepoActionButton
|
||||
repo={{ id: repo.id ?? "", status: repo.status }}
|
||||
isLoading={isLoading}
|
||||
onMirror={({ repoId }) =>
|
||||
onMirror({ repoId: repo.id ?? "" })
|
||||
}
|
||||
onSync={({ repoId }) => onSync({ repoId: repo.id ?? "" })}
|
||||
onRetry={({ repoId }) => onRetry({ repoId: repo.id ?? "" })}
|
||||
onMirror={() => onMirror({ repoId: repo.id ?? "" })}
|
||||
onSync={() => onSync({ repoId: repo.id ?? "" })}
|
||||
onRetry={() => onRetry({ repoId: repo.id ?? "" })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Links */}
|
||||
<div className="h-full p-3 flex items-center justify-center gap-x-2 flex-[0.8]">
|
||||
{(() => {
|
||||
const giteaUrl = getGiteaRepoUrl(repo);
|
||||
|
||||
// Determine tooltip based on status and configuration
|
||||
let tooltip: string;
|
||||
if (!giteaConfig?.url) {
|
||||
tooltip = "Gitea not configured";
|
||||
} else if (repo.status === 'imported') {
|
||||
tooltip = "Repository not yet mirrored to Gitea";
|
||||
} else if (repo.status === 'failed') {
|
||||
tooltip = "Repository mirroring failed";
|
||||
} else if (repo.status === 'mirroring') {
|
||||
tooltip = "Repository is being mirrored to Gitea";
|
||||
} else if (giteaUrl) {
|
||||
tooltip = "View on Gitea";
|
||||
} else {
|
||||
tooltip = "Gitea repository not available";
|
||||
}
|
||||
|
||||
return giteaUrl ? (
|
||||
<Button variant="ghost" size="icon" asChild>
|
||||
<a
|
||||
href={giteaUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title={tooltip}
|
||||
>
|
||||
<SiGitea className="h-4 w-4" />
|
||||
</a>
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="ghost" size="icon" disabled title={tooltip}>
|
||||
<SiGitea className="h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
})()}
|
||||
<Button variant="ghost" size="icon" asChild>
|
||||
<a
|
||||
href={repo.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title="View on GitHub"
|
||||
>
|
||||
<SiGithub className="h-4 w-4" />
|
||||
</a>
|
||||
@@ -303,6 +458,46 @@ export default function RepositoryTable({
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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">
|
||||
{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
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -316,12 +511,10 @@ function RepoActionButton({
|
||||
}: {
|
||||
repo: { id: string; status: string };
|
||||
isLoading: boolean;
|
||||
onMirror: ({ repoId }: { repoId: string }) => void;
|
||||
onSync: ({ repoId }: { repoId: string }) => void;
|
||||
onRetry: ({ repoId }: { repoId: string }) => void;
|
||||
onMirror: () => void;
|
||||
onSync: () => void;
|
||||
onRetry: () => void;
|
||||
}) {
|
||||
const repoId = repo.id ?? "";
|
||||
|
||||
let label = "";
|
||||
let icon = <></>;
|
||||
let onClick = () => {};
|
||||
@@ -330,23 +523,28 @@ function RepoActionButton({
|
||||
if (repo.status === "failed") {
|
||||
label = "Retry";
|
||||
icon = <RotateCcw className="h-4 w-4 mr-1" />;
|
||||
onClick = () => onRetry({ repoId });
|
||||
onClick = onRetry;
|
||||
} else if (["mirrored", "synced", "syncing"].includes(repo.status)) {
|
||||
label = "Sync";
|
||||
icon = <RefreshCw className="h-4 w-4 mr-1" />;
|
||||
onClick = () => onSync({ repoId });
|
||||
onClick = onSync;
|
||||
disabled ||= repo.status === "syncing";
|
||||
} else if (["imported", "mirroring"].includes(repo.status)) {
|
||||
label = "Mirror";
|
||||
icon = <RefreshCw className="h-4 w-4 mr-1" />;
|
||||
onClick = () => onMirror({ repoId });
|
||||
icon = <FlipHorizontal className="h-4 w-4 mr-1" />; // Don't change this icon to GitFork.
|
||||
onClick = onMirror;
|
||||
disabled ||= repo.status === "mirroring";
|
||||
} else {
|
||||
return null; // unsupported status
|
||||
}
|
||||
|
||||
return (
|
||||
<Button variant="ghost" disabled={disabled} onClick={onClick}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
className="min-w-[80px] justify-start"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<RefreshCw className="h-4 w-4 animate-spin mr-1" />
|
||||
|
||||
64
src/components/ui/accordion.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import * as React from "react"
|
||||
import * as AccordionPrimitive from "@radix-ui/react-accordion"
|
||||
import { ChevronDownIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Accordion({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
|
||||
return <AccordionPrimitive.Root data-slot="accordion" {...props} />
|
||||
}
|
||||
|
||||
function AccordionItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
|
||||
return (
|
||||
<AccordionPrimitive.Item
|
||||
data-slot="accordion-item"
|
||||
className={cn("border-b last:border-b-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AccordionTrigger({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
|
||||
return (
|
||||
<AccordionPrimitive.Header className="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
data-slot="accordion-trigger"
|
||||
className={cn(
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
)
|
||||
}
|
||||
|
||||
function AccordionContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
|
||||
return (
|
||||
<AccordionPrimitive.Content
|
||||
data-slot="accordion-content"
|
||||
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
|
||||
{...props}
|
||||
>
|
||||
<div className={cn("pt-0 pb-4", className)}>{children}</div>
|
||||
</AccordionPrimitive.Content>
|
||||
)
|
||||
}
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
|
||||
46
src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"span"> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "span"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
31
src/components/ui/collapsible.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
|
||||
|
||||
function Collapsible({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
|
||||
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
|
||||
}
|
||||
|
||||
function CollapsibleTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
|
||||
return (
|
||||
<CollapsiblePrimitive.CollapsibleTrigger
|
||||
data-slot="collapsible-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CollapsibleContent({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
|
||||
return (
|
||||
<CollapsiblePrimitive.CollapsibleContent
|
||||
data-slot="collapsible-content"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
||||
42
src/components/ui/hover-card.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import * as React from "react"
|
||||
import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function HoverCard({
|
||||
...props
|
||||
}: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
|
||||
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />
|
||||
}
|
||||
|
||||
function HoverCardTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
|
||||
return (
|
||||
<HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function HoverCardContent({
|
||||
className,
|
||||
align = "center",
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
|
||||
return (
|
||||
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
|
||||
<HoverCardPrimitive.Content
|
||||
data-slot="hover-card-content"
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</HoverCardPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { HoverCard, HoverCardTrigger, HoverCardContent }
|
||||
43
src/components/ui/radio-group.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import * as React from "react"
|
||||
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
|
||||
import { CircleIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function RadioGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
|
||||
return (
|
||||
<RadioGroupPrimitive.Root
|
||||
data-slot="radio-group"
|
||||
className={cn("grid gap-3", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function RadioGroupItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
|
||||
return (
|
||||
<RadioGroupPrimitive.Item
|
||||
data-slot="radio-group-item"
|
||||
className={cn(
|
||||
"border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<RadioGroupPrimitive.Indicator
|
||||
data-slot="radio-group-indicator"
|
||||
className="relative flex items-center justify-center"
|
||||
>
|
||||
<CircleIcon className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
|
||||
</RadioGroupPrimitive.Indicator>
|
||||
</RadioGroupPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
export { RadioGroup, RadioGroupItem }
|
||||
56
src/components/ui/scroll-area.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import * as React from "react"
|
||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function ScrollArea({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Root
|
||||
data-slot="scroll-area"
|
||||
className={cn("relative", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
data-slot="scroll-area-viewport"
|
||||
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
|
||||
>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function ScrollBar({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
data-slot="scroll-area-scrollbar"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none p-px transition-colors select-none",
|
||||
orientation === "vertical" &&
|
||||
"h-full w-2.5 border-l border-l-transparent",
|
||||
orientation === "horizontal" &&
|
||||
"h-2.5 flex-col border-t border-t-transparent",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb
|
||||
data-slot="scroll-area-thumb"
|
||||
className="bg-border relative flex-1 rounded-full"
|
||||
/>
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
)
|
||||
}
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
26
src/components/ui/separator.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import * as React from "react"
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
decorative = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
data-slot="separator"
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
29
src/components/ui/switch.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import * as React from "react"
|
||||
import * as SwitchPrimitive from "@radix-ui/react-switch"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Switch({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
|
||||
return (
|
||||
<SwitchPrimitive.Root
|
||||
data-slot="switch"
|
||||
className={cn(
|
||||
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
data-slot="switch-thumb"
|
||||
className={cn(
|
||||
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Switch }
|
||||
@@ -19,7 +19,11 @@ function TooltipProvider({
|
||||
function Tooltip({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function TooltipTrigger({
|
||||
@@ -40,7 +44,7 @@ function TooltipContent({
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-[var(--radix-tooltip-content-transform-origin)] rounded-md px-3 py-1.5 text-xs text-balance",
|
||||
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -1,17 +1,4 @@
|
||||
import { defineCollection, z } from 'astro:content';
|
||||
|
||||
// Define a schema for the documentation collection
|
||||
const docsCollection = defineCollection({
|
||||
type: 'content',
|
||||
schema: z.object({
|
||||
title: z.string(),
|
||||
description: z.string(),
|
||||
order: z.number().optional(),
|
||||
updatedDate: z.date().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
// Export the collections
|
||||
export const collections = {
|
||||
'docs': docsCollection,
|
||||
};
|
||||
// Export empty collections since docs have been moved
|
||||
export const collections = {};
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
---
|
||||
title: "Architecture"
|
||||
description: "Comprehensive overview of the Gitea Mirror application architecture."
|
||||
order: 1
|
||||
updatedDate: 2025-05-22
|
||||
---
|
||||
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-foreground">Gitea Mirror Architecture</h1>
|
||||
<p class="text-muted-foreground mt-2">This document provides a comprehensive overview of the Gitea Mirror application architecture, including component diagrams, project structure, and detailed explanations of each part of the system.</p>
|
||||
</div>
|
||||
|
||||
## System Overview
|
||||
|
||||
<div class="mb-4">
|
||||
<p class="text-muted-foreground">Gitea Mirror is a web application that automates the mirroring of GitHub repositories to Gitea instances. It provides a user-friendly interface for configuring, monitoring, and managing mirroring operations without requiring users to edit configuration files or run Docker commands.</p>
|
||||
</div>
|
||||
|
||||
The application is built using:
|
||||
|
||||
- <span class="font-semibold text-foreground">Astro</span>: Web framework for the frontend
|
||||
- <span class="font-semibold text-foreground">React</span>: Component library for interactive UI elements
|
||||
- <span class="font-semibold text-foreground">Shadcn UI</span>: UI component library built on Tailwind CSS
|
||||
- <span class="font-semibold text-foreground">SQLite</span>: Database for storing configuration, state, and events
|
||||
- <span class="font-semibold text-foreground">Bun</span>: Runtime environment for the backend
|
||||
- <span class="font-semibold text-foreground">Drizzle ORM</span>: Type-safe ORM for database interactions
|
||||
|
||||
## Architecture Diagram
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph "Gitea Mirror"
|
||||
Frontend["Frontend<br/>(Astro + React)"]
|
||||
Backend["Backend<br/>(Bun)"]
|
||||
Database["Database<br/>(SQLite + Drizzle)"]
|
||||
|
||||
Frontend <--> Backend
|
||||
Backend <--> Database
|
||||
end
|
||||
|
||||
subgraph "External APIs"
|
||||
GitHub["GitHub API"]
|
||||
Gitea["Gitea API"]
|
||||
end
|
||||
|
||||
Backend --> GitHub
|
||||
Backend --> Gitea
|
||||
```
|
||||
|
||||
## Component Breakdown
|
||||
|
||||
### Frontend (Astro + React)
|
||||
|
||||
The frontend is built with Astro, a modern web framework that allows for server-side rendering and partial hydration. React components are used for interactive elements, providing a responsive and dynamic user interface.
|
||||
|
||||
Key frontend components:
|
||||
|
||||
- **Dashboard**: Overview of mirroring status and recent activity
|
||||
- **Repository Management**: Interface for managing repositories to mirror
|
||||
- **Organization Management**: Interface for managing GitHub organizations
|
||||
- **Configuration**: Settings for GitHub and Gitea connections
|
||||
- **Activity Log**: Detailed log of mirroring operations
|
||||
|
||||
### Backend (Bun)
|
||||
|
||||
The backend is built with Bun and provides API endpoints for the frontend to interact with. It handles:
|
||||
|
||||
- Authentication and user management
|
||||
- GitHub API integration
|
||||
- Gitea API integration
|
||||
- Mirroring operations
|
||||
- Database interactions
|
||||
|
||||
### Database (SQLite + Drizzle ORM)
|
||||
|
||||
SQLite with Bun's native SQLite driver is used for data persistence, with Drizzle ORM providing type-safe database interactions. The database stores:
|
||||
|
||||
- User accounts and authentication data
|
||||
- GitHub and Gitea configuration
|
||||
- Repository and organization information
|
||||
- Mirroring job history and status
|
||||
- Event notifications and their read status
|
||||
|
||||
## Data Flow
|
||||
|
||||
1. **User Authentication**: Users authenticate through the frontend, which communicates with the backend to validate credentials.
|
||||
2. **Configuration**: Users configure GitHub and Gitea settings through the UI, which are stored in the SQLite database.
|
||||
3. **Repository Discovery**: The backend queries the GitHub API to discover repositories based on user configuration.
|
||||
4. **Mirroring Process**: When triggered, the backend fetches repository data from GitHub and pushes it to Gitea.
|
||||
5. **Status Tracking**: All operations are logged in the database and displayed in the Activity Log.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
gitea-mirror/
|
||||
├── src/ # Source code
|
||||
│ ├── components/ # React components
|
||||
│ ├── content/ # Documentation and content
|
||||
│ ├── layouts/ # Astro layout components
|
||||
│ ├── lib/ # Utility functions and database
|
||||
│ ├── pages/ # Astro pages and API routes
|
||||
│ └── styles/ # CSS and Tailwind styles
|
||||
├── public/ # Static assets
|
||||
├── data/ # Database and persistent data
|
||||
├── docker/ # Docker configuration
|
||||
└── scripts/ # Utility scripts for deployment and maintenance
|
||||
├── gitea-mirror-lxc-local.sh # Local LXC deployment script
|
||||
└── manage-db.ts # Database management tool
|
||||
```
|
||||
|
||||
## Deployment Options
|
||||
|
||||
Gitea Mirror supports multiple deployment options:
|
||||
|
||||
1. **Docker**: Run as a containerized application using Docker and docker-compose
|
||||
2. **LXC Containers**: Deploy in Linux Containers (LXC) on Proxmox VE (using community script by [Tobias/CrazyWolf13](https://github.com/CrazyWolf13)) or local workstations
|
||||
3. **Native**: Run directly on the host system using Bun runtime
|
||||
|
||||
Each deployment method has its own advantages:
|
||||
|
||||
- **Docker**: Isolation, easy updates, consistent environment
|
||||
- **LXC**: Lightweight virtualization, better performance than Docker, system-level isolation
|
||||
- **Native**: Best performance, direct access to system resources
|
||||
@@ -1,177 +0,0 @@
|
||||
---
|
||||
title: "Configuration"
|
||||
description: "Guide to configuring Gitea Mirror for your environment."
|
||||
order: 2
|
||||
updatedDate: 2025-05-22
|
||||
---
|
||||
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-foreground">Gitea Mirror Configuration Guide</h1>
|
||||
<p class="text-muted-foreground mt-2">This guide provides detailed information on how to configure Gitea Mirror for your environment.</p>
|
||||
</div>
|
||||
|
||||
## Configuration Methods
|
||||
|
||||
Gitea Mirror can be configured using:
|
||||
|
||||
1. <span class="font-semibold text-foreground">Environment Variables</span>: Set configuration options through environment variables
|
||||
2. <span class="font-semibold text-foreground">Web UI</span>: Configure the application through the web interface after installation
|
||||
|
||||
## Environment Variables
|
||||
|
||||
The following environment variables can be used to configure Gitea Mirror:
|
||||
|
||||
| Variable | Description | Default Value | Example |
|
||||
|----------|-------------|---------------|---------|
|
||||
| `NODE_ENV` | Runtime environment (development, production, test) | `development` | `production` |
|
||||
| `DATABASE_URL` | SQLite database URL | `file:data/gitea-mirror.db` | `file:path/to/your/database.db` |
|
||||
| `JWT_SECRET` | Secret key for JWT authentication | Auto-generated secure random string | `your-secure-random-string` |
|
||||
| `HOST` | Server host | `localhost` | `0.0.0.0` |
|
||||
| `PORT` | Server port | `4321` | `8080` |
|
||||
|
||||
### Important Security Note
|
||||
|
||||
The application will automatically generate a secure random `JWT_SECRET` on first run if one isn't provided or if the default value is used. This generated secret is stored in the data directory for persistence across container restarts.
|
||||
|
||||
While this auto-generation feature provides good security by default, you can still explicitly set your own `JWT_SECRET` for complete control over your deployment.
|
||||
|
||||
## Web UI Configuration
|
||||
|
||||
After installing and starting Gitea Mirror, you can configure it through the web interface:
|
||||
|
||||
1. Navigate to `http://your-server:port/`
|
||||
2. If this is your first time, you'll be guided through creating an admin account
|
||||
3. Log in with your credentials
|
||||
4. Go to the Configuration page
|
||||
|
||||
### GitHub Configuration
|
||||
|
||||
The GitHub configuration section allows you to connect to GitHub and specify which repositories to mirror.
|
||||
|
||||
| Option | Description | Default |
|
||||
|--------|-------------|---------|
|
||||
| Username | Your GitHub username | - |
|
||||
| Token | GitHub personal access token | - |
|
||||
| Skip Forks | Skip forked repositories | `false` |
|
||||
| Private Repositories | Include private repositories | `false` |
|
||||
| Mirror Issues | Mirror issues from GitHub to Gitea | `false` |
|
||||
| Mirror Starred | Mirror starred repositories | `false` |
|
||||
| Mirror Organizations | Mirror organization repositories | `false` |
|
||||
| Only Mirror Orgs | Only mirror organization repositories | `false` |
|
||||
| Preserve Org Structure | Maintain organization structure in Gitea | `false` |
|
||||
| Skip Starred Issues | Skip mirroring issues for starred repositories | `false` |
|
||||
|
||||
#### GitHub Token Permissions
|
||||
|
||||
Your GitHub token needs the following permissions:
|
||||
|
||||
- `repo` - Full control of private repositories
|
||||
- `read:org` - Read organization membership
|
||||
- `read:user` - Read user profile data
|
||||
|
||||
To create a GitHub token:
|
||||
|
||||
1. Go to [GitHub Settings > Developer settings > Personal access tokens](https://github.com/settings/tokens)
|
||||
2. Click "Generate new token"
|
||||
3. Select the required permissions
|
||||
4. Copy the generated token and paste it into Gitea Mirror
|
||||
|
||||
### Gitea Configuration
|
||||
|
||||
The Gitea configuration section allows you to connect to your Gitea instance and specify how repositories should be mirrored.
|
||||
|
||||
| Option | Description | Default |
|
||||
|--------|-------------|---------|
|
||||
| URL | Gitea server URL | - |
|
||||
| Token | Gitea access token | - |
|
||||
| Organization | Default organization for mirrored repositories | - |
|
||||
| Visibility | Default visibility for mirrored repositories | `public` |
|
||||
| Starred Repos Org | Organization for starred repositories | `github` |
|
||||
|
||||
#### Gitea Token Creation
|
||||
|
||||
To create a Gitea access token:
|
||||
|
||||
1. Log in to your Gitea instance
|
||||
2. Go to Settings > Applications
|
||||
3. Under "Generate New Token", enter a name for your token
|
||||
4. Click "Generate Token"
|
||||
5. Copy the generated token and paste it into Gitea Mirror
|
||||
|
||||
### Schedule Configuration
|
||||
|
||||
You can configure automatic mirroring on a schedule:
|
||||
|
||||
| Option | Description | Default |
|
||||
|--------|-------------|---------|
|
||||
| Enable Scheduling | Enable automatic mirroring | `false` |
|
||||
| Interval (seconds) | Time between mirroring operations | `3600` (1 hour) |
|
||||
|
||||
## Advanced Configuration
|
||||
|
||||
### Repository Filtering
|
||||
|
||||
You can include or exclude specific repositories using patterns:
|
||||
|
||||
- Include patterns: Only repositories matching these patterns will be mirrored
|
||||
- Exclude patterns: Repositories matching these patterns will be skipped
|
||||
|
||||
Example patterns:
|
||||
- `*` - All repositories
|
||||
- `org-name/*` - All repositories in a specific organization
|
||||
- `username/repo-name` - A specific repository
|
||||
|
||||
### Database Management
|
||||
|
||||
Gitea Mirror includes several database management tools that can be run from the command line:
|
||||
|
||||
```bash
|
||||
# Initialize the database (only if it doesn't exist)
|
||||
bun run init-db
|
||||
|
||||
# Check database status
|
||||
bun run check-db
|
||||
|
||||
# Fix database location issues
|
||||
bun run fix-db
|
||||
|
||||
# Reset all users (for testing signup flow)
|
||||
bun run reset-users
|
||||
|
||||
# Remove database files completely
|
||||
bun run cleanup-db
|
||||
```
|
||||
|
||||
### Event Management
|
||||
|
||||
Events in Gitea Mirror (such as repository mirroring operations) are stored in the SQLite database. You can manage these events using the following scripts:
|
||||
|
||||
```bash
|
||||
# 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
|
||||
|
||||
# Mark all events as read
|
||||
bun scripts/mark-events-read.ts
|
||||
```
|
||||
|
||||
### Health Check Endpoint
|
||||
|
||||
Gitea Mirror includes a built-in health check endpoint at `/api/health` that provides:
|
||||
|
||||
- System status and uptime
|
||||
- Database connectivity check
|
||||
- Memory usage statistics
|
||||
- Environment information
|
||||
|
||||
You can use this endpoint for monitoring your deployment:
|
||||
|
||||
```bash
|
||||
# Basic check (returns 200 OK if healthy)
|
||||
curl -I http://your-server:port/api/health
|
||||
|
||||
# Detailed health information (JSON)
|
||||
curl http://your-server:port/api/health
|
||||
```
|
||||
@@ -1,182 +0,0 @@
|
||||
---
|
||||
title: "Quick Start Guide"
|
||||
description: "Get started with Gitea Mirror quickly."
|
||||
order: 3
|
||||
updatedDate: 2025-05-22
|
||||
---
|
||||
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-foreground">Gitea Mirror Quick Start Guide</h1>
|
||||
<p class="text-muted-foreground mt-2">This guide will help you get Gitea Mirror up and running quickly.</p>
|
||||
</div>
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before you begin, make sure you have:
|
||||
|
||||
1. <span class="font-semibold text-foreground">A GitHub account with a personal access token</span>
|
||||
2. <span class="font-semibold text-foreground">A Gitea instance with an access token</span>
|
||||
3. <span class="font-semibold text-foreground">One of the following:</span>
|
||||
- Docker and docker-compose (for Docker deployment)
|
||||
- Bun 1.2.9+ (for native deployment)
|
||||
- Proxmox VE or LXD (for LXC container deployment)
|
||||
|
||||
## Installation Options
|
||||
|
||||
Choose the installation method that works best for your environment.
|
||||
|
||||
### Using Docker (Recommended for most users)
|
||||
|
||||
Docker provides the easiest way to get started with minimal configuration.
|
||||
|
||||
1. Clone the repository:
|
||||
```bash
|
||||
git clone https://github.com/arunavo4/gitea-mirror.git
|
||||
cd gitea-mirror
|
||||
```
|
||||
|
||||
2. Start the application in production mode:
|
||||
```bash
|
||||
docker-compose --profile production up -d
|
||||
```
|
||||
|
||||
3. Access the application at [http://localhost:4321](http://localhost:4321)
|
||||
|
||||
### Using Bun (Native Installation)
|
||||
|
||||
If you prefer to run the application directly on your system:
|
||||
|
||||
1. Clone the repository:
|
||||
```bash
|
||||
git clone https://github.com/arunavo4/gitea-mirror.git
|
||||
cd gitea-mirror
|
||||
```
|
||||
|
||||
2. Run the quick setup script:
|
||||
```bash
|
||||
bun run setup
|
||||
```
|
||||
This installs dependencies and initializes the database.
|
||||
|
||||
3. Choose how to run the application:
|
||||
|
||||
**Development Mode:**
|
||||
```bash
|
||||
bun run dev
|
||||
```
|
||||
|
||||
Note: For Bun-specific features, use:
|
||||
```bash
|
||||
bunx --bun astro dev
|
||||
```
|
||||
|
||||
**Production Mode:**
|
||||
```bash
|
||||
bun run build
|
||||
bun run start
|
||||
```
|
||||
|
||||
4. Access the application at [http://localhost:4321](http://localhost:4321)
|
||||
|
||||
### Using LXC Containers (Recommended for server deployments)
|
||||
|
||||
#### Proxmox VE (Online Installation)
|
||||
|
||||
For deploying on a Proxmox VE host with internet access:
|
||||
|
||||
```bash
|
||||
# Optional env overrides: CTID HOSTNAME STORAGE DISK_SIZE CORES MEMORY BRIDGE IP_CONF
|
||||
sudo bash -c "$(curl -fsSL https://raw.githubusercontent.com/arunavo4/gitea-mirror/main/scripts/gitea-mirror-lxc-proxmox.sh)"
|
||||
```
|
||||
|
||||
This script:
|
||||
- Creates a privileged LXC container
|
||||
- Installs Bun and dependencies
|
||||
- Clones and builds the application
|
||||
- Sets up a systemd service
|
||||
|
||||
#### Local LXD (Offline-friendly Installation)
|
||||
|
||||
For testing on a local workstation or in environments without internet access:
|
||||
|
||||
1. Clone the repository locally:
|
||||
```bash
|
||||
git clone https://github.com/arunavo4/gitea-mirror.git
|
||||
```
|
||||
|
||||
2. Download the Bun installer once:
|
||||
```bash
|
||||
curl -L -o /tmp/bun-linux-x64.zip https://github.com/oven-sh/bun/releases/latest/download/bun-linux-x64.zip
|
||||
```
|
||||
|
||||
3. Run the local LXC installer:
|
||||
```bash
|
||||
sudo LOCAL_REPO_DIR=~/path/to/gitea-mirror ./gitea-mirror/scripts/gitea-mirror-lxc-local.sh
|
||||
```
|
||||
|
||||
For more details on LXC deployment, see the [LXC Container Deployment Guide](https://github.com/arunavo4/gitea-mirror/blob/main/scripts/README-lxc.md).
|
||||
|
||||
## Initial Configuration
|
||||
|
||||
Follow these steps to configure Gitea Mirror for first use:
|
||||
|
||||
1. **Create Admin Account**
|
||||
- Upon first access, you'll be prompted to create an admin account
|
||||
- Choose a secure username and password
|
||||
- This will be your administrator account
|
||||
|
||||
2. **Configure GitHub Connection**
|
||||
- Navigate to the Configuration page
|
||||
- Enter your GitHub username
|
||||
- Enter your GitHub personal access token
|
||||
- Select which repositories to mirror (all, starred, organizations)
|
||||
- Configure repository filtering options
|
||||
|
||||
3. **Configure Gitea Connection**
|
||||
- Enter your Gitea server URL
|
||||
- Enter your Gitea access token
|
||||
- Configure organization and visibility settings
|
||||
|
||||
4. **Set Up Scheduling (Optional)**
|
||||
- Enable automatic mirroring if desired
|
||||
- Set the mirroring interval (in seconds)
|
||||
|
||||
5. **Save Configuration**
|
||||
- Click the "Save" button to store your settings
|
||||
|
||||
## Performing Your First Mirror
|
||||
|
||||
After completing the configuration, you can start mirroring repositories:
|
||||
|
||||
1. Click "Import GitHub Data" to fetch repositories from GitHub
|
||||
2. Go to the Repositories page to view your imported repositories
|
||||
3. Select the repositories you want to mirror
|
||||
4. Click "Mirror Selected" to start the mirroring process
|
||||
5. Monitor the progress on the Activity page
|
||||
6. You'll receive toast notifications about the success or failure of operations
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
If you encounter any issues:
|
||||
|
||||
- Check the Activity Log for detailed error messages
|
||||
- Verify your GitHub and Gitea tokens have the correct permissions
|
||||
- Ensure your Gitea instance is accessible from the machine running Gitea Mirror
|
||||
- Check logs based on your deployment method:
|
||||
- Docker: `docker logs gitea-mirror`
|
||||
- Native: Check the terminal output or system logs
|
||||
- LXC: `systemctl status gitea-mirror` or `journalctl -u gitea-mirror -f`
|
||||
- Use the health check endpoint to verify system status: `curl http://your-server:4321/api/health`
|
||||
- For database issues, try the database management tools: `bun run check-db` or `bun run fix-db`
|
||||
|
||||
## Next Steps
|
||||
|
||||
After your initial setup:
|
||||
|
||||
- Explore the dashboard for an overview of your mirroring status
|
||||
- Set up automatic mirroring schedules for hands-off operation
|
||||
- Configure organization mirroring for team repositories
|
||||
- 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`
|
||||
154
src/hooks/useConfigStatus.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { useCallback, useEffect, useState, useRef } from 'react';
|
||||
import { useAuth } from './useAuth';
|
||||
import { apiRequest } from '@/lib/utils';
|
||||
import type { ConfigApiResponse } from '@/types/config';
|
||||
|
||||
interface ConfigStatus {
|
||||
isGitHubConfigured: boolean;
|
||||
isGiteaConfigured: boolean;
|
||||
isFullyConfigured: boolean;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
// Cache to prevent duplicate API calls across components
|
||||
let configCache: { data: ConfigApiResponse | null; timestamp: number; userId: string | null } = {
|
||||
data: null,
|
||||
timestamp: 0,
|
||||
userId: null
|
||||
};
|
||||
|
||||
const CACHE_DURATION = 30000; // 30 seconds cache
|
||||
|
||||
/**
|
||||
* Hook to check if GitHub and Gitea are properly configured
|
||||
* Returns configuration status and prevents unnecessary API calls when not configured
|
||||
* Uses caching to prevent duplicate API calls across components
|
||||
*/
|
||||
export function useConfigStatus(): ConfigStatus {
|
||||
const { user } = useAuth();
|
||||
const [configStatus, setConfigStatus] = useState<ConfigStatus>({
|
||||
isGitHubConfigured: false,
|
||||
isGiteaConfigured: false,
|
||||
isFullyConfigured: false,
|
||||
isLoading: true,
|
||||
error: null,
|
||||
});
|
||||
|
||||
// Track if this hook has already checked config to prevent multiple calls
|
||||
const hasCheckedRef = useRef(false);
|
||||
|
||||
const checkConfiguration = useCallback(async () => {
|
||||
if (!user?.id) {
|
||||
setConfigStatus({
|
||||
isGitHubConfigured: false,
|
||||
isGiteaConfigured: false,
|
||||
isFullyConfigured: false,
|
||||
isLoading: false,
|
||||
error: 'No user found',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check cache first
|
||||
const now = Date.now();
|
||||
const isCacheValid = configCache.data &&
|
||||
configCache.userId === user.id &&
|
||||
(now - configCache.timestamp) < CACHE_DURATION;
|
||||
|
||||
if (isCacheValid && hasCheckedRef.current) {
|
||||
const configResponse = configCache.data!;
|
||||
|
||||
const isGitHubConfigured = !!(
|
||||
configResponse?.githubConfig?.username &&
|
||||
configResponse?.githubConfig?.token
|
||||
);
|
||||
|
||||
const isGiteaConfigured = !!(
|
||||
configResponse?.giteaConfig?.url &&
|
||||
configResponse?.giteaConfig?.username &&
|
||||
configResponse?.giteaConfig?.token
|
||||
);
|
||||
|
||||
const isFullyConfigured = isGitHubConfigured && isGiteaConfigured;
|
||||
|
||||
setConfigStatus({
|
||||
isGitHubConfigured,
|
||||
isGiteaConfigured,
|
||||
isFullyConfigured,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Only show loading if we haven't checked before or cache is invalid
|
||||
if (!hasCheckedRef.current) {
|
||||
setConfigStatus(prev => ({ ...prev, isLoading: true, error: null }));
|
||||
}
|
||||
|
||||
const configResponse = await apiRequest<ConfigApiResponse>(
|
||||
`/config?userId=${user.id}`,
|
||||
{ method: 'GET' }
|
||||
);
|
||||
|
||||
// Update cache
|
||||
configCache = {
|
||||
data: configResponse,
|
||||
timestamp: now,
|
||||
userId: user.id
|
||||
};
|
||||
|
||||
const isGitHubConfigured = !!(
|
||||
configResponse?.githubConfig?.username &&
|
||||
configResponse?.githubConfig?.token
|
||||
);
|
||||
|
||||
const isGiteaConfigured = !!(
|
||||
configResponse?.giteaConfig?.url &&
|
||||
configResponse?.giteaConfig?.username &&
|
||||
configResponse?.giteaConfig?.token
|
||||
);
|
||||
|
||||
const isFullyConfigured = isGitHubConfigured && isGiteaConfigured;
|
||||
|
||||
setConfigStatus({
|
||||
isGitHubConfigured,
|
||||
isGiteaConfigured,
|
||||
isFullyConfigured,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
hasCheckedRef.current = true;
|
||||
} catch (error) {
|
||||
setConfigStatus({
|
||||
isGitHubConfigured: false,
|
||||
isGiteaConfigured: false,
|
||||
isFullyConfigured: false,
|
||||
isLoading: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to check configuration',
|
||||
});
|
||||
hasCheckedRef.current = true;
|
||||
}
|
||||
}, [user?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
checkConfiguration();
|
||||
}, [checkConfiguration]);
|
||||
|
||||
return configStatus;
|
||||
}
|
||||
|
||||
// Export function to invalidate cache when config is updated
|
||||
export function invalidateConfigCache() {
|
||||
configCache = { data: null, timestamp: 0, userId: null };
|
||||
}
|
||||
|
||||
// Export function to get cached config data for other hooks
|
||||
export function getCachedConfig(): ConfigApiResponse | null {
|
||||
const now = Date.now();
|
||||
const isCacheValid = configCache.data && (now - configCache.timestamp) < CACHE_DURATION;
|
||||
return isCacheValid ? configCache.data : null;
|
||||
}
|
||||
73
src/hooks/useGiteaConfig.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useAuth } from './useAuth';
|
||||
import { apiRequest } from '@/lib/utils';
|
||||
import type { ConfigApiResponse, GiteaConfig } from '@/types/config';
|
||||
import { getCachedConfig } from './useConfigStatus';
|
||||
|
||||
interface GiteaConfigHook {
|
||||
giteaConfig: GiteaConfig | null;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get Gitea configuration data
|
||||
* Uses the same cache as useConfigStatus to prevent duplicate API calls
|
||||
*/
|
||||
export function useGiteaConfig(): GiteaConfigHook {
|
||||
const { user } = useAuth();
|
||||
const [giteaConfigState, setGiteaConfigState] = useState<GiteaConfigHook>({
|
||||
giteaConfig: null,
|
||||
isLoading: true,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const fetchGiteaConfig = useCallback(async () => {
|
||||
if (!user?.id) {
|
||||
setGiteaConfigState({
|
||||
giteaConfig: null,
|
||||
isLoading: false,
|
||||
error: 'User not authenticated',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to get from cache first
|
||||
const cachedConfig = getCachedConfig();
|
||||
if (cachedConfig) {
|
||||
setGiteaConfigState({
|
||||
giteaConfig: cachedConfig.giteaConfig || null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setGiteaConfigState(prev => ({ ...prev, isLoading: true, error: null }));
|
||||
|
||||
const configResponse = await apiRequest<ConfigApiResponse>(
|
||||
`/config?userId=${user.id}`,
|
||||
{ method: 'GET' }
|
||||
);
|
||||
|
||||
setGiteaConfigState({
|
||||
giteaConfig: configResponse?.giteaConfig || null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
} catch (error) {
|
||||
setGiteaConfigState({
|
||||
giteaConfig: null,
|
||||
isLoading: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to fetch Gitea configuration',
|
||||
});
|
||||
}
|
||||
}, [user?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchGiteaConfig();
|
||||
}, [fetchGiteaConfig]);
|
||||
|
||||
return giteaConfigState;
|
||||
}
|
||||
102
src/hooks/useLiveRefresh.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import * as React from "react";
|
||||
import { useState, useEffect, createContext, useContext, useCallback, useRef } from "react";
|
||||
import { usePageVisibility } from "./usePageVisibility";
|
||||
import { useConfigStatus } from "./useConfigStatus";
|
||||
|
||||
interface LiveRefreshContextType {
|
||||
isLiveEnabled: boolean;
|
||||
toggleLive: () => void;
|
||||
registerRefreshCallback: (callback: () => void) => () => void;
|
||||
}
|
||||
|
||||
const LiveRefreshContext = createContext<LiveRefreshContextType | undefined>(undefined);
|
||||
|
||||
const LIVE_REFRESH_INTERVAL = 3000; // 3 seconds
|
||||
const SESSION_STORAGE_KEY = 'gitea-mirror-live-refresh';
|
||||
|
||||
export function LiveRefreshProvider({ children }: { children: React.ReactNode }) {
|
||||
const [isLiveEnabled, setIsLiveEnabled] = useState<boolean>(false);
|
||||
const isPageVisible = usePageVisibility();
|
||||
const { isFullyConfigured } = useConfigStatus();
|
||||
const refreshCallbacksRef = useRef<Set<() => void>>(new Set());
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Load initial state from session storage
|
||||
useEffect(() => {
|
||||
const savedState = sessionStorage.getItem(SESSION_STORAGE_KEY);
|
||||
if (savedState === 'true') {
|
||||
setIsLiveEnabled(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Save state to session storage whenever it changes
|
||||
useEffect(() => {
|
||||
sessionStorage.setItem(SESSION_STORAGE_KEY, isLiveEnabled.toString());
|
||||
}, [isLiveEnabled]);
|
||||
|
||||
// Execute all registered refresh callbacks
|
||||
const executeRefreshCallbacks = useCallback(() => {
|
||||
refreshCallbacksRef.current.forEach(callback => {
|
||||
try {
|
||||
callback();
|
||||
} catch (error) {
|
||||
console.error('Error executing refresh callback:', error);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Setup/cleanup the refresh interval
|
||||
useEffect(() => {
|
||||
// Clear existing interval
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
|
||||
// Only set up interval if live is enabled, page is visible, and configuration is complete
|
||||
if (isLiveEnabled && isPageVisible && isFullyConfigured) {
|
||||
intervalRef.current = setInterval(executeRefreshCallbacks, LIVE_REFRESH_INTERVAL);
|
||||
}
|
||||
|
||||
// Cleanup on unmount
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [isLiveEnabled, isPageVisible, isFullyConfigured, executeRefreshCallbacks]);
|
||||
|
||||
const toggleLive = useCallback(() => {
|
||||
setIsLiveEnabled(prev => !prev);
|
||||
}, []);
|
||||
|
||||
const registerRefreshCallback = useCallback((callback: () => void) => {
|
||||
refreshCallbacksRef.current.add(callback);
|
||||
|
||||
// Return cleanup function
|
||||
return () => {
|
||||
refreshCallbacksRef.current.delete(callback);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const contextValue = {
|
||||
isLiveEnabled,
|
||||
toggleLive,
|
||||
registerRefreshCallback,
|
||||
};
|
||||
|
||||
return React.createElement(
|
||||
LiveRefreshContext.Provider,
|
||||
{ value: contextValue },
|
||||
children
|
||||
);
|
||||
}
|
||||
|
||||
export function useLiveRefresh() {
|
||||
const context = useContext(LiveRefreshContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("useLiveRefresh must be used within a LiveRefreshProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { mirrorApi } from '@/lib/api';
|
||||
import type { MirrorJob } from '@/lib/db/schema';
|
||||
|
||||
export function useMirror() {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [currentJob, setCurrentJob] = useState<MirrorJob | null>(null);
|
||||
const [jobs, setJobs] = useState<MirrorJob[]>([]);
|
||||
|
||||
const startMirror = async (configId: string, repositoryIds?: string[]) => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const job = await mirrorApi.startMirror(configId, repositoryIds);
|
||||
setCurrentJob(job);
|
||||
return job;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to start mirroring');
|
||||
throw err;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getMirrorJobs = async (configId: string) => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const fetchedJobs = await mirrorApi.getMirrorJobs(configId);
|
||||
setJobs(fetchedJobs);
|
||||
return fetchedJobs;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch mirror jobs');
|
||||
throw err;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getMirrorJob = async (jobId: string) => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const job = await mirrorApi.getMirrorJob(jobId);
|
||||
setCurrentJob(job);
|
||||
return job;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch mirror job');
|
||||
throw err;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const cancelMirrorJob = async (jobId: string) => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await mirrorApi.cancelMirrorJob(jobId);
|
||||
if (result.success && currentJob?.id === jobId) {
|
||||
setCurrentJob({ ...currentJob, status: 'failed' });
|
||||
}
|
||||
return result;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to cancel mirror job');
|
||||
throw err;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
isLoading,
|
||||
error,
|
||||
currentJob,
|
||||
jobs,
|
||||
startMirror,
|
||||
getMirrorJobs,
|
||||
getMirrorJob,
|
||||
cancelMirrorJob,
|
||||
};
|
||||
}
|
||||
28
src/hooks/usePageVisibility.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
/**
|
||||
* Hook to detect if the page/tab is currently visible
|
||||
* Returns false when user switches to another tab or minimizes the window
|
||||
*/
|
||||
export function usePageVisibility(): boolean {
|
||||
const [isVisible, setIsVisible] = useState<boolean>(true);
|
||||
|
||||
useEffect(() => {
|
||||
const handleVisibilityChange = () => {
|
||||
setIsVisible(!document.hidden);
|
||||
};
|
||||
|
||||
// Set initial state
|
||||
setIsVisible(!document.hidden);
|
||||
|
||||
// Listen for visibility changes
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return isVisible;
|
||||
}
|
||||
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,
|
||||
};
|
||||
}
|
||||