Multi-site distribution readiness: settings-driven site config, security closeout, release engineering, v0.5.0
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:
@@ -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 {
|
||||
|
||||
@@ -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 & 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; }
|
||||
|
||||
@@ -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' },
|
||||
],
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user