Compare commits

...

16 Commits

Author SHA1 Message Date
Pujit Mehrotra
c7a749efcd fix: duplicate notification subscription & toast 2025-02-01 12:46:53 -05:00
Pujit Mehrotra
b54660115e chore(web): improve notification debuggability in web component 2025-02-01 12:39:33 -05:00
Pujit Mehrotra
97769b25ad chore(web): hide activation view on page load 2025-02-01 12:39:06 -05:00
Pujit Mehrotra
5b7ce3ea1b apparently bun prettier and npm prettier conflict? rip me 2025-02-01 09:31:22 -05:00
Pujit Mehrotra
513b1ef2ff revert docker to node 2025-02-01 09:26:58 -05:00
Pujit Mehrotra
ce03244bbe tmp: disable api tests 2025-02-01 09:11:21 -05:00
Pujit Mehrotra
a98b8b371e fix lint errors 2025-02-01 09:01:55 -05:00
Pujit Mehrotra
aa51a874a3 update bun to 1.2.2 2025-02-01 08:56:57 -05:00
Pujit Mehrotra
c5c0e48288 use bun in docker + pm2 2025-02-01 08:38:34 -05:00
Pujit Mehrotra
1ee5353c83 rm unused & error-causing networkInterface calls 2025-02-01 08:05:32 -05:00
Pujit Mehrotra
cac0039ea2 restore incorrect readme omission 2025-02-01 08:05:12 -05:00
Pujit Mehrotra
329f021c3d chore: switch to bun in dockerfile & fix libvirt compat
- add trusted dependencies to run postinstall script
2025-01-31 23:40:29 -05:00
Pujit Mehrotra
ee0d10ef6a fix syntax error in plg script 2025-01-31 22:53:30 -05:00
Pujit Mehrotra
fce35a1495 change cli.ts shebang to bun 2025-01-31 22:41:40 -05:00
Pujit Mehrotra
58b467fa2d remove readme overwrite during plugin install
this conflicts with the static asset in the plugins folder and
overwrites changes made during plugin build (in pkg_build.sh)
2025-01-31 22:16:21 -05:00
Pujit Mehrotra
4971e031a5 replace node with bun in plugin 2025-01-31 22:10:22 -05:00
23 changed files with 190 additions and 94 deletions

View File

@@ -24,8 +24,10 @@ COPY tsconfig.json .eslintrc.ts .prettierrc.cjs .npmrc .env.production .env.stag
COPY package.json package-lock.json ./
RUN npm i -g bun
# Install deps
RUN npm i
RUN npm install
EXPOSE 4000

View File

@@ -5,6 +5,8 @@
"name": "unraid-api",
"script": "./dist/main.js",
"cwd": "/usr/local/unraid-api",
"interpreter": "bun",
"interpreter_args": "--smol",
"exec_mode": "fork",
"wait_ready": true,
"listen_timeout": 15000,

View File

@@ -16,6 +16,50 @@ setup:
@deploy:
./scripts/deploy-dev.sh
alias r:= restart
# restarts the api on {target_server}
restart target_server="" :
#!/usr/bin/env bash
# Path to store the last used server name
state_file="$HOME/.deploy_state"
# Read the last used server name from the state file
if [[ -f "$state_file" ]]; then
last_server_name=$(cat "$state_file")
else
last_server_name=""
fi
# Read the server name from the command-line argument or use the last used server name as the default
server_name=$([ "{{ target_server }}" = "" ] && echo "$last_server_name" || echo ""{{ target_server }}"")
# Check if the server name is provided
if [[ -z "$server_name" ]]; then
echo "Please provide the SSH server name."
exit 1
fi
# Save the current server name to the state file
echo "$server_name" > "$state_file"
# Run unraid-api restart on remote host
ssh root@"${server_name}" "LOG_LEVEL=trace unraid-api restart"
# build & deploy
bd: build deploy
# plays an os-specific bell
[no-cd]
chime:
#!/usr/bin/env bash
# Play built-in sound based on the operating system
if [[ "$OSTYPE" == "darwin"* ]]; then
# macOS
afplay /System/Library/Sounds/Glass.aiff
elif [[ "$OSTYPE" == "linux-gnu" ]]; then
# Linux
paplay /usr/share/sounds/freedesktop/stereo/complete.oga
elif [[ "$OSTYPE" == "msys" || "$OSTYPE" == "win32" ]]; then
# Windows
powershell.exe -c "(New-Object Media.SoundPlayer 'C:\Windows\Media\Windows Default.wav').PlaySync()"
fi

