mirror of
https://github.com/mudler/LocalAI.git
synced 2026-06-25 00:59:28 -04:00
* chore: gitignore SDD scratch directory Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: Claude:claude-opus-4-8 [Claude Code] * feat(nodes): add GET /api/nodes/models cluster-wide loaded-models endpoint Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: Claude:claude-opus-4-8 [Claude Code] * feat(ui): add nodesApi.allModels() for cluster-wide model roster Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: Claude:claude-opus-4-8 [Claude Code] * feat(ui): move Scheduling to its own page and nav item Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: Claude:claude-opus-4-8 [Claude Code] * feat(ui): replace nodes stat-card strip with cluster pulse + attention callout Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: Claude:claude-opus-4-8 [Claude Code] * feat(ui): node-panel roster with inline model chips and segmented filter Replace the Nodes table with a full-width node-panel roster that shows each backend node's running-model chips without an expand click, plus an All/Backend/Agent segmented filter. Per-node detail (models, backends, labels, capacity) moves to the node detail page. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: Claude:claude-opus-4-8 [Claude Code] * feat(ui): add deep-linkable node detail page at /app/nodes/:id Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: Claude:claude-opus-4-8 [Claude Code] * fix(ui): remove em-dash from CapacityEditor comment; align detail spec backend mock Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: Claude:claude-opus-4-8 [Claude Code] * chore(ui): nodes page cleanup, hover/chip polish, docs for restructured cluster view Nodes.jsx dead-code sweep confirmed clean (no StatCard/table/expand state/scheduling-form leftovers). Two App.css polish fixes: move the node-panel hover border-color onto the bordered element so hover gives real feedback, and add the missing .model-chip__state rule the ModelChip component already emits. Update distributed-mode docs prose to describe the restructured cluster view (cluster pulse, attention callout, node-panel roster with inline model chips, All/Backend/Agent filter, node detail page at /app/nodes/:id, Scheduling as its own page). Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: Claude:claude-opus-4-8 [Claude Code] * chore(ui): drop unused gpuVendorLabel export from nodeStatus Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: Claude:claude-opus-4-8 [Claude Code] --------- Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
170 lines
5.5 KiB
JavaScript
170 lines
5.5 KiB
JavaScript
import { test, expect } from './coverage-fixtures.js'
|
|
|
|
// These specs cover the per-node backend row in the Nodes page:
|
|
// - the upgrade affordance is self-explanatory (icon + tooltip)
|
|
// - a delete affordance is present and goes through ConfirmDialog
|
|
//
|
|
// We mock the distributed-mode API so the tests can run against the
|
|
// standalone ui-test-server without spinning up workers/NATS.
|
|
|
|
const NODE_ID = 'test-node-1'
|
|
const NODE_NAME = 'worker-test'
|
|
const BACKEND_NAME = 'cuda12-vllm-development'
|
|
|
|
async function mockDistributedNodes(page, { onDelete } = {}) {
|
|
const nodeRecord = {
|
|
id: NODE_ID,
|
|
name: NODE_NAME,
|
|
node_type: 'backend',
|
|
address: '10.0.0.1:50051',
|
|
http_address: '10.0.0.1:8090',
|
|
status: 'healthy',
|
|
total_vram: 0,
|
|
available_vram: 0,
|
|
total_ram: 8_000_000_000,
|
|
available_ram: 4_000_000_000,
|
|
gpu_vendor: '',
|
|
last_heartbeat: new Date().toISOString(),
|
|
created_at: new Date().toISOString(),
|
|
updated_at: new Date().toISOString(),
|
|
}
|
|
|
|
await page.route('**/api/nodes', (route) => {
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify([nodeRecord]),
|
|
})
|
|
})
|
|
|
|
// The detail page fetches the single node via nodesApi.get(id).
|
|
await page.route(`**/api/nodes/${NODE_ID}`, (route) => {
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify(nodeRecord),
|
|
})
|
|
})
|
|
|
|
await page.route('**/api/nodes/scheduling', (route) => {
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: '[]',
|
|
})
|
|
})
|
|
|
|
await page.route(`**/api/nodes/${NODE_ID}/models`, (route) => {
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: '[]',
|
|
})
|
|
})
|
|
|
|
await page.route(`**/api/nodes/${NODE_ID}/backends`, (route) => {
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify([
|
|
{
|
|
name: BACKEND_NAME,
|
|
is_system: false,
|
|
is_meta: false,
|
|
installed_at: new Date().toISOString(),
|
|
},
|
|
]),
|
|
})
|
|
})
|
|
|
|
await page.route(`**/api/nodes/${NODE_ID}/backends/delete`, async (route) => {
|
|
if (onDelete) {
|
|
await onDelete(route)
|
|
}
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({ message: 'backend deleted' }),
|
|
})
|
|
})
|
|
}
|
|
|
|
async function openNodeDetail(page) {
|
|
// The per-node backend table now lives on the deep-linkable detail page
|
|
// at /app/nodes/:id (the old expand-row + "Manage" disclosure was removed
|
|
// when the roster was restructured). Navigate straight there.
|
|
await page.goto(`/app/nodes/${NODE_ID}`)
|
|
await expect(page.getByRole('cell', { name: BACKEND_NAME, exact: true })).toBeVisible({ timeout: 10_000 })
|
|
}
|
|
|
|
test.describe('Nodes page — per-node backend actions', () => {
|
|
test('upgrade affordance is self-explanatory (not "Reinstall backend" with a sync icon)', async ({ page }) => {
|
|
await mockDistributedNodes(page)
|
|
await openNodeDetail(page)
|
|
|
|
// Negative: the old, ambiguous wording must not be used.
|
|
await expect(page.locator('button[title="Reinstall backend"]')).toHaveCount(0)
|
|
await expect(page.locator('button[title="Reinstall backend"] i.fa-sync-alt')).toHaveCount(0)
|
|
|
|
// Positive: a self-explanatory upgrade affordance is rendered next to the
|
|
// backend row. We accept either an arrow-up or arrows-rotate glyph; both
|
|
// map to "upgrade" semantics in FontAwesome 6 unambiguously.
|
|
const upgradeBtn = page.locator('button[title="Upgrade backend on this node"]')
|
|
await expect(upgradeBtn).toBeVisible()
|
|
const iconClass = await upgradeBtn.locator('i').getAttribute('class')
|
|
expect(iconClass).toMatch(/fa-(arrow-up|arrows-rotate|up-long)/)
|
|
})
|
|
|
|
test('per-node backend row shows a delete (trash) button next to upgrade', async ({ page }) => {
|
|
await mockDistributedNodes(page)
|
|
await openNodeDetail(page)
|
|
|
|
const deleteBtn = page.locator('button[title="Delete backend from this node"]')
|
|
await expect(deleteBtn).toBeVisible()
|
|
await expect(deleteBtn.locator('i.fa-trash')).toBeVisible()
|
|
})
|
|
|
|
test('clicking delete opens the confirm dialog and POSTs to the per-node delete endpoint', async ({ page }) => {
|
|
let postedBody = null
|
|
await mockDistributedNodes(page, {
|
|
onDelete: async (route) => {
|
|
postedBody = route.request().postDataJSON()
|
|
},
|
|
})
|
|
await openNodeDetail(page)
|
|
|
|
await page.locator('button[title="Delete backend from this node"]').click()
|
|
|
|
// ConfirmDialog uses role="alertdialog" and a danger confirm button.
|
|
const dialog = page.getByRole('alertdialog')
|
|
await expect(dialog).toBeVisible()
|
|
const confirmBtn = dialog.locator('button.btn-danger')
|
|
await expect(confirmBtn).toBeVisible()
|
|
await confirmBtn.click()
|
|
|
|
// Wait until the POST landed.
|
|
await expect.poll(() => postedBody, { timeout: 5_000 }).toEqual({ backend: BACKEND_NAME })
|
|
})
|
|
|
|
test('clicking delete and cancelling does not POST', async ({ page }) => {
|
|
let deleteCalls = 0
|
|
await mockDistributedNodes(page, {
|
|
onDelete: () => {
|
|
deleteCalls += 1
|
|
},
|
|
})
|
|
await openNodeDetail(page)
|
|
|
|
await page.locator('button[title="Delete backend from this node"]').click()
|
|
|
|
const dialog = page.getByRole('alertdialog')
|
|
await expect(dialog).toBeVisible()
|
|
await dialog.getByRole('button', { name: /cancel/i }).click()
|
|
await expect(dialog).toBeHidden()
|
|
|
|
// Give any errant request a moment to fire so a regression would be caught.
|
|
await page.waitForTimeout(500)
|
|
expect(deleteCalls).toBe(0)
|
|
})
|
|
})
|