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'),
})