nav resilience after pool restart, thinner marker rings, drop dead marker popup
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
74
frontend/src/composables/enabledPlugins.spec.js
Normal file
74
frontend/src/composables/enabledPlugins.spec.js
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user