From 770edd8e0b16e9b6d6a488fedd2825143e35c584 Mon Sep 17 00:00:00 2001 From: wenyunqing Date: Fri, 23 Oct 2026 14:40:01 +0800 Subject: [PATCH] fix(web): use environment defaults in canary creation Remove the Gateway step and route infrastructure editors. New experiments enable HTTPRoute with x-canary: force and inherit the environment dispatch port and proxy values. Fixes #95 --- web/src/modules/canary/lib/emit.test.ts | 4 +- web/src/modules/canary/lib/emit.ts | 4 +- web/src/modules/canary/lib/target.test.ts | 25 +++- web/src/modules/canary/lib/validate.ts | 8 +- web/src/modules/canary/locales.ts | 12 +- web/src/modules/canary/pages/CanaryCreate.tsx | 129 +----------------- 6 files changed, 32 insertions(+), 150 deletions(-) diff --git a/web/src/modules/canary/lib/emit.test.ts b/web/src/modules/canary/lib/emit.test.ts index 6d559a1..4623e8f 100644 --- a/web/src/modules/canary/lib/emit.test.ts +++ b/web/src/modules/canary/lib/emit.test.ts @@ -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); @@ -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" }; @@ -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, diff --git a/web/src/modules/canary/lib/emit.ts b/web/src/modules/canary/lib/emit.ts index c317ff3..e29d800 100644 --- a/web/src/modules/canary/lib/emit.ts +++ b/web/src/modules/canary/lib/emit.ts @@ -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 }, }; diff --git a/web/src/modules/canary/lib/target.test.ts b/web/src/modules/canary/lib/target.test.ts index 104288f..cf97159 100644 --- a/web/src/modules/canary/lib/target.test.ts +++ b/web/src/modules/canary/lib/target.test.ts @@ -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); }); @@ -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: " " }; diff --git a/web/src/modules/canary/lib/validate.ts b/web/src/modules/canary/lib/validate.ts index 2c2e850..a789a9a 100644 --- a/web/src/modules/canary/lib/validate.ts +++ b/web/src/modules/canary/lib/validate.ts @@ -21,7 +21,6 @@ export const WIZARD_STEPS = [ "target", "arms", "traffic", - "gateway", "route", "review", ] as const; @@ -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) ); diff --git a/web/src/modules/canary/locales.ts b/web/src/modules/canary/locales.ts index dfa7267..3497551 100644 --- a/web/src/modules/canary/locales.ts +++ b/web/src/modules/canary/locales.ts @@ -49,7 +49,7 @@ export const en = { environmentUnset: "Not configured (environment default)", environmentDefaultPort: "Use environment default port", armsHint: - "Each arm takes one catalog schema v2 variant: chart and values. The traffic split, Gateway, and HTTPRoute are the following steps.", + "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", @@ -91,7 +91,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.", @@ -116,7 +116,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", @@ -205,7 +205,7 @@ export const zh = { environmentUnset: "未配置(使用环境默认行为)", environmentDefaultPort: "使用环境默认端口", armsHint: - "每个组别选择一个目录 schema v2 变体,只决定 chart 和 values。流量拆分、Gateway 和 HTTPRoute 在后面的步骤里填写。", + "每个组别选择一个目录 schema v2 变体,只决定 chart 和 values。后续步骤设置流量拆分并确认路由,Gateway 自动使用环境配置。", addArm: "添加组别", removeArm: "删除", armName: "组别名称", @@ -245,7 +245,7 @@ export const zh = { sectionName: "监听器 (sectionName)", sectionHint: "环境配置的监听器,只读。留空表示不指定某个 listener。", routeHint: - "operator 创建并拥有这条 HTTPRoute。enabled 决定是否写入路由。path 是北极星实验声明的子路径。", + "operator 自动创建并拥有这条 HTTPRoute。路径和生产 Service 使用环境配置。", routeEnabled: "写入 HTTPRoute (route.enabled)", routeEnabledHint: "关闭时各组别和 proxy 继续运行,不写路由,生产继续服务该路径。", @@ -266,7 +266,7 @@ export const zh = { needArms: "从目录变体添加一个组别。", needTraffic: "填写生产与北极星权重,至少一个非零。", needGateway: "填写 Gateway 名称和命名空间。", - needRoute: "填写 HTTPRoute 路径和生产 openresty Service。", + needRoute: "所选生产目标缺少 Gateway、路由路径或生产 Service 配置,请联系管理员完善环境配置。", summary: "北极星实验定义", yaml: "LLMCanary YAML", copy: "复制 YAML", diff --git a/web/src/modules/canary/pages/CanaryCreate.tsx b/web/src/modules/canary/pages/CanaryCreate.tsx index a267405..cbead54 100644 --- a/web/src/modules/canary/pages/CanaryCreate.tsx +++ b/web/src/modules/canary/pages/CanaryCreate.tsx @@ -18,7 +18,6 @@ import { FormSection, PageBanner, SectionCard, - Switch, cn, } from "@modelsphere/ui"; import { useCanaryApi, useCanaryWrite } from "../lib/provider"; @@ -90,7 +89,6 @@ function stepLabel(step: Step): `create.step${string}` { function removeArmEditor(current: Record, 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 ? [] @@ -186,12 +184,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) => @@ -243,17 +237,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"); } @@ -570,38 +559,6 @@ export function CanaryCreate() { )} - {step === "gateway" && ( - -
- - - - -
-
- )} - {step === "route" && ( -
- - - patch({ route: { ...draft.route, enabled: checked } }) - } - aria-label={t("create.routeEnabled")} - /> - -
- - - - patch({ - route: { - ...draft.route, - forceHeader: { - ...draft.route.forceHeader, - name: e.target.value, - }, - }, - }) - } - /> - - - - patch({ - route: { - ...draft.route, - forceHeader: { - ...draft.route.forceHeader, - value: e.target.value, - }, - }, - }) - } - /> - - - - editYaml("proxy", e.target.value, (values) => - patch({ proxy: { ...draft.proxy, values } }), - ) - } - /> - - {yamlErrors.proxy && }
)}