From ff61a4c5e9bc81a64262dd78fe44e413c39d6539 Mon Sep 17 00:00:00 2001 From: Cam Proudlock Date: Fri, 31 Jul 2026 10:17:28 -0400 Subject: [PATCH] nav resilience after pool restart, thinner marker rings, drop dead marker popup --- frontend/src/components/ShopFloorMap.vue | 104 ++---------------- frontend/src/composables/enabledPlugins.js | 22 +++- .../src/composables/enabledPlugins.spec.js | 74 +++++++++++++ frontend/src/router/index.js | 43 +++++++- frontend/src/utils/mapPdf.js | 4 +- shopdb/__init__.py | 16 ++- 6 files changed, 163 insertions(+), 100 deletions(-) create mode 100644 frontend/src/composables/enabledPlugins.spec.js diff --git a/frontend/src/components/ShopFloorMap.vue b/frontend/src/components/ShopFloorMap.vue index 635e3cc7..11232730 100644 --- a/frontend/src/components/ShopFloorMap.vue +++ b/frontend/src/components/ShopFloorMap.vue @@ -96,7 +96,7 @@ import { ref, onMounted, onUnmounted, computed, watch } from 'vue' import L from 'leaflet' import 'leaflet/dist/leaflet.css' import { loadMapConfig, blueprintUrlFor, state as mapConfig } from '../composables/mapConfig' -import { assetTypeLabel, assetDetailRoute } from '../utils/assetTypes' +import { assetTypeLabel } from '../utils/assetTypes' import { getSubtypeId, markerRingColor, UNSPECIFIED_COLOR } from '../utils/mapColors' import api from '../api' @@ -356,22 +356,6 @@ function clearPosition() { emit('positionPicked', null) } -// Get the detail page route based on machine category -// Extensible for future addon types (network, cameras, etc.) -function getDetailRoute(machine) { - const category = machine.category?.toLowerCase() || '' - const routeMap = { - 'machine': '/machines', - 'pc': '/pcs', - 'printer': '/printers', - // Future addon routes can be added here: - // 'network': '/network', - // 'camera': '/cameras', - } - const basePath = routeMap[category] || '/machines' - return `${basePath}/${machine.machineid}` -} - // Fetch the declared map overlays (ADR-010) and each overlay's per-asset // decoration data, keyed by assetid for a fast join during rendering. async function loadOverlays() { @@ -452,7 +436,7 @@ function renderMarkers() { const leafletX = item.mapx // Determine color based on mode - let color, typeName, displayName, detailRoute + let color, typeName, displayName if (props.assetTypeMode) { // Unified asset mode - use subtype colors when a type is selected @@ -470,13 +454,11 @@ function renderMarkers() { if (item.dualpathpartner) { displayName = `${item.assetnumber} / ${item.dualpathpartner}` } - detailRoute = getAssetDetailRoute(item) } else { // Legacy machine mode typeName = item.machinetype || '' color = getTypeColor(typeName) displayName = item.alias || item.machinenumber || 'Unknown' - detailRoute = getDetailRoute(item) } // Use circleMarker instead of divIcon marker - renders on canvas @@ -487,7 +469,7 @@ function renderMarkers() { // Ring keyed on the theme's surface: dark on the white blueprint, light on // the dark one. A fixed white ring vanished against the light blueprint. color: markerRingColor(props.theme), - weight: 2, + weight: 1.25, fillOpacity: 1, renderer: canvasRenderer }) @@ -542,40 +524,11 @@ function renderMarkers() { className: 'marker-tooltip' }) - // Click popup (detailed info) - let popupContent - if (props.assetTypeMode) { - popupContent = ` -
- ${displayName} - - View Details -
- ` - } else { - popupContent = ` -
- ${displayName} - - View Details -
- ` - } - - marker.bindPopup(popupContent) + // Hover is a glance; the click belongs to the consumer. No popup is bound: + // MapView routes to the detail page on click, so a popup opened and was + // discarded by the navigation in the same tick - it was never visible. In + // the editor and the picker forms it DID render, where a 'View Details' + // link only served to pull the user off an unsaved form. marker.on('click', () => emit('markerClick', item)) marker.addTo(map) @@ -596,11 +549,6 @@ function renderMarkers() { applyFilters() } -// Get detail route for unified asset format (shared util = single source). -function getAssetDetailRoute(asset) { - return assetDetailRoute(asset) -} - function applyFilters() { const searchTerm = filters.value.search.toLowerCase() @@ -748,7 +696,8 @@ onUnmounted(() => { width: 18px; height: 18px; border-radius: 50%; - border: 2px solid var(--bg-card); + /* Ring color is bound inline (legendDotStyle) to match the markers. */ + border: 1.5px solid var(--bg-card); box-shadow: 0 1px 4px rgba(0,0,0,0.4); } @@ -795,39 +744,6 @@ onUnmounted(() => { font-size: 1.125rem; } -:deep(.marker-popup) { - min-width: 320px; -} - -:deep(.marker-popup strong) { - display: block; - margin-bottom: 0.625rem; - font-size: 1.5rem; -} - -:deep(.popup-details) { - font-size: 1.125rem; - line-height: 1.9; -} - -:deep(.popup-details .label) { - color: #666; - font-weight: 500; -} - -:deep(.popup-link) { - display: inline-block; - margin-top: 1rem; - color: #1976d2; - text-decoration: none; - font-size: 1.125rem; - font-weight: 500; -} - -:deep(.popup-link:hover) { - text-decoration: underline; -} - :deep(.machine-marker) { background: transparent !important; border: none !important; diff --git a/frontend/src/composables/enabledPlugins.js b/frontend/src/composables/enabledPlugins.js index b95e2a39..40026fca 100644 --- a/frontend/src/composables/enabledPlugins.js +++ b/frontend/src/composables/enabledPlugins.js @@ -7,11 +7,18 @@ import { pluginsApi } from '../api' let enabledPromise = null let enabledSet = null // resolved Set, or null while pending / on failure +// The router AWAITS this, so it must never outlast a navigation. An app-pool +// restart leaves requests hanging (IIS queues them while the worker starts) and +// axios has no timeout, so an unbounded wait here froze navigation outright - +// and because the promise was cached, every later navigation awaited the same +// hung request and stayed frozen after the backend recovered. +const GATE_TIMEOUT_MS = 4000 + // Kick off (or reuse) the single fetch. The endpoint is jwt-optional so this // works on unauthenticated kiosk routes (e.g. /tv) too. export function loadEnabledPlugins() { if (!enabledPromise) { - enabledPromise = pluginsApi.enabled() + const fetched = pluginsApi.enabled() .then(response => { const names = response?.data?.data // Only trust a real array; anything else -> fail open (null). @@ -23,6 +30,19 @@ export function loadEnabledPlugins() { enabledSet = null return null }) + + // Bounded wait, fail-open on expiry. A timed-out or failed attempt drops the + // cached promise so the NEXT navigation retries instead of inheriting a dead + // one; a request that lands late still populates enabledSet. + let timer + const bounded = new Promise(resolve => { + timer = setTimeout(() => resolve(null), GATE_TIMEOUT_MS) + }) + enabledPromise = Promise.race([fetched, bounded]).then(result => { + clearTimeout(timer) + if (result === null) enabledPromise = null + return result + }) } return enabledPromise } diff --git a/frontend/src/composables/enabledPlugins.spec.js b/frontend/src/composables/enabledPlugins.spec.js new file mode 100644 index 00000000..074be662 --- /dev/null +++ b/frontend/src/composables/enabledPlugins.spec.js @@ -0,0 +1,74 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' + +const enabled = vi.fn() +vi.mock('../api', () => ({ pluginsApi: { enabled: (...args) => enabled(...args) } })) + +const { loadEnabledPlugins, isPluginEnabled, resetEnabledPlugins } = + await import('./enabledPlugins') + +beforeEach(() => { + resetEnabledPlugins() + enabled.mockReset() + vi.useFakeTimers() +}) + +afterEach(() => { + vi.useRealTimers() +}) + +describe('loadEnabledPlugins', () => { + it('resolves the enabled set and gates on it', async () => { + enabled.mockResolvedValue({ data: { data: ['machines', 'printers'] } }) + + await loadEnabledPlugins() + + expect(isPluginEnabled('machines')).toBe(true) + expect(isPluginEnabled('usb')).toBe(false) + }) + + it('fails open on error', async () => { + enabled.mockRejectedValue(new Error('boom')) + + await loadEnabledPlugins() + + expect(isPluginEnabled('usb')).toBe(true) + }) + + it('does not hang the router when the request never settles', async () => { + // What an app-pool restart looks like: IIS queues the request while the + // worker starts, and axios has no timeout. + enabled.mockReturnValue(new Promise(() => {})) + + const pending = loadEnabledPlugins() + await vi.advanceTimersByTimeAsync(5000) + + await expect(pending).resolves.toBeNull() + expect(isPluginEnabled('usb')).toBe(true) + }) + + it('retries on the next navigation after a hung request', async () => { + enabled.mockReturnValueOnce(new Promise(() => {})) + + const first = loadEnabledPlugins() + await vi.advanceTimersByTimeAsync(5000) + await first + + // The dead promise must not be cached, or every later navigation inherits + // it and stays frozen even once the backend is back. + enabled.mockResolvedValueOnce({ data: { data: ['machines'] } }) + await loadEnabledPlugins() + + expect(enabled).toHaveBeenCalledTimes(2) + expect(isPluginEnabled('machines')).toBe(true) + expect(isPluginEnabled('usb')).toBe(false) + }) + + it('fetches once while the request is healthy', async () => { + enabled.mockResolvedValue({ data: { data: ['machines'] } }) + + await loadEnabledPlugins() + await loadEnabledPlugins() + + expect(enabled).toHaveBeenCalledTimes(1) + }) +}) diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index 2040ca35..03c7cbba 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -156,6 +156,42 @@ router.afterEach(async (to) => { document.title = page && page !== 'Home' ? `${siteTitle} - ${page}` : siteTitle }) +// A deploy replaces the hashed chunk files, so a tab that was open across it +// still asks for chunks that no longer exist. The dynamic import rejects, the +// navigation aborts, and to the user the site simply stops navigating. Reload +// once to pick up the new index.html; the sessionStorage flag stops a reload +// loop if the chunk is missing for any other reason. +const CHUNK_RELOAD_KEY = 'shopdb-chunk-reloaded' + +function isChunkLoadError(error) { + const text = `${error?.message || ''} ${error?.name || ''}` + return /dynamically imported module|Importing a module script failed|ChunkLoadError|Loading chunk/i.test(text) +} + +function reloadOnceForStaleChunk(error) { + if (!isChunkLoadError(error)) return false + if (sessionStorage.getItem(CHUNK_RELOAD_KEY)) return false + sessionStorage.setItem(CHUNK_RELOAD_KEY, '1') + window.location.reload() + return true +} + +router.onError(error => { + reloadOnceForStaleChunk(error) +}) + +// Vite raises this for a failed module preload, which does not always surface +// as a router error. +window.addEventListener('vite:preloadError', event => { + if (reloadOnceForStaleChunk(event?.payload || event)) event.preventDefault?.() +}) + +// A navigation that completes proves the current chunks are good, so clear the +// flag - otherwise the one allowed reload is spent for the rest of the session. +router.afterEach(() => { + sessionStorage.removeItem(CHUNK_RELOAD_KEY) +}) + // Navigation guard router.beforeEach(async (to, from, next) => { const authStore = useAuthStore() @@ -197,7 +233,12 @@ router.beforeEach(async (to, from, next) => { // First-run: steer a fresh admin into the setup wizard (once, until finished). if (authStore.isAuthenticated && authStore.isAdmin && to.path !== '/setup') { if (!isSetupLoaded()) { - await refreshSetupState() + // Bounded like the plugin gate: a hung request during an app-pool restart + // must not hold the navigation open. Its own catch already fails open. + await Promise.race([ + refreshSetupState(), + new Promise(resolve => setTimeout(resolve, 4000)), + ]) } if (!setupComplete.value && !setupSkipped.value) { return next('/setup') diff --git a/frontend/src/utils/mapPdf.js b/frontend/src/utils/mapPdf.js index eeb66410..b35c3106 100644 --- a/frontend/src/utils/mapPdf.js +++ b/frontend/src/utils/mapPdf.js @@ -98,7 +98,7 @@ export async function exportMapPdf(opts) { const [r, g, b] = hexToRgb(entry.color) doc.setFillColor(r, g, b) // The page is always white, so the ring is the light-surface one throughout. - doc.setDrawColor(markerRingColor('light')); doc.setLineWidth(0.4) + doc.setDrawColor(markerRingColor('light')); doc.setLineWidth(0.3) doc.rect(lx, ly - swatch + 1, swatch, swatch, 'FD') doc.setTextColor('#333333') doc.text(label, lx + swatch + 4, ly) @@ -132,7 +132,7 @@ export async function exportMapPdf(opts) { const [r, g, b] = hexToRgb(fill) doc.setFillColor(r, g, b) // Prints on white, where a white ring is invisible - use the dark ring. - doc.setDrawColor(markerRingColor('light')); doc.setLineWidth(0.6) + doc.setDrawColor(markerRingColor('light')); doc.setLineWidth(0.4) doc.circle(x, y, radius, 'FD') } diff --git a/shopdb/__init__.py b/shopdb/__init__.py index 760a2506..08e3a1b4 100644 --- a/shopdb/__init__.py +++ b/shopdb/__init__.py @@ -244,11 +244,23 @@ def register_frontend_routes(app: Flask): # (the probe was a path-traversal risk surface). if path: try: - return send_from_directory(frontend_dist, path) + response = send_from_directory(frontend_dist, path) + # Asset filenames carry a content hash, so a given URL never + # changes - cache them hard. Everything else stays revalidated. + if path.startswith('assets/'): + response.headers['Cache-Control'] = 'public, max-age=31536000, immutable' + else: + response.headers['Cache-Control'] = 'no-cache' + return response except Exception: pass - return send_from_directory(frontend_dist, 'index.html') + # index.html names the hashed chunks, so a stale copy points at files a + # deploy has already deleted and the SPA stops navigating. Always + # revalidate it. + response = send_from_directory(frontend_dist, 'index.html') + response.headers['Cache-Control'] = 'no-cache' + return response def configure_logging(app: Flask):