From f4f0cba6cada4073c90a5d0aa82469ec45270f81 Mon Sep 17 00:00:00 2001 From: Alex Andreev Date: Sun, 11 Oct 2020 19:36:19 +0300 Subject: [PATCH] Moving Pod logs into Dock panel (#1043) * Moving pod logs into Dock Signed-off-by: Alex Andreev * Always set up default container Signed-off-by: Alex Andreev * Open existent tab if fount Signed-off-by: Alex Andreev * Moving logs load and properties into store Signed-off-by: Alex Andreev * Setting a refresher Signed-off-by: Alex Andreev * Adding showButtons prop to InfoPanel Signed-off-by: Alex Andreev * Hiding sequence number in log tabs Signed-off-by: Alex Andreev * Removing PodLogsDialog Signed-off-by: Alex Andreev * Removing unused PodLogsDialog import Signed-off-by: Alex Andreev * Tiny cleaning Signed-off-by: Alex Andreev * A bit of cleaning up Signed-off-by: Alex Andreev * Hiding drawer when opening logs Signed-off-by: Alex Andreev --- src/renderer/api/endpoints/pods.api.ts | 1 + .../+workloads-pods/pod-logs-dialog.scss | 110 ------- .../+workloads-pods/pod-logs-dialog.tsx | 307 ------------------ .../components/+workloads-pods/pod-menu.tsx | 15 +- src/renderer/components/app.tsx | 2 - src/renderer/components/dock/dock.store.ts | 1 + src/renderer/components/dock/dock.tsx | 6 + src/renderer/components/dock/info-panel.tsx | 42 ++- src/renderer/components/dock/pod-logs.scss | 43 +++ .../components/dock/pod-logs.store.ts | 126 +++++++ src/renderer/components/dock/pod-logs.tsx | 235 ++++++++++++++ src/renderer/themes/kontena-dark.json | 1 + src/renderer/themes/kontena-light.json | 1 + src/renderer/themes/theme-vars.scss | 3 + 14 files changed, 453 insertions(+), 440 deletions(-) delete mode 100644 src/renderer/components/+workloads-pods/pod-logs-dialog.scss delete mode 100644 src/renderer/components/+workloads-pods/pod-logs-dialog.tsx create mode 100644 src/renderer/components/dock/pod-logs.scss create mode 100644 src/renderer/components/dock/pod-logs.store.ts create mode 100644 src/renderer/components/dock/pod-logs.tsx diff --git a/src/renderer/api/endpoints/pods.api.ts b/src/renderer/api/endpoints/pods.api.ts index c1394ab6db..e1545f09c7 100644 --- a/src/renderer/api/endpoints/pods.api.ts +++ b/src/renderer/api/endpoints/pods.api.ts @@ -47,6 +47,7 @@ export interface IPodLogsQuery { tailLines?: number; timestamps?: boolean; sinceTime?: string; // Date.toISOString()-format + follow?: boolean; } export enum PodStatus { diff --git a/src/renderer/components/+workloads-pods/pod-logs-dialog.scss b/src/renderer/components/+workloads-pods/pod-logs-dialog.scss deleted file mode 100644 index 0c8845c78f..0000000000 --- a/src/renderer/components/+workloads-pods/pod-logs-dialog.scss +++ /dev/null @@ -1,110 +0,0 @@ -.PodLogsDialog { - --log-line-height: 16px; - - .Wizard { - width: 90vw; - max-height: none; - - .WizardStep { - & > .step-content.scrollable { - max-height: none; - } - - & > :last-child { - padding: $padding * 2; - } - } - } - - .log-controls { - padding-bottom: $padding * 2; - - .time-range { - flex-grow: 2; - text-align: center; - } - - .controls { - width: 100%; - } - - .control-buttons { - margin-right: 0; - white-space: nowrap; - - .Icon { - border-radius: $radius; - padding: 3px; - - &:hover { - color: $textColorPrimary; - background: #f4f4f4; - } - - &.active { - color: $primary; - background: #f4f4f4; - } - } - } - - @include media("<=desktop") { - flex-direction: column; - align-items: start; - - .container { - width: 100%; - } - - .controls { - margin-top: $margin * 2; - - .time-range { - text-align: left; - } - } - } - } - - .logs-area { - position: relative; - @include custom-scrollbar; - - // fix for `this.logsArea.scrollTop = this.logsArea.scrollHeight` - // `overflow: overlay` don't allow scroll to the last line - overflow: auto; - - color: #C5C8C6; - background: #1D1F21; - line-height: var(--log-line-height); - border-radius: 2px; - height: 45vh; - padding: $padding / 4 $padding; - font-family: $font-monospace; - font-size: smaller; - white-space: pre; - - .no-logs { - text-align: center; - } - } - - .new-logs-sep { - position: relative; - display: block; - height: 0; - border-top: 1px solid $primary; - margin: $margin * 2; - - &:after { - position: absolute; - left: 50%; - transform: translate(-50%, -50%); - content: 'new'; - background: $primary; - color: white; - padding: $padding / 3 $padding /2; - border-radius: $radius; - } - } -} \ No newline at end of file diff --git a/src/renderer/components/+workloads-pods/pod-logs-dialog.tsx b/src/renderer/components/+workloads-pods/pod-logs-dialog.tsx deleted file mode 100644 index d7ff3863cc..0000000000 --- a/src/renderer/components/+workloads-pods/pod-logs-dialog.tsx +++ /dev/null @@ -1,307 +0,0 @@ -import "./pod-logs-dialog.scss"; - -import React from "react"; -import { observable } from "mobx"; -import { observer } from "mobx-react"; -import { t, Trans } from "@lingui/macro"; -import { _i18n } from "../../i18n"; -import { Dialog, DialogProps } from "../dialog"; -import { Wizard, WizardStep } from "../wizard"; -import { IPodContainer, Pod, podsApi } from "../../api/endpoints"; -import { Icon } from "../icon"; -import { Select, SelectOption } from "../select"; -import { Spinner } from "../spinner"; -import { cssNames, downloadFile, interval } from "../../utils"; -import AnsiUp from "ansi_up"; -import DOMPurify from "dompurify" - -interface IPodLogsDialogData { - pod: Pod; - container?: IPodContainer; -} - -interface Props extends Partial { -} - -@observer -export class PodLogsDialog extends React.Component { - @observable static isOpen = false; - @observable static data: IPodLogsDialogData = null; - - static open(pod: Pod, container?: IPodContainer) { - PodLogsDialog.isOpen = true; - PodLogsDialog.data = { pod, container }; - } - - static close() { - PodLogsDialog.isOpen = false; - } - - get data() { - return PodLogsDialog.data; - } - - private logsArea: HTMLDivElement; - private refresher = interval(5, () => this.load()); - private containers: IPodContainer[] = [] - private initContainers: IPodContainer[] = [] - private lastLineIsShown = true; // used for proper auto-scroll content after refresh - private colorConverter = new AnsiUp(); - - @observable logs = ""; // latest downloaded logs for pod - @observable newLogs = ""; // new logs since dialog is open - @observable logsReady = false; - @observable selectedContainer: IPodContainer; - @observable showTimestamps = true; - @observable tailLines = 1000; - - lineOptions = [ - { label: _i18n._(t`All logs`), value: Number.MAX_SAFE_INTEGER }, - { label: 1000, value: 1000 }, - { label: 10000, value: 10000 }, - { label: 100000, value: 100000 }, - ] - - onOpen = async () => { - const { pod, container } = this.data; - this.containers = pod.getContainers(); - this.initContainers = pod.getInitContainers(); - this.selectedContainer = container || this.containers[0]; - await this.load(); - this.refresher.start(); - } - - onClose = () => { - this.resetLogs(); - this.refresher.stop(); - } - - close = () => { - PodLogsDialog.close(); - } - - load = async () => { - if (!this.data) return; - const { pod } = this.data; - try { - // if logs already loaded, check the latest timestamp for getting updates only from this point - const logsTimestamps = this.getTimestamps(this.newLogs || this.logs); - let lastLogDate = new Date(0) - if (logsTimestamps) { - lastLogDate = new Date(logsTimestamps.slice(-1)[0]); - lastLogDate.setSeconds(lastLogDate.getSeconds() + 1); // avoid duplicates from last second - } - const namespace = pod.getNs(); - const name = pod.getName(); - const logs = await podsApi.getLogs({ namespace, name }, { - container: this.selectedContainer.name, - timestamps: true, - tailLines: this.tailLines ? this.tailLines : undefined, - sinceTime: lastLogDate.toISOString(), - }); - if (!this.logs) { - this.logs = logs; - } - else if (logs) { - this.newLogs = `${this.newLogs}\n${logs}`.trim(); - } - } catch (error) { - this.logs = [ - _i18n._(t`Failed to load logs: ${error.message}`), - _i18n._(t`Reason: ${error.reason} (${error.code})`), - ].join("\n") - } - this.logsReady = true; - } - - reload = async () => { - this.resetLogs(); - this.refresher.stop(); - await this.load(); - this.refresher.start(); - } - - componentDidUpdate() { - // scroll logs only when it's already in the end, - // otherwise it can interrupt reading by jumping after loading new logs update - if (this.logsArea && this.lastLineIsShown) { - this.logsArea.scrollTop = this.logsArea.scrollHeight; - } - } - - onScroll = (evt: React.UIEvent) => { - const logsArea = evt.currentTarget; - const { scrollHeight, clientHeight, scrollTop } = logsArea; - this.lastLineIsShown = clientHeight + scrollTop === scrollHeight; - }; - - getLogs() { - const { logs, newLogs, showTimestamps } = this; - return { - logs: showTimestamps ? logs : this.removeTimestamps(logs), - newLogs: showTimestamps ? newLogs : this.removeTimestamps(newLogs), - } - } - - getTimestamps(logs: string) { - return logs.match(/^\d+\S+/gm); - } - - removeTimestamps(logs: string) { - return logs.replace(/^\d+.*?\s/gm, ""); - } - - resetLogs() { - this.logs = ""; - this.newLogs = ""; - this.lastLineIsShown = true; - this.logsReady = false; - } - - onContainerChange = (option: SelectOption) => { - this.selectedContainer = this.containers - .concat(this.initContainers) - .find(container => container.name === option.value); - this.reload(); - } - - onTailLineChange = (option: SelectOption) => { - this.tailLines = option.value; - this.reload(); - } - - formatOptionLabel = (option: SelectOption) => { - const { value, label } = option; - return label || <> {value}; - } - - toggleTimestamps = () => { - this.showTimestamps = !this.showTimestamps; - } - - downloadLogs = () => { - const { logs, newLogs } = this.getLogs(); - const fileName = this.selectedContainer.name + ".log"; - const fileContents = logs + newLogs; - downloadFile(fileName, fileContents, "text/plain"); - } - - get containerSelectOptions() { - return [ - { - label: _i18n._(t`Containers`), - options: this.containers.map(container => { - return { value: container.name } - }), - }, - { - label: _i18n._(t`Init Containers`), - options: this.initContainers.map(container => { - return { value: container.name } - }), - } - ]; - } - - renderControlsPanel() { - const { logsReady, showTimestamps } = this; - if (!logsReady) return; - const timestamps = this.getTimestamps(this.logs + this.newLogs); - let from = ""; - let to = ""; - if (timestamps) { - from = new Date(timestamps[0]).toLocaleString(); - to = new Date(timestamps[timestamps.length - 1]).toLocaleString(); - } - return ( -
-
- {timestamps && From {from} to {to}} -
-
- - -
-
- ) - } - - renderLogs() { - if (!this.logsReady) { - return - } - const { logs, newLogs } = this.getLogs(); - if (!logs && !newLogs) { - return

There are no logs available for container.

- } - return ( - <> -
- {newLogs && ( - <> -

-

- - )} - - ); - } - - render() { - const { ...dialogProps } = this.props; - const { selectedContainer, tailLines } = this; - const podName = this.data ? this.data.pod.getName() : ""; - const header =
{podName} Logs
; - return ( - - - Close}> -
-
- Container - {selectedContainer && ( - -
- {this.renderControlsPanel()} -
-
this.logsArea = e}> - {this.renderLogs()} -
-
-
-
- ) - } -} diff --git a/src/renderer/components/+workloads-pods/pod-menu.tsx b/src/renderer/components/+workloads-pods/pod-menu.tsx index cf6fcd360f..64b5d1f5eb 100644 --- a/src/renderer/components/+workloads-pods/pod-menu.tsx +++ b/src/renderer/components/+workloads-pods/pod-menu.tsx @@ -3,15 +3,15 @@ import "./pod-menu.scss"; import React from "react"; import { t, Trans } from "@lingui/macro"; import { MenuItem, SubMenu } from "../menu"; -import { IPodContainer, Pod, nodesApi } from "../../api/endpoints"; +import { IPodContainer, Pod } from "../../api/endpoints"; import { Icon } from "../icon"; import { StatusBrick } from "../status-brick"; -import { PodLogsDialog } from "./pod-logs-dialog"; import { KubeObjectMenu, KubeObjectMenuProps } from "../kube-object/kube-object-menu"; import { cssNames, prevDefault } from "../../utils"; import { terminalStore, createTerminalTab } from "../dock/terminal.store"; import { _i18n } from "../../i18n"; import { hideDetails } from "../../navigation"; +import { createPodLogsTab } from "../dock/pod-logs.store"; interface Props extends KubeObjectMenuProps { } @@ -42,7 +42,16 @@ export class PodMenu extends React.Component { } showLogs(container: IPodContainer) { - PodLogsDialog.open(this.props.object, container); + hideDetails(); + const pod = this.props.object; + createPodLogsTab({ + pod, + containers: pod.getContainers(), + initContainers: pod.getInitContainers(), + selectedContainer: container, + showTimestamps: false, + tailLines: 1000 + }); } renderShellMenu() { diff --git a/src/renderer/components/app.tsx b/src/renderer/components/app.tsx index aee156ff31..cf8ba01003 100755 --- a/src/renderer/components/app.tsx +++ b/src/renderer/components/app.tsx @@ -23,7 +23,6 @@ import { eventRoute } from "./+events"; import { Apps, appsRoute } from "./+apps"; import { KubeObjectDetails } from "./kube-object/kube-object-details"; import { AddRoleBindingDialog } from "./+user-management-roles-bindings"; -import { PodLogsDialog } from "./+workloads-pods/pod-logs-dialog"; import { DeploymentScaleDialog } from "./+workloads-deployments/deployment-scale-dialog"; import { CronJobTriggerDialog } from "./+workloads-cronjobs/cronjob-trigger-dialog"; import { CustomResources } from "./+custom-resources/custom-resources"; @@ -82,7 +81,6 @@ export class App extends React.Component { - diff --git a/src/renderer/components/dock/dock.store.ts b/src/renderer/components/dock/dock.store.ts index 2fed511e75..2ec01df8de 100644 --- a/src/renderer/components/dock/dock.store.ts +++ b/src/renderer/components/dock/dock.store.ts @@ -11,6 +11,7 @@ export enum TabKind { EDIT_RESOURCE = "edit-resource", INSTALL_CHART = "install-chart", UPGRADE_CHART = "upgrade-chart", + POD_LOGS = "pod-logs", } export interface IDockTab { diff --git a/src/renderer/components/dock/dock.tsx b/src/renderer/components/dock/dock.tsx index c020d20672..f4936417e6 100644 --- a/src/renderer/components/dock/dock.tsx +++ b/src/renderer/components/dock/dock.tsx @@ -22,6 +22,8 @@ import { createResourceTab, isCreateResourceTab } from "./create-resource.store" import { isEditResourceTab } from "./edit-resource.store"; import { isInstallChartTab } from "./install-chart.store"; import { isUpgradeChartTab } from "./upgrade-chart.store"; +import { PodLogs } from "./pod-logs"; +import { isPodLogsTab } from "./pod-logs.store"; interface Props { className?: string; @@ -59,6 +61,9 @@ export class Dock extends React.Component { if (isInstallChartTab(tab) || isUpgradeChartTab(tab)) { return } /> } + if (isPodLogsTab(tab)) { + return + } } renderTabContent() { @@ -71,6 +76,7 @@ export class Dock extends React.Component { {isInstallChartTab(tab) && } {isUpgradeChartTab(tab) && } {isTerminalTab(tab) && } + {isPodLogsTab(tab) && }
) } diff --git a/src/renderer/components/dock/info-panel.tsx b/src/renderer/components/dock/info-panel.tsx index 122fea6d9f..777aa01027 100644 --- a/src/renderer/components/dock/info-panel.tsx +++ b/src/renderer/components/dock/info-panel.tsx @@ -13,7 +13,7 @@ import { Notifications } from "../notifications"; interface Props extends OptionalProps { tabId: TabId; - submit: () => Promise; + submit?: () => Promise; } interface OptionalProps { @@ -23,6 +23,7 @@ interface OptionalProps { submitLabel?: ReactNode; submittingMessage?: ReactNode; disableSubmit?: boolean; + showButtons?: boolean showSubmitClose?: boolean; showInlineInfo?: boolean; showNotifications?: boolean; @@ -33,6 +34,7 @@ export class InfoPanel extends Component { static defaultProps: OptionalProps = { submitLabel: Submit, submittingMessage: Submitting.., + showButtons: true, showSubmitClose: true, showInlineInfo: true, showNotifications: true, @@ -87,7 +89,7 @@ export class InfoPanel extends Component { } render() { - const { className, controls, submitLabel, disableSubmit, error, submittingMessage, showSubmitClose } = this.props; + const { className, controls, submitLabel, disableSubmit, error, submittingMessage, showButtons, showSubmitClose } = this.props; const { submit, close, submitAndClose, waiting } = this; const isDisabled = !!(disableSubmit || waiting || error); return ( @@ -98,22 +100,26 @@ export class InfoPanel extends Component {
{waiting ? <> {submittingMessage} : this.renderErrorIcon()}
-
); diff --git a/src/renderer/components/dock/pod-logs.scss b/src/renderer/components/dock/pod-logs.scss new file mode 100644 index 0000000000..73c6f523fb --- /dev/null +++ b/src/renderer/components/dock/pod-logs.scss @@ -0,0 +1,43 @@ +.PodLogs { + .logs { + @include custom-scrollbar; + + // fix for `this.logsElement.scrollTop = this.logsElement.scrollHeight` + // `overflow: overlay` don't allow scroll to the last line + overflow: auto; + + color: $textColorAccent; + background: $logsBackground; + line-height: var(--log-line-height); + border-radius: 2px; + padding: $padding * 2; + font-family: $font-monospace; + font-size: smaller; + white-space: pre; + flex-grow: 1; + + > div { + // Provides font better readability on large screens + -webkit-font-smoothing: subpixel-antialiased; + } + } + + .new-logs-sep { + position: relative; + display: block; + height: 0; + border-top: 1px solid $primary; + margin: $margin * 2; + + &:after { + position: absolute; + left: 50%; + transform: translate(-50%, -50%); + content: 'new'; + background: $primary; + color: white; + padding: $padding / 3; + border-radius: $radius; + } + } +} \ No newline at end of file diff --git a/src/renderer/components/dock/pod-logs.store.ts b/src/renderer/components/dock/pod-logs.store.ts new file mode 100644 index 0000000000..bf2bf9eb5c --- /dev/null +++ b/src/renderer/components/dock/pod-logs.store.ts @@ -0,0 +1,126 @@ +import { autorun, observable } from "mobx"; +import { Pod, IPodContainer, podsApi } from "../../api/endpoints"; +import { autobind, interval } from "../../utils"; +import { DockTabStore } from "./dock-tab.store"; +import { dockStore, IDockTab, TabKind } from "./dock.store"; +import { t } from "@lingui/macro"; +import { _i18n } from "../../i18n"; + +export interface IPodLogsData { + pod: Pod; + selectedContainer: IPodContainer + containers: IPodContainer[] + initContainers: IPodContainer[] + showTimestamps: boolean + tailLines: number +} + +type TabId = string; + +interface PodLogs { + oldLogs?: string + newLogs?: string +} + +@autobind() +export class PodLogsStore extends DockTabStore { + private refresher = interval(10, () => this.load(dockStore.selectedTabId)); + + @observable logs = observable.map(); + + constructor() { + super({ + storageName: "pod_logs" + }); + autorun(() => { + const { selectedTab, isOpen } = dockStore; + if (isPodLogsTab(selectedTab) && isOpen) { + this.refresher.start(); + } else { + this.refresher.stop(); + } + }, { delay: 500 }); + } + + load = async (tabId: TabId) => { + if (!this.logs.has(tabId)) { + this.logs.set(tabId, { oldLogs: "", newLogs: "" }) + } + const data = this.getData(tabId); + const { oldLogs, newLogs } = this.logs.get(tabId); + const { selectedContainer, tailLines } = data; + const pod = new Pod(data.pod); + try { + // if logs already loaded, check the latest timestamp for getting updates only from this point + const logsTimestamps = this.getTimestamps(newLogs || oldLogs); + let lastLogDate = new Date(0); + if (logsTimestamps) { + lastLogDate = new Date(logsTimestamps.slice(-1)[0]); + lastLogDate.setSeconds(lastLogDate.getSeconds() + 1); // avoid duplicates from last second + } + const namespace = pod.getNs(); + const name = pod.getName(); + const loadedLogs = await podsApi.getLogs({ namespace, name }, { + sinceTime: lastLogDate.toISOString(), + timestamps: true, // Always setting timestampt to separate old logs from new ones + container: selectedContainer.name, + tailLines: tailLines, + }); + if (!oldLogs) { + this.logs.set(tabId, { oldLogs: loadedLogs, newLogs }); + } else { + this.logs.set(tabId, { oldLogs, newLogs: loadedLogs }); + } + } catch (error) { + this.logs.set(tabId, { + oldLogs: [ + _i18n._(t`Failed to load logs: ${error.message}`), + _i18n._(t`Reason: ${error.reason} (${error.code})`) + ].join("\n"), + newLogs + }); + } + } + + getTimestamps(logs: string) { + return logs.match(/^\d+\S+/gm); + } + + removeTimestamps(logs: string) { + return logs.replace(/^\d+.*?\s/gm, ""); + } + + clearLogs(tabId: TabId) { + this.logs.delete(tabId); + } + + clearData(tabId: TabId) { + this.data.delete(tabId); + this.clearLogs(tabId); + } +} + +export const podLogsStore = new PodLogsStore(); + +export function createPodLogsTab(data: IPodLogsData, tabParams: Partial = {}) { + const podId = data.pod.getId(); + let tab = dockStore.getTabById(podId); + if (tab) { + dockStore.open(); + dockStore.selectTab(tab.id); + return; + } + // If no existent tab found + tab = dockStore.createTab({ + id: podId, + kind: TabKind.POD_LOGS, + title: `Logs: ${data.pod.getName()}`, + ...tabParams + }, false); + podLogsStore.setData(tab.id, data); + return tab; +} + +export function isPodLogsTab(tab: IDockTab) { + return tab && tab.kind === TabKind.POD_LOGS; +} diff --git a/src/renderer/components/dock/pod-logs.tsx b/src/renderer/components/dock/pod-logs.tsx new file mode 100644 index 0000000000..6f97163bde --- /dev/null +++ b/src/renderer/components/dock/pod-logs.tsx @@ -0,0 +1,235 @@ +import "./pod-logs.scss"; +import React from "react"; +import AnsiUp from "ansi_up"; +import DOMPurify from "dompurify"; +import { t, Trans } from "@lingui/macro"; +import { computed, observable, reaction } from "mobx"; +import { disposeOnUnmount, observer } from "mobx-react"; +import { _i18n } from "../../i18n"; +import { autobind, cssNames, downloadFile } from "../../utils"; +import { Icon } from "../icon"; +import { Select, SelectOption } from "../select"; +import { Spinner } from "../spinner"; +import { IDockTab } from "./dock.store"; +import { InfoPanel } from "./info-panel"; +import { IPodLogsData, podLogsStore } from "./pod-logs.store"; + +interface Props { + className?: string + tab: IDockTab +} + +@observer +export class PodLogs extends React.Component { + @observable ready = false; + + private logsElement: HTMLDivElement; + private lastLineIsShown = true; // used for proper auto-scroll content after refresh + private colorConverter = new AnsiUp(); + private lineOptions = [ + { label: _i18n._(t`All logs`), value: Number.MAX_SAFE_INTEGER }, + { label: 1000, value: 1000 }, + { label: 10000, value: 10000 }, + { label: 100000, value: 100000 }, + ]; + + componentDidMount() { + disposeOnUnmount(this, + reaction(() => this.props.tab.id, async () => { + if (podLogsStore.logs.has(this.tabId)) { + this.ready = true; + return; + } + await this.load(); + }, { fireImmediately: true }) + ); + } + + componentDidUpdate() { + // scroll logs only when it's already in the end, + // otherwise it can interrupt reading by jumping after loading new logs update + if (this.logsElement && this.lastLineIsShown) { + this.logsElement.scrollTop = this.logsElement.scrollHeight; + } + } + + get tabData() { + return podLogsStore.getData(this.tabId); + } + + get tabId() { + return this.props.tab.id; + } + + @autobind() + save(data: Partial) { + podLogsStore.setData(this.tabId, { ...this.tabData, ...data }); + } + + load = async () => { + this.ready = false; + await podLogsStore.load(this.tabId); + this.ready = true; + } + + reload = async () => { + podLogsStore.clearLogs(this.tabId); + this.lastLineIsShown = true; + await this.load(); + } + + @computed + get logs() { + if (!podLogsStore.logs.has(this.tabId)) return; + const { oldLogs, newLogs } = podLogsStore.logs.get(this.tabId); + const { getData, removeTimestamps } = podLogsStore; + const { showTimestamps } = getData(this.tabId); + return { + oldLogs: showTimestamps ? oldLogs : removeTimestamps(oldLogs), + newLogs: showTimestamps ? newLogs : removeTimestamps(newLogs) + } + } + + toggleTimestamps = () => { + this.save({ showTimestamps: !this.tabData.showTimestamps }); + } + + onScroll = (evt: React.UIEvent) => { + const logsArea = evt.currentTarget; + const { scrollHeight, clientHeight, scrollTop } = logsArea; + this.lastLineIsShown = clientHeight + scrollTop === scrollHeight; + }; + + downloadLogs = () => { + const { oldLogs, newLogs } = this.logs; + const { pod, selectedContainer } = this.tabData; + const fileName = selectedContainer ? selectedContainer.name : pod.getName(); + const fileContents = oldLogs + newLogs; + downloadFile(fileName + ".log", fileContents, "text/plain"); + } + + onContainerChange = (option: SelectOption) => { + const { containers, initContainers } = this.tabData; + this.save({ + selectedContainer: containers + .concat(initContainers) + .find(container => container.name === option.value) + }) + this.reload(); + } + + onTailLineChange = (option: SelectOption) => { + this.save({ tailLines: option.value }) + this.reload(); + } + + get containerSelectOptions() { + const { containers, initContainers } = this.tabData; + return [ + { + label: _i18n._(t`Containers`), + options: containers.map(container => { + return { value: container.name } + }), + }, + { + label: _i18n._(t`Init Containers`), + options: initContainers.map(container => { + return { value: container.name } + }), + } + ]; + } + + formatOptionLabel = (option: SelectOption) => { + const { value, label } = option; + return label || <> {value}; + } + + renderControls() { + if (!this.ready) return null; + const { selectedContainer, showTimestamps, tailLines } = this.tabData; + const timestamps = podLogsStore.getTimestamps(podLogsStore.logs.get(this.tabId).oldLogs); + return ( +
+ Container + +
+ {timestamps && ( + <> + Since{" "} + {new Date(timestamps[0]).toLocaleString()} + + )} +
+
+ + +
+
+ ); + } + + renderLogs() { + if (!this.ready) { + return ; + } + const { oldLogs, newLogs } = this.logs; + if (!oldLogs && !newLogs) { + return ( +
+ There are no logs available for container. +
+ ); + } + return ( + <> +
+ {newLogs && ( + <> +

+

+ + )} + + ); + } + + render() { + const { className } = this.props; + return ( +
+ +
this.logsElement = e}> + {this.renderLogs()} +
+
+ ); + } +} diff --git a/src/renderer/themes/kontena-dark.json b/src/renderer/themes/kontena-dark.json index 1071024841..25defd41bf 100644 --- a/src/renderer/themes/kontena-dark.json +++ b/src/renderer/themes/kontena-dark.json @@ -60,6 +60,7 @@ "dockHeadBackground": "#2e3136", "dockInfoBackground": "#1e2125", "dockInfoBorderColor": "#303136", + "logsBackground": "#000000", "terminalBackground": "#000000", "terminalForeground": "#ffffff", "terminalCursor": "#ffffff", diff --git a/src/renderer/themes/kontena-light.json b/src/renderer/themes/kontena-light.json index f39fa53d00..a1eca8d51c 100644 --- a/src/renderer/themes/kontena-light.json +++ b/src/renderer/themes/kontena-light.json @@ -61,6 +61,7 @@ "dockHeadBackground": "#e8e8e8", "dockInfoBackground": "#e8e8e8", "dockInfoBorderColor": "#c9cfd3", + "logsBackground": "#ffffff", "terminalBackground": "#ffffff", "terminalForeground": "#2d2d2d", "terminalCursor": "#2d2d2d", diff --git a/src/renderer/themes/theme-vars.scss b/src/renderer/themes/theme-vars.scss index c6c3072b8f..6a48721fcc 100644 --- a/src/renderer/themes/theme-vars.scss +++ b/src/renderer/themes/theme-vars.scss @@ -91,6 +91,9 @@ $terminalBrightMagenta: var(--terminalBrightMagenta); $terminalBrightCyan: var(--terminalBrightCyan); $terminalBrightWhite: var(--terminalBrightWhite); +// Logs +$logsBackground: var(--logsBackground); + // Dialogs $dialogTextColor: var(--dialogTextColor); $dialogBackground: var(--dialogBackground);