Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f34b9ca710 |
@@ -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">
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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'">◎</span>
|
<span class="map-pin" :title="report.location || 'On the floor plan'">◎</span>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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')),
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
Reference in New Issue
Block a user