Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions frontend/src/__tests__/history-retry-button.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLAnchorElement>('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);
Expand Down
54 changes: 32 additions & 22 deletions frontend/src/history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(`<a href="#history?execution=${encodeURIComponent(p.retry_execution_id)}" class="history-retry-link" title="Jump to the retry execution">↻ Retried as #${escapeHtml(shortExecID(p.retry_execution_id))}</a>`);
}
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(`<span class="history-retry-link history-retry-of" title="This row is retry #${attemptN} in its chain">↻ Retry #${attemptN}</span>`);
}
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
Expand All @@ -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 `<span class="history-ops-hint" title="${title}">⚠ ${escapeHtml(p.ops_hint)}</span>`;
// 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 [`<span class="history-ops-hint" title="${title}">⚠ ${escapeHtml(p.ops_hint)}</span>`, ...renderLineageLinks(p)].join(' ');
}
if (canRetryFailedRow(p) && p.purchase_id) {
const overThreshold = retryThresholdReached(p);
Expand All @@ -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(`<a href="#history?execution=${encodeURIComponent(p.retry_execution_id)}" class="history-retry-link" title="Jump to the retry execution">↻ Retried as #${escapeHtml(shortExecID(p.retry_execution_id))}</a>`);
}
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(`<span class="history-retry-link history-retry-of" title="This row is retry #${attemptN} in its chain">↻ Retry #${attemptN}</span>`);
}
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.
Expand Down
Loading