From da125a8af47c302c3d80a60b513fa88a46cc8b2d Mon Sep 17 00:00:00 2001 From: Astrid Gealer Date: Sat, 24 Oct 2026 00:06:54 +0000 Subject: [PATCH 1/2] feat(frontend): add one status tone and label map for badges Status badges picked their colors per page: "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. lib/status-badge.ts is the single place that maps a status from any API to a tone (success, warning, danger, info, muted) and to display text, plus helpers that render the shared .badge markup with the label escaped. Unknown statuses are muted, never green. It lives in lib/ rather than utils.ts because most suites mock utils wholesale. The callers move over in the next commit. Refs #737 Co-Authored-By: Claude Opus 5.5 --- frontend/src/__tests__/status-badge.test.ts | 90 +++++++++++++++++++++ frontend/src/lib/status-badge.ts | 89 ++++++++++++++++++++ 2 files changed, 179 insertions(+) create mode 100644 frontend/src/__tests__/status-badge.test.ts create mode 100644 frontend/src/lib/status-badge.ts diff --git a/frontend/src/__tests__/status-badge.test.ts b/frontend/src/__tests__/status-badge.test.ts new file mode 100644 index 000000000..1a3f1bb37 --- /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/lib/status-badge.ts b/frontend/src/lib/status-badge.ts new file mode 100644 index 000000000..84c114543 --- /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)); +} From 1c894d7032723debd3e893fb9ee53bb73dbc82e1 Mon Sep 17 00:00:00 2001 From: Astrid Gealer Date: Sat, 24 Oct 2026 00:06:54 +0000 Subject: [PATCH 2/2] fix(frontend): render every status badge with the shared tones Plans, planned purchases, RI exchange history, registrations and the Purchases history now render `.badge .badge-` from lib/status-badge.ts, so the same status has one color and one label on every page (RI exchange shows "Pending", not "pending"). - components.css: one .badge pill whose tones come from base.css tokens (all >= 4.5:1). The .status-badge variants, the dead .badge-admin / -user / -readonly rectangles and the planned-purchases Bootstrap palette in tables.css are removed. The orange #ffe0b2/#e65100 pair (2.99:1) is gone. - Not-finished statuses (pending, notified, scheduled, running, processing) are amber everywhere, matching what the Purchases history already did; paused/canceled/expired/disabled are muted. - Plan "Manual" is info (blue) instead of the orange warning: it is a valid configuration, not a problem. - Planned purchase rows no longer tint all their text by status (a running row had blue text next to its badge); paused rows stay dimmed. - The Unsaved-changes pill and the provider-disabled banner use the warn tokens instead of two more ad-hoc amber palettes. Refs #737 Co-Authored-By: Claude Opus 5.5 --- frontend/src/__tests__/css.test.ts | 13 ++- .../__tests__/plans-range-validation.test.ts | 2 +- frontend/src/__tests__/plans.test.ts | 25 ++-- frontend/src/__tests__/riexchange.test.ts | 22 ++++ frontend/src/__tests__/utils.test.ts | 12 +- .../src/__tests__/xss-purchase-status.test.ts | 15 +-- frontend/src/history.ts | 25 ++-- frontend/src/modules/registrations.ts | 10 +- frontend/src/plans.ts | 13 ++- frontend/src/riexchange.ts | 21 +--- frontend/src/styles/components.css | 108 +++--------------- frontend/src/styles/settings.css | 4 +- frontend/src/styles/tables.css | 58 +--------- frontend/src/utils.ts | 12 +- .../ri-exchange-confirmation.spec.ts | 4 +- 15 files changed, 126 insertions(+), 218 deletions(-) diff --git a/frontend/src/__tests__/css.test.ts b/frontend/src/__tests__/css.test.ts index 4bd8e531d..7e94cac51 100644 --- a/frontend/src/__tests__/css.test.ts +++ b/frontend/src/__tests__/css.test.ts @@ -89,10 +89,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/); + expect(css).not.toMatch(/\.badge-(admin|user|readonly)\b/); }); test('has toggle switch styles', () => { @@ -387,7 +390,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 350d85fa9..4f95cf8fd 100644 --- a/frontend/src/__tests__/plans-range-validation.test.ts +++ b/frontend/src/__tests__/plans-range-validation.test.ts @@ -75,7 +75,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 e9591d981..46c98e1d8 100644 --- a/frontend/src/__tests__/plans.test.ts +++ b/frontend/src/__tests__/plans.test.ts @@ -105,7 +105,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 ''; @@ -456,7 +456,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 () => { @@ -837,7 +839,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'); }); @@ -3261,6 +3263,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: [] }] @@ -3270,7 +3278,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'); }); @@ -3287,7 +3295,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'); }); @@ -3307,7 +3315,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'); }); @@ -3335,9 +3343,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'); }); @@ -3358,7 +3365,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.test.ts b/frontend/src/__tests__/riexchange.test.ts index e9f42d41c..dfe082e53 100644 --- a/frontend/src/__tests__/riexchange.test.ts +++ b/frontend/src/__tests__/riexchange.test.ts @@ -1351,6 +1351,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__/utils.test.ts b/frontend/src/__tests__/utils.test.ts index 58b4759c9..ae5c4f15a 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 cb19e76ed..5597ba111 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 b4c183d44..4d64c8945 100644 --- a/frontend/src/history.ts +++ b/frontend/src/history.ts @@ -14,6 +14,7 @@ import { showToast } from './toast'; import { getCurrentUser } from './state'; import { canAccess } from './permissions'; import { showSkeletonRows, teardownSkeleton } from './lib/skeleton'; +import { statusBadgeHtml } from './lib/status-badge'; import { getAccountName } from './recommendations'; import { applyColumnFilters } from './lib/column-filters'; import { @@ -467,22 +468,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 @@ -492,21 +496,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/modules/registrations.ts b/frontend/src/modules/registrations.ts index 81e5272c3..dfb762029 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'; @@ -45,11 +46,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; } @@ -131,7 +129,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 e9365d926..7c2984a51 100644 --- a/frontend/src/plans.ts +++ b/frontend/src/plans.ts @@ -17,6 +17,7 @@ import { showSkeletonTiles, showSkeletonRows, teardownSkeleton } from './lib/ske import { canAccess } from './permissions'; import { parseNumericFilter, applyColumnFilters as applyColumnFiltersLib } from './lib/column-filters'; import { suggestedPurchaseStartDate } from './lib/calendar-date'; +import { statusBadgeHtml } from './lib/status-badge'; import { mountPlanEditorPanel } from './plan-editor-panel'; // pendingPlanRecommendations holds the resolved plan target captured at @@ -752,7 +753,7 @@ function renderPlannedPurchaseRow(purchase: PlannedPurchase): string { ${termCell} ${upfrontShown} ${savingsCell} - ${escapeHtml(purchase.status)} + ${statusBadgeHtml(purchase.status)} ${canRunPurchase ? `` : ''} ${canPauseOrResumePurchase && isPending ? `` : ''} @@ -920,7 +921,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; @@ -973,14 +974,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. @@ -1099,7 +1100,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. @@ -1110,7 +1111,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 4b0855f7b..3247e3b8b 100644 --- a/frontend/src/riexchange.ts +++ b/frontend/src/riexchange.ts @@ -28,6 +28,7 @@ import type { } from './api'; import { openModal, closeModal } from './modal'; import { showSkeletonRows, teardownSkeleton } from './lib/skeleton'; +import { statusBadgeHtml } from './lib/status-badge'; import { canAccess } from './permissions'; import { applyReadOnlySettings, isPermissionDeniedError } from './settings'; import { showToast } from './toast'; @@ -2347,24 +2348,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 = ''; @@ -2388,7 +2371,7 @@ function renderExchangeHistory(container: HTMLElement, records: RIExchangeHistor + '' + escapeHtml(String(rec.source_count)) + 'x ' + escapeHtml(rec.source_instance_type) + '' + '' + escapeHtml(String(rec.target_count)) + 'x ' + escapeHtml(rec.target_instance_type) + '' + '$' + escapeHtml(rec.payment_due) + '' - + '' + escapeHtml(rec.status) + '' + + '' + statusBadgeHtml(rec.status) + '' + '' + exchangeIdCell + '' + '' + approveBtn + '' + ''; diff --git a/frontend/src/styles/components.css b/frontend/src/styles/components.css index 5d76ec88e..81f22a8a7 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; @@ -1225,32 +1225,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: 600; -} - -.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; @@ -1354,59 +1331,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: 600; - 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: 600; - 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: 600; - 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: 600; + 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 { @@ -1440,27 +1391,6 @@ tr.rec-variant-row:hover td:not(.checkbox-col) { font-weight: 600; } -/* 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 ddae897a8..f2a04f025 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 291bcf07c..39974eb60 100644 --- a/frontend/src/styles/tables.css +++ b/frontend/src/styles/tables.css @@ -122,58 +122,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: 500; - 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 ab2b3eeae..e05107ce5 100644 --- a/frontend/src/utils.ts +++ b/frontend/src/utils.ts @@ -344,21 +344,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 e61ebe377..0976eed9b 100644 --- a/frontend/tests-e2e/ri-exchange-confirmation.spec.ts +++ b/frontend/tests-e2e/ri-exchange-confirmation.spec.ts @@ -148,7 +148,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([]); }); @@ -215,7 +215,7 @@ for (const reopenBeforeResponse of [false, true]) { await page.locator('#ri-exchange-modal').getByRole('button', { name: 'Cancel', 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();