Rework the first-run setup wizard around what a site cannot run without

The wizard configured five of the product's 129 settings. A site that finished
it still had no timezone, no authentication, no mail server, no backup
destination and no endpoint agent policy, and nothing told the operator so.

Six steps now: Administrator, Site, Authentication, Features, Mail and Backups,
Review. Everything else moves to a standing setup checklist at
/settings/setupchecklist, because a wizard that asked for all 129 settings would
be worse than one that asked for none.

Two new endpoints back it. /setup/checklist reports twelve areas as
ok/partial/todo/na with a link to the page that fixes each, and /setup/health
reports account counts plus whether a plugin toggle has reached every worker.
Both take care not to lie:

- Several settings are SEEDED non-empty (the shipped logo, a placeholder floor
  plan, every identifier field), so "is it set" compares against the shipped
  default rather than emptiness.
- plugin.endpoints.sharedkeyallowed DEFAULTS TO ALLOWED when its row is absent,
  so an absent row is reported as the fleet-wide key being accepted. Reporting
  it as refused told an admin the opposite of the truth about the credential
  they are migrating away from.
- A plugin is held at "starting" until several DISTINCT workers report it live.
  Counting replies instead let one recycled worker answer four times and declare
  a change complete while other workers still served the old routes.

Recommended plugins are declared in each plugin's manifest rather than listed in
the wizard, so adding a plugin cannot leave a stale list in another file.

Also fixed: the logo preview was invisible, because the image borrowed a class
that hardcodes a white background while the shipped logo is white; and signing
in did not re-read setup state, so an admin already browsing was never steered
to the wizard and a hard refresh was the only cure.
This commit is contained in:
cproudlock
2026-09-20 09:06:29 -04:00
parent 9d6342b3ab
commit 32d3f4b29d
18 changed files with 1307 additions and 241 deletions

View File

