1
0
mirror of https://github.com/lensapp/lens.git synced 2025-05-20 05:10:56 +00:00

Display resource load errors seperately from list of items (#4228)

This commit is contained in:
Sebastian Malton 2021-11-16 12:41:10 -05:00 committed by GitHub
parent df230d2bec
commit 159c240a80
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 212 additions and 59 deletions

View File

@ -37,6 +37,32 @@ export interface KubeObjectStoreLoadingParams<K extends KubeObject> {
namespaces: string[]; namespaces: string[];
api?: KubeApi<K>; api?: KubeApi<K>;
reqInit?: RequestInit; reqInit?: RequestInit;
/**
* A function that is called when listing fails. If set then blocks errors
* being rejected with
*/
onLoadFailure?: (err: any) => void;
}
export interface KubeObjectStoreLoadAllParams {
namespaces?: string[];
merge?: boolean;
reqInit?: RequestInit;
/**
* A function that is called when listing fails. If set then blocks errors
* being rejected with
*/
onLoadFailure?: (err: any) => void;
}
export interface KubeObjectStoreSubscribeParams {
/**
* A function that is called when listing fails. If set then blocks errors
* being rejected with
*/
onLoadFailure?: (err: any) => void;
} }
export abstract class KubeObjectStore<T extends KubeObject> extends ItemStore<T> { export abstract class KubeObjectStore<T extends KubeObject> extends ItemStore<T> {
@ -141,30 +167,63 @@ export abstract class KubeObjectStore<T extends KubeObject> extends ItemStore<T>
} }
} }
protected async loadItems({ namespaces, api, reqInit }: KubeObjectStoreLoadingParams<T>): Promise<T[]> { protected async loadItems({ namespaces, api, reqInit, onLoadFailure }: KubeObjectStoreLoadingParams<T>): Promise<T[]> {
if (this.context?.cluster.isAllowedResource(api.kind)) { if (!this.context?.cluster.isAllowedResource(api.kind)) {
if (!api.isNamespaced) { return [];
return api.list({ reqInit }, this.query); }
const isLoadingAll = this.context.allNamespaces?.length > 1
&& this.context.cluster.accessibleNamespaces.length === 0
&& this.context.allNamespaces.every(ns => namespaces.includes(ns));
if (!api.isNamespaced || isLoadingAll) {
if (api.isNamespaced) {
this.loadedNamespaces = [];
} }
const isLoadingAll = this.context.allNamespaces?.length > 1 const res = api.list({ reqInit }, this.query);
&& this.context.cluster.accessibleNamespaces.length === 0
&& this.context.allNamespaces.every(ns => namespaces.includes(ns));
if (isLoadingAll) { if (onLoadFailure) {
this.loadedNamespaces = []; try {
return await res;
} catch (error) {
onLoadFailure(error?.message || error?.toString() || "Unknown error");
return api.list({ reqInit }, this.query); // reset the store because we are loading all, so that nothing is displayed
} else { this.items.clear();
this.loadedNamespaces = namespaces; this.selectedItemsIds.clear();
return Promise // load resources per namespace return [];
.all(namespaces.map(namespace => api.list({ namespace, reqInit }, this.query))) }
.then(items => items.flat().filter(Boolean)); }
return res;
}
this.loadedNamespaces = namespaces;
const results = await Promise.allSettled(
namespaces.map(namespace => api.list({ namespace, reqInit }, this.query)),
);
const res: T[] = [];
for (const result of results) {
switch (result.status) {
case "fulfilled":
res.push(...result.value);
break;
case "rejected":
if (onLoadFailure) {
onLoadFailure(result.reason.message);
} else {
// if onLoadFailure is not provided then preserve old behaviour
throw result.reason;
}
} }
} }
return []; return res;
} }
protected filterItemsOnLoad(items: T[]) { protected filterItemsOnLoad(items: T[]) {
@ -172,18 +231,18 @@ export abstract class KubeObjectStore<T extends KubeObject> extends ItemStore<T>
} }
@action @action
async loadAll(options: { namespaces?: string[], merge?: boolean, reqInit?: RequestInit } = {}): Promise<void | T[]> { async loadAll(options: KubeObjectStoreLoadAllParams = {}): Promise<void | T[]> {
await this.contextReady; await this.contextReady;
this.isLoading = true; this.isLoading = true;
const {
namespaces = this.context.allNamespaces, // load all namespaces by default
merge = true, // merge loaded items or return as result
reqInit,
onLoadFailure,
} = options;
try { try {
const { const items = await this.loadItems({ namespaces, api: this.api, reqInit, onLoadFailure });
namespaces = this.context.allNamespaces, // load all namespaces by default
merge = true, // merge loaded items or return as result
reqInit,
} = options;
const items = await this.loadItems({ namespaces, api: this.api, reqInit });
if (merge) { if (merge) {
this.mergeItems(items, { replace: false }); this.mergeItems(items, { replace: false });
@ -335,29 +394,29 @@ export abstract class KubeObjectStore<T extends KubeObject> extends ItemStore<T>
}); });
} }
subscribe() { subscribe(opts: KubeObjectStoreSubscribeParams = {}) {
const abortController = new AbortController(); const abortController = new AbortController();
if (this.api.isNamespaced) { if (this.api.isNamespaced) {
Promise.race([rejectPromiseBy(abortController.signal), Promise.all([this.contextReady, this.namespacesReady])]) Promise.race([rejectPromiseBy(abortController.signal), Promise.all([this.contextReady, this.namespacesReady])])
.then(() => { .then(() => {
if (this.context.cluster.isGlobalWatchEnabled && this.loadedNamespaces.length === 0) { if (this.context.cluster.isGlobalWatchEnabled && this.loadedNamespaces.length === 0) {
return this.watchNamespace("", abortController); return this.watchNamespace("", abortController, opts);
} }
for (const namespace of this.loadedNamespaces) { for (const namespace of this.loadedNamespaces) {
this.watchNamespace(namespace, abortController); this.watchNamespace(namespace, abortController, opts);
} }
}) })
.catch(noop); // ignore DOMExceptions .catch(noop); // ignore DOMExceptions
} else { } else {
this.watchNamespace("", abortController); this.watchNamespace("", abortController, opts);
} }
return () => abortController.abort(); return () => abortController.abort();
} }
private watchNamespace(namespace: string, abortController: AbortController) { private watchNamespace(namespace: string, abortController: AbortController, opts: KubeObjectStoreSubscribeParams) {
if (!this.api.getResourceVersion(namespace)) { if (!this.api.getResourceVersion(namespace)) {
return; return;
} }
@ -389,8 +448,8 @@ export abstract class KubeObjectStore<T extends KubeObject> extends ItemStore<T>
timedRetry = setTimeout(() => { timedRetry = setTimeout(() => {
( (
namespace namespace
? this.loadAll({ namespaces: [namespace], reqInit: { signal }}) ? this.loadAll({ namespaces: [namespace], reqInit: { signal }, ...opts })
: this.loadAll({ merge: false, reqInit: { signal }}) : this.loadAll({ merge: false, reqInit: { signal }, ...opts })
).then(watch); ).then(watch);
}, 1000); }, 1000);
} else if (error) { // not sure what to do, best to retry } else if (error) { // not sure what to do, best to retry

View File

@ -27,7 +27,7 @@ import type { ClusterContext } from "./cluster-context";
import plimit from "p-limit"; import plimit from "p-limit";
import { comparer, observable, reaction, makeObservable } from "mobx"; import { comparer, observable, reaction, makeObservable } from "mobx";
import { autoBind, Disposer, noop } from "../utils"; import { autoBind, disposer, Disposer, noop } from "../utils";
import type { KubeApi } from "./kube-api"; import type { KubeApi } from "./kube-api";
import type { KubeJsonApiData } from "./kube-json-api"; import type { KubeJsonApiData } from "./kube-json-api";
import { isDebugging, isProduction } from "../vars"; import { isDebugging, isProduction } from "../vars";
@ -38,11 +38,38 @@ export interface IKubeWatchEvent<T extends KubeJsonApiData> {
object?: T; object?: T;
} }
export interface IKubeWatchSubscribeStoreOptions { interface KubeWatchPreloadOptions {
namespaces?: string[]; // default: all accessible namespaces /**
preload?: boolean; // preload store items, default: true * The namespaces to watch
waitUntilLoaded?: boolean; // subscribe only after loading all stores, default: true * @default all-accessible
loadOnce?: boolean; // check store.isLoaded to skip loading if done already, default: false */
namespaces?: string[];
/**
* Whether to skip loading if the store is already loaded
* @default false
*/
loadOnce?: boolean;
/**
* A function that is called when listing fails. If set then blocks errors
* being rejected with
*/
onLoadFailure?: (err: any) => void;
}
export interface KubeWatchSubscribeStoreOptions extends KubeWatchPreloadOptions {
/**
* Whether to subscribe only after loading all stores
* @default true
*/
waitUntilLoaded?: boolean;
/**
* Whether to preload the stores before watching
* @default true
*/
preload?: boolean;
} }
export interface IKubeWatchLog { export interface IKubeWatchLog {
@ -63,15 +90,15 @@ export class KubeWatchApi {
return Boolean(this.context?.cluster.isAllowedResource(api.kind)); return Boolean(this.context?.cluster.isAllowedResource(api.kind));
} }
preloadStores(stores: KubeObjectStore<KubeObject>[], opts: { namespaces?: string[], loadOnce?: boolean } = {}) { preloadStores(stores: KubeObjectStore<KubeObject>[], { loadOnce, namespaces, onLoadFailure }: KubeWatchPreloadOptions = {}) {
const limitRequests = plimit(1); // load stores one by one to allow quick skipping when fast clicking btw pages const limitRequests = plimit(1); // load stores one by one to allow quick skipping when fast clicking btw pages
const preloading: Promise<any>[] = []; const preloading: Promise<any>[] = [];
for (const store of stores) { for (const store of stores) {
preloading.push(limitRequests(async () => { preloading.push(limitRequests(async () => {
if (store.isLoaded && opts.loadOnce) return; // skip if (store.isLoaded && loadOnce) return; // skip
return store.loadAll({ namespaces: opts.namespaces }); return store.loadAll({ namespaces, onLoadFailure });
})); }));
} }
@ -81,22 +108,22 @@ export class KubeWatchApi {
}; };
} }
subscribeStores(stores: KubeObjectStore<KubeObject>[], opts: IKubeWatchSubscribeStoreOptions = {}): Disposer { subscribeStores(stores: KubeObjectStore<KubeObject>[], opts: KubeWatchSubscribeStoreOptions = {}): Disposer {
const { preload = true, waitUntilLoaded = true, loadOnce = false } = opts; const { preload = true, waitUntilLoaded = true, loadOnce = false, onLoadFailure } = opts;
const subscribingNamespaces = opts.namespaces ?? this.context?.allNamespaces ?? []; const subscribingNamespaces = opts.namespaces ?? this.context?.allNamespaces ?? [];
const unsubscribeList: Function[] = []; const unsubscribeStores = disposer();
let isUnsubscribed = false; let isUnsubscribed = false;
const load = (namespaces = subscribingNamespaces) => this.preloadStores(stores, { namespaces, loadOnce }); const load = (namespaces = subscribingNamespaces) => this.preloadStores(stores, { namespaces, loadOnce, onLoadFailure });
let preloading = preload && load(); let preloading = preload && load();
let cancelReloading: Disposer = noop; let cancelReloading: Disposer = noop;
const subscribe = () => { const subscribe = () => {
if (isUnsubscribed) return; if (isUnsubscribed) {
return;
}
stores.forEach((store) => { unsubscribeStores.push(...stores.map(store => store.subscribe({ onLoadFailure })));
unsubscribeList.push(store.subscribe());
});
}; };
if (preloading) { if (preloading) {
@ -114,8 +141,7 @@ export class KubeWatchApi {
// reload stores only for context namespaces change // reload stores only for context namespaces change
cancelReloading = reaction(() => this.context?.contextNamespaces, namespaces => { cancelReloading = reaction(() => this.context?.contextNamespaces, namespaces => {
preloading?.cancelLoading(); preloading?.cancelLoading();
unsubscribeList.forEach(unsubscribe => unsubscribe()); unsubscribeStores();
unsubscribeList.length = 0;
preloading = load(namespaces); preloading = load(namespaces);
preloading.loading.then(subscribe); preloading.loading.then(subscribe);
}, { }, {
@ -129,8 +155,7 @@ export class KubeWatchApi {
isUnsubscribed = true; isUnsubscribed = true;
cancelReloading(); cancelReloading();
preloading?.cancelLoading(); preloading?.cancelLoading();
unsubscribeList.forEach(unsubscribe => unsubscribe()); unsubscribeStores();
unsubscribeList.length = 0;
}; };
} }

View File

@ -0,0 +1,27 @@
/**
* Copyright (c) 2021 OpenLens Authors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
.KubeObjectListLayout {
.Icon.load-error {
color: var(--colorWarning);
float: right;
}
}

View File

@ -19,10 +19,12 @@
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
import "./kube-object-list-layout.scss";
import React from "react"; import React from "react";
import { computed, makeObservable } from "mobx"; import { computed, makeObservable, observable, reaction } from "mobx";
import { disposeOnUnmount, observer } from "mobx-react"; import { disposeOnUnmount, observer } from "mobx-react";
import { cssNames } from "../../utils"; import { cssNames, Disposer } from "../../utils";
import type { KubeObject } from "../../../common/k8s-api/kube-object"; import type { KubeObject } from "../../../common/k8s-api/kube-object";
import { ItemListLayout, ItemListLayoutProps } from "../item-object-list/item-list-layout"; import { ItemListLayout, ItemListLayoutProps } from "../item-object-list/item-list-layout";
import type { KubeObjectStore } from "../../../common/k8s-api/kube-object.store"; import type { KubeObjectStore } from "../../../common/k8s-api/kube-object.store";
@ -32,6 +34,8 @@ import { clusterContext } from "../context";
import { NamespaceSelectFilter } from "../+namespaces/namespace-select-filter"; import { NamespaceSelectFilter } from "../+namespaces/namespace-select-filter";
import { ResourceKindMap, ResourceNames } from "../../utils/rbac"; import { ResourceKindMap, ResourceNames } from "../../utils/rbac";
import { kubeSelectedUrlParam, toggleDetails } from "../kube-detail-params"; import { kubeSelectedUrlParam, toggleDetails } from "../kube-detail-params";
import { Icon } from "../icon";
import { TooltipPosition } from "../tooltip";
export interface KubeObjectListLayoutProps<K extends KubeObject> extends ItemListLayoutProps<K> { export interface KubeObjectListLayoutProps<K extends KubeObject> extends ItemListLayoutProps<K> {
store: KubeObjectStore<K>; store: KubeObjectStore<K>;
@ -53,6 +57,8 @@ export class KubeObjectListLayout<K extends KubeObject> extends React.Component<
makeObservable(this); makeObservable(this);
} }
@observable loadErrors: string[] = [];
@computed get selectedItem() { @computed get selectedItem() {
return this.props.store.getByPath(kubeSelectedUrlParam.get()); return this.props.store.getByPath(kubeSelectedUrlParam.get());
} }
@ -60,15 +66,45 @@ export class KubeObjectListLayout<K extends KubeObject> extends React.Component<
componentDidMount() { componentDidMount() {
const { store, dependentStores = [], subscribeStores } = this.props; const { store, dependentStores = [], subscribeStores } = this.props;
const stores = Array.from(new Set([store, ...dependentStores])); const stores = Array.from(new Set([store, ...dependentStores]));
const reactions: Disposer[] = [
reaction(() => clusterContext.contextNamespaces.length, () => {
// clear load errors
this.loadErrors.length = 0;
}),
];
if (subscribeStores) { if (subscribeStores) {
disposeOnUnmount(this, [ reactions.push(
kubeWatchApi.subscribeStores(stores, { kubeWatchApi.subscribeStores(stores, {
preload: true, preload: true,
namespaces: clusterContext.contextNamespaces, namespaces: clusterContext.contextNamespaces,
onLoadFailure: error => this.loadErrors.push(String(error)),
}), }),
]); );
} }
disposeOnUnmount(this, reactions);
}
renderLoadErrors() {
if (this.loadErrors.length === 0) {
return null;
}
return (
<Icon
material="warning"
className="load-error"
tooltip={{
children: (
<>
{this.loadErrors.map((error, index) => <p key={index}>{error}</p>)}
</>
),
preferredPositions: TooltipPosition.BOTTOM,
}}
/>
);
} }
render() { render() {
@ -84,7 +120,7 @@ export class KubeObjectListLayout<K extends KubeObject> extends React.Component<
preloadStores={false} // loading handled in kubeWatchApi.subscribeStores() preloadStores={false} // loading handled in kubeWatchApi.subscribeStores()
detailsItem={this.selectedItem} detailsItem={this.selectedItem}
customizeHeader={[ customizeHeader={[
({ filters, searchProps, ...headerPlaceHolders }) => ({ ({ filters, searchProps, info, ...headerPlaceHolders }) => ({
filters: ( filters: (
<> <>
{filters} {filters}
@ -95,6 +131,12 @@ export class KubeObjectListLayout<K extends KubeObject> extends React.Component<
...searchProps, ...searchProps,
placeholder: `Search ${placeholderString}...`, placeholder: `Search ${placeholderString}...`,
}, },
info: (
<>
{info}
{this.renderLoadErrors()}
</>
),
...headerPlaceHolders, ...headerPlaceHolders,
}), }),
...[customizeHeader].flat(), ...[customizeHeader].flat(),