Skip to content

Commit 7bd1c3a

Browse files
mudlerlocalai-org-maint-bot
authored andcommitted
feat(ui): add Cluster page composing distributed and swarm sections
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
1 parent 821dc72 commit 7bd1c3a

3 files changed

Lines changed: 116 additions & 0 deletions

File tree

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { useState } from 'react'
2+
3+
// ClusterSection is a collapsible, titled container for one capability area of
4+
// the Cluster page (Distributed / Swarm). Default expanded.
5+
export default function ClusterSection({ icon, title, subtitle, defaultOpen = true, children }) {
6+
const [open, setOpen] = useState(defaultOpen)
7+
return (
8+
<section className="card" style={{ marginBottom: 'var(--spacing-lg)' }}>
9+
<button
10+
type="button"
11+
aria-expanded={open}
12+
onClick={() => setOpen((o) => !o)}
13+
style={{
14+
display: 'flex', alignItems: 'center', gap: 'var(--spacing-sm)',
15+
width: '100%', padding: 'var(--spacing-md)', background: 'none',
16+
border: 'none', cursor: 'pointer', textAlign: 'left', color: 'inherit',
17+
}}
18+
>
19+
<i className={`fas fa-chevron-${open ? 'down' : 'right'}`} style={{ width: '1rem', color: 'var(--color-text-muted)' }} />
20+
{icon && <i className={icon} style={{ color: 'var(--color-primary)' }} />}
21+
<span style={{ fontWeight: 600 }}>{title}</span>
22+
{subtitle && <span style={{ marginLeft: 'auto', color: 'var(--color-text-muted)', fontSize: '0.875rem' }}>{subtitle}</span>}
23+
</button>
24+
{open && <div style={{ padding: '0 var(--spacing-md) var(--spacing-md)' }}>{children}</div>}
25+
</section>
26+
)
27+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { useEffect, useState } from 'react'
2+
import { useTranslation } from 'react-i18next'
3+
import StatCard from './StatCard'
4+
import { nodesApi, p2pApi } from '../utils/api'
5+
6+
// ClusterSummary shows merged totals across both transports. Self-contained
7+
// (own lightweight fetch) so the page composes without lifting state out of the
8+
// two large section components.
9+
export default function ClusterSummary({ distributedEnabled, p2pEnabled }) {
10+
const { t } = useTranslation('admin')
11+
const [nats, setNats] = useState({ nodes: 0, inFlight: 0 })
12+
const [swarm, setSwarm] = useState({ online: 0, total: 0 })
13+
14+
useEffect(() => {
15+
let active = true
16+
async function load() {
17+
if (distributedEnabled) {
18+
try {
19+
const list = await nodesApi.list()
20+
const nodes = Array.isArray(list) ? list : (list?.nodes ?? [])
21+
if (active) setNats({ nodes: nodes.length, inFlight: nodes.reduce((a, n) => a + (n.in_flight_count || 0), 0) })
22+
} catch { /* leave zeros */ }
23+
}
24+
if (p2pEnabled) {
25+
try {
26+
const stats = await p2pApi.getStats()
27+
const online = (stats?.federated?.online || 0) + (stats?.llama_cpp_workers?.online || 0) + (stats?.mlx_workers?.online || 0)
28+
const total = (stats?.federated?.total || 0) + (stats?.llama_cpp_workers?.total || 0) + (stats?.mlx_workers?.total || 0)
29+
if (active) setSwarm({ online, total })
30+
} catch { /* leave zeros */ }
31+
}
32+
}
33+
load()
34+
return () => { active = false }
35+
}, [distributedEnabled, p2pEnabled])
36+
37+
return (
38+
<div className="stat-grid" style={{ marginBottom: 'var(--spacing-lg)' }}>
39+
{distributedEnabled && <StatCard icon="fas fa-network-wired" label={t('cluster.summary.nodes', 'Distributed nodes')} value={nats.nodes} />}
40+
{distributedEnabled && <StatCard icon="fas fa-bolt" label={t('cluster.summary.inFlight', 'In-flight requests')} value={nats.inFlight} />}
41+
{p2pEnabled && <StatCard icon="fas fa-circle-nodes" label={t('cluster.summary.peers', 'Swarm peers online')} value={`${swarm.online}/${swarm.total}`} />}
42+
</div>
43+
)
44+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { useTranslation } from 'react-i18next'
2+
import { useDistributedMode } from '../hooks/useDistributedMode'
3+
import { useP2PMode } from '../hooks/useP2PMode'
4+
import ClusterSection from '../components/ClusterSection'
5+
import ClusterSummary from '../components/ClusterSummary'
6+
import Nodes from './Nodes'
7+
import P2P from './P2P'
8+
9+
export default function Cluster() {
10+
const { t } = useTranslation('admin')
11+
const distributed = useDistributedMode()
12+
const p2p = useP2PMode()
13+
14+
const loading = distributed.loading || p2p.loading
15+
const nothingEnabled = !loading && !distributed.enabled && !p2p.enabled
16+
17+
return (
18+
<div className="page page--wide">
19+
<div className="page-header">
20+
<h1 className="page-title"><i className="fas fa-network-wired" /> {t('cluster.title', 'Cluster')}</h1>
21+
<p className="page-subtitle">{t('cluster.subtitle', 'Distributed and peer-to-peer nodes serving this instance')}</p>
22+
</div>
23+
24+
{!loading && <ClusterSummary distributedEnabled={distributed.enabled} p2pEnabled={p2p.enabled} />}
25+
26+
{distributed.enabled && (
27+
<ClusterSection icon="fas fa-network-wired" title={t('cluster.distributed.title', 'Distributed (NATS)')} defaultOpen>
28+
<Nodes embedded />
29+
</ClusterSection>
30+
)}
31+
32+
{p2p.enabled && (
33+
<ClusterSection icon="fas fa-circle-nodes" title={t('cluster.swarm.title', 'Swarm (p2p)')} defaultOpen={!distributed.enabled}>
34+
<P2P embedded />
35+
</ClusterSection>
36+
)}
37+
38+
{nothingEnabled && (
39+
<div className="card" style={{ padding: 'var(--spacing-lg)', textAlign: 'center', color: 'var(--color-text-muted)' }}>
40+
{t('cluster.empty', 'No distributed or p2p clustering is enabled. Start LocalAI in distributed or federated/p2p mode to manage cluster nodes here.')}
41+
</div>
42+
)}
43+
</div>
44+
)
45+
}

0 commit comments

Comments
 (0)