Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,10 @@ jobs:
contents: read
with:
run-fuzz-tests: true
run-mutation-tests: true
# Mutation testing runs ~15 min and its per-PR signal is hard to
# action locally. Skip on pull_request; let main / merge_group /
# schedule carry the MSI regression bar.
run-mutation-tests: ${{ github.event_name != 'pull_request' }}
# Matches the phpunit.xml testsuite name (lowercase "fuzzy").
# The reusable workflow defaults to "Fuzz" which returns "No tests executed!".
fuzz-testsuite: fuzzy
Expand Down
33 changes: 33 additions & 0 deletions Classes/Widgets/DataProvider/MonthlyCostDataProvider.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

/*
* Copyright (c) 2025-2026 Netresearch DTT GmbH
* SPDX-License-Identifier: GPL-2.0-or-later
*/

declare(strict_types=1);

namespace Netresearch\NrLlm\Widgets\DataProvider;

use Netresearch\NrLlm\Service\UsageTrackerServiceInterface;
use TYPO3\CMS\Dashboard\Widgets\NumberWithIconDataProviderInterface;

/**
* Data provider for the "AI cost this month" NumberWithIcon widget.
*
* Returns the rounded integer dollar total for the current calendar month
* aggregated from tx_nrllm_service_usage. Fractions of a dollar are
* truncated deliberately — the dashboard tile is an at-a-glance indicator,
* not an accounting source. Precise figures belong in the usage report.
*/
final readonly class MonthlyCostDataProvider implements NumberWithIconDataProviderInterface
{
public function __construct(
private UsageTrackerServiceInterface $usageTracker,
) {}

public function getNumber(): int
{
return (int)floor($this->usageTracker->getCurrentMonthCost());
}
}
93 changes: 93 additions & 0 deletions Classes/Widgets/DataProvider/RequestsByProviderDataProvider.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
<?php

/*
* Copyright (c) 2025-2026 Netresearch DTT GmbH
* SPDX-License-Identifier: GPL-2.0-or-later
*/

declare(strict_types=1);

namespace Netresearch\NrLlm\Widgets\DataProvider;

use DateTimeImmutable;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Dashboard\Widgets\ChartDataProviderInterface;

/**
* Chart.js bar-chart data provider for "requests by provider (last N days)".
*
* Aggregates tx_nrllm_service_usage rows by service_provider. Unlike
* UsageTrackerService::getUsageReport() this spans every service_type
* (chat, vision, translation, ...) because the widget gives an overall
* provider-traffic view, not a per-service breakdown.
*/
final readonly class RequestsByProviderDataProvider implements ChartDataProviderInterface
{
private const TABLE = 'tx_nrllm_service_usage';
private const DEFAULT_DAYS = 7;

public function __construct(
private ConnectionPool $connectionPool,
private int $days = self::DEFAULT_DAYS,
) {}

/**
* @return array{labels: list<string>, datasets: list<array{label: string, data: list<int>, backgroundColor?: list<string>}>}
*/
public function getChartData(): array
{
$since = (new DateTimeImmutable())->modify(sprintf('-%d days', max(1, $this->days)));

$queryBuilder = $this->connectionPool->getQueryBuilderForTable(self::TABLE);
$rows = $queryBuilder
->select('service_provider')
->addSelectLiteral('SUM(request_count) as total_requests')
->from(self::TABLE)
->where(
$queryBuilder->expr()->gte('request_date', $queryBuilder->createNamedParameter($since->getTimestamp())),
)
->groupBy('service_provider')
->orderBy('total_requests', 'DESC')
->executeQuery()
->fetchAllAssociative();

return self::shapeChartData($rows);
}

/**
* Shape the SQL rows into chart.js bar-chart format.
*
* Extracted for unit-testability — the ConnectionPool-driven query
* path is covered by functional tests.
*
* @param array<int, array<string, mixed>> $rows
*
* @return array{labels: list<string>, datasets: list<array{label: string, data: list<int>, backgroundColor?: list<string>}>}
*/
public static function shapeChartData(array $rows): array
{
$labels = [];
$data = [];
foreach ($rows as $row) {
$provider = is_string($row['service_provider'] ?? null) ? $row['service_provider'] : '';
if ($provider === '') {
continue;
}
$labels[] = $provider;
/** @var mixed $rawCount */
$rawCount = $row['total_requests'] ?? 0;
$data[] = is_numeric($rawCount) ? (int)$rawCount : 0;
}

return [
'labels' => $labels,
'datasets' => [
[
'label' => 'Requests',
'data' => $data,
'backgroundColor' => array_fill(0, count($data), '#2F99A4'),
],
],
];
}
}
48 changes: 48 additions & 0 deletions Configuration/Services.Dashboard.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Dashboard widget registration for nr-llm.
#
# Loaded conditionally from Configuration/Services.php when
# TYPO3\CMS\Dashboard\Widgets\WidgetInterface exists (i.e. typo3/cms-dashboard
# is installed). Without that guard, extensions without dashboard would fail
# to compile their container because of unresolvable class references.

