diff --git a/README.md b/README.md index 605acbb..98d7224 100644 --- a/README.md +++ b/README.md @@ -69,13 +69,17 @@ colored and exported like syslog messages: each log also carries `container`, `container_id`, `image`, `compose_project`, `compose_service` and `stream` (stdout/stderr), visible in the row details. - The **Source** filter shows only syslog or only Docker logs; Docker rows have a small cube - before the app name. + before the app name, colored like its compose project. - The severity comes from the line itself when the application writes it: JSON (`"level":"error"`), logfmt (`level=warn`), `[ERROR]`, or an upper-case level word at the start of the line (`ERROR`, `WARN`…). Otherwise it is `info`. Terminal color codes are removed. -- **Settings > Sources** lists the containers grouped by compose project, with a switch for - each one, "Enable all" / "Disable all", and whether new containers are followed - automatically (on by default). Choices are saved per compose service (or container name) +- **Settings > Sources** shows one label per container (`project/service`) in a single field: + followed containers in color, then not followed ones in grey; a click switches a label. + The color identifies the compose project, and Docker app names in the log list use the + same color. Stopped containers are hidden by default ("Show stopped containers" displays + them, dashed, and they stay editable). A filter box and "Enable all" / "Disable all" (applied + to the labels shown) help with many containers. New containers are followed automatically + unless that option is turned off. Choices are saved per compose service (or container name) in `/data/docker.json`, so they survive re-creations. - Logstream remembers the position read in each container (`/data/docker-state.json`): after a restart it resumes without losing or duplicating lines. A container seen for the first time diff --git a/web/app.js b/web/app.js index 828ea45..f26f36b 100644 --- a/web/app.js +++ b/web/app.js @@ -73,8 +73,11 @@ const I18N = { 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', + 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', @@ -174,8 +177,11 @@ const I18N = { 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é', + 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', @@ -370,6 +376,7 @@ function applyLang() { 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); 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)); } @@ -645,12 +652,20 @@ function rowHTML(r, isNew) { ? `${esc(r.host_name || r.host)}` : '') + (r.app - ? `${r.source_type === 'docker' ? DOCKER_ICON : ''}${esc(r.app)}` + ? `${r.source_type === 'docker' ? DOCKER_ICON : ''}${esc(r.app)}` : '') + `
${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. @@ -1083,12 +1098,39 @@ document.addEventListener('keydown', (ev) => { if (ev.key === 'Escape') setExpor /* ================= Docker source ================= */ let dockerState = null; +const dockerView = { showStopped: store.get('dockerShowStopped', '0') === '1', filter: '' }; +const LOCK_ICON = ''; async function loadDocker() { try { dockerState = await api('/api/docker'); } catch (e) { dockerState = { enabled: true, connected: false, error: e.message }; } renderDocker(); } +// One label per choice key ("project/service"): replicas of a service share it. +function dockerChips() { + const byKey = new Map(); + for (const c of (dockerState && dockerState.containers) || []) { + const k = byKey.get(c.key); + if (!k) { + byKey.set(c.key, { ...c, names: [c.name], running: c.state === 'running', following: c.following }); + } else { + k.names.push(c.name); + k.running = k.running || c.state === 'running'; + k.following = k.following || c.following; + } + } + return [...byKey.values()].sort((a, b) => a.key.localeCompare(b.key)); +} + +function chipHTML(c) { + const state = c.locked ? t('stLocked') : !c.running ? t('stStopped') : c.following ? t('stFollowing') : c.enabled ? t('stSelected') : t('stIgnored'); + const title = [c.key, c.names.join(', '), c.image, state + (c.locked ? ` (${t('stLockedTitle')})` : '')].join('\n'); + const cls = ['chip', c.enabled && !c.locked ? 'on' : '', c.running ? '' : 'stopped', c.locked ? 'locked' : ''].filter(Boolean).join(' '); + return ``; +} + function renderDocker() { const status = $('#dockerStatus'); const body = $('#dockerBody'); @@ -1110,33 +1152,20 @@ function renderDocker() { } body.hidden = !cs.length && !d.connected; $('#dockerDefault').checked = !!d.defaultEnabled; + $('#dockerStopped').checked = dockerView.showStopped; - // 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 += `
${esc(g || t('dockerStandalone'))}
`; - 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 += ``; - } - html += '
'; - } - $('#dockerList').innerHTML = html; + const chips = dockerChips(); + $('#dockerStoppedLbl').textContent = t('dockerStopped', chips.filter((c) => !c.running && !c.locked).length); + const q = dockerView.filter.toLowerCase(); + const visible = chips.filter((c) => (dockerView.showStopped || c.running) && (!q || c.key.toLowerCase().includes(q) || c.image.toLowerCase().includes(q))); + const groups = [ + visible.filter((c) => c.enabled && !c.locked), + visible.filter((c) => !c.enabled && !c.locked), + visible.filter((c) => c.locked), + ].filter((g) => g.length); + $('#dockerList').innerHTML = groups.length + ? groups.map((g) => g.map(chipHTML).join('')).join('') + : `${esc(t('dockerNoMatch'))}`; } async function configureDocker(body) { @@ -1144,18 +1173,36 @@ async function configureDocker(body) { dockerState = await api('/api/docker', { method: 'PUT', body }); renderDocker(); setTimeout(loadDocker, 1500); // followers start or stop in the background - } catch (e) { toast(e.message); } + } catch (e) { toast(e.message); loadDocker(); } } $('#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 } }); +$('#dockerStopped').addEventListener('change', (ev) => { + dockerView.showStopped = ev.target.checked; + store.set('dockerShowStopped', dockerView.showStopped ? '1' : '0'); + renderDocker(); +}); +$('#dockerFilter').addEventListener('input', (ev) => { dockerView.filter = ev.target.value.trim(); renderDocker(); }); +$('#dockerList').addEventListener('click', (ev) => { + const chip = ev.target.closest('.chip[data-key]'); + if (!chip || chip.disabled) return; + const key = chip.dataset.key; + const on = !chip.classList.contains('on'); + // Immediate feedback, confirmed by the server answer. + for (const c of (dockerState && dockerState.containers) || []) if (c.key === key) c.enabled = on; + renderDocker(); + configureDocker({ containers: { [key]: on } }); }); for (const [id, on] of [['#dockerAll', true], ['#dockerNone', false]]) { $(id).addEventListener('click', () => { + // Applies to the labels currently shown (filter and stopped option included). + const q = dockerView.filter.toLowerCase(); const containers = {}; - for (const c of (dockerState && dockerState.containers) || []) if (!c.locked) containers[c.key] = on; + for (const c of dockerChips()) { + if (c.locked || (!dockerView.showStopped && !c.running)) continue; + if (q && !c.key.toLowerCase().includes(q) && !c.image.toLowerCase().includes(q)) continue; + containers[c.key] = on; + } configureDocker({ containers }); }); } diff --git a/web/index.html b/web/index.html index f8ca1c2..1869812 100644 --- a/web/index.html +++ b/web/index.html @@ -182,15 +182,23 @@

Docker containers