merge storage and disk health into one card with two sections
Volume capacity and physical drive health were two adjacent cards, and the operator reading a full disk is usually the operator reading a dying disk in the same minute. They are now one Storage card with two sections. Visual grouping only - the two tables share no key, storage being per volume and health per physical device, and one drive can back several volumes or none the operator sees. Nothing is reconciled across the sections; this is not the mapped-drive merge where two sources described the same drive and the worse status won. The tabs render type was the only sections-in-one-card mechanism and emitted label/value fields with no table path, so it now takes table sections too. That is the renderer every plugin uses, so both behaviour changes in it are mutation-tested. Two things only surfaced on contact: the empty-section filter had to grow a table branch, because a table section declares columns up front and dropping it when it has no rows takes its tab with it and reads as no disks rather than nothing reported; and full-width span was keyed off the declared render type, but a tabs panel holding a six-column table needs the same span and that is only knowable from fetched data. The diskhealth route stays. It no longer backs a panel, but it is a stable read for callers outside the renderer and removing it for a UI-only change would break them.
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
a panel as JSON (title, endpoint, render mode, field map); this renders it
|
||||
on any asset detail page, so a plugin adds detail-page UI with no Vue. -->
|
||||
<template v-for="panel in groupedPanels" :key="panel.id">
|
||||
<div v-if="panelVisible(panel)" class="section-card" :class="{ 'pap-span': panel.render === 'table' }">
|
||||
<div v-if="panelVisible(panel)" class="section-card" :class="{ 'pap-span': spansFullWidth(panel) }">
|
||||
<!-- collapsible panels get a clickable header with a caret and, when the
|
||||
panel declares a status rollup, a green/yellow/red dot for the worst
|
||||
row (a stopped service or a warning/critical event). --><h3
|
||||
@@ -120,7 +120,39 @@
|
||||
@click="setTab(panel, si)"
|
||||
>{{ section.label }}</button>
|
||||
</div>
|
||||
<div class="pap-kv">
|
||||
<!-- A section carrying columns renders as a table; otherwise it is the
|
||||
original label/value grid. Both shapes coexist in one panel. -->
|
||||
<div
|
||||
v-if="((tabSections(panel)[activeTab(panel)] || {}).columns || []).length"
|
||||
class="pap-table-wrap"
|
||||
>
|
||||
<table class="pap-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th
|
||||
v-for="col in (tabSections(panel)[activeTab(panel)] || {}).columns"
|
||||
:key="col.key"
|
||||
>{{ col.label }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="(row, ri) in (tabSections(panel)[activeTab(panel)] || {}).rows || []"
|
||||
:key="ri"
|
||||
>
|
||||
<td
|
||||
v-for="col in (tabSections(panel)[activeTab(panel)] || {}).columns"
|
||||
:key="col.key"
|
||||
>{{ cell(row, col) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div
|
||||
v-if="!((tabSections(panel)[activeTab(panel)] || {}).rows || []).length"
|
||||
class="pap-empty"
|
||||
>{{ (tabSections(panel)[activeTab(panel)] || {}).empty || 'Nothing reported.' }}</div>
|
||||
</div>
|
||||
<div v-else class="pap-kv">
|
||||
<div
|
||||
v-for="(field, fi) in (tabSections(panel)[activeTab(panel)] || {}).fields || []"
|
||||
:key="fi"
|
||||
@@ -258,7 +290,7 @@ import { colorStyle } from '@/utils/colorStyle'
|
||||
import {
|
||||
toApiPath, rows, panelVisible, mapTitle, mapBadge, mapMeta, mapBody, manageLink,
|
||||
keyvalueFields, tableColumns, cell, badges, tabSections, rollupStatus,
|
||||
rowAction, facets, facetedRows, formatValue,
|
||||
rowAction, facets, facetedRows, formatValue, spansFullWidth,
|
||||
} from './pluginAssetPanels'
|
||||
|
||||
// Operator report screenshots are served by a JWT-gated route, so a bare
|
||||
|
||||
@@ -218,17 +218,38 @@ export function rollupStatus(panel) {
|
||||
// tabs: [{ label, fields: [{label, value, mono}] }]. One section is shown at a
|
||||
// time, so a panel with 30 values stays the height of its largest section
|
||||
// instead of dragging one multicol column far past the other.
|
||||
//
|
||||
// A section may instead carry { columns, rows, empty } and render as a table.
|
||||
// This exists so two tables that belong side by side for the reader but share
|
||||
// NO key can sit in one card - Storage is per volume, Disk health is per
|
||||
// physical device. The sections are independent tables by design: nothing here
|
||||
// reconciles rows across them, unlike the mapped-drive merge where two sources
|
||||
// described the same drive.
|
||||
export function tabSections(panel) {
|
||||
const data = panel._data
|
||||
const sections = (data && Array.isArray(data.sections)) ? data.sections : []
|
||||
return sections
|
||||
.map((s) => ({
|
||||
label: s.label,
|
||||
fields: (s.fields || []).map((f) => ({
|
||||
label: f.label, value: formatValue(f.value, f), mono: !!f.mono,
|
||||
})),
|
||||
}))
|
||||
.filter((s) => s.fields.length > 0)
|
||||
.map((s) => {
|
||||
if (Array.isArray(s.columns) && s.columns.length) {
|
||||
return {
|
||||
label: s.label,
|
||||
columns: s.columns.map((c) => ({ key: c.key, label: c.label || humanize(c.key) })),
|
||||
rows: Array.isArray(s.rows) ? s.rows : [],
|
||||
empty: s.empty || '',
|
||||
fields: [],
|
||||
}
|
||||
}
|
||||
return {
|
||||
label: s.label,
|
||||
fields: (s.fields || []).map((f) => ({
|
||||
label: f.label, value: formatValue(f.value, f), mono: !!f.mono,
|
||||
})),
|
||||
}
|
||||
})
|
||||
// A table section declares its columns up front, so it survives having no
|
||||
// rows and shows its own empty message. Dropping it would take the tab with
|
||||
// it and read as "this machine has no disks" rather than "nothing reported".
|
||||
.filter((s) => s.fields.length > 0 || (s.columns && s.columns.length > 0))
|
||||
}
|
||||
|
||||
// Inline row action for a list panel. A monitored services/processes row may
|
||||
@@ -255,6 +276,16 @@ export function rowAction(panel, item) {
|
||||
}
|
||||
}
|
||||
|
||||
// A table needs the full card width. `table` panels get it via panel.render,
|
||||
// but a `tabs` panel only needs it when one of its sections is a table, and
|
||||
// that is not knowable from the declaration - it depends on fetched data.
|
||||
export function spansFullWidth(panel) {
|
||||
if (!panel) return false
|
||||
if (panel.render === 'table') return true
|
||||
if (panel.render !== 'tabs') return false
|
||||
return tabSections(panel).some((s) => (s.columns || []).length > 0)
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
99
frontend/src/components/tabSectionsTable.spec.js
Normal file
99
frontend/src/components/tabSectionsTable.spec.js
Normal file
@@ -0,0 +1,99 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { tabSections, panelVisible, spansFullWidth } from './pluginAssetPanels'
|
||||
|
||||
// The tabs render type carried label/value sections only. It now also carries
|
||||
// table sections, so that two tables which belong together for the reader but
|
||||
// share no key can sit in one card. This is the shared renderer every plugin
|
||||
// uses, so the label/value path is pinned here alongside the new one.
|
||||
|
||||
function tabsPanel(sections) {
|
||||
return { id: 'p', render: 'tabs', _data: { sections } }
|
||||
}
|
||||
|
||||
const FIELD_SECTION = { label: 'Fields', fields: [{ label: 'A', value: '1' }] }
|
||||
const TABLE_SECTION = {
|
||||
label: 'Volumes',
|
||||
columns: [{ key: 'drive', label: 'Drive' }],
|
||||
rows: [{ drive: 'C:' }],
|
||||
empty: 'No disks reported.',
|
||||
}
|
||||
|
||||
describe('tabSections with table sections', () => {
|
||||
it('keeps rendering label/value sections as before', () => {
|
||||
const sections = tabSections(tabsPanel([FIELD_SECTION]))
|
||||
expect(sections).toHaveLength(1)
|
||||
expect(sections[0].fields).toEqual([{ label: 'A', value: '1', mono: false }])
|
||||
expect(sections[0].columns).toBeUndefined()
|
||||
})
|
||||
|
||||
it('carries columns and rows through for a table section', () => {
|
||||
const sections = tabSections(tabsPanel([TABLE_SECTION]))
|
||||
expect(sections[0].columns).toEqual([{ key: 'drive', label: 'Drive' }])
|
||||
expect(sections[0].rows).toEqual([{ drive: 'C:' }])
|
||||
})
|
||||
|
||||
it('lets one panel mix a table section and a field section', () => {
|
||||
const sections = tabSections(tabsPanel([TABLE_SECTION, FIELD_SECTION]))
|
||||
expect(sections.map((s) => s.label)).toEqual(['Volumes', 'Fields'])
|
||||
})
|
||||
|
||||
// The reason the empty-section filter needed a table branch at all. A card
|
||||
// whose second section is dropped loses that tab, and "no disks" is a very
|
||||
// different claim from "the collector reported nothing".
|
||||
it('keeps an empty table section so its tab and message survive', () => {
|
||||
const sections = tabSections(tabsPanel([{ ...TABLE_SECTION, rows: [] }]))
|
||||
expect(sections).toHaveLength(1)
|
||||
expect(sections[0].empty).toBe('No disks reported.')
|
||||
})
|
||||
|
||||
it('still drops a label/value section with no fields', () => {
|
||||
const sections = tabSections(tabsPanel([{ label: 'Empty', fields: [] }]))
|
||||
expect(sections).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('derives a column label from the key when none is given', () => {
|
||||
const sections = tabSections(tabsPanel([
|
||||
{ label: 'V', columns: [{ key: 'drive' }], rows: [] },
|
||||
]))
|
||||
expect(sections[0].columns[0].label).toBe('Drive')
|
||||
})
|
||||
|
||||
it('treats a missing rows array as no rows rather than throwing', () => {
|
||||
const sections = tabSections(tabsPanel([{ label: 'V', columns: [{ key: 'drive' }] }]))
|
||||
expect(sections[0].rows).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('panelVisible for a tabs panel of tables', () => {
|
||||
it('shows a panel whose only section is an empty table', () => {
|
||||
expect(panelVisible(tabsPanel([{ ...TABLE_SECTION, rows: [] }]))).toBe(true)
|
||||
})
|
||||
|
||||
it('still hides a tabs panel with no sections at all', () => {
|
||||
expect(panelVisible(tabsPanel([]))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('spansFullWidth', () => {
|
||||
// A six-column table squeezed into one half of a two-column grid is the
|
||||
// failure this prevents.
|
||||
it('spans a tabs panel that contains a table section', () => {
|
||||
expect(spansFullWidth(tabsPanel([TABLE_SECTION]))).toBe(true)
|
||||
})
|
||||
|
||||
it('does not span a tabs panel of label/value sections', () => {
|
||||
expect(spansFullWidth(tabsPanel([FIELD_SECTION]))).toBe(false)
|
||||
})
|
||||
|
||||
it('still spans a plain table panel', () => {
|
||||
expect(spansFullWidth({ render: 'table' })).toBe(true)
|
||||
})
|
||||
|
||||
it('does not span a keyvalue panel', () => {
|
||||
expect(spansFullWidth({ render: 'keyvalue' })).toBe(false)
|
||||
})
|
||||
|
||||
it('tolerates a null panel', () => {
|
||||
expect(spansFullWidth(null)).toBe(false)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user