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;