diff --git a/web/__test__/components/HeaderServerStatus.test.ts b/web/__test__/components/HeaderServerStatus.test.ts new file mode 100644 index 0000000000..cb3e194648 --- /dev/null +++ b/web/__test__/components/HeaderServerStatus.test.ts @@ -0,0 +1,93 @@ +import { nextTick } from 'vue'; +import { createPinia, setActivePinia } from 'pinia'; +import { mount } from '@vue/test-utils'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { VueWrapper } from '@vue/test-utils'; + +import ServerStatus from '~/components/UserProfile/ServerStatus.vue'; +import UptimeExpire from '~/components/UserProfile/UptimeExpire.vue'; +import { useServerStore } from '~/store/server'; +import { createTestI18n } from '../utils/i18n'; + +vi.mock('~/store/server', async () => { + const { defineStore } = await import('pinia'); + return { + useServerStore: defineStore('server', { + state: () => ({ + state: 'LIFETIME', + uptime: Date.now() - 9 * 86400000, + expireTime: Date.now() + 14 * 86400000, + dateTimeFormat: { date: '%c', time: '%R' }, + }), + }), + }; +}); + +vi.mock('@unraid/ui', () => { + const slot = { template: '
' }; + return { + cn: (...values: string[]) => values.filter(Boolean).join(' '), + Tooltip: slot, + TooltipContent: slot, + TooltipProvider: slot, + TooltipTrigger: slot, + }; +}); + +describe('header uptime and expiry states', () => { + let wrapper: VueWrapper | undefined; + + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-10-10T12:00:00Z')); + setActivePinia(createPinia()); + }); + + afterEach(() => { + wrapper?.unmount(); + vi.clearAllTimers(); + vi.useRealTimers(); + }); + + it('refreshes the compact timestamp when a running server changes to a trial or expired trial', async () => { + wrapper = mount(ServerStatus, { + global: { plugins: [createTestI18n()], stubs: { UpcServerState: true } }, + }); + const server = useServerStore(); + expect(wrapper.text()).toContain('Uptime 9 days'); + + server.state = 'TRIAL'; + await nextTick(); + expect(wrapper.text()).toContain('Expires in 14 days'); + expect(wrapper.text()).not.toContain('Expires in 9 days'); + + server.expireTime = Date.now() + 17 * 86400000; + await nextTick(); + expect(wrapper.text()).toContain('Expires in 17 days'); + + server.state = 'ENOCONN'; + await nextTick(); + expect(wrapper.text()).toContain('Expires in 17 days'); + + server.state = 'EEXPIRED'; + server.expireTime = Date.now() - 2 * 86400000; + await nextTick(); + expect(wrapper.text()).toContain('Expired 2 days'); + }); + + it('keeps compact expiry to its largest unit while preserving the full expiry display', () => { + const server = useServerStore(); + server.state = 'TRIAL'; + server.expireTime = Date.now() + 14 * 86400000 + 2 * 3600000 + 3 * 60000; + wrapper = mount(UptimeExpire, { + props: { shortText: true }, + global: { plugins: [createTestI18n()] }, + }); + expect(wrapper.text()).toBe('Expires in 14 days'); + wrapper.unmount(); + wrapper = mount(UptimeExpire, { global: { plugins: [createTestI18n()] } }); + expect(wrapper.text()).toContain('14 days 2 hours 3 minutes'); + }); +}); diff --git a/web/__test__/components/HeaderThemes.test.ts b/web/__test__/components/HeaderThemes.test.ts index 88872ff65a..762905501c 100644 --- a/web/__test__/components/HeaderThemes.test.ts +++ b/web/__test__/components/HeaderThemes.test.ts @@ -1,4 +1,7 @@ -import { ref } from 'vue'; +import { readFileSync } from 'node:fs'; +import { URL as NodeURL } from 'node:url'; + +import { nextTick, ref } from 'vue'; import { setActivePinia } from 'pinia'; import { provideApolloClient } from '@vue/apollo-composable'; import { mount } from '@vue/test-utils'; @@ -88,7 +91,7 @@ const THEMES = [ const layoutSignature = (wrapper: VueWrapper) => { const root = wrapper.get('#UnraidHeader'); const gradient = root.find('.unraid-banner-gradient-layer'); - const logoSvg = root.find('.uh-logo-block a svg'); + const logoSvg = root.find('.uh-logo-block a :is(svg, img)'); return { gradientLayer: gradient.exists() ? [...gradient.classes()].sort().join(' ') : null, logoSvg: logoSvg.exists() ? [...logoSvg.classes()].sort().join(' ') : null, @@ -185,6 +188,19 @@ describe('Header.standalone.vue theme layout', () => { } ); + it('embeds the original WebGUI logo asset without reconstructing its paths', () => { + const image = mountWithTheme('white').get('.uh-logo-block img'); + const original = readFileSync( + new NodeURL('../../../api/dev/webGui/images/UN-logotype-gradient.svg', import.meta.url), + 'utf8' + ); + expect(decodeURIComponent(image.attributes('src')!.replace('data:image/svg+xml,', ''))).toBe( + original + ); + expect(image.attributes('width')).toBe('222.36'); + expect(image.attributes('height')).toBe('39.04'); + }); + it.each(['gradient', 'theme'] as const)('renders the %s header logo style', (logoStyle) => { expect(layoutSignature(mountWithTheme('white', { logoStyle })).logoSvg).toMatchSnapshot(); }); @@ -199,7 +215,7 @@ describe('Header.standalone.vue theme layout', () => { it.each(THEMES.map((theme) => [theme.name, theme] as const))( 'keeps the logo a fixed size on the %s theme so the boot placeholder cannot resize on mount', (_name, theme) => { - const logoSvg = mountWithTheme(theme.name).get('.uh-logo-block a svg'); + const logoSvg = mountWithTheme(theme.name).get('.uh-logo-block a :is(svg, img)'); expect(logoSvg.classes()).toContain('shrink-0'); expect(logoSvg.classes()).not.toContain('max-w-full'); expect(logoSvg.classes()).toContain('w-[14rem]'); @@ -207,19 +223,33 @@ describe('Header.standalone.vue theme layout', () => { } ); - /** - * Regression guard: the banner gradient must stay within the legacy header's - * `min(30%, 320px)` edge width. A bare `left-[55%]` covered the right 45% - * (~576px at 1280px, ~1.8x legacy) and visibly obscured users' banner images. - */ - it('scopes the banner gradient to the legacy edge width', () => { - const gradient = mountWithTheme('white').get('.unraid-banner-gradient-layer'); - expect(gradient.classes()).toContain('w-[min(30%,320px)]'); - expect(gradient.classes()).toContain('right-0'); - expect(gradient.classes()).not.toContain('left-[55%]'); + it('enables the header fade only while both banner and fade are enabled', async () => { + const style = document.documentElement.style; + const previousGradient = style.getPropertyValue('--banner-gradient'); + style.setProperty('--banner-gradient', 'linear-gradient(90deg, transparent, #1c1b1b)'); + try { + const wrapper = mountWithTheme('white'); + const themeStore = useThemeStore(); + expect(wrapper.classes()).toContain('uh-banner-gradient-enabled'); + + themeStore.setTheme({ bannerGradient: false }); + await nextTick(); + expect(wrapper.classes()).not.toContain('uh-banner-gradient-enabled'); + + themeStore.setTheme({ bannerGradient: true }); + await nextTick(); + expect(wrapper.classes()).toContain('uh-banner-gradient-enabled'); + + themeStore.setTheme({ banner: false }); + await nextTick(); + expect(wrapper.classes()).not.toContain('uh-banner-gradient-enabled'); + } finally { + if (previousGradient) style.setProperty('--banner-gradient', previousGradient); + else style.removeProperty('--banner-gradient'); + } }); - it('omits the banner gradient styling hook only when the theme disables it', () => { + it('disables the backdrop without a banner', () => { const themeStore = useThemeStore(); themeStore.setTheme({ name: 'white', @@ -235,9 +265,8 @@ describe('Header.standalone.vue theme layout', () => { global: { plugins: [pinia], stubs }, }); wrappers.push(wrapper); - // The layer is always present; `--banner-gradient` is null when disabled, so - // it self-gates in CSS rather than by conditional rendering. expect(wrapper.find('.unraid-banner-gradient-layer').exists()).toBe(true); + expect(wrapper.classes()).not.toContain('uh-banner-gradient-enabled'); expect(wrapper.find('.uh-meta-over-banner').exists()).toBe(false); }); }); diff --git a/web/__test__/components/__snapshots__/HeaderThemes.test.ts.snap b/web/__test__/components/__snapshots__/HeaderThemes.test.ts.snap index acc7c2ec62..b16733f77f 100644 --- a/web/__test__/components/__snapshots__/HeaderThemes.test.ts.snap +++ b/web/__test__/components/__snapshots__/HeaderThemes.test.ts.snap @@ -2,8 +2,8 @@ exports[`Header.standalone.vue theme layout > renders a consistent layout on the azure theme 1`] = ` { - "gradientLayer": "absolute inset-y-0 pointer-events-none right-0 unraid-banner-gradient-layer w-[min(30%,320px)] z-0", - "logoSvg": "h-auto max-h-[3rem] shrink-0 w-[14rem] xs:w-[16rem]", + "gradientLayer": "absolute inset-y-0 pointer-events-none unraid-banner-gradient-layer z-0", + "logoSvg": "h-auto max-h-[3rem] object-contain shrink-0 w-[14rem] xs:w-[16rem]", "metaOverBanner": true, "regions": { "arrayUsage": true, @@ -17,8 +17,8 @@ exports[`Header.standalone.vue theme layout > renders a consistent layout on the exports[`Header.standalone.vue theme layout > renders a consistent layout on the black theme 1`] = ` { - "gradientLayer": "absolute inset-y-0 pointer-events-none right-0 unraid-banner-gradient-layer w-[min(30%,320px)] z-0", - "logoSvg": "h-auto max-h-[3rem] shrink-0 w-[14rem] xs:w-[16rem]", + "gradientLayer": "absolute inset-y-0 pointer-events-none unraid-banner-gradient-layer z-0", + "logoSvg": "h-auto max-h-[3rem] object-contain shrink-0 w-[14rem] xs:w-[16rem]", "metaOverBanner": true, "regions": { "arrayUsage": false, @@ -32,8 +32,8 @@ exports[`Header.standalone.vue theme layout > renders a consistent layout on the exports[`Header.standalone.vue theme layout > renders a consistent layout on the gray theme 1`] = ` { - "gradientLayer": "absolute inset-y-0 pointer-events-none right-0 unraid-banner-gradient-layer w-[min(30%,320px)] z-0", - "logoSvg": "h-auto max-h-[3rem] shrink-0 w-[14rem] xs:w-[16rem]", + "gradientLayer": "absolute inset-y-0 pointer-events-none unraid-banner-gradient-layer z-0", + "logoSvg": "h-auto max-h-[3rem] object-contain shrink-0 w-[14rem] xs:w-[16rem]", "metaOverBanner": true, "regions": { "arrayUsage": true, @@ -47,8 +47,8 @@ exports[`Header.standalone.vue theme layout > renders a consistent layout on the exports[`Header.standalone.vue theme layout > renders a consistent layout on the white theme 1`] = ` { - "gradientLayer": "absolute inset-y-0 pointer-events-none right-0 unraid-banner-gradient-layer w-[min(30%,320px)] z-0", - "logoSvg": "h-auto max-h-[3rem] shrink-0 w-[14rem] xs:w-[16rem]", + "gradientLayer": "absolute inset-y-0 pointer-events-none unraid-banner-gradient-layer z-0", + "logoSvg": "h-auto max-h-[3rem] object-contain shrink-0 w-[14rem] xs:w-[16rem]", "metaOverBanner": true, "regions": { "arrayUsage": false, @@ -60,6 +60,6 @@ exports[`Header.standalone.vue theme layout > renders a consistent layout on the } `; -exports[`Header.standalone.vue theme layout > renders the gradient header logo style 1`] = `"h-auto max-h-[3rem] shrink-0 w-[14rem] xs:w-[16rem]"`; +exports[`Header.standalone.vue theme layout > renders the gradient header logo style 1`] = `"h-auto max-h-[3rem] object-contain shrink-0 w-[14rem] xs:w-[16rem]"`; exports[`Header.standalone.vue theme layout > renders the theme header logo style 1`] = `"h-auto max-h-[3rem] shrink-0 text-header-text-primary w-[14rem] xs:w-[16rem]"`; diff --git a/web/src/assets/UN-logotype-gradient.svg b/web/src/assets/UN-logotype-gradient.svg new file mode 100644 index 0000000000..fe08730371 --- /dev/null +++ b/web/src/assets/UN-logotype-gradient.svg @@ -0,0 +1 @@ +unraid.net diff --git a/web/src/components/Brand/Avatar.vue b/web/src/components/Brand/Avatar.vue index 4be7462d89..7b5ea9cb83 100644 --- a/web/src/components/Brand/Avatar.vue +++ b/web/src/components/Brand/Avatar.vue @@ -22,11 +22,7 @@ const { avatar, connectPluginInstalled, registered, username } = storeToRefs(ser class="absolute inset-0 z-10 h-9 w-9 overflow-hidden rounded-full object-cover" /> diff --git a/web/src/components/Header.standalone.vue b/web/src/components/Header.standalone.vue index 7630aad806..d68978cb24 100644 --- a/web/src/components/Header.standalone.vue +++ b/web/src/components/Header.standalone.vue @@ -29,12 +29,8 @@ import { useThemeStore } from '~/store/theme'; * 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). + * A single grid keeps stateful controls mounted once. On mobile the version and + * update notice span the header width instead of competing with account actions. */ export interface Props { server?: Server | string; @@ -73,10 +69,6 @@ const theme = computed(() => themeStore.theme); const logoStyle = computed(() => props.headerLogoStyle === 'theme' ? 'theme' : 'gradient' ); -// On mobile the meta row spans full width over the banner image, so its text -// needs its own legibility treatment (drop-shadow) rather than the right-only -// banner gradient. Only when a banner image is present, otherwise the header is -// a solid color the theme text already reads against. const hasBanner = computed(() => theme.value?.banner === true); const dropdownOpen = ref(false); @@ -90,23 +82,15 @@ const copyLanIp = async () => { {{ name }} - +
+ - - - - + + + + +
diff --git a/web/src/components/Header/ArrayUsage.vue b/web/src/components/Header/ArrayUsage.vue index f52bff0d76..05602d1414 100644 --- a/web/src/components/Header/ArrayUsage.vue +++ b/web/src/components/Header/ArrayUsage.vue @@ -47,14 +47,18 @@ const hasData = computed(() => array.value != null); diff --git a/web/src/components/Header/HeaderVersion.vue b/web/src/components/Header/HeaderVersion.vue index b280d57bc7..9f9d327007 100644 --- a/web/src/components/Header/HeaderVersion.vue +++ b/web/src/components/Header/HeaderVersion.vue @@ -5,6 +5,7 @@ import { storeToRefs } from 'pinia'; import { useLazyQuery } from '@vue/apollo-composable'; import { + ArrowPathIcon, ArrowTopRightOnSquareIcon, BellAlertIcon, ClipboardDocumentIcon, @@ -116,7 +117,8 @@ const updateOsStatus = computed(() => { return { badge: { color: 'yellow', - icon: ExclamationTriangleIcon, + icon: + rebootType.value === 'thirdPartyDriversDownloading' ? ArrowPathIcon : ExclamationTriangleIcon, }, href: rebootType.value === 'downgrade' ? WEBGUI_TOOLS_DOWNGRADE : WEBGUI_TOOLS_UPDATE, text: t(rebootTypeText.value), @@ -146,7 +148,7 @@ const updateOsStatus = computed(() => { + + diff --git a/web/src/components/UserProfile/DropdownTrigger.vue b/web/src/components/UserProfile/DropdownTrigger.vue index 87a0c14be5..880ef54207 100644 --- a/web/src/components/UserProfile/DropdownTrigger.vue +++ b/web/src/components/UserProfile/DropdownTrigger.vue @@ -15,6 +15,8 @@ import BrandAvatar from '~/components/Brand/Avatar.vue'; import { useErrorsStore } from '~/store/errors'; import { useServerStore } from '~/store/server'; +const { compact = false } = defineProps<{ compact?: boolean }>(); + const { t } = useI18n(); const { errors } = storeToRefs(useErrorsStore()); @@ -68,7 +70,9 @@ const title = computed((): string => { /> - + + diff --git a/web/src/components/UserProfile/ServerStatus.vue b/web/src/components/UserProfile/ServerStatus.vue index c1fcca2675..7d58147788 100644 --- a/web/src/components/UserProfile/ServerStatus.vue +++ b/web/src/components/UserProfile/ServerStatus.vue @@ -1,10 +1,19 @@