diff --git a/frontend/src/__tests__/history-retry-button.test.ts b/frontend/src/__tests__/history-retry-button.test.ts index 7867ee71..7ed3495d 100644 --- a/frontend/src/__tests__/history-retry-button.test.ts +++ b/frontend/src/__tests__/history-retry-button.test.ts @@ -272,6 +272,49 @@ describe('History inline Retry button (issue #47)', () => { expect(hint?.getAttribute('title')).not.toContain('Operator action required'); }); + test('ops_hint row keeps its lineage links next to the hint (issue #1040)', async () => { + // A legacy row retried before the re-drive gate (#967) now carries the + // permanent hint; it must still link to its successor, and Retry stays hidden. + (getCurrentUser as jest.Mock).mockReturnValue(ADMIN_USER); + (api.getHistory as jest.Mock).mockResolvedValue({ + summary: {}, + purchases: [ + makeRow({ + purchase_id: 'r-legacy', + created_by_user_id: ADMIN_USER.id, + ops_hint: 'Azure savings plans have no provider-side duplicate guard', + redrive_unsafe: true, + retry_execution_id: 'succ-5678-aaaa', + retry_attempt_n: 2, + }), + ], + }); + + await loadHistory(); + const row = document.querySelector('#history-list tr[data-execution-id="r-legacy"]')!; + expect(row.querySelector('.history-ops-hint')?.textContent).toContain('Azure savings plans'); + const link = row.querySelector('a.history-retry-link'); + expect(link?.textContent).toContain('Retried as #succ-567'); + expect(link?.getAttribute('href')).toBe('#history?execution=succ-5678-aaaa'); + expect(row.querySelector('.history-retry-of')?.textContent).toContain('Retry #2'); + expect(row.querySelectorAll('.history-retry-btn')).toHaveLength(0); + }); + + test('ops_hint row without lineage renders the hint alone', async () => { + (getCurrentUser as jest.Mock).mockReturnValue(ADMIN_USER); + (api.getHistory as jest.Mock).mockResolvedValue({ + summary: {}, + purchases: [ + makeRow({ purchase_id: 'r-1', created_by_user_id: ADMIN_USER.id, ops_hint: 'Set FROM_EMAIL tfvar then retry' }), + ], + }); + + await loadHistory(); + const row = document.querySelector('#history-list tr[data-execution-id="r-1"]')!; + expect(row.querySelector('.history-ops-hint')).not.toBeNull(); + expect(row.querySelectorAll('.history-retry-link')).toHaveLength(0); + }); + test('threshold-reached row renders override button + sends force=true on accept', async () => { (getCurrentUser as jest.Mock).mockReturnValue(ADMIN_USER); (confirmDialog as jest.Mock).mockResolvedValue(true); diff --git a/frontend/src/history.ts b/frontend/src/history.ts index 10f1ebd7..abbb0aee 100644 --- a/frontend/src/history.ts +++ b/frontend/src/history.ts @@ -946,11 +946,37 @@ function renderPendingActionButtons(p: HistoryPurchase): string { // Decision tree (status-driven, mutually exclusive): // * pending|notified + any action → "Review in approval queue" link (issue // #183; Approve / Cancel live in the queue) -// * failed + ops_hint set → ⚠ ops-hint badge (issue #47, Q3 — no retry) +// * failed + ops_hint set → ⚠ ops-hint badge (issue #47, Q3 — no retry), +// plus any lineage link (issue #1040) // * failed + threshold reached → "Retried 5× — confirm to override" Retry button (Q2) // * failed + canRetry → standard ↻ Retry button // * any row with retry lineage → inline ↻ Retried as / ↻ Retry of link // * else → plan_name or "-" +// renderLineageLinks returns the inline retry-chain cross-links for a row: a +// row that was retried (retry_execution_id set) or is itself a retry +// (retry_attempt_n > 0) links to the other end of the chain so the user can +// navigate without scrolling. Both can be true simultaneously on a +// middle-of-chain row (failed_v2 was retried into v3 AND is itself a retry +// of v1). +function renderLineageLinks(p: HistoryPurchase): string[] { + const lineage: string[] = []; + if (p.retry_execution_id) { + lineage.push(`↻ Retried as #${escapeHtml(shortExecID(p.retry_execution_id))}`); + } + if ((p.retry_attempt_n ?? 0) > 0) { + // We don't carry a back-pointer field — a future enhancement + // could surface the predecessor's exec ID via the API. For now + // we render a static badge (no link target) so users at least + // see "this is a retry" provenance. + // Coerce to a number: the history response is cast, not validated + // (api.getHistory), so the TS number type is not a runtime guarantee + // and this value sits inside a title="..." attribute. + const attemptN = Number(p.retry_attempt_n) || 0; + lineage.push(`↻ Retry #${attemptN}`); + } + return lineage; +} + function renderActionCell(p: HistoryPurchase): string { // Pending → the Approval queue card is the one place to approve or cancel // (issue #183). Where the session has a pending action (or a 4-eyes reason @@ -970,7 +996,10 @@ function renderActionCell(p: HistoryPurchase): string { const title = p.redrive_unsafe ? 'Permanent: this execution can never be retried; check the account, then start a new purchase if needed' : 'Operator action required — Retry will not help until this is fixed'; - return `⚠ ${escapeHtml(p.ops_hint)}`; + // Keep the lineage links next to the hint (issue #1040): a legacy row + // retried before the re-drive gate (#967) now carries the permanent + // hint, and the operator still needs the link to its successor. + return [`⚠ ${escapeHtml(p.ops_hint)}`, ...renderLineageLinks(p)].join(' '); } if (canRetryFailedRow(p) && p.purchase_id) { const overThreshold = retryThresholdReached(p); @@ -980,26 +1009,7 @@ function renderActionCell(p: HistoryPurchase): string { } } - // Lineage links: a row that was retried (retry_execution_id set) or - // is itself a retry (retry_attempt_n > 0) gets an inline cross-link - // to the other end of the chain so the user can navigate without - // scrolling. Both can be true simultaneously on a middle-of-chain - // row (failed_v2 was retried into v3 AND is itself a retry of v1). - const lineage: string[] = []; - if (p.retry_execution_id) { - lineage.push(`↻ Retried as #${escapeHtml(shortExecID(p.retry_execution_id))}`); - } - if ((p.retry_attempt_n ?? 0) > 0) { - // We don't carry a back-pointer field — a future enhancement - // could surface the predecessor's exec ID via the API. For now - // we render a static badge (no link target) so users at least - // see "this is a retry" provenance. - // Coerce to a number: the history response is cast, not validated - // (api.getHistory), so the TS number type is not a runtime guarantee - // and this value sits inside a title="..." attribute. - const attemptN = Number(p.retry_attempt_n) || 0; - lineage.push(`↻ Retry #${attemptN}`); - } + const lineage = renderLineageLinks(p); // Build the trailing action buttons so they compose with lineage links // — a retry-descendant can also have an active listing that needs a // Cancel button, or be an Azure row still inside its revoke window.