UNPKG

@flanksource/clicky-ui

Version:

Flanksource Clicky UI — React component library built on shadcn/ui with light/dark and density theming.

581 lines (580 loc) 17.2 kB
import { jsxs, jsx } from "react/jsx-runtime"; import { useState, useMemo } from "react"; import { cn } from "../lib/utils.js"; import { Modal } from "../overlay/Modal.js"; import { AnsiHtml } from "./AnsiHtml.js"; import { DataTable } from "./DataTable.js"; import { Icon } from "./Icon.js"; const ANSI_PATTERN = new RegExp(`${String.fromCharCode(27)}\\[[0-9;?]*[ -/]*[@-~]`); const DEFAULT_LOG_COLUMNS = [ { key: "timestamp", label: "Timestamp", kind: "timestamp", shrink: true, minWidth: 180 }, { key: "level", label: "Level", kind: "status", shrink: true, minWidth: 96, status: { showLabel: true } }, { key: "pod", label: "Pod", shrink: true, minWidth: 180 }, { key: "logger", label: "Logger", shrink: true, minWidth: 220 }, { key: "thread", label: "Thread", shrink: true, minWidth: 180 }, { key: "message", label: "Message", grow: true, minWidth: 360, cellClassName: "font-mono text-xs", render: (value) => { const text = typeof value === "string" ? value : value == null ? "" : String(value); if (!ANSI_PATTERN.test(text)) return text; return /* @__PURE__ */ jsx(AnsiHtml, { as: "span", text, className: "whitespace-pre-wrap break-words" }); } }, { key: "tags", label: "Tags", kind: "tags", grow: true, tags: { maxVisible: 4 } } ]; const DEFAULT_SORT = { key: "timestamp", dir: "desc" }; const JSON_PARSE_FAILED = Symbol("json-parse-failed"); function LogsTable({ logs, columns, dark = true, showRawDetails = true, className, autoFilter = true, defaultSort, getRowId, renderExpandedRow, showDensityControl = false, showFullscreenControl = true, fullscreenTitle = "Logs", ...tableProps }) { const [fullscreenOpen, setFullscreenOpen] = useState(false); const rows = useMemo(() => normalizeLogsTableRows(logs), [logs]); const resolvedRenderExpandedRow = renderExpandedRow ?? (showRawDetails ? renderLogDetails : void 0); const renderTable = () => /* @__PURE__ */ jsx( LogsDataTable, { ...tableProps, rows, columns: columns ?? DEFAULT_LOG_COLUMNS, autoFilter, defaultSort: defaultSort ?? DEFAULT_SORT, showDensityControl, ...getRowId ? { getRowId } : {}, ...resolvedRenderExpandedRow ? { renderExpandedRow: resolvedRenderExpandedRow } : {} } ); return /* @__PURE__ */ jsxs( "div", { ...dark ? { "data-theme": "dark" } : {}, className: cn( "relative flex min-h-0 flex-col", dark && "rounded-md bg-background p-2 text-foreground", className ), children: [ showFullscreenControl && /* @__PURE__ */ jsx( "button", { type: "button", "aria-label": "Open logs full screen", title: "Open logs full screen", className: "absolute right-3 top-3 z-10 inline-flex h-8 w-8 items-center justify-center rounded-md border border-input bg-background text-muted-foreground shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2", onClick: () => setFullscreenOpen(true), children: /* @__PURE__ */ jsx(Icon, { name: "codicon:screen-full", className: "text-sm" }) } ), renderTable(), /* @__PURE__ */ jsx( Modal, { open: fullscreenOpen, onClose: () => setFullscreenOpen(false), title: fullscreenTitle, size: "full", className: "h-[95vh]", children: /* @__PURE__ */ jsx( "div", { ...dark ? { "data-theme": "dark" } : {}, className: cn("flex h-full min-h-0 flex-col", dark && "bg-background text-foreground"), children: renderTable() } ) } ) ] } ); } function LogsDataTable({ rows, columns, autoFilter, defaultSort, getRowId, renderExpandedRow, showDensityControl, ...tableProps }) { return /* @__PURE__ */ jsx( DataTable, { ...tableProps, data: rows, columns, density: "compact", autoFilter, getRowId: getRowId ?? ((row) => row.id), showDensityControl, defaultSort, ...renderExpandedRow ? { renderExpandedRow } : {} } ); } function normalizeLogsTableRows(logs) { const entries = typeof logs === "string" ? splitLogLines(logs) : logs; return entries.map((entry, index) => normalizeLogEntry(entry, index)); } function splitLogLines(logs) { return logs.split(/\r?\n/).filter((line) => line.length > 0); } function normalizeLogEntry(entry, index) { const parsedOuterValue = typeof entry === "string" ? tryParseJson(entry) : entry; const parsedOuter = parsedOuterValue === JSON_PARSE_FAILED ? entry : parsedOuterValue; const outer = asRecord(parsedOuter); const outerLine = stringValue(outer == null ? void 0 : outer.line); const parsedLineValue = outerLine ? tryParseJson(outerLine) : JSON_PARSE_FAILED; const parsedLine = parsedLineValue === JSON_PARSE_FAILED ? void 0 : parsedLineValue; const inner = asRecord(parsedLine) ?? (outerLine ? void 0 : outer); const labels = asRecord(outer == null ? void 0 : outer.labels); const timestamp = firstString( pick(outer, "timestamp"), pick(inner, "@timestamp"), pick(outer, "ts"), pick(inner, "timestamp"), pick(outer, "time"), pick(inner, "time") ); const level = firstString( pick(inner, "log.level"), pick(inner, "level"), pick(outer, "level"), pick(outer, "severity"), pick(inner, "severity") ); const pod = firstString( pick(outer, "pod"), pick(labels, "pod"), pick(inner, "kubernetes.pod.name") ); const namespace = firstString( pick(outer, "namespace"), pick(labels, "namespace"), pick(inner, "kubernetes.namespace") ); const container = firstString( pick(outer, "container"), pick(labels, "container"), pick(inner, "container.name") ); const service = firstString( pick(inner, "service.name"), pick(inner, "service"), pick(outer, "service"), pick(labels, "service") ); const dataset = firstString(pick(inner, "event.dataset"), pick(inner, "dataset")); const logger = firstString( pick(inner, "log.logger"), pick(inner, "logger"), pick(outer, "logger") ); const thread = firstString( pick(inner, "process.thread.name"), pick(inner, "thread"), pick(outer, "thread") ); const message = firstString( pick(inner, "message"), pick(inner, "msg"), pick(outer, "message"), outerLine, typeof entry === "string" ? entry : void 0 ); const tags = buildTags({ namespace, container, service, dataset, ecsVersion: firstString(pick(inner, "ecs.version")), labels }); return { id: `${index}:${timestamp || pod || message || stableString(entry)}`, timestamp, level, pod, logger, thread, message, tags, line: outerLine ?? (typeof entry === "string" ? entry : stableString(entry)), raw: parsedOuter, ...parsedLine !== void 0 ? { parsedLine } : {} }; } function buildTags({ namespace, container, service, dataset, ecsVersion, labels }) { const tags = []; const seen = /* @__PURE__ */ new Set(); const addTag = (key, value) => { const valueString = stringValue(value); if (!valueString) return; const tag = `${key}=${valueString}`; if (seen.has(tag)) return; seen.add(tag); tags.push(tag); }; addTag("namespace", namespace); addTag("container", container); addTag("service", service); addTag("dataset", dataset); addTag("ecs.version", ecsVersion); if (labels) { for (const [key, value] of Object.entries(labels)) { addTag(key, value); } } return tags; } function renderLogDetails(row) { return /* @__PURE__ */ jsx(LogDetails, { row }); } function LogDetails({ row }) { const [openPaths, setOpenPaths] = useState({}); const setPathOpen = (path, open) => { setOpenPaths((current) => ({ ...current, [path]: open })); }; const details = useMemo(() => buildProcessedLogDetails(row), [row]); return /* @__PURE__ */ jsx("div", { className: "space-y-density-3 text-xs", children: /* @__PURE__ */ jsx( PropertiesDescriptionList, { value: details, path: "details", openPaths, setPathOpen } ) }); } function PropertiesDescriptionList({ value, path, openPaths, setPathOpen, depth = 0 }) { const entries = getValueEntries(value); if (entries.length === 0) { return /* @__PURE__ */ jsx(LogValueSummary, { value }); } return /* @__PURE__ */ jsx( "dl", { className: cn( "divide-y divide-border rounded-md border border-border bg-muted/20", depth > 0 && "mt-density-1" ), children: entries.map(([key, entryValue]) => { const keyText = String(key); const label = formatPropertyLabel(keyText); const entryPath = `${path}.${keyText}`; const expandable = isExpandableValue(entryValue); const open = openPaths[entryPath] ?? depth < 1; return /* @__PURE__ */ jsxs( "div", { className: "grid min-w-0 grid-cols-[minmax(8rem,14rem)_minmax(0,1fr)] gap-density-3 px-density-2 py-density-1.5", children: [ /* @__PURE__ */ jsx( "dt", { "aria-label": label, className: "min-w-0 truncate font-mono text-[11px] text-muted-foreground", children: label } ), /* @__PURE__ */ jsxs("dd", { className: "min-w-0 space-y-density-1", children: [ /* @__PURE__ */ jsx( LogDetailsValueLine, { label, path: entryPath, value: entryValue, expandable, open, setPathOpen } ), expandable && open && /* @__PURE__ */ jsx( PropertiesDescriptionList, { value: entryValue, path: entryPath, openPaths, setPathOpen, depth: depth + 1 } ) ] }) ] }, entryPath ); }) } ); } function LogDetailsValueLine({ label, path, value, expandable, open, setPathOpen }) { return /* @__PURE__ */ jsxs("div", { className: "flex min-w-0 items-start gap-density-1", children: [ /* @__PURE__ */ jsxs("span", { className: "inline-flex shrink-0 items-center gap-0.5 pt-0.5", children: [ /* @__PURE__ */ jsx( LogDetailsActionButton, { label: `Expand ${label}`, icon: "lucide:zoom-in", disabled: !expandable || open, onClick: () => setPathOpen(path, true) } ), /* @__PURE__ */ jsx( LogDetailsActionButton, { label: `Collapse ${label}`, icon: "lucide:zoom-out", disabled: !expandable || !open, onClick: () => setPathOpen(path, false) } ) ] }), /* @__PURE__ */ jsx("div", { className: "min-w-0 max-w-full", children: expandable ? /* @__PURE__ */ jsx(LogValueSummary, { value }) : /* @__PURE__ */ jsx(LogScalarValue, { value }) }), /* @__PURE__ */ jsx( LogDetailsActionButton, { label: `Copy ${label}`, icon: "lucide:copy", onClick: () => copyLogDetailsValue(value) } ) ] }); } function LogDetailsActionButton({ label, icon, disabled, onClick }) { return /* @__PURE__ */ jsx( "button", { type: "button", "aria-label": label, title: label, disabled, className: cn( "inline-flex h-5 w-5 items-center justify-center rounded text-muted-foreground", "hover:bg-accent hover:text-foreground", "disabled:opacity-35 disabled:hover:bg-transparent disabled:hover:text-muted-foreground" ), onClick: (event) => { event.stopPropagation(); onClick(); }, children: /* @__PURE__ */ jsx(Icon, { name: icon, className: "text-xs" }) } ); } function LogValueSummary({ value }) { if (Array.isArray(value)) { return /* @__PURE__ */ jsxs("span", { className: "font-mono text-muted-foreground", children: [ "[", value.length, " items]" ] }); } const record = asRecord(value); if (record) { const count = Object.keys(record).length; return /* @__PURE__ */ jsxs("span", { className: "font-mono text-muted-foreground", children: [ "{ ", count, " properties", " }" ] }); } return /* @__PURE__ */ jsx(LogScalarValue, { value }); } function LogScalarValue({ value }) { if (value === null || value === void 0) { return /* @__PURE__ */ jsx("span", { className: "font-mono italic text-muted-foreground", children: "null" }); } if (typeof value === "string") { return /* @__PURE__ */ jsx("pre", { className: "max-h-48 overflow-auto whitespace-pre-wrap break-words font-mono text-[11px] leading-relaxed text-foreground", children: value }); } if (typeof value === "number" || typeof value === "boolean") { return /* @__PURE__ */ jsx("span", { className: "font-mono text-blue-700 dark:text-blue-400", children: String(value) }); } return /* @__PURE__ */ jsx("pre", { className: "max-h-48 overflow-auto whitespace-pre-wrap break-words font-mono text-[11px] leading-relaxed text-foreground", children: stablePrettyString(value) }); } function getValueEntries(value) { if (Array.isArray(value)) { return value.map((entry, index) => [index, entry]); } return Object.entries(asRecord(value) ?? {}); } function isExpandableValue(value) { return getValueEntries(value).length > 0; } function stablePrettyString(value) { if (typeof value === "string") return value; try { return JSON.stringify(value, null, 2); } catch { return String(value); } } function copyLogDetailsValue(value) { var _a; if (typeof navigator !== "undefined" && ((_a = navigator.clipboard) == null ? void 0 : _a.writeText)) { Promise.resolve(navigator.clipboard.writeText(stablePrettyString(value))).catch( () => void 0 ); } } function buildProcessedLogDetails(row) { const attributes = buildProcessedLogAttributes(row); return stripEmptyProperties({ timestamp: row.timestamp, level: row.level, pod: row.pod, logger: row.logger, thread: row.thread, message: row.message, tags: row.tags, ...Object.keys(attributes).length > 0 ? { attributes } : {} }); } function buildProcessedLogAttributes(row) { const attributes = { ...asRecord(row.raw), ...asRecord(row.parsedLine) }; const hiddenKeys = /* @__PURE__ */ new Set([ "@timestamp", "container", "event.dataset", "labels", "level", "line", "log.level", "log.logger", "logger", "message", "msg", "pod", "process.thread.name", "service", "service.name", "severity", "thread", "time", "timestamp" ]); return stripEmptyProperties( Object.fromEntries(Object.entries(attributes).filter(([key]) => !hiddenKeys.has(key))) ); } function formatPropertyLabel(key) { return key.replace(/[_-]+/g, " ").replace(/\.+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase()); } function stripEmptyProperties(record) { return Object.fromEntries( Object.entries(record).filter(([, value]) => { if (value == null) return false; if (typeof value === "string") return value.trim().length > 0; if (Array.isArray(value)) return value.length > 0; return true; }) ); } function tryParseJson(value) { try { return JSON.parse(value); } catch { return JSON_PARSE_FAILED; } } function asRecord(value) { return value && typeof value === "object" && !Array.isArray(value) ? value : void 0; } function pick(record, path) { if (!record) return void 0; if (Object.prototype.hasOwnProperty.call(record, path)) return record[path]; return path.split(".").reduce((current, key) => { if (current && typeof current === "object") { return current[key]; } return void 0; }, record); } function firstString(...values) { for (const value of values) { const string = stringValue(value); if (string) return string; } return ""; } function stringValue(value) { if (value == null) return ""; if (typeof value === "string") return value.trim(); if (typeof value === "number" || typeof value === "boolean") return String(value); return ""; } function stableString(value) { if (typeof value === "string") return value; try { return JSON.stringify(value); } catch { return String(value); } } export { LogsTable, normalizeLogsTableRows }; //# sourceMappingURL=LogsTable.js.map