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 += '