# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence. """The Analytics tab's data, one route per panel. Read-only, every one of them. This page can already rewrite ``.env`` and restart a container; the command history is the one thing on it that is worth nothing to an attacker and everything to a maintainer, and there is no reason for a write route to exist here at all. ``?group=`` is optional everywhere. Absent means the all-groups roll-up, and because ``Scope`` binds it as a nullable parameter the roll-up runs the same SQL as the per-group answer rather than a second query that would drift. """ from __future__ import annotations from typing import TYPE_CHECKING, Annotated from fastapi import APIRouter, Query from signal_bot.analytics.scope import Scope from signal_bot.animals import load_aliases from signal_bot.external import run from signal_bot.manage import analytics_panels, analytics_read if TYPE_CHECKING: from signal_bot.manage.deps import ManageDeps # Where the release tags live. Named so the shell-out reads as one thing. TAGS = "refs/tags" # Routes are annotated ``-> analytics_read.Gathered`` rather than ``-> # Gathered``. FastAPI resolves a return annotation through pydantic when the # app is built, and ``from __future__ import annotations`` makes that a # lookup in module globals -- where a name imported only under TYPE_CHECKING # does not exist. Going through the module, which is imported at runtime # anyway, keeps the annotation resolvable and the import honest. # What the near-miss grouping is matched against. Read from the alias file # rather than from a live registry: this is a second process with no bot in # it, and the file is the same data the bot registers from. def triggers() -> tuple[str, ...]: """Every trigger any command claims, for grouping near misses.""" return tuple(trigger for triggers in load_aliases().values() for trigger in triggers) def releases() -> tuple[tuple[str, str], ...]: """(version, day) for every release, taken from the git tags. Not from ``DOCS-changelog.md``, which was the obvious place to look and turns out not to work: the changelog is the authority on *what* shipped and carries no dates at all -- its headings are ``## — `` and nothing more. The tags are the only record of *when*. Shelled out through ``signal_bot.external``, which never raises: a checkout without tags, or a deployment that is not a git tree, comes back as no releases and the timeline simply draws without annotations. """ result = run(["git", "for-each-ref", "--format=%(refname:short) %(creatordate:short)", TAGS]) if not result.ok: return () found = [] for line in result.stdout.splitlines(): name, _, day = line.partition(" ") if name.startswith("v") and day: found.append((name.removeprefix("v"), day.strip())) return tuple(sorted(found, key=lambda pair: pair[1])) def router(deps: ManageDeps) -> APIRouter: """Seven read-only routes, all under /api/analytics.""" api = APIRouter(prefix="/api/analytics") path = deps.database_path @api.get("/groups") def groups() -> analytics_read.Gathered: """Every group with a history, for the picker.""" return analytics_read.groups(path) @api.get("/overview") def overview(group: Annotated[str | None, Query()] = None) -> analytics_read.Gathered: """Family A: what is used, by whom, and what is untouched.""" return analytics_panels.overview(path, Scope(group)) @api.get("/time") def times(group: Annotated[str | None, Query()] = None) -> analytics_read.Gathered: """Family B: hours, weekdays, days, months, and what has gone quiet.""" return analytics_panels.times(path, Scope(group)) @api.get("/health") def wellbeing(group: Annotated[str | None, Query()] = None) -> analytics_read.Gathered: """Family C: latency, refusals, errors and upstream behaviour.""" return analytics_panels.wellbeing(path, Scope(group)) @api.get("/demand") def wishes(group: Annotated[str | None, Query()] = None) -> analytics_read.Gathered: """Family D: near misses, grouped by the trigger they nearly hit.""" return analytics_panels.wishes(path, Scope(group), triggers()) @api.get("/behaviour") def behaviour(group: Annotated[str | None, Query()] = None) -> analytics_read.Gathered: """Family E: sittings, retention, releases and share of chat.""" return analytics_panels.behaviour(path, Scope(group), releases()) @api.get("/content") def items(group: Annotated[str | None, Query()] = None) -> analytics_read.Gathered: """Family F: how varied each pool actually is.""" return analytics_panels.items(path, Scope(group)) return api