UNPKG

@parseable/backstage-plugin-logstream

Version:

Backstage plugin for integrating with Parseable log streams

691 lines (688 loc) 26 kB
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