UNPKG

@jfvilas/plugin-kwirth-log

Version:

Frontend plugin for viewing real-time Kubernetes logs in Backstage

302 lines (299 loc) 17.5 kB
import React, { useState, useRef } from 'react'; import useAsync from 'react-use/esm/useAsync'; import { Progress, WarningPanel } from '@backstage/core-components'; import { useApi } from '@backstage/core-plugin-api'; import { isKwirthAvailable, ANNOTATION_KWIRTH_LOCATION } from '@jfvilas/plugin-kwirth-common'; import { useEntity, MissingAnnotationEmptyState } from '@backstage/plugin-catalog-react'; import { kwirthLogApiRef } from '../../api/types.esm.js'; import { SignalMessageLevelEnum, InstanceConfigScopeEnum, InstanceMessageTypeEnum, InstanceConfigChannelEnum, InstanceConfigViewEnum, accessKeySerialize, InstanceConfigFlowEnum, InstanceConfigActionEnum, InstanceConfigObjectEnum } from '@jfvilas/kwirth-common'; import { ComponentNotFound, ErrorType } from '../ComponentNotFound/ComponentNotFound.esm.js'; import { Options } from '../Options/Options.esm.js'; import { ClusterList } from '../ClusterList/ClusterList.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 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 KwirthLogLogo from '../../assets/kwirthlog-logo.svg'; import { ObjectSelector } from '../ObjectSelector/ObjectSelector.esm.js'; const LOG_MAX_MESSAGES = 1e3; const EntityKwirthLogContent = () => { const { entity } = useEntity(); const kwirthLogApi = useApi(kwirthLogApiRef); const [resources, setResources] = useState([]); const [selectedClusterName, setSelectedClusterName] = useState(""); const [_namespaceList, setNamespaceList] = useState([]); const [selectedNamespaces, setSelectedNamespaces] = useState([]); const [selectedPodNames, setSelectedPodNames] = useState([]); const [selectedContainerNames, setSelectedContainerNames] = 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 kwirthLogOptionsRef = useRef({ timestamp: 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 kwirthLogApi.getVersion()); var data = await kwirthLogApi.requestAccess(entity, "log", [InstanceConfigScopeEnum.VIEW, InstanceConfigScopeEnum.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 onSelectCluster = (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); }); setMessages([{ channel: InstanceConfigChannelEnum.LOG, type: InstanceMessageTypeEnum.SIGNAL, text: "Select namespace in order to decide which pod logs to view.", instance: "" }]); setSelectedNamespaces([]); setSelectedPodNames([]); setSelectedContainerNames([]); 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 (kwirthLogOptionsRef.current.follow && lastRef.current) lastRef.current.scrollIntoView({ behavior: "instant", block: "start" }); return [...prev, lmsg]; }); } break; case "signal": 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 websocketOnMessage = (wsEvent) => { let instanceMessage; try { instanceMessage = JSON.parse(wsEvent.data); } catch (err) { console.log(err); console.log(wsEvent.data); return; } switch (instanceMessage.channel) { case "log": processLogMessage(wsEvent); break; default: console.log("Invalid channel in message: ", instanceMessage); break; } }; const websocketOnOpen = (ws, options) => { let cluster = resources.find((cluster2) => cluster2.name === selectedClusterName); if (!cluster) { return; } let pods = cluster.data.filter((p2) => selectedNamespaces.includes(p2.namespace)); if (!pods) { return; } console.log(`WS connected`); let accessKey = cluster.accessKeys.get(InstanceConfigScopeEnum.VIEW); if (accessKey) { let containers = []; if (selectedContainerNames.length > 0) { for (var p of selectedPodNames) { for (var c of selectedContainerNames) { containers.push(p + "+" + c); } } } let iConfig = { channel: InstanceConfigChannelEnum.LOG, objects: InstanceConfigObjectEnum.PODS, action: InstanceConfigActionEnum.START, flow: InstanceConfigFlowEnum.REQUEST, instance: "", accessKey: accessKeySerialize(accessKey), scope: InstanceConfigScopeEnum.VIEW, view: selectedContainerNames.length > 0 ? InstanceConfigViewEnum.CONTAINER : InstanceConfigViewEnum.POD, namespace: selectedNamespaces.join(","), group: "", pod: selectedPodNames.map((p2) => p2).join(","), container: containers.join(","), data: { timestamp: options.timestamp, previous: false, maxMessages: LOG_MAX_MESSAGES, fromStart: options.fromStart } }; ws.send(JSON.stringify(iConfig)); } }; const startLogViewer = (options) => { let cluster = resources.find((cluster2) => cluster2.name === selectedClusterName); if (!cluster) { return; } setMessages([]); try { let ws = new WebSocket(cluster.url); ws.onopen = () => websocketOnOpen(ws, options); ws.onmessage = (event) => websocketOnMessage(event); ws.onclose = (event) => websocketOnClose(event); setWebsocket(ws); } 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 onChangeLogConfig = (options) => { kwirthLogOptionsRef.current = options; if (started) { clickStart(options); } }; const handleDownload = () => { let content = preRef.current.innerHTML.replaceAll("<pre>", "").replaceAll("</pre>", "\n"); content = content.replaceAll('<span style="color: green;">', ""); content = content.replaceAll('<span style="color: blue;">', ""); content = content.replaceAll("</span>", ""); let filename = selectedClusterName + "-" + selectedNamespaces + "-" + 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) hasViewKey = Boolean(cluster.accessKeys.get(InstanceConfigScopeEnum.VIEW)); 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(kwirthLogOptionsRef.current), title: "Play", disabled: started || !paused || selectedPodNames.length === 0 || !hasViewKey }, /* @__PURE__ */ React.createElement(PlayIcon, null)), /* @__PURE__ */ React.createElement(IconButton, { onClick: clickPause, title: "Pause", disabled: !(started && !paused.current && selectedPodNames.length > 0) }, /* @__PURE__ */ React.createElement(PauseIcon, null)), /* @__PURE__ */ React.createElement(IconButton, { onClick: clickStop, title: "Stop", disabled: stopped || selectedPodNames.length === 0 }, /* @__PURE__ */ React.createElement(StopIcon, null))); }; const statusButtons = (title) => { const show = (level) => { setShowStatusDialog(true); setStatusLevel(level); }; 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" } }, /* @__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); }; const onSelectObject = (namespaces, podNames, containerNames) => { setSelectedNamespaces(namespaces); setSelectedPodNames(podNames); setSelectedContainerNames(containerNames); }; const formatMessage = (m) => { if (!m.pod) { return /* @__PURE__ */ React.createElement(React.Fragment, null, m.text + "\n"); } let podPrefix = /* @__PURE__ */ React.createElement(React.Fragment, null); if (selectedPodNames.length !== 1) { podPrefix = /* @__PURE__ */ React.createElement("span", { style: { color: "green" } }, m.pod + " "); } let containerPrefix = /* @__PURE__ */ React.createElement(React.Fragment, null); if (selectedContainerNames.length !== 1) { containerPrefix = /* @__PURE__ */ React.createElement("span", { style: { color: "blue" } }, m.container + " "); } return /* @__PURE__ */ React.createElement(React.Fragment, null, podPrefix, containerPrefix, m.text + "\n"); }; return /* @__PURE__ */ React.createElement(React.Fragment, null, showError !== "" && /* @__PURE__ */ React.createElement(ShowError, { message: showError, onClose: () => setShowError("") }), loading && /* @__PURE__ */ React.createElement(Progress, null), !isKwirthAvailable(entity) && !loading && error && /* @__PURE__ */ React.createElement(WarningPanel, { title: "An error has ocurred while obtaining data from kuebernetes clusters.", message: error?.message }), !isKwirthAvailable(entity) && !loading && /* @__PURE__ */ React.createElement(MissingAnnotationEmptyState, { readMoreUrl: "https://github.com/jfvilas/plugin-kwirth-log", annotation: ANNOTATION_KWIRTH_LOCATION }), isKwirthAvailable(entity) && !loading && resources && resources.length === 0 && /* @__PURE__ */ React.createElement(ComponentNotFound, { error: ErrorType.NO_CLUSTERS, entity }), isKwirthAvailable(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 }), isKwirthAvailable(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, direction: "column", spacing: 3 }, /* @__PURE__ */ React.createElement(Grid, { item: true }, /* @__PURE__ */ React.createElement(Card, null, /* @__PURE__ */ React.createElement(ClusterList, { resources, selectedClusterName, onSelect: onSelectCluster }))), /* @__PURE__ */ React.createElement(Grid, { item: true }, /* @__PURE__ */ React.createElement(Card, null, /* @__PURE__ */ React.createElement(Options, { options: kwirthLogOptionsRef.current, onChange: onChangeLogConfig, disabled: selectedContainerNames.length === 0 || started || paused.current })))), /* @__PURE__ */ React.createElement(Grid, { item: true, xs: 10 }, !selectedClusterName && /* @__PURE__ */ React.createElement("img", { src: KwirthLogLogo, 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: "75vh" } }, /* @__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(ObjectSelector, { cluster: resources.find((cluster) => cluster.name === selectedClusterName), onSelect: onSelectObject, disabled: selectedClusterName === "" || started || paused.current, selectedNamespaces, selectedPodNames, selectedContainerNames })), /* @__PURE__ */ React.createElement(Divider, null), /* @__PURE__ */ React.createElement(CardContent, { style: { overflow: "auto" } }, /* @__PURE__ */ React.createElement("pre", { ref: preRef }, messages.map((m) => formatMessage(m))), /* @__PURE__ */ React.createElement("span", { ref: lastRef })))))), showStatusDialog && /* @__PURE__ */ React.createElement(StatusLog, { level: statusLevel, onClose: () => setShowStatusDialog(false), statusMessages, onClear: statusClear })); }; export { EntityKwirthLogContent }; //# sourceMappingURL=EntityKwirthLogContent.esm.js.map