dashboard: icon fallback on real img load failure, not container-state guess

This commit is contained in:
iris 2026-05-21 22:01:38 +02:00
parent 16f614f45d
commit ab1f8d6e33
3 changed files with 42 additions and 23 deletions

View file

@ -578,20 +578,27 @@
const pending = transientsState.get(c.name)?.kind || null;
const li = el('li', { class: 'container-row' + (pending ? ' pending' : '') });
// Full-height square agent icon, left of the card body. A
// background-image on a div (not <img>) contributes no intrinsic
// size, so loading or failing it can't shift the row layout —
// no broken-image glyph, no collapse. (issue #177)
// Full-height square agent icon, left of the card body. The
// icon is an <img> absolutely positioned inside a wrapper div:
// the div is the flex child and sizes itself via aspect-ratio +
// stretch, the <img> is out of flow so its load state — pending,
// loaded or broken — can never contribute intrinsic size or
// reflow the row. (issue #177)
//
// When the container is stopped or mid-transient (restarting,
// rebuilding…) its web server isn't answering, so `<url>/icon`
// would just fail to an empty box. Fall back to the dimmed
// The icon points straight at the agent's `<url>/icon`. We don't
// guess whether the agent is reachable from the container row —
// we just let the <img> try, and if it actually fails to load
// (agent stopped, restarting, rebuilding — web server not
// answering) the error handler falls it back to the dimmed
// hyperhive mark (`/favicon.svg`, served by the dashboard
// itself, always reachable) instead. (issue #195)
const reachable = c.running && !pending;
const icon = el('div', {
class: 'container-icon' + (reachable ? '' : ' icon-unreachable'),
style: `background-image:url("${reachable ? `${url}icon` : '/favicon.svg'}")`,
// itself, always reachable). (issues #195, #202)
const iconImg = el('img', { class: 'container-icon-img', src: `${url}icon`, alt: '' });
const icon = el('div', { class: 'container-icon' }, iconImg);
iconImg.addEventListener('error', () => {
if (iconImg.dataset.fallback) return; // guard: don't loop if the favicon itself 404s
iconImg.dataset.fallback = '1';
icon.classList.add('icon-unreachable');
iconImg.src = '/favicon.svg';
});
// Card body: the three stacked content lines, right of the icon.
const body = el('div', { class: 'card-body' });

View file

@ -89,17 +89,26 @@ a:hover {
gap: 0.7em;
}
.container-row:not(.tombstone) > .container-icon {
position: relative;
overflow: hidden;
flex: none;
align-self: stretch;
aspect-ratio: 1;
border-radius: 6px;
background-color: rgba(17, 17, 27, 0.6);
background-size: contain;
background-position: center;
background-repeat: no-repeat;
}
/* Stopped / mid-transient container: the dimmed hyperhive mark stands
in for the unreachable agent icon (issue #195). */
/* The icon image fills the square wrapper and is taken out of flow
(absolute) so its load state pending, loaded, broken can never
contribute intrinsic size or reflow the row. (issue #177) */
.container-row:not(.tombstone) > .container-icon > .container-icon-img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: contain;
}
/* When the <img> fails to load it falls back to the dimmed hyperhive
mark, standing in for the unreachable agent icon (issues #195, #202). */
.container-row:not(.tombstone) > .container-icon.icon-unreachable {
filter: grayscale(1);
opacity: 0.4;