From 29899cd1e0529f17d3842efd7d653f19d3618089 Mon Sep 17 00:00:00 2001 From: "mudler's LocalAI [bot]" <139863280+localai-bot@users.noreply.github.com> Date: Fri, 28 Aug 2026 22:57:35 +0200 Subject: [PATCH 1/4] fix(ui): size model fit against the cluster and move node labels into the selector (#11765) * fix(ui): move node labels into the scheduling selector field The scheduling page kept a node-label browser open above the rules whether or not anyone was writing one, while the field that actually needs labels, the rule's node selector, was two bare text inputs with no hint of what the cluster reports. The browser is gone. The selector's key input now completes against the label keys the cluster uses, and the value input offers only the values that key takes. The roster already loads for the page, so the suggestions cost no request, and a roster that fails to load costs the admin the hints and nothing else. Suggestions stay suggestions: a key no node reports yet still commits as typed, which is how an admin writes a rule before labelling the nodes for it. Assisted-by: Claude:claude-opus-5 golangci-lint eslint playwright Signed-off-by: Ettore Di Giacinto * fix(distributed): size model fit against the cluster, not the frontend The models page asked the frontend how much memory a model may occupy. In distributed mode the frontend is usually a GPU-less pod while every model runs on a worker, so a fleet of GPU nodes was told it could only run the smallest CPU build. The variant picker's fits flag and its auto-selection came from the same place, as did the hardware recommendations. The registry now reports the largest single healthy backend node. The largest node, not the fleet total: a model loads into one node, so four 16GB workers are not a home for a 40GB model. An operator-set VRAM budget caps a node's contribution, because the scheduler refuses a load above that ceiling anyway, and a GPU node beats a CPU node holding more system RAM. GET /api/resources and GET /api/models carry this as an additional cluster object. Their aggregate and ram fields keep reporting the frontend's own hardware, which is what the resource monitor shows. Variant selection judges backends against the union of the capabilities present in the cluster, the way backend discovery already did. Every path degrades to the local host: no cluster object in single-node mode, and none when the registry cannot be read, so a hiccup narrows the answer back to single-node behaviour rather than marking the whole catalog too large. The verdicts now name the node they belong to, since a model fits somewhere or nowhere. Assisted-by: Claude:claude-opus-5 golangci-lint eslint playwright Signed-off-by: Ettore Di Giacinto --------- Signed-off-by: Ettore Di Giacinto Co-authored-by: Ettore Di Giacinto --- core/gallery/cluster_env_test.go | 94 ++++++++++ core/gallery/models.go | 70 ++++++++ core/http/endpoints/localai/backend.go | 6 +- core/http/endpoints/localai/finetune.go | 2 +- core/http/endpoints/localai/quantization.go | 2 +- .../react-ui/e2e/models-cluster-fit.spec.js | 120 +++++++++++++ core/http/react-ui/e2e/scheduling.spec.js | 138 ++++++++------ core/http/react-ui/inline-style-baseline.txt | 2 +- .../react-ui/public/locales/de/models.json | 5 +- .../react-ui/public/locales/en/models.json | 5 +- .../react-ui/public/locales/es/models.json | 5 +- .../react-ui/public/locales/id/models.json | 5 +- .../react-ui/public/locales/it/models.json | 5 +- .../react-ui/public/locales/ko/models.json | 5 +- .../react-ui/public/locales/pt-BR/models.json | 5 +- .../react-ui/public/locales/zh-CN/models.json | 5 +- core/http/react-ui/src/App.css | 156 ++++++---------- .../src/components/nodes/KeyValueChips.jsx | 161 +++++++++++++---- .../src/hooks/useRecommendedModels.js | 13 +- core/http/react-ui/src/pages/Models.jsx | 36 ++-- core/http/react-ui/src/pages/Scheduling.jsx | 149 +++------------ core/http/react-ui/src/utils/modelBudget.js | 41 +++++ .../react-ui/src/utils/modelBudget.test.js | 56 ++++++ .../src/utils/nodeLabelSuggestions.js | 52 ++++++ .../src/utils/nodeLabelSuggestions.test.js | 57 ++++++ core/http/routes/cluster_memory.go | 99 ++++++++++ .../routes/cluster_memory_internal_test.go | 100 +++++++++++ core/http/routes/ui_api.go | 30 +++- core/services/nodes/cluster_memory.go | 93 ++++++++++ .../nodes/registry_clustermemory_test.go | 169 ++++++++++++++++++ docs/content/features/distributed-mode.md | 43 +++++ 31 files changed, 1380 insertions(+), 349 deletions(-) create mode 100644 core/gallery/cluster_env_test.go create mode 100644 core/http/react-ui/e2e/models-cluster-fit.spec.js create mode 100644 core/http/react-ui/src/utils/modelBudget.js create mode 100644 core/http/react-ui/src/utils/modelBudget.test.js create mode 100644 core/http/react-ui/src/utils/nodeLabelSuggestions.js create mode 100644 core/http/react-ui/src/utils/nodeLabelSuggestions.test.js create mode 100644 core/http/routes/cluster_memory.go create mode 100644 core/http/routes/cluster_memory_internal_test.go create mode 100644 core/services/nodes/cluster_memory.go create mode 100644 core/services/nodes/registry_clustermemory_test.go diff --git a/core/gallery/cluster_env_test.go b/core/gallery/cluster_env_test.go new file mode 100644 index 000000000000..4a411277eb61 --- /dev/null +++ b/core/gallery/cluster_env_test.go @@ -0,0 +1,94 @@ +package gallery_test + +import ( + "context" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/mudler/LocalAI/core/gallery" + "github.com/mudler/LocalAI/pkg/system" +) + +// On a distributed controller the GPUs live on the workers, so a variant +// picker sized against the controller tells admins a cluster of A100s can only +// run the smallest CPU build. +var _ = Describe("ClusterResolveEnv", func() { + gib := func(n uint64) uint64 { return n * 1024 * 1024 * 1024 } + + // The controller as Argus actually runs it: no GPU at all. + var controller *system.SystemState + + BeforeEach(func() { + controller = system.NewCapabilityState("default") + }) + + It("sizes models against the cluster reading rather than the controller", func() { + env := gallery.ClusterResolveEnv(context.Background(), controller, gib(80), []string{"nvidia-cuda-13"}) + + Expect(env.AvailableMemory).To(Equal(gib(80))) + }) + + It("accepts a CUDA backend that only the workers can run", func() { + env := gallery.ClusterResolveEnv(context.Background(), controller, gib(80), []string{"nvidia-cuda-13"}) + + Expect(env.BackendCompatible).ToNot(BeNil()) + // A name carrying the cuda token is what the controller rejects today; + // a bare engine name like "vllm" passes on any host and would prove + // nothing about the union. + Expect(env.BackendCompatible("cuda-13-vllm")).To(BeTrue()) + Expect(env.BackendCompatible("llama-cpp")).To(BeTrue()) + }) + + // The union must stay a filter, not an open door: a Linux NVIDIA fleet + // still cannot run an Apple-only build. + It("still rejects a backend no node in the cluster can run", func() { + env := gallery.ClusterResolveEnv(context.Background(), controller, gib(80), []string{"nvidia-cuda-13"}) + + Expect(env.BackendCompatible("mlx")).To(BeFalse()) + }) + + It("accepts a backend that any one node in a mixed fleet can run", func() { + env := gallery.ClusterResolveEnv(context.Background(), controller, gib(80), []string{"nvidia-cuda-13", "metal"}) + + Expect(env.BackendCompatible("mlx")).To(BeTrue()) + Expect(env.BackendCompatible("cuda-13-vllm")).To(BeTrue()) + }) + + // Ranking has to follow the hardware too, or a cluster of NVIDIA workers + // gets offered the GGUF build over the vLLM one it should prefer. + It("ranks engines by the workers' hardware, not the controller's", func() { + env := gallery.ClusterResolveEnv(context.Background(), controller, gib(80), []string{"nvidia-cuda-13"}) + + Expect(env.EnginePreference).To(Equal(system.NewCapabilityState("nvidia-cuda-13").EnginePreferenceTokens())) + }) + + // Every degradation path lands here, so it must be indistinguishable from + // the single-node behavior that shipped before any of this existed. + It("falls back to the host description when the cluster reports nothing", func() { + host := gallery.HostResolveEnv(context.Background(), controller) + env := gallery.ClusterResolveEnv(context.Background(), controller, 0, nil) + + Expect(env.AvailableMemory).To(Equal(host.AvailableMemory)) + Expect(env.EnginePreference).To(Equal(host.EnginePreference)) + Expect(env.BackendCompatible("cuda-13-vllm")).To(Equal(host.BackendCompatible("cuda-13-vllm"))) + Expect(env.BackendCompatible("mlx")).To(Equal(host.BackendCompatible("mlx"))) + }) + + // A cluster that reports capabilities but no usable memory reading should + // still gain the hardware view; only the size question falls back. + It("keeps the host memory when only the memory reading is missing", func() { + host := gallery.HostResolveEnv(context.Background(), controller) + env := gallery.ClusterResolveEnv(context.Background(), controller, 0, []string{"nvidia-cuda-13"}) + + Expect(env.AvailableMemory).To(Equal(host.AvailableMemory)) + Expect(env.BackendCompatible("cuda-13-vllm")).To(BeTrue()) + }) + + It("keeps the probe wired so variant sizes are still measured", func() { + env := gallery.ClusterResolveEnv(context.Background(), controller, gib(80), []string{"nvidia-cuda-13"}) + + Expect(env.ProbeMemory).ToNot(BeNil()) + Expect(env.ServingFeaturePreference).To(Equal(system.ServingFeaturePreferenceTokens())) + }) +}) diff --git a/core/gallery/models.go b/core/gallery/models.go index f3184648b15e..7664b6e8c652 100644 --- a/core/gallery/models.go +++ b/core/gallery/models.go @@ -938,3 +938,73 @@ func SafetyScanGalleryModel(galleryModel *GalleryModel) error { } return nil } + +// ClusterResolveEnv describes a CLUSTER to variant selection, where +// HostResolveEnv describes one machine. +// +// It exists because a distributed controller is the wrong machine to ask. The +// controller is typically a GPU-less pod while every model actually runs on a +// worker, so a picker sized against it reports that a fleet of A100s can only +// run the smallest CPU build, and auto-selection then installs exactly that. +// +// availableMemory is the largest single healthy node's budget, and capabilities +// are the capability strings present in the cluster. Either may be empty: a +// zero memory reading keeps the host's own figure and an empty capability list +// keeps the host's own hardware verdict, so every degradation path lands back +// on the single-node behavior rather than on a cluster described as having +// nothing. +func ClusterResolveEnv(ctx context.Context, systemState *system.SystemState, availableMemory uint64, capabilities []string) ResolveEnv { + env := HostResolveEnv(ctx, systemState) + + if availableMemory > 0 { + env.AvailableMemory = availableMemory + } + if len(capabilities) == 0 { + return env + } + + // One state pinned per capability, mirroring AvailableBackendsForCapabilities: + // the controller's own detection must not leak into a worker's verdict, and + // a forced capability on the controller image must not either. + nodeStates := make([]*system.SystemState, 0, len(capabilities)) + for _, capability := range capabilities { + nodeStates = append(nodeStates, system.NewCapabilityState(capability, + system.WithBackendPath(systemState.Backend.BackendsPath))) + } + + hostCompatible := env.BackendCompatible + // A union, because a variant only has to run SOMEWHERE. The controller + // stays in the union so a cluster whose workers all went offline still + // describes itself the way it did before distributed mode existed. + env.BackendCompatible = func(backend string) bool { + if hostCompatible != nil && hostCompatible(backend) { + return true + } + for _, nodeState := range nodeStates { + if nodeState.IsBackendCompatible(backend, "") { + return true + } + } + return false + } + + // Ranking follows the same hardware as the filter. Left on the controller's + // tokens, an NVIDIA fleet would be offered the GGUF build over the vLLM one + // even though nothing filtered the vLLM build out. + seen := make(map[string]struct{}) + preference := make([]string, 0, len(nodeStates)) + for _, nodeState := range nodeStates { + for _, token := range nodeState.EnginePreferenceTokens() { + if _, dup := seen[token]; dup { + continue + } + seen[token] = struct{}{} + preference = append(preference, token) + } + } + if len(preference) > 0 { + env.EnginePreference = preference + } + + return env +} diff --git a/core/http/endpoints/localai/backend.go b/core/http/endpoints/localai/backend.go index 4083333c9db8..d8a5612a18e3 100644 --- a/core/http/endpoints/localai/backend.go +++ b/core/http/endpoints/localai/backend.go @@ -356,7 +356,7 @@ func (mgs *BackendEndpointService) UpgradeBackendEndpoint() echo.HandlerFunc { // local system state is the only thing worth filtering against. type ClusterCapabilityProvider func(ctx context.Context) ([]string, error) -// resolveClusterCapabilities reads the capabilities present in the cluster, +// ResolveClusterCapabilities reads the capabilities present in the cluster, // degrading to the local-only listing on error. // // Every capability-filtered discovery endpoint shares this: on a distributed @@ -364,7 +364,7 @@ type ClusterCapabilityProvider func(ctx context.Context) ([]string, error) // (usually GPU-less) host hides GPU-only backends the cluster can actually // run. A registry hiccup must never blank the catalog, so a failure falls back // to the pre-existing local-only behavior rather than erroring the request. -func resolveClusterCapabilities(ctx context.Context, provider ClusterCapabilityProvider) []string { +func ResolveClusterCapabilities(ctx context.Context, provider ClusterCapabilityProvider) []string { if provider == nil { return nil } @@ -423,7 +423,7 @@ func installedInCluster(backend *gallery.GalleryBackend, clusterInstalled map[st // @Router /backends/available [get] func (mgs *BackendEndpointService) ListAvailableBackendsEndpoint(systemState *system.SystemState, clusterCapabilities ClusterCapabilityProvider, clusterInstalled ClusterInstalledProvider) echo.HandlerFunc { return func(c echo.Context) error { - capabilities := resolveClusterCapabilities(c.Request().Context(), clusterCapabilities) + capabilities := ResolveClusterCapabilities(c.Request().Context(), clusterCapabilities) backends, err := gallery.AvailableBackendsForCapabilities(mgs.galleries, systemState, capabilities) if err != nil { diff --git a/core/http/endpoints/localai/finetune.go b/core/http/endpoints/localai/finetune.go index 4948b65fcecb..2ddf1bcff3de 100644 --- a/core/http/endpoints/localai/finetune.go +++ b/core/http/endpoints/localai/finetune.go @@ -276,7 +276,7 @@ func DownloadExportedModelEndpoint(ftService *finetune.FineTuneService) echo.Han // ListFineTuneBackendsEndpoint returns installed backends tagged with "fine-tuning". func ListFineTuneBackendsEndpoint(appConfig *config.ApplicationConfig, clusterCapabilities ClusterCapabilityProvider, clusterInstalled ClusterInstalledProvider) echo.HandlerFunc { return func(c echo.Context) error { - capabilities := resolveClusterCapabilities(c.Request().Context(), clusterCapabilities) + capabilities := ResolveClusterCapabilities(c.Request().Context(), clusterCapabilities) installed := resolveClusterInstalled(c.Request().Context(), clusterInstalled) backends, err := gallery.AvailableBackendsForCapabilities(appConfig.BackendGalleries, appConfig.SystemState, capabilities) if err != nil { diff --git a/core/http/endpoints/localai/quantization.go b/core/http/endpoints/localai/quantization.go index 175bf974081d..ae6c9173985e 100644 --- a/core/http/endpoints/localai/quantization.go +++ b/core/http/endpoints/localai/quantization.go @@ -195,7 +195,7 @@ func DownloadQuantizedModelEndpoint(qService *quantization.QuantizationService) // ListQuantizationBackendsEndpoint returns installed backends tagged with "quantization". func ListQuantizationBackendsEndpoint(appConfig *config.ApplicationConfig, clusterCapabilities ClusterCapabilityProvider, clusterInstalled ClusterInstalledProvider) echo.HandlerFunc { return func(c echo.Context) error { - capabilities := resolveClusterCapabilities(c.Request().Context(), clusterCapabilities) + capabilities := ResolveClusterCapabilities(c.Request().Context(), clusterCapabilities) installed := resolveClusterInstalled(c.Request().Context(), clusterInstalled) backends, err := gallery.AvailableBackendsForCapabilities(appConfig.BackendGalleries, appConfig.SystemState, capabilities) if err != nil { diff --git a/core/http/react-ui/e2e/models-cluster-fit.spec.js b/core/http/react-ui/e2e/models-cluster-fit.spec.js new file mode 100644 index 000000000000..9c8916ca42f7 --- /dev/null +++ b/core/http/react-ui/e2e/models-cluster-fit.spec.js @@ -0,0 +1,120 @@ +import { test, expect } from "./coverage-fixtures.js"; + +// On a distributed controller the models run on the workers, so every "will +// this fit" answer on this page is about their hardware. The controller is +// usually a GPU-less pod: sized against it, a cluster of A100s is told it can +// only run the smallest CPU build. + +const GB = 1024 * 1024 * 1024; + +const MODELS = [ + { name: "big-gpu-model", description: "Needs a real GPU", backend: "vllm", installed: false, tags: ["chat"] }, +]; + +// 40GB: far past the controller's 8GB of RAM, comfortably inside one 80GB card. +const ESTIMATES = { + "big-gpu-model": { + sizeBytes: 40 * GB, + sizeDisplay: "40.0 GB", + estimates: { 8192: { vramBytes: 40 * GB, vramDisplay: "40.0 GB" } }, + }, +}; + +// The controller as Argus actually runs it: 8GB of system RAM, no GPU. +const CONTROLLER_ONLY = { + type: "ram", + available: true, + gpus: [], + aggregate: { total_memory: 8 * GB, used_memory: 2 * GB, free_memory: 6 * GB, gpu_count: 0 }, +}; + +const WITH_CLUSTER = { + ...CONTROLLER_ONLY, + cluster: { + enabled: true, + node_id: "n-1", + node_name: "dgx-01", + total_memory: 80 * GB, + is_gpu: true, + node_count: 4, + }, +}; + +async function mockModels(page, resources) { + await page.route("**/api/models*", (route) => + route.fulfill({ + contentType: "application/json", + body: JSON.stringify({ + models: MODELS, + allBackends: ["vllm"], + allTags: ["chat"], + availableModels: MODELS.length, + installedModels: 3, + totalPages: 1, + currentPage: 1, + }), + }), + ); + await page.route("**/api/models/estimate/*", (route) => { + const name = decodeURIComponent(new URL(route.request().url()).pathname.split("/").pop()); + return route.fulfill({ contentType: "application/json", body: JSON.stringify(ESTIMATES[name] || {}) }); + }); + await page.route("**/api/resources", (route) => + route.fulfill({ contentType: "application/json", body: JSON.stringify(resources) }), + ); +} + +const railItems = (page) => page.locator('[data-testid="discover-rail-item"]'); +const railItem = (page, name) => page.locator(`[data-entity="${name}"]`); +const railReady = (page) => expect(railItems(page).first()).toBeVisible({ timeout: 20_000 }); +const PANE = '[data-testid="discover-pane"]'; + +test.describe("Models gallery - cluster-aware fit", () => { + test("a model that only a worker can hold is not called too large", async ({ page }) => { + await mockModels(page, WITH_CLUSTER); + await page.goto("/app/models"); + + await railReady(page); + + // The whole defect in one assertion: 40GB against a 4-node cluster whose + // largest card holds 80GB. + await expect(railItem(page, "big-gpu-model")).toContainText("fits", { timeout: 20_000 }); + await expect(railItem(page, "big-gpu-model")).not.toContainText("too large"); + }); + + test("the fit verdict names the node it belongs to", async ({ page }) => { + await mockModels(page, WITH_CLUSTER); + await page.goto("/app/models"); + + await railReady(page); + await railItem(page, "big-gpu-model").click(); + // Wait for the detail itself: until it renders, the pane still holds the + // zero-state hero, which names the node for its own reasons. + await expect(page.locator(PANE).getByText("40.0 GB")).toBeVisible({ timeout: 20_000 }); + + // The headroom this model has is headroom SOMEWHERE, and the stat says + // where rather than leaving it to read as this machine's. + await expect(page.locator(PANE)).toContainText(/headroom on dgx-01/i); + }); + + test("the host summary describes the cluster, not the controller", async ({ page }) => { + await mockModels(page, WITH_CLUSTER); + await page.goto("/app/models"); + + await railReady(page); + // 80 GB is the cluster's best node; 8 GB is this pod's own RAM and must + // not be what the page advertises. + await expect(page.locator(".zero-pane__title")).toContainText("80 GB"); + await expect(page.locator(".zero-pane__title")).not.toContainText("8.00 GB"); + }); + + // Single-node behavior is the fallback every degradation path lands on, so + // it has to stay exactly as it was. + test("without a cluster the verdict is still the local host's", async ({ page }) => { + await mockModels(page, CONTROLLER_ONLY); + await page.goto("/app/models"); + + await railReady(page); + await expect(railItem(page, "big-gpu-model")).toContainText("too large", { timeout: 20_000 }); + }); +}); diff --git a/core/http/react-ui/e2e/scheduling.spec.js b/core/http/react-ui/e2e/scheduling.spec.js index 4d11d8b2603d..4ce06cb4a7ee 100644 --- a/core/http/react-ui/e2e/scheduling.spec.js +++ b/core/http/react-ui/e2e/scheduling.spec.js @@ -36,35 +36,80 @@ async function mockScheduling(page, { rules = [rule], nodeList = nodes } = {}) { } test.describe('Scheduling page', () => { - test('groups node labels, collapses the reference, filters forgivingly, and expands results', async ({ page }) => { + // Node labels are only ever needed while writing a rule's node selector, so + // they live in that field rather than in a card standing open above the + // rules whether or not anyone is writing one. + test('keeps no standing label browser on the page', async ({ page }) => { await mockScheduling(page) await page.goto('/app/scheduling') + await expect(page.getByText('llama-3.3')).toBeVisible() + + await expect(page.getByTestId('node-label-reference')).toHaveCount(0) + await expect(page.getByRole('button', { name: /node labels/i })).toHaveCount(0) + await expect(page.locator('.scheduling-node-card')).toHaveCount(0) + // Falcon GPU is a node name, and nothing on this page has a reason to + // enumerate node names until a selector is being filled. + await expect(page.getByText('Falcon GPU')).toHaveCount(0) + }) + + test('suggests the cluster\'s own label keys and values as the selector is typed', async ({ page }) => { + await mockScheduling(page) + await page.goto('/app/scheduling') + await page.getByRole('button', { name: 'Add Scheduling Rule' }).click() - const reference = page.getByTestId('node-label-reference') - await expect(reference.getByText('Falcon GPU')).toBeVisible() - await expect(reference.getByText('No labels')).toBeVisible() - await expect(reference.locator('.scheduling-node-card')).toHaveCount(5) - await expect(reference.getByText('5 of 27 nodes')).toBeVisible() - - const toggle = page.getByRole('button', { name: /node labels/i }) - await expect(toggle).toHaveAttribute('aria-expanded', 'true') - await toggle.click() - await expect(toggle).toHaveAttribute('aria-expanded', 'false') - await expect(reference.getByRole('searchbox')).toBeHidden() - await toggle.click() - - await reference.getByRole('searchbox').fill('GPU.VENDOR=nvi') - await expect(reference.locator('.scheduling-node-card')).toHaveCount(1) - await expect(reference.getByText('Falcon GPU')).toBeVisible() - - await reference.getByRole('searchbox').fill('flcn') - await expect(reference.locator('.scheduling-node-card')).toHaveCount(1) - await expect(reference.getByText('Falcon GPU')).toBeVisible() - - await reference.getByRole('searchbox').fill('') - await reference.getByRole('button', { name: 'Show 20 more nodes' }).click() - await expect(reference.locator('.scheduling-node-card')).toHaveCount(25) - await expect(reference.getByText('25 of 27 nodes')).toBeVisible() + const keyInput = page.getByRole('combobox', { name: 'Selector key' }) + await keyInput.click() + const suggestions = page.getByTestId('label-suggestions') + // Every key the cluster reports, before a single character is typed. + await expect(suggestions.getByRole('option', { name: 'gpu.vendor' })).toBeVisible() + await expect(suggestions.getByRole('option', { name: 'zone' })).toBeVisible() + + await keyInput.fill('vend') + await expect(suggestions.getByRole('option')).toHaveCount(1) + await suggestions.getByRole('option', { name: 'gpu.vendor' }).click() + await expect(keyInput).toHaveValue('gpu.vendor') + + // Values are scoped to the key being filled, so a selector cannot be built + // out of a pair no node matches. + const valueInput = page.getByRole('combobox', { name: 'Selector value' }) + await valueInput.click() + await expect(suggestions.getByRole('option', { name: 'NVIDIA' })).toBeVisible() + await expect(suggestions.getByRole('option', { name: 'amd' })).toBeVisible() + await expect(suggestions.getByRole('option', { name: 'east' })).toHaveCount(0) + + await valueInput.fill('nvi') + await suggestions.getByRole('option', { name: 'NVIDIA' }).click() + await expect(valueInput).toHaveValue('NVIDIA') + }) + + test('picks a suggestion from the keyboard', async ({ page }) => { + await mockScheduling(page) + await page.goto('/app/scheduling') + await page.getByRole('button', { name: 'Add Scheduling Rule' }).click() + + const keyInput = page.getByRole('combobox', { name: 'Selector key' }) + await keyInput.fill('zon') + await keyInput.press('ArrowDown') + await keyInput.press('Enter') + await expect(keyInput).toHaveValue('zone') + // Enter picked the suggestion rather than committing the chip, so the + // half-built pair is still in the inputs. + await expect(page.getByLabel('Node selector').getByText('zone=', { exact: true })).toHaveCount(0) + }) + + // The cluster's vocabulary is a suggestion, never a constraint: an admin + // labelling nodes for a rule they are about to write must still be able to + // type a key no node reports yet. + test('still accepts a label the cluster has never reported', async ({ page }) => { + await mockScheduling(page) + await page.goto('/app/scheduling') + await page.getByRole('button', { name: 'Add Scheduling Rule' }).click() + + await page.getByRole('combobox', { name: 'Selector key' }).fill('tenant') + await page.getByRole('combobox', { name: 'Selector value' }).fill('acme') + await page.getByRole('button', { name: 'Add selector' }).click() + + await expect(page.getByLabel('Node selector').getByText('tenant=acme', { exact: true })).toBeVisible() }) test('edits all fields with a locked model and preserves values after a failed save', async ({ page }) => { @@ -113,42 +158,29 @@ test.describe('Scheduling page', () => { await expect(page.getByRole('combobox', { name: '' }).first()).toBeEnabled() }) - test('shows node loading, empty, no-match, and retry states independently from rules', async ({ page }) => { - let attempts = 0 + // The roster feeds suggestions and nothing else now, so failing to load it + // must cost the admin nothing but the hints. + test('leaves the selector fully usable when the node roster fails to load', async ({ page }) => { await page.route('**/api/nodes/scheduling', route => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([rule]) })) - await page.route('**/api/nodes', async route => { - attempts++ - if (attempts === 1) { - await new Promise(resolve => setTimeout(resolve, 250)) - await route.fulfill({ status: 500, body: 'failed' }) - } else { - await route.fulfill({ status: 200, contentType: 'application/json', body: '[]' }) - } - }) + await page.route('**/api/nodes', route => route.fulfill({ status: 500, body: 'failed' })) await page.goto('/app/scheduling') - await expect(page.getByText('Loading node labels…')).toBeVisible() + + // The rules still render: the roster is not on their path. await expect(page.getByText('llama-3.3')).toBeVisible() - await expect(page.getByText('Could not load node labels.')).toBeVisible() - await page.getByRole('button', { name: 'Retry loading node labels' }).click() - await expect(page.getByText('No nodes are available yet.')).toBeVisible() - - await page.unroute('**/api/nodes') - await page.route('**/api/nodes', route => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(nodes) })) - await page.reload() - await page.getByRole('searchbox', { name: 'Search node labels' }).fill('not-a-real-label') - await expect(page.getByText('No nodes match your search.')).toBeVisible() + + await page.getByRole('button', { name: 'Add Scheduling Rule' }).click() + await page.getByRole('combobox', { name: 'Selector key' }).fill('gpu.vendor') + await page.getByRole('combobox', { name: 'Selector value' }).fill('nvidia') + await page.getByRole('button', { name: 'Add selector' }).click() + + await expect(page.getByLabel('Node selector').getByText('gpu.vendor=nvidia', { exact: true })).toBeVisible() }) - test('uses one node column and accessible rule actions on a narrow viewport', async ({ page }) => { + test('keeps rule actions reachable on a narrow viewport', async ({ page }) => { await page.setViewportSize({ width: 390, height: 844 }) await mockScheduling(page, { nodeList: nodes.slice(0, 2) }) await page.goto('/app/scheduling') - const cards = page.locator('.scheduling-node-card') - const first = await cards.nth(0).boundingBox() - const second = await cards.nth(1).boundingBox() - expect(second.y).toBeGreaterThan(first.y + first.height - 1) - const actions = page.locator('.scheduling-rule-actions') await expect(actions.getByRole('button', { name: 'Edit llama-3.3' })).toBeVisible() await expect(actions.getByRole('button', { name: 'Delete llama-3.3' })).toBeVisible() diff --git a/core/http/react-ui/inline-style-baseline.txt b/core/http/react-ui/inline-style-baseline.txt index 08f851b6ef58..a08796291d18 100644 --- a/core/http/react-ui/inline-style-baseline.txt +++ b/core/http/react-ui/inline-style-baseline.txt @@ -1 +1 @@ -519 +514 diff --git a/core/http/react-ui/public/locales/de/models.json b/core/http/react-ui/public/locales/de/models.json index 21e29f782a5c..779e0e7f6131 100644 --- a/core/http/react-ui/public/locales/de/models.json +++ b/core/http/react-ui/public/locales/de/models.json @@ -142,7 +142,8 @@ "sha256": "SHA256", "backToAll": "Alle Modelle", "vramAt": "VRAM bei {{context}}", - "headroom": "Spielraum" + "headroom": "Spielraum", + "headroomOn": "Spielraum auf {{node}}" }, "empty": { "title": "Keine Modelle gefunden", @@ -216,6 +217,8 @@ "browsing": "Durchsuchen", "pickHint": "Wähle ein Modell, um die Details zu sehen.", "heroWithRam": "{{ram}} Systemspeicher, {{count}} Modelle in der Galerie.", + "heroWithCluster": "{{vram}} auf {{node}}, dem größten von {{nodes}} Knoten, {{count}} Modelle in der Galerie.", + "heroWithNode": "{{vram}} auf {{node}}, {{count}} Modelle in der Galerie.", "byUseCase": "Oder mit einem Anwendungsfall starten", "pickText": "Chat, Reasoning, Embeddings", "pickVision": "Bilder und Dokumente lesen", diff --git a/core/http/react-ui/public/locales/en/models.json b/core/http/react-ui/public/locales/en/models.json index fdc2e397f932..ebadd3b3d412 100644 --- a/core/http/react-ui/public/locales/en/models.json +++ b/core/http/react-ui/public/locales/en/models.json @@ -152,7 +152,8 @@ "sha256": "SHA256", "backToAll": "All models", "vramAt": "VRAM at {{context}}", - "headroom": "Headroom" + "headroom": "Headroom", + "headroomOn": "Headroom on {{node}}" }, "empty": { "title": "No models found", @@ -232,6 +233,8 @@ "browsing": "Browsing", "pickHint": "Select a model to see its detail.", "heroWithRam": "{{ram}} of system memory, {{count}} models in the gallery.", + "heroWithCluster": "{{vram}} on {{node}}, the largest of {{nodes}} nodes, {{count}} models in the gallery.", + "heroWithNode": "{{vram}} on {{node}}, {{count}} models in the gallery.", "byUseCase": "Or start with a use case", "pickText": "Chat, reasoning, embeddings", "pickVision": "Read images and documents", diff --git a/core/http/react-ui/public/locales/es/models.json b/core/http/react-ui/public/locales/es/models.json index 3d5cdd8df36a..d833fba558e2 100644 --- a/core/http/react-ui/public/locales/es/models.json +++ b/core/http/react-ui/public/locales/es/models.json @@ -142,7 +142,8 @@ "sha256": "SHA256", "backToAll": "Todos los modelos", "vramAt": "VRAM a {{context}}", - "headroom": "Margen" + "headroom": "Margen", + "headroomOn": "Margen en {{node}}" }, "empty": { "title": "No se encontraron modelos", @@ -216,6 +217,8 @@ "browsing": "Explorando", "pickHint": "Selecciona un modelo para ver su detalle.", "heroWithRam": "{{ram}} de memoria del sistema, {{count}} modelos en la galería.", + "heroWithCluster": "{{vram}} en {{node}}, el mayor de {{nodes}} nodos, {{count}} modelos en la galería.", + "heroWithNode": "{{vram}} en {{node}}, {{count}} modelos en la galería.", "byUseCase": "O empieza por un caso de uso", "pickText": "Chat, razonamiento, embeddings", "pickVision": "Leer imágenes y documentos", diff --git a/core/http/react-ui/public/locales/id/models.json b/core/http/react-ui/public/locales/id/models.json index 27a42af64022..9088647b91ac 100644 --- a/core/http/react-ui/public/locales/id/models.json +++ b/core/http/react-ui/public/locales/id/models.json @@ -149,7 +149,8 @@ "sha256": "SHA256", "backToAll": "Semua model", "vramAt": "VRAM pada {{context}}", - "headroom": "Sisa ruang" + "headroom": "Sisa ruang", + "headroomOn": "Sisa ruang di {{node}}" }, "empty": { "title": "Model tidak ditemukan", @@ -229,6 +230,8 @@ "browsing": "Menjelajah", "pickHint": "Pilih model untuk melihat detailnya.", "heroWithRam": "Memori sistem {{ram}}, {{count}} model di galeri.", + "heroWithCluster": "{{vram}} di {{node}}, terbesar dari {{nodes}} node, {{count}} model di galeri.", + "heroWithNode": "{{vram}} di {{node}}, {{count}} model di galeri.", "byUseCase": "Atau mulai dari kasus penggunaan", "pickText": "Obrolan, penalaran, embedding", "pickVision": "Membaca gambar dan dokumen", diff --git a/core/http/react-ui/public/locales/it/models.json b/core/http/react-ui/public/locales/it/models.json index 80bd16284ca2..cbc06d22c162 100644 --- a/core/http/react-ui/public/locales/it/models.json +++ b/core/http/react-ui/public/locales/it/models.json @@ -142,7 +142,8 @@ "sha256": "SHA256", "backToAll": "Tutti i modelli", "vramAt": "VRAM a {{context}}", - "headroom": "Margine" + "headroom": "Margine", + "headroomOn": "Margine su {{node}}" }, "empty": { "title": "Nessun modello trovato", @@ -216,6 +217,8 @@ "browsing": "Esplorazione", "pickHint": "Seleziona un modello per vederne i dettagli.", "heroWithRam": "{{ram}} di memoria di sistema, {{count}} modelli nella galleria.", + "heroWithCluster": "{{vram}} su {{node}}, il più grande di {{nodes}} nodi, {{count}} modelli nella galleria.", + "heroWithNode": "{{vram}} su {{node}}, {{count}} modelli nella galleria.", "byUseCase": "Oppure parti da un caso d’uso", "pickText": "Chat, ragionamento, embedding", "pickVision": "Leggere immagini e documenti", diff --git a/core/http/react-ui/public/locales/ko/models.json b/core/http/react-ui/public/locales/ko/models.json index b4f1a46565af..8ed38bf0f9b2 100644 --- a/core/http/react-ui/public/locales/ko/models.json +++ b/core/http/react-ui/public/locales/ko/models.json @@ -148,7 +148,8 @@ "sha256": "SHA256", "backToAll": "모든 모델", "vramAt": "{{context}}에서의 VRAM", - "headroom": "여유 공간" + "headroom": "여유 공간", + "headroomOn": "{{node}}의 여유 공간" }, "empty": { "title": "모델을 찾을 수 없습니다", @@ -200,6 +201,8 @@ "browsing": "둘러보기", "pickHint": "모델을 선택하면 상세 정보가 표시됩니다.", "heroWithRam": "시스템 메모리 {{ram}}, 갤러리에 모델 {{count}}개.", + "heroWithCluster": "{{nodes}}개 노드 중 가장 큰 {{node}}에 {{vram}}, 갤러리에 모델 {{count}}개.", + "heroWithNode": "{{node}}에 {{vram}}, 갤러리에 모델 {{count}}개.", "byUseCase": "또는 용도로 시작하기", "pickText": "채팅, 추론, 임베딩", "pickVision": "이미지와 문서 읽기", diff --git a/core/http/react-ui/public/locales/pt-BR/models.json b/core/http/react-ui/public/locales/pt-BR/models.json index 9bcbdcffdbb9..c354e89ae227 100644 --- a/core/http/react-ui/public/locales/pt-BR/models.json +++ b/core/http/react-ui/public/locales/pt-BR/models.json @@ -152,7 +152,8 @@ "sha256": "SHA256", "backToAll": "Todos os modelos", "vramAt": "VRAM em {{context}}", - "headroom": "Margem de sobra" + "headroom": "Margem de sobra", + "headroomOn": "Folga em {{node}}" }, "empty": { "title": "Nenhum modelo encontrado", @@ -232,6 +233,8 @@ "browsing": "Explorando", "pickHint": "Selecione um modelo para ver seus detalhes.", "heroWithRam": "{{ram}} de memória do sistema, {{count}} modelos na galeria.", + "heroWithCluster": "{{vram}} em {{node}}, o maior de {{nodes}} nós, {{count}} modelos na galeria.", + "heroWithNode": "{{vram}} em {{node}}, {{count}} modelos na galeria.", "byUseCase": "Ou comece por um caso de uso", "pickText": "Chat, raciocínio, embeddings", "pickVision": "Leia imagens e documentos", diff --git a/core/http/react-ui/public/locales/zh-CN/models.json b/core/http/react-ui/public/locales/zh-CN/models.json index 181467b6a461..667009901047 100644 --- a/core/http/react-ui/public/locales/zh-CN/models.json +++ b/core/http/react-ui/public/locales/zh-CN/models.json @@ -142,7 +142,8 @@ "sha256": "SHA256", "backToAll": "全部模型", "vramAt": "{{context}} 时显存", - "headroom": "剩余显存" + "headroom": "剩余显存", + "headroomOn": "{{node}} 上的余量" }, "empty": { "title": "未找到模型", @@ -216,6 +217,8 @@ "browsing": "浏览中", "pickHint": "选择一个模型以查看详情。", "heroWithRam": "{{ram}} 系统内存,图库中有 {{count}} 个模型。", + "heroWithCluster": "{{node}} 上 {{vram}},为 {{nodes}} 个节点中最大,图库中有 {{count}} 个模型。", + "heroWithNode": "{{node}} 上 {{vram}},图库中有 {{count}} 个模型。", "byUseCase": "或从用途开始", "pickText": "对话、推理、向量", "pickVision": "读取图像与文档", diff --git a/core/http/react-ui/src/App.css b/core/http/react-ui/src/App.css index c7a2553db804..a5af8fbc63a0 100644 --- a/core/http/react-ui/src/App.css +++ b/core/http/react-ui/src/App.css @@ -2697,116 +2697,82 @@ select.input { cursor: not-allowed; } -.scheduling-node-reference { - margin-bottom: var(--spacing-md); - overflow: hidden; -} - -.scheduling-node-reference__toggle { - align-items: center; - background: transparent; - border: 0; - color: var(--color-text-primary); - cursor: pointer; +/* Key-value chip builder (node selectors, node labels) */ +.kvchips__chips { display: flex; - font: inherit; - font-weight: var(--font-weight-semibold); - justify-content: space-between; - padding: var(--spacing-md); - text-align: left; - width: 100%; -} - -.scheduling-node-reference__toggle:focus-visible { - outline: 2px solid var(--color-primary); - outline-offset: -2px; -} - -.scheduling-node-reference__content { - border-top: 1px solid var(--color-border-subtle); - padding: var(--spacing-md); -} - -.scheduling-node-reference__content > .text-note { - margin: 0 0 var(--spacing-sm); + flex-wrap: wrap; + gap: 4px; + margin-bottom: var(--spacing-xs); } -.scheduling-node-toolbar { +.kvchips__chip { align-items: center; - display: flex; - gap: var(--spacing-md); - margin-bottom: var(--spacing-md); -} - -.scheduling-node-toolbar .input { - flex: 1; -} - -.scheduling-node-toolbar .text-meta { - flex: none; -} - -.scheduling-node-grid { - display: grid; - gap: var(--spacing-sm); - grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); -} - -.scheduling-node-card { background: var(--color-bg-tertiary); border: 1px solid var(--color-border-subtle); - border-radius: var(--radius-md); - min-width: 0; - padding: var(--spacing-sm); -} - -.scheduling-node-card__header { - align-items: center; - display: flex; - gap: var(--spacing-sm); - justify-content: space-between; - margin-bottom: var(--spacing-xs); + border-radius: var(--radius-sm); + display: inline-flex; + font-family: var(--font-mono); + font-size: 0.75rem; + gap: 4px; + padding: 2px 8px; } -.scheduling-node-status { +.kvchips__chip-remove { + background: none; + border: none; color: var(--color-text-muted); - font-size: 0.75rem; - text-transform: capitalize; + cursor: pointer; + font-size: 0.625rem; + padding: 0; } -.scheduling-node-status--online, -.scheduling-node-status--ready, -.scheduling-node-status--healthy { - color: var(--color-success); +.kvchips__row { + align-items: stretch; + display: flex; + gap: var(--spacing-xs); + position: relative; } -.scheduling-node-labels { - display: flex; - flex-wrap: wrap; - gap: 4px; +.kvchips__add { + min-height: 36px; } -.scheduling-node-label { - border: 1px solid var(--color-border-subtle); +/* Anchored to the input row so the list covers what follows the field rather + than pushing the rest of the form down as the user types. */ +.kvchips__suggestions { + background: var(--color-bg-secondary); + border: 1px solid var(--color-border); border-radius: var(--radius-sm); - font-family: var(--font-mono); - font-size: 0.75rem; - overflow-wrap: anywhere; - padding: 2px 6px; + box-shadow: var(--shadow-md); + left: 0; + list-style: none; + margin: 4px 0 0; + max-height: 220px; + overflow-y: auto; + padding: 4px; + position: absolute; + right: 0; + top: 100%; + z-index: 20; } -.scheduling-node-message { - align-items: center; - color: var(--color-text-muted); - display: flex; - gap: var(--spacing-sm); - justify-content: center; - margin: var(--spacing-md) 0; - text-align: center; +.kvchips__suggestion { + background: none; + border: none; + border-radius: var(--radius-sm); + color: var(--color-text-primary); + cursor: pointer; + display: block; + font-family: var(--font-mono); + font-size: 0.8125rem; + padding: var(--spacing-xs) var(--spacing-sm); + text-align: left; + width: 100%; } -.scheduling-show-more { - margin-top: var(--spacing-md); +.kvchips__suggestion:hover, +.kvchips__suggestion--active { + background: var(--color-bg-tertiary); } .scheduling-rule-actions { @@ -2816,16 +2782,6 @@ select.input { } @media (max-width: 640px) { - .scheduling-node-grid { - grid-template-columns: minmax(0, 1fr); - } - - .scheduling-node-toolbar { - align-items: stretch; - flex-direction: column; - gap: var(--spacing-xs); - } - .scheduling-rule-actions { width: 100%; } diff --git a/core/http/react-ui/src/components/nodes/KeyValueChips.jsx b/core/http/react-ui/src/components/nodes/KeyValueChips.jsx index 8dcc926c2423..1d6baba45d29 100644 --- a/core/http/react-ui/src/components/nodes/KeyValueChips.jsx +++ b/core/http/react-ui/src/components/nodes/KeyValueChips.jsx @@ -1,4 +1,6 @@ -import { useState } from 'react' +import { useState, useRef, useEffect } from 'react' + +import { suggestKeys, suggestValues } from '../../utils/nodeLabelSuggestions' /** * Controlled chip-builder for { key: value } maps. Replaces the prior @@ -11,6 +13,14 @@ import { useState } from 'react' * labels editor). The component just renders chips and a key/value input * row. * + * With `suggestions` it also completes what the user types against the + * vocabulary the cluster actually uses. That is where label discovery lives on + * the scheduling page: labels only matter while a selector is being written, + * so browsing them belongs in the field rather than in a card standing open + * above the rules. The suggestions are never a constraint - a key no node + * reports yet still commits as typed, which is the workflow of writing a rule + * before labelling the nodes for it. + * * Props: * pairs - current map of key -> value * onAdd(k,v) - called when the user adds a pair (parent handles dedup @@ -18,45 +28,119 @@ import { useState } from 'react' * onRemove(k) - called when a chip's × is clicked * placeholderKey, placeholderValue - input hints * ariaLabel - accessible name for the section + * ariaLabelKey, ariaLabelValue - accessible names for the two inputs + * addLabel - accessible name for the commit button + * suggestions - label index from utils/nodeLabelSuggestions; omit for none */ -export default function KeyValueChips({ pairs, onAdd, onRemove, placeholderKey = 'key', placeholderValue = 'value', ariaLabel }) { +export default function KeyValueChips({ + pairs, onAdd, onRemove, + placeholderKey = 'key', placeholderValue = 'value', + ariaLabel, ariaLabelKey = 'Key', ariaLabelValue = 'Value', + addLabel = 'Add', suggestions, +}) { const [k, setK] = useState('') const [v, setV] = useState('') + // Which input owns the open list, and which of its options is armed for + // Enter. -1 means the user is typing free text and Enter should commit the + // pair rather than pick anything. + const [openField, setOpenField] = useState(null) + const [active, setActive] = useState(-1) + const rowRef = useRef(null) + + const entries = pairs ? Object.entries(pairs) : [] + + const options = !suggestions || !openField + ? [] + : openField === 'key' + ? suggestKeys(suggestions, k, entries.map(([key]) => key)) + : suggestValues(suggestions, k.trim(), v) + + // A click anywhere else is a dismissal. Without this the list survives the + // user moving on to the rest of the form and covers it. + useEffect(() => { + if (!openField) return undefined + const onDocumentPointerDown = (event) => { + if (!rowRef.current?.contains(event.target)) setOpenField(null) + } + document.addEventListener('mousedown', onDocumentPointerDown) + return () => document.removeEventListener('mousedown', onDocumentPointerDown) + }, [openField]) const add = () => { const key = k.trim() if (!key) return onAdd(key, v.trim()) setK(''); setV('') + setOpenField(null); setActive(-1) } - const onKeyDown = (e) => { - if (e.key === 'Enter') { e.preventDefault(); add() } + + const pick = (field, option) => { + if (field === 'key') setK(option) + else setV(option) + setOpenField(null) + setActive(-1) } - const entries = pairs ? Object.entries(pairs) : [] + const onKeyDown = (field) => (e) => { + const open = openField === field && options.length > 0 + if (e.key === 'ArrowDown' && open) { + e.preventDefault() + setActive(current => (current + 1) % options.length) + return + } + if (e.key === 'ArrowUp' && open) { + e.preventDefault() + setActive(current => (current <= 0 ? options.length - 1 : current - 1)) + return + } + if (e.key === 'Escape' && openField) { + e.preventDefault() + setOpenField(null) + setActive(-1) + return + } + if (e.key === 'Enter') { + e.preventDefault() + // Enter completes the suggestion the user armed, and commits the pair + // otherwise. Committing a half-typed key because a list happened to be + // open is the error this ordering avoids. + if (open && active >= 0) pick(field, options[active]) + else add() + } + } + + const listId = 'kvchips-suggestions' + const inputProps = (field, value, setValue, placeholder, label) => ({ + className: 'input flex-1', + type: 'text', + role: suggestions ? 'combobox' : undefined, + 'aria-expanded': suggestions ? openField === field : undefined, + 'aria-controls': suggestions && openField === field ? listId : undefined, + 'aria-autocomplete': suggestions ? 'list' : undefined, + 'aria-label': label, + placeholder, + value, + onChange: (e) => { + setValue(e.target.value) + if (suggestions) { setOpenField(field); setActive(-1) } + }, + onFocus: () => { if (suggestions) { setOpenField(field); setActive(-1) } }, + onKeyDown: onKeyDown(field), + }) + return (
{entries.length > 0 && ( -
+
{entries.map(([key, val]) => ( - + {key}={val} @@ -64,32 +148,35 @@ export default function KeyValueChips({ pairs, onAdd, onRemove, placeholderKey = ))}
)} -
- setK(e.target.value)} - onKeyDown={onKeyDown} - /> - setV(e.target.value)} - onKeyDown={onKeyDown} - /> +
+ + + {options.length > 0 && ( +
    + {options.map((option, index) => ( +
  • + +
  • + ))} +
+ )}
) diff --git a/core/http/react-ui/src/hooks/useRecommendedModels.js b/core/http/react-ui/src/hooks/useRecommendedModels.js index ca6090177c4a..c970c76250da 100644 --- a/core/http/react-ui/src/hooks/useRecommendedModels.js +++ b/core/http/react-ui/src/hooks/useRecommendedModels.js @@ -1,6 +1,7 @@ import { useState, useEffect } from 'react' import { modelsApi } from '../utils/api' import { useResources } from './useResources' +import { modelBudget } from '../utils/modelBudget' // Data-driven "recommended for your hardware" model picks. The gallery exposes // no popularity/download signal and the list response carries no size, so we: @@ -21,13 +22,21 @@ const DEFAULT_CTX = 4096 export const isNvfp4Name = (name) => /nvfp4/i.test(name || '') export function hasNvidiaGpu(resources) { + // A distributed controller has no GPUs of its own, so the question is + // whether any worker does. The registry reports the cluster's best node, + // and it is that node these picks have to run on. + if (resources?.cluster?.enabled) return !!resources.cluster.is_gpu return Array.isArray(resources?.gpus) && resources.gpus.some(g => (g?.vendor || '').toLowerCase() === 'nvidia') } export function recommendTier(resources) { - const isGpu = resources?.type === 'gpu' - const vram = resources?.aggregate?.total_memory || 0 + // Same reading the models page sizes against: the cluster's largest node in + // distributed mode, the local host otherwise. Ranked against the controller, + // a fleet of A100s was recommended the models a GPU-less pod could run. + const budget = modelBudget(resources) + const isGpu = budget.scope === 'cluster' ? budget.hasGpu : resources?.type === 'gpu' + const vram = budget.totalMemory if (!isGpu || vram <= 0) return { id: 'cpu', vram: 0 } if (vram < 8 * GB) return { id: 'gpu-small', vram } if (vram < 24 * GB) return { id: 'gpu-mid', vram } diff --git a/core/http/react-ui/src/pages/Models.jsx b/core/http/react-ui/src/pages/Models.jsx index 3018e22e3814..12b62f4cd835 100644 --- a/core/http/react-ui/src/pages/Models.jsx +++ b/core/http/react-ui/src/pages/Models.jsx @@ -7,6 +7,7 @@ import { safeHref } from '../utils/url' import { useDebouncedCallback } from '../hooks/useDebounce' import { useOperations } from '../hooks/useOperations' import { useResources } from '../hooks/useResources' +import { modelBudget } from '../utils/modelBudget' import SearchableSelect from '../components/SearchableSelect' import PageHeader from '../components/PageHeader' import GalleryLoader from '../components/GalleryLoader' @@ -208,12 +209,13 @@ export default function Models() { const [useCaseOpen, setUseCaseOpen] = useState(false) // Rail groups the user has folded away. const [collapsedGroups, setCollapsedGroups] = useState(() => new Set()) - // Total GPU memory for "fits" check - const totalGpuMemory = resources?.aggregate?.total_memory || 0 - // gpu_count is 0 and gpus is null on a CPU-only host, where total_memory is - // system RAM. The fits check has always used it either way; only the copy - // has to stop calling it VRAM. - const hasGpu = (resources?.aggregate?.gpu_count || 0) > 0 || (resources?.gpus?.length || 0) > 0 + // What every "will it fit" verdict on this page is measured against. In + // distributed mode that is the cluster's largest node rather than the + // controller serving the page, which is usually a GPU-less pod (see + // modelBudget). + const budget = modelBudget(resources) + const totalGpuMemory = budget.totalMemory + const hasGpu = budget.hasGpu const fetchModels = useCallback(async (params = {}) => { try { @@ -865,6 +867,7 @@ export default function Models() { onPickContext={setContextSize} totalGpuMemory={totalGpuMemory} fitsGpu={fitsGpu} + budgetNode={budget.scope === 'cluster' ? budget.nodeName : ''} installing={isInstalling(selectedName)} progress={getOperationProgress(selectedName)} onInstall={handleInstall} @@ -894,9 +897,16 @@ export default function Models() { the data did not support. */} {totalGpuMemory <= 0 ? t('shelves.heroNoGpu', { count: stats.total }) - : hasGpu - ? t('shelves.heroWithGpu', { vram: formatBytes(totalGpuMemory), count: stats.total }) - : t('shelves.heroWithRam', { ram: formatBytes(totalGpuMemory), count: stats.total })} + : budget.scope === 'cluster' + // Naming the node is the point: a cluster figure with + // no owner reads as this machine's, which is the very + // confusion the cluster reading exists to end. + ? t(budget.nodeCount > 1 ? 'shelves.heroWithCluster' : 'shelves.heroWithNode', { + vram: formatBytes(totalGpuMemory), node: budget.nodeName, nodes: budget.nodeCount, count: stats.total, + }) + : hasGpu + ? t('shelves.heroWithGpu', { vram: formatBytes(totalGpuMemory), count: stats.total }) + : t('shelves.heroWithRam', { ram: formatBytes(totalGpuMemory), count: stats.total })}

