Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
830f818
feat(p2p): gossip free VRAM per node and add testable online check
mudler Jun 1, 2026
7768b35
feat(p2p): route federation with shared clusterrouting policy (load +…
mudler Jun 1, 2026
ed38609
feat(routing): add load-guarded PickWithAffinity for prefix-cache rou…
mudler Jun 1, 2026
288d732
feat(p2p): gossip each peer's model set for model-aware federation
mudler Jun 1, 2026
14b57aa
feat(p2p): model-filtered federation candidates and request model ext…
mudler Jun 1, 2026
8ec536a
feat(p2p): affinity-aware peer selection and federation body-limit flag
mudler Jun 1, 2026
8df0bb6
feat(p2p): L7 HTTP-terminating federation proxy with model + prefix-a…
mudler Jun 1, 2026
91fc26f
refactor(p2p): dedupe forwarded Connection header and drop unused ext…
mudler Jun 1, 2026
ce8b97e
feat(p2p): edgevpn generic-channel publisher and handler for affinity…
mudler Jun 1, 2026
ec2a064
feat(p2p): optional cross-server prefix-affinity sync over the generi…
mudler Jun 1, 2026
bc42374
feat(ui): add useP2PMode hook and embedded prop to Nodes and P2P pages
mudler Jun 1, 2026
a0c7cec
feat(ui): add Cluster page composing distributed and swarm sections
mudler Jun 1, 2026
d887582
feat(ui): route /app/cluster, redirect /app/p2p and /app/nodes, singl…
mudler Jun 1, 2026
5033457
refactor(ui): repoint internal links from /app/nodes to /app/cluster
mudler Jun 1, 2026
8180fdd
test(ui): e2e specs for merged Cluster page and old-route redirects
mudler Jun 1, 2026
450376d
test(ui): repoint node-backend-actions spec to /app/cluster
mudler Jun 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion core/application/p2p.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,21 @@ func (a *Application) StartP2P() error {
return err
}

// modelsFn reports the model names this instance currently serves so the
// federation proxy can route a request only to peers that have the
// requested model. It is re-evaluated on every announce tick.
modelsFn := func() []string {
cfgs := a.ModelConfigLoader().GetAllModelsConfigs()
names := make([]string, 0, len(cfgs))
for _, c := range cfgs {
names = append(names, c.Name)
}
return names
}

// Here a new node is created and started
// and a service is exposed by the node
node, err := p2p.ExposeService(ctx, "localhost", port, a.applicationConfig.P2PToken, p2p.NetworkID(networkID, p2p.FederatedID))
node, err := p2p.ExposeService(ctx, "localhost", port, a.applicationConfig.P2PToken, p2p.NetworkID(networkID, p2p.FederatedID), modelsFn)
if err != nil {
return err
}
Expand Down
4 changes: 3 additions & 1 deletion core/cli/federated.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,14 @@ type FederatedCLI struct {
RandomWorker bool `env:"LOCALAI_RANDOM_WORKER,RANDOM_WORKER" default:"false" help:"Select a random worker from the pool" group:"p2p"`
Peer2PeerNetworkID string `env:"LOCALAI_P2P_NETWORK_ID,P2P_NETWORK_ID" help:"Network ID for P2P mode, can be set arbitrarly by the user for grouping a set of instances." group:"p2p"`
TargetWorker string `env:"LOCALAI_TARGET_WORKER,TARGET_WORKER" help:"Target worker to run the federated server on" group:"p2p"`
UploadLimit int `env:"LOCALAI_UPLOAD_LIMIT,UPLOAD_LIMIT" default:"15" help:"Default upload-size limit in megabytes" group:"api"`
AffinitySync bool `env:"LOCALAI_FEDERATED_AFFINITY_SYNC,FEDERATED_AFFINITY_SYNC" default:"false" help:"Broadcast prefix-cache affinity observations to other federation servers over the p2p generic channel (enable on every federation server that should cohere)" group:"p2p"`
}

func (f *FederatedCLI) Run(ctx *cliContext.Context) error {
warnDeprecatedFlags()

fs := p2p.NewFederatedServer(f.Address, p2p.NetworkID(f.Peer2PeerNetworkID, p2p.FederatedID), f.Peer2PeerToken, !f.RandomWorker, f.TargetWorker)
fs := p2p.NewFederatedServer(f.Address, p2p.NetworkID(f.Peer2PeerNetworkID, p2p.FederatedID), f.Peer2PeerToken, !f.RandomWorker, f.TargetWorker, int64(f.UploadLimit)*1024*1024, f.AffinitySync)

c, cancel := context.WithCancel(context.Background())

Expand Down
4 changes: 2 additions & 2 deletions core/cli/worker/worker_p2p.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ func (r *P2P) Run(ctx *cliContext.Context) error {
p = r.RunnerPort
}

_, err = p2p.ExposeService(c, address, p, r.Token, p2p.NetworkID(r.Peer2PeerNetworkID, p2p.LlamaCPPWorkerID))
_, err = p2p.ExposeService(c, address, p, r.Token, p2p.NetworkID(r.Peer2PeerNetworkID, p2p.LlamaCPPWorkerID), nil)
if err != nil {
return err
}
Expand Down Expand Up @@ -104,7 +104,7 @@ func (r *P2P) Run(ctx *cliContext.Context) error {
}
}()

_, err = p2p.ExposeService(c, address, fmt.Sprint(port), r.Token, p2p.NetworkID(r.Peer2PeerNetworkID, p2p.LlamaCPPWorkerID))
_, err = p2p.ExposeService(c, address, fmt.Sprint(port), r.Token, p2p.NetworkID(r.Peer2PeerNetworkID, p2p.LlamaCPPWorkerID), nil)
if err != nil {
return err
}
Expand Down
2 changes: 1 addition & 1 deletion core/cli/worker/worker_p2p_mlx.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ func (r *P2PMLX) Run(ctx *cliContext.Context) error {
}
}()

