Compare commits

...

15 Commits

Author SHA1 Message Date
mdatelle
647faab50b Merge branch 'feat/notification-loading' of github.com:unraid/api into feat/ui-notifications 2024-09-26 10:54:39 -04:00
Pujit Mehrotra
90f02aa1c5 feat: make notification id logic 2024-09-25 13:10:41 -04:00
Pujit Mehrotra
0e784b8ad6 chore: update uuid@10.0.0 for v7 uuids
v7 uuids are basically v4 uuids that are sortable (by creation time)
2024-09-25 11:48:40 -04:00
Pujit Mehrotra
87f60d953c fix: use correct ini encoder in notification service 2024-09-25 10:38:42 -04:00
Pujit Mehrotra
1dc665dbd8 fix: mv paths() to top of NotificationsService to make it more intuitive 2024-09-24 19:23:44 -04:00
Pujit Mehrotra
acc847ef1d fix: race condition when updating notification types 2024-09-24 19:12:34 -04:00
Pujit Mehrotra
48aca094f5 feat: expose mutations for notifications over graphql 2024-09-24 18:25:14 -04:00
Zack Spear
94143d96df feat: wip Notification UI starter 2024-09-24 13:54:36 -07:00
Pujit Mehrotra
56c2b29a80 feat: add deletion & update methods to NotificationService
also stubs create method
2024-09-23 17:13:23 -04:00
Pujit Mehrotra
f88415e3d9 chore: update prettier line width limit to 105ch
to prevent over-aggressive line breaks & wraps.
2024-09-23 17:12:41 -04:00
Pujit Mehrotra
0e4054cf3b fix: disable permissions bypass to avoid incorrect role assignment to api keys 2024-09-13 13:48:21 -04:00
Pujit Mehrotra
fff0861f75 feat: wrap Notifications in a GraphQL Node & implement notification overviews 2024-09-12 17:18:31 -04:00
mdatelle
a82513a9bc fix: add return to resolver and update jsdoc for getNotifications 2024-09-12 13:30:16 -04:00
mdatelle
f5b3e393f7 refactor: update notifications.resolver to handle filtering
- Updates the getNotifications function to use the refactored getNotificationsFromPaths function
- Adds filtering logic to the updated  getNotificationsFromPaths function
- Update JSdocs
2024-09-12 12:39:12 -04:00
Pujit Mehrotra
b178247046 fix: load notifications from file system instead of redux state
- Adds a Nest.js service for notifications
- Helps improve our memory footprint!
2024-09-11 17:46:43 -04:00
26 changed files with 1091 additions and 126 deletions

3
.gitignore vendored
View File

