Merge remote-tracking branch 'origin/main' into wilm

# Conflicts:
#	frontend/src/views/settings/SiteSettings.vue
This commit is contained in:
benjamin.baker
2026-08-27 05:45:52 -04:00
69 changed files with 5601 additions and 327 deletions

View File

@@ -359,6 +359,11 @@ export const printersApi = {
update(id, data) {
return api.put(`/printers/${id}`, data)
},
// Soft delete: the server deactivates the asset, so the printer leaves every
// list while its history and relationships stay intact.
remove(id) {
return api.delete(`/printers/${id}`)
},
updateExtension(id, data) {
return api.put(`/printers/${id}/printerdata`, data)
},

View File

@@ -0,0 +1,91 @@
// The browser tab title, including WHICH record is open.
//
// The router can only name the KIND of page, because it runs before the view
// has fetched anything: every application read "<facility> ShopDB -
// Application", so two tabs on two different machines were indistinguishable,
// and a shared link previewed as the section rather than the thing.
//
// So a detail view names itself once its record arrives:
//
// setRecordTitle(machine.assetnumber)
//
// THE ORDERING IS THE WHOLE PROBLEM. The router's afterEach awaits the site
// settings before it can build "<facility> ShopDB - <page>", so on a cold load
// it finishes AFTER a view that fetched quickly has already set a good title,
// and would overwrite it with the generic one. Both writers therefore go
// through here, and the generic write is dropped when a record title already
// belongs to the route being landed on. The key is the route's fullPath, so
// navigating away invalidates the previous record's claim.
import { getFacilityName } from '@/utils/siteSettings'
let activeKey = null // fullPath of the route the tab is currently showing
let recordTitle = null // record name set by a view for activeKey, if any
let sitePrefix = 'ShopDB' // '<facility> ShopDB', resolved once and cached
function compose(page) {
return page && page !== 'Home' ? `${sitePrefix} - ${page}` : sitePrefix
}
async function resolveSitePrefix() {
try {
const facility = await getFacilityName()
if (facility && facility !== 'ShopDB') sitePrefix = `${facility} ShopDB`
} catch (settingsError) {
// settings unavailable: plain ShopDB is a fine tab title
}
return sitePrefix
}
// Called by the router on every navigation, BEFORE it awaits anything, so a
// view that resolves first is not treated as belonging to the previous route.
export function beginNavigation(key) {
activeKey = key
recordTitle = null
}
// Called by the router once it knows the site prefix. Skipped when a view has
// already named the record for this route.
export async function applyGenericTitle(key, page) {
await resolveSitePrefix()
if (key !== activeKey) return // navigated on while we awaited
// A view that named the record before the facility name arrived composed its
// title against the placeholder prefix, so rewrite it rather than leaving
// 'ShopDB - CSF16' without the facility name in front of it.
document.title = compose(recordTitle !== null ? recordTitle : page)
}
// A tab shows very little, and the browser truncates mid-word wherever it runs
// out. Cut at a word boundary instead, so what survives is readable. Knowledge
// base articles are the case that forces this: their name is a whole sentence
// of description, not a short label.
const MAXLENGTH = 60
function shorten(text) {
if (text.length <= MAXLENGTH) return text
const cut = text.slice(0, MAXLENGTH)
const space = cut.lastIndexOf(' ')
return `${(space > MAXLENGTH / 2 ? cut.slice(0, space) : cut).trimEnd()}...`
}
// Called by a detail view once its record has loaded. An empty or missing name
// leaves the generic title in place rather than showing a bare id: "Machine"
// beats "Machine 4192" for somebody scanning a row of tabs, and a record that
// failed to load has no name to show anyway.
export function setRecordTitle(name) {
const text = (name === null || name === undefined) ? '' : String(name).trim()
if (!text) return
recordTitle = shorten(text)
document.title = compose(recordTitle)
}
// Exposed for tests.
export function _state() {
return { activeKey, recordTitle, sitePrefix }
}
export function _reset() {
activeKey = null
recordTitle = null
sitePrefix = 'ShopDB'
}

View File

@@ -0,0 +1,177 @@
// The tab title names the record, and the two writers do not fight.
//
// The router can only name the KIND of page - it runs before the view has
// fetched anything - so a detail view names the record itself once it loads.
// That leaves two writers for one string, and the router's is asynchronous
// (it awaits the site settings), so on a cold load it can finish LAST and
// overwrite a good title with the generic one. These pin that ordering.
import { describe, it, expect, beforeEach, vi } from 'vitest'
// The real getFacilityName caches, so only the first call is slow and every
// later one is instant. Mirror that: pending calls are held until the test
// releases them, and the value is served synchronously from then on. A mock
// that hands out a fresh unresolved promise per call hangs the moment a test
// navigates twice.
let pending = []
let settled = null
function resolveFacility(value) {
settled = value
const waiting = pending
pending = []
waiting.forEach((resolve) => resolve(value))
}
vi.mock('@/utils/siteSettings', () => ({
getFacilityName: () => (settled !== null
? Promise.resolve(settled)
: new Promise((resolve) => { pending.push(resolve) })),
}))
import {
beginNavigation, applyGenericTitle, setRecordTitle, _reset,
} from './pageTitle'
beforeEach(() => {
_reset()
document.title = ''
pending = []
settled = null
})
describe('the generic title', () => {
it('names the section when no record has been set', async () => {
beginNavigation('/machines')
const done = applyGenericTitle('/machines', 'Machines')
resolveFacility('West Jefferson')
await done
expect(document.title).toBe('West Jefferson ShopDB - Machines')
})
it('falls back to plain ShopDB when the facility is unset', async () => {
beginNavigation('/machines')
const done = applyGenericTitle('/machines', 'Machines')
resolveFacility('ShopDB')
await done
expect(document.title).toBe('ShopDB - Machines')
})
it('drops the site name entirely on the dashboard', async () => {
beginNavigation('/')
const done = applyGenericTitle('/', 'Home')
resolveFacility('West Jefferson')
await done
expect(document.title).toBe('West Jefferson ShopDB')
})
})
describe('a record title', () => {
it('names the record once the view has loaded it', async () => {
beginNavigation('/machines/12')
const done = applyGenericTitle('/machines/12', 'Machines')
resolveFacility('West Jefferson')
await done
setRecordTitle('CSF16')
expect(document.title).toBe('West Jefferson ShopDB - CSF16')
})
it('SURVIVES a slow router that resolves afterwards', async () => {
// The regression this file exists for. The view wins the race, the
// settings fetch lands second, and the generic write must stand down.
beginNavigation('/machines/12')
const done = applyGenericTitle('/machines/12', 'Machines')
setRecordTitle('CSF16') // view got there first
resolveFacility('West Jefferson')
await done // router finishes last
expect(document.title).toBe('West Jefferson ShopDB - CSF16')
})
it('is ignored when the record has no usable name', async () => {
beginNavigation('/machines/12')
const done = applyGenericTitle('/machines/12', 'Machines')
resolveFacility('West Jefferson')
await done
setRecordTitle('')
setRecordTitle(null)
setRecordTitle(undefined)
setRecordTitle(' ')
// the section name is a better tab than a blank or a bare id
expect(document.title).toBe('West Jefferson ShopDB - Machines')
})
it('shortens a name too long for a tab, at a word boundary', async () => {
// Knowledge base articles are named with a sentence of description, and a
// browser truncates those mid-word wherever it happens to run out.
beginNavigation('/knowledgebase/2')
const done = applyGenericTitle('/knowledgebase/2', 'Knowledge Base')
resolveFacility('West Jefferson')
await done
const full = 'Documentation on how to image a Standard or Business PC '
+ 'in GCC High using MediaCreator Lite'
setRecordTitle(full)
expect(document.title.length).toBeLessThan(90)
expect(document.title.startsWith('West Jefferson ShopDB - ')).toBe(true)
expect(document.title.endsWith('...')).toBe(true)
// what survives is a whole-word prefix of the original: the character
// following the cut in the source is a space, so no word was split
const kept = document.title
.replace('West Jefferson ShopDB - ', '')
.replace(/\.\.\.$/, '')
expect(full.startsWith(kept)).toBe(true)
expect(full[kept.length]).toBe(' ')
})
it('leaves a name that already fits alone', async () => {
beginNavigation('/machines/14')
const done = applyGenericTitle('/machines/14', 'Machines')
resolveFacility('West Jefferson')
await done
setRecordTitle('0600')
expect(document.title).toBe('West Jefferson ShopDB - 0600')
})
it('does not leak onto the next page', async () => {
beginNavigation('/machines/12')
const first = applyGenericTitle('/machines/12', 'Machines')
resolveFacility('West Jefferson')
await first
setRecordTitle('CSF16')
beginNavigation('/printers')
const second = applyGenericTitle('/printers', 'Printers')
await second
expect(document.title).toBe('West Jefferson ShopDB - Printers')
})
it('is discarded when the user navigates away mid-flight', async () => {
// Two navigations overlap; the first one's late generic write must not
// land on the second page.
beginNavigation('/machines/12')
const stale = applyGenericTitle('/machines/12', 'Machines')
beginNavigation('/printers/3')
const fresh = applyGenericTitle('/printers/3', 'Printers')
setRecordTitle('WJPRT04')
resolveFacility('West Jefferson')
await Promise.all([stale, fresh])
expect(document.title).toBe('West Jefferson ShopDB - WJPRT04')
})
})

View File

@@ -45,6 +45,7 @@ export const assetTypeCols = [
export const searchDomains = [
{ key: 'application', label: 'Applications' },
{ key: 'knowledgebase', label: 'Knowledge Base' },
{ key: 'guide', label: 'Guides' },
{ key: 'employee', label: 'Employees' },
{ key: 'machine', label: 'Machines' },
{ key: 'computer', label: 'PCs' },

View File

@@ -6,7 +6,7 @@ import { setupComplete, setupSkipped, isSetupLoaded, refreshSetupState,
needsAdmin, isNeedsAdminLoaded, refreshNeedsAdmin } from '../composables/setupState'
import { loadEnabledPlugins, isPluginEnabled } from '../composables/enabledPlugins'
import { useToast } from '../composables/toast'
import { getFacilityName } from '../utils/siteSettings'
import { beginNavigation, applyGenericTitle } from '../composables/pageTitle'
import { stagedChildren, stagedToplevel } from './routes.gen'
// Auto-discover all route modules from routes/ directory. A module's default
@@ -147,14 +147,12 @@ function pageTitleFor(route) {
word.charAt(0).toUpperCase() + word.slice(1)).join(' ')
}
router.afterEach(async (to) => {
let siteTitle = 'ShopDB'
try {
const facility = await getFacilityName()
if (facility && facility !== 'ShopDB') siteTitle = `${facility} ShopDB`
} catch (titleError) { /* settings unavailable - plain ShopDB */ }
const page = pageTitleFor(to)
document.title = page && page !== 'Home' ? `${siteTitle} - ${page}` : siteTitle
router.afterEach((to) => {
// Claim the route BEFORE awaiting anything: a detail view that resolves
// quickly sets the record title, and the generic write below must not
// overwrite it. See composables/pageTitle.js.
beginNavigation(to.fullPath)
applyGenericTitle(to.fullPath, pageTitleFor(to))
})
// A deploy replaces the hashed chunk files, so a tab that was open across it

View File

@@ -98,22 +98,20 @@ function darkenHexColor(hex, fraction) {
// ServiceNow ticket-link config. Returns the enabled flag, incident/change URL
// templates, and the global-search URL template (all with a {ticket}
// placeholder). Defaults mirror the previously-hardcoded GE ServiceNow URLs.
// placeholder).
//
// The defaults are BLANK and the integration ships OFF, matching the server.
// They used to be one organisation's QA ServiceNow tenant, so any site that had
// not configured ServiceNow still rendered ticket links - pointing its
// technicians at a tenant they cannot reach and, for an adopting site, at
// somebody else's. A blank URL renders tickets as plain text, which is the
// correct behaviour for "not configured".
export async function getServicenowUrls() {
const enabledRaw = await getSetting('servicenow_enabled', 'true')
const enabledRaw = await getSetting('servicenow_enabled', 'false')
const enabled = enabledRaw !== 'false' && enabledRaw !== '0' && enabledRaw !== false
const incidentUrl = await getSetting(
'servicenow_incident_url',
'https://geaerospaceqa.service-now.com/now/nav/ui/search/0f8b85d0c7922010099a308dc7c2606a/params/search-term/{ticket}/global-search-data-config-id/c861cea2c7022010099a308dc7c26041/'
)
const changeUrl = await getSetting(
'servicenow_change_url',
'https://geaerospaceqa.service-now.com/now/nav/ui/search/0f8b85d0c7922010099a308dc7c2606a/params/search-term/{ticket}/global-search-data-config-id/c861cea2c7022010099a308dc7c26041/'
)
const searchUrl = await getSetting(
'servicenow_search_url',
'https://geaerospaceqa.service-now.com/now/nav/ui/search/0f8b85d0c7922010099a308dc7c2606a/params/search-term/{ticket}/global-search-data-config-id/c861cea2c7022010099a308dc7c26041/'
)
const incidentUrl = await getSetting('servicenow_incident_url', '')
const changeUrl = await getSetting('servicenow_change_url', '')
const searchUrl = await getSetting('servicenow_search_url', '')
return { enabled, incidentUrl, changeUrl, searchUrl }
}

View File

@@ -35,6 +35,18 @@
<div v-if="loading" class="loading">Searching...</div>
<template v-else-if="query">
<!--
A domain that could not be searched is stated plainly. Without this,
a broken table reads as "that record does not exist" - and the person
goes off believing the machine was never entered.
-->
<div v-if="degraded.length" class="search-degraded">
<strong>Some results may be missing.</strong>
Could not search: {{ degraded.join(', ') }}.
Anything in {{ degraded.length > 1 ? 'those areas' : 'that area' }}
will not appear below, even if it exists.
</div>
<div v-if="filteredResults.length === 0 && results.length > 0" class="no-results">
No {{ activeFilter }} results for "{{ query }}"
<button class="btn btn-secondary" style="margin-top: 0.5rem;" @click="activeFilter = 'all'">Show all results</button>
@@ -103,6 +115,7 @@ const router = useRouter()
const loading = ref(false)
const results = ref([])
const suggestion = ref(null)
const degraded = ref([])
const query = ref('')
const searchInput = ref('')
const activeFilter = ref('all')
@@ -117,6 +130,7 @@ const typeLabels = {
computer: 'PC',
application: 'App',
knowledgebase: 'KB',
guide: 'Guide',
printer: 'Printer',
network_device: 'Network',
measuring_tool: 'Measuring Tool',
@@ -136,6 +150,7 @@ const filterTypeMap = {
measuringtools: ['measuring_tool'],
applications: ['application'],
knowledgebase: ['knowledgebase'],
guides: ['guide'],
notifications: ['notification'],
employees: ['employee'],
usb: ['usb_device'],
@@ -151,6 +166,7 @@ const filterList = [
{ key: 'measuringtools', label: 'Measuring Tools' },
{ key: 'applications', label: 'Apps' },
{ key: 'knowledgebase', label: 'KB' },
{ key: 'guides', label: 'Guides' },
{ key: 'notifications', label: 'Notices' },
{ key: 'employees', label: 'Employees' },
{ key: 'usb', label: 'USB' },
@@ -179,6 +195,7 @@ async function search(q) {
if (!q || q.length < 2) {
results.value = []
suggestion.value = null
degraded.value = []
return
}
@@ -192,6 +209,7 @@ async function search(q) {
// call is needed here. A list page that has no such key uses the
// useSearchSuggestion composable against /search/suggest instead.
suggestion.value = data?.suggestion || null
degraded.value = data?.degraded || []
// Handle ServiceNOW redirect
if (data?.redirect?.type === 'servicenow') {
@@ -424,6 +442,7 @@ watch(results, () => {
--rt-computer-bg: #e8f5e9; --rt-computer-fg: #2e7d32;
--rt-application-bg: #fff3e0; --rt-application-fg: #e65100;
--rt-knowledgebase-bg: #f3e5f5; --rt-knowledgebase-fg: #7b1fa2;
--rt-guide-bg: #e8f0fe; --rt-guide-fg: #1a56b8;
--rt-printer-bg: #fce4ec; --rt-printer-fg: #c2185b;
--rt-network-bg: #fff8e1; --rt-network-fg: #f57f17;
--rt-measuring-bg: #e0f7fa; --rt-measuring-fg: #00838f;
@@ -437,6 +456,7 @@ watch(results, () => {
.result-type.computer { background: var(--rt-computer-bg); color: var(--rt-computer-fg); }
.result-type.application { background: var(--rt-application-bg); color: var(--rt-application-fg); }
.result-type.knowledgebase { background: var(--rt-knowledgebase-bg); color: var(--rt-knowledgebase-fg); }
.result-type.guide { background: var(--rt-guide-bg); color: var(--rt-guide-fg); }
.result-type.printer { background: var(--rt-printer-bg); color: var(--rt-printer-fg); }
.result-type.network_device { background: var(--rt-network-bg); color: var(--rt-network-fg); }
.result-type.measuring_tool { background: var(--rt-measuring-bg); color: var(--rt-measuring-fg); }
@@ -507,4 +527,16 @@ watch(results, () => {
--rt-subnet-bg: rgba(191, 54, 12, 0.2); --rt-subnet-fg: #ffab91;
}
}
/* Warning, not an error: results below are still usable, just incomplete. */
.search-degraded {
margin-bottom: 1rem;
padding: 0.7rem 0.9rem;
border: 1px solid var(--warning, #b26a00);
border-left-width: 4px;
border-radius: 4px;
background: var(--surface, transparent);
color: var(--text);
font-size: 0.9rem;
}
</style>

View File

@@ -34,7 +34,7 @@
<input
type="url"
v-model="settings.servicenow_search_url"
placeholder="https://geaerospaceqa.service-now.com/now/nav/ui/search/.../{ticket}/..."
placeholder="https://yourtenant.service-now.com/now/nav/ui/search/.../{ticket}/..."
@blur="saveSetting('servicenow_search_url', settings.servicenow_search_url)"
:disabled="saving"
>

View File

@@ -35,12 +35,55 @@
</div>
</div>
</div>
<!--
Retention is a setting; purging is an ACT. Keeping them apart matters here
more than in most places: saving a number must never silently destroy
history, so the count of what would go is shown before anything happens
and the deletion is a separate, deliberate press.
-->
<div class="card">
<h3 class="group-title">Audit Log Size</h3>
<div v-if="audit.loading" class="muted">Loading...</div>
<div v-else-if="audit.error" class="error">{{ audit.error }}</div>
<template v-else>
<div class="audit-stats">
<span><strong>{{ audit.total.toLocaleString() }}</strong> entries</span>
<span v-if="audit.oldest" class="muted">oldest {{ shortDate(audit.oldest) }}</span>
<span v-if="audit.newest" class="muted">newest {{ shortDate(audit.newest) }}</span>
</div>
<p v-if="!audit.retentiondays" class="muted">
No retention period is set, so nothing is ever deleted. Set
<em>Audit Log Retention</em> above and save to enable purging.
</p>
<p v-else-if="audit.expired" class="muted">
<strong>{{ audit.expired.toLocaleString() }}</strong> entries are older
than {{ audit.retentiondays }} days and would be deleted.
</p>
<p v-else class="muted">
Nothing is older than {{ audit.retentiondays }} days.
</p>
<div class="actions">
<button class="btn btn-secondary" :disabled="audit.busy" @click="loadAudit">
Refresh
</button>
<button class="btn btn-danger"
:disabled="audit.busy || !audit.retentiondays || !audit.expired"
@click="purgeAudit">
{{ audit.busy ? 'Purging...' : 'Purge now' }}
</button>
<span v-if="audit.message" class="muted">{{ audit.message }}</span>
</div>
</template>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { settingsApi } from '@/api'
import api, { settingsApi } from '@/api'
import { apiError } from '../../utils/apiError'
const items = ref([])
@@ -75,6 +118,7 @@ const LABELS = {
employee_directory_mode: 'Employee Directory Mode',
usb_directory_mode: 'USB Directory Mode',
site_timezone: 'Site Timezone',
audit_retentiondays: 'Audit Log Retention (days)',
setup_complete: 'Setup Complete',
asset_import_csv_location: 'Asset Import CSV Location'
}
@@ -105,6 +149,7 @@ const GROUPS = [
{ title: 'Naming & Patterns', keys: ['pc_access_domain', 'printer_hostname_template', 'contact_email_domain', 'employeeid_pattern'] },
{ title: 'Data Sources', keys: ['employee_directory_mode', 'usb_directory_mode'] },
{ title: 'Localization', keys: ['site_timezone'] },
{ title: 'Audit Log', keys: ['audit_retentiondays'] },
{ title: 'System', keys: ['setup_complete'] }
]
@@ -117,12 +162,68 @@ const HELP = {
printer_hostname_template: 'Template for generating printer hostnames from an IP. Use {ip} where the dash-separated IP goes. Example: Printer-{ip}.printer.geaerospace.net', // ADR-015-OK: GE Aerospace-wide domain, and only the DEFAULT of a documented setting every site can override.
contact_email_domain: 'Email domain appended to a support contact SSO to build email (sso@domain) and Microsoft Teams chat links. Example: geaerospace.com. Leave blank to hide the contact action buttons.',
dualpath_single_machine: 'Treat a Dualpath pair (a dual-bay machine with one controller) as a single machine in lists, counts, and the floor map. Both bay records are always kept; detail pages stay per-bay with a sibling banner. Enter true or false. Default: true.',
audit_retentiondays: 'Delete audit entries older than this many days. 0 keeps everything forever, which is the default - nothing is removed until you set a number here. Saving a retention period does NOT delete anything by itself; use Purge below, or run "flask audit purge --apply" on the server.',
site_timezone: 'IANA timezone for this site. Notification start/end times are shown and entered in this zone, and daily-reset notification expiry is computed here. Default: America/New_York.'
}
function fieldHelp(key) {
return HELP[key] || ''
}
// ---------------------------------------------------------------- audit ---
const audit = ref({ loading: true, busy: false, error: '', message: '',
total: 0, oldest: null, newest: null,
retentiondays: 0, expired: 0 })
function shortDate (iso) {
const d = new Date(iso)
return isNaN(d) ? iso : d.toLocaleDateString()
}
async function loadAudit () {
audit.value.error = ''
try {
const response = await api.get('/auditlogs/retention')
const data = response.data?.data || response.data
Object.assign(audit.value, {
total: data.total || 0,
oldest: data.oldest,
newest: data.newest,
retentiondays: data.retentiondays || 0,
expired: data.expired || 0,
})
} catch (err) {
audit.value.error = apiError(err, 'Could not read the audit log size.')
} finally {
audit.value.loading = false
}
}
async function purgeAudit () {
const days = audit.value.retentiondays
const ok = window.confirm(
'Permanently delete ' + audit.value.expired.toLocaleString() +
' audit entries older than ' + days + ' days?\n\n' +
'Audit history cannot be recovered once deleted.')
if (!ok) return
audit.value.busy = true
audit.value.message = ''
try {
const response = await api.post('/auditlogs/purge', { days })
const result = response.data?.data || response.data
audit.value.message = response.data?.message || 'Purged.'
// A capped run leaves work behind, and says so rather than looking done.
if (result && result.complete === false) {
audit.value.message += ' Press Purge again to continue.'
}
await loadAudit()
} catch (err) {
audit.value.error = apiError(err, 'Could not purge the audit log.')
} finally {
audit.value.busy = false
}
}
async function load() {
loading.value = true
try {
@@ -156,7 +257,7 @@ async function save() {
}
}
onMounted(load)
onMounted(() => { load(); loadAudit() })
</script>
<style scoped>
@@ -218,4 +319,6 @@ onMounted(load)
gap: 12px;
margin-top: 8px;
}
.audit-stats { display: flex; gap: 1.25rem; align-items: baseline;
flex-wrap: wrap; margin-bottom: 0.5rem; }
</style>