report

Turn records and alerts into something to read or share: aligned text tables, Markdown, CSV, a self-contained HTML page, and a timeline. Rows are maps (column names are the keys) or lists.

import report

Example

import report

let rows = [
  {"ip": "203.0.113.42", "tries": 22, "level": "critical"},
  {"ip": "198.51.100.7", "tries": 6, "level": "medium"},
]
say(report.table(rows))
say(report.markdown(rows, ["ip", "level"]))

Functions

table(rows, columns?, options?)

returns text

An aligned text table to say(). rows is a list of maps (or lists, with the first list as the header). columns picks and orders the columns (default: every key, except big ones like records and raw). Long cells are cut to fit, with ...; options {"max_width": 60} changes the limit (default 40). Numbers line up on the right.

markdown(rows, columns?)

returns text

A Markdown table (for GitHub, tickets and notes). | characters in cells are escaped.

csv(rows, columns?)

returns text

CSV text with a header row, ready for a spreadsheet. Lists in a cell become "a, b".

html(title, rows, options?)

returns text

A complete HTML page (one file, styles included, light and dark mode) with a title and a table of rows. Every value is escaped, so log lines can't inject HTML. A "level" column gets coloured badges. options: {"columns": [...], "intro": text shown above the table, "stats": {label: number}, "sections": [{"title", "rows", "columns"?}, ...] for more tables}.

timeline(records, options?)

returns text

One line per record or alert, oldest first: "2026-03-12 09:14:05 [auth] Failed password for root ...". Records without a time go last. options: {"limit": n} keeps the first n lines, {"max_width": n} cuts long lines (default 160).

save(path, text)

returns text

Write a report to a file (making missing folders) and give back the full path, to print or open.