@frockbot/plugin-audit 0.3.8 → 0.3.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +6 -6
- package/src/client/AuditSection.vue +60 -9
- package/src/index.ts +3 -1
- package/src/store.test.ts +34 -0
- package/src/store.ts +95 -22
- package/src/testing.ts +26 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frockbot/plugin-audit",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.10",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -25,11 +25,11 @@
|
|
|
25
25
|
"typecheck": "vue-tsc --noEmit -p tsconfig.json"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@frockbot/client-core": "0.3.
|
|
29
|
-
"@frockbot/client-ui": "0.3.
|
|
30
|
-
"@frockbot/kernel-contracts": "0.3.
|
|
31
|
-
"@frockbot/plugin-shell": "0.3.
|
|
32
|
-
"@frockbot/secret-shapes": "0.3.
|
|
28
|
+
"@frockbot/client-core": "0.3.10",
|
|
29
|
+
"@frockbot/client-ui": "0.3.10",
|
|
30
|
+
"@frockbot/kernel-contracts": "0.3.10",
|
|
31
|
+
"@frockbot/plugin-shell": "0.3.10",
|
|
32
|
+
"@frockbot/secret-shapes": "0.3.10",
|
|
33
33
|
"cordis": "4.0.0-rc.8",
|
|
34
34
|
"vue": "3.5.41"
|
|
35
35
|
},
|
|
@@ -8,7 +8,12 @@
|
|
|
8
8
|
// reconciliation rule forbids. The same goes for truncation — a table trimmed
|
|
9
9
|
// to its retention bound says so above the rows rather than quietly answering
|
|
10
10
|
// with fewer.
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
formatMomentV1,
|
|
13
|
+
UiAnchor,
|
|
14
|
+
UiButton,
|
|
15
|
+
UiIcon,
|
|
16
|
+
} from "@frockbot/client-ui";
|
|
12
17
|
import { frockBotWebDataKey } from "@frockbot/plugin-shell/shared";
|
|
13
18
|
import { settingsLinkV1 } from "@frockbot/plugin-shell/settings-links";
|
|
14
19
|
import { computed, inject, watch } from "vue";
|
|
@@ -64,9 +69,14 @@ function target(entry: AuditEntryV1): string {
|
|
|
64
69
|
return entry.target.slice("remote:".length);
|
|
65
70
|
}
|
|
66
71
|
|
|
72
|
+
/**
|
|
73
|
+
* The moment, in the reader's own zone and in the house order. It used to be a
|
|
74
|
+
* bare `toLocaleString()`, which for an Australian reader rendered a US
|
|
75
|
+
* month-first date — the one column beside it that a person reads to orient
|
|
76
|
+
* themselves.
|
|
77
|
+
*/
|
|
67
78
|
function when(entry: AuditEntryV1): string {
|
|
68
|
-
|
|
69
|
-
return Number.isFinite(parsed) ? new Date(parsed).toLocaleString() : entry.at;
|
|
79
|
+
return formatMomentV1(entry.at);
|
|
70
80
|
}
|
|
71
81
|
</script>
|
|
72
82
|
|
|
@@ -166,17 +176,19 @@ function when(entry: AuditEntryV1): string {
|
|
|
166
176
|
:key="`${entry.runId}:${entry.occurrenceId}`"
|
|
167
177
|
class="audit-row"
|
|
168
178
|
>
|
|
169
|
-
<
|
|
179
|
+
<time class="audit-row__time" :datetime="entry.at">{{
|
|
180
|
+
when(entry)
|
|
181
|
+
}}</time>
|
|
170
182
|
<span class="audit-row__kind" :data-kind="entry.kind">{{
|
|
171
183
|
entry.kind
|
|
172
184
|
}}</span>
|
|
173
185
|
<span class="audit-row__target">{{ target(entry) }}</span>
|
|
174
|
-
<span class="audit-row__preview" :title="entry.toolName">{{
|
|
175
|
-
entry.preview
|
|
176
|
-
}}</span>
|
|
177
186
|
<span class="audit-row__outcome" :data-outcome="entry.outcome">{{
|
|
178
187
|
entry.outcome
|
|
179
188
|
}}</span>
|
|
189
|
+
<span class="audit-row__preview" :title="entry.toolName">{{
|
|
190
|
+
entry.preview
|
|
191
|
+
}}</span>
|
|
180
192
|
</li>
|
|
181
193
|
</ol>
|
|
182
194
|
|
|
@@ -308,8 +320,11 @@ function when(entry: AuditEntryV1): string {
|
|
|
308
320
|
.audit-row {
|
|
309
321
|
display: grid;
|
|
310
322
|
align-items: baseline;
|
|
311
|
-
gap: 8px;
|
|
312
|
-
|
|
323
|
+
gap: 4px 8px;
|
|
324
|
+
/* Time, kind, target, outcome across the top; what happened on its own line
|
|
325
|
+
underneath, where it has the whole row to say it. */
|
|
326
|
+
grid-template-areas: "time kind target outcome" "preview preview preview preview";
|
|
327
|
+
grid-template-columns: auto auto 1fr auto;
|
|
313
328
|
border: 1px solid var(--frock-border);
|
|
314
329
|
border-radius: var(--frock-radius-card);
|
|
315
330
|
padding: 6px 10px;
|
|
@@ -317,9 +332,45 @@ function when(entry: AuditEntryV1): string {
|
|
|
317
332
|
font-size: var(--frock-text-sm);
|
|
318
333
|
}
|
|
319
334
|
|
|
335
|
+
.audit-row__time {
|
|
336
|
+
grid-area: time;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
.audit-row__kind {
|
|
340
|
+
grid-area: kind;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
.audit-row__target {
|
|
344
|
+
grid-area: target;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
.audit-row__outcome {
|
|
348
|
+
grid-area: outcome;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
.audit-row__preview {
|
|
352
|
+
grid-area: preview;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/* On a phone the target is the first thing to go: it is the same for every row
|
|
356
|
+
of a session, while the tool name is the only part that says what happened
|
|
357
|
+
and used to be clipped to a single character. */
|
|
358
|
+
@media (max-width: 520px) {
|
|
359
|
+
.audit-row {
|
|
360
|
+
grid-template-areas: "time kind outcome" "preview preview preview";
|
|
361
|
+
grid-template-columns: 1fr auto auto;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
.audit-row__target {
|
|
365
|
+
display: none;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
320
369
|
.audit-row__time,
|
|
321
370
|
.audit-row__target {
|
|
371
|
+
overflow: hidden;
|
|
322
372
|
color: var(--frock-text-muted);
|
|
373
|
+
text-overflow: ellipsis;
|
|
323
374
|
white-space: nowrap;
|
|
324
375
|
}
|
|
325
376
|
|
package/src/index.ts
CHANGED
package/src/store.test.ts
CHANGED
|
@@ -128,6 +128,40 @@ describe("the audit table", () => {
|
|
|
128
128
|
expect(third.nextCursor).toBeUndefined();
|
|
129
129
|
});
|
|
130
130
|
|
|
131
|
+
test("rows written between two pages do not repeat or hide a row", () => {
|
|
132
|
+
const table = store();
|
|
133
|
+
table.insert([
|
|
134
|
+
entry({ occurrenceId: "tool:1:1:0", at: "2026-08-31T00:00:01.000Z" }),
|
|
135
|
+
entry({ occurrenceId: "tool:1:1:1", at: "2026-08-31T00:00:02.000Z" }),
|
|
136
|
+
entry({ occurrenceId: "tool:1:1:2", at: "2026-08-31T00:00:03.000Z" }),
|
|
137
|
+
]);
|
|
138
|
+
|
|
139
|
+
const first = table.query({ limit: 2 });
|
|
140
|
+
expect(first.entries.map((row) => row.occurrenceId)).toEqual([
|
|
141
|
+
"tool:1:1:2",
|
|
142
|
+
"tool:1:1:1",
|
|
143
|
+
]);
|
|
144
|
+
|
|
145
|
+
// The Bot keeps working while the reader reads. Under an offset cursor
|
|
146
|
+
// these two new rows shifted the window down by two, so "Load more"
|
|
147
|
+
// returned `tool:1:1:2` and `tool:1:1:1` a second time — duplicate rows on
|
|
148
|
+
// screen and duplicate Vue keys — and `tool:1:1:0` was never reachable.
|
|
149
|
+
table.insert([
|
|
150
|
+
entry({ occurrenceId: "tool:1:1:3", at: "2026-08-31T00:00:04.000Z" }),
|
|
151
|
+
entry({ occurrenceId: "tool:1:1:4", at: "2026-08-31T00:00:05.000Z" }),
|
|
152
|
+
]);
|
|
153
|
+
|
|
154
|
+
const second = table.query({ limit: 2, before: first.nextCursor! });
|
|
155
|
+
expect(second.entries.map((row) => row.occurrenceId)).toEqual([
|
|
156
|
+
"tool:1:1:0",
|
|
157
|
+
]);
|
|
158
|
+
expect(second.nextCursor).toBeUndefined();
|
|
159
|
+
const shown = [...first.entries, ...second.entries].map(
|
|
160
|
+
(row) => row.occurrenceId,
|
|
161
|
+
);
|
|
162
|
+
expect(new Set(shown).size).toBe(shown.length);
|
|
163
|
+
});
|
|
164
|
+
|
|
131
165
|
test("a rebuild empties the table and reproduces the identical set", async () => {
|
|
132
166
|
const table = store({ maxRows: 2 });
|
|
133
167
|
const entries = [
|
package/src/store.ts
CHANGED
|
@@ -346,10 +346,13 @@ export class AuditStoreV1 {
|
|
|
346
346
|
/**
|
|
347
347
|
* One filtered, paged answer.
|
|
348
348
|
*
|
|
349
|
-
* The cursor
|
|
350
|
-
*
|
|
351
|
-
*
|
|
352
|
-
*
|
|
349
|
+
* The cursor names the last row of the page it came from, not how many rows
|
|
350
|
+
* were skipped. An offset over a table written at the head is a window that
|
|
351
|
+
* moves under the reader: every row inserted between "Load more" and the
|
|
352
|
+
* page before it shifted the window down, so the next page repeated rows
|
|
353
|
+
* already on screen — visibly, as duplicate keys — and skipped exactly as
|
|
354
|
+
* many that nobody ever saw. A key range is stable under inserts because it
|
|
355
|
+
* addresses a row rather than a position.
|
|
353
356
|
*/
|
|
354
357
|
query(request: {
|
|
355
358
|
botId?: string;
|
|
@@ -364,7 +367,7 @@ export class AuditStoreV1 {
|
|
|
364
367
|
// row past the 180-day bound simply because no insert came to evict them.
|
|
365
368
|
this.evict();
|
|
366
369
|
const limit = Math.min(Math.max(request.limit ?? 50, 1), 500);
|
|
367
|
-
const
|
|
370
|
+
const after = decodeAuditCursorV1(request.before);
|
|
368
371
|
const clauses: string[] = [];
|
|
369
372
|
const bindings: unknown[] = [];
|
|
370
373
|
if (request.botId) {
|
|
@@ -388,20 +391,53 @@ export class AuditStoreV1 {
|
|
|
388
391
|
)
|
|
389
392
|
.toArray()[0]?.n ?? 0,
|
|
390
393
|
);
|
|
394
|
+
// The predicate is the sort order spelled out: strictly after the cursor
|
|
395
|
+
// row under `at DESC, bot_id ASC, run_id ASC, occurrence_id ASC`.
|
|
396
|
+
const pageClauses = [...clauses];
|
|
397
|
+
const pageBindings = [...bindings];
|
|
398
|
+
if (after) {
|
|
399
|
+
pageClauses.push(
|
|
400
|
+
"(at < ?" +
|
|
401
|
+
" OR (at = ? AND bot_id > ?)" +
|
|
402
|
+
" OR (at = ? AND bot_id = ? AND run_id > ?)" +
|
|
403
|
+
" OR (at = ? AND bot_id = ? AND run_id = ? AND occurrence_id > ?))",
|
|
404
|
+
);
|
|
405
|
+
pageBindings.push(
|
|
406
|
+
after.at,
|
|
407
|
+
after.at,
|
|
408
|
+
after.botId,
|
|
409
|
+
after.at,
|
|
410
|
+
after.botId,
|
|
411
|
+
after.runId,
|
|
412
|
+
after.at,
|
|
413
|
+
after.botId,
|
|
414
|
+
after.runId,
|
|
415
|
+
after.occurrenceId,
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
const pageWhere =
|
|
419
|
+
pageClauses.length > 0 ? ` WHERE ${pageClauses.join(" AND ")}` : "";
|
|
391
420
|
const rows = this.sql
|
|
392
421
|
.exec<AuditRow>(
|
|
393
|
-
`SELECT * FROM ${TABLE}${
|
|
394
|
-
"ORDER BY at DESC, bot_id ASC, run_id ASC, occurrence_id ASC LIMIT ?
|
|
395
|
-
...
|
|
422
|
+
`SELECT * FROM ${TABLE}${pageWhere} ` +
|
|
423
|
+
"ORDER BY at DESC, bot_id ASC, run_id ASC, occurrence_id ASC LIMIT ?",
|
|
424
|
+
...pageBindings,
|
|
396
425
|
limit + 1,
|
|
397
|
-
offset,
|
|
398
426
|
)
|
|
399
427
|
.toArray();
|
|
400
|
-
const
|
|
428
|
+
const kept = rows.slice(0, limit);
|
|
429
|
+
const last = kept.at(-1);
|
|
401
430
|
return {
|
|
402
|
-
entries:
|
|
403
|
-
...(rows.length > limit
|
|
404
|
-
? {
|
|
431
|
+
entries: kept.map(fromRow),
|
|
432
|
+
...(rows.length > limit && last
|
|
433
|
+
? {
|
|
434
|
+
nextCursor: encodeAuditCursorV1({
|
|
435
|
+
at: last.at,
|
|
436
|
+
botId: last.bot_id,
|
|
437
|
+
runId: last.run_id,
|
|
438
|
+
occurrenceId: last.occurrence_id,
|
|
439
|
+
}),
|
|
440
|
+
}
|
|
405
441
|
: {}),
|
|
406
442
|
total,
|
|
407
443
|
};
|
|
@@ -473,16 +509,53 @@ export class AuditStoreV1 {
|
|
|
473
509
|
}
|
|
474
510
|
}
|
|
475
511
|
|
|
476
|
-
|
|
512
|
+
/** The row a page cursor addresses. */
|
|
513
|
+
export interface AuditCursorV1 {
|
|
514
|
+
at: string;
|
|
515
|
+
botId: string;
|
|
516
|
+
runId: string;
|
|
517
|
+
occurrenceId: string;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const CURSOR_SEPARATOR = "\u0000";
|
|
521
|
+
const CURSOR_MAX_FIELD = 256;
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* The cursor is the sort key of the last row of a page, encoded opaquely.
|
|
525
|
+
*
|
|
526
|
+
* It addresses a row and not a position, so rows inserted at the head between
|
|
527
|
+
* one page and the next cannot shift the window: "Load more" continues from
|
|
528
|
+
* where the reader is, rather than from a count that has since moved.
|
|
529
|
+
*/
|
|
530
|
+
export function encodeAuditCursorV1(cursor: AuditCursorV1): string {
|
|
531
|
+
return btoa(
|
|
532
|
+
[cursor.at, cursor.botId, cursor.runId, cursor.occurrenceId].join(
|
|
533
|
+
CURSOR_SEPARATOR,
|
|
534
|
+
),
|
|
535
|
+
);
|
|
536
|
+
}
|
|
477
537
|
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
538
|
+
export function decodeAuditCursorV1(
|
|
539
|
+
cursor: string | undefined,
|
|
540
|
+
): AuditCursorV1 | undefined {
|
|
541
|
+
if (cursor === undefined) return undefined;
|
|
542
|
+
let decoded: string;
|
|
543
|
+
try {
|
|
544
|
+
decoded = atob(cursor);
|
|
545
|
+
} catch {
|
|
485
546
|
throw new Error("audit cursor is invalid");
|
|
486
547
|
}
|
|
487
|
-
|
|
548
|
+
const fields = decoded.split(CURSOR_SEPARATOR);
|
|
549
|
+
if (fields.length !== 4) throw new Error("audit cursor is invalid");
|
|
550
|
+
for (const field of fields) {
|
|
551
|
+
if (field.length > CURSOR_MAX_FIELD) {
|
|
552
|
+
throw new Error("audit cursor is invalid");
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
return {
|
|
556
|
+
at: fields[0]!,
|
|
557
|
+
botId: fields[1]!,
|
|
558
|
+
runId: fields[2]!,
|
|
559
|
+
occurrenceId: fields[3]!,
|
|
560
|
+
};
|
|
488
561
|
}
|
package/src/testing.ts
CHANGED
|
@@ -194,9 +194,10 @@ export class FakeAuditSql implements AuditSqlV1 {
|
|
|
194
194
|
(left, right) => -order(left, right),
|
|
195
195
|
);
|
|
196
196
|
if (!sql.includes("LIMIT")) return answer(descending);
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
197
|
+
// Paging is a key range now, not an offset: the limit is the last
|
|
198
|
+
// binding and the page starts wherever the cursor predicate left off.
|
|
199
|
+
const limit = Number(bindings[bindings.length - 1]);
|
|
200
|
+
return answer(descending.slice(0, limit));
|
|
200
201
|
}
|
|
201
202
|
throw new Error(`FakeAuditSql does not recognise: ${sql}`);
|
|
202
203
|
}
|
|
@@ -208,13 +209,34 @@ export class FakeAuditSql implements AuditSqlV1 {
|
|
|
208
209
|
if (where.startsWith("at <")) {
|
|
209
210
|
return this.rowsIn(table).filter((row) => row.at < String(values[0]));
|
|
210
211
|
}
|
|
212
|
+
// The keyset page predicate is always the last clause, and it is the one
|
|
213
|
+
// clause that is not `column = ?`.
|
|
214
|
+
const keysetAt = where.indexOf("(at < ?");
|
|
215
|
+
const columnWhere =
|
|
216
|
+
keysetAt < 0 ? where : where.slice(0, keysetAt).replace(/ AND $/u, "");
|
|
211
217
|
const predicates: Array<(row: FakeRow) => boolean> = [];
|
|
212
|
-
for (const clause of
|
|
218
|
+
for (const clause of columnWhere.split(" AND ").filter(Boolean)) {
|
|
213
219
|
const column = clause.split(" ")[0] as keyof FakeRow;
|
|
214
220
|
if (!COLUMNS.includes(column as (typeof COLUMNS)[number])) continue;
|
|
215
221
|
const expected = String(values.shift());
|
|
216
222
|
predicates.push((row) => String(row[column]) === expected);
|
|
217
223
|
}
|
|
224
|
+
if (keysetAt >= 0) {
|
|
225
|
+
const at = String(values[0]);
|
|
226
|
+
const botId = String(values[2]);
|
|
227
|
+
const runId = String(values[5]);
|
|
228
|
+
const occurrenceId = String(values[9]);
|
|
229
|
+
predicates.push(
|
|
230
|
+
(row) =>
|
|
231
|
+
row.at < at ||
|
|
232
|
+
(row.at === at && row.bot_id > botId) ||
|
|
233
|
+
(row.at === at && row.bot_id === botId && row.run_id > runId) ||
|
|
234
|
+
(row.at === at &&
|
|
235
|
+
row.bot_id === botId &&
|
|
236
|
+
row.run_id === runId &&
|
|
237
|
+
row.occurrence_id > occurrenceId),
|
|
238
|
+
);
|
|
239
|
+
}
|
|
218
240
|
return this.rowsIn(table).filter((row) =>
|
|
219
241
|
predicates.every((predicate) => predicate(row)),
|
|
220
242
|
);
|