mirror of
https://github.com/lensapp/lens.git
synced 2025-05-20 05:10:56 +00:00
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 <sebastian@malton.name>
This commit is contained in:
parent
fa3708c879
commit
cea7a34014
@ -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": {
|
||||
|
||||
12
extensions/metrics-cluster-feature/package-lock.json
generated
12
extensions/metrics-cluster-feature/package-lock.json
generated
@ -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": {
|
||||
|
||||
12
extensions/node-menu/package-lock.json
generated
12
extensions/node-menu/package-lock.json
generated
@ -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": {
|
||||
|
||||
@ -62,7 +62,7 @@ export abstract class BaseStore<T> 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({
|
||||
|
||||
@ -34,7 +34,9 @@ const electronRemote = (() => {
|
||||
if (ipcRenderer) {
|
||||
try {
|
||||
return require("@electron/remote");
|
||||
} catch {}
|
||||
} catch {
|
||||
// ignore temp
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
@ -102,13 +102,13 @@ export abstract class KubeObjectStore<T extends KubeObject> extends ItemStore<T>
|
||||
}
|
||||
|
||||
@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<T extends KubeObject> extends ItemStore<T>
|
||||
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,
|
||||
];
|
||||
}
|
||||
|
||||
@ -37,6 +37,15 @@ export type KubeObjectConstructor<K extends KubeObject> = (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 {
|
||||
|
||||
@ -50,14 +50,15 @@ export interface IKubeWatchEvent<T extends KubeJsonApiData> {
|
||||
|
||||
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 `<NamespaceSelectFilter />`
|
||||
* @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,
|
||||
})),
|
||||
);
|
||||
|
||||
50
src/common/utils/comparer.ts
Normal file
50
src/common/utils/comparer.ts
Normal file
@ -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<T>(a: T, b: T): boolean {
|
||||
return _comparer.identity(a, b);
|
||||
}
|
||||
|
||||
function defaultComparer<T>(a: T, b: T): boolean {
|
||||
return _comparer.default(a, b);
|
||||
}
|
||||
|
||||
function structural<T>(a: T, b: T): boolean {
|
||||
return _comparer.structural(a, b);
|
||||
}
|
||||
|
||||
function shallow<T>(a: T, b: T): boolean {
|
||||
return _comparer.shallow(a, b);
|
||||
}
|
||||
|
||||
export const comparer = {
|
||||
identity,
|
||||
default: defaultComparer,
|
||||
structural,
|
||||
shallow,
|
||||
};
|
||||
@ -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";
|
||||
|
||||
42
src/common/utils/reactions.ts
Normal file
42
src/common/utils/reactions.ts
Normal file
@ -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<T, FireImmediately extends boolean = false>(expression: (r: IReactionPublic) => T, effect: (arg: T, prev: FireImmediately extends true ? T | undefined : T, r: IReactionPublic) => Disposer, opts?: IReactionOptions<T, FireImmediately>): IReactionDisposer {
|
||||
let prevDisposer: Disposer;
|
||||
|
||||
const reactionDisposer = reaction<T, FireImmediately>(expression, (arg: T, prev: T, r: IReactionPublic) => {
|
||||
prevDisposer?.();
|
||||
prevDisposer = effect(arg, prev, r);
|
||||
}, opts);
|
||||
|
||||
return Object.assign(() => {
|
||||
reactionDisposer();
|
||||
prevDisposer?.();
|
||||
}, reactionDisposer);
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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";
|
||||
|
||||
/**
|
||||
|
||||
@ -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 (
|
||||
<LogResourceSelector
|
||||
tabId="tabId"
|
||||
tabData={tabData}
|
||||
save={jest.fn()}
|
||||
reload={jest.fn()}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
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("<LogResourceSelector />", () => {
|
||||
});
|
||||
|
||||
it("renders w/o errors", () => {
|
||||
const tabData = getOnePodTabData();
|
||||
const { container } = render(getComponent(tabData));
|
||||
const props = getOnePodTabProps();
|
||||
const { container } = render(<LogResourceSelector {...props} />);
|
||||
|
||||
expect(container).toBeInstanceOf(HTMLElement);
|
||||
});
|
||||
|
||||
it("renders proper namespace", () => {
|
||||
const tabData = getOnePodTabData();
|
||||
const { getByTestId } = render(getComponent(tabData));
|
||||
const props = getOnePodTabProps();
|
||||
const { getByTestId } = render(<LogResourceSelector {...props} />);
|
||||
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(<LogResourceSelector {...props} />);
|
||||
|
||||
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(<LogResourceSelector {...props} />);
|
||||
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(<LogResourceSelector {...props} />);
|
||||
const containerSelector: HTMLElement = container.querySelector(".container-selector");
|
||||
|
||||
selectEvent.openMenu(containerSelector);
|
||||
@ -145,8 +139,8 @@ describe("<LogResourceSelector />", () => {
|
||||
});
|
||||
|
||||
it("renders pod owner as dropdown title", () => {
|
||||
const tabData = getFewPodsTabData();
|
||||
const { getByText, container } = render(getComponent(tabData));
|
||||
const props = getFewPodsTabProps();
|
||||
const { getByText, container } = render(<LogResourceSelector {...props} />);
|
||||
const podSelector: HTMLElement = container.querySelector(".pod-selector");
|
||||
|
||||
selectEvent.openMenu(podSelector);
|
||||
|
||||
@ -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<DockManager> {
|
||||
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();
|
||||
expect(() => logTabStore.createPodTab(0 as any)).toThrow(/is not an object/);
|
||||
});
|
||||
|
||||
podsStore.items.push(new Pod(dockerPod));
|
||||
podsStore.items.push(new Pod(deploymentPod1));
|
||||
podsStore.items.push(new Pod(deploymentPod2));
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
describe("changeSelectedPod()", () => {
|
||||
let dockManager: jest.Mocked<DockManager>;
|
||||
let logTabStore: LogTabStore;
|
||||
let tabId: TabId;
|
||||
const pod = new Pod(dockerPod);
|
||||
|
||||
describe("log tab store", () => {
|
||||
beforeEach(() => {
|
||||
UserStore.createInstance();
|
||||
ThemeStore.createInstance();
|
||||
dockManager = getMockDockManager();
|
||||
logTabStore = new LogTabStore({ autoInit: false }, dockManager);
|
||||
tabId = logTabStore.createPodTab({ selectedContainer: { name: "docker-exporter" } as any, selectedPod: pod });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
logTabStore.reset();
|
||||
dockStore.reset();
|
||||
UserStore.resetInstance();
|
||||
ThemeStore.resetInstance();
|
||||
fse.remove("tmp");
|
||||
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("creates log tab without sibling pods", () => {
|
||||
const selectedPod = new Pod(dockerPod);
|
||||
const selectedContainer = selectedPod.getAllContainers()[0];
|
||||
it("should rename the tab", () => {
|
||||
const newPod = new Pod(deploymentPod1);
|
||||
|
||||
logTabStore.createPodTab({
|
||||
selectedPod,
|
||||
selectedContainer,
|
||||
logTabStore.changeSelectedPod(tabId, newPod);
|
||||
expect(dockManager.renameTab).toBeCalledWith(tabId, "Pod Logs: deploymentPod1");
|
||||
});
|
||||
});
|
||||
|
||||
expect(logTabStore.getData(dockStore.selectedTabId)).toEqual({
|
||||
pods: [selectedPod],
|
||||
selectedPod,
|
||||
selectedContainer,
|
||||
showTimestamps: false,
|
||||
describe("getData()", () => {
|
||||
let dockManager: jest.Mocked<DockManager>;
|
||||
let logTabStore: LogTabStore;
|
||||
let tabId: TabId;
|
||||
const pod = new Pod(dockerPod);
|
||||
|
||||
beforeEach(() => {
|
||||
dockManager = getMockDockManager();
|
||||
logTabStore = new LogTabStore({ autoInit: false }, dockManager);
|
||||
tabId = logTabStore.createPodTab({ selectedContainer: { name: "docker-exporter" } as any, selectedPod: pod });
|
||||
});
|
||||
|
||||
it("should return data if created", () => {
|
||||
expect(logTabStore.getData(tabId)).toMatchObject({
|
||||
selectedPod: pod.getId(),
|
||||
selectedContainer: "docker-exporter",
|
||||
});
|
||||
});
|
||||
|
||||
it("should return undefined for unknown tab ID", () => {
|
||||
expect(logTabStore.getData("foo")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("setData()", () => {
|
||||
let dockManager: jest.Mocked<DockManager>;
|
||||
let logTabStore: LogTabStore;
|
||||
|
||||
beforeEach(() => {
|
||||
dockManager = getMockDockManager();
|
||||
logTabStore = new LogTabStore({ autoInit: false }, dockManager);
|
||||
});
|
||||
|
||||
it("should throw an error on invalid data", () => {
|
||||
expect(() => logTabStore.setData("foo", 7 as any)).toThrowError();
|
||||
});
|
||||
|
||||
it("should not throw an error on valid data", () => {
|
||||
expect(() => logTabStore.setData("foo", {
|
||||
namespace: "foobar",
|
||||
previous: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("creates log tab with sibling pods", () => {
|
||||
const selectedPod = new Pod(deploymentPod1);
|
||||
const siblingPod = new Pod(deploymentPod2);
|
||||
const selectedContainer = selectedPod.getInitContainers()[0];
|
||||
|
||||
logTabStore.createPodTab({
|
||||
selectedPod,
|
||||
selectedContainer,
|
||||
});
|
||||
|
||||
expect(logTabStore.getData(dockStore.selectedTabId)).toEqual({
|
||||
pods: [selectedPod, siblingPod],
|
||||
selectedPod,
|
||||
selectedContainer,
|
||||
selectedPod: "blat",
|
||||
showTimestamps: false,
|
||||
previous: false,
|
||||
podsOwner: "bar",
|
||||
selectedContainer: "bat",
|
||||
})).not.toThrowError();
|
||||
expect(logTabStore.getData("foo")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("removes item from pods list if pod deleted from store", () => {
|
||||
const selectedPod = new Pod(deploymentPod1);
|
||||
const selectedContainer = selectedPod.getInitContainers()[0];
|
||||
|
||||
logTabStore.createPodTab({
|
||||
selectedPod,
|
||||
selectedContainer,
|
||||
});
|
||||
|
||||
podsStore.items.pop();
|
||||
|
||||
expect(logTabStore.getData(dockStore.selectedTabId)).toEqual({
|
||||
pods: [selectedPod],
|
||||
selectedPod,
|
||||
selectedContainer,
|
||||
showTimestamps: false,
|
||||
previous: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("adds item into pods list if new sibling pod added to store", () => {
|
||||
const selectedPod = new Pod(deploymentPod1);
|
||||
const selectedContainer = selectedPod.getInitContainers()[0];
|
||||
|
||||
logTabStore.createPodTab({
|
||||
selectedPod,
|
||||
selectedContainer,
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
podsStore.items.clear();
|
||||
|
||||
expect(logTabStore.getData(dockStore.selectedTabId)).toBeUndefined();
|
||||
expect(logTabStore.getData(id)).toBeUndefined();
|
||||
expect(dockStore.getTabById(id)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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<T> {
|
||||
/**
|
||||
* 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<T> = Record<TabId, T>;
|
||||
type PartialObject<T> = T extends object ? Partial<T> : never;
|
||||
|
||||
export class DockTabStore<T> {
|
||||
protected storage?: StorageHelper<DockTabStorageState<T>>;
|
||||
protected storage?: StorageHelper<Record<TabId, T>>;
|
||||
protected data = observable.map<TabId, T>();
|
||||
protected validator: (value: T) => void;
|
||||
|
||||
constructor(protected options: DockTabStoreOptions = {}) {
|
||||
constructor({ autoInit = true, storageKey, validator = noop }: DockTabStoreOptions<T> = {}) {
|
||||
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<T> {
|
||||
return data;
|
||||
}
|
||||
|
||||
protected toJSON(): DockTabStorageState<T> {
|
||||
protected toJSON(): Record<TabId, T> {
|
||||
const deepCopy = toJS(this.data);
|
||||
|
||||
deepCopy.forEach((tabData, key) => {
|
||||
@ -94,9 +119,21 @@ export class DockTabStore<T> {
|
||||
}
|
||||
|
||||
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<T>) {
|
||||
this.setData(tabId, { ...this.getData(tabId), ...data });
|
||||
}
|
||||
|
||||
clearData(tabId: TabId) {
|
||||
this.data.delete(tabId);
|
||||
}
|
||||
|
||||
@ -43,8 +43,8 @@ export class EditResourceStore extends DockTabStore<EditingResource> {
|
||||
autoBind(this);
|
||||
}
|
||||
|
||||
protected async init() {
|
||||
super.init();
|
||||
protected async init(storageKey: string | undefined) {
|
||||
super.init(storageKey);
|
||||
await this.storage.whenReady;
|
||||
|
||||
autorun(() => {
|
||||
|
||||
@ -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<LogTabData>) => 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) => {
|
||||
<div className="time-range">
|
||||
{since && (
|
||||
<span>
|
||||
Logs from{" "}
|
||||
<b>{new Date(since[0]).toLocaleString()}</b>
|
||||
Logs from <b>{since}</b>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gaps align-center">
|
||||
<Checkbox
|
||||
label="Show timestamps"
|
||||
value={showTimestamps}
|
||||
value={preferences.showTimestamps}
|
||||
onChange={toggleTimestamps}
|
||||
className="show-timestamps"
|
||||
/>
|
||||
<Checkbox
|
||||
label="Show previous terminated container"
|
||||
value={previous}
|
||||
value={preferences.previous}
|
||||
onChange={togglePrevious}
|
||||
className="show-previous"
|
||||
/>
|
||||
|
||||
@ -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<Props> {
|
||||
|
||||
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<Props> {
|
||||
|
||||
@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<Props> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<Props> {
|
||||
*/
|
||||
@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<Props> {
|
||||
* 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<Props> {
|
||||
*/
|
||||
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<Props> {
|
||||
};
|
||||
|
||||
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 (
|
||||
<div className="LogList flex box grow align-center justify-center">
|
||||
<Spinner center/>
|
||||
</div>
|
||||
);
|
||||
if (isLoading) {
|
||||
// Don't show a spinner since `Logs` will instead.
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!this.logs.length) {
|
||||
if (!logs.length) {
|
||||
return (
|
||||
<div className="LogList flex box grow align-center justify-center">
|
||||
There are no logs available for container
|
||||
There are no logs available for container {selectedContainer}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cssNames("LogList flex", { isLoading })}>
|
||||
<div className={cssNames("LogList flex")}>
|
||||
<VirtualList
|
||||
items={this.logs}
|
||||
rowHeights={rowHeights}
|
||||
items={logs}
|
||||
rowHeights={array.filled(logs.length, this.lineHeight)}
|
||||
getRow={this.getLogRow}
|
||||
onScroll={this.onScroll}
|
||||
outerRef={this.virtualListDiv}
|
||||
|
||||
@ -21,6 +21,6 @@
|
||||
|
||||
.LogResourceSelector {
|
||||
.Select {
|
||||
min-width: 150px;
|
||||
width: 250px;
|
||||
}
|
||||
}
|
||||
@ -21,96 +21,104 @@
|
||||
|
||||
import "./log-resource-selector.scss";
|
||||
|
||||
import React, { useEffect } from "react";
|
||||
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 { Badge } from "../badge";
|
||||
import { Select, SelectOption } from "../select";
|
||||
import { GroupSelectOption, Select, SelectOption } from "../select";
|
||||
import { LogTabData, logTabStore } from "./log-tab.store";
|
||||
import { podsStore } from "../+workloads-pods/pods.store";
|
||||
import type { TabId } from "./dock.store";
|
||||
|
||||
interface Props {
|
||||
tabId: TabId
|
||||
tabData: LogTabData
|
||||
save: (data: Partial<LogTabData>) => void
|
||||
reload: () => void
|
||||
export interface LogResourceSelectorStore {
|
||||
changeSelectedPod(tabId: TabId, newSelectedPod: Pod): void;
|
||||
mergeData(tabId: TabId, data: Partial<LogTabData>): 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<SelectOption<string>>[] = [
|
||||
{
|
||||
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 (
|
||||
<div className="LogResourceSelector flex gaps align-center">
|
||||
<span>Namespace</span> <Badge data-testid="namespace-badge" label={pod.getNs()}/>
|
||||
<span>Namespace</span>
|
||||
<Badge data-testid="namespace-badge" label={pod.getNs()}/>
|
||||
{
|
||||
pods && (
|
||||
<>
|
||||
<span>Pod</span>
|
||||
{
|
||||
pods.length === 1
|
||||
? (
|
||||
<Badge data-testid="pod-badge" label={pod.getName()}/>
|
||||
)
|
||||
: (
|
||||
<Select
|
||||
options={podSelectOptions}
|
||||
value={{ label: pod.getName(), value: pod.getName() }}
|
||||
onChange={onPodChange}
|
||||
options={[{
|
||||
label: pod.getOwnerRefs()[0].name,
|
||||
options: pods.map(pod => ({
|
||||
label: pod.getName(),
|
||||
value: pod,
|
||||
})),
|
||||
}]}
|
||||
value={{ label: pod.getName(), value: pod }}
|
||||
onChange={({ value }) => store.changeSelectedPod(tabId, value)}
|
||||
autoConvertOptions={false}
|
||||
className="pod-selector"
|
||||
/>
|
||||
)
|
||||
}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
<span>Container</span>
|
||||
{
|
||||
manyOptions
|
||||
? (
|
||||
<Select
|
||||
options={containerSelectOptions}
|
||||
value={{ label: selectedContainer.name, value: selectedContainer.name }}
|
||||
onChange={onContainerChange}
|
||||
value={{ value: selectedContainer, label: selectedContainer }}
|
||||
onChange={({ value }) => store.mergeData(tabId, { selectedContainer: value })}
|
||||
autoConvertOptions={false}
|
||||
className="container-selector"
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<Badge data-testid="container-badge" label={selectedContainer}/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@ -22,6 +22,7 @@
|
||||
.LogSearch {
|
||||
.SearchInput {
|
||||
min-width: 150px;
|
||||
width: 240px;
|
||||
|
||||
.find-count {
|
||||
margin-left: 2px;
|
||||
|
||||
@ -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<LogTabData> {
|
||||
constructor() {
|
||||
constructor(params: Pick<DockTabStoreOptions<LogTabData>, "autoInit"> = {}, protected dockManager: DockManager = dockStore) {
|
||||
super({
|
||||
...params,
|
||||
storageKey: "pod_logs",
|
||||
});
|
||||
validator: value => {
|
||||
const { error } = logTabDataValidator.validate(value);
|
||||
|
||||
reaction(() => podsStore.items.length, () => this.updateTabsData());
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private closeTab(tabId: string) {
|
||||
@action
|
||||
public closeTab(tabId: string) {
|
||||
this.clearData(tabId);
|
||||
dockStore.closeTab(tabId);
|
||||
this.dockManager.closeTab(tabId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a set of namespaces which pod tabs care about
|
||||
*/
|
||||
public getNamespaces(): string[] {
|
||||
const namespaces = new Set<string>();
|
||||
|
||||
for (const { namespace } of this.data.values()) {
|
||||
namespaces.add(namespace);
|
||||
}
|
||||
|
||||
return [...namespaces];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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 = /^(?<timestamp>\d+\S+)(?<line>.*)/.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<TabId, PodLogLine[]>();
|
||||
|
||||
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<IPodLogsQuery>): Promise<string[]> {
|
||||
const data = logTabStore.getData(tabId);
|
||||
const { selectedContainer, previous } = data;
|
||||
const pod = new Pod(data.selectedPod);
|
||||
async loadLogs(data: LogTabData, params: Partial<IPodLogsQuery>): Promise<string[]> {
|
||||
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);
|
||||
}
|
||||
|
||||
27
src/renderer/components/dock/logs.scss
Normal file
27
src/renderer/components/dock/logs.scss
Normal file
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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<Props> {
|
||||
@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<LogList>(); // A reference for VirtualList component
|
||||
|
||||
@ -51,40 +68,125 @@ export class Logs extends React.Component<Props> {
|
||||
}
|
||||
|
||||
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();
|
||||
};
|
||||
|
||||
/**
|
||||
* A function for various actions after search is happened
|
||||
* @param query {string} A text from search field
|
||||
*/
|
||||
@boundMethod
|
||||
onSearch() {
|
||||
this.toOverlay();
|
||||
if (!data) {
|
||||
return false;
|
||||
}
|
||||
|
||||
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<Props> {
|
||||
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 = (
|
||||
<div className="flex gaps">
|
||||
<LogResourceSelector
|
||||
tabId={this.tabId}
|
||||
tabData={data}
|
||||
save={newData => logTabStore.setData(this.tabId, { ...data, ...newData })}
|
||||
reload={this.reload}
|
||||
/>
|
||||
<LogSearch
|
||||
onSearch={this.onSearch}
|
||||
logs={searchLogs}
|
||||
toPrevOverlay={this.toOverlay}
|
||||
toNextOverlay={this.toOverlay}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
if (!podsStore.isLoaded) {
|
||||
return <Spinner center />;
|
||||
}
|
||||
|
||||
const logs = logStore.getLogs(this.tabId, data);
|
||||
const { podsOwner, selectedContainer, selectedPod, showTimestamps, previous, namespace } = data;
|
||||
const { pods, pod } = this.getPods(data);
|
||||
|
||||
if (!pod) {
|
||||
return (
|
||||
<div className="PodLogs flex column">
|
||||
<InfoPanel
|
||||
tabId={this.props.tab.id}
|
||||
controls={controls}
|
||||
controls={
|
||||
<div className="flex gaps align-center">
|
||||
<span>Namespace</span>
|
||||
<Badge label={namespace} />
|
||||
<span>Pod</span>
|
||||
<Badge label={selectedPod || "???"} />
|
||||
<span>Container</span>
|
||||
<Badge label={selectedContainer || "???"} />
|
||||
</div>
|
||||
}
|
||||
showSubmitClose={false}
|
||||
showButtons={false}
|
||||
showStatusPanel={false}
|
||||
/>
|
||||
<p className="pod-not-found">Pod is no longer found {podsOwner && `under owner ${podsOwner}`}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const logs = logStore.logs;
|
||||
const data = logTabStore.getData(this.tabId);
|
||||
|
||||
if (!data) {
|
||||
this.reload();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="PodLogs flex column">
|
||||
{this.renderResourceSelector(data)}
|
||||
<InfoPanel
|
||||
tabId={this.props.tab.id}
|
||||
controls={
|
||||
<div className="flex gaps">
|
||||
<LogResourceSelector
|
||||
tabId={this.tabId}
|
||||
pod={pod}
|
||||
pods={pods}
|
||||
selectedContainer={selectedContainer}
|
||||
/>
|
||||
{this.isLoading && <Spinner />}
|
||||
<LogSearch
|
||||
onSearch={this.onSearch}
|
||||
logs={logs}
|
||||
toPrevOverlay={this.onSearch}
|
||||
toNextOverlay={this.onSearch}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
showSubmitClose={false}
|
||||
showButtons={false}
|
||||
showStatusPanel={false}
|
||||
/>
|
||||
<LogList
|
||||
logs={logs}
|
||||
id={this.tabId}
|
||||
isLoading={this.isLoading}
|
||||
selectedContainer={selectedContainer}
|
||||
isLoading={this.isLoadingInitial}
|
||||
load={this.load}
|
||||
ref={this.logListElement}
|
||||
/>
|
||||
<LogControls
|
||||
tabId={this.tabId}
|
||||
pod={pod}
|
||||
preferences={{ previous, showTimestamps }}
|
||||
logs={logs}
|
||||
tabData={data}
|
||||
save={newData => logTabStore.setData(this.tabId, { ...data, ...newData })}
|
||||
reload={this.reload}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -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<T>(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<T>(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<string, any> = {}) {
|
||||
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();
|
||||
}
|
||||
}
|
||||
})()
|
||||
|
||||
Loading…
Reference in New Issue
Block a user