A plugin report renders as a report, not as a JSON dump
Some checks failed
CI / naming (push) Successful in 1m39s
CI / frontend (push) Successful in 2m28s
CI / migrations-mysql (push) Failing after 6m58s
CI / backend (push) Has been cancelled

The reports page ran a hardcoded switch over five core report ids and fell
through to `default: console.error('Unknown report')`, and its results panel was
a matching chain of per-id templates ending in a `JSON.stringify`. So a plugin
could register a report, watch it appear in the catalogue, and get a wall of
JSON when it was opened - which is where the three printedparts reports have
been since they were added.

Any report that declares an endpoint is now fetched from it, and any report
returning {columns, rows} renders as a table. That shape is already the
convention on the server side, so a plugin needs no core code at all to have a
working report. The per-id templates stay for the core reports whose payloads
are not tabular.

Export CSV goes through the api client rather than window.open. A new window
carries no Authorization header, so every report behind a real jwt_required -
which is every plugin one - answered the export button with a 401. The core
reports are jwt-optional, which is the only reason the old way ever appeared to
work. It also followed /api/reports/<id>, which does not exist for a plugin
report, instead of the endpoint the report itself came from.

A report that fails now says so instead of leaving an empty panel, and the page
offers the time window the endpoints already accepted - printedparts-consumption
was permanently stuck on its 30-day default because nothing could send one.

Verified in a browser against both plugin report families and the core reports.
This commit is contained in:
cproudlock
2026-08-27 10:20:38 -04:00
parent f59aca5a1b
commit 11f39253c2
2 changed files with 154 additions and 29 deletions

View File

@@ -24,6 +24,26 @@ ADR-007 and ADR-002.
### Fixed
- **Plugin reports rendered a raw JSON dump.** The reports page ran a hardcoded
switch over five core report ids and fell through to
`console.error('Unknown report')`, and its results panel was a matching chain
of per-id templates ending in a `JSON.stringify`. So a plugin could register a
report, watch it appear in the catalogue, and get a wall of JSON when it was
opened - which is where the three printedparts reports have been since they
were added. Any report that declares an endpoint is now fetched from it, and
any report returning `{columns, rows}` renders as a table. A plugin needs no
core code to have a working report. CSV export follows the same endpoint
rather than assuming `/api/reports/<id>`, which does not exist for a plugin
report, and a report that fails now says so instead of showing an empty panel.
Export CSV is fetched through the api client rather than `window.open`: a new
window carries no Authorization header, so every report behind a real
`jwt_required` answered the export button with a 401. The core reports are
jwt-optional, which is the only reason the old way ever appeared to work.
- **Reports had no way to choose a time period.** Endpoints accepted a window;
the page offered no control for it, so `printedparts-consumption` was
permanently stuck on its 30-day default.
- **USB kiosk: check a drive out and back in without a login.** The people who
actually take USB drives are standing at the cabinet, and most of them have no
ShopDB account - so check-out and check-in were gated behind a login that was

View File

