UNPKG

@jfvilas/plugin-kubelog

Version:

Frontend plugin for viewing Kubernetes logs in Backstage

300 lines (297 loc) 17.1 kB
import React, { useState, useRef } from 'react'; import useAsync from 'react-use/esm/useAsync'; import { Content, Progress, WarningPanel } from '@backstage/core-components'; import { useApi } from '@backstage/core-plugin-api'; import { isKubelogAvailable, ANNOTATION_KUBELOG_LOCATION } from '@jfvilas/plugin-kubelog-common'; import { useEntity, MissingAnnotationEmptyState } from '@backstage/plugin-catalog-react'; import { kubelogApiRef } from '../../api/types.esm.js'; import { SignalMessageLevelEnum, InstanceMessageTypeEnum, InstanceConfigChannelEnum, versionGreatOrEqualThan, accessKeySerialize, InstanceConfigObjectEnum, InstanceConfigViewEnum, InstanceConfigScopeEnum, InstanceConfigFlowEnum, InstanceConfigActionEnum } from '@jfvilas/kwirth-common'; import { ComponentNotFound, ErrorType } from '../ComponentNotFound/ComponentNotFound.esm.js'; import { KubelogOptions } from '../KubelogOptions/KubelogOptions.esm.js'; import { KubelogClusterList } from '../KubelogClusterList/KubelogClusterList.esm.js'; import { NamespaceChips } from '../NamespaceChips/NamespaceChips.esm.js'; import { ShowError } from '../ShowError/ShowError.esm.js'; import { StatusLog } from '../StatusLog/StatusLog.esm.js'; import { Grid, Card, CardHeader, CardContent } from '@material-ui/core'; import Divider from '@material-ui/core/Divider'; import IconButton from '@material-ui/core/IconButton'; import Typography from '@material-ui/core/Typography'; import PlayIcon from '@material-ui/icons/PlayArrow'; import PauseIcon from '@material-ui/icons/Pause'; import StopIcon from '@material-ui/icons/Stop'; import RefreshIcon from '@material-ui/icons/Refresh'; import InfoIcon from '@material-ui/icons/Info'; import WarningIcon from '@material-ui/icons/Warning'; import ErrorIcon from '@material-ui/icons/Error'; import DownloadIcon from '@material-ui/icons/CloudDownload'; import KubelogLogo from '../../assets/kubelog-logo.svg'; const LOG_MAX_MESSAGES = 1e3; const EntityKubelogContent = () => { const { entity } = useEntity(); const kubelogApi = useApi(kubelogApiRef); const [resources, setResources] = useState([]); const [selectedClusterName, setSelectedClusterName] = useState(""); const [namespaceList, setNamespaceList] = useState([]); const [selectedNamespace, setSelectedNamespace] = useState(""); const [showError, setShowError] = useState(""); const [started, setStarted] = useState(false); const [stopped, setStopped] = useState(true); const paused = useRef(false); const [messages, setMessages] = useState([]); const [pendingMessages, setPendingMessages] = useState([]); const [statusMessages, setStatusMessages] = useState([]); const [websocket, setWebsocket] = useState(); const kubelogOptionsRef = useRef({ timestamp: false, previous: false, follow: true, fromStart: false }); const [showStatusDialog, setShowStatusDialog] = useState(false); const [statusLevel, setStatusLevel] = useState(SignalMessageLevelEnum.INFO); const preRef = useRef(null); const lastRef = useRef(null); const [backendVersion, setBackendVersion] = useState(""); const { loading, error } = useAsync(async () => { if (backendVersion === "") setBackendVersion(await kubelogApi.getVersion()); var data = await kubelogApi.requestAccess(entity, ["view", "restart"]); setResources(data); }); const clickStart = (options) => { if (!paused.current) { setStarted(true); paused.current = false; setStopped(false); startLogViewer(options); } else { setMessages((prev) => [...prev, ...pendingMessages]); setPendingMessages([]); paused.current = false; setStarted(true); } }; const clickPause = () => { setStarted(false); paused.current = true; }; const clickStop = () => { setStarted(false); setStopped(true); paused.current = false; stopLogViewer(); }; const selectCluster = (name) => { if (name) { setSelectedClusterName(name); resources.filter((cluster) => cluster.name === name).map((x) => { var namespaces = Array.from(new Set(x.data.map((p) => p.namespace))); setNamespaceList(namespaces); }); setSelectedNamespace(""); setMessages([{ channel: InstanceConfigChannelEnum.LOG, type: InstanceMessageTypeEnum.SIGNAL, text: "Select namespace in order to decide which pod logs to view.", instance: "" }]); setStatusMessages([]); clickStop(); } }; const selectNamespace = (ns) => { if (selectedNamespace !== ns) { setSelectedNamespace(ns); setMessages([{ channel: InstanceConfigChannelEnum.LOG, type: InstanceMessageTypeEnum.SIGNAL, text: "Press PLAY on top-right button to start viewing your log.", instance: "" }]); setStatusMessages([]); clickStop(); } }; const processLogMessage = (wsEvent) => { let msg = JSON.parse(wsEvent.data); switch (msg.type) { case "data": var lmsg = msg; if (paused.current) { setPendingMessages((prev) => [...prev, lmsg]); } else { setMessages((prev) => { while (prev.length > LOG_MAX_MESSAGES - 1) { prev.splice(0, 1); } if (kubelogOptionsRef.current.follow && lastRef.current) lastRef.current.scrollIntoView({ behavior: "instant", block: "start" }); return [...prev, lmsg]; }); } break; case "signal": console.log(msg); let smsg = msg; setStatusMessages((prev) => [...prev, smsg]); break; default: console.log("Invalid message type:"); console.log(msg); setStatusMessages((prev) => [...prev, { channel: InstanceConfigChannelEnum.LOG, type: InstanceMessageTypeEnum.SIGNAL, level: SignalMessageLevelEnum.ERROR, text: "Invalid message type received: " + msg.type, instance: "" }]); break; } }; const websocketOnChunk = (wsEvent) => { let serviceMessage; try { serviceMessage = JSON.parse(wsEvent.data); } catch (err) { console.log(err); console.log(wsEvent.data); return; } switch (serviceMessage.channel) { case "log": processLogMessage(wsEvent); break; default: console.log("Invalid channel in message: ", serviceMessage); break; } }; const websocketOnOpen = (ws, options) => { let cluster = resources.find((cluster2) => cluster2.name === selectedClusterName); if (!cluster) { return; } let pod = cluster.data.find((p) => p.namespace === selectedNamespace); if (!pod) { return; } console.log(`WS connected`); let iConfig = { action: InstanceConfigActionEnum.START, flow: InstanceConfigFlowEnum.REQUEST, channel: InstanceConfigChannelEnum.LOG, instance: "", accessKey: accessKeySerialize(pod.accessKey || pod.viewAccessKey), scope: InstanceConfigScopeEnum.VIEW, view: InstanceConfigViewEnum.POD, namespace: selectedNamespace, group: "", pod: pod.name, container: "", data: { timestamp: options.timestamp, previous: options.previous, maxMessages: LOG_MAX_MESSAGES, fromStart: options.fromStart }, objects: InstanceConfigObjectEnum.PODS }; ws.send(JSON.stringify(iConfig)); }; const startLogViewer = (options) => { let cluster = resources.find((cluster2) => cluster2.name === selectedClusterName); if (!cluster) { return; } try { let ws = new WebSocket(cluster.url); ws.onopen = () => websocketOnOpen(ws, options); ws.onmessage = (event) => websocketOnChunk(event); ws.onclose = (event) => websocketOnClose(event); setWebsocket(ws); setMessages([]); } catch (err) { setMessages([{ channel: InstanceConfigChannelEnum.LOG, type: InstanceMessageTypeEnum.DATA, text: `Error opening log stream: ${err}`, instance: "" }]); } }; const websocketOnClose = (_event) => { console.log(`WS disconnected`); setStarted(false); paused.current = false; setStopped(true); }; const stopLogViewer = () => { messages.push({ channel: InstanceConfigChannelEnum.LOG, type: InstanceMessageTypeEnum.DATA, text: "============================================================================================================================", instance: "" }); websocket?.close(); }; const changeLogConfig = (options) => { kubelogOptionsRef.current = options; if (started) { clickStart(options); } }; const handleDownload = () => { let content = preRef.current.innerHTML.replaceAll("<pre>", "").replaceAll("</pre>", "\n"); let filename = selectedClusterName + "-" + selectedNamespace + "-" + entity.metadata.name + ".txt"; let mimeType = "text/plain"; const blob = new Blob([content], { type: mimeType }); const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = filename; document.body.appendChild(link); link.click(); document.body.removeChild(link); URL.revokeObjectURL(url); }; const actionButtons = () => { let hasViewKey = false; let cluster = resources.find((cluster2) => cluster2.name === selectedClusterName); if (cluster) { var podData = cluster.data.find((p) => p.namespace === selectedNamespace); hasViewKey = Boolean(podData?.viewAccessKey); } return /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement(IconButton, { title: "Download", onClick: handleDownload, disabled: messages.length <= 1 }, /* @__PURE__ */ React.createElement(DownloadIcon, null)), /* @__PURE__ */ React.createElement(IconButton, { onClick: () => clickStart(kubelogOptionsRef.current), title: "Play", disabled: started || !paused || selectedNamespace === "" || !hasViewKey }, /* @__PURE__ */ React.createElement(PlayIcon, null)), /* @__PURE__ */ React.createElement(IconButton, { onClick: clickPause, title: "Pause", disabled: !(started && !paused.current && selectedNamespace !== "") }, /* @__PURE__ */ React.createElement(PauseIcon, null)), /* @__PURE__ */ React.createElement(IconButton, { onClick: clickStop, title: "Stop", disabled: stopped || selectedNamespace === "" }, /* @__PURE__ */ React.createElement(StopIcon, null))); }; const restartPod = () => { let cluster = resources.find((cluster2) => cluster2.name === selectedClusterName); if (cluster) { let pod = cluster.data.find((p) => p.namespace === selectedNamespace); let url = cluster.url + `/managecluster/restartpod/${pod?.namespace}/${pod?.name}`; let fetchOptions = { method: "POST", headers: { Authorization: "Bearer " + accessKeySerialize(pod?.restartAccessKey), "Content-Type": "application/json" } }; fetch(url, fetchOptions); } }; const statusButtons = (title) => { const show = (level) => { setShowStatusDialog(true); setStatusLevel(level); }; let cluster = resources.find((cluster2) => cluster2.name === selectedClusterName); let existsRestartAccessKey = cluster?.data.some((p) => p.namespace === selectedNamespace && p.restartAccessKey); return /* @__PURE__ */ React.createElement(Grid, { container: true, direction: "row" }, /* @__PURE__ */ React.createElement(Grid, { item: true }, /* @__PURE__ */ React.createElement(Typography, { variant: "h5" }, title)), /* @__PURE__ */ React.createElement(Grid, { item: true, style: { marginTop: "-8px" } }, versionGreatOrEqualThan(backendVersion, "0.9.0") && /* @__PURE__ */ React.createElement(IconButton, { title: "Restart pod", disabled: !existsRestartAccessKey, onClick: restartPod }, /* @__PURE__ */ React.createElement(RefreshIcon, null)), /* @__PURE__ */ React.createElement(IconButton, { title: "info", disabled: !statusMessages.some((m) => m.type === InstanceMessageTypeEnum.SIGNAL && m.level === SignalMessageLevelEnum.INFO), onClick: () => show(SignalMessageLevelEnum.INFO) }, /* @__PURE__ */ React.createElement(InfoIcon, { style: { color: statusMessages.some((m) => m.type === InstanceMessageTypeEnum.SIGNAL && m.level === SignalMessageLevelEnum.INFO) ? "blue" : "#BDBDBD" } })), /* @__PURE__ */ React.createElement(IconButton, { title: "warning", disabled: !statusMessages.some((m) => m.type === InstanceMessageTypeEnum.SIGNAL && m.level === SignalMessageLevelEnum.WARNING), onClick: () => show(SignalMessageLevelEnum.WARNING), style: { marginLeft: "-16px" } }, /* @__PURE__ */ React.createElement(WarningIcon, { style: { color: statusMessages.some((m) => m.type === InstanceMessageTypeEnum.SIGNAL && m.level === SignalMessageLevelEnum.WARNING) ? "gold" : "#BDBDBD" } })), /* @__PURE__ */ React.createElement(IconButton, { title: "error", disabled: !statusMessages.some((m) => m.type === InstanceMessageTypeEnum.SIGNAL && m.level === SignalMessageLevelEnum.ERROR), onClick: () => show(SignalMessageLevelEnum.ERROR), style: { marginLeft: "-16px" } }, /* @__PURE__ */ React.createElement(ErrorIcon, { style: { color: statusMessages.some((m) => m.type === InstanceMessageTypeEnum.SIGNAL && m.level === SignalMessageLevelEnum.ERROR) ? "red" : "#BDBDBD" } })))); }; const statusClear = (level) => { setStatusMessages(statusMessages.filter((m) => m.level !== level)); setShowStatusDialog(false); }; return /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement(Content, null, showError !== "" && /* @__PURE__ */ React.createElement(ShowError, { message: showError, onClose: () => setShowError("") }), loading && /* @__PURE__ */ React.createElement(Progress, null), !isKubelogAvailable(entity) && !loading && error && /* @__PURE__ */ React.createElement(WarningPanel, { title: "An error has ocurred while obtaining data from kuebernetes clusters.", message: error?.message }), !isKubelogAvailable(entity) && !loading && /* @__PURE__ */ React.createElement(MissingAnnotationEmptyState, { readMoreUrl: "https://github.com/jfvilas/kubelog", annotation: ANNOTATION_KUBELOG_LOCATION }), isKubelogAvailable(entity) && !loading && resources && resources.length === 0 && /* @__PURE__ */ React.createElement(ComponentNotFound, { error: ErrorType.NO_CLUSTERS, entity }), isKubelogAvailable(entity) && !loading && resources && resources.length > 0 && resources.reduce((sum, cluster) => sum + cluster.data.length, 0) === 0 && /* @__PURE__ */ React.createElement(ComponentNotFound, { error: ErrorType.NO_PODS, entity }), isKubelogAvailable(entity) && !loading && resources && resources.length > 0 && resources.reduce((sum, cluster) => sum + cluster.data.length, 0) > 0 && /* @__PURE__ */ React.createElement(Grid, { container: true, direction: "row", spacing: 3 }, /* @__PURE__ */ React.createElement(Grid, { container: true, item: true, xs: 2 }, /* @__PURE__ */ React.createElement(Grid, { container: true, direction: "column", spacing: 3 }, /* @__PURE__ */ React.createElement(Grid, { item: true }, /* @__PURE__ */ React.createElement(Card, null, /* @__PURE__ */ React.createElement(KubelogClusterList, { resources, selectedClusterName, onSelect: selectCluster }))), /* @__PURE__ */ React.createElement(Grid, { item: true }, /* @__PURE__ */ React.createElement(Card, null, /* @__PURE__ */ React.createElement(KubelogOptions, { options: kubelogOptionsRef.current, onChange: changeLogConfig, disabled: selectedNamespace === "" || paused.current }))))), /* @__PURE__ */ React.createElement(Grid, { item: true, xs: 10, style: { marginTop: -8 } }, !selectedClusterName && /* @__PURE__ */ React.createElement("img", { src: KubelogLogo, alt: "No cluster selected", style: { left: "40%", marginTop: "10%", width: "20%", position: "relative" } }), selectedClusterName && /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement(Card, { style: { maxHeight: "70vh" } }, /* @__PURE__ */ React.createElement( CardHeader, { title: statusButtons(selectedClusterName), style: { marginTop: -4, marginBottom: 4, flexShrink: 0 }, action: actionButtons() } ), /* @__PURE__ */ React.createElement(Typography, { style: { marginLeft: 16, marginBottom: 4 } }, /* @__PURE__ */ React.createElement(NamespaceChips, { namespaceList, onSelect: selectNamespace, resources, selectedClusterName, selectedNamespace })), /* @__PURE__ */ React.createElement(Divider, null), /* @__PURE__ */ React.createElement(CardContent, { style: { overflow: "auto" } }, /* @__PURE__ */ React.createElement("pre", { ref: preRef }, messages.map((m) => m.text + "\n")), /* @__PURE__ */ React.createElement("span", { ref: lastRef }))))))), showStatusDialog && /* @__PURE__ */ React.createElement(StatusLog, { level: statusLevel, onClose: () => setShowStatusDialog(false), statusMessages, onClear: statusClear })); }; export { EntityKubelogContent }; //# sourceMappingURL=EntityKubelogContent.esm.js.map