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
29 changes: 28 additions & 1 deletion frontend/src/__tests__/ladder-timeline-filters.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { LadderEvent } from '../api/ladder';
import { emptyLadderFilters, filterLadderEvents, ladderFilterOptions, ladderTotal, ladderEditReason } from '../ladder-timeline-filters';
import { emptyLadderFilters, filterLadderEvents, ladderFilterOptions, ladderTotal, ladderEditReason, ladderLabel, formatLadderAmount, formatLadderDateTime } from '../ladder-timeline-filters';

const event: LadderEvent = {
id: 'one', config_id: 'config', run_id: 'run', layer_type: 'compute-sp', term: '1yr',
Expand Down Expand Up @@ -34,3 +34,30 @@ test('eligibility covers status, execution, deadline, and unavailable revision A
expect(ladderEditReason(event, Date.parse(event.scheduled_date))).toContain('passed');
expect(ladderEditReason({ ...event, revision: undefined as unknown as number })).toContain('upgraded');
});

// FIXTURE: enum codes from cloud-commitments-go pkg/ladder.
test('ladder labels cover every known enum and show unknown codes raw', () => {
expect(['ec2-instance-sp', 'compute-sp', 'convertible-ri', 'azure-reservation', 'azure-savings-plan'].map(v => ladderLabel('layer_type', v)))
.toEqual(['EC2 Instance Savings Plan', 'Compute Savings Plan', 'Convertible RI', 'Azure Reservation', 'Azure Savings Plan']);
expect(['scheduled', 'fired', 'completed', 'cancelled', 'failed'].map(v => ladderLabel('status', v)))
.toEqual(['Scheduled', 'Fired', 'Completed', 'Cancelled', 'Failed']);
expect(['1yr', '3yr'].map(v => ladderLabel('term', v))).toEqual(['1 Year', '3 Years']);
expect(['no-upfront', 'partial-upfront', 'all-upfront'].map(v => ladderLabel('payment_option', v))).toEqual(['No Upfront', 'Partial Upfront', 'All Upfront']);
expect(ladderLabel('layer_type', 'new-thing')).toBe('new-thing');
expect(ladderLabel('term', '5yrs')).toBe('5yrs');
expect(ladderLabel('status', '')).toBe('');
});

test('amounts keep zero and absent distinct and never round a small value to $0.00', () => {
expect(formatLadderAmount('0.000000')).toBe('$0.00/hr');
expect(formatLadderAmount(null)).toBe('unavailable');
expect(formatLadderAmount('0.001200')).toBe('$0.0012/hr');
expect(formatLadderAmount('1234.500000')).toBe('$1,234.50/hr');
expect(() => formatLadderAmount('abc')).toThrow('Invalid planning budget.');
});

test('dates render the UTC calendar day and time regardless of browser zone', () => {
expect(formatLadderDateTime('2030-01-02T23:30:00Z')).toBe('Jan 2, 2030, 23:30 UTC');
expect(formatLadderDateTime('2030-01-02T23:30:00-05:00')).toBe('Jan 3, 2030, 04:30 UTC');
expect(formatLadderDateTime('garbage')).toBe('garbage');
});
48 changes: 47 additions & 1 deletion frontend/src/__tests__/ladder-timeline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ test('real selection opens editor, UTC draft resets and cancel preserves authori
document.querySelector<HTMLButtonElement>('[data-event]')!.click();
const amount = document.getElementById('ladder-event-amount') as HTMLInputElement;
amount.value = '0.5'; amount.dispatchEvent(new Event('input'));
expect(document.getElementById('ladder-event-preview')!.textContent).toContain('0.500000');
expect(document.getElementById('ladder-event-preview')!.textContent).toContain('$0.5000/hr');
document.querySelector<HTMLButtonElement>('.ladder-event-editor [data-reset]')!.click();
expect(amount.value).toBe('1.000000');
document.querySelector<HTMLButtonElement>('.ladder-event-editor [data-cancel]')!.click();
Expand Down Expand Up @@ -187,3 +187,49 @@ test('focus returns to the edited purchase after a successful save and the canva
expect(document.querySelector('.ladder-event-editor')).toBeNull();
expect(document.activeElement).toBe(document.querySelector('[data-event="one"]'));
});

