ADR 024 · Accepted
Dashboard Widgets
Context
tx_nrllm_service_usage has tracked per-request cost and usage from day one, but the data was only reachable through the backend module's report views. Administrators wanted an at-a-glance view next to everything else they already follow — scheduled tasks, indexing, form submissions — which lives on TYPO3's dashboard.
Decision
Ship two widgets that reuse TYPO3's built-in widget classes and wire them up with nr-llm-specific data providers:
AI cost this month — NumberWithIconWidget backed by MonthlyCostDataProvider, which delegates to UsageTrackerService::getCurrentMonthCost(). Returns dollars floored to an integer; the dashboard tile is a glance-value, not an accounting figure.
AI requests by provider (7d) — BarChartWidget backed by RequestsByProviderDataProvider, which aggregates every service type (chat, vision, translation, speech, image) by service_provider over the last seven days.
Both are registered in a dedicated Configuration/Services.Dashboard.php imported conditionally from Configuration/Services.php only when interface_exists(TYPO3\CMS\Dashboard\Widgets\WidgetInterface::class). A PHP config file (not YAML) is used so the import can be guarded by that runtime interface_exists() check. Without the guard, TYPO3 instances that do not have typo3/cms-dashboard installed would fail at container compile time on the unresolved widget class.
Classes/Widgets/* is excluded from the global auto-registration in Services.yaml for the same reason — the data provider classes import dashboard interfaces and must not be loaded when dashboard is absent.
Trade-offs
+ Reuse core widget classes. Two core TYPO3 widget types cover the useful shapes. Writing a custom widget buys nothing.
+ Optional dependency. typo3/cms-dashboard is a suggest, not a hard require. Installs without dashboard lose the widgets but pay no runtime cost and see no container errors.
- Two data-shape spots. The row-shaping logic on RequestsByProviderDataProvider::shapeChartData() is static for unit-testability, but the SQL lives in an instance method bound to ConnectionPool. The trade-off keeps unit tests honest and functional coverage narrow.
- Flooring the cost. Displaying $12.97 as 12 is jarring for cost-sensitive users but the widget API returns int. Follow-up: a custom template could render the subtitle with fractional digits once we have one.
Alternatives considered
Custom widget classes implementing WidgetInterface directly. Rejected — duplicates what the core widgets already do.
Per-day time series instead of per-provider aggregate. Interesting but the current 7-day window is short enough that the distribution is the more useful glance value.
One combined widget with cost + count + top provider in a single tile. Rejected — mixes two summary numbers into one, and forcing both to share the NumberWithIconWidget shape cripples both.