diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 697e88a89..673774740 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/Classes/Widgets/DataProvider/MonthlyCostDataProvider.php b/Classes/Widgets/DataProvider/MonthlyCostDataProvider.php new file mode 100644 index 000000000..eced45f59 --- /dev/null +++ b/Classes/Widgets/DataProvider/MonthlyCostDataProvider.php @@ -0,0 +1,33 @@ +usageTracker->getCurrentMonthCost()); + } +} diff --git a/Classes/Widgets/DataProvider/RequestsByProviderDataProvider.php b/Classes/Widgets/DataProvider/RequestsByProviderDataProvider.php new file mode 100644 index 000000000..27110a45f --- /dev/null +++ b/Classes/Widgets/DataProvider/RequestsByProviderDataProvider.php @@ -0,0 +1,93 @@ +, datasets: list, backgroundColor?: list}>} + */ + 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> $rows + * + * @return array{labels: list, datasets: list, backgroundColor?: list}>} + */ + 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'), + ], + ], + ]; + } +} diff --git a/Configuration/Services.Dashboard.yaml b/Configuration/Services.Dashboard.yaml new file mode 100644 index 000000000..14959a0e1 --- /dev/null +++ b/Configuration/Services.Dashboard.yaml @@ -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' diff --git a/Configuration/Services.php b/Configuration/Services.php index 362c42ae8..457a22a17 100644 --- a/Configuration/Services.php +++ b/Configuration/Services.php @@ -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'); + } }; diff --git a/Configuration/Services.yaml b/Configuration/Services.yaml index 9e3cf6300..f19ce4e24 100644 --- a/Configuration/Services.yaml +++ b/Configuration/Services.yaml @@ -21,6 +21,7 @@ services: - '../Classes/Domain/Model/*' - '../Classes/Provider/Exception/*' - '../Classes/Specialized/Exception/*' + - '../Classes/Widgets/*' # ======================================== # Core Services (PUBLIC) diff --git a/Documentation/Adr/Adr024DashboardWidgets.rst b/Documentation/Adr/Adr024DashboardWidgets.rst new file mode 100644 index 000000000..be8fa2dab --- /dev/null +++ b/Documentation/Adr/Adr024DashboardWidgets.rst @@ -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. diff --git a/Documentation/Adr/Index.rst b/Documentation/Adr/Index.rst index f62ce4ed0..653002135 100644 --- a/Documentation/Adr/Index.rst +++ b/Documentation/Adr/Index.rst @@ -256,3 +256,4 @@ Modern architecture (v0.4+) Adr021ProviderFallbackChain Adr022AttributeBasedProviderRegistration Adr023BackendCapabilityPermissions + Adr024DashboardWidgets diff --git a/Resources/Private/Language/de.locallang_dashboard.xlf b/Resources/Private/Language/de.locallang_dashboard.xlf new file mode 100644 index 000000000..d8fa5fd49 --- /dev/null +++ b/Resources/Private/Language/de.locallang_dashboard.xlf @@ -0,0 +1,27 @@ + + + + + + AI cost this month + KI-Kosten diesen Monat + + + USD, rounded down + USD, abgerundet + + + Total estimated AI service cost for the current calendar month, aggregated from every nr-llm usage record. + Geschätzte Gesamtkosten für KI-Dienste im aktuellen Kalendermonat, aggregiert aus allen nr-llm-Nutzungsdaten. + + + AI requests by provider + KI-Anfragen nach Anbieter + + + Bar chart of nr-llm requests grouped by provider over the last seven days. Combines chat, vision, translation, speech, and image generation. + Balkendiagramm der nr-llm-Anfragen nach Anbieter in den letzten sieben Tagen. Umfasst Chat, Vision, Übersetzung, Sprache und Bilderzeugung. + + + + diff --git a/Resources/Private/Language/locallang_dashboard.xlf b/Resources/Private/Language/locallang_dashboard.xlf new file mode 100644 index 000000000..fa6a8bd44 --- /dev/null +++ b/Resources/Private/Language/locallang_dashboard.xlf @@ -0,0 +1,22 @@ + + + + + + AI cost this month + + + USD, rounded down + + + Total estimated AI service cost for the current calendar month, aggregated from every nr-llm usage record. + + + AI requests by provider + + + Bar chart of nr-llm requests grouped by provider over the last seven days. Combines chat, vision, translation, speech, and image generation. + + + + diff --git a/Tests/Unit/Widgets/DataProvider/MonthlyCostDataProviderTest.php b/Tests/Unit/Widgets/DataProvider/MonthlyCostDataProviderTest.php new file mode 100644 index 000000000..c87e32555 --- /dev/null +++ b/Tests/Unit/Widgets/DataProvider/MonthlyCostDataProviderTest.php @@ -0,0 +1,68 @@ +method('getCurrentMonthCost')->willReturn(12.95); + + $provider = new MonthlyCostDataProvider($tracker); + + self::assertSame(12, $provider->getNumber()); + } + + #[Test] + public function returnsZeroForZeroCost(): void + { + $tracker = self::createStub(UsageTrackerServiceInterface::class); + $tracker->method('getCurrentMonthCost')->willReturn(0.0); + + $provider = new MonthlyCostDataProvider($tracker); + + self::assertSame(0, $provider->getNumber()); + } + + /** + * @return array + */ + public static function costCases(): array + { + return [ + 'exact dollar' => [1.0, 1], + 'sub-dollar floors to zero' => [0.99, 0], + 'truncates cents' => [42.99, 42], + 'large value' => [12345.67, 12345], + ]; + } + + #[Test] + #[DataProvider('costCases')] + public function flooringTable(float $cost, int $expected): void + { + $tracker = self::createStub(UsageTrackerServiceInterface::class); + $tracker->method('getCurrentMonthCost')->willReturn($cost); + + $provider = new MonthlyCostDataProvider($tracker); + + self::assertSame($expected, $provider->getNumber()); + } +} diff --git a/Tests/Unit/Widgets/DataProvider/RequestsByProviderDataProviderTest.php b/Tests/Unit/Widgets/DataProvider/RequestsByProviderDataProviderTest.php new file mode 100644 index 000000000..b5f8d7ae7 --- /dev/null +++ b/Tests/Unit/Widgets/DataProvider/RequestsByProviderDataProviderTest.php @@ -0,0 +1,93 @@ + 'openai', 'total_requests' => 150], + ['service_provider' => 'claude', 'total_requests' => 80], + ['service_provider' => 'ollama', 'total_requests' => 5], + ]); + + self::assertSame(['openai', 'claude', 'ollama'], $shaped['labels']); + self::assertCount(1, $shaped['datasets']); + self::assertSame('Requests', $shaped['datasets'][0]['label']); + self::assertSame([150, 80, 5], $shaped['datasets'][0]['data']); + self::assertCount(3, $shaped['datasets'][0]['backgroundColor'] ?? []); + } + + #[Test] + public function skipsRowsWithEmptyProviderName(): void + { + $shaped = RequestsByProviderDataProvider::shapeChartData([ + ['service_provider' => 'openai', 'total_requests' => 10], + ['service_provider' => '', 'total_requests' => 100], // silently dropped + ['service_provider' => 'gemini', 'total_requests' => 20], + ]); + + self::assertSame(['openai', 'gemini'], $shaped['labels']); + self::assertSame([10, 20], $shaped['datasets'][0]['data']); + } + + #[Test] + public function skipsRowsWithNonStringProviderName(): void + { + $shaped = RequestsByProviderDataProvider::shapeChartData([ + ['service_provider' => null, 'total_requests' => 50], + ['service_provider' => 123, 'total_requests' => 70], + ['service_provider' => 'mistral', 'total_requests' => 9], + ]); + + self::assertSame(['mistral'], $shaped['labels']); + self::assertSame([9], $shaped['datasets'][0]['data']); + } + + #[Test] + public function treatsMissingRequestCountAsZero(): void + { + $shaped = RequestsByProviderDataProvider::shapeChartData([ + ['service_provider' => 'openai'], + ]); + + self::assertSame(['openai'], $shaped['labels']); + self::assertSame([0], $shaped['datasets'][0]['data']); + } + + #[Test] + public function returnsEmptyLabelsAndDataForEmptyRows(): void + { + $shaped = RequestsByProviderDataProvider::shapeChartData([]); + + self::assertSame([], $shaped['labels']); + self::assertSame([], $shaped['datasets'][0]['data']); + self::assertSame([], $shaped['datasets'][0]['backgroundColor'] ?? []); + } + + #[Test] + public function castsStringRequestCountToInt(): void + { + // MySQL SUM() can return strings; we normalize to int. + $shaped = RequestsByProviderDataProvider::shapeChartData([ + ['service_provider' => 'openai', 'total_requests' => '42'], + ]); + + self::assertSame([42], $shaped['datasets'][0]['data']); + } +} diff --git a/composer.json b/composer.json index d15d9430d..4f9fef7d9 100644 --- a/composer.json +++ b/composer.json @@ -43,8 +43,12 @@ "fakerphp/faker": "^1.24", "giorgiosironi/eris": "^1.0", "netresearch/typo3-ci-workflows": "^1.2", + "typo3/cms-dashboard": "^13.4 || ^14.0", "typo3/cms-install": "^13.4 || ^14.0" }, + "suggest": { + "typo3/cms-dashboard": "Adds AI usage / cost widgets to the TYPO3 dashboard" + }, "autoload": { "psr-4": { "Netresearch\\NrLlm\\": "Classes/"