From 31dd8dd5d4153616108d19fce3143bd3b184fc1d Mon Sep 17 00:00:00 2001 From: Pujit Mehrotra Date: Wed, 15 Oct 2025 12:10:08 -0400 Subject: [PATCH] feat: critical notifications --- api/generated-schema.graphql | 5 + .../resolvers/docker/docker-config.model.ts | 2 +- .../resolvers/docker/docker-config.service.ts | 2 +- .../docker/docker-template-icon.service.ts | 5 +- .../docker/docker-template-scanner.model.ts | 1 - .../docker-template-scanner.service.spec.ts | 6 +- .../docker/docker-template-scanner.service.ts | 10 +- .../graph/resolvers/docker/docker.resolver.ts | 2 +- .../organizer/docker-organizer.service.ts | 2 +- .../notifications/notifications.model.ts | 6 + .../notifications/notifications.resolver.ts | 7 + .../notifications.service.spec.ts | 67 ++++++ .../notifications/notifications.service.ts | 54 +++++ .../organizer/organizer.resolution.test.ts | 6 +- api/src/unraid-api/organizer/organizer.ts | 2 - web/components.d.ts | 1 + web/public/test-pages/all-components.html | 10 +- .../CriticalNotifications.standalone.vue | 194 ++++++++++++++++++ .../graphql/notification.query.ts | 11 + .../components/Wrapper/component-registry.ts | 7 + web/src/composables/gql/gql.ts | 6 + web/src/composables/gql/graphql.ts | 11 + 22 files changed, 397 insertions(+), 20 deletions(-) create mode 100644 web/src/components/Notifications/CriticalNotifications.standalone.vue diff --git a/api/generated-schema.graphql b/api/generated-schema.graphql index 96da6cb14..e4b8add3c 100644 --- a/api/generated-schema.graphql +++ b/api/generated-schema.graphql @@ -1831,6 +1831,11 @@ type Notifications implements Node { """A cached overview of the notifications in the system & their severity.""" overview: NotificationOverview! list(filter: NotificationFilter!): [Notification!]! + + """ + Deduplicated list of unread warning and alert notifications, sorted latest first. + """ + warningsAndAlerts: [Notification!]! } input NotificationFilter { diff --git a/api/src/unraid-api/graph/resolvers/docker/docker-config.model.ts b/api/src/unraid-api/graph/resolvers/docker/docker-config.model.ts index 00fbbc97e..b023933be 100644 --- a/api/src/unraid-api/graph/resolvers/docker/docker-config.model.ts +++ b/api/src/unraid-api/graph/resolvers/docker/docker-config.model.ts @@ -1,6 +1,6 @@ import { Field, ObjectType } from '@nestjs/graphql'; -import { IsObject, IsOptional, IsArray, IsString } from 'class-validator'; +import { IsArray, IsObject, IsOptional, IsString } from 'class-validator'; import { GraphQLJSON } from 'graphql-scalars'; @ObjectType() diff --git a/api/src/unraid-api/graph/resolvers/docker/docker-config.service.ts b/api/src/unraid-api/graph/resolvers/docker/docker-config.service.ts index 74d89ab72..c1c091ddb 100644 --- a/api/src/unraid-api/graph/resolvers/docker/docker-config.service.ts +++ b/api/src/unraid-api/graph/resolvers/docker/docker-config.service.ts @@ -42,7 +42,7 @@ export class DockerConfigService extends ConfigFilePersister { if (!cronExpression.valid) { throw new AppError(`Cron expression not supported: ${dockerConfig.updateCheckCronSchedule}`); } - + return dockerConfig; } } diff --git a/api/src/unraid-api/graph/resolvers/docker/docker-template-icon.service.ts b/api/src/unraid-api/graph/resolvers/docker/docker-template-icon.service.ts index 914cef675..ec4fea9e0 100644 --- a/api/src/unraid-api/graph/resolvers/docker/docker-template-icon.service.ts +++ b/api/src/unraid-api/graph/resolvers/docker/docker-template-icon.service.ts @@ -23,7 +23,9 @@ export class DockerTemplateIconService { return parsed.Container.Icon || null; } catch (error) { - this.logger.debug(`Failed to read icon from template ${templatePath}: ${error instanceof Error ? error.message : 'Unknown error'}`); + this.logger.debug( + `Failed to read icon from template ${templatePath}: ${error instanceof Error ? error.message : 'Unknown error'}` + ); return null; } } @@ -57,4 +59,3 @@ export class DockerTemplateIconService { return iconMap; } } - diff --git a/api/src/unraid-api/graph/resolvers/docker/docker-template-scanner.model.ts b/api/src/unraid-api/graph/resolvers/docker/docker-template-scanner.model.ts index 021e416ab..275039f46 100644 --- a/api/src/unraid-api/graph/resolvers/docker/docker-template-scanner.model.ts +++ b/api/src/unraid-api/graph/resolvers/docker/docker-template-scanner.model.ts @@ -14,4 +14,3 @@ export class DockerTemplateSyncResult { @Field(() => [String]) errors!: string[]; } - diff --git a/api/src/unraid-api/graph/resolvers/docker/docker-template-scanner.service.spec.ts b/api/src/unraid-api/graph/resolvers/docker/docker-template-scanner.service.spec.ts index 2e07483fe..54e9d8c77 100644 --- a/api/src/unraid-api/graph/resolvers/docker/docker-template-scanner.service.spec.ts +++ b/api/src/unraid-api/graph/resolvers/docker/docker-template-scanner.service.spec.ts @@ -1,6 +1,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { mkdir, rm, writeFile } from 'fs/promises'; import { join } from 'path'; + import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { DockerConfigService } from '@app/unraid-api/graph/resolvers/docker/docker-config.service.js'; @@ -151,9 +152,7 @@ describe('DockerTemplateScannerService', () => { image: 'test/image:latest', } as DockerContainer; - const templates = [ - { filePath: '/path/1', name: 'different', repository: 'other/image' }, - ]; + const templates = [{ filePath: '/path/1', name: 'different', repository: 'other/image' }]; const result = (service as any).matchContainerToTemplate(container, templates); @@ -424,4 +423,3 @@ describe('DockerTemplateScannerService', () => { }); }); }); - diff --git a/api/src/unraid-api/graph/resolvers/docker/docker-template-scanner.service.ts b/api/src/unraid-api/graph/resolvers/docker/docker-template-scanner.service.ts index b05ebb668..99b264b81 100644 --- a/api/src/unraid-api/graph/resolvers/docker/docker-template-scanner.service.ts +++ b/api/src/unraid-api/graph/resolvers/docker/docker-template-scanner.service.ts @@ -65,7 +65,9 @@ export class DockerTemplateScannerService { }); if (needsSync.length > 0) { - this.logger.log(`Found ${needsSync.length} containers without template mappings, triggering sync`); + this.logger.log( + `Found ${needsSync.length} containers without template mappings, triggering sync` + ); await this.scanTemplates(); return true; } @@ -178,7 +180,10 @@ export class DockerTemplateScannerService { } for (const template of templates) { - if (template.repository && this.normalizeRepository(template.repository) === containerImage) { + if ( + template.repository && + this.normalizeRepository(template.repository) === containerImage + ) { return template; } } @@ -203,4 +208,3 @@ export class DockerTemplateScannerService { this.dockerConfigService.replaceConfig(updated); } } - diff --git a/api/src/unraid-api/graph/resolvers/docker/docker.resolver.ts b/api/src/unraid-api/graph/resolvers/docker/docker.resolver.ts index c203e839b..f18364152 100644 --- a/api/src/unraid-api/graph/resolvers/docker/docker.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/docker/docker.resolver.ts @@ -7,8 +7,8 @@ import { UsePermissions } from '@unraid/shared/use-permissions.directive.js'; import { UseFeatureFlag } from '@app/unraid-api/decorators/use-feature-flag.decorator.js'; import { DockerFormService } from '@app/unraid-api/graph/resolvers/docker/docker-form.service.js'; import { DockerPhpService } from '@app/unraid-api/graph/resolvers/docker/docker-php.service.js'; -import { DockerTemplateScannerService } from '@app/unraid-api/graph/resolvers/docker/docker-template-scanner.service.js'; import { DockerTemplateSyncResult } from '@app/unraid-api/graph/resolvers/docker/docker-template-scanner.model.js'; +import { DockerTemplateScannerService } from '@app/unraid-api/graph/resolvers/docker/docker-template-scanner.service.js'; import { ExplicitStatusItem } from '@app/unraid-api/graph/resolvers/docker/docker-update-status.model.js'; import { Docker, diff --git a/api/src/unraid-api/graph/resolvers/docker/organizer/docker-organizer.service.ts b/api/src/unraid-api/graph/resolvers/docker/organizer/docker-organizer.service.ts index 024702ccb..ce077ac33 100644 --- a/api/src/unraid-api/graph/resolvers/docker/organizer/docker-organizer.service.ts +++ b/api/src/unraid-api/graph/resolvers/docker/organizer/docker-organizer.service.ts @@ -3,8 +3,8 @@ import { Injectable, Logger } from '@nestjs/common'; import type { ContainerListOptions } from 'dockerode'; import { AppError } from '@app/core/errors/app-error.js'; -import { DockerContainer } from '@app/unraid-api/graph/resolvers/docker/docker.model.js'; import { DockerTemplateIconService } from '@app/unraid-api/graph/resolvers/docker/docker-template-icon.service.js'; +import { DockerContainer } from '@app/unraid-api/graph/resolvers/docker/docker.model.js'; import { DockerService } from '@app/unraid-api/graph/resolvers/docker/docker.service.js'; import { DockerOrganizerConfigService } from '@app/unraid-api/graph/resolvers/docker/organizer/docker-organizer-config.service.js'; import { 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 069620cd4..64dd0893a 100644 --- a/api/src/unraid-api/graph/resolvers/notifications/notifications.model.ts +++ b/api/src/unraid-api/graph/resolvers/notifications/notifications.model.ts @@ -164,4 +164,10 @@ export class Notifications extends Node { @Field(() => [Notification]) @IsNotEmpty() list!: Notification[]; + + @Field(() => [Notification], { + description: 'Deduplicated list of unread warning and alert notifications, sorted latest first.', + }) + @IsNotEmpty() + warningsAndAlerts!: Notification[]; } 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 fe6e56ad6..f8929ee6b 100644 --- a/api/src/unraid-api/graph/resolvers/notifications/notifications.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/notifications/notifications.resolver.ts @@ -49,6 +49,13 @@ export class NotificationsResolver { return await this.notificationsService.getNotifications(filters); } + @ResolveField(() => [Notification], { + description: 'Deduplicated list of unread warning and alert notifications.', + }) + public async warningsAndAlerts(): Promise { + return this.notificationsService.getWarningsAndAlerts(); + } + /**============================================ * Mutations *=============================================**/ diff --git a/api/src/unraid-api/graph/resolvers/notifications/notifications.service.spec.ts b/api/src/unraid-api/graph/resolvers/notifications/notifications.service.spec.ts index 3808d55a0..e4159a259 100644 --- a/api/src/unraid-api/graph/resolvers/notifications/notifications.service.spec.ts +++ b/api/src/unraid-api/graph/resolvers/notifications/notifications.service.spec.ts @@ -289,6 +289,73 @@ describe.sequential('NotificationsService', () => { expect(loaded.length).toEqual(3); }); + describe('getWarningsAndAlerts', () => { + it('deduplicates unread warning and alert notifications', async ({ expect }) => { + const duplicateData = { + title: 'Array Status', + subject: 'Disk 1 is getting warm', + description: 'Disk temperature has exceeded threshold.', + importance: NotificationImportance.WARNING, + } as const; + + // Create duplicate warnings and an alert with different content + await createNotification(duplicateData); + await createNotification(duplicateData); + await createNotification({ + title: 'UPS Disconnected', + subject: 'The UPS connection has been lost', + description: 'Reconnect the UPS to restore protection.', + importance: NotificationImportance.ALERT, + }); + await createNotification({ + title: 'Parity Check Complete', + subject: 'A parity check has completed successfully', + description: 'No sync errors were detected.', + importance: NotificationImportance.INFO, + }); + + const results = await service.getWarningsAndAlerts(); + const warningMatches = results.filter( + (notification) => notification.subject === duplicateData.subject + ); + const alertMatches = results.filter((notification) => + notification.subject.includes('UPS connection') + ); + + expect(results.length).toEqual(2); + expect(warningMatches).toHaveLength(1); + expect(alertMatches).toHaveLength(1); + expect( + results.every((notification) => notification.importance !== NotificationImportance.INFO) + ).toBe(true); + }); + + it('respects the provided limit', async ({ expect }) => { + const limit = 2; + await createNotification({ + title: 'Array Warning', + subject: 'Disk 2 is getting warm', + description: 'Disk temperature has exceeded threshold.', + importance: NotificationImportance.WARNING, + }); + await createNotification({ + title: 'Network Down', + subject: 'Ethernet link is down', + description: 'Physical link failure detected.', + importance: NotificationImportance.ALERT, + }); + await createNotification({ + title: 'Critical Temperature', + subject: 'CPU temperature exceeded', + description: 'CPU temperature has exceeded safe operating limits.', + importance: NotificationImportance.ALERT, + }); + + const results = await service.getWarningsAndAlerts(limit); + expect(results.length).toEqual(limit); + }); + }); + /**-------------------------------------------- * CRUD: Update Tests *---------------------------------------------**/ 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 6ec780d66..466f13d49 100644 --- a/api/src/unraid-api/graph/resolvers/notifications/notifications.service.ts +++ b/api/src/unraid-api/graph/resolvers/notifications/notifications.service.ts @@ -567,6 +567,49 @@ export class NotificationsService { return notifications; } + /** + * Returns a deduplicated list of unread warning and alert notifications. + * + * Deduplication is based on the combination of importance, title, subject, description, and link. + * This ensures repeated notifications with the same user-facing content are only shown once, while + * still prioritizing the most recent occurrence of each unique notification. + * + * @param limit Maximum number of unique notifications to return. Default: 50. + */ + public async getWarningsAndAlerts(limit = 50): Promise { + const { UNREAD } = this.paths(); + const files = await this.listFilesInFolder(UNREAD); + const [notifications] = await this.loadNotificationsFromPaths(files, { + type: NotificationType.UNREAD, + }); + + const deduped: Notification[] = []; + const seen = new Set(); + + for (const notification of notifications) { + if ( + notification.importance !== NotificationImportance.ALERT && + notification.importance !== NotificationImportance.WARNING + ) { + continue; + } + + const key = this.getDeduplicationKey(notification); + if (seen.has(key)) { + continue; + } + + seen.add(key); + deduped.push(notification); + + if (deduped.length >= limit) { + break; + } + } + + return deduped; + } + /** * Given a path to a folder, returns the full (absolute) paths of the folder's top-level contents. * Sorted latest-first by default. @@ -791,4 +834,15 @@ export class NotificationsService { const defaultTimestamp = 0; return Number(b.timestamp ?? defaultTimestamp) - Number(a.timestamp ?? defaultTimestamp); } + + private getDeduplicationKey(notification: Notification): string { + const makePart = (value?: string | null) => (value ?? '').trim(); + return [ + notification.importance, + makePart(notification.title), + makePart(notification.subject), + makePart(notification.description), + makePart(notification.link), + ].join('|'); + } } diff --git a/api/src/unraid-api/organizer/organizer.resolution.test.ts b/api/src/unraid-api/organizer/organizer.resolution.test.ts index cb4f9e55f..e0d371282 100644 --- a/api/src/unraid-api/organizer/organizer.resolution.test.ts +++ b/api/src/unraid-api/organizer/organizer.resolution.test.ts @@ -139,10 +139,10 @@ describe('Organizer Resolver', () => { const resolved = resolveOrganizer(organizer); const flatEntries = resolved.views[0].flatEntries; - + // Should have 2 entries: root folder and the ref (kept as ref type since resource not found) expect(flatEntries).toHaveLength(2); - + const missingRefEntry = flatEntries[1]; expect(missingRefEntry.id).toBe('missing-ref'); expect(missingRefEntry.type).toBe('ref'); // Stays as ref when resource not found @@ -172,7 +172,7 @@ describe('Organizer Resolver', () => { const resolved = resolveOrganizer(organizer); const flatEntries = resolved.views[0].flatEntries; - + // Should only have root folder, missing entry is skipped expect(flatEntries).toHaveLength(1); expect(flatEntries[0].id).toBe('root-folder'); diff --git a/api/src/unraid-api/organizer/organizer.ts b/api/src/unraid-api/organizer/organizer.ts index c36214176..3aa9a2c1a 100644 --- a/api/src/unraid-api/organizer/organizer.ts +++ b/api/src/unraid-api/organizer/organizer.ts @@ -86,7 +86,6 @@ export function addMissingResourcesToView( return view; } - /** * Directly enriches flat entries from an organizer view without building an intermediate tree. * This is more efficient than building a tree just to flatten it again. @@ -228,7 +227,6 @@ export function resolveOrganizer( }; } - export interface CreateFolderInViewParams { view: OrganizerView; folderId: string; diff --git a/web/components.d.ts b/web/components.d.ts index 06f62a774..aef0301d3 100644 --- a/web/components.d.ts +++ b/web/components.d.ts @@ -37,6 +37,7 @@ declare module 'vue' { ConfirmDialog: typeof import('./src/components/ConfirmDialog.vue')['default'] 'ConnectSettings.standalone': typeof import('./src/components/ConnectSettings/ConnectSettings.standalone.vue')['default'] Console: typeof import('./src/components/Docker/Console.vue')['default'] + 'CriticalNotifications.standalone': typeof import('./src/components/Notifications/CriticalNotifications.standalone.vue')['default'] Detail: typeof import('./src/components/LayoutViews/Detail/Detail.vue')['default'] DetailContentHeader: typeof import('./src/components/LayoutViews/Detail/DetailContentHeader.vue')['default'] DetailLeftNavigation: typeof import('./src/components/LayoutViews/Detail/DetailLeftNavigation.vue')['default'] diff --git a/web/public/test-pages/all-components.html b/web/public/test-pages/all-components.html index 7f1da70c8..0e51644c5 100644 --- a/web/public/test-pages/all-components.html +++ b/web/public/test-pages/all-components.html @@ -73,6 +73,14 @@
+
🔔 Notifications
+
+

Critical Notifications

+ <unraid-critical-notifications> +
+ +
+
🐳 Docker
@@ -379,4 +387,4 @@ }); - \ No newline at end of file + diff --git a/web/src/components/Notifications/CriticalNotifications.standalone.vue b/web/src/components/Notifications/CriticalNotifications.standalone.vue new file mode 100644 index 000000000..d96fc6a42 --- /dev/null +++ b/web/src/components/Notifications/CriticalNotifications.standalone.vue @@ -0,0 +1,194 @@ + + + diff --git a/web/src/components/Notifications/graphql/notification.query.ts b/web/src/components/Notifications/graphql/notification.query.ts index f46781c0d..13c59bf58 100644 --- a/web/src/components/Notifications/graphql/notification.query.ts +++ b/web/src/components/Notifications/graphql/notification.query.ts @@ -34,6 +34,17 @@ export const getNotifications = graphql(/* GraphQL */ ` } `); +export const warningsAndAlerts = graphql(/* GraphQL */ ` + query WarningAndAlertNotifications { + notifications { + id + warningsAndAlerts { + ...NotificationFragment + } + } + } +`); + export const archiveNotification = graphql(/* GraphQL */ ` mutation ArchiveNotification($id: PrefixedID!) { archiveNotification(id: $id) { diff --git a/web/src/components/Wrapper/component-registry.ts b/web/src/components/Wrapper/component-registry.ts index 15cf81cf8..f726863f1 100644 --- a/web/src/components/Wrapper/component-registry.ts +++ b/web/src/components/Wrapper/component-registry.ts @@ -96,6 +96,13 @@ export const componentMappings: ComponentMapping[] = [ selector: 'unraid-dev-settings', appId: 'dev-settings', }, + { + component: defineAsyncComponent( + () => import('../Notifications/CriticalNotifications.standalone.vue') + ), + selector: 'unraid-critical-notifications', + appId: 'critical-notifications', + }, { component: defineAsyncComponent(() => import('../ApiKeyPage.standalone.vue')), selector: ['unraid-apikey-page', 'unraid-api-key-manager'], diff --git a/web/src/composables/gql/gql.ts b/web/src/composables/gql/gql.ts index 16c11648a..52f6d33ca 100644 --- a/web/src/composables/gql/gql.ts +++ b/web/src/composables/gql/gql.ts @@ -48,6 +48,7 @@ type Documents = { "\n fragment NotificationFragment on Notification {\n id\n title\n subject\n description\n importance\n link\n type\n timestamp\n formattedTimestamp\n }\n": typeof types.NotificationFragmentFragmentDoc, "\n fragment NotificationCountFragment on NotificationCounts {\n total\n info\n warning\n alert\n }\n": typeof types.NotificationCountFragmentFragmentDoc, "\n query Notifications($filter: NotificationFilter!) {\n notifications {\n id\n list(filter: $filter) {\n ...NotificationFragment\n }\n }\n }\n": typeof types.NotificationsDocument, + "\n query WarningAndAlertNotifications {\n notifications {\n id\n warningsAndAlerts {\n ...NotificationFragment\n }\n }\n }\n": typeof types.WarningAndAlertNotificationsDocument, "\n mutation ArchiveNotification($id: PrefixedID!) {\n archiveNotification(id: $id) {\n ...NotificationFragment\n }\n }\n": typeof types.ArchiveNotificationDocument, "\n mutation ArchiveAllNotifications {\n archiveAll {\n unread {\n total\n }\n archive {\n info\n warning\n alert\n total\n }\n }\n }\n": typeof types.ArchiveAllNotificationsDocument, "\n mutation DeleteNotification($id: PrefixedID!, $type: NotificationType!) {\n deleteNotification(id: $id, type: $type) {\n archive {\n total\n }\n }\n }\n": typeof types.DeleteNotificationDocument, @@ -107,6 +108,7 @@ const documents: Documents = { "\n fragment NotificationFragment on Notification {\n id\n title\n subject\n description\n importance\n link\n type\n timestamp\n formattedTimestamp\n }\n": types.NotificationFragmentFragmentDoc, "\n fragment NotificationCountFragment on NotificationCounts {\n total\n info\n warning\n alert\n }\n": types.NotificationCountFragmentFragmentDoc, "\n query Notifications($filter: NotificationFilter!) {\n notifications {\n id\n list(filter: $filter) {\n ...NotificationFragment\n }\n }\n }\n": types.NotificationsDocument, + "\n query WarningAndAlertNotifications {\n notifications {\n id\n warningsAndAlerts {\n ...NotificationFragment\n }\n }\n }\n": types.WarningAndAlertNotificationsDocument, "\n mutation ArchiveNotification($id: PrefixedID!) {\n archiveNotification(id: $id) {\n ...NotificationFragment\n }\n }\n": types.ArchiveNotificationDocument, "\n mutation ArchiveAllNotifications {\n archiveAll {\n unread {\n total\n }\n archive {\n info\n warning\n alert\n total\n }\n }\n }\n": types.ArchiveAllNotificationsDocument, "\n mutation DeleteNotification($id: PrefixedID!, $type: NotificationType!) {\n deleteNotification(id: $id, type: $type) {\n archive {\n total\n }\n }\n }\n": types.DeleteNotificationDocument, @@ -282,6 +284,10 @@ export function graphql(source: "\n fragment NotificationCountFragment on Notif * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ export function graphql(source: "\n query Notifications($filter: NotificationFilter!) {\n notifications {\n id\n list(filter: $filter) {\n ...NotificationFragment\n }\n }\n }\n"): (typeof documents)["\n query Notifications($filter: NotificationFilter!) {\n notifications {\n id\n list(filter: $filter) {\n ...NotificationFragment\n }\n }\n }\n"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "\n query WarningAndAlertNotifications {\n notifications {\n id\n warningsAndAlerts {\n ...NotificationFragment\n }\n }\n }\n"): (typeof documents)["\n query WarningAndAlertNotifications {\n notifications {\n id\n warningsAndAlerts {\n ...NotificationFragment\n }\n }\n }\n"]; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ diff --git a/web/src/composables/gql/graphql.ts b/web/src/composables/gql/graphql.ts index 77fb437e5..31e39b110 100644 --- a/web/src/composables/gql/graphql.ts +++ b/web/src/composables/gql/graphql.ts @@ -1510,6 +1510,8 @@ export type Notifications = Node & { list: Array; /** A cached overview of the notifications in the system & their severity. */ overview: NotificationOverview; + /** Deduplicated list of unread warning and alert notifications, sorted latest first. */ + warningsAndAlerts: Array; }; @@ -2863,6 +2865,14 @@ export type NotificationsQuery = { __typename?: 'Query', notifications: { __type & { ' $fragmentRefs'?: { 'NotificationFragmentFragment': NotificationFragmentFragment } } )> } }; +export type WarningAndAlertNotificationsQueryVariables = Exact<{ [key: string]: never; }>; + + +export type WarningAndAlertNotificationsQuery = { __typename?: 'Query', notifications: { __typename?: 'Notifications', id: string, warningsAndAlerts: Array<( + { __typename?: 'Notification' } + & { ' $fragmentRefs'?: { 'NotificationFragmentFragment': NotificationFragmentFragment } } + )> } }; + export type ArchiveNotificationMutationVariables = Exact<{ id: Scalars['PrefixedID']['input']; }>; @@ -3044,6 +3054,7 @@ export const LogFilesDocument = {"kind":"Document","definitions":[{"kind":"Opera export const LogFileContentDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"LogFileContent"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"path"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"lines"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"startLine"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"logFile"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"path"},"value":{"kind":"Variable","name":{"kind":"Name","value":"path"}}},{"kind":"Argument","name":{"kind":"Name","value":"lines"},"value":{"kind":"Variable","name":{"kind":"Name","value":"lines"}}},{"kind":"Argument","name":{"kind":"Name","value":"startLine"},"value":{"kind":"Variable","name":{"kind":"Name","value":"startLine"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"path"}},{"kind":"Field","name":{"kind":"Name","value":"content"}},{"kind":"Field","name":{"kind":"Name","value":"totalLines"}},{"kind":"Field","name":{"kind":"Name","value":"startLine"}}]}}]}}]} as unknown as DocumentNode; export const LogFileSubscriptionDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"subscription","name":{"kind":"Name","value":"LogFileSubscription"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"path"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"logFile"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"path"},"value":{"kind":"Variable","name":{"kind":"Name","value":"path"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"path"}},{"kind":"Field","name":{"kind":"Name","value":"content"}},{"kind":"Field","name":{"kind":"Name","value":"totalLines"}}]}}]}}]} as unknown as DocumentNode; export const NotificationsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"Notifications"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"filter"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"NotificationFilter"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"notifications"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"list"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"filter"},"value":{"kind":"Variable","name":{"kind":"Name","value":"filter"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"NotificationFragment"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"NotificationFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Notification"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"subject"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"importance"}},{"kind":"Field","name":{"kind":"Name","value":"link"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"timestamp"}},{"kind":"Field","name":{"kind":"Name","value":"formattedTimestamp"}}]}}]} as unknown as DocumentNode; +export const WarningAndAlertNotificationsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"WarningAndAlertNotifications"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"notifications"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"warningsAndAlerts"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"NotificationFragment"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"NotificationFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Notification"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"subject"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"importance"}},{"kind":"Field","name":{"kind":"Name","value":"link"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"timestamp"}},{"kind":"Field","name":{"kind":"Name","value":"formattedTimestamp"}}]}}]} as unknown as DocumentNode; export const ArchiveNotificationDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"ArchiveNotification"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"PrefixedID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"archiveNotification"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"NotificationFragment"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"NotificationFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Notification"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"subject"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"importance"}},{"kind":"Field","name":{"kind":"Name","value":"link"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"timestamp"}},{"kind":"Field","name":{"kind":"Name","value":"formattedTimestamp"}}]}}]} as unknown as DocumentNode; export const ArchiveAllNotificationsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"ArchiveAllNotifications"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"archiveAll"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"unread"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"total"}}]}},{"kind":"Field","name":{"kind":"Name","value":"archive"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"info"}},{"kind":"Field","name":{"kind":"Name","value":"warning"}},{"kind":"Field","name":{"kind":"Name","value":"alert"}},{"kind":"Field","name":{"kind":"Name","value":"total"}}]}}]}}]}}]} as unknown as DocumentNode; export const DeleteNotificationDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeleteNotification"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"PrefixedID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"type"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"NotificationType"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteNotification"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}},{"kind":"Argument","name":{"kind":"Name","value":"type"},"value":{"kind":"Variable","name":{"kind":"Name","value":"type"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"archive"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"total"}}]}}]}}]}}]} as unknown as DocumentNode;