LogViewer
Log lines with each level in its colour, buttons to show or hide levels, a search that marks matches, wrapping and following the end.
Demo
09:00:01INFOServer started on :8080
09:00:01DEBUGLoaded 42 routes in 3 ms
09:00:04INFOGET /health 200 0.4ms
09:00:09WARNSlow query: select * from orders where status = 'open' (812 ms)
09:00:12INFOPOST /api/orders 201 18ms
09:00:13ERRORPayment provider timed out after 10 s (order 1041)
09:00:13INFORetrying payment for order 1041 in 2 s
09:00:15INFOPayment for order 1041 accepted
at charge (billing.qwe:88)
09:00:20DEBUGCache hit ratio 0.93
09:00:31WARNDisk 81% full on /var/data
09:00:40INFOGET /api/orders?page=2 200 9ms
12 lines
Lines added at the end are followed while the box is scrolled to the bottom.
Get it
It needs theme.qwe, as every component does; or take the whole kit, quillo-ui.qwe. Each component brings its own styles to the pages that show it.
$ curl -O https://quillolang.org/components/ui/theme.qwe$ curl -O https://quillolang.org/components/ui/log_viewer.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/log_viewer.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/log_viewer.qwe
let log_viewer_demo_text = [ "09:00:01 INFO Server started on :8080", "09:00:01 DEBUG Loaded 42 routes in 3 ms", "09:00:04 INFO GET /health 200 0.4ms", "09:00:09 WARN Slow query: select * from orders where status = 'open' (812 ms)", "09:00:12 INFO POST /api/orders 201 18ms", "09:00:13 ERROR Payment provider timed out after 10 s (order 1041)", "09:00:13 INFO Retrying payment for order 1041 in 2 s", "09:00:15 INFO Payment for order 1041 accepted", " at charge (billing.qwe:88)", "09:00:20 DEBUG Cache hit ratio 0.93", "09:00:31 WARN Disk 81% full on /var/data", "09:00:40 INFO GET /api/orders?page=2 200 9ms",]component LogViewerDemoLive() { state extra = [] state n = 0 let lines = log_viewer_demo_text + extra <div.demo-stack> <LogViewer id="log-viewer-demo" label="API log" lines={lines} height="260px"/> <div.demo-row> <Button id="log-viewer-demo-add" variant="soft" size="sm" on_click={() => { n += 1 extra = push(extra, { time: `09:01:${n < 10 ? "0" : ""}${n}`, level: n % 3 == 0 ? "error" : "info", message: `Tick ${n}: worker ${n % 4} is alive`, source: "cron" }) }}>Add a line</Button> <p.demo-note>Lines added at the end are followed while the box is scrolled to the bottom.</p> </div> </div>}view LogViewerDemo() = <LogViewerDemoLive/>Source
ui/log_viewer.qwe
// LogViewer: log lines in a dark, monospaced box, each level in its colour, with// buttons that show or hide each level (and say how many lines it has), a search// that keeps matching lines and marks the match, line wrapping, and following the// end as lines come.//// <LogViewer id="api-log" label="API log" lines={log_text_lines}/>// <LogViewer id="jobs" lines={[{ time: "09:00:01", level: "info", message: "Job started", source: "worker-2" }]}/>//// lines: strings or maps. A string is read as "<time> <LEVEL> <message>" (or// "<LEVEL> <message>", or "[LEVEL] ..."), the level being TRACE, DEBUG, INFO,// WARN (WARNING), ERROR or FATAL in any case; a line it cannot read is shown as it// is, at no level. A map is { message, level?, time?, source? }. levels: the// levels shown at first (all of them). height: the box's height ("320px").// wrap: long lines wrap (false: they scroll sideways); a button flips it.// follow: keeps the end in view as lines are added (true), unless the reader has// scrolled up. numbers: line numbers down the left (true). label: the log's// name for screen readers. id: a prefix for its ids, unique on the page. The box// is role="log", so screen readers hear lines added at the end.import ui/theme.qwe use ui_clslet log_viewer_css = css`.ui-logviewer { display: grid; gap: 8px; min-width: 0; font: 13px/1.4 var(--ui-font); color: var(--ui-fg); }.ui-logviewer-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }.ui-logviewer-lvl { display: inline-flex; align-items: center; gap: 6px; height: 28px; padding: 0 9px; border: 1px solid var(--ui-border); border-radius: 999px; background: var(--ui-surface); color: var(--ui-muted); font: 500 12px/1 var(--ui-font); cursor: pointer; --t: var(--ui-muted); }.ui-logviewer-lvl::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--t); opacity: .4; }.ui-logviewer-lvl[aria-pressed="true"] { border-color: color-mix(in srgb, var(--t) 45%, var(--ui-border)); color: var(--ui-fg); background: color-mix(in srgb, var(--t) 10%, var(--ui-surface)); }.ui-logviewer-lvl[aria-pressed="true"]::before { opacity: 1; }.ui-logviewer-lvl:focus-visible, .ui-logviewer-wrapbtn:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-logviewer-lvl b { font-weight: 600; font-variant-numeric: tabular-nums; }.ui-logviewer-debug, .ui-logviewer-trace { --t: var(--ui-muted); }.ui-logviewer-info { --t: var(--ui-info); }.ui-logviewer-warn { --t: var(--ui-warning); }.ui-logviewer-error, .ui-logviewer-fatal { --t: var(--ui-danger); }.ui-logviewer-search { flex: 1 1 160px; max-width: 260px; height: 30px; margin-left: auto; padding: 0 10px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface); color: var(--ui-fg); font: 13px/1 var(--ui-font); }.ui-logviewer-search:focus { outline: none; border-color: var(--ui-accent); box-shadow: var(--ui-focus); }.ui-logviewer-wrapbtn { height: 30px; padding: 0 10px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface); color: var(--ui-muted); font: 500 12px/1 var(--ui-font); cursor: pointer; }.ui-logviewer-wrapbtn[aria-pressed="true"] { color: var(--ui-fg); background: var(--ui-subtle); }.ui-logviewer-box { overflow: auto; padding: 8px 0; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-bg); color: var(--ui-fg); font: 12.5px/1.6 var(--ui-mono); }.ui-logviewer-box:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-logviewer-line { display: flex; gap: 10px; padding: 0 12px; white-space: pre; }.ui-logviewer-wrap .ui-logviewer-line { white-space: pre-wrap; overflow-wrap: anywhere; }.ui-logviewer-line:hover { background: var(--ui-subtle); }.ui-logviewer-line.ui-logviewer-error, .ui-logviewer-line.ui-logviewer-fatal { background: color-mix(in srgb, var(--ui-danger) 10%, transparent); }.ui-logviewer-line.ui-logviewer-warn { background: color-mix(in srgb, var(--ui-warning) 8%, transparent); }.ui-logviewer-n { flex: none; min-width: 3ch; color: var(--ui-muted); opacity: .6; text-align: right; user-select: none; -webkit-user-select: none; }.ui-logviewer-time { flex: none; color: var(--ui-muted); }.ui-logviewer-tag { flex: none; width: 5ch; color: var(--t); font-weight: 700; }.ui-logviewer-src { flex: none; color: var(--ui-accent); }.ui-logviewer-msg { min-width: 0; }.ui-logviewer-box mark { border-radius: 2px; background: color-mix(in srgb, var(--ui-warning) 40%, transparent); color: inherit; }.ui-logviewer-none { padding: 18px 12px; color: var(--ui-muted); font-family: var(--ui-font); text-align: center; }.ui-logviewer-foot { color: var(--ui-muted); font-size: 12px; font-variant-numeric: tabular-nums; }`let log_viewer_levels = ["trace", "debug", "info", "warn", "error", "fatal"]// A line as { time, level, message, source }: level is one of the six, or "".fn log_viewer_parse(line) -> map { if type_of(line) == "map" { let lv = lower(str(line.level ?? "")) return { time: line.time, level: lv == "warning" ? "warn" : (index_of(log_viewer_levels, lv) != null ? lv : ""), message: str(line.message ?? ""), source: line.source } } let m = re.find(str(line), "^(?:(\\S+)\\s+)??\\[?(TRACE|DEBUG|INFO|WARN|WARNING|ERROR|FATAL)\\]?:?\\s+(.*)$", "i") if m == null { return { time: null, level: "", message: str(line), source: null } } let lv = lower(m.groups[1]) { time: m.groups[0], level: lv == "warning" ? "warn" : lv, message: m.groups[2], source: null }}// The text cut where it matches the needle (lower case): [{ text, hit }].fn log_viewer_parts(text: str, needle: str) -> list { if needle == "" { return [{ text, hit: false }] } let at = index_of(lower(text), needle) if at == null { return [{ text, hit: false }] } let n = len(needle) [{ text: slice(text, 0, at), hit: false }, { text: slice(text, at, at + n), hit: true }] + log_viewer_parts(slice(text, at + n, len(text)), needle)}component LogViewer(id: str, lines: list = [], levels: list? = null, height: str = "320px", wrap: bool = false, follow: bool = true, numbers: bool = true, label: str? = null, class: str? = null) { style global log_viewer_css state on = levels ?? log_viewer_levels state query = "" state wrapped = wrap state stuck = true // the box is scrolled to its end state box = null // the box, once on the page // New lines: back to the end once they are on the page, if it was there. if follow && stuck && box != null && len(lines) > 0 { js("queueMicrotask", () => { box.scrollTop = box.scrollHeight }) } let parsed = for [i, l] in enumerate(lines) => { ...log_viewer_parse(l), n: i + 1 } let present = log_viewer_levels | filter((lv) => any(parsed, (p) => p.level == lv)) let needle = lower(trim(query)) let shown = parsed | filter((p) => p.level == "" || index_of(on, p.level) != null) | filter((p) => needle == "" || contains(lower(p.message), needle) || contains(lower(p.source ?? ""), needle)) let count_of = (lv: str) => count(parsed, (p) => p.level == lv) <div class={ui_cls(["ui-logviewer", wrapped && "ui-logviewer-wrap", class])} id={id}> <div.ui-logviewer-bar role="group" aria-label="Levels"> {for lv in present => <button type="button" class={`ui-logviewer-lvl ui-logviewer-${lv}`} aria-pressed={index_of(on, lv) != null ? "true" : "false"} data-level={lv} @click={() => { on = index_of(on, lv) != null ? on | filter((x) => x != lv) : push(on, lv) }}> {upper(lv)} <b>{count_of(lv)}</b> </button>} <input.ui-logviewer-search type="search" placeholder="Search" value={query} aria-label={label != null ? `Search ${lower(label)}` : "Search the log"} aria-controls={`${id}-box`} @input={(e) => { query = e.value }}/> <button.ui-logviewer-wrapbtn type="button" aria-pressed={wrapped ? "true" : "false"} @click={() => { wrapped = !wrapped }}>Wrap</button> </div> <div.ui-logviewer-box id={`${id}-box`} role="log" aria-label={label} tabindex="0" style={`height: ${height}`} @scroll={(e) => { stuck = e.scrollTop + e.clientHeight >= e.scrollHeight - 8 }} @mount={(el) => { box = el if follow { el.scrollTop = el.scrollHeight } }}> {len(shown) == 0 && <div.ui-logviewer-none>{len(parsed) == 0 ? "No lines yet." : "No line matches."}</div>} {for p in shown => <div key={p.n} class={ui_cls(["ui-logviewer-line", p.level != "" && `ui-logviewer-${p.level}`])} data-n={p.n}> {numbers && <span.ui-logviewer-n aria-hidden="true">{p.n}</span>} {p.time != null && <span.ui-logviewer-time>{p.time}</span>} {p.level != "" && <span.ui-logviewer-tag>{upper(p.level)}</span>} {p.source != null && <span.ui-logviewer-src>{p.source}</span>} <span.ui-logviewer-msg>{for part in log_viewer_parts(p.message, needle) => part.hit ? <mark>{part.text}</mark> : part.text}</span> </div>} </div> <div.ui-logviewer-foot aria-live="polite">{len(shown) == len(parsed) ? `${len(parsed)} lines` : `${len(shown)} of ${len(parsed)} lines`}</div> </div>}Related
DataGridSort, search, filter by a column's values, pick rows and page through them, in one table.5 views · 8 downloadsJsonTableJSON as a table: open nested objects and arrays in place or as the table, search at every depth, export as JSON or CSV, thousands of lines kept fast.9 views · 7 downloadsAuditLogWho did what to what and when, newest first, filtered by person and action, searched, and paged.5 views · 7 downloadsDiffViewThe lines added and removed between two texts, unified or side by side, with line numbers.7 views · 5 downloadsQueryBuilderBuilds nested AND/OR filters with operators by field type, and gives a JSON filter and parametrised SQL, with a live count of the sample rows that match.3 views · 4 downloadsSearchListA list that filters as you type, marks what matched, counts the results and is walked with the arrow keys.7 views · 6 downloads
Thread
Thread 0 posts
On LogViewer: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.