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 <code@mjmair.com>
This commit is contained in:
OliverandMatthias Mair authored and GitHub committed 2026-11-12 11:14:01 +11:00
1 parent a0ec8e4c71
commit af5055b33e
11 files changed
+742 -157

No files matched your search

@@ -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
@@ -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. '<slug>.<key>') 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])
+29 -1
View File
@@ -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
@@ -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."""
+117
View File
@@ -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')
+2
View File
@@ -30,6 +30,8 @@ dist-ssr
/.nyc_output/
/coverage/
/flakiness-report/
/report/
/blob-report/
# Report generation
stats.html
+1
View File
@@ -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/',
+69 -100
View File
@@ -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<string[]>([]);
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 (
<Menu withinPortal={true} position='bottom-end'>
<Menu.Target>
@@ -70,21 +81,47 @@ export function Alerts() {
<ServerAlert alert={alert} closeAlert={closeAlert} />
</Menu.Item>
))}
<Menu.Divider />
<Menu.Item
component={Link}
to='/settings/admin/home'
aria-label='alerts-view-all'
leftSection={<IconListDetails size={16} />}
rightSection={<IconArrowRight size={16} />}
>
<Text size='md'>{t`View all messages`}</Text>
</Menu.Item>
</Menu.Dropdown>
</Menu>
);
return null;
}
function classifierIcon(classifier: AdminMessageClassifier) {
switch (classifier) {
case 'error':
return <IconExclamationCircle />;
case 'warning':
return <IconAlertTriangle />;
case 'info':
return <IconInfoCircle />;
case 'success':
default:
return <IconCircleCheck />;
}
}
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 (
<Alert
withCloseButton={!!closeAlert}
color={alert.condition ? (alert.error ? 'red' : 'orange') : 'green'}
icon={alert.condition ? <IconExclamationCircle /> : <IconCircleCheck />}
color={classifierColor(alert.classifier)}
icon={classifierIcon(alert.classifier)}
title={
<Group gap='xs'>
{alert.code && `${alert.code}: `}
@@ -94,81 +131,13 @@ export function ServerAlert({
onClose={closeAlert ? () => closeAlert(alert.key) : undefined}
>
<Stack gap='xs'>
{!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)}
</Stack>
</Alert>
);
}
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 (
<a
@@ -0,0 +1,94 @@
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { apiUrl } from '@lib/functions/Api';
import { t } from '@lingui/core/macro';
import { useQuery } from '@tanstack/react-query';
import { api } from '../App';
import { useUserState } from '../states/UserState';
export type AdminMessageClassifier = 'error' | 'warning' | 'info' | 'success';
/**
* A staff-only system message, as returned in the `messages` array of the /api/system/messages/ endpoint.
*/
export interface AdminMessage {
key: string;
title: string;
message: string;
classifier: AdminMessageClassifier;
code?: string | null;
plugin?: string | null;
}
/** Response format of the /api/system/messages/ endpoint */
export interface AdminMessageResponse {
messages: AdminMessage[];
}
/** All classifiers, ordered most severe first */
export const ADMIN_MESSAGE_CLASSIFIERS: AdminMessageClassifier[] = [
'error',
'warning',
'info',
'success'
];
/** Classifiers which indicate that the administrator should take action */
export const ACTIONABLE_CLASSIFIERS: AdminMessageClassifier[] = [
'error',
'warning'
];
export function classifierColor(classifier: AdminMessageClassifier): string {
switch (classifier) {
case 'error':
return 'red';
case 'warning':
return 'orange';
case 'info':
return 'blue';
case 'success':
default:
return 'green';
}
}
export function classifierLabel(classifier: AdminMessageClassifier): string {
switch (classifier) {
case 'error':
return t`Error`;
case 'warning':
return t`Warning`;
case 'info':
return t`Info`;
case 'success':
default:
return t`Success`;
}
}
/**
* Fetch the list of staff-only system messages from the server.
* The query is only enabled for staff users (the endpoint is staff-only),
* so `messages` is always empty for non-staff users.
*/
export function useAdminMessages() {
const user = useUserState();
const isStaff = user.isStaff();
const query = useQuery<AdminMessageResponse | null>({
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 ?? []) : []
};
}
@@ -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<AdminMessageClassifier, number>;
}>): JSX.Element {
return (
<Chip.Group
multiple
value={selected}
onChange={(value) => onChange(value as AdminMessageClassifier[])}
>
<Group gap='xs' aria-label='admin-message-classifier-selector'>
{ADMIN_MESSAGE_CLASSIFIERS.map((classifier) => (
<Chip
key={classifier}
value={classifier}
color={classifierColor(classifier)}
size='sm'
aria-label={`admin-message-classifier-${classifier}`}
>
{classifierLabel(classifier)}
<Badge
ml={6}
size='xs'
color={
counts[classifier] > 0 ? classifierColor(classifier) : 'gray'
}
variant={counts[classifier] > 0 ? 'white' : 'light'}
aria-label={`admin-message-count-${classifier}`}
>
{counts[classifier]}
</Badge>
</Chip>
))}
</Group>
</Chip.Group>
);
}
function SystemMessages(): JSX.Element {
const { messages, isFetching, isError } = useAdminMessages();
const [selected, setSelected] = useLocalStorage<AdminMessageClassifier[]>({
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<AdminMessageClassifier, number>;
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 (
<Stack gap='sm'>
<Group justify='flex-end' gap='sm'>
{isFetching && <Loader size='xs' />}
<ClassifierSelector
selected={selected}
onChange={setSelected}
counts={counts}
/>
</Group>
<Divider />
{isError && (
<Alert color='red' title={t`Error`}>
{t`Failed to load system messages`}
</Alert>
)}
{!isError && !isFetching && visible.length === 0 && (
<Text size='sm' c='dimmed'>
{t`No messages to display`}
</Text>
)}
<SimpleGrid
cols={{
base: 1,
'600px': 2,
'1200px': 3
}}
type='container'
spacing='sm'
aria-label='admin-message-list'
>
{visible.map((msg) => (
<ServerAlert key={msg.key} alert={msg} />
))}
</SimpleGrid>
</Stack>
);
}
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 (
<Stack gap='xs'>
<Accordion
@@ -55,31 +158,16 @@ export default function HomePanel(): JSX.Element {
<QuickAction />
</Accordion.Panel>
</Accordion.Item>
{accElements.map(
(item) =>
item.elements.length > 0 && (
<Accordion.Item key={item.key} value={item.key}>
<Accordion.Control>
<StylishText size='md'>{item.text}</StylishText>
</Accordion.Control>
<Accordion.Panel>
<SimpleGrid
cols={{
base: 1,
'600px': 2,
'1200px': 3
}}
type='container'
spacing='sm'
>
{item.elements.map((alert) => (
<ServerAlert alert={alert} />
))}
</SimpleGrid>
</Accordion.Panel>
</Accordion.Item>
)
)}
<Accordion.Item value='system-status'>
<Accordion.Control>
<StylishText size='md'>
<Trans>System Status</Trans>
</StylishText>
</Accordion.Control>
<Accordion.Panel>
<SystemMessages />
</Accordion.Panel>
</Accordion.Item>
</Accordion>
</Stack>
);
+77
View File
@@ -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,