feat(modpackchecker): Phase 5 complete - Dashboard badge and cron job

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) <claude@firefrostgaming.com>
This commit is contained in:
Claude (Chronicler #63)
2026-04-06 08:53:27 +00:00
parent 1eda8894d5
commit 0cbea6d993
5 changed files with 421 additions and 14 deletions

View File

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

View File

@@ -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 '/<ServerDetailsBlock className/a \ <ModpackVersionCard />' 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 '/<ServerDetailsBlock className/a \ <ModpackVersionCard />' 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|<p css={tw`text-lg break-words`}>{server.name}</p>|<p css={tw`text-lg break-words`}>{server.name}<UpdateBadge serverUuid={server.uuid} /></p>|' 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 ""

View File

@@ -0,0 +1,188 @@
<?php
namespace Pterodactyl\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Pterodactyl\Models\Server;
class CheckModpackUpdates extends Command
{
protected $signature = 'modpackchecker:check';
protected $description = 'Check all servers for modpack updates';
public function handle(): int
{
$this->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(),
])
);
}
}

View File

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

View File

@@ -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<StatusCache> | null = null;
const fetchAllStatuses = async (): Promise<StatusCache> => {
// 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<UpdateBadgeProps> = ({ serverUuid }) => {
const [status, setStatus] = useState<ServerStatus | null>(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 (
<span
style={dotStyle}
title={tooltipText}
aria-label={tooltipText}
/>
);
};
export default UpdateBadge;