@@ -47,8 +47,20 @@
</div>
</div>
<div v-if="reportError" class="error-message">{{ reportError }}</div>
<!-- Server-side filters this report accepts (see reportFilterFields) -->
<div v-if="activeFilterFields.length" class="filters">
<select
v-if="activeFilterFields.includes('period')"
v-model="filterState.period"
class="form-control"
@change="refreshReport"
>
<option v-for="option in PERIOD_OPTIONS" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
<select
v-if="activeFilterFields.includes('businessunit')"
v-model="filterState.businessunitid"
@@ -209,6 +221,39 @@
</div>
<!-- Generic fallback -->
<!--
Any report that returns {columns, rows} renders as a table. Every core
report above has a hand-written block; a plugin report had none, so it
fell through to a raw JSON dump - which is what the printedparts reports
have been showing since they were added. The shape is already the
convention on the server side, so honouring it here means a plugin gets
a real report with no core code at all.
-->
<div v-else-if="genericRows" class="report-content">
<p v-if="reportData.summary" class="report-summary">
<span v-for="(value, key) in reportData.summary" :key="key" class="summary-figure">
<strong>{{ value }}</strong> {{ titleCase(key) }}
</span>
</p>
<p v-else class="report-summary">{{ genericRows.length }} row(s)</p>
<div class="table-scroll">
<table>
<thead>
<tr><th v-for="column in genericColumns" :key="column">{{ titleCase(column) }}</th></tr>
</thead>
<tbody>
<tr v-for="(row, index) in genericRows" :key="index">
<td v-for="column in genericColumns" :key="column">{{ row[column] }}</td>
</tr>
</tbody>
</table>
</div>
<p v-if="!genericRows.length" class="report-empty">
Nothing in this period.
</p>
</div>
<!-- Last resort: a shape nothing here knows. Better visible than blank. -->
<div v-else class="report-content">
<pre>{{ JSON.stringify(reportData, null, 2) }}</pre>
</div>
@@ -219,8 +264,8 @@
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { reportsApi, businessunitsApi, assetsApi, locationsApi, applicationsApi } from '@/api'
import { withBase } from '@/utils/basePath'
import api, { reportsApi, businessunitsApi, assetsApi, locationsApi, applicationsApi } from '@/api'
import { apiError } from '@/utils/apiError'
const router = useRouter()
const route = useRoute()
@@ -240,18 +285,49 @@ const reportFilterFields = {
'assets-by-status': ['assettype', 'businessunit'],
'asset-inventory': ['businessunit', 'location'],
'kb-popularity': ['limit'],
'software-compliance': ['application']
'software-compliance': ['application'],
// Time-windowed reports. The endpoints already accepted a window; there was
// simply no way to choose one from the page.
'usb-audit': ['period'],
'printedparts-consumption': ['period'],
'printedparts-by-person': ['period']
}
const filterState = ref({ businessunitid: '', assettypeid: '', locationid: '', appid: '', limit: '' })
// Named windows rather than a day count: the question is "this shift" or "this
// year", and making somebody work out that a year is 365 serves nobody.
const PERIOD_OPTIONS = [
{ value: 'day', label: 'Today' },
{ value: 'month', label: 'Last month' },
{ value: 'sixmonths', label: 'Last 6 months' },
{ value: 'year', label: 'Last year' },
{ value: 'all', label: 'All time' }
]
const PERIOD_DAYS = { day: 1, month: 30, sixmonths: 182, year: 365, all: 0 }
const filterState = ref({ businessunitid: '', assettypeid: '', locationid: '', appid: '', limit: '', period: 'month' })
const reportError = ref('')
const filterOptions = ref({ businessunits: [], assettypes: [], locations: [], applications: [] })
// A report is "generic" when it returns rows plus the columns to show them in.
const genericRows = computed(() => {
const data = reportData.value
return data && Array.isArray(data.rows) ? data.rows : null
})
const genericColumns = computed(() => {
const data = reportData.value
if (!data) return []
if (Array.isArray(data.columns) && data.columns.length) return data.columns
// No declared columns: take them from the first row so the table still works.
return data.rows?.length ? Object.keys(data.rows[0]) : []
})
const activeFilterFields = computed(() =>
currentReport.value ? (reportFilterFields[currentReport.value.id] || []) : []
)
function resetFilters() {
filterState.value = { businessunitid: '', assettypeid: '', locationid: '', appid: '', limit: '' }
filterState.value = { businessunitid: '', assettypeid: '', locationid: '', appid: '', limit: '', period: 'month' }
reportError.value = ''
}
// fetch dropdown options once, only for the fields the open report needs
@@ -288,6 +364,11 @@ function filterParams() {
if (state.locationid) params.locationid = state.locationid
if (state.appid) params.appid = state.appid
if (state.limit) params.limit = state.limit
if (activeFilterFields.value.includes('period') && state.period) {
params.period = state.period
// Sent as well so an endpoint that only knows ?days= still honours it.
params.days = PERIOD_DAYS[state.period]
}
return params
}
@@ -384,44 +465,64 @@ function applyQuery(id) {
async function runReport(report) {
currentReport.value = report
loading.value = true
reportError.value = ''
reportData.value = null
window.scrollTo(0, 0)
const params = filterParams()
try {
let response
switch (report.id) {
case 'machines-by-type':
response = await reportsApi.machinesByType(params)
break
case 'assets-by-status':
response = await reportsApi.assetsByStatus(params)
break
case 'kb-popularity':
response = await reportsApi.kbPopularity(params)
break
case 'software-compliance':
response = await reportsApi.softwareCompliance(params)
break
case 'asset-inventory':
response = await reportsApi.assetInventory(params)
break
default:
console.error('Unknown report:', report.id)
return
}
// Any report that declares an endpoint is fetched from it.
//
// This used to be a switch over five hardcoded core ids with
// `default: console.error('Unknown report')`. A plugin could register a
// report, have it appear in the catalogue, and then render nothing at all
// when opened - the three printedparts reports were in exactly that state.
// The endpoint is already on the contract; using it means a plugin never
// needs a line of core code to be runnable.
const url = report.endpoint || `/api/reports/${report.id}`
const response = await api.get(stripApiPrefix(url), { params })
reportData.value = response.data.data
} catch (error) {
console.error('Error running report:', error)
reportError.value = apiError(error, 'Could not run this report')
} finally {
loading.value = false
}
}
function exportCSV() {
// The api client is already mounted at /api, so an endpoint from the contract
// (which carries the prefix, because other consumers call it absolutely) has to
// have it removed rather than doubled.
function stripApiPrefix (url) {
return url.startsWith('/api/') ? url.slice(4) : url
}
async function exportCSV() {
if (!currentReport.value) return
const params = new URLSearchParams({ format: 'csv', ...filterParams() })
window.open(withBase(`/api/reports/${currentReport.value.id}?${params}`), '_blank')
// Same endpoint the report itself came from - a plugin report used to export
// from /api/reports/<id>, which does not exist for it.
//
// Fetched through the api client, not window.open: a new window carries no
// Authorization header, so any report behind a real jwt_required (every
// plugin one) answered the export button with a 401 page. The core reports
// are jwt-optional, which is the only reason window.open ever appeared to
// work.
const url = currentReport.value.endpoint || `/api/reports/${currentReport.value.id}`
reportError.value = ''
try {
const response = await api.get(stripApiPrefix(url), {
params: { format: 'csv', ...filterParams() },
responseType: 'blob'
})
const link = document.createElement('a')
link.href = URL.createObjectURL(response.data)
link.download = `${currentReport.value.id}.csv`
link.click()
URL.revokeObjectURL(link.href)
} catch (error) {
console.error('Error exporting report:', error)
reportError.value = apiError(error, 'Could not export this report')
}
}
function clearReport() {
@@ -431,6 +532,10 @@ function clearReport() {
</script>
<style scoped>
.summary-figure { margin-right: 1.25rem; }
.table-scroll { overflow-x: auto; }
.report-empty { color: var(--text-light); margin-top: 0.75rem; }
.report-group {
margin-bottom: 2rem;
}