@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
JavaScript
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