feat(event-triage): per-user check interval, overriding the instance one
Nightly Build / build (push) Successful in 5m4s
Nightly Build / build (push) Successful in 5m4s
Event triage is the one system agent whose right cadence depends on who it
runs for: it fires on inbound events, so someone on a dozen mailing lists
has something waiting on nearly every tick while a quiet account has
something waiting almost never. A single instance-wide interval serves one
of them badly, and the observed failure is the first: the agent starts on
practically every pass.
An admin can now set a per-person interval on that user's page (Users ->
the person -> Event triage). Empty means "follow the instance setting",
which stays the state nobody has a row for.
- New registry table `system_agent_user_settings(agent_id, user_id,
interval_secs)`. A row is an override and its absence is inheritance --
no sentinel value, no row seeded at user creation, clearing the field
deletes the row. Registry rather than the user's own file because the
writer is the admin and a member's database is unreadable unless they
happen to be logged in; a setting that could only be changed during its
subject's session would not be a setting. Keyed by agent_id though only
one agent uses it, so a future agent's schedule is not a schema change.
- `SystemAgent` gains `interval_secs_for(user_id)`, which `is_due` now
measures against, and `shortest_interval_secs()`. Both default to the
existing `interval_secs`, so every other agent implements nothing. The
second is the non-obvious half: `base_tick` sleeps for the shortest
interval any enabled agent asks for, so without it an override below the
instance value would be rounded up to it -- an override that works when
it lengthens and silently does nothing when it shortens.
- `GET/PUT /api/users/{id}/event-triage`, admin-gated, minutes on the
wire, null to clear. Nothing rides the bus: the scheduler re-reads the
interval every tick and due-ness is counted from the user's own last
attempt, so a change lands on the next wake-up with no push.
Both helpers fail open onto the instance value -- an unreadable registry
must not turn into an agent that stops running for someone.
Docs: docs/system-agents.md gains the per-person section and no longer
reads as if the interval were one number for everybody.
This commit is contained in:
@@ -46,11 +46,14 @@ export class UsersPage extends LightElement {
|
||||
_connQ: { state: true },
|
||||
_noIcon: { state: true }, // connector names whose icon failed to load
|
||||
_plugs: { state: true }, // working copy of the user's plugin grants
|
||||
_triage: { state: true }, // { interval_minutes, default_interval_minutes } | null
|
||||
_triageIn: { state: true }, // the input's own string ('' = follow the instance default)
|
||||
_busy: { state: true },
|
||||
_dSaved: { state: true }, // "saved" ticks, one per section
|
||||
_pwSaved: { state: true },
|
||||
_connSaved: { state: true },
|
||||
_plugSaved: { state: true },
|
||||
_trgSaved: { state: true },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -73,11 +76,14 @@ export class UsersPage extends LightElement {
|
||||
this._conns = null;
|
||||
this._connQ = '';
|
||||
this._plugs = null;
|
||||
this._triage = null;
|
||||
this._triageIn = '';
|
||||
this._busy = false;
|
||||
this._dSaved = false;
|
||||
this._pwSaved = false;
|
||||
this._connSaved = false;
|
||||
this._plugSaved = false;
|
||||
this._trgSaved = false;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
@@ -153,6 +159,17 @@ export class UsersPage extends LightElement {
|
||||
this._conns = await cRes.json();
|
||||
this._plugs = await pRes.json();
|
||||
} catch (e) { this._error = e.message; }
|
||||
|
||||
// Admin-only, unlike the two above (which a role holding `plugin.manage` can
|
||||
// also reach). A refusal hides the section rather than reddening the page:
|
||||
// there is nothing wrong, this reader simply has no business with schedules.
|
||||
try {
|
||||
const res = await fetch(`/api/users/${encodeURIComponent(u.id)}/event-triage`);
|
||||
if (res.ok) {
|
||||
this._triage = await res.json();
|
||||
this._triageIn = this._triage.interval_minutes == null ? '' : String(this._triage.interval_minutes);
|
||||
}
|
||||
} catch { /* section stays hidden */ }
|
||||
}
|
||||
|
||||
_openUser(u) {
|
||||
@@ -294,6 +311,34 @@ export class UsersPage extends LightElement {
|
||||
finally { this._busy = false; }
|
||||
}
|
||||
|
||||
// ── Detail: event-triage schedule ────────────────────────────────────────────
|
||||
|
||||
async _saveTriage() {
|
||||
const u = this._user;
|
||||
const raw = this._triageIn.trim();
|
||||
// Empty is a value, not a missing one: it clears the override and puts this
|
||||
// person back on the instance schedule. Hence `null` rather than an omitted
|
||||
// field, and hence no "use default" checkbox — the empty box says it.
|
||||
let interval_minutes = null;
|
||||
if (raw !== '') {
|
||||
const n = Number(raw);
|
||||
if (!Number.isInteger(n) || n < 1) { this._error = t('users.triage.invalid'); return; }
|
||||
interval_minutes = n;
|
||||
}
|
||||
this._busy = true; this._error = null;
|
||||
try {
|
||||
const res = await fetch(`/api/users/${encodeURIComponent(u.id)}/event-triage`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ interval_minutes }),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
this._triage = await res.json();
|
||||
this._trgSaved = true;
|
||||
} catch (e) { this._error = e.message; }
|
||||
finally { this._busy = false; }
|
||||
}
|
||||
|
||||
// ── Detail: security ──────────────────────────────────────────────────────────
|
||||
|
||||
async _resetPassword() {
|
||||
@@ -428,6 +473,7 @@ export class UsersPage extends LightElement {
|
||||
${this._renderProfile(u)}
|
||||
${this._renderConnectors(u)}
|
||||
${this._renderPlugins(u)}
|
||||
${this._renderTriage(u)}
|
||||
${this._renderSecurity(u)}
|
||||
</div>
|
||||
</div>`;
|
||||
@@ -634,6 +680,40 @@ export class UsersPage extends LightElement {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// The one *schedule* on this page, and the only agent that gets one: event
|
||||
// triage fires on inbound events, so how often it runs is a fact about the
|
||||
// person, not about the instance. Someone on a dozen mailing lists is triaged
|
||||
// on nearly every tick.
|
||||
_renderTriage(u) {
|
||||
if (!this._triage) return nothing; // not an admin, or the fetch failed
|
||||
const def = this._triage.default_interval_minutes;
|
||||
return html`
|
||||
<div class="ud-section">
|
||||
<h3 class="ud-section-title"><i class="bi bi-clock-history me-2"></i>${t('users.detail.triage')}</h3>
|
||||
<div class="connector-card">
|
||||
<div class="form-text mb-2" style="font-size:.75rem">${t('users.triage.hint')}</div>
|
||||
<div class="row g-2 align-items-end">
|
||||
<div class="col-md-5">
|
||||
<label class="form-label">${t('users.triage.interval')}</label>
|
||||
<input type="number" min="1" max="1440" class="form-control form-control-sm"
|
||||
placeholder=${t('users.triage.placeholder', { n: def })}
|
||||
.value=${this._triageIn}
|
||||
@input=${(e) => { this._triageIn = e.target.value; this._trgSaved = false; }} />
|
||||
<div class="form-text">${this._triageIn.trim() === ''
|
||||
? t('users.triage.using_default', { n: def })
|
||||
: t('users.triage.using_override')}</div>
|
||||
</div>
|
||||
<div class="col-auto d-flex align-items-center gap-2 pb-4">
|
||||
<button class="btn btn-sm btn-primary" ?disabled=${this._busy} @click=${() => this._saveTriage()}>
|
||||
<i class="bi bi-check-lg me-1"></i>${t('users.modal.save_btn')}
|
||||
</button>
|
||||
${this._trgSaved ? html`<span class="ud-saved"><i class="bi bi-check2"></i>${t('users.detail.saved')}</span>` : nothing}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
_renderSecurity(u) {
|
||||
return html`
|
||||
<div class="ud-section">
|
||||
|
||||
@@ -1112,6 +1112,7 @@ export default {
|
||||
'users.detail.profile': 'Profile',
|
||||
'users.detail.connectors': 'Connectors',
|
||||
'users.detail.plugins': 'Plugins',
|
||||
'users.detail.triage': 'Event triage',
|
||||
'users.detail.security': 'Security',
|
||||
'users.detail.saved': 'Saved',
|
||||
'users.detail.delete_hint': 'Deletes this user, their database and all conversation history.',
|
||||
@@ -1128,6 +1129,13 @@ export default {
|
||||
'users.plug.empty': 'No plugins available.',
|
||||
'users.plug.admin_note': 'Admins can use every enabled plugin, whatever is ticked here.',
|
||||
|
||||
'users.triage.hint': 'Event triage reads the events this person\'s connectors pushed and notifies them about the ones worth an interruption. Someone who receives a lot of mail or messages triggers it on almost every pass; give them a slower cadence here.',
|
||||
'users.triage.interval': 'Check interval (minutes)',
|
||||
'users.triage.placeholder': 'Default ({n})',
|
||||
'users.triage.using_default': 'Empty: follows the instance setting ({n} min).',
|
||||
'users.triage.using_override': 'This user only. Clear the field to follow the instance setting again.',
|
||||
'users.triage.invalid': 'Enter a whole number of minutes, or leave the field empty.',
|
||||
|
||||
'users.modal.create_title': 'New user',
|
||||
'users.modal.username': 'Username',
|
||||
'users.modal.display_name': 'Display name',
|
||||
|
||||
@@ -1099,6 +1099,7 @@ export default {
|
||||
'users.detail.profile': 'Profil',
|
||||
'users.detail.connectors': 'Connecteurs',
|
||||
'users.detail.plugins': 'Plugins',
|
||||
'users.detail.triage': "Tri des événements",
|
||||
'users.detail.security': 'Sécurité',
|
||||
'users.detail.saved': 'Enregistré',
|
||||
'users.detail.delete_hint': 'Supprime cet utilisateur, sa base de données et tout l\'historique des conversations.',
|
||||
@@ -1115,6 +1116,13 @@ export default {
|
||||
'users.plug.empty': 'Aucun plugin disponible.',
|
||||
'users.plug.admin_note': "Les administrateurs peuvent utiliser tout plugin activé, quelles que soient les cases cochées ici.",
|
||||
|
||||
'users.triage.hint': "Le tri des événements lit les événements poussés par les connecteurs de cette personne et lui signale ceux qui méritent une interruption. Quelqu'un qui reçoit beaucoup de courrier ou de messages le déclenche à presque chaque passage : donnez-lui ici une cadence plus lente.",
|
||||
'users.triage.interval': "Intervalle de vérification (minutes)",
|
||||
'users.triage.placeholder': "Par défaut ({n})",
|
||||
'users.triage.using_default': "Vide : suit le réglage de l'instance ({n} min).",
|
||||
'users.triage.using_override': "Pour cet utilisateur uniquement. Videz le champ pour revenir au réglage de l'instance.",
|
||||
'users.triage.invalid': "Saisissez un nombre entier de minutes, ou laissez le champ vide.",
|
||||
|
||||
'users.modal.create_title': 'Nouvel utilisateur',
|
||||
'users.modal.username': 'Nom d\'utilisateur',
|
||||
'users.modal.display_name': 'Nom d\'affichage',
|
||||
|
||||
@@ -1099,6 +1099,7 @@ export default {
|
||||
'users.detail.profile': 'Profilo',
|
||||
'users.detail.connectors': 'Connettori',
|
||||
'users.detail.plugins': 'Plugin',
|
||||
'users.detail.triage': 'Triage eventi',
|
||||
'users.detail.security': 'Sicurezza',
|
||||
'users.detail.saved': 'Salvato',
|
||||
'users.detail.delete_hint': 'Elimina questo utente, il suo database e tutta la cronologia delle conversazioni.',
|
||||
@@ -1115,6 +1116,13 @@ export default {
|
||||
'users.plug.empty': 'Nessun plugin disponibile.',
|
||||
'users.plug.admin_note': 'Gli amministratori possono usare qualsiasi plugin abilitato, indipendentemente da ciò che è selezionato qui.',
|
||||
|
||||
'users.triage.hint': 'Il triage eventi legge gli eventi arrivati dai connettori di questa persona e le segnala quelli che meritano un\'interruzione. Chi riceve molta posta o molti messaggi lo fa partire quasi a ogni passaggio: qui puoi dargli una cadenza più lenta.',
|
||||
'users.triage.interval': 'Intervallo di controllo (minuti)',
|
||||
'users.triage.placeholder': 'Predefinito ({n})',
|
||||
'users.triage.using_default': 'Vuoto: segue l\'impostazione dell\'istanza ({n} min).',
|
||||
'users.triage.using_override': 'Solo per questo utente. Svuota il campo per tornare all\'impostazione dell\'istanza.',
|
||||
'users.triage.invalid': 'Inserisci un numero intero di minuti, oppure lascia il campo vuoto.',
|
||||
|
||||
'users.modal.create_title': 'Nuovo utente',
|
||||
'users.modal.username': 'Nome utente',
|
||||
'users.modal.display_name': 'Nome visualizzato',
|
||||
|
||||
Reference in New Issue
Block a user