@jfvilas/plugin-kubelog
Version:
Frontend plugin for viewing Kubernetes logs in Backstage
320 lines (317 loc) • 17.7 kB
JavaScript
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 { ESignalMessageLevel, EInstanceMessageType, versionGreatOrEqualThan, accessKeySerialize, EInstanceConfigObject, EInstanceConfigView, InstanceConfigScopeEnum, EInstanceMessageChannel, EInstanceMessageFlow, EInstanceMessageAction } 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 { 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 [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(ESignalMessageLevel.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 buffer = useRef("");
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([{
type: EInstanceMessageType.SIGNAL,
text: "Select namespace in order to decide which pod logs to view.",
namespace: "",
pod: "",
container: ""
}]);
setStatusMessages([]);
clickStop();
}
};
const selectNamespace = (ns) => {
if (selectedNamespace !== ns) {
setSelectedNamespace(ns);
setMessages([{
type: EInstanceMessageType.SIGNAL,
text: "Press PLAY on top-right button to start viewing your log.",
namespace: "",
pod: "",
container: ""
}]);
setStatusMessages([]);
clickStop();
}
};
const processLogMessage = (wsEvent) => {
let instanceMessage = JSON.parse(wsEvent.data);
switch (instanceMessage.type) {
case EInstanceMessageType.DATA:
let logMessage = instanceMessage;
let text = logMessage.text;
if (buffer.current !== "") {
text = buffer.current + text;
buffer.current = "";
}
if (!text.endsWith("\n")) {
let i = text.lastIndexOf("\n");
let next = text.substring(i);
buffer.current = next;
text = text.substring(0, i);
}
for (let line of text.split("\n")) {
if (line.trim() === "") continue;
let logLine = {
text: line,
namespace: logMessage.namespace,
pod: logMessage.pod,
container: logMessage.container,
type: logMessage.type
};
if (paused.current) {
setPendingMessages((prev) => [...prev, logLine]);
} 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, logLine];
});
}
}
break;
case EInstanceMessageType.SIGNAL:
let signalMessage = instanceMessage;
setStatusMessages((prev) => [...prev, signalMessage]);
break;
default:
console.log("Invalid message type:");
console.log(instanceMessage);
setStatusMessages((prev) => [...prev, {
channel: EInstanceMessageChannel.LOG,
type: EInstanceMessageType.SIGNAL,
level: ESignalMessageLevel.ERROR,
text: "Invalid message type received: " + instanceMessage.type,
instance: "",
action: EInstanceMessageAction.NONE,
flow: EInstanceMessageFlow.UNSOLICITED
}]);
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 EInstanceMessageChannel.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: EInstanceMessageAction.START,
flow: EInstanceMessageFlow.REQUEST,
channel: EInstanceMessageChannel.LOG,
instance: "",
accessKey: accessKeySerialize(pod.accessKey || pod.viewAccessKey),
scope: InstanceConfigScopeEnum.VIEW,
view: EInstanceConfigView.POD,
namespace: selectedNamespace,
group: "",
pod: pod.name,
container: "",
data: {
timestamp: options.timestamp,
previous: options.previous,
maxMessages: LOG_MAX_MESSAGES,
fromStart: options.fromStart
},
objects: EInstanceConfigObject.PODS,
type: EInstanceMessageType.SIGNAL
};
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([{
type: EInstanceMessageType.SIGNAL,
text: `Error opening log stream: ${err}`,
namespace: "",
pod: "",
container: ""
}]);
}
};
const websocketOnClose = (_event) => {
console.log(`WS disconnected`);
setStarted(false);
paused.current = false;
setStopped(true);
};
const stopLogViewer = () => {
messages.push({
type: EInstanceMessageType.SIGNAL,
text: "============================================================================================================================",
namespace: "",
pod: "",
container: ""
});
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 === EInstanceMessageType.SIGNAL && m.level === ESignalMessageLevel.INFO), onClick: () => show(ESignalMessageLevel.INFO) }, /* @__PURE__ */ React.createElement(InfoIcon, { style: { color: statusMessages.some((m) => m.type === EInstanceMessageType.SIGNAL && m.level === ESignalMessageLevel.INFO) ? "#1D63ED" : "#BDBDBD" } })), /* @__PURE__ */ React.createElement(IconButton, { title: "warning", disabled: !statusMessages.some((m) => m.type === EInstanceMessageType.SIGNAL && m.level === ESignalMessageLevel.WARNING), onClick: () => show(ESignalMessageLevel.WARNING), style: { marginLeft: "-16px" } }, /* @__PURE__ */ React.createElement(WarningIcon, { style: { color: statusMessages.some((m) => m.type === EInstanceMessageType.SIGNAL && m.level === ESignalMessageLevel.WARNING) ? "gold" : "#BDBDBD" } })), /* @__PURE__ */ React.createElement(IconButton, { title: "error", disabled: !statusMessages.some((m) => m.type === EInstanceMessageType.SIGNAL && m.level === ESignalMessageLevel.ERROR), onClick: () => show(ESignalMessageLevel.ERROR), style: { marginLeft: "-16px" } }, /* @__PURE__ */ React.createElement(ErrorIcon, { style: { color: statusMessages.some((m) => m.type === EInstanceMessageType.SIGNAL && m.level === ESignalMessageLevel.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, 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