// FIXTURE: hand-written wire shape of GET /api/ladder/tranches and /runs (not captured from a live backend).
test('table, filters, summary, snapshot and tooltip show formatted values, not raw wire values (#697)', async () => {
const raw = { ...event, term: '3yr', payment_option: 'all-upfront', layer_type: 'convertible-ri', amount_usd_hr: '0.123456', scheduled_date: '2030-01-02T23:30:00Z' };
const unknown = { ...event, id: 'two', layer_type: 'future-layer', amount_usd_hr: '0.000000', scheduled_date: '2030-01-03T00:00:00Z' };
jest.mocked(getLadderEvents).mockResolvedValue([raw, unknown]);
jest.mocked(getLadderRuns).mockResolvedValue([{ ...run, baseline_usd_hr: '0.000000', existing_usd_hr: null, target_usd_hr: '12.500000' }]);
await initLadderTimeline(document.getElementById('timeline')!, [], false);
const rows = Array.from(document.querySelectorAll('#timeline tbody tr')).map(row => row.textContent ?? '');
expect(rows[0]).toContain('Jan 2, 2030, 23:30 UTC');
expect(rows[0]).toContain('Convertible RI');
expect(rows[0]).toContain('3 Years');
expect(rows[0]).toContain('All Upfront');
expect(rows[0]).toContain('$0.1235/hr');
expect(rows[0]).toContain('Scheduled');
expect(rows[0]).not.toMatch(/0\.123456|2030-01-02T|convertible-ri|3yr|all-upfront/);
expect(rows[1]).toContain('future-layer');
expect(rows[1]).toContain('$0.00/hr');
const summary = document.querySelector('[data-summary]')!.textContent!;
expect(summary).toContain('$0.1235/hr in this selection');
expect(summary).not.toContain('USD/hour');
const layer = document.querySelector<HTMLSelectElement>('[data-filter="layer_type"]')!;
expect(Array.from(layer.options).map(o => [o.value, o.textContent])).toEqual([['', 'All'], ['convertible-ri', 'Convertible RI'], ['future-layer', 'future-layer']]);
const runFilter = document.querySelector<HTMLSelectElement>('[data-filter="run_id"]')!;
runFilter.value = 'run'; runFilter.dispatchEvent(new Event('change'));
expect(runFilter.options[1]?.textContent).toBe('Jan 1, 2026, 00:00 UTC');
const snapshot = document.querySelector('[data-snapshot]')!.textContent!;
expect(snapshot).toContain('baseline $0.00/hr, existing unavailable, target $12.50/hr');
const chartCalls = jest.mocked(Chart).mock.calls;
const tooltip = chartCalls[chartCalls.length - 1]?.[1].options?.plugins?.tooltip?.callbacks?.label as (c: { datasetIndex: number; dataIndex: number }) => string;
expect(tooltip({ datasetIndex: 0, dataIndex: 0 })).toBe('Jan 2, 2030, 23:30 UTC: $0.1235/hr (Convertible RI, Scheduled)');
});

// FIXTURE: hand-written editor input.
test('editor header and run ceiling are formatted while the amount input keeps the exact wire value (#697)', async () => {
jest.mocked(getLadderEvents).mockResolvedValue([{ ...event, term: '3yr', payment_option: 'partial-upfront', amount_usd_hr: '0.123456' }]);
jest.mocked(getLadderRuns).mockResolvedValue([{ ...run, total_hourly_commit: '2.500000' }]);
await initLadderTimeline(document.getElementById('timeline')!, [], false);
document.querySelector<HTMLButtonElement>('[data-event]')!.click();
const text = document.querySelector('.ladder-event-editor')!.textContent!;
expect(text).toContain('Compute Savings Plan / 3 Years / Partial Upfront');
expect(text).toContain('Original plan: Jan 1, 2026, 00:00 UTC');
expect(text).toContain('Original run ceiling: $2.50/hr');
expect(text).toContain('Current run total after this change: $0.1235/hr');
expect((document.getElementById('ladder-event-amount') as HTMLInputElement).value).toBe('0.123456');
});
10 changes: 5 additions & 5 deletions frontend/src/ladder-timeline-editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { ApiError } from './api/types';
import { amendLadderEvent, type LadderEvent, type LadderRun } from './api/ladder';
import { openModal, closeModal } from './modal';
import { escapeHtml } from './utils';
import { formatLadderUnits, ladderAmountUnits, ladderTotal } from './ladder-timeline-filters';
import { formatLadderAmount, formatLadderDateTime, formatLadderUnits, ladderAmountUnits, ladderLabel, ladderTotal } from './ladder-timeline-filters';

