Console panel, collapsible panels, Add on the Members panel

- Server settings > Console sends a raw console command for staff with @css/rcon
  (POST /api/admin/rcon). It doesn't wait for the server; the output is only logged.
- Staff & ranks and Server settings panels fold up by clicking their heads. All but
  Ranks and Server start folded; a folded Ranks panel shows the selected rank.
- The Members panel has an Add button that opens Add staff with that rank picked.
- The public rank log shows a rank being given as Promoted and taken away as Demoted.
This commit is contained in:
Astra 2026-10-03 12:09:43 +01:00
parent 31901495de
commit b26360e8f3
8 changed files with 123 additions and 24 deletions

View file

@ -43,6 +43,7 @@ RCON. Without it the panel still works, but shows the server as offline and can'
| See IP addresses | `@css/showip` |
| Change map | `@css/changemap` |
| Server cvars | `@css/cvar` |
| Raw console commands (Server settings > Console; sent without waiting, output only logged) | `@css/rcon` |
| See the Staff & ranks page | `@zio/webpanel` |
| Give and remove verification overrides | `@zio/verify` (not on themselves, unless Root) |
| Ranks, staff, SimpleAdmin settings, reload admins, GroupVerify's group and on/off | `@css/root` |

View file

@ -353,6 +353,43 @@ func (s *Server) restartRound(w http.ResponseWriter, r *http.Request, id *Identi
s.console(w, id, "@css/generic", "css_rr", "Restarting the match.")
}
// rcon runs a raw console command for staff with @css/rcon, the same as css_rcon in game. It doesn't
// wait for the server: the command is sent in the background and what it prints is only logged.
func (s *Server) rcon(w http.ResponseWriter, r *http.Request, id *Identity, x *staffIndex) {
if !id.Has("@css/rcon") {
fail(w, http.StatusForbidden, "Your rank can't do that.")
return
}
var req struct {
Command string `json:"command"`
}
if err := readJSON(r, &req); err != nil {
fail(w, http.StatusBadRequest, err.Error())
return
}
// Newlines would send several commands; ';' still does, as it does in the console.
cmd := strings.TrimSpace(strings.NewReplacer("\r", " ", "\n", " ").Replace(req.Command))
if cmd == "" {
fail(w, http.StatusBadRequest, "Enter a command first.")
return
}
if len(cmd) > 512 {
fail(w, http.StatusBadRequest, "That command is too long.")
return
}
slog.Info("rcon", "admin", id.SteamID, "cmd", cmd)
go func() {
out, err := s.rc.Exec(cmd)
if err != nil {
slog.Warn("rcon failed", "admin", id.SteamID, "cmd", cmd, "err", err)
return
}
slog.Info("rcon output", "admin", id.SteamID, "out", strings.TrimSpace(out))
s.poll.Refresh()
}()
done(w, "Sent.")
}
func (s *Server) reloadAdmins(w http.ResponseWriter, r *http.Request, id *Identity, x *staffIndex) {
s.staff.invalidate()
s.console(w, id, "@css/root", "css_reloadadmins", "Reloaded admins.")

View file

@ -397,7 +397,7 @@ func (s *Server) readCvar(key string) (string, bool) {
}
func (s *Server) adminSettings(w http.ResponseWriter, r *http.Request, id *Identity, x *staffIndex) {
resp := map[string]any{"canCvar": id.Has("@css/cvar"), "canRoot": id.Has("@css/root")}
resp := map[string]any{"canCvar": id.Has("@css/cvar"), "canRoot": id.Has("@css/root"), "canRcon": id.Has("@css/rcon")}
if id.Has("@css/cvar") && s.poll.Current().Online {
defs := make([]cvarDef, 0, len(cvars))

View file

@ -23,6 +23,8 @@ const state = {
query: "",
num: 1,
group: null,
// Collapsed panels, see foldHead.
fold: { ranks: false, rank: true, members: true, sa: true, connections: true, maintenance: true, rcon: true },
me: null,
counts: {},
};
@ -274,6 +276,15 @@ async function loadResults() {
}
}
/* ---------- Collapsible panels ---------- */
// A collapsible panel's head: clicking anywhere on it but its own buttons opens or closes it.
// state.fold remembers which are closed until the page is reloaded; a panel not in it starts open.
const foldHead = (key, title, aside = "") => `<div class="panel-head fold-head" data-fold="${key}">
<h2><button class="fold-toggle" type="button" aria-expanded="${!state.fold[key]}">${ICON.chevron}${title}</button></h2>${aside}
</div>`;
const folded = (key) => (state.fold[key] ? " folded" : "");
/* ---------- Staff & ranks ---------- */
async function staff() {
@ -313,8 +324,8 @@ async function staff() {
</div>
${d.groups.length ? `<div class="ranks-layout">
<section class="panel">
<div class="panel-head"><h2>Ranks</h2><p>Immunity</p></div>
<section class="panel${folded("ranks")}">
${foldHead("ranks", "Ranks", `<p class="fold-open">Immunity</p><p class="fold-shut"><span class="rank" style="--c:${esc(g.color)}"><i></i>${esc(g.label)}</span></p>`)}
<ul class="ladder">
${staffGroups.map(ladderItem).join("")}
${supporters.length ? `<li class="sep">Supporter ranks</li>${supporters.map(ladderItem).join("")}` : ""}
@ -322,12 +333,9 @@ async function staff() {
</section>
<div class="stack">
<section class="panel">
<div class="panel-head">
<h2><span class="rank" style="--c:${esc(g.color)};font:inherit"><i></i>${esc(g.label)}</span></h2>
<span class="sid">${esc(g.name)}</span>
</div>
<form class="panel-body" id="group-form" style="display:grid;gap:18px" data-id="${g.id}">
<section class="panel${folded("rank")}">
${foldHead("rank", `<span class="rank" style="--c:${esc(g.color)};font:inherit"><i></i>${esc(g.label)}</span>`, `<span class="sid">${esc(g.name)}</span>`)}
<form class="panel-body rank-form" id="group-form" data-id="${g.id}">
<div class="form-row">
<label class="field"><span>SimpleAdmin name</span><input class="input" name="name" value="${esc(g.name)}" ${manage ? "" : "readonly"} required><small>The display name and colour come from the panel's site config.</small></label>
<label class="field"><span>Immunity</span><input class="input" name="immunity" type="number" min="0" max="9999" value="${g.immunity}" ${manage ? "" : "readonly"} required><small>0 to 9999</small></label>
@ -350,8 +358,8 @@ async function staff() {
</form>
</section>
<section class="panel">
<div class="panel-head"><h2>Members</h2><p>${g.members.length} ${g.members.length === 1 ? "person" : "people"}</p></div>
<section class="panel${folded("members")}">
${foldHead("members", "Members", `<div style="display:flex;align-items:center;gap:12px"><p>${g.members.length} ${g.members.length === 1 ? "person" : "people"}</p>${manage ? `<button class="btn sm" type="button" data-act="add-staff" data-group="${esc(g.name)}">${ICON.plus}Add</button>` : ""}</div>`)}
<div class="table-wrap">
${g.members.length ? `<table><tbody>${g.members.map((p) => memberRow(p, g.name)).join("")}</tbody></table>`
: `<div class="empty">Nobody has this rank yet.${manage ? " Use Add staff to give it to someone." : ""}</div>`}
@ -540,28 +548,36 @@ async function settings() {
<div><h1>Server settings</h1><p class="sub">Server changes go live over RCON. SimpleAdmin's settings are saved to its config file.</p></div>
</div>
<div class="settings">
<section class="panel" id="cvars">
<div class="panel-head"><h2>Server</h2><p>Live over RCON. Resets when the server restarts.</p></div>
<section class="panel${folded("cvars")}" id="cvars">
${foldHead("cvars", "Server", "<p>Live over RCON. Resets when the server restarts.</p>")}
${!d.canCvar ? '<div class="empty">Your rank can\'t change server settings.</div>'
: !d.server.online ? '<div class="empty">The game server isn\'t answering, so these can\'t be read or changed right now.</div>'
: (d.cvars || []).map((c) => row(c.label, c.help, c.key, cvarControl(c))).join("")}
</section>
<section class="panel" id="sa">
<div class="panel-head"><h2>Punishments</h2><p>${sa.available ? `${sa.path ? `Saved to ${esc(sa.path)}. ` : ""}Applies after the next server restart.` : "Read-only"}</p></div>
<section class="panel${folded("sa")}" id="sa">
${foldHead("sa", "Punishments", `<p>${sa.available ? `${sa.path ? `Saved to ${esc(sa.path)}. ` : ""}Applies after the next server restart.` : "Read-only"}</p>`)}
${!sa.available ? '<div class="empty">The panel wasn\'t given CS2-SimpleAdmin.json (SAW_SA_CONFIG), so these can\'t be shown or changed here.</div>'
: sa.error ? `<div class="empty">${esc(sa.error)}</div>`
: sa.settings.map((s) => row(s.label, s.help, s.key, saControl(s))).join("")}
</section>
<section class="panel">
<div class="panel-head"><h2>Connections</h2></div>
${d.canRcon ? `<section class="panel${folded("rcon")}">
${foldHead("rcon", "Console", "<p>Runs as the server console. Doesn't wait for the server's reply.</p>")}
<form class="panel-body rcon-form" id="rcon-form" autocomplete="off">
<input class="input" name="command" maxlength="512" required placeholder="Console command, like mp_restartgame 1" aria-label="Console command" spellcheck="false">
<button class="btn primary" type="submit">Send</button>
</form>
</section>` : ""}
<section class="panel${folded("connections")}">
${foldHead("connections", "Connections")}
${row("Database", "SimpleAdmin's MySQL database.", "", d.database.connected ? '<span class="tag green">Connected</span>' : '<span class="tag red">Not connected</span>')}
${row("Game server", d.server.online ? "Live status through the WebPanelBridge plugin." : d.server.error || "", "", d.server.online ? '<span class="tag green">Connected</span>' : '<span class="tag red">Not answering</span>')}
</section>
<section class="panel">
<div class="panel-head"><h2>Maintenance</h2><p>Each of these asks first.</p></div>
<section class="panel${folded("maintenance")}">
${foldHead("maintenance", "Maintenance", "<p>Each of these asks first.</p>")}
${can("@css/root") ? row("Reload admins", "Re-read ranks and staff from the database. Runs css_reloadadmins.", "", '<button class="btn" type="button" data-act="reload-admins">Reload admins</button>') : ""}
${can("@css/generic") ? row("Restart the match", "Restarts the game on the current map. Runs css_rr.", "", '<button class="btn" type="button" data-act="restart">Restart match</button>') : ""}
${can("@css/changemap") ? row("Change map", "Switch to another map, or a Workshop map with ws:<id>.", "", '<button class="btn" type="button" data-act="map">Change map</button>') : ""}
@ -709,12 +725,13 @@ function punishDialog(kind, { sid = "", name = "", type = "GAG", reason = "" } =
</form>`);
}
async function rankDialog(sid, name, current) {
// preset picks the rank for someone new (current is their rank now, "" if they have none).
async function rankDialog(sid, name, current, preset = "") {
if (!staff.data) {
try { staff.data = await api("/api/admin/staff"); } catch (err) { toast(err.message); return; }
}
const groups = staff.data.groups;
const opts = (groups || []).map((g) => `<option value="${esc(g.name)}" ${g.name === current ? "selected" : ""}>${esc(g.label)} (${g.immunity})</option>`).join("");
const opts = (groups || []).map((g) => `<option value="${esc(g.name)}" ${g.name === (current || preset) ? "selected" : ""}>${esc(g.label)} (${g.immunity})</option>`).join("");
openModal(`
<form method="dialog" data-submit="rank" data-sid="${esc(sid)}" data-had="${esc(current)}">
<div class="modal-head"><h2>${current ? "Change rank" : "Add staff"}</h2><button class="close" type="button" data-modal-close aria-label="Close">×</button></div>
@ -878,6 +895,15 @@ document.addEventListener("click", (e) => {
return;
}
const fold = t.closest("[data-fold]");
if (fold && !t.closest("[data-act]")) {
const shut = !state.fold[fold.dataset.fold];
state.fold[fold.dataset.fold] = shut;
fold.closest(".panel").classList.toggle("folded", shut);
$(".fold-toggle", fold).setAttribute("aria-expanded", String(!shut));
return;
}
const grp = t.closest("[data-group]:not([data-act])");
if (grp) { state.group = Number(grp.dataset.group); staff(); return; }
@ -906,7 +932,7 @@ document.addEventListener("click", (e) => {
d.kind === "ban" ? "Unban" : "Lift", (form) => act("/api/admin/penalties/lift", "POST", { kind: d.kind, id: Number(d.id), reason: form.reason?.value || "" }));
}
case "rank": return rankDialog(d.sid, d.name, d.group);
case "add-staff": return rankDialog("", "", "");
case "add-staff": return rankDialog("", "", "", d.group || "");
case "new-group": return newGroupDialog();
case "delete-group":
confirmDialog.reason = false;
@ -957,6 +983,16 @@ document.addEventListener("submit", async (e) => {
return;
}
if (form.id === "rcon-form") {
// Not act(): re-rendering the page would throw away unsaved settings above.
try {
const res = await api("/api/admin/rcon", { method: "POST", body: { command: f.command.value } });
toast(res.message || "Sent.");
f.command.value = "";
} catch (err) { toast(err.message); }
return;
}
if (form.id === "group-form") {
const flags = [...form.querySelectorAll('input[name="flag"]')].filter((i) => i.checked && (!i.disabled || i.dataset.kept)).map((i) => i.value);
await act(`/api/admin/groups/${form.dataset.id}`, "PUT", { name: f.name.value.trim(), immunity: Number(f.immunity.value), flags });

View file

@ -6,6 +6,7 @@ const MIN = 60 * 1000;
const ICON = {
search: '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6"><circle cx="7" cy="7" r="4.5"/><path d="m10.5 10.5 3 3"/></svg>',
chevron: '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="m6 4 4 4-4 4"/></svg>',
plus: '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M8 3v10M3 8h10"/></svg>',
};

View file

@ -181,12 +181,13 @@ async function staff() {
loadRankLog();
}
// The database's added/removed are shown as promotions/demotions too: gaining or losing a rank.
const RANK_CHANGE = {
added: ["Joined", "green"],
added: ["Promoted", "green"],
promoted: ["Promoted", "green"],
demoted: ["Demoted", "amber"],
changed: ["Moved", ""],
removed: ["Removed", "red"],
removed: ["Demoted", "amber"],
};
async function loadRankLog() {

View file

@ -222,6 +222,24 @@ h3 { margin: 0; font: 600 17px/1.2 var(--display); letter-spacing: 0.01em; }
.panel-body { padding: 18px; }
/* Collapsible panels: a folded one shows only its head. */
.fold-head { cursor: pointer; user-select: none; }
.fold-toggle {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 0;
border: 0;
background: none;
color: inherit;
font: inherit;
cursor: pointer;
}
.fold-toggle svg { width: 14px; height: 14px; color: var(--muted); transition: transform 0.15s; }
.fold-toggle[aria-expanded="true"] svg { transform: rotate(90deg); }
.panel.folded > .fold-head { border-bottom: 0; }
.panel.folded > :not(.fold-head), .panel.folded .fold-open, .panel:not(.folded) .fold-shut { display: none; }
/* ---------- Controls ---------- */
.btn {
@ -572,6 +590,8 @@ td.num, th.num { text-align: right; white-space: nowrap; }
align-items: start;
}
.rank-form { display: grid; gap: 18px; }
.ladder { list-style: none; margin: 0; padding: 8px; display: grid; gap: 2px; }
.ladder button {
@ -639,6 +659,8 @@ td.num, th.num { text-align: right; white-space: nowrap; }
/* ---------- Settings ---------- */
.settings { display: grid; gap: 20px; max-width: 900px; }
.rcon-form { display: flex; gap: 8px; }
.rcon-form .input { flex: 1; min-width: 0; font-family: ui-monospace, "Cascadia Mono", Menlo, monospace; }
.setting {
display: grid;

View file

@ -112,6 +112,7 @@ func (s *Server) Handler() http.Handler {
admin("POST /api/admin/map", s.changeMap)
admin("POST /api/admin/restart", s.restartRound)
admin("POST /api/admin/reload-admins", s.reloadAdmins)
admin("POST /api/admin/rcon", s.rcon)
admin("POST /api/admin/groups", s.createGroup)
admin("PUT /api/admin/groups/{id}", s.updateGroup)
admin("DELETE /api/admin/groups/{id}", s.deleteGroup)