Guides image + draft gating, audit retention, ADR-015 gate, backup credential redaction
This commit is contained in:
@@ -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')
|
||||
@@ -182,6 +195,7 @@ async function search(q) {
|
||||
if (!q || q.length < 2) {
|
||||
results.value = []
|
||||
suggestion.value = null
|
||||
degraded.value = []
|
||||
return
|
||||
}
|
||||
|
||||
@@ -195,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') {
|
||||
@@ -512,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'
|
||||
}
|
||||
function prettyLabel(key) {
|
||||
@@ -104,6 +148,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'] }
|
||||
]
|
||||
|
||||
@@ -116,12 +161,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 {
|
||||
@@ -152,7 +253,7 @@ async function save() {
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
onMounted(() => { load(); loadAudit() })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -214,4 +315,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