Reading API /api/v1/logs with a stable cursor, and API tokens

GET /api/v1/logs?query=<LogsQL>&since=<cursor|time|duration>&limit=<n>
returns the logs after a cursor, oldest first, with next_cursor and
has_more. The cursor is a position on _time (the reception time) plus
the number of rows already returned among those sharing that exact
time, ordered by a hash of their content, so following the cursors
gives every row once even within groups cut by a page. Rows count once
older than the lag (30 s), and nothing is returned while the disk
spool still holds logs.

API tokens (Settings > API) give read-only access to /api/v1/ as
"Authorization: Bearer lst_...", in every auth mode; only their SHA-256
is kept in /data/api_tokens.json. README (EN/FR) documents the route and
an n8n workflow keeping the cursor in $getWorkflowStaticData.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
cedricandClaude Opus 5.5 committed 2026-11-01 15:47:57 +01:00
1 parent d61384ed2c
commit c8f45044a3
12 files changed
+1327 -7

No files matched your search

+108 -1
View File
@@ -174,6 +174,25 @@ const I18N = {
exportNote: (n) => `Up to ${n} logs matching the current filters, newest first. Dates use the time zone chosen in Settings.`,
exporting: 'Exporting…', exportDone: (f) => `Downloaded: ${f}`, exportError: 'Export failed: ',
err_export_pipes: 'Export does not support LogsQL pipes (| …): keep only the filter part',
tabApi: 'API',
tokensTitle: 'API tokens',
tokensHelp: 'Tokens give scripts and automation tools (n8n…) read-only access to /api/v1/logs, whatever the authentication mode. Only a hash is kept: a token is shown once, when it is created. Revoking it cuts off access at once.',
tokenCount: (n) => `${n} token${n > 1 ? 's' : ''}`,
tokenNone: 'No API token yet.',
tokenNamePh: 'Token name (n8n, backup script…)',
tokenCreate: 'Create a token',
tokenOnce: 'Copy this token now: it will not be shown again.',
tokenCopy: 'Copy',
tokenRevoke: 'Revoke',
tokenRevokeAsk: (n) => `Revoke the token "${n}"? Tools using it lose access at once.`,
tokenRevoked: 'Token revoked',
tokenCreated: (d) => `created ${d}`,
tokenUsed: (p) => `last used ${p.d}${p.ip ? ' from ' + p.ip : ''}`,
tokenNever: 'never used',
tokensUse: 'Usage',
tokensUseHelp: 'Send the token in an Authorization header. Each answer has next_cursor: pass it as since on the next call to get the following logs, without gaps or duplicates (see the README for an n8n workflow).',
err_token_name: 'Give the token a name of 1 to 80 characters',
err_token_max: 'Too many API tokens',
tabLocale: 'Localization', tabFilters: 'Patterns', tabInterface: 'Appearance', tabData: 'Database',
themeTitle: 'Theme', themeSystem: 'System', themeLight: 'Light', themeDark: 'Dark',
themeHelp: 'System follows the light/dark preference of your computer or phone.',
@@ -406,6 +425,25 @@ const I18N = {
exportNote: (n) => `Jusqu'à ${n} logs correspondant aux filtres, du plus récent au plus ancien. Dates dans le fuseau choisi dans Paramètres.`,
exporting: 'Export…', exportDone: (f) => `Téléchargé : ${f}`, exportError: 'Échec de l\'export : ',
err_export_pipes: 'L\'export ne gère pas les pipes LogsQL (| …) : gardez seulement la partie filtre',
tabApi: 'API',
tokensTitle: "Jetons d'API",
tokensHelp: "Les jetons donnent aux scripts et outils d'automatisation (n8n…) un accès en lecture seule à /api/v1/logs, quel que soit le mode d'authentification. Seule une empreinte est conservée : un jeton n'est affiché qu'une fois, à sa création. Le révoquer coupe l'accès immédiatement.",
tokenCount: (n) => `${n} jeton${n > 1 ? 's' : ''}`,
tokenNone: "Aucun jeton d'API pour l'instant.",
tokenNamePh: 'Nom du jeton (n8n, script de sauvegarde…)',
tokenCreate: 'Créer un jeton',
tokenOnce: "Copiez ce jeton maintenant : il ne sera plus affiché.",
tokenCopy: 'Copier',
tokenRevoke: 'Révoquer',
tokenRevokeAsk: (n) => `Révoquer le jeton « ${n} » ? Les outils qui l'utilisent perdent l'accès immédiatement.`,
tokenRevoked: 'Jeton révoqué',
tokenCreated: (d) => `créé le ${d}`,
tokenUsed: (p) => `dernière utilisation ${p.d}${p.ip ? ' depuis ' + p.ip : ''}`,
tokenNever: 'jamais utilisé',
tokensUse: 'Utilisation',
tokensUseHelp: "Envoyez le jeton dans un en-tête Authorization. Chaque réponse contient next_cursor : passez-le dans since à l'appel suivant pour obtenir les logs qui suivent, sans trou ni doublon (voir le README pour un workflow n8n).",
err_token_name: 'Donnez au jeton un nom de 1 à 80 caractères',
err_token_max: "Trop de jetons d'API",
tabLocale: 'Localisation et langue', tabFilters: 'Motifs de recherche', tabInterface: 'Affichage', tabData: 'Base de données',
themeTitle: 'Thème', themeSystem: 'Système', themeLight: 'Clair', themeDark: 'Sombre',
themeHelp: 'Système suit la préférence clair/sombre de votre ordinateur ou de votre téléphone.',
@@ -2664,6 +2702,74 @@ $('#purgeBtn').addEventListener('click', async () => {
}
});
/* ================= API tokens ================= */
const tokens = { list: null, error: null };
function renderTokens() {
const el = $('#tokenList');
const day = (x) => fmtFull(x).replace(/[.,]\d{3}(?=\D*$)/, '');
$('#tokenCount').textContent = tokens.list ? t('tokenCount', tokens.list.length) : '';
$('#tokenExample').textContent = `curl -H "Authorization: Bearer lst_…" \\\n "${location.origin}/api/v1/logs?query=severity:err&since=15m&limit=1000"`;
if (!tokens.list) {
el.innerHTML = tokens.error ? `<div class="token-empty bad">${esc(tokens.error)}</div>` : '';
return;
}
if (!tokens.list.length) {
el.innerHTML = `<div class="token-empty">${esc(t('tokenNone'))}</div>`;
return;
}
el.innerHTML = tokens.list.map((k) => {
const used = k.last_used ? t('tokenUsed', { d: day(k.last_used), ip: k.last_ip }) : t('tokenNever');
return `<div class="token-row"><div><b>${esc(k.name)}</b> <code>${esc(k.hint)}…</code></div>`
+ `<button class="btn danger" type="button" data-revoke="${esc(k.id)}">${esc(t('tokenRevoke'))}</button>`
+ `<div class="muted">${esc(t('tokenCreated', day(k.created)))} · ${esc(used)}</div></div>`;
}).join('');
}
async function loadTokens() {
$('#tokenNew').hidden = true;
try {
tokens.list = await api('/api/tokens');
tokens.error = null;
} catch (e) {
tokens.list = null;
tokens.error = e.data?.code === 'read_only' ? '' : e.message;
}
renderTokens();
}
$('#tokenForm').addEventListener('submit', async (ev) => {
ev.preventDefault();
const name = $('#tokenName').value.trim();
if (!name) { $('#tokenName').focus(); return; }
try {
const res = await api('/api/tokens', { method: 'POST', body: { name } });
$('#tokenName').value = '';
await loadTokens();
$('#tokenSecret').textContent = res.token;
$('#tokenNew').hidden = false;
} catch (e) {
toast(e.message);
}
});
$('#tokenCopy').addEventListener('click', () => copy($('#tokenSecret').textContent));
$('#tokenList').addEventListener('click', async (ev) => {
const b = ev.target.closest('[data-revoke]');
if (!b) return;
const k = tokens.list?.find((x) => x.id === b.dataset.revoke);
if (!k || !confirm(t('tokenRevokeAsk', k.name))) return;
try {
await api('/api/tokens/' + encodeURIComponent(k.id), { method: 'DELETE' });
toast(t('tokenRevoked'));
} catch (e) {
toast(e.message);
}
loadTokens();
});
/* ================= Tag management ================= */
const PALETTE = ['#6366f1', '#0ea5e9', '#14b8a6', '#a855f7', '#ec4899', '#eab308', '#64748b', '#f97316'];
@@ -2828,6 +2934,7 @@ $('#settingsBtn').addEventListener('click', () => {
loadDocker();
renderDbStats();
loadDbStats();
loadTokens();
showSettingsTab(store.get('settingsTab', 'locale'));
$('#settingsDlg').showModal();
});
@@ -3029,7 +3136,7 @@ fetch('/auth/me').then((res) => (res.ok ? res.json() : null)).then((me) => {
}
if (me.role === 'viewer') {
document.body.classList.add('read-only');
for (const p of document.querySelectorAll('[data-panel="filters"], [data-panel="sources"], [data-panel="data"]')) {
for (const p of document.querySelectorAll('[data-panel="filters"], [data-panel="sources"], [data-panel="data"], [data-panel="api"]')) {
for (const s of p.querySelectorAll('.set-section')) s.inert = true;
const note = document.createElement('p');
note.className = 'ro-note';
+32
View File
@@ -197,6 +197,10 @@
<svg viewBox="0 0 24 24"><ellipse cx="12" cy="5" rx="8" ry="3"/><path d="M4 5v14c0 1.7 3.6 3 8 3s8-1.3 8-3V5M4 12c0 1.7 3.6 3 8 3s8-1.3 8-3"/></svg>
<span data-i18n="tabData">Database</span>
</button>
<button type="button" role="tab" data-tab="api">
<svg viewBox="0 0 24 24"><circle cx="7.5" cy="15.5" r="4.5"/><path d="m10.7 12.3 9.3-9.3M16 7l3 3M18 5l2 2"/></svg>
<span data-i18n="tabApi">API</span>
</button>
</nav>
<div id="setActions" class="set-actions">
<button id="setSave" class="btn save" type="button" disabled>
@@ -446,6 +450,34 @@
</div>
</section>
</div>
<!-- API tokens -->
<div class="set-panel wide" role="tabpanel" data-panel="api" hidden>
<section class="set-section">
<div class="sec-head">
<h3 data-i18n="tokensTitle">API tokens</h3>
<span id="tokenCount" class="muted small"></span>
</div>
<p class="muted small" data-i18n="tokensHelp"></p>
<div id="tokenList" class="token-list"></div>
<form id="tokenForm" class="token-form">
<input id="tokenName" type="text" class="dk-filter" maxlength="80" autocomplete="off" spellcheck="false" data-i18n-ph="tokenNamePh" data-i18n-aria="tokenNamePh">
<button id="tokenCreate" class="btn" type="submit" data-i18n="tokenCreate">Create a token</button>
</form>
<div id="tokenNew" class="token-new" hidden>
<p class="small" data-i18n="tokenOnce"></p>
<div class="token-secret">
<code id="tokenSecret"></code>
<button id="tokenCopy" class="btn" type="button" data-i18n="tokenCopy">Copy</button>
</div>
</div>
</section>
<section class="set-section">
<h3 data-i18n="tokensUse">Usage</h3>
<p class="muted small" data-i18n="tokensUseHelp"></p>
<pre id="tokenExample" class="token-example"></pre>
</section>
</div>
</div>
</div>
</div>
+22
View File
@@ -912,3 +912,25 @@ body.login-page {
.login-card input:focus { outline: 0; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
.login-card .btn.primary { height: 38px; justify-content: center; margin-top: 6px; font-size: 14px; }
.login-err { margin: 0; color: var(--sev-err); font-size: 13px; }
/* API tokens (Settings > API) */
.token-list { display: flex; flex-direction: column; gap: 4px; margin: 12px 0 10px; }
.token-row {
display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 2px 12px; align-items: center;
padding: 8px 10px; border-radius: 8px; background: var(--panel-2); font-size: 12.5px;
}
.token-row b { font-weight: 600; overflow-wrap: anywhere; }
.token-row code { font-family: var(--mono); font-size: 11.5px; color: var(--muted); }
.token-row .muted { grid-column: 1; font-size: 11.5px; }
.token-row .btn { grid-row: 1 / span 2; grid-column: 2; height: 28px; font-size: 12px; }
.token-empty { color: var(--muted); font-size: 12.5px; }
.token-form { display: flex; gap: 10px; align-items: center; }
.token-form .btn { height: 28px; font-size: 12px; }
.token-new { margin-top: 12px; padding: 10px 12px; border-radius: 8px; border: 1px solid var(--accent); background: var(--accent-soft); }
.token-secret { display: flex; gap: 10px; align-items: center; margin-top: 8px; }
.token-secret code { flex: 1; min-width: 0; font: 12px var(--mono); overflow-wrap: anywhere; user-select: all; }
.token-secret .btn { height: 28px; font-size: 12px; }
.token-example {
margin: 8px 0 0; padding: 8px 12px; border-radius: 8px; background: var(--panel-2);
font: 12px var(--mono); white-space: pre-wrap; overflow-wrap: anywhere;
}