Public rank change log: sa_rank_log, written on rank changes, listed on the Staff page

This commit is contained in:
Astra 2026-09-29 16:56:37 +01:00
parent 861692c534
commit 611b79cdba
11 changed files with 346 additions and 17 deletions

View file

@ -4,8 +4,8 @@ A SourceBans-style web panel for [CS2-SimpleAdmin](https://github.com/daffyyyy/C
(built against 1.8.2b's MySQL schema). (built against 1.8.2b's MySQL schema).
- **Public site** (`/`): the live server (name, map, players, team score and a scoreboard), bans, - **Public site** (`/`): the live server (name, map, players, team score and a scoreboard), bans,
gags and mutes, and the staff list. Read-only. It never shows admin names, IP addresses or gags and mutes, the staff list and its rank changes (promotions, demotions, who made them).
internal immunity values. Read-only. It never shows who issued a penalty, IP addresses or internal immunity values.
- **Staff panel** (`/admin`): sign in through Steam. Ban, gag, mute, silence, warn and kick; lift - **Staff panel** (`/admin`): sign in through Steam. Ban, gag, mute, silence, warn and kick; lift
bans and blocks; manage ranks, permissions and staff; change server cvars and SimpleAdmin's bans and blocks; manage ranks, permissions and staff; change server cvars and SimpleAdmin's
punishment settings. punishment settings.
@ -82,6 +82,17 @@ SimpleAdmin groups have no display name or colour, so those come from a site con
`"supporter": true` are listed apart from staff and can't sign in unless they hold `"supporter": true` are listed apart from staff and can't sign in unless they hold
`@css/generic`. `@css/generic`.
### Rank changes
Every change to a player's rank (their highest-immunity group) is recorded in `sa_rank_log` and
listed under the public Staff page: who, from which rank to which, who changed it and when. The
panel creates the table at startup (`sql/rank-log.sql`, embedded; run it by hand if the database
user can't create tables). It logs changes made in the panel (giving, moving or removing a rank,
and deleting a rank that people hold). Our SimpleAdmin fork (1.8.2b-zio10+) logs `css_addadmin`,
`css_deladmin` and the in-game admin menu to the same table, marked "in game". Not logged: ranks
that lapse when their expiry passes, `css_delgroup`, and edits made straight in the database.
History starts when the table is created.
### Name history (optional) ### Name history (optional)
SimpleAdmin keeps one row per SteamID + IP in `sa_players_ips` and overwrites its name on each SimpleAdmin keeps one row per SteamID + IP in `sa_players_ips` and overwrites its name on each

140
internal/store/ranklog.go Normal file
View file

@ -0,0 +1,140 @@
package store
import (
"context"
"database/sql"
"strings"
"time"
)
// Rank history lives in sa_rank_log, which the panel owns (sql/rank-log.sql). Our SimpleAdmin fork
// writes the same rows for in-game changes. A player's rank is their highest-immunity group, the
// one the staff page lists them under.
// Actor is who made a change. An empty SteamID is Console.
type Actor struct {
SteamID string
Name string
}
// Where a rank change was made, stored in sa_rank_log.source.
const (
SourcePanel = "panel"
SourceGame = "game"
)
// RankChange is a row of sa_rank_log.
type RankChange struct {
ID int64 `json:"id"`
SteamID string `json:"steamid"`
Name string `json:"name"`
OldRank string `json:"oldRank,omitempty"`
NewRank string `json:"newRank,omitempty"`
Kind string `json:"kind"`
AdminSteamID string `json:"adminSteamid,omitempty"`
AdminName string `json:"admin"`
Source string `json:"source"`
Created time.Time `json:"created"`
}
// EnsureRankLog creates sa_rank_log if it's missing.
func (s *Store) EnsureRankLog(ctx context.Context, ddl string) error {
_, err := s.db.ExecContext(ctx, strings.TrimSuffix(strings.TrimSpace(ddl), ";"))
return err
}
// RankChanges lists rank changes, newest first.
func (s *Store) RankChanges(ctx context.Context, limit, offset int) ([]RankChange, int, error) {
var total int
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM sa_rank_log`).Scan(&total); err != nil {
return nil, 0, err
}
rows, err := s.db.QueryContext(ctx, `SELECT id, player_steamid, player_name, COALESCE(old_rank,''), COALESCE(new_rank,''),
kind, admin_steamid, admin_name, source, created
FROM sa_rank_log ORDER BY id DESC LIMIT ? OFFSET ?`, limit, offset)
if err != nil {
return nil, 0, err
}
defer rows.Close()
out := []RankChange{}
for rows.Next() {
var c RankChange
var player, admin sql.NullInt64
if err := rows.Scan(&c.ID, &player, &c.Name, &c.OldRank, &c.NewRank, &c.Kind, &admin, &c.AdminName, &c.Source, &c.Created); err != nil {
return nil, 0, err
}
c.SteamID, c.AdminSteamID = sid(player), sid(admin)
if c.AdminSteamID == "" {
c.AdminName = "Console"
}
c.Created = s.fromWall(c.Created)
out = append(out, c)
}
return out, total, rows.Err()
}
type rankState struct {
group string // "" for no rank
immunity int
}
// rankTx reads a player's current rank inside tx, counting only unexpired admin rows and groups
// that apply to this server, as Admins and Groups do.
func (s *Store) rankTx(ctx context.Context, tx *sql.Tx, n int64) (rankState, error) {
gsCond, gsArgs := s.serverFilter("gs.server_id")
aCond, aArgs := s.serverFilter("a.server_id")
args := append(gsArgs, n, s.wall(time.Now()))
args = append(args, aArgs...)
var r rankState
err := tx.QueryRowContext(ctx, `SELECT g.name, g.immunity
FROM sa_admins a
JOIN sa_admins_flags f ON f.admin_id = a.id
JOIN sa_groups g ON g.name = f.flag
JOIN sa_groups_servers gs ON gs.group_id = g.id AND `+gsCond+`
WHERE a.player_steamid = ? AND (a.ends IS NULL OR a.ends > ?) AND `+aCond+`
ORDER BY g.immunity DESC, g.id LIMIT 1`, args...).Scan(&r.group, &r.immunity)
if err == sql.ErrNoRows {
return rankState{}, nil
}
return r, err
}
func rankKind(before, after rankState) string {
switch {
case before.group == "":
return "added"
case after.group == "":
return "removed"
case after.immunity > before.immunity:
return "promoted"
case after.immunity < before.immunity:
return "demoted"
}
return "changed"
}
func nullIfEmpty(s string) any {
if s == "" {
return nil
}
return s
}
// logRank records a rank change inside tx, if the rank actually changed.
func (s *Store) logRank(ctx context.Context, tx *sql.Tx, n int64, name string, before, after rankState, by Actor) error {
if before.group == after.group {
return nil
}
a, _ := parseSID(by.SteamID)
_, err := tx.ExecContext(ctx, `INSERT INTO sa_rank_log (player_steamid, player_name, old_rank, new_rank, kind, admin_steamid, admin_name, source, created)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
n, name, nullIfEmpty(before.group), nullIfEmpty(after.group), rankKind(before, after), a, by.Name, SourcePanel, s.wall(time.Now()))
return err
}
// adminNameTx is the name on a player's admin rows, or "".
func adminNameTx(ctx context.Context, tx *sql.Tx, n int64) string {
var name sql.NullString
_ = tx.QueryRowContext(ctx, `SELECT player_name FROM sa_admins WHERE player_steamid = ? ORDER BY server_id IS NULL DESC, id LIMIT 1`, n).Scan(&name)
return name.String
}

