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
@@ -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>
);
}