mirror of
https://github.com/unraid/api.git
synced 2026-01-05 16:09:49 -06:00
feat: critical notifications
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -42,7 +42,7 @@ export class DockerConfigService extends ConfigFilePersister<DockerConfig> {
|
||||
if (!cronExpression.valid) {
|
||||
throw new AppError(`Cron expression not supported: ${dockerConfig.updateCheckCronSchedule}`);
|
||||
}
|
||||
|
||||
|
||||
return dockerConfig;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,4 +14,3 @@ export class DockerTemplateSyncResult {
|
||||
@Field(() => [String])
|
||||
errors!: string[];
|
||||
}
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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<Notification[]> {
|
||||
return this.notificationsService.getWarningsAndAlerts();
|
||||
}
|
||||
|
||||
/**============================================
|
||||
* Mutations
|
||||
*=============================================**/
|
||||
|
||||
@@ -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
|
||||
*---------------------------------------------**/
|
||||
|
||||
@@ -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<Notification[]> {
|
||||
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<string>();
|
||||
|
||||
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('|');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
|
||||
1
web/components.d.ts
vendored
1
web/components.d.ts
vendored
@@ -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']
|
||||
|
||||
@@ -73,6 +73,14 @@
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="category-header">🔔 Notifications</div>
|
||||
<div class="component-card" style="grid-column: 1 / -1;">
|
||||
<h3>Critical Notifications</h3>
|
||||
<span class="selector"><unraid-critical-notifications></span>
|
||||
<div class="component-mount">
|
||||
<unraid-critical-notifications></unraid-critical-notifications>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Docker -->
|
||||
<div class="category-header">🐳 Docker</div>
|
||||
<div class="component-grid">
|
||||
@@ -379,4 +387,4 @@
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useQuery, useSubscription } from '@vue/apollo-composable';
|
||||
|
||||
import { AlertTriangle, Octagon } from 'lucide-vue-next';
|
||||
|
||||
import type {
|
||||
NotificationFragmentFragment,
|
||||
WarningAndAlertNotificationsQuery,
|
||||
WarningAndAlertNotificationsQueryVariables,
|
||||
} from '~/composables/gql/graphql';
|
||||
|
||||
import {
|
||||
NOTIFICATION_FRAGMENT,
|
||||
warningsAndAlerts,
|
||||
} from '~/components/Notifications/graphql/notification.query';
|
||||
import {
|
||||
notificationAddedSubscription,
|
||||
notificationOverviewSubscription,
|
||||
} from '~/components/Notifications/graphql/notification.subscription';
|
||||
import { useFragment } from '~/composables/gql';
|
||||
import { NotificationImportance } from '~/composables/gql/graphql';
|
||||
|
||||
const { result, loading, refetch, error } = useQuery<
|
||||
WarningAndAlertNotificationsQuery,
|
||||
WarningAndAlertNotificationsQueryVariables
|
||||
>(warningsAndAlerts, undefined, {
|
||||
fetchPolicy: 'network-only',
|
||||
});
|
||||
|
||||
const extractNotifications = (
|
||||
notifications: NotificationFragmentFragment[] | null | undefined
|
||||
): NotificationFragmentFragment[] => {
|
||||
if (!notifications?.length) {
|
||||
return [];
|
||||
}
|
||||
return useFragment(NOTIFICATION_FRAGMENT, notifications) ?? [];
|
||||
};
|
||||
|
||||
const notifications = computed<NotificationFragmentFragment[]>(() => {
|
||||
const data = result.value?.notifications?.warningsAndAlerts;
|
||||
return extractNotifications(data);
|
||||
});
|
||||
|
||||
const formatTimestamp = (notification: NotificationFragmentFragment) => {
|
||||
if (notification.formattedTimestamp) {
|
||||
return notification.formattedTimestamp;
|
||||
}
|
||||
if (!notification.timestamp) {
|
||||
return '';
|
||||
}
|
||||
const parsed = new Date(notification.timestamp);
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return '';
|
||||
}
|
||||
return parsed.toLocaleString();
|
||||
};
|
||||
|
||||
const importanceMeta: Record<
|
||||
NotificationImportance,
|
||||
{ label: string; badge: string; icon: typeof AlertTriangle; accent: string }
|
||||
> = {
|
||||
[NotificationImportance.ALERT]: {
|
||||
label: 'Alert',
|
||||
badge: 'bg-red-100 text-red-700 border border-red-300',
|
||||
icon: Octagon,
|
||||
accent: 'text-red-600',
|
||||
},
|
||||
[NotificationImportance.WARNING]: {
|
||||
label: 'Warning',
|
||||
badge: 'bg-amber-100 text-amber-700 border border-amber-300',
|
||||
icon: AlertTriangle,
|
||||
accent: 'text-amber-600',
|
||||
},
|
||||
[NotificationImportance.INFO]: {
|
||||
label: 'Info',
|
||||
badge: 'bg-blue-100 text-blue-700 border border-blue-300',
|
||||
icon: AlertTriangle,
|
||||
accent: 'text-blue-600',
|
||||
},
|
||||
};
|
||||
|
||||
const enrichedNotifications = computed(() =>
|
||||
notifications.value.map((notification) => ({
|
||||
notification,
|
||||
displayTimestamp: formatTimestamp(notification),
|
||||
meta: importanceMeta[notification.importance],
|
||||
}))
|
||||
);
|
||||
|
||||
useSubscription(notificationAddedSubscription, null, {
|
||||
onResult: ({ data }) => {
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
const notification = useFragment(NOTIFICATION_FRAGMENT, data.notificationAdded);
|
||||
if (
|
||||
!notification ||
|
||||
(notification.importance !== NotificationImportance.ALERT &&
|
||||
notification.importance !== NotificationImportance.WARNING)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
void refetch();
|
||||
},
|
||||
});
|
||||
|
||||
useSubscription(notificationOverviewSubscription, null, {
|
||||
onResult: () => {
|
||||
void refetch();
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="flex flex-col gap-4 rounded-lg border border-amber-200 bg-white p-4 shadow-sm">
|
||||
<header class="flex items-center justify-between gap-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<AlertTriangle class="h-5 w-5 text-amber-600" aria-hidden="true" />
|
||||
<h2 class="text-base font-semibold text-gray-900">Warnings & Alerts</h2>
|
||||
</div>
|
||||
<span
|
||||
v-if="!loading"
|
||||
class="rounded-full bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-700"
|
||||
>
|
||||
{{ notifications.length }}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<div v-if="error" class="rounded border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||
Failed to load notifications. Please try again.
|
||||
</div>
|
||||
|
||||
<div v-else-if="loading" class="flex items-center gap-2 text-sm text-gray-500">
|
||||
<span class="h-2 w-2 animate-pulse rounded-full bg-amber-400" aria-hidden="true" />
|
||||
Loading latest notifications…
|
||||
</div>
|
||||
|
||||
<ul v-else-if="enrichedNotifications.length" class="flex flex-col gap-3">
|
||||
<li
|
||||
v-for="{ notification, displayTimestamp, meta } in enrichedNotifications"
|
||||
:key="notification.id"
|
||||
class="grid gap-2 rounded-md border border-gray-200 p-3 transition hover:border-amber-300"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<component
|
||||
:is="meta.icon"
|
||||
class="mt-0.5 h-5 w-5 flex-none"
|
||||
:class="meta.accent"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div class="flex flex-1 flex-col gap-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="rounded-full px-2 py-0.5 text-xs font-medium" :class="meta.badge">
|
||||
{{ meta.label }}
|
||||
</span>
|
||||
<span v-if="displayTimestamp" class="text-xs font-medium text-gray-500">
|
||||
{{ displayTimestamp }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-sm font-semibold text-gray-900">
|
||||
{{ notification.title }}
|
||||
</p>
|
||||
<p class="text-sm text-gray-600">
|
||||
{{ notification.subject }}
|
||||
</p>
|
||||
<p v-if="notification.description" class="text-sm text-gray-500">
|
||||
{{ notification.description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
v-if="notification.link"
|
||||
:href="notification.link"
|
||||
class="inline-flex w-fit items-center gap-1 text-sm font-medium text-amber-700 hover:text-amber-800"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
View details
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div v-else class="flex flex-col items-start gap-2 rounded-md border border-gray-200 p-3">
|
||||
<div class="flex items-center gap-2 text-sm font-medium text-gray-700">
|
||||
<span class="h-2 w-2 rounded-full bg-emerald-400" aria-hidden="true" />
|
||||
All clear. No active warnings or alerts.
|
||||
</div>
|
||||
<p class="text-sm text-gray-500">
|
||||
This panel automatically refreshes when new warnings or alerts are generated.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -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) {
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -1510,6 +1510,8 @@ export type Notifications = Node & {
|
||||
list: Array<Notification>;
|
||||
/** 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<Notification>;
|
||||
};
|
||||
|
||||
|
||||
@@ -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<LogFileContentQuery, LogFileContentQueryVariables>;
|
||||
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<LogFileSubscriptionSubscription, LogFileSubscriptionSubscriptionVariables>;
|
||||
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<NotificationsQuery, NotificationsQueryVariables>;
|
||||
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<WarningAndAlertNotificationsQuery, WarningAndAlertNotificationsQueryVariables>;
|
||||
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<ArchiveNotificationMutation, ArchiveNotificationMutationVariables>;
|
||||
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<ArchiveAllNotificationsMutation, ArchiveAllNotificationsMutationVariables>;
|
||||
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<DeleteNotificationMutation, DeleteNotificationMutationVariables>;
|
||||
|
||||
Reference in New Issue
Block a user