From 0cbea6d9932945cc7470beab8e4f5b6ecf044fc9 Mon Sep 17 00:00:00 2001 From: "Claude (Chronicler #63)" Date: Mon, 6 Apr 2026 08:53:27 +0000 Subject: [PATCH] feat(modpackchecker): Phase 5 complete - Dashboard badge and cron job MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 5 Components (completing Pyrrhus's work): NEW FILES: - views/dashboard/UpdateBadge.tsx: Dashboard badge component - Shows 🟢 (up to date) or 🟠 (update available) next to server names - Global cache prevents multiple API calls on page load - Reads from local database, never calls external APIs directly - Fire (#FF6B35) and Frost (#4ECDC4) brand colors - console/CheckModpackUpdates.php: Laravel cron command - Run with: php artisan modpackchecker:check - Loops through servers with MODPACK_PLATFORM variable - Checks CurseForge, Modrinth, FTB, Technic APIs - Rate limited (2s sleep between checks) - Stores results in modpackchecker_servers table UPDATED FILES: - Controllers/ModpackAPIController.php: - Added getStatus() method for dashboard badge endpoint - Returns all user's servers' update status in single query - Added DB facade import - routes/client.php: - Added GET /extensions/modpackchecker/status route - build.sh: - Complete rewrite for Phase 5 - Handles both console widget AND dashboard badge - Auto-detects extension directory (dev vs extensions) - Copies CheckModpackUpdates.php to app/Console/Commands/ - Injects UpdateBadge into ServerRow.tsx - Clear status output and next-steps guide Architecture (Gemini-approved): CRON (hourly) → Database cache → Single API endpoint → React badge Dashboard badge is 'dumb' - only reads from cache, never external APIs Completing work started by Chronicler #62 (Pyrrhus). UpdateBadge.tsx was lost in Blueprint corruption - reconstructed from handoff notes and architecture documentation. Signed-off-by: Claude (Chronicler #63) --- .../Controllers/ModpackAPIController.php | 31 +++ .../blueprint-extension/build.sh | 103 ++++++++-- .../console/CheckModpackUpdates.php | 188 ++++++++++++++++++ .../blueprint-extension/routes/client.php | 3 + .../views/dashboard/UpdateBadge.tsx | 110 ++++++++++ 5 files changed, 421 insertions(+), 14 deletions(-) create mode 100644 services/modpack-version-checker/blueprint-extension/console/CheckModpackUpdates.php create mode 100644 services/modpack-version-checker/blueprint-extension/views/dashboard/UpdateBadge.tsx diff --git a/services/modpack-version-checker/blueprint-extension/Controllers/ModpackAPIController.php b/services/modpack-version-checker/blueprint-extension/Controllers/ModpackAPIController.php index b903469..3e9e55d 100644 --- a/services/modpack-version-checker/blueprint-extension/Controllers/ModpackAPIController.php +++ b/services/modpack-version-checker/blueprint-extension/Controllers/ModpackAPIController.php @@ -9,6 +9,7 @@ use Pterodactyl\BlueprintFramework\Libraries\ExtensionLibrary\Admin\BlueprintAdm use Illuminate\Http\Request; use Illuminate\Http\JsonResponse; use Illuminate\Support\Facades\Http; +use Illuminate\Support\Facades\DB; class ModpackAPIController extends Controller { @@ -245,4 +246,34 @@ class ModpackAPIController extends Controller 'version' => $data['version'] ?? 'Unknown', ]; } + + /** + * Get cached update status for all servers (dashboard badge view) + * Called once on page load, returns status for all user's servers + */ + public function getStatus(Request $request): JsonResponse + { + $user = $request->user(); + + // Get all servers the user has access to + $serverIds = $user->accessibleServers()->pluck('id')->toArray(); + + // Query our cache table for these servers + $statuses = DB::table('modpackchecker_servers') + ->whereIn('server_id', $serverIds) + ->get() + ->keyBy('server_uuid'); + + $result = []; + foreach ($statuses as $uuid => $status) { + $result[$uuid] = [ + 'update_available' => (bool) $status->update_available, + 'modpack_name' => $status->modpack_name, + 'current_version' => $status->current_version, + 'latest_version' => $status->latest_version, + ]; + } + + return response()->json($result); + } } diff --git a/services/modpack-version-checker/blueprint-extension/build.sh b/services/modpack-version-checker/blueprint-extension/build.sh index 2a47b1d..9ef71ff 100755 --- a/services/modpack-version-checker/blueprint-extension/build.sh +++ b/services/modpack-version-checker/blueprint-extension/build.sh @@ -1,20 +1,95 @@ #!/bin/bash # build.sh - Executes automatically during blueprint -build +# Phase 5: Console widget + Dashboard badge injection -echo "Injecting ModpackChecker React Components..." +echo "==========================================" +echo "ModpackChecker Build Script - Phase 5" +echo "==========================================" -# 1. Copy your component into the Pterodactyl source tree -cp .blueprint/dev/views/server/wrapper.tsx resources/scripts/components/server/ModpackVersionCard.tsx - -# 2. Patch ServerConsoleContainer.tsx safely -if ! grep -q "ModpackVersionCard" resources/scripts/components/server/console/ServerConsoleContainer.tsx; then - # Inject the import at the top of the file - sed -i '1i import ModpackVersionCard from "@/components/server/ModpackVersionCard";' resources/scripts/components/server/console/ServerConsoleContainer.tsx - - # Inject the component directly below the ServerDetailsBlock - sed -i '/' resources/scripts/components/server/console/ServerConsoleContainer.tsx - - echo "ModpackVersionCard injected into ServerConsoleContainer.tsx" +# Determine the extension source directory +# Blueprint may run from .blueprint/dev/ or .blueprint/extensions/modpackchecker/ +if [ -d ".blueprint/extensions/modpackchecker/views" ]; then + EXT_DIR=".blueprint/extensions/modpackchecker" +elif [ -d ".blueprint/dev/views" ]; then + EXT_DIR=".blueprint/dev" else - echo "ModpackVersionCard already present, skipping injection" + echo "ERROR: Cannot find extension views directory" + exit 1 fi + +echo "Using extension directory: $EXT_DIR" + +# =========================================== +# 1. CONSOLE WIDGET (wrapper.tsx → ModpackVersionCard) +# =========================================== +echo "" +echo "--- Console Widget ---" + +if [ -f "$EXT_DIR/views/server/wrapper.tsx" ]; then + cp "$EXT_DIR/views/server/wrapper.tsx" resources/scripts/components/server/ModpackVersionCard.tsx + echo "✓ Copied ModpackVersionCard.tsx" +else + echo "⚠ wrapper.tsx not found, skipping console widget" +fi + +# Inject into ServerConsoleContainer.tsx +if ! grep -q "ModpackVersionCard" resources/scripts/components/server/console/ServerConsoleContainer.tsx 2>/dev/null; then + sed -i '1i import ModpackVersionCard from "@/components/server/ModpackVersionCard";' resources/scripts/components/server/console/ServerConsoleContainer.tsx + sed -i '/' resources/scripts/components/server/console/ServerConsoleContainer.tsx + echo "✓ Injected ModpackVersionCard into ServerConsoleContainer.tsx" +else + echo "○ ModpackVersionCard already present in ServerConsoleContainer.tsx" +fi + +# =========================================== +# 2. DASHBOARD BADGE (UpdateBadge.tsx) +# =========================================== +echo "" +echo "--- Dashboard Badge ---" + +if [ -f "$EXT_DIR/views/dashboard/UpdateBadge.tsx" ]; then + # Ensure target directory exists + mkdir -p resources/scripts/components/dashboard + cp "$EXT_DIR/views/dashboard/UpdateBadge.tsx" resources/scripts/components/dashboard/UpdateBadge.tsx + echo "✓ Copied UpdateBadge.tsx" +else + echo "⚠ UpdateBadge.tsx not found, skipping dashboard badge" +fi + +# Inject into ServerRow.tsx (dashboard server list) +if ! grep -q "UpdateBadge" resources/scripts/components/dashboard/ServerRow.tsx 2>/dev/null; then + # Add import at top + sed -i '1i import UpdateBadge from "@/components/dashboard/UpdateBadge";' resources/scripts/components/dashboard/ServerRow.tsx + + # Inject badge right after the server name + # The pattern looks for the server name paragraph and adds our badge inside it + sed -i 's|

{server.name}

|

{server.name}

|' resources/scripts/components/dashboard/ServerRow.tsx + + echo "✓ Injected UpdateBadge into ServerRow.tsx" +else + echo "○ UpdateBadge already present in ServerRow.tsx" +fi + +# =========================================== +# 3. CONSOLE COMMAND (CheckModpackUpdates.php) +# =========================================== +echo "" +echo "--- Console Command ---" + +if [ -f "$EXT_DIR/console/CheckModpackUpdates.php" ]; then + cp "$EXT_DIR/console/CheckModpackUpdates.php" app/Console/Commands/CheckModpackUpdates.php + echo "✓ Copied CheckModpackUpdates.php to app/Console/Commands/" +else + echo "⚠ CheckModpackUpdates.php not found, skipping cron command" +fi + +echo "" +echo "==========================================" +echo "ModpackChecker injection complete!" +echo "==========================================" +echo "" +echo "Next steps:" +echo " 1. Run: yarn build:production" +echo " 2. Restart: systemctl restart php8.3-fpm" +echo " 3. Test cron: php artisan modpackchecker:check" +echo "" diff --git a/services/modpack-version-checker/blueprint-extension/console/CheckModpackUpdates.php b/services/modpack-version-checker/blueprint-extension/console/CheckModpackUpdates.php new file mode 100644 index 0000000..d72ac4f --- /dev/null +++ b/services/modpack-version-checker/blueprint-extension/console/CheckModpackUpdates.php @@ -0,0 +1,188 @@ +info('Starting modpack update check...'); + + // Get all servers that have modpack variables set + $servers = Server::whereHas('variables', function ($q) { + $q->where('env_variable', 'MODPACK_PLATFORM'); + })->get(); + + $this->info("Found {$servers->count()} servers with modpack configuration"); + + foreach ($servers as $server) { + $this->checkServer($server); + // Rate limiting - sleep between checks + sleep(2); + } + + $this->info('Modpack update check complete!'); + return 0; + } + + private function checkServer(Server $server): void + { + $this->line("Checking: {$server->name} ({$server->uuid})"); + + try { + // Get platform and modpack ID from variables + $platform = $this->getVariable($server, 'MODPACK_PLATFORM'); + $modpackId = $this->getVariable($server, 'MODPACK_ID'); + + if (!$platform || !$modpackId) { + $this->warn(" Skipping - missing platform or modpack ID"); + return; + } + + // Get latest version from API + $latestData = $this->fetchLatestVersion($platform, $modpackId); + + if (!$latestData) { + $this->error(" Failed to fetch version data"); + $this->updateDatabase($server, [ + 'platform' => $platform, + 'modpack_id' => $modpackId, + 'error_message' => 'Failed to fetch version data', + 'update_available' => false, + ]); + return; + } + + // Get current version (from variable or file - for now just use variable) + $currentVersion = $this->getVariable($server, 'MODPACK_CURRENT_VERSION'); + + // Determine if update is available + $updateAvailable = $currentVersion && $currentVersion !== $latestData['version']; + + $this->updateDatabase($server, [ + 'platform' => $platform, + 'modpack_id' => $modpackId, + 'modpack_name' => $latestData['name'], + 'current_version' => $currentVersion, + 'latest_version' => $latestData['version'], + 'update_available' => $updateAvailable, + 'error_message' => null, + ]); + + $status = $updateAvailable ? '🟠 UPDATE AVAILABLE' : '🟢 Up to date'; + $this->info(" {$status}: {$latestData['name']} - {$latestData['version']}"); + + } catch (\Exception $e) { + $this->error(" Error: {$e->getMessage()}"); + $this->updateDatabase($server, [ + 'error_message' => $e->getMessage(), + 'update_available' => false, + ]); + } + } + + private function getVariable(Server $server, string $name): ?string + { + $variable = $server->variables() + ->where('env_variable', $name) + ->first(); + return $variable?->server_value; + } + + private function fetchLatestVersion(string $platform, string $modpackId): ?array + { + return match($platform) { + 'modrinth' => $this->checkModrinth($modpackId), + 'curseforge' => $this->checkCurseForge($modpackId), + 'ftb' => $this->checkFTB($modpackId), + 'technic' => $this->checkTechnic($modpackId), + default => null, + }; + } + + private function checkModrinth(string $projectId): ?array + { + $response = Http::withHeaders([ + 'User-Agent' => 'FirefrostGaming/ModpackChecker/1.0', + ])->get("https://api.modrinth.com/v2/project/{$projectId}"); + + if (!$response->successful()) return null; + $project = $response->json(); + + $versionResponse = Http::withHeaders([ + 'User-Agent' => 'FirefrostGaming/ModpackChecker/1.0', + ])->get("https://api.modrinth.com/v2/project/{$projectId}/version"); + + $versions = $versionResponse->json(); + + return [ + 'name' => $project['title'] ?? 'Unknown', + 'version' => $versions[0]['version_number'] ?? 'Unknown', + ]; + } + + private function checkCurseForge(string $modpackId): ?array + { + $apiKey = DB::table('settings') + ->where('key', 'like', '%modpackchecker::curseforge_api_key%') + ->value('value'); + + if (!$apiKey) return null; + + $response = Http::withHeaders([ + 'x-api-key' => $apiKey, + ])->get("https://api.curseforge.com/v1/mods/{$modpackId}"); + + if (!$response->successful()) return null; + $data = $response->json()['data'] ?? []; + + return [ + 'name' => $data['name'] ?? 'Unknown', + 'version' => $data['latestFiles'][0]['displayName'] ?? 'Unknown', + ]; + } + + private function checkFTB(string $modpackId): ?array + { + $response = Http::get("https://api.modpacks.ch/public/modpack/{$modpackId}"); + if (!$response->successful()) return null; + $data = $response->json(); + + return [ + 'name' => $data['name'] ?? 'Unknown', + 'version' => $data['versions'][0]['name'] ?? 'Unknown', + ]; + } + + private function checkTechnic(string $slug): ?array + { + $response = Http::get("https://api.technicpack.net/modpack/{$slug}?build=1"); + if (!$response->successful()) return null; + $data = $response->json(); + + return [ + 'name' => $data['displayName'] ?? $data['name'] ?? 'Unknown', + 'version' => $data['version'] ?? 'Unknown', + ]; + } + + private function updateDatabase(Server $server, array $data): void + { + DB::table('modpackchecker_servers')->updateOrInsert( + ['server_id' => $server->id], + array_merge($data, [ + 'server_uuid' => $server->uuid, + 'last_checked' => now(), + 'updated_at' => now(), + ]) + ); + } +} diff --git a/services/modpack-version-checker/blueprint-extension/routes/client.php b/services/modpack-version-checker/blueprint-extension/routes/client.php index fcd0a1d..92d3ec5 100644 --- a/services/modpack-version-checker/blueprint-extension/routes/client.php +++ b/services/modpack-version-checker/blueprint-extension/routes/client.php @@ -14,3 +14,6 @@ use Pterodactyl\BlueprintFramework\Extensions\modpackchecker\Controllers\Modpack */ Route::post('/servers/{server}/ext/modpackchecker/check', [ModpackAPIController::class, 'manualCheck']); + +// Dashboard badge status endpoint - returns all servers' update status in one call +Route::get('/extensions/modpackchecker/status', [ModpackAPIController::class, 'getStatus']); diff --git a/services/modpack-version-checker/blueprint-extension/views/dashboard/UpdateBadge.tsx b/services/modpack-version-checker/blueprint-extension/views/dashboard/UpdateBadge.tsx new file mode 100644 index 0000000..5348bc6 --- /dev/null +++ b/services/modpack-version-checker/blueprint-extension/views/dashboard/UpdateBadge.tsx @@ -0,0 +1,110 @@ +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) + */ + +interface ServerStatus { + update_available: boolean; + modpack_name?: string; + current_version?: string; + latest_version?: string; +} + +interface StatusCache { + [serverUuid: string]: ServerStatus; +} + +// Global cache - shared across all UpdateBadge instances +// Prevents multiple API calls when rendering server list +let globalCache: StatusCache | null = null; +let fetchPromise: Promise | null = null; + +const fetchAllStatuses = async (): Promise => { + // Return cached data if available + if (globalCache !== null) { + return globalCache; + } + + // If already fetching, wait for that promise + if (fetchPromise !== null) { + return fetchPromise; + } + + // Start new fetch + fetchPromise = http.get('/api/client/extensions/modpackchecker/status') + .then((response) => { + globalCache = response.data || {}; + return globalCache; + }) + .catch((error) => { + console.error('ModpackChecker: Failed to fetch status', error); + globalCache = {}; + return globalCache; + }) + .finally(() => { + fetchPromise = null; + }); + + return fetchPromise; +}; + +interface UpdateBadgeProps { + serverUuid: string; +} + +const UpdateBadge: React.FC = ({ serverUuid }) => { + const [status, setStatus] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + fetchAllStatuses() + .then((cache) => { + setStatus(cache[serverUuid] || null); + setLoading(false); + }); + }, [serverUuid]); + + // Don't render anything while loading or if no status + if (loading || !status) { + return null; + } + + // Only show badge if we have modpack data + if (!status.modpack_name) { + return null; + } + + const dotStyle: React.CSSProperties = { + display: 'inline-block', + width: '8px', + height: '8px', + borderRadius: '50%', + marginLeft: '8px', + backgroundColor: status.update_available ? '#FF6B35' : '#4ECDC4', // Fire : Frost + boxShadow: status.update_available + ? '0 0 4px rgba(255, 107, 53, 0.5)' + : '0 0 4px rgba(78, 205, 196, 0.5)', + }; + + const tooltipText = status.update_available + ? `Update available: ${status.latest_version}` + : `Up to date: ${status.latest_version}`; + + return ( + + ); +}; + +export default UpdateBadge;