View File

@@ -60,6 +60,7 @@
"@reduxjs/toolkit": "^2.3.0",
"@reflet/cron": "^1.3.1",
"@runonflux/nat-upnp": "^1.0.2",
"@vmngr/libvirt": "github:unraid/libvirt",
"accesscontrol": "^2.2.1",
"bycontract": "^2.0.11",
"bytes": "^3.1.2",
@@ -167,6 +168,7 @@
"nodemon": "^3.1.7",
"rollup-plugin-node-externals": "^7.1.3",
"standard-version": "^9.5.0",
"tsup": "^8.3.6",
"typescript": "^5.6.3",
"typescript-eslint": "^8.13.0",
"unplugin-swc": "^1.5.1",
@@ -176,12 +178,9 @@
"vitest": "^2.1.8",
"zx": "^8.2.0"
},
"optionalDependencies": {
"@vmngr/libvirt": "github:unraid/libvirt"
},
"overrides": {
"eslint": {
"jiti": "2"
}
}
"trustedDependencies": [
"@nestjs/core",
"@swc/core",
"@vmngr/libvirt"
]
}

View File

@@ -48,21 +48,6 @@ exit_code=$?
# Chown the directory
ssh root@"${server_name}" "chown -R root:root /usr/local/unraid-api"
# Run unraid-api restart on remote host
ssh root@"${server_name}" "INTROSPECTION=true LOG_LEVEL=trace unraid-api restart"
# Play built-in sound based on the operating system
if [[ "$OSTYPE" == "darwin"* ]]; then
# macOS
afplay /System/Library/Sounds/Glass.aiff
elif [[ "$OSTYPE" == "linux-gnu" ]]; then
# Linux
paplay /usr/share/sounds/freedesktop/stereo/complete.oga
elif [[ "$OSTYPE" == "msys" || "$OSTYPE" == "win32" ]]; then
# Windows
powershell.exe -c "(New-Object Media.SoundPlayer 'C:\Windows\Media\Windows Default.wav').PlaySync()"
fi
# Exit with the rsync command's exit code
exit $exit_code

View File

@@ -1,4 +1,4 @@
#!/usr/bin/env node
#!/usr/bin/env bun
import '@app/dotenv';

View File

@@ -80,5 +80,5 @@ export const KEYSERVER_VALIDATION_ENDPOINT = 'https://keys.lime-technology.com/v
/** Set the max retries for the GraphQL Client */
export const MAX_RETRIES_FOR_LINEAR_BACKOFF = 100;
export const PM2_PATH = join(import.meta.dirname, '../../', 'node_modules', '.bin', 'pm2');
export const PM2_PATH = 'bun x pm2';
export const ECOSYSTEM_PATH = join(import.meta.dirname, '../../', 'ecosystem.config.json');

View File

@@ -0,0 +1,8 @@
// Created from 'create-ts-index'
export * from './domain';
export * from './filter-devices';
export * from './get-hypervisor';
export * from './get-pci-devices';
export * from './parse-domain';
export * from './parse-domains';

View File

@@ -1,3 +0,0 @@
import { networkInterfaces } from 'systeminformation';
export const systemNetworkInterfaces = networkInterfaces();

View File