_, err = p2p.ExposeService(c, address, fmt.Sprint(port), r.Token, p2p.NetworkID(r.Peer2PeerNetworkID, p2p.MLXWorkerID))
_, err = p2p.ExposeService(c, address, fmt.Sprint(port), r.Token, p2p.NetworkID(r.Peer2PeerNetworkID, p2p.MLXWorkerID), nil)
if err != nil {
return err
}
Expand Down
37 changes: 37 additions & 0 deletions core/http/react-ui/e2e/cluster.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { test, expect } from './coverage-fixtures.js'

// The Cluster page composes two capability sections: "Distributed (NATS)" (the
// former Nodes page) and "Swarm (p2p)" (the former P2P page). Each section only
// mounts when its mode is enabled — distributed when /api/nodes answers OK, swarm
// when a non-empty p2p network token is present. We mock those probes so the page
// renders against the standalone ui-test-server without NATS / p2p running.

async function mockDistributedOnly(page) {
await page.route('**/api/nodes', (route) => {
route.fulfill({ status: 200, contentType: 'application/json', body: '[]' })
})
await page.route('**/api/nodes/scheduling', (route) => {
route.fulfill({ status: 200, contentType: 'application/json', body: '[]' })
})
// Swarm disabled: token probe fails, so the swarm section stays hidden.
await page.route('**/api/p2p/token', (route) => {
route.fulfill({ status: 503, contentType: 'text/plain', body: '' })
})
}

test.describe('Cluster page', () => {
test('shows the page title', async ({ page }) => {
await mockDistributedOnly(page)
await page.goto('/app/cluster')
await expect(page).toHaveURL(/\/app\/cluster$/)
await expect(page.getByRole('heading', { name: /Cluster/i })).toBeVisible()
})

test('shows the distributed section when /api/nodes responds', async ({ page }) => {
await mockDistributedOnly(page)
await page.goto('/app/cluster')
await expect(page).toHaveURL(/\/app\/cluster$/)
// The distributed capability section is titled "Distributed (NATS)".
await expect(page.getByText(/Distributed \(NATS\)/i)).toBeVisible()
})
})
7 changes: 7 additions & 0 deletions core/http/react-ui/e2e/navigation.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,11 @@ test.describe('Navigation', () => {
await expect(page).toHaveURL(/\/app\/traces/)
await expect(page.getByRole('heading', { name: 'Traces', exact: true })).toBeVisible()
})

