Collect the logs of the local Docker containers
- docker.go follows every running container through the Docker API (events + logs with follow), resumes after a restart from the last position saved in /data/docker-state.json, reads DOCKER_BACKFILL (1h) of history for new containers, strips terminal color codes and guesses the severity from the line (JSON, logfmt, [ERROR], ERROR ...). - Logs carry source_type=docker, container, container_id, image, compose_project, compose_service and stream; host is the Docker host. - Settings > Sources: one switch per container (grouped by compose project), enable/disable all, follow new containers automatically. Choices are saved per compose service in /data/docker.json. - Source filter (syslog / docker) in the filter bar and the live view. - docker-compose: read-only docker-socket-proxy; Logstream and the proxy are labelled logstream.exclude=true and never collected. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
aad0fb7c16
commit
085095c46f
12 files changed
+974
-10
No files matched your search
+123
-6
@@ -66,6 +66,19 @@ const I18N = {
|
||||
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',
|
||||
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.`,
|
||||
dockerStandalone: 'Standalone containers',
|
||||
stFollowing: 'followed', stIgnored: 'running, ignored', 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',
|
||||
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.`,
|
||||
@@ -154,6 +167,19 @@ const I18N = {
|
||||
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',
|
||||
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.`,
|
||||
dockerStandalone: 'Conteneurs isolés',
|
||||
stFollowing: 'suivi', stIgnored: 'actif, ignoré', 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',
|
||||
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.`,
|
||||
@@ -362,6 +388,7 @@ function setLang(next) {
|
||||
renderTimeSettings();
|
||||
renderPurge();
|
||||
renderInterface();
|
||||
renderDocker();
|
||||
}
|
||||
|
||||
$('#langSwitch').addEventListener('click', (ev) => {
|
||||
@@ -498,7 +525,7 @@ function params() {
|
||||
if (q) p.set('q', q);
|
||||
p.set('mode', state.mode);
|
||||
p.set('range', $('#range').value);
|
||||
for (const id of ['severity', 'host', 'app']) {
|
||||
for (const id of ['severity', 'host', 'app', 'source']) {
|
||||
const v = $('#' + id).value;
|
||||
if (v !== '') p.set(id, v);
|
||||
}
|
||||
@@ -518,7 +545,7 @@ function syncControls() {
|
||||
btn.disabled = !liveAvailable();
|
||||
btn.classList.toggle('on', state.live && liveAvailable());
|
||||
btn.title = liveAvailable() ? t('liveTitle') : t('liveUnavailable');
|
||||
for (const id of ['severity', 'host', 'app']) $('#' + id).classList.toggle('set', $('#' + id).value !== '');
|
||||
for (const id of ['severity', 'host', 'app', 'source']) $('#' + id).classList.toggle('set', $('#' + id).value !== '');
|
||||
}
|
||||
|
||||
/* ================= Tags and highlighting ================= */
|
||||
@@ -618,12 +645,14 @@ function rowHTML(r, isNew) {
|
||||
? `<span class="host" data-act="host" title="${esc(hostTitle(r) + ' · ' + t('clickHost'))}">${esc(r.host_name || r.host)}</span>`
|
||||
: '<span class="host"></span>')
|
||||
+ (r.app
|
||||
? `<span class="app" data-act="app" title="${esc(r.app + ' · ' + t('clickApp'))}">${esc(r.app)}</span>`
|
||||
? `<span class="app" data-act="app" title="${esc((r.container ? r.container + ' · ' : '') + r.app + ' · ' + t('clickApp'))}">${r.source_type === 'docker' ? DOCKER_ICON : ''}${esc(r.app)}</span>`
|
||||
: '<span class="app"></span>')
|
||||
+ `<div class="msg">${highlight(r._msg)}</div>`
|
||||
+ '</article>';
|
||||
}
|
||||
|
||||
const DOCKER_ICON = '<svg class="src-ico" viewBox="0 0 24 24" aria-hidden="true"><path d="M21 8 12 3 3 8v8l9 5 9-5z"/><path d="m3 8 9 5 9-5M12 13v8"/></svg>';
|
||||
|
||||
// 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 : '');
|
||||
@@ -700,12 +729,15 @@ function updateCount(tookMs) {
|
||||
}
|
||||
|
||||
function detailsHTML(r) {
|
||||
const order = ['received', 'msg_time', '_time', 'host', 'host_name', 'host_ip', 'app', 'procid', 'severity', 'facility', 'source', 'proto', 'sd', '_msg'];
|
||||
const order = ['received', 'msg_time', '_time', 'host', 'host_name', 'host_ip', 'source_type', 'container', 'image', 'compose_project', 'compose_service', 'stream', '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 = { 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 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'),
|
||||
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]))} <span class="muted">(${esc(r[k])})</span>`
|
||||
: esc(r[k]));
|
||||
@@ -847,7 +879,7 @@ $('#q').addEventListener('keydown', (ev) => {
|
||||
if (ev.key === 'Enter') refresh();
|
||||
if (ev.key === 'Escape') { ev.target.value = ''; refresh(); }
|
||||
});
|
||||
for (const id of ['range', 'severity', 'host', 'app']) $('#' + id).addEventListener('change', onFilterChange);
|
||||
for (const id of ['range', 'severity', 'host', 'app', 'source']) $('#' + id).addEventListener('change', onFilterChange);
|
||||
|
||||
$('#mode').addEventListener('click', () => {
|
||||
state.mode = state.mode === 'simple' ? 'logsql' : 'simple';
|
||||
@@ -1048,6 +1080,90 @@ $('#exportMenu').addEventListener('click', (ev) => {
|
||||
document.addEventListener('click', (ev) => { if (!ev.target.closest('.export')) setExportMenu(false); });
|
||||
document.addEventListener('keydown', (ev) => { if (ev.key === 'Escape') setExportMenu(false); });
|
||||
|
||||
/* ================= Docker source ================= */
|
||||
|
||||
let dockerState = null;
|
||||
|
||||
async function loadDocker() {
|
||||
try { dockerState = await api('/api/docker'); } catch (e) { dockerState = { enabled: true, connected: false, error: e.message }; }
|
||||
renderDocker();
|
||||
}
|
||||
|
||||
function renderDocker() {
|
||||
const status = $('#dockerStatus');
|
||||
const body = $('#dockerBody');
|
||||
const d = dockerState;
|
||||
if (!d) { status.textContent = ''; body.hidden = true; return; }
|
||||
if (!d.enabled) {
|
||||
status.textContent = t('dockerOff');
|
||||
status.className = 'docker-status muted';
|
||||
body.hidden = true;
|
||||
return;
|
||||
}
|
||||
const cs = d.containers || [];
|
||||
if (!d.connected) {
|
||||
status.textContent = t('dockerErr') + (d.error || '');
|
||||
status.className = 'docker-status bad';
|
||||
} else {
|
||||
status.textContent = t('dockerOk', { h: d.host || 'docker', n: cs.length, f: cs.filter((c) => c.following).length });
|
||||
status.className = 'docker-status good';
|
||||
}
|
||||
body.hidden = !cs.length && !d.connected;
|
||||
$('#dockerDefault').checked = !!d.defaultEnabled;
|
||||
|
||||
// Group by compose project; standalone containers last.
|
||||
const groups = new Map();
|
||||
for (const c of cs) {
|
||||
const g = c.project || '';
|
||||
if (!groups.has(g)) groups.set(g, []);
|
||||
groups.get(g).push(c);
|
||||
}
|
||||
const names = [...groups.keys()].sort((a, b) => (a === '') - (b === '') || a.localeCompare(b));
|
||||
let html = '';
|
||||
for (const g of names) {
|
||||
html += `<div class="dk-group"><div class="dk-group-title">${esc(g || t('dockerStandalone'))}</div>`;
|
||||
for (const c of groups.get(g)) {
|
||||
let st = 'stopped';
|
||||
let label = t('stStopped');
|
||||
if (c.locked) { st = 'locked'; label = t('stLocked'); }
|
||||
else if (c.following) { st = 'following'; label = t('stFollowing'); }
|
||||
else if (c.state === 'running') { st = 'ignored'; label = t('stIgnored'); }
|
||||
html += `<label class="dk-row${c.locked ? ' locked' : ''}">
|
||||
<input type="checkbox" class="switch" data-key="${esc(c.key)}"${c.enabled ? ' checked' : ''}${c.locked ? ' disabled' : ''}>
|
||||
<span class="dk-main"><strong>${esc(c.service || c.name)}</strong><small>${esc(c.name)} · ${esc(c.image)}</small></span>
|
||||
<span class="dk-state ${st}"${c.locked ? ` title="${esc(t('stLockedTitle'))}"` : ''}>${esc(label)}</span>
|
||||
</label>`;
|
||||
}
|
||||
html += '</div>';
|
||||
}
|
||||
$('#dockerList').innerHTML = html;
|
||||
}
|
||||
|
||||
async function configureDocker(body) {
|
||||
try {
|
||||
dockerState = await api('/api/docker', { method: 'PUT', body });
|
||||
renderDocker();
|
||||
setTimeout(loadDocker, 1500); // followers start or stop in the background
|
||||
} catch (e) { toast(e.message); }
|
||||
}
|
||||
|
||||
$('#dockerDefault').addEventListener('change', (ev) => configureDocker({ defaultEnabled: ev.target.checked }));
|
||||
$('#dockerList').addEventListener('change', (ev) => {
|
||||
const key = ev.target.dataset.key;
|
||||
if (key) configureDocker({ containers: { [key]: ev.target.checked } });
|
||||
});
|
||||
for (const [id, on] of [['#dockerAll', true], ['#dockerNone', false]]) {
|
||||
$(id).addEventListener('click', () => {
|
||||
const containers = {};
|
||||
for (const c of (dockerState && dockerState.containers) || []) if (!c.locked) containers[c.key] = on;
|
||||
configureDocker({ containers });
|
||||
});
|
||||
}
|
||||
// Keep the list fresh while the Sources tab is open.
|
||||
setInterval(() => {
|
||||
if ($('#settingsDlg').open && !document.querySelector('[data-panel="sources"]').hidden) loadDocker();
|
||||
}, 4000);
|
||||
|
||||
/* ================= Purge ================= */
|
||||
|
||||
const purge = { allowed: true, running: 0, error: null, code: null, wasRunning: false, timer: null };
|
||||
@@ -1207,6 +1323,7 @@ $('#settingsBtn').addEventListener('click', () => {
|
||||
renderTagList();
|
||||
renderInterface();
|
||||
loadPurgeStatus();
|
||||
loadDocker();
|
||||
showSettingsTab(store.get('settingsTab', 'locale'));
|
||||
$('#settingsDlg').showModal();
|
||||
});
|
||||
|
||||
@@ -66,6 +66,11 @@
|
||||
</select>
|
||||
<select id="host" data-i18n-aria="hostAria"><option value="" data-i18n="allHosts">All hosts</option></select>
|
||||
<select id="app" data-i18n-aria="appAria"><option value="" data-i18n="allApps">All apps</option></select>
|
||||
<select id="source" data-i18n-aria="sourceAria">
|
||||
<option value="" data-i18n="srcAll">All sources</option>
|
||||
<option value="syslog">Syslog</option>
|
||||
<option value="docker">Docker</option>
|
||||
</select>
|
||||
<span class="spacer"></span>
|
||||
<span id="count" class="muted"></span>
|
||||
<div class="export">
|
||||
@@ -121,6 +126,10 @@
|
||||
<svg viewBox="0 0 24 24"><path d="M3 12V4a1 1 0 0 1 1-1h8l9 9-9 9z"/><circle cx="8" cy="8" r="1.5"/></svg>
|
||||
<span data-i18n="tabFilters">Filters</span>
|
||||
</button>
|
||||
<button type="button" role="tab" data-tab="sources">
|
||||
<svg viewBox="0 0 24 24"><path d="M21 8 12 3 3 8v8l9 5 9-5z"/><path d="m3 8 9 5 9-5M12 13v8"/></svg>
|
||||
<span data-i18n="tabSources">Sources</span>
|
||||
</button>
|
||||
<button type="button" role="tab" data-tab="interface">
|
||||
<svg viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="12" rx="2"/><path d="M8 20h8M12 16v4"/></svg>
|
||||
<span data-i18n="tabInterface">Interface</span>
|
||||
@@ -167,6 +176,26 @@
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Sources -->
|
||||
<div class="set-panel" role="tabpanel" data-panel="sources" hidden>
|
||||
<section class="set-section">
|
||||
<h3 data-i18n="dockerTitle">Docker containers</h3>
|
||||
<p id="dockerStatus" class="docker-status"></p>
|
||||
<div id="dockerBody" hidden>
|
||||
<label class="switch-row">
|
||||
<input id="dockerDefault" type="checkbox" class="switch">
|
||||
<span data-i18n="dockerDefault">Follow new containers automatically</span>
|
||||
</label>
|
||||
<div class="docker-actions">
|
||||
<button id="dockerAll" class="btn" type="button" data-i18n="dockerAll">Enable all</button>
|
||||
<button id="dockerNone" class="btn ghost" type="button" data-i18n="dockerNone">Disable all</button>
|
||||
</div>
|
||||
<div id="dockerList" class="docker-list"></div>
|
||||
</div>
|
||||
<p class="muted small" data-i18n="dockerHelp"></p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Interface -->
|
||||
<div class="set-panel" role="tabpanel" data-panel="interface" hidden>
|
||||
<section class="set-section">
|
||||
|
||||
+46
-1
@@ -218,6 +218,9 @@ body.busy .progress::after {
|
||||
.menu small { font-size: 12px; color: var(--muted); }
|
||||
.menu-note { margin: 4px 0 0; padding: 8px 10px 4px; border-top: 1px solid var(--border); font-size: 11.5px; color: var(--muted); }
|
||||
|
||||
/* Docker logs */
|
||||
.src-ico { width: 1em; height: 1em; vertical-align: -.15em; margin-right: 4px; color: #2496ed; }
|
||||
|
||||
/* ---------- Histogram ---------- */
|
||||
.histo { padding: 0 20px 6px; }
|
||||
.histo .bars {
|
||||
@@ -411,6 +414,48 @@ select.field:focus { outline: 0; border-color: var(--accent); box-shadow: 0 0 0
|
||||
.purge-status { font-size: 12.5px; }
|
||||
.purge-status.bad { color: var(--sev-err); }
|
||||
.purge-status.good { color: var(--sev-info); }
|
||||
.docker-status { font-size: 13px; margin: 0 0 12px !important; }
|
||||
.docker-status.good::before, .docker-status.bad::before { content: "●"; margin-right: 6px; }
|
||||
.docker-status.good::before { color: var(--sev-info); }
|
||||
.docker-status.bad { color: var(--sev-err); }
|
||||
.switch-row { display: flex; align-items: center; gap: 10px; font-size: 13.5px; font-weight: 550; cursor: pointer; }
|
||||
.docker-actions { display: flex; gap: 8px; margin: 12px 0; }
|
||||
.docker-list { display: flex; flex-direction: column; gap: 14px; margin-bottom: 12px; }
|
||||
.dk-group { border: 1px solid var(--border); border-radius: 10px; overflow: hidden; }
|
||||
.dk-group-title {
|
||||
padding: 7px 12px; background: var(--panel-2);
|
||||
font-size: 12px; font-weight: 650; text-transform: uppercase; letter-spacing: .04em; color: var(--muted);
|
||||
}
|
||||
.dk-row {
|
||||
display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 12px;
|
||||
padding: 8px 12px; border-top: 1px solid var(--border); cursor: pointer;
|
||||
}
|
||||
.dk-row:hover { background: var(--row-hover); }
|
||||
.dk-row.locked { cursor: default; opacity: .65; }
|
||||
.dk-main { display: flex; flex-direction: column; min-width: 0; }
|
||||
.dk-main strong { font-size: 13.5px; font-weight: 600; }
|
||||
.dk-main small { font-size: 12px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.dk-state { font-size: 12px; white-space: nowrap; color: var(--muted); }
|
||||
.dk-state::before { content: "●"; margin-right: 5px; }
|
||||
.dk-state.following { color: var(--sev-info); }
|
||||
.dk-state.ignored::before { color: var(--sev-warning); }
|
||||
.dk-state.stopped::before, .dk-state.locked::before { opacity: .4; }
|
||||
|
||||
/* Toggle switch */
|
||||
input.switch {
|
||||
appearance: none; -webkit-appearance: none; flex: none;
|
||||
width: 34px; height: 20px; margin: 0; border-radius: 999px;
|
||||
background: var(--border); position: relative; cursor: pointer; transition: background .15s;
|
||||
}
|
||||
input.switch::after {
|
||||
content: ""; position: absolute; top: 2px; left: 2px; width: 16px; height: 16px; border-radius: 50%;
|
||||
background: #fff; box-shadow: 0 1px 2px rgba(0, 0, 0, .25); transition: transform .15s;
|
||||
}
|
||||
input.switch:checked { background: var(--accent); }
|
||||
input.switch:checked::after { transform: translateX(14px); }
|
||||
input.switch:disabled { cursor: not-allowed; }
|
||||
input.switch:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
|
||||
.set-section footer { display: flex; flex-wrap: wrap; gap: 8px; justify-content: space-between; margin-top: 14px; }
|
||||
|
||||
.seg { display: inline-flex; padding: 3px; gap: 3px; background: var(--panel-2); border: 1px solid var(--border); border-radius: 10px; }
|
||||
@@ -483,7 +528,7 @@ select.field:focus { outline: 0; border-color: var(--accent); box-shadow: 0 0 0
|
||||
dialog.settings { height: calc(100vh - 24px); width: calc(100vw - 16px); }
|
||||
.set-body { grid-template-columns: 1fr; grid-template-rows: auto minmax(0, 1fr); }
|
||||
.set-nav {
|
||||
display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); padding: 6px;
|
||||
display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); padding: 6px;
|
||||
border-right: 0; border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.set-nav button {
|
||||
|
||||
Reference in new issue
Block a user