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 ### 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 - **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 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 ShopDB account - so check-out and check-in were gated behind a login that was

View File

@@ -47,8 +47,20 @@
</div> </div>
</div> </div>
<div v-if="reportError" class="error-message">{{ reportError }}</div>
<!-- Server-side filters this report accepts (see reportFilterFields) --> <!-- Server-side filters this report accepts (see reportFilterFields) -->
<div v-if="activeFilterFields.length" class="filters"> <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 <select
v-if="activeFilterFields.includes('businessunit')" v-if="activeFilterFields.includes('businessunit')"
v-model="filterState.businessunitid" v-model="filterState.businessunitid"
@@ -209,6 +221,39 @@
</div> </div>
<!-- Generic fallback --> <!-- 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"> <div v-else class="report-content">
<pre>{{ JSON.stringify(reportData, null, 2) }}</pre> <pre>{{ JSON.stringify(reportData, null, 2) }}</pre>
</div> </div>
@@ -219,8 +264,8 @@
<script setup> <script setup>
import { ref, computed, onMounted, watch } from 'vue' import { ref, computed, onMounted, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import { reportsApi, businessunitsApi, assetsApi, locationsApi, applicationsApi } from '@/api' import api, { reportsApi, businessunitsApi, assetsApi, locationsApi, applicationsApi } from '@/api'
import { withBase } from '@/utils/basePath' import { apiError } from '@/utils/apiError'
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
@@ -240,18 +285,49 @@ const reportFilterFields = {
'assets-by-status': ['assettype', 'businessunit'], 'assets-by-status': ['assettype', 'businessunit'],
'asset-inventory': ['businessunit', 'location'], 'asset-inventory': ['businessunit', 'location'],
'kb-popularity': ['limit'], '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: [] }) 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(() => const activeFilterFields = computed(() =>
currentReport.value ? (reportFilterFields[currentReport.value.id] || []) : [] currentReport.value ? (reportFilterFields[currentReport.value.id] || []) : []
) )
function resetFilters() { 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 // 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.locationid) params.locationid = state.locationid
if (state.appid) params.appid = state.appid if (state.appid) params.appid = state.appid
if (state.limit) params.limit = state.limit 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 return params
} }
@@ -384,44 +465,64 @@ function applyQuery(id) {
async function runReport(report) { async function runReport(report) {
currentReport.value = report currentReport.value = report
loading.value = true loading.value = true
reportError.value = ''
reportData.value = null reportData.value = null
window.scrollTo(0, 0) window.scrollTo(0, 0)
const params = filterParams() const params = filterParams()
try { try {
let response // Any report that declares an endpoint is fetched from it.
switch (report.id) { //
case 'machines-by-type': // This used to be a switch over five hardcoded core ids with
response = await reportsApi.machinesByType(params) // `default: console.error('Unknown report')`. A plugin could register a
break // report, have it appear in the catalogue, and then render nothing at all
case 'assets-by-status': // when opened - the three printedparts reports were in exactly that state.
response = await reportsApi.assetsByStatus(params) // The endpoint is already on the contract; using it means a plugin never
break // needs a line of core code to be runnable.
case 'kb-popularity': const url = report.endpoint || `/api/reports/${report.id}`
response = await reportsApi.kbPopularity(params) const response = await api.get(stripApiPrefix(url), { 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
}
reportData.value = response.data.data reportData.value = response.data.data
} catch (error) { } catch (error) {
console.error('Error running report:', error) console.error('Error running report:', error)
reportError.value = apiError(error, 'Could not run this report')
} finally { } finally {
loading.value = false 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 if (!currentReport.value) return
const params = new URLSearchParams({ format: 'csv', ...filterParams() }) // Same endpoint the report itself came from - a plugin report used to export
window.open(withBase(`/api/reports/${currentReport.value.id}?${params}`), '_blank') // 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() { function clearReport() {
@@ -431,6 +532,10 @@ function clearReport() {
</script> </script>
<style scoped> <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 { .report-group {
margin-bottom: 2rem; margin-bottom: 2rem;
} }