Compare commits

...

64 Commits

Author SHA1 Message Date
Zack Spear
acbd861ce0 feat: WIP sidebar filter select 2024-10-08 10:22:59 -07:00
mdatelle
8ec2358061 feat(web): wip query api for notifications 2024-10-04 17:22:13 -04:00
mdatelle
e7c1c8e7fe refactor(api): local dev permissions for notifications 2024-10-04 17:19:17 -04:00
Zack Spear
e933f14c24 feat: WIP create teleport composable 2024-10-03 14:40:31 -07:00
Zack Spear
e6699448a2 refactor: Update connectPluginInstalled value in serverState.ts 2024-10-03 14:39:52 -07:00
Eli Bosley
38aac5e35f fix: floating-ui fixes 2024-10-03 13:49:58 -07:00
Zack Spear
16a70dcdb0 test: sidebar tabs 2024-10-03 13:49:58 -07:00
Zack Spear
e7b8733648 refactor: Update NotificationItemProps interface
- Add 'event' and 'date' properties to the NotificationItemProps interface
- Add 'view' property to the NotificationItemProps interface
- Remove trailing newline at the end of the file
2024-10-03 13:49:58 -07:00
Zack Spear
f68727320b refactor: Remove duplicate declaration of 'combinations' in terserReservations function 2024-10-03 13:49:58 -07:00
Zack Spear
9c23b9bd1b refactor: Remove extra whitespace in Notifications Sidebar and optimize Terser options in nuxt.config.ts 2024-10-03 13:49:58 -07:00
Zack Spear
32c0d0be0a feat: WIP notifications w/ shadcn
Currently the build doesn't work in webgui
2024-10-03 13:49:57 -07:00
Zack Spear
b9e9a1a2cf feat: wip Notification UI starter 2024-10-03 13:47:33 -07:00
Zack Spear
10cb681d64 refactor: update disableProductionConsoleLogs function to check for VITE_ALLOW_CONSOLE_LOGS value as a string 2024-10-03 13:47:32 -07:00
Zack Spear
b513cbe614 refactor(web): update README.md with instructions for dev testing and builds 2024-10-03 13:47:12 -07:00
Zack Spear
b5c525a9c2 refactor(web): tailwind config use .env VITE_TAILWIND_BASE_FONT_SIZE 2024-10-03 13:47:12 -07:00
Zack Spear
648b560148 refactor(package.json): update build scripts for dev and webgui
- Update the prebuild and postbuild scripts in package.json to handle environment variables and file paths correctly for the dev and webgui builds.
2024-10-03 13:47:12 -07:00
Zack Spear
6eb34c3501 refactor(prebuild-webgui-set-env.sh): update default file paths and handle requested env file
This commit updates the default file paths in the prebuild-webgui-set-env.sh script to use the requested env file instead of always using .env.production. If a specific env file is provided as an argument, its contents will be copied to .env. If the requested env file is not found, an error message will be displayed.
2024-10-03 13:47:12 -07:00
Zack Spear
21544bd2dc refactor(UserProfile): update text classes in banner section 2024-10-03 13:47:12 -07:00
Eli Bosley
3e115f84d7 fix: text classes 2024-10-02 16:02:01 -04:00
Eli Bosley
ba586fc438 feat: rem converter 2024-10-02 16:02:01 -04:00
Pujit Mehrotra
e6cbed14a9 fix(NotificationsService): edge-case in deleteAllNotifications by adding fs-extra package 2024-10-02 12:30:12 -04:00
Pujit Mehrotra
f531e68b87 doc(NotificationService): rm obsolete note about race conditions 2024-10-02 12:30:12 -04:00
Pujit Mehrotra
53f718e240 test: fix test definition for safely encoding top-level fields into INI strings 2024-10-02 12:30:12 -04:00
Pujit Mehrotra
de36bfab99 chore: fix lint issues 2024-10-02 12:30:12 -04:00
Pujit Mehrotra
1e2f57a4cd feat(NotificationService): endpoint to manually recalculate notification overview 2024-10-02 12:30:12 -04:00
Pujit Mehrotra
46aa3a3e24 refactor(NotificationService): batchProcess util, gql Notifications->list instead of ->data to get notifications 2024-10-02 12:30:12 -04:00
Pujit Mehrotra
0c627d1ade refactor(NotificationService): replace removeFromOverview
with `decrement` & `publishOverview`
2024-10-02 12:30:12 -04:00
Pujit Mehrotra
f20349fb2a chore: update vitest major version 2024-10-02 12:30:12 -04:00
Pujit Mehrotra
dc72d63481 fix(NotificationService): file watcher initialization 2024-10-02 12:30:12 -04:00
Pujit Mehrotra
e9efed8067 test(NotificationService): compatibility of outputs & combine archival filter tests 2024-10-02 12:30:12 -04:00
Pujit Mehrotra
71ce064008 fix: rm getServerIdentifier wrapping Notifications id 2024-10-02 12:30:12 -04:00
Pujit Mehrotra
b67b0ea633 test: filtering notifications 2024-10-02 12:30:12 -04:00
Pujit Mehrotra
bf3d46d190 test,fix: crud'ing notifications, timestamp format consistency 2024-10-02 12:30:12 -04:00
Pujit Mehrotra
a1fa3462eb feat,refactor: update notifications by filter & by id's 2024-10-02 12:30:12 -04:00
Pujit Mehrotra
c84175e763 feat: implement mutations for updating many notifications at once 2024-10-02 12:30:12 -04:00
Pujit Mehrotra
0f9fe18379 refactor: unraid timestamp into src/utils 2024-10-02 12:30:12 -04:00
Pujit Mehrotra
76c0d35783 feat: make notification id logic 2024-10-02 12:30:12 -04:00
Pujit Mehrotra
3ece0d1acc chore: update uuid@10.0.0 for v7 uuids
v7 uuids are basically v4 uuids that are sortable (by creation time)
2024-10-02 12:30:12 -04:00
Pujit Mehrotra
0473c9b676 fix: use correct ini encoder in notification service 2024-10-02 12:30:12 -04:00
Pujit Mehrotra
1956227f63 fix: mv paths() to top of NotificationsService to make it more intuitive 2024-10-02 12:30:12 -04:00
Pujit Mehrotra
c515d08d5c fix: race condition when updating notification types 2024-10-02 12:30:12 -04:00
Pujit Mehrotra
0bd9820c00 feat: expose mutations for notifications over graphql 2024-10-02 12:30:12 -04:00
Pujit Mehrotra
0c2299cfcd feat: add deletion & update methods to NotificationService
also stubs create method
2024-10-02 12:30:12 -04:00
Pujit Mehrotra
12fdfac467 chore: update prettier line width limit to 105ch
to prevent over-aggressive line breaks & wraps.
2024-10-02 12:30:12 -04:00
Pujit Mehrotra
3fc20ec593 fix: disable permissions bypass to avoid incorrect role assignment to api keys 2024-10-02 12:30:12 -04:00
Pujit Mehrotra
69a6163e29 feat: wrap Notifications in a GraphQL Node & implement notification overviews 2024-10-02 12:30:12 -04:00
mdatelle
00294699f0 fix: add return to resolver and update jsdoc for getNotifications 2024-10-02 12:30:12 -04:00
mdatelle
90ff980a00 refactor: update notifications.resolver to handle filtering
- Updates the getNotifications function to use the refactored getNotificationsFromPaths function
- Adds filtering logic to the updated  getNotificationsFromPaths function
- Update JSdocs
2024-10-02 12:30:12 -04:00
Pujit Mehrotra
17e7d2a2de fix: load notifications from file system instead of redux state
- Adds a Nest.js service for notifications
- Helps improve our memory footprint!
2024-10-02 12:30:12 -04:00
Eli Bosley
d2a88df5bf fix: lint issues 2024-09-27 13:57:47 -04:00
Eli Bosley
9471f5c918 fix: swap to flexible IDs in tests 2024-09-27 13:57:47 -04:00
Eli Bosley
492d45f363 feat: server identifier changes 2024-09-27 13:57:47 -04:00
Eli Bosley
2951d68f9d feat: ID prefixer improvement 2024-09-27 13:57:47 -04:00
Eli Bosley
4857bc0478 fix: convert updateId function to iterative instead of recursive 2024-09-27 13:57:47 -04:00
Eli Bosley
c794a1d1a1 feat: add ID prefix plugin to prefix IDs with server identifier 2024-09-27 13:57:47 -04:00
Zack Spear
d2a34acfb9 refactor: always show footer in CheckUpdateResponseModal 2024-09-12 20:14:10 -07:00
Zack Spear
3dc60b6106 feat: add deviceCount to serverAccountPayload for callbacks 2024-09-12 20:14:10 -07:00
Eli Bosley
57587b9175 chore(release): 3.11.0 2024-09-11 13:25:19 -04:00
ljm42
5ee7cb2647 feat: reduce how often rc.flashbackup checks for changes
Instead of checking once per minute, check once every 30 minutes
2024-09-10 12:48:40 -04:00
ljm42
911a3f8f1a feat: send api_version to flash/activate endpoint
also use _var() function in a few more places for consistency
2024-09-10 09:30:17 -07:00
ljm42
d426001372 feat: update ProvisionCert.php to clean hosts file when it runs 2024-09-09 12:49:15 -07:00
ljm42
2d0c65aaf4 fix: remove local flash backup ratelimit file on uninstall/update 2024-09-06 16:38:10 -07:00
ljm42
fd4605b956 chore: prevent corner case issue and fix php warning
* Update remoteerror in flashback.ini if it gets out of sync with gitratelimit (can happen during testing if you delete flashbackup.ini)
* Fix php warning for retry_after
2024-09-06 11:27:17 -07:00
Eli Bosley
3f84b6bbfd chore(release): 3.10.1 2024-09-03 14:43:08 -04:00
122 changed files with 5395 additions and 1269 deletions

