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