diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml
deleted file mode 100644
index cf119f44e..000000000
--- a/.github/workflows/claude.yml
+++ /dev/null
@@ -1,64 +0,0 @@
-name: Claude Code
-
-on:
- issue_comment:
- types: [created]
- pull_request_review_comment:
- types: [created]
- issues:
- types: [opened, assigned]
- pull_request_review:
- types: [submitted]
-
-jobs:
- claude:
- if: |
- (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
- (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
- (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
- (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
- runs-on: ubuntu-latest
- permissions:
- contents: read
- pull-requests: read
- issues: read
- id-token: write
- actions: read # Required for Claude to read CI results on PRs
- steps:
- - name: Checkout repository
- uses: actions/checkout@v5
- with:
- fetch-depth: 1
-
- - name: Run Claude Code
- id: claude
- uses: anthropics/claude-code-action@beta
- with:
- claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
-
- # This is an optional setting that allows Claude to read CI results on PRs
- additional_permissions: |
- actions: read
-
- # Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4)
- # model: "claude-opus-4-20250514"
-
- # Optional: Customize the trigger phrase (default: @claude)
- # trigger_phrase: "/claude"
-
- # Optional: Trigger when specific user is assigned to an issue
- # assignee_trigger: "claude-bot"
-
- # Optional: Allow Claude to run specific commands
- # allowed_tools: "Bash(npm install),Bash(npm run build),Bash(npm run test:*),Bash(npm run lint:*)"
-
- # Optional: Add custom instructions for Claude to customize its behavior for your project
- # custom_instructions: |
- # Follow our coding standards
- # Ensure all new code has tests
- # Use TypeScript for new files
-
- # Optional: Custom environment variables for Claude
- # claude_env: |
- # NODE_ENV: test
-
diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index 7fe34df16..003506403 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -310,9 +310,6 @@ jobs:
- name: Type Check
run: pnpm run type-check
- - name: Test
- run: pnpm run test:ci
-
- name: Build
run: pnpm run build
diff --git a/@tailwind-shared/base-utilities.css b/@tailwind-shared/base-utilities.css
index 1aec6db34..5fb843677 100644
--- a/@tailwind-shared/base-utilities.css
+++ b/@tailwind-shared/base-utilities.css
@@ -1,7 +1,8 @@
@custom-variant dark (&:where(.dark, .dark *));
/* Utility defaults for web components (when we were using shadow DOM) */
-:host {
+:host,
+.unapi {
--tw-divide-y-reverse: 0;
--tw-border-style: solid;
--tw-font-weight: initial;
@@ -61,7 +62,7 @@
}
*/
-body {
+.unapi {
--color-alpha: #1c1b1b;
--color-beta: #f2f2f2;
--color-gamma: #999999;
@@ -73,13 +74,14 @@ body {
--ring-shadow: 0 0 var(--color-beta);
}
-button:not(:disabled),
-[role='button']:not(:disabled) {
+.unapi button:not(:disabled),
+.unapi [role='button']:not(:disabled) {
cursor: pointer;
}
/* Font size overrides for SSO button component */
-unraid-sso-button {
+.unapi unraid-sso-button,
+unraid-sso-button.unapi {
--text-xs: 0.75rem;
--text-sm: 0.875rem;
--text-base: 1rem;
@@ -93,4 +95,4 @@ unraid-sso-button {
--text-7xl: 4.5rem;
--text-8xl: 6rem;
--text-9xl: 8rem;
-}
\ No newline at end of file
+}
diff --git a/plugin/source/dynamix.unraid.net/usr/local/emhttp/plugins/dynamix.my.servers/include/unraid-api.php b/plugin/source/dynamix.unraid.net/usr/local/emhttp/plugins/dynamix.my.servers/include/unraid-api.php
index 4cbda52d1..51df7c4dc 100644
--- a/plugin/source/dynamix.unraid.net/usr/local/emhttp/plugins/dynamix.my.servers/include/unraid-api.php
+++ b/plugin/source/dynamix.unraid.net/usr/local/emhttp/plugins/dynamix.my.servers/include/unraid-api.php
@@ -69,8 +69,45 @@ switch ($command) {
response_complete(200, array('result' => $output), $output);
break;
case 'restart':
+ $lockFilePath = '/var/run/unraid-api-restart.lock';
+ $lockHandle = @fopen($lockFilePath, 'c');
+ if ($lockHandle === false) {
+ response_complete(500, array('error' => 'Unable to open restart lock file'), 'Unable to open restart lock file');
+ }
+
+ // Use a lockfile to avoid concurrently running restart commands
+ $wouldBlock = null;
+ error_clear_last();
+ $acquiredLock = flock($lockHandle, LOCK_EX | LOCK_NB, $wouldBlock);
+ if (!$acquiredLock) {
+ if (!empty($wouldBlock)) {
+ fclose($lockHandle);
+ response_complete(200, array('success' => true, 'result' => 'Unraid API restart already in progress'), 'Restart already in progress');
+ }
+
+ $lastError = error_get_last();
+ $errorMessage = 'Unable to acquire restart lock';
+ if (!empty($lastError['message'])) {
+ $errorMessage .= ': ' . $lastError['message'];
+ }
+
+ fclose($lockHandle);
+ response_complete(500, array('error' => $errorMessage), $errorMessage);
+ }
+
+ $pid = getmypid();
+ if ($pid !== false) {
+ ftruncate($lockHandle, 0);
+ fwrite($lockHandle, (string)$pid);
+ fflush($lockHandle);
+ }
+
exec('/etc/rc.d/rc.unraid-api restart 2>&1', $output, $retval);
$output = implode(PHP_EOL, $output);
+
+ flock($lockHandle, LOCK_UN);
+ fclose($lockHandle);
+
response_complete(200, array('success' => ($retval === 0), 'result' => $output, 'error' => ($retval !== 0 ? $output : null)), $output);
break;
case 'status':
@@ -100,4 +137,4 @@ switch ($command) {
break;
}
exit;
-?>
\ No newline at end of file
+?>
diff --git a/web/__test__/components/ChangelogModal.test.ts b/web/__test__/components/ChangelogModal.test.ts
new file mode 100644
index 000000000..a5367f76c
--- /dev/null
+++ b/web/__test__/components/ChangelogModal.test.ts
@@ -0,0 +1,171 @@
+import { ref } from 'vue';
+import { mount } from '@vue/test-utils';
+
+import { DOCS } from '~/helpers/urls';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import ChangelogModal from '~/components/UpdateOs/ChangelogModal.vue';
+
+vi.mock('@unraid/ui', () => ({
+ BrandButton: { template: '' },
+ BrandLoading: { template: '
' },
+ cn: (...classes: string[]) => classes.filter(Boolean).join(' '),
+ ResponsiveModal: { template: '
', props: ['open'] },
+ ResponsiveModalFooter: { template: '
' },
+ ResponsiveModalHeader: { template: '
' },
+ ResponsiveModalTitle: { template: '
' },
+}));
+
+vi.mock('@heroicons/vue/24/solid', () => ({
+ ArrowRightIcon: { template: '' },
+ ArrowTopRightOnSquareIcon: { template: '' },
+ KeyIcon: { template: '' },
+ ServerStackIcon: { template: '' },
+}));
+
+vi.mock('~/components/UpdateOs/RawChangelogRenderer.vue', () => ({
+ default: { template: '', props: ['changelog', 'version', 'date', 't', 'changelogPretty'] },
+}));
+
+vi.mock('pinia', async () => {
+ const actual = await vi.importActual('pinia');
+
+ const isActualStore = (candidate: unknown): candidate is Parameters[0] =>
+ Boolean(candidate && typeof candidate === 'object' && '$id' in candidate);
+
+ const isRefLike = (input: unknown): input is { value: unknown } =>
+ Boolean(input && typeof input === 'object' && 'value' in input);
+
+ return {
+ ...actual,
+ storeToRefs: (store: unknown) => {
+ if (isActualStore(store)) {
+ return actual.storeToRefs(store);
+ }
+
+ if (!store || typeof store !== 'object') {
+ return {};
+ }
+
+ const refs: Record = {};
+ for (const [key, value] of Object.entries(store)) {
+ if (isRefLike(value)) {
+ refs[key] = value;
+ }
+ }
+
+ return refs;
+ },
+ };
+});
+
+const mockRenew = vi.fn();
+vi.mock('~/store/purchase', () => ({
+ usePurchaseStore: () => ({
+ renew: mockRenew,
+ }),
+}));
+
+const mockAvailableWithRenewal = ref(false);
+const mockReleaseForUpdate = ref(null);
+const mockChangelogModalVisible = ref(false);
+const mockSetReleaseForUpdate = vi.fn();
+const mockFetchAndConfirmInstall = vi.fn();
+vi.mock('~/store/updateOs', () => ({
+ useUpdateOsStore: () => ({
+ availableWithRenewal: mockAvailableWithRenewal,
+ releaseForUpdate: mockReleaseForUpdate,
+ changelogModalVisible: mockChangelogModalVisible,
+ setReleaseForUpdate: mockSetReleaseForUpdate,
+ fetchAndConfirmInstall: mockFetchAndConfirmInstall,
+ }),
+}));
+
+const mockDarkMode = ref(false);
+const mockTheme = ref({ name: 'default' });
+vi.mock('~/store/theme', () => ({
+ useThemeStore: () => ({
+ darkMode: mockDarkMode,
+ theme: mockTheme,
+ }),
+}));
+
+describe('ChangelogModal iframeSrc', () => {
+ const mountWithChangelog = (changelogPretty: string | null) =>
+ mount(ChangelogModal, {
+ props: {
+ t: (key: string) => key,
+ open: true,
+ release: {
+ version: '6.12.0',
+ changelogPretty: changelogPretty ?? undefined,
+ changelog: 'Raw changelog markdown',
+ name: 'Unraid OS 6.12.0',
+ date: '2024-01-01',
+ },
+ },
+ });
+
+ beforeEach(() => {
+ mockRenew.mockClear();
+ mockAvailableWithRenewal.value = false;
+ mockReleaseForUpdate.value = null;
+ mockChangelogModalVisible.value = false;
+ mockSetReleaseForUpdate.mockClear();
+ mockFetchAndConfirmInstall.mockClear();
+ mockDarkMode.value = false;
+ mockTheme.value = { name: 'default' };
+ });
+
+ it('sanitizes absolute docs URLs to embed within DOCS origin', () => {
+ const entry = `${DOCS.origin}/go/release-notes/?foo=bar#section`;
+ const wrapper = mountWithChangelog(entry);
+
+ const iframeSrc = (wrapper.vm as unknown as { iframeSrc: string | null }).iframeSrc;
+ expect(iframeSrc).toBeTruthy();
+
+ const iframeUrl = new URL(iframeSrc!);
+ expect(iframeUrl.origin).toBe(DOCS.origin);
+ expect(iframeUrl.pathname).toBe('/go/release-notes/');
+ expect(iframeUrl.searchParams.get('embed')).toBe('1');
+ expect(iframeUrl.searchParams.get('theme')).toBe('light');
+ expect(iframeUrl.searchParams.get('entry')).toBe('/go/release-notes/?foo=bar#section');
+ });
+
+ it('builds DOCS-relative URL when provided a path entry', () => {
+ const wrapper = mountWithChangelog('updates/6.12?tab=notes#overview');
+
+ const iframeSrc = (wrapper.vm as unknown as { iframeSrc: string | null }).iframeSrc;
+ expect(iframeSrc).toBeTruthy();
+
+ const iframeUrl = new URL(iframeSrc!);
+ expect(iframeUrl.origin).toBe(DOCS.origin);
+ expect(iframeUrl.pathname).toBe('/updates/6.12');
+ expect(iframeUrl.searchParams.get('entry')).toBe('/updates/6.12?tab=notes#overview');
+ });
+
+ it('applies dark theme when current UI theme requires it', () => {
+ mockTheme.value = { name: 'azure' };
+ const wrapper = mountWithChangelog(`${DOCS.origin}/release/6.12`);
+
+ const iframeSrc = (wrapper.vm as unknown as { iframeSrc: string | null }).iframeSrc;
+ expect(iframeSrc).toBeTruthy();
+
+ const iframeUrl = new URL(iframeSrc!);
+ expect(iframeUrl.searchParams.get('theme')).toBe('dark');
+ });
+
+ it('rejects non-docs origins and returns null', () => {
+ const wrapper = mountWithChangelog('https://example.com/bad');
+
+ const iframeSrc = (wrapper.vm as unknown as { iframeSrc: string | null }).iframeSrc;
+ expect(iframeSrc).toBeNull();
+ });
+
+ it('rejects non-http(s) protocols', () => {
+ const wrapper = mountWithChangelog('javascript:alert(1)');
+
+ const iframeSrc = (wrapper.vm as unknown as { iframeSrc: string | null }).iframeSrc;
+ expect(iframeSrc).toBeNull();
+ });
+});
diff --git a/web/__test__/components/CheckUpdateResponseModal.test.ts b/web/__test__/components/CheckUpdateResponseModal.test.ts
new file mode 100644
index 000000000..a6a074106
--- /dev/null
+++ b/web/__test__/components/CheckUpdateResponseModal.test.ts
@@ -0,0 +1,271 @@
+import { nextTick, ref } from 'vue';
+import { mount } from '@vue/test-utils';
+
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import type { ComposerTranslation } from 'vue-i18n';
+
+import CheckUpdateResponseModal from '~/components/UpdateOs/CheckUpdateResponseModal.vue';
+
+const translate: ComposerTranslation = ((key: string, params?: unknown) => {
+ if (Array.isArray(params) && params.length > 0) {
+ return params.reduce(
+ (result, value, index) => result.replace(`{${index}}`, String(value)),
+ key
+ );
+ }
+
+ if (params && typeof params === 'object') {
+ return Object.entries(params as Record).reduce(
+ (result, [placeholder, value]) => result.replace(`{${placeholder}}`, String(value)),
+ key
+ );
+ }
+
+ if (typeof params === 'number') {
+ return key.replace('{0}', String(params));
+ }
+
+ return key;
+}) as ComposerTranslation;
+
+vi.mock('@unraid/ui', () => ({
+ BrandButton: {
+ name: 'BrandButton',
+ props: {
+ text: {
+ type: String,
+ default: undefined,
+ },
+ },
+ emits: ['click'],
+ template: '',
+ },
+ BrandLoading: { template: '' },
+ Button: { template: '' },
+ cn: (...classes: string[]) => classes.filter(Boolean).join(' '),
+ DialogDescription: { template: '
' },
+ Label: { template: '' },
+ ResponsiveModal: {
+ name: 'ResponsiveModal',
+ props: ['open', 'dialogClass', 'sheetClass', 'showCloseButton'],
+ template: '
',
+ },
+ ResponsiveModalFooter: { template: '' },
+ ResponsiveModalHeader: { template: '' },
+ ResponsiveModalTitle: { template: '
' },
+ Switch: { name: 'Switch', props: ['modelValue'], template: '' },
+ Tooltip: { template: '
' },
+ TooltipTrigger: { template: '
' },
+ TooltipContent: { template: '
' },
+ TooltipProvider: { template: '
' },
+}));
+
+vi.mock('@heroicons/vue/24/solid', () => ({
+ ArrowTopRightOnSquareIcon: { template: '' },
+ CheckCircleIcon: { template: '' },
+ CogIcon: { template: '' },
+ EyeIcon: { template: '' },
+ IdentificationIcon: { template: '' },
+ KeyIcon: { template: '' },
+}));
+
+vi.mock('@heroicons/vue/24/outline', () => ({
+ ArrowDownTrayIcon: { template: '' },
+}));
+
+vi.mock('~/components/UpdateOs/IgnoredRelease.vue', () => ({
+ default: { template: '', props: ['label'] },
+}));
+
+vi.mock('~/composables/dateTime', () => ({
+ default: () => ({
+ outputDateTimeFormatted: ref('2024-01-01'),
+ outputDateTimeReadableDiff: ref('today'),
+ }),
+}));
+
+vi.mock('pinia', async () => {
+ const actual = await vi.importActual('pinia');
+
+ const isActualStore = (candidate: unknown): candidate is Parameters[0] =>
+ Boolean(candidate && typeof candidate === 'object' && '$id' in candidate);
+
+ const isRefLike = (input: unknown): input is { value: unknown } =>
+ Boolean(input && typeof input === 'object' && 'value' in input);
+
+ return {
+ ...actual,
+ storeToRefs: (store: unknown) => {
+ if (isActualStore(store)) {
+ return actual.storeToRefs(store);
+ }
+
+ if (!store || typeof store !== 'object') {
+ return {};
+ }
+
+ const refs: Record = {};
+ for (const [key, value] of Object.entries(store)) {
+ if (isRefLike(value)) {
+ refs[key] = value;
+ }
+ }
+
+ return refs;
+ },
+ };
+});
+
+const mockAccountUpdateOs = vi.fn();
+vi.mock('~/store/account', () => ({
+ useAccountStore: () => ({
+ updateOs: mockAccountUpdateOs,
+ }),
+}));
+
+const mockRenew = vi.fn();
+vi.mock('~/store/purchase', () => ({
+ usePurchaseStore: () => ({
+ renew: mockRenew,
+ }),
+}));
+
+const mockSetReleaseForUpdate = vi.fn();
+const mockSetModalOpen = vi.fn();
+const mockFetchAndConfirmInstall = vi.fn();
+
+const available = ref(null);
+const availableWithRenewal = ref(null);
+const availableReleaseDate = ref(null);
+const availableRequiresAuth = ref(false);
+const checkForUpdatesLoading = ref(false);
+
+vi.mock('~/store/updateOs', () => ({
+ useUpdateOsStore: () => ({
+ available,
+ availableWithRenewal,
+ availableReleaseDate,
+ availableRequiresAuth,
+ checkForUpdatesLoading,
+ setReleaseForUpdate: mockSetReleaseForUpdate,
+ setModalOpen: mockSetModalOpen,
+ fetchAndConfirmInstall: mockFetchAndConfirmInstall,
+ }),
+}));
+
+const regExp = ref(null);
+const regUpdatesExpired = ref(false);
+const dateTimeFormat = ref('YYYY-MM-DD');
+const osVersion = ref(null);
+const updateOsIgnoredReleases = ref([]);
+const updateOsNotificationsEnabled = ref(true);
+const updateOsResponse = ref<{ changelog?: string | null } | null>(null);
+
+const mockUpdateOsIgnoreRelease = vi.fn();
+
+vi.mock('~/store/server', () => ({
+ useServerStore: () => ({
+ regExp,
+ regUpdatesExpired,
+ dateTimeFormat,
+ osVersion,
+ updateOsIgnoredReleases,
+ updateOsNotificationsEnabled,
+ updateOsResponse,
+ updateOsIgnoreRelease: mockUpdateOsIgnoreRelease,
+ }),
+}));
+
+const mountModal = () =>
+ mount(CheckUpdateResponseModal, {
+ props: {
+ open: true,
+ t: translate,
+ },
+ });
+
+describe('CheckUpdateResponseModal', () => {
+ beforeEach(() => {
+ available.value = null;
+ availableWithRenewal.value = null;
+ availableReleaseDate.value = null;
+ availableRequiresAuth.value = false;
+ checkForUpdatesLoading.value = false;
+ regExp.value = null;
+ regUpdatesExpired.value = false;
+ osVersion.value = null;
+ updateOsIgnoredReleases.value = [];
+ updateOsNotificationsEnabled.value = true;
+ updateOsResponse.value = null;
+
+ mockAccountUpdateOs.mockClear();
+ mockRenew.mockClear();
+ mockSetModalOpen.mockClear();
+ mockSetReleaseForUpdate.mockClear();
+ mockFetchAndConfirmInstall.mockClear();
+ mockUpdateOsIgnoreRelease.mockClear();
+ });
+
+ it('renders loading state while checking for updates', () => {
+ checkForUpdatesLoading.value = true;
+
+ const wrapper = mountModal();
+
+ expect(wrapper.find('.responsive-modal-title').text()).toBe('Checking for OS updates...');
+ expect(wrapper.find('.brand-loading').exists()).toBe(true);
+ expect(wrapper.find('.ui-button').text()).toBe('More Options');
+ });
+
+ it('shows up-to-date messaging when no updates are available', async () => {
+ osVersion.value = '6.12.3';
+ updateOsNotificationsEnabled.value = false;
+
+ const wrapper = mountModal();
+ await nextTick();
+
+ expect(wrapper.find('.responsive-modal-title').text()).toBe('Unraid OS is up-to-date');
+ expect(wrapper.text()).toContain('Current Version 6.12.3');
+ expect(wrapper.text()).toContain(
+ 'Go to Settings > Notifications to enable automatic OS update notifications for future releases.'
+ );
+
+ expect(wrapper.find('.ui-button').text()).toBe('More Options');
+ expect(wrapper.text()).toContain('Enable update notifications');
+ });
+
+ it('displays update actions when a new release is available', async () => {
+ available.value = '6.13.0';
+ osVersion.value = '6.12.3';
+ updateOsResponse.value = { changelog: '### New release' };
+
+ const wrapper = mountModal();
+ await nextTick();
+
+ const actionButtons = wrapper.findAll('.brand-button');
+ const viewChangelogButton = actionButtons.find((button) =>
+ button.text().includes('View Changelog to Start Update')
+ );
+ expect(viewChangelogButton).toBeDefined();
+
+ await viewChangelogButton!.trigger('click');
+ expect(mockSetReleaseForUpdate).toHaveBeenCalledWith({ changelog: '### New release' });
+ });
+
+ it('includes renew option when update requires license renewal', async () => {
+ available.value = '6.14.0';
+ availableWithRenewal.value = '6.14.0';
+ updateOsResponse.value = { changelog: '### Renewal release' };
+
+ const wrapper = mountModal();
+ await nextTick();
+
+ const actionButtons = wrapper.findAll('.brand-button');
+ const labels = actionButtons.map((button) => button.text());
+ expect(labels).toContain('View Changelog');
+ expect(labels).toContain('Extend License');
+
+ await actionButtons.find((btn) => btn.text() === 'Extend License')?.trigger('click');
+ expect(mockRenew).toHaveBeenCalled();
+ });
+});
diff --git a/web/__test__/components/ColorSwitcher.test.ts b/web/__test__/components/ColorSwitcher.test.ts
index bb17a4770..0d010e00c 100644
--- a/web/__test__/components/ColorSwitcher.test.ts
+++ b/web/__test__/components/ColorSwitcher.test.ts
@@ -2,7 +2,7 @@
* ColorSwitcher Component Test Coverage
*/
-import { nextTick } from 'vue';
+import { nextTick, ref } from 'vue';
import { setActivePinia } from 'pinia';
import { mount } from '@vue/test-utils';
@@ -15,6 +15,15 @@ import type { MockInstance } from 'vitest';
import ColorSwitcher from '~/components/ColorSwitcher.standalone.vue';
import { useThemeStore } from '~/store/theme';
+vi.mock('@vue/apollo-composable', () => ({
+ useQuery: () => ({
+ result: ref(null),
+ loading: ref(false),
+ onResult: vi.fn(),
+ onError: vi.fn(),
+ }),
+}));
+
// Explicitly mock @unraid/ui to ensure we use the actual components
vi.mock('@unraid/ui', async (importOriginal) => {
const actual = (await importOriginal()) as Record;
diff --git a/web/__test__/components/HeaderOsVersion.test.ts b/web/__test__/components/HeaderOsVersion.test.ts
index 62537d33b..530a697c4 100644
--- a/web/__test__/components/HeaderOsVersion.test.ts
+++ b/web/__test__/components/HeaderOsVersion.test.ts
@@ -27,6 +27,8 @@ vi.mock('@vue/apollo-composable', () => ({
useQuery: () => ({
result: { value: {} },
loading: { value: false },
+ onResult: vi.fn(),
+ onError: vi.fn(),
}),
useLazyQuery: () => ({
result: { value: {} },
diff --git a/web/__test__/components/Registration.test.ts b/web/__test__/components/Registration.test.ts
index 4ab7c7b66..5d9d47b2f 100644
--- a/web/__test__/components/Registration.test.ts
+++ b/web/__test__/components/Registration.test.ts
@@ -30,6 +30,8 @@ vi.mock('@vue/apollo-composable', () => ({
useQuery: () => ({
result: { value: {} },
loading: { value: false },
+ onResult: vi.fn(),
+ onError: vi.fn(),
}),
useLazyQuery: () => ({
result: { value: {} },
diff --git a/web/__test__/store/theme.test.ts b/web/__test__/store/theme.test.ts
index abc7c7d1f..7e15402fa 100644
--- a/web/__test__/store/theme.test.ts
+++ b/web/__test__/store/theme.test.ts
@@ -2,7 +2,7 @@
* Theme store test coverage
*/
-import { nextTick } from 'vue';
+import { nextTick, ref } from 'vue';
import { createPinia, setActivePinia } from 'pinia';
import { defaultColors } from '~/themes/default';
@@ -11,6 +11,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useThemeStore } from '~/store/theme';
+vi.mock('@vue/apollo-composable', () => ({
+ useQuery: () => ({
+ result: ref(null),
+ loading: ref(false),
+ onResult: vi.fn(),
+ onError: vi.fn(),
+ }),
+}));
+
vi.mock('hex-to-rgba', () => ({
default: vi.fn((hex, opacity) => `rgba(mock-${hex}-${opacity})`),
}));
diff --git a/web/__test__/store/unraidApi.test.ts b/web/__test__/store/unraidApi.test.ts
index 19a711cf2..b2cbd6e45 100644
--- a/web/__test__/store/unraidApi.test.ts
+++ b/web/__test__/store/unraidApi.test.ts
@@ -126,12 +126,18 @@ describe('UnraidApi Store', () => {
store.unraidApiStatus = 'offline';
await nextTick();
+ expect(mockErrorsStore.removeErrorByRef).toHaveBeenCalledWith('unraidApiOffline');
expect(mockErrorsStore.setError).toHaveBeenCalledWith({
heading: 'Warning: API is offline!',
message: 'The Unraid API is currently offline.',
ref: 'unraidApiOffline',
level: 'warning',
type: 'unraidApiState',
+ actions: [
+ expect.objectContaining({
+ text: 'Restart unraid-api',
+ }),
+ ],
});
});
@@ -211,6 +217,28 @@ describe('UnraidApi Store', () => {
expect(store.unraidApiStatus).toBe('restarting');
});
+ it('should reuse existing restart promise when restart is already running', async () => {
+ const { WebguiUnraidApiCommand } = await import('~/composables/services/webgui');
+ const mockWebguiCommand = vi.mocked(WebguiUnraidApiCommand);
+
+ let resolveCommand: (() => void) | undefined;
+ const commandPromise = new Promise((resolve) => {
+ resolveCommand = resolve;
+ });
+
+ mockWebguiCommand.mockReturnValueOnce(commandPromise);
+
+ store.unraidApiStatus = 'online';
+
+ const firstCallPromise = store.restartUnraidApiClient();
+ const secondCallPromise = store.restartUnraidApiClient();
+
+ expect(mockWebguiCommand).toHaveBeenCalledTimes(1);
+
+ resolveCommand?.();
+ await Promise.all([firstCallPromise, secondCallPromise]);
+ });
+
it('should handle error during restart', async () => {
const { WebguiUnraidApiCommand } = await import('~/composables/services/webgui');
const mockWebguiCommand = vi.mocked(WebguiUnraidApiCommand);
diff --git a/web/postcss/scopeTailwindToUnapi.ts b/web/postcss/scopeTailwindToUnapi.ts
new file mode 100644
index 000000000..d6f75695e
--- /dev/null
+++ b/web/postcss/scopeTailwindToUnapi.ts
@@ -0,0 +1,165 @@
+interface Container {
+ type: string;
+ parent?: Container;
+}
+
+interface Rule extends Container {
+ selector?: string;
+ selectors?: string[];
+}
+
+interface AtRule extends Container {
+ name: string;
+ params: string;
+}
+
+type PostcssPlugin = {
+ postcssPlugin: string;
+ Rule?(rule: Rule): void;
+};
+
+type PluginCreator = {
+ (opts?: T): PostcssPlugin;
+ postcss?: boolean;
+};
+
+export interface ScopeOptions {
+ scope?: string;
+ layers?: string[];
+ includeRoot?: boolean;
+}
+
+const DEFAULT_SCOPE = '.unapi';
+const DEFAULT_LAYERS = ['*'];
+const DEFAULT_INCLUDE_ROOT = true;
+
+const KEYFRAME_AT_RULES = new Set(['keyframes']);
+const NON_SCOPED_AT_RULES = new Set(['font-face', 'page']);
+const MERGE_WITH_SCOPE_PATTERNS: RegExp[] = [/^\.theme-/, /^\.has-custom-/, /^\.dark\b/];
+
+function shouldScopeRule(rule: Rule, targetLayers: Set, includeRootRules: boolean): boolean {
+ const hasSelectorString = typeof rule.selector === 'string' && rule.selector.length > 0;
+ const hasSelectorArray = Array.isArray(rule.selectors) && rule.selectors.length > 0;
+
+ // Skip rules without selectors (e.g. @font-face) or nested keyframe steps
+ if (!hasSelectorString && !hasSelectorArray) {
+ return false;
+ }
+
+ const directParent = rule.parent;
+ if (directParent?.type === 'atrule') {
+ const parentAtRule = directParent as AtRule;
+ const parentAtRuleName = parentAtRule.name.toLowerCase();
+ if (KEYFRAME_AT_RULES.has(parentAtRuleName) || parentAtRuleName.endsWith('keyframes')) {
+ return false;
+ }
+ if (NON_SCOPED_AT_RULES.has(parentAtRuleName)) {
+ return false;
+ }
+ }
+
+ const includeAllLayers = targetLayers.has('*');
+
+ // Traverse ancestors to find the enclosing @layer declaration
+ let current: Container | undefined = rule.parent ?? undefined;
+
+ while (current) {
+ if (current.type === 'atrule') {
+ const currentAtRule = current as AtRule;
+ if (currentAtRule.name === 'layer') {
+ const layerNames = currentAtRule.params
+ .split(',')
+ .map((name: string) => name.trim())
+ .filter(Boolean);
+ if (includeAllLayers) {
+ return true;
+ }
+ return layerNames.some((name) => targetLayers.has(name));
+ }
+ }
+ current = current.parent ?? undefined;
+ }
+
+ // If the rule is not inside any @layer, treat it as root-level CSS
+ return includeRootRules;
+}
+
+function hasScope(selector: string, scope: string): boolean {
+ return selector.includes(scope);
+}
+
+function prefixSelector(selector: string, scope: string): string {
+ const trimmed = selector.trim();
+
+ if (!trimmed) {
+ return selector;
+ }
+
+ if (hasScope(trimmed, scope)) {
+ return trimmed;
+ }
+
+ // Do not prefix :host selectors – they are only valid at the top level
+ if (trimmed.startsWith(':host')) {
+ return trimmed;
+ }
+
+ if (trimmed === ':root') {
+ return scope;
+ }
+
+ if (trimmed.startsWith(':root')) {
+ return `${scope}${trimmed.slice(':root'.length)}`;
+ }
+
+ const firstToken = trimmed.split(/[\s>+~]/, 1)[0] ?? '';
+ const shouldMergeWithScope =
+ !firstToken.includes('\\:') && MERGE_WITH_SCOPE_PATTERNS.some((pattern) => pattern.test(firstToken));
+
+ if (shouldMergeWithScope) {
+ return `${scope}${trimmed}`;
+ }
+
+ return `${scope} ${trimmed}`;
+}
+
+export const scopeTailwindToUnapi: PluginCreator = (options: ScopeOptions = {}) => {
+ const scope = options.scope ?? DEFAULT_SCOPE;
+ const layers = options.layers ?? DEFAULT_LAYERS;
+ const includeRootRules = options.includeRoot ?? DEFAULT_INCLUDE_ROOT;
+ const targetLayers = new Set(layers);
+
+ return {
+ postcssPlugin: 'scope-tailwind-to-unapi',
+ Rule(rule: Rule) {
+ if (!shouldScopeRule(rule, targetLayers, includeRootRules)) {
+ return;
+ }
+
+ const hasSelectorArray = Array.isArray(rule.selectors);
+ let selectors: string[] = [];
+
+ if (hasSelectorArray && rule.selectors) {
+ selectors = rule.selectors;
+ } else if (rule.selector) {
+ selectors = [rule.selector];
+ }
+
+ if (!selectors.length) {
+ return;
+ }
+
+ const scopedSelectors = selectors.map((selector: string) => prefixSelector(selector, scope));
+
+ if (hasSelectorArray) {
+ rule.selectors = scopedSelectors;
+ } else {
+ rule.selector = scopedSelectors.join(', ');
+ }
+ },
+ };
+};
+
+scopeTailwindToUnapi.postcss = true;
+
+export default scopeTailwindToUnapi;
diff --git a/web/src/__tests__/scopeTailwindToUnapi.spec.ts b/web/src/__tests__/scopeTailwindToUnapi.spec.ts
new file mode 100644
index 000000000..b7d16a398
--- /dev/null
+++ b/web/src/__tests__/scopeTailwindToUnapi.spec.ts
@@ -0,0 +1,86 @@
+import { performance } from 'node:perf_hooks';
+
+import { describe, expect, it } from 'vitest';
+
+import scopeTailwindToUnapi from '../../postcss/scopeTailwindToUnapi';
+
+type LayerAtRule = {
+ type: string;
+ name: string;
+ params: string;
+ parent?: LayerAtRule;
+};
+
+type MutableRule = {
+ type: string;
+ selector?: string;
+ selectors?: string[];
+ parent?: LayerAtRule;
+};
+
+function createRule(selectors: string[], layer = 'utilities'): MutableRule {
+ return {
+ type: 'rule',
+ selector: selectors.join(', '),
+ selectors: [...selectors],
+ parent: {
+ type: 'atrule',
+ name: 'layer',
+ params: layer,
+ },
+ };
+}
+
+describe('scopeTailwindToUnapi plugin', () => {
+ it('prefixes simple selectors with .unapi scope', () => {
+ const plugin = scopeTailwindToUnapi();
+ const rule = createRule(['.btn-primary']);
+
+ plugin.Rule?.(rule);
+
+ expect(rule.selectors).toEqual(['.unapi .btn-primary']);
+ });
+
+ it('merges variant class selectors into the scope', () => {
+ const plugin = scopeTailwindToUnapi();
+ const rule = createRule(['.dark .btn-secondary']);
+
+ plugin.Rule?.(rule);
+
+ expect(rule.selectors).toEqual(['.unapi.dark .btn-secondary']);
+ });
+
+ it('handles rules expressed with selector strings only', () => {
+ const plugin = scopeTailwindToUnapi();
+ const rule: MutableRule = {
+ type: 'rule',
+ selector: '.card',
+ parent: {
+ type: 'atrule',
+ name: 'layer',
+ params: 'components',
+ },
+ };
+
+ plugin.Rule?.(rule);
+
+ expect(rule.selector).toBe('.unapi .card');
+ });
+
+ it('processes large rule sets within the target budget', () => {
+ const plugin = scopeTailwindToUnapi();
+ const totalRules = 10_000;
+
+ const start = performance.now();
+
+ for (let index = 0; index < totalRules; index += 1) {
+ const rule = createRule([`.test-${index}`]);
+ plugin.Rule?.(rule);
+ }
+
+ const durationMs = performance.now() - start;
+
+ // Ensure we stay well under 1 second even on slower CI hosts.
+ expect(durationMs).toBeLessThan(1_000);
+ });
+});
diff --git a/web/src/components/Logs/SingleLogViewer.vue b/web/src/components/Logs/SingleLogViewer.vue
index d6427d5ce..7e4817c63 100644
--- a/web/src/components/Logs/SingleLogViewer.vue
+++ b/web/src/components/Logs/SingleLogViewer.vue
@@ -325,7 +325,7 @@ defineExpose({ refreshLogContent });
-
+
@@ -412,9 +412,8 @@ defineExpose({ refreshLogContent });