diff --git a/frontend/src/assets/style.css b/frontend/src/assets/style.css index 5038c546..7cf95e64 100644 --- a/frontend/src/assets/style.css +++ b/frontend/src/assets/style.css @@ -1083,6 +1083,21 @@ td.actions .btn + .btn { display: contents; } +/* Full-width alternative to .content-grid, for a tab holding one long list + rather than short fact cards. An application inventory or an event feed + split into two balanced columns reads as two unrelated lists, and the eye + has to hunt for where the first one ended. */ +.detail-full { + display: flex; + flex-direction: column; + gap: 1rem; + margin-bottom: 25px; +} + +.detail-full > * { + min-width: 0; +} + /* Section Cards */ .section-card { background: var(--bg-card); diff --git a/frontend/src/components/AssetDetailTabs.vue b/frontend/src/components/AssetDetailTabs.vue index a4c49778..1cb9e335 100644 --- a/frontend/src/components/AssetDetailTabs.vue +++ b/frontend/src/components/AssetDetailTabs.vue @@ -70,11 +70,21 @@ function onKey(event, index) { .adt { display: flex; gap: 2px; + /* overflow-y MUST be stated. Per the spec, when one axis is not `visible` + the other computes from `visible` to `auto` - so `overflow-x: auto` alone + silently makes this scrollable vertically too, and the -1px margin the + tabs use to sit on the border is enough to trip it. The result is a tiny + vertical scrollbar inside the strip. */ overflow-x: auto; + overflow-y: hidden; + scrollbar-width: thin; border-bottom: 1px solid var(--border); margin-bottom: 1.25rem; } .adt-tab { + /* Never shrink: a squeezed tab wraps its label, which grows the strip and is + what horizontal scrolling exists to avoid. */ + flex: none; font: inherit; font-size: 0.875rem; font-weight: 500; diff --git a/frontend/src/components/PluginAssetPanels.vue b/frontend/src/components/PluginAssetPanels.vue index a3d3d587..58155e5d 100644 --- a/frontend/src/components/PluginAssetPanels.vue +++ b/frontend/src/components/PluginAssetPanels.vue @@ -191,18 +191,30 @@ const props = defineProps({ const panels = ref([]) -// The server resolves every panel's group before we see it, so an unknown or -// absent one is already 'details' by the time it arrives here. Filtering can -// therefore be a plain equality check and nothing falls through the gap. +// The groups this build renders. A panel in any other group would be fetched, +// held, and shown on no tab at all. +// +// The server normalises group before we see it, so a version-matched pair +// never lands here. A MISMATCHED pair does: deploy a new frontend against a +// server still serving an older vocabulary and every panel in a group that +// has since been renamed or removed silently disappears. That is what happened +// to tracked services when it moved out of 'actions' - the backend still said +// 'actions', this build had no such tab, and the card was simply gone with +// nothing logged. Fall back the same way the server does rather than drop it. +const RENDERED_GROUPS = ['details', 'software', 'metrics', 'activity'] +function groupOf(panel) { + return RENDERED_GROUPS.includes(panel.group) ? panel.group : 'details' +} + const groupedPanels = computed(() => - props.group ? panels.value.filter((p) => p.group === props.group) : panels.value + props.group ? panels.value.filter((p) => groupOf(p) === props.group) : panels.value ) // Which groups actually have a panel with data, so a parent can hide a tab // rather than offering an empty one. const populatedGroups = computed(() => { const seen = new Set() - panels.value.forEach((p) => { if (panelVisible(p)) seen.add(p.group || 'details') }) + panels.value.forEach((p) => { if (panelVisible(p)) seen.add(groupOf(p)) }) return seen }) diff --git a/frontend/src/components/pluginAssetPanels.js b/frontend/src/components/pluginAssetPanels.js index 310e2485..739d9e74 100644 --- a/frontend/src/components/pluginAssetPanels.js +++ b/frontend/src/components/pluginAssetPanels.js @@ -246,11 +246,17 @@ export function rowAction(panel, item) { } } +// A panel declaring `empty` is asking to stay visible with nothing in it and +// say so - "No installed software reported." is an answer, where an absent +// card is a question. `list` honoured that and `table` did not, for no reason +// anybody wrote down, so a table panel with an empty message still vanished. +// It matters more now that a panel can be the only thing in a tab: the tab +// itself disappears with it. export function panelVisible(panel) { if (panel.render === 'tabs') return tabSections(panel).length > 0 if (panel.render === 'list') return rows(panel).length > 0 || !!panel.empty if (panel.render === 'keyvalue') return keyvalueFields(panel).length > 0 - if (panel.render === 'table') return rows(panel).length > 0 + if (panel.render === 'table') return rows(panel).length > 0 || !!panel.empty if (panel.render === 'badge') return badges(panel).length > 0 return false } diff --git a/frontend/src/components/pluginAssetPanels.spec.js b/frontend/src/components/pluginAssetPanels.spec.js index 5e3810db..f6377ced 100644 --- a/frontend/src/components/pluginAssetPanels.spec.js +++ b/frontend/src/components/pluginAssetPanels.spec.js @@ -327,3 +327,16 @@ describe('facet chips on a card that shows a health dot', () => { expect(facetedRows(services, ['Start Pending']).length).toBe(1) }) }) + +describe('panelVisible and empty', () => { + it('keeps a table panel that declares an empty message', () => { + // It is the only card in the Software tab, so hiding it hides the tab and + // the reader cannot tell "nothing reported" from "feature missing". + const panel = { render: 'table', _data: { items: [] }, empty: 'No installed software reported.' } + expect(panelVisible(panel)).toBe(true) + }) + + it('still hides a table panel with nothing to say', () => { + expect(panelVisible({ render: 'table', _data: { items: [] } })).toBe(false) + }) +}) diff --git a/plugins/computers/frontend/views/PCDetail.vue b/plugins/computers/frontend/views/PCDetail.vue index 0f241544..9970e092 100644 --- a/plugins/computers/frontend/views/PCDetail.vue +++ b/plugins/computers/frontend/views/PCDetail.vue @@ -603,11 +603,6 @@ async function confirmRetire() {