test('old cluster routes redirect to /app/cluster', async ({ page }) => {
await page.goto('/app/p2p')
await expect(page).toHaveURL(/\/app\/cluster$/)
await page.goto('/app/nodes')
await expect(page).toHaveURL(/\/app\/cluster$/)
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ async function mockDistributedNodes(page, { onDelete } = {}) {
}

async function expandNodeAndWaitForBackends(page) {
await page.goto('/app/nodes')
await page.goto('/app/cluster')
// 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()
Expand Down
75 changes: 57 additions & 18 deletions core/http/react-ui/e2e/p2p.spec.js
Original file line number Diff line number Diff line change
@@ -1,26 +1,65 @@
import { test, expect } from './coverage-fixtures.js'

// P2P (Swarm) admin page — renders in the no-auth test harness (isAdmin).
test.describe('P2P page', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/app/p2p')
// The standalone P2P (Swarm) page was merged into the Cluster page: /app/p2p now
// redirects to /app/cluster, and the p2p content lives under the "Swarm (p2p)"
// section. That section only mounts when p2p is enabled (a network token is
// present), so we mock /api/p2p/token to return a non-empty token and assert the
// swarm content renders under the cluster page.
const P2P_TOKEN = 'test-network-token'

async function mockSwarmEnabled(page) {
await page.route('**/api/p2p/token', (route) => {
route.fulfill({
status: 200,
contentType: 'text/plain',
body: P2P_TOKEN,
})
})
await page.route('**/api/p2p/workers', (route) => {
route.fulfill({ status: 200, contentType: 'application/json', body: '{"nodes":[]}' })
})
await page.route('**/api/p2p/federation', (route) => {
route.fulfill({ status: 200, contentType: 'application/json', body: '{"nodes":[]}' })
})
await page.route('**/api/p2p/stats', (route) => {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
llama_cpp_workers: { online: 0, total: 0 },
federated: { online: 0, total: 0 },
mlx_workers: { online: 0, total: 0 },
}),
})
})
// The cluster page also probes /api/nodes for the distributed section; keep it
// failing (distributed disabled) so only the swarm section renders here.
await page.route('**/api/nodes', (route) => {
route.fulfill({ status: 503, contentType: 'application/json', body: '{}' })
})
}

