From e7f99e7efc49130ccbfc25c1f92206f4f5cd990d Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Wed, 8 Jul 2026 23:52:31 -0400 Subject: [PATCH 01/25] feat(web): consolidated single-component header for Unraid 7.3+ Introduce (Header.standalone.vue) which owns the entire header in one web component: Unraid logo, OS/API version dropdown and reboot/update banner, server name, server status, notifications bell, and the account dropdown/avatar. The header was previously split across and , with the user-profile cluster absolutely positioned over the logo/version area. On narrow screens the reboot banner, server title, and notification bell overlapped. The consolidated component lays everything out in a single mobile-first flex flow (stacked on mobile, two clusters justified apart on sm+), removing the overlap. Logo + version + reboot banner logic is extracted into a shared Header/HeaderVersion.vue used by both the new header and the legacy HeaderOsVersion.standalone.vue, so there is a single source of truth. The legacy multi-component header stays intact as the < 7.3 fallback. The consolidated header intentionally does not mount the deprecated CriticalNotifications popover (removed by the persistent-notifications work, #2033); persistent alerts now live in the bell. Gating lives in webgui Header.php via version_compare (separate change). --- web/__test__/components/Header.test.ts | 223 +++++++++++++++ .../Wrapper/component-registry.test.ts | 8 +- web/components.d.ts | 2 + web/src/components/Header.standalone.vue | 117 ++++++++ web/src/components/Header/HeaderVersion.vue | 264 ++++++++++++++++++ web/src/components/Header/useServerProp.ts | 54 ++++ .../components/HeaderOsVersion.standalone.vue | 261 +---------------- .../components/Wrapper/component-registry.ts | 8 + 8 files changed, 675 insertions(+), 262 deletions(-) create mode 100644 web/__test__/components/Header.test.ts create mode 100644 web/src/components/Header.standalone.vue create mode 100644 web/src/components/Header/HeaderVersion.vue create mode 100644 web/src/components/Header/useServerProp.ts diff --git a/web/__test__/components/Header.test.ts b/web/__test__/components/Header.test.ts new file mode 100644 index 0000000000..75a90d7c3c --- /dev/null +++ b/web/__test__/components/Header.test.ts @@ -0,0 +1,223 @@ +import { ref } from 'vue'; +import { setActivePinia } from 'pinia'; +import { provideApolloClient } from '@vue/apollo-composable'; +import { mount } from '@vue/test-utils'; + +import { ApolloClient, InMemoryCache } from '@apollo/client/core'; +import { createTestingPinia } from '@pinia/testing'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { VueWrapper } from '@vue/test-utils'; +import type { Server, ServerconnectPluginInstalled, ServerState } from '~/types/server'; +import type { Pinia } from 'pinia'; + +import Header from '~/components/Header.standalone.vue'; +import { useServerStore } from '~/store/server'; +import { useThemeStore } from '~/store/theme'; + +const mockCopy = vi.fn(); +const mockCopied = ref(false); +const mockIsSupported = ref(true); + +vi.mock('@vueuse/core', () => ({ + useClipboard: () => { + const actualCopy = (text: string) => { + if (mockIsSupported.value) { + mockCopy(text); + mockCopied.value = true; + } else { + mockCopied.value = false; + } + }; + return { + copy: actualCopy, + copied: mockCopied, + isSupported: mockIsSupported, + }; + }, + useLocalStorage: (key: string, initialValue: T) => { + const storage = new Map(); + if (!storage.has(key)) { + storage.set(key, initialValue); + } + return ref(storage.get(key) ?? initialValue); + }, +})); + +vi.mock('@unraid/ui', () => ({ + DropdownMenu: { + template: '
', + }, + Button: { + template: '', + props: ['variant', 'size'], + }, + cn: (...classes: string[]) => classes.filter(Boolean).join(' '), + isDarkModeActive: vi.fn(() => false), +})); + +const mockWatcher = vi.fn(); + +vi.mock('~/store/callbackActions', () => ({ + useCallbackActionsStore: vi.fn(() => ({ + watcher: mockWatcher, + callbackData: ref(null), + })), +})); + +const t = (key: string, args?: unknown[]) => (args ? `${key} ${JSON.stringify(args)}` : key); +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ t }), +})); + +const initialServerData: Server = { + name: 'DEVGEN', + description: 'Dev Server', + guid: 'TEST-GUID', + keyfile: 'keyfile.key', + lanIp: '192.168.1.100', + connectPluginInstalled: 'dynamix.unraid.net.plg' as ServerconnectPluginInstalled, + state: 'PRO' as ServerState, + dateTimeFormat: { date: 'YYYY-MM-DD', time: 'HH:mm' }, + deviceCount: 5, + flashProduct: 'TestFlash', + flashVendor: 'TestVendor', + regGuid: 'REG-GUID', + regTm: 1678886400, + regTo: 'Test User', + regTy: 'Pro', + regExp: undefined, + regUpdatesExpired: false, + registered: true, + wanIp: '8.8.8.8', +}; + +// Stub the heavier child components; the consolidated header is responsible for +// layout composition, which is what these tests exercise. +const stubs = { + HeaderVersion: { template: '
' }, + UpcServerStatus: { + template: '
', + props: ['class'], + }, + NotificationsSidebar: { template: '
' }, + DropdownMenu: { + template: '
', + }, + UpcDropdownContent: { template: '
' }, + UpcDropdownTrigger: { template: '' }, +}; + +describe('Header.standalone.vue', () => { + let wrapper: VueWrapper>; + let pinia: Pinia; + let serverStore: ReturnType; + let themeStore: ReturnType; + let consoleSpies: Array> = []; + + beforeEach(() => { + Object.defineProperty(window, 'location', { + value: { + hostname: 'localhost', + port: '3000', + pathname: '/', + protocol: 'http:', + href: 'http://localhost:3000/', + }, + writable: true, + configurable: true, + }); + + provideApolloClient(new ApolloClient({ cache: new InMemoryCache() })); + + consoleSpies = [ + vi.spyOn(console, 'log').mockImplementation(() => {}), + vi.spyOn(console, 'warn').mockImplementation(() => {}), + vi.spyOn(console, 'debug').mockImplementation(() => {}), + vi.spyOn(console, 'error').mockImplementation(() => {}), + ]; + + mockCopied.value = false; + mockIsSupported.value = true; + + pinia = createTestingPinia({ + createSpy: vi.fn, + initialState: { server: { ...initialServerData } }, + stubActions: false, + }); + setActivePinia(pinia); + + serverStore = useServerStore(); + themeStore = useThemeStore(); + themeStore.setTheme({ + name: 'white', + banner: true, + bannerGradient: true, + descriptionShow: true, + textColor: '', + metaColor: '', + bgColor: '', + }); + + vi.spyOn(serverStore, 'setServer').mockImplementation((server) => { + Object.assign(serverStore, server); + return server; + }); + + vi.clearAllMocks(); + + wrapper = mount(Header, { + props: { server: JSON.stringify(initialServerData) }, + global: { plugins: [pinia], stubs }, + }); + }); + + afterEach(() => { + wrapper?.unmount(); + consoleSpies.forEach((spy) => spy.mockRestore()); + vi.restoreAllMocks(); + }); + + it('renders every header region in a single component', () => { + expect(wrapper.find('[data-testid="header-version"]').exists()).toBe(true); + expect(wrapper.find('[data-testid="server-status"]').exists()).toBe(true); + expect(wrapper.find('[data-testid="notifications-sidebar"]').exists()).toBe(true); + expect(wrapper.find('[data-testid="dropdown-menu"]').exists()).toBe(true); + expect(wrapper.find('[data-testid="dropdown-trigger"]').exists()).toBe(true); + expect(wrapper.find('button').text()).toContain('DEVGEN'); + }); + + it('hydrates the server store from the prop and starts the callback watcher', () => { + expect(serverStore.setServer).toHaveBeenCalledTimes(1); + expect(serverStore.setServer).toHaveBeenCalledWith(JSON.parse(JSON.stringify(initialServerData))); + expect(mockWatcher).toHaveBeenCalledTimes(1); + }); + + it('throws when the server prop is missing', () => { + expect(() => mount(Header, { props: {}, global: { plugins: [pinia], stubs } })).toThrow( + 'Server data not present' + ); + }); + + it('lays out as a flow container that stacks on mobile and rows on desktop (no overlap)', () => { + const root = wrapper.get('#UnraidHeader'); + const cls = root.classes(); + // Mobile-first: vertical stack so the reboot banner, title, and bell never collide. + expect(cls).toContain('flex'); + expect(cls).toContain('flex-col'); + // Desktop: switch to a row with the two clusters pushed apart. + expect(cls).toContain('sm:flex-row'); + expect(cls).toContain('sm:justify-between'); + // The consolidated header must not absolutely-position a cluster over the + // logo/version area (the root cause of the old mobile overlap). + expect(cls).not.toContain('absolute'); + expect(root.find('.absolute:not(.unraid-banner-gradient-layer)').exists()).toBe(false); + }); + + it('copies the LAN IP when the server name is clicked', async () => { + mockIsSupported.value = true; + await wrapper.find('button').trigger('click'); + await wrapper.vm.$nextTick(); + expect(mockCopy).toHaveBeenCalledWith(initialServerData.lanIp); + }); +}); diff --git a/web/__test__/components/Wrapper/component-registry.test.ts b/web/__test__/components/Wrapper/component-registry.test.ts index 76ddf56675..5fc675df91 100644 --- a/web/__test__/components/Wrapper/component-registry.test.ts +++ b/web/__test__/components/Wrapper/component-registry.test.ts @@ -74,9 +74,11 @@ describe('component-registry', () => { it('should have priority components listed first', async () => { const { componentMappings } = await import('~/components/Wrapper/component-registry'); - // Priority components should be first - expect(componentMappings[0].appId).toBe('header-os-version'); - expect(componentMappings[1].appId).toBe('user-profile'); + // Priority components should be first: the consolidated header (7.3+) leads, + // followed by the legacy multi-component header used as the < 7.3 fallback. + expect(componentMappings[0].appId).toBe('header'); + expect(componentMappings[1].appId).toBe('header-os-version'); + expect(componentMappings[2].appId).toBe('user-profile'); }); it('should support multiple selectors for modals', async () => { diff --git a/web/components.d.ts b/web/components.d.ts index 8ba941e38e..cc30a4c98c 100644 --- a/web/components.d.ts +++ b/web/components.d.ts @@ -74,8 +74,10 @@ declare module 'vue' { EffectivePermissions: typeof import('./src/components/ApiKey/EffectivePermissions.vue')['default'] FileViewer: typeof import('./src/components/FileViewer.vue')['default'] FilteredLogModal: typeof import('./src/components/Logs/FilteredLogModal.vue')['default'] + 'Header.standalone': typeof import('./src/components/Header.standalone.vue')['default'] HeaderContent: typeof import('./src/components/Docker/HeaderContent.vue')['default'] 'HeaderOsVersion.standalone': typeof import('./src/components/HeaderOsVersion.standalone.vue')['default'] + HeaderVersion: typeof import('./src/components/Header/HeaderVersion.vue')['default'] IgnoredRelease: typeof import('./src/components/UpdateOs/IgnoredRelease.vue')['default'] Indicator: typeof import('./src/components/Notifications/Indicator.vue')['default'] InternalBootConfirmDialog: typeof import('./src/components/Onboarding/components/InternalBootConfirmDialog.vue')['default'] diff --git a/web/src/components/Header.standalone.vue b/web/src/components/Header.standalone.vue new file mode 100644 index 0000000000..e9e59a744e --- /dev/null +++ b/web/src/components/Header.standalone.vue @@ -0,0 +1,117 @@ + + + diff --git a/web/src/components/Header/HeaderVersion.vue b/web/src/components/Header/HeaderVersion.vue new file mode 100644 index 0000000000..843af6960c --- /dev/null +++ b/web/src/components/Header/HeaderVersion.vue @@ -0,0 +1,264 @@ + + + diff --git a/web/src/components/Header/useServerProp.ts b/web/src/components/Header/useServerProp.ts new file mode 100644 index 0000000000..fd3d0ac95e --- /dev/null +++ b/web/src/components/Header/useServerProp.ts @@ -0,0 +1,54 @@ +import { onBeforeMount, onMounted } from 'vue'; + +import { devConfig } from '~/helpers/env'; + +import type { Server } from '~/types/server'; + +import { useCallbackActionsStore } from '~/store/callbackActions'; +import { useServerStore } from '~/store/server'; + +/** + * Hydrates the server store from a `server` prop (JSON string when mounted as a + * web component, or an object in dev/testing) and wires up the callback watcher. + * + * Shared by the standalone header entry points so the consolidated header and the + * legacy `UserProfile` header behave identically when receiving server state. + */ +export function useServerProp(getServer: () => Server | string | undefined) { + const callbackStore = useCallbackActionsStore(); + const serverStore = useServerStore(); + + onBeforeMount(() => { + const server = getServer(); + if (!server) { + throw new Error('Server data not present'); + } + + if (typeof server === 'object') { + serverStore.setServer(server); + } else if (typeof server === 'string') { + serverStore.setServer(JSON.parse(server)); + } + + // look for any callback params + callbackStore.watcher(); + + if (serverStore.guid && serverStore.keyfile) { + if (callbackStore.callbackData) { + console.debug( + 'Renew callback detected, skipping auto check for key replacement, renewal eligibility, and OS Update.' + ); + } + } else { + console.warn( + 'A valid keyfile and USB Flash boot device are required to check for key renewals, key replacement eligibiliy, and OS update availability.' + ); + } + }); + + onMounted(() => { + if (devConfig.VITE_MOCK_USER_SESSION && devConfig.NODE_ENV === 'development') { + document.cookie = 'unraid_session_cookie=mockusersession'; + } + }); +} diff --git a/web/src/components/HeaderOsVersion.standalone.vue b/web/src/components/HeaderOsVersion.standalone.vue index fa2bf7ad4c..5e4cfbf65e 100644 --- a/web/src/components/HeaderOsVersion.standalone.vue +++ b/web/src/components/HeaderOsVersion.standalone.vue @@ -1,266 +1,9 @@ diff --git a/web/src/components/Wrapper/component-registry.ts b/web/src/components/Wrapper/component-registry.ts index 64ceb802b2..0808b51fd2 100644 --- a/web/src/components/Wrapper/component-registry.ts +++ b/web/src/components/Wrapper/component-registry.ts @@ -22,6 +22,14 @@ export type ComponentMapping = { // Define component mappings - all components use async loading for consistency // Priority components (header, user profile) are listed first for faster mounting export const componentMappings: ComponentMapping[] = [ + { + // Consolidated header (Unraid 7.3+). Owns the entire header in one component; + // the legacy `unraid-header-os-version` + `unraid-user-profile` pair below is + // the fallback for Unraid < 7.3. + component: defineAsyncComponent(() => import('@/components/Header.standalone.vue')), + selector: 'unraid-header', + appId: 'header', + }, { component: defineAsyncComponent(() => import('@/components/HeaderOsVersion.standalone.vue')), selector: 'unraid-header-os-version', From e9f8439c0cae9895d327921510418cf2701e334b Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Thu, 9 Jul 2026 00:15:52 -0400 Subject: [PATCH 02/25] feat(web): array-usage bar in consolidated header Replace the legacy webgui my_usage() widget (jQuery-injected into #array-usage-sidenav for sidebar themes) with a self-contained ArrayUsage.vue inside , driven by the GraphQL array.capacity field. Shown when webgui passes show-array-usage (sidebar theme + usage display enabled), matching where the legacy widget appeared. Color thresholds follow the legacy usage_color() defaults (70% warning / 90% critical). --- web/__test__/components/Header.test.ts | 12 ++++ web/components.d.ts | 1 + web/src/components/Header.standalone.vue | 16 ++++- web/src/components/Header/ArrayUsage.vue | 69 +++++++++++++++++++ .../components/Header/arrayCapacity.query.ts | 17 +++++ web/src/composables/gql/gql.ts | 6 ++ web/src/composables/gql/graphql.ts | 6 ++ web/src/locales/en.json | 3 + 8 files changed, 129 insertions(+), 1 deletion(-) create mode 100644 web/src/components/Header/ArrayUsage.vue create mode 100644 web/src/components/Header/arrayCapacity.query.ts diff --git a/web/__test__/components/Header.test.ts b/web/__test__/components/Header.test.ts index 75a90d7c3c..6b10b118cc 100644 --- a/web/__test__/components/Header.test.ts +++ b/web/__test__/components/Header.test.ts @@ -96,6 +96,7 @@ const initialServerData: Server = { // layout composition, which is what these tests exercise. const stubs = { HeaderVersion: { template: '
' }, + ArrayUsage: { template: '
' }, UpcServerStatus: { template: '
', props: ['class'], @@ -214,6 +215,17 @@ describe('Header.standalone.vue', () => { expect(root.find('.absolute:not(.unraid-banner-gradient-layer)').exists()).toBe(false); }); + it('hides the array-usage bar by default and shows it when enabled', async () => { + expect(wrapper.find('[data-testid="array-usage"]').exists()).toBe(false); + + const withUsage = mount(Header, { + props: { server: JSON.stringify(initialServerData), showArrayUsage: 'true' }, + global: { plugins: [pinia], stubs }, + }); + expect(withUsage.find('[data-testid="array-usage"]').exists()).toBe(true); + withUsage.unmount(); + }); + it('copies the LAN IP when the server name is clicked', async () => { mockIsSupported.value = true; await wrapper.find('button').trigger('click'); diff --git a/web/components.d.ts b/web/components.d.ts index cc30a4c98c..22b790af05 100644 --- a/web/components.d.ts +++ b/web/components.d.ts @@ -15,6 +15,7 @@ declare module 'vue' { 'ApiKeyPage.standalone': typeof import('./src/components/ApiKeyPage.standalone.vue')['default'] ApiStatus: typeof import('./src/components/ApiStatus/ApiStatus.vue')['default'] 'ApiStatus.standalone': typeof import('./src/components/ApiStatus/ApiStatus.standalone.vue')['default'] + ArrayUsage: typeof import('./src/components/Header/ArrayUsage.vue')['default'] 'Auth.standalone': typeof import('./src/components/Auth.standalone.vue')['default'] Avatar: typeof import('./src/components/Brand/Avatar.vue')['default'] BaseLogViewer: typeof import('./src/components/Logs/BaseLogViewer.vue')['default'] diff --git a/web/src/components/Header.standalone.vue b/web/src/components/Header.standalone.vue index e9e59a744e..9280e43046 100644 --- a/web/src/components/Header.standalone.vue +++ b/web/src/components/Header.standalone.vue @@ -6,6 +6,7 @@ import { Button, DropdownMenu } from '@unraid/ui'; import type { Server } from '~/types/server'; +import ArrayUsage from '~/components/Header/ArrayUsage.vue'; import HeaderVersion from '~/components/Header/HeaderVersion.vue'; import { useServerProp } from '~/components/Header/useServerProp'; import NotificationsSidebar from '~/components/Notifications/Sidebar.vue'; @@ -27,6 +28,13 @@ import { useThemeStore } from '~/store/theme'; */ export interface Props { server?: Server | string; + /** + * Render the array space-usage bar. Passed from webgui `Header.php` when the + * active theme is a sidebar theme with usage display enabled, matching where the + * legacy `#array-usage-sidenav` widget used to appear. Web-component attributes + * arrive as strings, so accept both. + */ + showArrayUsage?: boolean | string; } const props = defineProps(); @@ -37,6 +45,10 @@ const themeStore = useThemeStore(); useServerProp(() => props.server); +const showArrayUsage = computed( + () => props.showArrayUsage === true || props.showArrayUsage === 'true' || props.showArrayUsage === '' +); + const name = computed(() => serverStore.name); const description = computed(() => serverStore.description); const lanIp = computed(() => serverStore.lanIp); @@ -68,8 +80,10 @@ const copyLanIp = async () => { - +
+ +
+import { computed } from 'vue'; +import { useI18n } from 'vue-i18n'; +import { useQuery } from '@vue/apollo-composable'; + +import { ARRAY_CAPACITY_QUERY } from '~/components/Header/arrayCapacity.query'; + +/** + * Array space-usage bar for the consolidated header. Replaces the legacy + * webgui `my_usage()` widget that was jQuery-injected into `#array-usage-sidenav` + * for sidebar themes. Driven by the GraphQL `array.capacity` field so it stays + * self-contained within the header web component. + */ +const { t } = useI18n(); + +// Match the legacy `usage_color()` thresholds (utilization %). The webgui allows +// per-array overrides via display settings; those are not exposed over GraphQL yet, +// so fall back to Unraid's default warning/critical utilization levels. +const WARNING_PERCENT = 70; +const CRITICAL_PERCENT = 90; + +const { result } = useQuery(ARRAY_CAPACITY_QUERY, null, { + pollInterval: 30_000, + fetchPolicy: 'cache-and-network', +}); + +const array = computed(() => result.value?.array); +const started = computed(() => array.value?.state === 'STARTED'); + +const usedPercent = computed(() => { + const total = Number(array.value?.capacity?.kilobytes?.total ?? 0); + const free = Number(array.value?.capacity?.kilobytes?.free ?? 0); + if (!total || Number.isNaN(total) || Number.isNaN(free)) return 0; + return Math.min(100, Math.max(0, 100 - Math.round((100 * free) / total))); +}); + +const barColorClass = computed(() => { + if (usedPercent.value >= CRITICAL_PERCENT) return 'bg-unraid-red'; + if (usedPercent.value >= WARNING_PERCENT) return 'bg-orange-dark'; + return 'bg-unraid-green'; +}); + +// Only render once the array field has resolved to avoid a flash of an empty bar. +const hasData = computed(() => array.value != null); + + + diff --git a/web/src/components/Header/arrayCapacity.query.ts b/web/src/components/Header/arrayCapacity.query.ts new file mode 100644 index 0000000000..3d8044ae9b --- /dev/null +++ b/web/src/components/Header/arrayCapacity.query.ts @@ -0,0 +1,17 @@ +import { graphql } from '~/composables/gql'; + +export const ARRAY_CAPACITY_QUERY = graphql(/* GraphQL */ ` + query ArrayCapacity { + array { + id + state + capacity { + kilobytes { + free + used + total + } + } + } + } +`); diff --git a/web/src/composables/gql/gql.ts b/web/src/composables/gql/gql.ts index b09e234a71..6ec86bda9a 100644 --- a/web/src/composables/gql/gql.ts +++ b/web/src/composables/gql/gql.ts @@ -48,6 +48,7 @@ type Documents = { "\n mutation UpdateDockerAutostartConfiguration(\n $entries: [DockerAutostartEntryInput!]!\n $persistUserPreferences: Boolean\n ) {\n docker {\n updateAutostartConfiguration(entries: $entries, persistUserPreferences: $persistUserPreferences)\n }\n }\n": typeof types.UpdateDockerAutostartConfigurationDocument, "\n mutation UpdateDockerContainers($ids: [PrefixedID!]!) {\n docker {\n updateContainers(ids: $ids) {\n id\n names\n state\n isUpdateAvailable\n isRebuildReady\n }\n }\n }\n": typeof types.UpdateDockerContainersDocument, "\n mutation UpdateDockerViewPreferences($viewId: String, $prefs: JSON!) {\n updateDockerViewPreferences(viewId: $viewId, prefs: $prefs) {\n version\n views {\n id\n name\n rootId\n prefs\n flatEntries {\n id\n type\n name\n parentId\n depth\n position\n path\n hasChildren\n childrenIds\n meta {\n id\n names\n state\n status\n image\n ports {\n privatePort\n publicPort\n type\n }\n autoStart\n hostConfig {\n networkMode\n }\n created\n isUpdateAvailable\n isRebuildReady\n }\n }\n }\n }\n }\n": typeof types.UpdateDockerViewPreferencesDocument, + "\n query ArrayCapacity {\n array {\n id\n state\n capacity {\n kilobytes {\n free\n used\n total\n }\n }\n }\n }\n": typeof types.ArrayCapacityDocument, "\n query LogFiles {\n logFiles {\n name\n path\n size\n modifiedAt\n }\n }\n": typeof types.LogFilesDocument, "\n query LogFileContent($path: String!, $lines: Int, $startLine: Int) {\n logFile(path: $path, lines: $lines, startLine: $startLine) {\n path\n content\n totalLines\n startLine\n }\n }\n": typeof types.LogFileContentDocument, "\n subscription LogFileSubscription($path: String!) {\n logFile(path: $path) {\n path\n content\n totalLines\n }\n }\n": typeof types.LogFileSubscriptionDocument, @@ -136,6 +137,7 @@ const documents: Documents = { "\n mutation UpdateDockerAutostartConfiguration(\n $entries: [DockerAutostartEntryInput!]!\n $persistUserPreferences: Boolean\n ) {\n docker {\n updateAutostartConfiguration(entries: $entries, persistUserPreferences: $persistUserPreferences)\n }\n }\n": types.UpdateDockerAutostartConfigurationDocument, "\n mutation UpdateDockerContainers($ids: [PrefixedID!]!) {\n docker {\n updateContainers(ids: $ids) {\n id\n names\n state\n isUpdateAvailable\n isRebuildReady\n }\n }\n }\n": types.UpdateDockerContainersDocument, "\n mutation UpdateDockerViewPreferences($viewId: String, $prefs: JSON!) {\n updateDockerViewPreferences(viewId: $viewId, prefs: $prefs) {\n version\n views {\n id\n name\n rootId\n prefs\n flatEntries {\n id\n type\n name\n parentId\n depth\n position\n path\n hasChildren\n childrenIds\n meta {\n id\n names\n state\n status\n image\n ports {\n privatePort\n publicPort\n type\n }\n autoStart\n hostConfig {\n networkMode\n }\n created\n isUpdateAvailable\n isRebuildReady\n }\n }\n }\n }\n }\n": types.UpdateDockerViewPreferencesDocument, + "\n query ArrayCapacity {\n array {\n id\n state\n capacity {\n kilobytes {\n free\n used\n total\n }\n }\n }\n }\n": types.ArrayCapacityDocument, "\n query LogFiles {\n logFiles {\n name\n path\n size\n modifiedAt\n }\n }\n": types.LogFilesDocument, "\n query LogFileContent($path: String!, $lines: Int, $startLine: Int) {\n logFile(path: $path, lines: $lines, startLine: $startLine) {\n path\n content\n totalLines\n startLine\n }\n }\n": types.LogFileContentDocument, "\n subscription LogFileSubscription($path: String!) {\n logFile(path: $path) {\n path\n content\n totalLines\n }\n }\n": types.LogFileSubscriptionDocument, @@ -340,6 +342,10 @@ export function graphql(source: "\n mutation UpdateDockerContainers($ids: [Pref * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ export function graphql(source: "\n mutation UpdateDockerViewPreferences($viewId: String, $prefs: JSON!) {\n updateDockerViewPreferences(viewId: $viewId, prefs: $prefs) {\n version\n views {\n id\n name\n rootId\n prefs\n flatEntries {\n id\n type\n name\n parentId\n depth\n position\n path\n hasChildren\n childrenIds\n meta {\n id\n names\n state\n status\n image\n ports {\n privatePort\n publicPort\n type\n }\n autoStart\n hostConfig {\n networkMode\n }\n created\n isUpdateAvailable\n isRebuildReady\n }\n }\n }\n }\n }\n"): (typeof documents)["\n mutation UpdateDockerViewPreferences($viewId: String, $prefs: JSON!) {\n updateDockerViewPreferences(viewId: $viewId, prefs: $prefs) {\n version\n views {\n id\n name\n rootId\n prefs\n flatEntries {\n id\n type\n name\n parentId\n depth\n position\n path\n hasChildren\n childrenIds\n meta {\n id\n names\n state\n status\n image\n ports {\n privatePort\n publicPort\n type\n }\n autoStart\n hostConfig {\n networkMode\n }\n created\n isUpdateAvailable\n isRebuildReady\n }\n }\n }\n }\n }\n"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "\n query ArrayCapacity {\n array {\n id\n state\n capacity {\n kilobytes {\n free\n used\n total\n }\n }\n }\n }\n"): (typeof documents)["\n query ArrayCapacity {\n array {\n id\n state\n capacity {\n kilobytes {\n free\n used\n total\n }\n }\n }\n }\n"]; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ diff --git a/web/src/composables/gql/graphql.ts b/web/src/composables/gql/graphql.ts index 96384bef26..e9be3b367b 100644 --- a/web/src/composables/gql/graphql.ts +++ b/web/src/composables/gql/graphql.ts @@ -3845,6 +3845,11 @@ export type UpdateDockerViewPreferencesMutationVariables = Exact<{ export type UpdateDockerViewPreferencesMutation = { __typename?: 'Mutation', updateDockerViewPreferences: { __typename?: 'ResolvedOrganizerV1', version: number, views: Array<{ __typename?: 'ResolvedOrganizerView', id: string, name: string, rootId: string, prefs?: any | null, flatEntries: Array<{ __typename?: 'FlatOrganizerEntry', id: string, type: string, name: string, parentId?: string | null, depth: number, position: number, path: Array, hasChildren: boolean, childrenIds: Array, meta?: { __typename?: 'DockerContainer', id: string, names: Array, state: ContainerState, status: string, image: string, autoStart: boolean, created: number, isUpdateAvailable?: boolean | null, isRebuildReady?: boolean | null, ports: Array<{ __typename?: 'ContainerPort', privatePort?: number | null, publicPort?: number | null, type: ContainerPortType }>, hostConfig?: { __typename?: 'ContainerHostConfig', networkMode: string } | null } | null }> }> } }; +export type ArrayCapacityQueryVariables = Exact<{ [key: string]: never; }>; + + +export type ArrayCapacityQuery = { __typename?: 'Query', array: { __typename?: 'UnraidArray', id: string, state: ArrayState, capacity: { __typename?: 'ArrayCapacity', kilobytes: { __typename?: 'Capacity', free: string, used: string, total: string } } } }; + export type LogFilesQueryVariables = Exact<{ [key: string]: never; }>; @@ -4210,6 +4215,7 @@ export const UpdateAllDockerContainersDocument = {"kind":"Document","definitions export const UpdateDockerAutostartConfigurationDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateDockerAutostartConfiguration"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"entries"}},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"DockerAutostartEntryInput"}}}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"persistUserPreferences"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Boolean"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"docker"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateAutostartConfiguration"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"entries"},"value":{"kind":"Variable","name":{"kind":"Name","value":"entries"}}},{"kind":"Argument","name":{"kind":"Name","value":"persistUserPreferences"},"value":{"kind":"Variable","name":{"kind":"Name","value":"persistUserPreferences"}}}]}]}}]}}]} as unknown as DocumentNode; export const UpdateDockerContainersDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateDockerContainers"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"ids"}},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"PrefixedID"}}}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"docker"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateContainers"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"ids"},"value":{"kind":"Variable","name":{"kind":"Name","value":"ids"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"names"}},{"kind":"Field","name":{"kind":"Name","value":"state"}},{"kind":"Field","name":{"kind":"Name","value":"isUpdateAvailable"}},{"kind":"Field","name":{"kind":"Name","value":"isRebuildReady"}}]}}]}}]}}]} as unknown as DocumentNode; export const UpdateDockerViewPreferencesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateDockerViewPreferences"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"viewId"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"prefs"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"JSON"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateDockerViewPreferences"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"viewId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"viewId"}}},{"kind":"Argument","name":{"kind":"Name","value":"prefs"},"value":{"kind":"Variable","name":{"kind":"Name","value":"prefs"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"version"}},{"kind":"Field","name":{"kind":"Name","value":"views"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"rootId"}},{"kind":"Field","name":{"kind":"Name","value":"prefs"}},{"kind":"Field","name":{"kind":"Name","value":"flatEntries"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"parentId"}},{"kind":"Field","name":{"kind":"Name","value":"depth"}},{"kind":"Field","name":{"kind":"Name","value":"position"}},{"kind":"Field","name":{"kind":"Name","value":"path"}},{"kind":"Field","name":{"kind":"Name","value":"hasChildren"}},{"kind":"Field","name":{"kind":"Name","value":"childrenIds"}},{"kind":"Field","name":{"kind":"Name","value":"meta"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"names"}},{"kind":"Field","name":{"kind":"Name","value":"state"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"image"}},{"kind":"Field","name":{"kind":"Name","value":"ports"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"privatePort"}},{"kind":"Field","name":{"kind":"Name","value":"publicPort"}},{"kind":"Field","name":{"kind":"Name","value":"type"}}]}},{"kind":"Field","name":{"kind":"Name","value":"autoStart"}},{"kind":"Field","name":{"kind":"Name","value":"hostConfig"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"networkMode"}}]}},{"kind":"Field","name":{"kind":"Name","value":"created"}},{"kind":"Field","name":{"kind":"Name","value":"isUpdateAvailable"}},{"kind":"Field","name":{"kind":"Name","value":"isRebuildReady"}}]}}]}}]}}]}}]}}]} as unknown as DocumentNode; +export const ArrayCapacityDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ArrayCapacity"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"array"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"state"}},{"kind":"Field","name":{"kind":"Name","value":"capacity"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"kilobytes"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"free"}},{"kind":"Field","name":{"kind":"Name","value":"used"}},{"kind":"Field","name":{"kind":"Name","value":"total"}}]}}]}}]}}]}}]} as unknown as DocumentNode; export const LogFilesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"LogFiles"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"logFiles"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"path"}},{"kind":"Field","name":{"kind":"Name","value":"size"}},{"kind":"Field","name":{"kind":"Name","value":"modifiedAt"}}]}}]}}]} as unknown as DocumentNode; export const LogFileContentDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"LogFileContent"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"path"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"lines"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"startLine"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"logFile"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"path"},"value":{"kind":"Variable","name":{"kind":"Name","value":"path"}}},{"kind":"Argument","name":{"kind":"Name","value":"lines"},"value":{"kind":"Variable","name":{"kind":"Name","value":"lines"}}},{"kind":"Argument","name":{"kind":"Name","value":"startLine"},"value":{"kind":"Variable","name":{"kind":"Name","value":"startLine"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"path"}},{"kind":"Field","name":{"kind":"Name","value":"content"}},{"kind":"Field","name":{"kind":"Name","value":"totalLines"}},{"kind":"Field","name":{"kind":"Name","value":"startLine"}}]}}]}}]} as unknown as DocumentNode; export const LogFileSubscriptionDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"subscription","name":{"kind":"Name","value":"LogFileSubscription"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"path"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"logFile"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"path"},"value":{"kind":"Variable","name":{"kind":"Name","value":"path"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"path"}},{"kind":"Field","name":{"kind":"Name","value":"content"}},{"kind":"Field","name":{"kind":"Name","value":"totalLines"}}]}}]}}]} as unknown as DocumentNode; diff --git a/web/src/locales/en.json b/web/src/locales/en.json index 096b2a5916..0214592618 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -419,6 +419,9 @@ "connectSettings.updatedApiSettingsToast": "Updated API Settings", "downgradeOs.downgradeUnraidOs": "Downgrade Unraid OS", "downgradeOs.pleaseFinishTheInitiatedUpdateTo": "Please finish the initiated update to enable a downgrade.", + "headerArrayUsage.array": "Array", + "headerArrayUsage.offline": "Offline", + "headerArrayUsage.usedOfArray": "{0}% of array used", "headerOsVersion.apiVersionCopiedToClipboard": "API version copied to clipboard", "headerOsVersion.osVersionCopiedToClipboard": "OS version copied to clipboard", "headerOsVersion.unraidApi": "Unraid API", From 1ae725d5d30b4adaaf42acc933b0994f474dff40 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Thu, 9 Jul 2026 00:33:44 -0400 Subject: [PATCH 03/25] feat(web): app-style responsive grid layout for consolidated header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rework the consolidated header onto a CSS grid so a single account-actions block (bell + menu/avatar) repositions across the sm breakpoint without duplicating stateful components: - mobile: logo and account icons share the top row (app-style), then version + reboot banner, then array usage/status, then server name. - sm+: two columns — logo/version left, status over name+actions right (the prior desktop look). Splits the logo into Header/HeaderLogo.vue so it can sit on the account row on mobile; HeaderVersion.vue now renders only the version dropdown + reboot banner. HeaderOsVersion.standalone.vue (the < 7.3 fallback) composes both and is visually unchanged. Fixes the over-tall, left-adrift mobile header from the first pass. --- web/__test__/components/Header.test.ts | 23 +-- web/components.d.ts | 1 + web/src/components/Header.standalone.vue | 159 ++++++++++++------ web/src/components/Header/HeaderLogo.vue | 35 ++++ web/src/components/Header/HeaderVersion.vue | 31 +--- .../components/HeaderOsVersion.standalone.vue | 2 + 6 files changed, 159 insertions(+), 92 deletions(-) create mode 100644 web/src/components/Header/HeaderLogo.vue diff --git a/web/__test__/components/Header.test.ts b/web/__test__/components/Header.test.ts index 6b10b118cc..d869f2cbc5 100644 --- a/web/__test__/components/Header.test.ts +++ b/web/__test__/components/Header.test.ts @@ -200,18 +200,19 @@ describe('Header.standalone.vue', () => { ); }); - it('lays out as a flow container that stacks on mobile and rows on desktop (no overlap)', () => { + it('lays out on a responsive grid with each region in its own area (no overlap)', () => { const root = wrapper.get('#UnraidHeader'); - const cls = root.classes(); - // Mobile-first: vertical stack so the reboot banner, title, and bell never collide. - expect(cls).toContain('flex'); - expect(cls).toContain('flex-col'); - // Desktop: switch to a row with the two clusters pushed apart. - expect(cls).toContain('sm:flex-row'); - expect(cls).toContain('sm:justify-between'); - // The consolidated header must not absolutely-position a cluster over the - // logo/version area (the root cause of the old mobile overlap). - expect(cls).not.toContain('absolute'); + // A single grid owns the responsive layout (breakpoints live in scoped CSS). + expect(root.classes()).toContain('unraid-header-grid'); + // Every header region is placed in its own grid area, so nothing is absolutely + // positioned on top of anything else (the root cause of the old mobile overlap). + expect(root.find('.uh-logo').exists()).toBe(true); + expect(root.find('.uh-meta').exists()).toBe(true); + expect(root.find('.uh-status').exists()).toBe(true); + expect(root.find('.uh-name').exists()).toBe(true); + expect(root.find('.uh-actions').exists()).toBe(true); + // The only absolutely-positioned element allowed is the banner gradient layer. + expect(root.classes()).not.toContain('absolute'); expect(root.find('.absolute:not(.unraid-banner-gradient-layer)').exists()).toBe(false); }); diff --git a/web/components.d.ts b/web/components.d.ts index 22b790af05..52997a0fac 100644 --- a/web/components.d.ts +++ b/web/components.d.ts @@ -77,6 +77,7 @@ declare module 'vue' { FilteredLogModal: typeof import('./src/components/Logs/FilteredLogModal.vue')['default'] 'Header.standalone': typeof import('./src/components/Header.standalone.vue')['default'] HeaderContent: typeof import('./src/components/Docker/HeaderContent.vue')['default'] + HeaderLogo: typeof import('./src/components/Header/HeaderLogo.vue')['default'] 'HeaderOsVersion.standalone': typeof import('./src/components/HeaderOsVersion.standalone.vue')['default'] HeaderVersion: typeof import('./src/components/Header/HeaderVersion.vue')['default'] IgnoredRelease: typeof import('./src/components/UpdateOs/IgnoredRelease.vue')['default'] diff --git a/web/src/components/Header.standalone.vue b/web/src/components/Header.standalone.vue index 9280e43046..ce64948173 100644 --- a/web/src/components/Header.standalone.vue +++ b/web/src/components/Header.standalone.vue @@ -7,6 +7,7 @@ import { Button, DropdownMenu } from '@unraid/ui'; import type { Server } from '~/types/server'; import ArrayUsage from '~/components/Header/ArrayUsage.vue'; +import HeaderLogo from '~/components/Header/HeaderLogo.vue'; import HeaderVersion from '~/components/Header/HeaderVersion.vue'; import { useServerProp } from '~/components/Header/useServerProp'; import NotificationsSidebar from '~/components/Notifications/Sidebar.vue'; @@ -22,9 +23,17 @@ import { useThemeStore } from '~/store/theme'; * * Owns the entire header in a single web component so the responsive layout can be * managed in one place: the Unraid logo, OS/API version dropdown and - * reboot/update banner, the server name, server status, notifications bell, and the - * user/account dropdown. On Unraid < 7.3 the legacy multi-component header - * (`unraid-header-os-version` + `unraid-user-profile`) is rendered instead. + * reboot/update banner, the array-usage bar, the server name, server status, + * notifications bell, and the user/account dropdown. On Unraid < 7.3 the legacy + * multi-component header (`unraid-header-os-version` + `unraid-user-profile`) is + * rendered instead. + * + * Layout is a CSS grid so a single `actions` block (bell + account menu) can be + * repositioned across the breakpoint without duplicating stateful components: + * - mobile: app-style — logo + actions share the top row, then version, then + * status, then name. + * - sm+: two columns — logo/version on the left, status over name+actions on the + * right (the prior desktop look). */ export interface Props { server?: Server | string; @@ -66,66 +75,112 @@ const copyLanIp = async () => { + + diff --git a/web/src/components/Header/HeaderLogo.vue b/web/src/components/Header/HeaderLogo.vue new file mode 100644 index 0000000000..5bc033bf60 --- /dev/null +++ b/web/src/components/Header/HeaderLogo.vue @@ -0,0 +1,35 @@ + + + diff --git a/web/src/components/Header/HeaderVersion.vue b/web/src/components/Header/HeaderVersion.vue index 843af6960c..5b0f52c042 100644 --- a/web/src/components/Header/HeaderVersion.vue +++ b/web/src/components/Header/HeaderVersion.vue @@ -1,5 +1,5 @@ diff --git a/web/src/components/Header/HeaderLogo.vue b/web/src/components/Header/HeaderLogo.vue index 5bc033bf60..6644c8e43a 100644 --- a/web/src/components/Header/HeaderLogo.vue +++ b/web/src/components/Header/HeaderLogo.vue @@ -25,10 +25,11 @@ const unraidLogoHeaderLink = computed<{ href: string; title: string }>(() => ({ target="_blank" rel="noopener" :aria-label="unraidLogoHeaderLink.title" + class="block max-w-full min-w-0" > Unraid Logo From 171e60538639d23a8c843b5459909e8fcc8c57d3 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Thu, 9 Jul 2026 10:32:16 -0400 Subject: [PATCH 07/25] fix(web): tighten consolidated header control spacing --- web/src/components/Header.standalone.vue | 73 ++++++++++++------------ 1 file changed, 36 insertions(+), 37 deletions(-) diff --git a/web/src/components/Header.standalone.vue b/web/src/components/Header.standalone.vue index 355849afe2..47a96acb70 100644 --- a/web/src/components/Header.standalone.vue +++ b/web/src/components/Header.standalone.vue @@ -75,7 +75,10 @@ const copyLanIp = async () => { From 1cadc925791ce5fd6e51c80d409d96802bfbdfd0 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Thu, 9 Jul 2026 10:42:33 -0400 Subject: [PATCH 10/25] fix(web): align header version under logo --- web/src/components/Header.standalone.vue | 36 ++++++++++-------------- 1 file changed, 15 insertions(+), 21 deletions(-) diff --git a/web/src/components/Header.standalone.vue b/web/src/components/Header.standalone.vue index 023337216c..d3370ecaf9 100644 --- a/web/src/components/Header.standalone.vue +++ b/web/src/components/Header.standalone.vue @@ -84,10 +84,6 @@ const copyLanIp = async () => { class="unraid-banner-gradient-layer pointer-events-none absolute inset-0 z-0" /> -
- -
-
@@ -95,8 +91,11 @@ const copyLanIp = async () => {
-
+
+
+ +
@@ -144,25 +143,17 @@ const copyLanIp = async () => { From a1f74df4038c699e248408d5b51fd9c88ddccb2c Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Thu, 9 Jul 2026 11:08:54 -0400 Subject: [PATCH 13/25] fix(web): use transparent inline adaptive logo --- web/src/components/Header/HeaderLogo.vue | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/web/src/components/Header/HeaderLogo.vue b/web/src/components/Header/HeaderLogo.vue index deadc50777..b587af69aa 100644 --- a/web/src/components/Header/HeaderLogo.vue +++ b/web/src/components/Header/HeaderLogo.vue @@ -33,11 +33,19 @@ const unraidLogoHeaderLink = computed<{ href: string; title: string }>(() => ({ :aria-label="unraidLogoHeaderLink.title" class="block max-w-full min-w-0" > -