Sources: container labels in one field, colored by compose project

- One label per container (project/service) in a single field, like
  tag pickers: followed ones in the project color, a separator, then the
  others in grey; a click switches a label. Excluded ones last, locked.
- Stopped containers hidden by default, shown dashed and still editable
  with 'Show stopped containers'; filter box; enable/disable all apply to
  the labels shown.
- Docker app names in the log list use their compose project color.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
cedricandClaude Opus 5.5 committed 2026-09-28 17:18:27 +02:00
1 parent cd0307f6f6
commit d751fb54ae
4 files changed
+150 -68

No files matched your search

+8 -4
View File
@@ -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
+83 -36
View File
@@ -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) {
? `<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.container ? r.container + ' · ' : '') + r.app + ' · ' + t('clickApp'))}">${r.source_type === 'docker' ? DOCKER_ICON : ''}${esc(r.app)}</span>`
? `<span class="app${r.source_type === 'docker' ? ' proj' : ''}" data-act="app"${r.source_type === 'docker' ? ` style="--h:${hueOf(r.compose_project || r.container || r.app)}"` : ''} title="${esc((r.compose_project ? r.compose_project + '/' : '') + (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>';
}
// 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 = '<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.
@@ -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 = '<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="5" y="11" width="14" height="10" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/></svg>';
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 `<button type="button" class="${cls}" style="--h:${hueOf(c.project || c.name)}" data-key="${esc(c.key)}"`
+ ` aria-pressed="${c.enabled && !c.locked}"${c.locked ? ' disabled' : ''} title="${esc(title)}">`
+ `${c.locked ? LOCK_ICON : ''}${esc(c.key)}</button>`;
}
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 += `<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;
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('<span class="chip-sep" aria-hidden="true"></span>')
: `<span class="chip-empty">${esc(t('dockerNoMatch'))}</span>`;
}
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 });
});
}
+16 -8
View File
@@ -182,15 +182,23 @@
<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 class="dk-opts">
<label class="switch-row">
<input id="dockerDefault" type="checkbox" class="switch">
<span data-i18n="dockerDefault">Follow new containers automatically</span>
</label>
<label class="switch-row">
<input id="dockerStopped" type="checkbox" class="switch">
<span id="dockerStoppedLbl">Show stopped containers</span>
</label>
</div>
<div id="dockerList" class="docker-list"></div>
<div class="dk-toolbar">
<input id="dockerFilter" type="search" class="dk-filter" autocomplete="off" spellcheck="false" data-i18n-ph="dockerFilter" data-i18n-aria="dockerFilter">
<button id="dockerAll" class="link-btn" type="button" data-i18n="dockerAll">Enable all</button>
<button id="dockerNone" class="link-btn" type="button" data-i18n="dockerNone">Disable all</button>
</div>
<div id="dockerList" class="chip-field"></div>
<p class="dk-legend" data-i18n="dockerLegend"></p>
</div>
<p class="muted small" data-i18n="dockerHelp"></p>
</section>
+43 -20
View File
@@ -11,6 +11,7 @@
--row-hover: #f5f6fa;
--hit: rgba(99, 102, 241, .18);
--shadow: 0 10px 30px rgba(15, 23, 42, .12);
--chip-l: 38%; /* lightness of project-colored text (labels, Docker apps) */
--sev-crit: #be123c;
--sev-err: #dc2626;
@@ -45,6 +46,7 @@
--row-hover: #1a1f26;
--hit: rgba(129, 140, 248, .28);
--shadow: 0 10px 30px rgba(0, 0, 0, .5);
--chip-l: 70%;
--sev-crit: #fb7185;
--sev-err: #f87171;
--sev-warning: #fbbf24;
@@ -67,6 +69,7 @@
--row-hover: #1a1f26;
--hit: rgba(129, 140, 248, .28);
--shadow: 0 10px 30px rgba(0, 0, 0, .5);
--chip-l: 70%;
--sev-crit: #fb7185;
--sev-err: #f87171;
--sev-warning: #fbbf24;
@@ -220,6 +223,9 @@ body.busy .progress::after {
/* Docker logs */
.src-ico { width: 1em; height: 1em; vertical-align: -.15em; margin-right: 4px; color: #2496ed; }
/* Docker app names take the color of their compose project (see hueOf in app.js) */
.row .app.proj { color: hsl(var(--h) 55% var(--chip-l)); }
.row .app.proj .src-ico { color: inherit; }
/* ---------- Histogram ---------- */
.histo { padding: 0 20px 6px; }
@@ -418,28 +424,45 @@ select.field:focus { outline: 0; border-color: var(--accent); box-shadow: 0 0 0
.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);
.switch-row { display: flex; align-items: center; gap: 10px; font-size: 13px; font-weight: 550; cursor: pointer; }
.dk-opts { display: flex; flex-direction: column; gap: 8px; }
.dk-toolbar { display: flex; align-items: center; gap: 12px; margin: 14px 0 6px; }
.dk-filter {
flex: 1; min-width: 0; height: 28px; padding: 0 10px;
border: 1px solid var(--border); border-radius: 7px; background: var(--panel);
font: 12px var(--mono); outline: 0;
}
.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-filter:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
.link-btn { border: 0; background: none; padding: 0; font-size: 12px; font-weight: 550; color: var(--accent); white-space: nowrap; }
.link-btn:hover { text-decoration: underline; }
.dk-legend { font-size: 11.5px; color: var(--muted); margin: 8px 0 12px !important; }
/* Container labels (Settings > Sources) */
.chip-field {
display: flex; flex-wrap: wrap; align-items: center; gap: 5px;
padding: 8px; min-height: 40px;
border: 1px solid var(--border); border-radius: 10px; background: var(--panel);
}
.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; }
.chip {
display: inline-flex; align-items: center; gap: 4px;
height: 22px; padding: 0 7px;
border: 1px solid var(--border); border-radius: 5px; background: transparent;
color: var(--muted); font: 11px/1 var(--mono); white-space: nowrap; cursor: pointer;
}
.chip:hover { color: var(--text); border-color: var(--muted); }
.chip.on {
color: hsl(var(--h) 55% var(--chip-l));
border-color: hsl(var(--h) 50% var(--chip-l) / .7);
background: hsl(var(--h) 60% 50% / .13);
}
.chip.on:hover { background: hsl(var(--h) 60% 50% / .22); }
.chip.stopped { border-style: dashed; }
.chip.stopped:not(.on) { opacity: .7; }
.chip.locked { opacity: .45; cursor: not-allowed; }
.chip svg { width: 10px; height: 10px; }
.chip:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
.chip-sep { width: 1px; height: 18px; margin: 0 4px; background: var(--border); }
.chip-empty { font-size: 12px; color: var(--muted); padding: 0 4px; }
/* Toggle switch */
input.switch {