1
0
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:
Sebastian Malton 2021-11-17 17:03:46 -05:00
parent fa3708c879
commit cea7a34014
28 changed files with 973 additions and 616 deletions

View File

@ -116,9 +116,9 @@
"dev": true "dev": true
}, },
"@types/react": { "@types/react": {
"version": "17.0.35", "version": "17.0.37",
"resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.35.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.37.tgz",
"integrity": "sha512-r3C8/TJuri/SLZiiwwxQoLAoavaczARfT9up9b4Jr65+ErAUX3MIkU0oMOQnrpfgHme8zIqZLX7O5nnjm5Wayw==", "integrity": "sha512-2FS1oTqBGcH/s0E+CjrCCR9+JMpsu9b69RTFO+40ua43ZqP5MmQ4iUde/dMjWR909KxZwmOQIFq6AV6NjEG5xg==",
"dev": true, "dev": true,
"requires": { "requires": {
"@types/prop-types": "*", "@types/prop-types": "*",
@ -222,9 +222,9 @@
} }
}, },
"csstype": { "csstype": {
"version": "2.6.18", "version": "2.6.19",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-2.6.18.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-2.6.19.tgz",
"integrity": "sha512-RSU6Hyeg14am3Ah4VZEmeX8H7kLwEEirXe6aU2IPfKNvhXwTflK5HQRDNI0ypQXoqmm+QPyG2IaPuQE5zMwSIQ==", "integrity": "sha512-ZVxXaNy28/k3kJg0Fou5MiYpp88j7H9hLZp8PDC3jV0WFjfH5E9xHb56L0W59cPbKbcHXeP4qyT8PrHp8t6LcQ==",
"dev": true "dev": true
}, },
"debounce-fn": { "debounce-fn": {

View File

@ -770,9 +770,9 @@
"dev": true "dev": true
}, },
"@types/react": { "@types/react": {
"version": "17.0.35", "version": "17.0.37",
"resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.35.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.37.tgz",
"integrity": "sha512-r3C8/TJuri/SLZiiwwxQoLAoavaczARfT9up9b4Jr65+ErAUX3MIkU0oMOQnrpfgHme8zIqZLX7O5nnjm5Wayw==", "integrity": "sha512-2FS1oTqBGcH/s0E+CjrCCR9+JMpsu9b69RTFO+40ua43ZqP5MmQ4iUde/dMjWR909KxZwmOQIFq6AV6NjEG5xg==",
"dev": true, "dev": true,
"requires": { "requires": {
"@types/prop-types": "*", "@types/prop-types": "*",
@ -876,9 +876,9 @@
} }
}, },
"csstype": { "csstype": {
"version": "2.6.18", "version": "2.6.19",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-2.6.18.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-2.6.19.tgz",
"integrity": "sha512-RSU6Hyeg14am3Ah4VZEmeX8H7kLwEEirXe6aU2IPfKNvhXwTflK5HQRDNI0ypQXoqmm+QPyG2IaPuQE5zMwSIQ==", "integrity": "sha512-ZVxXaNy28/k3kJg0Fou5MiYpp88j7H9hLZp8PDC3jV0WFjfH5E9xHb56L0W59cPbKbcHXeP4qyT8PrHp8t6LcQ==",
"dev": true "dev": true
}, },
"debounce-fn": { "debounce-fn": {

View File

@ -736,9 +736,9 @@
"dev": true "dev": true
}, },
"@types/react": { "@types/react": {
"version": "17.0.35", "version": "17.0.37",
"resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.35.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.37.tgz",
"integrity": "sha512-r3C8/TJuri/SLZiiwwxQoLAoavaczARfT9up9b4Jr65+ErAUX3MIkU0oMOQnrpfgHme8zIqZLX7O5nnjm5Wayw==", "integrity": "sha512-2FS1oTqBGcH/s0E+CjrCCR9+JMpsu9b69RTFO+40ua43ZqP5MmQ4iUde/dMjWR909KxZwmOQIFq6AV6NjEG5xg==",
"dev": true, "dev": true,
"requires": { "requires": {
"@types/prop-types": "*", "@types/prop-types": "*",
@ -842,9 +842,9 @@
} }
}, },
"csstype": { "csstype": {
"version": "2.6.18", "version": "2.6.19",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-2.6.18.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-2.6.19.tgz",
"integrity": "sha512-RSU6Hyeg14am3Ah4VZEmeX8H7kLwEEirXe6aU2IPfKNvhXwTflK5HQRDNI0ypQXoqmm+QPyG2IaPuQE5zMwSIQ==", "integrity": "sha512-ZVxXaNy28/k3kJg0Fou5MiYpp88j7H9hLZp8PDC3jV0WFjfH5E9xHb56L0W59cPbKbcHXeP4qyT8PrHp8t6LcQ==",
"dev": true "dev": true
}, },
"debounce-fn": { "debounce-fn": {

View File

@ -62,7 +62,7 @@ export abstract class BaseStore<T> extends Singleton {
*/ */
load() { load() {
if (!isTestEnv) { 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({ this.storeConfig = new Config({

View File

@ -34,7 +34,9 @@ const electronRemote = (() => {
if (ipcRenderer) { if (ipcRenderer) {
try { try {
return require("@electron/remote"); return require("@electron/remote");
} catch {} } catch {
// ignore temp
}
} }
return null; return null;

View File

@ -102,13 +102,13 @@ export abstract class KubeObjectStore<T extends KubeObject> extends ItemStore<T>
} }
@computed get contextItems(): T[] { @computed get contextItems(): T[] {
const namespaces = this.context?.contextNamespaces ?? []; if (!this.api.isNamespaced) {
return this.items;
}
return this.items.filter(item => { const namespaces = new Set(this.context?.contextNamespaces ?? []);
const itemNamespace = item.getNs();
return !itemNamespace /* cluster-wide */ || namespaces.includes(itemNamespace); return this.items.filter(item => namespaces.has(item.getNs()));
});
} }
getTotalCount(): number { 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[] { protected mergeItems(partialItems: T[], { merge = true, updateStore = true, sort = true, filter = true } = {}): T[] {
let items = partialItems; let items = partialItems;
// update existing items
if (merge) { 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 = [ items = [
...this.items.filter(existingItem => !namespaces.includes(existingItem.getNs())), ...this.items.filter(existingItem => !uids.has(existingItem.getId())),
...partialItems, ...partialItems,
]; ];
} }

View File

@ -37,6 +37,15 @@ export type KubeObjectConstructor<K extends KubeObject> = (new (data: KubeJsonAp
apiBase?: string; apiBase?: string;
}; };
export interface KubeOwnerReference {
apiVersion: string;
kind: string;
name: string;
uid: string;
controller: boolean;
blockOwnerDeletion: boolean;
}
export interface KubeObjectMetadata { export interface KubeObjectMetadata {
uid: string; uid: string;
name: string; name: string;
@ -53,14 +62,7 @@ export interface KubeObjectMetadata {
annotations?: { annotations?: {
[annotation: string]: string; [annotation: string]: string;
}; };
ownerReferences?: { ownerReferences?: KubeOwnerReference[];
apiVersion: string;
kind: string;
name: string;
uid: string;
controller: boolean;
blockOwnerDeletion: boolean;
}[];
} }
export interface KubeStatusData { export interface KubeStatusData {

View File

@ -50,14 +50,15 @@ export interface IKubeWatchEvent<T extends KubeJsonApiData> {
export interface KubeWatchSubscribeStoreOptions { 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 * @default all selected namespaces
*/ */
namespaces?: string[]; namespaces?: string[];
/** /**
* A function that is called when listing fails. If set then blocks errors * A function that is called when listing fails. If set then blocks errors
* being rejected with * from rejecting promises
*/ */
onLoadFailure?: (err: any) => void; onLoadFailure?: (err: any) => void;
} }
@ -116,7 +117,11 @@ export class KubeWatchApi {
#watch = new WatchCount(); #watch = new WatchCount();
private subscribeStore({ store, parent, watchChanges, namespaces, onLoadFailure }: SubscribeStoreParams): Disposer { 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 // don't load or subscribe to a store more than once
return () => this.#watch.dec(store); return () => this.#watch.dec(store);
} }
@ -167,7 +172,7 @@ export class KubeWatchApi {
: noop; // don't watch namespaces if namespaces were provided : noop; // don't watch namespaces if namespaces were provided
return () => { return () => {
if (this.#watch.dec(store) === 0) { if (!isNamespaceFilterWatch || this.#watch.dec(store) === 0) {
// only stop the subcribe if this is the last one // only stop the subcribe if this is the last one
cancelReloading(); cancelReloading();
childController.abort(); childController.abort();
@ -183,7 +188,7 @@ export class KubeWatchApi {
store, store,
parent, parent,
watchChanges: !namespaces && store.api.isNamespaced, watchChanges: !namespaces && store.api.isNamespaced,
namespaces: namespaces ?? KubeWatchApi.context?.contextNamespaces ?? [], namespaces,
onLoadFailure, onLoadFailure,
})), })),
); );

View 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,
};

View File

@ -31,6 +31,7 @@ export * from "./autobind";
export * from "./camelCase"; export * from "./camelCase";
export * from "./cloneJson"; export * from "./cloneJson";
export * from "./cluster-id-url-parsing"; export * from "./cluster-id-url-parsing";
export * from "./comparer";
export * from "./convertCpu"; export * from "./convertCpu";
export * from "./convertMemory"; export * from "./convertMemory";
export * from "./debouncePromise"; export * from "./debouncePromise";
@ -49,6 +50,7 @@ export * from "./objects";
export * from "./openExternal"; export * from "./openExternal";
export * from "./paths"; export * from "./paths";
export * from "./promise-exec"; export * from "./promise-exec";
export * from "./reactions";
export * from "./reject-promise"; export * from "./reject-promise";
export * from "./singleton"; export * from "./singleton";
export * from "./sort-compare"; export * from "./sort-compare";

View 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);
}

View File

@ -205,7 +205,7 @@ export class ClusterManager extends Singleton {
if (error.code === "ENOENT" && error.path === entity.spec.kubeconfigPath) { if (error.code === "ENOENT" && error.path === entity.spec.kubeconfigPath) {
logger.warn(`${logPrefix} kubeconfig file disappeared`, model); logger.warn(`${logPrefix} kubeconfig file disappeared`, model);
} else { } else {
logger.error(`${logPrefix} failed to add cluster: ${error}`, model); logger.error(`${logPrefix} failed to add cluster: ${error}`, { model, source: entity.metadata.source });
} }
} }
} else { } else {

View File

@ -20,7 +20,7 @@
*/ */
import { ipcMain } from "electron"; 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 { broadcastMessage, ClusterListNamespaceForbiddenChannel } from "../common/ipc";
import { ContextHandler } from "./context-handler"; import { ContextHandler } from "./context-handler";
import { AuthorizationV1Api, CoreV1Api, HttpError, KubeConfig, V1ResourceAttributes } from "@kubernetes/client-node"; 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 plimit from "p-limit";
import type { ClusterState, ClusterRefreshOptions, ClusterMetricsResourceType, ClusterId, ClusterMetadata, ClusterModel, ClusterPreferences, ClusterPrometheusPreferences, UpdateClusterModel, KubeAuthUpdate } from "../common/cluster-types"; 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 { 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"; import type { Response } from "request";
/** /**

View File

@ -25,8 +25,7 @@ import { render } from "@testing-library/react";
import * as selectEvent from "react-select-event"; import * as selectEvent from "react-select-event";
import { Pod } from "../../../../common/k8s-api/endpoints"; import { Pod } from "../../../../common/k8s-api/endpoints";
import { LogResourceSelector } from "../log-resource-selector"; import { LogResourceSelector, LogResourceSelectorProps } from "../log-resource-selector";
import type { LogTabData } from "../log-tab.store";
import { dockerPod, deploymentPod1 } from "./pod.mock"; import { dockerPod, deploymentPod1 } from "./pod.mock";
import { ThemeStore } from "../../../theme.store"; import { ThemeStore } from "../../../theme.store";
import { UserStore } from "../../../../common/user-store"; import { UserStore } from "../../../../common/user-store";
@ -51,35 +50,26 @@ jest.mock("electron", () => ({
AppPaths.init(); AppPaths.init();
const getComponent = (tabData: LogTabData) => { const getOnePodTabProps = (): LogResourceSelectorProps => {
return (
<LogResourceSelector
tabId="tabId"
tabData={tabData}
save={jest.fn()}
reload={jest.fn()}
/>
);
};
const getOnePodTabData = (): LogTabData => {
const selectedPod = new Pod(dockerPod); const selectedPod = new Pod(dockerPod);
return { return {
pods: [] as Pod[], pod: selectedPod,
selectedPod, pods: [selectedPod],
selectedContainer: selectedPod.getContainers()[0], tabId: "tabId",
selectedContainer: selectedPod.getContainers()[0].name,
}; };
}; };
const getFewPodsTabData = (): LogTabData => { const getFewPodsTabProps = (): LogResourceSelectorProps => {
const selectedPod = new Pod(deploymentPod1); const selectedPod = new Pod(deploymentPod1);
const anotherPod = new Pod(dockerPod); const anotherPod = new Pod(dockerPod);
return { return {
pods: [anotherPod], pod: selectedPod,
selectedPod, pods: [selectedPod, anotherPod],
selectedContainer: selectedPod.getContainers()[0], tabId: "tabId",
selectedContainer: selectedPod.getContainers()[0].name,
}; };
}; };
@ -99,42 +89,46 @@ describe("<LogResourceSelector />", () => {
}); });
it("renders w/o errors", () => { it("renders w/o errors", () => {
const tabData = getOnePodTabData(); const props = getOnePodTabProps();
const { container } = render(getComponent(tabData)); const { container } = render(<LogResourceSelector {...props} />);
expect(container).toBeInstanceOf(HTMLElement); expect(container).toBeInstanceOf(HTMLElement);
}); });
it("renders proper namespace", () => { it("renders proper namespace", () => {
const tabData = getOnePodTabData(); const props = getOnePodTabProps();
const { getByTestId } = render(getComponent(tabData)); const { getByTestId } = render(<LogResourceSelector {...props} />);
const ns = getByTestId("namespace-badge"); const ns = getByTestId("namespace-badge");
expect(ns).toHaveTextContent("default"); expect(ns).toHaveTextContent("default");
}); });
it("renders proper selected items within dropdowns", () => { it("renders proper selected items within dropdowns", () => {
const tabData = getOnePodTabData(); const props = getOnePodTabProps();
const { getByText } = render(getComponent(tabData)); const { getByText } = render(<LogResourceSelector {...props} />);
expect(getByText("dockerExporter")).toBeInTheDocument(); expect(getByText("dockerExporter")).toBeInTheDocument();
expect(getByText("docker-exporter")).toBeInTheDocument(); expect(getByText("docker-exporter")).toBeInTheDocument();
}); });
it("renders sibling pods in dropdown", () => { it("renders sibling pods in dropdown", () => {
const tabData = getFewPodsTabData(); const props = getFewPodsTabProps();
const { container, getByText } = render(getComponent(tabData)); const { container, getByText } = render(<LogResourceSelector {...props} />);
const podSelector: HTMLElement = container.querySelector(".pod-selector"); const podSelector: HTMLElement = container.querySelector(".pod-selector");
selectEvent.openMenu(podSelector); selectEvent.openMenu(podSelector);
expect(getByText("dockerExporter")).toBeInTheDocument(); expect(getByText("dockerExporter", {
expect(getByText("deploymentPod1")).toBeInTheDocument(); selector: ".Select__option",
})).toBeInTheDocument();
expect(getByText("deploymentPod1", {
selector: ".Select__option",
})).toBeInTheDocument();
}); });
it("renders sibling containers in dropdown", () => { it("renders sibling containers in dropdown", () => {
const tabData = getFewPodsTabData(); const props = getFewPodsTabProps();
const { getByText, container } = render(getComponent(tabData)); const { getByText, container } = render(<LogResourceSelector {...props} />);
const containerSelector: HTMLElement = container.querySelector(".container-selector"); const containerSelector: HTMLElement = container.querySelector(".container-selector");
selectEvent.openMenu(containerSelector); selectEvent.openMenu(containerSelector);
@ -145,8 +139,8 @@ describe("<LogResourceSelector />", () => {
}); });
it("renders pod owner as dropdown title", () => { it("renders pod owner as dropdown title", () => {
const tabData = getFewPodsTabData(); const props = getFewPodsTabProps();
const { getByText, container } = render(getComponent(tabData)); const { getByText, container } = render(<LogResourceSelector {...props} />);
const podSelector: HTMLElement = container.querySelector(".pod-selector"); const podSelector: HTMLElement = container.querySelector(".pod-selector");
selectEvent.openMenu(podSelector); selectEvent.openMenu(podSelector);

View File

@ -19,146 +19,137 @@
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * 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 { Pod } from "../../../../common/k8s-api/endpoints";
import { ThemeStore } from "../../../theme.store"; import type { TabId } from "../dock.store";
import { dockStore } from "../dock.store"; import { DockManager, LogTabStore } from "../log-tab.store";
import { logTabStore } from "../log-tab.store"; import { deploymentPod1, dockerPod, noOwnersPod } from "./pod.mock";
import { deploymentPod1, deploymentPod2, deploymentPod3, dockerPod } from "./pod.mock";
import fse from "fs-extra";
import { mockWindow } from "../../../../../__mocks__/windowMock";
import { AppPaths } from "../../../../common/app-paths";
mockWindow(); function getMockDockManager(): jest.Mocked<DockManager> {
return {
renameTab: jest.fn(),
createTab: jest.fn(),
closeTab: jest.fn(),
};
}
jest.mock("electron", () => ({ describe("LogTabStore", () => {
app: { describe("createPodTab()", () => {
getVersion: () => "99.99.99", it("throws if data is not an object", () => {
getName: () => "lens", const dockManager = getMockDockManager();
setName: jest.fn(), const logTabStore = new LogTabStore({ autoInit: false }, dockManager);
setPath: jest.fn(),
getPath: () => "tmp",
getLocale: () => "en",
setLoginItemSettings: jest.fn(),
},
ipcMain: {
on: jest.fn(),
handle: jest.fn(),
},
}));
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));
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.getData(dockStore.selectedTabId)).toEqual({ it("throws if data.selectedPod is not an object", () => {
pods: [selectedPod], const dockManager = getMockDockManager();
selectedPod, const logTabStore = new LogTabStore({ autoInit: false }, dockManager);
selectedContainer,
showTimestamps: false, expect(() => logTabStore.createPodTab({ selectedPod: 1 as any, selectedContainer: {} as any })).toThrow(/is not an object/);
previous: false, });
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", () => { describe("changeSelectedPod()", () => {
const selectedPod = new Pod(deploymentPod1); let dockManager: jest.Mocked<DockManager>;
const siblingPod = new Pod(deploymentPod2); let logTabStore: LogTabStore;
const selectedContainer = selectedPod.getInitContainers()[0]; let tabId: TabId;
const pod = new Pod(dockerPod);
logTabStore.createPodTab({ beforeEach(() => {
selectedPod, dockManager = getMockDockManager();
selectedContainer, logTabStore = new LogTabStore({ autoInit: false }, dockManager);
tabId = logTabStore.createPodTab({ selectedContainer: { name: "docker-exporter" } as any, selectedPod: pod });
}); });
expect(logTabStore.getData(dockStore.selectedTabId)).toEqual({ it("should work as expected", () => {
pods: [selectedPod, siblingPod], const newPod = new Pod(deploymentPod1);
selectedPod,
selectedContainer, logTabStore.changeSelectedPod(tabId, newPod);
showTimestamps: false,
previous: false, 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", () => { describe("getData()", () => {
const selectedPod = new Pod(deploymentPod1); let dockManager: jest.Mocked<DockManager>;
const selectedContainer = selectedPod.getInitContainers()[0]; let logTabStore: LogTabStore;
let tabId: TabId;
const pod = new Pod(dockerPod);
logTabStore.createPodTab({ beforeEach(() => {
selectedPod, dockManager = getMockDockManager();
selectedContainer, 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({ it("should return undefined for unknown tab ID", () => {
pods: [selectedPod], expect(logTabStore.getData("foo")).toBeUndefined();
selectedPod,
selectedContainer,
showTimestamps: false,
previous: false,
}); });
}); });
it("adds item into pods list if new sibling pod added to store", () => { describe("setData()", () => {
const selectedPod = new Pod(deploymentPod1); let dockManager: jest.Mocked<DockManager>;
const selectedContainer = selectedPod.getInitContainers()[0]; let logTabStore: LogTabStore;
logTabStore.createPodTab({ beforeEach(() => {
selectedPod, dockManager = getMockDockManager();
selectedContainer, logTabStore = new LogTabStore({ autoInit: false }, dockManager);
}); });
podsStore.items.push(new Pod(deploymentPod3)); it("should throw an error on invalid data", () => {
expect(() => logTabStore.setData("foo", 7 as any)).toThrowError();
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(); it("should not throw an error on valid data", () => {
expect(() => logTabStore.setData("foo", {
expect(logTabStore.getData(dockStore.selectedTabId)).toBeUndefined(); namespace: "foobar",
expect(logTabStore.getData(id)).toBeUndefined(); previous: false,
expect(dockStore.getTabById(id)).toBeUndefined(); selectedPod: "blat",
showTimestamps: false,
podsOwner: "bar",
selectedContainer: "bat",
})).not.toThrowError();
expect(logTabStore.getData("foo")).toBeDefined();
});
}); });
}); });

View File

@ -19,9 +19,11 @@
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * 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", apiVersion: "v1",
kind: "dummy", kind: "Pod",
metadata: { metadata: {
uid: "dockerExporter", uid: "dockerExporter",
name: "dockerExporter", name: "dockerExporter",
@ -30,7 +32,48 @@ export const dockerPod = {
namespace: "default", namespace: "default",
}, },
spec: { 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: [ containers: [
{ {
name: "docker-exporter", name: "docker-exporter",

View File

@ -19,55 +19,80 @@
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
import { autorun, observable, reaction } from "mobx"; import { action, autorun, observable, reaction } from "mobx";
import { autoBind, createStorage, StorageHelper, toJS } from "../../utils"; import logger from "../../../common/logger";
import { autoBind, createStorage, noop, StorageHelper, toJS } from "../../utils";
import { dockStore, TabId } from "./dock.store"; import { dockStore, TabId } from "./dock.store";
export interface DockTabStoreOptions { export interface DockTabStoreOptions<T> {
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 * 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> { export class DockTabStore<T> {
protected storage?: StorageHelper<DockTabStorageState<T>>; protected storage?: StorageHelper<Record<TabId, T>>;
protected data = observable.map<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); autoBind(this);
this.options = { this.validator = validator;
autoInit: true,
...this.options,
};
if (this.options.autoInit) { if (autoInit) {
this.init(); this.init(storageKey);
} }
} }
protected init() { protected init(storageKey: string | undefined) {
const { storageKey } = this.options;
// auto-save to local-storage // auto-save to local-storage
if (storageKey) { if (storageKey) {
this.storage = createStorage(storageKey, {}); this.storage = createStorage(storageKey, {});
this.storage.whenReady.then(() => { this.storage.whenReady.then(action(() => {
this.data.replace(this.storage.get()); for (const [tabId, value] of Object.entries(this.storage.get())) {
reaction(() => this.toJSON(), data => this.storage.set(data)); 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 // clear data for closed tabs
autorun(() => { 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 => { for (const tabId in this.data) {
if (!currentTabs.includes(tabId)) { if (!currentTabs.has(tabId)) {
this.clearData(tabId); this.clearData(tabId);
} }
}); }
}); });
} }
@ -75,7 +100,7 @@ export class DockTabStore<T> {
return data; return data;
} }
protected toJSON(): DockTabStorageState<T> { protected toJSON(): Record<TabId, T> {
const deepCopy = toJS(this.data); const deepCopy = toJS(this.data);
deepCopy.forEach((tabData, key) => { deepCopy.forEach((tabData, key) => {
@ -94,9 +119,21 @@ export class DockTabStore<T> {
} }
setData(tabId: TabId, data: T) { setData(tabId: TabId, data: T) {
this.validator(data);
this.data.set(tabId, 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) { clearData(tabId: TabId) {
this.data.delete(tabId); this.data.delete(tabId);
} }

View File

@ -43,8 +43,8 @@ export class EditResourceStore extends DockTabStore<EditingResource> {
autoBind(this); autoBind(this);
} }
protected async init() { protected async init(storageKey: string | undefined) {
super.init(); super.init(storageKey);
await this.storage.whenReady; await this.storage.whenReady;
autorun(() => { autorun(() => {

View File

@ -24,45 +24,37 @@ import "./log-controls.scss";
import React from "react"; import React from "react";
import { observer } from "mobx-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 { cssNames, saveFileDialog } from "../../utils";
import { logStore } from "./log.store"; import { logStore } from "./log.store";
import { Checkbox } from "../checkbox"; import { Checkbox } from "../checkbox";
import { Icon } from "../icon"; 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 { interface Props {
tabData?: LogTabData pod: Pod;
logs: string[] tabId: TabId;
save: (data: Partial<LogTabData>) => void preferences: {
reload: () => void showTimestamps: boolean;
previous: boolean;
};
logs: string[];
} }
export const LogControls = observer((props: Props) => { export const LogControls = observer(({ pod, tabId, preferences, logs }: Props) => {
const { tabData, save, reload, logs } = props; const since = logStore.getFirstTime(tabId);
if (!tabData) {
return null;
}
const { showTimestamps, previous } = tabData;
const since = logs.length ? logStore.getTimestamps(logs[0]) : null;
const pod = new Pod(tabData.selectedPod);
const toggleTimestamps = () => { const toggleTimestamps = () => {
save({ showTimestamps: !showTimestamps }); logTabStore.mergeData(tabId, { showTimestamps: !preferences.showTimestamps });
}; };
const togglePrevious = () => { const togglePrevious = () => {
save({ previous: !previous }); logTabStore.mergeData(tabId, { previous: !preferences.previous });
reload();
}; };
const downloadLogs = () => { const downloadLogs = () => {
const fileName = pod.getName(); saveFileDialog(`${pod.getName()}.log`, logs.join("\n"), "text/plain");
const logsToDownload = showTimestamps ? logs : logStore.logsWithoutTimestamps;
saveFileDialog(`${fileName}.log`, logsToDownload.join("\n"), "text/plain");
}; };
return ( return (
@ -70,21 +62,20 @@ export const LogControls = observer((props: Props) => {
<div className="time-range"> <div className="time-range">
{since && ( {since && (
<span> <span>
Logs from{" "} Logs from <b>{since}</b>
<b>{new Date(since[0]).toLocaleString()}</b>
</span> </span>
)} )}
</div> </div>
<div className="flex gaps align-center"> <div className="flex gaps align-center">
<Checkbox <Checkbox
label="Show timestamps" label="Show timestamps"
value={showTimestamps} value={preferences.showTimestamps}
onChange={toggleTimestamps} onChange={toggleTimestamps}
className="show-timestamps" className="show-timestamps"
/> />
<Checkbox <Checkbox
label="Show previous terminated container" label="Show previous terminated container"
value={previous} value={preferences.previous}
onChange={togglePrevious} onChange={togglePrevious}
className="show-previous" className="show-previous"
/> />

View File

@ -25,25 +25,20 @@ import React from "react";
import AnsiUp from "ansi_up"; import AnsiUp from "ansi_up";
import DOMPurify from "dompurify"; import DOMPurify from "dompurify";
import debounce from "lodash/debounce"; 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 { disposeOnUnmount, observer } from "mobx-react";
import moment from "moment-timezone";
import type { Align, ListOnScrollProps } from "react-window"; import type { Align, ListOnScrollProps } from "react-window";
import { SearchStore, searchStore } from "../../../common/search-store"; import { SearchStore, searchStore } from "../../../common/search-store";
import { UserStore } from "../../../common/user-store";
import { array, boundMethod, cssNames } from "../../utils"; import { array, boundMethod, cssNames } from "../../utils";
import { Spinner } from "../spinner";
import { VirtualList } from "../virtual-list"; import { VirtualList } from "../virtual-list";
import { logStore } from "./log.store";
import { logTabStore } from "./log-tab.store";
import { ToBottom } from "./to-bottom"; import { ToBottom } from "./to-bottom";
interface Props { interface Props {
logs: string[] logs: string[]
isLoading: boolean isLoading: boolean
load: () => void load: () => void
id: string selectedContainer: string
} }
const colorConverter = new AnsiUp(); const colorConverter = new AnsiUp();
@ -64,9 +59,11 @@ export class LogList extends React.Component<Props> {
componentDidMount() { componentDidMount() {
disposeOnUnmount(this, [ disposeOnUnmount(this, [
reaction(() => this.props.logs, this.onLogsInitialLoad), reaction(() => this.props.logs, (logs, prevLogs) => {
reaction(() => this.props.logs, this.onLogsUpdate), this.onLogsInitialLoad(logs, prevLogs);
reaction(() => this.props.logs, this.onUserScrolledUp), this.onLogsUpdate();
this.onUserScrolledUp(logs, prevLogs);
}),
]); ]);
} }
@ -88,10 +85,14 @@ export class LogList extends React.Component<Props> {
@boundMethod @boundMethod
onUserScrolledUp(logs: string[], prevLogs: string[]) { onUserScrolledUp(logs: string[], prevLogs: string[]) {
if (!this.virtualListDiv.current) return; const { current } = this.virtualListDiv;
if (!current) {
return;
}
const newLogsAdded = prevLogs.length < logs.length; const newLogsAdded = prevLogs.length < logs.length;
const scrolledToBeginning = this.virtualListDiv.current.scrollTop === 0; const scrolledToBeginning = current.scrollTop === 0;
if (newLogsAdded && scrolledToBeginning) { if (newLogsAdded && scrolledToBeginning) {
const firstLineContents = prevLogs[0]; 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 * Checks if JumpToBottom button should be visible and sets its observable
* @param props Scrolling props from virtual list core * @param props Scrolling props from virtual list core
@ -142,7 +127,13 @@ export class LogList extends React.Component<Props> {
*/ */
@action @action
setLastLineVisibility = (props: ListOnScrollProps) => { setLastLineVisibility = (props: ListOnScrollProps) => {
const { scrollHeight, clientHeight } = this.virtualListDiv.current; const { current } = this.virtualListDiv;
if (!current) {
return;
}
const { scrollHeight, clientHeight } = current;
const { scrollOffset } = props; const { scrollOffset } = props;
this.isLastLineVisible = (clientHeight + scrollOffset) === scrollHeight; 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 * Check if user scrolled to top and new logs should be loaded
* @param props Scrolling props from virtual list core * @param props Scrolling props from virtual list core
*/ */
checkLoadIntent = (props: ListOnScrollProps) => { checkLoadIntent = ({ scrollOffset }: ListOnScrollProps) => {
const { scrollOffset } = props;
if (scrollOffset === 0) { if (scrollOffset === 0) {
this.props.load(); this.props.load();
} }
}; };
scrollToBottom = () => { scrollToItem = (index: number, align: Align) => {
if (!this.virtualListDiv.current) return; this.virtualListRef.current?.scrollToItem(index, align);
this.virtualListDiv.current.scrollTop = this.virtualListDiv.current.scrollHeight;
}; };
scrollToItem = (index: number, align: Align) => { scrollToBottom = () => {
this.virtualListRef.current.scrollToItem(index, align); const { current } = this.virtualListDiv;
if (!current) {
return;
}
current.scrollTop = current.scrollHeight;
}; };
onScroll = (props: ListOnScrollProps) => { onScroll = (props: ListOnScrollProps) => {
this.isLastLineVisible = false; this.isLastLineVisible = false;
this.setButtonVisibility(props);
this.setLastLineVisibility(props);
this.onScrollDebounced(props); this.onScrollDebounced(props);
}; };
onScrollDebounced = debounce((props: ListOnScrollProps) => { onScrollDebounced = debounce((props: ListOnScrollProps) => {
if (!this.virtualListDiv.current) return;
this.setButtonVisibility(props);
this.setLastLineVisibility(props);
this.checkLoadIntent(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 * 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) => { getLogRow = (rowIndex: number) => {
const { searchQuery, isActiveOverlay } = searchStore; const { searchQuery, isActiveOverlay } = searchStore;
const item = this.logs[rowIndex]; const item = this.props.logs[rowIndex];
const contents: React.ReactElement[] = []; const contents: React.ReactElement[] = [];
const ansiToHtml = (ansi: string) => DOMPurify.sanitize(colorConverter.ansi_to_html(ansi)); const ansiToHtml = (ansi: string) => DOMPurify.sanitize(colorConverter.ansi_to_html(ansi));
@ -232,31 +227,26 @@ export class LogList extends React.Component<Props> {
}; };
render() { render() {
const { isLoading } = this.props; const { logs, isLoading, selectedContainer } = this.props;
const isInitLoading = isLoading && !this.logs.length;
const rowHeights = array.filled(this.logs.length, this.lineHeight);
if (isInitLoading) { if (isLoading) {
return ( // Don't show a spinner since `Logs` will instead.
<div className="LogList flex box grow align-center justify-center"> return null;
<Spinner center/>
</div>
);
} }
if (!this.logs.length) { if (!logs.length) {
return ( return (
<div className="LogList flex box grow align-center justify-center"> <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> </div>
); );
} }
return ( return (
<div className={cssNames("LogList flex", { isLoading })}> <div className={cssNames("LogList flex")}>
<VirtualList <VirtualList
items={this.logs} items={logs}
rowHeights={rowHeights} rowHeights={array.filled(logs.length, this.lineHeight)}
getRow={this.getLogRow} getRow={this.getLogRow}
onScroll={this.onScroll} onScroll={this.onScroll}
outerRef={this.virtualListDiv} outerRef={this.virtualListDiv}

View File

@ -21,6 +21,6 @@
.LogResourceSelector { .LogResourceSelector {
.Select { .Select {
min-width: 150px; width: 250px;
} }
} }

View File

@ -21,96 +21,104 @@
import "./log-resource-selector.scss"; import "./log-resource-selector.scss";
import React, { useEffect } from "react"; import React from "react";
import { observer } from "mobx-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 { Badge } from "../badge";
import { Select, SelectOption } from "../select"; import { GroupSelectOption, Select, SelectOption } from "../select";
import { LogTabData, logTabStore } from "./log-tab.store"; import { LogTabData, logTabStore } from "./log-tab.store";
import { podsStore } from "../+workloads-pods/pods.store";
import type { TabId } from "./dock.store"; import type { TabId } from "./dock.store";
interface Props { export interface LogResourceSelectorStore {
tabId: TabId changeSelectedPod(tabId: TabId, newSelectedPod: Pod): void;
tabData: LogTabData mergeData(tabId: TabId, data: Partial<LogTabData>): void;
save: (data: Partial<LogTabData>) => void
reload: () => void
} }
export const LogResourceSelector = observer((props: Props) => { export interface LogResourceSelectorProps {
const { tabData, save, reload, tabId } = props; tabId: TabId;
const { selectedPod, selectedContainer, pods } = tabData; pod: Pod;
const pod = new Pod(selectedPod);
/**
* 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 containers = pod.getContainers();
const initContainers = pod.getInitContainers(); const initContainers = pod.getInitContainers();
const onContainerChange = (option: SelectOption) => { const containerSelectOptions: GroupSelectOption<SelectOption<string>>[] = [
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 = [
{ {
label: `Containers`, label: `Containers`,
options: getSelectOptions(containers.map(container => container.name)), options: containers.map(container => ({
value: container.name,
label: container.name,
})),
}, },
{ {
label: `Init Containers`, label: `Init Containers`,
options: getSelectOptions(initContainers.map(container => container.name)), options: initContainers.map(container => ({
value: container.name,
label: container.name,
})),
}, },
]; ];
const podSelectOptions = [ const manyOptions = (containers.length + initContainers.length) > 1;
{
label: pod.getOwnerRefs()[0]?.name,
options: getSelectOptions(pods.map(pod => pod.metadata.name)),
},
];
useEffect(() => {
reload();
}, [selectedPod]);
return ( return (
<div className="LogResourceSelector flex gaps align-center"> <div className="LogResourceSelector flex gaps align-center">
<span>Namespace</span> <Badge data-testid="namespace-badge" label={pod.getNs()}/> <span>Namespace</span>
<span>Pod</span> <Badge data-testid="namespace-badge" label={pod.getNs()}/>
<Select {
options={podSelectOptions} pods && (
value={{ label: pod.getName(), value: pod.getName() }} <>
onChange={onPodChange} <span>Pod</span>
autoConvertOptions={false} {
className="pod-selector" pods.length === 1
/> ? (
<Badge data-testid="pod-badge" label={pod.getName()}/>
)
: (
<Select
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> <span>Container</span>
<Select {
options={containerSelectOptions} manyOptions
value={{ label: selectedContainer.name, value: selectedContainer.name }} ? (
onChange={onContainerChange} <Select
autoConvertOptions={false} options={containerSelectOptions}
className="container-selector" 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> </div>
); );
}); });

View File

@ -22,6 +22,7 @@
.LogSearch { .LogSearch {
.SearchInput { .SearchInput {
min-width: 150px; min-width: 150px;
width: 240px;
.find-count { .find-count {
margin-left: 2px; margin-left: 2px;

View File

@ -19,131 +19,172 @@
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
import Joi from "joi";
import uniqueId from "lodash/uniqueId"; import uniqueId from "lodash/uniqueId";
import { reaction } from "mobx"; import { action } from "mobx";
import { podsStore } from "../+workloads-pods/pods.store"; import type { IPodContainer, Pod } from "../../../common/k8s-api/endpoints";
import { DockTabStore, DockTabStoreOptions } from "./dock-tab.store";
import { IPodContainer, Pod } from "../../../common/k8s-api/endpoints"; import { dockStore, DockTab, DockTabCreate, TabId, TabKind } from "./dock.store";
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";
export interface LogTabData { export interface LogTabData {
pods: Pod[]; /**
selectedPod: Pod; * The pod owner ID.
selectedContainer: IPodContainer */
showTimestamps?: boolean podsOwner?: string;
previous?: boolean
/**
* 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 selectedPod: Pod
selectedContainer: IPodContainer selectedContainer: IPodContainer
} }
interface WorkloadLogsTabData { export interface DockManager {
workload: WorkloadKubeObject renameTab(tabId: TabId, name: string): void;
createTab(rawTabDesc: DockTabCreate, addNumber?: boolean): DockTab;
closeTab(tabId: TabId): void;
} }
export class LogTabStore extends DockTabStore<LogTabData> { export class LogTabStore extends DockTabStore<LogTabData> {
constructor() { constructor(params: Pick<DockTabStoreOptions<LogTabData>, "autoInit"> = {}, protected dockManager: DockManager = dockStore) {
super({ super({
...params,
storageKey: "pod_logs", 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 { createPodTab(tabData: PodLogsTabData): string {
const pods = podsStore.getPodsByOwnerId(workload.getId()); 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]; if (!selectedPod || typeof selectedPod !== "object") {
const selectedContainer = selectedPod.getAllContainers()[0]; throw new TypeError("selectedPod is not an object");
const title = `${workload.kind} ${selectedPod.getName()}`; }
this.createLogsTab(title, { if (!selectedContainer || typeof selectedContainer !== "object") {
pods, throw new TypeError("selectedContainer is not an object");
selectedPod, }
selectedContainer,
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) { private getTabName(pod: Pod): string {
const { selectedPod } = this.getData(tabId); return `Pod Logs: ${pod.getName()}`;
dockStore.renameTab(tabId, `Pod ${selectedPod.metadata.name}`);
} }
private createDockTab(tabParams: DockTabCreateSpecific) { @action
dockStore.createTab({ changeSelectedPod(tabId: string, pod: Pod): void {
...tabParams, const oldSelectedPod = this.getData(tabId).selectedPod;
kind: TabKind.POD_LOGS,
}, false); 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 { private createLogsTab(title: string, data: LogTabData): string {
const id = uniqueId("log-tab-"); const id = uniqueId("log-tab-");
this.createDockTab({ id, title }); this.dockManager.createTab({
this.setData(id, { id,
...data, title,
showTimestamps: false, kind: TabKind.POD_LOGS,
previous: false, }, false);
}); this.setData(id, data);
return id; return id;
} }
private updateTabsData() { @action
for (const [tabId, tabData] of this.data) { public closeTab(tabId: string) {
try { this.clearData(tabId);
if (!tabData.selectedPod) { this.dockManager.closeTab(tabId);
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) { /**
this.clearData(tabId); * Get a set of namespaces which pod tabs care about
dockStore.closeTab(tabId); */
public getNamespaces(): string[] {
const namespaces = new Set<string>();
for (const { namespace } of this.data.values()) {
namespaces.add(namespace);
}
return [...namespaces];
} }
} }

View File

@ -19,40 +19,42 @@
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * 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 { IPodLogsQuery, podsApi } from "../../../common/k8s-api/endpoints";
import { autoBind, interval } from "../../utils"; import { UserStore } from "../../../common/user-store";
import { dockStore, TabId, TabKind } from "./dock.store"; import { autoBind } from "../../utils";
import { logTabStore } from "./log-tab.store"; import type { TabId } from "./dock.store";
import type { LogTabData } from "./log-tab.store";
type PodLogLine = string; type PodLogLine = string;
const logLinesToLoad = 500; 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 { 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[]>(); @observable podLogs = observable.map<TabId, PodLogLine[]>();
constructor() { constructor() {
makeObservable(this); makeObservable(this);
autoBind(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 { handlerError(tabId: TabId, error: any): void {
@ -65,7 +67,6 @@ export class LogStore {
`Reason: ${error.reason} (${error.code})`, `Reason: ${error.reason} (${error.code})`,
]; ];
this.refresher.stop();
this.podLogs.set(tabId, message); this.podLogs.set(tabId, message);
} }
@ -76,13 +77,14 @@ export class LogStore {
* messages * messages
* @param tabId * @param tabId
*/ */
load = async (tabId: TabId) => { load = async (tabId: TabId, data: LogTabData) => {
try { try {
const logs = await this.loadLogs(tabId, { const prevLogsLength = this.podLogs.get(tabId)?.length ?? 0;
tailLines: this.lines + logLinesToLoad, const params = {
}); tailLines: prevLogsLength + logLinesToLoad,
};
const logs = await this.loadLogs(data, params);
this.refresher.start();
this.podLogs.set(tabId, logs); this.podLogs.set(tabId, logs);
} catch (error) { } catch (error) {
this.handlerError(tabId, error); this.handlerError(tabId, error);
@ -95,19 +97,18 @@ export class LogStore {
* starting from last line received. * starting from last line received.
* @param tabId * @param tabId
*/ */
loadMore = async (tabId: TabId) => { loadMore = async (tabId: TabId, data: LogTabData) => {
if (!this.podLogs.get(tabId).length) { if (!this.podLogs.get(tabId).length) {
return; return;
} }
try { try {
const oldLogs = this.podLogs.get(tabId); const logs = await this.loadLogs(data, {
const logs = await this.loadLogs(tabId, {
sinceTime: this.getLastSinceTime(tabId), sinceTime: this.getLastSinceTime(tabId),
}); });
// Add newly received logs to bottom // Add newly received logs to bottom
this.podLogs.set(tabId, [...oldLogs, ...logs.filter(Boolean)]); this.podLogs.get(tabId).push(...logs.filter(Boolean));
} catch (error) { } catch (error) {
this.handlerError(tabId, error); this.handlerError(tabId, error);
} }
@ -120,48 +121,35 @@ export class LogStore {
* @param params request parameters described in IPodLogsQuery interface * @param params request parameters described in IPodLogsQuery interface
* @returns A fetch request promise * @returns A fetch request promise
*/ */
async loadLogs(tabId: TabId, params: Partial<IPodLogsQuery>): Promise<string[]> { async loadLogs(data: LogTabData, params: Partial<IPodLogsQuery>): Promise<string[]> {
const data = logTabStore.getData(tabId); const { podsOwner, selectedContainer, selectedPod, previous } = data;
const { selectedContainer, previous } = data; const pod = podsOwner
const pod = new Pod(data.selectedPod); ? podsStore.getPodsByOwnerId(podsOwner).find(pod => pod.getId() === selectedPod)
: podsStore.getById(selectedPod);
if (!pod) {
return [];
}
const namespace = pod.getNs(); const namespace = pod.getNs();
const name = pod.getName(); const name = pod.getName();
const result = await podsApi.getLogs({ namespace, name }, { const result = await podsApi.getLogs({ namespace, name }, {
...params, ...params,
timestamps: true, // Always setting timestamp to separate old logs from new ones timestamps: true, // Always setting timestamp to separate old logs from new ones
container: selectedContainer.name, container: selectedContainer,
previous, previous,
}); });
return result.trimEnd().split("\n"); return result.split("\n").filter(Boolean);
} }
/** getLogs(tabId: TabId, { showTimestamps }: LogTabData): string[] {
* Converts logs into a string array const logs = this.podLogs.get(tabId) ?? [];
* @returns Length of log lines const { localeTimezone } = UserStore.getInstance();
*/
@computed
get lines(): number {
return this.logs.length;
}
return showTimestamps
/** ? logs.map(log => formatTimestamp(log, localeTimezone))
* Returns logs with timestamps for selected tab : logs.map(removeTimestamp);
*/
@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));
} }
/** /**
@ -179,24 +167,33 @@ export class LogStore {
return stamp.toISOString(); 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) { if (!logs?.length) {
return ["", logs]; 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) { getTimestamps(logs: string) {
return logs.match(/^\d+\S+/gm); return logs.match(/^\d+\S+/gm);
} }
removeTimestamps(logs: string) {
return logs.replace(/^\d+.*?\s/gm, "");
}
clearLogs(tabId: TabId) { clearLogs(tabId: TabId) {
this.podLogs.delete(tabId); this.podLogs.delete(tabId);
} }

View 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);
}
}

View File

@ -19,12 +19,13 @@
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
import "./logs.scss";
import React from "react"; 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 { disposeOnUnmount, observer } from "mobx-react";
import { searchStore } from "../../../common/search-store"; import { searchStore } from "../../../common/search-store";
import { boundMethod } from "../../utils";
import type { DockTab } from "./dock.store"; import type { DockTab } from "./dock.store";
import { InfoPanel } from "./info-panel"; import { InfoPanel } from "./info-panel";
import { LogResourceSelector } from "./log-resource-selector"; import { LogResourceSelector } from "./log-resource-selector";
@ -33,15 +34,31 @@ import { logStore } from "./log.store";
import { LogSearch } from "./log-search"; import { LogSearch } from "./log-search";
import { LogControls } from "./log-controls"; import { LogControls } from "./log-controls";
import { LogTabData, logTabStore } from "./log-tab.store"; 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 { interface Props {
className?: string className?: string
tab: DockTab tab: DockTab
} }
interface GottenPods {
pods?: Pod[];
pod: Pod | undefined;
}
@observer @observer
export class Logs extends React.Component<Props> { 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 private logListElement = React.createRef<LogList>(); // A reference for VirtualList component
@ -51,40 +68,125 @@ export class Logs extends React.Component<Props> {
} }
componentDidMount() { componentDidMount() {
disposeOnUnmount(this, disposeOnUnmount(this, [
reaction(() => this.props.tab.id, this.reload, { fireImmediately: true }), 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; return this.props.tab.id;
} }
load = async () => { @computed get canSwap(): boolean {
this.isLoading = true; const data = this.tabData;
await logStore.load(this.tabId);
this.isLoading = false;
};
reload = async () => { if (!data) {
logStore.clearLogs(this.tabId); return false;
await this.load(); }
};
/** const { podsOwner } = data;
* A function for various actions after search is happened
* @param query {string} A text from search field if (!podsOwner) {
*/ return false;
@boundMethod }
onSearch() {
this.toOverlay(); 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) * Scrolling to active overlay (search word highlight)
*/ */
@boundMethod onSearch = () => {
toOverlay() {
const { activeOverlayLine } = searchStore; const { activeOverlayLine } = searchStore;
if (!this.logListElement.current || activeOverlayLine === undefined) return; if (!this.logListElement.current || activeOverlayLine === undefined) return;
@ -92,71 +194,86 @@ export class Logs extends React.Component<Props> {
this.logListElement.current.scrollToItem(activeOverlayLine, "center"); this.logListElement.current.scrollToItem(activeOverlayLine, "center");
// Scroll horizontally in timeout since virtual list need some time to prepare its contents // Scroll horizontally in timeout since virtual list need some time to prepare its contents
setTimeout(() => { setTimeout(() => {
const overlay = document.querySelector(".PodLogs .list span.active"); document.querySelector(".PodLogs .list span.active")?.scrollIntoViewIfNeeded();
if (!overlay) return;
overlay.scrollIntoViewIfNeeded();
}, 100); }, 100);
} };
render() {
const data = this.tabData;
renderResourceSelector(data?: LogTabData) {
if (!data) { if (!data) {
return null; return null;
} }
const logs = logStore.logs; if (!podsStore.isLoaded) {
const searchLogs = data.showTimestamps ? logs : logStore.logsWithoutTimestamps; return <Spinner center />;
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>
);
return ( const logs = logStore.getLogs(this.tabId, data);
<InfoPanel const { podsOwner, selectedContainer, selectedPod, showTimestamps, previous, namespace } = data;
tabId={this.props.tab.id} const { pods, pod } = this.getPods(data);
controls={controls}
showSubmitClose={false}
showButtons={false}
showStatusPanel={false}
/>
);
}
render() { if (!pod) {
const logs = logStore.logs; return (
const data = logTabStore.getData(this.tabId); <div className="PodLogs flex column">
<InfoPanel
if (!data) { tabId={this.props.tab.id}
this.reload(); 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>
);
} }
return ( return (
<div className="PodLogs flex column"> <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 <LogList
logs={logs} logs={logs}
id={this.tabId} selectedContainer={selectedContainer}
isLoading={this.isLoading} isLoading={this.isLoadingInitial}
load={this.load} load={this.load}
ref={this.logListElement} ref={this.logListElement}
/> />
<LogControls <LogControls
tabId={this.tabId}
pod={pod}
preferences={{ previous, showTimestamps }}
logs={logs} logs={logs}
tabData={data}
save={newData => logTabStore.setData(this.tabId, { ...data, ...newData })}
reload={this.reload}
/> />
</div> </div>
); );

View File

@ -27,6 +27,7 @@ import fse from "fs-extra";
import { StorageHelper } from "./storageHelper"; import { StorageHelper } from "./storageHelper";
import logger from "../../main/logger"; import logger from "../../main/logger";
import { isTestEnv } from "../../common/vars"; import { isTestEnv } from "../../common/vars";
import AwaitLock from "await-lock";
const storage = observable({ const storage = observable({
initialized: false, initialized: false,
@ -50,8 +51,11 @@ export function createStorage<T>(key: string, defaultValue: T) {
try { try {
storage.data = await fse.readJson(filePath); storage.data = await fse.readJson(filePath);
} catch { } catch (error) {
// ignore error // ignore file not found errors for logging
if (error?.code !== "ENOENT") {
logger.warn(`${logPrefix} failed to read JSON from ${filePath}`, error);
}
} finally { } finally {
if (!isTestEnv) { if (!isTestEnv) {
logger.info(`${logPrefix} loading finished for ${filePath}`); logger.info(`${logPrefix} loading finished for ${filePath}`);
@ -60,22 +64,29 @@ export function createStorage<T>(key: string, defaultValue: T) {
storage.loaded = true; storage.loaded = true;
} }
const lock = new AwaitLock();
// bind auto-saving data changes to %storage-file.json // bind auto-saving data changes to %storage-file.json
reaction(() => toJS(storage.data), saveFile, { 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 equals: comparer.structural, // save only when something really changed
}); });
async function saveFile(state: Record<string, any> = {}) { async function saveFile(state: Record<string, any> = {}) {
logger.info(`${logPrefix} saving ${filePath}`);
try { try {
await lock.acquireAsync();
logger.info(`${logPrefix} saving ${filePath}`);
await fse.ensureDir(path.dirname(filePath), { mode: 0o755 }); await fse.ensureDir(path.dirname(filePath), { mode: 0o755 });
await fse.writeJson(filePath, state, { spaces: 2 }); await fse.writeJson(filePath, state, { spaces: 2 });
} catch (error) { } catch (error) {
logger.error(`${logPrefix} saving failed: ${error}`, { const meta = {
json: state, jsonFilePath: filePath, json: state,
}); jsonFilePath: filePath,
};
logger.error(`${logPrefix} saving failed: ${error}`, meta);
} finally {
lock.release();
} }
} }
})() })()