Files
LocalAI/core/http/react-ui/e2e/nodes-per-node-backend-actions.spec.js
T
Ettore Di Giacinto eb1656ed9c chore(distributed): take the nats-io modules out of the build
Distributed mode has not dialled a message broker since the control plane
moved onto the workers' own outward tunnels and every fan-out family moved
onto PostgreSQL LISTEN/NOTIFY. What was left was the dependency itself, and
the code that existed only to feed it.

Dropped from go.mod: nats-io/jwt/v2, nats-io/nats.go, nats-io/nkeys,
nats-io/nuid and testcontainers-go/modules/nats, along with the fourteen
indirect requires that only the NATS testcontainer pulled in. go.sum carries
no nats line either, so the removal is not the partial kind where the require
goes and the checksum stays.

Deleted with them: pkg/natsauth in full, the broker client's remaining
options and TLS files, the per-node JWT minting on both the register and the
approve path, and the natsauth.Config parameter threaded through the node
routes. The credential manager is renamed and stripped rather than deleted,
because it still holds the tunnel token that every re-registration rotates.

The bus flags stay accepted and ignored, and are now hidden, on every command
that had them, so an existing unit file, compose file or Helm values file
still starts on the day of the upgrade. What is not kept is the validation
that REQUIRED one: a distributed frontend started with no bus URL is no
longer fatal. The TLS paths lose type:"existingfile" deliberately, so a
certificate deleted along with the broker cannot fail a startup.

One operator-visible behaviour change: --nats-require-auth no longer makes an
agent worker wait through admin approval. Ask for that wait with
--distributed-require-auth, which already implied it. It is documented in the
migration section and pinned from both sides.

A deployment now needs PostgreSQL and the frontends' own HTTP listener, and
nothing else.

coverage-baseline.txt moves from 54.2 to 62.0.

Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-27 03:05:13 +00:00

184 lines
5.9 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.
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 })
}
async function openBackendActions(page) {
const trigger = page.getByRole('button', { name: `Actions for backend ${BACKEND_NAME}` })
await expect(trigger).toBeVisible()
await trigger.click()
const menu = page.getByRole('menu', { name: `${BACKEND_NAME} backend actions` })
await expect(menu).toBeVisible()
return menu
}
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)
await expect(page.locator('.node-detail__metrics')).toContainText('RAM')
await expect(page.locator('.node-detail__metrics')).toContainText('3.7 GB / 7.5 GB')
const menu = await openBackendActions(page)
// Negative: the old, ambiguous wording must not be used.
await expect(menu.getByRole('menuitem', { name: 'Reinstall backend' })).toHaveCount(0)
// Positive: the action menu names the operation and uses an upgrade icon.
const upgradeItem = menu.getByRole('menuitem', { name: 'Upgrade backend' })
await expect(upgradeItem).toBeVisible()
await expect(upgradeItem.locator('i.fa-arrow-up')).toBeVisible()
})
test('per-node backend row shows a delete (trash) button next to upgrade', async ({ page }) => {
await mockDistributedNodes(page)
await openNodeDetail(page)
const menu = await openBackendActions(page)
const deleteItem = menu.getByRole('menuitem', { name: 'Delete backend…' })
await expect(deleteItem).toBeVisible()
await expect(deleteItem.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)
const menu = await openBackendActions(page)
await menu.getByRole('menuitem', { name: 'Delete backend…' }).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)
const menu = await openBackendActions(page)
await menu.getByRole('menuitem', { name: 'Delete backend…' }).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)
})
})