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:
2026-06-10 22:53:46 -04:00
parent d39c091cec
commit aba2b0d33f
9 changed files with 1474 additions and 1324 deletions
+81 -8
View File
@@ -20,8 +20,13 @@ import {
AlertTriangle, AlertTriangle,
User, User,
History, History,
LayoutDashboard,
Lock,
Globe,
Server as ServerIcon,
SlidersHorizontal,
} from 'lucide-react'; } 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 { ConfigProvider } from './contexts/ConfigContext';
import { DraftConfigProvider, useDraftConfig } from './contexts/DraftConfigContext'; import { DraftConfigProvider, useDraftConfig } from './contexts/DraftConfigContext';
import { AuthProvider, useAuth } from './contexts/AuthContext'; import { AuthProvider, useAuth } from './contexts/AuthContext';
@@ -36,7 +41,6 @@ import Logs from './pages/Logs';
import Settings from './pages/Settings'; import Settings from './pages/Settings';
import Vault from './pages/Vault'; import Vault from './pages/Vault';
import ContainerDashboard from './components/ContainerDashboard'; import ContainerDashboard from './components/ContainerDashboard';
import CellNetwork from './pages/CellNetwork';
import Login from './pages/Login'; import Login from './pages/Login';
import AccountSettings from './pages/AccountSettings'; import AccountSettings from './pages/AccountSettings';
import PeerDashboard from './pages/PeerDashboard'; import PeerDashboard from './pages/PeerDashboard';
@@ -45,7 +49,11 @@ import ServicesIndex from './pages/ServicesIndex';
import EmailPage from './pages/services/EmailPage'; import EmailPage from './pages/services/EmailPage';
import CalendarPage from './pages/services/CalendarPage'; import CalendarPage from './pages/services/CalendarPage';
import FilesPage from './pages/services/FilesPage'; 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 ActivityPage from './pages/Activity';
import Setup from './pages/Setup'; import Setup from './pages/Setup';
import SetupGuard from './components/SetupGuard'; import SetupGuard from './components/SetupGuard';
@@ -238,6 +246,8 @@ function AppCore() {
window.dispatchEvent(new CustomEvent('pic-config-discarded')); window.dispatchEvent(new CustomEvent('pic-config-discarded'));
}, [clearAllDirty]); }, [clearAllDirty]);
const { user } = useAuth();
const [activeServiceChildren, setActiveServiceChildren] = useState([]); const [activeServiceChildren, setActiveServiceChildren] = useState([]);
const fetchActiveServices = useCallback(async () => { const fetchActiveServices = useCallback(async () => {
@@ -258,6 +268,61 @@ function AppCore() {
return () => window.removeEventListener('pic-services-changed', fetchActiveServices); return () => window.removeEventListener('pic-services-changed', fetchActiveServices);
}, [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 = [ const adminNavigation = [
{ name: 'Dashboard', href: '/', icon: Home }, { name: 'Dashboard', href: '/', icon: Home },
{ name: 'Peers', href: '/peers', icon: Users }, { name: 'Peers', href: '/peers', icon: Users },
@@ -267,8 +332,7 @@ function AppCore() {
{ name: 'Routing', href: '/routing', icon: Wifi }, { name: 'Routing', href: '/routing', icon: Wifi },
{ name: 'Vault', href: '/vault', icon: Key }, { name: 'Vault', href: '/vault', icon: Key },
{ name: 'Containers', href: '/containers', icon: Package2 }, { name: 'Containers', href: '/containers', icon: Package2 },
{ name: 'Cell Network', href: '/cell-network', icon: Link2 }, { name: 'Connectivity', href: '/connectivity', icon: Network, children: connectivityChildren },
{ name: 'Connectivity', href: '/connectivity', icon: Network },
{ name: 'Logs', href: '/logs', icon: Activity }, { name: 'Logs', href: '/logs', icon: Activity },
{ name: 'Activity', href: '/activity', icon: History }, { name: 'Activity', href: '/activity', icon: History },
{ name: 'Settings', href: '/settings', icon: SettingsIcon }, { name: 'Settings', href: '/settings', icon: SettingsIcon },
@@ -282,7 +346,6 @@ function AppCore() {
{ name: 'Account', href: '/account', icon: User }, { name: 'Account', href: '/account', icon: User },
]; ];
const { user } = useAuth();
const navigation = user?.role === 'peer' ? peerNavigation : adminNavigation; const navigation = user?.role === 'peer' ? peerNavigation : adminNavigation;
if (isLoading) { if (isLoading) {
@@ -382,8 +445,18 @@ function AppCore() {
<Route path="/routing" element={<PrivateRoute requireRole="admin"><Routing /></PrivateRoute>} /> <Route path="/routing" element={<PrivateRoute requireRole="admin"><Routing /></PrivateRoute>} />
<Route path="/vault" element={<PrivateRoute requireRole="admin"><Vault /></PrivateRoute>} /> <Route path="/vault" element={<PrivateRoute requireRole="admin"><Vault /></PrivateRoute>} />
<Route path="/containers" element={<PrivateRoute requireRole="admin"><ContainerDashboard /></PrivateRoute>} /> <Route path="/containers" element={<PrivateRoute requireRole="admin"><ContainerDashboard /></PrivateRoute>} />
<Route path="/cell-network" element={<PrivateRoute requireRole="admin"><CellNetwork /></PrivateRoute>} /> {/* Connectivity area — Services-style subpages */}
<Route path="/connectivity" element={<PrivateRoute requireRole="admin"><Connectivity /></PrivateRoute>} /> <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="/logs" element={<PrivateRoute requireRole="admin"><Logs /></PrivateRoute>} />
<Route path="/activity" element={<PrivateRoute requireRole="admin"><ActivityPage /></PrivateRoute>} /> <Route path="/activity" element={<PrivateRoute requireRole="admin"><ActivityPage /></PrivateRoute>} />
<Route path="/settings" element={<PrivateRoute requireRole="admin"><Settings /></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>
);
}
+214
View File
@@ -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, peerconnection
* 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,
};
}