chore: lint api codebase

This commit is contained in:
Eli Bosley
2025-01-31 10:49:52 -05:00
parent 61ee689658
commit 2b213619db
290 changed files with 5685 additions and 3706 deletions

View File

@@ -1,4 +1,5 @@
import 'reflect-metadata';
import { expect, test } from 'vitest';
// Preloading imports for faster tests

View File

@@ -1,13 +1,11 @@
import { test, expect, vi } from 'vitest';
import { expect, test, vi } from 'vitest';
vi.mock('@app/core/pubsub', () => ({
pubsub: { publish: vi.fn() },
}));
test('Creates an array event', async () => {
const { getArrayData } = await import(
'@app/core/modules/array/get-array-data'
);
const { getArrayData } = await import('@app/core/modules/array/get-array-data');
const { store } = await import('@app/store');
const { loadStateFiles } = await import('@app/store/modules/emhttp');
const { loadConfigFile } = await import('@app/store/modules/config');
@@ -17,196 +15,194 @@ test('Creates an array event', async () => {
await store.dispatch(loadConfigFile());
const arrayEvent = getArrayData(store.getState);
expect(arrayEvent).toMatchObject(
{
"boot": {
"comment": "Unraid OS boot device",
"critical": null,
"device": "sda",
"exportable": true,
"format": "unknown",
"fsFree": 3191407,
"fsSize": 4042732,
"fsType": "vfat",
"fsUsed": 851325,
"id": "Cruzer",
"idx": 32,
"name": "flash",
"numErrors": 0,
"numReads": 0,
"numWrites": 0,
"rotational": true,
"size": 3956700,
"status": "DISK_OK",
"temp": null,
"transport": "usb",
"type": "Flash",
"warning": null,
expect(arrayEvent).toMatchObject({
boot: {
comment: 'Unraid OS boot device',
critical: null,
device: 'sda',
exportable: true,
format: 'unknown',
fsFree: 3191407,
fsSize: 4042732,
fsType: 'vfat',
fsUsed: 851325,
id: 'Cruzer',
idx: 32,
name: 'flash',
numErrors: 0,
numReads: 0,
numWrites: 0,
rotational: true,
size: 3956700,
status: 'DISK_OK',
temp: null,
transport: 'usb',
type: 'Flash',
warning: null,
},
"caches": [
{
"comment": "",
"critical": null,
"device": "sdi",
"exportable": false,
"format": "MBR: 4KiB-aligned",
"fsFree": 111810683,
"fsSize": 250059317,
"fsType": "btrfs",
"fsUsed": 137273827,
"id": "Samsung_SSD_850_EVO_250GB_S2R5NX0H643734Z",
"idx": 30,
"name": "cache",
"numErrors": 0,
"numReads": 0,
"numWrites": 0,
"rotational": false,
"size": 244198552,
"status": "DISK_OK",
"temp": 22,
"transport": "ata",
"type": "Cache",
"warning": null,
},
{
"comment": null,
"critical": null,
"device": "nvme0n1",
"exportable": false,
"format": "MBR: 4KiB-aligned",
"fsFree": null,
"fsSize": null,
"fsType": null,
"fsUsed": null,
"id": "KINGSTON_SA2000M8250G_50026B7282669D9E",
"idx": 31,
"name": "cache2",
"numErrors": 0,
"numReads": 0,
"numWrites": 0,
"rotational": false,
"size": 244198552,
"status": "DISK_OK",
"temp": 27,
"transport": "nvme",
"type": "Cache",
"warning": null,
},
caches: [
{
comment: '',
critical: null,
device: 'sdi',
exportable: false,
format: 'MBR: 4KiB-aligned',
fsFree: 111810683,
fsSize: 250059317,
fsType: 'btrfs',
fsUsed: 137273827,
id: 'Samsung_SSD_850_EVO_250GB_S2R5NX0H643734Z',
idx: 30,
name: 'cache',
numErrors: 0,
numReads: 0,
numWrites: 0,
rotational: false,
size: 244198552,
status: 'DISK_OK',
temp: 22,
transport: 'ata',
type: 'Cache',
warning: null,
},
{
comment: null,
critical: null,
device: 'nvme0n1',
exportable: false,
format: 'MBR: 4KiB-aligned',
fsFree: null,
fsSize: null,
fsType: null,
fsUsed: null,
id: 'KINGSTON_SA2000M8250G_50026B7282669D9E',
idx: 31,
name: 'cache2',
numErrors: 0,
numReads: 0,
numWrites: 0,
rotational: false,
size: 244198552,
status: 'DISK_OK',
temp: 27,
transport: 'nvme',
type: 'Cache',
warning: null,
},
],
"capacity": {
"disks": {
"free": "27",
"total": "30",
"used": "3",
},
"kilobytes": {
"free": "19495825571",
"total": "41994745901",
"used": "22498920330",
},
capacity: {
disks: {
free: '27',
total: '30',
used: '3',
},
kilobytes: {
free: '19495825571',
total: '41994745901',
used: '22498920330',
},
},
"disks": [
{
"comment": "Seagate Exos",
"critical": 75,
"device": "sdf",
"exportable": false,
"format": "GPT: 4KiB-aligned",
"fsFree": 13882739732,
"fsSize": 17998742753,
"fsType": "xfs",
"fsUsed": 4116003021,
"id": "ST18000NM000J-2TV103_ZR5B1W9X",
"idx": 1,
"name": "disk1",
"numErrors": 0,
"numReads": 0,
"numWrites": 0,
"rotational": true,
"size": 17578328012,
"status": "DISK_OK",
"temp": 30,
"transport": "ata",
"type": "Data",
"warning": 50,
},
{
"comment": "",
"critical": null,
"device": "sdj",
"exportable": false,
"format": "GPT: 4KiB-aligned",
"fsFree": 93140746,
"fsSize": 11998001574,
"fsType": "xfs",
"fsUsed": 11904860828,
"id": "WDC_WD120EDAZ-11F3RA0_5PJRD45C",
"idx": 2,
"name": "disk2",
"numErrors": 0,
"numReads": 0,
"numWrites": 0,
"rotational": true,
"size": 11718885324,
"status": "DISK_OK",
"temp": 30,
"transport": "ata",
"type": "Data",
"warning": null,
},
{
"comment": "",
"critical": null,
"device": "sde",
"exportable": false,
"format": "GPT: 4KiB-aligned",
"fsFree": 5519945093,
"fsSize": 11998001574,
"fsType": "xfs",
"fsUsed": 6478056481,
"id": "WDC_WD120EMAZ-11BLFA0_5PH8BTYD",
"idx": 3,
"name": "disk3",
"numErrors": 0,
"numReads": 0,
"numWrites": 0,
"rotational": true,
"size": 11718885324,
"status": "DISK_OK",
"temp": 30,
"transport": "ata",
"type": "Data",
"warning": null,
},
disks: [
{
comment: 'Seagate Exos',
critical: 75,
device: 'sdf',
exportable: false,
format: 'GPT: 4KiB-aligned',
fsFree: 13882739732,
fsSize: 17998742753,
fsType: 'xfs',
fsUsed: 4116003021,
id: 'ST18000NM000J-2TV103_ZR5B1W9X',
idx: 1,
name: 'disk1',
numErrors: 0,
numReads: 0,
numWrites: 0,
rotational: true,
size: 17578328012,
status: 'DISK_OK',
temp: 30,
transport: 'ata',
type: 'Data',
warning: 50,
},
{
comment: '',
critical: null,
device: 'sdj',
exportable: false,
format: 'GPT: 4KiB-aligned',
fsFree: 93140746,
fsSize: 11998001574,
fsType: 'xfs',
fsUsed: 11904860828,
id: 'WDC_WD120EDAZ-11F3RA0_5PJRD45C',
idx: 2,
name: 'disk2',
numErrors: 0,
numReads: 0,
numWrites: 0,
rotational: true,
size: 11718885324,
status: 'DISK_OK',
temp: 30,
transport: 'ata',
type: 'Data',
warning: null,
},
{
comment: '',
critical: null,
device: 'sde',
exportable: false,
format: 'GPT: 4KiB-aligned',
fsFree: 5519945093,
fsSize: 11998001574,
fsType: 'xfs',
fsUsed: 6478056481,
id: 'WDC_WD120EMAZ-11BLFA0_5PH8BTYD',
idx: 3,
name: 'disk3',
numErrors: 0,
numReads: 0,
numWrites: 0,
rotational: true,
size: 11718885324,
status: 'DISK_OK',
temp: 30,
transport: 'ata',
type: 'Data',
warning: null,
},
],
"id": expect.any(String),
"parities": [
{
"comment": null,
"critical": null,
"device": "sdh",
"exportable": false,
"format": "GPT: 4KiB-aligned",
"fsFree": null,
"fsSize": null,
"fsType": null,
"fsUsed": null,
"id": "ST18000NM000J-2TV103_ZR585CPY",
"idx": 0,
"name": "parity",
"numErrors": 0,
"numReads": 0,
"numWrites": 0,
"rotational": true,
"size": 17578328012,
"status": "DISK_OK",
"temp": 25,
"transport": "ata",
"type": "Parity",
"warning": null,
},
id: expect.any(String),
parities: [
{
comment: null,
critical: null,
device: 'sdh',
exportable: false,
format: 'GPT: 4KiB-aligned',
fsFree: null,
fsSize: null,
fsType: null,
fsUsed: null,
id: 'ST18000NM000J-2TV103_ZR585CPY',
idx: 0,
name: 'parity',
numErrors: 0,
numReads: 0,
numWrites: 0,
rotational: true,
size: 17578328012,
status: 'DISK_OK',
temp: 25,
transport: 'ata',
type: 'Parity',
warning: null,
},
],
"state": "STOPPED",
}
);
state: 'STOPPED',
});
});

View File

@@ -0,0 +1,22 @@
import { expect, test, vi } from 'vitest';
import { ConsoleNotifier } from '@app/core/notifiers/console';
vi.mock('@app/core/log', () => ({
logger: {
info: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
graphqlLogger: {
info: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
}));
test('Creates a console notifier', () => {
const notifier = new ConsoleNotifier();
expect(notifier.level).toBe('info');
expect(notifier.template).toBe('{{{ data }}}');
});

View File

@@ -0,0 +1,24 @@
import { expect, test, vi } from 'vitest';
import { UnraidLocalNotifier } from '@app/core/notifiers/unraid-local';
vi.mock('@app/core/log', () => ({
logger: {
info: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
graphqlLogger: {
info: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
}));
test('Creates an email notifier', () => {
const notifier = new UnraidLocalNotifier({ level: 'info' });
expect(notifier.level).toBe('normal');
expect(notifier.template).toBe('{{ message }}');
const rendered = notifier.render({ message: 'Remote access started' });
expect(rendered).toEqual('Remote access started');
});

View File

@@ -0,0 +1,23 @@
import { expect, test, vi } from 'vitest';
import type { SliceState } from '@app/store/modules/emhttp';
import { getters } from '@app/store';
test('Returns true if the array is started', async () => {
vi.spyOn(getters, 'emhttp').mockImplementation(
() => ({ var: { mdState: 'STARTED' } }) as unknown as SliceState
);
const { arrayIsRunning } = await import('@app/core/utils/array/array-is-running');
expect(arrayIsRunning()).toBe(true);
vi.spyOn(getters, 'emhttp').mockReset();
});
test('Returns false if the array is stopped', async () => {
vi.spyOn(getters, 'emhttp').mockImplementation(
() => ({ var: { mdState: 'Stopped' } }) as unknown as SliceState
);
const { arrayIsRunning } = await import('@app/core/utils/array/array-is-running');
expect(arrayIsRunning()).toBe(false);
vi.spyOn(getters, 'emhttp').mockReset();
});

View File

@@ -77,7 +77,7 @@ test('it creates a MEMORY config with NO OPTIONAL values', () => {
test('it creates a FLASH config with OPTIONAL values', () => {
const basicConfig = cloneDeep(initialState);
// 2fa & t2fa should be ignored
// 2fa & t2fa should be ignored
basicConfig.remote['2Fa'] = 'yes';
basicConfig.local['2Fa'] = 'yes';
basicConfig.local.showT2Fa = 'yes';
@@ -117,7 +117,7 @@ test('it creates a FLASH config with OPTIONAL values', () => {
test('it creates a MEMORY config with OPTIONAL values', () => {
const basicConfig = cloneDeep(initialState);
// 2fa & t2fa should be ignored
// 2fa & t2fa should be ignored
basicConfig.remote['2Fa'] = 'yes';
basicConfig.local['2Fa'] = 'yes';
basicConfig.local.showT2Fa = 'yes';

View File

@@ -1,7 +1,8 @@
import { test, expect } from 'vitest';
import { parse } from 'ini';
import { safelySerializeObjectToIni } from '@app/core/utils/files/safe-ini-serializer';
import { Serializer } from 'multi-ini';
import { expect, test } from 'vitest';
import { safelySerializeObjectToIni } from '@app/core/utils/files/safe-ini-serializer';
test('MultiIni breaks when serializing an object with a boolean inside', async () => {
const objectToSerialize = {

View File

@@ -1,23 +1,23 @@
import { getBannerPathIfPresent, getCasePathIfPresent } from "@app/core/utils/images/image-file-helpers";
import { loadDynamixConfigFile } from "@app/store/actions/load-dynamix-config-file";
import { store } from "@app/store/index";
import { expect, test } from 'vitest';
import { expect, test } from "vitest";
import { getBannerPathIfPresent, getCasePathIfPresent } from '@app/core/utils/images/image-file-helpers';
import { loadDynamixConfigFile } from '@app/store/actions/load-dynamix-config-file';
import { store } from '@app/store/index';
test('get case path returns expected result', async () => {
await expect(getCasePathIfPresent()).resolves.toContain('/dev/dynamix/case-model.png')
})
await expect(getCasePathIfPresent()).resolves.toContain('/dev/dynamix/case-model.png');
});
test('get banner path returns null (state unloaded)', async () => {
await expect(getBannerPathIfPresent()).resolves.toMatchInlineSnapshot('null')
})
await expect(getBannerPathIfPresent()).resolves.toMatchInlineSnapshot('null');
});
test('get banner path returns the banner (state loaded)', async() => {
await store.dispatch(loadDynamixConfigFile()).unwrap();
test('get banner path returns the banner (state loaded)', async () => {
await store.dispatch(loadDynamixConfigFile()).unwrap();
await expect(getBannerPathIfPresent()).resolves.toContain('/dev/dynamix/banner.png');
})
});
test('get banner path returns null when no banner (state loaded)', async () => {
await store.dispatch(loadDynamixConfigFile()).unwrap();
await expect(getBannerPathIfPresent('notabanner.png')).resolves.toMatchInlineSnapshot('null');
});
});

View File

@@ -0,0 +1,8 @@
import { expect, test } from 'vitest';
import { cleanStdout } from '@app/core/utils/misc/clean-stdout';
test('Returns trimmed stdout from execa command', () => {
expect(cleanStdout({ stdout: 'test' })).toBe('test');
expect(cleanStdout({ stdout: 'test ' })).toBe('test');
});

View File

@@ -0,0 +1,64 @@
import { expect, test } from 'vitest';
import { store } from '@app/store';
import { FileLoadStatus, StateFileKey } from '@app/store/types';
import '@app/core/utils/misc/get-key-file';
import '@app/store/modules/emhttp';
test('Before loading key returns null', async () => {
const { getKeyFile } = await import('@app/core/utils/misc/get-key-file');
const { status } = store.getState().registration;
expect(status).toBe(FileLoadStatus.UNLOADED);
await expect(getKeyFile()).resolves.toBe(null);
});
test('Requires emhttp to be loaded to find key file', async () => {
const { getKeyFile } = await import('@app/core/utils/misc/get-key-file');
const { loadRegistrationKey } = await import('@app/store/modules/registration');
// Load registration key into store
await store.dispatch(loadRegistrationKey());
// Check if store has state files loaded
const { status } = store.getState().registration;
expect(status).toBe(FileLoadStatus.LOADED);
await expect(getKeyFile()).resolves.toBe(null);
});
test('Returns empty key if key location is empty', async () => {
const { getKeyFile } = await import('@app/core/utils/misc/get-key-file');
const { updateEmhttpState } = await import('@app/store/modules/emhttp');
// Set key file location as empty
// This should only happen if the user doesn't have a key file
store.dispatch(
updateEmhttpState({
field: StateFileKey.var,
state: {
regFile: '',
},
})
);
// Check if store has state files loaded
const { status } = store.getState().registration;
expect(status).toBe(FileLoadStatus.LOADED);
await expect(getKeyFile()).resolves.toBe('');
});
test('Returns decoded key file if key location exists', async () => {
const { getKeyFile } = await import('@app/core/utils/misc/get-key-file');
const { loadStateFiles } = await import('@app/store/modules/emhttp');
// Load state files into store
await store.dispatch(loadStateFiles());
// Check if store has state files loaded
const { status } = store.getState().registration;
expect(status).toBe(FileLoadStatus.LOADED);
await expect(getKeyFile()).resolves.toMatchInlineSnapshot(
'"hVs1tLjvC9FiiQsIwIQ7G1KszAcexf0IneThhnmf22SB0dGs5WzRkqMiSMmt2DtR5HOXFUD32YyxuzGeUXmky3zKpSu6xhZNKVg5atGM1OfvkzHBMldI3SeBLuUFSgejLbpNUMdTrbk64JJdbzle4O8wiQgkIpAMIGxeYLwLBD4zHBcfyzq40QnxG--HcX6j25eE0xqa2zWj-j0b0rCAXahJV2a3ySCbPzr1MvfPRTVb0rr7KJ-25R592hYrz4H7Sc1B3p0lr6QUxHE6o7bcYrWKDRtIVoZ8SMPpd1_0gzYIcl5GsDFzFumTXUh8NEnl0Q8hwW1YE-tRc6Y_rrvd7w"'
);
});

View File

@@ -1,9 +1,11 @@
import { test, expect } from 'vitest';
import { parseConfig } from '@app/core/utils/misc/parse-config';
import { Parser as MultiIniParser } from 'multi-ini';
import { readFile, writeFile } from 'fs/promises';
import { parse } from 'ini';
import { Parser as MultiIniParser } from 'multi-ini';
import { expect, test } from 'vitest';
import { safelySerializeObjectToIni } from '@app/core/utils/files/safe-ini-serializer';
import { parseConfig } from '@app/core/utils/misc/parse-config';
const iniTestData = `["root"]
idx="0"
@@ -22,11 +24,11 @@ desc=""
passwd="no"`;
test('it loads a config from a passed in ini file successfully', () => {
const res = parseConfig<any>({
file: iniTestData,
type: 'ini',
});
expect(res).toMatchInlineSnapshot(`
const res = parseConfig<any>({
file: iniTestData,
type: 'ini',
});
expect(res).toMatchInlineSnapshot(`
{
"root": {
"desc": "Console and webGui login account",
@@ -48,26 +50,26 @@ test('it loads a config from a passed in ini file successfully', () => {
},
}
`);
expect(res?.root.desc).toEqual('Console and webGui login account');
expect(res?.root.desc).toEqual('Console and webGui login account');
});
test('it loads a config from disk properly', () => {
const path = './dev/states/var.ini';
const res = parseConfig<any>({ filePath: path, type: 'ini' });
expect(res.DOMAIN_SHORT).toEqual(undefined);
expect(res.domainShort).toEqual('');
expect(res.shareCount).toEqual('0');
const path = './dev/states/var.ini';
const res = parseConfig<any>({ filePath: path, type: 'ini' });
expect(res.DOMAIN_SHORT).toEqual(undefined);
expect(res.domainShort).toEqual('');
expect(res.shareCount).toEqual('0');
});
test('Confirm Multi-Ini Parser Still Broken', () => {
const parser = new MultiIniParser();
const res = parser.parse(iniTestData);
expect(res).toMatchInlineSnapshot('{}');
const parser = new MultiIniParser();
const res = parser.parse(iniTestData);
expect(res).toMatchInlineSnapshot('{}');
});
test('Combine Ini and Multi-Ini to read and then write a file with quotes', async () => {
const parsedFile = parse(iniTestData);
expect(parsedFile).toMatchInlineSnapshot(`
const parsedFile = parse(iniTestData);
expect(parsedFile).toMatchInlineSnapshot(`
{
"root": {
"desc": "Console and webGui login account",
@@ -90,10 +92,10 @@ test('Combine Ini and Multi-Ini to read and then write a file with quotes', asyn
}
`);
const ini = safelySerializeObjectToIni(parsedFile);
await writeFile('/tmp/test.ini', ini);
const file = await readFile('/tmp/test.ini', 'utf-8');
expect(file).toMatchInlineSnapshot(`
const ini = safelySerializeObjectToIni(parsedFile);
await writeFile('/tmp/test.ini', ini);
const file = await readFile('/tmp/test.ini', 'utf-8');
expect(file).toMatchInlineSnapshot(`
"[root]
idx="0"
name="root"

View File

@@ -1,12 +1,13 @@
import { expect, test } from 'vitest';
import { getShares } from '@app/core/utils/shares/get-shares';
import { store } from '@app/store';
import { loadStateFiles } from '@app/store/modules/emhttp';
test('Returns both disk and user shares', async () => {
await store.dispatch(loadStateFiles());
await store.dispatch(loadStateFiles());
expect(getShares()).toMatchInlineSnapshot(`
expect(getShares()).toMatchInlineSnapshot(`
{
"disks": [],
"users": [
@@ -96,8 +97,8 @@ test('Returns both disk and user shares', async () => {
});
test('Returns shares by type', async () => {
await store.dispatch(loadStateFiles());
expect(getShares('user')).toMatchInlineSnapshot(`
await store.dispatch(loadStateFiles());
expect(getShares('user')).toMatchInlineSnapshot(`
{
"allocator": "highwater",
"cachePool": "cache",
@@ -119,7 +120,7 @@ test('Returns shares by type', async () => {
"used": 33619300,
}
`);
expect(getShares('users')).toMatchInlineSnapshot(`
expect(getShares('users')).toMatchInlineSnapshot(`
[
{
"allocator": "highwater",
@@ -203,12 +204,12 @@ test('Returns shares by type', async () => {
},
]
`);
expect(getShares('disk')).toMatchInlineSnapshot('null');
expect(getShares('disks')).toMatchInlineSnapshot('[]');
expect(getShares('disk')).toMatchInlineSnapshot('null');
expect(getShares('disks')).toMatchInlineSnapshot('[]');
});
test('Returns shares by name', async () => {
expect(getShares('user', { name: 'domains' })).toMatchInlineSnapshot(`
expect(getShares('user', { name: 'domains' })).toMatchInlineSnapshot(`
{
"allocator": "highwater",
"cachePool": "cache",
@@ -230,8 +231,8 @@ test('Returns shares by name', async () => {
"used": 33619300,
}
`);
expect(getShares('user', { name: 'non-existent-user-share' })).toMatchInlineSnapshot('null');
// @TODO: disk shares need to be added to the dev ini files
expect(getShares('disk', { name: 'disk1' })).toMatchInlineSnapshot('null');
expect(getShares('disk', { name: 'non-existent-disk-share' })).toMatchInlineSnapshot('null');
expect(getShares('user', { name: 'non-existent-user-share' })).toMatchInlineSnapshot('null');
// @TODO: disk shares need to be added to the dev ini files
expect(getShares('disk', { name: 'disk1' })).toMatchInlineSnapshot('null');
expect(getShares('disk', { name: 'non-existent-disk-share' })).toMatchInlineSnapshot('null');
});

View File

@@ -0,0 +1,34 @@
import { afterEach, expect, test, vi } from 'vitest';
import { checkDNS } from '@app/graphql/resolvers/query/cloud/check-dns';
import { store } from '@app/store';
import { clearKey } from '@app/store/modules/cache';
import { CacheKeys } from '@app/store/types';
afterEach(() => {
store.dispatch(clearKey(CacheKeys.checkDns));
});
test('it resolves dns successfully', async () => {
// @TODO
const dns = await checkDNS('example.com');
expect(dns.cloudIp).not.toBeNull();
}, 25_000);
test('testing twice results in a cache hit', async () => {
// Hit mothership
const getters = await import('@app/store/getters');
const dnsSpy = vi.spyOn(getters, 'getDnsCache');
const dns = await checkDNS();
expect(dns.cloudIp).toBeTypeOf('string');
expect(dnsSpy.mock.results[0]).toMatchInlineSnapshot(`
{
"type": "return",
"value": undefined,
}
`);
const dnslookup2 = await checkDNS();
expect(dnslookup2.cloudIp).toEqual(dns.cloudIp);
expect(dnsSpy.mock.results[1].value.cloudIp).toEqual(dns.cloudIp);
expect(store.getState().cache.nodeCache.getTtl(CacheKeys.checkDns)).toBeGreaterThan(500);
});

View File

@@ -1,10 +1,16 @@
import 'reflect-metadata';
import { checkMothershipAuthentication } from "@app/graphql/resolvers/query/cloud/check-mothership-authentication";
import { expect, test } from "vitest";
import packageJson from '@app/../package.json'
import { expect, test } from 'vitest';
import packageJson from '@app/../package.json';
import { checkMothershipAuthentication } from '@app/graphql/resolvers/query/cloud/check-mothership-authentication';
test('It fails to authenticate with mothership with no credentials', async () => {
await expect(checkMothershipAuthentication('BAD', 'BAD')).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: Failed to connect to https://mothership.unraid.net/ws with a "426" HTTP error.]`);
await expect(checkMothershipAuthentication('BAD', 'BAD')).rejects.toThrowErrorMatchingInlineSnapshot(
`[Error: Failed to connect to https://mothership.unraid.net/ws with a "426" HTTP error.]`
);
expect(packageJson.version).not.toBeNull();
await expect(checkMothershipAuthentication(packageJson.version, 'BAD_API_KEY')).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: Invalid credentials]`);
}, 15_000)
await expect(
checkMothershipAuthentication(packageJson.version, 'BAD_API_KEY')
).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: Invalid credentials]`);
}, 15_000);

View File

@@ -1,124 +1,197 @@
import { expect, test } from 'vitest';
import { type Nginx } from '../../../../core/types/states/nginx';
import { getUrlForField, getUrlForServer, getServerIps, type NginxUrlFields } from '@app/graphql/resolvers/subscription/network';
import type { NginxUrlFields } from '@app/graphql/resolvers/subscription/network';
import { type Nginx } from '@app/core/types/states/nginx';
import {
getServerIps,
getUrlForField,
getUrlForServer,
} from '@app/graphql/resolvers/subscription/network';
import { store } from '@app/store';
import { loadStateFiles } from '@app/store/modules/emhttp';
import { loadConfigFile } from '@app/store/modules/config';
import { loadStateFiles } from '@app/store/modules/emhttp';
test.each([
[{ httpPort: 80, httpsPort: 443, url: 'my-default-url.com' }],
[{ httpPort: 123, httpsPort: 443, url: 'my-default-url.com' }],
[{ httpPort: 80, httpsPort: 12_345, url: 'my-default-url.com' }],
[{ httpPort: 212, httpsPort: 3_233, url: 'my-default-url.com' }],
[{ httpPort: 80, httpsPort: 443, url: 'https://BROKEN_URL' }],
[{ httpPort: 80, httpsPort: 443, url: 'my-default-url.com' }],
[{ httpPort: 123, httpsPort: 443, url: 'my-default-url.com' }],
[{ httpPort: 80, httpsPort: 12_345, url: 'my-default-url.com' }],
[{ httpPort: 212, httpsPort: 3_233, url: 'my-default-url.com' }],
[{ httpPort: 80, httpsPort: 443, url: 'https://BROKEN_URL' }],
])('getUrlForField', ({ httpPort, httpsPort, url }) => {
const responseInsecure = getUrlForField({
port: httpPort,
url,
});
const responseInsecure = getUrlForField({
port: httpPort,
url,
});
const responseSecure = getUrlForField({
portSsl: httpsPort,
url,
});
if (httpPort === 80) {
expect(responseInsecure.port).toBe('');
} else {
expect(responseInsecure.port).toBe(httpPort.toString());
}
const responseSecure = getUrlForField({
portSsl: httpsPort,
url,
});
if (httpPort === 80) {
expect(responseInsecure.port).toBe('');
} else {
expect(responseInsecure.port).toBe(httpPort.toString());
}
if (httpsPort === 443) {
expect(responseSecure.port).toBe('');
} else {
expect(responseSecure.port).toBe(httpsPort.toString());
}
if (httpsPort === 443) {
expect(responseSecure.port).toBe('');
} else {
expect(responseSecure.port).toBe(httpsPort.toString());
}
});
test('getUrlForServer - field exists, ssl disabled', () => {
const result = getUrlForServer({ nginx: { lanIp: '192.168.1.1', sslEnabled: false, httpPort: 123, httpsPort: 445 } as const as Nginx,
field: 'lanIp',
});
expect(result).toMatchInlineSnapshot('"http://192.168.1.1:123/"');
const result = getUrlForServer({
nginx: {
lanIp: '192.168.1.1',
sslEnabled: false,
httpPort: 123,
httpsPort: 445,
} as const as Nginx,
field: 'lanIp',
});
expect(result).toMatchInlineSnapshot('"http://192.168.1.1:123/"');
});
test('getUrlForServer - field exists, ssl yes', () => {
const result = getUrlForServer({
nginx: { lanIp: '192.168.1.1', sslEnabled: true, sslMode: 'yes', httpPort: 123, httpsPort: 445 } as const as Nginx,
field: 'lanIp',
});
expect(result).toMatchInlineSnapshot('"https://192.168.1.1:445/"');
const result = getUrlForServer({
nginx: {
lanIp: '192.168.1.1',
sslEnabled: true,
sslMode: 'yes',
httpPort: 123,
httpsPort: 445,
} as const as Nginx,
field: 'lanIp',
});
expect(result).toMatchInlineSnapshot('"https://192.168.1.1:445/"');
});
test('getUrlForServer - field exists, ssl yes, port empty', () => {
const result = getUrlForServer(
{ nginx: { lanIp: '192.168.1.1', sslEnabled: true, sslMode: 'yes', httpPort: 80, httpsPort: 443 } as const as Nginx,
field: 'lanIp',
});
expect(result).toMatchInlineSnapshot('"https://192.168.1.1/"');
const result = getUrlForServer({
nginx: {
lanIp: '192.168.1.1',
sslEnabled: true,
sslMode: 'yes',
httpPort: 80,
httpsPort: 443,
} as const as Nginx,
field: 'lanIp',
});
expect(result).toMatchInlineSnapshot('"https://192.168.1.1/"');
});
test('getUrlForServer - field exists, ssl auto', async () => {
const getResult = async () => getUrlForServer({
nginx: { lanIp: '192.168.1.1', sslEnabled: true, sslMode: 'auto', httpPort: 123, httpsPort: 445 } as const as Nginx,
field: 'lanIp',
});
await expect(getResult).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: Cannot get IP Based URL for field: "lanIp" SSL mode auto]`);
const getResult = async () =>
getUrlForServer({
nginx: {
lanIp: '192.168.1.1',
sslEnabled: true,
sslMode: 'auto',
httpPort: 123,
httpsPort: 445,
} as const as Nginx,
field: 'lanIp',
});
await expect(getResult).rejects.toThrowErrorMatchingInlineSnapshot(
`[Error: Cannot get IP Based URL for field: "lanIp" SSL mode auto]`
);
});
test('getUrlForServer - field does not exist, ssl disabled', async () => {
const getResult = async () => getUrlForServer(
{
nginx: { lanIp: '192.168.1.1', sslEnabled: false, sslMode: 'no' } as const as Nginx,
ports: {
port: ':123', portSsl: ':445', defaultUrl: new URL('https://my-default-url.unraid.net'),
},
// @ts-expect-error Field doesn't exist
field: 'idontexist',
});
await expect(getResult).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: IP URL Resolver: Could not resolve any access URL for field: "idontexist", is FQDN?: false]`);
const getResult = async () =>
getUrlForServer({
nginx: { lanIp: '192.168.1.1', sslEnabled: false, sslMode: 'no' } as const as Nginx,
ports: {
port: ':123',
portSsl: ':445',
defaultUrl: new URL('https://my-default-url.unraid.net'),
},
// @ts-expect-error Field doesn't exist
field: 'idontexist',
});
await expect(getResult).rejects.toThrowErrorMatchingInlineSnapshot(
`[Error: IP URL Resolver: Could not resolve any access URL for field: "idontexist", is FQDN?: false]`
);
});
test('getUrlForServer - FQDN - field exists, port non-empty', () => {
const result = getUrlForServer({
nginx: { lanFqdn: 'my-fqdn.unraid.net', httpsPort: 445 } as const as Nginx,
field: 'lanFqdn',
});
expect(result).toMatchInlineSnapshot('"https://my-fqdn.unraid.net:445/"');
const result = getUrlForServer({
nginx: { lanFqdn: 'my-fqdn.unraid.net', httpsPort: 445 } as const as Nginx,
field: 'lanFqdn',
});
expect(result).toMatchInlineSnapshot('"https://my-fqdn.unraid.net:445/"');
});
test('getUrlForServer - FQDN - field exists, port empty', () => {
const result = getUrlForServer({ nginx: { lanFqdn: 'my-fqdn.unraid.net', httpPort: 80, httpsPort: 443 } as const as Nginx,
field: 'lanFqdn',
});
expect(result).toMatchInlineSnapshot('"https://my-fqdn.unraid.net/"');
const result = getUrlForServer({
nginx: { lanFqdn: 'my-fqdn.unraid.net', httpPort: 80, httpsPort: 443 } as const as Nginx,
field: 'lanFqdn',
});
expect(result).toMatchInlineSnapshot('"https://my-fqdn.unraid.net/"');
});
test.each([
[{ nginx: { lanFqdn: 'my-fqdn.unraid.net', sslEnabled: false, sslMode: 'no', httpPort: 80, httpsPort: 443 } as const as Nginx, field: 'lanFqdn' as NginxUrlFields }],
[{ nginx: { wanFqdn: 'my-fqdn.unraid.net', sslEnabled: true, sslMode: 'yes', httpPort: 80, httpsPort: 443 } as const as Nginx, field: 'wanFqdn' as NginxUrlFields }],
[{ nginx: { wanFqdn6: 'my-fqdn.unraid.net', sslEnabled: true, sslMode: 'auto', httpPort: 80, httpsPort: 443 } as const as Nginx, field: 'wanFqdn6' as NginxUrlFields }],
[
{
nginx: {
lanFqdn: 'my-fqdn.unraid.net',
sslEnabled: false,
sslMode: 'no',
httpPort: 80,
httpsPort: 443,
} as const as Nginx,
field: 'lanFqdn' as NginxUrlFields,
},
],
[
{
nginx: {
wanFqdn: 'my-fqdn.unraid.net',
sslEnabled: true,
sslMode: 'yes',
httpPort: 80,
httpsPort: 443,
} as const as Nginx,
field: 'wanFqdn' as NginxUrlFields,
},
],
[
{
nginx: {
wanFqdn6: 'my-fqdn.unraid.net',
sslEnabled: true,
sslMode: 'auto',
httpPort: 80,
httpsPort: 443,
} as const as Nginx,
field: 'wanFqdn6' as NginxUrlFields,
},
],
])('getUrlForServer - FQDN', ({ nginx, field }) => {
const result = getUrlForServer({ nginx, field });
expect(result.toString()).toBe('https://my-fqdn.unraid.net/');
const result = getUrlForServer({ nginx, field });
expect(result.toString()).toBe('https://my-fqdn.unraid.net/');
});
test('getUrlForServer - field does not exist, ssl disabled', async () => {
const getResult = async () => getUrlForServer({ nginx:
{ lanFqdn: 'my-fqdn.unraid.net' } as const as Nginx,
ports: { portSsl: '', port: '', defaultUrl: new URL('https://my-default-url.unraid.net') },
// @ts-expect-error Field doesn't exist
field: 'idontexist' });
await expect(getResult).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: IP URL Resolver: Could not resolve any access URL for field: "idontexist", is FQDN?: false]`);
const getResult = async () =>
getUrlForServer({
nginx: { lanFqdn: 'my-fqdn.unraid.net' } as const as Nginx,
ports: { portSsl: '', port: '', defaultUrl: new URL('https://my-default-url.unraid.net') },
// @ts-expect-error Field doesn't exist
field: 'idontexist',
});
await expect(getResult).rejects.toThrowErrorMatchingInlineSnapshot(
`[Error: IP URL Resolver: Could not resolve any access URL for field: "idontexist", is FQDN?: false]`
);
});
test('integration test, loading nginx ini and generating all URLs', async () => {
await store.dispatch(loadStateFiles());
await store.dispatch(loadConfigFile());
await store.dispatch(loadStateFiles());
await store.dispatch(loadConfigFile());
const urls = getServerIps();
expect(urls.urls).toMatchInlineSnapshot(`
const urls = getServerIps();
expect(urls.urls).toMatchInlineSnapshot(`
[
{
"ipv4": "https://tower.local:4443/",
@@ -198,7 +271,7 @@ test('integration test, loading nginx ini and generating all URLs', async () =>
},
]
`);
expect(urls.errors).toMatchInlineSnapshot(`
expect(urls.errors).toMatchInlineSnapshot(`
[
[Error: IP URL Resolver: Could not resolve any access URL for field: "lanIp6", is FQDN?: false],
]

View File

@@ -4,45 +4,45 @@ import { beforeEach, expect, test, vi } from 'vitest';
import '@app/mothership/utils/convert-to-fuzzy-time';
vi.mock('fs', () => ({
default: {
readFileSync: vi.fn().mockReturnValue('my-file'),
writeFileSync: vi.fn(),
existsSync: vi.fn(),
},
readFileSync: vi.fn().mockReturnValue('my-file'),
existsSync: vi.fn(),
default: {
readFileSync: vi.fn().mockReturnValue('my-file'),
writeFileSync: vi.fn(),
existsSync: vi.fn(),
},
readFileSync: vi.fn().mockReturnValue('my-file'),
existsSync: vi.fn(),
}));
vi.mock('@graphql-tools/schema', () => ({
makeExecutableSchema: vi.fn(),
makeExecutableSchema: vi.fn(),
}));
vi.mock('@app/core/log', () => ({
default: { relayLogger: { trace: vi.fn() } },
relayLogger: { trace: vi.fn() },
logger: { trace: vi.fn() },
default: { relayLogger: { trace: vi.fn() } },
relayLogger: { trace: vi.fn() },
logger: { trace: vi.fn() },
}));
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
vi.resetModules();
vi.clearAllMocks();
});
const generateTestCases = () => {
const cases: Array<{ min: number; max: number }> = [];
for (let i = 0; i < 15; i += 1) {
const min = Math.round(Math.random() * 100);
const max = min + (Math.round(Math.random() * 20));
cases.push({ min, max });
}
const cases: Array<{ min: number; max: number }> = [];
for (let i = 0; i < 15; i += 1) {
const min = Math.round(Math.random() * 100);
const max = min + Math.round(Math.random() * 20);
cases.push({ min, max });
}
return cases;
return cases;
};
test.each(generateTestCases())('Successfully converts to fuzzy time %o', async ({ min, max }) => {
const { convertToFuzzyTime } = await import('@app/mothership/utils/convert-to-fuzzy-time');
const { convertToFuzzyTime } = await import('@app/mothership/utils/convert-to-fuzzy-time');
const res = convertToFuzzyTime(min, max);
expect(res).toBeGreaterThanOrEqual(min);
expect(res).toBeLessThanOrEqual(max);
const res = convertToFuzzyTime(min, max);
expect(res).toBeGreaterThanOrEqual(min);
expect(res).toBeLessThanOrEqual(max);
});

View File

@@ -1,6 +1,7 @@
import { config } from 'dotenv';
config({
path: './.env.test',
debug: false,
encoding: 'utf-8',
})
});

View File

@@ -0,0 +1,6 @@
import { vi } from 'vitest';
vi.mock('@app/core/utils/misc/send-form-to-keyserver', () => {
const sendFormToKeyServer = vi.fn().mockResolvedValue({ body: JSON.stringify({ valid: true }) });
return { sendFormToKeyServer };
});

View File

@@ -27,7 +27,7 @@ test('After init returns values from cfg file for all fields', async () => {
upnpStatus: '',
},
local: {
sandbox: expect.any(String)
sandbox: expect.any(String),
},
nodeEnv: 'test',
remote: {
@@ -77,7 +77,7 @@ test('updateUserConfig merges in changes to current state', async () => {
upnpStatus: '',
},
local: {
sandbox: expect.any(String)
sandbox: expect.any(String),
},
nodeEnv: 'test',
remote: {

View File

@@ -1,4 +1,5 @@
import { test, expect } from 'vitest';
import { expect, test } from 'vitest';
import { store } from '@app/store';
import { FileLoadStatus } from '@app/store/types';
@@ -6,9 +7,9 @@ import { FileLoadStatus } from '@app/store/types';
import '@app/store/modules/emhttp';
test('Before init returns default values for all fields', async () => {
const { status, ...state } = store.getState().emhttp;
expect(status).toBe(FileLoadStatus.UNLOADED);
expect(state).toMatchInlineSnapshot(`
const { status, ...state } = store.getState().emhttp;
expect(status).toBe(FileLoadStatus.UNLOADED);
expect(state).toMatchInlineSnapshot(`
{
"devices": [],
"disks": [],
@@ -24,16 +25,27 @@ test('Before init returns default values for all fields', async () => {
});
test('After init returns values from cfg file for all fields', async () => {
const { loadStateFiles } = await import('@app/store/modules/emhttp');
const { loadStateFiles } = await import('@app/store/modules/emhttp');
// Load state files into store
await store.dispatch(loadStateFiles());
// Load state files into store
await store.dispatch(loadStateFiles());
// Check if store has state files loaded
const { devices, networks, nfsShares, nginx, shares, disks, smbShares, status, users, var: varState } = store.getState().emhttp;
expect(status).toBe(FileLoadStatus.LOADED);
expect(devices).toMatchInlineSnapshot('[]');
expect(networks).toMatchInlineSnapshot(`
// Check if store has state files loaded
const {
devices,
networks,
nfsShares,
nginx,
shares,
disks,
smbShares,
status,
users,
var: varState,
} = store.getState().emhttp;
expect(status).toBe(FileLoadStatus.LOADED);
expect(devices).toMatchInlineSnapshot('[]');
expect(networks).toMatchInlineSnapshot(`
[
{
"bonding": true,
@@ -99,7 +111,7 @@ test('After init returns values from cfg file for all fields', async () => {
},
]
`);
expect(nginx).toMatchInlineSnapshot(`
expect(nginx).toMatchInlineSnapshot(`
{
"certificateName": "*.thisisfourtyrandomcharacters012345678900.myunraid.net",
"certificatePath": "/boot/config/ssl/certs/certificate_bundle.pem",
@@ -184,7 +196,7 @@ test('After init returns values from cfg file for all fields', async () => {
"wanIp": "",
}
`);
expect(disks).toMatchInlineSnapshot(`
expect(disks).toMatchInlineSnapshot(`
[
{
"comment": null,
@@ -356,7 +368,7 @@ test('After init returns values from cfg file for all fields', async () => {
},
]
`);
expect(shares).toMatchInlineSnapshot(`
expect(shares).toMatchInlineSnapshot(`
[
{
"allocator": "highwater",
@@ -432,7 +444,7 @@ test('After init returns values from cfg file for all fields', async () => {
},
]
`);
expect(nfsShares).toMatchInlineSnapshot(`
expect(nfsShares).toMatchInlineSnapshot(`
[
{
"enabled": false,
@@ -620,7 +632,7 @@ test('After init returns values from cfg file for all fields', async () => {
},
]
`);
expect(smbShares).toMatchInlineSnapshot(`
expect(smbShares).toMatchInlineSnapshot(`
[
{
"caseSensitive": "auto",
@@ -911,7 +923,7 @@ test('After init returns values from cfg file for all fields', async () => {
},
]
`);
expect(users).toMatchInlineSnapshot(`
expect(users).toMatchInlineSnapshot(`
[
{
"description": "Console and webGui login account",
@@ -936,7 +948,7 @@ test('After init returns values from cfg file for all fields', async () => {
},
]
`);
expect(varState).toMatchInlineSnapshot(`
expect(varState).toMatchInlineSnapshot(`
{
"bindMgt": false,
"cacheNumDevices": NaN,

View File

@@ -1,8 +1,9 @@
import { expect, test } from 'vitest';
import { setupNotificationWatch } from '@app/core/modules/notifications/setup-notification-watch';
import { sleep } from '@app/core/utils/misc/sleep';
import { loadDynamixConfigFile } from '@app/store/actions/load-dynamix-config-file';
import { store } from '@app/store/index';
import { expect, test } from 'vitest';
test('loads notifications properly', async () => {
await store.dispatch(loadDynamixConfigFile()).unwrap();

View File

@@ -1,4 +1,5 @@
import { expect, test } from 'vitest';
import { store } from '@app/store';
test('Returns paths', async () => {

View File

@@ -0,0 +1,66 @@
import { expect, test } from 'vitest';
import { store } from '@app/store';
import { loadRegistrationKey } from '@app/store/modules/registration';
import { FileLoadStatus, StateFileKey } from '@app/store/types';
// Preloading imports for faster tests
test('Before loading key returns null', async () => {
const { status, keyFile } = store.getState().registration;
expect(status).toBe(FileLoadStatus.UNLOADED);
expect(keyFile).toBe(null);
});
test('Requires emhttp to be loaded to find key file', async () => {
// Load registration key into store
await store.dispatch(loadRegistrationKey());
// Check if store has state files loaded
const { status, keyFile } = store.getState().registration;
expect(status).toBe(FileLoadStatus.LOADED);
expect(keyFile).toBe(null);
});
test('Returns empty key if key location is empty', async () => {
const { updateEmhttpState } = await import('@app/store/modules/emhttp');
const { loadRegistrationKey } = await import('@app/store/modules/registration');
// Set key file location as empty
// This should only happen if the user doesn't have a key file
store.dispatch(
updateEmhttpState({
field: StateFileKey.var,
state: {
regFile: '',
},
})
);
// Load registration key into store
await store.dispatch(loadRegistrationKey());
// Check if store has state files loaded
const { status, keyFile } = store.getState().registration;
expect(status).toBe(FileLoadStatus.LOADED);
expect(keyFile).toBe('');
});
test('Returns decoded key file if key location exists', async () => {
const { loadRegistrationKey } = await import('@app/store/modules/registration');
const { loadStateFiles } = await import('@app/store/modules/emhttp');
// Load state files into store
await store.dispatch(loadStateFiles());
// Load registration key into store
await store.dispatch(loadRegistrationKey());
// Check if store has state files loaded
const { status, keyFile } = store.getState().registration;
expect(status).toBe(FileLoadStatus.LOADED);
expect(keyFile).toMatchInlineSnapshot(
'"hVs1tLjvC9FiiQsIwIQ7G1KszAcexf0IneThhnmf22SB0dGs5WzRkqMiSMmt2DtR5HOXFUD32YyxuzGeUXmky3zKpSu6xhZNKVg5atGM1OfvkzHBMldI3SeBLuUFSgejLbpNUMdTrbk64JJdbzle4O8wiQgkIpAMIGxeYLwLBD4zHBcfyzq40QnxG--HcX6j25eE0xqa2zWj-j0b0rCAXahJV2a3ySCbPzr1MvfPRTVb0rr7KJ-25R592hYrz4H7Sc1B3p0lr6QUxHE6o7bcYrWKDRtIVoZ8SMPpd1_0gzYIcl5GsDFzFumTXUh8NEnl0Q8hwW1YE-tRc6Y_rrvd7w"'
);
});

View File

@@ -0,0 +1,18 @@
import { join } from 'path';
import { expect, test } from 'vitest';
import type { DevicesIni } from '@app/store/state-parsers/devices';
import { store } from '@app/store';
test('Returns parsed state file', async () => {
const { parse } = await import('@app/store/state-parsers/devices');
const { parseConfig } = await import('@app/core/utils/misc/parse-config');
const { paths } = store.getState();
const filePath = join(paths.states, 'devs.ini');
const stateFile = parseConfig<DevicesIni>({
filePath,
type: 'ini',
});
expect(parse(stateFile)).toMatchInlineSnapshot('[]');
});

View File

@@ -0,0 +1,83 @@
import { join } from 'path';
import { expect, test } from 'vitest';
import type { NetworkIni } from '@app/store/state-parsers/network';
import { store } from '@app/store';
test('Returns parsed state file', async () => {
const { parse } = await import('@app/store/state-parsers/network');
const { parseConfig } = await import('@app/core/utils/misc/parse-config');
const { paths } = store.getState();
const filePath = join(paths.states, 'network.ini');
const stateFile = parseConfig<NetworkIni>({
filePath,
type: 'ini',
});
expect(parse(stateFile)).toMatchInlineSnapshot(`
[
{
"bonding": true,
"bondingMiimon": "100",
"bondingMode": "1",
"bondname": "",
"bondnics": [
"eth0",
"eth1",
"eth2",
"eth3",
],
"brfd": "0",
"bridging": true,
"brname": "",
"brnics": "bond0",
"brstp": "0",
"description": [
"",
],
"dhcp6Keepresolv": false,
"dhcpKeepresolv": false,
"dnsServer1": "1.1.1.1",
"dnsServer2": "8.8.8.8",
"gateway": [
"192.168.1.1",
],
"gateway6": [
"",
],
"ipaddr": [
"192.168.1.150",
],
"ipaddr6": [
"",
],
"metric": [
"",
],
"metric6": [
"",
],
"mtu": "",
"netmask": [
"255.255.255.0",
],
"netmask6": [
"",
],
"privacy6": [
"",
],
"protocol": [
"",
],
"type": "access",
"useDhcp": [
true,
],
"useDhcp6": [
false,
],
},
]
`);
});

View File

@@ -0,0 +1,205 @@
import { join } from 'path';
import { expect, test } from 'vitest';
import type { NfsSharesIni } from '@app/store/state-parsers/nfs';
import { store } from '@app/store';
test('Returns parsed state file', async () => {
const { parse } = await import('@app/store/state-parsers/nfs');
const { parseConfig } = await import('@app/core/utils/misc/parse-config');
const { paths } = store.getState();
const filePath = join(paths.states, 'sec_nfs.ini');
const stateFile = parseConfig<NfsSharesIni>({
filePath,
type: 'ini',
});
expect(parse(stateFile)).toMatchInlineSnapshot(`
[
{
"enabled": false,
"hostList": "",
"name": "disk1",
"readList": [],
"security": "public",
"writeList": [],
},
{
"enabled": false,
"hostList": "",
"name": "disk2",
"readList": [],
"security": "public",
"writeList": [],
},
{
"enabled": false,
"hostList": "",
"name": "disk3",
"readList": [],
"security": "public",
"writeList": [],
},
{
"enabled": false,
"hostList": "",
"name": "disk4",
"readList": [],
"security": "public",
"writeList": [],
},
{
"enabled": false,
"hostList": "",
"name": "disk5",
"readList": [],
"security": "public",
"writeList": [],
},
{
"enabled": false,
"hostList": "",
"name": "disk6",
"readList": [],
"security": "public",
"writeList": [],
},
{
"enabled": false,
"hostList": "",
"name": "disk7",
"readList": [],
"security": "public",
"writeList": [],
},
{
"enabled": false,
"hostList": "",
"name": "disk8",
"readList": [],
"security": "public",
"writeList": [],
},
{
"enabled": false,
"hostList": "",
"name": "disk9",
"readList": [],
"security": "public",
"writeList": [],
},
{
"enabled": false,
"hostList": "",
"name": "disk10",
"readList": [],
"security": "public",
"writeList": [],
},
{
"enabled": false,
"hostList": "",
"name": "disk11",
"readList": [],
"security": "public",
"writeList": [],
},
{
"enabled": false,
"hostList": "",
"name": "disk12",
"readList": [],
"security": "public",
"writeList": [],
},
{
"enabled": false,
"hostList": "",
"name": "disk13",
"readList": [],
"security": "public",
"writeList": [],
},
{
"enabled": false,
"hostList": "",
"name": "disk14",
"readList": [],
"security": "public",
"writeList": [],
},
{
"enabled": false,
"hostList": "",
"name": "disk15",
"readList": [],
"security": "public",
"writeList": [],
},
{
"enabled": false,
"hostList": "",
"name": "disk16",
"readList": [],
"security": "public",
"writeList": [],
},
{
"enabled": false,
"hostList": "",
"name": "disk17",
"readList": [],
"security": "public",
"writeList": [],
},
{
"enabled": false,
"hostList": "",
"name": "disk18",
"readList": [],
"security": "public",
"writeList": [],
},
{
"enabled": false,
"hostList": "",
"name": "disk19",
"readList": [],
"security": "public",
"writeList": [],
},
{
"enabled": false,
"hostList": "",
"name": "disk20",
"readList": [],
"security": "public",
"writeList": [],
},
{
"enabled": false,
"hostList": "",
"name": "disk21",
"readList": [],
"security": "public",
"writeList": [],
},
{
"enabled": false,
"hostList": "",
"name": "disk22",
"readList": [],
"security": "public",
"writeList": [],
},
{
"enabled": false,
"hostList": "",
"name": "abc",
"readList": [],
"security": "public",
"writeList": [],
},
]
`);
});

View File

@@ -1,16 +1,18 @@
import { join } from 'path';
import { expect, test } from 'vitest';
import { store } from '@app/store';
import type { NginxIni } from '@app/store/state-parsers/nginx';
import { store } from '@app/store';
test('Returns parsed state file', async () => {
const { parse } = await import('@app/store/state-parsers/nginx');
const { parseConfig } = await import('@app/core/utils/misc/parse-config');
const { paths } = store.getState();
const filePath = join(paths.states, 'nginx.ini');
const stateFile = parseConfig<NginxIni>({
filePath,
type: 'ini',
});
expect(parse(stateFile)).toMatchSnapshot();
});
const { parse } = await import('@app/store/state-parsers/nginx');
const { parseConfig } = await import('@app/core/utils/misc/parse-config');
const { paths } = store.getState();
const filePath = join(paths.states, 'nginx.ini');
const stateFile = parseConfig<NginxIni>({
filePath,
type: 'ini',
});
expect(parse(stateFile)).toMatchSnapshot();
});

View File

@@ -1,18 +1,20 @@
import { join } from 'path';
import { expect, test } from 'vitest';
import { store } from '@app/store';
import type { SharesIni } from '@app/store/state-parsers/shares';
import { store } from '@app/store';
test('Returns parsed state file', async () => {
const { parse } = await import('@app/store/state-parsers/shares');
const { parseConfig } = await import('@app/core/utils/misc/parse-config');
const { paths } = store.getState();
const filePath = join(paths.states, 'shares.ini');
const stateFile = parseConfig<SharesIni>({
filePath,
type: 'ini',
});
expect(parse(stateFile)).toMatchInlineSnapshot(`
const { parse } = await import('@app/store/state-parsers/shares');
const { parseConfig } = await import('@app/core/utils/misc/parse-config');
const { paths } = store.getState();
const filePath = join(paths.states, 'shares.ini');
const stateFile = parseConfig<SharesIni>({
filePath,
type: 'ini',
});
expect(parse(stateFile)).toMatchInlineSnapshot(`
[
{
"allocator": "highwater",

View File

@@ -1,18 +1,20 @@
import { join } from 'path';
import { expect, test } from 'vitest';
import { store } from '@app/store';
import type { SlotsIni } from '@app/store/state-parsers/slots';
import { store } from '@app/store';
test('Returns parsed state file', async () => {
const { parse } = await import('@app/store/state-parsers/slots');
const { parseConfig } = await import('@app/core/utils/misc/parse-config');
const { paths } = store.getState();
const filePath = join(paths.states, 'disks.ini');
const stateFile = parseConfig<SlotsIni>({
filePath,
type: 'ini',
});
expect(parse(stateFile)).toMatchInlineSnapshot(`
const { parse } = await import('@app/store/state-parsers/slots');
const { parseConfig } = await import('@app/core/utils/misc/parse-config');
const { paths } = store.getState();
const filePath = join(paths.states, 'disks.ini');
const stateFile = parseConfig<SlotsIni>({
filePath,
type: 'ini',
});
expect(parse(stateFile)).toMatchInlineSnapshot(`
[
{
"comment": null,

View File

@@ -0,0 +1,308 @@
import { join } from 'path';
import { expect, test } from 'vitest';
import type { SmbIni } from '@app/store/state-parsers/smb';
import { store } from '@app/store';
test('Returns parsed state file', async () => {
const { parse } = await import('@app/store/state-parsers/smb');
const { parseConfig } = await import('@app/core/utils/misc/parse-config');
const { paths } = store.getState();
const filePath = join(paths.states, 'sec.ini');
const stateFile = parseConfig<SmbIni>({
filePath,
type: 'ini',
});
expect(parse(stateFile)).toMatchInlineSnapshot(`
[
{
"caseSensitive": "auto",
"enabled": true,
"fruit": "no",
"name": "disk1",
"readList": [],
"security": "public",
"timemachine": {
"volsizelimit": NaN,
},
"writeList": [],
},
{
"caseSensitive": "auto",
"enabled": true,
"fruit": "no",
"name": "disk2",
"readList": [],
"security": "public",
"timemachine": {
"volsizelimit": NaN,
},
"writeList": [],
},
{
"caseSensitive": "auto",
"enabled": true,
"fruit": "no",
"name": "disk3",
"readList": [],
"security": "public",
"timemachine": {
"volsizelimit": NaN,
},
"writeList": [],
},
{
"caseSensitive": "auto",
"enabled": true,
"fruit": "no",
"name": "disk4",
"readList": [],
"security": "public",
"timemachine": {
"volsizelimit": NaN,
},
"writeList": [],
},
{
"caseSensitive": "auto",
"enabled": true,
"fruit": "no",
"name": "disk5",
"readList": [],
"security": "public",
"timemachine": {
"volsizelimit": NaN,
},
"writeList": [],
},
{
"caseSensitive": "auto",
"enabled": true,
"fruit": "no",
"name": "disk6",
"readList": [],
"security": "public",
"timemachine": {
"volsizelimit": NaN,
},
"writeList": [],
},
{
"caseSensitive": "auto",
"enabled": true,
"fruit": "no",
"name": "disk7",
"readList": [],
"security": "public",
"timemachine": {
"volsizelimit": NaN,
},
"writeList": [],
},
{
"caseSensitive": "auto",
"enabled": true,
"fruit": "no",
"name": "disk8",
"readList": [],
"security": "public",
"timemachine": {
"volsizelimit": NaN,
},
"writeList": [],
},
{
"caseSensitive": "auto",
"enabled": true,
"fruit": "no",
"name": "disk9",
"readList": [],
"security": "public",
"timemachine": {
"volsizelimit": NaN,
},
"writeList": [],
},
{
"caseSensitive": "auto",
"enabled": true,
"fruit": "no",
"name": "disk10",
"readList": [],
"security": "public",
"timemachine": {
"volsizelimit": NaN,
},
"writeList": [],
},
{
"caseSensitive": "auto",
"enabled": true,
"fruit": "no",
"name": "disk11",
"readList": [],
"security": "public",
"timemachine": {
"volsizelimit": NaN,
},
"writeList": [],
},
{
"caseSensitive": "auto",
"enabled": true,
"fruit": "no",
"name": "disk12",
"readList": [],
"security": "public",
"timemachine": {
"volsizelimit": NaN,
},
"writeList": [],
},
{
"caseSensitive": "auto",
"enabled": true,
"fruit": "no",
"name": "disk13",
"readList": [],
"security": "public",
"timemachine": {
"volsizelimit": NaN,
},
"writeList": [],
},
{
"caseSensitive": "auto",
"enabled": true,
"fruit": "no",
"name": "disk14",
"readList": [],
"security": "public",
"timemachine": {
"volsizelimit": NaN,
},
"writeList": [],
},
{
"caseSensitive": "auto",
"enabled": true,
"fruit": "no",
"name": "disk15",
"readList": [],
"security": "public",
"timemachine": {
"volsizelimit": NaN,
},
"writeList": [],
},
{
"caseSensitive": "auto",
"enabled": true,
"fruit": "no",
"name": "disk16",
"readList": [],
"security": "public",
"timemachine": {
"volsizelimit": NaN,
},
"writeList": [],
},
{
"caseSensitive": "auto",
"enabled": true,
"fruit": "no",
"name": "disk17",
"readList": [],
"security": "public",
"timemachine": {
"volsizelimit": NaN,
},
"writeList": [],
},
{
"caseSensitive": "auto",
"enabled": true,
"fruit": "no",
"name": "disk18",
"readList": [],
"security": "public",
"timemachine": {
"volsizelimit": NaN,
},
"writeList": [],
},
{
"caseSensitive": "auto",
"enabled": true,
"fruit": "no",
"name": "disk19",
"readList": [],
"security": "public",
"timemachine": {
"volsizelimit": NaN,
},
"writeList": [],
},
{
"caseSensitive": "auto",
"enabled": true,
"fruit": "no",
"name": "disk20",
"readList": [],
"security": "public",
"timemachine": {
"volsizelimit": NaN,
},
"writeList": [],
},
{
"caseSensitive": "auto",
"enabled": true,
"fruit": "no",
"name": "disk21",
"readList": [],
"security": "public",
"timemachine": {
"volsizelimit": NaN,
},
"writeList": [],
},
{
"caseSensitive": "auto",
"enabled": true,
"fruit": "no",
"name": "disk22",
"readList": [],
"security": "public",
"timemachine": {
"volsizelimit": NaN,
},
"writeList": [],
},
{
"caseSensitive": "auto",
"enabled": true,
"fruit": "no",
"name": "abc",
"readList": [],
"security": "public",
"timemachine": {
"volsizelimit": NaN,
},
"writeList": [],
},
{
"enabled": true,
"fruit": "no",
"name": "flash",
"readList": [],
"security": "public",
"timemachine": {
"volsizelimit": NaN,
},
"writeList": [],
},
]
`);
});

View File

@@ -0,0 +1,42 @@
import { join } from 'path';
import { expect, test } from 'vitest';
import type { UsersIni } from '@app/store/state-parsers/users';
import { store } from '@app/store';
test('Returns parsed state file', async () => {
const { parse } = await import('@app/store/state-parsers/users');
const { parseConfig } = await import('@app/core/utils/misc/parse-config');
const { paths } = store.getState();
const filePath = join(paths.states, 'users.ini');
const stateFile = parseConfig<UsersIni>({
filePath,
type: 'ini',
});
expect(parse(stateFile)).toMatchInlineSnapshot(`
[
{
"description": "Console and webGui login account",
"id": "0",
"name": "root",
"password": true,
"role": "admin",
},
{
"description": "",
"id": "1",
"name": "xo",
"password": true,
"role": "user",
},
{
"description": "",
"id": "2",
"name": "test_user",
"password": false,
"role": "user",
},
]
`);
});

View File

@@ -1,19 +1,21 @@
import { join } from 'path';
import { expect, test } from 'vitest';
import { store } from '@app/store';
import type { VarIni } from '@app/store/state-parsers/var';
import { store } from '@app/store';
test('Returns parsed state file', async () => {
const { parse } = await import('@app/store/state-parsers/var');
const { parseConfig } = await import('@app/core/utils/misc/parse-config');
const { paths } = store.getState();
const filePath = join(paths.states, 'var.ini');
const stateFile = parseConfig<VarIni>({
filePath,
type: 'ini',
});
const { parse } = await import('@app/store/state-parsers/var');
const { parseConfig } = await import('@app/core/utils/misc/parse-config');
const { paths } = store.getState();
const filePath = join(paths.states, 'var.ini');
const stateFile = parseConfig<VarIni>({
filePath,
type: 'ini',
});
expect(parse(stateFile)).toMatchInlineSnapshot(`
expect(parse(stateFile)).toMatchInlineSnapshot(`
{
"bindMgt": false,
"cacheNumDevices": NaN,

View File

@@ -0,0 +1,30 @@
import { expect, test, vi } from 'vitest';
vi.mock('@app/core/pubsub', () => ({
pubsub: { publish: vi.fn() },
}));
test('Creates a registration event', async () => {
const { createRegistrationEvent } = await import('@app/store/sync/registration-sync');
const { store } = await import('@app/store');
const { loadStateFiles } = await import('@app/store/modules/emhttp');
// Load state files into store
await store.dispatch(loadStateFiles());
const state = store.getState();
const registrationEvent = createRegistrationEvent(state);
expect(registrationEvent).toMatchInlineSnapshot(`
{
"registration": {
"guid": "13FE-4200-C300-58C372A52B19",
"keyFile": {
"contents": null,
"location": "/app/dev/Unraid.net/Pro.key",
},
"state": "PRO",
"type": "PRO",
},
}
`);
});

View File

@@ -0,0 +1,20 @@
import { type Mapping } from '@runonflux/nat-upnp';
import { expect, test, vi } from 'vitest';
import { getWanPortForUpnp } from '@app/upnp/helpers';
test('it successfully gets a wan port given no exclusions', () => {
const port = getWanPortForUpnp(null, 36_000, 38_000);
expect(port).toBeGreaterThan(35_999);
expect(port).toBeLessThan(38_001);
});
test('it fails to get a wan port given exclusions', () => {
const port = getWanPortForUpnp([{ public: { port: 36_000 } }] as Mapping[], 36_000, 36_000);
expect(port).toBeNull();
});
test('it succeeds in getting a wan port given exclusions', () => {
const port = getWanPortForUpnp([{ public: { port: 36_000 } }] as Mapping[], 30_000, 36_000);
expect(port).not.toBeNull();
});

View File

@@ -1,12 +1,11 @@
import { getters, type RootState, store } from '@app/store';
import uniq from 'lodash/uniq';
import {
getServerIps,
getUrlForField,
} from '@app/graphql/resolvers/subscription/network';
import { FileLoadStatus } from '@app/store/types';
import { logger } from '../core';
import type { RootState } from '@app/store';
import { logger } from '@app/core';
import { GRAPHQL_INTROSPECTION } from '@app/environment';
import { getServerIps, getUrlForField } from '@app/graphql/resolvers/subscription/network';
import { getters, store } from '@app/store';
import { FileLoadStatus } from '@app/store/types';
const getAllowedSocks = (): string[] => [
// Notifier bridge
@@ -19,9 +18,7 @@ const getAllowedSocks = (): string[] => [
'/var/run/unraid-cli.sock',
];
const getLocalAccessUrlsForServer = (
state: RootState = store.getState()
): string[] => {
const getLocalAccessUrlsForServer = (state: RootState = store.getState()): string[] => {
const { emhttp } = state;
if (emhttp.status !== FileLoadStatus.LOADED) {
return [];
@@ -40,22 +37,17 @@ const getLocalAccessUrlsForServer = (
}).toString(),
];
} catch (error: unknown) {
logger.debug(
'Caught error in getLocalAccessUrlsForServer: \n%o',
error
);
logger.debug('Caught error in getLocalAccessUrlsForServer: \n%o', error);
return [];
}
};
const getRemoteAccessUrlsForAllowedOrigins = (
state: RootState = store.getState()
): string[] => {
const getRemoteAccessUrlsForAllowedOrigins = (state: RootState = store.getState()): string[] => {
const { urls } = getServerIps(state);
if (urls) {
return urls.reduce<string[]>((acc, curr) => {
if (curr.ipv4 && curr.ipv6 || curr.ipv4) {
if ((curr.ipv4 && curr.ipv6) || curr.ipv4) {
acc.push(curr.ipv4.toString());
} else if (curr.ipv6) {
acc.push(curr.ipv6.toString());
@@ -74,11 +66,7 @@ export const getExtraOrigins = (): string[] => {
return extraOrigins
.replaceAll(' ', '')
.split(',')
.filter(
(origin) =>
origin.startsWith('http://') ||
origin.startsWith('https://')
);
.filter((origin) => origin.startsWith('http://') || origin.startsWith('https://'));
}
return [];
@@ -99,9 +87,7 @@ const getApolloSandbox = (): string[] => {
return [];
};
export const getAllowedOrigins = (
state: RootState = store.getState()
): string[] =>
export const getAllowedOrigins = (state: RootState = store.getState()): string[] =>
uniq([
...getAllowedSocks(),
...getLocalAccessUrlsForServer(),

View File

@@ -1,4 +1,4 @@
import { uptime } from 'os';
// Get uptime on boot and convert to date
export const bootTimestamp = new Date(new Date().getTime() - (uptime() * 1_000));
export const bootTimestamp = new Date(new Date().getTime() - uptime() * 1_000);

View File

@@ -0,0 +1,15 @@
import { getters } from '@app/store';
import { FileLoadStatus } from '@app/store/types';
/**
* Unraid version string.
* @returns The current version.
*/
export const getUnraidVersion = async (): Promise<string> => {
const { status, var: emhttpVar } = getters.emhttp();
if (status === FileLoadStatus.LOADED) {
return emhttpVar.version;
}
return 'unknown';
};

View File

@@ -0,0 +1,9 @@
import { satisfies } from 'semver';
import { getters } from '@app/store';
/**
* Compare version against the current unraid version.
*/
export const compareUnraidVersion = (range: string) =>
satisfies(getters.emhttp().var.version, range, { includePrerelease: true });

View File

@@ -1,7 +1,9 @@
import { PORT } from '@app/environment';
import type { JSONWebKeySet } from 'jose';
import { join } from 'path';
import type { JSONWebKeySet } from 'jose';
import { PORT } from '@app/environment';
export const getInternalApiAddress = (isHttp = true, nginxPort = 80) => {
const envPort = PORT;
const protocol = isHttp ? 'http' : 'ws';
@@ -18,7 +20,6 @@ export const getInternalApiAddress = (isHttp = true, nginxPort = 80) => {
// Prod mode (user didn't change webgui port)
return `${protocol}://127.0.0.1/graphql`;
};
// Milliseconds
@@ -69,18 +70,15 @@ export const JWKS_LOCAL_PAYLOAD: JSONWebKeySet = {
],
};
export const OAUTH_BASE_URL =
'https://cognito-idp.us-west-2.amazonaws.com/us-west-2_btSkhlsEk';
export const OAUTH_BASE_URL = 'https://cognito-idp.us-west-2.amazonaws.com/us-west-2_btSkhlsEk';
export const OAUTH_CLIENT_ID = '53ci4o48gac8vq5jepubkjmo36';
export const OAUTH_OPENID_CONFIGURATION_URL =
OAUTH_BASE_URL + '/.well-known/openid-configuration';
export const OAUTH_OPENID_CONFIGURATION_URL = OAUTH_BASE_URL + '/.well-known/openid-configuration';
export const JWKS_REMOTE_LINK = OAUTH_BASE_URL + '/.well-known/jwks.json';
export const RCD_SCRIPT = 'rc.unraid-api';
export const KEYSERVER_VALIDATION_ENDPOINT =
'https://keys.lime-technology.com/validate/apikey';
export const KEYSERVER_VALIDATION_ENDPOINT = 'https://keys.lime-technology.com/validate/apikey';
/** 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 ECOSYSTEM_PATH = join(import.meta.dirname, '../../', 'ecosystem.config.json');
export const ECOSYSTEM_PATH = join(import.meta.dirname, '../../', 'ecosystem.config.json');

View File

@@ -1,11 +1,10 @@
import { AppError } from '@app/core/errors/app-error';
/**
* API key error.
*/
* API key error.
*/
export class ApiKeyError extends AppError {
constructor(message: string) {
super(message);
}
constructor(message: string) {
super(message);
}
}

View File

@@ -2,36 +2,36 @@
* Generic application error.
*/
export class AppError extends Error {
/** The HTTP status associated with this error. */
public status: number;
/** The HTTP status associated with this error. */
public status: number;
/** Should we kill the application when thrown. */
public fatal = false;
/** Should we kill the application when thrown. */
public fatal = false;
constructor(message: string, status?: number) {
// Calling parent constructor of base Error class.
super(message);
constructor(message: string, status?: number) {
// Calling parent constructor of base Error class.
super(message);
// Saving class name in the property of our custom error as a shortcut.
this.name = this.constructor.name;
// Saving class name in the property of our custom error as a shortcut.
this.name = this.constructor.name;
// Capturing stack trace, excluding constructor call from it.
Error.captureStackTrace(this, this.constructor);
// Capturing stack trace, excluding constructor call from it.
Error.captureStackTrace(this, this.constructor);
// We're using HTTP status codes with `500` as the default
this.status = status ?? 500;
}
// We're using HTTP status codes with `500` as the default
this.status = status ?? 500;
}
/**
* Convert error to JSON format.
*/
toJSON() {
return {
error: {
name: this.name,
message: this.message,
stacktrace: this.stack,
},
};
}
/**
* Convert error to JSON format.
*/
toJSON() {
return {
error: {
name: this.name,
message: this.message,
stacktrace: this.stack,
},
};
}
}

View File

@@ -4,7 +4,7 @@ import { AppError } from '@app/core/errors/app-error';
* The attempted operation can only be processed while the array is stopped.
*/
export class ArrayRunningError extends AppError {
constructor() {
super('Array needs to be stopped before any changes can occur.');
}
constructor() {
super('Array needs to be stopped before any changes can occur.');
}
}

View File

@@ -4,7 +4,11 @@ import { FatalAppError } from '@app/core/errors/fatal-error';
* Atomic write error
*/
export class AtomicWriteError extends FatalAppError {
constructor(message: string, private readonly filePath: string, status = 500) {
super(message, status);
}
constructor(
message: string,
private readonly filePath: string,
status = 500
) {
super(message, status);
}
}

View File

@@ -4,8 +4,8 @@ import { FatalAppError } from '@app/core/errors/fatal-error';
* Em cmd client error.
*/
export class EmCmdError extends FatalAppError {
constructor(method: string, option: string, options: string[]) {
const message = `Invalid option "${option}" for ${method}, allowed options ${JSON.stringify(options)}`;
super(message);
}
constructor(method: string, option: string, options: string[]) {
const message = `Invalid option "${option}" for ${method}, allowed options ${JSON.stringify(options)}`;
super(message);
}
}

View File

@@ -4,5 +4,5 @@ import { AppError } from '@app/core/errors/app-error';
* Fatal application error.
*/
export class FatalAppError extends AppError {
fatal = true;
fatal = true;
}

View File

@@ -4,8 +4,8 @@ import { AppError } from '@app/core/errors/app-error';
* Module is missing a needed field
*/
export class FieldMissingError extends AppError {
constructor(private readonly field: string) {
// Overriding both message and status code.
super(`Field missing: ${field}`, 400);
}
constructor(private readonly field: string) {
// Overriding both message and status code.
super(`Field missing: ${field}`, 400);
}
}

View File

@@ -4,11 +4,11 @@ import { AppError } from '@app/core/errors/app-error';
* The provided file is missing
*/
export class FileMissingError extends AppError {
/**
* @hideconstructor
*/
constructor(private readonly filePath: string) {
// Overriding both message and status code.
super('File missing: ' + filePath, 400);
}
/**
* @hideconstructor
*/
constructor(private readonly filePath: string) {
// Overriding both message and status code.
super('File missing: ' + filePath, 400);
}
}

View File

@@ -5,7 +5,7 @@ import { AppError } from '@app/core/errors/app-error';
* Sorry about that. 😔
*/
export class NotImplementedError extends AppError {
constructor() {
super('Not implemented!');
}
constructor() {
super('Not implemented!');
}
}

View File

@@ -1,12 +1,13 @@
import { format } from 'util';
import { AppError } from '@app/core/errors/app-error';
/**
* Invalid param provided to module
*/
export class ParamInvalidError extends AppError {
constructor(parameterName: string, parameter: any) {
// Overriding both message and status code.
super(format('Param invalid: %s = %s', parameterName, parameter), 500);
}
constructor(parameterName: string, parameter: any) {
// Overriding both message and status code.
super(format('Param invalid: %s = %s', parameterName, parameter), 500);
}
}

View File

@@ -4,8 +4,8 @@ import { AppError } from '@app/core/errors/app-error';
* Required param is missing
*/
export class ParameterMissingError extends AppError {
constructor(parameterName: string) {
// Override both message and status code.
super(`Param missing: ${parameterName}`, 500);
}
constructor(parameterName: string) {
// Override both message and status code.
super(`Param missing: ${parameterName}`, 500);
}
}

View File

@@ -4,7 +4,7 @@ import { AppError } from '@app/core/errors/app-error';
* Non fatal permission error
*/
export class PermissionError extends AppError {
constructor(message: string) {
super(message || 'Permission denied!');
}
constructor(message: string) {
super(message || 'Permission denied!');
}
}

View File

@@ -1,25 +1,14 @@
import { pino } from 'pino';
import { LOG_TYPE } from '@app/environment';
import pretty from 'pino-pretty';
export const levels = [
'trace',
'debug',
'info',
'warn',
'error',
'fatal',
] as const;
import { LOG_TYPE } from '@app/environment';
export type LogLevel = typeof levels[number];
export const levels = ['trace', 'debug', 'info', 'warn', 'error', 'fatal'] as const;
export type LogLevel = (typeof levels)[number];
const level =
levels[
levels.indexOf(
process.env.LOG_LEVEL?.toLowerCase() as (typeof levels)[number]
)
] ?? 'info';
levels[levels.indexOf(process.env.LOG_LEVEL?.toLowerCase() as (typeof levels)[number])] ?? 'info';
export const logDestination = pino.destination({
sync: true,

View File

@@ -5,34 +5,34 @@ import { ensurePermission } from '@app/core/utils/permissions/ensure-permission'
import { getters } from '@app/store';
export const addShare = async (context: CoreContext<unknown, { name: string }>): Promise<CoreResult> => {
const { user, data } = context;
const { user, data } = context;
if (!data?.name) {
throw new AppError('No name provided');
}
if (!data?.name) {
throw new AppError('No name provided');
}
// Check permissions
ensurePermission(user, {
resource: 'share',
action: 'create',
possession: 'any',
});
// Check permissions
ensurePermission(user, {
resource: 'share',
action: 'create',
possession: 'any',
});
const { shares, disks } = getters.emhttp();
const { shares, disks } = getters.emhttp();
const { name } = data;
const userShares = shares.map(({ name }) => name);
const diskShares = disks.filter(slot => slot.exportable).filter(({ name }) => name.startsWith('disk')).map(({ name }) => name);
const { name } = data;
const userShares = shares.map(({ name }) => name);
const diskShares = disks
.filter((slot) => slot.exportable)
.filter(({ name }) => name.startsWith('disk'))
.map(({ name }) => name);
// Existing share names
const inUseNames = new Set([
...userShares,
...diskShares,
]);
// Existing share names
const inUseNames = new Set([...userShares, ...diskShares]);
if (inUseNames.has(name)) {
throw new AppError(`Share already exists with name: ${name}`, 400);
}
if (inUseNames.has(name)) {
throw new AppError(`Share already exists with name: ${name}`, 400);
}
throw new NotImplementedError();
throw new NotImplementedError();
};

View File

@@ -1,83 +1,83 @@
import type { CoreContext, CoreResult } from '@app/core/types';
import { AppError } from '@app/core/errors/app-error';
import { FieldMissingError } from '@app/core/errors/field-missing-error';
import { pubsub } from '@app/core/pubsub';
import { emcmd } from '@app/core/utils/clients/emcmd';
import { ensurePermission } from '@app/core/utils/permissions/ensure-permission';
import { hasFields } from '@app/core/utils/validation/has-fields';
import { FieldMissingError } from '@app/core/errors/field-missing-error';
import { emcmd } from '@app/core/utils/clients/emcmd';
import { getters } from '@app/store';
import { pubsub } from '@app/core/pubsub';
interface Context extends CoreContext {
readonly data: {
/** Display name. */
readonly name: string;
/** User's password. */
readonly password: string;
/** Friendly description. */
readonly description: string;
};
readonly data: {
/** Display name. */
readonly name: string;
/** User's password. */
readonly password: string;
/** Friendly description. */
readonly description: string;
};
}
/**
* Add user account.
*/
export const addUser = async (context: Context): Promise<CoreResult> => {
const { data } = context;
// Check permissions
ensurePermission(context.user, {
resource: 'user',
action: 'create',
possession: 'any',
});
const { data } = context;
// Check permissions
ensurePermission(context.user, {
resource: 'user',
action: 'create',
possession: 'any',
});
// Validation
const { name, description = '', password } = data;
const missingFields = hasFields(data, ['name', 'password']);
// Validation
const { name, description = '', password } = data;
const missingFields = hasFields(data, ['name', 'password']);
if (missingFields.length !== 0) {
// Only log first error.
throw new FieldMissingError(missingFields[0]);
}
if (missingFields.length !== 0) {
// Only log first error.
throw new FieldMissingError(missingFields[0]);
}
// Check user name isn't taken
if (getters.emhttp().users.find(user => user.name === name)) {
throw new AppError('A user account with that name already exists.');
}
// Check user name isn't taken
if (getters.emhttp().users.find((user) => user.name === name)) {
throw new AppError('A user account with that name already exists.');
}
// Create user
await emcmd({
userName: name,
userDesc: description,
userPassword: password,
userPasswordConf: password,
cmdUserEdit: 'Add',
});
// Create user
await emcmd({
userName: name,
userDesc: description,
userPassword: password,
userPasswordConf: password,
cmdUserEdit: 'Add',
});
// Get fresh copy of Users with the new user
const user = getters.emhttp().users.find(user => user.name === name);
if (!user) {
// User managed to disappear between us creating it and the lookup?
throw new AppError('Internal Server Error!');
}
// Get fresh copy of Users with the new user
const user = getters.emhttp().users.find((user) => user.name === name);
if (!user) {
// User managed to disappear between us creating it and the lookup?
throw new AppError('Internal Server Error!');
}
// Update users channel with new user
pubsub.publish('users', {
users: {
mutation: 'CREATED',
node: [user],
},
});
// Update users channel with new user
pubsub.publish('users', {
users: {
mutation: 'CREATED',
node: [user],
},
});
// Update user channel with new user
pubsub.publish('user', {
user: {
mutation: 'CREATED',
node: user,
},
});
// Update user channel with new user
pubsub.publish('user', {
user: {
mutation: 'CREATED',
node: user,
},
});
return {
text: `User created successfully. ${JSON.stringify(user, null, 2)}`,
json: user,
};
return {
text: `User created successfully. ${JSON.stringify(user, null, 2)}`,
json: user,
};
};

View File

@@ -1,49 +1,49 @@
import { type CoreContext, type CoreResult } from '@app/core/types';
import { ArrayRunningError } from '@app/core/errors/array-running-error';
import { FieldMissingError } from '@app/core/errors/field-missing-error';
import { ensurePermission } from '@app/core/utils/permissions/ensure-permission';
import { hasFields } from '@app/core/utils/validation/has-fields';
import { getArrayData } from '@app/core/modules/array/get-array-data';
import { type CoreContext, type CoreResult } from '@app/core/types';
import { arrayIsRunning } from '@app/core/utils/array/array-is-running';
import { emcmd } from '@app/core/utils/clients/emcmd';
import { ArrayRunningError } from '@app/core/errors/array-running-error';
import { getArrayData } from '@app/core/modules/array/get-array-data';
import { ensurePermission } from '@app/core/utils/permissions/ensure-permission';
import { hasFields } from '@app/core/utils/validation/has-fields';
/**
* Add a disk to the array.
*/
export const addDiskToArray = async function (context: CoreContext): Promise<CoreResult> {
const { data = {}, user } = context;
const { data = {}, user } = context;
// Check permissions
ensurePermission(user, {
resource: 'array',
action: 'create',
possession: 'any',
});
// Check permissions
ensurePermission(user, {
resource: 'array',
action: 'create',
possession: 'any',
});
const missingFields = hasFields(data, ['id']);
if (missingFields.length !== 0) {
// Just log first error
throw new FieldMissingError(missingFields[0]);
}
const missingFields = hasFields(data, ['id']);
if (missingFields.length !== 0) {
// Just log first error
throw new FieldMissingError(missingFields[0]);
}
if (arrayIsRunning()) {
throw new ArrayRunningError();
}
if (arrayIsRunning()) {
throw new ArrayRunningError();
}
const { id: diskId, slot: preferredSlot } = data;
const slot = Number.parseInt(preferredSlot as string, 10);
const { id: diskId, slot: preferredSlot } = data;
const slot = Number.parseInt(preferredSlot as string, 10);
// Add disk
await emcmd({
changeDevice: 'apply',
[`slotId.${slot}`]: diskId,
});
// Add disk
await emcmd({
changeDevice: 'apply',
[`slotId.${slot}`]: diskId,
});
const array = getArrayData()
const array = getArrayData();
// Disk added successfully
return {
text: `Disk was added to the array in slot ${slot}.`,
json: array,
};
// Disk added successfully
return {
text: `Disk was added to the array in slot ${slot}.`,
json: array,
};
};

View File

@@ -1,14 +1,12 @@
import { getServerIdentifier } from '@app/core/utils/server-identifier';
import {
ArrayDiskType,
type ArrayCapacity,
type ArrayType,
} from '@app/graphql/generated/api/types';
import { store } from '@app/store/index';
import { FileLoadStatus } from '@app/store/types';
import { GraphQLError } from 'graphql';
import sum from 'lodash/sum';
import type { ArrayCapacity, ArrayType } from '@app/graphql/generated/api/types';
import { getServerIdentifier } from '@app/core/utils/server-identifier';
import { ArrayDiskType } from '@app/graphql/generated/api/types';
import { store } from '@app/store/index';
import { FileLoadStatus } from '@app/store/types';
export const getArrayData = (getState = store.getState): ArrayType => {
// Var state isn't loaded
const state = getState();
@@ -17,9 +15,7 @@ export const getArrayData = (getState = store.getState): ArrayType => {
state.emhttp.status !== FileLoadStatus.LOADED ||
Object.keys(state.emhttp.var).length === 0
) {
throw new GraphQLError(
'Attempt to get Array Data, but state was not loaded'
);
throw new GraphQLError('Attempt to get Array Data, but state was not loaded');
}
const { emhttp } = state;
@@ -29,9 +25,7 @@ export const getArrayData = (getState = store.getState): ArrayType => {
// Array boot/parities/disks/caches
const boot = allDisks.find((disk) => disk.type === ArrayDiskType.FLASH);
const parities = allDisks.filter(
(disk) => disk.type === ArrayDiskType.PARITY
);
const parities = allDisks.filter((disk) => disk.type === ArrayDiskType.PARITY);
const disks = allDisks.filter((disk) => disk.type === ArrayDiskType.DATA);
const caches = allDisks.filter((disk) => disk.type === ArrayDiskType.CACHE);
// Disk sizes

View File

@@ -1,16 +1,16 @@
import { type CoreContext, type CoreResult } from '@app/core/types';
import { ArrayRunningError } from '@app/core/errors/array-running-error';
import { FieldMissingError } from '@app/core/errors/field-missing-error';
import { getArrayData } from '@app/core/modules/array/get-array-data';
import { type CoreContext, type CoreResult } from '@app/core/types';
import { arrayIsRunning } from '@app/core/utils/array/array-is-running';
import { ensurePermission } from '@app/core/utils/permissions/ensure-permission';
import { hasFields } from '@app/core/utils/validation/has-fields';
import { arrayIsRunning } from '@app/core/utils/array/array-is-running';
import { ArrayRunningError } from '@app/core/errors/array-running-error';
import { getArrayData } from '@app/core/modules/array/get-array-data';
interface Context extends CoreContext {
data: {
/** The slot the disk is in. */
slot: string;
};
data: {
/** The slot the disk is in. */
slot: string;
};
}
/**
@@ -18,37 +18,37 @@ interface Context extends CoreContext {
* @returns The updated array.
*/
export const removeDiskFromArray = async (context: Context): Promise<CoreResult> => {
const { data, user } = context;
const { data, user } = context;
// Check permissions
ensurePermission(user, {
resource: 'array',
action: 'create',
possession: 'any',
});
// Check permissions
ensurePermission(user, {
resource: 'array',
action: 'create',
possession: 'any',
});
const missingFields = hasFields(data, ['id']);
const missingFields = hasFields(data, ['id']);
if (missingFields.length !== 0) {
// Only log first error
throw new FieldMissingError(missingFields[0]);
}
if (missingFields.length !== 0) {
// Only log first error
throw new FieldMissingError(missingFields[0]);
}
if (arrayIsRunning()) {
throw new ArrayRunningError();
}
if (arrayIsRunning()) {
throw new ArrayRunningError();
}
const { slot } = data;
const { slot } = data;
// Error removing disk
// if () {
// }
// Error removing disk
// if () {
// }
const array = getArrayData()
const array = getArrayData();
// Disk removed successfully
return {
text: `Disk was removed from the array in slot ${slot}.`,
json: array,
};
// Disk removed successfully
return {
text: `Disk was removed from the array in slot ${slot}.`,
json: array,
};
};

View File

@@ -1,13 +1,13 @@
import type { CoreContext, CoreResult } from '@app/core/types';
import { AppError } from '@app/core/errors/app-error';
import { FieldMissingError } from '@app/core/errors/field-missing-error';
import { ParamInvalidError } from '@app/core/errors/param-invalid-error';
import { getArrayData } from '@app/core/modules/array/get-array-data';
import { arrayIsRunning } from '@app/core/utils/array/array-is-running';
import { emcmd } from '@app/core/utils/clients/emcmd';
import { uppercaseFirstChar } from '@app/core/utils/misc/uppercase-first-char';
import { ensurePermission } from '@app/core/utils/permissions/ensure-permission';
import { hasFields } from '@app/core/utils/validation/has-fields';
import { arrayIsRunning } from '@app/core/utils/array/array-is-running';
import { emcmd } from '@app/core/utils/clients/emcmd';
import { FieldMissingError } from '@app/core/errors/field-missing-error';
import { ParamInvalidError } from '@app/core/errors/param-invalid-error';
import { AppError } from '@app/core/errors/app-error';
import { getArrayData } from '@app/core/modules/array/get-array-data';
// @TODO: Fix this not working across node apps
// each app has it's own lock since the var is scoped
@@ -15,57 +15,57 @@ import { getArrayData } from '@app/core/modules/array/get-array-data';
let locked = false;
export const updateArray = async (context: CoreContext): Promise<CoreResult> => {
const { data = {}, user } = context;
const { data = {}, user } = context;
// Check permissions
ensurePermission(user, {
resource: 'array',
action: 'update',
possession: 'any',
});
// Check permissions
ensurePermission(user, {
resource: 'array',
action: 'update',
possession: 'any',
});
const missingFields = hasFields(data, ['state']);
const missingFields = hasFields(data, ['state']);
if (missingFields.length !== 0) {
// Only log first error
throw new FieldMissingError(missingFields[0]);
}
if (missingFields.length !== 0) {
// Only log first error
throw new FieldMissingError(missingFields[0]);
}
const { state: nextState } = data;
const startState = arrayIsRunning() ? 'started' : 'stopped';
const pendingState = nextState === 'stop' ? 'stopping' : 'starting';
const { state: nextState } = data;
const startState = arrayIsRunning() ? 'started' : 'stopped';
const pendingState = nextState === 'stop' ? 'stopping' : 'starting';
if (!['start', 'stop'].includes(nextState)) {
throw new ParamInvalidError('state', nextState);
}
if (!['start', 'stop'].includes(nextState)) {
throw new ParamInvalidError('state', nextState);
}
// Prevent this running multiple times at once
if (locked) {
throw new AppError('Array state is still being updated.');
}
// Prevent this running multiple times at once
if (locked) {
throw new AppError('Array state is still being updated.');
}
// Prevent starting/stopping array when it's already in the same state
if ((arrayIsRunning() && nextState === 'start') || (!arrayIsRunning() && nextState === 'stop')) {
throw new AppError(`The array is already ${startState}`);
}
// Prevent starting/stopping array when it's already in the same state
if ((arrayIsRunning() && nextState === 'start') || (!arrayIsRunning() && nextState === 'stop')) {
throw new AppError(`The array is already ${startState}`);
}
// Set lock then start/stop array
locked = true;
const command = {
[`cmd${uppercaseFirstChar(nextState)}`]: uppercaseFirstChar(nextState),
startState: startState.toUpperCase(),
};
// Set lock then start/stop array
locked = true;
const command = {
[`cmd${uppercaseFirstChar(nextState)}`]: uppercaseFirstChar(nextState),
startState: startState.toUpperCase(),
};
// `await` has to be used otherwise the catch
// will finish after the return statement below
await emcmd(command).finally(() => {
locked = false;
});
// `await` has to be used otherwise the catch
// will finish after the return statement below
await emcmd(command).finally(() => {
locked = false;
});
// Get new array JSON
const array = getArrayData()
// Get new array JSON
const array = getArrayData();
/**
/**
* Update array details
*
* @memberof Core
@@ -76,13 +76,13 @@ export const updateArray = async (context: CoreContext): Promise<CoreResult> =>
* @param {State~User} context.user The current user.
* @returns {Core~Result} The updated array.
*/
return {
text: `Array was ${startState}, ${pendingState}.`,
json: {
...array.json,
state: nextState === 'start' ? 'started' : 'stopped',
previousState: startState,
pendingState,
},
};
return {
text: `Array was ${startState}, ${pendingState}.`,
json: {
...array.json,
state: nextState === 'start' ? 'started' : 'stopped',
previousState: startState,
pendingState,
},
};
};

View File

@@ -1,17 +1,17 @@
import type { CoreContext, CoreResult } from '@app/core/types';
import { ensurePermission } from '@app/core/utils/permissions/ensure-permission';
import { emcmd } from '@app/core/utils/clients/emcmd';
import { FieldMissingError } from '@app/core/errors/field-missing-error';
import { ParamInvalidError } from '@app/core/errors/param-invalid-error';
import { emcmd } from '@app/core/utils/clients/emcmd';
import { ensurePermission } from '@app/core/utils/permissions/ensure-permission';
import { getters } from '@app/store';
type State = 'start' | 'cancel' | 'resume' | 'cancel';
interface Context extends CoreContext {
data: {
state?: State;
correct?: boolean;
};
data: {
state?: State;
correct?: boolean;
};
}
/**
@@ -19,60 +19,60 @@ interface Context extends CoreContext {
* @returns The update array.
*/
export const updateParityCheck = async (context: Context): Promise<CoreResult> => {
const { user, data } = context;
const { user, data } = context;
// Check permissions
ensurePermission(user, {
resource: 'array',
action: 'update',
possession: 'any',
});
// Check permissions
ensurePermission(user, {
resource: 'array',
action: 'update',
possession: 'any',
});
// Validation
if (!data.state) {
throw new FieldMissingError('state');
}
// Validation
if (!data.state) {
throw new FieldMissingError('state');
}
const { state: wantedState } = data;
const emhttp = getters.emhttp();
const running = emhttp.var.mdResync !== 0;
const states = {
pause: {
cmdNoCheck: 'Pause',
},
resume: {
cmdCheck: 'Resume',
},
cancel: {
cmdNoCheck: 'Cancel',
},
start: {
cmdCheck: 'Check',
},
};
const { state: wantedState } = data;
const emhttp = getters.emhttp();
const running = emhttp.var.mdResync !== 0;
const states = {
pause: {
cmdNoCheck: 'Pause',
},
resume: {
cmdCheck: 'Resume',
},
cancel: {
cmdNoCheck: 'Cancel',
},
start: {
cmdCheck: 'Check',
},
};
let allowedStates = Object.keys(states);
let allowedStates = Object.keys(states);
// Only allow starting a check if there isn't already one running
if (running) {
allowedStates = allowedStates.splice(allowedStates.indexOf('start'), 1);
}
// Only allow starting a check if there isn't already one running
if (running) {
allowedStates = allowedStates.splice(allowedStates.indexOf('start'), 1);
}
// Only allow states from states object
if (!allowedStates.includes(wantedState)) {
throw new ParamInvalidError('state', wantedState);
}
// Only allow states from states object
if (!allowedStates.includes(wantedState)) {
throw new ParamInvalidError('state', wantedState);
}
// Should we write correction to the parity during the check
const writeCorrectionsToParity = wantedState === 'start' && data.correct;
// Should we write correction to the parity during the check
const writeCorrectionsToParity = wantedState === 'start' && data.correct;
await emcmd({
startState: 'STARTED',
...states[wantedState],
...(writeCorrectionsToParity ? { optionCorrect: 'correct' } : {}),
});
await emcmd({
startState: 'STARTED',
...states[wantedState],
...(writeCorrectionsToParity ? { optionCorrect: 'correct' } : {}),
});
return {
json: {},
};
return {
json: {},
};
};

View File

@@ -4,7 +4,7 @@ import { type CoreContext, type CoreResult } from '@app/core/types';
* Get internal context object.
*/
export const getContext = (context: CoreContext): CoreResult => ({
text: `Context: ${JSON.stringify(context, null, 2)}`,
json: context,
html: `<h1>Context</h1>\n<pre>${JSON.stringify(context, null, 2)}</pre>`,
text: `Context: ${JSON.stringify(context, null, 2)}`,
json: context,
html: `<h1>Context</h1>\n<pre>${JSON.stringify(context, null, 2)}</pre>`,
});

View File

@@ -1,35 +1,35 @@
import { type CoreContext, type CoreResult } from '@app/core/types';
import { AppError } from '@app/core/errors/app-error';
import { type CoreContext, type CoreResult } from '@app/core/types';
import { ensurePermission } from '@app/core/utils/permissions/ensure-permission';
interface Context extends CoreContext {
params: {
id: string;
};
params: {
id: string;
};
}
/**
* Get a single disk.
*/
export const getDisk = async (context: Context, Disks): Promise<CoreResult> => {
const { params, user } = context;
const { params, user } = context;
// Check permissions
ensurePermission(user, {
resource: 'disk',
action: 'read',
possession: 'any',
});
// Check permissions
ensurePermission(user, {
resource: 'disk',
action: 'read',
possession: 'any',
});
const { id } = params;
const disk = await Disks.findOne({ id });
const { id } = params;
const disk = await Disks.findOne({ id });
if (!disk) {
throw new AppError(`No disk found matching ${id}`, 404);
}
if (!disk) {
throw new AppError(`No disk found matching ${id}`, 404);
}
return {
text: `Disk: ${JSON.stringify(disk, null, 2)}`,
json: disk,
};
return {
text: `Disk: ${JSON.stringify(disk, null, 2)}`,
json: disk,
};
};

View File

@@ -1,17 +1,14 @@
import fs from 'fs';
import camelCaseKeys from 'camelcase-keys';
import { catchHandlers } from '@app/core/utils/misc/catch-handlers';
import { getters, store } from '@app/store';
import { updateDockerState } from '@app/store/modules/docker'
import {
type ContainerPort,
ContainerPortType,
type DockerContainer,
ContainerState,
} from '@app/graphql/generated/api/types';
import camelCaseKeys from 'camelcase-keys';
import type { ContainerPort, DockerContainer } from '@app/graphql/generated/api/types';
import { dockerLogger } from '@app/core/log';
import { docker } from '@app/core/utils/clients/docker';
import { catchHandlers } from '@app/core/utils/misc/catch-handlers';
import { ContainerPortType, ContainerState } from '@app/graphql/generated/api/types';
import { getters, store } from '@app/store';
import { updateDockerState } from '@app/store/modules/docker';
/**
* Get all Docker containers.
@@ -21,7 +18,7 @@ import { docker } from '@app/core/utils/clients/docker';
export const getDockerContainers = async (
{ useCache } = { useCache: true }
): Promise<Array<DockerContainer>> => {
const dockerState = getters.docker()
const dockerState = getters.docker();
if (useCache && dockerState.containers) {
dockerLogger.trace('Using docker container cache');
return dockerState.containers;
@@ -45,43 +42,36 @@ export const getDockerContainers = async (
all: true,
size: true,
})
.then((containers) =>
containers.map((object) => camelCaseKeys(object, { deep: true }))
)
.then((containers) => containers.map((object) => camelCaseKeys(object, { deep: true })))
// If docker throws an error return no containers
.catch(catchHandlers.docker);
// Cleanup container object
const containers: Array<DockerContainer> = rawContainers.map<DockerContainer>(
(container) => {
const names = container.names[0];
const containerData: DockerContainer = {
...container,
labels: container.labels,
// @ts-expect-error sizeRootFs is not on the dockerode type, but is fetched when size: true is set
sizeRootFs: container.sizeRootFs ?? undefined,
imageId: container.imageID,
state:
typeof container?.state === 'string'
? ContainerState[container.state.toUpperCase()] ??
ContainerState.EXITED
: ContainerState.EXITED,
autoStart: autoStarts.includes(names.split('/')[1]),
ports: container.ports.map<ContainerPort>((port) => ({
...port,
type: ContainerPortType[port.type.toUpperCase()],
})),
};
return containerData;
}
);
const containers: Array<DockerContainer> = rawContainers.map<DockerContainer>((container) => {
const names = container.names[0];
const containerData: DockerContainer = {
...container,
labels: container.labels,
// @ts-expect-error sizeRootFs is not on the dockerode type, but is fetched when size: true is set
sizeRootFs: container.sizeRootFs ?? undefined,
imageId: container.imageID,
state:
typeof container?.state === 'string'
? (ContainerState[container.state.toUpperCase()] ?? ContainerState.EXITED)
: ContainerState.EXITED,
autoStart: autoStarts.includes(names.split('/')[1]),
ports: container.ports.map<ContainerPort>((port) => ({
...port,
type: ContainerPortType[port.type.toUpperCase()],
})),
};
return containerData;
});
// Get all of the current containers
const installed = containers.length;
const running = containers.filter(
(container) => container.state === ContainerState.RUNNING
).length;
const running = containers.filter((container) => container.state === ContainerState.RUNNING).length;
store.dispatch(updateDockerState({ containers, installed, running }))
store.dispatch(updateDockerState({ containers, installed, running }));
return containers;
};

View File

@@ -1,34 +1,36 @@
import camelCaseKeys from 'camelcase-keys';
import { docker } from '@app/core/utils';
import { type CoreContext, type CoreResult } from '@app/core/types';
import { docker } from '@app/core/utils';
import { catchHandlers } from '@app/core/utils/misc/catch-handlers';
import { ensurePermission } from '@app/core/utils/permissions/ensure-permission';
export const getDockerNetworks = async (context: CoreContext): Promise<CoreResult> => {
const { user } = context;
const { user } = context;
// Check permissions
ensurePermission(user, {
resource: 'docker/network',
action: 'read',
possession: 'any',
});
// Check permissions
ensurePermission(user, {
resource: 'docker/network',
action: 'read',
possession: 'any',
});
const networks = await docker.listNetworks()
// If docker throws an error return no networks
.catch(catchHandlers.docker)
.then((networks = []) => networks.map(object => camelCaseKeys(object, { deep: true })));
const networks = await docker
.listNetworks()
// If docker throws an error return no networks
.catch(catchHandlers.docker)
.then((networks = []) => networks.map((object) => camelCaseKeys(object, { deep: true })));
/**
* Get all Docker networks
*
* @memberof Core
* @module docker/get-networks
* @param {Core~Context} context
* @returns {Core~Result} All the in/active Docker networks on the system.
*/
return {
text: `Networks: ${JSON.stringify(networks, null, 2)}`,
json: networks,
};
/**
* Get all Docker networks
*
* @memberof Core
* @module docker/get-networks
* @param {Core~Context} context
* @returns {Core~Result} All the in/active Docker networks on the system.
*/
return {
text: `Networks: ${JSON.stringify(networks, null, 2)}`,
json: networks,
};
};

View File

@@ -4,10 +4,10 @@ import type { CoreResult } from '@app/core/types';
* Get all apps.
*/
export const getApps = async (): Promise<CoreResult> => {
const apps = [];
const apps = [];
return {
text: `Apps: ${JSON.stringify(apps, null, 2)}`,
json: apps,
};
return {
text: `Apps: ${JSON.stringify(apps, null, 2)}`,
json: apps,
};
};

View File

@@ -1,4 +1,4 @@
import type { CoreResult, CoreContext } from '@app/core/types';
import type { CoreContext, CoreResult } from '@app/core/types';
import { ensurePermission } from '@app/core/utils/permissions/ensure-permission';
/**
@@ -6,15 +6,15 @@ import { ensurePermission } from '@app/core/utils/permissions/ensure-permission'
* @returns All currently connected devices.
*/
export const getDevices = async (context: CoreContext): Promise<CoreResult> => {
const { user } = context;
const { user } = context;
// Check permissions
ensurePermission(user, {
resource: 'device',
action: 'read',
possession: 'any',
});
/*
// Check permissions
ensurePermission(user, {
resource: 'device',
action: 'read',
possession: 'any',
});
/*
Const { devices } = getters.emhttp();
return {
@@ -22,8 +22,8 @@ export const getDevices = async (context: CoreContext): Promise<CoreResult> => {
json: devices,
};
*/
return {
text: 'Disabled Due To Bug With Devs Sub',
json: {},
};
return {
text: 'Disabled Due To Bug With Devs Sub',
json: {},
};
};

View File

@@ -1,20 +1,12 @@
import type { Systeminformation } from 'systeminformation';
import { execa } from 'execa';
import {
type Systeminformation,
blockDevices,
diskLayout,
} from 'systeminformation';
import {
type Disk,
DiskInterfaceType,
DiskSmartStatus,
DiskFsType,
} from '@app/graphql/generated/api/types';
import { graphqlLogger } from '@app/core/log';
import { blockDevices, diskLayout } from 'systeminformation';
const getTemperature = async (
disk: Systeminformation.DiskLayoutData
): Promise<number> => {
import type { Disk } from '@app/graphql/generated/api/types';
import { graphqlLogger } from '@app/core/log';
import { DiskFsType, DiskInterfaceType, DiskSmartStatus } from '@app/graphql/generated/api/types';
const getTemperature = async (disk: Systeminformation.DiskLayoutData): Promise<number> => {
try {
const stdout = await execa('smartctl', ['-A', disk.device])
.then(({ stdout }) => stdout)
@@ -23,9 +15,7 @@ const getTemperature = async (
const header = lines.find((line) => line.startsWith('ID#')) ?? '';
const fields = lines.splice(lines.indexOf(header) + 1, lines.length);
const field = fields.find(
(line) =>
line.includes('Temperature_Celsius') ||
line.includes('Airflow_Temperature_Cel')
(line) => line.includes('Temperature_Celsius') || line.includes('Airflow_Temperature_Cel')
);
if (!field) {
@@ -33,10 +23,7 @@ const getTemperature = async (
}
if (field.includes('Min/Max')) {
return Number.parseInt(
field.split(' - ')[1].trim().split(' ')[0],
10
);
return Number.parseInt(field.split(' - ')[1].trim().split(' ')[0], 10);
}
const line = field.split(' ');
@@ -54,9 +41,7 @@ const parseDisk = async (
): Promise<Disk> => {
const partitions = partitionsToParse
// Only get partitions from this disk
.filter((partition) =>
partition.name.startsWith(disk.device.split('/dev/')[1])
)
.filter((partition) => partition.name.startsWith(disk.device.split('/dev/')[1]))
// Remove unneeded fields
.map(({ name, fsType, size }) => ({
name,
@@ -82,18 +67,14 @@ const parseDisk = async (
/**
* Get all disks.
*/
export const getDisks = async (
options?: { temperature: boolean }
): Promise<Disk[]> => {
export const getDisks = async (options?: { temperature: boolean }): Promise<Disk[]> => {
// Return all fields but temperature
if (options?.temperature === false) {
const partitions = await blockDevices().then((devices) =>
devices.filter((device) => device.type === 'part')
);
const diskLayoutData = await diskLayout();
const disks = await Promise.all(
diskLayoutData.map((disk) => parseDisk(disk, partitions))
);
const disks = await Promise.all(diskLayoutData.map((disk) => parseDisk(disk, partitions)));
return disks;
}
@@ -101,9 +82,7 @@ export const getDisks = async (
const partitions = await blockDevices().then((devices) =>
devices.filter((device) => device.type === 'part')
);
const disks = await asyncMap(await diskLayout(), async (disk) =>
parseDisk(disk, partitions, true)
);
const disks = await asyncMap(await diskLayout(), async (disk) => parseDisk(disk, partitions, true));
return disks;
};

View File

@@ -1,8 +1,10 @@
import { promises as fs } from 'fs';
import { type CoreResult, type CoreContext } from '@app/core/types';
import { FileMissingError } from '@app/core/errors/file-missing-error';
import { ensurePermission } from '@app/core/utils/permissions/ensure-permission';
import Table from 'cli-table';
import { FileMissingError } from '@app/core/errors/file-missing-error';
import { type CoreContext, type CoreResult } from '@app/core/types';
import { ensurePermission } from '@app/core/utils/permissions/ensure-permission';
import { getters } from '@app/store';
/**
@@ -10,50 +12,50 @@ import { getters } from '@app/store';
* @returns All parity checks with their respective date, duration, speed, status and errors.
*/
export const getParityHistory = async (context: CoreContext): Promise<CoreResult> => {
const { user } = context;
const { user } = context;
// Bail if the user doesn't have permission
ensurePermission(user, {
resource: 'parity-history',
action: 'read',
possession: 'any',
});
// Bail if the user doesn't have permission
ensurePermission(user, {
resource: 'parity-history',
action: 'read',
possession: 'any',
});
const historyFilePath = getters.paths()['parity-checks'];
const history = await fs.readFile(historyFilePath).catch(() => {
throw new FileMissingError(historyFilePath);
});
const historyFilePath = getters.paths()['parity-checks'];
const history = await fs.readFile(historyFilePath).catch(() => {
throw new FileMissingError(historyFilePath);
});
// Convert checks into array of objects
const lines = history.toString().trim().split('\n').reverse();
const parityChecks = lines.map(line => {
const [date, duration, speed, status, errors = '0'] = line.split('|');
return {
date,
duration: Number.parseInt(duration, 10),
speed,
status,
errors: Number.parseInt(errors, 10),
};
});
// Convert checks into array of objects
const lines = history.toString().trim().split('\n').reverse();
const parityChecks = lines.map((line) => {
const [date, duration, speed, status, errors = '0'] = line.split('|');
return {
date,
duration: Number.parseInt(duration, 10),
speed,
status,
errors: Number.parseInt(errors, 10),
};
});
// Create table for text output
const table = new Table({
head: ['Date', 'Duration', 'Speed', 'Status', 'Errors'],
});
// Update raw values with strings
parityChecks.forEach(check => {
const array = Object.values({
...check,
speed: check.speed ? check.speed : 'Unavailable',
duration: check.duration >= 0 ? check.duration : 'Unavailable',
status: check.status === '-4' ? 'Cancelled' : 'OK',
});
table.push(array);
});
// Create table for text output
const table = new Table({
head: ['Date', 'Duration', 'Speed', 'Status', 'Errors'],
});
// Update raw values with strings
parityChecks.forEach((check) => {
const array = Object.values({
...check,
speed: check.speed ? check.speed : 'Unavailable',
duration: check.duration >= 0 ? check.duration : 'Unavailable',
status: check.status === '-4' ? 'Cancelled' : 'OK',
});
table.push(array);
});
return {
text: table.toString(),
json: parityChecks,
};
return {
text: table.toString(),
json: parityChecks,
};
};

View File

@@ -1,6 +1,6 @@
import { getEmhttpdService } from '@app/core/modules/services/get-emhttpd';
import type { CoreContext, CoreResult } from '@app/core/types';
import { logger } from '@app/core/log';
import type { CoreResult, CoreContext } from '@app/core/types';
import { getEmhttpdService } from '@app/core/modules/services/get-emhttpd';
import { getUnraidApiService } from '@app/core/modules/services/get-unraid-api';
import { NODE_ENV } from '@app/environment';

View File

@@ -1,50 +1,53 @@
import type { CoreContext, CoreResult } from '@app/core/types';
import { ensurePermission } from '@app/core/utils/permissions/ensure-permission';
import { AppError } from '@app/core/errors/app-error';
import { getters } from '@app/store';
import { type User } from '@app/core/types/states/user';
import { ensurePermission } from '@app/core/utils/permissions/ensure-permission';
import { getters } from '@app/store';
interface Context extends CoreContext {
query: {
/** Should all fields be returned? */
slim: string;
};
query: {
/** Should all fields be returned? */
slim: string;
};
}
/**
* Get all users.
*/
export const getUsers = async (context: Context): Promise<CoreResult> => {
const { query, user } = context;
const { query, user } = context;
// Check permissions
ensurePermission(user, {
resource: 'user',
action: 'read',
possession: 'any',
});
// Check permissions
ensurePermission(user, {
resource: 'user',
action: 'read',
possession: 'any',
});
// Default to only showing limited fields
const { slim = 'true' } = query;
const { users } = getters.emhttp();
// Default to only showing limited fields
const { slim = 'true' } = query;
const { users } = getters.emhttp();
if (users.length === 0) {
// This is likely a new install or something went horribly wrong
throw new AppError('No users found.', 404);
}
if (users.length === 0) {
// This is likely a new install or something went horribly wrong
throw new AppError('No users found.', 404);
}
const result = slim === 'true' ? users.map((user: User) => {
const { id, name, description, role } = user;
return {
id,
name,
description,
role,
};
}) : users;
const result =
slim === 'true'
? users.map((user: User) => {
const { id, name, description, role } = user;
return {
id,
name,
description,
role,
};
})
: users;
return {
text: `Users: ${JSON.stringify(result, null, 2)}`,
json: result,
};
return {
text: `Users: ${JSON.stringify(result, null, 2)}`,
json: result,
};
};

View File

@@ -1,5 +1,5 @@
import type { CoreContext, CoreResult } from '@app/core/types';
import { getUnraidVersion } from '@app/common/dashboard/get-unraid-version';
import type { CoreResult, CoreContext } from '@app/core/types';
import { ensurePermission } from '@app/core/utils/permissions/ensure-permission';
/**
@@ -7,22 +7,22 @@ import { ensurePermission } from '@app/core/utils/permissions/ensure-permission'
* @returns Welcomes a user.
*/
export const getWelcome = async (context: CoreContext): Promise<CoreResult> => {
const { user } = context;
const { user } = context;
// Bail if the user doesn't have permission
ensurePermission(user, {
resource: 'welcome',
action: 'read',
possession: 'any',
});
// Bail if the user doesn't have permission
ensurePermission(user, {
resource: 'welcome',
action: 'read',
possession: 'any',
});
const version = await getUnraidVersion();
const message = `Welcome ${user.name} to this Unraid ${version} server`;
const version = await getUnraidVersion();
const message = `Welcome ${user.name} to this Unraid ${version} server`;
return {
text: message,
json: {
message,
},
};
return {
text: message,
json: {
message,
},
};
};

View File

@@ -1,8 +1,11 @@
import { join } from 'node:path';
import type { FSWatcher } from 'chokidar';
import { watch } from 'chokidar';
import { getters, store } from '@app/store/index';
import { clearNotification, loadNotification } from '@app/store/modules/notifications';
import { FileLoadStatus } from '@app/store/types';
import { type FSWatcher, watch } from 'chokidar';
import { join } from 'node:path';
const handleNotificationAdd = (path: string) => {
store.dispatch(loadNotification({ path }));
@@ -18,7 +21,7 @@ export const setupNotificationWatch = async (): Promise<FSWatcher | null> => {
const { notify, status } = getters.dynamix();
if (status === FileLoadStatus.LOADED && notify?.path) {
if (watcher) {
await watcher.close()
await watcher.close();
}
watcher = watch(join(notify.path, 'unread'), {})
.on('add', (path) => {

View File

@@ -1,41 +1,42 @@
import { execa } from 'execa';
import { type CoreContext, type CoreResult } from '@app/core/types';
import { cleanStdout } from '@app/core/utils/misc/clean-stdout';
import { ensurePermission } from '@app/core/utils/permissions/ensure-permission';
interface Result extends CoreResult {
json: {
online: boolean;
uptime: number;
};
json: {
online: boolean;
uptime: number;
};
}
/**
* Get emhttpd service info.
*/
export const getEmhttpdService = async (context: CoreContext): Promise<Result> => {
const { user } = context;
const { user } = context;
// Check permissions
ensurePermission(user, {
resource: 'service/emhttpd',
action: 'read',
possession: 'any',
});
// Check permissions
ensurePermission(user, {
resource: 'service/emhttpd',
action: 'read',
possession: 'any',
});
// Only get uptime if process is online
const uptime = await execa('ps', ['-C', 'emhttpd', '-o', 'etimes', '--no-headers'])
.then(cleanStdout)
.then(uptime => Number.parseInt(uptime, 10))
.catch(() => -1);
// Only get uptime if process is online
const uptime = await execa('ps', ['-C', 'emhttpd', '-o', 'etimes', '--no-headers'])
.then(cleanStdout)
.then((uptime) => Number.parseInt(uptime, 10))
.catch(() => -1);
const online = uptime >= 1;
const online = uptime >= 1;
return {
text: `Online: ${online}\n Uptime: ${uptime}`,
json: {
online,
uptime,
},
};
return {
text: `Online: ${online}\n Uptime: ${uptime}`,
json: {
online,
uptime,
},
};
};

View File

@@ -1,16 +1,16 @@
import { ensurePermission } from '@app/core/utils/permissions/ensure-permission';
import type { CoreContext, CoreResult } from '@app/core/types';
import { ensurePermission } from '@app/core/utils/permissions/ensure-permission';
import { API_VERSION } from '@app/environment';
interface Result extends CoreResult {
json: {
name: string;
online: boolean;
uptime: {
timestamp: string;
seconds: number;
};
};
json: {
name: string;
online: boolean;
uptime: {
timestamp: string;
seconds: number;
};
};
}
// When this service started
@@ -20,29 +20,29 @@ const startTimestamp = new Date();
* Get Unraid api service info.
*/
export const getUnraidApiService = async (context: CoreContext): Promise<Result> => {
// Check permissions
ensurePermission(context.user, {
resource: 'service/unraid-api',
action: 'read',
possession: 'any',
});
// Check permissions
ensurePermission(context.user, {
resource: 'service/unraid-api',
action: 'read',
possession: 'any',
});
const now = new Date();
const uptimeTimestamp = startTimestamp.toISOString();
const uptimeSeconds = (now.getTime() - startTimestamp.getTime());
const now = new Date();
const uptimeTimestamp = startTimestamp.toISOString();
const uptimeSeconds = now.getTime() - startTimestamp.getTime();
const service = {
name: 'unraid-api',
online: true,
uptime: {
timestamp: uptimeTimestamp,
seconds: uptimeSeconds,
},
version: API_VERSION,
};
const service = {
name: 'unraid-api',
online: true,
uptime: {
timestamp: uptimeTimestamp,
seconds: uptimeSeconds,
},
version: API_VERSION,
};
return {
text: `Service: ${JSON.stringify(service, null, 2)}`,
json: service,
};
return {
text: `Service: ${JSON.stringify(service, null, 2)}`,
json: service,
};
};

View File

@@ -0,0 +1,15 @@
import { execa } from 'execa';
import { logger } from '@app/core/log';
export class NginxManager {
public reloadNginx = async () => {
try {
await execa('/etc/rc.d/rc.nginx', ['reload']);
return true;
} catch (err: unknown) {
logger.warn('Failed to restart Nginx with error: ', err);
return false;
}
};
}

View File

@@ -0,0 +1,15 @@
import { execa } from 'execa';
import { logger } from '@app/core/log';
export class UpdateDNSManager {
public updateDNS = async () => {
try {
await execa('/usr/bin/php', ['/usr/local/emhttp/plugins/dynamix/include/UpdateDNS.php']);
return true;
} catch (err: unknown) {
logger.warn('Failed to call Update DNS with error: ', err);
return false;
}
};
}

View File

@@ -1,150 +1,142 @@
import { type CoreContext, type CoreResult } from '@app/core/types';
import { EmCmdError } from '@app/core/errors/em-cmd-error';
import { ensurePermission } from '@app/core/utils/permissions/ensure-permission';
import { emcmd } from '@app/core/utils/clients/emcmd';
import { type CoreContext, type CoreResult } from '@app/core/types';
import { type Var } from '@app/core/types/states/var';
import { emcmd } from '@app/core/utils/clients/emcmd';
import { ensurePermission } from '@app/core/utils/permissions/ensure-permission';
import { getters } from '@app/store';
interface Context extends CoreContext {
data: Var;
data: Var;
}
interface Result extends CoreResult {
json: {
mdwriteMethod?: number;
startArray?: boolean;
spindownDelay?: number;
defaultFormat?: any;
defaultFsType?: any;
};
json: {
mdwriteMethod?: number;
startArray?: boolean;
spindownDelay?: number;
defaultFormat?: any;
defaultFsType?: any;
};
}
/**
* Update disk settings.
*/
export const updateDisk = async (context: Context): Promise<Result> => {
const { data, user } = context;
const { data, user } = context;
// Check permissions
ensurePermission(user, {
resource: 'disk/settings',
action: 'update',
possession: 'any',
});
// Check permissions
ensurePermission(user, {
resource: 'disk/settings',
action: 'update',
possession: 'any',
});
/**
* Check context.data[property] is using an allowed value.
*
* @param property The property of data to check values against.
* @param allowedValues Which values which are allowed.
* @param optional If the value can also be undefined.
*/
const check = (property: string, allowedValues: Record<string, string> | string[], optional = true): void => {
const value = data[property];
/**
* Check context.data[property] is using an allowed value.
*
* @param property The property of data to check values against.
* @param allowedValues Which values which are allowed.
* @param optional If the value can also be undefined.
*/
const check = (
property: string,
allowedValues: Record<string, string> | string[],
optional = true
): void => {
const value = data[property];
// Skip checking if the value isn't needed and it's not set
if (optional && value === undefined) {
return;
}
// Skip checking if the value isn't needed and it's not set
if (optional && value === undefined) {
return;
}
// AllowedValues is an object
if (!Array.isArray(allowedValues)) {
allowedValues = Object.keys(allowedValues);
}
// AllowedValues is an object
if (!Array.isArray(allowedValues)) {
allowedValues = Object.keys(allowedValues);
}
if (!allowedValues.includes(value)) {
throw new EmCmdError(property, value, allowedValues);
}
};
if (!allowedValues.includes(value)) {
throw new EmCmdError(property, value, allowedValues);
}
};
// If set to 'Yes' then if the device configuration is correct upon server start - up, the array will be automatically started and shares exported.
// If set to 'No' then you must start the array yourself.
check('startArray', ['yes', 'no']);
// If set to 'Yes' then if the device configuration is correct upon server start - up, the array will be automatically started and shares exported.
// If set to 'No' then you must start the array yourself.
check('startArray', ['yes', 'no']);
// Define the 'default' time-out for spinning hard drives down after a period of no I/O activity.
// You may also override the default value for an individual disk.
check('spindownDelay', {
// Define the 'default' time-out for spinning hard drives down after a period of no I/O activity.
// You may also override the default value for an individual disk.
check('spindownDelay', {
0: 'Never',
15: '15 minutes',
30: '30 minutes',
45: '45 minutes',
1: '1 hour',
2: '2 hours',
3: '3 hours',
4: '4 hours',
5: '5 hours',
6: '6 hours',
7: '7 hours',
8: '8 hours',
9: '9 hours',
});
0: 'Never',
15: '15 minutes',
30: '30 minutes',
45: '45 minutes',
1: '1 hour',
2: '2 hours',
3: '3 hours',
4: '4 hours',
5: '5 hours',
6: '6 hours',
7: '7 hours',
8: '8 hours',
9: '9 hours',
});
// Defines the type of partition layout to create when formatting hard drives 2TB in size and smaller **only**. (All devices larger then 2TB are always set up with GPT partition tables.)
// **MBR: unaligned** setting will create MBR-style partition table, where the single partition 1 will start in the **63rd sector** from the start of the disk. This is the *traditional* setting for virtually all MBR-style partition tables.
// **MBR: 4K-aligned** setting will create an MBR-style partition table, where the single partition 1 will start in the **64th sector** from the start of the disk. Since the sector size is 512 bytes, this will *align* the start of partition 1 on a 4K-byte boundary. This is required for proper support of so-called *Advanced Format* drives.
// Unless you have a specific requirement do not change this setting from the default **MBR: 4K-aligned**.
check('defaultFormat', {
1: 'MBR: unaligned',
2: 'MBR: 4K-aligned',
});
// Defines the type of partition layout to create when formatting hard drives 2TB in size and smaller **only**. (All devices larger then 2TB are always set up with GPT partition tables.)
// **MBR: unaligned** setting will create MBR-style partition table, where the single partition 1 will start in the **63rd sector** from the start of the disk. This is the *traditional* setting for virtually all MBR-style partition tables.
// **MBR: 4K-aligned** setting will create an MBR-style partition table, where the single partition 1 will start in the **64th sector** from the start of the disk. Since the sector size is 512 bytes, this will *align* the start of partition 1 on a 4K-byte boundary. This is required for proper support of so-called *Advanced Format* drives.
// Unless you have a specific requirement do not change this setting from the default **MBR: 4K-aligned**.
check('defaultFormat', {
// Selects the method to employ when writing to enabled disk in parity protected array.
check('writeMethod', {
auto: 'Auto - read/modify/write',
1: 'MBR: unaligned',
2: 'MBR: 4K-aligned',
});
0: 'read/modify/write',
1: 'reconstruct write',
});
// Selects the method to employ when writing to enabled disk in parity protected array.
check('writeMethod', {
auto: 'Auto - read/modify/write',
// Defines the default file system type to create when an * unmountable * array device is formatted.
// The default file system type for a single or multi - device cache is always Btrfs.
check('defaultFsType', {
xfs: 'xfs',
btrfs: 'btrfs',
reiserfs: 'reiserfs',
0: 'read/modify/write',
1: 'reconstruct write',
});
'luks:xfs': 'xfs - encrypted',
'luks:btrfs': 'btrfs - encrypted',
'luks:reiserfs': 'reiserfs - encrypted',
});
// Defines the default file system type to create when an * unmountable * array device is formatted.
// The default file system type for a single or multi - device cache is always Btrfs.
check('defaultFsType', {
xfs: 'xfs',
btrfs: 'btrfs',
reiserfs: 'reiserfs',
const { startArray, spindownDelay, defaultFormat, defaultFsType, mdWriteMethod } = data;
'luks:xfs': 'xfs - encrypted',
'luks:btrfs': 'btrfs - encrypted',
'luks:reiserfs': 'reiserfs - encrypted',
});
await emcmd({
startArray,
spindownDelay,
defaultFormat,
defaultFsType,
const {
startArray,
spindownDelay,
defaultFormat,
defaultFsType,
mdWriteMethod,
} = data;
md_write_method: mdWriteMethod,
changeDisk: 'Apply',
});
await emcmd({
startArray,
spindownDelay,
defaultFormat,
defaultFsType,
const emhttp = getters.emhttp();
md_write_method: mdWriteMethod,
changeDisk: 'Apply',
});
// @todo: return all disk settings
const result = {
mdwriteMethod: emhttp.var.mdWriteMethod,
startArray: emhttp.var.startArray,
spindownDelay: emhttp.var.spindownDelay,
defaultFormat: emhttp.var.defaultFormat,
defaultFsType: emhttp.var.defaultFormat,
};
const emhttp = getters.emhttp();
// @todo: return all disk settings
const result = {
mdwriteMethod: emhttp.var.mdWriteMethod,
startArray: emhttp.var.startArray,
spindownDelay: emhttp.var.spindownDelay,
defaultFormat: emhttp.var.defaultFormat,
defaultFsType: emhttp.var.defaultFormat,
};
return {
text: `Disk settings: ${JSON.stringify(result, null, 2)}`,
json: result,
};
return {
text: `Disk settings: ${JSON.stringify(result, null, 2)}`,
json: result,
};
};

View File

@@ -1,48 +1,45 @@
import type { CoreContext, CoreResult } from '@app/core/types/global';
import type { UserShare, DiskShare } from '@app/core/types/states/share';
import type { DiskShare, UserShare } from '@app/core/types/states/share';
import { AppError } from '@app/core/errors/app-error';
import { getShares } from '@app/core/utils';
import { ensurePermission } from '@app/core/utils/permissions/ensure-permission';
interface Context extends CoreContext {
params: {
/** Name of the share */
name: string;
};
params: {
/** Name of the share */
name: string;
};
}
interface Result extends CoreResult {
json: UserShare | DiskShare;
json: UserShare | DiskShare;
}
/**
* Get single share.
*/
export const getShare = async function (context: Context): Promise<Result> {
const { params, user } = context;
const { name } = params;
const { params, user } = context;
const { name } = params;
// Check permissions
ensurePermission(user, {
resource: 'share',
action: 'read',
possession: 'any',
});
// Check permissions
ensurePermission(user, {
resource: 'share',
action: 'read',
possession: 'any',
});
const userShare = getShares('user', { name });
const diskShare = getShares('disk', { name });
const userShare = getShares('user', { name });
const diskShare = getShares('disk', { name });
const share = [
userShare,
diskShare,
].filter(_ => _)[0];
const share = [userShare, diskShare].filter((_) => _)[0];
if (!share) {
throw new AppError('No share found with that name.', 404);
}
if (!share) {
throw new AppError('No share found with that name.', 404);
}
return {
text: `Share: ${JSON.stringify(share, null, 2)}`,
json: share,
};
return {
text: `Share: ${JSON.stringify(share, null, 2)}`,
json: share,
};
};

View File

@@ -1,46 +1,46 @@
import { type CoreContext, type CoreResult } from '@app/core/types';
import { AppError } from '@app/core/errors/app-error';
import { FieldMissingError } from '@app/core/errors/field-missing-error';
import { type CoreContext, type CoreResult } from '@app/core/types';
import { ensurePermission } from '@app/core/utils/permissions/ensure-permission';
import { hasFields } from '@app/core/utils/validation/has-fields';
import { FieldMissingError } from '@app/core/errors/field-missing-error';
import { getters } from '@app/store';
interface Context extends CoreContext {
params: {
/** Name of user to add the role to. */
name: string;
};
params: {
/** Name of user to add the role to. */
name: string;
};
}
/**
* Add role to user.
*/
export const addRole = async (context: Context): Promise<CoreResult> => {
const { user, params } = context;
const { user, params } = context;
// Check permissions
ensurePermission(user, {
resource: 'user',
action: 'update',
possession: 'any',
});
// Check permissions
ensurePermission(user, {
resource: 'user',
action: 'update',
possession: 'any',
});
// Validation
const { name } = params;
const missingFields = hasFields(params, ['name']);
// Validation
const { name } = params;
const missingFields = hasFields(params, ['name']);
if (missingFields.length !== 0) {
throw new FieldMissingError(missingFields[0]);
}
if (missingFields.length !== 0) {
throw new FieldMissingError(missingFields[0]);
}
// Check user exists
if (!getters.emhttp().users.find(user => user.name === name)) {
throw new AppError('No user exists with this name.');
}
// Check user exists
if (!getters.emhttp().users.find((user) => user.name === name)) {
throw new AppError('No user exists with this name.');
}
// @todo: add user role
// @todo: add user role
return {
text: 'User updated successfully.',
};
return {
text: 'User updated successfully.',
};
};

View File

@@ -1,51 +1,51 @@
import type { CoreContext, CoreResult } from '@app/core/types';
import { AppError } from '@app/core/errors/app-error';
import { FieldMissingError } from '@app/core/errors/field-missing-error';
import { emcmd } from '@app/core/utils/clients/emcmd';
import { ensurePermission } from '@app/core/utils/permissions/ensure-permission';
import { hasFields } from '@app/core/utils/validation/has-fields';
import { emcmd } from '@app/core/utils/clients/emcmd';
import { getters } from '@app/store';
interface Context extends CoreContext {
params: {
/** Name of user to delete. */
name: string;
};
params: {
/** Name of user to delete. */
name: string;
};
}
/**
* Delete user account.
*/
export const deleteUser = async (context: Context): Promise<CoreResult> => {
// Check permissions
ensurePermission(context.user, {
resource: 'user',
action: 'delete',
possession: 'any',
});
// Check permissions
ensurePermission(context.user, {
resource: 'user',
action: 'delete',
possession: 'any',
});
const { params } = context;
const { name } = params;
const missingFields = hasFields(params, ['name']);
const { params } = context;
const { name } = params;
const missingFields = hasFields(params, ['name']);
if (missingFields.length !== 0) {
// Just throw the first error
throw new FieldMissingError(missingFields[0]);
}
if (missingFields.length !== 0) {
// Just throw the first error
throw new FieldMissingError(missingFields[0]);
}
// Check user exists
if (!getters.emhttp().users.find(user => user.name === name)) {
throw new AppError('No user exists with this name.');
}
// Check user exists
if (!getters.emhttp().users.find((user) => user.name === name)) {
throw new AppError('No user exists with this name.');
}
// Delete user
await emcmd({
userName: name,
confirmDelete: 'on',
cmdUserEdit: 'Delete',
});
// Delete user
await emcmd({
userName: name,
confirmDelete: 'on',
cmdUserEdit: 'Delete',
});
return {
text: 'User deleted successfully.',
};
return {
text: 'User deleted successfully.',
};
};

View File

@@ -1,14 +1,14 @@
import type { CoreContext, CoreResult } from '@app/core/types';
import { AppError } from '@app/core/errors/app-error';
import { ensureParameter } from '@app/core/utils/validation/context';
import { ensurePermission } from '@app/core/utils/permissions/ensure-permission';
import { ensureParameter } from '@app/core/utils/validation/context';
import { getters } from '@app/store';
interface Context extends CoreContext {
params: {
/** User ID */
id: string;
};
params: {
/** User ID */
id: string;
};
}
/**
@@ -16,29 +16,29 @@ interface Context extends CoreContext {
* @returns The selected user.
*/
export const getUser = async (context: Context): Promise<CoreResult> => {
// Check permissions
ensurePermission(context.user, {
resource: 'user',
action: 'create',
possession: 'any',
});
// Check permissions
ensurePermission(context.user, {
resource: 'user',
action: 'create',
possession: 'any',
});
ensureParameter(context, 'id');
ensureParameter(context, 'id');
const id = context?.params?.id;
if (!id) {
throw new AppError('No id passed.');
}
const id = context?.params?.id;
if (!id) {
throw new AppError('No id passed.');
}
const user = getters.emhttp().users.find(user => user.id === id);
const user = getters.emhttp().users.find((user) => user.id === id);
if (!user) {
// This is likely a new install or something went horribly wrong
throw new AppError(`No users found matching ${id}`, 404);
}
if (!user) {
// This is likely a new install or something went horribly wrong
throw new AppError(`No users found matching ${id}`, 404);
}
return {
text: `User: ${JSON.stringify(user, null, 2)}`,
json: user,
};
return {
text: `User: ${JSON.stringify(user, null, 2)}`,
json: user,
};
};

View File

@@ -1,30 +1,31 @@
import { Notifier, type NotifierOptions, type NotifierSendOptions } from '@app/core/notifiers/notifier';
import type { NotifierOptions, NotifierSendOptions } from '@app/core/notifiers/notifier';
import { logger } from '@app/core/log';
import { Notifier } from '@app/core/notifiers/notifier';
/**
* Console notifier.
*/
export class ConsoleNotifier extends Notifier {
private readonly log: typeof logger;
private readonly log: typeof logger;
constructor(options: NotifierOptions = {}) {
super(options);
constructor(options: NotifierOptions = {}) {
super(options);
this.level = options.level ?? 'info';
this.helpers = options.helpers ?? {};
this.template = options.template ?? '{{{ data }}}';
this.log = logger;
}
this.level = options.level ?? 'info';
this.helpers = options.helpers ?? {};
this.template = options.template ?? '{{{ data }}}';
this.log = logger;
}
/**
* Send notification.
*/
send(options: NotifierSendOptions) {
const { title, data } = options;
const { level, helpers } = this;
// Render template
const template = this.render({ ...data }, helpers);
/**
* Send notification.
*/
send(options: NotifierSendOptions) {
const { title, data } = options;
const { level, helpers } = this;
// Render template
const template = this.render({ ...data }, helpers);
this.log[level](title, template);
}
this.log[level](title, template);
}
}

View File

@@ -1,15 +1,17 @@
import { got } from 'got';
import { Notifier, type NotifierOptions } from '@app/core/notifiers/notifier';
export type Options = NotifierOptions
import type { NotifierOptions } from '@app/core/notifiers/notifier';
import { Notifier } from '@app/core/notifiers/notifier';
export type Options = NotifierOptions;
/**
* HTTP notifier.
*/
export class HttpNotifier extends Notifier {
readonly $http = got;
constructor(options: Options) {
super(options);
}
readonly $http = got;
constructor(options: Options) {
super(options);
}
}

View File

@@ -1,25 +1,26 @@
import Mustache from 'mustache';
import { type LooseObject } from '@app/core/types';
import { type NotificationIni } from '../types/states/notification';
import { type NotificationIni } from '@app/core/types/states/notification';
export type NotifierLevel = 'info' | 'warn' | 'error';
export type NotifierOptions = Partial<{
level: NotifierLevel;
importance?: NotificationIni['importance'];
helpers?: Record<string, unknown>;
template?: string;
level: NotifierLevel;
importance?: NotificationIni['importance'];
helpers?: Record<string, unknown>;
template?: string;
}>;
export interface NotifierSendOptions {
/** Which type of notification. */
type?: string;
/** The notification's title. */
title: string;
/** Static data passed for rendering. */
data: LooseObject;
/** Functions to generate dynamic data for rendering. */
computed?: LooseObject;
/** Which type of notification. */
type?: string;
/** The notification's title. */
title: string;
/** Static data passed for rendering. */
data: LooseObject;
/** Functions to generate dynamic data for rendering. */
computed?: LooseObject;
}
/**
@@ -30,31 +31,31 @@ export interface NotifierSendOptions {
* @private
*/
export class Notifier {
template: string;
helpers: LooseObject;
level: string;
template: string;
helpers: LooseObject;
level: string;
constructor(options: NotifierOptions) {
this.template = options.template ?? '{{ data }}';
this.helpers = options.helpers ?? {};
this.level = options.level ?? 'info';
}
constructor(options: NotifierOptions) {
this.template = options.template ?? '{{ data }}';
this.helpers = options.helpers ?? {};
this.level = options.level ?? 'info';
}
/**
* Render template.
* @param data Static data for template rendering.
* @param helpers Functions for template rendering.
* @param computed Functions to generate dynamic data for rendering.
*/
render(data: LooseObject): string {
return Mustache.render(this.template, data);
}
/**
* Render template.
* @param data Static data for template rendering.
* @param helpers Functions for template rendering.
* @param computed Functions to generate dynamic data for rendering.
*/
render(data: LooseObject): string {
return Mustache.render(this.template, data);
}
/**
* Generates a mustache helper.
* @param func Function to be wrapped.
*/
generateHelper(func: (text: string) => string) {
return () => (text: string, render: (text: string) => string) => func(render(text));
}
/**
* Generates a mustache helper.
* @param func Function to be wrapped.
*/
generateHelper(func: (text: string) => string) {
return () => (text: string, render: (text: string) => string) => func(render(text));
}
}

View File

@@ -1,39 +1,52 @@
import { logger } from '@app/core/log';
import { Notifier, type NotifierSendOptions, type NotifierOptions } from '@app/core/notifiers/notifier';
import { execa } from 'execa';
import type { NotifierOptions, NotifierSendOptions } from '@app/core/notifiers/notifier';
import { logger } from '@app/core/log';
import { Notifier } from '@app/core/notifiers/notifier';
type ValidLocalLevels = 'alert' | 'warning' | 'normal';
export class UnraidLocalNotifier extends Notifier {
private convertNotifierLevel(level: NotifierOptions['level']): ValidLocalLevels {
switch (level) {
case 'error':
return 'alert';
case 'warn':
return 'warning';
case 'info':
return 'normal';
default:
return 'normal';
}
}
private convertNotifierLevel(level: NotifierOptions['level']): ValidLocalLevels {
switch (level) {
case 'error':
return 'alert';
case 'warn':
return 'warning';
case 'info':
return 'normal';
default:
return 'normal';
}
}
constructor(options: NotifierOptions = {}) {
super(options);
constructor(options: NotifierOptions = {}) {
super(options);
this.level = options.importance ?? this.convertNotifierLevel(options.level ?? 'info');
this.template = options.template ?? '{{ message }}';
}
this.level = options.importance ?? this.convertNotifierLevel(options.level ?? 'info');
this.template = options.template ?? '{{ message }}';
}
async send(options: NotifierSendOptions) {
const { title, data } = options;
const { level } = this;
async send(options: NotifierSendOptions) {
const { title, data } = options;
const { level } = this;
const template = this.render(data);
try {
await execa('/usr/local/emhttp/webGui/scripts/notify', ['-i', `${level}`, '-s', 'Unraid API', '-d', `${template}`, '-e', `${title}`]);
} catch (error: unknown) {
logger.warn(`Error sending unraid notification: ${error instanceof Error ? error.message : 'No Error Information'}`);
}
}
const template = this.render(data);
try {
await execa('/usr/local/emhttp/webGui/scripts/notify', [
'-i',
`${level}`,
'-s',
'Unraid API',
'-d',
`${template}`,
'-e',
`${title}`,
]);
} catch (error: unknown) {
logger.warn(
`Error sending unraid notification: ${error instanceof Error ? error.message : 'No Error Information'}`
);
}
}
}

View File

@@ -1,6 +1,7 @@
import { PubSub } from 'graphql-subscriptions';
import EventEmitter from 'events';
import { PubSub } from 'graphql-subscriptions';
// Allow subscriptions to have 30 connections
const eventEmitter = new EventEmitter();
eventEmitter.setMaxListeners(30);

View File

@@ -0,0 +1,19 @@
// @todo: finish this
export type DomainState = 'running' | 'stopped';
/**
* Vm domain.
*/
export interface Domain {
uuid: string;
osType: string;
autostart: string;
maxMemory: string;
schedulerType: string;
schedulerParameters: string;
securityLabel: string;
name: string;
state: string;
vcpus?: string;
memoryStats?: string;
}

View File

@@ -0,0 +1,36 @@
import { type User } from '@app/core/types/states/user';
/**
* Example: 1, 2, 3 or 1,2,3
*/
export type CommaSeparatedString = string;
export type LooseObject = Record<string, any>;
export type LooseStringObject = Record<string, string>;
/**
* Context object
* @property query Query params. e.g. { limit: 50 }
* @property data Data object.
* @property param Params object.
*/
export interface CoreContext<
Query = Record<string, unknown>,
Data = Record<string, unknown>,
Params = Record<string, unknown>,
> {
readonly query?: Readonly<Query>;
readonly data?: Readonly<Data>;
readonly params?: Readonly<Params>;
readonly user: Readonly<User>;
}
/**
* Result object
*/
export interface CoreResult<JSON = unknown> {
json?: JSON;
text?: string;
html?: string;
}

View File

@@ -11,9 +11,9 @@ interface Display {
critical: string;
custom: string;
dashapps: string;
/** a strftime format string */
/** a strftime format string */
date: string;
/** a strftime format string */
/** a strftime format string */
time?: string;
hot: string;
max: string;

View File

@@ -4,15 +4,15 @@ export type PciDeviceClass = 'vga' | 'audio' | 'gpu' | 'other';
* PCI device
*/
export interface PciDevice {
id: string;
allowed: boolean;
class: PciDeviceClass;
vendorname: string;
productname: string;
typeid: string;
serial: string;
product: string;
manufacturer: string;
guid: string;
name: string;
id: string;
allowed: boolean;
class: PciDeviceClass;
vendorname: string;
productname: string;
typeid: string;
serial: string;
product: string;
manufacturer: string;
guid: string;
name: string;
}

View File

@@ -1,33 +1,33 @@
export type Network = {
dhcpKeepresolv: boolean;
dnsServer1: string;
dnsServer2: string;
dhcp6Keepresolv: boolean;
bonding: boolean;
bondname: string;
bondnics: string[];
bondingMode: string;
bondingMiimon: string;
bridging: boolean;
brname: string;
brnics: string;
brstp: string;
brfd: string;
'description': string[];
'protocol': string[];
'useDhcp': boolean[];
'ipaddr': string[];
'netmask': string[];
'gateway': string[];
'metric': string[];
'useDhcp6': boolean[];
'ipaddr6': string[];
'netmask6': string[];
'gateway6': string[];
'metric6': string[];
'privacy6': string[];
mtu: string[];
type: string[];
dhcpKeepresolv: boolean;
dnsServer1: string;
dnsServer2: string;
dhcp6Keepresolv: boolean;
bonding: boolean;
bondname: string;
bondnics: string[];
bondingMode: string;
bondingMiimon: string;
bridging: boolean;
brname: string;
brnics: string;
brstp: string;
brfd: string;
description: string[];
protocol: string[];
useDhcp: boolean[];
ipaddr: string[];
netmask: string[];
gateway: string[];
metric: string[];
useDhcp6: boolean[];
ipaddr6: string[];
netmask6: string[];
gateway6: string[];
metric6: string[];
privacy6: string[];
mtu: string[];
type: string[];
};
export type Networks = Network[];

View File

@@ -0,0 +1,8 @@
export type NfsShare = {
name: string;
enabled: boolean;
writeList: string[];
readList: string[];
};
export type NfsShares = NfsShare[];

View File

@@ -1,23 +1,23 @@
export interface FqdnEntry {
interface: string;
id: number | null;
fqdn: string;
isIpv6: boolean;
interface: string;
id: number | null;
fqdn: string;
isIpv6: boolean;
}
export interface Nginx {
certificateName: string;
certificatePath: string;
defaultUrl: string;
httpPort: number;
httpsPort: number;
lanIp: string;
lanIp6: string;
lanMdns: string;
lanName: string;
sslEnabled: boolean;
sslMode: 'yes' | 'no' | 'auto';
wanAccessEnabled: boolean;
wanIp: string;
fqdnUrls: FqdnEntry[];
certificateName: string;
certificatePath: string;
defaultUrl: string;
httpPort: number;
httpsPort: number;
lanIp: string;
lanIp6: string;
lanMdns: string;
lanName: string;
sslEnabled: boolean;
sslMode: 'yes' | 'no' | 'auto';
wanAccessEnabled: boolean;
wanIp: string;
fqdnUrls: FqdnEntry[];
}

View File

@@ -0,0 +1,7 @@
import { type IniEnabled } from '@app/core/types/ini';
export interface SecIni {
export: IniEnabled;
writeList: string;
readList: string;
}

View File

@@ -1,16 +1,16 @@
export type Share = {
/** Share name. */
name: string;
/** Free space in bytes. */
free: number;
/** Total space in bytes. */
size: number;
/** Which disks to include from the share. */
include: string[];
/** Which disks to exclude from the share. */
exclude: string[];
/** If the share should use the cache. */
cache: boolean;
/** Share name. */
name: string;
/** Free space in bytes. */
free: number;
/** Total space in bytes. */
size: number;
/** Which disks to include from the share. */
include: string[];
/** Which disks to exclude from the share. */
exclude: string[];
/** If the share should use the cache. */
cache: boolean;
};
export type Shares = Share[];
@@ -19,14 +19,14 @@ export type Shares = Share[];
* Disk share
*/
export interface DiskShare extends Share {
type: 'disk';
type: 'disk';
}
/**
* User share
*/
export interface UserShare extends Share {
type: 'user';
type: 'user';
}
export type ShareType = 'user' | 'users' | 'disk' | 'disks';

View File

@@ -0,0 +1,35 @@
/**
* Security
*/
export type SmbSecurity =
/**
* When logged into the server as Guest, a macOS user can view and read/write all shares set as Public.
* Files created or modified in the share will be owned by user nobody of the users group.
* macOS users logged in with a user name/password previously created on the server can also view and read/write all shares set as Public.
* In this case, files created or modified on the server will be owned by the logged in user.
*/
| 'Public'
/**
* When logged into the server as Guest, a macOS user can view and read(but not write) all shares set as Secure.
* macOS users logged in with a user name/password previously created on the server can also view and read all shares set as Secure.
* If their access right is set to read/write for the share on the server, they may also write the share.
*/
| 'Secure'
/**
* When logged onto the server as Guest, no Private shares are visible or accessible to any macOS user.
* macOS users logged in with a user name/password previously created on the server may
* have read or read/write(or have no access)according their access right for the share on the server. */
| 'Private';
export type SmbShare = {
name: string;
enabled: boolean;
security: SmbSecurity;
writeList: string[];
readList: string[];
timemachine: {
volsizelimit: number;
};
};
export type SmbShares = SmbShare[];

View File

@@ -1,13 +1,13 @@
export type User = {
/** User's ID */
id: string;
/** Display name */
name: string;
description: string;
/** If password is set. */
password: boolean;
/** The main {@link Permissions~Role | role} linked to this account. */
role: string;
/** User's ID */
id: string;
/** Display name */
name: string;
description: string;
/** If password is set. */
password: boolean;
/** The main {@link Permissions~Role | role} linked to this account. */
role: string;
};
export type Users = User[];

View File

@@ -1,196 +1,201 @@
import { type registrationType, type ArrayState, type RegistrationState, type DiskFsType } from "@app/graphql/generated/api/types";
import {
type ArrayState,
type DiskFsType,
type RegistrationState,
type registrationType,
} from '@app/graphql/generated/api/types';
/**
* Global vars
*/
export type Var = {
bindMgt: boolean | null;
cacheNumDevices: number;
cacheSbNumDisks: number;
/** Description of your server (displayed in the "webGui"). */
comment: string;
/** Is the array's config valid. */
configValid: boolean;
/** @internal used to hold the value for config.error */
configState: string;
/** Current CSRF token for HTTP requests with emhttpd. */
csrfToken: string;
defaultFormat: string;
/** Default file system for data disks. */
defaultFsType: DiskFsType.XFS;
/** Amount of connected drives (license device count). */
deviceCount: number;
domain: string;
domainLogin: string;
domainShort: string;
flashGuid: string;
flashProduct: string;
flashVendor: string;
/** Current progress of the {@link ?content=mover | mover}. */
fsCopyPrcnt: number;
fsNumMounted: number;
fsNumUnmountable: number;
fsProgress: string;
/** Current state of the array. */
fsState: string;
fsUnmountableMask: string;
fuseDirectio: string;
fuseDirectioDefault: string;
fuseDirectioStatus: string;
fuseRemember: string;
fuseRememberDefault: string;
fuseRememberStatus: string;
hideDotFiles: boolean;
// JoinStatus
localMaster: boolean;
/** The local tld to use e.g. `.local`. */
localTld: string;
/** Absolute file path to the data disks' luks key. */
luksKeyfile: string;
maxArraysz: number; /** Max amount of data drives allowed in the array. */
maxCachesz: number; /** Max amount of cache drives allowed in the array. */
mdColor: string;
/** The amount of {@link ?content=array#disks-disabled | disabled disks} from the current array. */
mdNumDisabled: number;
mdNumDisks: number;
mdNumErased: number;
/** The amount of {@link ?content=array#disks-invalid | invalid disks} from the current array. */
mdNumInvalid: number;
/** The amount of {@link ?content=array#disks-missing | missing disks} from the current array. */
mdNumMissing: number;
mdNumNew: number;
mdNumStripes: number;
mdNumStripesDefault: number;
mdNumStripesStatus: string;
mdResync: number;
mdResyncAction: string;
mdResyncCorr: string;
mdResyncDb: string;
mdResyncDt: string;
mdResyncPos: number;
mdResyncSize: number;
mdState: ArrayState;
mdSyncThresh: number;
mdSyncThreshDefault: number;
mdSyncThreshStatus: string;
mdSyncWindow: number;
mdSyncWindowDefault: number;
mdSyncWindowStatus: string;
mdVersion: string;
mdWriteMethod: number;
mdWriteMethodDefault: string;
mdWriteMethodStatus: string;
/** Machine hostname. */
name: string;
// NrRequests
nrRequests: number;
// NrRequestsDefault
nrRequestsDefault: number;
// NrRequestsStatus
/** NTP Server 1. */
ntpServer1: string;
/** NTP Server 2. */
ntpServer2: string;
/** NTP Server 3. */
ntpServer3: string;
/** NTP Server 4. */
ntpServer4: string;
pollAttributes: string;
pollAttributesDefault: string;
pollAttributesStatus: string;
/** Port for the webui via HTTP. */
port: number;
/** Port for SSH daemon. */
portssh: number;
/** Port for the webui via HTTPS. */
portssl: number;
/** Port for telnet daemon. */
porttelnet: number;
queueDepth: string;
regCheck: string;
regState: RegistrationState;
/** Where the registration key is stored. (e.g. "/boot/config/Pro.key") */
regFile: string;
regGen: string;
regGuid: string;
/** Registration time for key */
regTm: string;
/** Expiration of license for Trial Keys */
regTm2: string;
/** Expiration of Updates for non-legacy keys */
regExp: string | null;
/** Who the current Unraid key is registered to. */
regTo: string;
/** Which type of key this is. */
regTy: registrationType;
/** Is the server currently in safe mode. */
safeMode: boolean;
sbClean: boolean;
sbEvents: number;
sbName: string;
sbNumDisks: number;
sbState: string;
sbSynced: number;
sbSynced2: number;
sbSyncErrs: number;
sbSyncExit: string;
sbUpdated: string;
sbVersion: string;
security: string;
shareAvahiEnabled: boolean;
shareAvahiSmbModel: string;
shareAvahiSmbName: string;
shareCacheEnabled: boolean;
shareCacheFloor: string;
/** Total number of disk/user shares. */
shareCount: number;
shareDisk: string;
shareInitialGroup: string;
shareInitialOwner: string;
/** If the {@link ?content=mover | mover} is currently active. */
shareMoverActive: boolean;
shareMoverLogging: boolean;
/** When the share mover script should run. Takes cron format time. */
shareMoverSchedule: string;
/** Total number of NFS shares. */
shareNfsCount: number;
shareNfsEnabled: boolean;
/** Total number of SMB shares. */
shareSmbCount: number;
/** Is smb enabled */
shareSmbEnabled: boolean;
/** Which mode is smb running in? active-directory | workgroup */
shareSmbMode: string;
shareUser: string;
// ShareUserExclude
shutdownTimeout: number;
/** How long until emhttpd should spin down the data drives in your array. */
spindownDelay: number;
spinupGroups: boolean;
/** Should the array be started by default on boot. */
startArray: boolean;
/** The default start mode for the server. */
startMode: string;
/** Which page to start the webGui on. */
startPage: string;
sysArraySlots: number;
sysCacheSlots: number;
sysFlashSlots: number;
sysModel: string;
/** Current timezone. {@link https://en.wikipedia.org/wiki/List_of_tz_database_time_zones | Timezone list}. */
timeZone: string;
/** Should a NTP server be used for time sync. */
useNtp: boolean;
/** Should SSH be enabled. */
useSsh: boolean;
/** If HTTPS should be be enabled in the webui. */
useSsl: boolean | null;
/** Should telnet be enabled. */
useTelnet: boolean;
/** The current Unraid version. */
version: string;
/** The SMB workgroup. */
workgroup: string;
/** UPNP Setting */
useUpnp: boolean;
bindMgt: boolean | null;
cacheNumDevices: number;
cacheSbNumDisks: number;
/** Description of your server (displayed in the "webGui"). */
comment: string;
/** Is the array's config valid. */
configValid: boolean;
/** @internal used to hold the value for config.error */
configState: string;
/** Current CSRF token for HTTP requests with emhttpd. */
csrfToken: string;
defaultFormat: string;
/** Default file system for data disks. */
defaultFsType: DiskFsType.XFS;
/** Amount of connected drives (license device count). */
deviceCount: number;
domain: string;
domainLogin: string;
domainShort: string;
flashGuid: string;
flashProduct: string;
flashVendor: string;
/** Current progress of the {@link ?content=mover | mover}. */
fsCopyPrcnt: number;
fsNumMounted: number;
fsNumUnmountable: number;
fsProgress: string;
/** Current state of the array. */
fsState: string;
fsUnmountableMask: string;
fuseDirectio: string;
fuseDirectioDefault: string;
fuseDirectioStatus: string;
fuseRemember: string;
fuseRememberDefault: string;
fuseRememberStatus: string;
hideDotFiles: boolean;
// JoinStatus
localMaster: boolean;
/** The local tld to use e.g. `.local`. */
localTld: string;
/** Absolute file path to the data disks' luks key. */
luksKeyfile: string;
maxArraysz: number /** Max amount of data drives allowed in the array. */;
maxCachesz: number /** Max amount of cache drives allowed in the array. */;
mdColor: string;
/** The amount of {@link ?content=array#disks-disabled | disabled disks} from the current array. */
mdNumDisabled: number;
mdNumDisks: number;
mdNumErased: number;
/** The amount of {@link ?content=array#disks-invalid | invalid disks} from the current array. */
mdNumInvalid: number;
/** The amount of {@link ?content=array#disks-missing | missing disks} from the current array. */
mdNumMissing: number;
mdNumNew: number;
mdNumStripes: number;
mdNumStripesDefault: number;
mdNumStripesStatus: string;
mdResync: number;
mdResyncAction: string;
mdResyncCorr: string;
mdResyncDb: string;
mdResyncDt: string;
mdResyncPos: number;
mdResyncSize: number;
mdState: ArrayState;
mdSyncThresh: number;
mdSyncThreshDefault: number;
mdSyncThreshStatus: string;
mdSyncWindow: number;
mdSyncWindowDefault: number;
mdSyncWindowStatus: string;
mdVersion: string;
mdWriteMethod: number;
mdWriteMethodDefault: string;
mdWriteMethodStatus: string;
/** Machine hostname. */
name: string;
// NrRequests
nrRequests: number;
// NrRequestsDefault
nrRequestsDefault: number;
// NrRequestsStatus
/** NTP Server 1. */
ntpServer1: string;
/** NTP Server 2. */
ntpServer2: string;
/** NTP Server 3. */
ntpServer3: string;
/** NTP Server 4. */
ntpServer4: string;
pollAttributes: string;
pollAttributesDefault: string;
pollAttributesStatus: string;
/** Port for the webui via HTTP. */
port: number;
/** Port for SSH daemon. */
portssh: number;
/** Port for the webui via HTTPS. */
portssl: number;
/** Port for telnet daemon. */
porttelnet: number;
queueDepth: string;
regCheck: string;
regState: RegistrationState;
/** Where the registration key is stored. (e.g. "/boot/config/Pro.key") */
regFile: string;
regGen: string;
regGuid: string;
/** Registration time for key */
regTm: string;
/** Expiration of license for Trial Keys */
regTm2: string;
/** Expiration of Updates for non-legacy keys */
regExp: string | null;
/** Who the current Unraid key is registered to. */
regTo: string;
/** Which type of key this is. */
regTy: registrationType;
/** Is the server currently in safe mode. */
safeMode: boolean;
sbClean: boolean;
sbEvents: number;
sbName: string;
sbNumDisks: number;
sbState: string;
sbSynced: number;
sbSynced2: number;
sbSyncErrs: number;
sbSyncExit: string;
sbUpdated: string;
sbVersion: string;
security: string;
shareAvahiEnabled: boolean;
shareAvahiSmbModel: string;
shareAvahiSmbName: string;
shareCacheEnabled: boolean;
shareCacheFloor: string;
/** Total number of disk/user shares. */
shareCount: number;
shareDisk: string;
shareInitialGroup: string;
shareInitialOwner: string;
/** If the {@link ?content=mover | mover} is currently active. */
shareMoverActive: boolean;
shareMoverLogging: boolean;
/** When the share mover script should run. Takes cron format time. */
shareMoverSchedule: string;
/** Total number of NFS shares. */
shareNfsCount: number;
shareNfsEnabled: boolean;
/** Total number of SMB shares. */
shareSmbCount: number;
/** Is smb enabled */
shareSmbEnabled: boolean;
/** Which mode is smb running in? active-directory | workgroup */
shareSmbMode: string;
shareUser: string;
// ShareUserExclude
shutdownTimeout: number;
/** How long until emhttpd should spin down the data drives in your array. */
spindownDelay: number;
spinupGroups: boolean;
/** Should the array be started by default on boot. */
startArray: boolean;
/** The default start mode for the server. */
startMode: string;
/** Which page to start the webGui on. */
startPage: string;
sysArraySlots: number;
sysCacheSlots: number;
sysFlashSlots: number;
sysModel: string;
/** Current timezone. {@link https://en.wikipedia.org/wiki/List_of_tz_database_time_zones | Timezone list}. */
timeZone: string;
/** Should a NTP server be used for time sync. */
useNtp: boolean;
/** Should SSH be enabled. */
useSsh: boolean;
/** If HTTPS should be be enabled in the webui. */
useSsl: boolean | null;
/** Should telnet be enabled. */
useTelnet: boolean;
/** The current Unraid version. */
version: string;
/** The SMB workgroup. */
workgroup: string;
/** UPNP Setting */
useUpnp: boolean;
};

View File

@@ -5,6 +5,6 @@ import { getters } from '@app/store';
* Is the array running?
*/
export const arrayIsRunning = () => {
const emhttp = getters.emhttp();
return emhttp.var.mdState === ArrayState.STARTED;
const emhttp = getters.emhttp();
return emhttp.var.mdState === ArrayState.STARTED;
};

View File

@@ -1,4 +1,5 @@
import { convert, type Data } from 'convert';
import type { Data } from 'convert';
import { convert } from 'convert';
// If it's "true", "yes" or "1" then it's true otherwise it's false
export const toBoolean = (value: string): boolean =>

View File

@@ -2,10 +2,10 @@ import Docker from 'dockerode';
const socketPath = '/var/run/docker.sock';
const client = new Docker({
socketPath,
socketPath,
});
/**
* Docker client
*/
export const docker = client;
export const docker = client;

Some files were not shown because too many files have changed in this diff Show More