3
.gitignore vendored
View File

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

View File

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

82
api/.gitignore vendored Normal file
View File

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

8
api/.prettierrc.cjs Normal file
View File

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

View File

@@ -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)

View File

@@ -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"

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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": {

View File

@@ -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",
}
`);
);
});

View File

@@ -4,32 +4,32 @@ import { safelySerializeObjectToIni } from '@app/core/utils/files/safe-ini-seria
import { Serializer } from 'multi-ini';
test('MultiIni breaks when serializing an object with a boolean inside', async () => {
const objectToSerialize = {
root: {
anonMode: false,
},
};
const serializer = new Serializer({ keep_quotes: false });
expect(serializer.serialize(objectToSerialize)).toMatchInlineSnapshot(`
const objectToSerialize = {
root: {
anonMode: false,
},
};
const serializer = new Serializer({ keep_quotes: false });
expect(serializer.serialize(objectToSerialize)).toMatchInlineSnapshot(`
"[root]
anonMode=false
"
`)
`);
});
test('MultiIni can safely serialize an object with a boolean inside', async () => {
const objectToSerialize = {
root: {
anonMode: false,
},
};
expect(safelySerializeObjectToIni(objectToSerialize)).toMatchInlineSnapshot(`
const objectToSerialize = {
root: {
anonMode: false,
},
};
expect(safelySerializeObjectToIni(objectToSerialize)).toMatchInlineSnapshot(`
"[root]
anonMode="false"
"
`);
const result = safelySerializeObjectToIni(objectToSerialize);
expect(parse(result)).toMatchInlineSnapshot(`
const result = safelySerializeObjectToIni(objectToSerialize);
expect(parse(result)).toMatchInlineSnapshot(`
{
"root": {
"anonMode": false,
@@ -37,3 +37,33 @@ test('MultiIni can safely serialize an object with a boolean inside', async () =
}
`);
});
test.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);
});

View File

@@ -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: {

View File

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

View File

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

View File

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

View File

@@ -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();
}

View File

@@ -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';

View File

@@ -2,7 +2,7 @@
import * as Types from '@app/graphql/generated/api/types';
import { z } from 'zod'
import { AccessUrl, AccessUrlInput, AllowedOriginInput, ApiKey, ApiKeyResponse, ArrayType, ArrayCapacity, ArrayDisk, ArrayDiskFsColor, ArrayDiskStatus, ArrayDiskType, ArrayPendingState, ArrayState, Baseboard, Capacity, Case, Cloud, CloudResponse, Config, ConfigErrorState, Connect, ConnectSignInInput, ConnectUserInfoInput, ContainerHostConfig, ContainerMount, ContainerPort, ContainerPortType, ContainerState, Devices, Disk, DiskFsType, DiskInterfaceType, DiskPartition, DiskSmartStatus, Display, Docker, DockerContainer, DockerNetwork, DynamicRemoteAccessStatus, DynamicRemoteAccessType, EnableDynamicRemoteAccessInput, Flash, Gpu, Importance, Info, InfoApps, InfoCpu, InfoMemory, KeyFile, Me, MemoryFormFactor, MemoryLayout, MemoryType, MinigraphStatus, MinigraphqlResponse, Mount, Network, Node, Notification, NotificationFilter, NotificationInput, NotificationType, Os, Owner, ParityCheck, Partition, Pci, ProfileModel, Registration, RegistrationState, RelayResponse, RemoteAccess, Server, ServerStatus, Service, SetupRemoteAccessInput, Share, System, Temperature, Theme, URL_TYPE, UnassignedDevice, Uptime, Usb, User, UserAccount, Vars, Versions, VmDomain, VmState, Vms, WAN_ACCESS_TYPE, WAN_FORWARD_TYPE, Welcome, addApiKeyInput, addUserInput, arrayDiskInput, authenticateInput, deleteUserInput, mdState, registrationType, updateApikeyInput, usersInput } from '@app/graphql/generated/api/types'
import { AccessUrl, AccessUrlInput, AllowedOriginInput, ApiKey, ApiKeyResponse, ArrayType, ArrayCapacity, ArrayDisk, ArrayDiskFsColor, ArrayDiskStatus, ArrayDiskType, ArrayPendingState, ArrayState, Baseboard, Capacity, Case, Cloud, CloudResponse, Config, ConfigErrorState, Connect, ConnectSignInInput, ConnectUserInfoInput, ContainerHostConfig, ContainerMount, ContainerPort, ContainerPortType, ContainerState, Devices, Disk, DiskFsType, DiskInterfaceType, DiskPartition, DiskSmartStatus, Display, Docker, DockerContainer, DockerNetwork, DynamicRemoteAccessStatus, DynamicRemoteAccessType, EnableDynamicRemoteAccessInput, Flash, Gpu, Importance, Info, InfoApps, InfoCpu, InfoMemory, KeyFile, Me, MemoryFormFactor, MemoryLayout, MemoryType, MinigraphStatus, MinigraphqlResponse, Mount, Network, Node, Notification, NotificationCounts, NotificationData, NotificationFilter, NotificationOverview, NotificationType, Notifications, 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()
})
}

View File

@@ -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>;

View File

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

View File

@@ -1,18 +1,6 @@
enum NotificationType {
UNREAD
ARCHIVED
RESTORED
}
input NotificationInput {
id: ID!
title: String!
subject: String!
description: String
importance: Importance!
link: String
type: NotificationType!
timestamp: String
ARCHIVE
}
input NotificationFilter {
@@ -23,11 +11,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!
}

View File

@@ -4,7 +4,6 @@ import {
Importance,
NotificationType,
type Notification,
type NotificationInput,
} from '@app/graphql/generated/api/types';
import { NotificationSchema } from '@app/graphql/generated/api/operations';
import { type RootState, type AppDispatch } from '@app/store/index';
@@ -14,6 +13,7 @@ import {
createSlice,
} from '@reduxjs/toolkit';
import { PUBSUB_CHANNEL, pubsub } from '@app/core/pubsub';
import { 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,

View File

@@ -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()

View File

@@ -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
}),
],

View 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);
}
},
};
},
};

View File

@@ -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'
};
}

View File

@@ -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

View File

@@ -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

View File

@@ -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',
};
}

View File

@@ -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'
};
}

View File

@@ -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);
}
}

View File

@@ -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);
});
});

View File

@@ -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);
}
}

View File

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

View File

@@ -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 ?? {},
}
}

View File

@@ -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: {

View File

@@ -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
View 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,
};
}

View File

@@ -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

View File

@@ -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() {

View File

@@ -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);

View File

@@ -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

View File

@@ -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)

View File

@@ -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",

View File

@@ -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
View 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"
}
}

View File

@@ -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" />

View 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>

View File

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

View File

@@ -0,0 +1,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>

View File

@@ -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="{

View File

@@ -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">&bull;</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" />

View File

@@ -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"

View 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>

View 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>

View 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>

View File

@@ -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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View 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>

View 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>

View File

@@ -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>

View File

@@ -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>

View 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>

View 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'

View 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>

View File

@@ -0,0 +1 @@
export { default as Input } from './Input.vue'

View 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>

View File

@@ -0,0 +1 @@
export { default as Label } from './Label.vue'

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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'

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -0,0 +1 @@
export { default as Switch } from './Switch.vue'

View 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>

View 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>

View 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