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:
co-authored by
Claude Opus 5
parent
ebdc8eacf2
commit
85f7b2488d
@@ -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 }),
|
||||
|
||||
|
||||
@@ -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 ?? '';
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user