diff --git a/CHANGELOG.md b/CHANGELOG.md
index f6c0779a..4f8ca72f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,92 @@ ADR-007 and ADR-002.
## [Unreleased]
+### Added
+
+- **The code generator can print a back side.** A badge card is two-sided and
+ the generator only ever emitted one page per card. The Back side section
+ turns on a second page per card carrying either the same code again, a fixed
+ block of text, its own per-row content (CSV `back` / `backlabel`), or
+ nothing. Page order is a choice, because the two kinds of printer want
+ opposite things: **interleaved** front-back-front-back for a duplex card
+ printer, **grouped** all-fronts-then-all-backs for a single-sided printer
+ that gets its stack flipped and reloaded. Getting that backwards prints
+ perfectly readable cards with the wrong back on every one, which is why the
+ order is pinned by tests rather than by looking at the preview.
+- **The gap between a code and its caption is its own control.** It was the
+ quiet zone doing double duty: closing up a caption meant shaving the clear
+ space a scanner needs, and the only way to get a code to scan was to push the
+ caption away from it. Quiet zone now means only the code's own margin; gap
+ means only the distance to the caption, and adds to the quiet zone rather
+ than eating into it. Every stock preset carries a gap that suits it.
+- **Contents can be positioned inside the label without moving the label.**
+ Align and Vertical place the code and caption block anywhere on the stock,
+ and Content X/Y nudge it from there. The existing X/Y nudge kept its
+ behavior and is now labeled Media X/Y: it moves the whole printed area, for
+ a printer whose origin is off, which is a different problem from composing a
+ label.
+
+- **A picture can go on the label.** Pick an image once and it prints on every
+ label - above the code, beside it, or behind everything as a watermark - on
+ the front, the back, or both. The file never leaves the browser, so an
+ air-gapped site can use it, and it is deliberately not saved between visits:
+ a data URL runs to megabytes and would take every sizing value with it when
+ the storage quota gave out.
+- **The back can carry its own code, in its own symbology.** "Back carries" now
+ defaults to a code rather than text, takes per-row content from the CSV
+ `back` column, and has its own QR/CODE128 choice - a QR on the front that
+ opens the record, a CODE128 on the back that the bench scanner already reads.
+ A back set to carry a code can no longer be laid out as text-only: that
+ combination printed a blank card that looked like the code had failed, and it
+ is corrected on load for anyone who already saved it.
+
+### Fixed
+
+- **A zero bar height printed a barcode 176 wide and 0 high.** Nothing rejected
+ it: the value went straight into a CSS length, `height: 0in` is perfectly
+ valid, and an image at `height: 100%` of a zero-height box is zero pixels
+ tall. A cleared field was its own version of the same bug - `'' + 'in'` is not
+ a length, the browser dropped the declaration, and the box fell back to auto
+ height. Every measurement now goes through `labelVars()`, which will not emit
+ NaN, an empty length, or a zero where a zero means the box stops existing;
+ padding, quiet zone and gap still accept zero, because there it is a real
+ answer. The stylesheet carries its own fallbacks too, though those only cover
+ a property that is absent - a property set to garbage is dropped at computed
+ value time and takes the fallback with it, which is why the sanitiser is the
+ fix and the CSS is only a net.
+- **A caption could eat the barcode.** The code box and the caption both grew,
+ so any caption took half the label and kept going: "BAY 12 PRESS LINE 1" on
+ 1.00 x 0.50in stock left a 36px stub, which is a code that is present,
+ unscannable, and reads as one that failed to render. The caption now takes
+ only the room it needs and a CODE128 keeps at least 40% of the label, all of
+ it when stacked. A code that genuinely fails to render says so in the preview
+ now, in red, rather than leaving white space that looks like a label with no
+ code on it.
+- **The generator prints from inside the app shell.** It was a full-screen
+ route with no sidebar and a narrow column of controls on the left. It is now
+ an ordinary page - navigation, full width - and `style.css` hides the shell at
+ print time, so the stock still gets nothing but labels. Verified against a
+ real print PDF rather than a preview.
+- A test print paired each page with a code by loop position while the codes
+ are keyed to position in the whole run, so a two-sided test card could carry
+ the wrong code. The page index now travels with the page.
+- The back label's dotted preview outline outspecified the print reset and drew
+ a dashed rule down the edge of every second card.
+
+### Changed
+
+- **Settings on the left, labels on the right.** The controls were a narrow
+ column with the preview trailing underneath, so the labels being adjusted were
+ off the bottom of the screen. The preview is now a column of its own that
+ stays in view and scrolls by itself, and it folds back to one column on a
+ narrow window. Note for anyone changing it: that scrolling column is exactly
+ the kind of ancestor that clips a multi-page print to whatever was on screen,
+ so print resets its overflow - verified by printing a six-page two-sided run,
+ not by looking at the preview.
+- CSV parsing and page building moved out of the generator component into
+ `plugins/tools/frontend/labelPages.js`, where the column contract and the
+ two-sided page order are covered by tests.
+
## [0.12.0] - 2026-08-20
Printers become a property of the bay rather than of whoever last walked up to a
diff --git a/frontend/src/assets/style.css b/frontend/src/assets/style.css
index 36e9d98c..5038c546 100644
--- a/frontend/src/assets/style.css
+++ b/frontend/src/assets/style.css
@@ -1763,3 +1763,31 @@ td.actions .btn + .btn {
independently. */
.clickable-row { cursor: pointer; }
.clickable-row:hover td { background: var(--bg); }
+
+/* --- Printing from inside the app shell -------------------------------------
+ *
+ * Core's label pages are full-screen routes precisely so the shell cannot reach
+ * the stock. A tool someone works in for ten minutes wants the navigation
+ * though, so the shell is hidden here instead: the sidebar goes, the content
+ * column gives back the margin it was holding for it, and the notification
+ * banner - which is chrome, not content - goes with them.
+ *
+ * Anything the page itself must keep off the paper marks itself .no-print. */
+@media print {
+ .sidebar,
+ .notification-banner,
+ .no-print {
+ display: none !important;
+ }
+
+ .app-layout {
+ display: block !important;
+ min-height: 0 !important;
+ }
+
+ .main-content {
+ margin-left: 0 !important;
+ padding: 0 !important;
+ overflow: visible !important;
+ }
+}
diff --git a/frontend/src/components/LocationMapTooltip.vue b/frontend/src/components/LocationMapTooltip.vue
index d58fff81..e2e3bb3b 100644
--- a/frontend/src/components/LocationMapTooltip.vue
+++ b/frontend/src/components/LocationMapTooltip.vue
@@ -97,11 +97,15 @@ const blueprintUrl = computed(() => {
// the wrong level's dimensions is precisely how a marker ends up plausibly
// placed and wrong.
const markerX = computed(() => {
- return (props.left / dimensionsFor(props.levelid).width) * 100
+ const fraction = props.left / dimensionsFor(props.levelid).width
+ const { offsetX, scaleX } = paintedBox.value
+ return (offsetX + fraction * scaleX) * 100
})
const markerY = computed(() => {
- return (props.top / dimensionsFor(props.levelid).height) * 100
+ const fraction = props.top / dimensionsFor(props.levelid).height
+ const { offsetY, scaleY } = paintedBox.value
+ return (offsetY + fraction * scaleY) * 100
})
// Marker style with counter-scale to maintain constant size
@@ -111,6 +115,42 @@ const markerStyle = computed(() => ({
transform: `translate(-50%, -50%) scale(${1 / zoom.value})`
}))
+// The frame the marker's percentages are measured against MUST be the frame the
+// drawing is painted in. The image used object-fit: contain inside a fixed
+// 390x300 box, so a level whose drawing is not that aspect ratio was letterboxed
+// or pillarboxed - and the marker, positioned at a percentage of the BOX, then
+// sat in a different coordinate space from the drawing inside it. A tall
+// drawing pillarboxes, every marker's x shifts, and one near the left edge
+// lands in the grey gutter beside the map. Levels near 390:300 looked correct,
+// which is why it only affected some assets.
+//
+// The preview is a fixed box and object-fit: contain paints the drawing inside it
+// at the drawing's own aspect ratio, so the drawing does not fill the box: it is
+// letterboxed on one axis. A marker placed at a percentage of the BOX therefore
+// lands somewhere else than the same percentage of the DRAWING, on any level
+// whose shape is not the box's.
+//
+// So convert: work out where the painted drawing sits inside the box, then map
+// the marker into box coordinates. Doing it here rather than by resizing the
+// frame keeps the centring transform below honest - its percentages resolve
+// against the element, so the element has to stay the size of the box.
+const PREVIEW_WIDTH = 390 // keep in step with .map-preview in the styles
+const PREVIEW_HEIGHT = 300
+const PREVIEW_ASPECT = PREVIEW_WIDTH / PREVIEW_HEIGHT
+
+// Fraction of the box the drawing covers, and the gap before it, per axis.
+const paintedBox = computed(() => {
+ const { width, height } = dimensionsFor(props.levelid)
+ const levelAspect = width / height
+ if (levelAspect >= PREVIEW_ASPECT) {
+ // Wider than the box: full width, bars above and below.
+ const scale = PREVIEW_ASPECT / levelAspect
+ return { scaleX: 1, offsetX: 0, scaleY: scale, offsetY: (1 - scale) / 2 }
+ }
+ // Taller than the box: full height, bars left and right.
+ const scale = levelAspect / PREVIEW_ASPECT
+ return { scaleX: scale, offsetX: (1 - scale) / 2, scaleY: 1, offsetY: 0 }
+})
// Transform style that centers on the marker and zooms toward it
const transformStyle = computed(() => {
// Calculate translation to center the marker in the preview
@@ -263,6 +303,9 @@ watch(currentTheme, () => {
.map-transform {
position: relative;
+ /* Fills the preview. The centring transform translates by percentages, which
+ resolve against THIS element, so it must stay the size of the box it is
+ centring within. The letterbox is accounted for in the marker maths. */
width: 100%;
height: 100%;
transition: transform 0.15s ease-out;
@@ -271,7 +314,10 @@ watch(currentTheme, () => {
.map-image {
width: 100%;
height: 100%;
+ /* contain, so a level of any shape is shown whole and undistorted. Where the
+ bars fall is computed in paintedBox and folded into the marker position. */
object-fit: contain;
+ display: block;
}
.marker-dot {
diff --git a/frontend/src/components/SearchSuggestion.vue b/frontend/src/components/SearchSuggestion.vue
new file mode 100644
index 00000000..0e8c9e90
--- /dev/null
+++ b/frontend/src/components/SearchSuggestion.vue
@@ -0,0 +1,42 @@
+
+
+
+ Did you mean
+ ?
+
+
+
+
+
+
diff --git a/frontend/src/composables/listQuery.js b/frontend/src/composables/listQuery.js
index cee61558..205dba43 100644
--- a/frontend/src/composables/listQuery.js
+++ b/frontend/src/composables/listQuery.js
@@ -1,5 +1,6 @@
import { ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
+import { useSearchSuggestion } from './searchSuggestion'
// Keep a list page's current page and search term in the URL query so the
// browser Back button restores them. Without this a list keeps page in local
@@ -16,6 +17,18 @@ import { useRoute, useRouter } from 'vue-router'
// deep link) so the list reloads at the restored page.
// extraKeys optional extra query keys to persist (e.g. ['typeid']); each
// gets a ref exposed under the returned `extras` object.
+//
+// SPELLING SUGGESTIONS come along for free, because this composable already
+// owns the search term. A list reports how many rows it got and gets back a
+// suggestion to offer when that number is zero:
+//
+// const { search, suggestion, reportCount } = useListQuery({ onChange: load })
+// // at the end of load(): reportCount(rows.value.length)
+// // in the empty state:
+//
+// A list that never calls reportCount simply never offers one, which is what
+// every list did before.
export function useListQuery(options = {}) {
const route = useRoute()
const router = useRouter()
@@ -98,5 +111,17 @@ export function useListQuery(options = {}) {
if (changed) onChange()
})
- return { page, search, extras, setPage, setSearch, setExtra }
+ const { suggestion, checkSuggestion, clearSuggestion } = useSearchSuggestion()
+
+ // Called by the list after a fetch. Zero rows for a non-empty search is the
+ // only moment a suggestion is worth asking for.
+ function reportCount(count) {
+ if (count > 0 || !search.value) clearSuggestion()
+ else checkSuggestion(search.value)
+ }
+
+ return {
+ page, search, extras, setPage, setSearch, setExtra,
+ suggestion, reportCount,
+ }
}
diff --git a/frontend/src/composables/searchSuggestion.js b/frontend/src/composables/searchSuggestion.js
new file mode 100644
index 00000000..9242c9a7
--- /dev/null
+++ b/frontend/src/composables/searchSuggestion.js
@@ -0,0 +1,56 @@
+import { ref } from 'vue'
+import api from '../api'
+
+/**
+ * "Did you mean" for a search that found nothing.
+ *
+ * ONE composable for every list in the product. Thirty routes take a search
+ * parameter across a dozen files, so carrying a suggestion in each of their
+ * responses would be a large change now and a thing every future plugin has to
+ * remember. Instead a page that renders no rows asks for a spelling, and a page
+ * that has not adopted this shows nothing - exactly what it showed before.
+ *
+ * Usage in a list view:
+ *
+ * const { suggestion, checkSuggestion, clearSuggestion } = useSearchSuggestion()
+ * // after a fetch:
+ * items.value.length ? clearSuggestion() : checkSuggestion(searchTerm.value)
+ *
+ * and in the template, beside the empty state:
+ *
+ *
+ */
+export function useSearchSuggestion() {
+ const suggestion = ref(null)
+ // Guards a slow answer arriving after a newer search has already been typed,
+ // which would offer a spelling for a query nobody is looking at any more.
+ let latest = 0
+
+ function clearSuggestion() {
+ suggestion.value = null
+ latest += 1
+ }
+
+ async function checkSuggestion(query) {
+ const term = (query || '').trim()
+ if (term.length < 2) {
+ clearSuggestion()
+ return
+ }
+ const ticket = ++latest
+ try {
+ const response = await api.get('/search/suggest', { params: { q: term } })
+ if (ticket !== latest) return
+ const offered = response.data?.data?.suggestion || null
+ // Never offer back what was typed: a suggestion identical to the query
+ // reads as a broken feature.
+ suggestion.value = offered && offered !== term.toLowerCase() ? offered : null
+ } catch (err) {
+ // A search that found nothing is already the answer. Failing to improve
+ // on it is not worth an error in front of anyone.
+ if (ticket === latest) suggestion.value = null
+ }
+ }
+
+ return { suggestion, checkSuggestion, clearSuggestion }
+}
diff --git a/frontend/src/router/routes/securesend.js b/frontend/src/router/routes/securesend.js
new file mode 100644
index 00000000..a3ba0354
--- /dev/null
+++ b/frontend/src/router/routes/securesend.js
@@ -0,0 +1,35 @@
+/**
+ * Securesend plugin routes.
+ *
+ * Auto-discovered by the router via import.meta.glob, so no registration
+ * edit is needed. Every route carries meta.plugin 'securesend' so the ADR-009
+ * guard redirects to the dashboard when the securesend backend plugin is
+ * disabled. Form routes add requiresAuth so anonymous users cannot reach
+ * create or edit.
+ */
+export default [
+ {
+ path: 'securesend',
+ name: 'securesend',
+ component: () => import('../../views/securesend/SecuresendList.vue'),
+ meta: { plugin: 'securesend' }
+ },
+ {
+ path: 'securesend/new',
+ name: 'securesend-new',
+ component: () => import('../../views/securesend/SecuresendForm.vue'),
+ meta: { requiresAuth: true, plugin: 'securesend' }
+ },
+ {
+ path: 'securesend/:id',
+ name: 'securesend-detail',
+ component: () => import('../../views/securesend/SecuresendDetail.vue'),
+ meta: { plugin: 'securesend' }
+ },
+ {
+ path: 'securesend/:id/edit',
+ name: 'securesend-edit',
+ component: () => import('../../views/securesend/SecuresendForm.vue'),
+ meta: { requiresAuth: true, plugin: 'securesend' }
+ }
+]
diff --git a/frontend/src/utils/codes.js b/frontend/src/utils/codes.js
index 381cba5c..cdeba7c7 100644
--- a/frontend/src/utils/codes.js
+++ b/frontend/src/utils/codes.js
@@ -24,15 +24,19 @@ import JsBarcode from 'jsbarcode'
import { getQrLogo } from '@/utils/siteSettings'
-// Label stock actually loaded in the printers, with the code size, padding and
-// quiet zone that fit each one. Sizes are inches, because that is what the
-// stock is sold as and what @page takes.
+// Label stock actually loaded in the printers, with the code size, padding,
+// quiet zone and code-to-caption gap that fit each one. Sizes are inches,
+// because that is what the stock is sold as and what @page takes.
+//
+// quiet and gap are NOT the same measurement. quiet is the clear space a
+// scanner needs around the code and is fixed by the symbology; gap is only how
+// far the caption sits from it, and is a matter of taste per stock.
export const LABEL_PRESETS = [
- { id: 'zebra1x05', name: '1.00 x 0.50 in (Zebra gap)', labelwidth: 1.0, labelheight: 0.5, codesize: 0.4286, padding: 0, quiet: 0.035, labelfont: 7 },
- { id: 'zebra2x1', name: '2.00 x 1.00 in', labelwidth: 2.0, labelheight: 1.0, codesize: 0.85, padding: 0.03, quiet: 0.05, labelfont: 10 },
- { id: 'zebra225x125', name: '2.25 x 1.25 in', labelwidth: 2.25, labelheight: 1.25, codesize: 1.05, padding: 0.04, quiet: 0.06, labelfont: 11 },
- { id: 'zebra4x6', name: '4.00 x 6.00 in (shipping)', labelwidth: 4.0, labelheight: 6.0, codesize: 3.0, padding: 0.15, quiet: 0.12, labelfont: 20 },
- { id: 'badge', name: '2.13 x 3.38 in (badge)', labelwidth: 2.13, labelheight: 3.38, codesize: 1.5, padding: 0.15, quiet: 0.1, labelfont: 12 },
+ { id: 'zebra1x05', name: '1.00 x 0.50 in (Zebra gap)', labelwidth: 1.0, labelheight: 0.5, codesize: 0.4286, padding: 0, quiet: 0.035, gap: 0.03, labelfont: 7 },
+ { id: 'zebra2x1', name: '2.00 x 1.00 in', labelwidth: 2.0, labelheight: 1.0, codesize: 0.85, padding: 0.03, quiet: 0.05, gap: 0.05, labelfont: 10 },
+ { id: 'zebra225x125', name: '2.25 x 1.25 in', labelwidth: 2.25, labelheight: 1.25, codesize: 1.05, padding: 0.04, quiet: 0.06, gap: 0.06, labelfont: 11 },
+ { id: 'zebra4x6', name: '4.00 x 6.00 in (shipping)', labelwidth: 4.0, labelheight: 6.0, codesize: 3.0, padding: 0.15, quiet: 0.12, gap: 0.12, labelfont: 20 },
+ { id: 'badge', name: '2.13 x 3.38 in (badge)', labelwidth: 2.13, labelheight: 3.38, codesize: 1.5, padding: 0.15, quiet: 0.1, gap: 0.1, labelfont: 12 },
]
// CODE128 is what the scanners on the floor are configured for, and a label
diff --git a/frontend/src/views/SearchResults.vue b/frontend/src/views/SearchResults.vue
index 48d35d5d..80d262f8 100644
--- a/frontend/src/views/SearchResults.vue
+++ b/frontend/src/views/SearchResults.vue
@@ -42,6 +42,7 @@
No results found for "{{ query }}"
+
@@ -94,12 +95,14 @@
import { ref, computed, onMounted, watch, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { searchApi, knowledgebaseApi } from '../api'
+import SearchSuggestion from '../components/SearchSuggestion.vue'
const route = useRoute()
const router = useRouter()
const loading = ref(false)
const results = ref([])
+const suggestion = ref(null)
const query = ref('')
const searchInput = ref('')
const activeFilter = ref('all')
@@ -175,6 +178,7 @@ const filteredResults = computed(() => {
async function search(q) {
if (!q || q.length < 2) {
results.value = []
+ suggestion.value = null
return
}
@@ -184,6 +188,10 @@ async function search(q) {
try {
const response = await searchApi.search(q)
const data = response.data.data
+ // The global endpoint carries the suggestion with the results, so no second
+ // call is needed here. A list page that has no such key uses the
+ // useSearchSuggestion composable against /search/suggest instead.
+ suggestion.value = data?.suggestion || null
// Handle ServiceNOW redirect
if (data?.redirect?.type === 'servicenow') {
@@ -212,6 +220,14 @@ async function search(q) {
}
}
+// Take the offered spelling: put it in the box and search it, so the person
+// sees what was searched rather than results for a word they did not type.
+function applySuggestion(word) {
+ searchInput.value = word
+ suggestion.value = null
+ performSearch()
+}
+
function performSearch() {
const term = searchInput.value.trim()
if (!term) return
diff --git a/frontend/src/views/securesend/SecuresendDetail.vue b/frontend/src/views/securesend/SecuresendDetail.vue
new file mode 100644
index 00000000..28604f38
--- /dev/null
+++ b/frontend/src/views/securesend/SecuresendDetail.vue
@@ -0,0 +1,149 @@
+
+
@@ -189,11 +190,21 @@ import PaginationBar from '../../components/PaginationBar.vue'
import { useToast } from '../../composables/toast'
import { apiError } from '../../utils/apiError'
import { useListQuery } from '@/composables/listQuery'
+import SearchSuggestion from '@/components/SearchSuggestion.vue'
const toast = useToast()
const vendors = ref([])
const loading = ref(true)
-const { page, search, setPage, setSearch } = useListQuery({ onChange: loadVendors })
+const { page, search, setPage, setSearch, suggestion, reportCount } = useListQuery({ onChange: loadVendors })
+
+// Take the offered spelling. setSearch only syncs the URL and the watcher
+// that reloads is suppressed because search.value already holds the new
+// term, so load directly.
+function applySuggestion(word) {
+ search.value = word
+ setSearch(word)
+ loadVendors()
+}
const totalPages = ref(1)
const perPage = ref(20)
@@ -232,6 +243,7 @@ async function loadVendors() {
const response = await vendorsApi.list(params)
vendors.value = response.data.data || []
+ reportCount(vendors.value.length)
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
} catch (err) {
console.error('Error loading vendors:', err)
diff --git a/plugins/computers/api/routes.py b/plugins/computers/api/routes.py
index fc9f26a4..94e3b828 100644
--- a/plugins/computers/api/routes.py
+++ b/plugins/computers/api/routes.py
@@ -639,6 +639,17 @@ def update_computer(computer_id: int):
asset = comp.asset
+ # An UPDATE may not blank the asset number. Create validates it and the
+ # column is NOT NULL, but the conflict check below only runs when the value
+ # DIFFERS, and '' never collides with anything - so a payload carrying an
+ # empty assetnumber wrote it straight through. A form that loaded blank
+ # (a failed reference load, a partial fetch) then saved the blank over a
+ # real record.
+ if 'assetnumber' in data and not (data['assetnumber'] or '').strip():
+ return error_response(
+ ErrorCodes.VALIDATION_ERROR,
+ 'assetnumber cannot be empty')
+
# Check for conflicting assetnumber
if 'assetnumber' in data and data['assetnumber'] != asset.assetnumber:
if Asset.query.filter_by(assetnumber=data['assetnumber']).first():
diff --git a/plugins/computers/client/Report-AssetToShopDB.ps1 b/plugins/computers/client/Report-AssetToShopDB.ps1
index d5f09d85..40b3079e 100644
--- a/plugins/computers/client/Report-AssetToShopDB.ps1
+++ b/plugins/computers/client/Report-AssetToShopDB.ps1
@@ -172,6 +172,7 @@ try {
# 2. C:\Enrollment\cmm\cmmid.txt - CMM bay id (e.g. CMM3).
# 3. C:\Enrollment\machine-number.txt - imaging value.
# keyence / genspect / part-marker have no per-bay id -> no machineNo sent.
+# Those bays name their DEVICE instead, in asset-id.txt below.
$machineNo = ''
foreach ($regPath in @(
'HKLM:\SOFTWARE\WOW6432Node\GE Aircraft Engines\DNC\General',
@@ -236,6 +237,45 @@ try {
Log "WARN could not read logged-in user: $($_.Exception.Message)"
}
+# The DEVICE hanging off this PC - the ONE enrollment file for every bay that
+# has no NTLARS/eDNC MachineNo registry to identify it. It names the asset and
+# nothing else: it does not say what kind of device that is, because the pc-type
+# already does, so the same file serves a part marker, a Keyence, a Genspect or
+# anything added later without a new file or a client change.
+#
+# Deliberately NOT machine-number.txt. machinenumber answers "which bay is
+# this" and is what GE-Enforce TargetMachineNumbers gates on, so naming a device
+# there would silently stop every bay-gated manifest entry from matching.
+#
+# WHY A FILE AT ALL: every other identity the server can fall back to is derived
+# from the PC - a prior link from this PC, or an asset number built from this
+# hostname - so none of them survive a PC SWAP. The replacement box mints a
+# SECOND record for the same physical device while the first keeps its history
+# under a dead PC's name. This file is what survives.
+#
+# The server ADOPTS the named asset, refuses a value it cannot resolve, and
+# refuses one that resolves to the wrong kind of thing - so a typo warns instead
+# of inventing a phantom device.
+$deviceId = ''
+$idFile = 'C:\Enrollment\asset-id.txt'
+if (Test-Path -LiteralPath $idFile) {
+ try {
+ $deviceId = ([string](Get-Content -LiteralPath $idFile -First 1 -ErrorAction Stop)).Trim()
+ if ($deviceId) { Log "deviceId from ${idFile}: $deviceId" }
+ } catch { Log "WARN could not read ${idFile}: $($_.Exception.Message)" }
+}
+# 0.12.0 shipped measuringtool-id.txt before the file was generalised. Read it
+# as a fallback so a bay already staged with one keeps reporting.
+if (-not $deviceId) {
+ $mtFile = 'C:\Enrollment\measuringtool-id.txt'
+ if (Test-Path -LiteralPath $mtFile) {
+ try {
+ $deviceId = ([string](Get-Content -LiteralPath $mtFile -First 1 -ErrorAction Stop)).Trim()
+ if ($deviceId) { Log "deviceId from ${mtFile} (pre-0.13 name): $deviceId" }
+ } catch { Log "WARN could not read ${mtFile}: $($_.Exception.Message)" }
+ }
+}
+
# imaging pc-type (gea-shopfloor-*), read from the enrollment file. Sent only
# when present; absent -> server leaves existing pctype untouched (a bare report
# never re-types a PC). Unmapped values return a warning, not an error.
@@ -302,6 +342,7 @@ $body = @{ hostname = $hostname }
if ($serialNumber) { $body['serialnumber'] = $serialNumber }
if ($machineNo) { $body['machinenumber'] = $machineNo }
if ($pcType) { $body['pctype'] = $pcType }
+if ($deviceId) { $body['deviceid'] = $deviceId }
if ($manufacturer) { $body['vendorname'] = $manufacturer }
if ($model) { $body['modelnumber'] = $model }
if ($osVersion) { $body['osname'] = $osVersion }
@@ -312,8 +353,8 @@ $body['lastcheckin'] = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:s
$json = $body | ConvertTo-Json -Compress -Depth 4
-Log ("POST {0} host={1} serial={2} pcType={3} make={4} model={5} os={6} boot={7} machineNo={8} user={9} ip={10}" -f `
- $ApiUrl, $hostname, $serialNumber, $pcType, $manufacturer, $model, $osVersion, $lastBootTime, $machineNo, $loggedInUser, $corpIp)
+Log ("POST {0} host={1} serial={2} pcType={3} make={4} model={5} os={6} boot={7} machineNo={8} user={9} ip={10} deviceId={11}" -f `
+ $ApiUrl, $hostname, $serialNumber, $pcType, $manufacturer, $model, $osVersion, $lastBootTime, $machineNo, $loggedInUser, $corpIp, $deviceId)
try {
$resp = Invoke-RestMethod -Uri $ApiUrl -Method Post -Body $json `
diff --git a/plugins/computers/frontend/views/PCsList.vue b/plugins/computers/frontend/views/PCsList.vue
index d5d8567d..361c2c20 100644
--- a/plugins/computers/frontend/views/PCsList.vue
+++ b/plugins/computers/frontend/views/PCsList.vue
@@ -66,6 +66,7 @@
No computers found
+
@@ -91,10 +92,20 @@ import { computersApi } from '@/api'
import PaginationBar from '@/components/PaginationBar.vue'
import { colorStyle } from '@/utils/colorStyle'
import { useListQuery } from '@/composables/listQuery'
+import SearchSuggestion from '@/components/SearchSuggestion.vue'
const computers = ref([])
const loading = ref(true)
-const { page, search, setPage, setSearch } = useListQuery({ onChange: loadComputers })
+const { page, search, setPage, setSearch, suggestion, reportCount } = useListQuery({ onChange: loadComputers })
+
+// Take the offered spelling. setSearch only syncs the URL and the watcher
+// that reloads is suppressed because search.value already holds the new
+// term, so load directly.
+function applySuggestion(word) {
+ search.value = word
+ setSearch(word)
+ loadComputers()
+}
const totalPages = ref(1)
const perPage = ref(20)
@@ -115,6 +126,7 @@ async function loadComputers() {
const response = await computersApi.list(params)
computers.value = response.data.data || []
+ reportCount(computers.value.length)
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
} catch (error) {
console.error('Error loading computers:', error)
diff --git a/plugins/computers/plugin.py b/plugins/computers/plugin.py
index f174b76c..96f1e6b6 100644
--- a/plugins/computers/plugin.py
+++ b/plugins/computers/plugin.py
@@ -106,12 +106,24 @@ class ComputersPlugin(BasePlugin):
'fields': {
'hostname': {'type': 'string', 'required': True},
'machinenumber': {'type': 'string'},
- # The instrument this PC drives, named by the enrollment file
- # measuringtool-id.txt. SEPARATE from machinenumber on purpose:
- # "which bay is this" and "which instrument is this" are
- # different facts, and machinenumber is what GE-Enforce
- # TargetMachineNumbers gates on - repointing it at a tool would
- # silently stop every bay-gated manifest entry matching.
+ # The DEVICE hanging off this PC, named by the enrollment file
+ # asset-id.txt. One file for every bay that has no NTLARS/eDNC
+ # MachineNo registry to identify it, and it does NOT say what
+ # kind of device it is: the pc-type already does, through
+ # SUBORDINATE_DEVICE_MAP.
+ #
+ # SEPARATE from machinenumber on purpose: "which bay is this"
+ # and "which device is this" are different facts, and
+ # machinenumber is what GE-Enforce TargetMachineNumbers gates
+ # on - repointing it at a device would silently stop every
+ # bay-gated manifest entry matching.
+ #
+ # Named deviceid rather than assetid because assetid is this
+ # contract's own RESPONSE field for a PC's integer primary key.
+ 'deviceid': {'type': 'string'},
+ # Shipped in 0.12.0, before the file was generalised. Accepted
+ # as an alias so a bay staged with measuringtool-id.txt keeps
+ # reporting; deviceid wins when both arrive.
'measuringtoolid': {'type': 'string'},
'pctype': {'type': 'string'},
'pcsubtype': {'type': 'string'},
@@ -394,14 +406,22 @@ class ComputersPlugin(BasePlugin):
# marker PC must not also claim the operation directly: several markers
# serve one operation number, so direct claims would fight over it. The
# marker is partof the operation and control propagates along that rail.
- partmarkers = self._sync_partmarker(comp, pctype, machinenumber,
- warnings)
+ # asset-id.txt, falling back to the 0.12.0 field name. One value feeds
+ # BOTH device paths: the file names the device and nothing else, so
+ # which sync consumes it follows from the pc-type.
+ deviceid = (payload.get('deviceid')
+ or payload.get('measuringtoolid') or '').strip() or None
- # PC -> machine link from the reported machine number.
- if partmarkers:
- machinelinks = []
- else:
+ partmarkers = self._sync_partmarker(comp, pctype, machinenumber,
+ warnings, deviceid=deviceid)
+
+ # PC -> machine link from the reported machine number. Only a PC that
+ # is NOT a marker PC (None above) takes it: a marker PC reaches its
+ # operation through `partof` on the marker, never by claiming it.
+ if partmarkers is None:
machinelinks = self._sync_machine_link(comp, machinenumber, warnings)
+ else:
+ machinelinks = []
# Printer relationship sync (only when the payload carried printer data).
printerlinks = self._sync_printer_links(comp.asset, payload, warnings)
@@ -410,7 +430,7 @@ class ComputersPlugin(BasePlugin):
# get an attached MeasuringTool asset auto-created and linked.
measuringtoollinks = self._sync_measuringtool_link(
comp.asset, pctype, hostname, warnings,
- measuringtoolid=payload.get('measuringtoolid'),
+ measuringtoolid=deviceid,
machinenumber=machinenumber)
db.session.commit()
@@ -426,7 +446,10 @@ class ComputersPlugin(BasePlugin):
'measuringtoollinkcount': len(measuringtoollinks),
'accessprotocols': accessprotocols,
'machinelinks': machinelinks,
- 'partmarkers': partmarkers,
+ # `or []`: None is the internal "not a marker PC" signal and
+ # has no business in the response, where it would read as a
+ # different state from "a marker PC that linked nothing".
+ 'partmarkers': partmarkers or [],
},
}
@@ -750,7 +773,132 @@ class ComputersPlugin(BasePlugin):
'machinenumber': machine.assetnumber,
'superseded': len(held)}]
- def _sync_partmarker(self, comp, pctype, machinenumber, warnings):
+ def _is_device_of_type(self, asset, spec):
+ """Is this asset the kind of device the pc-type's spec describes?
+
+ asset-id.txt names a device and says nothing about its type, so the
+ type is what proves the file points somewhere sensible. A measuring
+ tool is checked against the measuring-tool set; anything else is
+ checked against its machine type, which is where a Part Marker lives.
+ """
+ if asset is None:
+ return False
+ if spec.get('assettype') == 'measuring_tool':
+ return asset.assetid in self._measuringtool_assetids()
+ try:
+ from plugins.machines.models import Machine, MachineType
+ except ImportError:
+ return False
+ row = (db.session.query(MachineType.machinetype)
+ .join(Machine, Machine.machinetypeid == MachineType.machinetypeid)
+ .filter(Machine.assetid == asset.assetid).first())
+ return bool(row) and row[0] == spec['typename']
+
+ def _ensure_device_rows(self, deviceasset, spec, pcasset, controls, label,
+ warnings, active=True):
+ """Get-or-create the extension row and the PC -> device control link.
+
+ Shared by the named-device and minted paths: an adopted asset may have
+ no extension row, and the control link may already exist under another
+ label, so both are get-or-create rather than insert.
+ """
+ from shopdb.api import AssetRelationship
+ try:
+ from plugins.machines.models import Machine, MachineType
+ except ImportError:
+ warnings.append('machines plugin unavailable; {} device skipped'
+ .format(spec['typename']))
+ return
+ if not Machine.query.filter_by(assetid=deviceasset.assetid).first():
+ devicetype = MachineType.query.filter_by(
+ machinetype=spec['typename']).first()
+ if not devicetype:
+ devicetype = MachineType(machinetype=spec['typename'],
+ description=spec.get('description'))
+ db.session.add(devicetype)
+ db.session.flush()
+ db.session.add(Machine(assetid=deviceasset.assetid,
+ machinetypeid=devicetype.machinetypeid))
+ controlrow = AssetRelationship.query.filter_by(
+ sourceassetid=pcasset.assetid,
+ targetassetid=deviceasset.assetid,
+ relationshiptypeid=controls.relationshiptypeid).first()
+ if controlrow is not None:
+ controlrow.isactive = active
+ if not controlrow.label:
+ controlrow.label = label
+ else:
+ db.session.add(AssetRelationship(
+ sourceassetid=pcasset.assetid,
+ targetassetid=deviceasset.assetid,
+ relationshiptypeid=controls.relationshiptypeid,
+ label=label,
+ isactive=active))
+
+ def _device_incumbents_yield(self, deviceasset, pcasset, label, warnings):
+ """Settle a device already controlled by a DIFFERENT PC.
+
+ A physical instrument or marker hangs off one PC. Before this, both
+ paths only ever looked at links whose source was THIS PC, so a bay
+ naming a device another PC still holds produced two active `controls`
+ rows with no warning - the replaced PC kept its link forever, and a
+ file copied to a second bay claimed the device silently.
+
+ The machine link settled this years ago and this reuses its rule rather
+ than inventing a second one: an incumbent that has gone quiet past the
+ claim window, or been moved off In Use, has yielded, and its link is
+ ARCHIVED (never deleted, so "which PC drove this in June" stays
+ answerable). An incumbent still alive keeps the device, and the
+ challenger is recorded dormant instead of contesting a link that can
+ only have one holder.
+
+ Returns True when this PC may hold the device actively.
+ """
+ from shopdb.api import AssetRelationship, RelationshipType, Asset
+
+ controls = RelationshipType.query.filter_by(
+ relationshiptype='controls').first()
+ if not controls or not deviceasset or not pcasset:
+ return True
+
+ # EVERY collector-owned holder, not just ones under this label. On a
+ # CMM the instrument IS the reported bay, so the incumbent's link is the
+ # machine sync's row under MACHINE_LINK_ORIGIN. Filtering on our own
+ # label found no incumbent there, handed the challenger an active link,
+ # and left two live holders of one instrument - each invisible to the
+ # other because they were labelled differently.
+ #
+ # A row made by hand carries neither label and is deliberately NOT
+ # counted: a person's link is not the collector's to archive.
+ ourlabels = (label, MACHINE_LINK_ORIGIN, MEASURINGTOOL_LINK_ORIGIN,
+ PARTMARKER_LINK_ORIGIN)
+ others = AssetRelationship.query.filter(
+ AssetRelationship.targetassetid == deviceasset.assetid,
+ AssetRelationship.relationshiptypeid == controls.relationshiptypeid,
+ AssetRelationship.label.in_(ourlabels),
+ AssetRelationship.isactive.is_(True),
+ AssetRelationship.sourceassetid != pcasset.assetid,
+ ).all()
+ if not others:
+ return True
+
+ holding = []
+ for rel in others:
+ if self._incumbent_has_yielded(rel.sourceassetid):
+ rel.isactive = False
+ else:
+ holder = db.session.get(Asset, rel.sourceassetid)
+ holding.append(holder.assetnumber if holder else rel.sourceassetid)
+
+ if holding:
+ warnings.append(
+ '{} is still controlled by {}; recorded but not activated'
+ .format(deviceasset.assetnumber, ', '.join(str(h) for h in holding)))
+ return False
+ return True
+
+ def _sync_partmarker(self, comp, pctype, machinenumber, warnings,
+ deviceid=None):
"""Give a part-marker PC a marker asset of its own, under its operation.
Several Telesis markers serve one operation number - 0613, 0615 and
@@ -760,10 +908,19 @@ class ComputersPlugin(BasePlugin):
question about an individual marker (how many are there, which port,
which one failed) could be asked at all.
- One marker per PC, so the PC identifies the marker and the collector can
- mint it the same way it already mints a CMM or a Keyence unit for a
- metrology PC. The marker is a machine asset of type Part Marker, the PC
- `controls` it, and the marker is `partof` the operation it serves.
+ One marker per PC. The marker is a machine asset of type Part Marker,
+ the PC `controls` it, and the marker is `partof` the operation it
+ serves.
+
+ IDENTITY COMES FROM asset-id.txt FIRST, because everything else here is
+ derived from the PC and a PC is not permanent. Reuse looks for a prior
+ link from THIS PC asset and adoption looks up `-PARTMARKER`,
+ so both survive a re-image and neither survives a PC SWAP: a new
+ hostname is a new PC asset with no prior link and a predicted number
+ that has never existed, so the same physical Telesis unit gets a second
+ record while the first keeps its config and backup history under a dead
+ PC's name. That is the failure the metrology path already learned from,
+ where minting from the hostname left 43 instruments shadowed by twins.
That last rail is why the PC does not also claim the operation directly:
`controls` propagates through `partof` (seeded in reference-data), so
@@ -782,7 +939,16 @@ class ComputersPlugin(BasePlugin):
# Only a device that FILES UNDER an operation goes through here.
# A measuring tool is a subordinate device too, but it does not
# share a machine number, so it keeps the simpler path.
- return []
+ #
+ # NONE, not []. None is the ONLY answer that means "not a marker PC,
+ # run the ordinary machine link". Every other exit below means "this
+ # IS a marker PC" - possibly one that linked nothing - and returning
+ # [] there let the caller fall through to the machine link, which
+ # for a marker PC points at the OPERATION. That is the direct claim
+ # this whole path exists to prevent: several markers serve one
+ # operation, so two marker PCs would contest a link that can only
+ # have one holder, while the warning said "not linked".
+ return None
pcasset = comp.asset
controls = RelationshipType.query.filter_by(
@@ -801,8 +967,37 @@ class ComputersPlugin(BasePlugin):
label = spec['label']
- # Reuse this PC's existing device before minting one, so a re-image
- # never leaves a second device behind for the same physical unit.
+ # --- 1. an explicitly named device wins over everything -------------
+ #
+ # NAMING A DEVICE IS A COMMITMENT. If the bay says which device it
+ # drives and that name does not resolve, the answer is to link nothing
+ # and say so - NOT to fall through and mint -PARTMARKER, which is
+ # the hostname-derived twin this whole path exists to prevent. The
+ # warning used to be true about the typo'd number and false about the
+ # twin: it warned, then minted anyway.
+ #
+ # Silence still means "work it out", so a bay with no file keeps the
+ # reuse-then-mint behaviour it has always had.
+ named = (deviceid or '').strip()
+ namedasset = None
+ if named:
+ candidate = self._asset_by_number(named)
+ if candidate is None:
+ # Warn rather than invent: a typo must not mint a phantom
+ # device that nobody can account for.
+ warnings.append(
+ 'no asset for device {!r}; not linked'.format(named))
+ return []
+ if not self._is_device_of_type(candidate, spec):
+ # The name resolved, but not to this pc-type's device. Refuse
+ # and say which asset it hit, rather than filing an unrelated
+ # asset under a device label the collector also owns.
+ warnings.append(
+ 'asset {!r} is not a {}; not linked'.format(
+ candidate.assetnumber, spec['typename']))
+ return []
+ namedasset = candidate
+
existing = AssetRelationship.query.filter(
AssetRelationship.sourceassetid == pcasset.assetid,
AssetRelationship.relationshiptypeid == controls.relationshiptypeid,
@@ -811,9 +1006,32 @@ class ComputersPlugin(BasePlugin):
reuse = next((rel for rel in existing if rel.isactive), None) \
or (existing[0] if existing else None)
- if reuse:
- reuse.isactive = True
+ if namedasset is not None:
+ # The file is authoritative FOR THIS PC, but the device may still be
+ # held by another one. Settle that first: a yielded incumbent is
+ # archived, a live one keeps it and this link is recorded dormant.
+ mayhold = self._device_incumbents_yield(
+ namedasset, pcasset, label, warnings)
+ # The file is authoritative. A PC that previously minted its own
+ # marker and is now told the real one keeps only the named link;
+ # the stale link is archived by the one-marker-per-PC sweep below.
+ markerasset = namedasset
+ reuse = next((rel for rel in existing
+ if rel.targetassetid == namedasset.assetid), None)
+ if reuse is not None:
+ reuse.isactive = True
+ self._ensure_device_rows(markerasset, spec, pcasset, controls,
+ label, warnings, active=mayhold)
+ elif reuse:
+ # Re-check incumbency HERE too, not only on the named path. A
+ # challenger recorded dormant keeps its row, so the cycle after its
+ # asset-id.txt goes missing found that row as `reuse` and flipped it
+ # active with no incumbent check and no warning - two live holders,
+ # arrived at by deleting a file. _sync_machine_link never had this
+ # hole because it re-checks unconditionally.
markerasset = db.session.get(Asset, reuse.targetassetid)
+ reuse.isactive = self._device_incumbents_yield(
+ markerasset, pcasset, label, warnings)
else:
coretype = AssetType.query.filter_by(
assettype=spec['assettype']).first()
@@ -852,26 +1070,8 @@ class ComputersPlugin(BasePlugin):
db.session.add(markerasset)
db.session.flush()
- # The extension row may be missing on an adopted asset, and the link
- # may already exist under another label - both get-or-create.
- if not Machine.query.filter_by(assetid=markerasset.assetid).first():
- db.session.add(Machine(assetid=markerasset.assetid,
- machinetypeid=devicetype.machinetypeid))
- controlrow = AssetRelationship.query.filter_by(
- sourceassetid=pcasset.assetid,
- targetassetid=markerasset.assetid,
- relationshiptypeid=controls.relationshiptypeid).first()
- if controlrow is not None:
- controlrow.isactive = True
- if not controlrow.label:
- controlrow.label = label
- else:
- db.session.add(AssetRelationship(
- sourceassetid=pcasset.assetid,
- targetassetid=markerasset.assetid,
- relationshiptypeid=controls.relationshiptypeid,
- label=label,
- isactive=True))
+ self._ensure_device_rows(markerasset, spec, pcasset, controls,
+ label, warnings)
# One marker per PC: archive any other collector marker link.
for rel in existing:
@@ -1179,10 +1379,14 @@ class ComputersPlugin(BasePlugin):
candidate = self._asset_by_number(named)
if candidate is None:
# Warn rather than invent: a typo in the file must not mint a
- # phantom instrument that nobody can account for.
+ # phantom instrument that nobody can account for. RETURN, do
+ # not fall through - minting -CMM here would create the
+ # hostname-derived twin this resolution order exists to avoid,
+ # while the warning claimed nothing was linked.
warnings.append(
'no asset for measuring tool {!r}; not linked'.format(named))
- elif candidate.assetid not in self._measuringtool_assetids():
+ return []
+ if candidate.assetid not in self._measuringtool_assetids():
# The name resolved, but not to an instrument. measuringtool-id
# .txt holding a machine number would otherwise link the PC to
# that MACHINE under a measuring-tool label - a link that reads
@@ -1191,8 +1395,8 @@ class ComputersPlugin(BasePlugin):
warnings.append(
'asset {!r} is not a measuring tool; not linked'.format(
candidate.assetnumber))
- else:
- adopted = candidate
+ return []
+ adopted = candidate
# --- 2. a prior collector link (reactivate + retype) -----------------
reuse = next((rel for rel in existing if rel.isactive), None) \
@@ -1206,6 +1410,11 @@ class ComputersPlugin(BasePlugin):
pcasset, controls, machinenumber)
if adopted is not None:
+ # Settle any OTHER PC still holding this instrument first: a yielded
+ # incumbent is archived, a live one keeps it and this link is
+ # recorded dormant rather than becoming a second active holder.
+ mayhold = self._device_incumbents_yield(
+ adopted, pcasset, MEASURINGTOOL_LINK_ORIGIN, warnings)
# CLAIM the existing link rather than adding a second one. A row for
# (pc, tool, controls) usually already exists - that is how the tool
# was found - and assetrelationships is unique on exactly that
@@ -1229,20 +1438,24 @@ class ComputersPlugin(BasePlugin):
# is ours from here, so a later cycle reuses it instead of
# minting.
claimed.label = MEASURINGTOOL_LINK_ORIGIN
- claimed.isactive = True
+ claimed.isactive = mayhold
else:
db.session.add(AssetRelationship(
sourceassetid=pcasset.assetid,
targetassetid=adopted.assetid,
relationshiptypeid=controls.relationshiptypeid,
- label=MEASURINGTOOL_LINK_ORIGIN))
+ label=MEASURINGTOOL_LINK_ORIGIN,
+ isactive=mayhold))
if adopted.measuringtool and tooltype:
adopted.measuringtool.measuringtooltypeid = \
tooltype.measuringtooltypeid
targetid = adopted.assetid
elif reuse:
- reuse.isactive = True
+ # Same re-check as the marker path: a dormant challenger must not be
+ # promoted just because its enrollment file stopped being readable.
toolasset = db.session.get(Asset, reuse.targetassetid)
+ reuse.isactive = self._device_incumbents_yield(
+ toolasset, pcasset, MEASURINGTOOL_LINK_ORIGIN, warnings)
if toolasset and toolasset.measuringtool and tooltype:
toolasset.measuringtool.measuringtooltypeid = \
tooltype.measuringtooltypeid
diff --git a/plugins/knowledgebase/api/routes.py b/plugins/knowledgebase/api/routes.py
index bae722bb..70df875b 100644
--- a/plugins/knowledgebase/api/routes.py
+++ b/plugins/knowledgebase/api/routes.py
@@ -43,6 +43,39 @@ def _visible_articles():
KnowledgeBase.appid.notin_(retired)))
+def _search_clause(search):
+ """Articles containing EVERY word of the search, each word in the title, the
+ keywords, or the topic's name, in any order.
+
+ A single `ilike('%CMM Community%')` needs the whole phrase contiguous in ONE
+ field. An article tagged 'community' under the CMM topic has 'CMM' only in
+ its topic's name and 'Community' only in its keywords, so it matched nothing
+ and the search came back empty. Splitting the query and requiring each word
+ somewhere is what people mean when they type two words.
+
+ Kept in step with `_word_match` in shopdb/core/api/search.py, which does the
+ same job for global search. Two copies because the core helper is internal
+ and not on the plugin contract surface (ADR-001).
+
+ Active applications only for the topic. A retired application is not a topic
+ anyone should be offered: matching its name surfaced its articles and printed
+ the retired app as their subject, which reads as though it were still in
+ service.
+ """
+ words = [w for w in search.split() if w] or ['']
+ return db.and_(*[
+ db.or_(
+ KnowledgeBase.shortdescription.ilike(f'%{w}%'),
+ KnowledgeBase.keywords.ilike(f'%{w}%'),
+ KnowledgeBase.appid.in_(
+ db.session.query(Application.appid).filter(
+ Application.appname.ilike(f'%{w}%'),
+ Application.isactive.is_(True))),
+ )
+ for w in words
+ ])
+
+
@knowledgebase_bp.route('', methods=['GET'])
@jwt_required(optional=True)
def list_articles():
@@ -51,26 +84,11 @@ def list_articles():
query = _visible_articles()
- # Search: title, keywords, and the topic (its Application's name). The topic
- # is matched via an appid subquery instead of a join so it does not collide
- # with the sort=='topic' join below; articles with no app just miss that
- # clause and still match on title/keywords.
+ # The topic is matched via an appid subquery instead of a join so it does not
+ # collide with the sort=='topic' join below; articles with no app just miss
+ # that clause and still match on title/keywords.
if search := request.args.get('search'):
- like = f'%{search}%'
- # Active applications only. A retired application is not a topic anyone
- # should be offered: matching its name surfaced its articles and printed
- # the retired app as their subject, which reads as though it were still
- # in service.
- topic_appids = db.session.query(Application.appid).filter(
- Application.appname.ilike(like),
- Application.isactive.is_(True))
- query = query.filter(
- db.or_(
- KnowledgeBase.shortdescription.ilike(like),
- KnowledgeBase.keywords.ilike(like),
- KnowledgeBase.appid.in_(topic_appids)
- )
- )
+ query = query.filter(_search_clause(search))
# Filter by topic/application
if appid := request.args.get('appid'):
diff --git a/plugins/knowledgebase/frontend/views/KnowledgeBaseList.vue b/plugins/knowledgebase/frontend/views/KnowledgeBaseList.vue
index 01c048ca..dcbbfc6f 100644
--- a/plugins/knowledgebase/frontend/views/KnowledgeBaseList.vue
+++ b/plugins/knowledgebase/frontend/views/KnowledgeBaseList.vue
@@ -88,6 +88,7 @@
No articles found
+
@@ -112,12 +113,22 @@ import { ref, onMounted } from 'vue'
import { knowledgebaseApi, applicationsApi } from '@/api'
import PaginationBar from '@/components/PaginationBar.vue'
import { useListQuery } from '@/composables/listQuery'
+import SearchSuggestion from '@/components/SearchSuggestion.vue'
const loading = ref(true)
const articles = ref([])
const topics = ref([])
const stats = ref(null)
-const { page, search, setPage, setSearch } = useListQuery({ onChange: loadArticles })
+const { page, search, setPage, setSearch, suggestion, reportCount } = useListQuery({ onChange: loadArticles })
+
+// Take the offered spelling. setSearch only syncs the URL and the watcher
+// that reloads is suppressed because search.value already holds the new
+// term, so load directly.
+function applySuggestion(word) {
+ search.value = word
+ setSearch(word)
+ loadArticles()
+}
const perPage = ref(20)
const totalPages = ref(1)
const topicFilter = ref('')
@@ -161,6 +172,7 @@ async function loadArticles() {
const response = await knowledgebaseApi.list(params)
articles.value = response.data.data || []
+ reportCount(articles.value.length)
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
} catch (error) {
console.error('Error loading articles:', error)
diff --git a/plugins/machines/api/routes.py b/plugins/machines/api/routes.py
index b6af6721..7667add1 100644
--- a/plugins/machines/api/routes.py
+++ b/plugins/machines/api/routes.py
@@ -437,6 +437,17 @@ def update_machine(machine_id: int):
asset = mach.asset
+ # An UPDATE may not blank the asset number. Create validates it and the
+ # column is NOT NULL, but the conflict check below only runs when the value
+ # DIFFERS, and '' never collides with anything - so a payload carrying an
+ # empty assetnumber wrote it straight through. A form that loaded blank
+ # (a failed reference load, a partial fetch) then saved the blank over a
+ # real record.
+ if 'assetnumber' in data and not (data['assetnumber'] or '').strip():
+ return error_response(
+ ErrorCodes.VALIDATION_ERROR,
+ 'assetnumber cannot be empty')
+
# Check for conflicting assetnumber
if 'assetnumber' in data and data['assetnumber'] != asset.assetnumber:
if Asset.query.filter_by(assetnumber=data['assetnumber']).first():
diff --git a/plugins/machines/frontend/views/MachineForm.vue b/plugins/machines/frontend/views/MachineForm.vue
index 1b396df8..efe0846c 100644
--- a/plugins/machines/frontend/views/MachineForm.vue
+++ b/plugins/machines/frontend/views/MachineForm.vue
@@ -7,6 +7,11 @@
Loading...
+
+
{{ error }}
+
+
-
- Asset #
- {{ device.assetnumber }}
+
+ Asset #
+ {{ device.assetnumber }}
-
- Serial
- {{ device.serialnumber }}
+
+ Serial
+ {{ device.serialnumber }}
-
- Location
- {{ device.locationname }}
+
+ Location
+ {{ device.locationname }}
-
- Business Unit
- {{ device.businessunitname }}
+
+ Business Unit
+ {{ device.businessunitname }}
diff --git a/plugins/network/frontend/views/NetworkDeviceForm.vue b/plugins/network/frontend/views/NetworkDeviceForm.vue
index c53b7a9c..3559b7e2 100644
--- a/plugins/network/frontend/views/NetworkDeviceForm.vue
+++ b/plugins/network/frontend/views/NetworkDeviceForm.vue
@@ -24,9 +24,18 @@
type="text"
class="form-control"
:required="isEdit"
- :disabled="isEdit"
+ :disabled="isEdit && !assetNumberMissing"
:placeholder="generatedAssetNumber || 'leave blank to generate from the name'"
/>
+
+
+ This device has no asset number. Give it one to save.
+
Will be created as {{ generatedAssetNumber }}
@@ -161,7 +170,7 @@
@@ -393,8 +402,21 @@ const generatedAssetNumber = computed(() => {
if (name.toUpperCase().startsWith(prefix + '-')) return name
return `${prefix}-${name}`
})
+// True when the record loaded WITHOUT an asset number, which unlocks the field
+// so it can be repaired. Captured at load: once typed, the field must stay open.
+const assetNumberMissing = ref(false)
+
const vendors = ref([])
const models = ref([])
+
+// Models belong to a vendor, so picking one narrows the list - the same
+// behaviour PCForm, MachineForm and PrinterForm have. With no vendor chosen the
+// whole catalogue shows, rather than an empty dropdown that reads as "no models
+// exist" when it means "pick a vendor first".
+const filteredModels = computed(() => {
+ if (!form.value.vendorid) return models.value
+ return models.value.filter(m => m.vendorid === form.value.vendorid)
+})
const locations = ref([])
const statuses = ref([])
const businessUnits = ref([])
@@ -471,6 +493,9 @@ async function loadDevice() {
// Populate form with existing data
form.value.assetnumber = data.assetnumber || ''
+ // Remember that it arrived empty, so the field stays editable while the
+ // user types the number this record should have had.
+ assetNumberMissing.value = !(data.assetnumber || '').trim()
form.value.name = data.name || ''
form.value.serialnumber = data.serialnumber || ''
form.value.gaugelabreference = data.gaugelabreference || ''
diff --git a/plugins/network/frontend/views/NetworkDevicesList.vue b/plugins/network/frontend/views/NetworkDevicesList.vue
index 8195a9af..e6e5719f 100644
--- a/plugins/network/frontend/views/NetworkDevicesList.vue
+++ b/plugins/network/frontend/views/NetworkDevicesList.vue
@@ -97,6 +97,7 @@
No network devices found
+
@@ -122,13 +123,23 @@ import { colorStyle } from "@/utils/colorStyle"
import { networkApi, vendorsApi, locationsApi } from '@/api'
import PaginationBar from '@/components/PaginationBar.vue'
import { useListQuery } from '@/composables/listQuery'
+import SearchSuggestion from '@/components/SearchSuggestion.vue'
const devices = ref([])
const deviceTypes = ref([])
const vendors = ref([])
const locations = ref([])
const loading = ref(true)
-const { page, search, setPage, setSearch } = useListQuery({ onChange: loadDevices })
+const { page, search, setPage, setSearch, suggestion, reportCount } = useListQuery({ onChange: loadDevices })
+
+// Take the offered spelling. setSearch only syncs the URL and the watcher
+// that reloads is suppressed because search.value already holds the new
+// term, so load directly.
+function applySuggestion(word) {
+ search.value = word
+ setSearch(word)
+ loadDevices()
+}
const selectedType = ref(null)
const vendorFilter = ref('')
const locationFilter = ref('')
@@ -220,6 +231,7 @@ async function loadDevices() {
const response = await networkApi.list(params)
devices.value = response.data.data || []
+ reportCount(devices.value.length)
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
} catch (error) {
console.error('Error loading network devices:', error)
diff --git a/plugins/printers/api/asset_routes.py b/plugins/printers/api/asset_routes.py
index 158ffe82..8181a747 100644
--- a/plugins/printers/api/asset_routes.py
+++ b/plugins/printers/api/asset_routes.py
@@ -1963,6 +1963,17 @@ def update_printer(printer_id: int):
asset = printer.asset
+ # An UPDATE may not blank the asset number. Create validates it and the
+ # column is NOT NULL, but the conflict check below only runs when the value
+ # DIFFERS, and '' never collides with anything - so a payload carrying an
+ # empty assetnumber wrote it straight through. A form that loaded blank
+ # (a failed reference load, a partial fetch) then saved the blank over a
+ # real record.
+ if 'assetnumber' in data and not (data['assetnumber'] or '').strip():
+ return error_response(
+ ErrorCodes.VALIDATION_ERROR,
+ 'assetnumber cannot be empty')
+
# Check for conflicting assetnumber
if 'assetnumber' in data and data['assetnumber'] != asset.assetnumber:
if Asset.query.filter_by(assetnumber=data['assetnumber']).first():
@@ -2098,7 +2109,9 @@ def _annotate_supply(supply, vendor_name, modelnumberid):
Waste cartridge direction depends on vendor, so classification lives in
the supply_parts helper. Part numbers come from the modelsupplies table.
"""
- level = supply.get('level', 0)
+ # .get('level') with no default: a supply with no reading carries None, and
+ # a 0 default would put back the very substitution this reports around.
+ level = supply.get('level')
name = supply.get('name', 'Unknown')
supplytype = derivesupplytype(name)
color = derivecolor(name, supply.get('color'))
@@ -2106,6 +2119,7 @@ def _annotate_supply(supply, vendor_name, modelnumberid):
return {
'name': name,
'level': level,
+ 'lastseen': supply.get('lastseen'),
'color': color,
'supplytype': supplytype,
'status': cls['status'],
@@ -2149,6 +2163,7 @@ def _get_low_supplies_data():
results = []
total_checked = 0
+ unknown_printers = 0
for printer, asset, comm, vendor, model in unique_printers:
supplies = service.getsuppliesbyip_cached(comm.ipaddress)
@@ -2167,13 +2182,24 @@ def _get_low_supplies_data():
# shown it. The whole report exists to answer "what needs replacing".
annotated = []
has_low = False
+ has_unknown = False
for s in supplies:
item = _annotate_supply(s, vendor_name, modelnumberid)
if item['status'] == 'ok':
continue
+ # A supply nobody could read is not a supply that needs replacing,
+ # so it does not join the list of things to act on. It is counted
+ # instead: a printer that has gone quiet should be visible as that,
+ # not disappear from the report and not masquerade as empty.
+ if item['status'] == 'unknown':
+ has_unknown = True
+ continue
has_low = True
annotated.append(item)
+ if has_unknown:
+ unknown_printers += 1
+
if has_low:
# location name for the report row
# Via the relationship, the way the printers list does it. The
@@ -2211,7 +2237,12 @@ def _get_low_supplies_data():
'summary': {
'total_checked': total_checked,
'low': low_count,
- 'critical': critical_count
+ 'critical': critical_count,
+ # Printers with at least one unreadable supply. They are NOT in
+ # 'printers' above - nothing about them needs ordering - but a
+ # count that only ever went up when a cartridge ran low would hide
+ # a fleet quietly going dark.
+ 'unknown': unknown_printers
}
}
diff --git a/plugins/printers/frontend/views/PrinterDetail.vue b/plugins/printers/frontend/views/PrinterDetail.vue
index a57b780c..88e05507 100644
--- a/plugins/printers/frontend/views/PrinterDetail.vue
+++ b/plugins/printers/frontend/views/PrinterDetail.vue
@@ -215,8 +215,14 @@
@@ -245,9 +272,13 @@ function colorLabel(color) {
return color.charAt(0).toUpperCase() + color.slice(1)
}
+// The whole part row, not just its number: the table shows the name and keeps
+// the number for the hover, and both come from here. Several capacity tiers can
+// match; the first is the standard one, which is what orderlist() groups on and
+// what the low-supplies report shows first.
function firstPart(cartridge) {
const parts = cartridge.partnumbers || []
- return parts.length ? parts[0].partnumber : null
+ return parts.length ? parts[0] : null
}
function levelText(cartridge) {
@@ -262,11 +293,32 @@ function barWidth(cartridge) {
return Math.max(0, Math.min(100, level)) + '%'
}
+// daysleft is fractional, so the last day of a cartridge's life is readable
+// instead of collapsing to "empty". Only an actual zero is empty; anything above
+// it has time left and the row says how much, in whatever unit carries meaning.
+const HOURS_PER_DAY = 24
+const MINUTES_PER_HOUR = 60
+
function daysText(cartridge) {
- if (cartridge.daysleft == null) return '-'
- if (cartridge.daysleft === 0) return 'empty'
- if (cartridge.daysleft === 1) return '1 day'
- return cartridge.daysleft + ' days'
+ const days = cartridge.daysleft
+ if (days == null) return '-'
+ if (days <= 0) return 'empty'
+
+ if (days < 1) {
+ const hours = days * HOURS_PER_DAY
+ // Under an hour, hours would round to 0 and read as empty again.
+ if (hours < 1) {
+ const minutes = Math.max(1, Math.round(hours * MINUTES_PER_HOUR))
+ return `${minutes} min`
+ }
+ const rounded = Math.round(hours)
+ return rounded === 1 ? '1 hour' : `${rounded} hours`
+ }
+
+ // Whole days from here. The estimate is not precise enough for "3.7 days" to
+ // mean more than "about 4", and a column of decimals is harder to scan.
+ const rounded = Math.round(days)
+ return rounded === 1 ? '1 day' : `${rounded} days`
}
function orderKey(item) {
@@ -362,8 +414,10 @@ onMounted(load)
text-align: left;
}
.unmapped-block .order-list li { border-top: 1px dashed var(--border); }
-.partnumber { font-family: monospace; font-weight: 700; }
-.partnumber.unmapped { font-weight: 400; color: var(--warning); }
+/* A name is prose, not a code, so it does not get the monospace the part number
+ used to carry here. The help cursor is the only signal that the number is on
+ the hover. Replaced .partnumber, which nothing renders any more. */
+.supplyname { font-weight: 700; cursor: help; }
.order-detail { font-size: 0.9rem; }
.order-printers { font-size: 0.8rem; margin-left: auto; text-align: right; }
@@ -424,6 +478,10 @@ onMounted(load)
.days.empty { color: var(--danger); }
.days.soon { color: var(--warning); }
.small { font-size: 0.8rem; }
+
+/* Marks a rate the intervals do not agree on. Deliberately quiet - it qualifies
+ the number beside it rather than competing with the urgency bands. */
+.unstable { margin-left: 2px; font-weight: 600; cursor: help; }
.empty-state { padding: 2rem; text-align: center; }
.footnote { margin-top: 1rem; font-size: 0.85rem; }
diff --git a/plugins/printers/frontend/views/tonerForecastNames.spec.js b/plugins/printers/frontend/views/tonerForecastNames.spec.js
new file mode 100644
index 00000000..038d62f3
--- /dev/null
+++ b/plugins/printers/frontend/views/tonerForecastNames.spec.js
@@ -0,0 +1,172 @@
+import { describe, it, expect, vi } from 'vitest'
+import { mount, flushPromises } from '@vue/test-utils'
+
+// The forecast identifies a cartridge by its NAME - '414X Black' - and keeps the
+// part number on the hover. A part number alone ('W2020X') only identifies the
+// toner to someone who already knows it, and the two places that name a supply
+// (the order list and the Part column) had shown nothing else.
+//
+// The number has to survive somewhere: it is what an order is placed against.
+// It stays in the title attribute and, verbatim and unlabelled, in the text that
+// 'Copy list' writes to the clipboard. These specs pin both halves.
+//
+// marketingname is nullable, so every display falls back to the number.
+
+vi.mock('@/api', () => ({
+ printersApi: { supplyForecast: vi.fn() },
+}))
+vi.mock('@/utils/apiError', () => ({ apiError: (e) => String(e) }))
+
+import { printersApi } from '@/api'
+import TonerForecast from './TonerForecast.vue'
+
+function payload({ marketingname = '414X Black', partnumber = 'W2020X' } = {}) {
+ const part = { partnumber, marketingname, capacitytier: 'high', pageyield: 7500 }
+ return {
+ data: {
+ data: {
+ available: true,
+ days: 90,
+ horizondays: 14,
+ summary: { bands: { empty: 1 }, replacements: 3, toorder: 1 },
+ cartridges: [{
+ printerid: 1, printername: 'GageLab-Xerox-C415', assetnumber: 'P-1',
+ ipaddress: '10.49.215.12', name: 'Black toner', color: 'black',
+ currentlevel: 4, band: 'empty', daysleft: 0, burnrateperday: 1.8,
+ rateunstable: false, replacements: 3, basisdays: 12,
+ partnumbers: [part],
+ }],
+ unestimated: [],
+ orderlist: [{
+ partnumber, marketingname, color: 'black', supplytype: 'toner',
+ model: 'Versalink C415', alternates: [], quantity: 2,
+ printers: [{ printerid: 1, printername: 'GageLab-Xerox-C415', daysleft: 0 }],
+ }],
+ },
+ },
+ }
+}
+
+async function render(overrides) {
+ printersApi.supplyForecast.mockResolvedValue(payload(overrides))
+ const wrapper = mount(TonerForecast, {
+ global: { stubs: { 'router-link': { template: '' } } },
+ })
+ await flushPromises()
+ return wrapper
+}
+
+describe('the supply is named, not numbered', () => {
+ it('shows the name in the order list', async () => {
+ const wrapper = await render()
+ const name = wrapper.find('.order-list .supplyname')
+ expect(name.text()).toBe('414X Black')
+ })
+
+ it('shows the name in the Part column', async () => {
+ const wrapper = await render()
+ const cells = wrapper.findAll('tbody .supplyname')
+ expect(cells.length).toBeGreaterThan(0)
+ expect(cells[0].text()).toBe('414X Black')
+ })
+
+ it('keeps the part number on the hover, both places', async () => {
+ const wrapper = await render()
+ for (const el of wrapper.findAll('.supplyname')) {
+ expect(el.attributes('title')).toBe('W2020X')
+ }
+ })
+
+ it('never renders the part number as the visible label', async () => {
+ const wrapper = await render()
+ for (const el of wrapper.findAll('.supplyname')) {
+ expect(el.text()).not.toBe('W2020X')
+ }
+ })
+})
+
+describe('a supply with no name on file', () => {
+ it('falls back to the part number rather than showing nothing', async () => {
+ const wrapper = await render({ marketingname: null })
+ for (const el of wrapper.findAll('.supplyname')) {
+ expect(el.text()).toBe('W2020X')
+ }
+ })
+})
+
+describe('what purchasing receives is unchanged', () => {
+ it('copies the part number, not the name', async () => {
+ const written = []
+ Object.assign(navigator, {
+ clipboard: { writeText: (text) => { written.push(text); return Promise.resolve() } },
+ })
+ const wrapper = await render()
+ await wrapper.find('.order-head button').trigger('click')
+ await flushPromises()
+ expect(written).toHaveLength(1)
+ expect(written[0]).toContain('W2020X')
+ expect(written[0]).not.toContain('414X Black')
+ })
+})
+
+// --- how long is left ------------------------------------------------------
+//
+// daysleft used to be int(level / rate), so anything under a full day
+// truncated to 0 and the view printed "empty". A cartridge with six hours in it
+// still prints, and the row exists to say how long. Only a real zero is empty.
+
+describe('the countdown reads in whatever unit carries meaning', () => {
+ async function daysCell(daysleft) {
+ const band = daysleft === null ? 'soon' : daysleft <= 0 ? 'empty' : 'soon'
+ const base = payload()
+ base.data.data.cartridges[0].daysleft = daysleft
+ base.data.data.cartridges[0].band = band
+ printersApi.supplyForecast.mockResolvedValue(base)
+ const wrapper = mount(TonerForecast, {
+ global: { stubs: { 'router-link': { template: '' } } },
+ })
+ await flushPromises()
+ // Only the 'empty' band starts expanded - what is already out is what the
+ // page opens on. Every band renders a header whether or not it holds
+ // anything, so the right one has to be picked by its key rather than by
+ // position; clicking the first would close 'empty' instead.
+ if (band !== 'empty') {
+ const head = wrapper.findAll('.band-head')
+ .find(b => b.find(`.band-count.${band}`).exists())
+ await head.trigger('click')
+ await flushPromises()
+ }
+ return wrapper.find('tbody .days').text()
+ }
+
+ it('says empty only for an actual zero', async () => {
+ expect(await daysCell(0)).toBe('empty')
+ })
+
+ it('reads in hours below a day', async () => {
+ expect(await daysCell(0.25)).toBe('6 hours')
+ })
+
+ it('does not pluralise a single hour', async () => {
+ expect(await daysCell(1 / 24)).toBe('1 hour')
+ })
+
+ it('reads in minutes below an hour, never as empty', async () => {
+ const text = await daysCell(0.02)
+ expect(text).toBe('29 min')
+ expect(text).not.toBe('empty')
+ })
+
+ it('never rounds a live cartridge down to empty', async () => {
+ expect(await daysCell(0.0001)).toBe('1 min')
+ })
+
+ it('reads in whole days above a day', async () => {
+ expect(await daysCell(3.7)).toBe('4 days')
+ expect(await daysCell(1.2)).toBe('1 day')
+ })
+
+ it('still shows a dash when there is no estimate', async () => {
+ expect(await daysCell(null)).toBe('-')
+ })
+})
diff --git a/plugins/printers/plugin.py b/plugins/printers/plugin.py
index ae95a329..058c8390 100644
--- a/plugins/printers/plugin.py
+++ b/plugins/printers/plugin.py
@@ -323,7 +323,9 @@ class PrintersPlugin(BasePlugin):
click.echo(f'Supply levels for {ip}:')
for supply in supplies:
- click.echo(f" {supply['name']}: {supply['level']}%")
+ level = supply.get('level')
+ shown = 'unknown' if level is None else f'{level}%'
+ click.echo(f" {supply['name']}: {shown}")
@printerscli.command('seed-supplies')
def seedsuppliescommand():
diff --git a/plugins/printers/services/supply_history.py b/plugins/printers/services/supply_history.py
index 9917420e..26f984ce 100644
--- a/plugins/printers/services/supply_history.py
+++ b/plugins/printers/services/supply_history.py
@@ -20,21 +20,70 @@ from datetime import datetime, timezone
# new cartridge rather than noise.
REPLACEMENT_RISE = 10
+# A replacement must also LAND high. A fresh cartridge reads near full, so a
+# rise that stops mid-range is a gauge bouncing, not a swap. Without this the
+# count was any +10 between readings, and the two common noise shapes both
+# scored: a supply reading 0 or near-0 while it was out of the machine, then the
+# real level again (0 -> 60 counts as +60), and a coarse gauge ticking back up
+# after a reseat or a power cycle. That is how one cartridge claimed five
+# changes in ninety days.
+#
+# A site that fits PART-USED cartridges will under-count with this rule. That is
+# the right way round: a missed swap widens the run and slows the estimate,
+# while a phantom swap resets the run and throws the estimate away entirely.
+NEW_CARTRIDGE_LEVEL = 80
+
+# How far ABOVE its previous level a reading may come back and still count as a
+# recovery rather than a new cartridge. Small, because it exists for gauge
+# noise: a real swap returns near full, well past this.
+RECOVERY_TOLERANCE = 2
+
+# How long a dip may last and still be a bad reading rather than real use.
+# Levels alone cannot separate the two: a swap to full, ordinary consumption,
+# then another swap has the same SHAPE as an outage that recovers. What differs
+# is elapsed time. A supply out of the machine, a door open or a bad poll spans
+# minutes to an hour or two at the few-minute polling these items use; a level
+# that stays down for days is genuinely down, and deleting those readings would
+# hide a real empty period.
+MAX_DIP_HOURS = 6
+
# Below this many readings a slope is arithmetic, not evidence. Two points
# through a coarse gauge can "prove" any rate at all.
MIN_POINTS_FOR_ESTIMATE = 4
+# A rate needs TIME behind it, not just readings. Supply items are often polled
+# every few minutes, so four readings can span a quarter of an hour - and a 2
+# point drop across fifteen minutes extrapolates to nearly 200 percent a day,
+# which is how a cartridge sitting at 82 percent gets forecast to run out in a
+# fortnight. The history window can be short for the same reason: the fetch
+# keeps the newest rows up to a per-item cap, so a fast-polled item returns days
+# rather than the ninety asked for.
+#
+# Two days is the smallest span that survives a printer's daily rhythm - a
+# single heavy morning does not become the whole picture.
+MIN_DAYS_FOR_ESTIMATE = 2
+
# Many printers report in 10% steps, so a fortnight can pass on one plateau.
# Without a minimum observed drop the slope reads as zero and the forecast
# says "never", which is worse than saying nothing.
MIN_DROP_FOR_ESTIMATE = 2
-# At or below this, the cartridge is done and the arithmetic stops being the
-# useful answer. A supply sitting at 1% that drains a tenth of a point a day
-# computes to ten days; a printer at 1% is out of toner as far as anyone
-# standing at it is concerned, and it is what should be ordered first. Rate is
-# still reported - only the days-left figure is floored.
-EMPTY_LEVEL = 5
+# How far the fastest and slowest intervals may differ before a single rate
+# stops being a fair summary. A cartridge that ran at 10 percent/day for two
+# days and 0.2 percent/day since is not described by any one number, and a
+# precise-looking figure invites more trust than it has earned.
+RATE_SPREAD_FACTOR = 5
+
+# Empty means empty. This was 5, on the reasoning that a printer at 1% is out
+# of toner as far as anyone standing at it is concerned - but flooring the
+# days-left of everything at or below 5% threw away the only number the row
+# exists to give. A cartridge at 2% draining a point a day has two days left,
+# and two days is worth knowing; reporting it as already gone puts it beside
+# cartridges that genuinely are, and there is no way back to the difference.
+#
+# Anything above zero gets its real estimate, in hours if that is what it comes
+# to. Only a reading of zero is empty.
+EMPTY_LEVEL = 0
def _asfloat(value):
@@ -58,18 +107,103 @@ def normalise(points):
continue
out.append((datetime.fromtimestamp(seconds, tz=timezone.utc), level))
out.sort(key=lambda p: p[0])
+ return drop_spikes(out)
+
+
+def _recovered(previous, following, tolerance=RECOVERY_TOLERANCE):
+ """Did the level come BACK to where it was, rather than up to a new one?
+
+ Toner only falls, so a dip that recovers returns to at or below the level it
+ left - consumption carried on while the reading was junk. A cartridge that
+ was CHANGED comes back HIGHER than the level before the dip.
+
+ That asymmetry is the whole test. Comparing the absolute difference instead
+ treated a genuine swap of a nearly-full cartridge (95, then 5, then 100) as
+ a recovery and deleted the evidence, because 100 and 95 are close. The small
+ upward tolerance is for gauge noise, not for swaps.
+ """
+ return following <= previous + tolerance
+
+
+def drop_spikes(points, rise=REPLACEMENT_RISE):
+ """Remove dips that RECOVER to where they came from, however long they run.
+
+ A single reading far below both neighbours, then a recovery, is a big
+ upward step that scores as a cartridge change. That is how a cartridge
+ claimed five changes in ninety days.
+
+ NOT a Zabbix timeout: an item that does not answer records nothing, it does
+ not write a zero. The dip is a value the device really reported - a supply
+ pulled out to be shaken and reseated, a door open mid-poll, or a site whose
+ preprocessing maps the Printer MIB's "unknown" sentinels (-1/-2/-3, which
+ cannot land in the unsigned history table) onto 0.
+
+ The filter keys on SHAPE rather than cause, which is why it holds for all of
+ them: a level that comes back to where it was did not get a new cartridge.
+
+ ANY LENGTH, not just one reading. At the few-minute polling these items
+ often use, a door left open or a supply out of the machine spans several
+ polls, and a filter that only removed single readings left the original
+ failure in place for every cartridge above NEW_CARTRIDGE_LEVEL.
+
+ A genuine near-empty reading before a swap (30, 5, 100) does not recover -
+ it comes back HIGHER than 30 - so it is kept and the swap still counts.
+ """
+ if len(points) < 3:
+ return points
+ out = [points[0]]
+ index = 1
+ while index < len(points) - 1:
+ previous = points[index - 1][1]
+ if (previous - points[index][1]) < rise:
+ out.append(points[index])
+ index += 1
+ continue
+ # A dip starts here. Take every consecutive reading that stays down.
+ end = index
+ while end < len(points) - 1 and (previous - points[end][1]) >= rise:
+ end += 1
+ following = points[end][1]
+ # ONE bad sample is a candidate whatever the cadence: the reading is an
+ # instant, and the gap to its neighbours says nothing about how long the
+ # supply was actually out. SEVERAL consecutive low readings only count
+ # as one outage if they are close together - spread over days they are a
+ # real absence, and they also have the same shape as a swap, ordinary
+ # consumption, then another swap.
+ spanhours = (points[end][0] - points[index - 1][0]).total_seconds() / 3600
+ brief = (end - index) == 1 or spanhours <= MAX_DIP_HOURS
+ if (brief
+ and (following - points[end - 1][1]) >= rise
+ and _recovered(previous, following)):
+ index = end # drop the whole stretch
+ continue
+ for keep in range(index, end):
+ out.append(points[keep])
+ index = end
+ out.append(points[-1])
return out
-def find_replacements(points, rise=REPLACEMENT_RISE):
+def _is_replacement(previous, current, rise=REPLACEMENT_RISE,
+ newlevel=NEW_CARTRIDGE_LEVEL):
+ """One definition of "a cartridge was changed", used by every caller.
+
+ The rise must be big enough to clear gauge noise AND land near full, which
+ is what a new cartridge reads. Both conditions, because either alone admits
+ a shape that is not a swap.
+ """
+ return (current - previous) >= rise and current >= newlevel
+
+
+def find_replacements(points, rise=REPLACEMENT_RISE,
+ newlevel=NEW_CARTRIDGE_LEVEL):
"""Timestamps where the level jumped up - one per cartridge change.
- Returns [] for a series that only falls. A rise smaller than `rise` is
- treated as noise, not a replacement.
+ Returns [] for a series that only falls.
"""
replacements = []
for (_, previous), (when, current) in zip(points, points[1:]):
- if current - previous >= rise:
+ if _is_replacement(previous, current, rise, newlevel):
replacements.append(when)
return replacements
@@ -84,28 +218,78 @@ def current_run(points, rise=REPLACEMENT_RISE):
return []
start = 0
for index in range(1, len(points)):
- if points[index][1] - points[index - 1][1] >= rise:
+ if _is_replacement(points[index - 1][1], points[index][1], rise):
start = index
return points[start:]
+def interval_rates(points):
+ """Percent-per-day for each consecutive pair, falling intervals only.
+
+ A rise inside a run is gauge noise (a swap would have ended the run), and
+ a flat interval is real information - a cartridge that did not move - so it
+ stays in at zero.
+ """
+ rates = []
+ for (whenprev, prev), (when, current) in zip(points, points[1:]):
+ days = (when - whenprev).total_seconds() / 86400
+ if days <= 0:
+ continue
+ drop = prev - current
+ if drop < 0:
+ continue
+ rates.append(drop / days)
+ return rates
+
+
+def _median(values):
+ ordered = sorted(values)
+ count = len(ordered)
+ if not count:
+ return None
+ middle = count // 2
+ if count % 2:
+ return ordered[middle]
+ return (ordered[middle - 1] + ordered[middle]) / 2
+
+
def burn_rate(points):
"""Percent consumed per day over these readings, or None.
+ THE MEDIAN OF THE PER-INTERVAL RATES, not the slope between the first and
+ last reading. Two endpoints cannot tell "steady" from "burst then stopped":
+ a cartridge that lost 20 percent in two days and then barely moved for a
+ month reads as 0.83 percent/day forever after, so the report keeps promising
+ it will run out long after printing slowed. The burst is one interval among
+ many to a median, and one of two points to a secant.
+
None means "no honest estimate": too few readings, no elapsed time, or a
drop too small to distinguish from a gauge that has not moved yet.
"""
if len(points) < MIN_POINTS_FOR_ESTIMATE:
return None
- first_when, first_level = points[0]
- last_when, last_level = points[-1]
- days = (last_when - first_when).total_seconds() / 86400
- if days <= 0:
+ total_days = (points[-1][0] - points[0][0]).total_seconds() / 86400
+ if total_days < MIN_DAYS_FOR_ESTIMATE:
return None
- drop = first_level - last_level
- if drop < MIN_DROP_FOR_ESTIMATE:
+ # The overall drop still gates the estimate: a gauge sitting on one plateau
+ # has not proved anything yet, whatever the intervals say.
+ if points[0][1] - points[-1][1] < MIN_DROP_FOR_ESTIMATE:
return None
- return drop / days
+ rate = _median(interval_rates(points))
+ if not rate:
+ # Every interval flat or rising, yet the run dropped overall - the
+ # movement is all in intervals the median discarded. Fall back to the
+ # whole-run slope rather than reporting nothing.
+ return (points[0][1] - points[-1][1]) / total_days
+ return rate
+
+
+def rate_is_unstable(points, factor=RATE_SPREAD_FACTOR):
+ """True when the intervals disagree enough that one number oversells it."""
+ rates = [r for r in interval_rates(points) if r > 0]
+ if len(rates) < 2:
+ return False
+ return max(rates) >= min(rates) * factor
def analyse(points, rise=REPLACEMENT_RISE, currentlevel=None):
@@ -131,6 +315,7 @@ def analyse(points, rise=REPLACEMENT_RISE, currentlevel=None):
points = normalise(points)
result = {
'currentlevel': currentlevel, 'daysleft': None, 'burnrateperday': None,
+ 'rateunstable': False,
'reason': None, 'replacements': 0, 'lastreplaced': None,
'basisdays': 0, 'points': [],
}
@@ -164,8 +349,8 @@ def analyse(points, rise=REPLACEMENT_RISE, currentlevel=None):
rate = burn_rate(run)
- # Empty is empty. Ordering by a rate below this level ranks a dead
- # cartridge behind a healthy one that happens to be draining faster.
+ # Empty is empty, and only zero is empty. Ordering by a rate at this level
+ # ranks a dead cartridge behind a healthy one that happens to drain faster.
if level <= EMPTY_LEVEL:
result['daysleft'] = 0
result['burnrateperday'] = round(rate, 2) if rate is not None else None
@@ -173,7 +358,8 @@ def analyse(points, rise=REPLACEMENT_RISE, currentlevel=None):
if rate is None:
# Say which of the three it is; "no estimate" alone invites a bug report.
- if len(run) < MIN_POINTS_FOR_ESTIMATE:
+ basis = result.get('basisdays') or 0
+ if len(run) < MIN_POINTS_FOR_ESTIMATE or basis < MIN_DAYS_FOR_ESTIMATE:
result['reason'] = ('replaced recently' if replacements
else 'not enough history yet')
else:
@@ -181,7 +367,16 @@ def analyse(points, rise=REPLACEMENT_RISE, currentlevel=None):
return result
result['burnrateperday'] = round(rate, 2)
- result['daysleft'] = max(0, int(level / rate))
+ # Fractional, NOT int(). Truncating meant every cartridge with less than a
+ # full day left reported 0, which the bands read as empty and the view
+ # printed as "empty" - a cartridge with six hours in it was indistinguishable
+ # from one with nothing. Two decimal places is a quarter of an hour, which is
+ # finer than the estimate deserves but costs nothing and keeps the ordering
+ # of two nearly-spent cartridges meaningful.
+ result['daysleft'] = round(max(0.0, level / rate), 2)
+ # Say so when the intervals disagree wildly. The number is still the best
+ # estimate available; the flag stops it reading as a measurement.
+ result['rateunstable'] = rate_is_unstable(run)
return result
diff --git a/plugins/printers/services/supply_parts.py b/plugins/printers/services/supply_parts.py
index 8ba7c278..ca3e445a 100644
--- a/plugins/printers/services/supply_parts.py
+++ b/plugins/printers/services/supply_parts.py
@@ -68,19 +68,34 @@ def derivecolor(name: str, tagcolor: Optional[str] = None) -> str:
return "none"
-def classifysupply(level: float, name: str, vendor: Optional[str]) -> Dict:
- """Classify one supply item into ok/low/critical.
+def classifysupply(level: Optional[float], name: str,
+ vendor: Optional[str]) -> Dict:
+ """Classify one supply item into ok/low/critical, or unknown.
Waste cartridge fill is inverted vs a toner level: a full waste cartridge
is bad. Standard vendors report waste as percent FULL (high = bad). Xerox
EC/AltaLink series report waste as percent capacity REMAINING (low = bad),
same direction as toner. Normalise everything to percent remaining first.
+
+ A level of None means Zabbix has no usable reading; the status is 'unknown'
+ and remaining is None. Callers must not treat that as a low supply.
"""
lowername = (name or "").lower()
iswaste = "waste" in lowername
isdrum = "drum" in lowername or "imaging" in lowername
isxerox = bool(vendor) and "xerox" in vendor.lower()
+ # No reading is its own answer, not a level. It cannot be classified ok,
+ # low or critical without asserting something nobody measured - and every
+ # one of those three is a claim the reader would act on.
+ if level is None:
+ return {
+ "status": "unknown",
+ "remaining": None,
+ "iswaste": iswaste,
+ "isdrum": isdrum,
+ }
+
if iswaste and not isxerox:
remaining = 100 - level
else:
diff --git a/plugins/printers/services/zabbix_service.py b/plugins/printers/services/zabbix_service.py
index eb5123fa..da72cdd4 100644
--- a/plugins/printers/services/zabbix_service.py
+++ b/plugins/printers/services/zabbix_service.py
@@ -208,18 +208,64 @@ class ZabbixService:
continue
if str(item.get("state", "0")) != "0":
continue
- try:
- level = int(float(item.get("lastvalue", 0)))
- except (ValueError, TypeError):
- level = 0
+ level, lastseen = self._readlevel(item)
supplies.append({
"name": item.get("name", "Unknown"),
"level": level,
+ "lastseen": lastseen,
"color": self._extract_color(item),
"itemid": item.get("itemid"),
})
return supplies
+ # How old the newest reading may be and still be presented as the level.
+ # Generous on purpose: supply items are polled in hours, not seconds, and
+ # calling a slow poller "unknown" would be its own lie. This exists for the
+ # host that stopped answering, not for one that answers rarely.
+ STALE_AFTER_SECONDS = 24 * 3600
+
+ @classmethod
+ def _readlevel(cls, item):
+ """(level, lastseen) for one supply item; level is None when unknown.
+
+ An item that has never collected returns lastvalue as an EMPTY STRING,
+ and one whose host went quiet keeps a lastvalue that is arbitrarily old.
+ Both used to become 0 - the empty string because float('') raises and
+ the handler substituted zero, the stale one because nothing ever read
+ lastclock, which was fetched and then ignored.
+
+ Zero is not a safe stand-in for "no reading". It is a legitimate value
+ meaning the cartridge is spent, so the substitution was indistinguishable
+ from a real empty - and it hit every item on the host at once, which is
+ what a printer that has gone dark looks like. Downstream that reached
+ `level <= EMPTY_LEVEL`, so the forecast gave it daysleft 0 and the order
+ list asked purchasing to buy a full set of cartridges for a printer
+ nobody had measured.
+
+ None says "unknown" and every consumer already handles it: the detail
+ page renders N/A, the alert loop skips it, and analyse() falls back to
+ the last real level in history rather than inventing one.
+ """
+ raw = item.get("lastvalue")
+ if raw is None or str(raw).strip() == "":
+ return None, None
+ try:
+ level = int(float(raw))
+ except (ValueError, TypeError):
+ return None, None
+
+ try:
+ lastseen = int(item.get("lastclock") or 0)
+ except (ValueError, TypeError):
+ lastseen = 0
+ if lastseen <= 0:
+ # A value with no clock cannot be aged. Trust the value and say the
+ # age is unknown rather than discarding a reading that may be fine.
+ return level, None
+ if time.time() - lastseen > cls.STALE_AFTER_SECONDS:
+ return None, lastseen
+ return level, lastseen
+
# Per item, per call. Zabbix applies `limit` to the WHOLE result set, not
# per item, so the budget is multiplied by the number of items asked for.
HISTORY_PER_ITEM = 2000
diff --git a/plugins/tools/api/__init__.py b/plugins/tools/api/__init__.py
new file mode 100644
index 00000000..d0a9430e
--- /dev/null
+++ b/plugins/tools/api/__init__.py
@@ -0,0 +1,5 @@
+"""Tech Tools API package."""
+
+from .routes import tools_bp
+
+__all__ = ['tools_bp']
diff --git a/plugins/tools/api/routes.py b/plugins/tools/api/routes.py
new file mode 100644
index 00000000..f55dd620
--- /dev/null
+++ b/plugins/tools/api/routes.py
@@ -0,0 +1,176 @@
+"""Secure send: create a one-view link for a credential, and redeem it.
+
+Lives in Tech Tools because that is where somebody looks for a utility. It is
+the one tool here that is NOT purely browser-side: a link that expires and dies
+on first read needs somewhere to keep the ciphertext until then.
+
+TWO DIFFERENT DOORS, on purpose.
+
+CREATE needs a login and a permission. Anonymous creation would be an
+untraceable way to move data out of a system that otherwise records who did
+what, and the creator is the only person these rows can ever name.
+
+READ needs neither, because the recipient usually has no account here and the
+link is normally handed over in a call or a chat. The unguessable token IS the
+authorisation - which is why it is 128 bits of randomness and why the row is
+spent on the first successful read.
+
+WHAT KEEPS THIS FROM BECOMING A DOCUMENT TRANSFER. The key never reaches the
+server, so the server cannot look at what it holds and cannot tell a password
+from a page of a drawing. The only lever it has is SIZE, and MAX_CIPHERTEXT is
+therefore not a tuning knob - it is the whole enforcement. Everything else (line
+counts, file signatures, refusing a pasted file) runs in the browser, guides an
+honest person, and is bypassable by anyone willing to POST directly.
+"""
+
+import secrets as pysecrets
+from datetime import datetime, timedelta, timezone
+
+from flask import Blueprint, request
+from flask_jwt_extended import jwt_required, get_jwt_identity
+
+from shopdb.api import (
+ db,
+ success_response, error_response, ErrorCodes,
+ require_permission,
+)
+
+from ..models import SecureSecret
+
+tools_bp = Blueprint('tools', __name__)
+
+# Base64 of an encrypted credential. A password is well under a hundred
+# characters; this is generous for a connection string and useless for a
+# document. See the module docstring: this limit is the only server-side control
+# that a zero-knowledge design leaves available.
+MAX_CIPHERTEXT = 4096
+
+# Hours. The default is short because the normal case is "read it while we are
+# on this call"; the maximum exists so nothing lingers for a week.
+DEFAULT_EXPIRY_HOURS = 24
+MAX_EXPIRY_HOURS = 168
+
+MAX_VIEWS = 5
+
+
+def _utcnow():
+ return datetime.now(timezone.utc).replace(tzinfo=None)
+
+
+@tools_bp.route('/secrets', methods=['POST'])
+@jwt_required()
+@require_permission('tools.secrets')
+def create_secret():
+ """Store an already-encrypted secret and return its token."""
+ data = request.get_json(silent=True) or {}
+ ciphertext = (data.get('ciphertext') or '').strip()
+ iv = (data.get('iv') or '').strip()
+
+ if not ciphertext or not iv:
+ return error_response(ErrorCodes.VALIDATION_ERROR,
+ 'ciphertext and iv are required')
+
+ if len(ciphertext) > MAX_CIPHERTEXT:
+ # Say what the limit is FOR, not just that it exists. Somebody hitting
+ # it is usually trying to send a file, and the answer to that is a
+ # different tool rather than a bigger number here.
+ return error_response(
+ ErrorCodes.VALIDATION_ERROR,
+ 'Secret is too large ({} of {} characters). This is for passwords '
+ 'and short text, not documents - the server cannot read what it '
+ 'holds, so size is the only limit it can enforce.'.format(
+ len(ciphertext), MAX_CIPHERTEXT))
+
+ # `is None`, not `or`: an explicit 0 is falsy, so `or DEFAULT` silently
+ # turned "expire immediately" into "expire in a day" instead of refusing it.
+ # A caller who sends a number should be told when it is not allowed, not
+ # quietly given a different one.
+ rawhours = data.get('expiryhours')
+ rawviews = data.get('views')
+ try:
+ hours = int(DEFAULT_EXPIRY_HOURS if rawhours is None else rawhours)
+ views = int(1 if rawviews is None else rawviews)
+ except (TypeError, ValueError):
+ return error_response(ErrorCodes.VALIDATION_ERROR,
+ 'expiryhours and views must be whole numbers')
+
+ if not 1 <= hours <= MAX_EXPIRY_HOURS:
+ return error_response(
+ ErrorCodes.VALIDATION_ERROR,
+ 'expiryhours must be between 1 and {}'.format(MAX_EXPIRY_HOURS))
+ if not 1 <= views <= MAX_VIEWS:
+ return error_response(
+ ErrorCodes.VALIDATION_ERROR,
+ 'views must be between 1 and {}'.format(MAX_VIEWS))
+
+ try:
+ userid = int(get_jwt_identity())
+ except (TypeError, ValueError):
+ userid = None
+
+ secret = SecureSecret(
+ token=pysecrets.token_hex(16), # 128 bits; the link is the credential
+ ciphertext=ciphertext,
+ iv=iv,
+ expiresat=_utcnow() + timedelta(hours=hours),
+ viewsremaining=views,
+ createdbyuserid=userid,
+ )
+ db.session.add(secret)
+ db.session.commit()
+
+ return success_response(secret.to_dict(), http_code=201)
+
+
+@tools_bp.route('/secrets/', methods=['POST'])
+def redeem_secret(token):
+ """Hand over the ciphertext once, then spend the view.
+
+ POST rather than GET on purpose. A GET is prefetched by link previews in
+ chat and mail clients, and a one-view secret consumed by a preview window is
+ a secret the recipient never gets to see.
+
+ A missing, expired and spent secret all answer the SAME way. Distinguishing
+ them would confirm to anyone holding a guessed token whether it ever named
+ anything.
+ """
+ gone = error_response(
+ ErrorCodes.NOT_FOUND,
+ 'This link is not valid. It may have been opened already, or expired.',
+ http_code=404)
+
+ secret = SecureSecret.query.filter_by(token=(token or '').strip()).first()
+ if secret is None:
+ return gone
+
+ now = _utcnow()
+ if secret.is_expired(now) or secret.is_spent():
+ # Tidy up on the way past rather than waiting for a sweeper: a spent
+ # secret has no reason to keep its ciphertext.
+ db.session.delete(secret)
+ db.session.commit()
+ return gone
+
+ payload = {'ciphertext': secret.ciphertext, 'iv': secret.iv}
+
+ secret.viewsremaining -= 1
+ secret.viewcount += 1
+ secret.lastviewedat = now
+ if secret.viewsremaining <= 0:
+ db.session.delete(secret)
+ db.session.commit()
+
+ return success_response(payload)
+
+
+@tools_bp.route('/secrets/', methods=['GET'])
+def peek_secret(token):
+ """Whether a link is still live, WITHOUT spending it.
+
+ The read page asks this first so it can render "this secret is waiting, and
+ opening it will destroy it" rather than consuming the only view to find out
+ there was something there.
+ """
+ secret = SecureSecret.query.filter_by(token=(token or '').strip()).first()
+ live = bool(secret and not secret.is_expired() and not secret.is_spent())
+ return success_response({'available': live})
diff --git a/plugins/tools/frontend/labelPages.js b/plugins/tools/frontend/labelPages.js
new file mode 100644
index 00000000..7b6e4546
--- /dev/null
+++ b/plugins/tools/frontend/labelPages.js
@@ -0,0 +1,150 @@
+// Rows in, printed pages out.
+//
+// This is the half of the generator a preview cannot check. Two things live
+// here for that reason: the CSV columns, which are a contract with whoever
+// builds the file, and the PAGE ORDER of a two-sided card, which decides
+// whether card 3's back lands on card 3 or on card 4. Both are the kind of
+// wrong you only find once the stock is used up.
+//
+// Nothing here knows how a code is drawn or how big a label is. That is
+// utils/codes.js and the component's CSS respectively.
+
+// Rendering thousands of codes locks the tab, so stop at a number a person
+// would actually feed a label printer in one run. The cap counts CARDS, not
+// pages - a two-sided run of 500 is 1000 pages and that is fine.
+export const MAX_LABELS = 500
+export const MAX_COPIES = 1000
+
+const CONTENT_HEADERS = ['content', 'text', 'data', 'value', 'url', 'qr']
+const LABEL_HEADERS = ['label', 'name', 'caption', 'description']
+const COPIES_HEADERS = ['copies', 'qty', 'quantity', 'count']
+const BACK_HEADERS = ['back', 'backcontent', 'reverse']
+const BACKLABEL_HEADERS = ['backlabel', 'backcaption', 'backname']
+
+/** Split one CSV line, honoring quoted fields and "" escapes. */
+export function splitCsvLine(line) {
+ const fields = []
+ let current = ''
+ let inQuotes = false
+ for (let i = 0; i < line.length; i++) {
+ const char = line[i]
+ if (inQuotes) {
+ if (char === '"' && line[i + 1] === '"') { current += '"'; i++ }
+ else if (char === '"') { inQuotes = false }
+ else { current += char }
+ } else if (char === '"') {
+ inQuotes = true
+ } else if (char === ',') {
+ fields.push(current); current = ''
+ } else {
+ current += char
+ }
+ }
+ fields.push(current)
+ return fields.map(field => field.trim())
+}
+
+/**
+ * Parse the label CSV into rows.
+ *
+ * Columns: content (required), label, copies, back, backlabel. A header row is
+ * optional; without one the order is the order above. back/backlabel are only
+ * read when the back side is set to take its own content per row.
+ */
+export function parseCsv(text) {
+ const lines = String(text || '').split(/\r?\n/).filter(line => line.trim() !== '')
+ if (!lines.length) return []
+
+ let columns = { content: 0, label: 1, copies: 2, back: 3, backlabel: 4 }
+ let start = 0
+ const first = splitCsvLine(lines[0]).map(field => field.toLowerCase())
+ if (first.some(field => CONTENT_HEADERS.includes(field))) {
+ // Named header: map by name so column order does not matter.
+ const indexOf = names => first.findIndex(field => names.includes(field))
+ columns = {
+ content: indexOf(CONTENT_HEADERS),
+ label: indexOf(LABEL_HEADERS),
+ copies: indexOf(COPIES_HEADERS),
+ back: indexOf(BACK_HEADERS),
+ backlabel: indexOf(BACKLABEL_HEADERS),
+ }
+ start = 1
+ }
+
+ const at = (fields, index) => (index >= 0 ? (fields[index] || '') : '')
+
+ const rows = []
+ for (let i = start; i < lines.length; i++) {
+ const fields = splitCsvLine(lines[i])
+ const content = at(fields, columns.content)
+ if (!content) continue
+ const copies = columns.copies >= 0 ? parseInt(fields[columns.copies], 10) : 1
+ rows.push({
+ content,
+ label: at(fields, columns.label),
+ copies: Number.isFinite(copies) && copies > 0 ? Math.min(copies, MAX_COPIES) : 1,
+ back: at(fields, columns.back),
+ backlabel: at(fields, columns.backlabel),
+ })
+ }
+ return rows
+}
+
+/** The back page belonging to one row, per the chosen back source. */
+function backPageFor(row, back) {
+ const fallback = back.text || ''
+ switch (back.source) {
+ // The same payload again, so either face of a card scans. Worth it on a
+ // badge that can end up in a holder either way round.
+ case 'code':
+ return { content: row.content, label: row.label || '', side: 'back' }
+ // Per-row back: the CSV carries it. A row that left it empty still gets a
+ // page, because a card printer counts pages and a missing one shifts every
+ // back after it onto the wrong front.
+ case 'column':
+ return { content: row.back || '', label: row.backlabel || fallback, side: 'back' }
+ case 'blank':
+ return { content: '', label: '', side: 'back' }
+ case 'text':
+ default:
+ return { content: '', label: fallback, side: 'back' }
+ }
+}
+
+/**
+ * Expand rows by their copies count and, when a back side is enabled, pair
+ * each card with its back page.
+ *
+ * `back.order` is the one that matters on the floor:
+ * interleave - front, back, front, back. What a duplex card printer's driver
+ * expects: it takes pages two at a time.
+ * grouped - every front, then every back. For a single-sided printer,
+ * where the stack comes out, gets flipped and goes back in.
+ * Getting this backwards prints readable cards with the wrong names on the
+ * back, which is worse than a jam because nothing looks broken.
+ */
+export function buildPages(rows, options = {}) {
+ const { back = {}, max = MAX_LABELS } = options
+ const fronts = []
+ const backs = []
+ let dropped = 0
+
+ for (const row of rows || []) {
+ const wanted = Number.isFinite(row.copies) && row.copies > 0
+ ? Math.min(row.copies, MAX_COPIES) : 1
+ for (let i = 0; i < wanted; i++) {
+ if (fronts.length >= max) { dropped += 1; continue }
+ fronts.push({ content: row.content, label: row.label || '', side: 'front' })
+ if (back.enabled) backs.push(backPageFor(row, back))
+ }
+ }
+
+ let pages = fronts
+ if (back.enabled) {
+ pages = back.order === 'grouped'
+ ? [...fronts, ...backs]
+ : fronts.flatMap((front, index) => [front, backs[index]])
+ }
+
+ return { pages, fronts, backs, dropped }
+}
diff --git a/plugins/tools/frontend/labelPages.spec.js b/plugins/tools/frontend/labelPages.spec.js
new file mode 100644
index 00000000..31d33fbc
--- /dev/null
+++ b/plugins/tools/frontend/labelPages.spec.js
@@ -0,0 +1,113 @@
+// Page order is the one thing here that costs money to get wrong: a run of
+// badge cards with the backs one card out looks perfect until someone reads
+// one. These tests pin the order and the CSV contract.
+
+import { describe, it, expect } from 'vitest'
+
+import { parseCsv, splitCsvLine, buildPages, MAX_LABELS } from './labelPages'
+
+const sides = pages => pages.map(page => page.side).join(' ')
+const contents = pages => pages.map(page => page.content || '-').join(' ')
+
+describe('splitCsvLine', () => {
+ it('keeps a comma that is inside quotes', () => {
+ expect(splitCsvLine('WJ-0001,"Bay 12, press",2')).toEqual(['WJ-0001', 'Bay 12, press', '2'])
+ })
+
+ it('unescapes a doubled quote', () => {
+ expect(splitCsvLine('x,"the ""big"" one"')).toEqual(['x', 'the "big" one'])
+ })
+})
+
+describe('parseCsv', () => {
+ it('reads a named header in any column order', () => {
+ const rows = parseCsv('copies,label,content\n3,Line 1,WJ-0001')
+ expect(rows).toEqual([
+ { content: 'WJ-0001', label: 'Line 1', copies: 3, back: '', backlabel: '' },
+ ])
+ })
+
+ it('falls back to positional columns with no header', () => {
+ const rows = parseCsv('WJ-0001,Line 1,2,WJ-0001-B,Reverse')
+ expect(rows[0]).toEqual({
+ content: 'WJ-0001', label: 'Line 1', copies: 2,
+ back: 'WJ-0001-B', backlabel: 'Reverse',
+ })
+ })
+
+ it('skips a row with no content rather than printing a blank sticker', () => {
+ expect(parseCsv('content,label\n,orphan\nWJ-0002,real')).toHaveLength(1)
+ })
+
+ it('defaults copies to 1 when it is missing or junk', () => {
+ expect(parseCsv('content,copies\nA,\nB,zero')[0].copies).toBe(1)
+ expect(parseCsv('content,copies\nA,\nB,zero')[1].copies).toBe(1)
+ })
+})
+
+describe('buildPages without a back side', () => {
+ it('expands copies and emits fronts only', () => {
+ const { pages, backs } = buildPages([{ content: 'A', label: 'a', copies: 3 }])
+ expect(sides(pages)).toBe('front front front')
+ expect(backs).toHaveLength(0)
+ })
+
+ it('caps cards, not pages, and says how many it dropped', () => {
+ const { fronts, dropped } = buildPages([{ content: 'A', copies: MAX_LABELS + 5 }])
+ expect(fronts).toHaveLength(MAX_LABELS)
+ expect(dropped).toBe(5)
+ })
+})
+
+describe('buildPages with a back side', () => {
+ const rows = [
+ { content: 'A', label: 'a', copies: 2, back: 'A2', backlabel: 'a2' },
+ { content: 'B', label: 'b', copies: 1, back: 'B2', backlabel: 'b2' },
+ ]
+
+ it('interleaves front and back, which is what a duplex driver takes', () => {
+ const { pages } = buildPages(rows, { back: { enabled: true, source: 'column', order: 'interleave' } })
+ expect(sides(pages)).toBe('front back front back front back')
+ expect(contents(pages)).toBe('A A2 A A2 B B2')
+ })
+
+ it('groups every front then every back, for a flip-and-reload printer', () => {
+ const { pages } = buildPages(rows, { back: { enabled: true, source: 'column', order: 'grouped' } })
+ expect(sides(pages)).toBe('front front front back back back')
+ // Same card order in both halves, or the flipped stack pairs up wrong.
+ expect(contents(pages)).toBe('A A B A2 A2 B2')
+ })
+
+ it('still emits a back for a row that left the back column empty', () => {
+ const { pages } = buildPages(
+ [{ content: 'A', copies: 1 }, { content: 'B', copies: 1, back: 'B2' }],
+ { back: { enabled: true, source: 'column', order: 'interleave' } })
+ // A page count that changes per row would shift every later back onto the
+ // wrong front.
+ expect(sides(pages)).toBe('front back front back')
+ expect(contents(pages)).toBe('A - B B2')
+ })
+
+ it('repeats the front payload when the back is the same code', () => {
+ const { backs } = buildPages(rows, { back: { enabled: true, source: 'code' } })
+ expect(contents(backs)).toBe('A A B')
+ })
+
+ it('puts the same fixed text on every back and no code', () => {
+ const { backs } = buildPages(rows, { back: { enabled: true, source: 'text', text: 'Property of GE' } })
+ expect(backs.every(page => page.content === '')).toBe(true)
+ expect(backs.every(page => page.label === 'Property of GE')).toBe(true)
+ })
+
+ it('leaves a blank back entirely empty but still counted', () => {
+ const { pages, backs } = buildPages(rows, { back: { enabled: true, source: 'blank' } })
+ expect(pages).toHaveLength(6)
+ expect(backs.every(page => !page.content && !page.label)).toBe(true)
+ })
+
+ it('falls back to the fixed text when a row has no back label', () => {
+ const { backs } = buildPages([{ content: 'A', copies: 1, back: 'A2' }],
+ { back: { enabled: true, source: 'column', text: 'Return to IT' } })
+ expect(backs[0].label).toBe('Return to IT')
+ })
+})
diff --git a/plugins/tools/frontend/labelVars.js b/plugins/tools/frontend/labelVars.js
new file mode 100644
index 00000000..33625db4
--- /dev/null
+++ b/plugins/tools/frontend/labelVars.js
@@ -0,0 +1,80 @@
+// The label's measurements, turned into the CSS custom properties the preview
+// and @page read.
+//
+// This exists because of one failure mode. A number input that is cleared does
+// not hold 0, it holds an empty string, and `'' + 'in'` is not a length. The
+// browser drops the whole declaration, the box falls back to `height: auto`,
+// and an image sized `height: 100%` of an auto-height parent computes to ZERO.
+// The label then renders 176x0: full width, no height, no barcode - which
+// reads as "the code stopped rendering" and not as "a field is empty".
+//
+// So nothing here may emit NaN, an empty string, or a negative length. Every
+// value falls back to something printable, and the fallbacks are the defaults
+// the page ships with.
+
+export const LABEL_VAR_NAMES = [
+ '--tool-label-w', '--tool-label-h', '--tool-pad', '--tool-code', '--tool-barh',
+ '--tool-quiet', '--tool-gap', '--tool-font', '--tool-align', '--tool-valign',
+ '--tool-content-x', '--tool-content-y', '--tool-nudge-x', '--tool-nudge-y',
+ '--tool-pic', '--tool-pic-opacity',
+]
+
+export const VAR_FALLBACKS = {
+ labelwidth: 1, labelheight: 0.5, padding: 0, codesize: 0.4286, barheight: 0.3,
+ quiet: 0.035, gap: 0.03, labelfont: 7, picturesize: 0.5, pictureopacity: 0.15,
+}
+
+/** A length that must be greater than zero for the box to exist at all. */
+function positive(value, fallback) {
+ const number = Number(value)
+ return Number.isFinite(number) && number > 0 ? number : fallback
+}
+
+/** A length that may legitimately be zero - padding, a quiet zone, a gap. */
+function nonNegative(value, fallback) {
+ const number = Number(value)
+ return Number.isFinite(number) && number >= 0 ? number : fallback
+}
+
+/** An offset, which is signed: -0.05in is a real answer. */
+function offset(value) {
+ const number = Number(value)
+ return Number.isFinite(number) ? number : 0
+}
+
+const ALIGNMENTS = ['flex-start', 'center', 'flex-end']
+
+function alignment(value) {
+ return ALIGNMENTS.includes(value) ? value : 'center'
+}
+
+/**
+ * Map the generator's settings to CSS custom properties, in inches.
+ *
+ * Returns a plain object of property name to value, ready to hand to
+ * `style.setProperty`. Every entry is a valid CSS value whatever it was given.
+ */
+export function labelVars(state = {}) {
+ const inches = value => value + 'in'
+ const opacity = Number(state.pictureopacity)
+ return {
+ '--tool-label-w': inches(positive(state.labelwidth, VAR_FALLBACKS.labelwidth)),
+ '--tool-label-h': inches(positive(state.labelheight, VAR_FALLBACKS.labelheight)),
+ '--tool-pad': inches(nonNegative(state.padding, VAR_FALLBACKS.padding)),
+ '--tool-code': inches(positive(state.codesize, VAR_FALLBACKS.codesize)),
+ '--tool-barh': inches(positive(state.barheight, VAR_FALLBACKS.barheight)),
+ '--tool-quiet': inches(nonNegative(state.quiet, VAR_FALLBACKS.quiet)),
+ '--tool-gap': inches(nonNegative(state.gap, VAR_FALLBACKS.gap)),
+ '--tool-font': positive(state.labelfont, VAR_FALLBACKS.labelfont) + 'pt',
+ '--tool-align': alignment(state.align),
+ '--tool-valign': alignment(state.valign),
+ '--tool-content-x': inches(offset(state.contentx)),
+ '--tool-content-y': inches(offset(state.contenty)),
+ '--tool-nudge-x': inches(offset(state.nudgex)),
+ '--tool-nudge-y': inches(offset(state.nudgey)),
+ '--tool-pic': inches(positive(state.picturesize, VAR_FALLBACKS.picturesize)),
+ '--tool-pic-opacity': String(
+ Number.isFinite(opacity) && opacity > 0 && opacity <= 1
+ ? opacity : VAR_FALLBACKS.pictureopacity),
+ }
+}
diff --git a/plugins/tools/frontend/labelVars.spec.js b/plugins/tools/frontend/labelVars.spec.js
new file mode 100644
index 00000000..af67a82c
--- /dev/null
+++ b/plugins/tools/frontend/labelVars.spec.js
@@ -0,0 +1,76 @@
+// The 176x0 label: a cleared number field emitted `NaNin`, the browser dropped
+// the declaration, and the code box fell back to auto height, which is zero for
+// an image sized at 100% of it. Every case below is a value a number input can
+// actually hold.
+
+import { describe, it, expect } from 'vitest'
+
+import { labelVars, LABEL_VAR_NAMES, VAR_FALLBACKS } from './labelVars'
+
+const EMPTY_INPUT = '' // what v-model.number leaves behind on a cleared field
+
+describe('labelVars', () => {
+ it('emits every property the stylesheet reads', () => {
+ const vars = labelVars({})
+ for (const name of LABEL_VAR_NAMES) expect(vars[name]).toBeTruthy()
+ })
+
+ it('never emits NaN or an empty length, whatever it is handed', () => {
+ for (const junk of [EMPTY_INPUT, NaN, null, undefined, 'abc', Infinity]) {
+ const vars = labelVars({
+ labelwidth: junk, labelheight: junk, barheight: junk, codesize: junk,
+ quiet: junk, gap: junk, padding: junk, labelfont: junk, picturesize: junk,
+ contentx: junk, contenty: junk, nudgex: junk, nudgey: junk,
+ pictureopacity: junk,
+ })
+ for (const name of LABEL_VAR_NAMES) {
+ expect(vars[name]).not.toMatch(/NaN|Infinity|undefined|null/)
+ expect(vars[name]).not.toBe('in')
+ }
+ }
+ })
+
+ it('falls back to a printable bar height rather than a zero-height box', () => {
+ expect(labelVars({ barheight: EMPTY_INPUT })['--tool-barh'])
+ .toBe(VAR_FALLBACKS.barheight + 'in')
+ expect(labelVars({ barheight: 0 })['--tool-barh'])
+ .toBe(VAR_FALLBACKS.barheight + 'in')
+ expect(labelVars({ barheight: -2 })['--tool-barh'])
+ .toBe(VAR_FALLBACKS.barheight + 'in')
+ })
+
+ it('keeps a real measurement', () => {
+ const vars = labelVars({ labelwidth: 2.13, labelheight: 3.38, barheight: 0.5 })
+ expect(vars['--tool-label-w']).toBe('2.13in')
+ expect(vars['--tool-label-h']).toBe('3.38in')
+ expect(vars['--tool-barh']).toBe('0.5in')
+ })
+
+ it('lets padding, quiet zone and gap be zero, because zero is a real answer', () => {
+ const vars = labelVars({ padding: 0, quiet: 0, gap: 0 })
+ expect(vars['--tool-pad']).toBe('0in')
+ expect(vars['--tool-quiet']).toBe('0in')
+ expect(vars['--tool-gap']).toBe('0in')
+ })
+
+ it('keeps offsets signed - a negative nudge is the whole point of a nudge', () => {
+ const vars = labelVars({ contentx: -0.05, nudgey: -0.1, contenty: 0.02 })
+ expect(vars['--tool-content-x']).toBe('-0.05in')
+ expect(vars['--tool-nudge-y']).toBe('-0.1in')
+ expect(vars['--tool-content-y']).toBe('0.02in')
+ })
+
+ it('refuses an alignment it does not recognise', () => {
+ expect(labelVars({ align: 'flex-end' })['--tool-align']).toBe('flex-end')
+ expect(labelVars({ align: 'sideways' })['--tool-align']).toBe('center')
+ expect(labelVars({ valign: EMPTY_INPUT })['--tool-valign']).toBe('center')
+ })
+
+ it('clamps watermark opacity into a range that still prints', () => {
+ expect(labelVars({ pictureopacity: 0.4 })['--tool-pic-opacity']).toBe('0.4')
+ expect(labelVars({ pictureopacity: 0 })['--tool-pic-opacity'])
+ .toBe(String(VAR_FALLBACKS.pictureopacity))
+ expect(labelVars({ pictureopacity: 5 })['--tool-pic-opacity'])
+ .toBe(String(VAR_FALLBACKS.pictureopacity))
+ })
+})
diff --git a/plugins/tools/frontend/routes.js b/plugins/tools/frontend/routes.js
index aa152858..e17ac47e 100644
--- a/plugins/tools/frontend/routes.js
+++ b/plugins/tools/frontend/routes.js
@@ -3,9 +3,12 @@
*
* `default` = AppLayout child routes; `toplevel` = full-screen routes.
*
- * The index sits inside the app shell. Each tool that PRINTS is toplevel, the
- * way every other label page in this repo is: printing from inside AppLayout
- * would put the sidebar and header on the label stock.
+ * The generator sits INSIDE the app shell, unlike the label pages in core.
+ * Those are toplevel because a print from inside AppLayout would put the
+ * sidebar on the label stock - which is true unless the print stylesheet says
+ * otherwise, and style.css now does: the shell is hidden at print time so only
+ * the labels reach the page. A tool someone spends ten minutes in wants the
+ * navigation; a one-shot print page does not.
*/
export default [
{
@@ -14,11 +17,8 @@ export default [
component: () => import('./views/ToolsIndex.vue'),
meta: { requiresAuth: true, plugin: 'tools' }
},
-]
-
-export const toplevel = [
{
- path: '/tools/codes',
+ path: 'tools/codes',
name: 'tools-codes',
component: () => import('./views/CodeGenerator.vue'),
meta: { requiresAuth: true, plugin: 'tools' }
diff --git a/plugins/tools/frontend/views/CodeGenerator.vue b/plugins/tools/frontend/views/CodeGenerator.vue
index 6be1b832..834d14b8 100644
--- a/plugins/tools/frontend/views/CodeGenerator.vue
+++ b/plugins/tools/frontend/views/CodeGenerator.vue
@@ -1,12 +1,13 @@
-
-
-
-
-
Barcode / QR Generator
- Back to Tech Tools
-
+
+
+
Barcode / QR Generator
+ Back to Tech Tools
+
+
+
+
{{ csvError }}
- Columns: content (required), label, copies.
- A header row is optional; without one the order is content, label, copies.
+ Columns: content (required), label, copies, back, backlabel.
+ A header row is optional; without one the order is content, label, copies, back, backlabel.
@@ -99,11 +102,16 @@
+
-
-
+
+ The quiet zone is the clear space the SCANNER needs and it stays wrapped
+ around the code. The gap is only the space between the code and its
+ caption, and it adds to the quiet zone rather than eating into it.
+
+
@@ -121,40 +129,208 @@
+
+
Position
-
+
+
+ Content moves the code and caption INSIDE the label and leaves the label
+ where it is. Media moves the whole printed area on the stock - that one
+ is for a printer whose origin is off, not for composing a label.
+
+
+
+
Picture
+
+
+
+
+
+
+
+
+
+
+
+
+ The picture never leaves the browser, so this works on an air-gapped
+ site. It is also NOT remembered between visits, unlike the sizing -
+ an image is far too big to keep in the settings the page saves.
+ A watermark behind a code costs the scanner contrast: keep the opacity
+ low and test a scan before printing a run.
+
+
+
+
Back side
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Duplex printer: keep page order interleaved and set the
+ driver to double-sided, flip on SHORT edge for portrait card stock -
+ long edge prints the back upside down.
+ Single-sided printer: choose grouped, print the run,
+ flip the whole stack keeping its order, and print the second half.
+ Print one card both ways before committing the box.
+
+
+
+
-
- Showing the first {{ MAX_LABELS }} rows. {{ truncated }} more were left out
+ Showing the first {{ MAX_LABELS }} labels. {{ truncated }} more were left out
- split the CSV and print it in batches.
{{ overlong.length }} row(s) could not be encoded as CODE128 or were empty:
{{ overlong.slice(0, 3).join(', ') }}{{ overlong.length > 3 ? ' ...' : '' }}