`,
- );
- it.onclick = () => {
- const i = state.spaths.indexOf(path);
- if (i >= 0) {
- state.spaths.splice(i, 1);
- it.classList.remove('on');
- } else {
- state.spaths.push(path);
- it.classList.add('on');
- }
- };
- list.appendChild(it);
- };
- if (direct.length) {
- if (computed.length && !q) {
- const s = document.createElement('div');
- s.className = 'bsep';
- s.textContent = t('app.values');
- list.appendChild(s);
- }
- direct.forEach(addItem);
- }
- if (computed.length) {
- if (!q) {
- const s = document.createElement('div');
- s.className = 'bsep';
- s.textContent = t('app.computedFromArray');
- list.appendChild(s);
- }
- computed.forEach(addItem);
- }
-}
diff --git a/public/js/admin-color-control.js b/public/js/admin-color-control.js
index 495e695..0e34e5e 100644
--- a/public/js/admin-color-control.js
+++ b/public/js/admin-color-control.js
@@ -4,6 +4,7 @@ import { html, raw, setHtml } from '/js/html.js?v=c71f8903';
import { qa, q } from '/js/utils.js?v=b18c93ed';
const CC_SWATCHES = ['#1c1c1e', '#8e8e93', '#f2f2f7', '#ff393c', '#ffcd00', '#35c759', '#0289ff', '#cb30df'];
+export const BADGE_DEFAULT = '#1e6ef4';
export const BADGE_SWATCHES = ['#1c1c1e', '#8e8e93', '#f2f2f7', '#ff393c', '#ffcd00', '#35c759', '#1e6ef4', '#cb30df'];
const _ccIco = {
hueLo:
@@ -158,6 +159,10 @@ export function renderColorControl(
container.appendChild(hidden);
let mode = isSem(value) ? value : 'color';
let showTune = false;
+ /* The value as it was stored. The sliders are integers, so reading a colour
+ back out of them shifts it, and an untouched control would save a colour
+ nobody picked. Cleared the moment the user changes anything. */
+ let pristine = mode === 'color' ? value : null;
const curHex = () => _hsvToHex(+hEl.value, +sEl.value, +vEl.value);
const _rgb = h => {
const x = _cssToHex(h);
@@ -195,9 +200,10 @@ export function renderColorControl(
codeRv.textContent = mode === 'color' ? hex : mode === 'dark' ? 'Dark' : 'Light';
codeRv.classList.remove('is-ph');
}
- hidden.value = mode === 'color' ? hex : mode;
+ hidden.value = mode === 'color' ? (pristine ?? hex) : mode;
}
const commit = () => {
+ pristine = null;
paint();
onChange?.(hidden.value);
};
diff --git a/public/js/admin-save-logic.js b/public/js/admin-save-logic.js
index 4d7a2be..961f171 100644
--- a/public/js/admin-save-logic.js
+++ b/public/js/admin-save-logic.js
@@ -1,6 +1,11 @@
// @ts-check
/* Keep this module free of the DOM. The DOM reads stay in doSave. */
+const BADGE_DEFAULT = '#1e6ef4';
+
+/* Keep in step with MAX_LABELS in badge-logic.js. */
+const MAX_LABELS = 5;
+
export function cleanId(label, fallback = 'item') {
return (
String(label || '')
@@ -91,6 +96,21 @@ export function claimFolderChildren(items, folderId, childIds) {
return list;
}
+/** One stored Live Activity label. Array order is priority order.
+
+ @param {string} path @param {any} [style]
+ @returns {{ path: string, name?: string, unit?: string, color?: string, min?: number }} */
+export function buildActivityLabel(path, style) {
+ const min = Math.floor(Number(style?.min));
+ return {
+ path,
+ name: style?.name?.trim() || undefined,
+ unit: style?.unit?.trim() || undefined,
+ color: style?.color && style.color !== BADGE_DEFAULT ? style.color : undefined,
+ min: Number.isFinite(min) && min > 1 ? min : undefined,
+ };
+}
+
/** @param {any} v @param {any} [orig] @param {Iterable} [takenIds] */
export function buildAppItem(v, orig, takenIds = []) {
if (!v.label) return { error: 'Name required' };
@@ -111,6 +131,15 @@ export function buildAppItem(v, orig, takenIds = []) {
? { enabled: true, label: v.staticLabel.slice(0, 10), color: v.staticColor || 'blue' }
: undefined;
const spaths = v.spaths || [];
+ const combine = spaths.length >= 2 && !!v.actCombine;
+ const labels = spaths.length
+ ? spaths.slice(0, MAX_LABELS).map(p => buildActivityLabel(p, v.slabels?.[p]))
+ : undefined;
+ const first = combine ? buildActivityLabel('', v.slabels?.[spaths[0]]) : null;
+ const combinedCustom =
+ first && (first.color || first.unit || first.min)
+ ? { color: first.color, unit: first.unit, min: first.min }
+ : undefined;
return {
item: {
id: orig?.id || newItemId(v.label, 'app', takenIds),
@@ -129,8 +158,10 @@ export function buildAppItem(v, orig, takenIds = []) {
params: v.actParams?.length ? v.actParams : undefined,
headers: v.actHeaders?.length ? v.actHeaders : undefined,
extract: spaths.length === 1 ? spaths[0] : spaths.length > 1 ? spaths.map(p => ({ path: p })) : undefined,
+ labels: combine ? undefined : labels,
+ combine: combine || undefined,
interval: Math.max(10, v.actInt),
- custom: customObj,
+ custom: combinedCustom || customObj,
},
staticBadge: staticBadgeObj,
},
diff --git a/public/js/admin-state.js b/public/js/admin-state.js
index cc5e884..f94f85e 100644
--- a/public/js/admin-state.js
+++ b/public/js/admin-state.js
@@ -14,6 +14,9 @@ export const state = {
siurl: '',
scol: 'dark',
spaths: [],
+ /* Live Activity label styling, keyed by value path. */
+ slabels: Object.create(null),
+ slegacySum: false,
fnums: [],
_evItem: null,
diff --git a/public/js/admin-widget-form.js b/public/js/admin-widget-form.js
index 505734b..bb3cbe2 100644
--- a/public/js/admin-widget-form.js
+++ b/public/js/admin-widget-form.js
@@ -1,6 +1,6 @@
-import { state } from '/js/admin-state.js?v=b7731aa4';
+import { state } from '/js/admin-state.js?v=c23e6346';
import { PE_SVG, CHEV_SVG, initInlineEdit } from '/js/admin-shared.js?v=8f69dad6';
-import { renderWidgetConfigForm } from '/js/widget-config-form.js?v=45bbb2b7';
+import { renderWidgetConfigForm } from '/js/widget-config-form.js?v=63dccc19';
import { html, raw, setHtml } from '/js/html.js?v=c71f8903';
import {
sizesForView,
diff --git a/public/js/admin.js b/public/js/admin.js
index aadefa1..2db8baf 100644
--- a/public/js/admin.js
+++ b/public/js/admin.js
@@ -1,4 +1,4 @@
-import { buildAppForm, buildFolderForm, serializeKvRows } from '/js/admin-app-form.js?v=f236bd68';
+import { buildAppForm, buildFolderForm, captureActLabels, serializeKvRows } from '/js/admin-app-form.js?v=d54926e8';
import { checkAuth, requireLogin, wirePasswordStrength } from '/js/admin-auth.js?v=5e6450f0';
import { applyDrop, canJoinFolder, folderRowZone } from '/js/admin-drag-logic.js?v=ebe3e806';
import { reorderItems, resolveAdminSection } from '/js/admin-logic.js?v=ddfc6f80';
@@ -9,11 +9,11 @@ import {
saveWithRevert,
snapshotItems,
upsertItem,
-} from '/js/admin-save-logic.js?v=52a970d3';
+} from '/js/admin-save-logic.js?v=48a9e055';
import { loadSettings, showBgFields, showBgFit, showWallpaperFile } from '/js/admin-settings.js?v=f7078bc0';
import { ag, ap, initInlineEdit, setReauthHandler, toast } from '/js/admin-shared.js?v=8f69dad6';
-import { state } from '/js/admin-state.js?v=b7731aa4';
-import { buildWidgetForm } from '/js/admin-widget-form.js?v=15aad905';
+import { state } from '/js/admin-state.js?v=c23e6346';
+import { buildWidgetForm } from '/js/admin-widget-form.js?v=c4b1a7b4';
import { html, raw, setHtml } from '/js/html.js?v=c71f8903';
import { initI18n, LANGUAGES, t } from '/js/i18n.js?v=d056c9c5';
import { iconChain, loadLocalIcons, resolveIcon } from '/js/icons.js?v=69c2b9bd';
@@ -25,12 +25,12 @@ import {
NOTE,
parseErrorsAsSkipped,
SKIP,
-} from '/js/import-foreign.js?v=4161a917';
+} from '/js/import-foreign.js?v=ef4f3d44';
import { isMobileLayout, onLayoutChange } from '/js/layout.js?v=28416a75';
import { confirmModal, openModal as openDialog, promptModal } from '/js/modal.js?v=ff76dc56';
import { readMode, watchSystemTheme, writeMode } from '/js/theme.js?v=fbd2d2ef';
import { el, inp, q, qa, clr as rc, sanitizeCssUrl, setUserText, tgt } from '/js/utils.js?v=b18c93ed';
-import { normalizeColorInput } from '/js/admin-color-control.js?v=984a0b69';
+import { normalizeColorInput } from '/js/admin-color-control.js?v=9fded679';
import { parseYamlTolerant, YamlLiteError } from '/js/yaml-lite.js?v=cceca788';
/* A class rather than a bare media query. Some phones report a wider CSS
@@ -680,14 +680,31 @@ function openModal(idx) {
state._iframeOpts = item?.iframe ? { ...item.iframe } : {};
state.fnums = [];
state.spaths = [];
- if (item?.monitoring?.activity?.extract) {
+ state.slabels = Object.create(null);
+ const actLabels = item?.monitoring?.activity?.labels;
+ /* An older config summed its values. Saving must not turn that into a list. */
+ state.slegacySum = false;
+ if (Array.isArray(actLabels) && actLabels.length) {
+ for (const l of actLabels) {
+ if (!l || typeof l.path !== 'string' || !l.path) continue;
+ state.spaths.push(l.path);
+ state.slabels[l.path] = {
+ name: l.name || '',
+ unit: l.unit || '',
+ color: l.color || '#1e6ef4',
+ min: l.min == null ? '' : String(l.min),
+ };
+ }
+ } else if (item?.monitoring?.activity?.extract) {
const ex = Array.isArray(item.monitoring.activity.extract)
? item.monitoring.activity.extract
: [item.monitoring.activity.extract];
state.spaths = ex.map(e => (typeof e === 'string' ? e : e.path)).filter(Boolean);
+ state.slegacySum = state.spaths.length >= 2;
} else if (item?.badge?.extract) {
const ex = Array.isArray(item.badge.extract) ? item.badge.extract : [item.badge.extract];
state.spaths = ex.map(e => (typeof e === 'string' ? e : e.path)).filter(Boolean);
+ state.slegacySum = state.spaths.length >= 2;
}
const isEdit = idx != null;
@@ -960,6 +977,7 @@ async function doSave(orig) {
};
} else {
const isPing = inp('hc-type-ping')?.checked;
+ captureActLabels();
const v = {
label: inp('f-lbl')?.value?.trim(),
href: inp('f-href')?.value?.trim(),
@@ -982,6 +1000,8 @@ async function doSave(orig) {
iconUrl: state.siurl,
scol: state.scol,
spaths: state.spaths,
+ actCombine: inp('act-combine')?.checked || false,
+ slabels: state.slabels,
};
const res = buildAppItem(
v,
diff --git a/public/js/badge-logic.js b/public/js/badge-logic.js
index 0be0e4a..9c7ce1b 100644
--- a/public/js/badge-logic.js
+++ b/public/js/badge-logic.js
@@ -6,6 +6,8 @@ const EN = {
'status.healthy': 'Status: healthy',
'status.pending': '{count} pending',
'status.stale': '(may be out of date)',
+ 'status.moreValues': '{n} more badge',
+ 'status.moreValuesPlural': '{n} more badges',
'status.containerNotFound': 'Container not found',
'status.containerState': 'Container {state}',
'status.pingFailed': 'Ping failed: {error}',
@@ -18,9 +20,18 @@ function _fallback(key, vars) {
return vars ? s.replace(/\{(\w+)\}/g, (m, k) => (k in vars ? String(vars[k]) : m)) : s;
}
+/** @typedef {{ name: string, value: number|string, unit: string, color: string }} BadgeRow */
+
export const NAMED = { blue: '#1e6ef4', green: '#008932', yellow: '#ffcc00', red: '#e9152d', gray: '#636366' };
-/* WCAG contrast: dark text only where it beats white. */
+export const LABEL_DEFAULT_COLOR = '#1e6ef4';
+
+/* A badge list longer than this covers the tile it belongs to. */
+export const MAX_LABELS = 5;
+
+const AA_TEXT = 4.5;
+
+/* White unless white fails AA on this fill, even where dark scores higher. */
export function needsDark(hex) {
try {
const h = hex.replace(/^#/, '');
@@ -30,7 +41,7 @@ export function needsDark(hex) {
return v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
});
const L = 0.2126 * r + 0.7152 * g + 0.0722 * b;
- return (L + 0.05) / 0.0617 > 1.05 / (L + 0.05);
+ return 1.05 / (L + 0.05) < AA_TEXT;
} catch {
return false;
}
@@ -40,6 +51,17 @@ export function resolveColor(c) {
return c ? NAMED[c] || c : '';
}
+/** A colour safe to hand to CSS, or ''. The fill lands in a custom property,
+ which stores any string: `url(...)` there becomes a real request.
+ @param {unknown} c @returns {string} */
+export function safeColor(c) {
+ const v = typeof c === 'string' ? resolveColor(c) : '';
+ if (!v) return '';
+ const supports = globalThis.CSS?.supports;
+ if (typeof supports === 'function') return globalThis.CSS.supports('color', v) ? v : '';
+ return /^#(?:[0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(v) ? v : '';
+}
+
/* Why a tile is red, as one short line of hover text. */
const REASON_MAX = 90;
@@ -91,10 +113,35 @@ export function badgeMinimum(custom) {
return Number.isFinite(n) && n > 1 ? n : 1;
}
+/** The labels reaching their own threshold, in priority order. `values` is
+ positional: index n is the number for `labels[n]`.
+
+ @param {any[]} [labels] @param {number[]} [values]
+ @returns {Array<{ index: number, name: string, value: number, unit: string, color: string }>} */
+export function firingLabels(labels, values) {
+ if (!Array.isArray(labels) || !Array.isArray(values)) return [];
+ const out = [];
+ for (let i = 0; i < labels.length && out.length < MAX_LABELS; i++) {
+ const l = labels[i];
+ if (!l || typeof l.path !== 'string' || !l.path) continue;
+ const v = Number(values[i]);
+ if (!Number.isFinite(v) || v < badgeMinimum(l)) continue;
+ out.push({
+ index: i,
+ name: String(l.name || l.unit || l.path),
+ value: v,
+ unit: String(l.unit || ''),
+ color: safeColor(l.color) || LABEL_DEFAULT_COLOR,
+ });
+ }
+ return out;
+}
+
/** The badge an item should show, as class, text and background colour.
@param {{
health?: boolean, activity?: number,
custom?: { unit?: string, color?: string, min?: number },
+ labels?: any[], values?: number[],
staticBdg?: { enabled?: boolean, label?: string, color?: string },
hasHC?: boolean, hideHealthy?: boolean,
badgesStale?: boolean, healthStale?: boolean, activityStale?: boolean,
@@ -105,6 +152,8 @@ export function computeBadgeVisual({
health,
activity,
custom = {},
+ labels,
+ values,
staticBdg = {},
hasHC,
hideHealthy,
@@ -118,39 +167,51 @@ export function computeBadgeVisual({
/* Below this the item is treated as having no activity at all. One keeps the
original behaviour of badging any count above zero. */
const min = badgeMinimum(custom);
- const active = activity >= min;
+ const fired = firingLabels(labels, values);
+ const top = fired[0];
+ const rows = [];
+ const active = top ? true : activity >= min;
+ const fixed = !!(staticBdg.enabled && staticBdg.label);
let cls,
- txt,
+ num = '',
+ unit = '',
bg = '';
if (health) {
cls = 'badge on red';
- txt = '!';
+ num = '!';
+ } else if (top) {
+ cls = 'badge on blue';
+ num = top.value > 99 ? '99+' : String(top.value);
+ unit = top.unit ? top.unit.slice(0, 8) : '';
+ bg = top.color;
} else if (active) {
cls = 'badge on blue';
- txt = activity > 99 ? '99+' : String(activity);
- if (custom.unit) txt += ' ' + custom.unit.slice(0, 8);
- bg = resolveColor(custom.color);
- } else if (staticBdg.enabled && staticBdg.label) {
+ num = activity > 99 ? '99+' : String(activity);
+ unit = custom.unit ? custom.unit.slice(0, 8) : '';
+ bg = safeColor(custom.color);
+ } else if (fixed) {
cls = 'badge on blue';
- txt = staticBdg.label.slice(0, 10);
- bg = resolveColor(staticBdg.color);
+ num = staticBdg.label.slice(0, 10);
+ bg = safeColor(staticBdg.color);
} else if (!hideHealthy && hasHC) {
cls = 'badge on green';
- txt = '';
} else {
cls = 'badge';
- txt = '';
}
+ /* What the badge says, as one string. `num` and `unit` are the same text in
+ the two elements the pill draws, so a phone can drop the unit. */
+ const txt = num + (unit ? ' ' + unit : '');
/* Status text, so meaning is not carried by colour alone. */
let aria = '';
if (health) aria = tr('status.needsAttention');
+ else if (top) aria = top.name ? `${top.name}: ${top.value}` : tr('status.pending', { count: String(top.value) });
else if (active)
aria = tr('status.pending', {
count: (activity > 99 ? '99+' : String(activity)) + (custom.unit ? ' ' + custom.unit : ''),
});
- else if (staticBdg.enabled && staticBdg.label) aria = staticBdg.label;
+ else if (fixed) aria = staticBdg.label;
else if (cls.includes('green')) aria = tr('status.healthy');
if (
@@ -162,7 +223,7 @@ export function computeBadgeVisual({
}
/* Appended to the label too, so it is not sight-only. */
- const reason = health ? healthReason(healthDetail) : '';
+ const reason = health ? healthReason(healthDetail, translate) : '';
if (reason) aria = aria + ': ' + reason;
/* Only a user's own colour is inked here, and then always. A named badge is
@@ -170,14 +231,37 @@ export function computeBadgeVisual({
place. NAMED does not hold those token values and never decided this. */
const color = bg ? (needsDark(bg) ? '#1c1c1e' : '#ffffff') : '';
- return { cls, txt, bg, aria, color, title: reason };
+ if (health) rows.push({ name: reason || tr('status.needsAttention'), value: '!', unit: '', color: NAMED.red });
+ for (const f of fired) rows.push(f);
+ if (fixed) rows.push({ name: staticBdg.label, value: '', unit: '', color: safeColor(staticBdg.color) });
+
+ const more = Math.max(0, rows.length - 1);
+ if (more) {
+ cls += ' has-more';
+ aria = aria + '. ' + tr(more === 1 ? 'status.moreValues' : 'status.moreValuesPlural', { n: more });
+ }
+ const nextColor = more ? rows[1].color : '';
+
+ return { cls, txt, num, unit, bg, aria, color, title: reason, more, nextColor, rows: more ? rows : [] };
}
/** Identity of a rendered badge, for skipping DOM writes that change nothing.
Must cover every field computeBadgeVisual returns that reaches the element.
- @param {{ cls?: string, txt?: string, bg?: string, aria?: string, color?: string, title?: string }} visual */
-export function badgeSignature({ cls = '', txt = '', bg = '', aria = '', color = '', title = '' } = {}) {
+ @param {{ cls?: string, txt?: string, unit?: string, bg?: string, aria?: string,
+ color?: string, title?: string, nextColor?: string, rows?: BadgeRow[] }} visual */
+export function badgeSignature({
+ cls = '',
+ txt = '',
+ unit = '',
+ bg = '',
+ aria = '',
+ color = '',
+ title = '',
+ nextColor = '',
+ rows = [],
+} = {}) {
/* NUL separator. A plain space lets a class list and a label pack into one
string two ways and compare equal. */
- return [cls, txt, bg, aria, color, title].join('\u0000');
+ const digest = rows.map(r => `${r.name}\u0001${r.value}\u0001${r.unit}\u0001${r.color}`).join('\u0002');
+ return [cls, txt, unit, bg, aria, color, title, nextColor, digest].join('\u0000');
}
diff --git a/public/js/badge-popover.js b/public/js/badge-popover.js
new file mode 100644
index 0000000..cf7a806
--- /dev/null
+++ b/public/js/badge-popover.js
@@ -0,0 +1,162 @@
+// @ts-check
+/* One element for the whole dashboard. A per-tile popover outlives the tiles
+ the grid rebuilds on every resize. */
+
+const HOVER_IN_MS = 320;
+const COMPAT_CLICK_MS = 500;
+
+/** @type {HTMLElement|null} */
+let pop = null;
+/** @type {HTMLElement|null} */
+let openFor = null;
+/** @type {HTMLElement|null} */
+let describedEl = null;
+let openTimer = 0;
+let openedAt = 0;
+
+const ROWS = new WeakMap();
+
+function ensurePop() {
+ if (pop) return pop;
+ pop = document.createElement('div');
+ pop.className = 'badge-pop';
+ pop.id = 'badge-pop';
+ pop.setAttribute('role', 'tooltip');
+ pop.hidden = true;
+ document.body.appendChild(pop);
+ return pop;
+}
+
+/** The focusable tile a badge belongs to. Never make the badge focusable: a
+ control nested in a link is unreachable.
+ @param {HTMLElement} badge @returns {HTMLElement|null} */
+function tileOf(badge) {
+ return /** @type {HTMLElement|null} */ (badge.closest('a, [role="button"]'));
+}
+
+/** @param {HTMLElement} badge */
+function place(badge) {
+ const p = ensurePop();
+ const r = badge.getBoundingClientRect();
+ const pr = p.getBoundingClientRect();
+ const gap = 8;
+ let top = r.bottom + gap;
+ if (top + pr.height > window.innerHeight - 8) top = Math.max(8, r.top - gap - pr.height);
+ let left = r.right - pr.width;
+ left = Math.min(Math.max(8, left), window.innerWidth - pr.width - 8);
+ p.style.top = Math.round(top) + 'px';
+ p.style.left = Math.round(left) + 'px';
+}
+
+/** @param {HTMLElement} badge */
+function open(badge) {
+ const rows = ROWS.get(badge);
+ if (!rows || !rows.length) return;
+ const p = ensurePop();
+ p.textContent = '';
+ for (const row of rows) {
+ const line = document.createElement('div');
+ line.className = 'badge-pop-row';
+ const dot = document.createElement('span');
+ dot.className = 'badge-pop-dot';
+ if (row.color) dot.style.setProperty('background-color', row.color);
+ const name = document.createElement('span');
+ name.className = 'badge-pop-name';
+ /* User text. Assigned, never interpolated. */
+ name.textContent = row.name;
+ const val = document.createElement('span');
+ val.className = 'badge-pop-val';
+ val.textContent = row.unit ? `${row.value} ${row.unit}` : String(row.value);
+ line.append(dot, name, val);
+ p.appendChild(line);
+ }
+ p.hidden = false;
+ openFor = badge;
+ openedAt = Date.now();
+ place(badge);
+ const tile = tileOf(badge);
+ if (tile) {
+ tile.setAttribute('aria-describedby', 'badge-pop');
+ describedEl = tile;
+ }
+}
+
+export function closeBadgePopover() {
+ clearTimeout(openTimer);
+ openTimer = 0;
+ if (pop) pop.hidden = true;
+ openFor = null;
+ if (describedEl) {
+ describedEl.removeAttribute('aria-describedby');
+ describedEl = null;
+ }
+}
+
+/** @param {HTMLElement} badge */
+function schedule(badge, delay) {
+ clearTimeout(openTimer);
+ openTimer = window.setTimeout(() => open(badge), delay);
+}
+
+let globalsWired = false;
+function wireGlobals() {
+ if (globalsWired) return;
+ globalsWired = true;
+ document.addEventListener('keydown', e => {
+ if (e.key === 'Escape' && openFor) closeBadgePopover();
+ });
+ window.addEventListener('scroll', () => closeBadgePopover(), true);
+ window.addEventListener('resize', () => closeBadgePopover());
+ document.addEventListener(
+ 'pointerdown',
+ e => {
+ const target = /** @type {Node} */ (e.target);
+ if (openFor && pop && !pop.contains(target) && !openFor.contains(target)) closeBadgePopover();
+ },
+ true,
+ );
+}
+
+/** Attach or remove the popover on one badge. Null rows remove it.
+ @param {HTMLElement} badge
+ @param {Array<{ name: string, value: number|string, unit: string, color: string }>|null} rows */
+export function wireBadgePopover(badge, rows) {
+ if (!rows || !rows.length) {
+ ROWS.delete(badge);
+ if (openFor === badge) closeBadgePopover();
+ return;
+ }
+ wireGlobals();
+ ROWS.set(badge, rows);
+ if (openFor === badge) open(badge);
+ if (badge.dataset.popWired === '1') return;
+ badge.dataset.popWired = '1';
+
+ badge.addEventListener('pointerenter', e => {
+ if (/** @type {PointerEvent} */ (e).pointerType === 'touch') return;
+ schedule(badge, HOVER_IN_MS);
+ });
+ badge.addEventListener('pointerleave', e => {
+ if (/** @type {PointerEvent} */ (e).pointerType === 'touch') return;
+ closeBadgePopover();
+ });
+
+ /* Keep the tap. iOS answers a long press on a link with its own callout and
+ cancels the touch, leaving no other way in on Safari. */
+ badge.addEventListener('click', e => {
+ if (!ROWS.get(badge)) return;
+ e.preventDefault();
+ e.stopPropagation();
+ /* One tap can arrive twice: the touch click, then the compatibility mouse
+ click. Toggling on both opens and shuts it again. */
+ if (Date.now() - openedAt < COMPAT_CLICK_MS) return;
+ if (openFor === badge) closeBadgePopover();
+ else open(badge);
+ });
+
+ const tile = tileOf(badge);
+ if (tile) {
+ tile.addEventListener('focus', () => open(badge));
+ tile.addEventListener('blur', () => closeBadgePopover());
+ }
+}
diff --git a/public/js/dashboard.js b/public/js/dashboard.js
index ab5bb34..d32d8bc 100644
--- a/public/js/dashboard.js
+++ b/public/js/dashboard.js
@@ -27,7 +27,8 @@ import { initI18n, t, currentLang } from '/js/i18n.js?v=d056c9c5';
import { pwStrength, passwordMismatch } from '/js/password-strength.js?v=42f45ac7';
import { sanitizeItemLinks } from '/js/link-url.js?v=54adb40f';
import { initUI, mkFolder, openFolderDesktop, openFolderMobile, buildMobile } from '/js/ui.js?v=14202a72';
-import { badgeMinimum, badgeSignature, computeBadgeVisual, readBadgeUpdate } from '/js/badge-logic.js?v=771f3230';
+import { badgeMinimum, badgeSignature, computeBadgeVisual, readBadgeUpdate } from '/js/badge-logic.js?v=be6330d6';
+import { closeBadgePopover, wireBadgePopover } from '/js/badge-popover.js?v=d00c0629';
import {
configChanged,
landingAfterSetup,
@@ -161,10 +162,14 @@ function bupd(id) {
const staticBdg = item?.monitoring?.staticBadge || {};
const hasHC = !!(item?.monitoring?.healthcheck?.enabled || item?.container || item?.ping);
- const { cls, txt, bg, aria, color, title } = computeBadgeVisual({
+ const act = item?.monitoring?.activity || {};
+ const isFolder = item?.type === 'folder';
+ const { cls, txt, num, unit, bg, aria, color, title, more, nextColor, rows } = computeBadgeVisual({
health: s.health,
activity: s.activity,
activityStale: !!s.activityStale,
+ labels: isFolder ? s.labels : act.combine ? undefined : act.labels,
+ values: s.values,
custom,
staticBdg,
hasHC,
@@ -175,13 +180,23 @@ function bupd(id) {
translate: t,
});
- const sig = badgeSignature({ cls, txt, bg, aria, color, title });
+ const sig = badgeSignature({ cls, txt, unit, bg, aria, color, title, nextColor, rows });
els.forEach(el => {
if (BSIG.get(el) === sig) return;
BSIG.set(el, sig);
el.className = cls;
- el.textContent = txt;
+ let txtEl = el.firstElementChild;
+ let unitEl = txtEl?.nextElementSibling;
+ if (!txtEl) {
+ txtEl = document.createElement('span');
+ txtEl.className = 'badge-txt';
+ unitEl = document.createElement('span');
+ unitEl.className = 'badge-unit';
+ el.append(txtEl, unitEl);
+ }
+ txtEl.textContent = num;
+ unitEl.textContent = unit ? ' ' + unit : '';
if (aria) {
el.setAttribute('role', 'status');
el.setAttribute('aria-label', aria);
@@ -189,8 +204,14 @@ function bupd(id) {
el.removeAttribute('role');
el.removeAttribute('aria-label');
}
- el.style.background = bg;
+ /* Never the `background` shorthand. It resets background-clip, and the
+ pill behind is painted from this same value. */
+ if (bg) el.style.setProperty('--badge-bg', bg);
+ else el.style.removeProperty('--badge-bg');
el.style.color = color;
+ if (nextColor) el.style.setProperty('--badge-next', nextColor);
+ else el.style.removeProperty('--badge-next');
+ wireBadgePopover(el, more ? rows : null);
/* Assigned, never interpolated. An upstream error string must not become
markup. */
if (title) el.title = title;
@@ -204,18 +225,32 @@ function bset(id, type, val) {
bupd(id);
items.filter(i => i.type === 'folder' && (i.children || []).includes(id)).forEach(f => bupd(f.id));
}
+/** A folder's badge, from the badges of the apps inside it. A labelled child
+ contributes its labels; the rest add into a total. */
function folderBadge(folder) {
const children = (folder.children || []).map(id => items.find(i => i.id === id)).filter(Boolean);
let actSum = 0,
hasHealth = false;
+ const labels = [],
+ values = [];
for (const c of children) {
const s = badgeState[c.id] || {};
if (s.health) hasHealth = true;
+ const ca = c.monitoring?.activity;
+ const own = !ca?.combine && Array.isArray(ca?.labels) && ca.labels.length ? ca.labels : null;
+ if (own && Array.isArray(s.values)) {
+ own.forEach((l, n) => {
+ if (!l || typeof l.path !== 'string' || !l.path) return;
+ labels.push({ ...l, name: `${c.label || c.id} · ${l.name || l.unit || l.path}` });
+ values.push(s.values[n]);
+ });
+ continue;
+ }
/* A child whose own count is below its minimum shows no badge, so it must
not raise the folder's either. */
- if (s.activity >= badgeMinimum(c.monitoring?.activity?.custom)) actSum += s.activity;
+ if (s.activity >= badgeMinimum(ca?.custom)) actSum += s.activity;
}
- return { health: hasHealth, activity: actSum };
+ return { health: hasHealth, activity: actSum, labels, values };
}
const mkWrap = (item, sz, r, isz, cls) => _mkWrap(item, sz, r, isz, cls, breg);
@@ -333,6 +368,7 @@ function buildDesktop() {
/* Removing the DOM does not stop the observers and timers the previous
widgets started. */
teardownWidgets();
+ closeBadgePopover();
BEL.clear();
usedWidgetTitles = new Set();
/* Before paginate() and before any tile is built: both size against it. */
@@ -566,7 +602,10 @@ async function pollBadges() {
/* A failed item keeps its last value, marked stale. Overwriting it with
zero reads as "nothing pending", which is not what happened. */
bset(id, 'activityStale', failed);
- if (!failed) bset(id, 'activity', value);
+ if (!failed) {
+ bset(id, 'values', Array.isArray(v?.values) ? v.values.map(Number) : null);
+ bset(id, 'activity', value);
+ }
}
_badgeFails = 0;
if (badgesStale) {
diff --git a/public/js/import-foreign.js b/public/js/import-foreign.js
index 917cac9..799377e 100644
--- a/public/js/import-foreign.js
+++ b/public/js/import-foreign.js
@@ -3,7 +3,7 @@
only: a widget in the source becomes a plain app tile, never a Stackyard
widget. Keep this module free of the DOM and of the network. */
-import { buildAppItem, newItemId } from '/js/admin-save-logic.js?v=52a970d3';
+import { buildAppItem, newItemId } from '/js/admin-save-logic.js?v=48a9e055';
import { isSafeLinkUrl } from '/js/link-url.js?v=54adb40f';
export const SKIP = Object.freeze({
diff --git a/public/js/widget-config-form.js b/public/js/widget-config-form.js
index 3fe95e8..d5fe9b5 100644
--- a/public/js/widget-config-form.js
+++ b/public/js/widget-config-form.js
@@ -5,7 +5,7 @@
import { t } from '/js/i18n.js?v=d056c9c5';
import { html, raw, setHtml } from '/js/html.js?v=c71f8903';
import { wireChecklist } from '/js/admin-shared.js?v=8f69dad6';
-import { renderColorControl } from '/js/admin-color-control.js?v=984a0b69';
+import { renderColorControl } from '/js/admin-color-control.js?v=9fded679';
import {
seedCarried,
applyOptionSet,
diff --git a/src/content/docs/docs/badges.md b/src/content/docs/docs/badges.md
index 55d9a6c..340678a 100644
--- a/src/content/docs/docs/badges.md
+++ b/src/content/docs/docs/badges.md
@@ -56,27 +56,70 @@ Text you type once, capped at 10 characters. Pick a colour from the swatches or
## Live activity
-A number read from a service's own API.
+Numbers read from a service's own API.
-Enter the API URL and press Fetch. Stackyard reads the response and lists every number it found, including array lengths and counts of matching items, so you pick one. No custom widget, no code.
+Enter the API URL and press Fetch. Stackyard reads the response and lists every number it found, including array lengths and counts of matching items, so you pick from a menu rather than writing a path. No custom widget, no code.
| Field | What it does | Example |
| --- | --- | --- |
| API URL | The endpoint to poll. | `https://requests.example.com/api/v1/request/count` |
-| Value | Which number from the response to show. | `pending` |
| Authentication | A header or parameter to send. Tick Secret to keep the value out of the browser. | Header `X-Api-Key`, Secret ticked |
-| Unit | A short suffix after the number. | `requests` |
-| Show From | The count below which the badge stays hidden. | `1` |
| Poll | How often to re-read the endpoint. | `300` seconds |
-| Color | The badge fill. | `#ffcc00` |
-That example badges a request manager with its pending count. Pointing the same fields at Sonarr's queue instead badges episodes still downloading.
+### Labels
-Show From sets a floor, so a queue that is never quite empty stays quiet until it matters. Leave it blank to badge any count above zero. A count above 99 shows as `99+`.
+One poll can feed several labels. Each names one number from the response and carries its own text, colour, unit and threshold.
+
+
+
+ Press Add Label for another. Drag a card by its handle to reorder it, or use the arrows.
+
+
+
+
+| Field | What it does | Example |
+| --- | --- | --- |
+| Value | Which number from the response this label reads. | `pending` |
+| Label Text | The name shown in the list. Optional. | `pending` |
+| Color | The badge fill. Optional. | `#ffcc00` |
+| Unit | A short suffix after the number. Optional. | `pending` |
+| Show From | The count below which this label stays quiet. Optional. | `5` |
+
+
+
+A label is quiet until its number reaches Show From. Leave Show From blank to report any count above zero. A count above 99 shows as `99+`, with the full number in the list.
+
+An app can have five labels. Only the first one that has something to report is drawn on the tile, so a queue that is never quite empty stays out of the way until it matters.
+
+### Reading more than one
+
+When a second badge is reporting, a matching pill appears behind the first in that badge's colour. It means there is more to see.
+
+
+
+ Requests and Sonarr each have more behind the badge. Plex has one label, so nothing is stacked.
+
+
+Hover the badge, tap it on a phone, or move focus to the tile with a keyboard. Everything the tile is reporting opens in a list, with the full numbers and no truncation. Press Escape or tap elsewhere to close it. Tapping the badge opens the list without following the link.
+
+
+
+ The list carries whatever the badge has no room for, in the same order.
+
+
+The list only appears when there is a second badge. One badge is already fully shown on the tile.
+
+### One total instead
+
+Turn on Show as a Single Badge to add every label's value together and show the sum as one number, in the first label's colour and unit. This is how Live Activity behaved before labels, and dashboards that used it keep it.
+
+### Folders
+
+A folder shows the badge of the app inside it that is reporting, rather than a total. Opening its list names each row by the app it came from.
## Which badge wins
@@ -91,7 +134,7 @@ An app can have several configured. The first of these that applies is the one d
27
- Live activityOnly at or above Show From.
+ Live activityThe first label at or above its Show From.
3
@@ -110,6 +153,8 @@ An app can have several configured. The first of these that applies is the one d
+Whatever is not drawn is still reachable: two or more badges on one tile open the list described above.
+
## Stale values
If a poll fails, the last known badge stays on the tile and is marked stale. A failed poll is never read as zero.
@@ -131,4 +176,6 @@ If a poll fails, the last known badge stays on the tile and is marked stale. A f
## Accessibility
-Every badge carries a text description for screen readers, so meaning is never carried by colour alone. Dark text is used where it beats white for contrast.
+Every badge carries a text description for screen readers, so meaning is never carried by colour alone, and so does the list behind it. Badge text is white wherever white is readable against the fill, and dark only where white would fail the contrast requirement.
+
+The list opens on keyboard focus as well as on hover and tap, and label order can be changed with the arrow buttons as well as by dragging.