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 textAn 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 textA Markdown table (for GitHub, tickets and notes). | characters in cells are escaped.
csv(rows, columns?)
returns textCSV text with a header row, ready for a spreadsheet. Lists in a cell become "a, b".
html(title, rows, options?)
returns textA 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 textOne 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 textWrite a report to a file (making missing folders) and give back the full path, to print or open.