From 7b0f27e57d4c2ad90da9d517d52daf08e69488d9 Mon Sep 17 00:00:00 2001 From: amh1k Date: Tue, 29 Sep 2026 20:35:29 +0500 Subject: [PATCH 1/6] feat(ui): separate benchmark form and show target context Signed-off-by: amh1k --- src/BenchmarkForm.tsx | 57 ++++++++++++ src/BenchmarkTab.tsx | 186 +++++++++++++++++++++++++++++++++++++++ src/benchmarkForm.ts | 17 ++++ src/main.tsx | 196 ++---------------------------------------- 4 files changed, 266 insertions(+), 190 deletions(-) create mode 100644 src/BenchmarkForm.tsx create mode 100644 src/BenchmarkTab.tsx create mode 100644 src/benchmarkForm.ts diff --git a/src/BenchmarkForm.tsx b/src/BenchmarkForm.tsx new file mode 100644 index 0000000..cd1951f --- /dev/null +++ b/src/BenchmarkForm.tsx @@ -0,0 +1,57 @@ +import type { CSSProperties } from 'react'; +import type { PluginApi } from '@openeverest/plugin-sdk'; + +const styles: Record = { + card: { border: '1px solid #d9d9d9', borderRadius: 8, padding: 20, background: '#fff' }, + heading: { margin: '0 0 16px', fontSize: 18, fontWeight: 600 }, + fieldStack: { display: 'grid', gap: 14 }, + label: { display: 'grid', gap: 6, fontSize: 14, color: '#444' }, + control: { padding: '9px 10px', border: '1px solid #bbb', borderRadius: 4, background: '#f5f5f5' }, + actionRow: { display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }, + button: { padding: '9px 16px', border: 0, borderRadius: 4, color: '#777', background: '#ddd' }, + badge: { padding: '3px 9px', border: '1px solid #bbb', borderRadius: 12, fontSize: 12, color: '#555' }, + caption: { margin: 0, color: '#666', fontSize: 12 }, +}; + +type BenchmarkFormProps = { + react: PluginApi['React']; +}; + +export function BenchmarkForm({ react }: BenchmarkFormProps) { + return react.createElement( + 'section', + { style: styles.card }, + react.createElement('h3', { style: styles.heading }, 'Run configuration'), + react.createElement( + 'div', + { style: styles.fieldStack }, + react.createElement( + 'label', + { style: styles.label }, + 'Profile', + react.createElement( + 'select', + { style: styles.control, value: 'smoke', disabled: true, onChange: () => undefined }, + react.createElement('option', { value: 'smoke' }, 'Smoke test') + ) + ), + react.createElement( + 'label', + { style: styles.label }, + 'Duration', + react.createElement('input', { style: styles.control, value: '30 seconds', disabled: true, readOnly: true }) + ), + react.createElement( + 'div', + { style: styles.actionRow }, + react.createElement('button', { type: 'button', style: styles.button, disabled: true }, 'Start benchmark'), + react.createElement('span', { style: styles.badge }, 'Runner not implemented yet') + ), + react.createElement( + 'p', + { style: styles.caption }, + 'The scaffold reserves this workflow for the PostgreSQL benchmark runner.' + ) + ) + ); +} diff --git a/src/BenchmarkTab.tsx b/src/BenchmarkTab.tsx new file mode 100644 index 0000000..0d09180 --- /dev/null +++ b/src/BenchmarkTab.tsx @@ -0,0 +1,186 @@ +import type { CSSProperties } from 'react'; +import type { ClusterDetailTabProps, PluginApi, PluginRouteProps } from '@openeverest/plugin-sdk'; +import { BenchmarkForm } from './BenchmarkForm'; + +type RunSummary = { + id: string; + status: string; + profile: string; + createdAt: string; +}; + +const styles: Record = { + page: { padding: 24, maxWidth: 1100 }, + heading: { margin: 0, fontSize: 24, fontWeight: 600 }, + subtitle: { margin: '4px 0 24px', color: '#666' }, + stack: { display: 'grid', gap: 16 }, + targetContext: { border: '1px solid #d9d9d9', borderRadius: 8, padding: 16, background: '#fff' }, + targetHeading: { margin: '0 0 12px', fontSize: 16, fontWeight: 600 }, + targetGrid: { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: 12, margin: 0 }, + targetLabel: { margin: 0, color: '#666', fontSize: 12 }, + targetValue: { margin: '4px 0 0', fontSize: 14, overflowWrap: 'anywhere' }, + card: { border: '1px solid #d9d9d9', borderRadius: 8, padding: 20, background: '#fff' }, + cardHeading: { margin: '0 0 16px', fontSize: 18, fontWeight: 600 }, + caption: { margin: 0, color: '#666', fontSize: 12 }, + notice: { padding: 14, borderRadius: 6, color: '#345', background: '#eef5ff' }, + warning: { marginBottom: 16, padding: 14, borderRadius: 6, color: '#7a4b00', background: '#fff4d6' }, + table: { width: '100%', borderCollapse: 'collapse', fontSize: 14 }, + tableCell: { padding: '10px 8px', borderBottom: '1px solid #ddd', textAlign: 'left' }, +}; + +type BenchmarkTabProps = ClusterDetailTabProps & { + react: PluginApi['React']; + fetch: PluginApi['fetch']; +}; + +type BenchmarkPageProps = PluginRouteProps & { + react: PluginApi['React']; +}; + +async function apiFetch(fetcher: PluginApi['fetch'], path: string): Promise { + const response = await fetcher(`/api${path}`); + if (!response.ok) { + const message = await response.text().catch(() => ''); + throw new Error(message || `Request failed with HTTP ${response.status}`); + } + return response.json(); +} + +function PageHeader({ react, subtitle }: { react: PluginApi['React']; subtitle: string }) { + return react.createElement( + 'header', + null, + react.createElement('h2', { style: styles.heading }, 'Performance Benchmark'), + react.createElement('p', { style: styles.subtitle }, subtitle) + ); +} + +function RunHistory({ + react, + runs, + loading, +}: { + react: PluginApi['React']; + runs: RunSummary[]; + loading: boolean; +}) { + return react.createElement( + 'section', + { style: styles.card }, + react.createElement('h3', { style: styles.cardHeading }, 'Run history'), + loading + ? react.createElement('p', { style: styles.caption }, 'Loading runs…') + : runs.length === 0 + ? react.createElement('p', { style: styles.caption }, 'No benchmark runs yet.') + : react.createElement( + 'table', + { style: styles.table }, + react.createElement( + 'thead', + null, + react.createElement( + 'tr', + null, + ...['Run', 'Profile', 'Status', 'Created'].map((heading) => + react.createElement('th', { key: heading, style: styles.tableCell }, heading) + ) + ) + ), + react.createElement( + 'tbody', + null, + ...runs.map((run) => + react.createElement( + 'tr', + { key: run.id }, + react.createElement('td', { style: styles.tableCell }, run.id), + react.createElement('td', { style: styles.tableCell }, run.profile), + react.createElement('td', { style: styles.tableCell }, run.status), + react.createElement('td', { style: styles.tableCell }, new Date(run.createdAt).toLocaleString()) + ) + ) + ) + ) + ); +} + +export function BenchmarkTab(props: BenchmarkTabProps) { + const { react } = props; + const [runs, setRuns] = react.useState([]); + const [loading, setLoading] = react.useState(true); + const [error, setError] = react.useState(null); + + react.useEffect(() => { + let active = true; + setLoading(true); + setError(null); + + apiFetch(props.fetch, '/runs') + .then((data) => { + if (active) setRuns((data as { runs?: RunSummary[] }).runs ?? []); + }) + .catch((reason: unknown) => { + if (active) setError(reason instanceof Error ? reason.message : String(reason)); + }) + .finally(() => { + if (active) setLoading(false); + }); + + return () => { + active = false; + }; + }, [props.fetch, props.instanceName, props.namespace]); + + return react.createElement( + 'div', + { style: styles.page }, + react.createElement(PageHeader, { + react, + subtitle: 'Configure a benchmark for the selected PostgreSQL instance.', + }), + error && react.createElement('div', { style: styles.warning }, error), + react.createElement( + 'div', + { style: styles.stack }, + react.createElement( + 'section', + { style: styles.targetContext, 'aria-label': 'Selected database target' }, + react.createElement('h3', { style: styles.targetHeading }, 'Database target'), + react.createElement( + 'dl', + { style: styles.targetGrid }, + react.createElement( + 'div', + null, + react.createElement('dt', { style: styles.targetLabel }, 'Instance'), + react.createElement('dd', { style: styles.targetValue }, props.instanceName) + ), + react.createElement( + 'div', + null, + react.createElement('dt', { style: styles.targetLabel }, 'Namespace'), + react.createElement('dd', { style: styles.targetValue }, props.namespace) + ) + ) + ), + react.createElement(BenchmarkForm, { react }), + react.createElement(RunHistory, { react, runs, loading }) + ) + ); +} + +export function BenchmarkPage({ react }: BenchmarkPageProps) { + return react.createElement( + 'div', + { style: styles.page }, + react.createElement(PageHeader, { + react, + subtitle: 'Open a PostgreSQL cluster to configure and run a benchmark.', + }), + react.createElement( + 'div', + { style: styles.notice }, + 'The standalone benchmark workflow is planned after the MVP.' + ) + ); +} diff --git a/src/benchmarkForm.ts b/src/benchmarkForm.ts new file mode 100644 index 0000000..ac2deec --- /dev/null +++ b/src/benchmarkForm.ts @@ -0,0 +1,17 @@ +export type BenchmarkFormValues = { + database: string; + durationSeconds: string; + clients: string; + threads: string; + scale: string; + initialize: boolean; +}; + +export const initialBenchmarkFormValues: BenchmarkFormValues = { + database: '', + durationSeconds: '30', + clients: '1', + threads: '1', + scale: '1', + initialize: false, +}; diff --git a/src/main.tsx b/src/main.tsx index 1238e41..42f4ade 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -4,196 +4,11 @@ import type { PluginRegisterFn, PluginRouteProps, } from '@openeverest/plugin-sdk'; -import type { CSSProperties } from 'react'; - -// React is supplied by OpenEverest at runtime, as required by the plugin SDK. -let React: PluginApi['React']; -let pluginFetch: PluginApi['fetch']; - -type RunSummary = { - id: string; - status: string; - profile: string; - createdAt: string; -}; - -const styles: Record = { - page: { padding: 24, maxWidth: 1100 }, - heading: { margin: 0, fontSize: 24, fontWeight: 600 }, - subtitle: { margin: '4px 0 24px', color: '#666' }, - stack: { display: 'grid', gap: 16 }, - card: { border: '1px solid #d9d9d9', borderRadius: 8, padding: 20, background: '#fff' }, - cardHeading: { margin: '0 0 16px', fontSize: 18, fontWeight: 600 }, - fieldStack: { display: 'grid', gap: 14 }, - label: { display: 'grid', gap: 6, fontSize: 14, color: '#444' }, - control: { padding: '9px 10px', border: '1px solid #bbb', borderRadius: 4, background: '#f5f5f5' }, - actionRow: { display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }, - button: { padding: '9px 16px', border: 0, borderRadius: 4, color: '#777', background: '#ddd' }, - badge: { padding: '3px 9px', border: '1px solid #bbb', borderRadius: 12, fontSize: 12, color: '#555' }, - caption: { margin: 0, color: '#666', fontSize: 12 }, - notice: { padding: 14, borderRadius: 6, color: '#345', background: '#eef5ff' }, - warning: { marginBottom: 16, padding: 14, borderRadius: 6, color: '#7a4b00', background: '#fff4d6' }, - table: { width: '100%', borderCollapse: 'collapse', fontSize: 14 }, - tableCell: { padding: '10px 8px', borderBottom: '1px solid #ddd', textAlign: 'left' }, -}; - -async function apiFetch(path: string, options?: RequestInit): Promise { - const response = await pluginFetch(`/api${path}`, options); - if (!response.ok) { - const message = await response.text().catch(() => ''); - throw new Error(message || `Request failed with HTTP ${response.status}`); - } - return response.json(); -} - -const PageHeader = ({ subtitle }: { subtitle: string }) => - React.createElement( - 'header', - null, - React.createElement('h2', { style: styles.heading }, 'Performance Benchmark'), - React.createElement('p', { style: styles.subtitle }, subtitle) - ); - -const RunConfiguration = () => - React.createElement( - 'section', - { style: styles.card }, - React.createElement('h3', { style: styles.cardHeading }, 'Run configuration'), - React.createElement( - 'div', - { style: styles.fieldStack }, - React.createElement( - 'label', - { style: styles.label }, - 'Profile', - React.createElement( - 'select', - { style: styles.control, value: 'smoke', disabled: true, onChange: () => undefined }, - React.createElement('option', { value: 'smoke' }, 'Smoke test') - ) - ), - React.createElement( - 'label', - { style: styles.label }, - 'Duration', - React.createElement('input', { style: styles.control, value: '30 seconds', disabled: true, readOnly: true }) - ), - React.createElement( - 'div', - { style: styles.actionRow }, - React.createElement('button', { type: 'button', style: styles.button, disabled: true }, 'Start benchmark'), - React.createElement('span', { style: styles.badge }, 'Runner not implemented yet') - ), - React.createElement( - 'p', - { style: styles.caption }, - 'The scaffold reserves this workflow for the PostgreSQL benchmark runner.' - ) - ) - ); - -const RunHistory = ({ runs, loading }: { runs: RunSummary[]; loading: boolean }) => - React.createElement( - 'section', - { style: styles.card }, - React.createElement('h3', { style: styles.cardHeading }, 'Run history'), - loading - ? React.createElement('p', { style: styles.caption }, 'Loading runs…') - : runs.length === 0 - ? React.createElement('p', { style: styles.caption }, 'No benchmark runs yet.') - : React.createElement( - 'table', - { style: styles.table }, - React.createElement( - 'thead', - null, - React.createElement( - 'tr', - null, - ...['Run', 'Profile', 'Status', 'Created'].map((heading) => - React.createElement('th', { key: heading, style: styles.tableCell }, heading) - ) - ) - ), - React.createElement( - 'tbody', - null, - ...runs.map((run) => - React.createElement( - 'tr', - { key: run.id }, - React.createElement('td', { style: styles.tableCell }, run.id), - React.createElement('td', { style: styles.tableCell }, run.profile), - React.createElement('td', { style: styles.tableCell }, run.status), - React.createElement('td', { style: styles.tableCell }, new Date(run.createdAt).toLocaleString()) - ) - ) - ) - ) - ); - -const BenchmarkTab = (props: ClusterDetailTabProps) => { - const [runs, setRuns] = React.useState([]); - const [loading, setLoading] = React.useState(true); - const [error, setError] = React.useState(null); - - React.useEffect(() => { - let active = true; - setLoading(true); - setError(null); - - apiFetch('/runs') - .then((data) => { - if (active) setRuns((data as { runs?: RunSummary[] }).runs ?? []); - }) - .catch((reason: unknown) => { - if (active) setError(reason instanceof Error ? reason.message : String(reason)); - }) - .finally(() => { - if (active) setLoading(false); - }); - - return () => { - active = false; - }; - }, [props.instanceName, props.namespace]); - - const cluster = props.cluster as { metadata?: { name?: string }; name?: string }; - const clusterName = cluster?.metadata?.name ?? cluster?.name ?? props.instanceName; - - return React.createElement( - 'div', - { style: styles.page }, - React.createElement(PageHeader, { - subtitle: `PostgreSQL benchmark workspace for ${clusterName} in ${props.namespace}`, - }), - error && React.createElement('div', { style: styles.warning }, error), - React.createElement( - 'div', - { style: styles.stack }, - React.createElement(RunConfiguration), - React.createElement(RunHistory, { runs, loading }) - ) - ); -}; - -const BenchmarkPage = (_props: PluginRouteProps) => - React.createElement( - 'div', - { style: styles.page }, - React.createElement(PageHeader, { - subtitle: 'Open a PostgreSQL cluster to configure and run a benchmark.', - }), - React.createElement( - 'div', - { style: styles.notice }, - 'The standalone benchmark workflow is planned after the MVP.' - ) - ); +import { BenchmarkPage, BenchmarkTab } from './BenchmarkTab'; const register: PluginRegisterFn = (api: PluginApi) => { - React = api.React; - pluginFetch = api.fetch.bind(api); + const react = api.React; + const pluginFetch = api.fetch.bind(api); api.registerExtension({ type: 'sidebarItem', @@ -203,7 +18,7 @@ const register: PluginRegisterFn = (api: PluginApi) => { api.registerExtension({ type: 'route', label: 'Performance Benchmark', - component: BenchmarkPage, + component: (props: PluginRouteProps) => react.createElement(BenchmarkPage, { ...props, react }), }); api.registerExtension({ @@ -211,7 +26,8 @@ const register: PluginRegisterFn = (api: PluginApi) => { label: 'Performance Benchmark', path: 'performance-benchmark', providers: ['provider-cloudnative-pg', 'provider-percona-postgresql'], - component: BenchmarkTab, + component: (props: ClusterDetailTabProps) => + react.createElement(BenchmarkTab, { ...props, react, fetch: pluginFetch }), }); }; From 7e372e8e20715f22cbdaa1f836c38efff4c669af Mon Sep 17 00:00:00 2001 From: amh1k Date: Tue, 29 Sep 2026 22:28:32 +0500 Subject: [PATCH 2/6] feat(ui): validate benchmark configuration Signed-off-by: amh1k --- package.json | 1 + src/BenchmarkForm.tsx | 109 ++++++++++++++++++++++++++++++----- src/benchmarkForm.ts | 53 +++++++++++++++++ tests/benchmarkForm.test.mjs | 39 +++++++++++++ 4 files changed, 187 insertions(+), 15 deletions(-) create mode 100644 tests/benchmarkForm.test.mjs diff --git a/package.json b/package.json index e23f2bd..e2c7da6 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "scripts": { "dev": "vite", "build": "tsc --noEmit && vite build", + "test:form": "tsc src/benchmarkForm.ts --target ES2020 --module ESNext --moduleResolution Bundler --outDir dist/form-test && node tests/benchmarkForm.test.mjs", "preview": "vite preview --port 3001" }, "dependencies": { diff --git a/src/BenchmarkForm.tsx b/src/BenchmarkForm.tsx index cd1951f..403fc0f 100644 --- a/src/BenchmarkForm.tsx +++ b/src/BenchmarkForm.tsx @@ -1,12 +1,19 @@ import type { CSSProperties } from 'react'; import type { PluginApi } from '@openeverest/plugin-sdk'; +import { initialBenchmarkFormValues } from './benchmarkForm'; +import { validateBenchmarkForm } from './benchmarkForm'; +import type { BenchmarkFormField, BenchmarkFormValues } from './benchmarkForm'; const styles: Record = { card: { border: '1px solid #d9d9d9', borderRadius: 8, padding: 20, background: '#fff' }, heading: { margin: '0 0 16px', fontSize: 18, fontWeight: 600 }, fieldStack: { display: 'grid', gap: 14 }, + fieldGrid: { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: 14 }, label: { display: 'grid', gap: 6, fontSize: 14, color: '#444' }, control: { padding: '9px 10px', border: '1px solid #bbb', borderRadius: 4, background: '#f5f5f5' }, + checkboxLabel: { display: 'flex', alignItems: 'center', gap: 8, fontSize: 14, color: '#444' }, + error: { margin: 0, color: '#b42318', fontSize: 12 }, + warning: { margin: 0, padding: 12, borderRadius: 6, color: '#7a4b00', background: '#fff4d6', fontSize: 13 }, actionRow: { display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }, button: { padding: '9px 16px', border: 0, borderRadius: 4, color: '#777', background: '#ddd' }, badge: { padding: '3px 9px', border: '1px solid #bbb', borderRadius: 12, fontSize: 12, color: '#555' }, @@ -18,39 +25,111 @@ type BenchmarkFormProps = { }; export function BenchmarkForm({ react }: BenchmarkFormProps) { + const [values, setValues] = react.useState({ ...initialBenchmarkFormValues }); + const [touched, setTouched] = react.useState>>({}); + const errors = validateBenchmarkForm(values); + const updateValue = (key: K, value: BenchmarkFormValues[K]) => { + setValues((current) => ({ ...current, [key]: value })); + setTouched((current) => ({ ...current, [key]: true })); + }; + const fieldError = (field: BenchmarkFormField) => (touched[field] ? errors[field] : undefined); + const errorMessage = (field: BenchmarkFormField) => { + const message = fieldError(field); + return message + ? react.createElement('span', { id: `benchmark-${field}-error`, style: styles.error, role: 'alert' }, message) + : null; + }; + return react.createElement( - 'section', - { style: styles.card }, + 'form', + { + style: styles.card, + onSubmit: (event: { preventDefault: () => void }) => { + event.preventDefault(); + setTouched({ + database: true, + durationSeconds: true, + clients: true, + threads: true, + scale: true, + initialize: true, + }); + }, + }, react.createElement('h3', { style: styles.heading }, 'Run configuration'), react.createElement( 'div', { style: styles.fieldStack }, - react.createElement( - 'label', - { style: styles.label }, - 'Profile', + react.createElement('div', { style: styles.fieldGrid }, react.createElement( - 'select', - { style: styles.control, value: 'smoke', disabled: true, onChange: () => undefined }, - react.createElement('option', { value: 'smoke' }, 'Smoke test') + 'label', + { htmlFor: 'benchmark-database', style: styles.label }, + 'Database', + react.createElement('input', { + id: 'benchmark-database', + name: 'database', + type: 'text', + style: styles.control, + value: values.database, + 'aria-invalid': Boolean(fieldError('database')), + 'aria-describedby': fieldError('database') ? 'benchmark-database-error' : undefined, + onChange: (event: { currentTarget: { value: string } }) => updateValue('database', event.currentTarget.value), + }), + errorMessage('database') + ), + ...([ + ['durationSeconds', 'Duration (seconds)'], + ['clients', 'Clients'], + ['threads', 'Threads'], + ['scale', 'Scale'], + ] as const).map(([field, label]) => + react.createElement( + 'label', + { key: field, htmlFor: `benchmark-${field}`, style: styles.label }, + label, + react.createElement('input', { + id: `benchmark-${field}`, + name: field, + type: 'number', + min: 1, + step: 1, + style: styles.control, + value: values[field], + 'aria-invalid': Boolean(fieldError(field)), + 'aria-describedby': fieldError(field) ? `benchmark-${field}-error` : undefined, + onChange: (event: { currentTarget: { value: string } }) => updateValue(field, event.currentTarget.value), + }), + errorMessage(field) + ) ) ), react.createElement( 'label', - { style: styles.label }, - 'Duration', - react.createElement('input', { style: styles.control, value: '30 seconds', disabled: true, readOnly: true }) + { htmlFor: 'benchmark-initialize', style: styles.checkboxLabel }, + react.createElement('input', { + id: 'benchmark-initialize', + name: 'initialize', + type: 'checkbox', + checked: values.initialize, + onChange: (event: { currentTarget: { checked: boolean } }) => updateValue('initialize', event.currentTarget.checked), + }), + 'Initialize pgbench tables' + ), + values.initialize && react.createElement( + 'p', + { style: styles.warning, role: 'alert' }, + 'Initializing will drop and recreate the pgbench tables in the selected database.' ), react.createElement( 'div', { style: styles.actionRow }, - react.createElement('button', { type: 'button', style: styles.button, disabled: true }, 'Start benchmark'), - react.createElement('span', { style: styles.badge }, 'Runner not implemented yet') + react.createElement('button', { type: 'submit', style: styles.button, disabled: true }, 'Start benchmark'), + react.createElement('span', { style: styles.badge }, 'Run submission is not implemented yet') ), react.createElement( 'p', { style: styles.caption }, - 'The scaffold reserves this workflow for the PostgreSQL benchmark runner.' + 'Configure the run locally; benchmark submission will be added in a later step.' ) ) ); diff --git a/src/benchmarkForm.ts b/src/benchmarkForm.ts index ac2deec..32a92f8 100644 --- a/src/benchmarkForm.ts +++ b/src/benchmarkForm.ts @@ -15,3 +15,56 @@ export const initialBenchmarkFormValues: BenchmarkFormValues = { scale: '1', initialize: false, }; + +export type BenchmarkFormField = Exclude; +export type BenchmarkFormErrors = Partial>; + +const maxInt32 = 2_147_483_647; + +export function validateBenchmarkForm(values: BenchmarkFormValues): BenchmarkFormErrors { + const errors: BenchmarkFormErrors = {}; + const database = values.database.trim(); + + if (!database) { + errors.database = 'Database name is required.'; + } else if ( + database.includes('=') || + database.toLowerCase().startsWith('postgres://') || + database.toLowerCase().startsWith('postgresql://') + ) { + errors.database = 'Enter a database name, not a connection string.'; + } + + const numericFields: Array<[BenchmarkFormField, string]> = [ + ['durationSeconds', 'Duration'], + ['clients', 'Clients'], + ['threads', 'Threads'], + ['scale', 'Scale'], + ]; + const parsedValues: Partial> = {}; + + for (const [field, label] of numericFields) { + const value = values[field].trim(); + if (!/^\d+$/.test(value)) { + errors[field] = `${label} must be a positive whole number.`; + continue; + } + + const parsed = Number(value); + if (parsed < 1) { + errors[field] = `${label} must be greater than zero.`; + } else if (parsed > maxInt32) { + errors[field] = `${label} must not exceed ${maxInt32}.`; + } else { + parsedValues[field] = parsed; + } + } + + const clients = parsedValues.clients; + const threads = parsedValues.threads; + if (clients !== undefined && threads !== undefined && threads > clients) { + errors.threads = 'Threads cannot exceed clients.'; + } + + return errors; +} diff --git a/tests/benchmarkForm.test.mjs b/tests/benchmarkForm.test.mjs new file mode 100644 index 0000000..f4e2551 --- /dev/null +++ b/tests/benchmarkForm.test.mjs @@ -0,0 +1,39 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { initialBenchmarkFormValues, validateBenchmarkForm } from '../dist/form-test/benchmarkForm.js'; + +const validValues = { ...initialBenchmarkFormValues, database: 'benchdb' }; + +test('accepts valid form values and defaults initialization to false', () => { + assert.equal(initialBenchmarkFormValues.initialize, false); + assert.deepEqual(validateBenchmarkForm(validValues), {}); +}); + +test('requires a database name and rejects connection strings', () => { + assert.equal(validateBenchmarkForm({ ...validValues, database: ' ' }).database, 'Database name is required.'); + assert.equal(validateBenchmarkForm({ ...validValues, database: 'postgres://user:pass@host/db' }).database, + 'Enter a database name, not a connection string.'); + assert.equal(validateBenchmarkForm({ ...validValues, database: 'host=db' }).database, + 'Enter a database name, not a connection string.'); +}); + +test('requires positive whole numbers for all numeric fields', () => { + for (const invalid of ['', '0', '-1', '1.5', 'abc']) { + for (const field of ['durationSeconds', 'clients', 'threads', 'scale']) { + assert.ok(validateBenchmarkForm({ ...validValues, [field]: invalid })[field], `${field} should reject ${invalid}`); + } + } +}); + +test('rejects numeric values above the backend integer limit', () => { + for (const field of ['durationSeconds', 'clients', 'threads', 'scale']) { + assert.match(validateBenchmarkForm({ ...validValues, [field]: '2147483648' })[field], /2147483647/); + } +}); + +test('requires threads not to exceed clients', () => { + assert.equal( + validateBenchmarkForm({ ...validValues, clients: '2', threads: '3' }).threads, + 'Threads cannot exceed clients.' + ); +}); From 4cfd8bd9f380b90d44bddffa5f31a534de0e43e0 Mon Sep 17 00:00:00 2001 From: amh1k Date: Wed, 30 Sep 2026 18:44:11 +0500 Subject: [PATCH 3/6] feat(ui): decouple benchmark form from run API Signed-off-by: amh1k --- src/BenchmarkTab.tsx | 106 ++----------------------------------------- src/main.tsx | 3 +- 2 files changed, 5 insertions(+), 104 deletions(-) diff --git a/src/BenchmarkTab.tsx b/src/BenchmarkTab.tsx index 0d09180..2998243 100644 --- a/src/BenchmarkTab.tsx +++ b/src/BenchmarkTab.tsx @@ -2,50 +2,27 @@ import type { CSSProperties } from 'react'; import type { ClusterDetailTabProps, PluginApi, PluginRouteProps } from '@openeverest/plugin-sdk'; import { BenchmarkForm } from './BenchmarkForm'; -type RunSummary = { - id: string; - status: string; - profile: string; - createdAt: string; -}; - const styles: Record = { page: { padding: 24, maxWidth: 1100 }, heading: { margin: 0, fontSize: 24, fontWeight: 600 }, subtitle: { margin: '4px 0 24px', color: '#666' }, stack: { display: 'grid', gap: 16 }, - targetContext: { border: '1px solid #d9d9d9', borderRadius: 8, padding: 16, background: '#fff' }, - targetHeading: { margin: '0 0 12px', fontSize: 16, fontWeight: 600 }, - targetGrid: { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: 12, margin: 0 }, + targetContext: { border: '1px solid #d9d9d9', borderRadius: 8, padding: 14, background: '#fff' }, + targetHeading: { margin: '0 0 10px', fontSize: 16, fontWeight: 600 }, + targetGrid: { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: 14, margin: 0 }, targetLabel: { margin: 0, color: '#666', fontSize: 12 }, targetValue: { margin: '4px 0 0', fontSize: 14, overflowWrap: 'anywhere' }, - card: { border: '1px solid #d9d9d9', borderRadius: 8, padding: 20, background: '#fff' }, - cardHeading: { margin: '0 0 16px', fontSize: 18, fontWeight: 600 }, - caption: { margin: 0, color: '#666', fontSize: 12 }, notice: { padding: 14, borderRadius: 6, color: '#345', background: '#eef5ff' }, - warning: { marginBottom: 16, padding: 14, borderRadius: 6, color: '#7a4b00', background: '#fff4d6' }, - table: { width: '100%', borderCollapse: 'collapse', fontSize: 14 }, - tableCell: { padding: '10px 8px', borderBottom: '1px solid #ddd', textAlign: 'left' }, }; type BenchmarkTabProps = ClusterDetailTabProps & { react: PluginApi['React']; - fetch: PluginApi['fetch']; }; type BenchmarkPageProps = PluginRouteProps & { react: PluginApi['React']; }; -async function apiFetch(fetcher: PluginApi['fetch'], path: string): Promise { - const response = await fetcher(`/api${path}`); - if (!response.ok) { - const message = await response.text().catch(() => ''); - throw new Error(message || `Request failed with HTTP ${response.status}`); - } - return response.json(); -} - function PageHeader({ react, subtitle }: { react: PluginApi['React']; subtitle: string }) { return react.createElement( 'header', @@ -55,81 +32,8 @@ function PageHeader({ react, subtitle }: { react: PluginApi['React']; subtitle: ); } -function RunHistory({ - react, - runs, - loading, -}: { - react: PluginApi['React']; - runs: RunSummary[]; - loading: boolean; -}) { - return react.createElement( - 'section', - { style: styles.card }, - react.createElement('h3', { style: styles.cardHeading }, 'Run history'), - loading - ? react.createElement('p', { style: styles.caption }, 'Loading runs…') - : runs.length === 0 - ? react.createElement('p', { style: styles.caption }, 'No benchmark runs yet.') - : react.createElement( - 'table', - { style: styles.table }, - react.createElement( - 'thead', - null, - react.createElement( - 'tr', - null, - ...['Run', 'Profile', 'Status', 'Created'].map((heading) => - react.createElement('th', { key: heading, style: styles.tableCell }, heading) - ) - ) - ), - react.createElement( - 'tbody', - null, - ...runs.map((run) => - react.createElement( - 'tr', - { key: run.id }, - react.createElement('td', { style: styles.tableCell }, run.id), - react.createElement('td', { style: styles.tableCell }, run.profile), - react.createElement('td', { style: styles.tableCell }, run.status), - react.createElement('td', { style: styles.tableCell }, new Date(run.createdAt).toLocaleString()) - ) - ) - ) - ) - ); -} - export function BenchmarkTab(props: BenchmarkTabProps) { const { react } = props; - const [runs, setRuns] = react.useState([]); - const [loading, setLoading] = react.useState(true); - const [error, setError] = react.useState(null); - - react.useEffect(() => { - let active = true; - setLoading(true); - setError(null); - - apiFetch(props.fetch, '/runs') - .then((data) => { - if (active) setRuns((data as { runs?: RunSummary[] }).runs ?? []); - }) - .catch((reason: unknown) => { - if (active) setError(reason instanceof Error ? reason.message : String(reason)); - }) - .finally(() => { - if (active) setLoading(false); - }); - - return () => { - active = false; - }; - }, [props.fetch, props.instanceName, props.namespace]); return react.createElement( 'div', @@ -138,7 +42,6 @@ export function BenchmarkTab(props: BenchmarkTabProps) { react, subtitle: 'Configure a benchmark for the selected PostgreSQL instance.', }), - error && react.createElement('div', { style: styles.warning }, error), react.createElement( 'div', { style: styles.stack }, @@ -163,8 +66,7 @@ export function BenchmarkTab(props: BenchmarkTabProps) { ) ) ), - react.createElement(BenchmarkForm, { react }), - react.createElement(RunHistory, { react, runs, loading }) + react.createElement(BenchmarkForm, { react }) ) ); } diff --git a/src/main.tsx b/src/main.tsx index 42f4ade..65e9631 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -8,7 +8,6 @@ import { BenchmarkPage, BenchmarkTab } from './BenchmarkTab'; const register: PluginRegisterFn = (api: PluginApi) => { const react = api.React; - const pluginFetch = api.fetch.bind(api); api.registerExtension({ type: 'sidebarItem', @@ -27,7 +26,7 @@ const register: PluginRegisterFn = (api: PluginApi) => { path: 'performance-benchmark', providers: ['provider-cloudnative-pg', 'provider-percona-postgresql'], component: (props: ClusterDetailTabProps) => - react.createElement(BenchmarkTab, { ...props, react, fetch: pluginFetch }), + react.createElement(BenchmarkTab, { ...props, react }), }); }; From 47e9026ec77d81d51fddb8d4db2fc78ef03ab9bb Mon Sep 17 00:00:00 2001 From: amh1k Date: Sun, 4 Oct 2026 18:36:52 +0500 Subject: [PATCH 4/6] fix(ui): rename form validation module to avoid case-insensitive filename collisions Signed-off-by: amh1k --- package.json | 2 +- src/BenchmarkForm.tsx | 6 +++--- src/{benchmarkForm.ts => benchmarkFormValidation.ts} | 0 tests/benchmarkForm.test.mjs | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) rename src/{benchmarkForm.ts => benchmarkFormValidation.ts} (100%) diff --git a/package.json b/package.json index e2c7da6..ada0d11 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "scripts": { "dev": "vite", "build": "tsc --noEmit && vite build", - "test:form": "tsc src/benchmarkForm.ts --target ES2020 --module ESNext --moduleResolution Bundler --outDir dist/form-test && node tests/benchmarkForm.test.mjs", + "test:form": "tsc src/benchmarkFormValidation.ts --target ES2020 --module ESNext --moduleResolution Bundler --outDir dist/form-test && node tests/benchmarkForm.test.mjs", "preview": "vite preview --port 3001" }, "dependencies": { diff --git a/src/BenchmarkForm.tsx b/src/BenchmarkForm.tsx index 403fc0f..76fcda4 100644 --- a/src/BenchmarkForm.tsx +++ b/src/BenchmarkForm.tsx @@ -1,8 +1,8 @@ import type { CSSProperties } from 'react'; import type { PluginApi } from '@openeverest/plugin-sdk'; -import { initialBenchmarkFormValues } from './benchmarkForm'; -import { validateBenchmarkForm } from './benchmarkForm'; -import type { BenchmarkFormField, BenchmarkFormValues } from './benchmarkForm'; +import { initialBenchmarkFormValues } from './benchmarkFormValidation'; +import { validateBenchmarkForm } from './benchmarkFormValidation'; +import type { BenchmarkFormField, BenchmarkFormValues } from './benchmarkFormValidation'; const styles: Record = { card: { border: '1px solid #d9d9d9', borderRadius: 8, padding: 20, background: '#fff' }, diff --git a/src/benchmarkForm.ts b/src/benchmarkFormValidation.ts similarity index 100% rename from src/benchmarkForm.ts rename to src/benchmarkFormValidation.ts diff --git a/tests/benchmarkForm.test.mjs b/tests/benchmarkForm.test.mjs index f4e2551..a571356 100644 --- a/tests/benchmarkForm.test.mjs +++ b/tests/benchmarkForm.test.mjs @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { initialBenchmarkFormValues, validateBenchmarkForm } from '../dist/form-test/benchmarkForm.js'; +import { initialBenchmarkFormValues, validateBenchmarkForm } from '../dist/form-test/benchmarkFormValidation.js'; const validValues = { ...initialBenchmarkFormValues, database: 'benchdb' }; From abeacda5a9fc5e6b99eb1f723643b82598164dce Mon Sep 17 00:00:00 2001 From: amh1k Date: Wed, 30 Sep 2026 21:46:31 +0500 Subject: [PATCH 5/6] feat(ui): prepare benchmark run submission Signed-off-by: amh1k --- src/BenchmarkTab.tsx | 12 +++++- src/benchmarkApi.ts | 89 ++++++++++++++++++++++++++++++++++++++++++ src/benchmarkTarget.ts | 35 +++++++++++++++++ 3 files changed, 134 insertions(+), 2 deletions(-) create mode 100644 src/benchmarkApi.ts create mode 100644 src/benchmarkTarget.ts diff --git a/src/BenchmarkTab.tsx b/src/BenchmarkTab.tsx index 2998243..44aeae7 100644 --- a/src/BenchmarkTab.tsx +++ b/src/BenchmarkTab.tsx @@ -1,6 +1,7 @@ import type { CSSProperties } from 'react'; import type { ClusterDetailTabProps, PluginApi, PluginRouteProps } from '@openeverest/plugin-sdk'; import { BenchmarkForm } from './BenchmarkForm'; +import { targetFromClusterDetailProps } from './benchmarkTarget'; const styles: Record = { page: { padding: 24, maxWidth: 1100 }, @@ -34,6 +35,7 @@ function PageHeader({ react, subtitle }: { react: PluginApi['React']; subtitle: export function BenchmarkTab(props: BenchmarkTabProps) { const { react } = props; + const target = targetFromClusterDetailProps(props); return react.createElement( 'div', @@ -52,17 +54,23 @@ export function BenchmarkTab(props: BenchmarkTabProps) { react.createElement( 'dl', { style: styles.targetGrid }, + react.createElement( + 'div', + null, + react.createElement('dt', { style: styles.targetLabel }, 'Kubernetes cluster'), + react.createElement('dd', { style: styles.targetValue }, target.k8sCluster) + ), react.createElement( 'div', null, react.createElement('dt', { style: styles.targetLabel }, 'Instance'), - react.createElement('dd', { style: styles.targetValue }, props.instanceName) + react.createElement('dd', { style: styles.targetValue }, target.instance) ), react.createElement( 'div', null, react.createElement('dt', { style: styles.targetLabel }, 'Namespace'), - react.createElement('dd', { style: styles.targetValue }, props.namespace) + react.createElement('dd', { style: styles.targetValue }, target.namespace) ) ) ), diff --git a/src/benchmarkApi.ts b/src/benchmarkApi.ts new file mode 100644 index 0000000..e4e48af --- /dev/null +++ b/src/benchmarkApi.ts @@ -0,0 +1,89 @@ +import type { PluginApi } from '@openeverest/plugin-sdk'; +import type { BenchmarkTarget } from './benchmarkTarget'; + +export type CreateBenchmarkRunRequest = { + target: BenchmarkTarget; + database: string; + durationSeconds: number; + clients: number; + threads: number; + scale: number; + initialize: boolean; +}; + +export type CreateBenchmarkRunResponse = { + id: string; + status: 'running'; +}; + +export class BenchmarkApiError extends Error { + constructor( + message: string, + readonly status?: number + ) { + super(message); + this.name = 'BenchmarkApiError'; + } +} + +export async function createBenchmarkRun( + pluginFetch: PluginApi['fetch'], + request: CreateBenchmarkRunRequest +): Promise { + let response: Response; + try { + response = await pluginFetch('/api/runs', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(request), + }); + } catch { + throw new BenchmarkApiError('Could not connect to the benchmark service. Please try again.'); + } + + if (!response.ok) { + throw new BenchmarkApiError(await readErrorMessage(response), response.status); + } + + if (response.status !== 202) { + throw new BenchmarkApiError('The benchmark service returned an unexpected response.', response.status); + } + + try { + const body: unknown = await response.json(); + if ( + typeof body === 'object' && + body !== null && + 'id' in body && + typeof body.id === 'string' && + body.id.trim() !== '' && + 'status' in body && + body.status === 'running' + ) { + return { id: body.id, status: 'running' }; + } + } catch { + // Use the same safe message for invalid JSON and an unexpected response shape. + } + + throw new BenchmarkApiError('The benchmark service returned an invalid response.', response.status); +} + +async function readErrorMessage(response: Response): Promise { + try { + const body: unknown = await response.json(); + if ( + typeof body === 'object' && + body !== null && + 'error' in body && + typeof body.error === 'string' && + body.error.trim() !== '' + ) { + return body.error; + } + } catch { + // Fall back when the server response is not valid JSON. + } + + return `Could not start the benchmark (HTTP ${response.status}).`; +} diff --git a/src/benchmarkTarget.ts b/src/benchmarkTarget.ts new file mode 100644 index 0000000..6662b95 --- /dev/null +++ b/src/benchmarkTarget.ts @@ -0,0 +1,35 @@ +import type { ClusterDetailTabProps } from '@openeverest/plugin-sdk'; + +export type BenchmarkTarget = { + k8sCluster: string; + namespace: string; + instance: string; +}; + +type InstanceClusterContext = { + clusterName?: unknown; + spec?: { clusterName?: unknown }; +}; + +function nonEmptyString(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined; + const trimmed = value.trim(); + return trimmed ? trimmed : undefined; +} + +export function targetFromClusterDetailProps( + props: Pick +): BenchmarkTarget { + const instance = (props.cluster && typeof props.cluster === 'object' + ? props.cluster + : {}) as InstanceClusterContext; + + return { + k8sCluster: + nonEmptyString(instance.clusterName) ?? + nonEmptyString(instance.spec?.clusterName) ?? + 'main', + namespace: props.namespace, + instance: props.instanceName, + }; +} From 3b01ad1ffae1e9262f62682177884d160e55edc9 Mon Sep 17 00:00:00 2001 From: amh1k Date: Thu, 1 Oct 2026 08:49:17 +0500 Subject: [PATCH 6/6] feat(ui): add run submission feedback, timeout, and frontend tests Signed-off-by: amh1k --- .github/workflows/ci.yml | 4 +- package.json | 1 + src/BenchmarkForm.tsx | 37 +++++++++----- src/BenchmarkTab.tsx | 74 ++++++++++++++++++++++++++- src/benchmarkApi.ts | 64 ++++++++++++++++++++--- src/benchmarkFormValidation.ts | 22 ++++++++ src/main.tsx | 3 +- tests/benchmarkApi.test.mjs | 92 ++++++++++++++++++++++++++++++++++ 8 files changed, 273 insertions(+), 24 deletions(-) create mode 100644 tests/benchmarkApi.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0762b61..e54fdec 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,6 +20,8 @@ jobs: node-version: "20" cache: npm - run: npm ci + - run: npm run test:form + - run: npm run test:submission - run: npm run build backend: @@ -29,7 +31,7 @@ jobs: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 with: - go-version: "1.22" + go-version: "1.25" cache-dependency-path: backend/go.mod - name: Download modules run: cd backend && go mod download diff --git a/package.json b/package.json index ada0d11..ef237f9 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "dev": "vite", "build": "tsc --noEmit && vite build", "test:form": "tsc src/benchmarkFormValidation.ts --target ES2020 --module ESNext --moduleResolution Bundler --outDir dist/form-test && node tests/benchmarkForm.test.mjs", + "test:submission": "tsc src/benchmarkApi.ts --target ES2020 --module ESNext --moduleResolution Bundler --outDir dist/submission-test && node tests/benchmarkApi.test.mjs", "preview": "vite preview --port 3001" }, "dependencies": { diff --git a/src/BenchmarkForm.tsx b/src/BenchmarkForm.tsx index 76fcda4..ed3f870 100644 --- a/src/BenchmarkForm.tsx +++ b/src/BenchmarkForm.tsx @@ -1,7 +1,6 @@ import type { CSSProperties } from 'react'; import type { PluginApi } from '@openeverest/plugin-sdk'; -import { initialBenchmarkFormValues } from './benchmarkFormValidation'; -import { validateBenchmarkForm } from './benchmarkFormValidation'; +import { initialBenchmarkFormValues, validateBenchmarkForm } from './benchmarkFormValidation'; import type { BenchmarkFormField, BenchmarkFormValues } from './benchmarkFormValidation'; const styles: Record = { @@ -15,23 +14,28 @@ const styles: Record = { error: { margin: 0, color: '#b42318', fontSize: 12 }, warning: { margin: 0, padding: 12, borderRadius: 6, color: '#7a4b00', background: '#fff4d6', fontSize: 13 }, actionRow: { display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }, - button: { padding: '9px 16px', border: 0, borderRadius: 4, color: '#777', background: '#ddd' }, - badge: { padding: '3px 9px', border: '1px solid #bbb', borderRadius: 12, fontSize: 12, color: '#555' }, - caption: { margin: 0, color: '#666', fontSize: 12 }, + button: { padding: '9px 16px', border: 0, borderRadius: 4, color: '#fff', background: '#1565c0' }, + disabledButton: { color: '#777', background: '#ddd', cursor: 'not-allowed' }, }; type BenchmarkFormProps = { react: PluginApi['React']; + isSubmitting: boolean; + onSubmit: (values: BenchmarkFormValues) => void | Promise; }; -export function BenchmarkForm({ react }: BenchmarkFormProps) { +export function BenchmarkForm({ react, isSubmitting, onSubmit }: BenchmarkFormProps) { const [values, setValues] = react.useState({ ...initialBenchmarkFormValues }); const [touched, setTouched] = react.useState>>({}); const errors = validateBenchmarkForm(values); + const canSubmit = Object.keys(errors).length === 0 && !isSubmitting; const updateValue = (key: K, value: BenchmarkFormValues[K]) => { setValues((current) => ({ ...current, [key]: value })); setTouched((current) => ({ ...current, [key]: true })); }; + const markTouched = (field: BenchmarkFormField) => { + setTouched((current) => ({ ...current, [field]: true })); + }; const fieldError = (field: BenchmarkFormField) => (touched[field] ? errors[field] : undefined); const errorMessage = (field: BenchmarkFormField) => { const message = fieldError(field); @@ -54,6 +58,9 @@ export function BenchmarkForm({ react }: BenchmarkFormProps) { scale: true, initialize: true, }); + if (Object.keys(errors).length === 0 && !isSubmitting) { + void onSubmit(values); + } }, }, react.createElement('h3', { style: styles.heading }, 'Run configuration'), @@ -74,6 +81,7 @@ export function BenchmarkForm({ react }: BenchmarkFormProps) { 'aria-invalid': Boolean(fieldError('database')), 'aria-describedby': fieldError('database') ? 'benchmark-database-error' : undefined, onChange: (event: { currentTarget: { value: string } }) => updateValue('database', event.currentTarget.value), + onBlur: () => markTouched('database'), }), errorMessage('database') ), @@ -98,6 +106,7 @@ export function BenchmarkForm({ react }: BenchmarkFormProps) { 'aria-invalid': Boolean(fieldError(field)), 'aria-describedby': fieldError(field) ? `benchmark-${field}-error` : undefined, onChange: (event: { currentTarget: { value: string } }) => updateValue(field, event.currentTarget.value), + onBlur: () => markTouched(field), }), errorMessage(field) ) @@ -123,13 +132,15 @@ export function BenchmarkForm({ react }: BenchmarkFormProps) { react.createElement( 'div', { style: styles.actionRow }, - react.createElement('button', { type: 'submit', style: styles.button, disabled: true }, 'Start benchmark'), - react.createElement('span', { style: styles.badge }, 'Run submission is not implemented yet') - ), - react.createElement( - 'p', - { style: styles.caption }, - 'Configure the run locally; benchmark submission will be added in a later step.' + react.createElement( + 'button', + { + type: 'submit', + style: canSubmit ? styles.button : { ...styles.button, ...styles.disabledButton }, + disabled: !canSubmit, + }, + isSubmitting ? 'Starting…' : 'Start benchmark' + ) ) ) ); diff --git a/src/BenchmarkTab.tsx b/src/BenchmarkTab.tsx index 44aeae7..da26a19 100644 --- a/src/BenchmarkTab.tsx +++ b/src/BenchmarkTab.tsx @@ -1,7 +1,11 @@ import type { CSSProperties } from 'react'; import type { ClusterDetailTabProps, PluginApi, PluginRouteProps } from '@openeverest/plugin-sdk'; import { BenchmarkForm } from './BenchmarkForm'; +import type { BenchmarkFormValues } from './benchmarkFormValidation'; +import { toCreateBenchmarkRunRequest } from './benchmarkFormValidation'; +import { BenchmarkApiError, createBenchmarkRun } from './benchmarkApi'; import { targetFromClusterDetailProps } from './benchmarkTarget'; +import type { BenchmarkTarget } from './benchmarkTarget'; const styles: Record = { page: { padding: 24, maxWidth: 1100 }, @@ -13,13 +17,22 @@ const styles: Record = { targetGrid: { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: 14, margin: 0 }, targetLabel: { margin: 0, color: '#666', fontSize: 12 }, targetValue: { margin: '4px 0 0', fontSize: 14, overflowWrap: 'anywhere' }, + success: { margin: 0, color: '#176b36' }, + error: { margin: 0, color: '#b42318' }, notice: { padding: 14, borderRadius: 6, color: '#345', background: '#eef5ff' }, }; type BenchmarkTabProps = ClusterDetailTabProps & { react: PluginApi['React']; + pluginFetch: PluginApi['fetch']; }; +type SubmissionState = + | { status: 'idle' } + | { status: 'submitting' } + | { status: 'accepted'; id: string } + | { status: 'failed'; message: string }; + type BenchmarkPageProps = PluginRouteProps & { react: PluginApi['React']; }; @@ -34,8 +47,53 @@ function PageHeader({ react, subtitle }: { react: PluginApi['React']; subtitle: } export function BenchmarkTab(props: BenchmarkTabProps) { - const { react } = props; const target = targetFromClusterDetailProps(props); + return props.react.createElement(BenchmarkWorkflow, { + key: JSON.stringify([target.k8sCluster, target.namespace, target.instance]), + react: props.react, + pluginFetch: props.pluginFetch, + target, + }); +} + +function BenchmarkWorkflow({ react, pluginFetch, target }: { + react: PluginApi['React']; + pluginFetch: PluginApi['fetch']; + target: BenchmarkTarget; +}) { + const [submission, setSubmission] = react.useState({ status: 'idle' }); + const activeRequest = react.useRef(null); + + react.useEffect(() => () => { + activeRequest.current?.abort(); + activeRequest.current = null; + }, []); + + const submitRun = async (values: BenchmarkFormValues) => { + if (activeRequest.current) return; + const request = toCreateBenchmarkRunRequest(values, target); + if (!request) return; + + const controller = new AbortController(); + activeRequest.current = controller; + setSubmission({ status: 'submitting' }); + try { + const run = await createBenchmarkRun(pluginFetch, request, { signal: controller.signal }); + if (activeRequest.current !== controller || controller.signal.aborted) return; + setSubmission({ status: 'accepted', id: run.id }); + } catch (error) { + if (activeRequest.current !== controller || controller.signal.aborted) return; + setSubmission({ + status: 'failed', + message: + error instanceof BenchmarkApiError + ? error.message + : 'Could not confirm whether the benchmark started. Check the instance before submitting again.', + }); + } finally { + if (activeRequest.current === controller) activeRequest.current = null; + } + }; return react.createElement( 'div', @@ -74,7 +132,19 @@ export function BenchmarkTab(props: BenchmarkTabProps) { ) ) ), - react.createElement(BenchmarkForm, { react }) + react.createElement(BenchmarkForm, { + react, + isSubmitting: submission.status === 'submitting', + onSubmit: submitRun, + }), + submission.status === 'accepted' && + react.createElement( + 'p', + { style: styles.success, role: 'status' }, + `Benchmark run accepted. Run ID: ${submission.id} (status: running).` + ), + submission.status === 'failed' && + react.createElement('p', { style: styles.error, role: 'alert' }, submission.message) ) ); } diff --git a/src/benchmarkApi.ts b/src/benchmarkApi.ts index e4e48af..38e679f 100644 --- a/src/benchmarkApi.ts +++ b/src/benchmarkApi.ts @@ -19,7 +19,8 @@ export type CreateBenchmarkRunResponse = { export class BenchmarkApiError extends Error { constructor( message: string, - readonly status?: number + readonly status?: number, + readonly outcomeUnknown = false ) { super(message); this.name = 'BenchmarkApiError'; @@ -28,7 +29,51 @@ export class BenchmarkApiError extends Error { export async function createBenchmarkRun( pluginFetch: PluginApi['fetch'], - request: CreateBenchmarkRunRequest + request: CreateBenchmarkRunRequest, + { signal, timeoutMs = 30_000 }: { signal?: AbortSignal; timeoutMs?: number } = {} +): Promise { + const controller = new AbortController(); + let timedOut = false; + const aborted = new Promise((_, reject) => { + controller.signal.addEventListener('abort', () => { + reject(unknownOutcome(timedOut ? 'The submission timed out.' : 'The submission was interrupted.')); + }, { once: true }); + }); + const cancel = () => controller.abort(); + signal?.addEventListener('abort', cancel, { once: true }); + const timer = setTimeout(() => { + timedOut = true; + controller.abort(); + }, timeoutMs); + + try { + if (signal?.aborted) { + controller.abort(); + return await aborted; + } + // Bound both fetching and reading the body, even if the host ignores abort. + return await Promise.race([ + sendCreateBenchmarkRun(pluginFetch, request, controller.signal), + aborted, + ]); + } finally { + clearTimeout(timer); + signal?.removeEventListener('abort', cancel); + } +} + +function unknownOutcome(reason: string, status?: number): BenchmarkApiError { + return new BenchmarkApiError( + `${reason} Could not confirm whether the benchmark started. It may already be running; check the instance before submitting again.`, + status, + true + ); +} + +async function sendCreateBenchmarkRun( + pluginFetch: PluginApi['fetch'], + request: CreateBenchmarkRunRequest, + signal: AbortSignal ): Promise { let response: Response; try { @@ -36,17 +81,22 @@ export async function createBenchmarkRun( method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(request), + signal, }); } catch { - throw new BenchmarkApiError('Could not connect to the benchmark service. Please try again.'); + throw unknownOutcome('The connection to the benchmark service was lost.'); } if (!response.ok) { - throw new BenchmarkApiError(await readErrorMessage(response), response.status); + const message = await readErrorMessage(response); + if (response.status >= 500) { + throw unknownOutcome(message, response.status); + } + throw new BenchmarkApiError(message, response.status); } if (response.status !== 202) { - throw new BenchmarkApiError('The benchmark service returned an unexpected response.', response.status); + throw unknownOutcome('The benchmark service returned an unexpected response.', response.status); } try { @@ -66,7 +116,7 @@ export async function createBenchmarkRun( // Use the same safe message for invalid JSON and an unexpected response shape. } - throw new BenchmarkApiError('The benchmark service returned an invalid response.', response.status); + throw unknownOutcome('The benchmark service returned an invalid response.', response.status); } async function readErrorMessage(response: Response): Promise { @@ -85,5 +135,5 @@ async function readErrorMessage(response: Response): Promise { // Fall back when the server response is not valid JSON. } - return `Could not start the benchmark (HTTP ${response.status}).`; + return `The benchmark service returned HTTP ${response.status}.`; } diff --git a/src/benchmarkFormValidation.ts b/src/benchmarkFormValidation.ts index 32a92f8..3d5572f 100644 --- a/src/benchmarkFormValidation.ts +++ b/src/benchmarkFormValidation.ts @@ -1,3 +1,6 @@ +import type { CreateBenchmarkRunRequest } from './benchmarkApi'; +import type { BenchmarkTarget } from './benchmarkTarget'; + export type BenchmarkFormValues = { database: string; durationSeconds: string; @@ -68,3 +71,22 @@ export function validateBenchmarkForm(values: BenchmarkFormValues): BenchmarkFor return errors; } + +export function toCreateBenchmarkRunRequest( + values: BenchmarkFormValues, + target: BenchmarkTarget +): CreateBenchmarkRunRequest | undefined { + if (Object.keys(validateBenchmarkForm(values)).length > 0) { + return undefined; + } + + return { + target, + database: values.database.trim(), + durationSeconds: Number(values.durationSeconds), + clients: Number(values.clients), + threads: Number(values.threads), + scale: Number(values.scale), + initialize: values.initialize, + }; +} diff --git a/src/main.tsx b/src/main.tsx index 65e9631..976b3cd 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -8,6 +8,7 @@ import { BenchmarkPage, BenchmarkTab } from './BenchmarkTab'; const register: PluginRegisterFn = (api: PluginApi) => { const react = api.React; + const pluginFetch = api.fetch.bind(api); api.registerExtension({ type: 'sidebarItem', @@ -26,7 +27,7 @@ const register: PluginRegisterFn = (api: PluginApi) => { path: 'performance-benchmark', providers: ['provider-cloudnative-pg', 'provider-percona-postgresql'], component: (props: ClusterDetailTabProps) => - react.createElement(BenchmarkTab, { ...props, react }), + react.createElement(BenchmarkTab, { ...props, react, pluginFetch }), }); }; diff --git a/tests/benchmarkApi.test.mjs b/tests/benchmarkApi.test.mjs new file mode 100644 index 0000000..ed5f63a --- /dev/null +++ b/tests/benchmarkApi.test.mjs @@ -0,0 +1,92 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { BenchmarkApiError, createBenchmarkRun } from '../dist/submission-test/benchmarkApi.js'; + +const request = { + target: { k8sCluster: 'main', namespace: 'default', instance: 'postgres-a' }, + database: 'bench', durationSeconds: 30, clients: 1, threads: 1, scale: 1, initialize: false, +}; +const unknownOutcome = error => error instanceof BenchmarkApiError && error.outcomeUnknown; + +test('lost response reports an uncertain outcome without retrying the POST', async () => { + let calls = 0; + await assert.rejects(createBenchmarkRun(async () => { + calls++; + throw new TypeError('Connection dropped after acceptance'); + }, request), error => { + assert.ok(unknownOutcome(error)); + assert.match(error.message, /may already be running/); + assert.doesNotMatch(error.message, /Please try again/); + return true; + }); + assert.equal(calls, 1); +}); + +test('deadline bounds stalled fetch even when the transport ignores abort', async () => { + let signal; + await assert.rejects(createBenchmarkRun((_path, init) => { + signal = init.signal; + return new Promise(() => {}); + }, request, { timeoutMs: 10 }), error => unknownOutcome(error) && /timed out/.test(error.message)); + assert.equal(signal.aborted, true); +}); + +test('deadline also covers reading the response body', async () => { + let signal; + await assert.rejects(createBenchmarkRun(async (_path, init) => { + signal = init.signal; + return { ok: true, status: 202, json: () => new Promise(() => {}) }; + }, request, { timeoutMs: 10 }), unknownOutcome); + assert.equal(signal.aborted, true); +}); + +test('caller cancellation settles the request and ignores late success', async () => { + const controller = new AbortController(); + let signal; + let finish; + const pending = createBenchmarkRun((_path, init) => { + signal = init.signal; + return new Promise(resolve => { finish = resolve; }); + }, request, { signal: controller.signal }); + controller.abort(); + await assert.rejects(pending, unknownOutcome); + assert.equal(signal.aborted, true); + finish(new Response(JSON.stringify({ id: 'late-run', status: 'running' }), { status: 202 })); + await assert.rejects(pending, unknownOutcome); +}); + +test('an already cancelled caller never sends a POST', async () => { + const controller = new AbortController(); + controller.abort(); + let calls = 0; + await assert.rejects(createBenchmarkRun(async () => { calls++; }, request, { + signal: controller.signal, + }), unknownOutcome); + assert.equal(calls, 0); +}); + +test('unreadable or invalid acceptance and gateway errors remain uncertain', async () => { + for (const [body, status] of [['not JSON', 202], ['{}', 202], ['{"error":"gateway unavailable"}', 502]]) { + await assert.rejects(createBenchmarkRun(async () => new Response(body, { status }), request), + error => unknownOutcome(error) && error.status === status); + } +}); + +test('explicit rejection retains its message and is distinguishable from uncertainty', async () => { + await assert.rejects(createBenchmarkRun(async () => new Response('{"error":"capacity full"}', { + status: 429, + }), request), error => error instanceof BenchmarkApiError && !error.outcomeUnknown && + error.status === 429 && error.message === 'capacity full'); +}); + +test('accepted response succeeds and disconnects the caller abort listener', async () => { + const controller = new AbortController(); + let signal; + const result = await createBenchmarkRun(async (_path, init) => { + signal = init.signal; + return new Response('{"id":"run-1","status":"running"}', { status: 202 }); + }, request, { signal: controller.signal }); + assert.deepEqual(result, { id: 'run-1', status: 'running' }); + controller.abort(); + assert.equal(signal.aborted, false); +});