diff --git a/cypress/e2e/po/pages/explorer/workloads/workload-dashboard.po.ts b/cypress/e2e/po/pages/explorer/workloads/workload-dashboard.po.ts new file mode 100644 index 00000000000..d44184cf1ea --- /dev/null +++ b/cypress/e2e/po/pages/explorer/workloads/workload-dashboard.po.ts @@ -0,0 +1,73 @@ +import PagePo from '@/cypress/e2e/po/pages/page.po'; +import BurgerMenuPo from '@/cypress/e2e/po/side-bars/burger-side-menu.po'; +import ProductNavPo from '@/cypress/e2e/po/side-bars/product-side-nav.po'; +import CardPo from '@/cypress/e2e/po/components/Resource/Detail/Card/statusCard.po'; +import BannersPo from '@/cypress/e2e/po/components/banners.po'; + +export default class WorkloadDashboardPagePo extends PagePo { + private static createPath(clusterId: string) { + return `/c/${ clusterId }/explorer/workload-dashboard`; + } + + static goTo(clusterId: string): Cypress.Chainable { + return super.goTo(WorkloadDashboardPagePo.createPath(clusterId)); + } + + constructor(clusterId = 'local') { + super(WorkloadDashboardPagePo.createPath(clusterId)); + } + + static navTo(clusterId = 'local') { + const burgerMenu = new BurgerMenuPo(); + const sideNav = new ProductNavPo(); + + burgerMenu.goToCluster(clusterId); + sideNav.navToSideMenuGroupByLabel('Workloads'); + } + + title() { + return cy.get('[data-testid="workload-dashboard-title"]'); + } + + subtitle() { + return cy.get('[data-testid="workload-dashboard-subtitle"]'); + } + + byStateSection() { + return cy.get('[data-testid="workload-dashboard-by-state"]'); + } + + stateCards() { + return cy.get('[data-testid="workload-dashboard-state-card"]'); + } + + byTypeSection() { + return cy.get('[data-testid="workload-dashboard-by-type"]'); + } + + byTypeCard(index = 0) { + return new CardPo(`[data-testid="resource-detail-status-card"]:eq(${ index })`); + } + + byTypeCards() { + return cy.get('[data-testid="resource-detail-status-card"]'); + } + + interceptSummariesAsEmpty() { + return cy.intercept('GET', '/v1/*?summary=*', { + summary: [], count: 0, data: [] + }).as('emptySummary'); + } + + waitForEmptySummaries() { + return cy.wait('@emptySummary'); + } + + emptyState() { + return cy.get('[data-testid="workload-dashboard-empty"]'); + } + + errorBanner() { + return new BannersPo('.banner.error'); + } +} diff --git a/cypress/e2e/tests/pages/explorer2/workloads/workload-dashboard.spec.ts b/cypress/e2e/tests/pages/explorer2/workloads/workload-dashboard.spec.ts new file mode 100644 index 00000000000..dbe35d7367f --- /dev/null +++ b/cypress/e2e/tests/pages/explorer2/workloads/workload-dashboard.spec.ts @@ -0,0 +1,55 @@ +import WorkloadDashboardPagePo from '@/cypress/e2e/po/pages/explorer/workloads/workload-dashboard.po'; + +const workloadDashboard = new WorkloadDashboardPagePo('local'); + +describe('Workload Dashboard', { testIsolation: 'off', tags: ['@explorer2', '@adminUser'] }, () => { + before(() => { + cy.login(); + cy.updateNamespaceFilter('local', 'none', '{"local":[]}'); + WorkloadDashboardPagePo.navTo(); + workloadDashboard.waitForPage(); + }); + + it('should display the title', () => { + workloadDashboard.title().should('contain.text', 'Workloads Overview'); + }); + + it('should display a namespace subtitle with workload count', () => { + workloadDashboard.subtitle().should('be.visible'); + workloadDashboard.subtitle().invoke('text').should('match', /\(\d+\)/); + }); + + it('should display the By State section with state cards', () => { + workloadDashboard.byStateSection().should('be.visible'); + workloadDashboard.stateCards().should('have.length.gte', 1); + }); + + it('should display the By Type section with type cards', () => { + workloadDashboard.byTypeSection().should('be.visible'); + workloadDashboard.byTypeCards().should('have.length.gte', 1); + }); + + it('should navigate to the resource list when clicking a By Type card', () => { + WorkloadDashboardPagePo.navTo(); + workloadDashboard.waitForPage(); + + workloadDashboard.byTypeCards().first().click(); + + cy.url().should('match', /\/c\/local\/explorer\/(apps\.|batch\.)?[a-z]+/); + }); + + it('should show empty state when namespace filter matches no workloads', () => { + workloadDashboard.interceptSummariesAsEmpty(); + + WorkloadDashboardPagePo.navTo(); + workloadDashboard.waitForPage(); + workloadDashboard.waitForEmptySummaries(); + + workloadDashboard.emptyState().should('be.visible'); + }); + + after(() => { + cy.login(); + cy.updateNamespaceFilter('local', 'none', '{"local":["all://user"]}'); + }); +}); diff --git a/shell/assets/translations/en-us.yaml b/shell/assets/translations/en-us.yaml index f1f20218918..02f940f4638 100644 --- a/shell/assets/translations/en-us.yaml +++ b/shell/assets/translations/en-us.yaml @@ -7425,6 +7425,29 @@ wm: kubectlShell: title: "Kubectl: {name}" +workloadDashboard: + title: Workloads Overview + subtitle: + allNamespaces: "For all namespaces ({count})" + userNamespaces: "Only for user namespaces ({count})" + systemNamespaces: "Only for system namespaces ({count})" + namespacedOnly: "Only for namespaced resources ({count})" + clusterOnly: "Only for cluster resources ({count})" + project: "For the namespaces of Project {name} ({count})" + namespace: "For the namespace {name} ({count})" + multipleSelected: "For {selected} items selected ({count})" + sections: + byState: By State + byType: By Type + errors: + noAccess: "No access to {type}" + fetchType: "Failed to fetch {type}" + fetchAll: Failed to fetch workload summaries + empty: + title: No workloads to show + message: "Tips: undo the last namespace filter you applied or reset the namespaces filter." + docsMessage: "Want to learn more about Workloads? Read our documentation." + workload: scaleWorkloads: Scale workloads healthWorkloads: Jobs/Pods health status diff --git a/shell/components/SubtleLink.vue b/shell/components/SubtleLink.vue index e9023211f05..f72b58cc706 100644 --- a/shell/components/SubtleLink.vue +++ b/shell/components/SubtleLink.vue @@ -1,25 +1,50 @@ diff --git a/shell/composables/useStateColor.test.ts b/shell/composables/useStateColor.test.ts new file mode 100644 index 00000000000..8635ff454c8 --- /dev/null +++ b/shell/composables/useStateColor.test.ts @@ -0,0 +1,306 @@ +import { useStateColor } from '@shell/composables/useStateColor'; +import type { StateSummaryEntry } from '@shell/composables/useStateColor'; + +const mockGetters: Record = {}; +const mockDispatch = jest.fn(); + +jest.mock('vuex', () => ({ + useStore: () => ({ + getters: new Proxy(mockGetters, { + get(target, prop: string) { + return target[prop]; + }, + }), + dispatch: mockDispatch, + }), +})); + +jest.mock('@shell/plugins/steve/steve-pagination-utils', () => ({ + __esModule: true, + default: { createParamsForPagination: jest.fn(() => 'page=1&pagesize=1') }, +})); + +jest.mock('@shell/plugins/steve/projectAndNamespaceFiltering.utils', () => ({ + __esModule: true, + default: { createParam: jest.fn(() => '') }, +})); + +describe('composable: useStateColor', () => { + beforeEach(() => { + jest.clearAllMocks(); + Object.keys(mockGetters).forEach((key) => delete mockGetters[key]); + }); + + describe('toStateColor', () => { + it.each([ + ['running', 'success'], + ['active', 'success'], + ['completed', 'success'], + ['error', 'error'], + ['failed', 'error'], + ['stopped', 'error'], + ['warning', 'warning'], + ['initializing', 'warning'], + ['pending', 'info'], + ['waiting', 'info'], + ['creating', 'info'], + ])('should return %s color for known state "%s"', (state, expectedColor) => { + const { toStateColor } = useStateColor(); + + expect(toStateColor(state)).toStrictEqual(expectedColor); + }); + + it('should return "disabled" for state with "darker" color', () => { + const { toStateColor } = useStateColor(); + + expect(toStateColor('off')).toStrictEqual('disabled'); + }); + + it('should return "info" for unknown states', () => { + const { toStateColor } = useStateColor(); + + expect(toStateColor('unknown-state')).toStrictEqual('info'); + }); + + it('should be case-insensitive', () => { + const { toStateColor } = useStateColor(); + + expect(toStateColor('Running')).toStrictEqual(toStateColor('running')); + expect(toStateColor('ERROR')).toStrictEqual(toStateColor('error')); + }); + + it('should handle empty string', () => { + const { toStateColor } = useStateColor(); + + expect(toStateColor('')).toStrictEqual('info'); + }); + + it('should cache results across calls', () => { + const { toStateColor } = useStateColor(); + + const first = toStateColor('running'); + const second = toStateColor('running'); + + expect(first).toStrictEqual(second); + expect(first).toStrictEqual('success'); + }); + }); + + describe('resolveStateColors', () => { + const schema = { links: { collection: '/k8s/clusters/local/v1/pods' } }; + + beforeEach(() => { + mockGetters['cluster/schemaFor'] = () => schema; + }); + + it('should not make requests when all states are already known', async() => { + const { resolveStateColors } = useStateColor(); + + const entries: StateSummaryEntry[] = [{ + type: 'pod', + summary: [{ property: 'metadata.state.name', counts: { running: 5 } }], + }]; + + await resolveStateColors(entries); + + expect(mockDispatch).not.toHaveBeenCalled(); + }); + + it('should fetch a resource to resolve unknown state colors', async() => { + const { resolveStateColors } = useStateColor(); + + mockDispatch.mockResolvedValueOnce({ + data: [{ + metadata: { + state: { + name: 'customState', error: true, transitioning: false + } + } + }], + }); + + const entries: StateSummaryEntry[] = [{ + type: 'pod', + summary: [{ property: 'metadata.state.name', counts: { customState: 3 } }], + }]; + + await resolveStateColors(entries); + + expect(mockDispatch).toHaveBeenCalledWith('cluster/request', { url: `${ schema.links.collection }?page=1&pagesize=1` }); + }); + + it('should resolve error states from resource metadata', async() => { + const { toStateColor, resolveStateColors } = useStateColor(); + + mockDispatch.mockResolvedValueOnce({ + data: [{ + metadata: { + state: { + name: 'init:Error', error: true, transitioning: false + } + } + }], + }); + + const entries: StateSummaryEntry[] = [{ + type: 'pod', + summary: [{ property: 'metadata.state.name', counts: { 'init:Error': 1 } }], + }]; + + await resolveStateColors(entries); + + expect(toStateColor('init:Error')).toStrictEqual('error'); + }); + + it('should resolve transitioning states as info', async() => { + const { toStateColor, resolveStateColors } = useStateColor(); + + mockDispatch.mockResolvedValueOnce({ + data: [{ + metadata: { + state: { + name: 'init:0/1', error: false, transitioning: true + } + } + }], + }); + + const entries: StateSummaryEntry[] = [{ + type: 'pod', + summary: [{ property: 'metadata.state.name', counts: { 'init:0/1': 2 } }], + }]; + + await resolveStateColors(entries); + + expect(toStateColor('init:0/1')).toStrictEqual('info'); + }); + + it('should skip entries with null summary', async() => { + const { resolveStateColors } = useStateColor(); + + const entries: StateSummaryEntry[] = [{ + type: 'pod', + summary: null, + }]; + + await resolveStateColors(entries); + + expect(mockDispatch).not.toHaveBeenCalled(); + }); + + it('should skip summary properties that are not metadata.state.name', async() => { + const { resolveStateColors } = useStateColor(); + + const entries: StateSummaryEntry[] = [{ + type: 'pod', + summary: [{ property: 'metadata.namespace', counts: { default: 5 } }], + }]; + + await resolveStateColors(entries); + + expect(mockDispatch).not.toHaveBeenCalled(); + }); + + it('should handle API errors gracefully and fall back to default color', async() => { + const { toStateColor, resolveStateColors } = useStateColor(); + + mockDispatch.mockRejectedValueOnce(new Error('network error')); + + const entries: StateSummaryEntry[] = [{ + type: 'pod', + summary: [{ property: 'metadata.state.name', counts: { networkFailState: 1 } }], + }]; + + await resolveStateColors(entries); + + expect(toStateColor('networkFailState')).toStrictEqual('info'); + }); + + it('should handle response with no resource data', async() => { + const { toStateColor, resolveStateColors } = useStateColor(); + + mockDispatch.mockResolvedValueOnce({ data: [] }); + + const entries: StateSummaryEntry[] = [{ + type: 'pod', + summary: [{ property: 'metadata.state.name', counts: { emptyResult: 1 } }], + }]; + + await resolveStateColors(entries); + + expect(toStateColor('emptyResult')).toStrictEqual('info'); + }); + + it('should handle resource without metadata.state', async() => { + const { toStateColor, resolveStateColors } = useStateColor(); + + mockDispatch.mockResolvedValueOnce({ data: [{ metadata: {} }] }); + + const entries: StateSummaryEntry[] = [{ + type: 'pod', + summary: [{ property: 'metadata.state.name', counts: { noStateField: 1 } }], + }]; + + await resolveStateColors(entries); + + expect(toStateColor('noStateField')).toStrictEqual('info'); + }); + + it('should use schema.links.collection for the request URL', async() => { + const { resolveStateColors } = useStateColor(); + const customSchema = { links: { collection: '/k8s/clusters/c-m-abc123/v1/apps.deployments' } }; + + mockGetters['cluster/schemaFor'] = () => customSchema; + mockDispatch.mockResolvedValueOnce({ data: [] }); + + const entries: StateSummaryEntry[] = [{ + type: 'apps.deployments', + summary: [{ property: 'metadata.state.name', counts: { urlTestState: 1 } }], + }]; + + await resolveStateColors(entries); + + expect(mockDispatch).toHaveBeenCalledWith('cluster/request', { url: `${ customSchema.links.collection }?page=1&pagesize=1` }); + }); + + it('should resolve multiple unknown states across entries', async() => { + const { toStateColor, resolveStateColors } = useStateColor(); + + mockDispatch + .mockResolvedValueOnce({ + data: [{ + metadata: { + state: { + name: 'stateA', error: true, transitioning: false + } + } + }], + }) + .mockResolvedValueOnce({ + data: [{ + metadata: { + state: { + name: 'stateB', error: false, transitioning: false + } + } + }], + }); + + const entries: StateSummaryEntry[] = [ + { + type: 'pod', + summary: [{ property: 'metadata.state.name', counts: { stateA: 1 } }], + }, + { + type: 'apps.deployments', + summary: [{ property: 'metadata.state.name', counts: { stateB: 2 } }], + }, + ]; + + await resolveStateColors(entries); + + expect(toStateColor('stateA')).toStrictEqual('error'); + expect(toStateColor('stateB')).toStrictEqual('warning'); + }); + }); +}); diff --git a/shell/composables/useStateColor.ts b/shell/composables/useStateColor.ts new file mode 100644 index 00000000000..02889402b49 --- /dev/null +++ b/shell/composables/useStateColor.ts @@ -0,0 +1,108 @@ +import { reactive } from 'vue'; +import { useStore } from 'vuex'; +import { STATES, colorForState } from '@shell/plugins/dashboard-store/resource-class'; +import type { StateColor } from '@shell/utils/style'; +import stevePaginationUtils from '@shell/plugins/steve/steve-pagination-utils'; +import { PaginationParamFilter } from '@shell/types/store/pagination.types'; + +export interface StateSummaryEntry { + type: string; + summary: { property: string; counts: Record }[] | null; +} + +const stateColorCache = reactive>({}); + +export function useStateColor() { + const store = useStore(); + + function toStateColor(state: string): StateColor { + const key = (state || '').toLowerCase(); + + if (stateColorCache[key]) { + return stateColorCache[key]; + } + + const config = STATES[key]; + const color = config?.color || 'info'; + const resolved = (color === 'darker' ? 'disabled' : color) as StateColor; + + stateColorCache[key] = resolved; + + return resolved; + } + + async function resolveStateColors(entries: StateSummaryEntry[]): Promise { + const unresolvedStates = new Map(); + + for (const entry of entries) { + if (!entry.summary) { + continue; + } + + for (const s of entry.summary) { + if (s.property === 'metadata.state.name') { + for (const stateName of Object.keys(s.counts)) { + const key = stateName.toLowerCase(); + + if (!stateColorCache[key] && !unresolvedStates.has(key)) { + unresolvedStates.set(key, { originalName: stateName, type: entry.type }); + } + } + } + } + } + + if (unresolvedStates.size === 0) { + return; + } + + const newColors: Record = {}; + const pending = Array.from(unresolvedStates.entries()); + const concurrency = 10; + + for (let i = 0; i < pending.length; i += concurrency) { + const batch = pending.slice(i, i + concurrency); + + await Promise.all(batch.map(async([stateKey, { originalName, type }]) => { + try { + const schema = store.getters['cluster/schemaFor'](type); + const params = stevePaginationUtils.createParamsForPagination({ + schema, + opt: { + pagination: { + page: 1, + pageSize: 1, + sort: [], + filters: [PaginationParamFilter.createSingleField({ field: 'metadata.state.name', value: originalName })], + projectsOrNamespaces: [], + } + } + }); + const url = `${ schema.links.collection }?${ params }`; + const res = await store.dispatch('cluster/request', { url }); + const resource = res?.data?.[0]; + + if (!resource?.metadata?.state) { + return; + } + + const { error: isError, transitioning, name } = resource.metadata.state; + const rawColor = colorForState(name, isError, transitioning).replace('text-', ''); + + newColors[stateKey] = (rawColor === 'darker' ? 'disabled' : rawColor) as StateColor; + } catch { + // Fallback handled by toStateColor via STATES lookup + } + })); + } + + if (Object.keys(newColors).length > 0) { + Object.assign(stateColorCache, newColors); + } + } + + return { + toStateColor, + resolveStateColors, + }; +} diff --git a/shell/config/product/explorer.js b/shell/config/product/explorer.js index 11157114bb2..668623dfbf5 100644 --- a/shell/config/product/explorer.js +++ b/shell/config/product/explorer.js @@ -10,6 +10,7 @@ import { SNAPSHOT, VIRTUAL_TYPES, CAPI, + WORKLOAD_DASHBOARD, } from '@shell/config/types'; import { @@ -103,6 +104,7 @@ export function init(store) { CONFIG_MAP ], 'storage'); basicType([ + WORKLOAD_DASHBOARD, WORKLOAD, WORKLOAD_TYPES.DEPLOYMENT, WORKLOAD_TYPES.DAEMON_SET, @@ -112,10 +114,6 @@ export function init(store) { POD, ], 'workload'); - setGroupDefaultType('workload', () => { - return store.getters['features/get'](STEVE_CACHE) ? WORKLOAD_TYPES.DEPLOYMENT : undefined; - }); - weightGroup('cluster', 99, true); weightGroup('workload', 98, true); weightGroup('serviceDiscovery', 96, true); @@ -586,6 +584,20 @@ export function init(store) { overview: true, }); + // Workload Dashboard - overview page using the Resource Summary API + virtualType({ + label: store.getters['i18n/t'](`typeLabel.${ WORKLOAD }`, { count: 2 }), + group: 'Root', + namespaced: true, + name: WORKLOAD_DASHBOARD, + weight: 100, + icon: 'folder', + ifHaveSubTypes: Object.values(WORKLOAD_TYPES), + route: { name: 'c-cluster-explorer-workload-dashboard' }, + exact: true, + overview: true, + }); + virtualType({ labelKey: 'members.clusterAndProject', group: 'cluster', diff --git a/shell/config/router/routes.js b/shell/config/router/routes.js index 7ee35ee5e97..5b5b2e2fa47 100644 --- a/shell/config/router/routes.js +++ b/shell/config/router/routes.js @@ -349,6 +349,10 @@ export default [ path: '/c/:cluster/explorer/explorer-utils', component: () => interopDefault(import('@shell/pages/c/_cluster/explorer/explorer-utils.js')), name: 'c-cluster-explorer-explorer-utils' + }, { + path: '/c/:cluster/explorer/workload-dashboard', + component: () => interopDefault(import('@shell/pages/c/_cluster/explorer/workload-dashboard/index.vue')), + name: 'c-cluster-explorer-workload-dashboard' }, { path: '/c/:cluster/explorer/tools', component: () => interopDefault(import('@shell/pages/c/_cluster/explorer/tools/index.vue')), diff --git a/shell/config/types.js b/shell/config/types.js index 03e99d31949..e14f68dc919 100644 --- a/shell/config/types.js +++ b/shell/config/types.js @@ -81,6 +81,7 @@ export const RBAC = { }; export const WORKLOAD = 'workload'; +export const WORKLOAD_DASHBOARD = 'workload-dashboard'; /** * Rancher Workload types diff --git a/shell/mixins/__tests__/resource-fetch-api-pagination.test.ts b/shell/mixins/__tests__/resource-fetch-api-pagination.test.ts new file mode 100644 index 00000000000..788c5261cfd --- /dev/null +++ b/shell/mixins/__tests__/resource-fetch-api-pagination.test.ts @@ -0,0 +1,67 @@ +import { parseStructuredQuery } from '@shell/mixins/resource-fetch-api-pagination'; +import { PaginationFilterEquality } from '@shell/types/store/pagination.types'; + +describe('parseStructuredQuery', () => { + it('should return null for null input', () => { + expect(parseStructuredQuery(null)).toBeNull(); + }); + + it('should return null for undefined input', () => { + expect(parseStructuredQuery(undefined)).toBeNull(); + }); + + it('should return null for empty string', () => { + expect(parseStructuredQuery('')).toBeNull(); + }); + + it('should return null for plain text search query', () => { + expect(parseStructuredQuery('nginx')).toBeNull(); + }); + + it('should return null for malformed structured query', () => { + expect(parseStructuredQuery('"field"')).toBeNull(); + }); + + it('should parse a single key-value pair', () => { + const result = parseStructuredQuery('"metadata.state.name":"running"'); + + expect(result).toHaveLength(1); + expect(result?.[0].field).toStrictEqual('metadata.state.name'); + expect(result?.[0].value).toStrictEqual('running'); + expect(result?.[0].equality).toStrictEqual(PaginationFilterEquality.IN); + }); + + it('should parse multiple key-value pairs with the same field into a single IN filter', () => { + const result = parseStructuredQuery('"metadata.state.name":"running","metadata.state.name":"active"'); + + expect(result).toHaveLength(1); + expect(result?.[0].field).toStrictEqual('metadata.state.name'); + expect(result?.[0].value).toStrictEqual('running,active'); + expect(result?.[0].equality).toStrictEqual(PaginationFilterEquality.IN); + }); + + it('should parse pairs with different fields into separate filters', () => { + const result = parseStructuredQuery('"metadata.state.name":"running","metadata.namespace":"default"'); + + expect(result).toHaveLength(2); + expect(result?.[0].field).toStrictEqual('metadata.state.name'); + expect(result?.[0].value).toStrictEqual('running'); + expect(result?.[1].field).toStrictEqual('metadata.namespace'); + expect(result?.[1].value).toStrictEqual('default'); + }); + + it('should handle empty value in a pair', () => { + const result = parseStructuredQuery('"metadata.state.name":""'); + + expect(result).toHaveLength(1); + expect(result?.[0].field).toStrictEqual('metadata.state.name'); + expect(result?.[0].value).toStrictEqual(''); + }); + + it('should group multiple values for the same field from three pairs', () => { + const result = parseStructuredQuery('"metadata.state.name":"running","metadata.state.name":"active","metadata.state.name":"waiting"'); + + expect(result).toHaveLength(1); + expect(result?.[0].value).toStrictEqual('running,active,waiting'); + }); +}); diff --git a/shell/mixins/resource-fetch-api-pagination.js b/shell/mixins/resource-fetch-api-pagination.js index 92d6b2228fa..a1e021e9515 100644 --- a/shell/mixins/resource-fetch-api-pagination.js +++ b/shell/mixins/resource-fetch-api-pagination.js @@ -5,10 +5,46 @@ import { mapGetters } from 'vuex'; import { ResourceListComponentName } from '../components/ResourceList/resource-list.config'; import paginationUtils from '@shell/utils/pagination-utils'; import debounce from 'lodash/debounce'; -import { PaginationParamFilter, PaginationFilterField, PaginationArgs } from '@shell/types/store/pagination.types'; +import { PaginationParamFilter, PaginationFilterField, PaginationFilterEquality, PaginationArgs } from '@shell/types/store/pagination.types'; import stevePaginationUtils from '@shell/plugins/steve/steve-pagination-utils'; import { STEVE_WATCH_MODE } from '@shell/types/store/subscribe.types'; +// Validates overall shape only; capture groups beyond the first pair are discarded — actual extraction uses pairRegex in parseStructuredQuery +const STRUCTURED_QUERY_REGEX = /^"([^"]+)":"([^"]*)"(?:,"([^"]+)":"([^"]*)")*$/; + +export function parseStructuredQuery(query) { + if (!query || !STRUCTURED_QUERY_REGEX.test(query)) { + return null; + } + + const pairs = []; + const pairRegex = /"([^"]+)":"([^"]*)"/g; + let match; + + while ((match = pairRegex.exec(query)) !== null) { + pairs.push({ field: match[1], value: match[2] }); + } + + if (!pairs.length) { + return null; + } + + const grouped = {}; + + for (const { field, value } of pairs) { + if (!grouped[field]) { + grouped[field] = []; + } + grouped[field].push(value); + } + + return Object.entries(grouped).map(([field, values]) => new PaginationFilterField({ + field, + value: values.join(','), + equality: PaginationFilterEquality.IN, + })); +} + /** * Companion mixin used with `resource-fetch` for `ResourceList` to determine if the user needs to filter the list by a single namespace */ @@ -75,11 +111,12 @@ export default { const { page, perPage, filter, sort, descending } = event; - const searchFilters = filter.searchQuery ? filter.searchFields.map((field) => new PaginationFilterField({ + const structuredFilters = filter.searchQuery ? parseStructuredQuery(filter.searchQuery) : null; + const searchFilters = structuredFilters || (filter.searchQuery ? filter.searchFields.map((field) => new PaginationFilterField({ field, value: filter.searchQuery, exact: false, - })) : []; + })) : []); const pagination = new PaginationArgs({ page, diff --git a/shell/pages/c/_cluster/apps/charts/index.vue b/shell/pages/c/_cluster/apps/charts/index.vue index c8a9a0e4b29..b9b77082b11 100644 --- a/shell/pages/c/_cluster/apps/charts/index.vue +++ b/shell/pages/c/_cluster/apps/charts/index.vue @@ -25,6 +25,7 @@ import AppChartCardFooter from '@shell/pages/c/_cluster/apps/charts/AppChartCard import AddRepoLink from '@shell/pages/c/_cluster/apps/charts/AddRepoLink'; import StatusLabel from '@shell/pages/c/_cluster/apps/charts/StatusLabel'; import RichTranslation from '@shell/components/RichTranslation.vue'; +import SubtleLink from '@shell/components/SubtleLink.vue'; import { getLatestCompatibleVersion } from '@shell/utils/chart'; import Select from '@shell/components/form/Select'; import { getVersionData } from '@shell/config/version'; @@ -47,7 +48,8 @@ export default { AppChartCardSubHeader, AppChartCardFooter, Select, - RichTranslation + RichTranslation, + SubtleLink, }, async fetch() { @@ -683,16 +685,13 @@ export default { tag="span" > diff --git a/shell/pages/c/_cluster/explorer/workload-dashboard/ByStateSection.vue b/shell/pages/c/_cluster/explorer/workload-dashboard/ByStateSection.vue new file mode 100644 index 00000000000..053e7b0e5f1 --- /dev/null +++ b/shell/pages/c/_cluster/explorer/workload-dashboard/ByStateSection.vue @@ -0,0 +1,103 @@ + + + + + diff --git a/shell/pages/c/_cluster/explorer/workload-dashboard/ByTypeSection.vue b/shell/pages/c/_cluster/explorer/workload-dashboard/ByTypeSection.vue new file mode 100644 index 00000000000..f7b92a98288 --- /dev/null +++ b/shell/pages/c/_cluster/explorer/workload-dashboard/ByTypeSection.vue @@ -0,0 +1,29 @@ + + + + + diff --git a/shell/pages/c/_cluster/explorer/workload-dashboard/WorkloadCard.vue b/shell/pages/c/_cluster/explorer/workload-dashboard/WorkloadCard.vue new file mode 100644 index 00000000000..de10dab54e4 --- /dev/null +++ b/shell/pages/c/_cluster/explorer/workload-dashboard/WorkloadCard.vue @@ -0,0 +1,157 @@ + + + + + diff --git a/shell/pages/c/_cluster/explorer/workload-dashboard/WorkloadTypeCard.vue b/shell/pages/c/_cluster/explorer/workload-dashboard/WorkloadTypeCard.vue new file mode 100644 index 00000000000..729f787a647 --- /dev/null +++ b/shell/pages/c/_cluster/explorer/workload-dashboard/WorkloadTypeCard.vue @@ -0,0 +1,163 @@ + + + + + diff --git a/shell/pages/c/_cluster/explorer/workload-dashboard/__tests__/composable.test.ts b/shell/pages/c/_cluster/explorer/workload-dashboard/__tests__/composable.test.ts new file mode 100644 index 00000000000..27facbdbc72 --- /dev/null +++ b/shell/pages/c/_cluster/explorer/workload-dashboard/__tests__/composable.test.ts @@ -0,0 +1,363 @@ +import { useWorkloadDashboard } from '@shell/pages/c/_cluster/explorer/workload-dashboard/composable'; +import { WORKLOAD_RESOURCE_TYPES } from '@shell/pages/c/_cluster/explorer/workload-dashboard/types'; +import { defineComponent, h } from 'vue'; +import { shallowMount, flushPromises } from '@vue/test-utils'; + +const mockGetters: Record = {}; +const mockDispatch = jest.fn(); + +jest.mock('vuex', () => ({ + useStore: () => ({ + getters: new Proxy(mockGetters, { + get(target, prop: string) { + return target[prop]; + }, + }), + dispatch: mockDispatch, + }), +})); + +jest.mock('@shell/composables/useI18n', () => ({ useI18n: () => ({ t: (key: string, args?: Record) => `%${ key }%${ args ? JSON.stringify(args) : '' }` }) })); + +jest.mock('@shell/plugins/steve/steve-pagination-utils', () => ({ + __esModule: true, + default: { + createParamsFromNsFilter: jest.fn(() => ({ projectsOrNamespaces: [], filters: [] })), + createParamsForPagination: jest.fn(() => ''), + }, +})); + +jest.mock('@shell/plugins/steve/projectAndNamespaceFiltering.utils', () => ({ + __esModule: true, + default: { createParam: jest.fn(() => '') }, +})); + +const defaultGetters: Record = { + clusterId: 'local', + isAllNamespaces: true, + namespaceFilters: [], + namespaceMode: 'both', + 'prefs/get': () => ({}), + 'cluster/all': () => [], + 'cluster/schemaFor': () => null, + currentCluster: { isLocal: true }, + currentProduct: { hideSystemResources: false }, + 'management/all': () => [], +}; + +Object.assign(mockGetters, defaultGetters); + +function setupGetters(overrides: Record = {}) { + Object.keys(mockGetters).forEach((key) => delete mockGetters[key]); + Object.assign(mockGetters, defaultGetters, overrides); +} + +const summaryResponse = { + summary: [{ property: 'metadata.state.name', counts: { running: 5, error: 2 } }], + data: [], +}; + +function mountComposable(getterOverrides: Record = {}) { + setupGetters({ + 'cluster/schemaFor': () => ({ links: { collection: '/v1/test' } }), + 'cluster/canList': () => true, + ...getterOverrides, + }); + + mockDispatch.mockImplementation((action: string) => { + if (action === 'cluster/request') { + return Promise.resolve(summaryResponse); + } + + return Promise.resolve(); + }); + + let result: ReturnType; + + const wrapper = shallowMount(defineComponent({ + setup() { + result = useWorkloadDashboard(); + + return {}; + }, + render: () => h('div'), + })); + + return { + wrapper, + get result() { + return result!; + }, + }; +} + +describe('composable: useWorkloadDashboard', () => { + beforeEach(() => { + jest.clearAllMocks(); + setupGetters(); + }); + + describe('namespaceSubtitle', () => { + it('should return allNamespaces subtitle when isAllNamespaces is true', async() => { + const { wrapper, result } = mountComposable({ isAllNamespaces: true, namespaceMode: 'both' }); + + await flushPromises(); + + expect(result.namespaceSubtitle.value).toStrictEqual('%workloadDashboard.subtitle.allNamespaces%{"count":42}'); + wrapper.unmount(); + }); + + it('should return userNamespaces subtitle for ALL_USER filter', async() => { + const { wrapper, result } = mountComposable({ + isAllNamespaces: false, + namespaceMode: 'both', + namespaceFilters: ['all://user'], + }); + + await flushPromises(); + + expect(result.namespaceSubtitle.value).toStrictEqual('%workloadDashboard.subtitle.userNamespaces%{"count":42}'); + wrapper.unmount(); + }); + + it('should return systemNamespaces subtitle for ALL_SYSTEM filter', async() => { + const { wrapper, result } = mountComposable({ + isAllNamespaces: false, + namespaceMode: 'both', + namespaceFilters: ['all://system'], + }); + + await flushPromises(); + + expect(result.namespaceSubtitle.value).toStrictEqual('%workloadDashboard.subtitle.systemNamespaces%{"count":42}'); + wrapper.unmount(); + }); + + it('should return project subtitle for project filter', async() => { + const projectId = 'p-12345'; + + const { wrapper, result } = mountComposable({ + isAllNamespaces: false, + namespaceMode: 'both', + namespaceFilters: [`project://${ projectId }`], + 'management/all': () => [{ + id: `local/${ projectId }`, nameDisplay: 'My Project', metadata: { name: projectId } + }], + }); + + await flushPromises(); + + expect(result.namespaceSubtitle.value).toStrictEqual(`%workloadDashboard.subtitle.project%{"name":"My Project","count":42}`); + wrapper.unmount(); + }); + + it('should return namespace subtitle for namespace filter', async() => { + const { wrapper, result } = mountComposable({ + isAllNamespaces: false, + namespaceMode: 'both', + namespaceFilters: ['ns://cattle-system'], + }); + + await flushPromises(); + + expect(result.namespaceSubtitle.value).toStrictEqual('%workloadDashboard.subtitle.namespace%{"name":"cattle-system","count":42}'); + wrapper.unmount(); + }); + + it('should return multipleSelected subtitle for multiple filters', async() => { + const { wrapper, result } = mountComposable({ + isAllNamespaces: false, + namespaceMode: 'both', + namespaceFilters: ['ns://default', 'ns://kube-system'], + }); + + await flushPromises(); + + expect(result.namespaceSubtitle.value).toStrictEqual('%workloadDashboard.subtitle.multipleSelected%{"selected":2,"count":42}'); + wrapper.unmount(); + }); + }); + + describe('hasWorkloads', () => { + it('should return false when there are no summaries', async() => { + const { wrapper, result } = mountComposable({ 'cluster/canList': () => false }); + + await flushPromises(); + + expect(result.hasWorkloads.value).toStrictEqual(false); + wrapper.unmount(); + }); + + it('should return true when summaries contain workloads', async() => { + const { wrapper, result } = mountComposable(); + + await flushPromises(); + + expect(result.hasWorkloads.value).toStrictEqual(true); + wrapper.unmount(); + }); + }); + + describe('resourceRoute', () => { + it('should return route without query when no stateNames provided', async() => { + const { wrapper, result } = mountComposable(); + + await flushPromises(); + const route = result.resourceRoute('apps.deployment'); + + expect(route).toStrictEqual({ + name: 'c-cluster-product-resource', + params: { + cluster: 'local', + product: 'explorer', + resource: 'apps.deployment', + }, + }); + wrapper.unmount(); + }); + + it('should include state filter query when stateNames are provided', async() => { + const { wrapper, result } = mountComposable(); + + await flushPromises(); + const route = result.resourceRoute('apps.deployment', ['running', 'active']); + + expect((route as any).query).toStrictEqual({ q: '"metadata.state.name":"running","metadata.state.name":"active"' }); + wrapper.unmount(); + }); + + it('should not include query for empty stateNames array', async() => { + const { wrapper, result } = mountComposable(); + + await flushPromises(); + const route = result.resourceRoute('apps.deployment', []); + + expect((route as any).query).toBeUndefined(); + wrapper.unmount(); + }); + }); + + describe('resetNamespaceFilter', () => { + it('should dispatch switchNamespaces with empty ids', async() => { + const { wrapper, result } = mountComposable(); + + await flushPromises(); + result.resetNamespaceFilter(); + + expect(mockDispatch).toHaveBeenCalledWith('switchNamespaces', { ids: [], key: 'local' }); + wrapper.unmount(); + }); + }); + + describe('byTypeCards', () => { + it('should return a card for each workload type with data', async() => { + const { wrapper, result } = mountComposable(); + + await flushPromises(); + + expect(result.byTypeCards.value).toHaveLength(WORKLOAD_RESOURCE_TYPES.length); + wrapper.unmount(); + }); + + it('should include type and title on each card', async() => { + const { wrapper, result } = mountComposable(); + + await flushPromises(); + + const card = result.byTypeCards.value[0]; + + expect(card.type).toStrictEqual(WORKLOAD_RESOURCE_TYPES[0]); + expect(card.title).toBeTruthy(); + wrapper.unmount(); + }); + + it('should map resource states with correct colors', async() => { + const { wrapper, result } = mountComposable(); + + await flushPromises(); + + const card = result.byTypeCards.value[0]; + const colors = card.resources.map((r) => r.stateSimpleColor); + + expect(colors).toContain('success'); + expect(colors).toContain('error'); + wrapper.unmount(); + }); + + it('should capitalize stateDisplay for each resource', async() => { + const { wrapper, result } = mountComposable(); + + await flushPromises(); + + const card = result.byTypeCards.value[0]; + const runningResource = card.resources.find((r) => r.stateId === 'running'); + + expect(runningResource?.stateDisplay).toStrictEqual('Running'); + wrapper.unmount(); + }); + + it('should preserve counts from the summary response', async() => { + const { wrapper, result } = mountComposable(); + + await flushPromises(); + + const card = result.byTypeCards.value[0]; + const runningResource = card.resources.find((r) => r.stateId === 'running'); + const errorResource = card.resources.find((r) => r.stateId === 'error'); + + expect(runningResource?.count).toStrictEqual(5); + expect(errorResource?.count).toStrictEqual(2); + wrapper.unmount(); + }); + }); + + describe('byStateLayout', () => { + it('should assign the success card as hero', async() => { + const { wrapper, result } = mountComposable(); + + await flushPromises(); + + expect(result.byStateLayout.value.hero?.color).toStrictEqual('success'); + wrapper.unmount(); + }); + + it('should place non-hero cards in the cards array', async() => { + const { wrapper, result } = mountComposable(); + + await flushPromises(); + + const { cards } = result.byStateLayout.value; + + expect(cards.length).toBeGreaterThan(0); + expect(cards.every((c) => c.color !== 'success')).toStrictEqual(true); + wrapper.unmount(); + }); + + it('should set heroMode to wide when there is exactly one other card', async() => { + const { wrapper, result } = mountComposable(); + + await flushPromises(); + + expect(result.byStateLayout.value.heroMode).toStrictEqual('wide'); + wrapper.unmount(); + }); + + it('should set subHero to null when there are fewer than 3 other cards', async() => { + const { wrapper, result } = mountComposable(); + + await flushPromises(); + + expect(result.byStateLayout.value.subHero).toBeNull(); + wrapper.unmount(); + }); + + it('should set gridRows to 1 when there is one non-hero card', async() => { + const { wrapper, result } = mountComposable(); + + await flushPromises(); + + expect(result.byStateLayout.value.gridRows).toStrictEqual(1); + wrapper.unmount(); + }); + }); +}); diff --git a/shell/pages/c/_cluster/explorer/workload-dashboard/composable.ts b/shell/pages/c/_cluster/explorer/workload-dashboard/composable.ts new file mode 100644 index 00000000000..92c38e09c5d --- /dev/null +++ b/shell/pages/c/_cluster/explorer/workload-dashboard/composable.ts @@ -0,0 +1,367 @@ +import { + ref, computed, watch, onMounted, onBeforeUnmount +} from 'vue'; +import { useStore } from 'vuex'; +import type { RouteLocationRaw } from 'vue-router'; +import { NAMESPACE } from '@shell/config/types'; +import type { StateColor } from '@shell/utils/style'; +import { useI18n } from '@shell/composables/useI18n'; +import { useStateColor } from '@shell/composables/useStateColor'; +import { ALL_NAMESPACES } from '@shell/store/prefs'; +import { + NAMESPACE_FILTER_ALL_USER, + NAMESPACE_FILTER_ALL_SYSTEM, + NAMESPACE_FILTER_P_FULL_PREFIX, + NAMESPACE_FILTER_NS_FULL_PREFIX, +} from '@shell/utils/namespace-filter'; +import stevePaginationUtils from '@shell/plugins/steve/steve-pagination-utils'; +import { + WORKLOAD_RESOURCE_TYPES, COLOR_ORDER, + type WorkloadDashboardSummaryEntry, + type WorkloadDashboardEntry, + type WorkloadDashboardStateCard, + type WorkloadDashboardByStateLayout, + type WorkloadDashboardByTypeCard, +} from './types'; + +export function useWorkloadDashboard() { + const store = useStore(); + const { t } = useI18n(store); + const { toStateColor, resolveStateColors } = useStateColor(); + + const summaries = ref([]); + const fetchError = ref(null); + const loading = ref(true); + let pollTimer: ReturnType | null = null; + + const clusterId = computed(() => store.getters['clusterId']); + + // ── Namespace filtering ── + + const isAllNamespaces = computed(() => store.getters['isAllNamespaces']); + + const namespaceFilterParam = ref(''); + + function buildNamespaceFilterParam(): string { + const selection: string[] = store.getters['namespaceFilters']; + const { projectsOrNamespaces, filters } = stevePaginationUtils.createParamsFromNsFilter({ + allNamespaces: store.getters['cluster/all'](NAMESPACE), + selection, + isAllNamespaces: isAllNamespaces.value, + isLocalCluster: store.getters['currentCluster']?.isLocal, + showReservedRancherNamespaces: store.getters['prefs/get'](ALL_NAMESPACES), + productHidesSystemNamespaces: store.getters['currentProduct']?.hideSystemResources, + }); + + // Getting the first schema is sufficient since the namespace filter param structure is the same across all resource types + const schema = WORKLOAD_RESOURCE_TYPES + .map((type) => store.getters['cluster/schemaFor'](type)) + .find((s) => !!s); + + // To generate proper params path to be used + const path = stevePaginationUtils.createParamsForPagination({ + schema, + opt: { + pagination: { + filters, + projectsOrNamespaces, + page: 1, + sort: [], + } + } + }) || ''; + + return path.replace(/page=\d+&?/g, '').replace(/pagesize=\d+&?/g, '').replace(/&$/, ''); + } + + watch(() => store.getters['namespaceFilters'], () => { + namespaceFilterParam.value = buildNamespaceFilterParam(); + }, { immediate: true }); + + // ── Subtitle ── + + const totalWorkloads = computed(() => { + return workloadData.value.reduce((sum, w) => sum + (w.error ? 0 : w.total), 0); + }); + + const namespaceSubtitle = computed(() => { + const count = totalWorkloads.value; + const filters: string[] = store.getters['namespaceFilters']; + + if (isAllNamespaces.value) { + return t('workloadDashboard.subtitle.allNamespaces', { count }); + } + + if (filters.length === 1) { + const filter = filters[0]; + + if (filter === NAMESPACE_FILTER_ALL_USER) { + return t('workloadDashboard.subtitle.userNamespaces', { count }); + } + + if (filter === NAMESPACE_FILTER_ALL_SYSTEM) { + return t('workloadDashboard.subtitle.systemNamespaces', { count }); + } + + if (filter.startsWith(NAMESPACE_FILTER_P_FULL_PREFIX)) { + const projectId = filter.replace(NAMESPACE_FILTER_P_FULL_PREFIX, ''); + const projects = store.getters['management/all']('management.cattle.io.project'); + const project = projects.find((p: { id?: string; nameDisplay?: string; metadata?: { name: string } }) => p.id?.endsWith(`/${ projectId }`) || p.metadata?.name === projectId); + + return t('workloadDashboard.subtitle.project', { name: project?.nameDisplay || projectId, count }); + } + + if (filter.startsWith(NAMESPACE_FILTER_NS_FULL_PREFIX)) { + const name = filter.replace(NAMESPACE_FILTER_NS_FULL_PREFIX, ''); + + return t('workloadDashboard.subtitle.namespace', { name, count }); + } + } + + return t('workloadDashboard.subtitle.multipleSelected', { selected: filters.length, count }); + }); + + // ── Workload data ── + + const workloadData = computed(() => { + return summaries.value.map((entry) => { + const label = t(`typeLabel."${ entry.type }"`, { count: 2 })?.trim() || entry.type; + const stateCounts: Record = {}; + let total = 0; + + if (entry.summary) { + for (const s of entry.summary) { + if (s.property === 'metadata.state.name') { + Object.assign(stateCounts, s.counts); + total = Object.values(s.counts).reduce((sum, c) => sum + c, 0); + } + } + } + + return { + type: entry.type, + label, + total, + stateCounts, + error: entry.error, + }; + }); + }); + + const hasWorkloads = computed(() => { + return workloadData.value.some((w) => !w.error && w.total > 0); + }); + + // ── By State cards ── + + const byStateCards = computed(() => { + const colorGroups: Record }>> = { + error: {}, + warning: {}, + info: {}, + success: {}, + disabled: {}, + }; + + for (const w of workloadData.value) { + if (w.error || w.total === 0) { + continue; + } + + for (const [state, count] of Object.entries(w.stateCounts)) { + const color = toStateColor(state); + + if (!colorGroups[color][w.label]) { + colorGroups[color][w.label] = { + count: 0, type: w.type, stateNames: new Set() + }; + } + colorGroups[color][w.label].count += count; + colorGroups[color][w.label].stateNames.add(state); + } + } + + return Object.entries(colorGroups) + .filter(([, typeMap]) => Object.keys(typeMap).length > 0) + .map(([color, typeMap]) => ({ + color: color as StateColor, + rows: Object.entries(typeMap).map(([label, { count, type, stateNames }]) => ({ + label, + color: color as StateColor, + type, + stateNames: Array.from(stateNames), + counts: [{ label: '', count }], + })), + })); + }); + + const byStateLayout = computed(() => { + const cards = byStateCards.value; + const hero = cards.find((c) => c.color === 'success') || null; + const others = cards.filter((c) => c !== hero); + + let heroMode: 'default' | 'full' | 'wide' = 'default'; + + if (cards.length === 1) { + heroMode = 'full'; + } else if (others.length === 1) { + heroMode = 'wide'; + } + + const subHero = (hero && others.length >= 3) ? others.find((c) => c.color === 'info') || null : null; + + const regularCards = subHero ? others.filter((c) => c !== subHero) : others; + const gridRows = subHero ? Math.max(1, regularCards.length) : Math.max(1, Math.ceil(regularCards.length / 2)); + + return { + hero, + subHero, + cards: regularCards, + heroMode, + gridRows, + }; + }); + + // ── By Type cards ── + + const byTypeCards = computed(() => { + return workloadData.value.filter((w) => !w.error && w.total > 0).map((w) => { + const resources = Object.entries(w.stateCounts) + .sort(([a], [b]) => (COLOR_ORDER[toStateColor(a)] ?? 5) - (COLOR_ORDER[toStateColor(b)] ?? 5)) + .map(([state, count]) => ({ + stateDisplay: state ? state.charAt(0).toUpperCase() + state.slice(1) : '', + stateId: state, + stateSimpleColor: toStateColor(state), + count, + })); + + return { + title: w.label, + type: w.type, + resources, + }; + }); + }); + + // ── Actions ── + + function resetNamespaceFilter(): void { + store.dispatch('switchNamespaces', { + ids: [], + key: store.getters['clusterId'], + }); + } + + function resourceRoute(type: string, stateNames?: string[]): RouteLocationRaw { + const loc: { name: string; params: Record; query?: Record } = { + name: 'c-cluster-product-resource', + params: { + cluster: clusterId.value, + product: 'explorer', + resource: type, + }, + }; + + if (stateNames?.length) { + const q = stateNames.map((s) => `"metadata.state.name":"${ s }"`).join(','); + + loc.query = { q }; + } + + return loc; + } + + // ── Fetching & polling ── + + async function fetchSummaries(): Promise { + try { + const accessibleTypes = WORKLOAD_RESOURCE_TYPES.filter( + (type) => store.getters['cluster/canList'](type) + ); + + const workloadPromises = accessibleTypes.map(async(type): Promise => { + const schema = store.getters['cluster/schemaFor'](type); + + try { + let url = `${ schema.links.collection }?summary=metadata.state.name`; + + if (namespaceFilterParam.value) { + url += `&${ namespaceFilterParam.value }`; + } + + const res = await store.dispatch('cluster/request', { url }); + + return { + type, summary: res.summary || [], error: null + }; + } catch (e: unknown) { + const message = e instanceof Error ? e.message : t('workloadDashboard.errors.fetchType', { type }); + + return { + type, summary: null, error: message + }; + } + }); + + const results = await Promise.all(workloadPromises); + + await resolveStateColors(results); + summaries.value = results; + fetchError.value = null; + } catch (e: unknown) { + fetchError.value = e instanceof Error ? e.message : t('workloadDashboard.errors.fetchAll'); + } + } + + function stopPollTimer(): void { + if (pollTimer) { + clearInterval(pollTimer); + pollTimer = null; + } + } + + function startPollTimer(): void { + stopPollTimer(); + + pollTimer = setInterval(() => { + fetchSummaries(); + }, 5000); + } + + function handleVisibilityChange(): void { + if (document.hidden) { + stopPollTimer(); + } else { + fetchSummaries(); + startPollTimer(); + } + } + + watch(namespaceFilterParam, () => { + fetchSummaries(); + startPollTimer(); + }); + + onMounted(async() => { + await fetchSummaries(); + loading.value = false; + startPollTimer(); + document.addEventListener('visibilitychange', handleVisibilityChange); + }); + + onBeforeUnmount(() => { + stopPollTimer(); + document.removeEventListener('visibilitychange', handleVisibilityChange); + }); + + return { + loading, + fetchError, + hasWorkloads, + namespaceSubtitle, + byStateLayout, + byTypeCards, + resetNamespaceFilter, + resourceRoute, + }; +} diff --git a/shell/pages/c/_cluster/explorer/workload-dashboard/index.vue b/shell/pages/c/_cluster/explorer/workload-dashboard/index.vue new file mode 100644 index 00000000000..f8695112426 --- /dev/null +++ b/shell/pages/c/_cluster/explorer/workload-dashboard/index.vue @@ -0,0 +1,173 @@ + + + + + diff --git a/shell/pages/c/_cluster/explorer/workload-dashboard/types.ts b/shell/pages/c/_cluster/explorer/workload-dashboard/types.ts new file mode 100644 index 00000000000..9e6b0bff6fe --- /dev/null +++ b/shell/pages/c/_cluster/explorer/workload-dashboard/types.ts @@ -0,0 +1,64 @@ +import { WORKLOAD_TYPES, POD } from '@shell/config/types'; +import type { StateColor } from '@shell/utils/style'; +import type { RouteLocationRaw } from 'vue-router'; + +export interface WorkloadDashboardSummaryEntry { + type: string; + summary: { property: string; counts: Record }[] | null; + error: string | null; +} + +export interface WorkloadDashboardEntry { + type: string; + label: string; + total: number; + stateCounts: Record; + error: string | null; +} + +export interface WorkloadDashboardStateCardRow { + label: string; + color: StateColor; + type: string; + stateNames: string[]; + counts: { label: string; count: number }[]; +} + +export interface WorkloadDashboardStateCard { + color: StateColor; + rows: WorkloadDashboardStateCardRow[]; +} + +export interface WorkloadDashboardByStateLayout { + hero: WorkloadDashboardStateCard | null; + subHero: WorkloadDashboardStateCard | null; + cards: WorkloadDashboardStateCard[]; + heroMode: 'default' | 'full' | 'wide'; + gridRows: number; +} + +export interface WorkloadDashboardByTypeCard { + title: string; + type: string; + resources: { + stateDisplay: string; + stateId: string; + stateSimpleColor: StateColor; + count: number; + }[]; +} + +export type WorkloadDashboardResourceRouteFn = (type: string, stateNames?: string[]) => RouteLocationRaw; + +export const WORKLOAD_RESOURCE_TYPES: string[] = [ + WORKLOAD_TYPES.CRON_JOB, + WORKLOAD_TYPES.DAEMON_SET, + WORKLOAD_TYPES.DEPLOYMENT, + WORKLOAD_TYPES.JOB, + WORKLOAD_TYPES.STATEFUL_SET, + POD, +]; + +export const COLOR_ORDER: Record = { + error: 0, warning: 1, disabled: 2, info: 3, success: 4 +};