mirror of
https://github.com/unraid/api.git
synced 2026-01-02 22:50:02 -06:00
Compare commits
64 Commits
v3.10.0
...
feat/notif
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
acbd861ce0 | ||
|
|
8ec2358061 | ||
|
|
e7c1c8e7fe | ||
|
|
e933f14c24 | ||
|
|
e6699448a2 | ||
|
|
38aac5e35f | ||
|
|
16a70dcdb0 | ||
|
|
e7b8733648 | ||
|
|
f68727320b | ||
|
|
9c23b9bd1b | ||
|
|
32c0d0be0a | ||
|
|
b9e9a1a2cf | ||
|
|
10cb681d64 | ||
|
|
b513cbe614 | ||
|
|
b5c525a9c2 | ||
|
|
648b560148 | ||
|
|
6eb34c3501 | ||
|
|
21544bd2dc | ||
|
|
3e115f84d7 | ||
|
|
ba586fc438 | ||
|
|
e6cbed14a9 | ||
|
|
f531e68b87 | ||
|
|
53f718e240 | ||
|
|
de36bfab99 | ||
|
|
1e2f57a4cd | ||
|
|
46aa3a3e24 | ||
|
|
0c627d1ade | ||
|
|
f20349fb2a | ||
|
|
dc72d63481 | ||
|
|
e9efed8067 | ||
|
|
71ce064008 | ||
|
|
b67b0ea633 | ||
|
|
bf3d46d190 | ||
|
|
a1fa3462eb | ||
|
|
c84175e763 | ||
|
|
0f9fe18379 | ||
|
|
76c0d35783 | ||
|
|
3ece0d1acc | ||
|
|
0473c9b676 | ||
|
|
1956227f63 | ||
|
|
c515d08d5c | ||
|
|
0bd9820c00 | ||
|
|
0c2299cfcd | ||
|
|
12fdfac467 | ||
|
|
3fc20ec593 | ||
|
|
69a6163e29 | ||
|
|
00294699f0 | ||
|
|
90ff980a00 | ||
|
|
17e7d2a2de | ||
|
|
d2a88df5bf | ||
|
|
9471f5c918 | ||
|
|
492d45f363 | ||
|
|
2951d68f9d | ||
|
|
4857bc0478 | ||
|
|
c794a1d1a1 | ||
|
|
d2a34acfb9 | ||
|
|
3dc60b6106 | ||
|
|
57587b9175 | ||
|
|
5ee7cb2647 | ||
|
|
911a3f8f1a | ||
|
|
d426001372 | ||
|
|
2d0c65aaf4 | ||
|
|
fd4605b956 | ||
|
|
3f84b6bbfd |
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,
|
||||
};
|
||||
@@ -2,6 +2,22 @@
|
||||
|
||||
All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
|
||||
|
||||
## [3.11.0](https://github.com/unraid/api/compare/v3.10.1...v3.11.0) (2024-09-11)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* reduce how often rc.flashbackup checks for changes ([793d368](https://github.com/unraid/api/commit/793d3681404018e0ae933df0ad111809220ad138))
|
||||
* send api_version to flash/activate endpoint ([d8ec20e](https://github.com/unraid/api/commit/d8ec20ea6aa35aa241abd8424c4d884bcbb8f590))
|
||||
* update ProvisionCert.php to clean hosts file when it runs ([fbe20c9](https://github.com/unraid/api/commit/fbe20c97b327849c15a4b34f5f53476edaefbeb6))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* remove local flash backup ratelimit file on uninstall/update ([abf207b](https://github.com/unraid/api/commit/abf207b077861798c53739b1965207f87d5633b3))
|
||||
|
||||
### [3.10.1](https://github.com/unraid/api/compare/v3.10.0...v3.10.1) (2024-09-03)
|
||||
|
||||
## [3.10.0](https://github.com/unraid/api/compare/v3.9.0...v3.10.0) (2024-09-03)
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[api]
|
||||
version="3.8.1+d06e215a"
|
||||
extraOrigins="https://google.com,https://test.com"
|
||||
version="3.11.0+3f537b97"
|
||||
extraOrigins="https://google.com,https://test.com,http://localhost:4321"
|
||||
[local]
|
||||
[notifier]
|
||||
apikey="unnotify_30994bfaccf839c65bae75f7fa12dd5ee16e69389f754c3b98ed7d5"
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
[api]
|
||||
version="3.8.1+d06e215a"
|
||||
extraOrigins="https://google.com,https://test.com"
|
||||
version="3.11.0+3f537b97"
|
||||
extraOrigins="https://google.com,https://test.com,http://localhost:4321"
|
||||
[local]
|
||||
[notifier]
|
||||
apikey="unnotify_30994bfaccf839c65bae75f7fa12dd5ee16e69389f754c3b98ed7d5"
|
||||
[remote]
|
||||
wanaccess="no"
|
||||
wanaccess="yes"
|
||||
wanport="8443"
|
||||
upnpEnabled="no"
|
||||
apikey="_______________________BIG_API_KEY_HERE_________________________"
|
||||
@@ -16,8 +16,8 @@ regWizTime="1611175408732_0951-1653-3509-FBA155FA23C0"
|
||||
idtoken=""
|
||||
accesstoken=""
|
||||
refreshtoken=""
|
||||
allowedOrigins="/var/run/unraid-notifications.sock, /var/run/unraid-php.sock, /var/run/unraid-cli.sock, http://localhost:8080, https://localhost:4443, https://tower.local:4443, https://192.168.1.150:4443, https://tower:4443, https://192-168-1-150.thisisfourtyrandomcharacters012345678900.myunraid.net:4443, https://85-121-123-122.thisisfourtyrandomcharacters012345678900.myunraid.net:8443, https://10-252-0-1.hash.myunraid.net:4443, https://10-252-1-1.hash.myunraid.net:4443, https://10-253-3-1.hash.myunraid.net:4443, https://10-253-4-1.hash.myunraid.net:4443, https://10-253-5-1.hash.myunraid.net:4443, https://10-100-0-1.hash.myunraid.net:4443, https://10-100-0-2.hash.myunraid.net:4443, https://10-123-1-2.hash.myunraid.net:4443, https://221-123-121-112.hash.myunraid.net:4443, https://google.com, https://test.com, https://connect.myunraid.net, https://connect-staging.myunraid.net, https://dev-my.myunraid.net:4000, https://studio.apollographql.com"
|
||||
dynamicRemoteAccessType="STATIC"
|
||||
allowedOrigins="/var/run/unraid-notifications.sock, /var/run/unraid-php.sock, /var/run/unraid-cli.sock, http://localhost:8080, https://localhost:4443, https://tower.local:4443, https://192.168.1.150:4443, https://tower:4443, https://192-168-1-150.thisisfourtyrandomcharacters012345678900.myunraid.net:4443, https://85-121-123-122.thisisfourtyrandomcharacters012345678900.myunraid.net:8443, https://10-252-0-1.hash.myunraid.net:4443, https://10-252-1-1.hash.myunraid.net:4443, https://10-253-3-1.hash.myunraid.net:4443, https://10-253-4-1.hash.myunraid.net:4443, https://10-253-5-1.hash.myunraid.net:4443, https://10-100-0-1.hash.myunraid.net:4443, https://10-100-0-2.hash.myunraid.net:4443, https://10-123-1-2.hash.myunraid.net:4443, https://221-123-121-112.hash.myunraid.net:4443, https://google.com, https://test.com, http://localhost:4321, https://connect.myunraid.net, https://connect-staging.myunraid.net, https://dev-my.myunraid.net:4000, https://studio.apollographql.com"
|
||||
dynamicRemoteAccessType="DISABLED"
|
||||
[upc]
|
||||
apikey="unupc_fab6ff6ffe51040595c6d9ffb63a353ba16cc2ad7d93f813a2e80a5810"
|
||||
[connectionStatus]
|
||||
|
||||
1899
api/package-lock.json
generated
1899
api/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@unraid/api",
|
||||
"version": "3.10.0",
|
||||
"version": "3.11.0",
|
||||
"main": "dist/index.js",
|
||||
"bin": "dist/unraid-api.cjs",
|
||||
"type": "module",
|
||||
@@ -34,9 +34,9 @@
|
||||
"tsc": "tsc --noEmit",
|
||||
"lint": "DEBUG=eslint:cli-engine eslint . --config .eslintrc.cjs",
|
||||
"lint:fix": "DEBUG=eslint:cli-engine eslint . --fix --config .eslintrc.cjs",
|
||||
"test:watch": "vitest --segfault-retry=3 --pool=forks",
|
||||
"test": "vitest run --segfault-retry=3 --pool=forks",
|
||||
"coverage": "vitest run --segfault-retry=3 --coverage",
|
||||
"test:watch": "vitest --pool=forks",
|
||||
"test": "vitest run --pool=forks",
|
||||
"coverage": "vitest run --coverage",
|
||||
"patch:subscriptions-transport-ws": "node ./.scripts/patches/subscriptions-transport-ws.cjs",
|
||||
"release": "standard-version",
|
||||
"typesync": "typesync",
|
||||
@@ -97,7 +97,9 @@
|
||||
"dockerode": "^3.3.5",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.19.2",
|
||||
"filenamify": "^6.0.0",
|
||||
"find-process": "^1.4.7",
|
||||
"fs-extra": "^11.2.0",
|
||||
"global-agent": "^3.0.0",
|
||||
"graphql": "^16.8.1",
|
||||
"graphql-fields": "^2.0.3",
|
||||
@@ -116,6 +118,7 @@
|
||||
"mustache": "^4.2.0",
|
||||
"nanobus": "^4.5.0",
|
||||
"nest-access-control": "^3.1.0",
|
||||
"nest-authz": "^2.11.0",
|
||||
"nestjs-pino": "^4.0.0",
|
||||
"node-cache": "^5.1.2",
|
||||
"node-window-polyfill": "^1.0.2",
|
||||
@@ -133,7 +136,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,14 +173,14 @@
|
||||
"@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",
|
||||
"@typescript-eslint/parser": "^7.9.0",
|
||||
"@unraid/eslint-config": "github:unraid/eslint-config",
|
||||
"@vitest/coverage-v8": "^1.6.0",
|
||||
"@vitest/ui": "^1.6.0",
|
||||
"@vitest/coverage-v8": "^2.1.1",
|
||||
"@vitest/ui": "^2.1.1",
|
||||
"camelcase-keys": "^8.0.2",
|
||||
"cz-conventional-changelog": "3.3.0",
|
||||
"eslint": "^8.56.0",
|
||||
@@ -204,7 +207,7 @@
|
||||
"typescript": "^5.4.5",
|
||||
"typesync": "^0.12.1",
|
||||
"vite-tsconfig-paths": "^4.3.2",
|
||||
"vitest": "^1.6.0",
|
||||
"vitest": "^2.1.1",
|
||||
"zx": "^7.2.3"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
|
||||
@@ -17,7 +17,7 @@ test('Creates an array event', async () => {
|
||||
await store.dispatch(loadConfigFile());
|
||||
|
||||
const arrayEvent = getArrayData(store.getState);
|
||||
expect(arrayEvent).toMatchInlineSnapshot(`
|
||||
expect(arrayEvent).toMatchObject(
|
||||
{
|
||||
"boot": {
|
||||
"comment": "Unraid OS boot device",
|
||||
@@ -179,7 +179,7 @@ test('Creates an array event', async () => {
|
||||
"warning": null,
|
||||
},
|
||||
],
|
||||
"id": "97bbe87602982688216c367801f7aa24ea57350b44b7523160d01a9d48d6fcb9",
|
||||
"id": expect.any(String),
|
||||
"parities": [
|
||||
{
|
||||
"comment": null,
|
||||
@@ -208,5 +208,5 @@ test('Creates an array event', async () => {
|
||||
],
|
||||
"state": "STOPPED",
|
||||
}
|
||||
`);
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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.skip('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);
|
||||
});
|
||||
|
||||
@@ -20,6 +20,7 @@ const roles: Record<string, Role> = {
|
||||
{ resource: 'apikey', action: 'read:any', attributes: '*' },
|
||||
{ resource: 'cloud', action: 'read:own', attributes: '*' },
|
||||
{ resource: 'config', action: 'update:own', attributes: '*' },
|
||||
{ resource: 'config', action: 'read:any', attributes: '*' },
|
||||
{ resource: 'connect', action: 'read:own', attributes: '*' },
|
||||
{ resource: 'connect', action: 'update:own', attributes: '*' },
|
||||
{ resource: 'customizations', action: 'read:any', attributes: '*' },
|
||||
@@ -117,6 +118,8 @@ const roles: Record<string, Role> = {
|
||||
{ resource: 'config', action: 'update:own', attributes: '*' },
|
||||
{ resource: 'connect', action: 'read:own', attributes: '*' },
|
||||
{ resource: 'connect', action: 'update:own', attributes: '*' },
|
||||
{ resource: 'notifications', action: 'read:any', attributes: '*' },
|
||||
{ resource: 'notifications', action: 'update:any', attributes: '*' },
|
||||
],
|
||||
},
|
||||
my_servers: {
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
@@ -1,6 +1,14 @@
|
||||
import { getters } from '@app/store/index';
|
||||
import crypto from 'crypto';
|
||||
export const getServerIdentifier = (domain: string | null = null): string => {
|
||||
const config = getters.config();
|
||||
return crypto.createHash('sha256').update(`${domain ? domain : ''}-${config.api.version}-${config.remote.apikey ?? config.upc.apikey}`).digest('hex');
|
||||
import { hostname } from 'os';
|
||||
export const getServerIdentifier = (): string => {
|
||||
const flashGuid = getters.emhttp()?.var?.flashGuid ?? 'FLASH_GUID_NOT_FOUND';
|
||||
return crypto
|
||||
.createHash('sha256')
|
||||
.update(`${flashGuid}-${hostname()}`)
|
||||
.digest('hex');
|
||||
};
|
||||
|
||||
export const serverIdentifierMatches = (serverIdentifier: string): boolean => {
|
||||
return serverIdentifier === getServerIdentifier();
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ export const GRAPHQL_INTROSPECTION = Boolean(
|
||||
export const PORT = process.env.PORT ?? '/var/run/unraid-api.sock';
|
||||
export const DRY_RUN = process.env.DRY_RUN === 'true';
|
||||
export const BYPASS_PERMISSION_CHECKS = process.env.BYPASS_PERMISSION_CHECKS === 'true';
|
||||
export const BYPASS_CORS_CHECKS = process.env.BYPASS_CORS_CHECKS === 'true';
|
||||
export const LOG_CORS = process.env.LOG_CORS === 'true';
|
||||
export const LOG_TYPE = process.env.LOG_TYPE as 'pretty' | 'raw' ?? 'pretty';
|
||||
export const LOG_LEVEL = process.env.LOG_LEVEL as 'TRACE' | 'DEBUG' | 'INFO' | 'WARN' | 'ERROR' | 'FATAL';
|
||||
|
||||
@@ -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, NotificationslistArgs, 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(),
|
||||
id: z.string(),
|
||||
importance: ImportanceSchema,
|
||||
link: z.string().nullish(),
|
||||
subject: z.string(),
|
||||
timestamp: z.string().nullish(),
|
||||
title: z.string(),
|
||||
type: NotificationTypeSchema
|
||||
list: z.array(NotificationSchema()),
|
||||
overview: NotificationOverviewSchema()
|
||||
})
|
||||
}
|
||||
|
||||
export function NotificationslistArgsSchema(): z.ZodObject<Properties<NotificationslistArgs>> {
|
||||
return z.object({
|
||||
filter: NotificationFilterSchema()
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -622,11 +622,17 @@ export type Mutation = {
|
||||
addDiskToArray?: Maybe<ArrayType>;
|
||||
/** Add a new user */
|
||||
addUser?: Maybe<User>;
|
||||
archiveAll: NotificationOverview;
|
||||
/** Marks a notification as archived. */
|
||||
archiveNotification: NotificationOverview;
|
||||
archiveNotifications: 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'];
|
||||
@@ -637,6 +643,8 @@ export type Mutation = {
|
||||
/** Pause parity check */
|
||||
pauseParityCheck?: Maybe<Scalars['JSON']['output']>;
|
||||
reboot?: Maybe<Scalars['String']['output']>;
|
||||
/** Reads each notification to recompute & update the overview. */
|
||||
recalculateOverview: NotificationOverview;
|
||||
/** Remove existing disk from array. NOTE: The array must be stopped before running this otherwise it'll throw an error. */
|
||||
removeDiskFromArray?: Maybe<ArrayType>;
|
||||
/** Resume parity check */
|
||||
@@ -650,7 +658,11 @@ export type Mutation = {
|
||||
startParityCheck?: Maybe<Scalars['JSON']['output']>;
|
||||
/** Stop array */
|
||||
stopArray?: Maybe<ArrayType>;
|
||||
unarchiveAll: NotificationOverview;
|
||||
unarchiveNotifications: NotificationOverview;
|
||||
unmountArrayDisk?: Maybe<Disk>;
|
||||
/** Marks a notification as unread. */
|
||||
unreadNotification: NotificationOverview;
|
||||
/** Update an existing API key */
|
||||
updateApikey?: Maybe<ApiKey>;
|
||||
};
|
||||
@@ -672,6 +684,21 @@ export type MutationaddUserArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type MutationarchiveAllArgs = {
|
||||
importance?: InputMaybe<Importance>;
|
||||
};
|
||||
|
||||
|
||||
export type MutationarchiveNotificationArgs = {
|
||||
id: Scalars['String']['input'];
|
||||
};
|
||||
|
||||
|
||||
export type MutationarchiveNotificationsArgs = {
|
||||
ids?: InputMaybe<Array<Scalars['String']['input']>>;
|
||||
};
|
||||
|
||||
|
||||
export type MutationclearArrayDiskStatisticsArgs = {
|
||||
id: Scalars['ID']['input'];
|
||||
};
|
||||
@@ -682,6 +709,17 @@ export type MutationconnectSignInArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type MutationcreateNotificationArgs = {
|
||||
input: NotificationData;
|
||||
};
|
||||
|
||||
|
||||
export type MutationdeleteNotificationArgs = {
|
||||
id: Scalars['String']['input'];
|
||||
type: NotificationType;
|
||||
};
|
||||
|
||||
|
||||
export type MutationdeleteUserArgs = {
|
||||
input: deleteUserInput;
|
||||
};
|
||||
@@ -729,11 +767,26 @@ export type MutationstartParityCheckArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type MutationunarchiveAllArgs = {
|
||||
importance?: InputMaybe<Importance>;
|
||||
};
|
||||
|
||||
|
||||
export type MutationunarchiveNotificationsArgs = {
|
||||
ids?: InputMaybe<Array<Scalars['String']['input']>>;
|
||||
};
|
||||
|
||||
|
||||
export type MutationunmountArrayDiskArgs = {
|
||||
id: Scalars['ID']['input'];
|
||||
};
|
||||
|
||||
|
||||
export type MutationunreadNotificationArgs = {
|
||||
id: Scalars['String']['input'];
|
||||
};
|
||||
|
||||
|
||||
export type MutationupdateApikeyArgs = {
|
||||
input?: InputMaybe<updateApikeyInput>;
|
||||
name: Scalars['String']['input'];
|
||||
@@ -761,19 +814,36 @@ 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']>;
|
||||
/** Also known as 'event' */
|
||||
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 +851,30 @@ 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';
|
||||
id: Scalars['ID']['output'];
|
||||
list: Array<Notification>;
|
||||
/** A cached overview of the notifications in the system & their severity. */
|
||||
overview: NotificationOverview;
|
||||
};
|
||||
|
||||
|
||||
export type NotificationslistArgs = {
|
||||
filter: NotificationFilter;
|
||||
};
|
||||
|
||||
export type Os = {
|
||||
__typename?: 'Os';
|
||||
arch?: Maybe<Scalars['String']['output']>;
|
||||
@@ -940,7 +1017,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 +1059,6 @@ export type QuerydockerNetworksArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type QuerynotificationsArgs = {
|
||||
filter: NotificationFilter;
|
||||
};
|
||||
|
||||
|
||||
export type QueryuserArgs = {
|
||||
id: Scalars['ID']['input'];
|
||||
};
|
||||
@@ -1136,6 +1208,7 @@ export type Subscription = {
|
||||
info: Info;
|
||||
me?: Maybe<Me>;
|
||||
notificationAdded: Notification;
|
||||
notificationsOverview: NotificationOverview;
|
||||
online: Scalars['Boolean']['output'];
|
||||
owner: Owner;
|
||||
parityHistory: ParityCheck;
|
||||
@@ -1650,7 +1723,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 +1796,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 +1904,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 +2346,15 @@ 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'>>;
|
||||
archiveAll?: Resolver<ResolversTypes['NotificationOverview'], ParentType, ContextType, Partial<MutationarchiveAllArgs>>;
|
||||
archiveNotification?: Resolver<ResolversTypes['NotificationOverview'], ParentType, ContextType, RequireFields<MutationarchiveNotificationArgs, 'id'>>;
|
||||
archiveNotifications?: Resolver<ResolversTypes['NotificationOverview'], ParentType, ContextType, Partial<MutationarchiveNotificationsArgs>>;
|
||||
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'>>;
|
||||
@@ -2278,6 +2362,7 @@ export type MutationResolvers<ContextType = Context, ParentType extends Resolver
|
||||
mountArrayDisk?: Resolver<Maybe<ResolversTypes['Disk']>, ParentType, ContextType, RequireFields<MutationmountArrayDiskArgs, 'id'>>;
|
||||
pauseParityCheck?: Resolver<Maybe<ResolversTypes['JSON']>, ParentType, ContextType>;
|
||||
reboot?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
|
||||
recalculateOverview?: Resolver<ResolversTypes['NotificationOverview'], ParentType, ContextType>;
|
||||
removeDiskFromArray?: Resolver<Maybe<ResolversTypes['Array']>, ParentType, ContextType, Partial<MutationremoveDiskFromArrayArgs>>;
|
||||
resumeParityCheck?: Resolver<Maybe<ResolversTypes['JSON']>, ParentType, ContextType>;
|
||||
setAdditionalAllowedOrigins?: Resolver<Array<ResolversTypes['String']>, ParentType, ContextType, RequireFields<MutationsetAdditionalAllowedOriginsArgs, 'input'>>;
|
||||
@@ -2286,7 +2371,10 @@ export type MutationResolvers<ContextType = Context, ParentType extends Resolver
|
||||
startArray?: Resolver<Maybe<ResolversTypes['Array']>, ParentType, ContextType>;
|
||||
startParityCheck?: Resolver<Maybe<ResolversTypes['JSON']>, ParentType, ContextType, Partial<MutationstartParityCheckArgs>>;
|
||||
stopArray?: Resolver<Maybe<ResolversTypes['Array']>, ParentType, ContextType>;
|
||||
unarchiveAll?: Resolver<ResolversTypes['NotificationOverview'], ParentType, ContextType, Partial<MutationunarchiveAllArgs>>;
|
||||
unarchiveNotifications?: Resolver<ResolversTypes['NotificationOverview'], ParentType, ContextType, Partial<MutationunarchiveNotificationsArgs>>;
|
||||
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 +2397,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 +2413,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<{
|
||||
id?: Resolver<ResolversTypes['ID'], ParentType, ContextType>;
|
||||
list?: Resolver<Array<ResolversTypes['Notification']>, ParentType, ContextType, RequireFields<NotificationslistArgs, 'filter'>>;
|
||||
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 +2571,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 +2666,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 +3022,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,27 @@ input NotificationFilter {
|
||||
}
|
||||
|
||||
type Query {
|
||||
notifications(filter: NotificationFilter!): [Notification!]!
|
||||
notifications: Notifications!
|
||||
}
|
||||
|
||||
type Mutation {
|
||||
createNotification(input: NotificationData!): Notification!
|
||||
deleteNotification(id: String!, type: NotificationType!): NotificationOverview!
|
||||
"""Marks a notification as archived."""
|
||||
archiveNotification(id: String!): NotificationOverview!
|
||||
"""Marks a notification as unread."""
|
||||
unreadNotification(id: String!): NotificationOverview!
|
||||
archiveNotifications(ids: [String!]): NotificationOverview!
|
||||
unarchiveNotifications(ids: [String!]): NotificationOverview!
|
||||
archiveAll(importance: Importance): NotificationOverview!
|
||||
unarchiveAll(importance: Importance): NotificationOverview!
|
||||
"""Reads each notification to recompute & update the overview."""
|
||||
recalculateOverview: NotificationOverview!
|
||||
}
|
||||
|
||||
type Subscription {
|
||||
notificationAdded: Notification!
|
||||
notificationsOverview: NotificationOverview!
|
||||
}
|
||||
|
||||
enum Importance {
|
||||
@@ -36,14 +40,46 @@ enum Importance {
|
||||
WARNING
|
||||
}
|
||||
|
||||
type Notification {
|
||||
type Notifications implements Node {
|
||||
id: ID!
|
||||
"""A cached overview of the notifications in the system & their severity."""
|
||||
overview: NotificationOverview!
|
||||
list(filter: NotificationFilter!): [Notification!]!
|
||||
}
|
||||
|
||||
type Notification implements Node {
|
||||
id: ID!
|
||||
"""
|
||||
Also known as 'event'
|
||||
"""
|
||||
title: String!
|
||||
subject: String!
|
||||
description: String!
|
||||
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 { type 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,
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
import {
|
||||
setAllowedRemoteAccessUrl,
|
||||
} from '@app/store/modules/dynamic-remote-access';
|
||||
import { getServerIdentifier } from '@app/core/utils/server-identifier';
|
||||
|
||||
@Resolver('Connect')
|
||||
export class ConnectResolver implements ConnectResolvers {
|
||||
@@ -31,7 +30,7 @@ export class ConnectResolver implements ConnectResolvers {
|
||||
|
||||
@ResolveField()
|
||||
public id() {
|
||||
return getServerIdentifier('connect');
|
||||
return 'connect'
|
||||
}
|
||||
|
||||
@ResolveField()
|
||||
|
||||
@@ -19,6 +19,7 @@ import { ServicesResolver } from './services/services.resolver';
|
||||
import { SharesResolver } from './shares/shares.resolver';
|
||||
import { ConnectResolver } from './connect/connect.resolver';
|
||||
import { ConnectService } from './connect/connect.service';
|
||||
import { idPrefixPlugin } from '@app/unraid-api/graph/id-prefix-plugin';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -33,8 +34,8 @@ import { ConnectService } from './connect/connect.service';
|
||||
}),
|
||||
playground: false,
|
||||
plugins: GRAPHQL_INTROSPECTION
|
||||
? [ApolloServerPluginLandingPageLocalDefault()]
|
||||
: [],
|
||||
? [ApolloServerPluginLandingPageLocalDefault(), idPrefixPlugin]
|
||||
: [idPrefixPlugin],
|
||||
subscriptions: {
|
||||
'graphql-ws': {
|
||||
path: '/graphql',
|
||||
@@ -50,9 +51,7 @@ import { ConnectService } from './connect/connect.service';
|
||||
Port: PortResolver,
|
||||
URL: URLResolver,
|
||||
},
|
||||
validationRules: [
|
||||
NoUnusedVariablesRule
|
||||
]
|
||||
validationRules: [NoUnusedVariablesRule],
|
||||
// schema: schema
|
||||
}),
|
||||
],
|
||||
|
||||
51
api/src/unraid-api/graph/id-prefix-plugin.ts
Normal file
51
api/src/unraid-api/graph/id-prefix-plugin.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { type ApolloServerPlugin } from "@apollo/server";
|
||||
import { getServerIdentifier } from "@app/core/utils/server-identifier";
|
||||
|
||||
/**
|
||||
* Modify all ID fields in the GQL response object to include a prefix
|
||||
* @param obj GQL response object, to be modified in place
|
||||
*/
|
||||
const updateId = (obj: Record<string, unknown>) => {
|
||||
const serverId = getServerIdentifier();
|
||||
const stack = [obj];
|
||||
let iterations = 0;
|
||||
// Prevent infinite loops
|
||||
while (stack.length > 0 && iterations < 100) {
|
||||
const current = stack.pop();
|
||||
|
||||
if (current && typeof current === 'object') {
|
||||
if ('id' in current && typeof current.id === 'string') {
|
||||
current.id = `${serverId}:${current.id}`;
|
||||
}
|
||||
|
||||
for (const value of Object.values(current)) {
|
||||
if (value && typeof value === 'object') {
|
||||
stack.push(value as Record<string, unknown>);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
iterations++;
|
||||
}
|
||||
};
|
||||
|
||||
export const idPrefixPlugin: ApolloServerPlugin = {
|
||||
async requestDidStart(requestContext) {
|
||||
if (requestContext.request.operationName === 'IntrospectionQuery') {
|
||||
// Don't modify the introspection query
|
||||
return;
|
||||
}
|
||||
// If ID is requested, return an ID field with an extra prefix
|
||||
return {
|
||||
async willSendResponse({ response }) {
|
||||
if (
|
||||
response.body.kind === 'single' &&
|
||||
response.body.singleResult.data
|
||||
) {
|
||||
// Iteratively update all ID fields with a prefix
|
||||
updateId(response.body.singleResult.data);
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -1,4 +1,3 @@
|
||||
import { getServerIdentifier } from '@app/core/utils/server-identifier';
|
||||
import { AccessUrl, Network } from '@app/graphql/generated/api/types';
|
||||
import { getServerIps } from '@app/graphql/resolvers/subscription/network';
|
||||
import { Query, ResolveField, Resolver } from '@nestjs/graphql';
|
||||
@@ -16,7 +15,7 @@ export class NetworkResolver {
|
||||
@Query('network')
|
||||
public async network(): Promise<Network> {
|
||||
return {
|
||||
id: getServerIdentifier('network'),
|
||||
id: 'network'
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { getAllowedOrigins } from '@app/common/allowed-origins';
|
||||
import { getServerIdentifier } from '@app/core/utils/server-identifier';
|
||||
import { type AllowedOriginInput, Config, ConfigErrorState } from '@app/graphql/generated/api/types';
|
||||
import { getters, store } from '@app/store/index';
|
||||
import { updateAllowedOrigins } from '@app/store/modules/config';
|
||||
@@ -17,7 +16,7 @@ export class ConfigResolver {
|
||||
public async config(): Promise<Config> {
|
||||
const emhttp = getters.emhttp();
|
||||
return {
|
||||
id: getServerIdentifier('config'),
|
||||
id: 'config',
|
||||
valid: emhttp.var.configValid,
|
||||
error: emhttp.var.configValid
|
||||
? null
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { PUBSUB_CHANNEL, createSubscription } from '@app/core/pubsub';
|
||||
import { getServerIdentifier } from '@app/core/utils/server-identifier';
|
||||
import { type Display } from '@app/graphql/generated/api/types';
|
||||
import { getters } from '@app/store/index';
|
||||
import { Query, Resolver, Subscription } from '@nestjs/graphql';
|
||||
@@ -71,7 +70,7 @@ export class DisplayResolver {
|
||||
const dynamixBasePath = getters.paths()['dynamix-base'];
|
||||
const configFilePath = join(dynamixBasePath, 'case-model.cfg');
|
||||
const result = {
|
||||
id: getServerIdentifier('display'),
|
||||
id: 'display'
|
||||
}
|
||||
|
||||
// If the config file doesn't exist then it's a new OS install
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { getDockerContainers } from '@app/core/modules/index';
|
||||
import { getServerIdentifier } from '@app/core/utils/server-identifier';
|
||||
import { Query, ResolveField, Resolver } from '@nestjs/graphql';
|
||||
import { UseRoles } from 'nest-access-control';
|
||||
|
||||
@@ -13,7 +12,7 @@ export class DockerResolver {
|
||||
@Query()
|
||||
public docker() {
|
||||
return {
|
||||
id: getServerIdentifier('docker'),
|
||||
id: 'docker',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { PUBSUB_CHANNEL, createSubscription } from '@app/core/pubsub';
|
||||
import { getMachineId } from '@app/core/utils/misc/get-machine-id';
|
||||
import { getServerIdentifier } from '@app/core/utils/server-identifier';
|
||||
import {
|
||||
generateApps,
|
||||
generateCpu,
|
||||
@@ -24,7 +23,7 @@ export class InfoResolver {
|
||||
})
|
||||
public async info() {
|
||||
return {
|
||||
id: getServerIdentifier('info')
|
||||
id: 'info'
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,51 +1,137 @@
|
||||
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,
|
||||
NotificationOverview,
|
||||
} 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 { Importance } from '@app/graphql/generated/client/graphql';
|
||||
import { AppError } from '@app/core/errors/app-error';
|
||||
|
||||
@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: 'notifications',
|
||||
};
|
||||
}
|
||||
|
||||
@Subscription('notificationAdded')
|
||||
@ResolveField()
|
||||
public async overview() {
|
||||
return this.notificationsService.getOverview();
|
||||
}
|
||||
|
||||
@ResolveField()
|
||||
public async list(
|
||||
@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 async archiveNotifications(@Args('ids') ids: string[]) {
|
||||
await this.notificationsService.archiveIds(ids);
|
||||
return this.notificationsService.getOverview();
|
||||
}
|
||||
|
||||
@Mutation()
|
||||
public async archiveAll(@Args('importance') importance?: Importance): Promise<NotificationOverview> {
|
||||
const { overview } = await this.notificationsService.archiveAll(importance);
|
||||
return overview;
|
||||
}
|
||||
|
||||
@Mutation()
|
||||
public unreadNotification(@Args('id') id: string) {
|
||||
return this.notificationsService.markAsUnread({ id });
|
||||
}
|
||||
|
||||
@Mutation()
|
||||
public async unarchiveNotifications(@Args('ids') ids: string[]) {
|
||||
await this.notificationsService.unarchiveIds(ids);
|
||||
return this.notificationsService.getOverview();
|
||||
}
|
||||
|
||||
@Mutation()
|
||||
public async unarchiveAll(@Args('importance') importance?: Importance): Promise<NotificationOverview> {
|
||||
const { overview } = await this.notificationsService.unarchiveAll(importance);
|
||||
return overview;
|
||||
}
|
||||
|
||||
@Mutation()
|
||||
public async recalculateOverview() {
|
||||
const { overview, error } = await this.notificationsService.recalculateOverview();
|
||||
if (error) {
|
||||
throw new AppError("Failed to refresh overview", 500);
|
||||
}
|
||||
return overview;
|
||||
}
|
||||
|
||||
/**============================================
|
||||
* 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,365 @@
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
import { NotificationsService } from './notifications.service';
|
||||
import { describe, it, expect, vi, beforeAll, afterEach } from 'vitest';
|
||||
import { existsSync } from 'fs';
|
||||
import {
|
||||
Importance,
|
||||
type NotificationData,
|
||||
NotificationType,
|
||||
type Notification,
|
||||
type NotificationOverview,
|
||||
type NotificationCounts,
|
||||
type NotificationFilter,
|
||||
} from '@app/graphql/generated/api/types';
|
||||
import { NotificationSchema } from '@app/graphql/generated/api/operations';
|
||||
import { mkdir } from 'fs/promises';
|
||||
|
||||
// defined outside `describe` so it's defined inside the `beforeAll`
|
||||
// needed to mock the dynamix import
|
||||
const basePath = '/tmp/test/notifications';
|
||||
|
||||
// we run sequentially here because this module's state depends on external, shared systems
|
||||
// rn, it's complicated to make the tests atomic & isolated
|
||||
describe.sequential('NotificationsService', () => {
|
||||
const notificationImportance = Object.values(Importance);
|
||||
let service: NotificationsService;
|
||||
const testPaths = {
|
||||
basePath,
|
||||
UNREAD: `${basePath}/unread`,
|
||||
ARCHIVE: `${basePath}/archive`,
|
||||
};
|
||||
|
||||
/**------------------------------------------------------------------------
|
||||
* Lifecycle Setup
|
||||
*------------------------------------------------------------------------**/
|
||||
|
||||
beforeAll(async () => {
|
||||
await mkdir(basePath, { recursive: true });
|
||||
// need to mock the dynamix import bc the file watcher is init'ed in the service constructor
|
||||
// i.e. before we can mock service.paths()
|
||||
vi.mock(import('../../../../store'), async (importOriginal) => {
|
||||
const mod = await importOriginal();
|
||||
return {
|
||||
...mod,
|
||||
getters: {
|
||||
dynamix: () => ({
|
||||
notify: { path: basePath },
|
||||
}),
|
||||
},
|
||||
} as typeof mod;
|
||||
});
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [NotificationsService],
|
||||
}).compile();
|
||||
|
||||
service = module.get<NotificationsService>(NotificationsService); // this might need to be a module.resolve instead of get
|
||||
vi.spyOn(service, 'paths').mockImplementation(() => testPaths);
|
||||
|
||||
await service.deleteAllNotifications();
|
||||
});
|
||||
|
||||
// make sure each test is isolated (as much as possible)
|
||||
afterEach(async () => {
|
||||
await service.deleteAllNotifications();
|
||||
});
|
||||
|
||||
/**------------------------------------------------------------------------
|
||||
* Helper Functions
|
||||
*------------------------------------------------------------------------**/
|
||||
|
||||
async function createNotification(data: Partial<NotificationData> = {}) {
|
||||
const {
|
||||
title = 'Test Notification',
|
||||
subject = 'Test Subject',
|
||||
description = 'Test Description',
|
||||
importance = Importance.INFO,
|
||||
} = data;
|
||||
return service.createNotification({ title, subject, description, importance });
|
||||
}
|
||||
|
||||
async function findById(id: string, type: NotificationType = NotificationType.UNREAD) {
|
||||
return (await service.getNotifications({ type, limit: 50, offset: 0 })).find(
|
||||
(notification) => notification.id === id
|
||||
);
|
||||
}
|
||||
|
||||
// Some of these helpers accept `expect` implementations,
|
||||
// which allows them to be used in concurrent tests
|
||||
// e.g. doesExist(expect)(id, type)
|
||||
function doesExist(expectImplementation: typeof expect) {
|
||||
/** Asserts & returns whether a notification with the given id and type exists. */
|
||||
return async (
|
||||
{ id }: Pick<Notification, 'id'>,
|
||||
type: NotificationType = NotificationType.UNREAD
|
||||
) => {
|
||||
const storedNotification = await findById(id, type);
|
||||
expectImplementation(storedNotification).toBeDefined();
|
||||
return !!storedNotification;
|
||||
};
|
||||
}
|
||||
|
||||
async function forEachImportance(action: (importance: Importance) => Promise<void>) {
|
||||
for (const importance of notificationImportance) {
|
||||
await action(importance);
|
||||
}
|
||||
}
|
||||
|
||||
async function forEachType(action: (type: NotificationType) => Promise<void>) {
|
||||
for (const type of Object.values(NotificationType)) {
|
||||
await action(type);
|
||||
}
|
||||
}
|
||||
|
||||
// currently unused b/c of difficulty implementing NotificationOverview tests
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
async function forAllTypesAndImportances(
|
||||
action: (type: NotificationType, importance: Importance) => Promise<void>
|
||||
) {
|
||||
await forEachType(async (type) => {
|
||||
await forEachImportance(async (importance) => {
|
||||
await action(type, importance);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function diffCounts(current: NotificationCounts, previous: NotificationCounts) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(current).map(([key]) => {
|
||||
return [key, current[key] - previous[key]] as const;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// currently unused b/c of difficulty implementing NotificationOverview tests
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
function diffOverview(current: NotificationOverview, previous: NotificationOverview) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(current).map(([key]) => {
|
||||
return [key, diffCounts(current[key], previous[key])];
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const makeExpectIn =
|
||||
(expectImplementation: typeof expect) =>
|
||||
/**
|
||||
* Loads notifications from the service and asserts that the expected amount is returned.
|
||||
*
|
||||
* @param params
|
||||
* @param amount
|
||||
*/
|
||||
async (params: Partial<NotificationFilter> & { type: NotificationType }, amount: number) => {
|
||||
const { limit = 50, offset = 0, importance, type } = params;
|
||||
const loaded = await service.getNotifications({
|
||||
type,
|
||||
importance,
|
||||
limit,
|
||||
offset,
|
||||
});
|
||||
expectImplementation(loaded.length).toEqual(amount);
|
||||
};
|
||||
|
||||
/**------------------------------------------------------------------------
|
||||
* Sanity Tests
|
||||
*------------------------------------------------------------------------**/
|
||||
|
||||
it('test setup is correctly defined', ({ expect }) => {
|
||||
expect(service).toBeDefined();
|
||||
expect(service.paths()).toEqual(testPaths);
|
||||
const snapshot = service.getOverview();
|
||||
Object.values(testPaths).forEach((path) => expect(existsSync(path)).toBeTruthy());
|
||||
|
||||
const endSnapshot = service.getOverview();
|
||||
expect(snapshot).toEqual(endSnapshot);
|
||||
|
||||
// check that all counts are 0
|
||||
Object.values(snapshot.archive).forEach((count) => {
|
||||
expect(count).toEqual(0);
|
||||
});
|
||||
Object.values(snapshot.unread).forEach((count) => {
|
||||
expect(count).toEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('generates unique ids', async () => {
|
||||
const notifications = await Promise.all([...new Array(100)].map(() => createNotification()));
|
||||
const notificationIds = new Set(notifications.map((notification) => notification.id));
|
||||
expect(notificationIds.size).toEqual(notifications.length);
|
||||
});
|
||||
|
||||
it('returns ISO timestamps', async () => {
|
||||
const isISODate = (date: string) => new Date(date).toISOString() === date;
|
||||
const created = await createNotification();
|
||||
const loaded = await findById(created.id);
|
||||
expect(isISODate(created.timestamp ?? '')).toBeTruthy();
|
||||
expect(isISODate(loaded?.timestamp ?? '')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('generates gql-compatible notifications', async () => {
|
||||
const created = await createNotification();
|
||||
const loaded = await findById(created.id);
|
||||
const { success } = NotificationSchema().safeParse(loaded);
|
||||
expect(success).toBeTruthy();
|
||||
});
|
||||
|
||||
/**========================================================================
|
||||
* CRUD Smoke Tests
|
||||
*========================================================================**/
|
||||
|
||||
it('can correctly create, load, and delete a notification', async ({ expect }) => {
|
||||
const notificationData: NotificationData = {
|
||||
title: 'Test Notification',
|
||||
subject: 'Test Subject',
|
||||
description: 'Test Description',
|
||||
importance: Importance.INFO,
|
||||
};
|
||||
const notification = await createNotification(notificationData);
|
||||
|
||||
// HACK: we brute-force re-calculate instead of using service.getOverview()
|
||||
// because the file-system-watcher's test setup isn't working rn.
|
||||
let { overview } = await service.recalculateOverview();
|
||||
expect.soft(overview.unread.total).toEqual(1);
|
||||
|
||||
// data in returned notification (from createNotification) matches?
|
||||
Object.entries(notificationData).forEach(([key, value]) => {
|
||||
expect(notification[key]).toEqual(value);
|
||||
});
|
||||
|
||||
// data in stored notification matches?
|
||||
const storedNotification = await findById(notification.id);
|
||||
expect(storedNotification).toBeDefined();
|
||||
if (!storedNotification) return; // stop the test if there's no stored notification
|
||||
expect(storedNotification.id).toEqual(notification.id);
|
||||
expect(storedNotification.timestamp).toEqual(notification.timestamp);
|
||||
Object.entries(notificationData).forEach(([key, value]) => {
|
||||
expect(storedNotification[key]).toEqual(value);
|
||||
});
|
||||
|
||||
// notification was deleted
|
||||
await service.deleteNotification({ id: notification.id, type: NotificationType.UNREAD });
|
||||
const deleted = await findById(notification.id);
|
||||
expect(deleted).toBeUndefined();
|
||||
|
||||
({ overview } = await service.recalculateOverview());
|
||||
expect.soft(overview.unread.total).toEqual(0);
|
||||
});
|
||||
|
||||
it.each(notificationImportance)('loadNotifications respects %s filter', async (importance) => {
|
||||
const notifications = await Promise.all([
|
||||
createNotification({ importance: Importance.ALERT }),
|
||||
createNotification({ importance: Importance.ALERT }),
|
||||
createNotification({ importance: Importance.ALERT }),
|
||||
createNotification({ importance: Importance.INFO }),
|
||||
createNotification({ importance: Importance.INFO }),
|
||||
createNotification({ importance: Importance.INFO }),
|
||||
createNotification({ importance: Importance.WARNING }),
|
||||
createNotification({ importance: Importance.WARNING }),
|
||||
createNotification({ importance: Importance.WARNING }),
|
||||
]);
|
||||
const { overview } = await service.recalculateOverview();
|
||||
expect(notifications.length).toEqual(9);
|
||||
expect.soft(overview.unread.total).toEqual(9);
|
||||
|
||||
// don't use the `expectIn` helper, just in case it changes
|
||||
const loaded = await service.getNotifications({
|
||||
type: NotificationType.UNREAD,
|
||||
importance,
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
});
|
||||
expect(loaded.length).toEqual(3);
|
||||
});
|
||||
|
||||
/**--------------------------------------------
|
||||
* CRUD: Update Tests
|
||||
*---------------------------------------------**/
|
||||
|
||||
it.for(notificationImportance.map((i) => [i]))(
|
||||
'can correctly archive and unarchive a %s notification',
|
||||
async ([importance], { expect }) => {
|
||||
const notification = await createNotification({ importance });
|
||||
let { overview } = await service.recalculateOverview();
|
||||
expect.soft(overview.unread.total).toEqual(1);
|
||||
expect.soft(overview.archive.total).toEqual(0);
|
||||
|
||||
await service.archiveNotification(notification);
|
||||
let exists = await doesExist(expect)(notification, NotificationType.ARCHIVE);
|
||||
if (!exists) return;
|
||||
|
||||
({ overview } = await service.recalculateOverview());
|
||||
expect.soft(overview.unread.total).toEqual(0);
|
||||
expect.soft(overview.archive.total).toEqual(1);
|
||||
|
||||
await service.markAsUnread(notification);
|
||||
exists = await doesExist(expect)(notification, NotificationType.UNREAD);
|
||||
({ overview } = await service.recalculateOverview());
|
||||
expect.soft(overview.unread.total).toEqual(1);
|
||||
expect.soft(overview.archive.total).toEqual(0);
|
||||
}
|
||||
);
|
||||
|
||||
it.each(notificationImportance)('can archiveAll & unarchiveAll %s', async (importance) => {
|
||||
const expectIn = makeExpectIn(expect);
|
||||
const notifications = await Promise.all([
|
||||
createNotification({ importance: Importance.ALERT }),
|
||||
createNotification({ importance: Importance.ALERT }),
|
||||
createNotification({ importance: Importance.ALERT }),
|
||||
createNotification({ importance: Importance.INFO }),
|
||||
createNotification({ importance: Importance.INFO }),
|
||||
createNotification({ importance: Importance.INFO }),
|
||||
createNotification({ importance: Importance.WARNING }),
|
||||
createNotification({ importance: Importance.WARNING }),
|
||||
createNotification({ importance: Importance.WARNING }),
|
||||
]);
|
||||
|
||||
expect(notifications.length).toEqual(9);
|
||||
await expectIn({ type: NotificationType.UNREAD }, 9);
|
||||
let { overview } = await service.recalculateOverview();
|
||||
expect.soft(overview.unread.total).toEqual(9);
|
||||
expect.soft(overview.archive.total).toEqual(0);
|
||||
|
||||
await service.archiveAll();
|
||||
await expectIn({ type: NotificationType.ARCHIVE }, 9);
|
||||
|
||||
({ overview } = await service.recalculateOverview());
|
||||
expect.soft(overview.unread.total).toEqual(0);
|
||||
expect.soft(overview.archive.total).toEqual(9);
|
||||
|
||||
await service.unarchiveAll();
|
||||
await expectIn({ type: NotificationType.UNREAD }, 9);
|
||||
|
||||
({ overview } = await service.recalculateOverview());
|
||||
expect.soft(overview.unread.total).toEqual(9);
|
||||
expect.soft(overview.archive.total).toEqual(0);
|
||||
|
||||
await service.archiveAll(importance);
|
||||
await expectIn({ type: NotificationType.ARCHIVE }, 3);
|
||||
await expectIn({ type: NotificationType.UNREAD }, 6);
|
||||
|
||||
({ overview } = await service.recalculateOverview());
|
||||
expect.soft(overview.unread.total).toEqual(6);
|
||||
expect.soft(overview.archive.total).toEqual(3);
|
||||
|
||||
// archive another importance set, just to make sure unarchiveAll
|
||||
// isn't just ignoring the filter, which would be possible if it only
|
||||
// contained the stuff it was supposed to unarchive.
|
||||
|
||||
const anotherImportance = importance === Importance.ALERT ? Importance.INFO : Importance.ALERT;
|
||||
await service.archiveAll(anotherImportance);
|
||||
await expectIn({ type: NotificationType.ARCHIVE }, 6);
|
||||
await expectIn({ type: NotificationType.UNREAD }, 3);
|
||||
|
||||
({ overview } = await service.recalculateOverview());
|
||||
expect.soft(overview.unread.total).toEqual(3);
|
||||
expect.soft(overview.archive.total).toEqual(6);
|
||||
|
||||
await service.unarchiveAll(importance);
|
||||
await expectIn({ type: NotificationType.ARCHIVE }, 3);
|
||||
await expectIn({ type: NotificationType.UNREAD }, 6);
|
||||
|
||||
({ overview } = await service.recalculateOverview());
|
||||
expect.soft(overview.unread.total).toEqual(6);
|
||||
expect.soft(overview.archive.total).toEqual(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,657 @@
|
||||
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,
|
||||
type NotificationFilter,
|
||||
type NotificationOverview,
|
||||
type NotificationData,
|
||||
type NotificationCounts,
|
||||
} 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 { basename, join } from 'path';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { batchProcess, isFulfilled, isRejected, unraidTimestamp } from '@app/utils';
|
||||
import { FSWatcher, watch } from 'chokidar';
|
||||
import { pubsub, PUBSUB_CHANNEL } from '@app/core/pubsub';
|
||||
import { fileExists } from '@app/core/utils/files/file-exists';
|
||||
import { encode as encodeIni } from 'ini';
|
||||
import { v7 as uuidv7 } from 'uuid';
|
||||
import { CHOKIDAR_USEPOLLING } from '@app/environment';
|
||||
import { emptyDir } from 'fs-extra';
|
||||
|
||||
@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
|
||||
*/
|
||||
public 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 { basePath } = this.paths();
|
||||
|
||||
if (NotificationsService.watcher) {
|
||||
return NotificationsService.watcher;
|
||||
}
|
||||
|
||||
NotificationsService.watcher = watch(basePath, { usePolling: CHOKIDAR_USEPOLLING }).on(
|
||||
'add',
|
||||
(path) => {
|
||||
void this.handleNotificationAdd(path).catch((e) => this.logger.error(e));
|
||||
}
|
||||
);
|
||||
|
||||
return NotificationsService.watcher;
|
||||
}
|
||||
|
||||
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]);
|
||||
this.increment(notification.importance, NotificationsService.overview[type.toLowerCase()]);
|
||||
|
||||
this.publishOverview();
|
||||
pubsub.publish(PUBSUB_CHANNEL.NOTIFICATION_ADDED, {
|
||||
notificationAdded: notification,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a stable snapshot of the current notification overview.
|
||||
*
|
||||
* The notification overview is a dictionary that contains the total number of notifications
|
||||
* of each importance level, as well as the total number of notifications.
|
||||
*
|
||||
* @returns A Promise that resolves to a NotificationOverview object.
|
||||
*/
|
||||
public getOverview(): NotificationOverview {
|
||||
return structuredClone(NotificationsService.overview);
|
||||
}
|
||||
|
||||
private publishOverview(overview = NotificationsService.overview) {
|
||||
return pubsub.publish(PUBSUB_CHANNEL.NOTIFICATION_OVERVIEW, {
|
||||
notificationsOverview: overview,
|
||||
});
|
||||
}
|
||||
|
||||
private increment(importance: Importance, collector: NotificationCounts) {
|
||||
collector[importance.toLowerCase()] += 1;
|
||||
collector['total'] += 1;
|
||||
}
|
||||
|
||||
private decrement(importance: Importance, collector: NotificationCounts) {
|
||||
collector[importance.toLowerCase()] -= 1;
|
||||
collector['total'] -= 1;
|
||||
}
|
||||
|
||||
public async recalculateOverview() {
|
||||
const overview: NotificationOverview = {
|
||||
unread: {
|
||||
alert: 0,
|
||||
info: 0,
|
||||
warning: 0,
|
||||
total: 0,
|
||||
},
|
||||
archive: {
|
||||
alert: 0,
|
||||
info: 0,
|
||||
warning: 0,
|
||||
total: 0,
|
||||
},
|
||||
};
|
||||
|
||||
// todo - refactor this to be more memory efficient
|
||||
// i.e. by using a lazy generator vs the current eager implementation
|
||||
//
|
||||
// recalculates stats for a particular notification type
|
||||
const recalculate = async (type: NotificationType) => {
|
||||
const ids = await this.listFilesInFolder(this.paths()[type]);
|
||||
const [notifications] = await this.loadNotificationsFromPaths(ids, {});
|
||||
notifications.forEach((n) => this.increment(n.importance, overview[type.toLowerCase()]));
|
||||
};
|
||||
|
||||
const results = await batchProcess(
|
||||
[NotificationType.ARCHIVE, NotificationType.UNREAD],
|
||||
recalculate
|
||||
);
|
||||
|
||||
if (results.errorOccured) {
|
||||
results.errors.forEach((e) => this.logger.error('[recalculateOverview] ' + e));
|
||||
}
|
||||
|
||||
NotificationsService.overview = overview;
|
||||
void this.publishOverview();
|
||||
return { error: results.errorOccured, overview: this.getOverview() };
|
||||
}
|
||||
|
||||
/**------------------------------------------------------------------------
|
||||
* 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 this.notificationFileToGqlNotification({ id, type: NotificationType.UNREAD }, fileData);
|
||||
}
|
||||
|
||||
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`;
|
||||
}
|
||||
|
||||
/** transforms gql compliant NotificationData to .notify compliant data*/
|
||||
private makeNotificationFileData(notification: NotificationData): NotificationIni {
|
||||
const { title, subject, description, link, importance } = notification;
|
||||
|
||||
const data: NotificationIni = {
|
||||
timestamp: unraidTimestamp().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);
|
||||
|
||||
this.decrement(notification.importance, NotificationsService.overview[type.toLowerCase()]);
|
||||
await this.publishOverview();
|
||||
|
||||
// return both the overview & the deleted notification
|
||||
// this helps us reference the deleted notification in-memory if we want
|
||||
return { notification, overview: NotificationsService.overview };
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes all notifications from disk, but preserves
|
||||
* notification directories.
|
||||
*
|
||||
* Resets the notification overview to all zeroes.
|
||||
*/
|
||||
public async deleteAllNotifications() {
|
||||
const { UNREAD, ARCHIVE } = this.paths();
|
||||
// ensures the directory exists before deleting
|
||||
await emptyDir(ARCHIVE);
|
||||
await emptyDir(UNREAD);
|
||||
NotificationsService.overview = {
|
||||
unread: {
|
||||
alert: 0,
|
||||
info: 0,
|
||||
warning: 0,
|
||||
total: 0,
|
||||
},
|
||||
archive: {
|
||||
alert: 0,
|
||||
info: 0,
|
||||
warning: 0,
|
||||
total: 0,
|
||||
},
|
||||
};
|
||||
return this.getOverview();
|
||||
}
|
||||
|
||||
/**------------------------------------------------------------------------
|
||||
* CRUD: Updating Notifications
|
||||
*------------------------------------------------------------------------**/
|
||||
|
||||
/**
|
||||
* Returns a function that:
|
||||
* 1. moves a notification from one category to another.
|
||||
* 2. updates stats overview
|
||||
* 3. updates the stats snapshot if provided
|
||||
*
|
||||
* Note: the returned function implicitly triggers a pubsub event via `fs.rename`,
|
||||
* which is expected to trigger `NOTIFICATION_ADDED` & `NOTIFICATION_OVERVIEW`.
|
||||
*
|
||||
* The published overview will include the update from this operation.
|
||||
*
|
||||
* @param params
|
||||
* @returns lambda function
|
||||
*/
|
||||
private moveNotification(params: {
|
||||
from: NotificationType;
|
||||
to: NotificationType;
|
||||
snapshot?: NotificationOverview;
|
||||
}) {
|
||||
const { from, to, snapshot } = params;
|
||||
const paths = this.paths();
|
||||
const fromStatKey = from.toLowerCase();
|
||||
const toStatKey = to.toLowerCase();
|
||||
return async (notification: Notification) => {
|
||||
const currentPath = join(paths[from], notification.id);
|
||||
const targetPath = join(paths[to], notification.id);
|
||||
|
||||
/**-----------------------
|
||||
* Event, PubSub, & Overview Update logic
|
||||
*
|
||||
* We assume `rename` kicks off 'unlink' and 'add' events
|
||||
* in the chokidar file watcher (see `getNotificationsWatcher`).
|
||||
*
|
||||
* 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 here via `decrement` and implicitly expect
|
||||
* it to be updated (i.e. incremented & pubsub'd) 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.
|
||||
*------------------------**/
|
||||
this.decrement(notification.importance, NotificationsService.overview[fromStatKey]);
|
||||
try {
|
||||
await rename(currentPath, targetPath);
|
||||
} catch (err) {
|
||||
// revert our earlier decrement
|
||||
// we do it this way (decrement -> try rename -> revert if error) to avoid
|
||||
// a race condition between `rename` and `decrement`
|
||||
this.increment(notification.importance, NotificationsService.overview[fromStatKey]);
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (snapshot) {
|
||||
this.decrement(notification.importance, snapshot[fromStatKey]);
|
||||
this.increment(notification.importance, snapshot[toStatKey]);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public async archiveNotification({ id }: Pick<Notification, 'id'>): Promise<NotificationOverview> {
|
||||
const unreadPath = join(this.paths().UNREAD, id);
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
/**-----------------------
|
||||
* 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 snapshot = this.getOverview();
|
||||
const notification = await this.loadNotificationFile(unreadPath, NotificationType.UNREAD);
|
||||
const moveToArchive = this.moveNotification({
|
||||
from: NotificationType.UNREAD,
|
||||
to: NotificationType.ARCHIVE,
|
||||
snapshot,
|
||||
});
|
||||
await moveToArchive(notification);
|
||||
|
||||
return {
|
||||
...NotificationsService.overview,
|
||||
archive: snapshot.archive,
|
||||
};
|
||||
}
|
||||
|
||||
public async markAsUnread({ id }: Pick<Notification, 'id'>): Promise<NotificationOverview> {
|
||||
const archivePath = join(this.paths().ARCHIVE, id);
|
||||
// the target notification might not be in the archive!
|
||||
if (!(await fileExists(archivePath))) {
|
||||
this.logger.warn(`[markAsUnread] Could not find notification in archive: ${id}`);
|
||||
return NotificationsService.overview;
|
||||
}
|
||||
|
||||
// we use a snapshot to provide an accurate overview update
|
||||
// otherwise, we'd enter a race condition with the 'add' file watcher event handler
|
||||
const snapshot = this.getOverview();
|
||||
const notification = await this.loadNotificationFile(archivePath, NotificationType.ARCHIVE);
|
||||
const moveToUnread = this.moveNotification({
|
||||
from: NotificationType.ARCHIVE,
|
||||
to: NotificationType.UNREAD,
|
||||
snapshot,
|
||||
});
|
||||
|
||||
await moveToUnread(notification);
|
||||
return {
|
||||
...NotificationsService.overview,
|
||||
unread: snapshot.unread,
|
||||
};
|
||||
}
|
||||
|
||||
public async archiveAll(importance?: Importance) {
|
||||
const { UNREAD } = this.paths();
|
||||
|
||||
if (!importance) {
|
||||
await readdir(UNREAD).then((ids) => this.archiveIds(ids));
|
||||
return { overview: NotificationsService.overview };
|
||||
}
|
||||
|
||||
const overviewSnapshot = this.getOverview();
|
||||
const unreads = await this.listFilesInFolder(UNREAD);
|
||||
const [notifications] = await this.loadNotificationsFromPaths(unreads, { importance });
|
||||
const archive = this.moveNotification({
|
||||
from: NotificationType.UNREAD,
|
||||
to: NotificationType.ARCHIVE,
|
||||
snapshot: overviewSnapshot,
|
||||
});
|
||||
|
||||
const stats = await batchProcess(notifications, archive);
|
||||
return { ...stats, overview: overviewSnapshot };
|
||||
}
|
||||
|
||||
public async unarchiveAll(importance?: Importance) {
|
||||
const { ARCHIVE } = this.paths();
|
||||
|
||||
if (!importance) {
|
||||
// use arrow function to preserve `this`
|
||||
await readdir(ARCHIVE).then((ids) => this.unarchiveIds(ids));
|
||||
return { overview: NotificationsService.overview };
|
||||
}
|
||||
|
||||
const overviewSnapshot = this.getOverview();
|
||||
const archives = await this.listFilesInFolder(ARCHIVE);
|
||||
const [notifications] = await this.loadNotificationsFromPaths(archives, { importance });
|
||||
const unArchive = this.moveNotification({
|
||||
from: NotificationType.ARCHIVE,
|
||||
to: NotificationType.UNREAD,
|
||||
snapshot: overviewSnapshot,
|
||||
});
|
||||
|
||||
const stats = await batchProcess(notifications, unArchive);
|
||||
return { ...stats, overview: overviewSnapshot };
|
||||
}
|
||||
|
||||
/**
|
||||
* Archives notifications with the given id's.
|
||||
*
|
||||
* A notification id looks like '{event_type}_{uuid}.notify'
|
||||
* See `makeNotificationId` for more info.
|
||||
*
|
||||
* ID's are NOT full paths in this context
|
||||
* @param ids a list of '*.notify' id's, which correspond to id files
|
||||
* @returns
|
||||
*/
|
||||
public archiveIds(ids: string[]) {
|
||||
return batchProcess(ids, (id) => this.archiveNotification({ id }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Unarchives (marks as unread) notifications with the given id's.
|
||||
*
|
||||
* A notification id looks like '{event_type}_{uuid}.notify'
|
||||
* See `makeNotificationId` for more info.
|
||||
*
|
||||
* ID's are NOT full paths in this context
|
||||
* @param ids a list of '*.notify' id's, which correspond to id files
|
||||
* @returns
|
||||
*/
|
||||
public unarchiveIds(ids: string[]) {
|
||||
return batchProcess(ids, (id) => this.markAsUnread({ id }));
|
||||
}
|
||||
|
||||
/**------------------------------------------------------------------------
|
||||
* 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.listFilesInFolder(directoryPath);
|
||||
const [notifications] = await this.loadNotificationsFromPaths(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 listFilesInFolder(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 loadNotificationsFromPaths(
|
||||
files: string[],
|
||||
filters: Partial<NotificationFilter>
|
||||
): Promise<[Notification[], unknown[]]> {
|
||||
const { importance, type, offset = 0, limit = files.length } = filters;
|
||||
|
||||
const fileReads = files
|
||||
.slice(offset, limit + offset)
|
||||
.map((file) => this.loadNotificationFile(file, type ?? NotificationType.UNREAD));
|
||||
const results = await Promise.allSettled(fileReads);
|
||||
|
||||
// if the filter is defined & truthy, tests if the actual value matches the filter
|
||||
const passesFilter = <T>(actual: T, filter?: unknown) => !filter || actual === filter;
|
||||
|
||||
return [
|
||||
results
|
||||
.filter(isFulfilled)
|
||||
.map((result) => result.value)
|
||||
.filter(
|
||||
(notification) =>
|
||||
passesFilter(notification.importance, importance) &&
|
||||
passesFilter(notification.type, type)
|
||||
)
|
||||
.sort(this.sortLatestFirst),
|
||||
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.verbose(`Loaded notification ini file from ${path}}`);
|
||||
|
||||
const notification: Notification = this.notificationFileToGqlNotification(
|
||||
{ id: this.getIdFromPath(path), type },
|
||||
notificationFile
|
||||
);
|
||||
|
||||
// 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 getIdFromPath(path: string) {
|
||||
return basename(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes a NotificationIni (ini file data) and a few details of a notification,
|
||||
* and combines them into a Notification object.
|
||||
*
|
||||
* Does *not* validate the returned Notification object or the input file data.
|
||||
* This simply encapsulates data transformation logic.
|
||||
*
|
||||
* @param details The 'id' and 'type' of the notification to be combined.
|
||||
* @param fileData The NotificationIni data from the notification's ini file.
|
||||
* @returns A full Notification object.
|
||||
*/
|
||||
private notificationFileToGqlNotification(
|
||||
details: Pick<Notification, 'id' | 'type'>,
|
||||
fileData: NotificationIni
|
||||
): Notification {
|
||||
const { importance, timestamp, event: title, description = '', ...passthroughData } = fileData;
|
||||
const { type, id } = details;
|
||||
return {
|
||||
...passthroughData,
|
||||
id,
|
||||
type,
|
||||
title,
|
||||
description,
|
||||
importance: this.fileImportanceToGqlImportance(importance),
|
||||
timestamp: this.parseNotificationDateToIsoDate(timestamp),
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**------------------------------------------------------------------------
|
||||
* Helpers
|
||||
*------------------------------------------------------------------------**/
|
||||
|
||||
private sortLatestFirst(a: Notification, b: Notification) {
|
||||
const defaultTimestamp = 0;
|
||||
return Number(b.timestamp ?? defaultTimestamp) - Number(a.timestamp ?? defaultTimestamp);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { getServerIdentifier } from '@app/core/utils/server-identifier';
|
||||
import { getters } from '@app/store/index';
|
||||
import { Query, Resolver } from '@nestjs/graphql';
|
||||
import { UseRoles } from 'nest-access-control';
|
||||
@@ -13,7 +12,7 @@ export class VarsResolver {
|
||||
})
|
||||
public async vars() {
|
||||
return {
|
||||
id: getServerIdentifier('vars'),
|
||||
id: 'vars',
|
||||
...getters.emhttp().var ?? {},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { bootTimestamp } from '@app/common/dashboard/boot-timestamp';
|
||||
import { getServerIdentifier } from '@app/core/utils/server-identifier';
|
||||
import { API_VERSION } from '@app/environment';
|
||||
import { DynamicRemoteAccessType, Service } from '@app/graphql/generated/api/types';
|
||||
import { store } from '@app/store/index';
|
||||
@@ -15,7 +14,7 @@ export class ServicesResolver {
|
||||
const enabledStatus = config.remote.dynamicRemoteAccessType;
|
||||
|
||||
return {
|
||||
id: getServerIdentifier('service/dynamic-remote-access'),
|
||||
id: 'service/dynamic-remote-access',
|
||||
name: 'dynamic-remote-access',
|
||||
online: enabledStatus !== DynamicRemoteAccessType.DISABLED,
|
||||
version: dynamicRemoteAccess.runningType,
|
||||
@@ -27,7 +26,7 @@ export class ServicesResolver {
|
||||
|
||||
private getApiService = (): Service => {
|
||||
return {
|
||||
id: getServerIdentifier('service/unraid-api'),
|
||||
id: 'service/unraid-api',
|
||||
name: 'unraid-api',
|
||||
online: true,
|
||||
uptime: {
|
||||
|
||||
@@ -12,7 +12,7 @@ import { getAllowedOrigins } from '@app/common/allowed-origins';
|
||||
import { HttpExceptionFilter } from '@app/unraid-api/exceptions/http-exceptions.filter';
|
||||
import { GraphQLError } from 'graphql';
|
||||
import { GraphQLExceptionsFilter } from '@app/unraid-api/exceptions/graphql-exceptions.filter';
|
||||
import { BYPASS_PERMISSION_CHECKS, PORT } from '@app/environment';
|
||||
import { BYPASS_CORS_CHECKS, PORT } from '@app/environment';
|
||||
import { type FastifyInstance } from 'fastify';
|
||||
import { type Server, type IncomingMessage, type ServerResponse } from 'http';
|
||||
import { apiLogger } from '@app/core/log';
|
||||
@@ -25,7 +25,7 @@ export const corsOptionsDelegate: CorsOptionsDelegate = async (
|
||||
} else {
|
||||
apiLogger.debug(`Origin not in allowed origins: ${origin}`);
|
||||
|
||||
if (BYPASS_PERMISSION_CHECKS) {
|
||||
if (BYPASS_CORS_CHECKS) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
63
api/src/utils.ts
Normal file
63
api/src/utils.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
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';
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns the number of seconds since Unix Epoch
|
||||
*/
|
||||
export const secondsSinceUnixEpoch = (): number => Math.floor(Date.now() / 1_000);
|
||||
|
||||
/**
|
||||
* Helper to interop with Unraid, which communicates timestamps
|
||||
* in seconds since Unix Epoch.
|
||||
*
|
||||
* @returns the number of seconds since Unix Epoch
|
||||
*/
|
||||
export const unraidTimestamp = secondsSinceUnixEpoch;
|
||||
|
||||
/**
|
||||
* Wrapper for Promise-handling of batch operations based on
|
||||
* a list of items.
|
||||
*
|
||||
* @param items a list of items to process
|
||||
* @param action an async function operating on an item from the list
|
||||
* @returns
|
||||
* - data: return values from each successful action
|
||||
* - errors: list of errors (Promise Failure Reasons)
|
||||
* - successes: # of successful actions
|
||||
* - errorOccured: true if at least one error occurred
|
||||
*/
|
||||
export async function batchProcess<Input, T>(items: Input[], action: (id: Input) => Promise<T>) {
|
||||
const processes = items.map(action);
|
||||
|
||||
const results = await Promise.allSettled(processes);
|
||||
const successes = results.filter(isFulfilled);
|
||||
const errors = results.filter(isRejected).map((result) => result.reason);
|
||||
|
||||
return {
|
||||
data: successes,
|
||||
successes: successes.length,
|
||||
errors: errors,
|
||||
errorOccured: errors.length > 0,
|
||||
};
|
||||
}
|
||||
@@ -310,6 +310,7 @@ if [ -e /etc/rc.d/rc.unraid-api ]; then
|
||||
FILE=/usr/local/emhttp/plugins/dynamix/DisplaySettings.page && [[ -f "$FILE-" ]] && mv -f "$FILE-" "$FILE"
|
||||
FILE=/usr/local/emhttp/plugins/dynamix/Registration.page && [[ -f "$FILE-" ]] && mv -f "$FILE-" "$FILE"
|
||||
FILE=/usr/local/emhttp/plugins/dynamix/include/DefaultPageLayout.php && [[ -f "$FILE-" ]] && mv -f "$FILE-" "$FILE"
|
||||
FILE=/usr/local/emhttp/plugins/dynamix/include/ProvisionCert.php && [[ -f "$FILE-" ]] && mv -f "$FILE-" "$FILE"
|
||||
FILE=/usr/local/emhttp/plugins/dynamix/include/UpdateDNS.php && [[ -f "$FILE-" ]] && mv -f "$FILE-" "$FILE"
|
||||
FILE=/usr/local/emhttp/plugins/dynamix/include/Wrappers.php && [[ -f "$FILE-" ]] && mv -f "$FILE-" "$FILE"
|
||||
FILE=/usr/local/emhttp/plugins/dynamix.plugin.manager/Downgrade.page && [[ -f "$FILE-" ]] && mv -f "$FILE-" "$FILE"
|
||||
@@ -346,6 +347,7 @@ if [ -e /etc/rc.d/rc.unraid-api ]; then
|
||||
rm -f /etc/rc.d/rc6.d/K10_flash_backup
|
||||
rm -f /var/log/gitcount
|
||||
rm -f /var/log/gitflash
|
||||
rm -f /var/log/gitratelimit
|
||||
rm -f /usr/local/emhttp/state/flashbackup.ini
|
||||
rm -f /usr/local/emhttp/state/myservers.cfg
|
||||
# delete any legacy files that may exist
|
||||
@@ -508,6 +510,34 @@ done
|
||||
# no need to restore original file on uninstall
|
||||
if grep -q "keys.lime-technology.com" /etc/hosts &>/dev/null; then sed -i "/keys.lime-technology.com/d" /etc/hosts &>/dev/null; fi
|
||||
|
||||
# patch ProvisionCert.php
|
||||
# search text: curl_init("https://keys.lime-technology.com/account/ssl/provisionwildcard")
|
||||
# curl_init("https://keys.lime-technology.com/account/ssl/$endpoint");
|
||||
# prepend text: see $ADDTEXT4
|
||||
ADDTEXT4=$(
|
||||
cat <<'END_HEREDOC'
|
||||
// added by Unraid Connect
|
||||
// ensure keys.lime-technology.com is not hard-coded in the hosts file
|
||||
exec('if grep -q "keys.lime-technology.com" /etc/hosts &>/dev/null; then sed -i "/keys.lime-technology.com/d" /etc/hosts &>/dev/null; fi');
|
||||
END_HEREDOC
|
||||
)
|
||||
FILE=/usr/local/emhttp/plugins/dynamix/include/ProvisionCert.php
|
||||
# get line number matching the search text
|
||||
# shellcheck disable=SC2016
|
||||
LINENUM=$(grep -n 'curl_init("https://keys.lime-technology.com/account/ssl/provisionwildcard")' "$FILE" | cut -d : -f 1)
|
||||
[[ -z $LINENUM ]] && LINENUM=$(grep -n 'curl_init("https://keys.lime-technology.com/account/ssl/$endpoint")' "$FILE" | cut -d : -f 1)
|
||||
if [[ -n $LINENUM ]]; then
|
||||
# backup the file so it can be restored later
|
||||
cp -f "$FILE" "$FILE-"
|
||||
# sed should work, but it is very difficult to escape
|
||||
# instead, make a new file containing everything before LINENUM, then the new text, then everything including and after LINENUM
|
||||
head -$((LINENUM-1)) "$FILE" > "$FILE~"
|
||||
echo "$ADDTEXT4" >> "$FILE~"
|
||||
echo "" >> "$FILE~"
|
||||
tail +$LINENUM "$FILE" >> "$FILE~"
|
||||
mv -f "$FILE~" "$FILE"
|
||||
fi
|
||||
|
||||
# move settings on flash drive
|
||||
CFG_OLD=/boot/config/plugins/Unraid.net
|
||||
CFG_NEW=/boot/config/plugins/dynamix.my.servers
|
||||
|
||||
@@ -10,6 +10,7 @@ export GIT_OPTIONAL_LOCKS=0
|
||||
|
||||
FAST=1 # 1 second delay when waiting for git
|
||||
SLOW=10 # 10 second delay when waiting for git
|
||||
THIRTYMINS=1800 # 30 minutes is 1800 seconds
|
||||
# wait for existing git commands to complete
|
||||
# $1 is the time in seconds to sleep when waiting. SLOW or FAST
|
||||
_waitforgit() {
|
||||
@@ -101,12 +102,12 @@ _watch() {
|
||||
# wait for flush to complete
|
||||
sleep 3
|
||||
_waitforgitlog "${FAST}"
|
||||
logger "start watching for file changes" --tag flash_backup
|
||||
logger "checking for changes every $THIRTYMINS seconds" --tag flash_backup
|
||||
# start watcher loop
|
||||
while true; do
|
||||
# if system is connected to Unraid Connect Cloud, see if there are updates to process
|
||||
_connected && _f1
|
||||
sleep 60
|
||||
sleep "$THIRTYMINS"
|
||||
done
|
||||
}
|
||||
_f1() {
|
||||
|
||||
@@ -17,6 +17,7 @@ putenv('GIT_OPTIONAL_LOCKS=0');
|
||||
$cli = php_sapi_name()=='cli';
|
||||
|
||||
$docroot ??= ($_SERVER['DOCUMENT_ROOT'] ?: '/usr/local/emhttp');
|
||||
require_once "$docroot/webGui/include/Wrappers.php";
|
||||
|
||||
$myservers_flash_cfg_path='/boot/config/plugins/dynamix.my.servers/myservers.cfg';
|
||||
$myservers = file_exists($myservers_flash_cfg_path) ? @parse_ini_file($myservers_flash_cfg_path,true) : [];
|
||||
@@ -331,8 +332,10 @@ if (file_exists($rateLimitFile)) {
|
||||
$rateLimitRetryTimestamp = (int)@file_get_contents($rateLimitFile);
|
||||
$rateLimitRetryAfter = $rateLimitRetryTimestamp - time();
|
||||
if ($rateLimitRetryAfter > 0) {
|
||||
$msg = !empty($arrState['remoteerror']) ? $arrState['remoteerror'] : 'You are being rate limited - try again in '.$rateLimitRetryAfter.' seconds.';
|
||||
response_complete(406, array('error' => $msg));
|
||||
if (empty($arrState['remoteerror'])) {
|
||||
$arrState['remoteerror'] = 'You are being rate limited - try again in '.$rateLimitRetryAfter.' seconds.';
|
||||
}
|
||||
response_complete(406, array('error' => $arrState['remoteerror']));
|
||||
} else {
|
||||
unlink($rateLimitFile);
|
||||
$arrState['remoteerror'] = "";
|
||||
@@ -406,7 +409,8 @@ $ch = curl_init('https://keys.lime-technology.com/backup/flash/activate');
|
||||
curl_setopt($ch, CURLOPT_POST, 1);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, [
|
||||
'keyfile' => $keyfile,
|
||||
'version' => $var['version'],
|
||||
'version' => _var($var,'version'),
|
||||
'api_version' => _var($mystatus, 'version'),
|
||||
'bzfiles' => implode(',', $bzfilehashes)
|
||||
]);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
@@ -431,13 +435,15 @@ if (!empty($json['warn'])) {
|
||||
}
|
||||
|
||||
// check if being rate limited by keyserver
|
||||
if ($json['retry_after']) {
|
||||
if (!empty($json['retry_after'])) {
|
||||
// add five minute margin to ensure remote rate limit is cleared
|
||||
$rateLimitRetryAfter = $json['retry_after'] + 5*60;
|
||||
$rateLimitRetryTimestamp = time() + $rateLimitRetryAfter;
|
||||
file_put_contents($rateLimitFile, $rateLimitRetryTimestamp);
|
||||
$msg = !empty($arrState['remoteerror']) ? $arrState['remoteerror'] : 'You are being rate limited - try again in '.$rateLimitRetryAfter.' seconds.';
|
||||
response_complete(406, array('error' => $msg));
|
||||
if (empty($arrState['remoteerror'])) {
|
||||
$arrState['remoteerror'] = 'You are being rate limited - try again in '.$rateLimitRetryAfter.' seconds.';
|
||||
}
|
||||
response_complete(406, array('error' => $arrState['remoteerror']));
|
||||
}
|
||||
|
||||
if (empty($json['ssh_privkey']) || empty($json['ssh_pubkey'])) {
|
||||
@@ -504,7 +510,7 @@ if (!file_exists('/boot/.git/info/exclude')) {
|
||||
}
|
||||
|
||||
// setup a nice git description
|
||||
$gitdesc_text='Unraid flash drive for '.$var['NAME']."\n";
|
||||
$gitdesc_text='Unraid flash drive for '._var($var,'NAME')."\n";
|
||||
$gitdesc_file='/boot/.git/description';
|
||||
if (!file_exists($gitdesc_file) || (file_get_contents($gitdesc_file) != $gitdesc_text)) {
|
||||
file_put_contents($gitdesc_file, $gitdesc_text);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
VITE_ACCOUNT=https://localhost:8008
|
||||
VITE_ACCOUNT=http://localhost:5555
|
||||
VITE_CONNECT=https://connect.myunraid.net
|
||||
VITE_UNRAID_NET=https://unraid.ddev.site
|
||||
VITE_OS_RELEASES="https://releases.unraid.net/os"
|
||||
VITE_CALLBACK_KEY=aNotSoSecretKeyUsedToObfuscateQueryParams
|
||||
VITE_ALLOW_CONSOLE_LOGS=false
|
||||
VITE_WEBGUI=http://localhost
|
||||
VITE_ALLOW_CONSOLE_LOGS=true
|
||||
VTIE_TAILWIND_BASE_FONT_SIZE=10
|
||||
|
||||
@@ -1,43 +1,65 @@
|
||||
# connect-components via Nuxt 3
|
||||
|
||||
Look at the [Nuxt 3 documentation](https://nuxt.com/docs/getting-started/introduction) to learn more.
|
||||
|
||||
## Setup
|
||||
|
||||
Make sure to install the dependencies:
|
||||
## Install dependencies
|
||||
|
||||
```bash
|
||||
# npm
|
||||
npm install
|
||||
npm i
|
||||
```
|
||||
|
||||
## Development Server
|
||||
## Dev testing and builds with `.env` setup
|
||||
|
||||
Start the development server on `http://localhost:4321`
|
||||
There's 3 version required for various types of development, testing builds in the Unraid webgui, and creating a prod build for the Unraid webgui.
|
||||
|
||||
- `.env` for `npm run dev` local development
|
||||
- `.env.staging` for `npm run build:dev` which tests builds in the Unraid webgui
|
||||
- `.env.production` for `npm run build:webgui` which does a production build for the Unraid webgui
|
||||
|
||||
For the URL values, you can use what you'd like. So if you're testing locally, you can use `http://localhost:5555` for the account app if you have a local version running. Alternatively you're free to use the staging or production URLs.
|
||||
|
||||
For productions URLs you could ultimately not provide any value and the URL helpers will default to the production URLs. But for local dev and testing, it's usually easiest to keep the `.env` key value pairs so you don't forget about them.
|
||||
|
||||
### `.env` for `npm run dev` local development
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
VITE_ACCOUNT=http://localhost:5555
|
||||
VITE_CONNECT=https://connect.myunraid.net
|
||||
VITE_UNRAID_NET=https://preview.unraid.net
|
||||
VITE_OS_RELEASES="https://releases.unraid.net/os"
|
||||
VITE_CALLBACK_KEY="FIND_IN_1PASSWORD"
|
||||
VITE_ALLOW_CONSOLE_LOGS=true
|
||||
VITE_TAILWIND_BASE_FONT_SIZE=16
|
||||
```
|
||||
|
||||
## Production
|
||||
## `.env.staging` for `npm run build:dev` which tests builds in the Unraid webgui
|
||||
|
||||
Build the application for production:
|
||||
Please take a look at the `prebuild:dev` & `postbuild:dev` scripts in `package.json` to see how the `.env.staging` file is used.
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
VITE_ACCOUNT=https://staging.account.unraid.net
|
||||
VITE_CONNECT=https://connect.myunraid.net
|
||||
VITE_UNRAID_NET=https://staging.unraid.net
|
||||
VITE_OS_RELEASES="https://releases.unraid.net/os"
|
||||
VITE_CALLBACK_KEY="FIND_IN_1PASSWORD"
|
||||
VITE_ALLOW_CONSOLE_LOGS=TRUE
|
||||
```
|
||||
|
||||
Locally preview production build:
|
||||
Notice how `VITE_TAILWIND_BASE_FONT_SIZE` is not set in the `.env.staging` file.
|
||||
This is because the Unraid webgui uses the `font-size: 62.5%` "trick".
|
||||
|
||||
### `.env.production` for `npm run build:webgui` which does a production build for the Unraid webgui
|
||||
|
||||
Please take a look at the `prebuild:webgui` & `postbuild:webgui` scripts in `package.json` to see how the `.env.production` file is used.
|
||||
|
||||
```bash
|
||||
npm run preview
|
||||
VITE_ACCOUNT=https://account.unraid.net
|
||||
VITE_CONNECT=https://connect.myunraid.net
|
||||
VITE_UNRAID_NET=https://unraid.net
|
||||
VITE_OS_RELEASES="https://releases.unraid.net/os"
|
||||
VITE_CALLBACK_KEY="FIND_IN_1PASSWORD"
|
||||
```
|
||||
|
||||
Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.
|
||||
|
||||
## Build for Unraid webgui [@TODO]
|
||||
Instructions to come
|
||||
Both `VITE_ALLOW_CONSOLE_LOGS` and `VITE_TAILWIND_BASE_FONT_SIZE` should never be set here.
|
||||
|
||||
## Interfacing with `unraid-api`
|
||||
|
||||
@todo https://v4.apollo.vuejs.org/guide-composable/fragments.html#colocating-fragments
|
||||
@todo [Apollo VueJS Guide on Colocating Fragments](https://v4.apollo.vuejs.org/guide-composable/fragments.html#colocating-fragments)
|
||||
|
||||
@@ -44,12 +44,10 @@ import type {
|
||||
// EBLACKLISTED2
|
||||
// ENOCONN
|
||||
|
||||
// '1111-1111-5GDB-123412341234' Starter.key = TkJCrVyXMLWWGKZF6TCEvf0C86UYI9KfUDSOm7JoFP19tOMTMgLKcJ6QIOt9_9Psg_t0yF-ANmzSgZzCo94ljXoPm4BESFByR0K7nyY9KVvU8szLEUcBUT3xC2adxLrAXFNxiPeK-mZqt34n16uETKYvLKL_Sr5_JziG5L5lJFBqYZCPmfLMiguFo1vp0xL8pnBH7q8bYoBnePrAcAVb9mAGxFVPEInSPkMBfC67JLHz7XY1Y_K5bYIq3go9XPtLltJ53_U4BQiMHooXUBJCKXodpqoGxq0eV0IhNEYdauAhnTsG90qmGZig0hZalQ0soouc4JZEMiYEcZbn9mBxPg
|
||||
|
||||
const state: ServerState = "BASIC" as ServerState;
|
||||
const currentFlashGuid = "1111-1111-CFXF-TEST1234ZACK"; // this is the flash drive that's been booted from
|
||||
const regGuid = "1111-1111-CFXF-TEST1234ZACK"; // this guid is registered in key server
|
||||
const keyfileBase64 = "asdf"; // @todo raycast download key to base64
|
||||
const state: ServerState = "ENOKEYFILE2" as ServerState;
|
||||
const currentFlashGuid = "4444-1111-FOUR-999900008888"; // this is the flash drive that's been booted from
|
||||
const regGuid = "4444-1111-FOUR-999900008888"; // this guid is registered in key server
|
||||
const keyfileBase64 = "";
|
||||
|
||||
// const randomGuid = `1111-1111-${makeid(4)}-123412341234`; // this guid is registered in key server
|
||||
// const newGuid = `1234-1234-${makeid(4)}-123412341234`; // this is a new USB, not registered
|
||||
@@ -109,7 +107,7 @@ switch (state) {
|
||||
// const connectPluginInstalled = 'dynamix.unraid.net.staging.plg';
|
||||
const connectPluginInstalled = "";
|
||||
|
||||
const osVersion = "6.12.8";
|
||||
const osVersion = "7.0.0-beta.2.10";
|
||||
const osVersionBranch = "stable";
|
||||
// const parsedRegExp = regExp ? dayjs(regExp).format('YYYY-MM-DD') : undefined;
|
||||
|
||||
@@ -159,8 +157,8 @@ export const serverState: Server = {
|
||||
name: "dev-static",
|
||||
osVersion,
|
||||
osVersionBranch,
|
||||
// registered: connectPluginInstalled ? true : false,
|
||||
registered: false,
|
||||
registered: connectPluginInstalled ? true : false,
|
||||
// registered: false,
|
||||
regGen: 0,
|
||||
regTm: twoDaysAgo,
|
||||
regTo: "Zack Spear",
|
||||
|
||||
@@ -37,3 +37,79 @@ body {
|
||||
|
||||
/* Ensure this is always at the bottom – @see https://tailwindcss.com/docs/content-configuration#working-with-third-party-libraries */
|
||||
@tailwind utilities;
|
||||
|
||||
/* shadcn */
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 0 0% 3.9%;
|
||||
|
||||
--muted: 0 0% 96.1%;
|
||||
--muted-foreground: 0 0% 45.1%;
|
||||
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 0 0% 3.9%;
|
||||
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 0 0% 3.9%;
|
||||
|
||||
--border: 0 0% 89.8%;
|
||||
--input: 0 0% 89.8%;
|
||||
|
||||
--primary: 0 0% 9%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
|
||||
--secondary: 0 0% 96.1%;
|
||||
--secondary-foreground: 0 0% 9%;
|
||||
|
||||
--accent: 0 0% 96.1%;
|
||||
--accent-foreground: 0 0% 9%;
|
||||
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
|
||||
--ring: 0 0% 3.9%;
|
||||
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 0 0% 3.9%;
|
||||
--foreground: 0 0% 98%;
|
||||
|
||||
--muted: 0 0% 14.9%;
|
||||
--muted-foreground: 0 0% 63.9%;
|
||||
|
||||
--popover: 0 0% 3.9%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
|
||||
--card: 0 0% 3.9%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
|
||||
--border: 0 0% 14.9%;
|
||||
--input: 0 0% 14.9%;
|
||||
|
||||
--primary: 0 0% 98%;
|
||||
--primary-foreground: 0 0% 9%;
|
||||
|
||||
--secondary: 0 0% 14.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
|
||||
--accent: 0 0% 14.9%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
|
||||
--ring: 0 0% 83.1%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
17
web/components.json
Normal file
17
web/components.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"$schema": "https://shadcn-vue.com/schema.json",
|
||||
"style": "default",
|
||||
"typescript": true,
|
||||
"tsConfigPath": "tsconfig.json",
|
||||
"tailwind": {
|
||||
"config": "tailwind-shadcn.config.ts",
|
||||
"css": "assets/main.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true
|
||||
},
|
||||
"framework": "nuxt",
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/helpers/utils"
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ const { releaseForUpdate: updateOsChangelogModalVisible } = storeToRefs(useUpdat
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative z-[99999]">
|
||||
<div id="modals" class="relative z-[99999]">
|
||||
<UpcCallbackFeedback :t="t" :open="callbackStatus !== 'ready'" />
|
||||
<UpcTrial :t="t" :open="trialModalVisible" />
|
||||
<UpdateOsCheckUpdateResponseModal :t="t" :open="updateOsModalVisible" />
|
||||
|
||||
72
web/components/Notifications/Item.vue
Normal file
72
web/components/Notifications/Item.vue
Normal file
@@ -0,0 +1,72 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ArchiveBoxIcon,
|
||||
ShieldExclamationIcon,
|
||||
CheckBadgeIcon,
|
||||
ExclamationTriangleIcon,
|
||||
ChevronRightIcon,
|
||||
} from '@heroicons/vue/24/solid';
|
||||
|
||||
import type { NotificationItemProps } from '~/types/ui/notification';
|
||||
|
||||
const props = defineProps<NotificationItemProps>();
|
||||
|
||||
const icon = computed<{ component: Component, color: string } | null>(() => {
|
||||
switch (props.importance) {
|
||||
case 'INFO':
|
||||
return {
|
||||
component: CheckBadgeIcon,
|
||||
color: 'text-green-500',
|
||||
};
|
||||
case 'WARNING':
|
||||
return {
|
||||
component: ExclamationTriangleIcon,
|
||||
color: 'text-yellow-500',
|
||||
};
|
||||
case 'ALERT':
|
||||
return {
|
||||
component: ShieldExclamationIcon,
|
||||
color: 'text-red-500',
|
||||
};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="group/item relative w-full py-4 flex flex-col gap-2">
|
||||
<header class="w-full flex flex-row items-start justify-between gap-2">
|
||||
<h3 class="text-16px font-semibold leading-2 flex flex-row items-start gap-2">
|
||||
<component :is="icon.component" v-if="icon" class="size-6 shrink-0" :class="icon.color" />
|
||||
<span>{{ title }} • {{ subject }}</span>
|
||||
</h3>
|
||||
|
||||
<div class="shrink-0 flex flex-row items-center justify-end gap-2 mt-1">
|
||||
<p class="text-12px opacity-75">{{ timestamp }}</p>
|
||||
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button class="relative z-20">
|
||||
<span class="sr-only">Archive</span>
|
||||
<ArchiveBoxIcon class="size-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Archive</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="w-full flex flex-row items-center justify-between gap-2 opacity-75 group-hover/item:opacity-100 group-focus/item:opacity-100">
|
||||
<p>{{ description }}</p>
|
||||
<ChevronRightIcon class="size-4" />
|
||||
</div>
|
||||
|
||||
<a :href="link" class="absolute z-10 inset-0">
|
||||
<span class="sr-only">Take me there</span>
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
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>
|
||||
|
||||
122
web/components/Notifications/Sidebar.vue
Normal file
122
web/components/Notifications/Sidebar.vue
Normal file
@@ -0,0 +1,122 @@
|
||||
<script setup lang="ts">
|
||||
import { BellIcon } from "@heroicons/vue/24/solid";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "@/components/shadcn/sheet";
|
||||
|
||||
import type { NotificationItemProps } from "~/types/ui/notification";
|
||||
import { useUnraidApiStore } from "~/store/unraidApi";
|
||||
import gql from "graphql-tag";
|
||||
|
||||
const getNotifications = gql`
|
||||
query Notifications($filter: NotificationFilter!) {
|
||||
notifications {
|
||||
list(filter: $filter) {
|
||||
id
|
||||
title
|
||||
subject
|
||||
description
|
||||
importance
|
||||
link
|
||||
type
|
||||
timestamp
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const notifications = ref<NotificationItemProps[]>([]);
|
||||
watch(notifications, (newVal) => {
|
||||
console.log('[notifications]', newVal);
|
||||
});
|
||||
|
||||
const fetchType = ref<'UNREAD' | 'ARCHIVED'>('UNREAD');
|
||||
const setFetchType = (type: 'UNREAD' | 'ARCHIVED') => fetchType.value = type;
|
||||
|
||||
const { unraidApiClient } = storeToRefs(useUnraidApiStore());
|
||||
|
||||
watch(unraidApiClient, async(newVal) => {
|
||||
if (newVal) {
|
||||
const apiResponse = await newVal.query({
|
||||
query: getNotifications,
|
||||
variables: {
|
||||
filter: {
|
||||
offset: 0,
|
||||
limit: 10,
|
||||
type: fetchType.value,
|
||||
},
|
||||
},
|
||||
});
|
||||
notifications.value = apiResponse.data.notifications.list;
|
||||
}
|
||||
});
|
||||
|
||||
const { teleportTarget, determineTeleportTarget } = useTeleport();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Sheet>
|
||||
<SheetTrigger @click="determineTeleportTarget">
|
||||
<span class="sr-only">Notifications</span>
|
||||
<BellIcon class="w-6 h-6" />
|
||||
</SheetTrigger>
|
||||
|
||||
<SheetContent :to="teleportTarget" class="w-full max-w-[400px] sm:max-w-[540px]">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Notifications</SheetTitle>
|
||||
</SheetHeader>
|
||||
|
||||
<div class="flex flex-row justify-between items-center">
|
||||
<div class="w-auto flex flex-row justify-start items-center gap-1 p-2 rounded">
|
||||
<Button
|
||||
v-for="opt in ['Unread', 'Archived']"
|
||||
:key="opt"
|
||||
:variant="fetchType === opt ? 'secondary' : undefined"
|
||||
class="py-2 px-4 text-left"
|
||||
@click="setFetchType(opt.toUpperCase() as 'UNREAD' | 'ARCHIVED')"
|
||||
>
|
||||
{{ opt }}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="w-auto flex flex-row justify-start items-center gap-1 p-2 rounded">
|
||||
<Button
|
||||
variant="secondary"
|
||||
class="py-2 px-4 text-left"
|
||||
>
|
||||
{{ `Archive All` }}
|
||||
</Button>
|
||||
<Select>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Filter" />
|
||||
</SelectTrigger>
|
||||
<SelectContent :to="teleportTarget">
|
||||
<SelectGroup>
|
||||
<SelectLabel>Notification Types</SelectLabel>
|
||||
<SelectItem value="alert">Alert</SelectItem>
|
||||
<SelectItem value="info">Info</SelectItem>
|
||||
<SelectItem value="warning">Warning</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divide-y divide-gray-200">
|
||||
<NotificationsItem
|
||||
v-for="notification in notifications"
|
||||
:key="notification.id"
|
||||
v-bind="notification"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SheetFooter class="text-center">
|
||||
<p>Future pagination station</p>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</template>
|
||||
@@ -294,7 +294,7 @@ const modalWidth = computed(() => {
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="!checkForUpdatesLoading" #footer>
|
||||
<template #footer>
|
||||
<div
|
||||
class="w-full flex gap-8px mx-auto"
|
||||
:class="{
|
||||
|
||||
@@ -101,7 +101,7 @@ onBeforeMount(() => {
|
||||
>
|
||||
<div v-if="bannerGradient" class="absolute z-0 w-[125%] top-0 bottom-0 right-0" :style="bannerGradient" />
|
||||
|
||||
<div class="text-gamma text-10px xs:text-12px text-right font-semibold leading-normal relative z-10 flex flex-col items-end justify-end gap-x-4px xs:flex-row xs:items-baseline xs:gap-x-12px">
|
||||
<div class="text-xs text-gamma text-right font-semibold leading-normal relative z-10 flex flex-col items-end justify-end gap-x-4px xs:flex-row xs:items-baseline xs:gap-x-12px">
|
||||
<UpcUptimeExpire :t="t" />
|
||||
<span class="hidden xs:block">•</span>
|
||||
<UpcServerState :t="t" />
|
||||
@@ -127,6 +127,8 @@ onBeforeMount(() => {
|
||||
|
||||
<div class="block w-2px h-24px bg-gamma" />
|
||||
|
||||
<!-- <NotificationsSidebar /> -->
|
||||
|
||||
<OnClickOutside class="flex items-center justify-end h-full" :options="{ ignore: [clickOutsideIgnoreTarget] }" @trigger="outsideDropdown">
|
||||
<UpcDropdownTrigger ref="clickOutsideIgnoreTarget" :t="t" />
|
||||
<UpcDropdown ref="clickOutsideTarget" :t="t" />
|
||||
|
||||
@@ -23,7 +23,6 @@ const showExternalIconOnHover = computed(() => props.item?.external && props.ite
|
||||
:is="item?.click ? 'button' : 'a'"
|
||||
:disabled="item?.disabled"
|
||||
:href="item?.href ?? null"
|
||||
:title="item?.title ? t(item?.title) : null"
|
||||
:target="item?.external ? '_blank' : null"
|
||||
:rel="item?.external ? 'noopener noreferrer' : null"
|
||||
class="text-left text-14px w-full flex flex-row items-center justify-between gap-x-8px px-8px py-8px cursor-pointer"
|
||||
|
||||
26
web/components/shadcn/button/Button.vue
Normal file
26
web/components/shadcn/button/Button.vue
Normal file
@@ -0,0 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { Primitive, type PrimitiveProps } from 'radix-vue'
|
||||
import { type ButtonVariants, buttonVariants } from '.'
|
||||
import { cn } from '@/helpers/utils'
|
||||
|
||||
interface Props extends PrimitiveProps {
|
||||
variant?: ButtonVariants['variant']
|
||||
size?: ButtonVariants['size']
|
||||
class?: HTMLAttributes['class']
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
as: 'button',
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
:as="as"
|
||||
:as-child="asChild"
|
||||
:class="cn(buttonVariants({ variant, size }), props.class)"
|
||||
>
|
||||
<slot />
|
||||
</Primitive>
|
||||
</template>
|
||||
35
web/components/shadcn/button/index.ts
Normal file
35
web/components/shadcn/button/index.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { type VariantProps, cva } from 'class-variance-authority'
|
||||
|
||||
export { default as Button } from './Button.vue'
|
||||
|
||||
export const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
destructive:
|
||||
'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
||||
outline:
|
||||
'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
|
||||
secondary:
|
||||
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
default: 'h-10 px-4 py-2',
|
||||
xs: 'h-7 rounded px-2',
|
||||
sm: 'h-9 rounded-md px-3',
|
||||
lg: 'h-11 rounded-md px-8',
|
||||
icon: 'h-10 w-10',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
export type ButtonVariants = VariantProps<typeof buttonVariants>
|
||||
14
web/components/shadcn/dropdown-menu/DropdownMenu.vue
Normal file
14
web/components/shadcn/dropdown-menu/DropdownMenu.vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { DropdownMenuRoot, type DropdownMenuRootEmits, type DropdownMenuRootProps, useForwardPropsEmits } from 'radix-vue'
|
||||
|
||||
const props = defineProps<DropdownMenuRootProps>()
|
||||
const emits = defineEmits<DropdownMenuRootEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuRoot v-bind="forwarded">
|
||||
<slot />
|
||||
</DropdownMenuRoot>
|
||||
</template>
|
||||
@@ -0,0 +1,40 @@
|
||||
<script setup lang="ts">
|
||||
import { type HTMLAttributes, computed } from 'vue'
|
||||
import {
|
||||
DropdownMenuCheckboxItem,
|
||||
type DropdownMenuCheckboxItemEmits,
|
||||
type DropdownMenuCheckboxItemProps,
|
||||
DropdownMenuItemIndicator,
|
||||
useForwardPropsEmits,
|
||||
} from 'radix-vue'
|
||||
import { Check } from 'lucide-vue-next'
|
||||
import { cn } from '@/helpers/utils'
|
||||
|
||||
const props = defineProps<DropdownMenuCheckboxItemProps & { class?: HTMLAttributes['class'] }>()
|
||||
const emits = defineEmits<DropdownMenuCheckboxItemEmits>()
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props
|
||||
|
||||
return delegated
|
||||
})
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuCheckboxItem
|
||||
v-bind="forwarded"
|
||||
:class=" cn(
|
||||
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<span class="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuItemIndicator>
|
||||
<Check class="w-4 h-4" />
|
||||
</DropdownMenuItemIndicator>
|
||||
</span>
|
||||
<slot />
|
||||
</DropdownMenuCheckboxItem>
|
||||
</template>
|
||||
38
web/components/shadcn/dropdown-menu/DropdownMenuContent.vue
Normal file
38
web/components/shadcn/dropdown-menu/DropdownMenuContent.vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import { type HTMLAttributes, computed } from 'vue'
|
||||
import {
|
||||
DropdownMenuContent,
|
||||
type DropdownMenuContentEmits,
|
||||
type DropdownMenuContentProps,
|
||||
DropdownMenuPortal,
|
||||
useForwardPropsEmits,
|
||||
} from 'radix-vue'
|
||||
import { cn } from '@/helpers/utils'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<DropdownMenuContentProps & { class?: HTMLAttributes['class'] }>(),
|
||||
{
|
||||
sideOffset: 4,
|
||||
},
|
||||
)
|
||||
const emits = defineEmits<DropdownMenuContentEmits>()
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props
|
||||
|
||||
return delegated
|
||||
})
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent
|
||||
v-bind="forwarded"
|
||||
:class="cn('z-50 min-w-32 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuPortal>
|
||||
</template>
|
||||
11
web/components/shadcn/dropdown-menu/DropdownMenuGroup.vue
Normal file
11
web/components/shadcn/dropdown-menu/DropdownMenuGroup.vue
Normal file
@@ -0,0 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { DropdownMenuGroup, type DropdownMenuGroupProps } from 'radix-vue'
|
||||
|
||||
const props = defineProps<DropdownMenuGroupProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuGroup v-bind="props">
|
||||
<slot />
|
||||
</DropdownMenuGroup>
|
||||
</template>
|
||||
28
web/components/shadcn/dropdown-menu/DropdownMenuItem.vue
Normal file
28
web/components/shadcn/dropdown-menu/DropdownMenuItem.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import { type HTMLAttributes, computed } from 'vue'
|
||||
import { DropdownMenuItem, type DropdownMenuItemProps, useForwardProps } from 'radix-vue'
|
||||
import { cn } from '@/helpers/utils'
|
||||
|
||||
const props = defineProps<DropdownMenuItemProps & { class?: HTMLAttributes['class'], inset?: boolean }>()
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props
|
||||
|
||||
return delegated
|
||||
})
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuItem
|
||||
v-bind="forwardedProps"
|
||||
:class="cn(
|
||||
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
inset && 'pl-8',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<slot />
|
||||
</DropdownMenuItem>
|
||||
</template>
|
||||
24
web/components/shadcn/dropdown-menu/DropdownMenuLabel.vue
Normal file
24
web/components/shadcn/dropdown-menu/DropdownMenuLabel.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import { type HTMLAttributes, computed } from 'vue'
|
||||
import { DropdownMenuLabel, type DropdownMenuLabelProps, useForwardProps } from 'radix-vue'
|
||||
import { cn } from '@/helpers/utils'
|
||||
|
||||
const props = defineProps<DropdownMenuLabelProps & { class?: HTMLAttributes['class'], inset?: boolean }>()
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props
|
||||
|
||||
return delegated
|
||||
})
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuLabel
|
||||
v-bind="forwardedProps"
|
||||
:class="cn('px-2 py-1.5 text-sm font-semibold', inset && 'pl-8', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</DropdownMenuLabel>
|
||||
</template>
|
||||
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
DropdownMenuRadioGroup,
|
||||
type DropdownMenuRadioGroupEmits,
|
||||
type DropdownMenuRadioGroupProps,
|
||||
useForwardPropsEmits,
|
||||
} from 'radix-vue'
|
||||
|
||||
const props = defineProps<DropdownMenuRadioGroupProps>()
|
||||
const emits = defineEmits<DropdownMenuRadioGroupEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuRadioGroup v-bind="forwarded">
|
||||
<slot />
|
||||
</DropdownMenuRadioGroup>
|
||||
</template>
|
||||
@@ -0,0 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
import { type HTMLAttributes, computed } from 'vue'
|
||||
import {
|
||||
DropdownMenuItemIndicator,
|
||||
DropdownMenuRadioItem,
|
||||
type DropdownMenuRadioItemEmits,
|
||||
type DropdownMenuRadioItemProps,
|
||||
useForwardPropsEmits,
|
||||
} from 'radix-vue'
|
||||
import { Circle } from 'lucide-vue-next'
|
||||
import { cn } from '@/helpers/utils'
|
||||
|
||||
const props = defineProps<DropdownMenuRadioItemProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const emits = defineEmits<DropdownMenuRadioItemEmits>()
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props
|
||||
|
||||
return delegated
|
||||
})
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuRadioItem
|
||||
v-bind="forwarded"
|
||||
:class="cn(
|
||||
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<span class="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuItemIndicator>
|
||||
<Circle class="h-2 w-2 fill-current" />
|
||||
</DropdownMenuItemIndicator>
|
||||
</span>
|
||||
<slot />
|
||||
</DropdownMenuRadioItem>
|
||||
</template>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import { type HTMLAttributes, computed } from 'vue'
|
||||
import {
|
||||
DropdownMenuSeparator,
|
||||
type DropdownMenuSeparatorProps,
|
||||
} from 'radix-vue'
|
||||
import { cn } from '@/helpers/utils'
|
||||
|
||||
const props = defineProps<DropdownMenuSeparatorProps & {
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props
|
||||
|
||||
return delegated
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuSeparator v-bind="delegatedProps" :class="cn('-mx-1 my-1 h-px bg-muted', props.class)" />
|
||||
</template>
|
||||
14
web/components/shadcn/dropdown-menu/DropdownMenuShortcut.vue
Normal file
14
web/components/shadcn/dropdown-menu/DropdownMenuShortcut.vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/helpers/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span :class="cn('ml-auto text-xs tracking-widest opacity-60', props.class)">
|
||||
<slot />
|
||||
</span>
|
||||
</template>
|
||||
19
web/components/shadcn/dropdown-menu/DropdownMenuSub.vue
Normal file
19
web/components/shadcn/dropdown-menu/DropdownMenuSub.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
DropdownMenuSub,
|
||||
type DropdownMenuSubEmits,
|
||||
type DropdownMenuSubProps,
|
||||
useForwardPropsEmits,
|
||||
} from 'radix-vue'
|
||||
|
||||
const props = defineProps<DropdownMenuSubProps>()
|
||||
const emits = defineEmits<DropdownMenuSubEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuSub v-bind="forwarded">
|
||||
<slot />
|
||||
</DropdownMenuSub>
|
||||
</template>
|
||||
@@ -0,0 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import { type HTMLAttributes, computed } from 'vue'
|
||||
import {
|
||||
DropdownMenuSubContent,
|
||||
type DropdownMenuSubContentEmits,
|
||||
type DropdownMenuSubContentProps,
|
||||
useForwardPropsEmits,
|
||||
} from 'radix-vue'
|
||||
import { cn } from '@/helpers/utils'
|
||||
|
||||
const props = defineProps<DropdownMenuSubContentProps & { class?: HTMLAttributes['class'] }>()
|
||||
const emits = defineEmits<DropdownMenuSubContentEmits>()
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props
|
||||
|
||||
return delegated
|
||||
})
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuSubContent
|
||||
v-bind="forwarded"
|
||||
:class="cn('z-50 min-w-32 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</DropdownMenuSubContent>
|
||||
</template>
|
||||
@@ -0,0 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import { type HTMLAttributes, computed } from 'vue'
|
||||
import {
|
||||
DropdownMenuSubTrigger,
|
||||
type DropdownMenuSubTriggerProps,
|
||||
useForwardProps,
|
||||
} from 'radix-vue'
|
||||
import { ChevronRight } from 'lucide-vue-next'
|
||||
import { cn } from '@/helpers/utils'
|
||||
|
||||
const props = defineProps<DropdownMenuSubTriggerProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props
|
||||
|
||||
return delegated
|
||||
})
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuSubTrigger
|
||||
v-bind="forwardedProps"
|
||||
:class="cn(
|
||||
'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<slot />
|
||||
<ChevronRight class="ml-auto h-4 w-4" />
|
||||
</DropdownMenuSubTrigger>
|
||||
</template>
|
||||
13
web/components/shadcn/dropdown-menu/DropdownMenuTrigger.vue
Normal file
13
web/components/shadcn/dropdown-menu/DropdownMenuTrigger.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { DropdownMenuTrigger, type DropdownMenuTriggerProps, useForwardProps } from 'radix-vue'
|
||||
|
||||
const props = defineProps<DropdownMenuTriggerProps>()
|
||||
|
||||
const forwardedProps = useForwardProps(props)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuTrigger class="outline-none" v-bind="forwardedProps">
|
||||
<slot />
|
||||
</DropdownMenuTrigger>
|
||||
</template>
|
||||
16
web/components/shadcn/dropdown-menu/index.ts
Normal file
16
web/components/shadcn/dropdown-menu/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export { DropdownMenuPortal } from 'radix-vue'
|
||||
|
||||
export { default as DropdownMenu } from './DropdownMenu.vue'
|
||||
export { default as DropdownMenuTrigger } from './DropdownMenuTrigger.vue'
|
||||
export { default as DropdownMenuContent } from './DropdownMenuContent.vue'
|
||||
export { default as DropdownMenuGroup } from './DropdownMenuGroup.vue'
|
||||
export { default as DropdownMenuRadioGroup } from './DropdownMenuRadioGroup.vue'
|
||||
export { default as DropdownMenuItem } from './DropdownMenuItem.vue'
|
||||
export { default as DropdownMenuCheckboxItem } from './DropdownMenuCheckboxItem.vue'
|
||||
export { default as DropdownMenuRadioItem } from './DropdownMenuRadioItem.vue'
|
||||
export { default as DropdownMenuShortcut } from './DropdownMenuShortcut.vue'
|
||||
export { default as DropdownMenuSeparator } from './DropdownMenuSeparator.vue'
|
||||
export { default as DropdownMenuLabel } from './DropdownMenuLabel.vue'
|
||||
export { default as DropdownMenuSub } from './DropdownMenuSub.vue'
|
||||
export { default as DropdownMenuSubTrigger } from './DropdownMenuSubTrigger.vue'
|
||||
export { default as DropdownMenuSubContent } from './DropdownMenuSubContent.vue'
|
||||
24
web/components/shadcn/input/Input.vue
Normal file
24
web/components/shadcn/input/Input.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { useVModel } from '@vueuse/core'
|
||||
import { cn } from '@/helpers/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
defaultValue?: string | number
|
||||
modelValue?: string | number
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
|
||||
const emits = defineEmits<{
|
||||
(e: 'update:modelValue', payload: string | number): void
|
||||
}>()
|
||||
|
||||
const modelValue = useVModel(props, 'modelValue', emits, {
|
||||
passive: true,
|
||||
defaultValue: props.defaultValue,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<input v-model="modelValue" :class="cn('flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50', props.class)">
|
||||
</template>
|
||||
1
web/components/shadcn/input/index.ts
Normal file
1
web/components/shadcn/input/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as Input } from './Input.vue'
|
||||
27
web/components/shadcn/label/Label.vue
Normal file
27
web/components/shadcn/label/Label.vue
Normal file
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import { type HTMLAttributes, computed } from 'vue'
|
||||
import { Label, type LabelProps } from 'radix-vue'
|
||||
import { cn } from '@/helpers/utils'
|
||||
|
||||
const props = defineProps<LabelProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props
|
||||
|
||||
return delegated
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Label
|
||||
v-bind="delegatedProps"
|
||||
:class="
|
||||
cn(
|
||||
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</Label>
|
||||
</template>
|
||||
1
web/components/shadcn/label/index.ts
Normal file
1
web/components/shadcn/label/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as Label } from './Label.vue'
|
||||
15
web/components/shadcn/select/Select.vue
Normal file
15
web/components/shadcn/select/Select.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import type { SelectRootEmits, SelectRootProps } from 'radix-vue'
|
||||
import { SelectRoot, useForwardPropsEmits } from 'radix-vue'
|
||||
|
||||
const props = defineProps<SelectRootProps>()
|
||||
const emits = defineEmits<SelectRootEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectRoot v-bind="forwarded">
|
||||
<slot />
|
||||
</SelectRoot>
|
||||
</template>
|
||||
58
web/components/shadcn/select/SelectContent.vue
Normal file
58
web/components/shadcn/select/SelectContent.vue
Normal file
@@ -0,0 +1,58 @@
|
||||
<script setup lang="ts">
|
||||
import { type HTMLAttributes, computed } from 'vue'
|
||||
import {
|
||||
SelectContent,
|
||||
type SelectContentEmits,
|
||||
type SelectContentProps,
|
||||
SelectPortal,
|
||||
SelectViewport,
|
||||
useForwardPropsEmits,
|
||||
} from 'radix-vue'
|
||||
import { SelectScrollDownButton, SelectScrollUpButton } from '.'
|
||||
import { cn } from '@/helpers/utils'
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<SelectContentProps & {
|
||||
class?: HTMLAttributes['class']
|
||||
disabled?: boolean
|
||||
forceMount?: boolean
|
||||
to?: string | HTMLElement | Element
|
||||
}>(),
|
||||
{
|
||||
position: 'popper',
|
||||
},
|
||||
)
|
||||
const emits = defineEmits<SelectContentEmits>()
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props
|
||||
|
||||
return delegated
|
||||
})
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectPortal :disabled="disabled" :force-mount="forceMount" :to="to">
|
||||
<SelectContent
|
||||
v-bind="{ ...forwarded, ...$attrs }" :class="cn(
|
||||
'relative z-50 max-h-96 min-w-32 overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
position === 'popper'
|
||||
&& 'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectViewport :class="cn('p-1', position === 'popper' && 'h-[--radix-select-trigger-height] w-full min-w-[--radix-select-trigger-width]')">
|
||||
<slot />
|
||||
</SelectViewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectContent>
|
||||
</SelectPortal>
|
||||
</template>
|
||||
19
web/components/shadcn/select/SelectGroup.vue
Normal file
19
web/components/shadcn/select/SelectGroup.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import { type HTMLAttributes, computed } from 'vue'
|
||||
import { SelectGroup, type SelectGroupProps } from 'radix-vue'
|
||||
import { cn } from '@/helpers/utils'
|
||||
|
||||
const props = defineProps<SelectGroupProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props
|
||||
|
||||
return delegated
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectGroup :class="cn('p-1 w-full', props.class)" v-bind="delegatedProps">
|
||||
<slot />
|
||||
</SelectGroup>
|
||||
</template>
|
||||
44
web/components/shadcn/select/SelectItem.vue
Normal file
44
web/components/shadcn/select/SelectItem.vue
Normal file
@@ -0,0 +1,44 @@
|
||||
<script setup lang="ts">
|
||||
import { type HTMLAttributes, computed } from 'vue'
|
||||
import {
|
||||
SelectItem,
|
||||
SelectItemIndicator,
|
||||
type SelectItemProps,
|
||||
SelectItemText,
|
||||
useForwardProps,
|
||||
} from 'radix-vue'
|
||||
import { Check } from 'lucide-vue-next'
|
||||
import { cn } from '@/helpers/utils'
|
||||
|
||||
const props = defineProps<SelectItemProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props
|
||||
|
||||
return delegated
|
||||
})
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectItem
|
||||
v-bind="forwardedProps"
|
||||
:class="
|
||||
cn(
|
||||
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<span class="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectItemIndicator>
|
||||
<Check class="h-4 w-4" />
|
||||
</SelectItemIndicator>
|
||||
</span>
|
||||
|
||||
<SelectItemText>
|
||||
<slot />
|
||||
</SelectItemText>
|
||||
</SelectItem>
|
||||
</template>
|
||||
11
web/components/shadcn/select/SelectItemText.vue
Normal file
11
web/components/shadcn/select/SelectItemText.vue
Normal file
@@ -0,0 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { SelectItemText, type SelectItemTextProps } from 'radix-vue'
|
||||
|
||||
const props = defineProps<SelectItemTextProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectItemText v-bind="props">
|
||||
<slot />
|
||||
</SelectItemText>
|
||||
</template>
|
||||
13
web/components/shadcn/select/SelectLabel.vue
Normal file
13
web/components/shadcn/select/SelectLabel.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { SelectLabel, type SelectLabelProps } from 'radix-vue'
|
||||
import { cn } from '@/helpers/utils'
|
||||
|
||||
const props = defineProps<SelectLabelProps & { class?: HTMLAttributes['class'] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectLabel :class="cn('py-1.5 pl-8 pr-2 text-sm font-semibold', props.class)">
|
||||
<slot />
|
||||
</SelectLabel>
|
||||
</template>
|
||||
24
web/components/shadcn/select/SelectScrollDownButton.vue
Normal file
24
web/components/shadcn/select/SelectScrollDownButton.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import { type HTMLAttributes, computed } from 'vue'
|
||||
import { SelectScrollDownButton, type SelectScrollDownButtonProps, useForwardProps } from 'radix-vue'
|
||||
import { ChevronDown } from 'lucide-vue-next'
|
||||
import { cn } from '@/helpers/utils'
|
||||
|
||||
const props = defineProps<SelectScrollDownButtonProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props
|
||||
|
||||
return delegated
|
||||
})
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectScrollDownButton v-bind="forwardedProps" :class="cn('flex cursor-default items-center justify-center py-1', props.class)">
|
||||
<slot>
|
||||
<ChevronDown class="h-4 w-4" />
|
||||
</slot>
|
||||
</SelectScrollDownButton>
|
||||
</template>
|
||||
24
web/components/shadcn/select/SelectScrollUpButton.vue
Normal file
24
web/components/shadcn/select/SelectScrollUpButton.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import { type HTMLAttributes, computed } from 'vue'
|
||||
import { SelectScrollUpButton, type SelectScrollUpButtonProps, useForwardProps } from 'radix-vue'
|
||||
import { ChevronUp } from 'lucide-vue-next'
|
||||
import { cn } from '@/helpers/utils'
|
||||
|
||||
const props = defineProps<SelectScrollUpButtonProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props
|
||||
|
||||
return delegated
|
||||
})
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectScrollUpButton v-bind="forwardedProps" :class="cn('flex cursor-default items-center justify-center py-1', props.class)">
|
||||
<slot>
|
||||
<ChevronUp class="h-4 w-4" />
|
||||
</slot>
|
||||
</SelectScrollUpButton>
|
||||
</template>
|
||||
17
web/components/shadcn/select/SelectSeparator.vue
Normal file
17
web/components/shadcn/select/SelectSeparator.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import { type HTMLAttributes, computed } from 'vue'
|
||||
import { SelectSeparator, type SelectSeparatorProps } from 'radix-vue'
|
||||
import { cn } from '@/helpers/utils'
|
||||
|
||||
const props = defineProps<SelectSeparatorProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props
|
||||
|
||||
return delegated
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectSeparator v-bind="delegatedProps" :class="cn('-mx-1 my-1 h-px bg-muted', props.class)" />
|
||||
</template>
|
||||
31
web/components/shadcn/select/SelectTrigger.vue
Normal file
31
web/components/shadcn/select/SelectTrigger.vue
Normal file
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import { type HTMLAttributes, computed } from 'vue'
|
||||
import { SelectIcon, SelectTrigger, type SelectTriggerProps, useForwardProps } from 'radix-vue'
|
||||
import { ChevronDown } from 'lucide-vue-next'
|
||||
import { cn } from '@/helpers/utils'
|
||||
|
||||
const props = defineProps<SelectTriggerProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props
|
||||
|
||||
return delegated
|
||||
})
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectTrigger
|
||||
v-bind="forwardedProps"
|
||||
:class="cn(
|
||||
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:truncate text-start',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<slot />
|
||||
<SelectIcon as-child>
|
||||
<ChevronDown class="w-4 h-4 opacity-50 shrink-0" />
|
||||
</SelectIcon>
|
||||
</SelectTrigger>
|
||||
</template>
|
||||
11
web/components/shadcn/select/SelectValue.vue
Normal file
11
web/components/shadcn/select/SelectValue.vue
Normal file
@@ -0,0 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { SelectValue, type SelectValueProps } from 'radix-vue'
|
||||
|
||||
const props = defineProps<SelectValueProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectValue v-bind="props">
|
||||
<slot />
|
||||
</SelectValue>
|
||||
</template>
|
||||
11
web/components/shadcn/select/index.ts
Normal file
11
web/components/shadcn/select/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export { default as Select } from './Select.vue'
|
||||
export { default as SelectValue } from './SelectValue.vue'
|
||||
export { default as SelectTrigger } from './SelectTrigger.vue'
|
||||
export { default as SelectContent } from './SelectContent.vue'
|
||||
export { default as SelectGroup } from './SelectGroup.vue'
|
||||
export { default as SelectItem } from './SelectItem.vue'
|
||||
export { default as SelectItemText } from './SelectItemText.vue'
|
||||
export { default as SelectLabel } from './SelectLabel.vue'
|
||||
export { default as SelectSeparator } from './SelectSeparator.vue'
|
||||
export { default as SelectScrollUpButton } from './SelectScrollUpButton.vue'
|
||||
export { default as SelectScrollDownButton } from './SelectScrollDownButton.vue'
|
||||
14
web/components/shadcn/sheet/Sheet.vue
Normal file
14
web/components/shadcn/sheet/Sheet.vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { DialogRoot, type DialogRootEmits, type DialogRootProps, useForwardPropsEmits } from 'radix-vue'
|
||||
|
||||
const props = defineProps<DialogRootProps>()
|
||||
const emits = defineEmits<DialogRootEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogRoot v-bind="forwarded">
|
||||
<slot />
|
||||
</DialogRoot>
|
||||
</template>
|
||||
11
web/components/shadcn/sheet/SheetClose.vue
Normal file
11
web/components/shadcn/sheet/SheetClose.vue
Normal file
@@ -0,0 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { DialogClose, type DialogCloseProps } from 'radix-vue'
|
||||
|
||||
const props = defineProps<DialogCloseProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogClose v-bind="props">
|
||||
<slot />
|
||||
</DialogClose>
|
||||
</template>
|
||||
59
web/components/shadcn/sheet/SheetContent.vue
Normal file
59
web/components/shadcn/sheet/SheetContent.vue
Normal file
@@ -0,0 +1,59 @@
|
||||
<script setup lang="ts">
|
||||
import { type HTMLAttributes, computed } from 'vue'
|
||||
import {
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
type DialogContentEmits,
|
||||
type DialogContentProps,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
useForwardPropsEmits,
|
||||
} from 'radix-vue'
|
||||
import { X } from 'lucide-vue-next'
|
||||
import { type SheetVariants, sheetVariants } from '.'
|
||||
import { cn } from '@/helpers/utils'
|
||||
|
||||
interface SheetContentProps extends DialogContentProps {
|
||||
class?: HTMLAttributes['class']
|
||||
side?: SheetVariants['side']
|
||||
disabled?: boolean
|
||||
forceMount?: boolean
|
||||
to?: string | HTMLElement | Element
|
||||
}
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
const props = defineProps<SheetContentProps>()
|
||||
|
||||
const emits = defineEmits<DialogContentEmits>()
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, side, ...delegated } = props
|
||||
|
||||
return delegated
|
||||
})
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogPortal :disabled="disabled" :force-mount="forceMount" :to="to">
|
||||
<DialogOverlay
|
||||
class="fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
|
||||
/>
|
||||
<DialogContent
|
||||
:class="cn(sheetVariants({ side }), props.class)"
|
||||
v-bind="{ ...forwarded, ...$attrs }"
|
||||
>
|
||||
<slot />
|
||||
|
||||
<DialogClose
|
||||
class="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary"
|
||||
>
|
||||
<X class="w-4 h-4 text-muted-foreground" />
|
||||
</DialogClose>
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
</template>
|
||||
22
web/components/shadcn/sheet/SheetDescription.vue
Normal file
22
web/components/shadcn/sheet/SheetDescription.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import { type HTMLAttributes, computed } from 'vue'
|
||||
import { DialogDescription, type DialogDescriptionProps } from 'radix-vue'
|
||||
import { cn } from '@/helpers/utils'
|
||||
|
||||
const props = defineProps<DialogDescriptionProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props
|
||||
|
||||
return delegated
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogDescription
|
||||
:class="cn('text-sm text-muted-foreground', props.class)"
|
||||
v-bind="delegatedProps"
|
||||
>
|
||||
<slot />
|
||||
</DialogDescription>
|
||||
</template>
|
||||
19
web/components/shadcn/sheet/SheetFooter.vue
Normal file
19
web/components/shadcn/sheet/SheetFooter.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/helpers/utils'
|
||||
|
||||
const props = defineProps<{ class?: HTMLAttributes['class'] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex flex-col-reverse sm:flex-row sm:justify-end sm:gap-x-2',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
16
web/components/shadcn/sheet/SheetHeader.vue
Normal file
16
web/components/shadcn/sheet/SheetHeader.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/helpers/utils'
|
||||
|
||||
const props = defineProps<{ class?: HTMLAttributes['class'] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn('flex flex-col gap-y-2 text-center sm:text-left', props.class)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
22
web/components/shadcn/sheet/SheetTitle.vue
Normal file
22
web/components/shadcn/sheet/SheetTitle.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import { type HTMLAttributes, computed } from 'vue'
|
||||
import { DialogTitle, type DialogTitleProps } from 'radix-vue'
|
||||
import { cn } from '@/helpers/utils'
|
||||
|
||||
const props = defineProps<DialogTitleProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props
|
||||
|
||||
return delegated
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogTitle
|
||||
:class="cn('text-lg font-semibold text-foreground', props.class)"
|
||||
v-bind="delegatedProps"
|
||||
>
|
||||
<slot />
|
||||
</DialogTitle>
|
||||
</template>
|
||||
11
web/components/shadcn/sheet/SheetTrigger.vue
Normal file
11
web/components/shadcn/sheet/SheetTrigger.vue
Normal file
@@ -0,0 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { DialogTrigger, type DialogTriggerProps } from 'radix-vue'
|
||||
|
||||
const props = defineProps<DialogTriggerProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogTrigger v-bind="props">
|
||||
<slot />
|
||||
</DialogTrigger>
|
||||
</template>
|
||||
31
web/components/shadcn/sheet/index.ts
Normal file
31
web/components/shadcn/sheet/index.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { type VariantProps, cva } from 'class-variance-authority'
|
||||
|
||||
export { default as Sheet } from './Sheet.vue'
|
||||
export { default as SheetTrigger } from './SheetTrigger.vue'
|
||||
export { default as SheetClose } from './SheetClose.vue'
|
||||
export { default as SheetContent } from './SheetContent.vue'
|
||||
export { default as SheetHeader } from './SheetHeader.vue'
|
||||
export { default as SheetTitle } from './SheetTitle.vue'
|
||||
export { default as SheetDescription } from './SheetDescription.vue'
|
||||
export { default as SheetFooter } from './SheetFooter.vue'
|
||||
|
||||
export const sheetVariants = cva(
|
||||
'fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
|
||||
{
|
||||
variants: {
|
||||
side: {
|
||||
top: 'inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top',
|
||||
bottom:
|
||||
'inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom',
|
||||
left: 'inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm',
|
||||
right:
|
||||
'inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
side: 'right',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
export type SheetVariants = VariantProps<typeof sheetVariants>
|
||||
37
web/components/shadcn/switch/Switch.vue
Normal file
37
web/components/shadcn/switch/Switch.vue
Normal file
@@ -0,0 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
import { type HTMLAttributes, computed } from 'vue'
|
||||
import {
|
||||
SwitchRoot,
|
||||
type SwitchRootEmits,
|
||||
type SwitchRootProps,
|
||||
SwitchThumb,
|
||||
useForwardPropsEmits,
|
||||
} from 'radix-vue'
|
||||
import { cn } from '@/helpers/utils'
|
||||
|
||||
const props = defineProps<SwitchRootProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const emits = defineEmits<SwitchRootEmits>()
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props
|
||||
|
||||
return delegated
|
||||
})
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SwitchRoot
|
||||
v-bind="forwarded"
|
||||
:class="cn(
|
||||
'peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<SwitchThumb
|
||||
:class="cn('pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0')"
|
||||
/>
|
||||
</SwitchRoot>
|
||||
</template>
|
||||
1
web/components/shadcn/switch/index.ts
Normal file
1
web/components/shadcn/switch/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as Switch } from './Switch.vue'
|
||||
15
web/components/shadcn/tabs/Tabs.vue
Normal file
15
web/components/shadcn/tabs/Tabs.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import { TabsRoot, useForwardPropsEmits } from 'radix-vue'
|
||||
import type { TabsRootEmits, TabsRootProps } from 'radix-vue'
|
||||
|
||||
const props = defineProps<TabsRootProps>()
|
||||
const emits = defineEmits<TabsRootEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TabsRoot v-bind="forwarded">
|
||||
<slot />
|
||||
</TabsRoot>
|
||||
</template>
|
||||
22
web/components/shadcn/tabs/TabsContent.vue
Normal file
22
web/components/shadcn/tabs/TabsContent.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import { type HTMLAttributes, computed } from 'vue'
|
||||
import { TabsContent, type TabsContentProps } from 'radix-vue'
|
||||
import { cn } from '@/helpers/utils'
|
||||
|
||||
const props = defineProps<TabsContentProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props
|
||||
|
||||
return delegated
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TabsContent
|
||||
:class="cn('mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2', props.class)"
|
||||
v-bind="delegatedProps"
|
||||
>
|
||||
<slot />
|
||||
</TabsContent>
|
||||
</template>
|
||||
25
web/components/shadcn/tabs/TabsList.vue
Normal file
25
web/components/shadcn/tabs/TabsList.vue
Normal file
@@ -0,0 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import { type HTMLAttributes, computed } from 'vue'
|
||||
import { TabsList, type TabsListProps } from 'radix-vue'
|
||||
import { cn } from '@/helpers/utils'
|
||||
|
||||
const props = defineProps<TabsListProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props
|
||||
|
||||
return delegated
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TabsList
|
||||
v-bind="delegatedProps"
|
||||
:class="cn(
|
||||
'inline-flex items-center justify-center rounded-md bg-muted p-1 text-muted-foreground',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<slot />
|
||||
</TabsList>
|
||||
</template>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user