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 ( +
+ + {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.')} +
+ )} +
+ ) +}