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 L from 'leaflet'
|
||||||
import 'leaflet/dist/leaflet.css'
|
import 'leaflet/dist/leaflet.css'
|
||||||
import { loadMapConfig, blueprintUrlFor, state as mapConfig } from '../composables/mapConfig'
|
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 { getSubtypeId, markerRingColor, UNSPECIFIED_COLOR } from '../utils/mapColors'
|
||||||
import api from '../api'
|
import api from '../api'
|
||||||
|
|
||||||
@@ -356,22 +356,6 @@ function clearPosition() {
|
|||||||
emit('positionPicked', null)
|
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
|
// Fetch the declared map overlays (ADR-010) and each overlay's per-asset
|
||||||
// decoration data, keyed by assetid for a fast join during rendering.
|
// decoration data, keyed by assetid for a fast join during rendering.
|
||||||
async function loadOverlays() {
|
async function loadOverlays() {
|
||||||
@@ -452,7 +436,7 @@ function renderMarkers() {
|
|||||||
const leafletX = item.mapx
|
const leafletX = item.mapx
|
||||||
|
|
||||||
// Determine color based on mode
|
// Determine color based on mode
|
||||||
let color, typeName, displayName, detailRoute
|
let color, typeName, displayName
|
||||||
|
|
||||||
if (props.assetTypeMode) {
|
if (props.assetTypeMode) {
|
||||||
// Unified asset mode - use subtype colors when a type is selected
|
// Unified asset mode - use subtype colors when a type is selected
|
||||||
@@ -470,13 +454,11 @@ function renderMarkers() {
|
|||||||
if (item.dualpathpartner) {
|
if (item.dualpathpartner) {
|
||||||
displayName = `${item.assetnumber} / ${item.dualpathpartner}`
|
displayName = `${item.assetnumber} / ${item.dualpathpartner}`
|
||||||
}
|
}
|
||||||
detailRoute = getAssetDetailRoute(item)
|
|
||||||
} else {
|
} else {
|
||||||
// Legacy machine mode
|
// Legacy machine mode
|
||||||
typeName = item.machinetype || ''
|
typeName = item.machinetype || ''
|
||||||
color = getTypeColor(typeName)
|
color = getTypeColor(typeName)
|
||||||
displayName = item.alias || item.machinenumber || 'Unknown'
|
displayName = item.alias || item.machinenumber || 'Unknown'
|
||||||
detailRoute = getDetailRoute(item)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use circleMarker instead of divIcon marker - renders on canvas
|
// 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
|
// 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.
|
// the dark one. A fixed white ring vanished against the light blueprint.
|
||||||
color: markerRingColor(props.theme),
|
color: markerRingColor(props.theme),
|
||||||
weight: 2,
|
weight: 1.25,
|
||||||
fillOpacity: 1,
|
fillOpacity: 1,
|
||||||
renderer: canvasRenderer
|
renderer: canvasRenderer
|
||||||
})
|
})
|
||||||
@@ -542,40 +524,11 @@ function renderMarkers() {
|
|||||||
className: 'marker-tooltip'
|
className: 'marker-tooltip'
|
||||||
})
|
})
|
||||||
|
|
||||||
// Click popup (detailed info)
|
// Hover is a glance; the click belongs to the consumer. No popup is bound:
|
||||||
let popupContent
|
// MapView routes to the detail page on click, so a popup opened and was
|
||||||
if (props.assetTypeMode) {
|
// discarded by the navigation in the same tick - it was never visible. In
|
||||||
popupContent = `
|
// the editor and the picker forms it DID render, where a 'View Details'
|
||||||
<div class="marker-popup">
|
// link only served to pull the user off an unsaved form.
|
||||||
<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)
|
|
||||||
marker.on('click', () => emit('markerClick', item))
|
marker.on('click', () => emit('markerClick', item))
|
||||||
|
|
||||||
marker.addTo(map)
|
marker.addTo(map)
|
||||||
@@ -596,11 +549,6 @@ function renderMarkers() {
|
|||||||
applyFilters()
|
applyFilters()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get detail route for unified asset format (shared util = single source).
|
|
||||||
function getAssetDetailRoute(asset) {
|
|
||||||
return assetDetailRoute(asset)
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyFilters() {
|
function applyFilters() {
|
||||||
const searchTerm = filters.value.search.toLowerCase()
|
const searchTerm = filters.value.search.toLowerCase()
|
||||||
|
|
||||||
@@ -748,7 +696,8 @@ onUnmounted(() => {
|
|||||||
width: 18px;
|
width: 18px;
|
||||||
height: 18px;
|
height: 18px;
|
||||||
border-radius: 50%;
|
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);
|
box-shadow: 0 1px 4px rgba(0,0,0,0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -795,39 +744,6 @@ onUnmounted(() => {
|
|||||||
font-size: 1.125rem;
|
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) {
|
:deep(.machine-marker) {
|
||||||
background: transparent !important;
|
background: transparent !important;
|
||||||
border: none !important;
|
border: none !important;
|
||||||
|
|||||||
@@ -7,11 +7,18 @@ import { pluginsApi } from '../api'
|
|||||||
let enabledPromise = null
|
let enabledPromise = null
|
||||||
let enabledSet = null // resolved Set<string>, or null while pending / on failure
|
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
|
// Kick off (or reuse) the single fetch. The endpoint is jwt-optional so this
|
||||||
// works on unauthenticated kiosk routes (e.g. /tv) too.
|
// works on unauthenticated kiosk routes (e.g. /tv) too.
|
||||||
export function loadEnabledPlugins() {
|
export function loadEnabledPlugins() {
|
||||||
if (!enabledPromise) {
|
if (!enabledPromise) {
|
||||||
enabledPromise = pluginsApi.enabled()
|
const fetched = pluginsApi.enabled()
|
||||||
.then(response => {
|
.then(response => {
|
||||||
const names = response?.data?.data
|
const names = response?.data?.data
|
||||||
// Only trust a real array; anything else -> fail open (null).
|
// Only trust a real array; anything else -> fail open (null).
|
||||||
@@ -23,6 +30,19 @@ export function loadEnabledPlugins() {
|
|||||||
enabledSet = null
|
enabledSet = null
|
||||||
return 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
|
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
|
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
|
// Navigation guard
|
||||||
router.beforeEach(async (to, from, next) => {
|
router.beforeEach(async (to, from, next) => {
|
||||||
const authStore = useAuthStore()
|
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).
|
// First-run: steer a fresh admin into the setup wizard (once, until finished).
|
||||||
if (authStore.isAuthenticated && authStore.isAdmin && to.path !== '/setup') {
|
if (authStore.isAuthenticated && authStore.isAdmin && to.path !== '/setup') {
|
||||||
if (!isSetupLoaded()) {
|
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) {
|
if (!setupComplete.value && !setupSkipped.value) {
|
||||||
return next('/setup')
|
return next('/setup')
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ export async function exportMapPdf(opts) {
|
|||||||
const [r, g, b] = hexToRgb(entry.color)
|
const [r, g, b] = hexToRgb(entry.color)
|
||||||
doc.setFillColor(r, g, b)
|
doc.setFillColor(r, g, b)
|
||||||
// The page is always white, so the ring is the light-surface one throughout.
|
// 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.rect(lx, ly - swatch + 1, swatch, swatch, 'FD')
|
||||||
doc.setTextColor('#333333')
|
doc.setTextColor('#333333')
|
||||||
doc.text(label, lx + swatch + 4, ly)
|
doc.text(label, lx + swatch + 4, ly)
|
||||||
@@ -132,7 +132,7 @@ export async function exportMapPdf(opts) {
|
|||||||
const [r, g, b] = hexToRgb(fill)
|
const [r, g, b] = hexToRgb(fill)
|
||||||
doc.setFillColor(r, g, b)
|
doc.setFillColor(r, g, b)
|
||||||
// Prints on white, where a white ring is invisible - use the dark ring.
|
// 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')
|
doc.circle(x, y, radius, 'FD')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -244,11 +244,23 @@ def register_frontend_routes(app: Flask):
|
|||||||
# (the probe was a path-traversal risk surface).
|
# (the probe was a path-traversal risk surface).
|
||||||
if path:
|
if path:
|
||||||
try:
|
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:
|
except Exception:
|
||||||
pass
|
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):
|
def configure_logging(app: Flask):
|
||||||
|
|||||||
Reference in New Issue
Block a user