feat(admin): what has been done on this server, across every project

The other half of the error log. That one is what broke; this is what was done,
and nobody could ask it: `ListActivity` takes one project id, and an
administrator is not necessarily a member of the project they need to look into.

Paged with a keyset cursor rather than an offset. The log is written to
constantly and read rarely, so a page fetched a minute after the one before it
would be shifted by everything written in between — one row shown twice and the
row behind it skipped, which is the single failure an audit log must not have.
Both halves of the cursor matter: the log is written in whole seconds, so a
timestamp alone cannot separate two rows written in the same one.

Migration 0009 makes `activity.user_id` SET NULL for the same reason 0008 did it
to `created_by`. A log whose rows vanish with the account they describe answers
"what has happened here" with everything except the part somebody had a reason to
remove. `project_id` keeps cascading on purpose: the trash is the undo, and past
it the deletion was deliberate.

The event names moved to `web/src/lib/events.js`, because two surfaces read the
same log now and two copies of nineteen strings is two places for them to drift.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kristian Wind
2026-08-18 09:04:12 +02:00
co-authored by Claude Opus 5
parent ebdc8eacf2
commit 85f7b2488d
10 changed files with 996 additions and 56 deletions
+30
View File
@@ -160,6 +160,9 @@ export const api = {
logout: () => post('/auth/logout'),
me: () => get('/auth/me'),
updateProfile: (data) => patch('/auth/me', data),
// Its own route rather than a field on the profile: that one backs a form with
// a save button, and this writes on every click of a chevron.
setSidebarSections: (sections) => put('/auth/sidebar-sections', { sections }),
signup: (data) => post('/auth/signup', data),
forgotPassword: (email) => post('/auth/password/forgot', { email }),
resetPassword: (token, password) => post('/auth/password/reset', { token, password }),
@@ -180,6 +183,33 @@ export const api = {
revokeInvite: (id) => del(`/invites/${id}`),
listErrors: () => get('/errors'),
// The nightly backup, which ran since the beginning with nothing to show it.
// The OAuth client itself, which is the instance's registration with Google
// rather than anybody's mailbox.
gmailClient: () => get('/gmail/client'),
setGmailClient: (data) => put('/gmail/client', data),
listBackups: () => get('/backups'),
runBackup: () => post('/backups'),
// A link rather than a fetch: the browser's own download handles a file of
// this size, and a blob in memory of the whole database does not.
backupURL: (id) => `/api/v1/backups/${id}`,
// The instance-wide history, which is the other half of the error log: that
// one is what broke, this is what was done. Paged with the cursor the server
// hands back rather than an offset — see the OpenAPI note on /activity.
auditLog: ({ before, user_id, project_id, event, limit } = {}) => {
const q = new URLSearchParams();
if (before) q.set('before', before);
if (user_id) q.set('user_id', user_id);
if (project_id) q.set('project_id', project_id);
if (event) q.set('event', event);
if (limit) q.set('limit', limit);
const query = q.toString();
return get(`/activity${query ? `?${query}` : ''}`);
},
auditEvents: () => get('/activity/events'),
recoveryCodes: () => get('/auth/recovery-codes'),
regenerateRecoveryCodes: (password) => post('/auth/recovery-codes', { password }),
+44
View File
@@ -0,0 +1,44 @@
/**
* What each recorded activity event is called.
*
* The keys are the server's and they are stable; this is the only place they
* become Danish. It lives in its own module because two surfaces read the same log
* now — a project's own history panel, and the administrator's instance-wide audit
* page — and two copies of nineteen strings is two places for them to drift apart.
*
* An unknown key falls through to the key itself rather than being hidden: a build
* that records something this table has not learned yet should still show that it
* happened.
*/
export const EVENTS = {
'project.created': 'oprettede projektet',
'project.updated': 'ændrede projektet',
'project.deleted': 'slettede projektet',
'project.imported': 'importerede projektet',
'section.created': 'oprettede en sektion',
'section.updated': 'ændrede en sektion',
'section.deleted': 'slettede en sektion',
'member.invited': 'inviterede',
'member.added': 'tilføjede',
'member.removed': 'fjernede',
'member.role_changed': 'ændrede rollen for',
'task.created': 'oprettede en opgave',
'task.updated': 'ændrede en opgave',
'task.completed': 'lukkede en opgave',
'task.reopened': 'genåbnede en opgave',
'task.moved': 'flyttede en opgave',
'task.deleted': 'slettede en opgave',
'task.split': 'delte en opgave op',
'comment.created': 'skrev en kommentar'
};
/** The name of an event, or the raw key when it is one this build does not know. */
export function eventName(event) {
return EVENTS[event] ?? event;
}
/** The bit of context an entry carries, when it has one worth reading. */
export function eventDetail(entry) {
const p = entry.payload ?? {};
return p.name ?? p.email ?? p.role ?? '';
}
+4 -3
View File
@@ -2,7 +2,7 @@
/**
* The settings surface.
*
* Six pages rather than one long scroll: the sections have nothing to do with
* Several pages rather than one long scroll: the sections have nothing to do with
* each other, and a person opening "API-tokens" should not have to walk past
* their own password to get there.
*
@@ -28,6 +28,7 @@
...(app.user?.is_admin
? [
{ href: '/indstillinger/brugere', label: 'Brugere' },
{ href: '/indstillinger/historik', label: 'Historik' },
{ href: '/indstillinger/fejl', label: 'Fejl' }
]
: []),
@@ -65,8 +66,8 @@
margin-bottom: var(--s5);
}
/* A scrolling strip rather than a wrapping row: six labels wrap to two lines on
a phone, and a two-line tab bar looks like a mistake. */
/* A scrolling strip rather than a wrapping row: this many labels wrap to two
lines on a phone, and a two-line tab bar looks like a mistake. */
nav {
display: flex;
gap: var(--s1);
@@ -0,0 +1,307 @@
<script>
/**
* Historik: what has been done on this instance, across every project.
*
* The other half of Fejl. That page is what broke; this is what was done, and an
* administrator had nowhere to ask the second question: the per-project log
* takes one project id, and an administrator is not necessarily a member of the
* project they need to look into.
*
* Paged with the cursor the server hands back rather than a page number. The log
* is written to constantly and read rarely, so a second page fetched a minute
* after the first would otherwise be shifted by everything written in between —
* one row shown twice and the row behind it skipped, which is the one failure an
* audit log must not have.
*/
import { api, humanMessage } from '$lib/api.js';
import { app } from '$lib/stores.svelte.js';
import { eventName, eventDetail } from '$lib/events.js';
let entries = $state([]);
let cursor = $state(null);
let loading = $state(false);
let loaded = $state(false);
// The filters. Events come from the server rather than from a list here: the
// names are an internal vocabulary, and a select built from what actually
// occurred cannot offer an option that returns nothing.
let events = $state([]);
let people = $state([]);
let projects = $state([]);
let filterUser = $state('');
let filterProject = $state('');
let filterEvent = $state('');
$effect(() => {
Promise.all([api.auditEvents(), api.listUsers(), api.listProjects()])
.then(([e, u, p]) => {
events = e.events;
people = u.users;
projects = p.projects;
})
.catch((error) => app.toast(humanMessage(error)));
});
// Reloads from the top whenever a filter changes. Reading the three filters here
// is what subscribes the effect to them.
$effect(() => {
const query = {
user_id: filterUser,
project_id: filterProject,
event: filterEvent,
limit: 50
};
let cancelled = false;
loading = true;
api
.auditLog(query)
.then((r) => {
if (cancelled) return;
entries = r.activity;
cursor = r.next_cursor ?? null;
loaded = true;
})
.catch((error) => {
if (!cancelled) app.toast(humanMessage(error));
})
.finally(() => {
if (!cancelled) loading = false;
});
// A filter changed while a page was in flight: the late answer belongs to the
// old filter and must not land on top of the new one.
return () => {
cancelled = true;
};
});
async function more() {
if (!cursor || loading) return;
loading = true;
try {
const r = await api.auditLog({
before: cursor,
user_id: filterUser,
project_id: filterProject,
event: filterEvent,
limit: 50
});
entries = [...entries, ...r.activity];
cursor = r.next_cursor ?? null;
} catch (error) {
app.toast(humanMessage(error));
} finally {
loading = false;
}
}
function when(iso) {
const then = new Date(iso);
const seconds = Math.round((Date.now() - then) / 1000);
if (seconds < 60) return 'lige nu';
if (seconds < 3600) return `for ${Math.floor(seconds / 60)} min. siden`;
if (seconds < 86400) return `for ${Math.floor(seconds / 3600)} timer siden`;
return then.toLocaleString('da-DK', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit'
});
}
let filtered = $derived(Boolean(filterUser || filterProject || filterEvent));
function clearFilters() {
filterUser = '';
filterProject = '';
filterEvent = '';
}
</script>
<section class="panel">
<header>
<h2>Historik</h2>
<p class="hint">
Alt, der er blevet gjort på denne server, på tværs af alle projekter — også
dem, du ikke selv er med i. Projekternes egne historik-paneler viser det
samme, ét projekt ad gangen; det her er spørgsmålet, de ikke kan svare på.
Rækkerne bliver stående, når en konto slettes: det, der blev gjort, holder op
med at have et navn, ikke med at være sket.
</p>
</header>
<div class="filters">
<label>
<span>Person</span>
<select bind:value={filterUser}>
<option value="">Alle</option>
{#each people as person (person.id)}
<option value={person.id}>{person.name}</option>
{/each}
</select>
</label>
<label>
<span>Projekt</span>
<select bind:value={filterProject}>
<option value="">Alle</option>
{#each projects as project (project.id)}
<option value={project.id}>{project.name}</option>
{/each}
</select>
</label>
<label>
<span>Hændelse</span>
<select bind:value={filterEvent}>
<option value="">Alle</option>
{#each events as event (event.event)}
<option value={event.event}>{eventName(event.event)} ({event.count})</option>
{/each}
</select>
</label>
{#if filtered}
<button class="secondary" onclick={clearFilters}>Ryd</button>
{/if}
</div>
{#if entries.length}
<ul class="rows">
{#each entries as entry (entry.id)}
<li>
<div class="what">
<span class="primary-line">
<span class="who" class:gone={!entry.user_name}
>{entry.user_name || 'En slettet konto'}</span
>
<span class="event"
>{eventName(entry.event)}{#if eventDetail(entry)}
<span class="detail">{eventDetail(entry)}</span>{/if}</span
>
</span>
<span class="secondary">{entry.project_name}</span>
</div>
<span class="when">{when(entry.at)}</span>
</li>
{/each}
</ul>
{#if cursor}
<div class="row">
<button class="secondary" onclick={more} disabled={loading}>
{loading ? 'Henter…' : 'Hent flere'}
</button>
</div>
{/if}
{:else if loaded}
<p class="hint">
{filtered
? 'Ingen hændelser matcher de valgte filtre.'
: 'Der er ikke registreret noget endnu.'}
</p>
{/if}
</section>
<style>
/* The filters wrap rather than scroll: three selects on a phone are three lines,
and a select that has scrolled halfway out of view is a filter you cannot see
the state of. */
.filters {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: var(--s3);
}
.filters label {
display: flex;
flex-direction: column;
gap: var(--s1);
font-size: var(--text-sm);
color: var(--ink-muted);
min-width: 0;
flex: 1 1 10rem;
}
.filters select {
width: 100%;
padding: var(--s2) var(--s3);
background: var(--surface-sunken);
border: 1px solid var(--line);
border-radius: var(--radius);
font-size: var(--text-sm);
outline: none;
}
.filters select:focus {
border-color: var(--accent);
}
.rows {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
}
.rows li {
display: flex;
align-items: flex-start;
gap: var(--s3);
padding: var(--s3) 0;
border-bottom: 1px solid var(--line);
}
.rows li:last-child {
border-bottom: 0;
}
.what {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.primary-line {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: var(--s2);
font-size: var(--text-sm);
}
.who {
font-weight: 500;
}
/* A name that is not a name reads at the weight of the surrounding prose, not at
the weight a person's name gets. */
.who.gone {
font-weight: 400;
font-style: italic;
color: var(--ink-faint);
}
.event {
color: var(--ink-muted);
}
.detail {
color: var(--ink);
}
.secondary {
font-size: var(--text-xs);
color: var(--ink-faint);
overflow-wrap: anywhere;
}
.when {
font-size: var(--text-xs);
color: var(--ink-faint);
flex: none;
}
</style>
+26 -49
View File
@@ -5,11 +5,13 @@
import { api, humanMessage } from '$lib/api.js';
import { app } from '$lib/stores.svelte.js';
import { COLORS, colorVar } from '$lib/colors.js';
import { eventName, eventDetail } from '$lib/events.js';
import { TASK, carries, dragged, accept } from '$lib/dnd.js';
import TaskList from '$lib/components/TaskList.svelte';
import QuickAdd from '$lib/components/QuickAdd.svelte';
import BoardView from '$lib/components/BoardView.svelte';
import CalendarView from '$lib/components/CalendarView.svelte';
import { focusOnMount } from '$lib/focus.js';
let project = $state(null);
/**
@@ -75,45 +77,6 @@
}
});
/**
* What each recorded event is called.
*
* The log has been written since the beginning — nineteen kinds of entry — and
* nothing has ever read it. The keys are the server's and they are stable; this
* is the only place they become Danish.
*
* An unknown key falls through to the key itself rather than being hidden: a
* build that records something this table has not learned yet should still show
* that it happened.
*/
const EVENTS = {
'project.created': 'oprettede projektet',
'project.updated': 'ændrede projektet',
'project.deleted': 'slettede projektet',
'project.imported': 'importerede projektet',
'section.created': 'oprettede en sektion',
'section.updated': 'ændrede en sektion',
'section.deleted': 'slettede en sektion',
'member.invited': 'inviterede',
'member.added': 'tilføjede',
'member.removed': 'fjernede',
'member.role_changed': 'ændrede rollen for',
'task.created': 'oprettede en opgave',
'task.updated': 'ændrede en opgave',
'task.completed': 'lukkede en opgave',
'task.reopened': 'genåbnede en opgave',
'task.moved': 'flyttede en opgave',
'task.deleted': 'slettede en opgave',
'task.split': 'delte en opgave op',
'comment.created': 'skrev en kommentar'
};
/** The bit of context an entry carries, when it has one worth reading. */
function detail(entry) {
const p = entry.payload ?? {};
return p.name ?? p.email ?? p.role ?? '';
}
function when(iso) {
const then = new Date(iso);
const seconds = Math.round((Date.now() - then) / 1000);
@@ -359,10 +322,9 @@
what a screen reader announces on arrival. So the heading stays and
becomes editable on click. -->
{#if editing}
<!-- svelte-ignore a11y_autofocus -->
<input
class="title"
autofocus
use:focusOnMount
value={project.name}
aria-label="Projektets navn"
onblur={(e) => rename(e.currentTarget)}
@@ -432,10 +394,14 @@
<ul class="log">
{#each activity as entry (entry.id)}
<li>
<span class="who">{entry.user_name}</span>
<!-- Empty when the account has been deleted: the record of what was
done outlives whoever did it, so the row stays and the name goes. -->
<span class="who" class:gone={!entry.user_name}
>{entry.user_name || 'En slettet konto'}</span
>
<span class="what">
{EVENTS[entry.event] ?? entry.event}{#if detail(entry)}
<span class="detail">{detail(entry)}</span>{/if}
{eventName(entry.event)}{#if eventDetail(entry)}
<span class="detail">{eventDetail(entry)}</span>{/if}
</span>
<span class="when">{when(entry.created_at)}</span>
</li>
@@ -536,7 +502,12 @@
{/if}
{#if mode === 'board'}
<BoardView {project} {sections} {canEdit} />
<BoardView
{project}
{sections}
{canEdit}
onsectionadded={(section) => (sections = [...sections, section])}
/>
{:else if mode === 'calendar'}
<CalendarView projectId={project.id} />
{:else}
@@ -568,10 +539,9 @@
<div class="section-head">
{#if renamingSection === section.id}
<form onsubmit={(e) => renameSection(e, section)}>
<!-- svelte-ignore a11y_autofocus -->
<input
bind:value={sectionName}
autofocus
use:focusOnMount
aria-label="Sektionens navn"
onblur={() => (renamingSection = null)}
onkeydown={(e) => e.key === 'Escape' && (renamingSection = null)}
@@ -604,10 +574,9 @@
<section class="add-section">
{#if addingSection}
<form onsubmit={addSection}>
<!-- svelte-ignore a11y_autofocus -->
<input
bind:value={sectionName}
autofocus
use:focusOnMount
placeholder="Sektionens navn"
aria-label="Ny sektion"
onblur={() => !sectionName.trim() && (addingSection = false)}
@@ -1000,6 +969,14 @@
flex: none;
}
/* A name that is not a name reads at the weight of the surrounding prose, not
at the weight a person's name gets. */
.who.gone {
font-weight: 400;
font-style: italic;
color: var(--ink-faint);
}
.what {
color: var(--ink-muted);
flex: 1;