services:
_defaults:
autowire: true
autoconfigure: true
public: false

Netresearch\NrLlm\Widgets\DataProvider\MonthlyCostDataProvider: ~

Netresearch\NrLlm\Widgets\DataProvider\RequestsByProviderDataProvider: ~

dashboard.widget.nrllm.monthly_cost:
class: TYPO3\CMS\Dashboard\Widgets\NumberWithIconWidget
arguments:
$dataProvider: '@Netresearch\NrLlm\Widgets\DataProvider\MonthlyCostDataProvider'
$options:
icon: 'actions-currency'
title: 'LLL:EXT:nr_llm/Resources/Private/Language/locallang_dashboard.xlf:widget.monthly_cost.title'
subtitle: 'LLL:EXT:nr_llm/Resources/Private/Language/locallang_dashboard.xlf:widget.monthly_cost.subtitle'
tags:
- name: dashboard.widget
identifier: 'nrllm-monthly-cost'
groupNames: 'general'
title: 'LLL:EXT:nr_llm/Resources/Private/Language/locallang_dashboard.xlf:widget.monthly_cost.title'
description: 'LLL:EXT:nr_llm/Resources/Private/Language/locallang_dashboard.xlf:widget.monthly_cost.description'
iconIdentifier: 'actions-currency'
height: 'small'
width: 'small'

dashboard.widget.nrllm.requests_by_provider:
class: TYPO3\CMS\Dashboard\Widgets\BarChartWidget
arguments:
$dataProvider: '@Netresearch\NrLlm\Widgets\DataProvider\RequestsByProviderDataProvider'
tags:
- name: dashboard.widget
identifier: 'nrllm-requests-by-provider'
groupNames: 'general'
title: 'LLL:EXT:nr_llm/Resources/Private/Language/locallang_dashboard.xlf:widget.requests_by_provider.title'
description: 'LLL:EXT:nr_llm/Resources/Private/Language/locallang_dashboard.xlf:widget.requests_by_provider.description'
iconIdentifier: 'content-elements-mailform'
height: 'medium'
width: 'medium'
8 changes: 8 additions & 0 deletions Configuration/Services.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,15 @@
use Netresearch\NrLlm\DependencyInjection\ProviderCompilerPass;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
use TYPO3\CMS\Dashboard\Widgets\WidgetInterface;

return static function (ContainerConfigurator $containerConfigurator, ContainerBuilder $containerBuilder): void {
$containerBuilder->addCompilerPass(new ProviderCompilerPass());

// Dashboard widgets ship only when typo3/cms-dashboard is installed.
// Guarding here keeps TYPO3 installs without dashboard from blowing up
// on unresolvable class references during container compile.
if (class_exists(WidgetInterface::class)) {
$containerConfigurator->import('Services.Dashboard.yaml');
}
};
1 change: 1 addition & 0 deletions Configuration/Services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ services:
- '../Classes/Domain/Model/*'
- '../Classes/Provider/Exception/*'
- '../Classes/Specialized/Exception/*'
- '../Classes/Widgets/*'

# ========================================
# Core Services (PUBLIC)
Expand Down
85 changes: 85 additions & 0 deletions Documentation/Adr/Adr024DashboardWidgets.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
.. include:: /Includes.rst.txt

.. _adr-024:

==========================================
ADR-024: Dashboard Widgets
==========================================

:Status: Accepted
:Date: 2026-04
:Authors: Netresearch DTT GmbH

.. _adr-024-context:

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.

