mirror of
https://github.com/unraid/api.git
synced 2026-01-02 14:40:01 -06:00
Compare commits
12 Commits
feat/build
...
v4.19.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d3459ecbc6 | ||
|
|
534a07788b | ||
|
|
239cdd6133 | ||
|
|
77cfc07dda | ||
|
|
728b38ac11 | ||
|
|
44774d0acd | ||
|
|
e204eb80a0 | ||
|
|
0c727c37f4 | ||
|
|
292bc0fc81 | ||
|
|
53f501e1a7 | ||
|
|
6cf7c88242 | ||
|
|
33774aa596 |
@@ -1 +1 @@
|
||||
{".":"4.18.2"}
|
||||
{".":"4.19.0"}
|
||||
|
||||
@@ -76,4 +76,21 @@ body {
|
||||
button:not(:disabled),
|
||||
[role='button']:not(:disabled) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Font size overrides for SSO button component */
|
||||
unraid-sso-button {
|
||||
--text-xs: 0.75rem;
|
||||
--text-sm: 0.875rem;
|
||||
--text-base: 1rem;
|
||||
--text-lg: 1.125rem;
|
||||
--text-xl: 1.25rem;
|
||||
--text-2xl: 1.5rem;
|
||||
--text-3xl: 1.875rem;
|
||||
--text-4xl: 2.25rem;
|
||||
--text-5xl: 3rem;
|
||||
--text-6xl: 3.75rem;
|
||||
--text-7xl: 4.5rem;
|
||||
--text-8xl: 6rem;
|
||||
--text-9xl: 8rem;
|
||||
}
|
||||
@@ -229,6 +229,8 @@
|
||||
top: 0;
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
min-width: inherit !important;
|
||||
margin: 0 !important;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
@@ -696,4 +698,11 @@
|
||||
.sonner-loader[data-visible='false'] {
|
||||
opacity: 0;
|
||||
transform: scale(0.8) translate(-50%, -50%);
|
||||
}
|
||||
|
||||
/* Override Unraid webgui docker icon styles on sonner containers */
|
||||
[data-sonner-toast] [data-icon]:before,
|
||||
[data-sonner-toast] .fa-docker:before {
|
||||
font-family: inherit !important;
|
||||
content: '' !important;
|
||||
}
|
||||
@@ -167,6 +167,27 @@
|
||||
--max-width-800px: 800px;
|
||||
--max-width-1024px: 1024px;
|
||||
|
||||
/* Container sizes adjusted for 10px base font size (1.6x scale) */
|
||||
--container-xs: 32rem;
|
||||
--container-sm: 38.4rem;
|
||||
--container-md: 44.8rem;
|
||||
--container-lg: 51.2rem;
|
||||
--container-xl: 57.6rem;
|
||||
--container-2xl: 67.2rem;
|
||||
--container-3xl: 76.8rem;
|
||||
--container-4xl: 89.6rem;
|
||||
--container-5xl: 102.4rem;
|
||||
--container-6xl: 115.2rem;
|
||||
--container-7xl: 128rem;
|
||||
|
||||
/* Extended width scale for max-w-* utilities */
|
||||
--width-5xl: 102.4rem;
|
||||
--width-6xl: 115.2rem;
|
||||
--width-7xl: 128rem;
|
||||
--width-8xl: 140.8rem;
|
||||
--width-9xl: 153.6rem;
|
||||
--width-10xl: 166.4rem;
|
||||
|
||||
/* Animations */
|
||||
--animate-mark-2: mark-2 1.5s ease infinite;
|
||||
--animate-mark-3: mark-3 1.5s ease infinite;
|
||||
|
||||
@@ -1,5 +1,24 @@
|
||||
# Changelog
|
||||
|
||||
## [4.19.0](https://github.com/unraid/api/compare/v4.18.2...v4.19.0) (2025-09-04)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* mount vue apps, not web components ([#1639](https://github.com/unraid/api/issues/1639)) ([88087d5](https://github.com/unraid/api/commit/88087d5201992298cdafa791d5d1b5bb23dcd72b))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* api version json response ([#1653](https://github.com/unraid/api/issues/1653)) ([292bc0f](https://github.com/unraid/api/commit/292bc0fc810a0d0f0cce6813b0631ff25099cc05))
|
||||
* enhance DOM validation and cleanup in vue-mount-app ([6cf7c88](https://github.com/unraid/api/commit/6cf7c88242f2f4fe9f83871560039767b5b90273))
|
||||
* enhance getKeyFile function to handle missing key file gracefully ([#1659](https://github.com/unraid/api/issues/1659)) ([728b38a](https://github.com/unraid/api/commit/728b38ac11faeacd39ce9d0157024ad140e29b36))
|
||||
* info alert docker icon ([#1661](https://github.com/unraid/api/issues/1661)) ([239cdd6](https://github.com/unraid/api/commit/239cdd6133690699348e61f68e485d2b54fdcbdb))
|
||||
* oidc cache busting issues fixed ([#1656](https://github.com/unraid/api/issues/1656)) ([e204eb8](https://github.com/unraid/api/commit/e204eb80a00ab9242e3dca4ccfc3e1b55a7694b7))
|
||||
* **plugin:** restore cleanup behavior for unsupported unraid versions ([#1658](https://github.com/unraid/api/issues/1658)) ([534a077](https://github.com/unraid/api/commit/534a07788b76de49e9ba14059a9aed0bf16e02ca))
|
||||
* UnraidToaster component and update dialog close button ([#1657](https://github.com/unraid/api/issues/1657)) ([44774d0](https://github.com/unraid/api/commit/44774d0acdd25aa33cb60a5d0b4f80777f4068e5))
|
||||
* vue mounting logic with tests ([#1651](https://github.com/unraid/api/issues/1651)) ([33774aa](https://github.com/unraid/api/commit/33774aa596124a031a7452b62ca4c43743a09951))
|
||||
|
||||
## [4.18.2](https://github.com/unraid/api/compare/v4.18.1...v4.18.2) (2025-09-03)
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "4.18.1",
|
||||
"version": "4.18.2",
|
||||
"extraOrigins": [],
|
||||
"sandbox": true,
|
||||
"ssoSubIds": [],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@unraid/api",
|
||||
"version": "4.18.2",
|
||||
"version": "4.19.0",
|
||||
"main": "src/cli/index.ts",
|
||||
"type": "module",
|
||||
"corepack": {
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { expect, test } from 'vitest';
|
||||
import { expect, test, vi } from 'vitest';
|
||||
|
||||
import { store } from '@app/store/index.js';
|
||||
import { FileLoadStatus, StateFileKey } from '@app/store/types.js';
|
||||
|
||||
import '@app/core/utils/misc/get-key-file.js';
|
||||
import '@app/store/modules/emhttp.js';
|
||||
|
||||
vi.mock('fs/promises');
|
||||
|
||||
test('Before loading key returns null', async () => {
|
||||
const { getKeyFile } = await import('@app/core/utils/misc/get-key-file.js');
|
||||
const { status } = store.getState().registration;
|
||||
@@ -48,21 +49,70 @@ test('Returns empty key if key location is empty', async () => {
|
||||
await expect(getKeyFile()).resolves.toBe('');
|
||||
});
|
||||
|
||||
test(
|
||||
'Returns decoded key file if key location exists',
|
||||
async () => {
|
||||
const { getKeyFile } = await import('@app/core/utils/misc/get-key-file.js');
|
||||
const { loadStateFiles } = await import('@app/store/modules/emhttp.js');
|
||||
const { loadRegistrationKey } = await import('@app/store/modules/registration.js');
|
||||
// Load state files into store
|
||||
await store.dispatch(loadStateFiles());
|
||||
await store.dispatch(loadRegistrationKey());
|
||||
// Check if store has state files loaded
|
||||
const { status } = store.getState().registration;
|
||||
expect(status).toBe(FileLoadStatus.LOADED);
|
||||
await expect(getKeyFile()).resolves.toMatchInlineSnapshot(
|
||||
'"hVs1tLjvC9FiiQsIwIQ7G1KszAcexf0IneThhnmf22SB0dGs5WzRkqMiSMmt2DtR5HOXFUD32YyxuzGeUXmky3zKpSu6xhZNKVg5atGM1OfvkzHBMldI3SeBLuUFSgejLbpNUMdTrbk64JJdbzle4O8wiQgkIpAMIGxeYLwLBD4zHBcfyzq40QnxG--HcX6j25eE0xqa2zWj-j0b0rCAXahJV2a3ySCbPzr1MvfPRTVb0rr7KJ-25R592hYrz4H7Sc1B3p0lr6QUxHE6o7bcYrWKDRtIVoZ8SMPpd1_0gzYIcl5GsDFzFumTXUh8NEnl0Q8hwW1YE-tRc6Y_rrvd7w"'
|
||||
);
|
||||
},
|
||||
{ timeout: 10000 }
|
||||
);
|
||||
test('Returns empty string when key file does not exist (ENOENT)', async () => {
|
||||
const { readFile } = await import('fs/promises');
|
||||
|
||||
// Mock readFile to throw ENOENT error
|
||||
const readFileMock = vi.mocked(readFile);
|
||||
readFileMock.mockRejectedValueOnce(
|
||||
Object.assign(new Error('ENOENT: no such file or directory'), { code: 'ENOENT' })
|
||||
);
|
||||
|
||||
// Clear the module cache and re-import to get fresh module with mock
|
||||
vi.resetModules();
|
||||
const { getKeyFile } = await import('@app/core/utils/misc/get-key-file.js');
|
||||
const { updateEmhttpState } = await import('@app/store/modules/emhttp.js');
|
||||
const { store: freshStore } = await import('@app/store/index.js');
|
||||
|
||||
// Set key file location to a non-existent file
|
||||
freshStore.dispatch(
|
||||
updateEmhttpState({
|
||||
field: StateFileKey.var,
|
||||
state: {
|
||||
regFile: '/boot/config/Pro.key',
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
// Should return empty string when file doesn't exist
|
||||
await expect(getKeyFile()).resolves.toBe('');
|
||||
|
||||
// Clear mock
|
||||
readFileMock.mockReset();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
test('Returns decoded key file if key location exists', async () => {
|
||||
const { readFile } = await import('fs/promises');
|
||||
|
||||
// Mock a valid key file content
|
||||
const mockKeyContent =
|
||||
'hVs1tLjvC9FiiQsIwIQ7G1KszAcexf0IneThhnmf22SB0dGs5WzRkqMiSMmt2DtR5HOXFUD32YyxuzGeUXmky3zKpSu6xhZNKVg5atGM1OfvkzHBMldI3SeBLuUFSgejLbpNUMdTrbk64JJdbzle4O8wiQgkIpAMIGxeYLwLBD4zHBcfyzq40QnxG--HcX6j25eE0xqa2zWj-j0b0rCAXahJV2a3ySCbPzr1MvfPRTVb0rr7KJ-25R592hYrz4H7Sc1B3p0lr6QUxHE6o7bcYrWKDRtIVoZ8SMPpd1_0gzYIcl5GsDFzFumTXUh8NEnl0Q8hwW1YE-tRc6Y_rrvd7w==';
|
||||
const binaryContent = Buffer.from(mockKeyContent, 'base64').toString('binary');
|
||||
|
||||
const readFileMock = vi.mocked(readFile);
|
||||
readFileMock.mockResolvedValue(binaryContent);
|
||||
|
||||
// Clear the module cache and re-import to get fresh module with mock
|
||||
vi.resetModules();
|
||||
const { getKeyFile } = await import('@app/core/utils/misc/get-key-file.js');
|
||||
const { loadStateFiles } = await import('@app/store/modules/emhttp.js');
|
||||
const { loadRegistrationKey } = await import('@app/store/modules/registration.js');
|
||||
const { store: freshStore } = await import('@app/store/index.js');
|
||||
|
||||
// Load state files into store
|
||||
await freshStore.dispatch(loadStateFiles());
|
||||
await freshStore.dispatch(loadRegistrationKey());
|
||||
// Check if store has state files loaded
|
||||
const { status } = freshStore.getState().registration;
|
||||
expect(status).toBe(FileLoadStatus.LOADED);
|
||||
|
||||
const result = await getKeyFile();
|
||||
expect(result).toBe(
|
||||
'hVs1tLjvC9FiiQsIwIQ7G1KszAcexf0IneThhnmf22SB0dGs5WzRkqMiSMmt2DtR5HOXFUD32YyxuzGeUXmky3zKpSu6xhZNKVg5atGM1OfvkzHBMldI3SeBLuUFSgejLbpNUMdTrbk64JJdbzle4O8wiQgkIpAMIGxeYLwLBD4zHBcfyzq40QnxG--HcX6j25eE0xqa2zWj-j0b0rCAXahJV2a3ySCbPzr1MvfPRTVb0rr7KJ-25R592hYrz4H7Sc1B3p0lr6QUxHE6o7bcYrWKDRtIVoZ8SMPpd1_0gzYIcl5GsDFzFumTXUh8NEnl0Q8hwW1YE-tRc6Y_rrvd7w'
|
||||
);
|
||||
|
||||
// Clear mock
|
||||
readFileMock.mockReset();
|
||||
vi.resetModules();
|
||||
}, 10000);
|
||||
|
||||
@@ -16,11 +16,22 @@ export const getKeyFile = async function (appStore: RootState = store.getState()
|
||||
|
||||
const keyFileName = basename(emhttp.var?.regFile);
|
||||
const registrationKeyFilePath = join(paths['keyfile-base'], keyFileName);
|
||||
const keyFile = await readFile(registrationKeyFilePath, 'binary');
|
||||
return Buffer.from(keyFile, 'binary')
|
||||
.toString('base64')
|
||||
.trim()
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=/g, '');
|
||||
|
||||
try {
|
||||
const keyFile = await readFile(registrationKeyFilePath, 'binary');
|
||||
return Buffer.from(keyFile, 'binary')
|
||||
.toString('base64')
|
||||
.trim()
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=/g, '');
|
||||
} catch (error) {
|
||||
// Handle ENOENT error when Pro.key file doesn't exist
|
||||
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
|
||||
// Return empty string when key file is missing (ENOKEYFILE state)
|
||||
return '';
|
||||
}
|
||||
// Re-throw other errors
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
111
api/src/unraid-api/cli/__test__/version.command.test.ts
Normal file
111
api/src/unraid-api/cli/__test__/version.command.test.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, MockInstance, vi } from 'vitest';
|
||||
|
||||
import { LogService } from '@app/unraid-api/cli/log.service.js';
|
||||
import { VersionCommand } from '@app/unraid-api/cli/version.command.js';
|
||||
|
||||
let API_VERSION_MOCK = '4.18.2+build123';
|
||||
|
||||
vi.mock('@app/environment.js', async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as any;
|
||||
return {
|
||||
...actual,
|
||||
get API_VERSION() {
|
||||
return API_VERSION_MOCK;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe('VersionCommand', () => {
|
||||
let command: VersionCommand;
|
||||
let logService: LogService;
|
||||
let consoleLogSpy: MockInstance<typeof console.log>;
|
||||
|
||||
beforeEach(async () => {
|
||||
API_VERSION_MOCK = '4.18.2+build123'; // Reset to default before each test
|
||||
consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
VersionCommand,
|
||||
{
|
||||
provide: LogService,
|
||||
useValue: {
|
||||
info: vi.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
command = module.get<VersionCommand>(VersionCommand);
|
||||
logService = module.get<LogService>(LogService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('run', () => {
|
||||
it('should output version with logger when no options provided', async () => {
|
||||
await command.run([]);
|
||||
|
||||
expect(logService.info).toHaveBeenCalledWith('Unraid API v4.18.2+build123');
|
||||
expect(consoleLogSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should output version with logger when json option is false', async () => {
|
||||
await command.run([], { json: false });
|
||||
|
||||
expect(logService.info).toHaveBeenCalledWith('Unraid API v4.18.2+build123');
|
||||
expect(consoleLogSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should output JSON when json option is true', async () => {
|
||||
await command.run([], { json: true });
|
||||
|
||||
expect(logService.info).not.toHaveBeenCalled();
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
JSON.stringify({
|
||||
version: '4.18.2',
|
||||
build: 'build123',
|
||||
combined: '4.18.2+build123',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle version without build info', async () => {
|
||||
API_VERSION_MOCK = '4.18.2'; // Set version without build info
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
VersionCommand,
|
||||
{
|
||||
provide: LogService,
|
||||
useValue: {
|
||||
info: vi.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
const commandWithoutBuild = module.get<VersionCommand>(VersionCommand);
|
||||
|
||||
await commandWithoutBuild.run([], { json: true });
|
||||
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
JSON.stringify({
|
||||
version: '4.18.2',
|
||||
build: undefined,
|
||||
combined: '4.18.2',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseJson', () => {
|
||||
it('should return true', () => {
|
||||
expect(command.parseJson()).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,14 +1,37 @@
|
||||
import { Command, CommandRunner } from 'nest-commander';
|
||||
import { Command, CommandRunner, Option } from 'nest-commander';
|
||||
|
||||
import { API_VERSION } from '@app/environment.js';
|
||||
import { LogService } from '@app/unraid-api/cli/log.service.js';
|
||||
|
||||
@Command({ name: 'version' })
|
||||
interface VersionOptions {
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
@Command({ name: 'version', description: 'Display API version information' })
|
||||
export class VersionCommand extends CommandRunner {
|
||||
constructor(private readonly logger: LogService) {
|
||||
super();
|
||||
}
|
||||
async run(): Promise<void> {
|
||||
this.logger.info(`Unraid API v${API_VERSION}`);
|
||||
|
||||
@Option({
|
||||
flags: '-j, --json',
|
||||
description: 'Output version information as JSON',
|
||||
})
|
||||
parseJson(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
async run(passedParam: string[], options?: VersionOptions): Promise<void> {
|
||||
if (options?.json) {
|
||||
const [baseVersion, buildInfo] = API_VERSION.split('+');
|
||||
const versionInfo = {
|
||||
version: baseVersion || API_VERSION,
|
||||
build: buildInfo || undefined,
|
||||
combined: API_VERSION,
|
||||
};
|
||||
console.log(JSON.stringify(versionInfo));
|
||||
} else {
|
||||
this.logger.info(`Unraid API v${API_VERSION}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Test } from '@nestjs/testing';
|
||||
|
||||
import * as client from 'openid-client';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { OidcClientConfigService } from '@app/unraid-api/graph/resolvers/sso/client/oidc-client-config.service.js';
|
||||
import { OidcValidationService } from '@app/unraid-api/graph/resolvers/sso/core/oidc-validation.service.js';
|
||||
import { OidcProvider } from '@app/unraid-api/graph/resolvers/sso/models/oidc-provider.model.js';
|
||||
|
||||
vi.mock('openid-client');
|
||||
|
||||
describe('OidcClientConfigService - Cache Behavior', () => {
|
||||
let service: OidcClientConfigService;
|
||||
let validationService: OidcValidationService;
|
||||
|
||||
const createMockProvider = (port: number): OidcProvider => ({
|
||||
id: 'test-provider',
|
||||
name: 'Test Provider',
|
||||
clientId: 'test-client-id',
|
||||
clientSecret: 'test-secret',
|
||||
issuer: `http://localhost:${port}`,
|
||||
scopes: ['openid', 'profile', 'email'],
|
||||
authorizationRules: [],
|
||||
});
|
||||
|
||||
const createMockConfiguration = (port: number) => {
|
||||
const mockConfig = {
|
||||
serverMetadata: vi.fn(() => ({
|
||||
issuer: `http://localhost:${port}`,
|
||||
authorization_endpoint: `http://localhost:${port}/auth`,
|
||||
token_endpoint: `http://localhost:${port}/token`,
|
||||
jwks_uri: `http://localhost:${port}/jwks`,
|
||||
userinfo_endpoint: `http://localhost:${port}/userinfo`,
|
||||
})),
|
||||
};
|
||||
return mockConfig as unknown as client.Configuration;
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
const mockConfigService = {
|
||||
get: vi.fn(),
|
||||
set: vi.fn(),
|
||||
};
|
||||
|
||||
const module = await Test.createTestingModule({
|
||||
providers: [
|
||||
OidcClientConfigService,
|
||||
OidcValidationService,
|
||||
{
|
||||
provide: ConfigService,
|
||||
useValue: mockConfigService,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<OidcClientConfigService>(OidcClientConfigService);
|
||||
validationService = module.get<OidcValidationService>(OidcValidationService);
|
||||
});
|
||||
|
||||
describe('Configuration Caching', () => {
|
||||
it('should cache configuration on first call', async () => {
|
||||
const provider = createMockProvider(1029);
|
||||
const mockConfig = createMockConfiguration(1029);
|
||||
|
||||
vi.spyOn(validationService, 'performDiscovery').mockResolvedValueOnce(mockConfig);
|
||||
|
||||
// First call
|
||||
const config1 = await service.getOrCreateConfig(provider);
|
||||
expect(validationService.performDiscovery).toHaveBeenCalledTimes(1);
|
||||
expect(config1.serverMetadata().issuer).toBe('http://localhost:1029');
|
||||
|
||||
// Second call with same provider ID should use cache
|
||||
const config2 = await service.getOrCreateConfig(provider);
|
||||
expect(validationService.performDiscovery).toHaveBeenCalledTimes(1);
|
||||
expect(config2).toBe(config1);
|
||||
});
|
||||
|
||||
it('should return stale cached configuration when issuer changes without cache clear', async () => {
|
||||
const provider1029 = createMockProvider(1029);
|
||||
const provider1030 = createMockProvider(1030);
|
||||
const mockConfig1029 = createMockConfiguration(1029);
|
||||
const mockConfig1030 = createMockConfiguration(1030);
|
||||
|
||||
vi.spyOn(validationService, 'performDiscovery')
|
||||
.mockResolvedValueOnce(mockConfig1029)
|
||||
.mockResolvedValueOnce(mockConfig1030);
|
||||
|
||||
// Initial configuration on port 1029
|
||||
const config1 = await service.getOrCreateConfig(provider1029);
|
||||
expect(config1.serverMetadata().issuer).toBe('http://localhost:1029');
|
||||
expect(config1.serverMetadata().authorization_endpoint).toBe('http://localhost:1029/auth');
|
||||
|
||||
// Update provider to port 1030 (simulating UI change)
|
||||
// Without clearing cache, it should still return the old cached config
|
||||
const config2 = await service.getOrCreateConfig(provider1030);
|
||||
|
||||
// THIS IS THE BUG: The service returns cached config for port 1029
|
||||
// even though the provider now has issuer on port 1030
|
||||
expect(config2.serverMetadata().issuer).toBe('http://localhost:1029');
|
||||
expect(config2.serverMetadata().authorization_endpoint).toBe('http://localhost:1029/auth');
|
||||
|
||||
// performDiscovery should only be called once because cache is used
|
||||
expect(validationService.performDiscovery).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should return fresh configuration after cache is cleared', async () => {
|
||||
const provider1029 = createMockProvider(1029);
|
||||
const provider1030 = createMockProvider(1030);
|
||||
const mockConfig1029 = createMockConfiguration(1029);
|
||||
const mockConfig1030 = createMockConfiguration(1030);
|
||||
|
||||
vi.spyOn(validationService, 'performDiscovery')
|
||||
.mockResolvedValueOnce(mockConfig1029)
|
||||
.mockResolvedValueOnce(mockConfig1030);
|
||||
|
||||
// Initial configuration on port 1029
|
||||
const config1 = await service.getOrCreateConfig(provider1029);
|
||||
expect(config1.serverMetadata().issuer).toBe('http://localhost:1029');
|
||||
|
||||
// Clear cache for the provider
|
||||
service.clearCache(provider1030.id);
|
||||
|
||||
// Now it should fetch fresh config for port 1030
|
||||
const config2 = await service.getOrCreateConfig(provider1030);
|
||||
expect(config2.serverMetadata().issuer).toBe('http://localhost:1030');
|
||||
expect(config2.serverMetadata().authorization_endpoint).toBe('http://localhost:1030/auth');
|
||||
|
||||
// performDiscovery should be called twice (once for each port)
|
||||
expect(validationService.performDiscovery).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should clear all provider caches when clearCache is called without providerId', async () => {
|
||||
const provider1 = { ...createMockProvider(1029), id: 'provider1' };
|
||||
const provider2 = { ...createMockProvider(1030), id: 'provider2' };
|
||||
const mockConfig1 = createMockConfiguration(1029);
|
||||
const mockConfig2 = createMockConfiguration(1030);
|
||||
|
||||
vi.spyOn(validationService, 'performDiscovery')
|
||||
.mockResolvedValueOnce(mockConfig1)
|
||||
.mockResolvedValueOnce(mockConfig2)
|
||||
.mockResolvedValueOnce(mockConfig1)
|
||||
.mockResolvedValueOnce(mockConfig2);
|
||||
|
||||
// Cache both providers
|
||||
await service.getOrCreateConfig(provider1);
|
||||
await service.getOrCreateConfig(provider2);
|
||||
expect(service.getCacheSize()).toBe(2);
|
||||
|
||||
// Clear all caches
|
||||
service.clearCache();
|
||||
expect(service.getCacheSize()).toBe(0);
|
||||
|
||||
// Both should fetch fresh configs
|
||||
await service.getOrCreateConfig(provider1);
|
||||
await service.getOrCreateConfig(provider2);
|
||||
|
||||
// performDiscovery should be called 4 times total
|
||||
expect(validationService.performDiscovery).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Manual Configuration Caching', () => {
|
||||
it('should cache manual configuration and exhibit same stale cache issue', async () => {
|
||||
const provider1029: OidcProvider = {
|
||||
id: 'manual-provider',
|
||||
name: 'Manual Provider',
|
||||
clientId: 'client-id',
|
||||
clientSecret: 'secret',
|
||||
issuer: '',
|
||||
authorizationEndpoint: 'http://localhost:1029/auth',
|
||||
tokenEndpoint: 'http://localhost:1029/token',
|
||||
scopes: ['openid'],
|
||||
authorizationRules: [],
|
||||
};
|
||||
|
||||
const provider1030: OidcProvider = {
|
||||
...provider1029,
|
||||
authorizationEndpoint: 'http://localhost:1030/auth',
|
||||
tokenEndpoint: 'http://localhost:1030/token',
|
||||
};
|
||||
|
||||
// Mock the client.Configuration constructor for manual configs
|
||||
const mockManualConfig1029 = createMockConfiguration(1029);
|
||||
const mockManualConfig1030 = createMockConfiguration(1030);
|
||||
|
||||
let configCallCount = 0;
|
||||
vi.mocked(client.Configuration).mockImplementation(() => {
|
||||
configCallCount++;
|
||||
return configCallCount === 1 ? mockManualConfig1029 : mockManualConfig1030;
|
||||
});
|
||||
|
||||
vi.mocked(client.ClientSecretPost).mockReturnValue({} as any);
|
||||
vi.mocked(client.allowInsecureRequests).mockImplementation(() => {});
|
||||
|
||||
// First call with port 1029
|
||||
const config1 = await service.getOrCreateConfig(provider1029);
|
||||
expect(config1.serverMetadata().authorization_endpoint).toBe('http://localhost:1029/auth');
|
||||
|
||||
// Update to port 1030 without clearing cache
|
||||
const config2 = await service.getOrCreateConfig(provider1030);
|
||||
|
||||
// BUG: Still returns cached config with port 1029
|
||||
expect(config2.serverMetadata().authorization_endpoint).toBe('http://localhost:1029/auth');
|
||||
|
||||
// Clear cache and try again
|
||||
service.clearCache(provider1030.id);
|
||||
const config3 = await service.getOrCreateConfig(provider1030);
|
||||
|
||||
// Now it should return the updated config
|
||||
expect(config3.serverMetadata().authorization_endpoint).toBe('http://localhost:1030/auth');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { forwardRef, Module } from '@nestjs/common';
|
||||
|
||||
import { OidcClientConfigService } from '@app/unraid-api/graph/resolvers/sso/client/oidc-client-config.service.js';
|
||||
import { OidcRedirectUriService } from '@app/unraid-api/graph/resolvers/sso/client/oidc-redirect-uri.service.js';
|
||||
import { OidcBaseModule } from '@app/unraid-api/graph/resolvers/sso/core/oidc-base.module.js';
|
||||
|
||||
@Module({
|
||||
imports: [OidcBaseModule],
|
||||
imports: [forwardRef(() => OidcBaseModule)],
|
||||
providers: [OidcClientConfigService, OidcRedirectUriService],
|
||||
exports: [OidcClientConfigService, OidcRedirectUriService],
|
||||
})
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { forwardRef, Module } from '@nestjs/common';
|
||||
|
||||
import { UserSettingsModule } from '@unraid/shared/services/user-settings.js';
|
||||
|
||||
import { OidcClientModule } from '@app/unraid-api/graph/resolvers/sso/client/oidc-client.module.js';
|
||||
import { OidcConfigPersistence } from '@app/unraid-api/graph/resolvers/sso/core/oidc-config.service.js';
|
||||
import { OidcValidationService } from '@app/unraid-api/graph/resolvers/sso/core/oidc-validation.service.js';
|
||||
|
||||
@Module({
|
||||
imports: [UserSettingsModule],
|
||||
imports: [UserSettingsModule, forwardRef(() => OidcClientModule)],
|
||||
providers: [OidcConfigPersistence, OidcValidationService],
|
||||
exports: [OidcConfigPersistence, OidcValidationService],
|
||||
})
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import * as fs from 'fs/promises';
|
||||
|
||||
import { UserSettingsService } from '@unraid/shared/services/user-settings.js';
|
||||
import * as client from 'openid-client';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { OidcClientConfigService } from '@app/unraid-api/graph/resolvers/sso/client/oidc-client-config.service.js';
|
||||
import { OidcConfigPersistence } from '@app/unraid-api/graph/resolvers/sso/core/oidc-config.service.js';
|
||||
import { OidcValidationService } from '@app/unraid-api/graph/resolvers/sso/core/oidc-validation.service.js';
|
||||
import { OidcProvider } from '@app/unraid-api/graph/resolvers/sso/models/oidc-provider.model.js';
|
||||
|
||||
vi.mock('openid-client');
|
||||
vi.mock('fs/promises', () => ({
|
||||
writeFile: vi.fn().mockResolvedValue(undefined),
|
||||
mkdir: vi.fn().mockResolvedValue(undefined),
|
||||
stat: vi.fn().mockRejectedValue(new Error('File not found')),
|
||||
}));
|
||||
|
||||
describe('OIDC Config Cache Fix - Integration Test', () => {
|
||||
let configPersistence: OidcConfigPersistence;
|
||||
let clientConfigService: OidcClientConfigService;
|
||||
let mockConfigService: any;
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.PATHS_CONFIG;
|
||||
});
|
||||
|
||||
const createMockProvider = (port: number): OidcProvider => ({
|
||||
id: 'test-provider',
|
||||
name: 'Test Provider',
|
||||
clientId: 'test-client-id',
|
||||
clientSecret: 'test-secret',
|
||||
issuer: `http://localhost:${port}`,
|
||||
scopes: ['openid', 'profile', 'email'],
|
||||
authorizationRules: [
|
||||
{
|
||||
claim: 'email',
|
||||
operator: 'endsWith' as any,
|
||||
value: ['@example.com'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const createMockConfiguration = (port: number) => {
|
||||
const mockConfig = {
|
||||
serverMetadata: vi.fn(() => ({
|
||||
issuer: `http://localhost:${port}`,
|
||||
authorization_endpoint: `http://localhost:${port}/auth`,
|
||||
token_endpoint: `http://localhost:${port}/token`,
|
||||
jwks_uri: `http://localhost:${port}/jwks`,
|
||||
userinfo_endpoint: `http://localhost:${port}/userinfo`,
|
||||
})),
|
||||
};
|
||||
return mockConfig as unknown as client.Configuration;
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Set environment variable for config path
|
||||
process.env.PATHS_CONFIG = '/tmp/test-config';
|
||||
|
||||
mockConfigService = {
|
||||
get: vi.fn((key: string) => {
|
||||
if (key === 'oidc') {
|
||||
return {
|
||||
providers: [createMockProvider(1029)],
|
||||
defaultAllowedOrigins: [],
|
||||
};
|
||||
}
|
||||
if (key === 'paths.config') {
|
||||
return '/tmp/test-config';
|
||||
}
|
||||
return undefined;
|
||||
}),
|
||||
set: vi.fn(),
|
||||
getOrThrow: vi.fn((key: string) => {
|
||||
if (key === 'paths.config' || key === 'paths') {
|
||||
return '/tmp/test-config';
|
||||
}
|
||||
return '/tmp/test-config';
|
||||
}),
|
||||
};
|
||||
|
||||
const mockUserSettingsService = {
|
||||
register: vi.fn(),
|
||||
getAllSettings: vi.fn(),
|
||||
getAllValues: vi.fn(),
|
||||
updateNamespacedValues: vi.fn(),
|
||||
};
|
||||
|
||||
const module = await Test.createTestingModule({
|
||||
providers: [
|
||||
OidcConfigPersistence,
|
||||
OidcClientConfigService,
|
||||
OidcValidationService,
|
||||
{
|
||||
provide: ConfigService,
|
||||
useValue: mockConfigService,
|
||||
},
|
||||
{
|
||||
provide: UserSettingsService,
|
||||
useValue: mockUserSettingsService,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
configPersistence = module.get<OidcConfigPersistence>(OidcConfigPersistence);
|
||||
clientConfigService = module.get<OidcClientConfigService>(OidcClientConfigService);
|
||||
|
||||
// Mock the persist method since we don't want to write to disk in tests
|
||||
vi.spyOn(configPersistence as any, 'persist').mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
describe('Cache clearing on provider update', () => {
|
||||
it('should clear cache when provider is updated via upsertProvider', async () => {
|
||||
const provider1029 = createMockProvider(1029);
|
||||
const provider1030 = createMockProvider(1030);
|
||||
const mockConfig1029 = createMockConfiguration(1029);
|
||||
const mockConfig1030 = createMockConfiguration(1030);
|
||||
|
||||
// Mock validation service to return configs
|
||||
const validationService = (configPersistence as any).validationService;
|
||||
vi.spyOn(validationService, 'performDiscovery')
|
||||
.mockResolvedValueOnce(mockConfig1029)
|
||||
.mockResolvedValueOnce(mockConfig1030);
|
||||
|
||||
// First, get config for port 1029 - this caches it
|
||||
const config1 = await clientConfigService.getOrCreateConfig(provider1029);
|
||||
expect(config1.serverMetadata().issuer).toBe('http://localhost:1029');
|
||||
|
||||
// Spy on clearCache method
|
||||
const clearCacheSpy = vi.spyOn(clientConfigService, 'clearCache');
|
||||
|
||||
// Update the provider to port 1030 via upsertProvider
|
||||
await configPersistence.upsertProvider(provider1030);
|
||||
|
||||
// Verify cache was cleared for this specific provider
|
||||
expect(clearCacheSpy).toHaveBeenCalledWith(provider1030.id);
|
||||
|
||||
// Now get config again - should fetch fresh config for port 1030
|
||||
const config2 = await clientConfigService.getOrCreateConfig(provider1030);
|
||||
expect(config2.serverMetadata().issuer).toBe('http://localhost:1030');
|
||||
expect(config2.serverMetadata().authorization_endpoint).toBe('http://localhost:1030/auth');
|
||||
|
||||
// Verify discovery was called twice (not using cache)
|
||||
expect(validationService.performDiscovery).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should clear cache when provider is deleted', async () => {
|
||||
const provider = createMockProvider(1029);
|
||||
const mockConfig = createMockConfiguration(1029);
|
||||
|
||||
// Setup initial provider in config
|
||||
mockConfigService.get.mockReturnValue({
|
||||
providers: [provider, { ...provider, id: 'other-provider' }],
|
||||
defaultAllowedOrigins: [],
|
||||
});
|
||||
|
||||
// Mock validation service
|
||||
const validationService = (configPersistence as any).validationService;
|
||||
vi.spyOn(validationService, 'performDiscovery').mockResolvedValue(mockConfig);
|
||||
|
||||
// First, cache the provider config
|
||||
await clientConfigService.getOrCreateConfig(provider);
|
||||
|
||||
// Spy on clearCache
|
||||
const clearCacheSpy = vi.spyOn(clientConfigService, 'clearCache');
|
||||
|
||||
// Delete the provider
|
||||
const deleted = await configPersistence.deleteProvider(provider.id);
|
||||
expect(deleted).toBe(true);
|
||||
|
||||
// Verify cache was cleared for the deleted provider
|
||||
expect(clearCacheSpy).toHaveBeenCalledWith(provider.id);
|
||||
});
|
||||
|
||||
it('should clear all provider caches when updated via settings updateValues', async () => {
|
||||
// This simulates what happens when settings are saved through the UI
|
||||
const settingsCallback = (configPersistence as any).userSettings.register.mock.calls[0][1];
|
||||
|
||||
const newConfig = {
|
||||
providers: [
|
||||
{
|
||||
...createMockProvider(1030),
|
||||
authorizationMode: 'simple',
|
||||
simpleAuthorization: {
|
||||
allowedDomains: ['example.com'],
|
||||
allowedEmails: [],
|
||||
allowedUserIds: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
defaultAllowedOrigins: [],
|
||||
};
|
||||
|
||||
// Spy on clearCache
|
||||
const clearCacheSpy = vi.spyOn(clientConfigService, 'clearCache');
|
||||
|
||||
// Mock validation
|
||||
const validationService = (configPersistence as any).validationService;
|
||||
vi.spyOn(validationService, 'validateProvider').mockResolvedValue({
|
||||
isValid: true,
|
||||
});
|
||||
|
||||
// Call the updateValues function (simulating saving settings from UI)
|
||||
await settingsCallback.updateValues(newConfig);
|
||||
|
||||
// Verify cache was cleared (called without arguments to clear all)
|
||||
expect(clearCacheSpy).toHaveBeenCalledWith();
|
||||
});
|
||||
|
||||
it('should NOT require API restart after updating provider issuer', async () => {
|
||||
// This test confirms that the fix eliminates the need for API restart
|
||||
const settingsCallback = (configPersistence as any).userSettings.register.mock.calls[0][1];
|
||||
|
||||
const newConfig = {
|
||||
providers: [createMockProvider(1030)],
|
||||
defaultAllowedOrigins: [],
|
||||
};
|
||||
|
||||
// Mock validation
|
||||
const validationService = (configPersistence as any).validationService;
|
||||
vi.spyOn(validationService, 'validateProvider').mockResolvedValue({
|
||||
isValid: true,
|
||||
});
|
||||
|
||||
// Update settings
|
||||
const result = await settingsCallback.updateValues(newConfig);
|
||||
|
||||
// Verify that restartRequired is false
|
||||
expect(result.restartRequired).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Provider validation on save', () => {
|
||||
it('should validate providers and include warnings but still save', async () => {
|
||||
const settingsCallback = (configPersistence as any).userSettings.register.mock.calls[0][1];
|
||||
|
||||
const newConfig = {
|
||||
providers: [
|
||||
createMockProvider(1030),
|
||||
{ ...createMockProvider(1031), id: 'invalid-provider', name: 'Invalid Provider' },
|
||||
],
|
||||
defaultAllowedOrigins: [],
|
||||
};
|
||||
|
||||
// Mock validation - first provider valid, second invalid
|
||||
const validationService = (configPersistence as any).validationService;
|
||||
vi.spyOn(validationService, 'validateProvider')
|
||||
.mockResolvedValueOnce({ isValid: true })
|
||||
.mockResolvedValueOnce({
|
||||
isValid: false,
|
||||
error: 'Discovery failed: Unable to reach issuer',
|
||||
});
|
||||
|
||||
// Update settings
|
||||
const result = await settingsCallback.updateValues(newConfig);
|
||||
|
||||
// Should save successfully but include warnings
|
||||
expect(result.restartRequired).toBe(false);
|
||||
expect(result.warnings).toBeDefined();
|
||||
expect(result.warnings).toContain(
|
||||
'❌ Invalid Provider: Discovery failed: Unable to reach issuer'
|
||||
);
|
||||
expect(result.values.providers).toHaveLength(2);
|
||||
|
||||
// Cache should still be cleared even with validation warnings
|
||||
const clearCacheSpy = vi.spyOn(clientConfigService, 'clearCache');
|
||||
await settingsCallback.updateValues(newConfig);
|
||||
expect(clearCacheSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { forwardRef, Inject, Injectable, Optional } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
import { RuleEffect } from '@jsonforms/core';
|
||||
@@ -6,6 +6,7 @@ import { mergeSettingSlices } from '@unraid/shared/jsonforms/settings.js';
|
||||
import { ConfigFilePersister } from '@unraid/shared/services/config-file.js';
|
||||
import { UserSettingsService } from '@unraid/shared/services/user-settings.js';
|
||||
|
||||
import { OidcClientConfigService } from '@app/unraid-api/graph/resolvers/sso/client/oidc-client-config.service.js';
|
||||
import { OidcValidationService } from '@app/unraid-api/graph/resolvers/sso/core/oidc-validation.service.js';
|
||||
import {
|
||||
AuthorizationOperator,
|
||||
@@ -30,7 +31,10 @@ export class OidcConfigPersistence extends ConfigFilePersister<OidcConfig> {
|
||||
constructor(
|
||||
configService: ConfigService,
|
||||
private readonly userSettings: UserSettingsService,
|
||||
private readonly validationService: OidcValidationService
|
||||
private readonly validationService: OidcValidationService,
|
||||
@Optional()
|
||||
@Inject(forwardRef(() => OidcClientConfigService))
|
||||
private readonly clientConfigService?: OidcClientConfigService
|
||||
) {
|
||||
super(configService);
|
||||
this.registerSettings();
|
||||
@@ -252,6 +256,15 @@ export class OidcConfigPersistence extends ConfigFilePersister<OidcConfig> {
|
||||
this.configService.set(this.configKey(), newConfig);
|
||||
await this.persist(newConfig);
|
||||
|
||||
// Clear the OIDC client configuration cache when a provider is updated
|
||||
// This ensures the new issuer/endpoints are used immediately
|
||||
if (this.clientConfigService) {
|
||||
this.clientConfigService.clearCache(cleanedProvider.id);
|
||||
this.logger.debug(
|
||||
`Cleared OIDC client configuration cache for provider ${cleanedProvider.id}`
|
||||
);
|
||||
}
|
||||
|
||||
return cleanedProvider;
|
||||
}
|
||||
|
||||
@@ -328,6 +341,12 @@ export class OidcConfigPersistence extends ConfigFilePersister<OidcConfig> {
|
||||
this.configService.set(this.configKey(), newConfig);
|
||||
await this.persist(newConfig);
|
||||
|
||||
// Clear the cache for the deleted provider
|
||||
if (this.clientConfigService) {
|
||||
this.clientConfigService.clearCache(id);
|
||||
this.logger.debug(`Cleared OIDC client configuration cache for deleted provider ${id}`);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -440,6 +459,13 @@ export class OidcConfigPersistence extends ConfigFilePersister<OidcConfig> {
|
||||
this.configService.set(this.configKey(), processedConfig);
|
||||
await this.persist(processedConfig);
|
||||
|
||||
// Clear the OIDC client configuration cache to ensure fresh discovery
|
||||
// This fixes the issue where changing issuer URLs requires API restart
|
||||
if (this.clientConfigService) {
|
||||
this.clientConfigService.clearCache();
|
||||
this.logger.debug('Cleared OIDC client configuration cache after provider update');
|
||||
}
|
||||
|
||||
// Include validation results in response
|
||||
const response: { restartRequired: boolean; values: OidcConfig; warnings?: string[] } = {
|
||||
restartRequired: false,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "unraid-monorepo",
|
||||
"private": true,
|
||||
"version": "4.18.2",
|
||||
"version": "4.19.0",
|
||||
"scripts": {
|
||||
"build": "pnpm -r build",
|
||||
"build:watch": " pnpm -r --parallel build:watch",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@unraid/connect-plugin",
|
||||
"version": "4.18.2",
|
||||
"version": "4.19.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"commander": "14.0.0",
|
||||
|
||||
@@ -14,9 +14,9 @@
|
||||
<!ENTITY tag "">
|
||||
<!ENTITY api_version "">
|
||||
]>
|
||||
|
||||
<!-- Plugin min version is lower than the actual min version of Connect (6.12.0) to facilitate cleanup on newly unsupported versions. -->
|
||||
<PLUGIN name="&name;" author="&author;" version="&version;" pluginURL="&plugin_url;"
|
||||
launch="&launch;" min="6.12.15" icon="globe">
|
||||
launch="&launch;" min="6.9.0-rc1" icon="globe">
|
||||
|
||||
<CHANGES>
|
||||
##a long time ago in a galaxy far far away
|
||||
@@ -60,10 +60,10 @@ exit 0
|
||||
// Check Unraid version
|
||||
$version = @parse_ini_file('/etc/unraid-version', true)['version'];
|
||||
|
||||
// Check if this is a supported version
|
||||
// - Must be 6.12.15 or higher
|
||||
// - Must not be a 6.12.15 beta/rc version
|
||||
$is_stable_6_12_or_higher = version_compare($version, '6.12.15', '>=') && !preg_match('/^6\\.12\\.0-/', $version);
|
||||
// Check if this is a recommended version
|
||||
// - Should be 6.12.15 or higher
|
||||
// - Should not be a 6.12.15 beta/rc version
|
||||
$is_stable_6_12_or_higher = version_compare($version, '6.12.15', '>=') && !preg_match('/^6\\.12\\.15-/', $version);
|
||||
|
||||
if ($is_stable_6_12_or_higher) {
|
||||
echo "Running on supported version {$version}\n";
|
||||
@@ -71,9 +71,103 @@ if ($is_stable_6_12_or_higher) {
|
||||
}
|
||||
|
||||
echo "Warning: Unsupported Unraid version {$version}. This plugin requires Unraid 6.12.15 or higher.\n";
|
||||
echo "The plugin will not function correctly on this system.\n";
|
||||
echo "The plugin may not function correctly on this system. It may stop working entirely in the future.\n";
|
||||
echo "⚠️ Please uninstall this plugin or upgrade to a newer version of Unraid to enjoy Unraid Connect\n";
|
||||
|
||||
exit(1);
|
||||
// early escape handled via unraid_connect_cleanup_for_unsupported_os_versions step
|
||||
exit(0);
|
||||
]]>
|
||||
</INLINE>
|
||||
</FILE>
|
||||
|
||||
<!-- cleanup script for Unraid 6.12.0 and earlier. when run, it will exit the plugin install with an error status. -->
|
||||
<!-- See the version of dynamix.unraid.net.plg in commit a240a031a for the original implementation of this code. -->
|
||||
<FILE Name="/tmp/unraid_connect_cleanup_for_unsupported_os_versions.sh" Run="/bin/bash" Method="install" Max="6.12.0">
|
||||
<INLINE>
|
||||
<![CDATA[
|
||||
# Inlined copy of perform_connect_cleanup from
|
||||
# /usr/local/share/dynamix.unraid.net/install/scripts/cleanup.sh
|
||||
# to avoid the need to decompress the plugin tarball just to run the cleanup script
|
||||
|
||||
# Handle flash backup deactivation and Connect signout
|
||||
perform_connect_cleanup() {
|
||||
printf "\n**********************************\n"
|
||||
printf "🧹 CLEANING UP - may take a minute\n"
|
||||
printf "**********************************\n"
|
||||
|
||||
# Handle git-based flash backups
|
||||
if [ -f "/boot/.git" ]; then
|
||||
if [ -f "/etc/rc.d/rc.flash_backup" ]; then
|
||||
printf "\nStopping flash backup service. Please wait...\n"
|
||||
/etc/rc.d/rc.flash_backup stop >/dev/null 2>&1
|
||||
fi
|
||||
|
||||
if [ -f "/usr/local/emhttp/plugins/dynamix.my.servers/include/UpdateFlashBackup.php" ]; then
|
||||
printf "\nDeactivating flash backup. Please wait...\n"
|
||||
/usr/bin/php /usr/local/emhttp/plugins/dynamix.my.servers/include/UpdateFlashBackup.php deactivate
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check if connect.json or myservers.cfg exists
|
||||
if [ -f "/boot/config/plugins/dynamix.my.servers/configs/connect.json" ] || [ -f "/boot/config/plugins/dynamix.my.servers/myservers.cfg" ]; then
|
||||
# Stop unraid-api
|
||||
printf "\nStopping unraid-api. Please wait...\n"
|
||||
output=$(/etc/rc.d/rc.unraid-api stop --delete 2>&1)
|
||||
if [ -z "$output" ]; then
|
||||
echo "Waiting for unraid-api to stop..."
|
||||
sleep 5 # Give it time to stop
|
||||
fi
|
||||
echo "Stopped unraid-api: $output"
|
||||
|
||||
# Sign out of Unraid Connect (we'll use curl directly from shell)
|
||||
# We need to extract the username from connect.json or myservers.cfg and the registration key
|
||||
has_username=false
|
||||
|
||||
# Check connect.json first (newer format)
|
||||
if [ -f "/boot/config/plugins/dynamix.my.servers/configs/connect.json" ] && command -v jq >/dev/null 2>&1; then
|
||||
username=$(jq -r '.username' "/boot/config/plugins/dynamix.my.servers/configs/connect.json" 2>/dev/null)
|
||||
if [ -n "$username" ] && [ "$username" != "null" ]; then
|
||||
has_username=true
|
||||
fi
|
||||
fi
|
||||
|
||||
# Fallback to myservers.cfg (legacy format)
|
||||
if [ "$has_username" = false ] && [ -f "/boot/config/plugins/dynamix.my.servers/myservers.cfg" ]; then
|
||||
if grep -q 'username' "/boot/config/plugins/dynamix.my.servers/myservers.cfg"; then
|
||||
has_username=true
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$has_username" = true ]; then
|
||||
printf "\nSigning out of Unraid Connect\n"
|
||||
# Check if regFILE exists in var.ini
|
||||
if [ -f "/var/local/emhttp/var.ini" ]; then
|
||||
regfile=$(grep "regFILE" "/var/local/emhttp/var.ini" | cut -d= -f2)
|
||||
if [ -n "$regfile" ] && [ -f "$regfile" ]; then
|
||||
# Base64 encode the key file and send to server
|
||||
encoded_key=$(base64 "$regfile" | tr -d '\n')
|
||||
if [ -n "$encoded_key" ]; then
|
||||
curl -s -X POST "https://keys.lime-technology.com/account/server/unregister" \
|
||||
-d "keyfile=$encoded_key" >/dev/null 2>&1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Remove config files
|
||||
rm -f /boot/config/plugins/dynamix.my.servers/myservers.cfg
|
||||
rm -f /boot/config/plugins/dynamix.my.servers/configs/connect.json
|
||||
|
||||
# Reload nginx to disable Remote Access
|
||||
printf "\n⚠️ Reloading Web Server. If this window stops updating for two minutes please close it.\n"
|
||||
/etc/rc.d/rc.nginx reload >/dev/null 2>&1
|
||||
fi
|
||||
}
|
||||
|
||||
perform_connect_cleanup
|
||||
echo "Done. Please uninstall the Connect plugin to complete the cleanup."
|
||||
# Exit with error to clarify that further user action--either uninstalling the plugin or upgrading to a newer version of Unraid--is required.
|
||||
exit 1;
|
||||
]]>
|
||||
</INLINE>
|
||||
</FILE>
|
||||
@@ -332,6 +426,14 @@ if [ -d "/usr/local/unraid-api/node_modules" ]; then
|
||||
rm -rf "/usr/local/unraid-api/node_modules"
|
||||
fi
|
||||
|
||||
# Clear existing unraid-components directory contents to ensure clean installation
|
||||
echo "Cleaning up existing unraid-components directory..."
|
||||
DIR="/usr/local/emhttp/plugins/dynamix.my.servers/unraid-components"
|
||||
if [ -d "$DIR" ]; then
|
||||
echo "Clearing contents of: $DIR"
|
||||
rm -rf "$DIR"/*
|
||||
fi
|
||||
|
||||
# Install the package using the explicit file path
|
||||
upgradepkg --install-new --reinstall "${PKG_FILE}"
|
||||
if [ $? -ne 0 ]; then
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
#!/bin/sh
|
||||
# Script to handle cleanup operations during removal
|
||||
# NOTE: an inline copy of this script exists in dynamix.unraid.net.plg for Unraid 6.12.14 and earlier
|
||||
# When updating this script, be sure to update the inline copy as well.
|
||||
|
||||
# Get the operation mode
|
||||
MODE="${1:-cleanup}"
|
||||
@@ -23,8 +25,8 @@ perform_connect_cleanup() {
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check if myservers.cfg exists
|
||||
if [ -f "/boot/config/plugins/dynamix.my.servers/myservers.cfg" ]; then
|
||||
# Check if connect.json or myservers.cfg exists
|
||||
if [ -f "/boot/config/plugins/dynamix.my.servers/configs/connect.json" ] || [ -f "/boot/config/plugins/dynamix.my.servers/myservers.cfg" ]; then
|
||||
# Stop unraid-api
|
||||
printf "\nStopping unraid-api. Please wait...\n"
|
||||
output=$(/etc/rc.d/rc.unraid-api stop --delete 2>&1)
|
||||
@@ -35,8 +37,25 @@ perform_connect_cleanup() {
|
||||
echo "Stopped unraid-api: $output"
|
||||
|
||||
# Sign out of Unraid Connect (we'll use curl directly from shell)
|
||||
# We need to extract the username from myservers.cfg and the registration key
|
||||
if grep -q 'username' "/boot/config/plugins/dynamix.my.servers/myservers.cfg"; then
|
||||
# We need to extract the username from connect.json or myservers.cfg and the registration key
|
||||
has_username=false
|
||||
|
||||
# Check connect.json first (newer format)
|
||||
if [ -f "/boot/config/plugins/dynamix.my.servers/configs/connect.json" ] && command -v jq >/dev/null 2>&1; then
|
||||
username=$(jq -r '.username' "/boot/config/plugins/dynamix.my.servers/configs/connect.json" 2>/dev/null)
|
||||
if [ -n "$username" ] && [ "$username" != "null" ]; then
|
||||
has_username=true
|
||||
fi
|
||||
fi
|
||||
|
||||
# Fallback to myservers.cfg (legacy format)
|
||||
if [ "$has_username" = false ] && [ -f "/boot/config/plugins/dynamix.my.servers/myservers.cfg" ]; then
|
||||
if grep -q 'username' "/boot/config/plugins/dynamix.my.servers/myservers.cfg"; then
|
||||
has_username=true
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$has_username" = true ]; then
|
||||
printf "\nSigning out of Unraid Connect\n"
|
||||
# Check if regFILE exists in var.ini
|
||||
if [ -f "/var/local/emhttp/var.ini" ]; then
|
||||
@@ -52,8 +71,9 @@ perform_connect_cleanup() {
|
||||
fi
|
||||
fi
|
||||
|
||||
# Remove myservers.cfg
|
||||
# Remove config files
|
||||
rm -f /boot/config/plugins/dynamix.my.servers/myservers.cfg
|
||||
rm -f /boot/config/plugins/dynamix.my.servers/configs/connect.json
|
||||
|
||||
# Reload nginx to disable Remote Access
|
||||
printf "\n⚠️ Reloading Web Server. If this window stops updating for two minutes please close it.\n"
|
||||
@@ -131,4 +151,4 @@ case "$MODE" in
|
||||
echo "Usage: $0 [cleanup]"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
esac
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@unraid/ui",
|
||||
"version": "4.18.2",
|
||||
"version": "4.19.0",
|
||||
"private": true,
|
||||
"license": "GPL-2.0-or-later",
|
||||
"type": "module",
|
||||
|
||||
@@ -5,32 +5,7 @@ const props = defineProps<DialogCloseProps>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogClose v-bind="props">
|
||||
<DialogClose v-bind="props" as="span">
|
||||
<slot />
|
||||
</DialogClose>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* Reset webgui button styles for dialog close buttons */
|
||||
[role='dialog'] button[type='button'],
|
||||
button[aria-label*='close' i],
|
||||
button[aria-label*='dismiss' i] {
|
||||
/* Reset ALL webgui button styles using !important where needed */
|
||||
all: unset !important;
|
||||
|
||||
/* Re-apply necessary styles after reset */
|
||||
display: inline-flex !important;
|
||||
font-family: inherit !important;
|
||||
font-size: inherit !important;
|
||||
font-weight: inherit !important;
|
||||
line-height: inherit !important;
|
||||
cursor: pointer !important;
|
||||
box-sizing: border-box !important;
|
||||
|
||||
/* Reset any webgui CSS variables */
|
||||
--button-border: none !important;
|
||||
--button-text-color: inherit !important;
|
||||
--button-background: transparent !important;
|
||||
--button-background-size: auto !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -241,7 +241,9 @@ const updateItem = (index: number, newValue: unknown) => {
|
||||
</Tabs>
|
||||
|
||||
<div v-else class="border-muted rounded-lg border-2 border-dashed py-8 text-center">
|
||||
<p class="text-muted-foreground mb-4">No {{ itemTypeName.toLowerCase() }}s configured</p>
|
||||
<p class="text-muted-foreground mb-4 text-center">
|
||||
No {{ itemTypeName.toLowerCase() }}s configured
|
||||
</p>
|
||||
<Button variant="outline" size="md" :disabled="!control.enabled" @click="addItem">
|
||||
<Plus class="mr-2 h-4 w-4" />
|
||||
Add First {{ itemTypeName }}
|
||||
|
||||
866
web/__test__/components/Wrapper/vue-mount-app.test.ts
Normal file
866
web/__test__/components/Wrapper/vue-mount-app.test.ts
Normal file
@@ -0,0 +1,866 @@
|
||||
import { defineComponent, h } from 'vue';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { MockInstance } from 'vitest';
|
||||
import type { App as VueApp } from 'vue';
|
||||
|
||||
// Extend HTMLElement to include Vue's internal properties (matching the source file)
|
||||
interface HTMLElementWithVue extends HTMLElement {
|
||||
__vueParentComponent?: {
|
||||
appContext?: {
|
||||
app?: VueApp;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
// We'll manually mock createApp only in specific tests that need it
|
||||
vi.mock('vue', async () => {
|
||||
const actual = await vi.importActual<typeof import('vue')>('vue');
|
||||
return {
|
||||
...actual,
|
||||
};
|
||||
});
|
||||
|
||||
const mockEnsureTeleportContainer = vi.fn();
|
||||
vi.mock('@unraid/ui', () => ({
|
||||
ensureTeleportContainer: mockEnsureTeleportContainer,
|
||||
}));
|
||||
|
||||
const mockI18n = {
|
||||
global: {},
|
||||
install: vi.fn(),
|
||||
};
|
||||
vi.mock('vue-i18n', () => ({
|
||||
createI18n: vi.fn(() => mockI18n),
|
||||
}));
|
||||
|
||||
const mockApolloClient = { query: vi.fn(), mutate: vi.fn() };
|
||||
vi.mock('~/helpers/create-apollo-client', () => ({
|
||||
client: mockApolloClient,
|
||||
}));
|
||||
|
||||
const mockGlobalPinia = {
|
||||
install: vi.fn(),
|
||||
use: vi.fn(),
|
||||
_a: null,
|
||||
_e: null,
|
||||
_s: new Map(),
|
||||
state: {},
|
||||
};
|
||||
vi.mock('~/store/globalPinia', () => ({
|
||||
globalPinia: mockGlobalPinia,
|
||||
}));
|
||||
|
||||
vi.mock('~/locales/en_US.json', () => ({
|
||||
default: { test: 'Test Message' },
|
||||
}));
|
||||
|
||||
vi.mock('~/helpers/i18n-utils', () => ({
|
||||
createHtmlEntityDecoder: vi.fn(() => (str: string) => str),
|
||||
}));
|
||||
|
||||
vi.mock('~/assets/main.css?inline', () => ({
|
||||
default: '.test { color: red; }',
|
||||
}));
|
||||
|
||||
describe('vue-mount-app', () => {
|
||||
let mountVueApp: typeof import('~/components/Wrapper/vue-mount-app').mountVueApp;
|
||||
let unmountVueApp: typeof import('~/components/Wrapper/vue-mount-app').unmountVueApp;
|
||||
let getMountedApp: typeof import('~/components/Wrapper/vue-mount-app').getMountedApp;
|
||||
let autoMountComponent: typeof import('~/components/Wrapper/vue-mount-app').autoMountComponent;
|
||||
let TestComponent: ReturnType<typeof defineComponent>;
|
||||
let consoleWarnSpy: MockInstance;
|
||||
let consoleErrorSpy: MockInstance;
|
||||
let consoleInfoSpy: MockInstance;
|
||||
let consoleDebugSpy: MockInstance;
|
||||
let testContainer: HTMLDivElement;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module = await import('~/components/Wrapper/vue-mount-app');
|
||||
mountVueApp = module.mountVueApp;
|
||||
unmountVueApp = module.unmountVueApp;
|
||||
getMountedApp = module.getMountedApp;
|
||||
autoMountComponent = module.autoMountComponent;
|
||||
|
||||
TestComponent = defineComponent({
|
||||
name: 'TestComponent',
|
||||
props: {
|
||||
message: {
|
||||
type: String,
|
||||
default: 'Hello',
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
return () => h('div', { class: 'test-component' }, props.message);
|
||||
},
|
||||
});
|
||||
|
||||
consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
consoleInfoSpy = vi.spyOn(console, 'info').mockImplementation(() => {});
|
||||
consoleDebugSpy = vi.spyOn(console, 'debug').mockImplementation(() => {});
|
||||
|
||||
testContainer = document.createElement('div');
|
||||
testContainer.id = 'test-container';
|
||||
document.body.appendChild(testContainer);
|
||||
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Clear mounted apps from previous tests
|
||||
if (window.mountedApps) {
|
||||
window.mountedApps.clear();
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
document.body.innerHTML = '';
|
||||
if (window.mountedApps) {
|
||||
window.mountedApps.clear();
|
||||
}
|
||||
});
|
||||
|
||||
describe('mountVueApp', () => {
|
||||
it('should mount a Vue app to a single element', () => {
|
||||
const element = document.createElement('div');
|
||||
element.id = 'app';
|
||||
document.body.appendChild(element);
|
||||
|
||||
const app = mountVueApp({
|
||||
component: TestComponent,
|
||||
selector: '#app',
|
||||
});
|
||||
|
||||
expect(app).toBeTruthy();
|
||||
expect(element.querySelector('.test-component')).toBeTruthy();
|
||||
expect(element.textContent).toBe('Hello');
|
||||
expect(mockEnsureTeleportContainer).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should mount with custom props', () => {
|
||||
const element = document.createElement('div');
|
||||
element.id = 'app';
|
||||
document.body.appendChild(element);
|
||||
|
||||
const app = mountVueApp({
|
||||
component: TestComponent,
|
||||
selector: '#app',
|
||||
props: { message: 'Custom Message' },
|
||||
});
|
||||
|
||||
expect(app).toBeTruthy();
|
||||
expect(element.textContent).toBe('Custom Message');
|
||||
});
|
||||
|
||||
it('should parse props from element attributes', () => {
|
||||
const element = document.createElement('div');
|
||||
element.id = 'app';
|
||||
element.setAttribute('message', 'Attribute Message');
|
||||
document.body.appendChild(element);
|
||||
|
||||
const app = mountVueApp({
|
||||
component: TestComponent,
|
||||
selector: '#app',
|
||||
});
|
||||
|
||||
expect(app).toBeTruthy();
|
||||
expect(element.textContent).toBe('Attribute Message');
|
||||
});
|
||||
|
||||
it('should parse JSON props from attributes', () => {
|
||||
const element = document.createElement('div');
|
||||
element.id = 'app';
|
||||
element.setAttribute('message', '{"text": "JSON Message"}');
|
||||
document.body.appendChild(element);
|
||||
|
||||
const app = mountVueApp({
|
||||
component: TestComponent,
|
||||
selector: '#app',
|
||||
});
|
||||
|
||||
expect(app).toBeTruthy();
|
||||
expect(element.getAttribute('message')).toBe('{"text": "JSON Message"}');
|
||||
});
|
||||
|
||||
it('should handle HTML-encoded JSON in attributes', () => {
|
||||
const element = document.createElement('div');
|
||||
element.id = 'app';
|
||||
element.setAttribute('message', '{"text": "Encoded"}');
|
||||
document.body.appendChild(element);
|
||||
|
||||
const app = mountVueApp({
|
||||
component: TestComponent,
|
||||
selector: '#app',
|
||||
});
|
||||
|
||||
expect(app).toBeTruthy();
|
||||
expect(element.getAttribute('message')).toBe('{"text": "Encoded"}');
|
||||
});
|
||||
|
||||
it('should mount to multiple elements', () => {
|
||||
const element1 = document.createElement('div');
|
||||
element1.className = 'multi-mount';
|
||||
document.body.appendChild(element1);
|
||||
|
||||
const element2 = document.createElement('div');
|
||||
element2.className = 'multi-mount';
|
||||
document.body.appendChild(element2);
|
||||
|
||||
const app = mountVueApp({
|
||||
component: TestComponent,
|
||||
selector: '.multi-mount',
|
||||
});
|
||||
|
||||
expect(app).toBeTruthy();
|
||||
expect(element1.querySelector('.test-component')).toBeTruthy();
|
||||
expect(element2.querySelector('.test-component')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should use shadow root when specified', () => {
|
||||
const element = document.createElement('div');
|
||||
element.id = 'shadow-app';
|
||||
document.body.appendChild(element);
|
||||
|
||||
const app = mountVueApp({
|
||||
component: TestComponent,
|
||||
selector: '#shadow-app',
|
||||
useShadowRoot: true,
|
||||
});
|
||||
|
||||
expect(app).toBeTruthy();
|
||||
expect(element.shadowRoot).toBeTruthy();
|
||||
expect(element.shadowRoot?.querySelector('#app')).toBeTruthy();
|
||||
expect(element.shadowRoot?.querySelector('.test-component')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should inject styles into shadow root', () => {
|
||||
const element = document.createElement('div');
|
||||
element.id = 'shadow-app';
|
||||
document.body.appendChild(element);
|
||||
|
||||
mountVueApp({
|
||||
component: TestComponent,
|
||||
selector: '#shadow-app',
|
||||
useShadowRoot: true,
|
||||
});
|
||||
|
||||
const styleElement = element.shadowRoot?.querySelector('style[data-tailwind]');
|
||||
expect(styleElement).toBeTruthy();
|
||||
expect(styleElement?.textContent).toBe('.test { color: red; }');
|
||||
});
|
||||
|
||||
it('should inject global styles to document', () => {
|
||||
const element = document.createElement('div');
|
||||
element.id = 'app';
|
||||
document.body.appendChild(element);
|
||||
|
||||
mountVueApp({
|
||||
component: TestComponent,
|
||||
selector: '#app',
|
||||
});
|
||||
|
||||
const globalStyle = document.querySelector('style[data-tailwind-global]');
|
||||
expect(globalStyle).toBeTruthy();
|
||||
expect(globalStyle?.textContent).toBe('.test { color: red; }');
|
||||
});
|
||||
|
||||
it('should warn when app is already mounted', () => {
|
||||
const element = document.createElement('div');
|
||||
element.id = 'app';
|
||||
document.body.appendChild(element);
|
||||
|
||||
const app1 = mountVueApp({
|
||||
component: TestComponent,
|
||||
selector: '#app',
|
||||
appId: 'test-app',
|
||||
});
|
||||
|
||||
const app2 = mountVueApp({
|
||||
component: TestComponent,
|
||||
selector: '#app',
|
||||
appId: 'test-app',
|
||||
});
|
||||
|
||||
expect(app1).toBeTruthy();
|
||||
expect(app2).toBe(app1);
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith('[VueMountApp] App test-app is already mounted');
|
||||
});
|
||||
|
||||
it('should handle modal singleton behavior', () => {
|
||||
const element1 = document.createElement('div');
|
||||
element1.id = 'modals';
|
||||
document.body.appendChild(element1);
|
||||
|
||||
const element2 = document.createElement('div');
|
||||
element2.id = 'unraid-modals';
|
||||
document.body.appendChild(element2);
|
||||
|
||||
const app1 = mountVueApp({
|
||||
component: TestComponent,
|
||||
selector: '#modals',
|
||||
appId: 'modals',
|
||||
});
|
||||
|
||||
const app2 = mountVueApp({
|
||||
component: TestComponent,
|
||||
selector: '#unraid-modals',
|
||||
appId: 'unraid-modals',
|
||||
});
|
||||
|
||||
expect(app1).toBeTruthy();
|
||||
expect(app2).toBe(app1);
|
||||
expect(consoleDebugSpy).toHaveBeenCalledWith(
|
||||
'[VueMountApp] Modals component already mounted as modals, skipping unraid-modals'
|
||||
);
|
||||
});
|
||||
|
||||
it('should clean up existing Vue attributes', () => {
|
||||
const element = document.createElement('div');
|
||||
element.id = 'app';
|
||||
element.setAttribute('data-vue-mounted', 'true');
|
||||
element.setAttribute('data-v-app', '');
|
||||
element.setAttribute('data-server-rendered', 'true');
|
||||
element.setAttribute('data-v-123', '');
|
||||
document.body.appendChild(element);
|
||||
|
||||
mountVueApp({
|
||||
component: TestComponent,
|
||||
selector: '#app',
|
||||
});
|
||||
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
||||
'[VueMountApp] Element #app has Vue attributes but no content, cleaning up'
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle elements with problematic child nodes', () => {
|
||||
const element = document.createElement('div');
|
||||
element.id = 'app';
|
||||
element.appendChild(document.createTextNode(' '));
|
||||
element.appendChild(document.createComment('test comment'));
|
||||
document.body.appendChild(element);
|
||||
|
||||
const app = mountVueApp({
|
||||
component: TestComponent,
|
||||
selector: '#app',
|
||||
});
|
||||
|
||||
expect(app).toBeTruthy();
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
||||
'[VueMountApp] Cleaning up problematic nodes in #app before mounting'
|
||||
);
|
||||
});
|
||||
|
||||
it('should return null when no elements found', () => {
|
||||
const app = mountVueApp({
|
||||
component: TestComponent,
|
||||
selector: '#non-existent',
|
||||
});
|
||||
|
||||
expect(app).toBeNull();
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
||||
'[VueMountApp] No elements found for selector: #non-existent'
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle mount errors gracefully', () => {
|
||||
const element = document.createElement('div');
|
||||
element.id = 'app';
|
||||
document.body.appendChild(element);
|
||||
|
||||
const ErrorComponent = defineComponent({
|
||||
setup() {
|
||||
throw new Error('Component error');
|
||||
},
|
||||
});
|
||||
|
||||
expect(() => {
|
||||
mountVueApp({
|
||||
component: ErrorComponent,
|
||||
selector: '#app',
|
||||
});
|
||||
}).toThrow('Component error');
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should add unapi class to mounted elements', () => {
|
||||
const element = document.createElement('div');
|
||||
element.id = 'app';
|
||||
document.body.appendChild(element);
|
||||
|
||||
mountVueApp({
|
||||
component: TestComponent,
|
||||
selector: '#app',
|
||||
});
|
||||
|
||||
expect(element.classList.contains('unapi')).toBe(true);
|
||||
expect(element.getAttribute('data-vue-mounted')).toBe('true');
|
||||
});
|
||||
|
||||
it('should skip disconnected elements during multi-mount', () => {
|
||||
const element1 = document.createElement('div');
|
||||
element1.className = 'multi';
|
||||
document.body.appendChild(element1);
|
||||
|
||||
const element2 = document.createElement('div');
|
||||
element2.className = 'multi';
|
||||
// This element is NOT added to the document
|
||||
|
||||
// Create a third element and manually add it to element1 to simulate DOM issues
|
||||
const orphanedChild = document.createElement('span');
|
||||
element1.appendChild(orphanedChild);
|
||||
// Now remove element1 from DOM temporarily to trigger the warning
|
||||
element1.remove();
|
||||
|
||||
// Add element1 back
|
||||
document.body.appendChild(element1);
|
||||
|
||||
// Create elements matching the selector
|
||||
document.body.innerHTML = '';
|
||||
const validElement = document.createElement('div');
|
||||
validElement.className = 'multi';
|
||||
document.body.appendChild(validElement);
|
||||
|
||||
const disconnectedElement = document.createElement('div');
|
||||
disconnectedElement.className = 'multi';
|
||||
const container = document.createElement('div');
|
||||
container.appendChild(disconnectedElement);
|
||||
// Now disconnectedElement has a parent but that parent is not in the document
|
||||
|
||||
const app = mountVueApp({
|
||||
component: TestComponent,
|
||||
selector: '.multi',
|
||||
});
|
||||
|
||||
expect(app).toBeTruthy();
|
||||
// The app should mount only to the connected element
|
||||
expect(validElement.querySelector('.test-component')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('unmountVueApp', () => {
|
||||
it('should unmount a mounted app', () => {
|
||||
const element = document.createElement('div');
|
||||
element.id = 'app';
|
||||
document.body.appendChild(element);
|
||||
|
||||
const app = mountVueApp({
|
||||
component: TestComponent,
|
||||
selector: '#app',
|
||||
appId: 'test-app',
|
||||
});
|
||||
|
||||
expect(app).toBeTruthy();
|
||||
expect(getMountedApp('test-app')).toBe(app);
|
||||
|
||||
const result = unmountVueApp('test-app');
|
||||
expect(result).toBe(true);
|
||||
expect(getMountedApp('test-app')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should clean up data attributes on unmount', () => {
|
||||
const element = document.createElement('div');
|
||||
element.id = 'app';
|
||||
document.body.appendChild(element);
|
||||
|
||||
mountVueApp({
|
||||
component: TestComponent,
|
||||
selector: '#app',
|
||||
appId: 'test-app',
|
||||
});
|
||||
|
||||
expect(element.getAttribute('data-vue-mounted')).toBe('true');
|
||||
expect(element.classList.contains('unapi')).toBe(true);
|
||||
|
||||
unmountVueApp('test-app');
|
||||
|
||||
expect(element.getAttribute('data-vue-mounted')).toBeNull();
|
||||
});
|
||||
|
||||
it('should unmount cloned apps', () => {
|
||||
const element1 = document.createElement('div');
|
||||
element1.className = 'multi';
|
||||
document.body.appendChild(element1);
|
||||
|
||||
const element2 = document.createElement('div');
|
||||
element2.className = 'multi';
|
||||
document.body.appendChild(element2);
|
||||
|
||||
mountVueApp({
|
||||
component: TestComponent,
|
||||
selector: '.multi',
|
||||
appId: 'multi-app',
|
||||
});
|
||||
|
||||
const result = unmountVueApp('multi-app');
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should remove shadow root containers', () => {
|
||||
const element = document.createElement('div');
|
||||
element.id = 'shadow-app';
|
||||
document.body.appendChild(element);
|
||||
|
||||
mountVueApp({
|
||||
component: TestComponent,
|
||||
selector: '#shadow-app',
|
||||
appId: 'shadow-app',
|
||||
useShadowRoot: true,
|
||||
});
|
||||
|
||||
expect(element.shadowRoot?.querySelector('#app')).toBeTruthy();
|
||||
|
||||
unmountVueApp('shadow-app');
|
||||
|
||||
expect(element.shadowRoot?.querySelector('#app')).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should warn when unmounting non-existent app', () => {
|
||||
const result = unmountVueApp('non-existent');
|
||||
expect(result).toBe(false);
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
||||
'[VueMountApp] No app found with id: non-existent'
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle unmount errors gracefully', () => {
|
||||
const element = document.createElement('div');
|
||||
element.id = 'app';
|
||||
document.body.appendChild(element);
|
||||
|
||||
const app = mountVueApp({
|
||||
component: TestComponent,
|
||||
selector: '#app',
|
||||
appId: 'test-app',
|
||||
});
|
||||
|
||||
// Force an error by corrupting the app
|
||||
if (app) {
|
||||
(app as { unmount: () => void }).unmount = () => {
|
||||
throw new Error('Unmount error');
|
||||
};
|
||||
}
|
||||
|
||||
const result = unmountVueApp('test-app');
|
||||
expect(result).toBe(true);
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
||||
'[VueMountApp] Error unmounting app test-app:',
|
||||
expect.any(Error)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMountedApp', () => {
|
||||
it('should return mounted app by id', () => {
|
||||
const element = document.createElement('div');
|
||||
element.id = 'app';
|
||||
document.body.appendChild(element);
|
||||
|
||||
const app = mountVueApp({
|
||||
component: TestComponent,
|
||||
selector: '#app',
|
||||
appId: 'test-app',
|
||||
});
|
||||
|
||||
expect(getMountedApp('test-app')).toBe(app);
|
||||
});
|
||||
|
||||
it('should return undefined for non-existent app', () => {
|
||||
expect(getMountedApp('non-existent')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('autoMountComponent', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should auto-mount when DOM is ready', async () => {
|
||||
const element = document.createElement('div');
|
||||
element.id = 'auto-app';
|
||||
document.body.appendChild(element);
|
||||
|
||||
autoMountComponent(TestComponent, '#auto-app');
|
||||
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(element.querySelector('.test-component')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should wait for DOMContentLoaded if document is loading', async () => {
|
||||
Object.defineProperty(document, 'readyState', {
|
||||
value: 'loading',
|
||||
writable: true,
|
||||
});
|
||||
|
||||
const element = document.createElement('div');
|
||||
element.id = 'auto-app';
|
||||
document.body.appendChild(element);
|
||||
|
||||
autoMountComponent(TestComponent, '#auto-app');
|
||||
|
||||
expect(element.querySelector('.test-component')).toBeFalsy();
|
||||
|
||||
Object.defineProperty(document, 'readyState', {
|
||||
value: 'complete',
|
||||
writable: true,
|
||||
});
|
||||
|
||||
document.dispatchEvent(new Event('DOMContentLoaded'));
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(element.querySelector('.test-component')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should skip auto-mount for already mounted modals', async () => {
|
||||
const element1 = document.createElement('div');
|
||||
element1.id = 'modals';
|
||||
document.body.appendChild(element1);
|
||||
|
||||
mountVueApp({
|
||||
component: TestComponent,
|
||||
selector: '#modals',
|
||||
appId: 'modals',
|
||||
});
|
||||
|
||||
autoMountComponent(TestComponent, '#modals');
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(consoleDebugSpy).toHaveBeenCalledWith(
|
||||
'[VueMountApp] Modals component already mounted, skipping #modals'
|
||||
);
|
||||
});
|
||||
|
||||
it('should add delay for problematic selectors', async () => {
|
||||
const element = document.createElement('div');
|
||||
element.id = 'unraid-connect-settings';
|
||||
document.body.appendChild(element);
|
||||
|
||||
autoMountComponent(TestComponent, '#unraid-connect-settings');
|
||||
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(element.querySelector('.test-component')).toBeFalsy();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
expect(element.querySelector('.test-component')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should mount even when element is hidden', async () => {
|
||||
const element = document.createElement('div');
|
||||
element.id = 'hidden-app';
|
||||
element.style.display = 'none';
|
||||
document.body.appendChild(element);
|
||||
|
||||
autoMountComponent(TestComponent, '#hidden-app');
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
// Hidden elements should still mount successfully
|
||||
expect(element.querySelector('.test-component')).toBeTruthy();
|
||||
expect(consoleWarnSpy).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining('No valid DOM elements found')
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle nextSibling errors with retry', async () => {
|
||||
const element = document.createElement('div');
|
||||
element.id = 'error-app';
|
||||
element.setAttribute('data-vue-mounted', 'true');
|
||||
element.setAttribute('data-v-app', '');
|
||||
document.body.appendChild(element);
|
||||
|
||||
// Simulate the element having Vue instance references which cause nextSibling errors
|
||||
const mockVueInstance = { appContext: { app: {} as VueApp } };
|
||||
(element as HTMLElementWithVue).__vueParentComponent = mockVueInstance;
|
||||
|
||||
// Add an invalid child that will trigger cleanup
|
||||
const textNode = document.createTextNode(' ');
|
||||
element.appendChild(textNode);
|
||||
|
||||
autoMountComponent(TestComponent, '#error-app');
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
// Should detect and clean up existing Vue state
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('[VueMountApp] Element #error-app has Vue attributes but no content, cleaning up')
|
||||
);
|
||||
|
||||
// Should successfully mount after cleanup
|
||||
expect(element.querySelector('.test-component')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should skip mounting if no elements found', async () => {
|
||||
autoMountComponent(TestComponent, '#non-existent');
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(consoleWarnSpy).not.toHaveBeenCalled();
|
||||
expect(consoleErrorSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should pass options to mountVueApp', async () => {
|
||||
const element = document.createElement('div');
|
||||
element.id = 'options-app';
|
||||
document.body.appendChild(element);
|
||||
|
||||
autoMountComponent(TestComponent, '#options-app', {
|
||||
props: { message: 'Auto Mount Message' },
|
||||
useShadowRoot: true,
|
||||
});
|
||||
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(element.shadowRoot).toBeTruthy();
|
||||
expect(element.shadowRoot?.textContent).toContain('Auto Mount Message');
|
||||
});
|
||||
});
|
||||
|
||||
describe('i18n setup', () => {
|
||||
it('should setup i18n with default locale', () => {
|
||||
const element = document.createElement('div');
|
||||
element.id = 'i18n-app';
|
||||
document.body.appendChild(element);
|
||||
|
||||
mountVueApp({
|
||||
component: TestComponent,
|
||||
selector: '#i18n-app',
|
||||
});
|
||||
|
||||
expect(mockI18n.install).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should parse window locale data', () => {
|
||||
const localeData = {
|
||||
fr_FR: { test: 'Message de test' },
|
||||
};
|
||||
(window as unknown as Record<string, unknown>).LOCALE_DATA = encodeURIComponent(JSON.stringify(localeData));
|
||||
|
||||
const element = document.createElement('div');
|
||||
element.id = 'i18n-app';
|
||||
document.body.appendChild(element);
|
||||
|
||||
mountVueApp({
|
||||
component: TestComponent,
|
||||
selector: '#i18n-app',
|
||||
});
|
||||
|
||||
delete (window as unknown as Record<string, unknown>).LOCALE_DATA;
|
||||
});
|
||||
|
||||
it('should handle locale data parsing errors', () => {
|
||||
(window as unknown as Record<string, unknown>).LOCALE_DATA = 'invalid json';
|
||||
|
||||
const element = document.createElement('div');
|
||||
element.id = 'i18n-app';
|
||||
document.body.appendChild(element);
|
||||
|
||||
mountVueApp({
|
||||
component: TestComponent,
|
||||
selector: '#i18n-app',
|
||||
});
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'[VueMountApp] error parsing messages',
|
||||
expect.any(Error)
|
||||
);
|
||||
|
||||
delete (window as unknown as Record<string, unknown>).LOCALE_DATA;
|
||||
});
|
||||
});
|
||||
|
||||
describe('error recovery', () => {
|
||||
it('should attempt recovery from nextSibling error', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const element = document.createElement('div');
|
||||
element.id = 'recovery-app';
|
||||
document.body.appendChild(element);
|
||||
|
||||
// Create a mock Vue app that throws on first mount attempt
|
||||
let mountAttempt = 0;
|
||||
const mockApp = {
|
||||
use: vi.fn().mockReturnThis(),
|
||||
provide: vi.fn().mockReturnThis(),
|
||||
mount: vi.fn().mockImplementation(() => {
|
||||
mountAttempt++;
|
||||
if (mountAttempt === 1) {
|
||||
const error = new TypeError('Cannot read property nextSibling of null');
|
||||
throw error;
|
||||
}
|
||||
return mockApp;
|
||||
}),
|
||||
unmount: vi.fn(),
|
||||
version: '3.0.0',
|
||||
config: { globalProperties: {} },
|
||||
};
|
||||
|
||||
// Mock createApp using module mock
|
||||
const vueModule = await import('vue');
|
||||
vi.spyOn(vueModule, 'createApp').mockReturnValue(mockApp as never);
|
||||
|
||||
mountVueApp({
|
||||
component: TestComponent,
|
||||
selector: '#recovery-app',
|
||||
appId: 'recovery-app',
|
||||
});
|
||||
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
||||
'[VueMountApp] Attempting recovery from nextSibling error for #recovery-app'
|
||||
);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
|
||||
expect(consoleInfoSpy).toHaveBeenCalledWith(
|
||||
'[VueMountApp] Successfully recovered from nextSibling error for #recovery-app'
|
||||
);
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should not attempt recovery with skipRecovery flag', async () => {
|
||||
const element = document.createElement('div');
|
||||
element.id = 'no-recovery-app';
|
||||
document.body.appendChild(element);
|
||||
|
||||
const mockApp = {
|
||||
use: vi.fn().mockReturnThis(),
|
||||
provide: vi.fn().mockReturnThis(),
|
||||
mount: vi.fn().mockImplementation(() => {
|
||||
throw new TypeError('Cannot read property nextSibling of null');
|
||||
}),
|
||||
unmount: vi.fn(),
|
||||
version: '3.0.0',
|
||||
config: { globalProperties: {} },
|
||||
};
|
||||
|
||||
const vueModule = await import('vue');
|
||||
vi.spyOn(vueModule, 'createApp').mockReturnValue(mockApp as never);
|
||||
|
||||
expect(() => {
|
||||
mountVueApp({
|
||||
component: TestComponent,
|
||||
selector: '#no-recovery-app',
|
||||
skipRecovery: true,
|
||||
});
|
||||
}).toThrow('Cannot read property nextSibling of null');
|
||||
|
||||
expect(consoleWarnSpy).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining('Attempting recovery')
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('global exposure', () => {
|
||||
it('should expose mountedApps globally', () => {
|
||||
expect(window.mountedApps).toBeDefined();
|
||||
expect(window.mountedApps).toBeInstanceOf(Map);
|
||||
});
|
||||
|
||||
it('should expose globalPinia globally', () => {
|
||||
expect(window.globalPinia).toBeDefined();
|
||||
expect(window.globalPinia).toBe(mockGlobalPinia);
|
||||
});
|
||||
});
|
||||
});
|
||||
278
web/__test__/components/standalone-mount.test.ts
Normal file
278
web/__test__/components/standalone-mount.test.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// Mock all the component imports
|
||||
vi.mock('~/components/Auth.ce.vue', () => ({
|
||||
default: { name: 'MockAuth', template: '<div>Auth</div>' }
|
||||
}));
|
||||
vi.mock('~/components/ConnectSettings/ConnectSettings.ce.vue', () => ({
|
||||
default: { name: 'MockConnectSettings', template: '<div>ConnectSettings</div>' }
|
||||
}));
|
||||
vi.mock('~/components/DownloadApiLogs.ce.vue', () => ({
|
||||
default: { name: 'MockDownloadApiLogs', template: '<div>DownloadApiLogs</div>' }
|
||||
}));
|
||||
vi.mock('~/components/HeaderOsVersion.ce.vue', () => ({
|
||||
default: { name: 'MockHeaderOsVersion', template: '<div>HeaderOsVersion</div>' }
|
||||
}));
|
||||
vi.mock('~/components/Modals.ce.vue', () => ({
|
||||
default: { name: 'MockModals', template: '<div>Modals</div>' }
|
||||
}));
|
||||
vi.mock('~/components/UserProfile.ce.vue', () => ({
|
||||
default: { name: 'MockUserProfile', template: '<div>UserProfile</div>' }
|
||||
}));
|
||||
vi.mock('~/components/UpdateOs.ce.vue', () => ({
|
||||
default: { name: 'MockUpdateOs', template: '<div>UpdateOs</div>' }
|
||||
}));
|
||||
vi.mock('~/components/DowngradeOs.ce.vue', () => ({
|
||||
default: { name: 'MockDowngradeOs', template: '<div>DowngradeOs</div>' }
|
||||
}));
|
||||
vi.mock('~/components/Registration.ce.vue', () => ({
|
||||
default: { name: 'MockRegistration', template: '<div>Registration</div>' }
|
||||
}));
|
||||
vi.mock('~/components/WanIpCheck.ce.vue', () => ({
|
||||
default: { name: 'MockWanIpCheck', template: '<div>WanIpCheck</div>' }
|
||||
}));
|
||||
vi.mock('~/components/Activation/WelcomeModal.ce.vue', () => ({
|
||||
default: { name: 'MockWelcomeModal', template: '<div>WelcomeModal</div>' }
|
||||
}));
|
||||
vi.mock('~/components/SsoButton.ce.vue', () => ({
|
||||
default: { name: 'MockSsoButton', template: '<div>SsoButton</div>' }
|
||||
}));
|
||||
vi.mock('~/components/Logs/LogViewer.ce.vue', () => ({
|
||||
default: { name: 'MockLogViewer', template: '<div>LogViewer</div>' }
|
||||
}));
|
||||
vi.mock('~/components/ThemeSwitcher.ce.vue', () => ({
|
||||
default: { name: 'MockThemeSwitcher', template: '<div>ThemeSwitcher</div>' }
|
||||
}));
|
||||
vi.mock('~/components/ApiKeyPage.ce.vue', () => ({
|
||||
default: { name: 'MockApiKeyPage', template: '<div>ApiKeyPage</div>' }
|
||||
}));
|
||||
vi.mock('~/components/DevModalTest.ce.vue', () => ({
|
||||
default: { name: 'MockDevModalTest', template: '<div>DevModalTest</div>' }
|
||||
}));
|
||||
vi.mock('~/components/ApiKeyAuthorize.ce.vue', () => ({
|
||||
default: { name: 'MockApiKeyAuthorize', template: '<div>ApiKeyAuthorize</div>' }
|
||||
}));
|
||||
vi.mock('~/components/UnraidToaster.vue', () => ({
|
||||
default: { name: 'MockUnraidToaster', template: '<div>UnraidToaster</div>' }
|
||||
}));
|
||||
|
||||
// Mock vue-mount-app module
|
||||
const mockAutoMountComponent = vi.fn();
|
||||
const mockMountVueApp = vi.fn();
|
||||
const mockGetMountedApp = vi.fn();
|
||||
|
||||
vi.mock('~/components/Wrapper/vue-mount-app', () => ({
|
||||
autoMountComponent: mockAutoMountComponent,
|
||||
mountVueApp: mockMountVueApp,
|
||||
getMountedApp: mockGetMountedApp,
|
||||
}));
|
||||
|
||||
// Mock theme store
|
||||
const mockSetTheme = vi.fn();
|
||||
const mockSetCssVars = vi.fn();
|
||||
const mockUseThemeStore = vi.fn(() => ({
|
||||
setTheme: mockSetTheme,
|
||||
setCssVars: mockSetCssVars,
|
||||
}));
|
||||
|
||||
vi.mock('~/store/theme', () => ({
|
||||
useThemeStore: mockUseThemeStore,
|
||||
}));
|
||||
|
||||
// Mock globalPinia
|
||||
vi.mock('~/store/globalPinia', () => ({
|
||||
globalPinia: { state: {} },
|
||||
}));
|
||||
|
||||
// Mock apollo client
|
||||
const mockApolloClient = {
|
||||
query: vi.fn(),
|
||||
mutate: vi.fn(),
|
||||
};
|
||||
vi.mock('~/helpers/create-apollo-client', () => ({
|
||||
client: mockApolloClient,
|
||||
}));
|
||||
|
||||
// Mock @vue/apollo-composable
|
||||
const mockProvideApolloClient = vi.fn();
|
||||
vi.mock('@vue/apollo-composable', () => ({
|
||||
provideApolloClient: mockProvideApolloClient,
|
||||
}));
|
||||
|
||||
// Mock graphql
|
||||
const mockParse = vi.fn();
|
||||
vi.mock('graphql', () => ({
|
||||
parse: mockParse,
|
||||
}));
|
||||
|
||||
// Mock @unraid/ui
|
||||
const mockEnsureTeleportContainer = vi.fn();
|
||||
vi.mock('@unraid/ui', () => ({
|
||||
ensureTeleportContainer: mockEnsureTeleportContainer,
|
||||
}));
|
||||
|
||||
describe('standalone-mount', () => {
|
||||
beforeEach(() => {
|
||||
// Reset module cache to ensure fresh imports
|
||||
vi.resetModules();
|
||||
|
||||
// Reset all mocks
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Use Vitest's unstubAllGlobals to clean up any global stubs from previous tests
|
||||
vi.unstubAllGlobals();
|
||||
|
||||
// Mock document methods
|
||||
vi.spyOn(document.head, 'appendChild').mockImplementation(() => document.createElement('style'));
|
||||
vi.spyOn(document, 'addEventListener').mockImplementation(() => {});
|
||||
|
||||
// Clear DOM
|
||||
document.head.innerHTML = '';
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.resetModules();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('initialization', () => {
|
||||
|
||||
it('should set up Apollo client globally', async () => {
|
||||
await import('~/components/standalone-mount');
|
||||
|
||||
expect(window.apolloClient).toBe(mockApolloClient);
|
||||
expect(window.graphqlParse).toBe(mockParse);
|
||||
expect(window.gql).toBe(mockParse);
|
||||
expect(mockProvideApolloClient).toHaveBeenCalledWith(mockApolloClient);
|
||||
});
|
||||
|
||||
it('should initialize theme store', async () => {
|
||||
await import('~/components/standalone-mount');
|
||||
|
||||
expect(mockUseThemeStore).toHaveBeenCalled();
|
||||
expect(mockSetTheme).toHaveBeenCalled();
|
||||
expect(mockSetCssVars).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should ensure teleport container exists', async () => {
|
||||
await import('~/components/standalone-mount');
|
||||
|
||||
expect(mockEnsureTeleportContainer).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('component auto-mounting', () => {
|
||||
it('should auto-mount all defined components', async () => {
|
||||
await import('~/components/standalone-mount');
|
||||
|
||||
// Verify that autoMountComponent was called multiple times
|
||||
expect(mockAutoMountComponent.mock.calls.length).toBeGreaterThan(0);
|
||||
|
||||
// Verify all calls have the correct structure
|
||||
mockAutoMountComponent.mock.calls.forEach(call => {
|
||||
expect(call[0]).toBeDefined(); // Component
|
||||
expect(call[1]).toBeDefined(); // Selector
|
||||
expect(call[2]).toMatchObject({
|
||||
appId: expect.any(String),
|
||||
useShadowRoot: false,
|
||||
});
|
||||
});
|
||||
|
||||
// Extract all selectors that were mounted
|
||||
const mountedSelectors = mockAutoMountComponent.mock.calls.map(call => call[1]);
|
||||
|
||||
// Verify critical components are mounted
|
||||
expect(mountedSelectors).toContain('unraid-auth');
|
||||
expect(mountedSelectors).toContain('unraid-modals');
|
||||
expect(mountedSelectors).toContain('unraid-user-profile');
|
||||
expect(mountedSelectors).toContain('uui-toaster');
|
||||
expect(mountedSelectors).toContain('#modals'); // Legacy modal selector
|
||||
|
||||
// Verify no shadow DOM is used
|
||||
const allUseShadowRoot = mockAutoMountComponent.mock.calls.every(
|
||||
call => call[2].useShadowRoot === false
|
||||
);
|
||||
expect(allUseShadowRoot).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('global exports', () => {
|
||||
it('should expose UnraidComponents globally', async () => {
|
||||
await import('~/components/standalone-mount');
|
||||
|
||||
expect(window.UnraidComponents).toBeDefined();
|
||||
expect(window.UnraidComponents).toHaveProperty('Auth');
|
||||
expect(window.UnraidComponents).toHaveProperty('ConnectSettings');
|
||||
expect(window.UnraidComponents).toHaveProperty('DownloadApiLogs');
|
||||
expect(window.UnraidComponents).toHaveProperty('HeaderOsVersion');
|
||||
expect(window.UnraidComponents).toHaveProperty('Modals');
|
||||
expect(window.UnraidComponents).toHaveProperty('UserProfile');
|
||||
expect(window.UnraidComponents).toHaveProperty('UpdateOs');
|
||||
expect(window.UnraidComponents).toHaveProperty('DowngradeOs');
|
||||
expect(window.UnraidComponents).toHaveProperty('Registration');
|
||||
expect(window.UnraidComponents).toHaveProperty('WanIpCheck');
|
||||
expect(window.UnraidComponents).toHaveProperty('WelcomeModal');
|
||||
expect(window.UnraidComponents).toHaveProperty('SsoButton');
|
||||
expect(window.UnraidComponents).toHaveProperty('LogViewer');
|
||||
expect(window.UnraidComponents).toHaveProperty('ThemeSwitcher');
|
||||
expect(window.UnraidComponents).toHaveProperty('ApiKeyPage');
|
||||
expect(window.UnraidComponents).toHaveProperty('DevModalTest');
|
||||
expect(window.UnraidComponents).toHaveProperty('ApiKeyAuthorize');
|
||||
expect(window.UnraidComponents).toHaveProperty('UnraidToaster');
|
||||
});
|
||||
|
||||
it('should expose utility functions globally', async () => {
|
||||
await import('~/components/standalone-mount');
|
||||
|
||||
expect(window.mountVueApp).toBe(mockMountVueApp);
|
||||
expect(window.getMountedApp).toBe(mockGetMountedApp);
|
||||
});
|
||||
|
||||
it('should create dynamic mount functions for each component', async () => {
|
||||
await import('~/components/standalone-mount');
|
||||
|
||||
// Check for some dynamic mount functions
|
||||
expect(typeof window.mountAuth).toBe('function');
|
||||
expect(typeof window.mountConnectSettings).toBe('function');
|
||||
expect(typeof window.mountUserProfile).toBe('function');
|
||||
expect(typeof window.mountModals).toBe('function');
|
||||
expect(typeof window.mountThemeSwitcher).toBe('function');
|
||||
|
||||
// Test calling a dynamic mount function
|
||||
const customSelector = '#custom-auth';
|
||||
window.mountAuth?.(customSelector);
|
||||
|
||||
expect(mockMountVueApp).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
selector: customSelector,
|
||||
useShadowRoot: false,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should use default selector when no custom selector provided', async () => {
|
||||
await import('~/components/standalone-mount');
|
||||
|
||||
// Call mount function without custom selector
|
||||
window.mountAuth?.();
|
||||
|
||||
expect(mockMountVueApp).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
selector: 'unraid-auth',
|
||||
useShadowRoot: false,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// Skip SSR safety test as it's complex to test with module isolation
|
||||
describe.skip('SSR safety', () => {
|
||||
it('should not initialize when window is undefined', async () => {
|
||||
// This test is skipped because the module initialization happens at import time
|
||||
// and it's difficult to properly isolate the window object manipulation
|
||||
// The functionality is simple enough - just checking if window exists before running code
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -17,65 +17,74 @@
|
||||
|
||||
/*
|
||||
* Minimal styles for our components
|
||||
* Only essential styles to ensure components work properly
|
||||
* Using @layer to ensure these have lower priority than Tailwind utilities
|
||||
*/
|
||||
|
||||
/* Box-sizing for proper layout */
|
||||
.unapi *,
|
||||
.unapi *::before,
|
||||
.unapi *::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Reset figure element for logo */
|
||||
.unapi figure {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Reset heading elements - only margin/padding */
|
||||
.unapi h1,
|
||||
.unapi h2,
|
||||
.unapi h3,
|
||||
.unapi h4,
|
||||
.unapi h5 {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Reset paragraph element */
|
||||
.unapi p {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Reset toggle/switch button backgrounds */
|
||||
button[role="switch"],
|
||||
button[role="switch"][data-state="checked"],
|
||||
button[role="switch"][data-state="unchecked"] {
|
||||
background-color: transparent !important;
|
||||
background: transparent !important;
|
||||
border: 1px solid #ccc !important;
|
||||
}
|
||||
|
||||
/* Style for checked state */
|
||||
button[role="switch"][data-state="checked"] {
|
||||
background-color: #ff8c2f !important; /* Unraid orange */
|
||||
}
|
||||
|
||||
/* Style for unchecked state */
|
||||
button[role="switch"][data-state="unchecked"] {
|
||||
background-color: #e5e5e5 !important;
|
||||
}
|
||||
|
||||
/* Dark mode toggle styles */
|
||||
.dark button[role="switch"][data-state="unchecked"] {
|
||||
background-color: #333 !important;
|
||||
border-color: #555 !important;
|
||||
}
|
||||
|
||||
/* Toggle thumb/handle */
|
||||
button[role="switch"] span {
|
||||
background-color: white !important;
|
||||
@layer base {
|
||||
/* Box-sizing for proper layout */
|
||||
.unapi *,
|
||||
.unapi *::before,
|
||||
.unapi *::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Reset figure element for logo */
|
||||
.unapi figure {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Reset heading elements - only margin/padding */
|
||||
.unapi h1,
|
||||
.unapi h2,
|
||||
.unapi h3,
|
||||
.unapi h4,
|
||||
.unapi h5 {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Reset paragraph element */
|
||||
.unapi p {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Reset UL styles to prevent default browser styling */
|
||||
.unapi ul {
|
||||
padding-inline-start: 0;
|
||||
list-style-type: none;
|
||||
}
|
||||
|
||||
/* Reset toggle/switch button backgrounds */
|
||||
.unapi button[role="switch"],
|
||||
.unapi button[role="switch"][data-state="checked"],
|
||||
.unapi button[role="switch"][data-state="unchecked"] {
|
||||
background-color: transparent;
|
||||
background: transparent;
|
||||
border: 1px solid #ccc;
|
||||
}
|
||||
|
||||
/* Style for checked state */
|
||||
.unapi button[role="switch"][data-state="checked"] {
|
||||
background-color: #ff8c2f; /* Unraid orange */
|
||||
}
|
||||
|
||||
/* Style for unchecked state */
|
||||
.unapi button[role="switch"][data-state="unchecked"] {
|
||||
background-color: #e5e5e5;
|
||||
}
|
||||
|
||||
/* Dark mode toggle styles */
|
||||
.unapi.dark button[role="switch"][data-state="unchecked"],
|
||||
.dark .unapi button[role="switch"][data-state="unchecked"] {
|
||||
background-color: #333;
|
||||
border-color: #555;
|
||||
}
|
||||
|
||||
/* Toggle thumb/handle */
|
||||
.unapi button[role="switch"] span {
|
||||
background-color: white;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -417,7 +417,7 @@ const copyApiKey = async () => {
|
||||
:open="props.open"
|
||||
sheet-side="bottom"
|
||||
:sheet-class="'h-[100vh] flex flex-col'"
|
||||
:dialog-class="'max-w-4xl max-h-[90vh] overflow-hidden'"
|
||||
:dialog-class="'max-w-3xl max-h-[90vh] overflow-hidden'"
|
||||
:show-close-button="true"
|
||||
@update:open="
|
||||
(v) => {
|
||||
|
||||
@@ -113,7 +113,7 @@ const returnToApp = () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full max-w-4xl mx-auto p-6">
|
||||
<div class="w-full max-w-3xl mx-auto p-6">
|
||||
<!-- Success state -->
|
||||
<div v-if="showSuccess && createdApiKey" class="w-full bg-background rounded-lg shadow-sm border border-muted">
|
||||
<!-- Header -->
|
||||
|
||||
@@ -141,7 +141,7 @@ const updateOsStatus = computed(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-y-2 mt-2 ml-2">
|
||||
<div class="flex flex-col gap-y-2 mt-4 ml-4">
|
||||
<a
|
||||
:href="unraidLogoHeaderLink.href"
|
||||
:title="unraidLogoHeaderLink.title"
|
||||
@@ -156,7 +156,7 @@ const updateOsStatus = computed(() => {
|
||||
>
|
||||
</a>
|
||||
|
||||
<div class="flex flex-wrap justify-start gap-2">
|
||||
<div class="flex flex-wrap justify-start gap-2 mt-2">
|
||||
<DropdownMenuRoot>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button
|
||||
@@ -165,7 +165,7 @@ const updateOsStatus = computed(() => {
|
||||
:title="t('Version Information')"
|
||||
>
|
||||
<InformationCircleIcon
|
||||
class="fill-current w-3 h-3 xs:w-4 xs:h-4 shrink-0"
|
||||
:style="{ width: '12px', height: '12px', flexShrink: 0 }"
|
||||
/>
|
||||
{{ displayOsVersion }}
|
||||
</Button>
|
||||
|
||||
11
web/components/UnraidToaster.vue
Normal file
11
web/components/UnraidToaster.vue
Normal file
@@ -0,0 +1,11 @@
|
||||
<script lang="ts" setup>
|
||||
import { Toaster } from '@unraid/ui';
|
||||
|
||||
defineProps<{
|
||||
position: 'top-center' | 'top-right' | 'top-left' | 'bottom-center' | 'bottom-right' | 'bottom-left';
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Toaster rich-colors close-button :position="position" />
|
||||
</template>
|
||||
@@ -85,7 +85,7 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div id="UserProfile" class="text-foreground relative z-20 flex flex-col h-full gap-y-1 pt-2 pr-2">
|
||||
<div id="UserProfile" class="text-foreground z-20 flex flex-col h-full gap-y-1 pt-2 pr-2 absolute top-0 right-0">
|
||||
<div
|
||||
v-if="bannerGradient"
|
||||
class="absolute z-0 w-full top-0 bottom-0 right-0"
|
||||
|
||||
@@ -23,6 +23,15 @@ const mountedAppContainers = new Map<string, HTMLElement[]>(); // shadow-root co
|
||||
// Shared style injection tracking
|
||||
const styleInjected = new WeakSet<Document | ShadowRoot>();
|
||||
|
||||
// Extend HTMLElement to include Vue's internal properties
|
||||
interface HTMLElementWithVue extends HTMLElement {
|
||||
__vueParentComponent?: {
|
||||
appContext?: {
|
||||
app?: VueApp;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
// Expose globally for debugging
|
||||
declare global {
|
||||
interface Window {
|
||||
@@ -94,6 +103,7 @@ export interface MountOptions {
|
||||
appId?: string;
|
||||
useShadowRoot?: boolean;
|
||||
props?: Record<string, unknown>;
|
||||
skipRecovery?: boolean; // Internal flag to prevent recursive recovery attempts
|
||||
}
|
||||
|
||||
// Helper function to parse props from HTML attributes
|
||||
@@ -133,7 +143,7 @@ function parsePropsFromElement(element: Element): Record<string, unknown> {
|
||||
}
|
||||
|
||||
export function mountVueApp(options: MountOptions): VueApp | null {
|
||||
const { component, selector, appId = selector, useShadowRoot = false, props = {} } = options;
|
||||
const { component, selector, appId = selector, useShadowRoot = false, props = {}, skipRecovery = false } = options;
|
||||
|
||||
// Check if app is already mounted
|
||||
if (mountedApps.has(appId)) {
|
||||
@@ -141,6 +151,85 @@ export function mountVueApp(options: MountOptions): VueApp | null {
|
||||
return mountedApps.get(appId)!;
|
||||
}
|
||||
|
||||
// Special handling for modals - enforce singleton behavior
|
||||
if (selector.includes('unraid-modals') || selector === '#modals') {
|
||||
const existingModalApps = ['modals', 'modals-direct', 'unraid-modals'];
|
||||
for (const modalId of existingModalApps) {
|
||||
if (mountedApps.has(modalId)) {
|
||||
console.debug(`[VueMountApp] Modals component already mounted as ${modalId}, skipping ${appId}`);
|
||||
return mountedApps.get(modalId)!;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if any elements matching the selector already have Vue apps mounted
|
||||
const potentialTargets = document.querySelectorAll(selector);
|
||||
for (const target of potentialTargets) {
|
||||
const element = target as HTMLElementWithVue;
|
||||
const hasVueAttributes = element.hasAttribute('data-vue-mounted') ||
|
||||
element.hasAttribute('data-v-app') ||
|
||||
element.hasAttribute('data-server-rendered');
|
||||
|
||||
if (hasVueAttributes || element.__vueParentComponent) {
|
||||
// Check if the existing Vue component is actually working (has content)
|
||||
const hasContent = element.innerHTML.trim().length > 0 ||
|
||||
element.children.length > 0;
|
||||
|
||||
if (hasContent) {
|
||||
console.info(`[VueMountApp] Element ${selector} already has working Vue component, skipping remount`);
|
||||
// Return the existing app if we can find it
|
||||
const existingApp = mountedApps.get(appId);
|
||||
if (existingApp) {
|
||||
return existingApp;
|
||||
}
|
||||
// If we can't find the app reference but component is working, return null (success)
|
||||
return null;
|
||||
}
|
||||
|
||||
console.warn(`[VueMountApp] Element ${selector} has Vue attributes but no content, cleaning up`);
|
||||
|
||||
try {
|
||||
// DO NOT attempt to unmount existing Vue instances - this causes the nextSibling error
|
||||
// Instead, just clear the DOM state and let Vue handle the cleanup naturally
|
||||
|
||||
// Remove all Vue-related attributes
|
||||
element.removeAttribute('data-vue-mounted');
|
||||
element.removeAttribute('data-v-app');
|
||||
element.removeAttribute('data-server-rendered');
|
||||
|
||||
// Remove any Vue-injected attributes
|
||||
Array.from(element.attributes).forEach(attr => {
|
||||
if (attr.name.startsWith('data-v-')) {
|
||||
element.removeAttribute(attr.name);
|
||||
}
|
||||
});
|
||||
|
||||
// Clear the element content to ensure fresh state
|
||||
element.innerHTML = '';
|
||||
|
||||
// Remove the __vueParentComponent reference without calling unmount
|
||||
delete element.__vueParentComponent;
|
||||
|
||||
console.info(`[VueMountApp] Cleared Vue state from ${selector} without unmounting (prevents nextSibling errors)`);
|
||||
|
||||
} catch (error) {
|
||||
console.warn(`[VueMountApp] Error cleaning up existing Vue instance:`, error);
|
||||
// Force clear everything if normal cleanup fails
|
||||
element.innerHTML = '';
|
||||
element.removeAttribute('data-vue-mounted');
|
||||
element.removeAttribute('data-v-app');
|
||||
element.removeAttribute('data-server-rendered');
|
||||
|
||||
// Remove all data-v-* attributes
|
||||
Array.from(element.attributes).forEach(attr => {
|
||||
if (attr.name.startsWith('data-v-')) {
|
||||
element.removeAttribute(attr.name);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Find all mount targets
|
||||
const targets = document.querySelectorAll(selector);
|
||||
if (targets.length === 0) {
|
||||
@@ -174,8 +263,64 @@ export function mountVueApp(options: MountOptions): VueApp | null {
|
||||
targets.forEach((target, index) => {
|
||||
const mountTarget = target as HTMLElement;
|
||||
|
||||
// Add unapi class for minimal styling
|
||||
// Comprehensive DOM validation
|
||||
if (!mountTarget.isConnected || !mountTarget.parentNode || !document.contains(mountTarget)) {
|
||||
console.warn(`[VueMountApp] Mount target not properly connected to DOM for ${appId}, skipping`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Special handling for PHP-generated pages that might have whitespace/comment nodes
|
||||
if (mountTarget.childNodes.length > 0) {
|
||||
let hasProblematicNodes = false;
|
||||
const nodesToRemove: Node[] = [];
|
||||
|
||||
Array.from(mountTarget.childNodes).forEach(node => {
|
||||
// Check for orphaned nodes
|
||||
if (node.parentNode !== mountTarget) {
|
||||
hasProblematicNodes = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for empty text nodes or comments that could cause fragment issues
|
||||
if (node.nodeType === Node.TEXT_NODE && node.textContent?.trim() === '') {
|
||||
nodesToRemove.push(node);
|
||||
hasProblematicNodes = true;
|
||||
} else if (node.nodeType === Node.COMMENT_NODE) {
|
||||
nodesToRemove.push(node);
|
||||
hasProblematicNodes = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (hasProblematicNodes) {
|
||||
console.warn(`[VueMountApp] Cleaning up problematic nodes in ${selector} before mounting`);
|
||||
|
||||
// Remove problematic nodes
|
||||
nodesToRemove.forEach(node => {
|
||||
try {
|
||||
if (node.parentNode) {
|
||||
node.parentNode.removeChild(node);
|
||||
}
|
||||
} catch (_e) {
|
||||
// If removal fails, clear the entire content
|
||||
mountTarget.innerHTML = '';
|
||||
}
|
||||
});
|
||||
|
||||
// If we still have orphaned nodes after cleanup, clear everything
|
||||
const remainingInvalidChildren = Array.from(mountTarget.childNodes).filter(node => {
|
||||
return node.parentNode !== mountTarget;
|
||||
});
|
||||
|
||||
if (remainingInvalidChildren.length > 0) {
|
||||
console.warn(`[VueMountApp] Clearing all content due to remaining orphaned nodes in ${selector}`);
|
||||
mountTarget.innerHTML = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add unapi class for minimal styling and mark as mounted
|
||||
mountTarget.classList.add('unapi');
|
||||
mountTarget.setAttribute('data-vue-mounted', 'true');
|
||||
|
||||
if (useShadowRoot) {
|
||||
// Create shadow root if needed
|
||||
@@ -195,15 +340,26 @@ export function mountVueApp(options: MountOptions): VueApp | null {
|
||||
|
||||
// For the first target, use the main app, otherwise create clones
|
||||
if (index === 0) {
|
||||
app.mount(container);
|
||||
try {
|
||||
app.mount(container);
|
||||
} catch (error) {
|
||||
console.error(`[VueMountApp] Error mounting main app to shadow root ${selector}:`, error);
|
||||
throw error;
|
||||
}
|
||||
} else {
|
||||
const targetProps = { ...parsePropsFromElement(mountTarget), ...props };
|
||||
const clonedApp = createApp(component, targetProps);
|
||||
clonedApp.use(i18n);
|
||||
clonedApp.use(globalPinia);
|
||||
clonedApp.provide(DefaultApolloClient, apolloClient);
|
||||
clonedApp.mount(container);
|
||||
clones.push(clonedApp);
|
||||
|
||||
try {
|
||||
clonedApp.mount(container);
|
||||
clones.push(clonedApp);
|
||||
} catch (error) {
|
||||
console.error(`[VueMountApp] Error mounting cloned app to shadow root ${selector}:`, error);
|
||||
// Don't call unmount since mount failed - just let the app be garbage collected
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Direct mount without shadow root
|
||||
@@ -213,7 +369,45 @@ export function mountVueApp(options: MountOptions): VueApp | null {
|
||||
// but they'll share the same Pinia store
|
||||
if (index === 0) {
|
||||
// First target, use the main app
|
||||
app.mount(mountTarget);
|
||||
try {
|
||||
// Final validation before mounting
|
||||
if (!mountTarget.isConnected || !document.contains(mountTarget)) {
|
||||
throw new Error(`Mount target disconnected before mounting: ${selector}`);
|
||||
}
|
||||
|
||||
app.mount(mountTarget);
|
||||
} catch (error) {
|
||||
console.error(`[VueMountApp] Error mounting main app to ${selector}:`, error);
|
||||
|
||||
// Special handling for nextSibling error - attempt recovery (only if not already retrying)
|
||||
if (!skipRecovery && error instanceof TypeError && error.message.includes('nextSibling')) {
|
||||
console.warn(`[VueMountApp] Attempting recovery from nextSibling error for ${selector}`);
|
||||
|
||||
// Remove the problematic data attribute that might be causing issues
|
||||
mountTarget.removeAttribute('data-vue-mounted');
|
||||
|
||||
// Try mounting after a brief delay to let DOM settle
|
||||
setTimeout(() => {
|
||||
try {
|
||||
// Ensure element is still valid
|
||||
if (mountTarget.isConnected && document.contains(mountTarget)) {
|
||||
app.mount(mountTarget);
|
||||
mountTarget.setAttribute('data-vue-mounted', 'true');
|
||||
console.info(`[VueMountApp] Successfully recovered from nextSibling error for ${selector}`);
|
||||
} else {
|
||||
console.error(`[VueMountApp] Recovery failed - element no longer in DOM: ${selector}`);
|
||||
}
|
||||
} catch (retryError) {
|
||||
console.error(`[VueMountApp] Recovery attempt failed for ${selector}:`, retryError);
|
||||
}
|
||||
}, 10);
|
||||
|
||||
// Return without throwing to allow other elements to mount
|
||||
return;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
} else {
|
||||
// Additional targets, create cloned apps with their own props
|
||||
const targetProps = { ...parsePropsFromElement(mountTarget), ...props };
|
||||
@@ -221,8 +415,14 @@ export function mountVueApp(options: MountOptions): VueApp | null {
|
||||
clonedApp.use(i18n);
|
||||
clonedApp.use(globalPinia); // Shared Pinia instance
|
||||
clonedApp.provide(DefaultApolloClient, apolloClient);
|
||||
clonedApp.mount(mountTarget);
|
||||
clones.push(clonedApp);
|
||||
|
||||
try {
|
||||
clonedApp.mount(mountTarget);
|
||||
clones.push(clonedApp);
|
||||
} catch (error) {
|
||||
console.error(`[VueMountApp] Error mounting cloned app to ${selector}:`, error);
|
||||
// Don't call unmount since mount failed - just let the app be garbage collected
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -242,17 +442,43 @@ export function unmountVueApp(appId: string): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Unmount clones first
|
||||
// Unmount clones first with error handling
|
||||
const clones = mountedAppClones.get(appId) ?? [];
|
||||
for (const c of clones) c.unmount();
|
||||
for (const c of clones) {
|
||||
try {
|
||||
c.unmount();
|
||||
} catch (error) {
|
||||
console.warn(`[VueMountApp] Error unmounting clone for ${appId}:`, error);
|
||||
}
|
||||
}
|
||||
mountedAppClones.delete(appId);
|
||||
|
||||
// Remove shadow containers
|
||||
// Remove shadow containers with error handling
|
||||
const containers = mountedAppContainers.get(appId) ?? [];
|
||||
for (const el of containers) el.remove();
|
||||
for (const el of containers) {
|
||||
try {
|
||||
el.remove();
|
||||
} catch (error) {
|
||||
console.warn(`[VueMountApp] Error removing container for ${appId}:`, error);
|
||||
}
|
||||
}
|
||||
mountedAppContainers.delete(appId);
|
||||
|
||||
app.unmount();
|
||||
// Unmount main app with error handling
|
||||
try {
|
||||
app.unmount();
|
||||
|
||||
// Clean up data attributes from mounted elements
|
||||
const elements = document.querySelectorAll(`[data-vue-mounted="true"]`);
|
||||
elements.forEach(el => {
|
||||
if (el.classList.contains('unapi')) {
|
||||
el.removeAttribute('data-vue-mounted');
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn(`[VueMountApp] Error unmounting app ${appId}:`, error);
|
||||
}
|
||||
|
||||
mountedApps.delete(appId);
|
||||
return true;
|
||||
}
|
||||
@@ -264,16 +490,123 @@ export function getMountedApp(appId: string): VueApp | undefined {
|
||||
// Auto-mount function for script tags
|
||||
export function autoMountComponent(component: Component, selector: string, options?: Partial<MountOptions>) {
|
||||
const tryMount = () => {
|
||||
// Check if elements exist before attempting to mount
|
||||
if (document.querySelector(selector)) {
|
||||
try {
|
||||
mountVueApp({ component, selector, ...options });
|
||||
} catch (error) {
|
||||
console.error(`[VueMountApp] Failed to mount component for selector ${selector}:`, error);
|
||||
// Special handling for modals - should only mount once, ignore subsequent attempts
|
||||
if (selector.includes('unraid-modals') || selector === '#modals') {
|
||||
const modalAppId = options?.appId || 'modals';
|
||||
if (mountedApps.has(modalAppId) || mountedApps.has('modals-direct')) {
|
||||
console.debug(`[VueMountApp] Modals component already mounted, skipping ${selector}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if elements exist before attempting to mount
|
||||
const elements = document.querySelectorAll(selector);
|
||||
if (elements.length > 0) {
|
||||
// For specific problematic selectors, add extra delay to let page scripts settle
|
||||
const isProblematicSelector = selector.includes('unraid-connect-settings') ||
|
||||
selector.includes('unraid-modals') ||
|
||||
selector.includes('unraid-theme-switcher');
|
||||
|
||||
if (isProblematicSelector) {
|
||||
// Wait longer for PHP-generated pages with dynamic content
|
||||
setTimeout(() => {
|
||||
performMount();
|
||||
}, 200);
|
||||
return;
|
||||
}
|
||||
|
||||
performMount();
|
||||
}
|
||||
// Silently skip if no elements found - this is expected for most components
|
||||
};
|
||||
|
||||
const performMount = () => {
|
||||
const elements = document.querySelectorAll(selector);
|
||||
if (elements.length === 0) return;
|
||||
|
||||
// Validate all elements are properly connected to the DOM
|
||||
const validElements = Array.from(elements).filter(el => {
|
||||
const element = el as HTMLElement;
|
||||
|
||||
// Basic connectivity check - element must be in DOM
|
||||
if (!element.isConnected || !element.parentNode || !document.contains(element)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Additional check: ensure the element's parentNode relationship is stable
|
||||
// This catches cases where elements appear connected but have DOM manipulation issues
|
||||
try {
|
||||
// Try to access nextSibling - this will throw if DOM is in inconsistent state
|
||||
void element.nextSibling;
|
||||
// Verify parent-child relationship is intact
|
||||
if (element.parentNode && !Array.from(element.parentNode.childNodes).includes(element)) {
|
||||
console.warn(`[VueMountApp] Element ${selector} has broken parent-child relationship`);
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`[VueMountApp] Element ${selector} has unstable DOM state:`, error);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
if (validElements.length > 0) {
|
||||
try {
|
||||
mountVueApp({ component, selector, ...options });
|
||||
} catch (error) {
|
||||
console.error(`[VueMountApp] Failed to mount component for selector ${selector}:`, error);
|
||||
|
||||
// Additional debugging for this specific error
|
||||
if (error instanceof TypeError && error.message.includes('nextSibling')) {
|
||||
console.warn(`[VueMountApp] DOM state issue detected for ${selector}, attempting cleanup and retry`);
|
||||
|
||||
// Perform more aggressive cleanup for nextSibling errors
|
||||
validElements.forEach(el => {
|
||||
const element = el as HTMLElement;
|
||||
|
||||
// Remove all Vue-related attributes that might be causing issues
|
||||
element.removeAttribute('data-vue-mounted');
|
||||
element.removeAttribute('data-v-app');
|
||||
Array.from(element.attributes).forEach(attr => {
|
||||
if (attr.name.startsWith('data-v-')) {
|
||||
element.removeAttribute(attr.name);
|
||||
}
|
||||
});
|
||||
|
||||
// Completely reset the element's content and state
|
||||
element.innerHTML = '';
|
||||
element.className = element.className.replace(/\bunapi\b/g, '').trim();
|
||||
|
||||
// Remove any Vue instance references
|
||||
delete (element as unknown as HTMLElementWithVue).__vueParentComponent;
|
||||
});
|
||||
|
||||
// Wait for DOM to stabilize and try again
|
||||
setTimeout(() => {
|
||||
try {
|
||||
console.info(`[VueMountApp] Retrying mount for ${selector} after cleanup`);
|
||||
mountVueApp({ component, selector, ...options, skipRecovery: true });
|
||||
} catch (retryError) {
|
||||
console.error(`[VueMountApp] Retry failed for ${selector}:`, retryError);
|
||||
|
||||
// If retry also fails, try one more time with even more delay
|
||||
setTimeout(() => {
|
||||
try {
|
||||
console.info(`[VueMountApp] Final retry attempt for ${selector}`);
|
||||
mountVueApp({ component, selector, ...options, skipRecovery: true });
|
||||
} catch (finalError) {
|
||||
console.error(`[VueMountApp] All retry attempts failed for ${selector}:`, finalError);
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
}, 50);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.warn(`[VueMountApp] No valid DOM elements found for ${selector} (${elements.length} elements exist but not properly connected)`);
|
||||
}
|
||||
};
|
||||
|
||||
// Wait for DOM to be ready
|
||||
if (document.readyState === 'loading') {
|
||||
|
||||
@@ -27,7 +27,7 @@ const handleClick = () => {
|
||||
<img
|
||||
v-if="props.provider.buttonIcon"
|
||||
:src="props.provider.buttonIcon"
|
||||
class="w-6 h-6 sso-button-icon flex-shrink-0"
|
||||
class="w-4 h-4 sso-button-icon flex-shrink-0"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// Import all components
|
||||
import type { Component } from 'vue';
|
||||
import Auth from './Auth.ce.vue';
|
||||
import ConnectSettings from './ConnectSettings/ConnectSettings.ce.vue';
|
||||
import DownloadApiLogs from './DownloadApiLogs.ce.vue';
|
||||
@@ -17,7 +16,7 @@ import ThemeSwitcher from './ThemeSwitcher.ce.vue';
|
||||
import ApiKeyPage from './ApiKeyPage.ce.vue';
|
||||
import DevModalTest from './DevModalTest.ce.vue';
|
||||
import ApiKeyAuthorize from './ApiKeyAuthorize.ce.vue';
|
||||
|
||||
import UnraidToaster from './UnraidToaster.vue';
|
||||
// Import utilities
|
||||
import { autoMountComponent, mountVueApp, getMountedApp } from './Wrapper/vue-mount-app';
|
||||
import { useThemeStore } from '~/store/theme';
|
||||
@@ -27,92 +26,11 @@ import { provideApolloClient } from '@vue/apollo-composable';
|
||||
import { parse } from 'graphql';
|
||||
import { ensureTeleportContainer } from '@unraid/ui';
|
||||
|
||||
// Extend window interface for Apollo client
|
||||
declare global {
|
||||
interface Window {
|
||||
apolloClient: typeof apolloClient;
|
||||
gql: typeof parse;
|
||||
graphqlParse: typeof parse;
|
||||
}
|
||||
}
|
||||
// Window type definitions are automatically included via tsconfig.json
|
||||
|
||||
// Add pre-render CSS to hide components until they're mounted
|
||||
function injectPreRenderCSS() {
|
||||
const style = document.createElement('style');
|
||||
style.id = 'unraid-prerender-css';
|
||||
style.textContent = `
|
||||
/* Hide unraid components during initial load to prevent FOUC */
|
||||
unraid-auth,
|
||||
unraid-connect-settings,
|
||||
unraid-download-api-logs,
|
||||
unraid-header-os-version,
|
||||
unraid-modals,
|
||||
unraid-user-profile,
|
||||
unraid-update-os,
|
||||
unraid-downgrade-os,
|
||||
unraid-registration,
|
||||
unraid-wan-ip-check,
|
||||
unraid-welcome-modal,
|
||||
unraid-sso-button,
|
||||
unraid-log-viewer,
|
||||
unraid-theme-switcher,
|
||||
unraid-api-key-manager,
|
||||
unraid-dev-modal-test,
|
||||
unraid-api-key-authorize {
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
/* Show components once they have the unapi class (mounted) */
|
||||
unraid-auth.unapi,
|
||||
unraid-connect-settings.unapi,
|
||||
unraid-download-api-logs.unapi,
|
||||
unraid-header-os-version.unapi,
|
||||
unraid-modals.unapi,
|
||||
unraid-user-profile.unapi,
|
||||
unraid-update-os.unapi,
|
||||
unraid-downgrade-os.unapi,
|
||||
unraid-registration.unapi,
|
||||
unraid-wan-ip-check.unapi,
|
||||
unraid-welcome-modal.unapi,
|
||||
unraid-sso-button.unapi,
|
||||
unraid-log-viewer.unapi,
|
||||
unraid-theme-switcher.unapi,
|
||||
unraid-api-key-manager.unapi,
|
||||
unraid-dev-modal-test.unapi,
|
||||
unraid-api-key-authorize.unapi {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Font size overrides for SSO button component */
|
||||
unraid-sso-button {
|
||||
--text-xs: 0.75rem;
|
||||
--text-sm: 0.875rem;
|
||||
--text-base: 1rem;
|
||||
--text-lg: 1.125rem;
|
||||
--text-xl: 1.25rem;
|
||||
--text-2xl: 1.5rem;
|
||||
--text-3xl: 1.875rem;
|
||||
--text-4xl: 2.25rem;
|
||||
--text-5xl: 3rem;
|
||||
--text-6xl: 3.75rem;
|
||||
--text-7xl: 4.5rem;
|
||||
--text-8xl: 6rem;
|
||||
--text-9xl: 8rem;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
// Initialize global Apollo client context
|
||||
if (typeof window !== 'undefined') {
|
||||
// Inject pre-render CSS as early as possible
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', injectPreRenderCSS);
|
||||
} else {
|
||||
injectPreRenderCSS();
|
||||
}
|
||||
|
||||
// Make Apollo client globally available
|
||||
window.apolloClient = apolloClient;
|
||||
|
||||
@@ -140,6 +58,7 @@ const componentMappings = [
|
||||
{ component: DownloadApiLogs, selector: 'unraid-download-api-logs', appId: 'download-api-logs' },
|
||||
{ component: HeaderOsVersion, selector: 'unraid-header-os-version', appId: 'header-os-version' },
|
||||
{ component: Modals, selector: 'unraid-modals', appId: 'modals' },
|
||||
{ component: Modals, selector: '#modals', appId: 'modals-legacy' }, // Legacy ID selector
|
||||
{ component: UserProfile, selector: 'unraid-user-profile', appId: 'user-profile' },
|
||||
{ component: UpdateOs, selector: 'unraid-update-os', appId: 'update-os' },
|
||||
{ component: DowngradeOs, selector: 'unraid-downgrade-os', appId: 'downgrade-os' },
|
||||
@@ -152,6 +71,8 @@ const componentMappings = [
|
||||
{ component: ApiKeyPage, selector: 'unraid-api-key-manager', appId: 'api-key-manager' },
|
||||
{ component: DevModalTest, selector: 'unraid-dev-modal-test', appId: 'dev-modal-test' },
|
||||
{ component: ApiKeyAuthorize, selector: 'unraid-api-key-authorize', appId: 'api-key-authorize' },
|
||||
{ component: UnraidToaster, selector: 'uui-toaster', appId: 'toaster' },
|
||||
{ component: UnraidToaster, selector: 'unraid-toaster', appId: 'toaster-legacy' }, // Legacy alias
|
||||
];
|
||||
|
||||
// Auto-mount all components
|
||||
@@ -162,20 +83,7 @@ componentMappings.forEach(({ component, selector, appId }) => {
|
||||
});
|
||||
});
|
||||
|
||||
// Special handling for Modals - also mount to #modals
|
||||
autoMountComponent(Modals, '#modals', {
|
||||
appId: 'modals-direct',
|
||||
useShadowRoot: false,
|
||||
});
|
||||
|
||||
// Expose functions globally for testing and dynamic mounting
|
||||
declare global {
|
||||
interface Window {
|
||||
UnraidComponents: Record<string, Component>;
|
||||
mountVueApp: typeof mountVueApp;
|
||||
getMountedApp: typeof getMountedApp;
|
||||
}
|
||||
}
|
||||
// Window interface extensions are defined in ~/types/window.d.ts
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
// Expose all components
|
||||
@@ -197,6 +105,7 @@ if (typeof window !== 'undefined') {
|
||||
ApiKeyPage,
|
||||
DevModalTest,
|
||||
ApiKeyAuthorize,
|
||||
UnraidToaster,
|
||||
};
|
||||
|
||||
// Expose utility functions
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@unraid/web",
|
||||
"version": "4.18.2",
|
||||
"version": "4.19.0",
|
||||
"private": true,
|
||||
"license": "GPL-2.0-or-later",
|
||||
"scripts": {
|
||||
|
||||
@@ -15,12 +15,12 @@ echo "Cleaning deployed files from Unraid server: $server_name"
|
||||
exit_code=0
|
||||
|
||||
# Remove standalone apps directory
|
||||
echo "Removing standalone apps directory..."
|
||||
ssh root@"${server_name}" "rm -rf /usr/local/emhttp/plugins/dynamix.my.servers/unraid-components/standalone/"
|
||||
standalone_exit_code=$?
|
||||
if [ $standalone_exit_code -ne 0 ]; then
|
||||
echo "Warning: Failed to remove standalone apps directory"
|
||||
exit_code=$standalone_exit_code
|
||||
echo "Removing components directory..."
|
||||
ssh root@"${server_name}" "rm -rf /usr/local/emhttp/plugins/dynamix.my.servers/unraid-components/"
|
||||
components_exit_code=$?
|
||||
if [ $components_exit_code -ne 0 ]; then
|
||||
echo "Warning: Failed to remove components directory"
|
||||
exit_code=$components_exit_code
|
||||
fi
|
||||
|
||||
# Clean up auth-request.php file
|
||||
|
||||
@@ -34,6 +34,8 @@ if [ "$has_standalone" = true ]; then
|
||||
echo "Deploying standalone apps..."
|
||||
# Ensure remote directory exists
|
||||
ssh root@"${server_name}" "mkdir -p /usr/local/emhttp/plugins/dynamix.my.servers/unraid-components/standalone/"
|
||||
# Clear the remote standalone directory before rsyncing
|
||||
ssh root@"${server_name}" "rm -rf /usr/local/emhttp/plugins/dynamix.my.servers/unraid-components/*"
|
||||
# Run rsync with proper quoting
|
||||
rsync -avz --delete -e "ssh" "$standalone_directory" "root@${server_name}:/usr/local/emhttp/plugins/dynamix.my.servers/unraid-components/standalone/"
|
||||
standalone_exit_code=$?
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
{
|
||||
// https://nuxt.com/docs/guide/concepts/typescript
|
||||
"extends": "./.nuxt/tsconfig.json"
|
||||
"extends": "./.nuxt/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"types": ["./types/window"]
|
||||
}
|
||||
}
|
||||
|
||||
52
web/types/window.d.ts
vendored
Normal file
52
web/types/window.d.ts
vendored
Normal file
@@ -0,0 +1,52 @@
|
||||
import type { Component } from 'vue';
|
||||
import type { parse } from 'graphql';
|
||||
import type { client as apolloClient } from '~/helpers/create-apollo-client';
|
||||
import type { mountVueApp, getMountedApp } from '~/components/Wrapper/vue-mount-app';
|
||||
|
||||
/**
|
||||
* Global Window interface extensions for Unraid components
|
||||
* This file provides type definitions for properties added to the window object
|
||||
* by the standalone-mount.ts module
|
||||
*/
|
||||
declare global {
|
||||
interface Window {
|
||||
// Apollo GraphQL client and utilities
|
||||
apolloClient: typeof apolloClient;
|
||||
gql: typeof parse;
|
||||
graphqlParse: typeof parse;
|
||||
|
||||
// Vue component registry and utilities
|
||||
UnraidComponents: Record<string, Component>;
|
||||
mountVueApp: typeof mountVueApp;
|
||||
getMountedApp: typeof getMountedApp;
|
||||
|
||||
// Dynamic mount functions created at runtime
|
||||
// These are generated for each component in componentMappings
|
||||
mountAuth?: (selector?: string) => unknown;
|
||||
mountConnectSettings?: (selector?: string) => unknown;
|
||||
mountDownloadApiLogs?: (selector?: string) => unknown;
|
||||
mountHeaderOsVersion?: (selector?: string) => unknown;
|
||||
mountModals?: (selector?: string) => unknown;
|
||||
mountModalsLegacy?: (selector?: string) => unknown;
|
||||
mountUserProfile?: (selector?: string) => unknown;
|
||||
mountUpdateOs?: (selector?: string) => unknown;
|
||||
mountDowngradeOs?: (selector?: string) => unknown;
|
||||
mountRegistration?: (selector?: string) => unknown;
|
||||
mountWanIpCheck?: (selector?: string) => unknown;
|
||||
mountWelcomeModal?: (selector?: string) => unknown;
|
||||
mountSsoButton?: (selector?: string) => unknown;
|
||||
mountLogViewer?: (selector?: string) => unknown;
|
||||
mountThemeSwitcher?: (selector?: string) => unknown;
|
||||
mountApiKeyManager?: (selector?: string) => unknown;
|
||||
mountDevModalTest?: (selector?: string) => unknown;
|
||||
mountApiKeyAuthorize?: (selector?: string) => unknown;
|
||||
mountToaster?: (selector?: string) => unknown;
|
||||
mountToasterLegacy?: (selector?: string) => unknown;
|
||||
|
||||
// Index signature for any other dynamic mount functions
|
||||
[key: `mount${string}`]: ((selector?: string) => unknown) | undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// Export empty object to make this a module and enable augmentation
|
||||
export {};
|
||||
Reference in New Issue
Block a user