Modpack Installer: 7 UI tweaks before Holly review

1. Node selector shows live RAM/disk usage from Pterodactyl API (/node-info endpoint)
2. Disk allocation field, pre-filled at 3× estimated pack size
3. JVM args textarea auto-populated with Aikar G1GC flags
4. Spawn type: added 'vanilla' as new default, cleaned labels
5. Java version auto-selects from MC version (<=1.16→8, 1.17-1.20→17, 1.21+→21)
6. Port auto-assigned from max used port on node (/next-port endpoint), read-only + Re-roll
7. Pack size estimate shown on details card from provider file metadata

New endpoints: GET /node-info (Pterodactyl nodes API), GET /next-port?node=X (DB query)
All changes in _pack_details.ejs + modpack-installer.js. No migrations.
This commit is contained in:
Claude Code
2026-04-16 01:33:30 -05:00
parent f3e12beb2a
commit e2ddbe60de
3 changed files with 213 additions and 16 deletions

View File

@@ -14,10 +14,91 @@
const express = require('express');
const router = express.Router();
const axios = require('axios');
const db = require('../../database');
const { getProvider, listProviders } = require('../../services/providerApi');
const { estimateRam, slugify } = require('../../services/modpackInstaller');
const PANEL_URL = process.env.PANEL_URL || 'https://panel.firefrostgaming.com';
const PANEL_ADMIN_KEY = process.env.PANEL_ADMIN_KEY || '';
// Aikar G1GC flags template — {RAM} replaced with allocation in MB
const AIKAR_FLAGS = '-XX:+UseG1GC -XX:+ParallelRefProcEnabled -XX:MaxGCPauseMillis=200 ' +
'-XX:+UnlockExperimentalVMOptions -XX:+DisableExplicitGC -XX:+AlwaysPreTouch ' +
'-XX:G1NewSizePercent=30 -XX:G1MaxNewSizePercent=40 -XX:G1HeapRegionSize=8M ' +
'-XX:G1ReservePercent=20 -XX:G1HeapWastePercent=5 -XX:G1MixedGCCountTarget=4 ' +
'-XX:InitiatingHeapOccupancyPercent=15 -XX:G1MixedGCLiveThresholdPercent=90 ' +
'-XX:G1RSetUpdatingPauseTimePercent=5 -XX:SurvivorRatio=32 -XX:+PerfDisableSharedMem ' +
'-XX:MaxTenuringThreshold=1 -Dusing.aikars.flags=https://mcflags.emc.gs ' +
'-Daikars.new.flags=true';
function javaVersionForMC(mcVersion) {
if (!mcVersion) return 21;
const parts = mcVersion.split('.').map(Number);
const minor = parts[1] || 0;
if (minor <= 16) return 8;
if (minor <= 20) return 17;
return 21;
}
// ─── Node resource usage (JSON for form) ────────────────────────────────────
router.get('/node-info', async (req, res) => {
try {
const panelHeaders = {
'Authorization': `Bearer ${PANEL_ADMIN_KEY}`,
'Accept': 'application/json'
};
const nodesResp = await axios.get(`${PANEL_URL}/api/application/nodes`, {
headers: panelHeaders, timeout: 10000
});
const nodes = (nodesResp.data.data || []).map(n => {
const a = n.attributes;
return {
id: a.id, name: a.name, location_id: a.location_id,
memory: a.memory, memory_overallocate: a.memory_overallocate,
disk: a.disk, disk_overallocate: a.disk_overallocate,
allocated_resources: a.allocated_resources || {}
};
});
// Map to our node IDs
const result = {};
for (const n of nodes) {
const key = n.name.includes('TX') ? 'TX1' : n.name.includes('NC') ? 'NC1' : n.name;
const usedRam = n.allocated_resources.memory || 0;
const usedDisk = n.allocated_resources.disk || 0;
result[key] = {
label: key === 'TX1' ? '🔥 TX1 — Dallas' : '❄️ NC1 — Charlotte',
ramUsedMb: usedRam, ramTotalMb: n.memory,
diskUsedMb: usedDisk, diskTotalMb: n.disk
};
}
res.json(result);
} catch (err) {
console.error('[Installer] node-info error:', err.message);
res.json({
NC1: { label: '❄️ NC1 — Charlotte', ramUsedMb: 0, ramTotalMb: 65536, diskUsedMb: 0, diskTotalMb: 2048000 },
TX1: { label: '🔥 TX1 — Dallas', ramUsedMb: 0, ramTotalMb: 65536, diskUsedMb: 0, diskTotalMb: 2048000 }
});
}
});
// ─── Next available port on a node ──────────────────────────────────────────
router.get('/next-port', async (req, res) => {
try {
const node = req.query.node || 'NC1';
// Query server_config for all servers on this node, find max port
const result = await db.query(
`SELECT COALESCE(MAX(server_port), 25564) AS max_port FROM server_config WHERE node = $1`,
[node]
);
const nextPort = result.rows[0].max_port + 1;
res.json({ port: nextPort, node });
} catch (err) {
console.error('[Installer] next-port error:', err.message);
res.json({ port: 25565, node: req.query.node || 'NC1' });
}
});
// ─── Main page ──────────────────────────────────────────────────────────────
router.get('/', (req, res) => {
res.render('admin/modpack-installer/index', {
@@ -69,6 +150,8 @@ router.get('/pack/:provider/:id', async (req, res) => {
provider: req.params.provider,
estimateRam,
slugify,
javaVersionForMC,
aikarFlags: AIKAR_FLAGS,
csrfToken: req.csrfToken(),
layout: false
});

View File

@@ -1,8 +1,19 @@
<!-- Pack details + install form partial (HTMX) -->
<!-- REQ-2026-04-16-modpack-installer-tweaks: all 7 tweaks applied -->
<%
// Derive first MC version for Java auto-select
var firstVersion = versions.length > 0 ? (versions[0].gameVersions || [])[0] || '' : '';
var autoJava = javaVersionForMC(firstVersion);
// Estimate pack size from first version file
var firstFile = versions.length > 0 ? (versions[0].files ? versions[0].files[0] : versions[0]) : {};
var estSizeMb = firstFile.fileLength ? Math.round(firstFile.fileLength / 1024 / 1024) : (firstFile.size ? Math.round(firstFile.size / 1024 / 1024) : 0);
%>
<div class="bg-white dark:bg-darkcard rounded-lg border border-gray-200 dark:border-gray-700 p-5">
<h2 class="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3">3. Configure Install</h2>
<!-- Pack info + resource estimates (tweak #7) -->
<div class="flex gap-4 mb-4">
<% if (pack.thumbnail) { %>
<img src="<%= pack.thumbnail %>" alt="" class="w-20 h-20 rounded object-cover shrink-0">
@@ -10,13 +21,19 @@
<div>
<h3 class="text-lg font-bold"><%= pack.name %></h3>
<p class="text-sm text-gray-400 mt-1"><%= (pack.summary || '').substring(0, 200) %></p>
<% if (pack.categories && pack.categories.length > 0) { %>
<div class="flex gap-1 mt-2">
<% pack.categories.slice(0, 5).forEach(function(c) { %>
<span class="text-[10px] bg-gray-700 text-gray-300 px-1.5 py-0.5 rounded"><%= c %></span>
<div class="flex gap-3 mt-2 text-xs">
<% if (pack.categories && pack.categories.length > 0) { %>
<% pack.categories.slice(0, 4).forEach(function(c) { %>
<span class="bg-gray-700 text-gray-300 px-1.5 py-0.5 rounded"><%= c %></span>
<% }) %>
</div>
<% } %>
<% } %>
<% if (estSizeMb > 0) { %>
<span class="bg-gray-700 text-cyan-300 px-1.5 py-0.5 rounded">~<%= estSizeMb %> MB</span>
<% } %>
<% if (pack.downloadCount) { %>
<span class="text-gray-500">⬇ <%= pack.downloadCount.toLocaleString() %></span>
<% } %>
</div>
</div>
</div>
@@ -29,9 +46,13 @@
<!-- Version selector -->
<div>
<label class="block text-sm font-medium mb-1">Version</label>
<select name="versionId" required class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm">
<select name="versionId" id="dd-version" required
onchange="onVersionChange(this)"
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm">
<% versions.slice(0, 20).forEach(function(v, i) { %>
<option value="<%= v.fileId || v.versionId %>" <%= i === 0 ? 'selected' : '' %>>
<option value="<%= v.fileId || v.versionId %>"
data-mc="<%= (v.gameVersions || [])[0] || '' %>"
<%= i === 0 ? 'selected' : '' %>>
<%= v.displayName || v.versionNumber || v.fileName %>
<% if (v.gameVersions && v.gameVersions.length) { %>(MC <%= v.gameVersions[0] %>)<% } %>
</option>
@@ -48,37 +69,80 @@
<!-- Short name / subdomain -->
<div>
<label class="block text-sm font-medium mb-1">Short Name <span class="text-gray-500 text-xs">(Discord prefix + subdomain)</span></label>
<input name="shortName" required value="<%= slugify(pack.name) %>" pattern="[a-z0-9-]+"
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm font-mono">
<div class="text-[10px] text-gray-500 mt-1">→ <span class="text-cyan-400"><%= slugify(pack.name) %>.firefrostgaming.com</span></div>
<label class="block text-sm font-medium mb-1">Short Name <span class="text-gray-500 text-xs">(Discord + subdomain)</span></label>
<input name="shortName" id="dd-shortname" required value="<%= slugify(pack.name) %>" pattern="[a-z0-9-]+"
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm font-mono"
oninput="document.getElementById('subdomain-preview').textContent=this.value+'.firefrostgaming.com'">
<div class="text-[10px] text-gray-500 mt-1">→ <span id="subdomain-preview" class="text-cyan-400"><%= slugify(pack.name) %>.firefrostgaming.com</span></div>
</div>
<!-- Node -->
<!-- Node (tweak #1: live resource usage) -->
<div>
<label class="block text-sm font-medium mb-1">Node</label>
<select name="node" required class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm">
<select name="node" id="dd-node" required onchange="onNodeChange(this.value)"
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm">
<option value="NC1">❄️ NC1 — Charlotte</option>
<option value="TX1">🔥 TX1 — Dallas</option>
</select>
<div id="node-usage" class="text-[10px] text-gray-500 mt-1">Loading node stats...</div>
</div>
<!-- RAM -->
<div>
<label class="block text-sm font-medium mb-1">RAM (MB)</label>
<input name="ramMb" type="number" required value="8192" min="4096" max="32768" step="1024"
<input name="ramMb" id="dd-ram" type="number" required value="8192" min="4096" max="32768" step="1024"
onchange="updateJvmFlags()"
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm">
</div>
<!-- Spawn type -->
<!-- Disk (tweak #2) -->
<div>
<label class="block text-sm font-medium mb-1">Disk (MB)</label>
<input name="diskMb" id="dd-disk" type="number" required value="<%= estSizeMb > 0 ? Math.max(estSizeMb * 3, 10240) : 20480 %>" min="5120" max="500000" step="1024"
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm">
<% if (estSizeMb > 0) { %>
<div class="text-[10px] text-gray-500 mt-1">Pack size ~<%= estSizeMb %>MB → pre-filled 3× for world growth</div>
<% } %>
</div>
<!-- Java version (tweak #5: auto-select from MC version) -->
<div>
<label class="block text-sm font-medium mb-1">Java Version</label>
<select name="javaVersion" id="dd-java" class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm">
<option value="8" <%= autoJava === 8 ? 'selected' : '' %>>Java 8</option>
<option value="17" <%= autoJava === 17 ? 'selected' : '' %>>Java 17</option>
<option value="21" <%= autoJava === 21 ? 'selected' : '' %>>Java 21</option>
</select>
<div class="text-[10px] text-gray-500 mt-1">Auto-selected for MC <%= firstVersion || 'unknown' %></div>
</div>
<!-- Port (tweak #6: auto-assign) -->
<div>
<label class="block text-sm font-medium mb-1">Port</label>
<div class="flex gap-2">
<input name="port" id="dd-port" type="number" readonly value="25565"
class="flex-1 bg-gray-900 border border-gray-700 rounded px-3 py-2 text-sm text-gray-400">
<button type="button" onclick="refreshPort()" class="bg-gray-700 hover:bg-gray-600 text-white px-3 py-1 rounded text-xs">🔄 Re-roll</button>
</div>
</div>
<!-- Spawn type (tweak #4: add vanilla as default) -->
<div>
<label class="block text-sm font-medium mb-1">Spawn Type</label>
<select name="spawnType" class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm">
<option value="vanilla" selected>Vanilla (no spawn changes)</option>
<option value="standard">Standard (Bitch Bot pastes spawn)</option>
<option value="skyblock">Skyblock (no spawn paste)</option>
<option value="has_lobby">Has Lobby (pack provides its own)</option>
</select>
</div>
<!-- JVM args (tweak #3: Aikar flags auto-populated) -->
<div class="md:col-span-2">
<label class="block text-sm font-medium mb-1">JVM Arguments <span class="text-gray-500 text-xs">(Aikar G1GC — auto-populated)</span></label>
<textarea name="jvmArgs" id="dd-jvm" rows="3"
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-xs font-mono text-gray-300"><%= aikarFlags %></textarea>
</div>
</div>
<button type="submit"
@@ -87,3 +151,53 @@
</button>
</form>
</div>
<script>
var nodeData = {};
// Load node stats on partial render
fetch('/admin/modpack-installer/node-info', { headers: { 'HX-Request': 'true' } })
.then(function(r) { return r.json(); })
.then(function(d) {
nodeData = d;
showNodeUsage(document.getElementById('dd-node').value);
});
// Load initial port
refreshPort();
function showNodeUsage(node) {
var el = document.getElementById('node-usage');
var n = nodeData[node];
if (!n) { el.textContent = ''; return; }
var ramPct = Math.round(n.ramUsedMb / n.ramTotalMb * 100);
var diskGb = Math.round(n.diskUsedMb / 1024);
var diskTotalGb = Math.round(n.diskTotalMb / 1024);
el.innerHTML = 'RAM: <strong>' + Math.round(n.ramUsedMb/1024) + 'GB / ' + Math.round(n.ramTotalMb/1024) + 'GB</strong> (' + ramPct + '%) · Disk: <strong>' + diskGb + 'GB / ' + diskTotalGb + 'GB</strong>';
}
function onNodeChange(node) {
showNodeUsage(node);
refreshPort();
}
function refreshPort() {
var node = document.getElementById('dd-node').value;
fetch('/admin/modpack-installer/next-port?node=' + node, { headers: { 'HX-Request': 'true' } })
.then(function(r) { return r.json(); })
.then(function(d) { document.getElementById('dd-port').value = d.port; });
}
function onVersionChange(sel) {
var mc = sel.options[sel.selectedIndex].dataset.mc || '';
var parts = mc.split('.').map(Number);
var minor = parts[1] || 0;
var java = minor <= 16 ? 8 : minor <= 20 ? 17 : 21;
document.getElementById('dd-java').value = java;
}
function updateJvmFlags() {
// Aikar flags don't change per RAM — they're static. RAM is in -Xms/-Xmx which
// Pterodactyl handles via {{SERVER_MEMORY}} var. No rewrite needed.
}
</script>