diff --git a/deploy/windows/web.config b/deploy/windows/web.config index f365fcc1..513cca50 100644 --- a/deploy/windows/web.config +++ b/deploy/windows/web.config @@ -101,6 +101,17 @@ SHOPDB-CLIENTIP-END --> + + + + + + + -
+
@@ -302,6 +311,9 @@ const sets = ref([]) const loading = ref(true) const isBusy = ref(false) const lastGoodAt = ref(null) +// Assume configured until the list says otherwise, so a slow first load +// does not flash the not-configured notice on a working install. +const isConfigured = ref(true) const restoreTarget = ref(null) // Live restore progress. restoreStatus mirrors the api /status.restore payload; @@ -405,6 +417,10 @@ async function load() { const response = await api.get('/shopdbbackups') sets.value = response.data.data || [] lastGoodAt.value = response.data.meta?.lastgoodat || null + // A deployment without the backup overlay has no store behind any of + // this. Say so instead of showing 'No verified backup yet', which reads + // as a backup that failed rather than a feature that is not installed. + isConfigured.value = response.data.meta?.configured !== false } catch (error) { toast.error('Could not load backups.') } finally { @@ -647,6 +663,17 @@ onUnmounted(() => { .banner-none { border-color: #c53030; } .banner-none .banner-icon { color: #e53e3e; } +.not-configured { + display: flex; + flex-direction: column; + gap: 0.35rem; + padding: 1rem 1.25rem; + margin-bottom: 1rem; + border: 1px solid var(--border-color, #d8dbe0); + border-radius: 8px; + background: var(--surface-muted, #f6f7f9); +} + .explain { margin-bottom: 1.25rem; border: 1px solid var(--border); diff --git a/plugins/computers/frontend/views/ConfigurationPoliciesList.vue b/plugins/computers/frontend/views/ConfigurationPoliciesList.vue index cbabc33d..2dfcd5b3 100644 --- a/plugins/computers/frontend/views/ConfigurationPoliciesList.vue +++ b/plugins/computers/frontend/views/ConfigurationPoliciesList.vue @@ -174,7 +174,9 @@ async function loadData() { } if (types.status === 'fulfilled') { - pctypes.value = types.value.data.data || [] + // Plain array, same as applications below. Reading .data.data threw + // inside this try, so apps, actions and monitors never got assigned. + pctypes.value = types.value || [] } else { failures.push('PC types') console.error('Error loading PC types:', types.reason) diff --git a/plugins/computers/frontend/views/PCTypesList.vue b/plugins/computers/frontend/views/PCTypesList.vue index 5afef7e4..9a6d95a5 100644 --- a/plugins/computers/frontend/views/PCTypesList.vue +++ b/plugins/computers/frontend/views/PCTypesList.vue @@ -107,8 +107,10 @@ onMounted(() => loadData()) async function loadData() { loading.value = true try { + // listAll resolves to a plain array (fetchAllPages), not an axios + // response. Reading .data.data threw and left the page empty. const response = await computersApi.types.listAll({ active: false }) - pcTypes.value = response.data.data || [] + pcTypes.value = response || [] } catch (err) { console.error('Error loading PC types:', err) } finally { diff --git a/plugins/endpoints/plugin.py b/plugins/endpoints/plugin.py index f3cdb870..3e13c8da 100644 --- a/plugins/endpoints/plugin.py +++ b/plugins/endpoints/plugin.py @@ -1121,7 +1121,10 @@ class EndpointsPlugin(BasePlugin): if 'osinfo' in payload: state.osinfojson = _as_json(payload.get('osinfo')) if 'domain' in payload: - state.domain = payload.get('domain') + # Clamped like every other String column here: an over-long value + # aborts the whole collector transaction under STRICT_TRANS_TABLES, + # so one bad agent report would drop a machine's entire telemetry. + state.domain = _clamp(payload.get('domain'), 255) if 'pendingreboot' in payload: state.pendingreboot = payload.get('pendingreboot') if 'diskhealth' in payload: diff --git a/scripts/backup/Dockerfile.backup b/scripts/backup/Dockerfile.backup index a207fdde..89562e00 100644 --- a/scripts/backup/Dockerfile.backup +++ b/scripts/backup/Dockerfile.backup @@ -14,7 +14,13 @@ FROM mysql:8.4 # bzip2/python3/alembic or a base image that carries them) so the release image # needs no outbound calls and no embedded cert. The cert is gitignored (*.pem) # and must be staged at scripts/backup/zscaler-root-ca.pem before building. -COPY scripts/backup/zscaler-root-ca.pem /etc/pki/ca-trust/source/anchors/zscaler-root-ca.pem +# +# The trailing * makes the COPY optional: a plain COPY of a gitignored file +# fails the build outright, so nobody outside a TLS-intercepting network - +# another site, or our own CI - could build this image at all. Absent cert, +# the glob matches nothing, the layer is a no-op and the fetches below go out +# over the public trust store. +COPY scripts/backup/zscaler-root-ca.pem* /etc/pki/ca-trust/source/anchors/ RUN update-ca-trust # restic from the distro is often stale; pull the pinned release binary so the diff --git a/shopdb/__init__.py b/shopdb/__init__.py index f513c409..f9e4a1c7 100644 --- a/shopdb/__init__.py +++ b/shopdb/__init__.py @@ -110,7 +110,12 @@ from .plugins import plugin_manager # internals. The journal records the primitive mutations a bulk import makes # (update a cell, insert a row) tied to a batch id, and a generic undo replays # them in reverse. Additive names on the surface, minor bump. -__contract_version__ = '0.30.0' +# +# 0.31.0 widens the asset-panel vocabulary a plugin may declare: two more +# tab groups (serviceprocess, reports), a datetime column format, a map body +# key, and a tabs section that renders as a table. All optional, so a plugin +# that declares none of them is unaffected. Additive, minor bump. +__contract_version__ = '0.31.0' # Product release version (see ADR-007). The product version and the # plugin-contract version above are distinct series with independent diff --git a/shopdb/core/api/shopdbbackups.py b/shopdb/core/api/shopdbbackups.py index c2f722bd..f885b56b 100644 --- a/shopdb/core/api/shopdbbackups.py +++ b/shopdb/core/api/shopdbbackups.py @@ -85,6 +85,30 @@ def _backups_root(): return os.environ.get(BACKUPS_ROOT_ENV, DEFAULT_BACKUPS_ROOT) +def _backups_configured(): + """Whether this deployment has a backup store behind the feature. + + The overlay is a compose profile: a third container writes the sets onto a + shared volume, and the api container only ever reads them and drops a + sentinel. A Windows/IIS install has no such container, and the default root + resolves there to a drive-letter path nothing creates - so every button + either 500s on PermissionError or reports 'queued' for a run that will + never happen. Answer the caller honestly instead. + """ + return os.path.isdir(_backups_root()) + + +def _unavailable_response(): + # 404 + NOT_FOUND is the established shape for "this feature is not + # enabled here" (the configuration-policy routes answer the same way), and + # the UI already tells a 404 apart from a real failure. + return error_response( + ErrorCodes.NOT_FOUND, + 'Site Backups is not configured on this deployment. It needs the ' + 'backup overlay and a readable backups root (%s).' % BACKUPS_ROOT_ENV, + http_code=404) + + def _read_json(path): """Load a JSON file, or None when it is absent or unreadable.""" try: @@ -339,6 +363,7 @@ def list_backups(): 'retention': RETENTION, 'lastgoodat': (laststgood or {}).get('takenat'), 'count': len(sets), + 'configured': _backups_configured(), } return success_response(data=sets, meta=meta) @@ -373,6 +398,9 @@ def trigger_backup(): up. Returning isrunning=false with a clear message keeps the UI honest rather than pretending the run started inside this process. """ + if not _backups_configured(): + return _unavailable_response() + root = _backups_root() try: os.makedirs(root, exist_ok=True) @@ -401,6 +429,9 @@ def restore_backup(snapshotid): The actual restore is performed by scripts/backup/restore.sh in the backup container; here we validate and record intent. """ + if not _backups_configured(): + return _unavailable_response() + root = _backups_root() setdir = os.path.join(root, os.path.basename(snapshotid)) if not os.path.isdir(setdir): @@ -451,6 +482,9 @@ def finalize_restore(): Idempotent and safe to call when nothing is pending: if there is no .upgrade-requested sentinel it reports there was nothing to finalize. """ + if not _backups_configured(): + return _unavailable_response() + root = _backups_root() sentinel = os.path.join(root, '.upgrade-requested') try: @@ -496,6 +530,7 @@ def get_status(): value is None when that kind of run has never been requested. """ return success_response(data={ + 'configured': _backups_configured(), 'backup': _read_status('backup'), 'restore': _read_status('restore'), })