diff --git a/webui/src/App.jsx b/webui/src/App.jsx index 4df479f..b734d80 100644 --- a/webui/src/App.jsx +++ b/webui/src/App.jsx @@ -20,8 +20,13 @@ import { AlertTriangle, User, History, + LayoutDashboard, + Lock, + Globe, + Server as ServerIcon, + SlidersHorizontal, } from 'lucide-react'; -import { healthAPI, cellAPI, servicesAPI } from './services/api'; +import { healthAPI, cellAPI, servicesAPI, connectivityAPI, storeAPI } from './services/api'; import { ConfigProvider } from './contexts/ConfigContext'; import { DraftConfigProvider, useDraftConfig } from './contexts/DraftConfigContext'; import { AuthProvider, useAuth } from './contexts/AuthContext'; @@ -36,7 +41,6 @@ import Logs from './pages/Logs'; import Settings from './pages/Settings'; import Vault from './pages/Vault'; import ContainerDashboard from './components/ContainerDashboard'; -import CellNetwork from './pages/CellNetwork'; import Login from './pages/Login'; import AccountSettings from './pages/AccountSettings'; import PeerDashboard from './pages/PeerDashboard'; @@ -45,7 +49,11 @@ import ServicesIndex from './pages/ServicesIndex'; import EmailPage from './pages/services/EmailPage'; import CalendarPage from './pages/services/CalendarPage'; import FilesPage from './pages/services/FilesPage'; -import Connectivity from './pages/Connectivity'; +import ConnectivityOverview from './pages/connectivity/ConnectivityOverview'; +import ConnectionListPage from './pages/connectivity/ConnectionListPage'; +import ConnectionForm from './pages/connectivity/ConnectionForm'; +import AssignmentsPage from './pages/connectivity/AssignmentsPage'; +import CellsPage from './pages/connectivity/CellsPage'; import ActivityPage from './pages/Activity'; import Setup from './pages/Setup'; import SetupGuard from './components/SetupGuard'; @@ -238,6 +246,8 @@ function AppCore() { window.dispatchEvent(new CustomEvent('pic-config-discarded')); }, [clearAllDirty]); + const { user } = useAuth(); + const [activeServiceChildren, setActiveServiceChildren] = useState([]); const fetchActiveServices = useCallback(async () => { @@ -258,6 +268,61 @@ function AppCore() { return () => window.removeEventListener('pic-services-changed', fetchActiveServices); }, [fetchActiveServices]); + // Connectivity sidebar children. Overview, Cells and Assignments always show; + // Tunnels/Proxies/SSH/Tor are hidden when they have zero instances AND their + // backing store service is not installed. + const [connectivityChildren, setConnectivityChildren] = useState([ + { name: 'Overview', href: '/connectivity', icon: LayoutDashboard }, + { name: 'Cells', href: '/connectivity/cells', icon: Link2 }, + { name: 'Assignments', href: '/connectivity/assignments', icon: SlidersHorizontal }, + ]); + + const fetchConnectivityChildren = useCallback(async () => { + if (user?.role && user.role !== 'admin') return; + try { + const [connRes, storeRes] = await Promise.all([ + connectivityAPI.listConnections().catch(() => ({ data: {} })), + storeAPI.listInstalled().catch(() => ({ data: {} })), + ]); + const conns = connRes.data?.connections ?? connRes.data ?? []; + const counts = {}; + (Array.isArray(conns) ? conns : []).forEach((c) => { + counts[c.type] = (counts[c.type] || 0) + 1; + }); + const installedRaw = storeRes.data?.installed ?? storeRes.data ?? {}; + const installed = installedRaw && typeof installedRaw === 'object' && !Array.isArray(installedRaw) + ? installedRaw : {}; + + const groupDefs = [ + { name: 'Tunnels', href: '/connectivity/tunnels', icon: Shield, types: ['wireguard_ext', 'openvpn'], services: ['wireguard-ext', 'openvpn'] }, + { name: 'Proxies', href: '/connectivity/proxies', icon: Globe, types: ['proxy'], services: ['redsocks'] }, + { name: 'SSH', href: '/connectivity/ssh', icon: ServerIcon, types: ['sshuttle'], services: ['sshuttle'] }, + { name: 'Tor', href: '/connectivity/tor', icon: Lock, types: ['tor'], services: ['tor'] }, + ]; + + const visibleGroups = groupDefs.filter((g) => { + const hasInstance = g.types.some((t) => (counts[t] || 0) > 0); + const svcInstalled = g.services.some((s) => installed[s]); + return hasInstance || svcInstalled; + }).map(({ name, href, icon }) => ({ name, href, icon })); + + setConnectivityChildren([ + { name: 'Overview', href: '/connectivity', icon: LayoutDashboard }, + ...visibleGroups, + { name: 'Cells', href: '/connectivity/cells', icon: Link2 }, + { name: 'Assignments', href: '/connectivity/assignments', icon: SlidersHorizontal }, + ]); + } catch { + // keep the always-visible defaults on failure + } + }, [user?.role]); + + useEffect(() => { + fetchConnectivityChildren(); + window.addEventListener('pic-services-changed', fetchConnectivityChildren); + return () => window.removeEventListener('pic-services-changed', fetchConnectivityChildren); + }, [fetchConnectivityChildren]); + const adminNavigation = [ { name: 'Dashboard', href: '/', icon: Home }, { name: 'Peers', href: '/peers', icon: Users }, @@ -267,8 +332,7 @@ function AppCore() { { name: 'Routing', href: '/routing', icon: Wifi }, { name: 'Vault', href: '/vault', icon: Key }, { name: 'Containers', href: '/containers', icon: Package2 }, - { name: 'Cell Network', href: '/cell-network', icon: Link2 }, - { name: 'Connectivity', href: '/connectivity', icon: Network }, + { name: 'Connectivity', href: '/connectivity', icon: Network, children: connectivityChildren }, { name: 'Logs', href: '/logs', icon: Activity }, { name: 'Activity', href: '/activity', icon: History }, { name: 'Settings', href: '/settings', icon: SettingsIcon }, @@ -282,7 +346,6 @@ function AppCore() { { name: 'Account', href: '/account', icon: User }, ]; - const { user } = useAuth(); const navigation = user?.role === 'peer' ? peerNavigation : adminNavigation; if (isLoading) { @@ -382,8 +445,18 @@ function AppCore() { } /> } /> } /> - } /> - } /> + {/* Connectivity area — Services-style subpages */} + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + {/* Legacy redirect */} + } /> } /> } /> } /> diff --git a/webui/src/pages/Connectivity.jsx b/webui/src/pages/Connectivity.jsx deleted file mode 100644 index 669eeb7..0000000 --- a/webui/src/pages/Connectivity.jsx +++ /dev/null @@ -1,1316 +0,0 @@ -import { useState, useEffect, useCallback } from 'react'; -import { - Shield, - Lock, - Globe, - RefreshCw, - CheckCircle, - AlertCircle, - ChevronDown, - Upload, - ToggleLeft, - ToggleRight, - Layers, - Store, - Server, - Network, - Save, -} from 'lucide-react'; -import { connectivityAPI, egressAPI } from '../services/api'; - -// ── Toast helpers (same pattern as Store.jsx) ───────────────────────────────── - -function toastEvent(msg, type = 'success') { - window.dispatchEvent( - new CustomEvent('connectivity-toast', { detail: { msg, type } }) - ); -} - -function Toast({ toasts }) { - return ( -
- {toasts.map((t) => ( -
- {t.type === 'success' ? ( - - ) : ( - - )} - {t.msg} -
- ))} -
- ); -} - -function useToasts() { - const [toasts, setToasts] = useState([]); - useEffect(() => { - const handler = (e) => { - const id = Date.now(); - setToasts((prev) => [...prev, { ...e.detail, id }]); - setTimeout( - () => setToasts((prev) => prev.filter((t) => t.id !== id)), - 3000 - ); - }; - window.addEventListener('connectivity-toast', handler); - return () => window.removeEventListener('connectivity-toast', handler); - }, []); - return toasts; -} - -// ── Status badge ────────────────────────────────────────────────────────────── - -function StatusBadge({ status }) { - if (status === 'active') { - return ( - - - Active - - ); - } - if (status === 'configured') { - return ( - - - Configured - - ); - } - if (status === 'error') { - return ( - - - Error - - ); - } - // not configured - return ( - - Not configured - - ); -} - -// ── WireGuard External card ─────────────────────────────────────────────────── - -function WireguardExitCard({ exitInfo, onUploaded }) { - const [confText, setConfText] = useState(''); - const [uploading, setUploading] = useState(false); - const status = exitInfo?.status || 'not_configured'; - - const handleUpload = async () => { - if (!confText.trim()) return; - setUploading(true); - try { - await connectivityAPI.uploadWireguard(confText.trim()); - toastEvent('WireGuard config uploaded'); - setConfText(''); - onUploaded(); - } catch (err) { - const msg = - err.response?.data?.error || - err.response?.data?.message || - 'Failed to upload WireGuard config'; - toastEvent(msg, 'error'); - } finally { - setUploading(false); - } - }; - - return ( -
-
-
-
- -
-
-

WireGuard External

-

- Route traffic through an external WireGuard VPN tunnel -

-
-
- -
- -
- -