Carry the level everywhere a position is drawn, and gate it per occurrence

This commit is contained in:
2026-08-18 09:37:29 -04:00
parent 8dd383f4ee
commit 09e9d5f047
16 changed files with 516 additions and 400 deletions

View File

@@ -22,7 +22,7 @@
<!-- With coordinates, the name carries the same floor-plan preview <!-- With coordinates, the name carries the same floor-plan preview
the asset's own page uses. Without them, a plain link. --> the asset's own page uses. Without them, a plain link. -->
<LocationMapTooltip v-if="row.link && row.maphover" <LocationMapTooltip v-if="row.link && row.maphover"
:left="row.maphover.x" :top="row.maphover.y" :left="row.maphover.x" :top="row.maphover.y" :levelid="row.maphover.levelid"
:machineName="row.maphover.label"> :machineName="row.maphover.label">
<router-link :to="row.link" class="dc-row-title" <router-link :to="row.link" class="dc-row-title"
:title="row.titletip || undefined"> :title="row.titletip || undefined">

View File

@@ -127,7 +127,15 @@ export function mapHover(card, item) {
const x = item[spec.x] const x = item[spec.x]
const y = item[spec.y] const y = item[spec.y]
if (x === null || x === undefined || y === null || y === undefined) return null if (x === null || x === undefined || y === null || y === undefined) return null
return { x, y, label: spec.label ? (item[spec.label] || '') : '' } // The level travels with the coordinates (ADR-017) - they are pixels of ONE
// drawing. Read `levelid` unless the card names another field, so a card that
// predates levels still previews on the right floor instead of none.
const levelid = item[spec.level || 'levelid']
return {
x, y,
levelid: levelid === undefined ? null : levelid,
label: spec.label ? (item[spec.label] || '') : '',
}
} }
export function cardRows(card) { export function cardRows(card) {

View File

@@ -289,7 +289,12 @@ async function exportPdf() {
try { try {
await loadMapConfig() await loadMapConfig()
await exportMapPdf({ await exportMapPdf({
assets: filteredAssets.value, // Only this level's markers. The sheet is one drawing, so a marker
// positioned against another level would be printed on the wrong floor
// plan - the same failure the on-screen map had, in a form nobody can
// correct after it is printed and carried onto the floor.
assets: filteredAssets.value.filter(
asset => (asset.levelid ?? null) === shownLevelId.value),
// blueprintUrlFor applies withBase - the raw setting value is a // blueprintUrlFor applies withBase - the raw setting value is a
// root-relative /api path, which 404s under a subpath mount like /ops. // root-relative /api path, which 404s under a subpath mount like /ops.
// The PDF always uses the light blueprint: it prints on white paper. // The PDF always uses the light blueprint: it prints on white paper.

View File

@@ -185,6 +185,7 @@
v-if="computer.mapx != null && computer.mapy != null" v-if="computer.mapx != null && computer.mapy != null"
:left="computer.mapx" :left="computer.mapx"
:top="computer.mapy" :top="computer.mapy"
:levelid="computer.levelid"
:machineName="computer.assetnumber" :machineName="computer.assetnumber"
> >
<span class="location-link">{{ computer.locationname || 'On Map' }}</span> <span class="location-link">{{ computer.locationname || 'On Map' }}</span>

View File

@@ -1223,6 +1223,9 @@ def list_reports():
'location': known.get('location'), 'location': known.get('location'),
'mapx': known.get('mapx'), 'mapx': known.get('mapx'),
'mapy': known.get('mapy'), 'mapy': known.get('mapy'),
# Coordinates are pixels of ONE level (ADR-017), so the level goes
# with them or the hover preview has nothing to draw on.
'levelid': known.get('levelid'),
'machinenumber': known.get('machinenumber'), 'machinenumber': known.get('machinenumber'),
'machineassetid': known.get('machineassetid'), 'machineassetid': known.get('machineassetid'),
'machinepluginid': known.get('machinepluginid'), 'machinepluginid': known.get('machinepluginid'),

View File

@@ -51,7 +51,7 @@
empty map would be worse than none. --> empty map would be worse than none. -->
<LocationMapTooltip <LocationMapTooltip
v-if="report.mapx != null && report.mapy != null" v-if="report.mapx != null && report.mapy != null"
:left="report.mapx" :top="report.mapy" :left="report.mapx" :top="report.mapy" :levelid="report.levelid"
:machineName="report.hostname" :machineName="report.hostname"
> >
<span class="map-pin" :title="report.location || 'On the floor plan'">&#9678;</span> <span class="map-pin" :title="report.location || 'On the floor plan'">&#9678;</span>

View File

@@ -167,6 +167,7 @@
v-if="machine.mapx != null && machine.mapy != null" v-if="machine.mapx != null && machine.mapy != null"
:left="machine.mapx" :left="machine.mapx"
:top="machine.mapy" :top="machine.mapy"
:levelid="machine.levelid"
:machineName="machine.assetnumber" :machineName="machine.assetnumber"
> >
<span class="location-link">{{ machine.locationname || 'On Map' }}</span> <span class="location-link">{{ machine.locationname || 'On Map' }}</span>

View File

@@ -1042,7 +1042,7 @@ def _get_low_supplies_data():
'model': model_number, 'model': model_number,
'location': location_name, 'location': location_name,
'mapx': asset.mapx, 'mapx': asset.mapx,
'levelid': asset.levelid, 'levelid': asset.levelid,
'mapy': asset.mapy, 'mapy': asset.mapy,
'supplies': annotated 'supplies': annotated
}) })
@@ -1479,6 +1479,10 @@ def dashboard_supplies():
# card, it just has nothing to preview. # card, it just has nothing to preview.
'mapx': printer.get('mapx'), 'mapx': printer.get('mapx'),
'mapy': printer.get('mapy'), 'mapy': printer.get('mapy'),
# The level those pixels belong to (ADR-017). Without it the hover
# preview cannot draw the marker and says so, which is what the
# dashboard card and the toner report were both doing.
'levelid': printer.get('levelid'),
'iscritical': any(s['status'] == 'critical' for s in depleted), 'iscritical': any(s['status'] == 'critical' for s in depleted),
'supplies': [{ 'supplies': [{
'text': '{} {}%'.format(_shortsupplyname(supply.get('name')), 'text': '{} {}%'.format(_shortsupplyname(supply.get('name')),

View File

@@ -169,6 +169,7 @@
v-if="printer.mapx != null && printer.mapy != null" v-if="printer.mapx != null && printer.mapy != null"
:left="printer.mapx" :left="printer.mapx"
:top="printer.mapy" :top="printer.mapy"
:levelid="printer.levelid"
:machineName="printer.name || printer.assetnumber" :machineName="printer.name || printer.assetnumber"
> >
<span class="location-link">View on Map</span> <span class="location-link">View on Map</span>

View File

@@ -43,14 +43,13 @@
:class="[`pos-${pos}`, page[pos - 1] ? 'filled' : 'empty']" :class="[`pos-${pos}`, page[pos - 1] ? 'filled' : 'empty']"
> >
<template v-if="page[pos - 1]"> <template v-if="page[pos - 1]">
<div class="model-name">{{ page[pos - 1].printer?.modelname || '' }}</div> <div class="csf-name">{{ labelName(page[pos - 1]) }}</div>
<div class="qr-container"> <div class="qr-container">
<img v-if="qrImages[`${pageIdx}-${pos}`]" :src="qrImages[`${pageIdx}-${pos}`]" class="qr-img" alt="QR" /> <img v-if="qrImages[`${pageIdx}-${pos}`]" :src="qrImages[`${pageIdx}-${pos}`]" class="qr-img" alt="QR" />
</div> </div>
<div class="info-section"> <div class="info-section">
<div class="csf-name">{{ page[pos - 1].assetnumber }}</div>
<div class="info-inner"> <div class="info-inner">
<div v-if="page[pos - 1].printer?.windowsname" class="info-row">{{ page[pos - 1].printer.windowsname }}</div> <div v-if="fqdnFor(page[pos - 1])" class="info-row">{{ fqdnFor(page[pos - 1]) }}</div>
<div v-if="getIp(page[pos - 1])" class="info-row">{{ getIp(page[pos - 1]) }}</div> <div v-if="getIp(page[pos - 1])" class="info-row">{{ getIp(page[pos - 1]) }}</div>
</div> </div>
</div> </div>
@@ -67,6 +66,7 @@ import { ref, computed, onMounted, watch, nextTick } from 'vue'
import { printersApi } from '@/api' import { printersApi } from '@/api'
import { renderQrDataUrl } from '@/utils/codes' import { renderQrDataUrl } from '@/utils/codes'
import { buildQrUrl } from '@/utils/qrTarget' import { buildQrUrl } from '@/utils/qrTarget'
import { getPrinterHostnameTemplate } from '@/utils/siteSettings'
const printers = ref([]) const printers = ref([])
const selectedPrinters = ref([]) const selectedPrinters = ref([])
@@ -91,6 +91,7 @@ const pages = computed(() => {
onMounted(async () => { onMounted(async () => {
try { try {
hostnameTemplate.value = await getPrinterHostnameTemplate()
// listAll: perpage is clamped to 100, and a batch sheet must cover every // listAll: perpage is clamped to 100, and a batch sheet must cover every
// printer, not the first page of them. // printer, not the first page of them.
printers.value = await printersApi.listAll() printers.value = await printersApi.listAll()
@@ -129,6 +130,29 @@ async function generateQRCodes() {
qrImages.value = next qrImages.value = next
} }
// The printer's FQDN. A stored hostname wins; otherwise the site builds one
// from the IP through the printer_hostname_template setting (ADR-015), which is
// how PrinterForm and the toner report derive it.
const hostnameTemplate = ref('')
function fqdnFor(item) {
const stored = item?.printer?.hostname
if (stored) return stored
const ip = getIp(item)
if (!ip || !hostnameTemplate.value) return ''
return hostnameTemplate.value.replace('{ip}', ip.replace(/\./g, '-'))
}
// What goes on the label's prominent top line: the printer's NAME, e.g.
// 8201-HPLaserJetPro. Sites keep that name in different places - the Windows
// queue name when one is set, otherwise the asset's name, and in practice most
// printers carry it as the assetnumber and nothing else, so that is the last
// fallback rather than a blank label. This is a name, not an identifier line:
// the label deliberately carries no separate "Asset #".
function labelName(item) {
return item?.printer?.windowsname || item?.name || item?.assetnumber || ''
}
function displayName(printer) { function displayName(printer) {
return printer.assetnumber || printer.name || `Printer-${printer.assetid}` return printer.assetnumber || printer.name || `Printer-${printer.assetid}`
} }
@@ -286,7 +310,7 @@ function print() {
.info-section { margin-top: 0.1in; display: flex; flex-direction: column; align-items: center; } .info-section { margin-top: 0.1in; display: flex; flex-direction: column; align-items: center; }
.info-inner { text-align: left; } .info-inner { text-align: left; }
.info-row { font-size: 9pt; color: #333; margin: 1px 0; white-space: nowrap; } .info-row { font-size: 9pt; color: #333; margin: 1px 0; white-space: nowrap; }
.csf-name { font-size: 12pt; font-weight: bold; font-family: monospace; text-align: center; margin-bottom: 2px; color: #000; } .csf-name { font-size: 12pt; font-weight: bold; font-family: monospace; text-align: center; margin-bottom: 0.06in; color: #000; }
.empty-label { color: #999; font-size: 14px; } .empty-label { color: #999; font-size: 14px; }
@media print { @media print {

View File

@@ -24,14 +24,13 @@
:class="[`pos-${pos}`, pos === parseInt(position) ? 'active' : 'inactive']" :class="[`pos-${pos}`, pos === parseInt(position) ? 'active' : 'inactive']"
> >
<template v-if="pos === parseInt(position)"> <template v-if="pos === parseInt(position)">
<div class="model-name">{{ printer.printer?.modelname || '' }}</div> <div class="csf-name">{{ labelName }}</div>
<div class="qr-container"> <div class="qr-container">
<img v-if="qrImage" :src="qrImage" class="qr-img" alt="QR" /> <img v-if="qrImage" :src="qrImage" class="qr-img" alt="QR" />
</div> </div>
<div class="info-section"> <div class="info-section">
<div class="csf-name">{{ printer.assetnumber }}</div>
<div class="info-inner"> <div class="info-inner">
<div v-if="printer.printer?.windowsname" class="info-row">{{ printer.printer.windowsname }}</div> <div v-if="fqdn" class="info-row">{{ fqdn }}</div>
<div v-if="ipAddress" class="info-row">{{ ipAddress }}</div> <div v-if="ipAddress" class="info-row">{{ ipAddress }}</div>
</div> </div>
</div> </div>
@@ -49,6 +48,7 @@ import { useRoute } from 'vue-router'
import { printersApi } from '@/api' import { printersApi } from '@/api'
import { renderQrDataUrl } from '@/utils/codes' import { renderQrDataUrl } from '@/utils/codes'
import { buildQrUrl } from '@/utils/qrTarget' import { buildQrUrl } from '@/utils/qrTarget'
import { getPrinterHostnameTemplate } from '@/utils/siteSettings'
const route = useRoute() const route = useRoute()
const loading = ref(true) const loading = ref(true)
@@ -58,6 +58,18 @@ const position = ref('1')
// in print output, images print every time. // in print output, images print every time.
const qrImage = ref('') const qrImage = ref('')
// What goes on the label's prominent top line: the printer's NAME, e.g.
// 8201-HPLaserJetPro. Sites keep that name in different places - the Windows
// queue name when one is set, otherwise the asset's name, and in practice most
// printers carry it as the assetnumber and nothing else, so that is the last
// fallback rather than a blank label. This is a name, not an identifier line:
// the label deliberately carries no separate "Asset #".
const labelName = computed(() =>
printer.value?.printer?.windowsname
|| printer.value?.name
|| printer.value?.assetnumber
|| '')
const ipAddress = computed(() => { const ipAddress = computed(() => {
// Check direct ipaddress field first (from list API) // Check direct ipaddress field first (from list API)
if (printer.value?.ipaddress && printer.value.ipaddress !== 'USB') return printer.value.ipaddress if (printer.value?.ipaddress && printer.value.ipaddress !== 'USB') return printer.value.ipaddress
@@ -67,8 +79,21 @@ const ipAddress = computed(() => {
return primary?.ipaddress || primary?.address || null return primary?.ipaddress || primary?.address || null
}) })
// The printer's FQDN. A stored hostname wins; otherwise the site builds one from
// the IP through the printer_hostname_template setting (ADR-015), the same way
// PrinterForm and the toner report derive it.
const hostnameTemplate = ref('')
const fqdn = computed(() => {
const stored = printer.value?.printer?.hostname
if (stored) return stored
if (!ipAddress.value || !hostnameTemplate.value) return ''
return hostnameTemplate.value.replace('{ip}', ipAddress.value.replace(/\./g, '-'))
})
onMounted(async () => { onMounted(async () => {
try { try {
hostnameTemplate.value = await getPrinterHostnameTemplate()
const response = await printersApi.get(route.params.id) const response = await printersApi.get(route.params.id)
printer.value = response.data.data printer.value = response.data.data
} catch (error) { } catch (error) {
@@ -169,7 +194,7 @@ function print() {
.info-section { margin-top: 0.1in; display: flex; flex-direction: column; align-items: center; } .info-section { margin-top: 0.1in; display: flex; flex-direction: column; align-items: center; }
.info-inner { text-align: left; } .info-inner { text-align: left; }
.info-row { font-size: 9pt; color: #333; margin: 1px 0; white-space: nowrap; } .info-row { font-size: 9pt; color: #333; margin: 1px 0; white-space: nowrap; }
.csf-name { font-size: 12pt; font-weight: bold; font-family: monospace; text-align: center; margin-bottom: 2px; color: #000; } .csf-name { font-size: 12pt; font-weight: bold; font-family: monospace; text-align: center; margin-bottom: 0.06in; color: #000; }
@media print { @media print {
/* Force rendered images/colors to print even when "Background graphics" is /* Force rendered images/colors to print even when "Background graphics" is

View File

@@ -1,378 +1,379 @@
<template> <template>
<div> <div>
<div class="page-header"> <div class="page-header">
<h1>Toner Report</h1> <h1>Toner Report</h1>
<div class="header-actions"> <div class="header-actions">
<button v-if="!loading && !error" class="btn btn-secondary" @click="exportCSV">Export CSV</button> <button v-if="!loading && !error" class="btn btn-secondary" @click="exportCSV">Export CSV</button>
<EmailReportButton v-if="!loading && !error" subject="Toner / Supply Report" <EmailReportButton v-if="!loading && !error" subject="Toner / Supply Report"
:columns="emailColumns" :rows="emailRows" /> :columns="emailColumns" :rows="emailRows" />
<router-link to="/reports" class="btn btn-secondary">Back to Reports</router-link> <router-link to="/reports" class="btn btn-secondary">Back to Reports</router-link>
</div> </div>
</div> </div>
<div v-if="loading" class="loading">Loading supply data...</div> <div v-if="loading" class="loading">Loading supply data...</div>
<div v-else-if="error" class="card"> <div v-else-if="error" class="card">
<p class="text-danger">{{ error }}</p> <p class="text-danger">{{ error }}</p>
</div> </div>
<template v-else> <template v-else>
<!-- Summary Bar --> <!-- Summary Bar -->
<div class="summary-bar"> <div class="summary-bar">
<div class="summary-stat card"> <div class="summary-stat card">
<div class="stat-value">{{ summary.total_checked }}</div> <div class="stat-value">{{ summary.total_checked }}</div>
<div class="stat-label">Printers Checked</div> <div class="stat-label">Printers Checked</div>
</div> </div>
<div class="summary-stat card stat-low"> <div class="summary-stat card stat-low">
<div class="stat-value">{{ summary.low }}</div> <div class="stat-value">{{ summary.low }}</div>
<div class="stat-label">Low Supply</div> <div class="stat-label">Low Supply</div>
</div> </div>
<div class="summary-stat card stat-critical"> <div class="summary-stat card stat-critical">
<div class="stat-value">{{ summary.critical }}</div> <div class="stat-value">{{ summary.critical }}</div>
<div class="stat-label">Critical Supply</div> <div class="stat-label">Critical Supply</div>
</div> </div>
</div> </div>
<!-- Filter Buttons --> <!-- Filter Buttons -->
<div class="filters"> <div class="filters">
<button <button
v-for="f in filterOptions" v-for="f in filterOptions"
:key="f.value" :key="f.value"
class="btn" class="btn"
:class="filter === f.value ? 'btn-primary' : 'btn-secondary'" :class="filter === f.value ? 'btn-primary' : 'btn-secondary'"
@click="filter = f.value" @click="filter = f.value"
> >
{{ f.label }} {{ f.label }}
</button> </button>
</div> </div>
<!-- Printers Table --> <!-- Printers Table -->
<div class="card"> <div class="card">
<div class="table-container"> <div class="table-container">
<table v-if="filteredPrinters.length"> <table v-if="filteredPrinters.length">
<thead> <thead>
<tr> <tr>
<th>Printer Name</th> <th>Printer Name</th>
<th>Hostname</th> <th>Hostname</th>
<th>Supplies</th> <th>Supplies</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="printer in filteredPrinters" :key="printer.printerid"> <tr v-for="printer in filteredPrinters" :key="printer.printerid">
<td> <td>
<!-- Coordinates give the name the same floor-plan preview the <!-- Coordinates give the name the same floor-plan preview the
printer's own page uses; it replaces the Location column. --> printer's own page uses; it replaces the Location column. -->
<LocationMapTooltip <LocationMapTooltip
v-if="printer.mapx != null && printer.mapy != null" v-if="printer.mapx != null && printer.mapy != null"
:left="printer.mapx" :left="printer.mapx"
:top="printer.mapy" :top="printer.mapy"
:machineName="printer.printername || printer.assetnumber" :levelid="printer.levelid"
> :machineName="printer.printername || printer.assetnumber"
<router-link :to="`/printers/${printer.printerid}`"> >
{{ printer.printername || 'Unknown' }} <router-link :to="`/printers/${printer.printerid}`">
</router-link> {{ printer.printername || 'Unknown' }}
</LocationMapTooltip> </router-link>
<router-link v-else :to="`/printers/${printer.printerid}`"> </LocationMapTooltip>
{{ printer.printername || 'Unknown' }} <router-link v-else :to="`/printers/${printer.printerid}`">
</router-link> {{ printer.printername || 'Unknown' }}
</td> </router-link>
<td> </td>
<!-- Opens the printer's own web page. New tab: the report is a <td>
worklist, and losing your place in it to visit one printer <!-- Opens the printer's own web page. New tab: the report is a
means finding your row again. --> worklist, and losing your place in it to visit one printer
<a means finding your row again. -->
v-if="fqdnFor(printer)" <a
:href="`http://${fqdnFor(printer)}`" v-if="fqdnFor(printer)"
target="_blank" :href="`http://${fqdnFor(printer)}`"
rel="noopener noreferrer" target="_blank"
:title="printer.ipaddress" rel="noopener noreferrer"
>{{ fqdnFor(printer) }}</a> :title="printer.ipaddress"
<span v-else>{{ printer.ipaddress || '-' }}</span> >{{ fqdnFor(printer) }}</a>
</td> <span v-else>{{ printer.ipaddress || '-' }}</span>
<td class="supplies-cell"> </td>
<div <td class="supplies-cell">
v-for="(supply, idx) in printer.supplies" <div
:key="idx" v-for="(supply, idx) in printer.supplies"
class="supply-row" :key="idx"
> class="supply-row"
<span class="supply-name">{{ supply.name }}</span> >
<div class="supply-bar-track"> <span class="supply-name">{{ supply.name }}</span>
<div <div class="supply-bar-track">
class="supply-bar-fill" <div
:class="'supply-' + supply.status" class="supply-bar-fill"
:style="{ width: Math.max(supply.level, 2) + '%' }" :class="'supply-' + supply.status"
></div> :style="{ width: Math.max(supply.level, 2) + '%' }"
</div> ></div>
<span class="supply-level" :class="'supply-text-' + supply.status"> </div>
{{ supply.level }}% <span class="supply-level" :class="'supply-text-' + supply.status">
</span> {{ supply.level }}%
<span class="supply-parts"> </span>
<span <span class="supply-parts">
v-for="part in supply.partnumbers" <span
:key="part.partnumber" v-for="part in supply.partnumbers"
class="part-chip" :key="part.partnumber"
:title="partTooltip(part)" class="part-chip"
>{{ part.partnumber }}</span> :title="partTooltip(part)"
<span v-if="!supply.partnumbers || !supply.partnumbers.length" >{{ part.partnumber }}</span>
class="part-none" title="No part number on file for this model and color">-</span> <span v-if="!supply.partnumbers || !supply.partnumbers.length"
</span> class="part-none" title="No part number on file for this model and color">-</span>
</div> </span>
</td> </div>
</tr> </td>
</tbody> </tr>
</table> </tbody>
<p v-else class="empty-state">No printers match the selected filter.</p> </table>
</div> <p v-else class="empty-state">No printers match the selected filter.</p>
</div> </div>
</template> </div>
</div> </template>
</template> </div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue' <script setup>
import { printersApi } from '@/api' import { ref, computed, onMounted } from 'vue'
import EmailReportButton from '@/components/EmailReportButton.vue' import { printersApi } from '@/api'
import LocationMapTooltip from '@/components/LocationMapTooltip.vue' import EmailReportButton from '@/components/EmailReportButton.vue'
import { getPrinterHostnameTemplate } from '@/utils/siteSettings' import LocationMapTooltip from '@/components/LocationMapTooltip.vue'
import { getPrinterHostnameTemplate } from '@/utils/siteSettings'
const emailColumns = [
{ key: 'printer', label: 'Printer' }, const emailColumns = [
{ key: 'hostname', label: 'Hostname' }, { key: 'printer', label: 'Printer' },
{ key: 'supply', label: 'Supply' }, { key: 'hostname', label: 'Hostname' },
{ key: 'partnumber', label: 'Part Number' }, { key: 'supply', label: 'Supply' },
{ key: 'level', label: 'Level' }, { key: 'partnumber', label: 'Part Number' },
{ key: 'status', label: 'Status' }, { key: 'level', label: 'Level' },
] { key: 'status', label: 'Status' },
]
const loading = ref(true)
const error = ref(null) const loading = ref(true)
const printers = ref([]) const error = ref(null)
const summary = ref({ total_checked: 0, low: 0, critical: 0 }) const printers = ref([])
const filter = ref('all') const summary = ref({ total_checked: 0, low: 0, critical: 0 })
const filter = ref('all')
const filterOptions = [
{ label: 'All', value: 'all' }, const filterOptions = [
{ label: 'Critical', value: 'critical' }, { label: 'All', value: 'all' },
{ label: 'Low', value: 'low' } { label: 'Critical', value: 'critical' },
] { label: 'Low', value: 'low' }
]
// Printers have no stored FQDN; the site builds one from the IP via the
// printer_hostname_template setting, the same way PrinterForm does. // Printers have no stored FQDN; the site builds one from the IP via the
const hostnameTemplate = ref('') // printer_hostname_template setting, the same way PrinterForm does.
const hostnameTemplate = ref('')
function fqdnFor(printer) {
if (!printer.ipaddress || !hostnameTemplate.value) return '' function fqdnFor(printer) {
return hostnameTemplate.value.replace('{ip}', printer.ipaddress.replace(/\./g, '-')) if (!printer.ipaddress || !hostnameTemplate.value) return ''
} return hostnameTemplate.value.replace('{ip}', printer.ipaddress.replace(/\./g, '-'))
}
// A model can list several capacity tiers for one color, so the chip shows the
// part number and the tooltip says which one it is. // A model can list several capacity tiers for one color, so the chip shows the
function partTooltip(part) { // part number and the tooltip says which one it is.
const bits = [part.marketingname, part.capacitytier] function partTooltip(part) {
if (part.pageyield) bits.push(part.pageyield + ' pages') const bits = [part.marketingname, part.capacitytier]
return bits.filter(Boolean).join(' - ') if (part.pageyield) bits.push(part.pageyield + ' pages')
} return bits.filter(Boolean).join(' - ')
}
// One flat row per part number so an ordering list is copy-pasteable; supplies
// with no part on file still get a row. // One flat row per part number so an ordering list is copy-pasteable; supplies
function reportRows() { // with no part on file still get a row.
const rows = [] function reportRows() {
for (const printer of filteredPrinters.value) { const rows = []
for (const supply of printer.supplies || []) { for (const printer of filteredPrinters.value) {
const parts = supply.partnumbers && supply.partnumbers.length for (const supply of printer.supplies || []) {
? supply.partnumbers.map(p => p.partnumber) const parts = supply.partnumbers && supply.partnumbers.length
: [''] ? supply.partnumbers.map(p => p.partnumber)
for (const partnumber of parts) { : ['']
rows.push({ for (const partnumber of parts) {
printer: printer.printername || '', rows.push({
hostname: fqdnFor(printer) || printer.ipaddress || '', printer: printer.printername || '',
supply: supply.name || '', hostname: fqdnFor(printer) || printer.ipaddress || '',
partnumber: partnumber, supply: supply.name || '',
level: supply.level, partnumber: partnumber,
status: supply.status || '', level: supply.level,
}) status: supply.status || '',
} })
} }
} }
return rows }
} return rows
}
const filteredPrinters = computed(() => {
if (filter.value === 'all') return printers.value const filteredPrinters = computed(() => {
return printers.value.filter(p => if (filter.value === 'all') return printers.value
p.supplies.some(s => s.status === filter.value) return printers.value.filter(p =>
) p.supplies.some(s => s.status === filter.value)
}) )
})
// Emailed table, honoring the active filter.
const emailRows = computed(() => // Emailed table, honoring the active filter.
reportRows().map(row => ({ ...row, level: row.level + '%' })) const emailRows = computed(() =>
) reportRows().map(row => ({ ...row, level: row.level + '%' }))
)
function exportCSV() {
const quote = value => `"${String(value ?? '').replace(/"/g, '""')}"` function exportCSV() {
const header = ['printer', 'hostname', 'supply', 'partnumber', 'level', 'status'] const quote = value => `"${String(value ?? '').replace(/"/g, '""')}"`
const rows = [header] const header = ['printer', 'hostname', 'supply', 'partnumber', 'level', 'status']
for (const row of reportRows()) { const rows = [header]
rows.push(header.map(key => row[key])) for (const row of reportRows()) {
} rows.push(header.map(key => row[key]))
const csv = rows.map(row => row.map(quote).join(',')).join('\n') }
const link = document.createElement('a') const csv = rows.map(row => row.map(quote).join(',')).join('\n')
link.href = URL.createObjectURL(new Blob([csv], { type: 'text/csv' })) const link = document.createElement('a')
link.download = 'toner_report.csv' link.href = URL.createObjectURL(new Blob([csv], { type: 'text/csv' }))
link.click() link.download = 'toner_report.csv'
URL.revokeObjectURL(link.href) link.click()
} URL.revokeObjectURL(link.href)
}
onMounted(async () => {
try { onMounted(async () => {
hostnameTemplate.value = await getPrinterHostnameTemplate() try {
const response = await printersApi.lowSupplies() hostnameTemplate.value = await getPrinterHostnameTemplate()
const data = response.data.data const response = await printersApi.lowSupplies()
printers.value = data.printers || [] const data = response.data.data
summary.value = data.summary || { total_checked: 0, low: 0, critical: 0 } printers.value = data.printers || []
} catch (err) { summary.value = data.summary || { total_checked: 0, low: 0, critical: 0 }
console.error('Error loading toner report:', err) } catch (err) {
error.value = 'Failed to load supply data. Zabbix may not be configured or reachable.' console.error('Error loading toner report:', err)
} finally { error.value = 'Failed to load supply data. Zabbix may not be configured or reachable.'
loading.value = false } finally {
} loading.value = false
}) }
</script> })
</script>
<style scoped>
.summary-bar { <style scoped>
display: flex; .summary-bar {
gap: 1.5rem; display: flex;
margin-bottom: 1.5rem; gap: 1.5rem;
} margin-bottom: 1.5rem;
}
.summary-stat {
flex: 1; .summary-stat {
text-align: center; flex: 1;
padding: 1.25rem; text-align: center;
} padding: 1.25rem;
}
.stat-value {
font-size: 2rem; .stat-value {
font-weight: 700; font-size: 2rem;
color: var(--text); font-weight: 700;
} color: var(--text);
}
.stat-label {
color: var(--text-light); .stat-label {
margin-top: 0.25rem; color: var(--text-light);
} margin-top: 0.25rem;
}
.stat-low .stat-value {
color: var(--warning); .stat-low .stat-value {
} color: var(--warning);
}
.stat-critical .stat-value {
color: var(--danger); .stat-critical .stat-value {
} color: var(--danger);
}
/* One grid for the whole cell, with each supply row contributing its cells
directly (display: contents). Cartridge names were being ellipsised inside a /* One grid for the whole cell, with each supply row contributing its cells
fixed 120px column, which hid the very thing being reordered; a max-content directly (display: contents). Cartridge names were being ellipsised inside a
column sizes to the longest name instead, and the columns still line up fixed 120px column, which hid the very thing being reordered; a max-content
across the rows of one printer. */ column sizes to the longest name instead, and the columns still line up
.supplies-cell { across the rows of one printer. */
min-width: 460px; .supplies-cell {
display: grid; min-width: 460px;
grid-template-columns: max-content minmax(80px, 1fr) auto max-content; display: grid;
column-gap: 0.5rem; grid-template-columns: max-content minmax(80px, 1fr) auto max-content;
row-gap: 0.35rem; column-gap: 0.5rem;
align-items: center; row-gap: 0.35rem;
} align-items: center;
}
.supply-row {
display: contents; .supply-row {
} display: contents;
}
.supply-name {
font-size: 0.85rem; .supply-name {
color: var(--text-light); font-size: 0.85rem;
white-space: nowrap; color: var(--text-light);
} white-space: nowrap;
}
.supply-bar-track {
height: 8px; .supply-bar-track {
background: var(--border); height: 8px;
border-radius: 4px; background: var(--border);
overflow: hidden; border-radius: 4px;
} overflow: hidden;
}
.supply-bar-fill {
height: 100%; .supply-bar-fill {
border-radius: 4px; height: 100%;
transition: width 0.3s ease; border-radius: 4px;
} transition: width 0.3s ease;
}
.supply-ok {
background: var(--success); .supply-ok {
} background: var(--success);
}
.supply-low {
background: var(--warning); .supply-low {
} background: var(--warning);
}
.supply-critical {
background: var(--danger); .supply-critical {
} background: var(--danger);
}
.supply-level {
min-width: 40px; .supply-level {
text-align: right; min-width: 40px;
font-size: 0.85rem; text-align: right;
font-weight: 600; font-size: 0.85rem;
} font-weight: 600;
}
.supply-parts {
display: flex; .supply-parts {
flex-wrap: wrap; display: flex;
gap: 0.25rem; flex-wrap: wrap;
} gap: 0.25rem;
}
.part-chip {
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace; .part-chip {
font-size: 0.75rem; font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
padding: 0.05rem 0.35rem; font-size: 0.75rem;
border: 1px solid var(--border); padding: 0.05rem 0.35rem;
border-radius: 3px; border: 1px solid var(--border);
color: var(--text); border-radius: 3px;
white-space: nowrap; color: var(--text);
} white-space: nowrap;
}
.part-none {
font-size: 0.85rem; .part-none {
color: var(--text-light); font-size: 0.85rem;
} color: var(--text-light);
}
.supply-text-ok {
color: var(--success); .supply-text-ok {
} color: var(--success);
}
.supply-text-low {
color: var(--warning); .supply-text-low {
} color: var(--warning);
}
.supply-text-critical {
color: var(--danger); .supply-text-critical {
} color: var(--danger);
}
.empty-state {
text-align: center; .empty-state {
padding: 2rem; text-align: center;
color: var(--text-light); padding: 2rem;
} color: var(--text-light);
}
.text-danger {
color: var(--danger); .text-danger {
} color: var(--danger);
}
.header-actions {
display: flex; .header-actions {
gap: 0.5rem; display: flex;
} gap: 0.5rem;
</style> }
</style>

View File

@@ -6,6 +6,7 @@
v-if="machine && hasPosition" v-if="machine && hasPosition"
:left="machine.mapx" :left="machine.mapx"
:top="machine.mapy" :top="machine.mapy"
:levelid="machine.levelid"
:machineName="machine.machinenumber || machine.name || ''" :machineName="machine.machinenumber || machine.name || ''"
> >
<span class="mmc-chip" :title="hoverTitle"> <span class="mmc-chip" :title="hoverTitle">

View File

@@ -169,21 +169,44 @@ fi
# whether all of them travel with their level, because reading them by eye is # whether all of them travel with their level, because reading them by eye is
# how the twentieth gets missed. # how the twentieth gets missed.
echo "==> Checking that emitted map positions carry their level (ADR-017)..." echo "==> Checking that emitted map positions carry their level (ADR-017)..."
POSITION_FILES=$(grep -rln "'mapx':" --include='*.py' shopdb/ plugins/ 2>/dev/null \ # PER OCCURRENCE, not per file. The file-level form passed a module that emitted
| grep -v '/tests\?/' || true) # 'mapx' six times and 'levelid' once, and two payloads shipped without a level:
# the toner report and the enforcement report, both feeding a hover preview that
# then said "no level". Every 'mapx' must have a 'levelid' in the same literal -
# the window is wide enough for a comment between them, and no wider.
MISSING_LEVEL="" MISSING_LEVEL=""
for candidate in $POSITION_FILES; do while IFS= read -r hit; do
if ! grep -q "'levelid'" "$candidate"; then [ -z "$hit" ] && continue
MISSING_LEVEL="$MISSING_LEVEL$candidate"$'\n' file=${hit%%:*}
line=${hit#*:}; line=${line%%:*}
if ! sed -n "${line},$((line + 8))p" "$file" | grep -q "'levelid'"; then
MISSING_LEVEL="$MISSING_LEVEL$file:$line"$'\n'
fi fi
done done <<EOF
$(grep -rn "'mapx':" --include='*.py' shopdb/ plugins/ scripts/ 2>/dev/null | grep -v '/tests\?/' || true)
EOF
if [ -n "$MISSING_LEVEL" ]; then if [ -n "$MISSING_LEVEL" ]; then
echo "FAIL: these emit 'mapx' but never 'levelid' - a position with no level" echo "FAIL: these emit 'mapx' with no 'levelid' beside it - a position with no"
echo " cannot be rendered on the right drawing:" echo " level cannot be drawn on the right floor plan (ADR-017):"
echo "$MISSING_LEVEL" | sed 's/^/ /' echo "$MISSING_LEVEL" | sed 's/^/ /'
VIOLATIONS=$((VIOLATIONS + 1)) VIOLATIONS=$((VIOLATIONS + 1))
fi fi
# The same rule for the hover preview: binding coordinates into
# LocationMapTooltip without a level makes it report "no level" for every asset,
# which is exactly what shipped in 0.11.0 - all seven call sites missed it.
TOOLTIP_MISSING=""
for candidate in $(grep -rl "LocationMapTooltip" --include='*.vue' frontend/src plugins/ 2>/dev/null | grep -v plugins-staged || true); do
grep -q ':left=' "$candidate" || continue
grep -q ':levelid=' "$candidate" || TOOLTIP_MISSING="$TOOLTIP_MISSING$candidate"$'\n'
done
if [ -n "$TOOLTIP_MISSING" ]; then
echo "FAIL: these bind LocationMapTooltip coordinates without :levelid, so the"
echo " preview cannot know which drawing to use (ADR-017):"
echo "$TOOLTIP_MISSING" | sed 's/^/ /'
VIOLATIONS=$((VIOLATIONS + 1))
fi
# ENFORCING. It was report-only while the backlog was worked off, and the hit # ENFORCING. It was report-only while the backlog was worked off, and the hit
# count then did not move for weeks - a rule that only prints is read as no rule. # count then did not move for weeks - a rule that only prints is read as no rule.
# Set SITE_LITERALS_ENFORCE=0 to drop back to reporting for a local run. # Set SITE_LITERALS_ENFORCE=0 to drop back to reporting for a local run.

View File

@@ -100,6 +100,22 @@ class Harness:
self.ids = IdMap(idmap_path or default) self.ids = IdMap(idmap_path or default)
self.source = Source() self.source = Source()
self.errors = [] self.errors = []
self._defaultlevelid = None
@property
def defaultlevelid(self):
"""The level imported map positions belong to (ADR-017).
The legacy schema predates levels: it has ONE floor plan, so every
mapleft/maptop it carries is a coordinate on this site's default level.
Importing them without a level produces markers the map refuses to draw
- it will not guess a drawing for coordinates that do not name one.
"""
if self._defaultlevelid is None:
from shopdb.core.models import MapLevel
level = MapLevel.default_level()
self._defaultlevelid = level.levelid if level else None
return self._defaultlevelid
def _silence_sql_logging(self): def _silence_sql_logging(self):
import logging import logging

View File

@@ -287,6 +287,7 @@ def stage_assets(h):
'serialnumber': (m['serialnumber'] or '').strip() or None, 'serialnumber': (m['serialnumber'] or '').strip() or None,
'businessunitid': h.ids.get('businessunit', m['businessunitid']), 'businessunitid': h.ids.get('businessunit', m['businessunitid']),
'mapx': m['mapleft'], 'mapy': m['maptop'], 'mapx': m['mapleft'], 'mapy': m['maptop'],
'levelid': h.defaultlevelid,
'notes': m['machinenotes'], 'notes': m['machinenotes'],
'dateadded': str(m['dateadded']) if m['dateadded'] else None, 'dateadded': str(m['dateadded']) if m['dateadded'] else None,
'modifieddate': str(m['lastupdated']) if m['lastupdated'] else None, 'modifieddate': str(m['lastupdated']) if m['lastupdated'] else None,
@@ -345,6 +346,7 @@ def stage_printers(h):
'iscsf': _truthy_bit(p['iscsf']), 'iscsf': _truthy_bit(p['iscsf']),
'installpath': p['installpath'], 'pin': p['printerpin'], 'installpath': p['installpath'], 'pin': p['printerpin'],
'notes': p['printernotes'], 'mapx': p['mapleft'], 'mapy': p['maptop'], 'notes': p['printernotes'], 'mapx': p['mapleft'], 'mapy': p['maptop'],
'levelid': h.defaultlevelid,
'modelnumberid': h.ids.get('model', p['modelid']), 'modelnumberid': h.ids.get('model', p['modelid']),
'locationid': h.ids.get('location', p['machineid']), 'locationid': h.ids.get('location', p['machineid']),
} }
@@ -386,7 +388,8 @@ def stage_metrology(h):
'name': (f"{pcname} {toolname}").strip() or toolname, 'name': (f"{pcname} {toolname}").strip() or toolname,
'measuringtooltypeid': typeid, 'measuringtooltypeid': typeid,
'businessunitid': h.ids.get('businessunit', m['businessunitid']), 'businessunitid': h.ids.get('businessunit', m['businessunitid']),
'mapx': m['mapleft'], 'mapy': m['maptop']}) 'mapx': m['mapleft'], 'mapy': m['maptop'],
'levelid': h.defaultlevelid})
if status not in (200, 201): if status not in (200, 201):
continue continue
tool_assetid = _id_of(data, 'assetid') tool_assetid = _id_of(data, 'assetid')