feat(ui): E31 S4a — rail rows are links, the channel page opens on unwatched, users and audit tidied
- Nav rail page rows are real links (/?page=<id>): a middle-click or Ctrl-click opens the page in a new tab; a plain click stays in-app. - A fresh channel page shows the unwatched videos; a picked watch state is a remembered preference (channelShow). - Users: the role, suspend and delete move into the row's one menu (the "…", right-click, Shift+F10); locked ones keep their reason. - Audit log: back-to-back successful runs of one job fold into one row that opens to the earlier ones; a failed run reads in the danger colour. - DESIGN.md decision log: the E31 picks.
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
// E31: the audit log's first screen was one job's "done" ten times over. Back-to-back successful runs
|
||||
// of one job fold into one row; a failure, a skip, another job or an admin's own action breaks the run.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AuditRow } from "./api";
|
||||
import { foldJobRuns } from "./auditGroups";
|
||||
|
||||
let nextId = 1;
|
||||
const run = (job: string, status = "ok"): AuditRow => ({
|
||||
id: nextId++,
|
||||
created_at: null,
|
||||
actor: null,
|
||||
action: "job_run",
|
||||
target_type: "job",
|
||||
target_id: job,
|
||||
summary: null,
|
||||
before: null,
|
||||
after: { status },
|
||||
});
|
||||
const admin = (): AuditRow => ({ ...run("x"), action: "config_set", target_type: "config" });
|
||||
|
||||
const shape = (rows: ReturnType<typeof foldJobRuns>) =>
|
||||
rows.map((r) => `${r.target_id}:${String(r.after?.status)}×${r.runs?.length ?? 1}`);
|
||||
|
||||
describe("folding the job runs", () => {
|
||||
it("folds back-to-back successful runs of one job, newest first", () => {
|
||||
const rows = [run("enrich"), run("enrich"), run("enrich"), run("people")];
|
||||
const out = foldJobRuns(rows);
|
||||
expect(shape(out)).toEqual(["enrich:ok×3", "people:ok×1"]);
|
||||
expect(out[0]!.id).toBe(rows[0]!.id);
|
||||
expect(out[0]!.runs!.map((r) => r.id)).toEqual(rows.slice(0, 3).map((r) => r.id));
|
||||
});
|
||||
|
||||
it("never folds a failed or skipped run, and lets it break the run", () => {
|
||||
const rows = [run("rss"), run("rss", "error"), run("rss", "error"), run("rss"), run("rss")];
|
||||
expect(shape(foldJobRuns(rows))).toEqual([
|
||||
"rss:ok×1",
|
||||
"rss:error×1",
|
||||
"rss:error×1",
|
||||
"rss:ok×2",
|
||||
]);
|
||||
expect(shape(foldJobRuns([run("rss", "skipped"), run("rss", "skipped")]))).toEqual([
|
||||
"rss:skipped×1",
|
||||
"rss:skipped×1",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps an admin's own action between two runs, and the runs apart", () => {
|
||||
expect(shape(foldJobRuns([run("enrich"), admin(), run("enrich")]))).toEqual([
|
||||
"enrich:ok×1",
|
||||
"x:ok×1",
|
||||
"enrich:ok×1",
|
||||
]);
|
||||
});
|
||||
|
||||
it("leaves the rows it was given as they were", () => {
|
||||
const rows = [run("enrich"), run("enrich")];
|
||||
foldJobRuns(rows);
|
||||
expect("runs" in rows[0]!).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { AuditRow } from "./api";
|
||||
|
||||
// The audit log's scheduler noise, folded (E31, the owner's pick): the background jobs write a row per
|
||||
// run that changed something, so an hour of "enrichment done" filled the first screen and pushed the
|
||||
// admins' own actions down. Back-to-back SUCCESSFUL runs of the same job become one row that names how
|
||||
// many there were and opens to each; a failed or skipped run is never folded — it stays a row of its
|
||||
// own, where it is seen. Rows arrive newest first; the group stands at its newest run's place.
|
||||
|
||||
/** An audit row as the table lists it: a lone row, or the newest of a run of folded ones (`runs`, the
|
||||
* whole run newest first, the row itself included). */
|
||||
export type AuditListRow = AuditRow & { runs?: AuditRow[] };
|
||||
|
||||
/** The job a row is a successful run of, or null for any other row. */
|
||||
function okRunOf(row: AuditRow): string | null {
|
||||
if (row.action !== "job_run" || row.target_type !== "job" || !row.target_id) return null;
|
||||
return row.after?.status === "ok" ? row.target_id : null;
|
||||
}
|
||||
|
||||
export function foldJobRuns(rows: readonly AuditRow[]): AuditListRow[] {
|
||||
const out: AuditListRow[] = [];
|
||||
let head: AuditListRow | null = null;
|
||||
for (const row of rows) {
|
||||
const job = okRunOf(row);
|
||||
if (job !== null && head !== null && okRunOf(head) === job) {
|
||||
head.runs = [...(head.runs ?? [head]), row];
|
||||
continue;
|
||||
}
|
||||
head = { ...row };
|
||||
out.push(head);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -17,6 +17,13 @@ export function navigateTo(p: Page): void {
|
||||
_navigate?.(p);
|
||||
}
|
||||
|
||||
/** A page's own address, for a link to it (the nav rail's rows). Only the page: the rest of a page's
|
||||
* state — the feed's filters, Plex's library — a fresh tab restores from the account's own storage,
|
||||
* as a bookmark would; the CURRENT page's params would be the wrong page's state. */
|
||||
export function pageHref(p: Page): string {
|
||||
return `/?page=${p}`;
|
||||
}
|
||||
|
||||
/** Should this click be handled IN-APP, or left to the browser?
|
||||
*
|
||||
* An anchor that ALSO navigates in-app has to keep the browser's own gestures working — Ctrl/Cmd-click
|
||||
|
||||
@@ -35,6 +35,7 @@ export const LS = {
|
||||
channelLayout: "siftlode.channelLayout",
|
||||
// The manager's sort, lifted out of the table so the card layout shares it (per-account, per-tab).
|
||||
channelSort: "siftlode.channelSort",
|
||||
channelShow: "siftlode.channelShow",
|
||||
channelDiscoverySort: "siftlode.channelDiscoverySort",
|
||||
// Cards-per-page for the card layout (the table keeps its own rows-per-page in its persist blob).
|
||||
channelCardSize: "siftlode.channelCardSize",
|
||||
|
||||
Reference in New Issue
Block a user