mirror of
https://github.com/mudler/LocalAI.git
synced 2026-04-30 03:55:58 -04:00
* fix(distributed): surface per-node backend op errors to OpStatus
DistributedBackendManager.{Install,Upgrade,Delete}Backend discarded the
per-node BackendOpResult from enqueueAndDrainBackendOp with `_, err :=`.
When workers replied Success=false (e.g. an OCI image with no arm64
variant on a Jetson host), the per-node Error string was recorded in
result.Nodes[].Error but never reached the toplevel return value, so
OpStatus.Error stayed empty and the UI reported the install as
"completed" while the backend was nowhere on the cluster.
Add BackendOpResult.Err() that aggregates per-node Status=="error"
entries into a single error. Queued nodes (waiting for reconciler retry)
are deliberately not treated as failures. Wire the three callers and
DeleteBackendDetailed to call result.Err() so reply.Success=false
finally reaches OpStatus.Error → /api/backends/job/:uid → the UI.
The Delete closures had a related bug: they discarded the reply with
`_` and only checked the NATS round-trip error, so reply.Success=false
was a silent success even with the new aggregation. Check both.
Standalone mode (LocalBackendManager) already surfaces gallery errors
correctly through the same OpStatus.Error path; no change needed there.
Tests: 9 new Ginkgo specs covering all-success / all-fail with distinct
errors / mixed / all-queued / no-nodes for Install, Upgrade, Delete.
Assisted-by: Claude:claude-opus-4-7 [Bash] [Edit] [Read] [Write]
* feat(react-ui): per-node backend delete + clearer upgrade affordance
The Nodes page exposed a per-node "reinstall" button (fa-sync-alt,
tooltip "Reinstall backend") but no per-node delete, even though the
Go side has had POST /api/nodes/:id/backends/delete →
RemoteUnloaderAdapter.DeleteBackend → NATS-to-specific-node wired up
for a while. Sync icons read as "refresh data" — the action is
functionally an upgrade (re-pulls the gallery image), so the affordance
was misleading.
Per-node backend row now renders two icon buttons:
- Upgrade: btn-secondary btn-sm + fa-arrow-up, tooltip "Upgrade backend
on this node". Names both action and scope to differentiate from the
cluster-wide upgrade on the Backends page.
- Delete: btn-danger-ghost btn-sm + fa-trash, tooltip "Delete backend
from this node". Matches the node-level destructive style at the row
action column rather than the solid btn-danger of primary destructive
pages, since this is a secondary action inside a busy row.
Delete goes through the existing ConfirmDialog (danger=true) with copy
that names the backend and the node explicitly — it's a non-recoverable
op on a specific scope. Reuses nodesApi.deleteBackend(id, backend) which
already existed in the API client.
Tests: 4 new Playwright specs covering upgrade clarity (icon + tooltip),
delete button presence, confirm dialog flow with POST body assertion,
and cancel-doesn't-POST.
Assisted-by: Claude:claude-opus-4-7 [Bash] [Edit] [Read] [Write]
161 lines
5.4 KiB
JavaScript
161 lines
5.4 KiB
JavaScript
import { test, expect } from '@playwright/test'
|
|
|
|
// 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 } = {}) {
|
|
await page.route('**/api/nodes', (route) => {
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify([
|
|
{
|
|
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/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 expandNodeAndWaitForBackends(page) {
|
|
await page.goto('/app/nodes')
|
|
// Click the row to expand it. The chevron toggle and the row both work,
|
|
// but clicking the name cell is the most user-like.
|
|
await page.getByText(NODE_NAME).first().click()
|
|
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 expandNodeAndWaitForBackends(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 expandNodeAndWaitForBackends(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 expandNodeAndWaitForBackends(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 expandNodeAndWaitForBackends(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)
|
|
})
|
|
})
|