feat(panel): edit memoryMB from the Settings modal

Adds GET/POST /api/daemon/config (safe subset of server-config.json --
just memoryMB for now) and a "Resources" section in the existing Settings
modal. The Save and Save+restart buttons now POST both /api/server/settings
and /api/daemon/config; restartRequired is OR'd across the two endpoints.

Future fields like backupKeep / backupSchedule / bluemapDir can be added
by extending DAEMON_FIELDS in settings.js + the validation block in
RunCommand.cs. Sensitive fields (passwords, manifestUrl, ports) are
intentionally kept off this endpoint.
This commit is contained in:
Matt Sijbers
2026-05-20 21:25:47 +01:00
parent 9bf3cc69b0
commit 683c2c19ff
4 changed files with 116 additions and 5 deletions
+43
View File
@@ -707,6 +707,49 @@ public sealed class RunCommand : AsyncCommand<BaseCommandSettings>
return Results.Json(new { ok = true, restartRequired = serverProc.IsRunning }); return Results.Json(new { ok = true, restartRequired = serverProc.IsRunning });
}); });
// ── Daemon config (safe subset of server-config.json) ──
// Lets the panel edit things that live in our own config, not server.properties:
// memoryMB (the JVM -Xmx). Sensitive fields (passwords, manifestUrl, ports) are
// intentionally not exposed here -- those still need direct file edits + restart.
app.MapGet("/api/daemon/config", () => Results.Json(new
{
memoryMB = config.MemoryMB,
}));
app.MapPost("/api/daemon/config", async (HttpContext ctx) =>
{
using var sr = new StreamReader(ctx.Request.Body);
var payload = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(
await sr.ReadToEndAsync(), JsonOpts.CaseInsensitive);
if (payload is null || payload.Count == 0)
return Results.BadRequest(new { ok = false, error = "Empty payload." });
var changed = false;
if (payload.TryGetValue("memoryMB", out var memEl))
{
int memMB;
if (memEl.ValueKind == JsonValueKind.Number) memMB = memEl.GetInt32();
else if (!int.TryParse(memEl.GetString(), out memMB))
return Results.BadRequest(new { ok = false, error = "memoryMB must be a number" });
// Sanity bounds. MC needs at least 1 GB; absolute cap of 64 GB protects against
// a fat-fingered entry that would refuse to start (JVM allocates Xmx up front).
if (memMB < 1024) return Results.BadRequest(new { ok = false, error = "memoryMB must be >= 1024" });
if (memMB > 65536) return Results.BadRequest(new { ok = false, error = "memoryMB must be <= 65536" });
if (config.MemoryMB != memMB)
{
config.MemoryMB = memMB;
changed = true;
}
}
if (changed) config.Save(configPath);
// Memory changes always require a restart -- the JVM can't grow -Xmx at runtime.
return Results.Json(new { ok = true, restartRequired = changed && serverProc.IsRunning });
});
// ── BlueMap (manual render) ── // ── BlueMap (manual render) ──
app.MapGet("/api/map/status", () => Results.Json(new app.MapGet("/api/map/status", () => Results.Json(new
{ {
+8 -1
View File
@@ -263,8 +263,15 @@
<button class="modal-close" aria-label="Close">×</button> <button class="modal-close" aria-label="Close">×</button>
</div> </div>
<p style="font-size: 12px; color: var(--text-muted); margin: 0 0 14px;"> <p style="font-size: 12px; color: var(--text-muted); margin: 0 0 14px;">
These map to <code>server.properties</code>. MC reads them at startup, so changes need a server restart to take effect. These map to <code>server.properties</code> and the daemon config. MC reads them at startup, so changes need a server restart to take effect.
</p> </p>
<div class="settings-section-label">Resources</div>
<div class="settings-grid">
<label>Memory (MB)<input id="ssfMemoryMB" type="number" min="1024" max="65536" step="256" /></label>
</div>
<div class="settings-section-label" style="margin-top: 14px;">MC server.properties</div>
<div class="settings-grid"> <div class="settings-grid">
<label>MOTD<input id="ssfMotd" type="text" /></label> <label>MOTD<input id="ssfMotd" type="text" /></label>
<label>Gamemode<select id="ssfGamemode"> <label>Gamemode<select id="ssfGamemode">
+57 -4
View File
@@ -25,6 +25,13 @@ const FIELDS = [
{ id: "ssfEnableCommandBlock", key: "enable-command-block", type: "bool" }, { id: "ssfEnableCommandBlock", key: "enable-command-block", type: "bool" },
]; ];
// Fields that live in server-config.json (the daemon's own config), not
// server.properties. Endpoint is /api/daemon/config, payload is JSON-typed
// (not stringified like server.properties).
const DAEMON_FIELDS = [
{ id: "ssfMemoryMB", key: "memoryMB", type: "int" },
];
function readForm() { function readForm() {
const out = {}; const out = {};
for (const f of FIELDS) { for (const f of FIELDS) {
@@ -64,22 +71,68 @@ function renderSummary(values) {
wl ? (enf ? "enforced" : "enabled") : "off"; wl ? (enf ? "enforced" : "enabled") : "off";
} }
function readDaemonForm() {
const out = {};
for (const f of DAEMON_FIELDS) {
const el = document.getElementById(f.id);
if (!el) continue;
if (f.type === "int") {
const v = parseInt(el.value, 10);
if (Number.isFinite(v)) out[f.key] = v;
} else {
out[f.key] = el.value;
}
}
return out;
}
function writeDaemonForm(values) {
for (const f of DAEMON_FIELDS) {
const el = document.getElementById(f.id);
if (!el) continue;
const v = values[f.key];
if (v === undefined) continue;
el.value = String(v);
}
}
async function refresh() { async function refresh() {
try { try {
const data = await api("/api/server/settings"); const data = await api("/api/server/settings");
renderSummary(data.values || {}); renderSummary(data.values || {});
writeForm(data.values || {}); writeForm(data.values || {});
} catch { /* ignore -- panel just shows last-known */ } } catch { /* ignore -- panel just shows last-known */ }
try {
const daemon = await api("/api/daemon/config");
writeDaemonForm(daemon || {});
} catch { /* ignore */ }
} }
async function postSettings() { async function postSettings() {
const payload = readForm(); // Two endpoints, two payloads. We always POST both -- backend short-circuits
const res = await fetch("/api/server/settings", { // when nothing changed, and combining them in one round-trip would mean
// either endpoint failing leaves the other in an inconsistent state.
const propsPayload = readForm();
const daemonPayload = readDaemonForm();
const propsRes = await fetch("/api/server/settings", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload), body: JSON.stringify(propsPayload),
}); });
return { ok: res.ok, body: await res.json().catch(() => ({})) }; const propsBody = await propsRes.json().catch(() => ({}));
const daemonRes = await fetch("/api/daemon/config", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(daemonPayload),
});
const daemonBody = await daemonRes.json().catch(() => ({}));
const ok = propsRes.ok && daemonRes.ok && propsBody.ok !== false && daemonBody.ok !== false;
const error = propsBody.error || daemonBody.error || null;
const restartRequired = (propsBody.restartRequired || daemonBody.restartRequired) ?? false;
return { ok, body: { ok, error, restartRequired } };
} }
function showMsg(text, ok = false) { function showMsg(text, ok = false) {
+8
View File
@@ -382,6 +382,14 @@ button.ghost-btn {
.card.collapsible:not(.expanded) h2 { margin: 0; } .card.collapsible:not(.expanded) h2 { margin: 0; }
/* Server settings modal */ /* Server settings modal */
.settings-section-label {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.05em;
text-transform: uppercase;
color: var(--text-muted);
margin-bottom: 8px;
}
.settings-grid { .settings-grid {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;