@@ -1184,6 +1184,16 @@ export const setupApi = {
seedStarter() {
return api.post('/setup/seed-starter')
},
// What is configured and what is not, with a route to each fix. Backs the
// standing Setup Checklist page as well as the wizard's review step.
checklist() {
return api.get('/setup/checklist')
},
// Account counts plus whether plugins.json and the running process disagree,
// which is the only way to tell that a plugin toggle needs a restart.
health() {
return api.get('/setup/health')
},
complete() {
return api.post('/setup/complete')
}

View File

@@ -14,6 +14,21 @@ export function isSetupLoaded() {
return loaded.value
}
// Force the next navigation to re-read setup state.
//
// WHY THIS IS NEEDED. The router guard only calls refreshSetupState() when
// isSetupLoaded() is false, so the answer is fetched once per PAGE LOAD. Signing
// out and back in is not a page load: the module keeps `loaded` true and the
// stale value with it. An admin already browsing when setup state changes - or
// anyone signing in on a tab that was open beforehand - therefore never gets
// steered into the wizard, and a hard refresh is the only way out. Call this on
// login and logout so the next navigation asks again.
export function resetSetupState() {
loaded.value = false
setupComplete.value = true // optimistic until re-read, so nothing flashes
setupSkipped.value = false // a new session should be offered the wizard
}
export async function refreshSetupState() {
try {
const response = await settingsApi.get('setup_complete')

View File

@@ -284,5 +284,14 @@ export default [
name: 'shopdbbackups',
component: () => import('../../views/settings/ShopdbBackupsList.vue'),
meta: { requiresAuth: true, requiresAdmin: true }
},
{
// The other half of the first-run wizard. The wizard asks only for what a
// site cannot run without; this is where the rest stays visible instead of
// being buried across a dozen settings pages.
path: 'settings/setupchecklist',
name: 'setup-checklist',
component: () => import('../../views/settings/SetupChecklist.vue'),
meta: { requiresAuth: true, requiresAdmin: true }
}
]

View File

@@ -1,5 +1,6 @@
import { defineStore } from 'pinia'
import { authApi, employeesApi } from '../api'
import { resetSetupState } from '../composables/setupState'
export const useAuthStore = defineStore('auth', {
state: () => ({
@@ -43,6 +44,10 @@ export const useAuthStore = defineStore('auth', {
localStorage.setItem('token', access_token)
localStorage.setItem('refreshToken', refresh_token)
await this.enrichFromDirectory()
// Signing in is not a page load, so the router guard would otherwise
// keep whatever setup state it cached earlier in this tab and skip
// steering a fresh admin into the wizard.
resetSetupState()
return { success: true }
} catch (error) {
@@ -64,6 +69,9 @@ export const useAuthStore = defineStore('auth', {
localStorage.removeItem('token')
localStorage.removeItem('refreshToken')
localStorage.removeItem('user')
// The next user to sign in on this tab gets a fresh answer, not this
// one's. Also clears a session-only "skip for now".
resetSetupState()
},
// Clear the forced-password-change flag after a successful change so the

View File

@@ -3,7 +3,7 @@
<div class="wizard-card">
<header class="wizard-head">
<h1>Set up ShopDB</h1>
<p class="wizard-sub">A few quick steps to get this site ready.</p>
<p class="wizard-sub">The few things a site cannot run without. Everything else is on the setup checklist afterwards.</p>
<ol class="wizard-steps">
<li v-for="(s, i) in steps" :key="s.key" :class="{ active: i === step, done: i < step }">
<span class="step-num">{{ i < step ? '&check;' : i + 1 }}</span>
@@ -13,10 +13,25 @@
</header>
<section class="wizard-body">
<!-- Site -->
<div v-if="current.key === 'site'">
<h2>Site details</h2>
<p class="hint">Used for the dashboard header and QR / absolute links.</p>
<!-- Administrator -->
<div v-if="current.key === 'admin'">
<h2>Administrator</h2>
<p class="hint">You are signed in as an administrator, so the account this site was claimed with already exists. This step just confirms what is there.</p>
<dl class="fact-list">
<div><dt>Accounts</dt><dd>{{ health.users ?? '-' }}</dd></div>
<div><dt>Administrators</dt><dd>{{ health.admins ?? '-' }}</dd></div>
</dl>
<p v-if="health.admins === 1" class="warn-note">
Only one administrator exists. If that account is lost, nobody can administer this site.
Consider adding a second before going live.
</p>
<p class="hint">Add or change accounts under <router-link to="/settings/security/users">Settings &gt; Users</router-link>.</p>
</div>
<!-- Site identity -->
<div v-else-if="current.key === 'site'">
<h2>Site identity</h2>
<p class="hint">Used for the dashboard header, QR codes and every absolute link the app generates.</p>
<div class="form-group">
<label>Facility name</label>
<input v-model="form.facility_name" type="text" class="form-control" placeholder="Main Plant" />
@@ -25,24 +40,129 @@
<label>Site base URL <span class="hint">(blank = use the browsing origin)</span></label>
<input v-model="form.site_base_url" type="url" class="form-control" placeholder="https://shopdb.example.net" />
</div>
<div class="form-group">
<!-- Every stored timestamp is rendered against this. Left unset it
falls back to the server's clock, which is rarely what a site
means once the server and the floor are in different places. -->
<label>Site timezone</label>
<select v-model="form.site_timezone" class="form-control">
<option value="">Use the server timezone</option>
<option v-for="tz in timezones" :key="tz" :value="tz">{{ tz }}</option>
</select>
</div>
<div class="form-group">
<label>Site logo <span class="hint">(optional)</span></label>
<input type="file" accept="image/*" @change="uploadSiteLogo($event)" :disabled="logoUploading" />
<input v-model="logoFilename" type="text" class="form-control" placeholder="No file selected" readonly />
<img v-if="siteLogo" :src="withBase(siteLogo)" class="wizard-map-thumb" alt="site logo" />
<small class="hint">Skip to keep the shipped GE branding. You can change this later under Settings &gt; Branding.</small>
<!-- Preview on the sidebar colour, which is where this logo
actually lives. --sidebar-bg is the same dark navy in every
theme, so there is no light-background case to show. The login
box is the one light surface it reaches, and .login-logo
already inverts it there. -->
<figure v-if="siteLogo" class="logo-chip">
<img :src="withBase(siteLogo)" alt="site logo on the sidebar background" />
<figcaption>As shown in the sidebar</figcaption>
</figure>
<small class="hint">Skip to keep the shipped branding. Changeable later under Settings &gt; Branding.</small>
</div>
</div>
<!-- Authentication -->
<div v-else-if="current.key === 'auth'">
<h2>Authentication</h2>
<p class="hint">How people sign in. Local accounts always work; turn on SAML to use your identity provider.</p>
<label class="check-row">
<input type="checkbox" v-model="form.saml_enabled" />
<span>Enable SAML single sign-on</span>
</label>
<div v-if="form.saml_enabled" class="config-block">
<div class="form-group">
<label>Entity ID</label>
<input v-model="form.saml_entity_id" type="text" class="form-control" placeholder="shopdb" />
</div>
<div class="form-group">
<label>ACS URL <span class="hint">(where the IdP posts back)</span></label>
<input v-model="form.saml_acs_url" type="url" class="form-control" :placeholder="suggestedAcsUrl" />
</div>
<div class="form-group">
<label>IdP metadata URL</label>
<input v-model="form.saml_idp_metadata_url" type="url" class="form-control" />
</div>
<div class="form-group">
<label>Admin group <span class="hint">(group whose members become admins)</span></label>
<input v-model="form.saml_admin_group" type="text" class="form-control" />
</div>
<label class="check-row">
<input type="checkbox" v-model="form.saml_auto_create_users" />
<span>Create accounts automatically on first SAML login</span>
</label>
<label class="check-row">
<input type="checkbox" v-model="form.saml_allow_local_login" />
<span>Keep local username and password login available</span>
</label>
<!-- Turning SAML on and local login off in the same step can strand
an operator whose IdP is not finished yet, with no way back in. -->
<p v-if="!form.saml_allow_local_login" class="warn-note">
With local login off, a misconfigured IdP locks everyone out, including you.
Leave it on until a SAML sign-in has been proven to work.
</p>
</div>
</div>
<!-- Plugins -->
<div v-else-if="current.key === 'plugins'">
<h2>Features</h2>
<p class="hint">Turn features on or off. Changes to routes take effect after the app restarts.</p>
<p class="hint">Turn features on or off. Each one adds its own pages, API routes and tables.
Assets, applications, locations and users are core and always present, so they are not listed here.</p>
<div v-if="recommendedOff.length" class="rec-bar">
<span><strong>{{ recommendedOff.length }}</strong> recommended feature(s) are off.</span>
<button type="button" class="btn btn-secondary btn-sm" :disabled="bulkEnabling" @click="enableRecommended">
{{ bulkEnabling ? 'Enabling...' : 'Enable all recommended' }}
</button>
</div>
<!-- Plugins are read from plugins.json at startup, so a toggle here
does not reach the running process. Say so, and verify it. -->
<div v-if="health.restartrequired" class="restart-note">
<strong>Applying changes.</strong>
<span v-if="(health.plugins?.awaitingrestart || []).length">
Enabled but not yet loaded: <code>{{ (health.plugins.awaitingrestart || []).join(', ') }}</code>.
</span>
<span v-if="(health.plugins?.stillloaded || []).length">
Disabled but still running: <code>{{ (health.plugins.stillloaded || []).join(', ') }}</code>.
</span>
<p>Each worker picks the change up after finishing what it is serving,
so the site stays up while they roll over. This clears on its own,
usually within 20 seconds.</p>
<p class="progress-line">
<span class="spinner" aria-hidden="true"></span>
{{ readyWorkers.size }} of {{ REQUIRED_CLEAN_WORKERS }} workers confirmed
<span v-if="pollElapsed">({{ pollElapsed }}s)</span>
</p>
<button v-if="pollElapsed > 45" type="button" class="btn btn-secondary btn-sm"
:disabled="checkingHealth" @click="loadHealth">
{{ checkingHealth ? 'Checking...' : 'Check again' }}
</button>
</div>
<p v-else-if="health.plugins" class="ok-note">Enabled features all match what is running.</p>
<div v-if="plugins.length" class="plugin-list">
<div v-for="p in plugins" :key="p.name" class="plugin-item">
<div v-for="p in sortedPlugins" :key="p.name" class="plugin-item" :class="{ recommended: p.recommended }">
<label class="plugin-row">
<input type="checkbox" :checked="p.enabled" @change="togglePlugin(p, $event.target.checked)" />
<span class="plugin-name">{{ p.displayname || p.name }}</span>
<input type="checkbox" :checked="p.enabled"
:disabled="isPending(p) || bulkEnabling"
@change="togglePlugin(p, $event.target.checked)" />
<span class="plugin-name">
{{ p.displayname || p.name }}
<span v-if="p.recommended" class="rec-tag">recommended</span>
<!-- The API answers instantly but the plugin is not usable
until every worker has picked it up, so say which of the
three states this row is actually in. -->
<span v-if="isPending(p)" class="state-tag starting">starting{{ workerDots }}</span>
<span v-else-if="justReady[p.name]" class="state-tag ready">ready</span>
</span>
<span class="plugin-desc">{{ p.description }}</span>
</label>
@@ -58,14 +178,12 @@
Connect to your own database
</label>
<!-- Self-hosted: transparency note -->
<div v-if="pluginModes[p.provisioning_note.mode_setting] !== 'external'" class="provision-note">
<p>{{ p.provisioning_note.note }}</p>
<p class="provision-tables">Creates in shopdb: <code v-for="t in p.provisioning_note.tables" :key="t">{{ t }}</code></p>
<p v-if="p.provisioning_note.docs" class="provision-docs">Schema: <code>{{ p.provisioning_note.docs }}</code></p>
</div>
<!-- External: connection config -->
<div v-else class="config-block">
<div v-for="field in p.config_schema" :key="field.key" class="form-group">
<label>{{ field.label }}<span v-if="field.secret" class="secret-tag">secret</span></label>
@@ -82,68 +200,125 @@
</div>
<p v-else class="hint">No optional plugins found.</p>
<!-- Secrets are never stored in the DB; emit .env lines to paste -->
<!-- Plugin secrets carry an envvar and stay out of the database -->
<div v-if="envLines" class="env-block">
<p class="hint">Paste these into <code>.env</code> and restart the app (secrets are not stored in the database):</p>
<p class="hint">Paste these into <code>.env</code> and restart the app (these secrets are not stored in the database):</p>
<pre class="env-pre">{{ envLines }}</pre>
<button type="button" class="btn btn-secondary btn-sm" @click="copyEnv">Copy</button>
</div>
</div>
<!-- Floor map -->
<div v-else-if="current.key === 'map'">
<h2>Floor map</h2>
<p class="hint">Upload your facility blueprint (light and dark) for the default level, and set its native pixel size. Additional levels can be added later in Settings.</p>
<div class="form-group">
<label>Blueprint image (light theme)</label>
<input type="file" accept="image/*" @change="uploadBlueprint('light', $event)" :disabled="mapUploading" />
<input v-model="blueprintLightFilename" type="text" class="form-control" placeholder="No file selected" readonly />
<img v-if="blueprintLight" :src="withBase(blueprintLight)" class="wizard-map-thumb" alt="light blueprint" />
</div>
<div class="form-group">
<label>Blueprint image (dark theme)</label>
<input type="file" accept="image/*" @change="uploadBlueprint('dark', $event)" :disabled="mapUploading" />
<input v-model="blueprintDarkFilename" type="text" class="form-control" placeholder="No file selected" readonly />
<img v-if="blueprintDark" :src="withBase(blueprintDark)" class="wizard-map-thumb dark" alt="dark blueprint" />
</div>
<div class="form-row">
<div class="form-group">
<label>Map width (px)</label>
<input v-model.number="form.map_width" type="number" class="form-control" placeholder="2000" />
<!-- Essentials: mail + backup destination -->
<div v-else-if="current.key === 'essentials'">
<h2>Mail and backups</h2>
<p class="hint">Without a mail server no alert or notification can leave the site, and without a destination no backup leaves this host.</p>
<h3 class="sub-head">Outgoing mail</h3>
<label class="check-row">
<input type="checkbox" v-model="form.smtp_enabled" />
<span>Send mail through an SMTP server</span>
</label>
<div v-if="form.smtp_enabled" class="config-block">
<div class="form-row">
<div class="form-group">
<label>SMTP host</label>
<input v-model="form.smtp_host" type="text" class="form-control" placeholder="smtp.example.net" />
</div>
<div class="form-group">
<label>Port</label>
<input v-model="form.smtp_port" type="number" class="form-control" placeholder="587" />
</div>
</div>
<div class="form-group">
<label>Map height (px)</label>
<input v-model.number="form.map_height" type="number" class="form-control" placeholder="1200" />
<label>From address</label>
<input v-model="form.smtp_from_address" type="email" class="form-control" placeholder="shopdb@example.net" />
</div>
<div class="form-group">
<label>From name <span class="hint">(optional)</span></label>
<input v-model="form.smtp_from_name" type="text" class="form-control" placeholder="ShopDB" />
</div>
<div class="form-row">
<div class="form-group">
<label>Username <span class="hint">(optional)</span></label>
<input v-model="form.smtp_username" type="text" class="form-control" autocomplete="off" />
</div>
<div class="form-group">
<label>Password <span class="hint">(optional)</span></label>
<input v-model="form.smtp_password" type="password" class="form-control" autocomplete="new-password"
:placeholder="secretsAlreadySet.smtp_password ? 'unchanged' : ''" />
</div>
</div>
<label class="check-row">
<input type="checkbox" v-model="form.smtp_use_tls" />
<span>Use STARTTLS</span>
</label>
</div>
<h3 class="sub-head">Backup destination</h3>
<div class="form-group">
<label>Where backups are copied</label>
<select v-model="form.backup_destination_kind" class="form-control">
<option value="local">This host only</option>
<option value="smb">Windows / SMB share</option>
<option value="aws">Amazon S3</option>
</select>
<small v-if="form.backup_destination_kind === 'local'" class="hint">
Nothing leaves this machine, so losing the host loses the backups with it.
</small>
</div>
<div v-if="form.backup_destination_kind === 'smb'" class="config-block">
<div class="form-row">
<div class="form-group"><label>Host</label><input v-model="form.backup_smb_host" type="text" class="form-control" /></div>
<div class="form-group"><label>Share</label><input v-model="form.backup_smb_share" type="text" class="form-control" /></div>
</div>
<div class="form-group"><label>Path within the share <span class="hint">(optional)</span></label><input v-model="form.backup_smb_path" type="text" class="form-control" /></div>
<div class="form-row">
<div class="form-group"><label>Username</label><input v-model="form.backup_smb_username" type="text" class="form-control" autocomplete="off" /></div>
<div class="form-group"><label>Password</label><input v-model="form.backup_smb_password" type="password" class="form-control" autocomplete="new-password"
:placeholder="secretsAlreadySet.backup_smb_password ? 'unchanged' : ''" /></div>
</div>
</div>
<div v-else-if="form.backup_destination_kind === 'aws'" class="config-block">
<div class="form-row">
<div class="form-group"><label>Bucket</label><input v-model="form.backup_aws_bucket" type="text" class="form-control" /></div>
<div class="form-group"><label>Region</label><input v-model="form.backup_aws_region" type="text" class="form-control" placeholder="us-east-1" /></div>
</div>
<div class="form-group"><label>Prefix <span class="hint">(optional)</span></label><input v-model="form.backup_aws_prefix" type="text" class="form-control" /></div>
<div class="form-row">
<div class="form-group"><label>Access key id</label><input v-model="form.backup_aws_accesskeyid" type="text" class="form-control" autocomplete="off" /></div>
<div class="form-group"><label>Secret access key</label><input v-model="form.backup_aws_secretaccesskey" type="password" class="form-control" autocomplete="new-password"
:placeholder="secretsAlreadySet.backup_aws_secretaccesskey ? 'unchanged' : ''" /></div>
</div>
</div>
</div>
<!-- Starter data -->
<div v-else-if="current.key === 'data'">
<h2>Reference data</h2>
<p class="hint">Seed the lookup data a new site needs: statuses, types, permissions, and a short list of common vendors. Both are idempotent - safe to run more than once.</p>
<p class="hint">This does not create any assets. The site starts empty and assets are loaded afterwards via the import API, so an empty dashboard here is expected.</p>
<div class="starter-actions">
<div>
<button class="btn btn-secondary" :disabled="seeding" @click="seedReference">
{{ seeding ? 'Working...' : 'Seed core reference data' }}
</button>
<p class="hint">Statuses, machine/location/relationship types, permissions, default settings. Run this if you installed without the CLI seed.</p>
</div>
<div>
<button class="btn btn-secondary" :disabled="seeding" @click="seedStarter">
{{ seeding ? 'Working...' : 'Add common vendors' }}
</button>
<p class="hint">Dell, HP, Lenovo, and other common hardware vendors.</p>
</div>
</div>
<p v-if="seedResult" class="seed-result">{{ seedResult }}</p>
</div>
<!-- Review -->
<div v-else-if="current.key === 'review'">
<h2>Review</h2>
<p class="hint">Seed the lookup data this site needs, then finish. Anything still outstanding stays on the setup checklist, reachable from Settings at any time.</p>
<!-- Finish -->
<div v-else-if="current.key === 'finish'">
<h2>All set</h2>
<p class="hint">You can change any of this later under Settings. Finish to go to the dashboard.</p>
<div class="seed-row">
<button class="btn btn-secondary" :disabled="seeding" @click="seedReference">
{{ seeding ? 'Working...' : 'Seed reference data and permissions' }}
</button>
<button class="btn btn-secondary" :disabled="seeding" @click="seedStarter">
{{ seeding ? 'Working...' : 'Seed common vendors' }}
</button>
</div>
<p v-if="seedResult" class="ok-note">{{ seedResult }}</p>
<p class="hint">Both are idempotent, so running them twice is harmless. Neither creates assets; the site starts empty and assets arrive through the import API.</p>
<h3 class="sub-head">Still to do</h3>
<p v-if="checklistLoading" class="hint">Loading...</p>
<p v-else-if="checklistError" class="warn-note">{{ checklistError }}</p>
<p v-else-if="!checklist.length" class="ok-note">Nothing outstanding. This site is fully configured.</p>
<ul v-else class="check-list">
<li v-for="a in checklist" :key="a.key" :class="'st-' + a.status">
<span class="st-badge">{{ statusLabel(a.status) }}</span>
<span class="st-label">{{ a.label }}</span>
<span class="st-detail">{{ a.detail }}</span>
</li>
</ul>
</div>
</section>
@@ -165,7 +340,7 @@
<script setup>
import { withBase } from '../utils/basePath'
import { ref, computed, onMounted } from 'vue'
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
import { useRouter } from 'vue-router'
import { settingsApi, pluginsApi, setupApi } from '../api'
import { useToast } from '../composables/toast'
@@ -175,37 +350,187 @@ import { refreshSetupState, setupSkipped } from '../composables/setupState'
const router = useRouter()
const toast = useToast()
// Six steps, and deliberately no more. The product has well over a hundred
// settings; asking for all of them up front would be worse than asking for
// none. These are the ones a site cannot sensibly run without. Everything else
// lives on the setup checklist, which stays reachable after setup finishes.
const steps = [
{ key: 'admin', label: 'Administrator' },
{ key: 'site', label: 'Site' },
{ key: 'auth', label: 'Authentication' },
{ key: 'plugins', label: 'Features' },
{ key: 'map', label: 'Floor Map' },
{ key: 'data', label: 'Reference Data' },
{ key: 'finish', label: 'Finish' },
{ key: 'essentials', label: 'Mail & Backups' },
{ key: 'review', label: 'Review' },
]
const step = ref(0)
const current = computed(() => steps[step.value])
// Booleans are kept as real booleans here and stringified on save, because the
// settings table stores everything as text.
const form = ref({
facility_name: '', site_base_url: '',
map_width: null, map_height: null,
facility_name: '', site_base_url: '', site_timezone: '',
saml_enabled: false, saml_entity_id: '', saml_acs_url: '',
saml_idp_metadata_url: '', saml_admin_group: '',
saml_auto_create_users: false, saml_allow_local_login: true,
smtp_enabled: false, smtp_host: '', smtp_port: null,
smtp_from_address: '', smtp_from_name: '', smtp_username: '',
smtp_password: '', smtp_use_tls: true,
backup_destination_kind: 'local',
backup_smb_host: '', backup_smb_share: '', backup_smb_path: '',
backup_smb_username: '', backup_smb_password: '',
backup_aws_bucket: '', backup_aws_region: '', backup_aws_prefix: '',
backup_aws_accesskeyid: '', backup_aws_secretaccesskey: '',
})
// Written but never read back into the form. The settings API masks these on
// read, so round-tripping the mask corrupts the stored value.
const SECRET_KEYS = new Set([
'smtp_password', 'backup_smb_password', 'backup_aws_secretaccesskey',
])
const secretsAlreadySet = ref({})
const BOOLEAN_KEYS = new Set([
'saml_enabled', 'saml_auto_create_users', 'saml_allow_local_login',
'smtp_enabled', 'smtp_use_tls',
])
const plugins = ref([])
const pluginConfig = ref({}) // flat map: setting/field key -> value
const pluginModes = ref({}) // mode_setting key -> 'selfhosted' | 'external'
const saving = ref(false)
const seeding = ref(false)
const seedResult = ref('')
const mapUploading = ref(false)
const blueprintLight = ref('')
const blueprintDark = ref('')
const logoUploading = ref(false)
const siteLogo = ref('')
// A file input shows the chosen name in the browser chrome, which the kiosk-
// style full-screen wizard hides, so the operator had no confirmation that the
// file they picked was the one being uploaded.
const logoFilename = ref('')
const blueprintLightFilename = ref('')
const blueprintDarkFilename = ref('')
const health = ref({})
const checkingHealth = ref(false)
// Plugins toggled in this session that have not yet shown up as live. Driven by
// /setup/health rather than a timer, so the UI says "starting" for exactly as
// long as it really is starting.
const pendingPlugins = ref(new Set())
const justReady = ref({})
const readyWorkers = ref(new Set())
const pollElapsed = ref(0)
let pollTimer = null
let pollStartedAt = 0
const workerDots = ref('')
let dotTimer = null
function isPending(plugin) {
return pendingPlugins.value.has(plugin.name)
}
function startDots() {
if (dotTimer) return
dotTimer = setInterval(() => {
workerDots.value = workerDots.value.length >= 3 ? '' : workerDots.value + '.'
}, 500)
}
function stopDots() {
if (dotTimer) { clearInterval(dotTimer); dotTimer = null }
workerDots.value = ''
}
function stopPolling() {
if (pollTimer) { clearTimeout(pollTimer); pollTimer = null }
stopDots()
pollElapsed.value = 0
}
// Poll until the pool has rolled over.
//
// A single clean reply proves nothing about the fleet: it came from ONE worker,
// and the others may still hold the old set. Each reply names the worker that
// answered, so wait until several DISTINCT workers have each reported clean.
// Counting replies instead of workers let one recycled worker answer four times
// and declare victory while an idle worker still served the old routes.
const REQUIRED_CLEAN_WORKERS = 3
const MAX_WATCH_SECONDS = 120
const MAX_CONSECUTIVE_FAILURES = 10
function beginWatching(names) {
if (!names || !names.length) return // nothing succeeded: nothing to watch
names.forEach(n => pendingPlugins.value.add(n))
pendingPlugins.value = new Set(pendingPlugins.value)
readyWorkers.value = new Set()
startDots()
if (pollTimer) return
pollStartedAt = Date.now()
let failures = 0
const tick = async () => {
pollElapsed.value = Math.round((Date.now() - pollStartedAt) / 1000)
// Checked BEFORE the request, so a poll that keeps failing still stops.
// Returning early on error used to skip this and spin indefinitely.
if (pollElapsed.value > MAX_WATCH_SECONDS || failures >= MAX_CONSECUTIVE_FAILURES) {
stopPolling()
toast.error('Still applying. Restart the app if this does not clear.')
return
}
let data = null
try {
data = (await setupApi.health()).data?.data || {}
failures = 0
} catch (err) {
failures += 1 // a worker mid-recycle can refuse a poll
}
if (data) {
health.value = data
const awaiting = new Set(data?.plugins?.awaitingrestart || [])
const stale = new Set(data?.plugins?.stillloaded || [])
// stillloaded matters as much as awaitingrestart: DISABLING a plugin is
// only finished once no worker is still serving it.
const clean = awaiting.size === 0 && stale.size === 0
if (clean && data.worker) {
readyWorkers.value = new Set([...readyWorkers.value, data.worker])
} else if (!clean) {
readyWorkers.value = new Set() // someone regressed; start counting again
}
if (readyWorkers.value.size >= REQUIRED_CLEAN_WORKERS) {
for (const name of pendingPlugins.value) {
justReady.value[name] = true
setTimeout(() => { delete justReady.value[name] }, 6000)
}
pendingPlugins.value = new Set()
stopPolling()
return
}
}
pollTimer = setTimeout(tick, 2000)
}
pollTimer = setTimeout(tick, 1500)
}
onBeforeUnmount(stopPolling)
const checklist = ref([])
// Three states, not two: an empty list means "all green", which is the opposite
// of "still loading" and must not render as a permanent Loading hint.
const checklistLoading = ref(false)
const checklistError = ref('')
// Offered as a list rather than free text so a typo cannot silently leave every
// timestamp on the server clock. Intl gives the real zone list where available.
const timezones = computed(() => {
try {
if (typeof Intl.supportedValuesOf === 'function') return Intl.supportedValuesOf('timeZone')
} catch (err) { /* not supported in this browser */ }
return ['UTC', 'America/New_York', 'America/Chicago', 'America/Denver',
'America/Los_Angeles', 'Europe/London', 'Europe/Paris', 'Asia/Tokyo']
})
const suggestedAcsUrl = computed(() => {
const base = (form.value.site_base_url || window.location.origin).replace(/\/+$/, '')
return `${base}/api/auth/saml/acs`
})
function statusLabel(status) {
return { ok: 'done', partial: 'partial', todo: 'to do', na: 'n/a' }[status] || status
}
async function uploadSiteLogo(event) {
const file = event.target.files[0]
@@ -227,24 +552,46 @@ async function uploadSiteLogo(event) {
}
}
async function uploadBlueprint(theme, event) {
const file = event.target.files[0]
if (!file) return
if (theme === 'light') blueprintLightFilename.value = file.name
else blueprintDarkFilename.value = file.name
mapUploading.value = true
try {
const { data } = await settingsApi.uploadMapBlueprint(theme, file)
const url = data?.data?.value ? `${data.data.value}?t=${Date.now()}` : ''
if (theme === 'light') blueprintLight.value = url
else blueprintDark.value = url
toast.success('Blueprint uploaded')
} catch (err) {
toast.error(apiError(err, 'Upload failed'))
} finally {
mapUploading.value = false
event.target.value = ''
// Recommended first, then alphabetical inside each group, so the baseline a
// shop-floor site needs is what an operator sees before anything optional.
// The flag comes from each plugin's manifest, not a list held here.
const sortedPlugins = computed(() =>
[...plugins.value].sort((a, b) =>
(b.recommended === true) - (a.recommended === true) ||
(a.displayname || a.name).localeCompare(b.displayname || b.name)))
const recommendedOff = computed(() =>
plugins.value.filter(p => p.recommended && !p.enabled))
const bulkEnabling = ref(false)
async function enableRecommended() {
bulkEnabling.value = true
// Sequential, not Promise.all: each call rewrites plugins.json, and
// concurrent writers would race and lose toggles.
//
// Each is also tried independently. Aborting the loop on the first failure
// left the operator with one plugin enabled, no indication which, and a
// generic error - report what actually happened instead.
const failed = []
const enabledNames = []
for (const plugin of recommendedOff.value.slice()) {
try {
await pluginsApi.setEnabled(plugin.name, true)
plugin.enabled = true
enabledNames.push(plugin.name)
} catch (err) {
failed.push(plugin.displayname || plugin.name)
}
}
bulkEnabling.value = false
if (failed.length) {
toast.error(`Could not enable: ${failed.join(', ')}`)
}
// Only the ones that actually succeeded. Watching the failures too marked
// them "ready" the moment health came back clean, because a plugin that was
// never enabled never appears in awaitingrestart.
beginWatching(enabledNames)
}
function modeOf(plugin) {
@@ -258,7 +605,9 @@ const configurablePlugins = computed(() =>
plugins.value.filter(p => p.enabled && (p.config_schema || []).length &&
(!p.provisioning_note?.mode_setting || modeOf(p) === 'external')))
// .env lines for secret fields that have a value entered.
// .env lines for plugin secret fields that declare an envvar. Plugin secrets
// keep the paste-into-.env model; the core settings on the Mail and Backups
// step are stored in the settings table, which the public settings read filters.
const envLines = computed(() => {
const lines = []
for (const plugin of configurablePlugins.value) {
@@ -274,18 +623,58 @@ const envLines = computed(() => {
// Which settings each step owns, so Next only saves what changed on that step.
const stepSettings = {
site: ['facility_name', 'site_base_url'],
map: ['map_width', 'map_height'],
site: ['facility_name', 'site_base_url', 'site_timezone'],
auth: ['saml_enabled', 'saml_entity_id', 'saml_acs_url', 'saml_idp_metadata_url',
'saml_admin_group', 'saml_auto_create_users', 'saml_allow_local_login'],
essentials: ['smtp_enabled', 'smtp_host', 'smtp_port', 'smtp_from_address',
'smtp_from_name', 'smtp_username', 'smtp_password', 'smtp_use_tls',
'backup_destination_kind', 'backup_smb_host', 'backup_smb_share',
'backup_smb_path', 'backup_smb_username', 'backup_smb_password',
'backup_aws_bucket', 'backup_aws_region', 'backup_aws_prefix',
'backup_aws_accesskeyid', 'backup_aws_secretaccesskey'],
}
const ALL_FORM_KEYS = Object.values(stepSettings).flat()
async function loadHealth() {
checkingHealth.value = true
try {
const { data } = await setupApi.health()
health.value = data?.data || {}
} catch (err) { /* non-fatal: the wizard still works without it */ }
finally { checkingHealth.value = false }
}
async function loadChecklist() {
checklistLoading.value = true
checklistError.value = ''
try {
const { data } = await setupApi.checklist()
checklist.value = (data?.data?.areas || []).filter(a => a.status !== 'ok' && a.status !== 'na')
} catch (err) {
checklistError.value = apiError(err, 'Could not load the setup checklist')
} finally {
checklistLoading.value = false
}
}
onMounted(async () => {
// Load current values for the settings the wizard edits.
const keys = ['facility_name', 'site_base_url', 'map_width', 'map_height']
for (const key of keys) {
for (const key of ALL_FORM_KEYS) {
try {
const response = await settingsApi.get(key)
const value = response.data?.data?.value
if (value !== undefined && value !== null && value !== '') form.value[key] = value
if (value === undefined || value === null || value === '') continue
// Never load a secret into its input. The API returns stored secrets as
// a mask, and pre-filling it meant typing appended to the mask and saved
// the mask plus the new text verbatim. Leave the field blank: blank is
// "unchanged" on save, and the placeholder says a value is already set.
if (SECRET_KEYS.has(key)) {
secretsAlreadySet.value[key] = true
continue
}
form.value[key] = BOOLEAN_KEYS.has(key)
? String(value).toLowerCase() === 'true'
: value
} catch (err) { /* setting may not exist yet */ }
}
try {
@@ -322,11 +711,19 @@ onMounted(async () => {
} catch (err) {
siteLogo.value = SHIPPED_LOGO
}
await loadHealth()
})
// The review step is only honest if it reflects what the earlier steps just
// saved, so re-read rather than showing a snapshot from page load.
watch(step, (value) => {
if (steps[value].key === 'review') loadChecklist()
if (steps[value].key === 'plugins') loadHealth()
})
async function saveStep() {
// Plugins step: save each plugin's directory mode, plus non-secret external
// connection config (secrets stay in .env).
// connection config (plugin secrets stay in .env).
if (current.value.key === 'plugins') {
for (const [modeKey, modeValue] of Object.entries(pluginModes.value)) {
await settingsApi.update(modeKey, modeValue)
@@ -339,6 +736,7 @@ async function saveStep() {
await settingsApi.update(field.key, String(value))
}
}
await loadHealth()
return
}
const keys = stepSettings[current.value.key]
@@ -346,7 +744,11 @@ async function saveStep() {
for (const key of keys) {
const value = form.value[key]
if (value === null || value === undefined) continue
await settingsApi.update(key, String(value))
// Blank means "leave the stored secret alone". There is deliberately no way
// to clear one from here: clearing a credential is a destructive action and
// belongs on the settings page that owns it, not in a first-run wizard.
if (value === '' && SECRET_KEYS.has(key)) continue
await settingsApi.update(key, BOOLEAN_KEYS.has(key) ? String(!!value) : String(value))
}
}
@@ -382,7 +784,8 @@ async function togglePlugin(plugin, enabled) {
try {
await pluginsApi.setEnabled(plugin.name, enabled)
plugin.enabled = enabled
toast.success(`${plugin.name} ${enabled ? 'enabled' : 'disabled'} (restart to apply route changes).`)
beginWatching([plugin.name])
toast.success(`${plugin.displayname || plugin.name} ${enabled ? 'enabled' : 'disabled'}.`)
} catch (err) {
toast.error(apiError(err, 'Could not change plugin'))
}
@@ -393,6 +796,7 @@ async function seedReference() {
try {
const response = await setupApi.seedReference()
seedResult.value = response.data?.message || 'Reference data seeded.'
await loadChecklist()
} catch (err) {
toast.error(apiError(err, 'Could not seed reference data'))
} finally {
@@ -406,6 +810,7 @@ async function seedStarter() {
const response = await setupApi.seedStarter()
const data = response.data?.data || {}
seedResult.value = data.addedcount ? `Added ${data.addedcount}: ${data.added.join(', ')}` : 'All common vendors already present.'
await loadChecklist()
} catch (err) {
toast.error(apiError(err, 'Could not seed starter data'))
} finally {
@@ -438,36 +843,53 @@ async function finish() {
.wizard-steps li { display: flex; align-items: center; gap: 0.4rem; font-size: 0.82rem; color: var(--text-light); }
.wizard-steps li.active { color: var(--primary); font-weight: 600; }
.wizard-steps li.done { color: var(--success); }
.step-num { display: inline-flex; align-items: center; justify-content: center; width: 20px; height: 20px; border-radius: 50%; border: 1px solid currentColor; font-size: 0.72rem; }
.wizard-body { padding: 1.5rem 1.75rem; min-height: 220px; }
.wizard-body h2 { margin-top: 0; }
.hint { color: var(--text-light); font-size: 0.88rem; }
.form-group { margin-bottom: 1rem; }
.form-group label { display: block; margin-bottom: 0.3rem; font-weight: 500; }
.form-row { display: flex; gap: 1rem; }
.form-row .form-group { flex: 1; }
.wizard-body { padding: 1.5rem 1.75rem; }
.sub-head { margin: 1.5rem 0 0.5rem; font-size: 0.95rem; }
.check-row { display: flex; align-items: center; gap: 0.5rem; margin: 0.5rem 0; }
.config-block { border-left: 2px solid var(--border); padding-left: 1rem; margin: 0.75rem 0 0; }
.fact-list { margin: 0.5rem 0; }
.fact-list > div { display: flex; gap: 0.75rem; padding: 0.3rem 0; }
.fact-list dt { color: var(--text-light); min-width: 9rem; margin: 0; }
.fact-list dd { margin: 0; font-weight: 600; }
.warn-note { border-left: 3px solid var(--warning, #b8860b); padding: 0.5rem 0.75rem; margin: 0.75rem 0; background: rgba(184, 134, 11, 0.08); }
.ok-note { border-left: 3px solid var(--success, #2e7d32); padding: 0.5rem 0.75rem; margin: 0.75rem 0; background: rgba(46, 125, 50, 0.08); }
.restart-note { border-left: 3px solid var(--warning, #b8860b); padding: 0.6rem 0.85rem; margin: 0 0 1rem; background: rgba(184, 134, 11, 0.08); }
.restart-note p { margin: 0.4rem 0 0.6rem; }
.seed-row { display: flex; gap: 0.6rem; flex-wrap: wrap; margin: 0.75rem 0; }
.check-list { list-style: none; padding: 0; margin: 0.5rem 0 0; }
.check-list li { display: grid; grid-template-columns: 5rem 11rem 1fr; gap: 0.6rem; align-items: baseline; padding: 0.35rem 0; border-bottom: 1px solid var(--border); }
.st-badge { font-size: 10px; text-transform: uppercase; letter-spacing: 0.4px; }
.st-todo .st-badge { color: var(--text-light); }
.st-partial .st-badge { color: var(--warning, #b8860b); font-weight: 700; }
.st-label { font-weight: 600; }
.st-detail { color: var(--text-light); font-size: 0.86rem; }
/* Logo preview. Deliberately NOT .wizard-map-thumb: that class hardcodes a
white background for blueprint previews, which made the shipped white logo
invisible. --sidebar-bg is dark in every theme, so preview against it. */
.logo-chip { margin: 0.5rem 0 0; width: fit-content; border: 1px solid var(--border); border-radius: 4px; padding: 0.6rem 0.9rem; background: var(--sidebar-bg, #00003d); display: flex; flex-direction: column; align-items: center; gap: 0.4rem; }
.logo-chip img { max-height: 54px; max-width: 170px; height: auto; display: block; }
.logo-chip figcaption { font-size: 11px; letter-spacing: 0.3px; text-transform: uppercase; color: rgba(255, 255, 255, 0.7); }
.plugin-list { display: flex; flex-direction: column; gap: 0.6rem; }
.plugin-row { display: grid; grid-template-columns: auto auto 1fr; gap: 0.6rem; align-items: baseline; padding: 0.5rem 0.6rem; background: var(--bg); border-radius: 6px; }
.plugin-item { border: 1px solid var(--border); border-radius: 6px; padding: 0.6rem 0.8rem; }
.plugin-item.recommended { border-left: 3px solid var(--primary); }
.state-tag { margin-left: 0.5rem; font-size: 10px; text-transform: uppercase; letter-spacing: 0.4px; font-weight: 600; }
.state-tag.starting { color: var(--warning, #b8860b); }
.state-tag.ready { color: var(--success, #2e7d32); }
.progress-line { display: flex; align-items: center; gap: 0.5rem; margin: 0.5rem 0 0; font-size: 0.86rem; }
.spinner { width: 12px; height: 12px; border: 2px solid var(--border); border-top-color: var(--primary); border-radius: 50%; display: inline-block; animation: spin 0.8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.rec-tag { margin-left: 0.45rem; font-size: 10px; text-transform: uppercase; letter-spacing: 0.4px; color: var(--primary); font-weight: 600; }
.rec-bar { display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; flex-wrap: wrap; padding: 0.6rem 0.85rem; margin: 0 0 1rem; border-left: 3px solid var(--primary); background: var(--bg); border-radius: 4px; }
.plugin-row { display: grid; grid-template-columns: auto 10rem 1fr; gap: 0.6rem; align-items: baseline; }
.plugin-name { font-weight: 600; }
.plugin-desc { color: var(--text-light); font-size: 0.85rem; }
.plugin-mode { margin: 0.4rem 0 0.2rem 1.9rem; }
.mode-opt { display: block; font-size: 0.86rem; margin-bottom: 0.3rem; cursor: pointer; }
.mode-opt input { margin-right: 0.4rem; }
.plugin-mode .provision-note, .plugin-mode .config-block { margin-left: 0; }
.provision-note { margin: 0.3rem 0 0.2rem 1.9rem; padding: 0.6rem 0.75rem; background: var(--bg); border-left: 3px solid var(--warning); border-radius: 4px; font-size: 0.82rem; }
.provision-note p { margin: 0 0 0.35rem; }
.provision-note p:last-child { margin-bottom: 0; }
.provision-tables code, .provision-docs code { background: var(--bg-card); border: 1px solid var(--border); border-radius: 3px; padding: 0 0.3rem; margin-right: 0.3rem; font-size: 0.78rem; }
.seed-result { margin-top: 0.75rem; color: var(--success); font-size: 0.88rem; }
.starter-actions { display: flex; flex-direction: column; gap: 1.1rem; }
.starter-actions .hint { margin: 0.35rem 0 0; }
.config-block { margin-top: 1.25rem; padding-top: 1rem; border-top: 1px solid var(--border); }
.config-title { margin: 0 0 0.75rem; font-size: 1rem; text-transform: capitalize; }
.secret-tag { margin-left: 0.5rem; font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.04em; color: var(--warning); border: 1px solid var(--warning); border-radius: 4px; padding: 0 0.3rem; }
.plugin-desc { color: var(--text-light); font-size: 0.86rem; }
.plugin-mode { margin-top: 0.6rem; padding-left: 1.6rem; }
.mode-opt { display: flex; align-items: center; gap: 0.4rem; margin: 0.25rem 0; }
.provision-note { margin-top: 0.5rem; font-size: 0.86rem; color: var(--text-light); }
.provision-tables code, .provision-docs code { margin-right: 0.35rem; }
.secret-tag { margin-left: 0.4rem; font-size: 10px; text-transform: uppercase; letter-spacing: 0.4px; color: var(--warning, #b8860b); }
.env-block { margin-top: 1rem; }
.env-pre { background: var(--bg); border: 1px solid var(--border); border-radius: 6px; padding: 0.75rem; overflow-x: auto; font-size: 0.82rem; margin: 0.5rem 0; }
.wizard-map-thumb { display: block; margin-top: 0.5rem; max-height: 90px; border: 1px solid var(--border); border-radius: 4px; background: #fff; }
.wizard-map-thumb.dark { background: #222; }
.env-pre { background: var(--bg); border: 1px solid var(--border); border-radius: 4px; padding: 0.6rem; overflow-x: auto; }
.wizard-foot { display: flex; justify-content: space-between; align-items: center; padding: 1rem 1.75rem; border-top: 1px solid var(--border); }
.foot-right { display: flex; align-items: center; gap: 0.75rem; }
.btn-text { background: none; border: none; color: var(--text-light); text-decoration: none; }

View File

@@ -10,8 +10,17 @@
<template v-else>
<p class="hint">
Disabling a plugin removes its pages and API on the next app restart.
Enabling or disabling a plugin takes effect on its own: each worker
picks the change up after finishing what it is serving and restarts
itself, so the site stays up while they roll over. A plugin is usable
once its row reads <strong>ready</strong>.
</p>
<div v-if="pending.size" class="rolling-note">
<span class="spinner" aria-hidden="true"></span>
Applying: <code>{{ [...pending].join(', ') }}</code>
<span class="muted">{{ readyWorkers.size }} of {{ REQUIRED_CLEAN_WORKERS }} workers confirmed</span>
<span v-if="elapsed">({{ elapsed }}s)</span>
</div>
<div class="table-container">
<table>
<thead>
@@ -24,14 +33,19 @@
</thead>
<tbody>
<tr v-for="p in plugins" :key="p.name">
<td>{{ p.displayname || p.name }}</td>
<td>
{{ p.displayname || p.name }}
<span v-if="pending.has(p.name)" class="state-tag starting">starting{{ dots }}</span>
<span v-else-if="justReady[p.name]" class="state-tag ready">ready</span>
<span v-else-if="p.recommended" class="state-tag rec">recommended</span>
</td>
<td class="mono">{{ p.version }}</td>
<td class="cell-truncate" :title="p.description">{{ p.description || '-' }}</td>
<td>
<button
class="toggle-btn"
:class="{ active: p.enabled }"
:disabled="saving"
:disabled="pending.has(p.name)"
@click="toggle(p)"
>
<span class="toggle-slider"></span>
@@ -49,15 +63,21 @@
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { pluginsApi } from '../../api'
import { ref, onMounted, onBeforeUnmount } from 'vue'
import { pluginsApi, setupApi } from '../../api'
const plugins = ref([])
const contractVersion = ref('')
const loading = ref(true)
const saving = ref(false)
const error = ref('')
const message = ref('')
const pending = ref(new Set())
const justReady = ref({})
const dots = ref('')
const elapsed = ref(0)
const readyWorkers = ref(new Set())
let timer = null
let dotTimer = null
onMounted(() => load())
@@ -77,20 +97,110 @@ async function load() {
async function toggle(p) {
error.value = ''
message.value = ''
saving.value = true
try {
const response = await pluginsApi.setEnabled(p.name, !p.enabled)
await pluginsApi.setEnabled(p.name, !p.enabled)
p.enabled = !p.enabled
message.value = response.data.message || 'Updated'
watchUntilLive(p.name)
} catch (err) {
error.value = err.response?.data?.data?.error?.message || 'Failed to update plugin'
} finally {
saving.value = false
}
}
// The API answers as soon as the registry is written, but the plugin is not
// usable until every worker has recycled onto the new set. Poll /setup/health
// and hold the row in "starting" until it really is live, so the only state on
// screen is the one the fleet is actually in.
//
// Each reply names the worker that answered. Waiting for several DISTINCT
// workers to report clean is the point: counting replies instead let a single
// recycled worker answer repeatedly and declare the change complete while an
// idle worker still served the old routes.
const REQUIRED_CLEAN_WORKERS = 3
const MAX_WATCH_SECONDS = 120
const MAX_CONSECUTIVE_FAILURES = 10
function watchUntilLive(name) {
pending.value = new Set([...pending.value, name])
readyWorkers.value = new Set()
startDots()
if (timer) return
const startedAt = Date.now()
let failures = 0
const tick = async () => {
elapsed.value = Math.round((Date.now() - startedAt) / 1000)
// Checked BEFORE the request: a poll that keeps failing (expired token,
// API down) must still time out rather than spin with the spinner up.
if (elapsed.value > MAX_WATCH_SECONDS || failures >= MAX_CONSECUTIVE_FAILURES) {
error.value = 'Still applying. Restart the app if this does not clear.'
pending.value = new Set()
stopWatching()
return
}
let data = null
try {
data = (await setupApi.health()).data?.data || {}
failures = 0
} catch (err) {
failures += 1 // a worker mid-recycle may refuse one poll
}
if (data) {
const awaiting = new Set(data?.plugins?.awaitingrestart || [])
const stale = new Set(data?.plugins?.stillloaded || [])
// Both matter: enabling clears awaitingrestart, disabling clears
// stillloaded, and a toggle is not done until neither names anything.
const clean = awaiting.size === 0 && stale.size === 0
if (clean && data.worker) {
readyWorkers.value = new Set([...readyWorkers.value, data.worker])
} else if (!clean) {
readyWorkers.value = new Set()
}
if (readyWorkers.value.size >= REQUIRED_CLEAN_WORKERS) {
for (const n of pending.value) {
justReady.value[n] = true
setTimeout(() => { delete justReady.value[n] }, 6000)
}
pending.value = new Set()
stopWatching()
return
}
}
timer = setTimeout(tick, 2000)
}
timer = setTimeout(tick, 1500)
}
function startDots() {
if (dotTimer) return
dotTimer = setInterval(() => {
dots.value = dots.value.length >= 3 ? '' : dots.value + '.'
}, 500)
}
function stopWatching() {
if (timer) { clearTimeout(timer); timer = null }
if (dotTimer) { clearInterval(dotTimer); dotTimer = null }
dots.value = ''
elapsed.value = 0
}
onBeforeUnmount(stopWatching)
</script>
<style scoped>
.state-tag { margin-left: 0.5rem; font-size: 10px; text-transform: uppercase; letter-spacing: 0.4px; font-weight: 600; }
.state-tag.starting { color: var(--warning, #b8860b); }
.state-tag.ready { color: var(--success, #2e7d32); }
.state-tag.rec { color: var(--primary); }
.muted { color: var(--text-light); }
.rolling-note { display: flex; align-items: center; gap: 0.5rem; margin: 0 0 1rem; padding: 0.55rem 0.8rem;
border-left: 3px solid var(--primary); background: var(--bg); border-radius: 4px; font-size: 0.86rem; }
.spinner { width: 12px; height: 12px; border: 2px solid var(--border); border-top-color: var(--primary);
border-radius: 50%; display: inline-block; animation: spin 0.8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.mono {
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
}

View File

@@ -0,0 +1,113 @@
<template>
<div class="setup-checklist">
<header class="page-head">
<div>
<h1>Setup checklist</h1>
<p class="hint">What this site has configured and what it has not. The first-run wizard
covers only what a site cannot run without; the rest lives here.</p>
</div>
<button class="btn btn-secondary" :disabled="loading" @click="load">
{{ loading ? 'Checking...' : 'Re-check' }}
</button>
</header>
<div v-if="counts" class="summary">
<span class="pill ok">{{ counts.ok || 0 }} done</span>
<span class="pill partial">{{ counts.partial || 0 }} partial</span>
<span class="pill todo">{{ counts.todo || 0 }} to do</span>
<span v-if="counts.na" class="pill na">{{ counts.na }} n/a</span>
</div>
<!-- A pending restart makes the rest of this page misleading: a plugin can
be enabled and still answer 404, so say it before anything else. -->
<div v-if="health.restartrequired" class="restart-note">
<strong>Applying changes.</strong>
<span v-if="(health.plugins?.awaitingrestart || []).length">
Enabled but not loaded: <code>{{ (health.plugins.awaitingrestart || []).join(', ') }}</code>.
</span>
<span v-if="(health.plugins?.stillloaded || []).length">
Disabled but still running: <code>{{ (health.plugins.stillloaded || []).join(', ') }}</code>.
</span>
<p>Those features come online within a few seconds as each worker restarts itself.</p>
</div>
<p v-if="error" class="warn-note">{{ error }}</p>
<table v-if="areas.length" class="table checklist-table">
<thead>
<tr><th>Status</th><th>Area</th><th>Detail</th><th></th></tr>
</thead>
<tbody>
<tr v-for="a in areas" :key="a.key" :class="'st-' + a.status">
<td><span class="st-badge">{{ statusLabel(a.status) }}</span></td>
<td class="st-label">{{ a.label }}<span v-if="a.inwizard" class="wiz-tag">wizard</span></td>
<td class="st-detail">{{ a.detail }}</td>
<td class="st-action">
<router-link v-if="a.status !== 'na'" :to="a.route" class="btn btn-secondary btn-sm">
{{ a.status === 'ok' ? 'Review' : 'Configure' }}
</router-link>
</td>
</tr>
</tbody>
</table>
<p v-else-if="!loading && !error" class="hint">Nothing to show.</p>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { setupApi } from '../../api'
import { apiError } from '../../utils/apiError'
const areas = ref([])
const counts = ref(null)
const health = ref({})
const loading = ref(false)
const error = ref('')
function statusLabel(status) {
return { ok: 'done', partial: 'partial', todo: 'to do', na: 'n/a' }[status] || status
}
async function load() {
loading.value = true
error.value = ''
try {
const [list, state] = await Promise.all([setupApi.checklist(), setupApi.health()])
areas.value = list.data?.data?.areas || []
counts.value = list.data?.data?.counts || null
health.value = state.data?.data || {}
} catch (err) {
error.value = apiError(err, 'Could not load the setup checklist')
} finally {
loading.value = false
}
}
onMounted(load)
</script>
<style scoped>
.setup-checklist { padding: 1.5rem; }
.page-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 1rem; }
.page-head h1 { margin: 0; }
.hint { color: var(--text-light); margin: 0.25rem 0 0; max-width: 60ch; }
.summary { display: flex; gap: 0.5rem; margin: 1rem 0; flex-wrap: wrap; }
.pill { font-size: 11px; text-transform: uppercase; letter-spacing: 0.4px; padding: 0.2rem 0.55rem; border-radius: 999px; border: 1px solid var(--border); }
.pill.ok { color: var(--success, #2e7d32); }
.pill.partial { color: var(--warning, #b8860b); }
.pill.todo { color: var(--text-light); }
.pill.na { color: var(--text-light); opacity: 0.7; }
.restart-note { border-left: 3px solid var(--warning, #b8860b); padding: 0.6rem 0.85rem; margin: 0 0 1rem; background: rgba(184, 134, 11, 0.08); }
.restart-note p { margin: 0.4rem 0 0; }
.warn-note { border-left: 3px solid var(--warning, #b8860b); padding: 0.5rem 0.75rem; background: rgba(184, 134, 11, 0.08); }
.checklist-table { width: 100%; }
.st-badge { font-size: 10px; text-transform: uppercase; letter-spacing: 0.4px; }
.st-ok .st-badge { color: var(--success, #2e7d32); }
.st-partial .st-badge { color: var(--warning, #b8860b); font-weight: 700; }
.st-todo .st-badge, .st-na .st-badge { color: var(--text-light); }
.st-label { font-weight: 600; white-space: nowrap; }
.wiz-tag { margin-left: 0.4rem; font-size: 10px; text-transform: uppercase; letter-spacing: 0.4px; color: var(--text-light); font-weight: 400; }
.st-detail { color: var(--text-light); }
.st-action { text-align: right; white-space: nowrap; }
</style>

View File

@@ -4,7 +4,7 @@
// Order: site identity + the reference-data catalogs users touch daily come
// first, then the platform/system groups (integrations, communication, search,
// plugins, access) cluster together at the end.
import { Factory, MapPin, Tag, Package, Droplets, Monitor, MonitorSmartphone, Laptop, Cog, Building, Globe, Link, History, FileText, Users, Puzzle, Bell, Network, Home, Wrench, Printer, Router, Palette, SlidersHorizontal, Contact, Mail, ShieldCheck, KeyRound, Fingerprint, Search, Key, Upload, DatabaseBackup } from 'lucide-vue-next'
import { Factory, MapPin, Tag, Package, Droplets, Monitor, MonitorSmartphone, Laptop, Cog, Building, Globe, Link, History, FileText, Users, Puzzle, Bell, Network, Home, Wrench, Printer, Router, Palette, SlidersHorizontal, Contact, Mail, ShieldCheck, KeyRound, Fingerprint, Search, Key, Upload, DatabaseBackup, ListChecks} from 'lucide-vue-next'
export const settingsGroups = [
{
@@ -75,6 +75,7 @@ export const settingsGroups = [
{
title: 'System',
cards: [
{ to: '/settings/setupchecklist', icon: ListChecks, title: 'Setup Checklist', description: 'What this site has configured and what is still outstanding' },
{ to: '/settings/plugins', icon: Puzzle, title: 'Plugins', description: 'Enable or disable installed plugins' },
{ to: '/settings/shopdbbackups', icon: DatabaseBackup, title: 'Site Backups', description: 'Scheduled database and file backups with guided restore; keeps any day in the last 5' },
],

View File

@@ -2,6 +2,7 @@
"name": "backups",
"version": "1.0.0",
"description": "Per-asset configuration backups with revision history. Kind-pluggable: NTLARS/DNC registry, part marker, UDC, CMM. Parseable kinds store a JSON projection in ShopDB and re-render to their native format on download; opaque vendor formats stay on the SFLD share with ShopDB holding metadata and the UNC pointer.",
"recommended": true,
"author": "ShopDB Team",
"dependencies": [],
"core_version": ">=0.16.0,<1.0.0",

View File

@@ -1,23 +1,24 @@
{
"name": "computers",
"version": "1.0.0",
"description": "Computer management plugin for PCs, servers, and workstations with software tracking",
"author": "ShopDB Team",
"dependencies": [],
"core_version": ">=0.20.0,<1.0.0",
"api_prefix": "/api/computers",
"provides": {
"asset_type": "computer",
"features": [
"computer_tracking",
"software_inventory",
"remote_access",
"os_management"
]
},
"settings": {
"enable_winrm": true,
"enable_vnc": true,
"auto_report_interval_hours": 24
}
"name": "computers",
"version": "1.0.0",
"description": "Computer management plugin for PCs, servers, and workstations with software tracking",
"recommended": true,
"author": "ShopDB Team",
"dependencies": [],
"core_version": ">=0.20.0,<1.0.0",
"api_prefix": "/api/computers",
"provides": {
"asset_type": "computer",
"features": [
"computer_tracking",
"software_inventory",
"remote_access",
"os_management"
]
},
"settings": {
"enable_winrm": true,
"enable_vnc": true,
"auto_report_interval_hours": 24
}
}

View File

@@ -3,11 +3,14 @@
"display_name": "Guides",
"version": "1.0.0",
"description": "Step-by-step guides written and read inside ShopDB, with images, working checklists and printable output",
"recommended": true,
"author": "ShopDB Team",
"dependencies": [],
"core_version": ">=0.1.0,<1.0.0",
"api_prefix": "/api/guides",
"provides": {
"features": ["guides"]
"features": [
"guides"
]
}
}

View File

@@ -3,11 +3,14 @@
"display_name": "Knowledge Base",
"version": "1.0.0",
"description": "Knowledge Base articles linking to external resources",
"recommended": true,
"author": "ShopDB Team",
"dependencies": [],
"core_version": ">=0.1.0,<1.0.0",
"api_prefix": "/api/knowledgebase",
"provides": {
"features": ["knowledgebase"]
"features": [
"knowledgebase"
]
}
}

View File

@@ -1,22 +1,23 @@
{
"name": "machines",
"version": "1.0.0",
"description": "Machine management plugin for CNCs, CMMs, lathes, grinders, and other manufacturing machines",
"author": "ShopDB Team",
"dependencies": [],
"core_version": ">=0.20.0,<1.0.0",
"api_prefix": "/api/machines",
"provides": {
"asset_type": "machine",
"features": [
"machine_tracking",
"maintenance_scheduling",
"vendor_management",
"model_catalog"
]
},
"settings": {
"enable_maintenance_alerts": true,
"maintenance_alert_days": 30
}
"name": "machines",
"version": "1.0.0",
"description": "Machine management plugin for CNCs, CMMs, lathes, grinders, and other manufacturing machines",
"recommended": true,
"author": "ShopDB Team",
"dependencies": [],
"core_version": ">=0.20.0,<1.0.0",
"api_prefix": "/api/machines",
"provides": {
"asset_type": "machine",
"features": [
"machine_tracking",
"maintenance_scheduling",
"vendor_management",
"model_catalog"
]
},
"settings": {
"enable_maintenance_alerts": true,
"maintenance_alert_days": 30
}
}

View File

@@ -1,22 +1,22 @@
{
"name": "network",
"version": "1.0.0",
"description": "Network device management plugin for switches, APs, cameras, and IDFs",
"author": "ShopDB Team",
"dependencies": [],
"core_version": ">=0.20.0,<1.0.0",
"api_prefix": "/api/network",
"provides": {
"asset_type": "network_device",
"features": [
"network_device_tracking",
"port_management",
"firmware_tracking",
"poe_monitoring"
]
},
"settings": {
"enable_snmp_polling": false,
"snmp_community": "public"
}
"name": "network",
"version": "1.0.0",
"description": "Network device management plugin for switches, APs, cameras, and IDFs",
"author": "ShopDB Team",
"dependencies": [],
"core_version": ">=0.20.0,<1.0.0",
"api_prefix": "/api/network",
"provides": {
"asset_type": "network_device",
"features": [
"network_device_tracking",
"port_management",
"firmware_tracking",
"poe_monitoring"
]
},
"settings": {
"enable_snmp_polling": false,
"snmp_community": "public"
}
}

View File

@@ -1,12 +1,16 @@
{
"name": "notifications",
"version": "1.0.0",
"description": "Notifications and announcements management plugin",
"author": "ShopDB Team",
"dependencies": [],
"core_version": ">=0.1.0,<1.0.0",
"api_prefix": "/api/notifications",
"provides": {
"features": ["notifications", "announcements", "calendar_events"]
}
}
{
"name": "notifications",
"version": "1.0.0",
"description": "Notifications and announcements management plugin",
"author": "ShopDB Team",
"dependencies": [],
"core_version": ">=0.1.0,<1.0.0",
"api_prefix": "/api/notifications",
"provides": {
"features": [
"notifications",
"announcements",
"calendar_events"
]
}
}

View File

@@ -1,25 +1,26 @@
{
"name": "printers",
"version": "1.0.0",
"description": "Printer management plugin with Zabbix integration, supply tracking, and QR codes",
"author": "ShopDB Team",
"dependencies": [],
"core_version": ">=0.20.0,<1.0.0",
"api_prefix": "/api/printers",
"provides": {
"machine_category": "Printer",
"features": [
"printer_extensions",
"driver_management",
"supply_tracking",
"zabbix_integration",
"qr_codes"
]
},
"settings": {
"zabbix_url": "",
"zabbix_token": "",
"supply_alert_threshold": 10,
"default_driver_source": "internal"
}
"name": "printers",
"version": "1.0.0",
"description": "Printer management plugin with Zabbix integration, supply tracking, and QR codes",
"recommended": true,
"author": "ShopDB Team",
"dependencies": [],
"core_version": ">=0.20.0,<1.0.0",
"api_prefix": "/api/printers",
"provides": {
"machine_category": "Printer",
"features": [
"printer_extensions",
"driver_management",
"supply_tracking",
"zabbix_integration",
"qr_codes"
]
},
"settings": {
"zabbix_url": "",
"zabbix_token": "",
"supply_alert_threshold": 10,
"default_driver_source": "internal"
}
}

View File

@@ -2,11 +2,14 @@
"name": "warranty",
"version": "1.0.0",
"description": "Asset warranty tracking - manual entry now, Dell/Lenovo/HP provider lookups later. Derived coverage status + report buckets.",
"recommended": true,
"author": "ShopDB Team",
"dependencies": [],
"core_version": ">=0.2.0,<1.0.0",
"api_prefix": "/api/warranty",
"provides": {
"features": ["warranty-tracking"]
"features": [
"warranty-tracking"
]
}
}

View File

@@ -155,6 +155,367 @@ def seed_starter():
)
def _setting_values():
"""Every setting as {key: value}, read once.
The checklist inspects roughly forty keys across a dozen categories, so it
reads the table once rather than issuing a query per item.
"""
return {row.key: (row.value or '') for row in Setting.query.all()}
def _truthy(value):
return str(value or '').strip().lower() in ('true', '1', 'yes', 'on')
def _filled(values, *keys):
"""True only when every named key has a non-empty value."""
return all(str(values.get(k) or '').strip() for k in keys)
# Values that are SEEDED, not chosen. A setting holding one of these is still
# at its shipped default, so reporting it as configured would tell a site it had
# done something it has not: site_logo ships as the GE mark and the floor map
# ships as a placeholder image, and both are non-empty from the first boot.
SHIPPED_DEFAULTS = {
'site_logo': ('/ge-aerospace-logo.svg',),
'map_blueprint_light': ('/static/images/floorplan-placeholder.svg',),
'map_blueprint_dark': ('/static/images/floorplan-placeholder.svg',),
}
def _customised(values, key):
"""True when a key holds something other than what shipped with it."""
value = str(values.get(key) or '').strip()
if not value:
return False
if value in SHIPPED_DEFAULTS.get(key, ()):
return False
# Defensive: a renamed placeholder asset should still read as unconfigured.
return 'placeholder' not in value.lower()
def _area(key, label, status, detail, route, wizard=False):
return {'key': key, 'label': label, 'status': status,
'detail': detail, 'route': route, 'inwizard': wizard}
def _enabled_plugin_names():
"""Plugins the registry says are on. Empty when plugins are unavailable."""
from shopdb.plugins import plugin_manager
registry = getattr(plugin_manager, 'registry', None)
if registry is None:
return set()
try:
return set(registry.get_enabled_plugins())
except Exception: # a corrupt registry must not break the checklist
return set()
def _loaded_plugin_names():
"""Plugins actually loaded into THIS process."""
from shopdb.plugins import plugin_manager
loader = getattr(plugin_manager, 'loader', None)
if loader is None:
return set()
try:
return set(loader.get_all_loaded().keys())
except Exception:
return set()
def _manager_call(method):
"""Call a plugin-manager method by name, tolerating its absence."""
from shopdb.plugins import plugin_manager
getter = getattr(plugin_manager, method, None)
if getter is None:
return set()
try:
return set(getter())
except Exception:
return set()
def _enabled_on_disk():
"""Enabled plugins per plugins.json, or None when it cannot be read.
None and the empty set mean different things: "I could not tell you" versus
"nothing is enabled". Collapsing them made an all-disabled site fall back to
a stale in-memory view.
"""
from shopdb.plugins import plugin_manager
getter = getattr(plugin_manager, 'enabled_on_disk', None)
if getter is None:
return None
try:
return set(getter())
except Exception:
return None
def _booted_with_names():
return _manager_call('get_booted_with')
def _deferred_registration_names():
"""Plugins whose routes are waiting on a restart in this process."""
from shopdb.plugins import plugin_manager
getter = getattr(plugin_manager, 'get_deferred_registrations', None)
if getter is None:
return set()
try:
return set(getter())
except Exception:
return set()
def _build_checklist(values):
"""Configured-vs-not for every area a site is expected to set up.
WHY THIS EXISTS. The wizard covers only what a site cannot run without;
everything else would otherwise be invisible until someone went looking for
it in Settings. There are well over a hundred settings, and a wizard that
asked for all of them would be worse than no wizard. This is the other half:
a standing list of what is still unconfigured, with a link straight to the
page that configures it.
Status is deliberately three-valued. 'partial' matters because several areas
have an enable flag plus the fields that flag needs - SAML switched on with
no IdP metadata is a broken site, and reporting that as 'ok' would hide it.
"""
enabled = _enabled_plugin_names()
areas = []
# --- identity -------------------------------------------------------
# Only facility_name is genuinely required. The wizard offers "blank = use
# the browsing origin" for site_base_url and "Use the server timezone" for
# site_timezone, and saves '' for both - so demanding them here told an
# operator who took an option the wizard offered that they had not finished.
if _filled(values, 'facility_name'):
status, detail = 'ok', values.get('facility_name', '')
else:
status, detail = 'todo', 'Facility name is unset'
areas.append(_area('site_identity', 'Site identity', status, detail,
'/settings/siteconfig/site', wizard=True))
# --- authentication -------------------------------------------------
if _truthy(values.get('saml_enabled')):
if _filled(values, 'saml_entity_id', 'saml_acs_url', 'saml_idp_metadata_url'):
status, detail = 'ok', 'SAML configured'
else:
status, detail = 'partial', 'SAML is on but the IdP fields are incomplete'
else:
status, detail = 'todo', 'Local accounts only; SAML is off'
areas.append(_area('authentication', 'Authentication', status, detail,
'/settings/security/authentication', wizard=True))
# --- email ----------------------------------------------------------
if _truthy(values.get('smtp_enabled')):
if _filled(values, 'smtp_host', 'smtp_from_address'):
status, detail = 'ok', values.get('smtp_host', '')
else:
status, detail = 'partial', 'SMTP is on but host or from-address is unset'
else:
status, detail = 'todo', 'No mail server; alerts and notifications cannot send'
areas.append(_area('email', 'Email / SMTP', status, detail,
'/settings/email', wizard=True))
# --- backup destination ---------------------------------------------
kind = (values.get('backup_destination_kind') or '').strip().lower()
if kind == 'smb' and _filled(values, 'backup_smb_host', 'backup_smb_share'):
status, detail = 'ok', f"SMB: {values.get('backup_smb_host')}"
elif kind in ('aws', 's3') and _filled(values, 'backup_aws_bucket'):
status, detail = 'ok', f"S3: {values.get('backup_aws_bucket')}"
elif kind in ('smb', 'aws', 's3'):
status, detail = 'partial', f'{kind} selected but its fields are incomplete'
else:
# 'local' is the seeded default and a legitimate choice, but it means
# no copy leaves this host, so a site should make that decision rather
# than inherit it.
status, detail = 'todo', 'Backups stay on this host; no offsite copy'
areas.append(_area('backup', 'Backup destination', status, detail,
'/settings/shopdbbackups', wizard=True))
# --- branding -------------------------------------------------------
if _customised(values, 'site_logo'):
status, detail = 'ok', 'Custom logo set'
else:
status, detail = 'todo', 'Using the shipped logo and colours'
areas.append(_area('branding', 'Branding', status, detail,
'/settings/siteconfig/branding'))
# --- floor map ------------------------------------------------------
has_blueprint = _customised(values, 'map_blueprint_light')
if has_blueprint and _filled(values, 'map_width', 'map_height'):
status, detail = 'ok', f"{values.get('map_width')} x {values.get('map_height')}"
elif has_blueprint:
status, detail = 'partial', 'Blueprint uploaded but dimensions are unset'
else:
status, detail = 'todo', 'Still on the shipped placeholder blueprint'
areas.append(_area('floormap', 'Floor map', status, detail,
'/settings/siteconfig/floormap'))
# --- integrations ---------------------------------------------------
live = [name for flag, name in (
('zabbix_enabled', 'Zabbix'),
('warranty_dell_enabled', 'Dell warranty'),
('servicenow_enabled', 'ServiceNow'),
) if _truthy(values.get(flag))]
if live:
status, detail = 'ok', ', '.join(live)
else:
status, detail = 'todo', 'Zabbix, Dell warranty and ServiceNow are all off'
areas.append(_area('integrations', 'Integrations', status, detail,
'/settings/integrations'))
# --- identifiers ----------------------------------------------------
# Every identifier_* key is seeded 'true' (settings.py build_default_settings),
# so "any enabled" was true on every fresh site and this area could never
# read anything but ok. Report it as a review item instead of pretending a
# decision was made.
ident_total = [k for k in values if k.startswith('identifier_')]
ident_on = [k for k in ident_total if _truthy(values[k])]
if ident_total and len(ident_on) < len(ident_total):
status = 'ok'
detail = f'{len(ident_on)} of {len(ident_total)} identifier fields enabled'
else:
status = 'todo'
detail = 'All identifier fields are on by default; trim to what this site uses'
areas.append(_area('identifiers', 'Asset identifiers', status, detail,
'/settings/assetconfig/identifiers'))
# --- printing / QR ---------------------------------------------------
qr = [k for k in values if k.startswith('qr_target_') and _filled(values, k)]
if qr:
status, detail = 'ok', f'{len(qr)} QR target(s) set'
else:
status, detail = 'todo', 'QR codes will fall back to defaults'
areas.append(_area('printing', 'Printing and labels', status, detail,
'/settings/printing'))
# --- endpoint agent policy (only when the plugin is on) --------------
if 'endpoints' in enabled:
# The runtime default is PERMISSIVE: shared_key_allowed() in
# plugins/endpoints/api/agentauth.py reads the setting with default=True,
# so an absent row means the fleet-wide key is accepted. Treating absent
# as "off" told an admin the key was refused on exactly the sites that
# still accept it, which is the opposite of the truth about a credential
# readable by any local user.
raw = values.get('plugin.endpoints.sharedkeyallowed')
shared = True if raw in (None, '') else _truthy(raw)
if shared:
status = 'partial'
detail = ('The fleet-wide shared key is still accepted'
+ ('' if raw not in (None, '') else ' (setting unset, defaults to allowed)'))
else:
status, detail = 'ok', 'Shared key refused; per-PC credentials only'
else:
status, detail = 'na', 'Endpoints plugin is not enabled'
areas.append(_area('endpoint_policy', 'Endpoint agent policy', status, detail,
'/settings/plugins'))
# --- locations -------------------------------------------------------
# Location is a top-level export, so the import cannot fail; only the query
# can. Swallowing that reported "no locations" as a data fact AND left the
# session in a failed transaction, so the next count() below raised and the
# whole endpoint 500'd with a misleading detail already in the payload.
from shopdb.core.models import Location
try:
count = Location.query.count()
except Exception:
db.session.rollback()
count = None
if count is None:
status, detail = 'todo', 'Could not read locations'
else:
status = 'ok' if count else 'todo'
detail = f'{count} location(s)' if count else 'No locations defined yet'
areas.append(_area('locations', 'Locations', status, detail,
'/settings/locations/list'))
# --- reference data ---------------------------------------------------
vendors = Vendor.query.count()
status = 'ok' if vendors else 'todo'
detail = f'{vendors} vendor(s)' if vendors else 'No vendors seeded'
areas.append(_area('reference_data', 'Reference data', status, detail,
'/settings/catalog/vendors', wizard=True))
return areas
@setup_bp.route('/checklist', methods=['GET'])
@jwt_required()
@require_role('admin')
def checklist():
"""What is configured and what is not, with a link to each fix."""
values = _setting_values()
areas = _build_checklist(values)
counts = {'ok': 0, 'partial': 0, 'todo': 0, 'na': 0}
for area in areas:
counts[area['status']] = counts.get(area['status'], 0) + 1
return success_response({
'areas': areas,
'counts': counts,
'complete': _setup_already_complete(),
})
@setup_bp.route('/health', methods=['GET'])
@jwt_required()
@require_role('admin')
def health():
"""Post-setup reality check, including whether a restart is outstanding.
RESTART DETECTION IS THE POINT. Plugins are read from plugins.json at app
startup, so enabling one in the wizard changes the file but not the running
process: every route that plugin contributes answers 404 until the app is
restarted, with nothing on screen to say so. Comparing what the registry
says is enabled against what this process actually loaded turns that into
something the wizard can state plainly and then re-verify.
"""
# Intent comes from plugins.json re-read from disk, because that file is
# shared by every gunicorn worker while each worker's in-memory registry is
# its own. Reality is the set of plugins THIS process registered routes for
# at boot. Two things make loader.get_all_loaded() the wrong comparison:
# installing a plugin instantiates it (so it looks loaded while its
# blueprint was skipped), and a plugin enabled through another worker is
# invisible here. The boot snapshot has neither problem - a plugin enabled
# after start is missing from every worker's snapshot.
# `or` would treat "nothing enabled" as "could not read", falling back to
# this worker's stale boot view and hiding every stale-loaded plugin.
enabled = _enabled_on_disk()
if enabled is None:
enabled = _enabled_plugin_names()
booted = _booted_with_names()
pending = sorted(enabled - booted)
stale = sorted(booted - enabled)
loaded = _loaded_plugin_names()
# role.users is a dynamic relationship (AppenderQuery), so it answers to
# .count() and NOT to len(). len() raised here, the exception was swallowed,
# and the endpoint reported zero admins on a site that had one.
admin_role = Role.query.filter_by(rolename='admin').first()
admins = admin_role.users.count() if admin_role else 0
return success_response({
'database': True, # reaching here means the session works
# Which worker answered. Each one recycles independently when the
# enabled set changes, so a single clean reply only proves THAT worker
# has rolled. A caller that sees clean replies from several distinct
# pids knows the change has actually taken hold across the pool.
'worker': os.getpid(),
'users': User.query.count(),
'admins': admins,
'plugins': {
'enabled': sorted(enabled),
'loaded': sorted(loaded),
'awaitingrestart': pending,
'stillloaded': stale,
},
'restartrequired': bool(pending or stale),
})
@setup_bp.route('/complete', methods=['POST'])
@jwt_required()
@require_role('admin')