From b26360e8f3e2bc4a4254b41dafa80c6bef3e22d7 Mon Sep 17 00:00:00 2001 From: Astra Date: Sat, 3 Oct 2026 12:09:43 +0100 Subject: [PATCH] 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. --- README.md | 1 + internal/web/actions.go | 37 ++++++++++++++++ internal/web/admin.go | 2 +- internal/web/assets/admin.js | 78 +++++++++++++++++++++++++--------- internal/web/assets/common.js | 1 + internal/web/assets/public.js | 5 ++- internal/web/assets/styles.css | 22 ++++++++++ internal/web/server.go | 1 + 8 files changed, 123 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 2e4b46d..f2f3cf6 100644 --- a/README.md +++ b/README.md @@ -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` | diff --git a/internal/web/actions.go b/internal/web/actions.go index 8630d9d..278ca90 100644 --- a/internal/web/actions.go +++ b/internal/web/actions.go @@ -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.") diff --git a/internal/web/admin.go b/internal/web/admin.go index 577b267..867281e 100644 --- a/internal/web/admin.go +++ b/internal/web/admin.go @@ -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)) diff --git a/internal/web/assets/admin.js b/internal/web/assets/admin.js index 4e9cb6e..cc2b5c9 100644 --- a/internal/web/assets/admin.js +++ b/internal/web/assets/admin.js @@ -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 = "") => `
+

${aside} +
`; +const folded = (key) => (state.fold[key] ? " folded" : ""); + /* ---------- Staff & ranks ---------- */ async function staff() { @@ -313,8 +324,8 @@ async function staff() { ${d.groups.length ? `
-
-

Ranks

Immunity

+
+ ${foldHead("ranks", "Ranks", `

Immunity

${esc(g.label)}

`)}
    ${staffGroups.map(ladderItem).join("")} ${supporters.length ? `
  • Supporter ranks
  • ${supporters.map(ladderItem).join("")}` : ""} @@ -322,12 +333,9 @@ async function staff() {
-
-
-

${esc(g.label)}

- ${esc(g.name)} -
-
+
+ ${foldHead("rank", `${esc(g.label)}`, `${esc(g.name)}`)} +
@@ -350,8 +358,8 @@ async function staff() {
-
-

Members

${g.members.length} ${g.members.length === 1 ? "person" : "people"}

+
+ ${foldHead("members", "Members", `

${g.members.length} ${g.members.length === 1 ? "person" : "people"}

${manage ? `` : ""}
`)}
${g.members.length ? `${g.members.map((p) => memberRow(p, g.name)).join("")}
` : `
Nobody has this rank yet.${manage ? " Use Add staff to give it to someone." : ""}
`} @@ -540,28 +548,36 @@ async function settings() {

Server settings

Server changes go live over RCON. SimpleAdmin's settings are saved to its config file.

-
-

Server

Live over RCON. Resets when the server restarts.

+
+ ${foldHead("cvars", "Server", "

Live over RCON. Resets when the server restarts.

")} ${!d.canCvar ? '
Your rank can\'t change server settings.
' : !d.server.online ? '
The game server isn\'t answering, so these can\'t be read or changed right now.
' : (d.cvars || []).map((c) => row(c.label, c.help, c.key, cvarControl(c))).join("")}
-
-

Punishments

${sa.available ? `${sa.path ? `Saved to ${esc(sa.path)}. ` : ""}Applies after the next server restart.` : "Read-only"}

+
+ ${foldHead("sa", "Punishments", `

${sa.available ? `${sa.path ? `Saved to ${esc(sa.path)}. ` : ""}Applies after the next server restart.` : "Read-only"}

`)} ${!sa.available ? '
The panel wasn\'t given CS2-SimpleAdmin.json (SAW_SA_CONFIG), so these can\'t be shown or changed here.
' : sa.error ? `
${esc(sa.error)}
` : sa.settings.map((s) => row(s.label, s.help, s.key, saControl(s))).join("")}
-
-

Connections

+ ${d.canRcon ? `
+ ${foldHead("rcon", "Console", "

Runs as the server console. Doesn't wait for the server's reply.

")} +
+ + +
+
` : ""} + +
+ ${foldHead("connections", "Connections")} ${row("Database", "SimpleAdmin's MySQL database.", "", d.database.connected ? 'Connected' : 'Not connected')} ${row("Game server", d.server.online ? "Live status through the WebPanelBridge plugin." : d.server.error || "", "", d.server.online ? 'Connected' : 'Not answering')}
-
-

Maintenance

Each of these asks first.

+
+ ${foldHead("maintenance", "Maintenance", "

Each of these asks first.

")} ${can("@css/root") ? row("Reload admins", "Re-read ranks and staff from the database. Runs css_reloadadmins.", "", '') : ""} ${can("@css/generic") ? row("Restart the match", "Restarts the game on the current map. Runs css_rr.", "", '') : ""} ${can("@css/changemap") ? row("Change map", "Switch to another map, or a Workshop map with ws:.", "", '') : ""} @@ -709,12 +725,13 @@ function punishDialog(kind, { sid = "", name = "", type = "GAG", reason = "" } = `); } -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) => ``).join(""); + const opts = (groups || []).map((g) => ``).join(""); openModal(`
@@ -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 }); diff --git a/internal/web/assets/common.js b/internal/web/assets/common.js index 8d219d6..6621747 100644 --- a/internal/web/assets/common.js +++ b/internal/web/assets/common.js @@ -6,6 +6,7 @@ const MIN = 60 * 1000; const ICON = { search: '', + chevron: '', plus: '', }; diff --git a/internal/web/assets/public.js b/internal/web/assets/public.js index d060ed3..bbf27c5 100644 --- a/internal/web/assets/public.js +++ b/internal/web/assets/public.js @@ -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() { diff --git a/internal/web/assets/styles.css b/internal/web/assets/styles.css index 0c0a177..3526afa 100644 --- a/internal/web/assets/styles.css +++ b/internal/web/assets/styles.css @@ -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; diff --git a/internal/web/server.go b/internal/web/server.go index 0d69901..abb0beb 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -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)