Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions web/src/modules/canary/lib/emit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,6 @@ describe("emitManifests", () => {
draft.traffic = { production: 0, canary: 0 };
expect(wizardStepComplete("arms", draft)).toBe(true);
expect(wizardStepComplete("traffic", draft)).toBe(false);
expect(wizardStepComplete("gateway", draft)).toBe(false);
expect(wizardStepComplete("route", draft)).toBe(false);

const bare = emitManifests(draft);
Expand All @@ -113,7 +112,7 @@ describe("emitManifests", () => {
expect(paths).toContain("spec.route.path");
expect(paths).toContain("spec.route.productionOpenrestyService");
expect(bare.canary.spec.arms).toHaveLength(1);
expect(bare.canary.spec.route.enabled).toBe(false);
expect(bare.canary.spec.route.enabled).toBe(true);

draft.traffic = { production: 99, canary: 1 };
draft.gateway = { name: "openresty", namespace: "llm-route" };
Expand All @@ -127,7 +126,6 @@ describe("emitManifests", () => {
const ready = emitManifests(draft);
expect(ready.issues).toEqual([]);
expect(wizardStepComplete("traffic", draft)).toBe(true);
expect(wizardStepComplete("gateway", draft)).toBe(true);
expect(wizardStepComplete("route", draft)).toBe(true);
expect(ready.canary.spec.route).toMatchObject({
enabled: false,
Expand Down
4 changes: 2 additions & 2 deletions web/src/modules/canary/lib/emit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,10 +132,10 @@ export function emptyDraft(): CanaryDraft {
traffic: { production: 90, canary: 10 },
gateway: { name: "", namespace: "" },
route: {
enabled: false,
enabled: true,
productionOpenrestyService: "",
path: "",
forceHeader: { name: "", value: "" },
forceHeader: { name: "x-canary", value: "force" },
},
proxy: { mode: "PerArm", replicas: 2, sessionAffinity: false },
};
Expand Down
25 changes: 18 additions & 7 deletions web/src/modules/canary/lib/target.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,6 @@ describe("applyProductionTarget", () => {
expect(next.route.servicePort).toBeUndefined();
expect(next.route.enabled).toBe(previous.route.enabled);
expect(next.traffic).toEqual(previous.traffic);
expect(wizardStepComplete("gateway", next)).toBe(false);
expect(wizardStepComplete("route", next)).toBe(false);
});

Expand Down Expand Up @@ -94,25 +93,37 @@ describe("applyProductionTarget", () => {
expect(productionDraft.route.path).toBe("/production-model");
expect(productionDraft.route.productionOpenrestyService).toBe("production-dispatch");
expect(productionDraft.route.servicePort).toBeUndefined();
expect(productionDraft.route.enabled).toBe(true);
expect(productionDraft.route.forceHeader).toEqual({ name: "x-canary", value: "force" });
const { canary } = emitManifests(productionDraft);
expect(canary.spec.gateway).toEqual(productionDraft.gateway);
expect(canary.spec.route).toEqual({
enabled: true,
path: "/production-model",
productionOpenrestyService: "production-dispatch",
forceHeader: { name: "x-canary", value: "force" },
});
expect(canary.spec.proxy).not.toHaveProperty("values");
});
});

describe("gateway required fields", () => {
it("blocks a blank gateway name or namespace in the wizard and on the CR", () => {
const draft = emptyDraft();
expect(wizardStepComplete("gateway", draft)).toBe(false);
const draft = applyProductionTarget(emptyDraft(), qwen!);
draft.gateway = { name: "", namespace: "" };
expect(wizardStepComplete("route", draft)).toBe(false);

draft.gateway = { name: " ", namespace: "llm-route" };
expect(wizardStepComplete("gateway", draft)).toBe(false);
expect(wizardStepComplete("route", draft)).toBe(false);

draft.gateway = { name: "openresty", namespace: " " };
expect(wizardStepComplete("gateway", draft)).toBe(false);
expect(wizardStepComplete("route", draft)).toBe(false);

draft.gateway = { name: "openresty", namespace: "" };
expect(wizardStepComplete("gateway", draft)).toBe(false);
expect(wizardStepComplete("route", draft)).toBe(false);

draft.gateway = { name: "openresty", namespace: "llm-route" };
expect(wizardStepComplete("gateway", draft)).toBe(true);
expect(wizardStepComplete("route", draft)).toBe(true);

const blank = emptyDraft();
blank.gateway = { name: " ", namespace: " " };
Expand Down
8 changes: 2 additions & 6 deletions web/src/modules/canary/lib/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ export const WIZARD_STEPS = [
"target",
"arms",
"traffic",
"gateway",
"route",
"review",
] as const;
Expand Down Expand Up @@ -215,13 +214,10 @@ export function wizardStepComplete(
weightIssues(draft.traffic.production, draft.traffic.canary).length ===
0
);
case "gateway":
return (
draft.gateway.name.trim().length > 0 &&
dnsLabel(draft.gateway.namespace)
);
case "route":
return (
draft.gateway.name.trim().length > 0 &&
dnsLabel(draft.gateway.namespace) &&
ROUTE_PATH.test(draft.route.path ?? "") &&
dnsLabel(draft.route.productionOpenrestyService)
);
Expand Down
12 changes: 6 additions & 6 deletions web/src/modules/canary/locales.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ export const en = {
environmentUnset: "Not configured (environment default)",
environmentDefaultPort: "Use environment default port",
armsHint:
"Choose a catalog, then a model and variant for each arm. The default catalog is upstreamv2 when available.",
"Each arm takes one catalog schema v2 variant: chart and values. The traffic split and HTTPRoute are the following steps. Gateway configuration is supplied by the environment.",
addArm: "Add arm",
removeArm: "Remove",
armName: "Arm name",
Expand Down Expand Up @@ -103,7 +103,7 @@ export const en = {
sectionHint:
"Read-only environment listener. Empty means no specific listener is selected.",
routeHint:
"The operator creates this HTTPRoute and owns it. enabled writes or deletes the route. path is the subpath the canary claims.",
"The operator automatically creates and owns this HTTPRoute. The path and production Service come from the environment.",
routeEnabled: "Write HTTPRoute (route.enabled)",
routeEnabledHint:
"Off leaves the arms and proxy running and writes no route, so production keeps serving the path.",
Expand All @@ -128,7 +128,7 @@ export const en = {
needTraffic:
"Set production and canary weights. At least one must be non-zero.",
needGateway: "Enter the Gateway name and namespace.",
needRoute: "Enter an HTTPRoute path and the production openresty Service.",
needRoute: "The selected production target needs Gateway, route path and production Service configuration. Contact an administrator to configure the environment.",
summary: "Canary definition",
yaml: "LLMCanary YAML",
copy: "Copy YAML",
Expand Down Expand Up @@ -217,7 +217,7 @@ export const zh = {
environmentUnset: "未配置(使用环境默认行为)",
environmentDefaultPort: "使用环境默认端口",
armsHint:
"每个组别先选模型目录,再选该目录中的模型和变体。默认使用 upstreamv2(如已配置)。",
"每个组别选择一个目录 schema v2 变体,只决定 chart 和 values。后续步骤设置流量拆分并确认路由,Gateway 自动使用环境配置。",
addArm: "添加组别",
removeArm: "删除",
armName: "组别名称",
Expand Down Expand Up @@ -269,7 +269,7 @@ export const zh = {
sectionName: "监听器 (sectionName)",
sectionHint: "环境配置的监听器,只读。留空表示不指定某个 listener。",
routeHint:
"operator 创建并拥有这条 HTTPRoute。enabled 决定是否写入路由。path 是北极星实验声明的子路径。",
"operator 自动创建并拥有这条 HTTPRoute。路径和生产 Service 使用环境配置。",
routeEnabled: "写入 HTTPRoute (route.enabled)",
routeEnabledHint:
"关闭时各组别和 proxy 继续运行,不写路由,生产继续服务该路径。",
Expand All @@ -290,7 +290,7 @@ export const zh = {
needArms: "从目录变体添加一个组别。",
needTraffic: "填写生产与北极星权重,至少一个非零。",
needGateway: "填写 Gateway 名称和命名空间。",
needRoute: "填写 HTTPRoute 路径和生产 openresty Service。",
needRoute: "所选生产目标缺少 Gateway、路由路径或生产 Service 配置,请联系管理员完善环境配置。",
summary: "北极星实验定义",
yaml: "LLMCanary YAML",
copy: "复制 YAML",
Expand Down
129 changes: 3 additions & 126 deletions web/src/modules/canary/pages/CanaryCreate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ import {
FormSection,
PageBanner,
SectionCard,
Switch,
cn,
} from "@modelsphere/ui";
import { useCanaryApi, useCanaryWrite } from "../lib/provider";
Expand Down Expand Up @@ -93,7 +92,6 @@ function stepLabel(step: Step): `create.step${string}` {
function removeArmEditor<T>(current: Record<string, T>, removed: number) {
return Object.fromEntries(
Object.entries(current).flatMap(([key, value]) => {
if (key === "proxy") return [[key, value]];
const index = Number(key.slice(4));
return index === removed
? []
Expand Down Expand Up @@ -198,12 +196,8 @@ export function CanaryCreate() {
const armsYamlInvalid = Object.entries(yamlErrors).some(
([key, message]) => key.startsWith("arm-") && !!message,
);
// Only the step that owns the editor is blocked by its YAML error. A global
// lock strands the user on the wrong step: the error is rendered on the
// editor's step, but every forward button refuses to take them back to it.
const stepYamlInvalid =
(step === "arms" && armsYamlInvalid) ||
(step === "route" && !!yamlErrors.proxy);
// Keep navigation back to the arm editor available when its YAML is invalid.
const stepYamlInvalid = step === "arms" && armsYamlInvalid;
const targets = deployments.data ?? [];
const selectedTarget = targets.find(
(d) =>
Expand Down Expand Up @@ -255,17 +249,12 @@ export function CanaryCreate() {

function blocker(): string | null {
if (invalidYaml) {
const steps = [
armsYamlInvalid ? t("create.stepArms") : null,
yamlErrors.proxy ? t("create.stepRoute") : null,
].filter((label): label is string => !!label);
return t("create.yamlBlocked", { steps: steps.join(" / ") });
return t("create.yamlBlocked", { steps: t("create.stepArms") });
}
if (stepReady || step === "review") return null;
if (step === "target") return t("create.needTarget");
if (step === "arms") return t("create.needArms");
if (step === "traffic") return t("create.needTraffic");
if (step === "gateway") return t("create.needGateway");
return t("create.needRoute");
}

Expand Down Expand Up @@ -580,38 +569,6 @@ export function CanaryCreate() {
</FormSection>
)}

{step === "gateway" && (
<FormSection
title={t("create.stepGateway")}
summary={t("create.gatewayHint")}
>
<div className="grid gap-3 md:grid-cols-2">
<EnvironmentField
label={t("create.gatewayName")}
value={draft.gateway.name}
emptyText={t("create.selectTargetFirst")}
/>
<EnvironmentField
label={t("create.gatewayNs")}
value={draft.gateway.namespace}
emptyText={t("create.selectTargetFirst")}
/>
<EnvironmentField
label={t("create.hostname")}
hint={t("create.hostnameHint")}
value={draft.gateway.hostname}
emptyText={t("create.environmentUnset")}
/>
<EnvironmentField
label={t("create.sectionName")}
hint={t("create.sectionHint")}
value={draft.gateway.sectionName}
emptyText={t("create.environmentUnset")}
/>
</div>
</FormSection>
)}

{step === "route" && (
<FormSection
title={t("create.stepRoute")}
Expand All @@ -630,87 +587,7 @@ export function CanaryCreate() {
value={draft.route.productionOpenrestyService}
emptyText={t("create.selectTargetFirst")}
/>
<div className="md:col-span-2">
<FloatingField
layout="inline"
label={t("create.routeEnabled")}
hint={t("create.routeEnabledHint")}
>
<Switch
checked={draft.route.enabled}
onCheckedChange={(checked) =>
patch({ route: { ...draft.route, enabled: checked } })
}
aria-label={t("create.routeEnabled")}
/>
</FloatingField>
</div>
<EnvironmentField
label={t("create.servicePort")}
hint={t("create.servicePortHint")}
value={t("create.environmentDefaultPort")}
/>
<FloatingField label={`${t("create.forceHeader")} name`}>
<FieldInput
name="canary-force-header"
autoComplete="off"
value={draft.route.forceHeader.name}
onChange={(e) =>
patch({
route: {
...draft.route,
forceHeader: {
...draft.route.forceHeader,
name: e.target.value,
},
},
})
}
/>
</FloatingField>
<FloatingField label={`${t("create.forceHeader")} value`}>
<FieldInput
name="canary-force-header-value"
autoComplete="off"
value={draft.route.forceHeader.value}
onChange={(e) =>
patch({
route: {
...draft.route,
forceHeader: {
...draft.route.forceHeader,
value: e.target.value,
},
},
})
}
/>
</FloatingField>
</div>
<FloatingField
label={t("create.proxyValues")}
hint={t("create.proxyValuesHint")}
error={
yamlErrors.proxy
? describeError(yamlErrors.proxy, t).summary
: undefined
}
multiline
>
<FieldTextarea
rows={8}
value={
yamlTexts.proxy ??
(draft.proxy.values ? toYaml(draft.proxy.values) : "")
}
onChange={(e) =>
editYaml("proxy", e.target.value, (values) =>
patch({ proxy: { ...draft.proxy, values } }),
)
}
/>
</FloatingField>
{yamlErrors.proxy && <ErrorDetails error={yamlErrors.proxy} />}
</FormSection>
)}

Expand Down
Loading