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.
(cherry picked from commit 32d3f4b29d)
This commit is contained in:
@@ -1246,6 +1246,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')
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -1,288 +1,297 @@
|
||||
/**
|
||||
* Core routes - Dashboard, Search, Map, Reports, Settings, Employees
|
||||
*/
|
||||
export default [
|
||||
{
|
||||
path: '',
|
||||
name: 'dashboard',
|
||||
component: () => import('../../views/Dashboard.vue')
|
||||
},
|
||||
{
|
||||
path: 'search',
|
||||
name: 'search',
|
||||
component: () => import('../../views/SearchResults.vue')
|
||||
},
|
||||
{
|
||||
path: 'map',
|
||||
name: 'map',
|
||||
component: () => import('../../views/MapView.vue')
|
||||
},
|
||||
{
|
||||
path: 'map/editor',
|
||||
name: 'map-editor',
|
||||
component: () => import('../../views/MapEditor.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: 'reports',
|
||||
name: 'reports',
|
||||
component: () => import('../../views/reports/ReportsIndex.vue')
|
||||
},
|
||||
// Settings
|
||||
{
|
||||
path: 'settings',
|
||||
name: 'settings',
|
||||
component: () => import('../../views/settings/SettingsIndex.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/catalog',
|
||||
name: 'model-catalog',
|
||||
component: () => import('../../views/settings/ModelCatalogHub.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true },
|
||||
children: [
|
||||
{
|
||||
path: 'vendors',
|
||||
name: 'model-catalog-vendors',
|
||||
component: () => import('../../views/vendors/VendorsList.vue')
|
||||
},
|
||||
{
|
||||
path: 'types',
|
||||
name: 'model-catalog-types',
|
||||
component: () => import('../../views/settings/ModelTypesList.vue')
|
||||
},
|
||||
{
|
||||
path: 'models',
|
||||
name: 'model-catalog-models',
|
||||
component: () => import('../../views/settings/ModelsList.vue')
|
||||
}
|
||||
// Plugin settings routes (e.g., Operating Systems from computers plugin) are added
|
||||
// dynamically by ModelCatalogHub via router.addRoute() from /api/plugins/settings-routes.
|
||||
// This allows plugins to extend settings hubs without breaking lean builds.
|
||||
// This hub is the ONLY one that does it: no other hub page below fetches
|
||||
// or registers plugin routes, so a plugin declaring their hub id gets a
|
||||
// tab nobody draws. The server knows the same list
|
||||
// (SETTINGS_HUBS_WITH_PLUGIN_TABS in shopdb/plugins/base.py) and drops
|
||||
// anything outside it; adding a hub means writing the code into that
|
||||
// hub's .vue file AND adding it there. See ADR-019.
|
||||
]
|
||||
},
|
||||
{
|
||||
path: 'settings/assetconfig',
|
||||
name: 'asset-config',
|
||||
component: () => import('../../views/settings/AssetConfigHub.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true },
|
||||
children: [
|
||||
{
|
||||
path: 'statuses',
|
||||
name: 'asset-config-statuses',
|
||||
component: () => import('../../views/settings/StatusesList.vue')
|
||||
},
|
||||
{
|
||||
path: 'relationships',
|
||||
name: 'asset-config-relationships',
|
||||
component: () => import('../../views/settings/RelationshipTypesList.vue')
|
||||
},
|
||||
{
|
||||
path: 'fields',
|
||||
name: 'asset-config-fields',
|
||||
component: () => import('../../views/settings/CustomFieldsList.vue')
|
||||
},
|
||||
{
|
||||
path: 'identifiers',
|
||||
name: 'asset-config-identifiers',
|
||||
component: () => import('../../views/settings/AssetIdentifiersSettings.vue')
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: 'settings/security',
|
||||
name: 'security',
|
||||
component: () => import('../../views/settings/SecurityHub.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true },
|
||||
children: [
|
||||
{
|
||||
path: 'authentication',
|
||||
name: 'security-authentication',
|
||||
component: () => import('../../views/settings/AuthenticationSettings.vue')
|
||||
},
|
||||
{
|
||||
path: 'tokens',
|
||||
name: 'security-tokens',
|
||||
component: () => import('../../views/settings/ApiTokensList.vue')
|
||||
},
|
||||
{
|
||||
path: 'users',
|
||||
name: 'security-users',
|
||||
component: () => import('../../views/settings/UsersList.vue')
|
||||
},
|
||||
{
|
||||
path: 'audit',
|
||||
name: 'security-audit',
|
||||
component: () => import('../../views/settings/AuditSettings.vue')
|
||||
},
|
||||
{
|
||||
path: 'auditlogs',
|
||||
name: 'security-auditlogs',
|
||||
component: () => import('../../views/settings/AuditLogs.vue')
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: 'settings/siteconfig',
|
||||
name: 'site-config',
|
||||
component: () => import('../../views/settings/SiteConfigHub.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true },
|
||||
children: [
|
||||
{
|
||||
path: 'site',
|
||||
name: 'site-config-site',
|
||||
component: () => import('../../views/settings/SiteSettings.vue')
|
||||
},
|
||||
{
|
||||
path: 'floormap',
|
||||
name: 'site-config-floormap',
|
||||
component: () => import('../../views/settings/FloorMapSettings.vue')
|
||||
},
|
||||
{
|
||||
path: 'branding',
|
||||
name: 'site-config-branding',
|
||||
component: () => import('../../views/settings/BrandingSettings.vue')
|
||||
},
|
||||
{
|
||||
path: 'imports',
|
||||
name: 'site-config-imports',
|
||||
component: () => import('../../views/settings/ImportSiteAssets.vue')
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: 'settings/integrations',
|
||||
name: 'integrations',
|
||||
component: () => import('../../views/settings/IntegrationsHub.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/servicenow',
|
||||
name: 'servicenow-settings',
|
||||
component: () => import('../../views/settings/ServiceNowSettings.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/locations',
|
||||
name: 'locations-hub',
|
||||
component: () => import('../../views/settings/LocationsHub.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true },
|
||||
children: [
|
||||
{
|
||||
path: 'list',
|
||||
name: 'locations-hub-locations',
|
||||
component: () => import('../../views/settings/LocationsList.vue')
|
||||
},
|
||||
{
|
||||
path: 'businessunits',
|
||||
name: 'locations-hub-businessunits',
|
||||
component: () => import('../../views/settings/BusinessUnitsList.vue')
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: 'settings/network',
|
||||
name: 'network-hub',
|
||||
component: () => import('../../views/settings/NetworkHub.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true },
|
||||
children: []
|
||||
},
|
||||
{
|
||||
path: 'settings/supportteams',
|
||||
name: 'supportteams',
|
||||
component: () => import('../../views/settings/SupportTeamsList.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/dashboarddefaults',
|
||||
name: 'dashboarddefaults',
|
||||
component: () => import('../../views/settings/DashboardDefaultsList.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/locationtypes',
|
||||
name: 'location-types',
|
||||
component: () => import('../../views/settings/LocationTypesList.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/assettypes',
|
||||
component: () => import('../../views/settings/AssetTypeHub.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true },
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'asset-types-hub',
|
||||
component: () => import('../../views/settings/asset-types/TypesTabWrapper.vue'),
|
||||
props: route => ({ assettypeid: route.params.assettypeid || route.params.assettypeid })
|
||||
},
|
||||
{
|
||||
path: ':assettypeid/subtypes',
|
||||
name: 'asset-types-hub-subtypes',
|
||||
component: () => import('../../views/settings/asset-types/TypesTabWrapper.vue'),
|
||||
props: route => ({ assettypeid: route.params.assettypeid })
|
||||
},
|
||||
]
|
||||
},
|
||||
// The old monolithic System Settings tab page is split into one routed page
|
||||
// per section (below). This redirect keeps old bookmarks working: bare
|
||||
// /settings/system and every /settings/system?tab=<key> land on the right page.
|
||||
{
|
||||
path: 'settings/system',
|
||||
name: 'system-settings',
|
||||
redirect: to => {
|
||||
const tabRedirects = {
|
||||
integrations: '/settings/servicenow',
|
||||
branding: '/settings/branding',
|
||||
printing: '/settings/printing',
|
||||
email: '/settings/email',
|
||||
audit: '/settings/audit',
|
||||
auth: '/settings/authentication',
|
||||
identifiers: '/settings/assetidentifiers',
|
||||
search: '/settings/globalsearch',
|
||||
map: '/settings/floormap',
|
||||
pctype: '/settings/pctypemapping'
|
||||
}
|
||||
// vue-router carries the original ?tab= query onto the target; harmless
|
||||
// since the new pages ignore it, and the path is what routes the view.
|
||||
return tabRedirects[to.query.tab] || '/settings/servicenow'
|
||||
},
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/printing',
|
||||
name: 'printing-settings',
|
||||
component: () => import('../../views/settings/PrintingSettings.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/email',
|
||||
name: 'email-settings',
|
||||
component: () => import('../../views/settings/EmailSettings.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/globalsearch',
|
||||
name: 'global-search-settings',
|
||||
component: () => import('../../views/settings/GlobalSearchSettings.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/plugins',
|
||||
name: 'plugins',
|
||||
component: () => import('../../views/settings/PluginsList.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/shopdbbackups',
|
||||
name: 'shopdbbackups',
|
||||
component: () => import('../../views/settings/ShopdbBackupsList.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
}
|
||||
]
|
||||
/**
|
||||
* Core routes - Dashboard, Search, Map, Reports, Settings, Employees
|
||||
*/
|
||||
export default [
|
||||
{
|
||||
path: '',
|
||||
name: 'dashboard',
|
||||
component: () => import('../../views/Dashboard.vue')
|
||||
},
|
||||
{
|
||||
path: 'search',
|
||||
name: 'search',
|
||||
component: () => import('../../views/SearchResults.vue')
|
||||
},
|
||||
{
|
||||
path: 'map',
|
||||
name: 'map',
|
||||
component: () => import('../../views/MapView.vue')
|
||||
},
|
||||
{
|
||||
path: 'map/editor',
|
||||
name: 'map-editor',
|
||||
component: () => import('../../views/MapEditor.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: 'reports',
|
||||
name: 'reports',
|
||||
component: () => import('../../views/reports/ReportsIndex.vue')
|
||||
},
|
||||
// Settings
|
||||
{
|
||||
path: 'settings',
|
||||
name: 'settings',
|
||||
component: () => import('../../views/settings/SettingsIndex.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/catalog',
|
||||
name: 'model-catalog',
|
||||
component: () => import('../../views/settings/ModelCatalogHub.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true },
|
||||
children: [
|
||||
{
|
||||
path: 'vendors',
|
||||
name: 'model-catalog-vendors',
|
||||
component: () => import('../../views/vendors/VendorsList.vue')
|
||||
},
|
||||
{
|
||||
path: 'types',
|
||||
name: 'model-catalog-types',
|
||||
component: () => import('../../views/settings/ModelTypesList.vue')
|
||||
},
|
||||
{
|
||||
path: 'models',
|
||||
name: 'model-catalog-models',
|
||||
component: () => import('../../views/settings/ModelsList.vue')
|
||||
}
|
||||
// Plugin settings routes (e.g., Operating Systems from computers plugin) are added
|
||||
// dynamically by ModelCatalogHub via router.addRoute() from /api/plugins/settings-routes.
|
||||
// This allows plugins to extend settings hubs without breaking lean builds.
|
||||
// This hub is the ONLY one that does it: no other hub page below fetches
|
||||
// or registers plugin routes, so a plugin declaring their hub id gets a
|
||||
// tab nobody draws. The server knows the same list
|
||||
// (SETTINGS_HUBS_WITH_PLUGIN_TABS in shopdb/plugins/base.py) and drops
|
||||
// anything outside it; adding a hub means writing the code into that
|
||||
// hub's .vue file AND adding it there. See ADR-019.
|
||||
]
|
||||
},
|
||||
{
|
||||
path: 'settings/assetconfig',
|
||||
name: 'asset-config',
|
||||
component: () => import('../../views/settings/AssetConfigHub.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true },
|
||||
children: [
|
||||
{
|
||||
path: 'statuses',
|
||||
name: 'asset-config-statuses',
|
||||
component: () => import('../../views/settings/StatusesList.vue')
|
||||
},
|
||||
{
|
||||
path: 'relationships',
|
||||
name: 'asset-config-relationships',
|
||||
component: () => import('../../views/settings/RelationshipTypesList.vue')
|
||||
},
|
||||
{
|
||||
path: 'fields',
|
||||
name: 'asset-config-fields',
|
||||
component: () => import('../../views/settings/CustomFieldsList.vue')
|
||||
},
|
||||
{
|
||||
path: 'identifiers',
|
||||
name: 'asset-config-identifiers',
|
||||
component: () => import('../../views/settings/AssetIdentifiersSettings.vue')
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: 'settings/security',
|
||||
name: 'security',
|
||||
component: () => import('../../views/settings/SecurityHub.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true },
|
||||
children: [
|
||||
{
|
||||
path: 'authentication',
|
||||
name: 'security-authentication',
|
||||
component: () => import('../../views/settings/AuthenticationSettings.vue')
|
||||
},
|
||||
{
|
||||
path: 'tokens',
|
||||
name: 'security-tokens',
|
||||
component: () => import('../../views/settings/ApiTokensList.vue')
|
||||
},
|
||||
{
|
||||
path: 'users',
|
||||
name: 'security-users',
|
||||
component: () => import('../../views/settings/UsersList.vue')
|
||||
},
|
||||
{
|
||||
path: 'audit',
|
||||
name: 'security-audit',
|
||||
component: () => import('../../views/settings/AuditSettings.vue')
|
||||
},
|
||||
{
|
||||
path: 'auditlogs',
|
||||
name: 'security-auditlogs',
|
||||
component: () => import('../../views/settings/AuditLogs.vue')
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: 'settings/siteconfig',
|
||||
name: 'site-config',
|
||||
component: () => import('../../views/settings/SiteConfigHub.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true },
|
||||
children: [
|
||||
{
|
||||
path: 'site',
|
||||
name: 'site-config-site',
|
||||
component: () => import('../../views/settings/SiteSettings.vue')
|
||||
},
|
||||
{
|
||||
path: 'floormap',
|
||||
name: 'site-config-floormap',
|
||||
component: () => import('../../views/settings/FloorMapSettings.vue')
|
||||
},
|
||||
{
|
||||
path: 'branding',
|
||||
name: 'site-config-branding',
|
||||
component: () => import('../../views/settings/BrandingSettings.vue')
|
||||
},
|
||||
{
|
||||
path: 'imports',
|
||||
name: 'site-config-imports',
|
||||
component: () => import('../../views/settings/ImportSiteAssets.vue')
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: 'settings/integrations',
|
||||
name: 'integrations',
|
||||
component: () => import('../../views/settings/IntegrationsHub.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/servicenow',
|
||||
name: 'servicenow-settings',
|
||||
component: () => import('../../views/settings/ServiceNowSettings.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/locations',
|
||||
name: 'locations-hub',
|
||||
component: () => import('../../views/settings/LocationsHub.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true },
|
||||
children: [
|
||||
{
|
||||
path: 'list',
|
||||
name: 'locations-hub-locations',
|
||||
component: () => import('../../views/settings/LocationsList.vue')
|
||||
},
|
||||
{
|
||||
path: 'businessunits',
|
||||
name: 'locations-hub-businessunits',
|
||||
component: () => import('../../views/settings/BusinessUnitsList.vue')
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: 'settings/network',
|
||||
name: 'network-hub',
|
||||
component: () => import('../../views/settings/NetworkHub.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true },
|
||||
children: []
|
||||
},
|
||||
{
|
||||
path: 'settings/supportteams',
|
||||
name: 'supportteams',
|
||||
component: () => import('../../views/settings/SupportTeamsList.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/dashboarddefaults',
|
||||
name: 'dashboarddefaults',
|
||||
component: () => import('../../views/settings/DashboardDefaultsList.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/locationtypes',
|
||||
name: 'location-types',
|
||||
component: () => import('../../views/settings/LocationTypesList.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/assettypes',
|
||||
component: () => import('../../views/settings/AssetTypeHub.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true },
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'asset-types-hub',
|
||||
component: () => import('../../views/settings/asset-types/TypesTabWrapper.vue'),
|
||||
props: route => ({ assettypeid: route.params.assettypeid || route.params.assettypeid })
|
||||
},
|
||||
{
|
||||
path: ':assettypeid/subtypes',
|
||||
name: 'asset-types-hub-subtypes',
|
||||
component: () => import('../../views/settings/asset-types/TypesTabWrapper.vue'),
|
||||
props: route => ({ assettypeid: route.params.assettypeid })
|
||||
},
|
||||
]
|
||||
},
|
||||
// The old monolithic System Settings tab page is split into one routed page
|
||||
// per section (below). This redirect keeps old bookmarks working: bare
|
||||
// /settings/system and every /settings/system?tab=<key> land on the right page.
|
||||
{
|
||||
path: 'settings/system',
|
||||
name: 'system-settings',
|
||||
redirect: to => {
|
||||
const tabRedirects = {
|
||||
integrations: '/settings/servicenow',
|
||||
branding: '/settings/branding',
|
||||
printing: '/settings/printing',
|
||||
email: '/settings/email',
|
||||
audit: '/settings/audit',
|
||||
auth: '/settings/authentication',
|
||||
identifiers: '/settings/assetidentifiers',
|
||||
search: '/settings/globalsearch',
|
||||
map: '/settings/floormap',
|
||||
pctype: '/settings/pctypemapping'
|
||||
}
|
||||
// vue-router carries the original ?tab= query onto the target; harmless
|
||||
// since the new pages ignore it, and the path is what routes the view.
|
||||
return tabRedirects[to.query.tab] || '/settings/servicenow'
|
||||
},
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/printing',
|
||||
name: 'printing-settings',
|
||||
component: () => import('../../views/settings/PrintingSettings.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/email',
|
||||
name: 'email-settings',
|
||||
component: () => import('../../views/settings/EmailSettings.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/globalsearch',
|
||||
name: 'global-search-settings',
|
||||
component: () => import('../../views/settings/GlobalSearchSettings.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/plugins',
|
||||
name: 'plugins',
|
||||
component: () => import('../../views/settings/PluginsList.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/shopdbbackups',
|
||||
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 }
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,74 +200,134 @@
|
||||
</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>
|
||||
<!-- GE-Enforce needs operational config the wizard does not collect,
|
||||
so point at it rather than let a site assume it is ready. -->
|
||||
<div v-if="geenforceEnabled" class="finish-note">
|
||||
<strong>GE-Enforce is on.</strong> Two more steps before the fleet
|
||||
uses it: create a service token with the geenforce scopes under
|
||||
Settings > API Tokens, and set the on-share export root on the
|
||||
GE-Enforce page.
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
@@ -171,7 +349,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'
|
||||
@@ -181,37 +359,192 @@ 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']
|
||||
})
|
||||
|
||||
// GE-Enforce needs post-setup operational config (service token + share root)
|
||||
// the wizard does not collect; the Review step points there when it is on.
|
||||
const geenforceEnabled = computed(() =>
|
||||
plugins.value.some(p => p.name === 'geenforce' && p.enabled))
|
||||
|
||||
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]
|
||||
@@ -233,24 +566,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,18 +613,15 @@ function modeOf(plugin) {
|
||||
return key ? (pluginModes.value[key] || 'selfhosted') : null
|
||||
}
|
||||
|
||||
// GE-Enforce needs post-setup operational config (service token + share root)
|
||||
// the wizard does not collect; the Finish step points there when it is on.
|
||||
const geenforceEnabled = computed(() =>
|
||||
plugins.value.some(p => p.name === 'geenforce' && p.enabled))
|
||||
|
||||
// Enabled plugins whose external connection config should be collected right
|
||||
// now: those in external mode (or with config but no mode concept).
|
||||
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) {
|
||||
@@ -285,18 +637,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 {
|
||||
@@ -333,11 +725,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)
|
||||
@@ -350,6 +750,7 @@ async function saveStep() {
|
||||
await settingsApi.update(field.key, String(value))
|
||||
}
|
||||
}
|
||||
await loadHealth()
|
||||
return
|
||||
}
|
||||
const keys = stepSettings[current.value.key]
|
||||
@@ -357,7 +758,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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -393,7 +798,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'))
|
||||
}
|
||||
@@ -404,6 +810,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 {
|
||||
@@ -417,6 +824,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 {
|
||||
@@ -449,37 +857,54 @@ 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; }
|
||||
.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-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; }
|
||||
.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); }
|
||||
.finish-note { margin: 0.75rem 0 0; padding: 0.6rem 0.75rem; background: var(--bg); border-left: 3px solid var(--primary); border-radius: 4px; font-size: 0.85rem; }
|
||||
.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-list { display: flex; flex-direction: column; gap: 0.6rem; }
|
||||
.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.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; }
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
113
frontend/src/views/settings/SetupChecklist.vue
Normal file
113
frontend/src/views/settings/SetupChecklist.vue
Normal 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>
|
||||
@@ -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 = [
|
||||
{
|
||||
@@ -85,6 +85,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' },
|
||||
],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user