Multi-site distribution readiness: settings-driven site config, security closeout, release engineering, v0.5.0
Some checks failed
CI / backend (push) Failing after 2s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 7s

Make the app distributable to other GE Aerospace sites (one self-hosted
instance per site, ADR-004). GE values remain the shipped defaults; every
site-specific behavior is now a Setting an admin can change in the UI.

Settings-driven site config:
- Branding: site/QR/badge logos, favicon, primary color (upload endpoints
  mirror the map-blueprint pattern; new Settings > Branding section).
- ServiceNow: search/incident/change URL templates ({ticket}), ticket
  prefixes, enable toggle. Defaults point at the current
  geaerospaceqa.service-now.com global search. Disabled = plain-text tickets.
- Employee-id regex (employeeid_pattern), printer hostname template,
  QR label targets (qr_target_printer / qr_target_usb, blank = asset page,
  else URL template with placeholders), usb_label_style (barcode|qr).
- West Jefferson floor-plan PNGs removed from the tree; generic placeholder
  ships as the map default and sites upload their own blueprint.

Security closeout:
- dashboarddefaults writes now require admin.
- Collector: generic error messages (no str(exc) leak); API key accepted
  via X-API-Key header only (BREAKING: querystring api_key removed).
- IP-based login rate limiting (AUTH_RATELIMIT_* knobs) atop account lockout.
- Setting.set() creation race fixed (IntegrityError retry).

Release engineering and docs:
- __version__ 0.5.0 (distinct from __contract_version__, ADR-007),
  CHANGELOG.md, Gitea Actions CI config, frontend version aligned.
- One wizard-first install story across README/DEPLOY; new CONFIG.md,
  UPGRADE.md, BACKUP-RESTORE.md; CLAUDE.md and ROADMAP de-staled.
- Dockerfile multi-stage build now bundles the frontend; compose binds
  MySQL to 127.0.0.1; stale database/schema.sql and one-off SQL removed.

Debt and fixes:
- .query.get() -> db.session.get() sweep; datetime.utcnow() removed
  (naive-UTC via timezone-aware now); users.py on authz decorators.
- Fixed 4 stale tests (slides feed shape, shopfloor splitperemployee,
  plugin contract purity) and the USB label page field mapping (both usb
  modes emit the cmmc shape: device_id/device_desc).
- Health endpoint reports the real version.

248 tests pass; naming/style check green; frontend builds; fresh-DB
flask db upgrade + seeds verified; QR targets verified by decoding
rendered codes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-07-10 15:02:07 -04:00
parent bf9e60e607
commit b8c22244a1
96 changed files with 3818 additions and 1942 deletions

View File

