Endpoint telemetry, PC Detail rework and a checked backup/restore path

This commit is contained in:
2026-09-06 18:53:11 -04:00
parent 32b8745d10
commit 993353dfc0
97 changed files with 13340 additions and 756 deletions

View File

@@ -3,7 +3,7 @@
a panel as JSON (title, endpoint, render mode, field map); this renders it
on any asset detail page, so a plugin adds detail-page UI with no Vue. -->
<template v-for="panel in groupedPanels" :key="panel.id">
<div v-if="panelVisible(panel)" class="section-card" :class="{ 'pap-span': panel.render === 'table' }">
<div v-if="panelVisible(panel)" class="section-card" :class="{ 'pap-span': spansFullWidth(panel) }">
<!-- collapsible panels get a clickable header with a caret and, when the
panel declares a status rollup, a green/yellow/red dot for the worst
row (a stopped service or a warning/critical event). --><h3
@@ -52,7 +52,20 @@
<div v-for="(item, index) in visibleRows(panel)" :key="index" class="pap-item">
<div class="pap-item-top">
<span class="pap-title">{{ mapTitle(panel, item) }}</span>
<span v-if="mapBadge(panel, item)" class="status-badge"
<!-- On a row carrying a body the severity badge IS the expander:
it already says what the row is, and a second control beside
it only repeated that. Rows with no body keep the plain
non-interactive badge. -->
<button
v-if="mapBadge(panel, item) && mapBody(panel, item)"
type="button"
class="status-badge pap-badge-toggle"
:style="colorStyle(mapBadge(panel, item).color)"
:aria-expanded="isBodyOpen(panel, index) ? 'true' : 'false'"
:title="isBodyOpen(panel, index) ? 'Hide details' : 'Show details'"
@click="toggleBody(panel, index)"
>{{ mapBadge(panel, item).label }}</button>
<span v-else-if="mapBadge(panel, item)" class="status-badge"
:style="colorStyle(mapBadge(panel, item).color)">
{{ mapBadge(panel, item).label }}
</span>
@@ -68,6 +81,9 @@
<span v-for="(m, mi) in mapMeta(panel, item)" :key="mi"
:class="{ mono: m.mono }">{{ m.text }}</span>
</div>
<div v-if="isBodyOpen(panel, index) && mapBody(panel, item)" class="pap-body">
{{ mapBody(panel, item) }}
</div>
</div>
<router-link v-if="panel.manage" :to="manageLink(panel, assetid)" class="pap-manage">
{{ panel.manage.label || 'Manage' }}
@@ -104,7 +120,42 @@
@click="setTab(panel, si)"
>{{ section.label }}</button>
</div>
<div class="pap-kv">
<!-- A section carrying columns renders as a table; otherwise it is the
original label/value grid. Both shapes coexist in one panel. -->
<div
v-if="((tabSections(panel)[activeTab(panel)] || {}).columns || []).length"
class="pap-table-wrap"
>
<table
v-if="((tabSections(panel)[activeTab(panel)] || {}).rows || []).length"
class="pap-table"
>
<thead>
<tr>
<th
v-for="col in (tabSections(panel)[activeTab(panel)] || {}).columns"
:key="col.key"
>{{ col.label }}</th>
</tr>
</thead>
<tbody>
<tr
v-for="(row, ri) in (tabSections(panel)[activeTab(panel)] || {}).rows || []"
:key="ri"
>
<td
v-for="col in (tabSections(panel)[activeTab(panel)] || {}).columns"
:key="col.key"
>{{ cell(row, col) }}</td>
</tr>
</tbody>
</table>
<div
v-if="!((tabSections(panel)[activeTab(panel)] || {}).rows || []).length"
class="pap-empty"
>{{ (tabSections(panel)[activeTab(panel)] || {}).empty || 'Nothing reported.' }}</div>
</div>
<div v-else class="pap-kv">
<div
v-for="(field, fi) in (tabSections(panel)[activeTab(panel)] || {}).fields || []"
:key="fi"
@@ -126,7 +177,10 @@
<!-- table: columns declared by the panel (or inferred from row keys) -->
<div v-else-if="panel.render === 'table'" class="pap-table-wrap">
<table class="pap-table">
<!-- Headers are suppressed when there are no rows. A bare header row
over an empty body reads as a broken card; the declared empty
message says which of "nothing here" or "nothing reported" it is. -->
<table v-if="rows(panel).length" class="pap-table">
<thead>
<tr><th v-for="col in tableColumns(panel)" :key="col.key">{{ col.label }}</th></tr>
</thead>
@@ -136,6 +190,9 @@
</tr>
</tbody>
</table>
<div v-else class="pap-empty">
<span class="muted">{{ panel.empty || 'Nothing to show.' }}</span>
</div>
</div>
<!-- badge: a row of colored badges -->
@@ -240,9 +297,9 @@ import { ref, computed, watch, onUnmounted, nextTick } from 'vue'
import api from '../api'
import { colorStyle } from '@/utils/colorStyle'
import {
toApiPath, rows, panelVisible, mapTitle, mapBadge, mapMeta, manageLink,
toApiPath, rows, panelVisible, mapTitle, mapBadge, mapMeta, mapBody, manageLink,
keyvalueFields, tableColumns, cell, badges, tabSections, rollupStatus,
rowAction, facets, facetedRows, formatValue,
rowAction, facets, facetedRows, formatValue, spansFullWidth,
} from './pluginAssetPanels'
// Operator report screenshots are served by a JWT-gated route, so a bare
@@ -365,12 +422,39 @@ function visibleRows(panel) {
return facetedRows(panel, facetSelection.value[panel.id])
}
// Which row bodies are expanded, keyed by panel id and row index. Closed on
// every panel refresh: the row at index 3 after a refresh is not necessarily
// the row that was opened, so keeping it open would expand an unrelated event.
const bodyOpen = ref({})
function bodyKey(panel, index) {
return `${panel.id}:${index}`
}
function isBodyOpen(panel, index) {
return !!bodyOpen.value[bodyKey(panel, index)]
}
function toggleBody(panel, index) {
const key = bodyKey(panel, index)
bodyOpen.value = { ...bodyOpen.value, [key]: !bodyOpen.value[key] }
}
const props = defineProps({
assetid: { type: [Number, String], default: null },
// Which detail-page tab to render. Null renders every panel, which is what
// the detail pages that have not adopted tabs still do - so this stays
// additive and no existing page changes behaviour.
group: { type: String, default: null },
// Render exactly these panel ids, ignoring group. For lifting one card out
// of its tab and showing it somewhere else - Network Interfaces is pinned to
// the top of Details while still belonging to monitors. Kept as a panel-id
// filter rather than a new group because a group would need the four-file
// vocabulary change AND would add a tab nobody asked for.
only: { type: Array, default: null },
// The other half of `only`: the group instance must drop what was lifted out
// of it, or the card renders twice on two different tabs.
exclude: { type: Array, default: null },
})
const panels = ref([])
@@ -386,14 +470,19 @@ const panels = ref([])
// 'actions', this build had no such tab, and the card was simply gone with
// nothing logged. The same applies to the 'metrics' to 'monitors' rename.
// Fall back the same way the server does rather than drop it.
const RENDERED_GROUPS = ['details', 'monitors', 'software', 'activity']
const RENDERED_GROUPS = ['details', 'monitors', 'serviceprocess', 'reports', 'software', 'activity']
function groupOf(panel) {
return RENDERED_GROUPS.includes(panel.group) ? panel.group : 'details'
}
const groupedPanels = computed(() =>
props.group ? panels.value.filter((p) => groupOf(p) === props.group) : panels.value
)
const groupedPanels = computed(() => {
if (props.only) return panels.value.filter((p) => props.only.includes(p.id))
let list = props.group
? panels.value.filter((p) => groupOf(p) === props.group)
: panels.value
if (props.exclude) list = list.filter((p) => !props.exclude.includes(p.id))
return list
})
// Which groups actually have a panel with data, so a parent can hide a tab
// rather than offering an empty one.
@@ -525,6 +614,7 @@ async function load() {
panels.value = []
revokeReportImageUrls()
closeReport()
bodyOpen.value = {}
if (!props.assetid) return
let declared
try {
@@ -562,6 +652,31 @@ defineExpose({ refresh: load, populatedGroups })
.pap-title { font-weight: 600; color: var(--text); }
.status-badge { padding: 0.15rem 0.6rem; border-radius: 12px; font-size: 0.75rem; font-weight: 600; }
.pap-meta { margin-top: 0.35rem; display: flex; flex-wrap: wrap; gap: 0.75rem; font-size: 0.82rem; color: var(--text-light); }
/* The badge doubles as the expander, so it needs to look pressable without
becoming a second style of badge: same pill, just a pointer and a hover
lift. margin-left:auto pins it right even on a row that also has an
action button, where space-between alone would centre it. */
.pap-badge-toggle {
margin-left: auto;
border: none;
cursor: pointer;
font-family: inherit;
line-height: inherit;
}
.pap-badge-toggle:hover { filter: brightness(1.1); }
.pap-badge-toggle:focus-visible { outline: 2px solid var(--primary, #4181ff); outline-offset: 2px; }
/* pre-wrap, not normal: the message keeps the line breaks the event was
written with, which is what makes a multi-line fault readable. */
.pap-body {
margin-top: 0.5rem;
padding: 0.5rem 0.6rem;
background: var(--surface-2, rgba(127, 127, 127, 0.08));
border-radius: 4px;
font-size: 0.82rem;
color: var(--text-light);
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.pap-empty { display: flex; align-items: center; gap: 0.6rem; }
.pap-facets {

View File

@@ -0,0 +1,51 @@
import { describe, it, expect } from 'vitest'
import { readFileSync } from 'fs'
import { resolve } from 'path'
// A `table` panel with no rows used to render a bare header row over an empty
// body, which reads as a broken card rather than as an answer. `list` had
// honoured panel.empty for a long time; `table` had not, for no reason anybody
// wrote down. Found on a live page where Network Interfaces and Listening
// Ports both showed column headers and nothing else.
//
// Asserted against the SOURCE because the condition lives in the template, not
// in an exported helper - the same reason panelGroups.spec.js reads source.
const source = readFileSync(
resolve(__dirname, 'PluginAssetPanels.vue'), 'utf8',
)
function tableBlock() {
const start = source.indexOf("panel.render === 'table'")
expect(start).toBeGreaterThan(-1)
// Up to the next render branch, so we only look at the table path.
const end = source.indexOf("panel.render === 'badge'", start)
expect(end).toBeGreaterThan(start)
return source.slice(start, end)
}
describe('an empty table panel', () => {
it('does not render the table element when there are no rows', () => {
expect(tableBlock()).toMatch(/<table[^>]*v-if="rows\(panel\)\.length"/)
})
it('falls back to the panel-declared empty message', () => {
expect(tableBlock()).toContain('panel.empty')
})
it('keeps a default message for a panel that declares none', () => {
// An empty card with no words in it is indistinguishable from a card that
// failed to load.
expect(tableBlock()).toMatch(/panel\.empty \|\| '[^']+'/)
})
})
describe('an empty table SECTION inside a tabs panel', () => {
it('also suppresses its header row', () => {
// The section path was added with the Storage merge and had the same bug.
const start = source.indexOf("panel.render === 'tabs'")
const end = source.indexOf("panel.render === 'keyvalue'", start)
const block = source.slice(start, end)
expect(block).toMatch(/<table\s+v-if="\(\(tabSections\(panel\)/)
})
})

View File

@@ -0,0 +1,124 @@
import { describe, it, expect } from 'vitest'
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
// The group vocabulary is split across four files and there is no runtime that
// checks they agree. When they disagree the failure is SILENT: a panel in a
// group nothing renders is fetched, held, and shown on no tab, with nothing
// logged. That has eaten cards twice - tracked services moving out of
// 'actions', and the 'metrics' to 'monitors' rename.
//
// So this reads the sources and asserts the vocabularies match. It is a source
// check rather than a behaviour check because RENDERED_GROUPS is a module-local
// constant in the .vue file with no export to assert against.
function read(relative) {
// .href, not the URL object: the jsdom test environment supplies its own
// URL global, and node's fileURLToPath rejects a foreign instance with
// "must be ... an instance of URL. Received an instance of URL".
return readFileSync(fileURLToPath(new URL(relative, import.meta.url).href), 'utf8')
}
function renderedGroups() {
const source = read('./PluginAssetPanels.vue')
const match = source.match(/const RENDERED_GROUPS = \[([^\]]*)\]/)
if (!match) throw new Error('RENDERED_GROUPS not found in PluginAssetPanels.vue')
return match[1].split(',')
.map((entry) => entry.trim().replace(/^'|'$/g, ''))
.filter(Boolean)
}
function pcDetailSource() {
return read('../../../plugins/computers/frontend/views/PCDetail.vue')
}
// The tabs that actually HOST panels - those where PCDetail mounts a
// PluginAssetPanels for the group. A tab is only bound by the vocabulary if a
// panel can land in it. Corporate Tools is the first tab that hosts none: it is
// permission-gated and renders a fixed action list, so RENDERED_GROUPS has
// nothing to say about it and requiring it there would make 'corptools' a legal
// panel group with no panel host - fetched, held, rendered nowhere, which is
// the very failure this file exists to catch.
function panelHostedGroups(source) {
return [...source.matchAll(/group="([a-z]+)"/g)].map((match) => match[1])
}
describe('panel group vocabulary', () => {
it('renders a group for every panel-backed tab PCDetail offers', () => {
const groups = renderedGroups()
const source = pcDetailSource()
const hosted = panelHostedGroups(source)
const tabKeys = [...source.matchAll(/tabs\.push\(\{\s*key:\s*'([a-z]+)'/g)]
.map((match) => match[1])
.filter((key) => hosted.includes(key))
expect(tabKeys.length).toBeGreaterThan(0)
for (const key of tabKeys) {
expect(groups).toContain(key)
}
})
it('never hosts panels in a group this build does not render', () => {
// The mirror of that exemption, so opting a tab out of panels cannot become
// a loophole: the moment a tab mounts a PluginAssetPanels, its group MUST
// be rendered, or the panels are fetched and dropped with nothing logged.
const groups = renderedGroups()
const hosted = panelHostedGroups(pcDetailSource())
expect(hosted.length).toBeGreaterThan(0)
for (const group of hosted) {
expect(groups).toContain(group)
}
})
it('gives every rendered group somewhere to actually show', () => {
// A group in RENDERED_GROUPS with no v-show block is the mirror failure:
// the panel is kept rather than dropped, but still appears nowhere.
const source = pcDetailSource()
for (const group of renderedGroups()) {
expect(source).toContain(`activegroup === '${group}'`)
}
})
it('keeps the service/process split wired end to end', () => {
// Pins the specific split rather than only the general rule, so removing
// the tab is a deliberate edit to this test and not a silent regression.
const source = pcDetailSource()
expect(renderedGroups()).toContain('serviceprocess')
expect(source).toContain("key: 'serviceprocess'")
expect(source).toContain("group=\"serviceprocess\"")
})
it('still renders the system monitors group after the rename', () => {
// The rename is label-only; the group key must not drift with it.
expect(renderedGroups()).toContain('monitors')
expect(pcDetailSource()).toContain("label: 'System Monitors'")
})
it('agrees with the server vocabulary that resolves groups first', () => {
// The fourth file this comment block claims to cover, and the one that was
// NOT actually being checked. asset_panels() rewrites any group missing
// from ASSET_PANEL_GROUPS to 'details' before the client sees it, so the
// frontend can be perfectly consistent with itself and still show nothing:
// that is exactly how the Service/Process tab went missing after Phase 3.
const source = read('../../../shopdb/core/api/pluginui.py')
const match = source.match(/ASSET_PANEL_GROUPS = \(([\s\S]*?)\)/)
if (!match) throw new Error('ASSET_PANEL_GROUPS not found in pluginui.py')
const serverGroups = match[1].split(',')
.map((entry) => entry.trim().replace(/^'|'$/g, ''))
.filter(Boolean)
for (const group of renderedGroups()) {
expect(serverGroups).toContain(group)
}
})
it('keeps operator reports in a tab of its own', () => {
const source = pcDetailSource()
expect(renderedGroups()).toContain('reports')
expect(source).toContain("key: 'reports'")
expect(source).toContain('group="reports"')
})
})

View File

@@ -0,0 +1,211 @@
import { describe, it, expect } from 'vitest'
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
// Network Interfaces is DECLARED in the monitors group but RENDERED at the top
// of the Details tab. That is done with a panel-id filter (`only`) rather than
// a new group, because a new group would need the four-file vocabulary change
// and would add a tab nobody asked for.
//
// The pair is the fragile part: `only` lifts the panel out, and `exclude` must
// drop it from the group instance it was lifted from. Miss the second half and
// the same card renders on two tabs, which looks like duplicated data rather
// than a layout bug.
function read(relative) {
// .href, not the URL object: the jsdom test environment supplies its own
// URL global, and node's fileURLToPath rejects a foreign instance with
// "must be ... an instance of URL. Received an instance of URL".
return readFileSync(fileURLToPath(new URL(relative, import.meta.url).href), 'utf8')
}
const panelsSource = () => read('./PluginAssetPanels.vue')
const pcDetailSource = () => read('../../../plugins/computers/frontend/views/PCDetail.vue')
describe('lifting one panel out of its group', () => {
it('accepts an only prop', () => {
expect(panelsSource()).toMatch(/only:\s*\{\s*type:\s*Array/)
})
it('accepts an exclude prop', () => {
expect(panelsSource()).toMatch(/exclude:\s*\{\s*type:\s*Array/)
})
it('lets only bypass the group filter entirely', () => {
// Details renders a panel belonging to monitors, so `only` must NOT be
// and-ed with the group or it would match nothing.
const source = panelsSource()
const start = source.indexOf('const groupedPanels')
const block = source.slice(start, start + 400)
expect(block).toMatch(/if\s*\(props\.only\)/)
})
})
describe('the pinned Network Interfaces card', () => {
it('renders at the top of Details by panel id', () => {
expect(pcDetailSource()).toContain("only=\"['endpoints-interfaces']\"")
})
it('is excluded from System Monitors so it cannot render twice', () => {
// Scoped to the monitors instance and matched on membership, not on the
// exact array text: more than one panel is lifted now.
const source = pcDetailSource()
const start = source.indexOf('group="monitors"')
const block = source.slice(start, start + 200)
expect(block).toContain('endpoints-interfaces')
})
it('is still declared in monitors, not moved to a new group', () => {
// One declaration, no vocabulary change. If somebody "fixes" this by
// adding a reports-style group, the four-file dance applies again.
// Bounded by the NEXT panel's id: the columns list contains '},' so
// slicing to that would cut the block off before the group key.
const plugin = read('../../../plugins/endpoints/plugin.py')
const start = plugin.indexOf("'id': 'endpoints-interfaces'")
const next = plugin.indexOf("'id': '", start + 10)
const block = plugin.slice(start, next === -1 ? undefined : next)
expect(block).toContain("'group': 'monitors'")
})
})
describe('the header cloud identity block', () => {
it('shows the block on template presence, not on this PC having an id', () => {
// A site with no Intune/Entra at all must not see a permanent request for
// an id it will never have.
const source = pcDetailSource()
const start = source.indexOf('const hasCloudIdentity')
const block = source.slice(start, start + 260)
expect(block).toContain('entraUrlTemplate.value')
expect(block).toContain('intuneUrlTemplate.value')
})
it('waits for the portal templates before rendering', () => {
// The templates come from an async settings fetch. Rendering the ids
// first and adding the buttons a moment later looked like the buttons
// were missing until the page was manually refreshed.
const source = pcDetailSource()
const start = source.indexOf('const hasCloudIdentity')
const block = source.slice(start, start + 260)
expect(block).toContain('portalTemplatesLoaded.value')
})
it('marks the templates settled even when the fetch fails', () => {
// Otherwise a down settings backend hides the block permanently.
// Bounded by the function's own closing brace rather than a character
// count: a fixed window silently excluded the finally block as soon as
// the retry logic grew, which reported as "the finally is missing" when
// the real code was correct. Slice to the next top-level declaration.
const source = pcDetailSource()
const start = source.indexOf('async function loadPortalTemplates')
const next = source.indexOf('\nonUnmounted(', start)
const block = source.slice(start, next === -1 ? undefined : next)
expect(block).toContain('finally')
expect(block).toContain('portalTemplatesLoaded.value = true')
})
it('names which id is missing rather than rendering an empty block', () => {
const source = pcDetailSource()
expect(source).toContain('Please provide DeviceID')
expect(source).toContain('Please provide ObjectID')
})
it('no longer carries the old Entra Device Info card', () => {
expect(pcDetailSource()).not.toContain('<h3 class="section-title">Entra Device Info</h3>')
})
it('no longer carries the hand-written Network card', () => {
// Its two halves are the IP History and IP Configuration panels now.
expect(pcDetailSource()).not.toContain('<h3 class="section-title">Network</h3>')
})
it('keeps the full ids on screen so they can be copied', () => {
// The move to the header was a MOVE, not a redesign. Rendering only an
// "Open device" link loses the id itself, which gets pasted into tickets
// and Graph queries. Both ids must still be printed in full.
const source = pcDetailSource()
expect(source).toContain('computer.computer.azuredeviceid }}')
expect(source).toContain('computer.computer.azureobjectid }}')
})
it('offers a copy button for each id', () => {
// Clicking to select still leaves the operator to press ctrl+c; these ids
// get pasted into tickets and Graph queries constantly.
const source = pcDetailSource()
expect(source).toContain("copyId(computer.computer.azuredeviceid, 'Device ID')")
expect(source).toContain("copyId(computer.computer.azureobjectid, 'Object ID')")
})
it('reports a failed copy instead of a false success', () => {
// clipboard.writeText is undefined over plain HTTP and rejects when the
// tab is unfocused. Claiming success there would be a lie.
const source = pcDetailSource()
const start = source.indexOf('async function copyId')
const block = source.slice(start, start + 420)
expect(block).toContain('catch')
expect(block).toContain('toast.error')
})
it('keeps the id selectable as a clipboard fallback', () => {
const source = pcDetailSource()
const start = source.indexOf('.hero-portal-id {')
const block = source.slice(start, start + 260)
expect(block).toContain('user-select: all')
})
it('does not truncate an id with an ellipsis', () => {
// A half-copied GUID is worse than no GUID: it looks complete.
const source = pcDetailSource()
const start = source.indexOf('.hero-portal-id {')
const block = source.slice(start, start + 260)
expect(block).not.toContain('text-overflow: ellipsis')
expect(block).toContain('user-select: all')
})
it('keeps the original Intune and Entra buttons', () => {
// These carry a race-condition fix. Relabelling them to "Open device" /
// "Open object" threw that away once already.
const source = pcDetailSource()
expect(source).toContain('>Intune</a>')
expect(source).toContain('>Entra</a>')
})
it('lays the portal block out horizontally, not as a narrow rail', () => {
// A fixed-width right rail wrapped 36-character GUIDs into a tall ragged
// stack that grew past the identity block beside it.
const source = pcDetailSource()
const start = source.indexOf('.hero-portals {')
const block = source.slice(start, start + 260)
expect(block).toContain('flex-wrap: wrap')
expect(block).not.toContain('width: 300px')
})
it('does not restyle the header classes other assets share', () => {
// Machines, Printers and friends share .hero-details/.hero-detail-* from
// global style.css. A PC-only header must not redefine them.
const source = pcDetailSource()
const style = source.slice(source.indexOf('<style scoped>'))
expect(style).not.toContain('.hero-details {')
expect(style).not.toContain('.hero-detail-label {')
expect(style).not.toContain('.hero-detail-value {')
})
})
describe('the lifted Mapped drives card', () => {
it('renders on Details', () => {
expect(pcDetailSource()).toContain("['endpoints-mappeddrives']")
})
it('is excluded from System Monitors so it renders once', () => {
const source = pcDetailSource()
const start = source.indexOf('group="monitors"')
const block = source.slice(start, start + 200)
expect(block).toContain('endpoints-mappeddrives')
})
it('is still declared in monitors, not moved to a new group', () => {
const plugin = read('../../../plugins/endpoints/plugin.py')
const start = plugin.indexOf("'id': 'endpoints-mappeddrives'")
const block = plugin.slice(start, start + 1200)
expect(block).toContain("'group': 'monitors'")
})
})

View File

@@ -19,6 +19,15 @@ export function formatValue(value, spec = {}) {
const raw = String(value)
return new Date(raw + (raw.length === 10 ? 'T00:00:00' : '')).toLocaleDateString()
}
// Timestamps arrive as UTC ISO ("2026-09-04T16:13:29Z") because the wire
// format is UTC by contract. Render in the viewer's local zone: an operator
// comparing "last reported" against the clock on the wall cannot do the
// offset in their head, and the Z suffix invites reading it as local.
if (spec.format === 'datetime') {
const parsed = new Date(String(value))
if (isNaN(parsed)) return value
return parsed.toLocaleString()
}
if (typeof value === 'boolean') return value ? 'Yes' : 'No'
return value
}
@@ -138,6 +147,14 @@ export function mapMeta(panel, item) {
.filter(Boolean)
}
export function mapBody(panel, item) {
const key = panel.map && panel.map.body
if (!key) return null
const value = item[key]
if (value === null || value === undefined || value === '') return null
return String(value)
}
export function manageLink(panel, assetid) {
return panel.manage ? subst(panel.manage.to, assetid) : null
}
@@ -210,17 +227,38 @@ export function rollupStatus(panel) {
// tabs: [{ label, fields: [{label, value, mono}] }]. One section is shown at a
// time, so a panel with 30 values stays the height of its largest section
// instead of dragging one multicol column far past the other.
//
// A section may instead carry { columns, rows, empty } and render as a table.
// This exists so two tables that belong side by side for the reader but share
// NO key can sit in one card - Storage is per volume, Disk health is per
// physical device. The sections are independent tables by design: nothing here
// reconciles rows across them, unlike the mapped-drive merge where two sources
// described the same drive.
export function tabSections(panel) {
const data = panel._data
const sections = (data && Array.isArray(data.sections)) ? data.sections : []
return sections
.map((s) => ({
label: s.label,
fields: (s.fields || []).map((f) => ({
label: f.label, value: formatValue(f.value, f), mono: !!f.mono,
})),
}))
.filter((s) => s.fields.length > 0)
.map((s) => {
if (Array.isArray(s.columns) && s.columns.length) {
return {
label: s.label,
columns: s.columns.map((c) => ({ key: c.key, label: c.label || humanize(c.key) })),
rows: Array.isArray(s.rows) ? s.rows : [],
empty: s.empty || '',
fields: [],
}
}
return {
label: s.label,
fields: (s.fields || []).map((f) => ({
label: f.label, value: formatValue(f.value, f), mono: !!f.mono,
})),
}
})
// A table section declares its columns up front, so it survives having no
// rows and shows its own empty message. Dropping it would take the tab with
// it and read as "this machine has no disks" rather than "nothing reported".
.filter((s) => s.fields.length > 0 || (s.columns && s.columns.length > 0))
}
// Inline row action for a list panel. A monitored services/processes row may
@@ -247,6 +285,16 @@ export function rowAction(panel, item) {
}
}
// A table needs the full card width. `table` panels get it via panel.render,
// but a `tabs` panel only needs it when one of its sections is a table, and
// that is not knowable from the declaration - it depends on fetched data.
export function spansFullWidth(panel) {
if (!panel) return false
if (panel.render === 'table') return true
if (panel.render !== 'tabs') return false
return tabSections(panel).some((s) => (s.columns || []).length > 0)
}
// A panel declaring `empty` is asking to stay visible with nothing in it and
// say so - "No installed software reported." is an answer, where an absent
// card is a question. `list` honoured that and `table` did not, for no reason

View File

@@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest'
import {
toApiPath, subst, rows, mapTitle, mapBadge, mapMeta, manageLink,
toApiPath, subst, rows, mapTitle, mapBadge, mapMeta, mapBody, manageLink,
keyvalueFields, tableColumns, badges, panelVisible, tabSections, rollupStatus,
rowAction, facets, facetedRows,
} from './pluginAssetPanels'
@@ -64,7 +64,6 @@ describe('list render mapping (warranty)', () => {
it('builds the manage link with the assetid', () => {
expect(manageLink(warrantyPanel, 7)).toBe('/warranties?addfor=7')
})
it('is visible when it has rows or an empty message', () => {
expect(panelVisible(warrantyPanel)).toBe(true)
expect(panelVisible({ ...warrantyPanel, _data: [] })).toBe(true) // has empty
@@ -72,6 +71,24 @@ describe('list render mapping (warranty)', () => {
})
})
describe('expandable row body (activity feed)', () => {
const activityPanel = {
id: 'endpoints-activity', render: 'list',
map: { title: 'title', body: 'body' },
}
it('returns the body only for rows that carry one', () => {
expect(mapBody(activityPanel, { title: 'Crash', body: 'Faulting module' }))
.toBe('Faulting module')
expect(mapBody(activityPanel, { title: 'Crash' })).toBeNull()
expect(mapBody(activityPanel, { title: 'Crash', body: '' })).toBeNull()
})
it('returns null when the panel declares no body key', () => {
expect(mapBody(warrantyPanel, { body: 'ignored' })).toBeNull()
})
})
describe('list row filtering (tracked services vs processes)', () => {
const feedPanel = {
id: 'endpoints-tracked-services', render: 'list',

View File

@@ -0,0 +1,99 @@
import { describe, it, expect } from 'vitest'
import { tabSections, panelVisible, spansFullWidth } from './pluginAssetPanels'
// The tabs render type carried label/value sections only. It now also carries
// table sections, so that two tables which belong together for the reader but
// share no key can sit in one card. This is the shared renderer every plugin
// uses, so the label/value path is pinned here alongside the new one.
function tabsPanel(sections) {
return { id: 'p', render: 'tabs', _data: { sections } }
}
const FIELD_SECTION = { label: 'Fields', fields: [{ label: 'A', value: '1' }] }
const TABLE_SECTION = {
label: 'Volumes',
columns: [{ key: 'drive', label: 'Drive' }],
rows: [{ drive: 'C:' }],
empty: 'No disks reported.',
}
describe('tabSections with table sections', () => {
it('keeps rendering label/value sections as before', () => {
const sections = tabSections(tabsPanel([FIELD_SECTION]))
expect(sections).toHaveLength(1)
expect(sections[0].fields).toEqual([{ label: 'A', value: '1', mono: false }])
expect(sections[0].columns).toBeUndefined()
})
it('carries columns and rows through for a table section', () => {
const sections = tabSections(tabsPanel([TABLE_SECTION]))
expect(sections[0].columns).toEqual([{ key: 'drive', label: 'Drive' }])
expect(sections[0].rows).toEqual([{ drive: 'C:' }])
})
it('lets one panel mix a table section and a field section', () => {
const sections = tabSections(tabsPanel([TABLE_SECTION, FIELD_SECTION]))
expect(sections.map((s) => s.label)).toEqual(['Volumes', 'Fields'])
})
// The reason the empty-section filter needed a table branch at all. A card
// whose second section is dropped loses that tab, and "no disks" is a very
// different claim from "the collector reported nothing".
it('keeps an empty table section so its tab and message survive', () => {
const sections = tabSections(tabsPanel([{ ...TABLE_SECTION, rows: [] }]))
expect(sections).toHaveLength(1)
expect(sections[0].empty).toBe('No disks reported.')
})
it('still drops a label/value section with no fields', () => {
const sections = tabSections(tabsPanel([{ label: 'Empty', fields: [] }]))
expect(sections).toHaveLength(0)
})
it('derives a column label from the key when none is given', () => {
const sections = tabSections(tabsPanel([
{ label: 'V', columns: [{ key: 'drive' }], rows: [] },
]))
expect(sections[0].columns[0].label).toBe('Drive')
})
it('treats a missing rows array as no rows rather than throwing', () => {
const sections = tabSections(tabsPanel([{ label: 'V', columns: [{ key: 'drive' }] }]))
expect(sections[0].rows).toEqual([])
})
})
describe('panelVisible for a tabs panel of tables', () => {
it('shows a panel whose only section is an empty table', () => {
expect(panelVisible(tabsPanel([{ ...TABLE_SECTION, rows: [] }]))).toBe(true)
})
it('still hides a tabs panel with no sections at all', () => {
expect(panelVisible(tabsPanel([]))).toBe(false)
})
})
describe('spansFullWidth', () => {
// A six-column table squeezed into one half of a two-column grid is the
// failure this prevents.
it('spans a tabs panel that contains a table section', () => {
expect(spansFullWidth(tabsPanel([TABLE_SECTION]))).toBe(true)
})
it('does not span a tabs panel of label/value sections', () => {
expect(spansFullWidth(tabsPanel([FIELD_SECTION]))).toBe(false)
})
it('still spans a plain table panel', () => {
expect(spansFullWidth({ render: 'table' })).toBe(true)
})
it('does not span a keyvalue panel', () => {
expect(spansFullWidth({ render: 'keyvalue' })).toBe(false)
})
it('tolerates a null panel', () => {
expect(spansFullWidth(null)).toBe(false)
})
})

View File

@@ -221,5 +221,11 @@ export default [
name: 'users',
component: () => import('../../views/settings/UsersList.vue'),
meta: { requiresAuth: true, requiresAdmin: true }
},
{
path: 'settings/shopdbbackups',
name: 'shopdbbackups',
component: () => import('../../views/settings/ShopdbBackupsList.vue'),
meta: { requiresAuth: true, requiresAdmin: true }
}
]

View File

@@ -5,19 +5,46 @@ import { settingsApi } from '@/api'
import { withBase } from '@/utils/basePath'
let settingsCache = null
let inFlight = null
let retryAfter = 0
// Whether the last resolved load actually produced settings. A caller cannot
// tell that from the value alone: a failed load and a site that simply has not
// configured a key both read as empty, and those want opposite handling -
// hide the feature vs try again.
export function settingsLoadFailed() {
return settingsCache === null
}
// A failed load is cached briefly, not for the page session. A transient blip
// used to blank every settings-driven link until the operator reloaded, and
// they have no reason to know that would fix it. A short window still bounds a
// hard-down backend to two requests a minute per client.
const FAILURE_RETRY_MS = 30000
// The in-flight promise is cached alongside the result. Callers that arrive
// during the first load share one request instead of each firing their own;
// applyBranding() alone awaits five settings in sequence at bootstrap.
async function loadSettings() {
if (settingsCache) return settingsCache
try {
const response = await settingsApi.list()
const items = response.data?.data || response.data || []
settingsCache = {}
for (const s of items) settingsCache[s.key] = s.value
} catch (err) {
console.error('Error loading site settings:', err)
settingsCache = {}
}
return settingsCache
if (inFlight) return inFlight
if (retryAfter && Date.now() < retryAfter) return {}
inFlight = settingsApi.list()
.then((response) => {
const items = response.data?.data || response.data || []
const loaded = {}
for (const s of items) loaded[s.key] = s.value
settingsCache = loaded
retryAfter = 0
return loaded
})
.catch((error) => {
console.error('Error loading site settings:', error)
retryAfter = Date.now() + FAILURE_RETRY_MS
return {}
})
.finally(() => { inFlight = null })
return inFlight
}
export async function getSetting(key, fallback = '') {
@@ -132,10 +159,14 @@ export async function getServicenowUrls() {
// setting instead of waiting for a release.
//
// Empty is the default and means "no link" - the id still renders as text.
//
// `loaded` distinguishes a real answer from a failed fetch. Both return empty
// strings, but only one of them means "this site has no portal links".
export async function getEntraDeviceUrls() {
const entraUrl = await getSetting('entra_device_url', '')
const intuneUrl = await getSetting('intune_device_url', '')
return { entraUrl, intuneUrl }
const lapsUrl = await getSetting('laps_device_url', '')
return { entraUrl, intuneUrl, lapsUrl, loaded: !settingsLoadFailed() }
}
// Printer hostname template. {ip} is replaced with the dash-separated IP.

View File

@@ -0,0 +1,119 @@
// Site settings load once, and a blip does not blank the page for good.
//
// Two bugs motivated these. The cache used to hold only the RESOLVED object, so
// every caller arriving before the first response fired its own request -
// applyBranding() alone awaits five settings in sequence at bootstrap. And a
// failed load cached {} for the page session, so one bad GET silently blanked
// every settings-driven link (the Intune/Entra buttons) until a manual reload.
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
const list = vi.fn()
vi.mock('@/api', () => ({ settingsApi: { list: () => list() } }))
// The module caches in module scope, so each test needs a fresh copy.
async function freshModule() {
vi.resetModules()
return import('./siteSettings')
}
function respond(items) {
return { data: { data: items } }
}
beforeEach(() => {
list.mockReset()
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
describe('loading', () => {
it('fetches once for concurrent callers rather than once per caller', async () => {
let release
list.mockReturnValue(new Promise((resolve) => { release = resolve }))
const settings = await freshModule()
const waiting = Promise.all([
settings.getSetting('entra_device_url'),
settings.getSetting('intune_device_url'),
settings.getFacilityName(),
])
release(respond([{ key: 'entra_device_url', value: 'https://entra/{id}' }]))
const [entra, intune, facility] = await waiting
expect(list).toHaveBeenCalledTimes(1)
expect(entra).toBe('https://entra/{id}')
expect(intune).toBe('')
expect(facility).toBe('ShopDB')
})
it('serves later callers from cache without refetching', async () => {
list.mockResolvedValue(respond([{ key: 'facility_name', value: 'Wilmington' }]))
const settings = await freshModule()
expect(await settings.getFacilityName()).toBe('Wilmington')
expect(await settings.getFacilityName()).toBe('Wilmington')
expect(list).toHaveBeenCalledTimes(1)
})
it('gives both portal templates to a single caller from one fetch', async () => {
list.mockResolvedValue(respond([
{ key: 'entra_device_url', value: 'https://entra/{id}' },
{ key: 'intune_device_url', value: 'https://intune/{id}' },
]))
const settings = await freshModule()
expect(await settings.getEntraDeviceUrls()).toEqual({
entraUrl: 'https://entra/{id}',
intuneUrl: 'https://intune/{id}',
lapsUrl: '',
loaded: true,
})
expect(list).toHaveBeenCalledTimes(1)
})
})
describe('failure', () => {
it('falls back without throwing so a caller still renders', async () => {
list.mockRejectedValue(new Error('network down'))
vi.spyOn(console, 'error').mockImplementation(() => {})
const settings = await freshModule()
expect(await settings.getFacilityName()).toBe('ShopDB')
// loaded:false is the point - a failed fetch and an unconfigured site both
// read as empty strings, and the caller needs to tell them apart.
expect(await settings.getEntraDeviceUrls()).toEqual({
entraUrl: '', intuneUrl: '', lapsUrl: '', loaded: false,
})
})
it('does not hammer a backend that is down', async () => {
list.mockRejectedValue(new Error('network down'))
vi.spyOn(console, 'error').mockImplementation(() => {})
const settings = await freshModule()
await settings.getFacilityName()
await settings.getFacilityName()
await settings.getFacilityName()
expect(list).toHaveBeenCalledTimes(1)
})
it('recovers after the retry window instead of staying blank for the session', async () => {
list.mockRejectedValueOnce(new Error('network down'))
vi.spyOn(console, 'error').mockImplementation(() => {})
const settings = await freshModule()
expect(await settings.getSetting('entra_device_url')).toBe('')
vi.setSystemTime(Date.now() + 31000)
list.mockResolvedValue(respond([{ key: 'entra_device_url', value: 'https://entra/{id}' }]))
expect(await settings.getSetting('entra_device_url')).toBe('https://entra/{id}')
expect(list).toHaveBeenCalledTimes(2)
})
})

View File

@@ -0,0 +1,891 @@
<template>
<div class="backups-page">
<div class="page-header">
<h2>Backups</h2>
</div>
<div v-if="!isConfigured" class="not-configured">
<strong>Site Backups is not configured on this deployment.</strong>
<span>
The feature needs the backup overlay (a separate container writing sets
onto a shared volume) and a readable backups root. A Windows/IIS install
does not run it, so nothing here would have a store behind it.
</span>
</div>
<!-- Confidence banner: the single most reassuring thing an admin wants to
see - is there a recent, verified backup? Everything else is secondary. -->
<div v-if="isConfigured" class="confidence-banner" :class="bannerClass">
<span class="banner-icon">
<component :is="bannerIcon" :size="22" />
</span>
<div class="banner-text">
<strong>{{ bannerHeadline }}</strong>
<span class="banner-sub">{{ bannerSub }}</span>
</div>
<button class="btn-primary" :disabled="isBusy" @click="runBackup">
{{ isBusy ? 'Working...' : 'Back up now' }}
</button>
</div>
<!-- Light explanation, details tucked away. Leads the UI-simplification
charge: one plain sentence up top, the depth behind a disclosure. -->
<details class="explain">
<summary>What is backed up, and how restore works</summary>
<div class="explain-body">
<p>
Each backup is one recoverable set: the full database (users,
settings, and every asset), the uploaded files, and a manifest that
records the exact code version so a restore can never land on the
wrong schema.
</p>
<p>
Backups keep <strong>any day in the last 5</strong>, plus weekly and
monthly copies. Restoring your <em>data</em> is separate from rolling
back <em>code</em>: code is handled in git. If a restore needs a newer
code version than you are running, this screen tells you before it
lets you proceed.
</p>
</div>
</details>
<!-- Destination configuration: where sets ship and the credentials to get
there. SMB and AWS are wired but not yet armed (banner says so)
because their client libraries wait on the security review. -->
<details class="explain destination" :open="settingsOpen">
<summary @click.prevent="settingsOpen = !settingsOpen">
Backup destination
<span class="dest-chip">{{ destinationLabel(settings.destinationkind) }}</span>
</summary>
<div class="explain-body">
<div v-if="settingsLoading" class="muted">Loading destination...</div>
<template v-else>
<label class="field">
<span class="field-label">Ship backups to</span>
<select v-model="settings.destinationkind" class="input">
<option
v-for="kind in settings.destinationkinds"
:key="kind"
:value="kind"
>
{{ destinationLabel(kind) }}
</option>
</select>
</label>
<p v-if="settings.destinationkind === 'local'" class="muted small">
Sets stay on the mounted backups volume. No credentials needed.
</p>
<div
v-else
class="not-armed"
>
<component :is="ShieldAlert" :size="16" />
<span>
This destination is configured but not yet armed. Its client
library is held back until the security review clears it against
external exploit; until then sets keep writing locally.
</span>
</div>
<template v-if="settings.destinationkind === 'smb'">
<label class="field">
<span class="field-label">Server host</span>
<input v-model="settings.fields.backup_smb_host" class="input" />
</label>
<label class="field">
<span class="field-label">Share</span>
<input v-model="settings.fields.backup_smb_share" class="input" />
</label>
<label class="field">
<span class="field-label">Path within share</span>
<input v-model="settings.fields.backup_smb_path" class="input" />
</label>
<label class="field">
<span class="field-label">Username</span>
<input v-model="settings.fields.backup_smb_username" class="input" />
</label>
<label class="field">
<span class="field-label">Password</span>
<input
v-model="settings.fields.backup_smb_password"
type="password"
class="input"
autocomplete="new-password"
/>
</label>
</template>
<template v-else-if="settings.destinationkind === 'aws'">
<label class="field">
<span class="field-label">Region</span>
<input v-model="settings.fields.backup_aws_region" class="input" />
</label>
<label class="field">
<span class="field-label">Bucket</span>
<input v-model="settings.fields.backup_aws_bucket" class="input" />
</label>
<label class="field">
<span class="field-label">Key prefix</span>
<input v-model="settings.fields.backup_aws_prefix" class="input" />
</label>
<label class="field">
<span class="field-label">Access key id</span>
<input v-model="settings.fields.backup_aws_accesskeyid" class="input" />
</label>
<label class="field">
<span class="field-label">Secret access key</span>
<input
v-model="settings.fields.backup_aws_secretaccesskey"
type="password"
class="input"
autocomplete="new-password"
/>
</label>
</template>
<div class="modal-actions">
<button class="btn-primary" :disabled="savingSettings" @click="saveSettings">
{{ savingSettings ? 'Saving...' : 'Save destination' }}
</button>
</div>
</template>
</div>
</details>
<div v-if="loading" class="muted">Loading backups...</div>
<div v-else-if="!sets.length" class="empty-state">
<p>No backups yet. Run your first one above - it is safe to do anytime.</p>
</div>
<table v-else class="backups-table">
<thead>
<tr>
<th>Taken</th>
<th>Size</th>
<th>Status</th>
<th>Restore</th>
</tr>
</thead>
<tbody>
<tr v-for="set in sets" :key="set.snapshotid">
<td>
<span class="taken">{{ formatTaken(set.takenat) }}</span>
<span class="setid">{{ set.snapshotid }}</span>
</td>
<td>{{ formatSize(set.sizebytes) }}</td>
<td>
<span class="chip" :class="statusClass(set)">
{{ statusLabel(set) }}
</span>
</td>
<td>
<button
class="btn-restore"
:class="{ blocked: !set.iscompatible }"
:disabled="isBusy"
@click="openRestore(set)"
>
Restore...
</button>
</td>
</tr>
</tbody>
</table>
<!-- Operational run records, written by the backup container onto the
shared volume. Two cards: what was backed up, and what was restored. -->
<div class="history-cards">
<div class="history-card">
<h3 class="history-title">Backup history</h3>
<p v-if="!backups.length" class="muted">No backup runs recorded yet.</p>
<ul v-else class="history-list">
<li v-for="(entry, index) in backups" :key="'b' + index" class="history-row">
<span class="history-outcome" :class="outcomeClass(entry.outcome)">
{{ outcomeLabel(entry.outcome) }}
</span>
<span class="history-set">{{ entry.setname || 'unknown set' }}</span>
<span class="history-when">{{ formatTaken(entry.finishedat) }}</span>
</li>
</ul>
</div>
<div class="history-card">
<h3 class="history-title">Restore history</h3>
<p v-if="!restores.length" class="muted">No restore runs recorded yet.</p>
<ul v-else class="history-list">
<li v-for="(entry, index) in restores" :key="'r' + index" class="history-row">
<span class="history-outcome" :class="outcomeClass(entry.outcome)">
{{ outcomeLabel(entry.outcome) }}
</span>
<span class="history-set">{{ entry.setname || 'unknown set' }}</span>
<span class="history-when">{{ formatTaken(entry.finishedat) }}</span>
</li>
</ul>
</div>
</div>
<!-- Guarded restore modal: the one genuinely risky action, so it spends the
UI budget on the compatibility verdict before allowing the click. -->
<div v-if="restoreTarget" class="modal-backdrop" @click.self="closeRestore">
<div class="modal">
<h3>Restore this backup?</h3>
<p class="modal-set">{{ formatTaken(restoreTarget.takenat || restoreTarget.snapshotid) }}</p>
<!-- Progress mode: once a restore is queued we swap the compatibility
body for an indeterminate spinner and the current phase label. -->
<template v-if="restorePhase">
<div class="restore-progress" :class="{ failed: restorePhase === 'failed' }">
<span v-if="isRestoreRunning" class="spinner" aria-hidden="true"></span>
<component
:is="restorePhase === 'done' ? ShieldCheck : AlertTriangle"
v-else
:size="18"
/>
<span class="restore-phase">{{ restorePhaseLabel }}</span>
</div>
<p class="restore-hint">
Keep this open until the restore finishes. This can take a moment.
</p>
<div class="modal-actions">
<button
class="btn-ghost"
:disabled="isRestoreRunning"
@click="closeRestore"
>
Close
</button>
</div>
</template>
<template v-else>
<div class="compat" :class="restoreTarget.iscompatible ? 'ok' : 'warn'">
<component :is="restoreTarget.iscompatible ? ShieldCheck : AlertTriangle" :size="18" />
<span v-if="restoreTarget.iscompatible">
Compatible with the code you are running. Safe to restore.
</span>
<span v-else>
This backup was taken on a newer schema than the running code.
Upgrade the code first, then retry - restore is blocked until then.
</span>
</div>
<details class="explain small">
<summary>What restoring does</summary>
<div class="explain-body">
<p>
Restoring replaces the current database and uploaded files with the
contents of this backup. Code is not changed. This cannot be undone,
so it is gated on the compatibility check above.
</p>
</div>
</details>
<div class="modal-actions">
<button class="btn-ghost" @click="closeRestore">Cancel</button>
<button
class="btn-danger"
:disabled="!restoreTarget.iscompatible || isBusy"
@click="confirmRestore"
>
Restore
</button>
</div>
</template>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { ShieldCheck, AlertTriangle, ShieldAlert, Clock } from 'lucide-vue-next'
import api from '../../api'
import { useToast } from '../../composables/toast'
const toast = useToast()
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;
// restorePhase drives the indeterminate spinner and label in the modal.
const restoreStatus = ref(null)
let statusTimer = null
let restoreStartedAt = 0
// The Flask-less backup sidecar loads the dump and parks the restore in
// 'upgrading', because it cannot run `flask db upgrade` itself. The api can, so
// the browser pokes the finalize endpoint once to roll the restored data up to
// the running code. Guarded so we fire it exactly once per restore run.
let finalizeRequested = false
const backups = ref([])
const restores = ref([])
// Human labels for the phase names the watcher scripts write. Anything we do
// not recognize falls back to the raw phase so a new phase still shows.
const RESTORE_PHASE_LABELS = {
queued: 'Queued...',
checking: 'Checking compatibility...',
files: 'Restoring files...',
database: 'Loading database...',
upgrading: 'Upgrading schema to code...',
done: 'Restore complete',
failed: 'Restore failed',
}
const restorePhase = computed(() => restoreStatus.value?.phase || null)
const isRestoreRunning = computed(() => {
const phase = restorePhase.value
return !!phase && phase !== 'done' && phase !== 'failed'
})
const restorePhaseLabel = computed(() => {
const phase = restorePhase.value
if (!phase) return ''
return RESTORE_PHASE_LABELS[phase] || phase
})
const settingsOpen = ref(false)
const settingsLoading = ref(true)
const savingSettings = ref(false)
const settings = ref({
destinationkind: 'local',
destinationkinds: ['local'],
fields: {},
})
const DESTINATION_LABELS = {
local: 'Local volume',
smb: 'SMB / CIFS share',
aws: 'AWS S3 bucket',
}
function destinationLabel(kind) {
return DESTINATION_LABELS[kind] || kind
}
async function loadSettings() {
settingsLoading.value = true
try {
const response = await api.get('/shopdbbackups/settings')
const data = response.data.data || {}
settings.value = {
destinationkind: data.destinationkind || 'local',
destinationkinds: data.destinationkinds || ['local'],
fields: data.fields || {},
}
} catch (error) {
toast.error('Could not load backup destination.')
} finally {
settingsLoading.value = false
}
}
async function saveSettings() {
savingSettings.value = true
try {
const response = await api.put('/shopdbbackups/settings', {
destinationkind: settings.value.destinationkind,
fields: settings.value.fields,
})
const data = response.data.data || {}
settings.value = {
destinationkind: data.destinationkind || 'local',
destinationkinds: data.destinationkinds || ['local'],
fields: data.fields || {},
}
toast.success('Backup destination saved.')
} catch (error) {
const message = error.response?.data?.data?.error?.message
toast.error(message || 'Could not save the backup destination.')
} finally {
savingSettings.value = false
}
}
async function load() {
loading.value = true
try {
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 {
loading.value = false
}
}
async function runBackup() {
isBusy.value = true
try {
const response = await api.post('/shopdbbackups')
toast.success(response.data.message || 'Backup queued.')
} catch (error) {
toast.error('Could not queue a backup.')
} finally {
isBusy.value = false
load()
}
}
function openRestore(set) {
restoreTarget.value = set
}
function closeRestore() {
stopStatusPolling()
restoreStatus.value = null
restoreTarget.value = null
}
async function confirmRestore() {
if (!restoreTarget.value?.iscompatible) return
isBusy.value = true
try {
await api.post(`/shopdbbackups/${restoreTarget.value.snapshotid}/restore`)
toast.success('Restore queued and validated against the live schema.')
// Keep the modal open and switch it to progress mode; polling drives the
// spinner through each phase until the watcher reports done or failed.
startStatusPolling()
} catch (error) {
const message = error.response?.data?.data?.error?.message
toast.error(message || 'Could not queue the restore.')
} finally {
isBusy.value = false
}
}
async function loadHistory() {
try {
const response = await api.get('/shopdbbackups/history')
const data = response.data.data || {}
backups.value = data.backups || []
restores.value = data.restores || []
} catch (error) {
// History is non-critical; a failure here should not disrupt the page.
}
}
async function pollStatus() {
let status
try {
const response = await api.get('/shopdbbackups/status')
status = response.data.data?.restore || null
} catch (error) {
return
}
// Ignore a terminal status left over from a previous restore: if it finished
// before we started this one, it is stale and would close the modal instantly
// before the spinner ever paints. Wait for a fresh status from this run.
if (status && (status.phase === 'done' || status.phase === 'failed')) {
const finishedAt = new Date(normalizeStamp(status.updatedat || '')).getTime()
if (Number.isNaN(finishedAt) || finishedAt < restoreStartedAt) {
return
}
}
restoreStatus.value = status
const phase = status?.phase
if (phase === 'upgrading' && !finalizeRequested) {
// Hand off to the api container, which owns the Flask CLI, to run the
// post-load schema upgrade. Fire once; polling keeps tracking until it
// reports done or failed.
finalizeRequested = true
try {
await api.post('/shopdbbackups/finalize-restore')
} catch (error) {
// The finalize endpoint stamps its own failed status on error, which the
// next poll surfaces; nothing extra to do here.
}
}
if (phase === 'done' || phase === 'failed') {
stopStatusPolling()
if (phase === 'done') {
toast.success('Restore complete.')
closeRestore()
} else {
toast.error(status?.message || 'Restore failed.')
}
load()
loadHistory()
}
}
function startStatusPolling() {
stopStatusPolling()
restoreStartedAt = Date.now()
finalizeRequested = false
restoreStatus.value = { phase: 'queued', message: 'Restore queued' }
// Poll on an interval only; skip the immediate call so the seeded "queued"
// phase is guaranteed to paint before the first real status can replace it.
statusTimer = setInterval(pollStatus, 1000)
}
function stopStatusPolling() {
if (statusTimer) {
clearInterval(statusTimer)
statusTimer = null
}
}
// -- Confidence banner state ------------------------------------------------
const STALE_HOURS = 48
const hoursSinceLastGood = computed(() => {
if (!lastGoodAt.value) return null
const then = new Date(normalizeStamp(lastGoodAt.value)).getTime()
return (Date.now() - then) / 36e5
})
const bannerState = computed(() => {
if (!lastGoodAt.value) return 'none'
if (hoursSinceLastGood.value > STALE_HOURS) return 'stale'
return 'good'
})
const bannerClass = computed(() => `banner-${bannerState.value}`)
const bannerIcon = computed(() => ({
good: ShieldCheck, stale: Clock, none: ShieldAlert,
}[bannerState.value]))
const bannerHeadline = computed(() => ({
good: 'Your data is protected',
stale: 'Backup is overdue',
none: 'No verified backup yet',
}[bannerState.value]))
const bannerSub = computed(() => {
if (bannerState.value === 'none') return 'Run one now - it only takes a moment.'
return `Last verified backup ${formatTaken(lastGoodAt.value)}.`
})
// -- Formatting -------------------------------------------------------------
function formatTaken(value) {
if (!value) return 'unknown'
const date = new Date(normalizeStamp(value))
if (Number.isNaN(date.getTime())) return value
// Stamps are UTC; render them in UTC so the displayed time matches the
// Zulu-stamped set id rather than shifting to the operator's local zone.
return date.toLocaleString(undefined, { timeZone: 'UTC', timeZoneName: 'short' })
}
function outcomeClass(outcome) {
return outcome === 'success' ? 'outcome-success' : 'outcome-failed'
}
function outcomeLabel(outcome) {
return outcome === 'success' ? 'Success' : 'Failed'
}
// Backup ids and manifest stamps use a filename-safe UTC form with no colons
// (YYYY-MM-DDTHHMMSSZ, sometimes prefixed with a site name), which the Date
// parser rejects. Reinsert the colons so it parses as real ISO 8601.
function normalizeStamp(value) {
const match = String(value).match(/(\d{4}-\d{2}-\d{2})T(\d{2})(\d{2})(\d{2})Z$/)
if (!match) return value
const [, day, hour, minute, second] = match
return `${day}T${hour}:${minute}:${second}Z`
}
function formatSize(bytes) {
if (!bytes) return '0 B'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
let value = bytes
let unit = 0
while (value >= 1024 && unit < units.length - 1) {
value /= 1024
unit += 1
}
return `${value.toFixed(value < 10 && unit > 0 ? 1 : 0)} ${units[unit]}`
}
function statusClass(set) {
if (set.ischeckpassed) return 'chip-verified'
if (!set.hasmanifest) return 'chip-unverified'
return 'chip-pending'
}
function statusLabel(set) {
if (set.ischeckpassed) return 'Verified'
if (!set.hasmanifest) return 'Unverified'
return 'Not checked'
}
onMounted(() => {
load()
loadSettings()
loadHistory()
})
onUnmounted(() => {
stopStatusPolling()
})
</script>
<style scoped>
.page-header {
margin-bottom: 1rem;
}
.confidence-banner {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem 1.1rem;
border-radius: 10px;
border: 1px solid var(--border);
background: var(--bg-card);
margin-bottom: 1rem;
}
.confidence-banner .banner-text {
display: flex;
flex-direction: column;
flex: 1;
}
.banner-sub {
color: var(--text-light);
font-size: 0.85rem;
}
.banner-good { border-color: #2f855a; }
.banner-good .banner-icon { color: #38a169; }
.banner-stale { border-color: #b7791f; }
.banner-stale .banner-icon { color: #d69e2e; }
.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);
border-radius: 8px;
background: var(--bg-card);
}
.explain.small { margin: 0.75rem 0; }
.explain summary {
cursor: pointer;
padding: 0.6rem 0.9rem;
font-size: 0.86rem;
color: var(--text-light);
user-select: none;
}
.explain-body {
padding: 0 0.9rem 0.8rem 0.9rem;
}
.explain-body p {
margin: 0 0 0.6rem 0;
color: var(--text-light);
font-size: 0.86rem;
line-height: 1.5;
}
.backups-table {
width: 100%;
border-collapse: collapse;
}
.backups-table th,
.backups-table td {
text-align: left;
padding: 0.6rem 0.7rem;
border-bottom: 1px solid var(--border);
font-size: 0.88rem;
}
.backups-table th {
color: var(--text-light);
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.taken { display: block; color: var(--text); }
.setid { display: block; color: var(--text-light); font-size: 0.74rem; }
.chip {
display: inline-block;
padding: 0.15rem 0.55rem;
border-radius: 999px;
font-size: 0.74rem;
font-weight: 600;
}
.chip-verified { background: rgba(56, 161, 105, 0.18); color: #48bb78; }
.chip-pending { background: rgba(214, 158, 46, 0.18); color: #ecc94b; }
.chip-unverified { background: rgba(229, 62, 62, 0.18); color: #fc8181; }
.btn-primary {
background: var(--primary);
color: #fff;
border: none;
border-radius: 6px;
padding: 0.5rem 0.9rem;
cursor: pointer;
font-weight: 600;
}
.btn-primary:disabled { opacity: 0.6; cursor: default; }
.btn-restore {
background: transparent;
border: 1px solid var(--border);
color: var(--text);
border-radius: 6px;
padding: 0.35rem 0.7rem;
cursor: pointer;
}
.btn-restore.blocked { opacity: 0.55; }
.empty-state, .muted {
color: var(--text-light);
padding: 1rem 0;
}
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
display: flex;
align-items: center;
justify-content: center;
z-index: 50;
}
.modal {
width: min(460px, 92vw);
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 10px;
padding: 1.2rem;
}
.modal h3 { margin: 0 0 0.3rem 0; }
.modal-set { color: var(--text-light); font-size: 0.8rem; margin: 0 0 0.9rem 0; }
.compat {
display: flex;
align-items: flex-start;
gap: 0.5rem;
padding: 0.7rem 0.8rem;
border-radius: 8px;
font-size: 0.85rem;
line-height: 1.4;
}
.compat.ok { background: rgba(56, 161, 105, 0.15); color: #9ae6b4; }
.compat.warn { background: rgba(214, 158, 46, 0.15); color: #f6e05e; }
.restore-progress {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.7rem 0.8rem;
border-radius: 8px;
background: rgba(56, 161, 105, 0.15);
color: #9ae6b4;
font-size: 0.9rem;
}
.restore-progress.failed { background: rgba(197, 48, 48, 0.15); color: #feb2b2; }
.restore-phase { font-weight: 600; }
.restore-hint {
color: var(--text-light);
font-size: 0.8rem;
margin: 0.5rem 0 0 0;
}
.spinner {
width: 16px;
height: 16px;
border: 2px solid rgba(154, 230, 180, 0.35);
border-top-color: #9ae6b4;
border-radius: 50%;
animation: spin 0.8s linear infinite;
flex-shrink: 0;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.history-cards {
display: flex;
flex-direction: column;
gap: 1rem;
margin-top: 1.5rem;
}
.history-card {
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg-card);
padding: 1rem 1.1rem;
}
.history-title {
margin: 0 0 0.75rem 0;
font-size: 0.95rem;
}
.history-list {
list-style: none;
margin: 0;
padding: 0;
}
.history-row {
display: flex;
align-items: center;
gap: 0.8rem;
padding: 0.5rem 0;
border-top: 1px solid var(--border);
font-size: 0.85rem;
}
.history-row:first-child { border-top: none; }
.history-outcome {
flex-shrink: 0;
padding: 0.1rem 0.5rem;
border-radius: 999px;
font-size: 0.72rem;
font-weight: 600;
}
.outcome-success { background: rgba(56, 161, 105, 0.15); color: #9ae6b4; }
.outcome-failed { background: rgba(197, 48, 48, 0.15); color: #feb2b2; }
.history-set {
flex: 1;
font-family: monospace;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.history-when { color: var(--text-light); flex-shrink: 0; }
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.6rem;
margin-top: 1rem;
}
.btn-ghost {
background: transparent;
border: 1px solid var(--border);
color: var(--text);
border-radius: 6px;
padding: 0.5rem 0.9rem;
cursor: pointer;
}
.btn-danger {
background: #c53030;
color: #fff;
border: none;
border-radius: 6px;
padding: 0.5rem 0.9rem;
cursor: pointer;
font-weight: 600;
}
.btn-danger:disabled { opacity: 0.5; cursor: default; }
</style>

View File

@@ -4,7 +4,7 @@
// Order: site identity + the reference-data catalogs users touch daily come
// first, then the platform/system groups (integrations, communication, search,
// plugins, access) cluster together at the end.
import { Factory, MapPin, Tag, Package, Droplets, Monitor, MonitorSmartphone, Laptop, Cog, Building, Globe, Link, History, FileText, Users, Puzzle, Bell, Network, Home, Wrench, Printer, Router, Palette, SlidersHorizontal, Contact, Mail, ShieldCheck, KeyRound, Fingerprint, Search, Key, Upload } 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 } from 'lucide-vue-next'
export const settingsGroups = [
{
@@ -113,6 +113,7 @@ export const settingsGroups = [
title: 'System',
cards: [
{ 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' },
],
},
{