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(); 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( renderWithProviders(
<AvailabilityStatusBadge status="Maintenance" message="Scheduled downtime until 18:00" />, <AvailabilityStatusBadge status="Maintenance" message="Scheduled downtime until 18:00" />,
); );
@@ -44,16 +60,30 @@ describe('AvailabilityStatusBadge', () => {
); );
}); });
it('hides message element when message is empty string', () => { it('Maintenance falls back to default translated message when no reason is provided', () => {
renderWithProviders(<AvailabilityStatusBadge status="Available" message="" />); 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', () => { it('Unavailable shows a custom reason when provided', () => {
renderWithProviders(<AvailabilityStatusBadge status="Available" />); 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}', () => { it('shows stale indicator when stale={true}', () => {
@@ -10,24 +10,34 @@ interface AvailabilityStatusBadgeProps {
const STATUS_CONFIG: Record< const STATUS_CONFIG: Record<
AvailabilityStatus, 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: { Available: {
labelKey: 'availability.available', labelKey: 'availability.available',
colorClass: defaultMessageKey: 'availability.messageAvailable',
'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200', alwaysDefault: true,
colorClass: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
Icon: CheckCircle, Icon: CheckCircle,
}, },
Maintenance: { Maintenance: {
labelKey: 'availability.maintenance', labelKey: 'availability.maintenance',
colorClass: defaultMessageKey: 'availability.messageMaintenance',
'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200', alwaysDefault: false,
colorClass: 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200',
Icon: AlertTriangle, Icon: AlertTriangle,
}, },
Unavailable: { Unavailable: {
labelKey: 'availability.unavailable', labelKey: 'availability.unavailable',
colorClass: defaultMessageKey: 'availability.messageUnavailable',
'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200', alwaysDefault: false,
colorClass: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
Icon: XCircle, Icon: XCircle,
}, },
}; };
@@ -38,7 +48,8 @@ export function AvailabilityStatusBadge({
stale = false, stale = false,
}: AvailabilityStatusBadgeProps) { }: AvailabilityStatusBadgeProps) {
const { t } = useTranslation(); 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 ( return (
<div <div
@@ -50,14 +61,12 @@ export function AvailabilityStatusBadge({
<span data-testid="availability-status-label">{t(labelKey)}</span> <span data-testid="availability-status-label">{t(labelKey)}</span>
</div> </div>
{message && message.length > 0 && (
<p <p
data-testid="availability-message" data-testid="availability-message"
className="mt-2 text-sm text-muted-foreground" className="mt-2 text-sm text-muted-foreground"
> >
{message} {displayMessage}
</p> </p>
)}
{stale && ( {stale && (
<div <div
@@ -66,6 +66,9 @@
"available": "Available", "available": "Available",
"maintenance": "Maintenance", "maintenance": "Maintenance",
"unavailable": "Unavailable", "unavailable": "Unavailable",
"messageAvailable": "System is running normally.",
"messageMaintenance": "System is undergoing scheduled maintenance.",
"messageUnavailable": "System is currently unavailable.",
"errorTitle": "Could not load status", "errorTitle": "Could not load status",
"staleLabel": "Last known status", "staleLabel": "Last known status",
"retry": "Retry" "retry": "Retry"
@@ -66,6 +66,9 @@
"available": "Beschikbaar", "available": "Beschikbaar",
"maintenance": "Onderhoud", "maintenance": "Onderhoud",
"unavailable": "Niet beschikbaar", "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", "errorTitle": "Status kon niet worden geladen",
"staleLabel": "Laatste bekende status", "staleLabel": "Laatste bekende status",
"retry": "Opnieuw proberen" "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> /// </summary>
/// <returns>De huidige <see cref="AvailabilityStatus"/>.</returns> /// <returns>De huidige <see cref="AvailabilityStatus"/>.</returns>
Task<AvailabilityStatus> IsAvailableAsync(); 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] [AllowAnonymous]
public async Task<IActionResult> GetStatus() public async Task<IActionResult> GetStatus()
{ {
var status = await _availabilityService.IsAvailableAsync(); var details = await _availabilityService.GetStatusDetailsAsync();
return Ok(new return Ok(new
{ {
Status = status.ToString(), Status = details.Status.ToString(),
CheckedAt = DateTimeOffset.UtcNow, 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> /// <summary>
/// Update de globale systeemstatus. /// Update de globale systeemstatus.
/// </summary> /// </summary>