Merge pull request 'Discord admin page + hide admin-only bot pool from operators (#213 items 2, 4)' (#219) from feat/213-discord-admin into main
Build & Deploy / Build & push images (push) Successful in 5m1s
Build & Deploy / Deploy Firestore rules & indexes (push) Successful in 5s
Build & Deploy / Deploy to VM (push) Successful in 7s
Build & Deploy / Report a failed deploy (push) Skipped

Reviewed-on: #219
This commit was merged in pull request #219.
This commit is contained in:
2026-10-04 22:05:36 -04:00
13 changed files with 1510 additions and 173 deletions
+91 -3
View File
@@ -107,6 +107,9 @@ class StreamStart(BaseModel):
@router.post("/streams")
async def start_stream(body: StreamStart, _: dict = Depends(require_service_key_or_admin)):
# The bot always sends snowflakes; the web admin page types them in.
if not (str(body.guild_id).isdigit() and str(body.channel_id).isdigit()):
raise HTTPException(400, "guild_id and channel_id must be Discord ids (digits only).")
org_id = await _org_or_403(body.guild_id)
system = await fstore.doc_get_cached("systems", body.system_id)
if not system or system.get("org_id") not in (None, org_id):
@@ -144,18 +147,103 @@ async def stop_guild(guild_id: str, body: GuildStop, _: dict = Depends(require_s
class GuildLink(BaseModel):
org_id: str
# Display label only (the admin page); Discord never reads it.
guild_name: Optional[str] = None
@router.put("/guilds/{guild_id}/link")
async def link_guild(guild_id: str, body: GuildLink, decoded: dict = Depends(require_admin_token)):
if not guild_id.isdigit():
raise HTTPException(400, "guild_id must be a Discord server id (digits only).")
org = await fstore.doc_get("organizations", body.org_id)
if not org:
raise HTTPException(404, "Organization not found.")
await fstore.doc_set(streams.GUILDS, guild_id, {
previous_org = await streams.resolve_guild_org(guild_id)
doc = {
"guild_id": guild_id, "org_id": body.org_id,
"linked_by": decoded.get("uid"), "linked_at": datetime.now(timezone.utc).isoformat(),
})
return {"ok": True, "guild_id": guild_id, "org_id": body.org_id}
}
if body.guild_name and body.guild_name.strip():
doc["guild_name"] = body.guild_name.strip()[:100]
await fstore.doc_set(streams.GUILDS, guild_id, doc)
# Live channels in this server were placed on the previous org's nodes;
# left running, the reconciler would keep re-placing them there, so one
# org's equipment would keep serving a server that now belongs to another.
stopped: list[str] = []
if previous_org and previous_org != body.org_id:
stopped = await streams.stop_guild(guild_id, "server linked to another organization")
return {"ok": True, "guild_id": guild_id, "org_id": body.org_id, "stopped": stopped}
# ---------------------------------------------------------------------------
# Admin view — the web Discord page (server-26#213 item 2)
# ---------------------------------------------------------------------------
@router.get("/admin/overview")
async def admin_overview(_: dict = Depends(require_admin_token)):
"""Every Discord server DRB knows about, every radio channel (desired vs
actual) and what the page needs to act on them. Platform admin only.
A server is "known" when it is linked (``discord_guilds``) or when it has
ever had a radio channel — an unlinked server that ran ``/join`` acts for
``DISCORD_UNLINKED_GUILD_ORG_ID`` and should be visible so it can be linked.
Radio bots come from ``GET /tokens``; no token string is returned here.
Discord user ids (``created_by_discord_id``) and the linking admin's uid
are deliberately left out.
"""
links = {str(g.get("guild_id")): g for g in await fstore.collection_list(streams.GUILDS)
if g.get("guild_id")}
all_streams = await fstore.collection_list(streams.STREAMS)
orgs = {o.get("org_id"): o for o in await fstore.collection_list("organizations") if o.get("org_id")}
nodes = {n.get("node_id"): n for n in await fstore.collection_list("nodes") if n.get("node_id")}
tokens = {t.get("token_id"): t for t in await fstore.collection_list(leases.TOKENS) if t.get("token_id")}
fallback = settings.discord_unlinked_guild_org_id or None
guilds: dict[str, dict] = {}
for gid, link in links.items():
guilds[gid] = {"guild_id": gid, "guild_name": link.get("guild_name"), "linked": bool(link.get("org_id")),
"org_id": link.get("org_id"), "linked_at": link.get("linked_at"),
# Explicitly unlinked servers get no fallback org (mirrors
# streams.resolve_guild_org once server-26#218 lands).
"_no_fallback": bool(link.get("unlinked_at"))}
rows = []
for s in all_streams:
gid = str(s.get("guild_id") or leases.guild_of(s.get("stream_id") or ""))
g = guilds.setdefault(gid, {"guild_id": gid, "guild_name": None, "linked": False,
"org_id": None, "linked_at": None})
if s.get("guild_name") and not g.get("guild_name"):
g["guild_name"] = s["guild_name"]
node = nodes.get(s.get("node_id")) or {}
tok = tokens.get(s.get("token_id")) or {}
rows.append({**_public_stream(s), "guild_id": gid, "org_id": s.get("org_id"), "guild_name": s.get("guild_name"),
"created_at": s.get("created_at"), "node_name": node.get("name"),
"node_status": node.get("status"), "token_name": tok.get("name")})
for g in guilds.values():
no_fallback = g.pop("_no_fallback", False)
acting = g["org_id"] if g["linked"] else (None if no_fallback else fallback)
g["acting_org_id"] = acting
g["acting_org_name"] = (orgs.get(acting) or {}).get("name") if acting else None
g["live_streams"] = sum(1 for r in rows if r.get("guild_id") == g["guild_id"] and r.get("desired") == "on")
rows.sort(key=lambda r: (0 if r.get("desired") == "on" else 1, r.get("guild_name") or r.get("guild_id") or "",
r.get("channel_name") or r.get("channel_id") or ""))
systems = await fstore.collection_list("systems")
# Names for every node a stream or a radio-bot lease points at.
referenced = {r.get("node_id") for r in rows} | {(t.get("lease") or {}).get("node_id") for t in tokens.values()}
node_names = {nid: nodes[nid].get("name") or nid for nid in referenced if nid in nodes}
return {
"guilds": sorted(guilds.values(), key=lambda g: (not g["linked"], g.get("guild_name") or g["guild_id"])),
"streams": rows,
"orgs": sorted(({"org_id": oid, "name": o.get("name") or oid} for oid, o in orgs.items()),
key=lambda o: o["name"].lower()),
"node_names": node_names,
"systems": [{"system_id": s.get("system_id"), "name": s.get("name"), "org_id": s.get("org_id")}
for s in systems if s.get("system_id")],
"limits": {"guild_max_streams": settings.discord_guild_max_streams,
"node_max_streams": settings.discord_node_max_streams,
"unlinked_guild_org_id": fallback,
"unlinked_guild_org_name": (orgs.get(fallback) or {}).get("name") if fallback else None},
}
# ---------------------------------------------------------------------------