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 @@ + + + + + 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 @@ + + + + + diff --git a/frontend/src/views/securesend/SecuresendForm.vue b/frontend/src/views/securesend/SecuresendForm.vue new file mode 100644 index 00000000..0c149029 --- /dev/null +++ b/frontend/src/views/securesend/SecuresendForm.vue @@ -0,0 +1,228 @@ + + + + + diff --git a/frontend/src/views/securesend/SecuresendList.vue b/frontend/src/views/securesend/SecuresendList.vue new file mode 100644 index 00000000..7c65dfae --- /dev/null +++ b/frontend/src/views/securesend/SecuresendList.vue @@ -0,0 +1,142 @@ + + + + + diff --git a/frontend/src/views/vendors/VendorsList.vue b/frontend/src/views/vendors/VendorsList.vue index 734d5516..1b08da3e 100644 --- a/frontend/src/views/vendors/VendorsList.vue +++ b/frontend/src/views/vendors/VendorsList.vue @@ -55,6 +55,7 @@ No vendors found + @@ -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 }}
+

Identity

@@ -425,6 +430,10 @@ const statuses = ref([]) const vendors = ref([]) const locations = ref([]) const models = ref([]) +// Set when the record itself could not be loaded, as opposed to a reference +// list failing. The form is withheld entirely, because an empty edit form is +// indistinguishable from a record whose fields are genuinely empty. +const loadFailed = ref(false) const businessunits = ref([]) const pcs = ref([]) const relationshipTypes = ref([]) @@ -506,9 +515,29 @@ onMounted(async () => { relationshipTypeId.value = controlsType.relationshiptypeid } - // Load machine if editing + // Load machine if editing. + // + // ITS OWN try/catch, deliberately. This used to sit inside the same block + // as the eight reference loads above, so ONE transient failure among them - + // a page of listAll() timing out on a busy collector cycle - rejected the + // whole thing and rendered a fully editable EDIT form with every field + // BLANK, including the required Asset Number, next to a Save button. Typing + // a number and saving then wrote the blank-loaded values over a real + // machine. The reference lists degrade to empty dropdowns; the record must + // not degrade at all. if (isEdit.value) { - const response = await machinesApi.get(route.params.id) + let response + try { + response = await machinesApi.get(route.params.id) + } catch (err) { + console.error('Error loading machine:', err) + error.value = 'Could not load this machine. Nothing has been changed - ' + + 'reload the page rather than saving, or the blank form would ' + + 'overwrite the record.' + loadFailed.value = true + loading.value = false + return + } const data = response.data.data currentAssetId.value = data.assetid || null currentMachine.value = data diff --git a/plugins/machines/frontend/views/MachinesList.vue b/plugins/machines/frontend/views/MachinesList.vue index b02123e2..158d45a8 100644 --- a/plugins/machines/frontend/views/MachinesList.vue +++ b/plugins/machines/frontend/views/MachinesList.vue @@ -36,7 +36,9 @@ - + {{ item.assetnumber }} @@ -58,17 +60,26 @@ {{ item.locationname || '-' }} + View + - No machines found + @@ -95,10 +106,22 @@ import { colorStyle } from "@/utils/colorStyle" import { machinesApi } from '@/api' import PaginationBar from '@/components/PaginationBar.vue' import { useListQuery } from '@/composables/listQuery' +import SearchSuggestion from '@/components/SearchSuggestion.vue' const machines = ref([]) const loading = ref(true) -const { page, search, setPage, setSearch } = useListQuery({ onChange: loadMachines }) +const { page, search, setPage, setSearch, suggestion, reportCount } = useListQuery({ onChange: loadMachines }) + +// Take the offered spelling: put it in the box and search it, so what was +// searched is what the person sees. +function applySuggestion(word) { + search.value = word + setSearch(word) + // setSearch only syncs the URL, and the watcher that reloads is suppressed + // because search.value already holds the new term - the same trap the global + // search page documents. Load directly. + loadMachines() +} const totalPages = ref(1) const perPage = ref(20) @@ -119,6 +142,7 @@ async function loadMachines() { const response = await machinesApi.list(params) machines.value = response.data.data || [] + reportCount(machines.value.length) totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1 } catch (error) { console.error('Error loading machines:', error) diff --git a/plugins/measuringtools/api/routes.py b/plugins/measuringtools/api/routes.py index b0384454..9ee2748d 100644 --- a/plugins/measuringtools/api/routes.py +++ b/plugins/measuringtools/api/routes.py @@ -316,6 +316,17 @@ def update_tool(tool_id: int): data = request.get_json() or {} asset = tool.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') + if 'assetnumber' in data and data['assetnumber'] != asset.assetnumber: if Asset.query.filter_by(assetnumber=data['assetnumber']).first(): return error_response(ErrorCodes.CONFLICT, diff --git a/plugins/measuringtools/frontend/views/MeasuringToolsList.vue b/plugins/measuringtools/frontend/views/MeasuringToolsList.vue index f0ea431b..856c8fc1 100644 --- a/plugins/measuringtools/frontend/views/MeasuringToolsList.vue +++ b/plugins/measuringtools/frontend/views/MeasuringToolsList.vue @@ -73,6 +73,7 @@ No measuring tools found + @@ -97,11 +98,21 @@ import { colorStyle } from '@/utils/colorStyle' import { measuringtoolsApi } from '@/api' import PaginationBar from '@/components/PaginationBar.vue' import { useListQuery } from '@/composables/listQuery' +import SearchSuggestion from '@/components/SearchSuggestion.vue' const tools = ref([]) const types = ref([]) const loading = ref(true) -const { page, search, setPage, setSearch } = useListQuery({ onChange: loadTools }) +const { page, search, setPage, setSearch, suggestion, reportCount } = useListQuery({ onChange: loadTools }) + +// 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) + loadTools() +} const typeid = ref('') const calibrationstatus = ref('') const totalPages = ref(1) @@ -133,6 +144,7 @@ async function loadTools() { const response = await measuringtoolsApi.list(params) tools.value = response.data.data || [] + reportCount(tools.value.length) totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1 } catch (err) { console.error('Error loading measuring tools:', err) diff --git a/plugins/network/api/routes.py b/plugins/network/api/routes.py index 4c7780b5..f8e1f93d 100644 --- a/plugins/network/api/routes.py +++ b/plugins/network/api/routes.py @@ -483,6 +483,17 @@ def update_network_device(device_id: int): asset = netdev.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/network/frontend/views/NetworkDeviceDetail.vue b/plugins/network/frontend/views/NetworkDeviceDetail.vue index 3e984f10..6921b403 100644 --- a/plugins/network/frontend/views/NetworkDeviceDetail.vue +++ b/plugins/network/frontend/views/NetworkDeviceDetail.vue @@ -33,22 +33,26 @@ {{ heroWarranty.label }}
+
-
- 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 @@
{{ supply.name }} - - {{ supply.level !== null ? `${supply.level}%` : 'N/A' }} + + + {{ supply.level !== null && supply.level !== undefined ? `${supply.level}%` : 'N/A' }}
@@ -227,7 +233,7 @@ >
- {{ formatSupplyType(supply.supplytype) }} + {{ formatSupplyType(supply.supplytype) }} No printers found + @@ -92,12 +93,24 @@ import { ref, onMounted } from 'vue' import { printersApi } from '@/api' import PaginationBar from '@/components/PaginationBar.vue' import { useListQuery } from '@/composables/listQuery' +import SearchSuggestion from '@/components/SearchSuggestion.vue' const printers = ref([]) const printerTypes = ref([]) const typeFilter = ref('') const loading = ref(true) -const { page, search, setPage, setSearch } = useListQuery({ onChange: loadPrinters }) +const { page, search, setPage, setSearch, suggestion, reportCount } = useListQuery({ onChange: loadPrinters }) + +// Take the offered spelling: put it in the box and search it, so what was +// searched is what the person sees. +function applySuggestion(word) { + search.value = word + setSearch(word) + // setSearch only syncs the URL, and the watcher that reloads is suppressed + // because search.value already holds the new term - the same trap the global + // search page documents. Load directly. + loadPrinters() +} const totalPages = ref(1) const perPage = ref(20) @@ -125,6 +138,7 @@ async function loadPrinters() { const response = await printersApi.list(params) printers.value = response.data.data || [] + reportCount(printers.value.length) totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1 } catch (error) { console.error('Error loading printers:', error) diff --git a/plugins/printers/frontend/views/TonerForecast.vue b/plugins/printers/frontend/views/TonerForecast.vue index f9b4e2e0..b79570f1 100644 --- a/plugins/printers/frontend/views/TonerForecast.vue +++ b/plugins/printers/frontend/views/TonerForecast.vue @@ -42,7 +42,11 @@
  • {{ item.quantity }}x - {{ item.partnumber }} + + + {{ item.marketingname || item.partnumber }} + {{ colorLabel(item.color) }} @@ -90,7 +94,7 @@ Level Runs out Rate - Changed + Replacements @@ -107,7 +111,16 @@ {{ c.name }} - {{ firstPart(c) }} + + + {{ firstPart(c).marketingname || firstPart(c).partnumber }} + - @@ -123,11 +136,25 @@ {{ daysText(c) }} - {{ c.burnrateperday != null ? c.burnrateperday + '%/day' : '-' }} + + - {{ c.replacements || 0 }} - / {{ c.basisdays }}d + + + @@ -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 @@