feat(ui): add Cluster page composing distributed and swarm sections

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
Ettore Di Giacinto
2026-06-01 23:07:11 +00:00
parent bc42374d8a
commit a0c7cecddd
3 changed files with 116 additions and 0 deletions

View File

@@ -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>
)
}

View File

@@ -0,0 +1,44 @@
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import StatCard from './StatCard'
import { nodesApi, p2pApi } from '../utils/api'
// ClusterSummary shows merged totals across both transports. Self-contained
// (own lightweight fetch) so the page composes without lifting state out of the
// two large section components.
export default function ClusterSummary({ distributedEnabled, p2pEnabled }) {
const { t } = useTranslation('admin')
const [nats, setNats] = useState({ nodes: 0, inFlight: 0 })
const [swarm, setSwarm] = useState({ online: 0, total: 0 })
useEffect(() => {
let active = true
async function load() {
if (distributedEnabled) {
try {
const list = await nodesApi.list()
const nodes = Array.isArray(list) ? list : (list?.nodes ?? [])
if (active) setNats({ nodes: nodes.length, inFlight: nodes.reduce((a, n) => a + (n.in_flight_count || 0), 0) })
} catch { /* leave zeros */ }
}
if (p2pEnabled) {
try {
const stats = await p2pApi.getStats()
const online = (stats?.federated?.online || 0) + (stats?.llama_cpp_workers?.online || 0) + (stats?.mlx_workers?.online || 0)
const total = (stats?.federated?.total || 0) + (stats?.llama_cpp_workers?.total || 0) + (stats?.mlx_workers?.total || 0)
if (active) setSwarm({ online, total })
} catch { /* leave zeros */ }
}
}
load()
return () => { active = false }
}, [distributedEnabled, p2pEnabled])
return (
<div className="stat-grid" style={{ marginBottom: 'var(--spacing-lg)' }}>
{distributedEnabled && <StatCard icon="fas fa-network-wired" label={t('cluster.summary.nodes', 'Distributed nodes')} value={nats.nodes} />}
{distributedEnabled && <StatCard icon="fas fa-bolt" label={t('cluster.summary.inFlight', 'In-flight requests')} value={nats.inFlight} />}
{p2pEnabled && <StatCard icon="fas fa-circle-nodes" label={t('cluster.summary.peers', 'Swarm peers online')} value={`${swarm.online}/${swarm.total}`} />}
</div>
)
}

View File

@@ -0,0 +1,45 @@
import { useTranslation } from 'react-i18next'
import { useDistributedMode } from '../hooks/useDistributedMode'
import { useP2PMode } from '../hooks/useP2PMode'
import ClusterSection from '../components/ClusterSection'
import ClusterSummary from '../components/ClusterSummary'
import Nodes from './Nodes'
import P2P from './P2P'
export default function Cluster() {
const { t } = useTranslation('admin')
const distributed = useDistributedMode()
const p2p = useP2PMode()
const loading = distributed.loading || p2p.loading
const nothingEnabled = !loading && !distributed.enabled && !p2p.enabled
return (
<div className="page page--wide">
<div className="page-header">
<h1 className="page-title"><i className="fas fa-network-wired" /> {t('cluster.title', 'Cluster')}</h1>
<p className="page-subtitle">{t('cluster.subtitle', 'Distributed and peer-to-peer nodes serving this instance')}</p>
</div>
{!loading && <ClusterSummary distributedEnabled={distributed.enabled} p2pEnabled={p2p.enabled} />}
{distributed.enabled && (
<ClusterSection icon="fas fa-network-wired" title={t('cluster.distributed.title', 'Distributed (NATS)')} defaultOpen>
<Nodes embedded />
</ClusterSection>
)}
{p2p.enabled && (
<ClusterSection icon="fas fa-circle-nodes" title={t('cluster.swarm.title', 'Swarm (p2p)')} defaultOpen={!distributed.enabled}>
<P2P embedded />
</ClusterSection>
)}
{nothingEnabled && (
<div className="card" style={{ padding: 'var(--spacing-lg)', textAlign: 'center', color: 'var(--color-text-muted)' }}>
{t('cluster.empty', 'No distributed or p2p clustering is enabled. Start LocalAI in distributed or federated/p2p mode to manage cluster nodes here.')}
</div>
)}
</div>
)
}