diff --git a/aocsync.py b/aocsync.py index 7e58811..27b6e83 100755 --- a/aocsync.py +++ b/aocsync.py @@ -14,12 +14,19 @@ import shutil import re import time import logging +import threading from pathlib import Path from datetime import datetime from typing import Dict, List, Optional, Tuple from dataclasses import dataclass, asdict from collections import defaultdict +try: + from flask import Flask, Response, jsonify + FLASK_AVAILABLE = True +except ImportError: + FLASK_AVAILABLE = False + # Configure logging logging.basicConfig( level=logging.INFO, @@ -2338,8 +2345,12 @@ class AOCSync: except Exception as e: logger.error(f"Error building Podman image: {e}") - def sync_all(self): + def sync_all(self, force: bool = None): """Sync all repositories""" + if force is not None: + original_force = self.force_rerun + self.force_rerun = force + logger.info("Starting sync of all repositories...") # Clear log file at start of sync @@ -2364,6 +2375,102 @@ class AOCSync: # Rsync output if configured self._rsync_output() + + if force is not None: + self.force_rerun = original_force + + def sync_repo(self, repo_name: str, force: bool = True): + """Sync a specific repository by name""" + logger.info(f"Starting sync for repository: {repo_name} (force={force})...") + + original_force = self.force_rerun + self.force_rerun = force + + # Append to log file instead of clearing + log_file = Path(self.config.output_dir) / 'cargo-aoc.log' + log_file.parent.mkdir(parents=True, exist_ok=True) + with open(log_file, 'a', encoding='utf-8') as f: + f.write(f"\n{'#'*80}\n") + f.write(f"# Sync started for {repo_name} at {datetime.now().isoformat()}\n") + f.write(f"{'#'*80}\n\n") + + found = False + for repo_config in self.config.repositories: + if repo_config['name'] == repo_name: + found = True + try: + self.process_repository(repo_config, repo_name) + except Exception as e: + logger.error(f"Error processing repository {repo_name}: {e}") + break + + if not found: + logger.error(f"Repository {repo_name} not found") + + # Generate HTML report + logger.info("Generating HTML report...") + self.html_gen.generate(self.db, self.config) + + # Rsync output if configured + self._rsync_output() + + self.force_rerun = original_force + + def sync_year(self, year: int, force: bool = True): + """Sync all repositories for a specific year""" + logger.info(f"Starting sync for year: {year} (force={force})...") + + original_force = self.force_rerun + self.force_rerun = force + + # Append to log file instead of clearing + log_file = Path(self.config.output_dir) / 'cargo-aoc.log' + log_file.parent.mkdir(parents=True, exist_ok=True) + with open(log_file, 'a', encoding='utf-8') as f: + f.write(f"\n{'#'*80}\n") + f.write(f"# Sync started for year {year} at {datetime.now().isoformat()}\n") + f.write(f"{'#'*80}\n\n") + + for repo_config in self.config.repositories: + user_name = repo_config['name'] + repo_type = repo_config.get('type', 'single') + + try: + if repo_type == 'single': + # Check if this repo has the year + url = repo_config['url'] + local_path = repo_config['local_path'] + if self.git_manager.clone_or_update_repo(url, local_path): + repo_path = Path(local_path) + config_years = repo_config.get('years', []) + years_to_process = config_years if config_years else CargoAOCRunner.extract_years_from_repo(repo_path) + + if year in years_to_process: + logger.info(f"Processing {user_name} year {year}...") + self._run_and_store_benchmarks(repo_path, year, user_name, + repo_url=url, is_multi_year=False) + + elif repo_type == 'multi-year': + years_config = repo_config.get('years', []) + for year_config in years_config: + if year_config['year'] == year: + url = year_config['url'] + local_path = year_config['local_path'] + if self.git_manager.clone_or_update_repo(url, local_path): + repo_path = Path(local_path) + self._run_and_store_benchmarks(repo_path, year, user_name, + repo_url=url, is_multi_year=True) + except Exception as e: + logger.error(f"Error processing repository {user_name} for year {year}: {e}") + + # Generate HTML report + logger.info("Generating HTML report...") + self.html_gen.generate(self.db, self.config) + + # Rsync output if configured + self._rsync_output() + + self.force_rerun = original_force def _rsync_output(self): """Rsync output directory to remote server if configured""" @@ -2419,6 +2526,333 @@ class AOCSync: logger.info("Stopped by user") +class WebServer: + """Simple web server for viewing logs and triggering refreshes""" + + def __init__(self, sync: AOCSync, host: str = '0.0.0.0', port: int = 8080): + self.sync = sync + self.host = host + self.port = port + self.app = None + self._setup_app() + + def _setup_app(self): + """Setup Flask application""" + if not FLASK_AVAILABLE: + raise ImportError("Flask is required for web server. Install with: pip install Flask") + self.app = Flask(__name__) + + @self.app.route('/') + def index(): + return self._get_index_page() + + @self.app.route('/logs') + def logs(): + """View logs""" + log_file = Path(self.sync.config.output_dir) / 'cargo-aoc.log' + if log_file.exists(): + with open(log_file, 'r', encoding='utf-8') as f: + content = f.read() + return Response(content, mimetype='text/plain') + return "No log file found", 404 + + @self.app.route('/api/refresh/all', methods=['POST']) + def refresh_all(): + """Trigger refresh for all repositories""" + thread = threading.Thread(target=self.sync.sync_all, kwargs={'force': True}) + thread.daemon = True + thread.start() + return jsonify({'status': 'started', 'message': 'Refresh started for all repositories'}) + + @self.app.route('/api/refresh/repo/', methods=['POST']) + def refresh_repo(repo_name): + """Trigger refresh for a specific repository""" + thread = threading.Thread(target=self.sync.sync_repo, args=(repo_name,), kwargs={'force': True}) + thread.daemon = True + thread.start() + return jsonify({'status': 'started', 'message': f'Refresh started for repository: {repo_name}'}) + + @self.app.route('/api/refresh/year/', methods=['POST']) + def refresh_year(year): + """Trigger refresh for a specific year""" + thread = threading.Thread(target=self.sync.sync_year, args=(year,), kwargs={'force': True}) + thread.daemon = True + thread.start() + return jsonify({'status': 'started', 'message': f'Refresh started for year: {year}'}) + + @self.app.route('/api/repos', methods=['GET']) + def get_repos(): + """Get list of repositories""" + repos = [{'name': r['name'], 'type': r.get('type', 'single')} for r in self.sync.config.repositories] + return jsonify({'repos': repos}) + + @self.app.route('/api/years', methods=['GET']) + def get_years(): + """Get list of years""" + years = self.sync.db.get_all_years() + return jsonify({'years': sorted(years, reverse=True)}) + + def _get_index_page(self): + """Generate the main web interface page""" + repos = [r['name'] for r in self.sync.config.repositories] + years = sorted(self.sync.db.get_all_years(), reverse=True) + + # Build HTML - use regular string and format variables manually to avoid brace escaping issues + html = """ + + + + + AOC Sync Control Panel + + + +
+

