Compare commits

...

8 Commits

Author SHA1 Message Date
renovate[bot]
5fb34f3c91 Merge d159d4b0dd into 4168f43e3e 2025-07-09 15:03:20 +00:00
renovate[bot]
d159d4b0dd fix(deps): update all non-major dependencies 2025-07-09 15:03:17 +00:00
Pujit Mehrotra
4168f43e3e fix: duplicated header logo after api stops (#1493)
Move legacy header logo omission from API file modifier to plg install
step to avoid breaking rollback (ie upon `unraid-api stop`) due to web
component upgrade.

Tested on 7.1.4 & 7.2.0-beta.0.16

Testing & Reproduction procedure:

1. Install connect plugin
2. Run `unraid-api stop` on server
3. Refresh page. Before Unraid 7.2, Plugin versions prior to this will
display a duplicated logo that blocks the nav menu. Now, it will not.
Plugin uninstall behavior remains unchanged.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* The plugin installation process now updates the header logo by
removing the old logo from the interface during installation.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-09 10:59:10 -04:00
github-actions[bot]
20de3ec8d6 chore(main): release 4.9.2 (#1488)
🤖 I have created a release *beep* *boop*
---


## [4.9.2](https://github.com/unraid/api/compare/v4.9.1...v4.9.2)
(2025-07-09)


### Bug Fixes

* invalid configs no longer crash API
([#1491](https://github.com/unraid/api/issues/1491))
([6bf3f77](6bf3f77638))
* invalid state for unraid plugin
([#1492](https://github.com/unraid/api/issues/1492))
([39b8f45](39b8f453da))
* release note escaping
([5b6bcb6](5b6bcb6043))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-07-09 09:23:25 -04:00
Eli Bosley
39b8f453da fix: invalid state for unraid plugin (#1492)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Improved connection status handling by introducing a new service that
writes connection status to a JSON file for enhanced integration.
* Updated system components to read connection status and allowed
origins from the new JSON file, ensuring more reliable and up-to-date
information.

* **Chores**
* Expanded allowed Bash command permissions to include commands starting
with "mv:".
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-09 09:21:43 -04:00
Eli Bosley
6bf3f77638 fix: invalid configs no longer crash API (#1491)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Bug Fixes**
* Improved error handling when loading and parsing configuration files,
preventing crashes and ensuring fallback to default settings if issues
occur.
* Enhanced logging for configuration errors, including warnings for
empty files and detailed error messages for JSON parsing failures.
* Added error handling to plugin listing to avoid failures when
configuration loading encounters errors.

* **Chores**
* Updated permissions to allow linting only for the `./api` package
using a filtered command.

* **Tests**
* Added comprehensive tests for configuration loading, parsing,
persistence, and updating, covering various file states and error
scenarios.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-09 08:50:43 -04:00
Eli Bosley
a79d049865 chore: lint for renovate PR (#1481) 2025-07-08 17:01:49 -04:00
Eli Bosley
5b6bcb6043 fix: release note escaping 2025-07-08 16:41:05 -04:00
36 changed files with 2541 additions and 1572 deletions

View File

@@ -9,7 +9,9 @@
"Bash(pnpm test:*)",
"Bash(grep:*)",
"Bash(pnpm type-check:*)",
"Bash(pnpm lint:*)"
"Bash(pnpm lint:*)",
"Bash(pnpm --filter ./api lint)",
"Bash(mv:*)"
]
},
"enableAllProjectMcpServers": false

View File

@@ -33,7 +33,7 @@ jobs:
run_install: false
- name: Cache APT Packages
uses: awalsh128/cache-apt-pkgs-action@v1.4.3
uses: awalsh128/cache-apt-pkgs-action@v1.5.1
with:
packages: bash procps python3 libvirt-dev jq zstd git build-essential libvirt-daemon-system
version: 1.0

View File

@@ -45,7 +45,7 @@ jobs:
node-version-file: ".nvmrc"
- name: Cache APT Packages
uses: awalsh128/cache-apt-pkgs-action@v1.4.3
uses: awalsh128/cache-apt-pkgs-action@v1.5.1
with:
packages: bash procps python3 libvirt-dev jq zstd git build-essential libvirt-daemon-system
version: 1.0
@@ -190,7 +190,7 @@ jobs:
${{ runner.os }}-pnpm-store-
- name: Cache APT Packages
uses: awalsh128/cache-apt-pkgs-action@v1.4.3
uses: awalsh128/cache-apt-pkgs-action@v1.5.1
with:
packages: bash procps python3 libvirt-dev jq zstd git build-essential
version: 1.0
@@ -267,7 +267,7 @@ jobs:
${{ runner.os }}-pnpm-store-
- name: Cache APT Packages
uses: awalsh128/cache-apt-pkgs-action@v1.4.3
uses: awalsh128/cache-apt-pkgs-action@v1.5.1
with:
packages: bash procps python3 libvirt-dev jq zstd git build-essential
version: 1.0

View File

@@ -32,7 +32,9 @@ jobs:
with:
node-version: '22.17.0'
- run: |
echo '${{ steps.release-info.outputs.body }}' >> release-notes.txt
cat << 'EOF' > release-notes.txt
${{ steps.release-info.outputs.body }}
EOF
- run: npm install html-escaper@2 xml2js
- name: Update Plugin Changelog
uses: actions/github-script@v7

View File

@@ -31,7 +31,7 @@ jobs:
python-version: "3.13.5"
- name: Cache APT Packages
uses: awalsh128/cache-apt-pkgs-action@v1.4.3
uses: awalsh128/cache-apt-pkgs-action@v1.5.1
with:
packages: libvirt-dev
version: 1.0

2
.nvmrc
View File

@@ -1 +1 @@
22.16.0
22.17.0

View File

@@ -1 +1 @@
{".":"4.9.1"}
{".":"4.9.2"}

View File

@@ -1,5 +1,14 @@
# Changelog
## [4.9.2](https://github.com/unraid/api/compare/v4.9.1...v4.9.2) (2025-07-09)
### Bug Fixes
* invalid configs no longer crash API ([#1491](https://github.com/unraid/api/issues/1491)) ([6bf3f77](https://github.com/unraid/api/commit/6bf3f776380edeff5133517e6aca223556e30144))
* invalid state for unraid plugin ([#1492](https://github.com/unraid/api/issues/1492)) ([39b8f45](https://github.com/unraid/api/commit/39b8f453da23793ef51f8e7f7196370aada8c5aa))
* release note escaping ([5b6bcb6](https://github.com/unraid/api/commit/5b6bcb6043a5269bff4dc28714d787a5a3f07e22))
## [4.9.1](https://github.com/unraid/api/compare/v4.9.0...v4.9.1) (2025-07-08)

View File

@@ -1,6 +1,6 @@
{
"name": "@unraid/api",
"version": "4.9.1",
"version": "4.9.2",
"main": "src/cli/index.ts",
"type": "module",
"corepack": {
@@ -10,7 +10,7 @@
"author": "Lime Technology, Inc. <unraid.net>",
"license": "GPL-2.0-or-later",
"engines": {
"pnpm": "10.12.4"
"pnpm": "10.13.1"
},
"scripts": {
"// Development": "",
@@ -57,7 +57,7 @@
"@as-integrations/fastify": "2.1.1",
"@fastify/cookie": "11.0.2",
"@fastify/helmet": "13.0.1",
"@graphql-codegen/client-preset": "4.8.2",
"@graphql-codegen/client-preset": "4.8.3",
"@graphql-tools/load-files": "7.0.1",
"@graphql-tools/merge": "9.0.24",
"@graphql-tools/schema": "10.0.23",
@@ -82,7 +82,7 @@
"accesscontrol": "2.2.1",
"bycontract": "2.0.11",
"bytes": "3.1.2",
"cache-manager": "7.0.0",
"cache-manager": "7.0.1",
"cacheable-lookup": "7.0.0",
"camelcase-keys": "9.1.3",
"casbin": "5.38.0",
@@ -138,11 +138,11 @@
"rxjs": "7.8.2",
"semver": "7.7.2",
"strftime": "0.10.3",
"systeminformation": "5.27.6",
"systeminformation": "5.27.7",
"uuid": "11.1.0",
"ws": "8.18.2",
"ws": "8.18.3",
"zen-observable-ts": "1.1.0",
"zod": "3.25.67"
"zod": "3.25.76"
},
"peerDependencies": {
"unraid-api-plugin-connect": "workspace:*"
@@ -153,12 +153,12 @@
}
},
"devDependencies": {
"@eslint/js": "9.29.0",
"@eslint/js": "9.30.1",
"@graphql-codegen/add": "5.0.3",
"@graphql-codegen/cli": "5.0.7",
"@graphql-codegen/fragment-matcher": "5.1.0",
"@graphql-codegen/import-types-preset": "3.0.1",
"@graphql-codegen/typed-document-node": "5.1.1",
"@graphql-codegen/typed-document-node": "5.1.2",
"@graphql-codegen/typescript": "4.1.6",
"@graphql-codegen/typescript-operations": "4.6.1",
"@graphql-codegen/typescript-resolvers": "4.5.1",
@@ -167,21 +167,21 @@
"@nestjs/testing": "11.1.3",
"@originjs/vite-plugin-commonjs": "1.0.3",
"@rollup/plugin-node-resolve": "16.0.1",
"@swc/core": "1.12.4",
"@swc/core": "1.12.11",
"@types/async-exit-hook": "2.0.2",
"@types/bytes": "3.1.5",
"@types/cli-table": "0.3.4",
"@types/command-exists": "1.2.3",
"@types/cors": "2.8.19",
"@types/dockerode": "3.3.41",
"@types/dockerode": "3.3.42",
"@types/graphql-fields": "1.3.9",
"@types/graphql-type-uuid": "0.2.6",
"@types/ini": "4.1.1",
"@types/ip": "1.1.3",
"@types/lodash": "4.17.18",
"@types/lodash": "4.17.20",
"@types/lodash-es": "4.17.12",
"@types/mustache": "4.2.6",
"@types/node": "22.15.32",
"@types/node": "22.16.2",
"@types/pify": "6.1.0",
"@types/semver": "7.7.0",
"@types/sendmail": "1.4.7",
@@ -193,27 +193,27 @@
"@vitest/coverage-v8": "3.2.4",
"@vitest/ui": "3.2.4",
"cz-conventional-changelog": "3.3.0",
"eslint": "9.29.0",
"eslint-plugin-import": "2.31.0",
"eslint-plugin-n": "17.20.0",
"eslint": "9.30.1",
"eslint-plugin-import": "2.32.0",
"eslint-plugin-n": "17.21.0",
"eslint-plugin-no-relative-import-paths": "1.6.1",
"eslint-plugin-prettier": "5.5.0",
"eslint-plugin-prettier": "5.5.1",
"graphql-codegen-typescript-validation-schema": "0.17.1",
"jiti": "2.4.2",
"nodemon": "3.1.10",
"prettier": "3.5.3",
"prettier": "3.6.2",
"rollup-plugin-node-externals": "8.0.1",
"commit-and-tag-version": "9.5.0",
"commit-and-tag-version": "9.6.0",
"tsx": "4.20.3",
"type-fest": "4.41.0",
"typescript": "5.8.3",
"typescript-eslint": "8.34.1",
"typescript-eslint": "8.36.0",
"unplugin-swc": "1.5.5",
"vite": "7.0.3",
"vite-plugin-node": "7.0.0",
"vite-tsconfig-paths": "5.1.4",
"vitest": "3.2.4",
"zx": "8.5.5"
"zx": "8.6.2"
},
"overrides": {
"eslint": {
@@ -228,5 +228,5 @@
}
},
"private": true,
"packageManager": "pnpm@10.12.4"
"packageManager": "pnpm@10.13.1"
}

View File

@@ -124,7 +124,15 @@ export const parseConfig = <T extends Record<string, any>>(
throw new AppError('Invalid Parameters Passed to ParseConfig');
}
const data: Record<string, any> = parseIni(fileContents);
let data: Record<string, any>;
try {
data = parseIni(fileContents);
} catch (error) {
throw new AppError(
`Failed to parse config file: ${error instanceof Error ? error.message : String(error)}`
);
}
// Remove quotes around keys
const dataWithoutQuoteKeys = Object.fromEntries(
Object.entries(data).map(([key, value]) => [key.replace(/^"(.+(?="$))"$/, '$1'), value])

View File

@@ -12,6 +12,8 @@ import { ConfigPersistenceHelper } from '@app/unraid-api/config/persistence.help
export { type ApiConfig };
const logger = new Logger('ApiConfig');
const createDefaultConfig = (): ApiConfig => ({
version: API_VERSION,
extraOrigins: [],
@@ -33,21 +35,54 @@ export const persistApiConfig = async (config: ApiConfig) => {
};
export const loadApiConfig = async () => {
const defaultConfig = createDefaultConfig();
const apiConfig = new ApiStateConfig<ApiConfig>(
{
name: 'api',
defaultConfig,
parse: (data) => data as ApiConfig,
},
new ConfigPersistenceHelper()
);
const diskConfig = await apiConfig.parseConfig();
return {
...defaultConfig,
...diskConfig,
version: API_VERSION,
};
try {
const defaultConfig = createDefaultConfig();
const apiConfig = new ApiStateConfig<ApiConfig>(
{
name: 'api',
defaultConfig,
parse: (data) => data as ApiConfig,
},
new ConfigPersistenceHelper()
);
let diskConfig: ApiConfig | undefined;
try {
diskConfig = await apiConfig.parseConfig();
} catch (error) {
logger.error('Failed to load API config from disk, using defaults:', error);
diskConfig = undefined;
// Try to overwrite the invalid config with defaults to fix the issue
try {
const configToWrite = {
...defaultConfig,
version: API_VERSION,
};
const writeSuccess = await apiConfig.persist(configToWrite);
if (writeSuccess) {
logger.log('Successfully overwrote invalid config file with defaults.');
} else {
logger.error(
'Failed to overwrite invalid config file. Continuing with defaults in memory only.'
);
}
} catch (persistError) {
logger.error('Error during config file repair:', persistError);
}
}
return {
...defaultConfig,
...diskConfig,
version: API_VERSION,
};
} catch (outerError) {
// This should never happen, but ensures the config factory never throws
logger.error('Critical error in loadApiConfig, using minimal defaults:', outerError);
return createDefaultConfig();
}
};
/**
@@ -81,21 +116,29 @@ export class ApiConfigPersistence {
}
async onModuleInit() {
if (!(await fileExists(this.filePath))) {
this.migrateFromMyServersConfig();
try {
if (!(await fileExists(this.filePath))) {
this.migrateFromMyServersConfig();
}
await this.persistenceHelper.persistIfChanged(this.filePath, this.config);
this.configService.changes$.pipe(bufferTime(25)).subscribe({
next: async (changes) => {
if (changes.some((change) => change.path.startsWith('api'))) {
this.logger.verbose(`API Config changed ${JSON.stringify(changes)}`);
try {
await this.persistenceHelper.persistIfChanged(this.filePath, this.config);
} catch (persistError) {
this.logger.error('Error persisting config changes:', persistError);
}
}
},
error: (err) => {
this.logger.error('Error receiving config changes:', err);
},
});
} catch (error) {
this.logger.error('Error during API config module initialization:', error);
}
await this.persistenceHelper.persistIfChanged(this.filePath, this.config);
this.configService.changes$.pipe(bufferTime(25)).subscribe({
next: async (changes) => {
if (changes.some((change) => change.path.startsWith('api'))) {
this.logger.verbose(`API Config changed ${JSON.stringify(changes)}`);
await this.persistenceHelper.persistIfChanged(this.filePath, this.config);
}
},
error: (err) => {
this.logger.error('Error receiving config changes:', err);
},
});
}
convertLegacyConfig(

View File

@@ -2,9 +2,26 @@ import { ConfigService } from '@nestjs/config';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ApiConfigPersistence } from '@app/unraid-api/config/api-config.module.js';
import { fileExists } from '@app/core/utils/files/file-exists.js';
import { ApiConfigPersistence, loadApiConfig } from '@app/unraid-api/config/api-config.module.js';
import { ConfigPersistenceHelper } from '@app/unraid-api/config/persistence.helper.js';
// Mock the core file-exists utility used by ApiStateConfig
vi.mock('@app/core/utils/files/file-exists.js', () => ({
fileExists: vi.fn(),
}));
// Mock the shared file-exists utility used by ConfigPersistenceHelper
vi.mock('@unraid/shared/util/file.js', () => ({
fileExists: vi.fn(),
}));
// Mock fs/promises for file I/O operations
vi.mock('fs/promises', () => ({
readFile: vi.fn(),
writeFile: vi.fn(),
}));
describe('ApiConfigPersistence', () => {
let service: ApiConfigPersistence;
let configService: ConfigService;
@@ -135,3 +152,127 @@ describe('ApiConfigPersistence', () => {
});
});
});
describe('loadApiConfig', () => {
let readFile: any;
let writeFile: any;
beforeEach(async () => {
vi.clearAllMocks();
// Reset modules to ensure fresh imports
vi.resetModules();
// Get mocked functions
const fsMocks = await import('fs/promises');
readFile = fsMocks.readFile;
writeFile = fsMocks.writeFile;
});
it('should return default config when file does not exist', async () => {
vi.mocked(fileExists).mockResolvedValue(false);
const result = await loadApiConfig();
expect(result).toEqual({
version: expect.any(String),
extraOrigins: [],
sandbox: false,
ssoSubIds: [],
plugins: [],
});
});
it('should merge disk config with defaults when file exists', async () => {
const diskConfig = {
extraOrigins: ['https://example.com'],
sandbox: true,
ssoSubIds: ['sub1', 'sub2'],
};
vi.mocked(fileExists).mockResolvedValue(true);
vi.mocked(readFile).mockResolvedValue(JSON.stringify(diskConfig));
const result = await loadApiConfig();
expect(result).toEqual({
version: expect.any(String),
extraOrigins: ['https://example.com'],
sandbox: true,
ssoSubIds: ['sub1', 'sub2'],
plugins: [],
});
});
it('should use default config and overwrite file when JSON parsing fails', async () => {
const { fileExists: sharedFileExists } = await import('@unraid/shared/util/file.js');
vi.mocked(fileExists).mockResolvedValue(true);
vi.mocked(readFile).mockResolvedValue('{ invalid json }');
vi.mocked(sharedFileExists).mockResolvedValue(false); // For persist operation
vi.mocked(writeFile).mockResolvedValue(undefined);
const result = await loadApiConfig();
// Error logging is handled by NestJS Logger, just verify the config is returned
expect(writeFile).toHaveBeenCalled();
expect(result).toEqual({
version: expect.any(String),
extraOrigins: [],
sandbox: false,
ssoSubIds: [],
plugins: [],
});
});
it('should handle write failure gracefully when JSON parsing fails', async () => {
const { fileExists: sharedFileExists } = await import('@unraid/shared/util/file.js');
vi.mocked(fileExists).mockResolvedValue(true);
vi.mocked(readFile).mockResolvedValue('{ invalid json }');
vi.mocked(sharedFileExists).mockResolvedValue(false); // For persist operation
vi.mocked(writeFile).mockRejectedValue(new Error('Permission denied'));
const result = await loadApiConfig();
// Error logging is handled by NestJS Logger, just verify the config is returned
expect(writeFile).toHaveBeenCalled();
expect(result).toEqual({
version: expect.any(String),
extraOrigins: [],
sandbox: false,
ssoSubIds: [],
plugins: [],
});
});
it('should use default config when file is empty', async () => {
vi.mocked(fileExists).mockResolvedValue(true);
vi.mocked(readFile).mockResolvedValue('');
const result = await loadApiConfig();
// No error logging expected for empty files
expect(result).toEqual({
version: expect.any(String),
extraOrigins: [],
sandbox: false,
ssoSubIds: [],
plugins: [],
});
});
it('should always override version with current API_VERSION', async () => {
const diskConfig = {
version: 'old-version',
extraOrigins: ['https://example.com'],
};
vi.mocked(fileExists).mockResolvedValue(true);
vi.mocked(readFile).mockResolvedValue(JSON.stringify(diskConfig));
const result = await loadApiConfig();
expect(result.version).not.toBe('old-version');
expect(result.version).toBeTruthy();
});
});

View File

@@ -0,0 +1,364 @@
import { Logger } from '@nestjs/common';
import { readFile } from 'node:fs/promises';
import { join } from 'path';
import type { Mock } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { fileExists } from '@app/core/utils/files/file-exists.js';
import { ApiStateConfig } from '@app/unraid-api/config/factory/api-state.model.js';
import { ConfigPersistenceHelper } from '@app/unraid-api/config/persistence.helper.js';
vi.mock('node:fs/promises');
vi.mock('@app/core/utils/files/file-exists.js');
vi.mock('@app/environment.js', () => ({
PATHS_CONFIG_MODULES: '/test/config/path',
}));
describe('ApiStateConfig', () => {
let mockPersistenceHelper: ConfigPersistenceHelper;
let mockLogger: Logger;
interface TestConfig {
name: string;
value: number;
enabled: boolean;
}
const defaultConfig: TestConfig = {
name: 'test',
value: 42,
enabled: true,
};
const parseFunction = (data: unknown): TestConfig => {
if (!data || typeof data !== 'object') {
throw new Error('Invalid config format');
}
return data as TestConfig;
};
beforeEach(() => {
vi.clearAllMocks();
mockPersistenceHelper = {
persistIfChanged: vi.fn().mockResolvedValue(true),
} as any;
mockLogger = {
log: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
} as any;
vi.spyOn(Logger.prototype, 'log').mockImplementation(mockLogger.log);
vi.spyOn(Logger.prototype, 'warn').mockImplementation(mockLogger.warn);
vi.spyOn(Logger.prototype, 'error').mockImplementation(mockLogger.error);
vi.spyOn(Logger.prototype, 'debug').mockImplementation(mockLogger.debug);
});
describe('constructor', () => {
it('should initialize with cloned default config', () => {
const config = new ApiStateConfig(
{
name: 'test-config',
defaultConfig,
parse: parseFunction,
},
mockPersistenceHelper
);
expect(config.config).toEqual(defaultConfig);
expect(config.config).not.toBe(defaultConfig);
});
});
describe('token', () => {
it('should generate correct token', () => {
const config = new ApiStateConfig(
{
name: 'my-config',
defaultConfig,
parse: parseFunction,
},
mockPersistenceHelper
);
expect(config.token).toBe('ApiConfig.my-config');
});
});
describe('file paths', () => {
it('should generate correct file name', () => {
const config = new ApiStateConfig(
{
name: 'test-config',
defaultConfig,
parse: parseFunction,
},
mockPersistenceHelper
);
expect(config.fileName).toBe('test-config.json');
});
it('should generate correct file path', () => {
const config = new ApiStateConfig(
{
name: 'test-config',
defaultConfig,
parse: parseFunction,
},
mockPersistenceHelper
);
expect(config.filePath).toBe(join('/test/config/path', 'test-config.json'));
});
});
describe('parseConfig', () => {
let config: ApiStateConfig<TestConfig>;
beforeEach(() => {
config = new ApiStateConfig(
{
name: 'test-config',
defaultConfig,
parse: parseFunction,
},
mockPersistenceHelper
);
});
it('should return undefined when file does not exist', async () => {
(fileExists as Mock).mockResolvedValue(false);
const result = await config.parseConfig();
expect(result).toBeUndefined();
expect(readFile).not.toHaveBeenCalled();
});
it('should parse valid JSON config', async () => {
const validConfig = { name: 'custom', value: 100, enabled: false };
(fileExists as Mock).mockResolvedValue(true);
(readFile as Mock).mockResolvedValue(JSON.stringify(validConfig));
const result = await config.parseConfig();
expect(result).toEqual(validConfig);
expect(readFile).toHaveBeenCalledWith(config.filePath, 'utf8');
});
it('should return undefined for empty file', async () => {
(fileExists as Mock).mockResolvedValue(true);
(readFile as Mock).mockResolvedValue('');
const result = await config.parseConfig();
expect(result).toBeUndefined();
expect(mockLogger.warn).toHaveBeenCalledWith(expect.stringContaining('is empty'));
});
it('should return undefined for whitespace-only file', async () => {
(fileExists as Mock).mockResolvedValue(true);
(readFile as Mock).mockResolvedValue(' \n\t ');
const result = await config.parseConfig();
expect(result).toBeUndefined();
expect(mockLogger.warn).toHaveBeenCalledWith(expect.stringContaining('is empty'));
});
it('should throw error for invalid JSON', async () => {
(fileExists as Mock).mockResolvedValue(true);
(readFile as Mock).mockResolvedValue('{ invalid json }');
await expect(config.parseConfig()).rejects.toThrow();
expect(mockLogger.error).toHaveBeenCalledWith(
expect.stringContaining('Failed to parse JSON')
);
expect(mockLogger.debug).toHaveBeenCalledWith(expect.stringContaining('{ invalid json }'));
});
it('should throw error for incomplete JSON', async () => {
(fileExists as Mock).mockResolvedValue(true);
(readFile as Mock).mockResolvedValue('{ "name": "test"');
await expect(config.parseConfig()).rejects.toThrow();
expect(mockLogger.error).toHaveBeenCalledWith(
expect.stringContaining('Failed to parse JSON')
);
});
it('should use custom file path when provided', async () => {
const customPath = '/custom/path/config.json';
(fileExists as Mock).mockResolvedValue(true);
(readFile as Mock).mockResolvedValue(JSON.stringify(defaultConfig));
await config.parseConfig({ filePath: customPath });
expect(fileExists).toHaveBeenCalledWith(customPath);
expect(readFile).toHaveBeenCalledWith(customPath, 'utf8');
});
});
describe('persist', () => {
let config: ApiStateConfig<TestConfig>;
beforeEach(() => {
config = new ApiStateConfig(
{
name: 'test-config',
defaultConfig,
parse: parseFunction,
},
mockPersistenceHelper
);
});
it('should persist current config when no argument provided', async () => {
const result = await config.persist();
expect(result).toBe(true);
expect(mockPersistenceHelper.persistIfChanged).toHaveBeenCalledWith(
config.filePath,
defaultConfig
);
});
it('should persist provided config', async () => {
const customConfig = { name: 'custom', value: 999, enabled: false };
const result = await config.persist(customConfig);
expect(result).toBe(true);
expect(mockPersistenceHelper.persistIfChanged).toHaveBeenCalledWith(
config.filePath,
customConfig
);
});
it('should return false and log error on persistence failure', async () => {
(mockPersistenceHelper.persistIfChanged as Mock).mockResolvedValue(false);
const result = await config.persist();
expect(result).toBe(false);
expect(mockLogger.error).toHaveBeenCalledWith(
expect.stringContaining('Could not write config')
);
});
});
describe('load', () => {
let config: ApiStateConfig<TestConfig>;
beforeEach(() => {
config = new ApiStateConfig(
{
name: 'test-config',
defaultConfig,
parse: parseFunction,
},
mockPersistenceHelper
);
});
it('should load config from file when it exists', async () => {
const savedConfig = { name: 'saved', value: 200, enabled: true };
(fileExists as Mock).mockResolvedValue(true);
(readFile as Mock).mockResolvedValue(JSON.stringify(savedConfig));
await config.load();
expect(config.config).toEqual(savedConfig);
});
it('should create default config when file does not exist', async () => {
(fileExists as Mock).mockResolvedValue(false);
await config.load();
expect(config.config).toEqual(defaultConfig);
expect(mockLogger.log).toHaveBeenCalledWith(
expect.stringContaining('Config file does not exist')
);
expect(mockPersistenceHelper.persistIfChanged).toHaveBeenCalledWith(
config.filePath,
defaultConfig
);
});
it('should not modify config when file is invalid', async () => {
(fileExists as Mock).mockResolvedValue(true);
(readFile as Mock).mockResolvedValue('invalid json');
await config.load();
expect(config.config).toEqual(defaultConfig);
expect(mockLogger.warn).toHaveBeenCalledWith(
expect.any(Error),
expect.stringContaining('is invalid')
);
});
it('should not throw even when persist fails', async () => {
(fileExists as Mock).mockResolvedValue(false);
(mockPersistenceHelper.persistIfChanged as Mock).mockResolvedValue(false);
await expect(config.load()).resolves.not.toThrow();
expect(config.config).toEqual(defaultConfig);
});
});
describe('update', () => {
let config: ApiStateConfig<TestConfig>;
beforeEach(() => {
config = new ApiStateConfig(
{
name: 'test-config',
defaultConfig,
parse: parseFunction,
},
mockPersistenceHelper
);
});
it('should update config with partial values', () => {
config.update({ value: 123 });
expect(config.config).toEqual({
name: 'test',
value: 123,
enabled: true,
});
});
it('should return self for chaining', () => {
const result = config.update({ enabled: false });
expect(result).toBe(config);
});
it('should validate updated config through parse function', () => {
const badParseFunction = vi.fn().mockImplementation(() => {
throw new Error('Validation failed');
});
const strictConfig = new ApiStateConfig(
{
name: 'strict-config',
defaultConfig,
parse: badParseFunction,
},
mockPersistenceHelper
);
expect(() => strictConfig.update({ value: -1 })).toThrow('Validation failed');
});
});
});

View File

@@ -56,13 +56,11 @@ export class ApiStateConfig<T> {
* @returns True if the config was written successfully, false otherwise.
*/
async persist(config = this.#config) {
try {
await this.persistenceHelper.persistIfChanged(this.filePath, config);
return true;
} catch (error) {
this.logger.error(error, `Could not write config to ${this.filePath}.`);
return false;
const success = await this.persistenceHelper.persistIfChanged(this.filePath, config);
if (!success) {
this.logger.error(`Could not write config to ${this.filePath}.`);
}
return success;
}
/**
@@ -76,8 +74,23 @@ export class ApiStateConfig<T> {
const { filePath = this.filePath } = opts;
if (!(await fileExists(filePath))) return undefined;
const rawConfig = JSON.parse(await readFile(filePath, 'utf8'));
return this.options.parse(rawConfig);
const fileContent = await readFile(filePath, 'utf8');
if (!fileContent || fileContent.trim() === '') {
this.logger.warn(`Config file '${filePath}' is empty.`);
return undefined;
}
try {
const rawConfig = JSON.parse(fileContent);
return this.options.parse(rawConfig);
} catch (error) {
this.logger.error(
`Failed to parse JSON from '${filePath}': ${error instanceof Error ? error.message : String(error)}`
);
this.logger.debug(`File content: ${fileContent.substring(0, 100)}...`);
throw error;
}
}
/**

View File

@@ -12,24 +12,59 @@ export class ConfigPersistenceHelper {
*
* @param filePath - The path to the config file.
* @param data - The data to persist.
* @returns `true` if the config was persisted, `false` otherwise.
* @returns `true` if the config was persisted, `false` if no changes were needed or if persistence failed.
*
* @throws {Error} if the config file does not exist or is unreadable.
* @throws {Error} if the config file is not valid JSON.
* @throws {Error} if given data is not JSON (de)serializable.
* @throws {Error} if the config file is not writable.
* This method is designed to never throw errors. If the existing file is corrupted or unreadable,
* it will attempt to overwrite it with the new data. If write operations fail, it returns false
* but does not crash the application.
*/
async persistIfChanged(filePath: string, data: unknown): Promise<boolean> {
if (!(await fileExists(filePath))) {
await writeFile(filePath, JSON.stringify(data ?? {}, null, 2));
return true;
try {
const jsonString = JSON.stringify(data ?? {}, null, 2);
await writeFile(filePath, jsonString);
return true;
} catch (error) {
// JSON serialization or write failed, but don't crash - just return false
return false;
}
}
const currentData = JSON.parse(await readFile(filePath, 'utf8'));
const stagedData = JSON.parse(JSON.stringify(data));
let currentData: unknown;
try {
const fileContent = await readFile(filePath, 'utf8');
currentData = JSON.parse(fileContent);
} catch (error) {
// If existing file is corrupted, treat it as if it doesn't exist
// and write the new data
try {
const jsonString = JSON.stringify(data ?? {}, null, 2);
await writeFile(filePath, jsonString);
return true;
} catch (writeError) {
// JSON serialization or write failed, but don't crash - just return false
return false;
}
}
let stagedData: unknown;
try {
stagedData = JSON.parse(JSON.stringify(data));
} catch (error) {
// If data can't be serialized to JSON, we can't persist it
return false;
}
if (isEqual(currentData, stagedData)) {
return false;
}
await writeFile(filePath, JSON.stringify(stagedData, null, 2));
return true;
try {
await writeFile(filePath, JSON.stringify(stagedData, null, 2));
return true;
} catch (error) {
// Write failed, but don't crash - just return false
return false;
}
}
}

View File

@@ -65,7 +65,16 @@ export class PluginService {
* @returns A tuple of the plugin name and version.
*/
static async listPlugins(): Promise<[string, string][]> {
const { plugins = [] } = await loadApiConfig();
let plugins: string[] = [];
try {
const config = await loadApiConfig();
plugins = config.plugins || [];
} catch (error) {
PluginService.logger.error(
'Failed to load API config for plugin discovery, using empty list:',
error
);
}
const pluginNames = new Set(
plugins.map((plugin) => {
const { name } = parsePackageArg(plugin);

View File

@@ -711,7 +711,7 @@ $.ajaxPrefilter(function(s, orig, xhr){
<div class="upgrade_notice" style="display:none"></div>
<div id="header" class="<?=$display['banner']?>">
<div class="logo">
<a href="https://unraid.net" target="_blank"><?readfile("$docroot/webGui/images/UN-logotype-gradient.svg")?></a>
<unraid-i18n-host><unraid-header-os-version></unraid-header-os-version></unraid-i18n-host>
</div>
<?include "$docroot/plugins/dynamix.my.servers/include/myservers2.php"?>

View File

@@ -65,13 +65,6 @@ if (is_localhost() && !is_good_session()) {
return this.prependDoctypeWithPhp(source, newPhpCode);
}
private hideHeaderLogo(source: string): string {
return source.replace(
'<a href="https://unraid.net" target="_blank"><?readfile("$docroot/webGui/images/UN-logotype-gradient.svg")?></a>',
''
);
}
private addModalsWebComponent(source: string): string {
return source.replace('<body>', '<body>\n<unraid-modals></unraid-modals>');
}
@@ -81,7 +74,6 @@ if (is_localhost() && !is_good_session()) {
this.replaceToasts.bind(this),
this.addToaster.bind(this),
this.patchGuiBootAuth.bind(this),
this.hideHeaderLogo.bind(this),
this.addModalsWebComponent.bind(this),
];

View File

@@ -53,7 +53,7 @@ Index: /usr/local/emhttp/plugins/dynamix/include/DefaultPageLayout.php
}
function closeNotifier() {
@@ -695,15 +704,16 @@
@@ -695,10 +704,11 @@
});
</script>
<?include "$docroot/plugins/dynamix.my.servers/include/myservers1.php"?>
@@ -64,13 +64,7 @@ Index: /usr/local/emhttp/plugins/dynamix/include/DefaultPageLayout.php
<div class="upgrade_notice" style="display:none"></div>
<div id="header" class="<?=$display['banner']?>">
<div class="logo">
- <a href="https://unraid.net" target="_blank"><?readfile("$docroot/webGui/images/UN-logotype-gradient.svg")?></a>
+
<unraid-i18n-host><unraid-header-os-version></unraid-header-os-version></unraid-i18n-host>
</div>
<?include "$docroot/plugins/dynamix.my.servers/include/myservers2.php"?>
</div>
<a href="#" class="move_to_end" title="<?=_('Move To End')?>"><i class="fa fa-arrow-circle-down"></i></a>
<a href="https://unraid.net" target="_blank"><?readfile("$docroot/webGui/images/UN-logotype-gradient.svg")?></a>
@@ -748,12 +758,12 @@
}
// create list of nchan scripts to be started

View File

@@ -1,7 +1,7 @@
{
"name": "unraid-monorepo",
"private": true,
"version": "4.9.1",
"version": "4.9.2",
"scripts": {
"build": "pnpm -r build",
"build:watch": " pnpm -r --parallel build:watch",
@@ -57,5 +57,5 @@
"pnpm lint:fix"
]
},
"packageManager": "pnpm@10.12.4"
"packageManager": "pnpm@10.13.1"
}

View File

@@ -25,7 +25,7 @@
"description": "Unraid Connect plugin for Unraid API",
"devDependencies": {
"@apollo/client": "3.13.8",
"@faker-js/faker": "9.8.0",
"@faker-js/faker": "9.9.0",
"@graphql-codegen/cli": "5.0.7",
"@graphql-typed-document-node/core": "3.2.0",
"@ianvs/prettier-plugin-sort-imports": "4.4.2",
@@ -41,13 +41,13 @@
"@types/ini": "4.1.1",
"@types/ip": "1.1.3",
"@types/lodash-es": "4.17.12",
"@types/node": "22.15.32",
"@types/node": "22.16.2",
"@types/ws": "8.18.1",
"camelcase-keys": "9.1.3",
"class-transformer": "0.5.1",
"class-validator": "0.14.2",
"execa": "9.6.0",
"fast-check": "4.1.1",
"fast-check": "4.2.0",
"got": "14.4.7",
"graphql": "16.11.0",
"graphql-scalars": "1.24.2",
@@ -57,13 +57,13 @@
"jose": "6.0.11",
"lodash-es": "4.17.21",
"nest-authz": "2.17.0",
"prettier": "3.5.3",
"prettier": "3.6.2",
"rimraf": "6.0.1",
"rxjs": "7.8.2",
"type-fest": "4.41.0",
"typescript": "5.8.3",
"vitest": "3.2.4",
"ws": "8.18.2",
"ws": "8.18.3",
"zen-observable-ts": "1.1.0"
},
"dependencies": {

View File

@@ -0,0 +1,69 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { writeFile } from 'fs/promises';
import { ConnectionMetadata, ConfigType } from './connect.config.js';
@Injectable()
export class ConnectStatusWriterService implements OnModuleInit {
constructor(private readonly configService: ConfigService<ConfigType, true>) {}
private logger = new Logger(ConnectStatusWriterService.name);
get statusFilePath() {
// Write to /var/local/emhttp/connectStatus.json so PHP can read it
return '/var/local/emhttp/connectStatus.json';
}
async onModuleInit() {
this.logger.verbose(`Status file path: ${this.statusFilePath}`);
// Write initial status
await this.writeStatus();
// Listen for changes to connection status
this.configService.changes$.subscribe({
next: async (change) => {
const connectionChanged = change.path && change.path.startsWith('connect.mothership');
if (connectionChanged) {
await this.writeStatus();
}
},
error: (err) => {
this.logger.error('Error receiving config changes:', err);
},
});
}
private async writeStatus() {
try {
const connectionMetadata = this.configService.get<ConnectionMetadata>('connect.mothership');
// Try to get allowed origins from the store
let allowedOrigins = '';
try {
// We can't import from @app here, so we'll skip allowed origins for now
// This can be added later if needed
allowedOrigins = '';
} catch (error) {
this.logger.debug('Could not get allowed origins:', error);
}
const statusData = {
connectionStatus: connectionMetadata?.status || 'PRE_INIT',
error: connectionMetadata?.error || null,
lastPing: connectionMetadata?.lastPing || null,
allowedOrigins: allowedOrigins,
timestamp: Date.now()
};
const data = JSON.stringify(statusData, null, 2);
this.logger.verbose(`Writing connection status: ${data}`);
await writeFile(this.statusFilePath, data);
this.logger.verbose(`Status written to ${this.statusFilePath}`);
} catch (error) {
this.logger.error(error, `Error writing status to '${this.statusFilePath}'`);
}
}
}

View File

@@ -3,6 +3,7 @@ import { ConfigModule, ConfigService } from '@nestjs/config';
import { ConnectConfigPersister } from './config/config.persistence.js';
import { configFeature } from './config/connect.config.js';
import { ConnectStatusWriterService } from './config/connect-status-writer.service.js';
import { MothershipModule } from './mothership-proxy/mothership.module.js';
import { ConnectModule } from './unraid-connect/connect.module.js';
@@ -10,7 +11,7 @@ export const adapter = 'nestjs';
@Module({
imports: [ConfigModule.forFeature(configFeature), ConnectModule, MothershipModule],
providers: [ConnectConfigPersister],
providers: [ConnectConfigPersister, ConnectStatusWriterService],
exports: [],
})
class ConnectPluginModule {

View File

@@ -15,7 +15,7 @@
"commander": "14.0.0",
"create-create-app": "7.3.0",
"fs-extra": "11.3.0",
"inquirer": "12.6.3",
"inquirer": "12.7.0",
"validate-npm-package-name": "6.0.1"
},
"devDependencies": {
@@ -25,7 +25,7 @@
"@nestjs/graphql": "13.1.0",
"@types/fs-extra": "11.0.4",
"@types/inquirer": "9.0.8",
"@types/node": "22.15.32",
"@types/node": "22.16.2",
"@types/validate-npm-package-name": "4.0.2",
"class-transformer": "0.5.1",
"class-validator": "0.14.2",

View File

@@ -31,9 +31,9 @@
"@jsonforms/core": "3.6.0",
"@nestjs/common": "11.1.3",
"@nestjs/graphql": "13.1.0",
"@types/bun": "1.2.16",
"@types/bun": "1.2.18",
"@types/lodash-es": "4.17.12",
"@types/node": "22.15.32",
"@types/node": "22.16.2",
"class-validator": "0.14.2",
"graphql": "16.11.0",
"graphql-scalars": "1.24.2",

View File

@@ -1,17 +1,17 @@
{
"name": "@unraid/connect-plugin",
"version": "4.9.1",
"version": "4.9.2",
"private": true,
"dependencies": {
"commander": "14.0.0",
"conventional-changelog": "6.0.0",
"date-fns": "4.1.0",
"glob": "11.0.1",
"glob": "11.0.3",
"html-sloppy-escaper": "0.1.0",
"semver": "7.7.1",
"tsx": "4.19.3",
"zod": "3.24.2",
"zx": "8.3.2"
"semver": "7.7.2",
"tsx": "4.20.3",
"zod": "3.25.76",
"zx": "8.6.2"
},
"type": "module",
"license": "GPL-2.0-or-later",
@@ -37,7 +37,7 @@
"devDependencies": {
"http-server": "14.1.1",
"nodemon": "3.1.10",
"vitest": "3.0.7"
"vitest": "3.2.4"
},
"packageManager": "pnpm@10.12.4"
"packageManager": "pnpm@10.13.1"
}

View File

@@ -138,6 +138,29 @@ exit 0
</INLINE>
</FILE>
<FILE Run="/bin/bash" Method="install">
<INLINE>
<![CDATA[
echo "Patching header logo if necessary..."
# We do this here instead of via API FileModification to avoid undesirable
# rollback when the API is stopped.
#
# This is necessary on < 7.2 because the unraid-header-os-version web component
# that ships with the base OS only displayes the version, not the logo as well.
#
# Rolling back in this case (i.e when stopping the API) yields a duplicate logo
# that blocks interaction with the navigation menu.
# Remove the old header logo from DefaultPageLayout.php if present
if [ -f "/usr/local/emhttp/plugins/dynamix/include/DefaultPageLayout.php" ]; then
sed -i 's|<a href="https://unraid.net" target="_blank"><?readfile("$docroot/webGui/images/UN-logotype-gradient.svg")?></a>||g' "/usr/local/emhttp/plugins/dynamix/include/DefaultPageLayout.php"
fi
]]>
</INLINE>
</FILE>
<FILE Run="/bin/bash" Method="remove">
<INLINE>
MAINNAME="&name;"

View File

@@ -23,9 +23,16 @@ $myservers_flash_cfg_path='/boot/config/plugins/dynamix.my.servers/myservers.cfg
$myservers = file_exists($myservers_flash_cfg_path) ? @parse_ini_file($myservers_flash_cfg_path,true) : [];
$isRegistered = !empty($myservers['remote']['username']);
$myservers_memory_cfg_path ='/var/local/emhttp/myservers.cfg';
$mystatus = (file_exists($myservers_memory_cfg_path)) ? @parse_ini_file($myservers_memory_cfg_path) : [];
$isConnected = (($mystatus['minigraph']??'')==='CONNECTED') ? true : false;
// Read connection status from the new API status file
$statusFilePath = '/var/local/emhttp/connectStatus.json';
$connectionStatus = '';
if (file_exists($statusFilePath)) {
$statusData = @json_decode(file_get_contents($statusFilePath), true);
$connectionStatus = $statusData['connectionStatus'] ?? '';
}
$isConnected = ($connectionStatus === 'CONNECTED') ? true : false;
$flashbackup_ini = '/var/local/emhttp/flashbackup.ini';

View File

@@ -168,9 +168,8 @@ class ServerState
private function getMyServersCfgValues()
{
/**
* @todo can we read this from somewhere other than the flash? Connect page uses this path and /boot/config/plugins/dynamix.my.servers/myservers.cfg
* - $myservers_memory_cfg_path ='/var/local/emhttp/myservers.cfg';
* - $mystatus = (file_exists($myservers_memory_cfg_path)) ? @parse_ini_file($myservers_memory_cfg_path) : [];
* Memory config is now written by the new API to /usr/local/emhttp/state/myservers.cfg
* This contains runtime state including connection status.
*/
$flashCfgPath = '/boot/config/plugins/dynamix.my.servers/myservers.cfg';
$this->myServersFlashCfg = file_exists($flashCfgPath) ? @parse_ini_file($flashCfgPath, true) : [];
@@ -212,11 +211,19 @@ class ServerState
* Include localhost in the test, but only display HTTP(S) URLs that do not include localhost.
*/
$this->host = $_SERVER['HTTP_HOST'] ?? "unknown";
$memoryCfgPath = '/var/local/emhttp/myservers.cfg';
$this->myServersMemoryCfg = (file_exists($memoryCfgPath)) ? @parse_ini_file($memoryCfgPath) : [];
$this->myServersMiniGraphConnected = (($this->myServersMemoryCfg['minigraph'] ?? '') === 'CONNECTED');
// Read connection status and allowed origins from the new API status file
$statusFilePath = '/var/local/emhttp/connectStatus.json';
$connectionStatus = '';
$allowedOrigins = '';
if (file_exists($statusFilePath)) {
$statusData = @json_decode(file_get_contents($statusFilePath), true);
$connectionStatus = $statusData['connectionStatus'] ?? '';
$allowedOrigins = $statusData['allowedOrigins'] ?? '';
}
$this->myServersMiniGraphConnected = ($connectionStatus === 'CONNECTED');
$allowedOrigins = $this->myServersMemoryCfg['allowedOrigins'] ?? "";
$extraOrigins = $this->myServersFlashCfg['api']['extraOrigins'] ?? "";
$combinedOrigins = $allowedOrigins . "," . $extraOrigins; // combine the two strings for easier searching
$combinedOrigins = str_replace(" ", "", $combinedOrigins); // replace any spaces with nothing

3036
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "@unraid/ui",
"version": "4.9.1",
"version": "4.9.2",
"private": true,
"license": "GPL-2.0-or-later",
"type": "module",
@@ -54,17 +54,17 @@
"@jsonforms/core": "3.6.0",
"@jsonforms/vue": "3.6.0",
"@jsonforms/vue-vanilla": "3.6.0",
"@vueuse/core": "13.4.0",
"@vueuse/core": "13.5.0",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"dompurify": "3.2.6",
"kebab-case": "2.0.2",
"lucide-vue-next": "0.519.0",
"lucide-vue-next": "0.525.0",
"marked": "16.0.0",
"reka-ui": "2.3.1",
"reka-ui": "2.3.2",
"shadcn-vue": "2.2.0",
"tailwind-merge": "2.6.0",
"vue-sonner": "1.3.0"
"vue-sonner": "1.3.2"
},
"devDependencies": {
"@ianvs/prettier-plugin-sort-imports": "4.4.2",
@@ -75,9 +75,9 @@
"@tailwindcss/typography": "0.5.16",
"@testing-library/vue": "8.1.0",
"@types/jsdom": "21.1.7",
"@types/node": "22.15.32",
"@types/node": "22.16.2",
"@types/testing-library__vue": "5.3.0",
"@typescript-eslint/eslint-plugin": "8.34.1",
"@typescript-eslint/eslint-plugin": "8.36.0",
"@unraid/tailwind-rem-to-rem": "1.1.0",
"@vitejs/plugin-vue": "5.2.4",
"@vitest/coverage-v8": "3.2.4",
@@ -85,19 +85,19 @@
"@vue/test-utils": "2.4.6",
"@vue/tsconfig": "0.7.0",
"autoprefixer": "10.4.21",
"concurrently": "9.1.2",
"eslint": "9.29.0",
"concurrently": "9.2.0",
"eslint": "9.30.1",
"eslint-config-prettier": "10.1.5",
"eslint-plugin-import": "2.31.0",
"eslint-plugin-import": "2.32.0",
"eslint-plugin-no-relative-import-paths": "1.6.1",
"eslint-plugin-prettier": "5.5.0",
"eslint-plugin-prettier": "5.5.1",
"eslint-plugin-storybook": "9.0.16",
"eslint-plugin-vue": "10.2.0",
"happy-dom": "18.0.0",
"eslint-plugin-vue": "10.3.0",
"happy-dom": "18.0.1",
"jiti": "^2.4.2",
"postcss": "8.5.6",
"postcss-import": "16.1.1",
"prettier": "3.5.3",
"prettier": "3.6.2",
"prettier-plugin-tailwindcss": "0.6.13",
"rimraf": "6.0.1",
"storybook": "9.0.16",
@@ -105,7 +105,7 @@
"tailwindcss": "3.4.17",
"tailwindcss-animate": "1.0.7",
"typescript": "5.8.3",
"typescript-eslint": "8.34.1",
"typescript-eslint": "8.36.0",
"vite": "7.0.3",
"vite-plugin-dts": "3.9.1",
"vite-plugin-vue-devtools": "7.7.7",
@@ -115,7 +115,7 @@
"wrangler": "^3.87.0"
},
"optionalDependencies": {
"@rollup/rollup-linux-x64-gnu": "4.44.0"
"@rollup/rollup-linux-x64-gnu": "4.44.2"
},
"exports": {
".": {
@@ -139,5 +139,5 @@
"import": "./dist/theme/preset.js"
}
},
"packageManager": "pnpm@10.12.4"
"packageManager": "pnpm@10.13.1"
}

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed, type Component } from 'vue';
import { computed } from 'vue';
import type { Component } from 'vue';
import { BellIcon, ExclamationTriangleIcon, ShieldExclamationIcon } from '@heroicons/vue/24/solid';
import { cn } from '@unraid/ui';

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed, reactive, type Component } from 'vue';
import { computed, reactive } from 'vue';
import type { Component } from 'vue';
import { computedAsync } from '@vueuse/core';
import { Markdown } from '@/helpers/markdown';
import {

View File

@@ -1,4 +1,5 @@
import { InMemoryCache, type InMemoryCacheConfig } from '@apollo/client/core';
import { InMemoryCache } from '@apollo/client/core';
import type { InMemoryCacheConfig } from '@apollo/client/core';
import type { NotificationOverview } from '~/composables/gql/graphql';

View File

@@ -1,5 +1,6 @@
import DOMPurify from 'isomorphic-dompurify';
import { Marked, type MarkedExtension } from 'marked';
import { Marked } from 'marked';
import type { MarkedExtension } from 'marked';
const defaultMarkedExtension: MarkedExtension = {
hooks: {

View File

@@ -1,6 +1,6 @@
{
"name": "@unraid/web",
"version": "4.9.1",
"version": "4.9.2",
"private": true,
"license": "GPL-2.0-or-later",
"scripts": {
@@ -39,13 +39,13 @@
},
"devDependencies": {
"@graphql-codegen/cli": "5.0.7",
"@graphql-codegen/client-preset": "4.8.2",
"@graphql-codegen/client-preset": "4.8.3",
"@graphql-codegen/introspection": "4.0.3",
"@graphql-typed-document-node/core": "3.2.0",
"@ianvs/prettier-plugin-sort-imports": "4.4.2",
"@nuxt/devtools": "2.5.0",
"@nuxt/eslint": "1.4.1",
"@nuxt/test-utils": "3.19.1",
"@nuxt/devtools": "2.6.2",
"@nuxt/eslint": "1.5.2",
"@nuxt/test-utils": "3.19.2",
"@nuxtjs/tailwindcss": "6.14.0",
"@pinia/testing": "1.0.2",
"@rollup/plugin-strip": "3.0.4",
@@ -53,25 +53,25 @@
"@testing-library/vue": "8.1.0",
"@types/crypto-js": "4.2.2",
"@types/eslint-config-prettier": "6.11.3",
"@types/node": "22.15.32",
"@types/node": "22.16.2",
"@types/semver": "7.7.0",
"@typescript-eslint/eslint-plugin": "8.34.1",
"@typescript-eslint/eslint-plugin": "8.36.0",
"@unraid/tailwind-rem-to-rem": "1.1.0",
"@vitejs/plugin-vue": "5.2.4",
"@vitest/coverage-v8": "3.2.4",
"@vue/apollo-util": "4.2.2",
"@vue/test-utils": "2.4.6",
"@vueuse/core": "13.4.0",
"@vueuse/nuxt": "13.4.0",
"eslint": "9.29.0",
"@vueuse/core": "13.5.0",
"@vueuse/nuxt": "13.5.0",
"eslint": "9.30.1",
"eslint-config-prettier": "10.1.5",
"eslint-import-resolver-typescript": "4.4.4",
"eslint-plugin-import": "2.31.0",
"happy-dom": "18.0.0",
"eslint-plugin-import": "2.32.0",
"happy-dom": "18.0.1",
"lodash-es": "4.17.21",
"nuxt": "3.17.5",
"nuxt": "3.17.6",
"nuxt-custom-elements": "2.0.0-beta.32",
"prettier": "3.5.3",
"prettier": "3.6.2",
"prettier-plugin-tailwindcss": "0.6.13",
"shadcn-nuxt": "2.2.0",
"tailwindcss": "3.4.17",
@@ -80,7 +80,7 @@
"typescript": "5.8.3",
"vite": "7.0.3",
"vite-plugin-remove-console": "2.2.0",
"vite-plugin-vue-tracer": "0.1.4",
"vite-plugin-vue-tracer": "0.1.5",
"vitest": "3.2.4",
"vue": "3.5.17",
"vue-tsc": "3.0.1",
@@ -88,9 +88,9 @@
},
"dependencies": {
"@apollo/client": "3.13.8",
"@floating-ui/dom": "1.7.1",
"@floating-ui/utils": "0.2.9",
"@floating-ui/vue": "1.1.6",
"@floating-ui/dom": "1.7.2",
"@floating-ui/utils": "0.2.10",
"@floating-ui/vue": "1.1.7",
"@headlessui/vue": "1.7.23",
"@heroicons/vue": "2.2.0",
"@jsonforms/core": "3.6.0",
@@ -102,8 +102,8 @@
"@unraid/shared-callbacks": "1.1.1",
"@unraid/ui": "link:../unraid-ui",
"@vue/apollo-composable": "4.2.2",
"@vueuse/components": "13.4.0",
"@vueuse/integrations": "13.4.0",
"@vueuse/components": "13.5.0",
"@vueuse/integrations": "13.5.0",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"crypto-js": "4.2.0",
@@ -114,23 +114,23 @@
"graphql-ws": "6.0.5",
"hex-to-rgba": "2.0.1",
"highlight.js": "11.11.1",
"isomorphic-dompurify": "2.25.0",
"lucide-vue-next": "0.519.0",
"isomorphic-dompurify": "2.26.0",
"lucide-vue-next": "0.525.0",
"marked": "16.0.0",
"marked-base-url": "1.1.6",
"marked-base-url": "1.1.7",
"pinia": "3.0.3",
"semver": "7.7.2",
"tailwind-merge": "2.6.0",
"vue-i18n": "11.1.6",
"vue-i18n": "11.1.9",
"vue-web-component-wrapper": "1.7.7",
"vuetify": "3.8.10",
"vuetify": "3.9.0",
"wretch": "2.11.0"
},
"optionalDependencies": {
"@rollup/rollup-linux-x64-gnu": "4.44.0"
"@rollup/rollup-linux-x64-gnu": "4.44.2"
},
"overrides": {
"vue": "latest"
},
"packageManager": "pnpm@10.12.4"
"packageManager": "pnpm@10.13.1"
}