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:
parent
31901495de
commit
b26360e8f3
8 changed files with 123 additions and 24 deletions
|
|
@ -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.")
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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>',
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue