A plugin report renders as a report, not as a JSON dump
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:
20
CHANGELOG.md
20
CHANGELOG.md
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user