Merge remote-tracking branch 'origin/main' into wilm
# Conflicts: # frontend/src/views/settings/SiteSettings.vue
This commit is contained in:
@@ -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)
|
||||
},
|
||||
|
||||
91
frontend/src/composables/pageTitle.js
Normal file
91
frontend/src/composables/pageTitle.js
Normal 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'
|
||||
}
|
||||
177
frontend/src/composables/pageTitle.spec.js
Normal file
177
frontend/src/composables/pageTitle.spec.js
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -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' },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user