nav resilience after pool restart, thinner marker rings, drop dead marker popup

This commit is contained in:
2026-07-31 10:17:28 -04:00
parent d84f7e3911
commit ff61a4c5e9
6 changed files with 163 additions and 100 deletions

View File

@@ -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 = `
<div class="marker-popup">
<strong>${displayName}</strong>
<div class="popup-details">
<div><span class="label">Asset #:</span> ${item.assetnumber || '-'}</div>
<div><span class="label">Type:</span> ${item.assettype || '-'}</div>
<div><span class="label">Status:</span> ${item.status || '-'}</div>
<div><span class="label">Location:</span> ${item.location || '-'}</div>
${item.primaryip ? `<div><span class="label">IP:</span> ${item.primaryip}</div>` : ''}
</div>
<a href="${detailRoute}" class="popup-link">View Details</a>
</div>
`
} else {
popupContent = `
<div class="marker-popup">
<strong>${displayName}</strong>
<div class="popup-details">
<div><span class="label">Number:</span> ${item.machinenumber || '-'}</div>
<div><span class="label">Type:</span> ${typeName || '-'}</div>
<div><span class="label">Category:</span> ${item.category || '-'}</div>
<div><span class="label">Status:</span> ${item.status || '-'}</div>
<div><span class="label">Vendor:</span> ${item.vendor || '-'}</div>
<div><span class="label">Model:</span> ${item.model || '-'}</div>
</div>
<a href="${detailRoute}" class="popup-link">View Details</a>
</div>
`
}
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;

View File

@@ -7,11 +7,18 @@ import { pluginsApi } from '../api'
let enabledPromise = null
let enabledSet = null // resolved Set<string>, 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
}

View File

@@ -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)
})
})

View File

@@ -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')

View File

@@ -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')
}

View File

@@ -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):