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

Fix NamespaceSelectFilter to correctly track accessibleNamespaces

- Consolidate the ClusterContext functions into a class

- Add method for the correct determiation of whether all possible
  namespaces are selected to ClusterContext

- Use said method in the two API related use cases as well in the above
  component

Fix only able to select one namespace

- Extends isAllPossibleNamespaces with isFilterSelect option to
  correctly check the empty namespaces imply selecting all case

- renamed contextNamespaces to selectedNamespaces

- renamed contextNs to rawSelectedNamespaces

fix not restarting subscribes when adding new accessibleNamespaces

Signed-off-by: Sebastian Malton <sebastian@malton.name>
This commit is contained in:
Sebastian Malton 2021-02-19 10:02:53 -05:00
parent 53606202c1
commit df7f72e84c
14 changed files with 141 additions and 69 deletions

View File

@ -0,0 +1,8 @@
/**
* This function changes the TS type from an array literal over the union of
* element types to a strict tuple.
* @param arg The array literal to be made into a tuple
*/
export function asTuple<T extends [any] | any[]>(arg: T): T {
return arg;
}

View File

@ -3,6 +3,7 @@
export const noop: any = () => { /* empty */ }; export const noop: any = () => { /* empty */ };
export * from "./app-version"; export * from "./app-version";
export * from "./as-tuple";
export * from "./autobind"; export * from "./autobind";
export * from "./base64"; export * from "./base64";
export * from "./camelCase"; export * from "./camelCase";
@ -11,6 +12,7 @@ export * from "./debouncePromise";
export * from "./defineGlobal"; export * from "./defineGlobal";
export * from "./delay"; export * from "./delay";
export * from "./disposer"; export * from "./disposer";
export * from "./disposer";
export * from "./downloadFile"; export * from "./downloadFile";
export * from "./escapeRegExp"; export * from "./escapeRegExp";
export * from "./getRandId"; export * from "./getRandId";

View File

