mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-27 14:07:31 +00:00
Drop the unread points response meta from the recovery decode
After the useRecoveryPoints trim in #2773 the TrueNAS Protection tab reads only the points. The decode still carried the endpoint's pagination meta (page, limit, total, totalPages) through normalizeRecoveryMeta, the meta field on both points response types, the hook's initialValue and several test fixtures, none of which anything reads. A whole-program checker scan over src and tests (positive control: the data member, read in the hook and a test; negative control: eight planted readers all found) showed zero reads of the normalized or transport meta. The one read of the transport meta was the decode itself, and the four RecoveryResponseMeta members were read only inside normalizeRecoveryMeta. The response is never spread, serialized or iterated, and the query layer treats the value as opaque. Remove the decode, the meta fields on RecoveryPointsResponse and RecoveryPointsTransportResponse, the initialValue meta and the meta fixtures. RecoveryResponseMeta stays: the protection postures response type still declares it. recoveryTransport.test.ts pins the new shape: the response types' keys are exactly data (a type-level pin that fails under tsc), and a backend-shaped response that includes meta decodes to the points alone. The backend response and internal/api/recovery_handlers.go are unchanged, and the contract prose now says the frontend decode carries the points alone. A before/after production build pair differs in one chunk (the TrueNAS page chunk, 387 bytes smaller) and only by the removed statements; the CSS and the other 93 files are identical apart from chunk hashes. The TrueNAS Protection tab shows the same rows, filters, search results and detail panel at desktop and phone width, and the backend still sends meta in every response. Coverage of the hook and the decode loses nothing but the removed meta statements.
This commit is contained in:
@@ -4985,9 +4985,18 @@ that fetched the unfiltered first page had no reader once the aggregate Recovery
|
||||
page was deleted, and are gone. A surface that needs one of those filters adds
|
||||
it to the query type together with its caller, and sends a linked-resource
|
||||
filter as `itemResourceId`, never `subjectResourceId`.
|
||||
The decode of that response follows the same rule:
|
||||
`normalizeRecoveryPointsResponse` and the `RecoveryPointsResponse` and
|
||||
`RecoveryPointsTransportResponse` types in
|
||||
`frontend-modern/src/types/recovery.ts` carry the points alone. The endpoint
|
||||
still returns its pagination meta (`page`, `limit`, `total`, `totalPages`) and
|
||||
that backend contract is unchanged, but no frontend surface reads it, so a
|
||||
surface that pages the points adds the meta back to the response types together
|
||||
with its reader.
|
||||
`frontend-modern/src/api/__tests__/recoveryTransport.test.ts` pins the query
|
||||
keys and the result members at the type level and the three parameters that
|
||||
reach the wire at runtime.
|
||||
keys, the result members and the response keys at the type level, and at
|
||||
runtime the three parameters that reach the wire and that a backend response's
|
||||
meta does not survive the decode.
|
||||
The retired dashboard recovery and storage entry points must stay removed:
|
||||
`useDashboardRecovery`, `DashboardRecoveryStatusPanel`,
|
||||
`DashboardStoragePanel`, dashboard storage/recovery presentation helpers, and
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "d196f1cb518e81b771668dd7327808867c235511",
|
||||
"verified_at": "2026-10-10T09:08:59Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/hooks/useRecoveryPoints.ts",
|
||||
"frontend-modern/src/types/recovery.ts",
|
||||
"frontend-modern/src/utils/recoveryPlatformModel.ts"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/hooks/useRecoveryPoints.ts": "63904218b7726faa7e7b883f06054fcc307decb3971470d813837115ad620c07",
|
||||
"frontend-modern/src/types/recovery.ts": "bdafd86ef62522cb1d23dc55c66cce848a33f4b8b76c94d8f826e42eafc734cc",
|
||||
"frontend-modern/src/utils/recoveryPlatformModel.ts": "3512b2210726228d7b2fb9f24a60a3dc96046da3cbe114991b0ed1a78e6c18b0"
|
||||
},
|
||||
"routes": [
|
||||
"/truenas/overview",
|
||||
"/truenas/protection"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1440,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
"height": 844
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"Before/after pair on pulse-dev: production vite builds of the parent tree (the PR 2773 head 4511a3ded0) and of the same tree with this change, each served with vite preview proxied to the 50-node mock rig on :7699 (which includes the TrueNAS recovery fixtures), driven by the same headless Chromium script at 1440x900 and 390x844. Two probe runs per build. Main has moved since (to d196f1cb51); none of its commits touches a file this change touches, and the files that mention the points types or endpoint are the same set as in the verified tree.",
|
||||
"Off the Protection tab: /truenas/overview makes no /api/recovery/points request in any run of either build, so the hook still sees a null query there. Opening Protection requests /api/recovery/points?page=1&limit=200&platform=truenas twice (the open-time duplicate that both builds show), the 33 s window on the tab adds one poll request, and leaving to Overview adds none.",
|
||||
"TrueNAS Protection: 10 rows in every run of both builds at both widths, named by dataset (archive/backups, tank/apps, vault/compliance, tank/photos, tank/media, fast/vm-images), status filter All 10, Attention 1, Healthy 8, Warning 0, Failed 1, Running 1, Unknown 0 (the filter is hidden at phone width in both builds); searching \"backups\" narrows to the same three rows; the first row opens the identical Protection detail panel (Kind Replication, Outcome Failed, Dataset archive/backups, Host truenas-main, Replication and Target sections) apart from the mock clock minute. Every deterministic measurement (row count, labels, status filter, search token and result, detail text with the clock masked, open-time requests, the off-tab request list) has exactly one distinct value across the four runs at each width.",
|
||||
"The backend response is unchanged and the page ignores it: every /api/recovery/points response body in both builds has the top-level keys data and meta, and meta carries limit, page, total and totalPages, with 10 rows.",
|
||||
"Timing-dependent counts differ within a build as much as between builds: the duplicate requests on returning to the tab are 3 and 1 on desktop in the before build and 1 and 1 in the after build (phone 1 in both), and one before-build phone run saw no poll inside the 33 s window. Each build also has one run where a section-tab click timed out at 30 s under pulse-dev load of 45 to 60 (before: the Overview tab link never resolved at phone width; after: the Protection tab link click at desktop width); the other run of each build completed every step at both widths. No failing responses and no console errors in any run.",
|
||||
"Dist proof: 189 files each side; 94 byte-identical (the CSS included), 94 differ only in chunk-hash names and SRI strings, and 1 differs in code. After normalising hashes and short identifiers, TrueNAS-*.js (68278 to 67891 bytes) loses only the removed statements: the meta normalizer, the meta field of the decode and the meta member of the hook initial value; the two statements that replace them are the data-only decode and the data-only initial value.",
|
||||
"check-bundle-size.mjs passes on both builds with no chunk renamed and .bundlesize.json unchanged: TrueNAS 16.57 to 16.49 kB gzip, total JS 1372.50 to 1372.47 kB, total CSS 32.11 kB in both.",
|
||||
"Coverage preserved: vitest v8 coverage of the hook and the decode over the recovery suites, matched by normalised source text, loses nothing (LOST_TOTAL 0); the decode goes from 74 to 52 covered statements and branch arms with none uncovered either side, and the hook stays at 57 covered and 9 uncovered. 253 tests before, 252 after."
|
||||
],
|
||||
"interactions": [
|
||||
"Sign in with the rig login, load /truenas/overview and wait, click the Protection section tab, read the row labels, status filter options and row count, type \"backups\" into \"Search snapshots or replication\", read the narrowed rows, clear it, expand the first row and read the Protection detail panel; repeat at 390x844.",
|
||||
"Stay on Protection for 33 s and count /api/recovery/points requests, click the Overview section tab and wait 33 s, click Protection again and count requests and rows; repeat at 390x844. Record the top-level and meta keys of every /api/recovery/points response body.",
|
||||
"Run the same script twice against each of the before and after builds and compare the JSON results; screenshots of both viewports inspected."
|
||||
]
|
||||
}
|
||||
@@ -13,6 +13,8 @@ import type {
|
||||
RecoveryPoint,
|
||||
RecoveryPointDisplay,
|
||||
RecoveryPointDisplayTransport,
|
||||
RecoveryPointsResponse,
|
||||
RecoveryPointsTransportResponse,
|
||||
RecoveryPointTransport,
|
||||
} from '@/types/recovery';
|
||||
import { normalizeRecoveryPointsResponse } from '@/utils/recoveryPlatformModel';
|
||||
@@ -51,7 +53,6 @@ describe('recovery transport', () => {
|
||||
subjectRef: { type: 'truenas-dataset', name: 'tank/apps' },
|
||||
},
|
||||
],
|
||||
meta: { page: 1, limit: 100, total: 1, totalPages: 1 },
|
||||
}),
|
||||
).toEqual({
|
||||
data: [
|
||||
@@ -65,7 +66,6 @@ describe('recovery transport', () => {
|
||||
itemRef: { type: 'truenas-dataset', name: 'tank/apps' },
|
||||
},
|
||||
],
|
||||
meta: { page: 1, limit: 100, total: 1, totalPages: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -81,11 +81,39 @@ describe('recovery transport', () => {
|
||||
display: { subjectLabel: ' tank/apps ', subjectType: 'dataset' },
|
||||
},
|
||||
],
|
||||
meta: { page: 1, limit: 100, total: 1, totalPages: 1 },
|
||||
}).data;
|
||||
expect(point.display).toEqual({ itemLabel: 'tank/apps', itemType: 'dataset' });
|
||||
});
|
||||
|
||||
it('decodes only the points of a backend response and drops its pagination meta', () => {
|
||||
// The points handler still sends page, limit, total and totalPages next to
|
||||
// the data. No surface reads them, so the response type does not declare
|
||||
// them and the decode does not carry them.
|
||||
const backendResponse: RecoveryPointsTransportResponse & { meta: unknown } = {
|
||||
data: [
|
||||
{
|
||||
id: 'point-4',
|
||||
provider: 'truenas',
|
||||
kind: 'snapshot',
|
||||
mode: 'snapshot',
|
||||
outcome: 'success',
|
||||
},
|
||||
],
|
||||
meta: { page: 1, limit: 200, total: 1, totalPages: 1 },
|
||||
};
|
||||
expect(normalizeRecoveryPointsResponse(backendResponse)).toStrictEqual({
|
||||
data: [
|
||||
{
|
||||
id: 'point-4',
|
||||
platform: 'truenas',
|
||||
kind: 'snapshot',
|
||||
mode: 'snapshot',
|
||||
outcome: 'success',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('hands the TrueNAS Protection tab normalized recovery points', async () => {
|
||||
apiFetchJSONMock.mockResolvedValue({
|
||||
data: [
|
||||
@@ -99,7 +127,6 @@ describe('recovery transport', () => {
|
||||
display: { subjectLabel: 'tank/apps', subjectType: 'dataset' },
|
||||
},
|
||||
],
|
||||
meta: { page: 1, limit: 200, total: 1, totalPages: 1 },
|
||||
});
|
||||
let dispose = () => {};
|
||||
const points = createRoot((rootDispose) => {
|
||||
@@ -128,10 +155,7 @@ describe('recovery transport', () => {
|
||||
it('asks /api/recovery/points only for the page, limit and platform it was given', async () => {
|
||||
// The points endpoint accepts more filters, but the only reader sends these
|
||||
// three. A caller that casts extra filters in must not get them on the wire.
|
||||
apiFetchJSONMock.mockResolvedValue({
|
||||
data: [],
|
||||
meta: { page: 2, limit: 200, total: 0, totalPages: 1 },
|
||||
});
|
||||
apiFetchJSONMock.mockResolvedValue({ data: [] });
|
||||
const smuggled = {
|
||||
platform: ' truenas ',
|
||||
page: 2.7,
|
||||
@@ -168,10 +192,7 @@ describe('recovery transport', () => {
|
||||
});
|
||||
|
||||
it('leaves the platform off a blank request and makes no request without a query', async () => {
|
||||
apiFetchJSONMock.mockResolvedValue({
|
||||
data: [],
|
||||
meta: { page: 1, limit: 200, total: 0, totalPages: 1 },
|
||||
});
|
||||
apiFetchJSONMock.mockResolvedValue({ data: [] });
|
||||
let dispose = () => {};
|
||||
createRoot((rootDispose) => {
|
||||
dispose = rootDispose;
|
||||
@@ -203,7 +224,6 @@ describe('recovery transport', () => {
|
||||
});
|
||||
apiFetchJSONMock.mockResolvedValue({
|
||||
data: [backendPoint('point-a', 'tank/zeta'), backendPoint('point-b', 'tank/alpha')],
|
||||
meta: { page: 1, limit: 200, total: 2, totalPages: 1 },
|
||||
});
|
||||
let dispose = () => {};
|
||||
const points = createRoot((rootDispose) => {
|
||||
@@ -239,12 +259,15 @@ describe('recovery transport', () => {
|
||||
// Enforced by the frontend type check, not at runtime. The tab sends
|
||||
// platform, page and limit and reads the points, the loading and error
|
||||
// state and a refetch; the aggregate page's other filters and its meta and
|
||||
// resolvedOnce members went with it.
|
||||
// resolvedOnce members went with it, and the decoded response keeps the
|
||||
// points alone: the endpoint's pagination meta has no reader.
|
||||
expectTypeOf<keyof RecoveryPointsQuery>().toEqualTypeOf<'page' | 'limit' | 'platform'>();
|
||||
type Result = ReturnType<typeof useRecoveryPoints>;
|
||||
expectTypeOf<keyof Result>().toEqualTypeOf<'response' | 'points' | 'refetch'>();
|
||||
expectTypeOf<Result>().not.toHaveProperty('meta');
|
||||
expectTypeOf<Result>().not.toHaveProperty('resolvedOnce');
|
||||
expectTypeOf<keyof RecoveryPointsResponse>().toEqualTypeOf<'data'>();
|
||||
expectTypeOf<keyof RecoveryPointsTransportResponse>().toEqualTypeOf<'data'>();
|
||||
expectTypeOf<Parameters<typeof useRecoveryPoints>[0]>().toEqualTypeOf<
|
||||
Accessor<RecoveryPointsQuery | null | undefined>
|
||||
>();
|
||||
|
||||
@@ -84,10 +84,7 @@ export function useRecoveryPoints(query: Accessor<RecoveryPointsQuery | null | u
|
||||
source,
|
||||
cacheKey: (key) => `recovery-points:${key}`,
|
||||
fetcher: async (key) => fetchRecoveryPointsResponse(parseSerializedQuery(key)),
|
||||
initialValue: {
|
||||
data: [],
|
||||
meta: { page: 1, limit: DEFAULT_LIMIT, total: 0, totalPages: 1 },
|
||||
},
|
||||
initialValue: { data: [] },
|
||||
pollMs: REFRESH_MS,
|
||||
});
|
||||
|
||||
|
||||
@@ -85,12 +85,10 @@ export interface RecoveryResponseMeta {
|
||||
|
||||
export interface RecoveryPointsResponse {
|
||||
data: RecoveryPoint[];
|
||||
meta: RecoveryResponseMeta;
|
||||
}
|
||||
|
||||
export interface RecoveryPointsTransportResponse {
|
||||
data: RecoveryPointTransport[];
|
||||
meta: RecoveryResponseMeta;
|
||||
}
|
||||
|
||||
export type ProtectionState = 'protected' | 'attention' | 'unprotected' | 'unknown';
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
* Branch-coverage companion to recoveryPlatformModel.test.ts.
|
||||
*
|
||||
* The non-exported helpers (toTrimmedString, normalizeRecoveryDisplay,
|
||||
* getRecoveryItemResourceId, getRecoveryItemRef, normalizeRecoveryMeta) are
|
||||
* getRecoveryItemResourceId, getRecoveryItemRef) are
|
||||
* exercised indirectly through the exported normalizers, since they are
|
||||
* module-private. Each test below targets specific arms of the conditionals
|
||||
* documented in recoveryPlatformModel.ts.
|
||||
@@ -197,42 +197,14 @@ describe('recoveryPlatformModel.branchcov2', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeRecoveryMeta (via response normalizers)', () => {
|
||||
it('passes valid finite numeric meta through unchanged', () => {
|
||||
const meta = { page: 2, limit: 50, total: 7, totalPages: 1 };
|
||||
expect(normalizeRecoveryPointsResponse({ data: [], meta })).toStrictEqual({
|
||||
data: [],
|
||||
meta,
|
||||
});
|
||||
});
|
||||
|
||||
it('applies defaults when meta is null or undefined', () => {
|
||||
const defaults = { page: 1, limit: 0, total: 0, totalPages: 1 };
|
||||
expect(
|
||||
normalizeRecoveryPointsResponse({
|
||||
data: [],
|
||||
} as unknown as Parameters<typeof normalizeRecoveryPointsResponse>[0]),
|
||||
).toStrictEqual({ data: [], meta: defaults });
|
||||
expect(
|
||||
normalizeRecoveryPointsResponse({
|
||||
data: [],
|
||||
meta: null,
|
||||
} as unknown as Parameters<typeof normalizeRecoveryPointsResponse>[0]),
|
||||
).toStrictEqual({ data: [], meta: defaults });
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeRecoveryPointsResponse', () => {
|
||||
it('coerces non-finite and non-number meta fields to defaults and non-array data to []', () => {
|
||||
it('coerces non-array data to [] and carries no response-level meta', () => {
|
||||
expect(
|
||||
normalizeRecoveryPointsResponse({
|
||||
data: 'not-an-array',
|
||||
meta: { page: NaN, limit: Infinity, total: 'twenty', totalPages: undefined },
|
||||
} as unknown as Parameters<typeof normalizeRecoveryPointsResponse>[0]),
|
||||
).toStrictEqual({
|
||||
data: [],
|
||||
meta: { page: 1, limit: 0, total: 0, totalPages: 1 },
|
||||
});
|
||||
).toStrictEqual({ data: [] });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -74,7 +74,6 @@ describe('recoveryPlatformModel', () => {
|
||||
subjectRef: { type: 'truenas-dataset', name: 'tank/apps' },
|
||||
},
|
||||
],
|
||||
meta: { page: 1, limit: 100, total: 1, totalPages: 1 },
|
||||
}),
|
||||
).toEqual({
|
||||
data: [
|
||||
@@ -88,7 +87,6 @@ describe('recoveryPlatformModel', () => {
|
||||
itemRef: { type: 'truenas-dataset', name: 'tank/apps' },
|
||||
},
|
||||
],
|
||||
meta: { page: 1, limit: 100, total: 1, totalPages: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import type {
|
||||
RecoveryPointsResponse,
|
||||
RecoveryPointsTransportResponse,
|
||||
RecoveryPointTransport,
|
||||
RecoveryResponseMeta,
|
||||
} from '@/types/recovery';
|
||||
|
||||
const toTrimmedString = (value: unknown): string => (typeof value === 'string' ? value.trim() : '');
|
||||
@@ -59,22 +58,6 @@ export const getRecoveryPointPlatform = (
|
||||
point: RecoveryPointPlatformLike | null | undefined,
|
||||
): string => toTrimmedString(point?.platform) || toTrimmedString(point?.provider);
|
||||
|
||||
const normalizeRecoveryMeta = (
|
||||
meta: RecoveryResponseMeta | null | undefined,
|
||||
): RecoveryResponseMeta => {
|
||||
const page = typeof meta?.page === 'number' && Number.isFinite(meta.page) ? meta.page : 1;
|
||||
const limit = typeof meta?.limit === 'number' && Number.isFinite(meta.limit) ? meta.limit : 0;
|
||||
const total = typeof meta?.total === 'number' && Number.isFinite(meta.total) ? meta.total : 0;
|
||||
const totalPages =
|
||||
typeof meta?.totalPages === 'number' && Number.isFinite(meta.totalPages) ? meta.totalPages : 1;
|
||||
return {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages,
|
||||
};
|
||||
};
|
||||
|
||||
export const normalizeRecoveryPoint = (
|
||||
point: RecoveryPointTransport | RecoveryPoint,
|
||||
): RecoveryPoint => {
|
||||
@@ -102,5 +85,4 @@ export const normalizeRecoveryPointsResponse = (
|
||||
response: RecoveryPointsTransportResponse,
|
||||
): RecoveryPointsResponse => ({
|
||||
data: Array.isArray(response?.data) ? response.data.map(normalizeRecoveryPoint) : [],
|
||||
meta: normalizeRecoveryMeta(response?.meta),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user