Import the wilm endpoint telemetry, PC Detail rework and backup overlay
Ports 37 of Benjamin Baker's commits from the wilm branch, mirrored from the
corporate GitHub at 7905917c. wilm rides the publication lineage and shares no
ancestor with wj, so this is a filtered tree patch rather than a merge:
94 files, and every path the publication export strips (docs/, tools/, mcp/,
CLAUDE.md, start-*.sh, .gitea/, the three excluded tests) is held back, because
a raw diff would delete each of them from this tree.
What comes across:
* Endpoint telemetry, phase 2. Physical disk health from two sources, NIC link
state, boot type, Defender health, processor/memory/chassis inventory, the
listening TCP set, mapped network drives and Windows Update history. Five
additive migrations, 0012 through 0016, off the 0011 head.
* Mapped drives are collected by a probe running in the interactive user's
session, because they do not exist in the agent's session 0. Two Windows
sources can disagree about one drive and the worse status wins, so a
half-broken share is never rendered as healthy.
* Windows events are reported once each, keyed on the channel's own record id,
instead of being re-sent whenever a report overlapped the last one.
* PC Detail rework: Details cards flow, the hand-written Network card gives way
to a full address history, Storage and disk health merge into one card with
two sections, Operator Reports and Corporate Tools get their own tabs.
* An opt-in restic backup overlay with a third container, restore gated on the
dump's own data revision rather than the code head, and backup/restore history.
* The agent's version is read from the binary's PE version rather than trusted
from the csproj, and the Fleet view gains an Agent Version column.
* The enqueue route now asks caller_holds instead of the owner's permissions,
which closes the scoped-token elevation on run_script, update_app and
system_restart.
Held back deliberately:
* agent/installer/install-shopdbagent.ps1 stays deleted, as upstream deleted it.
The installer that reaches a bay is generated by _install_script() in
plugins/endpoints/api/siteagent.py; the standalone script had stopped being
shipped and was collecting fixes no endpoint ever ran.
* The .gitignore hunk adding docs/*.md. Correct on the publication lineage,
where docs/ becomes the wiki and its absence is how the gates detect a
published checkout. Here it would silently ignore every new doc.
* Their deletions of docs/DEV-BUILD-TEST.md and docs/PLUGIN-HOOKS.md. Upstream
those were stubs; here PLUGIN-HOOKS.md is the 31KB document CLAUDE.md points
readers at.
* Five zero-byte files committed by an accidental `git add -A`, a run log from
the author's own machine, and docs/notes/, which is working scratch that says
so in its own header and belongs in devnotes/ on this side.
Carried with the import so it lands green: the 17 new routes are described in
docs/api-inventory.json with the spec and project map regenerated, and two new
frontend specs read fixtures through fileURLToPath(URL) which node rejects under
jsdom's own URL global.
Suite 2591 passed / 9 skipped; frontend 391 passed; naming, style and doc gates
clean.
Scope: product
This commit is contained in:
@@ -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 {
|
||||
|
||||
51
frontend/src/components/emptyTablePanel.spec.js
Normal file
51
frontend/src/components/emptyTablePanel.spec.js
Normal 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\)/)
|
||||
})
|
||||
})
|
||||
124
frontend/src/components/panelGroups.spec.js
Normal file
124
frontend/src/components/panelGroups.spec.js
Normal 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"')
|
||||
})
|
||||
})
|
||||
211
frontend/src/components/pinnedPanels.spec.js
Normal file
211
frontend/src/components/pinnedPanels.spec.js
Normal 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'")
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
99
frontend/src/components/tabSectionsTable.spec.js
Normal file
99
frontend/src/components/tabSectionsTable.spec.js
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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.
|
||||
|
||||
119
frontend/src/utils/siteSettings.spec.js
Normal file
119
frontend/src/utils/siteSettings.spec.js
Normal 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)
|
||||
})
|
||||
})
|
||||
864
frontend/src/views/settings/ShopdbBackupsList.vue
Normal file
864
frontend/src/views/settings/ShopdbBackupsList.vue
Normal file
@@ -0,0 +1,864 @@
|
||||
<template>
|
||||
<div class="backups-page">
|
||||
<div class="page-header">
|
||||
<h2>Backups</h2>
|
||||
</div>
|
||||
|
||||
<!-- Confidence banner: the single most reassuring thing an admin wants to
|
||||
see - is there a recent, verified backup? Everything else is secondary. -->
|
||||
<div 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)
|
||||
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
|
||||
} 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; }
|
||||
|
||||
.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>
|
||||
@@ -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' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user