@ -6,7 +6,7 @@ import type { ClusterContext } from "../components/context";
import plimit from "p-limit"; import plimit from "p-limit";
import { comparer, IReactionDisposer, observable, reaction, when } from "mobx"; import { comparer, IReactionDisposer, observable, reaction, when } from "mobx";
import { autobind, noop } from "../utils"; import { asTuple, autobind, noop } from "../utils";
import { KubeApi } from "./kube-api"; import { KubeApi } from "./kube-api";
import { KubeJsonApiData } from "./kube-json-api"; import { KubeJsonApiData } from "./kube-json-api";
import { isDebugging, isProduction } from "../../common/vars"; import { isDebugging, isProduction } from "../../common/vars";
@ -88,7 +88,15 @@ 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(() => {
const namespaces = this.context?.selectedNamespaces;
/**
* react to the changing of "allPossibleNamespaces" so that adding
* accessibleNamespaces means that this is restarted
*/
return asTuple([this.context?.isAllPossibleNamespaces(namespaces), namespaces]);
}, ([, namespaces]) => {
preloading?.cancelLoading(); preloading?.cancelLoading();
unsubscribeList.forEach(unsubscribe => unsubscribe()); unsubscribeList.forEach(unsubscribe => unsubscribe());
unsubscribeList.length = 0; unsubscribeList.length = 0;

View File

@ -7,11 +7,19 @@ import { Secret } from "../../api/endpoints";
import { secretsStore } from "../+config-secrets/secrets.store"; import { secretsStore } from "../+config-secrets/secrets.store";
import { namespaceStore } from "../+namespaces/namespace.store"; import { namespaceStore } from "../+namespaces/namespace.store";
import { Notifications } from "../notifications"; import { Notifications } from "../notifications";
import { ClusterContext } from "../context";
@autobind() @autobind()
export class ReleaseStore extends ItemStore<HelmRelease> { export class ReleaseStore extends ItemStore<HelmRelease> {
@observable static defaultContext: ClusterContext; // TODO: support multiple cluster contexts
releaseSecrets = observable.map<string, Secret>(); releaseSecrets = observable.map<string, Secret>();
contextReady = when(() => Boolean(this.context));
get context(): ClusterContext {
return ReleaseStore.defaultContext;
}
constructor() { constructor() {
super(); super();
when(() => secretsStore.isLoaded, () => { when(() => secretsStore.isLoaded, () => {
@ -36,7 +44,7 @@ export class ReleaseStore extends ItemStore<HelmRelease> {
} }
watchSelecteNamespaces(): (() => void) { watchSelecteNamespaces(): (() => void) {
return reaction(() => namespaceStore.context.contextNamespaces, namespaces => { return reaction(() => namespaceStore.selectedNamespaces, namespaces => {
this.loadAll(namespaces); this.loadAll(namespaces);
}); });
} }
@ -79,15 +87,13 @@ export class ReleaseStore extends ItemStore<HelmRelease> {
} }
async loadFromContextNamespaces(): Promise<void> { async loadFromContextNamespaces(): Promise<void> {
return this.loadAll(namespaceStore.context.contextNamespaces); return this.loadAll(namespaceStore.selectedNamespaces);
} }
async loadItems(namespaces: string[]) { async loadItems(namespaces: string[]) {
const isLoadingAll = namespaceStore.context.allNamespaces?.length > 1 await this.contextReady;
&& namespaceStore.context.cluster.accessibleNamespaces.length === 0
&& namespaceStore.context.allNamespaces.every(ns => namespaces.includes(ns));
if (isLoadingAll) { if (this.context.isAllPossibleNamespaces(namespaces)) {
return listReleases(); return listReleases();
} }

View File

@ -13,11 +13,14 @@ import { namespaceStore } from "./namespace.store";
const Placeholder = observer((props: PlaceholderProps<any>) => { const Placeholder = observer((props: PlaceholderProps<any>) => {
const getPlaceholder = (): React.ReactNode => { const getPlaceholder = (): React.ReactNode => {
const namespaces = namespaceStore.contextNamespaces; const namespaces = namespaceStore.selectedNamespaces;
if (namespaceStore.selectedAll) {
return <>All namespaces</>;
}
switch (namespaces.length) { switch (namespaces.length) {
case 0: case 0:
case namespaceStore.allowedNamespaces.length:
return <>All namespaces</>; return <>All namespaces</>;
case 1: case 1:
return <>Namespace: {namespaces[0]}</>; return <>Namespace: {namespaces[0]}</>;

View File

@ -11,17 +11,32 @@ import { kubeWatchApi } from "../../api/kube-watch-api";
import { components, ValueContainerProps } from "react-select"; import { components, ValueContainerProps } from "react-select";
interface Props extends SelectProps { interface Props extends SelectProps {
/**
* Show icons preceeding the entry names
* @default true
*/
showIcons?: boolean; showIcons?: boolean;
showClusterOption?: boolean; // show "Cluster" option on the top (default: false)
showAllNamespacesOption?: boolean; // show "All namespaces" option on the top (default: false) /**
* show a "Cluster" option above all namespaces
* @default false
*/
showClusterOption?: boolean;
/**
* show "All namespaces" option on the top (has precedence over `showClusterOption`)
* @default false
*/
showAllNamespacesOption?: boolean;
/**
* A function to change the options for the select
* @param options the current options to display
* @default passthrough
*/
customizeOptions?(options: SelectOption[]): SelectOption[]; customizeOptions?(options: SelectOption[]): SelectOption[];
} }
const defaultProps: Partial<Props> = {
showIcons: true,
showClusterOption: false,
};
function GradientValueContainer<T>({children, ...rest}: ValueContainerProps<T>) { function GradientValueContainer<T>({children, ...rest}: ValueContainerProps<T>) {
return ( return (
<components.ValueContainer {...rest}> <components.ValueContainer {...rest}>
@ -34,7 +49,12 @@ function GradientValueContainer<T>({children, ...rest}: ValueContainerProps<T>)
@observer @observer
export class NamespaceSelect extends React.Component<Props> { export class NamespaceSelect extends React.Component<Props> {
static defaultProps = defaultProps as object; static defaultProps: Props = {
showIcons: true,
showClusterOption: false,
showAllNamespacesOption: false,
customizeOptions: (opts) => opts,
};
componentDidMount() { componentDidMount() {
disposeOnUnmount(this, [ disposeOnUnmount(this, [
@ -47,7 +67,7 @@ export class NamespaceSelect extends React.Component<Props> {
@computed.struct get options(): SelectOption[] { @computed.struct get options(): SelectOption[] {
const { customizeOptions, showClusterOption, showAllNamespacesOption } = this.props; const { customizeOptions, showClusterOption, showAllNamespacesOption } = this.props;
let options: SelectOption[] = namespaceStore.items.map(ns => ({ value: ns.getName() })); const options: SelectOption[] = namespaceStore.allowedNamespaces.map(ns => ({ value: ns }));
if (showAllNamespacesOption) { if (showAllNamespacesOption) {
options.unshift({ label: "All Namespaces", value: "" }); options.unshift({ label: "All Namespaces", value: "" });
@ -55,11 +75,7 @@ export class NamespaceSelect extends React.Component<Props> {
options.unshift({ label: "Cluster", value: "" }); options.unshift({ label: "Cluster", value: "" });
} }
if (customizeOptions) { return customizeOptions(options);
options = customizeOptions(options);
}
return options;
} }
formatOptionLabel = (option: SelectOption) => { formatOptionLabel = (option: SelectOption) => {

View File

@ -31,7 +31,14 @@ export function getDummyNamespace(name: string) {
export class NamespaceStore extends KubeObjectStore<Namespace> { export class NamespaceStore extends KubeObjectStore<Namespace> {
api = namespacesApi; api = namespacesApi;
@observable private contextNs = observable.set<string>(); @observable private rawSelectedNamespaces = observable.set<string>();
/**
* @depreated use `NamespaceStore.rawSelectedNamespaces` instead
*/
get contextNs() {
return this.rawSelectedNamespaces;
}
constructor() { constructor() {
super(); super();
@ -48,7 +55,7 @@ export class NamespaceStore extends KubeObjectStore<Namespace> {
} }
public onContextChange(callback: (contextNamespaces: string[]) => void, opts: IReactionOptions = {}): IReactionDisposer { public onContextChange(callback: (contextNamespaces: string[]) => void, opts: IReactionOptions = {}): IReactionDisposer {
return reaction(() => Array.from(this.contextNs), callback, { return reaction(() => Array.from(this.rawSelectedNamespaces), callback, {
equals: comparer.shallow, equals: comparer.shallow,
...opts, ...opts,
}); });
@ -89,6 +96,16 @@ export class NamespaceStore extends KubeObjectStore<Namespace> {
return []; return [];
} }
/**
* @deprecated use `NamespaceStore.allowedNamespaces` instead
*/
get contextNamespaces() {
return this.allowedNamespaces;
}
/**
* The array of namespace names that this store knows about
*/
@computed get allowedNamespaces(): string[] { @computed get allowedNamespaces(): string[] {
return Array.from(new Set([ return Array.from(new Set([
...(this.context?.allNamespaces ?? []), // allowed namespaces from cluster (main), updating every 30s ...(this.context?.allNamespaces ?? []), // allowed namespaces from cluster (main), updating every 30s
@ -96,8 +113,8 @@ export class NamespaceStore extends KubeObjectStore<Namespace> {
].flat())); ].flat()));
} }
@computed get contextNamespaces(): string[] { @computed get selectedNamespaces(): string[] {
const namespaces = Array.from(this.contextNs); const namespaces = Array.from(this.rawSelectedNamespaces);
if (!namespaces.length) { if (!namespaces.length) {
return this.allowedNamespaces; // show all namespaces when nothing selected return this.allowedNamespaces; // show all namespaces when nothing selected
@ -133,28 +150,27 @@ export class NamespaceStore extends KubeObjectStore<Namespace> {
setContext(namespace: string | string[]) { setContext(namespace: string | string[]) {
const namespaces = [namespace].flat(); const namespaces = [namespace].flat();
this.contextNs.replace(namespaces); this.rawSelectedNamespaces.replace(namespaces);
}
@action
resetContext() {
this.contextNs.clear();
} }
hasContext(namespaces: string | string[]) { hasContext(namespaces: string | string[]) {
return [namespaces].flat().every(namespace => this.contextNs.has(namespace)); return [namespaces].flat().every(namespace => this.rawSelectedNamespaces.has(namespace));
}
@computed get selectedAll(): boolean {
return this.context?.isAllPossibleNamespaces(Array.from(this.rawSelectedNamespaces), true) ?? false;
} }
@computed get hasAllContexts(): boolean { @computed get hasAllContexts(): boolean {
return this.contextNs.size === this.allowedNamespaces.length; return this.rawSelectedNamespaces.size === this.allowedNamespaces.length;
} }
@action @action
toggleContext(namespace: string) { toggleContext(namespace: string) {
if (this.hasContext(namespace)) { if (this.hasContext(namespace)) {
this.contextNs.delete(namespace); this.rawSelectedNamespaces.delete(namespace);
} else { } else {
this.contextNs.add(namespace); this.rawSelectedNamespaces.add(namespace);
} }
} }
@ -164,7 +180,7 @@ export class NamespaceStore extends KubeObjectStore<Namespace> {
if (showAll) { if (showAll) {
this.setContext(this.allowedNamespaces); this.setContext(this.allowedNamespaces);
} else { } else {
this.resetContext(); // empty context considered as "All namespaces" this.rawSelectedNamespaces.clear();
} }
} else { } else {
this.toggleAll(!this.hasAllContexts); this.toggleAll(!this.hasAllContexts);
@ -174,7 +190,7 @@ export class NamespaceStore extends KubeObjectStore<Namespace> {
@action @action
async remove(item: Namespace) { async remove(item: Namespace) {
await super.remove(item); await super.remove(item);
this.contextNs.delete(item.getName()); this.rawSelectedNamespaces.delete(item.getName());
} }
} }

View File

@ -26,7 +26,7 @@ export class OverviewStatuses extends React.Component {
@autobind() @autobind()
renderWorkload(resource: KubeResource): React.ReactElement { renderWorkload(resource: KubeResource): React.ReactElement {
const store = workloadStores[resource]; const store = workloadStores[resource];
const items = store.getAllByNs(namespaceStore.contextNamespaces); const items = store.getAllByNs(namespaceStore.selectedNamespaces);
return ( return (
<div className="workload" key={resource}> <div className="workload" key={resource}>

View File

@ -16,13 +16,16 @@ import { cronJobStore } from "../+workloads-cronjobs/cronjob.store";
import { Events } from "../+events"; import { Events } from "../+events";
import { isAllowedResource } from "../../../common/rbac"; import { isAllowedResource } from "../../../common/rbac";
import { kubeWatchApi } from "../../api/kube-watch-api"; import { kubeWatchApi } from "../../api/kube-watch-api";
import { clusterContext } from "../context"; import { observable } from "mobx";
import { ClusterContext } from "../context";
interface Props extends RouteComponentProps<IWorkloadsOverviewRouteParams> { interface Props extends RouteComponentProps<IWorkloadsOverviewRouteParams> {
} }
@observer @observer
export class WorkloadsOverview extends React.Component<Props> { export class WorkloadsOverview extends React.Component<Props> {
@observable static defaultContext: ClusterContext; // TODO: support multiple cluster contexts
componentDidMount() { componentDidMount() {
disposeOnUnmount(this, [ disposeOnUnmount(this, [
kubeWatchApi.subscribeStores([ kubeWatchApi.subscribeStores([
@ -30,7 +33,7 @@ export class WorkloadsOverview extends React.Component<Props> {
jobStore, cronJobStore, eventStore, jobStore, cronJobStore, eventStore,
], { ], {
preload: true, preload: true,
namespaces: clusterContext.contextNamespaces, namespaces: WorkloadsOverview.defaultContext.selectedNamespaces,
}), }),
]); ]);
} }

View File

@ -49,7 +49,9 @@ import { kubeWatchApi } from "../api/kube-watch-api";
import { ReplicaSetScaleDialog } from "./+workloads-replicasets/replicaset-scale-dialog"; import { ReplicaSetScaleDialog } from "./+workloads-replicasets/replicaset-scale-dialog";
import { CommandContainer } from "./command-palette/command-container"; import { CommandContainer } from "./command-palette/command-container";
import { KubeObjectStore } from "../kube-object.store"; import { KubeObjectStore } from "../kube-object.store";
import { clusterContext } from "./context"; import { ReleaseStore } from "./+apps-releases/release.store";
import { ClusterContext } from "./context";
import { WorkloadsOverview } from "./+workloads-overview/overview";
@observer @observer
export class App extends React.Component { export class App extends React.Component {
@ -78,8 +80,11 @@ export class App extends React.Component {
whatInput.ask(); // Start to monitor user input device whatInput.ask(); // Start to monitor user input device
// Setup hosted cluster context // Setup hosted cluster context
KubeObjectStore.defaultContext = clusterContext; KubeObjectStore.defaultContext
kubeWatchApi.context = clusterContext; = ReleaseStore.defaultContext
= WorkloadsOverview.defaultContext
= kubeWatchApi.context
= new ClusterContext();
} }
componentDidMount() { componentDidMount() {

View File

@ -2,16 +2,10 @@ import type { Cluster } from "../../main/cluster";
import { getHostedCluster } from "../../common/cluster-store"; import { getHostedCluster } from "../../common/cluster-store";
import { namespaceStore } from "./+namespaces/namespace.store"; import { namespaceStore } from "./+namespaces/namespace.store";
export interface ClusterContext { export class ClusterContext {
cluster?: Cluster;
allNamespaces: string[]; // available / allowed namespaces from cluster.ts
contextNamespaces: string[]; // selected by user (see: namespace-select.tsx)
}
export const clusterContext: ClusterContext = {
get cluster(): Cluster | null { get cluster(): Cluster | null {
return getHostedCluster(); return getHostedCluster() ?? null;
}, }
get allNamespaces(): string[] { get allNamespaces(): string[] {
if (!this.cluster) { if (!this.cluster) {
@ -30,9 +24,25 @@ export const clusterContext: ClusterContext = {
// fallback to cluster resolved namespaces because we could not load list // fallback to cluster resolved namespaces because we could not load list
return this.cluster.allowedNamespaces || []; return this.cluster.allowedNamespaces || [];
} }
}, }
get contextNamespaces(): string[] { /**
return namespaceStore.contextNamespaces ?? []; * This function returns true if the list of namespaces provided is the
}, * same as all the namespaces that exist (for certain) on the cluster
}; * @param namespaces The list of namespaces to check
*/
public isAllPossibleNamespaces(namespaceList: string[], isFilterSelect = false): boolean {
const namespaces = new Set(namespaceList);
return this.allNamespaces.length > 1
&& this.cluster.accessibleNamespaces.length === 0
&& (
(isFilterSelect && namespaces.size === 0)
|| this.allNamespaces.every(ns => namespaces.has(ns))
);
}
get selectedNamespaces(): string[] {
return namespaceStore.selectedNamespaces ?? [];
}
}

View File

@ -137,7 +137,7 @@ export class ItemListLayout extends React.Component<ItemListLayoutProps> {
const stores = Array.from(new Set([store, ...dependentStores])); const stores = Array.from(new Set([store, ...dependentStores]));
// load context namespaces by default (see also: `<NamespaceSelectFilter/>`) // load context namespaces by default (see also: `<NamespaceSelectFilter/>`)
stores.forEach(store => store.loadAll(namespaceStore.contextNamespaces)); stores.forEach(store => store.loadAll(namespaceStore.selectedNamespaces));
} }
private filterCallbacks: { [type: string]: ItemsFilter } = { private filterCallbacks: { [type: string]: ItemsFilter } = {

View File

@ -8,7 +8,6 @@ import { KubeObjectStore } from "../../kube-object.store";
import { KubeObjectMenu } from "./kube-object-menu"; import { KubeObjectMenu } from "./kube-object-menu";
import { kubeSelectedUrlParam, showDetails } from "./kube-object-details"; import { kubeSelectedUrlParam, showDetails } from "./kube-object-details";
import { kubeWatchApi } from "../../api/kube-watch-api"; import { kubeWatchApi } from "../../api/kube-watch-api";
import { clusterContext } from "../context";
export interface KubeObjectListLayoutProps extends ItemListLayoutProps { export interface KubeObjectListLayoutProps extends ItemListLayoutProps {
store: KubeObjectStore; store: KubeObjectStore;
@ -34,7 +33,7 @@ export class KubeObjectListLayout extends React.Component<KubeObjectListLayoutPr
disposeOnUnmount(this, [ disposeOnUnmount(this, [
kubeWatchApi.subscribeStores(stores, { kubeWatchApi.subscribeStores(stores, {
preload: true, preload: true,
namespaces: clusterContext.contextNamespaces, namespaces: store.context.selectedNamespaces,
}) })
]); ]);
} }

View File

@ -35,7 +35,7 @@ export abstract class KubeObjectStore<T extends KubeObject = any> extends ItemSt
} }
@computed get contextItems(): T[] { @computed get contextItems(): T[] {
const namespaces = this.context?.contextNamespaces ?? []; const namespaces = this.context?.selectedNamespaces ?? [];
return this.items.filter(item => { return this.items.filter(item => {
const itemNamespace = item.getNs(); const itemNamespace = item.getNs();
@ -109,11 +109,7 @@ export abstract class KubeObjectStore<T extends KubeObject = any> extends ItemSt
return api.list({}, this.query); return api.list({}, this.query);
} }
const isLoadingAll = this.context.allNamespaces?.length > 1 if (this.context.isAllPossibleNamespaces(namespaces)) {
&& this.context.cluster.accessibleNamespaces.length === 0
&& this.context.allNamespaces.every(ns => namespaces.includes(ns));
if (isLoadingAll) {
this.loadedNamespaces = []; this.loadedNamespaces = [];
return api.list({}, this.query); return api.list({}, this.query);