From b27a2654e45cfd0cebd626fbad027822c4f15575 Mon Sep 17 00:00:00 2001 From: Jim Ehrismann Date: Wed, 3 Nov 2021 11:53:54 -0400 Subject: [PATCH 1/5] Use JSON patch for editing components (#3674) Signed-off-by: Jim Ehrismann --- package.json | 1 + .../k8s-api/endpoints/resource-applier.api.ts | 34 ++++--- src/common/k8s-api/kube-object.store.ts | 21 +++-- src/common/k8s-api/kube-object.ts | 59 ++++++++---- src/main/resource-applier.ts | 89 ++++++++++++++----- src/main/router.ts | 1 + src/main/routes/resource-applier-route.ts | 14 ++- src/main/utils/http-responses.ts | 27 +++++- .../+config-maps/config-map-details.tsx | 2 + .../deployment-scale-dialog.test.tsx | 1 + .../replicaset-scale-dialog.test.tsx | 1 + .../statefulset-scale-dialog.test.tsx | 1 + .../components/dock/create-resource.tsx | 48 +++++----- .../components/dock/edit-resource.store.ts | 5 ++ .../components/dock/edit-resource.tsx | 26 +++--- src/renderer/components/dock/editor-panel.tsx | 5 +- .../kube-object-menu/kube-object-menu.tsx | 8 +- yarn.lock | 5 ++ 18 files changed, 242 insertions(+), 106 deletions(-) diff --git a/package.json b/package.json index af46679ade..3321d17a34 100644 --- a/package.json +++ b/package.json @@ -237,6 +237,7 @@ "readable-stream": "^3.6.0", "request": "^2.88.2", "request-promise-native": "^1.0.9", + "rfc6902": "^4.0.2", "semver": "^7.3.2", "serializr": "^2.0.5", "shell-env": "^3.0.1", diff --git a/src/common/k8s-api/endpoints/resource-applier.api.ts b/src/common/k8s-api/endpoints/resource-applier.api.ts index b6a35d3a60..14b5d5ad46 100644 --- a/src/common/k8s-api/endpoints/resource-applier.api.ts +++ b/src/common/k8s-api/endpoints/resource-applier.api.ts @@ -22,19 +22,27 @@ import jsYaml from "js-yaml"; import type { KubeJsonApiData } from "../kube-json-api"; import { apiBase } from "../index"; +import type { Patch } from "rfc6902"; -export const resourceApplierApi = { - annotations: [ - "kubectl.kubernetes.io/last-applied-configuration" - ], +export const annotations = [ + "kubectl.kubernetes.io/last-applied-configuration" +]; - async update(resource: object | string): Promise { - if (typeof resource === "string") { - resource = jsYaml.safeLoad(resource); - } - - const [data = null] = await apiBase.post("/stack", { data: resource }); - - return data; +export async function update(resource: object | string): Promise { + if (typeof resource === "string") { + resource = jsYaml.safeLoad(resource); } -}; + + return apiBase.post("/stack", { data: resource }); +} + +export async function patch(name: string, kind: string, ns: string, patch: Patch): Promise { + return apiBase.patch("/stack", { + data: { + name, + kind, + ns, + patch, + }, + }); +} diff --git a/src/common/k8s-api/kube-object.store.ts b/src/common/k8s-api/kube-object.store.ts index f90eaab8e5..7c8118da1f 100644 --- a/src/common/k8s-api/kube-object.store.ts +++ b/src/common/k8s-api/kube-object.store.ts @@ -32,6 +32,7 @@ import { parseKubeApi } from "./kube-api-parse"; import type { KubeJsonApiData } from "./kube-json-api"; import type { RequestInit } from "node-fetch"; import AbortController from "abort-controller"; +import type { Patch } from "rfc6902"; export interface KubeObjectStoreLoadingParams { namespaces: string[]; @@ -279,19 +280,29 @@ export abstract class KubeObjectStore extends ItemStore return newItem; } - async update(item: T, data: Partial): Promise { - const rawItem = await item.update(data); + private postUpdate(rawItem: KubeJsonApiData): T { const newItem = new this.api.objectConstructor(rawItem); + const index = this.items.findIndex(item => item.getId() === newItem.getId()); ensureObjectSelfLink(this.api, newItem); - const index = this.items.findIndex(item => item.getId() === newItem.getId()); - - this.items.splice(index, 1, newItem); + if (index < 0) { + this.items.push(newItem); + } else { + this.items[index] = newItem; + } return newItem; } + async patch(item: T, patch: Patch): Promise { + return this.postUpdate(await item.patch(patch)); + } + + async update(item: T, data: Partial): Promise { + return this.postUpdate(await item.update(data)); + } + async remove(item: T) { await item.delete(); this.items.remove(item); diff --git a/src/common/k8s-api/kube-object.ts b/src/common/k8s-api/kube-object.ts index c1e40eb2e5..bcbe6bf05f 100644 --- a/src/common/k8s-api/kube-object.ts +++ b/src/common/k8s-api/kube-object.ts @@ -27,9 +27,9 @@ import { autoBind, formatDuration } from "../utils"; import type { ItemObject } from "../item.store"; import { apiKube } from "./index"; import type { JsonApiParams } from "./json-api"; -import { resourceApplierApi } from "./endpoints/resource-applier.api"; +import * as resourceApplierApi from "./endpoints/resource-applier.api"; import { hasOptionalProperty, hasTypedProperty, isObject, isString, bindPredicate, isTypedArray, isRecord } from "../../common/utils/type-narrowing"; -import _ from "lodash"; +import type { Patch } from "rfc6902"; export type KubeObjectConstructor = (new (data: KubeJsonApiData | any) => K) & { kind?: string; @@ -191,18 +191,28 @@ export class KubeObject `${name}=${value}`); } - protected static readonly nonEditableFields = [ - "apiVersion", - "kind", - "metadata.name", - "metadata.selfLink", - "metadata.resourceVersion", - "metadata.uid", - "managedFields", - "status", + /** + * These must be RFC6902 compliant paths + */ + private static readonly nonEditiablePathPrefixes = [ + "/metadata/managedFields", + "/status", ]; + private static readonly nonEditablePaths = new Set([ + "/apiVersion", + "/kind", + "/metadata/name", + "/metadata/selfLink", + "/metadata/resourceVersion", + "/metadata/uid", + ...KubeObject.nonEditiablePathPrefixes, + ]); constructor(data: KubeJsonApiData) { + if (typeof data !== "object") { + throw new TypeError(`Cannot create a KubeObject from ${typeof data}`); + } + Object.assign(this, data); autoBind(this); } @@ -286,14 +296,31 @@ export class KubeObject): Promise { - for (const field of KubeObject.nonEditableFields) { - if (!_.isEqual(_.get(this, field), _.get(data, field))) { - throw new Error(`Failed to update Kube Object: ${field} has been modified`); + async patch(patch: Patch): Promise { + for (const op of patch) { + if (KubeObject.nonEditablePaths.has(op.path)) { + throw new Error(`Failed to update ${this.kind}: JSON pointer ${op.path} has been modified`); + } + + for (const pathPrefix of KubeObject.nonEditiablePathPrefixes) { + if (op.path.startsWith(`${pathPrefix}/`)) { + throw new Error(`Failed to update ${this.kind}: Child JSON pointer of ${op.path} has been modified`); + } } } + return resourceApplierApi.patch(this.getName(), this.kind, this.getNs(), patch); + } + + /** + * Perform a full update (or more specifically a replace) + * + * Note: this is brittle if `data` is not actually partial (but instead whole). + * As fields such as `resourceVersion` will probably out of date. This is a + * common race condition. + */ + async update(data: Partial): Promise { + // use unified resource-applier api for updating all k8s objects return resourceApplierApi.update({ ...this.toPlainObject(), ...data, diff --git a/src/main/resource-applier.ts b/src/main/resource-applier.ts index f4772c5275..a305ff1091 100644 --- a/src/main/resource-applier.ts +++ b/src/main/resource-applier.ts @@ -22,55 +22,96 @@ import type { Cluster } from "./cluster"; import type { KubernetesObject } from "@kubernetes/client-node"; import { exec } from "child_process"; -import fs from "fs"; +import fs from "fs-extra"; import * as yaml from "js-yaml"; import path from "path"; import * as tempy from "tempy"; import logger from "./logger"; import { appEventBus } from "../common/event-bus"; import { cloneJsonObject } from "../common/utils"; +import type { Patch } from "rfc6902"; +import { promiseExecFile } from "./promise-exec"; export class ResourceApplier { - constructor(protected cluster: Cluster) { + constructor(protected cluster: Cluster) {} + + /** + * Patch a kube resource's manifest, throwing any error that occurs. + * @param name The name of the kube resource + * @param kind The kind of the kube resource + * @param patch The list of JSON operations + * @param ns The optional namespace of the kube resource + */ + async patch(name: string, kind: string, patch: Patch, ns?: string): Promise { + appEventBus.emit({ name: "resource", action: "patch" }); + + const kubectl = await this.cluster.ensureKubectl(); + const kubectlPath = await kubectl.getPath(); + const proxyKubeconfigPath = await this.cluster.getProxyKubeconfigPath(); + const args = [ + "--kubeconfig", proxyKubeconfigPath, + "patch", + kind, + name, + ]; + + if (ns) { + args.push("--namespace", ns); + } + + args.push( + "--type", "json", + "--patch", JSON.stringify(patch), + "-o", "json" + ); + + try { + const { stdout } = await promiseExecFile(kubectlPath, args); + + return stdout; + } catch (error) { + throw error.stderr ?? error; + } } async apply(resource: KubernetesObject | any): Promise { resource = this.sanitizeObject(resource); appEventBus.emit({ name: "resource", action: "apply" }); - return await this.kubectlApply(yaml.safeDump(resource)); + return this.kubectlApply(yaml.safeDump(resource)); } protected async kubectlApply(content: string): Promise { const kubectl = await this.cluster.ensureKubectl(); const kubectlPath = await kubectl.getPath(); const proxyKubeconfigPath = await this.cluster.getProxyKubeconfigPath(); + const fileName = tempy.file({ name: "resource.yaml" }); + const args = [ + "apply", + "--kubeconfig", proxyKubeconfigPath, + "-o", "json", + "-f", fileName, + ]; - return new Promise((resolve, reject) => { - const fileName = tempy.file({ name: "resource.yaml" }); + logger.debug(`shooting manifests with ${kubectlPath}`, { args }); - fs.writeFileSync(fileName, content); - const cmd = `"${kubectlPath}" apply --kubeconfig "${proxyKubeconfigPath}" -o json -f "${fileName}"`; + const execEnv = { ...process.env }; + const httpsProxy = this.cluster.preferences?.httpsProxy; - logger.debug(`shooting manifests with: ${cmd}`); - const execEnv: NodeJS.ProcessEnv = Object.assign({}, process.env); - const httpsProxy = this.cluster.preferences?.httpsProxy; + if (httpsProxy) { + execEnv.HTTPS_PROXY = httpsProxy; + } - if (httpsProxy) { - execEnv["HTTPS_PROXY"] = httpsProxy; - } - exec(cmd, { env: execEnv }, - (error, stdout, stderr) => { - if (stderr != "") { - fs.unlinkSync(fileName); - reject(stderr); + try { + await fs.writeFile(fileName, content); + const { stdout } = await promiseExecFile(kubectlPath, args); - return; - } - fs.unlinkSync(fileName); - resolve(JSON.parse(stdout)); - }); - }); + return stdout; + } catch (error) { + throw error?.stderr ?? error; + } finally { + await fs.unlink(fileName); + } } public async kubectlApplyAll(resources: string[], extraArgs = ["-o", "json"]): Promise { diff --git a/src/main/router.ts b/src/main/router.ts index 50175aa9de..d758cc2314 100644 --- a/src/main/router.ts +++ b/src/main/router.ts @@ -198,5 +198,6 @@ export class Router { // Resource Applier API this.router.add({ method: "post", path: `${apiPrefix}/stack` }, ResourceApplierApiRoute.applyResource); + this.router.add({ method: "patch", path: `${apiPrefix}/stack` }, ResourceApplierApiRoute.patchResource); } } diff --git a/src/main/routes/resource-applier-route.ts b/src/main/routes/resource-applier-route.ts index cb716cf1f5..0adf916188 100644 --- a/src/main/routes/resource-applier-route.ts +++ b/src/main/routes/resource-applier-route.ts @@ -30,7 +30,19 @@ export class ResourceApplierApiRoute { try { const resource = await new ResourceApplier(cluster).apply(payload); - respondJson(response, [resource], 200); + respondJson(response, resource, 200); + } catch (error) { + respondText(response, error, 422); + } + } + + static async patchResource(request: LensApiRequest) { + const { response, cluster, payload } = request; + + try { + const resource = await new ResourceApplier(cluster).patch(payload.name, payload.kind, payload.patch, payload.ns); + + respondJson(response, resource, 200); } catch (error) { respondText(response, error, 422); } diff --git a/src/main/utils/http-responses.ts b/src/main/utils/http-responses.ts index 36287806f1..5d57b6dd76 100644 --- a/src/main/utils/http-responses.ts +++ b/src/main/utils/http-responses.ts @@ -21,14 +21,37 @@ import type http from "http"; -export function respondJson(res: http.ServerResponse, content: any, status = 200) { - respond(res, JSON.stringify(content), "application/json", status); +/** + * Respond to a HTTP request with a body of JSON data + * @param res The HTTP response to write data to + * @param content The data or its JSON stringified version of it + * @param status [200] The status code to respond with + */ +export function respondJson(res: http.ServerResponse, content: Object | string, status = 200) { + const normalizedContent = typeof content === "object" + ? JSON.stringify(content) + : content; + + respond(res, normalizedContent, "application/json", status); } +/** + * Respond to a HTTP request with a body of plain text data + * @param res The HTTP response to write data to + * @param content The string data to respond with + * @param status [200] The status code to respond with + */ export function respondText(res: http.ServerResponse, content: string, status = 200) { respond(res, content, "text/plain", status); } +/** + * Respond to a HTTP request with a body of plain text data + * @param res The HTTP response to write data to + * @param content The string data to respond with + * @param contentType The HTTP Content-Type header value + * @param status [200] The status code to respond with + */ export function respond(res: http.ServerResponse, content: string, contentType: string, status = 200) { res.setHeader("Content-Type", contentType); res.statusCode = status; diff --git a/src/renderer/components/+config-maps/config-map-details.tsx b/src/renderer/components/+config-maps/config-map-details.tsx index abbd31c97a..6150aa1ad9 100644 --- a/src/renderer/components/+config-maps/config-map-details.tsx +++ b/src/renderer/components/+config-maps/config-map-details.tsx @@ -72,6 +72,8 @@ export class ConfigMapDetails extends React.Component { <>ConfigMap {configMap.getName()} successfully updated.

); + } catch (error) { + Notifications.error(`Failed to save config map: ${error}`); } finally { this.isSaving = false; } diff --git a/src/renderer/components/+workloads-deployments/deployment-scale-dialog.test.tsx b/src/renderer/components/+workloads-deployments/deployment-scale-dialog.test.tsx index 22c90dda2c..3bc22b8263 100644 --- a/src/renderer/components/+workloads-deployments/deployment-scale-dialog.test.tsx +++ b/src/renderer/components/+workloads-deployments/deployment-scale-dialog.test.tsx @@ -112,6 +112,7 @@ const dummyDeployment: Deployment = { toPlainObject: jest.fn(), update: jest.fn(), delete: jest.fn(), + patch: jest.fn(), }; describe("", () => { diff --git a/src/renderer/components/+workloads-replicasets/replicaset-scale-dialog.test.tsx b/src/renderer/components/+workloads-replicasets/replicaset-scale-dialog.test.tsx index 7f8c31e99c..e948d3a339 100755 --- a/src/renderer/components/+workloads-replicasets/replicaset-scale-dialog.test.tsx +++ b/src/renderer/components/+workloads-replicasets/replicaset-scale-dialog.test.tsx @@ -107,6 +107,7 @@ const dummyReplicaSet: ReplicaSet = { toPlainObject: jest.fn(), update: jest.fn(), delete: jest.fn(), + patch: jest.fn(), }; describe("", () => { diff --git a/src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.test.tsx b/src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.test.tsx index b27d8b0892..016d431eb2 100755 --- a/src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.test.tsx +++ b/src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.test.tsx @@ -117,6 +117,7 @@ const dummyStatefulSet: StatefulSet = { toPlainObject: jest.fn(), update: jest.fn(), delete: jest.fn(), + patch: jest.fn(), }; describe("", () => { diff --git a/src/renderer/components/dock/create-resource.tsx b/src/renderer/components/dock/create-resource.tsx index a357d0dc62..c7de411e3c 100644 --- a/src/renderer/components/dock/create-resource.tsx +++ b/src/renderer/components/dock/create-resource.tsx @@ -33,10 +33,10 @@ import { createResourceStore } from "./create-resource.store"; import type { DockTab } from "./dock.store"; import { EditorPanel } from "./editor-panel"; import { InfoPanel } from "./info-panel"; -import { resourceApplierApi } from "../../../common/k8s-api/endpoints/resource-applier.api"; -import type { JsonApiErrorParsed } from "../../../common/k8s-api/json-api"; +import * as resourceApplierApi from "../../../common/k8s-api/endpoints/resource-applier.api"; import { Notifications } from "../notifications"; import { monacoModelsManager } from "./monaco-model-manager"; +import logger from "../../../common/logger"; interface Props { className?: string; @@ -95,7 +95,7 @@ export class CreateResource extends React.Component { }); }; - create = async () => { + create = async (): Promise => { if (this.error || !this.data.trim()) { // do not save when field is empty or there is an error return null; @@ -103,31 +103,31 @@ export class CreateResource extends React.Component { // skip empty documents if "---" pasted at the beginning or end const resources = jsYaml.safeLoadAll(this.data).filter(Boolean); - const createdResources: string[] = []; - const errors: string[] = []; - await Promise.all( - resources.map(data => { - return resourceApplierApi.update(data) - .then(item => createdResources.push(item.metadata.name)) - .catch((err: JsonApiErrorParsed) => errors.push(err.toString())); - }) - ); - - if (errors.length) { - errors.forEach(error => Notifications.error(error)); - if (!createdResources.length) throw errors[0]; + if (resources.length === 0) { + return void logger.info("Nothing to create"); } - const successMessage = ( -

- {createdResources.length === 1 ? "Resource" : "Resources"}{" "} - {createdResources.join(", ")} successfully created -

- ); - Notifications.ok(successMessage); + const createdResources: string[] = []; - return successMessage; + for (const result of await Promise.allSettled(resources.map(resourceApplierApi.update))) { + if (result.status === "fulfilled") { + createdResources.push(result.value.metadata.name); + } else { + Notifications.error(result.reason.toString()); + } + } + + if (createdResources.length > 0) { + Notifications.ok(( +

+ {createdResources.length === 1 ? "Resource" : "Resources"}{" "} + {createdResources.join(", ")} successfully created +

+ )); + } + + return undefined; }; renderControls(){ diff --git a/src/renderer/components/dock/edit-resource.store.ts b/src/renderer/components/dock/edit-resource.store.ts index b5be580313..d8b6d06d6b 100644 --- a/src/renderer/components/dock/edit-resource.store.ts +++ b/src/renderer/components/dock/edit-resource.store.ts @@ -31,6 +31,7 @@ import {monacoModelsManager} from "./monaco-model-manager"; export interface EditingResource { resource: string; // resource path, e.g. /api/v1/namespaces/default draft?: string; // edited draft in yaml + firstDraft?: string; } export class EditResourceStore extends DockTabStore { @@ -106,6 +107,10 @@ export class EditResourceStore extends DockTabStore { return dockStore.getTabById(tabId); } + clearInitialDraft(tabId: TabId): void { + delete this.getData(tabId)?.firstDraft; + } + reset() { super.reset(); Array.from(this.watchers).forEach(([tabId, dispose]) => { diff --git a/src/renderer/components/dock/edit-resource.tsx b/src/renderer/components/dock/edit-resource.tsx index 19a238ab30..068855eebb 100644 --- a/src/renderer/components/dock/edit-resource.tsx +++ b/src/renderer/components/dock/edit-resource.tsx @@ -24,7 +24,7 @@ import "./edit-resource.scss"; import React from "react"; import { action, computed, makeObservable, observable } from "mobx"; import { observer } from "mobx-react"; -import jsYaml from "js-yaml"; +import yaml from "js-yaml"; import type { DockTab } from "./dock.store"; import { cssNames } from "../../utils"; import { editResourceStore } from "./edit-resource.store"; @@ -33,6 +33,7 @@ import { Badge } from "../badge"; import { EditorPanel } from "./editor-panel"; import { Spinner } from "../spinner"; import type { KubeObject } from "../../../common/k8s-api/kube-object"; +import { createPatch } from "rfc6902"; interface Props { className?: string; @@ -71,16 +72,17 @@ export class EditResource extends React.Component { return draft; } - return jsYaml.safeDump(this.resource.toPlainObject()); // dump resource first time + return yaml.safeDump(this.resource.toPlainObject()); // dump resource first time } @action saveDraft(draft: string | object) { if (typeof draft === "object") { - draft = draft ? jsYaml.safeDump(draft) : undefined; + draft = draft ? yaml.safeDump(draft) : undefined; } editResourceStore.setData(this.tabId, { + firstDraft: draft, // this must be before the next line ...editResourceStore.getData(this.tabId), draft, }); @@ -95,16 +97,18 @@ export class EditResource extends React.Component { if (this.error) { return null; } - const store = editResourceStore.getStore(this.tabId); - const updatedResource: KubeObject = await store.update(this.resource, jsYaml.safeLoad(this.draft)); - this.saveDraft(updatedResource.toPlainObject()); // update with new resourceVersion to avoid further errors on save - const resourceType = updatedResource.kind; - const resourceName = updatedResource.getName(); + const store = editResourceStore.getStore(this.tabId); + const currentVersion = yaml.safeLoad(this.draft); + const firstVersion = yaml.safeLoad(editResourceStore.getData(this.tabId).firstDraft ?? this.draft); + const patches = createPatch(firstVersion, currentVersion); + const updatedResource = await store.patch(this.resource, patches); + + editResourceStore.clearInitialDraft(this.tabId); return (

- {resourceType} {resourceName} updated. + {updatedResource.kind} {updatedResource.getName()} updated.

); }; @@ -126,9 +130,9 @@ export class EditResource extends React.Component { submittingMessage="Applying.." controls={(
- Kind: + Kind: Name: - Namespace: + Namespace:
)} /> diff --git a/src/renderer/components/dock/editor-panel.tsx b/src/renderer/components/dock/editor-panel.tsx index 31ccc5b2e2..bbf0b96e53 100644 --- a/src/renderer/components/dock/editor-panel.tsx +++ b/src/renderer/components/dock/editor-panel.tsx @@ -91,10 +91,7 @@ export class EditorPanel extends React.Component { onChange = (value: string) => { this.validate(value); - - if (this.props.onChange) { - this.props.onChange(value, this.yamlError); - } + this.props.onChange?.(value, this.yamlError); }; render() { diff --git a/src/renderer/components/kube-object-menu/kube-object-menu.tsx b/src/renderer/components/kube-object-menu/kube-object-menu.tsx index 1848fa2906..ac2a99cc88 100644 --- a/src/renderer/components/kube-object-menu/kube-object-menu.tsx +++ b/src/renderer/components/kube-object-menu/kube-object-menu.tsx @@ -44,15 +44,11 @@ export class KubeObjectMenu extends React.Component Date: Thu, 7 Oct 2021 10:09:21 -0400 Subject: [PATCH 2/5] Catch metadata being undefined at KubeObject creation (#3960) Signed-off-by: Jim Ehrismann --- src/common/k8s-api/kube-object.ts | 14 +++- .../dock/__test__/log-tab.store.test.ts | 13 ++-- src/renderer/components/dock/dock.store.ts | 14 ++-- src/renderer/components/dock/log-tab.store.ts | 66 ++++++++++--------- 4 files changed, 59 insertions(+), 48 deletions(-) diff --git a/src/common/k8s-api/kube-object.ts b/src/common/k8s-api/kube-object.ts index bcbe6bf05f..0cb4ac15f6 100644 --- a/src/common/k8s-api/kube-object.ts +++ b/src/common/k8s-api/kube-object.ts @@ -98,6 +98,12 @@ export interface KubeObjectStatus { export type KubeMetaField = keyof KubeObjectMetadata; +export class KubeCreationError extends Error { + constructor(message: string, public data: any) { + super(message); + } +} + export class KubeObject implements ItemObject { static readonly kind: string; static readonly namespaced: boolean; @@ -209,10 +215,14 @@ export class KubeObject ({ ...ownerRef, namespace })); diff --git a/src/renderer/components/dock/__test__/log-tab.store.test.ts b/src/renderer/components/dock/__test__/log-tab.store.test.ts index 8eea8ce970..5221c1a8c0 100644 --- a/src/renderer/components/dock/__test__/log-tab.store.test.ts +++ b/src/renderer/components/dock/__test__/log-tab.store.test.ts @@ -25,9 +25,11 @@ import { Pod } from "../../../../common/k8s-api/endpoints"; import { ThemeStore } from "../../../theme.store"; import { dockStore } from "../dock.store"; import { logTabStore } from "../log-tab.store"; -import { TerminalStore } from "../terminal.store"; import { deploymentPod1, deploymentPod2, deploymentPod3, dockerPod } from "./pod.mock"; import fse from "fs-extra"; +import { mockWindow } from "../../../../../__mocks__/windowMock"; + +mockWindow(); jest.mock("react-monaco-editor", () => null); @@ -45,7 +47,6 @@ describe("log tab store", () => { beforeEach(() => { UserStore.createInstance(); ThemeStore.createInstance(); - TerminalStore.createInstance(); }); afterEach(() => { @@ -53,7 +54,6 @@ describe("log tab store", () => { dockStore.reset(); UserStore.resetInstance(); ThemeStore.resetInstance(); - TerminalStore.resetInstance(); fse.remove("tmp"); }); @@ -135,11 +135,11 @@ describe("log tab store", () => { }); // FIXME: this is failed when it's not .only == depends on something above - it.only("closes tab if no pods left in store", () => { + it.only("closes tab if no pods left in store", async () => { const selectedPod = new Pod(deploymentPod1); const selectedContainer = selectedPod.getInitContainers()[0]; - logTabStore.createPodTab({ + const id = logTabStore.createPodTab({ selectedPod, selectedContainer }); @@ -147,6 +147,7 @@ describe("log tab store", () => { podsStore.items.clear(); expect(logTabStore.getData(dockStore.selectedTabId)).toBeUndefined(); - expect(dockStore.getTabById(dockStore.selectedTabId)).toBeUndefined(); + expect(logTabStore.getData(id)).toBeUndefined(); + expect(dockStore.getTabById(id)).toBeUndefined(); }); }); diff --git a/src/renderer/components/dock/dock.store.ts b/src/renderer/components/dock/dock.store.ts index 7551de1a2f..f2fd74a064 100644 --- a/src/renderer/components/dock/dock.store.ts +++ b/src/renderer/components/dock/dock.store.ts @@ -160,7 +160,7 @@ export class DockStore implements DockStorageState { window.addEventListener("resize", throttle(this.adjustHeight, 250)); // create monaco models this.whenReady.then(() => {this.tabs.forEach(tab => { - if (this.usesMonacoEditor(tab)) { + if (this.usesMonacoEditor(tab)) { monacoModelsManager.addModel(tab.id); } });}); @@ -274,7 +274,7 @@ export class DockStore implements DockStorageState { title }; - // add monaco model + // add monaco model if (this.usesMonacoEditor(tab)) { monacoModelsManager.addModel(id); } @@ -287,14 +287,14 @@ export class DockStore implements DockStorageState { } @action - async closeTab(tabId: TabId) { + closeTab(tabId: TabId) { const tab = this.getTabById(tabId); if (!tab || tab.pinned) { return; } - // remove monaco model + // remove monaco model if (this.usesMonacoEditor(tab)) { monacoModelsManager.removeModel(tabId); } @@ -305,12 +305,6 @@ export class DockStore implements DockStorageState { if (this.tabs.length) { const newTab = this.tabs.slice(-1)[0]; // last - if (newTab?.kind === TabKind.TERMINAL) { - // close the dock when selected sibling inactive terminal tab - const { TerminalStore } = await import("./terminal.store"); - - if (!TerminalStore.getInstance(false)?.isConnected(newTab.id)) this.close(); - } this.selectTab(newTab.id); } else { this.selectedTabId = null; diff --git a/src/renderer/components/dock/log-tab.store.ts b/src/renderer/components/dock/log-tab.store.ts index 5b6b73904f..d28410a8ad 100644 --- a/src/renderer/components/dock/log-tab.store.ts +++ b/src/renderer/components/dock/log-tab.store.ts @@ -25,6 +25,7 @@ import { podsStore } from "../+workloads-pods/pods.store"; import { IPodContainer, Pod } from "../../../common/k8s-api/endpoints"; import type { WorkloadKubeObject } from "../../../common/k8s-api/workload-kube-object"; +import logger from "../../../common/logger"; import { DockTabStore } from "./dock-tab.store"; import { dockStore, DockTabCreateSpecific, TabKind } from "./dock.store"; @@ -51,17 +52,15 @@ export class LogTabStore extends DockTabStore { storageKey: "pod_logs" }); - reaction(() => podsStore.items.length, () => { - this.updateTabsData(); - }); + reaction(() => podsStore.items.length, () => this.updateTabsData()); } - createPodTab({ selectedPod, selectedContainer }: PodLogsTabData): void { + createPodTab({ selectedPod, selectedContainer }: PodLogsTabData): string { const podOwner = selectedPod.getOwnerRefs()[0]; const pods = podsStore.getPodsByOwnerId(podOwner?.uid); const title = `Pod ${selectedPod.getName()}`; - this.createLogsTab(title, { + return this.createLogsTab(title, { pods: pods.length ? pods : [selectedPod], selectedPod, selectedContainer @@ -95,9 +94,9 @@ export class LogTabStore extends DockTabStore { ...tabParams, kind: TabKind.POD_LOGS, }, false); - } + } - private createLogsTab(title: string, data: LogTabData) { + private createLogsTab(title: string, data: LogTabData): string { const id = uniqueId("log-tab-"); this.createDockTab({ id, title }); @@ -106,38 +105,45 @@ export class LogTabStore extends DockTabStore { showTimestamps: false, previous: false }); + + return id; } - private async updateTabsData() { - const promises: Promise[] = []; - + private updateTabsData() { for (const [tabId, tabData] of this.data) { - const pod = new Pod(tabData.selectedPod); - const pods = podsStore.getPodsByOwnerId(pod.getOwnerRefs()[0]?.uid); - const isSelectedPodInList = pods.find(item => item.getId() == pod.getId()); - const selectedPod = isSelectedPodInList ? pod : pods[0]; - const selectedContainer = isSelectedPodInList ? tabData.selectedContainer : pod.getAllContainers()[0]; + try { + if (!tabData.selectedPod) { + tabData.selectedPod = tabData.pods[0]; + } - if (pods.length) { - this.setData(tabId, { - ...tabData, - selectedPod, - selectedContainer, - pods - }); - - this.renameTab(tabId); - } else { - promises.push(this.closeTab(tabId)); + const pod = new Pod(tabData.selectedPod); + const pods = podsStore.getPodsByOwnerId(pod.getOwnerRefs()[0]?.uid); + const isSelectedPodInList = pods.find(item => item.getId() == pod.getId()); + const selectedPod = isSelectedPodInList ? pod : pods[0]; + const selectedContainer = isSelectedPodInList ? tabData.selectedContainer : pod.getAllContainers()[0]; + + if (pods.length > 0) { + this.setData(tabId, { + ...tabData, + selectedPod, + selectedContainer, + pods + }); + + this.renameTab(tabId); + } else { + this.closeTab(tabId); + } + } catch (error) { + logger.error(`[LOG-TAB-STORE]: failed to set data for tabId=${tabId} deleting`, error,); + this.data.delete(tabId); } } - - await Promise.all(promises); } - private async closeTab(tabId: string) { + private closeTab(tabId: string) { this.clearData(tabId); - await dockStore.closeTab(tabId); + dockStore.closeTab(tabId); } } From 9782c37d11f4f6e980e85cdc2e44309e5505a253 Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Tue, 12 Oct 2021 07:31:11 -0400 Subject: [PATCH 3/5] Use the served and storage version of a CRD instead of the first (#3999) Signed-off-by: Jim Ehrismann --- src/common/k8s-api/__tests__/crd.test.ts | 144 +++++++++++++---------- src/common/k8s-api/endpoints/crd.api.ts | 86 +++++++++++--- src/common/k8s-api/json-api.ts | 3 +- src/renderer/utils/createStorage.ts | 5 +- 4 files changed, 152 insertions(+), 86 deletions(-) diff --git a/src/common/k8s-api/__tests__/crd.test.ts b/src/common/k8s-api/__tests__/crd.test.ts index 459a7f67d9..a981cdda12 100644 --- a/src/common/k8s-api/__tests__/crd.test.ts +++ b/src/common/k8s-api/__tests__/crd.test.ts @@ -20,92 +20,108 @@ */ import { CustomResourceDefinition } from "../endpoints"; -import type { KubeObjectMetadata } from "../kube-object"; describe("Crds", () => { describe("getVersion", () => { - it("should get the first version name from the list of versions", () => { + it("should throw if none of the versions are served", () => { const crd = new CustomResourceDefinition({ - apiVersion: "foo", + apiVersion: "apiextensions.k8s.io/v1", kind: "CustomResourceDefinition", - metadata: {} as KubeObjectMetadata, + metadata: { + name: "foo", + resourceVersion: "12345", + uid: "12345", + }, + spec: { + versions: [ + { + name: "123", + served: false, + storage: false, + }, + { + name: "1234", + served: false, + storage: false, + }, + ], + }, }); - crd.spec = { - versions: [ - { - name: "123", - served: false, - storage: false, - } - ] - } as any; + expect(() => crd.getVersion()).toThrowError("Failed to find a version for CustomResourceDefinition foo"); + }); + + it("should should get the version that is both served and stored", () => { + const crd = new CustomResourceDefinition({ + apiVersion: "apiextensions.k8s.io/v1", + kind: "CustomResourceDefinition", + metadata: { + name: "foo", + resourceVersion: "12345", + uid: "12345", + }, + spec: { + versions: [ + { + name: "123", + served: true, + storage: true, + }, + { + name: "1234", + served: false, + storage: false, + }, + ], + }, + }); expect(crd.getVersion()).toBe("123"); }); - it("should get the first version name from the list of versions (length 2)", () => { + it("should should get the version that is both served and stored even with version field", () => { const crd = new CustomResourceDefinition({ - apiVersion: "foo", + apiVersion: "apiextensions.k8s.io/v1", kind: "CustomResourceDefinition", - metadata: {} as KubeObjectMetadata, + metadata: { + name: "foo", + resourceVersion: "12345", + uid: "12345", + }, + spec: { + version: "abc", + versions: [ + { + name: "123", + served: true, + storage: true, + }, + { + name: "1234", + served: false, + storage: false, + }, + ], + }, }); - crd.spec = { - versions: [ - { - name: "123", - served: false, - storage: false, - }, - { - name: "1234", - served: false, - storage: false, - } - ] - } as any; - expect(crd.getVersion()).toBe("123"); }); - it("should get the first version name from the list of versions (length 2) even with version field", () => { + it("should get the version name from the version field", () => { const crd = new CustomResourceDefinition({ - apiVersion: "foo", + apiVersion: "apiextensions.k8s.io/v1beta1", kind: "CustomResourceDefinition", - metadata: {} as KubeObjectMetadata, + metadata: { + name: "foo", + resourceVersion: "12345", + uid: "12345", + }, + spec: { + version: "abc", + } }); - crd.spec = { - version: "abc", - versions: [ - { - name: "123", - served: false, - storage: false, - }, - { - name: "1234", - served: false, - storage: false, - } - ] - } as any; - - expect(crd.getVersion()).toBe("123"); - }); - - it("should get the first version name from the version field", () => { - const crd = new CustomResourceDefinition({ - apiVersion: "foo", - kind: "CustomResourceDefinition", - metadata: {} as KubeObjectMetadata, - }); - - crd.spec = { - version: "abc" - } as any; - expect(crd.getVersion()).toBe("abc"); }); }); diff --git a/src/common/k8s-api/endpoints/crd.api.ts b/src/common/k8s-api/endpoints/crd.api.ts index b1ab179cc4..98c8d38df0 100644 --- a/src/common/k8s-api/endpoints/crd.api.ts +++ b/src/common/k8s-api/endpoints/crd.api.ts @@ -19,10 +19,11 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import { KubeObject } from "../kube-object"; +import { KubeCreationError, KubeObject } from "../kube-object"; import { KubeApi } from "../kube-api"; import { crdResourcesURL } from "../../routes"; import { isClusterPageContext } from "../../utils/cluster-id-url-parsing"; +import type { KubeJsonApiData } from "../kube-json-api"; type AdditionalPrinterColumnsCommon = { name: string; @@ -39,10 +40,21 @@ type AdditionalPrinterColumnsV1Beta = AdditionalPrinterColumnsCommon & { JSONPath: string; }; +export interface CRDVersion { + name: string; + served: boolean; + storage: boolean; + schema?: object; // required in v1 but not present in v1beta + additionalPrinterColumns?: AdditionalPrinterColumnsV1[]; +} + export interface CustomResourceDefinition { spec: { group: string; - version?: string; // deprecated in v1 api + /** + * @deprecated for apiextensions.k8s.io/v1 but used previously + */ + version?: string; names: { plural: string; singular: string; @@ -50,19 +62,19 @@ export interface CustomResourceDefinition { listKind: string; }; scope: "Namespaced" | "Cluster" | string; - validation?: any; - versions?: { - name: string; - served: boolean; - storage: boolean; - schema?: unknown; // required in v1 but not present in v1beta - additionalPrinterColumns?: AdditionalPrinterColumnsV1[] - }[]; + /** + * @deprecated for apiextensions.k8s.io/v1 but used previously + */ + validation?: object; + versions?: CRDVersion[]; conversion: { strategy?: string; webhook?: any; }; - additionalPrinterColumns?: AdditionalPrinterColumnsV1Beta[]; // removed in v1 + /** + * @deprecated for apiextensions.k8s.io/v1 but used previously + */ + additionalPrinterColumns?: AdditionalPrinterColumnsV1Beta[]; }; status: { conditions: { @@ -83,11 +95,23 @@ export interface CustomResourceDefinition { }; } +export interface CRDApiData extends KubeJsonApiData { + spec: object; // TODO: make better +} + export class CustomResourceDefinition extends KubeObject { static kind = "CustomResourceDefinition"; static namespaced = false; static apiBase = "/apis/apiextensions.k8s.io/v1/customresourcedefinitions"; + constructor(data: CRDApiData) { + super(data); + + if (!data.spec || typeof data.spec !== "object") { + throw new KubeCreationError("Cannot create a CustomResourceDefinition from an object without spec", data); + } + } + getResourceUrl() { return crdResourcesURL({ params: { @@ -125,9 +149,36 @@ export class CustomResourceDefinition extends KubeObject { return this.spec.scope; } + getPreferedVersion(): CRDVersion { + // Prefer the modern `versions` over the legacy `version` + if (this.spec.versions) { + for (const version of this.spec.versions) { + /** + * If the version is not served then 404 errors will occur + * We should also prefer the storage version + */ + if (version.served && version.storage) { + return version; + } + } + } else if (this.spec.version) { + const { additionalPrinterColumns: apc } = this.spec; + const additionalPrinterColumns = apc?.map(({ JSONPath, ...apc}) => ({ ...apc, jsonPath: JSONPath })); + + return { + name: this.spec.version, + served: true, + storage: true, + schema: this.spec.validation, + additionalPrinterColumns, + }; + } + + throw new Error(`Failed to find a version for CustomResourceDefinition ${this.metadata.name}`); + } + getVersion() { - // v1 has removed the spec.version property, if it is present it must match the first version - return this.spec.versions?.[0]?.name ?? this.spec.version; + return this.getPreferedVersion().name; } isNamespaced() { @@ -147,17 +198,14 @@ export class CustomResourceDefinition extends KubeObject { } getPrinterColumns(ignorePriority = true): AdditionalPrinterColumnsV1[] { - const columns = this.spec.versions?.find(a => this.getVersion() == a.name)?.additionalPrinterColumns - ?? this.spec.additionalPrinterColumns?.map(({ JSONPath, ...rest }) => ({ ...rest, jsonPath: JSONPath })) // map to V1 shape - ?? []; + const columns = this.getPreferedVersion().additionalPrinterColumns ?? []; return columns - .filter(column => column.name != "Age") - .filter(column => ignorePriority ? true : !column.priority); + .filter(column => column.name != "Age" && (ignorePriority || !column.priority)); } getValidation() { - return JSON.stringify(this.spec.validation ?? this.spec.versions?.[0]?.schema, null, 2); + return JSON.stringify(this.getPreferedVersion().schema, null, 2); } getConditions() { diff --git a/src/common/k8s-api/json-api.ts b/src/common/k8s-api/json-api.ts index 8690521099..dde8f5f5a2 100644 --- a/src/common/k8s-api/json-api.ts +++ b/src/common/k8s-api/json-api.ts @@ -27,8 +27,7 @@ import { stringify } from "querystring"; import { EventEmitter } from "../../common/event-emitter"; import logger from "../../common/logger"; -export interface JsonApiData { -} +export interface JsonApiData {} export interface JsonApiError { code?: number; diff --git a/src/renderer/utils/createStorage.ts b/src/renderer/utils/createStorage.ts index 854a7aef2f..2e79e35209 100755 --- a/src/renderer/utils/createStorage.ts +++ b/src/renderer/utils/createStorage.ts @@ -28,6 +28,7 @@ import { StorageHelper } from "./storageHelper"; import { ClusterStore } from "../../common/cluster-store"; import logger from "../../main/logger"; import { getHostedClusterId, getPath } from "../../common/utils"; +import { isTestEnv } from "../../common/vars"; const storage = observable({ initialized: false, @@ -62,7 +63,9 @@ export function createAppStorage(key: string, defaultValue: T, clusterId?: st .then(data => storage.data = data) .catch(() => null) // ignore empty / non-existing / invalid json files .finally(() => { - logger.info(`${logPrefix} loading finished for ${filePath}`); + if (!isTestEnv) { + logger.info(`${logPrefix} loading finished for ${filePath}`); + } storage.loaded = true; }); From f2b2c34fe00304206b0b13347864bf0719b93eb2 Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Fri, 15 Oct 2021 12:11:46 -0400 Subject: [PATCH 4/5] Check object instanceof on all detail panels (#4054) * Check object instanceof on all detail panels Signed-off-by: Sebastian Malton * Fix unit tests - Remove prettier as snapShot testing is too tight of a bound Signed-off-by: Sebastian Malton Signed-off-by: Jim Ehrismann --- .../+config-autoscalers/hpa-details.tsx | 26 +++++++----- .../limit-range-details.tsx | 20 ++++++--- .../+config-maps/config-map-details.tsx | 42 +++++++++++-------- .../pod-disruption-budgets-details.tsx | 14 ++++++- .../resource-quota-details.tsx | 13 +++++- .../+config-secrets/secret-details.tsx | 13 +++++- .../+custom-resources/crd-details.tsx | 14 ++++++- .../crd-resource-details.tsx | 17 +++++++- .../components/+events/event-details.tsx | 14 ++++++- .../components/+events/kube-event-details.tsx | 14 ++++++- .../+namespaces/namespace-details.tsx | 12 +++++- .../+network-endpoints/endpoint-details.tsx | 13 +++++- .../+network-ingresses/ingress-details.tsx | 9 +++- .../network-policy-details.tsx | 10 ++++- .../+network-services/service-details.tsx | 14 ++++++- .../components/+nodes/node-details.tsx | 12 +++++- .../pod-security-policy-details.tsx | 35 ++++++++++------ .../storage-class-details.tsx | 16 +++++-- .../volume-claim-details.tsx | 8 ++++ .../+storage-volumes/volume-details.tsx | 8 ++++ .../+workloads-cronjobs/cronjob-details.tsx | 24 +++++++---- .../daemonset-details.tsx | 12 +++++- .../deployment-details.tsx | 12 +++++- .../+workloads-jobs/job-details.tsx | 12 +++++- .../+workloads-pods/pod-details.tsx | 12 +++++- .../replicaset-details.tsx | 12 +++++- .../statefulset-details.tsx | 12 +++++- .../kube-object-meta/kube-object-meta.tsx | 14 ++++++- 28 files changed, 351 insertions(+), 83 deletions(-) diff --git a/src/renderer/components/+config-autoscalers/hpa-details.tsx b/src/renderer/components/+config-autoscalers/hpa-details.tsx index 4dd544e5b1..3e6c74b9bb 100644 --- a/src/renderer/components/+config-autoscalers/hpa-details.tsx +++ b/src/renderer/components/+config-autoscalers/hpa-details.tsx @@ -33,6 +33,7 @@ import { Table, TableCell, TableHead, TableRow } from "../table"; import { apiManager } from "../../../common/k8s-api/api-manager"; import { KubeObjectMeta } from "../kube-object-meta"; import { getDetailsUrl } from "../kube-detail-params"; +import logger from "../../../common/logger"; export interface HpaDetailsProps extends KubeObjectDetailsProps { } @@ -80,17 +81,13 @@ export class HpaDetails extends React.Component { Current / Target { - hpa.getMetrics().map((metric, index) => { - const name = renderName(metric); - const values = hpa.getMetricValues(metric); - - return ( + hpa.getMetrics() + .map((metric, index) => ( - {name} - {values} + {renderName(metric)} + {hpa.getMetricValues(metric)} - ); - }) + )) } ); @@ -99,7 +96,16 @@ export class HpaDetails extends React.Component { render() { const { object: hpa } = this.props; - if (!hpa) return null; + if (!hpa) { + return null; + } + + if (!(hpa instanceof HorizontalPodAutoscaler)) { + logger.error("[HpaDetails]: passed object that is not an instanceof HorizontalPodAutoscaler", hpa); + + return null; + } + const { scaleTargetRef } = hpa.spec; return ( diff --git a/src/renderer/components/+config-limit-ranges/limit-range-details.tsx b/src/renderer/components/+config-limit-ranges/limit-range-details.tsx index e9acbd0a22..14ecbec7ed 100644 --- a/src/renderer/components/+config-limit-ranges/limit-range-details.tsx +++ b/src/renderer/components/+config-limit-ranges/limit-range-details.tsx @@ -28,6 +28,7 @@ import { LimitPart, LimitRange, LimitRangeItem, Resource } from "../../../common import { KubeObjectMeta } from "../kube-object-meta"; import { DrawerItem } from "../drawer/drawer-item"; import { Badge } from "../badge"; +import logger from "../../../common/logger"; interface Props extends KubeObjectDetailsProps { } @@ -57,15 +58,13 @@ function renderResourceLimits(limit: LimitRangeItem, resource: Resource) { function renderLimitDetails(limits: LimitRangeItem[], resources: Resource[]) { - return resources.map(resource => + return resources.map(resource => ( { - limits.map(limit => - renderResourceLimits(limit, resource) - ) + limits.map(limit => renderResourceLimits(limit, resource)) } - ); + )); } @observer @@ -73,7 +72,16 @@ export class LimitRangeDetails extends React.Component { render() { const { object: limitRange } = this.props; - if (!limitRange) return null; + if (!limitRange) { + return null; + } + + if (!(limitRange instanceof LimitRange)) { + logger.error("[LimitRangeDetails]: passed object that is not an instanceof LimitRange", limitRange); + + return null; + } + const containerLimits = limitRange.getContainerLimits(); const podLimits = limitRange.getPodLimits(); const pvcLimits = limitRange.getPVCLimits(); diff --git a/src/renderer/components/+config-maps/config-map-details.tsx b/src/renderer/components/+config-maps/config-map-details.tsx index 6150aa1ad9..082a340a88 100644 --- a/src/renderer/components/+config-maps/config-map-details.tsx +++ b/src/renderer/components/+config-maps/config-map-details.tsx @@ -30,8 +30,9 @@ import { Input } from "../input"; import { Button } from "../button"; import { configMapsStore } from "./config-maps.store"; import type { KubeObjectDetailsProps } from "../kube-object-details"; -import type { ConfigMap } from "../../../common/k8s-api/endpoints"; +import { ConfigMap } from "../../../common/k8s-api/endpoints"; import { KubeObjectMeta } from "../kube-object-meta"; +import logger from "../../../common/logger"; interface Props extends KubeObjectDetailsProps { } @@ -82,7 +83,16 @@ export class ConfigMapDetails extends React.Component { render() { const { object: configMap } = this.props; - if (!configMap) return null; + if (!configMap) { + return null; + } + + if (!(configMap instanceof ConfigMap)) { + logger.error("[ConfigMapDetails]: passed object that is not an instanceof ConfigMap", configMap); + + return null; + } + const data = Array.from(this.data.entries()); return ( @@ -93,22 +103,20 @@ export class ConfigMapDetails extends React.Component { <> { - data.map(([name, value]) => { - return ( -
-
{name}
-
- this.data.set(name, v)} - /> -
+ data.map(([name, value]) => ( +
+
{name}
+
+ this.data.set(name, v)} + />
- ); - }) +
+ )) }