Compare commits

...

9 Commits

Author SHA1 Message Date
renovate[bot]
ce16b90c3e Merge 8f07eab623 into dd759d9f0f 2025-07-10 14:24:14 +00:00
renovate[bot]
8f07eab623 chore(deps): pin dependencies 2025-07-10 14:24:09 +00:00
Pujit Mehrotra
dd759d9f0f fix(connect): rm eager restart on ERROR_RETYING connection status (#1502)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Improved connection handling to prevent unnecessary reconnection
attempts during error retry states, ensuring reconnections only occur on
specific failures.

* **Tests**
* Added comprehensive tests to verify connection recovery,
identity-based connection, logout behavior, DDoS prevention, and edge
case handling for connection state changes.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-10 10:21:54 -04:00
github-actions[bot]
74da8d81ef chore(main): release 4.9.4 (#1498)
🤖 I have created a release *beep* *boop*
---


## [4.9.4](https://github.com/unraid/api/compare/v4.9.3...v4.9.4)
(2025-07-09)


### Bug Fixes

* backport `<unraid-modals>` upon plg install when necessary
([#1499](https://github.com/unraid/api/issues/1499))
([33e0b1a](33e0b1ab24))
* DefaultPageLayout patch rollback omits legacy header logo
([#1497](https://github.com/unraid/api/issues/1497))
([ea20d1e](ea20d1e211))
* event emitter setup for writing status
([#1496](https://github.com/unraid/api/issues/1496))
([ca4e2db](ca4e2db1f2))

---
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 13:40:56 -04:00
Pujit Mehrotra
33e0b1ab24 fix: backport <unraid-modals> upon plg install when necessary (#1499)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Prevented duplicate insertion of the modal component in the page
layout.

* **Chores**
* Improved installation script to ensure the modal component is added
only if missing.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-09 13:32:55 -04:00
Eli Bosley
ca4e2db1f2 fix: event emitter setup for writing status (#1496)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Chores**
* Updated permissions to allow additional Bash command patterns in the
configuration.
* Improved connection status updates by triggering them via event
listeners during application bootstrap.
* Adjusted module provider registrations to reflect service relocation
within the application structure.
* **Tests**
* Added comprehensive unit and integration tests for connection status
writing and cleanup behaviors.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-09 13:16:53 -04:00
Pujit Mehrotra
ea20d1e211 fix: DefaultPageLayout patch rollback omits legacy header logo (#1497)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Enhanced the header by displaying the OS version and additional server
information.
* Introduced a new notification system using a modern UI component for
toasts.
* Automatically creates a root session for local requests when no valid
session exists.

* **Bug Fixes**
* Removed outdated pop-up notification logic and bell icon from the
navigation area.

* **Style**
* Updated header layout and improved formatting for a cleaner
appearance.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-09 13:12:18 -04:00
github-actions[bot]
79c57b8ed0 chore(main): release 4.9.3 (#1495)
🤖 I have created a release *beep* *boop*
---


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


### Bug Fixes

* duplicated header logo after api stops
([#1493](https://github.com/unraid/api/issues/1493))
([4168f43](4168f43e3e))

---
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 11:06:47 -04: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
21 changed files with 852 additions and 109 deletions

View File

@@ -11,7 +11,10 @@
"Bash(pnpm type-check:*)",
"Bash(pnpm lint:*)",
"Bash(pnpm --filter ./api lint)",
"Bash(mv:*)"
"Bash(mv:*)",
"Bash(ls:*)",
"mcp__ide__getDiagnostics",
"Bash(pnpm --filter \"*connect*\" test connect-status-writer.service.spec)"
]
},
"enableAllProjectMcpServers": false

View File

@@ -25,7 +25,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
node-version: '20.19.3'
- uses: pnpm/action-setup@v4
name: Install pnpm

View File

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

View File

@@ -1,5 +1,21 @@
# Changelog
## [4.9.4](https://github.com/unraid/api/compare/v4.9.3...v4.9.4) (2025-07-09)
### Bug Fixes
* backport `<unraid-modals>` upon plg install when necessary ([#1499](https://github.com/unraid/api/issues/1499)) ([33e0b1a](https://github.com/unraid/api/commit/33e0b1ab24bedb6a2c7b376ea73dbe65bc3044be))
* DefaultPageLayout patch rollback omits legacy header logo ([#1497](https://github.com/unraid/api/issues/1497)) ([ea20d1e](https://github.com/unraid/api/commit/ea20d1e2116fcafa154090fee78b42ec5d9ba584))
* event emitter setup for writing status ([#1496](https://github.com/unraid/api/issues/1496)) ([ca4e2db](https://github.com/unraid/api/commit/ca4e2db1f29126a1fa3784af563832edda64b0ca))
## [4.9.3](https://github.com/unraid/api/compare/v4.9.2...v4.9.3) (2025-07-09)
### Bug Fixes
* duplicated header logo after api stops ([#1493](https://github.com/unraid/api/issues/1493)) ([4168f43](https://github.com/unraid/api/commit/4168f43e3ecd51479bec3aae585abbe6dcd3e416))
## [4.9.2](https://github.com/unraid/api/compare/v4.9.1...v4.9.2) (2025-07-09)

View File

@@ -1,6 +1,6 @@
{
"name": "@unraid/api",
"version": "4.9.2",
"version": "4.9.4",
"main": "src/cli/index.ts",
"type": "module",
"corepack": {

View File

@@ -65,6 +65,13 @@ if (is_localhost() && !is_good_session()) {
return this.prependDoctypeWithPhp(source, newPhpCode);
}
private addModalsWebComponent(source: string): string {
if (source.includes('<unraid-modals>')) {
return source;
}
return source.replace('<body>', '<body>\n<unraid-modals></unraid-modals>');
}
private hideHeaderLogo(source: string): string {
return source.replace(
'<a href="https://unraid.net" target="_blank"><?readfile("$docroot/webGui/images/UN-logotype-gradient.svg")?></a>',
@@ -72,17 +79,14 @@ if (is_localhost() && !is_good_session()) {
);
}
private addModalsWebComponent(source: string): string {
return source.replace('<body>', '<body>\n<unraid-modals></unraid-modals>');
}
private applyToSource(fileContent: string): string {
const transformers = [
this.removeNotificationBell.bind(this),
this.replaceToasts.bind(this),
this.addToaster.bind(this),
this.patchGuiBootAuth.bind(this),
this.hideHeaderLogo.bind(this),
this.addModalsWebComponent.bind(this),
this.hideHeaderLogo.bind(this),
];
return transformers.reduce((content, transformer) => transformer(content), fileContent);

View File

@@ -1,7 +1,7 @@
{
"name": "unraid-monorepo",
"private": true,
"version": "4.9.2",
"version": "4.9.4",
"scripts": {
"build": "pnpm -r build",
"build:watch": " pnpm -r --parallel build:watch",

View File

@@ -97,7 +97,7 @@
"lodash-es": "4.17.21",
"nest-authz": "2.17.0",
"rxjs": "7.8.2",
"ws": "^8.18.0",
"ws": "8.18.3",
"zen-observable-ts": "1.1.0"
}
}

View File

@@ -0,0 +1,269 @@
import { EventEmitter2 } from '@nestjs/event-emitter';
import { PubSub } from 'graphql-subscriptions';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { MinigraphStatus } from '../config/connect.config.js';
import { EVENTS, GRAPHQL_PUBSUB_CHANNEL } from '../helper/nest-tokens.js';
import { MothershipConnectionService } from '../mothership-proxy/connection.service.js';
import { MothershipController } from '../mothership-proxy/mothership.controller.js';
import { MothershipHandler } from '../mothership-proxy/mothership.events.js';
describe('MothershipHandler - Behavioral Tests', () => {
let handler: MothershipHandler;
let connectionService: MothershipConnectionService;
let mothershipController: MothershipController;
let pubSub: PubSub;
let eventEmitter: EventEmitter2;
// Track actual state changes and effects
let connectionAttempts: Array<{ timestamp: number; reason: string }> = [];
let publishedMessages: Array<{ channel: string; data: any }> = [];
let controllerStops: Array<{ timestamp: number; reason?: string }> = [];
beforeEach(() => {
// Reset tracking arrays
connectionAttempts = [];
publishedMessages = [];
controllerStops = [];
// Create real event emitter for integration testing
eventEmitter = new EventEmitter2();
// Mock connection service with realistic behavior
connectionService = {
getIdentityState: vi.fn(),
getConnectionState: vi.fn(),
} as any;
// Mock controller that tracks behavior instead of just method calls
mothershipController = {
initOrRestart: vi.fn().mockImplementation(() => {
connectionAttempts.push({
timestamp: Date.now(),
reason: 'initOrRestart called',
});
return Promise.resolve();
}),
stop: vi.fn().mockImplementation(() => {
controllerStops.push({
timestamp: Date.now(),
});
return Promise.resolve();
}),
} as any;
// Mock PubSub that tracks published messages
pubSub = {
publish: vi.fn().mockImplementation((channel: string, data: any) => {
publishedMessages.push({ channel, data });
return Promise.resolve();
}),
} as any;
handler = new MothershipHandler(connectionService, mothershipController, pubSub);
});
describe('Connection Recovery Behavior', () => {
it('should attempt reconnection when ping fails', async () => {
// Given: Connection is in ping failure state
vi.mocked(connectionService.getConnectionState).mockReturnValue({
status: MinigraphStatus.PING_FAILURE,
error: 'Ping timeout after 3 minutes',
});
// When: Connection status change event occurs
await handler.onMothershipConnectionStatusChanged();
// Then: System should attempt to recover the connection
expect(connectionAttempts).toHaveLength(1);
expect(connectionAttempts[0].reason).toBe('initOrRestart called');
});
it('should NOT interfere with exponential backoff during error retry state', async () => {
// Given: Connection is in error retry state (GraphQL client managing backoff)
vi.mocked(connectionService.getConnectionState).mockReturnValue({
status: MinigraphStatus.ERROR_RETRYING,
error: 'Network error',
timeout: 20000,
timeoutStart: Date.now(),
});
// When: Connection status change event occurs
await handler.onMothershipConnectionStatusChanged();
// Then: System should NOT interfere with ongoing retry logic
expect(connectionAttempts).toHaveLength(0);
});
it('should remain stable during normal connection states', async () => {
const stableStates = [MinigraphStatus.CONNECTED, MinigraphStatus.CONNECTING];
for (const status of stableStates) {
// Reset for each test
connectionAttempts.length = 0;
// Given: Connection is in a stable state
vi.mocked(connectionService.getConnectionState).mockReturnValue({
status,
error: null,
});
// When: Connection status change event occurs
await handler.onMothershipConnectionStatusChanged();
// Then: System should not trigger unnecessary reconnection attempts
expect(connectionAttempts).toHaveLength(0);
}
});
});
describe('Identity-Based Connection Behavior', () => {
it('should establish connection when valid API key becomes available', async () => {
// Given: Valid API key is present
vi.mocked(connectionService.getIdentityState).mockReturnValue({
state: {
apiKey: 'valid-unraid-key-12345',
unraidVersion: '6.12.0',
flashGuid: 'test-flash-guid',
apiVersion: '1.0.0',
},
isLoaded: true,
});
// When: Identity changes
await handler.onIdentityChanged();
// Then: System should establish mothership connection
expect(connectionAttempts).toHaveLength(1);
});
it('should not attempt connection without valid credentials', async () => {
const invalidCredentials = [{ apiKey: undefined }, { apiKey: '' }];
for (const credentials of invalidCredentials) {
// Reset for each test
connectionAttempts.length = 0;
// Given: Invalid or missing API key
vi.mocked(connectionService.getIdentityState).mockReturnValue({
state: credentials,
isLoaded: false,
});
// When: Identity changes
await handler.onIdentityChanged();
// Then: System should not attempt connection
expect(connectionAttempts).toHaveLength(0);
}
});
});
describe('Logout Behavior', () => {
it('should properly clean up connections and notify subscribers on logout', async () => {
// When: User logs out
await handler.logout({ reason: 'User initiated logout' });
// Then: System should clean up connections
expect(controllerStops).toHaveLength(1);
// And: Subscribers should be notified of empty state
expect(publishedMessages).toHaveLength(2);
const serversMessage = publishedMessages.find(
(m) => m.channel === GRAPHQL_PUBSUB_CHANNEL.SERVERS
);
const ownerMessage = publishedMessages.find(
(m) => m.channel === GRAPHQL_PUBSUB_CHANNEL.OWNER
);
expect(serversMessage?.data).toEqual({ servers: [] });
expect(ownerMessage?.data).toEqual({
owner: { username: 'root', url: '', avatar: '' },
});
});
it('should handle logout gracefully even without explicit reason', async () => {
// When: System logout occurs without reason
await handler.logout({});
// Then: Cleanup should still occur properly
expect(controllerStops).toHaveLength(1);
expect(publishedMessages).toHaveLength(2);
});
});
describe('DDoS Prevention Behavior', () => {
it('should demonstrate exponential backoff is respected during network errors', async () => {
// Given: Multiple rapid network errors occur
const errorStates = [
{ status: MinigraphStatus.ERROR_RETRYING, error: 'Network error 1' },
{ status: MinigraphStatus.ERROR_RETRYING, error: 'Network error 2' },
{ status: MinigraphStatus.ERROR_RETRYING, error: 'Network error 3' },
];
// When: Rapid error retry states occur
for (const state of errorStates) {
vi.mocked(connectionService.getConnectionState).mockReturnValue(state);
await handler.onMothershipConnectionStatusChanged();
}
// Then: No linear retry attempts should be made (respecting exponential backoff)
expect(connectionAttempts).toHaveLength(0);
});
it('should differentiate between network errors and ping failures', async () => {
// Given: Network error followed by ping failure
vi.mocked(connectionService.getConnectionState).mockReturnValue({
status: MinigraphStatus.ERROR_RETRYING,
error: 'Network error',
});
// When: Network error occurs
await handler.onMothershipConnectionStatusChanged();
// Then: No immediate reconnection attempt
expect(connectionAttempts).toHaveLength(0);
// Given: Ping failure occurs (different issue)
vi.mocked(connectionService.getConnectionState).mockReturnValue({
status: MinigraphStatus.PING_FAILURE,
error: 'Ping timeout',
});
// When: Ping failure occurs
await handler.onMothershipConnectionStatusChanged();
// Then: Immediate reconnection attempt should occur
expect(connectionAttempts).toHaveLength(1);
});
});
describe('Edge Cases and Error Handling', () => {
it('should handle missing connection state gracefully', async () => {
// Given: Connection service returns undefined
vi.mocked(connectionService.getConnectionState).mockReturnValue(undefined);
// When: Connection status change occurs
await handler.onMothershipConnectionStatusChanged();
// Then: No errors should occur, no reconnection attempts
expect(connectionAttempts).toHaveLength(0);
});
it('should handle malformed connection state', async () => {
// Given: Malformed connection state
vi.mocked(connectionService.getConnectionState).mockReturnValue({
status: 'UNKNOWN_STATUS' as any,
error: 'Malformed state',
});
// When: Connection status change occurs
await handler.onMothershipConnectionStatusChanged();
// Then: Should not trigger reconnection for unknown states
expect(connectionAttempts).toHaveLength(0);
});
});
});

View File

@@ -0,0 +1,158 @@
import { ConfigService } from '@nestjs/config';
import { access, constants, mkdir, readFile, rm } from 'fs/promises';
import { join } from 'path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ConfigType } from '../config/connect.config.js';
import { ConnectStatusWriterService } from './connect-status-writer.service.js';
describe('ConnectStatusWriterService Config Behavior', () => {
let service: ConnectStatusWriterService;
let configService: ConfigService<ConfigType, true>;
const testDir = '/tmp/connect-status-config-test';
const testFilePath = join(testDir, 'connectStatus.json');
// Simulate config changes
let configStore: any = {};
beforeEach(async () => {
vi.clearAllMocks();
// Reset config store
configStore = {};
// Create test directory
await mkdir(testDir, { recursive: true });
// Create a ConfigService mock that behaves like the real one
configService = {
get: vi.fn().mockImplementation((key: string) => {
console.log(`ConfigService.get('${key}') called, returning:`, configStore[key]);
return configStore[key];
}),
set: vi.fn().mockImplementation((key: string, value: any) => {
console.log(`ConfigService.set('${key}', ${JSON.stringify(value)}) called`);
configStore[key] = value;
}),
} as unknown as ConfigService<ConfigType, true>;
service = new ConnectStatusWriterService(configService);
// Override the status file path to use our test location
Object.defineProperty(service, 'statusFilePath', {
get: () => testFilePath,
});
});
afterEach(async () => {
await service.onModuleDestroy();
await rm(testDir, { recursive: true, force: true });
});
it('should write status when config is updated directly', async () => {
// Initialize service - should write PRE_INIT
await service.onApplicationBootstrap();
await new Promise(resolve => setTimeout(resolve, 50));
let content = await readFile(testFilePath, 'utf-8');
let data = JSON.parse(content);
console.log('Initial status:', data);
expect(data.connectionStatus).toBe('PRE_INIT');
// Update config directly (simulating what ConnectionService does)
console.log('\n=== Updating config to CONNECTED ===');
configService.set('connect.mothership', {
status: 'CONNECTED',
error: null,
lastPing: Date.now(),
});
// Call the writeStatus method directly (since @OnEvent handles the event)
await service['writeStatus']();
content = await readFile(testFilePath, 'utf-8');
data = JSON.parse(content);
console.log('Status after config update:', data);
expect(data.connectionStatus).toBe('CONNECTED');
});
it('should test the actual flow with multiple status updates', async () => {
await service.onApplicationBootstrap();
await new Promise(resolve => setTimeout(resolve, 50));
const statusUpdates = [
{ status: 'CONNECTING', error: null, lastPing: null },
{ status: 'CONNECTED', error: null, lastPing: Date.now() },
{ status: 'DISCONNECTED', error: 'Lost connection', lastPing: Date.now() - 10000 },
{ status: 'RECONNECTING', error: null, lastPing: Date.now() - 10000 },
{ status: 'CONNECTED', error: null, lastPing: Date.now() },
];
for (const update of statusUpdates) {
console.log(`\n=== Updating to ${update.status} ===`);
// Update config
configService.set('connect.mothership', update);
// Call writeStatus directly
await service['writeStatus']();
const content = await readFile(testFilePath, 'utf-8');
const data = JSON.parse(content);
console.log(`Status file shows: ${data.connectionStatus}`);
expect(data.connectionStatus).toBe(update.status);
}
});
it('should handle case where config is not set before event', async () => {
await service.onApplicationBootstrap();
await new Promise(resolve => setTimeout(resolve, 50));
// Delete the config
delete configStore['connect.mothership'];
// Call writeStatus without config
console.log('\n=== Calling writeStatus with no config ===');
await service['writeStatus']();
const content = await readFile(testFilePath, 'utf-8');
const data = JSON.parse(content);
console.log('Status with no config:', data);
expect(data.connectionStatus).toBe('PRE_INIT');
// Now set config and call writeStatus again
console.log('\n=== Setting config and calling writeStatus ===');
configService.set('connect.mothership', {
status: 'CONNECTED',
error: null,
lastPing: Date.now(),
});
await service['writeStatus']();
const content2 = await readFile(testFilePath, 'utf-8');
const data2 = JSON.parse(content2);
console.log('Status after setting config:', data2);
expect(data2.connectionStatus).toBe('CONNECTED');
});
describe('cleanup on shutdown', () => {
it('should delete status file on module destroy', async () => {
await service.onApplicationBootstrap();
await new Promise(resolve => setTimeout(resolve, 50));
// Verify file exists
await expect(access(testFilePath, constants.F_OK)).resolves.not.toThrow();
// Cleanup
await service.onModuleDestroy();
// Verify file is deleted
await expect(access(testFilePath, constants.F_OK)).rejects.toThrow();
});
it('should handle cleanup when file does not exist', async () => {
// Don't bootstrap (so no file is written)
await expect(service.onModuleDestroy()).resolves.not.toThrow();
});
});
});

View File

@@ -0,0 +1,167 @@
import { ConfigService } from '@nestjs/config';
import { access, constants, mkdir, readFile, rm } from 'fs/promises';
import { join } from 'path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ConfigType } from '../config/connect.config.js';
import { ConnectStatusWriterService } from './connect-status-writer.service.js';
describe('ConnectStatusWriterService Integration', () => {
let service: ConnectStatusWriterService;
let configService: ConfigService<ConfigType, true>;
const testDir = '/tmp/connect-status-test';
const testFilePath = join(testDir, 'connectStatus.json');
beforeEach(async () => {
vi.clearAllMocks();
// Create test directory
await mkdir(testDir, { recursive: true });
configService = {
get: vi.fn().mockImplementation((key: string) => {
console.log(`ConfigService.get called with key: ${key}`);
return {
status: 'CONNECTED',
error: null,
lastPing: Date.now(),
};
}),
} as unknown as ConfigService<ConfigType, true>;
service = new ConnectStatusWriterService(configService);
// Override the status file path to use our test location
Object.defineProperty(service, 'statusFilePath', {
get: () => testFilePath,
});
});
afterEach(async () => {
await service.onModuleDestroy();
await rm(testDir, { recursive: true, force: true });
});
it('should write initial PRE_INIT status, then update on event', async () => {
// First, mock the config to return undefined (no connection metadata)
vi.mocked(configService.get).mockReturnValue(undefined);
console.log('=== Starting onApplicationBootstrap ===');
await service.onApplicationBootstrap();
// Wait a bit for the initial write to complete
await new Promise(resolve => setTimeout(resolve, 50));
// Read initial status
const initialContent = await readFile(testFilePath, 'utf-8');
const initialData = JSON.parse(initialContent);
console.log('Initial status written:', initialData);
expect(initialData.connectionStatus).toBe('PRE_INIT');
expect(initialData.error).toBeNull();
expect(initialData.lastPing).toBeNull();
// Now update the mock to return CONNECTED status
vi.mocked(configService.get).mockReturnValue({
status: 'CONNECTED',
error: null,
lastPing: 1234567890,
});
console.log('=== Calling writeStatus directly ===');
await service['writeStatus']();
// Read updated status
const updatedContent = await readFile(testFilePath, 'utf-8');
const updatedData = JSON.parse(updatedContent);
console.log('Updated status after writeStatus:', updatedData);
expect(updatedData.connectionStatus).toBe('CONNECTED');
expect(updatedData.lastPing).toBe(1234567890);
});
it('should handle rapid status changes correctly', async () => {
const statusChanges = [
{ status: 'PRE_INIT', error: null, lastPing: null },
{ status: 'CONNECTING', error: null, lastPing: null },
{ status: 'CONNECTED', error: null, lastPing: Date.now() },
{ status: 'DISCONNECTED', error: 'Connection lost', lastPing: Date.now() - 5000 },
{ status: 'CONNECTED', error: null, lastPing: Date.now() },
];
let changeIndex = 0;
vi.mocked(configService.get).mockImplementation(() => {
const change = statusChanges[changeIndex];
console.log(`Returning status ${changeIndex}: ${change.status}`);
return change;
});
await service.onApplicationBootstrap();
await new Promise(resolve => setTimeout(resolve, 50));
// Simulate the final status change
changeIndex = statusChanges.length - 1;
console.log(`=== Calling writeStatus for final status: ${statusChanges[changeIndex].status} ===`);
await service['writeStatus']();
// Read final status
const finalContent = await readFile(testFilePath, 'utf-8');
const finalData = JSON.parse(finalContent);
console.log('Final status after status change:', finalData);
// Should have the last status
expect(finalData.connectionStatus).toBe('CONNECTED');
expect(finalData.error).toBeNull();
});
it('should handle multiple write calls correctly', async () => {
const writes: number[] = [];
const originalWriteStatus = service['writeStatus'].bind(service);
service['writeStatus'] = async function() {
const timestamp = Date.now();
writes.push(timestamp);
console.log(`writeStatus called at ${timestamp}`);
return originalWriteStatus();
};
await service.onApplicationBootstrap();
await new Promise(resolve => setTimeout(resolve, 50));
const initialWrites = writes.length;
console.log(`Initial writes: ${initialWrites}`);
// Make multiple write calls
for (let i = 0; i < 3; i++) {
console.log(`Calling writeStatus ${i}`);
await service['writeStatus']();
}
console.log(`Total writes: ${writes.length}`);
console.log('Write timestamps:', writes);
// Should have initial write + 3 additional writes
expect(writes.length).toBe(initialWrites + 3);
});
describe('cleanup on shutdown', () => {
it('should delete status file on module destroy', async () => {
await service.onApplicationBootstrap();
await new Promise(resolve => setTimeout(resolve, 50));
// Verify file exists
await expect(access(testFilePath, constants.F_OK)).resolves.not.toThrow();
// Cleanup
await service.onModuleDestroy();
// Verify file is deleted
await expect(access(testFilePath, constants.F_OK)).rejects.toThrow();
});
it('should handle cleanup gracefully when file does not exist', async () => {
// Don't bootstrap (so no file is created)
await expect(service.onModuleDestroy()).resolves.not.toThrow();
});
});
});

View File

@@ -0,0 +1,140 @@
import { ConfigService } from '@nestjs/config';
import { unlink, writeFile } from 'fs/promises';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ConfigType } from '../config/connect.config.js';
import { ConnectStatusWriterService } from './connect-status-writer.service.js';
vi.mock('fs/promises', () => ({
writeFile: vi.fn(),
unlink: vi.fn(),
}));
describe('ConnectStatusWriterService', () => {
let service: ConnectStatusWriterService;
let configService: ConfigService<ConfigType, true>;
let writeFileMock: ReturnType<typeof vi.fn>;
let unlinkMock: ReturnType<typeof vi.fn>;
beforeEach(async () => {
vi.clearAllMocks();
vi.useFakeTimers();
writeFileMock = vi.mocked(writeFile);
unlinkMock = vi.mocked(unlink);
configService = {
get: vi.fn().mockReturnValue({
status: 'CONNECTED',
error: null,
lastPing: Date.now(),
}),
} as unknown as ConfigService<ConfigType, true>;
service = new ConnectStatusWriterService(configService);
});
afterEach(async () => {
vi.useRealTimers();
});
describe('onApplicationBootstrap', () => {
it('should write initial status on bootstrap', async () => {
await service.onApplicationBootstrap();
expect(writeFileMock).toHaveBeenCalledTimes(1);
expect(writeFileMock).toHaveBeenCalledWith(
'/var/local/emhttp/connectStatus.json',
expect.stringContaining('CONNECTED')
);
});
it('should handle event-driven status changes', async () => {
await service.onApplicationBootstrap();
writeFileMock.mockClear();
// The service uses @OnEvent decorator, so we need to call the method directly
await service['writeStatus']();
expect(writeFileMock).toHaveBeenCalledTimes(1);
});
});
describe('write content', () => {
it('should write correct JSON structure with all fields', async () => {
const mockMetadata = {
status: 'CONNECTED',
error: 'Some error',
lastPing: 1234567890,
};
vi.mocked(configService.get).mockReturnValue(mockMetadata);
await service.onApplicationBootstrap();
const writeCall = writeFileMock.mock.calls[0];
const writtenData = JSON.parse(writeCall[1] as string);
expect(writtenData).toMatchObject({
connectionStatus: 'CONNECTED',
error: 'Some error',
lastPing: 1234567890,
allowedOrigins: '',
});
expect(writtenData.timestamp).toBeDefined();
expect(typeof writtenData.timestamp).toBe('number');
});
it('should handle missing connection metadata', async () => {
vi.mocked(configService.get).mockReturnValue(undefined);
await service.onApplicationBootstrap();
const writeCall = writeFileMock.mock.calls[0];
const writtenData = JSON.parse(writeCall[1] as string);
expect(writtenData).toMatchObject({
connectionStatus: 'PRE_INIT',
error: null,
lastPing: null,
allowedOrigins: '',
});
});
});
describe('error handling', () => {
it('should handle write errors gracefully', async () => {
writeFileMock.mockRejectedValue(new Error('Write failed'));
await expect(service.onApplicationBootstrap()).resolves.not.toThrow();
// Test direct write error handling
await expect(service['writeStatus']()).resolves.not.toThrow();
});
});
describe('cleanup on shutdown', () => {
it('should delete status file on module destroy', async () => {
await service.onModuleDestroy();
expect(unlinkMock).toHaveBeenCalledTimes(1);
expect(unlinkMock).toHaveBeenCalledWith('/var/local/emhttp/connectStatus.json');
});
it('should handle file deletion errors gracefully', async () => {
unlinkMock.mockRejectedValue(new Error('File not found'));
await expect(service.onModuleDestroy()).resolves.not.toThrow();
expect(unlinkMock).toHaveBeenCalledTimes(1);
});
it('should ensure file is deleted even if it was never written', async () => {
// Don't bootstrap (so no file is written)
await service.onModuleDestroy();
expect(unlinkMock).toHaveBeenCalledTimes(1);
expect(unlinkMock).toHaveBeenCalledWith('/var/local/emhttp/connectStatus.json');
});
});
});

View File

@@ -1,11 +1,14 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { Injectable, Logger, OnApplicationBootstrap, OnModuleDestroy } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { OnEvent } from '@nestjs/event-emitter';
import { unlink } from 'fs/promises';
import { writeFile } from 'fs/promises';
import { ConnectionMetadata, ConfigType } from './connect.config.js';
import { ConfigType, ConnectionMetadata } from '../config/connect.config.js';
import { EVENTS } from '../helper/nest-tokens.js';
@Injectable()
export class ConnectStatusWriterService implements OnModuleInit {
export class ConnectStatusWriterService implements OnApplicationBootstrap, OnModuleDestroy {
constructor(private readonly configService: ConfigService<ConfigType, true>) {}
private logger = new Logger(ConnectStatusWriterService.name);
@@ -15,30 +18,27 @@ export class ConnectStatusWriterService implements OnModuleInit {
return '/var/local/emhttp/connectStatus.json';
}
async onModuleInit() {
async onApplicationBootstrap() {
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);
},
});
}
async onModuleDestroy() {
try {
await unlink(this.statusFilePath);
this.logger.verbose(`Status file deleted: ${this.statusFilePath}`);
} catch (error) {
this.logger.debug(`Could not delete status file: ${error}`);
}
}
@OnEvent(EVENTS.MOTHERSHIP_CONNECTION_STATUS_CHANGED, { async: true })
private async writeStatus() {
try {
const connectionMetadata = this.configService.get<ConnectionMetadata>('connect.mothership');
// Try to get allowed origins from the store
let allowedOrigins = '';
try {
@@ -48,22 +48,22 @@ export class ConnectStatusWriterService implements OnModuleInit {
} 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()
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,7 +3,6 @@ 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';
@@ -11,7 +10,7 @@ export const adapter = 'nestjs';
@Module({
imports: [ConfigModule.forFeature(configFeature), ConnectModule, MothershipModule],
providers: [ConnectConfigPersister, ConnectStatusWriterService],
providers: [ConnectConfigPersister],
exports: [],
})
class ConnectPluginModule {

View File

@@ -32,7 +32,7 @@ export class MothershipHandler {
const state = this.connectionService.getConnectionState();
if (
state &&
[MinigraphStatus.PING_FAILURE, MinigraphStatus.ERROR_RETRYING].includes(state.status)
[MinigraphStatus.PING_FAILURE].includes(state.status)
) {
this.logger.verbose(
'Mothership connection status changed to %s; setting up mothership subscription',

View File

@@ -3,18 +3,20 @@ import { Module } from '@nestjs/common';
import { ConnectApiKeyService } from '../authn/connect-api-key.service.js';
import { CloudResolver } from '../connection-status/cloud.resolver.js';
import { CloudService } from '../connection-status/cloud.service.js';
import { ConnectStatusWriterService } from '../connection-status/connect-status-writer.service.js';
import { TimeoutCheckerJob } from '../connection-status/timeout-checker.job.js';
import { InternalClientService } from '../internal-rpc/internal.client.js';
import { RemoteAccessModule } from '../remote-access/remote-access.module.js';
import { MothershipConnectionService } from './connection.service.js';
import { MothershipGraphqlClientService } from './graphql.client.js';
import { MothershipSubscriptionHandler } from './mothership-subscription.handler.js';
import { MothershipHandler } from './mothership.events.js';
import { MothershipController } from './mothership.controller.js';
import { MothershipHandler } from './mothership.events.js';
@Module({
imports: [RemoteAccessModule],
providers: [
ConnectStatusWriterService,
ConnectApiKeyService,
MothershipConnectionService,
MothershipGraphqlClientService,

View File

@@ -1,6 +1,6 @@
{
"name": "@unraid/connect-plugin",
"version": "4.9.2",
"version": "4.9.4",
"private": true,
"dependencies": {
"commander": "14.0.0",

View File

@@ -138,6 +138,34 @@ 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"
# Add unraid-modals element if not already present
if ! grep -q '<unraid-modals>' "/usr/local/emhttp/plugins/dynamix/include/DefaultPageLayout.php"; then
sed -i 's|<body>|<body>\n<unraid-modals></unraid-modals>|' "/usr/local/emhttp/plugins/dynamix/include/DefaultPageLayout.php"
fi
fi
]]>
</INLINE>
</FILE>
<FILE Run="/bin/bash" Method="remove">
<INLINE>
MAINNAME="&name;"

91
pnpm-lock.yaml generated
View File

@@ -913,7 +913,7 @@ importers:
version: 10.1.5(eslint@9.29.0(jiti@2.4.2))
eslint-plugin-import:
specifier: 2.31.0
version: 2.31.0(@typescript-eslint/parser@8.34.1(eslint@9.29.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.29.0(jiti@2.4.2))
version: 2.31.0(@typescript-eslint/parser@8.34.1(eslint@9.29.0(jiti@2.4.2))(typescript@5.8.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.29.0(jiti@2.4.2))
eslint-plugin-no-relative-import-paths:
specifier: 1.6.1
version: 1.6.1
@@ -930,7 +930,7 @@ importers:
specifier: 18.0.0
version: 18.0.0
jiti:
specifier: ^2.4.2
specifier: 2.4.2
version: 2.4.2
postcss:
specifier: 8.5.6
@@ -984,7 +984,7 @@ importers:
specifier: 3.0.1
version: 3.0.1(typescript@5.8.3)
wrangler:
specifier: ^3.87.0
specifier: 3.114.10
version: 3.114.10
optionalDependencies:
'@rollup/rollup-linux-x64-gnu':
@@ -13538,7 +13538,7 @@ snapshots:
'@babel/traverse': 7.27.4
'@babel/types': 7.27.6
convert-source-map: 2.0.0
debug: 4.4.1
debug: 4.4.1(supports-color@5.5.0)
gensync: 1.0.0-beta.2
json5: 2.2.3
semver: 6.3.1
@@ -13879,7 +13879,7 @@ snapshots:
'@babel/parser': 7.27.5
'@babel/template': 7.27.2
'@babel/types': 7.27.6
debug: 4.4.1
debug: 4.4.1(supports-color@5.5.0)
globals: 11.12.0
transitivePeerDependencies:
- supports-color
@@ -14307,7 +14307,7 @@ snapshots:
'@eslint/config-array@0.20.1':
dependencies:
'@eslint/object-schema': 2.1.6
debug: 4.4.1
debug: 4.4.1(supports-color@5.5.0)
minimatch: 3.1.2
transitivePeerDependencies:
- supports-color
@@ -14352,7 +14352,7 @@ snapshots:
'@eslint/eslintrc@3.3.1':
dependencies:
ajv: 6.12.6
debug: 4.4.1
debug: 4.4.1(supports-color@5.5.0)
espree: 10.4.0
globals: 14.0.0
ignore: 5.3.2
@@ -17065,7 +17065,7 @@ snapshots:
'@typescript-eslint/types': 8.34.1
'@typescript-eslint/typescript-estree': 8.34.1(typescript@5.8.3)
'@typescript-eslint/visitor-keys': 8.34.1
debug: 4.4.1
debug: 4.4.1(supports-color@5.5.0)
eslint: 9.29.0(jiti@2.4.2)
typescript: 5.8.3
transitivePeerDependencies:
@@ -17075,7 +17075,7 @@ snapshots:
dependencies:
'@typescript-eslint/tsconfig-utils': 8.34.1(typescript@5.8.3)
'@typescript-eslint/types': 8.34.1
debug: 4.4.1
debug: 4.4.1(supports-color@5.5.0)
typescript: 5.8.3
transitivePeerDependencies:
- supports-color
@@ -17093,7 +17093,7 @@ snapshots:
dependencies:
'@typescript-eslint/typescript-estree': 8.34.1(typescript@5.8.3)
'@typescript-eslint/utils': 8.34.1(eslint@9.29.0(jiti@2.4.2))(typescript@5.8.3)
debug: 4.4.1
debug: 4.4.1(supports-color@5.5.0)
eslint: 9.29.0(jiti@2.4.2)
ts-api-utils: 2.1.0(typescript@5.8.3)
typescript: 5.8.3
@@ -17108,7 +17108,7 @@ snapshots:
'@typescript-eslint/tsconfig-utils': 8.34.1(typescript@5.8.3)
'@typescript-eslint/types': 8.34.1
'@typescript-eslint/visitor-keys': 8.34.1
debug: 4.4.1
debug: 4.4.1(supports-color@5.5.0)
fast-glob: 3.3.3
is-glob: 4.0.3
minimatch: 9.0.5
@@ -17281,7 +17281,7 @@ snapshots:
'@ampproject/remapping': 2.3.0
'@bcoe/v8-coverage': 1.0.2
ast-v8-to-istanbul: 0.3.3
debug: 4.4.1
debug: 4.4.1(supports-color@5.5.0)
istanbul-lib-coverage: 3.2.2
istanbul-lib-report: 3.0.1
istanbul-lib-source-maps: 5.0.6
@@ -19400,10 +19400,6 @@ snapshots:
dependencies:
ms: 2.1.3
debug@4.4.1:
dependencies:
ms: 2.1.3
debug@4.4.1(supports-color@5.5.0):
dependencies:
ms: 2.1.3
@@ -19969,7 +19965,7 @@ snapshots:
esbuild-register@3.6.0(esbuild@0.25.5):
dependencies:
debug: 4.4.1
debug: 4.4.1(supports-color@5.5.0)
esbuild: 0.25.5
transitivePeerDependencies:
- supports-color
@@ -20172,16 +20168,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
eslint-module-utils@2.12.0(@typescript-eslint/parser@8.34.1(eslint@9.29.0(jiti@2.4.2))(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint@9.29.0(jiti@2.4.2)):
dependencies:
debug: 3.2.7
optionalDependencies:
'@typescript-eslint/parser': 8.34.1(eslint@9.29.0(jiti@2.4.2))(typescript@5.8.3)
eslint: 9.29.0(jiti@2.4.2)
eslint-import-resolver-node: 0.3.9
transitivePeerDependencies:
- supports-color
eslint-plugin-es-x@7.8.0(eslint@9.29.0(jiti@2.4.2)):
dependencies:
'@eslint-community/eslint-utils': 4.7.0(eslint@9.29.0(jiti@2.4.2))
@@ -20236,35 +20222,6 @@ snapshots:
- eslint-import-resolver-webpack
- supports-color
eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.34.1(eslint@9.29.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.29.0(jiti@2.4.2)):
dependencies:
'@rtsao/scc': 1.1.0
array-includes: 3.1.8
array.prototype.findlastindex: 1.2.5
array.prototype.flat: 1.3.3
array.prototype.flatmap: 1.3.3
debug: 3.2.7
doctrine: 2.1.0
eslint: 9.29.0(jiti@2.4.2)
eslint-import-resolver-node: 0.3.9
eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.34.1(eslint@9.29.0(jiti@2.4.2))(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint@9.29.0(jiti@2.4.2))
hasown: 2.0.2
is-core-module: 2.16.1
is-glob: 4.0.3
minimatch: 3.1.2
object.fromentries: 2.0.8
object.groupby: 1.0.3
object.values: 1.2.1
semver: 6.3.1
string.prototype.trimend: 1.0.9
tsconfig-paths: 3.15.0
optionalDependencies:
'@typescript-eslint/parser': 8.34.1(eslint@9.29.0(jiti@2.4.2))(typescript@5.8.3)
transitivePeerDependencies:
- eslint-import-resolver-typescript
- eslint-import-resolver-webpack
- supports-color
eslint-plugin-jsdoc@50.8.0(eslint@9.29.0(jiti@2.4.2)):
dependencies:
'@es-joy/jsdoccomment': 0.50.2
@@ -20406,7 +20363,7 @@ snapshots:
ajv: 6.12.6
chalk: 4.1.2
cross-spawn: 7.0.6
debug: 4.4.1
debug: 4.4.1(supports-color@5.5.0)
escape-string-regexp: 4.0.0
eslint-scope: 8.4.0
eslint-visitor-keys: 4.2.1
@@ -21508,7 +21465,7 @@ snapshots:
http-proxy-agent@7.0.2:
dependencies:
agent-base: 7.1.3
debug: 4.4.1
debug: 4.4.1(supports-color@5.5.0)
transitivePeerDependencies:
- supports-color
@@ -21555,7 +21512,7 @@ snapshots:
https-proxy-agent@7.0.6:
dependencies:
agent-base: 7.1.3
debug: 4.4.1
debug: 4.4.1(supports-color@5.5.0)
transitivePeerDependencies:
- supports-color
@@ -22039,7 +21996,7 @@ snapshots:
istanbul-lib-source-maps@5.0.6:
dependencies:
'@jridgewell/trace-mapping': 0.3.25
debug: 4.4.1
debug: 4.4.1(supports-color@5.5.0)
istanbul-lib-coverage: 3.2.2
transitivePeerDependencies:
- supports-color
@@ -24057,7 +24014,7 @@ snapshots:
postcss-styl@0.12.3:
dependencies:
debug: 4.4.1
debug: 4.4.1(supports-color@5.5.0)
fast-diff: 1.3.0
lodash.sortedlastindex: 4.1.0
postcss: 8.5.6
@@ -25434,7 +25391,7 @@ snapshots:
stylus@0.57.0:
dependencies:
css: 3.0.0
debug: 4.4.1
debug: 4.4.1(supports-color@5.5.0)
glob: 7.2.3
safer-buffer: 2.1.2
sax: 1.2.4
@@ -26255,7 +26212,7 @@ snapshots:
vite-node@3.2.4(@types/node@22.15.32)(jiti@2.4.2)(stylus@0.57.0)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0):
dependencies:
cac: 6.7.14
debug: 4.4.1
debug: 4.4.1(supports-color@5.5.0)
es-module-lexer: 1.7.0
pathe: 2.0.3
vite: 7.0.3(@types/node@22.15.32)(jiti@2.4.2)(stylus@0.57.0)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0)
@@ -26297,7 +26254,7 @@ snapshots:
'@microsoft/api-extractor': 7.43.0(@types/node@22.15.32)
'@rollup/pluginutils': 5.2.0(rollup@4.44.0)
'@vue/language-core': 1.8.27(typescript@5.8.3)
debug: 4.4.1
debug: 4.4.1(supports-color@5.5.0)
kolorist: 1.8.0
magic-string: 0.30.17
typescript: 5.8.3
@@ -26313,7 +26270,7 @@ snapshots:
dependencies:
'@antfu/utils': 0.7.10
'@rollup/pluginutils': 5.2.0(rollup@4.44.0)
debug: 4.4.1
debug: 4.4.1(supports-color@5.5.0)
error-stack-parser-es: 0.1.5
fs-extra: 11.3.0
open: 10.1.2
@@ -26549,7 +26506,7 @@ snapshots:
'@vitest/spy': 3.2.4
'@vitest/utils': 3.2.4
chai: 5.2.0
debug: 4.4.1
debug: 4.4.1(supports-color@5.5.0)
expect-type: 1.2.1
magic-string: 0.30.17
pathe: 2.0.3
@@ -26634,7 +26591,7 @@ snapshots:
vue-eslint-parser@10.1.3(eslint@9.29.0(jiti@2.4.2)):
dependencies:
debug: 4.4.1
debug: 4.4.1(supports-color@5.5.0)
eslint: 9.29.0(jiti@2.4.2)
eslint-scope: 8.4.0
eslint-visitor-keys: 4.2.1

View File

@@ -1,6 +1,6 @@
{
"name": "@unraid/ui",
"version": "4.9.2",
"version": "4.9.4",
"private": true,
"license": "GPL-2.0-or-later",
"type": "module",
@@ -94,7 +94,7 @@
"eslint-plugin-storybook": "9.0.16",
"eslint-plugin-vue": "10.2.0",
"happy-dom": "18.0.0",
"jiti": "^2.4.2",
"jiti": "2.4.2",
"postcss": "8.5.6",
"postcss-import": "16.1.1",
"prettier": "3.5.3",
@@ -112,7 +112,7 @@
"vitest": "3.2.4",
"vue": "3.5.17",
"vue-tsc": "3.0.1",
"wrangler": "^3.87.0"
"wrangler": "3.114.10"
},
"optionalDependencies": {
"@rollup/rollup-linux-x64-gnu": "4.44.0"

View File

@@ -1,6 +1,6 @@
{
"name": "@unraid/web",
"version": "4.9.2",
"version": "4.9.4",
"private": true,
"license": "GPL-2.0-or-later",
"scripts": {