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:
1 parent
d61384ed2c
commit
c8f45044a3
12 files changed
+1327
-7
No files matched your search
+108
-1
@@ -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';
|
||||
|
||||
Reference in new issue
Block a user