feat: add plugin framework with UI contributions and suite selection

- Implemented PluginBlocks component to render various plugin UI elements.
- Created SuitePicker for selecting test suites and questions.
- Introduced usePluginUI hook for managing plugin UI state and manifest.
- Developed DocsPage for comprehensive plugin framework documentation.
- Added PluginPage to render pages declared by plugins based on the current path.
This commit is contained in:
Netherwarlord
2026-07-28 03:00:55 -04:00
parent adf61ae1a0
commit 8f246fabbc
73 changed files with 4972 additions and 640 deletions
+173 -3
View File
@@ -10,11 +10,31 @@ server internals and keep working across refactors of either.
from __future__ import annotations
from dataclasses import dataclass, field
from dataclasses import dataclass, field, is_dataclass, asdict
from pathlib import Path
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List, Optional, Sequence, Union
__all__ = ["Grade", "GradeEntry", "RunRecord", "TestRecord", "PluginInfo"]
__all__ = [
# grading
"Grade",
"GradeEntry",
"RunRecord",
"TestRecord",
"PluginInfo",
# ui blocks
"Stat",
"StatRow",
"Table",
"Markdown",
"Action",
"Panel",
# ui surfaces
"NavItem",
"Page",
"SlotPanel",
"UI_SLOTS",
"ui_payload",
]
@dataclass(frozen=True)
@@ -31,6 +51,16 @@ class TestRecord:
error: Optional[str] = None
metrics: Dict[str, Any] = field(default_factory=dict)
elapsed: float = 0.0
#: Slug of the suite this question came from. Empty only for records built
#: by older callers. ``filename`` is unique *within* a suite but not across
#: them — several suites ship a ``test1.txt`` — so identify a question by
#: ``question_id``, never by ``filename`` alone.
suite: str = ""
@property
def question_id(self) -> str:
"""Globally unique ``"<suite>/<file>"`` identifier."""
return f"{self.suite}/{self.filename}" if self.suite else self.filename
@property
def tokens_per_second(self) -> float:
@@ -115,3 +145,143 @@ class PluginInfo:
enabled: bool
hooks: List[str] = field(default_factory=list)
error: Optional[str] = None
# ---------------------------------------------------------------------------
# UI contributions
# ---------------------------------------------------------------------------
# A plugin describes interface it wants to add; the React app renders it. The
# plugin never ships JavaScript and the frontend is never rebuilt to install
# one — the manifest is fetched at runtime from /api/plugins/ui.
#
# Every block may carry inline data *or* a ``source`` URL. With a source, the
# frontend fetches it and expects the same field names back as JSON, so a panel
# can be static, live, or refreshed by an Action without changing shape.
@dataclass(frozen=True)
class Stat:
"""One number with a caption, for a headline row."""
label: str
value: Union[str, int, float]
hint: str = ""
#: "default" | "good" | "warn" | "bad" — colours the value only.
tone: str = "default"
@dataclass(frozen=True)
class StatRow:
"""A row of headline numbers."""
stats: Sequence[Stat] = field(default_factory=tuple)
source: Optional[str] = None
kind: str = field(default="stat_row", init=False)
@dataclass(frozen=True)
class Table:
"""A column-headed table. ``rows`` are lists of cell strings."""
columns: Sequence[str] = field(default_factory=tuple)
rows: Sequence[Sequence[Any]] = field(default_factory=tuple)
source: Optional[str] = None
empty: str = "Nothing to show yet."
kind: str = field(default="table", init=False)
@dataclass(frozen=True)
class Markdown:
"""Rendered markdown — the same renderer the reports use."""
text: str = ""
source: Optional[str] = None
kind: str = field(default="markdown", init=False)
@dataclass(frozen=True)
class Action:
"""A button that POSTs to one of the plugin's own routes.
The response may return ``{"message": ...}`` to show a toast, and/or
``{"refresh": true}`` to make sibling blocks re-fetch their sources.
"""
label: str
post: str
confirm: Optional[str] = None
#: "primary" | "ghost" | "danger"
style: str = "primary"
icon: Optional[str] = None
kind: str = field(default="action", init=False)
@dataclass(frozen=True)
class Panel:
"""A titled card grouping other blocks."""
title: str
blocks: Sequence[Any] = field(default_factory=tuple)
subtitle: str = ""
kind: str = field(default="panel", init=False)
Block = Union[StatRow, Table, Markdown, Action, Panel]
@dataclass(frozen=True)
class NavItem:
"""An entry in the left rail."""
label: str
path: str
#: Any lucide icon name the host allowlists; falls back to a puzzle piece.
icon: str = "puzzle"
hint: str = ""
order: int = 100
@dataclass(frozen=True)
class Page:
"""A full page at ``path``, reachable from a NavItem or a direct link."""
path: str
title: str
blocks: Sequence[Any] = field(default_factory=tuple)
subtitle: str = ""
#: Injection points on the built-in pages.
UI_SLOTS = (
"run.aside",
"reports.aside",
"suite.aside",
"settings.section",
)
@dataclass(frozen=True)
class SlotPanel:
"""A Panel injected into a built-in page. ``slot`` must be in UI_SLOTS."""
slot: str
panel: Panel
order: int = 100
def ui_payload(value: Any) -> Any:
"""Convert contribution dataclasses into JSON-safe structures.
Recurses through sequences and nested blocks. Anything that is not a
dataclass is passed through untouched, so plugins may hand back plain
dicts and lists where that is simpler.
"""
if is_dataclass(value) and not isinstance(value, type):
return {key: ui_payload(item) for key, item in asdict(value).items()}
if isinstance(value, dict):
return {key: ui_payload(item) for key, item in value.items()}
if isinstance(value, (list, tuple)):
return [ui_payload(item) for item in value]
if isinstance(value, Path):
return str(value)
return value