diff --git a/backend/cpp/ds4/Makefile b/backend/cpp/ds4/Makefile index 5e73c1328bbb..b171fa391d76 100644 --- a/backend/cpp/ds4/Makefile +++ b/backend/cpp/ds4/Makefile @@ -1,10 +1,10 @@ # ds4 backend Makefile. # -# Upstream pin lives below as DS4_VERSION?=c1d4597a80e300b803dc642519718f2c999589da +# Upstream pin lives below as DS4_VERSION?=8db89fe083ae4d17c9a2428ccd29803d3ae8f577 # (.github/bump_deps.sh) can find and update it - matches the # llama-cpp / ik-llama-cpp / turboquant convention. -DS4_VERSION?=c1d4597a80e300b803dc642519718f2c999589da +DS4_VERSION?=8db89fe083ae4d17c9a2428ccd29803d3ae8f577 DS4_REPO?=https://github.com/antirez/ds4 CURRENT_MAKEFILE_DIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST)))) diff --git a/backend/cpp/ik-llama-cpp/Makefile b/backend/cpp/ik-llama-cpp/Makefile index 5f2ad4d310a2..2ee3f30cda2a 100644 --- a/backend/cpp/ik-llama-cpp/Makefile +++ b/backend/cpp/ik-llama-cpp/Makefile @@ -1,5 +1,5 @@ -IK_LLAMA_VERSION?=7cff686d3732bfef5ce18bc4a6115fbceda29c14 +IK_LLAMA_VERSION?=15dddc60b3fc937a9e2a210359ecce392ccdf446 LLAMA_REPO?=https://github.com/ikawrakow/ik_llama.cpp CMAKE_ARGS?= 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 ( -
-