Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
ab5c720
feat(notifications): persistent ("sticky until resolved") notifications
elibosley Jun 19, 2026
8e132af
feat(notifications): pin persistent notifications to the top of the list
elibosley Jun 19, 2026
59ba79b
fix(notifications): parse persistent flag coerced to boolean by the i…
elibosley Jun 19, 2026
97d59e5
feat(notifications): give persistent items a pinned treatment + tight…
elibosley Jun 19, 2026
396ddf4
feat(notifications): allow dismissing pinned notifications behind a w…
elibosley Jun 19, 2026
8a63554
feat(notifications): sweep archive on clear-by-key; unify item card s…
elibosley Jun 19, 2026
be24dc3
style(notifications): make item actions subtle (ghost/sm) instead of …
elibosley Jun 19, 2026
b46fbab
style(notifications): smoother dismiss/list animation
elibosley Jun 19, 2026
27daa52
style(notifications): modernize the panel header
elibosley Jun 19, 2026
907c88f
style(notifications): smoother JS-driven dismiss (slide + height coll…
elibosley Jun 19, 2026
1b78c8c
style(notifications): give Archive all / Delete all icons + consisten…
elibosley Jun 19, 2026
9146385
fix(notifications): never archive persistent notifications in archiveAll
elibosley Jun 20, 2026
25297a7
fix(web): refine pinned notification styling, rename Dismiss -> Archive
elibosley Jun 20, 2026
1d85ec1
fix(web): keep orange pinned styling, neutralize the View link color
elibosley Jun 20, 2026
068ba21
fix(notifications): never bulk-delete persistent notifications
elibosley Jun 20, 2026
45eb228
fix(web): hide archive button on persistent notifications
elibosley Jun 20, 2026
76240f6
feat(web): add a Pinned filter toggle to the notifications sidebar
elibosley Jun 20, 2026
66c8244
style(web): match Pinned filter toggle to the importance filter UI
elibosley Jun 20, 2026
eb96cdb
style(web): drop icon from Pinned filter toggle
elibosley Jun 20, 2026
04088aa
refactor(web): label persistent notifications "Active" in the UI
elibosley Jun 20, 2026
dce0632
feat(web): persist notification filters for the browser session
elibosley Jun 20, 2026
e501c08
feat(web): persist the Unread/Archived tab for the session too
elibosley Jun 20, 2026
2a58ea4
refactor(web): tighten notifications panel header
elibosley Jun 20, 2026
6bf6df8
refactor(web): label bulk action, move settings gear back to header
elibosley Jun 20, 2026
91b0e4a
style(web): anchor settings gear to the title, not the corner
elibosley Jun 20, 2026
56cc247
feat(notifications): backend-authoritative banner reconciliation
elibosley Jun 20, 2026
1b5c705
fix(web): don't render empty subject/description lines on notifications
elibosley Jun 20, 2026
a726d30
fix(notifications): allow empty description; self-correct badge on load
elibosley Jun 20, 2026
7e56da0
fix(web): address CodeRabbit review (cast + timestamp fallback)
elibosley Jun 20, 2026
9986475
fix(notifications): hoist persistent notifications into the first page
elibosley Jun 22, 2026
0aab59c
refactor(notifications): read persistent notifications from a dedicat…
elibosley Jun 22, 2026
39d46aa
feat(notifications): dedicated Active tab for persistent notifications
elibosley Jun 22, 2026
adda214
test(notifications): missing description is valid, not masked
elibosley Jun 23, 2026
7e1e904
fix(notifications): always read/write active notifications from tmpfs
elibosley Jun 23, 2026
31e3608
test(notifications): watch active dir as a second path when off-disk
elibosley Jun 23, 2026
a0f0372
fix(notifications): reflect external file removals in the overview
elibosley Jun 23, 2026
9fe768b
fix(web): hide the Active notifications tab when there are none
elibosley Jun 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions api/generated-schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -2138,6 +2138,9 @@ type NotificationCounts {
type NotificationOverview {
unread: NotificationCounts!
archive: NotificationCounts!

"""Counts for persistent ("Active") condition-style notifications."""
active: NotificationCounts!
}

type Notification implements Node {
Expand All @@ -2149,6 +2152,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"""
Expand All @@ -2165,6 +2176,7 @@ enum NotificationImportance {
enum NotificationType {
UNREAD
ARCHIVE
ACTIVE
}

type Notifications implements Node {
Expand Down Expand Up @@ -3401,6 +3413,16 @@ 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!

"""
Reconciles JS-sourced banner notifications: clears any unread "banner-" keyed notification not stamped with the supplied current page-load generation (i.e. a banner the producer stopped rendering).
"""
reconcileBannerNotifications(currentGeneration: String!): NotificationOverview!

"""Marks a notification as unread."""
unreadNotification(id: PrefixedID!): Notification!
unarchiveNotifications(ids: [PrefixedID!]!): NotificationOverview!
Expand Down Expand Up @@ -3468,6 +3490,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 {
Expand Down
11 changes: 11 additions & 0 deletions api/src/core/types/states/notification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,15 @@ 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;
/**
* Per-page-load generation stamp for JS-sourced banner notifications (keys prefixed
* 'banner-'). The page re-raises active banners on every load with the current
* generation; reconcileBannerNotifications clears any 'banner-' entry whose gen no
* longer matches, i.e. one the producer stopped rendering.
*/
gen?: string;
}
Original file line number Diff line number Diff line change
Expand Up @@ -121,8 +121,10 @@ describe('NotificationsService - loadNotificationFile (minimal mocks)', () => {
});

it('creates the notifications watcher without replaying existing files', () => {
// The configured path (testNotificationsDir) is not the tmpfs default, so the
// always-off-disk active dir falls outside it and is watched as a second path.
expect(mockWatch).toHaveBeenCalledWith(
testNotificationsDir,
[testNotificationsDir, '/tmp/notifications/active'],
expect.objectContaining({
ignoreInitial: true,
})
Expand Down Expand Up @@ -177,6 +179,32 @@ describe('NotificationsService - loadNotificationFile (minimal mocks)', () => {
expect(processNotificationAdd).toHaveBeenCalledWith(bufferedPath);
});

it('debounces a single authoritative recalc when notification files are unlinked', async () => {
vi.useFakeTimers();
try {
const recalc = vi
.spyOn(service, 'recalculateOverview')
.mockResolvedValue({ error: false, overview: service.getOverview() });
Reflect.set(service, 'publishWarningsAndAlerts', vi.fn().mockResolvedValue(undefined));
const handleUnlink = (
Reflect.get(service, 'handleNotificationUnlink') as (path: string) => void
).bind(service);

// Non-notification files are ignored entirely.
handleUnlink(`${testNotificationsDir}/active/not-a-notification.txt`);
// A burst of real removals collapses into one rebuild.
handleUnlink(`${testNotificationsDir}/active/a.notify`);
handleUnlink(`${testNotificationsDir}/active/b.notify`);
handleUnlink(`${testNotificationsDir}/unread/c.notify`);

expect(recalc).not.toHaveBeenCalled(); // still within the debounce window
await vi.advanceTimersByTimeAsync(200);
expect(recalc).toHaveBeenCalledTimes(1);
} finally {
vi.useRealTimers();
}
});

it('should load and validate a valid notification file', async () => {
const mockFileContent = `timestamp=1609459200
event=Test Event
Expand Down Expand Up @@ -242,7 +270,7 @@ importance=not-a-valid-enum`;
expect(result.description).toBe('Test Description');
});

it('should handle missing description field (should return masked warning notification)', async () => {
it('should allow a missing description field (empty is valid, not masked)', async () => {
const mockFileContent = `timestamp=1609459200
event=Test Event
subject=Test Subject
Expand All @@ -254,9 +282,13 @@ importance=normal`;
'/test/path/test.notify',
NotificationType.UNREAD
);
// Should be a masked warning notification
expect(result.description).toContain('invalid and cannot be displayed');
expect(result.importance).toBe(NotificationImportance.WARNING);
// A missing description is allowed: condition/banner notifications carry their
// meaning in the title + Active badge, and the UI hides the empty line. It must
// not be masked as invalid.
expect(result.id).toBe('test.notify');
expect(result.description).toBe('');
expect(result.description).not.toContain('invalid and cannot be displayed');
expect(result.importance).toBe(NotificationImportance.INFO);
});

it('should preserve passthrough data from notification file (only known fields)', async () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
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',
ARCHIVE = 'ARCHIVE',
// Persistent ("Active") condition-style notifications. Stored separately; they
// stay until their producer clears them and are never archived by the user.
ACTIVE = 'ACTIVE',
}

export enum NotificationImportance {
Expand Down Expand Up @@ -60,9 +63,11 @@ export class NotificationData {
@IsNotEmpty()
subject!: string;

// Description is optional in practice (e.g. condition/banner notifications carry
// their meaning in the title + Active badge). Allow empty so they aren't masked
// as invalid; the UI hides the line when empty.
@Field()
@IsString()
@IsNotEmpty()
description!: string;

@Field(() => NotificationImportance)
Expand All @@ -74,6 +79,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')
Expand Down Expand Up @@ -108,6 +132,12 @@ export class NotificationOverview {
@Field(() => NotificationCounts)
@IsNotEmpty()
archive!: NotificationCounts;

@Field(() => NotificationCounts, {
description: 'Counts for persistent ("Active") condition-style notifications.',
})
@IsNotEmpty()
active!: NotificationCounts;
}

@ObjectType({ implements: () => Node })
Expand All @@ -122,9 +152,11 @@ export class Notification extends Node {
@IsNotEmpty()
subject!: string;

// Description is optional in practice (e.g. condition/banner notifications carry
// their meaning in the title + Active badge). Allow empty so they aren't masked
// as invalid; the UI hides the line when empty.
@Field()
@IsString()
@IsNotEmpty()
description!: string;

@Field(() => NotificationImportance)
Expand All @@ -137,6 +169,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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,28 @@ 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<NotificationOverview> {
return this.notificationsService.clearNotificationsByKey(key);
}

@Mutation(() => NotificationOverview, {
description:
'Reconciles JS-sourced banner notifications: clears any unread "banner-" keyed notification not stamped with the supplied current page-load generation (i.e. a banner the producer stopped rendering).',
})
public reconcileBannerNotifications(
@Args('currentGeneration', { type: () => String })
currentGeneration: string
): Promise<NotificationOverview> {
return this.notificationsService.reconcileBannerNotifications(currentGeneration);
}

@Mutation(() => Notification, { description: 'Marks a notification as unread.' })
public unreadNotification(
@Args('id', { type: () => PrefixedID })
Expand Down
Loading
Loading