All files now credit: Firefrost Gaming / Frostystyle <dev@firefrostgaming.com> Updated: - conf.yml author field - README.md credits section - ModpackAPIController.php @author tag - CheckModpackUpdates.php @author tag - UpdateBadge.tsx @author tag Removed internal Chronicler references from commercial codebase. Signed-off-by: Claude (Chronicler #63) <claude@firefrostgaming.com>
320 lines
12 KiB
TypeScript
320 lines
12 KiB
TypeScript
/**
|
|
* =============================================================================
|
|
* 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 / Frostystyle <dev@firefrostgaming.com>
|
|
* @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';
|
|
|
|
// =============================================================================
|
|
// 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
|
|
// =============================================================================
|
|
//
|
|
// 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<StatusCache> | 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<StatusCache> => {
|
|
// FAST PATH: Return cached data if available
|
|
if (globalCache !== null) {
|
|
return globalCache;
|
|
}
|
|
|
|
// DEDUP PATH: If a fetch is already in progress, wait for it
|
|
// instead of starting another request
|
|
if (fetchPromise !== null) {
|
|
return fetchPromise;
|
|
}
|
|
|
|
// 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
|
|
* <p>{server.name}<UpdateBadge serverUuid={server.uuid} /></p>
|
|
* ```
|
|
*
|
|
* 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<UpdateBadgeProps> = ({ serverUuid }) => {
|
|
// =========================================================================
|
|
// STATE
|
|
// =========================================================================
|
|
|
|
/** This specific server's status (extracted from global cache) */
|
|
const [status, setStatus] = useState<ServerStatus | null>(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]); // Re-run if serverUuid changes (unlikely in practice)
|
|
|
|
// =========================================================================
|
|
// 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;
|
|
}
|
|
|
|
// 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%', // 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)' // 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 (
|
|
<span
|
|
style={dotStyle}
|
|
title={tooltipText} // Native browser tooltip
|
|
aria-label={tooltipText} // Accessibility for screen readers
|
|
/>
|
|
);
|
|
};
|
|
|
|
export default UpdateBadge;
|