From cea7a340145f62c650a41aaee744f61ae49a5f9b Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Wed, 17 Nov 2021 17:03:46 -0500 Subject: [PATCH] Fix crash with logs view - Switch to saving only IDs of pods, owners, and containers instead of whole objects - Add more checks about data integrety Signed-off-by: Sebastian Malton --- .../package-lock.json | 12 +- .../metrics-cluster-feature/package-lock.json | 12 +- extensions/node-menu/package-lock.json | 12 +- src/common/base-store.ts | 2 +- src/common/ipc/ipc.ts | 4 +- src/common/k8s-api/kube-object.store.ts | 22 +- src/common/k8s-api/kube-object.ts | 18 +- src/common/k8s-api/kube-watch-api.ts | 15 +- src/common/utils/comparer.ts | 50 ++++ src/common/utils/index.ts | 2 + src/common/utils/reactions.ts | 42 +++ src/main/cluster-manager.ts | 2 +- src/main/cluster.ts | 4 +- .../__test__/log-resource-selector.test.tsx | 64 ++--- .../dock/__test__/log-tab.store.test.ts | 219 +++++++-------- .../components/dock/__test__/pod.mock.ts | 49 +++- .../components/dock/dock-tab.store.ts | 89 ++++-- .../components/dock/edit-resource.store.ts | 4 +- src/renderer/components/dock/log-controls.tsx | 45 ++- src/renderer/components/dock/log-list.tsx | 102 ++++--- .../dock/log-resource-selector.scss | 4 +- .../components/dock/log-resource-selector.tsx | 142 +++++----- src/renderer/components/dock/log-search.scss | 3 +- src/renderer/components/dock/log-tab.store.ts | 219 +++++++++------ src/renderer/components/dock/log.store.ts | 141 +++++----- src/renderer/components/dock/logs.scss | 27 ++ src/renderer/components/dock/logs.tsx | 257 +++++++++++++----- src/renderer/utils/createStorage.ts | 27 +- 28 files changed, 973 insertions(+), 616 deletions(-) create mode 100644 src/common/utils/comparer.ts create mode 100644 src/common/utils/reactions.ts create mode 100644 src/renderer/components/dock/logs.scss diff --git a/extensions/kube-object-event-status/package-lock.json b/extensions/kube-object-event-status/package-lock.json index 648af47afb..24542e5c7a 100644 --- a/extensions/kube-object-event-status/package-lock.json +++ b/extensions/kube-object-event-status/package-lock.json @@ -116,9 +116,9 @@ "dev": true }, "@types/react": { - "version": "17.0.35", - "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.35.tgz", - "integrity": "sha512-r3C8/TJuri/SLZiiwwxQoLAoavaczARfT9up9b4Jr65+ErAUX3MIkU0oMOQnrpfgHme8zIqZLX7O5nnjm5Wayw==", + "version": "17.0.37", + "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.37.tgz", + "integrity": "sha512-2FS1oTqBGcH/s0E+CjrCCR9+JMpsu9b69RTFO+40ua43ZqP5MmQ4iUde/dMjWR909KxZwmOQIFq6AV6NjEG5xg==", "dev": true, "requires": { "@types/prop-types": "*", @@ -222,9 +222,9 @@ } }, "csstype": { - "version": "2.6.18", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-2.6.18.tgz", - "integrity": "sha512-RSU6Hyeg14am3Ah4VZEmeX8H7kLwEEirXe6aU2IPfKNvhXwTflK5HQRDNI0ypQXoqmm+QPyG2IaPuQE5zMwSIQ==", + "version": "2.6.19", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-2.6.19.tgz", + "integrity": "sha512-ZVxXaNy28/k3kJg0Fou5MiYpp88j7H9hLZp8PDC3jV0WFjfH5E9xHb56L0W59cPbKbcHXeP4qyT8PrHp8t6LcQ==", "dev": true }, "debounce-fn": { diff --git a/extensions/metrics-cluster-feature/package-lock.json b/extensions/metrics-cluster-feature/package-lock.json index b67c57706b..3c1c3a1230 100644 --- a/extensions/metrics-cluster-feature/package-lock.json +++ b/extensions/metrics-cluster-feature/package-lock.json @@ -770,9 +770,9 @@ "dev": true }, "@types/react": { - "version": "17.0.35", - "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.35.tgz", - "integrity": "sha512-r3C8/TJuri/SLZiiwwxQoLAoavaczARfT9up9b4Jr65+ErAUX3MIkU0oMOQnrpfgHme8zIqZLX7O5nnjm5Wayw==", + "version": "17.0.37", + "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.37.tgz", + "integrity": "sha512-2FS1oTqBGcH/s0E+CjrCCR9+JMpsu9b69RTFO+40ua43ZqP5MmQ4iUde/dMjWR909KxZwmOQIFq6AV6NjEG5xg==", "dev": true, "requires": { "@types/prop-types": "*", @@ -876,9 +876,9 @@ } }, "csstype": { - "version": "2.6.18", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-2.6.18.tgz", - "integrity": "sha512-RSU6Hyeg14am3Ah4VZEmeX8H7kLwEEirXe6aU2IPfKNvhXwTflK5HQRDNI0ypQXoqmm+QPyG2IaPuQE5zMwSIQ==", + "version": "2.6.19", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-2.6.19.tgz", + "integrity": "sha512-ZVxXaNy28/k3kJg0Fou5MiYpp88j7H9hLZp8PDC3jV0WFjfH5E9xHb56L0W59cPbKbcHXeP4qyT8PrHp8t6LcQ==", "dev": true }, "debounce-fn": { diff --git a/extensions/node-menu/package-lock.json b/extensions/node-menu/package-lock.json index 3d8c201d93..d6d7440100 100644 --- a/extensions/node-menu/package-lock.json +++ b/extensions/node-menu/package-lock.json @@ -736,9 +736,9 @@ "dev": true }, "@types/react": { - "version": "17.0.35", - "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.35.tgz", - "integrity": "sha512-r3C8/TJuri/SLZiiwwxQoLAoavaczARfT9up9b4Jr65+ErAUX3MIkU0oMOQnrpfgHme8zIqZLX7O5nnjm5Wayw==", + "version": "17.0.37", + "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.37.tgz", + "integrity": "sha512-2FS1oTqBGcH/s0E+CjrCCR9+JMpsu9b69RTFO+40ua43ZqP5MmQ4iUde/dMjWR909KxZwmOQIFq6AV6NjEG5xg==", "dev": true, "requires": { "@types/prop-types": "*", @@ -842,9 +842,9 @@ } }, "csstype": { - "version": "2.6.18", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-2.6.18.tgz", - "integrity": "sha512-RSU6Hyeg14am3Ah4VZEmeX8H7kLwEEirXe6aU2IPfKNvhXwTflK5HQRDNI0ypQXoqmm+QPyG2IaPuQE5zMwSIQ==", + "version": "2.6.19", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-2.6.19.tgz", + "integrity": "sha512-ZVxXaNy28/k3kJg0Fou5MiYpp88j7H9hLZp8PDC3jV0WFjfH5E9xHb56L0W59cPbKbcHXeP4qyT8PrHp8t6LcQ==", "dev": true }, "debounce-fn": { diff --git a/src/common/base-store.ts b/src/common/base-store.ts index 9f00ec3fbb..5bc613d107 100644 --- a/src/common/base-store.ts +++ b/src/common/base-store.ts @@ -62,7 +62,7 @@ export abstract class BaseStore extends Singleton { */ load() { if (!isTestEnv) { - logger.info(`[${kebabCase(this.displayName).toUpperCase()}]: LOADING from ${this.path} ...`); + logger.info(`[${kebabCase(this.displayName).toUpperCase()}]: LOADING store with key ${this.params.configName} ...`); } this.storeConfig = new Config({ diff --git a/src/common/ipc/ipc.ts b/src/common/ipc/ipc.ts index 0442d07630..95b7c672a4 100644 --- a/src/common/ipc/ipc.ts +++ b/src/common/ipc/ipc.ts @@ -34,7 +34,9 @@ const electronRemote = (() => { if (ipcRenderer) { try { return require("@electron/remote"); - } catch {} + } catch { + // ignore temp + } } return null; diff --git a/src/common/k8s-api/kube-object.store.ts b/src/common/k8s-api/kube-object.store.ts index 1eb7d9ff50..827d54028c 100644 --- a/src/common/k8s-api/kube-object.store.ts +++ b/src/common/k8s-api/kube-object.store.ts @@ -102,13 +102,13 @@ export abstract class KubeObjectStore extends ItemStore } @computed get contextItems(): T[] { - const namespaces = this.context?.contextNamespaces ?? []; + if (!this.api.isNamespaced) { + return this.items; + } - return this.items.filter(item => { - const itemNamespace = item.getNs(); + const namespaces = new Set(this.context?.contextNamespaces ?? []); - return !itemNamespace /* cluster-wide */ || namespaces.includes(itemNamespace); - }); + return this.items.filter(item => namespaces.has(item.getNs())); } getTotalCount(): number { @@ -275,12 +275,18 @@ export abstract class KubeObjectStore extends ItemStore protected mergeItems(partialItems: T[], { merge = true, updateStore = true, sort = true, filter = true } = {}): T[] { let items = partialItems; - // update existing items if (merge) { - const namespaces = partialItems.map(item => item.getNs()); + /** + * Replace older KubeObjects (by UID) with the new ones. + * + * This is done by UID and not by namespace (as was previous) because + * the store might be loaded (and watching) two different sets of + * namespaces at the same time. + */ + const uids = new Set(partialItems.map(item => item.getId())); items = [ - ...this.items.filter(existingItem => !namespaces.includes(existingItem.getNs())), + ...this.items.filter(existingItem => !uids.has(existingItem.getId())), ...partialItems, ]; } diff --git a/src/common/k8s-api/kube-object.ts b/src/common/k8s-api/kube-object.ts index 0338472c60..87d3f17d29 100644 --- a/src/common/k8s-api/kube-object.ts +++ b/src/common/k8s-api/kube-object.ts @@ -37,6 +37,15 @@ export type KubeObjectConstructor = (new (data: KubeJsonAp apiBase?: string; }; +export interface KubeOwnerReference { + apiVersion: string; + kind: string; + name: string; + uid: string; + controller: boolean; + blockOwnerDeletion: boolean; +} + export interface KubeObjectMetadata { uid: string; name: string; @@ -53,14 +62,7 @@ export interface KubeObjectMetadata { annotations?: { [annotation: string]: string; }; - ownerReferences?: { - apiVersion: string; - kind: string; - name: string; - uid: string; - controller: boolean; - blockOwnerDeletion: boolean; - }[]; + ownerReferences?: KubeOwnerReference[]; } export interface KubeStatusData { diff --git a/src/common/k8s-api/kube-watch-api.ts b/src/common/k8s-api/kube-watch-api.ts index 63636eddc3..8f390b1fb9 100644 --- a/src/common/k8s-api/kube-watch-api.ts +++ b/src/common/k8s-api/kube-watch-api.ts @@ -50,14 +50,15 @@ export interface IKubeWatchEvent { export interface KubeWatchSubscribeStoreOptions { /** - * The namespaces to watch + * The namespaces to subscribe to. If not specified then will also watch the + * changes to set of selected namespaces from `` * @default all selected namespaces */ namespaces?: string[]; /** * A function that is called when listing fails. If set then blocks errors - * being rejected with + * from rejecting promises */ onLoadFailure?: (err: any) => void; } @@ -116,7 +117,11 @@ export class KubeWatchApi { #watch = new WatchCount(); private subscribeStore({ store, parent, watchChanges, namespaces, onLoadFailure }: SubscribeStoreParams): Disposer { - if (this.#watch.inc(store) > 1) { + const isNamespaceFilterWatch = !namespaces; + + namespaces ??= KubeWatchApi.context?.contextNamespaces ?? []; + + if (isNamespaceFilterWatch && this.#watch.inc(store) > 1) { // don't load or subscribe to a store more than once return () => this.#watch.dec(store); } @@ -167,7 +172,7 @@ export class KubeWatchApi { : noop; // don't watch namespaces if namespaces were provided return () => { - if (this.#watch.dec(store) === 0) { + if (!isNamespaceFilterWatch || this.#watch.dec(store) === 0) { // only stop the subcribe if this is the last one cancelReloading(); childController.abort(); @@ -183,7 +188,7 @@ export class KubeWatchApi { store, parent, watchChanges: !namespaces && store.api.isNamespaced, - namespaces: namespaces ?? KubeWatchApi.context?.contextNamespaces ?? [], + namespaces, onLoadFailure, })), ); diff --git a/src/common/utils/comparer.ts b/src/common/utils/comparer.ts new file mode 100644 index 0000000000..1186fa4520 --- /dev/null +++ b/src/common/utils/comparer.ts @@ -0,0 +1,50 @@ +/** + * 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. + */ + +/** + * The functions of this module should be used so that typescript doesn't + * assume the types of a `reaction` or `autorun` is `any` + */ + +import { comparer as _comparer } from "mobx"; + +function identity(a: T, b: T): boolean { + return _comparer.identity(a, b); +} + +function defaultComparer(a: T, b: T): boolean { + return _comparer.default(a, b); +} + +function structural(a: T, b: T): boolean { + return _comparer.structural(a, b); +} + +function shallow(a: T, b: T): boolean { + return _comparer.shallow(a, b); +} + +export const comparer = { + identity, + default: defaultComparer, + structural, + shallow, +}; diff --git a/src/common/utils/index.ts b/src/common/utils/index.ts index ea2f742bc7..6760a59082 100644 --- a/src/common/utils/index.ts +++ b/src/common/utils/index.ts @@ -31,6 +31,7 @@ export * from "./autobind"; export * from "./camelCase"; export * from "./cloneJson"; export * from "./cluster-id-url-parsing"; +export * from "./comparer"; export * from "./convertCpu"; export * from "./convertMemory"; export * from "./debouncePromise"; @@ -49,6 +50,7 @@ export * from "./objects"; export * from "./openExternal"; export * from "./paths"; export * from "./promise-exec"; +export * from "./reactions"; export * from "./reject-promise"; export * from "./singleton"; export * from "./sort-compare"; diff --git a/src/common/utils/reactions.ts b/src/common/utils/reactions.ts new file mode 100644 index 0000000000..c882684cca --- /dev/null +++ b/src/common/utils/reactions.ts @@ -0,0 +1,42 @@ +/** + * 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 type { IReactionPublic, IReactionOptions, IReactionDisposer } from "mobx"; +import { reaction } from "mobx"; +import type { Disposer } from "./disposer"; + +/** + * Similar to mobx's builtin `reaction` function but supports returning a + * disposer from `effect` that will be cancelled everytime a new reaction is + * fired and when the reaction is disposed. + */ +export function disposingReaction(expression: (r: IReactionPublic) => T, effect: (arg: T, prev: FireImmediately extends true ? T | undefined : T, r: IReactionPublic) => Disposer, opts?: IReactionOptions): IReactionDisposer { + let prevDisposer: Disposer; + + const reactionDisposer = reaction(expression, (arg: T, prev: T, r: IReactionPublic) => { + prevDisposer?.(); + prevDisposer = effect(arg, prev, r); + }, opts); + + return Object.assign(() => { + reactionDisposer(); + prevDisposer?.(); + }, reactionDisposer); +} diff --git a/src/main/cluster-manager.ts b/src/main/cluster-manager.ts index 850b87c7b8..f655f38571 100644 --- a/src/main/cluster-manager.ts +++ b/src/main/cluster-manager.ts @@ -205,7 +205,7 @@ export class ClusterManager extends Singleton { if (error.code === "ENOENT" && error.path === entity.spec.kubeconfigPath) { logger.warn(`${logPrefix} kubeconfig file disappeared`, model); } else { - logger.error(`${logPrefix} failed to add cluster: ${error}`, model); + logger.error(`${logPrefix} failed to add cluster: ${error}`, { model, source: entity.metadata.source }); } } } else { diff --git a/src/main/cluster.ts b/src/main/cluster.ts index 2ec97c792a..7113c556d7 100644 --- a/src/main/cluster.ts +++ b/src/main/cluster.ts @@ -20,7 +20,7 @@ */ import { ipcMain } from "electron"; -import { action, comparer, computed, makeObservable, observable, reaction, when } from "mobx"; +import { action, computed, makeObservable, observable, reaction, when } from "mobx"; import { broadcastMessage, ClusterListNamespaceForbiddenChannel } from "../common/ipc"; import { ContextHandler } from "./context-handler"; import { AuthorizationV1Api, CoreV1Api, HttpError, KubeConfig, V1ResourceAttributes } from "@kubernetes/client-node"; @@ -34,7 +34,7 @@ import { DetectorRegistry } from "./cluster-detectors/detector-registry"; import plimit from "p-limit"; import type { ClusterState, ClusterRefreshOptions, ClusterMetricsResourceType, ClusterId, ClusterMetadata, ClusterModel, ClusterPreferences, ClusterPrometheusPreferences, UpdateClusterModel, KubeAuthUpdate } from "../common/cluster-types"; import { ClusterMetadataKey, initialNodeShellImage, ClusterStatus } from "../common/cluster-types"; -import { disposer, storedKubeConfigFolder, toJS } from "../common/utils"; +import { disposer, storedKubeConfigFolder, toJS, comparer } from "../common/utils"; import type { Response } from "request"; /** diff --git a/src/renderer/components/dock/__test__/log-resource-selector.test.tsx b/src/renderer/components/dock/__test__/log-resource-selector.test.tsx index 9fe75a9756..a0565c11ac 100644 --- a/src/renderer/components/dock/__test__/log-resource-selector.test.tsx +++ b/src/renderer/components/dock/__test__/log-resource-selector.test.tsx @@ -25,8 +25,7 @@ import { render } from "@testing-library/react"; import * as selectEvent from "react-select-event"; import { Pod } from "../../../../common/k8s-api/endpoints"; -import { LogResourceSelector } from "../log-resource-selector"; -import type { LogTabData } from "../log-tab.store"; +import { LogResourceSelector, LogResourceSelectorProps } from "../log-resource-selector"; import { dockerPod, deploymentPod1 } from "./pod.mock"; import { ThemeStore } from "../../../theme.store"; import { UserStore } from "../../../../common/user-store"; @@ -51,35 +50,26 @@ jest.mock("electron", () => ({ AppPaths.init(); -const getComponent = (tabData: LogTabData) => { - return ( - - ); -}; - -const getOnePodTabData = (): LogTabData => { +const getOnePodTabProps = (): LogResourceSelectorProps => { const selectedPod = new Pod(dockerPod); return { - pods: [] as Pod[], - selectedPod, - selectedContainer: selectedPod.getContainers()[0], + pod: selectedPod, + pods: [selectedPod], + tabId: "tabId", + selectedContainer: selectedPod.getContainers()[0].name, }; }; -const getFewPodsTabData = (): LogTabData => { +const getFewPodsTabProps = (): LogResourceSelectorProps => { const selectedPod = new Pod(deploymentPod1); const anotherPod = new Pod(dockerPod); return { - pods: [anotherPod], - selectedPod, - selectedContainer: selectedPod.getContainers()[0], + pod: selectedPod, + pods: [selectedPod, anotherPod], + tabId: "tabId", + selectedContainer: selectedPod.getContainers()[0].name, }; }; @@ -99,42 +89,46 @@ describe("", () => { }); it("renders w/o errors", () => { - const tabData = getOnePodTabData(); - const { container } = render(getComponent(tabData)); + const props = getOnePodTabProps(); + const { container } = render(); expect(container).toBeInstanceOf(HTMLElement); }); it("renders proper namespace", () => { - const tabData = getOnePodTabData(); - const { getByTestId } = render(getComponent(tabData)); + const props = getOnePodTabProps(); + const { getByTestId } = render(); const ns = getByTestId("namespace-badge"); expect(ns).toHaveTextContent("default"); }); it("renders proper selected items within dropdowns", () => { - const tabData = getOnePodTabData(); - const { getByText } = render(getComponent(tabData)); + const props = getOnePodTabProps(); + const { getByText } = render(); expect(getByText("dockerExporter")).toBeInTheDocument(); expect(getByText("docker-exporter")).toBeInTheDocument(); }); it("renders sibling pods in dropdown", () => { - const tabData = getFewPodsTabData(); - const { container, getByText } = render(getComponent(tabData)); + const props = getFewPodsTabProps(); + const { container, getByText } = render(); const podSelector: HTMLElement = container.querySelector(".pod-selector"); selectEvent.openMenu(podSelector); - expect(getByText("dockerExporter")).toBeInTheDocument(); - expect(getByText("deploymentPod1")).toBeInTheDocument(); + expect(getByText("dockerExporter", { + selector: ".Select__option", + })).toBeInTheDocument(); + expect(getByText("deploymentPod1", { + selector: ".Select__option", + })).toBeInTheDocument(); }); it("renders sibling containers in dropdown", () => { - const tabData = getFewPodsTabData(); - const { getByText, container } = render(getComponent(tabData)); + const props = getFewPodsTabProps(); + const { getByText, container } = render(); const containerSelector: HTMLElement = container.querySelector(".container-selector"); selectEvent.openMenu(containerSelector); @@ -145,8 +139,8 @@ describe("", () => { }); it("renders pod owner as dropdown title", () => { - const tabData = getFewPodsTabData(); - const { getByText, container } = render(getComponent(tabData)); + const props = getFewPodsTabProps(); + const { getByText, container } = render(); const podSelector: HTMLElement = container.querySelector(".pod-selector"); selectEvent.openMenu(podSelector); 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 a342ddce79..233d3349f6 100644 --- a/src/renderer/components/dock/__test__/log-tab.store.test.ts +++ b/src/renderer/components/dock/__test__/log-tab.store.test.ts @@ -19,146 +19,137 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import { podsStore } from "../../+workloads-pods/pods.store"; -import { UserStore } from "../../../../common/user-store"; import { Pod } from "../../../../common/k8s-api/endpoints"; -import { ThemeStore } from "../../../theme.store"; -import { dockStore } from "../dock.store"; -import { logTabStore } from "../log-tab.store"; -import { deploymentPod1, deploymentPod2, deploymentPod3, dockerPod } from "./pod.mock"; -import fse from "fs-extra"; -import { mockWindow } from "../../../../../__mocks__/windowMock"; -import { AppPaths } from "../../../../common/app-paths"; +import type { TabId } from "../dock.store"; +import { DockManager, LogTabStore } from "../log-tab.store"; +import { deploymentPod1, dockerPod, noOwnersPod } from "./pod.mock"; -mockWindow(); +function getMockDockManager(): jest.Mocked { + return { + renameTab: jest.fn(), + createTab: jest.fn(), + closeTab: jest.fn(), + }; +} -jest.mock("electron", () => ({ - app: { - getVersion: () => "99.99.99", - getName: () => "lens", - setName: jest.fn(), - setPath: jest.fn(), - getPath: () => "tmp", - getLocale: () => "en", - setLoginItemSettings: jest.fn(), - }, - ipcMain: { - on: jest.fn(), - handle: jest.fn(), - }, -})); +describe("LogTabStore", () => { + describe("createPodTab()", () => { + it("throws if data is not an object", () => { + const dockManager = getMockDockManager(); + const logTabStore = new LogTabStore({ autoInit: false }, dockManager); -AppPaths.init(); - -podsStore.items.push(new Pod(dockerPod)); -podsStore.items.push(new Pod(deploymentPod1)); -podsStore.items.push(new Pod(deploymentPod2)); - -describe("log tab store", () => { - beforeEach(() => { - UserStore.createInstance(); - ThemeStore.createInstance(); - }); - - afterEach(() => { - logTabStore.reset(); - dockStore.reset(); - UserStore.resetInstance(); - ThemeStore.resetInstance(); - fse.remove("tmp"); - }); - - it("creates log tab without sibling pods", () => { - const selectedPod = new Pod(dockerPod); - const selectedContainer = selectedPod.getAllContainers()[0]; - - logTabStore.createPodTab({ - selectedPod, - selectedContainer, + expect(() => logTabStore.createPodTab(0 as any)).toThrow(/is not an object/); }); - expect(logTabStore.getData(dockStore.selectedTabId)).toEqual({ - pods: [selectedPod], - selectedPod, - selectedContainer, - showTimestamps: false, - previous: false, + it("throws if data.selectedPod is not an object", () => { + const dockManager = getMockDockManager(); + const logTabStore = new LogTabStore({ autoInit: false }, dockManager); + + expect(() => logTabStore.createPodTab({ selectedPod: 1 as any, selectedContainer: {} as any })).toThrow(/is not an object/); + }); + + it("throws if data.selectedContainer is not an object", () => { + const dockManager = getMockDockManager(); + const logTabStore = new LogTabStore({ autoInit: false }, dockManager); + + expect(() => logTabStore.createPodTab({ selectedContainer: 1 as any, selectedPod: {} as any })).toThrow(/is not an object/); + }); + + it("does not throw if selectedPod has no owner refs", () => { + const dockManager = getMockDockManager(); + const logTabStore = new LogTabStore({ autoInit: false }, dockManager); + const pod = new Pod(noOwnersPod); + + expect(() => logTabStore.createPodTab({ selectedContainer: {} as any, selectedPod: pod })).not.toThrow(); + }); + + it("should return a TabId if created sucessfully", () => { + const dockManager = getMockDockManager(); + const logTabStore = new LogTabStore({ autoInit: false }, dockManager); + const pod = new Pod(dockerPod); + + expect(typeof logTabStore.createPodTab({ selectedContainer: { name: "docker-exporter" } as any, selectedPod: pod })).toBe("string"); }); }); - it("creates log tab with sibling pods", () => { - const selectedPod = new Pod(deploymentPod1); - const siblingPod = new Pod(deploymentPod2); - const selectedContainer = selectedPod.getInitContainers()[0]; + describe("changeSelectedPod()", () => { + let dockManager: jest.Mocked; + let logTabStore: LogTabStore; + let tabId: TabId; + const pod = new Pod(dockerPod); - logTabStore.createPodTab({ - selectedPod, - selectedContainer, + beforeEach(() => { + dockManager = getMockDockManager(); + logTabStore = new LogTabStore({ autoInit: false }, dockManager); + tabId = logTabStore.createPodTab({ selectedContainer: { name: "docker-exporter" } as any, selectedPod: pod }); }); - expect(logTabStore.getData(dockStore.selectedTabId)).toEqual({ - pods: [selectedPod, siblingPod], - selectedPod, - selectedContainer, - showTimestamps: false, - previous: false, + it("should work as expected", () => { + const newPod = new Pod(deploymentPod1); + + logTabStore.changeSelectedPod(tabId, newPod); + + expect(logTabStore.getData(tabId)).toMatchObject({ + selectedPod: newPod.getId(), + selectedContainer: newPod.getContainers()[0].name, + }); + }); + + it("should rename the tab", () => { + const newPod = new Pod(deploymentPod1); + + logTabStore.changeSelectedPod(tabId, newPod); + expect(dockManager.renameTab).toBeCalledWith(tabId, "Pod Logs: deploymentPod1"); }); }); - it("removes item from pods list if pod deleted from store", () => { - const selectedPod = new Pod(deploymentPod1); - const selectedContainer = selectedPod.getInitContainers()[0]; + describe("getData()", () => { + let dockManager: jest.Mocked; + let logTabStore: LogTabStore; + let tabId: TabId; + const pod = new Pod(dockerPod); - logTabStore.createPodTab({ - selectedPod, - selectedContainer, + beforeEach(() => { + dockManager = getMockDockManager(); + logTabStore = new LogTabStore({ autoInit: false }, dockManager); + tabId = logTabStore.createPodTab({ selectedContainer: { name: "docker-exporter" } as any, selectedPod: pod }); }); - podsStore.items.pop(); + it("should return data if created", () => { + expect(logTabStore.getData(tabId)).toMatchObject({ + selectedPod: pod.getId(), + selectedContainer: "docker-exporter", + }); + }); - expect(logTabStore.getData(dockStore.selectedTabId)).toEqual({ - pods: [selectedPod], - selectedPod, - selectedContainer, - showTimestamps: false, - previous: false, + it("should return undefined for unknown tab ID", () => { + expect(logTabStore.getData("foo")).toBeUndefined(); }); }); - it("adds item into pods list if new sibling pod added to store", () => { - const selectedPod = new Pod(deploymentPod1); - const selectedContainer = selectedPod.getInitContainers()[0]; + describe("setData()", () => { + let dockManager: jest.Mocked; + let logTabStore: LogTabStore; - logTabStore.createPodTab({ - selectedPod, - selectedContainer, + beforeEach(() => { + dockManager = getMockDockManager(); + logTabStore = new LogTabStore({ autoInit: false }, dockManager); }); - podsStore.items.push(new Pod(deploymentPod3)); - - expect(logTabStore.getData(dockStore.selectedTabId)).toEqual({ - pods: [selectedPod, deploymentPod3], - selectedPod, - selectedContainer, - showTimestamps: false, - previous: false, - }); - }); - - // FIXME: this is failed when it's not .only == depends on something above - it.only("closes tab if no pods left in store", async () => { - const selectedPod = new Pod(deploymentPod1); - const selectedContainer = selectedPod.getInitContainers()[0]; - - const id = logTabStore.createPodTab({ - selectedPod, - selectedContainer, + it("should throw an error on invalid data", () => { + expect(() => logTabStore.setData("foo", 7 as any)).toThrowError(); }); - podsStore.items.clear(); - - expect(logTabStore.getData(dockStore.selectedTabId)).toBeUndefined(); - expect(logTabStore.getData(id)).toBeUndefined(); - expect(dockStore.getTabById(id)).toBeUndefined(); + it("should not throw an error on valid data", () => { + expect(() => logTabStore.setData("foo", { + namespace: "foobar", + previous: false, + selectedPod: "blat", + showTimestamps: false, + podsOwner: "bar", + selectedContainer: "bat", + })).not.toThrowError(); + expect(logTabStore.getData("foo")).toBeDefined(); + }); }); }); diff --git a/src/renderer/components/dock/__test__/pod.mock.ts b/src/renderer/components/dock/__test__/pod.mock.ts index 253939819d..8f900bce64 100644 --- a/src/renderer/components/dock/__test__/pod.mock.ts +++ b/src/renderer/components/dock/__test__/pod.mock.ts @@ -19,9 +19,11 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -export const dockerPod = { +import type { IPodContainer } from "../../../../common/k8s-api/endpoints"; + +export const noOwnersPod = { apiVersion: "v1", - kind: "dummy", + kind: "Pod", metadata: { uid: "dockerExporter", name: "dockerExporter", @@ -30,7 +32,48 @@ export const dockerPod = { namespace: "default", }, spec: { - initContainers: [] as any, + initContainers: [] as IPodContainer[], + containers: [ + { + name: "docker-exporter", + image: "docker.io/prom/node-exporter:v1.0.0-rc.0", + imagePullPolicy: "pull", + }, + ], + serviceAccountName: "dummy", + serviceAccount: "dummy", + }, + status: { + phase: "Running", + conditions: [{ + type: "Running", + status: "Running", + lastProbeTime: 1, + lastTransitionTime: "Some time", + }], + hostIP: "dummy", + podIP: "dummy", + startTime: "dummy", + }, +}; + +export const dockerPod = { + apiVersion: "v1", + kind: "Pod", + metadata: { + uid: "dockerExporter", + name: "dockerExporter", + creationTimestamp: "dummy", + resourceVersion: "dummy", + namespace: "default", + ownerReferences: [ + { + uid: "dockerExporterOwner", + }, + ], + }, + spec: { + initContainers: [] as IPodContainer[], containers: [ { name: "docker-exporter", diff --git a/src/renderer/components/dock/dock-tab.store.ts b/src/renderer/components/dock/dock-tab.store.ts index a554e3fbd2..cad8c98544 100644 --- a/src/renderer/components/dock/dock-tab.store.ts +++ b/src/renderer/components/dock/dock-tab.store.ts @@ -19,55 +19,80 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import { autorun, observable, reaction } from "mobx"; -import { autoBind, createStorage, StorageHelper, toJS } from "../../utils"; +import { action, autorun, observable, reaction } from "mobx"; +import logger from "../../../common/logger"; +import { autoBind, createStorage, noop, StorageHelper, toJS } from "../../utils"; import { dockStore, TabId } from "./dock.store"; -export interface DockTabStoreOptions { - autoInit?: boolean; // load data from storage when `storageKey` is provided and bind events, default: true - storageKey?: string; // save data to persistent storage under the key +export interface DockTabStoreOptions { + /** + * load data from storage when `storageKey` is provided and bind events + * + * @default true + */ + autoInit?: boolean; + + /** + * save data to persistent storage under the key + */ + storageKey?: string; + + /** + * A function to call for validating values. It should `throw` if an error is present + */ + validator?: (value: T) => void; } -export type DockTabStorageState = Record; +type PartialObject = T extends object ? Partial : never; export class DockTabStore { - protected storage?: StorageHelper>; + protected storage?: StorageHelper>; protected data = observable.map(); + protected validator: (value: T) => void; - constructor(protected options: DockTabStoreOptions = {}) { + constructor({ autoInit = true, storageKey, validator = noop }: DockTabStoreOptions = {}) { autoBind(this); - this.options = { - autoInit: true, - ...this.options, - }; + this.validator = validator; - if (this.options.autoInit) { - this.init(); + if (autoInit) { + this.init(storageKey); } } - protected init() { - const { storageKey } = this.options; - + protected init(storageKey: string | undefined) { // auto-save to local-storage if (storageKey) { this.storage = createStorage(storageKey, {}); - this.storage.whenReady.then(() => { - this.data.replace(this.storage.get()); - reaction(() => this.toJSON(), data => this.storage.set(data)); - }); + this.storage.whenReady.then(action(() => { + for (const [tabId, value] of Object.entries(this.storage.get())) { + try { + this.setData(tabId, value); + } catch (error) { + logger.warn(`[DOCK-TAB-STORE-${storageKey}]: data for ${tabId} was invalid, skipping`, error); + dockStore.closeTab(tabId); + } + } + reaction( + () => this.toJSON(), + data => this.storage.set(data), + { + // fireImmediately so that invalid data is removed from the store + fireImmediately: true, + }, + ); + })); } // clear data for closed tabs autorun(() => { - const currentTabs = dockStore.tabs.map(tab => tab.id); + const currentTabs = new Set(dockStore.tabs.map(tab => tab.id)); - Array.from(this.data.keys()).forEach(tabId => { - if (!currentTabs.includes(tabId)) { + for (const tabId in this.data) { + if (!currentTabs.has(tabId)) { this.clearData(tabId); } - }); + } }); } @@ -75,7 +100,7 @@ export class DockTabStore { return data; } - protected toJSON(): DockTabStorageState { + protected toJSON(): Record { const deepCopy = toJS(this.data); deepCopy.forEach((tabData, key) => { @@ -94,9 +119,21 @@ export class DockTabStore { } setData(tabId: TabId, data: T) { + this.validator(data); this.data.set(tabId, data); } + /** + * Do a partial update for the dock tab data. + * + * NOTE: only supported for object types + * @param tabId The ID of the tab to merge data with + * @param data The partial value of the data + */ + mergeData(tabId: TabId, data: PartialObject) { + this.setData(tabId, { ...this.getData(tabId), ...data }); + } + clearData(tabId: TabId) { this.data.delete(tabId); } diff --git a/src/renderer/components/dock/edit-resource.store.ts b/src/renderer/components/dock/edit-resource.store.ts index 1b9c5fc1f8..2697ffc1da 100644 --- a/src/renderer/components/dock/edit-resource.store.ts +++ b/src/renderer/components/dock/edit-resource.store.ts @@ -43,8 +43,8 @@ export class EditResourceStore extends DockTabStore { autoBind(this); } - protected async init() { - super.init(); + protected async init(storageKey: string | undefined) { + super.init(storageKey); await this.storage.whenReady; autorun(() => { diff --git a/src/renderer/components/dock/log-controls.tsx b/src/renderer/components/dock/log-controls.tsx index b7ac1c5fb4..bb08d3ffdf 100644 --- a/src/renderer/components/dock/log-controls.tsx +++ b/src/renderer/components/dock/log-controls.tsx @@ -24,45 +24,37 @@ import "./log-controls.scss"; import React from "react"; import { observer } from "mobx-react"; -import { Pod } from "../../../common/k8s-api/endpoints"; +import type { Pod } from "../../../common/k8s-api/endpoints"; import { cssNames, saveFileDialog } from "../../utils"; import { logStore } from "./log.store"; import { Checkbox } from "../checkbox"; import { Icon } from "../icon"; -import type { LogTabData } from "./log-tab.store"; +import type { TabId } from "./dock.store"; +import { logTabStore } from "./log-tab.store"; interface Props { - tabData?: LogTabData - logs: string[] - save: (data: Partial) => void - reload: () => void + pod: Pod; + tabId: TabId; + preferences: { + showTimestamps: boolean; + previous: boolean; + }; + logs: string[]; } -export const LogControls = observer((props: Props) => { - const { tabData, save, reload, logs } = props; - - if (!tabData) { - return null; - } - - const { showTimestamps, previous } = tabData; - const since = logs.length ? logStore.getTimestamps(logs[0]) : null; - const pod = new Pod(tabData.selectedPod); +export const LogControls = observer(({ pod, tabId, preferences, logs }: Props) => { + const since = logStore.getFirstTime(tabId); const toggleTimestamps = () => { - save({ showTimestamps: !showTimestamps }); + logTabStore.mergeData(tabId, { showTimestamps: !preferences.showTimestamps }); }; const togglePrevious = () => { - save({ previous: !previous }); - reload(); + logTabStore.mergeData(tabId, { previous: !preferences.previous }); }; const downloadLogs = () => { - const fileName = pod.getName(); - const logsToDownload = showTimestamps ? logs : logStore.logsWithoutTimestamps; - - saveFileDialog(`${fileName}.log`, logsToDownload.join("\n"), "text/plain"); + saveFileDialog(`${pod.getName()}.log`, logs.join("\n"), "text/plain"); }; return ( @@ -70,21 +62,20 @@ export const LogControls = observer((props: Props) => {
{since && ( - Logs from{" "} - {new Date(since[0]).toLocaleString()} + Logs from {since} )}
diff --git a/src/renderer/components/dock/log-list.tsx b/src/renderer/components/dock/log-list.tsx index 1da9fc0dfe..1a865d7224 100644 --- a/src/renderer/components/dock/log-list.tsx +++ b/src/renderer/components/dock/log-list.tsx @@ -25,25 +25,20 @@ import React from "react"; import AnsiUp from "ansi_up"; import DOMPurify from "dompurify"; import debounce from "lodash/debounce"; -import { action, computed, observable, makeObservable, reaction } from "mobx"; +import { action, observable, makeObservable, reaction } from "mobx"; import { disposeOnUnmount, observer } from "mobx-react"; -import moment from "moment-timezone"; import type { Align, ListOnScrollProps } from "react-window"; import { SearchStore, searchStore } from "../../../common/search-store"; -import { UserStore } from "../../../common/user-store"; import { array, boundMethod, cssNames } from "../../utils"; -import { Spinner } from "../spinner"; import { VirtualList } from "../virtual-list"; -import { logStore } from "./log.store"; -import { logTabStore } from "./log-tab.store"; import { ToBottom } from "./to-bottom"; interface Props { logs: string[] isLoading: boolean load: () => void - id: string + selectedContainer: string } const colorConverter = new AnsiUp(); @@ -64,9 +59,11 @@ export class LogList extends React.Component { componentDidMount() { disposeOnUnmount(this, [ - reaction(() => this.props.logs, this.onLogsInitialLoad), - reaction(() => this.props.logs, this.onLogsUpdate), - reaction(() => this.props.logs, this.onUserScrolledUp), + reaction(() => this.props.logs, (logs, prevLogs) => { + this.onLogsInitialLoad(logs, prevLogs); + this.onLogsUpdate(); + this.onUserScrolledUp(logs, prevLogs); + }), ]); } @@ -88,10 +85,14 @@ export class LogList extends React.Component { @boundMethod onUserScrolledUp(logs: string[], prevLogs: string[]) { - if (!this.virtualListDiv.current) return; + const { current } = this.virtualListDiv; + + if (!current) { + return; + } const newLogsAdded = prevLogs.length < logs.length; - const scrolledToBeginning = this.virtualListDiv.current.scrollTop === 0; + const scrolledToBeginning = current.scrollTop === 0; if (newLogsAdded && scrolledToBeginning) { const firstLineContents = prevLogs[0]; @@ -103,22 +104,6 @@ export class LogList extends React.Component { } } - /** - * Returns logs with or without timestamps regarding to showTimestamps prop - */ - @computed - get logs() { - const showTimestamps = logTabStore.getData(this.props.id)?.showTimestamps; - - if (!showTimestamps) { - return logStore.logsWithoutTimestamps; - } - - return this.props.logs - .map(log => logStore.splitOutTimestamp(log)) - .map(([logTimestamp, log]) => (`${logTimestamp && moment.tz(logTimestamp, UserStore.getInstance().localeTimezone).format()}${log}`)); - } - /** * Checks if JumpToBottom button should be visible and sets its observable * @param props Scrolling props from virtual list core @@ -142,7 +127,13 @@ export class LogList extends React.Component { */ @action setLastLineVisibility = (props: ListOnScrollProps) => { - const { scrollHeight, clientHeight } = this.virtualListDiv.current; + const { current } = this.virtualListDiv; + + if (!current) { + return; + } + + const { scrollHeight, clientHeight } = current; const { scrollOffset } = props; this.isLastLineVisible = (clientHeight + scrollOffset) === scrollHeight; @@ -152,34 +143,38 @@ export class LogList extends React.Component { * Check if user scrolled to top and new logs should be loaded * @param props Scrolling props from virtual list core */ - checkLoadIntent = (props: ListOnScrollProps) => { - const { scrollOffset } = props; - + checkLoadIntent = ({ scrollOffset }: ListOnScrollProps) => { if (scrollOffset === 0) { this.props.load(); } }; - scrollToBottom = () => { - if (!this.virtualListDiv.current) return; - this.virtualListDiv.current.scrollTop = this.virtualListDiv.current.scrollHeight; + scrollToItem = (index: number, align: Align) => { + this.virtualListRef.current?.scrollToItem(index, align); }; - scrollToItem = (index: number, align: Align) => { - this.virtualListRef.current.scrollToItem(index, align); + scrollToBottom = () => { + const { current } = this.virtualListDiv; + + if (!current) { + return; + } + + current.scrollTop = current.scrollHeight; }; onScroll = (props: ListOnScrollProps) => { this.isLastLineVisible = false; + this.setButtonVisibility(props); + this.setLastLineVisibility(props); this.onScrollDebounced(props); }; onScrollDebounced = debounce((props: ListOnScrollProps) => { - if (!this.virtualListDiv.current) return; - this.setButtonVisibility(props); - this.setLastLineVisibility(props); this.checkLoadIntent(props); - }, 700); // Increasing performance and giving some time for virtual list to settle down + }, 700, { + leading: true, + }); // Increasing performance and giving some time for virtual list to settle down /** * A function is called by VirtualList for rendering each of the row @@ -188,7 +183,7 @@ export class LogList extends React.Component { */ getLogRow = (rowIndex: number) => { const { searchQuery, isActiveOverlay } = searchStore; - const item = this.logs[rowIndex]; + const item = this.props.logs[rowIndex]; const contents: React.ReactElement[] = []; const ansiToHtml = (ansi: string) => DOMPurify.sanitize(colorConverter.ansi_to_html(ansi)); @@ -232,31 +227,26 @@ export class LogList extends React.Component { }; render() { - const { isLoading } = this.props; - const isInitLoading = isLoading && !this.logs.length; - const rowHeights = array.filled(this.logs.length, this.lineHeight); + const { logs, isLoading, selectedContainer } = this.props; - if (isInitLoading) { - return ( -
- -
- ); + if (isLoading) { + // Don't show a spinner since `Logs` will instead. + return null; } - if (!this.logs.length) { + if (!logs.length) { return (
- There are no logs available for container + There are no logs available for container {selectedContainer}
); } return ( -
+
) => void - reload: () => void +export interface LogResourceSelectorStore { + changeSelectedPod(tabId: TabId, newSelectedPod: Pod): void; + mergeData(tabId: TabId, data: Partial): void; } -export const LogResourceSelector = observer((props: Props) => { - const { tabData, save, reload, tabId } = props; - const { selectedPod, selectedContainer, pods } = tabData; - const pod = new Pod(selectedPod); +export interface LogResourceSelectorProps { + tabId: TabId; + pod: Pod; + + /** + * The list of possible pods to switch between. If not specifed then it won't be displayed + */ + pods?: Pod[]; + selectedContainer: string; + store?: LogResourceSelectorStore; +} + +export const LogResourceSelector = observer(({ tabId, pod, pods, selectedContainer, store = logTabStore }: LogResourceSelectorProps) => { const containers = pod.getContainers(); const initContainers = pod.getInitContainers(); - const onContainerChange = (option: SelectOption) => { - save({ - selectedContainer: containers - .concat(initContainers) - .find(container => container.name === option.value), - }); - reload(); - }; - - const onPodChange = (option: SelectOption) => { - const selectedPod = podsStore.getByName(option.value, pod.getNs()); - - save({ selectedPod }); - logTabStore.renameTab(tabId); - }; - - const getSelectOptions = (items: string[]) => { - return items.map(item => { - return { - value: item, - label: item, - }; - }); - }; - - const containerSelectOptions = [ + const containerSelectOptions: GroupSelectOption>[] = [ { label: `Containers`, - options: getSelectOptions(containers.map(container => container.name)), + options: containers.map(container => ({ + value: container.name, + label: container.name, + })), }, { label: `Init Containers`, - options: getSelectOptions(initContainers.map(container => container.name)), + options: initContainers.map(container => ({ + value: container.name, + label: container.name, + })), }, ]; - const podSelectOptions = [ - { - label: pod.getOwnerRefs()[0]?.name, - options: getSelectOptions(pods.map(pod => pod.metadata.name)), - }, - ]; - - useEffect(() => { - reload(); - }, [selectedPod]); + const manyOptions = (containers.length + initContainers.length) > 1; return (
- Namespace - Pod - ({ + label: pod.getName(), + value: pod, + })), + }]} + value={{ label: pod.getName(), value: pod }} + onChange={({ value }) => store.changeSelectedPod(tabId, value)} + autoConvertOptions={false} + className="pod-selector" + /> + ) + } + + ) + } + Container - store.mergeData(tabId, { selectedContainer: value })} + autoConvertOptions={false} + className="container-selector" + /> + ) + : ( + + ) + }
); }); diff --git a/src/renderer/components/dock/log-search.scss b/src/renderer/components/dock/log-search.scss index 85f31a4c98..017b92b5c2 100644 --- a/src/renderer/components/dock/log-search.scss +++ b/src/renderer/components/dock/log-search.scss @@ -22,6 +22,7 @@ .LogSearch { .SearchInput { min-width: 150px; + width: 240px; .find-count { margin-left: 2px; @@ -31,4 +32,4 @@ padding-bottom: 7px; } } -} \ No newline at end of file +} diff --git a/src/renderer/components/dock/log-tab.store.ts b/src/renderer/components/dock/log-tab.store.ts index 8b767ba91f..f2a3fe8753 100644 --- a/src/renderer/components/dock/log-tab.store.ts +++ b/src/renderer/components/dock/log-tab.store.ts @@ -19,131 +19,172 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ +import Joi from "joi"; import uniqueId from "lodash/uniqueId"; -import { reaction } from "mobx"; -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"; +import { action } from "mobx"; +import type { IPodContainer, Pod } from "../../../common/k8s-api/endpoints"; +import { DockTabStore, DockTabStoreOptions } from "./dock-tab.store"; +import { dockStore, DockTab, DockTabCreate, TabId, TabKind } from "./dock.store"; export interface LogTabData { - pods: Pod[]; - selectedPod: Pod; - selectedContainer: IPodContainer - showTimestamps?: boolean - previous?: boolean + /** + * The pod owner ID. + */ + podsOwner?: string; + + /** + * The ID of the pod from the list of pods owned by `.podsOwner` + */ + selectedPod: string; + + /** + * The namespace of the pods so that the pods can be retrieved. + */ + namespace: string; + + /** + * The name of the container within the selected pod. + * + * Note: container names are guaranteed unique + */ + selectedContainer?: string; + + /** + * Whether to show timestamps inline with the logs + */ + showTimestamps: boolean; + + /** + * Query for getting logs of the previous container restart + */ + previous: boolean; } -interface PodLogsTabData { +const logTabDataValidator = Joi.object({ + podsOwner: Joi + .string() + .optional(), + selectedPod: Joi + .string() + .required(), + namespace: Joi + .string() + .required(), + selectedContainer: Joi + .string() + .optional(), + showTimestamps: Joi + .boolean() + .required(), + previous: Joi + .boolean() + .required(), +}); + +/** + * Data for creating a pod logs tab based on a specific pod + */ +export interface PodLogsTabData { selectedPod: Pod selectedContainer: IPodContainer } -interface WorkloadLogsTabData { - workload: WorkloadKubeObject +export interface DockManager { + renameTab(tabId: TabId, name: string): void; + createTab(rawTabDesc: DockTabCreate, addNumber?: boolean): DockTab; + closeTab(tabId: TabId): void; } export class LogTabStore extends DockTabStore { - constructor() { + constructor(params: Pick, "autoInit"> = {}, protected dockManager: DockManager = dockStore) { super({ + ...params, storageKey: "pod_logs", - }); + validator: value => { + const { error } = logTabDataValidator.validate(value); - reaction(() => podsStore.items.length, () => this.updateTabsData()); - } - - createPodTab({ selectedPod, selectedContainer }: PodLogsTabData): string { - const podOwner = selectedPod.getOwnerRefs()[0]; - const pods = podsStore.getPodsByOwnerId(podOwner?.uid); - const title = `Pod ${selectedPod.getName()}`; - - return this.createLogsTab(title, { - pods: pods.length ? pods : [selectedPod], - selectedPod, - selectedContainer, + if (error) { + throw error; + } + }, }); } - createWorkloadTab({ workload }: WorkloadLogsTabData): void { - const pods = podsStore.getPodsByOwnerId(workload.getId()); + createPodTab(tabData: PodLogsTabData): string { + if (!tabData || typeof tabData !== "object") { + throw new TypeError("tabData is not an object"); + } - if (!pods.length) return; + const { selectedPod, selectedContainer } = tabData; - const selectedPod = pods[0]; - const selectedContainer = selectedPod.getAllContainers()[0]; - const title = `${workload.kind} ${selectedPod.getName()}`; + if (!selectedPod || typeof selectedPod !== "object") { + throw new TypeError("selectedPod is not an object"); + } - this.createLogsTab(title, { - pods, - selectedPod, - selectedContainer, + if (!selectedContainer || typeof selectedContainer !== "object") { + throw new TypeError("selectedContainer is not an object"); + } + + return this.createLogsTab(this.getTabName(selectedPod), { + podsOwner: selectedPod.getOwnerRefs()[0]?.uid, + namespace: selectedPod.getNs(), + selectedPod: selectedPod.getId(), + selectedContainer: selectedContainer.name, + showTimestamps: false, + previous: false, }); } - renameTab(tabId: string) { - const { selectedPod } = this.getData(tabId); - - dockStore.renameTab(tabId, `Pod ${selectedPod.metadata.name}`); + private getTabName(pod: Pod): string { + return `Pod Logs: ${pod.getName()}`; } - private createDockTab(tabParams: DockTabCreateSpecific) { - dockStore.createTab({ - ...tabParams, - kind: TabKind.POD_LOGS, - }, false); - } + @action + changeSelectedPod(tabId: string, pod: Pod): void { + const oldSelectedPod = this.getData(tabId).selectedPod; + + if (pod.getId() === oldSelectedPod) { + // Do nothing + return; + } + + this.mergeData(tabId, { + selectedPod: pod.getId(), + selectedContainer: pod.getContainers()[0]?.name, + }); + this.dockManager.renameTab(tabId, this.getTabName(pod)); + } private createLogsTab(title: string, data: LogTabData): string { const id = uniqueId("log-tab-"); - this.createDockTab({ id, title }); - this.setData(id, { - ...data, - showTimestamps: false, - previous: false, - }); + this.dockManager.createTab({ + id, + title, + kind: TabKind.POD_LOGS, + }, false); + this.setData(id, data); return id; } - private updateTabsData() { - for (const [tabId, tabData] of this.data) { - try { - if (!tabData.selectedPod) { - tabData.selectedPod = tabData.pods[0]; - } - - 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); - } - } + @action + public closeTab(tabId: string) { + this.clearData(tabId); + this.dockManager.closeTab(tabId); } - private closeTab(tabId: string) { - this.clearData(tabId); - dockStore.closeTab(tabId); + /** + * Get a set of namespaces which pod tabs care about + */ + public getNamespaces(): string[] { + const namespaces = new Set(); + + for (const { namespace } of this.data.values()) { + namespaces.add(namespace); + } + + return [...namespaces]; } } diff --git a/src/renderer/components/dock/log.store.ts b/src/renderer/components/dock/log.store.ts index 3517db01ae..10550b2b76 100644 --- a/src/renderer/components/dock/log.store.ts +++ b/src/renderer/components/dock/log.store.ts @@ -19,40 +19,42 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import { autorun, computed, observable, makeObservable } from "mobx"; +import { observable, makeObservable } from "mobx"; +import moment from "moment"; +import { podsStore } from "../+workloads-pods/pods.store"; -import { IPodLogsQuery, Pod, podsApi } from "../../../common/k8s-api/endpoints"; -import { autoBind, interval } from "../../utils"; -import { dockStore, TabId, TabKind } from "./dock.store"; -import { logTabStore } from "./log-tab.store"; +import { IPodLogsQuery, podsApi } from "../../../common/k8s-api/endpoints"; +import { UserStore } from "../../../common/user-store"; +import { autoBind } from "../../utils"; +import type { TabId } from "./dock.store"; +import type { LogTabData } from "./log-tab.store"; type PodLogLine = string; const logLinesToLoad = 500; +function removeTimestamp(logLine: string) { + return logLine.replace(/^\d+.*?\s/gm, ""); +} + +function formatTimestamp(log: string, tz: string): string { + const extraction = /^(?\d+\S+)(?.*)/.exec(log); + + if (!extraction) { + return log; + } + + const { timestamp, line } = extraction.groups; + + return `${moment.tz(timestamp, tz).format()}${line}`; +} + export class LogStore { - private refresher = interval(10, () => { - const id = dockStore.selectedTabId; - - if (!this.podLogs.get(id)) return; - this.loadMore(id); - }); - @observable podLogs = observable.map(); constructor() { makeObservable(this); autoBind(this); - - autorun(() => { - const { selectedTab, isOpen } = dockStore; - - if (selectedTab?.kind === TabKind.POD_LOGS && isOpen) { - this.refresher.start(); - } else { - this.refresher.stop(); - } - }, { delay: 500 }); } handlerError(tabId: TabId, error: any): void { @@ -65,7 +67,6 @@ export class LogStore { `Reason: ${error.reason} (${error.code})`, ]; - this.refresher.stop(); this.podLogs.set(tabId, message); } @@ -76,13 +77,14 @@ export class LogStore { * messages * @param tabId */ - load = async (tabId: TabId) => { + load = async (tabId: TabId, data: LogTabData) => { try { - const logs = await this.loadLogs(tabId, { - tailLines: this.lines + logLinesToLoad, - }); + const prevLogsLength = this.podLogs.get(tabId)?.length ?? 0; + const params = { + tailLines: prevLogsLength + logLinesToLoad, + }; + const logs = await this.loadLogs(data, params); - this.refresher.start(); this.podLogs.set(tabId, logs); } catch (error) { this.handlerError(tabId, error); @@ -95,19 +97,18 @@ export class LogStore { * starting from last line received. * @param tabId */ - loadMore = async (tabId: TabId) => { + loadMore = async (tabId: TabId, data: LogTabData) => { if (!this.podLogs.get(tabId).length) { return; } try { - const oldLogs = this.podLogs.get(tabId); - const logs = await this.loadLogs(tabId, { + const logs = await this.loadLogs(data, { sinceTime: this.getLastSinceTime(tabId), }); // Add newly received logs to bottom - this.podLogs.set(tabId, [...oldLogs, ...logs.filter(Boolean)]); + this.podLogs.get(tabId).push(...logs.filter(Boolean)); } catch (error) { this.handlerError(tabId, error); } @@ -120,48 +121,35 @@ export class LogStore { * @param params request parameters described in IPodLogsQuery interface * @returns A fetch request promise */ - async loadLogs(tabId: TabId, params: Partial): Promise { - const data = logTabStore.getData(tabId); - const { selectedContainer, previous } = data; - const pod = new Pod(data.selectedPod); + async loadLogs(data: LogTabData, params: Partial): Promise { + const { podsOwner, selectedContainer, selectedPod, previous } = data; + const pod = podsOwner + ? podsStore.getPodsByOwnerId(podsOwner).find(pod => pod.getId() === selectedPod) + : podsStore.getById(selectedPod); + + if (!pod) { + return []; + } + const namespace = pod.getNs(); const name = pod.getName(); - const result = await podsApi.getLogs({ namespace, name }, { ...params, timestamps: true, // Always setting timestamp to separate old logs from new ones - container: selectedContainer.name, + container: selectedContainer, previous, }); - return result.trimEnd().split("\n"); + return result.split("\n").filter(Boolean); } - /** - * Converts logs into a string array - * @returns Length of log lines - */ - @computed - get lines(): number { - return this.logs.length; - } + getLogs(tabId: TabId, { showTimestamps }: LogTabData): string[] { + const logs = this.podLogs.get(tabId) ?? []; + const { localeTimezone } = UserStore.getInstance(); - - /** - * Returns logs with timestamps for selected tab - */ - @computed - get logs() { - return this.podLogs.get(dockStore.selectedTabId) ?? []; - } - - /** - * Removes timestamps from each log line and returns changed logs - * @returns Logs without timestamps - */ - @computed - get logsWithoutTimestamps() { - return this.logs.map(item => this.removeTimestamps(item)); + return showTimestamps + ? logs.map(log => formatTimestamp(log, localeTimezone)) + : logs.map(removeTimestamp); } /** @@ -179,24 +167,33 @@ export class LogStore { return stamp.toISOString(); } - splitOutTimestamp(logs: string): [string, string] { - const extraction = /^(\d+\S+)(.*)/m.exec(logs); + /** + * Get the local formatted date of the first log line's timestamp + * @param tabId The ID of the tab to get the time of + * @returns `""` if no logs, or log does not have a timestamp + */ + getFirstTime(tabId: TabId): string { + const logs = this.podLogs.get(tabId); - if (!extraction || extraction.length < 3) { - return ["", logs]; + if (!logs?.length) { + return ""; } - return [extraction[1], extraction[2]]; + const timestamps = this.getTimestamps(logs[0]); + + if (!timestamps) { + return ""; + } + + const stamp = new Date(timestamps[0]); + + return stamp.toLocaleString(); } getTimestamps(logs: string) { return logs.match(/^\d+\S+/gm); } - removeTimestamps(logs: string) { - return logs.replace(/^\d+.*?\s/gm, ""); - } - clearLogs(tabId: TabId) { this.podLogs.delete(tabId); } diff --git a/src/renderer/components/dock/logs.scss b/src/renderer/components/dock/logs.scss new file mode 100644 index 0000000000..5a6ad4229e --- /dev/null +++ b/src/renderer/components/dock/logs.scss @@ -0,0 +1,27 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +.PodLogs { + .pod-not-found { + margin: auto; + color: var(--terminalBrightWhite); + } +} diff --git a/src/renderer/components/dock/logs.tsx b/src/renderer/components/dock/logs.tsx index 6c5c8d115f..effb5cf904 100644 --- a/src/renderer/components/dock/logs.tsx +++ b/src/renderer/components/dock/logs.tsx @@ -19,12 +19,13 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ +import "./logs.scss"; + import React from "react"; -import { observable, reaction, makeObservable } from "mobx"; +import { observable, makeObservable, computed, when, reaction, runInAction } from "mobx"; import { disposeOnUnmount, observer } from "mobx-react"; import { searchStore } from "../../../common/search-store"; -import { boundMethod } from "../../utils"; import type { DockTab } from "./dock.store"; import { InfoPanel } from "./info-panel"; import { LogResourceSelector } from "./log-resource-selector"; @@ -33,15 +34,31 @@ import { logStore } from "./log.store"; import { LogSearch } from "./log-search"; import { LogControls } from "./log-controls"; import { LogTabData, logTabStore } from "./log-tab.store"; +import { podsStore } from "../+workloads-pods/pods.store"; +import { kubeWatchApi } from "../../../common/k8s-api/kube-watch-api"; +import { Spinner } from "../spinner"; +import { disposingReaction, comparer } from "../../utils"; +import type { Pod } from "../../../common/k8s-api/endpoints"; +import { Badge } from "../badge"; interface Props { className?: string tab: DockTab } +interface GottenPods { + pods?: Pod[]; + pod: Pod | undefined; +} + @observer export class Logs extends React.Component { - @observable isLoading = true; + @observable isLoading = false; + /** + * Only used for the inital loading of logs so that when logs are shorter + * than the viewport the user doesn't get incessant spinner every 700ms + */ + @observable isLoadingInitial = true; private logListElement = React.createRef(); // A reference for VirtualList component @@ -51,40 +68,125 @@ export class Logs extends React.Component { } componentDidMount() { - disposeOnUnmount(this, - reaction(() => this.props.tab.id, this.reload, { fireImmediately: true }), + disposeOnUnmount(this, [ + kubeWatchApi.subscribeStores([ + podsStore, + ], { + namespaces: logTabStore.getNamespaces(), + }), + when(() => this.canLoad, () => this.load(true)), + this.loadWhenPossibleOnTabChange(), + this.changeSelectedPodWhenCurrentDisappears(), + ]); + } + + /** + * Only used in `componentDidMount` + */ + private loadWhenPossibleOnTabChange() { + return disposingReaction( + () => [this.tabId, this.tabData] as const, + ([curTabId], [oldTabId]) => { + if (curTabId !== oldTabId) { + logStore.clearLogs(this.tabId); + } + + return when(() => this.canLoad, () => this.load(true)); + }, + { + equals: comparer.structural, + }, ); } - get tabId() { + /** + * Only used in `componentDidMount` + */ + private changeSelectedPodWhenCurrentDisappears() { + return reaction(() => this.getPods(this.tabData), (data) => { + if (!data) { + return; + } + + const { pods, pod } = data; + + if (pods && !pod && pods.length > 0) { + const selectedPod = pods[0]; + + logTabStore.mergeData(this.tabId, { + selectedPod: selectedPod.getId(), + selectedContainer: selectedPod.getContainers()[0]?.name, + }); + } + }, { + fireImmediately: true, + }); + } + + private getPods(data: LogTabData): GottenPods; + private getPods(data: LogTabData | undefined): GottenPods | undefined { + if (!data) { + return undefined; + } + + const { podsOwner, selectedPod } = data; + + if (podsOwner) { + const pods = podsStore.getPodsByOwnerId(podsOwner); + const pod = pods.find(pod => pod.getId() === selectedPod); + + return { pods, pod }; + } + + return { pod: podsStore.getById(selectedPod) }; + } + + @computed get tabData(): LogTabData { + return logTabStore.getData(this.tabId); + } + + @computed get tabId() { return this.props.tab.id; } - load = async () => { - this.isLoading = true; - await logStore.load(this.tabId); - this.isLoading = false; - }; + @computed get canSwap(): boolean { + const data = this.tabData; - reload = async () => { - logStore.clearLogs(this.tabId); - await this.load(); - }; + if (!data) { + return false; + } - /** - * A function for various actions after search is happened - * @param query {string} A text from search field - */ - @boundMethod - onSearch() { - this.toOverlay(); + const { podsOwner } = data; + + if (!podsOwner) { + return false; + } + + return podsStore.getPodsByOwnerId(podsOwner).length > 0; } + @computed get canLoad(): boolean { + return Boolean(this.getPods(this.tabData)?.pod); + } + + load = async (initial = false) => { + runInAction(() => { + this.isLoading = true; + this.isLoadingInitial = initial; + }); + + await logStore.load(this.tabId, this.tabData); + + runInAction(() => { + this.isLoading = false; + this.isLoadingInitial = false; + }); + }; + /** * Scrolling to active overlay (search word highlight) */ - @boundMethod - toOverlay() { + onSearch = () => { const { activeOverlayLine } = searchStore; if (!this.logListElement.current || activeOverlayLine === undefined) return; @@ -92,71 +194,86 @@ export class Logs extends React.Component { this.logListElement.current.scrollToItem(activeOverlayLine, "center"); // Scroll horizontally in timeout since virtual list need some time to prepare its contents setTimeout(() => { - const overlay = document.querySelector(".PodLogs .list span.active"); - - if (!overlay) return; - overlay.scrollIntoViewIfNeeded(); + document.querySelector(".PodLogs .list span.active")?.scrollIntoViewIfNeeded(); }, 100); - } + }; + + render() { + const data = this.tabData; - renderResourceSelector(data?: LogTabData) { if (!data) { return null; } - const logs = logStore.logs; - const searchLogs = data.showTimestamps ? logs : logStore.logsWithoutTimestamps; - const controls = ( -
- logTabStore.setData(this.tabId, { ...data, ...newData })} - reload={this.reload} - /> - -
- ); + if (!podsStore.isLoaded) { + return ; + } - return ( - - ); - } + const logs = logStore.getLogs(this.tabId, data); + const { podsOwner, selectedContainer, selectedPod, showTimestamps, previous, namespace } = data; + const { pods, pod } = this.getPods(data); - render() { - const logs = logStore.logs; - const data = logTabStore.getData(this.tabId); - - if (!data) { - this.reload(); + if (!pod) { + return ( +
+ + Namespace + + Pod + + Container + +
+ } + showSubmitClose={false} + showButtons={false} + showStatusPanel={false} + /> +

Pod is no longer found {podsOwner && `under owner ${podsOwner}`}

+
+ ); } return (
- {this.renderResourceSelector(data)} + + + {this.isLoading && } + +
+ } + showSubmitClose={false} + showButtons={false} + showStatusPanel={false} + /> logTabStore.setData(this.tabId, { ...data, ...newData })} - reload={this.reload} />
); diff --git a/src/renderer/utils/createStorage.ts b/src/renderer/utils/createStorage.ts index c282994e61..bbf7801419 100755 --- a/src/renderer/utils/createStorage.ts +++ b/src/renderer/utils/createStorage.ts @@ -27,6 +27,7 @@ import fse from "fs-extra"; import { StorageHelper } from "./storageHelper"; import logger from "../../main/logger"; import { isTestEnv } from "../../common/vars"; +import AwaitLock from "await-lock"; const storage = observable({ initialized: false, @@ -50,8 +51,11 @@ export function createStorage(key: string, defaultValue: T) { try { storage.data = await fse.readJson(filePath); - } catch { - // ignore error + } catch (error) { + // ignore file not found errors for logging + if (error?.code !== "ENOENT") { + logger.warn(`${logPrefix} failed to read JSON from ${filePath}`, error); + } } finally { if (!isTestEnv) { logger.info(`${logPrefix} loading finished for ${filePath}`); @@ -60,22 +64,29 @@ export function createStorage(key: string, defaultValue: T) { storage.loaded = true; } + const lock = new AwaitLock(); + // bind auto-saving data changes to %storage-file.json reaction(() => toJS(storage.data), saveFile, { - delay: 250, // lazy, avoid excessive writes to fs + delay: 1000, // lazy, avoid excessive writes to fs equals: comparer.structural, // save only when something really changed }); async function saveFile(state: Record = {}) { - logger.info(`${logPrefix} saving ${filePath}`); - try { + await lock.acquireAsync(); + logger.info(`${logPrefix} saving ${filePath}`); await fse.ensureDir(path.dirname(filePath), { mode: 0o755 }); await fse.writeJson(filePath, state, { spaces: 2 }); } catch (error) { - logger.error(`${logPrefix} saving failed: ${error}`, { - json: state, jsonFilePath: filePath, - }); + const meta = { + json: state, + jsonFilePath: filePath, + }; + + logger.error(`${logPrefix} saving failed: ${error}`, meta); + } finally { + lock.release(); } } })()