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:
@@ -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
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user