diff --git a/openspec/changes/add-svelte-notifications-management/design.md b/openspec/changes/add-svelte-notifications-management/design.md new file mode 100644 index 0000000000..b61fa8f90f --- /dev/null +++ b/openspec/changes/add-svelte-notifications-management/design.md @@ -0,0 +1,98 @@ +# Design: Add Svelte Notifications Management + +## Backend + +### NotificationService extraction + +Extract shared notification logic from `StatusController` into a service to avoid duplication: + +```csharp +// src/Exceptionless.Core/Services/NotificationService.cs +public class NotificationService(ICacheClient cacheClient, IMessagePublisher messagePublisher, TimeProvider timeProvider) +{ + public Task GetSystemNotificationAsync(); + public Task SetSystemNotificationAsync(string message, bool publish = true); + public Task ClearSystemNotificationAsync(bool publish = true); + public Task SendReleaseNotificationAsync(string? message, bool critical); +} +``` + +- Cache key: `system-notification` (unchanged) +- Publish: via `IMessagePublisher` (unchanged) +- `StatusController` delegates to this service (behavior unchanged) + +### Admin endpoints + +Added to `AdminController` (already `[Authorize(Policy = GlobalAdminPolicy)]`): + +| Method | Route | Body | Response | +|--------|-------|------|----------| +| GET | `admin/notifications` | — | `{ configured_message, system_notification }` | +| PUT | `admin/notifications/system` | `{ message }` | `SystemNotification` | +| DELETE | `admin/notifications/system?publish=true` | — | 204 | +| POST | `admin/notifications/release` | `{ message, critical }` | `ReleaseNotification` | +| POST | `admin/notifications/force-refresh` | — | `ReleaseNotification` | + +DTOs in `src/Exceptionless.Web/Models/Admin/`: +- `NotificationSettingsResponse` — configured fallback + current persisted notification +- `SetSystemNotificationRequest` — message string +- `SendReleaseNotificationRequest` — message + critical flag + +### Authorization + +All admin endpoints require `GlobalAdminPolicy` (existing controller-level attribute). + +## Frontend (Svelte) + +### Feature module + +`src/Exceptionless.Web/ClientApp/src/lib/features/notifications/` + +- `models.ts` — Re-export websocket models + admin DTOs +- `api.svelte.ts` — TanStack Query wrappers using `useFetchClient` +- `components/notification-banners.svelte` — Banner rendering component + +### Notification banners + +Rendered in `src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte`. + +Behavior: +1. On mount: fetch `GET /api/v2/notifications/system` (existing StatusController endpoint) +2. Fallback: if no persisted notification, use `PUBLIC_SYSTEM_NOTIFICATION_MESSAGE` env var +3. Listen for `SystemNotification` and `ReleaseNotification` DOM CustomEvents (existing websocket dispatch) +4. System notification → destructive/danger Alert with `role="alert"` and `aria-live="assertive"` +5. Release notification → info Alert with `role="status"` and `aria-live="polite"` +6. Critical release → `window.location.reload()` immediately + +Security: Plain text only. No `{@html}`. If sanitization is needed later, use dompurify. + +### Admin page + +Route: `/system/notifications` +- Added to system nav in `routes.svelte.ts`, visible to global admins only +- Card layout showing current state +- Dialog-based actions: set notification, clear, send release, force refresh +- Uses shadcn-svelte Alert, Button, Dialog, Input/Textarea, Card +- Mutations use svelte-sonner for success/error toasts +- Invalidates queries on mutation success + +### Query keys + +```typescript +export const notificationKeys = { + settings: ['admin', 'notifications'] as const, + current: ['notifications', 'system'] as const, +}; +``` + +## Security + +- Admin endpoints already behind GlobalAdminPolicy +- No HTML injection: plain text rendering only +- No secrets exposed in admin response (only configured message text) + +## Accessibility + +- Banners use `role="alert"` / `role="status"` with appropriate `aria-live` +- Dismiss button (if added) is keyboard-accessible +- Color is not sole indicator (icon + text in banners) diff --git a/openspec/changes/add-svelte-notifications-management/proposal.md b/openspec/changes/add-svelte-notifications-management/proposal.md new file mode 100644 index 0000000000..33c7c4427d --- /dev/null +++ b/openspec/changes/add-svelte-notifications-management/proposal.md @@ -0,0 +1,29 @@ +# Proposal: Add Svelte Notifications Management + +## Summary + +Implement global notification support in the Svelte 5 UI matching the legacy Angular behavior: system notification banners, release notification banners, critical release force-refresh, configuration fallback messages, and a global-admin notification management page. + +Additionally, add dedicated admin endpoints under `/api/v2/admin/notifications` to provide a cleaner management interface while preserving the existing `StatusController` notification endpoints for backwards compatibility. + +## Classification + +- **Type:** Feature + UI migration +- **Affected areas:** Backend/API, Svelte UI, Redis (cache key `system-notification`), WebSocket messages, tests +- **OpenSpec justification:** New API endpoints, WebSocket message consumption in new UI, admin authorization, cross-cutting UI/API contract, Angular-to-Svelte behavior migration + +## Compatibility Risks + +| Risk | Mitigation | +|------|-----------| +| Existing `StatusController` notification endpoints | Preserved unchanged; new admin endpoints are additive | +| WebSocket message format (`SystemNotification`, `ReleaseNotification`) | Consumed as-is; no format changes | +| Redis cache key `system-notification` | Shared between StatusController and new AdminController endpoints via extracted service | +| Config key `PUBLIC_SYSTEM_NOTIFICATION_MESSAGE` | Read-only consumption; no changes to how it's set | +| SDK/client expectations | No SDK changes; WebSocket messages unchanged | + +## Rollback Plan + +- New admin endpoints are additive; removing them has no impact on existing clients. +- Frontend notification banners degrade gracefully (no banner shown if fetch fails). +- Admin page is behind global-admin nav guard; removing it is safe. diff --git a/openspec/changes/add-svelte-notifications-management/specs/notifications/spec.md b/openspec/changes/add-svelte-notifications-management/specs/notifications/spec.md new file mode 100644 index 0000000000..885edd5985 --- /dev/null +++ b/openspec/changes/add-svelte-notifications-management/specs/notifications/spec.md @@ -0,0 +1,165 @@ +# Spec: Notifications + +## ADDED: StatusController notification management endpoints + +### Requirement: Admin can read notification settings + +Given an authenticated global admin user +When they send `GET /api/v2/notifications/settings` +Then the response is 200 with a `NotificationSettingsResponse`: +- `configured_system_notification_message`: the `AppOptions.NotificationMessage` value (may be null) +- `system_notification`: the currently cached `SystemNotification` (may be null) + +### Requirement: Non-admin cannot access admin notification endpoints + +Given an authenticated non-admin user +When they send any request to `POST/DELETE /api/v2/notifications/*` or `GET /api/v2/notifications/settings` +Then the response is 403 Forbidden. + +### Requirement: Admin can set a system notification + +Given an authenticated global admin user +When they send `POST /api/v2/notifications/system` with body `{ "value": "Maintenance tonight" }` +Then: +- The system notification is stored in cache key `system-notification` +- A `SystemNotification` message is published to the message bus (when `publish=true`, the default) +- The response is 200 with the `SystemNotification` object + +#### Scenario: Empty message is rejected + +Given an authenticated global admin user +When they send `POST /api/v2/notifications/system` with body `{ "value": "" }` +Then the response is 400 Bad Request. + +#### Scenario: Publish suppression + +Given an authenticated global admin user +When they send `POST /api/v2/notifications/system?publish=false` with body `{ "value": "Silent" }` +Then: +- The system notification is stored in cache +- No message is published to the message bus +- The response is 200 with the `SystemNotification` object + +### Requirement: Admin can clear a system notification + +Given an authenticated global admin user +When they send `DELETE /api/v2/notifications/system` +Then: +- The `system-notification` cache key is removed +- A blank `SystemNotification` is published to the message bus (when `publish=true`, the default) +- The response is 200 + +#### Scenario: Clear without publish + +Given an authenticated global admin user +When they send `DELETE /api/v2/notifications/system?publish=false` +Then: +- The `system-notification` cache key is removed +- No message is published to the message bus +- The response is 200 + +### Requirement: Admin can send a release notification + +Given an authenticated global admin user +When they send `POST /api/v2/notifications/release` with body `{ "value": "v8.0 released" }` and optional `?critical=false` +Then: +- A `ReleaseNotification` is published to the message bus +- The response is 200 with the `ReleaseNotification` object + +### Requirement: Admin can force-refresh all clients + +Given an authenticated global admin user +When they send `POST /api/v2/notifications/force-refresh` with optional body `{ "value": "reason" }` +Then: +- A `ReleaseNotification` is published with `critical=true` and optional message +- The response is 200 with the `ReleaseNotification` object + +## ADDED: Svelte notification banners (Svelte UI only) + +### Requirement: System notification banner displays on page load + +Given a system notification is persisted in cache +When a user loads any authenticated page in the Svelte app +Then a destructive/danger banner is displayed at the top of the layout with the notification message. + +#### Scenario: Fallback to configured message + +Given no system notification is persisted in cache +And `PUBLIC_SYSTEM_NOTIFICATION_MESSAGE` is set to "Scheduled maintenance" +When a user loads any authenticated page +Then a destructive/danger banner displays "Scheduled maintenance". + +#### Scenario: No notification and no fallback + +Given no system notification is persisted in cache +And `PUBLIC_SYSTEM_NOTIFICATION_MESSAGE` is empty +When a user loads any authenticated page +Then no system notification banner is displayed. + +### Requirement: Realtime system notification updates via WebSocket + +Given a user has the Svelte app open +When a `SystemNotification` WebSocket message is received with a non-empty message +Then the system notification banner updates to show the new message. + +#### Scenario: Clear notification via WebSocket + +Given a user has the Svelte app open with a system notification banner visible +When a `SystemNotification` WebSocket message is received with an empty/null message +Then the system notification banner is hidden (falls back to env var if configured). + +### Requirement: Release notification banner displays on WebSocket message + +Given a user has the Svelte app open +When a `ReleaseNotification` WebSocket message is received with `critical=false` +Then an info banner is displayed with the release message. + +### Requirement: Critical release notification triggers page reload + +Given a user has the Svelte app open +When a `ReleaseNotification` WebSocket message is received with `critical=true` +Then `window.location.reload()` is called immediately. + +### Requirement: Notification banners use accessible markup + +Given a system notification banner is displayed +Then it has `role="alert"` and `aria-live="assertive"`. + +Given a release notification banner is displayed +Then it has `role="status"` and `aria-live="polite"`. + +### Requirement: No unsafe HTML rendering + +Given any notification message content +When it is rendered in the Svelte UI +Then it is rendered as plain text (no innerHTML or {@html}). + +## ADDED: Svelte admin notifications page (Svelte UI only) + +### Requirement: System → Notifications page exists for global admins + +Given an authenticated global admin user +When they navigate to `/system/notifications` +Then they see the notification management page with: +- Current configured fallback message (read-only) +- Current persisted system notification (if any) +- Actions: Set system notification, Clear, Send release notification, Force refresh + +### Requirement: Notifications page is hidden from non-admins + +Given an authenticated non-admin user +When they view the system navigation +Then the "Notifications" link is not visible. + +## HTTP test file updates + +### Requirement: tests/http files updated for notification endpoints + +Given the notification endpoints exist in StatusController +Then `tests/http/status.http` includes sample requests for: +- GET notifications/settings +- POST notifications/system +- DELETE notifications/system +- POST notifications/release +- POST notifications/force-refresh + diff --git a/openspec/changes/add-svelte-notifications-management/tasks.md b/openspec/changes/add-svelte-notifications-management/tasks.md new file mode 100644 index 0000000000..676a724c47 --- /dev/null +++ b/openspec/changes/add-svelte-notifications-management/tasks.md @@ -0,0 +1,66 @@ +# Tasks: Add Svelte Notifications Management + +## Backend + +- [x] **Task 1: Extract NotificationService** + - Created `src/Exceptionless.Core/Services/NotificationService.cs` + - Methods: `GetSystemNotificationAsync`, `SetSystemNotificationAsync`, `ClearSystemNotificationAsync`, `SendReleaseNotificationAsync` + - Registered as singleton in DI (`Bootstrapper.cs`) + - Refactored `StatusController` to delegate to `NotificationService` (no behavior change) + +- [x] **Task 2: Add new StatusController endpoints** + - `GET notifications/settings` — returns configured fallback + current notification (admin only) + - `POST notifications/force-refresh` — force reload all clients via critical release notification (admin only) + - Added `bool publish = true` query param to POST and DELETE system notification endpoints + - All endpoints use existing `ValueFromBody` pattern for backward compatibility + +- [x] **Task 3: Update HTTP test samples** + - Updated `tests/http/status.http` with force-refresh and settings endpoint samples + +- [x] **Task 4: Add backend integration tests** + - Added tests to existing `tests/Exceptionless.Tests/Controllers/StatusControllerTests.cs` + - Tests: settings (admin/non-admin), force-refresh (with message/without/non-admin), publish flag + - All tests follow AAA (Arrange/Act/Assert) pattern + +## Frontend — System Notification Banner + +- [x] **Task 5: Create system-notifications feature module** + - Created `src/Exceptionless.Web/ClientApp/src/lib/features/system-notifications/models.ts` + - Created `src/Exceptionless.Web/ClientApp/src/lib/features/system-notifications/api.svelte.ts` + - All API calls target StatusController routes (`notifications/*`) + +- [x] **Task 6: Create system-notification-banner component** + - Created `src/Exceptionless.Web/ClientApp/src/lib/features/system-notifications/components/system-notification-banner.svelte` + - Three-tier message resolution: realtime WebSocket → persisted API → env fallback + - System = destructive Alert with `role="alert"` `aria-live="assertive"` + - Release = info Alert with `role="status"` `aria-live="polite"` + - Critical release = `window.location.reload()` + +- [x] **Task 7: Integrate banner into app layout** + - Added `` to `(app)/+layout.svelte` + +- [x] **Task 8: Add unit tests** + - Created `system-notification-banner.test.ts` with 6 tests covering event handling and message resolution logic + +## Frontend — Admin Page + +- [x] **Task 9: Add System → Notifications route and nav entry** + - Created `src/Exceptionless.Web/ClientApp/src/routes/(app)/system/notifications/+page.svelte` + - Added Bell icon + Notifications nav entry in `routes.svelte.ts`, visible only to global admins + +- [x] **Task 10: Implement notifications admin page** + - Displays current state (configured fallback, persisted notification) + - Actions via dialogs: Set system notification, Clear/reset, Send release notification, Force refresh + - Uses shadcn-svelte components, svelte-sonner toasts, TanStack Query mutations + - Invalidates notification queries on success + +## Final Validation + +- [x] **Task 11: Full build and test validation** + - `dotnet build` ✓ + - `dotnet test -- --filter-class StatusControllerTests` → 19/19 pass ✓ + - `npm run check` (svelte-check) → 0 errors ✓ + - `npm run lint` → clean ✓ + - `npm run build` → success ✓ + - `npm run test:unit` → 249/249 pass ✓ + - UI dogfood via browser → all flows verified ✓ diff --git a/src/Exceptionless.Core/Bootstrapper.cs b/src/Exceptionless.Core/Bootstrapper.cs index bfd443ef16..751d8e0880 100644 --- a/src/Exceptionless.Core/Bootstrapper.cs +++ b/src/Exceptionless.Core/Bootstrapper.cs @@ -198,6 +198,7 @@ public static void RegisterServices(IServiceCollection services, AppOptions appO services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddStartupAction(); services.AddSingleton(); diff --git a/src/Exceptionless.Core/Models/Messaging/NotificationSettingsResponse.cs b/src/Exceptionless.Core/Models/Messaging/NotificationSettingsResponse.cs new file mode 100644 index 0000000000..ad7172a25c --- /dev/null +++ b/src/Exceptionless.Core/Models/Messaging/NotificationSettingsResponse.cs @@ -0,0 +1,7 @@ +namespace Exceptionless.Core.Messaging.Models; + +public record NotificationSettingsResponse +{ + public string? ConfiguredSystemNotificationMessage { get; init; } + public SystemNotification? SystemNotification { get; init; } +} diff --git a/src/Exceptionless.Core/Services/NotificationService.cs b/src/Exceptionless.Core/Services/NotificationService.cs new file mode 100644 index 0000000000..10b0f093b6 --- /dev/null +++ b/src/Exceptionless.Core/Services/NotificationService.cs @@ -0,0 +1,40 @@ +using Exceptionless.Core.Messaging.Models; +using Foundatio.Caching; +using Foundatio.Messaging; + +namespace Exceptionless.Core.Services; + +public class NotificationService(ICacheClient cacheClient, IMessagePublisher messagePublisher, TimeProvider timeProvider) +{ + private const string SystemNotificationCacheKey = "system-notification"; + + public async Task GetSystemNotificationAsync() + { + var result = await cacheClient.GetAsync(SystemNotificationCacheKey); + return result.HasValue ? result.Value : null; + } + + public async Task SetSystemNotificationAsync(string message, bool publish = true) + { + var notification = new SystemNotification { Date = timeProvider.GetUtcNow().UtcDateTime, Message = message }; + await cacheClient.SetAsync(SystemNotificationCacheKey, notification); + if (publish) + await messagePublisher.PublishAsync(notification); + return notification; + } + + public async Task ClearSystemNotificationAsync(bool publish = true) + { + await cacheClient.RemoveAsync(SystemNotificationCacheKey); + if (publish) + await messagePublisher.PublishAsync(new SystemNotification { Date = timeProvider.GetUtcNow().UtcDateTime }); + } + + public async Task SendReleaseNotificationAsync(string? message, bool critical, bool publish = true) + { + var notification = new ReleaseNotification { Critical = critical, Date = timeProvider.GetUtcNow().UtcDateTime, Message = message }; + if (publish) + await messagePublisher.PublishAsync(notification); + return notification; + } +} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/system-notifications/api.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/system-notifications/api.svelte.ts new file mode 100644 index 0000000000..724be62e69 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/system-notifications/api.svelte.ts @@ -0,0 +1,93 @@ +import type { ReleaseNotification, SystemNotification } from '$features/websockets/models'; + +import { type ProblemDetails, useFetchClient } from '@exceptionless/fetchclient'; +import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query'; + +import type { NotificationSettings } from './models'; + +export const queryKeys = { + current: ['notifications', 'system'] as const, + settings: ['notifications', 'settings'] as const +}; + +export function clearSystemNotificationMutation() { + const queryClient = useQueryClient(); + return createMutation(() => ({ + mutationFn: async (params: { publish?: boolean }) => { + const client = useFetchClient(); + const publish = params.publish !== false; + await client.delete(`notifications/system?publish=${publish}`); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.settings }); + queryClient.invalidateQueries({ queryKey: queryKeys.current }); + } + })); +} + +export function forceRefreshClientsMutation() { + return createMutation(() => ({ + mutationFn: async (params?: { message?: string }) => { + const client = useFetchClient(); + const response = params?.message + ? await client.postJSON('notifications/force-refresh', { value: params.message }) + : await client.postJSON('notifications/force-refresh'); + return response.data!; + } + })); +} + +export function getCurrentSystemNotificationQuery() { + return createQuery(() => ({ + queryFn: async ({ signal }: { signal: AbortSignal }) => { + const client = useFetchClient(); + const response = await client.getJSON('notifications/system', { signal }); + return response.data ?? null; + }, + queryKey: queryKeys.current, + staleTime: 30_000 + })); +} + +export function getNotificationSettingsQuery() { + return createQuery(() => ({ + queryFn: async ({ signal }: { signal: AbortSignal }) => { + const client = useFetchClient(); + const response = await client.getJSON('notifications/settings', { signal }); + return response.data!; + }, + queryKey: queryKeys.settings, + staleTime: 30_000 + })); +} + +export function sendReleaseNotificationMutation() { + return createMutation(() => ({ + mutationFn: async (params: { critical?: boolean; message?: string }) => { + const client = useFetchClient(); + const critical = params.critical ?? false; + const response = await client.postJSON(`notifications/release?critical=${critical}`, { + value: params.message ?? null + }); + return response.data!; + } + })); +} + +export function setSystemNotificationMutation() { + const queryClient = useQueryClient(); + return createMutation(() => ({ + mutationFn: async (params: { message: string; publish?: boolean }) => { + const client = useFetchClient(); + const publish = params.publish !== false; + const response = await client.postJSON(`notifications/system?publish=${publish}`, { + value: params.message + }); + return response.data!; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.settings }); + queryClient.invalidateQueries({ queryKey: queryKeys.current }); + } + })); +} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/system-notifications/components/system-notification-banner.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/system-notifications/components/system-notification-banner.svelte new file mode 100644 index 0000000000..ad5aac2e58 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/system-notifications/components/system-notification-banner.svelte @@ -0,0 +1,62 @@ + + +{#if displaySystemMessage} + + {#snippet icon()} + + {/snippet} + {displaySystemMessage} + +{/if} + +{#if releaseMessage} + + {#snippet icon()} + + {/snippet} + {releaseMessage} + +{/if} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/system-notifications/components/system-notification-banner.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/system-notifications/components/system-notification-banner.test.ts new file mode 100644 index 0000000000..3c6d7be114 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/system-notifications/components/system-notification-banner.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; + +import { resolveDisplayMessage } from '../resolve-message'; + +describe('resolveDisplayMessage', () => { + describe('when realtime message has not been received (undefined)', () => { + it('returns persisted message over fallback', () => { + expect(resolveDisplayMessage(undefined, 'Active outage', 'Scheduled maintenance')).toBe('Active outage'); + }); + + it('returns fallback when no persisted message', () => { + expect(resolveDisplayMessage(undefined, null, 'Scheduled maintenance')).toBe('Scheduled maintenance'); + }); + + it('returns null when no persisted or fallback message', () => { + expect(resolveDisplayMessage(undefined, null, null)).toBeNull(); + }); + }); + + describe('when realtime message is received (string)', () => { + it('returns the realtime message', () => { + expect(resolveDisplayMessage('Realtime alert', 'Old persisted', 'Fallback')).toBe('Realtime alert'); + }); + + it('ignores persisted message', () => { + expect(resolveDisplayMessage('New message', 'Stale persisted', null)).toBe('New message'); + }); + }); + + describe('when realtime message is cleared (null)', () => { + it('falls back to fallback message', () => { + expect(resolveDisplayMessage(null, 'Persisted', 'Fallback')).toBe('Fallback'); + }); + + it('returns null when no fallback configured', () => { + expect(resolveDisplayMessage(null, 'Persisted', null)).toBeNull(); + }); + }); + + describe('edge cases', () => { + it('empty string realtime falls through to fallback', () => { + expect(resolveDisplayMessage('', null, 'Fallback')).toBe('Fallback'); + }); + + it('empty string persisted falls through to fallback', () => { + expect(resolveDisplayMessage(undefined, '', 'Fallback')).toBe('Fallback'); + }); + + it('all empty/null returns null', () => { + expect(resolveDisplayMessage(undefined, null, null)).toBeNull(); + }); + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/system-notifications/models.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/system-notifications/models.ts new file mode 100644 index 0000000000..51c5f15097 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/system-notifications/models.ts @@ -0,0 +1,8 @@ +import type { ReleaseNotification, SystemNotification } from '$features/websockets/models'; + +export type { ReleaseNotification, SystemNotification }; + +export interface NotificationSettings { + configured_system_notification_message?: null | string; + system_notification?: null | SystemNotification; +} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/system-notifications/resolve-message.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/system-notifications/resolve-message.ts new file mode 100644 index 0000000000..72866458fa --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/system-notifications/resolve-message.ts @@ -0,0 +1,21 @@ +/** + * Three-tier system notification message resolution. + * + * Priority (highest to lowest): + * 1. realtimeMessage — from WebSocket (undefined = not yet received, null = explicitly cleared) + * 2. persistedMessage — from GET /notifications/system on mount + * 3. fallbackMessage — from PUBLIC_SYSTEM_NOTIFICATION_MESSAGE env var + * + * Empty strings are treated as absent (no notification to display). + */ +export function resolveDisplayMessage( + realtimeMessage: null | string | undefined, + persistedMessage: null | string, + fallbackMessage: null | string +): null | string { + if (realtimeMessage !== undefined) { + return realtimeMessage || fallbackMessage; + } + + return persistedMessage || fallbackMessage; +} diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte index a76a8df242..c710816cda 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte @@ -24,6 +24,7 @@ import { savedViewHref } from '$features/saved-views/slugs'; import { appKeyboardShortcuts, isKeyboardShortcut } from '$features/shared/keyboard-shortcuts'; import { invalidateStackQueries } from '$features/stacks/api.svelte'; + import SystemNotificationBanner from '$features/system-notifications/components/system-notification-banner.svelte'; import { invalidateTokenQueries } from '$features/tokens/api.svelte'; import { getMeQuery, invalidateUserQueries } from '$features/users/api.svelte'; import { getGravatarFromCurrentUser } from '$features/users/gravatar.svelte'; @@ -499,6 +500,8 @@ /> + + {#if showOrganizationNotifications.current} {/if} diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/system/notifications/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/system/notifications/+page.svelte new file mode 100644 index 0000000000..222107e443 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/system/notifications/+page.svelte @@ -0,0 +1,281 @@ + + +
+
+

Notifications

+

Manage system notifications, release announcements, and client refresh.

+
+ + + + Current Status + Active notification configuration and state. + + + {#if settingsQuery.isLoading} +

Loading...

+ {:else if settingsQuery.isError} +

Failed to load notification settings.

+ {:else if settingsQuery.data} +
+
+ Configured Fallback Message: + + {settingsQuery.data.configured_system_notification_message || '(none)'} + +
+
+ Active System Notification: + {#if settingsQuery.data.system_notification?.message} + + {settingsQuery.data.system_notification.message} + + + (set {new Date(settingsQuery.data.system_notification.date).toLocaleString()}) + + {:else} + (none) + {/if} +
+
+ {/if} +
+
+ +
+ + + + + Set System Notification + + Display a persistent notification banner to all users. + + + + + + + + + + + Clear System Notification + + Remove the current system notification banner. + + + + + + + + + + + Send Release Notification + + Send a one-time release announcement to all connected clients. + + + + + + + + + + + Force Refresh Clients + + Force all connected clients to reload. Use with caution. + + + + + +
+
+ + + + + + Set System Notification + This message will be displayed as a banner to all users. + +
+
+ +