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
},
"@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": {

View File

@ -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": {

View File

@ -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": {

View File

@ -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({

View File

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

View File

@ -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,
];
}

View File

@ -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 {

View File

@ -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,
})),
);

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 "./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";

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) {
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 {

View File

@ -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";
/**

View File

@ -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);

View File

@ -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();
});
});

View File

@ -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",

View File

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

View File

@ -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(() => {

View File

@ -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"
/>

View File

@ -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}

View File

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

View File

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

View File

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

View File

@ -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];
}
}

View File

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

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.
*/
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>
);

View File

@ -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();
}
}
})()