From ab5c72030a8f0a0d974b6eb05855a4c8fcdfafa5 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Fri, 19 Jun 2026 16:21:46 -0400 Subject: [PATCH 01/37] feat(notifications): persistent ("sticky until resolved") notifications MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a persistent notification capability so condition-style alerts (reboot required, boot device corrupt, etc.) can be surfaced in the notification bell and stay until their condition resolves — replacing the legacy webgui floating yellow banner. API: - Notification/NotificationData gain `persistent` (Boolean) and `key` (stable producer key). Severity stays INFO/WARNING/ALERT (ALERT already drives the red bell), so persistence is orthogonal to severity. - .notify file format + service read/write the new fields. - Keyed creates are idempotent (raising with an existing key replaces it), and `clearNotificationByKey(key)` resolves a condition (removes matching unread). - Persistent notifications are not user-archivable: archiveNotification rejects them and bulk archiveAll skips them. Web: - NotificationFragment exposes `persistent`; the bell Item and the legacy-embedded CriticalNotifications hide the dismiss/archive control for persistent alerts and show "Clears automatically when resolved". - Regenerated GraphQL schema + web codegen. Part of OS-471. Pairs with the webgui PR that routes the yellow-banner conditions through persistent notifications. Co-Authored-By: Claude Opus 4.8 --- api/generated-schema.graphql | 23 ++++++ api/src/core/types/states/notification.ts | 4 ++ .../notifications/notifications.model.ts | 36 +++++++++- .../notifications/notifications.resolver.ts | 11 +++ .../notifications/notifications.service.ts | 72 +++++++++++++++++-- web/__test__/store/notifications.test.ts | 2 + .../CriticalNotifications.standalone.vue | 8 +++ web/src/components/Notifications/Item.vue | 2 +- .../graphql/notification.query.ts | 1 + web/src/composables/gql/gql.ts | 6 +- web/src/composables/gql/graphql.ts | 31 +++++--- 11 files changed, 178 insertions(+), 18 deletions(-) diff --git a/api/generated-schema.graphql b/api/generated-schema.graphql index c697f98867..d8e10029dd 100644 --- a/api/generated-schema.graphql +++ b/api/generated-schema.graphql @@ -2149,6 +2149,14 @@ type Notification implements Node { description: String! importance: NotificationImportance! link: String + + """Stable key for condition-style notifications (idempotent raise / clear-by-key).""" + key: String + + """ + Whether this notification persists until its condition is resolved. Persistent notifications are not user-archivable. + """ + persistent: Boolean! type: NotificationType! """ISO Timestamp for when the notification occurred""" @@ -3401,6 +3409,11 @@ type Mutation { notifyIfUnique(input: NotificationData!): Notification archiveAll(importance: NotificationImportance): NotificationOverview! + """ + Clears all unread notifications that share a stable producer key. Used to resolve condition-style (persistent) notifications when their condition no longer holds. + """ + clearNotificationByKey(key: String!): NotificationOverview! + """Marks a notification as unread.""" unreadNotification(id: PrefixedID!): Notification! unarchiveNotifications(ids: [PrefixedID!]!): NotificationOverview! @@ -3468,6 +3481,16 @@ input NotificationData { description: String! importance: NotificationImportance! link: String + + """ + Stable key for a condition-style notification. Raising again with the same key replaces the existing one; clear it with clearNotificationByKey when the condition resolves. + """ + key: String + + """ + Persistent notifications cannot be archived by the user; they stay until cleared programmatically (typically via their key) when the underlying condition resolves. + """ + persistent: Boolean = false } input UpdateSshInput { diff --git a/api/src/core/types/states/notification.ts b/api/src/core/types/states/notification.ts index b773ec31b7..7091750e21 100644 --- a/api/src/core/types/states/notification.ts +++ b/api/src/core/types/states/notification.ts @@ -5,4 +5,8 @@ export interface NotificationIni { description: string; importance: 'normal' | 'alert' | 'warning'; link?: string; + /** Stable producer key for condition-style notifications (idempotent raise / clear-by-key). */ + key?: string; + /** 'true' when the notification is persistent (not user-archivable). Stored as a string in the ini file. */ + persistent?: string; } diff --git a/api/src/unraid-api/graph/resolvers/notifications/notifications.model.ts b/api/src/unraid-api/graph/resolvers/notifications/notifications.model.ts index 64dd0893a1..c95923b5ad 100644 --- a/api/src/unraid-api/graph/resolvers/notifications/notifications.model.ts +++ b/api/src/unraid-api/graph/resolvers/notifications/notifications.model.ts @@ -1,7 +1,7 @@ import { Field, InputType, Int, ObjectType, registerEnumType } from '@nestjs/graphql'; import { Node } from '@unraid/shared/graphql.model.js'; -import { IsEnum, IsInt, IsNotEmpty, IsOptional, IsString, Min } from 'class-validator'; +import { IsBoolean, IsEnum, IsInt, IsNotEmpty, IsOptional, IsString, Min } from 'class-validator'; export enum NotificationType { UNREAD = 'UNREAD', @@ -74,6 +74,25 @@ export class NotificationData { @IsString() @IsOptional() link?: string; + + @Field({ + nullable: true, + description: + 'Stable key for a condition-style notification. Raising again with the same key replaces the existing one; clear it with clearNotificationByKey when the condition resolves.', + }) + @IsString() + @IsOptional() + key?: string; + + @Field({ + nullable: true, + defaultValue: false, + description: + 'Persistent notifications cannot be archived by the user; they stay until cleared programmatically (typically via their key) when the underlying condition resolves.', + }) + @IsBoolean() + @IsOptional() + persistent?: boolean; } @ObjectType('NotificationCounts') @@ -137,6 +156,21 @@ export class Notification extends Node { @IsOptional() link?: string; + @Field({ + nullable: true, + description: 'Stable key for condition-style notifications (idempotent raise / clear-by-key).', + }) + @IsString() + @IsOptional() + key?: string; + + @Field({ + description: + 'Whether this notification persists until its condition is resolved. Persistent notifications are not user-archivable.', + }) + @IsBoolean() + persistent!: boolean; + @Field(() => NotificationType) @IsEnum(NotificationType) @IsNotEmpty() diff --git a/api/src/unraid-api/graph/resolvers/notifications/notifications.resolver.ts b/api/src/unraid-api/graph/resolvers/notifications/notifications.resolver.ts index d3e0c6797b..5a286c1774 100644 --- a/api/src/unraid-api/graph/resolvers/notifications/notifications.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/notifications/notifications.resolver.ts @@ -124,6 +124,17 @@ export class NotificationsResolver { return overview; } + @Mutation(() => NotificationOverview, { + description: + 'Clears all unread notifications that share a stable producer key. Used to resolve condition-style (persistent) notifications when their condition no longer holds.', + }) + public clearNotificationByKey( + @Args('key', { type: () => String }) + key: string + ): Promise { + return this.notificationsService.clearNotificationsByKey(key); + } + @Mutation(() => Notification, { description: 'Marks a notification as unread.' }) public unreadNotification( @Args('id', { type: () => PrefixedID }) diff --git a/api/src/unraid-api/graph/resolvers/notifications/notifications.service.ts b/api/src/unraid-api/graph/resolvers/notifications/notifications.service.ts index 7a32c7b15f..a56affdb8f 100644 --- a/api/src/unraid-api/graph/resolvers/notifications/notifications.service.ts +++ b/api/src/unraid-api/graph/resolvers/notifications/notifications.service.ts @@ -300,6 +300,12 @@ export class NotificationsService { *------------------------------------------------------------------------**/ public async createNotification(data: NotificationData): Promise { + // Condition-style notifications are keyed and idempotent: raising one with an + // existing key replaces the prior instance (latest wins) instead of stacking dupes. + if (data.key) { + await this.clearNotificationsByKey(data.key); + } + const id: string = await this.makeNotificationId(data.title); const fileData = this.makeNotificationFileData(data); @@ -332,7 +338,7 @@ export class NotificationsService { * @returns A 2-element tuple containing the legacy notifier command and arguments. */ public getLegacyScriptArgs(notification: NotificationIni): [string, string[]] { - const { event, subject, description, link, importance } = notification; + const { event, subject, description, link, importance, key, persistent } = notification; const args = [ ['-i', importance], ['-e', event], @@ -342,6 +348,12 @@ export class NotificationsService { if (link) { args.push(['-l', link]); } + if (key) { + args.push(['-k', key]); + } + if (persistent === 'true') { + args.push(['-p']); + } return ['/usr/local/emhttp/webGui/scripts/notify', args.flat()]; } @@ -370,7 +382,7 @@ export class NotificationsService { /** transforms gql compliant NotificationData to .notify compliant data*/ private makeNotificationFileData(notification: NotificationData): NotificationIni { - const { title, subject, description, link, importance } = notification; + const { title, subject, description, link, importance, key, persistent } = notification; const data: NotificationIni = { timestamp: unraidTimestamp().toString(), @@ -382,11 +394,17 @@ export class NotificationsService { // HACK - the ini encoder stringifies all fields defined on the object, even if they're undefined. // this results in a field like "link=undefined" in the resulting ini string. - // So, we only add a link if it's defined + // So, we only add optional fields if they're defined/truthy. if (link) { data.link = link; } + if (key) { + data.key = key; + } + if (persistent) { + data.persistent = 'true'; + } return data; } @@ -447,6 +465,29 @@ export class NotificationsService { return this.getOverview(); } + /** + * Clears all unread notifications that share a stable producer `key`. + * + * This is how condition-style (typically persistent) notifications are resolved: a + * producer raises one with a key (e.g. "reboot-required") and clears it with the same + * key once the condition no longer holds. Also used to make keyed creates idempotent. + * + * @param key The stable producer key to clear. + * @returns The updated notification overview. + */ + public async clearNotificationsByKey(key: string): Promise { + const unread = await this.getNotifications({ + type: NotificationType.UNREAD, + offset: 0, + limit: Number.MAX_SAFE_INTEGER, + }); + const matches = unread.filter((notification) => notification.key === key); + for (const notification of matches) { + await this.deleteNotification({ id: notification.id, type: NotificationType.UNREAD }); + } + return this.getOverview(); + } + /**------------------------------------------------------------------------ * CRUD: Updating Notifications *------------------------------------------------------------------------**/ @@ -538,6 +579,16 @@ export class NotificationsService { *------------------------**/ const snapshot = this.getOverview(); const notification = await this.loadNotificationFile(unreadPath, NotificationType.UNREAD); + + // Persistent notifications represent an ongoing condition and are not user-dismissible; + // they clear automatically when the condition resolves (see clearNotificationsByKey). + if (notification.persistent) { + throw new AppError( + 'Cannot archive a persistent notification; it clears automatically when its condition resolves.', + 409 + ); + } + const moveToArchive = this.moveNotification({ from: NotificationType.UNREAD, to: NotificationType.ARCHIVE, @@ -589,7 +640,9 @@ export class NotificationsService { const overviewSnapshot = this.getOverview(); const unreads = await this.listFilesInFolder(UNREAD); - const [notifications] = await this.loadNotificationsFromPaths(unreads, { importance }); + const [loaded] = await this.loadNotificationsFromPaths(unreads, { importance }); + // Never bulk-archive persistent notifications — they clear on resolution, not by the user. + const notifications = loaded.filter((notification) => !notification.persistent); const archive = this.moveNotification({ from: NotificationType.UNREAD, to: NotificationType.ARCHIVE, @@ -883,6 +936,7 @@ export class NotificationsService { subject: nameMask, description: `This notification is invalid and cannot be displayed! For details, see the logs and the notification file at ${path}`, importance: NotificationImportance.WARNING, + persistent: false, timestamp: dateMask.toISOString(), formattedTimestamp: this.formatDatetime(dateMask), }; @@ -908,7 +962,14 @@ export class NotificationsService { details: Pick, fileData: NotificationIni ): Notification { - const { importance, timestamp, event: title, description = '', ...passthroughData } = fileData; + const { + importance, + timestamp, + event: title, + description = '', + persistent, + ...passthroughData + } = fileData; const { type, id } = details; return { ...passthroughData, @@ -917,6 +978,7 @@ export class NotificationsService { title, description, importance: this.fileImportanceToGqlImportance(importance), + persistent: persistent === 'true', timestamp: this.parseNotificationDateToIsoDate(timestamp)?.toISOString(), formattedTimestamp: this.formatTimestamp(timestamp), }; diff --git a/web/__test__/store/notifications.test.ts b/web/__test__/store/notifications.test.ts index df94a4847a..f6aea0140c 100644 --- a/web/__test__/store/notifications.test.ts +++ b/web/__test__/store/notifications.test.ts @@ -70,6 +70,7 @@ describe('Notifications Store', () => { subject: 'Test Subject 1', description: 'This is a test notification 1', importance: 'NORMAL' as NotificationImportance, + persistent: false, type: 'SYSTEM' as NotificationType, timestamp: '2023-01-01T12:00:00Z', formattedTimestamp: 'Jan 1, 2023', @@ -81,6 +82,7 @@ describe('Notifications Store', () => { subject: 'Test Subject 2', description: 'This is a test notification 2', importance: 'HIGH' as NotificationImportance, + persistent: false, type: 'UPDATE' as NotificationType, timestamp: '2023-01-02T12:00:00Z', formattedTimestamp: 'Jan 2, 2023', diff --git a/web/src/components/Notifications/CriticalNotifications.standalone.vue b/web/src/components/Notifications/CriticalNotifications.standalone.vue index 304ad430d0..cd1c173b78 100644 --- a/web/src/components/Notifications/CriticalNotifications.standalone.vue +++ b/web/src/components/Notifications/CriticalNotifications.standalone.vue @@ -256,6 +256,7 @@ onNotificationAdded(({ data }) => { View Details + + Clears automatically when resolved + diff --git a/web/src/components/Notifications/Item.vue b/web/src/components/Notifications/Item.vue index ede4b1afe3..a47369582d 100644 --- a/web/src/components/Notifications/Item.vue +++ b/web/src/components/Notifications/Item.vue @@ -134,7 +134,7 @@ const reformattedTimestamp = computed(() => { {{ t('notifications.item.viewLink') }}