🎄 AOC Sync Control Panel

+ +
+

Refresh Controls

+ +
+

Refresh All

+ +
+
+ +
+

Refresh by Repository

+
+""" + for repo in repos: + # Use double quotes for outer string, single quotes for JavaScript + html += f" \n" + + html += """
+
+
+ +
+

Refresh by Year

+
+""" + for year in years: + html += f' \n' + + html += """
+
+
+
+ +
+

Logs

+

+ 📋 View Full Logs +

+
Loading logs...
+
+
+ + + +""" + return html + + def run(self): + """Run the web server""" + logger.info(f"Starting web server on http://{self.host}:{self.port}") + self.app.run(host=self.host, port=self.port, debug=False, use_reloader=False) + + def main(): """Main entry point""" import argparse @@ -2428,15 +2862,35 @@ def main(): parser.add_argument('--once', action='store_true', help='Run once instead of continuously') parser.add_argument('--force', '--rerun-all', action='store_true', dest='force_rerun', help='Force rerun all days even if repository has not changed') + parser.add_argument('--web', action='store_true', help='Start web server for logs and refresh controls') + parser.add_argument('--web-host', default='0.0.0.0', help='Web server host (default: 0.0.0.0)') + parser.add_argument('--web-port', type=int, default=8080, help='Web server port (default: 8080)') args = parser.parse_args() sync = AOCSync(args.config, force_rerun=args.force_rerun) - if args.once: - sync.sync_all() + if args.web: + if not FLASK_AVAILABLE: + logger.error("Flask is required for web server. Install with: pip install Flask") + sys.exit(1) + # Start web server + web_server = WebServer(sync, host=args.web_host, port=args.web_port) + if args.once: + # Run once then start web server + sync.sync_all() + web_server.run() + else: + # Start web server in background thread, then run continuous sync + web_thread = threading.Thread(target=web_server.run) + web_thread.daemon = True + web_thread.start() + sync.run_continuous() else: - sync.run_continuous() + if args.once: + sync.sync_all() + else: + sync.run_continuous() if __name__ == '__main__': diff --git a/requirements.txt b/requirements.txt index c1a201d..48ba09a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,2 @@ PyYAML>=6.0 +Flask>=2.0.0