Skip to content
Open
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
5 changes: 4 additions & 1 deletion .husky/pre-commit
Original file line number Diff line number Diff line change
@@ -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/
22 changes: 22 additions & 0 deletions dist/components/ClusterCards.d.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, any>>;
/** 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;
45 changes: 45 additions & 0 deletions dist/components/ClusterCards.js
Original file line number Diff line number Diff line change
@@ -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));
}) }));
}
5 changes: 5 additions & 0 deletions dist/components/Compute.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ declare namespace Compute {
let showStatusTrack: PropTypes.Requireable<boolean>;
let showAdvancedOptions: PropTypes.Requireable<boolean>;
let accountUsers: PropTypes.Requireable<any[]>;
let showAllErrors: PropTypes.Requireable<boolean>;
let useComputeCards: PropTypes.Requireable<boolean>;
let clusterMetadata: PropTypes.Requireable<any[]>;
let computeQuota: PropTypes.Requireable<object>;
let runs: PropTypes.Requireable<number>;
}
namespace defaultProps {
let editable_1: boolean;
Expand Down
14 changes: 12 additions & 2 deletions dist/components/Compute.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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,
Expand Down
21 changes: 21 additions & 0 deletions dist/components/ComputeEstimatePanel.d.ts
Original file line number Diff line number Diff line change
@@ -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;
64 changes: 64 additions & 0 deletions dist/components/ComputeEstimatePanel.js
Original file line number Diff line number Diff line change
@@ -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`;
}
54 changes: 53 additions & 1 deletion dist/components/ComputeForm.d.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -27,24 +28,75 @@ 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<string>;
}
export declare class ComputeForm extends React.Component<ComputeFormProps, ComputeFormState> {
computeUiSchema: UISchema;
schema: JSONSchema7;
validator: any;
getErrorMessage: any;
constructor(props: ComputeFormProps);
/**
* `fieldId` is RJSF's id for the field that changed. It is what makes
* progressive validation possible: errors stay hidden until the reader has
* been to the field in question.
*/
handleFormUpdate({ formData }: {
formData: Record<string, any>;
}): void;
}, fieldId?: string): void;
onNotifyUpdate(notify: Record<string, any>): void;
getURLForChargesPerJodID(jid: string): any;
getNode: () => ClusterNode | undefined;
getClusterQueues(): any;
customValidate: (data: Record<string, any>, errors: any) => any;
/** Limits and pricing for the cluster currently chosen, if the host published any. */
get selectedClusterMetadata(): ClusterMetadata | undefined;
/**
* The compute the cards surface is editing, in the unflattened shape the
* estimate and limit checks expect.
*/
get computeFromFormData(): {
cluster: {
fqdn: any;
};
nodes: any;
ppn: any;
timeLimit: any;
queue: any;
};
/**
* Writes from the cards surface go through the same path as a keystroke in
* the schema form — same touched-field bookkeeping, same validate-then-
* `onUpdate` gate — so the two cannot get out of step.
*/
applyComputePatch: (patch: Record<string, any>) => void;
onClusterSelect: (hostname: string) => void;
clusterOptions(): {
label: any;
value: any;
Expand Down
Loading
Loading