@@ -105,6 +105,7 @@ export const viteNodeApp = async () => {
},
{ wait: 9999 }
);
await new Promise(() => {});
} catch (error: unknown) {
if (error instanceof Error) {
logger.error(error, 'API-ERROR');

View File

@@ -10,13 +10,13 @@ import { AuthActionVerb } from 'nest-authz';
import { v4 as uuidv4 } from 'uuid';
import { ZodError } from 'zod';
import type { Permission } from '@app/graphql/generated/api/types';
import { environment } from '@app/environment';
import { ApiKeySchema, ApiKeyWithSecretSchema } from '@app/graphql/generated/api/operations';
import {
AddPermissionInput,
ApiKey,
ApiKeyWithSecret,
Permission,
Resource,
Role,
} from '@app/graphql/generated/api/types';

View File

@@ -86,8 +86,11 @@ export class GraphqlAuthGuard
// parse cookies from raw headers on initial web socket connection request
if (fullContext.connectionParams) {
console.log(JSON.stringify(fullContext.req, null, 2));
const rawHeaders = fullContext.req.extra.request.rawHeaders;
const headerIndex = rawHeaders.findIndex((headerOrValue) => headerOrValue === 'Cookie');
const headerIndex = rawHeaders.findIndex(
(headerOrValue) => headerOrValue.toLowerCase() === 'cookie'
);
const cookieString = rawHeaders[headerIndex + 1];
request.cookies = parseCookies(cookieString);
}

View File

@@ -1,6 +1,7 @@
import { ChoicesFor, Question, QuestionSet, WhenFor } from 'nest-commander';
import { Permission, Role } from '@app/graphql/generated/api/types';
import type { Permission } from '@app/graphql/generated/api/types';
import { Role } from '@app/graphql/generated/api/types';
import { ApiKeyService } from '@app/unraid-api/auth/api-key.service';
import { LogService } from '@app/unraid-api/cli/log.service';

View File

@@ -1,7 +1,8 @@
import { AuthActionVerb } from 'nest-authz';
import { Command, CommandRunner, InquirerService, Option } from 'nest-commander';
import { Permission, Resource, Role } from '@app/graphql/generated/api/types';
import type { Permission } from '@app/graphql/generated/api/types';
import { Resource, Role } from '@app/graphql/generated/api/types';
import { ApiKeyService } from '@app/unraid-api/auth/api-key.service';
import { AddApiKeyQuestionSet } from '@app/unraid-api/cli/apikey/add-api-key.questions';
import { LogService } from '@app/unraid-api/cli/log.service';

View File

@@ -8,6 +8,7 @@ import { DeveloperCommand } from '@app/unraid-api/cli/developer/developer.comman
import { DeveloperQuestions } from '@app/unraid-api/cli/developer/developer.questions';
import { LogService } from '@app/unraid-api/cli/log.service';
import { LogsCommand } from '@app/unraid-api/cli/logs.command';
import { PM2Service } from '@app/unraid-api/cli/pm2.service';
import { ReportCommand } from '@app/unraid-api/cli/report.command';
import { RestartCommand } from '@app/unraid-api/cli/restart.command';
import { AddSSOUserCommand } from '@app/unraid-api/cli/sso/add-sso-user.command';
@@ -31,6 +32,7 @@ import { VersionCommand } from '@app/unraid-api/cli/version.command';
RemoveSSOUserQuestionSet,
ListSSOUserCommand,
LogService,
PM2Service,
StartCommand,
StopCommand,
RestartCommand,

View File

@@ -0,0 +1,47 @@
import { Injectable } from '@nestjs/common';
import type { Options, Result } from 'execa';
import { execa } from 'execa';
import { LogService } from '@app/unraid-api/cli/log.service';
type CmdContext = {
tag: string;
env?: Record<string, string>;
/** Default: false.
*
* When true, results will not be automatically handled and logged.
* The caller must handle desired effects.
*/
raw?: boolean;
};
@Injectable()
export class PM2Service {
constructor(private readonly logger: LogService) {}
/**
* Executes a PM2 command with the provided arguments and environment variables.
*
* @param context - An object containing a tag for logging purposes and optional environment variables (merging with current env).
* @param args - Arguments to pass to the PM2 command. Each arguement is escaped.
* @returns A promise that resolves to a Result object containing the command's output.
* Logs debug information on success and error details on failure.
*/
async run(context: CmdContext, ...args: string[]) {
const { tag, env, raw = false } = context;
const runCommand = () => execa('bun', ['x', 'pm2', ...args], { env } satisfies Options);
if (raw) {
return runCommand();
}
return runCommand()
.then((result) => {
this.logger.debug(result.stdout);
return result;
})
.catch((result: Result) => {
this.logger.error(`PM2 error occurred from tag "${tag}": ${result.stdout}\n`);
return result;
});
}
}

View File

@@ -5,6 +5,7 @@ import type { LogLevel } from '@app/core/log';
import { ECOSYSTEM_PATH, PM2_PATH } from '@app/consts';
import { levels } from '@app/core/log';
import { LogService } from '@app/unraid-api/cli/log.service';
import { PM2Service } from '@app/unraid-api/cli/pm2.service';
interface StartCommandOptions {
'log-level'?: string;
@@ -12,38 +13,38 @@ interface StartCommandOptions {
@Command({ name: 'start' })
export class StartCommand extends CommandRunner {
constructor(private readonly logger: LogService) {
constructor(
private readonly logger: LogService,
private readonly pm2: PM2Service
) {
super();
}
async cleanupPM2State() {
await execa(PM2_PATH, ['stop', ECOSYSTEM_PATH])
.then(({ stdout }) => this.logger.debug(stdout))
.catch(({ stderr }) => this.logger.error('PM2 Stop Error: ' + stderr));
await execa(PM2_PATH, ['delete', ECOSYSTEM_PATH])
.then(({ stdout }) => this.logger.debug(stdout))
.catch(({ stderr }) => this.logger.error('PM2 Delete API Error: ' + stderr));
// Update PM2
await execa(PM2_PATH, ['update'])
.then(({ stdout }) => this.logger.debug(stdout))
.catch(({ stderr }) => this.logger.error('PM2 Update Error: ' + stderr));
await this.pm2.run({ tag: 'PM2 Stop' }, 'stop', ECOSYSTEM_PATH);
await this.pm2.run({ tag: 'PM2 Delete' }, 'delete', ECOSYSTEM_PATH);
await this.pm2.run({ tag: 'PM2 Update' }, 'update');
}
async run(_: string[], options: StartCommandOptions): Promise<void> {
this.logger.info('Starting the Unraid API');
await this.cleanupPM2State();
const envLog = options['log-level'] ? `LOG_LEVEL=${options['log-level']}` : '';
const { stderr, stdout } = await execa(`${envLog} ${PM2_PATH}`.trim(), [
const env: Record<string, string> = {};
if (options['log-level']) {
env.LOG_LEVEL = options['log-level'];
}
const { stderr, stdout } = await this.pm2.run(
{ tag: 'PM2 Start', env, raw: true },
'start',
ECOSYSTEM_PATH,
'--update-env',
]);
'--update-env'
);
if (stdout) {
this.logger.log(stdout);
this.logger.log(stdout.toString());
}
if (stderr) {
this.logger.error(stderr);
this.logger.error(stderr.toString());
process.exit(1);
}
process.exit(0);

View File

@@ -103,10 +103,12 @@ export class NotificationsService {
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,
});
if (type === NotificationType.UNREAD) {
this.publishOverview();
pubsub.publish(PUBSUB_CHANNEL.NOTIFICATION_ADDED, {
notificationAdded: notification,
});
}
}
/**

View File

@@ -10,9 +10,9 @@
<!ENTITY SHA256 "">
<!ENTITY API_version "">
<!ENTITY API_SHA256 "">
<!ENTITY NODEJS_FILENAME "node-v20.18.1-linux-x64.tar.xz">
<!ENTITY NODEJS_SHA256 "c6fa75c841cbffac851678a472f2a5bd612fff8308ef39236190e1f8dbb0e567">
<!ENTITY NODEJS_TXZ "https://nodejs.org/dist/v20.18.1/node-v20.18.1-linux-x64.tar.xz">
<!ENTITY NODEJS_FILENAME "bun-linux-x64-baseline.zip">
<!ENTITY NODEJS_SHA256 "cad7756a6ee16f3432a328f8023fc5cd431106822eacfa6d6d3afbad6fdc24db">
<!ENTITY NODEJS_TXZ "https://github.com/oven-sh/bun/releases/download/bun-v1.2.2/bun-linux-x64-baseline.zip">
<!ENTITY MAIN_TXZ "">
<!ENTITY API_TGZ "">
]>
@@ -128,29 +128,23 @@ sha256check() {
<![CDATA[
# Check if the Node.js archive exists
if [[ ! -f "/boot/config/plugins/dynamix.my.servers/${NODE_FILE}" ]]; then
echo "Node.js archive not found at /boot/config/plugins/dynamix.my.servers/${NODE_FILE}"
echo "Bun archive not found at /boot/config/plugins/dynamix.my.servers/${NODE_FILE}"
exit 1
fi
# Perform a dry run to verify the archive is valid
if ! tar --strip-components=1 -tf "/boot/config/plugins/dynamix.my.servers/${NODE_FILE}" > /dev/null; then
echo "Node.js archive is corrupt or invalid"
if ! unzip -t "/boot/config/plugins/dynamix.my.servers/${NODE_FILE}" > /dev/null; then
echo "Bun archive is corrupt or invalid"
exit 1
fi
# Define the target directory for Node.js
NODE_DIR="/usr/local/node"
# Create the target directory if it doesn't exist
mkdir -p "${NODE_DIR}" || { echo "Failed to create ${NODE_DIR}"; exit 1; }
# Extract the archive to the target directory
if ! tar --strip-components=1 -xf "/boot/config/plugins/dynamix.my.servers/${NODE_FILE}" -C "${NODE_DIR}"; then
echo "Failed to extract Node.js archive"
# Extract the Bun binary from the archive and move it to /usr/local/bin
if ! unzip -j -o "/boot/config/plugins/dynamix.my.servers/${NODE_FILE}" 'bun-linux-x64-baseline/bun' -d /usr/local/bin/; then
echo "Failed to extract Bun binary"
exit 1
fi
echo "Node.js installation successful"
echo "Bun installation successful"
exit 0
]]>
</INLINE>

View File

@@ -0,0 +1,4 @@
#! /bin/bash
# Add alias for bunx
alias bunx='bun x'

View File

@@ -1,6 +0,0 @@
#! /bin/bash
# Add Node.js binary path to PATH if not already present
if [[ ":$PATH:" != *":/usr/local/node/bin:"* ]]; then
export PATH="/usr/local/node/bin:$PATH"
fi

View File

@@ -131,19 +131,19 @@ const osVersionBranch = 'stable';
// };
export const serverState: Server = {
activationCodeData: {
code: 'CC2KP3TDRF',
partnerName: 'OEM Partner',
partnerUrl: 'https://unraid.net/OEM+Partner',
sysModel: 'OEM Partner v1',
comment: 'OEM Partner NAS',
caseIcon: 'case-model.png',
header: '#ffffff',
headermetacolor: '#eeeeee',
background: '#000000',
showBannerGradient: 'yes',
partnerLogo: true,
},
// activationCodeData: {
// code: 'CC2KP3TDRF',
// partnerName: 'OEM Partner',
// partnerUrl: 'https://unraid.net/OEM+Partner',
// sysModel: 'OEM Partner v1',
// comment: 'OEM Partner NAS',
// caseIcon: 'case-model.png',
// header: '#ffffff',
// headermetacolor: '#eeeeee',
// background: '#000000',
// showBannerGradient: 'yes',
// partnerLogo: true,
// },
avatar: 'https://source.unsplash.com/300x300/?portrait',
config: {
id: 'config-id',

View File

@@ -42,6 +42,13 @@ const { onResult: onNotificationAdded } = useSubscription(notificationAddedSubsc
onNotificationAdded(({ data }) => {
if (!data) return;
const notif = useFragment(NOTIFICATION_FRAGMENT, data.notificationAdded);
if (notif.type !== NotificationType.Unread) return;
// probably smart to leave this log outside the if-block for the initial release
console.log('incoming notification', notif);
if (!globalThis.toast) {
return;
}
const funcMapping: Record<Importance, (typeof globalThis)['toast']['info' | 'error' | 'warning']> = {
[Importance.Alert]: globalThis.toast.error,
@@ -51,10 +58,12 @@ onNotificationAdded(({ data }) => {
const toast = funcMapping[notif.importance];
const createOpener = () => ({ label: 'Open', onClick: () => location.assign(notif.link as string) });
toast(notif.title, {
description: notif.subject,
action: notif.link ? createOpener() : undefined,
});
requestAnimationFrame(() =>
toast(notif.title, {
description: notif.subject,
action: notif.link ? createOpener() : undefined,
})
);
});
const overview = computed(() => {