From af5055b33ea90de7b2b20aba1af7df2148dbcfe5 Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 12 Nov 2026 11:14:01 +1100 Subject: [PATCH] Messages endpoint (#13247) * Adds /api/status/ endpoint for admin-facing system messages * update alerts menu * right-align badges * Rename SERVER_RESTART_REQUIRED to _SERVER_RESTART_REQUIRED * Adjust API endpoint - Move to /api/system/status/ - Make messages a sub-list - Remove "system_state" from /api/ * move staff-only messages to new endpoint (and adjust UI accordingly) * system dialogs staff only * Reduce scope and revert most changes * ignore reports in frontend * clean up blob --------- Co-authored-by: Matthias Mair --- .../InvenTree/InvenTree/api_version.py | 5 +- .../InvenTree/common/admin_messages.py | 180 ++++++++++++++++ src/backend/InvenTree/common/api.py | 30 ++- src/backend/InvenTree/common/serializers.py | 26 +++ src/backend/InvenTree/common/test_api.py | 117 +++++++++++ src/frontend/.gitignore | 2 + src/frontend/lib/enums/ApiEndpoints.tsx | 1 + src/frontend/src/components/nav/Alerts.tsx | 169 ++++++--------- src/frontend/src/hooks/UseAdminMessages.tsx | 94 +++++++++ .../Index/Settings/AdminCenter/HomePanel.tsx | 198 +++++++++++++----- src/frontend/tests/pui_settings.spec.ts | 77 +++++++ 11 files changed, 742 insertions(+), 157 deletions(-) create mode 100644 src/backend/InvenTree/common/admin_messages.py create mode 100644 src/frontend/src/hooks/UseAdminMessages.tsx diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index 8dc45cae32..2cfcbafc12 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,11 +1,14 @@ """InvenTree API version information.""" # InvenTree API version -INVENTREE_API_VERSION = 575 +INVENTREE_API_VERSION = 576 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v576 -> 2026-11-11 : https://github.com/inventree/InvenTree/pull/13247 + - Adds /api/system/messages/ endpoint: staff-only, read-only; classified system messages are provided in the "messages" array + v575 -> 2026-11-10 : https://github.com/inventree/InvenTree/pull/13208 - Adds optional read-only "consumed_by_detail" to the StockItem API diff --git a/src/backend/InvenTree/common/admin_messages.py b/src/backend/InvenTree/common/admin_messages.py new file mode 100644 index 0000000000..abee996e3d --- /dev/null +++ b/src/backend/InvenTree/common/admin_messages.py @@ -0,0 +1,180 @@ +"""Staff-only system messages, displayed in the admin center. + +Each message describes the outcome of a single system check. Messages are +generated on demand (they are not stored in the database), and are exposed +via the read-only /api/system/messages/ endpoint. +""" + +from dataclasses import dataclass +from enum import StrEnum +from typing import Optional + +from django.conf import settings +from django.utils.translation import gettext_lazy as _ + +import structlog + +from common.settings import get_global_setting + +logger = structlog.get_logger('inventree') + + +class AdminMessageClassifier(StrEnum): + """Severity classifier for an admin message.""" + + ERROR = 'error' + WARNING = 'warning' + INFO = 'info' + SUCCESS = 'success' + + +# Ordering used when sorting messages (most severe first) +CLASSIFIER_RANK = { + AdminMessageClassifier.ERROR: 0, + AdminMessageClassifier.WARNING: 1, + AdminMessageClassifier.INFO: 2, + AdminMessageClassifier.SUCCESS: 3, +} + + +@dataclass +class AdminMessage: + """A single staff-facing system message. + + Attributes: + key: Unique identifier for the message (e.g. 'worker') + title: Short, human readable title + message: Detailed message text + classifier: Severity of the message + code: Optional InvenTree error code (e.g. 'INVE-W5'), used to link to documentation + plugin: Slug of the plugin which provided this message (None for core messages) + """ + + key: str + title: str + message: str + classifier: AdminMessageClassifier + code: Optional[str] = None + plugin: Optional[str] = None + + +def _check( + key: str, + title: str, + message: str, + condition: bool, + code: Optional[str] = None, + classifier: AdminMessageClassifier = AdminMessageClassifier.WARNING, +) -> AdminMessage: + """Construct a message for a simple pass/fail system check. + + Arguments: + key: Unique message key + title: Message title + message: Message text to display if the check fails + condition: True if the check has *failed* (i.e. the issue is present) + code: Optional error code + classifier: Classifier to use if the check fails + """ + if condition: + return AdminMessage( + key=key, title=title, message=message, classifier=classifier, code=code + ) + + return AdminMessage( + key=key, + title=title, + message=_('No issues detected'), + classifier=AdminMessageClassifier.SUCCESS, + code=code, + ) + + +def _pending_migrations() -> int: + """Return the number of pending database migrations (as recorded by the background worker).""" + try: + return int(get_global_setting('_PENDING_MIGRATIONS', 0) or 0) + except (TypeError, ValueError): + return 0 + + +def get_core_admin_messages() -> list[AdminMessage]: + """Return the set of core InvenTree system messages.""" + # Imported here to avoid circular imports at app load + from InvenTree.helpers_email import is_email_configured + from InvenTree.status import is_worker_running + + return [ + _check( + 'debug', + _('Debug Mode'), + _('The server is running in debug mode.'), + bool(settings.DEBUG), + code='INVE-W4', + ), + _check( + 'worker', + _('Background Worker'), + _('The background worker process is not running.'), + not is_worker_running(), + code='INVE-W5', + ), + _check( + 'restart', + _('Server Restart'), + _('The server requires a restart to apply changes.'), + bool(get_global_setting('SERVER_RESTART_REQUIRED', False, cache=False)), + code='INVE-W6', + ), + _check( + 'email', + _('Email settings'), + _('Email settings not configured.'), + not is_email_configured(), + code='INVE-W7', + ), + _check( + 'migrations', + _('Database Migrations'), + _('There are pending database migrations.'), + _pending_migrations() > 0, + code='INVE-W8', + ), + _check( + 'cors', + _('CORS Settings'), + _('CORS is set to allow all origins.'), + bool(settings.CORS_ALLOW_ALL_ORIGINS), + code='INVE-W16', + ), + _check( + 'basic_auth', + _('Basic Authentication'), + _('Basic (username:password) authentication is enabled for the API.'), + bool(settings.BASIC_AUTH_ENABLED), + code='INVE-W18', + ), + ] + + +def get_plugin_admin_messages() -> list[AdminMessage]: + """Return system messages provided by plugins. + + PLACEHOLDER: There is not yet a plugin mixin for providing admin messages. + + The intended design is a dedicated mixin (e.g. 'AdminMessageMixin') with a + method returning a list of AdminMessage objects. Each returned message + should be tagged with the providing plugin's slug (AdminMessage.plugin), + and have its key namespaced (e.g. '.') to avoid collisions + with core messages. Any exception raised by a plugin must be caught and + logged here, so that a faulty plugin cannot break the endpoint. + """ + return [] + + +def get_admin_messages() -> list[AdminMessage]: + """Return all admin messages (core and plugin), sorted most severe first.""" + messages = get_core_admin_messages() + get_plugin_admin_messages() + + # Stable sort - preserves definition order within each classifier + return sorted(messages, key=lambda msg: CLASSIFIER_RANK[msg.classifier]) diff --git a/src/backend/InvenTree/common/api.py b/src/backend/InvenTree/common/api.py index 05827fdc9e..09ac537dc6 100644 --- a/src/backend/InvenTree/common/api.py +++ b/src/backend/InvenTree/common/api.py @@ -1741,6 +1741,32 @@ class HealthCheckStatusSerializer(serializers.Serializer): ) +class AdminMessageList(APIView): + """Read-only, staff-only list of classified system messages (error / warning / info / success). + + Messages are generated on demand from core system checks (and potentially plugins). + """ + + permission_classes = [IsAdminOrAdminScope] + + @extend_schema( + responses={ + 200: OpenApiResponse( + response=common.serializers.AdminMessageListSerializer, + description='System messages for staff users', + ) + } + ) + def get(self, request, *args, **kwargs): + """Return system messages (most severe first).""" + from common.admin_messages import get_admin_messages + + serializer = common.serializers.AdminMessageListSerializer({ + 'messages': get_admin_messages() + }) + return Response(serializer.data) + + class HealthCheckView(APIView): """Simple JSON endpoint for InvenTree health check. @@ -2170,7 +2196,9 @@ common_api_urls = [ 'system/', include([ # Health check - path('health/', HealthCheckView.as_view(), name='api-system-health') + path('health/', HealthCheckView.as_view(), name='api-system-health'), + # Staff-only system status messages + path('messages/', AdminMessageList.as_view(), name='api-system-messages'), ]), ), # Internal System APIs - DO NOT USE diff --git a/src/backend/InvenTree/common/serializers.py b/src/backend/InvenTree/common/serializers.py index 5b5cac1899..2f88b6d345 100644 --- a/src/backend/InvenTree/common/serializers.py +++ b/src/backend/InvenTree/common/serializers.py @@ -16,6 +16,7 @@ from rest_framework import serializers from rest_framework.exceptions import PermissionDenied from taggit.models import Tag +import common.admin_messages import common.filters import common.models as common_models import common.validators @@ -506,6 +507,31 @@ class CustomStateSerializer(DataImportExportSerializerMixin, InvenTreeModelSeria ) +class AdminMessageSerializer(serializers.Serializer): + """Serializer for a (read-only) staff-facing system message. + + Instances are common.admin_messages.AdminMessage dataclass objects. + """ + + key = serializers.CharField(read_only=True) + title = serializers.CharField(read_only=True) + message = serializers.CharField(read_only=True) + classifier = serializers.ChoiceField( + read_only=True, + choices=[ + (c.value, c.value) for c in common.admin_messages.AdminMessageClassifier + ], + ) + code = serializers.CharField(read_only=True, allow_null=True) + plugin = serializers.CharField(read_only=True, allow_null=True) + + +class AdminMessageListSerializer(serializers.Serializer): + """Serializer for the staff-only system messages endpoint.""" + + messages = AdminMessageSerializer(many=True, read_only=True) + + class FlagSerializer(serializers.Serializer): """Serializer for feature flags.""" diff --git a/src/backend/InvenTree/common/test_api.py b/src/backend/InvenTree/common/test_api.py index 4c2a9d2640..2eae604ded 100644 --- a/src/backend/InvenTree/common/test_api.py +++ b/src/backend/InvenTree/common/test_api.py @@ -3592,3 +3592,120 @@ class GenericMetadataAuthorizationTests(InvenTreeAPITestCase): request = factory.patch('/') request.user = self.user self.assertTrue(perm.has_object_permission(request, None, unit)) + + +class AdminMessageAPITests(InvenTreeAPITestCase): + """Tests for the staff-only /api/system/messages/ endpoint.""" + + roles = [] + + CORE_KEYS = { + 'debug', + 'worker', + 'restart', + 'email', + 'migrations', + 'cors', + 'basic_auth', + } + + @property + def url(self): + """Return the endpoint URL.""" + return reverse('api-system-messages') + + def _messages(self) -> dict: + """Fetch messages, keyed by message key.""" + response = self.get(self.url, expected_code=200) + return {msg['key']: msg for msg in response.data['messages']} + + def test_permissions(self): + """Only staff users may access the endpoint.""" + self.get(self.url, expected_code=200) + + self.user.is_staff = False + self.user.save() + self.get(self.url, expected_code=403) + + self.logout() + self.get(self.url, expected_code=401) + + def test_read_only(self): + """The endpoint does not accept write methods.""" + self.post(self.url, {}, expected_code=405) + self.put(self.url, {}, expected_code=405) + self.patch(self.url, {}, expected_code=405) + self.delete(self.url, expected_code=405) + + def test_core_messages(self): + """All core system checks are reported, with valid structure.""" + response = self.get(self.url, expected_code=200) + self.assertEqual(set(response.data.keys()), {'messages'}) + self.assertIsInstance(response.data['messages'], list) + + messages = self._messages() + + self.assertTrue(self.CORE_KEYS.issubset(messages.keys())) + + for msg in messages.values(): + self.assertIn(msg['classifier'], ['error', 'warning', 'info', 'success']) + self.assertTrue(msg['title']) + self.assertTrue(msg['message']) + self.assertIsNone(msg['plugin']) + + self.assertEqual(messages['basic_auth']['code'], 'INVE-W18') + + def test_ordering(self): + """Messages are returned most-severe first.""" + rank = {'error': 0, 'warning': 1, 'info': 2, 'success': 3} + response = self.get(self.url, expected_code=200) + ranks = [rank[msg['classifier']] for msg in response.data['messages']] + self.assertEqual(ranks, sorted(ranks)) + + def test_conditions(self): + """Message classifiers reflect the underlying system state.""" + with override_settings(CORS_ALLOW_ALL_ORIGINS=True, BASIC_AUTH_ENABLED=True): + messages = self._messages() + self.assertEqual(messages['cors']['classifier'], 'warning') + self.assertEqual(messages['basic_auth']['classifier'], 'warning') + + with override_settings(CORS_ALLOW_ALL_ORIGINS=False, BASIC_AUTH_ENABLED=False): + messages = self._messages() + self.assertEqual(messages['cors']['classifier'], 'success') + self.assertEqual(messages['basic_auth']['classifier'], 'success') + + set_global_setting('_PENDING_MIGRATIONS', 3, None) + set_global_setting('SERVER_RESTART_REQUIRED', True, None) + messages = self._messages() + self.assertEqual(messages['migrations']['classifier'], 'warning') + self.assertEqual(messages['restart']['classifier'], 'warning') + + set_global_setting('_PENDING_MIGRATIONS', 0, None) + set_global_setting('SERVER_RESTART_REQUIRED', False, None) + messages = self._messages() + self.assertEqual(messages['migrations']['classifier'], 'success') + self.assertEqual(messages['restart']['classifier'], 'success') + + def test_plugin_messages(self): + """Plugin-provided messages are merged in and sorted by severity.""" + from unittest import mock + + from common.admin_messages import AdminMessage, AdminMessageClassifier + + plugin_msg = AdminMessage( + key='sample.error', + title='Plugin Error', + message='Something is wrong', + classifier=AdminMessageClassifier.ERROR, + plugin='sample', + ) + + with mock.patch( + 'common.admin_messages.get_plugin_admin_messages', return_value=[plugin_msg] + ): + response = self.get(self.url, expected_code=200) + + first = response.data['messages'][0] + self.assertEqual(first['key'], 'sample.error') + self.assertEqual(first['classifier'], 'error') + self.assertEqual(first['plugin'], 'sample') diff --git a/src/frontend/.gitignore b/src/frontend/.gitignore index 139bf919c1..ee70169c39 100644 --- a/src/frontend/.gitignore +++ b/src/frontend/.gitignore @@ -30,6 +30,8 @@ dist-ssr /.nyc_output/ /coverage/ /flakiness-report/ +/report/ +/blob-report/ # Report generation stats.html diff --git a/src/frontend/lib/enums/ApiEndpoints.tsx b/src/frontend/lib/enums/ApiEndpoints.tsx index 62c83dcb5b..5bd8fe10a8 100644 --- a/src/frontend/lib/enums/ApiEndpoints.tsx +++ b/src/frontend/lib/enums/ApiEndpoints.tsx @@ -57,6 +57,7 @@ export enum ApiEndpoints { settings_global_list = 'settings/global/', settings_user_list = 'settings/user/', news = 'news/', + system_messages = 'system/messages/', global_status = 'generic/status/', custom_state_list = 'generic/status/custom/', version = 'version/', diff --git a/src/frontend/src/components/nav/Alerts.tsx b/src/frontend/src/components/nav/Alerts.tsx index a903512e59..9a84d19314 100644 --- a/src/frontend/src/components/nav/Alerts.tsx +++ b/src/frontend/src/components/nav/Alerts.tsx @@ -1,56 +1,67 @@ -import { ActionIcon, Alert, Group, Menu, Stack, Tooltip } from '@mantine/core'; -import { IconCircleCheck, IconExclamationCircle } from '@tabler/icons-react'; +import { + ActionIcon, + Alert, + Group, + Menu, + Stack, + Text, + Tooltip +} from '@mantine/core'; +import { + IconAlertTriangle, + IconArrowRight, + IconCircleCheck, + IconExclamationCircle, + IconInfoCircle, + IconListDetails +} from '@tabler/icons-react'; import { useMemo, useState } from 'react'; +import { Link } from 'react-router-dom'; -import type { SettingsStateProps } from '@lib/types/Settings'; import { t } from '@lingui/core/macro'; -import { useShallow } from 'zustand/react/shallow'; import { docLinks } from '../../defaults/links'; -import { useServerApiState } from '../../states/ServerApiState'; -import { useGlobalSettingsState } from '../../states/SettingsStates'; -import { useUserState } from '../../states/UserState'; -import type { ServerAPIProps } from '../../states/states'; - -interface AlertInfo { - key: string; - title: string; - code?: string; - message: string; - error?: boolean; -} +import { + ACTIONABLE_CLASSIFIERS, + type AdminMessage, + type AdminMessageClassifier, + classifierColor, + useAdminMessages +} from '../../hooks/UseAdminMessages'; /** - * The `Alerts` component displays a menu of alerts for staff users based on the server state - * and global settings. Alerts are shown as a dropdown menu with actionable items that can be dismissed. + * The `Alerts` component displays a menu of alerts for staff users, based on the + * system messages provided by the server (via the /api/system/messages/ endpoint). * + * Only actionable messages (errors and warnings) are displayed here. * Dismissed alerts are filtered out and will not reappear in the current session. * * @returns A dropdown menu of alerts for staff users or `null` if there are no alerts or the user is not a staff member. */ export function Alerts() { - const user = useUserState(); - const [server] = useServerApiState(useShallow((state) => [state.server])); - const globalSettings = useGlobalSettingsState(); + const { messages } = useAdminMessages(); const [dismissed, setDismissed] = useState([]); - const alerts: ExtendedAlertInfo[] = useMemo( + const alerts: AdminMessage[] = useMemo( () => - getAlerts(server, globalSettings).filter( - (alert) => !dismissed.includes(alert.key) + messages.filter( + (msg) => + ACTIONABLE_CLASSIFIERS.includes(msg.classifier) && + !dismissed.includes(msg.key) ), - [server, dismissed, globalSettings] + [messages, dismissed] ); const anyErrors: boolean = useMemo( - () => alerts.some((alert) => alert.error), + () => alerts.some((alert) => alert.classifier === 'error'), [alerts] ); + function closeAlert(key: string) { setDismissed([...dismissed, key]); } - if (user.isStaff() && alerts.length > 0) + if (alerts.length > 0) return ( @@ -70,21 +81,47 @@ export function Alerts() { ))} + + } + rightSection={} + > + {t`View all messages`} + ); return null; } +function classifierIcon(classifier: AdminMessageClassifier) { + switch (classifier) { + case 'error': + return ; + case 'warning': + return ; + case 'info': + return ; + case 'success': + default: + return ; + } +} + export function ServerAlert({ alert, closeAlert -}: { alert: ExtendedAlertInfo; closeAlert?: (key: string) => void }) { +}: { alert: AdminMessage; closeAlert?: (key: string) => void }) { + const actionable = ACTIONABLE_CLASSIFIERS.includes(alert.classifier); + return ( : } + color={classifierColor(alert.classifier)} + icon={classifierIcon(alert.classifier)} title={ {alert.code && `${alert.code}: `} @@ -94,81 +131,13 @@ export function ServerAlert({ onClose={closeAlert ? () => closeAlert(alert.key) : undefined} > - {!alert.condition && t`No issues detected`} - {alert.condition && alert.message} - {alert.condition && alert.code && errorCodeLink(alert.code)} + {alert.message} + {actionable && alert.code && errorCodeLink(alert.code)} ); } -export type ExtendedAlertInfo = AlertInfo & { - condition: boolean; -}; - -export function getAlerts( - server: ServerAPIProps, - globalSettings: SettingsStateProps, - inactive = false -): ExtendedAlertInfo[] { - const n_migrations = - Number.parseInt(globalSettings.getSetting('_PENDING_MIGRATIONS')) ?? 0; - - const allAlerts: ExtendedAlertInfo[] = [ - { - key: 'debug', - title: t`Debug Mode`, - code: 'INVE-W4', - message: t`The server is running in debug mode.`, - condition: server?.debug_mode || false - }, - { - key: 'worker', - title: t`Background Worker`, - code: 'INVE-W5', - message: t`The background worker process is not running.`, - condition: !server?.worker_running - }, - { - key: 'restart', - title: t`Server Restart`, - code: 'INVE-W6', - message: t`The server requires a restart to apply changes.`, - condition: globalSettings.isSet('SERVER_RESTART_REQUIRED') - }, - { - key: 'email', - title: t`Email settings`, - code: 'INVE-W7', - message: t`Email settings not configured.`, - condition: !server?.email_configured - }, - { - key: 'migrations', - title: t`Database Migrations`, - code: 'INVE-W8', - message: t`There are pending database migrations.`, - condition: n_migrations > 0 - }, - { - key: 'cors', - title: t`CORS Settings`, - code: 'INVE-W16', - message: t`CORS is set to allow all origins.`, - condition: server?.system_state?.cors_allow_all || false - }, - { - key: 'basic_auth', - title: t`Basic Authentication`, - code: 'INVE-W18', - message: t`Basic (username:password) authentication is enabled for the API.`, - condition: server?.system_state?.basic_auth_enabled || false - } - ]; - - return allAlerts.filter((alert) => inactive || alert.condition); -} - export function errorCodeLink(code: string) { return ( ({ + enabled: isStaff, + queryKey: ['admin-messages', user.userId()], + // System state changes infrequently, and checks may hit the database + staleTime: 60 * 1000, + refetchOnWindowFocus: false, + queryFn: async () => + api + .get(apiUrl(ApiEndpoints.system_messages)) + .then((response) => response.data ?? null) + }); + + return { + ...query, + messages: isStaff ? (query.data?.messages ?? []) : [] + }; +} diff --git a/src/frontend/src/pages/Index/Settings/AdminCenter/HomePanel.tsx b/src/frontend/src/pages/Index/Settings/AdminCenter/HomePanel.tsx index 8b70200b9e..1004d01ca2 100644 --- a/src/frontend/src/pages/Index/Settings/AdminCenter/HomePanel.tsx +++ b/src/frontend/src/pages/Index/Settings/AdminCenter/HomePanel.tsx @@ -1,43 +1,146 @@ import { StylishText } from '@lib/components/StylishText'; import { t } from '@lingui/core/macro'; import { Trans } from '@lingui/react/macro'; -import { Accordion, SimpleGrid, Stack } from '@mantine/core'; -import { type JSX, useMemo } from 'react'; -import { useShallow } from 'zustand/react/shallow'; import { - type ExtendedAlertInfo, - ServerAlert, - getAlerts -} from '../../../../components/nav/Alerts'; + Accordion, + Alert, + Badge, + Chip, + Divider, + Group, + Loader, + SimpleGrid, + Stack, + Text +} from '@mantine/core'; +import { useLocalStorage } from '@mantine/hooks'; +import { type JSX, useMemo } from 'react'; +import { ServerAlert } from '../../../../components/nav/Alerts'; import { QuickAction } from '../../../../components/settings/QuickAction'; -import { useServerApiState } from '../../../../states/ServerApiState'; -import { useGlobalSettingsState } from '../../../../states/SettingsStates'; +import { + ADMIN_MESSAGE_CLASSIFIERS, + type AdminMessageClassifier, + classifierColor, + classifierLabel, + useAdminMessages +} from '../../../../hooks/UseAdminMessages'; -function rankAlert(alert: ExtendedAlertInfo): number { - if (!alert.condition) return 0; - if (alert.error) return 2; +// Passing checks ('success') are hidden by default, to highlight actionable messages +const DEFAULT_VISIBLE_CLASSIFIERS: AdminMessageClassifier[] = + ADMIN_MESSAGE_CLASSIFIERS.filter((c) => c !== 'success'); - return 1; +/** + * Selector allowing the user to toggle display of each message classifier. + */ +function ClassifierSelector({ + selected, + onChange, + counts +}: Readonly<{ + selected: AdminMessageClassifier[]; + onChange: (value: AdminMessageClassifier[]) => void; + counts: Record; +}>): JSX.Element { + return ( + onChange(value as AdminMessageClassifier[])} + > + + {ADMIN_MESSAGE_CLASSIFIERS.map((classifier) => ( + + {classifierLabel(classifier)} + 0 ? classifierColor(classifier) : 'gray' + } + variant={counts[classifier] > 0 ? 'white' : 'light'} + aria-label={`admin-message-count-${classifier}`} + > + {counts[classifier]} + + + ))} + + + ); +} + +function SystemMessages(): JSX.Element { + const { messages, isFetching, isError } = useAdminMessages(); + + const [selected, setSelected] = useLocalStorage({ + key: 'admin-center-message-classifiers', + defaultValue: DEFAULT_VISIBLE_CLASSIFIERS + }); + + const counts = useMemo(() => { + const result = Object.fromEntries( + ADMIN_MESSAGE_CLASSIFIERS.map((c) => [c, 0]) + ) as Record; + + messages.forEach((msg) => { + if (msg.classifier in result) result[msg.classifier] += 1; + }); + + return result; + }, [messages]); + + // Server returns messages sorted by severity + const visible = useMemo( + () => messages.filter((msg) => selected.includes(msg.classifier)), + [messages, selected] + ); + + return ( + + + {isFetching && } + + + + {isError && ( + + {t`Failed to load system messages`} + + )} + {!isError && !isFetching && visible.length === 0 && ( + + {t`No messages to display`} + + )} + + {visible.map((msg) => ( + + ))} + + + ); } export default function HomePanel(): JSX.Element { - const [server] = useServerApiState(useShallow((state) => [state.server])); - const globalSettings = useGlobalSettingsState(); - - const accElements = useMemo(() => { - const _alerts = getAlerts(server, globalSettings, true).sort( - (a, b) => rankAlert(b) - rankAlert(a) - ); - - return [ - { - key: 'system-status', - text: t`System Status`, - elements: _alerts - } - ]; - }, [server, globalSettings]); - return ( - {accElements.map( - (item) => - item.elements.length > 0 && ( - - - {item.text} - - - - {item.elements.map((alert) => ( - - ))} - - - - ) - )} + + + + System Status + + + + + + ); diff --git a/src/frontend/tests/pui_settings.spec.ts b/src/frontend/tests/pui_settings.spec.ts index a6529a4de0..1df84fdfdc 100644 --- a/src/frontend/tests/pui_settings.spec.ts +++ b/src/frontend/tests/pui_settings.spec.ts @@ -350,6 +350,83 @@ test('Settings - Admin', async ({ browser }) => { await page.getByRole('button', { name: 'Submit' }).click(); }); +test('Settings - Admin - System Status', async ({ browser }) => { + const page = await doCachedLogin(browser, { + user: adminuser, + url: 'settings/admin/home' + }); + + // Messages are loaded from the server (/api/system/messages/ endpoint) + await page.getByText('System Status').waitFor(); + await page.getByLabel('admin-message-classifier-selector').waitFor(); + const messages = page.getByLabel('admin-message-list').getByRole('alert'); + + await messages.filter({ hasText: 'Basic Authentication' }).waitFor(); + + const classifiers = ['error', 'warning', 'info', 'success']; + + // Each classifier chip displays its message count as a badge + for (const c of classifiers) { + await expect(page.getByLabel(`admin-message-count-${c}`)).toHaveText( + /^\d+$/ + ); + } + // Toggle a classifier chip (the native checkbox input is visually hidden) + const toggle = async (c: string, checked: boolean) => { + const chip = page.getByLabel(`admin-message-classifier-${c}`); + await chip.dispatchEvent('click'); + if (checked) await expect(chip).toBeChecked(); + else await expect(chip).not.toBeChecked(); + }; + + const count = async (c: string) => + Number(await page.getByLabel(`admin-message-count-${c}`).textContent()); + + // Success messages are hidden by default + await expect( + page.getByLabel('admin-message-classifier-success') + ).not.toBeChecked(); + await expect(messages.filter({ hasText: 'No issues detected' })).toHaveCount( + 0 + ); + + const actionable = await messages.count(); + const successCount = await count('success'); + const total = actionable + successCount; + expect(total).toBeGreaterThanOrEqual(7); + + // Show success messages + await toggle('success', true); + await expect(messages).toHaveCount(total); + + // Hide all classifiers + for (const c of classifiers) { + await toggle(c, false); + } + await page.getByText('No messages to display').waitFor(); + await expect(messages).toHaveCount(0); + + // Restore the default selection + for (const c of ['error', 'warning', 'info']) { + await toggle(c, true); + } + await expect(messages).toHaveCount(actionable); +}); + +test('Settings - Admin - Alerts View All', async ({ browser }) => { + const page = await doCachedLogin(browser, { user: adminuser }); + + // Navbar alerts dropdown links through to the admin center + await page.getByLabel('open-alerts').click(); + + const viewAll = page.getByLabel('alerts-view-all'); + await expect(viewAll).toHaveAttribute('href', /\/settings\/admin\/home$/); + await viewAll.click(); + + await page.waitForURL('**/settings/admin/home**'); + await page.getByLabel('admin-message-classifier-selector').waitFor(); +}); + test('Settings - Admin - Background Tasks', async ({ browser }) => { const page = await doCachedLogin(browser, { user: adminuser,