@@ -55,6 +55,9 @@ typings/
# OSX
.DS_Store
# Jetbrains Settings Files
.idea
# Temp dir for tests
test/__temp__/*

View File

@@ -14,5 +14,5 @@ INTROSPECTION=true
MOTHERSHIP_GRAPHQL_LINK="http://authenticator:3000/graphql"
NODE_TLS_REJECT_UNAUTHORIZED=0
BYPASS_PERMISSION_CHECKS=false
BYPASS_CORS_CHECKS=false
BYPASS_CORS_CHECKS=true
CHOKIDAR_USEPOLLING=true

82
api/.gitignore vendored Normal file
View File

@@ -0,0 +1,82 @@
# Logs
./logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
coverage-ts
# nyc test coverage
.nyc_output
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# TypeScript v1 declaration files
typings/
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
# next.js build output
.next
# Visual Studio Code workspace
.vscode/*
!.vscode/extensions.json
# OSX
.DS_Store
# Temp dir for tests
test/__temp__/*
# Built files
dist
# Typescript
typescript
# Ultra runner
.ultra.cache.json
# Github actions
RELEASE_NOTES.md
# Docker Deploy Folder
deploy/*
!deploy/.gitkeep
# pkg cache
.pkg-cache
# IDE Settings Files
.idea

8
api/.prettierrc.cjs Normal file
View File

@@ -0,0 +1,8 @@
// prettier.config.js or .prettierrc.js
module.exports = {
trailingComma: "es5",
tabWidth: 4,
semi: true,
singleQuote: true,
printWidth: 105,
};

118
api/package-lock.json generated
View File

@@ -46,6 +46,7 @@
"dockerode": "^3.3.5",
"dotenv": "^16.4.5",
"express": "^4.19.2",
"filenamify": "^6.0.0",
"find-process": "^1.4.7",
"global-agent": "^3.0.0",
"graphql": "^16.8.1",
@@ -82,7 +83,7 @@
"stoppable": "^1.1.0",
"systeminformation": "^5.22.9",
"ts-command-line-args": "^2.5.1",
"uuid": "^9.0.1",
"uuid": "^10.0.0",
"ws": "^8.17.0",
"wtfnode": "^0.9.2",
"xhr2": "^0.2.1",
@@ -122,7 +123,7 @@
"@types/semver": "^7.5.8",
"@types/sendmail": "^1.4.7",
"@types/stoppable": "^1.1.3",
"@types/uuid": "^9.0.8",
"@types/uuid": "^10.0.0",
"@types/ws": "^8.5.10",
"@types/wtfnode": "^0.7.3",
"@typescript-eslint/eslint-plugin": "^7.9.0",
@@ -361,6 +362,18 @@
"graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0"
}
},
"node_modules/@apollo/server/node_modules/uuid": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
"integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/@apollo/usage-reporting-protobuf": {
"version": "4.1.1",
"license": "MIT",
@@ -3997,6 +4010,18 @@
"graphql": ">=0.11 <=16"
}
},
"node_modules/@nestjs/graphql/node_modules/uuid": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
"integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/@nestjs/graphql/node_modules/ws": {
"version": "8.16.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.16.0.tgz",
@@ -4104,6 +4129,18 @@
"luxon": "~3.4.0"
}
},
"node_modules/@nestjs/schedule/node_modules/uuid": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
"integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/@nestjs/testing": {
"version": "10.3.8",
"resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-10.3.8.tgz",
@@ -5424,9 +5461,9 @@
}
},
"node_modules/@types/uuid": {
"version": "9.0.8",
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.8.tgz",
"integrity": "sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==",
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
"integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==",
"dev": true
},
"node_modules/@types/validator": {
@@ -10150,6 +10187,31 @@
"license": "MIT",
"optional": true
},
"node_modules/filename-reserved-regex": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/filename-reserved-regex/-/filename-reserved-regex-3.0.0.tgz",
"integrity": "sha512-hn4cQfU6GOT/7cFHXBqeBg2TbrMBgdD0kcjLhvSQYYwm3s4B6cjvBfb7nBALJLAXqmU5xajSa7X2NnUud/VCdw==",
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/filenamify": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/filenamify/-/filenamify-6.0.0.tgz",
"integrity": "sha512-vqIlNogKeyD3yzrm0yhRMQg8hOVwYcYRfjEoODd49iCprMn4HL85gK3HcykQE53EPIpX3HcAbGA5ELQv216dAQ==",
"dependencies": {
"filename-reserved-regex": "^3.0.0"
},
"engines": {
"node": ">=16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/fill-range": {
"version": "7.0.1",
"license": "MIT",
@@ -18122,9 +18184,9 @@
}
},
"node_modules/uuid": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
"integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==",
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz",
"integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
@@ -19475,6 +19537,11 @@
"@graphql-typed-document-node/core": "^3.1.1",
"tslib": "^2.4.0"
}
},
"uuid": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
"integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="
}
}
},
@@ -21988,6 +22055,11 @@
"integrity": "sha512-F/i2xNIVbaEF2xWggID0X/UZQa2V8kqKDPO8hwmu53bVOcTL7uNkxnexeEgSCVxYBQUTUNEI8+e4LO1FOhKPKQ==",
"requires": {}
},
"uuid": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
"integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="
},
"ws": {
"version": "8.16.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.16.0.tgz",
@@ -22046,6 +22118,11 @@
"@types/luxon": "~3.4.0",
"luxon": "~3.4.0"
}
},
"uuid": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
"integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="
}
}
},
@@ -22982,9 +23059,9 @@
}
},
"@types/uuid": {
"version": "9.0.8",
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.8.tgz",
"integrity": "sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==",
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
"integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==",
"dev": true
},
"@types/validator": {
@@ -26171,6 +26248,19 @@
"version": "1.0.0",
"optional": true
},
"filename-reserved-regex": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/filename-reserved-regex/-/filename-reserved-regex-3.0.0.tgz",
"integrity": "sha512-hn4cQfU6GOT/7cFHXBqeBg2TbrMBgdD0kcjLhvSQYYwm3s4B6cjvBfb7nBALJLAXqmU5xajSa7X2NnUud/VCdw=="
},
"filenamify": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/filenamify/-/filenamify-6.0.0.tgz",
"integrity": "sha512-vqIlNogKeyD3yzrm0yhRMQg8hOVwYcYRfjEoODd49iCprMn4HL85gK3HcykQE53EPIpX3HcAbGA5ELQv216dAQ==",
"requires": {
"filename-reserved-regex": "^3.0.0"
}
},
"fill-range": {
"version": "7.0.1",
"requires": {
@@ -31444,9 +31534,9 @@
"version": "1.0.1"
},
"uuid": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
"integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz",
"integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ=="
},
"v8-compile-cache-lib": {
"version": "3.0.1",

View File

@@ -97,6 +97,7 @@
"dockerode": "^3.3.5",
"dotenv": "^16.4.5",
"express": "^4.19.2",
"filenamify": "^6.0.0",
"find-process": "^1.4.7",
"global-agent": "^3.0.0",
"graphql": "^16.8.1",
@@ -133,7 +134,7 @@
"stoppable": "^1.1.0",
"systeminformation": "^5.22.9",
"ts-command-line-args": "^2.5.1",
"uuid": "^9.0.1",
"uuid": "^10.0.0",
"ws": "^8.17.0",
"wtfnode": "^0.9.2",
"xhr2": "^0.2.1",
@@ -170,7 +171,7 @@
"@types/semver": "^7.5.8",
"@types/sendmail": "^1.4.7",
"@types/stoppable": "^1.1.3",
"@types/uuid": "^9.0.8",
"@types/uuid": "^10.0.0",
"@types/ws": "^8.5.10",
"@types/wtfnode": "^0.7.3",
"@typescript-eslint/eslint-plugin": "^7.9.0",

View File

@@ -4,32 +4,32 @@ import { safelySerializeObjectToIni } from '@app/core/utils/files/safe-ini-seria
import { Serializer } from 'multi-ini';
test('MultiIni breaks when serializing an object with a boolean inside', async () => {
const objectToSerialize = {
root: {
anonMode: false,
},
};
const serializer = new Serializer({ keep_quotes: false });
expect(serializer.serialize(objectToSerialize)).toMatchInlineSnapshot(`
const objectToSerialize = {
root: {
anonMode: false,
},
};
const serializer = new Serializer({ keep_quotes: false });
expect(serializer.serialize(objectToSerialize)).toMatchInlineSnapshot(`
"[root]
anonMode=false
"
`)
`);
});
test('MultiIni can safely serialize an object with a boolean inside', async () => {
const objectToSerialize = {
root: {
anonMode: false,
},
};
expect(safelySerializeObjectToIni(objectToSerialize)).toMatchInlineSnapshot(`
const objectToSerialize = {
root: {
anonMode: false,
},
};
expect(safelySerializeObjectToIni(objectToSerialize)).toMatchInlineSnapshot(`
"[root]
anonMode="false"
"
`);
const result = safelySerializeObjectToIni(objectToSerialize);
expect(parse(result)).toMatchInlineSnapshot(`
const result = safelySerializeObjectToIni(objectToSerialize);
expect(parse(result)).toMatchInlineSnapshot(`
{
"root": {
"anonMode": false,
@@ -37,3 +37,33 @@ test('MultiIni can safely serialize an object with a boolean inside', async () =
}
`);
});
test('Can serialize top-level fields', async () => {
const objectToSerialize = {
id: 'an-id',
message: 'hello-world',
number: 1,
float: 1.1,
flag: true,
flag2: false,
item: undefined,
missing: null,
empty: {},
};
const expected = `
"id=an-id
message=hello-world
number=1
float=1.1
flag="true"
flag2="false"
[empty]
"
`
.split('\n')
.map((line) => line.trim())
.join('\n');
expect(safelySerializeObjectToIni({ objectToSerialize })).toMatchInlineSnapshot(expected);
});

View File

@@ -11,6 +11,8 @@ export enum PUBSUB_CHANNEL {
DISPLAY = 'DISPLAY',
INFO = 'INFO',
NOTIFICATION = 'NOTIFICATION',
NOTIFICATION_ADDED = 'NOTIFICATION_ADDED',
NOTIFICATION_OVERVIEW = 'NOTIFICATION_OVERVIEW',
OWNER = 'OWNER',
SERVERS = 'SERVERS',
VMS = 'VMS',

View File

@@ -0,0 +1,8 @@
export interface NotificationIni {
timestamp: string;
event: string;
subject: string;
description: string;
importance: 'normal' | 'alert' | 'warning';
link?: string;
}

View File

@@ -0,0 +1,21 @@
import { Serializer } from 'multi-ini';
const serializer = new Serializer({ keep_quotes: false });
const replacer = (_, value: unknown) => {
if (typeof value === 'boolean') {
return value ? 'true' : 'false';
}
return value;
};
/**
*
* @param object Any object to serialize
* @returns String converted to ini with multi-ini, with any booleans string escaped to prevent a crash
*/
export const safelySerializeObjectToIni = (object: object): string => {
const safeObject = JSON.parse(JSON.stringify(object, replacer));
return serializer.serialize(safeObject);
};

View File

