AuditLog
Who did what to what and when, newest first, filtered by person and action, searched, and paged.
Demo
| When | Who | Action | Target | IP |
|---|---|---|---|---|
| ada@example.com | user.login | — | — | |
| alan@example.com | api_key.create | ci-deploy | 81.2.69.101 | |
| grace@example.com | user.remove | grace@example.com | 81.2.69.102 | |
| ada@example.com | project.update | Team planFrom the mobile app | 81.2.69.103 | |
| alan@example.com | billing.change | Moon shot | — | |
| grace@example.com | user.invite | — | 81.2.69.105 | |
| ada@example.com | api_key.delete | grace@example.com | 81.2.69.106 | |
| alan@example.com | user.login | Team plan | 81.2.69.107 | |
| grace@example.com | api_key.create | Moon shot | — | |
| ada@example.com | user.remove | ci-deploy | 81.2.69.109 |
on_filter: 30 matching. Times are shown in Europe/Lisbon.
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/audit_log.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/audit_log.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/audit_log.qwe
let audit_log_demo_people = ["ada@example.com", "grace@example.com", "alan@example.com"]let audit_log_demo_actions = ["user.login", "user.invite", "project.update", "api_key.create", "api_key.delete", "billing.change", "user.remove"]// 30 entries, an hour and a quarter apart, made the same way on both sides.let audit_log_demo_entries = for i in range(0, 30) => { who: audit_log_demo_people[(i * 2) % 3], action: audit_log_demo_actions[(i * 3) % 7], target: i % 5 == 0 ? null : ["Moon shot", "ci-deploy", "grace@example.com", "Team plan"][i % 4], at: date.from_ms(date.ms("2026-10-28T12:00:00Z") - i * 4500000), ip: i % 4 == 0 ? null : `81.2.69.${100 + i}`, detail: i % 7 == 3 ? "From the mobile app" : null,}component AuditLogDemoLive() { state matching = len(audit_log_demo_entries) <div.demo-stack> <AuditLog id="audit-log-demo" label="Audit log" zone="Europe/Lisbon" page_size={10} entries={audit_log_demo_entries} on_filter={(n) => { matching = n }}/> <p.demo-note>on_filter: {matching} matching. Times are shown in Europe/Lisbon.</p> </div>}view AuditLogDemo() = <AuditLogDemoLive/>Source
ui/audit_log.qwe
// AuditLog: who did what to what, and when, as a table newest first, with// filters by person and by action, a search box, and a count of what is shown:// the security or admin history of an account.//// <AuditLog id="audit" label="Audit log" zone="Europe/Lisbon" entries={[// { who: "ada@example.com", action: "user.invite", target: "grace@example.com",// at: "2026-10-28T09:12:00Z", ip: "81.2.69.160" },// { who: "grace@example.com", action: "api_key.delete", target: "ci-deploy", at: "2026-10-27T17:40:00Z",// detail: "Key ending 4f2a" },// ]}/>//// entries: maps of { who, action, target?, at, ip?, detail?, tone? }: at is an ISO// date-time; detail a line under the target; tone the action's colour ("success",// "warning", "danger", "info", "neutral"), by default from its words: create, add,// invite, enable and login are success, delete, remove, revoke and disable danger,// update, change and edit info. They are shown newest first. zone: the time zone// times are shown in (UTC by default; an IANA name or "+05:30"). show_ip: an IP// column (true when any entry has one). page_size: entries per page (25), with// Newer and Older. empty: what it says when nothing matches. label: the table's// name. id: a prefix for its ids, unique on the page. The filters are selects of// the people and actions found, and the search keeps entries where any field has// what is typed. on_filter(count): inside one of your components, called with how// many entries match after a filter changes.import ui/theme.qwe use ui_clslet audit_log_css = css`.ui-auditlog { display: grid; gap: 10px; min-width: 0; font: 14px/1.45 var(--ui-font); color: var(--ui-fg); }.ui-auditlog-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }.ui-auditlog-bar select, .ui-auditlog-search { height: 34px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background-color: var(--ui-surface); color: var(--ui-fg); font: 13px/1 var(--ui-font); }.ui-auditlog-bar select { padding: 0 28px 0 10px; appearance: none; -webkit-appearance: none; background: var(--ui-surface) no-repeat right 9px center / 9px url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 6' fill='none' stroke='%23888' stroke-width='1.6' stroke-linecap='round'%3E%3Cpath d='M1 1l4 4 4-4'/%3E%3C/svg%3E"); }.ui-auditlog-search { flex: 1 1 180px; max-width: 280px; padding: 0 10px; }.ui-auditlog-bar select:focus, .ui-auditlog-search:focus { outline: none; border-color: var(--ui-accent); box-shadow: var(--ui-focus); }.ui-auditlog-count { margin-left: auto; color: var(--ui-muted); font-size: 13px; font-variant-numeric: tabular-nums; }.ui-auditlog-scroll { overflow: auto; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface); }.ui-auditlog table { width: 100%; border-collapse: separate; border-spacing: 0; }.ui-auditlog th, .ui-auditlog td { padding: 9px 14px; text-align: left; vertical-align: top; border-bottom: 1px solid var(--ui-border); }.ui-auditlog th { background: var(--ui-subtle); color: var(--ui-muted); font-size: 12px; font-weight: 600; letter-spacing: .03em; text-transform: uppercase; white-space: nowrap; }.ui-auditlog tbody tr:last-child td { border-bottom: 0; }.ui-auditlog tbody tr:hover td { background: color-mix(in srgb, var(--ui-subtle) 70%, transparent); }.ui-auditlog-when { white-space: nowrap; color: var(--ui-muted); font-variant-numeric: tabular-nums; }.ui-auditlog-who { font-weight: 500; }.ui-auditlog-action { display: inline-block; padding: 1px 7px; border-radius: 4px; font: 500 12px/1.5 var(--ui-mono); white-space: nowrap; --t: var(--ui-muted); color: var(--t); background: color-mix(in srgb, var(--t) 12%, transparent); }.ui-auditlog-success { --t: var(--ui-success); }.ui-auditlog-warning { --t: var(--ui-warning); }.ui-auditlog-danger { --t: var(--ui-danger); }.ui-auditlog-info { --t: var(--ui-info); }.ui-auditlog-detail { display: block; color: var(--ui-muted); font-size: 12px; }.ui-auditlog-ip { font: 12px/1.6 var(--ui-mono); color: var(--ui-muted); white-space: nowrap; }.ui-auditlog-empty td { padding: 28px 14px; text-align: center; color: var(--ui-muted); }.ui-auditlog-pages { display: flex; justify-content: flex-end; gap: 6px; }.ui-auditlog-pages button { height: 30px; padding: 0 11px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface); color: var(--ui-fg); font: 500 13px/1 var(--ui-font); cursor: pointer; }.ui-auditlog-pages button:hover:not(:disabled) { border-color: var(--ui-strong); background: var(--ui-subtle); }.ui-auditlog-pages button:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-auditlog-pages button:disabled { opacity: .5; cursor: not-allowed; }`// An action's tone from its words: "user.delete" is danger.fn audit_log_tone(action: str) -> str { let a = lower(action) if re.test(a, "(delete|remove|revoke|disable|fail|ban)") { return "danger" } if re.test(a, "(create|add|invite|enable|login|grant)") { return "success" } if re.test(a, "(update|change|edit|rename|reset)") { return "info" } "neutral"}// When, in a zone: "Oct 28, 2026, 10:12".fn audit_log_when(at: str, zone: str) -> str = date.valid(at) ? date.format(date.to_zone(at, zone), "MMM D, YYYY, HH:mm") : atcomponent AuditLog(id: str, entries: list = [], zone: str = "UTC", show_ip: bool? = null, page_size: int = 25, empty: str = "No entries match.", label: str? = null, on_filter = null, class: str? = null) { style global audit_log_css state who = "" state action = "" state query = "" state page = 0 let newest = reverse(sort_by(entries, (e) => date.valid(e.at ?? "") ? date.ms(e.at) : 0)) let people = sort(unique(entries | map((e) => str(e.who ?? ""))) | filter((p) => p != "")) let actions = sort(unique(entries | map((e) => str(e.action ?? ""))) | filter((a) => a != "")) let needle = lower(trim(query)) let found = newest | filter((e) => who == "" || e.who == who) | filter((e) => action == "" || e.action == action) | filter((e) => needle == "" || contains(lower(join([e.who ?? "", e.action ?? "", e.target ?? "", e.detail ?? "", e.ip ?? "", audit_log_when(e.at ?? "", zone)], " ")), needle)) let ips = show_ip ?? any(entries, (e) => e.ip != null) let total = len(found) let pages = total == 0 ? 1 : ceil(total / page_size) let at = page >= pages ? pages - 1 : page let shown = slice(found, at * page_size, at * page_size + page_size) let changed = () => { page = 0 // Worked out here from the new filters: the `let`s follow them a moment later. on_filter && on_filter(len(newest | filter((e) => who == "" || e.who == who) | filter((e) => action == "" || e.action == action) | filter((e) => trim(query) == "" || contains(lower(join([e.who ?? "", e.action ?? "", e.target ?? "", e.detail ?? "", e.ip ?? "", audit_log_when(e.at ?? "", zone)], " ")), lower(trim(query)))))) } let cols = ips ? 5 : 4 <div class={ui_cls(["ui-auditlog", class])} id={id}> <div.ui-auditlog-bar> <select aria-label="Filter by person" id={`${id}-who`} @change={(e) => { who = e.value; changed() }}> <option value="" selected={who == ""}>Everyone</option> {for p in people => <option value={p} selected={who == p}>{p}</option>} </select> <select aria-label="Filter by action" id={`${id}-action`} @change={(e) => { action = e.value; changed() }}> <option value="" selected={action == ""}>All actions</option> {for a in actions => <option value={a} selected={action == a}>{a}</option>} </select> <input.ui-auditlog-search type="search" placeholder="Search" aria-label="Search the log" value={query} @input={(e) => { query = e.value; changed() }}/> <span.ui-auditlog-count aria-live="polite">{total == len(entries) ? `${total} entries` : `${total} of ${len(entries)} entries`}</span> </div> <div.ui-auditlog-scroll> <table aria-label={label}> <thead> <tr> <th scope="col">When</th> <th scope="col">Who</th> <th scope="col">Action</th> <th scope="col">Target</th> {ips && <th scope="col">IP</th>} </tr> </thead> <tbody> {total == 0 && <tr.ui-auditlog-empty><td colspan={str(cols)}>{empty}</td></tr>} {for [i, e] in enumerate(shown) => <tr key={`${e.at}-${e.who}-${e.action}-${at * page_size + i}`}> <td.ui-auditlog-when><time datetime={e.at}>{audit_log_when(e.at ?? "", zone)}</time></td> <td.ui-auditlog-who>{e.who}</td> <td><span class={`ui-auditlog-action ui-auditlog-${e.tone ?? audit_log_tone(e.action ?? "")}`}>{e.action}</span></td> <td>{e.target ?? "—"}{e.detail != null && <span.ui-auditlog-detail>{e.detail}</span>}</td> {ips && <td.ui-auditlog-ip>{e.ip ?? "—"}</td>} </tr>} </tbody> </table> </div> {pages > 1 && <div.ui-auditlog-pages> <button type="button" disabled={at == 0} @click={() => { page = at - 1 }}>Newer</button> <button type="button" disabled={at >= pages - 1} @click={() => { page = at + 1 }}>Older</button> </div>} </div>}Related
DataGridSort, search, filter by a column's values, pick rows and page through them, in one table.5 views · 8 downloadsComparisonTableFeatures down the side and options across, with ticks, crosses and values in the cells, and one option picked out.5 views · 6 downloadsCsvTableA table made from CSV text, with quoted fields, a header row, numbers lined up on the right and a row limit.6 views · 7 downloadsDataTableRows of data sorted by their headers, with search, pages, number, money, percent and date columns.5 views · 9 downloadsEditableTableA grid edited in place: text, number, select, checkbox and date cells, checked, sorted, with rows added and deleted.7 views · 5 downloadsExpandableTableRows with a button that opens a panel of details under them: labelled fields, a note, or your own markup.5 views · 6 downloads
Thread
Thread 0 posts
On AuditLog: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.