diff --git a/services/modpack-version-checker/blueprint-extension/Controllers/ModpackAPIController.php b/services/modpack-version-checker/blueprint-extension/Controllers/ModpackAPIController.php index 3e9e55d..a68f277 100644 --- a/services/modpack-version-checker/blueprint-extension/Controllers/ModpackAPIController.php +++ b/services/modpack-version-checker/blueprint-extension/Controllers/ModpackAPIController.php @@ -1,5 +1,62 @@ manualCheck() + * - GET /api/client/extensions/modpackchecker/status -> getStatus() + * + * @package Pterodactyl\BlueprintFramework\Extensions\modpackchecker + * @author Firefrost Gaming (Chroniclers #52, #62, #63) + * @version 1.0.0 + * @see CheckModpackUpdates.php (cron command that populates the cache) + * @see UpdateBadge.tsx (React component that consumes getStatus) + * ============================================================================= + */ + namespace Pterodactyl\BlueprintFramework\Extensions\modpackchecker\Controllers; use Pterodactyl\Http\Controllers\Controller; @@ -11,6 +68,13 @@ use Illuminate\Http\JsonResponse; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\DB; +/** + * Handles all modpack version checking API requests. + * + * Two public endpoints: + * - manualCheck(): Live check for single server (console button) + * - getStatus(): Cached status for all servers (dashboard badge) + */ class ModpackAPIController extends Controller { public function __construct( @@ -19,7 +83,41 @@ class ModpackAPIController extends Controller ) {} /** - * Manual version check triggered from React frontend + * Manual version check triggered from the server console UI. + * + * This endpoint makes LIVE API calls to external modpack platforms. + * It's designed for on-demand, user-initiated checks of a single server. + * + * DETECTION PRIORITY: + * 1. Egg variables (MODPACK_PLATFORM + MODPACK_ID) - most reliable + * 2. Platform-specific variables (CURSEFORGE_ID, MODRINTH_PROJECT_ID, etc.) + * 3. File fingerprinting (manifest.json, modrinth.index.json) + * + * WHY THIS ORDER? + * Egg variables are explicitly set by the server owner, so they're most + * trustworthy. File detection is a fallback for servers that were set up + * before this extension was installed. + * + * @param Request $request The incoming HTTP request (includes auth) + * @param Server $server The server to check (injected by route model binding) + * @return JsonResponse Contains: success, platform, modpack_id, modpack_name, + * latest_version, status, and error (if applicable) + * + * @example Success response: + * { + * "success": true, + * "platform": "modrinth", + * "modpack_id": "adrenaserver", + * "modpack_name": "Adrenaserver", + * "latest_version": "1.7.0+1.21.1.fabric", + * "status": "checked" + * } + * + * @example Error response (no modpack detected): + * { + * "success": false, + * "message": "Could not detect modpack. Set MODPACK_PLATFORM and MODPACK_ID in startup variables." + * } */ public function manualCheck(Request $request, Server $server): JsonResponse { @@ -82,7 +180,17 @@ class ModpackAPIController extends Controller } /** - * Get an egg variable value for a server + * Retrieve an egg variable value for a specific server. + * + * Egg variables are the startup parameters defined in Pterodactyl eggs. + * For modpack detection, we look for variables like: + * - MODPACK_PLATFORM: "curseforge", "modrinth", "ftb", "technic" + * - MODPACK_ID: The platform-specific identifier + * - MODPACK_CURRENT_VERSION: What version is currently installed + * + * @param Server $server The server to query + * @param string $name The environment variable name (e.g., "MODPACK_PLATFORM") + * @return string|null The variable's value, or null if not set */ private function getEggVariable(Server $server, string $name): ?string { @@ -93,7 +201,25 @@ class ModpackAPIController extends Controller } /** - * Attempt to detect modpack from files + * Attempt to detect modpack platform and ID by reading server files. + * + * This is a FALLBACK method when egg variables aren't set. It works by + * looking for platform-specific manifest files that modpack installers create: + * + * - CurseForge: manifest.json with manifestType="minecraftModpack" + * - Modrinth: modrinth.index.json with formatVersion field + * + * LIMITATIONS: + * - Requires the server to be running (Wings must be accessible) + * - Some modpack installations don't include these files + * - Modrinth index doesn't always contain the project ID + * + * WHY NOT FTB/TECHNIC? + * FTB and Technic launchers don't leave standardized manifest files. + * Servers using these platforms MUST set egg variables manually. + * + * @param Server $server The server to scan for manifest files + * @return array Contains: platform, modpack_id, name, version (all nullable) */ private function detectFromFiles(Server $server): array { @@ -133,7 +259,20 @@ class ModpackAPIController extends Controller } /** - * Read a file from the server via Wings + * Read a file from the game server via the Wings daemon. + * + * Uses Pterodactyl's DaemonFileRepository to communicate with Wings, + * which runs on the game server node. This allows us to read files + * from the server's filesystem without direct SSH access. + * + * FAILURE MODES: + * - Server offline: Wings can't access stopped containers + * - File doesn't exist: Returns null (caught exception) + * - Wings unreachable: Network/auth issues return null + * + * @param Server $server The server whose files we're reading + * @param string $path Relative path from server root (e.g., "manifest.json") + * @return string|null File contents, or null if unreadable */ private function readServerFile(Server $server, string $path): ?string { @@ -146,7 +285,22 @@ class ModpackAPIController extends Controller } /** - * Check CurseForge API for latest version + * Query CurseForge API for latest modpack version. + * + * REQUIRES: API key configured in admin panel. + * CurseForge's API is not public - you must apply for access: + * https://docs.curseforge.com/#getting-started + * + * API ENDPOINT: GET https://api.curseforge.com/v1/mods/{modId} + * + * RATE LIMITS: CurseForge allows ~1000 requests/day for personal keys. + * The cron job handles rate limiting via sleep() between checks. + * + * @param string $modpackId CurseForge project ID (numeric string) + * @return array Contains: name, version + * @throws \Exception If API key missing or request fails + * + * @see https://docs.curseforge.com/#get-mod */ private function checkCurseForge(string $modpackId): array { @@ -175,7 +329,25 @@ class ModpackAPIController extends Controller } /** - * Check Modrinth API for latest version + * Query Modrinth API for latest modpack version. + * + * NO API KEY REQUIRED - Modrinth's API is public. + * However, they require a User-Agent header for identification. + * + * API ENDPOINTS: + * - GET https://api.modrinth.com/v2/project/{id}/version (get versions) + * - GET https://api.modrinth.com/v2/project/{id} (get project name) + * + * RATE LIMITS: 300 requests/minute (generous, but cron still throttles) + * + * NOTE: We make TWO API calls here - one for versions, one for project + * name. This could be optimized to a single call if performance matters. + * + * @param string $projectId Modrinth project ID or slug + * @return array Contains: name, version + * @throws \Exception If request fails + * + * @see https://docs.modrinth.com/api/operations/getproject/ */ private function checkModrinth(string $projectId): array { @@ -206,7 +378,22 @@ class ModpackAPIController extends Controller } /** - * Check FTB (modpacks.ch) API for latest version + * Query Feed The Beast (FTB) API for latest modpack version. + * + * NO API KEY REQUIRED - modpacks.ch is a public API. + * + * API ENDPOINT: GET https://api.modpacks.ch/public/modpack/{id} + * + * NOTE: FTB modpack IDs are numeric but often displayed differently + * in the launcher. Users may need to find the ID from the modpack URL. + * + * The versions array is sorted newest-first, so [0] is always latest. + * + * @param string $modpackId FTB modpack ID (numeric string) + * @return array Contains: name, version + * @throws \Exception If request fails + * + * @see https://api.modpacks.ch/ */ private function checkFTB(string $modpackId): array { @@ -227,7 +414,23 @@ class ModpackAPIController extends Controller } /** - * Check Technic API for latest version + * Query Technic Platform API for latest modpack version. + * + * NO API KEY REQUIRED - Technic's API is public. + * + * IMPORTANT: Technic uses SLUGS, not numeric IDs! + * The slug is the URL-friendly name from the modpack page. + * Example: "tekkit" for https://www.technicpack.net/modpack/tekkit + * + * API ENDPOINT: GET https://api.technicpack.net/modpack/{slug}?build=1 + * + * The ?build=1 parameter includes build metadata in the response. + * + * @param string $slug Technic modpack slug (URL-friendly name) + * @return array Contains: name (displayName preferred), version + * @throws \Exception If request fails + * + * @see https://www.technicpack.net/api */ private function checkTechnic(string $slug): array { @@ -248,8 +451,48 @@ class ModpackAPIController extends Controller } /** - * Get cached update status for all servers (dashboard badge view) - * Called once on page load, returns status for all user's servers + * Get cached update status for all of a user's servers. + * + * THIS IS THE DASHBOARD BADGE ENDPOINT. + * + * CRITICAL: This method ONLY reads from the local database cache. + * It NEVER makes external API calls. This is by design. + * + * WHY CACHED-ONLY? + * Imagine a panel with 50 servers across 20 users. If each dashboard + * load triggered 50 live API calls, you'd hit rate limits in minutes. + * Instead, the CheckModpackUpdates cron job runs hourly/daily and + * populates the modpackchecker_servers table. This endpoint just + * reads that cache. + * + * The React component (UpdateBadge.tsx) calls this ONCE on page load + * and caches the result client-side to avoid even repeated DB queries. + * + * RESPONSE FORMAT: + * Keyed by server UUID for easy lookup in the React component. + * Only includes servers that have been checked by the cron job. + * + * @param Request $request The incoming HTTP request (includes auth user) + * @return JsonResponse Keyed by server_uuid, contains update status + * + * @example Response: + * { + * "a1b2c3d4-...": { + * "update_available": true, + * "modpack_name": "All The Mods 9", + * "current_version": "0.2.51", + * "latest_version": "0.2.60" + * }, + * "e5f6g7h8-...": { + * "update_available": false, + * "modpack_name": "Adrenaserver", + * "current_version": "1.7.0", + * "latest_version": "1.7.0" + * } + * } + * + * @see CheckModpackUpdates.php (cron command that populates this cache) + * @see UpdateBadge.tsx (React component that consumes this endpoint) */ public function getStatus(Request $request): JsonResponse { diff --git a/services/modpack-version-checker/blueprint-extension/README.md b/services/modpack-version-checker/blueprint-extension/README.md new file mode 100644 index 0000000..4a257ee --- /dev/null +++ b/services/modpack-version-checker/blueprint-extension/README.md @@ -0,0 +1,365 @@ +# ModpackChecker — Pterodactyl Blueprint Extension + +**Version:** 1.0.0 +**Author:** Firefrost Gaming +**License:** Proprietary (Commercial product for BuiltByBit) + +A Pterodactyl Panel extension that checks modpack versions across CurseForge, Modrinth, FTB, and Technic platforms. Shows update status on the dashboard and provides manual version checks from the server console. + +--- + +## Table of Contents + +1. [Features](#features) +2. [Architecture Overview](#architecture-overview) +3. [File Structure](#file-structure) +4. [Installation](#installation) +5. [Configuration](#configuration) +6. [Usage](#usage) +7. [Development](#development) +8. [API Reference](#api-reference) +9. [Troubleshooting](#troubleshooting) +10. [Design Decisions](#design-decisions) + +--- + +## Features + +### Dashboard Badge +- Shows a colored dot next to each server name on the dashboard +- **🟠 Orange (Fire #FF6B35):** Update available +- **🟢 Teal (Frost #4ECDC4):** Up to date +- Hover for version details tooltip +- Single API call per page load (cached globally) + +### Console Widget +- "Check for Updates" button on each server's console page +- Real-time version check against platform API +- Shows modpack name, current version, and latest version + +### Admin Panel +- Configure CurseForge API key +- View extension status +- (Future: Rate limit settings, notification preferences) + +### Supported Platforms +| Platform | ID Type | API Key Required | +|----------|---------|------------------| +| CurseForge | Numeric project ID | ✅ Yes | +| Modrinth | Project ID or slug | ❌ No | +| FTB | Numeric modpack ID | ❌ No | +| Technic | URL slug | ❌ No | + +--- + +## Architecture Overview + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ MODPACK VERSION CHECKER │ +│ Architecture Diagram │ +└─────────────────────────────────────────────────────────────────────────────┘ + + ┌─────────────────────────────────────┐ + │ CRON JOB (runs every 4-6 hrs) │ + │ php artisan modpackchecker:check │ + │ │ + │ • Finds servers with MODPACK_* │ + │ • Calls platform APIs one by one │ + │ • 2-second delay between calls │ + │ • Stores results in database │ + └──────────────────┬──────────────────┘ + │ + ▼ + ┌─────────────────────────────────────┐ + │ DATABASE CACHE │ + │ modpackchecker_servers table │ + │ │ + │ • server_id, server_uuid │ + │ • platform, modpack_id │ + │ • current_version, latest_version │ + │ • update_available (boolean) │ + │ • last_checked timestamp │ + └──────────────────┬──────────────────┘ + │ + ┌──────────────────┴──────────────────┐ + │ │ + ▼ ▼ + ┌───────────────────────────┐ ┌───────────────────────────┐ + │ DASHBOARD BADGE │ │ CONSOLE WIDGET │ + │ (UpdateBadge.tsx) │ │ (wrapper.tsx) │ + │ │ │ │ + │ • Reads from cache ONLY │ │ • Manual "Check" button │ + │ • Never calls external │ │ • LIVE API call │ + │ • One API call per page │ │ • Single server only │ + │ • Shows 🟠 or 🟢 dot │ │ • Shows full details │ + └───────────────────────────┘ └───────────────────────────┘ +``` + +### Why This Architecture? + +**The Problem:** A panel with 50 servers and 20 active users could generate thousands of API calls per day if each dashboard view triggered live checks. + +**The Solution:** +1. Cron job handles external API calls with rate limiting +2. Dashboard reads from local cache only +3. Console provides on-demand checks for specific servers + +This was validated by Gemini AI during architectural review. + +--- + +## File Structure + +``` +blueprint-extension/ +├── README.md # This file +├── conf.yml # Blueprint configuration +├── build.sh # Injection script (runs during blueprint -build) +│ +├── Controllers/ +│ └── ModpackAPIController.php # API endpoints (manualCheck, getStatus) +│ +├── admin/ +│ ├── controller.php # Admin panel logic +│ └── view.blade.php # Admin panel UI +│ +├── console/ +│ └── CheckModpackUpdates.php # Laravel cron command +│ +├── database/ +│ └── migrations/ +│ └── 2024_XX_XX_create_modpackchecker_servers_table.php +│ +├── routes/ +│ └── client.php # API route definitions +│ +└── views/ + ├── server/ + │ └── wrapper.tsx # Console "Check for Updates" widget + └── dashboard/ + └── UpdateBadge.tsx # Dashboard status dot component +``` + +--- + +## Installation + +### Prerequisites +- Pterodactyl Panel v1.11+ +- Blueprint Framework (beta-2026-01 or newer) +- PHP 8.1+ +- Node.js 18+ + +### Steps + +1. **Copy extension to Blueprint directory:** + ```bash + cp -r blueprint-extension /var/www/pterodactyl/.blueprint/extensions/modpackchecker + chown -R www-data:www-data /var/www/pterodactyl/.blueprint/extensions/modpackchecker + ``` + +2. **Build the extension:** + ```bash + cd /var/www/pterodactyl + blueprint -build + ``` + +3. **Compile frontend assets:** + ```bash + export NODE_OPTIONS=--openssl-legacy-provider + yarn build:production + ``` + +4. **Run database migration:** + ```bash + php artisan migrate + ``` + +5. **Restart PHP-FPM:** + ```bash + systemctl restart php8.3-fpm + ``` + +6. **Set up cron job:** + ```bash + # Add to /etc/crontab or crontab -e + 0 */6 * * * www-data cd /var/www/pterodactyl && php artisan modpackchecker:check >> /dev/null 2>&1 + ``` + +--- + +## Configuration + +### Server Egg Variables + +For modpack detection, set these variables in your server's egg: + +| Variable | Description | Example | +|----------|-------------|---------| +| `MODPACK_PLATFORM` | Platform name | `modrinth`, `curseforge`, `ftb`, `technic` | +| `MODPACK_ID` | Platform-specific ID | `adrenaserver` (Modrinth slug) | +| `MODPACK_CURRENT_VERSION` | Installed version | `1.7.0` | + +### CurseForge API Key + +CurseForge requires an API key. To configure: + +1. Apply for API access at https://docs.curseforge.com/ +2. Go to **Admin Panel → Extensions → ModpackChecker** +3. Enter your API key and save + +--- + +## Usage + +### Dashboard Badge +No action needed — badges appear automatically for servers that: +- Have `MODPACK_PLATFORM` egg variable set +- Have been checked by the cron job at least once + +### Manual Check +1. Go to any server's console page +2. Click "Check for Updates" button +3. View results showing modpack name and version status + +### Cron Command +Run manually for testing: +```bash +cd /var/www/pterodactyl +php artisan modpackchecker:check +``` + +Output: +``` +Starting modpack update check... +Found 12 servers with modpack configuration +Checking: ATM9 Server (a1b2c3d4-...) + 🟠 UPDATE AVAILABLE: All The Mods 9 - 0.2.60 +Checking: Vanilla+ (e5f6g7h8-...) + 🟢 Up to date: Vanilla+ - 1.2.0 +Modpack update check complete! +``` + +--- + +## Development + +### Local Development +1. Enable Blueprint developer mode in admin panel +2. Make changes in `.blueprint/dev/` or `.blueprint/extensions/modpackchecker/` +3. Run `blueprint -build` after changes +4. Run `yarn build:production` for frontend changes + +### Testing API Endpoints +```bash +# Manual check (requires auth token) +curl -X POST "https://panel.example.com/api/client/servers/{uuid}/ext/modpackchecker/check" \ + -H "Authorization: Bearer {token}" + +# Get all statuses (requires auth token) +curl "https://panel.example.com/api/client/extensions/modpackchecker/status" \ + -H "Authorization: Bearer {token}" +``` + +### Adding a New Platform +1. Add check method to `ModpackAPIController.php` (e.g., `checkNewPlatform()`) +2. Add to the `match()` statement in `checkVersion()` +3. Add same method to `CheckModpackUpdates.php` +4. Update this README + +--- + +## API Reference + +### POST /api/client/servers/{server}/ext/modpackchecker/check + +Manual version check for a specific server. Makes live API call. + +**Response:** +```json +{ + "success": true, + "platform": "modrinth", + "modpack_id": "adrenaserver", + "modpack_name": "Adrenaserver", + "latest_version": "1.7.0+1.21.1.fabric", + "status": "checked" +} +``` + +### GET /api/client/extensions/modpackchecker/status + +Get cached status for all servers accessible to the authenticated user. + +**Response:** +```json +{ + "a1b2c3d4-...": { + "update_available": true, + "modpack_name": "All The Mods 9", + "current_version": "0.2.51", + "latest_version": "0.2.60" + }, + "e5f6g7h8-...": { + "update_available": false, + "modpack_name": "Adrenaserver", + "current_version": "1.7.0", + "latest_version": "1.7.0" + } +} +``` + +--- + +## Troubleshooting + +### Badge not showing +1. Check server has `MODPACK_PLATFORM` variable set +2. Run cron command manually: `php artisan modpackchecker:check` +3. Check `modpackchecker_servers` table for entries + +### "CurseForge API key not configured" +1. Go to Admin → Extensions → ModpackChecker +2. Enter your CurseForge API key +3. Key must have mod read permissions + +### 500 errors on check +1. Check PHP error log: `tail -f /var/log/php8.3-fpm.log` +2. Verify controller namespace: `Pterodactyl\BlueprintFramework\Extensions\modpackchecker\Controllers` +3. Restart PHP-FPM: `systemctl restart php8.3-fpm` + +### Build.sh not running +1. Ensure file is executable: `chmod +x build.sh` +2. Check Blueprint version supports build scripts +3. Run manually from panel root: `bash .blueprint/extensions/modpackchecker/build.sh` + +--- + +## Design Decisions + +### Why cache instead of live checks? +Rate limits. CurseForge allows ~1000 requests/day for personal keys. A busy panel could exhaust that in hours without caching. + +### Why 2-second sleep in cron? +Prevents burst traffic to APIs. 50 servers × 2 seconds = ~2 minute runtime, which is acceptable for a background job. + +### Why inline styles in React? +The component is injected into Pterodactyl's build. Adding CSS classes would require modifying their build pipeline. Inline styles are self-contained. + +### Why separate console widget and dashboard badge? +Different use cases: +- Dashboard: Quick overview, needs to be fast → cached +- Console: User wants current info → live API call is acceptable + +--- + +## Credits + +**Development Team:** +- Architecture design: Gemini AI +- Implementation: Chroniclers #52, #62, #63 (Claude instances) +- Project Lead: Michael "Frostystyle" Krause + +**Part of Firefrost Gaming** +*Fire + Frost + Foundation = Where Love Builds Legacy* 🔥❄️💙 diff --git a/services/modpack-version-checker/blueprint-extension/console/CheckModpackUpdates.php b/services/modpack-version-checker/blueprint-extension/console/CheckModpackUpdates.php index d72ac4f..2357b55 100644 --- a/services/modpack-version-checker/blueprint-extension/console/CheckModpackUpdates.php +++ b/services/modpack-version-checker/blueprint-extension/console/CheckModpackUpdates.php @@ -1,5 +1,67 @@ > /dev/null 2>&1 + * + * HOW IT WORKS: + * 1. Finds all servers with MODPACK_PLATFORM egg variable set + * 2. Loops through each server, checking the appropriate API + * 3. Stores results in modpackchecker_servers database table + * 4. Dashboard badges read from this table (never calling APIs directly) + * + * RATE LIMITING: + * Each API call is followed by a 2-second sleep to avoid rate limits. + * For 50 servers, a full check takes ~2 minutes. Adjust sleep() if needed. + * + * PLATFORM SUPPORT: + * - CurseForge: Requires API key in settings (configured via admin panel) + * - Modrinth: Public API, no key needed (uses User-Agent) + * - FTB: Public API via modpacks.ch + * - Technic: Public API, uses slug instead of numeric ID + * + * DATABASE TABLE: modpackchecker_servers + * This table caches the latest check results. See migration file for schema. + * Uses updateOrInsert for upsert behavior - first run creates, subsequent update. + * + * EXAMPLE OUTPUT: + * Starting modpack update check... + * Found 12 servers with modpack configuration + * Checking: ATM9 Server (a1b2c3d4-...) + * 🟠 UPDATE AVAILABLE: All The Mods 9 - 0.2.60 + * Checking: Creative Server (e5f6g7h8-...) + * Skipping - missing platform or modpack ID + * Checking: Vanilla Server (i9j0k1l2-...) + * 🟢 Up to date: Adrenaserver - 1.7.0 + * Modpack update check complete! + * + * ERROR HANDLING: + * If an API call fails, the error is logged to the database and the command + * continues to the next server. One failure doesn't stop the whole batch. + * + * DEPENDENCIES: + * - Database table: modpackchecker_servers (from migration) + * - Settings: modpackchecker::curseforge_api_key (for CurseForge only) + * + * @package Pterodactyl\Console\Commands + * @author Firefrost Gaming (Chroniclers #62, #63) + * @version 1.0.0 + * @see ModpackAPIController.php (provides getStatus endpoint for badges) + * @see UpdateBadge.tsx (React component that displays the results) + * ============================================================================= + */ + namespace Pterodactyl\Console\Commands; use Illuminate\Console\Command; @@ -7,11 +69,37 @@ use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Http; use Pterodactyl\Models\Server; +/** + * Artisan command to check all servers for modpack updates. + * + * Run manually with: php artisan modpackchecker:check + * Or schedule in app/Console/Kernel.php: + * $schedule->command('modpackchecker:check')->everyFourHours(); + */ class CheckModpackUpdates extends Command { + /** + * The console command signature. + * + * @var string + */ protected $signature = 'modpackchecker:check'; + + /** + * The console command description (shown in `php artisan list`). + * + * @var string + */ protected $description = 'Check all servers for modpack updates'; + /** + * Execute the console command. + * + * Main entry point. Finds all servers with modpack configuration + * and checks each one against its platform's API. + * + * @return int Exit code (0 = success) + */ public function handle(): int { $this->info('Starting modpack update check...'); @@ -33,6 +121,21 @@ class CheckModpackUpdates extends Command return 0; } + /** + * Check a single server for modpack updates. + * + * This method: + * 1. Retrieves modpack configuration from egg variables + * 2. Calls the appropriate platform API + * 3. Compares current vs latest version + * 4. Stores results in database cache + * + * If any step fails, the error is logged to the database + * and the method returns (doesn't throw). + * + * @param Server $server The server to check + * @return void + */ private function checkServer(Server $server): void { $this->line("Checking: {$server->name} ({$server->uuid})"); @@ -89,6 +192,19 @@ class CheckModpackUpdates extends Command } } + /** + * Get an egg variable value from a server. + * + * Egg variables are the startup parameters configured in the server's + * egg. For modpack checking, we look for: + * - MODPACK_PLATFORM: Which API to query + * - MODPACK_ID: The modpack identifier for that platform + * - MODPACK_CURRENT_VERSION: What's currently installed (optional) + * + * @param Server $server The server to query + * @param string $name The variable name (e.g., "MODPACK_PLATFORM") + * @return string|null The variable value, or null if not set + */ private function getVariable(Server $server, string $name): ?string { $variable = $server->variables() @@ -97,6 +213,15 @@ class CheckModpackUpdates extends Command return $variable?->server_value; } + /** + * Route to the appropriate platform API based on platform name. + * + * This is just a dispatcher - actual API logic is in the check* methods. + * + * @param string $platform The platform name (modrinth, curseforge, ftb, technic) + * @param string $modpackId The platform-specific modpack identifier + * @return array|null Contains [name, version] or null on failure + */ private function fetchLatestVersion(string $platform, string $modpackId): ?array { return match($platform) { @@ -174,6 +299,22 @@ class CheckModpackUpdates extends Command ]; } + /** + * Store or update the modpack check results in the database. + * + * Uses updateOrInsert for upsert behavior: + * - First check: Creates a new row + * - Subsequent checks: Updates the existing row + * + * The server_id column is the unique key for matching. + * + * This cache table is read by ModpackAPIController::getStatus() + * which powers the dashboard badges. + * + * @param Server $server The server being checked + * @param array $data The data to store (platform, version info, errors, etc.) + * @return void + */ private function updateDatabase(Server $server, array $data): void { DB::table('modpackchecker_servers')->updateOrInsert( diff --git a/services/modpack-version-checker/blueprint-extension/views/dashboard/UpdateBadge.tsx b/services/modpack-version-checker/blueprint-extension/views/dashboard/UpdateBadge.tsx index 5348bc6..bd75224 100644 --- a/services/modpack-version-checker/blueprint-extension/views/dashboard/UpdateBadge.tsx +++ b/services/modpack-version-checker/blueprint-extension/views/dashboard/UpdateBadge.tsx @@ -1,108 +1,317 @@ +/** + * ============================================================================= + * MODPACK VERSION CHECKER - DASHBOARD BADGE COMPONENT + * ============================================================================= + * + * React component that displays a colored indicator dot next to server names + * on the Pterodactyl dashboard, showing modpack update status at a glance. + * + * VISUAL DESIGN: + * - 🟢 Frost (#4ECDC4): Server's modpack is up to date + * - 🟠 Fire (#FF6B35): Update available for this modpack + * - No dot: Server has no modpack configured or not yet checked + * + * Colors match Firefrost Gaming brand palette. + * + * CRITICAL ARCHITECTURE DECISION (Gemini-approved): + * This component is intentionally "dumb" - it ONLY reads from a local cache. + * It NEVER makes external API calls to modpack platforms. + * + * WHY? + * Imagine a dashboard with 20 servers. If each server row triggered a live + * API call to CurseForge/Modrinth, you'd make 20+ requests on every page load. + * Multiply by multiple users refreshing throughout the day, and you'd hit + * rate limits within hours. + * + * Instead, this component: + * 1. Makes ONE API call to our backend (/api/client/extensions/modpackchecker/status) + * 2. Backend returns cached data from modpackchecker_servers table + * 3. Results are cached globally in JS memory for the session + * 4. Each badge instance reads from this shared cache + * + * The actual modpack checking is done by a cron job (CheckModpackUpdates.php) + * that runs on a schedule with proper rate limiting. + * + * INJECTION: + * This component is injected into ServerRow.tsx by build.sh during + * `blueprint -build`. It receives the server UUID as a prop. + * + * DEPENDENCIES: + * - @/api/http: Pterodactyl's axios wrapper (handles auth automatically) + * - Backend endpoint: GET /api/client/extensions/modpackchecker/status + * + * @package ModpackChecker Blueprint Extension + * @author Firefrost Gaming (Chroniclers #62, #63) + * @version 1.0.0 + * @see CheckModpackUpdates.php (cron that populates the cache) + * @see ModpackAPIController::getStatus() (backend endpoint) + * ============================================================================= + */ + import React, { useEffect, useState } from 'react'; import http from '@/api/http'; -/** - * UpdateBadge - Shows a colored dot next to server name on dashboard - * - * Architecture (per Gemini/Pyrrhus): - * - NEVER calls external APIs directly from dashboard - * - Reads from local database cache populated by cron job - * - Single API call on page load, cached globally - * - Shows 🟢 (up to date) or 🟠 (update available) - */ +// ============================================================================= +// TYPE DEFINITIONS +// ============================================================================= +/** + * Status data for a single server, as returned from the backend. + * + * This mirrors the structure returned by ModpackAPIController::getStatus(). + * All fields except update_available are optional because servers might + * have partial data (e.g., error during last check). + */ interface ServerStatus { + /** True if latest_version differs from current_version */ update_available: boolean; + /** Human-readable modpack name (e.g., "All The Mods 9") */ modpack_name?: string; + /** Version currently installed on the server */ current_version?: string; + /** Latest version available from the platform */ latest_version?: string; } +/** + * The full cache structure - keyed by server UUID. + * + * Example: + * { + * "a1b2c3d4-e5f6-7890-...": { update_available: true, modpack_name: "ATM9", ... }, + * "b2c3d4e5-f6g7-8901-...": { update_available: false, modpack_name: "Vanilla+", ... } + * } + */ interface StatusCache { [serverUuid: string]: ServerStatus; } -// Global cache - shared across all UpdateBadge instances -// Prevents multiple API calls when rendering server list +// ============================================================================= +// GLOBAL CACHE +// ============================================================================= +// +// These module-level variables are shared across ALL instances of UpdateBadge. +// This is intentional - we want exactly ONE API call for the entire dashboard, +// not one per server row. +// +// The pattern here is a simple "fetch-once" cache: +// - globalCache: Stores the data once fetched +// - fetchPromise: Prevents duplicate in-flight requests +// +// LIFECYCLE: +// 1. First UpdateBadge mounts → fetchAllStatuses() called → API request starts +// 2. Second UpdateBadge mounts → fetchAllStatuses() returns same promise +// 3. API response arrives → globalCache populated, all badges update +// 4. Any future calls → return globalCache immediately (no API call) +// +// CACHE INVALIDATION: +// Currently, cache persists until page refresh. For real-time updates, +// you could add a timeout or expose a refresh function. +// ============================================================================= + +/** Cached status data. Null until first fetch completes. */ let globalCache: StatusCache | null = null; + +/** Promise for in-flight fetch. Prevents duplicate requests. */ let fetchPromise: Promise | null = null; +/** + * Fetch all server statuses from the backend. + * + * This function implements a "fetch-once" pattern: + * - First call: Makes the API request, stores result in globalCache + * - Subsequent calls: Returns cached data immediately + * - Concurrent calls: Wait for the same promise (no duplicate requests) + * + * ENDPOINT: GET /api/client/extensions/modpackchecker/status + * + * The backend (ModpackAPIController::getStatus) returns only servers + * that the authenticated user has access to, so there's no data leakage. + * + * ERROR HANDLING: + * On failure, we cache an empty object rather than null. This prevents + * retry spam - if the API is down, we don't hammer it on every badge mount. + * Users can refresh the page to retry. + * + * @returns Promise resolving to the status cache (keyed by server UUID) + */ const fetchAllStatuses = async (): Promise => { - // Return cached data if available + // FAST PATH: Return cached data if available if (globalCache !== null) { return globalCache; } - // If already fetching, wait for that promise + // DEDUP PATH: If a fetch is already in progress, wait for it + // instead of starting another request if (fetchPromise !== null) { return fetchPromise; } - // Start new fetch + // FETCH PATH: Start a new API request + // This is the only code path that actually makes an HTTP call fetchPromise = http.get('/api/client/extensions/modpackchecker/status') .then((response) => { + // Store the response data in the global cache globalCache = response.data || {}; return globalCache; }) .catch((error) => { + // Log the error for debugging console.error('ModpackChecker: Failed to fetch status', error); + // Cache empty object to prevent retry spam + // Users can refresh the page to try again globalCache = {}; return globalCache; }) .finally(() => { + // Clear the promise reference + // This allows future retries if cache is manually cleared fetchPromise = null; }); return fetchPromise; }; +// ============================================================================= +// COMPONENT +// ============================================================================= + +/** + * Props for the UpdateBadge component. + */ interface UpdateBadgeProps { + /** + * The UUID of the server to show status for. + * This is passed from ServerRow.tsx where the component is injected. + * Example: "a1b2c3d4-e5f6-7890-abcd-ef1234567890" + */ serverUuid: string; } +/** + * Dashboard badge showing modpack update status. + * + * Renders a small colored dot next to the server name: + * - Orange (#FF6B35) = Update available (Fire brand color) + * - Teal (#4ECDC4) = Up to date (Frost brand color) + * - Nothing = No modpack configured or not yet checked by cron + * + * Includes a native browser tooltip on hover showing version details. + * + * USAGE (injected by build.sh, not manually added): + * ```tsx + *

{server.name}

+ * ``` + * + * ACCESSIBILITY: + * - Uses aria-label for screen readers + * - Native title attribute provides tooltip for sighted users + * - Color is not the only indicator (tooltip shows text status) + */ const UpdateBadge: React.FC = ({ serverUuid }) => { + // ========================================================================= + // STATE + // ========================================================================= + + /** This specific server's status (extracted from global cache) */ const [status, setStatus] = useState(null); + + /** Loading state - true until we've checked the cache */ const [loading, setLoading] = useState(true); + // ========================================================================= + // DATA FETCHING + // ========================================================================= + useEffect(() => { + // Fetch from global cache (makes API call only on first badge mount) fetchAllStatuses() .then((cache) => { + // Extract this server's status from the cache + // Will be null/undefined if server not in cache setStatus(cache[serverUuid] || null); setLoading(false); }); - }, [serverUuid]); + }, [serverUuid]); // Re-run if serverUuid changes (unlikely in practice) - // Don't render anything while loading or if no status - if (loading || !status) { + // ========================================================================= + // RENDER CONDITIONS + // ========================================================================= + + // Don't render anything while waiting for cache + // This prevents flicker - badges appear all at once when data arrives + if (loading) { + return null; + } + + // Don't render if no status data exists for this server + // This happens for servers that: + // - Don't have MODPACK_PLATFORM configured + // - Haven't been checked by the cron job yet + // - Had an error during their last check + if (!status) { return null; } - // Only show badge if we have modpack data + // Don't render if we have a status entry but no modpack name + // This can happen if the check errored but created a partial record if (!status.modpack_name) { return null; } + // ========================================================================= + // STYLING + // ========================================================================= + + /** + * Inline styles for the dot indicator. + * + * Using inline styles rather than CSS classes because: + * 1. This component is injected into Pterodactyl's build + * 2. We can't easily add to their CSS pipeline + * 3. Inline styles are self-contained and reliable + * + * BRAND COLORS (Firefrost Gaming): + * - Fire: #FF6B35 (used for "update available" - action needed) + * - Frost: #4ECDC4 (used for "up to date" - all good) + */ const dotStyle: React.CSSProperties = { + // Layout display: 'inline-block', width: '8px', height: '8px', - borderRadius: '50%', - marginLeft: '8px', - backgroundColor: status.update_available ? '#FF6B35' : '#4ECDC4', // Fire : Frost + borderRadius: '50%', // Perfect circle + marginLeft: '8px', // Space from server name + + // Color based on update status + backgroundColor: status.update_available ? '#FF6B35' : '#4ECDC4', + + // Subtle glow effect for visual polish + // Uses rgba version of the same color at 50% opacity boxShadow: status.update_available - ? '0 0 4px rgba(255, 107, 53, 0.5)' - : '0 0 4px rgba(78, 205, 196, 0.5)', + ? '0 0 4px rgba(255, 107, 53, 0.5)' // Fire glow + : '0 0 4px rgba(78, 205, 196, 0.5)', // Frost glow }; + /** + * Tooltip text shown on hover. + * + * Uses native browser tooltip (title attribute) for simplicity. + * A fancier tooltip library could be added later if needed. + */ const tooltipText = status.update_available ? `Update available: ${status.latest_version}` : `Up to date: ${status.latest_version}`; + // ========================================================================= + // RENDER + // ========================================================================= + return ( ); };