@parseable/backstage-plugin-logstream
Version:
Backstage plugin for integrating with Parseable log streams
691 lines (688 loc) • 26 kB
JavaScript
import { jsx, jsxs } from 'react/jsx-runtime';
import { useState, useEffect, useCallback } from 'react';
import { useApi } from '@backstage/core-plugin-api';
import { useEntity } from '@backstage/plugin-catalog-react';
import { Progress, Content, ContentHeader, ErrorPanel, EmptyState, SupportButton, InfoCard, Table } from '@backstage/core-components';
import { makeStyles, Grid, Typography, TextField, FormControl, InputLabel, Select, MenuItem, Button, Tooltip, IconButton, Dialog, DialogTitle, DialogContent, DialogActions } from '@material-ui/core';
import CloseIcon from '@material-ui/icons/Close';
import SearchIcon from '@material-ui/icons/Search';
import PlayArrowIcon from '@material-ui/icons/PlayArrow';
import PauseIcon from '@material-ui/icons/Pause';
import GetAppIcon from '@material-ui/icons/GetApp';
import CheckIcon from '@material-ui/icons/Check';
import { useAsync } from 'react-use';
import { p as parseableApiRef } from './index-75099be5.esm.js';
import 'zod';
import '@material-ui/icons/FileCopy';
const useStyles = makeStyles((theme) => ({
root: {
display: "flex",
flexDirection: "column",
height: "100%"
},
controls: {
display: "flex",
flexWrap: "wrap",
gap: theme.spacing(2),
marginBottom: theme.spacing(2)
},
formControl: {
minWidth: 200
},
searchField: {
minWidth: 300
},
logContainer: {
height: "calc(100vh - 300px)",
overflowY: "auto",
backgroundColor: theme.palette.background.default,
padding: theme.spacing(1),
fontFamily: "monospace",
fontSize: "0.8rem"
},
logLine: {
padding: theme.spacing(0.5),
borderBottom: `1px solid ${theme.palette.divider}`,
display: "flex",
alignItems: "center",
"&:hover": {
backgroundColor: theme.palette.action.hover
}
},
logContent: {
flexGrow: 1,
wordBreak: "break-all"
},
copyButton: {
visibility: "hidden",
padding: theme.spacing(0.5),
"$logLine:hover &": {
visibility: "visible"
}
},
timeRangeControls: {
display: "flex",
gap: theme.spacing(2),
alignItems: "center",
marginBottom: theme.spacing(2)
},
schemaCard: {
marginBottom: theme.spacing(2)
},
logsContainer: {
marginTop: theme.spacing(2)
},
error: {
color: theme.palette.error.main
},
buttonGroup: {
display: "flex",
gap: theme.spacing(1),
alignItems: "center",
marginTop: theme.spacing(1),
marginBottom: theme.spacing(1)
},
actionButton: {
borderRadius: 20,
boxShadow: "none",
textTransform: "none",
fontWeight: 500,
padding: theme.spacing(0.5, 2)
},
iconButton: {
padding: theme.spacing(1)
},
searchFieldEnhanced: {
marginBottom: theme.spacing(1),
width: "100%"
},
warn: {
color: theme.palette.warning.main
},
info: {
color: theme.palette.info.main
}
}));
const ParseableLogstreamPage = () => {
const classes = useStyles();
const parseableClient = useApi(parseableApiRef);
const [selectedDataset, setSelectedDataset] = useState("");
const [logs, setLogs] = useState([]);
const [logsLoading, setLogsLoading] = useState(false);
const [error, setError] = useState(void 0);
const [isLiveTail, setIsLiveTail] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [startDate, setStartDate] = useState("");
const [endDate, setEndDate] = useState("");
const [baseUrl, setBaseUrl] = useState("https://demo.parseable.com");
const [toastOpen, setToastOpen] = useState(false);
const [toastContent, setToastContent] = useState("");
const [copyFeedback, setCopyFeedback] = useState("");
const entityContext = (() => {
try {
const { entity } = useEntity();
return { entity, available: true };
} catch (e) {
return { entity: void 0, available: false };
}
})();
useEffect(() => {
var _a;
if (entityContext.available && entityContext.entity) {
const url = ((_a = entityContext.entity.metadata.annotations) == null ? void 0 : _a["parseable.io/base-url"]) || "";
setBaseUrl(url);
}
}, [entityContext]);
const { loading: datasetsLoading, value: datasets = [], error: datasetsError } = useAsync(
async () => {
if (!baseUrl)
return [];
try {
const userInfo = await parseableClient.getUserInfo(baseUrl);
console.log("Fetched datasets:", userInfo.datasets);
return userInfo.datasets || [];
} catch (error2) {
console.error("Error fetching datasets:", error2);
throw error2;
}
},
[baseUrl]
);
const fetchLogs = useCallback(async () => {
if (!baseUrl || !selectedDataset)
return;
try {
setLogsLoading(true);
console.log("Fetching logs with query:", searchQuery, "for dataset:", selectedDataset);
const logData = await parseableClient.getLogs(
baseUrl,
selectedDataset,
100,
searchQuery,
startDate || void 0,
endDate || void 0
);
console.log("Fetched logs:", logData.length);
setLogs(logData);
setError(void 0);
setLogsLoading(false);
} catch (err) {
console.error("Error fetching logs:", err);
setError(err instanceof Error ? err : new Error(String(err)));
setIsLiveTail(false);
setLogsLoading(false);
}
}, [baseUrl, selectedDataset, searchQuery, startDate, endDate, parseableClient]);
useEffect(() => {
let intervalId;
if (isLiveTail && selectedDataset && baseUrl) {
fetchLogs();
intervalId = setInterval(fetchLogs, 6e5);
}
return () => {
if (intervalId) {
clearInterval(intervalId);
}
};
}, [baseUrl, selectedDataset, isLiveTail]);
const handleDatasetChange = (event) => {
const newDataset = event.target.value;
setSelectedDataset(newDataset);
setLogs([]);
setError(void 0);
if (newDataset && baseUrl) {
const now = /* @__PURE__ */ new Date();
const thirtyMinutesAgo = new Date(now.getTime() - 30 * 60 * 1e3);
const formattedEndDate = now.toISOString();
const formattedStartDate = thirtyMinutesAgo.toISOString();
setStartDate(formattedStartDate);
setEndDate(formattedEndDate);
setLogsLoading(true);
console.log("Fetching default logs for dataset:", newDataset);
console.log("Time range:", formattedStartDate, "to", formattedEndDate);
parseableClient.getLogs(
baseUrl,
newDataset,
100,
// Default limit
"",
// Empty query string
formattedStartDate,
formattedEndDate
).then((logData) => {
console.log("Fetched initial logs for dataset:", logData.length);
setLogs(logData);
setError(void 0);
setLogsLoading(false);
}).catch((err) => {
console.error("Error fetching initial logs:", err);
setError(err instanceof Error ? err : new Error(String(err)));
setLogsLoading(false);
});
}
};
const toggleLiveTail = () => {
setIsLiveTail(!isLiveTail);
};
const handleSearch = () => {
setIsLiveTail(false);
fetchLogs();
};
const [isExporting, setIsExporting] = useState(false);
const handleExportCsv = async () => {
if (!baseUrl || !selectedDataset)
return;
try {
setIsExporting(true);
let query = searchQuery;
if (startDate && endDate) {
const timeFilter = `timestamp >= "${startDate}" AND timestamp <= "${endDate}"`;
query = query ? `${query} AND ${timeFilter}` : timeFilter;
}
const blob = await parseableClient.exportToCsv(baseUrl, selectedDataset, query);
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${selectedDataset}-logs.csv`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (err) {
setError(err instanceof Error ? err : new Error(String(err)));
} finally {
setIsExporting(false);
}
};
const getLogLevelColor = (log) => {
if (!log)
return "inherit";
const level = (log.level || log.severity || log.event_type || "").toString().toLowerCase();
if (level.includes("error"))
return "#d32f2f";
if (level.includes("warn") || level.includes("warning"))
return "#f57c00";
if (level.includes("info"))
return "#0288d1";
if (level.includes("metrics"))
return "#0288d1";
return "inherit";
};
const formatTimestamp = (timestamp) => {
if (!timestamp)
return "";
try {
const date = new Date(timestamp);
if (isNaN(date.getTime())) {
return timestamp;
}
return date.toLocaleString();
} catch {
return timestamp;
}
};
const extractLogFields = (logs2) => {
const fieldsSet = /* @__PURE__ */ new Set();
logs2.forEach((log) => {
Object.keys(log).forEach((key) => {
if (!["p_timestamp", "event_time", "timestamp", "datetime"].includes(key)) {
fieldsSet.add(key);
}
});
});
return Array.from(fieldsSet);
};
const prepareLogsForRendering = (logs2) => {
return logs2.map((log) => {
const preparedLog = {
// Add a special property that Material Table needs for the 'in' operator
original: {}
};
Object.entries(log).forEach(([key, value]) => {
if (value === null || value === void 0) {
preparedLog[key] = "";
} else if (typeof value === "object") {
preparedLog[key] = JSON.stringify(value);
} else {
preparedLog[key] = String(value);
}
preparedLog.original[key] = value;
});
if (log.levelColor) {
preparedLog.levelColor = log.levelColor;
preparedLog.original.levelColor = log.levelColor;
}
return preparedLog;
});
};
const formatBodyContent = (value) => {
if (!value)
return "";
if (typeof value === "object") {
try {
return JSON.stringify(value, null, 2);
} catch (e) {
return String(value);
}
}
return String(value);
};
const renderTruncatedText = (text) => {
return /* @__PURE__ */ jsxs("div", { children: [
/* @__PURE__ */ jsxs("span", { style: { display: "inline-block", maxWidth: "200px", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, children: [
text.substring(0, 50),
"..."
] }),
/* @__PURE__ */ jsx(
Button,
{
color: "primary",
size: "small",
style: { marginLeft: "8px", textTransform: "none" },
onClick: () => {
setToastContent(text);
setToastOpen(true);
},
children: "See more"
}
)
] });
};
if (logsLoading) {
return /* @__PURE__ */ jsx(Progress, {});
}
if (error) {
return /* @__PURE__ */ jsxs(Content, { children: [
/* @__PURE__ */ jsx(ContentHeader, { title: "Parseable Dataset" }),
/* @__PURE__ */ jsx(
ErrorPanel,
{
error,
children: "Check your Parseable credentials and base URL configuration."
}
)
] });
}
if (datasets.length === 0) {
return /* @__PURE__ */ jsxs(Content, { children: [
/* @__PURE__ */ jsx(ContentHeader, { title: "Parseable Dataset" }),
/* @__PURE__ */ jsx(
EmptyState,
{
missing: "data",
title: "No datasets available",
description: "No Parseable datasets found for your user. Make sure you have access to at least one dataset."
}
)
] });
}
return /* @__PURE__ */ jsxs(Content, { children: [
/* @__PURE__ */ jsx(ContentHeader, { title: "Parseable Dataset", children: /* @__PURE__ */ jsx(SupportButton, { children: "View your Parseable datasets with advanced search capabilities." }) }),
/* @__PURE__ */ jsxs(Grid, { container: true, spacing: 3, children: [
/* @__PURE__ */ jsx(Grid, { item: true, xs: 12, children: /* @__PURE__ */ jsxs(InfoCard, { title: "Parseable Dataset", children: [
!entityContext.available && /* @__PURE__ */ jsxs("div", { style: { marginBottom: "24px" }, children: [
/* @__PURE__ */ jsx(Typography, { variant: "subtitle1", gutterBottom: true, children: "You're viewing this plugin in standalone mode. Please enter your Parseable server URL below:" }),
/* @__PURE__ */ jsx(
TextField,
{
label: "Parseable Base URL",
value: baseUrl,
onChange: (e) => setBaseUrl(e.target.value),
placeholder: "https://demo.parseable.com",
variant: "outlined",
size: "small",
disabled: datasetsLoading
}
)
] }),
/* @__PURE__ */ jsxs(FormControl, { className: classes.formControl, children: [
/* @__PURE__ */ jsx(InputLabel, { id: "dataset-select-label", children: "Dataset" }),
/* @__PURE__ */ jsxs(
Select,
{
labelId: "dataset-select-label",
value: selectedDataset,
onChange: handleDatasetChange,
disabled: datasetsLoading || Boolean(datasetsError),
children: [
/* @__PURE__ */ jsx(MenuItem, { value: "", children: /* @__PURE__ */ jsx("em", { children: "Select a dataset" }) }),
datasetsError ? /* @__PURE__ */ jsx(MenuItem, { disabled: true, children: "Error loading datasets" }) : datasets.map((dataset) => /* @__PURE__ */ jsx(MenuItem, { value: dataset, children: dataset }, dataset))
]
}
)
] }),
/* @__PURE__ */ jsx(
TextField,
{
className: classes.searchFieldEnhanced,
label: "Search Query (SQL syntax supported)",
value: searchQuery,
onChange: (e) => setSearchQuery(e.target.value),
onKeyPress: (e) => e.key === "Enter" && handleSearch(),
disabled: isLiveTail,
placeholder: "Simple text or SQL WHERE clause",
helperText: "Example: level='error' OR status>=400",
fullWidth: true,
size: "small",
variant: "outlined",
margin: "normal"
}
),
/* @__PURE__ */ jsxs("div", { className: classes.buttonGroup, children: [
/* @__PURE__ */ jsx(
Button,
{
variant: "contained",
color: "primary",
size: "small",
startIcon: /* @__PURE__ */ jsx(SearchIcon, {}),
onClick: handleSearch,
disabled: !selectedDataset || isLiveTail,
className: classes.actionButton,
children: "Show Logs"
}
),
/* @__PURE__ */ jsx(Tooltip, { title: isLiveTail ? "Pause live tail" : "Start live tail", children: /* @__PURE__ */ jsxs("span", { children: [
" ",
/* @__PURE__ */ jsx(
IconButton,
{
onClick: toggleLiveTail,
color: isLiveTail ? "secondary" : "default",
disabled: !selectedDataset,
size: "small",
className: classes.iconButton,
children: isLiveTail ? /* @__PURE__ */ jsx(PauseIcon, {}) : /* @__PURE__ */ jsx(PlayArrowIcon, {})
}
)
] }) }),
selectedDataset && /* @__PURE__ */ jsx(
Button,
{
variant: "outlined",
color: "primary",
size: "small",
startIcon: /* @__PURE__ */ jsx(GetAppIcon, {}),
onClick: handleExportCsv,
disabled: logs.length === 0 || isExporting,
className: classes.actionButton,
children: isExporting ? "Exporting..." : "Export"
}
)
] }),
error && /* @__PURE__ */ jsx(ErrorPanel, { error, title: "Error fetching logs" })
] }) }),
/* @__PURE__ */ jsxs(Grid, { item: true, xs: 12, children: [
logsLoading && /* @__PURE__ */ jsx(Progress, {}),
error && /* @__PURE__ */ jsx(ErrorPanel, { error }),
!logsLoading && !error && logs.length === 0 && /* @__PURE__ */ jsx(
EmptyState,
{
missing: "data",
title: "No logs found",
description: "No logs were found for the selected dataset and time range."
}
),
!selectedDataset && /* @__PURE__ */ jsx(Typography, { variant: "body2", align: "center", children: "Select a dataset to view logs" }),
!logsLoading && !error && logs.length > 0 && /* @__PURE__ */ jsx(InfoCard, { title: `Logs for ${selectedDataset}`, className: classes.logsContainer, children: /* @__PURE__ */ jsx(
Table,
{
options: {
pageSize: 10,
pageSizeOptions: [10, 20, 50],
headerStyle: {
backgroundColor: "#e3f2fd",
color: "#000000",
fontWeight: "bold"
},
maxBodyHeight: "600px",
minBodyHeight: "400px",
padding: "default",
tableLayout: "auto",
search: true,
paging: true,
sorting: true,
columnsButton: true
},
data: prepareLogsForRendering(logs).map((log, index) => {
const rowData = { id: index };
rowData.timestamp = formatTimestamp(String(log.p_timestamp || log.datetime || log.event_time || ""));
Object.entries(log).forEach(([key, value]) => {
if (!["p_timestamp", "event_time", "timestamp", "datetime"].includes(key)) {
if (typeof value === "object" && value !== null) {
if (key === "body") {
rowData[key] = formatBodyContent(value);
} else {
try {
const seen = /* @__PURE__ */ new WeakSet();
rowData[key] = JSON.stringify(value, (_k, v) => {
if (typeof v === "object" && v !== null) {
if (seen.has(v))
return "[Circular]";
seen.add(v);
}
return v;
});
if (rowData[key].length > 200) {
rowData[key] = rowData[key].substring(0, 200) + "...";
}
} catch (error2) {
rowData[key] = `[Error: ${error2 instanceof Error ? error2.message : String(error2)}]`;
}
}
} else {
rowData[key] = value === null ? "" : String(value);
}
}
});
rowData.levelColor = getLogLevelColor(log);
return rowData;
}),
components: {
// Use a custom container to avoid the scrollWidth error
Container: (props) => /* @__PURE__ */ jsx("div", { ...props, style: { overflowX: "auto", width: "100%" } })
},
columns: (() => {
const columns = [
{
title: "Timestamp",
field: "timestamp",
render: (rowData) => /* @__PURE__ */ jsx("span", { children: String(rowData.timestamp || "") })
}
];
if (logs.length > 0) {
const fields = extractLogFields(logs);
const priorityFields = ["level", "meta-state", "status", "method", "host", "id"];
priorityFields.forEach((field) => {
if (fields.includes(field)) {
columns.push({
title: field.charAt(0).toUpperCase() + field.slice(1).replace(/-/g, " "),
field,
render: (rowData) => {
if (["level", "meta-state", "status"].includes(field)) {
return /* @__PURE__ */ jsx("span", { style: {
color: field === "status" && Number(rowData[field]) >= 400 ? "#d32f2f" : field === "level" || field === "meta-state" ? rowData.levelColor : "inherit",
fontWeight: "medium"
}, children: String(rowData[field] || "") });
}
return /* @__PURE__ */ jsx("span", { children: String(rowData[field] || "") });
}
});
fields.splice(fields.indexOf(field), 1);
}
});
fields.forEach((field) => {
if (field === "body") {
columns.push({
title: "Body",
field: "body",
render: (rowData) => {
const bodyValue = rowData.body;
if (!bodyValue || typeof bodyValue !== "string") {
return /* @__PURE__ */ jsx("span", { children: String(bodyValue || "") });
}
try {
if (bodyValue.startsWith("{") || bodyValue.startsWith("[")) {
const parsed = JSON.parse(bodyValue);
const displaySummary = typeof parsed === "object" ? Object.keys(parsed).slice(0, 3).map((k) => k).join(", ") : String(parsed).substring(0, 30);
return /* @__PURE__ */ jsxs("div", { children: [
/* @__PURE__ */ jsx("span", { style: { display: "inline-block", maxWidth: "200px", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, children: displaySummary }),
/* @__PURE__ */ jsx(
Button,
{
color: "primary",
size: "small",
style: { marginLeft: "8px", textTransform: "none" },
onClick: () => {
setToastContent(JSON.stringify(parsed, null, 2));
setToastOpen(true);
},
children: "See more"
}
)
] });
}
} catch (e) {
console.debug("Failed to parse JSON body:", e);
}
return bodyValue.length > 50 ? renderTruncatedText(bodyValue) : /* @__PURE__ */ jsx("span", { children: bodyValue });
}
});
} else {
columns.push({
title: field.charAt(0).toUpperCase() + field.slice(1).replace(/-/g, " "),
field,
render: (rowData) => /* @__PURE__ */ jsx("span", { children: String(rowData[field] || "") })
});
}
});
}
return columns;
})()
},
`logs-table-${selectedDataset}`
) })
] })
] }),
/* @__PURE__ */ jsxs(
Dialog,
{
open: toastOpen,
onClose: () => setToastOpen(false),
maxWidth: "md",
fullWidth: true,
PaperProps: {
style: {
maxHeight: "80vh",
backgroundColor: "#282828",
// Dark background to match Backstage theme
color: "#fff"
}
},
children: [
/* @__PURE__ */ jsxs(DialogTitle, { style: { borderBottom: "1px solid #444" }, children: [
"Log Body Content",
/* @__PURE__ */ jsx(
IconButton,
{
"aria-label": "close",
style: { position: "absolute", right: 8, top: 8, color: "#fff" },
onClick: () => setToastOpen(false),
children: /* @__PURE__ */ jsx(CloseIcon, {})
}
)
] }),
/* @__PURE__ */ jsx(DialogContent, { dividers: true, style: { borderTop: "1px solid #444", borderBottom: "1px solid #444" }, children: /* @__PURE__ */ jsx("pre", { style: {
whiteSpace: "pre-wrap",
fontSize: "13px",
backgroundColor: "#333",
color: "#eee",
padding: "16px",
borderRadius: "4px",
overflow: "auto",
maxHeight: "60vh",
border: "1px solid #444"
}, children: toastContent }) }),
/* @__PURE__ */ jsxs(DialogActions, { style: { padding: "16px" }, children: [
/* @__PURE__ */ jsx(Button, { onClick: () => setToastOpen(false), style: { color: "#9cc9ff" }, children: "Close" }),
/* @__PURE__ */ jsx(
Button,
{
onClick: () => {
navigator.clipboard.writeText(toastContent);
setCopyFeedback("Copied!");
setTimeout(() => setCopyFeedback(""), 2e3);
},
style: { color: "#9cc9ff" },
startIcon: copyFeedback ? /* @__PURE__ */ jsx(CheckIcon, { style: { color: "#4caf50" } }) : void 0,
children: copyFeedback || "Copy to Clipboard"
}
)
] })
]
}
)
] });
};
export { ParseableLogstreamPage };
//# sourceMappingURL=ParseableLogstreamPage-808ca1cc.esm.js.map