mirror of
https://github.com/inventree/InvenTree.git
synced 2026-11-14 12:32:44 +00:00
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:
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])
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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')
|
||||
@@ -30,6 +30,8 @@ dist-ssr
|
||||
/.nyc_output/
|
||||
/coverage/
|
||||
/flakiness-report/
|
||||
/report/
|
||||
/blob-report/
|
||||
|
||||
# Report generation
|
||||
stats.html
|
||||
@@ -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/',
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user