Endpoint telemetry, PC Detail rework and a checked backup/restore path
This commit is contained in:
@@ -5,19 +5,46 @@ import { settingsApi } from '@/api'
|
||||
import { withBase } from '@/utils/basePath'
|
||||
|
||||
let settingsCache = null
|
||||
let inFlight = null
|
||||
let retryAfter = 0
|
||||
|
||||
// Whether the last resolved load actually produced settings. A caller cannot
|
||||
// tell that from the value alone: a failed load and a site that simply has not
|
||||
// configured a key both read as empty, and those want opposite handling -
|
||||
// hide the feature vs try again.
|
||||
export function settingsLoadFailed() {
|
||||
return settingsCache === null
|
||||
}
|
||||
|
||||
// A failed load is cached briefly, not for the page session. A transient blip
|
||||
// used to blank every settings-driven link until the operator reloaded, and
|
||||
// they have no reason to know that would fix it. A short window still bounds a
|
||||
// hard-down backend to two requests a minute per client.
|
||||
const FAILURE_RETRY_MS = 30000
|
||||
|
||||
// The in-flight promise is cached alongside the result. Callers that arrive
|
||||
// during the first load share one request instead of each firing their own;
|
||||
// applyBranding() alone awaits five settings in sequence at bootstrap.
|
||||
async function loadSettings() {
|
||||
if (settingsCache) return settingsCache
|
||||
try {
|
||||
const response = await settingsApi.list()
|
||||
const items = response.data?.data || response.data || []
|
||||
settingsCache = {}
|
||||
for (const s of items) settingsCache[s.key] = s.value
|
||||
} catch (err) {
|
||||
console.error('Error loading site settings:', err)
|
||||
settingsCache = {}
|
||||
}
|
||||
return settingsCache
|
||||
if (inFlight) return inFlight
|
||||
if (retryAfter && Date.now() < retryAfter) return {}
|
||||
inFlight = settingsApi.list()
|
||||
.then((response) => {
|
||||
const items = response.data?.data || response.data || []
|
||||
const loaded = {}
|
||||
for (const s of items) loaded[s.key] = s.value
|
||||
settingsCache = loaded
|
||||
retryAfter = 0
|
||||
return loaded
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Error loading site settings:', error)
|
||||
retryAfter = Date.now() + FAILURE_RETRY_MS
|
||||
return {}
|
||||
})
|
||||
.finally(() => { inFlight = null })
|
||||
return inFlight
|
||||
}
|
||||
|
||||
export async function getSetting(key, fallback = '') {
|
||||
@@ -132,10 +159,14 @@ export async function getServicenowUrls() {
|
||||
// setting instead of waiting for a release.
|
||||
//
|
||||
// Empty is the default and means "no link" - the id still renders as text.
|
||||
//
|
||||
// `loaded` distinguishes a real answer from a failed fetch. Both return empty
|
||||
// strings, but only one of them means "this site has no portal links".
|
||||
export async function getEntraDeviceUrls() {
|
||||
const entraUrl = await getSetting('entra_device_url', '')
|
||||
const intuneUrl = await getSetting('intune_device_url', '')
|
||||
return { entraUrl, intuneUrl }
|
||||
const lapsUrl = await getSetting('laps_device_url', '')
|
||||
return { entraUrl, intuneUrl, lapsUrl, loaded: !settingsLoadFailed() }
|
||||
}
|
||||
|
||||
// Printer hostname template. {ip} is replaced with the dash-separated IP.
|
||||
|
||||
119
frontend/src/utils/siteSettings.spec.js
Normal file
119
frontend/src/utils/siteSettings.spec.js
Normal file
@@ -0,0 +1,119 @@
|
||||
// Site settings load once, and a blip does not blank the page for good.
|
||||
//
|
||||
// Two bugs motivated these. The cache used to hold only the RESOLVED object, so
|
||||
// every caller arriving before the first response fired its own request -
|
||||
// applyBranding() alone awaits five settings in sequence at bootstrap. And a
|
||||
// failed load cached {} for the page session, so one bad GET silently blanked
|
||||
// every settings-driven link (the Intune/Entra buttons) until a manual reload.
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
|
||||
const list = vi.fn()
|
||||
|
||||
vi.mock('@/api', () => ({ settingsApi: { list: () => list() } }))
|
||||
|
||||
// The module caches in module scope, so each test needs a fresh copy.
|
||||
async function freshModule() {
|
||||
vi.resetModules()
|
||||
return import('./siteSettings')
|
||||
}
|
||||
|
||||
function respond(items) {
|
||||
return { data: { data: items } }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
list.mockReset()
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('loading', () => {
|
||||
it('fetches once for concurrent callers rather than once per caller', async () => {
|
||||
let release
|
||||
list.mockReturnValue(new Promise((resolve) => { release = resolve }))
|
||||
const settings = await freshModule()
|
||||
|
||||
const waiting = Promise.all([
|
||||
settings.getSetting('entra_device_url'),
|
||||
settings.getSetting('intune_device_url'),
|
||||
settings.getFacilityName(),
|
||||
])
|
||||
release(respond([{ key: 'entra_device_url', value: 'https://entra/{id}' }]))
|
||||
const [entra, intune, facility] = await waiting
|
||||
|
||||
expect(list).toHaveBeenCalledTimes(1)
|
||||
expect(entra).toBe('https://entra/{id}')
|
||||
expect(intune).toBe('')
|
||||
expect(facility).toBe('ShopDB')
|
||||
})
|
||||
|
||||
it('serves later callers from cache without refetching', async () => {
|
||||
list.mockResolvedValue(respond([{ key: 'facility_name', value: 'Wilmington' }]))
|
||||
const settings = await freshModule()
|
||||
|
||||
expect(await settings.getFacilityName()).toBe('Wilmington')
|
||||
expect(await settings.getFacilityName()).toBe('Wilmington')
|
||||
expect(list).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('gives both portal templates to a single caller from one fetch', async () => {
|
||||
list.mockResolvedValue(respond([
|
||||
{ key: 'entra_device_url', value: 'https://entra/{id}' },
|
||||
{ key: 'intune_device_url', value: 'https://intune/{id}' },
|
||||
]))
|
||||
const settings = await freshModule()
|
||||
|
||||
expect(await settings.getEntraDeviceUrls()).toEqual({
|
||||
entraUrl: 'https://entra/{id}',
|
||||
intuneUrl: 'https://intune/{id}',
|
||||
lapsUrl: '',
|
||||
loaded: true,
|
||||
})
|
||||
expect(list).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('failure', () => {
|
||||
it('falls back without throwing so a caller still renders', async () => {
|
||||
list.mockRejectedValue(new Error('network down'))
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const settings = await freshModule()
|
||||
|
||||
expect(await settings.getFacilityName()).toBe('ShopDB')
|
||||
// loaded:false is the point - a failed fetch and an unconfigured site both
|
||||
// read as empty strings, and the caller needs to tell them apart.
|
||||
expect(await settings.getEntraDeviceUrls()).toEqual({
|
||||
entraUrl: '', intuneUrl: '', lapsUrl: '', loaded: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('does not hammer a backend that is down', async () => {
|
||||
list.mockRejectedValue(new Error('network down'))
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const settings = await freshModule()
|
||||
|
||||
await settings.getFacilityName()
|
||||
await settings.getFacilityName()
|
||||
await settings.getFacilityName()
|
||||
|
||||
expect(list).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('recovers after the retry window instead of staying blank for the session', async () => {
|
||||
list.mockRejectedValueOnce(new Error('network down'))
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const settings = await freshModule()
|
||||
|
||||
expect(await settings.getSetting('entra_device_url')).toBe('')
|
||||
|
||||
vi.setSystemTime(Date.now() + 31000)
|
||||
list.mockResolvedValue(respond([{ key: 'entra_device_url', value: 'https://entra/{id}' }]))
|
||||
|
||||
expect(await settings.getSetting('entra_device_url')).toBe('https://entra/{id}')
|
||||
expect(list).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user