From a1a1c240e9293070386fd52ef86e0d9994d11c05 Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Wed, 9 Jun 2021 13:40:55 -0400 Subject: [PATCH] Split UserManagement into seperate Cluster and Namespace Roles and RoleBindings (#2755) --- src/common/utils/__tests__/hash-set.test.ts | 528 ++++++++++++++++++ src/common/utils/__tests__/n-fircate.test.ts | 55 ++ src/common/utils/hash-set.ts | 260 +++++++++ src/common/utils/index.ts | 5 +- src/common/utils/n-fircate.ts | 52 ++ src/extensions/renderer-api/k8s-api.ts | 6 +- .../api/endpoints/cluster-role-binding.api.ts | 31 +- .../api/endpoints/cluster-role.api.ts | 17 +- .../api/endpoints/role-binding.api.ts | 8 +- .../+namespaces/namespace-select.tsx | 6 +- .../components/+namespaces/namespace.store.ts | 13 +- .../add-role-binding-dialog.tsx | 318 ----------- .../role-bindings.store.ts | 100 ---- .../role-bindings.tsx | 85 --- .../+user-management-roles/roles.store.ts | 75 --- .../+cluster-role-bindings/details.scss} | 0 .../+cluster-role-bindings/details.tsx | 157 ++++++ .../+cluster-role-bindings/dialog.scss | 11 + .../+cluster-role-bindings/dialog.tsx | 286 ++++++++++ .../+cluster-role-bindings/hashers.ts} | 13 +- .../+cluster-role-bindings}/index.ts | 7 +- .../+cluster-role-bindings/store.ts | 63 +++ .../+cluster-role-bindings/view.scss | 11 + .../+cluster-role-bindings/view.tsx | 88 +++ .../+cluster-roles/add-dialog.scss} | 0 .../+cluster-roles/add-dialog.tsx | 105 ++++ .../+cluster-roles/details.scss} | 0 .../+cluster-roles/details.tsx | 104 ++++ .../+cluster-roles}/index.ts | 7 +- .../+user-management/+cluster-roles/store.ts | 44 ++ .../+user-management/+cluster-roles/view.scss | 11 + .../+user-management/+cluster-roles/view.tsx | 80 +++ .../+role-bindings/details.scss | 2 + .../+role-bindings/details.tsx} | 94 ++-- .../+role-bindings/dialog.scss} | 0 .../+role-bindings/dialog.tsx | 303 ++++++++++ .../+role-bindings/hashers.ts | 32 ++ .../+user-management/+role-bindings/index.ts | 23 + .../+user-management/+role-bindings/store.ts | 62 ++ .../+role-bindings/view.scss} | 0 .../+user-management/+role-bindings/view.tsx | 91 +++ .../+user-management/+roles/add-dialog.scss | 5 + .../+roles/add-dialog.tsx} | 41 +- .../+user-management/+roles/details.scss | 21 + .../+roles/details.tsx} | 34 +- .../+user-management/+roles/index.ts | 23 + .../+user-management/+roles/store.ts | 48 ++ .../+roles/view.scss} | 0 .../+roles/view.tsx} | 20 +- .../+service-accounts/create-dialog.scss} | 2 +- .../+service-accounts/create-dialog.tsx} | 43 +- .../+service-accounts/details.scss} | 2 +- .../+service-accounts/details.tsx} | 25 +- .../+service-accounts/index.ts | 23 + .../+service-accounts/secret.scss} | 0 .../+service-accounts/secret.tsx} | 11 +- .../+service-accounts/store.ts} | 8 +- .../+service-accounts/view.scss} | 0 .../+service-accounts/view.tsx} | 28 +- .../+user-management/select-options.tsx | 49 ++ .../+user-management/user-management.route.ts | 37 +- .../+user-management/user-management.tsx | 61 +- src/renderer/components/app.tsx | 2 - .../editable-list/editable-list.scss | 8 +- .../editable-list/editable-list.tsx | 20 +- src/renderer/components/input/input.scss | 30 +- src/renderer/components/input/input.tsx | 45 +- src/renderer/components/select/select.tsx | 14 +- src/renderer/kube-object.store.ts | 44 +- 69 files changed, 2896 insertions(+), 901 deletions(-) create mode 100644 src/common/utils/__tests__/hash-set.test.ts create mode 100644 src/common/utils/__tests__/n-fircate.test.ts create mode 100644 src/common/utils/hash-set.ts create mode 100644 src/common/utils/n-fircate.ts delete mode 100644 src/renderer/components/+user-management-roles-bindings/add-role-binding-dialog.tsx delete mode 100644 src/renderer/components/+user-management-roles-bindings/role-bindings.store.ts delete mode 100644 src/renderer/components/+user-management-roles-bindings/role-bindings.tsx delete mode 100644 src/renderer/components/+user-management-roles/roles.store.ts rename src/renderer/components/{+user-management-roles-bindings/role-binding-details.scss => +user-management/+cluster-role-bindings/details.scss} (100%) create mode 100644 src/renderer/components/+user-management/+cluster-role-bindings/details.tsx create mode 100644 src/renderer/components/+user-management/+cluster-role-bindings/dialog.scss create mode 100644 src/renderer/components/+user-management/+cluster-role-bindings/dialog.tsx rename src/renderer/components/{+user-management-service-accounts/index.ts => +user-management/+cluster-role-bindings/hashers.ts} (76%) rename src/renderer/components/{+user-management-roles => +user-management/+cluster-role-bindings}/index.ts (92%) create mode 100644 src/renderer/components/+user-management/+cluster-role-bindings/store.ts create mode 100644 src/renderer/components/+user-management/+cluster-role-bindings/view.scss create mode 100644 src/renderer/components/+user-management/+cluster-role-bindings/view.tsx rename src/renderer/components/{+user-management-roles/add-role-dialog.scss => +user-management/+cluster-roles/add-dialog.scss} (100%) create mode 100644 src/renderer/components/+user-management/+cluster-roles/add-dialog.tsx rename src/renderer/components/{+user-management-roles/role-details.scss => +user-management/+cluster-roles/details.scss} (100%) create mode 100644 src/renderer/components/+user-management/+cluster-roles/details.tsx rename src/renderer/components/{+user-management-roles-bindings => +user-management/+cluster-roles}/index.ts (90%) create mode 100644 src/renderer/components/+user-management/+cluster-roles/store.ts create mode 100644 src/renderer/components/+user-management/+cluster-roles/view.scss create mode 100644 src/renderer/components/+user-management/+cluster-roles/view.tsx create mode 100644 src/renderer/components/+user-management/+role-bindings/details.scss rename src/renderer/components/{+user-management-roles-bindings/role-binding-details.tsx => +user-management/+role-bindings/details.tsx} (59%) rename src/renderer/components/{+user-management-roles-bindings/add-role-binding-dialog.scss => +user-management/+role-bindings/dialog.scss} (100%) create mode 100644 src/renderer/components/+user-management/+role-bindings/dialog.tsx create mode 100644 src/renderer/components/+user-management/+role-bindings/hashers.ts create mode 100644 src/renderer/components/+user-management/+role-bindings/index.ts create mode 100644 src/renderer/components/+user-management/+role-bindings/store.ts rename src/renderer/components/{+user-management-roles-bindings/role-bindings.scss => +user-management/+role-bindings/view.scss} (100%) create mode 100644 src/renderer/components/+user-management/+role-bindings/view.tsx create mode 100644 src/renderer/components/+user-management/+roles/add-dialog.scss rename src/renderer/components/{+user-management-roles/add-role-dialog.tsx => +user-management/+roles/add-dialog.tsx} (79%) create mode 100644 src/renderer/components/+user-management/+roles/details.scss rename src/renderer/components/{+user-management-roles/role-details.tsx => +user-management/+roles/details.tsx} (80%) create mode 100644 src/renderer/components/+user-management/+roles/index.ts create mode 100644 src/renderer/components/+user-management/+roles/store.ts rename src/renderer/components/{+user-management-roles/roles.scss => +user-management/+roles/view.scss} (100%) rename src/renderer/components/{+user-management-roles/roles.tsx => +user-management/+roles/view.tsx} (85%) rename src/renderer/components/{+user-management-service-accounts/create-service-account-dialog.scss => +user-management/+service-accounts/create-dialog.scss} (99%) rename src/renderer/components/{+user-management-service-accounts/create-service-account-dialog.tsx => +user-management/+service-accounts/create-dialog.tsx} (76%) rename src/renderer/components/{+user-management-service-accounts/service-accounts-details.scss => +user-management/+service-accounts/details.scss} (99%) rename src/renderer/components/{+user-management-service-accounts/service-accounts-details.tsx => +user-management/+service-accounts/details.tsx} (88%) create mode 100644 src/renderer/components/+user-management/+service-accounts/index.ts rename src/renderer/components/{+user-management-service-accounts/service-accounts-secret.scss => +user-management/+service-accounts/secret.scss} (100%) rename src/renderer/components/{+user-management-service-accounts/service-accounts-secret.tsx => +user-management/+service-accounts/secret.tsx} (94%) rename src/renderer/components/{+user-management-service-accounts/service-accounts.store.ts => +user-management/+service-accounts/store.ts} (87%) rename src/renderer/components/{+user-management-service-accounts/service-accounts.scss => +user-management/+service-accounts/view.scss} (100%) rename src/renderer/components/{+user-management-service-accounts/service-accounts.tsx => +user-management/+service-accounts/view.tsx} (80%) create mode 100644 src/renderer/components/+user-management/select-options.tsx diff --git a/src/common/utils/__tests__/hash-set.test.ts b/src/common/utils/__tests__/hash-set.test.ts new file mode 100644 index 0000000000..38614b7d3e --- /dev/null +++ b/src/common/utils/__tests__/hash-set.test.ts @@ -0,0 +1,528 @@ +/** + * 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 { HashSet, ObservableHashSet } from "../hash-set"; + +describe("ObservableHashSet", () => { + it("should not throw on creation", () => { + expect(() => new ObservableHashSet<{ a: number }>([], item => item.a.toString())).not.toThrowError(); + }); + + it("should be initializable", () => { + const res = new ObservableHashSet([ + { a: 1 }, + { a: 2 }, + { a: 3 }, + { a: 4 }, + ], item => item.a.toString()); + + expect(res.size).toBe(4); + }); + + it("has should work as expected", () => { + const res = new ObservableHashSet([ + { a: 1 }, + { a: 2 }, + { a: 3 }, + { a: 4 }, + ], item => item.a.toString()); + + expect(res.has({ a: 1 })).toBe(true); + expect(res.has({ a: 5 })).toBe(false); + }); + + it("forEach should work as expected", () => { + const res = new ObservableHashSet([ + { a: 1 }, + { a: 2 }, + { a: 3 }, + { a: 4 }, + ], item => item.a.toString()); + + let a = 1; + + res.forEach((item) => { + expect(item.a).toEqual(a++); + }); + }); + + it("delete should work as expected", () => { + const res = new ObservableHashSet([ + { a: 1 }, + { a: 2 }, + { a: 3 }, + { a: 4 }, + ], item => item.a.toString()); + + expect(res.has({ a: 1 })).toBe(true); + expect(res.delete({ a: 1 })).toBe(true); + expect(res.has({ a: 1 })).toBe(false); + + expect(res.has({ a: 5 })).toBe(false); + expect(res.delete({ a: 5 })).toBe(false); + expect(res.has({ a: 5 })).toBe(false); + }); + + it("toggle should work as expected", () => { + const res = new ObservableHashSet([ + { a: 1 }, + { a: 2 }, + { a: 3 }, + { a: 4 }, + ], item => item.a.toString()); + + expect(res.has({ a: 1 })).toBe(true); + res.toggle({ a: 1 }); + expect(res.has({ a: 1 })).toBe(false); + + expect(res.has({ a: 6 })).toBe(false); + res.toggle({ a: 6 }); + expect(res.has({ a: 6 })).toBe(true); + }); + + it("add should work as expected", () => { + const res = new ObservableHashSet([ + { a: 1 }, + { a: 2 }, + { a: 3 }, + { a: 4 }, + ], item => item.a.toString()); + + expect(res.has({ a: 6 })).toBe(false); + res.add({ a: 6 }); + expect(res.has({ a: 6 })).toBe(true); + }); + + it("add should treat the hash to be the same as equality", () => { + const res = new ObservableHashSet([ + { a: 1, foobar: "hello" }, + { a: 2 }, + { a: 3 }, + { a: 4 }, + ], item => item.a.toString()); + + expect(res.has({ a: 1 })).toBe(true); + res.add({ a: 1, foobar: "goodbye" }); + expect(res.has({ a: 1 })).toBe(true); + }); + + it("clear should work as expected", () => { + const res = new ObservableHashSet([ + { a: 1 }, + { a: 2 }, + { a: 3 }, + { a: 4 }, + ], item => item.a.toString()); + + expect(res.size).toBe(4); + res.clear(); + expect(res.size).toBe(0); + }); + + it("replace should work as expected", () => { + const res = new ObservableHashSet([ + { a: 1 }, + { a: 2 }, + { a: 3 }, + { a: 4 }, + ], item => item.a.toString()); + + expect(res.size).toBe(4); + res.replace([{ a: 13 }]); + expect(res.size).toBe(1); + expect(res.has({ a: 1 })).toBe(false); + expect(res.has({ a: 13 })).toBe(true); + }); + + it("toJSON should work as expected", () => { + const res = new ObservableHashSet([ + { a: 1 }, + { a: 2 }, + { a: 3 }, + { a: 4 }, + ], item => item.a.toString()); + + expect(res.toJSON()).toStrictEqual([ + { a: 1 }, + { a: 2 }, + { a: 3 }, + { a: 4 }, + ]); + }); + + it("values should work as expected", () => { + const res = new ObservableHashSet([ + { a: 1 }, + { a: 2 }, + { a: 3 }, + { a: 4 }, + ], item => item.a.toString()); + const iter = res.values(); + + expect(iter.next()).toStrictEqual({ + value: { a: 1 }, + done: false, + }); + + expect(iter.next()).toStrictEqual({ + value: { a: 2 }, + done: false, + }); + + expect(iter.next()).toStrictEqual({ + value: { a: 3 }, + done: false, + }); + + expect(iter.next()).toStrictEqual({ + value: { a: 4 }, + done: false, + }); + + expect(iter.next()).toStrictEqual({ + value: undefined, + done: true, + }); + }); + + it("keys should work as expected", () => { + const res = new ObservableHashSet([ + { a: 1 }, + { a: 2 }, + { a: 3 }, + { a: 4 }, + ], item => item.a.toString()); + const iter = res.keys(); + + expect(iter.next()).toStrictEqual({ + value: { a: 1 }, + done: false, + }); + + expect(iter.next()).toStrictEqual({ + value: { a: 2 }, + done: false, + }); + + expect(iter.next()).toStrictEqual({ + value: { a: 3 }, + done: false, + }); + + expect(iter.next()).toStrictEqual({ + value: { a: 4 }, + done: false, + }); + + expect(iter.next()).toStrictEqual({ + value: undefined, + done: true, + }); + }); + + it("entries should work as expected", () => { + const res = new ObservableHashSet([ + { a: 1 }, + { a: 2 }, + { a: 3 }, + { a: 4 }, + ], item => item.a.toString()); + const iter = res.entries(); + + expect(iter.next()).toStrictEqual({ + value: [{ a: 1 }, { a: 1 }], + done: false, + }); + + expect(iter.next()).toStrictEqual({ + value: [{ a: 2 }, { a: 2 }], + done: false, + }); + + expect(iter.next()).toStrictEqual({ + value: [{ a: 3 }, { a: 3 }], + done: false, + }); + + expect(iter.next()).toStrictEqual({ + value: [{ a: 4 }, { a: 4 }], + done: false, + }); + + expect(iter.next()).toStrictEqual({ + value: undefined, + done: true, + }); + }); +}); + +describe("HashSet", () => { + it("should not throw on creation", () => { + expect(() => new HashSet<{ a: number }>([], item => item.a.toString())).not.toThrowError(); + }); + + it("should be initializable", () => { + const res = new HashSet([ + { a: 1 }, + { a: 2 }, + { a: 3 }, + { a: 4 }, + ], item => item.a.toString()); + + expect(res.size).toBe(4); + }); + + it("has should work as expected", () => { + const res = new HashSet([ + { a: 1 }, + { a: 2 }, + { a: 3 }, + { a: 4 }, + ], item => item.a.toString()); + + expect(res.has({ a: 1 })).toBe(true); + expect(res.has({ a: 5 })).toBe(false); + }); + + it("forEach should work as expected", () => { + const res = new HashSet([ + { a: 1 }, + { a: 2 }, + { a: 3 }, + { a: 4 }, + ], item => item.a.toString()); + + let a = 1; + + res.forEach((item) => { + expect(item.a).toEqual(a++); + }); + }); + + it("delete should work as expected", () => { + const res = new HashSet([ + { a: 1 }, + { a: 2 }, + { a: 3 }, + { a: 4 }, + ], item => item.a.toString()); + + expect(res.has({ a: 1 })).toBe(true); + expect(res.delete({ a: 1 })).toBe(true); + expect(res.has({ a: 1 })).toBe(false); + + expect(res.has({ a: 5 })).toBe(false); + expect(res.delete({ a: 5 })).toBe(false); + expect(res.has({ a: 5 })).toBe(false); + }); + + it("toggle should work as expected", () => { + const res = new HashSet([ + { a: 1 }, + { a: 2 }, + { a: 3 }, + { a: 4 }, + ], item => item.a.toString()); + + expect(res.has({ a: 1 })).toBe(true); + res.toggle({ a: 1 }); + expect(res.has({ a: 1 })).toBe(false); + + expect(res.has({ a: 6 })).toBe(false); + res.toggle({ a: 6 }); + expect(res.has({ a: 6 })).toBe(true); + }); + + it("add should work as expected", () => { + const res = new HashSet([ + { a: 1 }, + { a: 2 }, + { a: 3 }, + { a: 4 }, + ], item => item.a.toString()); + + expect(res.has({ a: 6 })).toBe(false); + res.add({ a: 6 }); + expect(res.has({ a: 6 })).toBe(true); + }); + + it("add should treat the hash to be the same as equality", () => { + const res = new HashSet([ + { a: 1, foobar: "hello" }, + { a: 2 }, + { a: 3 }, + { a: 4 }, + ], item => item.a.toString()); + + expect(res.has({ a: 1 })).toBe(true); + res.add({ a: 1, foobar: "goodbye" }); + expect(res.has({ a: 1 })).toBe(true); + }); + + it("clear should work as expected", () => { + const res = new HashSet([ + { a: 1 }, + { a: 2 }, + { a: 3 }, + { a: 4 }, + ], item => item.a.toString()); + + expect(res.size).toBe(4); + res.clear(); + expect(res.size).toBe(0); + }); + + it("replace should work as expected", () => { + const res = new HashSet([ + { a: 1 }, + { a: 2 }, + { a: 3 }, + { a: 4 }, + ], item => item.a.toString()); + + expect(res.size).toBe(4); + res.replace([{ a: 13 }]); + expect(res.size).toBe(1); + expect(res.has({ a: 1 })).toBe(false); + expect(res.has({ a: 13 })).toBe(true); + }); + + it("toJSON should work as expected", () => { + const res = new HashSet([ + { a: 1 }, + { a: 2 }, + { a: 3 }, + { a: 4 }, + ], item => item.a.toString()); + + expect(res.toJSON()).toStrictEqual([ + { a: 1 }, + { a: 2 }, + { a: 3 }, + { a: 4 }, + ]); + }); + + it("values should work as expected", () => { + const res = new HashSet([ + { a: 1 }, + { a: 2 }, + { a: 3 }, + { a: 4 }, + ], item => item.a.toString()); + const iter = res.values(); + + expect(iter.next()).toStrictEqual({ + value: { a: 1 }, + done: false, + }); + + expect(iter.next()).toStrictEqual({ + value: { a: 2 }, + done: false, + }); + + expect(iter.next()).toStrictEqual({ + value: { a: 3 }, + done: false, + }); + + expect(iter.next()).toStrictEqual({ + value: { a: 4 }, + done: false, + }); + + expect(iter.next()).toStrictEqual({ + value: undefined, + done: true, + }); + }); + + it("keys should work as expected", () => { + const res = new HashSet([ + { a: 1 }, + { a: 2 }, + { a: 3 }, + { a: 4 }, + ], item => item.a.toString()); + const iter = res.keys(); + + expect(iter.next()).toStrictEqual({ + value: { a: 1 }, + done: false, + }); + + expect(iter.next()).toStrictEqual({ + value: { a: 2 }, + done: false, + }); + + expect(iter.next()).toStrictEqual({ + value: { a: 3 }, + done: false, + }); + + expect(iter.next()).toStrictEqual({ + value: { a: 4 }, + done: false, + }); + + expect(iter.next()).toStrictEqual({ + value: undefined, + done: true, + }); + }); + + it("entries should work as expected", () => { + const res = new HashSet([ + { a: 1 }, + { a: 2 }, + { a: 3 }, + { a: 4 }, + ], item => item.a.toString()); + const iter = res.entries(); + + expect(iter.next()).toStrictEqual({ + value: [{ a: 1 }, { a: 1 }], + done: false, + }); + + expect(iter.next()).toStrictEqual({ + value: [{ a: 2 }, { a: 2 }], + done: false, + }); + + expect(iter.next()).toStrictEqual({ + value: [{ a: 3 }, { a: 3 }], + done: false, + }); + + expect(iter.next()).toStrictEqual({ + value: [{ a: 4 }, { a: 4 }], + done: false, + }); + + expect(iter.next()).toStrictEqual({ + value: undefined, + done: true, + }); + }); +}); diff --git a/src/common/utils/__tests__/n-fircate.test.ts b/src/common/utils/__tests__/n-fircate.test.ts new file mode 100644 index 0000000000..ad502d05e4 --- /dev/null +++ b/src/common/utils/__tests__/n-fircate.test.ts @@ -0,0 +1,55 @@ +/** + * 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 { nFircate } from "../n-fircate"; + +describe("nFircate", () => { + it("should produce an empty array if no parts are provided", () => { + expect(nFircate([{ a: 1 }, { a: 2 }], "a", []).length).toBe(0); + }); + + it("should ignore non-matching parts", () => { + const res = nFircate([{ a: 1 }, { a: 2 }], "a", [1]); + + expect(res.length).toBe(1); + expect(res[0].length).toBe(1); + }); + + it("should include all matching parts in each type", () => { + const res = nFircate([{ a: 1, b: "a" }, { a: 2, b: "b" }, { a: 1, b: "c" }], "a", [1, 2]); + + expect(res.length).toBe(2); + expect(res[0].length).toBe(2); + expect(res[0][0].b).toBe("a"); + expect(res[0][1].b).toBe("c"); + expect(res[1].length).toBe(1); + expect(res[1][0].b).toBe("b"); + }); + + it("should throw a type error if the same part is provided more than once", () => { + try { + nFircate([{ a: 1, b: "a" }, { a: 2, b: "b" }, { a: 1, b: "c" }], "a", [1, 2, 1]); + fail("Expected error"); + } catch (error) { + expect(error).toBeInstanceOf(TypeError); + } + }); +}); diff --git a/src/common/utils/hash-set.ts b/src/common/utils/hash-set.ts new file mode 100644 index 0000000000..87fa8daa4c --- /dev/null +++ b/src/common/utils/hash-set.ts @@ -0,0 +1,260 @@ +/** + * 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 function makeIterableIterator(iterator: Iterator): IterableIterator { + (iterator as IterableIterator)[Symbol.iterator] = () => iterator as IterableIterator; + + return iterator as IterableIterator; +} + +export class HashSet implements Set { + #hashmap: Map; + + constructor(initialValues: Iterable, protected hasher: (item: T) => string) { + this.#hashmap = new Map(Array.from(initialValues, value => [this.hasher(value), value])); + } + + replace(other: ObservableHashSet | ObservableSet | Set | readonly T[]): this { + if (other === null || other === undefined) { + return this; + } + + if (!(Array.isArray(other) || other instanceof Set || other instanceof ObservableHashSet || other instanceof ObservableSet)) { + throw new Error(`ObservableHashSet: Cannot initialize set from ${other}`); + } + + this.clear(); + + for (const value of other) { + this.add(value); + } + + return this; + } + + clear(): void { + this.#hashmap.clear(); + } + + add(value: T): this { + this.#hashmap.set(this.hasher(value), value); + + return this; + } + + toggle(value: T): void { + 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 makeIterableIterator<[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 makeIterableIterator({ + 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 Set]"; + } +} + +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 (other === null || other === undefined) { + return this; + } + + if (!(Array.isArray(other) || other instanceof Set || other instanceof ObservableHashSet || other instanceof ObservableSet)) { + throw new Error(`ObservableHashSet: Cannot initialize set from ${other}`); + } + + this.clear(); + + for (const value of other) { + this.add(value); + } + + return this; + } + + clear(): void { + this.#hashmap.clear(); + } + + add(value: T): this { + this.#hashmap.set(this.hasher(value), value); + + return this; + } + + @action + toggle(value: T): void { + 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 makeIterableIterator<[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 makeIterableIterator({ + 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/common/utils/index.ts b/src/common/utils/index.ts index cc33ab332e..34bd65755b 100644 --- a/src/common/utils/index.ts +++ b/src/common/utils/index.ts @@ -29,17 +29,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 "./paths"; export * from "./reject-promise"; @@ -47,6 +47,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..289d391e3e --- /dev/null +++ b/src/common/utils/n-fircate.ts @@ -0,0 +1,52 @@ +/** + * 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: []): []; +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[][] { + if (new Set(parts).size !== parts.length) { + throw new TypeError("Duplicate parts entries"); + } + + 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..c579aafbb6 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/+role-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..868f00261a 100644 --- a/src/renderer/api/endpoints/cluster-role-binding.api.ts +++ b/src/renderer/api/endpoints/cluster-role-binding.api.ts @@ -18,14 +18,39 @@ * 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; + namespace?: 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..c8c83d3151 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; 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} - /> - ) - } - - ) - } - - - ) => { + if (!this.selectedRoleRef || this.bindingName === this.selectedRoleRef.getName()) { + this.bindingName = value.getName(); + } + + this.selectedRoleRef = value; + }} + /> + + + this.bindingName = val} + /> + + + + 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..43d8dedcbb --- /dev/null +++ b/src/renderer/components/+user-management/+cluster-roles/view.tsx @@ -0,0 +1,80 @@ +/** + * 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/+role-bindings/details.scss b/src/renderer/components/+user-management/+role-bindings/details.scss new file mode 100644 index 0000000000..805e5e05fe --- /dev/null +++ b/src/renderer/components/+user-management/+role-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/+role-bindings/details.tsx similarity index 59% rename from src/renderer/components/+user-management-roles-bindings/role-binding-details.tsx rename to src/renderer/components/+user-management/+role-bindings/details.tsx index 3fb14c4bc4..39080d56b5 100644 --- a/src/renderer/components/+user-management-roles-bindings/role-binding-details.tsx +++ b/src/renderer/components/+user-management/+role-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 "./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.removeSubjects(roleBinding, selectedSubjects.toJSON()), labelOk: `Remove`, message: (

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

@@ -94,9 +80,9 @@ export class RoleBindingDetails extends React.Component { return (
- + - + Kind @@ -110,26 +96,27 @@ export class RoleBindingDetails extends React.Component {
- + {subjects.length > 0 && ( - - Binding + + Name Type Namespace { 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(() => this.selectedSubjects.toggle(subject))} > - + {name} {kind} {namespace || "-"} @@ -141,9 +128,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 +153,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/+role-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/+role-bindings/dialog.scss diff --git a/src/renderer/components/+user-management/+role-bindings/dialog.tsx b/src/renderer/components/+user-management/+role-bindings/dialog.tsx new file mode 100644 index 0000000000..0bed732381 --- /dev/null +++ b/src/renderer/components/+user-management/+role-bindings/dialog.tsx @@ -0,0 +1,303 @@ +/** + * 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, action } 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, ServiceAccountOption } from "../select-options"; +import { ObservableHashSet, 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([], sa => sa.metadata.uid); + 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(): ServiceAccountOption[] { + return serviceAccountsStore.items.map(account => { + const name = account.getName(); + const namespace = account.getNs(); + + return { + value: `${account.getName()}%${account.getNs()}`, + account, + label: <> {name} ({namespace}) + }; + }); + } + + @computed get selectedServiceAccountOptions(): ServiceAccountOption[] { + return this.serviceAccountOptions.filter(({ account }) => this.selectedAccounts.has(account)); + } + + @action + 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)); + }; + + @action + 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(this.roleBinding, 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 + { } diff --git a/src/renderer/components/+user-management/+service-accounts/index.ts b/src/renderer/components/+user-management/+service-accounts/index.ts new file mode 100644 index 0000000000..b02c71be28 --- /dev/null +++ b/src/renderer/components/+user-management/+service-accounts/index.ts @@ -0,0 +1,23 @@ +/** + * 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. + */ +export * from "./view"; +export * from "./details"; +export * from "./create-dialog"; diff --git a/src/renderer/components/+user-management-service-accounts/service-accounts-secret.scss b/src/renderer/components/+user-management/+service-accounts/secret.scss similarity index 100% rename from src/renderer/components/+user-management-service-accounts/service-accounts-secret.scss rename to src/renderer/components/+user-management/+service-accounts/secret.scss diff --git a/src/renderer/components/+user-management-service-accounts/service-accounts-secret.tsx b/src/renderer/components/+user-management/+service-accounts/secret.tsx similarity index 94% rename from src/renderer/components/+user-management-service-accounts/service-accounts-secret.tsx rename to src/renderer/components/+user-management/+service-accounts/secret.tsx index d57bb8cc05..4eff2b30dd 100644 --- a/src/renderer/components/+user-management-service-accounts/service-accounts-secret.tsx +++ b/src/renderer/components/+user-management/+service-accounts/secret.tsx @@ -19,13 +19,14 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import "./service-accounts-secret.scss"; +import "./secret.scss"; -import React from "react"; import moment from "moment"; -import { Icon } from "../icon"; -import type { Secret } from "../../api/endpoints/secret.api"; -import { prevDefault } from "../../utils"; +import React from "react"; + +import type { Secret } from "../../../api/endpoints/secret.api"; +import { prevDefault } from "../../../utils"; +import { Icon } from "../../icon"; interface Props { secret: Secret; diff --git a/src/renderer/components/+user-management-service-accounts/service-accounts.store.ts b/src/renderer/components/+user-management/+service-accounts/store.ts similarity index 87% rename from src/renderer/components/+user-management-service-accounts/service-accounts.store.ts rename to src/renderer/components/+user-management/+service-accounts/store.ts index c0917dab12..51bbabca34 100644 --- a/src/renderer/components/+user-management-service-accounts/service-accounts.store.ts +++ b/src/renderer/components/+user-management/+service-accounts/store.ts @@ -19,10 +19,10 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import { autoBind } from "../../utils"; -import { ServiceAccount, serviceAccountsApi } from "../../api/endpoints"; -import { KubeObjectStore } from "../../kube-object.store"; -import { apiManager } from "../../api/api-manager"; +import { apiManager } from "../../../api/api-manager"; +import { ServiceAccount, serviceAccountsApi } from "../../../api/endpoints"; +import { KubeObjectStore } from "../../../kube-object.store"; +import { autoBind } from "../../../utils"; export class ServiceAccountsStore extends KubeObjectStore { api = serviceAccountsApi; diff --git a/src/renderer/components/+user-management-service-accounts/service-accounts.scss b/src/renderer/components/+user-management/+service-accounts/view.scss similarity index 100% rename from src/renderer/components/+user-management-service-accounts/service-accounts.scss rename to src/renderer/components/+user-management/+service-accounts/view.scss diff --git a/src/renderer/components/+user-management-service-accounts/service-accounts.tsx b/src/renderer/components/+user-management/+service-accounts/view.tsx similarity index 80% rename from src/renderer/components/+user-management-service-accounts/service-accounts.tsx rename to src/renderer/components/+user-management/+service-accounts/view.tsx index dff4d1fe58..442fbe3d77 100644 --- a/src/renderer/components/+user-management-service-accounts/service-accounts.tsx +++ b/src/renderer/components/+user-management/+service-accounts/view.tsx @@ -19,22 +19,22 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import "./service-accounts.scss"; +import "./view.scss"; -import React from "react"; import { observer } from "mobx-react"; -import type { ServiceAccount } from "../../api/endpoints/service-accounts.api"; +import React from "react"; import type { RouteComponentProps } from "react-router"; -import type { KubeObjectMenuProps } from "../kube-object/kube-object-menu"; -import { MenuItem } from "../menu"; -import { openServiceAccountKubeConfig } from "../kubeconfig-dialog"; -import { Icon } from "../icon"; -import { KubeObjectListLayout } from "../kube-object"; -import type { IServiceAccountsRouteParams } from "../+user-management"; -import { serviceAccountsStore } from "./service-accounts.store"; -import { CreateServiceAccountDialog } from "./create-service-account-dialog"; -import { kubeObjectMenuRegistry } from "../../../extensions/registries/kube-object-menu-registry"; -import { KubeObjectStatusIcon } from "../kube-object-status-icon"; +import type { ServiceAccountsRouteParams } from "../user-management.route"; +import { kubeObjectMenuRegistry } from "../../../../extensions/registries/kube-object-menu-registry"; +import type { ServiceAccount } from "../../../api/endpoints/service-accounts.api"; +import { Icon } from "../../icon"; +import { KubeObjectListLayout } from "../../kube-object"; +import { KubeObjectStatusIcon } from "../../kube-object-status-icon"; +import type { KubeObjectMenuProps } from "../../kube-object/kube-object-menu"; +import { openServiceAccountKubeConfig } from "../../kubeconfig-dialog"; +import { MenuItem } from "../../menu"; +import { CreateServiceAccountDialog } from "./create-dialog"; +import { serviceAccountsStore } from "./store"; enum columnId { name = "name", @@ -42,7 +42,7 @@ enum columnId { age = "age", } -interface Props extends RouteComponentProps { +interface Props extends RouteComponentProps { } @observer diff --git a/src/renderer/components/+user-management/select-options.tsx b/src/renderer/components/+user-management/select-options.tsx new file mode 100644 index 0000000000..65bfeeec9e --- /dev/null +++ b/src/renderer/components/+user-management/select-options.tsx @@ -0,0 +1,49 @@ +/** + * 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 React from "react"; +import type { ServiceAccount } from "../../api/endpoints"; +import type { KubeObject } from "../../api/kube-object"; +import { Icon } from "../icon"; +import type { SelectOption } from "../select"; +import { TooltipPosition } from "../tooltip"; + +export type ServiceAccountOption = SelectOption & { account: ServiceAccount }; + +export function getRoleRefSelectOption(item: T): SelectOption { + return { + value: item, + label: ( + <> + + {" "} + {item.getName()} + + ), + }; +} diff --git a/src/renderer/components/+user-management/user-management.route.ts b/src/renderer/components/+user-management/user-management.route.ts index f6e4def1e8..290e7fa2d4 100644 --- a/src/renderer/components/+user-management/user-management.route.ts +++ b/src/renderer/components/+user-management/user-management.route.ts @@ -19,45 +19,62 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import type { RouteProps } from "react-router"; import { buildURL, IURLParams } from "../../../common/utils/buildUrl"; +import type { RouteProps } from "react-router"; + // Routes export const serviceAccountsRoute: RouteProps = { path: "/service-accounts" }; +export const podSecurityPoliciesRoute: RouteProps = { + path: "/pod-security-policies" +}; export const rolesRoute: RouteProps = { path: "/roles" }; +export const clusterRolesRoute: RouteProps = { + path: "/cluster-roles" +}; export const roleBindingsRoute: RouteProps = { path: "/role-bindings" }; -export const podSecurityPoliciesRoute: RouteProps = { - path: "/pod-security-policies" +export const clusterRoleBindingsRoute: RouteProps = { + path: "/cluster-role-bindings" }; export const usersManagementRoute: RouteProps = { path: [ serviceAccountsRoute, + podSecurityPoliciesRoute, roleBindingsRoute, + clusterRoleBindingsRoute, rolesRoute, - podSecurityPoliciesRoute + clusterRolesRoute, ].map(route => route.path.toString()) }; // Route params -export interface IServiceAccountsRouteParams { +export interface ServiceAccountsRouteParams { } -export interface IRoleBindingsRouteParams { +export interface RoleBindingsRouteParams { } -export interface IRolesRouteParams { +export interface ClusterRoleBindingsRouteParams { +} + +export interface RolesRouteParams { +} + +export interface ClusterRolesRouteParams { } // URL-builders export const usersManagementURL = (params?: IURLParams) => serviceAccountsURL(params); -export const serviceAccountsURL = buildURL(serviceAccountsRoute.path); -export const roleBindingsURL = buildURL(roleBindingsRoute.path); -export const rolesURL = buildURL(rolesRoute.path); +export const serviceAccountsURL = buildURL(serviceAccountsRoute.path); export const podSecurityPoliciesURL = buildURL(podSecurityPoliciesRoute.path); +export const roleBindingsURL = buildURL(roleBindingsRoute.path); +export const clusterRoleBindingsURL = buildURL(clusterRoleBindingsRoute.path); +export const rolesURL = buildURL(rolesRoute.path); +export const clusterRolesURL = buildURL(clusterRolesRoute.path); diff --git a/src/renderer/components/+user-management/user-management.tsx b/src/renderer/components/+user-management/user-management.tsx index cb5c5837f3..c714f34525 100644 --- a/src/renderer/components/+user-management/user-management.tsx +++ b/src/renderer/components/+user-management/user-management.tsx @@ -20,15 +20,32 @@ */ import "./user-management.scss"; -import React from "react"; + import { observer } from "mobx-react"; -import { TabLayout, TabLayoutRoute } from "../layout/tab-layout"; -import { Roles } from "../+user-management-roles"; -import { RoleBindings } from "../+user-management-roles-bindings"; -import { ServiceAccounts } from "../+user-management-service-accounts"; -import { podSecurityPoliciesRoute, podSecurityPoliciesURL, roleBindingsRoute, roleBindingsURL, rolesRoute, rolesURL, serviceAccountsRoute, serviceAccountsURL } from "./user-management.route"; +import React from "react"; + import { PodSecurityPolicies } from "../+pod-security-policies"; import { isAllowedResource } from "../../../common/rbac"; +import { TabLayout, TabLayoutRoute } from "../layout/tab-layout"; +import { ClusterRoles } from "./+cluster-roles"; +import { ClusterRoleBindings } from "./+cluster-role-bindings"; +import { Roles } from "./+roles"; +import { RoleBindings } from "./+role-bindings"; +import { ServiceAccounts } from "./+service-accounts"; +import { + clusterRoleBindingsRoute, + clusterRoleBindingsURL, + clusterRolesRoute, + clusterRolesURL, + podSecurityPoliciesRoute, + podSecurityPoliciesURL, + roleBindingsRoute, + roleBindingsURL, + rolesRoute, + rolesURL, + serviceAccountsRoute, + serviceAccountsURL, +} from "./user-management.route"; @observer export class UserManagement extends React.Component { @@ -44,18 +61,16 @@ export class UserManagement extends React.Component { }); } - if (isAllowedResource("rolebindings") || isAllowedResource("clusterrolebindings")) { - // TODO: seperate out these two pages + if (isAllowedResource("clusterroles")) { tabRoutes.push({ - title: "Role Bindings", - component: RoleBindings, - url: roleBindingsURL(), - routePath: roleBindingsRoute.path.toString(), + title: "Cluster Roles", + component: ClusterRoles, + url: clusterRolesURL(), + routePath: clusterRolesRoute.path.toString(), }); } - if (isAllowedResource("roles") || isAllowedResource("clusterroles")) { - // TODO: seperate out these two pages + if (isAllowedResource("roles")) { tabRoutes.push({ title: "Roles", component: Roles, @@ -64,6 +79,24 @@ export class UserManagement extends React.Component { }); } + if (isAllowedResource("clusterrolebindings")) { + tabRoutes.push({ + title: "Cluster Role Bindings", + component: ClusterRoleBindings, + url: clusterRoleBindingsURL(), + routePath: clusterRoleBindingsRoute.path.toString(), + }); + } + + if (isAllowedResource("rolebindings")) { + tabRoutes.push({ + title: "Role Bindings", + component: RoleBindings, + url: roleBindingsURL(), + routePath: roleBindingsRoute.path.toString(), + }); + } + if (isAllowedResource("podsecuritypolicies")) { tabRoutes.push({ title: "Pod Security Policies", diff --git a/src/renderer/components/app.tsx b/src/renderer/components/app.tsx index 948fec03ce..cde061b1aa 100755 --- a/src/renderer/components/app.tsx +++ b/src/renderer/components/app.tsx @@ -41,7 +41,6 @@ import { Events } from "./+events/events"; import { eventRoute } from "./+events"; import { Apps, appsRoute } from "./+apps"; import { KubeObjectDetails } from "./kube-object/kube-object-details"; -import { AddRoleBindingDialog } from "./+user-management-roles-bindings"; import { DeploymentScaleDialog } from "./+workloads-deployments/deployment-scale-dialog"; import { CronJobTriggerDialog } from "./+workloads-cronjobs/cronjob-trigger-dialog"; import { CustomResources } from "./+custom-resources/custom-resources"; @@ -201,7 +200,6 @@ export class App extends React.Component { - diff --git a/src/renderer/components/editable-list/editable-list.scss b/src/renderer/components/editable-list/editable-list.scss index 9bcd266da1..b30fdf7f57 100644 --- a/src/renderer/components/editable-list/editable-list.scss +++ b/src/renderer/components/editable-list/editable-list.scss @@ -23,7 +23,7 @@ .el-contents { display: flex; flex-direction: column; - margin-top: $padding * 2; + margin: $padding 0px; .el-value-remove { .Icon { @@ -35,7 +35,9 @@ display: grid; grid-template-columns: 1fr auto; padding: $padding $padding * 2; - margin-bottom: 1px; + margin-bottom: $padding / 4; + backdrop-filter: brightness(0.75); + border-radius: var(--border-radius); :last-child { margin-bottom: unset; @@ -46,4 +48,4 @@ } } } -} \ No newline at end of file +} diff --git a/src/renderer/components/editable-list/editable-list.tsx b/src/renderer/components/editable-list/editable-list.tsx index 47bfaa8fe5..ec31a2586e 100644 --- a/src/renderer/components/editable-list/editable-list.tsx +++ b/src/renderer/components/editable-list/editable-list.tsx @@ -21,11 +21,11 @@ import "./editable-list.scss"; +import { observer } from "mobx-react"; import React from "react"; + import { Icon } from "../icon"; import { Input } from "../input"; -import { observable, makeObservable } from "mobx"; -import { observer } from "mobx-react"; import { boundMethod } from "../../utils"; export interface Props { @@ -47,20 +47,14 @@ const defaultProps: Partial> = { @observer export class EditableList extends React.Component> { static defaultProps = defaultProps as Props; - @observable currentNewItem = ""; - - constructor(props: Props) { - super(props); - makeObservable(this); - } @boundMethod - onSubmit(val: string) { + onSubmit(val: string, evt: React.KeyboardEvent) { const { add } = this.props; if (val) { + evt.preventDefault(); add(val); - this.currentNewItem = ""; } } @@ -71,17 +65,15 @@ export class EditableList extends React.Component> {
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/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 f1b9cfba96..f869d131d4 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"; @@ -309,51 +309,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