diff --git a/frontend/src/__tests__/css.test.ts b/frontend/src/__tests__/css.test.ts index 07cc6e8e..c0bc403b 100644 --- a/frontend/src/__tests__/css.test.ts +++ b/frontend/src/__tests__/css.test.ts @@ -91,10 +91,13 @@ describe('CSS Styles', () => { expect(css).toMatch(/\.provider-badge\.gcp/); }); - test('has status badge classes', () => { - expect(css).toMatch(/\.status-badge\.active/); - expect(css).toMatch(/\.status-badge\.paused/); - expect(css).toMatch(/\.status-badge\.disabled/); + test('has one badge component with a tone per status family (#737)', () => { + for (const tone of ['success', 'warning', 'danger', 'info', 'muted']) { + expect(componentsCss).toMatch(new RegExp(`\\.badge-${tone}\\s*\\{[^}]*background:\\s*var\\(--cudly-`)); + } + // The retired per-page variants must not come back. + expect(css).not.toMatch(/\.status-badge\b/); + expect(css).not.toMatch(/\.badge-(admin|user|readonly)\b/); }); test('has toggle switch styles', () => { @@ -389,7 +392,7 @@ describe('CSS File Organization', () => { expect(components).toMatch(/button\s*\{/); expect(components).toMatch(/button\.primary\s*\{/); expect(components).toMatch(/\.provider-badge/); - expect(components).toMatch(/\.status-badge/); + expect(components).toMatch(/\.badge\s*\{/); }); test('forms.css contains form elements', () => { diff --git a/frontend/src/__tests__/plans-range-validation.test.ts b/frontend/src/__tests__/plans-range-validation.test.ts index 049a064e..33646406 100644 --- a/frontend/src/__tests__/plans-range-validation.test.ts +++ b/frontend/src/__tests__/plans-range-validation.test.ts @@ -76,7 +76,7 @@ jest.mock('../utils', () => ({ formatDate: jest.fn((v) => v ?? ''), formatTerm: jest.fn((y) => `${y}yr`), formatRampSchedule: jest.fn((v) => v ?? ''), - getStatusBadge: jest.fn(() => ({ class: 'active', label: 'Active' })), + getStatusBadge: jest.fn(() => ({ class: 'success', label: 'Active' })), escapeHtml: jest.fn((s) => s ?? ''), escapeHtmlAttr: jest.fn((str: string | null | undefined) => { if (str == null) return ''; diff --git a/frontend/src/__tests__/plans.test.ts b/frontend/src/__tests__/plans.test.ts index b09adeed..ad918f6e 100644 --- a/frontend/src/__tests__/plans.test.ts +++ b/frontend/src/__tests__/plans.test.ts @@ -106,7 +106,7 @@ jest.mock('../utils', () => ({ formatDate: jest.fn((val) => val ? new Date(val).toLocaleDateString() : ''), formatTerm: jest.fn((years) => years == null ? '' : `${years} Year${years === 1 ? '' : 's'}`), formatRampSchedule: jest.fn((val) => val || 'Unknown'), - getStatusBadge: jest.fn(() => ({ class: 'active', label: 'Active' })), + getStatusBadge: jest.fn(() => ({ class: 'success', label: 'Active' })), escapeHtml: jest.fn((str) => str || ''), escapeHtmlAttr: jest.fn((str: string | null | undefined) => { if (!str) return ''; @@ -478,7 +478,9 @@ describe('Plans Module', () => { await loadPlans(); const list = document.getElementById('plans-list'); - expect(list?.innerHTML).toContain('status-badge'); + // getStatusBadge is mocked to the success tone; the card renders it as + // the shared .badge pill (issue #737). + expect(list?.querySelector('.plan-status .badge.badge-success')?.textContent).toBe('Active'); }); test('renders toggle switches for plans', async () => { @@ -859,7 +861,7 @@ describe('Plans Module', () => { // Paused rows stay visible with a Paused badge and are NOT replaced by // the empty-state message. expect(container?.innerHTML).toContain('status-paused'); - expect(container?.innerHTML).toContain('>paused<'); + expect(container?.querySelector('.badge.badge-muted')?.textContent).toBe('Paused'); expect(container?.innerHTML).not.toContain('No planned purchases'); }); @@ -3283,6 +3285,12 @@ describe('Plans Module', () => { ramp_schedule: { type: 'immediate', percent_per_step: 100, step_interval_days: 0 } }; + // The plan's own status badge (Active, success tone) shares the card, so + // assert on the health badge element rather than on the card's markup. + const healthBadge = (): Element | undefined => Array.from( + document.querySelectorAll('#plans-list .badge'), + ).find((b) => (b.textContent ?? '').startsWith('Health:')); + test('renders a green badge for a healthy score (>= 80)', async () => { (api.getPlans as jest.Mock).mockResolvedValue({ plans: [{ ...basePlan, health_score: 90, health_factors: [] }] @@ -3292,7 +3300,7 @@ describe('Plans Module', () => { await loadPlans(); const list = document.getElementById('plans-list'); - expect(list?.innerHTML).toContain('badge-success'); + expect(healthBadge()?.className).toBe('badge badge-success'); expect(list?.innerHTML).toContain('Health: 90'); }); @@ -3309,7 +3317,7 @@ describe('Plans Module', () => { await loadPlans(); const list = document.getElementById('plans-list'); - expect(list?.innerHTML).toContain('badge-warning'); + expect(healthBadge()?.className).toBe('badge badge-warning'); expect(list?.innerHTML).toContain('Health: 65'); }); @@ -3329,7 +3337,7 @@ describe('Plans Module', () => { await loadPlans(); const list = document.getElementById('plans-list'); - expect(list?.innerHTML).toContain('badge-danger'); + expect(healthBadge()?.className).toBe('badge badge-danger'); expect(list?.innerHTML).toContain('Health: 30'); }); @@ -3357,9 +3365,8 @@ describe('Plans Module', () => { const list = document.getElementById('plans-list'); expect(list?.innerHTML).toContain('Health: unknown'); - expect(list?.innerHTML).toContain('badge-muted'); // Critically, it must NOT be dressed up as a healthy score. - expect(list?.innerHTML).not.toContain('badge-success'); + expect(healthBadge()?.className).toBe('badge badge-muted'); expect(list?.innerHTML).not.toContain('Health: 100'); }); @@ -3380,7 +3387,7 @@ describe('Plans Module', () => { await loadPlans(); const list = document.getElementById('plans-list'); - expect(list?.innerHTML).toContain(expectedClass); + expect(healthBadge()?.className).toBe(`badge ${expectedClass}`); expect(list?.innerHTML).toContain(`Health: ${score}`); }); diff --git a/frontend/src/__tests__/riexchange-audit-321.test.ts b/frontend/src/__tests__/riexchange-audit-321.test.ts index 2e737ebf..62477dc3 100644 --- a/frontend/src/__tests__/riexchange-audit-321.test.ts +++ b/frontend/src/__tests__/riexchange-audit-321.test.ts @@ -105,7 +105,7 @@ describe('RI exchange history failure reason (A12-069)', () => { await loadRIExchange(); const cell = statusCell(); - expect(cell.querySelector('.status-badge')?.textContent).toBe('failed'); + expect(cell.querySelector('.badge.badge-danger')?.textContent).toBe('Failed'); const detail = cell.querySelector('.status-detail'); expect(detail?.textContent).toBe('AcceptReservedInstancesExchangeQuote: InvalidParameter'); expect(detail?.querySelector('b')).toBeNull(); diff --git a/frontend/src/__tests__/riexchange.test.ts b/frontend/src/__tests__/riexchange.test.ts index d352c4ec..671fe1af 100644 --- a/frontend/src/__tests__/riexchange.test.ts +++ b/frontend/src/__tests__/riexchange.test.ts @@ -1541,6 +1541,28 @@ describe('exchange history table counts (issue #286)', () => { expect(cells).toContain('1x m5.4xlarge'); expect(cells).not.toContain('4'); }); + + // Issue #737: the status cell used to print the raw lowercase value with a + // page-local color (pending blue, processing orange). It now uses the shared + // badge tones and display labels. + it.each([ + ['pending', 'badge-warning', 'Pending'], + ['processing', 'badge-warning', 'Processing'], + ['completed', 'badge-success', 'Completed'], + ['failed', 'badge-danger', 'Failed'], + ['canceled', 'badge-muted', 'Cancelled'], + ])('renders status %s as a %s badge reading %s', async (status, cls, label) => { + (api.getRIExchangeHistory as jest.Mock).mockResolvedValue([{ + id: 'h1', account_id: 'a', exchange_id: 'exch-1', region: 'us-east-1', source_ri_ids: ['ri-1'], + source_instance_type: 'm5.large', source_count: 1, target_offering_id: 'o', target_instance_type: 'm5.xlarge', + target_count: 1, payment_due: '1.00', status, mode: 'manual', + created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', + }]); + await loadRIExchange(); + const badge = document.querySelector('#ri-exchange-history-list tbody .badge')!; + expect(badge.className).toBe(`badge ${cls}`); + expect(badge.textContent).toBe(label); + }); }); // Issue #1181: RIs left out of reshape analysis must be visible in the UI. diff --git a/frontend/src/__tests__/status-badge.test.ts b/frontend/src/__tests__/status-badge.test.ts new file mode 100644 index 00000000..1a3f1bb3 --- /dev/null +++ b/frontend/src/__tests__/status-badge.test.ts @@ -0,0 +1,90 @@ +/** + * Issue #737: one status -> tone map and one label map for every badge. + */ +import { badgeClass, badgeHtml, statusBadgeHtml, statusLabel, statusTone } from '../lib/status-badge'; + +describe('statusTone', () => { + test.each([ + ['completed', 'success'], + ['approved', 'success'], + ['active', 'success'], + ['pending', 'warning'], + ['notified', 'warning'], + ['scheduled', 'warning'], + ['running', 'warning'], + ['processing', 'warning'], + ['in_progress', 'warning'], + ['partially_completed', 'warning'], + ['failed', 'danger'], + ['rejected', 'danger'], + ['canceled', 'muted'], + ['cancelled', 'muted'], + ['expired', 'muted'], + ['paused', 'muted'], + ['disabled', 'muted'], + ])('%s -> %s', (status, tone) => { + expect(statusTone(status)).toBe(tone); + }); + + test('pending and running share one tone on every page', () => { + // The bug: pending was blue in RI exchange and amber on Purchases; running + // was orange in one table and blue in another. + expect(statusTone('pending')).toBe(statusTone('notified')); + expect(statusTone('running')).toBe(statusTone('processing')); + }); + + test('is case- and whitespace-insensitive', () => { + expect(statusTone(' Completed ')).toBe('success'); + expect(statusTone('FAILED')).toBe('danger'); + }); + + test('unknown, empty and prototype-key values are muted, never success', () => { + expect(statusTone('something_new')).toBe('muted'); + expect(statusTone('')).toBe('muted'); + expect(statusTone(null)).toBe('muted'); + expect(statusTone(undefined)).toBe('muted'); + expect(statusTone('constructor')).toBe('muted'); + }); +}); + +describe('statusLabel', () => { + test.each([ + ['pending', 'Pending'], + ['processing', 'Processing'], + ['in_progress', 'In Progress'], + ['partially_completed', 'Partial'], + ['canceled', 'Cancelled'], + ['cancelled', 'Cancelled'], + ['PAUSED', 'Paused'], + ['awaiting_quote', 'Awaiting quote'], + ])('%s -> %s', (status, label) => { + expect(statusLabel(status)).toBe(label); + }); + + test('empty status reads Unknown', () => { + expect(statusLabel('')).toBe('Unknown'); + expect(statusLabel(null)).toBe('Unknown'); + }); +}); + +describe('badge markup', () => { + test('badgeClass builds the shared class list', () => { + expect(badgeClass('info')).toBe('badge badge-info'); + }); + + test('badgeHtml escapes the label and title', () => { + const html = badgeHtml('danger', 'x', 'a "quoted" title'); + expect(html).toBe('<b>x</b>'); + }); + + test('statusBadgeHtml combines tone and label', () => { + expect(statusBadgeHtml('in_progress')).toBe('In Progress'); + }); + + test('statusBadgeHtml neutralizes markup in an unknown status', () => { + const host = document.createElement('div'); + host.innerHTML = statusBadgeHtml(''); + expect(host.querySelector('img')).toBeNull(); + expect(host.querySelector('.badge')?.className).toBe('badge badge-muted'); + }); +}); diff --git a/frontend/src/__tests__/utils.test.ts b/frontend/src/__tests__/utils.test.ts index 572235f8..744cf88d 100644 --- a/frontend/src/__tests__/utils.test.ts +++ b/frontend/src/__tests__/utils.test.ts @@ -434,21 +434,21 @@ describe('formatRampSchedule', () => { }); describe('getStatusBadge', () => { - test('returns disabled for disabled items', () => { + test('returns the muted tone for disabled plans', () => { const result = getStatusBadge(false, true); - expect(result.class).toBe('disabled'); + expect(result.class).toBe('muted'); expect(result.label).toBe('Disabled'); }); - test('returns active for enabled + auto purchase', () => { + test('returns the success tone for enabled + auto purchase', () => { const result = getStatusBadge(true, true); - expect(result.class).toBe('active'); + expect(result.class).toBe('success'); expect(result.label).toBe('Active'); }); - test('returns paused for enabled without auto purchase', () => { + test('returns the info tone (not a warning) for enabled without auto purchase', () => { const result = getStatusBadge(true, false); - expect(result.class).toBe('paused'); + expect(result.class).toBe('info'); expect(result.label).toBe('Manual'); }); }); diff --git a/frontend/src/__tests__/xss-purchase-status.test.ts b/frontend/src/__tests__/xss-purchase-status.test.ts index cb19e76e..5597ba11 100644 --- a/frontend/src/__tests__/xss-purchase-status.test.ts +++ b/frontend/src/__tests__/xss-purchase-status.test.ts @@ -4,6 +4,7 @@ * * plans.ts:renderPlannedPurchaseRow used to emit: * ${purchase.status} + * (it now renders lib/status-badge.ts statusBadgeHtml, which escapes the label) * * An attacker-controlled status value such as `` * would be parsed as HTML and could execute arbitrary JavaScript. The fix @@ -167,12 +168,12 @@ describe('XSS regression: purchase.status rendered as text node (#445)', () => { // No element should have been injected as a real DOM node. expect(list!.querySelectorAll('img').length).toBe(0); // The payload literal appears only as escaped text, not as a live attribute. - const badge = list!.querySelector('.status-badge'); + const badge = list!.querySelector('.badge'); expect(badge).not.toBeNull(); expect(badge!.textContent).toContain('onerror'); }); - test('valid status "pending" renders as plain text inside the status badge', async () => { + test('valid status "pending" renders as its plain-text label inside the badge', async () => { (api.getPlannedPurchases as jest.Mock).mockResolvedValue({ purchases: [makePurchase('pending')], }); @@ -181,12 +182,12 @@ describe('XSS regression: purchase.status rendered as text node (#445)', () => { const list = document.getElementById('planned-purchases-list'); expect(list).not.toBeNull(); - const badge = list!.querySelector('.status-badge'); + const badge = list!.querySelector('.badge'); expect(badge).not.toBeNull(); - expect(badge!.textContent).toBe('pending'); + expect(badge!.textContent).toBe('Pending'); }); - test('valid status "paused" renders as plain text inside the status badge', async () => { + test('valid status "paused" renders as its plain-text label inside the badge', async () => { (api.getPlannedPurchases as jest.Mock).mockResolvedValue({ purchases: [makePurchase('paused')], }); @@ -195,8 +196,8 @@ describe('XSS regression: purchase.status rendered as text node (#445)', () => { const list = document.getElementById('planned-purchases-list'); expect(list).not.toBeNull(); - const badge = list!.querySelector('.status-badge'); + const badge = list!.querySelector('.badge'); expect(badge).not.toBeNull(); - expect(badge!.textContent).toBe('paused'); + expect(badge!.textContent).toBe('Paused'); }); }); diff --git a/frontend/src/history.ts b/frontend/src/history.ts index 01ee0e59..822b9d28 100644 --- a/frontend/src/history.ts +++ b/frontend/src/history.ts @@ -22,6 +22,7 @@ import { renderApprovalQueueLink, wireApprovalQueueLinks } from './history-queue import { getCurrentUser } from './state'; import { canAccess } from './permissions'; import { showSkeletonRows, teardownSkeleton } from './lib/skeleton'; +import { statusBadgeHtml } from './lib/status-badge'; import { localDayRangeToUtcDays, toLocalDateInputValue } from './lib/local-date'; import { getAccountName } from './recommendations'; import { applyColumnFilters } from './lib/column-filters'; @@ -562,22 +563,25 @@ function renderHistorySummary(summary: HistorySummary | null): void { `; } -function statusBadgeHTML(status: string): string { +// historyDisplayStatus folds the purchase lifecycle onto the statuses the +// Status column shows. Tone and text then come from lib/status-badge.ts, the +// same map every other page uses (issue #737). +function historyDisplayStatus(status: string): string { const normalized = (status || 'completed').toLowerCase(); switch (normalized) { case 'pending': case 'notified': - return 'Pending'; + return 'pending'; case 'approved': case 'running': case 'paused': // In-flight (issue #621): not finished — never show the green Completed // badge for these, or the user may think the purchase is done. - return 'In Progress'; + return 'in_progress'; case 'scheduled': // Issue #239: a delayed purchase whose provider call has not fired // yet — must never fall through to the default green Completed badge. - return 'Scheduled'; + return 'scheduled'; case 'canceled': case 'cancelled': // Migration 000089 (expand-contract rename): the backend may return @@ -587,21 +591,24 @@ function statusBadgeHTML(status: string): string { // badge instead of falling through to the green Completed default. // The CONTRACT migration (#1278) will normalize the data once the deploy // is stable; the British branch can be removed then. - return 'Cancelled'; + return 'cancelled'; case 'partially_completed': // #642: some commitments succeeded, some failed. Not a clean success // and never "failed" (real commitments exist) — a distinct warning badge // so the user knows to read the description and reconcile the failures. - return 'Partial'; + return 'partially_completed'; case 'failed': - return 'Failed'; case 'expired': - return 'Expired'; + return normalized; default: - return 'Completed'; + return 'completed'; } } +function statusBadgeHTML(status: string): string { + return statusBadgeHtml(historyDisplayStatus(status)); +} + function buildStatusChipRowHTML(purchases: HistoryPurchase[], active: StatusFilter): string { const counts: Record = { all: purchases.length, diff --git a/frontend/src/lib/status-badge.ts b/frontend/src/lib/status-badge.ts new file mode 100644 index 00000000..84c11454 --- /dev/null +++ b/frontend/src/lib/status-badge.ts @@ -0,0 +1,89 @@ +/** + * One status vocabulary for every badge in the app (issue #737). + * + * Before this module each page picked its own colors: "pending" was blue in + * RI exchange history and amber on Purchases, "running" was orange in one + * table and blue in another, and RI exchange printed the raw lowercase + * status. Every status badge now goes through statusTone() for its color and + * statusLabel() for its text, and renders as `.badge .badge-` + * (styles/components.css), so the same state reads the same everywhere: + * + * success finished well completed, approved, active + * warning not finished yet pending, notified, scheduled, running, + * processing, in progress, partial + * danger failed or refused failed, rejected + * muted inactive / final canceled, expired, paused, disabled + * info informational tags not a status; "You", "Self", "creds", ... + * + * An unknown status is muted rather than green: guessing "success" would tell + * the user something finished when we don't know that. + */ +import { escapeHtml } from '../utils'; + +export type BadgeTone = 'success' | 'warning' | 'danger' | 'info' | 'muted'; + +const STATUS_TONES: ReadonlyMap = new Map([ + ['completed', 'success'], + ['approved', 'success'], + ['active', 'success'], + ['pending', 'warning'], + ['notified', 'warning'], + ['scheduled', 'warning'], + ['running', 'warning'], + ['processing', 'warning'], + ['in_progress', 'warning'], + ['partially_completed', 'warning'], + ['failed', 'danger'], + ['rejected', 'danger'], + // Migration 000089 renames 'cancelled' to 'canceled'; rows may carry either + // spelling until the contract migration (#1278) lands. + ['canceled', 'muted'], + ['cancelled', 'muted'], + ['expired', 'muted'], + ['paused', 'muted'], + ['disabled', 'muted'], +]); + +// Display text where title-casing the wire value is not enough. The canceled +// label keeps the History page's existing "Cancelled" copy so both spellings +// and every page show one word. +const STATUS_LABELS: ReadonlyMap = new Map([ + ['in_progress', 'In Progress'], + ['partially_completed', 'Partial'], + ['canceled', 'Cancelled'], + ['cancelled', 'Cancelled'], +]); + +function normalize(status: string | null | undefined): string { + return (status ?? '').trim().toLowerCase(); +} + +/** Color family for a status value from any API (purchases, plans, RI exchange, registrations). */ +export function statusTone(status: string | null | undefined): BadgeTone { + return STATUS_TONES.get(normalize(status)) ?? 'muted'; +} + +/** Human display text for a status: "in_progress" -> "In Progress", "pending" -> "Pending". */ +export function statusLabel(status: string | null | undefined): string { + const key = normalize(status); + const mapped = STATUS_LABELS.get(key); + if (mapped) return mapped; + const words = key.replace(/_/g, ' '); + return words ? words.charAt(0).toUpperCase() + words.slice(1) : 'Unknown'; +} + +/** Class list for a badge of the given tone, for DOM callers that set className. */ +export function badgeClass(tone: BadgeTone): string { + return `badge badge-${tone}`; +} + +/** `label`; the label is HTML-escaped. */ +export function badgeHtml(tone: BadgeTone, label: string, title?: string): string { + const titleAttr = title ? ` title="${escapeHtml(title)}"` : ''; + return `${escapeHtml(label)}`; +} + +/** Badge markup for a raw status value: tone from statusTone, text from statusLabel. */ +export function statusBadgeHtml(status: string | null | undefined): string { + return badgeHtml(statusTone(status), statusLabel(status)); +} diff --git a/frontend/src/modules/registrations.ts b/frontend/src/modules/registrations.ts index 06836ba0..4a03f35f 100644 --- a/frontend/src/modules/registrations.ts +++ b/frontend/src/modules/registrations.ts @@ -7,6 +7,7 @@ import type { AccountRegistration } from '../api/registrations'; import type { CloudAccount, CloudAccountRequest } from '../api/accounts'; import { openAccountModal, loadAccountsForProvider } from '../settings'; import { formatDateTime } from '../utils'; +import { badgeClass, statusLabel, statusTone } from '../lib/status-badge'; import { showToast } from '../toast'; import { confirmDialog } from '../confirmDialog'; import { friendlyError } from '../api/errors'; @@ -46,11 +47,8 @@ function providerLabel(p: string): string { function createStatusBadge(status: string): HTMLSpanElement { const span = document.createElement('span'); - const cls = status === 'pending' ? 'badge-warning' - : status === 'approved' ? 'badge-success' - : 'badge-danger'; - span.className = `status-badge ${cls}`; - span.textContent = status; + span.className = badgeClass(statusTone(status)); + span.textContent = statusLabel(status); return span; } @@ -132,7 +130,7 @@ function renderRegistrationsTable(container: HTMLElement, regs: AccountRegistrat statusTd.appendChild(createStatusBadge(reg.status)); if (reg.has_credentials) { const credBadge = document.createElement('span'); - credBadge.className = 'status-badge badge-info ml-xs'; + credBadge.className = `${badgeClass('info')} ml-xs`; credBadge.textContent = 'creds'; credBadge.title = 'Credentials included — will be auto-stored on approval'; statusTd.appendChild(credBadge); diff --git a/frontend/src/plans.ts b/frontend/src/plans.ts index 3c7afd11..1cc78781 100644 --- a/frontend/src/plans.ts +++ b/frontend/src/plans.ts @@ -23,6 +23,7 @@ import { sortedDistinctValues, } from './lib/column-filter-popover'; import { suggestedPurchaseStartDate } from './lib/calendar-date'; +import { badgeHtml, statusBadgeHtml } from './lib/status-badge'; import { isPastScheduledDate } from './lib/purchase-row-guards'; import { mountPlanEditorPanel } from './plan-editor-panel'; @@ -441,7 +442,7 @@ function renderPlannedPurchaseRow(purchase: PlannedPurchase): string { const overdue = isPending && isPastScheduledDate(purchase.scheduled_date); const overdueHint = canRunPurchase ? 'Not run on its date. Run now or pause.' : 'Not run on its date.'; const overdueBadge = overdue - ? 'Overdue' + ? badgeHtml('danger', 'Overdue', 'Scheduled date has passed') : ''; const overdueHintHtml = overdue ? `
${overdueHint}
` : ''; @@ -459,7 +460,7 @@ function renderPlannedPurchaseRow(purchase: PlannedPurchase): string { ${termCell} ${upfrontShown} ${savingsCell} - ${escapeHtml(purchase.status)}${overdueBadge}${overdueHintHtml} + ${statusBadgeHtml(purchase.status)}${overdueBadge}${overdueHintHtml} ${canRunPurchase ? `` : ''} ${canPauseOrResumePurchase && isPending ? `` : ''} @@ -627,7 +628,7 @@ interface BackendPlan { // Score bands for the per-plan health badge (issue #340 follow-up). Green // >= 80 (healthy), amber 50-79 (needs attention), red < 50 (action needed). -// Reuses the existing status-badge palette (badge-success/badge-warning/ +// Reuses the shared .badge tones (badge-success/badge-warning/ // badge-danger) already used elsewhere on this card rather than introducing // new CSS. const HEALTH_SCORE_GOOD_THRESHOLD = 80; @@ -680,14 +681,14 @@ function healthBadgeHtml(plan: BackendPlan): string { // execution history, while a non-finite value means the number itself // arrived unusable. Naming only the first would send an operator // chasing a database problem that may not exist. - return `Health: unknown`; + return `Health: unknown`; } const factors = plan.health_factors || []; const factorLines = factors.length > 0 ? factors.map(f => `-${f.penalty}: ${f.note}`) : ['No issues detected']; const tooltip = [`Plan health: ${plan.health_score}/100`, ...factorLines].join('\n'); - return `Health: ${plan.health_score}`; + return `Health: ${plan.health_score}`; } // Pretty label for a service slug used inside the plan card. @@ -816,7 +817,7 @@ function renderPlanCard(plan: BackendPlan, canManagePlan: boolean, canDeletePlan // visible implies the plan will still run on that date, which it won't. const showNextDate = Boolean(plan.next_execution_date) && plan.enabled; const overdueBadge = overdue && plan.enabled - ? 'Overdue' + ? 'Overdue' : ''; // Read-only mode: unassigned plans cannot be purchased against until an // operator re-assigns them to at least one account. @@ -827,7 +828,7 @@ function renderPlanCard(plan: BackendPlan, canManagePlan: boolean, canDeletePlan

${escapeHtml(plan.name)}

- ${status.label} + ${status.label} ${healthBadgeHtml(plan)} ${overdueBadge} ${canManagePlan && !isUnassigned ? ` diff --git a/frontend/src/riexchange.ts b/frontend/src/riexchange.ts index c419f5d1..2d973389 100644 --- a/frontend/src/riexchange.ts +++ b/frontend/src/riexchange.ts @@ -31,6 +31,7 @@ import type { } from './api'; import { openModal, closeModal } from './modal'; import { showSkeletonRows, showSkeletonText, teardownSkeleton } from './lib/skeleton'; +import { statusBadgeHtml } from './lib/status-badge'; import { canAccess } from './permissions'; import { createIcon, iconSvg } from './icons'; import { applyReadOnlySettings, isPermissionDeniedError } from './settings'; @@ -1994,24 +1995,6 @@ async function loadExchangeHistory(generation: number = scopeGeneration): Promis } } -function getStatusBadgeClass(status: string): string { - switch (status) { - case 'completed': return 'status-badge completed'; - case 'pending': return 'status-badge pending'; - case 'processing': return 'status-badge running'; - case 'failed': return 'status-badge failed'; - // Migration 000089 (expand-contract rename, ri_exchange_history.status): - // backend may return either spelling during the rolling deploy window. - // Match both so a row written by EITHER old or new code keeps the muted - // "disabled" visual treatment instead of falling through to the default - // class. The CONTRACT migration (#1278) normalizes the data; the British - // branch can be removed then. - case 'canceled': - case 'cancelled': return 'status-badge disabled'; - default: return 'status-badge'; - } -} - function renderExchangeHistory(container: HTMLElement, records: RIExchangeHistoryRecord[]): void { if (!records || records.length === 0) { container.textContent = ''; @@ -2042,7 +2025,7 @@ function renderExchangeHistory(container: HTMLElement, records: RIExchangeHistor // payment_due is USD (the execute cap is max_payment_due_usd); an // empty amount renders as a dash, not a lone "$" (issue #284). + '' + (rec.payment_due ? '$' + escapeHtml(rec.payment_due) : '—') + '' - + '' + escapeHtml(rec.status) + '' + errorLine + '' + + '' + statusBadgeHtml(rec.status) + errorLine + '' + '' + exchangeIdCell + '' + '' + approveBtn + '' + ''; diff --git a/frontend/src/styles/components.css b/frontend/src/styles/components.css index 8e6ebee7..3b5982ee 100644 --- a/frontend/src/styles/components.css +++ b/frontend/src/styles/components.css @@ -489,9 +489,9 @@ button.secondary:hover { content: 'Unsaved changes'; display: inline-block; padding: 0.25rem 0.6rem; - background: #fff3cd; - color: #856404; - border-radius: 12px; + background: var(--cudly-warn-bg); + color: var(--cudly-warn-fg); + border-radius: var(--cudly-r-full); font-size: 0.8rem; margin-right: 0.75rem; vertical-align: middle; @@ -1271,32 +1271,9 @@ tr.rec-variant-row:hover td:not(.checkbox-col) { color: var(--cudly-primary); } -/* Status badges */ -.status-badge { - padding: 0.25rem 0.75rem; - border-radius: 12px; - font-size: 0.8rem; - font-weight: var(--cudly-fw-semibold); -} - -.status-badge.active { - background: var(--cudly-success-bg); - color: #137333; -} - -.status-badge.paused { - background: #ffe0b2; - color: #e65100; -} - -.status-badge.disabled { - background: var(--cudly-bg); - color: var(--cudly-text-muted); -} - /* Cost chips: used in the reshape-alternatives cell and the exchange - * modal's per-target-row cost slot. Shares geometry with - * .status-badge so the visual style stays consistent. Neutral grey + * modal's per-target-row cost slot. Pill-shaped like .badge so the visual + * style stays consistent. Neutral grey * background since costs are advisory information, not status. */ .cost-chip { display: inline-block; @@ -1406,59 +1383,33 @@ tr.rec-variant-row:hover td:not(.checkbox-col) { text-transform: lowercase; } -/* Role badges in user table */ -.badge-admin { - display: inline-block; - font-size: 0.8rem; - font-weight: var(--cudly-fw-semibold); - padding: 0.2rem 0.5rem; - border-radius: 4px; - background: var(--cudly-info-bg); - color: var(--cudly-primary); -} - -.badge-user { - display: inline-block; - font-size: 0.8rem; - font-weight: var(--cudly-fw-semibold); - padding: 0.2rem 0.5rem; - border-radius: 4px; - background: var(--cudly-border); - color: var(--cudly-text-muted); -} - -.badge-readonly { - display: inline-block; - font-size: 0.8rem; - font-weight: var(--cudly-fw-semibold); - padding: 0.2rem 0.5rem; - border-radius: 4px; - background: var(--cudly-bg); - color: #999; -} - -/* Generic status badges used by the Purchase History status column and the - * Accounts table status column. Base `.badge` sets the pill shape; the - * modifier classes set the colour pair. Kept alongside the existing role - * badges so any table can drop a `` inline. +/* Badges (issue #737): the one pill used for every status and tag. Pick the + * tone with statusTone() in lib/status-badge.ts rather than by hand, so a + * status reads the same on every page: + * success finished well warning not finished yet (pending, running) + * danger failed or refused muted inactive or final (canceled, paused) + * info informational tags, not a status + * Colors come from the tokens in base.css (all >= 4.5:1 text contrast). */ .badge { display: inline-block; font-size: 0.8rem; font-weight: var(--cudly-fw-semibold); + line-height: var(--cudly-lh-tight); padding: 0.2rem 0.55rem; - border-radius: 999px; + border-radius: var(--cudly-r-full); letter-spacing: 0.01em; + white-space: nowrap; } .badge-success { background: var(--cudly-success-bg); - color: #137333; + color: var(--cudly-success-fg); } .badge-warning { - background: #fef7e0; - color: #8a6d0c; + background: var(--cudly-warn-bg); + color: var(--cudly-warn-fg); } .badge-danger { @@ -1513,27 +1464,6 @@ tr.rec-variant-row:hover td:not(.checkbox-col) { line-height: 1.25; } -/* Purchase detail status badges */ -.status-badge.completed { - background: var(--cudly-success-bg); - color: #137333; -} - -.status-badge.pending { - background: var(--cudly-info-bg); - color: var(--cudly-primary); -} - -.status-badge.failed { - background: var(--cudly-error-bg); - color: var(--cudly-error-fg); -} - -.status-badge.running { - background: #ffe0b2; - color: #e65100; -} - /* Copy button */ .copy-btn { background: none; diff --git a/frontend/src/styles/settings.css b/frontend/src/styles/settings.css index d78fed3d..ed6e4b8a 100644 --- a/frontend/src/styles/settings.css +++ b/frontend/src/styles/settings.css @@ -437,8 +437,8 @@ select.dirty { .provider-disabled-banner { display: none; - background: #fff3cd; - color: #664d03; + background: var(--cudly-warn-bg); + color: var(--cudly-warn-fg); border: 1px solid #ffecb5; border-radius: 4px; padding: 0.5rem 0.75rem; diff --git a/frontend/src/styles/tables.css b/frontend/src/styles/tables.css index da36965c..d736662b 100644 --- a/frontend/src/styles/tables.css +++ b/frontend/src/styles/tables.css @@ -148,58 +148,12 @@ tr.history-row-highlight:hover { white-space: nowrap; } -/* Planned purchase status badges */ -.status-pending { - color: #856404; -} - -.status-paused { - color: #9e9e9e; -} - -.status-running { - color: var(--cudly-primary); -} - -.status-completed { - color: var(--cudly-success); -} - -.status-failed { - color: var(--cudly-error); -} - -.planned-purchases-table .status-badge { - padding: 0.25rem 0.5rem; - border-radius: 4px; - font-size: 0.8rem; - font-weight: var(--cudly-fw-medium); - text-transform: capitalize; -} - -.planned-purchases-table .status-badge.status-pending { - background: #fef3cd; - color: #856404; -} - -.planned-purchases-table .status-badge.status-paused { - background: #e9ecef; - color: #495057; -} - -.planned-purchases-table .status-badge.status-running { - background: #cce5ff; - color: #004085; -} - -.planned-purchases-table .status-badge.status-completed { - background: #d4edda; - color: #155724; -} - -.planned-purchases-table .status-badge.status-failed { - background: #f8d7da; - color: #721c24; +/* Planned purchase rows keep a status- class as a hook (tests and column + * filters count rows by it). The status color lives only on the row's .badge + * (issue #737); tinting the whole row too gave e.g. a running row blue text + * next to an amber badge. A paused step is just dimmed. */ +.planned-purchase-row.status-paused { + color: var(--cudly-text-subtle); } /* Coverage breakdown bar, Inventory & Coverage -> Coverage (issue #1777). diff --git a/frontend/src/utils.ts b/frontend/src/utils.ts index e1f62f5f..c7ada533 100644 --- a/frontend/src/utils.ts +++ b/frontend/src/utils.ts @@ -352,21 +352,23 @@ export function formatRampSchedule(schedule: RampSchedule | string | null | unde } export interface StatusBadge { - class: 'active' | 'paused' | 'disabled'; + /** Badge tone (lib/status-badge.ts BadgeTone); rendered as `badge badge-`. */ + class: 'success' | 'info' | 'muted'; label: string; } /** - * Get status badge class + * Plan status badge. "Manual" (enabled, purchases need a manual run) is a + * valid configuration, not a warning, so it is info rather than amber. */ export function getStatusBadge(enabled: boolean, autoPurchase: boolean): StatusBadge { if (!enabled) { - return { class: 'disabled', label: 'Disabled' }; + return { class: 'muted', label: 'Disabled' }; } if (autoPurchase) { - return { class: 'active', label: 'Active' }; + return { class: 'success', label: 'Active' }; } - return { class: 'paused', label: 'Manual' }; + return { class: 'info', label: 'Manual' }; } /** diff --git a/frontend/tests-e2e/ri-exchange-confirmation.spec.ts b/frontend/tests-e2e/ri-exchange-confirmation.spec.ts index 6ec42c12..e3f73c97 100644 --- a/frontend/tests-e2e/ri-exchange-confirmation.spec.ts +++ b/frontend/tests-e2e/ri-exchange-confirmation.spec.ts @@ -151,7 +151,7 @@ for (const action of ['Execute', 'Approve']) { await expect.poll(() => state.requests.length).toBe(1); if (action === 'Approve') { expect(state.requests[0]).toEqual({ path: `/api/ri-exchange/approve/${state.history.id}`, body: null }); - await expect(page.locator('#ri-exchange-history-list')).toContainText('completed'); + await expect(page.locator('#ri-exchange-history-list')).toContainText('Completed'); } expect(state.errors).toEqual([]); }); @@ -219,7 +219,7 @@ for (const reopenBeforeResponse of [false, true]) { await page.locator('#ri-exchange-modal').getByRole('button', { name: reopenBeforeResponse ? 'Cancel' : 'Close', exact: true }).click(); await page.locator(`[data-action="quote-ri"][data-ri-id="${otherRI}"]`).click(); if (reopenBeforeResponse) release(); - await expect(page.locator('#ri-exchange-history-list')).toContainText('completed'); + await expect(page.locator('#ri-exchange-history-list')).toContainText('Completed'); await expect(page.locator(`[data-action="quote-ri"][data-ri-id="${ri}"]`)).toHaveCount(0); await page.waitForTimeout(2200); await expect(page.locator('#ri-exchange-modal')).toBeVisible();