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

+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 {