fix(availability): translate status messages, return stored admin reason from API

- Add AvailabilityStatusDetails record and GetStatusDetailsAsync() to
  IAvailabilityService; implement in PersistentAvailabilityService so the
  admin-set reason stored in the database is returned alongside the status
- AvailabilityController.GetStatus() now returns the stored message instead
  of hardcoded English strings
- Add messageAvailable / messageMaintenance / messageUnavailable translation
  keys in en + nl so default messages are fully translatable
- AvailabilityStatusBadge: Available always shows the translated default;
  Maintenance and Unavailable show the custom admin reason when set,
  otherwise fall back to the translated default

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-06-22 14:53:11 +02:00
co-authored by Claude Haiku 4.5
parent d69512a449
commit d8b0484803
8 changed files with 100 additions and 26 deletions
@@ -34,7 +34,23 @@ describe('AvailabilityStatusBadge', () => {
expect(badge.querySelector('.bg-red-100')).toBeTruthy();
});
it('shows message subtitle when message is non-empty', () => {
it('Available always shows the default translated message, ignoring any backend message', () => {
renderWithProviders(<AvailabilityStatusBadge status="Available" message="ignored" />);
expect(screen.getByTestId('availability-message')).toHaveTextContent(
'System is running normally.',
);
});
it('Available shows the default translated message when no message is provided', () => {
renderWithProviders(<AvailabilityStatusBadge status="Available" />);
expect(screen.getByTestId('availability-message')).toHaveTextContent(
'System is running normally.',
);
});
it('Maintenance shows a custom reason when provided', () => {
renderWithProviders(
<AvailabilityStatusBadge status="Maintenance" message="Scheduled downtime until 18:00" />,
);
@@ -44,16 +60,30 @@ describe('AvailabilityStatusBadge', () => {
);
});
it('hides message element when message is empty string', () => {
renderWithProviders(<AvailabilityStatusBadge status="Available" message="" />);
it('Maintenance falls back to default translated message when no reason is provided', () => {
renderWithProviders(<AvailabilityStatusBadge status="Maintenance" />);
expect(screen.queryByTestId('availability-message')).toBeNull();
expect(screen.getByTestId('availability-message')).toHaveTextContent(
'System is undergoing scheduled maintenance.',
);
});
it('hides message element when message prop is omitted', () => {
renderWithProviders(<AvailabilityStatusBadge status="Available" />);
it('Unavailable shows a custom reason when provided', () => {
renderWithProviders(
<AvailabilityStatusBadge status="Unavailable" message="Emergency shutdown in progress" />,
);
expect(screen.queryByTestId('availability-message')).toBeNull();
expect(screen.getByTestId('availability-message')).toHaveTextContent(
'Emergency shutdown in progress',
);
});
it('Unavailable falls back to default translated message when no reason is provided', () => {
renderWithProviders(<AvailabilityStatusBadge status="Unavailable" />);
expect(screen.getByTestId('availability-message')).toHaveTextContent(
'System is currently unavailable.',
);
});
it('shows stale indicator when stale={true}', () => {
@@ -10,24 +10,34 @@ interface AvailabilityStatusBadgeProps {
const STATUS_CONFIG: Record<
AvailabilityStatus,
{ labelKey: string; colorClass: string; Icon: React.ComponentType<{ className?: string }> }
{
labelKey: string;
defaultMessageKey: string;
/** When true the default translated message is always shown, ignoring any backend message. */
alwaysDefault: boolean;
colorClass: string;
Icon: React.ComponentType<{ className?: string }>;
}
> = {
Available: {
labelKey: 'availability.available',
colorClass:
'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
defaultMessageKey: 'availability.messageAvailable',
alwaysDefault: true,
colorClass: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
Icon: CheckCircle,
},
Maintenance: {
labelKey: 'availability.maintenance',
colorClass:
'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200',
defaultMessageKey: 'availability.messageMaintenance',
alwaysDefault: false,
colorClass: 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200',
Icon: AlertTriangle,
},
Unavailable: {
labelKey: 'availability.unavailable',
colorClass:
'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
defaultMessageKey: 'availability.messageUnavailable',
alwaysDefault: false,
colorClass: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
Icon: XCircle,
},
};
@@ -38,7 +48,8 @@ export function AvailabilityStatusBadge({
stale = false,
}: AvailabilityStatusBadgeProps) {
const { t } = useTranslation();
const { labelKey, colorClass, Icon } = STATUS_CONFIG[status];
const { labelKey, defaultMessageKey, alwaysDefault, colorClass, Icon } = STATUS_CONFIG[status];
const displayMessage = alwaysDefault ? t(defaultMessageKey) : (message || t(defaultMessageKey));
return (
<div
@@ -50,14 +61,12 @@ export function AvailabilityStatusBadge({
<span data-testid="availability-status-label">{t(labelKey)}</span>
</div>
{message && message.length > 0 && (
<p
data-testid="availability-message"
className="mt-2 text-sm text-muted-foreground"
>
{message}
{displayMessage}
</p>
)}
{stale && (
<div
@@ -66,6 +66,9 @@
"available": "Available",
"maintenance": "Maintenance",
"unavailable": "Unavailable",
"messageAvailable": "System is running normally.",
"messageMaintenance": "System is undergoing scheduled maintenance.",
"messageUnavailable": "System is currently unavailable.",
"errorTitle": "Could not load status",
"staleLabel": "Last known status",
"retry": "Retry"
@@ -66,6 +66,9 @@
"available": "Beschikbaar",
"maintenance": "Onderhoud",
"unavailable": "Niet beschikbaar",
"messageAvailable": "Systeem werkt normaal.",
"messageMaintenance": "Het systeem ondergaat gepland onderhoud.",
"messageUnavailable": "Het systeem is momenteel niet beschikbaar.",
"errorTitle": "Status kon niet worden geladen",
"staleLabel": "Laatste bekende status",
"retry": "Opnieuw proberen"
@@ -0,0 +1,6 @@
namespace SlpModularCms.Core.Availability;
/// <summary>
/// Status en optionele admin-melding van de systeembeschikbaarheid.
/// </summary>
public record AvailabilityStatusDetails(AvailabilityStatus Status, string? Message);
@@ -10,4 +10,9 @@ public interface IAvailabilityService
/// </summary>
/// <returns>De huidige <see cref="AvailabilityStatus"/>.</returns>
Task<AvailabilityStatus> IsAvailableAsync();
/// <summary>
/// Geeft de huidige status inclusief de optionele admin-melding terug.
/// </summary>
Task<AvailabilityStatusDetails> GetStatusDetailsAsync();
}
@@ -20,12 +20,12 @@ public class AvailabilityController : ControllerBase
[AllowAnonymous]
public async Task<IActionResult> GetStatus()
{
var status = await _availabilityService.IsAvailableAsync();
var details = await _availabilityService.GetStatusDetailsAsync();
return Ok(new
{
Status = status.ToString(),
Status = details.Status.ToString(),
CheckedAt = DateTimeOffset.UtcNow,
Message = string.Empty,
Message = details.Message ?? string.Empty,
});
}
@@ -54,6 +54,24 @@ public class PersistentAvailabilityService : IAvailabilityService
}
}
public async Task<AvailabilityStatusDetails> GetStatusDetailsAsync()
{
try
{
var state = await _context.AvailabilityStates.FirstOrDefaultAsync();
if (state == null)
return new AvailabilityStatusDetails(AvailabilityStatus.Available, null);
return new AvailabilityStatusDetails(state.Status, state.Message);
}
catch (Exception)
{
_lastErrorTime = DateTimeOffset.UtcNow;
_cachedStatus = AvailabilityStatus.Available;
return new AvailabilityStatusDetails(_cachedStatus, null);
}
}
/// <summary>
/// Update de globale systeemstatus.
/// </summary>