diff --git a/core/http/react-ui/src/components/ClusterSection.jsx b/core/http/react-ui/src/components/ClusterSection.jsx
new file mode 100644
index 000000000..9ff7ce1d7
--- /dev/null
+++ b/core/http/react-ui/src/components/ClusterSection.jsx
@@ -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 (
+
+ 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',
+ }}
+ >
+
+ {icon && }
+ {title}
+ {subtitle && {subtitle} }
+
+ {open && {children}
}
+
+ )
+}
diff --git a/core/http/react-ui/src/components/ClusterSummary.jsx b/core/http/react-ui/src/components/ClusterSummary.jsx
new file mode 100644
index 000000000..0bd78b3cd
--- /dev/null
+++ b/core/http/react-ui/src/components/ClusterSummary.jsx
@@ -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 (
+
+ {distributedEnabled && }
+ {distributedEnabled && }
+ {p2pEnabled && }
+
+ )
+}
diff --git a/core/http/react-ui/src/pages/Cluster.jsx b/core/http/react-ui/src/pages/Cluster.jsx
new file mode 100644
index 000000000..3e87ddd36
--- /dev/null
+++ b/core/http/react-ui/src/pages/Cluster.jsx
@@ -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 (
+
+
+
{t('cluster.title', 'Cluster')}
+
{t('cluster.subtitle', 'Distributed and peer-to-peer nodes serving this instance')}
+
+
+ {!loading &&
}
+
+ {distributed.enabled && (
+
+
+
+ )}
+
+ {p2p.enabled && (
+
+
+
+ )}
+
+ {nothingEnabled && (
+
+ {t('cluster.empty', 'No distributed or p2p clustering is enabled. Start LocalAI in distributed or federated/p2p mode to manage cluster nodes here.')}
+
+ )}
+
+ )
+}