diff --git a/.husky/pre-commit b/.husky/pre-commit index 76031ec..5325f24 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,6 +1,9 @@ #!/bin/sh . "$(dirname "$0")/_/husky.sh" -npx lint-staged +# dist/ is tracked and shipped ("files": ["/dist", "/src"]), so a src change +# committed without it leaves consumers on the old code - and a brand-new module +# is worse than stale, since its dist file is absent entirely and the emitted +# code imports something that does not exist. npm run transpile git add dist/ diff --git a/dist/components/ClusterCards.d.ts b/dist/components/ClusterCards.d.ts new file mode 100644 index 0000000..6a443a7 --- /dev/null +++ b/dist/components/ClusterCards.d.ts @@ -0,0 +1,22 @@ +import React from "react"; +import type { ClusterMetadata } from "../utils/computeEstimate"; +export interface ClusterCardsProps { + /** The host's cluster list, unchanged — `hostname`, `displayName`, `queues`. */ + clusters: Array>; + /** Pricing, limits and queue waits, keyed by fqdn. Optional. */ + clusterMetadata?: ClusterMetadata[]; + selectedHostname?: string; + onSelect: (hostname: string) => void; + disabled?: boolean; + id?: string; +} +/** + * The cluster choice, as cards. + * + * It used to be a `select`: one line of text per cluster, and everything a + * reader would actually choose on — what it costs, how long the queue is, how + * big a job it will take — either absent or a click away in a popover table. + * The card carries those facts next to the name, which is the only arrangement + * that makes the choice a comparison rather than a guess. + */ +export default function ClusterCards({ clusters, clusterMetadata, selectedHostname, onSelect, disabled, id, }: ClusterCardsProps): React.JSX.Element; diff --git a/dist/components/ClusterCards.js b/dist/components/ClusterCards.js new file mode 100644 index 0000000..5726f09 --- /dev/null +++ b/dist/components/ClusterCards.js @@ -0,0 +1,45 @@ +import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; +import MetricTile from "@mat3ra/cove/dist/mui/components/metric/MetricTile"; +import SelectableCard, { SelectableCardGroup, } from "@mat3ra/cove/dist/mui/components/selectable-card/SelectableCard"; +import StatusChip from "@mat3ra/cove/dist/mui/components/status/StatusChip"; +import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; +import { formatCost } from "../utils/computeEstimate"; +/** How busy a queue is, as words rather than a bare number. */ +function queueBadge(metadata) { + const wait = metadata === null || metadata === void 0 ? void 0 : metadata.queueWaitMinutes; + if (wait === undefined) + return null; + const tone = wait <= 10 ? "success" : wait <= 30 ? "warning" : "neutral"; + return (_jsx(StatusChip, { tone: tone, iconName: wait <= 10 ? "shapes.check" : "shapes.loop", label: `~${wait} min`, title: "Typical wait before the job starts" })); +} +function describeCapacity(cluster, metadata) { + var _a; + const queues = (_a = cluster.queues) !== null && _a !== void 0 ? _a : []; + const queueNames = queues.map((queue) => queue.name).join(", "); + const limits = metadata === null || metadata === void 0 ? void 0 : metadata.limits; + const capacity = (limits === null || limits === void 0 ? void 0 : limits.maxNodes) && (limits === null || limits === void 0 ? void 0 : limits.maxPpn) + ? `up to ${limits.maxNodes} nodes × ${limits.maxPpn} cores` + : undefined; + return [queueNames && `queue ${queueNames}`, capacity].filter(Boolean).join(" · "); +} +/** + * The cluster choice, as cards. + * + * It used to be a `select`: one line of text per cluster, and everything a + * reader would actually choose on — what it costs, how long the queue is, how + * big a job it will take — either absent or a click away in a popover table. + * The card carries those facts next to the name, which is the only arrangement + * that makes the choice a comparison rather than a guess. + */ +export default function ClusterCards({ clusters, clusterMetadata = [], selectedHostname, onSelect, disabled = false, id, }) { + if (!clusters.length) { + return (_jsx(Typography, { variant: "body2", color: "text.secondary", children: "No clusters are available on this account." })); + } + return (_jsx(SelectableCardGroup, { label: "Cluster", id: id, children: clusters.map((cluster) => { + var _a, _b; + const metadata = clusterMetadata.find((entry) => entry.fqdn === cluster.hostname); + const price = formatCost(metadata === null || metadata === void 0 ? void 0 : metadata.pricePerCoreHour, metadata === null || metadata === void 0 ? void 0 : metadata.currency); + return (_jsx(SelectableCard, { id: `cluster-card-${cluster.hostname}`, title: (_a = cluster.displayName) !== null && _a !== void 0 ? _a : cluster.hostname, subtitle: describeCapacity(cluster, metadata), selected: cluster.hostname === selectedHostname, disabled: disabled, onSelect: () => onSelect(cluster.hostname), badge: queueBadge(metadata), children: _jsxs(Stack, { direction: "row", spacing: 3, children: [_jsx(MetricTile, { size: "small", label: "Price", value: price, unit: price ? "/ core·h" : undefined, caption: price ? undefined : "not published" }), _jsx(MetricTile, { size: "small", label: "Max walltime", value: (_b = metadata === null || metadata === void 0 ? void 0 : metadata.limits) === null || _b === void 0 ? void 0 : _b.maxWalltimeHours, unit: "h" })] }) }, cluster.hostname)); + }) })); +} diff --git a/dist/components/Compute.d.ts b/dist/components/Compute.d.ts index 4386aca..ad9447e 100644 --- a/dist/components/Compute.d.ts +++ b/dist/components/Compute.d.ts @@ -28,6 +28,11 @@ declare namespace Compute { let showStatusTrack: PropTypes.Requireable; let showAdvancedOptions: PropTypes.Requireable; let accountUsers: PropTypes.Requireable; + let showAllErrors: PropTypes.Requireable; + let useComputeCards: PropTypes.Requireable; + let clusterMetadata: PropTypes.Requireable; + let computeQuota: PropTypes.Requireable; + let runs: PropTypes.Requireable; } namespace defaultProps { let editable_1: boolean; diff --git a/dist/components/Compute.js b/dist/components/Compute.js index 6b37021..7a5089f 100644 --- a/dist/components/Compute.js +++ b/dist/components/Compute.js @@ -65,8 +65,8 @@ class Compute extends React.Component { return Boolean(showStatusTrack && job.statusTrack && job.statusTrack.length); } render() { - const { className, showHeader, isLoading, adjustable, editable, showComputeForm, compute, user, account, clusters, onUpdate, job, showAdvancedOptions, accountUsers, isAccountUsersLoading, } = this.props; - return (_jsxs("div", { className: setClass(className, "wizard-step", "compute-step"), children: [showHeader ? (_jsxs(EntityHeaderContainer, { children: [_jsx(EntityHeader, { name: "Compute", subtitle: "Runtime configuration parameters", icon: "pages.compute", isLoading: isLoading, editable: false, adjustable: true, isDescriptionEditorHidden: true }), adjustable || editable ? (_jsx(AutoSetActionContainer, { children: _jsx(Dropdown, { className: "pull-right", actions: this.getDropdownAction(), children: _jsx(DropdownButton, { children: _jsx(IconByName, { name: "shapes.dots.vertical" }) }) }) })) : null] })) : null, showComputeForm && (_jsx(ComputeForm, { editable: editable, compute: compute, user: user, account: account, clusters: clusters, onUpdate: onUpdate, appName: job.workflow.usedApplicationNames[0], showAdvancedOptions: showAdvancedOptions, accountUsers: accountUsers, isAccountUsersLoading: isAccountUsersLoading })), this.showStatusTrack && (_jsx(Box, { p: 2, children: _jsx(StatusTrackTable, { entity: job }) }))] })); + const { className, showHeader, isLoading, adjustable, editable, showComputeForm, compute, user, account, clusters, onUpdate, job, showAdvancedOptions, accountUsers, isAccountUsersLoading, showAllErrors, useComputeCards, clusterMetadata, computeQuota, runs, } = this.props; + return (_jsxs("div", { className: setClass(className, "wizard-step", "compute-step"), children: [showHeader ? (_jsxs(EntityHeaderContainer, { children: [_jsx(EntityHeader, { name: "Compute", subtitle: "Runtime configuration parameters", icon: "pages.compute", isLoading: isLoading, editable: false, adjustable: true, isDescriptionEditorHidden: true }), adjustable || editable ? (_jsx(AutoSetActionContainer, { children: _jsx(Dropdown, { className: "pull-right", actions: this.getDropdownAction(), children: _jsx(DropdownButton, { children: _jsx(IconByName, { name: "shapes.dots.vertical" }) }) }) })) : null] })) : null, showComputeForm && (_jsx(ComputeForm, { editable: editable, compute: compute, user: user, account: account, clusters: clusters, onUpdate: onUpdate, appName: job.workflow.usedApplicationNames[0], showAdvancedOptions: showAdvancedOptions, accountUsers: accountUsers, isAccountUsersLoading: isAccountUsersLoading, showAllErrors: showAllErrors, useComputeCards: useComputeCards, clusterMetadata: clusterMetadata, computeQuota: computeQuota, runs: runs })), this.showStatusTrack && (_jsx(Box, { p: 2, children: _jsx(StatusTrackTable, { entity: job }) }))] })); } } Compute.propTypes = { @@ -81,6 +81,16 @@ Compute.propTypes = { showStatusTrack: PropTypes.bool, showAdvancedOptions: PropTypes.bool, accountUsers: PropTypes.array, + /** Reveal every validation error, not just those for fields already touched. */ + showAllErrors: PropTypes.bool, + /** Render cluster cards, resource steppers and the estimate above the schema form. */ + useComputeCards: PropTypes.bool, + /** Per-cluster pricing, limits and queue waits. Not part of the job document. */ + clusterMetadata: PropTypes.array, + /** Remaining allowance for the paying account, when the host tracks one. */ + computeQuota: PropTypes.object, + /** Multi-material jobs run once per material; the estimate covers all of them. */ + runs: PropTypes.number, }; Compute.defaultProps = { editable: true, diff --git a/dist/components/ComputeEstimatePanel.d.ts b/dist/components/ComputeEstimatePanel.d.ts new file mode 100644 index 0000000..4d82186 --- /dev/null +++ b/dist/components/ComputeEstimatePanel.d.ts @@ -0,0 +1,21 @@ +import React from "react"; +import type { ClusterMetadata, ComputeConfiguration, ComputeQuota } from "../utils/computeEstimate"; +export interface ComputeEstimatePanelProps { + compute?: ComputeConfiguration | null; + clusterMetadata?: ClusterMetadata[]; + quota?: ComputeQuota | null; + /** Multi-material jobs run once per material; the estimate is for all of them. */ + runs?: number; + id?: string; +} +/** + * What this configuration will consume, while it is still being configured. + * + * The form's fields are inputs to an arithmetic nobody was doing: 4 nodes × 32 + * cores × 12 hours is 1536 core-hours, which on a 500-hour quota is a + * conversation the reader should have before submitting, not after. Every tile + * degrades independently — core-hours need only the job, cost needs a published + * price, the quota bar needs an injected allowance — and a tile with nothing + * behind it shows an em dash rather than a zero. + */ +export default function ComputeEstimatePanel({ compute, clusterMetadata, quota, runs, id, }: ComputeEstimatePanelProps): React.JSX.Element; diff --git a/dist/components/ComputeEstimatePanel.js b/dist/components/ComputeEstimatePanel.js new file mode 100644 index 0000000..22cdf53 --- /dev/null +++ b/dist/components/ComputeEstimatePanel.js @@ -0,0 +1,64 @@ +import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; +import MetricTile from "@mat3ra/cove/dist/mui/components/metric/MetricTile"; +import SegmentedMeter from "@mat3ra/cove/dist/mui/components/metric/SegmentedMeter"; +import Paper from "@mui/material/Paper"; +import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; +import { estimateComputeUsage, findClusterMetadata, formatCoreHours, formatCost, } from "../utils/computeEstimate"; +/** + * What this configuration will consume, while it is still being configured. + * + * The form's fields are inputs to an arithmetic nobody was doing: 4 nodes × 32 + * cores × 12 hours is 1536 core-hours, which on a 500-hour quota is a + * conversation the reader should have before submitting, not after. Every tile + * degrades independently — core-hours need only the job, cost needs a published + * price, the quota bar needs an injected allowance — and a tile with nothing + * behind it shows an em dash rather than a zero. + */ +export default function ComputeEstimatePanel({ compute, clusterMetadata = [], quota, runs = 1, id = "compute-estimate-panel", }) { + var _a, _b, _c; + const estimate = estimateComputeUsage(compute, clusterMetadata, runs); + const cluster = findClusterMetadata(compute, clusterMetadata); + const remaining = quota === null || quota === void 0 ? void 0 : quota.remainingCoreHours; + const isOverQuota = remaining !== undefined && ((_a = estimate.coreHours) !== null && _a !== void 0 ? _a : 0) > remaining; + const usedBefore = (quota === null || quota === void 0 ? void 0 : quota.totalCoreHours) !== undefined && remaining !== undefined + ? Math.max(quota.totalCoreHours - remaining, 0) + : undefined; + return (_jsxs(Paper, { id: id, sx: { p: 2 }, children: [_jsx(Typography, { variant: "subtitle2", gutterBottom: true, children: "Estimate" }), _jsxs(Stack, { direction: "row", spacing: 3, flexWrap: "wrap", useFlexGap: true, children: [_jsx(MetricTile, { id: "estimate-core-hours", label: "Core-hours", value: estimate.coreHours === undefined + ? undefined + : (_b = formatCoreHours(estimate.coreHours)) === null || _b === void 0 ? void 0 : _b.replace(" core·h", ""), unit: "core\u00B7h", tone: isOverQuota ? "error" : "default", caption: describeDerivation(estimate.nodes, estimate.ppn, estimate.walltimeHours, runs) }), _jsx(MetricTile, { id: "estimate-cost", label: "Cost", value: formatCost(estimate.cost, estimate.currency), caption: (cluster === null || cluster === void 0 ? void 0 : cluster.pricePerCoreHour) === undefined + ? "no price published for this cluster" + : `at ${formatCost(cluster.pricePerCoreHour, cluster.currency)} / core·h` }), _jsx(MetricTile, { id: "estimate-queue-wait", label: "Queue wait", value: (cluster === null || cluster === void 0 ? void 0 : cluster.queueWaitMinutes) === undefined + ? undefined + : `~${cluster.queueWaitMinutes}`, unit: "min", caption: "typical, before the job starts" })] }), remaining !== undefined && (quota === null || quota === void 0 ? void 0 : quota.totalCoreHours) !== undefined ? (_jsx(Stack, { sx: { mt: 2 }, children: _jsx(SegmentedMeter, { id: "estimate-quota-meter", label: "Monthly quota", total: quota.totalCoreHours, caption: describeQuota(estimate.coreHours, remaining), segments: [ + ...(usedBefore + ? [ + { + label: "Already used", + value: usedBefore, + color: "primary.main", + }, + ] + : []), + { + label: "This job", + value: (_c = estimate.coreHours) !== null && _c !== void 0 ? _c : 0, + color: isOverQuota ? "error.main" : "warning.main", + isProjected: true, + }, + ] }) })) : null] })); +} +function describeDerivation(nodes, ppn, walltimeHours, runs = 1) { + if (!nodes || !ppn || walltimeHours === undefined) + return "set nodes, cores and a walltime"; + const base = `${nodes} × ${ppn} × ${Math.round(walltimeHours * 10) / 10} h`; + return runs > 1 ? `${base}, ${runs} materials` : base; +} +function describeQuota(coreHours, remaining) { + if (coreHours === undefined) + return `${formatCoreHours(remaining)} left this month`; + if (coreHours > remaining) { + return `over by ${formatCoreHours(coreHours - remaining)}`; + } + return `${formatCoreHours(remaining - coreHours)} would remain`; +} diff --git a/dist/components/ComputeForm.d.ts b/dist/components/ComputeForm.d.ts index bae6671..db88297 100644 --- a/dist/components/ComputeForm.d.ts +++ b/dist/components/ComputeForm.d.ts @@ -1,5 +1,6 @@ import { JSONSchema7 } from "json-schema"; import React from "react"; +import type { ClusterMetadata, ComputeQuota } from "../utils/computeEstimate"; import { UISchema } from "../utils/schemas"; /** Minimal interface for cluster node objects passed from the host application. */ export interface ClusterNode { @@ -27,9 +28,33 @@ interface ComputeFormProps { onUpdate: (s: string) => void; appName?: string; pathForClusters?: string; + /** + * Reveals every validation error at once, including for fields the reader + * has not touched. Off by default: a form the reader has not filled in yet + * should not open by listing everything wrong with it. Turn it on when the + * whole form has to answer for itself — on submit, or from a preflight check. + */ + showAllErrors?: boolean; + /** + * Renders the cluster choice, the resource fields and the estimate as their + * own surface above the schema form, hiding those fields from it. + * + * Opt-in per host, like job-designer's guided layout: the fields move, so a + * host with its own tests or documentation against the schema form should + * flip this when it is ready rather than find it flipped for it. + */ + useComputeCards?: boolean; + /** Pricing, limits and queue waits per cluster. Only used with `useComputeCards`. */ + clusterMetadata?: ClusterMetadata[]; + /** Remaining allowance for the paying account, when the host tracks one. */ + computeQuota?: ComputeQuota | null; + /** Multi-material jobs run once per material; the estimate covers all of them. */ + runs?: number; } interface ComputeFormState { formData: any; + /** Form-data keys the reader has edited; see `utils/touchedFields`. */ + touchedFields: ReadonlySet; } export declare class ComputeForm extends React.Component { computeUiSchema: UISchema; @@ -37,14 +62,41 @@ export declare class ComputeForm extends React.Component