| 1 | import ArrowDown from "lucide-solid/icons/arrow-down"; |
| 2 | import { createMemo, type JSX, Show } from "solid-js"; |
| 3 | import type { LogLine } from "../types/model.ts"; |
| 4 | import { clock, count, date, datetime } from "../format.ts"; |
| 5 | import { VirtualList } from "./VirtualList.tsx"; |
| 6 | import "./LogView.css"; |
| 7 | |
| 8 | /** SGR sequences capture their parameters; every other escape and control character is dropped. */ |
| 9 | const ESCAPE = /\x1b\[([0-9;]*)m|\x1b\[[0-?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[@-_]?|[\x00-\x08\x0b-\x1f\x7f]/g; |
| 10 | /** The eight base colors in theme terms; the bright eight reuse them. */ |
| 11 | const ANSI = ["var(--muted)", "var(--critical)", "var(--good)", "var(--warning)", "var(--series-1)", "var(--series-5)", |
| 12 | "var(--series-3)", "var(--text)"]; |
| 13 | |
| 14 | function xterm(n: number) { |
| 15 | if (n < 16) return ANSI[n % 8]!; |
| 16 | if (n >= 232) return `rgb(${Array(3).fill(8 + (n - 232) * 10).join(" ")})`; |
| 17 | return `rgb(${[36, 6, 1].map((step) => Math.floor((n - 16) / step) % 6).map((c) => (c ? 55 + c * 40 : 0)).join(" ")})`; |
| 18 | } |
| 19 | |
| 20 | function sgr(style: JSX.CSSProperties, params: string): JSX.CSSProperties { |
| 21 | const codes = (params || "0").split(";").map(Number); |
| 22 | const next = { ...style }; |
| 23 | const color = (key: "--fg" | "--bg", value: string | undefined) => (value ? (next[key] = value) : delete next[key]); |
| 24 | for (let i = 0; i < codes.length; i++) { |
| 25 | const code = codes[i]!; |
| 26 | if (code === 0) for (const key in next) delete next[key as keyof JSX.CSSProperties]; |
| 27 | else if (code === 1) next["font-weight"] = 600; |
| 28 | else if (code === 2) next.opacity = 0.65; |
| 29 | else if (code === 3) next["font-style"] = "italic"; |
| 30 | else if (code === 4) next["text-decoration"] = "underline"; |
| 31 | else if (code === 22) { |
| 32 | delete next["font-weight"]; |
| 33 | delete next.opacity; |
| 34 | } |
| 35 | else if (code === 23) delete next["font-style"]; |
| 36 | else if (code === 24) delete next["text-decoration"]; |
| 37 | else if ((code >= 30 && code <= 37) || (code >= 90 && code <= 97)) color("--fg", ANSI[code % 10]); |
| 38 | else if ((code >= 40 && code <= 47) || (code >= 100 && code <= 107)) color("--bg", ANSI[code % 10]); |
| 39 | else if (code === 39 || code === 49) color(code === 39 ? "--fg" : "--bg", undefined); |
| 40 | else if (code === 38 || code === 48) { |
| 41 | const rgb = codes[i + 1] === 5 ? xterm(codes[i + 2]!) : `rgb(${codes.slice(i + 2, i + 5).join(" ")})`; |
| 42 | color(code === 38 ? "--fg" : "--bg", rgb); |
| 43 | i += codes[i + 1] === 5 ? 2 : 4; |
| 44 | } |
| 45 | } |
| 46 | return next; |
| 47 | } |
| 48 | |
| 49 | /** A log line as a terminal shows it: colored, each line from its last carriage return; each of `needles` is marked. */ |
| 50 | function ansi(raw: string, needles: string[]) { |
| 51 | const text = raw.replace(/[^\n]*\r(?=[^\n])/g, ""); |
| 52 | if (!needles.length && !/[\x00-\x08\x0b-\x1f\x7f]/.test(text)) return text; |
| 53 | const segments: { text: string; style: JSX.CSSProperties }[] = []; |
| 54 | let style: JSX.CSSProperties = {}; |
| 55 | let last = 0; |
| 56 | for (const match of text.matchAll(ESCAPE)) { |
| 57 | if (match.index > last) segments.push({ text: text.slice(last, match.index), style }); |
| 58 | if (match[1] !== undefined) style = sgr(style, match[1]); |
| 59 | last = match.index + match[0].length; |
| 60 | } |
| 61 | segments.push({ text: text.slice(last), style }); |
| 62 | const plain = segments.map((segment) => segment.text).join("").toLowerCase(); |
| 63 | const found: [number, number][] = []; |
| 64 | for (const needle of needles.map((needle) => needle.toLowerCase())) { |
| 65 | for (let at = plain.indexOf(needle); at !== -1; at = plain.indexOf(needle, at + needle.length)) found.push([at, at + needle.length]); |
| 66 | } |
| 67 | const marks: [number, number][] = []; |
| 68 | for (const [from, to] of found.sort((a, b) => a[0] - b[0])) { |
| 69 | const last = marks.at(-1); |
| 70 | if (last && from <= last[1]) last[1] = Math.max(last[1], to); |
| 71 | else marks.push([from, to]); |
| 72 | } |
| 73 | let end = 0; |
| 74 | return segments.map((segment) => { |
| 75 | const start = end; |
| 76 | end += segment.text.length; |
| 77 | const parts: JSX.Element[] = []; |
| 78 | let cut = start; |
| 79 | for (const [from, to] of marks) { |
| 80 | if (to <= cut || from >= end) continue; |
| 81 | if (from > cut) parts.push(segment.text.slice(cut - start, from - start)); |
| 82 | cut = Math.min(to, end); |
| 83 | parts.push(<mark>{segment.text.slice(Math.max(from, start) - start, cut - start)}</mark>); |
| 84 | } |
| 85 | if (cut < end) parts.push(segment.text.slice(cut - start)); |
| 86 | return Object.keys(segment.style).length ? <span class="ansi" style={segment.style}>{parts}</span> : parts; |
| 87 | }); |
| 88 | } |
| 89 | |
| 90 | /** Line height and character width of `.logs` text, for estimating how a line wraps before it renders. */ |
| 91 | const LINE = 18.6; |
| 92 | const CHAR = 7.2; |
| 93 | /** Everything in a log row beside the text: the gutters, the time column, and the gap after it. */ |
| 94 | const ROW_CHROME = 2 * 28 + 62 + 12; |
| 95 | |
| 96 | function estimate(line: LogLine, width: number) { |
| 97 | const columns = Math.max(16, Math.floor((width - ROW_CHROME) / CHAR)); |
| 98 | let rows = 0; |
| 99 | for (const part of line.text.replace(ESCAPE, "").split("\n")) rows += Math.max(1, Math.ceil(part.length / columns)); |
| 100 | return rows * LINE; |
| 101 | } |
| 102 | |
| 103 | /** |
| 104 | * Log lines, newest at the bottom, as a terminal shows them: colored, soft-wrapped, split by day, `marks` marked, and |
| 105 | * named by container when several are mixed. Follows new lines while scrolled to the end; away from it, a button leads back. |
| 106 | */ |
| 107 | export function LogView(props: { |
| 108 | lines: LogLine[]; |
| 109 | follow: boolean; |
| 110 | onFollow: (atEnd: boolean) => void; |
| 111 | marks?: string[]; |
| 112 | /** Makes a line's container name a button, when several containers are mixed. */ |
| 113 | onContainer?: (container: string) => void; |
| 114 | /** Called while the view is near the oldest line. */ |
| 115 | onStart?: () => void; |
| 116 | /** Lines that arrived while away from the end, counted on the button back to it. */ |
| 117 | unseen?: number; |
| 118 | /** What the button back to the end does, if more than following again. */ |
| 119 | onLatest?: () => void; |
| 120 | /** Rows above a line, after its day divider. */ |
| 121 | before?: (line: LogLine, index: () => number) => JSX.Element; |
| 122 | }) { |
| 123 | const mixed = createMemo(() => new Set(props.lines.map((line) => line.container)).size > 1); |
| 124 | return ( |
| 125 | <div class="log-view"> |
| 126 | <VirtualList class="logs" label="Log lines" items={props.lines} estimate={estimate} follow={props.follow} |
| 127 | onFollow={props.onFollow} onStart={props.onStart}> |
| 128 | {(line, index) => ( |
| 129 | <> |
| 130 | {/* Rows can outlive a swapped-in list for a tick, so the previous line may not exist yet. */} |
| 131 | <Show when={index() > 0 && props.lines[index() - 1] && date(props.lines[index() - 1]!.t) !== date(line.t)}> |
| 132 | <div class="log-edge day">{date(line.t)}</div> |
| 133 | </Show> |
| 134 | {props.before?.(line, index)} |
| 135 | <div class={`log ${line.level ?? ""}`}> |
| 136 | <time data-tip={datetime(line.t)}>{clock(line.t)}</time> |
| 137 | <span> |
| 138 | <Show when={mixed()}> |
| 139 | <Show when={props.onContainer} fallback={<span class="container">{line.container}</span>}> |
| 140 | {(filter) => ( |
| 141 | <button class="container" data-tip={`only ${line.container}`} onClick={() => filter()(line.container)}> |
| 142 | {line.container} |
| 143 | </button> |
| 144 | )} |
| 145 | </Show> |
| 146 | </Show> |
| 147 | {ansi(line.text, props.marks ?? [])} |
| 148 | </span> |
| 149 | </div> |
| 150 | </> |
| 151 | )} |
| 152 | </VirtualList> |
| 153 | <Show when={!props.follow}> |
| 154 | <button class="button small latest" onClick={() => (props.onLatest ?? (() => props.onFollow(true)))()}> |
| 155 | <ArrowDown size={14} />{props.unseen ? `${count(props.unseen)} new` : "latest"} |
| 156 | </button> |
| 157 | </Show> |
| 158 | </div> |
| 159 | ); |
| 160 | } |