mirror of
https://github.com/unraid/api.git
synced 2026-01-02 14:40:01 -06:00
Compare commits
15 Commits
refactor/r
...
feat/ui-no
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
647faab50b | ||
|
|
90f02aa1c5 | ||
|
|
0e784b8ad6 | ||
|
|
87f60d953c | ||
|
|
1dc665dbd8 | ||
|
|
acc847ef1d | ||
|
|
48aca094f5 | ||
|
|
94143d96df | ||
|
|
56c2b29a80 | ||
|
|
f88415e3d9 | ||
|
|
0e4054cf3b | ||
|
|
fff0861f75 | ||
|
|
a82513a9bc | ||
|
|
f5b3e393f7 | ||
|
|
b178247046 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -55,6 +55,9 @@ typings/
|
||||
# OSX
|
||||
.DS_Store
|
||||
|
||||
# Jetbrains Settings Files
|
||||
.idea
|
||||
|
||||
# Temp dir for tests
|
||||
test/__temp__/*
|
||||
|
||||
|
||||
@@ -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
82
api/.gitignore
vendored
Normal 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
8
api/.prettierrc.cjs
Normal 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
118
api/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
8
api/src/core/types/states/notification.ts
Normal file
8
api/src/core/types/states/notification.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export interface NotificationIni {
|
||||
timestamp: string;
|
||||
event: string;
|
||||
subject: string;
|
||||
description: string;
|
||||
importance: 'normal' | 'alert' | 'warning';
|
||||
link?: string;
|
||||
}
|
||||
21
api/src/core/utils/files/safe-ini-serializer.ts
Normal file
21
api/src/core/utils/files/safe-ini-serializer.ts
Normal 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);
|
||||
};
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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!
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
23
api/src/utils.ts
Normal 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';
|
||||
}
|
||||
12
web/components/Notifications/OpenButton.vue
Normal file
12
web/components/Notifications/OpenButton.vue
Normal 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>
|
||||
|
||||
14
web/components/Notifications/Sidebar.vue
Normal file
14
web/components/Notifications/Sidebar.vue
Normal 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>
|
||||
@@ -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" />
|
||||
|
||||
@@ -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' },
|
||||
|
||||
24
web/store/notifications.ts
Normal file
24
web/store/notifications.ts
Normal 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,
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user