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 = `
-
- `
- } else {
- popupContent = `
-
- `
- }
-
- 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):