diff --git a/README.md b/README.md index 676ae8b..2651b87 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ Console is a Go backend-for-frontend with a React UI compiled into the same bina - **Identity and access control.** Users, roles and login history stored as Kubernetes CRDs; OAuth2 password login with HS256 tokens; Kubernetes-RBAC-style roles that gate both pages and backend APIs. The seeded administrator is asked to set a password on first login. - **Model deployment.** With [Swiss](https://github.com/modelsphere/swiss) on the cluster, browse the model catalog and deploy, upgrade and uninstall models, with a diff before every change and the GPU nodes they run on. -- **Canary experiments.** When the Canary backend is configured, each experiment arm selects its own v2 model catalog, then a model and variant within it. The catalog selector defaults to `upstreamv2` when available, otherwise the supported environment default or first supported catalog. Changing catalogs clears the arm's previous model, variant, chart pin and YAML overrides; v1 catalogs cannot be selected. +- **Optional extensions.** The public build contains only built-in modules. Reviewed private distributions can register additional modules and service ownership decorations through the [extension interface](docs/console-design.md#optional-build-time-extensions). - **Playground.** Streaming chat with the models deployed in Model Serving, full sampling parameters, per-answer TTFT, tokens/s and cache hit rate, reasoning output, and "view code" for cURL, Python and Node.js. - **OpenAI-compatible router.** Programs call `/v1` with API keys that administrators issue — with expiry and optional per-model scope. Keys are stored hashed; usage is exported as Prometheus metrics. - **Gateway discovery.** The inference entrypoint, routes and gateway key are read from the cluster (Swiss's site profile or the route ConfigMap) and followed as they change — no URLs or keys copied into configuration. diff --git a/docs/console-design.md b/docs/console-design.md index 6188caf..113a9a3 100644 --- a/docs/console-design.md +++ b/docs/console-design.md @@ -697,3 +697,39 @@ requests carry the same headers Global's apiserver sets. | P7 | Container management modules, moved over from Rise Global. | Each phase is independently committable and verifiable. + +## Optional build-time extensions + +The public frontend registry `web/src/extensions/registry.ts` is empty by default. Distributors can register reviewed modules and service owner decorations in a separate build tree without changing the public source distribution. + +| Interface | Behavior | +|---|---| +| `ConsoleExtensions.modules` | Existing `ConsoleModule` routes, menus, frames, UI permissions and backend capabilities | +| `ConsoleExtensions.serviceOwners` | Optional owner UI selected by exact API version and kind; receives owner UID, verification and display flags | +| Empty registry | No optional routes or code imports; generic owner display and service write protection remain active | + +The host keeps login, authenticated fetch, i18n, theme, QueryClient and route guards. Backend capabilities are keyed by backend name; registration does not grant backend permissions. No remote script loader or runtime configuration key is introduced. Extension sources are scanned by the existing Tailwind build and use the host lockfile and React dependencies. + +Public builds must remain independent of any private package or checkout. Private build source, images and artifacts belong in private storage; they must not be copied into the public working tree. Removing an extension does not remove service-side ownership or assignment protection. + + +## Referenced model service actions + +| Relationship | Upgrade | Uninstall | +|---|---|---| +| Independent | Normal form, including separate autoscaling and SLO controls | Allowed | +| Experiment-owned | Manage through the experiment | Blocked | +| Verified referenced arm | Adaptive concurrency only | Blocked | +| Verified referenced production | Model/configuration and scaling changes that preserve chart, release and declared route/Service binding | Blocked until released | +| Unverifiable reference | Blocked | Blocked | + +Production upgrade plans compare the effective values after all stored layers, +including YAML edits. Historical rollback is blocked while referenced because it +can restore a different binding. Changing chart templates requires stopping the experiment +because it can replace routing resources. These UI checks do not replace an +admission policy for direct API or Kubernetes callers. The optional owner +extension provides role-specific explanations; Swiss remains the metadata source. + +Autoscaling remains independent of SLO requirements. The deployment Resources +section exposes scaler bounds or a fixed replica count; creating experiment arms +uses fixed replicas. Turning off SLO does not implicitly disable autoscaling. diff --git a/web/src/extensions/registry.ts b/web/src/extensions/registry.ts new file mode 100644 index 0000000..309596f --- /dev/null +++ b/web/src/extensions/registry.ts @@ -0,0 +1,3 @@ +import type { ConsoleExtensions } from "./types"; + +export const extensions: ConsoleExtensions = { modules: [], serviceOwners: [] }; diff --git a/web/src/extensions/types.ts b/web/src/extensions/types.ts new file mode 100644 index 0000000..6e30b54 --- /dev/null +++ b/web/src/extensions/types.ts @@ -0,0 +1,19 @@ +import type { ComponentType } from "react"; +import type { ConsoleModule } from "@/shell/module"; + +import type { ServiceOwner } from "@swiss/lib/api"; + +export interface ServiceOwnerProps { + owner: ServiceOwner; + compact?: boolean; + assigned?: boolean; +} +export interface ServiceOwnerExtension { + apiVersion: string; + kind: string; + component: ComponentType; +} +export interface ConsoleExtensions { + modules: ConsoleModule[]; + serviceOwners: ServiceOwnerExtension[]; +} diff --git a/web/src/modules/canary/components/CanaryCondition.test.tsx b/web/src/modules/canary/components/CanaryCondition.test.tsx deleted file mode 100644 index e54ef07..0000000 --- a/web/src/modules/canary/components/CanaryCondition.test.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { renderToStaticMarkup } from "react-dom/server"; -import { describe, expect, it } from "vitest"; -import type { Condition, Phase } from "../types"; -import { CanaryCondition } from "./CanaryCondition"; - -const render = (condition: Condition, phase: Phase = "Provisioning") => - renderToStaticMarkup(); - -describe("Canary condition presentation", () => { - it("treats Degraded=False as healthy and Degraded=True as a failure", () => { - const healthy = render({ type: "Degraded", status: "False", reason: "NoIssues", message: "no degradation detected" }); - expect(healthy).not.toContain('role="alert"'); - expect(healthy).toContain('data-variant="success"'); - const failed = render({ type: "Degraded", status: "True", reason: "ProvisionFailed", message: "chart could not be fetched" }, "Failed"); - expect(failed).toContain('role="alert"'); - expect(failed).toContain("chart could not be fetched"); - }); - - it.each(["ArmsNotReady", "ProxyNotReady"])("presents %s as progress without an error", (reason) => { - const html = render({ type: "Provisioned", status: "False", reason, message: "waiting" }); - expect(html).toContain('role="status"'); - expect(html).not.toContain('role="alert"'); - expect(html).toContain('data-variant="secondary"'); - }); - - it("retains failed condition diagnostics", () => { - const html = render({ type: "TrafficProgrammed", status: "False", reason: "WriteFailed", message: "route permission denied" }, "Failed"); - expect(html).toContain('role="alert"'); - expect(html).toContain("route permission denied"); - }); -}); diff --git a/web/src/modules/canary/components/CanaryCondition.tsx b/web/src/modules/canary/components/CanaryCondition.tsx deleted file mode 100644 index c9a9c81..0000000 --- a/web/src/modules/canary/components/CanaryCondition.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { Badge } from "@modelsphere/ui"; -import type { Condition, Phase } from "../types"; -import { useTranslation } from "../lib/translation"; -import { ErrorNotice } from "./ErrorNotice"; - -export function CanaryCondition({ condition, phase }: { condition: Condition; phase?: Phase }) { - const { t } = useTranslation(); - const degraded = condition.type === "Degraded"; - const failed = degraded - ? condition.status === "True" - : condition.status === "False" && phase === "Failed"; - const healthy = degraded - ? condition.status === "False" - : condition.status === "True"; - const waiting = !failed && condition.status === "False" && - (condition.reason === "ArmsNotReady" || condition.reason === "ProxyNotReady"); - return ( -
-
- - {condition.status} - - {condition.type} - {condition.reason} -
- {failed && condition.message ? ( - - ) : waiting ? ( -

- {t(condition.reason === "ArmsNotReady" ? "detail.waitingArms" : "detail.waitingProxy")} -

- ) : condition.message ? ( -

{condition.message}

- ) : null} -
- ); -} diff --git a/web/src/modules/canary/components/EnvironmentField.test.ts b/web/src/modules/canary/components/EnvironmentField.test.ts deleted file mode 100644 index d51a51f..0000000 --- a/web/src/modules/canary/components/EnvironmentField.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { createElement } from "react"; -import { renderToStaticMarkup } from "react-dom/server"; -import { describe, expect, it } from "vitest"; -import { EnvironmentField } from "./EnvironmentField"; - -describe("EnvironmentField", () => { - it("displays the configured value as read-only, while still allowing selection and copying", () => { - const html = renderToStaticMarkup( - createElement(EnvironmentField, { label: "Gateway", value: "openresty" }), - ); - expect(html).toContain('value="openresty"'); - expect(html).toContain('readOnly=""'); - expect(html).not.toContain('disabled=""'); - }); - - it("displays explanatory text for an unset optional value without binding it to form state", () => { - const html = renderToStaticMarkup( - createElement(EnvironmentField, { - label: "Hostname", - emptyText: "Not configured", - }), - ); - expect(html).toContain('value="Not configured"'); - expect(html).toContain('readOnly=""'); - expect(html).not.toMatch(/\sname=/); - }); -}); diff --git a/web/src/modules/canary/components/EnvironmentField.tsx b/web/src/modules/canary/components/EnvironmentField.tsx deleted file mode 100644 index 7868e66..0000000 --- a/web/src/modules/canary/components/EnvironmentField.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { FieldInput, FloatingField } from "@modelsphere/ui"; - -export function EnvironmentField({ - label, - value, - hint, - emptyText, -}: { - label: string; - value?: string; - hint?: string; - emptyText?: string; -}) { - return ( - - - - ); -} diff --git a/web/src/modules/canary/components/ErrorNotice.tsx b/web/src/modules/canary/components/ErrorNotice.tsx deleted file mode 100644 index b61f0e3..0000000 --- a/web/src/modules/canary/components/ErrorNotice.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import type { ReactNode } from "react"; -import { useTranslation } from "../lib/translation"; -import { describeError } from "../lib/errors"; -export function ErrorDetails({ error }: { error: unknown }) { - const { t } = useTranslation(); - const { details } = describeError(error, t); - return details ? ( -
- {t("errors.details")} -
-        {details}
-      
-
- ) : null; -} -export function ErrorNotice({ - error, - className = "text-sm text-destructive", - fallback, - children, -}: { - error: unknown; - className?: string; - fallback?: Parameters[2]; - children?: ReactNode; -}) { - const { t } = useTranslation(); - return ( -
-

- {describeError(error, t, fallback).summary} -

- - {children} -
- ); -} diff --git a/web/src/modules/canary/components/MetricsPanel.tsx b/web/src/modules/canary/components/MetricsPanel.tsx deleted file mode 100644 index 0a3d01f..0000000 --- a/web/src/modules/canary/components/MetricsPanel.tsx +++ /dev/null @@ -1,1001 +0,0 @@ -import { ErrorNotice } from "./ErrorNotice"; -import { useEffect, useRef, useState, type ReactNode } from "react"; -import { useSearchParams } from "react-router"; -import { useQuery } from "@tanstack/react-query"; -import { useTranslation } from "../lib/translation"; -import { useCanaryApi } from "../lib/provider"; -import type { - Currency, - Experiment, - MetricSummary, - Metrics, - TokenPrices, - ValuationView, -} from "../types"; -import { - finiteNumber, - format, - rangeBounds, - readPreference, - savePreference, - timeRanges, - tpsPreview, -} from "../lib/metric-presentation"; -import { quotedRevenue, type QuoteMode } from "../lib/quoted-revenue"; -import { - ComparisonBars, - ComparisonTable, - MetricCard, - type ComparisonRow, -} from "./metrics/MetricCard"; -import { EconomicsSettings } from "./metrics/EconomicsSettings"; -import { MetricTrend } from "./metrics/MetricTrend"; -import { TpsBreachChart } from "./metrics/TpsBreachChart"; - -const ids = ["cache", "throughput", "ttft", "revenue", "quote", "tps"] as const; -type CardID = (typeof ids)[number]; -const ttftKeys = [ - "ttftMean", - "ttftP50", - "ttftP95", - "ttftP99", - "ttftP999", -] as const; -type TTFTKey = (typeof ttftKeys)[number]; -const inputClass = - "min-w-0 rounded-md border border-slate-300 bg-white px-2 py-1.5 text-xs text-slate-900 dark:border-slate-600 dark:bg-slate-950 dark:text-slate-100"; -const buttonClass = - "rounded-md border border-slate-200 px-2.5 py-1.5 text-xs hover:bg-slate-100 disabled:opacity-50 dark:border-slate-700 dark:hover:bg-slate-800"; -function Control({ label, children }: { label: string; children: ReactNode }) { - return ( - - ); -} -function dateTime(value: number | null | undefined) { - return value == null ? "—" : new Date(value * 1000).toLocaleString(); -} -function localInput(seconds: number) { - if (!Number.isFinite(seconds) || Math.abs(seconds) > 8.64e12) - seconds = Date.now() / 1000; - const date = new Date(seconds * 1000); - return new Date(date.getTime() - date.getTimezoneOffset() * 60000) - .toISOString() - .slice(0, 16); -} - -export function MetricsPanel({ experiment }: { experiment: Experiment }) { - return ; -} -function Dashboard({ experiment }: { experiment: Experiment }) { - const { t } = useTranslation(); - const api = useCanaryApi(); - const [params, setParams] = useSearchParams(); - const prefKey = `canary:metrics:${experiment.id}`; - const [preferences] = useState(() => - readPreference<{ - currency?: Currency; - normalization?: ValuationView["normalization"]; - targetGPUs?: number; - ttft?: TTFTKey; - }>(prefKey, {}), - ); - const [currency, setCurrency] = useState( - preferences.currency === "CNY" || preferences.currency === "USD" - ? preferences.currency - : "", - ); - const [normalization, setNormalization] = useState< - ValuationView["normalization"] - >( - ["raw", "as-if", "per-gpu"].includes(preferences.normalization ?? "") - ? preferences.normalization! - : "raw", - ); - const [targetGPUs, setTargetGPUs] = useState( - preferences.targetGPUs && - Number.isInteger(preferences.targetGPUs) && - preferences.targetGPUs >= 1 && - preferences.targetGPUs <= 1024 - ? preferences.targetGPUs - : 8, - ); - const [ttft, setTTFT] = useState( - ttftKeys.includes(preferences.ttft!) ? preferences.ttft! : "ttftP95", - ); - const rangeParam = params.get("range") ?? "0"; - const range = timeRanges.some(([n]) => String(n) === rangeParam) - ? Number(rangeParam) - : rangeParam === "custom" - ? "custom" - : 0; - const initialBounds = rangeBounds(experiment, 3600); - const [customStart, setCustomStart] = useState(() => - localInput(Number(params.get("start")) || initialBounds.start!), - ); - const [customEnd, setCustomEnd] = useState(() => - localInput(Number(params.get("end")) || initialBounds.end), - ); - const [customError, setCustomError] = useState(""); - const [quoteHit, setQuoteHit] = useState("50"); - const [quoteMode, setQuoteMode] = useState("fixed"); - const [threshold, setThreshold] = useState("0"); - const [customPrices, setCustomPrices] = useState< - Record> - >({}); - const [priceOverride, setPriceOverride] = useState(false); - const details = useRef(null); - const lastOpened = useRef(null); - const trigger = useRef(null); - const metricParam = params.get("metric"); - const expanded = ids.includes(metricParam as CardID) - ? (metricParam as CardID) - : null; - const selectedStart = Number(params.get("start")), - selectedEnd = Number(params.get("end")); - const validCustom = - Number.isFinite(selectedStart) && - selectedStart < 8.64e12 && - selectedEnd < 8.64e12 && - Number.isFinite(selectedEnd) && - selectedStart > 0 && - selectedEnd > selectedStart; - const bounds = - range === "custom" - ? { start: selectedStart, end: selectedEnd } - : rangeBounds(experiment, range); - const validGPUs = - normalization !== "as-if" || - (Number.isInteger(targetGPUs) && targetGPUs >= 1 && targetGPUs <= 1024); - const q = useQuery({ - queryKey: [ - "experiment-metrics", - api.mode, - experiment.id, - range, - range === "custom" ? bounds : null, - currency, - normalization, - targetGPUs, - experiment.stopAt, - ], - enabled: (range !== "custom" || validCustom) && validGPUs, - queryFn: () => { - const latest = - range === "custom" ? bounds : rangeBounds(experiment, range); - return api.experiments.metrics(experiment.id, latest.start, latest.end, { - ...(currency ? { currency } : {}), - normalization, - targetGPUs, - }); - }, - refetchInterval: - experiment.state !== "Stopped" || experiment.snapshot !== "complete" - ? 60000 - : false, - refetchIntervalInBackground: false, - }); - useEffect(() => { - const value = { currency, normalization, targetGPUs, ttft }; - savePreference(prefKey, value); - }, [currency, normalization, targetGPUs, ttft, prefKey]); - function updateParams(values: Record) { - setParams( - (previous) => { - const next = new URLSearchParams(previous); - for (const [key, value] of Object.entries(values)) { - if (value == null) next.delete(key); - else next.set(key, value); - } - return next; - }, - { replace: true }, - ); - } - function toggle(id: CardID) { - const button = document.activeElement; - if (button instanceof HTMLButtonElement) trigger.current = button; - updateParams({ metric: expanded === id ? null : id }); - } - useEffect(() => { - if (!expanded) lastOpened.current = null; - if (expanded && q.data && lastOpened.current !== expanded) { - lastOpened.current = expanded; - details.current?.scrollIntoView({ behavior: "smooth", block: "start" }); - details.current?.focus({ preventScroll: true }); - } - }, [expanded, !!q.data]); - const data = q.data; - const arms = data?.compareArms ?? experiment.compareArms; - const get = (key: keyof MetricSummary) => - arms.map((a) => data?.summary[a]?.[key]); - const row = (key: keyof MetricSummary, unit?: string): ComparisonRow => ({ - label: t(`experiments.${key}`, { defaultValue: t(`dashboard.${key}`) }), - values: get(key), - unit, - }); - const selectedCurrency = currency || data?.valuation.currency || "USD"; - const thresholdNumber = threshold.trim() !== "" ? Number(threshold) : NaN; - const thresholdValid = - finiteNumber(thresholdNumber) && - thresholdNumber >= 0 && - thresholdNumber <= 1e6; - const thresholdOn = thresholdValid && thresholdNumber > 0; - const hitNumber = quoteHit.trim() !== "" ? Number(quoteHit) : NaN; - const quoteValid = - finiteNumber(hitNumber) && hitNumber >= 0 && hitNumber <= 100; - const previews = arms.map((arm) => - data ? tpsPreview(data, arm, thresholdNumber) : null, - ); - const prices = arms.map((arm) => { - if (!priceOverride) - return data?.economics?.arms[arm]?.prices[selectedCurrency]; - const custom = customPrices[`${selectedCurrency}:${arm}`] ?? {}; - return Object.fromEntries( - ( - ["inputPerMillion", "cachedPerMillion", "outputPerMillion"] as const - ).map((key) => [key, custom[key]?.trim() ? Number(custom[key]) : NaN]), - ) as unknown as TokenPrices; - }); - const quotes = arms.map((arm, i) => - quotedRevenue(data?.summary[arm], prices[i], hitNumber, quoteMode), - ); - const scaledLabel = - normalization === "raw" - ? t("economics.raw") - : normalization === "per-gpu" - ? t("economics.perGPU") - : `${t("economics.asIf")} ${targetGPUs}`; - const revenueUnit = `${selectedCurrency}/min`; - const coverage: ComparisonRow = { - label: `${t("dashboard.coverage")} / ${data?.eligibleMinutes ?? 0}`, - values: get("completeMinutes"), - }; - const gpu: ComparisonRow = { - label: t("economics.gpuTotal"), - values: arms.map((a) => data?.economics?.arms[a]?.gpuCount), - }; - const quoteRows: ComparisonRow[] = [ - { label: t("dashboard.actualHit"), values: get("cacheHit"), unit: "%" }, - { - label: t("dashboard.appliedHit"), - values: quotes.map((q) => q?.applied), - unit: "%", - }, - { - label: t("experiments.revenuePerHour"), - values: quotes.map((q) => q?.hour), - unit: `${selectedCurrency}/hr`, - }, - ]; - const cards: { - id: CardID; - title: string; - subtitle: string; - values: (number | null | undefined)[]; - unit: string; - rows: ComparisonRow[]; - trend: (keyof MetricSummary)[]; - controls?: ReactNode; - lowerBetter?: boolean; - }[] = [ - { - id: "cache", - title: t("experiments.cacheHit"), - subtitle: t("dashboard.cacheHint"), - values: get("cacheHit"), - unit: "%", - rows: [row("inputTokens"), row("cachedTokens"), coverage], - trend: ["cacheHit"], - }, - { - id: "throughput", - title: t("dashboard.throughput"), - subtitle: `${t("dashboard.throughputHint")} · ${scaledLabel}`, - values: get("totalTPM"), - unit: "token/min", - rows: [ - row("uncachedTPM"), - row("cachedTPM"), - row("outputTPM"), - row("requests"), - gpu, - ], - trend: ["totalTPM", "inputTPM", "uncachedTPM", "cachedTPM", "outputTPM"], - }, - { - id: "ttft", - title: t("dashboard.ttft"), - subtitle: t("dashboard.ttftHint"), - values: get(ttft), - unit: "s", - lowerBetter: true, - rows: [row("ttftMean", "s"), row("ttftSamples"), coverage], - trend: [ttft], - controls: ( - - - - ), - }, - { - id: "revenue", - title: t("dashboard.revenue"), - subtitle: `${t("dashboard.frozenRevenue")} · ${scaledLabel}`, - values: get("revenuePerMinute"), - unit: revenueUnit, - rows: [ - row("revenuePerHour", `${selectedCurrency}/hr`), - row("revenueInputPerMinute", revenueUnit), - row("revenueCachedPerMinute", revenueUnit), - row("revenueOutputPerMinute", revenueUnit), - ], - trend: ["revenuePerMinute", "revenuePerHour"], - }, - { - id: "quote", - title: t("dashboard.quote"), - subtitle: `${t("dashboard.localPreview")} · ${scaledLabel}`, - values: quotes.map((q) => q?.minute), - unit: revenueUnit, - rows: quoteRows, - trend: [], - controls: ( -
- - setQuoteHit(e.target.value)} - /> - % - - - {!quoteValid && ( - - {t("dashboard.invalidHit")} - - )} -
- ), - }, - { - id: "tps", - title: t(thresholdOn ? "dashboard.tpsPass" : "dashboard.tps"), - subtitle: t("dashboard.tpsHint"), - values: thresholdOn ? previews.map((p) => p?.pass) : get("overallTPS"), - unit: thresholdOn ? "%" : "token/s", - rows: [ - row("overallTPS", "token/s"), - row("decodeTPS", "token/s"), - { - label: t("dashboard.evaluated"), - values: previews.map((p) => p?.evaluated), - }, - ...(thresholdOn - ? [ - { - label: t("dashboard.failed"), - values: previews.map((p) => p?.failed), - }, - ] - : []), - ], - trend: ["overallTPS", "decodeTPS"], - controls: ( -
- - setThreshold(e.target.value)} - /> - token/s - - setThreshold(e.target.value)} - className="w-full accent-blue-600" - /> - {!thresholdValid && ( - - {t("dashboard.invalidThreshold")} - - )} -
- ), - }, - ]; - const current = cards.find((c) => c.id === expanded); - return ( -
-
-
- - {t(`experiments.${experiment.state}`)} · {experiment.creator} - - - {t("dashboard.snapshot")}: {t(`experiments.${experiment.snapshot}`)} - -
-
- - {t("dashboard.created")}:{" "} - {new Date(experiment.createdAt).toLocaleString()} - - {experiment.stopAt && ( - - {t("dashboard.stopped")}:{" "} - {new Date(experiment.stopAt).toLocaleString()} - - )} - - {t("dashboard.observation")}:{" "} - {experiment.windows.length - ? `${experiment.windows[0].start} — ${experiment.windows.at(-1)!.end}` - : "—"} - -
-
- - {t("dashboard.compareEconomics")} ·{" "} - {arms - .map( - (a) => `${a}: ${data?.economics?.arms[a]?.gpuCount ?? "—"} GPU`, - ) - .join(" / ")} - - {data && ( -
- -
- )} -
-
- {timeRanges.map(([seconds, label]) => ( - - ))} - - -
- {range === "custom" && ( -
- - setCustomStart(e.currentTarget.value)} - /> - - - setCustomEnd(e.currentTarget.value)} - /> - - - - {Intl.DateTimeFormat().resolvedOptions().timeZone} - - {(customError || !validCustom) && ( -

- {customError || t("dashboard.invalidRange")} -

- )} -
- )} -
- - - - - - - {normalization === "as-if" && ( - - setTargetGPUs(Number(e.target.value))} - /> - - )} -
- {!validGPUs && ( -

- {t("dashboard.invalidGPUs")} -

- )} -

