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`, each log also carries `container`, `container_id`, `image`, `compose_project`,
`compose_service` and `stream` (stdout/stderr), visible in the row details. `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 - 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 - 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 (`"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. 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 - **Settings > Sources** shows one label per container (`project/service`) in a single field:
each one, "Enable all" / "Disable all", and whether new containers are followed followed containers in color, then not followed ones in grey; a click switches a label.
automatically (on by default). Choices are saved per compose service (or container name) 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. in `/data/docker.json`, so they survive re-creations.
- Logstream remembers the position read in each container (`/data/docker-state.json`): after a - 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 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).', dockerOff: 'The Docker source is disabled on this server. Set DOCKER_LOGS=on (see docker-compose.yml).',
dockerErr: 'Cannot reach Docker: ', dockerErr: 'Cannot reach Docker: ',
dockerOk: ({ h, n, f }) => `Connected to Docker on ${h}: ${n} containers, ${f} followed.`, dockerOk: ({ h, n, f }) => `Connected to Docker on ${h}: ${n} containers, ${f} followed.`,
dockerStandalone: 'Standalone containers', dockerStopped: (n) => `Show stopped containers (${n})`,
stFollowing: 'followed', stIgnored: 'running, ignored', stStopped: 'stopped', 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', 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.', 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', 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).', dockerOff: 'La source Docker est désactivée sur ce serveur. Réglez DOCKER_LOGS=on (voir docker-compose.yml).',
dockerErr: 'Docker injoignable : ', dockerErr: 'Docker injoignable : ',
dockerOk: ({ h, n, f }) => `Connecté à Docker sur ${h} : ${n} conteneurs, ${f} suivis.`, dockerOk: ({ h, n, f }) => `Connecté à Docker sur ${h} : ${n} conteneurs, ${f} suivis.`,
dockerStandalone: 'Conteneurs isolés', dockerStopped: (n) => `Afficher les conteneurs arrêtés (${n})`,
stFollowing: 'suivi', stIgnored: 'actif, ignoré', stStopped: 'arrêté', 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', 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.', 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', 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]')) 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-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-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)); 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" data-act="host" title="${esc(hostTitle(r) + ' · ' + t('clickHost'))}">${esc(r.host_name || r.host)}</span>`
: '<span class="host"></span>') : '<span class="host"></span>')
+ (r.app + (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>') : '<span class="app"></span>')
+ `<div class="msg">${highlight(r._msg)}</div>` + `<div class="msg">${highlight(r._msg)}</div>`
+ '</article>'; + '</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>'; 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. // 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 ================= */ /* ================= Docker source ================= */
let dockerState = null; 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() { async function loadDocker() {
try { dockerState = await api('/api/docker'); } catch (e) { dockerState = { enabled: true, connected: false, error: e.message }; } try { dockerState = await api('/api/docker'); } catch (e) { dockerState = { enabled: true, connected: false, error: e.message }; }
renderDocker(); 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() { function renderDocker() {
const status = $('#dockerStatus'); const status = $('#dockerStatus');
const body = $('#dockerBody'); const body = $('#dockerBody');
@@ -1110,33 +1152,20 @@ function renderDocker() {
} }
body.hidden = !cs.length && !d.connected; body.hidden = !cs.length && !d.connected;
$('#dockerDefault').checked = !!d.defaultEnabled; $('#dockerDefault').checked = !!d.defaultEnabled;
$('#dockerStopped').checked = dockerView.showStopped;
// Group by compose project; standalone containers last. const chips = dockerChips();
const groups = new Map(); $('#dockerStoppedLbl').textContent = t('dockerStopped', chips.filter((c) => !c.running && !c.locked).length);
for (const c of cs) { const q = dockerView.filter.toLowerCase();
const g = c.project || ''; const visible = chips.filter((c) => (dockerView.showStopped || c.running) && (!q || c.key.toLowerCase().includes(q) || c.image.toLowerCase().includes(q)));
if (!groups.has(g)) groups.set(g, []); const groups = [
groups.get(g).push(c); visible.filter((c) => c.enabled && !c.locked),
} visible.filter((c) => !c.enabled && !c.locked),
const names = [...groups.keys()].sort((a, b) => (a === '') - (b === '') || a.localeCompare(b)); visible.filter((c) => c.locked),
let html = ''; ].filter((g) => g.length);
for (const g of names) { $('#dockerList').innerHTML = groups.length
html += `<div class="dk-group"><div class="dk-group-title">${esc(g || t('dockerStandalone'))}</div>`; ? groups.map((g) => g.map(chipHTML).join('')).join('<span class="chip-sep" aria-hidden="true"></span>')
for (const c of groups.get(g)) { : `<span class="chip-empty">${esc(t('dockerNoMatch'))}</span>`;
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) { async function configureDocker(body) {
@@ -1144,18 +1173,36 @@ async function configureDocker(body) {
dockerState = await api('/api/docker', { method: 'PUT', body }); dockerState = await api('/api/docker', { method: 'PUT', body });
renderDocker(); renderDocker();
setTimeout(loadDocker, 1500); // followers start or stop in the background 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 })); $('#dockerDefault').addEventListener('change', (ev) => configureDocker({ defaultEnabled: ev.target.checked }));
$('#dockerList').addEventListener('change', (ev) => { $('#dockerStopped').addEventListener('change', (ev) => {
const key = ev.target.dataset.key; dockerView.showStopped = ev.target.checked;
if (key) configureDocker({ containers: { [key]: 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]]) { for (const [id, on] of [['#dockerAll', true], ['#dockerNone', false]]) {
$(id).addEventListener('click', () => { $(id).addEventListener('click', () => {
// Applies to the labels currently shown (filter and stopped option included).
const q = dockerView.filter.toLowerCase();
const containers = {}; 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 }); configureDocker({ containers });
}); });
} }
+16 -8
View File
@@ -182,15 +182,23 @@
<h3 data-i18n="dockerTitle">Docker containers</h3> <h3 data-i18n="dockerTitle">Docker containers</h3>
<p id="dockerStatus" class="docker-status"></p> <p id="dockerStatus" class="docker-status"></p>
<div id="dockerBody" hidden> <div id="dockerBody" hidden>
<label class="switch-row"> <div class="dk-opts">
<input id="dockerDefault" type="checkbox" class="switch"> <label class="switch-row">
<span data-i18n="dockerDefault">Follow new containers automatically</span> <input id="dockerDefault" type="checkbox" class="switch">
</label> <span data-i18n="dockerDefault">Follow new containers automatically</span>
<div class="docker-actions"> </label>
<button id="dockerAll" class="btn" type="button" data-i18n="dockerAll">Enable all</button> <label class="switch-row">
<button id="dockerNone" class="btn ghost" type="button" data-i18n="dockerNone">Disable all</button> <input id="dockerStopped" type="checkbox" class="switch">
<span id="dockerStoppedLbl">Show stopped containers</span>
</label>
</div> </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> </div>
<p class="muted small" data-i18n="dockerHelp"></p> <p class="muted small" data-i18n="dockerHelp"></p>
</section> </section>
+43 -20
View File
@@ -11,6 +11,7 @@
--row-hover: #f5f6fa; --row-hover: #f5f6fa;
--hit: rgba(99, 102, 241, .18); --hit: rgba(99, 102, 241, .18);
--shadow: 0 10px 30px rgba(15, 23, 42, .12); --shadow: 0 10px 30px rgba(15, 23, 42, .12);
--chip-l: 38%; /* lightness of project-colored text (labels, Docker apps) */
--sev-crit: #be123c; --sev-crit: #be123c;
--sev-err: #dc2626; --sev-err: #dc2626;
@@ -45,6 +46,7 @@
--row-hover: #1a1f26; --row-hover: #1a1f26;
--hit: rgba(129, 140, 248, .28); --hit: rgba(129, 140, 248, .28);
--shadow: 0 10px 30px rgba(0, 0, 0, .5); --shadow: 0 10px 30px rgba(0, 0, 0, .5);
--chip-l: 70%;
--sev-crit: #fb7185; --sev-crit: #fb7185;
--sev-err: #f87171; --sev-err: #f87171;
--sev-warning: #fbbf24; --sev-warning: #fbbf24;
@@ -67,6 +69,7 @@
--row-hover: #1a1f26; --row-hover: #1a1f26;
--hit: rgba(129, 140, 248, .28); --hit: rgba(129, 140, 248, .28);
--shadow: 0 10px 30px rgba(0, 0, 0, .5); --shadow: 0 10px 30px rgba(0, 0, 0, .5);
--chip-l: 70%;
--sev-crit: #fb7185; --sev-crit: #fb7185;
--sev-err: #f87171; --sev-err: #f87171;
--sev-warning: #fbbf24; --sev-warning: #fbbf24;
@@ -220,6 +223,9 @@ body.busy .progress::after {
/* Docker logs */ /* Docker logs */
.src-ico { width: 1em; height: 1em; vertical-align: -.15em; margin-right: 4px; color: #2496ed; } .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 ---------- */ /* ---------- Histogram ---------- */
.histo { padding: 0 20px 6px; } .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, .docker-status.bad::before { content: "●"; margin-right: 6px; }
.docker-status.good::before { color: var(--sev-info); } .docker-status.good::before { color: var(--sev-info); }
.docker-status.bad { color: var(--sev-err); } .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; } .switch-row { display: flex; align-items: center; gap: 10px; font-size: 13px; font-weight: 550; cursor: pointer; }
.docker-actions { display: flex; gap: 8px; margin: 12px 0; } .dk-opts { display: flex; flex-direction: column; gap: 8px; }
.docker-list { display: flex; flex-direction: column; gap: 14px; margin-bottom: 12px; } .dk-toolbar { display: flex; align-items: center; gap: 12px; margin: 14px 0 6px; }
.dk-group { border: 1px solid var(--border); border-radius: 10px; overflow: hidden; } .dk-filter {
.dk-group-title { flex: 1; min-width: 0; height: 28px; padding: 0 10px;
padding: 7px 12px; background: var(--panel-2); border: 1px solid var(--border); border-radius: 7px; background: var(--panel);
font-size: 12px; font-weight: 650; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); font: 12px var(--mono); outline: 0;
} }
.dk-row { .dk-filter:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 12px; .link-btn { border: 0; background: none; padding: 0; font-size: 12px; font-weight: 550; color: var(--accent); white-space: nowrap; }
padding: 8px 12px; border-top: 1px solid var(--border); cursor: pointer; .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); } .chip {
.dk-row.locked { cursor: default; opacity: .65; } display: inline-flex; align-items: center; gap: 4px;
.dk-main { display: flex; flex-direction: column; min-width: 0; } height: 22px; padding: 0 7px;
.dk-main strong { font-size: 13.5px; font-weight: 600; } border: 1px solid var(--border); border-radius: 5px; background: transparent;
.dk-main small { font-size: 12px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } color: var(--muted); font: 11px/1 var(--mono); white-space: nowrap; cursor: pointer;
.dk-state { font-size: 12px; white-space: nowrap; color: var(--muted); } }
.dk-state::before { content: "●"; margin-right: 5px; } .chip:hover { color: var(--text); border-color: var(--muted); }
.dk-state.following { color: var(--sev-info); } .chip.on {
.dk-state.ignored::before { color: var(--sev-warning); } color: hsl(var(--h) 55% var(--chip-l));
.dk-state.stopped::before, .dk-state.locked::before { opacity: .4; } 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 */ /* Toggle switch */
input.switch { input.switch {