Endpoint telemetry, PC Detail rework and a checked backup/restore path
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)
|
||||
})
|
||||
})
|
||||
891
frontend/src/views/settings/ShopdbBackupsList.vue
Normal file
891
frontend/src/views/settings/ShopdbBackupsList.vue
Normal 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>
|
||||
@@ -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