- {t("economics.normalizationHint")} -

- {data && ( -
- - {t("dashboard.selectedRange")}:{" "} - {data.range?.start ? dateTime(data.range.start) : "Full"} —{" "} - {dateTime(data.range?.end)} · {t("dashboard.windows")}:{" "} - {data.range?.intersections.length ?? experiment.windows.length} - - - {t("dashboard.storedRange")}: {dateTime(data.range?.dataStart)} —{" "} - {dateTime(data.range?.dataEnd)} · {t("dashboard.lastCollected")}:{" "} - {data.lastCollectedAt - ? new Date(data.lastCollectedAt).toLocaleString() - : "—"} - - {[1, 0].map((i) => ( - - {t(i ? "dashboard.experiment" : "dashboard.control")} ·{" "} - {arms[i]} · {t("dashboard.coverage")}:{" "} - {data.summary[arms[i]]?.completeMinutes ?? 0}/ - {data.eligibleMinutes} - - ))} - {!!data.excludedMinutes && ( - - {t("experiments.excludedMinutes", { - count: data.excludedMinutes, - })} - - )} - {arms.some( - (a) => - (data.summary[a]?.completeMinutes ?? 0) < data.eligibleMinutes, - ) && {t("dashboard.partial")}} -
- )} -
- {q.error && ( - - - - )} - {data?.snapshotError && ( - - )} - {q.isLoading && !data && ( -
- {ids.map((id) => ( -
- ))} -
- )} - {data && ( - <> - {!data.series.length && ( -

- {t("experiments.noData")} -

- )} -
- {cards.map((card) => ( - toggle(card.id)} - > - {card.controls} - - ))} -
- {current && ( -
-
-
-

- {current.title} Details -

-

- {current.subtitle} -

-
- -
- - - {current.id === "quote" && ( -
-

- {t("dashboard.quoteHint")} -

- q?.input), - unit: revenueUnit, - }, - { - label: t("experiments.revenueCachedPerMinute"), - values: quotes.map((q) => q?.cached), - unit: revenueUnit, - }, - { - label: t("experiments.revenueOutputPerMinute"), - values: quotes.map((q) => q?.output), - unit: revenueUnit, - }, - ]} - /> - - {arms.map((arm, i) => ( -
- - {arm} · {data.economics?.arms[arm]?.pricingKey ?? "—"} - -
- {( - [ - "inputPerMillion", - "cachedPerMillion", - "outputPerMillion", - ] as const - ).map((key) => ( - - {priceOverride ? ( - - setCustomPrices((previous) => ({ - ...previous, - [`${selectedCurrency}:${arm}`]: { - ...previous[`${selectedCurrency}:${arm}`], - [key]: e.target.value, - }, - })) - } - /> - ) : ( - - {format(prices[i]?.[key])} - - )} - - ))} -
-
- ))} - {quotes.some((q) => !q) && ( -

{t("dashboard.quoteUnavailable")}

- )} -
- )} - {current.id === "tps" && ( - <> -

- {t("dashboard.tpsMethod")} -

- - - )} - {(current.id === "revenue" || current.id === "quote") && ( -

- {t("economics.revenueHint")}{" "} - {t("dashboard.unavailableEconomics")} -

- )} -
- - - - - - - - - {(data.windows ?? experiment.windows).map( - (window, index) => ( - - - - - ), - )} - -
{t("dashboard.observation")}Service / UID
- {new Date(window.start).toLocaleString()} —{" "} - {new Date(window.end).toLocaleString()} - - {arms.map((arm) => ( -
- {arm}:{" "} - {window.bindings[arm] - ? `${window.bindings[arm].namespace}/${window.bindings[arm].service} · ${window.bindings[arm].uid}` - : "—"} -
- ))} -
-
- {current.trend.length > 0 && ( - - )} -
- )} - - )} -
- ); -} -function TrendDetails({ - metrics, - data, - threshold = 0, -}: { - metrics: (keyof MetricSummary)[]; - data: Metrics; - threshold?: number; -}) { - const { t } = useTranslation(); - const [open, setOpen] = useState(false); - return ( -
setOpen(e.currentTarget.open)}> - - {t("dashboard.trend")} - - {open && ( -
- {metrics.map((key) => ( -
-

- {t(`experiments.${key}`, { - defaultValue: t(`dashboard.${key}`), - })} -

- -
- ))} -
- )} -
- ); -} diff --git a/web/src/modules/canary/components/PhaseBadge.tsx b/web/src/modules/canary/components/PhaseBadge.tsx deleted file mode 100644 index 9646a81..0000000 --- a/web/src/modules/canary/components/PhaseBadge.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import { StatusIndicator, type StatusVariant } from "@modelsphere/ui"; -import { useTranslation } from "../lib/translation"; -import type { Phase } from "../types"; - -const PHASE: Record = { - Serving: { variant: "success" }, - Provisioning: { variant: "info", animated: true }, - Idle: { variant: "default" }, - Draining: { variant: "warning", animated: true }, - Failed: { variant: "error" }, -}; - -/** - * Shared status for the list and the detail header. A stopped experiment is - * historical whatever phase it last reported, so both surfaces agree on the - * label, colour and the absence of animation. - */ -export function phaseStatus( - phase?: Phase, - stopped = false, - stoppedLabel = "Stopped", -) { - if (stopped) { - return { tone: "default" as const, label: stoppedLabel, animated: false }; - } - const value = phase ?? "Idle"; - const spec = PHASE[value]; - return { tone: spec.variant, label: value, animated: spec.animated }; -} - -export function PhaseBadge({ - phase, - state, -}: { - phase?: Phase; - state?: string; -}) { - const { t } = useTranslation("canary"); - const historical = state === "Stopped"; - const status = phaseStatus(phase, historical, t("experiments.Stopped")); - return ( - - - - ); -} diff --git a/web/src/modules/canary/components/TrafficBar.tsx b/web/src/modules/canary/components/TrafficBar.tsx deleted file mode 100644 index 6583850..0000000 --- a/web/src/modules/canary/components/TrafficBar.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { trafficShare } from "../lib/values"; - -export function TrafficBar({ - production, - canary, -}: { - production: number; - canary: number; -}) { - const share = trafficShare(production, canary); - const prodPct = Math.round(share.production * 1000) / 10; - const canPct = Math.round(share.canary * 1000) / 10; - return ( -
-
-
-
-
-
- - prod {production} ({prodPct}%) - - - canary {canary} ({canPct}%) - -
-
- ); -} diff --git a/web/src/modules/canary/components/TrafficEditor.test.tsx b/web/src/modules/canary/components/TrafficEditor.test.tsx deleted file mode 100644 index c167f87..0000000 --- a/web/src/modules/canary/components/TrafficEditor.test.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { renderToStaticMarkup } from "react-dom/server"; -import { TrafficEditor, canAdjustTraffic, validTrafficWeights } from "./TrafficEditor"; -import { CanaryProvider } from "../lib/provider"; -import { fixtureApi } from "../lib/api"; -import type { Experiment } from "../types"; - -const experiment = { - id: "exp", uid: "uid", state: "Running", cleanup: "pending", - canary: { metadata: { generation: 2 }, spec: { traffic: { production: 100, canary: 0 }, route: { enabled: true } }, - status: { phase: "Serving", observedGeneration: 2, conditions: [{ type: "TrafficProgrammed", status: "True" }] } }, -} as Experiment; -const render = (e: Experiment, canWrite = true) => renderToStaticMarkup(); - -describe("live traffic editing", () => { - it.each([["100", "0", true], ["90", "10", true], ["0", "100", true], ["1", "3", true], ["0", "0", false], ["", "10", false], ["-1", "10", false], ["1.1", "2", false], ["Infinity", "1", false], ["1000001", "1", false]])("validates %s:%s", (production, canary, valid) => { - expect(validTrafficWeights(String(production), String(canary))).toBe(valid); - }); - it("shows the form only to writers of observed running experiments", () => { - expect(render(experiment)).toContain("Apply weights"); - expect(render(experiment, false)).not.toContain("Apply weights"); - expect(canAdjustTraffic({ ...experiment, state: "Stopping" }, true)).toBe(false); - expect(canAdjustTraffic({ ...experiment, stopAt: new Date().toISOString() }, true)).toBe(false); - expect(canAdjustTraffic({ ...experiment, canary: { ...experiment.canary, status: { ...experiment.canary.status, observedGeneration: 1 } } }, true)).toBe(false); - }); - it("shows pending changes and audit history to readers without enabling edits", () => { - const e: Experiment = { ...experiment, trafficChanges: [{ id: "change", from: { production: 100, canary: 0 }, to: { production: 90, canary: 10 }, requestedAt: "2026-10-22T00:00:00Z", requestedBy: "writer", expectedGeneration: 2, status: "applying" }] }; - const html = render(e, false); - expect(html).toContain("100:0 → 90:10"); - expect(html).toContain("Applying"); - expect(html).toContain("writer"); - expect(html).not.toContain("Apply weights"); - }); -}); diff --git a/web/src/modules/canary/components/TrafficEditor.tsx b/web/src/modules/canary/components/TrafficEditor.tsx deleted file mode 100644 index 71d0121..0000000 --- a/web/src/modules/canary/components/TrafficEditor.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import { useState } from "react"; -import { Button, FieldInput, FloatingField, SectionCard } from "@modelsphere/ui"; -import { useCanaryApi, useCanaryWrite } from "../lib/provider"; -import { useTranslation } from "../lib/translation"; -import { asError } from "../lib/errors"; -import type { Experiment } from "../types"; -import { ErrorNotice } from "./ErrorNotice"; -import { TrafficBar } from "./TrafficBar"; - -export function validTrafficWeights(production: string, canary: string) { - if (production.trim() === "" || canary.trim() === "") return false; - const values = [Number(production), Number(canary)]; - return values.every((v) => Number.isSafeInteger(v) && v >= 0 && v <= 1000000) && values[0] + values[1] > 0; -} - -export function canAdjustTraffic(experiment: Experiment, canWrite: boolean) { - const { canary } = experiment; - return canWrite && experiment.state === "Running" && !experiment.stopAt && experiment.cleanup !== "complete" && !!experiment.uid && - canary.spec.route.enabled !== false && !!canary.metadata.generation && canary.status?.phase === "Serving" && - canary.status.observedGeneration === canary.metadata.generation && - !!canary.status.conditions?.some((c) => c.type === "TrafficProgrammed" && c.status === "True") && - !canary.status.conditions?.some((c) => c.type === "Degraded" && c.status === "True"); -} - -export function TrafficEditor({ experiment, onRefresh }: { experiment: Experiment; onRefresh?: () => void }) { - const { t } = useTranslation("canary"); - const api = useCanaryApi(); - const canWrite = useCanaryWrite(); - const current = experiment.canary.spec.traffic; - const [production, setProduction] = useState(String(current.production)); - const [canary, setCanary] = useState(String(current.canary)); - const [busy, setBusy] = useState(false); - const [error, setError] = useState(null); - const changes = experiment.trafficChanges ?? []; - const pending = changes.some((c) => c.status === "pending" || c.status === "applying"); - const editable = canAdjustTraffic(experiment, canWrite); - const valid = validTrafficWeights(production, canary); - const unchanged = Number(production) === current.production && Number(canary) === current.canary; - async function submit(event: React.FormEvent) { - event.preventDefault(); - if (!editable || !valid || unchanged || busy || pending) return; - setBusy(true); setError(null); - try { - await api.experiments.updateTraffic(experiment.id, { production: Number(production), canary: Number(canary), expectedGeneration: experiment.canary.metadata.generation! }); - onRefresh?.(); - } catch (e) { setError(asError(e)); } - finally { setBusy(false); } - } - return
- {editable && -
void submit(event)}> -
- setProduction(e.target.value)} /> - setCanary(e.target.value)} /> -
- {valid ? :

{t("trafficUpdate.invalid")}

} - - -
} - {error && } - {pending &&

{t("trafficUpdate.waiting")}

} - {changes.length > 0 && -
    {[...changes].reverse().map((c) =>
  • -

    {c.from.production}:{c.from.canary} → {c.to.production}:{c.to.canary} · {t(`trafficUpdate.${c.status}`)}

    -

    {new Date(c.requestedAt).toLocaleString()} · {c.requestedBy}

    - {c.error && } -
  • )}
-
} -
; -} diff --git a/web/src/modules/canary/components/metrics/EconomicsSettings.tsx b/web/src/modules/canary/components/metrics/EconomicsSettings.tsx deleted file mode 100644 index 2b9a571..0000000 --- a/web/src/modules/canary/components/metrics/EconomicsSettings.tsx +++ /dev/null @@ -1,168 +0,0 @@ -import { ErrorNotice } from "../../components/ErrorNotice"; -import { asError } from "../../lib/errors"; -import { useState } from "react"; -import { useQueryClient } from "@tanstack/react-query"; -import { - Button, - FieldInput, - FloatingField, - SectionCard, -} from "@modelsphere/ui"; -import { useTranslation } from "../../lib/translation"; -import { useCanaryApi, useCanaryWrite } from "../../lib/provider"; -import type { Metrics } from "../../types"; -import { format } from "../../lib/metric-presentation"; -export function EconomicsSettings({ - data, - experimentId, -}: { - data: Metrics; - experimentId: string; -}) { - const { t } = useTranslation("canary"); - const api = useCanaryApi(); - const canWrite = useCanaryWrite(); - const qc = useQueryClient(); - const [editing, setEditing] = useState(false); - const [busy, setBusy] = useState(false); - const [error, setError] = useState(null); - const [arms, setArms] = useState(() => - (Object.keys(data.economics?.arms ?? {}).length - ? Object.keys(data.economics!.arms) - : data.compareArms - ).map((name) => { - const x = data.economics?.arms[name]; - return { - name, - pricingKey: x?.resolution === "exact" ? x.pricingKey : "", - gpuCount: - x?.gpuSource === "manual" ? (x.gpuCount ?? undefined) : undefined, - }; - }), - ); - async function save() { - setBusy(true); - setError(null); - try { - await api.experiments.revalue( - experimentId, - data.economics?.revision ?? 0, - arms, - ); - await Promise.all([ - qc.invalidateQueries({ - queryKey: ["experiment-metrics", api.mode, experimentId], - }), - qc.invalidateQueries({ - queryKey: ["experiment", api.mode, experimentId], - }), - ]); - setEditing(false); - } catch (e) { - setError(asError(e)); - } finally { - setBusy(false); - } - } - return ( - - {!data.economics &&

{t("economics.legacy")}

} -
- {data.compareArms.map((name) => { - const x = data.economics?.arms[name]; - const rates = x?.prices[data.valuation.currency ?? "USD"]; - return ( -
- {name} · {t("economics.gpuTotal")}:{" "} - {x?.gpuCount ?? "—"} ·{" "} - {t(`economics.${x?.gpuSource ?? "unknown"}`)} -

- {t("economics.pricingKey")}: {x?.pricingKey || "—"} ·{" "} - {t(`economics.${x?.resolution ?? "missing"}`)} -

-

- {rates - ? `${data.valuation.currency} / 1M · ${t("economics.inputPerMillion")} ${format(rates.inputPerMillion)} · ${t("economics.cachedPerMillion")} ${format(rates.cachedPerMillion)} · ${t("economics.outputPerMillion")} ${format(rates.outputPerMillion)}` - : t("economics.unpriced")} -

-
- ); - })} -
- {data.economics && ( -

- {new Date(data.economics.frozenAt).toLocaleString()} ·{" "} - {data.economics.frozenBy} · {t("economics.priceRevision")}{" "} - {data.economics.pricingRevision} -

- )} - {canWrite && ( - - )} - {editing && ( -
-

- {t("economics.revalueHint")} -

-

- {t("economics.bindingHint")} -

- {arms.map((arm, index) => ( -
- {arm.name} - - - setArms( - arms.map((a, i) => - i === index ? { ...a, pricingKey: e.target.value } : a, - ), - ) - } - /> - - - - setArms( - arms.map((a, i) => - i === index - ? { - ...a, - gpuCount: - e.target.value === "" - ? undefined - : Number(e.target.value), - } - : a, - ), - ) - } - /> - -
- ))} - -
- )} - {error && } -
- ); -} diff --git a/web/src/modules/canary/components/metrics/MetricCard.tsx b/web/src/modules/canary/components/metrics/MetricCard.tsx deleted file mode 100644 index 599de7d..0000000 --- a/web/src/modules/canary/components/metrics/MetricCard.tsx +++ /dev/null @@ -1,208 +0,0 @@ -import type { ReactNode } from "react"; -import { - difference, - finiteNumber, - format, -} from "../../lib/metric-presentation"; -import { useTranslation } from "../../lib/translation"; -export interface ComparisonRow { - label: string; - values: (number | null | undefined)[]; - unit?: string; -} -export function ComparisonBars({ - arms, - values, - unit = "", - hypothetical = false, -}: { - arms: string[]; - values: (number | null | undefined)[]; - unit?: string; - hypothetical?: boolean; -}) { - const { t } = useTranslation(); - const max = Math.max(0, ...values.filter(finiteNumber)) || 1; - return ( -
- {[1, 0].map((i) => ( -
-
- - {t(i ? "dashboard.experiment" : "dashboard.control")} - -
- {arms[i]} -
-
-
-
-
- - {format(values[i], /USD|CNY/.test(unit) ? 6 : 3)} - {finiteNumber(values[i]) && unit ? ` ${unit}` : ""} - -
- ))} -
- ); -} -export function ComparisonTable({ - rows, - arms, -}: { - rows: ComparisonRow[]; - arms: string[]; -}) { - const { t } = useTranslation(); - return ( -
- - - - - {[1, 0].map((i) => ( - - ))} - - - - {rows.map((row) => ( - - - {[1, 0].map((i) => ( - - ))} - - ))} - -
- {t("dashboard.metric")} - - {t(i ? "dashboard.experiment" : "dashboard.control")} -
- {row.label} - {row.unit ? ` (${row.unit})` : ""} - - {format( - row.values[i], - /USD|CNY/.test(row.unit ?? "") ? 6 : 3, - )} -
-
- ); -} -export function MetricCard({ - id, - title, - subtitle, - arms, - values, - unit, - rows, - lowerBetter, - hypothetical, - expanded, - onToggle, - children, -}: { - id: string; - title: string; - subtitle: string; - arms: string[]; - values: (number | null | undefined)[]; - unit: string; - rows: ComparisonRow[]; - lowerBetter?: boolean; - hypothetical?: boolean; - expanded: boolean; - onToggle: () => void; - children?: ReactNode; -}) { - const { t } = useTranslation(); - const delta = difference(values[0], values[1]); - const good = - delta.absolute != null && - (lowerBetter ? delta.absolute < 0 : delta.absolute > 0); - const color = - delta.absolute == null || delta.absolute === 0 - ? "text-muted-foreground" - : good - ? "text-green-700 dark:text-green-400" - : "text-red-600 dark:text-red-400"; - return ( -
{ - if ( - !(e.target as HTMLElement).closest( - "button,input,select,label,summary,a", - ) - ) - onToggle(); - }} - > -
-
-

{title}

-

{subtitle}

-
- - {delta.relative != null && delta.relative > 0 ? "+" : ""} - {format(delta.relative, 2)} - {delta.relative != null ? "%" : ""} - -
- {children} - -
- Δ {t("dashboard.experiment")} vs {t("dashboard.control")}:{" "} - {delta.absolute != null && delta.absolute > 0 ? "+" : ""} - {format(delta.absolute, /USD|CNY/.test(unit) ? 6 : 3)}{" "} - {unit === "%" ? "pp" : unit} -
- - -
- ); -} diff --git a/web/src/modules/canary/components/metrics/MetricTrend.tsx b/web/src/modules/canary/components/metrics/MetricTrend.tsx deleted file mode 100644 index a92781f..0000000 --- a/web/src/modules/canary/components/metrics/MetricTrend.tsx +++ /dev/null @@ -1,278 +0,0 @@ -import { describeError } from "../../lib/errors"; -import { useTranslation } from "../../lib/translation"; -import { useId, useState } from "react"; -import type { Metrics, MetricSummary } from "../../types"; -import { format } from "../../lib/metric-presentation"; -export function MetricTrend({ - data, - metric, - label, - threshold = 0, -}: { - data: Metrics; - metric: keyof MetricSummary; - label: string; - threshold?: number; -}) { - const { t } = useTranslation(); - const tooltipId = useId(); - const [activeTime, setActiveTime] = useState(null); - const width = 640, - height = 170; - const times = data.series.map((p) => p.time); - const values = data.series - .flatMap((p) => data.compareArms.map((a) => p.arms[a]?.[metric])) - .filter((v): v is number => typeof v === "number"); - if (times.length === 0 || values.length === 0) return null; - const minT = Math.min(...times), - maxT = Math.max(...times); - const maxV = Math.max(0, threshold, ...values) || 1; - const x = (time: number) => - 30 + ((time - minT) / Math.max(60, maxT - minT)) * (width - 40); - const y = (v: number) => height - 25 - (v / maxV) * (height - 40); - const colors = ["#94a3b8", "#2563eb"]; - const activePoint = data.series.find((point) => point.time === activeTime); - const digits = metric.startsWith("revenue") ? 6 : 3; - return ( -
- { - const bounds = event.currentTarget.getBoundingClientRect(); - const pointerX = - ((event.clientX - bounds.left) / bounds.width) * width; - const pointerY = - ((event.clientY - bounds.top) / bounds.height) * height; - if ( - pointerX < 26 || - pointerX > width - 6 || - pointerY < 10 || - pointerY > height - 20 - ) { - setActiveTime(null); - return; - } - const time = - minT + - ((Math.max(30, Math.min(width - 10, pointerX)) - 30) / - (width - 40)) * - Math.max(60, maxT - minT); - const nearest = data.series.reduce((best, point) => - Math.abs(point.time - time) < Math.abs(best.time - time) - ? point - : best, - ); - // Do not present a record from a different minute across an observation gap. - setActiveTime( - Math.abs(nearest.time - time) <= 30 ? nearest.time : null, - ); - }} - onPointerLeave={() => setActiveTime(null)} - onFocus={() => setActiveTime(minT)} - onBlur={() => setActiveTime(null)} - onKeyDown={(event) => { - if (event.key === "Escape") { - setActiveTime(null); - return; - } - if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return; - event.preventDefault(); - const index = data.series.findIndex( - (point) => point.time === activeTime, - ); - const next = - index < 0 - ? 0 - : Math.max( - 0, - Math.min( - data.series.length - 1, - index + (event.key === "ArrowRight" ? 1 : -1), - ), - ); - setActiveTime(data.series[next].time); - }} - > - - - {format(maxV, metric.startsWith("revenue") ? 6 : 3)} - - {threshold > 0 && ( - - - - - {threshold} token/s - - - )} - {data.compareArms.map((arm, i) => { - const segments: string[][] = []; - let segment: string[] = []; - let previous = 0; - for (const point of data.series) { - const v = point.arms[arm]?.[metric]; - if ( - v === null || - v === undefined || - point.error || - (previous && point.time - previous > 60) - ) { - if (segment.length) segments.push(segment); - segment = []; - } - if (v !== null && v !== undefined && !point.error) - segment.push(`${x(point.time)},${y(v)}`); - previous = point.time; - } - if (segment.length) segments.push(segment); - return ( - - {segments.map((points, j) => ( - - ))} - {data.series - .filter((p) => !p.error && p.arms[arm]?.[metric] != null) - .map((p) => ( - 0 && p.arms[arm][metric]! < threshold - ? "#dc2626" - : colors[i] - } - stroke={ - threshold > 0 && p.arms[arm][metric]! < threshold - ? colors[i] - : undefined - } - strokeWidth="2" - /> - ))} - - ); - })} - {activePoint && ( - - - {data.compareArms.map((arm, i) => { - const value = activePoint.arms[arm]?.[metric]; - return !activePoint.error && value != null ? ( - - ) : null; - })} - - )} - - {new Date(minT * 1000).toLocaleString()} - - - {new Date(maxT * 1000).toLocaleString()} - - - {activePoint && ( - - )} -
- ); -} diff --git a/web/src/modules/canary/components/metrics/TpsBreachChart.tsx b/web/src/modules/canary/components/metrics/TpsBreachChart.tsx deleted file mode 100644 index 9d6148d..0000000 --- a/web/src/modules/canary/components/metrics/TpsBreachChart.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import type { Metrics } from "../../types"; -import { tpsPreview, format } from "../../lib/metric-presentation"; -import { useTranslation } from "../../lib/translation"; -export function TpsBreachChart({ - data, - threshold, -}: { - data: Metrics; - threshold: number; -}) { - const { t } = useTranslation(); - const previews = data.compareArms.map((arm) => - tpsPreview(data, arm, threshold), - ); - const max = Math.max(1, threshold, ...previews.flatMap((p) => p.values)); - const bins = 12; - const counts = previews.map((p) => - Array.from( - { length: bins }, - (_, i) => - p.values.filter( - (v) => Math.min(bins - 1, Math.floor((v / max) * bins)) === i, - ).length, - ), - ); - const height = Math.max(1, ...counts.flat()); - return ( -
-

{t("dashboard.distribution")}

- - - {counts.map((group, a) => - group.map((count, i) => ( - - - {data.compareArms[a]}: {format((i / bins) * max)}– - {format(((i + 1) / bins) * max)} token/s · {count}{" "} - {t("dashboard.minutes")} - - - )), - )} - {threshold > 0 && ( - - - - - {t("dashboard.threshold")}: {threshold} - - - )} - - 0 token/s - - - {format(max)} token/s - - -
- {data.compareArms.map((arm, i) => ( - - ● {arm}:{" "} - {previews[i].evaluated} {t("dashboard.minutes")} - {threshold > 0 - ? ` · ${t("dashboard.failed")}: ${previews[i].failed}` - : ""} - - ))} -
-
- ); -} diff --git a/web/src/modules/canary/error-locales.ts b/web/src/modules/canary/error-locales.ts deleted file mode 100644 index 2b289fb..0000000 --- a/web/src/modules/canary/error-locales.ts +++ /dev/null @@ -1,231 +0,0 @@ -export const errorEn = { - trafficChanged: "Traffic or experiment configuration changed. Refresh the experiment and retry.", - trafficWeights: "Use integer traffic weights from 0 to 1,000,000 with a nonzero sum.", - trafficPending: "Another traffic change is still being applied. Wait for it to finish.", - trafficRunning: "Traffic can only change on a running, observed Serving experiment with routing enabled.", - trafficStopped: "The traffic change was cancelled because the experiment stopped.", - requestFailed: "The request failed. Open the details for diagnostics.", - network: "Cannot reach the server. Check your connection and retry.", - cancelled: "The request was cancelled. Retry if needed.", - unauthorized: - "Your session has expired or authentication is required. Sign in again.", - forbidden: "You do not have permission to perform this action.", - notFound: "The requested experiment or resource was not found.", - conflict: "The resource changed or already exists. Refresh before retrying.", - targetBusy: "Production target {target} is already used by Canary experiment {experiment}. Only one active experiment is allowed per target.\nYou can keep that experiment and choose another target, or stop it, wait for cleanup to complete, then create a new experiment for this target.", - experimentNameBusy: "Experiment name {experiment} is already in use. Choose another name, or stop the existing experiment and wait for cleanup to complete.", - invalidRequest: "The request is invalid. Check the input and error details.", - tooLarge: - "The request is too large. Reduce the configuration size and retry.", - rateLimited: "Too many requests. Wait a moment and retry.", - server: "The server could not complete the request. Try again later.", - badResponse: - "The server returned an invalid response. Refresh or contact an administrator.", - timeout: "The request timed out. Try again later.", - details: "Technical details (original message)", - snapshot: "Metric collection failed. Open the details for diagnostics.", - condition: "The workload reported a condition requiring attention.", - invalidName: - "Invalid experiment name: use a DNS label of at most 28 characters.", - invalidNamespace: - "Invalid namespace: use a DNS label of at most 63 characters.", - invalidNameNamespace: - "Invalid experiment name (maximum 28 characters) or namespace (maximum 63 characters).", - namespaceDisabled: "Namespace {namespace} is not enabled for experiments.", - targetRequired: "Select a production HelmRelease.", - armCount: "Configure two to eight experiment arms.", - invalidArm: - "Arm {arm} is invalid or duplicated; use a unique DNS label of at most 20 characters.", - exactChart: "Arm {arm} requires an exact chart version.", - pinnedSource: - "Arm {arm} requires a pinned catalog source and a positive replica count.", - compareArms: "Select two distinct, existing comparison arms.", - requiredField: "Field {field} is required.", - weights: "Traffic weights must be nonnegative integers.", - zeroWeights: "Production and canary traffic weights cannot both be zero.", - gatewayRequired: - "Gateway, production Service and a non-root route path are required.", - gatewayMismatch: - "Gateway and production Service must match the deployment configuration.", - proxyMode: "Only a PerArm proxy without session affinity is supported.", - objectField: "Field {field} must be an object.", - booleanField: "Field {field} must be a boolean.", - stringField: "Field {field} must be a string.", - nullField: "Field {field} cannot be null; remove the key to use the default.", - routePort: - "Route port must be an integer from 1 to 65535, or 0 for the default.", - forceHeader: "Provide a valid header name and a nonempty value.", - routeMismatch: "The route must match the production deployment path.", - targetUnavailable: - "The production deployment is unavailable. Refresh the deployment list.", - pricingChanged: "Prices changed. Reload the price table before saving.", - valuationChanged: "The experiment valuation changed. Refresh before saving.", - prices: - "Each enabled currency needs three finite prices from 0 to 1,000,000.", - currency: "Currency must be USD or CNY.", - normalization: "Normalization must be raw, per-gpu or as-if.", - gpuRange: "GPU count must be an integer from 1 to 1024.", - armGPU: "GPU total for arm {arm} must be an integer from 1 to 1024.", - pricingKey: "Pricing key {key} is invalid.", - armPricingKey: "Arm {arm} has an invalid pricing key.", - missingPricingKey: "Pricing key {key} for arm {arm} is not configured.", - unknownPricingKey: "Pricing key {key} is not configured.", - priceCurrency: "Pricing key {key} needs at least one enabled currency.", - valuationArm: "The valuation contains an unknown or duplicate arm.", - frozenUnavailable: "The frozen arm configuration is unavailable.", - range: "The end time must be after the start time.", - nameExists: - "An experiment or resource with this name already exists. Choose another name.", - stopFirst: "Stop the experiment before archiving it.", - planIdentity: - "The prepared plan belongs to another user. Prepare a new plan.", - prepareAgain: - "The plan can no longer be used. Prepare a new plan before creating the experiment.", - legacyStop: - "Stop this experiment using its original Operator before migrating the API group.", - uidMismatch: - "The resource identity changed. The operation was refused to protect existing resources.", - remoteChart: "Arm {arm} requires an exact, remotely accessible chart.", - catalogSchema: "A compatible catalog schema v2 is required.", - json: "The request must contain one valid JSON document.", - prometheus: "Prometheus is not configured. Contact an administrator.", - historicalMinute: - "A historical minute is unavailable; its evidence cannot be reconstructed.", - armNoData: "Arm {arm} never reported in the observation window.", - armMinuteMissing: - "Arm {arm} has missing data in the minute ending at {time}.", - exporterGap: - "This minute is excluded because the monitoring exporter changed.", - yamlSyntax: - "Invalid YAML at line {line}, column {column}. Check indentation and syntax.", - yamlObject: "YAML must be an object with named keys.", - yamlValue: - "YAML at {field} contains a value that cannot be represented as JSON.", - yamlRecursive: - "YAML contains a recursive alias at {field}; remove the cycle.", - armContext: "Arm {arm}: {message}", - fieldContext: "{field}: {message}", -}; -export const errorZh: Record = { - trafficChanged: "流量或实验配置已变化,请刷新实验后重试。", - trafficWeights: "请输入 0 至 1,000,000 的整数流量权重,且总和不能为零。", - trafficPending: "另一项流量变更仍在应用,请等待完成。", - trafficRunning: "仅能调整已启用路由、正在运行且 Operator 已确认 Serving 的实验。", - trafficStopped: "实验已停止,此次流量变更已取消。", - requestFailed: "请求失败,请展开详情查看诊断信息。", - network: "无法连接服务器,请检查网络连接后重试。", - cancelled: "请求已取消,可按需重试。", - unauthorized: "登录已过期或尚未认证,请重新登录。", - forbidden: "你没有执行此操作的权限。", - notFound: "未找到请求的实验或资源。", - conflict: "资源已变更或已存在,请刷新后重试。", - targetBusy: "生产目标 {target} 已被北极星实验 {experiment} 占用。同一 target 同时只能有一个未完成清理的实验。\n你可以保留原有实验并选择其他 target;或先停止原有实验,等待清理完成后,再基于此 target 创建新的实验。", - experimentNameBusy: "实验名称 {experiment} 已被占用。请选择其他名称;或先停止原有实验,等待清理完成后再使用该名称。", - invalidRequest: "请求参数无效,请检查输入和错误详情。", - tooLarge: "请求内容过大,请缩减配置后重试。", - rateLimited: "请求过于频繁,请稍后重试。", - server: "服务器暂时无法完成请求,请稍后重试。", - badResponse: "服务器返回的数据格式无效,请刷新或联系管理员。", - timeout: "请求超时,请稍后重试。", - details: "技术详情(原始信息)", - snapshot: "指标采集失败,请展开详情查看诊断信息。", - condition: "工作负载状态异常,请查看详情。", - invalidName: "实验名称无效:请使用不超过 28 个字符的 DNS 标签。", - invalidNamespace: "命名空间无效:请使用不超过 63 个字符的 DNS 标签。", - invalidNameNamespace: - "实验名称或命名空间无效:名称最多 28 个字符,命名空间最多 63 个字符。", - namespaceDisabled: "命名空间 {namespace} 未启用实验功能。", - targetRequired: "请选择生产 HelmRelease。", - armCount: "请配置 2–8 个实验组。", - invalidArm: - "实验组 {arm} 无效或重复;请使用不超过 20 个字符的唯一 DNS 标签。", - exactChart: "实验组 {arm} 必须指定精确的 chart 版本。", - pinnedSource: "实验组 {arm} 需要固定版本的目录来源,且副本数必须为正整数。", - compareArms: "请选择两个不同且已配置的比较组。", - requiredField: "字段 {field} 为必填项。", - weights: "流量权重必须为非负整数。", - zeroWeights: "生产与实验流量权重不能同时为零。", - gatewayRequired: "必须配置 Gateway、生产 Service 和非根路径。", - gatewayMismatch: "Gateway 和生产 Service 必须与部署环境配置一致。", - proxyMode: "仅支持不启用会话亲和性的 PerArm 代理。", - objectField: "字段 {field} 必须为对象。", - booleanField: "字段 {field} 必须为布尔值。", - stringField: "字段 {field} 必须为字符串。", - nullField: "字段 {field} 不能为 null;如需默认值,请删除该字段。", - routePort: "路由端口必须为 1–65535 的整数,或使用 0 表示默认值。", - forceHeader: "请提供有效的请求头名称及非空值。", - routeMismatch: "路由必须与生产部署路径一致。", - targetUnavailable: "生产部署不可用,请刷新部署列表。", - pricingChanged: "价格已变更,请重新加载价表后再保存。", - valuationChanged: "实验估值已变更,请刷新后再保存。", - prices: "每个启用的币种必须填写三个 0–1,000,000 范围内的有限价格。", - currency: "币种必须为 USD 或 CNY。", - normalization: "归一化方式必须为 raw、per-gpu 或 as-if。", - gpuRange: "GPU 数量必须为 1–1024 的整数。", - armGPU: "实验组 {arm} 的 GPU 总数必须为 1–1024 的整数。", - pricingKey: "定价 key {key} 无效。", - armPricingKey: "实验组 {arm} 的定价 key 无效。", - missingPricingKey: "实验组 {arm} 的定价 key {key} 尚未配置。", - unknownPricingKey: "定价 key {key} 尚未配置。", - priceCurrency: "定价 key {key} 至少需要启用一个币种。", - valuationArm: "估值中存在未知或重复的实验组。", - frozenUnavailable: "冻结的实验组配置不可用。", - range: "结束时间必须晚于开始时间。", - nameExists: "同名实验或资源已存在,请使用其他名称。", - stopFirst: "请先停止实验,再执行归档。", - planIdentity: "预备方案属于其他用户,请重新生成方案。", - prepareAgain: "当前方案已不可用,请重新生成方案后创建实验。", - legacyStop: "请先使用原 Operator 停止实验,再迁移 API 组。", - uidMismatch: "资源身份已变更,为保护现有资源,操作已拒绝。", - remoteChart: "实验组 {arm} 需要精确版本且可远程访问的 chart。", - catalogSchema: "需要兼容的 catalog schema v2。", - json: "请求必须包含一个有效的 JSON 文档。", - prometheus: "尚未配置 Prometheus,请联系管理员。", - historicalMinute: "历史分钟数据不可用,无法重建对应的指标证据。", - armNoData: "实验组 {arm} 在观测窗口内没有上报数据。", - armMinuteMissing: "实验组 {arm} 在结束于 {time} 的分钟内缺少上报数据。", - exporterGap: "该分钟因指标采集器切换而未参与比较。", - yamlSyntax: "YAML 第 {line} 行、第 {column} 列语法无效,请检查缩进与格式。", - yamlObject: "YAML 必须是包含命名字段的对象。", - yamlValue: "YAML 字段 {field} 包含无法表示为 JSON 的值。", - yamlRecursive: "YAML 字段 {field} 包含递归别名,请移除循环引用。", - armContext: "实验组 {arm}:{message}", - fieldContext: "{field}:{message}", -}; -export const validationEn = { - required: "Required.", - dns: "Must be a valid DNS label.", - requiredDNS: "A valid DNS label is required.", - armName: "Use a DNS label of 1–20 characters.", - duplicateArm: "Arm name {arm} is duplicated.", - chartName: "Enter a valid chart name.", - exactVersion: "Use an exact version rather than a version range.", - apiVersion: "API version must be {version}.", - oneArm: "At least one arm is required.", - operatorOwned: - "Do not set {fields} on the CR; these fields are managed by the Operator.", - routePath: "Use a non-root path beginning with /.", - port: "Enter an integer port from 1 to 65535.", - headerName: "Invalid HTTP header name.", - headerPair: "Both header name and value are required.", - perArm: "Shared mode is unsupported; use PerArm.", - affinity: "Session affinity is unavailable in PerArm mode.", -}; -export const validationZh: Record = { - required: "此项为必填。", - dns: "必须为有效的 DNS 标签。", - requiredDNS: "必须填写有效的 DNS 标签。", - armName: "请使用 1–20 个字符的 DNS 标签。", - duplicateArm: "实验组名称 {arm} 重复。", - chartName: "请输入有效的 chart 名称。", - exactVersion: "请使用精确版本,不能使用版本范围。", - apiVersion: "API 版本必须为 {version}。", - oneArm: "至少需要一个实验组。", - operatorOwned: "不能在 CR 中设置 {fields};这些字段由 Operator 管理。", - routePath: "请填写以 / 开头的非根路径。", - port: "请输入 1–65535 的整数端口。", - headerName: "HTTP 请求头名称无效。", - headerPair: "必须同时填写请求头名称和值。", - perArm: "不支持 Shared 模式,请使用 PerArm。", - affinity: "PerArm 模式不支持会话亲和性。", -}; diff --git a/web/src/modules/canary/fixtures/canaries.ts b/web/src/modules/canary/fixtures/canaries.ts deleted file mode 100644 index 736370a..0000000 --- a/web/src/modules/canary/fixtures/canaries.ts +++ /dev/null @@ -1,279 +0,0 @@ -import type { LLMCanary } from "../types"; - -const now = "2026-10-10T08:00:00Z"; - -export const fixtureCanaries: LLMCanary[] = [ - { - apiVersion: "canary.modelsphere.dev/v1alpha1", - kind: "LLMCanary", - metadata: { - name: "qwen-tune", - namespace: "llm-serving", - generation: 3, - creationTimestamp: "2026-10-08T02:10:00Z", - }, - spec: { - targetRef: { - kind: "HelmRelease", - name: "qwen-prod", - namespace: "llm-serving", - }, - arms: [ - { - name: "baseline", - chart: { name: "sglang", version: "0.8.0" }, - values: { - replicaCount: 1, - model: { localPath: "/mnt/models/qwen3.6-35b-a3b" }, - extraArgs: ["--tp-size=8"], - }, - source: { - model: "qwen3.6-35b-a3b", - version: "1.1.0", - variant: "sglang-tp8-h100-baseline", - }, - }, - { - name: "candidate", - chart: { name: "sglang", version: "0.8.0" }, - values: { - replicaCount: 1, - model: { localPath: "/mnt/models/qwen3.6-35b-a3b" }, - extraArgs: ["--chunked-prefill-size=16384", "--tp-size=8"], - }, - source: { - model: "qwen3.6-35b-a3b", - version: "1.1.0", - variant: "sglang-tp8-h100-optimized", - }, - }, - ], - proxy: { mode: "PerArm", replicas: 2 }, - traffic: { production: 90, canary: 10 }, - gateway: { - name: "openresty", - namespace: "llm-route", - hostname: "ai-cloud.example.com", - sectionName: "https", - }, - route: { - enabled: true, - productionOpenrestyService: "openresty", - path: "/qwen", - forceHeader: { name: "x-canary", value: "force" }, - }, - }, - status: { - phase: "Serving", - observedGeneration: 3, - armsReadySince: "2026-10-08T03:00:00Z", - arms: [ - { name: "baseline", ready: true }, - { name: "candidate", ready: true }, - ], - canaryProxy: { name: "qwen-tune-openresty", ready: true }, - canaryProxies: [ - { name: "qwen-tune-openresty-baseline", arm: "baseline", ready: true }, - { - name: "qwen-tune-openresty-candidate", - arm: "candidate", - ready: true, - }, - ], - routeName: "canary-llm-serving-qwen-tune", - routePath: "/qwen", - gatewayNamespace: "llm-route", - conditions: [ - { - type: "Provisioned", - status: "True", - reason: "ArmsReady", - lastTransitionTime: now, - }, - { - type: "TrafficProgrammed", - status: "True", - reason: "RouteProgrammed", - lastTransitionTime: now, - }, - { - type: "Degraded", - status: "False", - reason: "NoIssues", - lastTransitionTime: now, - }, - ], - }, - }, - { - apiVersion: "canary.modelsphere.dev/v1alpha1", - kind: "LLMCanary", - metadata: { - name: "kimi-engine", - namespace: "llm-serving", - generation: 1, - creationTimestamp: "2026-10-12T11:00:00Z", - }, - spec: { - targetRef: { - kind: "HelmRelease", - name: "kimi-prod", - namespace: "llm-serving", - }, - arms: [ - { - name: "sglang", - chart: { name: "sglang", version: "0.7.9" }, - values: { replicaCount: 1, extraArgs: ["--tp-size=8"] }, - source: { - model: "kimi-k3", - version: "1.0.5", - variant: "sglang-tp8-b300-optimized", - }, - }, - { - name: "vllm", - chart: { name: "vllm", version: "0.5.0" }, - values: { replicaCount: 1, extraArgs: ["--tensor-parallel-size=8"] }, - source: { - model: "kimi-k3", - version: "1.0.5", - variant: "vllm-tp8-b300", - }, - }, - ], - proxy: { mode: "PerArm", replicas: 2 }, - traffic: { production: 99, canary: 1 }, - gateway: { name: "openresty", namespace: "llm-route" }, - route: { - enabled: false, - productionOpenrestyService: "openresty", - path: "/kimi-k3", - }, - }, - status: { - phase: "Provisioning", - observedGeneration: 1, - arms: [ - { name: "sglang", ready: true }, - { name: "vllm", ready: false }, - ], - canaryProxy: { name: "kimi-engine-openresty", ready: false }, - routePath: "/kimi-k3", - gatewayNamespace: "llm-route", - conditions: [ - { - type: "Provisioned", - status: "False", - reason: "ArmsNotReady", - message: "arm vllm is not ready", - lastTransitionTime: now, - }, - { - type: "TrafficProgrammed", - status: "False", - reason: "TrafficDisabled", - lastTransitionTime: now, - }, - ], - }, - }, - { - apiVersion: "canary.modelsphere.dev/v1alpha1", - kind: "LLMCanary", - metadata: { - name: "glm-fail", - namespace: "models", - generation: 2, - creationTimestamp: "2026-10-09T16:40:00Z", - }, - spec: { - targetRef: { kind: "HelmRelease", name: "glm-prod", namespace: "models" }, - arms: [ - { - name: "candidate", - chart: { name: "sglang", version: "0.8.0" }, - values: { replicaCount: 1, scaler: { minReplicas: 1 } }, - }, - ], - traffic: { production: 50, canary: 50 }, - gateway: { name: "edge", namespace: "models-route", sectionName: "http" }, - route: { - enabled: true, - productionOpenrestyService: "glm-openresty", - path: "/glm", - }, - }, - status: { - phase: "Failed", - observedGeneration: 2, - arms: [{ name: "candidate", ready: false }], - routeName: "canary-models-glm-fail", - routePath: "/glm", - gatewayNamespace: "models-route", - conditions: [ - { - type: "Degraded", - status: "True", - reason: "InvalidValues", - message: - "arm candidate must not set scaler.enabled: the operator writes it on both arms", - lastTransitionTime: now, - }, - ], - }, - }, - { - apiVersion: "canary.modelsphere.dev/v1alpha1", - kind: "LLMCanary", - metadata: { - name: "kimi-old", - namespace: "llm-serving", - generation: 8, - creationTimestamp: "2026-09-30T09:00:00Z", - }, - spec: { - targetRef: { - kind: "HelmRelease", - name: "kimi-prod", - namespace: "llm-serving", - }, - arms: [ - { - name: "baseline", - chart: { name: "sglang", version: "0.7.9" }, - values: { replicaCount: 1 }, - }, - ], - traffic: { production: 100, canary: 0 }, - gateway: { - name: "openresty", - namespace: "llm-route", - hostname: "ai-cloud.example.com", - }, - route: { - enabled: false, - productionOpenrestyService: "openresty", - path: "/kimi-k3", - }, - drain: { maxWait: "1h" }, - }, - status: { - phase: "Draining", - observedGeneration: 8, - drainStartedAt: "2026-10-13T01:10:00Z", - drainInFlight: 3, - routePath: "/kimi-k3", - gatewayNamespace: "llm-route", - arms: [{ name: "baseline", ready: true }], - conditions: [ - { - type: "TrafficProgrammed", - status: "False", - reason: "WithdrawFailed", - lastTransitionTime: now, - }, - ], - }, - }, -]; diff --git a/web/src/modules/canary/fixtures/catalog-v2.ts b/web/src/modules/canary/fixtures/catalog-v2.ts deleted file mode 100644 index 6455cfa..0000000 --- a/web/src/modules/canary/fixtures/catalog-v2.ts +++ /dev/null @@ -1,233 +0,0 @@ -import type { CatalogIndexV2, CatalogVariant } from "../types"; - -const COMMIT = "7cbd6a8c0e1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c"; - -function digest(n: string): string { - const hex = (n + "0".repeat(64)).replace(/[^a-f0-9]/g, "a").slice(0, 64); - return `sha256:${hex}`; -} - -const qwenTp2Values: CatalogVariant["values"] = { - model: { hostPathType: "Directory", contextLength: "" }, - extraArgs: [ - "--tp-size=2", - "--mem-fraction-static=0.85", - "--trust-remote-code", - ], - env: [{ name: "NVIDIA_DISABLE_REQUIRE", value: "1" }], -}; - -const qwenTp8BaselineValues: CatalogVariant["values"] = { - model: { hostPathType: "Directory", contextLength: "262144" }, - extraArgs: [ - "--tp-size=8", - "--mem-fraction-static=0.8", - "--trust-remote-code", - ], - env: [{ name: "NVIDIA_DISABLE_REQUIRE", value: "1" }], -}; - -const qwenTp8OptValues: CatalogVariant["values"] = { - model: { hostPathType: "Directory", contextLength: "262144" }, - extraArgs: [ - "--chunked-prefill-size=16384", - "--tp-size=8", - "--mem-fraction-static=0.8", - "--trust-remote-code", - ], - env: [{ name: "NVIDIA_DISABLE_REQUIRE", value: "1" }], -}; - -const kimiSglangValues: CatalogVariant["values"] = { - model: { mountPath: "/model", hostPathType: "Directory", contextLength: "" }, - extraArgs: [ - "--tp-size=8", - "--kv-cache-dtype=fp8_e4m3", - "--trust-remote-code", - ], -}; - -const kimiVllmValues: CatalogVariant["values"] = { - extraArgs: ["--tensor-parallel-size=8", "--trust-remote-code"], - env: [{ name: "NVIDIA_DISABLE_REQUIRE", value: "1" }], -}; - -/** Variant files the wizard reads for chart+values stubs. Schema v2 only. */ -export const catalogVariants: CatalogVariant[] = [ - { - apiVersion: "catalog.swiss/v2", - kind: "Variant", - model: "qwen3.6-35b-a3b", - id: "sglang-tp2-h100", - version: "1.1.0", - servedName: "qwen", - description: "TP2 on two H100 or H800 GPUs, single node.", - engine: "sglang", - chart: { name: "sglang", version: ">=0.7.1" }, - resolvedChartVersion: "0.8.0", - image: { repository: "lmsysorg/sglang", tag: "v0.5.18-cu129" }, - requires: { - gpus: 2, - topology: "single-node", - gpuProduct: ["NVIDIA-H100-80GB-HBM3", "NVIDIA-H800"], - }, - values: qwenTp2Values, - digest: digest("qwen-tp2"), - path: "models/qwen3.6-35b-a3b/sglang-tp2-h100.yaml", - }, - { - apiVersion: "catalog.swiss/v2", - kind: "Variant", - model: "qwen3.6-35b-a3b", - id: "sglang-tp8-h100-baseline", - version: "1.1.0", - servedName: "qwen", - description: "Baseline: TP8 on eight H100 GPUs, single node.", - engine: "sglang", - chart: { name: "sglang", version: ">=0.7.1" }, - resolvedChartVersion: "0.8.0", - image: { repository: "lmsysorg/sglang", tag: "v0.5.19-cu129" }, - requires: { - gpus: 8, - topology: "single-node", - gpuProduct: ["NVIDIA-H100-80GB-HBM3"], - }, - values: qwenTp8BaselineValues, - digest: digest("qwen-tp8b"), - path: "models/qwen3.6-35b-a3b/sglang-tp8-h100-baseline.yaml", - }, - { - apiVersion: "catalog.swiss/v2", - kind: "Variant", - model: "qwen3.6-35b-a3b", - id: "sglang-tp8-h100-optimized", - version: "1.1.0", - servedName: "qwen", - description: "Tuned TP8 on eight H100 GPUs.", - engine: "sglang", - chart: { name: "sglang", version: ">=0.7.1" }, - resolvedChartVersion: "0.8.0", - image: { repository: "lmsysorg/sglang", tag: "v0.5.19-cu129" }, - requires: { - gpus: 8, - topology: "single-node", - gpuProduct: ["NVIDIA-H100-80GB-HBM3"], - }, - values: qwenTp8OptValues, - digest: digest("qwen-tp8o"), - path: "models/qwen3.6-35b-a3b/sglang-tp8-h100-optimized.yaml", - }, - { - apiVersion: "catalog.swiss/v2", - kind: "Variant", - model: "kimi-k3", - id: "sglang-tp8-b300-optimized", - version: "1.0.5", - servedName: "kimi-k3", - description: "Tuned SGLang TP8 on eight B300 GPUs.", - engine: "sglang", - chart: { name: "sglang", version: ">=0.7.9" }, - resolvedChartVersion: "0.7.9", - image: { repository: "lmsysorg/sglang", tag: "v0.5.19" }, - requires: { - gpus: 8, - topology: "single-node", - gpuProduct: ["NVIDIA-B300-SXM6-AC"], - }, - values: kimiSglangValues, - digest: digest("kimi-sgl"), - path: "models/kimi-k3/sglang-tp8-b300-optimized.yaml", - }, - { - apiVersion: "catalog.swiss/v2", - kind: "Variant", - model: "kimi-k3", - id: "vllm-tp8-b300", - version: "1.0.5", - servedName: "kimi-k3", - description: "vLLM TP8 on eight B300 GPUs (cross-engine vs SGLang).", - engine: "vllm", - chart: { name: "vllm", version: ">=0.4.6" }, - resolvedChartVersion: "0.5.0", - image: { repository: "4pdosc/vllm-openai", tag: "v0.30.1-nightly" }, - requires: { - gpus: 8, - topology: "single-node", - gpuProduct: ["NVIDIA-B300-SXM6-AC"], - }, - values: kimiVllmValues, - digest: digest("kimi-vllm"), - path: "models/kimi-k3/vllm-tp8-b300.yaml", - }, -]; - -function indexVariant(v: CatalogVariant) { - return { - id: v.id, - version: v.version, - digest: v.digest ?? digest(v.id), - engine: v.engine, - chart: v.chart, - requires: v.requires, - servedName: v.servedName, - description: v.description, - }; -} - -export const catalogIndexV2: CatalogIndexV2 = { - schema: "v2", - name: "fixture", - ref: COMMIT, - models: [ - { - name: "qwen3.6-35b-a3b", - displayName: "Qwen3.6 35B-A3B", - description: "Stock Qwen3.6-35B-A3B on two or eight H100 GPUs.", - family: "qwen", - tags: ["chat", "moe"], - defaultVariant: "sglang-tp2-h100", - source: { hf: "Qwen/Qwen3.6-35B-A3B" }, - tuning: [ - { - baseline: { id: "sglang-tp8-h100-baseline", version: "1.1.0" }, - optimized: { id: "sglang-tp8-h100-optimized", version: "1.1.0" }, - uplift: 4.3, - report: "qwen3.6-35b-a3b-h100-report.html", - }, - ], - variants: catalogVariants - .filter((v) => v.model === "qwen3.6-35b-a3b") - .map(indexVariant), - }, - { - name: "kimi-k3", - displayName: "Kimi K3", - description: "Kimi K3 with SGLang and vLLM on eight B300 GPUs.", - family: "kimi", - tags: ["chat", "reasoning"], - defaultVariant: "sglang-tp8-b300-optimized", - source: { hf: "moonshotai/Kimi-K3" }, - tuning: [ - { - baseline: { id: "sglang-tp8-b300-baseline", version: "1.0.5" }, - optimized: { id: "sglang-tp8-b300-optimized", version: "1.0.5" }, - uplift: 57.6, - }, - ], - variants: catalogVariants - .filter((v) => v.model === "kimi-k3") - .map(indexVariant), - }, - ], -}; - -export function findVariant( - model: string, - id: string, - version?: string, -): CatalogVariant | undefined { - return catalogVariants.find( - (v) => - v.model === model && v.id === id && (!version || v.version === version), - ); -} diff --git a/web/src/modules/canary/fixtures/deployments.ts b/web/src/modules/canary/fixtures/deployments.ts deleted file mode 100644 index 8a156d0..0000000 --- a/web/src/modules/canary/fixtures/deployments.ts +++ /dev/null @@ -1,46 +0,0 @@ -import type { ProductionTarget } from "../types"; - -/** Production traffic-route keys (HelmRelease by name). Not arm templates. */ -export const productionTargets: ProductionTarget[] = [ - { - kind: "HelmRelease", - name: "qwen-prod", - namespace: "llm-serving", - model: "qwen3.6-35b-a3b", - variant: "sglang-tp8-h100-baseline", - version: "1.1.0", - route: "qwen", - chart: "sglang-0.8.0", - gateway: { - name: "openresty", - namespace: "llm-route", - hostname: "ai-cloud.example.com", - sectionName: "https", - }, - openrestyService: "openresty", - }, - { - kind: "HelmRelease", - name: "kimi-prod", - namespace: "llm-serving", - model: "kimi-k3", - variant: "sglang-tp8-b300-optimized", - version: "1.0.5", - route: "kimi-k3", - chart: "sglang-0.7.9", - gateway: { name: "openresty", namespace: "llm-route" }, - openrestyService: "openresty", - }, - { - kind: "HelmRelease", - name: "glm-prod", - namespace: "models", - model: "glm5.3", - variant: "sglang-tp8-h100", - version: "1.0.0", - route: "glm", - chart: "sglang-0.8.0", - gateway: { name: "edge", namespace: "models-route", sectionName: "http" }, - openrestyService: "glm-openresty", - }, -]; diff --git a/web/src/modules/canary/index.tsx b/web/src/modules/canary/index.tsx deleted file mode 100644 index 1e6e478..0000000 --- a/web/src/modules/canary/index.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { lazy, Suspense, type ReactNode } from "react"; -import { FlaskConical, Plus } from "lucide-react"; -import type { ConsoleModule } from "@/shell"; -import { apiFetch } from "@/shell/api"; -import { i18n, useT } from "@/shell/i18n"; -import { useCapabilities } from "@/shell/capabilities"; -import { CanaryProvider } from "./lib/provider"; -import { PluginBase } from "./lib/host"; -import { registerCanaryLocales } from "./locales"; - -const CanaryList = lazy(() => import("./pages/CanaryList").then((m) => ({ default: m.CanaryList }))); -const CanaryCreate = lazy(() => import("./pages/CanaryCreate").then((m) => ({ default: m.CanaryCreate }))); -const ExperimentDetail = lazy(() => import("./pages/ExperimentDetail").then((m) => ({ default: m.ExperimentDetail }))); -const LiveCanaryDetail = lazy(() => import("./pages/LiveCanaryDetail").then((m) => ({ default: m.LiveCanaryDetail }))); -const Pricing = lazy(() => import("./pages/Pricing").then((m) => ({ default: m.Pricing }))); - -registerCanaryLocales(i18n); -const authFetch: typeof fetch = (input, init) => apiFetch(String(input), init); - -function CanaryScope({ children }: { children: ReactNode }) { - const { data } = useCapabilities(); - const t = useT("shell"); - return ( - - - {t("capability.loading")}
}>{children} - - - ); -} - -const page = (node: ReactNode) => {node}; - -export const canaryModule: ConsoleModule = { - id: "canary", - title: "Canary", - basePath: "/canary", - capability: "canary", - frame: "flush", - pages: [ - { path: "", element: page(), menu: { label: "实验", icon: FlaskConical } }, - { path: "new", element: page(), permission: "canary.write", requiresWrite: true, menu: { label: "新建实验", icon: Plus } }, - { path: "pricing", element: page(), menu: { label: "价格", icon: FlaskConical } }, - { path: "experiments/:id", element: page() }, - { path: ":ns/:name", element: page() }, - ], -}; diff --git a/web/src/modules/canary/lib/api.ts b/web/src/modules/canary/lib/api.ts deleted file mode 100644 index efa4534..0000000 --- a/web/src/modules/canary/lib/api.ts +++ /dev/null @@ -1,335 +0,0 @@ -import type { - ChartVersions, - ChartVersionsSource, - CatalogIndexV2, - CatalogVariant, - CatalogInfo, - LLMCanary, - ProductionTarget, -} from "../types"; -import { - catalogIndexV2, - catalogVariants, - findVariant, -} from "../fixtures/catalog-v2"; -import { fixtureCanaries } from "../fixtures/canaries"; -import { productionTargets } from "../fixtures/deployments"; - -/** - * Cluster writes stay behind this interface. The fixture host never talks to kube. - */ -export interface CanaryApi { - list(): Promise; - get(namespace: string, name: string): Promise; - /** Upsert a client-side fixture. The host still does not write to a cluster. */ - create(canary: LLMCanary): Promise; -} - -export interface CatalogApi { - list(): Promise; - index(catalog?: string): Promise; - variant( - model: string, - id: string, - version?: string, - ): Promise; - variants(catalog?: string): Promise; - chartVersions(source: ChartVersionsSource): Promise; -} - -export interface SwissApi { - deployments(): Promise; -} - -/** Mutable overlay. The exported fixture array stays unchanged for tests. */ -const canaryStore: LLMCanary[] = structuredClone(fixtureCanaries); - -export const fixtureCanaryApi: CanaryApi = { - async list() { - return structuredClone(canaryStore); - }, - async get(namespace, name) { - const found = canaryStore.find( - (c) => c.metadata.namespace === namespace && c.metadata.name === name, - ); - return found ? structuredClone(found) : null; - }, - async create(canary) { - const next = structuredClone(canary); - const index = canaryStore.findIndex( - (c) => - c.metadata.namespace === next.metadata.namespace && - c.metadata.name === next.metadata.name, - ); - if (index >= 0) canaryStore[index] = next; - else canaryStore.push(next); - return structuredClone(next); - }, -}; - -export const fixtureCatalogApi: CatalogApi = { - async list() { - return [{ name: catalogIndexV2.name, schema: "v2", default: true }]; - }, - async index() { - if (catalogIndexV2.schema !== "v2") { - throw new Error("catalog schema v1 is not supported"); - } - return catalogIndexV2; - }, - async variant(model, id, version) { - return findVariant(model, id, version) ?? null; - }, - async chartVersions(source) { - const v = findVariant(source.model, source.variant, source.version); - if (!v) throw new Error("Not found"); - return { chart: v.chart.name, range: v.chart.version, variant: v.id, - versions: [v.resolvedChartVersion] }; - }, - async variants() { - return catalogVariants; - }, -}; - -export const fixtureSwissApi: SwissApi = { - async deployments() { - return productionTargets; - }, -}; - -export const api = { - canary: fixtureCanaryApi, - catalog: fixtureCatalogApi, - swiss: fixtureSwissApi, -}; - -import type { CanaryClient } from "./live-api"; -import type { Experiment, Prepared, PricingTable } from "../types"; -import { freezeFixtureEconomics } from "./fixture-economics"; -import { emitManifests } from "./emit"; -const fixtureExperiments: Experiment[] = canaryStore.map((c, i) => ({ - id: `fixture-${i}`, - prepareId: `fixture-${i}`, - name: c.metadata.name, - namespace: c.metadata.namespace, - creator: "fixture", - createdAt: c.metadata.creationTimestamp ?? new Date().toISOString(), - canary: c, - prepared: { - id: `fixture-${i}`, - createdAt: new Date().toISOString(), - draft: {} as Prepared["draft"], - canary: c, - plans: [], - warnings: [], - }, - compareArms: c.spec.arms.slice(0, 2).map((a) => a.name), - state: "Running", - cleanup: "pending", - snapshot: "pending", - archived: false, - windows: [], -})); -const fixturePlans = new Map(); -function fixtureExperiment(id: string) { - const e = fixtureExperiments.find((e) => e.id === id); - if (!e) throw new Error("Not found"); - return e; -} -import { fixtureMetrics } from "./fixture-metrics"; -export const fixtureApi: CanaryClient = { - mode: "fixture", - async config() { - return { namespaces: ["llm-serving"] }; - }, - pricing: { - async get() { - return structuredClone(fixturePricing); - }, - async save(table) { - if (table.revision !== fixturePricing.revision) - throw new Error("Pricing changed; reload before saving"); - fixturePricing = { - ...structuredClone(table), - revision: table.revision + 1, - updatedAt: new Date().toISOString(), - updatedBy: "fixture", - }; - return structuredClone(fixturePricing); - }, - }, - canary: fixtureCanaryApi, - catalog: fixtureCatalogApi, - swiss: fixtureSwissApi, - experiments: { - async updateTraffic() { - throw new Error("Live traffic updates require the live backend"); - }, - async list() { - return structuredClone( - fixtureExperiments.map((e) => ({ - id: e.id, - name: e.name, - namespace: e.namespace, - creator: e.creator, - createdAt: e.createdAt, - state: e.state, - archived: e.archived, - cleanup: e.cleanup, - snapshot: e.snapshot, - snapshotError: e.snapshotError, - error: e.error, - compareArms: e.compareArms, - targetRef: e.canary.spec.targetRef, - phase: e.canary.status?.phase, - })), - ); - }, - async get(id) { - return structuredClone(fixtureExperiment(id)); - }, - async prepare(draft) { - const emitted = emitManifests(draft); - const issues = emitted.issues; - if (issues.length) - throw new Error( - issues.map((i) => `${i.path}: ${i.message}`).join("; "), - ); - const p: Prepared = { - id: crypto.randomUUID(), - createdAt: new Date().toISOString(), - draft: structuredClone(draft), - canary: emitted.canary, - plans: [], - warnings: [], - }; - p.economics = freezeFixtureEconomics( - p.canary, - draft.arms, - fixturePricing, - ); - fixturePlans.set(p.id, p); - return structuredClone(p); - }, - async create(id) { - const old = fixtureExperiments.find((e) => e.prepareId === id); - if (old) return structuredClone(old); - const p = fixturePlans.get(id); - if (!p) throw new Error("Not found"); - if ( - fixtureExperiments.some( - (e) => - e.name === p.draft.name && - e.namespace === p.draft.namespace && - e.cleanup !== "complete", - ) - ) - throw new Error("Name already exists"); - const e: Experiment = { - id: crypto.randomUUID(), - prepareId: id, - name: p.draft.name, - namespace: p.draft.namespace, - creator: "fixture", - createdAt: new Date().toISOString(), - canary: p.canary, - prepared: p, - compareArms: - p.draft.compareArms ?? p.draft.arms.slice(0, 2).map((a) => a.name), - state: "Running", - cleanup: "pending", - snapshot: "pending", - archived: false, - windows: [], - }; - fixtureExperiments.unshift(e); - canaryStore.push(e.canary); - return structuredClone(e); - }, - async stop(id) { - const e = fixtureExperiment(id); - e.state = "Stopped"; - e.cleanup = "complete"; - e.stopAt = new Date().toISOString(); - e.snapshot = "complete"; - return structuredClone(e); - }, - async archive(id, archived) { - const e = fixtureExperiment(id); - if (e.state !== "Stopped") throw new Error("Stop experiment first"); - e.archived = archived; - return structuredClone(e); - }, - async snapshot(id) { - return structuredClone(fixtureExperiment(id)); - }, - async revalue(id, revision, arms) { - const e = fixtureExperiment(id); - if ((e.prepared.economics?.revision ?? 0) !== revision) - throw new Error("Valuation changed; reload before saving"); - e.prepared.economics = freezeFixtureEconomics( - e.canary, - arms.map((a) => ({ - ...a, - chart: { name: "", version: "" }, - layers: {}, - replicaCount: 1, - })), - fixturePricing, - ); - e.prepared.economics.revision = revision + 1; - return structuredClone(e); - }, - async metrics(id, start, end, requestedView) { - const e = fixtureExperiment(id); - return fixtureMetrics(e, start, end, { - currency: e.prepared.economics?.currency ?? fixturePricing.currency, - normalization: "raw", - ...requestedView, - }); - }, - }, -}; - -let fixturePricing: PricingTable = { - revision: 1, - currency: "USD", - models: {}, - default: { - USD: { inputPerMillion: 1, cachedPerMillion: 0.1, outputPerMillion: 2 }, - CNY: { inputPerMillion: 7, cachedPerMillion: 0.7, outputPerMillion: 14 }, - }, -}; -for (const e of fixtureExperiments) - e.prepared.economics = freezeFixtureEconomics(e.canary, [], fixturePricing); - -const fixtureEnd = Math.floor(Date.now() / 60000) * 60 - 120; -for (const [index, e] of fixtureExperiments.entries()) { - const end = - index === 0 ? Date.parse("2026-10-20T08:00:00Z") / 1000 : fixtureEnd; - e.createdAt = new Date((end - 3600) * 1000).toISOString(); - if (index === 0) { - e.state = "Stopped"; - e.stopAt = new Date(end * 1000).toISOString(); - e.snapshot = "complete"; - e.cleanup = "complete"; - } - e.windows = [ - [end - 2400, end - 1320], - [end - 1200, end], - ].map(([start, stop], generation) => ({ - start: new Date(start * 1000).toISOString(), - end: new Date(stop * 1000).toISOString(), - generation: generation + 1, - bindings: Object.fromEntries( - e.compareArms.map((arm) => [ - arm, - { - namespace: e.namespace, - service: `fixture-${arm}`, - uid: `synthetic-${generation}-${arm}`, - }, - ]), - ), - })); -} diff --git a/web/src/modules/canary/lib/catalog.test.ts b/web/src/modules/canary/lib/catalog.test.ts deleted file mode 100644 index 2102c30..0000000 --- a/web/src/modules/canary/lib/catalog.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { changeArmCatalog, defaultCatalog } from "./catalog"; -import type { ArmDraft } from "../types"; - -describe("arm catalog selection", () => { - it("prefers upstreamv2 even when another catalog is the environment default", () => { - expect(defaultCatalog([ - { name: "upstream", schema: "v1" }, - { name: "inner", schema: "v2", default: true }, - { name: "upstreamv2", schema: "v2" }, - ])).toBe("upstreamv2"); - }); - - it("uses a supported environment default or first supported catalog when upstreamv2 is absent", () => { - expect(defaultCatalog([{ name: "old", schema: "v1", default: true }, { name: "inner", schema: "v2" }])).toBe("inner"); - expect(defaultCatalog([{ name: "first", schema: "v2" }, { name: "inner", schema: "v2", default: true }])).toBe("inner"); - expect(defaultCatalog([{ name: "old", schema: "v1" }])).toBe(""); - expect(defaultCatalog([])).toBe(""); - }); - - it("clears source-specific configuration even when both catalogs contain the same model and variant names", () => { - const arm: ArmDraft = { - name: "baseline", replicaCount: 2, pricingKey: "custom", - source: { catalog: "upstreamv2", model: "same-model", variant: "same-variant", version: "1.0.0", digest: "sha256:old" }, - chart: { name: "sglang", version: "0.8.0" }, - layers: { catalog: { model: { gpus: 8 } }, form: { replicaCount: 2 }, edit: { image: { tag: "old" } } }, - }; - const switched = changeArmCatalog(arm, "inner"); - expect(switched.source).toEqual({ catalog: "inner", model: "", variant: "", version: "" }); - expect(switched.chart).toEqual({ name: "", version: "" }); - expect(switched.layers).toEqual({ form: { replicaCount: 2 } }); - expect(switched).toMatchObject({ name: "baseline", replicaCount: 2, pricingKey: "custom" }); - expect(arm.source?.catalog).toBe("upstreamv2"); - expect(arm.layers.edit).toBeDefined(); - }); -}); diff --git a/web/src/modules/canary/lib/catalog.ts b/web/src/modules/canary/lib/catalog.ts deleted file mode 100644 index d82d4ae..0000000 --- a/web/src/modules/canary/lib/catalog.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { ArmDraft, CatalogInfo } from "../types"; - -export function defaultCatalog(catalogs: CatalogInfo[]): string { - const supported = catalogs.filter((c) => c.schema !== "v1"); - return (supported.find((c) => c.name === "upstreamv2") ?? - supported.find((c) => c.default) ?? supported[0])?.name ?? ""; -} - -export function changeArmCatalog(arm: ArmDraft, catalog: string): ArmDraft { - return { - ...arm, - source: { catalog, model: "", variant: "", version: "" }, - chart: { name: "", version: "" }, - layers: { form: { replicaCount: arm.replicaCount } }, - }; -} diff --git a/web/src/modules/canary/lib/emit.test.ts b/web/src/modules/canary/lib/emit.test.ts deleted file mode 100644 index 4623e8f..0000000 --- a/web/src/modules/canary/lib/emit.test.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { emptyDraft, emitManifests, layersFromVariant } from "./emit"; -import { catalogVariants } from "../fixtures/catalog-v2"; -import { fixtureCanaries } from "../fixtures/canaries"; -import { validateCanary, wizardStepComplete } from "./validate"; -import type { ArmDraft } from "../types"; - -describe("emitManifests", () => { - it("puts merged values on the CR without operator-owned enabled keys", () => { - const variant = - catalogVariants.find((v) => v.id === "sglang-tp8-h100-optimized") ?? - catalogVariants[0]; - const draft = emptyDraft(); - draft.name = "qwen-prod-canary"; - draft.targetRef = { - kind: "HelmRelease", - name: "qwen-prod", - namespace: "llm-serving", - }; - draft.gateway = { - name: "openresty", - namespace: "llm-route", - hostname: "ai-cloud.example.com", - sectionName: "https", - }; - draft.traffic = { production: 90, canary: 10 }; - draft.route = { - enabled: true, - productionOpenrestyService: "openresty", - path: "/qwen", - forceHeader: { name: "x-canary", value: "force" }, - }; - draft.arms = [ - { - name: "candidate", - chart: { - name: variant.chart.name, - version: variant.resolvedChartVersion, - }, - replicaCount: 1, - source: { - model: variant.model, - version: variant.version, - variant: variant.id, - }, - layers: { - ...layersFromVariant(variant, 1), - edit: { scaler: { enabled: true }, extraArgs: ["--tp-size=8"] }, - }, - }, - ]; - const result = emitManifests(draft); - expect(result.canary.apiVersion).toBe("canary.modelsphere.dev/v1alpha1"); - expect(result.yaml).not.toContain("canary.4pd.io"); - expect(result.issues).toEqual([]); - expect(result.canary.spec.arms[0]?.values.scaler).toBeUndefined(); - expect(result.stripped.candidate).toContain("scaler.enabled"); - expect(result.yaml).toContain("kind: LLMCanary"); - expect(result.yaml).not.toContain("kind: ConfigMap"); - expect(result.canary.spec.traffic).toEqual({ production: 90, canary: 10 }); - expect(result.canary.spec.gateway).toEqual({ - name: "openresty", - namespace: "llm-route", - hostname: "ai-cloud.example.com", - sectionName: "https", - }); - expect(result.canary.spec.route).toMatchObject({ - enabled: true, - path: "/qwen", - productionOpenrestyService: "openresty", - forceHeader: { name: "x-canary", value: "force" }, - }); - expect(result.yaml).toContain("path: /qwen"); - expect(result.yaml).toContain("enabled: true"); - expect(validateCanary(result.canary)).toEqual([]); - }); - - it("rejects a catalog arm until gateway, route, and traffic split are set", () => { - const variant = catalogVariants[0]; - const arm: ArmDraft = { - name: "candidate", - chart: { - name: variant.chart.name, - version: variant.resolvedChartVersion, - }, - replicaCount: 1, - source: { - model: variant.model, - version: variant.version, - variant: variant.id, - }, - layers: layersFromVariant(variant, 1), - }; - const draft = emptyDraft(); - draft.name = "qwen-prod-canary"; - draft.targetRef = { - kind: "HelmRelease", - name: "qwen-prod", - namespace: "llm-serving", - }; - draft.arms = [arm]; - draft.traffic = { production: 0, canary: 0 }; - expect(wizardStepComplete("arms", draft)).toBe(true); - expect(wizardStepComplete("traffic", draft)).toBe(false); - expect(wizardStepComplete("route", draft)).toBe(false); - - const bare = emitManifests(draft); - const paths = bare.issues.map((issue) => issue.path); - expect(paths).toContain("spec.traffic"); - expect(paths).toContain("spec.gateway.name"); - expect(paths).toContain("spec.gateway.namespace"); - 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(true); - - draft.traffic = { production: 99, canary: 1 }; - draft.gateway = { name: "openresty", namespace: "llm-route" }; - draft.route = { - enabled: false, - productionOpenrestyService: "openresty", - path: "/qwen", - servicePort: 8080, - forceHeader: { name: "", value: "" }, - }; - const ready = emitManifests(draft); - expect(ready.issues).toEqual([]); - expect(wizardStepComplete("traffic", draft)).toBe(true); - expect(wizardStepComplete("route", draft)).toBe(true); - expect(ready.canary.spec.route).toMatchObject({ - enabled: false, - path: "/qwen", - productionOpenrestyService: "openresty", - servicePort: 8080, - }); - expect(ready.canary.spec.traffic).toEqual({ production: 99, canary: 1 }); - expect(ready.yaml).toContain("enabled: false"); - }); -}); - -describe("fixture canaries", () => { - it("stores gateway, HTTPRoute path, route.enabled, and a traffic split", () => { - expect(fixtureCanaries.length).toBeGreaterThan(0); - for (const canary of fixtureCanaries) { - expect(canary.spec.gateway.name).toBeTruthy(); - expect(canary.spec.gateway.namespace).toMatch( - /^[a-z0-9]([-a-z0-9]*[a-z0-9])?$/, - ); - expect(canary.spec.route.path).toMatch(/^\/.+/); - expect(typeof canary.spec.route.enabled).toBe("boolean"); - expect(canary.spec.route.productionOpenrestyService).toBeTruthy(); - expect( - canary.spec.traffic.production + canary.spec.traffic.canary, - ).toBeGreaterThan(0); - expect(validateCanary(canary)).toEqual([]); - } - }); -}); diff --git a/web/src/modules/canary/lib/emit.ts b/web/src/modules/canary/lib/emit.ts deleted file mode 100644 index e29d800..0000000 --- a/web/src/modules/canary/lib/emit.ts +++ /dev/null @@ -1,142 +0,0 @@ -import type { - ArmDraft, - CanaryDraft, - CatalogVariant, - JsonObject, - LLMCanary, -} from "../types"; -import { mergeLayers, stripOperatorOwned } from "./values"; -import { toYaml } from "./yaml"; -import { validateDraft, type ValidationIssue } from "./validate"; -import { CANARY_API_VERSION, CANARY_GROUP } from "./kubernetes"; - -export const DERIVED_OPERATOR_LAYER: JsonObject = { - scaler: { enabled: false }, - cart: { enabled: true }, - sloRequirement: { enabled: false }, -}; - -export function layersFromVariant( - variant: CatalogVariant, - replicaCount: number, - extraForm: JsonObject = {}, -): ArmDraft["layers"] { - const catalog: JsonObject = { ...variant.values }; - if (variant.image) catalog.image = { ...variant.image }; - return { - catalog, - form: { - replicaCount, - ...extraForm, - }, - derived: DERIVED_OPERATOR_LAYER, - }; -} - -export function armValuesForCr(arm: ArmDraft): { - values: JsonObject; - stripped: string[]; -} { - const merged = mergeLayers(arm.layers); - const { stripped, removed } = stripOperatorOwned(merged); - return { values: stripped, stripped: removed }; -} - -export function draftToCanary(draft: CanaryDraft): { - canary: LLMCanary; - stripped: Record; -} { - const stripped: Record = {}; - const canary: LLMCanary = { - apiVersion: CANARY_API_VERSION, - kind: "LLMCanary", - metadata: { - name: draft.name, - namespace: draft.namespace, - annotations: { - [`${CANARY_GROUP}/catalog-schema`]: "catalog.swiss/v2", - }, - }, - spec: { - targetRef: { - kind: draft.targetRef.kind || "HelmRelease", - name: draft.targetRef.name, - ...(draft.targetRef.namespace - ? { namespace: draft.targetRef.namespace } - : {}), - }, - arms: draft.arms.map((arm) => { - const built = armValuesForCr(arm); - stripped[arm.name] = built.stripped; - return { - name: arm.name, - chart: arm.chart, - values: built.values, - }; - }), - proxy: { - mode: "PerArm", - replicas: draft.proxy.replicas || 2, - ...(draft.proxy.values && Object.keys(draft.proxy.values).length > 0 - ? { values: draft.proxy.values } - : {}), - }, - traffic: { - production: draft.traffic.production, - canary: draft.traffic.canary, - }, - gateway: { - name: draft.gateway.name, - namespace: draft.gateway.namespace, - ...(draft.gateway.hostname ? { hostname: draft.gateway.hostname } : {}), - ...(draft.gateway.sectionName - ? { sectionName: draft.gateway.sectionName } - : {}), - }, - route: { - enabled: draft.route.enabled, - productionOpenrestyService: draft.route.productionOpenrestyService, - ...(draft.route.path ? { path: draft.route.path } : {}), - ...(draft.route.servicePort !== undefined - ? { servicePort: draft.route.servicePort } - : {}), - ...(draft.route.forceHeader.name && draft.route.forceHeader.value - ? { forceHeader: { ...draft.route.forceHeader } } - : {}), - }, - }, - }; - return { canary, stripped }; -} - -export interface EmitResult { - canary: LLMCanary; - issues: ValidationIssue[]; - yaml: string; - stripped: Record; -} - -export function emitManifests(draft: CanaryDraft): EmitResult { - const { canary, stripped } = draftToCanary(draft); - const issues = validateDraft(draft, canary); - const yaml = toYaml(canary); - return { canary, issues, yaml, stripped }; -} - -export function emptyDraft(): CanaryDraft { - return { - name: "", - namespace: "llm-serving", - targetRef: { kind: "HelmRelease", name: "", namespace: "llm-serving" }, - arms: [], - traffic: { production: 90, canary: 10 }, - gateway: { name: "", namespace: "" }, - route: { - enabled: true, - productionOpenrestyService: "", - path: "", - forceHeader: { name: "x-canary", value: "force" }, - }, - proxy: { mode: "PerArm", replicas: 2, sessionAffinity: false }, - }; -} diff --git a/web/src/modules/canary/lib/errors.test.ts b/web/src/modules/canary/lib/errors.test.ts deleted file mode 100644 index 93f030f..0000000 --- a/web/src/modules/canary/lib/errors.test.ts +++ /dev/null @@ -1,247 +0,0 @@ -import { createElement } from "react"; -import { renderToStaticMarkup } from "react-dom/server"; -import i18next from "i18next"; -import ICU from "i18next-icu"; -import { describe, expect, it } from "vitest"; -import { - asError, - CanaryApiError, - describeError, - visibleExperimentError, -} from "./errors"; -import { canaryLocales, registerCanaryLocales } from "../locales"; -import { createLiveApi } from "./live-api"; -import { fromYamlObject } from "./yaml"; -import { CanaryI18nContext } from "./translation"; -import { ErrorNotice } from "../components/ErrorNotice"; -function translator(language: string) { - const i18n = i18next.createInstance().use(ICU); - void i18n.init({ - lng: language, - fallbackLng: "en", - initAsync: false, - defaultNS: "canary", - resources: { - en: { canary: canaryLocales.en }, - zh: { canary: canaryLocales.zh }, - }, - }); - registerCanaryLocales(i18n); - return i18n; -} -describe("localized UI errors", () => { - it("hides expected provisioning transitions but keeps actionable failures", () => { - expect( - visibleExperimentError({ - state: "Creating", - error: - 'Operation cannot be fulfilled on llmcanaries.modelsphere.dev "demo": the object has been modified', - }), - ).toBeUndefined(); - expect( - visibleExperimentError({ - state: "Running", - error: "not Serving or generation not observed", - }), - ).toBeUndefined(); - expect( - visibleExperimentError({ - state: "Stopping", - error: "deletion refused", - }), - ).toBe("deletion refused"); - }); - it("explains the occupied target and both safe choices on creation conflict", async () => { - const message = "active experiment conflict: production target HelmRelease/qwen36/qwen3-6-35b-a3b is already used by canary/wyq; stop that experiment and wait for cleanup, or choose another production target"; - const api = createLiveApi(async () => new Response(JSON.stringify({ error: message }), { status: 409 })); - const error = await api.experiments.create("plan").catch((e) => e); - const zh = describeError(error, translator("zh").getFixedT("zh", "canary")); - expect(zh.summary).toContain("HelmRelease/qwen36/qwen3-6-35b-a3b"); - expect(zh.summary).toContain("canary/wyq"); - expect(zh.summary).toContain("保留原有实验并选择其他 target"); - expect(zh.summary).toContain("等待清理完成后"); - expect(zh.details).toBe(message); - const en = describeError(error, translator("en").getFixedT("en", "canary")); - expect(en.summary).toContain("keep that experiment and choose another target"); - expect(en.summary).toContain("wait for cleanup to complete"); - }); - it("explains an occupied experiment name", () => { - const error = new CanaryApiError("active experiment conflict: experiment name canary/wyq is already in use; choose another name or wait for cleanup", 409); - expect(describeError(error, translator("zh").getFixedT("zh", "canary")).summary).toContain("实验名称 canary/wyq 已被占用"); - }); - it("translates backend name and namespace validation, including the old combined error", () => { - const zh = translator("zh-CN").getFixedT("zh-CN", "canary"), - en = translator("en-US").getFixedT("en-US", "canary"); - expect( - describeError( - new Error("invalid canary name (maximum 28 characters)"), - zh, - ).summary, - ).toContain("实验名称无效"); - expect( - describeError( - new Error("invalid canary namespace (maximum 63 characters)"), - zh, - ).summary, - ).toContain("命名空间无效"); - const old = new Error( - "invalid canary name (maximum 28 characters) or namespace", - ); - expect(describeError(old, zh).summary).toContain("28"); - expect(describeError(old, zh).summary).toContain("63"); - expect(describeError(old, en).summary).toContain("namespace"); - expect(describeError(old, zh).details).toBe(old.message); - }); - it.each([401, 403, 404, 409, 413, 429, 500, 502, 503])( - "preserves HTTP %i and localizes non-JSON responses", - async (status) => { - const api = createLiveApi( - async () => new Response("gateway failure", { status }), - ); - const error = await api.experiments.list().catch((e) => e); - expect(error.status).toBe(status); - expect(error.message).toBe(`HTTP ${status}`); - const zh = describeError( - error, - translator("zh").getFixedT("zh", "canary"), - ); - const en = describeError( - error, - translator("en").getFixedT("en", "canary"), - ); - expect(zh.summary).not.toBe(en.summary); - expect(zh.summary).not.toContain("HTTP"); - expect(zh.details).toBe(`HTTP ${status}`); - }, - ); - it("keeps conflict specifics instead of replacing them with a generic status", async () => { - const api = createLiveApi( - async () => - new Response( - JSON.stringify({ error: "pricing changed; reload before saving" }), - { status: 409 }, - ), - ); - const error = await api.pricing.get().catch((e) => e); - expect( - describeError(error, translator("zh").getFixedT("zh", "canary")).summary, - ).toBe("价格已变更,请重新加载价表后再保存。"); - }); - it("localizes transport failure and rejects malformed successful responses", async () => { - const t = translator("zh").getFixedT("zh", "canary"); - const offline = createLiveApi(async () => { - throw new TypeError("Failed to fetch"); - }); - expect( - describeError(await offline.config().catch((e) => e), t).summary, - ).toContain("无法连接服务器"); - const invalid = createLiveApi(async () => new Response("not json")); - expect( - describeError(await invalid.config().catch((e) => e), t).summary, - ).toContain("数据格式无效"); - }); - it("preserves structured error parameters and ignores translation options from the payload", async () => { - const api = createLiveApi( - async () => - new Response( - JSON.stringify({ - error: "raw", - code: "namespaceDisabled", - params: { - namespace: "test-{scope}", - lng: "en", - returnObjects: true, - }, - }), - { status: 400 }, - ), - ); - const error = await api.config().catch((e) => e); - expect( - describeError(error, translator("zh").getFixedT("zh", "canary")).summary, - ).toBe("命名空间 test-{scope} 未启用实验功能。"); - expect( - describeError( - new CanaryApiError("new server error", 400, "notYetKnown"), - translator("zh").getFixedT("zh", "canary"), - ).summary, - ).toContain("请求失败"); - }); - it("localizes validation paths, wrapped arm errors and compound snapshot failures", () => { - const t = translator("zh").getFixedT("zh", "canary"); - expect( - describeError( - 'arm candidate: arm candidate pricing key "model.v2" is not configured', - t, - ).summary, - ).toContain("model.v2 尚未配置"); - expect(describeError('duplicate arm name "baseline"', t).summary).toBe( - "实验组名称 baseline 重复。", - ); - expect( - describeError( - "spec.gateway.name: required; spec.traffic: weights must be integers >= 0", - t, - ).summary, - ).toContain("流量权重必须为非负整数"); - const raw = - "arm baseline never reported in the measurement window; arm candidate reported nothing in the minute ending 2026-10-21T04:00:00Z"; - const result = describeError(raw, t, "snapshot"); - expect(result.summary).toContain("baseline 在观测窗口内没有上报数据"); - expect(result.summary).toContain("2026-10-21T04:00:00Z"); - expect(result.details).toBe(raw); - }); - it("keeps YAML errors as objects and translates syntax locations, shape and recursion", () => { - const t = translator("zh").getFixedT("zh", "canary"); - let syntax: unknown; - try { - fromYamlObject("image: ["); - } catch (e) { - syntax = e; - } - expect(describeError(syntax, t).summary).toMatch( - /YAML 第 \d+ 行、第 \d+ 列/, - ); - expect(describeError(syntax, t).details).toContain("unexpected end"); - let recursive: unknown; - try { - fromYamlObject("a: &x\n b: *x"); - } catch (e) { - recursive = e; - } - expect(describeError(recursive, t).summary).toContain("递归别名"); - expect( - describeError("YAML must be an object with named keys.", t).summary, - ).toBe("YAML 必须是包含命名字段的对象。"); - }); - it("changes an already displayed error with the language and escapes diagnostics", async () => { - const i18n = translator("zh"); - const error = new CanaryApiError("", 403); - const render = () => - renderToStaticMarkup( - createElement( - CanaryI18nContext.Provider, - { value: i18n }, - createElement(ErrorNotice, { error }), - ), - ); - const zh = render(); - expect(zh).toContain("你没有执行此操作的权限"); - expect(zh).toContain("技术详情(原始信息)"); - expect(zh).not.toContain("