Files
firefrost-services/services/modpack-version-checker/blueprint-extension/views/dashboard/UpdateBadge.tsx
Claude (Chronicler #63) 845d121fb2 chore(modpackchecker): Update authorship for commercial release
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>
2026-04-06 11:20:20 +00:00

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;