@@ -10,8 +10,8 @@
<div v-else class="form-grid">
<label v-for="s in items" :key="s.key" class="field">
<span>{{ prettyLabel(s.key) }}</span>
<input v-model="s.value" type="text" :placeholder="s.description" />
<small class="muted">{{ s.description }}</small>
<input v-model="s.value" type="text" :placeholder="fieldHelp(s.key) ? '' : s.description" />
<small class="muted">{{ fieldHelp(s.key) || s.description }}</small>
</label>
<div v-if="!items.length" class="muted">No site settings found.</div>
@@ -42,12 +42,23 @@ const error = ref('')
const LABELS = {
site_base_url: 'Site URL / FQDN',
facility_name: 'Facility Name',
pc_access_domain: 'PC Access Domain'
pc_access_domain: 'PC Access Domain',
employeeid_pattern: 'Employee ID Pattern',
printer_hostname_template: 'Printer Hostname Template'
}
function prettyLabel(key) {
return LABELS[key] || key
}
// Inline help for site fields that need more than the stored description.
const HELP = {
employeeid_pattern: 'Regular expression that a scanned/typed employee ID must match to be recognized. Default: ^\\d{9}$ (9 digits). An invalid regex is ignored and the default is used.',
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'
}
function fieldHelp(key) {
return HELP[key] || ''
}
async function load() {
loading.value = true
try {

View File

@@ -157,6 +157,198 @@
<span>{{ dellMessage }}</span>
</div>
</div>
<div class="setting-group">
<h3>ServiceNow</h3>
<p class="setting-description">
Wire global search and ticket links to your ServiceNow instance. When
enabled, matching ticket numbers become clickable links and can trigger
a smart-redirect from global search. Defaults ship for GE; change them
for your site.
</p>
<div class="setting-row">
<label class="toggle-label">
<span>Enable ServiceNow</span>
<button
class="toggle-btn"
:class="{ active: settings.servicenow_enabled }"
@click="toggleSetting('servicenow_enabled')"
:disabled="saving"
>
<span class="toggle-slider"></span>
</button>
</label>
</div>
<template v-if="settings.servicenow_enabled">
<div class="setting-row">
<label>
<span>Search URL</span>
<input
type="url"
v-model="settings.servicenow_search_url"
placeholder="https://geaerospaceqa.service-now.com/now/nav/ui/search/.../{ticket}/..."
@blur="saveSetting('servicenow_search_url', settings.servicenow_search_url)"
:disabled="saving"
>
<small class="input-hint">Global-search redirect target. Use {ticket} where the ticket number goes.</small>
</label>
</div>
<div class="setting-row">
<label>
<span>Ticket Prefixes</span>
<input
type="text"
v-model="settings.servicenow_ticket_prefixes"
placeholder="GEINC,GECHG,GERIT,GESCT"
@blur="saveSetting('servicenow_ticket_prefixes', settings.servicenow_ticket_prefixes)"
:disabled="saving"
>
<small class="input-hint">Comma-separated ticket-number prefixes that this site recognizes.</small>
</label>
</div>
<div class="setting-row">
<label>
<span>Incident URL</span>
<input
type="url"
v-model="settings.servicenow_incident_url"
placeholder="(blank = plain text; use {ticket} in a URL template)"
@blur="saveSetting('servicenow_incident_url', settings.servicenow_incident_url)"
:disabled="saving"
>
<small class="input-hint">Link template for incident tickets. Use {ticket} where the ticket number goes.</small>
</label>
</div>
<div class="setting-row">
<label>
<span>Change URL</span>
<input
type="url"
v-model="settings.servicenow_change_url"
placeholder="(blank = plain text; use {ticket} in a URL template)"
@blur="saveSetting('servicenow_change_url', settings.servicenow_change_url)"
:disabled="saving"
>
<small class="input-hint">Link template for change tickets. Use {ticket} where the ticket number goes.</small>
</label>
</div>
</template>
</div>
</div>
<!-- Branding Section -->
<div class="section-card" v-show="isVisible('branding')">
<h2 class="section-title">Branding</h2>
<div class="setting-group">
<p class="setting-description">
Replace the shipped GE logos with your own site branding. Each logo can
be uploaded, or set to a path/URL directly. Leave blank to use the
bundled default.
</p>
<div class="setting-row" v-for="logo in brandingLogos" :key="logo.kind">
<label>
<span>{{ logo.label }}</span>
<input
type="text"
v-model="settings[logo.key]"
:placeholder="logo.placeholder"
@blur="saveSetting(logo.key, settings[logo.key])"
:disabled="saving"
>
<div class="map-upload-row">
<input type="file" :accept="logo.accept" @change="uploadLogo(logo.kind, logo.key, $event)" :disabled="brandingUploading" />
<img v-if="settings[logo.key]" :src="settings[logo.key]" class="map-thumb" :alt="logo.label" />
</div>
<small class="input-hint">{{ logo.hint }}</small>
</label>
</div>
<div class="setting-row">
<label>
<span>Primary brand color</span>
<div class="color-input-row">
<input
type="color"
:value="settings.brand_primary_color || '#000000'"
@input="settings.brand_primary_color = $event.target.value"
@change="saveSetting('brand_primary_color', settings.brand_primary_color)"
:disabled="saving"
>
<input
type="text"
v-model="settings.brand_primary_color"
placeholder="(blank = built-in)"
@blur="saveSetting('brand_primary_color', settings.brand_primary_color)"
:disabled="saving"
>
</div>
<small class="input-hint">Hex color for the primary accent. Leave blank to use the built-in palette.</small>
</label>
</div>
</div>
</div>
<!-- Printing & Labels Section -->
<div class="section-card" v-show="isVisible('printing')">
<h2 class="section-title">Printing &amp; Labels</h2>
<div class="setting-group">
<p class="setting-description">
Where printed QR codes point. Leave a target blank to link to the
asset's own page on this instance, or enter a custom URL template
with {placeholder} substitution.
</p>
<div class="setting-row">
<label>
<span>Printer QR target</span>
<input
type="text"
v-model="settings.qr_target_printer"
placeholder="(blank = printer page)"
@blur="saveSetting('qr_target_printer', settings.qr_target_printer)"
:disabled="saving"
>
<small class="input-hint">Placeholders: {printerid}, {assetid}, {assetnumber}, {serialnumber}, {ip}, {hostname}</small>
</label>
</div>
<div class="setting-row">
<label>
<span>USB label QR target</span>
<input
type="text"
v-model="settings.qr_target_usb"
placeholder="(blank = USB device page)"
@blur="saveSetting('qr_target_usb', settings.qr_target_usb)"
:disabled="saving"
>
<small class="input-hint">Placeholders: {id}, {serialnumber}, {alias}</small>
</label>
</div>
<div class="setting-row">
<label>
<span>USB label style</span>
<select
v-model="settings.usb_label_style"
@change="saveSetting('usb_label_style', settings.usb_label_style)"
:disabled="saving"
>
<option value="barcode">Barcode (CODE128 of the serial number)</option>
<option value="qr">QR code (links to the USB label QR target)</option>
</select>
<small class="input-hint">Applies to the batch USB mini-label print sheet.</small>
</label>
</div>
</div>
</div>
<!-- Email Section -->
@@ -670,7 +862,9 @@ import { apiError } from '../../utils/apiError'
// Section tabs - one section visible at a time to avoid a long scroll. The
// search box filters tabs (and, while searching, shows every matching section).
const SETTINGS_TABS = [
{ key: 'integrations', label: 'Integrations', keywords: 'zabbix toner supply printer monitoring api integration' },
{ key: 'integrations', label: 'Integrations', keywords: 'zabbix toner supply printer monitoring api integration servicenow ticket incident change dell warranty' },
{ key: 'branding', label: 'Branding', keywords: 'branding logo site qr badge favicon color brand primary theme image' },
{ key: 'printing', label: 'Printing & Labels', keywords: 'printing qr label barcode usb printer target url template sticker' },
{ key: 'email', label: 'Email / SMTP', keywords: 'email smtp mail notifications alerts tls from recipients' },
{ key: 'audit', label: 'Audit & Logging', keywords: 'audit log retention history' },
{ key: 'auth', label: 'Authentication', keywords: 'auth saml sso login users idp' },
@@ -719,6 +913,22 @@ const settings = reactive({
warranty_dell_clientsecret: '',
warranty_dell_tokenurl: '',
warranty_dell_apiurl: '',
// ServiceNow
servicenow_enabled: true,
servicenow_search_url: '',
servicenow_ticket_prefixes: '',
servicenow_incident_url: '',
servicenow_change_url: '',
// Branding
site_logo: '',
qr_logo: '',
badge_logo: '',
site_favicon: '',
brand_primary_color: '',
// Printing and labels
qr_target_printer: '',
qr_target_usb: '',
usb_label_style: 'barcode',
// Email
smtp_enabled: false,
smtp_host: '',
@@ -746,6 +956,23 @@ const settings = reactive({
saml_admin_group: ''
})
// Branding logo upload widgets. kind maps to the backend endpoint; key is the
// setting the resulting URL is stored under.
const brandingLogos = [
{ kind: 'site', key: 'site_logo', label: 'Site logo', accept: 'image/*',
placeholder: '/ge-aerospace-logo.svg',
hint: 'Shown in the app header and login. Upload an image or type a path/URL.' },
{ kind: 'qr', key: 'qr_logo', label: 'QR overlay logo', accept: 'image/*',
placeholder: '/ge-monogram.svg',
hint: 'Logo overlaid on printed QR codes. Leave blank for no overlay.' },
{ kind: 'badge', key: 'badge_logo', label: 'Equipment badge logo', accept: 'image/*',
placeholder: '/ge-aerospace-logo.svg',
hint: 'Logo printed on equipment badges. Upload an image or type a path/URL.' },
{ kind: 'favicon', key: 'site_favicon', label: 'Favicon', accept: 'image/*,.ico',
placeholder: '(blank = shipped /favicon.svg)',
hint: 'Browser-tab icon. Leave blank to use the shipped favicon.' },
]
// Asset identifier matrix: identifier x asset type. Keys follow
// identifier_<name>_<assettype>_enabled. Missing = enabled (default on).
const identifierRows = [
@@ -797,6 +1024,7 @@ const computerTypes = ref([]) // ComputerType names for the dropdown
const loading = ref(true)
const saving = ref(false)
const mapUploading = ref(false)
const brandingUploading = ref(false)
const testingEmail = ref(false)
const error = ref('')
const success = ref('')
@@ -938,6 +1166,26 @@ async function uploadBlueprint(theme, event) {
}
}
async function uploadLogo(kind, key, event) {
const file = event.target.files[0]
if (!file) return
brandingUploading.value = true
error.value = ''
success.value = ''
try {
const { data } = await settingsApi.uploadBrandingLogo(kind, file)
const url = data?.data?.value
if (url) settings[key] = url
success.value = 'Logo uploaded'
setTimeout(() => { success.value = '' }, 2000)
} catch (e) {
error.value = apiError(e, 'Upload failed')
} finally {
brandingUploading.value = false
event.target.value = ''
}
}
async function toggleSetting(key) {
const newValue = !settings[key]
await saveSetting(key, newValue)
@@ -1349,6 +1597,9 @@ onMounted(loadSettings)
.identifier-matrix .identifier-name {
color: var(--text);
}
.color-input-row { display: flex; align-items: center; gap: 0.75rem; }
.color-input-row input[type="color"] { width: 48px; height: 34px; padding: 2px; cursor: pointer; }
.color-input-row input[type="text"] { max-width: 160px; }
.map-upload-row { display: flex; align-items: center; gap: 0.75rem; margin-top: 0.4rem; }
.map-thumb { height: 40px; border: 1px solid var(--border); border-radius: 4px; background: #fff; }
.map-thumb-dark { background: #222; }

View File

@@ -7,8 +7,9 @@ export const settingsGroups = [
{
title: 'Site & Facility',
cards: [
{ to: '/settings/site', icon: Home, title: 'Site & Facility', description: 'Site URL/FQDN, facility name, and PC access domain' },
{ to: '/settings/site', icon: Home, title: 'Site & Facility', description: 'Site URL/FQDN, facility name, PC access domain, employee-ID pattern, printer hostname template' },
{ to: '/settings/system?tab=map', icon: MapPin, title: 'Floor Map', description: 'Facility floor-plan blueprint and dimensions' },
{ to: '/settings/system?tab=branding', icon: Palette, title: 'Branding', description: 'Site, QR, and badge logos, favicon, and primary brand color' },
],
},
{
@@ -72,7 +73,7 @@ export const settingsGroups = [
{
title: 'System',
cards: [
{ to: '/settings/system', icon: Settings, title: 'System Settings', description: 'Integrations, identifiers, search, and PC-type mapping' },
{ to: '/settings/system', icon: Settings, title: 'System Settings', description: 'Integrations (ServiceNow, Zabbix), branding, identifiers, search, and PC-type mapping' },
{ to: '/settings/plugins', icon: Puzzle, title: 'Plugins', description: 'Enable or disable installed plugins' },
],
},