test('renders the P2P distribution overview and capability cards', async ({ page }) => {
await expect(page).toHaveURL(/\/app\/p2p$/)
await expect(page.getByRole('heading', { name: /P2P Distribution Not Enabled/i })).toBeVisible()
await expect(page.getByRole('heading', { name: 'Instance Federation' })).toBeVisible()
await expect(page.getByRole('heading', { name: 'Model Sharding' })).toBeVisible()
await expect(page.getByRole('heading', { name: 'Resource Sharing' })).toBeVisible()
await expect(page.getByRole('heading', { name: /How to Enable P2P/i })).toBeVisible()
test.describe('P2P (Swarm) section on the Cluster page', () => {
test('the old /app/p2p route lands on the cluster page', async ({ page }) => {
await mockSwarmEnabled(page)
// /app/p2p redirects to /app/cluster.
await page.goto('/app/p2p')
await expect(page).toHaveURL(/\/app\/cluster$/)
await expect(page.getByRole('heading', { name: /Cluster/i })).toBeVisible()
})

test('hardware selector offers build targets and responds to selection', async ({ page }) => {
const cpu = page.getByRole('button').filter({ hasText: /^CPU$/ })
const cuda = page.getByRole('button').filter({ hasText: /^CUDA 12$/ })
await expect(cpu).toBeVisible()
await expect(cuda).toBeVisible()
await cuda.click() // selecting a build target must not break the page
await expect(page.getByRole('heading', { name: /How to Enable P2P/i })).toBeVisible()
test('renders the Swarm (p2p) section when p2p is enabled', async ({ page }) => {
await mockSwarmEnabled(page)
await page.goto('/app/cluster')
await expect(page).toHaveURL(/\/app\/cluster$/)

// The collapsible swarm section is titled "Swarm (p2p)".
await expect(page.getByText(/Swarm \(p2p\)/i)).toBeVisible()

// The enabled p2p content (Network Token panel + the federation / sharding
// tabs) is rendered inside the swarm section.
await expect(page.getByRole('heading', { name: /Network Token/i })).toBeVisible()
await expect(page.getByText('Federation', { exact: true })).toBeVisible()
await expect(page.getByText('Model Sharding', { exact: true })).toBeVisible()
})
})
16 changes: 16 additions & 0 deletions core/http/react-ui/public/locales/de/admin.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,5 +58,21 @@
"explorer": {
"title": "Explorer",
"subtitle": "Dateien und Konfiguration durchsuchen"
},
"cluster": {
"title": "Cluster",
"subtitle": "Verteilte und Peer-to-Peer-Knoten, die diese Instanz bedienen",
"empty": "Es ist kein verteiltes oder p2p-Clustering aktiviert. Starte LocalAI im verteilten oder föderierten/p2p-Modus, um hier Cluster-Knoten zu verwalten.",
"distributed": {
"title": "Verteilt (NATS)"
},
"swarm": {
"title": "Swarm (p2p)"
},
"summary": {
"nodes": "Verteilte Knoten",
"inFlight": "Laufende Anfragen",
"peers": "Swarm-Peers online"
}
}
}
1 change: 1 addition & 0 deletions core/http/react-ui/public/locales/de/nav.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
"traces": "Traces",
"nodes": "Knoten",
"swarm": "Swarm",
"cluster": "Cluster",
"system": "System",
"settings": "Einstellungen",
"api": "API"
Expand Down
16 changes: 16 additions & 0 deletions core/http/react-ui/public/locales/en/admin.json
Original file line number Diff line number Diff line change
Expand Up @@ -81,5 +81,21 @@
"explorer": {
"title": "Explorer",
"subtitle": "Browse files and configuration"
},
"cluster": {
"title": "Cluster",
"subtitle": "Distributed and peer-to-peer nodes serving this instance",
"empty": "No distributed or p2p clustering is enabled. Start LocalAI in distributed or federated/p2p mode to manage cluster nodes here.",
"distributed": {
"title": "Distributed (NATS)"
},
"swarm": {
"title": "Swarm (p2p)"
},
"summary": {
"nodes": "Distributed nodes",
"inFlight": "In-flight requests",
"peers": "Swarm peers online"
}
}
}
1 change: 1 addition & 0 deletions core/http/react-ui/public/locales/en/nav.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
"traces": "Traces",
"nodes": "Nodes",
"swarm": "Swarm",
"cluster": "Cluster",
"system": "System",
"settings": "Settings",
"api": "API"
Expand Down
16 changes: 16 additions & 0 deletions core/http/react-ui/public/locales/es/admin.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,5 +58,21 @@
"explorer": {
"title": "Explorador",
"subtitle": "Explora archivos y configuración"
},
"cluster": {
"title": "Clúster",
"subtitle": "Nodos distribuidos y entre pares que sirven a esta instancia",
"empty": "No hay clustering distribuido ni p2p habilitado. Inicia LocalAI en modo distribuido o federado/p2p para gestionar aquí los nodos del clúster.",
"distributed": {
"title": "Distribuido (NATS)"
},
"swarm": {
"title": "Swarm (p2p)"
},
"summary": {
"nodes": "Nodos distribuidos",
"inFlight": "Solicitudes en curso",
"peers": "Pares de Swarm en línea"
}
}
}
1 change: 1 addition & 0 deletions core/http/react-ui/public/locales/es/nav.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
"traces": "Trazas",
"nodes": "Nodos",
"swarm": "Swarm",
"cluster": "Clúster",
"system": "Sistema",
"settings": "Configuración",
"api": "API"
Expand Down
16 changes: 16 additions & 0 deletions core/http/react-ui/public/locales/it/admin.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,5 +58,21 @@
"explorer": {
"title": "Esplora risorse",
"subtitle": "Sfoglia file e configurazioni"
},
"cluster": {
"title": "Cluster",
"subtitle": "Nodi distribuiti e peer-to-peer al servizio di questa istanza",
"empty": "Nessun clustering distribuito o p2p è abilitato. Avvia LocalAI in modalità distribuita o federata/p2p per gestire qui i nodi del cluster.",
"distributed": {
"title": "Distribuito (NATS)"
},
"swarm": {
"title": "Swarm (p2p)"
},
"summary": {
"nodes": "Nodi distribuiti",
"inFlight": "Richieste in corso",
"peers": "Peer Swarm online"
}
}
}
1 change: 1 addition & 0 deletions core/http/react-ui/public/locales/it/nav.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
"traces": "Tracce",
"nodes": "Nodi",
"swarm": "Swarm",
"cluster": "Cluster",
"system": "Sistema",
"settings": "Impostazioni",
"api": "API"
Expand Down
16 changes: 16 additions & 0 deletions core/http/react-ui/public/locales/zh-CN/admin.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,5 +58,21 @@
"explorer": {
"title": "资源浏览器",
"subtitle": "浏览文件和配置"
},
"cluster": {
"title": "集群",
"subtitle": "为此实例提供服务的分布式和点对点节点",
"empty": "未启用分布式或 p2p 集群。请以分布式或联邦/p2p 模式启动 LocalAI,以便在此管理集群节点。",
"distributed": {
"title": "分布式 (NATS)"
},
"swarm": {
"title": "Swarm (p2p)"
},
"summary": {
"nodes": "分布式节点",
"inFlight": "进行中的请求",
"peers": "在线 Swarm 节点"
}
}
}
1 change: 1 addition & 0 deletions core/http/react-ui/public/locales/zh-CN/nav.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
"traces": "追踪",
"nodes": "节点",
"swarm": "Swarm",
"cluster": "集群",
"system": "系统",
"settings": "设置",
"api": "API"
Expand Down
27 changes: 27 additions & 0 deletions core/http/react-ui/src/components/ClusterSection.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { useState } from 'react'