{t('shelves.heroHint')}

@@ -1413,7 +1423,7 @@ function VramByContext({ estimate, contextSize, onPickContext, totalGpuMemory, t // and hands the rest to ModelDetail, which already knows how to render an // entry's fields and is shared with the per-variant panel. function DiscoverDetail({ - model, estimate, contextSize, onPickContext, totalGpuMemory, fitsGpu, + model, estimate, contextSize, onPickContext, totalGpuMemory, fitsGpu, budgetNode, installing, progress, onInstall, installedProfile, onOpen, onManage, onBack, expandedFiles, setExpandedFiles, variantData, variantDetails, onLoadVariantDetail, t, }) { @@ -1472,7 +1482,11 @@ function DiscoverDetail({ { label: t('detail.size'), value: sizeDisplay && sizeDisplay !== '0 B' ? sizeDisplay : '—' }, { label: t('detail.vramAt', { context: contextLabel }), value: vramBytes ? formatBytes(vramBytes) : '—' }, { - label: t('detail.headroom'), + // Headroom is headroom somewhere. On a distributed controller that + // somewhere is a worker, and an unqualified figure reads as this + // machine's, which is the confusion the cluster reading exists to + // end. + label: budgetNode ? t('detail.headroomOn', { node: budgetNode }) : t('detail.headroom'), value: headroom === null ? '—' : (headroom < 0 ? '−' : '') + formatBytes(Math.abs(headroom)), tone: headroom === null ? undefined : headroom < 0 ? 'bad' : 'ok', }, diff --git a/core/http/react-ui/src/pages/Scheduling.jsx b/core/http/react-ui/src/pages/Scheduling.jsx index f586bd4f2699..1558281c3226 100644 --- a/core/http/react-ui/src/pages/Scheduling.jsx +++ b/core/http/react-ui/src/pages/Scheduling.jsx @@ -7,6 +7,7 @@ import ConfirmDialog from '../components/ConfirmDialog' import ResponsiveTable from '../components/ResponsiveTable' import SearchableModelSelect from '../components/SearchableModelSelect' import KeyValueChips from '../components/nodes/KeyValueChips' +import { labelIndex } from '../utils/nodeLabelSuggestions' // Numeric input with quick-pick preset chips. Picked over a slider because // replica counts are exact specs (operator math), not fuzzy estimates. The @@ -65,7 +66,7 @@ function configMode(config) { return 'placement' } -function SchedulingForm({ initialConfig, onSave, onCancel }) { +function SchedulingForm({ initialConfig, onSave, onCancel, labels }) { const [mode, setMode] = useState(() => configMode(initialConfig)) const [modelName, setModelName] = useState(initialConfig?.model_name || '') // Selector is now a chip-builder map instead of a comma-separated string. @@ -174,6 +175,10 @@ function SchedulingForm({ initialConfig, onSave, onCancel }) { placeholderKey="key (e.g. gpu.vendor)" placeholderValue="value (e.g. nvidia)" ariaLabel="Node selector" + ariaLabelKey="Selector key" + ariaLabelValue="Selector value" + addLabel="Add selector" + suggestions={labels} /> {mode === 'placement' @@ -285,139 +290,25 @@ function SchedulingForm({ initialConfig, onSave, onCancel }) { ) } -const INITIAL_NODE_LIMIT = 5 -const NODE_LIMIT_STEP = 20 - -function fuzzyIncludes(text, term) { - if (text.includes(term)) return true - let termIndex = 0 - for (const character of text) { - if (character === term[termIndex]) termIndex++ - if (termIndex === term.length) return true - } - return false -} - -function matchesNode(node, query) { - const terms = query.toLocaleLowerCase().trim().split(/\s+/).filter(Boolean) - if (!terms.length) return true - const labels = Object.entries(node.labels || {}) - const haystack = [ - node.name, - node.id, - ...labels.flatMap(([key, value]) => [key, String(value), `${key}=${value}`]), - ].filter(Boolean).join(' ').toLocaleLowerCase() - return terms.every(term => fuzzyIncludes(haystack, term)) -} - -function NodeLabelReference() { - const [expanded, setExpanded] = useState(true) - const [nodes, setNodes] = useState([]) - const [query, setQuery] = useState('') - const [visibleLimit, setVisibleLimit] = useState(INITIAL_NODE_LIMIT) - const [loading, setLoading] = useState(true) - const [error, setError] = useState(false) - - const fetchNodes = useCallback(async () => { - setLoading(true) - setError(false) - try { - const data = await nodesApi.list() - setNodes(Array.isArray(data) ? data : []) - } catch { - setError(true) - } finally { - setLoading(false) - } - }, []) - - useEffect(() => { fetchNodes() }, [fetchNodes]) - - const filtered = nodes.filter(node => matchesNode(node, query)) - const visible = filtered.slice(0, visibleLimit) - const updateQuery = event => { - setQuery(event.target.value) - setVisibleLimit(INITIAL_NODE_LIMIT) - } - - return ( -
-