Show empty-state text on empty table panels
A table panel with no rows rendered its column headers over an empty body. On a live PC Details page that made Network Interfaces and Listening Ports look like cards that had failed to load, when the real answer was that the agent had not reported any yet. List panels already honoured panel.empty; table panels did not. Also records the simulation-suite requirement uncovered when rolling containers: the pytest suite builds its schema from the models, so an unapplied migration is invisible to it while every query against the affected table 500s in the running image.
This commit is contained in:
@@ -423,6 +423,52 @@ would be the behaviour the removed comment falsely claimed.
|
||||
|
||||
---
|
||||
|
||||
## Simulation suite - required check (from a real dev-environment failure)
|
||||
|
||||
The suite must assert that CODE AND SCHEMA ARE NEVER SERVED OUT OF STEP, and
|
||||
must do it by bringing the image UP, not by building a schema from the models.
|
||||
|
||||
Observed 2026-09-05 in dev, after rolling containers for the Phase 3/4 UI work:
|
||||
every query against `endpointstates` returned 500 with
|
||||
|
||||
(1054, "Unknown column 'endpointstates.diskhealthjson' in 'field list'")
|
||||
|
||||
The Phase 2 migrations (endpoints 0014, 0015) were written and committed WITH
|
||||
their models, correctly. They had simply never been applied to that database.
|
||||
`flask plugin upgrade-all` fixed it in one command.
|
||||
|
||||
Why nothing caught it:
|
||||
|
||||
- The pytest suite builds its schema from the models, so model and schema are
|
||||
by construction in step. This class of fault is INVISIBLE to it. A green test
|
||||
run says nothing about whether a deployed database has the columns.
|
||||
- Nothing in the startup path checks. `create_app()` and `wsgi.py` do no
|
||||
migration-state validation, so the container reports HEALTHY while every
|
||||
query against the affected table 500s. The health check passes because it
|
||||
does not touch the table.
|
||||
- `flask db preflight` exists but is manual and pre-migration; it is not a
|
||||
guard on serving traffic.
|
||||
|
||||
The failure is also SILENT IN THE UI in a way that misdirects: a panel whose
|
||||
endpoint 500s is dropped from `populatedGroups`, so its whole TAB disappears
|
||||
from PC Details. The visible symptom was a missing "Service/Process Monitors"
|
||||
tab, which reads as a UI regression in the tab-vocabulary code and sends you
|
||||
to entirely the wrong file. Nothing on the page mentions the database.
|
||||
|
||||
So the suite needs, at minimum:
|
||||
|
||||
1. A start-up case that runs the real image against a database deliberately
|
||||
left one migration behind, and asserts the mismatch is reported LOUDLY -
|
||||
not a healthy container serving 500s.
|
||||
2. Coverage that the documented order (`flask db upgrade`, then
|
||||
`flask plugin upgrade-all`, THEN serve) is what the container actually does.
|
||||
3. A UI-level assertion that a 500ing panel endpoint does not silently remove a
|
||||
tab with no explanation on screen.
|
||||
|
||||
Worth considering separately from the suite: a startup guard that refuses to
|
||||
serve, or at least logs at ERROR, when a model references a column the database
|
||||
does not have. Cheap here, expensive on a fleet rollout.
|
||||
|
||||
## Open questions - answer before the relevant phase
|
||||
|
||||
1. ~~LAPS~~ - RESOLVED: not a feature request. The user was illustrating why the
|
||||
|
||||
@@ -126,7 +126,10 @@
|
||||
v-if="((tabSections(panel)[activeTab(panel)] || {}).columns || []).length"
|
||||
class="pap-table-wrap"
|
||||
>
|
||||
<table class="pap-table">
|
||||
<table
|
||||
v-if="((tabSections(panel)[activeTab(panel)] || {}).rows || []).length"
|
||||
class="pap-table"
|
||||
>
|
||||
<thead>
|
||||
<tr>
|
||||
<th
|
||||
@@ -174,7 +177,10 @@
|
||||
|
||||
<!-- table: columns declared by the panel (or inferred from row keys) -->
|
||||
<div v-else-if="panel.render === 'table'" class="pap-table-wrap">
|
||||
<table class="pap-table">
|
||||
<!-- Headers are suppressed when there are no rows. A bare header row
|
||||
over an empty body reads as a broken card; the declared empty
|
||||
message says which of "nothing here" or "nothing reported" it is. -->
|
||||
<table v-if="rows(panel).length" class="pap-table">
|
||||
<thead>
|
||||
<tr><th v-for="col in tableColumns(panel)" :key="col.key">{{ col.label }}</th></tr>
|
||||
</thead>
|
||||
@@ -184,6 +190,9 @@
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-else class="pap-empty">
|
||||
<span class="muted">{{ panel.empty || 'Nothing to show.' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- badge: a row of colored badges -->
|
||||
|
||||
51
frontend/src/components/emptyTablePanel.spec.js
Normal file
51
frontend/src/components/emptyTablePanel.spec.js
Normal file
@@ -0,0 +1,51 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { readFileSync } from 'fs'
|
||||
import { resolve } from 'path'
|
||||
|
||||
// A `table` panel with no rows used to render a bare header row over an empty
|
||||
// body, which reads as a broken card rather than as an answer. `list` had
|
||||
// honoured panel.empty for a long time; `table` had not, for no reason anybody
|
||||
// wrote down. Found on a live page where Network Interfaces and Listening
|
||||
// Ports both showed column headers and nothing else.
|
||||
//
|
||||
// Asserted against the SOURCE because the condition lives in the template, not
|
||||
// in an exported helper - the same reason panelGroups.spec.js reads source.
|
||||
|
||||
const source = readFileSync(
|
||||
resolve(__dirname, 'PluginAssetPanels.vue'), 'utf8',
|
||||
)
|
||||
|
||||
function tableBlock() {
|
||||
const start = source.indexOf("panel.render === 'table'")
|
||||
expect(start).toBeGreaterThan(-1)
|
||||
// Up to the next render branch, so we only look at the table path.
|
||||
const end = source.indexOf("panel.render === 'badge'", start)
|
||||
expect(end).toBeGreaterThan(start)
|
||||
return source.slice(start, end)
|
||||
}
|
||||
|
||||
describe('an empty table panel', () => {
|
||||
it('does not render the table element when there are no rows', () => {
|
||||
expect(tableBlock()).toMatch(/<table[^>]*v-if="rows\(panel\)\.length"/)
|
||||
})
|
||||
|
||||
it('falls back to the panel-declared empty message', () => {
|
||||
expect(tableBlock()).toContain('panel.empty')
|
||||
})
|
||||
|
||||
it('keeps a default message for a panel that declares none', () => {
|
||||
// An empty card with no words in it is indistinguishable from a card that
|
||||
// failed to load.
|
||||
expect(tableBlock()).toMatch(/panel\.empty \|\| '[^']+'/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('an empty table SECTION inside a tabs panel', () => {
|
||||
it('also suppresses its header row', () => {
|
||||
// The section path was added with the Storage merge and had the same bug.
|
||||
const start = source.indexOf("panel.render === 'tabs'")
|
||||
const end = source.indexOf("panel.render === 'keyvalue'", start)
|
||||
const block = source.slice(start, end)
|
||||
expect(block).toMatch(/<table\s+v-if="\(\(tabSections\(panel\)/)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user