// ClusterSection is a collapsible, titled container for one capability area of
// the Cluster page (Distributed / Swarm). Default expanded.
export default function ClusterSection({ icon, title, subtitle, defaultOpen = true, children }) {
const [open, setOpen] = useState(defaultOpen)
return (
<section className="card" style={{ marginBottom: 'var(--spacing-lg)' }}>
<button
type="button"
aria-expanded={open}
onClick={() => setOpen((o) => !o)}
style={{
display: 'flex', alignItems: 'center', gap: 'var(--spacing-sm)',
width: '100%', padding: 'var(--spacing-md)', background: 'none',
border: 'none', cursor: 'pointer', textAlign: 'left', color: 'inherit',
}}
>
<i className={`fas fa-chevron-${open ? 'down' : 'right'}`} style={{ width: '1rem', color: 'var(--color-text-muted)' }} />
{icon && <i className={icon} style={{ color: 'var(--color-primary)' }} />}
<span style={{ fontWeight: 600 }}>{title}</span>
{subtitle && <span style={{ marginLeft: 'auto', color: 'var(--color-text-muted)', fontSize: '0.875rem' }}>{subtitle}</span>}
</button>
{open && <div style={{ padding: '0 var(--spacing-md) var(--spacing-md)' }}>{children}</div>}
</section>
)
}
Loading
Loading