diff --git a/src/common/utils/hash-set.ts b/src/common/utils/hash-set.ts new file mode 100644 index 0000000000..c19bf14685 --- /dev/null +++ b/src/common/utils/hash-set.ts @@ -0,0 +1,147 @@ +/** + * 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. + */ + +import { action, IInterceptable, IInterceptor, IListenable, ISetWillChange, observable, ObservableMap, ObservableSet } from "mobx"; + +export * from "./hashers"; + +export function makeIterable(iterator: Iterator): IterableIterator { + (iterator as IterableIterator)[Symbol.iterator] = () => iterator as IterableIterator; + + return iterator as IterableIterator; +} + +export class ObservableHashSet implements Set, IInterceptable, IListenable { + #hashmap: ObservableMap; + + get interceptors_(): IInterceptor>[] { + return []; + } + + get changeListeners_(): Function[] { + return []; + } + + constructor(initialValues: Iterable, protected hasher: (item: T) => string) { + this.#hashmap = observable.map(Array.from(initialValues, value => [this.hasher(value), value]), undefined); + } + + @action + replace(other: ObservableHashSet | ObservableSet | Set | readonly T[]): this { + if (Array.isArray(other)) { + this.clear(); + other.forEach(value => this.add(value)); + } else if (other instanceof Set || other instanceof ObservableHashSet || other instanceof ObservableSet) { + this.clear(); + other.forEach(value => this.add(value)); + } else if (other !== null && other !== undefined) { + throw new Error(`ObservableHashSet: Cannot initialize set from ${other}`); + } + + return this; + } + + clear(): void { + this.#hashmap.clear(); + } + + add(value: T): this { + this.#hashmap.set(this.hasher(value), value); + + return this; + } + + @action + toggle(value: T) { + const hash = this.hasher(value); + + if (this.#hashmap.has(hash)) { + this.#hashmap.delete(hash); + } else { + this.#hashmap.set(hash, value); + } + } + + delete(value: T): boolean { + return this.#hashmap.delete(this.hasher(value)); + } + + forEach(callbackfn: (value: T, key: T, set: Set) => void, thisArg?: any): void { + this.#hashmap.forEach(value => callbackfn(value, value, thisArg ?? this)); + } + + has(value: T): boolean { + return this.#hashmap.has(this.hasher(value)); + } + + get size(): number { + return this.#hashmap.size; + } + + entries(): IterableIterator<[T, T]> { + let nextIndex = 0; + const keys = Array.from(this.keys()); + const values = Array.from(this.values()); + + return makeIterable<[T, T]>({ + next() { + const index = nextIndex++; + + return index < values.length + ? { value: [keys[index], values[index]], done: false } + : { done: true, value: undefined }; + } + }); + } + + keys(): IterableIterator { + return this.values(); + } + + values(): IterableIterator { + let nextIndex = 0; + const observableValues = Array.from(this.#hashmap.values()); + + return makeIterable({ + next: () => { + return nextIndex < observableValues.length + ? { value: observableValues[nextIndex++], done: false } + : { done: true, value: undefined }; + } + }); + } + + [Symbol.iterator](): IterableIterator { + return this.#hashmap.values(); + } + + get [Symbol.toStringTag](): string { + return "Set"; + } + + toJSON(): T[] { + return Array.from(this); + } + + toString(): string { + return "[object ObservableSet]"; + } +} diff --git a/src/renderer/components/+user-management-service-accounts/index.ts b/src/common/utils/hashers.ts similarity index 68% rename from src/renderer/components/+user-management-service-accounts/index.ts rename to src/common/utils/hashers.ts index 7c6de338fa..b815a73a00 100644 --- a/src/renderer/components/+user-management-service-accounts/index.ts +++ b/src/common/utils/hashers.ts @@ -19,6 +19,19 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -export * from "./service-accounts"; -export * from "./service-accounts-details"; -export * from "./create-service-account-dialog"; +import { MD5 } from "crypto-js"; +import type { RoleBindingSubject } from "../../renderer/api/endpoints"; +import type { KubeObject } from "../../renderer/api/kube-object"; + +export function hashKubeObject(k: KubeObject): string { + return k.metadata.uid; +} + +export function hashRoleBindingSubject(subject: RoleBindingSubject): string { + return MD5(JSON.stringify([ + ["kind", subject.kind], + ["name", subject.name], + ["namespace", subject.namespace], + ["apiGroup", subject.apiGroup], + ])).toString(); +} diff --git a/src/common/utils/index.ts b/src/common/utils/index.ts index b1980d3f79..1c2f4c888f 100644 --- a/src/common/utils/index.ts +++ b/src/common/utils/index.ts @@ -27,17 +27,17 @@ export * from "./app-version"; export * from "./autobind"; export * from "./base64"; export * from "./camelCase"; -export * from "./toJS"; export * from "./cloneJson"; export * from "./debouncePromise"; export * from "./defineGlobal"; export * from "./delay"; export * from "./disposer"; -export * from "./disposer"; export * from "./downloadFile"; export * from "./escapeRegExp"; export * from "./extended-map"; export * from "./getRandId"; +export * from "./hash-set"; +export * from "./n-fircate"; export * from "./openExternal"; export * from "./reject-promise"; export * from "./saveToAppFiles"; @@ -45,6 +45,7 @@ export * from "./singleton"; export * from "./splitArray"; export * from "./tar"; export * from "./toggle-set"; +export * from "./toJS"; export * from "./type-narrowing"; import * as iter from "./iter"; diff --git a/src/common/utils/n-fircate.ts b/src/common/utils/n-fircate.ts new file mode 100644 index 0000000000..5a6350c710 --- /dev/null +++ b/src/common/utils/n-fircate.ts @@ -0,0 +1,47 @@ +/** + * 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. + */ + +/** + * Split an iterable into several arrays with matching fields + * @param from The iterable of items to split up + * @param field The field of each item to split over + * @param parts What each array will be filtered to + * @returns A `parts.length` tuple of `T[]` where each array has matching `field` values + */ +export function nFircate(from: Iterable, field: keyof T, parts: [T[typeof field]]): [T[]]; +export function nFircate(from: Iterable, field: keyof T, parts: [T[typeof field], T[typeof field]]): [T[], T[]]; +export function nFircate(from: Iterable, field: keyof T, parts: [T[typeof field], T[typeof field], T[typeof field]]): [T[], T[], T[]]; + +export function nFircate(from: Iterable, field: keyof T, parts: T[typeof field][]): T[][] { + const res = Array.from(parts, () => [] as T[]); + + for (const item of from) { + const index = parts.indexOf(item[field]); + + if (index < 0) { + continue; + } + + res[index].push(item); + } + + return res; +} diff --git a/src/extensions/renderer-api/k8s-api.ts b/src/extensions/renderer-api/k8s-api.ts index d16dbe83f5..5e939bc54d 100644 --- a/src/extensions/renderer-api/k8s-api.ts +++ b/src/extensions/renderer-api/k8s-api.ts @@ -86,8 +86,8 @@ export type { PersistentVolumesStore } from "../../renderer/components/+storage- export type { VolumeClaimStore } from "../../renderer/components/+storage-volume-claims/volume-claim.store"; export type { StorageClassStore } from "../../renderer/components/+storage-classes/storage-class.store"; export type { NamespaceStore } from "../../renderer/components/+namespaces/namespace.store"; -export type { ServiceAccountsStore } from "../../renderer/components/+user-management-service-accounts/service-accounts.store"; -export type { RolesStore } from "../../renderer/components/+user-management-roles/roles.store"; -export type { RoleBindingsStore } from "../../renderer/components/+user-management-roles-bindings/role-bindings.store"; +export type { ServiceAccountsStore } from "../../renderer/components/+user-management/+service-accounts/store"; +export type { RolesStore } from "../../renderer/components/+user-management/+roles/store"; +export type { RoleBindingsStore } from "../../renderer/components/+user-management/+roles-bindings/store"; export type { CRDStore } from "../../renderer/components/+custom-resources/crd.store"; export type { CRDResourceStore } from "../../renderer/components/+custom-resources/crd-resource.store"; diff --git a/src/renderer/api/endpoints/cluster-role-binding.api.ts b/src/renderer/api/endpoints/cluster-role-binding.api.ts index 6f165316ef..2301e4124d 100644 --- a/src/renderer/api/endpoints/cluster-role-binding.api.ts +++ b/src/renderer/api/endpoints/cluster-role-binding.api.ts @@ -18,14 +18,38 @@ * 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. */ - -import { RoleBinding } from "./role-binding.api"; import { KubeApi } from "../kube-api"; +import { KubeObject } from "../kube-object"; -export class ClusterRoleBinding extends RoleBinding { +export type ClusterRoleBindingSubjectKind = "Group" | "ServiceAccount" | "User"; + +export interface ClusterRoleBindingSubject { + kind: ClusterRoleBindingSubjectKind; + name: string; + apiGroup?: string; +} + +export interface ClusterRoleBinding { + subjects?: ClusterRoleBindingSubject[]; + roleRef: { + kind: string; + name: string; + apiGroup?: string; + }; +} + +export class ClusterRoleBinding extends KubeObject { static kind = "ClusterRoleBinding"; static namespaced = false; static apiBase = "/apis/rbac.authorization.k8s.io/v1/clusterrolebindings"; + + getSubjects() { + return this.subjects || []; + } + + getSubjectNames(): string { + return this.getSubjects().map(subject => subject.name).join(", "); + } } export const clusterRoleBindingApi = new KubeApi({ diff --git a/src/renderer/api/endpoints/cluster-role.api.ts b/src/renderer/api/endpoints/cluster-role.api.ts index 6471b9c5e1..55a2f5283d 100644 --- a/src/renderer/api/endpoints/cluster-role.api.ts +++ b/src/renderer/api/endpoints/cluster-role.api.ts @@ -19,13 +19,26 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import { Role } from "./role.api"; import { KubeApi } from "../kube-api"; +import { KubeObject } from "../kube-object"; -export class ClusterRole extends Role { +export interface ClusterRole { + rules: { + verbs: string[]; + apiGroups: string[]; + resources: string[]; + resourceNames?: string[]; + }[]; +} + +export class ClusterRole extends KubeObject { static kind = "ClusterRole"; static namespaced = false; static apiBase = "/apis/rbac.authorization.k8s.io/v1/clusterroles"; + + getRules() { + return this.rules || []; + } } export const clusterRoleApi = new KubeApi({ diff --git a/src/renderer/api/endpoints/role-binding.api.ts b/src/renderer/api/endpoints/role-binding.api.ts index 4e53752df1..270495142e 100644 --- a/src/renderer/api/endpoints/role-binding.api.ts +++ b/src/renderer/api/endpoints/role-binding.api.ts @@ -24,15 +24,17 @@ import { KubeObject } from "../kube-object"; import { KubeApi } from "../kube-api"; import type { KubeJsonApiData } from "../kube-json-api"; -export interface IRoleBindingSubject { - kind: string; +export type RoleBindingSubjectKind = "Group" | "ServiceAccount" | "User"; + +export interface RoleBindingSubject { + kind: RoleBindingSubjectKind; name: string; - namespace?: string; + namespace: string; apiGroup?: string; } export interface RoleBinding { - subjects?: IRoleBindingSubject[]; + subjects?: RoleBindingSubject[]; roleRef: { kind: string; name: string; diff --git a/src/renderer/components/+namespaces/namespace-select.tsx b/src/renderer/components/+namespaces/namespace-select.tsx index c35ae72513..c1fe06c258 100644 --- a/src/renderer/components/+namespaces/namespace-select.tsx +++ b/src/renderer/components/+namespaces/namespace-select.tsx @@ -32,14 +32,12 @@ import { kubeWatchApi } from "../../api/kube-watch-api"; interface Props extends SelectProps { showIcons?: boolean; - showClusterOption?: boolean; // show "Cluster" option on the top (default: false) showAllNamespacesOption?: boolean; // show "All namespaces" option on the top (default: false) customizeOptions?(options: SelectOption[]): SelectOption[]; } const defaultProps: Partial = { showIcons: true, - showClusterOption: false, }; @observer @@ -61,13 +59,11 @@ export class NamespaceSelect extends React.Component { } @computed.struct get options(): SelectOption[] { - const { customizeOptions, showClusterOption, showAllNamespacesOption } = this.props; + const { customizeOptions, showAllNamespacesOption } = this.props; let options: SelectOption[] = namespaceStore.items.map(ns => ({ value: ns.getName() })); if (showAllNamespacesOption) { options.unshift({ label: "All Namespaces", value: "" }); - } else if (showClusterOption) { - options.unshift({ label: "Cluster", value: "" }); } if (customizeOptions) { diff --git a/src/renderer/components/+namespaces/namespace.store.ts b/src/renderer/components/+namespaces/namespace.store.ts index 29098c8a49..f865787d76 100644 --- a/src/renderer/components/+namespaces/namespace.store.ts +++ b/src/renderer/components/+namespaces/namespace.store.ts @@ -20,7 +20,7 @@ */ import { action, comparer, computed, IReactionDisposer, IReactionOptions, makeObservable, reaction, } from "mobx"; -import { autoBind, createStorage } from "../../utils"; +import { autoBind, createStorage, noop } from "../../utils"; import { KubeObjectStore, KubeObjectStoreLoadingParams } from "../../kube-object.store"; import { Namespace, namespacesApi } from "../../api/endpoints/namespaces.api"; import { apiManager } from "../../api/api-manager"; @@ -97,13 +97,16 @@ export class NamespaceStore extends KubeObjectStore { return this.selectedNamespaces; } - getSubscribeApis() { - // if user has given static list of namespaces let's not start watches because watch adds stuff that's not wanted + subscribe() { + /** + * if user has given static list of namespaces let's not start watches + * because watch adds stuff that's not wanted or will just fail + */ if (this.context?.cluster.accessibleNamespaces.length > 0) { - return []; + return noop; } - return super.getSubscribeApis(); + return super.subscribe(); } protected async loadItems(params: KubeObjectStoreLoadingParams) { diff --git a/src/renderer/components/+user-management-roles-bindings/add-role-binding-dialog.tsx b/src/renderer/components/+user-management-roles-bindings/add-role-binding-dialog.tsx deleted file mode 100644 index 273a3c0d51..0000000000 --- a/src/renderer/components/+user-management-roles-bindings/add-role-binding-dialog.tsx +++ /dev/null @@ -1,318 +0,0 @@ -/** - * 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. - */ - -import "./add-role-binding-dialog.scss"; - -import React from "react"; -import { computed, observable, makeObservable } from "mobx"; -import { observer } from "mobx-react"; -import { Dialog, DialogProps } from "../dialog"; -import { Wizard, WizardStep } from "../wizard"; -import { Select, SelectOption } from "../select"; -import { SubTitle } from "../layout/sub-title"; -import type { IRoleBindingSubject, Role, RoleBinding, ServiceAccount } from "../../api/endpoints"; -import { Icon } from "../icon"; -import { Input } from "../input"; -import { NamespaceSelect } from "../+namespaces/namespace-select"; -import { Checkbox } from "../checkbox"; -import { KubeObject } from "../../api/kube-object"; -import { Notifications } from "../notifications"; -import { rolesStore } from "../+user-management-roles/roles.store"; -import { namespaceStore } from "../+namespaces/namespace.store"; -import { serviceAccountsStore } from "../+user-management-service-accounts/service-accounts.store"; -import { roleBindingsStore } from "./role-bindings.store"; -import { showDetails } from "../kube-object"; -import type { KubeObjectStore } from "../../kube-object.store"; - -interface BindingSelectOption extends SelectOption { - value: string; // binding name - item?: ServiceAccount | any; - subject?: IRoleBindingSubject; // used for new user/group when users-management-api not available -} - -interface Props extends Partial { -} - -const dialogState = observable.object({ - isOpen: false, - data: null as RoleBinding, -}); - -@observer -export class AddRoleBindingDialog extends React.Component { - constructor(props: Props) { - super(props); - makeObservable(this); - } - - static open(roleBinding?: RoleBinding) { - dialogState.isOpen = true; - dialogState.data = roleBinding; - } - - static close() { - dialogState.isOpen = false; - } - - get roleBinding(): RoleBinding { - return dialogState.data; - } - - @observable isLoading = false; - @observable selectedRoleId = ""; - @observable useRoleForBindingName = true; - @observable bindingName = ""; // new role-binding name - @observable bindContext = ""; // empty value means "cluster-wide", otherwise bind to namespace - @observable selectedAccounts = observable.array([], { deep: false }); - - @computed get isEditing() { - return !!this.roleBinding; - } - - @computed get selectedRole() { - return rolesStore.items.find(role => role.getId() === this.selectedRoleId); - } - - @computed get selectedBindings() { - return [ - ...this.selectedAccounts, - ]; - } - - close = () => { - AddRoleBindingDialog.close(); - }; - - async loadData() { - const stores: KubeObjectStore[] = [ - namespaceStore, - rolesStore, - serviceAccountsStore, - ]; - - this.isLoading = true; - await Promise.all(stores.map(store => store.reloadAll())); - this.isLoading = false; - } - - onOpen = async () => { - await this.loadData(); - - if (this.roleBinding) { - const { name, kind } = this.roleBinding.roleRef; - const role = rolesStore.items.find(role => role.kind === kind && role.getName() === name); - - if (role) { - this.selectedRoleId = role.getId(); - this.bindContext = role.getNs() || ""; - } - } - }; - - reset = () => { - this.selectedRoleId = ""; - this.bindContext = ""; - this.selectedAccounts.clear(); - }; - - onBindContextChange = (namespace: string) => { - this.bindContext = namespace; - const roleContext = this.selectedRole && this.selectedRole.getNs() || ""; - - if (this.bindContext && this.bindContext !== roleContext) { - this.selectedRoleId = ""; // reset previously selected role for specific context - } - }; - - createBindings = async () => { - const { selectedRole, bindContext: namespace, selectedBindings, bindingName, useRoleForBindingName } = this; - - const subjects = selectedBindings.map((item: KubeObject | IRoleBindingSubject) => { - if (item instanceof KubeObject) { - return { - name: item.getName(), - kind: item.kind, - namespace: item.getNs(), - }; - } - - return item; - }); - - try { - let roleBinding: RoleBinding; - - if (this.isEditing) { - roleBinding = await roleBindingsStore.updateSubjects({ - roleBinding: this.roleBinding, - addSubjects: subjects, - }); - } else { - const name = useRoleForBindingName ? selectedRole.getName() : bindingName; - - roleBinding = await roleBindingsStore.create({ name, namespace }, { - subjects, - roleRef: { - name: selectedRole.getName(), - kind: selectedRole.kind, - } - }); - } - showDetails(roleBinding.selfLink); - this.close(); - } catch (err) { - Notifications.error(err); - } - }; - - @computed get roleOptions(): BindingSelectOption[] { - let roles = rolesStore.items as Role[]; - - if (this.bindContext) { - // show only cluster-roles or roles for selected context namespace - roles = roles.filter(role => !role.getNs() || role.getNs() === this.bindContext); - } - - return roles.map(role => { - const name = role.getName(); - const namespace = role.getNs(); - - return { - value: role.getId(), - label: name + (namespace ? ` (${namespace})` : "") - }; - }); - } - - @computed get serviceAccountOptions(): BindingSelectOption[] { - return serviceAccountsStore.items.map(account => { - const name = account.getName(); - const namespace = account.getNs(); - - return { - item: account, - value: name, - label: <> {name} ({namespace}) - }; - }); - } - - renderContents() { - const unwrapBindings = (options: BindingSelectOption[]) => options.map(option => option.item || option.subject); - - return ( - <> - - this.onBindContextChange(value)} - /> - - - this.bindingName = v} - /> - ) - } - - ) - } - - - this.selectedRoleRef = value} + /> + + + + Users + this.selectedUsers.add(newUser)} + items={Array.from(this.selectedUsers)} + remove={({ oldItem }) => this.selectedUsers.delete(oldItem)} + /> + + Groups + this.selectedGroups.add(newGroup)} + items={Array.from(this.selectedGroups)} + remove={({ oldItem }) => this.selectedGroups.delete(oldItem)} + /> + + Service Accounts + this.clusterRoleName = v} + /> + + + + ); + } +} diff --git a/src/renderer/components/+user-management-roles/role-details.scss b/src/renderer/components/+user-management/+cluster-roles/details.scss similarity index 100% rename from src/renderer/components/+user-management-roles/role-details.scss rename to src/renderer/components/+user-management/+cluster-roles/details.scss diff --git a/src/renderer/components/+user-management/+cluster-roles/details.tsx b/src/renderer/components/+user-management/+cluster-roles/details.tsx new file mode 100644 index 0000000000..52bee8dc8e --- /dev/null +++ b/src/renderer/components/+user-management/+cluster-roles/details.tsx @@ -0,0 +1,104 @@ +/** + * 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. + */ + +import "./details.scss"; + +import { observer } from "mobx-react"; +import React from "react"; + +import { KubeEventDetails } from "../../+events/kube-event-details"; +import { kubeObjectDetailRegistry } from "../../../api/kube-object-detail-registry"; +import { DrawerTitle } from "../../drawer"; +import type { KubeObjectDetailsProps } from "../../kube-object"; +import { KubeObjectMeta } from "../../kube-object/kube-object-meta"; +import type { ClusterRole } from "../../../api/endpoints"; + +interface Props extends KubeObjectDetailsProps { +} + +@observer +export class ClusterRoleDetails extends React.Component { + render() { + const { object: clusterRole } = this.props; + + if (!clusterRole) return null; + const rules = clusterRole.getRules(); + + return ( +
+ + + + {rules.map(({ resourceNames, apiGroups, resources, verbs }, index) => { + return ( +
+ {resources && ( + <> +
Resources
+
{resources.join(", ")}
+ + )} + {verbs && ( + <> +
Verbs
+
{verbs.join(", ")}
+ + )} + {apiGroups && ( + <> +
Api Groups
+
+ {apiGroups + .map(apiGroup => apiGroup === "" ? `'${apiGroup}'` : apiGroup) + .join(", ") + } +
+ + )} + {resourceNames && ( + <> +
Resource Names
+
{resourceNames.join(", ")}
+ + )} +
+ ); + })} +
+ ); + } +} + +kubeObjectDetailRegistry.add({ + kind: "ClusterRole", + apiVersions: ["rbac.authorization.k8s.io/v1"], + components: { + Details: (props) => + } +}); +kubeObjectDetailRegistry.add({ + kind: "ClusterRole", + apiVersions: ["rbac.authorization.k8s.io/v1"], + priority: 5, + components: { + Details: (props) => + } +}); diff --git a/src/renderer/components/+user-management-roles-bindings/index.ts b/src/renderer/components/+user-management/+cluster-roles/index.ts similarity index 90% rename from src/renderer/components/+user-management-roles-bindings/index.ts rename to src/renderer/components/+user-management/+cluster-roles/index.ts index ca08a30691..e45d58073c 100644 --- a/src/renderer/components/+user-management-roles-bindings/index.ts +++ b/src/renderer/components/+user-management/+cluster-roles/index.ts @@ -18,7 +18,6 @@ * 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. */ - -export * from "./role-bindings"; -export * from "./role-binding-details"; -export * from "./add-role-binding-dialog"; +export * from "./view"; +export * from "./details"; +export * from "./add-dialog"; diff --git a/src/renderer/components/+user-management/+cluster-roles/store.ts b/src/renderer/components/+user-management/+cluster-roles/store.ts new file mode 100644 index 0000000000..6fe25a2c42 --- /dev/null +++ b/src/renderer/components/+user-management/+cluster-roles/store.ts @@ -0,0 +1,44 @@ +/** + * 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. + */ +import { apiManager } from "../../../api/api-manager"; +import { ClusterRole, clusterRoleApi } from "../../../api/endpoints"; +import { KubeObjectStore } from "../../../kube-object.store"; +import { autoBind } from "../../../utils"; + +export class ClusterRolesStore extends KubeObjectStore { + api = clusterRoleApi; + + constructor() { + super(); + autoBind(this); + } + + protected sortItems(items: ClusterRole[]) { + return super.sortItems(items, [ + clusterRole => clusterRole.kind, + clusterRole => clusterRole.getName(), + ]); + } +} + +export const clusterRolesStore = new ClusterRolesStore(); + +apiManager.registerStore(clusterRolesStore); diff --git a/src/renderer/components/+user-management/+cluster-roles/view.scss b/src/renderer/components/+user-management/+cluster-roles/view.scss new file mode 100644 index 0000000000..2225c17a06 --- /dev/null +++ b/src/renderer/components/+user-management/+cluster-roles/view.scss @@ -0,0 +1,11 @@ +.ClusterRoles { + .help-icon { + margin-left: $margin / 2; + } + + .TableCell { + &.warning { + @include table-cell-warning; + } + } +} diff --git a/src/renderer/components/+user-management/+cluster-roles/view.tsx b/src/renderer/components/+user-management/+cluster-roles/view.tsx new file mode 100644 index 0000000000..e89e66afd4 --- /dev/null +++ b/src/renderer/components/+user-management/+cluster-roles/view.tsx @@ -0,0 +1,81 @@ +/** + * 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. + */ + +import "./view.scss"; + +import { observer } from "mobx-react"; +import React from "react"; +import type { RouteComponentProps } from "react-router"; +import type { ClusterRole } from "../../../api/endpoints"; +import { KubeObjectListLayout } from "../../kube-object"; +import { KubeObjectStatusIcon } from "../../kube-object-status-icon"; +import type { ClusterRolesRouteParams } from "../user-management.route"; +import { AddClusterRoleDialog } from "./add-dialog"; +import { clusterRolesStore } from "./store"; + +enum columnId { + name = "name", + namespace = "namespace", + age = "age", +} + +interface Props extends RouteComponentProps { +} + +@observer +export class ClusterRoles extends React.Component { + render() { + return ( + <> + clusterRole.getName(), + [columnId.age]: (clusterRole: ClusterRole) => clusterRole.getTimeDiffFromNow(), + }} + searchFilters={[ + (clusterRole: ClusterRole) => clusterRole.getSearchFields(), + ]} + renderHeaderTitle="Cluster Roles" + renderTableHeader={[ + { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name }, + { className: "warning", showWithColumn: columnId.name }, + { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, + ]} + renderTableContents={(clusterRole: ClusterRole) => [ + clusterRole.getName(), + , + clusterRole.getAge(), + ]} + addRemoveButtons={{ + onAdd: () => AddClusterRoleDialog.open(), + addTooltip: "Create new ClusterRole", + }} + /> + + + ); + } +} diff --git a/src/renderer/components/+user-management/+roles-bindings/details.scss b/src/renderer/components/+user-management/+roles-bindings/details.scss new file mode 100644 index 0000000000..805e5e05fe --- /dev/null +++ b/src/renderer/components/+user-management/+roles-bindings/details.scss @@ -0,0 +1,2 @@ +.RoleBindingDetails { +} \ No newline at end of file diff --git a/src/renderer/components/+user-management-roles-bindings/role-binding-details.tsx b/src/renderer/components/+user-management/+roles-bindings/details.tsx similarity index 62% rename from src/renderer/components/+user-management-roles-bindings/role-binding-details.tsx rename to src/renderer/components/+user-management/+roles-bindings/details.tsx index 3fb14c4bc4..7aa87c5a40 100644 --- a/src/renderer/components/+user-management-roles-bindings/role-binding-details.tsx +++ b/src/renderer/components/+user-management/+roles-bindings/details.tsx @@ -19,35 +19,32 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import "./role-binding-details.scss"; +import "./details.scss"; -import React from "react"; -import { AddRemoveButtons } from "../add-remove-buttons"; -import type { IRoleBindingSubject, RoleBinding } from "../../api/endpoints"; -import { boundMethod, prevDefault } from "../../utils"; -import { Table, TableCell, TableHead, TableRow } from "../table"; -import { ConfirmDialog } from "../confirm-dialog"; -import { DrawerTitle } from "../drawer"; -import { KubeEventDetails } from "../+events/kube-event-details"; +import { reaction } from "mobx"; import { disposeOnUnmount, observer } from "mobx-react"; -import { observable, reaction, makeObservable } from "mobx"; -import { roleBindingsStore } from "./role-bindings.store"; -import { AddRoleBindingDialog } from "./add-role-binding-dialog"; -import type { KubeObjectDetailsProps } from "../kube-object"; -import { KubeObjectMeta } from "../kube-object/kube-object-meta"; -import { kubeObjectDetailRegistry } from "../../api/kube-object-detail-registry"; +import React from "react"; +import { KubeEventDetails } from "../../+events/kube-event-details"; +import type { RoleBinding, RoleBindingSubject } from "../../../api/endpoints"; +import { kubeObjectDetailRegistry } from "../../../api/kube-object-detail-registry"; +import { prevDefault, boundMethod } from "../../../utils"; +import { AddRemoveButtons } from "../../add-remove-buttons"; +import { ConfirmDialog } from "../../confirm-dialog"; +import { DrawerTitle } from "../../drawer"; +import type { KubeObjectDetailsProps } from "../../kube-object"; +import { KubeObjectMeta } from "../../kube-object/kube-object-meta"; +import { Table, TableCell, TableHead, TableRow } from "../../table"; +import { RoleBindingDialog } from "./dialog"; +import { roleBindingsStore } from "./store"; +import { ObservableHashSet } from "../../../../common/utils/hash-set"; +import { hashRoleBindingSubject } from "../../../../common/utils/hashers"; interface Props extends KubeObjectDetailsProps { } @observer export class RoleBindingDetails extends React.Component { - @observable selectedSubjects = observable.array([], { deep: false }); - - constructor(props: Props) { - super(props); - makeObservable(this); - } + selectedSubjects = new ObservableHashSet([], hashRoleBindingSubject); async componentDidMount() { disposeOnUnmount(this, [ @@ -57,24 +54,13 @@ export class RoleBindingDetails extends React.Component { ]); } - selectSubject(subject: IRoleBindingSubject) { - const { selectedSubjects } = this; - const isSelected = selectedSubjects.includes(subject); - - selectedSubjects.replace( - isSelected - ? selectedSubjects.filter(sub => sub !== subject) // unselect - : selectedSubjects.concat(subject) // select - ); - } - @boundMethod removeSelectedSubjects() { const { object: roleBinding } = this.props; const { selectedSubjects } = this; ConfirmDialog.open({ - ok: () => roleBindingsStore.updateSubjects({ roleBinding, removeSubjects: selectedSubjects }), + ok: () => roleBindingsStore.updateSubjects({ roleBinding, removeSubjects: selectedSubjects.toJSON() }), labelOk: `Remove`, message: (

Remove selected bindings for {roleBinding.getName()}?

@@ -94,9 +80,9 @@ export class RoleBindingDetails extends React.Component { return (
- + - + Kind @@ -110,11 +96,11 @@ export class RoleBindingDetails extends React.Component {
- + {subjects.length > 0 && ( - + Binding Type Namespace @@ -122,14 +108,18 @@ export class RoleBindingDetails extends React.Component { { subjects.map((subject, i) => { const { kind, name, namespace } = subject; - const isSelected = selectedSubjects.includes(subject); + const isSelected = selectedSubjects.has(subject); return ( this.selectSubject(subject))} + key={i} + selected={isSelected} + onClick={prevDefault(() => { + console.log("toggling", subject); + this.selectedSubjects.toggle(subject); + })} > - + {name} {kind} {namespace || "-"} @@ -141,9 +131,9 @@ export class RoleBindingDetails extends React.Component { )} AddRoleBindingDialog.open(roleBinding)} - onRemove={selectedSubjects.length ? this.removeSelectedSubjects : null} - addTooltip={`Add bindings to ${roleRef.name}`} + onAdd={() => RoleBindingDialog.open(roleBinding)} + onRemove={selectedSubjects.size ? this.removeSelectedSubjects : null} + addTooltip={`Edit bindings of ${roleRef.name}`} removeTooltip={`Remove selected bindings from ${roleRef.name}`} /> @@ -166,20 +156,3 @@ kubeObjectDetailRegistry.add({ Details: (props) => } }); - - -kubeObjectDetailRegistry.add({ - kind: "ClusterRoleBinding", - apiVersions: ["rbac.authorization.k8s.io/v1"], - components: { - Details: (props) => - } -}); -kubeObjectDetailRegistry.add({ - kind: "ClusterRoleBinding", - apiVersions: ["rbac.authorization.k8s.io/v1"], - priority: 5, - components: { - Details: (props) => - } -}); diff --git a/src/renderer/components/+user-management-roles-bindings/add-role-binding-dialog.scss b/src/renderer/components/+user-management/+roles-bindings/dialog.scss similarity index 100% rename from src/renderer/components/+user-management-roles-bindings/add-role-binding-dialog.scss rename to src/renderer/components/+user-management/+roles-bindings/dialog.scss diff --git a/src/renderer/components/+user-management/+roles-bindings/dialog.tsx b/src/renderer/components/+user-management/+roles-bindings/dialog.tsx new file mode 100644 index 0000000000..e6122578b2 --- /dev/null +++ b/src/renderer/components/+user-management/+roles-bindings/dialog.tsx @@ -0,0 +1,304 @@ +/** + * 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. + */ + +import "./dialog.scss"; + +import { computed, observable, makeObservable } from "mobx"; +import { observer } from "mobx-react"; +import React from "react"; + +import { rolesStore } from "../+roles/store"; +import { serviceAccountsStore } from "../+service-accounts/store"; +import { NamespaceSelect } from "../../+namespaces/namespace-select"; +import { ClusterRole, Role, roleApi, RoleBinding, RoleBindingSubject, ServiceAccount } from "../../../api/endpoints"; +import { Dialog, DialogProps } from "../../dialog"; +import { EditableList } from "../../editable-list"; +import { Icon } from "../../icon"; +import { showDetails } from "../../kube-object"; +import { SubTitle } from "../../layout/sub-title"; +import { Notifications } from "../../notifications"; +import { Select, SelectOption } from "../../select"; +import { Wizard, WizardStep } from "../../wizard"; +import { roleBindingsStore } from "./store"; +import { clusterRolesStore } from "../+cluster-roles/store"; +import { Input } from "../../input"; +import { getRoleRefSelectOption } from "../role-ref-select-option"; +import { ObservableHashSet, hashKubeObject, nFircate } from "../../../utils"; + +interface Props extends Partial { +} + +interface DialogState { + isOpen: boolean; + data?: RoleBinding; +} + +@observer +export class RoleBindingDialog extends React.Component { + static state = observable.object({ + isOpen: false, + }); + + constructor(props: Props) { + super(props); + makeObservable(this); + } + + static open(roleBinding?: RoleBinding) { + RoleBindingDialog.state.isOpen = true; + RoleBindingDialog.state.data = roleBinding; + } + + static close() { + RoleBindingDialog.state.isOpen = false; + RoleBindingDialog.state.data = undefined; + } + + get roleBinding(): RoleBinding { + return RoleBindingDialog.state.data; + } + + @computed get isEditing() { + return !!this.roleBinding; + } + + @observable.ref selectedRoleRef: Role | ClusterRole | undefined = undefined; + @observable bindingName = ""; + @observable bindingNamespace = ""; + selectedAccounts = new ObservableHashSet([], hashKubeObject); + selectedUsers = observable.set([]); + selectedGroups = observable.set([]); + + @computed get selectedBindings(): RoleBindingSubject[] { + const serviceAccounts = Array.from(this.selectedAccounts, sa => ({ + name: sa.getName(), + kind: "ServiceAccount" as const, + namespace: this.bindingNamespace, + })); + const users = Array.from(this.selectedUsers, user => ({ + name: user, + kind: "User" as const, + namespace: this.bindingNamespace, + })); + const groups = Array.from(this.selectedGroups, group => ({ + name: group, + kind: "Group" as const, + namespace: this.bindingNamespace, + })); + + return [ + ...serviceAccounts, + ...users, + ...groups, + ]; + } + + @computed get roleRefOptions(): SelectOption[] { + const roles = rolesStore.items + .filter(role => role.getNs() === this.bindingNamespace) + .map(getRoleRefSelectOption); + const clusterRoles = clusterRolesStore.items + .map(getRoleRefSelectOption); + + return [ + ...roles, + ...clusterRoles, + ]; + } + + @computed get serviceAccountOptions(): SelectOption[] { + return serviceAccountsStore.items + .filter(role => role.getNs() === this.bindingNamespace) + .map(account => ({ + value: account, + label: <> {account.getName()} + })); + } + + onOpen = () => { + const binding = this.roleBinding; + + if (!binding) { + return this.reset(); + } + + this.selectedRoleRef = (binding.roleRef.kind === roleApi.kind ? rolesStore : clusterRolesStore) + .items + .find(item => item.getName() === binding.roleRef.name); + + this.bindingName = binding.getName(); + this.bindingNamespace = binding.getNs(); + + const [saSubjects, uSubjects, gSubjects] = nFircate(binding.getSubjects(), "kind", ["ServiceAccount", "User", "Group"]); + const accountNames = new Set(saSubjects.map(acc => acc.name)); + + this.selectedAccounts.replace( + serviceAccountsStore.items + .filter(sa => accountNames.has(sa.getName())) + ); + this.selectedUsers.replace(uSubjects.map(user => user.name)); + this.selectedGroups.replace(gSubjects.map(group => group.name)); + }; + + reset = () => { + this.selectedRoleRef = undefined; + this.bindingName = ""; + this.bindingNamespace = ""; + this.selectedAccounts.clear(); + this.selectedUsers.clear(); + this.selectedGroups.clear(); + }; + + createBindings = async () => { + const { selectedRoleRef, bindingNamespace: namespace, selectedBindings } = this; + + try { + const roleBinding = this.isEditing + ? await roleBindingsStore.updateSubjects({ + roleBinding: this.roleBinding, + addSubjects: selectedBindings, + }) + : await roleBindingsStore.create({ name: this.bindingName, namespace }, { + subjects: selectedBindings, + roleRef: { + name: selectedRoleRef.getName(), + kind: selectedRoleRef.kind, + } + }); + + showDetails(roleBinding.selfLink); + RoleBindingDialog.close(); + } catch (err) { + Notifications.error(err); + } + }; + + renderContents() { + return ( + <> + + this.bindingNamespace = value} + /> + + + this.bindingName = value} + /> + + )} + + + + Users + this.selectedUsers.add(newUser)} + items={Array.from(this.selectedUsers)} + remove={({ oldItem }) => this.selectedUsers.delete(oldItem)} + /> + + Groups + this.selectedGroups.add(newGroup)} + items={Array.from(this.selectedGroups)} + remove={({ oldItem }) => this.selectedGroups.delete(oldItem)} + /> + + Service Accounts + this.currentNewItem = val} />
{ items.map((item, index) => ( -
+
{renderItem(item, index)}
remove(({ index, oldItem: item }))} /> diff --git a/src/renderer/components/input/input.scss b/src/renderer/components/input/input.scss index 224fec829c..97ad5c058a 100644 --- a/src/renderer/components/input/input.scss +++ b/src/renderer/components/input/input.scss @@ -48,7 +48,7 @@ --flex-gap: #{$padding / 1.5}; position: relative; - padding: $padding /4 * 3 0; + padding: $padding / 4 * 3 0; border-bottom: 1px solid $halfGray; line-height: 1; @@ -110,23 +110,17 @@ //- Themes &.theme { - &.round-black { + &.round { &.invalid.dirty { label { border-color: $colorSoftError; } } - label { - background: var(--inputControlBackground); - border: 1px solid var(--inputControlBorder); - border-radius: 5px; - padding: $padding; - color: var(--textColorTertiary); - - &:hover { - border-color: var(--inputControlHoverBorder); - } + border-radius: $radius; + border: 1px solid $halfGray; + color: inherit; + padding: $padding / 4 * 3 $padding / 4 * 3; &:focus-within { border-color: $colorInfo; @@ -136,6 +130,18 @@ display: none; } } + &.black { + label { + background: var(--inputControlBackground); + border-color: var(--inputControlBorder); + color: var(--textColorTertiary); + padding: $padding; + + &:hover { + border-color: var(--inputControlHoverBorder); + } + } + } } } } diff --git a/src/renderer/components/input/input.tsx b/src/renderer/components/input/input.tsx index 1d0700b846..5de07cb856 100644 --- a/src/renderer/components/input/input.tsx +++ b/src/renderer/components/input/input.tsx @@ -41,7 +41,7 @@ type InputElement = HTMLInputElement | HTMLTextAreaElement; type InputElementProps = InputHTMLAttributes & TextareaHTMLAttributes & DOMAttributes; export type InputProps = Omit & { - theme?: "round-black"; + theme?: "round-black" | "round"; className?: string; value?: T; autoSelectOnFocus?: boolean @@ -55,7 +55,7 @@ export type InputProps = Omit): void; - onSubmit?(value: T): void; + onSubmit?(value: T, evt: React.KeyboardEvent): void; }; interface State { @@ -90,7 +90,7 @@ export class Input extends React.Component { return this.state.valid; } - setValue(value: string) { + setValue(value = "") { if (value !== this.getValue()) { const nativeInputValueSetter = Object.getOwnPropertyDescriptor(this.input.constructor.prototype, "value").set; @@ -236,16 +236,15 @@ export class Input extends React.Component { } @boundMethod - onChange(evt: React.ChangeEvent) { - if (this.props.onChange) { - this.props.onChange(evt.currentTarget.value, evt); - } - + onChange(evt: React.ChangeEvent) { + this.props.onChange?.(evt.currentTarget.value, evt); this.validate(); this.autoFitHeight(); // mark input as dirty for the first time only onBlur() to avoid immediate error-state show when start typing - if (!this.state.dirty) this.setState({ dirtyOnBlur: true }); + if (!this.state.dirty) { + this.setState({ dirtyOnBlur: true }); + } // re-render component when used as uncontrolled input // when used @defaultValue instead of @value changing real input.value doesn't call render() @@ -255,17 +254,19 @@ export class Input extends React.Component { } @boundMethod - onKeyDown(evt: React.KeyboardEvent) { + onKeyDown(evt: React.KeyboardEvent) { const modified = evt.shiftKey || evt.metaKey || evt.altKey || evt.ctrlKey; - if (this.props.onKeyDown) { - this.props.onKeyDown(evt); - } + this.props.onKeyDown?.(evt); switch (evt.key) { case "Enter": if (this.props.onSubmit && !modified && !evt.repeat && this.isValid) { - this.props.onSubmit(this.getValue()); + this.props.onSubmit(this.getValue(), evt); + + if (this.isUncontrolled) { + this.setValue(); + } } break; } @@ -303,6 +304,20 @@ export class Input extends React.Component { } } + get themeSelection(): Record { + const { theme } = this.props; + + if (!theme) { + return {}; + } + + return { + theme: true, + round: true, + black: theme === "round-black", + }; + } + @boundMethod bindRef(elem: InputElement) { this.input = elem; @@ -318,7 +333,7 @@ export class Input extends React.Component { const { focused, dirty, valid, validating, errors } = this.state; const className = cssNames("Input", this.props.className, { - [`theme ${theme}`]: theme, + ...this.themeSelection, focused, disabled, invalid: !valid, diff --git a/src/renderer/components/kube-object/kube-object-details.tsx b/src/renderer/components/kube-object/kube-object-details.tsx index 217c7c25c8..46e659b197 100644 --- a/src/renderer/components/kube-object/kube-object-details.tsx +++ b/src/renderer/components/kube-object/kube-object-details.tsx @@ -33,6 +33,7 @@ import { crdStore } from "../+custom-resources/crd.store"; import { CrdResourceDetails } from "../+custom-resources"; import { KubeObjectMenu } from "./kube-object-menu"; import { kubeObjectDetailRegistry } from "../../api/kube-object-detail-registry"; +import type { CustomResourceDefinition } from "../../api/endpoints"; /** * Used to store `object.selfLink` to show more info about resource in the details panel. @@ -99,11 +100,7 @@ export class KubeObjectDetails extends React.Component { } @computed get object() { - const store = apiManager.getStore(this.path); - - if (store) { - return store.getByPath(this.path); - } + return apiManager.getStore(this.path)?.getByPath(this.path); } @computed get isCrdInstance() { @@ -151,7 +148,7 @@ export class KubeObjectDetails extends React.Component { }); if (isCrdInstance && details.length === 0) { - details.push(); + details.push(); } } diff --git a/src/renderer/components/select/select.scss b/src/renderer/components/select/select.scss index 55fddec850..286f70c140 100644 --- a/src/renderer/components/select/select.scss +++ b/src/renderer/components/select/select.scss @@ -46,16 +46,15 @@ html { } &__control { - border: none; + border: solid 1px $halfGray; border-radius: $radius; background: var(--select-menu-bgc); min-height: 0; - box-shadow: 0 0 0 1px $halfGray; cursor: pointer; line-height: 1; &--is-focused { - box-shadow: 0 0 0 1px $primary; + border-color: $primary; } } @@ -90,7 +89,7 @@ html { &__menu { background: var(--select-menu-bgc); - box-shadow: inset 0 0 0 1px var(--select-menu-border-color); + border-color: var(--select-menu-border-color); width: max-content; min-width: 100%; @@ -171,7 +170,7 @@ html { .Select { &__multi-value { background: none; - box-shadow: 0 0 0 1px $textColorSecondary; + border-color: $textColorSecondary; } &__option { @@ -196,7 +195,7 @@ html { &.theme-outlined { .Select__control { - box-shadow: 0 0 0 1px $colorVague; + border-color: $colorVague; color: $primary; .Select__value-container { @@ -212,23 +211,23 @@ html { &.theme-lens { :hover { &.Select__control { - box-shadow: 0 0 0 1px var(--inputControlHoverBorder); + border-color: var(--inputControlHoverBorder); } } :focus-within { &.Select__control { - box-shadow: 0 0 0 1px $colorInfo; + border-color: $colorInfo; } } &.Select__menu { - box-shadow: inset 0 0 0 1px var(--inputControlBorder); + border-color: var(--inputControlBorder); } .Select { &__control { - box-shadow: 0 0 0 1px var(--inputControlBorder); + border-color: var(--inputControlBorder); background: var(--inputControlBackground); border-radius: 5px; } diff --git a/src/renderer/components/select/select.tsx b/src/renderer/components/select/select.tsx index 9dd5a61798..af13fecaae 100644 --- a/src/renderer/components/select/select.tsx +++ b/src/renderer/components/select/select.tsx @@ -26,10 +26,11 @@ import "./select.scss"; import React, { ReactNode } from "react"; import { computed, makeObservable } from "mobx"; import { observer } from "mobx-react"; -import { boundMethod, cssNames } from "../../utils"; import ReactSelect, { ActionMeta, components, OptionTypeBase, Props as ReactSelectProps, Styles } from "react-select"; import Creatable, { CreatableProps } from "react-select/creatable"; + import { ThemeStore } from "../../theme.store"; +import { boundMethod, cssNames } from "../../utils"; const { Menu } = components; @@ -65,8 +66,10 @@ export class Select extends React.Component { makeObservable(this); } - @computed get theme() { - return this.props.themeName || ThemeStore.getInstance().activeTheme.type; + @computed get themeClass() { + const themeName = this.props.themeName || ThemeStore.getInstance().activeTheme.type; + + return `theme-${themeName}`; } private styles: Styles = { @@ -128,7 +131,6 @@ export class Select extends React.Component { className, menuClass, isCreatable, autoConvertOptions, value, options, components = {}, ...props } = this.props; - const themeClass = `theme-${this.theme}`; const WrappedMenu = components.Menu ?? Menu; const selectProps: Partial = { @@ -138,14 +140,14 @@ export class Select extends React.Component { options: autoConvertOptions ? this.options : options, onChange: this.onChange, onKeyDown: this.onKeyDown, - className: cssNames("Select", themeClass, className), + className: cssNames("Select", this.themeClass, className), classNamePrefix: "Select", components: { ...components, Menu: props => ( ), } diff --git a/src/renderer/kube-object.store.ts b/src/renderer/kube-object.store.ts index b7686d168b..ec26f0e2dc 100644 --- a/src/renderer/kube-object.store.ts +++ b/src/renderer/kube-object.store.ts @@ -22,7 +22,7 @@ import type { ClusterContext } from "./components/context"; import { action, computed, makeObservable, observable, reaction, when } from "mobx"; -import { autoBind, bifurcateArray, noop, rejectPromiseBy } from "./utils"; +import { autoBind, noop, rejectPromiseBy } from "./utils"; import { KubeObject, KubeStatus } from "./api/kube-object"; import type { IKubeWatchEvent } from "./api/kube-watch-api"; import { ItemStore } from "./item.store"; @@ -306,51 +306,31 @@ export abstract class KubeObjectStore extends ItemSt }); } - getSubscribeApis(): KubeApi[] { - return [this.api]; - } - - subscribe(apis = this.getSubscribeApis()) { + subscribe() { const abortController = new AbortController(); - const [clusterScopedApis, namespaceScopedApis] = bifurcateArray(apis, api => api.isNamespaced); - for (const api of namespaceScopedApis) { - const store = apiManager.getStore(api); - - // This waits for the context and namespaces to be ready or fails fast if the disposer is called - Promise.race([rejectPromiseBy(abortController.signal), Promise.all([store.contextReady, store.namespacesReady])]) + if (this.api.isNamespaced) { + Promise.race([rejectPromiseBy(abortController.signal), Promise.all([this.contextReady, this.namespacesReady])]) .then(() => { - if ( - store.context.cluster.isGlobalWatchEnabled - && store.loadedNamespaces.length === 0 - ) { - return store.watchNamespace(api, "", abortController); + if (this.context.cluster.isGlobalWatchEnabled && this.loadedNamespaces.length === 0) { + return this.watchNamespace("", abortController); } for (const namespace of this.loadedNamespaces) { - store.watchNamespace(api, namespace, abortController); + this.watchNamespace(namespace, abortController); } }) .catch(noop); // ignore DOMExceptions + } else { + this.watchNamespace("", abortController); } - for (const api of clusterScopedApis) { - /** - * if the api is cluster scoped then we will never assign to `loadedNamespaces` - * and thus `store.namespacesReady` will never resolve. Futhermore, we don't care - * about watching namespaces. - */ - apiManager.getStore(api).watchNamespace(api, "", abortController); - } - - return () => { - abortController.abort(); - }; + return () => abortController.abort(); } - private watchNamespace(api: KubeApi, namespace: string, abortController: AbortController) { + private watchNamespace(namespace: string, abortController: AbortController) { let timedRetry: NodeJS.Timeout; - const watch = () => api.watch({ + const watch = () => this.api.watch({ namespace, abortController, callback