Files
firefrost-operations-manual/docs/tasks/modpack-version-checker/dashboard.html
Claude (Chronicler #47) cfa838e86a feat: modpack version checker dashboard + PHP proxy (v1.0)
WHAT WAS DONE:
- Built browser dashboard (dashboard.html) showing installed vs latest version
  for all Pterodactyl game servers
- Built PHP proxy (proxy.php + config.php) for Billing VPS deployment
- Created isolated Nginx server block (version-proxy.conf)
- Created full deployment guide (DEPLOYMENT-GUIDE.md)

ARCHITECTURE:
- PHP proxy at /var/www/version-proxy on Billing VPS (38.68.14.188)
- Isolated from Paymenter/Laravel routing — separate directory + port
- API keys (Pterodactyl ptlc_, CurseForge) live server-side only
- FTB packs: fully automatic via .manifest.json + FTB public API
- CurseForge packs: reads manifest.json, needs CF Project ID + API key
- config.php blocked from direct web access via Nginx

PENDING AT DEPLOYMENT:
- Verify port 8080 is free (ss -tlnp) before enabling Nginx block
- Fill real API keys into config.php on server
- Enter CurseForge Project IDs for CF packs (saved in localStorage)

COLLABORATION:
- PHP proxy architecture designed by Gemini (consultation session 2026-03-29)
- Dashboard HTML and detection logic by Chronicler #47
- Gemini identified Laravel routing conflict and content-type gotcha

WHY:
- Interim solution before full Blueprint extension (post-launch)
- Hands-off modpack update monitoring for staff
- Zero manual checking required after initial CF Project ID setup

Signed-off-by: claude@firefrostgaming.com
2026-03-29 14:10:47 +00:00

547 lines
23 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Firefrost — Modpack Version Checker</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Rajdhani:wght@400;500;600;700&family=Share+Tech+Mono&display=swap');
:root {
--fire: #FF6B35;
--frost: #4ECDC4;
--gold: #FFD700;
--dark: #0F0F1E;
--dark2: #151528;
--dark3: #1a1a35;
--border: rgba(78,205,196,0.15);
--up-to-date: #4ade80;
--outdated: #f87171;
--unknown: #94a3b8;
--checking: #60a5fa;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: var(--dark);
color: #e2e8f0;
font-family: 'Rajdhani', sans-serif;
min-height: 100vh;
padding: 24px;
}
body::before {
content: '';
position: fixed;
inset: 0;
background-image:
linear-gradient(rgba(78,205,196,0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(78,205,196,0.03) 1px, transparent 1px);
background-size: 40px 40px;
pointer-events: none;
}
header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 32px;
flex-wrap: wrap;
gap: 12px;
}
.logo { display: flex; align-items: center; gap: 12px; }
.logo-text { font-size: 28px; font-weight: 700; letter-spacing: 2px; text-transform: uppercase; }
.logo-fire { color: var(--fire); }
.logo-frost { color: var(--frost); }
.subtitle { font-size: 13px; color: #64748b; letter-spacing: 3px; text-transform: uppercase; font-family: 'Share Tech Mono', monospace; }
.header-right { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.last-checked {
font-family: 'Share Tech Mono', monospace;
font-size: 12px; color: #475569;
padding: 6px 12px;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--dark2);
}
button {
font-family: 'Rajdhani', sans-serif;
font-size: 14px; font-weight: 600;
letter-spacing: 1px; text-transform: uppercase;
padding: 8px 20px; border: none; border-radius: 4px;
cursor: pointer; transition: all 0.2s;
}
.btn-refresh { background: var(--frost); color: var(--dark); }
.btn-refresh:hover { background: #5ee8de; transform: translateY(-1px); }
.btn-refresh:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
.summary { display: flex; gap: 16px; margin-bottom: 28px; flex-wrap: wrap; }
.summary-card {
flex: 1; min-width: 140px;
background: var(--dark2); border: 1px solid var(--border);
border-radius: 8px; padding: 16px 20px;
display: flex; align-items: center; gap: 12px;
}
.summary-dot { width: 12px; height: 12px; border-radius: 50%; flex-shrink: 0; }
.summary-label { font-size: 12px; color: #64748b; text-transform: uppercase; letter-spacing: 1px; }
.summary-count { font-size: 28px; font-weight: 700; line-height: 1; }
.servers-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
gap: 16px;
}
.server-card {
background: var(--dark2); border: 1px solid var(--border);
border-radius: 8px; overflow: hidden;
transition: border-color 0.2s, transform 0.2s;
}
.server-card:hover { border-color: rgba(78,205,196,0.35); transform: translateY(-2px); }
.server-card.outdated { border-color: rgba(248,113,113,0.3); }
.server-card.up-to-date { border-color: rgba(74,222,128,0.2); }
.card-header {
padding: 14px 18px; background: var(--dark3);
border-bottom: 1px solid var(--border);
display: flex; align-items: center; justify-content: space-between; gap: 8px;
}
.server-name { font-size: 15px; font-weight: 600; letter-spacing: 0.5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.node-badge { font-family: 'Share Tech Mono', monospace; font-size: 10px; padding: 2px 8px; border-radius: 3px; white-space: nowrap; flex-shrink: 0; }
.node-nc { background: rgba(255,107,53,0.15); color: var(--fire); border: 1px solid rgba(255,107,53,0.3); }
.node-tx { background: rgba(78,205,196,0.12); color: var(--frost); border: 1px solid rgba(78,205,196,0.25); }
.card-body { padding: 16px 18px; display: flex; flex-direction: column; gap: 10px; }
.platform-row { display: flex; align-items: center; gap: 8px; }
.platform-badge { font-family: 'Share Tech Mono', monospace; font-size: 10px; padding: 2px 8px; border-radius: 3px; font-weight: 600; }
.platform-ftb { background: rgba(255,107,53,0.15); color: #fb923c; border: 1px solid rgba(255,107,53,0.3); }
.platform-curseforge { background: rgba(249,115,22,0.12); color: #f97316; border: 1px solid rgba(249,115,22,0.25); }
.platform-modrinth { background: rgba(34,197,94,0.12); color: #4ade80; border: 1px solid rgba(34,197,94,0.25); }
.platform-unknown { background: rgba(148,163,184,0.1); color: #94a3b8; border: 1px solid rgba(148,163,184,0.2); }
.pack-name { font-size: 13px; color: #94a3b8; font-family: 'Share Tech Mono', monospace; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.version-comparison { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.version-box { background: var(--dark3); border-radius: 6px; padding: 10px 12px; border: 1px solid rgba(255,255,255,0.05); }
.version-label { font-size: 10px; color: #475569; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 4px; }
.version-value { font-family: 'Share Tech Mono', monospace; font-size: 16px; font-weight: 600; }
.v-installed { color: #e2e8f0; }
.v-latest-current { color: var(--up-to-date); }
.v-latest-newer { color: var(--outdated); }
.v-unknown { color: #475569; }
.status-row { display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-radius: 6px; font-size: 13px; font-weight: 600; letter-spacing: 0.5px; }
.status-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.status-up-to-date { background: rgba(74,222,128,0.08); border: 1px solid rgba(74,222,128,0.2); color: var(--up-to-date); }
.status-outdated { background: rgba(248,113,113,0.08); border: 1px solid rgba(248,113,113,0.2); color: var(--outdated); }
.status-checking { background: rgba(96,165,250,0.08); border: 1px solid rgba(96,165,250,0.2); color: var(--checking); }
.status-unknown { background: rgba(148,163,184,0.08); border: 1px solid rgba(148,163,184,0.15); color: var(--unknown); }
.mc-version { font-family: 'Share Tech Mono', monospace; font-size: 11px; color: #475569; }
.curseforge-id-row { display: flex; align-items: center; gap: 8px; }
.curseforge-id-row label { font-size: 11px; color: #475569; text-transform: uppercase; letter-spacing: 1px; white-space: nowrap; flex-shrink: 0; }
.curseforge-id-row input {
background: var(--dark3); border: 1px solid rgba(78,205,196,0.15);
border-radius: 3px; padding: 4px 8px; color: #94a3b8;
font-family: 'Share Tech Mono', monospace; font-size: 12px;
outline: none; width: 100%; transition: border-color 0.2s;
}
.curseforge-id-row input:focus { border-color: var(--frost); color: #e2e8f0; }
.btn-check-single {
background: transparent; border: 1px solid rgba(78,205,196,0.3);
color: var(--frost); padding: 4px 12px; font-size: 11px;
border-radius: 3px; flex-shrink: 0;
}
.btn-check-single:hover { background: rgba(78,205,196,0.1); }
.spinner {
display: inline-block; width: 12px; height: 12px;
border: 2px solid rgba(96,165,250,0.3); border-top-color: var(--checking);
border-radius: 50%; animation: spin 0.8s linear infinite; flex-shrink: 0;
}
@keyframes spin { to { transform: rotate(360deg); } }
.error-state {
font-family: 'Share Tech Mono', monospace; font-size: 11px; color: #f87171;
padding: 6px 10px; background: rgba(248,113,113,0.06);
border-radius: 4px; border: 1px solid rgba(248,113,113,0.15);
}
footer { margin-top: 40px; text-align: center; font-size: 11px; color: #1e293b; font-family: 'Share Tech Mono', monospace; letter-spacing: 2px; }
@media (max-width: 600px) {
.servers-grid { grid-template-columns: 1fr; }
.version-comparison { grid-template-columns: 1fr 1fr; }
}
</style>
</head>
<body>
<header>
<div class="logo">
<div>
<div class="logo-text"><span class="logo-fire">FIRE</span><span class="logo-frost">FROST</span></div>
<div class="subtitle">Modpack Version Monitor</div>
</div>
</div>
<div class="header-right">
<div class="last-checked" id="lastChecked">Never checked</div>
<button class="btn-refresh" id="refreshBtn" onclick="checkAll()">&#8635; Check All</button>
</div>
</header>
<div class="summary">
<div class="summary-card">
<div class="summary-dot" style="background:var(--up-to-date)"></div>
<div><div class="summary-label">Up to Date</div><div class="summary-count" id="countCurrent" style="color:var(--up-to-date)"></div></div>
</div>
<div class="summary-card">
<div class="summary-dot" style="background:var(--outdated)"></div>
<div><div class="summary-label">Outdated</div><div class="summary-count" id="countOutdated" style="color:var(--outdated)"></div></div>
</div>
<div class="summary-card">
<div class="summary-dot" style="background:var(--unknown)"></div>
<div><div class="summary-label">Unknown</div><div class="summary-count" id="countUnknown" style="color:var(--unknown)"></div></div>
</div>
<div class="summary-card">
<div class="summary-dot" style="background:var(--frost)"></div>
<div><div class="summary-label">Total Servers</div><div class="summary-count" id="countTotal" style="color:var(--frost)"></div></div>
</div>
</div>
<div class="servers-grid" id="serversGrid">
<div style="color:#475569;font-family:'Share Tech Mono',monospace;font-size:13px;grid-column:1/-1;padding:40px;text-align:center;">
Click "Check All" to load servers and check versions.
</div>
</div>
<footer>FIRE + FROST + FOUNDATION &nbsp;|&nbsp; FOR CHILDREN NOT YET BORN &nbsp;💙</footer>
<script>
// ─── CONFIGURATION ───────────────────────────────────────────────
// Point this to your PHP proxy. Verify port with `ss -tlnp` before deploying.
// Default: http://38.68.14.188:8080/proxy.php
// If 8080 is taken, change to 8081 or 8090 here and in your Nginx config.
const PROXY_URL = 'http://38.68.14.188:8080/proxy.php';
// ─── STATE ───────────────────────────────────────────────────────
const serverStates = {};
const packMappings = JSON.parse(localStorage.getItem('ffg_pack_mappings') || '{}');
function saveMappings() {
localStorage.setItem('ffg_pack_mappings', JSON.stringify(packMappings));
}
// ─── PROXY API CALLS ─────────────────────────────────────────────
// All requests go through the PHP proxy — no API keys in the browser.
async function apiCall(action, params = '') {
const resp = await fetch(`${PROXY_URL}?action=${action}${params}`);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
return action === 'read' ? resp.text() : resp.json();
}
async function getFileContents(identifier, path) {
try {
return await apiCall('read', `&server=${identifier}&file=${encodeURIComponent(path)}`);
} catch { return null; }
}
async function listFiles(identifier) {
try {
const data = await apiCall('files', `&server=${identifier}`);
return data.data.map(f => f.attributes.name);
} catch { return []; }
}
// ─── VERSION DETECTION ───────────────────────────────────────────
async function detectInstalledVersion(identifier) {
const files = await listFiles(identifier);
// FTB (.manifest.json)
if (files.includes('.manifest.json')) {
const content = await getFileContents(identifier, '.manifest.json');
if (content) {
try {
const data = JSON.parse(content);
return { platform: 'ftb', packName: data.name, version: data.versionName, ftbPackId: data.id, ftbVersionId: data.versionId };
} catch {}
}
}
// Modrinth (modrinth.index.json)
if (files.includes('modrinth.index.json')) {
const content = await getFileContents(identifier, 'modrinth.index.json');
if (content) {
try {
const data = JSON.parse(content);
return { platform: 'modrinth', packName: data.name, version: data.versionId || 'unknown' };
} catch {}
}
}
// CurseForge (manifest.json)
if (files.includes('manifest.json')) {
const content = await getFileContents(identifier, 'manifest.json');
if (content) {
try {
const data = JSON.parse(content);
if (data.minecraft || data.name) {
return { platform: 'curseforge', packName: data.name || 'Unknown', version: data.version || 'unknown' };
}
} catch {}
}
}
return { platform: 'unknown', packName: null, version: null };
}
// ─── LATEST VERSION LOOKUPS ──────────────────────────────────────
async function getLatestFtbVersion(packId) {
try {
// FTB API is public — no proxy needed, no CORS issue
const resp = await fetch(`https://api.modpacks.ch/public/modpack/${packId}`);
if (!resp.ok) return null;
const data = await resp.json();
const versions = (data.versions || []).filter(v => v.type === 'Release' || v.type === 'release');
if (versions.length === 0) return data.versions?.[0]?.name || null;
return versions[versions.length - 1].name;
} catch { return null; }
}
async function getLatestCurseForgeVersion(projectId) {
// Routes through proxy — CF API key stays server-side
if (!projectId) return null;
try {
const data = await apiCall('curseforge', `&project=${projectId}`);
const file = data.data?.[0];
if (!file) return null;
return file.displayName || file.fileName;
} catch { return null; }
}
// ─── VERSION COMPARISON ──────────────────────────────────────────
function compareVersions(installed, latest) {
if (!installed || !latest) return 'unknown';
const a = installed.trim().toLowerCase().replace(/^v/, '');
const b = latest.trim().toLowerCase().replace(/^v/, '');
if (a === b) return 'current';
const aParts = a.split('.').map(x => parseInt(x) || 0);
const bParts = b.split('.').map(x => parseInt(x) || 0);
const len = Math.max(aParts.length, bParts.length);
for (let i = 0; i < len; i++) {
const av = aParts[i] || 0;
const bv = bParts[i] || 0;
if (bv > av) return 'outdated';
if (av > bv) return 'current';
}
return 'current';
}
// ─── CARD RENDERING ──────────────────────────────────────────────
function renderCard(server, state) {
const identifier = server.identifier;
const name = server.name;
const node = name.includes('- NC') ? 'NC' : name.includes('- TX') ? 'TX' : '?';
const mapping = packMappings[identifier] || {};
const platformLabels = { ftb: 'FTB', curseforge: 'CurseForge', modrinth: 'Modrinth', unknown: 'Unknown' };
const platformClass = { ftb: 'platform-ftb', curseforge: 'platform-curseforge', modrinth: 'platform-modrinth', unknown: 'platform-unknown' };
let statusHtml = '', installedVersionHtml = '', latestVersionHtml = '', cardClass = '', extraControls = '';
if (state.loading) {
statusHtml = `<div class="status-row status-checking"><div class="spinner"></div> Checking...</div>`;
installedVersionHtml = `<span class="v-unknown">—</span>`;
latestVersionHtml = `<span class="v-unknown">—</span>`;
} else if (state.error) {
statusHtml = `<div class="error-state">Error: ${state.error}</div>`;
installedVersionHtml = `<span class="v-unknown">—</span>`;
latestVersionHtml = `<span class="v-unknown">—</span>`;
} else {
const installed = state.version;
const latest = state.latestVersion;
const platform = state.platform || 'unknown';
const status = state.status || 'unknown';
installedVersionHtml = installed
? `<span class="v-installed">${installed}</span>`
: `<span class="v-unknown">Not detected</span>`;
latestVersionHtml = latest
? `<span class="${status === 'outdated' ? 'v-latest-newer' : 'v-latest-current'}">${latest}</span>`
: `<span class="v-unknown">—</span>`;
if (status === 'current') {
statusHtml = `<div class="status-row status-up-to-date"><div class="status-dot" style="background:var(--up-to-date)"></div> Up to Date</div>`;
cardClass = 'up-to-date';
} else if (status === 'outdated') {
statusHtml = `<div class="status-row status-outdated"><div class="status-dot" style="background:var(--outdated)"></div> Update Available</div>`;
cardClass = 'outdated';
} else {
statusHtml = `<div class="status-row status-unknown"><div class="status-dot" style="background:var(--unknown)"></div> Unknown</div>`;
}
if (platform === 'curseforge' || platform === 'unknown') {
const cfId = mapping.cfId || '';
extraControls = `
<div class="curseforge-id-row">
<label>CF Project ID</label>
<input type="text" placeholder="e.g. 389615" value="${cfId}"
onchange="updateMapping('${identifier}', 'cfId', this.value)" />
<button class="btn-check-single" onclick="checkSingle('${identifier}')">Check</button>
</div>`;
}
}
const packNameDisplay = state.packName
? `<span class="pack-name" title="${state.packName}">${state.packName}</span>`
: '';
return `
<div class="server-card ${cardClass}" id="card-${identifier}">
<div class="card-header">
<div class="server-name" title="${name}">${name.replace(' - TX','').replace(' - NC','')}</div>
<span class="node-badge node-${node.toLowerCase()}">${node}</span>
</div>
<div class="card-body">
<div class="platform-row">
<span class="platform-badge ${platformClass[state.platform || 'unknown']}">${platformLabels[state.platform || 'unknown']}</span>
${packNameDisplay}
</div>
<div class="version-comparison">
<div class="version-box"><div class="version-label">Installed</div><div class="version-value">${installedVersionHtml}</div></div>
<div class="version-box"><div class="version-label">Latest</div><div class="version-value">${latestVersionHtml}</div></div>
</div>
${statusHtml}
${extraControls}
<div class="mc-version">MC ${server.mcVersion || '?'}</div>
</div>
</div>`;
}
// ─── STATE MANAGEMENT ────────────────────────────────────────────
function updateMapping(identifier, key, value) {
if (!packMappings[identifier]) packMappings[identifier] = {};
packMappings[identifier][key] = value;
saveMappings();
}
function updateCardDisplay(identifier) {
const card = document.getElementById(`card-${identifier}`);
if (!card) return;
card.outerHTML = renderCard(serverStates[identifier]._serverData, serverStates[identifier]);
}
function updateSummary() {
const states = Object.values(serverStates);
document.getElementById('countCurrent').textContent = states.filter(s => s.status === 'current').length;
document.getElementById('countOutdated').textContent = states.filter(s => s.status === 'outdated').length;
document.getElementById('countUnknown').textContent = states.filter(s => s.status === 'unknown' || s.loading).length;
document.getElementById('countTotal').textContent = states.length;
}
// ─── CORE CHECK LOGIC ────────────────────────────────────────────
async function checkServerVersion(identifier) {
const state = serverStates[identifier];
const detected = await detectInstalledVersion(identifier);
state.platform = detected.platform;
state.packName = detected.packName;
state.version = detected.version;
const mapping = packMappings[identifier] || {};
if (detected.platform === 'ftb' && detected.ftbPackId) {
state.latestVersion = await getLatestFtbVersion(detected.ftbPackId);
} else if (detected.platform === 'curseforge' && mapping.cfId) {
state.latestVersion = await getLatestCurseForgeVersion(mapping.cfId);
} else if (mapping.cfId) {
state.platform = 'curseforge';
state.latestVersion = await getLatestCurseForgeVersion(mapping.cfId);
} else {
state.latestVersion = null;
}
state.status = compareVersions(state.version, state.latestVersion);
state.loading = false;
}
async function checkSingle(identifier) {
if (!serverStates[identifier]) return;
serverStates[identifier].loading = true;
serverStates[identifier].error = null;
updateCardDisplay(identifier);
try {
await checkServerVersion(identifier);
} catch(e) {
serverStates[identifier].loading = false;
serverStates[identifier].error = e.message;
}
updateCardDisplay(identifier);
updateSummary();
}
async function checkAll() {
const btn = document.getElementById('refreshBtn');
btn.disabled = true;
btn.textContent = 'Checking...';
const grid = document.getElementById('serversGrid');
grid.innerHTML = '<div style="color:#475569;font-family:\'Share Tech Mono\',monospace;font-size:13px;grid-column:1/-1;padding:40px;text-align:center;">Loading servers...</div>';
try {
const allServersResp = await apiCall('servers');
const servers = allServersResp.data.map(s => ({
identifier: s.attributes.identifier,
name: s.attributes.name,
mcVersion: null,
}));
for (const server of servers) {
serverStates[server.identifier] = {
loading: true, error: null, platform: 'unknown',
packName: null, version: null, latestVersion: null, status: 'unknown',
_serverData: server
};
}
grid.innerHTML = servers.map(s => renderCard(s, serverStates[s.identifier])).join('');
document.getElementById('countTotal').textContent = servers.length;
await Promise.all(servers.map(async (server) => {
try {
await checkServerVersion(server.identifier);
} catch(e) {
serverStates[server.identifier].loading = false;
serverStates[server.identifier].error = e.message;
}
updateCardDisplay(server.identifier);
updateSummary();
}));
document.getElementById('lastChecked').textContent = 'Last: ' + new Date().toLocaleTimeString();
} catch(e) {
grid.innerHTML = `<div style="color:#f87171;font-family:'Share Tech Mono',monospace;font-size:13px;grid-column:1/-1;padding:40px;text-align:center;">Error: ${e.message}<br><br><small style="color:#475569">Is the proxy running? Check http://38.68.14.188:[PORT]/proxy.php</small></div>`;
}
btn.disabled = false;
btn.textContent = '↻ Check All';
}
</script>
</body>
</html>