diff --git a/packages/kubernetes-api/src/client/namespace-client.ts b/packages/kubernetes-api/src/client/namespace-client.ts index 7902c2dd..1fa9ae86 100644 --- a/packages/kubernetes-api/src/client/namespace-client.ts +++ b/packages/kubernetes-api/src/client/namespace-client.ts @@ -1,11 +1,13 @@ import jsonpath from 'jsonpath' -import { JOLOKIA_PORT_QUERY, KubeObject, KubePod, Paging } from '../globals' +import { JOLOKIA_PORT_QUERY, KubeObject, KubePod, Paging, log } from '../globals' +import { TypeFilter } from '../filter' import { WatchTypes } from '../model' import { isObject } from '../utils' +import { SortOrder } from '../sort' import { Watched, KOptions, ProcessDataCallback } from './globals' import { clientFactory } from './client-factory' -export type NamespaceClientCallback = (jolokiaPods: KubePod[], jolokiaTotal: number, error?: Error) => void +export type NamespaceClientCallback = (jolokiaPods: KubePod[], fullPodCount: number, error?: Error) => void export interface Client { watched: Watched @@ -23,11 +25,17 @@ interface PodWatchers { export class NamespaceClient implements Paging { private _current = 0 + private _podList: Set = new Set() + private _filteredList: string[] = [] + private _notifyChange = false + private _podWatchers: PodWatchers = {} private _nsWatcher?: Client private _refreshing = 0 private _limit = 3 + private _sortOrder?: SortOrder + private _typeFilter?: TypeFilter constructor( private _namespace: string, @@ -44,6 +52,12 @@ export class NamespaceClient implements Paging { this._callback(this.getJolokiaPods(), this._podList.size, cbError) } + private isFiltered(): boolean { + if (!this._typeFilter) return false + + return !this._typeFilter.filterNS(this._namespace) + } + private initPodOptions(kind: string, name?: string): KOptions { const podOptions: KOptions = { kind: kind, @@ -58,24 +72,74 @@ export class NamespaceClient implements Paging { } private createPodWatchers() { - if (this._podList.size === 0 || this._current >= this._podList.size) return + log.debug(`[NamespaceClient ${this._namespace}]: creating pod watchers`) + + if (this._podList.size === 0 || this._current >= this._podList.size) { + log.debug(`[NamespaceClient ${this._namespace}]: no pods in namespace`) + this._callback(this.getJolokiaPods(), 0) + return + } + + const sortFn = (a: string, b: string) => { + const order = this._sortOrder === SortOrder.DESC ? -1 : 1 + return a.localeCompare(b) * order + } + + /* + * Sort the pods according to the supplied sort order + * then selects the block of pods determined by current and limit + */ + log.debug(`[NamespaceClient ${this._namespace}]: sort order: `, this._sortOrder, ' filter: ', this._typeFilter) + + let pagedPods: string[] + if (this.isFiltered()) { + // Filtered out by namespace + this._filteredList = [] + pagedPods = [] + } else { + /* + * Filter out pods based on pod name and then sort + */ + log.debug(`[NamespaceClient ${this._namespace}]: has pods`, this._podList) + + this._filteredList = Array.from(this._podList) + .filter(podName => (!this._typeFilter ? true : this._typeFilter.filterPod(podName))) + .sort(sortFn) - const podNames = Array.from(this._podList) - .sort() - .slice(this._current, this._current + this._limit) + log.debug(`[NamespaceClient ${this._namespace}]: pods after filtering`, this._podList) - // Remove watchers for pods not in the slice of the sorted list + if (this._current >= this._filteredList.length) { + // if current is bigger than filtered podNames list then + // reset it back to 0 + this._current = 0 + } + + // Set the paged pods list base on current and limit + pagedPods = this._filteredList.slice(this._current, this._current + this._limit) + } + + log.debug(`[NamespaceClient ${this._namespace}]: pods to be watched`, pagedPods) + + /* + * Remove watchers for pods not in the slice of the filtered/sorted list + */ Object.entries(this._podWatchers) - .filter(([name, _]) => { - return !podNames.includes(name) - }) + .filter(([name, _]) => !pagedPods.includes(name)) .forEach(([name, podWatcher]) => { + log.debug(`[NamespaceClient ${this._namespace}]: deleting pod watcher [${name}]`) + clientFactory.destroy(podWatcher.client.watched, podWatcher.client.watch) delete this._podWatchers[name] + this._notifyChange = true }) - this._refreshing = podNames.length - podNames.forEach(name => { + log.debug(`[NamespaceClient ${this._namespace}]: pod watchers already initialised`, this._podWatchers) + + /* + * Create any new pod watchers + */ + this._refreshing = pagedPods.length + pagedPods.forEach(name => { // Already watching this pod if (isObject(this._podWatchers[name])) { this._refreshing-- @@ -87,17 +151,25 @@ export class NamespaceClient implements Paging { const _podWatcher = _podClient.watch(podList => { if (this._refreshing > 0) this._refreshing-- - if (podList.length === 0) return - - // podList should only contain 1 pod (due to name) - this._podWatchers[name].jolokiaPod = podList[0] + if (podList.length > 0) { + if (this._podWatchers[name]) { + // podList should only contain 1 pod (due to name) + this._podWatchers[name].jolokiaPod = podList[0] + } else { + log.warn( + `[NamespaceClient ${this._namespace}]: pod watcher with name ${name} no longer exists yet still watching`, + ) + } + } - if (this._refreshing === 0) { + if (this._refreshing <= 0) { // Limit callback to final watch returning - this._callback(this.getJolokiaPods(), this._podList.size) + this._callback(this.getJolokiaPods(), this._filteredList.length) } }) + log.debug(`[NamespaceClient ${this._namespace}]: connecting new pod client`) + /* * Pod is part of the current page so connect its pod watcher */ @@ -111,6 +183,18 @@ export class NamespaceClient implements Paging { jolokiaPod: undefined, } }) + + if (pagedPods.length === 0 || this._notifyChange) { + log.debug( + `[NamespaceClient ${this._namespace}]: notifying since page is either empty or watcher configuration has changed`, + ) + this._notifyChange = false + this._callback(this.getJolokiaPods(), this._filteredList.length) + } + } + + get namespace(): string { + return this._namespace } isConnected(): boolean { @@ -123,11 +207,21 @@ export class NamespaceClient implements Paging { this._limit = limit > 1 ? limit : 1 this._current = 0 + if (this.isFiltered()) { + /* + * Do not watch since this namespace has been filtered out + * Return no pods and zero total + */ + this._callback([], 0) + return + } + const _nsClient = clientFactory.create(this.initPodOptions(WatchTypes.PODS)) const _nsWatch = _nsClient.watch(pods => { /* - * Filter out any non-jolokia pods immediately and add - * the applicable pods to the pod name list + * Filter out any non-jolokia pods or + * any pods that do not conform to any pod filters + * immediately and add the applicable pods to the pod name list */ const podNames: string[] = [] pods @@ -187,6 +281,38 @@ export class NamespaceClient implements Paging { delete pr.jolokiaPod } this._podWatchers = {} + this._podList.clear() + } + + filter(typeFilter: TypeFilter) { + this._typeFilter = typeFilter + this._notifyChange = true + + /* + * If already connected then recreate the pod watchers + * according to the new type filter + */ + if (this.isConnected()) { + this.createPodWatchers() + } else if (!this.isFiltered()) { + this.connect(this._limit) + } + + /* + * Not connected and does not conform to the + * namespace part of the type filter + */ + } + + sort(sortOrder: SortOrder) { + this._sortOrder = sortOrder + this._notifyChange = true + + /* + * If already connected then recreate the pod watchers + * according to the new sort order + */ + if (this.isConnected()) this.createPodWatchers() } first() { @@ -239,7 +365,7 @@ export class NamespaceClient implements Paging { last() { let remainder = this._podList.size % this._limit - remainder = (remainder === 0) ? this._limit : remainder + remainder = remainder === 0 ? this._limit : remainder this._current = this._podList.size - remainder @@ -254,16 +380,16 @@ export class NamespaceClient implements Paging { * pageIdx: parameter representing a page index of the form (podList.size / limit) */ page(pageIdx: number) { - this._current = this._limit * (pageIdx - 1) - if (this._current > this._podList.size) { - // Navigate to last page if bigger than podList size - this.last() - return - } else if (this._current < 0) { - // Navigate to first page if index is somehow -ve - this.first() - return - } + this._current = this._limit * (pageIdx - 1) + if (this._current > this._podList.size) { + // Navigate to last page if bigger than podList size + this.last() + return + } else if (this._current < 0) { + // Navigate to first page if index is somehow -ve + this.first() + return + } /* * If already connected then recreate the pod watchers diff --git a/packages/kubernetes-api/src/filter.ts b/packages/kubernetes-api/src/filter.ts new file mode 100644 index 00000000..2898464f --- /dev/null +++ b/packages/kubernetes-api/src/filter.ts @@ -0,0 +1,71 @@ +export enum TypeFilterType { + NAME = 'Name', + NAMESPACE = 'Namespace', +} + +export function typeFilterTypeValueOf(str: string): TypeFilterType | undefined { + switch (str) { + case TypeFilterType.NAME: + return TypeFilterType.NAME + case TypeFilterType.NAMESPACE: + return TypeFilterType.NAMESPACE + default: + return undefined + } +} + +export class TypeFilter { + private _nsValues: Set + private _nameValues: Set + + constructor(nsValues?: string[], nameValues?: string[]) { + this._nsValues = new Set(nsValues ?? []) + this._nameValues = new Set(nameValues ?? []) + } + + get nsValues(): string[] { + return Array.from(this._nsValues) + } + + addNSValue(ns: string) { + this._nsValues.add(ns) + } + + deleteNSValue(ns: string) { + this._nsValues.delete(ns) + } + + get nameValues(): string[] { + return Array.from(this._nameValues) + } + + addNameValue(name: string) { + this._nameValues.add(name) + } + + deleteNameValue(name: string) { + this._nameValues.delete(name) + } + + filterNS(ns: string): boolean { + if (this._nsValues.size === 0) return true + + let resolved = false + this._nsValues.forEach(v => { + if (ns.includes(v)) resolved = true + }) + + return resolved + } + + filterPod(podName: string): boolean { + if (this._nameValues.size === 0) return true + + let resolved = false + this._nameValues.forEach(v => { + if (podName.includes(v)) resolved = true + }) + + return resolved + } +} diff --git a/packages/kubernetes-api/src/globals.ts b/packages/kubernetes-api/src/globals.ts index fe333c81..9d26657e 100644 --- a/packages/kubernetes-api/src/globals.ts +++ b/packages/kubernetes-api/src/globals.ts @@ -43,7 +43,7 @@ export type KubeProject = KubeObject & { } export type KubePodsOrError = { - total: number + fullPodCount: number pods: KubePod[] error?: Error } diff --git a/packages/kubernetes-api/src/index.ts b/packages/kubernetes-api/src/index.ts index a6d90ee4..d580896d 100644 --- a/packages/kubernetes-api/src/index.ts +++ b/packages/kubernetes-api/src/index.ts @@ -15,5 +15,7 @@ export async function isK8ApiRegistered(): Promise { } export * from './globals' +export * from './filter' +export * from './sort' export { k8Api, k8Service } from './init' export * from './utils' diff --git a/packages/kubernetes-api/src/kubernetes-service.ts b/packages/kubernetes-api/src/kubernetes-service.ts index e00023b3..1605a59a 100644 --- a/packages/kubernetes-api/src/kubernetes-service.ts +++ b/packages/kubernetes-api/src/kubernetes-service.ts @@ -13,6 +13,8 @@ import { isError, pathGet } from './utils' import { clientFactory, log, Client, NamespaceClient } from './client' import { K8Actions, KubePod, KubePodsByProject, KubeProject, Paging } from './globals' import { k8Api } from './init' +import { TypeFilter } from './filter' +import { SortOrder } from './sort' export class KubernetesService extends EventEmitter implements Paging { private _loading = 0 @@ -25,6 +27,8 @@ export class KubernetesService extends EventEmitter implements Paging { private namespace_clients: { [namespace: string]: NamespaceClient } = {} private _nsLimit = 3 + private _typeFilter?: TypeFilter + private _sortOrder?: SortOrder async initialize(): Promise { if (this._initialized) return this._initialized @@ -55,11 +59,13 @@ export class KubernetesService extends EventEmitter implements Paging { } private initNamespaceClient(namespace: string) { - const cb = (jolokiaPods: KubePod[], jolokiaTotal: number, error?: Error) => { + const cb = (jolokiaPods: KubePod[], fullPodCount: number, error?: Error) => { + log.debug(`[KubeService ${namespace}]: callback: fullPodCount: ${fullPodCount}`, 'jolokia pods: ', jolokiaPods) + this._loading = this._loading > 0 ? this._loading-- : 0 if (isError(error)) { - this.podsByProject[namespace] = { total: jolokiaTotal, pods: [], error: error } + this.podsByProject[namespace] = { fullPodCount: fullPodCount, pods: [], error: error } this.emit(K8Actions.CHANGED) return } @@ -73,7 +79,11 @@ export class KubernetesService extends EventEmitter implements Paging { projectPods.push(jpod) } - this.podsByProject[namespace] = { total: jolokiaTotal, pods: projectPods } + if (projectPods.length === 0) delete this.podsByProject[namespace] + else { + this.podsByProject[namespace] = { fullPodCount: fullPodCount, pods: projectPods } + } + this.emit(K8Actions.CHANGED) } @@ -285,12 +295,38 @@ export class KubernetesService extends EventEmitter implements Paging { return reason || 'unknown' } + /***************************** + * Filtering and Sort support + *****************************/ + sort(order: SortOrder) { + this._sortOrder = order + + Object.values(this.namespace_clients).forEach(client => { + client.sort(order) + }) + } + + filter(typeFilter: TypeFilter) { + this._typeFilter = new TypeFilter(typeFilter.nsValues, typeFilter.nameValues) + + Object.values(this.namespace_clients).forEach(client => { + client.filter(typeFilter) + }) + } + /******************** + * Paging interface + ********************/ first(namespace?: string) { if (!namespace) return - const namespaceClient = this.namespace_clients[namespace] - if (!namespaceClient) return + const nsClient = this.namespace_clients[namespace] + if (!nsClient) return + + if (!nsClient.isConnected) { + log.warn(`k8 Service cannot page on disconnected namespace ${namespace}`) + return + } this.namespace_clients[namespace].first() } @@ -298,8 +334,10 @@ export class KubernetesService extends EventEmitter implements Paging { hasPrevious(namespace?: string): boolean { if (!namespace) return false - const namespaceClient = this.namespace_clients[namespace] - if (!namespaceClient) return false + const nsClient = this.namespace_clients[namespace] + if (!nsClient) return false + + if (!nsClient.isConnected) return false return this.namespace_clients[namespace].hasPrevious() } @@ -307,8 +345,13 @@ export class KubernetesService extends EventEmitter implements Paging { previous(namespace?: string) { if (!namespace) return - const namespaceClient = this.namespace_clients[namespace] - if (!namespaceClient) return + const nsClient = this.namespace_clients[namespace] + if (!nsClient) return + + if (!nsClient.isConnected) { + log.warn(`k8 Service cannot page on disconnected namespace ${namespace}`) + return + } this.namespace_clients[namespace].previous() } @@ -316,8 +359,10 @@ export class KubernetesService extends EventEmitter implements Paging { hasNext(namespace?: string): boolean { if (!namespace) return false - const namespaceClient = this.namespace_clients[namespace] - if (!namespaceClient) return false + const nsClient = this.namespace_clients[namespace] + if (!nsClient) return false + + if (!nsClient.isConnected) return false return this.namespace_clients[namespace].hasNext() } @@ -325,8 +370,13 @@ export class KubernetesService extends EventEmitter implements Paging { next(namespace?: string) { if (!namespace) return - const namespaceClient = this.namespace_clients[namespace] - if (!namespaceClient) return + const nsClient = this.namespace_clients[namespace] + if (!nsClient) return + + if (!nsClient.isConnected) { + log.warn(`k8 Service cannot page on disconnected namespace ${namespace}`) + return + } this.namespace_clients[namespace].next() } @@ -334,8 +384,13 @@ export class KubernetesService extends EventEmitter implements Paging { last(namespace?: string) { if (!namespace) return - const namespaceClient = this.namespace_clients[namespace] - if (!namespaceClient) return + const nsClient = this.namespace_clients[namespace] + if (!nsClient) return + + if (!nsClient.isConnected) { + log.warn(`k8 Service cannot page on disconnected namespace ${namespace}`) + return + } this.namespace_clients[namespace].last() } @@ -343,8 +398,13 @@ export class KubernetesService extends EventEmitter implements Paging { page(pageIdx: number, namespace?: string) { if (!namespace) return - const namespaceClient = this.namespace_clients[namespace] - if (!namespaceClient) return + const nsClient = this.namespace_clients[namespace] + if (!nsClient) return + + if (!nsClient.isConnected) { + log.warn(`k8 Service cannot page on disconnected namespace ${namespace}`) + return + } this.namespace_clients[namespace].page(pageIdx) } diff --git a/packages/kubernetes-api/src/sort.ts b/packages/kubernetes-api/src/sort.ts new file mode 100644 index 00000000..13462b54 --- /dev/null +++ b/packages/kubernetes-api/src/sort.ts @@ -0,0 +1,4 @@ +export enum SortOrder { + ASC = 'Ascending', + DESC = 'Descending', +} diff --git a/packages/management-api/src/globals.ts b/packages/management-api/src/globals.ts index f2ce3c47..91fac355 100644 --- a/packages/management-api/src/globals.ts +++ b/packages/management-api/src/globals.ts @@ -15,24 +15,3 @@ export enum MgmtActions { export type MPodsByUid = { [uid: string]: ManagedPod } export type ManagedProjects = { [key: string]: ManagedProject } - -export enum TypeFilterType { - NAME = 'Name', - NAMESPACE = 'Namespace', -} - -export function typeFilterTypeValueOf(str: string): TypeFilterType | undefined { - switch (str) { - case TypeFilterType.NAME: - return TypeFilterType.NAME - case TypeFilterType.NAMESPACE: - return TypeFilterType.NAMESPACE - default: - return undefined - } -} - -export type TypeFilter = { - type: TypeFilterType - values: Set -} diff --git a/packages/management-api/src/managed-pod.ts b/packages/management-api/src/managed-pod.ts index 00d58383..c06a96b8 100644 --- a/packages/management-api/src/managed-pod.ts +++ b/packages/management-api/src/managed-pod.ts @@ -5,7 +5,7 @@ import Jolokia, { } from 'jolokia.js' import 'jolokia.js/simple' import $ from 'jquery' -import { TypeFilter, log } from './globals' +import { log } from './globals' import jsonpath from 'jsonpath' import { k8Api, @@ -259,23 +259,4 @@ export class ManagedPod { eventService.notify({ type: 'danger', message: msg }) } } - - filter(filter: TypeFilter): boolean { - const metadata = this.metadata - if (!metadata) return false - - type KubeObjKey = keyof typeof metadata - const podProp = metadata[filter.type.toLowerCase() as KubeObjKey] as string - - // Want to filter on this property but value - // is null so filter fails - if (!podProp) return false - - // values is tested as OR - for (const value of filter.values) { - if (podProp.toLowerCase().includes(value.toLowerCase())) return true - } - - return false - } } diff --git a/packages/management-api/src/managed-project.ts b/packages/management-api/src/managed-project.ts index bf897200..04e6cbb0 100644 --- a/packages/management-api/src/managed-project.ts +++ b/packages/management-api/src/managed-project.ts @@ -5,7 +5,7 @@ import { ManagedPod } from './managed-pod' export class ManagedProject { private _error?: Error private podsByUid: MPodsByUid = {} - private _podTotal: number = 0 + private _fullPodCount = 0 constructor(private _name: string) {} @@ -13,12 +13,12 @@ export class ManagedProject { return this._name } - get podTotal(): number { - return this._podTotal + get fullPodCount(): number { + return this._fullPodCount } - set podTotal(podTotal: number) { - this._podTotal = podTotal + set fullPodCount(fullPodCount: number) { + this._fullPodCount = fullPodCount } get error(): Error | undefined { diff --git a/packages/management-api/src/management-service.ts b/packages/management-api/src/management-service.ts index 4a05da8f..020a49dc 100644 --- a/packages/management-api/src/management-service.ts +++ b/packages/management-api/src/management-service.ts @@ -10,6 +10,8 @@ import { debounce, KubePodsByProject, Paging, + SortOrder, + TypeFilter, } from '@hawtio/online-kubernetes-api' import { ManagedProjects, MgmtActions, log } from './globals' import { ManagedProject } from './managed-project' @@ -32,6 +34,7 @@ export class ManagementService extends EventEmitter implements Paging { fireUpdate: false, uids: new Set(), } + private _orderingAndFiltering = false private _jolokiaPolling = 15000 private _pollingHandle?: NodeJS.Timeout @@ -55,6 +58,20 @@ export class ManagementService extends EventEmitter implements Paging { if (!this.hasError()) { const kPodsByProject: KubePodsByProject = k8Service.getPods() + /* + * Delete any projects no longer contained in the k8 service + */ + Object.keys(this._managedProjects) + .filter(ns => !Object.keys(kPodsByProject).includes(ns)) + .forEach(ns => { + /* Flag this project to be removed in the update */ + this._managedProjects[ns].pods = [] + this._managedProjects[ns].fullPodCount = 0 + }) + + /* + * Update the remaining projects + */ Object.entries(kPodsByProject).forEach(([project, kPodsOrError]) => { /* * Either project has never been seen before so initialise @@ -68,8 +85,8 @@ export class ManagementService extends EventEmitter implements Paging { // Project may have an error mgmtProject.error = kPodsOrError - mgmtProject.podTotal = kPodsOrError.total mgmtProject.pods = kPodsOrError.pods + mgmtProject.fullPodCount = kPodsOrError.fullPodCount }) // let's kick a polling cycle @@ -90,7 +107,7 @@ export class ManagementService extends EventEmitter implements Paging { private preMgmtUpdate() { /* Reset the update queue */ this.updateQueue.uids.clear() - this.updateQueue.fireUpdate = false + this.updateQueue.fireUpdate = this._orderingAndFiltering // Add all the uids to the queue Object.values(this._managedProjects) @@ -103,10 +120,17 @@ export class ManagementService extends EventEmitter implements Paging { this.updateQueue.uids.delete(emitter.uid) } - /* If the emitter should fire then update the queue */ - this.updateQueue.fireUpdate = emitter.fireUpdate ? emitter.fireUpdate : this.updateQueue.fireUpdate + /* + * If not already set to fire then check whether + * the emitter should fire then update the queue + */ + if (!this.updateQueue.fireUpdate) + this.updateQueue.fireUpdate = emitter.fireUpdate ? emitter.fireUpdate : this.updateQueue.fireUpdate - if (this.updateQueue.fireUpdate && this.updateQueue.uids.size === 0) this.emit(MgmtActions.UPDATED) + if (this.updateQueue.fireUpdate && this.updateQueue.uids.size === 0) { + this._orderingAndFiltering = false // reset ready for next time + this.emit(MgmtActions.UPDATED) + } } private async mgmtUpdate() { @@ -126,6 +150,7 @@ export class ManagementService extends EventEmitter implements Paging { const mPodsByUid = managedProject.pods if (Object.entries(mPodsByUid).length === 0) { + delete this._managedProjects[managedProject.name] this.emitUpdate({ fireUpdate: true }) continue } @@ -367,6 +392,19 @@ export class ManagementService extends EventEmitter implements Paging { connectService.connect(connection) } + /******************** + * Filtering & Sort support + ********************/ + filter(typeFilter: TypeFilter) { + this._orderingAndFiltering = true + k8Service.filter(typeFilter) + } + + sort(sortOrder: SortOrder) { + this._orderingAndFiltering = true + k8Service.sort(sortOrder) + } + /******************** * Paging interface ********************/ diff --git a/packages/online-shell/src/discover/Discover.tsx b/packages/online-shell/src/discover/Discover.tsx index be143ae9..a71d5096 100644 --- a/packages/online-shell/src/discover/Discover.tsx +++ b/packages/online-shell/src/discover/Discover.tsx @@ -3,26 +3,22 @@ import { Alert, Card, CardBody, - EmptyState, - EmptyStateBody, - EmptyStateIcon, PageSection, PageSectionVariants, Title, - EmptyStateHeader, Tabs, TabTitleText, Tab, } from '@patternfly/react-core' -import { CubesIcon } from '@patternfly/react-icons' import { discoverService } from './discover-service' import { DiscoverToolbar } from './DiscoverToolbar' import { DiscoverContext, useDisplayItems } from './context' import { DiscoverProjectContent } from './DiscoverProjectContent' import { DiscoverLoadingPanel } from './DiscoverLoadingPanel' +import { DiscoverEmptyContent } from './DiscoverEmptyContent' export const Discover: React.FunctionComponent = () => { - const { error, isLoading, refreshing, setRefreshing, discoverProjects, setDiscoverProjects, filters, setFilters } = + const { error, isLoading, refreshing, setRefreshing, discoverProjects, setDiscoverProjects, filter, setFilter } = useDisplayItems() const [activeTabKey, setActiveTabKey] = React.useState('') @@ -44,7 +40,7 @@ export const Discover: React.FunctionComponent = () => { return activeKey }) - }, [isLoading, error, discoverProjects, filters]) + }, [isLoading, error, discoverProjects, filter]) if (isLoading) { return ( @@ -78,31 +74,20 @@ export const Discover: React.FunctionComponent = () => { setRefreshing, discoverProjects, setDiscoverProjects, - filters, - setFilters, + filter, + setFilter, }} > - {discoverProjects.length === 0 && ( - - } - headingLevel='h1' - /> - - There are no containers running with a port configured whose name is jolokia. - - - )} + {discoverProjects.length === 0 && } {discoverProjects.length > 0 && ( {discoverProjects.map(discoverProject => ( {`${discoverProject.name} (${discoverProject.podsTotal})`}} + title={{`${discoverProject.name} (${discoverProject.fullPodCount})`}} key={`discover-project-${discoverProject.name}`} > diff --git a/packages/online-shell/src/discover/DiscoverEmptyContent.tsx b/packages/online-shell/src/discover/DiscoverEmptyContent.tsx new file mode 100644 index 00000000..7fab46a2 --- /dev/null +++ b/packages/online-shell/src/discover/DiscoverEmptyContent.tsx @@ -0,0 +1,44 @@ +import React, { useContext } from 'react' +import { + EmptyState, + EmptyStateHeader, + EmptyStateIcon, + EmptyStateBody, + Panel, + PanelMain, + PanelMainBody, +} from '@patternfly/react-core' +import { CubesIcon } from '@patternfly/react-icons' +import { DiscoverContext } from './context' +import { DiscoverLoadingPanel } from './DiscoverLoadingPanel' + +export const DiscoverEmptyContent: React.FunctionComponent = () => { + const { refreshing } = useContext(DiscoverContext) + + return ( + + {refreshing && ( + + + + + + + + )} + + {!refreshing && ( + + } + headingLevel='h1' + /> + + There are no containers running with a port configured whose name is jolokia. + + + )} + + ) +} diff --git a/packages/online-shell/src/discover/DiscoverProjectContent.tsx b/packages/online-shell/src/discover/DiscoverProjectContent.tsx index 9c56ec7f..ccaed2bd 100644 --- a/packages/online-shell/src/discover/DiscoverProjectContent.tsx +++ b/packages/online-shell/src/discover/DiscoverProjectContent.tsx @@ -1,6 +1,5 @@ -import React, { useContext } from 'react' +import React, { useContext, useEffect } from 'react' import { - Button, List, Pagination, Panel, @@ -12,13 +11,13 @@ import { ToolbarGroup, ToolbarItem, } from '@patternfly/react-core' -import { DiscoverProject } from './discover-project' +import { k8Service } from '@hawtio/online-kubernetes-api' +import { mgmtService } from '@hawtio/online-management-api' import { DiscoverGroupList } from './DiscoverGroupList' import { DiscoverPodItem } from './DiscoverPodItem' -import { mgmtService } from '@hawtio/online-management-api' import { DiscoverLoadingPanel } from './DiscoverLoadingPanel' import { DiscoverContext } from './context' -import { k8Service } from '@hawtio/online-kubernetes-api' +import { DiscoverProject } from './discover-project' interface DiscoverProjectCntProps { project: DiscoverProject @@ -30,6 +29,21 @@ export const DiscoverProjectContent: React.FunctionComponent { + if (refreshing) return + + setPage(prev => { + const pagedPods = prev * k8Service.namespaceLimit + const nextPagePods = props.project.fullPodCount + k8Service.namespaceLimit + + /* + * If filtering has caused the fullPodCount to decrease + * below the (page * limit) total then reset back to 1 + */ + return pagedPods > nextPagePods ? 1 : prev + }) + }, [refreshing, props.project.fullPodCount]) + const firstPods = (page: number) => { setPage(page) setRefreshing(true) @@ -68,10 +82,10 @@ export const DiscoverProjectContent: React.FunctionComponent pagePods(page)} @@ -87,7 +101,6 @@ export const DiscoverProjectContent: React.FunctionComponent - {refreshing && ( @@ -96,7 +109,7 @@ export const DiscoverProjectContent: React.FunctionComponent )} - {! refreshing && ( + {!refreshing && ( {props.project.groups.length > 0 && } diff --git a/packages/online-shell/src/discover/DiscoverToolbar.tsx b/packages/online-shell/src/discover/DiscoverToolbar.tsx index ebf10b54..bcab6cb6 100644 --- a/packages/online-shell/src/discover/DiscoverToolbar.tsx +++ b/packages/online-shell/src/discover/DiscoverToolbar.tsx @@ -15,51 +15,55 @@ import { } from '@patternfly/react-core' import { LongArrowAltUpIcon, LongArrowAltDownIcon } from '@patternfly/react-icons' -import { TypeFilterType, TypeFilter, typeFilterTypeValueOf } from '@hawtio/online-management-api' +import { TypeFilterType, TypeFilter, typeFilterTypeValueOf, SortOrder } from '@hawtio/online-kubernetes-api' +import { mgmtService } from '@hawtio/online-management-api' import { DiscoverContext } from './context' import { DiscoverProject } from './discover-project' const defaultFilterInputPlaceholder = 'Filter by Name...' -interface SortOrder { - id: string - icon: ReactNode +enum SortType { + NAME = 'Name', + NAMESPACE = 'Namespace', } -const ascending: SortOrder = { id: 'ascending', icon: } -const descending: SortOrder = { id: 'descending', icon: } - -interface SortMetaType { - id: string - order: SortOrder +function sortTypeValueOf(str: string): SortType | undefined { + switch (str) { + case SortType.NAME: + return SortType.NAME + case SortType.NAMESPACE: + return SortType.NAMESPACE + default: + return undefined + } } -type FilterMeta = { - [name: string]: TypeFilterType +interface SortOrderIcon { + type: SortOrder + icon: ReactNode } -type SortMeta = { - [name: string]: SortMetaType -} +const ascending: SortOrderIcon = { type: SortOrder.ASC, icon: } +const descending: SortOrderIcon = { type: SortOrder.DESC, icon: } -const filterMeta: FilterMeta = { +const filterMeta = { name: TypeFilterType.NAME, namespace: TypeFilterType.NAMESPACE, } -const sortMeta: SortMeta = { +const sortMeta = { name: { - id: 'Name', - order: ascending, + id: SortType.NAME, + orderIcon: ascending, }, namespace: { - id: 'Namespace', - order: ascending, + id: SortType.NAMESPACE, + orderIcon: ascending, }, } export const DiscoverToolbar: React.FunctionComponent = () => { - const { discoverProjects, setDiscoverProjects, filters, setFilters } = useContext(DiscoverContext) + const { discoverProjects, setDiscoverProjects, filter, setFilter, setRefreshing } = useContext(DiscoverContext) // Ref for toggle of filter type Select control const filterTypeToggleRef = useRef() @@ -73,18 +77,23 @@ export const DiscoverToolbar: React.FunctionComponent = () => { const [filterInputPlaceholder, setFilterInputPlaceholder] = useState(defaultFilterInputPlaceholder) // Ref for toggle of sort type Select control - useRef() + const sortTypeToggleRef = useRef() // The type of sort to be created - chosen by the Select control - const [sortType, setSortType] = useState(sortMeta.name.id || '') + const [sortType, setSortType] = useState(sortMeta.name.id || '') // Flag to determine whether the Sort Select control is open or closed const [isSortTypeOpen, setIsSortTypeOpen] = useState(false) // Icon showing the sort - const [sortOrder, setSortOrder] = useState(sortMeta.name.order) + const [sortOrderIcon, setSortOrderIcon] = useState(sortMeta.name.orderIcon) + + const callMgmtFilter = (filter: TypeFilter) => { + setRefreshing(true) + setFilter(filter) + mgmtService.filter(filter) + } const clearFilters = () => { - const emptyFilters: TypeFilter[] = [] - setFilters(emptyFilters) setFilterInput('') + callMgmtFilter(new TypeFilter()) } const onSelectFilterType = (_event?: ChangeEvent | MouseEvent, value?: string | number) => { @@ -100,9 +109,9 @@ export const DiscoverToolbar: React.FunctionComponent = () => { const filterChips = (): string[] => { const chips: string[] = [] - filters.forEach(filter => { - Array.from(filter.values).map(v => chips.push(filter.type + ':' + v)) - }) + filter.nsValues.map(v => chips.push(TypeFilterType.NAMESPACE + ':' + v)) + + filter.nameValues.map(v => chips.push(TypeFilterType.NAME + ':' + v)) return chips } @@ -112,71 +121,89 @@ export const DiscoverToolbar: React.FunctionComponent = () => { if (!filterType) return - const newFilters = [...filters] + const newTypeFilter = new TypeFilter(filter.nsValues, filter.nameValues) - const typeFilter = newFilters.filter(f => f.type === filterType) - if (typeFilter.length === 0) { - const filter: TypeFilter = { - type: filterType, - values: new Set([value]), - } - newFilters.push(filter) - } else { - typeFilter[0].values.add(value) + switch (filterType) { + case TypeFilterType.NAME: + newTypeFilter.addNameValue(value) + break + case TypeFilterType.NAMESPACE: + newTypeFilter.addNSValue(value) } - setFilters(newFilters) + callMgmtFilter(newTypeFilter) } const deleteFilter = (filterChip: string) => { - const remaining = filters.filter(filter => { - const [typeId, value] = filterChip.split(':') - const type = typeFilterTypeValueOf(typeId) + const [typeId, value] = filterChip.split(':') + const filterType = typeFilterTypeValueOf(typeId) + const newTypeFilter = new TypeFilter(filter.nsValues, filter.nameValues) - if (filter.type !== type) return true - - filter.values.delete(value) - return filter.values.size > 0 - }) + switch (filterType) { + case TypeFilterType.NAME: + newTypeFilter.deleteNameValue(value) + break + case TypeFilterType.NAMESPACE: + newTypeFilter.deleteNSValue(value) + } - setFilters(remaining) + callMgmtFilter(newTypeFilter) } const onSelectSortType = (_event?: MouseEvent, value?: string | number) => { if (!value) return // Updates the sort type either Name or Namespace - setSortType(value as string) + const type = sortTypeValueOf(`${value}`) + setSortType(type as SortType) // Updates the sort order to whichever the sort type is set to - setSortOrder(value === sortMeta.name.id ? sortMeta.name.order : sortMeta.namespace.order) + setSortOrderIcon(value === sortMeta.name.id ? sortMeta.name.orderIcon : sortMeta.namespace.orderIcon) setIsSortTypeOpen(false) - filterTypeToggleRef?.current?.focus() + sortTypeToggleRef?.current?.focus() + } + + const isSortButtonEnabled = () => { + switch (sortType) { + case SortType.NAMESPACE: + return Object.values(discoverProjects).length > 1 + case SortType.NAME: + return discoverProjects.filter(discoverProject => discoverProject.fullPodCount > 1).length > 0 + default: + return true // enable by default + } } const sortItems = () => { - const sortedProjects = [...discoverProjects] - const newSortOrder = sortOrder === ascending ? descending : ascending + const newSortOrderIcon = sortOrderIcon === ascending ? descending : ascending + setSortOrderIcon(newSortOrderIcon) switch (sortType) { - case sortMeta.name.id: - // Sorting via name - sortMeta.name.order = newSortOrder - sortedProjects.forEach(project => { - project.sort(newSortOrder === ascending ? 1 : -1) - }) - break - case sortMeta.namespace.id: - // Sorting via namespace - sortMeta.namespace.order = newSortOrder + case SortType.NAMESPACE: { + const sortedProjects = [...discoverProjects] + /* + * Sorting via namespace requires simply moving around the + * tabs in the UI + */ + sortMeta.namespace.orderIcon = newSortOrderIcon.type === SortOrder.ASC ? ascending : descending + sortedProjects.sort((ns1: DiscoverProject, ns2: DiscoverProject) => { let value = ns1.name.localeCompare(ns2.name) - return newSortOrder === descending ? (value *= -1) : value + return newSortOrderIcon === descending ? (value *= -1) : value }) - } - setDiscoverProjects(sortedProjects) - setSortOrder(newSortOrder) + setDiscoverProjects(sortedProjects) + break + } + case SortType.NAME: + /* + * Resorting the pod names required going back to k8 level + * in order to correctly sort all pods and get back the right + * set according to the paging limit + */ + setRefreshing(true) + mgmtService.sort(newSortOrderIcon.type) + } } return ( @@ -225,18 +252,14 @@ export const DiscoverToolbar: React.FunctionComponent = () => { /> + - diff --git a/packages/online-shell/src/discover/context.ts b/packages/online-shell/src/discover/context.ts index 602abce3..907d2b99 100644 --- a/packages/online-shell/src/discover/context.ts +++ b/packages/online-shell/src/discover/context.ts @@ -1,5 +1,6 @@ import { createContext, useCallback, useEffect, useRef, useState } from 'react' -import { MgmtActions, isMgmtApiRegistered, mgmtService, TypeFilter } from '@hawtio/online-management-api' +import { TypeFilter } from '@hawtio/online-kubernetes-api' +import { MgmtActions, isMgmtApiRegistered, mgmtService } from '@hawtio/online-management-api' import { discoverService } from './discover-service' import { DiscoverProject } from './discover-project' @@ -17,16 +18,16 @@ export function useDisplayItems() { const [error, setError] = useState() const [discoverProjects, setDiscoverProjects] = useState([]) - // Set of filters created by filter control and displayed as chips - const [filters, setFilters] = useState([]) + // type filter created by filter control and displayed as chips + const [filter, setFilter] = useState(new TypeFilter()) const organisePods = useCallback(() => { - const discoverProjects = discoverService.filterAndGroupPods(filters) + const discoverProjects = discoverService.groupPods() setDiscoverProjects([...discoverProjects]) setIsLoading(false) setRefreshing(false) - }, [filters]) + }, []) useEffect(() => { const waitLoading = async () => { @@ -63,7 +64,7 @@ export function useDisplayItems() { } }, [organisePods]) - return { error, isLoading, refreshing, setRefreshing, discoverProjects, setDiscoverProjects, filters, setFilters } + return { error, isLoading, refreshing, setRefreshing, discoverProjects, setDiscoverProjects, filter, setFilter } } type DiscoverContext = { @@ -71,8 +72,8 @@ type DiscoverContext = { setRefreshing: (refresh: boolean) => void discoverProjects: DiscoverProject[] setDiscoverProjects: (projects: DiscoverProject[]) => void - filters: TypeFilter[] - setFilters: (filters: TypeFilter[]) => void + filter: TypeFilter + setFilter: (filter: TypeFilter) => void } export const DiscoverContext = createContext({ @@ -84,8 +85,8 @@ export const DiscoverContext = createContext({ setDiscoverProjects: () => { // no-op }, - filters: [], - setFilters: () => { + filter: new TypeFilter(), + setFilter: () => { // no-op }, }) diff --git a/packages/online-shell/src/discover/discover-project.ts b/packages/online-shell/src/discover/discover-project.ts index 96107c2b..0c0323f1 100644 --- a/packages/online-shell/src/discover/discover-project.ts +++ b/packages/online-shell/src/discover/discover-project.ts @@ -1,23 +1,22 @@ import { ManagedPod } from '@hawtio/online-management-api' -import { DiscoverGroup, DiscoverPod, DiscoverType } from './globals' import { OwnerReference } from '@hawtio/online-kubernetes-api' +import { DiscoverGroup, DiscoverPod, DiscoverType } from './globals' export type DiscoverProjects = { [name: string]: DiscoverProject } -type SortOrderType = 1 | -1 - export class DiscoverProject { private discoverGroups: DiscoverGroup[] = [] private discoverPods: DiscoverPod[] = [] + private _fullPodCount = 0 constructor( private projectName: string, - private totalPods: number, + fullPodCount: number, mgmtPods: ManagedPod[], ) { - this.refresh(mgmtPods) + this.refresh(fullPodCount, mgmtPods) } private podOwner(pod: ManagedPod): OwnerReference | null { @@ -68,7 +67,7 @@ export class DiscoverProject { return values[key] } - refresh(pods: ManagedPod[]) { + refresh(fullPodCount: number, pods: ManagedPod[]) { const discoverPods: DiscoverPod[] = [] const discoverGroups: DiscoverGroup[] = [] @@ -97,6 +96,7 @@ export class DiscoverProject { this.discoverGroups = discoverGroups this.discoverPods = discoverPods + this._fullPodCount = fullPodCount /** * Notify event service of any errors in the groups and pods @@ -112,8 +112,8 @@ export class DiscoverProject { return this.projectName } - get podsTotal(): number { - return this.totalPods + get fullPodCount(): number { + return this._fullPodCount } get pods(): DiscoverPod[] { @@ -123,14 +123,4 @@ export class DiscoverProject { get groups(): DiscoverGroup[] { return this.discoverGroups } - - sort(sortOrder: SortOrderType) { - this.discoverGroups.sort((a: DiscoverGroup, b: DiscoverGroup) => { - return a.name.localeCompare(b.name) * sortOrder - }) - - this.discoverPods.sort((a: DiscoverPod, b: DiscoverPod) => { - return a.name.localeCompare(b.name) * sortOrder - }) - } } diff --git a/packages/online-shell/src/discover/discover-service.ts b/packages/online-shell/src/discover/discover-service.ts index 96103416..3cd269a6 100644 --- a/packages/online-shell/src/discover/discover-service.ts +++ b/packages/online-shell/src/discover/discover-service.ts @@ -1,7 +1,6 @@ -import { mgmtService, MPodsByUid, TypeFilterType, TypeFilter } from '@hawtio/online-management-api' +import { mgmtService, ManagedPod } from '@hawtio/online-management-api' import { DiscoverPod } from './globals' import { DiscoverProject, DiscoverProjects } from './discover-project' -import { ManagedProject } from '@hawtio/online-management-api' export enum ViewType { listView = 'listView', @@ -11,88 +10,26 @@ export enum ViewType { class DiscoverService { private discoverProjects: DiscoverProjects = {} - filterAndGroupPods(filters: TypeFilter[]): DiscoverProject[] { - /* - * Find all the namespace filters and reduce them together - */ - const nsFilters = filters.filter(f => f.type === TypeFilterType.NAMESPACE) - let nsFilter: TypeFilter | undefined - if (nsFilters.length === 0) nsFilter = undefined - else { - nsFilter = nsFilters.reduceRight((accumulator, currentValue, currentIndex, array) => { - currentValue.values.forEach(v => accumulator.values.add(v)) - return accumulator - }) - } - - const podFilters = filters.filter(f => f.type === TypeFilterType.NAME) - - const filteredProjects: ManagedProject[] = [] - + groupPods(): DiscoverProject[] { + const projectNames: string[] = [] Object.values(mgmtService.projects).forEach(mgmtProject => { - if (!nsFilter) { - filteredProjects.push(mgmtProject) - return - } - - /* - * Namespace filter values will be tested as an OR filter - * This corresponds to Patternfly design guidelines that - * "[...] there is an "AND" relationship between facets, - * and an "OR" relationship between values." - * (https://www.patternfly.org/patterns/filters/design-guidelines/#filter-group) - */ - let exclude = true - nsFilter.values.forEach(v => { - if (mgmtProject.name.includes(v)) { - filteredProjects.push(mgmtProject) - exclude = false - } - }) - - if (exclude) { - /* - * By removing any projects that do not correspond to the namespace - * filter we are effectively doing an AND test with the name filter below - * - * ie. if the project fails the namespace filter then it should not - * even be tested against the name filter - */ - delete this.discoverProjects[mgmtProject.name] - } - }) - - filteredProjects.forEach(mgmtProject => { - const podsByUid: MPodsByUid = mgmtProject.pods - - const filtered = Object.values(podsByUid).filter(pod => { - if (podFilters.length === 0) return true - - for (const f of podFilters) { - if (pod.filter(f)) { - return true // Include as it conforms to at least one filter - } - } - - return false - }) - - if (filtered.length === 0) { - // Remove the project as no longer contains any pods - if (this.discoverProjects[mgmtProject.name]) { - delete this.discoverProjects[mgmtProject.name] - } - return - } + const pods: ManagedPod[] = Object.values(mgmtProject.pods) + projectNames.push(mgmtProject.name) if (!this.discoverProjects[mgmtProject.name]) { - const discoverProject = new DiscoverProject(mgmtProject.name, mgmtProject.podTotal, filtered) + const discoverProject = new DiscoverProject(mgmtProject.name, mgmtProject.fullPodCount, pods) this.discoverProjects[mgmtProject.name] = discoverProject } else { - this.discoverProjects[mgmtProject.name].refresh(filtered) + this.discoverProjects[mgmtProject.name].refresh(mgmtProject.fullPodCount, pods) } }) + Object.keys(this.discoverProjects) + .filter(ns => !projectNames.includes(ns)) + .forEach(ns => { + delete this.discoverProjects[ns] + }) + return Object.values(this.discoverProjects) }