diff --git a/integration/helpers/utils.ts b/integration/helpers/utils.ts index c7fd4e8ddb..066fecd2a2 100644 --- a/integration/helpers/utils.ts +++ b/integration/helpers/utils.ts @@ -86,7 +86,7 @@ export async function clickWhatsNew(app: Application) { export async function clickWelcomeNotification(app: Application) { const itemsText = await app.client.$("div.info-panel").getText(); - if (itemsText === "0 item") { + if (itemsText === "0 items") { // welcome notification should be present, dismiss it await app.client.waitUntilTextExists("div.message", "Welcome!"); await app.client.click("i.Icon.close"); diff --git a/src/renderer/components/+apps-helm-charts/helm-chart.store.ts b/src/renderer/components/+apps-helm-charts/helm-chart.store.ts index 78d4c677bf..70c130dca5 100644 --- a/src/renderer/components/+apps-helm-charts/helm-chart.store.ts +++ b/src/renderer/components/+apps-helm-charts/helm-chart.store.ts @@ -14,8 +14,18 @@ export interface IChartVersion { export class HelmChartStore extends ItemStore { @observable versions = observable.map(); - loadAll() { - return this.loadItems(() => helmChartsApi.list()); + async loadAll() { + try { + const res = await this.loadItems(() => helmChartsApi.list()); + + this.failedLoading = false; + + return res; + } catch (error) { + this.failedLoading = true; + + throw error; + } } getByName(name: string, repo: string) { diff --git a/src/renderer/components/+apps-releases/release.store.ts b/src/renderer/components/+apps-releases/release.store.ts index 59e8d9a5bc..1da5e61e89 100644 --- a/src/renderer/components/+apps-releases/release.store.ts +++ b/src/renderer/components/+apps-releases/release.store.ts @@ -1,5 +1,5 @@ import isEqual from "lodash/isEqual"; -import { action, IReactionDisposer, observable, reaction, when } from "mobx"; +import { action, observable, reaction, when } from "mobx"; import { autobind } from "../../utils"; import { HelmRelease, helmReleasesApi, IReleaseCreatePayload, IReleaseUpdatePayload } from "../../api/endpoints/helm-releases.api"; import { ItemStore } from "../../item.store"; @@ -10,64 +10,64 @@ import { Notifications } from "../notifications"; @autobind() export class ReleaseStore extends ItemStore { - @observable releaseSecrets: Secret[] = []; - @observable secretWatcher: IReactionDisposer; + releaseSecrets = observable.map(); constructor() { super(); when(() => secretsStore.isLoaded, () => { - this.releaseSecrets = this.getReleaseSecrets(); + this.releaseSecrets.replace(this.getReleaseSecrets()); }); } - watch() { - this.secretWatcher = reaction(() => secretsStore.items.toJS(), () => { + watchAssociatedSecrets(): (() => void) { + return reaction(() => secretsStore.items.toJS(), () => { if (this.isLoading) return; - const secrets = this.getReleaseSecrets(); - const amountChanged = secrets.length !== this.releaseSecrets.length; - const labelsChanged = this.releaseSecrets.some(item => { - const secret = secrets.find(secret => secret.getId() == item.getId()); - - if (!secret) return; - - return !isEqual(item.getLabels(), secret.getLabels()); - }); + const newSecrets = this.getReleaseSecrets(); + const amountChanged = newSecrets.length !== this.releaseSecrets.size; + const labelsChanged = newSecrets.some(([id, secret]) => ( + !isEqual(secret.getLabels(), this.releaseSecrets.get(id)?.getLabels()) + )); if (amountChanged || labelsChanged) { this.loadFromContextNamespaces(); } - this.releaseSecrets = [...secrets]; + this.releaseSecrets.replace(newSecrets); }); } - unwatch() { - this.secretWatcher(); + watchSelecteNamespaces(): (() => void) { + return reaction(() => namespaceStore.context.contextNamespaces, namespaces => { + this.loadAll(namespaces); + }); } - getReleaseSecrets() { - return secretsStore.getByLabel({ owner: "helm" }); + private getReleaseSecrets() { + return secretsStore + .getByLabel({ owner: "helm" }) + .map(s => [s.getId(), s] as const); } getReleaseSecret(release: HelmRelease) { - const labels = { + return secretsStore.getByLabel({ owner: "helm", name: release.getName() - }; - - return secretsStore.getByLabel(labels) - .filter(secret => secret.getNs() == release.getNs())[0]; + }) + .find(secret => secret.getNs() == release.getNs()); } @action async loadAll(namespaces: string[]) { this.isLoading = true; + this.isLoaded = false; try { const items = await this.loadItems(namespaces); this.items.replace(this.sortItems(items)); this.isLoaded = true; + this.failedLoading = false; } catch (error) { + this.failedLoading = true; console.error("Loading Helm Chart releases has failed", error); if (error.error) { @@ -79,17 +79,18 @@ export class ReleaseStore extends ItemStore { } async loadFromContextNamespaces(): Promise { - return this.loadAll(namespaceStore.contextNamespaces); + return this.loadAll(namespaceStore.context.contextNamespaces); } async loadItems(namespaces: string[]) { - const isLoadingAll = namespaceStore.allowedNamespaces.every(ns => namespaces.includes(ns)); - const noAccessibleNamespaces = namespaceStore.context.cluster.accessibleNamespaces.length === 0; + const isLoadingAll = namespaceStore.context.allNamespaces?.length > 1 + && namespaceStore.context.cluster.accessibleNamespaces.length === 0 + && namespaceStore.context.allNamespaces.every(ns => namespaces.includes(ns)); - if (isLoadingAll && noAccessibleNamespaces) { + if (isLoadingAll) { return helmReleasesApi.list(); } else { - return Promise + return Promise // load resources per namespace .all(namespaces.map(namespace => helmReleasesApi.list(namespace))) .then(items => items.flat()); } diff --git a/src/renderer/components/+apps-releases/releases.tsx b/src/renderer/components/+apps-releases/releases.tsx index 71cf3d954f..8b8f4ea048 100644 --- a/src/renderer/components/+apps-releases/releases.tsx +++ b/src/renderer/components/+apps-releases/releases.tsx @@ -2,7 +2,7 @@ import "./releases.scss"; import React, { Component } from "react"; import kebabCase from "lodash/kebabCase"; -import { observer } from "mobx-react"; +import { disposeOnUnmount, observer } from "mobx-react"; import { RouteComponentProps } from "react-router"; import { releaseStore } from "./release.store"; import { IReleaseRouteParams, releaseURL } from "./release.route"; @@ -30,14 +30,11 @@ interface Props extends RouteComponentProps { @observer export class HelmReleases extends Component { - componentDidMount() { - // Watch for secrets associated with releases and react to their changes - releaseStore.watch(); - } - - componentWillUnmount() { - releaseStore.unwatch(); + disposeOnUnmount(this, [ + releaseStore.watchAssociatedSecrets(), + releaseStore.watchSelecteNamespaces(), + ]); } get selectedRelease() { @@ -49,21 +46,16 @@ export class HelmReleases extends Component { } showDetails = (item: HelmRelease) => { - if (!item) { - navigation.merge(releaseURL()); - } - else { - navigation.merge(releaseURL({ - params: { - name: item.getName(), - namespace: item.getNs() - } - })); - } + navigation.merge(releaseURL({ + params: { + name: item.getName(), + namespace: item.getNs() + } + })); }; hideDetails = () => { - this.showDetails(null); + navigation.merge(releaseURL()); }; renderRemoveDialogMessage(selectedItems: HelmRelease[]) { @@ -114,30 +106,22 @@ export class HelmReleases extends Component { { title: "Status", className: "status", sortBy: columnId.status, id: columnId.status }, { title: "Updated", className: "updated", sortBy: columnId.updated, id: columnId.updated }, ]} - renderTableContents={(release: HelmRelease) => { - const version = release.getVersion(); - - return [ - release.getName(), - release.getNs(), - release.getChart(), - release.getRevision(), - <> - {version} - , - release.appVersion, - { title: release.getStatus(), className: kebabCase(release.getStatus()) }, - release.getUpdated(), - ]; - }} - renderItemMenu={(release: HelmRelease) => { - return ( - - ); - }} + renderTableContents={(release: HelmRelease) => [ + release.getName(), + release.getNs(), + release.getChart(), + release.getRevision(), + release.getVersion(), + release.appVersion, + { title: release.getStatus(), className: kebabCase(release.getStatus()) }, + release.getUpdated(), + ]} + renderItemMenu={(release: HelmRelease) => ( + + )} customizeRemoveDialog={(selectedItems: HelmRelease[]) => ({ message: this.renderRemoveDialogMessage(selectedItems) })} diff --git a/src/renderer/components/+landing-page/workspace-cluster.store.ts b/src/renderer/components/+landing-page/workspace-cluster.store.ts index 8fd2966b93..e4f3418867 100644 --- a/src/renderer/components/+landing-page/workspace-cluster.store.ts +++ b/src/renderer/components/+landing-page/workspace-cluster.store.ts @@ -65,8 +65,18 @@ export class WorkspaceClusterStore extends ItemStore { }); } - loadAll() { - return this.loadItems(() => this.clusters); + async loadAll() { + try { + const res = await this.loadItems(() => this.clusters); + + this.failedLoading = false; + + return res; + } catch (error) { + this.failedLoading = true; + + throw error; + } } async remove(clusterItem: ClusterItem) { diff --git a/src/renderer/components/context.ts b/src/renderer/components/context.ts index e8c9b1327d..5233faba83 100755 --- a/src/renderer/components/context.ts +++ b/src/renderer/components/context.ts @@ -4,8 +4,8 @@ import { namespaceStore } from "./+namespaces/namespace.store"; export interface ClusterContext { cluster?: Cluster; - allNamespaces?: string[]; // available / allowed namespaces from cluster.ts - contextNamespaces?: string[]; // selected by user (see: namespace-select.tsx) + allNamespaces: string[]; // available / allowed namespaces from cluster.ts + contextNamespaces: string[]; // selected by user (see: namespace-select.tsx) } export const clusterContext: ClusterContext = { diff --git a/src/renderer/components/item-object-list/item-list-layout.tsx b/src/renderer/components/item-object-list/item-list-layout.tsx index 5c7db4cd41..b609b046ea 100644 --- a/src/renderer/components/item-object-list/item-list-layout.tsx +++ b/src/renderer/components/item-object-list/item-list-layout.tsx @@ -172,6 +172,10 @@ export class ItemListLayout extends React.Component { return this.props.isReady ?? this.props.store.isLoaded; } + @computed get failedToLoad() { + return this.props.store.failedLoading; + } + @computed get filters() { let { activeFilters } = pageFilters; const { isSearchable, searchFilters } = this.props; @@ -281,6 +285,11 @@ export class ItemListLayout extends React.Component { }); } + @autobind() + toggleFilters() { + this.showFilters = !this.showFilters; + } + renderFilters() { const { hideFilters } = this.props; const { isReady, filters } = this; @@ -293,6 +302,14 @@ export class ItemListLayout extends React.Component { } renderNoItems() { + if (this.failedToLoad) { + return Failed to load items.; + } + + if (!this.isReady) { + return ; + } + if (this.filters.length > 0) { return ( @@ -309,6 +326,14 @@ export class ItemListLayout extends React.Component { return ; } + renderItems() { + if (this.props.virtual) { + return null; + } + + return this.items.map(item => this.getRow(item.getId())); + } + renderHeaderContent(placeholders: IHeaderPlaceholders): ReactNode { const { isSearchable, searchFilters } = this.props; const { title, filters, search, info } = placeholders; @@ -317,7 +342,7 @@ export class ItemListLayout extends React.Component { <> {title}
- {this.isReady && info} + {info}
{filters} {isSearchable && searchFilters && search} @@ -326,20 +351,17 @@ export class ItemListLayout extends React.Component { } renderInfo() { - const { items, isReady, filters } = this; + const { items, filters } = this; const allItemsCount = this.props.store.getTotalCount(); const itemsCount = items.length; - const isFiltered = isReady && filters.length > 0; - - if (isFiltered) { - const toggleFilters = () => this.showFilters = !this.showFilters; + if (filters.length > 0) { return ( - <>Filtered: {itemsCount} / {allItemsCount} + <>Filtered: {itemsCount} / {allItemsCount} ); } - return allItemsCount <= 1 ? `${allItemsCount} item` : `${allItemsCount} items`; + return allItemsCount === 1 ? `${allItemsCount} item` : `${allItemsCount} items`; } renderHeader() { @@ -412,40 +434,31 @@ export class ItemListLayout extends React.Component { renderList() { const { - store, hasDetailsView, addRemoveButtons = {}, virtual, sortingCallbacks, detailsItem, - tableProps = {}, tableId + store, hasDetailsView, addRemoveButtons = {}, virtual, sortingCallbacks, + detailsItem, className, tableProps = {}, tableId, } = this.props; - const { isReady, removeItemsDialog, items } = this; + const { removeItemsDialog, items } = this; const { selectedItems } = store; const selectedItemId = detailsItem && detailsItem.getId(); + const classNames = cssNames(className, "box", "grow", themeStore.activeTheme.type); return (
- {!isReady && ( - - )} - {isReady && ( - - {this.renderTableHeader()} - { - !virtual && items.map(item => this.getRow(item.getId())) - } -
- - )} + + {this.renderTableHeader()} + {this.renderItems()} +
{ protected defaultSorting = (item: T) => item.getName(); + @observable failedLoading = false; @observable isLoading = false; @observable isLoaded = false; @observable items = observable.array([], { deep: false }); diff --git a/src/renderer/kube-object.store.ts b/src/renderer/kube-object.store.ts index 987112a25c..ae1517d79f 100644 --- a/src/renderer/kube-object.store.ts +++ b/src/renderer/kube-object.store.ts @@ -146,18 +146,20 @@ export abstract class KubeObjectStore extends ItemSt const items = await this.loadItems({ namespaces, api: this.api }); - this.isLoaded = true; - if (merge) { this.mergeItems(items, { replace: false }); } else { this.mergeItems(items, { replace: true }); } + this.isLoaded = true; + this.failedLoading = false; + return items; } catch (error) { console.error("Loading store items failed", { error, store: this }); this.resetOnError(error); + this.failedLoading = true; } finally { this.isLoading = false; }