feat(ui): E31 S4b — the admin pages behind one rail row, Configuration as a sectioned list, Statistics as tiles and bars

- NavRailGroup: one "Admin" rail row opening a panel of the admin pages (links, so a
  middle-click still opens a tab); the rail fits 133% zoom without a hidden scroll
- sysconfig.SECTIONS orders the config groups under four headings (every group in exactly
  one, test-held); the page shows them as Settings' side list (Tabs `group` headings);
  the lone registration group reads "Registration" under "Access"
- StatTiles / StatTile / StatBars: the Statistics overview's figures as tiles, the
  per-operation quota use as horizontal bars (a zero draws none)
This commit is contained in:
2026-10-02 00:07:00 +02:00
parent 25905aa786
commit be156fc52b
21 changed files with 753 additions and 223 deletions
+16 -3
View File
@@ -45,8 +45,8 @@ class ConfigSpec:
# Registry of DB-overridable keys. Add a key here + wire its read through get_*() to move it
# off env into the admin UI; the admin page renders it automatically from this list. The ORDER is the
# admin page's tab order (first appearance of each group), so keep a group's keys together.
# off env into the admin UI; the admin page renders it automatically from this list. A group's keys
# stay together, in the order its section lists them; a NEW group also needs its place in SECTIONS.
SPECS: tuple[ConfigSpec, ...] = (
# --- Access / registration ---
ConfigSpec("allow_registration", "bool", "access", "allow_registration"),
@@ -219,6 +219,16 @@ SPECS: tuple[ConfigSpec, ...] = (
# is a config.py default — both stay out of SPECS, like plex_sync_interval_min.)
)
# The admin page's sections (E31, the owner's pick): sixteen groups as tabs wrapped to three rows at 133%
# zoom, so they are a vertical list under four headings, in this order. Every group of SPECS sits in
# exactly one section (test-held), so a new group cannot fall off the page.
SECTIONS: tuple[tuple[str, tuple[str, ...]], ...] = (
("access", ("access", "google", "email")),
("youtube", ("youtube", "quota", "backfill", "shorts", "batch", "explore")),
("media", ("downloads", "plex", "tmdb")),
("system", ("maintenance", "audit", "diagnostics", "issues")),
)
_BY_KEY: dict[str, ConfigSpec] = {s.key: s for s in SPECS}
@@ -429,4 +439,7 @@ def describe(db: Session) -> dict:
item["value"] = get(db, s.key)
item["default"] = _default(s)
groups.setdefault(s.group, []).append(item)
return {"groups": groups, "secrets_manageable": secrets_manageable()}
sections = [
{"id": sid, "groups": [g for g in members if g in groups]} for sid, members in SECTIONS
]
return {"groups": groups, "sections": sections, "secrets_manageable": secrets_manageable()}
+21 -4
View File
@@ -1,8 +1,10 @@
"""The sysconfig registry: its order is the admin page's tab order, and a closed-set knob is a CHOICE.
"""The sysconfig registry: a group's keys stay together, every group has its section, and a closed-set
knob is a CHOICE.
The admin Configuration page used to keep its own hand-written group order, which had already drifted
(`explore` was missing, so its tab sorted last). The page now follows SPECS, so SPECS has to read in
tab order: one run of keys per group.
(`explore` was missing, so its tab sorted last). The page now follows the registry: SECTIONS orders the
groups under their headings (E31), and a group missing there would fall off the page — so each group
of SPECS sits in exactly one section.
A closed-set admin knob is a CHOICE, not free text (E22 S2 follow-up).
@@ -22,7 +24,7 @@ from app.config import Settings, choices_of
CHOICE_KEYS = {s.key for s in sysconfig.SPECS if s.type == "choice"}
def test_each_group_is_one_run_of_keys_so_the_registry_reads_in_tab_order():
def test_each_group_is_one_run_of_keys():
runs = [
s.group
for i, s in enumerate(sysconfig.SPECS)
@@ -32,6 +34,13 @@ def test_each_group_is_one_run_of_keys_so_the_registry_reads_in_tab_order():
assert runs[:2] == ["access", "email"]
def test_every_group_sits_in_exactly_one_section():
listed = [g for _, members in sysconfig.SECTIONS for g in members]
assert len(listed) == len(set(listed)), listed
assert set(listed) == {s.group for s in sysconfig.SPECS}
assert [sid for sid, _ in sysconfig.SECTIONS] == ["access", "youtube", "media", "system"]
def test_every_closed_set_knob_is_a_choice_whose_options_come_from_config():
assert CHOICE_KEYS == {
"search_source",
@@ -85,6 +94,14 @@ def test_describe_hands_the_picker_its_options():
assert items["quota_daily_budget"]["choices"] == []
def test_describe_lists_every_group_under_its_section_in_order():
d = sysconfig.describe(_FakeDb())
assert [s["id"] for s in d["sections"]] == ["access", "youtube", "media", "system"]
assert d["sections"][0]["groups"] == ["access", "google", "email"]
listed = [g for s in d["sections"] for g in s["groups"]]
assert sorted(listed) == sorted(d["groups"])
def test_set_value_stores_the_canonical_spelling_and_refuses_anything_else(db):
sysconfig.set_value(db, "search_source", "API")
assert sysconfig.get_str(db, "search_source") == "api"