export function openLadderEventEditor(event: LadderEvent, run: LadderRun, siblings: readonly LadderEvent[], onReload: () => void): void {
const modal = document.createElement('div');
Expand All @@ -12,16 +12,16 @@ export function openLadderEventEditor(event: LadderEvent, run: LadderRun, siblin
modal.setAttribute('aria-labelledby', 'ladder-event-title');
modal.innerHTML = `<div class="modal-content"><div class="modal-header">
<h3 id="ladder-event-title">Edit planned purchase</h3></div><form>
<p>${escapeHtml(event.layer_type)} / ${escapeHtml(event.term)} / ${escapeHtml(event.payment_option)}</p>
<p>Original plan: ${escapeHtml(run.started_at)}.
<p>${escapeHtml(ladderLabel('layer_type', event.layer_type))} / ${escapeHtml(ladderLabel('term', event.term))} / ${escapeHtml(ladderLabel('payment_option', event.payment_option))}</p>
<p>Original plan: ${escapeHtml(formatLadderDateTime(run.started_at))}.
${run.actions.filter(action => action.layer === event.layer_type).map(action => `${escapeHtml(action.rationale)}${action.data_sources?.length ? ` Sources: ${escapeHtml(action.data_sources.join(', '))}.` : ''}`).join(' ')}</p>
<p>These are planning budgets. Purchases are not executed by this editor.</p>
<div class="form-row"><label for="ladder-event-date">Scheduled date and time (UTC)</label>
<input id="ladder-event-date" type="datetime-local" step="1" required></div>
<div class="form-row"><label for="ladder-event-amount">Planning budget (USD/hour)</label>
<input id="ladder-event-amount" inputmode="decimal" required></div>
<p id="ladder-event-preview" aria-live="polite"></p>
<p>Original run ceiling: ${escapeHtml(run.total_hourly_commit)} USD/hour.
<p>Original run ceiling: ${escapeHtml(formatLadderAmount(run.total_hourly_commit))}.
The server also enforces the current per-run cap.</p>
<p>Reducing a tranche can cause the next planning run to add it back.
Change target coverage in account settings for a lasting target reduction.</p>
Expand Down Expand Up @@ -53,7 +53,7 @@ export function openLadderEventEditor(event: LadderEvent, run: LadderRun, siblin
try {
const units = ladderAmountUnits(amount.value);
const total = originalTotal - ladderAmountUnits(event.amount_usd_hr) + units;
preview.textContent = `Current run total after this change: ${formatLadderUnits(total)} USD/hour.`;
preview.textContent = `Current run total after this change: ${formatLadderAmount(formatLadderUnits(total))}.`;
save.disabled = pending || units <= 0n || total > ladderAmountUnits(run.total_hourly_commit);
} catch {
preview.textContent = 'Enter a positive decimal with up to six fractional digits.';
Expand Down
41 changes: 41 additions & 0 deletions frontend/src/ladder-timeline-filters.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import type { LadderEvent } from './api/ladder';
import { formatCurrency, formatDate, formatTerm } from './utils';
import { getPaymentLabel } from './commitmentOptions';

export const ladderDimensions = ['run_id', 'layer_type', 'term', 'payment_option', 'status'] as const;
export type LadderDimension = typeof ladderDimensions[number];
Expand Down Expand Up @@ -44,3 +46,42 @@ export function ladderEditReason(event: LadderEvent, now = Date.now()): string |
if (!Number.isSafeInteger(event.revision) || event.revision < 0) return 'Editing is unavailable until the timeline API is upgraded.';
return undefined;
}

// Enum values from cloud-commitments-go pkg/ladder (types.go, store.go).
const layerLabels: Record<string, string> = {
'ec2-instance-sp': 'EC2 Instance Savings Plan', 'compute-sp': 'Compute Savings Plan', 'convertible-ri': 'Convertible RI',
'azure-reservation': 'Azure Reservation', 'azure-savings-plan': 'Azure Savings Plan',
};
const statusLabels: Record<string, string> = {
scheduled: 'Scheduled', fired: 'Fired', completed: 'Completed', cancelled: 'Cancelled', failed: 'Failed',
};

/** Label for a ladder enum code. An unknown code is shown raw so it stays visible. */
export function ladderLabel(dimension: LadderDimension, value: string): string {
switch (dimension) {
case 'layer_type': return layerLabels[value] ?? value;
case 'status': return statusLabels[value] ?? value;
case 'payment_option': return getPaymentLabel(value);
case 'term': {
const years = /^([1-9]\d?)yr$/.exec(value);
return years ? formatTerm(Number(years[1])) : value;
}
case 'run_id': return value;
}
}

/** USD/hour budget for display: 2 digits from $1, 4 below $1 so a small non-zero value never reads as $0.00. */
export function formatLadderAmount(amount: string | null | undefined): string {
if (amount === null || amount === undefined) return 'unavailable';
ladderAmountUnits(amount);
const value = Number(amount);
return `${formatCurrency(value, '$', value > 0 && value < 1 ? 4 : 2)}/hr`;
}

/** Calendar date and UTC time ("Jan 2, 2030, 00:00 UTC"); never browser-local. Unparseable input is shown raw. */
export function formatLadderDateTime(iso: string): string {
const stamp = Date.parse(iso);
if (!Number.isFinite(stamp)) return iso;
const utc = new Date(stamp).toISOString();
return `${formatDate(utc.slice(0, 10))}, ${utc.slice(11, 16)} UTC`;
}
14 changes: 7 additions & 7 deletions frontend/src/ladder-timeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { listAccountsMinimal, type AccountSummary } from './api/accounts';
import { getLadderEvents, getLadderRuns, type LadderConfig, type LadderEvent, type LadderRun } from './api/ladder';
import { canAccess } from './permissions';
import { escapeHtml } from './utils';
import { emptyLadderFilters, filterLadderEvents, ladderDimensions, ladderFilterOptions, ladderEditReason, ladderTotal, type LadderFilters } from './ladder-timeline-filters';
import { emptyLadderFilters, filterLadderEvents, ladderDimensions, ladderFilterOptions, ladderEditReason, ladderTotal, ladderLabel, formatLadderAmount, formatLadderDateTime, type LadderFilters } from './ladder-timeline-filters';
import { openLadderEventEditor } from './ladder-timeline-editor';

Chart.register(...registerables);
Expand Down Expand Up @@ -62,13 +62,13 @@ export async function initLadderTimeline(root: HTMLElement, configs: readonly La
const key = select.dataset['filter'] as typeof ladderDimensions[number];
const options = ladderFilterOptions(events, filters, key);
if (filters[key] && !options.includes(filters[key])) options.unshift(filters[key]);
select.innerHTML = `<option value="">All</option>${options.map(value => `<option value="${escapeHtml(value)}">${escapeHtml(key === 'run_id' ? `${runs.find(run => run.id === value)?.started_at ?? value}` : value)}</option>`).join('')}`;
select.innerHTML = `<option value="">All</option>${options.map(value => `<option value="${escapeHtml(value)}">${escapeHtml(key === 'run_id' ? formatLadderDateTime(runs.find(run => run.id === value)?.started_at ?? value) : ladderLabel(key, value))}</option>`).join('')}`;
select.value = filters[key];
});
summary.textContent = `${visible.length} of ${events.length} retained tranches; ${ladderTotal(visible)} USD/hour in this selection.`;
summary.textContent = `${visible.length} of ${events.length} retained tranches; ${formatLadderAmount(ladderTotal(visible))} in this selection.`;
const snapshot = root.querySelector<HTMLElement>('[data-snapshot]')!;
const run = runs.find(item => item.id === filters.run_id);
snapshot.textContent = run ? `Original run ${run.started_at}: baseline ${run.baseline_usd_hr ?? 'unavailable'}, existing ${run.existing_usd_hr ?? 'unavailable'}, target ${run.target_usd_hr ?? 'unavailable'} USD/hour. These snapshots are not a coverage forecast. ${run.actions.map(action => `${action.rationale}${action.data_sources?.length ? ` Sources: ${action.data_sources.join(', ')}.` : ''}`).join(' ')}` : 'Select a run to inspect its original baseline, existing commitment and target snapshots.';
snapshot.textContent = run ? `Original run ${formatLadderDateTime(run.started_at)}: baseline ${formatLadderAmount(run.baseline_usd_hr)}, existing ${formatLadderAmount(run.existing_usd_hr)}, target ${formatLadderAmount(run.target_usd_hr)}. These snapshots are not a coverage forecast. ${run.actions.map(action => `${action.rationale}${action.data_sources?.length ? ` Sources: ${action.data_sources.join(', ')}.` : ''}`).join(' ')}` : 'Select a run to inspect its original baseline, existing commitment and target snapshots.';
chart?.destroy();
const groups = Array.from(new Set(visible.map(event => event.layer_type))).map(layer => ({ layer, events: visible.filter(event => event.layer_type === layer) }));
const colors: Record<string, string> = { 'compute-sp': '#2563eb', 'ec2-instance-sp': '#0891b2', 'convertible-ri': '#7c3aed' };
Expand All @@ -82,7 +82,7 @@ export async function initLadderTimeline(root: HTMLElement, configs: readonly La
y: { beginAtZero: true, title: { display: true, text: 'Additional planning budget (USD/hour)' } } },
plugins: { legend: { onClick: (_event, item) => { const group = groups[item.datasetIndex ?? -1]; if (group) { filters.layer_type = filters.layer_type === group.layer ? '' : group.layer; render(); } } }, tooltip: { callbacks: { label: context => {
const event = groups[context.datasetIndex]?.events[context.dataIndex];
return event ? `${event.scheduled_date}: ${event.amount_usd_hr} USD/hour (${event.layer_type}, ${event.status})` : '';
return event ? `${formatLadderDateTime(event.scheduled_date)}: ${formatLadderAmount(event.amount_usd_hr)} (${ladderLabel('layer_type', event.layer_type)}, ${ladderLabel('status', event.status)})` : '';
} } } }, onClick: (_event, points) => { const point = points[0]; const item = point && groups[point.datasetIndex]?.events[point.index]; if (item) selectEvent(item); },
},
});
Expand All @@ -91,9 +91,9 @@ export async function initLadderTimeline(root: HTMLElement, configs: readonly La
table.textContent = events.length ? 'No purchases match these filters. Clear filters to see the retained plan.' : `No planned purchase tranches. ${rationale}`;
return;
}
table.innerHTML = `<table class="data-table" aria-label="Planned purchase tranches"><thead><tr><th>Scheduled (UTC)</th><th>Type</th><th>Term</th><th>Payment</th><th>Budget (USD/hour)</th><th>Status</th><th>Select or edit</th></tr></thead><tbody>${visible.map(event => {
table.innerHTML = `<table class="data-table" aria-label="Planned purchase tranches"><thead><tr><th>Scheduled (UTC)</th><th>Type</th><th>Term</th><th>Payment</th><th>Budget</th><th>Status</th><th>Select or edit</th></tr></thead><tbody>${visible.map(event => {
const reason = ladderEditReason(event);
return `<tr class="${event.id === selectedID ? 'ladder-selected' : ''}"><td>${escapeHtml(event.scheduled_date)}</td><td>${escapeHtml(event.layer_type)}</td><td>${escapeHtml(event.term)}</td><td>${escapeHtml(event.payment_option)}</td><td>${escapeHtml(event.amount_usd_hr)}</td><td>${escapeHtml(event.status)}</td><td><button type="button" data-event="${escapeHtml(event.id)}" ${event.id === selectedID ? 'aria-current="true"' : ''}>${!reason && canAccess('update', 'config') ? 'Edit' : 'Select'}</button>${reason ? `<span>${escapeHtml(reason)}</span>` : ''}</td></tr>`;
return `<tr class="${event.id === selectedID ? 'ladder-selected' : ''}"><td>${escapeHtml(formatLadderDateTime(event.scheduled_date))}</td><td>${escapeHtml(ladderLabel('layer_type', event.layer_type))}</td><td>${escapeHtml(ladderLabel('term', event.term))}</td><td>${escapeHtml(ladderLabel('payment_option', event.payment_option))}</td><td>${escapeHtml(formatLadderAmount(event.amount_usd_hr))}</td><td>${escapeHtml(ladderLabel('status', event.status))}</td><td><button type="button" data-event="${escapeHtml(event.id)}" ${event.id === selectedID ? 'aria-current="true"' : ''}>${!reason && canAccess('update', 'config') ? 'Edit' : 'Select'}</button>${reason ? `<span>${escapeHtml(reason)}</span>` : ''}</td></tr>`;
}).join('')}</tbody></table>`;
table.querySelectorAll<HTMLButtonElement>('[data-event]').forEach(button => button.addEventListener('click', () => {
const event = visible.find(item => item.id === button.dataset['event']);
Expand Down
Loading
Loading