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:
Alex Andreev authored and GitHub committed 2022-08-30 16:00:11 +03:00
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;
}
+10 -33
View File
@@ -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>
);
}