diff --git a/web/__test__/components/Header.test.ts b/web/__test__/components/Header.test.ts new file mode 100644 index 0000000000..d869f2cbc5 --- /dev/null +++ b/web/__test__/components/Header.test.ts @@ -0,0 +1,236 @@ +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: '
' }, + ArrayUsage: { 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 on a responsive grid with each region in its own area (no overlap)', () => { + const root = wrapper.get('#UnraidHeader'); + // 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); + }); + + 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'); + 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..52997a0fac 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'] @@ -74,8 +75,11 @@ 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'] + 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'] Indicator: typeof import('./src/components/Notifications/Indicator.vue')['default'] InternalBootConfirmDialog: typeof import('./src/components/Onboarding/components/InternalBootConfirmDialog.vue')['default'] diff --git a/web/src/assets/main.css b/web/src/assets/main.css index 6c36c79dfc..bac7947bb2 100644 --- a/web/src/assets/main.css +++ b/web/src/assets/main.css @@ -42,6 +42,14 @@ unraid-modals.unapi { isolation: isolate; } +/* Consolidated header (7.3+): custom elements default to display:inline, which + shrink-wraps the grid. Make it fill the header so the layout can space the logo + and account controls across the full available width. */ +unraid-header.unapi { + display: block; + width: 100%; +} + /* Style for Unraid progress frame */ iframe#progressFrame { background-color: var(--background-color); diff --git a/web/src/components/Header.standalone.vue b/web/src/components/Header.standalone.vue new file mode 100644 index 0000000000..7cd0f7f651 --- /dev/null +++ b/web/src/components/Header.standalone.vue @@ -0,0 +1,191 @@ + + + + + diff --git a/web/src/components/Header/ArrayUsage.vue b/web/src/components/Header/ArrayUsage.vue new file mode 100644 index 0000000000..f52bff0d76 --- /dev/null +++ b/web/src/components/Header/ArrayUsage.vue @@ -0,0 +1,69 @@ + + + diff --git a/web/src/components/Header/HeaderLogo.vue b/web/src/components/Header/HeaderLogo.vue new file mode 100644 index 0000000000..b587af69aa --- /dev/null +++ b/web/src/components/Header/HeaderLogo.vue @@ -0,0 +1,56 @@ + + + diff --git a/web/src/components/Header/HeaderVersion.vue b/web/src/components/Header/HeaderVersion.vue new file mode 100644 index 0000000000..5b0f52c042 --- /dev/null +++ b/web/src/components/Header/HeaderVersion.vue @@ -0,0 +1,237 @@ + + + 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/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..916e0ed24d 100644 --- a/web/src/components/HeaderOsVersion.standalone.vue +++ b/web/src/components/HeaderOsVersion.standalone.vue @@ -1,266 +1,11 @@ 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', 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", diff --git a/web/src/themes/types.d.ts b/web/src/themes/types.d.ts index 719cd13816..6880ec44ac 100644 --- a/web/src/themes/types.d.ts +++ b/web/src/themes/types.d.ts @@ -1,3 +1,5 @@ +export type HeaderLogoStyle = 'gradient' | 'theme'; + export interface Theme { banner: boolean; bannerGradient: boolean;