'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)',
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)',
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();
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}`;
}
function rowHTML(r, isNew) {
const sev = r.severity || '';
const cls = SEV_CLASS[sev] || 'info';
return ``
+ ``
+ ``
+ `${esc(sev || '—')}`
+ (r.host
? `${esc(r.host_name || r.host)}`
: '')
+ (r.app
? `${r.source_type === 'docker' ? DOCKER_ICON : ''}${esc(r.app)}`
: '')
+ `${codesHTML(r._msg)}`
+ `
${highlight(r._msg)}
`
+ '';
}
// 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 = highlight(r._msg);
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', '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']);
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 '