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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user