View file

@ -780,8 +780,9 @@ func (s *Store) UpdateGroup(ctx context.Context, id int64, name string, immunity
} }
// DeleteGroup removes a group and every admin's reference to it. Admins left with no flags or // DeleteGroup removes a group and every admin's reference to it. Admins left with no flags or
// groups are removed too, which is what makes them regular players again. // groups are removed too, which is what makes them regular players again. Members whose rank
func (s *Store) DeleteGroup(ctx context.Context, id int64) error { // changes because of it are logged as changed by by.
func (s *Store) DeleteGroup(ctx context.Context, by Actor, id int64) error {
tx, err := s.db.BeginTx(ctx, nil) tx, err := s.db.BeginTx(ctx, nil)
if err != nil { if err != nil {
return err return err
@ -794,6 +795,33 @@ func (s *Store) DeleteGroup(ctx context.Context, id int64) error {
} }
return err return err
} }
type member struct {
n int64
name string
before rankState
}
var members []member
rows, err := tx.QueryContext(ctx, `SELECT DISTINCT a.player_steamid FROM sa_admins a
JOIN sa_admins_flags f ON f.admin_id = a.id WHERE f.flag = ? AND a.player_steamid <> 0`, name)
if err != nil {
return err
}
for rows.Next() {
var m member
if err := rows.Scan(&m.n); err != nil {
rows.Close()
return err
}
members = append(members, m)
}
rows.Close()
for i := range members {
m := &members[i]
m.name = adminNameTx(ctx, tx, m.n)
if m.before, err = s.rankTx(ctx, tx, m.n); err != nil {
return err
}
}
if _, err := tx.ExecContext(ctx, `DELETE FROM sa_admins_flags WHERE flag = ?`, name); err != nil { if _, err := tx.ExecContext(ctx, `DELETE FROM sa_admins_flags WHERE flag = ?`, name); err != nil {
return err return err
} }
@ -803,6 +831,15 @@ func (s *Store) DeleteGroup(ctx context.Context, id int64) error {
if err := deleteEmptyAdmins(ctx, tx); err != nil { if err := deleteEmptyAdmins(ctx, tx); err != nil {
return err return err
} }
for _, m := range members {
after, err := s.rankTx(ctx, tx, m.n)
if err != nil {
return err
}
if err := s.logRank(ctx, tx, m.n, m.name, m.before, after, by); err != nil {
return err
}
}
return tx.Commit() return tx.Commit()
} }
@ -816,8 +853,8 @@ func deleteEmptyAdmins(ctx context.Context, tx *sql.Tx) error {
// SetAdminGroup gives a player exactly one rank (replacing any #group they had), creating a // SetAdminGroup gives a player exactly one rank (replacing any #group they had), creating a
// global admin row if they have none. immunity is stored on the admin row, as css_addadmin does. // global admin row if they have none. immunity is stored on the admin row, as css_addadmin does.
// ends is nil for no expiry. // ends is nil for no expiry. A change of rank is logged as made by by.
func (s *Store) SetAdminGroup(ctx context.Context, steamid, name, group string, immunity int, ends *time.Time) error { func (s *Store) SetAdminGroup(ctx context.Context, by Actor, steamid, name, group string, immunity int, ends *time.Time) error {
n, err := parseSID(steamid) n, err := parseSID(steamid)
if err != nil { if err != nil {
return err return err
@ -827,6 +864,10 @@ func (s *Store) SetAdminGroup(ctx context.Context, steamid, name, group string,
return err return err
} }
defer tx.Rollback() defer tx.Rollback()
before, err := s.rankTx(ctx, tx, n)
if err != nil {
return err
}
var rowID int64 var rowID int64
err = tx.QueryRowContext(ctx, `SELECT id FROM sa_admins WHERE player_steamid = ? ORDER BY server_id IS NULL DESC, id LIMIT 1`, n).Scan(&rowID) err = tx.QueryRowContext(ctx, `SELECT id FROM sa_admins WHERE player_steamid = ? ORDER BY server_id IS NULL DESC, id LIMIT 1`, n).Scan(&rowID)
@ -856,17 +897,40 @@ func (s *Store) SetAdminGroup(ctx context.Context, steamid, name, group string,
if _, err := tx.ExecContext(ctx, `INSERT INTO sa_admins_flags (admin_id, flag) VALUES (?, ?)`, rowID, group); err != nil { if _, err := tx.ExecContext(ctx, `INSERT INTO sa_admins_flags (admin_id, flag) VALUES (?, ?)`, rowID, group); err != nil {
return err return err
} }
after, err := s.rankTx(ctx, tx, n)
if err != nil {
return err
}
if err := s.logRank(ctx, tx, n, name, before, after, by); err != nil {
return err
}
return tx.Commit() return tx.Commit()
} }
// RemoveAdmin deletes every sa_admins row for a SteamID, like css_deladmin ... -g. // RemoveAdmin deletes every sa_admins row for a SteamID, like css_deladmin ... -g, and logs the
func (s *Store) RemoveAdmin(ctx context.Context, steamid string) error { // lost rank as removed by by.
func (s *Store) RemoveAdmin(ctx context.Context, by Actor, steamid string) error {
n, err := parseSID(steamid) n, err := parseSID(steamid)
if err != nil { if err != nil {
return err return err
} }
_, err = s.db.ExecContext(ctx, `DELETE FROM sa_admins WHERE player_steamid = ? AND id <> 0`, n) tx, err := s.db.BeginTx(ctx, nil)
return err if err != nil {
return err
}
defer tx.Rollback()
before, err := s.rankTx(ctx, tx, n)
if err != nil {
return err
}
name := adminNameTx(ctx, tx, n)
if _, err := tx.ExecContext(ctx, `DELETE FROM sa_admins WHERE player_steamid = ? AND id <> 0`, n); err != nil {
return err
}
if err := s.logRank(ctx, tx, n, name, before, rankState{}, by); err != nil {
return err
}
return tx.Commit()
} }
// ---------------------------------------------------------------- Writes for penalties // ---------------------------------------------------------------- Writes for penalties

View file

@ -517,7 +517,7 @@ func (s *Server) deleteGroup(w http.ResponseWriter, r *http.Request, id *Identit
fail(w, http.StatusForbidden, "You can't delete your own rank.") fail(w, http.StatusForbidden, "You can't delete your own rank.")
return return
} }
if err := s.st.DeleteGroup(r.Context(), g.ID); err != nil { if err := s.st.DeleteGroup(r.Context(), id.actor(), g.ID); err != nil {
internal(w, r, err) internal(w, r, err)
return return
} }
@ -576,7 +576,7 @@ func (s *Server) applyRank(w http.ResponseWriter, r *http.Request, id *Identity,
if name == "" { if name == "" {
name = req.SteamID name = req.SteamID
} }
if err := s.st.SetAdminGroup(r.Context(), req.SteamID, name, req.Group, grp.Immunity, ends); err != nil { if err := s.st.SetAdminGroup(r.Context(), id.actor(), req.SteamID, name, req.Group, grp.Immunity, ends); err != nil {
internal(w, r, err) internal(w, r, err)
return return
} }
@ -618,7 +618,7 @@ func (s *Server) removeStaff(w http.ResponseWriter, r *http.Request, id *Identit
if !s.checkStaffTarget(w, id, x, steamid) { if !s.checkStaffTarget(w, id, x, steamid) {
return return
} }
if err := s.st.RemoveAdmin(r.Context(), steamid); err != nil { if err := s.st.RemoveAdmin(r.Context(), id.actor(), steamid); err != nil {
internal(w, r, err) internal(w, r, err)
return return
} }

View file

@ -1,5 +1,5 @@
// Public, read-only site: the live server, bans, gags/mutes and staff. The API leaves out admin // Public, read-only site: the live server, bans, gags/mutes, and staff with their rank changes. The
// names and IPs; nothing here can change anything. // API leaves out who issued penalties and IPs; nothing here can change anything.
const PAGES = [ const PAGES = [
{ id: "server", label: "Server" }, { id: "server", label: "Server" },
@ -175,7 +175,46 @@ async function staff() {
<div><h1>Staff</h1><p class="sub">${online ? `${online} staff ${online === 1 ? "is" : "are"} on the server right now.` : "No staff are on the server right now."}</p></div> <div><h1>Staff</h1><p class="sub">${online ? `${online} staff ${online === 1 ? "is" : "are"} on the server right now.` : "No staff are on the server right now."}</p></div>
</div> </div>
${staffGroups.length ? `<section class="panel crew-panel">${staffGroups.map(rankRow).join("")}</section>` : '<div class="empty">No staff are listed yet.</div>'} ${staffGroups.length ? `<section class="panel crew-panel">${staffGroups.map(rankRow).join("")}</section>` : '<div class="empty">No staff are listed yet.</div>'}
${supporters.length ? `<h2 class="crew-sep">Supporters</h2><section class="panel crew-panel">${supporters.map(rankRow).join("")}</section>` : ""}`; ${supporters.length ? `<h2 class="crew-sep">Supporters</h2><section class="panel crew-panel">${supporters.map(rankRow).join("")}</section>` : ""}
<h2 class="crew-sep">Rank changes</h2>
<section class="panel"><div class="table-wrap" id="rank-log">${loading()}</div></section>`;
loadRankLog();
}
const RANK_CHANGE = {
added: ["Joined", "green"],
promoted: ["Promoted", "green"],
demoted: ["Demoted", "amber"],
changed: ["Moved", ""],
removed: ["Removed", "red"],
};
async function loadRankLog() {
const box = $("#rank-log");
if (!box) return;
const want = state.num;
loadRankLog.want = want;
let list;
try { list = await api("/api/public/ranks" + qs({ page: state.num })); } catch (err) { box.innerHTML = errorBox(err); return; }
if (loadRankLog.want !== want || !$("#rank-log")) return;
if (!list.items.length) { box.innerHTML = '<div class="empty">No rank changes recorded yet.</div>'; return; }
box.innerHTML = `<table>
<thead><tr><th>Player</th><th>Change</th><th class="hide-sm">By</th><th>When</th></tr></thead>
<tbody>
${list.items.map((c) => {
const [label, tone] = RANK_CHANGE[c.kind] || [c.kind, ""];
const by = c.adminSteamid
? `<a href="${steamProfile(c.adminSteamid)}" target="_blank" rel="noopener">${esc(c.admin)}</a>`
: esc(c.admin);
return `<tr class="clickable" data-player="${esc(c.steamid)}">
<td><div class="who"><div><b>${esc(c.name || "Unknown")}</b><span class="sid">${esc(c.steamid)}</span></div></div></td>
<td><div class="rank-move"><span class="tag ${tone}">${label}</span>${rankTag(c.old)}<span class="faint" aria-label="to">→</span>${rankTag(c.new)}</div></td>
<td class="hide-sm">${by}${c.source === "game" ? ' <span class="faint">in game</span>' : ""}</td>
<td>${fmtDate(c.created)}</td>
</tr>`;
}).join("")}
</tbody>
</table>` + pager(list);
} }
/* ---------- Player lookup (read-only) ---------- */ /* ---------- Player lookup (read-only) ---------- */
@ -243,8 +282,9 @@ document.addEventListener("click", (e) => {
return; return;
} }
const pg = t.closest("[data-page]"); const pg = t.closest("[data-page]");
if (pg) { state.num = Number(pg.dataset.page); loadResults(); return; } if (pg) { state.num = Number(pg.dataset.page); (state.page === "staff" ? loadRankLog : loadResults)(); return; }
if (t.closest("[data-close]")) { closeDrawer(); return; } if (t.closest("[data-close]")) { closeDrawer(); return; }
if (t.closest("a[href]")) return;
const row = t.closest("[data-player]"); const row = t.closest("[data-player]");
if (row) openPlayer(row.dataset.player); if (row) openPlayer(row.dataset.player);
}); });

View file

@ -928,6 +928,7 @@ dialog::backdrop { background: rgba(5, 6, 9, 0.65); }
.crew small { color: var(--muted); font-size: 13px; } .crew small { color: var(--muted); font-size: 13px; }
.crew-sep { margin: 28px 0 12px; } .crew-sep { margin: 28px 0 12px; }
.rank-move { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 8px; }
@media (max-width: 820px) { @media (max-width: 820px) {
.topbar-inner { gap: 12px; padding: 0 16px; } .topbar-inner { gap: 12px; padding: 0 16px; }

View file

@ -208,6 +208,39 @@ func (s *Server) publicStaff(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"groups": groups, "staffOnline": s.serverView(x, st).StaffOnline}) writeJSON(w, http.StatusOK, map[string]any{"groups": groups, "staffOnline": s.serverView(x, st).StaffOnline})
} }
type rankChangeView struct {
store.RankChange
Old *rankView `json:"old,omitempty"`
New *rankView `json:"new,omitempty"`
}
// publicRankChanges is the promotions and demotions list, with each rank's label and colour.
func (s *Server) publicRankChanges(w http.ResponseWriter, r *http.Request) {
lim, off, num := page(r)
items, total, err := s.st.RankChanges(r.Context(), lim, off)
if err != nil {
internal(w, r, err)
return
}
x, err := s.staffIdx(r.Context())
if err != nil {
internal(w, r, err)
return
}
view := func(g string) *rankView {
if g == "" {
return nil
}
rk := s.site.rank(g, x.groupIndex(g))
return &rankView{Name: g, Label: rk.Label, Color: rk.Color, Supporter: rk.Supporter}
}
out := make([]rankChangeView, 0, len(items))
for _, c := range items {
out = append(out, rankChangeView{RankChange: c, Old: view(c.OldRank), New: view(c.NewRank)})
}
writeJSON(w, http.StatusOK, newList(out, total, num))
}
func (s *Server) publicPlayer(w http.ResponseWriter, r *http.Request) { func (s *Server) publicPlayer(w http.ResponseWriter, r *http.Request) {
steamid := r.PathValue("steamid") steamid := r.PathValue("steamid")
if !steam.ValidID(steamid) { if !steam.ValidID(steamid) {

View file

@ -89,6 +89,7 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("GET /api/public/bans", s.publicBans) mux.HandleFunc("GET /api/public/bans", s.publicBans)
mux.HandleFunc("GET /api/public/comms", s.publicComms) mux.HandleFunc("GET /api/public/comms", s.publicComms)
mux.HandleFunc("GET /api/public/staff", s.publicStaff) mux.HandleFunc("GET /api/public/staff", s.publicStaff)
mux.HandleFunc("GET /api/public/ranks", s.publicRankChanges)
mux.HandleFunc("GET /api/public/players/{steamid}", s.publicPlayer) mux.HandleFunc("GET /api/public/players/{steamid}", s.publicPlayer)
mux.HandleFunc("GET /avatar/{steamid}", s.avatar) mux.HandleFunc("GET /avatar/{steamid}", s.avatar)

View file

@ -124,6 +124,9 @@ func (id *Identity) Has(flag string) bool {
return slices.Contains(id.Flags, "@css/root") || slices.Contains(id.Flags, flag) return slices.Contains(id.Flags, "@css/root") || slices.Contains(id.Flags, flag)
} }
// actor is who the store credits for a change.
func (id *Identity) actor() store.Actor { return store.Actor{SteamID: id.SteamID, Name: id.Name} }
// CanTarget follows CounterStrikeSharp: you can act on players whose immunity isn't above yours. // CanTarget follows CounterStrikeSharp: you can act on players whose immunity isn't above yours.
// Nobody acts on themselves through the panel. // Nobody acts on themselves through the panel.
func (id *Identity) CanTarget(x *staffIndex, steamid string) bool { func (id *Identity) CanTarget(x *staffIndex, steamid string) bool {

View file

@ -5,6 +5,7 @@ package main
import ( import (
"context" "context"
"database/sql" "database/sql"
_ "embed"
"errors" "errors"
"fmt" "fmt"
"log/slog" "log/slog"
@ -27,6 +28,11 @@ import (
"git.zio.sh/cs2/simpleadmin-web/internal/web" "git.zio.sh/cs2/simpleadmin-web/internal/web"
) )
// rankLogSQL creates sa_rank_log, the rank history (see the file).
//
//go:embed sql/rank-log.sql
var rankLogSQL string
func env(key, def string) string { func env(key, def string) string {
if v := strings.TrimSpace(os.Getenv(key)); v != "" { if v := strings.TrimSpace(os.Getenv(key)); v != "" {
return v return v
@ -127,6 +133,9 @@ func run() error {
return errors.New("sa_servers lists several servers; set SAW_SERVER_ID to this server's id") return errors.New("sa_servers lists several servers; set SAW_SERVER_ID to this server's id")
} }
st = store.New(db, loc, srv.ID, timeMode) st = store.New(db, loc, srv.ID, timeMode)
if err := st.EnsureRankLog(ctx, rankLogSQL); err != nil {
slog.Warn("couldn't create sa_rank_log; rank changes won't be logged until sql/rank-log.sql is run", "err", err)
}
rconAddr := env("SAW_RCON_ADDR", srv.Address) rconAddr := env("SAW_RCON_ADDR", srv.Address)
rconPass := env("SAW_RCON_PASSWORD", srv.RconPassword) rconPass := env("SAW_RCON_PASSWORD", srv.RconPassword)

27
sql/rank-log.sql Normal file
View file

@ -0,0 +1,27 @@
-- Rank history: one row each time a player's rank (their highest-immunity SimpleAdmin group)
-- changes, for the public promotions/demotions list.
--
-- The panel creates this table itself at startup (it's embedded in the binary), so running it by
-- hand is only needed if the panel's database user can't CREATE TABLE. Written by the panel for
-- changes made there, and by our SimpleAdmin fork (1.8.2b-zio10+) for css_addadmin/css_deladmin
-- and the in-game admin menu. Nothing writes it for ranks that lapse when an admin's `ends` passes.
--
-- kind is added, removed, promoted, demoted (compared by group immunity) or changed (same
-- immunity). old_rank/new_rank are group names ("#rank/mod"), NULL for no rank. admin_steamid 0
-- is Console. created is a wall-clock value in SimpleAdmin's Timezone, like its other tables.
CREATE TABLE IF NOT EXISTS `sa_rank_log` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`player_steamid` bigint(20) NOT NULL,
`player_name` varchar(128) NOT NULL DEFAULT '',
`old_rank` varchar(64) NULL,
`new_rank` varchar(64) NULL,
`kind` varchar(16) NOT NULL,
`admin_steamid` bigint(20) NOT NULL DEFAULT 0,
`admin_name` varchar(128) NOT NULL DEFAULT '',
`source` varchar(16) NOT NULL,
`created` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_rank_log_created` (`created`),
KEY `idx_rank_log_player` (`player_steamid`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;