diff --git a/backend/__fixtures__/config.js b/backend/__fixtures__/config.js index 2a93bf3876..2a7606e604 100644 --- a/backend/__fixtures__/config.js +++ b/backend/__fixtures__/config.js @@ -22,6 +22,7 @@ const defaultConfig = { apiServerUrl: 'https://kubernetes.external.foo.bar', apiServerCaData: toBase64(ca), tokenRequestAudiences: ['aud1', 'aud2'], + experimentalUseWatchCacheForListShoots: 'no', gitHub: { apiUrl: 'https://api.github.com', org: 'gardener', diff --git a/backend/__fixtures__/shoots.js b/backend/__fixtures__/shoots.js index 6b69a4cbd1..56682de4b5 100644 --- a/backend/__fixtures__/shoots.js +++ b/backend/__fixtures__/shoots.js @@ -119,6 +119,9 @@ const shoots = { const items = shoots.list(namespace) return find(items, ['metadata.name', name]) }, + getByUid (uid) { + return find(shootList, ['metadata.uid', uid]) + }, list (namespace) { const items = cloneDeep(shootList) return namespace diff --git a/backend/lib/cache/index.js b/backend/lib/cache/index.js index bfd0a7ec59..1c2dd64b77 100644 --- a/backend/lib/cache/index.js +++ b/backend/lib/cache/index.js @@ -7,6 +7,7 @@ const _ = require('lodash') const { NotFound } = require('http-errors') const createTicketCache = require('./tickets') +const { parseSelectors, filterBySelectors } = require('../utils') /* In file `lib/api.js` the synchronization is started with the privileged dashboardClient. @@ -97,12 +98,26 @@ module.exports = { .get('spec.namespace') .value() }, - getShoots () { - return cache.getShoots() + getShoots (namespace, query = {}) { + if (!namespace) { + throw new TypeError('Namespace is required') + } + let items = cache.getShoots() + if (namespace !== '_all') { + items = items.filter(item => item.metadata.namespace === namespace) + } + const selectors = parseSelectors(query.labelSelector?.split(',') ?? []) + if (selectors.length) { + items = items.filter(filterBySelectors(selectors)) + } + return items }, getShoot (namespace, name) { return cache.get('shoots').find({ metadata: { namespace, name } }) }, + getShootByUid (uid) { + return cache.get('shoots').find(['metadata.uid', uid]) + }, getControllerRegistrations () { return cache.getControllerRegistrations() }, diff --git a/backend/lib/io.js b/backend/lib/io.js index 53dce3619e..b99116c294 100644 --- a/backend/lib/io.js +++ b/backend/lib/io.js @@ -13,7 +13,7 @@ const createError = require('http-errors') const kubernetesClient = require('@gardener-dashboard/kube-client') const cache = require('./cache') const logger = require('./logger') -const { projectFilter } = require('./utils') +const { projectFilter, trimObjectMetadata, parseRooms } = require('./utils') const { authenticate } = require('./security') const { authorization } = require('./services') @@ -126,6 +126,64 @@ async function unsubscribe (socket, key) { } } +function synchronizeShoots (socket, uids = []) { + const rooms = Array.from(socket.rooms).filter(room => room !== socket.id) + const [ + isAdmin, + namespaces, + qualifiedNames + ] = parseRooms(rooms) + + const uidNotFound = uid => { + return { + kind: 'Status', + apiVersion: 'v1', + status: 'Failure', + message: `Shoot with uid ${uid} does not exist`, + reason: 'NotFound', + details: { + uid, + group: 'core.gardener.cloud', + kind: 'shoots' + }, + code: 404 + } + } + return uids.map(uid => { + const object = cache.getShootByUid(uid) + if (!object) { + // the shoot has been removed from the cache + return uidNotFound(uid) + } + const { namespace, name } = object.metadata + const qualifiedName = [namespace, name].join('/') + const hasValidSubscription = isAdmin || namespaces.includes(namespace) || qualifiedNames.includes(qualifiedName) + if (!hasValidSubscription) { + // the socket has NOT joined a room (admin, namespace or individual shoot) the current shoot belongs to + return uidNotFound(uid) + } + // only send all shoot details for single shoot subscriptions + if (!qualifiedNames.includes(qualifiedName)) { + trimObjectMetadata(object) + } + return object + }) +} + +function synchronize (socket, key, ...args) { + switch (key) { + case 'shoots': { + const [uids] = args + if (!Array.isArray(uids)) { + throw new TypeError('Invalid parameters for synchronize shoots') + } + return synchronizeShoots(socket, uids) + } + default: + throw new TypeError(`Invalid synchronization type - ${key}`) + } +} + function setDisconnectTimeout (socket, delay) { delay = Math.min(2147483647, delay) // setTimeout delay must not exceed 32-bit signed integer logger.debug('Socket %s will expire in %d seconds', socket.id, Math.floor(delay / 1000)) @@ -205,6 +263,19 @@ function init (httpServer, cache) { } }) + // handle 'synchronize' events + socket.on('synchronize', async (key, ...args) => { + const done = args.pop() + try { + const items = await synchronize(socket, key, ...args) + done({ statusCode: 200, items }) + } catch (err) { + logger.error('Socket %s synchronize error: %s', socket.id, err.message) + const { statusCode = 500, name, message } = err + done({ statusCode, name, message }) + } + }) + // handle 'disconnect' event socket.once('disconnect', reason => { clearTimeout(timeoutId) diff --git a/backend/lib/routes/shoots.js b/backend/lib/routes/shoots.js index 9ed333a650..0e7e239a86 100644 --- a/backend/lib/routes/shoots.js +++ b/backend/lib/routes/shoots.js @@ -9,6 +9,7 @@ const express = require('express') const { shoots } = require('../services') const { metricsRoute } = require('../middleware') +const { trimObjectMetadata, useWatchCacheForListShoots } = require('../utils') const router = module.exports = express.Router({ mergeParams: true @@ -23,7 +24,12 @@ router.route('/') const user = req.user const namespace = req.params.namespace const labelSelector = req.query.labelSelector - res.send(await shoots.list({ user, namespace, labelSelector })) + const useCache = useWatchCacheForListShoots(req.query.useCache) + const shootList = await shoots.list({ user, namespace, labelSelector, useCache }) + for (const object of shootList.items) { + trimObjectMetadata(object) + } + res.send(shootList) } catch (err) { next(err) } diff --git a/backend/lib/services/shoots.js b/backend/lib/services/shoots.js index e57cb15151..d26fe6589b 100644 --- a/backend/lib/services/shoots.js +++ b/backend/lib/services/shoots.js @@ -9,6 +9,7 @@ const { isHttpError } = require('@gardener-dashboard/request') const { cleanKubeconfig, Config } = require('@gardener-dashboard/kube-config') const { dashboardClient } = require('@gardener-dashboard/kube-client') +const createError = require('http-errors') const utils = require('../utils') const cache = require('../cache') const authorization = require('./authorization') @@ -20,31 +21,61 @@ const config = require('../config') const { decodeBase64, getSeedNameFromShoot, getSeedIngressDomain, projectFilter } = utils const { getSeed } = cache -exports.list = async function ({ user, namespace, labelSelector, shootsWithIssuesOnly = false }) { +exports.list = async function ({ user, namespace, labelSelector, useCache = false }) { const client = user.client const query = {} if (labelSelector) { query.labelSelector = labelSelector - } else if (shootsWithIssuesOnly) { - query.labelSelector = 'shoot.gardener.cloud/status!=healthy' } if (namespace === '_all') { if (await authorization.canListShoots(user)) { + // user is permitted to list shoots in all namespaces + if (useCache) { + return { + apiVersion: 'v1', + kind: 'List', + items: cache.getShoots(namespace, query) + } + } return client['core.gardener.cloud'].shoots.listAllNamespaces(query) } else { - const promises = _ + // user is permitted to list shoots only in namespaces associated with their projects + const namespaces = _ .chain(cache.getProjects()) .filter(projectFilter(user, false)) - .map(project => client['core.gardener.cloud'].shoots.list(project.spec.namespace, query)) + .map('spec.namespace') .value() - const shootLists = await Promise.all(promises) + if (useCache) { + const statuses = await Promise.allSettled(namespaces.map(namespace => authorization.canListShoots(user, namespace))) + return { + apiVersion: 'v1', + kind: 'List', + items: namespaces + .filter((_, i) => statuses[i].status === 'fulfilled' && statuses[i].value) + .flatMap(namespace => cache.getShoots(namespace, query)) + } + } + const statuses = await Promise.allSettled(namespaces.map(namespace, client['core.gardener.cloud'].shoots.list(namespace, query))) return { apiVersion: 'v1', kind: 'List', - items: _.flatMap(shootLists, 'items') + items: statuses + .filter(({ status }) => status === 'fulfilled') + .flatMap(({ value }) => value.items) } } } + if (useCache) { + const isAllowed = await authorization.canListShoots(user, namespace) + if (!isAllowed) { + throw createError(403, `No authorization to list shoots in namespace ${namespace}`) + } + return { + apiVersion: 'v1', + kind: 'List', + items: cache.getShoots(namespace, query) + } + } return client['core.gardener.cloud'].shoots.list(namespace, query) } diff --git a/backend/lib/utils/index.js b/backend/lib/utils/index.js index 6fb6834ab3..e3f2353c7c 100644 --- a/backend/lib/utils/index.js +++ b/backend/lib/utils/index.js @@ -10,6 +10,11 @@ const _ = require('lodash') const config = require('../config') const assert = require('assert').strict +const EXISTS = '∃' +const NOT_EXISTS = '!∃' +const EQUAL = '=' +const NOT_EQUAL = '!=' + function decodeBase64 (value) { if (!value) { return @@ -64,6 +69,112 @@ function projectFilter (user, canListProjects = false) { } } +function parseRooms (rooms) { + let isAdmin = false + const namespaces = [] + const qualifiedNames = [] + for (const room of rooms) { + const parts = room.split(';') + const keys = parts[0].split(':') + if (keys.shift() !== 'shoots') { + continue + } + if (keys.pop() === 'admin') { + isAdmin = true + } + if (parts.length < 2) { + continue + } + const [namespace, name] = parts[1].split('/') + if (!name) { + namespaces.push(namespace) + } else { + qualifiedNames.push([namespace, name].join('/')) + } + } + return [ + isAdmin, + namespaces, + qualifiedNames + ] +} + +function trimObjectMetadata (object) { + object.metadata.managedFields = undefined + if (object.metadata.annotations) { + object.metadata.annotations['kubectl.kubernetes.io/last-applied-configuration'] = undefined + } + return object +} + +function parseSelectors (selectors) { + const items = [] + for (const selector of selectors) { + const [, notOperator, key, operator, value = ''] = /^(!)?([a-zA-Z0-9._/-]+)(=|==|!=)?([a-zA-Z0-9._-]+)?$/.exec(selector) ?? [] + if (notOperator) { + if (!operator) { + items.push({ op: NOT_EXISTS, key }) + } + } else if (!operator) { + items.push({ op: EXISTS, key }) + } else if (operator === '!=') { + items.push({ op: NOT_EQUAL, key, value }) + } else if (operator === '=' || operator === '==') { + items.push({ op: EQUAL, key, value }) + } + } + return items +} + +function filterBySelectors (selectors) { + return item => { + const labels = item.metadata.labels ?? {} + for (const { op, key, value } of selectors) { + const labelValue = labels[key] ?? '' + switch (op) { + case NOT_EXISTS: { + if (key in labels) { + return false + } + break + } + case EXISTS: { + if (!(key in labels)) { + return false + } + break + } + case NOT_EQUAL: { + if (labelValue === value) { + return false + } + break + } + case EQUAL: { + if (labelValue !== value) { + return false + } + break + } + } + } + return true + } +} + +function useWatchCacheForListShoots (useCache) { + switch (config.experimentalUseWatchCacheForListShoots) { + case 'never': + return false + case 'always': + return true + case 'no': + return ['true', 'yes', 'on'].includes(useCache) + case 'yes': + return !['false', 'no', 'off'].includes(useCache) + } +} + function getConfigValue (path, defaultValue) { const value = _.get(config, path, defaultValue) if (arguments.length === 1 && typeof value === 'undefined') { @@ -98,9 +209,20 @@ module.exports = { decodeBase64, encodeBase64, projectFilter, + parseRooms, + trimObjectMetadata, + parseSelectors, + filterBySelectors, + useWatchCacheForListShoots, getConfigValue, getSeedNameFromShoot, shootHasIssue, isSeedUnreachable, - getSeedIngressDomain + getSeedIngressDomain, + constants: Object.freeze({ + EXISTS, + NOT_EXISTS, + EQUAL, + NOT_EQUAL + }) } diff --git a/backend/lib/watches/shoots.js b/backend/lib/watches/shoots.js index a8297cf076..54334f82c2 100644 --- a/backend/lib/watches/shoots.js +++ b/backend/lib/watches/shoots.js @@ -8,39 +8,48 @@ const { shootHasIssue } = require('../utils') -module.exports = (io, informer, { shootsWithIssues = new Set() } = {}) => { +module.exports = (io, informer, options) => { const nsp = io.of('/') + const { shootsWithIssues = new Set() } = options ?? {} - const handleEvent = event => { - const { namespace, name } = event.object.metadata + const publishShoots = event => { + const { type, object } = event + const { namespace, name, uid } = object.metadata const rooms = [ 'shoots:admin', `shoots;${namespace}`, `shoots;${namespace}/${name}` ] - nsp.to(rooms).emit('shoots', event) - - const unhealthyShootsPublish = ({ type, object }) => { - const { uid } = object.metadata - if (shootHasIssue(object)) { - if (!shootsWithIssues.has(uid)) { - shootsWithIssues.add(uid) - } else if (type === 'DELETED') { - shootsWithIssues.delete(uid) - } - } else if (shootsWithIssues.has(uid)) { - type = 'DELETED' + nsp.to(rooms).emit('shoots', { type, uid }) + } + + const publishUnhealthyShoots = event => { + let type = event.type + const object = event.object + const { namespace, uid } = object.metadata + + if (shootHasIssue(object)) { + if (!shootsWithIssues.has(uid)) { + shootsWithIssues.add(uid) + } else if (type === 'DELETED') { shootsWithIssues.delete(uid) - } else { - return } - const rooms = [ - 'shoots:unhealthy:admin', - `shoots:unhealthy;${namespace}` - ] - nsp.to(rooms).emit('shoots', { type, object }) + } else if (shootsWithIssues.has(uid)) { + type = 'DELETED' + shootsWithIssues.delete(uid) + } else { + return } - unhealthyShootsPublish(event) + const rooms = [ + 'shoots:unhealthy:admin', + `shoots:unhealthy;${namespace}` + ] + nsp.to(rooms).emit('shoots', { type, uid }) + } + + const handleEvent = event => { + publishShoots(event) + publishUnhealthyShoots(event) } informer.on('add', object => handleEvent({ type: 'ADDED', object })) diff --git a/backend/test/acceptance/__snapshots__/api.shoots.spec.js.snap b/backend/test/acceptance/__snapshots__/api.shoots.spec.js.snap index 6b2a1a343d..2e0c5f5d39 100644 --- a/backend/test/acceptance/__snapshots__/api.shoots.spec.js.snap +++ b/backend/test/acceptance/__snapshots__/api.shoots.spec.js.snap @@ -1512,3 +1512,397 @@ Object { ], } `; + +exports[`api shoots when served from cache should return all shoots 1`] = ` +Array [ + Array [ + Object { + ":authority": "kubernetes:6443", + ":method": "post", + ":path": "/apis/authorization.k8s.io/v1/selfsubjectaccessreviews", + ":scheme": "https", + "authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImZvb0BleGFtcGxlLm9yZyIsImlhdCI6MTU3NzgzNjgwMCwiYXVkIjpbImdhcmRlbmVyIl0sImV4cCI6MzE1NTcxNjgwMCwianRpIjoianRpIn0.k3kGjF6AgugJLdwERXEWZPaibFAPFPOnmpT3YM9H0xU", + }, + Object { + "apiVersion": "authorization.k8s.io/v1", + "kind": "SelfSubjectAccessReview", + "spec": Object { + "nonResourceAttributes": undefined, + "resourceAttributes": Object { + "group": "core.gardener.cloud", + "namespace": undefined, + "resource": "shoots", + "verb": "list", + }, + }, + }, + ], +] +`; + +exports[`api shoots when served from cache should return all shoots 2`] = ` +Object { + "apiVersion": "v1", + "items": Array [ + Object { + "metadata": Object { + "annotations": Object { + "gardener.cloud/created-by": "foo@example.org", + }, + "labels": Object { + "shoot.gardener.cloud/status": "healthy", + }, + "name": "fooShoot", + "namespace": "garden-foo", + "uid": 1, + }, + "spec": Object { + "cloudProfileName": "infra1-profileName", + "hibernation": Object { + "enabled": false, + }, + "kubernetes": Object { + "version": "1.16.0", + }, + "provider": Object { + "type": "fooInfra", + }, + "purpose": "fooPurpose", + "region": "foo-west", + "secretBindingName": "foo-infra1", + "seedName": "infra1-seed", + }, + "status": Object { + "advertisedAddresses": Array [ + Object { + "name": "external", + "url": "https://api.fooShoot.foo.shoot.test", + }, + ], + "technicalID": "shoot--foo--fooShoot", + }, + }, + Object { + "metadata": Object { + "annotations": Object { + "gardener.cloud/created-by": "bar@example.org", + }, + "labels": Object { + "shoot.gardener.cloud/status": "healthy", + }, + "name": "barShoot", + "namespace": "garden-foo", + "uid": 2, + }, + "spec": Object { + "cloudProfileName": "infra1-profileName", + "hibernation": Object { + "enabled": false, + }, + "kubernetes": Object { + "version": "1.16.0", + }, + "provider": Object { + "type": "fooInfra", + }, + "purpose": "barPurpose", + "region": "foo-west", + "secretBindingName": "foo-infra1", + "seedName": "infra1-seed", + }, + "status": Object { + "advertisedAddresses": Array [ + Object { + "name": "external", + "url": "https://api.barShoot.foo.shoot.test", + }, + ], + "technicalID": "shoot--foo--barShoot", + }, + }, + Object { + "metadata": Object { + "annotations": Object { + "gardener.cloud/created-by": "foo@example.org", + }, + "labels": Object { + "shoot.gardener.cloud/status": "unhealthy", + }, + "name": "dummyShoot", + "namespace": "garden-foo", + "uid": 3, + }, + "spec": Object { + "cloudProfileName": "infra1-profileName", + "hibernation": Object { + "enabled": false, + }, + "kubernetes": Object { + "version": "1.16.0", + }, + "provider": Object { + "type": "fooInfra", + }, + "purpose": "fooPurpose", + "region": "foo-west", + "secretBindingName": "barSecretName", + "seedName": "infra4-seed-without-secretRef", + }, + "status": Object { + "technicalID": "shoot--foo--dummyShoot", + }, + }, + Object { + "metadata": Object { + "annotations": Object { + "gardener.cloud/created-by": "admin@example.org", + }, + "labels": Object { + "shoot.gardener.cloud/status": "healthy", + }, + "name": "infra1-seed", + "namespace": "garden", + "uid": 4, + }, + "spec": Object { + "cloudProfileName": "infra1-profileName", + "hibernation": Object { + "enabled": false, + }, + "kubernetes": Object { + "version": "1.16.0", + }, + "provider": Object { + "type": "fooInfra", + }, + "purpose": "foo-purpose", + "region": "foo-west", + "secretBindingName": "soil-infra1", + "seedName": "soil-infra1", + }, + "status": Object { + "advertisedAddresses": Array [ + Object { + "name": "external", + "url": "https://api.infra1-seed.garden.shoot.test", + }, + ], + "technicalID": "shoot--garden--infra1-seed", + }, + }, + ], + "kind": "List", +} +`; + +exports[`api shoots when served from cache should return all unhealthy shoots 1`] = ` +Array [ + Array [ + Object { + ":authority": "kubernetes:6443", + ":method": "post", + ":path": "/apis/authorization.k8s.io/v1/selfsubjectaccessreviews", + ":scheme": "https", + "authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImZvb0BleGFtcGxlLm9yZyIsImlhdCI6MTU3NzgzNjgwMCwiYXVkIjpbImdhcmRlbmVyIl0sImV4cCI6MzE1NTcxNjgwMCwianRpIjoianRpIn0.k3kGjF6AgugJLdwERXEWZPaibFAPFPOnmpT3YM9H0xU", + }, + Object { + "apiVersion": "authorization.k8s.io/v1", + "kind": "SelfSubjectAccessReview", + "spec": Object { + "nonResourceAttributes": undefined, + "resourceAttributes": Object { + "group": "core.gardener.cloud", + "namespace": undefined, + "resource": "shoots", + "verb": "list", + }, + }, + }, + ], +] +`; + +exports[`api shoots when served from cache should return all unhealthy shoots 2`] = ` +Object { + "apiVersion": "v1", + "items": Array [ + Object { + "metadata": Object { + "annotations": Object { + "gardener.cloud/created-by": "foo@example.org", + }, + "labels": Object { + "shoot.gardener.cloud/status": "unhealthy", + }, + "name": "dummyShoot", + "namespace": "garden-foo", + "uid": 3, + }, + "spec": Object { + "cloudProfileName": "infra1-profileName", + "hibernation": Object { + "enabled": false, + }, + "kubernetes": Object { + "version": "1.16.0", + }, + "provider": Object { + "type": "fooInfra", + }, + "purpose": "fooPurpose", + "region": "foo-west", + "secretBindingName": "barSecretName", + "seedName": "infra4-seed-without-secretRef", + }, + "status": Object { + "technicalID": "shoot--foo--dummyShoot", + }, + }, + ], + "kind": "List", +} +`; + +exports[`api shoots when served from cache should return shoots for a single namespace 1`] = ` +Array [ + Array [ + Object { + ":authority": "kubernetes:6443", + ":method": "post", + ":path": "/apis/authorization.k8s.io/v1/selfsubjectaccessreviews", + ":scheme": "https", + "authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImZvb0BleGFtcGxlLm9yZyIsImlhdCI6MTU3NzgzNjgwMCwiYXVkIjpbImdhcmRlbmVyIl0sImV4cCI6MzE1NTcxNjgwMCwianRpIjoianRpIn0.k3kGjF6AgugJLdwERXEWZPaibFAPFPOnmpT3YM9H0xU", + }, + Object { + "apiVersion": "authorization.k8s.io/v1", + "kind": "SelfSubjectAccessReview", + "spec": Object { + "nonResourceAttributes": undefined, + "resourceAttributes": Object { + "group": "core.gardener.cloud", + "namespace": "garden-foo", + "resource": "shoots", + "verb": "list", + }, + }, + }, + ], +] +`; + +exports[`api shoots when served from cache should return shoots for a single namespace 2`] = ` +Object { + "apiVersion": "v1", + "items": Array [ + Object { + "metadata": Object { + "annotations": Object { + "gardener.cloud/created-by": "foo@example.org", + }, + "labels": Object { + "shoot.gardener.cloud/status": "healthy", + }, + "name": "fooShoot", + "namespace": "garden-foo", + "uid": 1, + }, + "spec": Object { + "cloudProfileName": "infra1-profileName", + "hibernation": Object { + "enabled": false, + }, + "kubernetes": Object { + "version": "1.16.0", + }, + "provider": Object { + "type": "fooInfra", + }, + "purpose": "fooPurpose", + "region": "foo-west", + "secretBindingName": "foo-infra1", + "seedName": "infra1-seed", + }, + "status": Object { + "advertisedAddresses": Array [ + Object { + "name": "external", + "url": "https://api.fooShoot.foo.shoot.test", + }, + ], + "technicalID": "shoot--foo--fooShoot", + }, + }, + Object { + "metadata": Object { + "annotations": Object { + "gardener.cloud/created-by": "bar@example.org", + }, + "labels": Object { + "shoot.gardener.cloud/status": "healthy", + }, + "name": "barShoot", + "namespace": "garden-foo", + "uid": 2, + }, + "spec": Object { + "cloudProfileName": "infra1-profileName", + "hibernation": Object { + "enabled": false, + }, + "kubernetes": Object { + "version": "1.16.0", + }, + "provider": Object { + "type": "fooInfra", + }, + "purpose": "barPurpose", + "region": "foo-west", + "secretBindingName": "foo-infra1", + "seedName": "infra1-seed", + }, + "status": Object { + "advertisedAddresses": Array [ + Object { + "name": "external", + "url": "https://api.barShoot.foo.shoot.test", + }, + ], + "technicalID": "shoot--foo--barShoot", + }, + }, + Object { + "metadata": Object { + "annotations": Object { + "gardener.cloud/created-by": "foo@example.org", + }, + "labels": Object { + "shoot.gardener.cloud/status": "unhealthy", + }, + "name": "dummyShoot", + "namespace": "garden-foo", + "uid": 3, + }, + "spec": Object { + "cloudProfileName": "infra1-profileName", + "hibernation": Object { + "enabled": false, + }, + "kubernetes": Object { + "version": "1.16.0", + }, + "provider": Object { + "type": "fooInfra", + }, + "purpose": "fooPurpose", + "region": "foo-west", + "secretBindingName": "barSecretName", + "seedName": "infra4-seed-without-secretRef", + }, + "status": Object { + "technicalID": "shoot--foo--dummyShoot", + }, + }, + ], + "kind": "List", +} +`; diff --git a/backend/test/acceptance/__snapshots__/io.spec.js.snap b/backend/test/acceptance/__snapshots__/io.spec.js.snap index c6138865ae..e8ff0601cc 100644 --- a/backend/test/acceptance/__snapshots__/io.spec.js.snap +++ b/backend/test/acceptance/__snapshots__/io.spec.js.snap @@ -28,6 +28,59 @@ Array [ ] `; +exports[`api events when user is "admin" should subscribe shoots for a single cluster 2`] = ` +Array [ + Object { + "metadata": Object { + "annotations": Object { + "gardener.cloud/created-by": "foo@example.org", + }, + "name": "fooShoot", + "namespace": "garden-foo", + "uid": 1, + }, + "spec": Object { + "cloudProfileName": "infra1-profileName", + "hibernation": Object { + "enabled": false, + }, + "kubernetes": Object { + "version": "1.16.0", + }, + "provider": Object { + "type": "fooInfra", + }, + "purpose": "fooPurpose", + "region": "foo-west", + "secretBindingName": "foo-infra1", + "seedName": "infra1-seed", + }, + "status": Object { + "advertisedAddresses": Array [ + Object { + "name": "external", + "url": "https://api.fooShoot.foo.shoot.test", + }, + ], + "technicalID": "shoot--foo--fooShoot", + }, + }, + Object { + "apiVersion": "v1", + "code": 404, + "details": Object { + "group": "core.gardener.cloud", + "kind": "shoots", + "uid": 2, + }, + "kind": "Status", + "message": "Shoot with uid 2 does not exist", + "reason": "NotFound", + "status": "Failure", + }, +] +`; + exports[`api events when user is "admin" should subscribe shoots for a single namespace 1`] = ` Array [ Array [ @@ -55,6 +108,59 @@ Array [ ] `; +exports[`api events when user is "admin" should subscribe shoots for a single namespace 2`] = ` +Array [ + Object { + "metadata": Object { + "annotations": Object { + "gardener.cloud/created-by": "foo@example.org", + }, + "name": "fooShoot", + "namespace": "garden-foo", + "uid": 1, + }, + "spec": Object { + "cloudProfileName": "infra1-profileName", + "hibernation": Object { + "enabled": false, + }, + "kubernetes": Object { + "version": "1.16.0", + }, + "provider": Object { + "type": "fooInfra", + }, + "purpose": "fooPurpose", + "region": "foo-west", + "secretBindingName": "foo-infra1", + "seedName": "infra1-seed", + }, + "status": Object { + "advertisedAddresses": Array [ + Object { + "name": "external", + "url": "https://api.fooShoot.foo.shoot.test", + }, + ], + "technicalID": "shoot--foo--fooShoot", + }, + }, + Object { + "apiVersion": "v1", + "code": 404, + "details": Object { + "group": "core.gardener.cloud", + "kind": "shoots", + "uid": 4, + }, + "kind": "Status", + "message": "Shoot with uid 4 does not exist", + "reason": "NotFound", + "status": "Failure", + }, +] +`; + exports[`api events when user is "admin" should subscribe shoots for all namespace 1`] = ` Array [ Array [ @@ -81,6 +187,81 @@ Array [ ] `; +exports[`api events when user is "admin" should subscribe shoots for all namespace 2`] = ` +Array [ + Object { + "metadata": Object { + "annotations": Object { + "gardener.cloud/created-by": "foo@example.org", + }, + "name": "fooShoot", + "namespace": "garden-foo", + "uid": 1, + }, + "spec": Object { + "cloudProfileName": "infra1-profileName", + "hibernation": Object { + "enabled": false, + }, + "kubernetes": Object { + "version": "1.16.0", + }, + "provider": Object { + "type": "fooInfra", + }, + "purpose": "fooPurpose", + "region": "foo-west", + "secretBindingName": "foo-infra1", + "seedName": "infra1-seed", + }, + "status": Object { + "advertisedAddresses": Array [ + Object { + "name": "external", + "url": "https://api.fooShoot.foo.shoot.test", + }, + ], + "technicalID": "shoot--foo--fooShoot", + }, + }, + Object { + "metadata": Object { + "annotations": Object { + "gardener.cloud/created-by": "admin@example.org", + }, + "name": "infra1-seed", + "namespace": "garden", + "uid": 4, + }, + "spec": Object { + "cloudProfileName": "infra1-profileName", + "hibernation": Object { + "enabled": false, + }, + "kubernetes": Object { + "version": "1.16.0", + }, + "provider": Object { + "type": "fooInfra", + }, + "purpose": "foo-purpose", + "region": "foo-west", + "secretBindingName": "soil-infra1", + "seedName": "soil-infra1", + }, + "status": Object { + "advertisedAddresses": Array [ + Object { + "name": "external", + "url": "https://api.infra1-seed.garden.shoot.test", + }, + ], + "technicalID": "shoot--garden--infra1-seed", + }, + }, +] +`; + exports[`api events when user is "admin" should subscribe unhealthy shoots for all namespace 1`] = ` Array [ Array [ diff --git a/backend/test/acceptance/api.shoots.spec.js b/backend/test/acceptance/api.shoots.spec.js index 14775ff5d3..049b22e664 100644 --- a/backend/test/acceptance/api.shoots.spec.js +++ b/backend/test/acceptance/api.shoots.spec.js @@ -7,9 +7,17 @@ 'use strict' const { mockRequest } = require('@gardener-dashboard/request') +const { Store } = require('@gardener-dashboard/kube-client') const kubeconfig = require('@gardener-dashboard/kube-config') -const logger = require('../../lib/logger') const yaml = require('js-yaml') +const logger = require('../../lib/logger') +const cache = require('../../lib/cache') + +function createStore (items) { + const store = new Store() + store.replace(items) + return store +} describe('api', function () { let agent @@ -35,6 +43,86 @@ describe('api', function () { id: 'foo@example.org' }) + describe('when served from cache', () => { + const useCache = true + + beforeAll(() => { + cache.initialize({ + projects: { + store: createStore(fixtures.projects.list()) + }, + shoots: { + store: createStore(fixtures.shoots.list().map(item => { + const status = item.metadata.uid !== 3 + ? 'healthy' + : 'unhealthy' + item.metadata.labels = { + ...item.metadata.labels, + 'shoot.gardener.cloud/status': status + } + return item + })) + } + }) + }) + + afterAll(() => { + cache.cache.resetTicketCache() + }) + + it('should return shoots for a single namespace', async () => { + mockRequest.mockImplementationOnce(fixtures.auth.mocks.reviewSelfSubjectAccess()) + + const res = await agent + .get(`/api/namespaces/${namespace}/shoots`) + .query({ useCache }) + .set('cookie', await user.cookie) + .expect('content-type', /json/) + .expect(200) + + expect(mockRequest).toBeCalledTimes(1) + expect(mockRequest.mock.calls).toMatchSnapshot() + + expect(res.body.items.map(item => item.metadata.uid)).toEqual([1, 2, 3]) + expect(res.body).toMatchSnapshot() + }) + + it('should return all shoots', async () => { + mockRequest.mockImplementationOnce(fixtures.auth.mocks.reviewSelfSubjectAccess()) + + const res = await agent + .get('/api/namespaces/_all/shoots') + .query({ useCache }) + .set('cookie', await user.cookie) + .expect('content-type', /json/) + .expect(200) + + expect(mockRequest).toBeCalledTimes(1) + expect(mockRequest.mock.calls).toMatchSnapshot() + + expect(res.body.items.map(item => item.metadata.uid)).toEqual([1, 2, 3, 4]) + expect(res.body).toMatchSnapshot() + }) + + it('should return all unhealthy shoots', async () => { + const labelSelector = 'shoot.gardener.cloud/status!=healthy' + mockRequest.mockImplementationOnce(fixtures.auth.mocks.reviewSelfSubjectAccess()) + + const res = await agent + .get('/api/namespaces/_all/shoots') + .query({ useCache, labelSelector }) + .set('cookie', await user.cookie) + .expect('content-type', /json/) + .expect(200) + + expect(mockRequest).toBeCalledTimes(1) + expect(mockRequest.mock.calls).toMatchSnapshot() + + expect(res.body.items.map(item => item.metadata.uid)).toEqual([3]) + expect(res.body).toMatchSnapshot() + }) + }) + it('should return three shoots', async function () { mockRequest.mockImplementationOnce(fixtures.shoots.mocks.list()) diff --git a/backend/test/acceptance/io.spec.js b/backend/test/acceptance/io.spec.js index 82ee677a56..76973d9175 100644 --- a/backend/test/acceptance/io.spec.js +++ b/backend/test/acceptance/io.spec.js @@ -10,9 +10,11 @@ const { mockRequest } = require('@gardener-dashboard/request') const { Store } = require('@gardener-dashboard/kube-client') const { mockListIssues, mockListComments } = require('@octokit/rest') const pEvent = require('p-event') +const createError = require('http-errors') const tickets = require('../../lib/services/tickets') const cache = require('../../lib/cache') const io = require('../../lib/io') +const fixtures = require('../../__fixtures__') function publishEvent (socket, room, eventName, metadata) { const data = { object: { metadata } } @@ -55,6 +57,25 @@ function unsubscribe (socket, ...args) { return emit(socket, 'unsubscribe', ...args) } +async function synchronize (socket, ...args) { + const { + statusCode = 500, + name = 'InternalError', + message = 'Failed to synchronize shoots', + items = [] + } = await socket.timeout(1000).emitWithAck('synchronize', ...args) + if (statusCode === 200) { + return items + } + throw createError(statusCode, message, { name }) +} + +function createStore (items) { + const store = new Store() + store.replace(items) + return store +} + describe('api', function () { let agent let socket @@ -62,10 +83,13 @@ describe('api', function () { beforeAll(() => { cache.cache.resetTicketCache() - const store = new Store() - store.replace(fixtures.projects.list()) cache.initialize({ - projects: { store } + projects: { + store: createStore(fixtures.projects.list()) + }, + shoots: { + store: createStore(fixtures.shoots.list()) + } }) agent = createAgent('io', cache) nsp = agent.io.sockets @@ -252,6 +276,9 @@ describe('api', function () { socket.id, 'shoots;garden-foo/fooShoot' ])) + + const items = await synchronize(socket, 'shoots', [1, 2]) + expect(items).toMatchSnapshot() }) it('should subscribe shoots for a single namespace', async function () { @@ -266,6 +293,9 @@ describe('api', function () { socket.id, 'shoots;garden-foo' ])) + + const items = await synchronize(socket, 'shoots', [1, 4]) + expect(items).toMatchSnapshot() }) it('should subscribe shoots for all namespace', async function () { @@ -280,6 +310,9 @@ describe('api', function () { socket.id, 'shoots:admin' ])) + + const items = await synchronize(socket, 'shoots', [1, 4]) + expect(items).toMatchSnapshot() }) it('should subscribe unhealthy shoots for all namespace', async function () { @@ -295,6 +328,14 @@ describe('api', function () { 'shoots:unhealthy:admin' ])) }) + + it('should fail to syncronize cats', async function () { + mockRequest.mockImplementationOnce(fixtures.auth.mocks.reviewSelfSubjectAccess()) + + await expect(synchronize(socket, 'cats', [42])) + .rejects + .toThrow('Invalid synchronization type - cats') + }) }) }) diff --git a/backend/test/cache.spec.js b/backend/test/cache.spec.js index b504538c53..9f645f56e4 100644 --- a/backend/test/cache.spec.js +++ b/backend/test/cache.spec.js @@ -57,12 +57,17 @@ describe('cache', function () { }) it('should dispatch "getShoots" to internal cache', function () { - const object = { metadata: { uid: 1 } } - const list = [object] + const list = [ + { metadata: { uid: 1, namespace: 'foo' } }, + { metadata: { uid: 2, namespace: 'bar' } } + ] const store = new Store() store.replace(list) internalCache.set('shoots', store) - expect(cache.getShoots()).toEqual(list) + expect(cache.getShoots('_all')).toEqual(list) + expect(cache.getShoots('foo')).toEqual(list.slice(0, 1)) + expect(cache.getShoots('bar')).toEqual(list.slice(1, 2)) + expect(() => cache.getShoots()).toThrow(TypeError) }) it('should dispatch "getShoot" to internal cache', function () { @@ -72,6 +77,14 @@ describe('cache', function () { expect(cache.getShoot('garden-foo', 'fooShoot')).toBe(store.getByKey(1)) }) + it('should dispatch "getShootByUid" to internal cache', function () { + const store = new Store() + store.replace(fixtures.shoots.list()) + internalCache.set('shoots', store) + const object = store.getByKey(1) + expect(cache.getShootByUid(object.metadata.uid)).toBe(object) + }) + it('should dispatch "getControllerRegistrations" to internal cache', function () { const list = [] const stub = jest.spyOn(internalCache, 'getControllerRegistrations').mockReturnValue(list) diff --git a/backend/test/utils.spec.js b/backend/test/utils.spec.js index 8caf9ce95a..822c7f2159 100644 --- a/backend/test/utils.spec.js +++ b/backend/test/utils.spec.js @@ -7,7 +7,21 @@ 'use strict' const { AssertionError } = require('assert').strict -const { encodeBase64, decodeBase64, getConfigValue, shootHasIssue, getSeedNameFromShoot } = require('../lib/utils') +const { merge } = require('lodash') +const config = require('../lib/config') +const { + encodeBase64, + decodeBase64, + getConfigValue, + shootHasIssue, + getSeedNameFromShoot, + parseSelectors, + filterBySelectors, + useWatchCacheForListShoots, + constants, + trimObjectMetadata, + parseRooms +} = require('../lib/utils') describe('utils', function () { describe('index', function () { @@ -55,5 +69,157 @@ describe('utils', function () { } expect(getSeedNameFromShoot(shoot)).toBe('foo') }) + + it('should trim object metadata', () => { + const name = 'test' + const managedFields = 'managedFields' + const lastAppliedConfiguration = 'last-applied-configuration' + const metadata = { + name, + annotations: { + foo: 'bar' + } + } + expect(trimObjectMetadata({ metadata })).toEqual({ metadata }) + const extendedMetadata = merge(metadata, { + managedFields, + annotations: { + 'kubectl.kubernetes.io/last-applied-configuration': lastAppliedConfiguration + } + }) + expect(trimObjectMetadata({ metadata: extendedMetadata })).toEqual({ metadata }) + }) + + it('should parse labelSelectors', () => { + expect(parseSelectors([ + 'shoot.gardener.cloud/status!=healthy' + ])).toEqual([{ + key: 'shoot.gardener.cloud/status', + op: constants.NOT_EQUAL, + value: 'healthy' + }]) + expect(parseSelectors([ + 'foo=1', + 'bar==2', + 'qux!=3' + ])).toEqual([{ + key: 'foo', + op: constants.EQUAL, + value: '1' + }, { + key: 'bar', + op: constants.EQUAL, + value: '2' + }, { + key: 'qux', + op: constants.NOT_EQUAL, + value: '3' + }]) + expect(parseSelectors([ + 'foo', + '!baz' + ])).toEqual([{ + key: 'foo', + op: constants.EXISTS + }, { + key: 'baz', + op: constants.NOT_EXISTS + }]) + }) + + it('should filter by labelSelectors', () => { + const labels = { + foo: '1', + bar: '2', + qux: '3' + } + const item = { + metadata: { + labels + } + } + expect(filterBySelectors([{ + key: 'foo', + op: constants.EXISTS + }])(item)).toBe(true) + expect(filterBySelectors([{ + key: 'baz', + op: constants.EXISTS + }])(item)).toBe(false) + expect(filterBySelectors([{ + key: 'baz', + op: constants.NOT_EXISTS + }])(item)).toBe(true) + expect(filterBySelectors([{ + key: 'foo', + op: constants.NOT_EXISTS + }])(item)).toBe(false) + expect(filterBySelectors([{ + key: 'foo', + op: constants.EQUAL, + value: '1' + }])(item)).toBe(true) + expect(filterBySelectors([{ + key: 'bar', + op: constants.EQUAL, + value: '1' + }])(item)).toBe(false) + expect(filterBySelectors([{ + key: 'qux', + op: constants.NOT_EQUAL, + value: '2' + }])(item)).toBe(true) + expect(filterBySelectors([{ + key: 'qux', + op: constants.NOT_EQUAL, + value: '3' + }])(item)).toBe(false) + }) + + it('should parse rooms for all kind of shoot subscriptions', () => { + expect(parseRooms(['seeds:admin'])).toEqual([ + false, [], [] + ]) + expect(parseRooms(['shoots:admin'])).toEqual([ + true, [], [] + ]) + expect(parseRooms(['shoots:unhealthy:admin'])).toEqual([ + true, [], [] + ]) + expect(parseRooms(['shoots;garden-foo'])).toEqual([ + false, ['garden-foo'], [] + ]) + expect(parseRooms(['shoots:unhealthy;garden-foo'])).toEqual([ + false, ['garden-foo'], [] + ]) + expect(parseRooms(['shoots;garden-foo/bar'])).toEqual([ + false, [], ['garden-foo/bar'] + ]) + }) + + describe('control usage of watch cache', () => { + let experimentalUseWatchCacheForListShoots + + beforeAll(() => { + experimentalUseWatchCacheForListShoots = config.experimentalUseWatchCacheForListShoots + }) + + afterAll(() => { + config.experimentalUseWatchCacheForListShoots = experimentalUseWatchCacheForListShoots + }) + + it('return if the watch cache should be used for list shoots request', () => { + config.experimentalUseWatchCacheForListShoots = 'never' + expect(useWatchCacheForListShoots(true)).toBe(false) + config.experimentalUseWatchCacheForListShoots = 'always' + expect(useWatchCacheForListShoots(false)).toBe(true) + config.experimentalUseWatchCacheForListShoots = 'yes' + expect(useWatchCacheForListShoots(undefined)).toBe(true) + expect(useWatchCacheForListShoots('false')).toBe(false) + config.experimentalUseWatchCacheForListShoots = 'no' + expect(useWatchCacheForListShoots(undefined)).toBe(false) + expect(useWatchCacheForListShoots('true')).toBe(true) + }) + }) }) }) diff --git a/backend/test/watches.spec.js b/backend/test/watches.spec.js index ff773676a7..9d52b52e2a 100644 --- a/backend/test/watches.spec.js +++ b/backend/test/watches.spec.js @@ -105,15 +105,15 @@ describe('watches', function () { expect(room.emit.mock.calls).toEqual([ [ 'shoots', - { type: 'ADDED', object: foobar } + { type: 'ADDED', uid: foobar.metadata.uid } ], [ 'shoots', - { type: 'MODIFIED', object: foobar } + { type: 'MODIFIED', uid: foobar.metadata.uid } ], [ 'shoots', - { type: 'DELETED', object: foobar } + { type: 'DELETED', uid: foobar.metadata.uid } ] ]) } @@ -141,23 +141,23 @@ describe('watches', function () { expect(fooIssuesRoom.emit.mock.calls).toEqual([ [ 'shoots', - { type: 'ADDED', object: foobarUnhealthy } + { type: 'ADDED', uid: foobarUnhealthy.metadata.uid } ], [ 'shoots', - { type: 'DELETED', object: foobar } + { type: 'DELETED', uid: foobar.metadata.uid } ], [ 'shoots', - { type: 'ADDED', object: foobazUnhealthy } + { type: 'ADDED', uid: foobazUnhealthy.metadata.uid } ], [ 'shoots', - { type: 'MODIFIED', object: foobazUnhealthy } + { type: 'MODIFIED', uid: foobazUnhealthy.metadata.uid } ], [ 'shoots', - { type: 'DELETED', object: foobazUnhealthy } + { type: 'DELETED', uid: foobazUnhealthy.metadata.uid } ] ]) }) diff --git a/charts/__tests__/gardener-dashboard/runtime/dashboard/__snapshots__/configmap.spec.js.snap b/charts/__tests__/gardener-dashboard/runtime/dashboard/__snapshots__/configmap.spec.js.snap index 7aca902b2e..d2cbde338b 100644 --- a/charts/__tests__/gardener-dashboard/runtime/dashboard/__snapshots__/configmap.spec.js.snap +++ b/charts/__tests__/gardener-dashboard/runtime/dashboard/__snapshots__/configmap.spec.js.snap @@ -146,6 +146,16 @@ Object { } `; +exports[`gardener-dashboard configmap experimental should render the template with experimental features 1`] = ` +Object { + "frontend": Object { + "experimental": Object { + "throttleDelayPerCluster": 42, + }, + }, +} +`; + exports[`gardener-dashboard configmap grantTypes should render the template 1`] = ` Object { "frontend": Object { @@ -374,6 +384,7 @@ Object { "'self'", ], }, + "experimentalUseWatchCacheForListShoots": "never", "frontend": Object { "defaultHibernationSchedule": Object { "development": Array [ @@ -390,6 +401,9 @@ Object { "production": null, }, "defaultNodesCIDR": "10.250.0.0/16", + "experimental": Object { + "throttleDelayPerCluster": 10, + }, "externalTools": Array [ Object { "icon": "apps", diff --git a/charts/__tests__/gardener-dashboard/runtime/dashboard/configmap.spec.js b/charts/__tests__/gardener-dashboard/runtime/dashboard/configmap.spec.js index 510fc6fa0d..9750900904 100644 --- a/charts/__tests__/gardener-dashboard/runtime/dashboard/configmap.spec.js +++ b/charts/__tests__/gardener-dashboard/runtime/dashboard/configmap.spec.js @@ -801,5 +801,43 @@ describe('gardener-dashboard', function () { expect(pick(config, ['frontend.knownConditions'])).toMatchSnapshot() }) }) + + describe('experimental', function () { + it('should render the template with experimental features', async function () { + const values = { + global: { + dashboard: { + frontendConfig: { + experimental: { + throttleDelayPerCluster: 42 + } + } + } + } + } + const documents = await renderTemplates(templates, values) + expect(documents).toHaveLength(1) + const [configMap] = documents + const config = yaml.load(configMap.data['config.yaml']) + expect(pick(config, ['frontend.experimental'])).toMatchSnapshot() + }) + }) + + describe('experimentalUseWatchCacheForListShoots', function () { + it('should render the template with value "no"', async function () { + const values = { + global: { + dashboard: { + experimentalUseWatchCacheForListShoots: 'no' + } + } + } + const documents = await renderTemplates(templates, values) + expect(documents).toHaveLength(1) + const [configMap] = documents + const config = yaml.load(configMap.data['config.yaml']) + expect(config.experimentalUseWatchCacheForListShoots).toBe('no') + }) + }) }) }) diff --git a/charts/gardener-dashboard/charts/runtime/templates/dashboard/configmap.yaml b/charts/gardener-dashboard/charts/runtime/templates/dashboard/configmap.yaml index 09123cda3e..43bf5d75ae 100644 --- a/charts/gardener-dashboard/charts/runtime/templates/dashboard/configmap.yaml +++ b/charts/gardener-dashboard/charts/runtime/templates/dashboard/configmap.yaml @@ -34,6 +34,7 @@ data: {{- if .Values.global.dashboard.clusterIdentity }} clusterIdentity: {{ .Values.global.dashboard.clusterIdentity }} {{- end }} + experimentalUseWatchCacheForListShoots: {{ .Values.global.dashboard.experimentalUseWatchCacheForListShoots | default "never" }} readinessProbe: periodSeconds: {{ .Values.global.dashboard.readinessProbe.periodSeconds }} {{- if .Values.global.dashboard.gitHub }} @@ -210,6 +211,8 @@ data: features: terminalEnabled: {{ .Values.global.dashboard.frontendConfig.features.terminalEnabled | default false }} projectTerminalShortcutsEnabled: {{ .Values.global.dashboard.frontendConfig.features.projectTerminalShortcutsEnabled | default false }} + experimental: + throttleDelayPerCluster: {{ .Values.global.dashboard.frontendConfig.experimental.throttleDelayPerCluster | default 10 }} {{- if .Values.global.dashboard.frontendConfig.terminal }} terminal: {{- if .Values.global.dashboard.frontendConfig.terminal.heartbeatIntervalSeconds }} diff --git a/charts/gardener-dashboard/values.yaml b/charts/gardener-dashboard/values.yaml index 71ea590ef0..4d6866782a 100644 --- a/charts/gardener-dashboard/values.yaml +++ b/charts/gardener-dashboard/values.yaml @@ -64,6 +64,17 @@ global: # - foo # # the identifier of the gardener landscape (defaults to the name stored in kube-system/cluster-identity configmap) # clusterIdentity: my-landscape-dev + + # Experimental Feature: Control of Watch Cache for List Shoots Requests + # This feature allows fine-tuning the behavior of how list shoots requests are handled in terms of caching. + # Note: As this is an experimental feature, it is subject to change and may not be stable. + # Possible values for `experimentalUseWatchCacheForListShoots` are: + # - 'never': All list shoots requests are directly forwarded to the kube-apiserver without using the watch cache. + # - 'no': The watch cache is not utilized by default. Clients can opt-in to use the watch cache. + # - 'yes': The watch cache is used by default for serving list shoots requests. Clients can opt-out if they require data directly from the kube-apiserver. + # - 'always': All list shoots requests are served from the watch cache. + experimentalUseWatchCacheForListShoots: never + containerPort: 8080 metricsContainerPort: 9050 servicePort: 8080 @@ -215,6 +226,15 @@ global: features: terminalEnabled: false projectTerminalShortcutsEnabled: false + # Experimental Features + # Note: The following features are experimental and may be subject to change. + experimental: + # This configuration sets the base multiplier for calculating the throttle wait delay + # in synchronization processes. The 'throttleDelayPerCluster' value + # defines the amount of time in milliseconds that should be added per cluster to + # the throttle delay. This helps in dynamically adjusting the delay based on the + # number of active clusters, thereby optimizing network traffic and resource usage. + throttleDelayPerCluster: 10 # alert: # type: error # message: This is an **alert** banner diff --git a/frontend/__tests__/stores/shoot.spec.js b/frontend/__tests__/stores/shoot.spec.js index cc27187921..d202ff1ec0 100644 --- a/frontend/__tests__/stores/shoot.spec.js +++ b/frontend/__tests__/stores/shoot.spec.js @@ -21,8 +21,11 @@ import { useApi } from '@/composables/useApi' import { cloneDeep, map, + find, } from '@/lodash' +const globalSetImmediate = global.setImmediate + const noop = () => {} describe('stores', () => { @@ -38,19 +41,27 @@ describe('stores', () => { }) const api = useApi() - let mockGetShoots // eslint-disable-line no-unused-vars - let mockGetIssues // eslint-disable-line no-unused-vars - let mockGetShoot // eslint-disable-line no-unused-vars - let mockGetIssuesAndComments // eslint-disable-line no-unused-vars - let mockGetShootInfo // eslint-disable-line no-unused-vars - let mockEmitSubscribe // eslint-disable-line no-unused-vars - let mockEmitUnsubscribe // eslint-disable-line no-unused-vars + let mockGetShoots + /* eslint-disable no-unused-vars */ + let mockGetIssues + let mockGetShoot + let mockGetIssuesAndComments + let mockGetShootInfo + let mockEmitSubscribe + let mockEmitUnsubscribe + let mockSynchronize + /* eslint-enable no-unused-vars */ let authnStore let authzStore let projectStore let socketStore let shootStore + const flushEvents = () => { + shootStore.state.subscriptionEventHandler.flush() + return new Promise(resolve => globalSetImmediate(resolve)) + } + const shootList = [ { metadata: { @@ -212,6 +223,16 @@ describe('stores', () => { socketStore = useSocketStore() mockEmitSubscribe = vi.spyOn(socketStore, 'emitSubscribe').mockImplementation(noop) mockEmitUnsubscribe = vi.spyOn(socketStore, 'emitUnsubscribe').mockImplementation(noop) + const getShoots = uids => map(uids, uid => { + return find(shootList, ['metadata.uid', uid]) ?? { + metadata: { + name: `shoot${uid}`, + namespace: 'foo', + uid: `${uid}`, + }, + } + }) + mockSynchronize = vi.spyOn(socketStore, 'synchronize').mockImplementation(uids => Promise.resolve(getShoots(uids))) shootStore = useShootStore() shootStore.initializeShootListFilters() }) @@ -285,69 +306,77 @@ describe('stores', () => { }) describe('focus mode', () => { + let uid + beforeEach(async () => { await shootStore.subscribe({ namespace: '_all' }) }) - it('should mark no longer existing shoots as stale when shoot list is freezed', () => { + it('should mark no longer existing shoots as stale when shoot list is freezed', async () => { + uid = shootStore.activeShoots[0].metadata.uid + expect(shootStore.isShootActive(uid)).toBe(true) shootStore.handleEvent({ type: 'DELETED', - object: shootStore.filteredShoots[0], + uid, }) - expect(shootStore.filteredShoots.length).toBe(2) - expect(shootStore.shootList.length).toBe(2) - expect(shootStore.shootList[0].stale).toBeUndefined() + await flushEvents() + expect(shootStore.activeShoots).toHaveLength(2) + expect(shootStore.shootList).toHaveLength(2) + expect(shootStore.isShootActive(uid)).toBe(false) + expect(shootStore.staleShoots[uid]).toBeUndefined() shootStore.setFocusMode(true) - expect(shootStore.shootList[0].stale).toBeUndefined() + uid = shootStore.activeShoots[0].metadata.uid + expect(shootStore.isShootActive(uid)).toBe(true) shootStore.handleEvent({ type: 'DELETED', - object: shootStore.filteredShoots[0], + uid, }) - expect(shootStore.filteredShoots.length).toBe(1) - expect(shootStore.shootList.length).toBe(2) - expect(shootStore.shootList[0].stale).toBe(true) + await flushEvents() + expect(shootStore.activeShoots).toHaveLength(1) + expect(shootStore.shootList).toHaveLength(2) + expect(shootStore.isShootActive(uid)).toBe(false) + expect(shootStore.staleShoots[uid]).toBeDefined() }) - it('should not add new shoots to list when shoot list is freezed', () => { + it('should not add new shoots to list when shoot list is freezed', async () => { + uid = 4 shootStore.setFocusMode(true) shootStore.handleEvent({ type: 'ADDED', - object: { - metadata: { - name: 'shoot4', - namespace: 'foo', - uid: 'shoot4', - }, - }, + uid, }) - expect(shootStore.filteredShoots.length).toBe(4) - expect(shootStore.sortedUidsAtFreeze.length).toBe(3) - expect(shootStore.shootList.length).toBe(3) + await flushEvents() + expect(shootStore.activeShoots).toHaveLength(4) + expect(shootStore.state.froozenUids).toHaveLength(3) + expect(shootStore.shootList).toHaveLength(3) shootStore.setFocusMode(false) - expect(shootStore.shootList.length).toBe(4) + expect(shootStore.shootList).toHaveLength(4) shootStore.setFocusMode(true) - expect(shootStore.shootList.length).toBe(4) + expect(shootStore.shootList).toHaveLength(4) }) - it('should add and remove staleShoots', () => { + it('should add and remove staleShoots', async () => { const shoot = shootList[1] + uid = shoot.metadata.uid shootStore.setFocusMode(true) shootStore.handleEvent({ type: 'DELETED', - object: shoot, + uid, }) - expect(shootStore.staleShoots[shoot.metadata.uid]).toBeDefined() + await flushEvents() + expect(shootStore.staleShoots[uid]).toEqual(shoot) shootStore.handleEvent({ type: 'ADDED', - object: shoot, + uid, }) - expect(shootStore.staleShoots[shoot.metadata.uid]).toBeUndefined() + await flushEvents() + expect(shootStore.staleShoots[uid]).toBeUndefined() }) it('should receive items and update staleShoots', async () => { @@ -358,13 +387,16 @@ describe('stores', () => { shootStore.setFocusMode(true) shootStore.handleEvent({ type: 'DELETED', - object: deletedItem, + uid: deletedItem.metadata.uid, }) - - expect(shootStore.sortedUidsAtFreeze.length).toBe(3) + await flushEvents() + expect(shootStore.state.froozenUids).toHaveLength(3) expect(shootStore.staleShoots[deletedItem.metadata.uid]).toBeDefined() - expect(shootStore.filteredShoots.length).toBe(2) - expect(map(shootStore.filteredShoots, 'metadata.name')).toEqual(['shoot1', 'shoot3']) + expect(shootStore.activeShoots).toHaveLength(2) + expect(map(shootStore.activeShoots, 'metadata.name').sort()).toEqual([ + 'shoot1', + 'shoot3', + ]) mockGetShoots.mockResolvedValueOnce({ data: { @@ -373,11 +405,14 @@ describe('stores', () => { }) await shootStore.synchronize() - expect(shootStore.sortedUidsAtFreeze.length).toBe(3) - expect(shootStore.staleShoots[missingItem.metadata.uid]).toBeDefined() + expect(shootStore.state.froozenUids).toHaveLength(3) + expect(shootStore.staleShoots[missingItem.metadata.uid]).toEqual(missingItem) expect(shootStore.staleShoots[deletedItem.metadata.uid]).toBeUndefined() - expect(shootStore.filteredShoots.length).toBe(2) - expect(map(shootStore.filteredShoots, 'metadata.name')).toEqual(['shoot2', 'shoot1']) + expect(shootStore.activeShoots).toHaveLength(2) + expect(map(shootStore.activeShoots, 'metadata.name').sort()).toEqual([ + 'shoot1', + 'shoot2', + ]) }) }) }) diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 2c65986b55..9619daf5af 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -19,18 +19,22 @@ import { onKeyStroke, useEventBus, useColorMode, + useDocumentVisibility, } from '@vueuse/core' import { useConfigStore } from '@/store/config' import { useLoginStore } from '@/store/login' import { useLocalStorageStore } from '@/store/localStorage' +import { useShootStore } from '@/store/shoot' import { useCustomColors } from '@/composables/useCustomColors' const theme = useTheme() const localStorageStore = useLocalStorageStore() +const visibility = useDocumentVisibility() const configStore = useConfigStore() const loginStore = useLoginStore() +const shootStore = useShootStore() const logger = inject('logger') async function setCustomColors () { @@ -66,4 +70,10 @@ onKeyStroke('Escape', e => { bus.emit() e.preventDefault() }) + +watch(visibility, (current, previous) => { + if (current === 'visible' && previous === 'hidden') { + shootStore.invokeSubscriptionEventHandler() + } +}) diff --git a/frontend/src/components/GClusterMetrics.vue b/frontend/src/components/GClusterMetrics.vue index 12df81390d..fa4915a978 100644 --- a/frontend/src/components/GClusterMetrics.vue +++ b/frontend/src/components/GClusterMetrics.vue @@ -55,19 +55,19 @@ export default { mixins: [shootItem], computed: { plutonoUrl () { - return get(this.shootItem, 'info.plutonoUrl', '') + return get(this.shootInfo, 'plutonoUrl', '') }, prometheusUrl () { - return get(this.shootItem, 'info.prometheusUrl', '') + return get(this.shootInfo, 'prometheusUrl', '') }, alertmanagerUrl () { - return get(this.shootItem, 'info.alertmanagerUrl', '') + return get(this.shootInfo, 'alertmanagerUrl', '') }, username () { - return get(this.shootItem, 'info.monitoringUsername', '') + return get(this.shootInfo, 'monitoringUsername', '') }, password () { - return get(this.shootItem, 'info.monitoringPassword', '') + return get(this.shootInfo, 'monitoringPassword', '') }, hasAlertmanager () { const emailReceivers = get(this.shootItem, 'spec.monitoring.alerting.emailReceivers', []) diff --git a/frontend/src/components/GCopyBtn.vue b/frontend/src/components/GCopyBtn.vue index 75c8521be8..5f4411d46a 100644 --- a/frontend/src/components/GCopyBtn.vue +++ b/frontend/src/components/GCopyBtn.vue @@ -60,10 +60,11 @@ const props = defineProps({ }, }) +let timeoutId + // reactive state const snackbar = ref(false) const copySucceeded = ref(false) -const timeoutId = ref() // computed const snackbarText = computed(() => props.copyFailedText) @@ -95,8 +96,8 @@ const copyText = async () => { const text = await toValue(props.clipboardText) await navigator.clipboard.writeText(text) copySucceeded.value = true - clearTimeout(timeoutId.value) - timeoutId.value = setTimeout(() => { + clearTimeout(timeoutId) + timeoutId = setTimeout(() => { copySucceeded.value = false }, 1000) emit('copy') diff --git a/frontend/src/components/floating/GPopover.vue b/frontend/src/components/floating/GPopover.vue index 0f9a7ee15d..5bf192e14b 100644 --- a/frontend/src/components/floating/GPopover.vue +++ b/frontend/src/components/floating/GPopover.vue @@ -48,7 +48,7 @@ SPDX-License-Identifier: Apache-2.0 density="comfortable" size="small" icon="mdi-close" - @click="hide()" + @click.stop.prevent="hide()" /> diff --git a/frontend/src/composables/useApi/api.js b/frontend/src/composables/useApi/api.js index 0aaad8cfee..34a37c9fa8 100644 --- a/frontend/src/composables/useApi/api.js +++ b/frontend/src/composables/useApi/api.js @@ -79,9 +79,16 @@ export function getIssuesAndComments ({ namespace, name }) { /* Shoot Clusters */ -export function getShoots ({ namespace, labelSelector }) { - const search = labelSelector - ? '?' + new URLSearchParams({ labelSelector }).toString() +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() : '' namespace = encodeURIComponent(namespace) return getResource(`/api/namespaces/${namespace}/shoots` + search) diff --git a/frontend/src/lib/terminal.js b/frontend/src/lib/terminal.js index 26f1df5acb..440e4319fe 100644 --- a/frontend/src/lib/terminal.js +++ b/frontend/src/lib/terminal.js @@ -293,7 +293,7 @@ function closeWsIfNotClosed (ws) { } } -async function waitForPodRunning (hostCluster, containerName, handleEvent, timeoutSeconds) { +async function waitForPodRunning (hostCluster, containerName, onPodEvent, timeoutSeconds) { const connectTimeoutSeconds = 5 const createConnection = () => { @@ -372,11 +372,11 @@ async function waitForPodRunning (hostCluster, containerName, handleEvent, timeo return } const pod = event.object - if (typeof handleEvent === 'function') { + if (typeof onPodEvent === 'function') { try { - handleEvent(event) + onPodEvent(event) } catch (error) { - logger.error('error during handleEvent', error.message) + logger.error('error during handling of pod event', error.message) } } diff --git a/frontend/src/lodash.js b/frontend/src/lodash.js index 1297d94eb6..43060aff4c 100644 --- a/frontend/src/lodash.js +++ b/frontend/src/lodash.js @@ -77,3 +77,4 @@ export { default as upperCase } from 'lodash/upperCase' export { default as upperFirst } from 'lodash/upperFirst' export { default as values } from 'lodash/values' export { default as words } from 'lodash/words' +export { default as throttle } from 'lodash/throttle' diff --git a/frontend/src/mixins/shootItem.js b/frontend/src/mixins/shootItem.js index ab0cb5aab3..ff3de8d56e 100644 --- a/frontend/src/mixins/shootItem.js +++ b/frontend/src/mixins/shootItem.js @@ -6,6 +6,7 @@ import { mapActions } from 'pinia' +import { useShootStore } from '@/store/shoot' import { useCloudProfileStore } from '@/store/cloudProfile' import { useProjectStore } from '@/store/project' import { useSeedStore } from '@/store/seed' @@ -170,7 +171,7 @@ export const shootItem = { return this.shootInfo.seedShootIngressDomain || '' }, canLinkToSeed () { - return get(this.shootItem, 'info.canLinkToSeed', false) + return get(this.shootInfo, 'canLinkToSeed', false) }, isShootLastOperationTypeDelete () { return isTypeDelete(this.shootLastOperation) @@ -265,10 +266,13 @@ export const shootItem = { return this.lastMaintenance.state === 'Failed' }, isStaleShoot () { - return this.shootItem?.stale + return !this.isShootActive(this.shootMetadata.uid) }, }, methods: { + ...mapActions(useShootStore, [ + 'isShootActive', + ]), ...mapActions(useCloudProfileStore, [ 'selectedAccessRestrictionsForShootByCloudProfileNameAndRegion', ]), diff --git a/frontend/src/mixins/shootSubscription.js b/frontend/src/mixins/shootSubscription.js index d0f086a37a..68bf300c71 100644 --- a/frontend/src/mixins/shootSubscription.js +++ b/frontend/src/mixins/shootSubscription.js @@ -30,12 +30,15 @@ export const shootSubscription = { 'active', ]), kind () { - if (this.loading) { + if (this.subscriptionState === constants.LOADING) { return this.subscriptionError - ? this.subscriptionState === constants.LOADING - ? 'alert-load' - : 'alert-subscribe' - : 'progress' + ? 'alert-load' + : 'progress-load' + } + if (this.subscriptionState === constants.LOADED || this.subscriptionState === constants.OPENING) { + return this.subscriptionError + ? 'alert-subscribe' + : 'progress-subscribe' } if (!this.connected) { return this.active @@ -52,7 +55,8 @@ export const shootSubscription = { return 'error' case 'progress-connect': return colors.grey.darken1 - case 'progress': + case 'progress-load': + case 'progress-subscribe': default: return 'primary' } @@ -70,7 +74,9 @@ export const shootSubscription = { return `Subscribing ${name} failed. Data may be outdated` case 'progress-connect': return 'Establishing real-time server connection ...' - case 'progress': + case 'progress-load': + return `Loading ${name} ...` + case 'progress-subscribe': return `Subscribing ${name} ...` default: return this.subscribed diff --git a/frontend/src/router/guards.js b/frontend/src/router/guards.js index 2457c8cda2..c58a968969 100644 --- a/frontend/src/router/guards.js +++ b/frontend/src/router/guards.js @@ -71,7 +71,7 @@ export function createGlobalBeforeGuards () { function ensureDataLoaded () { return async (to, from, next) => { const { meta = {} } = to - if (meta.public || to.name === 'Error') { + if (meta.public) { shootStore.unsubscribeShoots() return next() } @@ -99,6 +99,11 @@ export function createGlobalBeforeGuards () { } switch (to.name) { + case 'Home': + case 'ProjectList': { + // no action required for redirect routes + break + } case 'Secrets': case 'Secret': { shootStore.subscribeShoots() @@ -128,14 +133,20 @@ export function createGlobalBeforeGuards () { } break } + case 'ShootItem': + case 'ShootItemEditor': + case 'ShootItemHibernationSettings': + case 'ShootItemTerminal': { + // shoot subscription and data retrieval is done in GShootItemPlaceholder + break + } case 'Members': case 'Administration': { shootStore.subscribeShoots() await memberStore.fetchMembers() break } - case 'Account': - case 'Settings': { + default: { shootStore.unsubscribeShoots() break } diff --git a/frontend/src/store/config.js b/frontend/src/store/config.js index 0ea8e90ee0..3a244bc6d0 100644 --- a/frontend/src/store/config.js +++ b/frontend/src/store/config.js @@ -129,6 +129,10 @@ export const useConfigStore = defineStore('config', () => { return state.value?.features }) + const experimental = computed(() => { + return state.value?.experimental + }) + const grantTypes = computed(() => { return state.value?.grantTypes ?? ['auto', 'authcode', 'device-code'] }) @@ -137,6 +141,13 @@ export const useConfigStore = defineStore('config', () => { return state.value?.knownConditions }) + const allKnownConditions = computed(() => { + return { + ...wellKnownConditions, + ...knownConditions.value, + } + }) + const resourceQuotaHelp = computed(() => { return state.value?.resourceQuotaHelp }) @@ -219,6 +230,10 @@ export const useConfigStore = defineStore('config', () => { return features.value?.projectTerminalShortcutsEnabled === true }) + const throttleDelayPerCluster = computed(() => { + return experimental.value?.throttleDelayPerCluster ?? 10 + }) + const alertBannerMessage = computed(() => { return alert.value?.message }) @@ -307,7 +322,7 @@ export const useConfigStore = defineStore('config', () => { } function conditionForType (type) { - return get(knownConditions.value, type, getCondition(type)) + return allKnownConditions.value[type] ?? getCondition(type) } return { @@ -340,6 +355,7 @@ export const useConfigStore = defineStore('config', () => { serviceAccountDefaultTokenExpiration, isTerminalEnabled, isProjectTerminalShortcutsEnabled, + throttleDelayPerCluster, alertBannerMessage, alertBannerType, alertBannerIdentifier, diff --git a/frontend/src/store/localStorage.js b/frontend/src/store/localStorage.js index 1ffcfdcf21..a3a8c9c89f 100644 --- a/frontend/src/store/localStorage.js +++ b/frontend/src/store/localStorage.js @@ -221,6 +221,11 @@ export const useLocalStorageStore = defineStore('localStorage', () => { writeDefaults: false, }) + const shootListFetchFromCache = useLocalStorage('projects/shoot-list/fetch-from-cache', false, { + serializer: StorageSerializers.flag, + writeDefaults: false, + }) + const lazyLocalStorage = useLazyLocalStorage() const shootCustomSelectedColumns = computed({ @@ -272,6 +277,7 @@ export const useLocalStorageStore = defineStore('localStorage', () => { shootItemsPerPage, shootSortBy, allProjectsShootFilter, + shootListFetchFromCache, shootCustomSortBy, shootCustomSelectedColumns, terminalSplitpaneTree, diff --git a/frontend/src/store/project.js b/frontend/src/store/project.js index f6eef37372..fcf5b3f7cb 100644 --- a/frontend/src/store/project.js +++ b/frontend/src/store/project.js @@ -52,6 +52,16 @@ export const useProjectStore = defineStore('project', () => { return map(list.value, 'metadata.namespace') }) + const projectNameMap = computed(() => { + const projectNames = {} + if (Array.isArray(list.value)) { + for (const { metadata: { namespace, name } } of list.value) { + projectNames[namespace] = name + } + } + return projectNames + }) + const defaultNamespace = computed(() => { if (namespaces.value) { return namespaces.value.includes('garden') @@ -153,8 +163,7 @@ export const useProjectStore = defineStore('project', () => { const namespace = typeof metadata === 'string' ? metadata : metadata?.namespace - const project = find(list.value, ['metadata.namespace', namespace]) - return get(project, 'metadata.name') || replace(namespace, /^garden-/, '') + return projectNameMap.value[namespace] ?? replace(namespace, /^garden-/, '') } async function fetchProjects () { diff --git a/frontend/src/store/shoot/helper.js b/frontend/src/store/shoot/helper.js index d9ce266b76..2ed34af070 100644 --- a/frontend/src/store/shoot/helper.js +++ b/frontend/src/store/shoot/helper.js @@ -4,7 +4,9 @@ // SPDX-License-Identifier: Apache-2.0 // -import semver from 'semver' +import { computed } from 'vue' + +import { useLogger } from '@/composables/useLogger' import { shortRandomString, @@ -33,7 +35,6 @@ import { import { find, includes, - assign, set, head, get, @@ -52,17 +53,6 @@ import { export const uriPattern = /^([^:/?#]+:)?(\/\/[^/?#]*)?([^?#]*)(\?[^#]*)?(#.*)?/ -export function keyForShoot ({ name, namespace }) { - return `${name}_${namespace}` -} - -export function findItem (state) { - return ({ name, namespace }) => { - const key = keyForShoot({ name, namespace }) - return state.shoots[key] - } -} - const tokenizePattern = /(-?"([^"]|"")*"|\S+)/g export function tokenizeSearch (text) { @@ -116,10 +106,11 @@ export function parseSearch (text) { export const constants = Object.freeze({ DEFINED: 0, LOADING: 1, - OPENING: 2, - OPEN: 3, - CLOSING: 4, - CLOSED: 5, + LOADED: 2, + OPENING: 3, + OPEN: 4, + CLOSING: 5, + CLOSED: 6, }) export function createShootResource (context) { @@ -277,102 +268,88 @@ export function onlyAllShootsWithIssues (state, context) { return authzStore.namespace === '_all' && get(state.shootListFilters, 'onlyShootsWithIssues', true) } -export function getFilteredItems (state, context) { +export function getFilteredUids (state, context) { const { projectStore, ticketStore, configStore, } = context - let items = Object.values(state.shoots) - if (onlyAllShootsWithIssues(state, context)) { - if (get(state, 'shootListFilters.progressing', false)) { - const predicate = item => { - return !isStatusProgressing(get(item, 'metadata', {})) - } - items = filter(items, predicate) - } - if (get(state, 'shootListFilters.noOperatorAction', false)) { - const predicate = item => { - const ignoreIssues = isTruthyValue(get(item, ['metadata', 'annotations', 'dashboard.gardener.cloud/ignore-issues'])) - if (ignoreIssues) { - return false - } - const lastErrors = get(item, 'status.lastErrors', []) - const allLastErrorCodes = errorCodesFromArray(lastErrors) - if (isTemporaryError(allLastErrorCodes)) { - return false - } - const conditions = get(item, 'status.conditions', []) - const allConditionCodes = errorCodesFromArray(conditions) - const constraints = get(item, 'status.constraints', []) - const allConstraintCodes = errorCodesFromArray(constraints) + // filter function + const notProgressing = item => { + return !isStatusProgressing(get(item, 'metadata', {})) + } - return !(isUserError(allLastErrorCodes) || isUserError(allConditionCodes) || isUserError(allConstraintCodes)) - } - items = filter(items, predicate) + const noUserError = item => { + const ignoreIssues = isTruthyValue(get(item, ['metadata', 'annotations', 'dashboard.gardener.cloud/ignore-issues'])) + if (ignoreIssues) { + return false } - if (get(state, 'shootListFilters.deactivatedReconciliation', false)) { - const predicate = item => { - return !isReconciliationDeactivated(get(item, 'metadata', {})) - } - items = filter(items, predicate) + const lastErrors = get(item, 'status.lastErrors', []) + const allLastErrorCodes = errorCodesFromArray(lastErrors) + if (isTemporaryError(allLastErrorCodes)) { + return false } - if (get(state, 'shootListFilters.hideTicketsWithLabel', false)) { - const predicate = item => { - const hideClustersWithLabels = get(configStore.ticket, 'hideClustersWithLabels') - if (!hideClustersWithLabels) { - return true - } - const metadata = get(item, 'metadata', {}) - metadata.projectName = projectStore.projectNameByNamespace(metadata) - const ticketsForCluster = ticketStore.issues(metadata) - if (!ticketsForCluster.length) { - return true - } + const conditions = get(item, 'status.conditions', []) + const allConditionCodes = errorCodesFromArray(conditions) - const ticketsWithoutHideLabel = filter(ticketsForCluster, ticket => { - const labelNames = map(get(ticket, 'data.labels'), 'name') - const ticketHasHideLabel = some(hideClustersWithLabels, hideClustersWithLabel => includes(labelNames, hideClustersWithLabel)) - return !ticketHasHideLabel - }) - return ticketsWithoutHideLabel.length > 0 - } - items = filter(items, predicate) - } + const constraints = get(item, 'status.constraints', []) + const allConstraintCodes = errorCodesFromArray(constraints) + + return !(isUserError(allLastErrorCodes) || isUserError(allConditionCodes) || isUserError(allConstraintCodes)) } - return items -} + const reconciliationNotDeactivated = item => { + return !isReconciliationDeactivated(get(item, 'metadata', {})) + } -export function putItem (state, newItem) { - const item = findItem(state)(newItem.metadata) - if (item) { - if (item.metadata.resourceVersion !== newItem.metadata.resourceVersion) { - const key = keyForShoot(item.metadata) - state.shoots[key] = assign(item, newItem) + const hasTicketsWithoutHideLabel = item => { + const hideClustersWithLabels = get(configStore.ticket, 'hideClustersWithLabels') + if (!hideClustersWithLabels) { + return true } - } else { - if (state.focusMode) { - const uid = newItem.metadata.uid - delete state.staleShoots[uid] + const metadata = get(item, 'metadata', {}) + metadata.projectName = projectStore.projectNameByNamespace(metadata) + const ticketsForCluster = ticketStore.issues(metadata) + if (!ticketsForCluster.length) { + return true } - newItem.info = undefined // register property to ensure reactivity - const key = keyForShoot(newItem.metadata) - state.shoots[key] = newItem + + const ticketsWithoutHideLabel = filter(ticketsForCluster, ticket => { + const labelNames = map(get(ticket, 'data.labels'), 'name') + const ticketHasHideLabel = some(hideClustersWithLabels, hideClustersWithLabel => includes(labelNames, hideClustersWithLabel)) + return !ticketHasHideLabel + }) + return ticketsWithoutHideLabel.length > 0 } -} -export function deleteItem (state, deletedItem) { - const item = findItem(state)(deletedItem.metadata) - if (item) { - if (state.focusMode) { - const uid = deletedItem.metadata.uid - state.staleShoots[uid] = item + // list of active filter function + const predicates = [] + if (onlyAllShootsWithIssues(state, context)) { + if (get(state, 'shootListFilters.progressing', false)) { + predicates.push(notProgressing) + } + if (get(state, 'shootListFilters.noOperatorAction', false)) { + predicates.push(noUserError) + } + if (get(state, 'shootListFilters.deactivatedReconciliation', false)) { + predicates.push(reconciliationNotDeactivated) + } + if (get(state, 'shootListFilters.hideTicketsWithLabel', false)) { + predicates.push(hasTicketsWithoutHideLabel) } - const key = keyForShoot(item.metadata) - delete state.shoots[key] } + + return Object.values(state.shoots) + .filter(item => { + for (const predicate of predicates) { + if (!predicate(item)) { + return false + } + } + return true + }) + .map(item => item.metadata.uid) } export function getRawVal (context, item, column) { @@ -427,60 +404,77 @@ export function getRawVal (context, item, column) { } } -export function getSortVal (context, item, sortBy) { +export function getSortVal (state, context, item, sortBy) { const { + configStore, projectStore, ticketStore, } = context + const purposeValue = { + infrastructure: 0, + production: 1, + development: 2, + evaluation: 3, + } + const value = getRawVal(context, item, sortBy) const status = item.status switch (sortBy) { case 'purpose': - switch (value) { - case 'infrastructure': - return 0 - case 'production': - return 1 - case 'development': - return 2 - case 'evaluation': - return 3 - default: - return 4 - } + return purposeValue[value] ?? 4 + case 'k8sVersion': + return (value || '0.0.0').split('.').map(i => padStart(i, 4, '0')).join('.') case 'lastOperation': { const operation = value || {} - const inProgress = operation.progress !== 100 && operation.state !== 'Failed' && !!operation.progress - const lastErrors = get(item, 'status.lastErrors', []) + const lastErrors = item.status?.lastErrors ?? [] const isError = operation.state === 'Failed' || lastErrors.length - const allErrorCodes = errorCodesFromArray(lastErrors) - const userError = isUserError(allErrorCodes) - const ignoredFromReconciliation = isReconciliationDeactivated(get(item, 'metadata', {})) + const ignoredFromReconciliation = isReconciliationDeactivated(item.metadata ?? {}) if (ignoredFromReconciliation) { - if (isError) { - return 400 - } else { - return 450 - } - } else if (userError && !inProgress) { - return 200 - } else if (userError && inProgress) { - const progress = padStart(operation.progress, 2, '0') - return `3${progress}` - } else if (isError && !inProgress) { - return 0 - } else if (isError && inProgress) { - const progress = padStart(operation.progress, 2, '0') - return `1${progress}` - } else if (inProgress) { - const progress = padStart(operation.progress, 2, '0') - return `6${progress}` - } else if (isShootStatusHibernated(status)) { - return 500 + return isError + ? 400 + : 450 + } + + const userError = isUserError(errorCodesFromArray(lastErrors)) + const inProgress = operation.progress !== 100 && operation.state !== 'Failed' && !!operation.progress + + if (userError) { + return inProgress + ? '3' + padStart(operation.progress, 2, '0') + : 200 } - return 700 + if (isError) { + return inProgress + ? '1' + padStart(operation.progress, 2, '0') + : 0 + } + return inProgress + ? '6' + padStart(operation.progress, 2, '0') + : isShootStatusHibernated(status) + ? 500 + : 700 + } + case 'readiness': { + const conditions = item.status?.conditions ?? [] + if (!conditions.length) { + // items without conditions have medium priority + const priority = '00000100' + const lastTransitionTime = item.status?.lastOperation.lastUpdateTime ?? item.metadata.creationTimestamp + return `${priority}-${lastTransitionTime}` + } + const hideProgressingClusters = get(state.shootListFilters, 'progressing', false) + const iteratee = ({ type, status = 'True', lastTransitionTime = '1970-01-01T00:00:00Z' }) => { + const isError = status !== 'True' && !(hideProgressingClusters && status === 'Progressing') + // items without any error have lowest priority + const priority = !isError + ? '99999999' + : padStart(configStore.conditionForType(type).sortOrder, 8, '0') + return `${priority}-${lastTransitionTime}` + } + // the condition with the lowest priority and transitionTime is used + return head(conditions.map(iteratee).sort()) } case 'ticket': { const metadata = item.metadata @@ -499,11 +493,27 @@ export function searchItemsFn (state, context) { projectStore, } = context + const searchableCustomFields = computed(() => { + return filter(projectStore.shootCustomFieldList, ['searchable', true]) + }) + let searchQuery - let lastSearchString + let searchQueryTerms = [] + let lastSearch return (search, item) => { - const searchableCustomFields = filter(projectStore.shootCustomFieldList, ['searchable', true]) + if (search !== lastSearch) { + lastSearch = search + searchQuery = parseSearch(search) + searchQueryTerms = map(searchQuery.terms, 'value') + } + + if (searchQueryTerms.includes('workerless')) { + if (getRawVal(context, item, 'workers') === 0) { + return true + } + } + const values = [ getRawVal(context, item, 'name'), getRawVal(context, item, 'infrastructure'), @@ -515,93 +525,59 @@ export function searchItemsFn (state, context) { getRawVal(context, item, 'ticketLabels'), getRawVal(context, item, 'errorCodes'), getRawVal(context, item, 'controlPlaneHighAvailability'), - ...map(searchableCustomFields, ({ key }) => getRawVal(context, item, key)), + ...map(searchableCustomFields.value, ({ key }) => getRawVal(context, item, key)), ] - if (search !== lastSearchString) { - lastSearchString = search - searchQuery = parseSearch(search) - } - - if (map(searchQuery.terms, 'value').includes('workerless')) { - if (getRawVal(context, item, 'workers') === 0) { - return true - } - } - return searchQuery.matches(values) } } export function sortItemsFn (state, context) { - const { - configStore, - } = context - - return (items, sortByArr) => { + return (items, sortByItems) => { if (state.focusMode) { // no need to sort in focus mode sorting is freezed and filteredItems return items in last sorted order return items } - const sortByObj = head(sortByArr) - if (!sortByObj || !sortByObj.key) { + const { key, order = 'asc' } = head(sortByItems) ?? {} + if (!key) { return items } - const sortBy = sortByObj.key - - const sortOrder = sortByObj.order - const sortbyNameAsc = (a, b) => { - if (getRawVal(context, a, 'name') > getRawVal(context, b, 'name')) { - return 1 - } else if (getRawVal(context, a, 'name') < getRawVal(context, b, 'name')) { - return -1 - } - return 0 - } - const inverse = sortOrder === 'desc' ? -1 : 1 - switch (sortBy) { - case 'k8sVersion': { - items.sort((a, b) => { - const versionA = getRawVal(context, a, sortBy) - const versionB = getRawVal(context, b, sortBy) - - if (semver.gt(versionA, versionB)) { - return 1 * inverse - } else if (semver.lt(versionA, versionB)) { - return -1 * inverse - } else { - return sortbyNameAsc(a, b) - } - }) - return items - } - case 'readiness': { - const hideProgressingClusters = get(state.shootListFilters, 'progressing', false) - return orderBy(items, item => { - const errorGroups = map(item.status?.conditions, itemCondition => { - const isErrorCondition = (itemCondition?.status !== 'True' && - (!hideProgressingClusters || itemCondition?.status !== 'Progressing')) - if (!isErrorCondition) { - return { - sortOrder: `${Number.MAX_SAFE_INTEGER}`, - lastTransitionTime: itemCondition.lastTransitionTime, - } - } - const type = itemCondition.type - const condition = configStore.conditionForType(type) - return { - sortOrder: condition.sortOrder, - lastTransitionTime: itemCondition.lastTransitionTime, - } - }) - const { sortOrder, lastTransitionTime } = head(orderBy(errorGroups, ['sortOrder'])) - return [sortOrder, lastTransitionTime, 'metadata.name'] - }, - [sortOrder, sortOrder, 'asc']) - } - default: { - return orderBy(items, [item => getSortVal(context, item, sortBy), 'metadata.name'], [sortOrder, 'asc']) - } + + const iteratee = item => getSortVal(state, context, item, key) + return orderBy(items, [iteratee, 'metadata.name'], [order, 'asc']) + } +} + +export function shootHasIssue (object) { + return get(object, ['metadata', 'labels', 'shoot.gardener.cloud/status'], 'healthy') !== 'healthy' +} + +// Updates subscription state, ensuring consistency with transition states. +export function setSubscriptionState (state, value) { + if ([constants.LOADED, constants.OPEN, constants.CLOSED].includes(value) && value !== state.subscriptionState + 1) { + const logger = useLogger() + logger.error('Unexpected subscription state change: %d --> %d', state.subscriptionState, value) + return + } + state.subscriptionState = value + state.subscriptionError = null +} + +export function setSubscriptionError (state, err) { + if (err) { + const name = err.name + const statusCode = get(err, 'response.status', 500) + const message = get(err, 'response.data.message', err.message) + const reason = get(err, 'response.data.reason', 'InternalError') + const code = get(err, 'response.data.code', 500) + state.subscriptionError = { + name, + statusCode, + message, + code, + reason, } + } else { + state.subscriptionError = null } } diff --git a/frontend/src/store/shoot/index.js b/frontend/src/store/shoot/index.js index 5d5e96aac7..6b51897e62 100644 --- a/frontend/src/store/shoot/index.js +++ b/frontend/src/store/shoot/index.js @@ -12,13 +12,15 @@ import { computed, reactive, watch, + markRaw, } from 'vue' +import { useDocumentVisibility } from '@vueuse/core' import { useLogger } from '@/composables/useLogger' import { useApi } from '@/composables/useApi' -import { shootHasIssue } from '@/utils' import { isNotFound } from '@/utils/error' +import { isTooManyRequestsError } from '@/utils/errors' import { useAppStore } from '../app' import { useAuthnStore } from '../authn' @@ -35,16 +37,15 @@ import { useShootStagingStore } from '../shootStaging' import { uriPattern, - keyForShoot, - findItem, createShootResource, constants, onlyAllShootsWithIssues, - getFilteredItems, - putItem, - deleteItem, + getFilteredUids, searchItemsFn, sortItemsFn, + shootHasIssue, + setSubscriptionState, + setSubscriptionError, } from './helper' import { @@ -54,13 +55,15 @@ import { pick, replace, difference, - differenceWith, find, + includes, + throttle, } from '@/lodash' export const useShootStore = defineStore('shoot', () => { const api = useApi() const logger = useLogger() + const visibility = useDocumentVisibility() const appStore = useAppStore() const authnStore = useAuthnStore() @@ -91,31 +94,33 @@ export const useShootStore = defineStore('shoot', () => { const state = reactive({ shoots: {}, + shootInfos: {}, staleShoots: {}, // shoots will be moved here when they are removed in case focus mode is active - sortedUidsAtFreeze: [], - filteredShoots: [], selection: undefined, shootListFilters: undefined, newShootResource: undefined, initialNewShootResource: undefined, focusMode: false, + froozenUids: [], subscription: null, subscriptionState: constants.CLOSED, subscriptionError: null, + subscriptionEventHandler: undefined, sortBy: undefined, }) + const shootEvents = new Map() // state const staleShoots = computed(() => { return state.staleShoots }) - const sortedUidsAtFreeze = computed(() => { - return state.sortedUidsAtFreeze + const activeShoots = computed(() => { + return activeUids.value.map(uid => state.shoots[uid]) }) - const filteredShoots = computed(() => { - return state.filteredShoots + const activeUids = computed(() => { + return getFilteredUids(state, context) }) const shootListFilters = computed(() => { @@ -150,32 +155,20 @@ export const useShootStore = defineStore('shoot', () => { const shootList = computed(() => { if (state.focusMode) { // When state is freezed, do not include new items - return map(state.sortedUidsAtFreeze, freezedUID => { - const activeItem = find(state.filteredShoots, ['metadata.uid', freezedUID]) - if (activeItem) { - return activeItem - } - let staleItem = state.staleShoots[freezedUID] - if (!staleItem) { - // Object may have been filtered (e.g. now progressing) but is still in shoots. Also show as stale in this case - staleItem = find(Object.values(state.shoots), ['metadata.uid', freezedUID]) - if (!staleItem) { - // This should never happen ... - logger.error('Could not find freezed shoot with uid %s in shoots or staleShoots', freezedUID) - } - } - return { - ...staleItem, - stale: true, - } + return state.froozenUids.map(uid => { + const object = state.shoots[uid] ?? state.staleShoots[uid] + return assignShootInfo(object) }) } - return state.filteredShoots + return activeUids.value.map(uid => { + const object = state.shoots[uid] + return assignShootInfo(object) + }) }) const selectedShoot = computed(() => { - return state.selection - ? shootByNamespaceAndName(state.selection) + return state.selectedUid + ? assignShootInfo(state.shoots[state.selectedUid]) : null }) @@ -184,7 +177,7 @@ export const useShootStore = defineStore('shoot', () => { }) const loading = computed(() => { - return state.subscriptionState > constants.DEFINED && state.subscriptionState < constants.OPEN + return state.subscriptionState === constants.LOADING }) const subscribed = computed(() => { @@ -220,61 +213,67 @@ export const useShootStore = defineStore('shoot', () => { if (!state.focusMode) { return 0 } - return differenceWith(state.filteredShoots, state.sortedUidsAtFreeze, (filteredShoot, uid) => { - return filteredShoot.metadata.uid === uid - }).length + return difference(activeUids.value, state.froozenUids).length }) // actions function clearAll () { - clear() + const shootStore = this + shootStore.$patch(({ state }) => { + state.shoots = {} + state.shootInfos = {} + state.staleShoots = {} + }) + shootEvents.clear() ticketStore.clearIssues() ticketStore.clearComments() } - function subscribe (metadata = {}) { + async function subscribe (metadata = {}) { + const shootStore = this const { namespace = authzStore.namespace, name, } = metadata - setSubscription({ namespace, name }) - return this.synchronize() + if (state.subscription) { + await shootStore.unsubscribe() + } + shootStore.$patch(({ state }) => { + state.subscription = { namespace, name } + setSubscriptionState(state, constants.DEFINED) + }) + await shootStore.synchronize() } function subscribeShoots (metadata) { - (async () => { + (async shootStore => { try { - await this.subscribe(metadata) + await shootStore.subscribe(metadata) } catch (err) { appStore.setError(err) } - })() + })(this) } - function unsubscribe () { - closeSubscription() - clearAll() + async function unsubscribe () { + const shootStore = this + await shootStore.closeSubscription() + shootStore.clearAll() } function unsubscribeShoots () { - try { - unsubscribe() - } catch (err) { - appStore.setError(err) - } - } - - async function assignInfo (metadata) { - if (metadata) { + (async shootStore => { try { - await fetchInfo(metadata) + await shootStore.unsubscribe() } catch (err) { - logger.error('Failed to fetch shoot info:', err.message) + appStore.setError(err) } - } + })(this) } function synchronize () { + const shootStore = this + const fetchShoot = async options => { const [ { data: shoot }, @@ -284,7 +283,7 @@ export const useShootStore = defineStore('shoot', () => { api.getIssuesAndComments(options), ]) // fetch shootInfo in the background (do not await the promise) - assignInfo(shoot?.metadata) + shootStore.fetchInfo(shoot.metadata) logger.debug('Fetched shoot and tickets for %s in namespace %s', options.name, options.namespace) return { shoots: [shoot], issues, comments } } @@ -302,24 +301,50 @@ export const useShootStore = defineStore('shoot', () => { return { shoots: items, issues, comments: [] } } + const getThrottleDelay = (options, n) => { + if (options.name) { + return 0 + } + const p = n > 0 + ? Math.pow(10, Math.floor(Math.log10(n))) + : 1 + const m = configStore.throttleDelayPerCluster + const d = m * p * Math.round(n / p) + return Math.min(30_000, Math.max(200, d)) + } + // await and handle response data in the background const fetchData = async options => { + let throttleDelay try { - setSubscriptionState(constants.LOADING) + setSubscriptionState(state, constants.LOADING) const promise = options.name ? fetchShoot(options) - : fetchShoots(options) + : fetchShoots({ + useCache: localStorageStore.shootListFetchFromCache, + ...options, + }) const { shoots, issues, comments } = await promise - receive(shoots) + shootStore.receive(shoots) ticketStore.receiveIssues(issues) ticketStore.receiveComments(comments) - openSubscription(options) + setSubscriptionState(state, constants.LOADED) + throttleDelay = getThrottleDelay(options, shoots.length) } catch (err) { - const message = get(err, 'response.data.message', err.message) - logger.error('Failed to fetch shoots or tickets: %s', message) - setSubscriptionError(err) - clearAll() + shootStore.clearAll() + if (isNotFound(err) && options.name) { + setSubscriptionState(state, constants.LOADED) + throttleDelay = getThrottleDelay(options, 1) + } else { + const message = get(err, 'response.data.message', err.message) + logger.error('Failed to fetch shoots or tickets: %s', message) + setSubscriptionError(state, err) + } throw err + } finally { + if (state.subscriptionState === constants.LOADED) { + await shootStore.openSubscription(options, throttleDelay) + } } } @@ -342,9 +367,12 @@ export const useShootStore = defineStore('shoot', () => { return response } - async function fetchInfo ({ name, namespace }) { + async function fetchInfo (metadata) { + if (!metadata) { + return + } try { - const { data: info } = await api.getShootInfo({ namespace, name }) + const { data: info } = await api.getShootInfo(metadata) if (info.serverUrl) { const [, scheme, host] = uriPattern.exec(info.serverUrl) const authority = `//${replace(host, /^\/\//, '')}` @@ -352,7 +380,6 @@ export const useShootStore = defineStore('shoot', () => { info.dashboardUrl = [scheme, authority, pathname].join('') info.dashboardUrlText = [scheme, host].join('') } - if (info.seedShootIngressDomain) { const baseHost = info.seedShootIngressDomain info.plutonoUrl = `https://gu-${baseHost}` @@ -361,27 +388,35 @@ export const useShootStore = defineStore('shoot', () => { info.alertmanagerUrl = `https://au-${baseHost}` } - receiveInfo({ name, namespace, info }) - } catch (error) { - // shoot info not found -> ignore if KubernetesError - if (isNotFound(error)) { + state.shootInfos[metadata.uid] = markRaw(info) + } catch (err) { + // ignore shoot info not found + if (isNotFound(err)) { return } - throw error + logger.error('Failed to fetch shoot info:', err.message) + } + } + + function assignShootInfo (object) { + const uid = object?.metadata.uid + const info = state.shootInfos[uid] + return { + ...object, + info, } } function setSelection (metadata) { + const shootStore = this if (!metadata) { - state.selection = null - return - } - const item = findItem(state)(metadata) - if (item) { - const { namespace, name } = metadata - state.selection = { namespace, name } - if (!item.info) { - assignInfo(metadata) + state.selectedUid = null + } else { + const uid = metadata.uid + state.selectedUid = uid + const shootInfo = state.shootInfos[uid] + if (!shootInfo) { + shootStore.fetchInfo(metadata) } } } @@ -396,13 +431,11 @@ export const useShootStore = defineStore('shoot', () => { hideTicketsWithLabel: isAdmin, ...localStorageStore.allProjectsShootFilter, } - updateFilteredShoots() } function toogleShootListFilter (key) { if (state.shootListFilters) { state.shootListFilters[key] = !state.shootListFilters[key] - updateFilteredShoots() } } @@ -418,19 +451,12 @@ export const useShootStore = defineStore('shoot', () => { deep: true, }) - function updateFilteredShoots () { - try { - state.filteredShoots = getFilteredItems(state, context) - } catch (err) { - appStore.setError(err) - } - } - function setNewShootResource (value) { state.newShootResource = value } function resetNewShootResource () { + const shootStore = this const value = createShootResource({ logger, appStore, @@ -440,161 +466,207 @@ export const useShootStore = defineStore('shoot', () => { cloudProfileStore, gardenerExtensionStore, }) - - state.newShootResource = value - state.initialNewShootResource = cloneDeep(value) + shootStore.$patch(({ state }) => { + state.newShootResource = value + state.initialNewShootResource = cloneDeep(value) + }) shootStagingStore.workerless = false } function setFocusMode (value) { - let sortedUids + const shootStore = this + let uids = [] if (value) { - const sortedShoots = sortItems([...state.filteredShoots], state.sortBy) - sortedUids = map(sortedShoots, 'metadata.uid') + const activeShoots = map(activeUids.value, uid => state.shoots[uid]) + const sortedShoots = sortItems(activeShoots, state.sortBy) + uids = map(sortedShoots, 'metadata.uid') } - state.focusMode = value - state.sortedUidsAtFreeze = sortedUids + shootStore.$patch(({ state }) => { + state.focusMode = value + state.froozenUids = uids + }) } - const shootByNamespaceAndName = findItem(state) const searchItems = searchItemsFn(state, context) const sortItems = sortItemsFn(state, context) - function setSortBy (value) { - state.sortBy = value - } - - function setSubscription (value) { - state.subscription = value - state.subscriptionState = constants.DEFINED - state.subscriptionError = null - } - - function setSubscriptionState (value) { - if (Object.values(constants).includes(value)) { - state.subscriptionState = value - } else if (Object.keys(constants).includes(value)) { - state.subscriptionState = constants[value] + function shootByNamespaceAndName ({ namespace, name } = {}) { + if (!(namespace && name)) { + return + } + const object = find(Object.values(state.shoots), { metadata: { namespace, name } }) + if (!object) { + return } + return assignShootInfo(object) } - function setSubscriptionError (err) { - if (err) { - const name = err.name - const statusCode = get(err, 'response.status', 500) - const message = get(err, 'response.data.message', err.message) - const reason = get(err, 'response.data.reason') - const code = get(err, 'response.data.code', 500) - state.subscriptionError = { - name, - statusCode, - message, - code, - reason, - } - } else { - state.subscriptionError = null - } + function setSortBy (value) { + state.sortBy = value } - // mutations function receive (items) { + const shootStore = this const notOnlyShootsWithIssues = !onlyAllShootsWithIssues(state, context) const shoots = {} - for (const object of items) { - if (notOnlyShootsWithIssues || shootHasIssue(object)) { - const key = keyForShoot(object.metadata) - shoots[key] = object + for (const item of items) { + if (notOnlyShootsWithIssues || shootHasIssue(item)) { + const uid = item.metadata.uid + shoots[uid] = markRaw(item) } } - if (state.focusMode) { - const oldKeys = Object.keys(state.shoots) - const newKeys = Object.keys(shoots) - const removedShootKeys = difference(oldKeys, newKeys) - const addedShootKeys = difference(newKeys, oldKeys) - - removedShootKeys.forEach(removedShootKey => { - const removedShoot = state.shoots[removedShootKey] - if (state.sortedUidsAtFreeze.includes(removedShoot.metadata.uid)) { - const uid = removedShoot.metadata.uid - state.staleShoots[uid] = { - ...removedShoot, - stale: true, + shootStore.$patch(({ state }) => { + if (state.focusMode) { + const oldUids = Object.keys(state.shoots) + const newUids = Object.keys(shoots) + for (const uid of difference(oldUids, newUids)) { + if (includes(state.froozenUids, uid)) { + state.staleShoots[uid] = state.shoots[uid] } } - }) + for (const uid of difference(newUids, oldUids)) { + delete state.staleShoots[uid] + } + } + state.shoots = shoots + }) + } - addedShootKeys.forEach(addedShootKey => { - const addedShoot = shoots[addedShootKey] - const uid = addedShoot.metadata.uid - delete state.staleShoots[uid] - }) + function cancelSubscriptionEventHandler (state) { + if (typeof state.subscriptionEventHandler?.cancel === 'function') { + state.subscriptionEventHandler.cancel() } - - state.shoots = shoots - updateFilteredShoots() } - function receiveInfo ({ namespace, name, info }) { - const item = findItem(state)({ namespace, name }) - if (item !== undefined) { - item.info = info + function setSubscriptionEventHandler (state, func, throttleDelay) { + if (throttleDelay > 0) { + func = throttle(func, throttleDelay) } + state.subscriptionEventHandler = markRaw(func) } - function clear () { - state.shoots = {} - state.staleShoots = {} + function unsetSubscriptionEventHandler (state) { + state.subscriptionEventHandler = undefined } - function clearStaleShoots () { - state.staleShoots = {} + async function openSubscription (value, throttleDelay) { + const shootStore = this + shootStore.$patch(({ state }) => { + setSubscriptionState(state, constants.OPENING) + cancelSubscriptionEventHandler(state) + shootEvents.clear() + setSubscriptionEventHandler(state, handleEvents, throttleDelay) + }) + try { + await socketStore.emitSubscribe(value) + setSubscriptionState(state, constants.OPEN) + } catch (err) { + logger.error('Failed to open subscription: %s', err.message) + setSubscriptionError(state, err) + } } - function openSubscription (options) { - state.subscriptionState = constants.OPENING - state.subscriptionError = null - socketStore.emitSubscribe(options) + async function closeSubscription () { + const shootStore = this + shootStore.$patch(({ state }) => { + state.subscription = null + setSubscriptionState(state, constants.CLOSING) + cancelSubscriptionEventHandler(state) + shootEvents.clear() + unsetSubscriptionEventHandler(state) + }) + try { + await socketStore.emitUnsubscribe() + setSubscriptionState(state, constants.CLOSED) + } catch (err) { + logger.error('Failed to close subscription: %s', err.message) + setSubscriptionError(state, err) + } } - function closeSubscription () { - state.subscriptionState = constants.CLOSING - state.subscriptionError = null - state.subscription = null - socketStore.emitUnsubscribe() + async function handleEvents (shootStore) { + const events = Array.from(shootEvents.values()) + shootEvents.clear() + const uids = [] + const deletedUids = [] + for (const { type, uid } of events) { + if (type === 'DELETED') { + deletedUids.push(uid) + } else { + uids.push(uid) + } + } + try { + const items = await socketStore.synchronize(uids) + const notOnlyShootsWithIssues = !onlyAllShootsWithIssues(state, context) + shootStore.$patch(({ state }) => { + for (const uid of deletedUids) { + if (state.focusMode) { + state.staleShoots[uid] = state.shoots[uid] + } + delete state.shoots[uid] + } + for (const item of items) { + if (item.kind === 'Status') { + logger.info('Failed to synchronize a single shoot: %s', item.message) + if (item.code === 404) { + const uid = item.details?.uid + if (uid) { + delete state.shoots[uid] + } + } + } else if (notOnlyShootsWithIssues || shootHasIssue(item)) { + const uid = item.metadata.uid + if (state.focusMode) { + delete state.staleShoots[uid] + } + state.shoots[uid] = markRaw(item) + } + } + }) + } catch (err) { + if (isTooManyRequestsError(err)) { + logger.info('Skipped synchronization of modified shoots: %s', err.message) + } else { + logger.error('Failed to synchronize modified shoots: %s', err.message) + } + // Synchronization failed. Rollback shoot events + for (const event of events) { + const { uid } = event + if (!shootEvents.has(uid)) { + shootEvents.set(uid, event) + } + } + } } function handleEvent (event) { - const notOnlyShootsWithIssues = !onlyAllShootsWithIssues(state, context) - let setFilteredItemsRequired = false - switch (event.type) { - case 'ADDED': - case 'MODIFIED': - // Do not add healthy shoots when onlyShootsWithIssues=true, this can happen when toggeling flag - if (notOnlyShootsWithIssues || shootHasIssue(event.object)) { - putItem(state, event.object) - setFilteredItemsRequired = true - } - break - case 'DELETED': - deleteItem(state, event.object) - setFilteredItemsRequired = true - break - default: - logger.error('undhandled event type', event.type) + const shootStore = this + const { type, uid } = event + if (!['ADDED', 'MODIFIED', 'DELETED'].includes(type)) { + logger.error('undhandled event type', type) + return } - if (setFilteredItemsRequired) { - updateFilteredShoots() + shootEvents.set(uid, event) + shootStore.invokeSubscriptionEventHandler() + } + + function isShootActive (uid) { + return includes(activeUids.value, uid) + } + + function invokeSubscriptionEventHandler () { + if (typeof state.subscriptionEventHandler === 'function' && visibility.value === 'visible') { + state.subscriptionEventHandler(this) } } return { // state + state, staleShoots, - sortedUidsAtFreeze, - filteredShoots, newShootResource, initialNewShootResource, shootListFilters, @@ -603,9 +675,9 @@ export const useShootStore = defineStore('shoot', () => { focusMode, sortBy, // getters + activeShoots, shootList, selectedShoot, - selectedItem: selectedShoot, // TODO: deprecated - use selectedShoot onlyShootsWithIssues, loading, subscribed, @@ -613,13 +685,15 @@ export const useShootStore = defineStore('shoot', () => { subscription, numberOfNewItemsSinceFreeze, // actions - clear, - clearStaleShoots, + receive, synchronize, + clearAll, subscribe, subscribeShoots, + openSubscription, unsubscribe, unsubscribeShoots, + closeSubscription, handleEvent, createShoot, deleteShoot, @@ -634,9 +708,8 @@ export const useShootStore = defineStore('shoot', () => { searchItems, sortItems, setSortBy, - setSubscription, - setSubscriptionState, - setSubscriptionError, + isShootActive, + invokeSubscriptionEventHandler, } }) diff --git a/frontend/src/store/socket/helper.js b/frontend/src/store/socket/helper.js index 17ec6a385d..0aedc8ba9c 100644 --- a/frontend/src/store/socket/helper.js +++ b/frontend/src/store/socket/helper.js @@ -15,7 +15,6 @@ export function createSocket (state, context) { const { logger, authnStore, - projectStore, shootStore, ticketStore, } = context @@ -200,10 +199,7 @@ export function createSocket (state, context) { // handle custom events socket.on('shoots', event => { - const namespaces = projectStore.currentNamespaces - if (namespaces.includes(event.object?.metadata.namespace)) { - shootStore.handleEvent(event) - } + shootStore.handleEvent(event) }) socket.on('issues', event => { diff --git a/frontend/src/store/socket/index.js b/frontend/src/store/socket/index.js index b423968666..2714d61616 100644 --- a/frontend/src/store/socket/index.js +++ b/frontend/src/store/socket/index.js @@ -17,14 +17,17 @@ import { import { useLogger } from '@/composables/useLogger' +import { createError } from '@/utils/errors' + import { useAuthnStore } from '../authn' import { useProjectStore } from '../project' import { useShootStore } from '../shoot' -import { constants } from '../shoot/helper' import { useTicketStore } from '../ticket' import { createSocket } from './helper' +const acknowledgementTimeout = 60_000 + export const useSocketStore = defineStore('socket', () => { const logger = useLogger() @@ -47,6 +50,7 @@ export const useSocketStore = defineStore('socket', () => { jitter: 0.5, attempts: 0, }, + synchronizing: false, }) const socket = createSocket(state, { @@ -92,34 +96,57 @@ export const useSocketStore = defineStore('socket', () => { socket.disconnect() } - function emitSubscribe (options) { - if (socket.connected) { - socket.emit('subscribe', 'shoots', options, ({ statusCode, message }) => { - if (statusCode === 200) { - logger.debug('subscribed shoots') - shootStore.setSubscriptionState(constants.OPEN) - } else { - const err = new Error(message) - err.name = 'SubscribeError' - logger.debug('failed to subscribe shoots: %s', err.message) - shootStore.setSubscriptionError(err) - } + async function emitSubscribe (options) { + if (!socket.connected) { + return + } + const { + statusCode = 500, + message = 'Failed to subscribe shoots', + } = await socket.timeout(acknowledgementTimeout).emitWithAck('subscribe', 'shoots', options) + if (statusCode !== 200) { + logger.debug('Subscribe Error: %s', message) + throw createError(statusCode, message, { + name: 'SubscribeError', }) } } - function emitUnsubscribe () { - socket.emit('unsubscribe', 'shoots', ({ statusCode, message }) => { + async function emitUnsubscribe () { + const { + statusCode = 500, + message = 'Failed to unsubscribe shoots', + } = await socket.timeout(acknowledgementTimeout).emitWithAck('unsubscribe', 'shoots') + if (statusCode !== 200) { + logger.debug('Unsubscribe Error: %s', message) + throw createError(statusCode, message, { + name: 'UnsubscribeError', + }) + } + } + + async function synchronize (uids) { + if (!uids.length) { + return [] + } + if (state.synchronizing) { + throw createError(429, 'Synchronization is still in progress', { name: 'TooManyRequests' }) + } + state.synchronizing = true + try { + const { + statusCode = 500, + name = 'InternalError', + message = 'Failed to synchronize shoots', + items = [], + } = await socket.timeout(acknowledgementTimeout).emitWithAck('synchronize', 'shoots', uids) if (statusCode === 200) { - logger.debug('unsubscribed shoots') - shootStore.setSubscriptionState(constants.CLOSED) - } else { - const err = new Error(message) - err.name = 'UnsubscribeError' - logger.debug('failed to unsubscribe shoots: %s', err.message) - shootStore.setSubscriptionError(err) + return items } - }) + throw createError(statusCode, message, { name }) + } finally { + state.synchronizing = false + } } watch(() => authnStore.user, value => { @@ -142,6 +169,7 @@ export const useSocketStore = defineStore('socket', () => { disconnect, emitSubscribe, emitUnsubscribe, + synchronize, } }) diff --git a/frontend/src/store/ticket.js b/frontend/src/store/ticket.js index 6b6b03245e..eaeff1c4d6 100644 --- a/frontend/src/store/ticket.js +++ b/frontend/src/store/ticket.js @@ -17,29 +17,18 @@ import { useLogger } from '@/composables/useLogger' import { assign, - filter, findIndex, get, head, - flatMap, matches, matchesProperty, groupBy, orderBy, uniqBy, + flatMap, + mapValues, } from '@/lodash' -const eql = ({ projectName, name, state = undefined }) => { - const source = { metadata: { projectName } } - if (name) { - source.metadata.name = name - } - if (state) { - source.metadata.state = state - } - return matches(source) -} - const eqIssue = issue => { return matches({ metadata: { number: issue.metadata.number } }) } @@ -99,6 +88,10 @@ const putToList = (list, newItem, updatedAtKeyPath, matcher, descending = true) } } +function issueKey (projectName, name) { + return projectName + '/' + name +} + export const useTicketStore = defineStore('ticket', () => { const logger = useLogger() @@ -109,12 +102,17 @@ export const useTicketStore = defineStore('ticket', () => { return issueList.value }) + const issuesMap = computed(() => { + return groupBy(issueList.value, item => issueKey(item.metadata.projectName, item.metadata.name)) + }) + + const labelsMap = computed(() => { + return mapValues(issuesMap.value, items => uniqBy(flatMap(items, 'data.labels'), 'id')) + }) + function issues ({ name, projectName }) { - return filter(issueList.value, eql({ - name, - projectName, - state: 'open', - })) + const key = issueKey(projectName, name) + return issuesMap.value[key] ?? [] } function comments ({ issueNumber }) { @@ -126,7 +124,8 @@ export const useTicketStore = defineStore('ticket', () => { } function labels ({ name, projectName }) { - return uniqBy(flatMap(issues({ name, projectName }), 'data.labels'), 'id') + const key = issueKey(projectName, name) + return labelsMap.value[key] ?? [] } function receiveIssues (issues) { diff --git a/frontend/src/utils/errors.js b/frontend/src/utils/errors.js index efa74f67df..da2739595c 100644 --- a/frontend/src/utils/errors.js +++ b/frontend/src/utils/errors.js @@ -76,6 +76,10 @@ export function isGatewayTimeoutError (err) { return hasStatusCode(err, 504) } +export function isTooManyRequestsError (err) { + return hasStatusCode(err, 429) +} + export function isNoUserError (err) { return err.name === 'NoUserError' } diff --git a/frontend/src/utils/index.js b/frontend/src/utils/index.js index 67a04ff300..f07e6267af 100644 --- a/frontend/src/utils/index.js +++ b/frontend/src/utils/index.js @@ -356,10 +356,6 @@ export function isShootStatusHibernated (status) { return get(status, 'hibernated', false) } -export function shootHasIssue (shoot) { - return get(shoot, ['metadata', 'labels', 'shoot.gardener.cloud/status'], 'healthy') !== 'healthy' -} - export function isReconciliationDeactivated (metadata) { const ignoreDeprecated = get(metadata, ['annotations', 'shoot.garden.sapcloud.io/ignore']) const ignore = get(metadata, ['annotations', 'shoot.gardener.cloud/ignore'], ignoreDeprecated) diff --git a/frontend/src/views/GSettings.vue b/frontend/src/views/GSettings.vue index cc15ccb3d5..8812c42ac6 100644 --- a/frontend/src/views/GSettings.vue +++ b/frontend/src/views/GSettings.vue @@ -139,16 +139,19 @@ SPDX-License-Identifier: Apache-2.0 label="Operator Features" color="primary" density="compact" - hide-details - /> -
- Enable operator features for project cluster lists
- You can set the focus mode for cluster lists. This mode will freeze the current - list and allows to get an overview of clusters with issues by sorting the list by - the ISSUE SINCE column. -
+ + @@ -180,3 +183,9 @@ const { operatorFeatures, } = storeToRefs(localStorageStore) + + diff --git a/frontend/src/views/GShootItemPlaceholder.vue b/frontend/src/views/GShootItemPlaceholder.vue index 512f6376e3..523529a6aa 100644 --- a/frontend/src/views/GShootItemPlaceholder.vue +++ b/frontend/src/views/GShootItemPlaceholder.vue @@ -49,13 +49,12 @@ export default { } }, beforeRouteLeave (to, from) { - this.unsubscribe() + this.readyState = 'initial' }, data () { return { error: null, readyState: 'initial', - unsubscribeShootStore: () => {}, } }, computed: { @@ -70,6 +69,9 @@ export default { ...mapState(useAuthnStore, [ 'isAdmin', ]), + shootItem () { + return this.shootByNamespaceAndName(this.$route.params) + }, component () { if (this.error) { return 'g-shoot-item-error' @@ -109,38 +111,37 @@ export default { }, }, watch: { - '$route' () { - this.readyState = 'loaded' + '$route' (value) { + if (value) { + this.readyState = 'loaded' + } + }, + shootItem (value) { + if (this.readyState === 'loaded') { + if (!value) { + this.error = Object.assign(new Error('The cluster you are looking for is no longer available'), { + code: 410, + reason: 'Cluster is gone', + }) + } else if ([404, 410].includes(this.error?.code)) { + this.error = null + } + } }, }, beforeMount () { this.readyState = 'initial' }, async mounted () { - const shootStore = useShootStore() - this.unsubscribeShootStore = shootStore.$onAction(({ - name, - args, - after, - }) => { - switch (name) { - case 'handleEvent': { - after(() => this.handleShootEvent(...args)) - break - } - } - }) await this.load(this.$route) this.readyState = 'loaded' }, beforeUnmount () { this.readyState = 'initial' - this.unsubscribeShootStore() }, methods: { ...mapActions(useShootStore, [ 'subscribe', - 'unsubscribe', 'shootByNamespaceAndName', ]), ...mapActions(useSecretStore, [ @@ -149,21 +150,6 @@ export default { ...mapActions(useTerminalStore, [ 'ensureProjectTerminalShortcutsLoaded', ]), - handleShootEvent ({ type, object }) { - const metadata = object.metadata - const routeParams = this.$route.params ?? {} - if (metadata.namespace !== routeParams.namespace || metadata.name !== routeParams.name) { - return - } - if (type === 'DELETED') { - this.error = Object.assign(new Error('The cluster you are looking for is no longer available'), { - code: 410, - reason: 'Cluster is gone', - }) - } else if (type === 'ADDED' && [404, 410].includes(this.error?.code)) { - this.error = null - } - }, async load (route) { this.error = null this.readyState = 'loading' diff --git a/frontend/src/views/GShootList.vue b/frontend/src/views/GShootList.vue index edcc43c206..c7a07a81c5 100644 --- a/frontend/src/views/GShootList.vue +++ b/frontend/src/views/GShootList.vue @@ -163,16 +163,21 @@ SPDX-License-Identifier: Apache-2.0 hover :loading="loading || !connected" :items-per-page-options="itemsPerPageOptions" - :custom-key-sort="disableCustomKeySort(visibleHeaders)" + :custom-key-sort="customKeySort" must-sort class="g-table" > + +