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
14 changes: 14 additions & 0 deletions web/src/modules/inferences/SiteProfilePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,20 @@ function ProfileView({ p }: { p: SiteProfile }) {
"sites",
p.sites.map((s) => ({ label: s.name, value: mono(s.url) })),
)}
{p.presets && Object.keys(p.presets).length > 0 && (
<SectionCard title={t("profile.groups.presets")}>
<p className="text-sm text-muted-foreground">{t("profile.hints.presets")}</p>
{Object.entries(p.presets).map(([name, preset]) => (
<div key={name} className="flex flex-col gap-1.5">
<div className="text-sm">
<span className="font-mono font-medium">{name}</span>
{preset.description && <span className="ms-2 text-muted-foreground">{preset.description}</span>}
</div>
<CodeBlock language="yaml" value={toYaml(preset.values ?? {})} />
</div>
))}
</SectionCard>
)}
{p.extra && Object.keys(p.extra).length > 0 && (
<SectionCard title={t("profile.groups.extra")}>
<CodeBlock language="yaml" value={toYaml(p.extra)} />
Expand Down
70 changes: 66 additions & 4 deletions web/src/modules/inferences/components/DeployForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
Badge,
Button,
Checkbox,
CodeBlock,
DataSelect,
DropdownMenu,
DropdownMenuContent,
Expand All @@ -23,12 +24,14 @@ import {
cn,
} from "@modelsphere/ui";
import { Plus, RefreshCw, X } from "lucide-react";
import type { ClusterInfo } from "@swiss/lib/api";
import type { ClusterInfo, Preset } from "@swiss/lib/api";
import { editRule, listLabel, type AffinityRule } from "@swiss/lib/affinity";
import { toYaml } from "@swiss/lib/yaml";
import { cacheFieldError, type CacheCard, type CacheDefaults } from "@swiss/lib/cache";
import { unlistedProducts, type GPUCapacity } from "@swiss/lib/gpu";
import { CHECK_MS, inputError, rememberable, type InputRow } from "@swiss/lib/inputs";
import { useDebounced } from "@swiss/lib/useDebounced";
import { effective, imageChoices, type Form, type Toleration } from "@swiss/components/DeploySettings";
import { effective, imageChoices, tolerationExtras, type Form, type Toleration } from "@swiss/components/DeploySettings";
import { MAX_VALUES, rejectKey } from "@swiss/lib/routeDirectives";
import { FALLBACK_ROUTE_OPTIONS, routeDirectives, runtimeValue, type RouteDirective, type RouteOptions } from "@/modules/inferences/routeOptions";
import { useT } from "@/modules/inferences/i18n";
Expand Down Expand Up @@ -67,9 +70,11 @@ export interface DeployFormProps {
// Which typed answers to save to the site profile once the deploy applies; absent where it cannot be saved.
remember?: Record<string, boolean>;
onRemember?: (key: string, on: boolean) => void;
// The site profile's presets, on a deploy.
presets?: Record<string, Preset>;
}

export function DeployForm({ form, onChange, upgrade, submitted, target, cluster, defaults, gpuOptions, gpuListed, gpuResources, capacity, expanded, onExpand, idPrefix, routeOptions, catalogRoute, helmValues, recovery, inputs, remember, onRemember }: DeployFormProps) {
export function DeployForm({ form, onChange, upgrade, submitted, target, cluster, defaults, gpuOptions, gpuListed, gpuResources, capacity, expanded, onExpand, idPrefix, routeOptions, catalogRoute, helmValues, recovery, inputs, remember, onRemember, presets }: DeployFormProps) {
const t = useT();
const checked = useDebounced(form.inputs, CHECK_MS);
const on = effective(form);
Expand Down Expand Up @@ -145,7 +150,9 @@ export function DeployForm({ form, onChange, upgrade, submitted, target, cluster
<FieldSelect multiple values={form.gpuProducts} onValuesChange={(v) => onChange({ gpuProducts: v })} options={gpuOptions.map((o) => ({ value: o, label: o, badge: unlistedProducts(gpuListed, [o]).length ? <Badge variant="warning">{f("gpuUnverified")}</Badge> : <Badge variant="success">{f("gpuVerified")}</Badge> }))} placeholder={gpuOptions.length ? undefined : f("gpuProductsEmpty")} disabled={!gpuOptions.length} />
</FloatingField>
{capacity && <CapacityHint {...capacity} />}
{presets && Object.keys(presets).length > 0 && <PresetField presets={presets} value={form.preset} onChange={(preset) => onChange({ preset })} />}
<Tolerations rows={form.tolerations} onChange={(v) => onChange({ tolerations: v })} />
{form.affinity.length > 0 && <AffinityRules rules={form.affinity} onChange={(affinity) => onChange({ affinity })} />}
<div className="grid grid-cols-2 gap-3">
<FloatingField value={shown(form.priorityClassName, cluster?.priorityClassName)} label={f("priorityClass")}>
<Suggest value={form.priorityClassName} onChange={(priorityClassName) => onChange({ priorityClassName })} suggestion={cluster?.priorityClassName} />
Expand Down Expand Up @@ -396,13 +403,14 @@ function Tolerations({ rows, onChange }: { rows: Toleration[]; onChange: (v: Tol
<Group label={f("tolerations")} hint={f("tolerationsHint")}>
{rows.map((r, i) => (
<div key={i} className="grid grid-cols-[1fr_7rem_1fr_9rem_auto] items-center gap-2">
<Input value={r.key} onChange={(e) => patch(i, { key: e.target.value })} placeholder={f("tolKey")} aria-label={f("tolKey")} />
<Input value={r.key} onChange={(e) => patch(i, { key: e.target.value })} placeholder={r.raw && !r.raw.key ? f("tolAny") : f("tolKey")} aria-label={f("tolKey")} />
<DataSelect value={r.operator} onValueChange={(v) => patch(i, { operator: v as Toleration["operator"], value: v === "Exists" ? "" : r.value })} options={["Equal", "Exists"].map((o) => ({ value: o, label: o }))} />
<Input value={r.value} onChange={(e) => patch(i, { value: e.target.value })} placeholder={f("tolValue")} aria-label={f("tolValue")} disabled={r.operator === "Exists"} />
<DataSelect value={r.effect} onValueChange={(v) => patch(i, { effect: v })} options={EFFECTS.map((e) => ({ value: e, label: e || f("anyEffect") }))} />
<Button variant="ghost" size="icon" className="size-8" aria-label={t("common:actions.delete")} onClick={() => onChange(rows.filter((_, j) => j !== i))}>
<X className="size-4" />
</Button>
{tolerationExtras(r).length > 0 && <span className="col-span-5 font-mono text-xs text-muted-foreground">{tolerationExtras(r).join(" · ")}</span>}
</div>
))}
<Button variant="ghost" size="sm" className="w-fit" onClick={() => onChange([...rows, { key: "", operator: "Equal", value: "", effect: "" }])}>
Expand All @@ -412,6 +420,60 @@ function Tolerations({ rows, onChange }: { rows: Toleration[]; onChange: (v: Tol
);
}

const NO_PRESET = "__none";

function PresetField({ presets, value, onChange }: { presets: Record<string, Preset>; value: string; onChange: (v: string) => void }) {
const t = useT();
const f = (k: string) => t(`deploy.fields.${k}`);
const picked = presets[value];
return (
<div className="flex flex-col gap-1.5">
<FloatingField label={f("preset")} hint={f("presetHint")}>
<FieldSelect
value={value || NO_PRESET}
onValueChange={(v) => v && onChange(v === NO_PRESET ? "" : v)}
options={[{ value: NO_PRESET, label: f("presetNone") }, ...Object.keys(presets).sort().map((n) => ({ value: n, label: n }))]}
/>
</FloatingField>
{picked?.description && <p className="text-xs text-muted-foreground">{picked.description}</p>}
{picked && <CodeBlock language="yaml" value={toYaml(picked.values ?? {})} />}
</div>
);
}

function AffinityRules({ rules, onChange }: { rules: AffinityRule[]; onChange: (v: AffinityRule[]) => void }) {
const t = useT();
const f = (k: string) => t(`deploy.fields.${k}`);
const patch = (i: number, r: AffinityRule) => onChange(rules.map((x, j) => (i === j ? r : x)));
return (
<Group label={f("affinity")} hint={f("affinityHint")}>
{rules.map((r, i) => (
<div key={i} className={cn("flex flex-col gap-1 rounded-md border p-2", r.drop && "opacity-50")}>
<div className="flex items-center gap-2 text-xs">
<span className="min-w-0 truncate font-mono text-muted-foreground">
{r.scope} · {listLabel(r.list)}
</span>
<Button variant="ghost" size="sm" className="ms-auto h-7" onClick={() => patch(i, { ...r, drop: !r.drop })}>
{r.drop ? f("affinityUndo") : f("affinityRemove")}
</Button>
</div>
<textarea
value={r.text}
onChange={(e) => patch(i, editRule(r, e.target.value))}
disabled={r.drop}
spellCheck={false}
aria-label={`${r.scope} ${listLabel(r.list)}`}
aria-invalid={!!r.error}
rows={Math.max(2, r.text.split("\n").length)}
className={cn("w-full rounded-md border bg-background px-2 py-1 font-mono text-[11px] leading-snug", r.drop && "line-through", r.error && "border-destructive")}
/>
{r.error && <p className="text-xs text-destructive">{t("deploy.fields.affinityInvalid", { error: r.error })}</p>}
</div>
))}
</Group>
);
}

function directiveText(t: ReturnType<typeof useT>, d: RouteDirective | undefined, field: "label" | "detail"): string {
return d ? t(`deploy.routeOptions.options.${d.key}.${field}`, { defaultValue: d[field] }) : "";
}
Expand Down
6 changes: 4 additions & 2 deletions web/src/modules/inferences/components/DeploySheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import { DiffView } from "@swiss/components/DiffView";
import { deployCapacity, gpuShort, pickableProducts, productChoices, unlistedProducts, vendorResources } from "@swiss/lib/gpu";
import { isChartRange, movableCatalogs } from "@swiss/lib/upgrade";
import { CHECK_MS, inputRows, inputsAnswered, siteSaves, type SiteSave } from "@swiss/lib/inputs";
import { affinityInvalid } from "@swiss/lib/affinity";
import { saveSiteAnswers } from "@swiss/lib/siteAnswers";
import { messageOf, useToast } from "@swiss/components/ui/toast";
import { useDebounced } from "@swiss/lib/useDebounced";
Expand Down Expand Up @@ -210,7 +211,7 @@ function DeploySheetBody({ target, onClose }: { target: DeployTarget; onClose: (
const requestFor = (f: Form) => planRequest(
{ ...f, serviceId: f.serviceId || (upgrade ? f.serviceId : defaultServiceId(target.model)) },
upgrade
? { model: recorded!.model, version: version || undefined, variant: variant || undefined, catalog: moveTo || undefined, chartVersion: chartVersion || undefined, fromRelease: target.release, cacheUnsupported: !!cache.unsupported, forceGpuProducts, inputs: v?.inputs }
? { model: recorded!.model, version: version || undefined, variant: variant || undefined, catalog: moveTo || undefined, chartVersion: chartVersion || undefined, fromRelease: target.release, cacheUnsupported: !!cache.unsupported, forceGpuProducts, inputs: v?.inputs, formLayer: seedPlan?.layers?.form }
: { model: target.model, version, variant: keptVariant, catalog: target.catalog, chartVersion: chartVersion || undefined, cacheUnsupported: !!cache.unsupported, forceGpuProducts, inputs: v?.inputs },
profile.data?.profile,
);
Expand Down Expand Up @@ -511,6 +512,7 @@ function DeploySheetBody({ target, onClose }: { target: DeployTarget; onClose: (
inputs={inputs}
remember={canRemember ? remember : undefined}
onRemember={(key, on) => setRemember((r) => ({ ...r, [key]: on }))}
presets={upgrade ? undefined : profile.data?.profile?.presets}
/>
<div style={{ height: nav.tailSpace }} aria-hidden />
</>
Expand Down Expand Up @@ -540,7 +542,7 @@ function DeploySheetBody({ target, onClose }: { target: DeployTarget; onClose: (
{step === "form" ? (
<>
<SheetCancelButton />
<Button onClick={submit} disabled={!!readOnly || loading || compose.isPending || !inputsAnswered(inputs, checkedInputs)}>
<Button onClick={submit} disabled={!!readOnly || loading || compose.isPending || !inputsAnswered(inputs, checkedInputs) || affinityInvalid(form.affinity)}>
{compose.isPending ? t("deploy.review.composing") : t("deploy.review.compose")}
</Button>
</>
Expand Down
96 changes: 96 additions & 0 deletions web/src/modules/inferences/components/PresetTable.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { useState } from "react";
import { Button, Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, FieldInput, FieldTextarea, FloatingField } from "@modelsphere/ui";
import { Pencil, Plus, X } from "lucide-react";
import type { SiteProfile } from "@swiss/lib/api";
import { hasPresetErrors, presetErrors, presetRow, putPreset, type PresetRow } from "@swiss/lib/profile";
import { useT } from "@/modules/inferences/i18n";

// The profile's presets, each edited in a dialog: a preset goes into the
// profile only once its name and values are valid.
export function PresetTable({ presets, onChange }: { presets: SiteProfile["presets"]; onChange: (next: SiteProfile["presets"]) => void }) {
const t = useT();
const f = (k: string) => t(`profile.fields.${k}`);
// null adds a preset; a name opens that one.
const [open, setOpen] = useState<string | null | undefined>(undefined);
const list = Object.entries(presets ?? {});
return (
<div className="flex flex-col gap-2">
{list.length === 0 && <p className="rounded-md border border-dashed px-3 py-2 text-xs text-muted-foreground">{f("presetsNone")}</p>}
{list.map(([name, p]) => (
<div key={name} className="flex items-center gap-2 rounded-md border px-3 py-2">
<div className="min-w-0 flex-1">
<div className="text-sm">
<span className="font-mono font-medium">{name}</span>
{p.description && <span className="ms-2 text-muted-foreground">{p.description}</span>}
</div>
<div className="truncate font-mono text-xs text-muted-foreground">{Object.keys(p.values ?? {}).join(", ") || f("presetNoValues")}</div>
</div>
<Button variant="ghost" size="icon" className="size-8" aria-label={f("presetEdit")} onClick={() => setOpen(name)}>
<Pencil className="size-3.5" />
</Button>
<Button variant="ghost" size="icon" className="size-8" aria-label={t("common:actions.delete")} onClick={() => onChange(putPreset(presets, name))}>
<X className="size-4" />
</Button>
</div>
))}
<Button variant="ghost" size="sm" className="w-fit" onClick={() => setOpen(null)}>
<Plus className="size-3.5" /> {f("presetAdd")}
</Button>
{open !== undefined && (
<PresetDialog
from={open ?? undefined}
value={presetRow(open ?? "", open === null ? undefined : presets?.[open])}
others={list.map(([n]) => n).filter((n) => n !== open)}
onClose={() => setOpen(undefined)}
onSave={(row) => {
onChange(putPreset(presets, open ?? undefined, row));
setOpen(undefined);
}}
/>
)}
</div>
);
}

function PresetDialog({ from, value, others, onClose, onSave }: { from?: string; value: PresetRow; others: string[]; onClose: () => void; onSave: (row: PresetRow) => void }) {
const t = useT();
const f = (k: string) => t(`profile.fields.${k}`);
const [row, setRow] = useState(value);
const [submitted, setSubmitted] = useState(false);
const errs = presetErrors(row, others);
const edit = (patch: Partial<PresetRow>) => setRow((r) => ({ ...r, ...patch }));
const save = () => {
setSubmitted(true);
if (!hasPresetErrors(errs)) onSave(row);
};
const valuesError = errs.values === "yaml" ? t("profile.errors.preset.yaml", { error: errs.yaml }) : errs.values === "map" ? t("profile.errors.preset.map") : undefined;
return (
<Dialog open onOpenChange={(o) => !o && onClose()}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{from === undefined ? f("presetAdd") : f("presetEdit")}</DialogTitle>
<DialogDescription>{t("profile.hints.presets")}</DialogDescription>
</DialogHeader>
<div className={`flex flex-col ${submitted && hasPresetErrors(errs) ? "gap-5" : "gap-3"}`}>
<div className="grid gap-3 sm:grid-cols-[minmax(0,14rem)_minmax(0,1fr)]">
<FloatingField label={f("presetName")} required error={submitted && errs.name ? t(`profile.errors.preset.${errs.name}`) : undefined}>
<FieldInput value={row.name} onChange={(e) => edit({ name: e.target.value })} className="font-mono text-xs" />
</FloatingField>
<FloatingField label={f("presetDescription")} hint={f("presetDescriptionHint")}>
<FieldInput value={row.description} onChange={(e) => edit({ description: e.target.value })} />
</FloatingField>
</div>
<FloatingField label={f("presetValues")} hint={f("presetValuesHint")} error={submitted ? valuesError : undefined} multiline>
<FieldTextarea value={row.values} onChange={(e) => edit({ values: e.target.value })} rows={16} spellCheck={false} className="font-mono text-xs" />
</FloatingField>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
{t("common:actions.cancel")}
</Button>
<Button onClick={save}>{t("common:actions.save")}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
5 changes: 4 additions & 1 deletion web/src/modules/inferences/components/ProfileForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { useT } from "@/modules/inferences/i18n";
import { fromRows, markDefault, toRows, withNodes, type Row } from "@/modules/inferences/profile-lib";
import { answersText, parseAnswers } from "@swiss/lib/inputs";
import { GpuTable } from "@/modules/inferences/components/GpuResources";
import { PresetTable } from "@/modules/inferences/components/PresetTable";
import { catalogErrors, FEATURES, featureOn, withFeature, type CatalogError } from "@swiss/lib/profile";

// The copied swiss types predate writable.
Expand All @@ -21,7 +22,7 @@ export interface TokenField {
onChange: (v: string) => void;
}

const GROUPS = ["placement", "catalog", "model", "inputs", "routing", "auth", "scaler", "pdScale", "defaults", "sites"] as const;
const GROUPS = ["placement", "catalog", "model", "inputs", "presets", "routing", "auth", "scaler", "pdScale", "defaults", "sites"] as const;

export function ProfileForm({
value: p,
Expand Down Expand Up @@ -132,6 +133,8 @@ export function ProfileForm({
<KV label={f("inputs")} hint={f("inputsHint")} value={answersText(p.inputs)} onChange={(o) => set({ inputs: parseAnswers(o) })} />,
)}

{group("presets", <PresetTable presets={p.presets} onChange={(presets) => set({ presets })} />)}

{group(
"routing",
<>
Expand Down
3 changes: 2 additions & 1 deletion web/src/modules/inferences/deployment-settings.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { EMPTY as SWISS_EMPTY, formFromPlan as swissFormFromPlan, planRequest as swissPlanRequest, type Form } from "@swiss/components/DeploySettings";
import type { Plan, SiteProfile } from "@swiss/lib/api";
import { presetValues } from "@swiss/lib/formLayer";

// The site's monitor ConfigMap (modelRoute.monitor.outputConfigMap) turns Monitor on in the form layer.
// An unknown profile (unread, unparsed) decides nothing: an upgrade keeps its plan's Monitor.
Expand All @@ -15,4 +16,4 @@ export const EMPTY: Form = monitorSettings(SWISS_EMPTY, false);
export const formFromPlan = (plan: Plan): Form => swissFormFromPlan(plan);

export const planRequest = (form: Form, options: Parameters<typeof swissPlanRequest>[1], profile?: SiteProfile) =>
swissPlanRequest(profile ? monitorSettings(form, monitorOn(profile)) : form, options);
swissPlanRequest(profile ? monitorSettings(form, monitorOn(profile)) : form, { ...options, preset: presetValues(profile, form.preset) });
Loading
Loading