feat: connectivity redesign phase 6 — subpages UI, assignment matrix, Cell Network merge
Replace the monolithic Connectivity page with Services-style subpages: overview dashboard (aggregated status), per-type connection lists (tunnels/ proxies/ssh/tor) with add/edit forms + lifecycle/health badges + empty states, a peer+service assignment matrix with per-peer fail-open toggle, and Cell Network moved under /connectivity/cells. Sidebar gains Connectivity children, hidden when a type has no instances and its store service isn't installed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+81
-8
@@ -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() {
|
||||
<Route path="/routing" element={<PrivateRoute requireRole="admin"><Routing /></PrivateRoute>} />
|
||||
<Route path="/vault" element={<PrivateRoute requireRole="admin"><Vault /></PrivateRoute>} />
|
||||
<Route path="/containers" element={<PrivateRoute requireRole="admin"><ContainerDashboard /></PrivateRoute>} />
|
||||
<Route path="/cell-network" element={<PrivateRoute requireRole="admin"><CellNetwork /></PrivateRoute>} />
|
||||
<Route path="/connectivity" element={<PrivateRoute requireRole="admin"><Connectivity /></PrivateRoute>} />
|
||||
{/* Connectivity area — Services-style subpages */}
|
||||
<Route path="/connectivity" element={<PrivateRoute requireRole="admin"><ConnectivityOverview /></PrivateRoute>} />
|
||||
<Route path="/connectivity/tunnels" element={<PrivateRoute requireRole="admin"><ConnectionListPage group="tunnels" /></PrivateRoute>} />
|
||||
<Route path="/connectivity/proxies" element={<PrivateRoute requireRole="admin"><ConnectionListPage group="proxies" /></PrivateRoute>} />
|
||||
<Route path="/connectivity/ssh" element={<PrivateRoute requireRole="admin"><ConnectionListPage group="ssh" /></PrivateRoute>} />
|
||||
<Route path="/connectivity/tor" element={<PrivateRoute requireRole="admin"><ConnectionListPage group="tor" /></PrivateRoute>} />
|
||||
<Route path="/connectivity/cells" element={<PrivateRoute requireRole="admin"><CellsPage /></PrivateRoute>} />
|
||||
<Route path="/connectivity/assignments" element={<PrivateRoute requireRole="admin"><AssignmentsPage /></PrivateRoute>} />
|
||||
<Route path="/connectivity/:group/new" element={<PrivateRoute requireRole="admin"><ConnectionForm /></PrivateRoute>} />
|
||||
<Route path="/connectivity/:group/:id" element={<PrivateRoute requireRole="admin"><ConnectionForm /></PrivateRoute>} />
|
||||
{/* Legacy redirect */}
|
||||
<Route path="/cell-network" element={<Navigate to="/connectivity/cells" replace />} />
|
||||
<Route path="/logs" element={<PrivateRoute requireRole="admin"><Logs /></PrivateRoute>} />
|
||||
<Route path="/activity" element={<PrivateRoute requireRole="admin"><ActivityPage /></PrivateRoute>} />
|
||||
<Route path="/settings" element={<PrivateRoute requireRole="admin"><Settings /></PrivateRoute>} />
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,286 @@
|
||||
import { useState } from 'react';
|
||||
import { ChevronDown, RefreshCw, AlertCircle, Users, Layers, Info } from 'lucide-react';
|
||||
import { connectivityAPI, egressAPI } from '../../services/api';
|
||||
import { useConnectivityData } from './useConnectivityData';
|
||||
import {
|
||||
Toast, useToasts, toastEvent, apiError, typeMeta, GROUP_LABELS, GROUP_TYPES,
|
||||
} from './shared';
|
||||
|
||||
// Build the <optgroup>ed option list once: "Direct", then named connection
|
||||
// instances grouped by type, then any cell-relay placeholders.
|
||||
function ConnectionSelect({ value, options, onChange, disabled, ariaLabel }) {
|
||||
return (
|
||||
<div className="relative inline-block">
|
||||
<select
|
||||
value={value || 'default'}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
aria-label={ariaLabel}
|
||||
className="appearance-none bg-white border border-gray-300 text-sm text-gray-800 rounded-md pl-3 pr-8 py-1.5 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 disabled:opacity-50"
|
||||
>
|
||||
<option value="default">Direct (no tunnel)</option>
|
||||
{options.groups.map((g) => (
|
||||
<optgroup key={g.label} label={g.label}>
|
||||
{g.items.map((it) => (
|
||||
<option key={it.id} value={it.id} disabled={it.disabled}>
|
||||
{it.label}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown className="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FailopenControl({ value, onChange, saving }) {
|
||||
// value: true | false | null (null = type default)
|
||||
const current = value === null || value === undefined ? 'default' : value ? 'on' : 'off';
|
||||
return (
|
||||
<div className="relative inline-flex items-center gap-1.5">
|
||||
<div className="relative inline-block">
|
||||
<select
|
||||
value={current}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
onChange(v === 'default' ? null : v === 'on');
|
||||
}}
|
||||
disabled={saving}
|
||||
aria-label="Fail-open behaviour"
|
||||
className="appearance-none bg-white border border-gray-300 text-sm text-gray-700 rounded-md pl-3 pr-7 py-1.5 focus:outline-none focus:ring-2 focus:ring-primary-500 disabled:opacity-50"
|
||||
>
|
||||
<option value="default">Default</option>
|
||||
<option value="on">Fail-open</option>
|
||||
<option value="off">Fail-closed</option>
|
||||
</select>
|
||||
<ChevronDown className="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-gray-400" />
|
||||
</div>
|
||||
<span
|
||||
className="text-gray-300 hover:text-gray-500 cursor-help"
|
||||
title="Fail-open: when the assigned tunnel is down, this peer's traffic falls back to a direct route. Fail-closed: traffic is blocked while the tunnel is down. Default uses the tunnel type's built-in behaviour."
|
||||
>
|
||||
<Info className="h-4 w-4" />
|
||||
</span>
|
||||
{saving && <RefreshCw className="h-3.5 w-3.5 animate-spin text-gray-400" />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AssignmentsPage() {
|
||||
const toasts = useToasts();
|
||||
const {
|
||||
connections, peerExits, peerFailopen, serviceEgress, installed, peers, cells,
|
||||
loading, error, reload, setPeerExits, setPeerFailopen, setServiceEgress,
|
||||
} = useConnectivityData();
|
||||
|
||||
const [savingPeer, setSavingPeer] = useState({});
|
||||
const [savingFailopen, setSavingFailopen] = useState({});
|
||||
const [savingService, setSavingService] = useState({});
|
||||
|
||||
// Build grouped options from connection instances + cell-relay placeholders.
|
||||
const options = (() => {
|
||||
const groups = [];
|
||||
Object.keys(GROUP_TYPES).forEach((group) => {
|
||||
const items = connections
|
||||
.filter((c) => GROUP_TYPES[group].includes(c.type))
|
||||
.map((c) => ({ id: c.id, label: `${c.name} (${typeMeta(c.type).short})` }));
|
||||
if (items.length) groups.push({ label: GROUP_LABELS[group], items });
|
||||
});
|
||||
// Cell-relay: remote cells that offer their internet. Backend wiring for
|
||||
// cell-relay exits lands in P7; surface them disabled so the option is
|
||||
// discoverable without breaking assignment.
|
||||
const relayItems = (cells || [])
|
||||
.filter((c) => c.remote_exit_offered || c.exit_offered)
|
||||
.map((c) => ({
|
||||
id: `cell:${c.cell_name}`,
|
||||
label: `${c.cell_name} (cell relay — coming soon)`,
|
||||
disabled: true,
|
||||
}));
|
||||
if (relayItems.length) groups.push({ label: 'Cell relay', items: relayItems });
|
||||
return { groups };
|
||||
})();
|
||||
|
||||
const setPeer = async (name, connectionId) => {
|
||||
setSavingPeer((s) => ({ ...s, [name]: true }));
|
||||
const prev = peerExits[name];
|
||||
setPeerExits((p) => ({ ...p, [name]: connectionId }));
|
||||
try {
|
||||
await connectivityAPI.setPeerExit(name, connectionId);
|
||||
toastEvent(`Updated exit for ${name}`);
|
||||
} catch (err) {
|
||||
setPeerExits((p) => ({ ...p, [name]: prev }));
|
||||
toastEvent(apiError(err, `Failed to update exit for ${name}`), 'error');
|
||||
} finally {
|
||||
setSavingPeer((s) => ({ ...s, [name]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const setFailopen = async (name, failopen) => {
|
||||
setSavingFailopen((s) => ({ ...s, [name]: true }));
|
||||
const prev = peerFailopen[name];
|
||||
setPeerFailopen((p) => ({ ...p, [name]: failopen }));
|
||||
try {
|
||||
await connectivityAPI.setPeerFailopen(name, failopen);
|
||||
toastEvent(`Updated fail-open for ${name}`);
|
||||
} catch (err) {
|
||||
setPeerFailopen((p) => ({ ...p, [name]: prev }));
|
||||
toastEvent(apiError(err, `Failed to update fail-open for ${name}`), 'error');
|
||||
} finally {
|
||||
setSavingFailopen((s) => ({ ...s, [name]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const setService = async (svcId, connectionId) => {
|
||||
setSavingService((s) => ({ ...s, [svcId]: true }));
|
||||
const prev = serviceEgress[svcId]?.exit_via;
|
||||
setServiceEgress((p) => ({ ...p, [svcId]: { ...p[svcId], exit_via: connectionId } }));
|
||||
try {
|
||||
await egressAPI.setServiceExit(svcId, connectionId);
|
||||
toastEvent(`Updated egress for ${svcId}`);
|
||||
} catch (err) {
|
||||
setServiceEgress((p) => ({ ...p, [svcId]: { ...p[svcId], exit_via: prev } }));
|
||||
toastEvent(apiError(err, `Failed to update egress for ${svcId}`), 'error');
|
||||
} finally {
|
||||
setSavingService((s) => ({ ...s, [svcId]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const serviceIds = Object.keys(serviceEgress);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Toast toasts={toasts} />
|
||||
|
||||
<div className="mb-6 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Assignments</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Route each peer and service through a named connection, or keep it direct
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={reload} disabled={loading} className="btn-secondary flex items-center gap-2 text-sm shrink-0">
|
||||
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-6 animate-pulse space-y-3">
|
||||
<div className="h-4 bg-gray-200 rounded w-1/3" />
|
||||
<div className="h-4 bg-gray-100 rounded w-1/2" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && error && (
|
||||
<div className="bg-red-50 rounded-lg border border-red-200 p-4 flex items-start gap-3">
|
||||
<AlertCircle className="h-5 w-5 text-red-500 mt-0.5 shrink-0" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-red-800">Failed to load assignments</p>
|
||||
<p className="text-sm text-red-600 mt-1">{error}</p>
|
||||
</div>
|
||||
<button onClick={reload} className="btn-secondary text-sm shrink-0">Retry</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && (
|
||||
<div className="space-y-8">
|
||||
{/* ── Peers ───────────────────────────────────────────────────── */}
|
||||
<section>
|
||||
<h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3 flex items-center gap-2">
|
||||
<Users className="h-4 w-4" /> Peers
|
||||
</h2>
|
||||
{peers.length === 0 ? (
|
||||
<div className="bg-white rounded-lg border border-gray-200 py-10 text-center">
|
||||
<p className="text-sm font-medium text-gray-500">No WireGuard peers</p>
|
||||
<p className="text-xs text-gray-400 mt-1">Add peers on the WireGuard page first.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-lg border border-gray-200 overflow-x-auto">
|
||||
<table className="min-w-full">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 border-b border-gray-200">
|
||||
<th className="py-3 px-4 text-left text-xs font-semibold text-gray-500 uppercase tracking-wide">Peer</th>
|
||||
<th className="py-3 px-4 text-left text-xs font-semibold text-gray-500 uppercase tracking-wide">Exit connection</th>
|
||||
<th className="py-3 px-4 text-left text-xs font-semibold text-gray-500 uppercase tracking-wide">Fail-open</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{peers.map((p) => (
|
||||
<tr key={p.name} className="border-t border-gray-100">
|
||||
<td className="py-3 px-4 text-sm font-medium text-gray-900 truncate max-w-[200px]">{p.name}</td>
|
||||
<td className="py-3 px-4">
|
||||
<ConnectionSelect
|
||||
value={peerExits[p.name]}
|
||||
options={options}
|
||||
disabled={savingPeer[p.name]}
|
||||
ariaLabel={`Exit for ${p.name}`}
|
||||
onChange={(v) => setPeer(p.name, v)}
|
||||
/>
|
||||
{savingPeer[p.name] && <RefreshCw className="inline h-3.5 w-3.5 animate-spin text-gray-400 ml-2" />}
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<FailopenControl
|
||||
value={peerFailopen[p.name]}
|
||||
saving={savingFailopen[p.name]}
|
||||
onChange={(v) => setFailopen(p.name, v)}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* ── Services ────────────────────────────────────────────────── */}
|
||||
<section>
|
||||
<h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3 flex items-center gap-2">
|
||||
<Layers className="h-4 w-4" /> Services
|
||||
</h2>
|
||||
{serviceIds.length === 0 ? (
|
||||
<div className="bg-white rounded-lg border border-gray-200 py-10 text-center">
|
||||
<p className="text-sm font-medium text-gray-500">No services with egress support</p>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
Install a service that declares egress support from the{' '}
|
||||
<a href="/services" className="text-primary-600 hover:underline">Store</a>.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-lg border border-gray-200 overflow-x-auto">
|
||||
<table className="min-w-full">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 border-b border-gray-200">
|
||||
<th className="py-3 px-4 text-left text-xs font-semibold text-gray-500 uppercase tracking-wide">Service</th>
|
||||
<th className="py-3 px-4 text-left text-xs font-semibold text-gray-500 uppercase tracking-wide">Exit connection</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{serviceIds.map((svcId) => (
|
||||
<tr key={svcId} className="border-t border-gray-100">
|
||||
<td className="py-3 px-4 text-sm font-medium text-gray-900">
|
||||
{installed[svcId]?.name || svcId}
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<ConnectionSelect
|
||||
value={serviceEgress[svcId]?.exit_via}
|
||||
options={options}
|
||||
disabled={savingService[svcId]}
|
||||
ariaLabel={`Egress for ${svcId}`}
|
||||
onChange={(v) => setService(svcId, v)}
|
||||
/>
|
||||
{savingService[svcId] && <RefreshCw className="inline h-3.5 w-3.5 animate-spin text-gray-400 ml-2" />}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import CellNetwork from '../CellNetwork';
|
||||
|
||||
// Cell Network now lives under the Connectivity area. The page content is
|
||||
// unchanged — it is re-exported here so the route tree mirrors the Services
|
||||
// pattern without duplicating the (substantial) CellNetwork implementation.
|
||||
export default function CellsPage() {
|
||||
return <CellNetwork />;
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||
import { ChevronDown, RefreshCw, Save, ArrowLeft, AlertCircle, KeyRound } from 'lucide-react';
|
||||
import { connectivityAPI } from '../../services/api';
|
||||
import {
|
||||
Toast, useToasts, toastEvent, apiError, typeMeta,
|
||||
GROUP_TYPES, TypeIcon,
|
||||
} from './shared';
|
||||
|
||||
const inputCls =
|
||||
'w-full rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-800 placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500';
|
||||
const monoCls =
|
||||
'w-full rounded-md border border-gray-300 px-3 py-2 font-mono text-xs text-gray-800 placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 resize-y';
|
||||
|
||||
function FieldRow({ label, required, hint, children, error }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-sm font-medium text-gray-700">
|
||||
{label}
|
||||
{required && <span className="text-red-500" aria-hidden="true"> *</span>}
|
||||
</label>
|
||||
{children}
|
||||
{error && <p className="text-xs text-red-600">{error}</p>}
|
||||
{hint && !error && <p className="text-xs text-gray-400">{hint}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// A write-only secret control: on a new connection it's a plain editable field;
|
||||
// on edit it shows "•••• set" with a Replace button that reveals the input.
|
||||
function SecretField({ label, required, hint, isEdit, hasExisting, value, onChange, multiline, placeholder }) {
|
||||
const [replacing, setReplacing] = useState(!isEdit || !hasExisting);
|
||||
if (isEdit && hasExisting && !replacing) {
|
||||
return (
|
||||
<FieldRow label={label} required={required} hint={hint}>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="inline-flex items-center gap-1.5 text-sm text-gray-500 bg-gray-50 border border-gray-200 rounded-md px-3 py-2">
|
||||
<KeyRound className="h-4 w-4 text-gray-400" />
|
||||
•••• set
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setReplacing(true); }}
|
||||
className="text-sm font-medium text-primary-600 hover:text-primary-700"
|
||||
>
|
||||
Replace
|
||||
</button>
|
||||
</div>
|
||||
</FieldRow>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<FieldRow label={label} required={required && !isEdit} hint={hint}>
|
||||
{multiline ? (
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
rows={6}
|
||||
autoComplete="off"
|
||||
spellCheck="false"
|
||||
placeholder={placeholder}
|
||||
className={monoCls}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
type="password"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
placeholder={placeholder}
|
||||
className={inputCls}
|
||||
/>
|
||||
)}
|
||||
{isEdit && hasExisting && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { onChange(''); setReplacing(false); }}
|
||||
className="text-xs text-gray-500 hover:text-gray-700 self-start"
|
||||
>
|
||||
Keep existing secret
|
||||
</button>
|
||||
)}
|
||||
</FieldRow>
|
||||
);
|
||||
}
|
||||
|
||||
const EMPTY = {
|
||||
name: '',
|
||||
// sshuttle
|
||||
host: '', port: '', user: '', auth: 'key', private_key: '', password: '', known_hosts: '',
|
||||
// proxy
|
||||
scheme: 'socks5', proxy_user: '', proxy_password: '',
|
||||
// wireguard / openvpn
|
||||
conf: '',
|
||||
};
|
||||
|
||||
export default function ConnectionForm() {
|
||||
const toasts = useToasts();
|
||||
const navigate = useNavigate();
|
||||
const { group, id } = useParams();
|
||||
const isEdit = id && id !== 'new';
|
||||
|
||||
const groupTypes = GROUP_TYPES[group] || [];
|
||||
const [type, setType] = useState(groupTypes[0] || 'wireguard_ext');
|
||||
const [form, setForm] = useState(EMPTY);
|
||||
const [existing, setExisting] = useState(null); // the loaded connection on edit
|
||||
const [loading, setLoading] = useState(isEdit);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [loadError, setLoadError] = useState(null);
|
||||
|
||||
const set = (k, v) => setForm((f) => ({ ...f, [k]: v }));
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEdit) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await connectivityAPI.listConnections();
|
||||
const conns = res.data?.connections ?? res.data ?? [];
|
||||
const conn = (Array.isArray(conns) ? conns : []).find((c) => c.id === id);
|
||||
if (cancelled) return;
|
||||
if (!conn) { setLoadError('Connection not found'); setLoading(false); return; }
|
||||
setExisting(conn);
|
||||
setType(conn.type);
|
||||
const cfg = conn.config || {};
|
||||
setForm({
|
||||
...EMPTY,
|
||||
name: conn.name || '',
|
||||
host: cfg.host || '',
|
||||
port: cfg.port != null ? String(cfg.port) : '',
|
||||
user: cfg.user || '',
|
||||
auth: cfg.auth || 'key',
|
||||
scheme: cfg.scheme || 'socks5',
|
||||
proxy_user: cfg.user || '',
|
||||
});
|
||||
setLoading(false);
|
||||
} catch (err) {
|
||||
if (!cancelled) { setLoadError(apiError(err, 'Failed to load connection')); setLoading(false); }
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [isEdit, id]);
|
||||
|
||||
// Secrets that already exist on the connection (so we can show "•••• set").
|
||||
const hasSecret = useMemo(() => {
|
||||
const cfg = existing?.config || {};
|
||||
return {
|
||||
conf: !!existing && (type === 'wireguard_ext' || type === 'openvpn'),
|
||||
private_key: !!existing && type === 'sshuttle' && (cfg.auth || 'key') === 'key',
|
||||
password: !!existing && ((type === 'sshuttle' && cfg.auth === 'password') || type === 'proxy'),
|
||||
known_hosts: !!existing && type === 'sshuttle',
|
||||
};
|
||||
}, [existing, type]);
|
||||
|
||||
const buildPayload = () => {
|
||||
const config = {};
|
||||
const secrets = {};
|
||||
if (type === 'wireguard_ext' || type === 'openvpn') {
|
||||
if (form.conf.trim()) secrets.conf = form.conf;
|
||||
} else if (type === 'sshuttle') {
|
||||
config.host = form.host.trim();
|
||||
config.port = Number(form.port || 22);
|
||||
config.user = form.user.trim();
|
||||
config.auth = form.auth;
|
||||
if (form.known_hosts.trim()) secrets.known_hosts = form.known_hosts.trim();
|
||||
if (form.auth === 'key') {
|
||||
if (form.private_key.trim()) secrets.private_key = form.private_key;
|
||||
} else if (form.password) {
|
||||
secrets.password = form.password;
|
||||
}
|
||||
} else if (type === 'proxy') {
|
||||
config.scheme = form.scheme;
|
||||
config.host = form.host.trim();
|
||||
config.port = Number(form.port);
|
||||
config.user = form.proxy_user.trim();
|
||||
if (form.proxy_password) secrets.password = form.proxy_password;
|
||||
}
|
||||
return { config, secrets };
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!form.name.trim()) { toastEvent('Name is required', 'error'); return; }
|
||||
setSaving(true);
|
||||
const { config, secrets } = buildPayload();
|
||||
try {
|
||||
if (isEdit) {
|
||||
const fields = { name: form.name.trim(), config };
|
||||
if (Object.keys(secrets).length) fields.secrets = secrets;
|
||||
await connectivityAPI.updateConnection(id, fields);
|
||||
toastEvent(`Saved ${form.name.trim()}`);
|
||||
} else {
|
||||
await connectivityAPI.createConnection(type, form.name.trim(), config, secrets);
|
||||
toastEvent(`Created ${form.name.trim()}`);
|
||||
}
|
||||
navigate(`/connectivity/${group}`);
|
||||
} catch (err) {
|
||||
toastEvent(apiError(err, 'Failed to save connection'), 'error');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-6 animate-pulse space-y-3">
|
||||
<div className="h-5 bg-gray-200 rounded w-1/3" />
|
||||
<div className="h-4 bg-gray-100 rounded w-1/2" />
|
||||
<div className="h-32 bg-gray-100 rounded" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loadError) {
|
||||
return (
|
||||
<div className="bg-red-50 rounded-lg border border-red-200 p-4 flex items-start gap-3">
|
||||
<AlertCircle className="h-5 w-5 text-red-500 mt-0.5 shrink-0" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-red-800">{loadError}</p>
|
||||
</div>
|
||||
<Link to={`/connectivity/${group}`} className="btn-secondary text-sm shrink-0">Back</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const meta = typeMeta(type);
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl">
|
||||
<Toast toasts={toasts} />
|
||||
|
||||
<Link
|
||||
to={`/connectivity/${group}`}
|
||||
className="inline-flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-700 mb-4"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" /> Back
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<TypeIcon type={type} />
|
||||
<h1 className="text-2xl font-bold text-gray-900">
|
||||
{isEdit ? `Edit ${meta.short}` : `Add ${meta.short}`}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="bg-white rounded-lg border border-gray-200 p-6 flex flex-col gap-4">
|
||||
{/* Type picker (only when the group has more than one type and we're adding) */}
|
||||
{!isEdit && groupTypes.length > 1 && (
|
||||
<FieldRow label="Type">
|
||||
<div className="relative">
|
||||
<select
|
||||
value={type}
|
||||
onChange={(e) => setType(e.target.value)}
|
||||
className={`${inputCls} appearance-none pr-8`}
|
||||
>
|
||||
{groupTypes.map((t) => (
|
||||
<option key={t} value={t}>{typeMeta(t).label}</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown className="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
</div>
|
||||
</FieldRow>
|
||||
)}
|
||||
|
||||
<FieldRow label="Name" required hint="A label to identify this connection in assignments">
|
||||
<input
|
||||
type="text"
|
||||
value={form.name}
|
||||
onChange={(e) => set('name', e.target.value)}
|
||||
placeholder={`my-${meta.short.toLowerCase()}`}
|
||||
className={inputCls}
|
||||
/>
|
||||
</FieldRow>
|
||||
|
||||
{(type === 'wireguard_ext' || type === 'openvpn') && (
|
||||
<SecretField
|
||||
label={type === 'wireguard_ext' ? 'WireGuard config (.conf)' : 'OpenVPN profile (.ovpn)'}
|
||||
required
|
||||
isEdit={isEdit}
|
||||
hasExisting={hasSecret.conf}
|
||||
value={form.conf}
|
||||
onChange={(v) => set('conf', v)}
|
||||
multiline
|
||||
placeholder={type === 'wireguard_ext'
|
||||
? '[Interface]\nPrivateKey = ...\n\n[Peer]\nPublicKey = ...'
|
||||
: 'client\ndev tun\nproto udp\nremote ...'}
|
||||
hint="Stored encrypted in the vault. Paste the file contents."
|
||||
/>
|
||||
)}
|
||||
|
||||
{type === 'sshuttle' && (
|
||||
<>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="col-span-2">
|
||||
<FieldRow label="Host" required>
|
||||
<input type="text" value={form.host} onChange={(e) => set('host', e.target.value)}
|
||||
placeholder="ssh.example.com" className={inputCls} />
|
||||
</FieldRow>
|
||||
</div>
|
||||
<FieldRow label="Port">
|
||||
<input type="number" min="1" max="65535" value={form.port}
|
||||
onChange={(e) => set('port', e.target.value)} placeholder="22" className={inputCls} />
|
||||
</FieldRow>
|
||||
</div>
|
||||
<FieldRow label="User" required>
|
||||
<input type="text" value={form.user} onChange={(e) => set('user', e.target.value)}
|
||||
placeholder="tunnel" className={inputCls} />
|
||||
</FieldRow>
|
||||
<FieldRow label="Authentication">
|
||||
<div className="flex rounded-md border border-gray-200 overflow-hidden w-fit">
|
||||
{['key', 'password'].map((a) => (
|
||||
<button
|
||||
key={a} type="button" onClick={() => set('auth', a)}
|
||||
className={`px-3 py-1.5 text-sm font-medium transition-colors ${
|
||||
form.auth === a ? 'bg-primary-600 text-white' : 'bg-white text-gray-600 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{a === 'key' ? 'Private key' : 'Password'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</FieldRow>
|
||||
{form.auth === 'key' ? (
|
||||
<SecretField
|
||||
label="Private key" required isEdit={isEdit} hasExisting={hasSecret.private_key}
|
||||
value={form.private_key} onChange={(v) => set('private_key', v)} multiline
|
||||
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"
|
||||
hint="Stored encrypted in the vault."
|
||||
/>
|
||||
) : (
|
||||
<SecretField
|
||||
label="Password" required isEdit={isEdit} hasExisting={hasSecret.password}
|
||||
value={form.password} onChange={(v) => set('password', v)}
|
||||
/>
|
||||
)}
|
||||
<SecretField
|
||||
label="Pinned host key (known_hosts line)" required isEdit={isEdit}
|
||||
hasExisting={hasSecret.known_hosts}
|
||||
value={form.known_hosts} onChange={(v) => set('known_hosts', v)}
|
||||
placeholder="ssh.example.com ssh-ed25519 AAAA..."
|
||||
hint="Get it with: ssh-keyscan -t ed25519 ssh.example.com"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{type === 'proxy' && (
|
||||
<>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<FieldRow label="Scheme">
|
||||
<div className="relative">
|
||||
<select value={form.scheme} onChange={(e) => set('scheme', e.target.value)}
|
||||
className={`${inputCls} appearance-none pr-8`}>
|
||||
<option value="socks5">SOCKS5</option>
|
||||
<option value="http">HTTP</option>
|
||||
</select>
|
||||
<ChevronDown className="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
</div>
|
||||
</FieldRow>
|
||||
<FieldRow label="Host" required>
|
||||
<input type="text" value={form.host} onChange={(e) => set('host', e.target.value)}
|
||||
placeholder="proxy.example.com" className={inputCls} />
|
||||
</FieldRow>
|
||||
<FieldRow label="Port" required>
|
||||
<input type="number" min="1" max="65535" value={form.port}
|
||||
onChange={(e) => set('port', e.target.value)} placeholder="1080" className={inputCls} />
|
||||
</FieldRow>
|
||||
</div>
|
||||
<FieldRow label="User" hint="Leave blank for an unauthenticated proxy">
|
||||
<input type="text" value={form.proxy_user} onChange={(e) => set('proxy_user', e.target.value)}
|
||||
autoComplete="off" placeholder="optional" className={inputCls} />
|
||||
</FieldRow>
|
||||
<SecretField
|
||||
label="Password" isEdit={isEdit} hasExisting={hasSecret.password}
|
||||
value={form.proxy_password} onChange={(v) => set('proxy_password', v)}
|
||||
hint="Required only when a user is set"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{type === 'tor' && (
|
||||
<p className="text-sm text-gray-500">
|
||||
Tor needs no configuration. Create the connection, then assign peers or services to it.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2 border-t border-gray-100">
|
||||
<Link to={`/connectivity/${group}`} className="btn-secondary text-sm">Cancel</Link>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-white bg-primary-600 hover:bg-primary-700 disabled:opacity-50 rounded-md transition-colors"
|
||||
>
|
||||
{saving ? <RefreshCw className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
|
||||
{saving ? 'Saving…' : isEdit ? 'Save changes' : 'Create connection'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
import { useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { Plus, Pencil, Trash2, RefreshCw, AlertCircle, Store } from 'lucide-react';
|
||||
import { connectivityAPI } from '../../services/api';
|
||||
import { useConnectivityData } from './useConnectivityData';
|
||||
import {
|
||||
Toast, useToasts, toastEvent, apiError, typeMeta,
|
||||
GROUP_TYPES, GROUP_LABELS, LifecycleBadge, HealthDot, TypeIcon,
|
||||
lifecycleOf, buildRefCounts,
|
||||
} from './shared';
|
||||
|
||||
function DeleteDialog({ conn, onConfirm, onCancel, deleting }) {
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-xl shadow-xl p-6 w-96 mx-4">
|
||||
<div className="flex items-start gap-3 mb-4">
|
||||
<AlertCircle className="h-5 w-5 text-red-500 mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900">Delete "{conn.name}"?</h3>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
This removes the connection. Peers or services assigned to it must be
|
||||
re-pointed first — the API blocks deletion while it is in use.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button onClick={onCancel} className="btn-secondary text-sm">Cancel</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
disabled={deleting}
|
||||
className="flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 disabled:opacity-50 rounded-md transition-colors"
|
||||
>
|
||||
{deleting ? <RefreshCw className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ConnectionListPage({ group }) {
|
||||
const toasts = useToasts();
|
||||
const navigate = useNavigate();
|
||||
const {
|
||||
connections, peerExits, serviceEgress, installed, loading, error, reload,
|
||||
} = useConnectivityData();
|
||||
const [delTarget, setDelTarget] = useState(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
const types = GROUP_TYPES[group] || [];
|
||||
const rows = connections.filter((c) => types.includes(c.type));
|
||||
const refs = buildRefCounts(peerExits, serviceEgress);
|
||||
|
||||
// The backing store service for this group is considered installed if any of
|
||||
// its types' service ids is present in the installed map.
|
||||
const anyServiceInstalled = types.some((t) => {
|
||||
const svc = typeMeta(t).service;
|
||||
return svc && installed[svc];
|
||||
});
|
||||
|
||||
const handleDelete = async () => {
|
||||
setDeleting(true);
|
||||
try {
|
||||
await connectivityAPI.deleteConnection(delTarget.id);
|
||||
toastEvent(`Deleted ${delTarget.name}`);
|
||||
setDelTarget(null);
|
||||
await reload();
|
||||
} catch (err) {
|
||||
// 409 → in use; surface the API's specific message.
|
||||
toastEvent(apiError(err, `Failed to delete ${delTarget.name}`), 'error');
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const title = GROUP_LABELS[group] || group;
|
||||
// For single-type groups the "Add" button targets that type; multi-type
|
||||
// groups default to the first type (the form lets the user switch).
|
||||
const addType = types[0];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Toast toasts={toasts} />
|
||||
|
||||
<div className="mb-6 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">{title}</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
{group === 'tor'
|
||||
? 'A single Tor exit for anonymised peer traffic'
|
||||
: `Manage ${title.toLowerCase()} exit connections`}
|
||||
</p>
|
||||
</div>
|
||||
{/* Tor is a singleton — hide Add once one exists */}
|
||||
{!(group === 'tor' && rows.length > 0) && (
|
||||
<Link
|
||||
to={`/connectivity/${group}/new`}
|
||||
className="flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-white bg-primary-600 hover:bg-primary-700 rounded-md transition-colors shrink-0"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add {typeMeta(addType).short}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-6 animate-pulse space-y-3">
|
||||
<div className="h-4 bg-gray-200 rounded w-1/3" />
|
||||
<div className="h-4 bg-gray-100 rounded w-1/2" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && error && (
|
||||
<div className="bg-red-50 rounded-lg border border-red-200 p-4 flex items-start gap-3">
|
||||
<AlertCircle className="h-5 w-5 text-red-500 mt-0.5 shrink-0" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-red-800">Failed to load connections</p>
|
||||
<p className="text-sm text-red-600 mt-1">{error}</p>
|
||||
</div>
|
||||
<button onClick={reload} className="btn-secondary text-sm shrink-0">Retry</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && rows.length === 0 && (
|
||||
<div className="bg-white rounded-lg border border-gray-200 py-12 text-center">
|
||||
<div className="flex justify-center mb-3">
|
||||
<TypeIcon type={addType} />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-gray-500">No {title.toLowerCase()} configured yet</p>
|
||||
<p className="text-xs text-gray-400 mt-1 mb-4">
|
||||
{anyServiceInstalled
|
||||
? `Add your first ${typeMeta(addType).short} connection to route peer traffic through it.`
|
||||
: 'The backing service is not installed yet.'}
|
||||
</p>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Link
|
||||
to={`/connectivity/${group}/new`}
|
||||
className="inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-white bg-primary-600 hover:bg-primary-700 rounded-md"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add {typeMeta(addType).short}
|
||||
</Link>
|
||||
{!anyServiceInstalled && (
|
||||
<Link
|
||||
to="/services"
|
||||
className="inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-md"
|
||||
>
|
||||
<Store className="h-4 w-4" />
|
||||
Open Store
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && rows.length > 0 && (
|
||||
<div className="bg-white rounded-lg border border-gray-200 overflow-x-auto">
|
||||
<table className="min-w-full">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 border-b border-gray-200">
|
||||
<th className="py-3 px-4 text-left text-xs font-semibold text-gray-500 uppercase tracking-wide">Name</th>
|
||||
<th className="py-3 px-4 text-left text-xs font-semibold text-gray-500 uppercase tracking-wide">Type</th>
|
||||
<th className="py-3 px-4 text-left text-xs font-semibold text-gray-500 uppercase tracking-wide">Lifecycle</th>
|
||||
<th className="py-3 px-4 text-left text-xs font-semibold text-gray-500 uppercase tracking-wide">Health</th>
|
||||
<th className="py-3 px-4 text-left text-xs font-semibold text-gray-500 uppercase tracking-wide">Used by</th>
|
||||
<th className="py-3 px-4" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((conn) => {
|
||||
const meta = typeMeta(conn.type);
|
||||
const ref = refs[conn.id] || { peers: 0, services: 0 };
|
||||
const lifecycle = lifecycleOf(conn, ref.peers + ref.services);
|
||||
return (
|
||||
<tr key={conn.id} className="border-t border-gray-100">
|
||||
<td className="py-3 px-4 text-sm font-medium text-gray-900">{conn.name}</td>
|
||||
<td className="py-3 px-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<TypeIcon type={conn.type} size="sm" />
|
||||
<span className="text-sm text-gray-600">{meta.label}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 px-4"><LifecycleBadge lifecycle={lifecycle} /></td>
|
||||
<td className="py-3 px-4"><HealthDot health={conn?.status?.health || 'unknown'} withLabel /></td>
|
||||
<td className="py-3 px-4 text-sm text-gray-500">
|
||||
{ref.peers} peer{ref.peers === 1 ? '' : 's'} · {ref.services} svc
|
||||
</td>
|
||||
<td className="py-3 px-4 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<button
|
||||
onClick={() => navigate(`/connectivity/${group}/${conn.id}`)}
|
||||
className="p-1.5 text-gray-400 hover:text-primary-600 rounded-md hover:bg-gray-50"
|
||||
aria-label={`Edit ${conn.name}`}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDelTarget(conn)}
|
||||
className="p-1.5 text-gray-400 hover:text-red-600 rounded-md hover:bg-gray-50"
|
||||
aria-label={`Delete ${conn.name}`}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{delTarget && (
|
||||
<DeleteDialog
|
||||
conn={delTarget}
|
||||
deleting={deleting}
|
||||
onConfirm={handleDelete}
|
||||
onCancel={() => setDelTarget(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { RefreshCw, Plus, AlertCircle, Network } from 'lucide-react';
|
||||
import { connectivityAPI } from '../../services/api';
|
||||
import { useConnectivityData } from './useConnectivityData';
|
||||
import {
|
||||
Toast, useToasts, toastEvent, apiError, typeMeta,
|
||||
LifecycleBadge, HealthDot, TypeIcon, lifecycleOf, buildRefCounts,
|
||||
isHealthWorking, isHealthDown,
|
||||
} from './shared';
|
||||
|
||||
function StatCard({ label, value, accent }) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-4">
|
||||
<p className="text-xs font-medium text-gray-500 uppercase tracking-wide">{label}</p>
|
||||
<p className={`mt-1 text-2xl font-bold ${accent || 'text-gray-900'}`}>{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConnectionCard({ conn, refs }) {
|
||||
const meta = typeMeta(conn.type);
|
||||
const ref = refs[conn.id] || { peers: 0, services: 0 };
|
||||
const lifecycle = lifecycleOf(conn, ref.peers + ref.services);
|
||||
const health = conn?.status?.health || 'unknown';
|
||||
return (
|
||||
<Link
|
||||
to={`/connectivity/${meta.group}/${conn.id}`}
|
||||
className="bg-white rounded-lg border border-gray-200 p-4 flex flex-col gap-3 hover:border-primary-300 hover:shadow-sm transition-all"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<TypeIcon type={conn.type} size="sm" />
|
||||
<div className="min-w-0">
|
||||
<p className="font-semibold text-gray-900 truncate">{conn.name}</p>
|
||||
<p className="text-xs text-gray-400">{meta.label}</p>
|
||||
</div>
|
||||
</div>
|
||||
<HealthDot health={health} />
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2 mt-auto pt-2 border-t border-gray-100">
|
||||
<LifecycleBadge lifecycle={lifecycle} />
|
||||
<span className="text-xs text-gray-500">
|
||||
{ref.peers} peer{ref.peers === 1 ? '' : 's'} · {ref.services} service{ref.services === 1 ? '' : 's'}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ConnectivityOverview() {
|
||||
const toasts = useToasts();
|
||||
const navigate = useNavigate();
|
||||
const { connections, peerExits, serviceEgress, loading, error, reload } = useConnectivityData();
|
||||
const [applying, setApplying] = useState(false);
|
||||
|
||||
const refs = buildRefCounts(peerExits, serviceEgress);
|
||||
|
||||
const working = connections.filter((c) => isHealthWorking(c?.status?.health)).length;
|
||||
const notWorking = connections.filter((c) => isHealthDown(c?.status?.health)).length;
|
||||
|
||||
const handleApply = async () => {
|
||||
setApplying(true);
|
||||
try {
|
||||
await connectivityAPI.applyRoutes();
|
||||
toastEvent('Routes applied successfully');
|
||||
await reload();
|
||||
} catch (err) {
|
||||
toastEvent(apiError(err, 'Failed to apply routes'), 'error');
|
||||
} finally {
|
||||
setApplying(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Toast toasts={toasts} />
|
||||
|
||||
<div className="mb-6 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Connectivity</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Exit connections and how each peer and service routes its traffic
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<button
|
||||
onClick={handleApply}
|
||||
disabled={applying || loading}
|
||||
className="btn-secondary flex items-center gap-2 text-sm"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${applying ? 'animate-spin' : ''}`} />
|
||||
{applying ? 'Applying…' : 'Apply routes'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigate('/connectivity/tunnels/new')}
|
||||
className="flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-white bg-primary-600 hover:bg-primary-700 rounded-md transition-colors"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add connection
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 animate-pulse">
|
||||
{[1, 2, 3, 4].map((n) => (
|
||||
<div key={n} className="bg-white rounded-lg border border-gray-200 p-4 h-20" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && error && (
|
||||
<div className="bg-red-50 rounded-lg border border-red-200 p-4 flex items-start gap-3">
|
||||
<AlertCircle className="h-5 w-5 text-red-500 mt-0.5 shrink-0" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-red-800">Failed to load connectivity data</p>
|
||||
<p className="text-sm text-red-600 mt-1">{error}</p>
|
||||
</div>
|
||||
<button onClick={reload} className="btn-secondary text-sm shrink-0">Retry</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<StatCard label="Connections" value={connections.length} />
|
||||
<StatCard label="Working" value={working} accent="text-green-600" />
|
||||
<StatCard label="Not working" value={notWorking} accent={notWorking ? 'text-red-600' : 'text-gray-900'} />
|
||||
<StatCard
|
||||
label="Unknown"
|
||||
value={connections.length - working - notWorking}
|
||||
accent="text-gray-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{notWorking > 0 && (
|
||||
<div className="flex items-start gap-2 rounded-lg border border-yellow-200 bg-yellow-50 px-4 py-3">
|
||||
<AlertCircle className="h-4 w-4 text-yellow-600 mt-0.5 shrink-0" />
|
||||
<p className="text-sm text-yellow-800">
|
||||
{notWorking} connection{notWorking === 1 ? '' : 's'} not working. Peers and
|
||||
services assigned to a down connection fall back to a direct route only when
|
||||
fail-open is enabled — otherwise their traffic is blocked.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{connections.length === 0 ? (
|
||||
<div className="bg-white rounded-lg border border-gray-200 py-12 text-center">
|
||||
<Network className="h-10 w-10 text-gray-300 mx-auto mb-3" />
|
||||
<p className="text-sm font-medium text-gray-500">No connections yet</p>
|
||||
<p className="text-xs text-gray-400 mt-1 mb-4">
|
||||
Add a tunnel, proxy, SSH or Tor exit to start routing peer traffic.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => navigate('/connectivity/tunnels/new')}
|
||||
className="inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-white bg-primary-600 hover:bg-primary-700 rounded-md"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add connection
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{connections.map((conn) => (
|
||||
<ConnectionCard key={conn.id} conn={conn} refs={refs} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Shield, Lock, Globe, Server, Network, CheckCircle, AlertCircle,
|
||||
} from 'lucide-react';
|
||||
|
||||
// ── Connection type metadata ──────────────────────────────────────────────────
|
||||
// Each connectivity type maps to: a label, an icon, the route segment it lives
|
||||
// under, the backing store service id (used to decide whether to surface an
|
||||
// "install from store" hint / hide an empty child), and which list subpage it
|
||||
// belongs to.
|
||||
|
||||
export const TYPE_META = {
|
||||
wireguard_ext: {
|
||||
label: 'WireGuard External',
|
||||
short: 'WireGuard',
|
||||
Icon: Shield,
|
||||
color: 'primary',
|
||||
group: 'tunnels',
|
||||
service: 'wireguard-ext',
|
||||
},
|
||||
openvpn: {
|
||||
label: 'OpenVPN',
|
||||
short: 'OpenVPN',
|
||||
Icon: Lock,
|
||||
color: 'indigo',
|
||||
group: 'tunnels',
|
||||
service: 'openvpn',
|
||||
},
|
||||
proxy: {
|
||||
label: 'Upstream Proxy',
|
||||
short: 'Proxy',
|
||||
Icon: Network,
|
||||
color: 'sky',
|
||||
group: 'proxies',
|
||||
service: 'redsocks',
|
||||
},
|
||||
sshuttle: {
|
||||
label: 'SSH Tunnel',
|
||||
short: 'SSH',
|
||||
Icon: Server,
|
||||
color: 'emerald',
|
||||
group: 'ssh',
|
||||
service: 'sshuttle',
|
||||
},
|
||||
tor: {
|
||||
label: 'Tor',
|
||||
short: 'Tor',
|
||||
Icon: Globe,
|
||||
color: 'purple',
|
||||
group: 'tor',
|
||||
service: 'tor',
|
||||
},
|
||||
};
|
||||
|
||||
// Subpage groups → which connection types they contain.
|
||||
export const GROUP_TYPES = {
|
||||
tunnels: ['wireguard_ext', 'openvpn'],
|
||||
proxies: ['proxy'],
|
||||
ssh: ['sshuttle'],
|
||||
tor: ['tor'],
|
||||
};
|
||||
|
||||
export const GROUP_LABELS = {
|
||||
tunnels: 'Tunnels',
|
||||
proxies: 'Proxies',
|
||||
ssh: 'SSH',
|
||||
tor: 'Tor',
|
||||
};
|
||||
|
||||
export function typeMeta(type) {
|
||||
return TYPE_META[type] || {
|
||||
label: type, short: type, Icon: Network, color: 'gray',
|
||||
group: 'tunnels', service: null,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Error message extraction ──────────────────────────────────────────────────
|
||||
|
||||
export function apiError(err, fallback) {
|
||||
return (
|
||||
err?.response?.data?.error ||
|
||||
err?.response?.data?.message ||
|
||||
fallback ||
|
||||
'Something went wrong'
|
||||
);
|
||||
}
|
||||
|
||||
// ── Toasts (shared event channel for the whole connectivity area) ─────────────
|
||||
|
||||
export function toastEvent(msg, type = 'success') {
|
||||
window.dispatchEvent(new CustomEvent('connectivity-toast', { detail: { msg, type } }));
|
||||
}
|
||||
|
||||
export function Toast({ toasts }) {
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 z-50 space-y-2 pointer-events-none">
|
||||
{toasts.map((t) => (
|
||||
<div
|
||||
key={t.id}
|
||||
className={`px-4 py-3 rounded-lg shadow-lg text-sm text-white flex items-center gap-2 pointer-events-auto ${
|
||||
t.type === 'success' ? 'bg-green-600' : t.type === 'error' ? 'bg-red-600' : 'bg-yellow-600'
|
||||
}`}
|
||||
>
|
||||
{t.type === 'success'
|
||||
? <CheckCircle className="h-4 w-4 shrink-0" />
|
||||
: <AlertCircle className="h-4 w-4 shrink-0" />}
|
||||
{t.msg}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function useToasts() {
|
||||
const [toasts, setToasts] = useState([]);
|
||||
useEffect(() => {
|
||||
const handler = (e) => {
|
||||
const id = Date.now() + Math.random();
|
||||
setToasts((prev) => [...prev, { ...e.detail, id }]);
|
||||
setTimeout(() => setToasts((prev) => prev.filter((t) => t.id !== id)), 3500);
|
||||
};
|
||||
window.addEventListener('connectivity-toast', handler);
|
||||
return () => window.removeEventListener('connectivity-toast', handler);
|
||||
}, []);
|
||||
return toasts;
|
||||
}
|
||||
|
||||
// ── Lifecycle badge ───────────────────────────────────────────────────────────
|
||||
// Lifecycle is derived from the connection's status.state ('added' | 'configured')
|
||||
// plus whether anything references it (peers/services) → "in use".
|
||||
|
||||
export function lifecycleOf(conn, refCount) {
|
||||
if (refCount > 0) return 'in_use';
|
||||
const state = conn?.status?.state || 'added';
|
||||
return state === 'configured' ? 'configured' : 'added';
|
||||
}
|
||||
|
||||
export function LifecycleBadge({ lifecycle }) {
|
||||
const map = {
|
||||
in_use: { label: 'In use', cls: 'text-green-700 bg-green-50 border-green-200' },
|
||||
configured: { label: 'Configured', cls: 'text-blue-700 bg-blue-50 border-blue-200' },
|
||||
added: { label: 'Added', cls: 'text-gray-600 bg-gray-100 border-gray-200' },
|
||||
};
|
||||
const m = map[lifecycle] || map.added;
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1 text-xs font-medium border rounded-full px-2 py-0.5 ${m.cls}`}>
|
||||
{m.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Health dot ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function HealthDot({ health, withLabel }) {
|
||||
const map = {
|
||||
working: { cls: 'bg-green-500', label: 'Working' },
|
||||
up: { cls: 'bg-green-500', label: 'Working' },
|
||||
down: { cls: 'bg-red-500', label: 'Down' },
|
||||
not_working: { cls: 'bg-red-500', label: 'Down' },
|
||||
};
|
||||
const m = map[health] || { cls: 'bg-gray-300', label: 'Unknown' };
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5" title={m.label}>
|
||||
<span className={`inline-block h-2.5 w-2.5 rounded-full ${m.cls}`} />
|
||||
{withLabel && <span className="text-xs text-gray-500">{m.label}</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function isHealthWorking(health) {
|
||||
return health === 'working' || health === 'up';
|
||||
}
|
||||
|
||||
export function isHealthDown(health) {
|
||||
return health === 'down' || health === 'not_working';
|
||||
}
|
||||
|
||||
// ── Type icon chip ────────────────────────────────────────────────────────────
|
||||
|
||||
const COLOR_BG = {
|
||||
primary: 'bg-primary-50 text-primary-600',
|
||||
indigo: 'bg-indigo-50 text-indigo-600',
|
||||
sky: 'bg-sky-50 text-sky-600',
|
||||
emerald: 'bg-emerald-50 text-emerald-600',
|
||||
purple: 'bg-purple-50 text-purple-600',
|
||||
gray: 'bg-gray-100 text-gray-500',
|
||||
};
|
||||
|
||||
export function TypeIcon({ type, size = 'md' }) {
|
||||
const m = typeMeta(type);
|
||||
const box = size === 'sm' ? 'w-8 h-8' : 'w-10 h-10';
|
||||
const icon = size === 'sm' ? 'h-4 w-4' : 'h-5 w-5';
|
||||
return (
|
||||
<div className={`flex items-center justify-center ${box} rounded-lg shrink-0 ${COLOR_BG[m.color] || COLOR_BG.gray}`}>
|
||||
<m.Icon className={icon} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Reference counting (how many peers / services use each connection) ────────
|
||||
// peerExits: { peer_name: connection_id }
|
||||
// serviceExits: { service_id: { exit_via } }
|
||||
|
||||
export function buildRefCounts(peerExits, serviceEgress) {
|
||||
const counts = {}; // connection_id -> { peers, services }
|
||||
const bump = (id, key) => {
|
||||
if (!id || id === 'default') return;
|
||||
counts[id] = counts[id] || { peers: 0, services: 0 };
|
||||
counts[id][key] += 1;
|
||||
};
|
||||
Object.values(peerExits || {}).forEach((id) => bump(id, 'peers'));
|
||||
Object.entries(serviceEgress || {}).forEach(([, info]) => bump(info?.exit_via, 'services'));
|
||||
return counts;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
connectivityAPI, egressAPI, storeAPI, wireguardAPI, cellLinkAPI,
|
||||
} from '../../services/api';
|
||||
|
||||
/**
|
||||
* Central loader for the connectivity area. Pulls everything the subpages and
|
||||
* the assignment matrix need: connection instances, peer→connection
|
||||
* assignments, service egress assignments, installed store services, the
|
||||
* WireGuard peer list (registry-backed, same source the legacy peer-exit table
|
||||
* used) and connected cells (for cell-relay exit options).
|
||||
*
|
||||
* Each source degrades gracefully — a failure in one does not blank the page.
|
||||
*/
|
||||
export function useConnectivityData() {
|
||||
const [connections, setConnections] = useState([]);
|
||||
const [peerExits, setPeerExits] = useState({}); // peer_name -> connection_id
|
||||
const [peerFailopen, setPeerFailopen] = useState({}); // peer_name -> bool|null
|
||||
const [serviceEgress, setServiceEgress] = useState({}); // service_id -> { exit_via }
|
||||
const [installed, setInstalled] = useState({}); // service_id -> info
|
||||
const [peers, setPeers] = useState([]); // [{ name }]
|
||||
const [cells, setCells] = useState([]); // [{ cell_name, exit_offered? }]
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setError(null);
|
||||
try {
|
||||
const [connRes, peerExitsRes, egressRes, storeRes, wgRes, cellsRes] = await Promise.all([
|
||||
connectivityAPI.listConnections().catch(() => ({ data: {} })),
|
||||
connectivityAPI.getPeerExits().catch(() => ({ data: {} })),
|
||||
egressAPI.getStatus().catch(() => ({ data: {} })),
|
||||
storeAPI.listInstalled().catch(() => ({ data: {} })),
|
||||
wireguardAPI.getPeers().catch(() => ({ data: {} })),
|
||||
cellLinkAPI.listConnections().catch(() => ({ data: [] })),
|
||||
]);
|
||||
|
||||
const conns = connRes.data?.connections ?? connRes.data ?? [];
|
||||
setConnections(Array.isArray(conns) ? conns : []);
|
||||
|
||||
// getPeerExits → { peers: { name: connection_id } }
|
||||
const pe = peerExitsRes.data?.peers ?? peerExitsRes.data ?? {};
|
||||
setPeerExits(
|
||||
Array.isArray(pe)
|
||||
? Object.fromEntries(pe.map((p) => [p.name || p.peer, p.exit_via]))
|
||||
: pe
|
||||
);
|
||||
|
||||
// egress getStatus → { services: { id: { exit_via, ... } } }
|
||||
setServiceEgress(egressRes.data?.services || {});
|
||||
|
||||
// store listInstalled → { id: info } or { installed: { id: info } }
|
||||
const inst = storeRes.data?.installed ?? storeRes.data ?? {};
|
||||
setInstalled(inst && typeof inst === 'object' && !Array.isArray(inst) ? inst : {});
|
||||
|
||||
// wireguard getPeers → { peers: [...] } or [...]
|
||||
const rawPeers = wgRes.data?.peers ?? wgRes.data ?? [];
|
||||
const peerList = (Array.isArray(rawPeers) ? rawPeers : []).map((p) => ({
|
||||
name: p.name || p.peer || p.public_key,
|
||||
failopen: p.exit_failopen,
|
||||
}));
|
||||
setPeers(peerList);
|
||||
setPeerFailopen(
|
||||
Object.fromEntries(peerList.map((p) => [p.name, p.failopen ?? null]))
|
||||
);
|
||||
|
||||
const cellList = Array.isArray(cellsRes.data) ? cellsRes.data : (cellsRes.data?.cells ?? []);
|
||||
setCells(cellList);
|
||||
} catch (err) {
|
||||
setError(
|
||||
err?.response?.data?.error ||
|
||||
'Could not load connectivity data. Check that the API is reachable.'
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
return {
|
||||
connections, peerExits, peerFailopen, serviceEgress, installed, peers, cells,
|
||||
loading, error, reload: load,
|
||||
setPeerExits, setPeerFailopen, setServiceEgress,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user