CnLogsPage
A read-only audit-trail / activity-log page. Wraps CnDataTable with server-side sorting, pagination and filtering, and supports two data-source modes selected via pages[].config:
- OpenRegister-backed — set
register+schema. List state (params, paging, sort, filters) is driven by the shareduseListViewcomposable againstuseObjectStore(). - Custom URL — set
sourceto a HTTP endpoint. The component fetches viaaxios.get(source). Sorting is applied client-side; there is no pagination.
Mounted automatically by CnPageRenderer when a manifest page declares type: "logs". Honours headerComponent, actionsComponent, and the generic slots map alongside the other built-in page types.
Wraps: CnDataTable, CnPagination, CnPageHeader, CnDetailGrid, NcDialog, NcButton, NcEmptyContent, NcLoadingIcon.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
title | String | 'Activity log' | Page title (used by the optional inline header) |
description | String | '' | Subtitle shown under the title when showTitle is set |
showTitle | Boolean | true | Whether to render the inline CnPageHeader visibly. When false the <h1> is still rendered visually-hidden, so the <main> landmark keeps its accessible heading |
icon | String | '' | MDI icon name |
register | String | '' | OpenRegister register slug (paired with schema). Changing it after mount requires a remount — CnPageRenderer already keys the page on register+schema, and a direct consumer that rebinds it instead gets a development console warning |
schema | String | '' | OpenRegister schema slug (paired with register). Same remount note as register |
source | String | '' | Custom log-source URL — used when register+schema is not set |
columns | Array | [] | Column definitions (strings → {key, label}; or full objects). When empty, a store-backed page derives its columns from the loaded schema — see Columns |
filter | Object | null | Fixed filter map merged into every fetch, above the $route.query deep-link filters. Supports the @route.<param> / :<param> / @me / @today±Nd / @workspace.<key> token grammar. Reactive — changing the map, or what a token in it resolves to, re-fetches from page 1. Store mode only |
pagination | Object | null | Page-size config; only limit is read, sent as _limit. Null = the store default of 20. Store mode only |
sortKey | String | null | Initial sort column. Null sends no _order, leaving the server's own ordering in place |
sortOrder | String | 'asc' | Initial sort direction. Inert while sortKey is null |
sortKeys | Array | [] | Initial multi-column sort as an ordered priority list — [{ key, order }, …]. Takes precedence over sortKey/sortOrder when non-empty; a ?_order= param on the URL outranks both — see Sorting |
fixedLayout | Boolean | false | Make the columns' declared width authoritative (table-layout: fixed) rather than a hint the browser may override from cell content — see Column widths |
rowDetail | Boolean | false | Open a read-only detail dialog on a row click, rendering the entry's fields including nested bags (a stack trace, an argument map). Default false keeps a row click inert. Ignored when rowRoute is set |
rowRoute | String | '' | Manifest page id to open on a row click, pushed as { name: rowRoute, params: { id: row[rowKey] } } — the same shape CnIndexPage's open-page actions use. For a log whose detail surface is a real page (a step timeline, a replay action) rather than a read-only dump. Takes precedence over rowDetail |
rowKey | String | 'id' | Property used as the unique row identifier |
emptyText | String | 'No log entries to show' | Text rendered when there are no entries |
errorText | String | 'Could not load log entries' | Text rendered when fetch fails |
closeLabel | String | 'Close' | Label for the row-detail dialog's close button |
store | Object | null | Override the default useObjectStore() (e.g. when the consumer uses createObjectStore with a custom ID) |
Slots
| Slot | Scope | Description |
|---|---|---|
header | { title, description, icon } | Replaces the default CnPageHeader |
actions | — | Right-aligned actions (refresh, export). Filled by pages[].actionsComponent when set |
empty | — | Replaces the empty-state |
error | { error } | Replaces the error block |
row-actions | { row } | Per-row action menu inside the table |
row-detail | { row } | Replaces the built-in row-detail dialog body |
column-<key> | { row, value } | Custom cell renderer for a specific column |
Events
| Event | Payload | Description |
|---|---|---|
row-click | row | A log row's body was clicked. Fires whether or not rowDetail opened the dialog, so a host can navigate instead of (or as well as) showing it |
action | — | Declared for hosts dispatching row actions through the #row-actions slot. Never emitted by this component itself; kept in the surface because consumers may already listen for it |
A refresh() method is also exposed for actionsComponent implementations to call.
Filtering
In store mode two filter sources are merged into every request, with filter winning a key collision:
$route.query— every non-_-prefixed entry becomes a filter, so a "View logs" link like/jobs/logs?jobId=<uuid>lands the page scoped to one parent. Reserved list params (_page,_limit,_search,_order) are skipped. Array values (?status[]=a&status[]=b) stay arrays, which the API reads as an IN match.filter— the page's own scoping from the manifest, with tokens resolved at fetch time.
The merged result is watched as a whole, so the list re-fetches (from page 1) whenever the scope it resolves to changes — a same-path query change, a route param, a new filter prop, or a write to the workspace / app-config bag a @workspace.<key> / @config.<key> token reads. Nothing else re-fetches: a bag write that no token in the filter reads leaves the list alone, and a navigation away from the page does not re-fetch under the destination route's query (which would otherwise overwrite the shared store collection that other mounted widgets on the same objectType read).
Columns
columns wins whenever it is set. When it is empty:
- Store mode with a loaded schema →
CnDataTablederives the columns from the schema, giving type-aware cell rendering (dates, booleans, enums) for free. Properties oftype: 'object'are excluded, so nested bags don't render as raw JSON. sourcemode, or a schema that failed to load → the legacy default of[timestamp, actor, action, target, details].
That legacy default matches no OpenRegister log schema, which is why a store-backed page prefers the schema. Curate the set explicitly when you want specific widths, badges or formatters.
Opening a row
Three mutually-exclusive behaviours, in precedence order:
rowRoute— navigate to a real page. Use this when the log's detail surface does something a dialog cannot: a step timeline, a replay action, anything with its own verbs.rowDetail— open the built-in read-only dialog. Good for a log whose payload is just data: nested bags of primitives render as labelled grids, anything deeper as pretty-printed JSON. Replace the body wholesale with#row-detail.- Neither (the default) — a row click does nothing but emit
row-click, which a host can handle itself.
row-click is emitted in all three cases, so a host can always add its own behaviour on top.
Sorting
Store mode sorts server-side (the sort becomes _order on the request); source mode has no server to ask, so the header sort is applied client-side over the loaded rows.
The initial sort is resolved in this precedence order:
?_order=on the URL — the JSON-encoded ordered listCnIndexPagewrites when a header is clicked. A shared or reloaded logs link therefore opens on the sort it carried. A malformed param is ignored rather than throwing.sortKeys— the manifest's own multi-column default.sortKey+sortOrder— the single-column form.
With none of the three set, no _order is sent at all and the server's own ordering stands.
Column widths
A log table is a bad fit for the browser's default table-layout: auto, where a column's width is only a hint and the real widths come from cell content. Two things go wrong:
- Any column left unsized absorbs every remaining pixel. With one free-text
messagecolumn and five sized ones,messageends up several times wider than it needs to be. - A long unbreakable value — a PHP FQCN like
OCA\OpenConnector\Action\SynchronizationAction, a UUID — has a min-content width equal to the whole string. It widens its own column, and where it can't, the text paints past the cell box and collides with the next column.
Set fixedLayout to make the declared widths binding. Long values then wrap inside their cell instead of overflowing it. Size every column when you do (percentages summing to 100 are the easiest to reason about) — columns left unsized share whatever is left, which reintroduces the first problem.
For a value that is one long unbreakable token — a URL, a fully-qualified class name — wrapping is usually worse than clipping: it breaks mid-token over several lines and makes every row that tall. Give that column "cellClass": "cn-cell--truncate" for a single line with a trailing ellipsis; the full value stays reachable through the cell's title tooltip.
Manifest configuration
{
"id": "job-logs",
"route": "/jobs/logs",
"type": "logs",
"title": "myapp.logs.title",
"config": {
"register": "openconnector",
"schema": "job_log",
// OR a custom source URL:
// "source": "/index.php/apps/myapp/api/logs"
"sortKey": "created",
"sortOrder": "desc",
"pagination": { "limit": 50 },
"rowDetail": true,
"fixedLayout": true,
// Percentages summing to 100, honoured exactly because of `fixedLayout`.
"columns": [
{ "key": "created", "label": "Time", "sortable": true, "width": "14%", "formatter": "datetime" },
{
"key": "level",
"label": "Level",
"width": "9%",
"widget": "badge",
"widgetProps": { "colorMap": { "info": "info", "warning": "warning", "error": "error" } }
},
{ "key": "message", "label": "Message", "sortable": false, "width": "38%" },
{
"key": "executionTime",
"label": "Duration",
"width": "9%",
"format": { "style": "duration", "unit": "milliseconds" }
},
{ "key": "jobClass", "label": "Job class", "sortable": true, "width": "19%" },
{
"key": "stackTrace",
"label": "Stack trace",
"sortable": false,
"width": "11%",
"formatter": "count",
"formatterOptions": { "singular": "{n} frame", "plural": "{n} frames", "zero": "—" }
}
]
}
}
Usage (standalone)
<template>
<CnLogsPage
register="audit-trail-immutable"
schema="audit-event"
:columns="['timestamp', 'actor', 'action']"
sort-key="timestamp"
sort-order="desc"
:pagination="{ limit: 50 }"
row-detail
show-title
title="Activity" />
</template>
<script>
import { CnLogsPage } from '@conduction/nextcloud-vue'
export default { components: { CnLogsPage } }
</script>
Custom-fallback notes
- The component does NOT bundle a filter bar, time-range picker, or actor autocomplete — those live in the consumer's
actionsComponent(or asrow-actionsslot fills). Deep-link filters and a manifestfiltercover the common "scope this log to one parent" case without any of that. - The row-detail dialog renders a bag of primitives (a stack trace's frames, an argument map) as its own labelled grid, and anything deeper as pretty-printed JSON. Override the whole body with
#row-detailwhen a log shape deserves bespoke rendering. fetchCollectionrecords failures on the store rather than throwing, so the#errorslot readsstore.errors[type]in store mode.- When neither register+schema nor source is set, the component renders the empty-state and emits a single
console.warn. This is deliberate — a misconfigured manifest does not break the app shell.