From d7ffca7bcdd46b3e40265decd9d7a96b45a7f3db Mon Sep 17 00:00:00 2001 From: ricoberger Date: Tue, 3 Aug 2021 20:07:51 +0200 Subject: [PATCH] Show logs in terminal The logs of a Pod can now be accessed via the actions. Therefore the logs tab was removed from the Pod details. When a user selects the logs action a modal with various options will be shown. When the user then clicks the "Show Logs" button the logs will be loaded and a new terminal with the retrieved log lines will be added. --- CHANGELOG.md | 1 + pkg/api/clusters/cluster/cluster.go | 35 +++- plugins/resources/package.json | 1 - plugins/resources/resources.go | 12 +- .../src/components/panel/details/Actions.tsx | 18 ++ .../src/components/panel/details/Details.tsx | 14 -- .../src/components/panel/details/Logs.tsx | 106 ---------- .../components/panel/details/LogsToolbar.tsx | 179 ----------------- .../components/panel/details/actions/Logs.tsx | 182 ++++++++++++++++++ yarn.lock | 15 -- 10 files changed, 242 insertions(+), 321 deletions(-) delete mode 100644 plugins/resources/src/components/panel/details/Logs.tsx delete mode 100644 plugins/resources/src/components/panel/details/LogsToolbar.tsx create mode 100644 plugins/resources/src/components/panel/details/actions/Logs.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index caee8cc6c..2c4bfa4ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,7 @@ NOTE: As semantic versioning states all 0.y.z releases can contain breaking chan - [#88](https://github.com/kobsio/kobs/pull/88): Improve handling of actions for Kubernetes resources. - [#95](https://github.com/kobsio/kobs/pull/95): It is now possible to get Kubernetes resources for all namespaces by not selecting a namespace from the select box on the resources page. - [#96](https://github.com/kobsio/kobs/pull/96): Add RSS plugin to show the latest status updates of third party services. +- [#101](https://github.com/kobsio/kobs/pull/101): Show logs in the terminal. ## [v0.4.0](https://github.com/kobsio/kobs/releases/tag/v0.4.0) (2021-07-14) diff --git a/pkg/api/clusters/cluster/cluster.go b/pkg/api/clusters/cluster/cluster.go index f973fa6a6..5ea9302e9 100644 --- a/pkg/api/clusters/cluster/cluster.go +++ b/pkg/api/clusters/cluster/cluster.go @@ -196,17 +196,44 @@ func (c *Cluster) CreateResource(ctx context.Context, namespace, name, path, res // GetLogs returns the logs for a Container. The Container is identified by the namespace and pod name and the container // name. Is is also possible to set the time since when the logs should be received and with the previous flag the logs // for the last container can be received. -func (c *Cluster) GetLogs(ctx context.Context, namespace, name, container string, since int64, previous bool) (string, error) { - res, err := c.clientset.CoreV1().Pods(namespace).GetLogs(name, &corev1.PodLogOptions{ +func (c *Cluster) GetLogs(ctx context.Context, namespace, name, container, regex string, since, tail int64, previous bool) (string, error) { + options := &corev1.PodLogOptions{ Container: container, SinceSeconds: &since, Previous: previous, - }).DoRaw(ctx) + } + + if tail > 0 { + options.TailLines = &tail + } + + res, err := c.clientset.CoreV1().Pods(namespace).GetLogs(name, options).DoRaw(ctx) if err != nil { return "", err } - return string(res), nil + if regex == "" { + var logs []string + for _, line := range strings.Split(string(res), "\n") { + logs = append(logs, line) + } + + return strings.Join(logs, "\n\r") + "\n\r", nil + } + + reg, err := regexp.Compile(regex) + if err != nil { + return "", err + } + + var logs []string + for _, line := range strings.Split(string(res), "\n") { + if reg.MatchString(line) { + logs = append(logs, line) + } + } + + return strings.Join(logs, "\n\r") + "\n\r", nil } // GetTerminal starts a new terminal session via the given WebSocket connection. diff --git a/plugins/resources/package.json b/plugins/resources/package.json index d03a50639..53c39a0bc 100644 --- a/plugins/resources/package.json +++ b/plugins/resources/package.json @@ -16,7 +16,6 @@ "@nivo/bar": "^0.73.1", "@patternfly/react-core": "^4.128.2", "@patternfly/react-icons": "^4.10.11", - "@patternfly/react-log-viewer": "^4.1.19", "@patternfly/react-table": "^4.27.24", "@types/react": "^17.0.0", "@types/react-dom": "^17.0.0", diff --git a/plugins/resources/resources.go b/plugins/resources/resources.go index 7cabcef2a..6b441cb79 100644 --- a/plugins/resources/resources.go +++ b/plugins/resources/resources.go @@ -281,10 +281,12 @@ func (router *Router) getLogs(w http.ResponseWriter, r *http.Request) { namespace := r.URL.Query().Get("namespace") name := r.URL.Query().Get("name") container := r.URL.Query().Get("container") + regex := r.URL.Query().Get("regex") since := r.URL.Query().Get("since") + tail := r.URL.Query().Get("tail") previous := r.URL.Query().Get("previous") - log.WithFields(logrus.Fields{"cluster": clusterName, "namespace": namespace, "name": name, "container": container, "since": since, "previous": previous}).Tracef("getLogs") + log.WithFields(logrus.Fields{"cluster": clusterName, "namespace": namespace, "name": name, "container": container, "regex": regex, "since": since, "previous": previous}).Tracef("getLogs") cluster := router.clusters.GetCluster(clusterName) if cluster == nil { @@ -298,13 +300,19 @@ func (router *Router) getLogs(w http.ResponseWriter, r *http.Request) { return } + parsedTail, err := strconv.ParseInt(tail, 10, 64) + if err != nil { + errresponse.Render(w, r, err, http.StatusBadRequest, "Could not parse tail parameter") + return + } + parsedPrevious, err := strconv.ParseBool(previous) if err != nil { errresponse.Render(w, r, err, http.StatusBadRequest, "Could not parse previous parameter") return } - logs, err := cluster.GetLogs(r.Context(), namespace, name, container, parsedSince, parsedPrevious) + logs, err := cluster.GetLogs(r.Context(), namespace, name, container, regex, parsedSince, parsedTail, parsedPrevious) if err != nil { errresponse.Render(w, r, err, http.StatusBadGateway, "Could not get logs") return diff --git a/plugins/resources/src/components/panel/details/Actions.tsx b/plugins/resources/src/components/panel/details/Actions.tsx index 40d0d03af..db73f92cc 100644 --- a/plugins/resources/src/components/panel/details/Actions.tsx +++ b/plugins/resources/src/components/panel/details/Actions.tsx @@ -8,6 +8,7 @@ import Delete from './actions/Delete'; import Edit from './actions/Edit'; import { IAlert } from '../../../utils/interfaces'; import { IResource } from '@kobsio/plugin-core'; +import Logs from './actions/Logs'; import Restart from './actions/Restart'; import Scale from './actions/Scale'; import Terminal from './actions/Terminal'; @@ -24,6 +25,7 @@ const Actions: React.FunctionComponent = ({ request, resource, ref const [showScale, setShowScale] = useState(false); const [showRestart, setShowRestart] = useState(false); const [showCreateJob, setShowCreateJob] = useState(false); + const [showLogs, setShowLogs] = useState(false); const [showTerminal, setShowTerminal] = useState(false); const [showCreateEphemeralContainer, setShowCreateEphemeralContainer] = useState(false); const [showEdit, setShowEdit] = useState(false); @@ -82,6 +84,20 @@ const Actions: React.FunctionComponent = ({ request, resource, ref ); } + if (request.resource === 'pods') { + dropdownItems.push( + { + setShowDropdown(false); + setShowLogs(true); + }} + > + Logs + , + ); + } + if (request.resource === 'pods') { dropdownItems.push( = ({ request, resource, ref refetch={refetch} /> + + = ({ request, resource, cl - {request.resource === 'pods' ? ( - Logs}> -
- -
-
- ) : null} - {podSelector || request.resource === 'nodes' ? ( Pods}>
diff --git a/plugins/resources/src/components/panel/details/Logs.tsx b/plugins/resources/src/components/panel/details/Logs.tsx deleted file mode 100644 index 18c2c4e9a..000000000 --- a/plugins/resources/src/components/panel/details/Logs.tsx +++ /dev/null @@ -1,106 +0,0 @@ -import { Card, CardBody } from '@patternfly/react-core'; -import React, { useEffect, useRef, useState } from 'react'; -import { LogViewer } from '@patternfly/react-log-viewer'; -import { V1Pod } from '@kubernetes/client-node'; -import { useQuery } from 'react-query'; - -import LogsToolbar, { IOptions } from './LogsToolbar'; - -// getContainers returns a list with all container names for the given Pod. It contains all specified init containers -// and the "normal" containers. -const getContainers = (pod: V1Pod): string[] => { - const containers: string[] = []; - - if (pod.spec?.initContainers) { - for (const container of pod.spec?.initContainers) { - containers.push(container.name); - } - } - - if (pod.spec?.containers) { - for (const container of pod.spec?.containers) { - containers.push(container.name); - } - } - - return containers; -}; - -interface ILogsProps { - cluster: string; - namespace: string; - name: string; - pod: V1Pod; -} - -// Logs is the component, which is used in the logs tab of an Pod. It allows a user to retrieve the logs for an -// Pod from the Kubernetes API. -const Logs: React.FunctionComponent = ({ cluster, namespace, name, pod }: ILogsProps) => { - const ref = useRef(null); - const [width, setWidth] = useState(0); - const [height, setHeight] = useState(0); - - const containers = getContainers(pod); - - // Initialize the states for the component. We do not set an inital container, to avoid the first request against the - // Kubernetes API to retrieve the logs. The user should select is options first and then trigger the API call via the - // search button. - const [options, setOptions] = useState({ - container: '', - containers: containers, - previous: false, - since: 3600, - }); - - const { isError, isLoading, error, data } = useQuery( - ['resources/logs', cluster, namespace, name, options.container, options.since, options.previous], - async () => { - try { - if (options.container !== '') { - const response = await fetch( - `/api/plugins/resources/logs?cluster=${cluster}&namespace=${namespace}&name=${name}&container=${options.container}&since=${options.since}&previous=${options.previous}`, - { method: 'get' }, - ); - const json = await response.json(); - - if (response.status >= 200 && response.status < 300) { - return json.logs; - } - - if (json.error) { - throw new Error(json.error); - } else { - throw new Error('An unknown error occured'); - } - } - } catch (err) { - throw err; - } - }, - ); - - useEffect(() => { - if (ref && ref.current) { - setWidth(ref.current.getBoundingClientRect().width); - setHeight(ref.current.getBoundingClientRect().height); - } - }, []); - - return ( - - -
- } - height={height} - width={width} - /> -
-
-
- ); -}; - -export default Logs; diff --git a/plugins/resources/src/components/panel/details/LogsToolbar.tsx b/plugins/resources/src/components/panel/details/LogsToolbar.tsx deleted file mode 100644 index 9d520ca3b..000000000 --- a/plugins/resources/src/components/panel/details/LogsToolbar.tsx +++ /dev/null @@ -1,179 +0,0 @@ -import { - Button, - ButtonVariant, - Checkbox, - Level, - LevelItem, - Modal, - ModalVariant, - Select, - SelectOption, - SelectOptionObject, - SelectVariant, - SimpleList, - SimpleListItem, - Toolbar, - ToolbarContent, - ToolbarGroup, - ToolbarItem, - ToolbarToggleGroup, -} from '@patternfly/react-core'; -import { FilterIcon, SearchIcon } from '@patternfly/react-icons'; -import React, { useState } from 'react'; -import { LogViewerSearch } from '@patternfly/react-log-viewer'; - -// ITimes is the interface for all available times, which can be used for the log lines. The keys are the seconds, which -// are used for the Kubernetes API request (since) and the value is a string, for the user. -interface ITimes { - [key: number]: string; -} - -// times is the variable, which implements the ITimes interface. These are the available options for the since parameter -// to get the logs. -const times: ITimes = { - 10800: 'Last 3 Hours', - 172800: 'Last 2 Days', - 1800: 'Last 30 Minutes', - 21600: 'Last 6 Hours', - 300: 'Last 5 Minutes', - 3600: 'Last 1 Hour', - 43200: 'Last 12 Hours', - 604800: 'Last 7 Days', - 86400: 'Last 1 Day', - 900: 'Last 15 Minutes', -}; - -export interface IOptions { - container: string; - containers: string[]; - previous: boolean; - since: number; -} - -interface ILogsToolbarProps { - isLoading: boolean; - options: IOptions; - setOptions: (options: IOptions) => void; -} - -// LogsToolbar is the toolbar, to set some options for the Kubernetes logs request. The user can provide a -// regular expression, can select a container and time since when the logs should be returned. Last but not least a user -// can also select if he wants to view the logs of the previous container. The previous and time option are shown in a -// modal similar to the one used by the plugins. -const LogsToolbar: React.FunctionComponent = ({ - isLoading, - options, - setOptions, -}: ILogsToolbarProps) => { - // The container is not set in the parent component, so that we have to set it from the containers option. This is - // done, because we want to avoid an API call to get the logs, when the user hasn't clicked the search button. - const [container, setContainer] = useState(options.containers[0]); - const [previous, setPrevious] = useState(options.previous); - const [since, setSince] = useState(options.since); - const [showContainer, setShowContainer] = useState(false); - const [showModal, setShowModal] = useState(false); - - // quick is the function called, when the user selects a time from the options modal. We change the time and then we - // close the modal. - const quick = (seconds: number): void => { - setSince(seconds); - setShowModal(false); - }; - - return ( - - - - } breakpoint="lg"> - - - - - - - - - - - - - - - - - - - setShowModal(false)} - > - - - - - - - quick(300)}>{times[300]} - quick(900)}>{times[900]} - quick(1800)}>{times[1800]} - quick(3600)}>{times[3600]} - quick(10800)}>{times[10800]} - - - - - quick(21600)}>{times[21600]} - quick(43200)}>{times[43200]} - quick(86400)}>{times[86400]} - quick(172800)}>{times[172800]} - quick(604800)}>{times[604800]} - - - - - - ); -}; - -export default LogsToolbar; diff --git a/plugins/resources/src/components/panel/details/actions/Logs.tsx b/plugins/resources/src/components/panel/details/actions/Logs.tsx new file mode 100644 index 000000000..378c86de7 --- /dev/null +++ b/plugins/resources/src/components/panel/details/actions/Logs.tsx @@ -0,0 +1,182 @@ +import { + Button, + ButtonVariant, + Checkbox, + Form, + FormGroup, + FormSelect, + FormSelectOption, + Modal, + ModalVariant, + TextInput, +} from '@patternfly/react-core'; +import React, { useContext, useState } from 'react'; +import { IRow } from '@patternfly/react-table'; +import { V1Pod } from '@kubernetes/client-node'; +import { Terminal as xTerm } from 'xterm'; + +import { IResource, ITerminalContext, TERMINAL_OPTIONS, TerminalsContext } from '@kobsio/plugin-core'; + +// getContainers returns a list with all container names for the given Pod. It contains all specified init containers +// and the "normal" containers. +const getContainers = (pod: V1Pod): string[] => { + const containers: string[] = []; + + if (pod.spec?.initContainers) { + for (const container of pod.spec?.initContainers) { + containers.push(container.name); + } + } + + if (pod.spec?.containers) { + for (const container of pod.spec?.containers) { + containers.push(container.name); + } + } + + if (pod.spec?.ephemeralContainers) { + for (const container of pod.spec?.ephemeralContainers) { + containers.push(container.name); + } + } + + return containers; +}; + +interface ILogsProps { + request: IResource; + resource: IRow; + show: boolean; + setShow: (value: boolean) => void; +} + +const Logs: React.FunctionComponent = ({ request, resource, show, setShow }: ILogsProps) => { + const containers = getContainers(resource.props); + + const terminalsContext = useContext(TerminalsContext); + const [isLoading, setIsLoading] = useState(false); + const [container, setContainer] = useState(containers[0]); + const [since, setSince] = useState(900); + const [regex, setRegex] = useState(''); + const [previous, setPrevious] = useState(false); + + const getLogs = async (): Promise => { + setIsLoading(true); + const term = new xTerm(TERMINAL_OPTIONS); + + try { + const response = await fetch( + `/api/plugins/resources/logs?cluster=${resource.cluster.title}${ + resource.namespace ? `&namespace=${resource.namespace.title}` : '' + }&name=${resource.name.title}&container=${container}®ex=${encodeURIComponent(regex)}&since=${since}&tail=${ + TERMINAL_OPTIONS.scrollback + }&previous=${previous}`, + { method: 'get' }, + ); + const json = await response.json(); + + if (response.status >= 200 && response.status < 300) { + term.write(`${json.logs}`); + terminalsContext.addTerminal({ + name: `${resource.namespace.title}: ${container} (logs)`, + terminal: term, + }); + setIsLoading(false); + setShow(false); + } else { + if (json.error) { + throw new Error(json.error); + } else { + throw new Error('An unknown error occured'); + } + } + } catch (err) { + if (err.message) { + term.write(`${err.message}\n\r`); + terminalsContext.addTerminal({ + name: `${resource.namespace.title}: ${container} (logs)`, + terminal: term, + }); + } + setIsLoading(false); + setShow(false); + } + }; + + return ( + setShow(false)} + actions={[ + , + , + ]} + > +
+ + setContainer(value)} + id="logs-form-container" + name="logs-form-container" + aria-label="Container" + > + {containers.map((container, index) => ( + + ))} + + + + + setSince(parseInt(value))} + id="logs-form-since" + name="logs-form-since" + aria-label="Since" + > + + + + + + + + + + + + + + + setRegex(value)} + /> + + + + + +
+
+ ); +}; + +export default Logs; diff --git a/yarn.lock b/yarn.lock index fe2def0ab..94f333055 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2519,16 +2519,6 @@ resolved "https://registry.yarnpkg.com/@patternfly/react-icons/-/react-icons-4.11.0.tgz#26790eeff22dc3204aa8cd094470f0a2f915634a" integrity sha512-WsIX34bO9rhVRmPG0jlV3GoFGfYgPC64TscNV0lxQosiVRnYIA6Z3nBSArtJsxo5Yn6c63glIefC/YTy6D/ZYg== -"@patternfly/react-log-viewer@^4.1.19": - version "4.2.0" - resolved "https://registry.yarnpkg.com/@patternfly/react-log-viewer/-/react-log-viewer-4.2.0.tgz#a0fd0cbd31ba09e2599d9b4395927d28bbc4d287" - integrity sha512-49sICDwV5fOAcsUZpqFWVdR+/k5ZMWXmrWCshLh531H5BZxAprNTmIN+KHCEmipouTMBC528WvVYtGgPjsvD1g== - dependencies: - "@patternfly/react-core" "^4.135.0" - "@patternfly/react-icons" "^4.11.0" - "@patternfly/react-styles" "^4.11.0" - memoize-one "^5.1.0" - "@patternfly/react-styles@^4.11.0": version "4.11.0" resolved "https://registry.yarnpkg.com/@patternfly/react-styles/-/react-styles-4.11.0.tgz#0068dcb18e1343242f93fa6024dcc077acd57fb9" @@ -9633,11 +9623,6 @@ media-typer@0.3.0: resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" integrity sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g= -memoize-one@^5.1.0: - version "5.2.1" - resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-5.2.1.tgz#8337aa3c4335581839ec01c3d594090cebe8f00e" - integrity sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q== - memory-fs@^0.4.1: version "0.4.1" resolved "https://registry.yarnpkg.com/memory-fs/-/memory-fs-0.4.1.tgz#3a9a20b8462523e447cfbc7e8bb80ed667bfc552"