From f215fa9e5ae924e89ca8e81f2c9459acc85f1775 Mon Sep 17 00:00:00 2001 From: huskcass <333684917+huskcass@users.noreply.github.com> Date: Mon, 26 Oct 2026 02:09:52 -0700 Subject: [PATCH 1/2] fix(swiss): show a held scale step on the scaler card and deploy page The scaler card and the standalone deploy page's Scaling step showed AbleToScale=True with RolloutInProgress, ScaleDownSettling or ScaleDownStabilized as steady, while the Overview tile showed it as waiting. Both now show "holding: " in the existing waiting style. - scalerFailing, scalerHold and scalerNeutral move to swiss/lib/scaler.ts - the inferences Scaling step, the scaler card and the deploy page's Scaling step all use them; the inferences step behaves as before - tests for the helpers and the deploy page's Scaling step Co-authored-by: aceforeverd <11715200+aceforeverd@users.noreply.github.com> --- web/src/modules/inferences/lib.ts | 17 +--- .../swiss/components/ReleaseObjects.tsx | 14 +--- .../components/scaler-conditions.test.ts | 23 ------ web/src/modules/swiss/lib/scaler.test.ts | 77 +++++++++++++++++++ web/src/modules/swiss/lib/scaler.ts | 23 ++++++ .../modules/swiss/routes/DeploymentDetail.tsx | 7 +- 6 files changed, 112 insertions(+), 49 deletions(-) delete mode 100644 web/src/modules/swiss/components/scaler-conditions.test.ts create mode 100644 web/src/modules/swiss/lib/scaler.test.ts create mode 100644 web/src/modules/swiss/lib/scaler.ts diff --git a/web/src/modules/inferences/lib.ts b/web/src/modules/inferences/lib.ts index bec3598..4fc084e 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,30 +160,20 @@ 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 hold = scalerHold(status?.conditions); + if (hold) return { key, state: "wait", detail: "deferred", params: { reason: hold } }; const cur = status?.currentReplicas; const want = status?.desiredReplicas; diff --git a/web/src/modules/swiss/components/ReleaseObjects.tsx b/web/src/modules/swiss/components/ReleaseObjects.tsx index c3b2b5f..fbaeab5 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 = holding: {hold}; else if (current !== undefined && desired !== undefined) { if (desired > current) state = scaling up; else if (desired < current) @@ -681,17 +684,6 @@ 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[] }) { if (!conditions?.length) return null; return ( 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..9db56fb --- /dev/null +++ b/web/src/modules/swiss/lib/scaler.test.ts @@ -0,0 +1,77 @@ +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("holds on ScaleDownStabilized", () => { + expect(step([c("ScalingActive", "True", "MetricsAvailable"), c("AbleToScale", "True", "ScaleDownStabilized")], 3, 1)) + .toEqual({ label: "Scaling", state: "wait", detail: "holding: ScaleDownStabilized" }); + }); + 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..c2f1b96 --- /dev/null +++ b/web/src/modules/swiss/lib/scaler.ts @@ -0,0 +1,23 @@ +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. 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..9157305 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,13 +338,15 @@ 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" }; if (result.missing) return { label, state: "bad", detail: "LLMScaler deleted" }; const failing = scalerFailing(status?.conditions); if (failing) return { label, state: "bad", detail: failing.reason || failing.type }; + const hold = scalerHold(status?.conditions); + if (hold) return { label, state: "wait", detail: `holding: ${hold}` }; const cur = status?.currentReplicas; const want = status?.desiredReplicas; if (cur === undefined) return { label, state: "wait", detail: "no decision yet" }; From 6c169dd691f8123825ff19a2234f33ffb6aeba29 Mon Sep 17 00:00:00 2001 From: huskcass <333684917+huskcass@users.noreply.github.com> Date: Mon, 26 Oct 2026 02:37:10 -0700 Subject: [PATCH 2/2] fix(swiss): report a scaler hold as steady A hold (AbleToScale=True with RolloutInProgress, ScaleDownSettling or ScaleDownStabilized) is not a state of its own: the scaler is ok or bad. - the Overview tile, the deploy page's Scaling step and the scaler card report a hold as steady, even before the replica count catches up - the only sign of a hold is a warning dot on the AbleToScale condition, through a new warn list on Conditions; other callers are unchanged - drop the unused "deferred" locale strings Co-authored-by: aceforeverd <11715200+aceforeverd@users.noreply.github.com> --- web/src/modules/inferences/lib.test.ts | 10 ++++-- web/src/modules/inferences/lib.ts | 6 ++-- web/src/modules/inferences/locales/en-US.json | 1 - web/src/modules/inferences/locales/zh-CN.json | 1 - .../swiss/components/Conditions.test.tsx | 32 +++++++++++++++++ .../swiss/components/ReleaseObjects.tsx | 34 +++++++++++++------ web/src/modules/swiss/lib/scaler.test.ts | 12 +++++-- web/src/modules/swiss/lib/scaler.ts | 3 +- .../modules/swiss/routes/DeploymentDetail.tsx | 5 ++- 9 files changed, 79 insertions(+), 25 deletions(-) create mode 100644 web/src/modules/swiss/components/Conditions.test.tsx 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 4fc084e..a97ce09 100644 --- a/web/src/modules/inferences/lib.ts +++ b/web/src/modules/inferences/lib.ts @@ -172,13 +172,11 @@ function scalerStep({ result, status }: Picked): return { key, state: "bad", detail: "failing", params: { reason } }; } - const hold = scalerHold(status?.conditions); - if (hold) return { key, state: "wait", detail: "deferred", params: { reason: hold } }; - 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 fbaeab5..d32fe21 100644 --- a/web/src/modules/swiss/components/ReleaseObjects.tsx +++ b/web/src/modules/swiss/components/ReleaseObjects.tsx @@ -254,7 +254,7 @@ function ScalerPanel({ let state: React.ReactNode = null; if (failing) state = {failing.reason || failing.type}; - else if (hold) state = holding: {hold}; + else if (hold) state = steady; else if (current !== undefined && desired !== undefined) { if (desired > current) state = scaling up; else if (desired < current) @@ -352,7 +352,11 @@ function ScalerPanel({ )} - + )} @@ -684,7 +688,15 @@ function Stale({ ); } -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 (
    @@ -694,13 +706,15 @@ export function Conditions({ conditions, neutral = [] }: { conditions?: Conditio {c.type} diff --git a/web/src/modules/swiss/lib/scaler.test.ts b/web/src/modules/swiss/lib/scaler.test.ts index 9db56fb..b034f6c 100644 --- a/web/src/modules/swiss/lib/scaler.test.ts +++ b/web/src/modules/swiss/lib/scaler.test.ts @@ -62,9 +62,15 @@ describe("scalerStep", () => { expect(step([c("ScalingActive", "True", "DecisionReceived"), c("AbleToScale", "False", "TargetNotFound")])) .toEqual({ label: "Scaling", state: "bad", detail: "TargetNotFound" }); }); - it("holds on ScaleDownStabilized", () => { - expect(step([c("ScalingActive", "True", "MetricsAvailable"), c("AbleToScale", "True", "ScaleDownStabilized")], 3, 1)) - .toEqual({ label: "Scaling", state: "wait", detail: "holding: ScaleDownStabilized" }); + 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)) diff --git a/web/src/modules/swiss/lib/scaler.ts b/web/src/modules/swiss/lib/scaler.ts index c2f1b96..4be2057 100644 --- a/web/src/modules/swiss/lib/scaler.ts +++ b/web/src/modules/swiss/lib/scaler.ts @@ -9,7 +9,8 @@ export function scalerFailing(conditions?: Condition[]): Condition | undefined { ); } -// AbleToScale=True reasons that hold a step. Synced and DeferralTimedOut are a normal scale. +// 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 { diff --git a/web/src/modules/swiss/routes/DeploymentDetail.tsx b/web/src/modules/swiss/routes/DeploymentDetail.tsx index 9157305..f4f10ee 100644 --- a/web/src/modules/swiss/routes/DeploymentDetail.tsx +++ b/web/src/modules/swiss/routes/DeploymentDetail.tsx @@ -345,12 +345,11 @@ export function scalerStep({ result, status }: ReturnType