mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-24 12:42:49 +00:00
Merge pull request #2156 from rcourtman/maintainer/20260922T112213Z
Keep PBS datastore History attached during refresh
This commit is contained in:
@@ -4,6 +4,7 @@
|
||||
// treated as ambiguous, so the PBS row kept its service metrics target and the
|
||||
// History tab showed "Collecting history" for a host that has history.
|
||||
// Synthetic props only; the check script intercepts the metrics-history request.
|
||||
import { createSignal } from 'solid-js';
|
||||
import { render } from 'solid-js/web';
|
||||
|
||||
import { ProxmoxBackupServersTable } from '../src/features/proxmox/ProxmoxBackupServersTable';
|
||||
@@ -30,13 +31,16 @@ const pbs = {
|
||||
hostname: 'proxback-vm',
|
||||
version: '3.2.1',
|
||||
connectionHealth: 'healthy',
|
||||
datastores: [{ name: 'tank', total: 1000, used: 400, available: 600, usagePercent: 40 }],
|
||||
datastores: [
|
||||
{ name: 'tank', total: 1000, used: 400, available: 600, usagePercent: 40 },
|
||||
{ name: 'archive', total: 2000, used: 600, available: 1400, usagePercent: 30 },
|
||||
],
|
||||
},
|
||||
// The PBS service target: correct for the service, but it has no host series.
|
||||
metricsTarget: { resourceType: 'agent', resourceId: 'pbs-1' },
|
||||
platformData: {
|
||||
sources: ['pbs'],
|
||||
pbs: { instanceId: 'proxback', hostname: 'proxback-vm', datastoreCount: 1 },
|
||||
pbs: { instanceId: 'proxback', hostname: 'proxback-vm', datastoreCount: 2 },
|
||||
},
|
||||
} as unknown as Resource;
|
||||
|
||||
@@ -74,7 +78,29 @@ const standalone = {
|
||||
platformData: { sources: ['agent', 'pbs'], agent: sharedAgent },
|
||||
} as unknown as Resource;
|
||||
|
||||
render(
|
||||
() => <ProxmoxBackupServersTable servers={[pbs, guest, standalone]} />,
|
||||
document.getElementById('root') as HTMLElement,
|
||||
);
|
||||
const Fixture = () => {
|
||||
const [servers, setServers] = createSignal(structuredClone([pbs, guest, standalone]));
|
||||
let snapshot = 0;
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() => {
|
||||
snapshot += 1;
|
||||
const next = structuredClone([pbs, guest, standalone]);
|
||||
next[1].cpu = { current: 15.4 + snapshot };
|
||||
const tank = next[0].pbs!.datastores!.find((store) => store.name === 'tank')!;
|
||||
tank.used = 400 + snapshot * 10;
|
||||
tank.available = 600 - snapshot * 10;
|
||||
tank.usagePercent = 40 + snapshot;
|
||||
if (snapshot % 2 === 1) next[0].pbs!.datastores!.reverse();
|
||||
setServers(next);
|
||||
}}
|
||||
>
|
||||
Refresh resource snapshot
|
||||
</button>
|
||||
<ProxmoxBackupServersTable servers={servers()} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
render(() => <Fixture />, document.getElementById('root') as HTMLElement);
|
||||
|
||||
@@ -1,57 +1,40 @@
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "76f96f990638652ed3d4af890bbcdaa8d47374aa",
|
||||
"verified_at": "2026-09-22T09:47:00Z",
|
||||
"base_sha": "014190ea4c630397e13088e3888bffb93012b8f3",
|
||||
"verified_at": "2026-09-22T11:10:32.326040Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx",
|
||||
"frontend-modern/src/utils/alertOverviewPresentation.ts"
|
||||
"frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx": "18535683c37f5b5a481ac012a49b7dcdd6264ac992076381c74fdfbb037e31eb",
|
||||
"frontend-modern/src/utils/alertOverviewPresentation.ts": "7f68a70800deb0cb8214d4fd25c7454c5b0fde1084e08365eb93bc361b80846a"
|
||||
"frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx": "b463452e00e796a0d9ff3639f21ff7ac0d7219539cfacf50f6101282d829f78a"
|
||||
},
|
||||
"routes": [
|
||||
"/browser-tests/alert-card-footer.html",
|
||||
"/proxmox/backups (production surface represented by the component fixture)",
|
||||
"/proxmox/backups (production component fixture)",
|
||||
"/browser-tests/pbs-host-history-correlation.html"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 2010,
|
||||
"height": 1072
|
||||
},
|
||||
{
|
||||
"width": 1280,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"width": 480,
|
||||
"height": 800
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
"height": 844
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"Alerts overview alert-card footer with the Started timestamp and the adjacent delivery-status run",
|
||||
"The Started run classed by getAlertOverviewStartedAtClass() inside the items-center footer flex row",
|
||||
"The Backups PBS server row renders for a host whose agent is surfaced as both a PVE guest and a standalone source=pbs host row",
|
||||
"Expanding the PBS row opens the canonical drawer; History is selectable and renders the host metric groups",
|
||||
"The PBS drawer's merged header shows the guest's current CPU/memory (15.4% / 16.9%) rather than the PBS service values",
|
||||
"The History request resolves to the guest series vm/proxmox:100; the PBS service target agent/pbs-1 is never requested",
|
||||
"Desktop and narrow layouts for both surfaces"
|
||||
"Two datastores on one PBS, with a single agent represented by guest and standalone rows",
|
||||
"History on tank, archive, then tank again, with populated synthetic CPU/memory/disk/network history",
|
||||
"Fresh resource snapshots with changing usage and alternating datastore array order"
|
||||
],
|
||||
"interactions": [
|
||||
"Mount the production footer markup from AlertOverviewAlertCard.tsx with the class returned by getAlertOverviewStartedAtClass() and the project CSS",
|
||||
"Measure the text bounding rects of the Started run and the status run and assert their top and bottom edges match within 1px",
|
||||
"Mount the production ProxmoxBackupServersTable with synthetic PBS server, PVE guest and standalone agent resources sharing one agent identity",
|
||||
"Focus the PBS row disclosure button and activate it with Enter, then select the History subtab",
|
||||
"Intercept /api/metrics-store/history requests and assert the requested resourceType/resourceId pair",
|
||||
"Repeat both surfaces at desktop and narrow widths and capture a screenshot of each rendered surface",
|
||||
"Record Chromium from the offline Playwright runtime with no uncaught page errors"
|
||||
"Keyboard-expand each datastore row and select History",
|
||||
"Check both datastore identities before each switch",
|
||||
"Refresh three snapshots at each viewport; assert updated usage, retained drawer DOM, selected History tab and seven chart paths",
|
||||
"Assert all six history requests use vm/proxmox:100, never the PBS service target",
|
||||
"Inspect desktop and mobile screenshots; no uncaught page errors"
|
||||
],
|
||||
"command": "pulse-worker-browser scripts/check-alert-card-footer.cjs; pulse-worker-browser scripts/check-pbs-host-history-correlation.cjs",
|
||||
"notes": "Two offline Playwright Chromium runs, each against the production component with the project CSS and synthetic props; no live Pulse, backend, WebSocket or credential path. #2119: the alert-card footer is an items-center flex row that already carries the top margin, so the child 'mt-1' pushed the Started run 2px below the delivery-status span; removing it aligns both runs (top 66 / bottom 82 at 2010x1072 and 480x800, delta 0). #1723: the fixture feeds synthetic PBS/guest/agent resources and answers /api/metrics-store/history with empty points, so the panels correctly show the collecting-history state; the assertion is the requested history target, vm/proxmox:100 (the guest series), never agent/pbs-1. No page errors and no failed responses. This receipt is content-addressed to the two changed frontend sources at this candidate base."
|
||||
"command": "pulse-worker-browser .pbs-proof/check.cjs (assigned workspace root; invokes scripts/check-pbs-host-history-correlation.cjs in the disposable source copy)",
|
||||
"notes": "Chromium 141.0.7390.37 / installed Playwright 1.56.1. Production source SHA-256 matched the assigned tree; package-lock SHA-256 87e579d28080ad1fb42992dd5b398c373aa08aff63bbf3f0ed63ac8b4a8f89a0, matching offline dependency snapshot dac6a9b347a1c76ad3bf0f5eadc17b84d468ce0d3878354e5d39e745101ccb53. Before the fix, a reversed datastore array overwrote the archive row to tank, reproduced in Chromium and the new component test. Isolating the row datastore object corrects it. Synthetic metrics/capability/AI responses, not live backend or reporter data. History reset to Summary was not reproduced, and this is not whole-issue resolution. Final screenshots visually inspected. Logs retained in web-product-h8pgc3la/pbs-browser-final.log; disposable copy retained in that output directory."
|
||||
}
|
||||
|
||||
@@ -279,7 +279,10 @@ export function buildBackupServerRows(
|
||||
rows.push({
|
||||
key: `${server.id}:${datastore.name}`,
|
||||
...host,
|
||||
datastore,
|
||||
// Row stores reconcile independently from the nested PBS snapshot. Do
|
||||
// not alias its datastore object: a reordered snapshot reconciles that
|
||||
// array by position and would overwrite this row's datastore identity.
|
||||
datastore: { ...datastore },
|
||||
backupCount: countFor(server, datastore.name),
|
||||
});
|
||||
}
|
||||
|
||||
+29
@@ -83,6 +83,35 @@ describe('ProxmoxBackupServersTable details', () => {
|
||||
expect(screen.getByTestId('pbs-resource-detail')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps each datastore identity and open drawer through reordered snapshots', async () => {
|
||||
const makeServers = (reverse = false) => {
|
||||
const pbs = makePbsResource();
|
||||
pbs.pbs!.datastores!.push({
|
||||
name: 'archive',
|
||||
total: 2000,
|
||||
used: 600,
|
||||
available: 1400,
|
||||
usagePercent: 30,
|
||||
});
|
||||
if (reverse) pbs.pbs!.datastores!.reverse();
|
||||
return [pbs];
|
||||
};
|
||||
const [servers, setServers] = createSignal(makeServers());
|
||||
const { container } = render(() => <ProxmoxBackupServersTable servers={servers()} />);
|
||||
const datastoreNames = () =>
|
||||
Array.from(container.querySelectorAll('td[title]')).map((cell) => cell.getAttribute('title'));
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'Expand details for pbs-main' })[1]);
|
||||
const detail = screen.getByTestId('pbs-resource-detail');
|
||||
expect(datastoreNames()).toEqual(['pbs-main · archive', 'pbs-main · tank']);
|
||||
|
||||
setServers(makeServers(true));
|
||||
await waitFor(() =>
|
||||
expect(datastoreNames()).toEqual(['pbs-main · archive', 'pbs-main · tank']),
|
||||
);
|
||||
expect(screen.getByTestId('pbs-resource-detail')).toBe(detail);
|
||||
expect(resourceDetailDrawerMount).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('opens the canonical resource drawer with merged host details expanded', () => {
|
||||
render(() => <ProxmoxBackupServersTable servers={[makePbsResource()]} />);
|
||||
|
||||
|
||||
@@ -2,16 +2,23 @@
|
||||
//
|
||||
// Mounts the production ProxmoxBackupServersTable with a PBS host whose agent
|
||||
// is present twice (PVE guest with agent telemetry, and a standalone source=pbs
|
||||
// host row). Expands the PBS row, opens History, and records the metrics
|
||||
// host row), plus two datastores. Switches between them, replaces/reorders
|
||||
// snapshots with fresh CPU telemetry, and checks chart paths and tab retention.
|
||||
// Records the metrics
|
||||
// history target the drawer requests. The correct target is the guest's vm
|
||||
// series; the PBS service target has no host history and leaves the tab on
|
||||
// "Collecting history". Run with:
|
||||
// pulse-worker-browser scripts/check-pbs-host-history-correlation.cjs
|
||||
const path = require('node:path');
|
||||
const assert = require('node:assert/strict');
|
||||
const { chromium } = require('playwright');
|
||||
|
||||
const ROOT = path.resolve(process.cwd(), 'frontend-modern');
|
||||
const launchOptions = { headless: true, channel: 'chromium', args: ['--no-sandbox'] };
|
||||
const launchOptions = {
|
||||
headless: true,
|
||||
channel: 'chromium',
|
||||
args: ['--no-sandbox'],
|
||||
};
|
||||
|
||||
(async () => {
|
||||
process.chdir(ROOT);
|
||||
@@ -39,6 +46,22 @@ const launchOptions = { headless: true, channel: 'chromium', args: ['--no-sandbo
|
||||
const page = await browser.newPage({ viewport });
|
||||
page.on('pageerror', (error) => pageErrors.push(`${viewport.width}px: ${error.message}`));
|
||||
|
||||
await page.route('**/api/license/runtime-capabilities', (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
capabilities: [],
|
||||
limits: [],
|
||||
hosted_mode: false,
|
||||
max_history_days: 7,
|
||||
runtime: { build: 'community', label: 'Pulse Community runtime' },
|
||||
blocked_capabilities: [],
|
||||
},
|
||||
}),
|
||||
);
|
||||
await page.route('**/api/settings/ai', (route) =>
|
||||
route.fulfill({ json: { enabled: false } }),
|
||||
);
|
||||
|
||||
await page.route('**/api/metrics-store/history?**', async (route) => {
|
||||
const query = new URL(route.request().url()).searchParams;
|
||||
targets.push(`${query.get('resourceType')}/${query.get('resourceId')}`);
|
||||
@@ -46,22 +69,96 @@ const launchOptions = { headless: true, channel: 'chromium', args: ['--no-sandbo
|
||||
json: {
|
||||
resourceType: query.get('resourceType'),
|
||||
resourceId: query.get('resourceId'),
|
||||
points: [],
|
||||
range: query.get('range'),
|
||||
start: Date.now() - 3600000,
|
||||
end: Date.now(),
|
||||
source: 'store',
|
||||
metrics: Object.fromEntries(
|
||||
['cpu', 'memory', 'disk', 'netin', 'netout', 'diskread', 'diskwrite'].map(
|
||||
(metric) => [
|
||||
metric,
|
||||
[30, 20, 10].map((minutes, i) => ({
|
||||
timestamp: Date.now() - minutes * 60000,
|
||||
value: 10 + i * 5,
|
||||
min: 10 + i * 5,
|
||||
max: 10 + i * 5,
|
||||
})),
|
||||
],
|
||||
),
|
||||
),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto('http://127.0.0.1:5198/browser-tests/pbs-host-history-correlation.html');
|
||||
const expand = page.getByRole('button', {
|
||||
name: 'Expand details for proxback',
|
||||
exact: true,
|
||||
const rows = page.locator('tr').filter({
|
||||
has: page.getByRole('button', {
|
||||
name: 'Expand details for proxback',
|
||||
exact: true,
|
||||
}),
|
||||
});
|
||||
await expand.waitFor({ timeout: 20000 });
|
||||
await expand.focus();
|
||||
await page.keyboard.press('Enter');
|
||||
await page.getByRole('tab', { name: 'History', exact: true }).click();
|
||||
// Give the history fetch a beat to fire.
|
||||
await page.waitForTimeout(750);
|
||||
await rows.first().waitFor({ timeout: 20000 });
|
||||
const openDatastore = async (name) => {
|
||||
const row = page
|
||||
.locator('tr')
|
||||
.filter({ has: page.locator(`td[title="proxback · ${name}"]`) });
|
||||
await row.getByRole('button').focus();
|
||||
await page.keyboard.press('Enter');
|
||||
await page.getByRole('tab', { name: 'History', exact: true }).click();
|
||||
await page.locator('[data-testid="guest-history-plot"] path').first().waitFor();
|
||||
assert.equal(
|
||||
await page
|
||||
.getByRole('tab', { name: 'History', exact: true })
|
||||
.getAttribute('aria-selected'),
|
||||
'true',
|
||||
);
|
||||
};
|
||||
const observations = [];
|
||||
// Both datastore rows must use the same host series, including revisiting
|
||||
// the first row after its history component has been disposed.
|
||||
for (const [index, datastore] of ['tank', 'archive', 'tank'].entries()) {
|
||||
assert.deepEqual(
|
||||
await page
|
||||
.locator('td[title^="proxback · "]')
|
||||
.evaluateAll((nodes) => nodes.map((node) => node.getAttribute('title'))),
|
||||
['proxback · archive', 'proxback · tank'],
|
||||
);
|
||||
await openDatastore(datastore);
|
||||
const detail = page.locator('[data-inline-platform-resource-detail-for="pbs-1"]');
|
||||
await detail.evaluate((node) => {
|
||||
node.dataset.proofIdentity = 'retained';
|
||||
});
|
||||
const pathsBefore = await detail.locator('[data-testid="guest-history-plot"] path').count();
|
||||
assert.ok(pathsBefore > 0);
|
||||
await page.getByRole('button', { name: 'Refresh resource snapshot' }).click();
|
||||
// Datastore usage is visible at both widths and proves that the fresh
|
||||
// snapshot reached the UI (the CPU column is hidden on narrow screens).
|
||||
await page
|
||||
.getByText(`${(41 + index).toFixed(1)}%`, { exact: true })
|
||||
.first()
|
||||
.waitFor();
|
||||
assert.equal(
|
||||
await detail.getAttribute('data-proof-identity'),
|
||||
'retained',
|
||||
'drawer remounted',
|
||||
);
|
||||
assert.equal(
|
||||
await page
|
||||
.getByRole('tab', { name: 'History', exact: true })
|
||||
.getAttribute('aria-selected'),
|
||||
'true',
|
||||
'history reset after snapshot',
|
||||
);
|
||||
assert.equal(
|
||||
await detail.locator('[data-testid="guest-history-plot"] path').count(),
|
||||
pathsBefore,
|
||||
);
|
||||
observations.push({
|
||||
datastore,
|
||||
paths: pathsBefore,
|
||||
refreshPreservedHistory: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (!targets.includes(expected)) {
|
||||
throw new Error(
|
||||
@@ -77,7 +174,7 @@ const launchOptions = { headless: true, channel: 'chromium', args: ['--no-sandbo
|
||||
await page.screenshot({
|
||||
path: path.join(ROOT, 'browser-tests', `pbs-host-history-${viewport.width}.png`),
|
||||
});
|
||||
results.push({ viewport, targets });
|
||||
results.push({ viewport, targets, observations });
|
||||
await page.close();
|
||||
}
|
||||
|
||||
@@ -85,7 +182,9 @@ const launchOptions = { headless: true, channel: 'chromium', args: ['--no-sandbo
|
||||
throw new Error(`page errors: ${pageErrors.join(' | ')}`);
|
||||
}
|
||||
|
||||
console.log(JSON.stringify({ result: 'passed', expected, results }, null, 2));
|
||||
console.log(
|
||||
JSON.stringify({ result: 'passed', browser: browser.version(), expected, results }, null, 2),
|
||||
);
|
||||
} finally {
|
||||
if (browser) await browser.close();
|
||||
await server.close();
|
||||
|
||||
Reference in New Issue
Block a user