mirror of
https://github.com/lensapp/lens.git
synced 2025-05-20 05:10:56 +00:00
Add option to download all logs from the container (#5970)
* Creating callForAllLogsInjectable Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com> * Add sketch of Download all logs button Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com> * Use randomId while creating pod logs tab Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com> * Initial draft of download all logs tests Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com> * Introduce download logs dropdown Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com> * Cleaning up Controls component Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com> * Coloring and styling Download logs button Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com> * Drop waiting state on network or other error Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com> * After clicking on button test cases Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com> * Linter fixes Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com> * Removing previous Download icon Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com> * Respect timestamps and previous props in callForAllLogsInjectable Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com> * Update snapshots Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com> * Update snapshots Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com> * Remove unused .mockReturnValueOnce Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com> * Remove one more unused line Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com> * Cleanin up by overriding internals of logsViewModel injectable Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com> * Replace usage of callForAllLogs with simple callForLogs Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com> * Use css modules for the Controls component Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com> * Move downloadAllLogs logic to injectable Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com> * Move downloadLogs logic to the model Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com> * Remove withInjectables wrapper from LogControls Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com> * Move downloadAllLogs to model Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com> * Testing resolve/reject options for callForLogsInjectable Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com> * Catching call for logs errors Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com> * Doesn't show save dialog if no logs received Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com> * More descriptive describe statement Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com> * Introduce Dropdown component with Menu Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com> * Use <Dropdown/> in Download All Logs dropdown Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com> * Fix line-break symbol Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com> * Update snapshots Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com> * Return a Promise from downloadAllLogs() Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com> * Extend LogTabViewModel mocks in other tests Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com> * Fix downloadAllLogs prop typings Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com> * Fixing linter Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com> * Fix linter harder Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com> * Fix selectors in integration test Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com> * Move tests into /behaviours Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com> Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com>
This commit is contained in:
1 parent
106c3d246a
commit
5795452cc3
17 files changed
+1394
-50
No files matched your search
@@ -57,6 +57,8 @@ function mockLogTabViewModel(tabId: TabId, deps: Partial<LogTabViewModelDependen
|
||||
getPodsByOwnerId: jest.fn(),
|
||||
searchStore: new SearchStore(),
|
||||
areLogsPresent: jest.fn(),
|
||||
downloadLogs: jest.fn(),
|
||||
downloadAllLogs: jest.fn(),
|
||||
...deps,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -31,6 +31,8 @@ function mockLogTabViewModel(tabId: TabId, deps: Partial<LogTabViewModelDependen
|
||||
getPodsByOwnerId: jest.fn(),
|
||||
areLogsPresent: jest.fn(),
|
||||
searchStore: new SearchStore(),
|
||||
downloadLogs: jest.fn(),
|
||||
downloadAllLogs: jest.fn(),
|
||||
...deps,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
.controls {
|
||||
@include hidden-scrollbar;
|
||||
|
||||
display: flex;
|
||||
gap: var(--padding);
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-flow: row wrap;
|
||||
background: var(--dockInfoBackground);
|
||||
padding: var(--padding) calc(var(--padding) * 2);
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
|
||||
.LogControls {
|
||||
@include hidden-scrollbar;
|
||||
|
||||
background: var(--dockInfoBackground);
|
||||
padding: $padding $padding * 2;
|
||||
}
|
||||
@@ -3,27 +3,20 @@
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
|
||||
import "./controls.scss";
|
||||
import styles from "./controls.module.scss";
|
||||
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import React from "react";
|
||||
|
||||
import { cssNames } from "../../../utils";
|
||||
import { Checkbox } from "../../checkbox";
|
||||
import { Icon } from "../../icon";
|
||||
import { DownloadLogsDropdown } from "./download-logs-dropdown";
|
||||
import type { LogTabViewModel } from "./logs-view-model";
|
||||
import { withInjectables } from "@ogre-tools/injectable-react";
|
||||
import openSaveFileDialogInjectable from "../../../utils/save-file.injectable";
|
||||
|
||||
export interface LogControlsProps {
|
||||
model: LogTabViewModel;
|
||||
}
|
||||
|
||||
interface Dependencies {
|
||||
openSaveFileDialog: (filename: string, contents: BlobPart | BlobPart[], type: string) => void;
|
||||
}
|
||||
|
||||
const NonInjectedLogControls = observer(({ openSaveFileDialog, model }: Dependencies & LogControlsProps) => {
|
||||
export const LogControls = observer(({ model }: LogControlsProps) => {
|
||||
const tabData = model.logTabData.get();
|
||||
const pod = model.pod.get();
|
||||
|
||||
@@ -44,18 +37,9 @@ const NonInjectedLogControls = observer(({ openSaveFileDialog, model }: Dependen
|
||||
model.reloadLogs();
|
||||
};
|
||||
|
||||
const downloadLogs = () => {
|
||||
const fileName = pod.getName();
|
||||
const logsToDownload: string[] = showTimestamps
|
||||
? model.logs.get()
|
||||
: model.logsWithoutTimestamps.get();
|
||||
|
||||
openSaveFileDialog(`${fileName}.log`, logsToDownload.join("\n"), "text/plain");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cssNames("LogControls flex gaps align-center justify-space-between wrap")}>
|
||||
<div className="time-range">
|
||||
<div className={styles.controls} data-testid="log-controls">
|
||||
<div>
|
||||
{since && (
|
||||
<span>
|
||||
Logs from
|
||||
@@ -77,20 +61,13 @@ const NonInjectedLogControls = observer(({ openSaveFileDialog, model }: Dependen
|
||||
onChange={togglePrevious}
|
||||
className="show-previous"
|
||||
/>
|
||||
<Icon
|
||||
material="get_app"
|
||||
onClick={downloadLogs}
|
||||
tooltip="Download"
|
||||
className="download-icon"
|
||||
|
||||
<DownloadLogsDropdown
|
||||
downloadVisibleLogs={model.downloadLogs}
|
||||
downloadAllLogs={model.downloadAllLogs}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export const LogControls = withInjectables<Dependencies, LogControlsProps>(NonInjectedLogControls, {
|
||||
getProps: (di, props) => ({
|
||||
openSaveFileDialog: di.inject(openSaveFileDialogInjectable),
|
||||
...props,
|
||||
}),
|
||||
});
|
||||
@@ -6,20 +6,21 @@ import { getInjectable } from "@ogre-tools/injectable";
|
||||
import type { DockTabCreate, DockTab, TabId } from "../dock/store";
|
||||
import { TabKind } from "../dock/store";
|
||||
import type { LogTabData } from "./tab-store";
|
||||
import * as uuid from "uuid";
|
||||
import { runInAction } from "mobx";
|
||||
import createDockTabInjectable from "../dock/create-dock-tab.injectable";
|
||||
import setLogTabDataInjectable from "./set-log-tab-data.injectable";
|
||||
import getRandomIdForPodLogsTabInjectable from "./get-random-id-for-pod-logs-tab.injectable";
|
||||
|
||||
export type CreateLogsTabData = Pick<LogTabData, "owner" | "selectedPodId" | "selectedContainer" | "namespace"> & Omit<Partial<LogTabData>, "owner" | "selectedPodId" | "selectedContainer" | "namespace">;
|
||||
|
||||
interface Dependencies {
|
||||
createDockTab: (rawTabDesc: DockTabCreate, addNumber?: boolean) => DockTab;
|
||||
setLogTabData: (tabId: string, data: LogTabData) => void;
|
||||
getRandomId: () => string;
|
||||
}
|
||||
|
||||
const createLogsTab = ({ createDockTab, setLogTabData }: Dependencies) => (title: string, data: CreateLogsTabData): TabId => {
|
||||
const id = `log-tab-${uuid.v4()}`;
|
||||
const createLogsTab = ({ createDockTab, setLogTabData, getRandomId }: Dependencies) => (title: string, data: CreateLogsTabData): TabId => {
|
||||
const id = `log-tab-${getRandomId()}`;
|
||||
|
||||
runInAction(() => {
|
||||
createDockTab({
|
||||
@@ -43,6 +44,7 @@ const createLogsTabInjectable = getInjectable({
|
||||
instantiate: (di) => createLogsTab({
|
||||
createDockTab: di.inject(createDockTabInjectable),
|
||||
setLogTabData: di.inject(setLogTabDataInjectable),
|
||||
getRandomId: di.inject(getRandomIdForPodLogsTabInjectable),
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
import { getInjectable } from "@ogre-tools/injectable";
|
||||
import type { PodLogsQuery } from "../../../../common/k8s-api/endpoints";
|
||||
import type { ResourceDescriptor } from "../../../../common/k8s-api/kube-api";
|
||||
import loggerInjectable from "../../../../common/logger.injectable";
|
||||
import openSaveFileDialogInjectable from "../../../utils/save-file.injectable";
|
||||
import callForLogsInjectable from "./call-for-logs.injectable";
|
||||
|
||||
const downloadAllLogsInjectable = getInjectable({
|
||||
id: "download-all-logs",
|
||||
|
||||
instantiate: (di) => {
|
||||
const callForLogs = di.inject(callForLogsInjectable);
|
||||
const openSaveFileDialog = di.inject(openSaveFileDialogInjectable);
|
||||
const logger = di.inject(loggerInjectable);
|
||||
|
||||
return async (params: ResourceDescriptor, query: PodLogsQuery) => {
|
||||
const logs = await callForLogs(params, query).catch(error => {
|
||||
logger.error("Can't download logs: ", error);
|
||||
});
|
||||
|
||||
if (logs) {
|
||||
openSaveFileDialog(`${params.name}.log`, logs, "text/plain");
|
||||
}
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export default downloadAllLogsInjectable;
|
||||
@@ -0,0 +1,37 @@
|
||||
.dropdown {
|
||||
--accent-color: var(--colorInfo);
|
||||
|
||||
border: 1px solid var(--accent-color);
|
||||
border-radius: 4px;
|
||||
color: var(--accent-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: calc(var(--padding) / 4) var(--padding);
|
||||
gap: 6px;
|
||||
position: relative;
|
||||
|
||||
&:disabled {
|
||||
cursor: progress;
|
||||
opacity: .7;
|
||||
}
|
||||
|
||||
&:hover::before{
|
||||
opacity: 0.25;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
box-shadow: 0 0 0 2px var(--accent-color);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
&::before {
|
||||
content: " ";
|
||||
position: absolute;
|
||||
background: var(--accent-color);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
left: 0;
|
||||
opacity: 0;
|
||||
transition: opacity 0.1s;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
|
||||
import styles from "./download-logs-dropdown.module.scss";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { Icon } from "../../icon";
|
||||
import { MenuItem } from "../../menu";
|
||||
import { Dropdown } from "../../dropdown/dropdown";
|
||||
|
||||
interface DownloadLogsDropdownProps {
|
||||
downloadVisibleLogs: () => void;
|
||||
downloadAllLogs: () => Promise<void> | undefined;
|
||||
}
|
||||
|
||||
export function DownloadLogsDropdown({ downloadAllLogs, downloadVisibleLogs }: DownloadLogsDropdownProps) {
|
||||
const [waiting, setWaiting] = useState(false);
|
||||
|
||||
const downloadAll = async () => {
|
||||
setWaiting(true);
|
||||
|
||||
try {
|
||||
await downloadAllLogs();
|
||||
} finally {
|
||||
setWaiting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
id="download-logs-dropdown"
|
||||
contentForToggle={(
|
||||
<button
|
||||
data-testid="download-logs-dropdown"
|
||||
className={styles.dropdown}
|
||||
disabled={waiting}
|
||||
>
|
||||
Download
|
||||
<Icon material="arrow_drop_down" smallest/>
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
<MenuItem onClick={downloadVisibleLogs} data-testid="download-visible-logs">
|
||||
Visible logs
|
||||
</MenuItem>
|
||||
<MenuItem onClick={downloadAll} data-testid="download-all-logs">
|
||||
All logs
|
||||
</MenuItem>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
import { getInjectable } from "@ogre-tools/injectable";
|
||||
import openSaveFileDialogInjectable from "../../../utils/save-file.injectable";
|
||||
|
||||
const downloadLogsInjectable = getInjectable({
|
||||
id: "download-logs",
|
||||
|
||||
instantiate: (di) => {
|
||||
const openSaveFileDialog = di.inject(openSaveFileDialogInjectable);
|
||||
|
||||
return (filename: string, logs: string[]) => {
|
||||
openSaveFileDialog(filename, logs.join("\n"), "text/plain");
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export default downloadLogsInjectable;
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
import { getInjectable } from "@ogre-tools/injectable";
|
||||
import getRandomIdInjectable from "../../../../common/utils/get-random-id.injectable";
|
||||
|
||||
const getRandomIdForPodLogsTabInjectable = getInjectable({
|
||||
id: "get-random-id-for-pod-logs-tab",
|
||||
instantiate: (di) => di.inject(getRandomIdInjectable),
|
||||
});
|
||||
|
||||
export default getRandomIdForPodLogsTabInjectable;
|
||||
@@ -18,6 +18,8 @@ import areLogsPresentInjectable from "./are-logs-present.injectable";
|
||||
import searchStoreInjectable from "../../../search-store/search-store.injectable";
|
||||
import getPodsByOwnerIdInjectable from "../../+workloads-pods/get-pods-by-owner-id.injectable";
|
||||
import getPodByIdInjectable from "../../+workloads-pods/get-pod-by-id.injectable";
|
||||
import downloadLogsInjectable from "./download-logs.injectable";
|
||||
import downloadAllLogsInjectable from "./download-all-logs.injectable";
|
||||
|
||||
export interface InstantiateArgs {
|
||||
tabId: TabId;
|
||||
@@ -39,6 +41,8 @@ const logsViewModelInjectable = getInjectable({
|
||||
areLogsPresent: di.inject(areLogsPresentInjectable),
|
||||
getPodById: di.inject(getPodByIdInjectable),
|
||||
getPodsByOwnerId: di.inject(getPodsByOwnerIdInjectable),
|
||||
downloadLogs: di.inject(downloadLogsInjectable),
|
||||
downloadAllLogs: di.inject(downloadAllLogsInjectable),
|
||||
searchStore: di.inject(searchStoreInjectable),
|
||||
}),
|
||||
lifecycle: lifecycleEnum.transient,
|
||||
|
||||
@@ -7,12 +7,13 @@ import type { IComputedValue } from "mobx";
|
||||
import { computed } from "mobx";
|
||||
import type { TabId } from "../dock/store";
|
||||
import type { SearchStore } from "../../../search-store/search-store";
|
||||
import type { Pod } from "../../../../common/k8s-api/endpoints";
|
||||
import type { Pod, PodLogsQuery } from "../../../../common/k8s-api/endpoints";
|
||||
import { isDefined } from "../../../utils";
|
||||
import assert from "assert";
|
||||
import type { GetPodById } from "../../+workloads-pods/get-pod-by-id.injectable";
|
||||
import type { GetPodsByOwnerId } from "../../+workloads-pods/get-pods-by-owner-id.injectable";
|
||||
import type { LoadLogs } from "./load-logs.injectable";
|
||||
import type { ResourceDescriptor } from "../../../../common/k8s-api/kube-api";
|
||||
|
||||
export interface LogTabViewModelDependencies {
|
||||
getLogs: (tabId: TabId) => string[];
|
||||
@@ -27,6 +28,8 @@ export interface LogTabViewModelDependencies {
|
||||
getPodById: GetPodById;
|
||||
getPodsByOwnerId: GetPodsByOwnerId;
|
||||
areLogsPresent: (tabId: TabId) => boolean;
|
||||
downloadLogs: (filename: string, logs: string[]) => void;
|
||||
downloadAllLogs: (params: ResourceDescriptor, query: PodLogsQuery) => Promise<void>;
|
||||
searchStore: SearchStore;
|
||||
}
|
||||
|
||||
@@ -77,4 +80,32 @@ export class LogTabViewModel {
|
||||
reloadLogs = () => this.dependencies.reloadLogs(this.tabId, this.pod, 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();
|
||||
|
||||
if (pod && tabData) {
|
||||
const fileName = pod.getName();
|
||||
const logsToDownload: string[] = tabData.showTimestamps
|
||||
? this.logs.get()
|
||||
: this.logsWithoutTimestamps.get();
|
||||
|
||||
this.dependencies.downloadLogs(`${fileName}.log`, logsToDownload);
|
||||
}
|
||||
};
|
||||
|
||||
downloadAllLogs = () => {
|
||||
const pod = this.pod.get();
|
||||
const tabData = this.logTabData.get();
|
||||
|
||||
if (pod && tabData) {
|
||||
const params = { name: pod.getName(), namespace: pod.getNs() };
|
||||
const query = { timestamps: tabData.showTimestamps, previous: tabData.showPrevious };
|
||||
|
||||
return this.dependencies.downloadAllLogs(params, query);
|
||||
}
|
||||
|
||||
return;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
|
||||
import type { HTMLAttributes } from "react";
|
||||
import React, { useState } from "react";
|
||||
import { Menu } from "../menu";
|
||||
|
||||
interface DropdownProps extends HTMLAttributes<HTMLDivElement> {
|
||||
contentForToggle: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Dropdown(props: DropdownProps) {
|
||||
const { id, contentForToggle, children, ...rest } = props;
|
||||
const [opened, setOpened] = useState(false);
|
||||
|
||||
const toggle = () => {
|
||||
setOpened(!opened);
|
||||
};
|
||||
|
||||
return (
|
||||
<div {...rest}>
|
||||
<div id={id}>
|
||||
{contentForToggle}
|
||||
</div>
|
||||
<Menu
|
||||
usePortal
|
||||
htmlFor={id}
|
||||
isOpen={opened}
|
||||
close={toggle}
|
||||
open={toggle}
|
||||
>
|
||||
{React.Children.toArray(children)}
|
||||
</Menu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user