.. _adr-024-decision:

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** — :php:`NumberWithIconWidget` backed by
:php:`MonthlyCostDataProvider`, which delegates to
:php:`UsageTrackerService::getCurrentMonthCost()`. Returns dollars
floored to an integer; the dashboard tile is a glance-value, not an
accounting figure.
* **AI requests by provider (7d)** — :php:`BarChartWidget` backed by
:php:`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 :file:`Configuration/Services.Dashboard.yaml`
imported conditionally from :file:`Configuration/Services.php` when
:php:`TYPO3\\CMS\\Dashboard\\Widgets\\WidgetInterface` exists. Without that
guard, TYPO3 instances that do not have :code:`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
:file:`Services.yaml` for the same reason — the data provider classes
import dashboard interfaces and must not be loaded when dashboard is
absent.

.. _adr-024-tradeoffs:

Trade-offs
==========

* **+ Reuse core widget classes.** Two core TYPO3 widget types cover the
useful shapes. Writing a custom widget buys nothing.
* **+ Optional dependency.** :code:`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
:php:`RequestsByProviderDataProvider::shapeChartData()` is static for
unit-testability, but the SQL lives in an instance method bound to
:code:`ConnectionPool`. The trade-off keeps unit tests honest and
functional coverage narrow.
* **- Flooring the cost.** Displaying :code:`$12.97` as :code:`12` is
jarring for cost-sensitive users but the widget API returns :code:`int`.
Follow-up: a custom template could render the subtitle with fractional
digits once we have one.

.. _adr-024-alternatives:

Alternatives considered
=======================

* **Custom widget classes** implementing :php:`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 :php:`NumberWithIconWidget` shape cripples both.
1 change: 1 addition & 0 deletions Documentation/Adr/Index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -256,3 +256,4 @@ Modern architecture (v0.4+)
Adr021ProviderFallbackChain
Adr022AttributeBasedProviderRegistration
Adr023BackendCapabilityPermissions
Adr024DashboardWidgets
27 changes: 27 additions & 0 deletions Resources/Private/Language/de.locallang_dashboard.xlf
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?>
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
<file source-language="en" target-language="de" datatype="plaintext" original="messages">
<body>
<trans-unit id="widget.monthly_cost.title">
<source>AI cost this month</source>
<target>KI-Kosten diesen Monat</target>
</trans-unit>
<trans-unit id="widget.monthly_cost.subtitle">
<source>USD, rounded down</source>
<target>USD, abgerundet</target>
</trans-unit>
<trans-unit id="widget.monthly_cost.description">
<source>Total estimated AI service cost for the current calendar month, aggregated from every nr-llm usage record.</source>
<target>Geschätzte Gesamtkosten für KI-Dienste im aktuellen Kalendermonat, aggregiert aus allen nr-llm-Nutzungsdaten.</target>
</trans-unit>
<trans-unit id="widget.requests_by_provider.title">
<source>AI requests by provider</source>
<target>KI-Anfragen nach Anbieter</target>
</trans-unit>
<trans-unit id="widget.requests_by_provider.description">
<source>Bar chart of nr-llm requests grouped by provider over the last seven days. Combines chat, vision, translation, speech, and image generation.</source>
<target>Balkendiagramm der nr-llm-Anfragen nach Anbieter in den letzten sieben Tagen. Umfasst Chat, Vision, Übersetzung, Sprache und Bilderzeugung.</target>
</trans-unit>
</body>
</file>
</xliff>
22 changes: 22 additions & 0 deletions Resources/Private/Language/locallang_dashboard.xlf
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
<file source-language="en" datatype="plaintext" original="messages">
<body>
<trans-unit id="widget.monthly_cost.title">
<source>AI cost this month</source>
</trans-unit>
<trans-unit id="widget.monthly_cost.subtitle">
<source>USD, rounded down</source>
</trans-unit>
<trans-unit id="widget.monthly_cost.description">
<source>Total estimated AI service cost for the current calendar month, aggregated from every nr-llm usage record.</source>
</trans-unit>
<trans-unit id="widget.requests_by_provider.title">
<source>AI requests by provider</source>
</trans-unit>
<trans-unit id="widget.requests_by_provider.description">
<source>Bar chart of nr-llm requests grouped by provider over the last seven days. Combines chat, vision, translation, speech, and image generation.</source>
</trans-unit>
</body>
</file>
</xliff>
Loading
Loading