diff --git a/web/src/modules/inferences/lib.test.ts b/web/src/modules/inferences/lib.test.ts index 3142772..537ff98 100644 --- a/web/src/modules/inferences/lib.test.ts +++ b/web/src/modules/inferences/lib.test.ts @@ -238,14 +238,20 @@ describe("installSteps", () => { { type: "ScalingLimited", status: "True", reason: "TooManyReplicas" }, ])).toEqual({ key: "scaler", state: "ok", detail: "steady", params: { n: 1 } }); }); - it("holds while a rollout or scale-down guard is in effect", () => { + it("reports a hold as steady even when the replica count has not caught up", () => { for (const reason of ["RolloutInProgress", "ScaleDownSettling", "ScaleDownStabilized"]) { expect(step([ { type: "ScalingActive", status: "True", reason: "MetricsAvailable" }, { type: "AbleToScale", status: "True", reason }, - ], 3, 1)).toEqual({ key: "scaler", state: "wait", detail: "deferred", params: { reason } }); + ], 3, 1)).toEqual({ key: "scaler", state: "ok", detail: "steady", params: { n: 3 } }); } }); + it("lets a failure win over a hold", () => { + expect(step([ + { type: "ScalingActive", status: "False", reason: "DecisionUnavailable" }, + { type: "AbleToScale", status: "True", reason: "ScaleDownStabilized" }, + ], 3, 1)).toEqual({ key: "scaler", state: "bad", detail: "failing", params: { reason: "DecisionUnavailable" } }); + }); it("treats DeferralTimedOut as a normal scale", () => { expect(step([ { type: "ScalingActive", status: "True", reason: "DecisionReceived" }, diff --git a/web/src/modules/inferences/lib.ts b/web/src/modules/inferences/lib.ts index bec3598..a97ce09 100644 --- a/web/src/modules/inferences/lib.ts +++ b/web/src/modules/inferences/lib.ts @@ -12,6 +12,7 @@ import type { Run, } from "@swiss/lib/api"; import { isUninstalling } from "@swiss/lib/phase"; +import { scalerFailing, scalerHold } from "@swiss/lib/scaler"; export type Tone = "success" | "info" | "warning" | "error" | "muted"; @@ -159,35 +160,23 @@ function routeStep({ result, spec, status }: Picked): Step { const key = "scaler"; if (!result) return { key, state: "off", detail: "none" }; if (result.error) return { key, state: "wait", detail: "unreadable" }; if (result.missing) return { key, state: "bad", detail: "deleted" }; - // DecisionAvailable is a deprecated mirror of ScalingActive and the only condition operator 0.5.0 writes. - const conditions = status?.conditions ?? []; - const ofType = (type: string) => conditions.find((c) => c.type === type); - const active = ofType("ScalingActive") ?? ofType("DecisionAvailable"); - const able = ofType("AbleToScale"); - - const failing = able?.status === "False" ? able : active?.status === "False" ? active : undefined; + const failing = scalerFailing(status?.conditions); if (failing) { const reason = failing.reason || failing.type; if (failing.message) return { key, state: "bad", detail: "failingMessage", params: { reason, message: failing.message } }; return { key, state: "bad", detail: "failing", params: { reason } }; } - if (able?.status === "True" && able.reason && SCALE_HOLD_REASONS.has(able.reason)) { - return { key, state: "wait", detail: "deferred", params: { reason: able.reason } }; - } - const cur = status?.currentReplicas; const want = status?.desiredReplicas; if (cur === undefined) return { key, state: "wait", detail: "noDecision" }; - if (want !== undefined && want !== cur) return { key, state: "wait", detail: "scaling", params: { cur, want } }; + if (want !== undefined && want !== cur && !scalerHold(status?.conditions)) + return { key, state: "wait", detail: "scaling", params: { cur, want } }; return { key, state: "ok", detail: "steady", params: { n: cur } }; } diff --git a/web/src/modules/inferences/locales/en-US.json b/web/src/modules/inferences/locales/en-US.json index 30bc09c..7b8be0d 100644 --- a/web/src/modules/inferences/locales/en-US.json +++ b/web/src/modules/inferences/locales/en-US.json @@ -198,7 +198,6 @@ "deleted": "LLMScaler deleted", "failing": "{reason}", "failingMessage": "{reason}: {message}", - "deferred": "Holding: {reason}", "noDecision": "No decision yet", "scaling": "{cur} → {want} replicas", "steady": "{n} replicas, steady" diff --git a/web/src/modules/inferences/locales/zh-CN.json b/web/src/modules/inferences/locales/zh-CN.json index 7b85367..102077d 100644 --- a/web/src/modules/inferences/locales/zh-CN.json +++ b/web/src/modules/inferences/locales/zh-CN.json @@ -187,7 +187,6 @@ "deleted": "LLMScaler 已被删除", "failing": "{reason}", "failingMessage": "{reason}:{message}", - "deferred": "伸缩暂缓:{reason}", "noDecision": "还没有伸缩决策", "scaling": "{cur} → {want} 副本", "steady": "{n} 副本,稳定" diff --git a/web/src/modules/swiss/components/Conditions.test.tsx b/web/src/modules/swiss/components/Conditions.test.tsx new file mode 100644 index 0000000..edd4b5e --- /dev/null +++ b/web/src/modules/swiss/components/Conditions.test.tsx @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { renderToString } from "react-dom/server"; +import type { Condition } from "@swiss/lib/api"; +import { Conditions } from "@swiss/components/ReleaseObjects"; + +const able = (status: Condition["status"]): Condition => ({ type: "AbleToScale", status, reason: "ScaleDownStabilized" }); +const active: Condition = { type: "ScalingActive", status: "True", reason: "MetricsAvailable" }; + +// The dot is the rounded-full span immediately before the condition type. +function dotClass(html: string, type: string): string { + const at = html.indexOf(`>${type}<`); + const marks = [...html.slice(0, at).matchAll(/class="([^"]*rounded-full[^"]*)"/g)]; + return marks.at(-1)?.[1] ?? ""; +} + +describe("Conditions", () => { + it("colors a warned condition bg-warning and leaves the others alone", () => { + const conditions = [able("True"), active]; + const warned = renderToString(); + expect(dotClass(warned, "AbleToScale")).toContain("bg-warning"); + expect(dotClass(warned, "ScalingActive")).not.toContain("bg-warning"); + + const plain = renderToString(); + expect(dotClass(plain, "AbleToScale")).toContain("bg-success"); + expect(dotClass(plain, "AbleToScale")).not.toContain("bg-warning"); + + // warn is checked before neutral, so a listed type stays yellow even when neutral would mute it. + const both = renderToString(); + expect(dotClass(both, "AbleToScale")).toContain("bg-warning"); + expect(dotClass(both, "AbleToScale")).not.toContain("bg-muted-foreground"); + }); +}); diff --git a/web/src/modules/swiss/components/ReleaseObjects.tsx b/web/src/modules/swiss/components/ReleaseObjects.tsx index c3b2b5f..d32fe21 100644 --- a/web/src/modules/swiss/components/ReleaseObjects.tsx +++ b/web/src/modules/swiss/components/ReleaseObjects.tsx @@ -17,6 +17,7 @@ import { HoverHint } from "@swiss/components/ui/hint"; import { Button } from "@swiss/components/ui/button"; import { Card } from "@swiss/components/ui/card"; import { ErrorState } from "@swiss/components/States"; +import { scalerFailing, scalerHold, scalerNeutral } from "@swiss/lib/scaler"; import { cn, timeAgo } from "@swiss/lib/utils"; // Unpolled, it loads once per visit and then moves only when the page's own @@ -247,11 +248,13 @@ function ScalerPanel({ const current = status?.currentReplicas; const desired = status?.desiredReplicas; const failing = scalerFailing(status?.conditions); + const hold = scalerHold(status?.conditions); const custom = spec?.metricProvider === "Custom"; let state: React.ReactNode = null; if (failing) state = {failing.reason || failing.type}; + else if (hold) state = steady; else if (current !== undefined && desired !== undefined) { if (desired > current) state = scaling up; else if (desired < current) @@ -349,7 +352,11 @@ function ScalerPanel({ )} - + )} @@ -681,18 +688,15 @@ function Stale({ ); } -// Only AbleToScale=False or ScalingActive=False (DecisionAvailable on operator 0.5.0) is a scaler failure. -export function scalerFailing(conditions?: Condition[]): Condition | undefined { - const of = (type: string) => conditions?.find((c) => c.type === type); - return [of("AbleToScale"), of("ScalingActive") ?? of("DecisionAvailable")].find((c) => c?.status === "False"); -} - -// ScalingLimited is informational; DecisionAvailable only mirrors ScalingActive when that is present. -function scalerNeutral(conditions?: Condition[]): string[] { - return conditions?.some((c) => c.type === "ScalingActive") ? ["ScalingLimited", "DecisionAvailable"] : ["ScalingLimited"]; -} - -export function Conditions({ conditions, neutral = [] }: { conditions?: Condition[]; neutral?: string[] }) { +export function Conditions({ + conditions, + neutral = [], + warn = [], +}: { + conditions?: Condition[]; + neutral?: string[]; + warn?: string[]; +}) { if (!conditions?.length) return null; return (
    @@ -702,13 +706,15 @@ export function Conditions({ conditions, neutral = [] }: { conditions?: Conditio {c.type} diff --git a/web/src/modules/swiss/components/scaler-conditions.test.ts b/web/src/modules/swiss/components/scaler-conditions.test.ts deleted file mode 100644 index a9b1889..0000000 --- a/web/src/modules/swiss/components/scaler-conditions.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { scalerFailing } from "@swiss/components/ReleaseObjects"; - -describe("scalerFailing", () => { - it("does not treat ScalingLimited as a failure", () => { - expect(scalerFailing([ - { type: "ScalingActive", status: "True", reason: "DecisionReceived" }, - { type: "AbleToScale", status: "True", reason: "Synced" }, - { type: "ScalingLimited", status: "False", reason: "DesiredWithinRange" }, - ])).toBeUndefined(); - }); - it("fails on AbleToScale=False or ScalingActive=False", () => { - expect(scalerFailing([{ type: "AbleToScale", status: "False", reason: "TargetNotFound" }])?.reason).toBe("TargetNotFound"); - expect(scalerFailing([{ type: "ScalingActive", status: "False", reason: "InvalidSpec" }])?.reason).toBe("InvalidSpec"); - }); - it("uses DecisionAvailable only when ScalingActive is absent", () => { - expect(scalerFailing([{ type: "DecisionAvailable", status: "False", reason: "DecisionUnavailable" }])?.reason).toBe("DecisionUnavailable"); - expect(scalerFailing([ - { type: "ScalingActive", status: "True", reason: "MetricsAvailable" }, - { type: "DecisionAvailable", status: "False", reason: "DecisionUnavailable" }, - ])).toBeUndefined(); - }); -}); diff --git a/web/src/modules/swiss/lib/scaler.test.ts b/web/src/modules/swiss/lib/scaler.test.ts new file mode 100644 index 0000000..b034f6c --- /dev/null +++ b/web/src/modules/swiss/lib/scaler.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest"; +import type { Condition, ObjectResult } from "@swiss/lib/api"; +import { scalerFailing, scalerHold, scalerNeutral } from "@swiss/lib/scaler"; +import { scalerStep } from "@swiss/routes/DeploymentDetail"; + +const c = (type: string, status: Condition["status"], reason?: string): Condition => ({ type, status, reason }); + +describe("scalerFailing", () => { + it("does not treat ScalingLimited as a failure", () => { + expect(scalerFailing([ + c("ScalingActive", "True", "DecisionReceived"), + c("AbleToScale", "True", "Synced"), + c("ScalingLimited", "False", "DesiredWithinRange"), + ])).toBeUndefined(); + }); + it("fails on AbleToScale=False or ScalingActive=False", () => { + expect(scalerFailing([c("AbleToScale", "False", "TargetNotFound")])?.reason).toBe("TargetNotFound"); + expect(scalerFailing([c("ScalingActive", "False", "InvalidSpec")])?.reason).toBe("InvalidSpec"); + }); + it("prefers AbleToScale when both are False", () => { + expect(scalerFailing([c("ScalingActive", "False", "InvalidSpec"), c("AbleToScale", "False", "TargetWriteFailed")])?.reason) + .toBe("TargetWriteFailed"); + }); + it("uses DecisionAvailable only when ScalingActive is absent", () => { + expect(scalerFailing([c("DecisionAvailable", "False", "DecisionUnavailable")])?.reason).toBe("DecisionUnavailable"); + expect(scalerFailing([c("ScalingActive", "True", "MetricsAvailable"), c("DecisionAvailable", "False", "DecisionUnavailable")])) + .toBeUndefined(); + }); +}); + +describe("scalerHold", () => { + it("returns the hold reason of AbleToScale=True", () => { + for (const reason of ["RolloutInProgress", "ScaleDownSettling", "ScaleDownStabilized"]) + expect(scalerHold([c("AbleToScale", "True", reason)])).toBe(reason); + }); + it("is undefined for a normal scale or a failure", () => { + expect(scalerHold([c("AbleToScale", "True", "Synced")])).toBeUndefined(); + expect(scalerHold([c("AbleToScale", "True", "DeferralTimedOut")])).toBeUndefined(); + expect(scalerHold([c("AbleToScale", "False", "ScaleDownStabilized")])).toBeUndefined(); + expect(scalerHold(undefined)).toBeUndefined(); + }); +}); + +describe("scalerNeutral", () => { + it("always includes ScalingLimited, and DecisionAvailable only next to ScalingActive", () => { + expect(scalerNeutral([c("DecisionAvailable", "True", "DecisionReceived")])).toEqual(["ScalingLimited"]); + expect(scalerNeutral([c("ScalingActive", "True", "DecisionReceived")])).toEqual(["ScalingLimited", "DecisionAvailable"]); + expect(scalerNeutral(undefined)).toEqual(["ScalingLimited"]); + }); +}); + +describe("scalerStep", () => { + const result = { ref: { apiVersion: "v1", kind: "LLMScaler", name: "r" } } as ObjectResult; + const step = (conditions: Condition[], cur = 1, want = 1) => + scalerStep({ result, spec: undefined, status: { currentReplicas: cur, desiredReplicas: want, conditions } }); + + it("is steady with ScalingLimited=False", () => { + expect(step([c("ScalingActive", "True", "DecisionReceived"), c("AbleToScale", "True", "Synced"), c("ScalingLimited", "False", "DesiredWithinRange")])) + .toEqual({ label: "Scaling", state: "ok", detail: "1 replica, steady" }); + }); + it("fails on AbleToScale=False", () => { + expect(step([c("ScalingActive", "True", "DecisionReceived"), c("AbleToScale", "False", "TargetNotFound")])) + .toEqual({ label: "Scaling", state: "bad", detail: "TargetNotFound" }); + }); + it("reports a hold as steady", () => { + for (const reason of ["RolloutInProgress", "ScaleDownSettling", "ScaleDownStabilized"]) { + expect(step([c("ScalingActive", "True", "MetricsAvailable"), c("AbleToScale", "True", reason)], 3, 1)) + .toEqual({ label: "Scaling", state: "ok", detail: "3 replicas, steady" }); + } + }); + it("lets a failure win over a hold", () => { + expect(step([c("ScalingActive", "False", "DecisionUnavailable"), c("AbleToScale", "True", "ScaleDownStabilized")], 3, 1)) + .toEqual({ label: "Scaling", state: "bad", detail: "DecisionUnavailable" }); + }); + it("reads a healthy 0.5.0 object with DecisionAvailable=True", () => { + expect(step([c("DecisionAvailable", "True", "DecisionReceived")], 2, 2)) + .toEqual({ label: "Scaling", state: "ok", detail: "2 replicas, steady" }); + }); + it("reads a Prometheus object with no conditions from its replicas", () => { + expect(step([], 1, 3)).toEqual({ label: "Scaling", state: "wait", detail: "1 → 3 replicas" }); + expect(step([], 3, 3)).toEqual({ label: "Scaling", state: "ok", detail: "3 replicas, steady" }); + }); +}); diff --git a/web/src/modules/swiss/lib/scaler.ts b/web/src/modules/swiss/lib/scaler.ts new file mode 100644 index 0000000..4be2057 --- /dev/null +++ b/web/src/modules/swiss/lib/scaler.ts @@ -0,0 +1,24 @@ +import type { Condition } from "@swiss/lib/api"; + +const of = (conditions: Condition[] | undefined, type: string) => conditions?.find((c) => c.type === type); + +// Only AbleToScale=False or ScalingActive=False (DecisionAvailable on operator 0.5.0) is a scaler failure. +export function scalerFailing(conditions?: Condition[]): Condition | undefined { + return [of(conditions, "AbleToScale"), of(conditions, "ScalingActive") ?? of(conditions, "DecisionAvailable")].find( + (c) => c?.status === "False", + ); +} + +// AbleToScale=True reasons that hold a step. A hold reports as steady; only its AbleToScale +// condition is marked. Synced and DeferralTimedOut are a normal scale. +const HOLD_REASONS = new Set(["RolloutInProgress", "ScaleDownSettling", "ScaleDownStabilized"]); + +export function scalerHold(conditions?: Condition[]): string | undefined { + const able = of(conditions, "AbleToScale"); + return able?.status === "True" && able.reason && HOLD_REASONS.has(able.reason) ? able.reason : undefined; +} + +// ScalingLimited is informational; DecisionAvailable only mirrors ScalingActive when that is present. +export function scalerNeutral(conditions?: Condition[]): string[] { + return of(conditions, "ScalingActive") ? ["ScalingLimited", "DecisionAvailable"] : ["ScalingLimited"]; +} diff --git a/web/src/modules/swiss/routes/DeploymentDetail.tsx b/web/src/modules/swiss/routes/DeploymentDetail.tsx index 1fd25cc..f4f10ee 100644 --- a/web/src/modules/swiss/routes/DeploymentDetail.tsx +++ b/web/src/modules/swiss/routes/DeploymentDetail.tsx @@ -32,7 +32,8 @@ import { Dialog } from "@swiss/components/ui/dialog"; import { Field, Input } from "@swiss/components/ui/input"; import { CatalogModelDiff } from "@swiss/components/CatalogModelDiff"; import { Provenance } from "@swiss/components/Provenance"; -import { ReleaseObjects, pick, scalerFailing, useReleaseObjects } from "@swiss/components/ReleaseObjects"; +import { ReleaseObjects, pick, useReleaseObjects } from "@swiss/components/ReleaseObjects"; +import { scalerFailing, scalerHold } from "@swiss/lib/scaler"; import { AccessPoint } from "@swiss/components/Endpoint"; import { ReleaseStatus } from "@swiss/components/ReleaseStatus"; import { SLODialog } from "@swiss/components/SLOCard"; @@ -337,7 +338,7 @@ function routeStep({ result, spec, status }: ReturnType>): Step { +export function scalerStep({ result, status }: ReturnType>): Step { const label = "Scaling"; if (!result) return { label, state: "off", detail: "fixed replicas" }; if (result.error) return { label, state: "wait", detail: "unreadable" }; @@ -347,7 +348,8 @@ function scalerStep({ result, status }: ReturnType