Compare commits

..

151 Commits

Author SHA1 Message Date
Arunavo Ray
4d8d75c8a6 chore: bump version to v2.17.0 2025-06-24 12:13:46 +05:30
Arunavo Ray
e6c4ca0731 feat: implement InlineDestinationEditor for repository destination management and add API support for updating destination organization 2025-06-24 12:11:10 +05:30
Arunavo Ray
f03405b87a feat: add Gitea organization URL support and update button tooltip logic 2025-06-24 11:45:35 +05:30
Arunavo Ray
ce367e3761 feat: implement MirrorDestinationEditor component for customizable organization destination 2025-06-24 11:33:02 +05:30
Arunavo Ray
cfe65cadca feat: add organization destination update API and enhance organization list editing functionality 2025-06-24 11:16:44 +05:30
Arunavo Ray
68108b8383 feat: add personal repositories organization override and update related configurations 2025-06-24 11:02:57 +05:30
Arunavo Ray
d2bec1d56e chore: bump version to v2.16.3 2025-06-20 11:16:27 +05:30
Arunavo Ray
c9404f2674 feat: implement custom 404 page with NotFound component and navigation options 2025-06-20 11:12:09 +05:30
Arunavo Ray
80ef19c634 feat: add openDelay prop to HoverCard in GitHubConfigForm and OrganizationStrategy components 2025-06-20 11:00:36 +05:30
Arunavo Ray
83c924566c feat: add HoverCard component and replace Popover usage in GitHubConfigForm and OrganizationStrategy 2025-06-20 10:58:46 +05:30
Arunavo Ray
7b58df375e chore: bump version to v2.16.2 2025-06-17 17:23:44 +05:30
Arunavo Ray
1d27bd31d8 feat: add bulk actions for repository management with selection support 2025-06-17 17:22:38 +05:30
Arunavo Ray
13d4257c4f refactor: enhance organization card display with status badges and improved layout 2025-06-17 16:52:15 +05:30
Arunavo Ray
818ba77693 chore: bump version to v2.16.1 2025-06-17 15:50:42 +05:30
Arunavo Ray
056970e577 refactor: improve repository owner handling and mirror strategy in Gitea integration 2025-06-17 15:44:04 +05:30
Arunavo Ray
65ea73e238 refactor: update label for starred repositories organization for consistency 2025-06-17 15:32:43 +05:30
Arunavo Ray
398f00aceb chore: bump version to v2.16.0 2025-06-17 15:16:10 +05:30
Arunavo Ray
50972713a3 refactor: improve version comparison logic in health API 2025-06-17 15:11:05 +05:30
Arunavo Ray
fbf3033455 refactor: remove ConnectionsForm and useMirror hook; update issue mirroring logic for starred repos 2025-06-17 15:06:54 +05:30
Arunavo Ray
cc4d8dabbc refactor: enhance starred repos content selection and improve layout 2025-06-17 14:54:23 +05:30
Arunavo Ray
8bba3d3521 chore: bump version to v2.15.0 2025-06-17 14:37:47 +05:30
Arunavo Ray
be63555e5c refactor: update titles and descriptions in strategyConfig for clarity 2025-06-17 14:34:17 +05:30
Arunavo Ray
32a906369f refactor: remove unused Building2 import in OrganizationConfiguration component 2025-06-17 14:28:49 +05:30
Arunavo Ray
064474fd13 refactor: remove unused tooltip imports in OrganizationStrategy component 2025-06-17 14:27:38 +05:30
Arunavo Ray
2ac933b599 refactor: streamline layout and improve tooltip descriptions in OrganizationConfiguration component 2025-06-17 14:26:52 +05:30
Arunavo Ray
403fe08bae refactor: update icons in GitHubMirrorSettings and OrganizationConfiguration components for improved clarity 2025-06-17 14:16:15 +05:30
Arunavo Ray
23c7ff7349 refactor: enhance layout and flexibility in GitHubConfigForm and GiteaConfigForm components 2025-06-17 14:05:10 +05:30
Arunavo Ray
3169af44cb refactor: improve layout and spacing in GitHubMirrorSettings component 2025-06-17 13:49:53 +05:30
Arunavo Ray
c1d93dbbc6 feat: enhance GitHubMirrorSettings with improved layout and metadata options 2025-06-17 13:43:17 +05:30
Arunavo Ray
047719cde9 feat: add OrganizationConfiguration component and integrate it into GiteaConfigForm 2025-06-17 13:20:03 +05:30
Arunavo Ray
13d4b03541 refactor: simplify OrganizationStrategy component by removing unused imports and details 2025-06-17 13:12:12 +05:30
Arunavo Ray
f07ae220b0 refactor: clean up imports and improve layout in OrganizationStrategy component 2025-06-17 12:59:29 +05:30
Arunavo Ray
01647445f2 Improved layout in larger screens 2025-06-17 12:48:38 +05:30
Arunavo Ray
13cbf86309 Updated Layout 2025-06-17 12:42:32 +05:30
Arunavo Ray
792096d209 Updates to Organisation Strategy Layouts 2025-06-17 11:39:41 +05:30
Arunavo Ray
e94de5c9ca fix: mirror to one org issue 2025-06-17 11:35:09 +05:30
Arunavo Ray
b3f42624d8 chore: bump version to v2.14.0 2025-06-17 10:34:59 +05:30
Arunavo Ray
d79e4fecf4 Updated Docker compose dev 2025-06-17 10:30:33 +05:30
Arunavo Ray
eb78f959c7 Updated Docs 2025-06-17 09:47:46 +05:30
Arunavo Ray
51e536c317 feat: add @radix-ui/react-accordion dependency 2025-06-17 09:35:14 +05:30
Arunavo Ray
7af1f6da17 fix: implement proper mirror strategies for starred and org repos 2025-06-17 09:26:26 +05:30
Arunavo Ray
c7e310b340 updated instructions 2025-06-16 00:35:48 +05:30
Arunavo Ray
23cfa45d89 chore: bump version to v2.13.2 2025-06-16 00:33:28 +05:30
Arunavo Ray
b1346e8c77 Updated Docs and Readme 2025-06-16 00:28:55 +05:30
Arunavo Ray
6e673249dc Docs Design updated 2025-06-16 00:01:45 +05:30
Arunavo Ray
ee801f5d0e chore: bump version to v2.13.1
- Updated package.json version from 2.13.0 to 2.13.1
- Added CHANGELOG.md entry for v2.13.1 with Docker workflow improvements
- Includes Docker Scout migration, CI/CD enhancements, and package updates
2025-06-15 23:06:05 +05:30
Arunavo Ray
caf680d999 fix: add wait step for image availability in Docker build workflow 2025-06-15 15:10:48 +05:30
Arunavo Ray
214599a5fd Dont fail workflow on security issues 2025-06-15 15:03:54 +05:30
Arunavo Ray
9e2285d614 fix: update Docker Scout image references and add wait step for image availability 2025-06-15 14:43:31 +05:30
Arunavo Ray
7f7e510400 fix: resolve Docker Scout image reference issues
- Use specific SHA-based image tags instead of multi-line tags output
- Add separate Docker Scout steps for push vs pull request workflows
- Use local image reference for PR scanning (local://gitea-mirror:scan)
- Optimize PR builds to single platform (linux/amd64) for faster scanning
- Maintain multi-platform builds for production pushes
2025-06-15 14:38:25 +05:30
Arunavo Ray
d1aa8810f7 fix: update Docker Scout action to use the latest version 2025-06-15 14:35:35 +05:30
Arunavo Ray
bfa4b4034c feat: add Docker Hub authentication for Docker Scout
- Add optional Docker Hub login for enhanced vulnerability data
- Use continue-on-error to make Docker Hub auth optional
- Requires DOCKERHUB_USERNAME and DOCKERHUB_TOKEN secrets for full functionality
2025-06-15 14:23:44 +05:30
Arunavo Ray
8fbde95f92 feat: consolidate Docker workflows into comprehensive build, push & security scan
- Merge docker-build.yml and docker-scan.yml into single efficient workflow
- Add comprehensive Docker Scout security analysis with multiple commands
- Include vulnerability scanning, recommendations, and policy evaluation
- Add comparison to latest for pull requests
- Maintain SARIF output for GitHub Security tab integration
- Add proper path-based triggers and scheduled weekly scans
- Remove redundant docker-scan.yml workflow
2025-06-15 14:20:48 +05:30
Arunavo Ray
00fb66baa7 fix: add workflow file to Docker scan triggers to enable testing 2025-06-15 14:15:27 +05:30
Arunavo Ray
5fec1e6a58 fix: update Docker Scout action to specific version v1.18.1 2025-06-15 14:14:16 +05:30
Arunavo Ray
2ec55c6070 Migrate from Trivy to Docker scout 2025-06-15 14:11:45 +05:30
Arunavo Ray
546bda8514 Updated packages 2025-06-15 14:04:12 +05:30
Arunavo Ray
d05847dfe8 Wrong bun verison 2025-06-15 13:52:45 +05:30
Arunavo Ray
6551ea719c fix: update Bun base image and enhance security scanning
- Update Bun from 1.2.14 to 1.2.18 to address CVE-2025-22874
- Pin Trivy action to stable version (0.28.0)
- Add SARIF output for GitHub Security tab integration
- Set ignore-unfixed to false for comprehensive vulnerability detection
- Add security-events permission for uploading scan results
- Include fallback table output on scan failures
2025-06-15 13:48:58 +05:30
Arunavo Ray
ae57b1b320 docs: update CHANGELOG for v2.13.0 release 2025-06-15 13:33:44 +05:30
Arunavo Ray
4d3ad2a337 chore: bump version to 2.13.0 2025-06-15 13:32:35 +05:30
Arunavo Ray
e9c12bb9ff feat: enhance API config handling by adding mapping functions for UI and database structures 2025-06-15 13:31:06 +05:30
Arunavo Ray
42314ab0e3 feat: add collapsible component and integrate it into OrganizationStrategy for improved UI 2025-06-15 13:08:39 +05:30
Arunavo Ray
1be53bfa87 refactor: simplify imports and enhance styling in AutomationSettings component 2025-06-15 13:00:08 +05:30
Arunavo Ray
e8d48376a0 Updated Automation & Maintainence 2025-06-15 12:56:25 +05:30
Arunavo Ray
0cdb386f56 Re-Organsied options 2025-06-15 12:35:26 +05:30
Arunavo Ray
7456fe3fae Fixed contrast issues 2025-06-15 12:22:38 +05:30
Arunavo Ray
f4df7c3d19 Added some basic options 2025-06-15 12:15:14 +05:30
Arunavo Ray
544b60f881 refactor: update Card components to use self-start class for consistent alignment 2025-06-12 15:29:47 +05:30
Arunavo Ray
2eda800a7c refactor: update test files to use bun:test and remove vitest configuration 2025-06-12 10:24:55 +05:30
Arunavo Ray
51de51baa0 feat: add permissions section to workflows for consistent access control 2025-06-12 10:13:41 +05:30
Arunavo Ray
0d60c2fdf1 feat: implement createSecureErrorResponse for consistent error handling across API routes 2025-06-12 09:50:43 +05:30
Arunavo Ray
df8dac0e9b feat: add ConnectionsForm and ScheduleAndCleanupForm components with configuration forms 2025-06-11 22:17:31 +05:30
Arunavo Ray
8e0c31fbb9 feat: add ScrollArea and Separator components with Radix UI integration 2025-06-11 22:16:30 +05:30
Arunavo Ray
2c815b13f0 Added accordion 2025-06-11 21:51:39 +05:30
Arunavo Ray
bbd49d7d52 Tabs Config 2025-06-11 21:43:43 +05:30
Arunavo Ray
8f62da4572 Replace logo in LoginForm, SignupForm, and Header components with light and dark mode images 2025-06-11 20:36:07 +05:30
Arunavo Ray
0f671a4088 feat: add support for mirroring wiki pages in configuration 2025-06-11 19:48:24 +05:30
Arunavo Ray
108408be81 fix: update Proxmox VE installation script references in README files 2025-06-05 23:27:56 +05:30
Arunavo Ray
e24b856416 chore: bump version to 2.12.0
- 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
- 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
2025-06-02 15:08:10 +05:30
Arunavo Ray
612805f030 feat: add table creation and existence check for database initialization 2025-06-02 15:05:20 +05:30
Arunavo Ray
7705dffee0 chore: bump version to 2.11.2 2025-05-28 20:29:23 +05:30
Arunavo Ray
3dceb34174 feat: replace SiGitea icons with custom logo
- Replace SiGitea icon with custom logo.svg in LoginForm and Header components
- Add custom logo.svg file with theme-aware styling
- Update favicon.svg to use the same custom logo design
- Remove unused SiGitea imports and clean up dependencies
- Logo automatically adapts to light/dark themes via CSS media queries
2025-05-28 20:28:59 +05:30
Arunavo Ray
6b747ba891 chore: bump version to 2.11.1 2025-05-28 19:58:46 +05:30
Arunavo Ray
ddd67faeab fix: resolve repository mirroring status inconsistencies
- Fix 'already exists, skipping migration' logic that left repositories with incorrect 'imported' status
- Update database status to 'mirrored' when repository already exists in Gitea
- Add automatic startup repair to fix existing inconsistencies on container start
- Create diagnostic and repair tools for troubleshooting mirroring issues
- Ensure consistent state between Gitea and application database

Resolves issue where repositories showed successful mirroring logs but remained
in 'imported' status, causing UI confusion and preventing proper status tracking.

Changes:
- src/lib/gitea.ts: Fixed mirrorGithubRepoToGitea() and mirrorGitHubRepoToGiteaOrg()
- docker-entrypoint.sh: Added automatic repository status repair on startup
- scripts/investigate-repo.ts: New diagnostic tool for repository analysis
- scripts/repair-mirrored-repos.ts: New repair tool with startup mode support
- scripts/cleanup-duplicate-repos.ts: New tool for removing duplicate entries

Fixes multiple user reports of misleading 'successfully mirrored' logs
while repositories remained in inconsistent state.
2025-05-28 19:58:15 +05:30
Arunavo Ray
832b57538d chore: bump version to 2.11.0 2025-05-28 14:08:45 +05:30
Arunavo Ray
415bff8e41 feat: enhance Organizations page with live refresh and fix repository breakdown bug
- Add live refresh functionality to Organizations page using the same pattern as Repositories and Activity Log pages
- Fix repository breakdown bug where public/private/fork counts disappeared after toggling mirroring
- Change toggle text from 'Include in mirroring' to 'Enable mirroring' for better clarity
- Automatically refresh organization data after mirroring starts to maintain breakdown visibility
- Clean up unused imports and variables for better code quality
2025-05-28 14:08:07 +05:30
Arunavo Ray
13c3ddea04 Added a small gap to Verison Info 2025-05-28 13:55:50 +05:30
Arunavo Ray
b917b30830 docs: add Docker bind mount vs named volume permission guidance
- Add new section 'Docker Volume Types and Permissions'
- Explain difference between named volumes and bind mounts
- Provide solution for bind mount permission issues (UID 1001)
- Clarify why named volumes are recommended and used in official docker-compose.yml
- Address SQLite permission errors in Docker environments using bind mounts

Addresses issue reported by user using bind mounts in Portainer.
2025-05-28 13:37:07 +05:30
Arunavo Ray
b34ed5595b chore: bump version to 2.10.0 2025-05-28 13:27:04 +05:30
Arunavo Ray
cbc11155ef fix: resolve organizations getting stuck on mirroring status when empty
- Fixed mirrorGitHubOrgToGitea function to properly handle empty organizations
- Organizations with no repositories now transition from 'mirroring' to 'mirrored' status
- Enhanced logging with clearer messages for empty organization processing
- Improved activity log details to distinguish between empty and non-empty orgs
- Added comprehensive test coverage for empty organization scenarios
- Ensures consistent status lifecycle for all organizations regardless of repository count
2025-05-28 13:26:20 +05:30
Arunavo Ray
941f61830f feat: implement comprehensive auto-save for all config forms and remove manual save button
- Add auto-save functionality to all GitHub config form fields (text inputs and checkboxes)
- Add auto-save functionality to all Gitea config form fields (text inputs and select dropdown)
- Extend existing auto-save pattern to cover text inputs with 500ms debounce
- Remove Save Configuration button and related manual save logic
- Update Import GitHub Data button to depend on form validation instead of saved state
- Remove isConfigSaved dependency from all auto-save functions for immediate activation
- Add proper cleanup for all auto-save timeouts on component unmount
- Maintain silent auto-save operation without intrusive notifications

All configuration changes now auto-save seamlessly, providing a better UX while maintaining data consistency and error handling.
2025-05-28 13:17:48 +05:30
Arunavo Ray
5b60cffaae Add fork tags to repository UI and enhance organization cards with repository breakdown
- Add fork tags to repository table and dashboard list components
- Display 'Fork' badge for repositories where isForked is true
- Enhance organization cards to show breakdown of public, private, and fork repositories
- Update organization API to respect user configuration filters (private repos, forks)
- Add visual indicators with colored dots for each repository type
- Ensure consistent filtering between repository and organization APIs
- Fix issue where private repositories weren't showing due to configuration filtering
2025-05-28 12:53:32 +05:30
Arunavo Ray
ede5b4dbe8 feat: enhance toast error messages with structured parsing
- Add parseErrorMessage() utility to parse JSON error responses
- Add showErrorToast() helper for consistent error display
- Update all toast.error calls to use structured error parsing
- Support multiple error formats: error+troubleshooting, title+description, message+details
- Enhance apiRequest() to support both 'body' and 'data' properties
- Add comprehensive unit tests for error parsing functionality
- Improve user experience with clear, actionable error messages

Fixes structured error messages from Gitea API responses that were showing as raw JSON
2025-05-28 11:11:28 +05:30
Arunavo Ray
99336e2607 chore: bump version to 2.9.2 2025-05-28 10:15:43 +05:30
Arunavo Ray
cba421d606 feat: enhance error logging for better debugging of JSON parsing issues
- Add comprehensive error logging in mirror-repo API endpoint
- Enhance HTTP client error handling with detailed response information
- Improve concurrency utility error reporting with context
- Add specific detection and guidance for JSON parsing errors
- Include troubleshooting information in error responses
- Update tests to accommodate enhanced logging

This will help users diagnose issues like 'JSON Parse error: Unexpected EOF'
by providing detailed information about what responses are being received
from the Gitea API and what might be causing the failures.
2025-05-28 10:13:41 +05:30
Arunavo Ray
c4b9a82806 chore: bump version to 2.9.1 2025-05-28 10:00:14 +05:30
Arunavo Ray
38e0fb33b9 fix: resolve JSON parsing error and standardize HTTP client usage
- Fix JSON parsing error in getOrCreateGiteaOrg function (#19)
  - Add content-type validation before JSON parsing
  - Add response cloning for better error debugging
  - Enhance error messages with actual response content
  - Add comprehensive logging for troubleshooting

- Standardize HTTP client usage across codebase
  - Create new http-client.ts utility with consistent error handling
  - Replace all superagent calls with fetch-based functions
  - Replace all axios calls with fetch-based functions
  - Remove superagent, axios, and @types/superagent dependencies
  - Update tests to mock new HTTP client
  - Maintain backward compatibility

- Benefits:
  - Smaller bundle size (removed 3 HTTP client libraries)
  - Better performance (leveraging Bun's optimized fetch)
  - Consistent error handling across all HTTP operations
  - Improved debugging with detailed error messages
  - Easier maintenance with single HTTP client pattern
2025-05-28 09:56:59 +05:30
Arunavo Ray
22a4b71653 docs: add SQLite permission troubleshooting for direct installation
- Add new section 'Database Permissions for Direct Installation' to README
- Explain common SQLite permission errors when running without Docker
- Provide secure permission fixes (chmod 755/644 instead of 777)
- Clarify why Docker deployment avoids these issues
- Recommend Docker/Docker Compose as preferred deployment method

Addresses permission issues reported by users running the application directly on their systems.
2025-05-28 09:21:01 +05:30
ARUNAVO RAY
52568eda36 Merge pull request #21 from arunavo4/18-meta-docs-links-are-broken
fix: correct broken documentation links in README
2025-05-28 09:11:59 +05:30
Arunavo Ray
a84191f0a5 fix: correct broken documentation links in README
- Fix Quick Start Guide link to point to src/content/docs/quickstart.md
- Fix Configuration Guide link to point to src/content/docs/configuration.md
- Links were previously pointing to non-existent docs/ directory
2025-05-28 09:10:38 +05:30
Arunavo Ray
33829eda20 fix: update image sizes in README for better display on dashboard 2025-05-25 11:08:36 +05:30
Arunavo Ray
1e63fd2278 feat: implement graceful shutdown and enhanced job recovery
- Add comprehensive graceful shutdown manager with signal handling
- Implement container-aware shutdown with proper signal forwarding
- Add shutdown-aware job processing with automatic state persistence
- Enhance cleanup service with proper shutdown coordination
- Add integration tests for graceful shutdown functionality
- Update Docker entrypoint for proper signal handling
- Add comprehensive documentation for shutdown process

Features:
- Fast shutdown (under 30 seconds) without waiting for job completion
- Automatic job state saving and recovery after restart
- Support for SIGTERM, SIGINT, SIGHUP signals
- Container orchestrator compatibility (Docker, Kubernetes)
- Zero data loss during container lifecycle events
- Detailed logging and monitoring capabilities

Version: 2.9.0
2025-05-24 23:10:38 +05:30
Arunavo Ray
daf4ab6a93 feat: Implement graceful shutdown and enhanced job recovery
- Added shutdown handler in docker-entrypoint.sh to manage application termination signals.
- Introduced shutdown manager to track active jobs and ensure state persistence during shutdown.
- Enhanced cleanup service to support stopping and status retrieval.
- Integrated signal handlers for proper response to termination signals (SIGTERM, SIGINT, SIGHUP).
- Updated middleware to initialize shutdown manager and cleanup service.
- Created integration tests for graceful shutdown functionality, verifying job state preservation and recovery.
- Documented graceful shutdown process and configuration in GRACEFUL_SHUTDOWN.md and SHUTDOWN_PROCESS.md.
- Added new scripts for testing shutdown behavior and cleanup.
2025-05-24 23:06:28 +05:30
Arunavo Ray
4404af7d40 refactor: remove live refresh registration from Organization component to streamline loading logic 2025-05-24 21:10:54 +05:30
Arunavo Ray
97ff8d190d refactor: update ActivityList and ActivityLog components to improve loading state management and add live active indicator 2025-05-24 21:06:53 +05:30
Arunavo Ray
3ff86de67d refactor: improve loading state management and add live active indicator in RepositoryTable 2025-05-24 21:00:27 +05:30
Arunavo Ray
3d8bdff9af refactor: enhance live refresh button tooltip and update button state logic 2025-05-24 20:22:39 +05:30
Arunavo Ray
a28a766f8b refactor: update cleanup and schedule config to use seconds for retentionDays and improve nextRun calculation 2025-05-24 20:12:27 +05:30
Arunavo Ray
7afe364a24 refactor: improve layout of Last Run and Next Run fields in Database and Schedule config forms 2025-05-24 19:23:40 +05:30
Arunavo Ray
a4e771d3bd feat: replace mirror icon with FlipHorizontal in RepoActionButton component 2025-05-24 19:16:17 +05:30
Arunavo Ray
703156b15c fix: update success message for GitHub data import to direct users to the Repositories page 2025-05-24 19:13:47 +05:30
Arunavo Ray
20a771f340 chore: bump version to 2.8.0 2025-05-24 18:49:30 +05:30
Arunavo Ray
d925b3c155 refactor: Remove unnecessary console logs from event polling and retrieval 2025-05-24 18:41:55 +05:30
Arunavo Ray
47e1c7b493 feat: Implement automatic database cleanup feature with configuration options and API support 2025-05-24 18:33:59 +05:30
Arunavo Ray
d7ce2a6908 feat: Refactor database cleanup process by removing scripts and updating documentation to use the Activity Log for event management 2025-05-24 17:58:37 +05:30
Arunavo Ray
4efe741c64 Bump version to 2.7.0 2025-05-24 17:39:23 +05:30
Arunavo Ray
773842fa72 feat: Improve cron job setup for automatic database cleanup with better error handling 2025-05-24 17:37:36 +05:30
Arunavo Ray
90944a40c6 feat: Remove health API tests to streamline codebase 2025-05-24 16:14:22 +05:30
Arunavo Ray
f436737efb Bump version to 2.6.0 2025-05-24 15:41:12 +05:30
Arunavo Ray
a4e4afdaaf feat: Enhance RepositoryTable layout by adding Links column and adjusting action button alignment 2025-05-24 15:32:59 +05:30
Arunavo Ray
cbef04d4b4 feat: Add Gitea configuration hook and enhance repository list with Gitea links 2025-05-24 15:18:31 +05:30
Arunavo Ray
a988be1028 feat: Implement comprehensive job recovery and resume process improvements
- Added a startup recovery script to handle interrupted jobs before application startup.
- Enhanced recovery system with database connection validation and stale job cleanup.
- Improved middleware to check for recovery needs and handle recovery during requests.
- Updated health check endpoint to include recovery system status and metrics.
- Introduced test scripts for verifying recovery functionality and job state management.
- Enhanced logging and error handling throughout the recovery process.
2025-05-24 13:45:25 +05:30
Arunavo Ray
98610482ae feat: enhance event management by adding duplicate removal, cleanup functionality, and improving activity logging 2025-05-24 13:25:58 +05:30
Arunavo Ray
546db472e5 feat: enhance navigation handling by updating navigation key and improving page state management 2025-05-24 12:52:02 +05:30
Arunavo Ray
70b3e412ad feat: implement navigation context and enhance component loading states across the application 2025-05-24 12:51:57 +05:30
Arunavo Ray
a3ac31795c refactor: remove problematic useEffect to prevent circular dependencies and optimize user data fetching 2025-05-24 12:24:04 +05:30
Arunavo Ray
f41fb9b91f feat: adjust height of RepositoryTable and add status bar to display filtered repository count 2025-05-24 11:37:05 +05:30
Arunavo Ray
0b568a3b37 feat: implement auto-save functionality for schedule config changes and enhance UI with loading indicator 2025-05-24 11:31:40 +05:30
Arunavo Ray
a1da82a718 refactor: simplify ConfigCardSkeleton structure and enhance layout in ConfigTabs component 2025-05-24 10:53:33 +05:30
Arunavo Ray
645d495e80 refactor: remove Docker configuration generation and clipboard copy functionality from ConfigTabs component 2025-05-24 10:48:43 +05:30
Arunavo Ray
0890ed0bb8 feat: add live refresh functionality and configuration status hooks; enhance UI components with new switch and refresh features 2025-05-24 10:24:25 +05:30
Arunavo Ray
fc985f29df fix: update base image version in Dockerfile and remove cron installation 2025-05-23 16:07:42 +05:30
Arunavo Ray
7d32112369 feat: implement automatic database cleanup with cron jobs for events and mirror jobs 2025-05-23 12:15:34 +05:30
Arunavo Ray
3bb85a4cdb chore: bump version to 2.5.4 2025-05-23 11:43:36 +05:30
Arunavo Ray
30182544ba feat: enhance ActivityLog and ActivityList components with key generation and filtering improvements 2025-05-23 11:29:39 +05:30
Arunavo Ray
fb73f33aeb fix: update healthcheck endpoints for Gitea services in docker-compose files 2025-05-22 22:48:33 +05:30
Arunavo Ray
48f63bdfc8 Release v2.5.3 2025-05-22 21:59:21 +05:30
Arunavo Ray
e2506a874e feat: enhance JWT_SECRET handling with auto-generation and persistence 2025-05-22 20:58:22 +05:30
Arunavo Ray
b67473ec7e refactor: update Proxmox LXC deployment instructions and replace deprecated script 2025-05-22 20:35:18 +05:30
Arunavo Ray
4ca4356ad1 Release v2.5.2 2025-05-22 19:54:07 +05:30
Arunavo Ray
3136a2120d feat: extract version from package.json and set as environment variable 2025-05-22 19:48:42 +05:30
Arunavo Ray
615ebd5079 Release 2.5.1: Fix Docker entrypoint to prevent unnecessary bun install 2025-05-22 19:33:07 +05:30
Arunavo Ray
6e48d3f86c refactor: remove bun setup for pre-built images and streamline database initialization scripts 2025-05-22 19:29:15 +05:30
Arunavo Ray
c5de7e616d chore: bump version to 2.5.0 2025-05-22 18:53:43 +05:30
Arunavo Ray
309f8c4341 feat: add version information component and integrate version check in health API 2025-05-22 18:51:11 +05:30
ARUNAVO RAY
0c596ac241 Merge pull request #15 from arunavo4/parallel-jobs
Implement parallel processing
2025-05-22 18:18:14 +05:30
Arunavo Ray
894be88a28 feat: migrate testing framework to Bun and update test configurations
- Updated GitHub Actions workflow to use Bun's test runner and coverage reporting.
- Added comprehensive testing documentation for the Gitea Mirror project.
- Refactored test scripts in package.json to align with Bun's testing commands.
- Created new test files for database, Gitea, GitHub, health, and mirroring APIs.
- Implemented mock functions for API tests to handle various scenarios and responses.
- Established a test setup file for consistent test environment configuration.
2025-05-22 18:08:51 +05:30
Arunavo Ray
6ab7f0a5a0 fix: add missing --bun flag to vitest and astro build commands 2025-05-22 16:44:14 +05:30
Arunavo Ray
abe3113755 feat: enhance job resilience with new database schema and recovery mechanisms
- Added new fields to the mirror_jobs table for job resilience, including job_type, batch_id, total_items, completed_items, item_ids, completed_item_ids, in_progress, started_at, completed_at, and last_checkpoint.
- Implemented database migration scripts to update the mirror_jobs table schema.
- Introduced processWithResilience utility for handling item processing with checkpointing and recovery capabilities.
- Updated API routes for mirroring organizations and repositories to utilize the new resilience features.
- Created recovery system to detect and resume interrupted jobs on application startup.
- Added middleware to initialize the recovery system when the server starts.
2025-05-22 14:33:03 +05:30
Arunavo Ray
f4bc28e6c2 Implement parallel processing with retry logic for repository mirroring and syncing operations 2025-05-22 13:28:28 +05:30
157 changed files with 16915 additions and 3592 deletions

View 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.

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

After

Width:  |  Height:  |  Size: 816 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 164 KiB

After

Width:  |  Height:  |  Size: 891 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 194 KiB

After

Width:  |  Height:  |  Size: 943 KiB

BIN
.github/assets/logo-no-bg.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 784 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 170 KiB

After

Width:  |  Height:  |  Size: 970 KiB

View File

@@ -12,6 +12,10 @@ on:
- 'README.md'
- 'docs/**'
permissions:
contents: read
actions: read
jobs:
build-and-test:
name: Build and Test Astro Project
@@ -38,10 +42,10 @@ jobs:
bun install
- name: Run tests
run: bunx vitest run
run: bun test --coverage
- name: Build Astro project
run: bunx astro build
run: bunx --bun astro build
- name: Upload build artifacts
uses: actions/upload-artifact@v4

View File

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

View File

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

207
CHANGELOG.md Normal file
View File

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

128
CLAUDE.md Normal file
View 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/`

View File

@@ -1,8 +1,8 @@
# syntax=docker/dockerfile:1.4
FROM oven/bun:1.2.9-alpine AS base
FROM oven/bun:1.2.16-alpine AS base
WORKDIR /app
RUN apk add --no-cache libc6-compat python3 make g++ gcc wget sqlite
RUN apk add --no-cache libc6-compat python3 make g++ gcc wget sqlite openssl
# ----------------------------
FROM base AS deps

204
README.md
View File

@@ -1,5 +1,5 @@
<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">
@@ -14,14 +14,14 @@
```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)
curl -fsSL https://raw.githubusercontent.com/arunavo4/gitea-mirror/main/scripts/gitea-mirror-lxc-proxmox.sh | bash
# For Proxmox VE (online) - Community script by Tobias ([CrazyWolf13](https://github.com/CrazyWolf13))
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 +30,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 +51,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 +70,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 +116,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
@@ -162,40 +163,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
# Optional env overrides: CTID HOSTNAME STORAGE DISK_SIZE CORES MEMORY BRIDGE IP_CONF
curl -fsSL https://raw.githubusercontent.com/arunavo4/gitea-mirror/main/scripts/gitea-mirror-lxc-proxmox.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.
@@ -232,8 +225,10 @@ The Docker container can be configured with the following environment variables:
- `DATABASE_URL`: SQLite database URL (default: `file:data/gitea-mirror.db`)
- `HOST`: Host to bind to (default: `0.0.0.0`)
- `PORT`: Port to listen on (default: `4321`)
- `JWT_SECRET`: Secret key for JWT token generation (important for security)
- `JWT_SECRET`: Secret key for JWT token generation (auto-generated if not provided)
> [!TIP]
> For security, Gitea Mirror will automatically generate a secure random JWT secret on first run if one isn't provided or if the default value is used. This generated secret is stored in the data directory for persistence across container restarts.
#### Manual Installation
@@ -279,9 +274,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:
@@ -293,6 +318,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
@@ -330,13 +406,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
```
@@ -467,7 +543,7 @@ Try the following steps:
> ghcr.io/arunavo4/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
@@ -475,10 +551,40 @@ Try the following steps:
> 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
@@ -495,20 +601,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]
@@ -539,3 +634,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)

933
bun.lock

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,7 @@
# 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}
build:
@@ -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:

View File

@@ -5,12 +5,36 @@ set -e
# Ensure data directory exists
mkdir -p /app/data
# If bun is available, run setup (for dev images)
if command -v bun >/dev/null 2>&1; then
echo "Running bun setup (if needed)..."
bun run setup || true
# Generate a secure JWT secret if one isn't provided or is using the default value
JWT_SECRET_FILE="/app/data/.jwt_secret"
if [ "$JWT_SECRET" = "your-secret-key-change-this-in-production" ] || [ -z "$JWT_SECRET" ]; then
# Check if we have a previously generated secret
if [ -f "$JWT_SECRET_FILE" ]; then
echo "Using previously generated JWT secret"
export JWT_SECRET=$(cat "$JWT_SECRET_FILE")
else
echo "Generating a secure random JWT secret"
# Try to generate a secure random string using OpenSSL
if command -v openssl >/dev/null 2>&1; then
GENERATED_SECRET=$(openssl rand -hex 32)
else
# Fallback to using /dev/urandom if openssl is not available
echo "OpenSSL not found, using fallback method for random generation"
GENERATED_SECRET=$(head -c 32 /dev/urandom | sha256sum | cut -d' ' -f1)
fi
export JWT_SECRET="$GENERATED_SECRET"
# Save the secret to a file for persistence across container restarts
echo "$GENERATED_SECRET" > "$JWT_SECRET_FILE"
chmod 600 "$JWT_SECRET_FILE"
fi
echo "JWT_SECRET has been set to a secure random value"
fi
# Skip dependency installation entirely for pre-built images
# Dependencies are already installed during the Docker build process
# Initialize the database if it doesn't exist
if [ ! -f "/app/data/gitea-mirror.db" ]; then
echo "Initializing database..."
@@ -18,6 +42,8 @@ if [ ! -f "/app/data/gitea-mirror.db" ]; then
bun dist/scripts/init-db.js
elif [ -f "dist/scripts/manage-db.js" ]; then
bun dist/scripts/manage-db.js init
elif [ -f "scripts/manage-db.ts" ]; then
bun scripts/manage-db.ts init
else
echo "Warning: Could not find database initialization scripts in dist/scripts."
echo "Creating and initializing database manually..."
@@ -111,9 +137,28 @@ if [ ! -f "/app/data/gitea-mirror.db" ]; then
status TEXT NOT NULL DEFAULT 'imported',
message TEXT NOT NULL,
timestamp TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
-- New fields for job resilience
job_type TEXT NOT NULL DEFAULT 'mirror',
batch_id TEXT,
total_items INTEGER,
completed_items INTEGER DEFAULT 0,
item_ids TEXT, -- JSON array as text
completed_item_ids TEXT DEFAULT '[]', -- JSON array as text
in_progress INTEGER NOT NULL DEFAULT 0, -- Boolean as integer
started_at TIMESTAMP,
completed_at TIMESTAMP,
last_checkpoint TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
);
CREATE INDEX IF NOT EXISTS idx_mirror_jobs_user_id ON mirror_jobs(user_id);
CREATE INDEX IF NOT EXISTS idx_mirror_jobs_batch_id ON mirror_jobs(batch_id);
CREATE INDEX IF NOT EXISTS idx_mirror_jobs_in_progress ON mirror_jobs(in_progress);
CREATE INDEX IF NOT EXISTS idx_mirror_jobs_job_type ON mirror_jobs(job_type);
CREATE INDEX IF NOT EXISTS idx_mirror_jobs_timestamp ON mirror_jobs(timestamp);
CREATE TABLE IF NOT EXISTS events (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
@@ -136,12 +181,96 @@ else
bun dist/scripts/fix-db-issues.js
elif [ -f "dist/scripts/manage-db.js" ]; then
bun dist/scripts/manage-db.js fix
elif [ -f "scripts/manage-db.ts" ]; then
bun scripts/manage-db.ts fix
fi
# Since the application is not used by anyone yet, we've removed the schema updates and migrations
echo "Database already exists, no migrations needed."
# Run database migrations
echo "Running database migrations..."
# Update mirror_jobs table with new columns for resilience
if [ -f "dist/scripts/update-mirror-jobs-table.js" ]; then
echo "Updating mirror_jobs table..."
bun dist/scripts/update-mirror-jobs-table.js
elif [ -f "scripts/update-mirror-jobs-table.ts" ]; then
echo "Updating mirror_jobs table using TypeScript script..."
bun scripts/update-mirror-jobs-table.ts
else
echo "Warning: Could not find mirror_jobs table update script."
fi
fi
# Extract version from package.json and set as environment variable
if [ -f "package.json" ]; then
export npm_package_version=$(grep -o '"version": *"[^"]*"' package.json | cut -d'"' -f4)
echo "Setting application version: $npm_package_version"
fi
# 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
View 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

View 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
View 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.

127
docs/testing.md Normal file
View File

@@ -0,0 +1,127 @@
# Testing in Gitea Mirror
This document provides guidance on testing in the Gitea Mirror project.
## Current Status
The project now uses Bun's built-in test runner, which is Jest-compatible and provides a fast, reliable testing experience. We've migrated away from Vitest due to compatibility issues with Bun.
## Running Tests
To run tests, use the following commands:
```bash
# Run all tests
bun test
# Run tests in watch mode (automatically re-run when files change)
bun test --watch
# Run tests with coverage reporting
bun test --coverage
```
## Test File Naming Conventions
Bun's test runner automatically discovers test files that match the following patterns:
- `*.test.{js|jsx|ts|tsx}`
- `*_test.{js|jsx|ts|tsx}`
- `*.spec.{js|jsx|ts|tsx}`
- `*_spec.{js|jsx|ts|tsx}`
## Writing Tests
The project uses Bun's test runner with a Jest-compatible API. Here's an example test:
```typescript
// example.test.ts
import { describe, test, expect } from "bun:test";
describe("Example Test", () => {
test("should pass", () => {
expect(true).toBe(true);
});
});
```
### Testing React Components
For testing React components, we use React Testing Library:
```typescript
// component.test.tsx
import { describe, test, expect } from "bun:test";
import { render, screen } from "@testing-library/react";
import MyComponent from "../components/MyComponent";
describe("MyComponent", () => {
test("renders correctly", () => {
render(<MyComponent />);
expect(screen.getByText("Hello World")).toBeInTheDocument();
});
});
```
## Test Setup
The test setup is defined in `src/tests/setup.bun.ts` and includes:
- Automatic cleanup after each test
- Setup for any global test environment needs
## Mocking
Bun's test runner provides built-in mocking capabilities:
```typescript
import { test, expect, mock } from "bun:test";
// Create a mock function
const mockFn = mock(() => "mocked value");
test("mock function", () => {
const result = mockFn();
expect(result).toBe("mocked value");
expect(mockFn).toHaveBeenCalled();
});
// Mock a module
mock.module("./some-module", () => {
return {
someFunction: () => "mocked module function"
};
});
```
## CI Integration
The CI workflow has been updated to use Bun's test runner. Tests are automatically run as part of the CI pipeline.
## Test Coverage
To generate test coverage reports, run:
```bash
bun test --coverage
```
This will generate a coverage report in the `coverage` directory.
## Types of Tests
The project includes several types of tests:
1. **Unit Tests**: Testing individual functions and utilities
2. **API Tests**: Testing API endpoints
3. **Component Tests**: Testing React components
4. **Integration Tests**: Testing how components work together
## Future Improvements
When expanding the test suite, consider:
1. Adding more comprehensive API endpoint tests
2. Increasing component test coverage
3. Setting up end-to-end tests with a tool like Playwright
4. Adding performance tests for critical paths

View File

@@ -1,7 +1,7 @@
{
"name": "gitea-mirror",
"type": "module",
"version": "2.1.0",
"version": "2.17.0",
"engines": {
"bun": ">=1.2.9"
},
@@ -16,69 +16,80 @@
"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 dist/server/entry.mjs",
"test": "bunx --bun vitest run",
"test:watch": "bunx --bun vitest",
"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"
}

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 749 B

After

Width:  |  Height:  |  Size: 21 KiB

16
public/logo-dark.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 13 KiB

16
public/logo-light.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 13 KiB

16
public/logo.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 13 KiB

56
scripts/README-dev.md Normal file
View 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.

View File

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

View File

@@ -1,41 +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
# 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)"
# 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:
* Creates **privileged** CT `$CTID` with nesting enabled
* Installs curl / git / Bun (official installer)
* Clones & builds `arunavo4/gitea-mirror`
* Writes a root-run systemd service and starts it
* Prints the container IP + random `JWT_SECRET`
* 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)

View File

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

View File

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

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

View File

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

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

View File

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

178
scripts/investigate-repo.ts Normal file
View 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);
});

View File

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

View File

@@ -145,9 +145,31 @@ async function ensureTablesExist() {
status TEXT NOT NULL DEFAULT 'imported',
message TEXT NOT NULL,
timestamp TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
-- New fields for job resilience
job_type TEXT NOT NULL DEFAULT 'mirror',
batch_id TEXT,
total_items INTEGER,
completed_items INTEGER DEFAULT 0,
item_ids TEXT, -- JSON array as text
completed_item_ids TEXT DEFAULT '[]', -- JSON array as text
in_progress INTEGER NOT NULL DEFAULT 0, -- Boolean as integer
started_at TIMESTAMP,
completed_at TIMESTAMP,
last_checkpoint TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
)
`);
// Create indexes for better performance
db.exec(`
CREATE INDEX IF NOT EXISTS idx_mirror_jobs_user_id ON mirror_jobs(user_id);
CREATE INDEX IF NOT EXISTS idx_mirror_jobs_batch_id ON mirror_jobs(batch_id);
CREATE INDEX IF NOT EXISTS idx_mirror_jobs_in_progress ON mirror_jobs(in_progress);
CREATE INDEX IF NOT EXISTS idx_mirror_jobs_job_type ON mirror_jobs(job_type);
CREATE INDEX IF NOT EXISTS idx_mirror_jobs_timestamp ON mirror_jobs(timestamp);
`);
break;
case "events":
db.exec(`
@@ -175,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
}
}
/**
@@ -306,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)
@@ -437,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(
@@ -453,6 +509,7 @@ async function initializeDatabase() {
include,
exclude,
scheduleConfig,
cleanupConfig,
Date.now(),
Date.now()
);

View File

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

View 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();

View 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
View 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();

View 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
View 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();

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

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

View File

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

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

View File

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

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

View File

@@ -0,0 +1,193 @@
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, personal repos org for preserve, empty div for others */}
{strategy === "single-org" ? (
<div className="space-y-1">
<Label htmlFor="destinationOrg" className="text-sm font-normal flex items-center gap-2">
Destination Organization
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<Info className="h-3.5 w-3.5 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent>
<p>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">
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>
);
};

View File

@@ -0,0 +1,312 @@
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";
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"
}
}
};
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>
);
}
return null;
};
export const OrganizationStrategy: React.FC<OrganizationStrategyProps> = ({
strategy,
destinationOrg,
starredReposOrg,
onStrategyChange,
githubUsername,
giteaUsername,
}) => {
return (
<div className="space-y-4">
<div>
<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 mb-4">
Choose how your repositories will be organized in Gitea
</p>
</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>
);
};

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,12 +2,14 @@ import { useEffect, useState } from "react";
import { cn } from "@/lib/utils";
import { ExternalLink } from "lucide-react";
import { links } from "@/data/Sidebar";
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(() => {
@@ -17,20 +19,53 @@ 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 py-4">
<div className="flex flex-col h-full pt-4">
<nav className="flex flex-col gap-y-1 pl-2 pr-3">
{links.map((link, index) => {
const isActive = currentPath === link.href;
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"
@@ -38,7 +73,7 @@ export function Sidebar({ className }: SidebarProps) {
>
<Icon className="h-4 w-4" />
{link.label}
</a>
</button>
);
})}
</nav>
@@ -59,6 +94,7 @@ export function Sidebar({ className }: SidebarProps) {
<ExternalLink className="h-3 w-3" />
</a>
</div>
<VersionInfo />
</div>
</div>
</aside>

View File

@@ -0,0 +1,49 @@
import { useEffect, useState } from "react";
import { healthApi } from "@/lib/api";
export function VersionInfo() {
const [versionInfo, setVersionInfo] = useState<{
current: string;
latest: string;
updateAvailable: boolean;
}>({
current: "loading...",
latest: "",
updateAvailable: false
});
useEffect(() => {
const fetchVersion = async () => {
try {
const healthData = await healthApi.check();
setVersionInfo({
current: healthData.version || "unknown",
latest: healthData.latestVersion || "unknown",
updateAvailable: healthData.updateAvailable || false
});
} catch (error) {
console.error("Failed to fetch version:", error);
setVersionInfo({
current: "unknown",
latest: "",
updateAvailable: false
});
}
};
fetchVersion();
}, []);
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 gap-1">
<span>v{versionInfo.current}</span>
<span className="text-primary">v{versionInfo.latest} available</span>
</div>
) : (
<span>v{versionInfo.current}</span>
)}
</div>
);
}

View File

@@ -0,0 +1,193 @@
import { useState } from "react";
import { ArrowRight, Edit3, RotateCcw, CheckCircle2, XCircle, 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>
);
}

View File

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

View File

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

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

View File

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

View File

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

View 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 }

View 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 }

View 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 }

View 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 }

View 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 }

View 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 }

View 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 }

View 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 }

View File

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

View File

@@ -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 = {};

View File

@@ -1,124 +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-proxmox.sh # Proxmox LXC deployment script
├── 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 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

View File

@@ -1,175 +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 | `your-secret-key-change-this-in-production` | `your-secure-random-string` |
| `HOST` | Server host | `localhost` | `0.0.0.0` |
| `PORT` | Server port | `4321` | `8080` |
### Important Security Note
In production environments, you should always set a strong, unique `JWT_SECRET` to ensure secure authentication.
## 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
```

View File

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

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

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

View File

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

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

View File

@@ -94,6 +94,8 @@ export interface HealthResponse {
status: "ok" | "error";
timestamp: string;
version: string;
latestVersion: string;
updateAvailable: boolean;
database: {
connected: boolean;
message: string;
@@ -147,6 +149,8 @@ export const healthApi = {
timestamp: new Date().toISOString(),
error: error instanceof Error ? error.message : "Unknown error checking health",
version: "unknown",
latestVersion: "unknown",
updateAvailable: false,
database: { connected: false, message: "Failed to connect to API" },
system: {
uptime: { startTime: "", uptimeMs: 0, formatted: "N/A" },

Some files were not shown because too many files have changed in this diff Show More