@jfvilas/plugin-kwirth-log
Version:
Frontend plugin for viewing real-time Kubernetes logs in Backstage
302 lines (299 loc) • 17.5 kB
JavaScript
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