mirror of
https://github.com/mudler/LocalAI.git
synced 2026-09-13 22:57:36 -04:00
* docs(ui): design scheduling rule editing Document the approved in-place rule editing flow and scalable node-label reference for the scheduling view. Assisted-by: Codex:gpt-5 * feat(ui): improve scheduling rule management Add scalable node-label discovery and editable scheduling rules with responsive, accessible controls. Assisted-by: Codex:gpt-5 * chore(ui): ratchet inline style baseline Record the static inline style removed by the scheduling view enhancement. Assisted-by: Codex:gpt-5 --------- Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
158 lines
7.2 KiB
JavaScript
158 lines
7.2 KiB
JavaScript
import { test, expect } from './coverage-fixtures.js'
|
|
|
|
const rule = {
|
|
model_name: 'llama-3.3',
|
|
node_selector: { 'gpu.vendor': 'nvidia' },
|
|
min_replicas: 2,
|
|
max_replicas: 8,
|
|
spread_all: false,
|
|
route_policy: 'prefix_cache',
|
|
balance_abs_threshold: 4,
|
|
balance_rel_threshold: 1.5,
|
|
min_prefix_match: 0.75,
|
|
}
|
|
|
|
const nodes = Array.from({ length: 27 }, (_, index) => ({
|
|
id: `node-${index + 1}`,
|
|
name: index === 0 ? 'Falcon GPU' : `Worker ${index + 1}`,
|
|
status: index === 1 ? 'offline' : 'online',
|
|
labels: index === 2 ? {} : {
|
|
'gpu.vendor': index === 0 ? 'NVIDIA' : 'amd',
|
|
zone: index % 2 ? 'west' : 'east',
|
|
},
|
|
}))
|
|
|
|
async function mockScheduling(page, { rules = [rule], nodeList = nodes } = {}) {
|
|
await page.route('**/api/nodes/scheduling', route => route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify(rules),
|
|
}))
|
|
await page.route('**/api/nodes', route => route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify(nodeList),
|
|
}))
|
|
}
|
|
|
|
test.describe('Scheduling page', () => {
|
|
test('groups node labels, collapses the reference, filters forgivingly, and expands results', async ({ page }) => {
|
|
await mockScheduling(page)
|
|
await page.goto('/app/scheduling')
|
|
|
|
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()
|
|
})
|
|
|
|
test('edits all fields with a locked model and preserves values after a failed save', async ({ page }) => {
|
|
await mockScheduling(page)
|
|
let submitted
|
|
await page.route('**/api/nodes/scheduling', async route => {
|
|
if (route.request().method() === 'POST') {
|
|
submitted = route.request().postDataJSON()
|
|
await route.fulfill({ status: 500, contentType: 'application/json', body: '{"error":"nope"}' })
|
|
} else {
|
|
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([rule]) })
|
|
}
|
|
})
|
|
await page.goto('/app/scheduling')
|
|
await page.getByRole('button', { name: 'Edit llama-3.3' }).click()
|
|
|
|
await expect(page.getByLabel('Model')).toHaveValue('llama-3.3')
|
|
await expect(page.getByLabel('Model')).toHaveAttribute('readonly', '')
|
|
await expect(page.getByRole('radio', { name: 'Auto-scale' })).toHaveAttribute('aria-checked', 'true')
|
|
await expect(page.getByLabel('Node selector').getByText('gpu.vendor=nvidia', { exact: true })).toBeVisible()
|
|
await expect(page.getByLabel('Min replicas')).toHaveValue('2')
|
|
await expect(page.getByLabel('Max replicas')).toHaveValue('8')
|
|
await expect(page.getByLabel('Routing policy')).toHaveValue('prefix_cache')
|
|
await expect(page.getByLabel('Min prefix match')).toHaveValue('0.75')
|
|
await expect(page.getByLabel('Balance abs threshold')).toHaveValue('4')
|
|
await expect(page.getByLabel('Balance rel threshold')).toHaveValue('1.5')
|
|
|
|
await page.getByLabel('Min replicas').fill('3')
|
|
await page.getByRole('button', { name: 'Save rule' }).click()
|
|
await expect.poll(() => submitted?.min_replicas).toBe(3)
|
|
await expect(page.getByLabel('Min replicas')).toHaveValue('3')
|
|
|
|
await page.getByRole('button', { name: 'Cancel' }).click()
|
|
await page.getByRole('button', { name: 'Edit llama-3.3' }).click()
|
|
await expect(page.getByLabel('Min replicas')).toHaveValue('2')
|
|
})
|
|
|
|
test('keeps a single add or edit form open and leaves Add blank', async ({ page }) => {
|
|
await mockScheduling(page)
|
|
await page.goto('/app/scheduling')
|
|
await page.getByRole('button', { name: 'Edit llama-3.3' }).click()
|
|
await expect(page.locator('.scheduling-form')).toHaveCount(1)
|
|
await page.getByRole('button', { name: 'Add Scheduling Rule' }).click()
|
|
await expect(page.locator('.scheduling-form')).toHaveCount(1)
|
|
await expect(page.getByRole('combobox', { name: '' }).first()).toHaveValue('')
|
|
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
|
|
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.goto('/app/scheduling')
|
|
await expect(page.getByText('Loading node labels…')).toBeVisible()
|
|
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()
|
|
})
|
|
|
|
test('uses one node column and accessible rule actions 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()
|
|
expect((await actions.boundingBox()).width).toBeGreaterThan(200)
|
|
})
|
|
})
|