'use strict';
/* ================= Translations ================= */
const I18N = {
en: {
locale: 'en-US',
searchAria: 'Search logs',
searchSimple: 'Search… error -timeout "disk full" (press /)',
searchLogsql: 'LogsQL: error AND host:web-01 | sort by (_time)',
modeTitle: 'Switch between simple search and LogsQL',
live: 'Live',
liveTitle: 'Show new messages live',
liveUnavailable: 'Live view is not available in LogsQL mode',
settings: 'Settings',
theme: 'Light / dark theme',
logout: 'Log out',
close: 'Close',
rangeAria: 'Time range', severityAria: 'Severity', hostAria: 'Host', appAria: 'Application',
histoAria: 'Log volume over time',
r5m: '5 min', r15m: '15 min', r1h: '1 hour', r6h: '6 hours', r24h: '24 hours', r7d: '7 days', r30d: '30 days', rAll: 'All',
sevAll: 'All severities', sev2: 'Critical and above', sev3: 'Errors and above',
sev4: 'Warnings and above', sev5: 'Notice and above', sev6: 'Info and above',
allHosts: 'All hosts', allApps: 'All apps',
emptyTitle: 'No matching logs.',
emptyHint: 'Widen the time range or send syslog messages to port 514 (UDP or TCP).',
count: (n) => `${n} ${n === 1 ? 'log' : 'logs'} shown`,
histoMax: (n) => `max ${n} / interval`,
searchError: 'Search error: ',
connLive: 'live', connConnecting: 'connecting…', connRetry: 'reconnecting…',
connPaused: 'live paused', connLogsql: 'live unavailable (LogsQL)',
newLogs: (n) => `↑ ${n} new ${n === 1 ? 'log' : 'logs'}`,
skipped: (n) => `${n} messages not shown (rate too high)`,
stats: (s) => `received ${s.received} · stored ${s.ingested} · dropped ${s.dropped} · queue ${s.queue}`,
storageErr: 'storage: ',
spooled: (n) => `${n} waiting on disk`,
unreachable: 'server unreachable',
fTime: 'timestamp', fMsg: 'message', fPid: 'pid', fSd: 'structured data',
filterHost: 'Filter on this host', filterApp: 'Filter on this app',
copyMsg: 'Copy message', copyJson: 'Copy as JSON', copied: 'Copied',
language: 'Language',
tagsTitle: 'Color tags',
tagsHelp: 'Keywords are highlighted in messages. Changes are saved automatically. When tags overlap, the one highest in the list wins.',
addTag: '+ Add tag', resetTags: 'Restore default tags',
optWhole: 'whole word', optWholeTitle: 'Only color whole words (avoids "ok" in "token")',
optCaseTitle: 'Match case', optRegexTitle: 'Regular expression',
optActive: 'active', optActiveTitle: 'Enable this tag',
colorAria: 'Color', keyword: 'keyword', preview: 'preview', del: 'Delete', noTags: 'No tags.',
newTag: 'new',
confirmDelete: (p) => `Delete tag "${p}"?`,
confirmReset: 'Replace all tags with the defaults (warning, error)?',
presetAria: 'Add a preset', presetPick: '+ Preset…',
tagFilter: 'Filter tags (keyword, label or code)',
tagCount: (n) => (n.shown === n.total ? `${n.total} tag${n.total === 1 ? '' : 's'}` : `${n.shown} / ${n.total} tags`),
noTagMatch: 'No tag matches the filter.',
presetAdded: (n) => (n ? `${n} tag(s) added` : 'These tags are already in the list'),
presetsFileErr: (f) => `Invalid presets file ${f}, built-in presets used: `,
tagsLoadErr: 'Tags: ',
err_pattern_required: 'The keyword is required',
err_invalid_color: 'Invalid color (expected #rrggbb)',
err_invalid_regex: 'Invalid regular expression',
err_tag_not_found: 'Tag not found',
err_too_many_tags: 'Too many tags (99 at most)',
err_live_logsql: 'Live view is not available in LogsQL mode',
dateTime: 'Date & time',
tzLabel: 'Time zone',
fmtLabel: 'Reception time format',
tzBrowser: (z) => `Browser time zone (${z})`,
tzGroups: { General: 'General', Europe: 'Europe', Africa: 'Africa', America: 'Americas', Asia: 'Asia', Oceania: 'Oceania & Pacific', Other: 'Atlantic & Indian Ocean' },
fmtEpoch: 'Unix epoch (milliseconds)',
timePreview: 'Preview: ',
timeHelp: 'The reception time comes from the server clock. The time zone applies to every date shown.',
fReceived: 'received',
rcvTitle: 'Received by the server',
msgTimeTitle: 'Timestamp from the message',
fHostName: 'host name (DNS)', fHostIp: 'host IP',
clickHost: 'Click to filter on this host', clickApp: 'Click to filter on this app',
sourceAria: 'Source', srcAll: 'All sources', tabSources: 'Sources',
srcHost: 'Host system',
srcKernel: 'Kernel (dmesg)',
auditTitle: 'Linux audit (auditd)',
auditEnabled: 'Collect the audit events of this machine',
auditPath: 'File',
auditOff: 'Off: the audit log of the machine hosting LogStream is not read.',
auditOk: ({ p, r }) => `Following ${p}: ${r} events since startup.`,
audit_not_found: 'Audit log not found: mount /var/log/audit in the container (see docker-compose.yml) and check the path. ',
audit_permission: 'Permission denied on the audit log: set log_group in /etc/audit/auditd.conf, or add an ACL for UID 10001 (see the README). ',
audit_read: 'Cannot read the audit log: ',
auditHelp: 'Reads the log of the Linux audit daemon (auditd): logins, sudo, watched files, syscalls matched by your rules… The records of one event are grouped in one line, with a summary (who, what, result) and the raw record below it; failures are warnings. When turned on, the last hour is read first (AUDIT_BACKFILL); rotations are followed and reading resumes where it stopped.',
selfTitle: 'LogStream internal logs',
selfEnabled: 'Record the events of LogStream itself',
selfLevel: 'Minimum level',
selfOff: 'Off: the events of LogStream only go to its container output.',
selfOk: ({ n }) => `${n} events recorded since startup (host logstream).`,
selfDropped: (n) => ` ${n} left out (repeated or too many at once).`,
selfHelp: 'Turns the events of LogStream into logs (host logstream, app = component): VictoriaLogs unreachable and back, lost batches, syslog listener errors, Docker streams lost, failing sources, start and stop. The same event is recorded at most once every 30 s, with the number of repeats.',
hostTitle: 'Host system logs',
hostEnabled: 'Collect the system logs of this machine',
hostOff: 'Off: the system logs of the machine hosting LogStream are not collected.',
hostWaiting: 'Starting…',
hostJournal: ({ n, r }) => `Reading the systemd journal (${n} files): ${r} entries since startup.`,
hostFiles: ({ n, r }) => `Following ${n} files of /var/log: ${r} lines since startup.`,
hostCompressed: (n) => ` ${n} compressed fields skipped (long messages, readable with journalctl).`,
host_not_mounted: 'No host log directory found: mount /var/log and /run/log/journal under /host (see docker-compose.yml).',
host_permission: 'Permission denied: give the container a group allowed to read the logs (HOST_LOGS_GID in .env, see the README). ',
host_empty: 'No systemd journal and no log file found in /var/log.',
host_read: 'Cannot read the host logs: ',
hostHelp: 'Reads the systemd journal of the host (/var/log/journal, /run/log/journal), or the text files of /var/log (syslog, messages, *.log) when there is no journal. Directories are mounted read-only. When turned on, the last hour is read first (HOST_LOGS_BACKFILL).',
dmesgTitle: 'Kernel messages (dmesg)',
dmesgEnabled: 'Collect the kernel messages of this machine',
dmesgOff: 'Off: the kernel messages of the machine hosting LogStream are not collected.',
dmesgOk: ({ d, r }) => `Reading ${d}: ${r} messages since startup.`,
dmesgLost: (n) => ` ${n} messages were overwritten in the kernel buffer before being read.`,
dmesg_not_found: 'Device /dev/kmsg not found in the container: uncomment its devices section in docker-compose.yml (see the README).',
dmesg_permission: 'Permission denied on /dev/kmsg: set kernel.dmesg_restrict=0 on the host, or run the container as root with the SYSLOG capability (see the README). ',
dmesg_read: 'Cannot read the kernel messages: ',
dmesgHelp: 'Reads the kernel ring buffer of the host through /dev/kmsg, like dmesg: hardware, drivers, OOM killer, firewall… When turned on, the last hour is read first (DMESG_BACKFILL); after a restart, reading resumes where it stopped.',
syslogEnabled: 'Receive syslog messages', syslogProtocols: 'Protocols', syslogPort: 'Port',
syslogListening: ({ port, protos }) => `Listening on port ${port} (${protos}).`,
syslogOff: 'Syslog reception is off: syslog messages are ignored (Docker logs are still collected).',
syslogNone: 'No protocol selected: syslog messages are ignored.',
syslogErr: (p) => `Cannot listen over ${p}: `,
syslogPortHelp: (i) => `Published by docker-compose (container port ${i}). To change it, set SYSLOG_PORT in .env, then run docker compose up -d.`,
dockerTitle: 'Docker containers',
dockerDefault: 'Follow new containers automatically',
dockerAll: 'Enable all', dockerNone: 'Disable all',
dockerOff: 'The Docker source is disabled on this server. Set DOCKER_LOGS=on (see docker-compose.yml).',
dockerErr: 'Cannot reach Docker: ',
dockerOk: ({ h, n, f }) => `Connected to Docker on ${h}: ${n} containers, ${f} followed.`,
dockerStopped: (n) => `Show stopped containers (${n})`,
dockerFilter: 'Filter containers',
dockerLegend: 'Colored: followed · grey: not followed · dashed: stopped · the color identifies the compose project. Click a label to switch it.',
dockerNoMatch: 'No container',
stFollowing: 'followed', stSelected: 'followed when running', stIgnored: 'not followed', stStopped: 'stopped',
stLocked: 'excluded', stLockedTitle: 'LogStream itself, or label logstream.exclude=true',
dockerHelp: 'Logs are read through docker-socket-proxy, a read-only gateway: LogStream can list containers and read their logs, nothing else. Choices apply per compose service (or container name), so they survive container re-creations.',
fContainer: 'container', fContainerId: 'container ID', fImage: 'image', fProject: 'compose project',
fService: 'compose service', fStream: 'stream', fSourceType: 'source',
fUnit: 'systemd unit', fLogFile: 'log file',
export: 'Export', exportCsvHint: 'Comma separated, UTF-8',
exportExcel: 'CSV for Excel', exportExcelHint: 'Semicolon separated, for Excel in French',
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',
tabLocale: 'Localization', tabFilters: 'Filters', tabInterface: 'Interface', tabData: 'Data',
themeTitle: 'Theme', themeSystem: 'System', themeLight: 'Light', themeDark: 'Dark',
themeHelp: 'System follows the light/dark preference of your computer or phone.',
logDisplay: 'Log display', fontSize: 'Font size',
sizeTiny: 'Tiny', sizeSmall: 'Small', sizeMedium: 'Medium', sizeLarge: 'Large',
density: 'Density', densityNormal: 'Normal', densityCompact: 'Compact',
fontLabel: 'Font', fontSystem: 'System monospace (no download)', fontBuiltin: 'built in, narrow',
fontHelp: 'Free fonts. The built-in ones (Inconsolata Condensed, the narrowest, Iosevka and Ubuntu Mono) are served by LogStream itself and work offline; they are narrow, so more text fits on each line. The others are loaded by your browser from Bunny Fonts, a privacy-friendly European font service; without internet access, the system font is used.',
previewMsgs: ['error: link down on eth1, carrier lost', 'warning: disk /dev/sda temperature 58°C', 'backup finished: ok (12.4 GB in 00:14:32)'],
dangerZone: 'Danger zone',
dbTitle: 'Database', dbRefresh: 'Refresh', dbLoading: 'Loading…',
dbUnits: ['B', 'KB', 'MB', 'GB', 'TB'],
dbRows: 'Stored lines', dbDisk: 'Size on disk', dbIndex: (x) => `including ${x} of index`,
dbRaw: 'Raw size', dbRatio: (x) => `compressed ×${x}`,
dbPeriod: 'Period', dbFromTo: (p) => `${p.from} → ${p.to}`, dbDays: (n) => `${n} day${n > 1 ? 's' : ''}`,
dbRetention: (r) => `retention ${r}`,
dbRecent: 'Last 24 h', dbLastHour: (n) => `${n} in the last hour`,
dbSources: 'Sources', dbHostsApps: (p) => `${p.h} host${p.hn > 1 ? 's' : ''} · ${p.a} app${p.an > 1 ? 's' : ''}`,
dbFree: 'Free disk space', dbEmpty: 'The database is empty.',
dbQueryErr: 'Some figures could not be computed: ',
purgeHelp: 'Permanently delete every stored log to start from scratch. Color tags and settings are kept.',
purgeBtn: 'Delete all logs…',
purgePrompt: 'This permanently deletes ALL stored logs.\n\nType PURGE to confirm:',
purgeStarted: 'Deletion started',
purgeRunning: 'Deletion in progress… logs disappear gradually.',
purgeDone: 'All logs have been deleted.',
purgeForbidden: 'Purging is disabled on this server (ALLOW_PURGE=false).',
err_purge_unavailable: 'VictoriaLogs refuses deletions: start it with -delete.enable (see docker-compose.yml)',
err_purge_forbidden: 'Purging is disabled on this server (ALLOW_PURGE=false)',
err_read_only: 'Read-only account: changes are reserved to administrators',
err_cross_site: 'Request refused: it comes from another site',
authOff: 'No authentication: anyone who can reach this page can read the logs and change the settings. Set AUTH_USER / AUTH_PASS or AUTH_MODE=oidc.',
readOnlyNote: 'Read-only account: these settings can only be changed by an administrator.',
err_purge_confirm: 'Type PURGE to confirm',
liveZoomed: 'Live view is only available in Stream mode',
connZoom: 'live paused (time range)',
viewAria: 'Display mode', viewStream: 'Stream', viewPeriod: 'Time range',
viewStreamTitle: 'Stream: the latest logs over a sliding duration, updated live',
viewPeriodTitle: 'Time range: the logs between a start and an end date',
perFrom: 'Start', perTo: 'End', perPrev: 'Previous time range', perNext: 'Next time range',
perOut: 'Zoom out (twice as long)',
histoTitle: 'Timeline',
hScale: 'Scale', hLinear: 'Linear', hHeight: 'Height', hColor: 'Color',
hColSeverity: 'Severity', hColIntensity: 'Intensity', hColNone: 'None',
hRender: 'Display', hBars: 'Bars', hArea: 'Area',
hStep: 'Division', hAuto: 'Automatic', hRefresh: 'Refresh', hOff: 'Off', hRefreshAuto: 'At each new interval',
histoHelp: 'Click a bar to show its interval in Time range mode, or drag across several. The √ scale keeps small volumes visible next to bursts. Intensity compares each interval with the median of the window: calm, burst (more than 3×), anomaly (more than 10×). In live mode, the last interval is updated as messages arrive.',
hLeg_err: 'error and above', hLeg_warn: 'warning', hLeg_other: 'notice, info, debug',
hLeg_calm: 'calm', hLeg_burst: 'burst > 3×', hLeg_anom: 'anomaly > 10×',
hTotal: 'Total', hRatio: (x) => `${x}× the median`, hUnshown: 'not detailed (high rate)',
hDivision: (s) => `interval ${s}`, hCapped: 'enlarged',
hCappedTitle: 'The chosen division would exceed 300 intervals over this range: it has been enlarged.',
hUnzoom: '× Back to stream', unitDay: 'd',
toTop: 'Back to top',
colRcv: 'Received', colMt: 'Message time', colSev: 'Severity', colHost: 'Host', colApp: 'App', colCodes: 'Filters', colMsg: 'Message',
codesTitle: 'Codes of the color tags found in the message',
tagCodeTitle: 'Code shown on the log lines this tag matches',
colGrip: 'Drag to resize, double-click for the automatic width',
resetCols: 'Reset column widths', colsReset: 'Column widths reset',
colsHelp: 'Drag the edge of a column header in the log list to resize it (remembered by this browser).',
},
fr: {
locale: 'fr-FR',
searchAria: 'Rechercher dans les logs',
searchSimple: 'Rechercher… error -timeout "disk full" (touche /)',
searchLogsql: 'LogsQL : error AND host:web-01 | sort by (_time)',
modeTitle: 'Basculer entre recherche simple et LogsQL',
live: 'Direct',
liveTitle: 'Afficher les nouveaux messages en direct',
liveUnavailable: 'Le direct n\'est pas disponible en mode LogsQL',
settings: 'Paramètres',
theme: 'Thème clair / sombre',
logout: 'Se déconnecter',
close: 'Fermer',
rangeAria: 'Période', severityAria: 'Sévérité', hostAria: 'Hôte', appAria: 'Application',
histoAria: 'Volume de logs dans le temps',
r5m: '5 min', r15m: '15 min', r1h: '1 heure', r6h: '6 heures', r24h: '24 heures', r7d: '7 jours', r30d: '30 jours', rAll: 'Tout',
sevAll: 'Toutes sévérités', sev2: 'Critiques et +', sev3: 'Erreurs et +',
sev4: 'Warnings et +', sev5: 'Notice et +', sev6: 'Info et +',
allHosts: 'Tous les hôtes', allApps: 'Toutes les applis',
emptyTitle: 'Aucun log ne correspond.',
emptyHint: 'Élargissez la période ou envoyez des messages syslog sur le port 514 (UDP ou TCP).',
count: (n) => `${n} ${n > 1 ? 'logs affichés' : 'log affiché'}`,
histoMax: (n) => `max ${n} / intervalle`,
searchError: 'Erreur de recherche : ',
connLive: 'direct', connConnecting: 'connexion…', connRetry: 'reconnexion…',
connPaused: 'direct en pause', connLogsql: 'direct indisponible (LogsQL)',
newLogs: (n) => `↑ ${n} ${n > 1 ? 'nouveaux logs' : 'nouveau log'}`,
skipped: (n) => `${n} messages non affichés (débit trop élevé)`,
stats: (s) => `reçus ${s.received} · stockés ${s.ingested} · perdus ${s.dropped} · file ${s.queue}`,
storageErr: 'stockage : ',
spooled: (n) => `${n} en attente sur disque`,
unreachable: 'serveur injoignable',
fTime: 'horodatage', fMsg: 'message', fPid: 'pid', fSd: 'données structurées',
filterHost: 'Filtrer sur cet hôte', filterApp: 'Filtrer sur cette appli',
copyMsg: 'Copier le message', copyJson: 'Copier en JSON', copied: 'Copié',
language: 'Langue',
tagsTitle: 'Tags de couleur',
tagsHelp: 'Les mots-clés sont surlignés dans les messages. Les modifications sont enregistrées automatiquement. En cas de chevauchement, le tag le plus haut dans la liste l\'emporte.',
addTag: '+ Ajouter un tag', resetTags: 'Rétablir les tags par défaut',
optWhole: 'mot entier', optWholeTitle: 'Ne colorer que les mots entiers (évite « ok » dans « token »)',
optCaseTitle: 'Respecter la casse', optRegexTitle: 'Expression régulière',
optActive: 'actif', optActiveTitle: 'Activer ce tag',
colorAria: 'Couleur', keyword: 'mot-clé', preview: 'aperçu', del: 'Supprimer', noTags: 'Aucun tag.',
newTag: 'nouveau',
confirmDelete: (p) => `Supprimer le tag « ${p} » ?`,
confirmReset: 'Remplacer tous les tags par les tags par défaut (warning, error) ?',
presetAria: 'Ajouter un préréglage', presetPick: '+ Préréglage…',
tagFilter: 'Filtrer les tags (mot-clé, libellé ou code)',
tagCount: (n) => (n.shown === n.total ? `${n.total} tag${n.total > 1 ? 's' : ''}` : `${n.shown} / ${n.total} tags`),
noTagMatch: 'Aucun tag ne correspond au filtre.',
presetAdded: (n) => (n ? `${n} tag(s) ajouté(s)` : 'Ces tags sont déjà dans la liste'),
presetsFileErr: (f) => `Fichier de préréglages ${f} invalide, préréglages intégrés utilisés : `,
tagsLoadErr: 'Tags : ',
err_pattern_required: 'Le mot-clé est obligatoire',
err_invalid_color: 'Couleur invalide (format #rrggbb attendu)',
err_invalid_regex: 'Expression régulière invalide',
err_tag_not_found: 'Tag introuvable',
err_too_many_tags: 'Trop de tags (99 au maximum)',
err_live_logsql: 'Le direct n\'est pas disponible en mode LogsQL',
dateTime: 'Date et heure',
tzLabel: 'Fuseau horaire',
fmtLabel: 'Format de l\'heure de réception',
tzBrowser: (z) => `Fuseau du navigateur (${z})`,
tzGroups: { General: 'Général', Europe: 'Europe', Africa: 'Afrique', America: 'Amériques', Asia: 'Asie', Oceania: 'Océanie et Pacifique', Other: 'Atlantique et océan Indien' },
fmtEpoch: 'Epoch Unix (millisecondes)',
timePreview: 'Aperçu : ',
timeHelp: 'L\'heure de réception provient de l\'horloge du serveur. Le fuseau horaire s\'applique à toutes les dates affichées.',
fReceived: 'réception',
rcvTitle: 'Reçu par le serveur',
msgTimeTitle: 'Horodatage contenu dans le message',
fHostName: 'nom d\'hôte (DNS)', fHostIp: 'IP de l\'hôte',
clickHost: 'Cliquer pour filtrer sur cet hôte', clickApp: 'Cliquer pour filtrer sur cette appli',
sourceAria: 'Source', srcAll: 'Toutes les sources', tabSources: 'Sources',
srcHost: 'Système hôte',
srcKernel: 'Noyau (dmesg)',
auditTitle: 'Audit Linux (auditd)',
auditEnabled: 'Collecter les événements d\'audit de cette machine',
auditPath: 'Fichier',
auditOff: 'Désactivé : le journal d\'audit de la machine qui héberge LogStream n\'est pas lu.',
auditOk: ({ p, r }) => `Suivi de ${p} : ${r} événements depuis le démarrage.`,
audit_not_found: 'Journal d\'audit introuvable : montez /var/log/audit dans le conteneur (voir docker-compose.yml) et vérifiez le chemin. ',
audit_permission: 'Accès refusé au journal d\'audit : réglez log_group dans /etc/audit/auditd.conf, ou ajoutez une ACL pour l\'UID 10001 (voir le README). ',
audit_read: 'Lecture du journal d\'audit impossible : ',
auditHelp: 'Lit le journal du démon d\'audit Linux (auditd) : connexions, sudo, fichiers surveillés, appels système visés par vos règles… Les enregistrements d\'un même événement sont regroupés sur une ligne, avec un résumé (qui, quoi, résultat) et l\'enregistrement brut en dessous ; les échecs sont des warnings. À l\'activation, la dernière heure est lue d\'abord (AUDIT_BACKFILL) ; les rotations sont suivies et la lecture reprend où elle s\'était arrêtée.',
selfTitle: 'Logs internes de LogStream',
selfEnabled: 'Enregistrer les événements de LogStream lui-même',
selfLevel: 'Niveau minimal',
selfOff: 'Désactivé : les événements de LogStream ne vont que dans la sortie de son conteneur.',
selfOk: ({ n }) => `${n} événements enregistrés depuis le démarrage (hôte logstream).`,
selfDropped: (n) => ` ${n} écartés (répétés ou trop nombreux d\'un coup).`,
selfHelp: 'Transforme les événements de LogStream en logs (hôte logstream, app = composant) : VictoriaLogs injoignable puis de retour, lots perdus, erreurs du listener syslog, flux Docker décrochés, sources en échec, démarrage et arrêt. Un même événement est enregistré au plus une fois toutes les 30 s, avec le nombre de répétitions.',
hostTitle: 'Logs système de l\'hôte',
hostEnabled: 'Collecter les logs système de cette machine',
hostOff: 'Désactivé : les logs système de la machine qui héberge LogStream ne sont pas collectés.',
hostWaiting: 'Démarrage…',
hostJournal: ({ n, r }) => `Lecture du journal systemd (${n} fichiers) : ${r} entrées depuis le démarrage.`,
hostFiles: ({ n, r }) => `Suivi de ${n} fichiers de /var/log : ${r} lignes depuis le démarrage.`,
hostCompressed: (n) => ` ${n} champs compressés ignorés (messages longs, lisibles avec journalctl).`,
host_not_mounted: 'Aucun répertoire de logs de l\'hôte trouvé : montez /var/log et /run/log/journal sous /host (voir docker-compose.yml).',
host_permission: 'Accès refusé : donnez au conteneur un groupe autorisé à lire les logs (HOST_LOGS_GID dans .env, voir le README). ',
host_empty: 'Ni journal systemd ni fichier de log trouvé dans /var/log.',
host_read: 'Lecture des logs de l\'hôte impossible : ',
hostHelp: 'Lit le journal systemd de l\'hôte (/var/log/journal, /run/log/journal), ou les fichiers texte de /var/log (syslog, messages, *.log) s\'il n\'y a pas de journal. Les répertoires sont montés en lecture seule. À l\'activation, la dernière heure est lue d\'abord (HOST_LOGS_BACKFILL).',
dmesgTitle: 'Messages du noyau (dmesg)',
dmesgEnabled: 'Collecter les messages du noyau de cette machine',
dmesgOff: 'Désactivé : les messages du noyau de la machine qui héberge LogStream ne sont pas collectés.',
dmesgOk: ({ d, r }) => `Lecture de ${d} : ${r} messages depuis le démarrage.`,
dmesgLost: (n) => ` ${n} messages ont été écrasés dans le buffer du noyau avant d'être lus.`,
dmesg_not_found: 'Périphérique /dev/kmsg absent du conteneur : décommentez sa section devices dans docker-compose.yml (voir le README).',
dmesg_permission: 'Accès refusé à /dev/kmsg : réglez kernel.dmesg_restrict=0 sur l\'hôte, ou lancez le conteneur en root avec la capacité SYSLOG (voir le README). ',
dmesg_read: 'Lecture des messages du noyau impossible : ',
dmesgHelp: 'Lit le buffer circulaire du noyau de l\'hôte via /dev/kmsg, comme dmesg : matériel, pilotes, OOM killer, pare-feu… À l\'activation, la dernière heure est lue d\'abord (DMESG_BACKFILL) ; après un redémarrage, la lecture reprend où elle s\'était arrêtée.',
syslogEnabled: 'Recevoir les messages syslog', syslogProtocols: 'Protocoles', syslogPort: 'Port',
syslogListening: ({ port, protos }) => `Écoute sur le port ${port} (${protos}).`,
syslogOff: 'Réception syslog désactivée : les messages syslog sont ignorés (les logs Docker sont toujours collectés).',
syslogNone: 'Aucun protocole sélectionné : les messages syslog sont ignorés.',
syslogErr: (p) => `Impossible d'écouter en ${p} : `,
syslogPortHelp: (i) => `Publié par docker-compose (port ${i} dans le conteneur). Pour le changer, modifiez SYSLOG_PORT dans .env, puis lancez docker compose up -d.`,
dockerTitle: 'Conteneurs Docker',
dockerDefault: 'Suivre automatiquement les nouveaux conteneurs',
dockerAll: 'Tout activer', dockerNone: 'Tout désactiver',
dockerOff: 'La source Docker est désactivée sur ce serveur. Réglez DOCKER_LOGS=on (voir docker-compose.yml).',
dockerErr: 'Docker injoignable : ',
dockerOk: ({ h, n, f }) => `Connecté à Docker sur ${h} : ${n} conteneurs, ${f} suivis.`,
dockerStopped: (n) => `Afficher les conteneurs arrêtés (${n})`,
dockerFilter: 'Filtrer les conteneurs',
dockerLegend: 'En couleur : suivis · en gris : non suivis · pointillés : arrêtés · la couleur identifie le projet compose. Cliquez sur une étiquette pour la basculer.',
dockerNoMatch: 'Aucun conteneur',
stFollowing: 'suivi', stSelected: 'suivi dès qu\'il tourne', stIgnored: 'non suivi', stStopped: 'arrêté',
stLocked: 'exclu', stLockedTitle: 'LogStream lui-même, ou étiquette logstream.exclude=true',
dockerHelp: 'Les logs sont lus via docker-socket-proxy, une passerelle en lecture seule : LogStream peut lister les conteneurs et lire leurs logs, rien d\'autre. Les choix s\'appliquent par service compose (ou nom de conteneur), ils survivent donc à la recréation des conteneurs.',
fContainer: 'conteneur', fContainerId: 'ID du conteneur', fImage: 'image', fProject: 'projet compose',
fService: 'service compose', fStream: 'flux', fSourceType: 'source',
fUnit: 'unité systemd', fLogFile: 'fichier de log',
export: 'Exporter', exportCsvHint: 'Séparateur virgule, UTF-8',
exportExcel: 'CSV pour Excel', exportExcelHint: 'Séparateur point-virgule, pour Excel en français',
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',
tabLocale: 'Localisation', tabFilters: 'Filtres', tabInterface: 'Interface', tabData: '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.',
logDisplay: 'Affichage des logs', fontSize: 'Taille du texte',
sizeTiny: 'Très petite', sizeSmall: 'Petite', sizeMedium: 'Moyenne', sizeLarge: 'Grande',
density: 'Densité', densityNormal: 'Normale', densityCompact: 'Compacte',
fontLabel: 'Police', fontSystem: 'Monospace du système (aucun téléchargement)', fontBuiltin: 'intégrée, étroite',
fontHelp: 'Polices libres. Les polices intégrées (Inconsolata Condensed, la plus étroite, Iosevka et Ubuntu Mono) sont servies par LogStream lui-même et fonctionnent hors ligne ; elles sont étroites, donc chaque ligne affiche plus de texte. Les autres sont chargées par votre navigateur depuis Bunny Fonts, un service européen respectueux de la vie privée ; sans accès à internet, la police du système est utilisée.',
previewMsgs: ['error: link down on eth1, carrier lost', 'warning: disk /dev/sda temperature 58°C', 'backup finished: ok (12.4 GB in 00:14:32)'],
dangerZone: 'Zone de danger',
dbTitle: 'Base de données', dbRefresh: 'Actualiser', dbLoading: 'Chargement…',
dbUnits: ['o', 'Ko', 'Mo', 'Go', 'To'],
dbRows: 'Lignes stockées', dbDisk: 'Taille sur disque', dbIndex: (x) => `dont ${x} d'index`,
dbRaw: 'Taille brute', dbRatio: (x) => `compression ×${x}`,
dbPeriod: 'Période', dbFromTo: (p) => `${p.from} → ${p.to}`, dbDays: (n) => `${n} jour${n > 1 ? 's' : ''}`,
dbRetention: (r) => `rétention ${r}`,
dbRecent: 'Dernières 24 h', dbLastHour: (n) => `${n} sur la dernière heure`,
dbSources: 'Sources', dbHostsApps: (p) => `${p.h} hôte${p.hn > 1 ? 's' : ''} · ${p.a} application${p.an > 1 ? 's' : ''}`,
dbFree: 'Espace disque libre', dbEmpty: 'La base est vide.',
dbQueryErr: 'Certains chiffres n\'ont pas pu être calculés : ',
purgeHelp: 'Supprime définitivement tous les logs stockés pour repartir de zéro. Les tags de couleur et les réglages sont conservés.',
purgeBtn: 'Supprimer tous les logs…',
purgePrompt: 'Cette action supprime définitivement TOUS les logs stockés.\n\nTapez PURGE pour confirmer :',
purgeStarted: 'Suppression lancée',
purgeRunning: 'Suppression en cours… les logs disparaissent progressivement.',
purgeDone: 'Tous les logs ont été supprimés.',
purgeForbidden: 'La purge est désactivée sur ce serveur (ALLOW_PURGE=false).',
err_purge_unavailable: 'VictoriaLogs refuse les suppressions : lancez-le avec -delete.enable (voir docker-compose.yml)',
err_purge_forbidden: 'La purge est désactivée sur ce serveur (ALLOW_PURGE=false)',
err_read_only: 'Compte en lecture seule : les modifications sont réservées aux administrateurs',
err_cross_site: 'Requête refusée : elle vient d\'un autre site',
authOff: 'Aucune authentification : toute personne qui atteint cette page peut lire les logs et changer les réglages. Définissez AUTH_USER / AUTH_PASS ou AUTH_MODE=oidc.',
readOnlyNote: 'Compte en lecture seule : seul un administrateur peut modifier ces réglages.',
err_purge_confirm: 'Tapez PURGE pour confirmer',
liveZoomed: 'Le direct n\'est disponible qu\'en mode Flux',
connZoom: 'direct en pause (période)',
viewAria: 'Mode d\'affichage', viewStream: 'Flux', viewPeriod: 'Période',
viewStreamTitle: 'Flux : les derniers logs sur une durée glissante, mis à jour en direct',
viewPeriodTitle: 'Période : les logs entre une date de début et une date de fin',
perFrom: 'Début', perTo: 'Fin', perPrev: 'Période précédente', perNext: 'Période suivante',
perOut: 'Élargir (deux fois plus long)',
histoTitle: 'Frise',
hScale: 'Échelle', hLinear: 'Linéaire', hHeight: 'Hauteur', hColor: 'Couleur',
hColSeverity: 'Sévérité', hColIntensity: 'Intensité', hColNone: 'Aucune',
hRender: 'Rendu', hBars: 'Barres', hArea: 'Aire',
hStep: 'Division', hAuto: 'Automatique', hRefresh: 'Rafraîchissement', hOff: 'Désactivé', hRefreshAuto: 'À chaque nouvel intervalle',
histoHelp: 'Cliquez sur une barre pour afficher son intervalle en mode Période, ou glissez sur plusieurs. L\'échelle √ garde visibles les petits volumes à côté des rafales. L\'intensité compare chaque intervalle à la médiane de la fenêtre : calme, rafale (plus de 3×), anomalie (plus de 10×). En direct, le dernier intervalle se met à jour à l\'arrivée des messages.',
hLeg_err: 'error et plus', hLeg_warn: 'warning', hLeg_other: 'notice, info, debug',
hLeg_calm: 'calme', hLeg_burst: 'rafale > 3×', hLeg_anom: 'anomalie > 10×',
hTotal: 'Total', hRatio: (x) => `${x}× la médiane`, hUnshown: 'sans détail (débit élevé)',
hDivision: (s) => `intervalle ${s}`, hCapped: 'élargi',
hCappedTitle: 'La division choisie dépasserait 300 intervalles sur cette plage : elle a été élargie.',
hUnzoom: '× Revenir au flux', unitDay: 'j',
toTop: 'Revenir en haut',
colRcv: 'Réception', colMt: 'Heure message', colSev: 'Sévérité', colHost: 'Hôte', colApp: 'App', colCodes: 'Filtres', colMsg: 'Message',
codesTitle: 'Codes des tags de couleur trouvés dans le message',
tagCodeTitle: 'Code affiché sur les lignes de log où ce tag est trouvé',
colGrip: 'Glisser pour redimensionner, double-clic pour la largeur automatique',
resetCols: 'Réinitialiser les colonnes', colsReset: 'Largeurs de colonnes réinitialisées',
colsHelp: 'Glissez le bord d\'un en-tête de colonne de la liste pour la redimensionner (mémorisé par ce navigateur).',
},
};
/* ================= Utilities ================= */
const $ = (sel) => document.querySelector(sel);
const PAGE_SIZE = 500; // rows loaded per search
const MAX_ROWS = 2000; // rows kept on screen in live mode
const RANGE_MS = { '5m': 3e5, '15m': 9e5, '1h': 36e5, '6h': 216e5, '24h': 864e5, '7d': 6048e5, '30d': 2592e6 };
const store = {
get(k, def) { try { const v = localStorage.getItem('logstream.' + k); return v === null ? def : v; } catch { return def; } },
set(k, v) { try { localStorage.setItem('logstream.' + k, v); } catch { /* storage unavailable */ } },
};
let lang = I18N[store.get('lang', 'en')] ? store.get('lang', 'en') : 'en';
function t(key, arg) {
const v = I18N[lang][key] ?? I18N.en[key] ?? key;
return typeof v === 'function' ? v(arg) : v;
}
const ESC = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ESC[c]);
const pad = (n, w = 2) => String(n).padStart(w, '0');
const debounce = (fn, ms) => { let tm; return (...a) => { clearTimeout(tm); tm = setTimeout(() => fn(...a), ms); }; };
const fmtNum = (n) => Number(n || 0).toLocaleString(t('locale'));
// Date.parse only accepts 3 decimals everywhere: drop the nanoseconds.
const parseTime = (s) => new Date(String(s || '').replace(/(\.\d{3})\d+/, '$1'));
/* ================= Time zones and time formats ================= */
const TIME_ZONES = {
General: ['UTC'],
Europe: ['Europe/London', 'Europe/Dublin', 'Europe/Lisbon', 'Europe/Paris', 'Europe/Brussels', 'Europe/Amsterdam',
'Europe/Luxembourg', 'Europe/Berlin', 'Europe/Zurich', 'Europe/Madrid', 'Europe/Rome', 'Europe/Vienna',
'Europe/Prague', 'Europe/Warsaw', 'Europe/Stockholm', 'Europe/Oslo', 'Europe/Copenhagen', 'Europe/Helsinki',
'Europe/Athens', 'Europe/Bucharest', 'Europe/Kyiv', 'Europe/Istanbul', 'Europe/Moscow'],
Africa: ['Africa/Casablanca', 'Africa/Algiers', 'Africa/Tunis', 'Africa/Dakar', 'Africa/Abidjan', 'Africa/Lagos',
'Africa/Cairo', 'Africa/Nairobi', 'Africa/Johannesburg'],
America: ['America/St_Johns', 'America/Halifax', 'America/Montreal', 'America/Toronto', 'America/New_York',
'America/Chicago', 'America/Denver', 'America/Phoenix', 'America/Los_Angeles', 'America/Vancouver',
'America/Anchorage', 'America/Mexico_City', 'America/Martinique', 'America/Guadeloupe', 'America/Cayenne',
'America/Bogota', 'America/Lima', 'America/Santiago', 'America/Sao_Paulo', 'America/Buenos_Aires'],
Asia: ['Asia/Dubai', 'Asia/Tehran', 'Asia/Karachi', 'Asia/Kolkata', 'Asia/Kathmandu', 'Asia/Dhaka', 'Asia/Bangkok',
'Asia/Ho_Chi_Minh', 'Asia/Jakarta', 'Asia/Singapore', 'Asia/Hong_Kong', 'Asia/Shanghai', 'Asia/Taipei',
'Asia/Manila', 'Asia/Seoul', 'Asia/Tokyo'],
Oceania: ['Australia/Perth', 'Australia/Adelaide', 'Australia/Brisbane', 'Australia/Sydney', 'Pacific/Noumea',
'Pacific/Auckland', 'Pacific/Tahiti', 'Pacific/Honolulu'],
Other: ['Atlantic/Reykjavik', 'Atlantic/Azores', 'Atlantic/Canary', 'Indian/Reunion', 'Indian/Mayotte', 'Indian/Mauritius'],
};
// Each format receives the date parts in the selected time zone (and the Date itself).
const TIME_FORMATS = [
{ id: 'dmy', label: 'DD/MM/YYYY HH:MM:SS', f: (p) => `${p.D}/${p.M}/${p.Y} ${p.h}:${p.m}:${p.s}` },
{ id: 'dmy-ms', label: 'DD/MM/YYYY HH:MM:SS.mmm', f: (p) => `${p.D}/${p.M}/${p.Y} ${p.h}:${p.m}:${p.s}.${p.ms}` },
{ id: 'ymd', label: 'YYYY-MM-DD HH:MM:SS', f: (p) => `${p.Y}-${p.M}-${p.D} ${p.h}:${p.m}:${p.s}` },
{ id: 'ymd-ms', label: 'YYYY-MM-DD HH:MM:SS.mmm', f: (p) => `${p.Y}-${p.M}-${p.D} ${p.h}:${p.m}:${p.s}.${p.ms}` },
{ id: 'mdy12', label: 'MM/DD/YYYY hh:MM:SS.mmm AM/PM', f: (p) => {
const H = Number(p.h);
return `${p.M}/${p.D}/${p.Y} ${pad(H % 12 || 12)}:${p.m}:${p.s}.${p.ms} ${H < 12 ? 'AM' : 'PM'}`;
} },
{ id: 'time-ms', label: 'HH:MM:SS.mmm', f: (p) => `${p.h}:${p.m}:${p.s}.${p.ms}` },
{ id: 'iso', label: 'ISO 8601 (YYYY-MM-DDTHH:MM:SS.mmm+hh:mm)', f: (p) => `${p.Y}-${p.M}-${p.D}T${p.h}:${p.m}:${p.s}.${p.ms}${p.off === '+00:00' ? 'Z' : p.off}` },
{ id: 'epoch', label: null, f: (p, d) => String(d.getTime()) },
];
const DEFAULT_TIME_FMT = 'dmy'; // usual French display: 28/09/2026 14:55:23
const timePrefs = { tz: '', fmt: DEFAULT_TIME_FMT }; // tz '' = browser time zone
const partsFormatters = new Map();
function validTimeZone(tz) {
if (!tz) return true;
try { new Intl.DateTimeFormat('en-US', { timeZone: tz }); return true; } catch { return false; }
}
// Date parts in a time zone ('' = browser zone), plus the UTC offset as "+hh:mm".
function zoneParts(date, tz = timePrefs.tz) {
let f = partsFormatters.get(tz);
if (!f) {
f = new Intl.DateTimeFormat('en-US', {
timeZone: tz || undefined, year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23', timeZoneName: 'longOffset',
});
partsFormatters.set(tz, f);
}
const p = {};
for (const x of f.formatToParts(date)) p[x.type] = x.value;
const off = (p.timeZoneName || 'GMT').replace('GMT', '') || '+00:00';
return {
Y: p.year, M: p.month, D: p.day, h: p.hour === '24' ? '00' : p.hour, m: p.minute, s: p.second,
ms: pad(date.getUTCMilliseconds(), 3), off,
};
}
function fmtWith(fmtId, date) {
const fmt = TIME_FORMATS.find((x) => x.id === fmtId) || TIME_FORMATS[0];
return fmt.f(zoneParts(date), date);
}
// Full date in the selected format (reception time, details panel).
function fmtFull(s) {
const d = parseTime(s);
return isNaN(d) ? String(s ?? '') : fmtWith(timePrefs.fmt, d);
}
async function api(url, opts = {}) {
if (opts.body && typeof opts.body !== 'string') {
opts.body = JSON.stringify(opts.body);
opts.headers = { 'Content-Type': 'application/json' };
}
const res = await fetch(url, opts);
const text = await res.text();
let data = null;
try { data = text ? JSON.parse(text) : null; } catch { /* not JSON */ }
if (!res.ok) throw apiError(res, text, data);
return data;
}
// Known error codes are translated; otherwise the server message is shown.
function apiError(res, text, data) {
// OIDC session expired: reloading the page goes through the login again.
if (res.status === 401 && data && data.code === 'auth') location.reload();
let msg = (data && data.error) || text || res.statusText;
if (data && data.code && I18N[lang]['err_' + data.code]) {
msg = t('err_' + data.code) + (data.detail ? (lang === 'fr' ? ' : ' : ': ') + data.detail : '');
}
return new Error(msg);
}
function toast(msg) {
const el = $('#toast');
// A modal dialog sits above everything: move the toast into it so it stays visible.
const dlg = $('#settingsDlg');
(dlg.open ? dlg : document.body).append(el);
el.textContent = msg;
el.hidden = false;
clearTimeout(toast.tm);
toast.tm = setTimeout(() => { el.hidden = true; }, 1800);
}
/* ================= State ================= */
const state = {
rows: [], // displayed records, newest first
pending: [], // live messages received while scrolled down
tags: [],
presets: [], // preset groups from /api/presets
matchers: [], // compiled tags + search terms
mode: store.get('mode', 'simple'),
live: store.get('live', '1') === '1',
es: null,
reqId: 0,
zoom: null, // {from, to} in ms in Time range mode (range "custom"), null in Stream mode
tookMs: null,
conn: ['', 'connPaused'],
stats: null,
};
const recOf = new WeakMap(); // .row element -> record
const list = $('#list');
/* ================= Language ================= */
function applyLang() {
document.documentElement.lang = lang;
for (const el of document.querySelectorAll('[data-i18n]')) el.textContent = t(el.dataset.i18n);
for (const el of document.querySelectorAll('[data-i18n-title]')) el.title = t(el.dataset.i18nTitle) + (el.dataset.user ? ` (${el.dataset.user})` : '');
for (const el of document.querySelectorAll('[data-i18n-aria]')) el.setAttribute('aria-label', t(el.dataset.i18nAria));
for (const el of document.querySelectorAll('[data-i18n-ph]')) el.placeholder = t(el.dataset.i18nPh);
for (const b of document.querySelectorAll('#langSwitch [data-lang]')) b.setAttribute('aria-checked', String(b.dataset.lang === lang));
}
function setLang(next) {
if (next === lang || !I18N[next]) return;
lang = next;
store.set('lang', lang);
applyLang();
syncControls();
setConn(...state.conn);
renderList();
updateCount(state.tookMs);
renderPeriod();
renderHisto();
renderStats();
renderTagList();
renderPresets();
renderTimeSettings();
renderPurge();
renderDbStats();
renderInterface();
renderSyslog();
renderHost();
renderDmesg();
renderAudit();
renderSelfLog();
renderDocker();
}
$('#langSwitch').addEventListener('click', (ev) => {
const b = ev.target.closest('[data-lang]');
if (b) setLang(b.dataset.lang);
});
/* ================= Theme ================= */
function effectiveTheme() {
const th = document.documentElement.dataset.theme;
if (th) return th;
return matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
// mode: 'system' (follow the OS), 'light' or 'dark'.
function setTheme(mode) {
if (mode === 'light' || mode === 'dark') document.documentElement.dataset.theme = mode;
else { delete document.documentElement.dataset.theme; mode = 'system'; }
store.set('theme', mode);
renderThemeSwitch();
}
function renderThemeSwitch() {
const mode = document.documentElement.dataset.theme || 'system';
for (const b of document.querySelectorAll('#themeSwitch [data-theme-opt]')) {
b.setAttribute('aria-checked', String(b.dataset.themeOpt === mode));
}
}
// The header button switches between light and dark.
$('#themeBtn').addEventListener('click', () => setTheme(effectiveTheme() === 'dark' ? 'light' : 'dark'));
$('#themeSwitch').addEventListener('click', (ev) => {
const b = ev.target.closest('[data-theme-opt]');
if (b) setTheme(b.dataset.themeOpt);
});
/* ================= Log font and size ================= */
// Free monospace fonts, served by Bunny Fonts (https://fonts.bunny.net), except the
// built-in ones (web/fonts, declared in style.css), which also work offline.
const LOG_FONTS = [
{ id: 'system' },
{ id: 'inconsolata-condensed', family: 'Inconsolata Condensed', builtin: true },
{ id: 'iosevka', family: 'Iosevka', builtin: true },
{ id: 'ubuntu-mono', family: 'Ubuntu Mono', builtin: true },
{ id: 'jetbrains-mono', family: 'JetBrains Mono' },
{ id: 'fira-code', family: 'Fira Code' },
{ id: 'source-code-pro', family: 'Source Code Pro' },
{ id: 'ibm-plex-mono', family: 'IBM Plex Mono' },
{ id: 'cascadia-code', family: 'Cascadia Code' },
{ id: 'roboto-mono', family: 'Roboto Mono' },
{ id: 'inconsolata', family: 'Inconsolata' },
{ id: 'red-hat-mono', family: 'Red Hat Mono' },
{ id: 'noto-sans-mono', family: 'Noto Sans Mono' },
{ id: 'victor-mono', family: 'Victor Mono' },
{ id: 'dm-mono', family: 'DM Mono', weights: '400,500' },
];
const LOG_SIZES = { tiny: '10.5px', small: '11.5px', medium: '12.5px', large: '14px' };
const LOG_DENSITIES = ['normal', 'compact'];
const ui = { font: 'system', size: 'medium', density: 'normal' };
function applyLogFont(id) {
const f = LOG_FONTS.find((x) => x.id === id) || LOG_FONTS[0];
ui.font = f.id;
const root = document.documentElement.style;
if (!f.family) {
root.removeProperty('--log-font');
return;
}
if (f.builtin) {
root.setProperty('--log-font', `'${f.family}', var(--mono)`);
return;
}
const href = `https://fonts.bunny.net/css?family=${f.id}:${f.weights || '400,700'}&display=swap`;
let link = document.getElementById('logFontCss');
if (!link) {
link = Object.assign(document.createElement('link'), { id: 'logFontCss', rel: 'stylesheet' });
document.head.append(link);
}
if (link.getAttribute('href') !== href) link.href = href;
root.setProperty('--log-font', `'${f.family}', var(--mono)`);
}
function applyLogSize(id) {
ui.size = LOG_SIZES[id] ? id : 'medium';
document.documentElement.style.setProperty('--log-size', LOG_SIZES[ui.size]);
}
function applyLogDensity(id) {
ui.density = LOG_DENSITIES.includes(id) ? id : 'normal';
document.documentElement.dataset.density = ui.density;
}
function renderInterface() {
renderThemeSwitch();
for (const b of document.querySelectorAll('#sizeSwitch [data-size]')) {
b.setAttribute('aria-checked', String(b.dataset.size === ui.size));
}
for (const b of document.querySelectorAll('#densitySwitch [data-density]')) {
b.setAttribute('aria-checked', String(b.dataset.density === ui.density));
}
const sel = $('#fontSelect');
const fontName = (f) => (!f.family ? t('fontSystem') : f.builtin ? `${f.family} (${t('fontBuiltin')})` : f.family);
sel.innerHTML = LOG_FONTS.map((f) => ``).join('');
sel.value = ui.font;
renderFontPreview();
renderHistoSettings();
}
function renderFontPreview() {
const now = new Date().toISOString();
const [m1, m2, m3] = t('previewMsgs');
const rows = [
{ received: now, msg_time: now, severity: 'err', host: 'fw-edge', app: 'kernel', _msg: m1 },
{ received: now, msg_time: now, severity: 'warning', host: 'nas', app: 'smartd', _msg: m2 },
{ received: now, msg_time: now, severity: 'info', host: 'web-01', app: 'backup', _msg: m3 },
];
$('#fontPreview').innerHTML = rows.map((r) => rowHTML(r, false)).join('');
}
$('#sizeSwitch').addEventListener('click', (ev) => {
const b = ev.target.closest('[data-size]');
if (!b) return;
applyLogSize(b.dataset.size);
store.set('logSize', ui.size);
renderInterface();
});
$('#densitySwitch').addEventListener('click', (ev) => {
const b = ev.target.closest('[data-density]');
if (!b) return;
applyLogDensity(b.dataset.density);
store.set('logDensity', ui.density);
renderInterface();
});
$('#fontSelect').addEventListener('change', (ev) => {
applyLogFont(ev.target.value);
store.set('logFont', ui.font);
});
/* ================= Settings tabs ================= */
function showSettingsTab(name) {
const tabs = [...document.querySelectorAll('#setNav [data-tab]')];
if (!tabs.some((b) => b.dataset.tab === name)) name = 'locale';
for (const b of tabs) b.setAttribute('aria-selected', String(b.dataset.tab === name));
for (const p of document.querySelectorAll('.set-panel')) p.hidden = p.dataset.panel !== name;
store.set('settingsTab', name);
}
$('#setNav').addEventListener('click', (ev) => {
const b = ev.target.closest('[data-tab]');
if (b) showSettingsTab(b.dataset.tab);
});
/* ================= Filters ================= */
function params() {
const p = new URLSearchParams();
const q = $('#q').value.trim();
if (q) p.set('q', q);
p.set('mode', state.mode);
if (state.zoom) {
p.set('range', 'custom');
p.set('from', state.zoom.from);
p.set('to', state.zoom.to);
} else {
p.set('range', $('#range').value);
}
for (const id of ['severity', 'host', 'app', 'source']) {
const v = $('#' + id).value;
if (v !== '') p.set(id, v);
}
return p;
}
function liveAvailable() {
return !state.zoom && !(state.mode === 'logsql' && $('#q').value.trim());
}
function syncControls() {
const logsql = state.mode === 'logsql';
$('#mode').textContent = logsql ? 'LogsQL' : 'Simple';
$('#mode').classList.toggle('on', logsql);
$('#q').placeholder = logsql ? t('searchLogsql') : t('searchSimple');
const btn = $('#live');
btn.disabled = !liveAvailable();
btn.classList.toggle('on', state.live && liveAvailable());
btn.title = liveAvailable() ? t('liveTitle') : t(state.zoom ? 'liveZoomed' : 'liveUnavailable');
for (const id of ['severity', 'host', 'app', 'source']) $('#' + id).classList.toggle('set', $('#' + id).value !== '');
}
/* ================= Tags and highlighting ================= */
// Readable text color (black or white) for a given background.
function textOn(hex) {
const n = parseInt(hex.slice(1), 16);
const yiq = (((n >> 16) & 255) * 299 + ((n >> 8) & 255) * 587 + (n & 255) * 114) / 1000;
return yiq >= 150 ? '#111827' : '#ffffff';
}
const tagStyle = (color) => `background:${color};color:${textOn(color)}`;
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
// Severity badges share the colors of the "error" and "warning" tags.
function applySeverityColors() {
const style = document.documentElement.style;
for (const name of ['error', 'warning']) {
const tag = state.tags.find((x) => String(x.pattern).toLowerCase() === name && /^#[0-9a-f]{6}$/i.test(x.color));
if (tag) {
style.setProperty(`--tag-${name}`, tag.color);
style.setProperty(`--tag-${name}-text`, textOn(tag.color));
} else {
style.removeProperty(`--tag-${name}`);
style.removeProperty(`--tag-${name}-text`);
}
}
}
function compileMatchers() {
applySeverityColors();
const out = [];
for (const tag of state.tags) {
if (!tag.enabled || !tag.pattern) continue;
let src = tag.regex ? tag.pattern : escapeRe(tag.pattern);
if (tag.wholeWord) src = `(?/.test(tag.pattern);
if (hl) src = src.replace(/\(\?P/g, '(?');
out.push({ re: new RegExp(src, 'gu' + (hl ? 'd' : '') + (tag.caseSensitive ? '' : 'i')), html: ``,
code: tag.code, name: tag.label || tag.pattern });
} catch (e) {
console.warn('Tag skipped (invalid pattern):', tag.pattern, e.message);
}
}
// Simple-search terms get a subtle highlight, with the lowest priority.
if (state.mode === 'simple') {
for (const tok of $('#q').value.match(/-?"[^"]*"|\S+/g) || []) {
if (tok.startsWith('-')) continue;
const word = tok.replace(/^"|"$/g, '');
if (word) out.push({ re: new RegExp(escapeRe(word), 'giu'), html: '' });
}
}
state.matchers = out;
}
function highlight(text) {
text = String(text ?? '');
if (!state.matchers.length || !text) return esc(text);
const spans = [];
state.matchers.forEach((m, prio) => {
m.re.lastIndex = 0;
let x;
while ((x = m.re.exec(text)) !== null) {
if (x[0] === '') { m.re.lastIndex++; continue; }
const g = x.indices?.groups?.hl;
if (g) { if (g[1] > g[0]) spans.push({ s: g[0], e: g[1], prio, html: m.html }); continue; }
spans.push({ s: x.index, e: x.index + x[0].length, prio, html: m.html });
}
});
if (!spans.length) return esc(text);
spans.sort((a, b) => a.s - b.s || a.prio - b.prio);
let out = '';
let pos = 0;
for (const sp of spans) {
if (sp.s < pos) continue; // overlap: first one wins
out += esc(text.slice(pos, sp.s)) + sp.html + esc(text.slice(sp.s, sp.e)) + '';
pos = sp.e;
}
return out + esc(text.slice(pos));
}
// Badges with the codes of the tags found in a message, in list order. The
// column has room for 3: beyond that, 2 badges and "+N" (all in the tooltip).
function codesHTML(text) {
text = String(text ?? '');
const found = [];
for (const m of state.matchers) {
if (!m.code || !text) continue;
m.re.lastIndex = 0;
let x;
while ((x = m.re.exec(text)) !== null) {
if (x[0] === '') { m.re.lastIndex++; continue; }
const g = x.indices?.groups?.hl;
if (!x.indices?.groups || g) { found.push(m); break; }
}
}
if (!found.length) return '';
const shown = found.length > 3 ? found.slice(0, 2) : found;
const title = found.map((m) => `${m.code} ${m.name}`).join('\n');
return ``
+ shown.map((m) => `${esc(m.code)}`).join('')
+ (found.length > 3 ? `+${found.length - 2}` : '')
+ '';
}
/* ================= List rendering ================= */
const SEV_CLASS = { emerg: 'crit', alert: 'crit', crit: 'crit', err: 'err', warning: 'warning', notice: 'notice', info: 'info', debug: 'debug' };
function fmtTime(s) {
const d = parseTime(s);
if (isNaN(d)) return esc(s);
const p = zoneParts(d);
const day = lang === 'fr' ? `${p.D}/${p.M}` : `${p.M}/${p.D}`;
return `${day} ${p.h}:${p.m}:${p.s}.${p.ms}`;
}
// Sources with their own look in the list: a colored edge and a badge
// before the app name (the severity badge stays the same).
const SRC_BADGE = { audit: 'AUDIT', logstream: 'LS' };
function rowHTML(r, isNew) {
const sev = r.severity || '';
const cls = SEV_CLASS[sev] || 'info';
const badge = SRC_BADGE[r.source_type];
return ``
+ ``
+ ``
+ `${esc(sev || '—')}`
+ (r.host
? `${esc(r.host_name || r.host)}`
: '')
+ (r.app
? `${r.source_type === 'docker' ? DOCKER_ICON : ''}${badge ? `${badge}` : ''}${esc(r.app)}`
: '')
+ `${codesHTML(r._msg)}`
+ `
${msgHTML(r)}
`
+ '';
}
// Message cell; audit events also show their raw record (hidden in compact
// density until the row is opened).
function msgHTML(r) {
return highlight(r._msg)
+ (r.source_type === 'audit' && r.audit_raw ? `
${esc(r.audit_raw)}
` : '');
}
// Stable hue (0-359) for a name: the compose project color, shared by the
// Sources labels and the Docker app names in the log list.
function hueOf(name) {
let h = 0;
for (const ch of String(name)) h = (h * 31 + ch.codePointAt(0)) >>> 0;
return h % 360;
}
const DOCKER_ICON = '';
// Tooltip of the host cell: name and IP when both are known.
function hostTitle(r) {
const ip = r.host_ip || (r.host_name ? r.host : '');
const name = r.host_name || r.host;
return ip ? `${name} (${ip})` : name;
}
// Map the .row elements starting at `first` to their records.
function bindRows(first, rows) {
let el = first;
for (const r of rows) {
if (!el) break;
recOf.set(el, r);
el = el.nextElementSibling;
}
}
// Adds the hosts/apps of displayed logs to the filter lists (live logs can
// show a host before the next refresh of /api/facets).
function addFacetValues(rows) {
for (const id of ['host', 'app']) {
const sel = $('#' + id);
const have = new Set([...sel.options].map((o) => o.value));
let added = false;
for (const r of rows) {
const v = r[id];
if (!v || have.has(v)) continue;
have.add(v);
sel.add(new Option(id === 'host' && r.host_name ? `${r.host_name} (${v})` : v, v));
added = true;
}
if (added) {
const cur = sel.value;
const [first, ...rest] = [...sel.options];
rest.sort((a, b) => a.text.localeCompare(b.text));
sel.replaceChildren(first, ...rest);
sel.value = cur;
}
}
}
function renderList() {
addFacetValues(state.rows);
list.innerHTML = state.rows.map((r) => rowHTML(r, false)).join('');
bindRows(list.firstElementChild, state.rows);
$('#table').hidden = !state.rows.length;
$('#empty').hidden = state.rows.length > 0 || !$('#error').hidden;
}
function rehighlight() {
for (const row of list.children) {
const r = recOf.get(row);
if (!r) continue;
row.querySelector('.msg').innerHTML = msgHTML(r);
row.querySelector('.codes').innerHTML = codesHTML(r._msg);
}
}
function prependRows(rows) {
if (!rows.length) return;
addFacetValues(rows);
list.insertAdjacentHTML('afterbegin', rows.map((r) => rowHTML(r, true)).join(''));
bindRows(list.firstElementChild, rows);
state.rows = rows.concat(state.rows);
$('#table').hidden = false;
while (state.rows.length > MAX_ROWS) {
state.rows.pop();
list.lastElementChild?.remove();
}
$('#empty').hidden = true;
updateCount();
}
function updateCount(tookMs) {
if (!$('#error').hidden) { $('#count').textContent = ''; return; }
const took = tookMs != null ? ` · ${tookMs} ms` : '';
$('#count').textContent = t('count', state.rows.length).replace(/^\d+/, fmtNum(state.rows.length)) + took;
}
function detailsHTML(r) {
const order = ['received', 'msg_time', '_time', 'host', 'host_name', 'host_ip', 'source_type', 'audit_type', 'audit_serial', 'auid', 'user', 'exe', 'res', 'key', 'container', 'image', 'compose_project', 'compose_service', 'stream', 'unit', 'log_file', 'subsystem', 'device', 'app', 'procid', 'severity', 'facility', 'source', 'proto', 'sd', '_msg'];
const hidden = new Set(['_stream_id', '_stream', 'sevnum', 'audit_raw']); // audit_raw: shown under the message
if (r.msg_time) hidden.add('_time'); // same as the reception time
const keys = order.filter((k) => r[k] != null && r[k] !== '' && !hidden.has(k))
.concat(Object.keys(r).filter((k) => !order.includes(k) && !hidden.has(k)).sort());
const label = {
container: t('fContainer'), container_id: t('fContainerId'), image: t('fImage'), compose_project: t('fProject'),
compose_service: t('fService'), stream: t('fStream'), source_type: t('fSourceType'), unit: t('fUnit'), log_file: t('fLogFile'),
msg_time: t('fTime'), host_name: t('fHostName'), host_ip: t('fHostIp'), received: t('fReceived'), _time: t('fTime'), _msg: t('fMsg'), procid: t('fPid'), sd: t('fSd') };
const val = (k) => (k === '_time' || k === 'received' || k === 'msg_time'
? `${esc(fmtFull(r[k]))} (${esc(r[k])})`
: esc(r[k]));
return '