@@ -2,7 +2,7 @@
import * as Types from '@app/graphql/generated/api/types';
import { z } from 'zod'
import { AccessUrl, AccessUrlInput, AllowedOriginInput, ApiKey, ApiKeyResponse, ArrayType, ArrayCapacity, ArrayDisk, ArrayDiskFsColor, ArrayDiskStatus, ArrayDiskType, ArrayPendingState, ArrayState, Baseboard, Capacity, Case, Cloud, CloudResponse, Config, ConfigErrorState, Connect, ConnectSignInInput, ConnectUserInfoInput, ContainerHostConfig, ContainerMount, ContainerPort, ContainerPortType, ContainerState, Devices, Disk, DiskFsType, DiskInterfaceType, DiskPartition, DiskSmartStatus, Display, Docker, DockerContainer, DockerNetwork, DynamicRemoteAccessStatus, DynamicRemoteAccessType, EnableDynamicRemoteAccessInput, Flash, Gpu, Importance, Info, InfoApps, InfoCpu, InfoMemory, KeyFile, Me, MemoryFormFactor, MemoryLayout, MemoryType, MinigraphStatus, MinigraphqlResponse, Mount, Network, Node, Notification, NotificationFilter, NotificationInput, NotificationType, Os, Owner, ParityCheck, Partition, Pci, ProfileModel, Registration, RegistrationState, RelayResponse, RemoteAccess, Server, ServerStatus, Service, SetupRemoteAccessInput, Share, System, Temperature, Theme, URL_TYPE, UnassignedDevice, Uptime, Usb, User, UserAccount, Vars, Versions, VmDomain, VmState, Vms, WAN_ACCESS_TYPE, WAN_FORWARD_TYPE, Welcome, addApiKeyInput, addUserInput, arrayDiskInput, authenticateInput, deleteUserInput, mdState, registrationType, updateApikeyInput, usersInput } from '@app/graphql/generated/api/types'
import { AccessUrl, AccessUrlInput, AllowedOriginInput, ApiKey, ApiKeyResponse, ArrayType, ArrayCapacity, ArrayDisk, ArrayDiskFsColor, ArrayDiskStatus, ArrayDiskType, ArrayPendingState, ArrayState, Baseboard, Capacity, Case, Cloud, CloudResponse, Config, ConfigErrorState, Connect, ConnectSignInInput, ConnectUserInfoInput, ContainerHostConfig, ContainerMount, ContainerPort, ContainerPortType, ContainerState, Devices, Disk, DiskFsType, DiskInterfaceType, DiskPartition, DiskSmartStatus, Display, Docker, DockerContainer, DockerNetwork, DynamicRemoteAccessStatus, DynamicRemoteAccessType, EnableDynamicRemoteAccessInput, Flash, Gpu, Importance, Info, InfoApps, InfoCpu, InfoMemory, KeyFile, Me, MemoryFormFactor, MemoryLayout, MemoryType, MinigraphStatus, MinigraphqlResponse, Mount, Network, Node, Notification, NotificationCounts, NotificationData, NotificationFilter, NotificationOverview, NotificationType, Notifications, NotificationsdataArgs, Os, Owner, ParityCheck, Partition, Pci, ProfileModel, Registration, RegistrationState, RelayResponse, RemoteAccess, Server, ServerStatus, Service, SetupRemoteAccessInput, Share, System, Temperature, Theme, URL_TYPE, UnassignedDevice, Uptime, Usb, User, UserAccount, Vars, Versions, VmDomain, VmState, Vms, WAN_ACCESS_TYPE, WAN_FORWARD_TYPE, Welcome, addApiKeyInput, addUserInput, arrayDiskInput, authenticateInput, deleteUserInput, mdState, registrationType, updateApikeyInput, usersInput } from '@app/graphql/generated/api/types'
import { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core';
type Properties<T> = Required<{
@@ -601,6 +601,26 @@ export function NotificationSchema(): z.ZodObject<Properties<Notification>> {
})
}
export function NotificationCountsSchema(): z.ZodObject<Properties<NotificationCounts>> {
return z.object({
__typename: z.literal('NotificationCounts').optional(),
alert: z.number(),
info: z.number(),
total: z.number(),
warning: z.number()
})
}
export function NotificationDataSchema(): z.ZodObject<Properties<NotificationData>> {
return z.object({
description: z.string(),
importance: ImportanceSchema,
link: z.string().nullish(),
subject: z.string(),
title: z.string()
})
}
export function NotificationFilterSchema(): z.ZodObject<Properties<NotificationFilter>> {
return z.object({
importance: ImportanceSchema.nullish(),
@@ -610,16 +630,26 @@ export function NotificationFilterSchema(): z.ZodObject<Properties<NotificationF
})
}
export function NotificationInputSchema(): z.ZodObject<Properties<NotificationInput>> {
export function NotificationOverviewSchema(): z.ZodObject<Properties<NotificationOverview>> {
return z.object({
description: z.string().nullish(),
__typename: z.literal('NotificationOverview').optional(),
archive: NotificationCountsSchema(),
unread: NotificationCountsSchema()
})
}
export function NotificationsSchema(): z.ZodObject<Properties<Notifications>> {
return z.object({
__typename: z.literal('Notifications').optional(),
data: z.array(NotificationSchema()),
id: z.string(),
importance: ImportanceSchema,
link: z.string().nullish(),
subject: z.string(),
timestamp: z.string().nullish(),
title: z.string(),
type: NotificationTypeSchema
overview: NotificationOverviewSchema()
})
}
export function NotificationsdataArgsSchema(): z.ZodObject<Properties<NotificationsdataArgs>> {
return z.object({
filter: NotificationFilterSchema()
})
}

View File

@@ -622,11 +622,14 @@ export type Mutation = {
addDiskToArray?: Maybe<ArrayType>;
/** Add a new user */
addUser?: Maybe<User>;
archiveNotification: NotificationOverview;
/** Cancel parity check */
cancelParityCheck?: Maybe<Scalars['JSON']['output']>;
clearArrayDiskStatistics?: Maybe<Scalars['JSON']['output']>;
connectSignIn: Scalars['Boolean']['output'];
connectSignOut: Scalars['Boolean']['output'];
createNotification: Notification;
deleteNotification: NotificationOverview;
/** Delete a user */
deleteUser?: Maybe<User>;
enableDynamicRemoteAccess: Scalars['Boolean']['output'];
@@ -651,6 +654,7 @@ export type Mutation = {
/** Stop array */
stopArray?: Maybe<ArrayType>;
unmountArrayDisk?: Maybe<Disk>;
unreadNotification: NotificationOverview;
/** Update an existing API key */
updateApikey?: Maybe<ApiKey>;
};
@@ -672,6 +676,11 @@ export type MutationaddUserArgs = {
};
export type MutationarchiveNotificationArgs = {
id: Scalars['String']['input'];
};
export type MutationclearArrayDiskStatisticsArgs = {
id: Scalars['ID']['input'];
};
@@ -682,6 +691,17 @@ export type MutationconnectSignInArgs = {
};
export type MutationcreateNotificationArgs = {
input: NotificationData;
};
export type MutationdeleteNotificationArgs = {
id: Scalars['String']['input'];
type: NotificationType;
};
export type MutationdeleteUserArgs = {
input: deleteUserInput;
};
@@ -734,6 +754,11 @@ export type MutationunmountArrayDiskArgs = {
};
export type MutationunreadNotificationArgs = {
id: Scalars['String']['input'];
};
export type MutationupdateApikeyArgs = {
input?: InputMaybe<updateApikeyInput>;
name: Scalars['String']['input'];
@@ -761,19 +786,35 @@ export type Node = {
id: Scalars['ID']['output'];
};
export type Notification = {
export type Notification = Node & {
__typename?: 'Notification';
description: Scalars['String']['output'];
id: Scalars['ID']['output'];
importance: Importance;
link?: Maybe<Scalars['String']['output']>;
subject: Scalars['String']['output'];
/** ISO Timestamp for when the notification occurred */
/** ISO Timestamp for when the notification occurred */
timestamp?: Maybe<Scalars['String']['output']>;
title: Scalars['String']['output'];
type: NotificationType;
};
export type NotificationCounts = {
__typename?: 'NotificationCounts';
alert: Scalars['Int']['output'];
info: Scalars['Int']['output'];
total: Scalars['Int']['output'];
warning: Scalars['Int']['output'];
};
export type NotificationData = {
description: Scalars['String']['input'];
importance: Importance;
link?: InputMaybe<Scalars['String']['input']>;
subject: Scalars['String']['input'];
title: Scalars['String']['input'];
};
export type NotificationFilter = {
importance?: InputMaybe<Importance>;
limit: Scalars['Int']['input'];
@@ -781,23 +822,29 @@ export type NotificationFilter = {
type?: InputMaybe<NotificationType>;
};
export type NotificationInput = {
description?: InputMaybe<Scalars['String']['input']>;
id: Scalars['ID']['input'];
importance: Importance;
link?: InputMaybe<Scalars['String']['input']>;
subject: Scalars['String']['input'];
timestamp?: InputMaybe<Scalars['String']['input']>;
title: Scalars['String']['input'];
type: NotificationType;
export type NotificationOverview = {
__typename?: 'NotificationOverview';
archive: NotificationCounts;
unread: NotificationCounts;
};
export enum NotificationType {
ARCHIVED = 'ARCHIVED',
RESTORED = 'RESTORED',
ARCHIVE = 'ARCHIVE',
UNREAD = 'UNREAD'
}
export type Notifications = Node & {
__typename?: 'Notifications';
data: Array<Notification>;
id: Scalars['ID']['output'];
overview: NotificationOverview;
};
export type NotificationsdataArgs = {
filter: NotificationFilter;
};
export type Os = {
__typename?: 'Os';
arch?: Maybe<Scalars['String']['output']>;
@@ -940,7 +987,7 @@ export type Query = {
/** Current user account */
me?: Maybe<Me>;
network?: Maybe<Network>;
notifications: Array<Notification>;
notifications: Notifications;
online?: Maybe<Scalars['Boolean']['output']>;
owner?: Maybe<Owner>;
parityHistory?: Maybe<Array<Maybe<ParityCheck>>>;
@@ -982,11 +1029,6 @@ export type QuerydockerNetworksArgs = {
};
export type QuerynotificationsArgs = {
filter: NotificationFilter;
};
export type QueryuserArgs = {
id: Scalars['ID']['input'];
};
@@ -1136,6 +1178,7 @@ export type Subscription = {
info: Info;
me?: Maybe<Me>;
notificationAdded: Notification;
notificationsOverview: NotificationOverview;
online: Scalars['Boolean']['output'];
owner: Owner;
parityHistory: ParityCheck;
@@ -1650,7 +1693,7 @@ export type DirectiveResolverFn<TResult = {}, TParent = {}, TContext = {}, TArgs
/** Mapping of interface types */
export type ResolversInterfaceTypes<RefType extends Record<string, unknown>> = ResolversObject<{
Node: ( ArrayType ) | ( Config ) | ( Connect ) | ( Docker ) | ( Info ) | ( Network ) | ( Service ) | ( Vars );
Node: ( ArrayType ) | ( Config ) | ( Connect ) | ( Docker ) | ( Info ) | ( Network ) | ( Notification ) | ( Notifications ) | ( Service ) | ( Vars );
UserAccount: ( Me ) | ( User );
}>;
@@ -1723,9 +1766,12 @@ export type ResolversTypes = ResolversObject<{
Network: ResolverTypeWrapper<Network>;
Node: ResolverTypeWrapper<ResolversInterfaceTypes<ResolversTypes>['Node']>;
Notification: ResolverTypeWrapper<Notification>;
NotificationCounts: ResolverTypeWrapper<NotificationCounts>;
NotificationData: NotificationData;
NotificationFilter: NotificationFilter;
NotificationInput: NotificationInput;
NotificationOverview: ResolverTypeWrapper<NotificationOverview>;
NotificationType: NotificationType;
Notifications: ResolverTypeWrapper<Notifications>;
Os: ResolverTypeWrapper<Os>;
Owner: ResolverTypeWrapper<Owner>;
ParityCheck: ResolverTypeWrapper<ParityCheck>;
@@ -1828,8 +1874,11 @@ export type ResolversParentTypes = ResolversObject<{
Network: Network;
Node: ResolversInterfaceTypes<ResolversParentTypes>['Node'];
Notification: Notification;
NotificationCounts: NotificationCounts;
NotificationData: NotificationData;
NotificationFilter: NotificationFilter;
NotificationInput: NotificationInput;
NotificationOverview: NotificationOverview;
Notifications: Notifications;
Os: Os;
Owner: Owner;
ParityCheck: ParityCheck;
@@ -2267,10 +2316,13 @@ export type MutationResolvers<ContextType = Context, ParentType extends Resolver
addApikey?: Resolver<Maybe<ResolversTypes['ApiKey']>, ParentType, ContextType, RequireFields<MutationaddApikeyArgs, 'name'>>;
addDiskToArray?: Resolver<Maybe<ResolversTypes['Array']>, ParentType, ContextType, Partial<MutationaddDiskToArrayArgs>>;
addUser?: Resolver<Maybe<ResolversTypes['User']>, ParentType, ContextType, RequireFields<MutationaddUserArgs, 'input'>>;
archiveNotification?: Resolver<ResolversTypes['NotificationOverview'], ParentType, ContextType, RequireFields<MutationarchiveNotificationArgs, 'id'>>;
cancelParityCheck?: Resolver<Maybe<ResolversTypes['JSON']>, ParentType, ContextType>;
clearArrayDiskStatistics?: Resolver<Maybe<ResolversTypes['JSON']>, ParentType, ContextType, RequireFields<MutationclearArrayDiskStatisticsArgs, 'id'>>;
connectSignIn?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType, RequireFields<MutationconnectSignInArgs, 'input'>>;
connectSignOut?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType>;
createNotification?: Resolver<ResolversTypes['Notification'], ParentType, ContextType, RequireFields<MutationcreateNotificationArgs, 'input'>>;
deleteNotification?: Resolver<ResolversTypes['NotificationOverview'], ParentType, ContextType, RequireFields<MutationdeleteNotificationArgs, 'id' | 'type'>>;
deleteUser?: Resolver<Maybe<ResolversTypes['User']>, ParentType, ContextType, RequireFields<MutationdeleteUserArgs, 'input'>>;
enableDynamicRemoteAccess?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType, RequireFields<MutationenableDynamicRemoteAccessArgs, 'input'>>;
getApiKey?: Resolver<Maybe<ResolversTypes['ApiKey']>, ParentType, ContextType, RequireFields<MutationgetApiKeyArgs, 'name'>>;
@@ -2287,6 +2339,7 @@ export type MutationResolvers<ContextType = Context, ParentType extends Resolver
startParityCheck?: Resolver<Maybe<ResolversTypes['JSON']>, ParentType, ContextType, Partial<MutationstartParityCheckArgs>>;
stopArray?: Resolver<Maybe<ResolversTypes['Array']>, ParentType, ContextType>;
unmountArrayDisk?: Resolver<Maybe<ResolversTypes['Disk']>, ParentType, ContextType, RequireFields<MutationunmountArrayDiskArgs, 'id'>>;
unreadNotification?: Resolver<ResolversTypes['NotificationOverview'], ParentType, ContextType, RequireFields<MutationunreadNotificationArgs, 'id'>>;
updateApikey?: Resolver<Maybe<ResolversTypes['ApiKey']>, ParentType, ContextType, RequireFields<MutationupdateApikeyArgs, 'name'>>;
}>;
@@ -2309,7 +2362,7 @@ export type NetworkResolvers<ContextType = Context, ParentType extends Resolvers
}>;
export type NodeResolvers<ContextType = Context, ParentType extends ResolversParentTypes['Node'] = ResolversParentTypes['Node']> = ResolversObject<{
__resolveType: TypeResolveFn<'Array' | 'Config' | 'Connect' | 'Docker' | 'Info' | 'Network' | 'Service' | 'Vars', ParentType, ContextType>;
__resolveType: TypeResolveFn<'Array' | 'Config' | 'Connect' | 'Docker' | 'Info' | 'Network' | 'Notification' | 'Notifications' | 'Service' | 'Vars', ParentType, ContextType>;
id?: Resolver<ResolversTypes['ID'], ParentType, ContextType>;
}>;
@@ -2325,6 +2378,27 @@ export type NotificationResolvers<ContextType = Context, ParentType extends Reso
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
}>;
export type NotificationCountsResolvers<ContextType = Context, ParentType extends ResolversParentTypes['NotificationCounts'] = ResolversParentTypes['NotificationCounts']> = ResolversObject<{
alert?: Resolver<ResolversTypes['Int'], ParentType, ContextType>;
info?: Resolver<ResolversTypes['Int'], ParentType, ContextType>;
total?: Resolver<ResolversTypes['Int'], ParentType, ContextType>;
warning?: Resolver<ResolversTypes['Int'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
}>;
export type NotificationOverviewResolvers<ContextType = Context, ParentType extends ResolversParentTypes['NotificationOverview'] = ResolversParentTypes['NotificationOverview']> = ResolversObject<{
archive?: Resolver<ResolversTypes['NotificationCounts'], ParentType, ContextType>;
unread?: Resolver<ResolversTypes['NotificationCounts'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
}>;
export type NotificationsResolvers<ContextType = Context, ParentType extends ResolversParentTypes['Notifications'] = ResolversParentTypes['Notifications']> = ResolversObject<{
data?: Resolver<Array<ResolversTypes['Notification']>, ParentType, ContextType, RequireFields<NotificationsdataArgs, 'filter'>>;
id?: Resolver<ResolversTypes['ID'], ParentType, ContextType>;
overview?: Resolver<ResolversTypes['NotificationOverview'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
}>;
export type OsResolvers<ContextType = Context, ParentType extends ResolversParentTypes['Os'] = ResolversParentTypes['Os']> = ResolversObject<{
arch?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
build?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
@@ -2462,7 +2536,7 @@ export type QueryResolvers<ContextType = Context, ParentType extends ResolversPa
info?: Resolver<Maybe<ResolversTypes['Info']>, ParentType, ContextType>;
me?: Resolver<Maybe<ResolversTypes['Me']>, ParentType, ContextType>;
network?: Resolver<Maybe<ResolversTypes['Network']>, ParentType, ContextType>;
notifications?: Resolver<Array<ResolversTypes['Notification']>, ParentType, ContextType, RequireFields<QuerynotificationsArgs, 'filter'>>;
notifications?: Resolver<ResolversTypes['Notifications'], ParentType, ContextType>;
online?: Resolver<Maybe<ResolversTypes['Boolean']>, ParentType, ContextType>;
owner?: Resolver<Maybe<ResolversTypes['Owner']>, ParentType, ContextType>;
parityHistory?: Resolver<Maybe<Array<Maybe<ResolversTypes['ParityCheck']>>>, ParentType, ContextType>;
@@ -2557,6 +2631,7 @@ export type SubscriptionResolvers<ContextType = Context, ParentType extends Reso
info?: SubscriptionResolver<ResolversTypes['Info'], "info", ParentType, ContextType>;
me?: SubscriptionResolver<Maybe<ResolversTypes['Me']>, "me", ParentType, ContextType>;
notificationAdded?: SubscriptionResolver<ResolversTypes['Notification'], "notificationAdded", ParentType, ContextType>;
notificationsOverview?: SubscriptionResolver<ResolversTypes['NotificationOverview'], "notificationsOverview", ParentType, ContextType>;
online?: SubscriptionResolver<ResolversTypes['Boolean'], "online", ParentType, ContextType>;
owner?: SubscriptionResolver<ResolversTypes['Owner'], "owner", ParentType, ContextType>;
parityHistory?: SubscriptionResolver<ResolversTypes['ParityCheck'], "parityHistory", ParentType, ContextType>;
@@ -2912,6 +2987,9 @@ export type Resolvers<ContextType = Context> = ResolversObject<{
Network?: NetworkResolvers<ContextType>;
Node?: NodeResolvers<ContextType>;
Notification?: NotificationResolvers<ContextType>;
NotificationCounts?: NotificationCountsResolvers<ContextType>;
NotificationOverview?: NotificationOverviewResolvers<ContextType>;
Notifications?: NotificationsResolvers<ContextType>;
Os?: OsResolvers<ContextType>;
Owner?: OwnerResolvers<ContextType>;
ParityCheck?: ParityCheckResolvers<ContextType>;

View File

@@ -201,7 +201,9 @@ export function RemoteAccessInputSchema(): z.ZodObject<Properties<RemoteAccessIn
export function RemoteGraphQLClientInputSchema(): z.ZodObject<Properties<RemoteGraphQLClientInput>> {
return z.object({
apiKey: z.string(),
body: z.string()
body: z.string(),
timeout: z.number().nullish(),
ttl: z.number().nullish()
})
}

View File

@@ -1,18 +1,6 @@
enum NotificationType {
UNREAD
ARCHIVED
RESTORED
}
input NotificationInput {
id: ID!
title: String!
subject: String!
description: String
importance: Importance!
link: String
type: NotificationType!
timestamp: String
ARCHIVE
}
input NotificationFilter {
@@ -23,11 +11,19 @@ input NotificationFilter {
}
type Query {
notifications(filter: NotificationFilter!): [Notification!]!
notifications: Notifications!
}
type Mutation {
createNotification(input: NotificationData!): Notification!
deleteNotification(id: String!, type: NotificationType!): NotificationOverview!
archiveNotification(id: String!): NotificationOverview!
unreadNotification(id: String!): NotificationOverview!
}
type Subscription {
notificationAdded: Notification!
notificationsOverview: NotificationOverview!
}
enum Importance {
@@ -36,7 +32,13 @@ enum Importance {
WARNING
}
type Notification {
type Notifications implements Node {
id: ID!
overview: NotificationOverview!
data(filter: NotificationFilter!): [Notification!]!
}
type Notification implements Node {
id: ID!
title: String!
subject: String!
@@ -44,6 +46,28 @@ type Notification {
importance: Importance!
link: String
type: NotificationType!
""" ISO Timestamp for when the notification occurred """
"""
ISO Timestamp for when the notification occurred
"""
timestamp: String
}
input NotificationData {
title: String!
subject: String!
description: String!
importance: Importance!
link: String
}
type NotificationOverview {
unread: NotificationCounts!
archive: NotificationCounts!
}
type NotificationCounts {
info: Int!
warning: Int!
alert: Int!
total: Int!
}

View File

@@ -4,7 +4,6 @@ import {
Importance,
NotificationType,
type Notification,
type NotificationInput,
} from '@app/graphql/generated/api/types';
import { NotificationSchema } from '@app/graphql/generated/api/operations';
import { type RootState, type AppDispatch } from '@app/store/index';
@@ -14,6 +13,7 @@ import {
createSlice,
} from '@reduxjs/toolkit';
import { PUBSUB_CHANNEL, pubsub } from '@app/core/pubsub';
import { NotificationIni } from '@app/core/types/states/notification';
interface NotificationState {
notifications: Record<string, Notification>;
@@ -23,15 +23,6 @@ const notificationInitialState: NotificationState = {
notifications: {},
};
interface NotificationIni {
timestamp: string;
event: string;
subject: string;
description: string;
importance: 'normal' | 'alert' | 'warning';
link?: string;
}
const fileImportanceToGqlImportance = (
importance: NotificationIni['importance']
): Importance => {
@@ -64,7 +55,7 @@ export const loadNotification = createAsyncThunk<
type: 'ini',
});
const notification: NotificationInput = {
const notification: Notification = {
id: path,
title: notificationFile.event,
subject: notificationFile.subject,

View File

@@ -20,6 +20,7 @@ import { SharesResolver } from './shares/shares.resolver';
import { ConnectResolver } from './connect/connect.resolver';
import { ConnectService } from './connect/connect.service';
@Module({
imports: [
ResolversModule,

View File

@@ -1,51 +1,102 @@
import { type NotificationFilter } from '@app/graphql/generated/api/types';
import { getters } from '@app/store/index';
import { Query, Resolver, Args, Subscription } from '@nestjs/graphql';
import { GraphQLError } from 'graphql';
import type {
NotificationData,
NotificationType,
NotificationFilter,
} from '@app/graphql/generated/api/types';
import { Args, Mutation, Query, ResolveField, Resolver, Subscription } from '@nestjs/graphql';
import { UseRoles } from 'nest-access-control';
import { PUBSUB_CHANNEL, createSubscription } from '@app/core/pubsub';
import { createSubscription, PUBSUB_CHANNEL } from '@app/core/pubsub';
import { NotificationsService } from './notifications.service';
import { getServerIdentifier } from '@app/core/utils/server-identifier';
@Resolver()
@Resolver('Notifications')
export class NotificationsResolver {
constructor(readonly notificationsService: NotificationsService) {}
/**============================================
* Queries
*=============================================**/
@Query()
@UseRoles({
resource: 'notifications',
action: 'read',
possession: 'any',
})
public async notifications(
@Args('filter')
{ limit, importance, type, offset }: NotificationFilter
) {
if (limit > 50) {
throw new GraphQLError('Limit must be less than 50');
}
return Object.values(getters.notifications().notifications)
.filter((notification) => {
if (importance && importance !== notification.importance) {
return false;
}
if (type && type !== notification.type) {
return false;
}
return true;
})
.sort(
(a, b) =>
new Date(b.timestamp ?? 0).getTime() -
new Date(a.timestamp ?? 0).getTime()
)
.slice(offset, limit + offset);
public async notifications() {
return {
id: getServerIdentifier('notifications'),
};
}
@Subscription('notificationAdded')
@ResolveField()
public async overview() {
return await this.notificationsService.getOverview();
}
@ResolveField()
public async data(
@Args('filter')
filters: NotificationFilter
) {
return await this.notificationsService.getNotifications(filters);
}
/**============================================
* Mutations
*=============================================**/
/** Creates a new notification record */
@Mutation()
public createNotification(
@Args('input')
data: NotificationData
) {
return this.notificationsService.createNotification(data);
}
@Mutation()
public async deleteNotification(
@Args('id')
id: string,
@Args('type')
type: NotificationType
) {
const { overview } = await this.notificationsService.deleteNotification({ id, type });
return overview;
}
@Mutation()
public archiveNotification(@Args('id') id: string) {
return this.notificationsService.archiveNotification({ id });
}
@Mutation()
public unreadNotification(@Args('id') id: string) {
return this.notificationsService.markAsUnread({ id });
}
/**============================================
* Subscriptions
*=============================================**/
@Subscription()
@UseRoles({
resource: 'notifications',
action: 'read',
possession: 'any',
})
async notificationAdded() {
return createSubscription(PUBSUB_CHANNEL.NOTIFICATION);
return createSubscription(PUBSUB_CHANNEL.NOTIFICATION_ADDED);
}
@Subscription()
@UseRoles({
resource: 'notifications',
action: 'read',
possession: 'any',
})
async notificationsOverview() {
return createSubscription(PUBSUB_CHANNEL.NOTIFICATION_OVERVIEW);
}
}

View File

@@ -0,0 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing';
import { NotificationsService } from './notifications.service';
describe('NotificationsService', () => {
let service: NotificationsService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [NotificationsService],
}).compile();
service = module.get<NotificationsService>(NotificationsService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});

View File

@@ -0,0 +1,447 @@
import { NotificationIni } from '@app/core/types/states/notification';
import { parseConfig } from '@app/core/utils/misc/parse-config';
import { NotificationSchema } from '@app/graphql/generated/api/operations';
import {
Importance,
NotificationType,
type Notification,
NotificationFilter,
NotificationOverview,
NotificationData,
} from '@app/graphql/generated/api/types';
import { getters } from '@app/store';
import { Injectable } from '@nestjs/common';
import { readdir, rename, unlink, writeFile } from 'fs/promises';
import { join } from 'path';
import { Logger } from '@nestjs/common';
import { isFulfilled, isRejected } from '@app/utils';
import { FSWatcher, watch } from 'chokidar';
import { FileLoadStatus } from '@app/store/types';
import { pubsub, PUBSUB_CHANNEL } from '@app/core/pubsub';
import { fileExists } from '@app/core/utils/files/file-exists';
// import { safelySerializeObjectToIni as encodeIni } from '@app/core/utils/files/safe-ini-serializer';
import { encode as encodeIni } from 'ini';
import { v7 as uuidv7 } from 'uuid';
@Injectable()
export class NotificationsService {
private logger = new Logger(NotificationsService.name);
private static watcher: FSWatcher | null = null;
private static overview: NotificationOverview = {
unread: {
alert: 0,
info: 0,
warning: 0,
total: 0,
},
archive: {
alert: 0,
info: 0,
warning: 0,
total: 0,
},
};
constructor() {
NotificationsService.watcher = this.getNotificationsWatcher();
}
/**
* Returns the paths to the notification directories.
*
* @returns an object with the:
* - base path
* - path to the unread notifications
* - path to the archived notifications
*/
private paths(): Record<'basePath' | NotificationType, string> {
const basePath = getters.dynamix().notify!.path;
const makePath = (type: NotificationType) => join(basePath, type.toLowerCase());
return {
basePath,
[NotificationType.UNREAD]: makePath(NotificationType.UNREAD),
[NotificationType.ARCHIVE]: makePath(NotificationType.ARCHIVE),
};
}
/**------------------------------------------------------------------------
* Subscription Events
*
* Sets up a notification watcher, which hooks up notification lifecycle
* events to their event handlers.
*------------------------------------------------------------------------**/
private getNotificationsWatcher() {
const { notify, status } = getters.dynamix();
if (status === FileLoadStatus.LOADED && notify?.path) {
if (NotificationsService.watcher) {
return NotificationsService.watcher;
}
NotificationsService.watcher = watch(notify.path, {}).on('add', (path) => {
void this.handleNotificationAdd(path).catch((e) => this.logger.error(e));
});
// Do we even want to listen to removals?
// .on('unlink', (path) => {
// void this.handleNotificationRemoval(path).catch((e) =>
// this.logger.error(e)
// );
// });
return NotificationsService.watcher;
}
return null;
}
private async handleNotificationAdd(path: string) {
// The path looks like /{notification base path}/{type}/{notification id}
const type = path.includes('/unread/') ? NotificationType.UNREAD : NotificationType.ARCHIVE;
this.logger.debug(`Adding ${type} Notification: ${path}`);
const notification = await this.loadNotificationFile(path, NotificationType[type]);
NotificationsService.overview[type.toLowerCase()][notification.importance.toLowerCase()] += 1;
NotificationsService.overview[type.toLowerCase()]['total'] += 1;
pubsub.publish(PUBSUB_CHANNEL.NOTIFICATION_ADDED, {
notificationAdded: notification,
});
pubsub.publish(PUBSUB_CHANNEL.NOTIFICATION_OVERVIEW, {
notificationsOverview: NotificationsService.overview,
});
}
private async removeFromOverview(notification: Notification) {
const { type, id, importance } = notification;
this.logger.debug(`Removing ${type} Notification: ${id}`);
NotificationsService.overview[type.toLowerCase()][importance.toLowerCase()] -= 1;
NotificationsService.overview[type.toLowerCase()]['total'] -= 1;
return pubsub.publish(PUBSUB_CHANNEL.NOTIFICATION_OVERVIEW, {
notificationsOverview: NotificationsService.overview,
});
}
public async getOverview(): Promise<NotificationOverview> {
return NotificationsService.overview;
}
/**------------------------------------------------------------------------
* CRUD: Creating Notifications
*------------------------------------------------------------------------**/
public async createNotification(data: NotificationData): Promise<Notification> {
const id: string = await this.makeNotificationId(data.title);
const path = join(this.paths().UNREAD, id);
const fileData = this.makeNotificationFileData(data);
this.logger.debug(`[createNotification] FileData: ${JSON.stringify(fileData, null, 4)}`);
const ini = encodeIni(fileData);
// this.logger.debug(`[createNotification] INI: ${ini}`);
await writeFile(path, ini);
// await this.addToOverview(notification);
// make sure both NOTIFICATION_ADDED and NOTIFICATION_OVERVIEW are fired
return { ...data, id, type: NotificationType.UNREAD, timestamp: fileData.timestamp };
}
private async makeNotificationId(eventTitle: string, replacement = '_'): Promise<string> {
const { default: filenamify } = await import('filenamify');
const allWhitespace = /\s+/g;
// replace symbols & whitespace with underscores
const prefix = filenamify(eventTitle, { replacement }).replace(allWhitespace, replacement);
/**-----------------------
* Why UUIDv7?
*
* So we can sort notifications chronologically
* without having to read the contents of the files.
*
* This makes it more annoying to manually distinguish id's because
* the start of the uuid encodes the timestamp, and the random bits
* are at the end, so the first few chars of each uuid might be relatively common.
*
* See https://uuid7.com/ for an overview of UUIDv7
* See https://park.is/blog_posts/20240803_extracting_timestamp_from_uuid_v7/ for how
* timestamps are encoded
*------------------------**/
return `${prefix}_${uuidv7()}.notify`;
}
private makeNotificationFileData(notification: NotificationData): NotificationIni {
const { title, subject, description, link, importance } = notification;
const secondsSinceUnixEpoch = Math.floor(Date.now() / 1_000);
const data: NotificationIni = {
timestamp: secondsSinceUnixEpoch.toString(),
event: title,
subject,
description,
importance: this.gqlImportanceToFileImportance(importance),
};
// 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
if (link) {
data.link = link;
}
return data;
}
/**------------------------------------------------------------------------
* CRUD: Deleting Notifications
*------------------------------------------------------------------------**/
public async deleteNotification({ id, type }: Pick<Notification, 'id' | 'type'>) {
const path = join(this.paths()[type], id);
// we don't want to update the overview stats if the deletion (unlink) fails
// so we do the file system ops first
const notification = await this.loadNotificationFile(path, type);
await unlink(path);
await this.removeFromOverview(notification);
// return both the overview & the deleted notification
// this helps us reference the deleted notification in-memory if we want
return { notification, overview: NotificationsService.overview };
}
/**------------------------------------------------------------------------
* CRUD: Updating Notifications
*------------------------------------------------------------------------**/
public async archiveNotification({ id }: Pick<Notification, 'id'>): Promise<NotificationOverview> {
const { UNREAD, ARCHIVE } = this.paths();
const unreadPath = join(UNREAD, id);
const archivePath = join(ARCHIVE, id);
/**-----------------------
* Why we use a snapshot
*
* An implicit update to `overview` creates a race condition:
* it might be missing changes from the 'add' event (i.e. incrementing the notification's new category).
*
* So, we use & modify a snapshot of the overview to make sure we're returning accurate
* data to the client.
*------------------------**/
const archiveSnapshot = structuredClone(NotificationsService.overview.archive);
// We expect to only archive 'unread' notifications, but it's possible that the notification
// has already been archived or deleted (e.g. retry logic, spike in network latency).
if (!(await fileExists(unreadPath))) {
this.logger.warn(`[archiveNotification] Could not find notification in unreads: ${id}`);
return NotificationsService.overview;
}
const notification = await this.loadNotificationFile(unreadPath, NotificationType.UNREAD);
await rename(unreadPath, archivePath);
await this.removeFromOverview(notification);
archiveSnapshot.total += 1;
archiveSnapshot[notification.importance.toLowerCase()] += 1;
/**-----------------------
* Event & PubSub logic
*
* We assume `rename` kicks off 'unlink' and 'add' events
* in the chokidar file watcher.
*
* We assume the 'add' handler publishes to
* NOTIFICATION_ADDED & NOTIFICATION_OVERVIEW,
* and that no pubsub or overview updates occur upon 'unlink'.
*
* Thus, we explicitly update our state & pubsub via `removeFromOverview`
* and implicitly expect it to be updated via our filesystem changes.
*
* The reasons for this discrepancy are:
* - Backwards compatibility: not every notification will be created through this API,
* so we track state by watching the store (i.e. the file system).
*
* - Technical Limitations: By the time the unlink event fires, the notification file
* can no longer be read. This means we can only track overview totals accurately;
* to track other stats, we have to update them manually, prior to file deletion.
*------------------------**/
return {
...NotificationsService.overview,
archive: archiveSnapshot,
};
}
public async markAsUnread({ id }: Pick<Notification, 'id'>): Promise<NotificationOverview> {
const { UNREAD, ARCHIVE } = this.paths();
const unreadPath = join(UNREAD, id);
const archivePath = join(ARCHIVE, id);
// see `archiveNotification` for why we use a snapshot
// it's b/c of a race condition
const unreadSnapshot = structuredClone(NotificationsService.overview.unread);
if (!(await fileExists(archivePath))) {
this.logger.warn(`[markAsUnread] Could not find notification in archive: ${id}`);
return NotificationsService.overview;
}
const notification = await this.loadNotificationFile(archivePath, NotificationType.ARCHIVE);
await rename(archivePath, unreadPath);
// see `archiveNotification` for why there are 2 ways of updating our overview state,
// and the implications it has for updating notifications.
await this.removeFromOverview(notification);
unreadSnapshot.total += 1;
unreadSnapshot[notification.importance.toLowerCase()] += 1;
return {
...NotificationsService.overview,
unread: unreadSnapshot,
};
}
/**------------------------------------------------------------------------
* CRUD: Reading Notifications
*------------------------------------------------------------------------**/
/**
* Retrieves all notifications from the file system.
* @param filters Filters to apply to the notifications
* @returns An array of all notifications in the system.
*/
public async getNotifications(filters: NotificationFilter): Promise<Notification[]> {
this.logger.debug('Getting Notifications');
const { ARCHIVE, UNREAD } = this.paths();
const directoryPath = filters.type === NotificationType.ARCHIVE ? ARCHIVE : UNREAD;
const unreadFiles = await this.getFilesInFolder(directoryPath);
const [notifications] = await this.getNotificationsFromPaths(unreadFiles, filters);
return notifications;
}
/**
* Given a path to a folder, returns the full (absolute) paths of the folder's top-level contents.
* @param folderPath The path of the folder to read.
* @returns A list of absolute paths of all the files and contents in the folder.
*/
private async getFilesInFolder(folderPath: string): Promise<string[]> {
const contents = await readdir(folderPath);
return contents.map((content) => join(folderPath, content));
}
/**
* Given a an array of files, reads and filters all the files in the directory,
* and attempts to parse each file as a Notification.
*
* Returns an array of two elements:
* - the first element is an array of successfully parsed and filtered Notifications,
* - the second element is an array of errors for any files that failed parsing.
*
* @param files the files (absolute paths) to read
* @param filters the filters to apply to the notifications
* @returns an array of two elements: [successes, errors/failures]
*/
private async getNotificationsFromPaths(
files: string[],
filters: NotificationFilter
): Promise<[Notification[], unknown[]]> {
const { limit, importance, type, offset } = filters;
const fileReads = files
.slice(offset, limit + offset)
.map((file) => this.loadNotificationFile(file, type ?? NotificationType.UNREAD));
const results = await Promise.allSettled(fileReads);
return [
results
.filter(isFulfilled)
.map((result) => result.value)
.filter((notification) => {
if (importance && importance !== notification.importance) {
return false;
}
if (type && type !== notification.type) {
return false;
}
return true;
})
.sort(
(a, b) => new Date(b.timestamp ?? 0).getTime() - new Date(a.timestamp ?? 0).getTime()
),
results.filter(isRejected).map((result) => result.reason),
];
}
/**
* Loads a notification file from disk, parses it to a Notification object, and
* validates the object against the NotificationSchema.
*
* @param path The path to the notification file on disk.
* @param type The type of the notification that is being loaded.
* @returns A parsed Notification object, or throws an error if the object is invalid.
* @throws An error if the object is invalid (doesn't conform to the graphql NotificationSchema).
*/
private async loadNotificationFile(path: string, type: NotificationType): Promise<Notification> {
const notificationFile = parseConfig<NotificationIni>({
filePath: path,
type: 'ini',
});
this.logger.debug(
`Loaded notification ini file from ${path}: ${JSON.stringify(notificationFile, null, 4)}`
);
const notification: Notification = {
id: path,
title: notificationFile.event,
subject: notificationFile.subject,
description: notificationFile.description ?? '',
importance: this.fileImportanceToGqlImportance(notificationFile.importance),
link: notificationFile.link,
timestamp: this.parseNotificationDateToIsoDate(notificationFile.timestamp),
type,
};
// The contents of the file, and therefore the notification, may not always be a valid notification.
// so we parse it through the schema to make sure it is
return NotificationSchema().parse(notification);
}
private fileImportanceToGqlImportance(importance: NotificationIni['importance']): Importance {
switch (importance) {
case 'alert':
return Importance.ALERT;
case 'warning':
return Importance.WARNING;
default:
return Importance.INFO;
}
}
private gqlImportanceToFileImportance(importance: Importance): NotificationIni['importance'] {
switch (importance) {
case Importance.ALERT:
return 'alert';
case Importance.WARNING:
return 'warning';
default:
return 'normal';
}
}
private parseNotificationDateToIsoDate(unixStringSeconds: string | undefined): string | null {
if (unixStringSeconds && !isNaN(Number(unixStringSeconds))) {
return new Date(Number(unixStringSeconds) * 1_000).toISOString();
}
return null;
}
}

View File

@@ -14,6 +14,7 @@ import { RegistrationResolver } from './registration/registration.resolver';
import { ServerResolver } from './servers/server.resolver';
import { VarsResolver } from './vars/vars.resolver';
import { DockerResolver } from '@app/unraid-api/graph/resolvers/docker/docker.resolver';
import { NotificationsService } from './notifications/notifications.service';
@Module({
providers: [
@@ -32,6 +33,7 @@ import { DockerResolver } from '@app/unraid-api/graph/resolvers/docker/docker.re
ServerResolver,
VarsResolver,
VmsResolver,
NotificationsService,
],
})
export class ResolversModule {}

23
api/src/utils.ts Normal file
View File

@@ -0,0 +1,23 @@
export function notNull<T>(value: T): value is NonNullable<T> {
return value !== null;
}
/**
* Checks if a PromiseSettledResult is fulfilled.
*
* @param result A PromiseSettledResult.
* @returns true if the result is fulfilled, false otherwise.
*/
export function isFulfilled<T>(result: PromiseSettledResult<T>): result is PromiseFulfilledResult<T> {
return result.status === 'fulfilled';
}
/**
* Checks if a PromiseSettledResult is rejected.
*
* @param result A PromiseSettledResult.
* @returns true if the result is rejected, false otherwise.
*/
export function isRejected<T>(result: PromiseSettledResult<T>): result is PromiseRejectedResult {
return result.status === 'rejected';
}

View File

@@ -0,0 +1,12 @@
<script setup lang="ts">
import { useNotificationsStore } from '~/store/notifications';
const notificationsStore = useNotificationsStore();
</script>
<template>
<BrandButton text="My Button" @click="notificationsStore.toggle" />
<NotificationsSidebar />
</template>

View File

@@ -0,0 +1,14 @@
<script setup lang="ts">
import { storeToRefs } from 'pinia';
import { useNotificationsStore } from '~/store/notifications';
const notificationsStore = useNotificationsStore();
const { isOpen } = storeToRefs(notificationsStore);
</script>
<template>
<div v-if="isOpen" class="h-full w-full max-w-36">
This is my sidebar
</div>
</template>

View File

@@ -127,6 +127,8 @@ onBeforeMount(() => {
<div class="block w-2px h-24px bg-gamma" />
<NotificationsOpenButton />
<OnClickOutside class="flex items-center justify-end h-full" :options="{ ignore: [clickOutsideIgnoreTarget] }" @trigger="outsideDropdown">
<UpcDropdownTrigger ref="clickOutsideIgnoreTarget" :t="t" />
<UpcDropdown ref="clickOutsideTarget" :t="t" />

View File

@@ -55,6 +55,7 @@ export default defineNuxtConfig({
components: [
{ path: '~/components/Brand', prefix: 'Brand' },
{ path: '~/components/ConnectSettings', prefix: 'ConnectSettings' },
{ path: '~/components/Notifications', prefix: 'Notifications' },
{ path: '~/components/Ui', prefix: 'Ui' },
{ path: '~/components/UserProfile', prefix: 'Upc' },
{ path: '~/components/UpdateOs', prefix: 'UpdateOs' },

View File

@@ -0,0 +1,24 @@
import { defineStore, createPinia, setActivePinia } from 'pinia';
/**
* @see https://stackoverflow.com/questions/73476371/using-pinia-with-vue-js-web-components
* @see https://github.com/vuejs/pinia/discussions/1085
*/
setActivePinia(createPinia());
export const useNotificationsStore = defineStore('notifications', () => {
const isOpen = ref<boolean>(false);
const title = computed<string>(() => isOpen.value ? 'Notifications Are Open' : 'Notifications Are Closed');
const toggle = () => isOpen.value = !isOpen.value;
return {
// state
isOpen,
// getters
title,
// actions
toggle,
};
});