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:
@@ -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()}
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user