From 01676ee329ded49b98a36d60a023bb8bcf3ec267 Mon Sep 17 00:00:00 2001 From: krishtimil Date: Sun, 16 Aug 2026 05:09:16 +0545 Subject: [PATCH] Add combined logs for multi-pod workloads Adds a "Logs" action to the workload context menu for Deployments, DaemonSets, StatefulSets, ReplicaSets, and Jobs that opens a single dock tab merging the logs of every child pod, chronologically interleaved and tagged with a per-pod color so lines from different pods are distinguishable at a glance. Single-pod tabs are unaffected. Coloring covers the full log line (tag and message), not just the pod-name tag, so it stays legible whether or not "Show timestamps" is on. Co-Authored-By: Claude Sonnet 5 --- .../__tests__/kube-object.store.test.ts | 73 +++++++++++++ .../src/common/k8s-api/kube-object.store.ts | 14 +++ ...reate-workload-logs-tab.injectable.test.ts | 94 ++++++++++++++++ .../__test__/log-resource-selector.test.tsx | 51 ++++++++- .../dock/logs/__test__/merge-pod-logs.test.ts | 84 +++++++++++++++ .../dock/logs/__test__/test-utils.ts | 1 + .../components/dock/logs/controls.tsx | 3 +- .../create-workload-logs-tab.injectable.ts | 25 +++-- .../download-all-logs-for-pods.injectable.ts | 64 +++++++++++ .../dock/logs/load-logs.injectable.ts | 8 +- .../dock/logs/log-tab-data.validator.ts | 1 + .../dock/logs/logs-view-model.injectable.ts | 2 + .../components/dock/logs/logs-view-model.ts | 70 +++++++++--- .../components/dock/logs/merge-pod-logs.ts | 96 +++++++++++++++++ .../dock/logs/register-injectables.ts | 6 ++ .../dock/logs/reload-logs.injectable.ts | 4 +- .../dock/logs/resource-selector.tsx | 48 ++++++--- .../renderer/components/dock/logs/store.ts | 100 ++++++++++++------ .../components/dock/logs/tab-store.ts | 8 ++ .../workloads-daemonsets/daemonset-menu.tsx | 8 ++ .../workloads-deployments/deployment-menu.tsx | 13 +++ .../components/workloads-jobs/job-menu.tsx | 8 ++ .../replica-set-menu.tsx | 8 ++ .../statefulset-menu.tsx | 8 ++ 24 files changed, 717 insertions(+), 80 deletions(-) create mode 100644 packages/core/src/renderer/components/dock/logs/__test__/create-workload-logs-tab.injectable.test.ts create mode 100644 packages/core/src/renderer/components/dock/logs/__test__/merge-pod-logs.test.ts create mode 100644 packages/core/src/renderer/components/dock/logs/download-all-logs-for-pods.injectable.ts create mode 100644 packages/core/src/renderer/components/dock/logs/merge-pod-logs.ts diff --git a/packages/core/src/common/k8s-api/__tests__/kube-object.store.test.ts b/packages/core/src/common/k8s-api/__tests__/kube-object.store.test.ts index add3fa4b97..f3b8afe033 100644 --- a/packages/core/src/common/k8s-api/__tests__/kube-object.store.test.ts +++ b/packages/core/src/common/k8s-api/__tests__/kube-object.store.test.ts @@ -44,6 +44,33 @@ class FakeKubeObjectStore extends KubeObjectStore { } } +// Unlike FakeKubeObjectStore above, this exercises the real `loadItems()` -- +// the method that owns the `onLoadFailure` branches -- against a fake +// `api.list()`, instead of bypassing it entirely. +class RealLoadItemsKubeObjectStore extends KubeObjectStore { + constructor(api: Partial>, isLoadingAll: (namespaces: string[]) => boolean = () => true) { + super( + { + context: { + allNamespaces: [], + contextNamespaces: [], + hasSelectedAll: false, + isGlobalWatchEnabled: () => true, + isLoadingAll, + }, + logger: { + debug: noop, + error: noop, + info: noop, + silly: noop, + warn: noop, + }, + }, + api as KubeApi, + ); + } +} + describe("KubeObjectStore", () => { it("should remove an object from the list of items after it is not returned from listing the same namespace again", async () => { const loadItems = vi.fn(); @@ -282,4 +309,50 @@ describe("KubeObjectStore", () => { warnSpy.mockRestore(); }); + + it("does not report an aborted cluster-scoped/all-namespaces load through onLoadFailure", async () => { + const onLoadFailure = vi.fn(); + const list = vi.fn().mockRejectedValueOnce(new DOMException("The operation was aborted.", "AbortError")); + const store = new RealLoadItemsKubeObjectStore({ isNamespaced: false, list }); + + const result = await store.loadAll({ onLoadFailure }); + + expect(result).toBeUndefined(); + expect(onLoadFailure).not.toHaveBeenCalled(); + expect(store.failedLoading).toBe(false); + }); + + it("still reports a genuine cluster-scoped/all-namespaces load failure through onLoadFailure", async () => { + const onLoadFailure = vi.fn(); + const list = vi.fn().mockRejectedValueOnce(new Error("boom")); + const store = new RealLoadItemsKubeObjectStore({ isNamespaced: false, list }); + + await store.loadAll({ onLoadFailure }); + + expect(onLoadFailure).toHaveBeenCalledTimes(1); + expect(onLoadFailure.mock.calls[0][0].message).toContain("Failed to load"); + }); + + it("does not report an aborted namespaced load through onLoadFailure", async () => { + const onLoadFailure = vi.fn(); + const list = vi.fn().mockRejectedValueOnce(new DOMException("The operation was aborted.", "AbortError")); + const store = new RealLoadItemsKubeObjectStore({ isNamespaced: true, list }, () => false); + + const result = await store.loadAll({ namespaces: ["some-namespace"], onLoadFailure }); + + expect(result).toBeUndefined(); + expect(onLoadFailure).not.toHaveBeenCalled(); + expect(store.failedLoading).toBe(false); + }); + + it("still reports a genuine namespaced load failure through onLoadFailure", async () => { + const onLoadFailure = vi.fn(); + const list = vi.fn().mockRejectedValueOnce(new Error("boom")); + const store = new RealLoadItemsKubeObjectStore({ isNamespaced: true, list }, () => false); + + await store.loadAll({ namespaces: ["some-namespace"], onLoadFailure }); + + expect(onLoadFailure).toHaveBeenCalledTimes(1); + expect(onLoadFailure.mock.calls[0][0].message).toContain("Failed to load"); + }); }); diff --git a/packages/core/src/common/k8s-api/kube-object.store.ts b/packages/core/src/common/k8s-api/kube-object.store.ts index 988e62fae4..a72b479b4a 100644 --- a/packages/core/src/common/k8s-api/kube-object.store.ts +++ b/packages/core/src/common/k8s-api/kube-object.store.ts @@ -213,6 +213,14 @@ export class KubeObjectStore< try { return (await res) ?? []; } catch (error) { + // An aborted request (e.g. the view unmounted before the list + // arrived) is not a real failure -- let it propagate so the + // abort-aware catch in `loadAll` can no-op on it instead of + // surfacing a spurious "Failed to load" error and blanking the list. + if (isAbortError(error)) { + throw error; + } + onLoadFailure(new Error(`Failed to load ${this.api.apiBase}`, { cause: error })); // reset the store because we are loading all, so that nothing is displayed @@ -240,6 +248,12 @@ export class KubeObjectStore< break; case "rejected": + // See the single-namespace branch above: an aborted request must + // propagate, not be reported through onLoadFailure. + if (isAbortError(result.reason)) { + throw result.reason; + } + if (onLoadFailure) { onLoadFailure(new Error(`Failed to load ${this.api.apiBase}`, { cause: result.reason })); } else { diff --git a/packages/core/src/renderer/components/dock/logs/__test__/create-workload-logs-tab.injectable.test.ts b/packages/core/src/renderer/components/dock/logs/__test__/create-workload-logs-tab.injectable.test.ts new file mode 100644 index 0000000000..1d31e24218 --- /dev/null +++ b/packages/core/src/renderer/components/dock/logs/__test__/create-workload-logs-tab.injectable.test.ts @@ -0,0 +1,94 @@ +/** + * Copyright (c) Freelens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getDiForUnitTesting } from "../../../../getDiForUnitTesting"; +import getPodsByOwnerIdInjectable from "../../../workloads-pods/get-pods-by-owner-id.injectable"; +import createWorkloadLogsTabInjectable from "../create-workload-logs-tab.injectable"; +import getLogTabDataInjectable from "../get-log-tab-data.injectable"; +import getRandomIdForPodLogsTabInjectable from "../get-random-id-for-pod-logs-tab.injectable"; +import { deploymentPod1, deploymentPod2, deploymentPod3 } from "./pod.mock"; + +import type { KubeObject } from "@freelensapp/kube-object"; + +import type { DiContainer } from "@ogre-tools/injectable"; + +function fakeWorkload(kind: string, name: string, uid: string): KubeObject { + return { + kind, + getName: () => name, + getId: () => uid, + } as unknown as KubeObject; +} + +describe("create workload logs tab", () => { + let di: DiContainer; + + beforeEach(() => { + di = getDiForUnitTesting(); + di.override(getRandomIdForPodLogsTabInjectable, () => () => "test-id"); + // getPodsByOwnerIdInjectable pulls in podStoreInjectable, which asserts it + // is only created in a cluster-frame environment. It is only actually + // called by the fallback (no explicit `pods`) path, but injectable() + // resolves the whole dependency graph eagerly, so every test needs a + // usable stand-in even when it never exercises that path. + di.override(getPodsByOwnerIdInjectable, () => () => []); + }); + + it("returns undefined when the workload has no pods", () => { + const createWorkloadLogsTab = di.inject(createWorkloadLogsTabInjectable); + const workload = fakeWorkload("StatefulSet", "empty-set", "uid-1"); + + expect(createWorkloadLogsTab({ workload })).toBeUndefined(); + }); + + it("combines every pod passed in explicitly, regardless of how deep the ownership chain is", () => { + const createWorkloadLogsTab = di.inject(createWorkloadLogsTabInjectable); + const getLogTabData = di.inject(getLogTabDataInjectable); + // Deployment pods are owned by an intermediate ReplicaSet, not the + // Deployment itself, so this only works with explicitly-passed pods. + const workload = fakeWorkload("Deployment", "super-deployment", "uuid"); + + const tabId = createWorkloadLogsTab({ + workload, + pods: [deploymentPod1, deploymentPod2, deploymentPod3], + }); + + expect(tabId).toBeDefined(); + expect(getLogTabData(tabId!)).toMatchObject({ + selectedPodId: deploymentPod1.getId(), + mergedPodIds: [deploymentPod2.getId(), deploymentPod3.getId()], + owner: { kind: "Deployment", name: "super-deployment", uid: "uuid" }, + }); + }); + + it("falls back to direct ownerReferences lookup when no pods are passed explicitly", () => { + di.override(getPodsByOwnerIdInjectable, () => (id) => (id === "uuid" ? [deploymentPod1, deploymentPod2] : [])); + + const createWorkloadLogsTab = di.inject(createWorkloadLogsTabInjectable); + const getLogTabData = di.inject(getLogTabDataInjectable); + const workload = fakeWorkload("ReplicaSet", "super-replicaset", "uuid"); + + const tabId = createWorkloadLogsTab({ workload }); + + expect(tabId).toBeDefined(); + expect(getLogTabData(tabId!)).toMatchObject({ + selectedPodId: deploymentPod1.getId(), + mergedPodIds: [deploymentPod2.getId()], + }); + }); + + it("does not set mergedPodIds for a single-pod workload", () => { + const createWorkloadLogsTab = di.inject(createWorkloadLogsTabInjectable); + const getLogTabData = di.inject(getLogTabDataInjectable); + const workload = fakeWorkload("Deployment", "solo-deployment", "uuid"); + + const tabId = createWorkloadLogsTab({ workload, pods: [deploymentPod1] }); + + expect(getLogTabData(tabId!)).toMatchObject({ + selectedPodId: deploymentPod1.getId(), + mergedPodIds: undefined, + }); + }); +}); diff --git a/packages/core/src/renderer/components/dock/logs/__test__/log-resource-selector.test.tsx b/packages/core/src/renderer/components/dock/logs/__test__/log-resource-selector.test.tsx index 2f9d9ba024..6ddd316cc5 100644 --- a/packages/core/src/renderer/components/dock/logs/__test__/log-resource-selector.test.tsx +++ b/packages/core/src/renderer/components/dock/logs/__test__/log-resource-selector.test.tsx @@ -17,7 +17,7 @@ import { renderFor } from "../../../test-utils/renderFor"; import callForLogsInjectable from "../call-for-logs.injectable"; import { LogTabViewModel } from "../logs-view-model"; import { LogResourceSelector } from "../resource-selector"; -import { deploymentPod1, deploymentPod2, dockerPod } from "./pod.mock"; +import { deploymentPod1, deploymentPod2, deploymentPod3, dockerPod } from "./pod.mock"; import { createMockLogTabViewModel, getDefaultOnePodLogTabData, @@ -94,6 +94,33 @@ const getFewPodsTabData = ( }); }; +const getCombinedLogsTabData = ( + tabId: TabId, + userPreferencesState: UserPreferencesState, + deps: Partial = {}, +): LogTabViewModel => { + const selectedPod = deploymentPod1; + const otherPods = [deploymentPod2, deploymentPod3]; + + return createMockLogTabViewModel(tabId, userPreferencesState, { + getLogTabData: () => ({ + ...getDefaultOnePodLogTabData({ + selectedPodId: selectedPod.getId(), + selectedContainer: selectedPod.getContainers()[0].name, + namespace: selectedPod.getNs(), + }), + mergedPodIds: otherPods.map((pod) => pod.getId()), + owner: { + uid: "uuid", + kind: "Deployment", + name: "super-deployment", + }, + }), + getPodById: (id) => [selectedPod, ...otherPods].find((pod) => pod.getId() === id), + ...deps, + }); +}; + describe("", () => { let render: DiRender; let user: UserEvent; @@ -202,4 +229,26 @@ describe("", () => { expect(renameTab).toBeCalledWith("foobar", "Pod deploymentPod2"); }); }); + + describe("with combined logs of several pods", () => { + let model: LogTabViewModel; + + beforeEach(() => { + model = getCombinedLogsTabData("foobar", userPreferencesState); + }); + + it("shows a pod count badge instead of the pod switcher dropdown", async () => { + const { container, findByTestId } = render(); + + expect(await findByTestId("merged-pods-badge")).toHaveTextContent("3 pods"); + expect(container.querySelector(".pod-selector")).not.toBeInTheDocument(); + }); + + it("still renders the owner and container selector", async () => { + const { findByText, container } = render(); + + expect(await findByText("super-deployment", { exact: false })).toBeInTheDocument(); + expect(container.querySelector(".container-selector")).toBeInTheDocument(); + }); + }); }); diff --git a/packages/core/src/renderer/components/dock/logs/__test__/merge-pod-logs.test.ts b/packages/core/src/renderer/components/dock/logs/__test__/merge-pod-logs.test.ts new file mode 100644 index 0000000000..f1ce6514d0 --- /dev/null +++ b/packages/core/src/renderer/components/dock/logs/__test__/merge-pod-logs.test.ts @@ -0,0 +1,84 @@ +/** + * Copyright (c) Freelens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getLeadingTimestamp, getPodLogColor, mergePodLogs } from "../merge-pod-logs"; + +describe("mergePodLogs", () => { + it("returns the lines unchanged when there is a single source pod", () => { + const lines = ["2024-01-01T00:00:00.000000000Z hello", "2024-01-01T00:00:01.000000000Z world"]; + + expect(mergePodLogs(new Map([["pod-a", lines]]))).toEqual(lines); + }); + + it("returns an empty array when given no pods", () => { + expect(mergePodLogs(new Map())).toEqual([]); + }); + + it("interleaves lines from several pods in chronological order", () => { + const merged = mergePodLogs( + new Map([ + ["pod-a", ["2024-01-01T00:00:00.000000000Z a1", "2024-01-01T00:00:02.000000000Z a2"]], + ["pod-b", ["2024-01-01T00:00:01.000000000Z b1"]], + ]), + ); + + expect(merged.map((line) => getLeadingTimestamp(line))).toEqual([ + "2024-01-01T00:00:00.000000000Z", + "2024-01-01T00:00:01.000000000Z", + "2024-01-01T00:00:02.000000000Z", + ]); + expect(merged[0]).toContain("[pod-a]"); + expect(merged[0]).toContain("a1"); + expect(merged[1]).toContain("[pod-b]"); + expect(merged[1]).toContain("b1"); + expect(merged[2]).toContain("[pod-a]"); + expect(merged[2]).toContain("a2"); + }); + + it("skips pods with no lines", () => { + const merged = mergePodLogs( + new Map([ + ["pod-a", ["2024-01-01T00:00:00.000000000Z a1"]], + ["pod-b", []], + ]), + ); + + expect(merged).toHaveLength(1); + expect(merged[0]).toContain("[pod-a]"); + }); + + it("tags a line without a leading timestamp using only the pod name", () => { + const merged = mergePodLogs( + new Map([ + ["pod-a", ["not-a-timestamp continuation line"]], + ["pod-b", ["2024-01-01T00:00:00.000000000Z b1"]], + ]), + ); + + const taggedLine = merged.find((line) => line.includes("continuation line")); + + expect(taggedLine).toContain("[pod-a]"); + expect(taggedLine).toContain("not-a-timestamp continuation line"); + }); + + it("assigns the same color to a pod name across calls", () => { + expect(getPodLogColor("pod-a")).toBe(getPodLogColor("pod-a")); + }); + + it("colors the whole line, not just the pod-name tag", () => { + const merged = mergePodLogs( + new Map([ + ["pod-a", ["2024-01-01T00:00:00.000000000Z hello world"]], + ["pod-b", []], + ]), + ); + const color = getPodLogColor("pod-a"); + + // The color escape must stay open across the tag and the log content, and + // only reset (\x1b[0m) once, at the very end of the line -- not right + // after the tag, which would leave the log content in the default color. + expect(merged[0]).toBe(`2024-01-01T00:00:00.000000000Z \x1b[${color}m[pod-a] hello world\x1b[0m`); + }); +}); diff --git a/packages/core/src/renderer/components/dock/logs/__test__/test-utils.ts b/packages/core/src/renderer/components/dock/logs/__test__/test-utils.ts index 8c672e6d17..b75d311cd8 100644 --- a/packages/core/src/renderer/components/dock/logs/__test__/test-utils.ts +++ b/packages/core/src/renderer/components/dock/logs/__test__/test-utils.ts @@ -49,6 +49,7 @@ export function createMockLogTabViewModel( searchStore: new SearchStore(), downloadLogs: vi.fn(), downloadAllLogs: vi.fn(), + downloadAllLogsForPods: vi.fn(), userPreferencesState, ...deps, }); diff --git a/packages/core/src/renderer/components/dock/logs/controls.tsx b/packages/core/src/renderer/components/dock/logs/controls.tsx index fbeb5681d7..7b72c97108 100644 --- a/packages/core/src/renderer/components/dock/logs/controls.tsx +++ b/packages/core/src/renderer/components/dock/logs/controls.tsx @@ -17,9 +17,8 @@ export interface LogControlsProps { export const LogControls = observer(({ model }: LogControlsProps) => { const tabData = model.logTabData.get(); - const pod = model.pod.get(); - if (!tabData || !pod) { + if (!tabData) { return null; } diff --git a/packages/core/src/renderer/components/dock/logs/create-workload-logs-tab.injectable.ts b/packages/core/src/renderer/components/dock/logs/create-workload-logs-tab.injectable.ts index 6d01082653..e7dd00d7e5 100644 --- a/packages/core/src/renderer/components/dock/logs/create-workload-logs-tab.injectable.ts +++ b/packages/core/src/renderer/components/dock/logs/create-workload-logs-tab.injectable.ts @@ -9,14 +9,24 @@ import getPodsByOwnerIdInjectable from "../../workloads-pods/get-pods-by-owner-i import createLogsTabInjectable from "./create-logs-tab.injectable"; import { findOptimalDefaultContainerOfPod } from "./default-container-helper"; -import type { DaemonSet, Deployment, Job, ReplicaSet, StatefulSet } from "@freelensapp/kube-object"; +import type { KubeObject, Pod } from "@freelensapp/kube-object"; import type { GetPodsByOwnerId } from "../../workloads-pods/get-pods-by-owner-id.injectable"; import type { TabId } from "../dock/store"; import type { CreateLogsTabData } from "./create-logs-tab.injectable"; export interface WorkloadLogsTabData { - workload: StatefulSet | Job | Deployment | DaemonSet | ReplicaSet; + workload: KubeObject; + /** + * The pods to show combined logs for. When omitted, falls back to looking + * up pods whose `ownerReferences` point directly at `workload` -- which + * only finds anything for workload kinds that own pods directly (e.g. + * ReplicaSet, DaemonSet, StatefulSet, Job). A Deployment's pods are owned + * by its ReplicaSet(s), not the Deployment itself, so callers opening + * combined logs for a Deployment (or any other multi-hop owner) must pass + * the already-resolved `pods` explicitly. + */ + pods?: Pod[]; } interface Dependencies { @@ -26,18 +36,19 @@ interface Dependencies { const createWorkloadLogsTab = ({ createLogsTab, getPodsByOwnerId }: Dependencies) => - ({ workload }: WorkloadLogsTabData): TabId | undefined => { - const pods = getPodsByOwnerId(workload.getId()); + ({ workload, pods }: WorkloadLogsTabData): TabId | undefined => { + const resolvedPods = pods ?? getPodsByOwnerId(workload.getId()); - if (pods.length === 0) { + if (resolvedPods.length === 0) { return undefined; } - const selectedPod = pods[0]; + const [selectedPod, ...restOfPods] = resolvedPods; - return createLogsTab(`${workload.kind} ${selectedPod.getName()}`, { + return createLogsTab(`${workload.kind} ${workload.getName()}`, { selectedContainer: findOptimalDefaultContainerOfPod(selectedPod).name, selectedPodId: selectedPod.getId(), + mergedPodIds: restOfPods.length ? restOfPods.map((pod) => pod.getId()) : undefined, namespace: selectedPod.getNs(), owner: { kind: workload.kind, diff --git a/packages/core/src/renderer/components/dock/logs/download-all-logs-for-pods.injectable.ts b/packages/core/src/renderer/components/dock/logs/download-all-logs-for-pods.injectable.ts new file mode 100644 index 0000000000..dce7e67c0f --- /dev/null +++ b/packages/core/src/renderer/components/dock/logs/download-all-logs-for-pods.injectable.ts @@ -0,0 +1,64 @@ +/** + * Copyright (c) Freelens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { loggerInjectionToken } from "@freelensapp/logger"; +import { showErrorNotificationInjectable } from "@freelensapp/notifications"; +import { getInjectable } from "@ogre-tools/injectable"; +import openSaveFileDialogInjectable from "../../../utils/save-file.injectable"; +import callForLogsInjectable from "./call-for-logs.injectable"; +import { mergePodLogs } from "./merge-pod-logs"; + +import type { PodLogsQuery } from "@freelensapp/kube-object"; + +export interface PodLogsDescriptor { + name: string; + namespace: string; +} + +export type DownloadAllLogsForPods = ( + filename: string, + pods: readonly PodLogsDescriptor[], + query: PodLogsQuery, +) => Promise; + +const downloadAllLogsForPodsInjectable = getInjectable({ + id: "download-all-logs-for-pods", + + instantiate: (di): DownloadAllLogsForPods => { + const callForLogs = di.inject(callForLogsInjectable); + const openSaveFileDialog = di.inject(openSaveFileDialogInjectable); + const logger = di.inject(loggerInjectionToken); + const showErrorNotification = di.inject(showErrorNotificationInjectable); + + return async (filename, pods, query) => { + const results = await Promise.allSettled( + pods.map(async (pod) => ({ + podName: pod.name, + lines: (await callForLogs(pod, query)).trimEnd().replace(/\r/g, "\n").split("\n").filter(Boolean), + })), + ); + + const linesByPod = new Map(); + + for (const result of results) { + if (result.status === "fulfilled") { + linesByPod.set(result.value.podName, result.value.lines); + } else { + logger.error("Can't download logs: ", result.reason); + } + } + + const logs = mergePodLogs(linesByPod).join("\n"); + + if (logs) { + openSaveFileDialog(`${filename}.log`, logs, "text/plain"); + } else { + showErrorNotification("No logs to download"); + } + }; + }, +}); + +export default downloadAllLogsForPodsInjectable; diff --git a/packages/core/src/renderer/components/dock/logs/load-logs.injectable.ts b/packages/core/src/renderer/components/dock/logs/load-logs.injectable.ts index 873a6ddf8d..cc266b3532 100644 --- a/packages/core/src/renderer/components/dock/logs/load-logs.injectable.ts +++ b/packages/core/src/renderer/components/dock/logs/load-logs.injectable.ts @@ -14,11 +14,7 @@ import type { IComputedValue } from "mobx"; import type { LogTabData } from "./tab-store"; export interface LoadLogs { - ( - tabId: string, - pod: IComputedValue, - logTabData: IComputedValue, - ): Promise; + (tabId: string, pods: IComputedValue, logTabData: IComputedValue): Promise; } const loadLogsInjectable = getInjectable({ @@ -27,7 +23,7 @@ const loadLogsInjectable = getInjectable({ instantiate: (di): LoadLogs => { const logStore = di.inject(logStoreInjectable); - return (tabId, pod, logTabData) => logStore.load(tabId, pod, logTabData); + return (tabId, pods, logTabData) => logStore.load(tabId, pods, logTabData); }, }); diff --git a/packages/core/src/renderer/components/dock/logs/log-tab-data.validator.ts b/packages/core/src/renderer/components/dock/logs/log-tab-data.validator.ts index 1acd808890..0c9ada0bdb 100644 --- a/packages/core/src/renderer/components/dock/logs/log-tab-data.validator.ts +++ b/packages/core/src/renderer/components/dock/logs/log-tab-data.validator.ts @@ -17,6 +17,7 @@ export const logTabDataValidator = Joi.object({ .unknown(true) .optional(), selectedPodId: Joi.string().required(), + mergedPodIds: Joi.array().items(Joi.string()).optional(), namespace: Joi.string().required(), selectedContainer: Joi.string().optional(), showTimestamps: Joi.boolean().required(), diff --git a/packages/core/src/renderer/components/dock/logs/logs-view-model.injectable.ts b/packages/core/src/renderer/components/dock/logs/logs-view-model.injectable.ts index e969a215ec..728b1180b2 100644 --- a/packages/core/src/renderer/components/dock/logs/logs-view-model.injectable.ts +++ b/packages/core/src/renderer/components/dock/logs/logs-view-model.injectable.ts @@ -12,6 +12,7 @@ import getPodsByOwnerIdInjectable from "../../workloads-pods/get-pods-by-owner-i import renameTabInjectable from "../dock/rename-tab.injectable"; import areLogsPresentInjectable from "./are-logs-present.injectable"; import downloadAllLogsInjectable from "./download-all-logs.injectable"; +import downloadAllLogsForPodsInjectable from "./download-all-logs-for-pods.injectable"; import downloadLogsInjectable from "./download-logs.injectable"; import getLogTabDataInjectable from "./get-log-tab-data.injectable"; import getLogsInjectable from "./get-logs.injectable"; @@ -48,6 +49,7 @@ const logsViewModelInjectable = getInjectable({ getPodsByOwnerId: di.inject(getPodsByOwnerIdInjectable), downloadLogs: di.inject(downloadLogsInjectable), downloadAllLogs: di.inject(downloadAllLogsInjectable), + downloadAllLogsForPods: di.inject(downloadAllLogsForPodsInjectable), searchStore: di.inject(searchStoreInjectable), userPreferencesState: di.inject(userPreferencesStateInjectable), }), diff --git a/packages/core/src/renderer/components/dock/logs/logs-view-model.ts b/packages/core/src/renderer/components/dock/logs/logs-view-model.ts index 558241669a..dba2ef2b84 100644 --- a/packages/core/src/renderer/components/dock/logs/logs-view-model.ts +++ b/packages/core/src/renderer/components/dock/logs/logs-view-model.ts @@ -32,7 +32,7 @@ export interface LogTabViewModelDependencies { loadLogs: LoadLogs; reloadLogs: ( tabId: TabId, - pod: IComputedValue, + pods: IComputedValue, logTabData: IComputedValue, ) => Promise; renameTab: (tabId: TabId, title: string) => void; @@ -42,6 +42,11 @@ export interface LogTabViewModelDependencies { areLogsPresent: (tabId: TabId) => boolean; downloadLogs: (filename: string, logs: string[]) => void; downloadAllLogs: (params: ResourceDescriptor, query: PodLogsQuery) => Promise; + downloadAllLogsForPods: ( + filename: string, + pods: readonly { name: string; namespace: string }[], + query: PodLogsQuery, + ) => Promise; searchStore: SearchStore; userPreferencesState: UserPreferencesState; } @@ -84,6 +89,29 @@ export class LogTabViewModel { return this.dependencies.getPodById(data.selectedPodId); }); + /** + * True when this tab combines the logs of more than one pod (a "combined + * logs" tab opened for a workload) rather than showing a single pod. + */ + readonly isMerged = computed(() => (this.logTabData.get()?.mergedPodIds?.length ?? 0) > 0); + + /** + * The pods whose logs are fetched and merged into this tab's log stream: + * just the selected pod normally, or the selected pod plus every pod listed + * in `mergedPodIds` for a combined logs tab. + */ + readonly logSourcePods = computed(() => { + const data = this.logTabData.get(); + + if (!data) { + return []; + } + + const podIds = [data.selectedPodId, ...(data.mergedPodIds ?? [])]; + + return podIds.map((id) => this.dependencies.getPodById(id)).filter(isDefined); + }); + updateLogTabData = (partialData: Partial) => { const data = this.logTabData.get(); @@ -105,17 +133,19 @@ export class LogTabViewModel { this.updateLogTabData(partialPreferences); }; - loadLogs = () => this.dependencies.loadLogs(this.tabId, this.pod, this.logTabData); - reloadLogs = () => this.dependencies.reloadLogs(this.tabId, this.pod, this.logTabData); + loadLogs = () => this.dependencies.loadLogs(this.tabId, this.logSourcePods, this.logTabData); + reloadLogs = () => this.dependencies.reloadLogs(this.tabId, this.logSourcePods, this.logTabData); renameTab = (title: string) => this.dependencies.renameTab(this.tabId, title); stopLoadingLogs = () => this.dependencies.stopLoadingLogs(this.tabId); downloadLogs = () => { - const pod = this.pod.get(); const tabData = this.logTabData.get(); + const pods = this.logSourcePods.get(); - if (pod && tabData) { - const fileName = pod.getName(); + if (pods.length && tabData) { + // A combined logs tab is named after the workload it was opened for, not + // any single one of its pods. + const fileName = this.isMerged.get() && tabData.owner ? tabData.owner.name : pods[0].getName(); const logsToDownload: string[] = tabData.showTimestamps ? this.logs.get() : this.logsWithoutTimestamps.get(); this.dependencies.downloadLogs(`${fileName}.log`, logsToDownload); @@ -123,20 +153,28 @@ export class LogTabViewModel { }; downloadAllLogs = () => { - const pod = this.pod.get(); const tabData = this.logTabData.get(); + const pods = this.logSourcePods.get(); - if (pod && tabData) { - const params = { name: pod.getName(), namespace: pod.getNs() }; - const query = { - timestamps: tabData.showTimestamps, - previous: tabData.showPrevious, - container: tabData.selectedContainer, - }; + if (!pods.length || !tabData) { + return; + } - return this.dependencies.downloadAllLogs(params, query); + const query = { + timestamps: tabData.showTimestamps, + previous: tabData.showPrevious, + container: tabData.selectedContainer, + }; + + if (this.isMerged.get()) { + const fileName = tabData.owner?.name ?? pods[0].getName(); + const podDescriptors = pods.map((pod) => ({ name: pod.getName(), namespace: pod.getNs() })); + + return this.dependencies.downloadAllLogsForPods(fileName, podDescriptors, query); } - return; + const params = { name: pods[0].getName(), namespace: pods[0].getNs() }; + + return this.dependencies.downloadAllLogs(params, query); }; } diff --git a/packages/core/src/renderer/components/dock/logs/merge-pod-logs.ts b/packages/core/src/renderer/components/dock/logs/merge-pod-logs.ts new file mode 100644 index 0000000000..37db71f4f7 --- /dev/null +++ b/packages/core/src/renderer/components/dock/logs/merge-pod-logs.ts @@ -0,0 +1,96 @@ +/** + * Copyright (c) Freelens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +// ANSI 256-color codes used to tag each pod's lines in a combined logs view. +// Chosen to stay legible on both light and dark terminal-style backgrounds and +// to avoid red/white, which are already used for error/plain text. +const podColorPalette = ["36", "33", "35", "32", "34", "96", "93", "95", "92", "94"]; + +function hashString(value: string): number { + let hash = 0; + + for (let i = 0; i < value.length; i += 1) { + hash = (hash * 31 + value.charCodeAt(i)) | 0; + } + + return Math.abs(hash); +} + +/** + * Deterministically picks an ANSI color for a pod so the same pod always gets + * the same color across reloads/refreshes of the same combined logs tab. + */ +export function getPodLogColor(podName: string): string { + return podColorPalette[hashString(podName) % podColorPalette.length]; +} + +/** + * Extracts the leading RFC3339 timestamp token that the Kubernetes API prefixes + * every log line with when `timestamps: true` is requested. Returns undefined + * for lines that don't start with one (e.g. lines wrapped from a multi-line + * message, which the API does not re-stamp). + */ +export function getLeadingTimestamp(line: string): string | undefined { + return /^\d+\S+/.exec(line)?.[0]; +} + +function tagLine(line: string, podName: string): string { + const color = getPodLogColor(podName); + const timestamp = getLeadingTimestamp(line); + const tag = `[${color}m[${podName}]`; + + if (!timestamp) { + return `${tag} ${line}`; + } + + return `${timestamp} ${tag}${line.slice(timestamp.length)}`; +} + +/** + * Merges the per-pod log line arrays of a "combined logs" tab into a single + * chronologically ordered array. + * + * Each pod's own lines are already in chronological order (as returned by the + * Kubernetes API), so this performs a stable k-way merge across pods by + * comparing each line's leading timestamp lexicographically -- which is valid + * because Kubernetes always emits RFC3339Nano timestamps in a fixed-width, + * zero-padded, UTC ("Z") form. + * + * When there is a single source pod, its lines are returned unchanged -- no + * pod-name tag/color is added -- so single-pod tabs keep their existing, + * untagged output. + */ +export function mergePodLogs(linesByPodName: ReadonlyMap): string[] { + if (linesByPodName.size <= 1) { + return [...linesByPodName.values()][0]?.slice() ?? []; + } + + const cursors = [...linesByPodName.entries()] + .map(([podName, lines]) => ({ podName, lines, index: 0 })) + .filter((cursor) => cursor.lines.length > 0); + const merged: string[] = []; + + while (cursors.length > 0) { + let winner = cursors[0]; + + for (const cursor of cursors) { + const winnerTimestamp = getLeadingTimestamp(winner.lines[winner.index]) ?? ""; + const cursorTimestamp = getLeadingTimestamp(cursor.lines[cursor.index]) ?? ""; + + if (cursorTimestamp < winnerTimestamp) { + winner = cursor; + } + } + + merged.push(tagLine(winner.lines[winner.index], winner.podName)); + winner.index += 1; + + if (winner.index >= winner.lines.length) { + cursors.splice(cursors.indexOf(winner), 1); + } + } + + return merged; +} diff --git a/packages/core/src/renderer/components/dock/logs/register-injectables.ts b/packages/core/src/renderer/components/dock/logs/register-injectables.ts index 45e56da725..2e62ce3fab 100644 --- a/packages/core/src/renderer/components/dock/logs/register-injectables.ts +++ b/packages/core/src/renderer/components/dock/logs/register-injectables.ts @@ -13,6 +13,7 @@ import createLogsTabInjectable from "./create-logs-tab.injectable"; import createPodLogsTabInjectable from "./create-pod-logs-tab.injectable"; import createWorkloadLogsTabInjectable from "./create-workload-logs-tab.injectable"; import downloadAllLogsInjectable from "./download-all-logs.injectable"; +import downloadAllLogsForPodsInjectable from "./download-all-logs-for-pods.injectable"; import downloadLogsInjectable from "./download-logs.injectable"; import getLogTabDataInjectable from "./get-log-tab-data.injectable"; import getLogsInjectable from "./get-logs.injectable"; @@ -61,6 +62,11 @@ export function registerInjectables(di: DiContainerForInjection): void { } catch (e) { /* Ignore duplicate registration */ } + try { + di.register(downloadAllLogsForPodsInjectable); + } catch (e) { + /* Ignore duplicate registration */ + } try { di.register(downloadAllLogsInjectable); } catch (e) { diff --git a/packages/core/src/renderer/components/dock/logs/reload-logs.injectable.ts b/packages/core/src/renderer/components/dock/logs/reload-logs.injectable.ts index 22acb03457..77e5f6807a 100644 --- a/packages/core/src/renderer/components/dock/logs/reload-logs.injectable.ts +++ b/packages/core/src/renderer/components/dock/logs/reload-logs.injectable.ts @@ -21,9 +21,9 @@ const reloadLogsInjectable = getInjectable({ return ( tabId: string, - pod: IComputedValue, + pods: IComputedValue, logTabData: IComputedValue, - ): Promise => logStore.reload(tabId, pod, logTabData); + ): Promise => logStore.reload(tabId, pods, logTabData); }, }); diff --git a/packages/core/src/renderer/components/dock/logs/resource-selector.tsx b/packages/core/src/renderer/components/dock/logs/resource-selector.tsx index dbacfd1ca4..e9fec8fc1a 100644 --- a/packages/core/src/renderer/components/dock/logs/resource-selector.tsx +++ b/packages/core/src/renderer/components/dock/logs/resource-selector.tsx @@ -30,6 +30,7 @@ export const LogResourceSelector = observer(({ model }: LogResourceSelectorProps } const { selectedContainer, owner } = tabData; + const isMerged = model.isMerged.get(); const pods = model.pods.get(); const pod = model.pod.get(); @@ -37,10 +38,15 @@ export const LogResourceSelector = observer(({ model }: LogResourceSelectorProps return null; } - const podOptions = pods.map((pod) => ({ - value: pod, - label: pod.getName(), - })); + // Sibling pods (for the switcher dropdown) are only relevant -- and only + // looked up -- outside of merged mode, where a combined-logs tab's `pods` + // come from `logSourcePods` instead (rendered separately below). + const podOptions = isMerged + ? [] + : pods.map((pod) => ({ + value: pod, + label: pod.getName(), + })); const allContainers = pod.getAllContainers(); const container = allContainers.find((container) => container.name === selectedContainer) ?? null; const onContainerChange = (option: SingleValue>) => { @@ -92,15 +98,31 @@ export const LogResourceSelector = observer(({ model }: LogResourceSelectorProps Owner )} - Pod - + + )} Container , false> id="container-selector-input" diff --git a/packages/core/src/renderer/components/dock/logs/store.ts b/packages/core/src/renderer/components/dock/logs/store.ts index 3867c63ac3..f6639d2d1e 100644 --- a/packages/core/src/renderer/components/dock/logs/store.ts +++ b/packages/core/src/renderer/components/dock/logs/store.ts @@ -6,6 +6,7 @@ import { getOrInsertWith, interval, waitUntilDefined } from "@freelensapp/utilities"; import { observable } from "mobx"; +import { mergePodLogs } from "./merge-pod-logs"; import type { Pod, PodLogsQuery } from "@freelensapp/kube-object"; import type { IntervalFn } from "@freelensapp/utilities"; @@ -49,16 +50,16 @@ export class LogStore { */ public async load( tabId: TabId, - computedPod: IComputedValue, + computedPods: IComputedValue, logTabData: IComputedValue, ): Promise { try { - const logs = await this.loadLogs(computedPod, logTabData, { + const linesByPod = await this.loadLogs(computedPods, logTabData, { tailLines: this.getLogLines(tabId) + logLinesToLoad, }); - this.getRefresher(tabId, computedPod, logTabData).start(); - this.podLogs.set(tabId, logs); + this.getRefresher(tabId, computedPods, logTabData).start(); + this.podLogs.set(tabId, mergePodLogs(linesByPod)); } catch (error) { this.handlerError(tabId, error); } @@ -66,13 +67,13 @@ export class LogStore { private getRefresher( tabId: TabId, - computedPod: IComputedValue, + computedPods: IComputedValue, logTabData: IComputedValue, ): IntervalFn { return getOrInsertWith(this.refreshers, tabId, () => interval(10, () => { if (this.podLogs.has(tabId)) { - this.loadMore(tabId, computedPod, logTabData); + this.loadMore(tabId, computedPods, logTabData); } }), ); @@ -94,7 +95,7 @@ export class LogStore { */ public async loadMore( tabId: TabId, - computedPod: IComputedValue, + computedPods: IComputedValue, logTabData: IComputedValue, ): Promise { const oldLogs = this.podLogs.get(tabId); @@ -104,56 +105,89 @@ export class LogStore { } try { - const logs = await this.loadLogs(computedPod, logTabData, { + const linesByPod = await this.loadLogs(computedPods, logTabData, { sinceTime: this.getLastSinceTime(tabId), }); + // Every pod's new lines are all chronologically after everything already + // shown (they were all fetched with the same `sinceTime`, derived from the + // most recent line already in `oldLogs`), so merging just this batch and + // appending it keeps the whole buffer in order without re-merging history. + const newLines = mergePodLogs(linesByPod).filter(Boolean); + // Add newly received logs to bottom - this.podLogs.set(tabId, [...oldLogs, ...logs.filter(Boolean)]); + this.podLogs.set(tabId, [...oldLogs, ...newLines]); } catch (error) { this.handlerError(tabId, error); } } /** - * Main logs loading function adds necessary data to payload and makes - * an API request - * @param tabId + * Main logs loading function adds necessary data to payload and makes an API + * request per pod (in parallel), keyed by pod name for tagging/merging. + * @param computedPods the pod(s) to fetch logs for; more than one means this + * is a combined logs tab + * @param logTabData * @param params request parameters described in IPodLogsQuery interface - * @returns A fetch request promise + * @returns A map of pod name to its fetched log lines */ private async loadLogs( - computedPod: IComputedValue, + computedPods: IComputedValue, logTabData: IComputedValue, params: Partial, - ): Promise { + ): Promise> { const { - pod, + pods, tabData: { selectedContainer, showPrevious }, } = await waitUntilDefined(() => { - const pod = computedPod.get(); + const pods = computedPods.get(); const tabData = logTabData.get(); - if (pod && tabData) { - return { pod, tabData }; + if (pods.length && tabData) { + return { pods, tabData }; } return undefined; }); - const namespace = pod.getNs(); - const name = pod.getName(); - - const result = await this.dependencies.callForLogs( - { namespace, name }, - { - ...params, - timestamps: true, // Always setting timestamp to separate old logs from new ones - container: selectedContainer, - previous: showPrevious, - }, + + const results = await Promise.allSettled( + pods.map(async (pod) => { + const result = await this.dependencies.callForLogs( + { namespace: pod.getNs(), name: pod.getName() }, + { + ...params, + timestamps: true, // Always setting timestamp to separate old logs from new ones + container: selectedContainer, + previous: showPrevious, + }, + ); + + return { + podName: pod.getName(), + lines: result.trimEnd().replace(/\r/g, "\n").split("\n").filter(Boolean), + }; + }), ); - return result.trimEnd().replace(/\r/g, "\n").split("\n"); + const linesByPod = new Map(); + const errors: unknown[] = []; + + for (const result of results) { + if (result.status === "fulfilled") { + linesByPod.set(result.value.podName, result.value.lines); + } else { + errors.push(result.reason); + } + } + + // Only surface an error (and blank out the tab) when every pod failed; a + // single pod being briefly unreachable (e.g. it just got deleted) shouldn't + // wipe out the logs still being received from the rest of a combined tab. + if (linesByPod.size === 0 && errors.length > 0) { + throw errors[0]; + } + + return linesByPod; } /** @@ -241,11 +275,11 @@ export class LogStore { reload( tabId: TabId, - computedPod: IComputedValue, + computedPods: IComputedValue, logTabData: IComputedValue, ): Promise { this.clearLogs(tabId); - return this.load(tabId, computedPod, logTabData); + return this.load(tabId, computedPods, logTabData); } } diff --git a/packages/core/src/renderer/components/dock/logs/tab-store.ts b/packages/core/src/renderer/components/dock/logs/tab-store.ts index 28ef5d2c9b..cbba637a02 100644 --- a/packages/core/src/renderer/components/dock/logs/tab-store.ts +++ b/packages/core/src/renderer/components/dock/logs/tab-store.ts @@ -36,6 +36,14 @@ export interface LogTabData { */ selectedPodId: string; + /** + * When set, the uids of additional pods whose logs are combined with + * `selectedPodId`'s and shown interleaved chronologically, tagged with a + * color-coded pod name prefix per line. Used for viewing the combined logs + * of a workload (e.g. all pods of a Deployment) instead of a single pod. + */ + mergedPodIds?: string[]; + /** * The namespace of the pods/workload */ diff --git a/packages/core/src/renderer/components/workloads-daemonsets/daemonset-menu.tsx b/packages/core/src/renderer/components/workloads-daemonsets/daemonset-menu.tsx index cac9d07cb0..cc74d36120 100644 --- a/packages/core/src/renderer/components/workloads-daemonsets/daemonset-menu.tsx +++ b/packages/core/src/renderer/components/workloads-daemonsets/daemonset-menu.tsx @@ -9,6 +9,7 @@ import { daemonSetApiInjectable } from "@freelensapp/kube-api-specifics"; import { showCheckedErrorNotificationInjectable } from "@freelensapp/notifications"; import { withInjectables } from "@ogre-tools/injectable-react"; import openConfirmDialogInjectable from "../confirm-dialog/open.injectable"; +import createWorkloadLogsTabInjectable from "../dock/logs/create-workload-logs-tab.injectable"; import { MenuItem } from "../menu"; import type { DaemonSetApi } from "@freelensapp/kube-api"; @@ -24,6 +25,7 @@ interface Dependencies { daemonSetApi: DaemonSetApi; openConfirmDialog: OpenConfirmDialog; showCheckedErrorNotification: ShowCheckedErrorNotification; + createWorkloadLogsTab: ReturnType; } const NonInjectedDaemonSetMenu = ({ @@ -32,8 +34,13 @@ const NonInjectedDaemonSetMenu = ({ toolbar, openConfirmDialog, showCheckedErrorNotification, + createWorkloadLogsTab, }: Dependencies & DaemonSetMenuProps) => ( <> + createWorkloadLogsTab({ workload: object })}> + + Logs + openConfirmDialog({ @@ -69,5 +76,6 @@ export const DaemonSetMenu = withInjectables(N daemonSetApi: di.inject(daemonSetApiInjectable), openConfirmDialog: di.inject(openConfirmDialogInjectable), showCheckedErrorNotification: di.inject(showCheckedErrorNotificationInjectable), + createWorkloadLogsTab: di.inject(createWorkloadLogsTabInjectable), }), }); diff --git a/packages/core/src/renderer/components/workloads-deployments/deployment-menu.tsx b/packages/core/src/renderer/components/workloads-deployments/deployment-menu.tsx index 600e7ccb21..1c6a28d8d1 100644 --- a/packages/core/src/renderer/components/workloads-deployments/deployment-menu.tsx +++ b/packages/core/src/renderer/components/workloads-deployments/deployment-menu.tsx @@ -9,8 +9,10 @@ import { deploymentApiInjectable } from "@freelensapp/kube-api-specifics"; import { showCheckedErrorNotificationInjectable } from "@freelensapp/notifications"; import { withInjectables } from "@ogre-tools/injectable-react"; import openConfirmDialogInjectable from "../confirm-dialog/open.injectable"; +import createWorkloadLogsTabInjectable from "../dock/logs/create-workload-logs-tab.injectable"; import { MenuItem } from "../menu"; import openDeploymentScaleDialogInjectable from "./scale/open.injectable"; +import deploymentStoreInjectable from "./store.injectable"; import type { DeploymentApi } from "@freelensapp/kube-api"; import type { Deployment } from "@freelensapp/kube-object"; @@ -19,25 +21,34 @@ import type { ShowCheckedErrorNotification } from "@freelensapp/notifications"; import type { OpenConfirmDialog } from "../confirm-dialog/open.injectable"; import type { KubeObjectMenuProps } from "../kube-object-menu"; import type { OpenDeploymentScaleDialog } from "./scale/open.injectable"; +import type { DeploymentStore } from "./store"; export interface DeploymentMenuProps extends KubeObjectMenuProps {} interface Dependencies { openDeploymentScaleDialog: OpenDeploymentScaleDialog; deploymentApi: DeploymentApi; + deploymentStore: DeploymentStore; openConfirmDialog: OpenConfirmDialog; showCheckedErrorNotification: ShowCheckedErrorNotification; + createWorkloadLogsTab: ReturnType; } const NonInjectedDeploymentMenu = ({ deploymentApi, + deploymentStore, object, openDeploymentScaleDialog, toolbar, openConfirmDialog, showCheckedErrorNotification, + createWorkloadLogsTab, }: Dependencies & DeploymentMenuProps) => ( <> + createWorkloadLogsTab({ workload: object, pods: deploymentStore.getChildPods(object) })}> + + Logs + openDeploymentScaleDialog(object)}> Scale @@ -75,8 +86,10 @@ export const DeploymentMenu = withInjectables getProps: (di, props) => ({ ...props, deploymentApi: di.inject(deploymentApiInjectable), + deploymentStore: di.inject(deploymentStoreInjectable), openDeploymentScaleDialog: di.inject(openDeploymentScaleDialogInjectable), openConfirmDialog: di.inject(openConfirmDialogInjectable), showCheckedErrorNotification: di.inject(showCheckedErrorNotificationInjectable), + createWorkloadLogsTab: di.inject(createWorkloadLogsTabInjectable), }), }); diff --git a/packages/core/src/renderer/components/workloads-jobs/job-menu.tsx b/packages/core/src/renderer/components/workloads-jobs/job-menu.tsx index d581eec52f..0fcc46f736 100644 --- a/packages/core/src/renderer/components/workloads-jobs/job-menu.tsx +++ b/packages/core/src/renderer/components/workloads-jobs/job-menu.tsx @@ -9,6 +9,7 @@ import { jobApiInjectable } from "@freelensapp/kube-api-specifics"; import { showCheckedErrorNotificationInjectable } from "@freelensapp/notifications"; import { withInjectables } from "@ogre-tools/injectable-react"; import openConfirmDialogInjectable from "../confirm-dialog/open.injectable"; +import createWorkloadLogsTabInjectable from "../dock/logs/create-workload-logs-tab.injectable"; import { MenuItem } from "../menu"; import type { JobApi } from "@freelensapp/kube-api"; @@ -24,6 +25,7 @@ interface Dependencies { openConfirmDialog: OpenConfirmDialog; jobApi: JobApi; showCheckedErrorNotification: ShowCheckedErrorNotification; + createWorkloadLogsTab: ReturnType; } const NonInjectedJobMenu = ({ @@ -32,8 +34,13 @@ const NonInjectedJobMenu = ({ openConfirmDialog, jobApi, showCheckedErrorNotification, + createWorkloadLogsTab, }: Dependencies & JobMenuProps) => ( <> + createWorkloadLogsTab({ workload: object })}> + + Logs + {object.isSuspend() ? ( @@ -92,5 +99,6 @@ export const JobMenu = withInjectables(NonInjectedJo openConfirmDialog: di.inject(openConfirmDialogInjectable), jobApi: di.inject(jobApiInjectable), showCheckedErrorNotification: di.inject(showCheckedErrorNotificationInjectable), + createWorkloadLogsTab: di.inject(createWorkloadLogsTabInjectable), }), }); diff --git a/packages/core/src/renderer/components/workloads-replicasets/replica-set-menu.tsx b/packages/core/src/renderer/components/workloads-replicasets/replica-set-menu.tsx index f8f36b35c5..8c448bd3dc 100644 --- a/packages/core/src/renderer/components/workloads-replicasets/replica-set-menu.tsx +++ b/packages/core/src/renderer/components/workloads-replicasets/replica-set-menu.tsx @@ -6,6 +6,7 @@ import { Icon } from "@freelensapp/icon"; import { withInjectables } from "@ogre-tools/injectable-react"; +import createWorkloadLogsTabInjectable from "../dock/logs/create-workload-logs-tab.injectable"; import { MenuItem } from "../menu"; import openReplicaSetScaleDialogInjectable from "./scale-dialog/open.injectable"; @@ -18,14 +19,20 @@ export interface ReplicaSetMenuProps extends KubeObjectMenuProps {} interface Dependencies { openReplicaSetScaleDialog: OpenReplicaSetScaleDialog; + createWorkloadLogsTab: ReturnType; } const NonInjectedReplicaSetMenu = ({ object, toolbar, openReplicaSetScaleDialog, + createWorkloadLogsTab, }: Dependencies & ReplicaSetMenuProps) => ( <> + createWorkloadLogsTab({ workload: object })}> + + Logs + openReplicaSetScaleDialog(object)}> Scale @@ -37,5 +44,6 @@ export const ReplicaSetMenu = withInjectables getProps: (di, props) => ({ ...props, openReplicaSetScaleDialog: di.inject(openReplicaSetScaleDialogInjectable), + createWorkloadLogsTab: di.inject(createWorkloadLogsTabInjectable), }), }); diff --git a/packages/core/src/renderer/components/workloads-statefulsets/statefulset-menu.tsx b/packages/core/src/renderer/components/workloads-statefulsets/statefulset-menu.tsx index dd7aefac6d..aabcdcc28b 100644 --- a/packages/core/src/renderer/components/workloads-statefulsets/statefulset-menu.tsx +++ b/packages/core/src/renderer/components/workloads-statefulsets/statefulset-menu.tsx @@ -9,6 +9,7 @@ import { statefulSetApiInjectable } from "@freelensapp/kube-api-specifics"; import { showCheckedErrorNotificationInjectable } from "@freelensapp/notifications"; import { withInjectables } from "@ogre-tools/injectable-react"; import openConfirmDialogInjectable from "../confirm-dialog/open.injectable"; +import createWorkloadLogsTabInjectable from "../dock/logs/create-workload-logs-tab.injectable"; import { MenuItem } from "../menu"; import type { StatefulSetApi } from "@freelensapp/kube-api"; @@ -24,6 +25,7 @@ interface Dependencies { statefulSetApi: StatefulSetApi; openConfirmDialog: OpenConfirmDialog; showCheckedErrorNotification: ShowCheckedErrorNotification; + createWorkloadLogsTab: ReturnType; } const NonInjectedStatefulSetMenu = ({ @@ -32,8 +34,13 @@ const NonInjectedStatefulSetMenu = ({ toolbar, showCheckedErrorNotification, openConfirmDialog, + createWorkloadLogsTab, }: Dependencies & StatefulSetMenuProps) => ( <> + createWorkloadLogsTab({ workload: object })}> + + Logs + openConfirmDialog({ @@ -69,5 +76,6 @@ export const StatefulSetMenu = withInjectables