|
|
|
|
@@ -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 ? '✓' : 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 > 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 > 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 > 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; }
|
|
|
|
|
|