diff --git a/.gitignore b/.gitignore index f3bfd2ff04..686246b970 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,6 @@ kubeconfig* # husky managed hooks .husky/user-config + +# Claude Code local settings +.claude/settings.local.json diff --git a/backend/__tests__/acceptance/api.members.spec.js b/backend/__tests__/acceptance/api.members.spec.js index 37f60d8404..f1bb90a571 100644 --- a/backend/__tests__/acceptance/api.members.spec.js +++ b/backend/__tests__/acceptance/api.members.spec.js @@ -13,6 +13,7 @@ import { beforeEach, } from 'vitest' import request from '@gardener-dashboard/request' +import { seedProjectNamespaceIndex } from '../helpers/cache.js' const { mockRequest } = request @@ -21,6 +22,7 @@ describe('api', function () { beforeAll(async () => { agent = await createAgent() + seedProjectNamespaceIndex() }) afterAll(() => { diff --git a/backend/__tests__/acceptance/api.seedstats.spec.js b/backend/__tests__/acceptance/api.seedstats.spec.js new file mode 100644 index 0000000000..7f8b3e37cf --- /dev/null +++ b/backend/__tests__/acceptance/api.seedstats.spec.js @@ -0,0 +1,163 @@ +// +// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Gardener contributors +// +// SPDX-License-Identifier: Apache-2.0 +// + +import { + describe, + it, + expect, + beforeAll, + afterAll, + beforeEach, +} from 'vitest' +import request from '@gardener-dashboard/request' +import { Store } from '@gardener-dashboard/kube-client' +import cache from '../../lib/cache/index.js' + +const { mockRequest } = request + +function createStore (items) { + const store = new Store() + store.replace(items) + return store +} + +function seedShootsBySeedNameIndex (shoots = fixtures.shoots.list()) { + const handlers = new Map() + cache.indexShootsBySeedName({ + on (event, handler) { + handlers.set(event, handler) + }, + }) + const add = handlers.get('add') + for (const shoot of shoots) { + add(shoot) + } +} + +describe('api', function () { + let agent + + beforeAll(async () => { + agent = await createAgent() + + const shoots = fixtures.shoots.list() + + cache.initialize({ + seeds: { + store: createStore(fixtures.seeds.list()), + }, + shoots: { + store: createStore(shoots), + }, + }) + seedShootsBySeedNameIndex(shoots) + }) + + afterAll(() => { + return agent.close() + }) + + beforeEach(() => { + mockRequest.mockReset() + }) + + describe('seedstats', function () { + const user = fixtures.auth.createUser({ id: 'john.doe@example.org' }) + + it('should return stats for all seeds', async function () { + mockRequest + .mockImplementationOnce(fixtures.auth.mocks.reviewSelfSubjectAccess()) + .mockImplementationOnce(fixtures.auth.mocks.reviewSelfSubjectAccess()) + + const res = await agent + .get('/api/seedstats?unhealthyFilterMask=0') + .set('cookie', await user.cookie) + .expect('content-type', /json/) + .expect(200) + + expect(mockRequest).toHaveBeenCalledTimes(2) + expect(res.body).toEqual(expect.arrayContaining([ + expect.objectContaining({ + apiVersion: 'dashboard.gardener.cloud/v1alpha1', + kind: 'SeedStat', + metadata: expect.objectContaining({ name: 'infra1-seed' }), + counts: { + shootCount: 3, + unhealthyShoots: { + total: 0, + matching: 0, + }, + }, + }), + ])) + }) + + it('should return stats for one seed', async function () { + mockRequest + .mockImplementationOnce(fixtures.auth.mocks.reviewSelfSubjectAccess()) + .mockImplementationOnce(fixtures.auth.mocks.reviewSelfSubjectAccess()) + + const res = await agent + .get('/api/seedstats/infra1-seed?unhealthyFilterMask=0') + .set('cookie', await user.cookie) + .expect('content-type', /json/) + .expect(200) + + expect(mockRequest).toHaveBeenCalledTimes(2) + expect(res.body).toEqual(expect.objectContaining({ + metadata: { + name: 'infra1-seed', + uid: 'seed--infra1-seed', + }, + counts: { + shootCount: 3, + unhealthyShoots: { + total: 0, + matching: 0, + }, + }, + })) + }) + + it('should forbid listing seed stats without full access', async function () { + mockRequest + .mockImplementationOnce(fixtures.auth.mocks.reviewSelfSubjectAccess()) + .mockImplementationOnce(fixtures.auth.mocks.reviewSelfSubjectAccess({ allowed: false })) + + const res = await agent + .get('/api/seedstats?unhealthyFilterMask=0') + .set('cookie', await user.cookie) + .expect('content-type', /json/) + .expect(403) + + expect(mockRequest).toHaveBeenCalledTimes(2) + expect(res.body).toEqual(expect.objectContaining({ + code: 403, + reason: 'Forbidden', + message: 'You are not allowed to list seed stats', + })) + }) + + it('should reject requests without unhealthyFilterMask', async function () { + mockRequest + .mockImplementationOnce(fixtures.auth.mocks.reviewSelfSubjectAccess()) + .mockImplementationOnce(fixtures.auth.mocks.reviewSelfSubjectAccess()) + + const res = await agent + .get('/api/seedstats') + .set('cookie', await user.cookie) + .expect('content-type', /json/) + .expect(422) + + expect(mockRequest).toHaveBeenCalledTimes(2) + expect(res.body).toEqual(expect.objectContaining({ + code: 422, + reason: 'Unprocessable Entity', + message: "The 'unhealthyFilterMask' query parameter must be a non-negative integer with no bits set outside the known flags (0–7)", + })) + }) + }) +}) diff --git a/backend/__tests__/acceptance/api.terminals.spec.js b/backend/__tests__/acceptance/api.terminals.spec.js index 48833e876e..90d95fba67 100644 --- a/backend/__tests__/acceptance/api.terminals.spec.js +++ b/backend/__tests__/acceptance/api.terminals.spec.js @@ -15,6 +15,7 @@ import { } from 'vitest' import { padStart } from 'lodash-es' import request from '@gardener-dashboard/request' +import { seedProjectNamespaceIndex } from '../helpers/cache.js' import { converter } from '../../lib/services/terminals/index.js' const { mockRequest } = request @@ -32,6 +33,7 @@ describe('api', function () { beforeAll(async () => { agent = await createAgent() + seedProjectNamespaceIndex() }) afterAll(() => { diff --git a/backend/__tests__/acceptance/api.tickets.spec.js b/backend/__tests__/acceptance/api.tickets.spec.js index f3f24cf72a..65c47c129e 100644 --- a/backend/__tests__/acceptance/api.tickets.spec.js +++ b/backend/__tests__/acceptance/api.tickets.spec.js @@ -19,6 +19,7 @@ import { mockListIssues, mockListComments, } from '@octokit/core' +import { seedProjectNamespaceIndex } from '../helpers/cache.js' import * as tickets from '../../lib/services/tickets.js' const { mockRequest } = request @@ -28,6 +29,7 @@ describe('api', function () { beforeAll(async () => { agent = await createAgent() + seedProjectNamespaceIndex() }) afterAll(() => { diff --git a/backend/__tests__/acceptance/io.cors.spec.js b/backend/__tests__/acceptance/io.cors.spec.js index 52ef9f4f6a..4dce513e6c 100644 --- a/backend/__tests__/acceptance/io.cors.spec.js +++ b/backend/__tests__/acceptance/io.cors.spec.js @@ -43,6 +43,7 @@ describe('cors', () => { .mockImplementationOnce(fixtures.auth.mocks.reviewSelfSubjectAccess()) .mockImplementationOnce(fixtures.auth.mocks.reviewSelfSubjectAccess()) .mockImplementationOnce(fixtures.auth.mocks.reviewSelfSubjectAccess()) + .mockImplementationOnce(fixtures.auth.mocks.reviewSelfSubjectAccess()) await expect(createAgent('io', cache)) .rejects.toThrow('WebSocket allowed origins configuration is required') }) @@ -54,6 +55,7 @@ describe('cors', () => { .mockImplementationOnce(fixtures.auth.mocks.reviewSelfSubjectAccess()) .mockImplementationOnce(fixtures.auth.mocks.reviewSelfSubjectAccess()) .mockImplementationOnce(fixtures.auth.mocks.reviewSelfSubjectAccess()) + .mockImplementationOnce(fixtures.auth.mocks.reviewSelfSubjectAccess()) agent = await createAgent('io', cache) const cookie = await user.cookie await expect( @@ -68,6 +70,7 @@ describe('cors', () => { .mockImplementationOnce(fixtures.auth.mocks.reviewSelfSubjectAccess()) .mockImplementationOnce(fixtures.auth.mocks.reviewSelfSubjectAccess()) .mockImplementationOnce(fixtures.auth.mocks.reviewSelfSubjectAccess()) + .mockImplementationOnce(fixtures.auth.mocks.reviewSelfSubjectAccess()) agent = await createAgent('io', cache) const cookie = await user.cookie await expect( @@ -82,6 +85,7 @@ describe('cors', () => { .mockImplementationOnce(fixtures.auth.mocks.reviewSelfSubjectAccess()) .mockImplementationOnce(fixtures.auth.mocks.reviewSelfSubjectAccess()) .mockImplementationOnce(fixtures.auth.mocks.reviewSelfSubjectAccess()) + .mockImplementationOnce(fixtures.auth.mocks.reviewSelfSubjectAccess()) agent = await createAgent('io', cache) const cookie = await user.cookie socket = await agent.connect({ cookie, originHeader: 'https://allowed.example.org' }) @@ -95,6 +99,7 @@ describe('cors', () => { .mockImplementationOnce(fixtures.auth.mocks.reviewSelfSubjectAccess()) .mockImplementationOnce(fixtures.auth.mocks.reviewSelfSubjectAccess()) .mockImplementationOnce(fixtures.auth.mocks.reviewSelfSubjectAccess()) + .mockImplementationOnce(fixtures.auth.mocks.reviewSelfSubjectAccess()) agent = await createAgent('io', cache) const cookie = await user.cookie socket = await agent.connect({ cookie, originHeader: 'https://any.example.org' }) diff --git a/backend/__tests__/acceptance/io.spec.js b/backend/__tests__/acceptance/io.spec.js index 273dc962e1..a4b407e2e0 100644 --- a/backend/__tests__/acceptance/io.spec.js +++ b/backend/__tests__/acceptance/io.spec.js @@ -88,6 +88,19 @@ function createStore (items) { return store } +function seedShootsBySeedNameIndex (shoots = fixtures.shoots.list()) { + const handlers = new Map() + cache.indexShootsBySeedName({ + on (event, handler) { + handlers.set(event, handler) + }, + }) + const add = handlers.get('add') + for (const shoot of shoots) { + add(shoot) + } +} + function notFoundStatus ({ group, kind, uid }) { return { kind: 'Status', @@ -111,23 +124,27 @@ describe('api', function () { beforeAll(async () => { cache.cache.resetTicketCache() + const shoots = [ + ...fixtures.shoots.list(), + fixtures.shoots.create({ + uid: 5, + name: 'orphan-shoot', + namespace: 'garden', + project: 'garden', + createdBy: 'admin@example.org', + secretBindingName: 'soil-orphan', + seed: 'soil-orphan', + }), + ] cache.initialize({ projects: { store: createStore(fixtures.projects.list()), }, + seeds: { + store: createStore(fixtures.seeds.list()), + }, shoots: { - store: createStore([ - ...fixtures.shoots.list(), - fixtures.shoots.create({ - uid: 5, - name: 'orphan-shoot', - namespace: 'garden', - project: 'garden', - createdBy: 'admin@example.org', - secretBindingName: 'soil-orphan', - seed: 'soil-orphan', - }), - ]), + store: createStore(shoots), }, managedseeds: { store: createStore([ @@ -148,6 +165,7 @@ describe('api', function () { ]), }, }) + seedShootsBySeedNameIndex(shoots) agent = await createAgent('io', cache) nsp = agent.io.sockets }) @@ -185,26 +203,52 @@ describe('api', function () { let args beforeEach(async () => { - // authorization check for `canListProjects`, `canListSeeds`, + // authorization check for `canListProjects`, `canListSeeds`, `canListShoots`, // `canListManagedSeedsInGardenNamespace`, and `canListShootsInGardenNamespace` mockRequest .mockImplementationOnce(fixtures.auth.mocks.reviewSelfSubjectAccess()) .mockImplementationOnce(fixtures.auth.mocks.reviewSelfSubjectAccess()) .mockImplementationOnce(fixtures.auth.mocks.reviewSelfSubjectAccess()) .mockImplementationOnce(fixtures.auth.mocks.reviewSelfSubjectAccess()) + .mockImplementationOnce(fixtures.auth.mocks.reviewSelfSubjectAccess()) socket = await agent.connect({ cookie: await user.cookie, }) defaultRooms = [ socket.id, ioHelper.sha256(username), + 'seeds', 'managedseeds;garden', 'managedseed-shoots;garden', ] - expect(mockRequest).toHaveBeenCalledTimes(4) + expect(mockRequest).toHaveBeenCalledTimes(5) mockRequest.mockClear() }) + it('should subscribe seedstats for all seeds', async function () { + await subscribe(socket, 'seedstats', { unhealthyFilterMask: 0 }) + + expect(mockRequest).not.toHaveBeenCalled() + expect(getRooms(socket, nsp)).toEqual(new Set([ + ...defaultRooms, + 'seedstats;uf=0', + ])) + + await unsubscribe(socket, 'seedstats') + expect(getRooms(socket, nsp)).toEqual(new Set(defaultRooms)) + }) + + it('should re-subscribe seedstats for a single seed', async function () { + await subscribe(socket, 'seedstats', { unhealthyFilterMask: 0 }) + await subscribe(socket, 'seedstats', { name: 'infra1-seed', unhealthyFilterMask: 7 }) + + expect(mockRequest).not.toHaveBeenCalled() + expect(getRooms(socket, nsp)).toEqual(new Set([ + ...defaultRooms, + 'seedstats;seed=infra1-seed;uf=7', + ])) + }) + it('should subscribe shoots for a single cluster', async function () { mockRequest.mockImplementationOnce(fixtures.auth.mocks.reviewSelfSubjectAccess()) @@ -353,6 +397,7 @@ describe('api', function () { it('should fail to synchronize managed seeds without garden access', async function () { mockRequest + .mockImplementationOnce(fixtures.auth.mocks.reviewSelfSubjectAccess()) .mockImplementationOnce(fixtures.auth.mocks.reviewSelfSubjectAccess()) .mockImplementationOnce(fixtures.auth.mocks.reviewSelfSubjectAccess()) .mockImplementationOnce(fixtures.auth.mocks.reviewSelfSubjectAccess({ allowed: false })) @@ -378,6 +423,7 @@ describe('api', function () { it('should fail to synchronize managed seed shoots without garden access', async function () { mockRequest + .mockImplementationOnce(fixtures.auth.mocks.reviewSelfSubjectAccess()) .mockImplementationOnce(fixtures.auth.mocks.reviewSelfSubjectAccess()) .mockImplementationOnce(fixtures.auth.mocks.reviewSelfSubjectAccess()) .mockImplementationOnce(fixtures.auth.mocks.reviewSelfSubjectAccess({ allowed: false })) @@ -410,26 +456,38 @@ describe('api', function () { let defaultRooms beforeEach(async () => { - // authorization check for `canListProjects`, `canListSeeds`, + // authorization check for `canListProjects`, `canListSeeds`, `canListShoots`, // `canListManagedSeedsInGardenNamespace`, and `canListShootsInGardenNamespace` mockRequest .mockImplementationOnce(fixtures.auth.mocks.reviewSelfSubjectAccess()) .mockImplementationOnce(fixtures.auth.mocks.reviewSelfSubjectAccess()) .mockImplementationOnce(fixtures.auth.mocks.reviewSelfSubjectAccess()) .mockImplementationOnce(fixtures.auth.mocks.reviewSelfSubjectAccess()) + .mockImplementationOnce(fixtures.auth.mocks.reviewSelfSubjectAccess()) socket = await agent.connect({ cookie: await user.cookie, }) defaultRooms = [ socket.id, ioHelper.sha256(username), + 'seeds', 'managedseeds;garden', 'managedseed-shoots;garden', ] - expect(mockRequest).toHaveBeenCalledTimes(4) + expect(mockRequest).toHaveBeenCalledTimes(5) mockRequest.mockClear() }) + it('should subscribe seedstats for all seeds', async function () { + await subscribe(socket, 'seedstats', { unhealthyFilterMask: 2 }) + + expect(mockRequest).not.toHaveBeenCalled() + expect(getRooms(socket, nsp)).toEqual(new Set([ + ...defaultRooms, + 'seedstats;uf=2', + ])) + }) + it('should subscribe shoots for a single cluster', async function () { mockRequest.mockImplementationOnce(fixtures.auth.mocks.reviewSelfSubjectAccess()) @@ -547,6 +605,27 @@ describe('api', function () { const items = await synchronize(socket, 'projects', [2]) expect(items).toMatchSnapshot() }) + + it('should synchronize seed stats', async function () { + const items = await synchronize(socket, 'seedstats', ['seed--infra1-seed'], { unhealthyFilterMask: 0 }) + expect(items).toEqual([ + { + apiVersion: 'dashboard.gardener.cloud/v1alpha1', + kind: 'SeedStat', + metadata: { + name: 'infra1-seed', + uid: 'seed--infra1-seed', + }, + counts: { + shootCount: 3, + unhealthyShoots: { + total: 0, + matching: 0, + }, + }, + }, + ]) + }) }) }) @@ -622,16 +701,18 @@ describe('api', function () { refresh_at: Math.ceil(Date.now() / 1000) + 4, } const user = fixtures.auth.createUser(options) - // authorization check for `canListProjects` and `canListSeeds` + // authorization check for `canListProjects`, `canListSeeds`, `canListShoots`, + // `canListManagedSeedsInGardenNamespace`, and `canListShootsInGardenNamespace` mockRequest .mockImplementationOnce(fixtures.auth.mocks.reviewSelfSubjectAccess()) .mockImplementationOnce(fixtures.auth.mocks.reviewSelfSubjectAccess()) .mockImplementationOnce(fixtures.auth.mocks.reviewSelfSubjectAccess()) .mockImplementationOnce(fixtures.auth.mocks.reviewSelfSubjectAccess()) + .mockImplementationOnce(fixtures.auth.mocks.reviewSelfSubjectAccess()) socket = await agent.connect({ cookie: await user.cookie, }) - expect(mockRequest).toHaveBeenCalledTimes(4) + expect(mockRequest).toHaveBeenCalledTimes(5) expect(mockSetDisconnectTimeout).toHaveBeenCalledTimes(1) expect(mockSetDisconnectTimeout.mock.calls[0]).toEqual([ expect.objectContaining({ diff --git a/backend/__tests__/cache.spec.js b/backend/__tests__/cache.spec.js index 5a427bc9d3..edfacc2344 100644 --- a/backend/__tests__/cache.spec.js +++ b/backend/__tests__/cache.spec.js @@ -120,5 +120,28 @@ describe('cache', function () { expect(cache.getTicketCache()).toBe(cache.ticketCache) }) }) + + describe('#getShootsBySeedName', function () { + it('should return an empty iterable when no shoots are indexed for the seed', function () { + expect(Array.from(cache.getShootsBySeedName('missing-seed'))).toEqual([]) + }) + + it('should return indexed shoots for the given seed name', function () { + const handlers = new Map() + cache.indexShootsBySeedName({ + on (event, handler) { + handlers.set(event, handler) + }, + }) + + const add = handlers.get('add') + for (const shoot of fixtures.shoots.list()) { + add(shoot) + } + + expect(Array.from(cache.getShootsBySeedName('infra1-seed'))).toHaveLength(3) + expect(Array.from(cache.getShootsBySeedName('soil-infra1'))).toHaveLength(1) + }) + }) }) }) diff --git a/backend/__tests__/cache.tickets.spec.js b/backend/__tests__/cache.tickets.spec.js index 76eacc81e0..bb6a872574 100644 --- a/backend/__tests__/cache.tickets.spec.js +++ b/backend/__tests__/cache.tickets.spec.js @@ -186,6 +186,39 @@ describe('cache', function () { }) }) + describe('#getIssuesForShoot', function () { + it('should return issues matching projectName and name', function () { + const result = cache.getIssuesForShoot({ projectName: 'test', name: 'foo' }) + expect(result).toEqual([firstIssue, secondIssue]) + expect(emitSpy).not.toHaveBeenCalled() + }) + + it('should return empty array for unknown shoot', function () { + const result = cache.getIssuesForShoot({ projectName: 'test', name: 'unknown' }) + expect(result).toEqual([]) + }) + + it('should update index when issue is removed', function () { + cache.removeIssue({ issue: firstIssue }) + const result = cache.getIssuesForShoot({ projectName: 'test', name: 'foo' }) + expect(result).toEqual([secondIssue]) + }) + + it('should update index when issue is updated with new shoot', function () { + const updatedIssue = { + metadata: { + number: 1, + updated_at: new Date(Date.now() + 60000).toISOString(), + name: 'bar', + projectName: 'test', + }, + } + cache.addOrUpdateIssue({ issue: updatedIssue }) + expect(cache.getIssuesForShoot({ projectName: 'test', name: 'foo' })).toEqual([secondIssue]) + expect(cache.getIssuesForShoot({ projectName: 'test', name: 'bar' })).toEqual([thirdIssue, updatedIssue]) + }) + }) + describe('#getCommentsForIssue', function () { it('should return the all comments for the first issue', function () { const comments = cache.getCommentsForIssue({ issueNumber: 1 }) diff --git a/backend/__tests__/helpers/cache.js b/backend/__tests__/helpers/cache.js new file mode 100644 index 0000000000..57f9236154 --- /dev/null +++ b/backend/__tests__/helpers/cache.js @@ -0,0 +1,24 @@ +// +// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Gardener contributors +// +// SPDX-License-Identifier: Apache-2.0 +// + +import cache from '../../lib/cache/index.js' + +function seedProjectNamespaceIndex (projects = fixtures.projects.list()) { + const handlers = new Map() + cache.indexProjectsByNamespace({ + on (event, handler) { + handlers.set(event, handler) + }, + }) + const add = handlers.get('add') + for (const project of projects) { + add(project) + } +} + +export { + seedProjectNamespaceIndex, +} diff --git a/backend/__tests__/hooks.spec.js b/backend/__tests__/hooks.spec.js index 05305c0089..685999664a 100644 --- a/backend/__tests__/hooks.spec.js +++ b/backend/__tests__/hooks.spec.js @@ -101,6 +101,7 @@ describe('hooks', () => { informers = keys.reduce((acc, key) => { return Object.assign(acc, { [key]: { + on: vi.fn(), run: vi.fn(), store: { untilHasSynced: Promise.resolve(key), @@ -110,6 +111,8 @@ describe('hooks', () => { }, {}) hooks.constructor.createInformers = mockCreateInformers = vi.fn(() => informers) cache.initialize = vi.fn() + cache.indexProjectsByNamespace = vi.fn() + cache.indexShootsBySeedName = vi.fn() cache.getTicketCache = vi.fn(() => ticketCache) io.mockReturnValue(ioInstance) }) @@ -131,6 +134,12 @@ describe('hooks', () => { expect(cache.initialize.mock.calls[0]).toHaveLength(1) expect(cache.initialize.mock.calls[0][0]).toBe(informers) + expect(cache.indexProjectsByNamespace).toHaveBeenCalledTimes(1) + expect(cache.indexProjectsByNamespace.mock.calls[0]).toEqual([informers.projects]) + + expect(cache.indexShootsBySeedName).toHaveBeenCalledTimes(1) + expect(cache.indexShootsBySeedName.mock.calls[0]).toEqual([informers.shoots]) + expect(io).toHaveBeenCalledTimes(1) expect(io.mock.calls[0]).toEqual([server, expect.anything()]) diff --git a/backend/__tests__/io.seedstats.spec.js b/backend/__tests__/io.seedstats.spec.js new file mode 100644 index 0000000000..f6ad8855ea --- /dev/null +++ b/backend/__tests__/io.seedstats.spec.js @@ -0,0 +1,100 @@ +// +// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Gardener contributors +// +// SPDX-License-Identifier: Apache-2.0 +// + +import { + describe, + it, + expect, + vi, + afterEach, +} from 'vitest' +import * as seedstatsService from '../lib/services/seedstats.js' +import { + getJoinedRooms, + subscribe, + synchronize, +} from '../lib/io/seedstats.js' + +describe('io/seedstats', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + function createSocket (user) { + return { + data: { user }, + join: vi.fn(), + rooms: new Set(), + } + } + + function notFound (uid) { + return { + kind: 'Status', + apiVersion: 'v1', + status: 'Failure', + message: `SeedStat with uid ${uid} does not exist`, + reason: 'NotFound', + details: { + uid, + group: 'dashboard.gardener.cloud', + kind: 'SeedStat', + }, + code: 404, + } + } + + it('should throw for invalid seedstats rooms while collecting joined rooms', () => { + let invocation = 0 + const room = { + [Symbol.toPrimitive] () { + invocation += 1 + return invocation === 1 ? 'seedstats;uf=0' : 'invalid-room' + }, + } + const nsp = { + adapter: { + rooms: new Map([[room, new Set()]]), + }, + } + + expect(() => getJoinedRooms(nsp, { seedName: 'seed-1' })).toThrow(new TypeError('Invalid seedstats room: invalid-room')) + }) + + it('should reject unauthorized seedstats subscriptions', async () => { + const socket = createSocket({ + id: 'user@example.org', + profiles: { + canListSeeds: true, + canListShoots: false, + }, + }) + + await expect(subscribe(socket, { unhealthyFilterMask: 0 })).rejects.toEqual(expect.objectContaining({ + statusCode: 403, + message: 'Insufficient authorization for seedstats subscription', + })) + expect(socket.join).not.toHaveBeenCalled() + }) + + it('should return not found statuses for unauthorized seedstats synchronization', async () => { + const getByUidsSpy = vi.spyOn(seedstatsService, 'getByUids') + const socket = createSocket({ + id: 'user@example.org', + profiles: { + canListSeeds: false, + canListShoots: true, + }, + }) + const uids = ['seed-1', 'seed-2'] + + await expect(synchronize(socket, uids, { unhealthyFilterMask: 0 })).resolves.toEqual([ + notFound('seed-1'), + notFound('seed-2'), + ]) + expect(getByUidsSpy).not.toHaveBeenCalled() + }) +}) diff --git a/backend/__tests__/services.seedstats.spec.js b/backend/__tests__/services.seedstats.spec.js new file mode 100644 index 0000000000..efa0de0b68 --- /dev/null +++ b/backend/__tests__/services.seedstats.spec.js @@ -0,0 +1,258 @@ +// +// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Gardener contributors +// +// SPDX-License-Identifier: Apache-2.0 +// + +import { + describe, + it, + expect, + vi, + beforeEach, +} from 'vitest' + +vi.mock('../lib/cache/index.js', () => ({ + default: { + getSeeds: vi.fn(), + getSeed: vi.fn(), + getSeedByUid: vi.fn(), + getShoots: vi.fn(), + getShootsBySeedName: vi.fn(), + getTicketCache: vi.fn(), + findProjectByNamespace: vi.fn(), + }, +})) + +vi.mock('../lib/services/authorization.js', () => ({ + canListSeeds: vi.fn(), + canListShoots: vi.fn(), +})) + +const { default: cache } = await import('../lib/cache/index.js') +const { default: config } = await import('../lib/config/index.js') +const authorization = await import('../lib/services/authorization.js') +const seedstats = await import('../lib/services/seedstats.js') + +describe('services/seedstats', () => { + const user = { id: 'admin@example.org' } + + function createSeed ({ name, uid }) { + return { metadata: { name, uid } } + } + + function createShoot ({ + name, + namespace, + seed, + status = 'unhealthy', + errorCodes, + ignored, + } = {}) { + const shoot = { + spec: seed ? { seedName: seed } : {}, + metadata: { + labels: { + 'shoot.gardener.cloud/status': status, + }, + }, + } + if (name) { + shoot.metadata.name = name + } + if (namespace) { + shoot.metadata.namespace = namespace + } + if (errorCodes) { + shoot.status = { lastErrors: [{ codes: errorCodes }] } + } + if (ignored) { + shoot.metadata.annotations = { 'shoot.gardener.cloud/ignore': 'true' } + } + return shoot + } + + const seeds = [ + createSeed({ name: 'infra1-seed', uid: 'seed-1' }), + createSeed({ name: 'infra2-seed', uid: 'seed-2' }), + ] + + const shoots = [ + createShoot({ seed: 'infra1-seed', status: 'healthy' }), + createShoot({ seed: 'infra1-seed' }), + createShoot({ seed: 'infra1-seed', status: 'progressing', name: 'progressing-shoot', namespace: 'garden-foo' }), + createShoot({ seed: 'infra1-seed', name: 'user-error-shoot', namespace: 'garden-foo', errorCodes: ['ERR_CONFIGURATION_PROBLEM'] }), + createShoot({ seed: 'infra1-seed', name: 'temporary-error-shoot', namespace: 'garden-foo', errorCodes: ['ERR_INFRA_RATE_LIMITS_EXCEEDED'] }), + createShoot({ seed: 'infra1-seed', name: 'deactivated-shoot', namespace: 'garden-foo', ignored: true }), + createShoot({ seed: 'infra1-seed', name: 'hidden-ticket-shoot', namespace: 'garden-foo' }), + createShoot({ seed: 'infra1-seed', name: 'visible-ticket-shoot', namespace: 'garden-foo' }), + createShoot({ seed: 'infra2-seed' }), + createShoot({ seed: 'unknown-seed' }), + createShoot({}), + ] + + const issues = [ + { + metadata: { + projectName: 'foo', + name: 'hidden-ticket-shoot', + }, + data: { + labels: [{ name: 'suppress' }], + }, + }, + { + metadata: { + projectName: 'foo', + name: 'visible-ticket-shoot', + }, + data: { + labels: [{ name: 'customer-visible' }], + }, + }, + ] + + const ticketCache = { + getIssuesForShoot: vi.fn(({ name, projectName }) => + issues.filter(issue => issue.metadata.projectName === projectName && issue.metadata.name === name), + ), + } + + beforeEach(() => { + vi.resetAllMocks() + ticketCache.getIssuesForShoot.mockImplementation(({ name, projectName }) => + issues.filter(issue => issue.metadata.projectName === projectName && issue.metadata.name === name), + ) + config.frontend.ticket = { + hideClustersWithLabels: ['suppress'], + } + authorization.canListSeeds.mockResolvedValue(true) + authorization.canListShoots.mockResolvedValue(true) + cache.getSeeds.mockReturnValue(seeds) + cache.getShoots.mockReturnValue(shoots) + cache.getShootsBySeedName.mockImplementation(seedName => shoots.filter(s => s?.spec?.seedName === seedName).values()) + cache.getSeed.mockImplementation(name => seeds.find(seed => seed.metadata.name === name)) + cache.getSeedByUid.mockImplementation(uid => seeds.find(seed => seed.metadata.uid === uid)) + cache.getTicketCache.mockReturnValue(ticketCache) + cache.findProjectByNamespace.mockImplementation(namespace => { + switch (namespace) { + case 'garden-foo': + return { metadata: { name: 'foo' } } + case 'garden-bar': + return { metadata: { name: 'bar' } } + default: + throw new Error(`Unknown namespace ${namespace}`) + } + }) + }) + + it('should reject unauthorized users', async () => { + authorization.canListShoots.mockResolvedValue(false) + + await expect(seedstats.list({ user, unhealthyFilterMask: 0 })).rejects.toEqual(expect.objectContaining({ + statusCode: 403, + message: 'You are not allowed to list seed stats', + })) + }) + + it('should reject a missing unhealthyFilterMask on list', async () => { + await expect(seedstats.list({ user })).rejects.toEqual(expect.objectContaining({ + statusCode: 422, + message: "The 'unhealthyFilterMask' query parameter must be a non-negative integer with no bits set outside the known flags (0–7)", + })) + }) + + it('should reject an invalid unhealthyFilterMask on read', async () => { + await expect(seedstats.read({ user, name: 'infra1-seed', unhealthyFilterMask: 'abc' })).rejects.toEqual(expect.objectContaining({ + statusCode: 422, + message: "The 'unhealthyFilterMask' query parameter must be a non-negative integer with no bits set outside the known flags (0–7)", + })) + }) + + it('should return stats for all seeds', async () => { + const result = await seedstats.list({ user, unhealthyFilterMask: 0 }) + + expect(result).toEqual([ + { + apiVersion: 'dashboard.gardener.cloud/v1alpha1', + kind: 'SeedStat', + metadata: { + name: 'infra1-seed', + uid: 'seed-1', + }, + counts: { + shootCount: 8, + unhealthyShoots: { + total: 7, + matching: 7, + }, + }, + }, + { + apiVersion: 'dashboard.gardener.cloud/v1alpha1', + kind: 'SeedStat', + metadata: { + name: 'infra2-seed', + uid: 'seed-2', + }, + counts: { + shootCount: 1, + unhealthyShoots: { + total: 1, + matching: 1, + }, + }, + }, + ]) + }) + + it.each([ + [1, { total: 7, matching: 6 }], + [2, { total: 7, matching: 5 }], + [4, { total: 7, matching: 6 }], + [7, { total: 7, matching: 3 }], + ])('should apply unhealthyFilterMask %s', async (unhealthyFilterMask, unhealthyShoots) => { + const result = await seedstats.read({ user, name: 'infra1-seed', unhealthyFilterMask }) + + expect(result).toEqual(expect.objectContaining({ + counts: { + shootCount: 8, + unhealthyShoots, + }, + })) + }) + + it('should return stats for a single seed', async () => { + const result = await seedstats.read({ user, name: 'infra1-seed', unhealthyFilterMask: 0 }) + + expect(result).toEqual({ + apiVersion: 'dashboard.gardener.cloud/v1alpha1', + kind: 'SeedStat', + metadata: { + name: 'infra1-seed', + uid: 'seed-1', + }, + counts: { + shootCount: 8, + unhealthyShoots: { + total: 7, + matching: 7, + }, + }, + }) + }) + + it('should reject unknown seeds on read', async () => { + await expect(seedstats.read({ user, name: 'does-not-exist', unhealthyFilterMask: 0 })).rejects.toEqual(expect.objectContaining({ + statusCode: 404, + message: "Seed with name 'does-not-exist' not found", + })) + }) + + it('should always return total unhealthy shoots greater than or equal to matching unhealthy shoots', async () => { + for (const unhealthyFilterMask of [0, 1, 2, 4, 7]) { + const result = await seedstats.read({ user, name: 'infra1-seed', unhealthyFilterMask }) + expect(result.counts.unhealthyShoots.total).toBeGreaterThanOrEqual(result.counts.unhealthyShoots.matching) + } + }) +}) diff --git a/backend/__tests__/services.terminals.spec.js b/backend/__tests__/services.terminals.spec.js index aeef6da40b..fd7c2a368c 100644 --- a/backend/__tests__/services.terminals.spec.js +++ b/backend/__tests__/services.terminals.spec.js @@ -35,6 +35,7 @@ import { } from '../lib/services/terminals/utils.js' import kubeClientModule from '@gardener-dashboard/kube-client' +import { seedProjectNamespaceIndex } from './helpers/cache.js' const { cache } = cacheModule const { dashboardClient } = kubeClientModule @@ -132,6 +133,7 @@ describe('services', function () { Object.defineProperty(config, 'terminal', { get: terminalStub }) seedList = fixtures.seeds.list() vi.spyOn(cache, 'getSeeds').mockReturnValue(seedList) + seedProjectNamespaceIndex() }) afterEach(function () { diff --git a/backend/__tests__/utils.spec.js b/backend/__tests__/utils.spec.js index c77eb90df9..cfa8768dbb 100644 --- a/backend/__tests__/utils.spec.js +++ b/backend/__tests__/utils.spec.js @@ -18,6 +18,7 @@ import { encodeBase64, decodeBase64, getConfigValue, + isTruthyValue, shootHasIssue, getSeedNameFromShoot, parseSelectors, @@ -64,6 +65,15 @@ describe('utils', function () { expect(shootHasIssue(shoot)).toBe(true) }) + it('should detect truthy values', function () { + expect(isTruthyValue('1')).toBe(true) + expect(isTruthyValue('true')).toBe(true) + expect(isTruthyValue('TRUE')).toBe(true) + expect(isTruthyValue('false')).toBe(false) + expect(isTruthyValue('')).toBe(false) + expect(isTruthyValue(undefined)).toBe(false) + }) + it('should return the seed name for a shoot resource', function () { expect(() => getSeedNameFromShoot({})).toThrow(AssertionError) const shoot = { diff --git a/backend/__tests__/watches.spec.js b/backend/__tests__/watches.spec.js index 44ee132f40..abda4518cd 100644 --- a/backend/__tests__/watches.spec.js +++ b/backend/__tests__/watches.spec.js @@ -33,6 +33,11 @@ const { sha256 } = helper const flushPromises = () => new Promise(setImmediate) const rooms = new Map() +const adapterRooms = new Map() + +function joinRoom (name, members = ['socket-1']) { + adapterRooms.set(name, new Set(members)) +} function getRoom (name) { if (!rooms.has(name)) { @@ -44,6 +49,9 @@ function getRoom (name) { } const nsp = { + adapter: { + rooms: adapterRooms, + }, to: vi.fn().mockImplementation(name => { if (Array.isArray(name)) { return { @@ -114,6 +122,7 @@ describe('watches', function () { beforeEach(function () { informer = new EventEmitter() rooms.clear() + adapterRooms.clear() vi.clearAllMocks() }) @@ -133,6 +142,22 @@ describe('watches', function () { beforeEach(() => { shootsWithIssues = new Set() vi.spyOn(cache, 'getManagedSeedForShootInGardenNamespace').mockReturnValue(undefined) + vi.spyOn(cache, 'getSeed').mockImplementation(name => { + if (name === 'infra1-seed') { + return { + metadata: { + uid: 'seed-1', + }, + } + } + if (name === 'infra1-seed2') { + return { + metadata: { + uid: 'seed-2', + }, + } + } + }) }) it('should watch shoots without issues', async function () { @@ -256,6 +281,197 @@ describe('watches', function () { ['managedseed-shoots', { type: 'DELETED', uid: gardenManagedSeedShoot.metadata.uid }], ]) }) + + it('should emit seedstats updates for joined rooms whose total or filtered counts changed', async function () { + const oldShoot = { + metadata: { + namespace: 'garden-foo', + name: 'fooShoot', + uid: 9, + labels: { + 'shoot.gardener.cloud/status': 'healthy', + }, + }, + spec: { + seedName: 'infra1-seed', + }, + } + const updatedShoot = cloneDeep(oldShoot) + updatedShoot.metadata.labels['shoot.gardener.cloud/status'] = 'progressing' + + joinRoom('seedstats;uf=0') + joinRoom('seedstats;uf=1') + joinRoom('seedstats;seed=infra1-seed;uf=0') + joinRoom('seedstats;seed=infra1-seed;uf=1') + + watches.shoots(io, informer) + + informer.emit('update', updatedShoot, oldShoot) + + await flushPromises() + + expect(rooms.get('seedstats;uf=0').emit).toHaveBeenCalledTimes(1) + expect(rooms.get('seedstats;uf=0').emit.mock.calls).toEqual([ + ['seedstats', { type: 'MODIFIED', uid: 'seed-1' }], + ]) + expect(rooms.get('seedstats;uf=1').emit).toHaveBeenCalledTimes(1) + expect(rooms.get('seedstats;uf=1').emit.mock.calls).toEqual([ + ['seedstats', { type: 'MODIFIED', uid: 'seed-1' }], + ]) + expect(rooms.get('seedstats;seed=infra1-seed;uf=0').emit).toHaveBeenCalledTimes(1) + expect(rooms.get('seedstats;seed=infra1-seed;uf=0').emit.mock.calls).toEqual([ + ['seedstats', { type: 'MODIFIED', uid: 'seed-1' }], + ]) + expect(rooms.get('seedstats;seed=infra1-seed;uf=1').emit).toHaveBeenCalledTimes(1) + expect(rooms.get('seedstats;seed=infra1-seed;uf=1').emit.mock.calls).toEqual([ + ['seedstats', { type: 'MODIFIED', uid: 'seed-1' }], + ]) + }) + + it('should emit seedstats for ADDED shoot', async function () { + const newShoot = { + metadata: { + namespace: 'garden-foo', + name: 'fooShoot', + uid: 9, + labels: { 'shoot.gardener.cloud/status': 'healthy' }, + }, + spec: { seedName: 'infra1-seed' }, + } + + joinRoom('seedstats;uf=0') + joinRoom('seedstats;seed=infra1-seed;uf=0') + + watches.shoots(io, informer) + + informer.emit('add', newShoot) + + await flushPromises() + + expect(rooms.get('seedstats;uf=0').emit.mock.calls).toEqual([ + ['seedstats', { type: 'MODIFIED', uid: 'seed-1' }], + ]) + expect(rooms.get('seedstats;seed=infra1-seed;uf=0').emit.mock.calls).toEqual([ + ['seedstats', { type: 'MODIFIED', uid: 'seed-1' }], + ]) + }) + + it('should emit seedstats for DELETED shoot', async function () { + const shoot = { + metadata: { + namespace: 'garden-foo', + name: 'fooShoot', + uid: 9, + labels: { 'shoot.gardener.cloud/status': 'healthy' }, + }, + spec: { seedName: 'infra1-seed' }, + } + + joinRoom('seedstats;uf=0') + joinRoom('seedstats;seed=infra1-seed;uf=0') + + watches.shoots(io, informer) + + informer.emit('delete', shoot) + + await flushPromises() + + expect(rooms.get('seedstats;uf=0').emit.mock.calls).toEqual([ + ['seedstats', { type: 'MODIFIED', uid: 'seed-1' }], + ]) + expect(rooms.get('seedstats;seed=infra1-seed;uf=0').emit.mock.calls).toEqual([ + ['seedstats', { type: 'MODIFIED', uid: 'seed-1' }], + ]) + }) + + it('should not emit seedstats when shoot MODIFIED but health state unchanged', async function () { + const oldShoot = { + metadata: { + namespace: 'garden-foo', + name: 'fooShoot', + uid: 9, + labels: { 'shoot.gardener.cloud/status': 'healthy' }, + }, + spec: { seedName: 'infra1-seed' }, + } + // Same health state, different field that doesn't affect seedstats + const updatedShoot = cloneDeep(oldShoot) + updatedShoot.metadata.resourceVersion = '999' + + joinRoom('seedstats;uf=0') + joinRoom('seedstats;seed=infra1-seed;uf=0') + + watches.shoots(io, informer) + + informer.emit('update', updatedShoot, oldShoot) + + await flushPromises() + + expect(rooms.get('seedstats;uf=0')).toBeUndefined() + expect(rooms.get('seedstats;seed=infra1-seed;uf=0')).toBeUndefined() + }) + + it('should not emit seedstats when seed is not found in cache', async function () { + const shoot = { + metadata: { + namespace: 'garden-foo', + name: 'fooShoot', + uid: 9, + labels: { 'shoot.gardener.cloud/status': 'healthy' }, + }, + spec: { seedName: 'unknown-seed' }, + } + + joinRoom('seedstats;uf=0') + + watches.shoots(io, informer) + + informer.emit('add', shoot) + + await flushPromises() + + expect(rooms.get('seedstats;uf=0')).toBeUndefined() + }) + + it('should emit seedstats updates for list and single-seed rooms when a shoot changes seeds', async function () { + const oldShoot = { + metadata: { + namespace: 'garden-foo', + name: 'fooShoot', + uid: 9, + labels: { + 'shoot.gardener.cloud/status': 'healthy', + }, + }, + spec: { + seedName: 'infra1-seed', + }, + } + const updatedShoot = cloneDeep(oldShoot) + updatedShoot.spec.seedName = 'infra1-seed2' + updatedShoot.metadata.labels['shoot.gardener.cloud/status'] = 'unhealthy' + + joinRoom('seedstats;uf=0') + joinRoom('seedstats;seed=infra1-seed;uf=0') + joinRoom('seedstats;seed=infra1-seed2;uf=0') + + watches.shoots(io, informer) + + informer.emit('update', updatedShoot, oldShoot) + + await flushPromises() + + expect(rooms.get('seedstats;uf=0').emit.mock.calls).toEqual([ + ['seedstats', { type: 'MODIFIED', uid: 'seed-1' }], + ['seedstats', { type: 'MODIFIED', uid: 'seed-2' }], + ]) + expect(rooms.get('seedstats;seed=infra1-seed;uf=0').emit.mock.calls).toEqual([ + ['seedstats', { type: 'MODIFIED', uid: 'seed-1' }], + ]) + expect(rooms.get('seedstats;seed=infra1-seed2;uf=0').emit.mock.calls).toEqual([ + ['seedstats', { type: 'MODIFIED', uid: 'seed-2' }], + ]) + }) }) describe('projects', function () { @@ -315,7 +531,7 @@ describe('watches', function () { }) describe('seeds', function () { - it('should watch seeds', async function () { + it('should watch seeds only for joined seedstats rooms', async function () { watches.seeds(io, informer) const uid = 7 @@ -326,24 +542,55 @@ describe('watches', function () { }, } + joinRoom('seedstats;uf=0') + joinRoom('seedstats;seed=seed-foo;uf=7') + joinRoom('seedstats;seed=other-seed;uf=7') + informer.emit('add', seed) informer.emit('update', seed) informer.emit('delete', seed) await flushPromises() - const ids = ['seeds'] + const ids = ['seeds', 'seedstats;uf=0', 'seedstats;seed=seed-foo;uf=7'] expect(Array.from(rooms.keys())).toEqual(ids) - expect(nsp.to).toHaveBeenCalledTimes(3) + expect(nsp.to).toHaveBeenCalledTimes(9) + expect(nsp.to.mock.calls).toEqual([ + ['seeds'], + ['seedstats;uf=0'], + ['seedstats;seed=seed-foo;uf=7'], + ['seeds'], + ['seedstats;uf=0'], + ['seedstats;seed=seed-foo;uf=7'], + ['seeds'], + ['seedstats;uf=0'], + ['seedstats;seed=seed-foo;uf=7'], + ]) - const seedsRoom = rooms.get(ids[0]) + const seedsRoom = rooms.get('seeds') expect(seedsRoom.emit).toHaveBeenCalledTimes(3) expect(seedsRoom.emit.mock.calls).toEqual([ ['seeds', { type: 'ADDED', uid }], ['seeds', { type: 'MODIFIED', uid }], ['seeds', { type: 'DELETED', uid }], ]) + + const seedStatsListRoom = rooms.get('seedstats;uf=0') + expect(seedStatsListRoom.emit).toHaveBeenCalledTimes(3) + expect(seedStatsListRoom.emit.mock.calls).toEqual([ + ['seedstats', { type: 'ADDED', uid }], + ['seedstats', { type: 'MODIFIED', uid }], + ['seedstats', { type: 'DELETED', uid }], + ]) + + const seedStatsSeedRoom = rooms.get('seedstats;seed=seed-foo;uf=7') + expect(seedStatsSeedRoom.emit).toHaveBeenCalledTimes(3) + expect(seedStatsSeedRoom.emit.mock.calls).toEqual([ + ['seedstats', { type: 'ADDED', uid }], + ['seedstats', { type: 'MODIFIED', uid }], + ['seedstats', { type: 'DELETED', uid }], + ]) }) }) @@ -488,6 +735,16 @@ describe('watches', function () { signal = abortController.signal vi.spyOn(cache, 'getTicketCache').mockReturnValue(ticketCache) + vi.spyOn(cache, 'getShoot').mockReturnValue({ + spec: { + seedName: 'infra1-seed', + }, + }) + vi.spyOn(cache, 'getSeed').mockReturnValue({ + metadata: { + uid: 'seed-1', + }, + }) }) it('should log a warning if gitHub config is missing and not continue', async function () { @@ -569,7 +826,11 @@ describe('watches', function () { expect(pLimit).toHaveBeenCalledWith(42) }) - it('should emit ticket cache events to socket io', async () => { + it('should emit ticket cache events to socket io and seedstats only for bit-2 rooms', async () => { + joinRoom('seedstats;uf=4') + joinRoom('seedstats;seed=infra1-seed;uf=4') + joinRoom('seedstats;uf=0') + watches.leases(io, informer, { signal }) expect(nsp.emit).toHaveBeenCalledTimes(1) @@ -580,6 +841,62 @@ describe('watches', function () { expect(nsp.to).toHaveBeenCalledWith([room]) expect(mockRoom.emit).toHaveBeenCalledTimes(1) expect(mockRoom.emit).toHaveBeenCalledWith('comments', issueEvent) + + expect(rooms.get('seedstats;uf=4').emit).toHaveBeenCalledTimes(1) + expect(rooms.get('seedstats;uf=4').emit).toHaveBeenCalledWith('seedstats', { type: 'MODIFIED', uid: 'seed-1' }) + expect(rooms.get('seedstats;seed=infra1-seed;uf=4').emit).toHaveBeenCalledTimes(1) + expect(rooms.get('seedstats;seed=infra1-seed;uf=4').emit).toHaveBeenCalledWith('seedstats', { type: 'MODIFIED', uid: 'seed-1' }) + expect(rooms.has('seedstats;uf=0')).toBe(false) + }) + + it('should not emit seedstats for issue events when no joined rooms have FILTER_HIDE_TICKETS bit set', async () => { + joinRoom('seedstats;uf=0') + joinRoom('seedstats;uf=1') + joinRoom('seedstats;seed=infra1-seed;uf=0') + + watches.leases(io, informer, { signal }) + + expect(rooms.has('seedstats;uf=0')).toBe(false) + expect(rooms.has('seedstats;uf=1')).toBe(false) + expect(rooms.has('seedstats;seed=infra1-seed;uf=0')).toBe(false) + }) + + it('should not emit seedstats for issue events when shoot is not found in cache', async () => { + cache.getShoot.mockReturnValue(undefined) + + joinRoom('seedstats;uf=4') + + watches.leases(io, informer, { signal }) + + expect(rooms.has('seedstats;uf=4')).toBe(false) + }) + + it('should not emit seedstats for issue events when seed is not found in cache', async () => { + cache.getSeed.mockReturnValue(undefined) + + joinRoom('seedstats;uf=4') + + watches.leases(io, informer, { signal }) + + expect(rooms.has('seedstats;uf=4')).toBe(false) + }) + + it('should not emit seedstats for issue events with missing metadata', async () => { + const badEvent = { object: { metadata: {} } } + const ticketCacheWithBadEvent = { + on (eventName, handler) { + if (eventName === 'issue') { + handler(badEvent) + } + }, + } + cache.getTicketCache.mockReturnValue(ticketCacheWithBadEvent) + + joinRoom('seedstats;uf=4') + + watches.leases(io, informer, { signal }) + + expect(rooms.has('seedstats;uf=4')).toBe(false) }) it('should listen to informer update events', async function () { diff --git a/backend/lib/cache/index.js b/backend/lib/cache/index.js index eb31cfa2f5..33f561810e 100644 --- a/backend/lib/cache/index.js +++ b/backend/lib/cache/index.js @@ -7,6 +7,7 @@ import _ from 'lodash-es' import httpErrors from 'http-errors' import createTicketCache from './tickets.js' +import logger from '../logger/index.js' import { parseSelectors, filterBySelectors, @@ -20,11 +21,83 @@ const { NotFound } = httpErrors */ class Cache extends Map { + #namespaceToProject = new Map() + #seedNameToShoots = new Map() + constructor () { super() this.ticketCache = createTicketCache() } + indexProjectsByNamespace (informer) { + const set = obj => { + const ns = obj?.spec?.namespace + if (!ns) { + return + } + const existing = this.#namespaceToProject.get(ns) + if (existing && existing.metadata.uid !== obj.metadata.uid) { + logger.warn('Two projects claim namespace %s: %s and %s — namespace index may be inconsistent', ns, existing.metadata.name, obj.metadata.name) + } + this.#namespaceToProject.set(ns, obj) + } + const del = obj => { + const ns = obj?.spec?.namespace + if (ns) { + this.#namespaceToProject.delete(ns) + } + } + informer.on('add', set) + informer.on('update', (obj, old) => { + del(old) + set(obj) + }) + informer.on('delete', del) + } + + indexShootsBySeedName (informer) { + const add = shoot => { + const seedName = shoot?.spec?.seedName + if (!seedName) { + return + } + const uid = shoot?.metadata?.uid + if (!uid) { + return + } + let set = this.#seedNameToShoots.get(seedName) + if (!set) { + set = new Map() + this.#seedNameToShoots.set(seedName, set) + } + set.set(uid, shoot) + } + const del = shoot => { + const seedName = shoot?.spec?.seedName + const uid = shoot?.metadata?.uid + if (!seedName || !uid) { + return + } + const set = this.#seedNameToShoots.get(seedName) + if (set) { + set.delete(uid) + if (set.size === 0) { + this.#seedNameToShoots.delete(seedName) + } + } + } + informer.on('add', add) + informer.on('update', (obj, old) => { + del(old) + add(obj) + }) + informer.on('delete', del) + } + + getShootsBySeedName (seedName) { + return this.#seedNameToShoots.get(seedName)?.values() ?? [] + } + getCloudProfiles () { return this.get('cloudprofiles').list() } @@ -63,6 +136,14 @@ class Cache extends Map { getTicketCache () { return this.ticketCache } + + findProjectByNamespace (namespace) { + const project = this.#namespaceToProject.get(namespace) + if (!project) { + throw new NotFound(`Namespace '${namespace}' is not related to a gardener project`) + } + return project + } } const cache = new Cache() @@ -140,11 +221,7 @@ export default { return cache.getResourceQuotas() }, findProjectByNamespace (namespace) { - const project = cache.get('projects').find(['spec.namespace', namespace]) - if (!project) { - throw new NotFound(`Namespace '${namespace}' is not related to a gardener project`) - } - return project + return cache.findProjectByNamespace(namespace) }, getManagedSeedsInGardenNamespace () { return cache.getManagedSeedsInGardenNamespace() @@ -164,6 +241,9 @@ export default { getTicketCache () { return cache.getTicketCache() }, + getShootsBySeedName (seedName) { + return cache.getShootsBySeedName(seedName) + }, getByUid (kind, uid) { switch (kind) { case 'Project': @@ -178,4 +258,10 @@ export default { throw new TypeError(`Kind '${kind}' not supported`) } }, + indexProjectsByNamespace (informer) { + cache.indexProjectsByNamespace(informer) + }, + indexShootsBySeedName (informer) { + cache.indexShootsBySeedName(informer) + }, } diff --git a/backend/lib/cache/tickets.js b/backend/lib/cache/tickets.js index ebeb48c9da..ad915bb142 100644 --- a/backend/lib/cache/tickets.js +++ b/backend/lib/cache/tickets.js @@ -8,8 +8,13 @@ import EventEmitter from 'events' import _ from 'lodash-es' import logger from '../logger/index.js' +function shootKey (projectName, name) { + return JSON.stringify([projectName, name]) +} + function init () { const issues = new Map() + const issuesByShoot = new Map() // Map> const commentsForIssues = new Map() // we could also think of getting rid of the comments cache const emitter = new EventEmitter() @@ -55,11 +60,19 @@ function init () { } function getIssueNumbersForNameAndProjectName ({ name, projectName }) { - return _ - .chain(getIssues()) - .filter(_.matches({ metadata: { name, projectName } })) - .map('metadata.number') - .value() + const map = issuesByShoot.get(shootKey(projectName, name)) + if (!map) { + return [] + } + return Array.from(map.keys()) + } + + function getIssuesForShoot ({ name, projectName }) { + const map = issuesByShoot.get(shootKey(projectName, name)) + if (!map) { + return [] + } + return Array.from(map.values()) } function getCommentsForIssueCache ({ issueNumber }) { @@ -76,7 +89,14 @@ function init () { } function addOrUpdateIssue ({ issue }) { + const number = issue.metadata.number + const oldIssue = issues.get(number) + if (oldIssue) { + removeFromShootIndex(oldIssue) + } updateIfNewer('issue', issues, issue) + // Re-read from issues map — updateIfNewer may have kept the old item if it was newer + addToShootIndex(issues.get(number)) } function addOrUpdateComment ({ issueNumber, comment }) { @@ -88,6 +108,11 @@ function init () { const issueNumber = issue.metadata.number logger.trace('removing issue', issueNumber, 'and comments') + const cachedIssue = issues.get(issueNumber) + if (cachedIssue) { + removeFromShootIndex(cachedIssue) + } + const comments = getCommentsForIssueCache({ issueNumber }) issues.delete(issueNumber) @@ -107,6 +132,36 @@ function init () { emitCommmentDeleted(comment) } + function addToShootIndex (issue) { + const { projectName, name, number } = issue?.metadata ?? {} + if (!projectName || !name) { + return + } + const key = shootKey(projectName, name) + let map = issuesByShoot.get(key) + if (!map) { + map = new Map() + issuesByShoot.set(key, map) + } + map.set(number, issue) + } + + function removeFromShootIndex (issue) { + const { projectName, name, number } = issue?.metadata ?? {} + if (!projectName || !name) { + return + } + const key = shootKey(projectName, name) + const map = issuesByShoot.get(key) + if (!map) { + return + } + map.delete(number) + if (map.size === 0) { + issuesByShoot.delete(key) + } + } + function updateIfNewer (kind, cachedMap, item) { const identifier = kind === 'issue' ? item.metadata.number @@ -142,6 +197,7 @@ function init () { getCommentsForIssue, getIssueNumbers, getIssueNumbersForNameAndProjectName, + getIssuesForShoot, addOrUpdateIssues, addOrUpdateIssue, addOrUpdateComment, diff --git a/backend/lib/hooks.js b/backend/lib/hooks.js index da6c4c0295..2824ec10a4 100644 --- a/backend/lib/hooks.js +++ b/backend/lib/hooks.js @@ -40,6 +40,9 @@ class LifecycleHooks { const informers = this.constructor.createInformers(this.client) // initialize cache cache.initialize(informers) + // build derived indexes + cache.indexProjectsByNamespace(informers.projects) + cache.indexShootsBySeedName(informers.shoots) // run informers const untilHasSyncedList = [] for (const informer of Object.values(informers)) { diff --git a/backend/lib/io/dispatcher.js b/backend/lib/io/dispatcher.js index 9cc16c7816..7afcbf6581 100644 --- a/backend/lib/io/dispatcher.js +++ b/backend/lib/io/dispatcher.js @@ -11,6 +11,11 @@ import { } from './shoots.js' import { synchronize as synchronizeProjects } from './projects.js' import { synchronize as synchronizeSeeds } from './seeds.js' +import { + subscribe as subscribeSeedStats, + unsubscribe as unsubscribeSeedStats, + synchronize as synchronizeSeedStats, +} from './seedstats.js' import { synchronize as synchronizeManagedSeeds } from './managedseeds.js' import { synchronize as synchronizeManagedSeedShoots } from './managedseedshoots.js' @@ -19,6 +24,9 @@ async function subscribe (socket, key, options = {}) { case 'shoots': await unsubscribeShoots(socket) return subscribeShoots(socket, options) + case 'seedstats': + await unsubscribeSeedStats(socket) + return subscribeSeedStats(socket, options) default: throw new TypeError(`Invalid subscription type - ${key}`) } @@ -28,6 +36,8 @@ async function unsubscribe (socket, key) { switch (key) { case 'shoots': return unsubscribeShoots(socket) + case 'seedstats': + return unsubscribeSeedStats(socket) default: throw new TypeError(`Invalid subscription type - ${key}`) } @@ -50,6 +60,11 @@ function synchronize (socket, key, ...args) { assertArray(uids) return synchronizeSeeds(socket, uids) } + case 'seedstats': { + const [uids, options] = args + assertArray(uids) + return synchronizeSeedStats(socket, uids, options) + } case 'managedseeds': { const [uids] = args assertArray(uids) diff --git a/backend/lib/io/helper.js b/backend/lib/io/helper.js index 2cb21829b0..f6ab016c9a 100644 --- a/backend/lib/io/helper.js +++ b/backend/lib/io/helper.js @@ -67,17 +67,20 @@ async function userProfiles (req, res, next) { const [ canListProjects, canListSeeds, + canListShoots, canListManagedSeedsInGardenNamespace, canListShootsInGardenNamespace, ] = await Promise.all([ authorization.canListProjects(req.user), authorization.canListSeeds(req.user), + authorization.canListShoots(req.user), authorization.canListManagedSeedsInGardenNamespace(req.user), authorization.canListShootsInGardenNamespace(req.user), ]) const profiles = Object.freeze({ canListProjects, canListSeeds, + canListShoots, canGetManagedSeedAndShootInGardenNs: canListManagedSeedsInGardenNamespace && canListShootsInGardenNamespace, }) Object.defineProperty(req.user, 'profiles', { diff --git a/backend/lib/io/index.js b/backend/lib/io/index.js index a2cde23e77..8898a87ba9 100644 --- a/backend/lib/io/index.js +++ b/backend/lib/io/index.js @@ -52,22 +52,24 @@ function init (httpServer, cache) { io.use(helper.authenticationMiddleware()) // handle connections (see https://socket.io/docs/v4/server-application-structure) - io.on('connection', socket => { + io.on('connection', async socket => { const socketId = socket.id const timeoutId = socket.data.timeoutId delete socket.data.timeoutId - helper.joinPrivateRoom(socket) + await helper.joinPrivateRoom(socket) const user = helper.getUserFromSocket(socket) if (_.get(user, ['profiles', 'canListSeeds'], false)) { - socket.join('seeds') + await socket.join('seeds') logger.debug('Socket %s auto-joined seeds room', socket.id) } if (_.get(user, ['profiles', 'canGetManagedSeedAndShootInGardenNs'], false)) { - socket.join('managedseeds;garden') - socket.join('managedseed-shoots;garden') + await Promise.all([ + socket.join('managedseeds;garden'), + socket.join('managedseed-shoots;garden'), + ]) logger.debug('Socket %s auto-joined managed seed garden rooms', socket.id) } diff --git a/backend/lib/io/seedstats.js b/backend/lib/io/seedstats.js new file mode 100644 index 0000000000..0aaf9771e8 --- /dev/null +++ b/backend/lib/io/seedstats.js @@ -0,0 +1,140 @@ +// +// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Gardener contributors +// +// SPDX-License-Identifier: Apache-2.0 +// + +import createError from 'http-errors' +import logger from '../logger/index.js' +import helper from './helper.js' +import * as seedstats from '../services/seedstats.js' + +const { getUserFromSocket } = helper + +const group = 'dashboard.gardener.cloud' +const kind = 'SeedStat' +const listRoomRegExp = /^seedstats;uf=(?[0-7])$/ +const seedRoomRegExp = /^seedstats;seed=(?[^;]+);uf=(?[0-7])$/ + +function notFound (uid) { + return { + kind: 'Status', + apiVersion: 'v1', + status: 'Failure', + message: `${kind} with uid ${uid} does not exist`, + reason: 'NotFound', + details: { + uid, + group, + kind, + }, + code: 404, + } +} + +function getListRoomName (unhealthyFilterMask) { + unhealthyFilterMask = seedstats.parseUnhealthyFilterMask(unhealthyFilterMask) + return `seedstats;uf=${unhealthyFilterMask}` +} + +function getSeedRoomName (seedName, unhealthyFilterMask) { + unhealthyFilterMask = seedstats.parseUnhealthyFilterMask(unhealthyFilterMask) + return `seedstats;seed=${seedName};uf=${unhealthyFilterMask}` +} + +function getRoomName ({ seedName, unhealthyFilterMask }) { + if (seedName) { + return getSeedRoomName(seedName, unhealthyFilterMask) + } + return getListRoomName(unhealthyFilterMask) +} + +function isSeedStatsRoom (room) { + return listRoomRegExp.test(room) || seedRoomRegExp.test(room) +} + +function parseRoomName (room) { + const listMatch = listRoomRegExp.exec(room) + if (listMatch) { + const { mask } = listMatch.groups + return { + room, + unhealthyFilterMask: Number(mask), + } + } + + const seedMatch = seedRoomRegExp.exec(room) + if (seedMatch) { + const { seedName, mask } = seedMatch.groups + return { + room, + seedName, + unhealthyFilterMask: Number(mask), + } + } + + throw new TypeError(`Invalid seedstats room: ${room}`) +} + +function getJoinedRooms (nsp, { seedName }) { + if (!seedName) { + logger.warn('getJoinedRooms called without seedName') + return [] + } + + const adapterRooms = nsp?.adapter?.rooms + if (!adapterRooms) { + return [] + } + + const appliesToSeed = ({ seedName: roomSeedName }) => !roomSeedName || roomSeedName === seedName + return Array.from(adapterRooms.keys()) + .filter(isSeedStatsRoom) + .map(parseRoomName) + .filter(appliesToSeed) +} + +function hasSeedStatsAccess (user) { + return user?.profiles?.canListSeeds === true && user?.profiles?.canListShoots === true +} + +async function subscribe (socket, options = {}) { + const user = getUserFromSocket(socket) + + if (!hasSeedStatsAccess(user)) { + throw createError(403, 'Insufficient authorization for seedstats subscription') + } + + const unhealthyFilterMask = seedstats.parseUnhealthyFilterMask(options.unhealthyFilterMask) + const room = getRoomName({ + seedName: options.name, + unhealthyFilterMask, + }) + logger.debug('User %s joined room [%s]', user.id, room) + return socket.join(room) +} + +function unsubscribe (socket) { + const promises = Array.from(socket.rooms) + .filter(room => isSeedStatsRoom(room)) + .map(room => socket.leave(room)) + return Promise.all(promises) +} + +async function synchronize (socket, uids = [], options = {}) { + const user = getUserFromSocket(socket) + const unhealthyFilterMask = seedstats.parseUnhealthyFilterMask(options.unhealthyFilterMask) + + if (!hasSeedStatsAccess(user)) { + return uids.map(uid => notFound(uid)) + } + + return seedstats.getByUids(uids, unhealthyFilterMask).map((stat, i) => stat ?? notFound(uids[i])) // eslint-disable-line security/detect-object-injection -- i is the map index +} + +export { + getJoinedRooms, + subscribe, + unsubscribe, + synchronize, +} diff --git a/backend/lib/io/shoots.js b/backend/lib/io/shoots.js index 996a13dfc7..f838b8ed00 100644 --- a/backend/lib/io/shoots.js +++ b/backend/lib/io/shoots.js @@ -37,7 +37,7 @@ async function subscribe (socket, { namespace, name, labelSelector }) { const user = helper.getUserFromSocket(socket) const joinRoom = room => { - logger.debug('User %s joined rooms [%s]', user.id, room) + logger.debug('User %s joined room [%s]', user.id, room) return socket.join(room) } diff --git a/backend/lib/routes/index.js b/backend/lib/routes/index.js index 27d9aaa3f4..040b72f11c 100644 --- a/backend/lib/routes/index.js +++ b/backend/lib/routes/index.js @@ -12,6 +12,7 @@ import openapiRoute from '../openapi/index.js' import userRoute from './user.js' import cloudprofilesRoute from './cloudprofiles.js' import seedsRoute from './seeds.js' +import seedStatsRoute from './seedstats.js' import managedSeedsRoute from './managedseeds.js' import managedSeedShootsRoute from './managedseedshoots.js' import gardenerExtensionsRoute from './gardenerExtensions.js' @@ -30,6 +31,7 @@ const routes = { '/user': userRoute, '/cloudprofiles': cloudprofilesRoute, '/seeds': seedsRoute, + '/seedstats': seedStatsRoute, '/namespaces/:namespace/managedseeds': managedSeedsRoute, '/namespaces/:namespace/managedseed-shoots': managedSeedShootsRoute, '/gardenerextensions': gardenerExtensionsRoute, diff --git a/backend/lib/routes/seedstats.js b/backend/lib/routes/seedstats.js new file mode 100644 index 0000000000..c58806772f --- /dev/null +++ b/backend/lib/routes/seedstats.js @@ -0,0 +1,42 @@ +// +// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Gardener contributors +// +// SPDX-License-Identifier: Apache-2.0 +// + +import express from 'express' +import services from '../services/index.js' +import { metricsRoute } from '../middleware.js' + +const { seedstats } = services + +const router = express.Router() + +const metricsMiddleware = metricsRoute('seedstats') + +router.route('/') + .all(metricsMiddleware) + .get(async (req, res, next) => { + try { + const user = req.user + const unhealthyFilterMask = req.query.unhealthyFilterMask + res.send(await seedstats.list({ user, unhealthyFilterMask })) + } catch (err) { + next(err) + } + }) + +router.route('/:name') + .all(metricsMiddleware) + .get(async (req, res, next) => { + try { + const user = req.user + const name = req.params.name + const unhealthyFilterMask = req.query.unhealthyFilterMask + res.send(await seedstats.read({ user, name, unhealthyFilterMask })) + } catch (err) { + next(err) + } + }) + +export default router diff --git a/backend/lib/services/index.js b/backend/lib/services/index.js index 772633c10e..d67a56922d 100644 --- a/backend/lib/services/index.js +++ b/backend/lib/services/index.js @@ -6,6 +6,7 @@ import * as cloudprofiles from './cloudprofiles.js' import * as seeds from './seeds.js' +import * as seedstats from './seedstats.js' import * as managedseeds from './managedseeds.js' import * as managedseedshoots from './managedseedshoots.js' import * as projects from './projects.js' @@ -24,6 +25,7 @@ const terminals = terminalsModule export default { cloudprofiles, seeds, + seedstats, managedseeds, managedseedshoots, projects, diff --git a/backend/lib/services/seedstats.js b/backend/lib/services/seedstats.js new file mode 100644 index 0000000000..69ec867e4d --- /dev/null +++ b/backend/lib/services/seedstats.js @@ -0,0 +1,296 @@ +// +// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Gardener contributors +// +// SPDX-License-Identifier: Apache-2.0 +// + +import httpErrors from 'http-errors' +import { + compact, + flatMap, + uniq, +} from 'lodash-es' +import * as authorization from './authorization.js' +import cache from '../cache/index.js' +import config from '../config/index.js' +import logger from '../logger/index.js' +import { + isTruthyValue, + shootHasIssue, +} from '../utils/index.js' + +const { Forbidden, NotFound, UnprocessableEntity } = httpErrors + +const apiVersion = 'dashboard.gardener.cloud/v1alpha1' +const kind = 'SeedStat' + +// unhealthyFilterMask is a bitmask controlling which unhealthy shoots are counted +// in unhealthyShoots.matching. Each set bit *excludes* a category from matching: +// 0 (000) — no filter: matching === total (every unhealthy shoot counts) +// 1 (001) — exclude shoots whose status is 'progressing' +// 2 (010) — exclude shoots where no operator action is required +// (ignored issues, temporary errors, or user-caused errors) +// 4 (100) — exclude shoots whose only open tickets carry the hide label +// Bits may be combined freely; 7 (111) gives the most filtered view. +const FILTER_PROGRESSING = 1 +const FILTER_NO_OPERATOR_ACTION = 2 +const FILTER_HIDE_TICKETS = 4 +const ALL_UNHEALTHY_FILTER_FLAGS = FILTER_PROGRESSING | FILTER_NO_OPERATOR_ACTION | FILTER_HIDE_TICKETS + +// Keep this derived classification in sync with frontend/src/utils/errorCodes.js +// (`userError` / `temporaryError` flags). +const userErrorCodes = new Set([ + 'ERR_INFRA_UNAUTHENTICATED', + 'ERR_INFRA_UNAUTHORIZED', + 'ERR_INFRA_QUOTA_EXCEEDED', + 'ERR_INFRA_DEPENDENCIES', + 'ERR_CLEANUP_CLUSTER_RESOURCES', + 'ERR_INFRA_RESOURCES_DEPLETED', + 'ERR_CONFIGURATION_PROBLEM', + 'ERR_RETRYABLE_CONFIGURATION_PROBLEM', + 'ERR_PROBLEMATIC_WEBHOOK', +]) + +const temporaryErrorCodes = new Set([ + 'ERR_INFRA_RATE_LIMITS_EXCEEDED', + 'ERR_RETRYABLE_INFRA_DEPENDENCIES', +]) + +async function list ({ user, unhealthyFilterMask }) { + await ensureAccess(user) + + unhealthyFilterMask = parseUnhealthyFilterMask(unhealthyFilterMask) + + const seeds = cache.getSeeds() + const statsMap = getSeedStatsMap(seeds, unhealthyFilterMask) + + return seeds.map(seed => toSeedStat(seed, statsMap.get(seed.metadata.name))) +} + +async function read ({ user, name, unhealthyFilterMask }) { + await ensureAccess(user) + + unhealthyFilterMask = parseUnhealthyFilterMask(unhealthyFilterMask) + + const seed = cache.getSeed(name) + if (!seed) { + throw new NotFound(`Seed with name '${name}' not found`) + } + + const counts = getCountsForSeedName(name, unhealthyFilterMask) + return toSeedStat(seed, counts) +} + +function getByUids (uids, unhealthyFilterMask) { + unhealthyFilterMask = parseUnhealthyFilterMask(unhealthyFilterMask) + return uids.map(uid => { + const seed = cache.getSeedByUid(uid) + if (!seed) { + return undefined + } + const counts = getCountsForSeedName(seed.metadata.name, unhealthyFilterMask) + return toSeedStat(seed, counts) + }) +} + +function toSeedStat (seed, counts = emptyCounts()) { + return { + apiVersion, + kind, + metadata: { + name: seed.metadata.name, + uid: seed.metadata.uid, + }, + counts: { + shootCount: counts.shootCount ?? 0, + unhealthyShoots: { + total: counts.unhealthyShoots?.total ?? 0, + matching: counts.unhealthyShoots?.matching ?? 0, + }, + }, + } +} + +function getSeedStatsMap (seeds, unhealthyFilterMask) { + const statsMap = new Map() + for (const seed of seeds) { + const counts = getCountsForSeedName(seed.metadata.name, unhealthyFilterMask) + statsMap.set(seed.metadata.name, counts) + } + return statsMap +} + +function getCountsForSeedName (seedName, unhealthyFilterMask) { + const counts = emptyCounts() + for (const shoot of cache.getShootsBySeedName(seedName)) { + countShoot(counts, shoot, unhealthyFilterMask) + } + return counts +} + +function emptyCounts () { + return { + shootCount: 0, + unhealthyShoots: { + total: 0, + matching: 0, + }, + } +} + +function countShoot (counts, shoot, unhealthyFilterMask) { + counts.shootCount += 1 + if (!shootHasIssue(shoot)) { + return + } + + counts.unhealthyShoots.total += 1 + if (shouldCountAsUnhealthy(shoot, unhealthyFilterMask)) { + counts.unhealthyShoots.matching += 1 + } +} + +async function ensureAccess (user) { + const [canListSeeds, canListAllShoots] = await Promise.all([ + authorization.canListSeeds(user), + authorization.canListShoots(user), + ]) + + if (!canListSeeds || !canListAllShoots) { + throw new Forbidden('You are not allowed to list seed stats') + } +} + +function parseUnhealthyFilterMask (unhealthyFilterMask) { + if (typeof unhealthyFilterMask === 'undefined') { + throw invalidUnhealthyFilterMask() + } + + if (typeof unhealthyFilterMask === 'string') { + unhealthyFilterMask = Number(unhealthyFilterMask) + } + + if (!isValidUnhealthyFilterMask(unhealthyFilterMask)) { + throw invalidUnhealthyFilterMask() + } + + return unhealthyFilterMask +} + +function isValidUnhealthyFilterMask (value) { + return typeof value === 'number' && + Number.isInteger(value) && + (value & ~ALL_UNHEALTHY_FILTER_FLAGS) === 0 // rejects negatives and any bits not in 0b111 (7) +} + +function shouldCountAsUnhealthy (shoot, unhealthyFilterMask) { + if (isFilterEnabled(unhealthyFilterMask, FILTER_PROGRESSING) && isStatusProgressing(shoot)) { + return false + } + if (isFilterEnabled(unhealthyFilterMask, FILTER_NO_OPERATOR_ACTION) && hasNoOperatorActionRequired(shoot)) { + return false + } + if (isFilterEnabled(unhealthyFilterMask, FILTER_HIDE_TICKETS) && hasOnlyTicketsWithHideLabel(shoot)) { + return false + } + return true +} + +function isFilterEnabled (mask, flag) { + return (mask & flag) !== 0 +} + +function isStatusProgressing (shoot) { + return shoot?.metadata?.labels?.['shoot.gardener.cloud/status'] === 'progressing' +} + +function hasNoOperatorActionRequired (shoot) { + if (isTruthyValue(shoot?.metadata?.annotations?.['dashboard.gardener.cloud/ignore-issues'])) { + return true + } + + const lastErrorCodes = errorCodesFromArray(shoot?.status?.lastErrors) + if (hasAnyTemporaryError(lastErrorCodes)) { + return true + } + + if (hasAnyUserError(lastErrorCodes)) { + return true + } + + if (hasAnyUserError(errorCodesFromArray(shoot?.status?.conditions))) { + return true + } + + return hasAnyUserError(errorCodesFromArray(shoot?.status?.constraints)) +} + +function hasOnlyTicketsWithHideLabel (shoot) { + const hideClustersWithLabels = config.frontend?.ticket?.hideClustersWithLabels + if (!Array.isArray(hideClustersWithLabels) || hideClustersWithLabels.length === 0) { + return false + } + + const ticketsForShoot = getTicketsForShoot(shoot) + if (ticketsForShoot.length === 0) { + return false + } + + return ticketsForShoot.every(ticket => { + const labelNames = ticket?.data?.labels?.map(({ name }) => name) ?? [] + return hideClustersWithLabels.some(label => labelNames.includes(label)) + }) +} + +function getTicketsForShoot (shoot) { + const projectName = getProjectNameForShoot(shoot) + const shootName = shoot?.metadata?.name + if (!projectName || !shootName) { + return [] + } + + return cache + .getTicketCache() + .getIssuesForShoot({ projectName, name: shootName }) +} + +function getProjectNameForShoot (shoot) { + const namespace = shoot?.metadata?.namespace + if (!namespace) { + return + } + try { + const project = cache.findProjectByNamespace(namespace) + return project?.metadata?.name + } catch (err) { + logger.warn('Failed to resolve project for namespace %s: %s', namespace, err.message) + return undefined + } +} + +function errorCodesFromArray (items = []) { + return uniq(compact(flatMap(items, 'codes'))) +} + +function hasAnyUserError (codes = []) { + return codes.some(code => userErrorCodes.has(code)) +} + +function hasAnyTemporaryError (codes = []) { + return codes.some(code => temporaryErrorCodes.has(code)) +} + +function invalidUnhealthyFilterMask () { + return new UnprocessableEntity(`The 'unhealthyFilterMask' query parameter must be a non-negative integer with no bits set outside the known flags (0–${ALL_UNHEALTHY_FILTER_FLAGS})`) +} + +export { + FILTER_PROGRESSING, + FILTER_NO_OPERATOR_ACTION, + FILTER_HIDE_TICKETS, + list, + read, + getByUids, + parseUnhealthyFilterMask, + shouldCountAsUnhealthy, +} diff --git a/backend/lib/utils/index.js b/backend/lib/utils/index.js index b783c7cd93..c091838b41 100644 --- a/backend/lib/utils/index.js +++ b/backend/lib/utils/index.js @@ -262,6 +262,10 @@ function shootHasIssue (shoot) { return _.get(shoot, ['metadata', 'labels', 'shoot.gardener.cloud/status'], 'healthy') !== 'healthy' } +function isTruthyValue (value) { + return ['1', 't', 'T', 'true', 'TRUE', 'True'].includes(value) +} + function getSeedIngressDomain (seed) { return _.get(seed, ['spec', 'ingress', 'domain']) } @@ -292,6 +296,7 @@ export { getConfigValue, getSeedNameFromShoot, shootHasIssue, + isTruthyValue, getSeedIngressDomain, isSeedUnreachable, } diff --git a/backend/lib/watches/leases.js b/backend/lib/watches/leases.js index bffc0b16a3..38f660d1e8 100644 --- a/backend/lib/watches/leases.js +++ b/backend/lib/watches/leases.js @@ -9,6 +9,8 @@ import logger from '../logger/index.js' import config from '../config/index.js' import cache from '../cache/index.js' import * as tickets from '../services/tickets.js' +import { getJoinedRooms } from '../io/seedstats.js' +import { FILTER_HIDE_TICKETS } from '../services/seedstats.js' import SyncManager from '../github/SyncManager.js' // exported for testing @@ -35,7 +37,10 @@ export default (io, informer, { signal }) => { const ticketCache = cache.getTicketCache() const nsp = io.of('/') - ticketCache.on('issue', event => nsp.emit('issues', event)) + ticketCache.on('issue', event => { + nsp.emit('issues', event) + publishSeedStats(event) + }) ticketCache.on('comment', event => { const { projectName, name } = event.object.metadata const namespace = cache.getProjectNamespace(projectName) @@ -57,4 +62,33 @@ export default (io, informer, { signal }) => { const handleEvent = event => syncManager.sync() informer.on('update', object => handleEvent({ type: 'MODIFIED', object })) + + function publishSeedStats (event) { + const { projectName, name } = event.object?.metadata ?? {} + if (!projectName || !name) { + return + } + + const namespace = cache.getProjectNamespace(projectName) + if (!namespace) { + return + } + + const seedName = cache.getShoot(namespace, name)?.spec?.seedName + if (!seedName) { + return + } + + const seedUid = cache.getSeed(seedName)?.metadata?.uid + if (!seedUid) { + return + } + + const hasHideTicketsFilterEnabled = room => (room.unhealthyFilterMask & FILTER_HIDE_TICKETS) !== 0 + const joinedRooms = getJoinedRooms(nsp, { seedName }) + .filter(hasHideTicketsFilterEnabled) + for (const room of joinedRooms) { + nsp.to(room.room).emit('seedstats', { type: 'MODIFIED', uid: seedUid }) + } + } } diff --git a/backend/lib/watches/seeds.js b/backend/lib/watches/seeds.js index 82edb0100d..1e88cb1481 100644 --- a/backend/lib/watches/seeds.js +++ b/backend/lib/watches/seeds.js @@ -5,15 +5,20 @@ // import { get } from 'lodash-es' +import { getJoinedRooms } from '../io/seedstats.js' export default (io, informer) => { const nsp = io.of('/') - const handleEvent = (type, newObject, oldObject) => { - const path = ['metadata', 'uid'] - const uid = get(newObject, path, get(oldObject, path)) + const handleEvent = (type, newObject) => { + const uid = get(newObject, ['metadata', 'uid']) + const seedName = get(newObject, ['metadata', 'name']) const event = { uid, type } nsp.to('seeds').emit('seeds', event) + + for (const room of getJoinedRooms(nsp, { seedName })) { + nsp.to(room.room).emit('seedstats', event) + } } informer.on('add', object => handleEvent('ADDED', object)) diff --git a/backend/lib/watches/shoots.js b/backend/lib/watches/shoots.js index 5aa761c812..02626421cd 100644 --- a/backend/lib/watches/shoots.js +++ b/backend/lib/watches/shoots.js @@ -6,6 +6,8 @@ import { shootHasIssue } from '../utils/index.js' import cache from '../cache/index.js' +import { getJoinedRooms } from '../io/seedstats.js' +import { shouldCountAsUnhealthy } from '../services/seedstats.js' export default (io, informer, options) => { const nsp = io.of('/') @@ -65,13 +67,69 @@ export default (io, informer, options) => { nsp.to('managedseed-shoots;garden').emit('managedseed-shoots', { type, uid }) } + const publishSeedStats = event => { + const { object, oldObject } = event + const oldSeedName = oldObject?.spec?.seedName + const newSeedName = object?.spec?.seedName + const affectedSeedNames = new Set([oldSeedName, newSeedName].filter(Boolean)) + + for (const seedName of affectedSeedNames) { + const seedUid = cache.getSeed(seedName)?.metadata?.uid + if (!seedUid) { + continue + } + + const joinedRooms = getJoinedRooms(nsp, { seedName }) + for (const { room, unhealthyFilterMask } of joinedRooms) { + if (!shouldEmitSeedStatsEvent(event, { unhealthyFilterMask })) { + continue + } + nsp.to(room).emit('seedstats', { type: 'MODIFIED', uid: seedUid }) + } + } + } + const handleEvent = event => { publishShoots(event) publishUnhealthyShoots(event) publishManagedSeedShoots(event) + publishSeedStats(event) } informer.on('add', object => handleEvent({ type: 'ADDED', object })) - informer.on('update', object => handleEvent({ type: 'MODIFIED', object })) - informer.on('delete', object => handleEvent({ type: 'DELETED', object })) + informer.on('update', (object, oldObject) => handleEvent({ type: 'MODIFIED', object, oldObject })) + informer.on('delete', object => handleEvent({ type: 'DELETED', object, oldObject: object })) +} + +function shouldEmitSeedStatsEvent (event, { unhealthyFilterMask }) { + const { type, object, oldObject } = event + const oldSeedName = oldObject?.spec?.seedName + const newSeedName = object?.spec?.seedName + + switch (type) { + case 'ADDED': + case 'DELETED': + return true + case 'MODIFIED': { + const seedChanged = oldSeedName !== newSeedName + if (seedChanged) { + return true + } + return didShootHealthStateChange(oldObject, object, unhealthyFilterMask) + } + default: + return false + } +} + +function didShootHealthStateChange (oldObject, newObject, unhealthyFilterMask) { + const hadIssueBefore = shootHasIssue(oldObject) + const hasIssueNow = shootHasIssue(newObject) + + if (hadIssueBefore !== hasIssueNow) { + return true + } + + return shouldCountAsUnhealthy(oldObject, unhealthyFilterMask) !== + shouldCountAsUnhealthy(newObject, unhealthyFilterMask) } diff --git a/frontend/__tests__/components/GSeedInfrastructureCard.spec.js b/frontend/__tests__/components/GSeedInfrastructureCard.spec.js new file mode 100644 index 0000000000..120e38dc88 --- /dev/null +++ b/frontend/__tests__/components/GSeedInfrastructureCard.spec.js @@ -0,0 +1,135 @@ +// +// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Gardener contributors +// +// SPDX-License-Identifier: Apache-2.0 +// + +import { ref } from 'vue' +import { + createPinia, + setActivePinia, +} from 'pinia' +import { shallowMount } from '@vue/test-utils' + +import { useSeedStatStore } from '@/store/seedStat' + +import GSeedInfrastructureCard from '@/components/SeedDetails/GSeedInfrastructureCard.vue' + +describe('components', () => { + describe('g-seed-infrastructure-card', () => { + function createSeedItem () { + return { + seedProviderType: ref('aws'), + seedProviderRegion: ref('eu-west-1'), + seedProviderZones: ref(['eu-west-1a']), + seedNetworksNodes: ref('10.250.0.0/16'), + seedNetworksPods: ref('100.96.0.0/11'), + seedNetworksServices: ref('100.64.0.0/13'), + seedNetworksShootDefaultsPods: ref('10.0.0.0/16'), + seedNetworksShootDefaultsServices: ref('10.1.0.0/16'), + seedNetworksBlockCIDRs: ref([]), + seedAllocatableShoots: ref(20), + seedShootCount: ref(7), + seedTotalUnhealthyShoots: ref(4), + seedUnhealthyShoots: ref(2), + seedName: ref('infra1-seed'), + } + } + + function mountComponent () { + setActivePinia(createPinia()) + + const seedStatStore = useSeedStatStore() + + const subscribeSpy = vi.spyOn(seedStatStore, 'subscribe').mockResolvedValue() + const unsubscribeSpy = vi.spyOn(seedStatStore, 'unsubscribe').mockResolvedValue() + + const wrapper = shallowMount(GSeedInfrastructureCard, { + global: { + provide: { + 'seed-item': createSeedItem(), + }, + stubs: { + VCard: { + template: '
', + }, + VDivider: { + template: '
', + }, + VIcon: { + template: '', + }, + GToolbar: { + props: ['title'], + template: '
{{ title }}
', + }, + GList: { + template: '
', + }, + GListItem: { + template: '
', + }, + GListItemContent: { + props: ['label'], + template: ` +
+ +
+
+ `, + }, + GVendor: { + template: '
', + }, + GShootHealthDonut: { + props: ['shootCount', 'totalUnhealthyShoots', 'matchingUnhealthyShoots'], + template: '
', + }, + GSeedCapacityIndicator: { + props: ['allocatableShoots', 'shootCount'], + template: '
', + }, + }, + }, + }) + + return { + wrapper, + seedStatStore, + subscribeSpy, + unsubscribeSpy, + } + } + + it('should render the shoot health donut and capacity in the infrastructure card', () => { + const { + wrapper, + seedStatStore, + subscribeSpy, + unsubscribeSpy, + } = mountComponent() + + const healthDonut = wrapper.find('.shoot-health-donut-stub') + const capacityIndicator = wrapper.find('.seed-capacity-indicator-stub') + + expect(wrapper.text()).toContain('Capacity') + expect(wrapper.text()).toContain('Shoot Health') + expect(capacityIndicator.exists()).toBe(true) + expect(capacityIndicator.attributes('data-allocatable-shoots')).toBe('20') + expect(capacityIndicator.attributes('data-shoot-count')).toBe('7') + expect(healthDonut.exists()).toBe(true) + expect(healthDonut.attributes('data-shoot-count')).toBe('7') + expect(healthDonut.attributes('data-unhealthy-total')).toBe('4') + expect(healthDonut.attributes('data-unhealthy-matching')).toBe('2') + expect(wrapper.text()).not.toContain('total unhealthy') + expect(subscribeSpy).toHaveBeenCalledWith({ + name: 'infra1-seed', + unhealthyFilterMask: seedStatStore.currentUnhealthyFilterMask, + }) + + wrapper.unmount() + + expect(unsubscribeSpy).toHaveBeenCalled() + }) + }) +}) diff --git a/frontend/__tests__/components/GShootHealthDonut.spec.js b/frontend/__tests__/components/GShootHealthDonut.spec.js new file mode 100644 index 0000000000..01be70f683 --- /dev/null +++ b/frontend/__tests__/components/GShootHealthDonut.spec.js @@ -0,0 +1,345 @@ +// +// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Gardener contributors +// +// SPDX-License-Identifier: Apache-2.0 +// + +import { shallowMount } from '@vue/test-utils' + +import GShootHealthDonut from '@/components/GShootHealthDonut.vue' + +describe('components', () => { + describe('g-shoot-health-donut', () => { + function mountComponent (props = {}) { + return shallowMount(GShootHealthDonut, { + props, + global: { + stubs: { + 'v-tooltip': { + template: '
', + }, + 'v-card': { + template: '
', + }, + 'v-icon': true, + 'g-list': { + template: '
', + }, + 'g-list-item': { + template: '
', + }, + 'g-list-item-content': { + props: ['label', 'description'], + template: '', + }, + }, + }, + }) + } + + describe('empty state', () => { + it('should show a dash when shootCount is 0', () => { + const wrapper = mountComponent({ shootCount: 0 }) + + expect(wrapper.find('.empty').exists()).toBe(true) + expect(wrapper.find('.empty').text()).toBe('-') + expect(wrapper.find('svg').exists()).toBe(false) + }) + + it('should show a dash when shootCount is not provided', () => { + const wrapper = mountComponent() + + expect(wrapper.find('.empty').exists()).toBe(true) + }) + }) + + describe('donut rendering', () => { + it('should render an SVG when there are shoots', () => { + const wrapper = mountComponent({ + shootCount: 10, + totalUnhealthyShoots: 3, + matchingUnhealthyShoots: 1, + }) + + expect(wrapper.find('.empty').exists()).toBe(false) + expect(wrapper.find('svg').exists()).toBe(true) + }) + + it('should render base segments for unhealthy and healthy', () => { + const wrapper = mountComponent({ + shootCount: 10, + totalUnhealthyShoots: 4, + matchingUnhealthyShoots: 2, + }) + + const segments = wrapper.findAll('circle.segment') + const keys = segments.map(s => { + if (s.classes().includes('matching')) { + return 'matching' + } + if (s.classes().includes('unhealthy')) { + return 'unhealthy' + } + if (s.classes().includes('healthy')) { + return 'healthy' + } + return 'unknown' + }) + + expect(keys).toContain('unhealthy') + expect(keys).toContain('healthy') + expect(keys).toContain('matching') + }) + + it('should not render overlay segment when matching is 0', () => { + const wrapper = mountComponent({ + shootCount: 10, + totalUnhealthyShoots: 3, + matchingUnhealthyShoots: 0, + }) + + const segments = wrapper.findAll('circle.segment') + const keys = segments.map(s => { + if (s.classes().includes('matching')) { + return 'matching' + } + if (s.classes().includes('unhealthy')) { + return 'unhealthy' + } + if (s.classes().includes('healthy')) { + return 'healthy' + } + return 'unknown' + }) + + expect(keys).not.toContain('matching') + expect(keys).toContain('unhealthy') + expect(keys).toContain('healthy') + }) + + it('should render all healthy when no unhealthy shoots', () => { + const wrapper = mountComponent({ + shootCount: 5, + totalUnhealthyShoots: 0, + matchingUnhealthyShoots: 0, + }) + + const segments = wrapper.findAll('circle.segment') + + expect(segments).toHaveLength(1) + expect(segments[0].classes()).toContain('healthy') + }) + }) + + describe('center text', () => { + it('should show the matching unhealthy count', () => { + const wrapper = mountComponent({ + shootCount: 10, + totalUnhealthyShoots: 5, + matchingUnhealthyShoots: 3, + }) + + const text = wrapper.find('.center-text') + expect(text.text()).toBe('3') + }) + + it('should show 0 when no matching unhealthy', () => { + const wrapper = mountComponent({ + shootCount: 10, + totalUnhealthyShoots: 0, + matchingUnhealthyShoots: 0, + }) + + const text = wrapper.find('.center-text') + expect(text.text()).toBe('0') + }) + + it('should use compact format for values >= 1000', () => { + const wrapper = mountComponent({ + shootCount: 2000, + totalUnhealthyShoots: 1500, + matchingUnhealthyShoots: 1500, + }) + + const text = wrapper.find('.center-text') + expect(text.text()).toBe('1.5k') + expect(text.classes()).toContain('compact') + }) + + it('should use small class for values >= 100', () => { + const wrapper = mountComponent({ + shootCount: 500, + totalUnhealthyShoots: 150, + matchingUnhealthyShoots: 150, + }) + + const text = wrapper.find('.center-text') + expect(text.text()).toBe('150') + expect(text.classes()).toContain('small') + }) + + it('should have error class when matching unhealthy > 0', () => { + const wrapper = mountComponent({ + shootCount: 10, + totalUnhealthyShoots: 3, + matchingUnhealthyShoots: 2, + }) + + expect(wrapper.find('.center-text').classes()).toContain('error') + }) + + it('should not have error class when matching unhealthy is 0', () => { + const wrapper = mountComponent({ + shootCount: 10, + totalUnhealthyShoots: 0, + matchingUnhealthyShoots: 0, + }) + + expect(wrapper.find('.center-text').classes()).not.toContain('error') + }) + }) + + describe('tooltip legend', () => { + it('should show matching count when matching equals total', () => { + const wrapper = mountComponent({ + shootCount: 10, + totalUnhealthyShoots: 5, + matchingUnhealthyShoots: 5, + }) + + const unhealthy = wrapper.find('.g-list-item-content-stub[data-label="Unhealthy"]') + expect(unhealthy.exists()).toBe(true) + expect(unhealthy.text()).toBe('5') + + const excluded = wrapper.find('.g-list-item-content-stub[data-label="Excluded"]') + expect(excluded.exists()).toBe(false) + }) + + it('should show excluded row when matching differs from total', () => { + const wrapper = mountComponent({ + shootCount: 10, + totalUnhealthyShoots: 5, + matchingUnhealthyShoots: 2, + activeFilterLabels: ['User Errors', 'Progressing Clusters'], + }) + + const unhealthy = wrapper.find('.g-list-item-content-stub[data-label="Unhealthy"]') + expect(unhealthy.text()).toBe('2') + expect(unhealthy.attributes('data-description')).toBeUndefined() + + const excluded = wrapper.find('.g-list-item-content-stub[data-label="Excluded"]') + expect(excluded.exists()).toBe(true) + expect(excluded.text()).toBe('3') + expect(excluded.attributes('data-description')).toBe('User Errors, Progressing Clusters') + }) + + it('should truncate filter labels after 2 with "& N more"', () => { + const wrapper = mountComponent({ + shootCount: 20, + totalUnhealthyShoots: 10, + matchingUnhealthyShoots: 4, + activeFilterLabels: ['Progressing Clusters', 'User Errors', 'Deactivated Reconciliation', 'Tickets with Ignore Labels'], + }) + + const excluded = wrapper.find('.g-list-item-content-stub[data-label="Excluded"]') + expect(excluded.text()).toBe('6') + expect(excluded.attributes('data-description')).toBe('Progressing Clusters, User Errors & 2 more') + }) + + it('should show excluded row without description when no filter labels provided', () => { + const wrapper = mountComponent({ + shootCount: 10, + totalUnhealthyShoots: 5, + matchingUnhealthyShoots: 2, + }) + + const excluded = wrapper.find('.g-list-item-content-stub[data-label="Excluded"]') + expect(excluded.exists()).toBe(true) + expect(excluded.text()).toBe('3') + expect(excluded.attributes('data-description')).toBeUndefined() + }) + + it('should show healthy legend when there are healthy shoots', () => { + const wrapper = mountComponent({ + shootCount: 10, + totalUnhealthyShoots: 3, + matchingUnhealthyShoots: 1, + }) + + const content = wrapper.find('.g-list-item-content-stub[data-label="Healthy"]') + expect(content.exists()).toBe(true) + expect(content.text()).toBe('7') + }) + }) + + describe('accessibility', () => { + it('should have a descriptive aria-label on the SVG', () => { + const wrapper = mountComponent({ + shootCount: 10, + totalUnhealthyShoots: 3, + matchingUnhealthyShoots: 1, + activeFilterLabels: ['User Errors'], + }) + + const svg = wrapper.find('svg') + const label = svg.attributes('aria-label') + + expect(label).toContain('Shoot health distribution') + expect(label).toContain('10 shoots') + expect(label).toContain('1 unhealthy shoots') + expect(label).toContain('2 excluded by filter (User Errors)') + expect(label).toContain('7 healthy shoots') + }) + }) + + describe('prop validators', () => { + it('should reject negative shootCount', () => { + const shootCountProp = GShootHealthDonut.props.shootCount + expect(shootCountProp.validator(-1)).toBe(false) + }) + + it('should reject non-integer shootCount', () => { + const shootCountProp = GShootHealthDonut.props.shootCount + expect(shootCountProp.validator(1.5)).toBe(false) + }) + + it('should accept valid shootCount', () => { + const shootCountProp = GShootHealthDonut.props.shootCount + expect(shootCountProp.validator(0)).toBe(true) + expect(shootCountProp.validator(10)).toBe(true) + }) + + it('should reject negative totalUnhealthyShoots', () => { + const prop = GShootHealthDonut.props.totalUnhealthyShoots + expect(prop.validator(-1)).toBe(false) + }) + + it('should reject non-integer totalUnhealthyShoots', () => { + const prop = GShootHealthDonut.props.totalUnhealthyShoots + expect(prop.validator(1.5)).toBe(false) + }) + + it('should accept valid totalUnhealthyShoots', () => { + const prop = GShootHealthDonut.props.totalUnhealthyShoots + expect(prop.validator(0)).toBe(true) + expect(prop.validator(5)).toBe(true) + }) + + it('should reject negative matchingUnhealthyShoots', () => { + const prop = GShootHealthDonut.props.matchingUnhealthyShoots + expect(prop.validator(-1)).toBe(false) + }) + + it('should reject non-integer matchingUnhealthyShoots', () => { + const prop = GShootHealthDonut.props.matchingUnhealthyShoots + expect(prop.validator(1.5)).toBe(false) + }) + + it('should accept valid matchingUnhealthyShoots', () => { + const prop = GShootHealthDonut.props.matchingUnhealthyShoots + expect(prop.validator(0)).toBe(true) + expect(prop.validator(3)).toBe(true) + }) + }) + }) +}) diff --git a/frontend/__tests__/composables/useDonutChart.spec.js b/frontend/__tests__/composables/useDonutChart.spec.js new file mode 100644 index 0000000000..2ed1b69caa --- /dev/null +++ b/frontend/__tests__/composables/useDonutChart.spec.js @@ -0,0 +1,158 @@ +// +// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Gardener contributors +// +// SPDX-License-Identifier: Apache-2.0 +// + +import { + ref, + nextTick, +} from 'vue' + +import { useDonutChart } from '@/composables/useDonutChart' + +describe('composables', () => { + describe('useDonutChart', () => { + it('should throw if segments are not provided as a ref', () => { + expect(() => useDonutChart([])).toThrow(new TypeError('First argument `segmentsDef` must be a ref object')) + }) + + it('should throw if total override is not provided as a ref', () => { + const segments = ref([]) + + expect(() => useDonutChart(segments, { total: 100 })).toThrow(new TypeError('Option `total` must be a ref object')) + }) + + it('should return correct static geometry for default options', () => { + const segments = ref([]) + const donut = useDonutChart(segments) + + expect(donut.size).toBe(30) + expect(donut.center).toBe(15) + expect(donut.strokeWidth).toBe(4) + expect(donut.radius).toBe(13) + expect(donut.viewBox).toBe('0 0 30 30') + expect(donut.rotateTransform).toBe('rotate(-90 15 15)') + expect(donut.circumference).toBeCloseTo(2 * Math.PI * 13) + }) + + it('should return correct geometry for custom size and strokeWidth', () => { + const segments = ref([]) + const donut = useDonutChart(segments, { size: 64, strokeWidth: 8 }) + + expect(donut.size).toBe(64) + expect(donut.center).toBe(32) + expect(donut.strokeWidth).toBe(8) + expect(donut.radius).toBe(28) + expect(donut.viewBox).toBe('0 0 64 64') + }) + + it('should return empty visibleSegments when segments are empty', () => { + const segments = ref([]) + const donut = useDonutChart(segments) + + expect(donut.visibleSegments.value).toEqual([]) + }) + + it('should filter out segments with value <= 0', () => { + const segments = ref([ + { key: 'a', value: 10 }, + { key: 'b', value: 0 }, + { key: 'c', value: -5 }, + { key: 'd', value: 5 }, + ]) + const donut = useDonutChart(segments) + + const keys = donut.visibleSegments.value.map(s => s.key) + expect(keys).toEqual(['a', 'd']) + }) + + it('should compute correct dasharray for a single segment', () => { + const segments = ref([ + { key: 'only', value: 100 }, + ]) + const donut = useDonutChart(segments) + const [seg] = donut.visibleSegments.value + + // Single segment fills the entire circumference + expect(seg.dasharray).toBe(`${donut.circumference} 0`) + expect(seg.dashoffset).toBe('0') + }) + + it('should compute correct dasharray and dashoffset for multiple segments', () => { + const segments = ref([ + { key: 'a', value: 75 }, + { key: 'b', value: 25 }, + ]) + const donut = useDonutChart(segments) + const result = donut.visibleSegments.value + const c = donut.circumference + + expect(result).toHaveLength(2) + + // First segment: 75% of circumference + const lengthA = 0.75 * c + expect(result[0].dasharray).toBe(`${lengthA} ${c}`) + expect(result[0].dashoffset).toBe('0') + + // Second segment: 25% of circumference, offset by first + const lengthB = 0.25 * c + expect(result[1].dasharray).toBe(`${lengthB} ${c}`) + expect(result[1].dashoffset).toBe(`${-lengthA}`) + }) + + it('should use total override when provided', () => { + const segments = ref([ + { key: 'a', value: 20 }, + ]) + const total = ref(100) + const donut = useDonutChart(segments, { total }) + const [seg] = donut.visibleSegments.value + const c = donut.circumference + + // 20/100 = 20% of circumference + const expected = 0.2 * c + expect(seg.dasharray).toBe(`${expected} ${c}`) + }) + + it('should fall back to sum when total override is 0', () => { + const segments = ref([ + { key: 'a', value: 50 }, + { key: 'b', value: 50 }, + ]) + const donut = useDonutChart(segments, { total: ref(0) }) + const result = donut.visibleSegments.value + + // Falls back to sum = 100, each gets 50% + expect(result).toHaveLength(2) + }) + + it('should react to segment changes', async () => { + const segments = ref([ + { key: 'a', value: 100 }, + ]) + const donut = useDonutChart(segments) + + expect(donut.visibleSegments.value).toHaveLength(1) + + segments.value = [ + { key: 'a', value: 60 }, + { key: 'b', value: 40 }, + ] + await nextTick() + + expect(donut.visibleSegments.value).toHaveLength(2) + }) + + it('should preserve extra properties on segments', () => { + const segments = ref([ + { key: 'x', value: 10, color: 'red' }, + ]) + const donut = useDonutChart(segments) + const [seg] = donut.visibleSegments.value + + expect(seg.color).toBe('red') + expect(seg.key).toBe('x') + }) + }) +}) diff --git a/frontend/__tests__/composables/useSeedTableSorting.spec.js b/frontend/__tests__/composables/useSeedTableSorting.spec.js index 39552d15cf..5a058a1951 100644 --- a/frontend/__tests__/composables/useSeedTableSorting.spec.js +++ b/frontend/__tests__/composables/useSeedTableSorting.spec.js @@ -121,5 +121,21 @@ describe('composables', () => { 'error', ]) }) + + it('should sort shoot count numerically', () => { + const { customKeySort } = useSeedTableSorting() + + expect(customKeySort.shootCount(2, 10)).toBeLessThan(0) + expect(customKeySort.shootCount(10, 2)).toBeGreaterThan(0) + expect(customKeySort.shootCount(4, 4)).toBe(0) + }) + + it('should sort unhealthy shoots numerically', () => { + const { customKeySort } = useSeedTableSorting() + + expect(customKeySort.unhealthyShoots(0, 1)).toBeLessThan(0) + expect(customKeySort.unhealthyShoots(3, 1)).toBeGreaterThan(0) + expect(customKeySort.unhealthyShoots(1, 1)).toBe(0) + }) }) }) diff --git a/frontend/__tests__/composables/useShootItem.spec.js b/frontend/__tests__/composables/useShootItem.spec.js index 29c6cfb4fb..c63479ac86 100644 --- a/frontend/__tests__/composables/useShootItem.spec.js +++ b/frontend/__tests__/composables/useShootItem.spec.js @@ -20,6 +20,7 @@ import { useProjectStore } from '@/store/project' import { useCloudProfileStore } from '@/store/cloudProfile' import { useSeedStore } from '@/store/seed' import { useCredentialStore } from '@/store/credential' +import { useLocalStorageStore } from '@/store/localStorage' import { createShootItemComposable } from '@/composables/useShootItem' @@ -27,6 +28,15 @@ import set from 'lodash/set' import cloneDeep from 'lodash/cloneDeep' import unset from 'lodash/unset' +// Disable createSharedComposable so each test gets a fresh composable instance +vi.mock('@vueuse/core', async importOriginal => { + const actual = await importOriginal() + return { + ...actual, + createSharedComposable: fn => fn, + } +}) + describe('composables', () => { describe('useProvideShootItem', () => { let shootItem @@ -211,7 +221,8 @@ describe('composables', () => { it('should compute isStaleShoot correctly', () => { authzStore._setNamespace('_all') - shootStore.state.shootListFilters = { + const localStorageStore = useLocalStorageStore() + localStorageStore.allProjectsShootFilter = { onlyShootsWithIssues: true, progressing: true, } diff --git a/frontend/__tests__/composables/useShootListFilters.spec.js b/frontend/__tests__/composables/useShootListFilters.spec.js new file mode 100644 index 0000000000..06bd8299ae --- /dev/null +++ b/frontend/__tests__/composables/useShootListFilters.spec.js @@ -0,0 +1,122 @@ +// +// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Gardener contributors +// +// SPDX-License-Identifier: Apache-2.0 +// + +import { + createPinia, + setActivePinia, +} from 'pinia' + +import { useAuthnStore } from '@/store/authn' +import { useConfigStore } from '@/store/config' +import { useLocalStorageStore } from '@/store/localStorage' + +import { useShootListFilters } from '@/composables/useShootListFilters' + +// Disable createSharedComposable so each test gets a fresh composable instance +vi.mock('@vueuse/core', async importOriginal => { + const actual = await importOriginal() + return { + ...actual, + createSharedComposable: fn => fn, + } +}) + +describe('composables', () => { + describe('useShootListFilters', () => { + let authnStore + let configStore + let localStorageStore + + beforeEach(() => { + setActivePinia(createPinia()) + authnStore = useAuthnStore() + configStore = useConfigStore() + localStorageStore = useLocalStorageStore() + localStorageStore.allProjectsShootFilter = {} + }) + + it('should return empty labels when onlyShootsWithIssues is false', () => { + authnStore.user = { isAdmin: true } + localStorageStore.allProjectsShootFilter = { + onlyShootsWithIssues: false, + progressing: true, + noOperatorAction: true, + } + + const { activeFilterLabels } = useShootListFilters() + expect(activeFilterLabels.value).toEqual([]) + }) + + it('should return active filter labels for admin defaults', () => { + authnStore.user = { isAdmin: true } + + const { activeFilterLabels } = useShootListFilters() + expect(activeFilterLabels.value).toEqual([ + 'Progressing Clusters', + 'User Errors', + 'Tickets with Ignore Labels', + ]) + }) + + it('should return only progressing for non-admin defaults', () => { + authnStore.user = { isAdmin: false } + + const { activeFilterLabels } = useShootListFilters() + // non-admin defaults: onlyShootsWithIssues=false, so no labels + expect(activeFilterLabels.value).toEqual([]) + }) + + it('should return labels matching locally stored filters', () => { + authnStore.user = { isAdmin: true } + localStorageStore.allProjectsShootFilter = { + onlyShootsWithIssues: true, + progressing: true, + noOperatorAction: false, + hideTicketsWithLabel: false, + } + + const { activeFilterLabels } = useShootListFilters() + expect(activeFilterLabels.value).toEqual([ + 'Progressing Clusters', + ]) + }) + + it('should exclude hideTicketsWithLabel when ticket config is missing', () => { + authnStore.user = { isAdmin: true } + configStore.setConfiguration({ ticket: {} }) + localStorageStore.allProjectsShootFilter = { + onlyShootsWithIssues: true, + progressing: false, + noOperatorAction: false, + hideTicketsWithLabel: true, + } + + const { activeFilterLabels } = useShootListFilters() + expect(activeFilterLabels.value).toEqual([]) + }) + + it('should include hideTicketsWithLabel when ticket config is present', () => { + authnStore.user = { isAdmin: true } + configStore.setConfiguration({ + ticket: { + gitHubRepoUrl: 'https://github.com/org/repo', + hideClustersWithLabels: ['ignore'], + }, + }) + localStorageStore.allProjectsShootFilter = { + onlyShootsWithIssues: true, + progressing: false, + noOperatorAction: false, + hideTicketsWithLabel: true, + } + + const { activeFilterLabels } = useShootListFilters() + expect(activeFilterLabels.value).toEqual([ + 'Tickets with Ignore Labels', + ]) + }) + }) +}) diff --git a/frontend/__tests__/stores/seedStat.spec.js b/frontend/__tests__/stores/seedStat.spec.js new file mode 100644 index 0000000000..be45284cd9 --- /dev/null +++ b/frontend/__tests__/stores/seedStat.spec.js @@ -0,0 +1,220 @@ +// +// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Gardener contributors +// +// SPDX-License-Identifier: Apache-2.0 +// + +import { + setActivePinia, + createPinia, +} from 'pinia' +import { flushPromises } from '@vue/test-utils' + +import { useSeedStatStore } from '@/store/seedStat' +import { useSocketStore } from '@/store/socket' + +import { useApi } from '@/composables/useApi' +import { getUnhealthyFilterMaskFromShootListFilters } from '@/composables/useShootListFilters' + +describe('stores', () => { + describe('seedStat', () => { + let api + let socketStore + let seedStatStore + + beforeEach(() => { + setActivePinia(createPinia()) + api = useApi() + socketStore = useSocketStore() + seedStatStore = useSeedStatStore() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('should fetch seed stats', async () => { + const getSeedStats = vi.spyOn(api, 'getSeedStats').mockResolvedValue({ + data: [{ + metadata: { + name: 'infra1-seed', + uid: 'seed-1', + }, + counts: { + shootCount: 3, + unhealthyShoots: { + total: 2, + matching: 1, + }, + }, + }], + }) + + seedStatStore.subscribe({ unhealthyFilterMask: 0 }) + await flushPromises() + + expect(getSeedStats).toHaveBeenCalledWith({ unhealthyFilterMask: 0 }) + expect(seedStatStore.shootCountForSeed('infra1-seed')).toBe(3) + expect(seedStatStore.unhealthyShootsForSeed('infra1-seed')).toBe(1) + }) + + it('should fetch a single seed stat', async () => { + const getSeedStat = vi.spyOn(api, 'getSeedStat').mockResolvedValue({ + data: { + metadata: { + name: 'infra1-seed', + uid: 'seed-1', + }, + counts: { + shootCount: 4, + unhealthyShoots: { + total: 4, + matching: 2, + }, + }, + }, + }) + + seedStatStore.subscribe({ + name: 'infra1-seed', + unhealthyFilterMask: 3, + }) + await flushPromises() + + expect(getSeedStat).toHaveBeenCalledWith({ + name: 'infra1-seed', + unhealthyFilterMask: 3, + }) + expect(seedStatStore.list).toEqual([ + expect.objectContaining({ + metadata: expect.objectContaining({ + name: 'infra1-seed', + }), + }), + ]) + expect(seedStatStore.unhealthyShootsForSeed('infra1-seed')).toBe(2) + }) + + it('should subscribe and unsubscribe seed stats', async () => { + vi.spyOn(api, 'getSeedStats').mockResolvedValue({ + data: [], + }) + vi.spyOn(socketStore, 'connected', 'get').mockReturnValue(true) + const emitSubscribe = vi.spyOn(socketStore, 'emitSubscribe').mockResolvedValue() + const emitUnsubscribe = vi.spyOn(socketStore, 'emitUnsubscribe').mockResolvedValue() + + seedStatStore.subscribe({ unhealthyFilterMask: 3 }) + await flushPromises() + + expect(emitSubscribe).toHaveBeenCalledWith('seedstats', { + unhealthyFilterMask: 3, + }) + expect(seedStatStore.synchronizeOptions).toEqual({ + unhealthyFilterMask: 3, + }) + + seedStatStore.unsubscribe() + await flushPromises() + + expect(emitUnsubscribe).toHaveBeenCalledWith('seedstats') + expect(seedStatStore.subscription).toBe(null) + expect(seedStatStore.list).toBe(null) + }) + + it('should cancel in-flight subscribe on unsubscribe', async () => { + let fetchResolve + const fetchPromise = new Promise(resolve => { + fetchResolve = resolve + }) + vi.spyOn(api, 'getSeedStats').mockReturnValue(fetchPromise) + vi.spyOn(socketStore, 'connected', 'get').mockReturnValue(true) + const emitSubscribe = vi.spyOn(socketStore, 'emitSubscribe').mockResolvedValue() + vi.spyOn(socketStore, 'emitUnsubscribe').mockResolvedValue() + + // Start subscribe — fetch will hang + seedStatStore.subscribe({ unhealthyFilterMask: 0 }) + await flushPromises() + + // Unsubscribe while fetch is pending + seedStatStore.unsubscribe() + await flushPromises() + + // Now resolve the fetch — stale subscribe should not call openSubscription + fetchResolve({ data: [{ metadata: { name: 'seed1' } }] }) + await flushPromises() + + expect(emitSubscribe).not.toHaveBeenCalled() + expect(seedStatStore.subscription).toBe(null) + expect(seedStatStore.list).toBe(null) + }) + + it('should cancel in-flight subscribe(A) when subscribe(B) is called', async () => { + let fetchResolveA + const fetchPromiseA = new Promise(resolve => { + fetchResolveA = resolve + }) + const fetchResultB = { data: [{ metadata: { name: 'seed-b' } }] } + + vi.spyOn(api, 'getSeedStats') + .mockReturnValueOnce(fetchPromiseA) + .mockResolvedValueOnce(fetchResultB) + vi.spyOn(socketStore, 'connected', 'get').mockReturnValue(true) + const emitSubscribe = vi.spyOn(socketStore, 'emitSubscribe').mockResolvedValue() + vi.spyOn(socketStore, 'emitUnsubscribe').mockResolvedValue() + + // Start subscribe A — fetch will hang + seedStatStore.subscribe({ unhealthyFilterMask: 1 }) + await flushPromises() + + // Subscribe B before A completes + seedStatStore.subscribe({ unhealthyFilterMask: 2 }) + await flushPromises() + + // Resolve A's fetch — should be ignored + fetchResolveA({ data: [{ metadata: { name: 'seed-a' } }] }) + await flushPromises() + + // Final state should reflect B + expect(seedStatStore.subscription).toEqual({ unhealthyFilterMask: 2 }) + expect(seedStatStore.list).toEqual([{ metadata: { name: 'seed-b' } }]) + expect(emitSubscribe).toHaveBeenCalledWith('seedstats', { unhealthyFilterMask: 2 }) + }) + + it('should re-fetch and re-open on synchronize', async () => { + const fetchData = [{ metadata: { name: 'seed1' }, counts: { shootCount: 5 } }] + const getSeedStats = vi.spyOn(api, 'getSeedStats').mockResolvedValue({ data: fetchData }) + vi.spyOn(socketStore, 'connected', 'get').mockReturnValue(true) + const emitSubscribe = vi.spyOn(socketStore, 'emitSubscribe').mockResolvedValue() + vi.spyOn(socketStore, 'emitUnsubscribe').mockResolvedValue() + + seedStatStore.subscribe({ unhealthyFilterMask: 0 }) + await flushPromises() + + expect(getSeedStats).toHaveBeenCalledTimes(1) + expect(emitSubscribe).toHaveBeenCalledTimes(1) + + // Trigger synchronize + seedStatStore.synchronize() + await flushPromises() + + expect(getSeedStats).toHaveBeenCalledTimes(2) + expect(emitSubscribe).toHaveBeenCalledTimes(2) + }) + + it('should derive the unhealthy filter mask from shoot list filters', () => { + expect(getUnhealthyFilterMaskFromShootListFilters({ + onlyShootsWithIssues: true, + progressing: true, + noOperatorAction: true, + hideTicketsWithLabel: true, + })).toBe(7) + + expect(getUnhealthyFilterMaskFromShootListFilters({ + onlyShootsWithIssues: false, + progressing: true, + noOperatorAction: true, + hideTicketsWithLabel: true, + })).toBe(0) + }) + }) +}) diff --git a/frontend/__tests__/stores/shoot.spec.js b/frontend/__tests__/stores/shoot.spec.js index c608cda5d3..b5d1f08864 100644 --- a/frontend/__tests__/stores/shoot.spec.js +++ b/frontend/__tests__/stores/shoot.spec.js @@ -235,7 +235,6 @@ describe('stores', () => { } }) mockSynchronize = vi.spyOn(socketStore, 'synchronize').mockImplementation((key, uids) => Promise.resolve(getShoots(uids))) - shootStore.initializeShootListFilters() }) describe('#sortItems', () => { diff --git a/frontend/src/components/GSeedListRow.vue b/frontend/src/components/GSeedListRow.vue index 0576228db3..e516fbece9 100644 --- a/frontend/src/components/GSeedListRow.vue +++ b/frontend/src/components/GSeedListRow.vue @@ -31,6 +31,22 @@ SPDX-License-Identifier: Apache-2.0 hide-unmanaged-chip /> + + diff --git a/frontend/src/components/Seeds/GSeedCapacityIndicator.vue b/frontend/src/components/Seeds/GSeedCapacityIndicator.vue new file mode 100644 index 0000000000..a99ba26701 --- /dev/null +++ b/frontend/src/components/Seeds/GSeedCapacityIndicator.vue @@ -0,0 +1,162 @@ + + + + + + + diff --git a/frontend/src/composables/useApi/api.js b/frontend/src/composables/useApi/api.js index a0a873c3ba..4290b110d1 100644 --- a/frontend/src/composables/useApi/api.js +++ b/frontend/src/composables/useApi/api.js @@ -21,6 +21,17 @@ function getResource (url) { return request('GET', url) } +function withQuery (url, query = {}) { + const filteredQueryEntries = Object.entries(query) + .filter(([, value]) => typeof value !== 'undefined' && value !== null) + + const search = new URLSearchParams(filteredQueryEntries).toString() + + return search + ? `${url}?${search}` + : url +} + function deleteResource (url) { return request('DELETE', url) } @@ -98,19 +109,9 @@ export function getIssuesAndComments ({ namespace, name }) { /* Shoot Clusters */ -export function getShoots ({ namespace, labelSelector, useCache }) { - const query = {} - if (labelSelector) { - query.labelSelector = labelSelector - } - if (useCache) { - query.useCache = true - } - const search = Object.keys(query).length - ? '?' + new URLSearchParams(query).toString() - : '' +export function getShoots ({ namespace, labelSelector }) { namespace = encodeURIComponent(namespace) - return getResource(`/api/namespaces/${namespace}/shoots` + search) + return getResource(withQuery(`/api/namespaces/${namespace}/shoots`, { labelSelector })) } export function getShoot ({ namespace, name }) { @@ -251,6 +252,22 @@ export function getSeeds () { return getResource('/api/seeds') } +export function getSeedStats ({ unhealthyFilterMask } = {}) { + return getResource(withQuery('/api/seedstats', { + unhealthyFilterMask, + })) +} + +export function getSeedStat ({ name, unhealthyFilterMask } = {}) { + if (!name) { + throw new TypeError('getSeedStat requires a name parameter') + } + name = encodeURIComponent(name) + return getResource(withQuery(`/api/seedstats/${name}`, { + unhealthyFilterMask, + })) +} + /* Managed Seeds */ export function getManagedSeedsForGardenNamespace () { @@ -462,6 +479,8 @@ export default { createShootAdminKubeconfig, getCloudProfiles, getSeeds, + getSeedStats, + getSeedStat, getManagedSeedsForGardenNamespace, getManagedSeedShootsForGardenNamespace, getProjects, diff --git a/frontend/src/composables/useDonutChart.js b/frontend/src/composables/useDonutChart.js new file mode 100644 index 0000000000..f7785fef56 --- /dev/null +++ b/frontend/src/composables/useDonutChart.js @@ -0,0 +1,90 @@ +// +// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Gardener contributors +// +// SPDX-License-Identifier: Apache-2.0 +// + +import { + computed, + isRef, +} from 'vue' + +/** + * Composable for SVG donut chart geometry. + * + * @param {import('vue').Ref>} segmentsDef + * Reactive segment definitions. Each segment must have a `key` and a numeric `value`. + * Segments with `value <= 0` are automatically filtered out. + * @param {object} [options] + * @param {number} [options.size=30] Outer SVG size in px + * @param {number} [options.strokeWidth=4] Stroke width of the donut ring + * @param {import('vue').Ref} [options.total] + * Override the total used for proportional calculation. + * If omitted, total is computed as the sum of all segment values. + * @returns Reactive donut geometry + */ +export function useDonutChart (segmentsDef, options = {}) { + if (!isRef(segmentsDef)) { + throw new TypeError('First argument `segmentsDef` must be a ref object') + } + + const { + size = 30, + strokeWidth = 4, + total: totalOverride, + } = options + + if (totalOverride !== undefined && !isRef(totalOverride)) { + throw new TypeError('Option `total` must be a ref object') + } + + const center = size / 2 + const radius = center - strokeWidth / 2 + const circumference = 2 * Math.PI * radius + + const viewBox = `0 0 ${size} ${size}` + const rotateTransform = `rotate(-90 ${center} ${center})` + + const visibleSegments = computed(() => { + const raw = segmentsDef.value + if (!raw?.length) { + return [] + } + + const override = totalOverride?.value + const total = override != null && override > 0 + ? override + : raw.reduce((sum, s) => sum + Math.max(0, s.value), 0) + if (total === 0) { + return [] + } + + let offset = 0 + return raw + .filter(s => s.value > 0) + .map(segment => { + const length = (segment.value / total) * circumference + const dasharray = length === circumference + ? `${circumference} 0` + : `${length} ${circumference}` + const dashoffset = `${-offset}` + offset += length + return { + ...segment, + dasharray, + dashoffset, + } + }) + }) + + return { + size, + center, + radius, + strokeWidth, + circumference, + viewBox, + rotateTransform, + visibleSegments, + } +} diff --git a/frontend/src/composables/useSeedItem/index.js b/frontend/src/composables/useSeedItem/index.js index 678a3a2849..613a7bb658 100644 --- a/frontend/src/composables/useSeedItem/index.js +++ b/frontend/src/composables/useSeedItem/index.js @@ -11,6 +11,8 @@ import { provide, } from 'vue' +import { useSeedStatStore } from '@/store/seedStat' + import { useSeedMetadata } from '@/composables/useSeedMetadata' import { isFailureToleranceTypeZoneSupported } from './helper' @@ -61,6 +63,11 @@ export function createSeedItemComposable (seedItem) { const seedKubernetesVersion = useSeedKubernetesVersion(seedItem) const seedGardenerVersion = useSeedGardenerVersion(seedItem) const seedAllocatableShoots = useSeedAllocatableShoots(seedItem) + const { + seedShootCount, + seedTotalUnhealthyShoots, + seedUnhealthyShoots, + } = useSeedStats(seedItem) const seedLastOperation = useSeedLastOperation(seedItem) return { @@ -101,6 +108,9 @@ export function createSeedItemComposable (seedItem) { seedKubernetesVersion, seedGardenerVersion, seedAllocatableShoots, + seedShootCount, + seedTotalUnhealthyShoots, + seedUnhealthyShoots, seedLastOperation, } } @@ -229,10 +239,32 @@ export function useSeedGardenerVersion (seedItem) { export function useSeedAllocatableShoots (seedItem) { return computed(() => { - return get(seedItem.value, ['status', 'allocatable', 'shoots']) + const value = get(seedItem.value, ['status', 'allocatable', 'shoots']) + return value == null ? undefined : Number(value) }) } +export function useSeedStats (seedItem) { + if (!isRef(seedItem)) { + throw new TypeError('First argument `seedItem` must be a ref object') + } + + const seedStatStore = useSeedStatStore() + + const seedName = computed(() => get(seedItem.value, ['metadata', 'name'])) + const seedStat = computed(() => seedStatStore.statByName(seedName.value)) + + const seedShootCount = computed(() => seedStat.value?.counts?.shootCount) + const seedTotalUnhealthyShoots = computed(() => seedStat.value?.counts?.unhealthyShoots?.total) + const seedUnhealthyShoots = computed(() => seedStat.value?.counts?.unhealthyShoots?.matching) + + return { + seedShootCount, + seedTotalUnhealthyShoots, + seedUnhealthyShoots, + } +} + export function useSeedLastOperation (seedItem) { return computed(() => { return get(seedItem.value, ['status', 'lastOperation'], {}) diff --git a/frontend/src/composables/useSeedTableSorting.js b/frontend/src/composables/useSeedTableSorting.js index be2491d32e..6a2fb64de7 100644 --- a/frontend/src/composables/useSeedTableSorting.js +++ b/frontend/src/composables/useSeedTableSorting.js @@ -66,6 +66,8 @@ export function useSeedTableSorting () { const customKeySort = { name: compareValues, infrastructure: compareValues, + shootCount: compareValues, + unhealthyShoots: compareValues, lastOperation: (a, b) => compareLastOperation(a, b, compareValues), kubernetesVersion: compareSemanticVersions, gardenerVersion: compareSemanticVersions, diff --git a/frontend/src/composables/useShootListFilters.js b/frontend/src/composables/useShootListFilters.js new file mode 100644 index 0000000000..bfdc570066 --- /dev/null +++ b/frontend/src/composables/useShootListFilters.js @@ -0,0 +1,113 @@ +// +// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Gardener contributors +// +// SPDX-License-Identifier: Apache-2.0 +// + +import { computed } from 'vue' +import { createSharedComposable } from '@vueuse/core' + +import { useAuthnStore } from '@/store/authn' +import { useConfigStore } from '@/store/config' +import { useLocalStorageStore } from '@/store/localStorage' + +import pick from 'lodash/pick' + +const FILTER_LABELS = [ + { key: 'progressing', label: 'Progressing Clusters' }, + { key: 'noOperatorAction', label: 'User Errors' }, + { key: 'hideTicketsWithLabel', label: 'Tickets with Ignore Labels' }, +] + +const FILTER_KEYS = [ + 'onlyShootsWithIssues', + 'progressing', + 'noOperatorAction', + 'hideTicketsWithLabel', +] + +function getDefaultAllProjectsShootFilters (isAdmin) { + return { + onlyShootsWithIssues: isAdmin, + progressing: true, + noOperatorAction: isAdmin, + hideTicketsWithLabel: isAdmin, + } +} + +export function getUnhealthyFilterMaskFromShootListFilters (shootListFilters = {}) { + if (!shootListFilters.onlyShootsWithIssues) { + return 0 + } + + let mask = 0 + if (shootListFilters.progressing) { + mask |= 1 + } + if (shootListFilters.noOperatorAction) { + mask |= 2 + } + if (shootListFilters.hideTicketsWithLabel) { + mask |= 4 + } + return mask +} + +export const useShootListFilters = createSharedComposable(function useShootListFilters () { + const authnStore = useAuthnStore() + const configStore = useConfigStore() + const localStorageStore = useLocalStorageStore() + + const shootListFilters = computed({ + get () { + const filters = { + ...getDefaultAllProjectsShootFilters(authnStore.isAdmin), + ...localStorageStore.allProjectsShootFilter, + } + + const { ticket } = configStore + if (ticket && (!ticket.gitHubRepoUrl || !ticket.hideClustersWithLabels?.length)) { + filters.hideTicketsWithLabel = false + } + + return filters + }, + set (value) { + localStorageStore.allProjectsShootFilter = pick(value, FILTER_KEYS) + }, + }) + + const onlyShootsWithIssues = computed(() => { + return shootListFilters.value.onlyShootsWithIssues ?? true + }) + + function toggleShootListFilter (key) { + shootListFilters.value = { + ...shootListFilters.value, + [key]: !shootListFilters.value[key], // eslint-disable-line security/detect-object-injection -- key is a fixed set of strings, not user input + } + } + + const unhealthyFilterMask = computed(() => { + return getUnhealthyFilterMaskFromShootListFilters(shootListFilters.value) + }) + + const activeFilterLabels = computed(() => { + const filters = shootListFilters.value + if (!filters.onlyShootsWithIssues) { + return [] + } + + return FILTER_LABELS + .filter(({ key }) => filters[key]) // eslint-disable-line security/detect-object-injection -- key is a fixed set of strings, not user input + .map(({ label }) => label) + }) + + return { + shootListFilters, + onlyShootsWithIssues, + toggleShootListFilter, + unhealthyFilterMask, + activeFilterLabels, + } +}) diff --git a/frontend/src/composables/useSocketEventHandler.js b/frontend/src/composables/useSocketEventHandler.js index 467ef8d3e1..40c7f084f8 100644 --- a/frontend/src/composables/useSocketEventHandler.js +++ b/frontend/src/composables/useSocketEventHandler.js @@ -48,6 +48,7 @@ export function useSocketEventHandler (useStore, options = {}) { socketStore = useSocketStore(), visibility = useDocumentVisibility(), createOperator = createDefaultOperator, + getSynchronizeOptions, } = options const eventMap = new Map([]) @@ -67,7 +68,10 @@ export function useSocketEventHandler (useStore, options = {}) { uids.push(uid) } } - const items = await socketStore.synchronize(pluralName, uids) + const synchronizeOptions = getSynchronizeOptions + ? getSynchronizeOptions(store) + : undefined + const items = await socketStore.synchronize(pluralName, uids, synchronizeOptions) for (const item of items) { if (item.kind !== 'Status') { uidMap.set(item.metadata.uid, item) diff --git a/frontend/src/router/guards.js b/frontend/src/router/guards.js index d9021a5e04..b9b0c68d1e 100644 --- a/frontend/src/router/guards.js +++ b/frontend/src/router/guards.js @@ -163,8 +163,6 @@ export function createGlobalResolveGuards () { break } case 'ShootList': { - // filter has to be set before subscribing shoots - shootStore.initializeShootListFilters() shootStore.subscribeShoots() const promises = [] if (authzStore.canUseProjectTerminalShortcuts) { diff --git a/frontend/src/store/helper.js b/frontend/src/store/helper.js new file mode 100644 index 0000000000..6340d564a0 --- /dev/null +++ b/frontend/src/store/helper.js @@ -0,0 +1,30 @@ +// +// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Gardener contributors +// +// SPDX-License-Identifier: Apache-2.0 +// + +import { useLogger } from '@/composables/useLogger' + +import isEqual from 'lodash/isEqual' + +export function createSynchronizeLock (storeName) { + const logger = useLogger() + return { + expiresAt: 0, + options: null, + acquire (options) { + if (isEqual(this.options, options) && this.expiresAt > Date.now()) { + logger.warn('Detected concurrent synchronization attempts for the same %s subscription', storeName) + return false + } + this.expiresAt = Date.now() + 30_000 + this.options = { ...options } + return true + }, + release () { + this.expiresAt = 0 + this.options = null + }, + } +} diff --git a/frontend/src/store/localStorage.js b/frontend/src/store/localStorage.js index ead0965e08..f9f1be4f51 100644 --- a/frontend/src/store/localStorage.js +++ b/frontend/src/store/localStorage.js @@ -211,11 +211,6 @@ export const useLocalStorageStore = defineStore('localStorage', () => { writeDefaults: false, }) - const shootListFetchFromCache = useLocalStorage('projects/shoot-list/fetch-from-cache', false, { - serializer: StorageSerializers.flag, - writeDefaults: false, - }) - const seedSelectedColumns = useLocalStorage('seeds/seed-list/selected-columns', {}, { serializer: StorageSerializers.json, writeDefaults: false, @@ -278,7 +273,6 @@ export const useLocalStorageStore = defineStore('localStorage', () => { seedSelectedColumns, seedSortBy, allProjectsShootFilter, - shootListFetchFromCache, shootCustomSortBy, shootCustomSelectedColumns, terminalSplitpaneTreeRef, diff --git a/frontend/src/store/seedStat.js b/frontend/src/store/seedStat.js new file mode 100644 index 0000000000..8c8ff667df --- /dev/null +++ b/frontend/src/store/seedStat.js @@ -0,0 +1,174 @@ +// +// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Gardener contributors +// +// SPDX-License-Identifier: Apache-2.0 +// + +import { + defineStore, + acceptHMRUpdate, +} from 'pinia' +import { + ref, + computed, + watch, +} from 'vue' + +import { useAppStore } from '@/store/app' +import { useSocketStore } from '@/store/socket' + +import { useApi } from '@/composables/useApi' +import { useLogger } from '@/composables/useLogger' +import { useShootListFilters } from '@/composables/useShootListFilters' +import { useSocketEventHandler } from '@/composables/useSocketEventHandler' + +import find from 'lodash/find' +import isEqual from 'lodash/isEqual' + +export const useSeedStatStore = defineStore('seedstat', () => { + const api = useApi() + const logger = useLogger() + const appStore = useAppStore() + const socketStore = useSocketStore() + const { + unhealthyFilterMask: currentUnhealthyFilterMask, + } = useShootListFilters() + + const list = ref(null) + const subscription = ref(null) + const refreshNonce = ref(0) + const subscribed = ref(false) + + const isInitial = computed(() => { + return list.value === null + }) + + const synchronizeOptions = computed(() => { + if (!subscription.value) { + return undefined + } + + return { + unhealthyFilterMask: subscription.value.unhealthyFilterMask, + } + }) + + async function fetchSeedStats (options = {}) { + const response = options.name + ? await api.getSeedStat(options) + : await api.getSeedStats(options) + const data = options.name + ? [response.data] + : response.data + return data + } + + async function openSubscription (options) { + if (!socketStore.connected) { + subscribed.value = false + return + } + + await socketStore.emitSubscribe('seedstats', options) + subscribed.value = true + } + + async function closeSubscription () { + await socketStore.emitUnsubscribe('seedstats') + subscribed.value = false + } + + watch( + [subscription, refreshNonce], + async ([newSub], [oldSub], onCleanup) => { + let cancelled = false + onCleanup(() => { + cancelled = true + }) + + const subChanged = !isEqual(newSub, oldSub) + if (subChanged && oldSub) { + await closeSubscription() + if (cancelled) { + return + } + } + + if (!newSub) { + list.value = null + subscribed.value = false + return + } + + try { + const data = await fetchSeedStats(newSub) + if (cancelled) { + return + } + list.value = data + await openSubscription(newSub) + } catch (err) { + if (!cancelled) { + appStore.setError(err) + } + } + }, + { deep: true }, + ) + + function subscribe (options = {}) { + subscription.value = options + } + + function unsubscribe () { + subscription.value = null + } + + function synchronize () { + if (!subscription.value) { + return + } + refreshNonce.value++ + } + + function statByName (name) { + return find(list.value, ['metadata.name', name]) + } + + function shootCountForSeed (name) { + return statByName(name)?.counts?.shootCount + } + + function unhealthyShootsForSeed (name) { + const unhealthyShoots = statByName(name)?.counts?.unhealthyShoots + + return unhealthyShoots?.matching + } + + const socketEventHandler = useSocketEventHandler(useSeedStatStore, { + logger, + getSynchronizeOptions (store) { + return store.synchronizeOptions + }, + }) + socketEventHandler.start(500) + + return { + list, + subscription, + currentUnhealthyFilterMask, + synchronizeOptions, + isInitial, + subscribe, + unsubscribe, + synchronize, + statByName, + shootCountForSeed, + unhealthyShootsForSeed, + handleEvent: socketEventHandler.listener, + } +}) + +if (import.meta.hot) { + import.meta.hot.accept(acceptHMRUpdate(useSeedStatStore, import.meta.hot)) +} diff --git a/frontend/src/store/shoot/helper.js b/frontend/src/store/shoot/helper.js index 57bd8282f0..80bc11242f 100644 --- a/frontend/src/store/shoot/helper.js +++ b/frontend/src/store/shoot/helper.js @@ -23,7 +23,6 @@ import { parseSearch } from '@/composables/useTableFilter/helper' import { isTruthyValue, isStatusProgressing, - isReconciliationDeactivated, getCreatedBy, getIssueSince, } from '@/utils' @@ -57,8 +56,9 @@ export const constants = Object.freeze({ export function onlyAllShootsWithIssues (state, context) { const { authzStore, + onlyShootsWithIssues, } = context - return authzStore.namespace === '_all' && get(state.shootListFilters, ['onlyShootsWithIssues'], true) + return authzStore.namespace === '_all' && (onlyShootsWithIssues.value ?? true) } export function getFilteredUids (state, context) { @@ -92,10 +92,6 @@ export function getFilteredUids (state, context) { return !(isUserError(allLastErrorCodes) || isUserError(allConditionCodes) || isUserError(allConstraintCodes)) } - const reconciliationNotDeactivated = item => { - return !isReconciliationDeactivated(get(item, ['metadata'], {})) - } - const hasTicketsWithoutHideLabel = item => { const hideClustersWithLabels = get(configStore.ticket, ['hideClustersWithLabels']) if (!hideClustersWithLabels) { @@ -119,16 +115,19 @@ export function getFilteredUids (state, context) { // list of active filter function const predicates = [] if (onlyAllShootsWithIssues(state, context)) { - if (get(state, ['shootListFilters', 'progressing'], false)) { + const { + progressing, + noOperatorAction, + hideTicketsWithLabel, + } = context + + if (progressing.value) { predicates.push(notProgressing) } - if (get(state, ['shootListFilters', 'noOperatorAction'], false)) { + if (noOperatorAction.value) { predicates.push(noUserError) } - if (get(state, ['shootListFilters', 'deactivatedReconciliation'], false)) { - predicates.push(reconciliationNotDeactivated) - } - if (get(state, ['shootListFilters', 'hideTicketsWithLabel'], false)) { + if (hideTicketsWithLabel.value) { predicates.push(hasTicketsWithoutHideLabel) } } @@ -239,7 +238,7 @@ export function getSortVal (state, context, item, sortBy) { const conditions = item.status?.conditions ?? [] const constraints = item.status?.constraints ?? [] const readinessConditions = [...conditions, ...constraints] - const hideProgressingClusters = get(state.shootListFilters, ['progressing'], false) + const hideProgressingClusters = context.progressing.value ?? false const lastOperationTime = item.status?.lastOperation?.lastUpdateTime const creationTime = item.metadata.creationTimestamp const isErrorFn = status => status !== 'True' && !(hideProgressingClusters && status === 'Progressing') diff --git a/frontend/src/store/shoot/shoot.js b/frontend/src/store/shoot/shoot.js index 007d7adc2b..54f4d3144a 100644 --- a/frontend/src/store/shoot/shoot.js +++ b/frontend/src/store/shoot/shoot.js @@ -11,7 +11,6 @@ import { import { computed, reactive, - watch, markRaw, toRaw, toRef, @@ -20,12 +19,12 @@ import { import { useLogger } from '@/composables/useLogger' import { useApi } from '@/composables/useApi' import { useProjectShootCustomFields } from '@/composables/useProjectShootCustomFields' +import { useShootListFilters } from '@/composables/useShootListFilters' import { useSocketEventHandler } from '@/composables/useSocketEventHandler' import { isNotFound } from '@/utils/error' import { useAppStore } from '../app' -import { useAuthnStore } from '../authn' import { useAuthzStore } from '../authz' import { useProjectStore } from '../project' import { useCloudProfileStore } from '../cloudProfile' @@ -35,7 +34,7 @@ import { useCredentialStore } from '../credential' import { useSocketStore } from '../socket' import { useTicketStore } from '../ticket' import { useSeedStore } from '../seed' -import { useLocalStorageStore } from '../localStorage' +import { createSynchronizeLock } from '../helper' import { constants, @@ -46,12 +45,10 @@ import { shootHasIssue, } from './helper' -import isEqual from 'lodash/isEqual' import isEmpty from 'lodash/isEmpty' import includes from 'lodash/includes' import find from 'lodash/find' import difference from 'lodash/difference' -import pick from 'lodash/pick' import map from 'lodash/map' import unset from 'lodash/unset' import set from 'lodash/set' @@ -62,7 +59,6 @@ const useShootStore = defineStore('shoot', () => { const logger = useLogger() const appStore = useAppStore() - const authnStore = useAuthnStore() const authzStore = useAuthzStore() const projectStore = useProjectStore() const cloudProfileStore = useCloudProfileStore() @@ -72,11 +68,18 @@ const useShootStore = defineStore('shoot', () => { const ticketStore = useTicketStore() const socketStore = useSocketStore() const seedStore = useSeedStore() - const localStorageStore = useLocalStorageStore() const projectItem = toRef(projectStore, 'project') const shootCustomFieldsComposable = useProjectShootCustomFields(projectItem, { logger }) + const { + shootListFilters, + onlyShootsWithIssues, + } = useShootListFilters() + + const progressing = computed(() => shootListFilters.value.progressing) + const noOperatorAction = computed(() => shootListFilters.value.noOperatorAction) + const hideTicketsWithLabel = computed(() => shootListFilters.value.hideTicketsWithLabel) const context = { api, @@ -92,6 +95,11 @@ const useShootStore = defineStore('shoot', () => { socketStore, seedStore, shootCustomFieldsComposable, + shootListFilters, + onlyShootsWithIssues, + progressing, + noOperatorAction, + hideTicketsWithLabel, } const state = reactive({ @@ -99,7 +107,6 @@ const useShootStore = defineStore('shoot', () => { shootInfos: {}, staleShoots: {}, // shoots will be moved here when they are removed in case focus mode is active selection: undefined, - shootListFilters: undefined, focusMode: false, froozenUids: [], subscription: null, @@ -123,10 +130,6 @@ const useShootStore = defineStore('shoot', () => { return getFilteredUids(state, context) }) - const shootListFilters = computed(() => { - return state.shootListFilters - }) - const focusMode = computed(() => { return state.focusMode }) @@ -167,10 +170,6 @@ const useShootStore = defineStore('shoot', () => { : null }) - const onlyShootsWithIssues = computed(() => { - return get(state.shootListFilters, ['onlyShootsWithIssues'], true) - }) - const loading = computed(() => { return state.subscriptionState === constants.LOADING }) @@ -296,23 +295,7 @@ const useShootStore = defineStore('shoot', () => { })(this) } - const synchronizeLock = { - expiresAt: 0, - options: null, - aquire (options) { - if (isEqual(this.options, options) && this.expiresAt > Date.now()) { - logger.warn('Detected concurrent synchronization attempts for the same shoot subscription') - return false - } - this.expiresAt = Date.now() + 30_000 - this.options = { ...options } - return true - }, - release () { - this.expiresAt = 0 - this.options = null - }, - } + const synchronizeLock = createSynchronizeLock('shoot') function synchronize () { const shootStore = this @@ -378,17 +361,14 @@ const useShootStore = defineStore('shoot', () => { const fetchData = async options => { let throttleDelay // check if a synchronize operation with the same options is already in progress and hasn't expired. - if (!synchronizeLock.aquire(options)) { + if (!synchronizeLock.acquire(options)) { return } try { setSubscriptionState(state, constants.LOADING) const promise = options.name ? fetchShoot(options) - : fetchShoots({ - useCache: localStorageStore.shootListFetchFromCache, - ...options, - }) + : fetchShoots(options) const { shoots, issues, comments } = await promise shootStore.receive(shoots) ticketStore.receiveIssues(issues) @@ -465,37 +445,6 @@ const useShootStore = defineStore('shoot', () => { } } - function initializeShootListFilters () { - const isAdmin = authnStore.isAdmin - state.shootListFilters = { - onlyShootsWithIssues: isAdmin, - progressing: true, - noOperatorAction: isAdmin, - deactivatedReconciliation: isAdmin, - hideTicketsWithLabel: isAdmin, - ...localStorageStore.allProjectsShootFilter, - } - } - - function toogleShootListFilter (key) { - if (state.shootListFilters) { - const value = get(state.shootListFilters, [key]) - set(state.shootListFilters, [key], !value) - } - } - - watch(() => state.shootListFilters, value => { - localStorageStore.allProjectsShootFilter = pick(value, [ - 'onlyShootsWithIssues', - 'progressing', - 'noOperatorAction', - 'deactivatedReconciliation', - 'hideTicketsWithLabel', - ]) - }, { - deep: true, - }) - function setFocusMode (value) { const shootStore = this let uids = [] @@ -565,7 +514,7 @@ const useShootStore = defineStore('shoot', () => { state.subscriptionEventHandler = socketEventHandler.start(throttleDelay) }) try { - await socketStore.emitSubscribe(value) + await socketStore.emitSubscribe('shoots', value) setSubscriptionState(state, constants.OPEN) } catch (err) { logger.error('Failed to open subscription: %s', err.message) @@ -585,7 +534,7 @@ const useShootStore = defineStore('shoot', () => { state.subscriptionEventHandler = undefined }) try { - await socketStore.emitUnsubscribe() + await socketStore.emitUnsubscribe('shoots') setSubscriptionState(state, constants.CLOSED) } catch (err) { logger.error('Failed to close subscription: %s', err.message) @@ -625,7 +574,6 @@ const useShootStore = defineStore('shoot', () => { // state state, staleShoots, - shootListFilters, subscriptionState, subscriptionError, focusMode, @@ -634,7 +582,6 @@ const useShootStore = defineStore('shoot', () => { activeShoots, shootList, selectedShoot, - onlyShootsWithIssues, loading, subscribed, unsubscribed, @@ -653,8 +600,6 @@ const useShootStore = defineStore('shoot', () => { deleteShoot, fetchInfo, setSelection, - initializeShootListFilters, - toogleShootListFilter, setFocusMode, shootByNamespaceAndName, searchItems, diff --git a/frontend/src/store/socket/helper.js b/frontend/src/store/socket/helper.js index 40cd96522a..190b530851 100644 --- a/frontend/src/store/socket/helper.js +++ b/frontend/src/store/socket/helper.js @@ -19,6 +19,7 @@ export function createSocket (state, context) { ticketStore, projectStore, seedStore, + seedStatStore, managedSeedStore, managedSeedShootStore, } = context @@ -115,6 +116,7 @@ export function createSocket (state, context) { state.active = socket.active setConnected(socket.connected) shootStore.synchronize() + seedStatStore.synchronize() }) socket.on('disconnect', reason => { @@ -222,6 +224,10 @@ export function createSocket (state, context) { seedStore.handleEvent(event) }) + socket.on('seedstats', event => { + seedStatStore.handleEvent(event) + }) + socket.on('managedseeds', event => { managedSeedStore.handleEvent(event) }) diff --git a/frontend/src/store/socket/index.js b/frontend/src/store/socket/index.js index af90bbe54f..ceac34768e 100644 --- a/frontend/src/store/socket/index.js +++ b/frontend/src/store/socket/index.js @@ -24,6 +24,7 @@ import { useShootStore } from '../shoot' import { useTicketStore } from '../ticket' import { useProjectStore } from '../project' import { useSeedStore } from '../seed' +import { useSeedStatStore } from '../seedStat' import { useManagedSeedStore } from '../managedSeed' import { useManagedSeedShootStore } from '../managedSeedShoot' @@ -39,6 +40,7 @@ export const useSocketStore = defineStore('socket', () => { const ticketStore = useTicketStore() const projectStore = useProjectStore() const seedStore = useSeedStore() + const seedStatStore = useSeedStatStore() const managedSeedStore = useManagedSeedStore() const managedSeedShootStore = useManagedSeedShootStore() @@ -62,6 +64,7 @@ export const useSocketStore = defineStore('socket', () => { ['shoots', false], ['projects', false], ['seeds', false], + ['seedstats', false], ['managedseeds', false], ['managedseed-shoots', false], ]) @@ -73,6 +76,7 @@ export const useSocketStore = defineStore('socket', () => { ticketStore, projectStore, seedStore, + seedStatStore, managedSeedStore, managedSeedShootStore, }) @@ -112,14 +116,14 @@ export const useSocketStore = defineStore('socket', () => { socket.disconnect() } - async function emitSubscribe (options) { + async function emitSubscribe (key, options) { if (!socket.connected) { return } const { statusCode = 500, - message = 'Failed to subscribe shoots', - } = await socket.timeout(acknowledgementTimeout).emitWithAck('subscribe', 'shoots', options) + message = `Failed to subscribe ${key}`, + } = await socket.timeout(acknowledgementTimeout).emitWithAck('subscribe', key, options) if (statusCode !== 200) { logger.debug('Subscribe Error: %s', message) throw createError(statusCode, message, { @@ -128,11 +132,14 @@ export const useSocketStore = defineStore('socket', () => { } } - async function emitUnsubscribe () { + async function emitUnsubscribe (key) { + if (!socket.connected) { + return + } const { statusCode = 500, - message = 'Failed to unsubscribe shoots', - } = await socket.timeout(acknowledgementTimeout).emitWithAck('unsubscribe', 'shoots') + message = `Failed to unsubscribe ${key}`, + } = await socket.timeout(acknowledgementTimeout).emitWithAck('unsubscribe', key) if (statusCode !== 200) { logger.debug('Unsubscribe Error: %s', message) throw createError(statusCode, message, { @@ -141,7 +148,7 @@ export const useSocketStore = defineStore('socket', () => { } } - async function synchronize (key, uids) { + async function synchronize (key, uids, options) { if (!uids.length) { return [] } @@ -155,7 +162,7 @@ export const useSocketStore = defineStore('socket', () => { name = 'InternalError', message = `Failed to synchronize ${key}`, items = [], - } = await socket.timeout(acknowledgementTimeout).emitWithAck('synchronize', key, uids) + } = await socket.timeout(acknowledgementTimeout).emitWithAck('synchronize', key, uids, options) if (statusCode === 200) { return items } diff --git a/frontend/src/utils/errorCodes.js b/frontend/src/utils/errorCodes.js index e484e7ebae..8987544b81 100644 --- a/frontend/src/utils/errorCodes.js +++ b/frontend/src/utils/errorCodes.js @@ -13,8 +13,8 @@ import flatMap from 'lodash/flatMap' import map from 'lodash/map' import some from 'lodash/some' -export function errorCodesFromArray (array) { - return uniq(compact(flatMap(array, 'codes'))) +export function errorCodesFromArray (items) { + return uniq(compact(flatMap(items, 'codes'))) } export function isUserError (errorCodesArray) { diff --git a/frontend/src/utils/index.js b/frontend/src/utils/index.js index cd93b8aa96..a006573607 100644 --- a/frontend/src/utils/index.js +++ b/frontend/src/utils/index.js @@ -395,8 +395,7 @@ export function isReconciliationDeactivated (metadata) { } export function isTruthyValue (value) { - const truthyValues = ['1', 't', 'T', 'true', 'TRUE', 'True'] - return includes(truthyValues, value) + return ['1', 't', 'T', 'true', 'TRUE', 'True'].includes(value) } export function isStatusProgressing (metadata) { diff --git a/frontend/src/views/GAdministration.vue b/frontend/src/views/GAdministration.vue index 1294f67f33..0eaadacbb7 100644 --- a/frontend/src/views/GAdministration.vue +++ b/frontend/src/views/GAdministration.vue @@ -446,6 +446,7 @@ SPDX-License-Identifier: Apache-2.0 :model-value="resourceQuota.percentage" :color="resourceQuota.progressColor" :height="8" + rounded />
diff --git a/frontend/src/views/GSeedList.vue b/frontend/src/views/GSeedList.vue index 1df18d4cc9..cbac6746e9 100644 --- a/frontend/src/views/GSeedList.vue +++ b/frontend/src/views/GSeedList.vue @@ -72,10 +72,13 @@ import { computed, reactive, provide, + onUnmounted, + watch, } from 'vue' import { storeToRefs } from 'pinia' import { useSeedStore } from '@/store/seed' +import { useSeedStatStore } from '@/store/seedStat' import { useManagedSeedStore } from '@/store/managedSeed' import { useSocketStore } from '@/store/socket' import { useLocalStorageStore } from '@/store/localStorage' @@ -96,10 +99,12 @@ import { errorCodesFromArray } from '@/utils/errorCodes' import get from 'lodash/get' import filter from 'lodash/filter' +import find from 'lodash/find' import map from 'lodash/map' import join from 'lodash/join' const seedStore = useSeedStore() +const seedStatStore = useSeedStatStore() const managedSeedStore = useManagedSeedStore() const socketStore = useSocketStore() const localStorageStore = useLocalStorageStore() @@ -165,6 +170,33 @@ const allHeaders = computed(() => [ hidden: false, value: item => item, }, + { + title: 'CAPACITY', + key: 'shootCount', + sortable: true, + align: 'center', + defaultSelected: true, + hidden: false, + value: item => { + const shootCount = seedStatStore.shootCountForSeed(get(item, ['metadata', 'name'])) ?? 0 + const allocatableShoots = Number(get(item, ['status', 'allocatable', 'shoots'])) + + if (Number.isFinite(allocatableShoots) && allocatableShoots > 0) { + return shootCount / allocatableShoots + } + + return shootCount + }, + }, + { + title: 'SHOOT HEALTH', + key: 'unhealthyShoots', + sortable: true, + align: 'center', + defaultSelected: true, + hidden: false, + value: item => seedStatStore.unhealthyShootsForSeed(get(item, ['metadata', 'name'])) ?? 0, + }, { title: 'ACCESS RESTRICTIONS', key: 'accessRestrictions', @@ -279,4 +311,34 @@ function resetTableSettings () { function getItemKey (item, fallback) { return get(item, ['metadata', 'uid'], fallback) } + +function isHeaderSelected (key) { + const header = find(headers.value, ['key', key]) + return header?.selected ?? false +} + +const seedStatsSubscriptionOptions = computed(() => { + const shouldSubscribeToSeedStats = isHeaderSelected('unhealthyShoots') || isHeaderSelected('shootCount') + if (!shouldSubscribeToSeedStats) { + return null + } + + return { + unhealthyFilterMask: seedStatStore.currentUnhealthyFilterMask, + } +}) + +watch(seedStatsSubscriptionOptions, options => { + if (options) { + seedStatStore.subscribe(options) + } else { + seedStatStore.unsubscribe() + } +}, { + immediate: true, +}) + +onUnmounted(async () => { + await seedStatStore.unsubscribe() +}) diff --git a/frontend/src/views/GShootList.vue b/frontend/src/views/GShootList.vue index d85e041a1a..c2bdb53b56 100644 --- a/frontend/src/views/GShootList.vue +++ b/frontend/src/views/GShootList.vue @@ -156,6 +156,7 @@ import GTableSearch from '@/components/GTableSearch.vue' import { useProjectShootCustomFields } from '@/composables/useProjectShootCustomFields' import { isCustomField } from '@/composables/useProjectShootCustomFields/helper' import { useProvideShootAction } from '@/composables/useShootAction' +import { useShootListFilters } from '@/composables/useShootListFilters' import { useUrlSearchSync } from '@/composables/useUrlSearchSync' import { mapTableHeader } from '@/utils' @@ -164,7 +165,6 @@ import upperCase from 'lodash/upperCase' import sortBy from 'lodash/sortBy' import some from 'lodash/some' import map from 'lodash/map' -import join from 'lodash/join' import isEmpty from 'lodash/isEmpty' import unset from 'lodash/unset' import get from 'lodash/get' @@ -235,6 +235,13 @@ export default { setDebouncedShootSearch() } + const { + activeFilterLabels, + shootListFilters, + onlyShootsWithIssues, + toggleShootListFilter, + } = useShootListFilters() + return { activePopoverKey, expandedWorkerGroups, @@ -244,6 +251,10 @@ export default { debouncedShootSearch, setShootSearch, onUpdateShootSearch, + activeFilterLabels, + shootListFilters, + onlyShootsWithIssues, + toggleShootListFilter, } }, data () { @@ -275,10 +286,8 @@ export default { ]), ...mapState(useShootStore, [ 'shootList', - 'shootListFilters', 'loading', 'selectedShoot', - 'onlyShootsWithIssues', 'numberOfNewItemsSinceFreeze', 'focusMode', 'sortBy', @@ -288,7 +297,6 @@ export default { 'shootSortBy', 'shootCustomSelectedColumns', 'shootCustomSortBy', - 'allProjectsShootFilter', 'operatorFeatures', ]), defaultSortBy () { @@ -585,13 +593,6 @@ export default { ], disabled: this.changeFiltersDisabled, }, - { - text: 'Hide clusters with deactivated reconciliation', - value: 'deactivatedReconciliation', - selected: this.isFilterActive('deactivatedReconciliation'), - hidden: this.projectScope || !this.isAdmin || this.showAllShoots, - disabled: this.changeFiltersDisabled, - }, { text: 'Hide clusters with configured ticket labels', value: 'hideTicketsWithLabel', @@ -635,23 +636,14 @@ export default { : '' }, headlineSubtitle () { - const subtitle = [] - if (!this.projectScope && this.showOnlyShootsWithIssues) { - subtitle.push('Hide: Healthy Clusters') - if (this.isFilterActive('progressing')) { - subtitle.push('Progressing Clusters') - } - if (this.isFilterActive('noOperatorAction')) { - subtitle.push('User Errors') - } - if (this.isFilterActive('deactivatedReconciliation')) { - subtitle.push('Deactivated Reconciliation') - } - if (this.isFilterActive('hideTicketsWithLabel')) { - subtitle.push('Tickets with Ignore Labels') - } + if (this.projectScope || !this.showOnlyShootsWithIssues) { + return '' } - return join(subtitle, ', ') + const all = ['Healthy Clusters', ...this.activeFilterLabels] + const shown = all.slice(0, 2).join(', ') + const remaining = all.length - 2 + const suffix = remaining > 0 ? ` & ${remaining} more` : '' + return `Excluding: ${shown}${suffix}` }, gitHubRepoUrl () { return get(this.ticketConfig, ['gitHubRepoUrl']) @@ -694,7 +686,6 @@ export default { }, methods: { ...mapActions(useShootStore, [ - 'toogleShootListFilter', 'subscribeShoots', 'sortItems', 'searchItems', @@ -738,7 +729,7 @@ export default { this.sortByInternal = this.defaultSortBy }, toggleFilter ({ value: key }) { - this.toogleShootListFilter(key) + this.toggleShootListFilter(key) if (key === 'onlyShootsWithIssues') { this.subscribeShoots() }