56d677e925
- CellNetwork.jsx CopyButton: use execCommand fallback when clipboard API is unavailable (HTTP non-localhost context) - Makefile reset-admin-password: run inside cell-api container via docker exec so bcrypt and all deps are available without host installation - docker-compose.yml: mount ./scripts:/app/scripts:ro in cell-api so the reset script is accessible inside the container - scripts/reset_admin_password.py: auto-detect API module path and data dir so the script works in both host (api/ sibling) and container (/app) layouts Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
552 lines
22 KiB
React
552 lines
22 KiB
React
import { useState, useEffect } from 'react';
|
|
import { Link2, Link2Off, Copy, CheckCheck, RefreshCw, Plug, Unplug, Globe, Wifi, Calendar, FolderOpen, Mail, HardDrive, ChevronDown, ChevronRight } from 'lucide-react';
|
|
import { cellLinkAPI } from '../services/api';
|
|
import { useConfig } from '../contexts/ConfigContext';
|
|
import QRCode from 'qrcode';
|
|
|
|
const relativeTime = (ts) => {
|
|
if (!ts) return null;
|
|
const diff = Math.floor((Date.now() / 1000) - (typeof ts === 'string' ? new Date(ts).getTime() / 1000 : ts));
|
|
if (diff < 60) return `${diff}s ago`;
|
|
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
|
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
|
return `${Math.floor(diff / 86400)}d ago`;
|
|
};
|
|
|
|
const SERVICE_DEFS = [
|
|
{ key: 'calendar', label: 'Calendar', Icon: Calendar },
|
|
{ key: 'files', label: 'Files', Icon: FolderOpen },
|
|
{ key: 'mail', label: 'Mail', Icon: Mail },
|
|
{ key: 'webdav', label: 'WebDAV', Icon: HardDrive },
|
|
];
|
|
|
|
function CopyButton({ text, small }) {
|
|
const [copied, setCopied] = useState(false);
|
|
const copy = () => {
|
|
if (navigator.clipboard && window.isSecureContext) {
|
|
navigator.clipboard.writeText(text);
|
|
} else {
|
|
const el = document.createElement('textarea');
|
|
el.value = text;
|
|
el.style.cssText = 'position:fixed;opacity:0;top:0;left:0';
|
|
document.body.appendChild(el);
|
|
el.select();
|
|
document.execCommand('copy');
|
|
document.body.removeChild(el);
|
|
}
|
|
setCopied(true);
|
|
setTimeout(() => setCopied(false), 1500);
|
|
};
|
|
const sz = small ? 'h-3.5 w-3.5' : 'h-4 w-4';
|
|
return (
|
|
<button onClick={copy} className="text-gray-400 hover:text-gray-600 ml-1.5" title="Copy">
|
|
{copied ? <CheckCheck className={`${sz} text-green-500`} /> : <Copy className={sz} />}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function StatusDot({ online }) {
|
|
if (online === null || online === undefined) {
|
|
return <span className="inline-block h-2 w-2 rounded-full bg-gray-300 mr-1.5" title="Unknown" />;
|
|
}
|
|
return online
|
|
? <span className="inline-block h-2 w-2 rounded-full bg-green-500 mr-1.5" title="Online" />
|
|
: <span className="inline-block h-2 w-2 rounded-full bg-red-400 mr-1.5" title="Offline" />;
|
|
}
|
|
|
|
function Toast({ toasts }) {
|
|
return (
|
|
<div className="fixed bottom-4 right-4 z-50 space-y-2">
|
|
{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 ${
|
|
t.type === 'error' ? 'bg-red-600' : 'bg-green-600'
|
|
}`}>
|
|
{t.msg}
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function useToasts() {
|
|
const [toasts, setToasts] = useState([]);
|
|
const add = (msg, type = 'success') => {
|
|
const id = Date.now();
|
|
setToasts(p => [...p, { id, msg, type }]);
|
|
setTimeout(() => setToasts(p => p.filter(t => t.id !== id)), 4000);
|
|
};
|
|
return [toasts, add];
|
|
}
|
|
|
|
function DisconnectConfirmModal({ cellName, onConfirm, onCancel }) {
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
|
<div className="bg-white rounded-xl shadow-2xl p-6 max-w-sm w-full mx-4">
|
|
<div className="flex items-center gap-3 mb-3">
|
|
<Unplug className="h-6 w-6 text-red-500 flex-shrink-0" />
|
|
<h3 className="text-base font-semibold text-gray-900">Disconnect "{cellName}"?</h3>
|
|
</div>
|
|
<p className="text-sm text-gray-600 mb-2">
|
|
This will remove the WireGuard tunnel and all sharing permissions between your cells.
|
|
</p>
|
|
<p className="text-xs text-gray-400 mb-5">
|
|
The other cell's admin will need to remove the connection on their end too. Shared services will become inaccessible immediately.
|
|
</p>
|
|
<div className="flex gap-3 justify-end">
|
|
<button onClick={onCancel} autoFocus className="btn btn-secondary text-sm">Cancel</button>
|
|
<button onClick={onConfirm} className="btn btn-danger text-sm flex items-center gap-2">
|
|
<Unplug className="h-4 w-4" /> Disconnect
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ServiceShareToggle({ serviceKey, label, Icon, enabled, saving, onChange }) {
|
|
return (
|
|
<label className="flex items-center gap-3 cursor-pointer select-none py-1">
|
|
<div
|
|
role="switch"
|
|
aria-checked={enabled}
|
|
aria-label={`Share ${label}`}
|
|
tabIndex={0}
|
|
onClick={() => !saving && onChange(!enabled)}
|
|
onKeyDown={e => (e.key === 'Enter' || e.key === ' ') && !saving && onChange(!enabled)}
|
|
className={`relative w-10 h-5 rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-primary-400 focus:ring-offset-1 ${
|
|
enabled ? 'bg-primary-500' : 'bg-gray-300'
|
|
} ${saving ? 'opacity-60 cursor-wait' : 'cursor-pointer'}`}
|
|
>
|
|
<span className={`absolute top-0.5 left-0.5 w-4 h-4 bg-white rounded-full shadow transition-transform ${
|
|
enabled ? 'translate-x-5' : ''
|
|
}`} />
|
|
</div>
|
|
<Icon className="h-4 w-4 text-gray-500 shrink-0" />
|
|
<span className="text-sm text-gray-700">{label}</span>
|
|
</label>
|
|
);
|
|
}
|
|
|
|
function InboundServiceBadge({ label, Icon, active }) {
|
|
return (
|
|
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium ${
|
|
active ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-400'
|
|
}`}>
|
|
<Icon className="h-3 w-3" />
|
|
{label}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function CellPanel({ conn, onDisconnect, addToast }) {
|
|
const [open, setOpen] = useState(false);
|
|
const [inboundPerms, setInboundPerms] = useState(conn.permissions?.inbound || {});
|
|
const [saving, setSaving] = useState({});
|
|
const [confirmDisconnect, setConfirmDisconnect] = useState(false);
|
|
|
|
const handleToggle = async (serviceKey, newValue) => {
|
|
setSaving(s => ({ ...s, [serviceKey]: true }));
|
|
const newInbound = { ...inboundPerms, [serviceKey]: newValue };
|
|
try {
|
|
await cellLinkAPI.updatePermissions(conn.cell_name, newInbound, conn.permissions?.outbound || {});
|
|
setInboundPerms(newInbound);
|
|
addToast(`${serviceKey} sharing ${newValue ? 'enabled' : 'disabled'}`, 'success');
|
|
} catch {
|
|
addToast('Failed to save sharing permission', 'error');
|
|
} finally {
|
|
setSaving(s => ({ ...s, [serviceKey]: false }));
|
|
}
|
|
};
|
|
|
|
const hasRevokedService = Object.values(inboundPerms).some(v => !v);
|
|
|
|
return (
|
|
<>
|
|
{confirmDisconnect && (
|
|
<DisconnectConfirmModal
|
|
cellName={conn.cell_name}
|
|
onConfirm={() => { setConfirmDisconnect(false); onDisconnect(conn.cell_name); }}
|
|
onCancel={() => setConfirmDisconnect(false)}
|
|
/>
|
|
)}
|
|
<div className="border border-gray-100 rounded-lg overflow-hidden">
|
|
<button
|
|
className="w-full flex items-center justify-between px-4 py-3 bg-gray-50 hover:bg-gray-100 transition-colors text-left"
|
|
onClick={() => setOpen(v => !v)}
|
|
aria-expanded={open}
|
|
>
|
|
<div className="flex items-center gap-3 min-w-0">
|
|
<StatusDot online={conn.online} />
|
|
<div className="min-w-0">
|
|
<div className="flex items-center gap-1.5 min-w-0">
|
|
<span className="font-medium text-gray-900 truncate">{conn.cell_name}</span>
|
|
<span className="text-xs text-gray-400 font-mono shrink-0">.{conn.domain}</span>
|
|
</div>
|
|
<div className="flex flex-wrap gap-x-3 text-xs text-gray-500 mt-0.5">
|
|
<span className={conn.online ? 'text-green-600 font-medium' : 'text-gray-400'}>
|
|
{conn.online ? 'Online' : 'Offline'}
|
|
</span>
|
|
{conn.last_handshake && (
|
|
<span>{relativeTime(conn.last_handshake)}</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{open
|
|
? <ChevronDown className="h-4 w-4 text-gray-400 shrink-0" />
|
|
: <ChevronRight className="h-4 w-4 text-gray-400 shrink-0" />}
|
|
</button>
|
|
|
|
{open && (
|
|
<div className="px-4 py-4 bg-white border-t border-gray-100">
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
|
<div>
|
|
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-3">
|
|
I share with {conn.cell_name}
|
|
</p>
|
|
<div className="space-y-1">
|
|
{SERVICE_DEFS.map(({ key, label, Icon }) => (
|
|
<ServiceShareToggle
|
|
key={key}
|
|
serviceKey={key}
|
|
label={label}
|
|
Icon={Icon}
|
|
enabled={!!inboundPerms[key]}
|
|
saving={!!saving[key]}
|
|
onChange={v => handleToggle(key, v)}
|
|
/>
|
|
))}
|
|
</div>
|
|
{hasRevokedService && (
|
|
<p className="text-xs text-gray-400 mt-2">
|
|
Services you stop sharing become unreachable from {conn.cell_name} immediately.
|
|
</p>
|
|
)}
|
|
</div>
|
|
<div>
|
|
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-3">
|
|
{conn.cell_name} shares with me
|
|
</p>
|
|
{(conn.permissions?.outbound && Object.values(conn.permissions.outbound).some(Boolean)) ? (
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{SERVICE_DEFS.map(({ key, label, Icon }) => (
|
|
<InboundServiceBadge
|
|
key={key}
|
|
label={label}
|
|
Icon={Icon}
|
|
active={!!conn.permissions.outbound[key]}
|
|
/>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<p className="text-xs text-gray-400">Nothing shared yet.</p>
|
|
)}
|
|
<p className="text-xs text-gray-400 mt-3">
|
|
Inbound sharing is set by the other cell's admin.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="mt-4 pt-3 border-t border-gray-100 flex flex-wrap items-center justify-between gap-3">
|
|
<dl className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-gray-500">
|
|
{conn.vpn_subnet && <div><dt className="inline text-gray-400">Subnet </dt><dd className="inline font-mono">{conn.vpn_subnet}</dd></div>}
|
|
{conn.endpoint && <div><dt className="inline text-gray-400">Endpoint </dt><dd className="inline font-mono">{conn.endpoint}</dd></div>}
|
|
</dl>
|
|
<button
|
|
onClick={() => setConfirmDisconnect(true)}
|
|
className="btn btn-danger flex items-center gap-2 text-sm py-1.5"
|
|
>
|
|
<Unplug className="h-4 w-4" />
|
|
Disconnect
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
export default function CellNetwork() {
|
|
const { cell_name = 'mycell', domain = 'cell' } = useConfig();
|
|
const [toasts, addToast] = useToasts();
|
|
|
|
const [invite, setInvite] = useState(null);
|
|
const [inviteQr, setInviteQr] = useState('');
|
|
const [inviteLoading, setInviteLoading] = useState(true);
|
|
|
|
const [connections, setConnections] = useState([]);
|
|
const [connsLoading, setConnsLoading] = useState(true);
|
|
|
|
const [pasteText, setPasteText] = useState('');
|
|
const [pasteError, setPasteError] = useState('');
|
|
const [connecting, setConnecting] = useState(false);
|
|
|
|
useEffect(() => {
|
|
loadInvite();
|
|
loadConnections();
|
|
}, []);
|
|
|
|
const loadInvite = async () => {
|
|
setInviteLoading(true);
|
|
try {
|
|
const r = await cellLinkAPI.getInvite();
|
|
setInvite(r.data);
|
|
const qr = await QRCode.toDataURL(JSON.stringify(r.data), { width: 200, margin: 1 });
|
|
setInviteQr(qr);
|
|
} catch (e) {
|
|
addToast('Failed to load invite', 'error');
|
|
} finally {
|
|
setInviteLoading(false);
|
|
}
|
|
};
|
|
|
|
const loadConnections = async () => {
|
|
setConnsLoading(true);
|
|
try {
|
|
const r = await cellLinkAPI.listConnections();
|
|
const conns = r.data || [];
|
|
|
|
// Fetch all WireGuard peer statuses in one call and index by public_key
|
|
let statusByKey = {};
|
|
try {
|
|
const { wireguardAPI } = await import('../services/api');
|
|
const sr = await wireguardAPI.getPeerStatuses();
|
|
(sr.data?.peers || []).forEach(p => {
|
|
if (p.public_key) statusByKey[p.public_key] = p;
|
|
});
|
|
} catch {
|
|
// Status enrichment is best-effort; continue without it
|
|
}
|
|
|
|
const enriched = conns.map(conn => {
|
|
const wg = conn.public_key ? statusByKey[conn.public_key] : null;
|
|
return {
|
|
...conn,
|
|
online: wg ? wg.online : false,
|
|
last_handshake: wg ? wg.last_handshake : null,
|
|
};
|
|
});
|
|
|
|
setConnections(enriched);
|
|
} catch {
|
|
addToast('Failed to load connections', 'error');
|
|
} finally {
|
|
setConnsLoading(false);
|
|
}
|
|
};
|
|
|
|
const validatePaste = (text) => {
|
|
if (!text.trim()) { setPasteError(''); return; }
|
|
try {
|
|
const p = JSON.parse(text.trim());
|
|
if (!p.cell_name || !p.public_key || !p.vpn_subnet) {
|
|
setPasteError('JSON is missing required fields (cell_name, public_key, vpn_subnet)');
|
|
} else {
|
|
setPasteError('');
|
|
}
|
|
} catch {
|
|
setPasteError('Not valid JSON — paste the complete invite from the other cell');
|
|
}
|
|
};
|
|
|
|
const handleConnect = async () => {
|
|
if (!pasteText.trim()) return;
|
|
let parsed;
|
|
try {
|
|
parsed = JSON.parse(pasteText.trim());
|
|
} catch {
|
|
addToast('Invalid JSON — paste the full invite from the other cell', 'error');
|
|
return;
|
|
}
|
|
setConnecting(true);
|
|
try {
|
|
await cellLinkAPI.addConnection(parsed);
|
|
addToast(`Connected to cell "${parsed.cell_name}"`);
|
|
setPasteText('');
|
|
setPasteError('');
|
|
loadConnections();
|
|
} catch (e) {
|
|
addToast(e?.response?.data?.error || 'Connection failed', 'error');
|
|
} finally {
|
|
setConnecting(false);
|
|
}
|
|
};
|
|
|
|
// Confirmation is handled inside CellPanel via DisconnectConfirmModal
|
|
const handleDisconnect = async (name) => {
|
|
try {
|
|
await cellLinkAPI.removeConnection(name);
|
|
addToast(`Disconnected from "${name}"`);
|
|
loadConnections();
|
|
} catch (e) {
|
|
addToast(e?.response?.data?.error || 'Disconnect failed', 'error');
|
|
}
|
|
};
|
|
|
|
const inviteJson = invite ? JSON.stringify(invite, null, 2) : '';
|
|
|
|
return (
|
|
<div>
|
|
<Toast toasts={toasts} />
|
|
|
|
<div className="mb-8">
|
|
<h1 className="text-2xl font-bold text-gray-900">Cell Network</h1>
|
|
<p className="mt-2 text-gray-600">
|
|
Connect multiple PIC cells into a mesh — site-to-site WireGuard tunnels with automatic DNS forwarding.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
|
|
{/* ── This cell's invite ─────────────────────────────────────────── */}
|
|
<div className="card">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<div className="flex items-center gap-2">
|
|
<Plug className="h-5 w-5 text-primary-500" />
|
|
<h3 className="text-lg font-medium text-gray-900">Your Cell's Invite</h3>
|
|
</div>
|
|
<button onClick={loadInvite} className="text-gray-400 hover:text-gray-600" title="Refresh">
|
|
<RefreshCw className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
|
|
{inviteLoading ? (
|
|
<div className="flex justify-center py-8">
|
|
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary-600" />
|
|
</div>
|
|
) : invite ? (
|
|
<div className="space-y-4">
|
|
<div className="bg-gray-50 rounded-lg p-3 space-y-1 text-xs">
|
|
<div className="flex justify-between gap-2 min-w-0">
|
|
<span className="text-gray-500 shrink-0">Cell</span>
|
|
<span className="font-mono font-medium truncate" title={invite.cell_name}>{invite.cell_name}</span>
|
|
</div>
|
|
<div className="flex justify-between gap-2 min-w-0">
|
|
<span className="text-gray-500 shrink-0">Domain</span>
|
|
<span className="font-mono font-medium truncate" title={invite.domain}>{invite.domain}</span>
|
|
</div>
|
|
<div className="flex justify-between gap-2 min-w-0">
|
|
<span className="text-gray-500 shrink-0">Endpoint</span>
|
|
<span className="font-mono font-medium truncate" title={invite.endpoint || '(no external IP)'}>{invite.endpoint || '(no external IP)'}</span>
|
|
</div>
|
|
<div className="flex justify-between gap-2 min-w-0">
|
|
<span className="text-gray-500 shrink-0">VPN subnet</span>
|
|
<span className="font-mono font-medium truncate" title={invite.vpn_subnet}>{invite.vpn_subnet}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<div className="flex items-center justify-between mb-1">
|
|
<span className="text-sm text-gray-600">Invite JSON</span>
|
|
<CopyButton text={inviteJson} />
|
|
</div>
|
|
<pre className="bg-gray-900 text-green-400 text-xs rounded-lg p-3 overflow-x-auto max-h-40">
|
|
{inviteJson}
|
|
</pre>
|
|
</div>
|
|
|
|
{inviteQr && (
|
|
<div className="text-center">
|
|
<p className="text-xs text-gray-500 mb-2">Or scan with phone camera</p>
|
|
<img src={inviteQr} alt="Invite QR" className="inline-block border rounded-lg p-1 bg-white" />
|
|
</div>
|
|
)}
|
|
|
|
<p className="text-xs text-gray-400">
|
|
Share this JSON with the admin of another PIC cell. They paste it in "Connect to Cell" on their side.
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<p className="text-gray-500 text-sm">Could not load invite.</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* ── Connect to another cell ────────────────────────────────────── */}
|
|
<div className="card">
|
|
<div className="flex items-center gap-2 mb-4">
|
|
<Link2 className="h-5 w-5 text-primary-500" />
|
|
<h3 className="text-lg font-medium text-gray-900">Connect to Another Cell</h3>
|
|
</div>
|
|
|
|
<div className="space-y-3">
|
|
<p className="text-sm text-gray-600">
|
|
Paste the invite JSON from the other cell's "Your Cell's Invite" panel:
|
|
</p>
|
|
<div>
|
|
<textarea
|
|
value={pasteText}
|
|
onChange={e => { setPasteText(e.target.value); if (pasteError) validatePaste(e.target.value); }}
|
|
onBlur={e => validatePaste(e.target.value)}
|
|
placeholder={'{\n "cell_name": "...",\n "public_key": "...",\n ...\n}'}
|
|
rows={8}
|
|
className={`w-full text-xs font-mono border rounded-lg p-3 focus:outline-none focus:ring-2 focus:ring-primary-400 resize-none bg-white ${
|
|
pasteError ? 'border-red-400 focus:ring-red-400' : ''
|
|
}`}
|
|
/>
|
|
{pasteError && <p className="text-xs text-red-600 mt-1">{pasteError}</p>}
|
|
</div>
|
|
<button
|
|
onClick={handleConnect}
|
|
disabled={connecting || !pasteText.trim() || !!pasteError}
|
|
className="w-full btn btn-primary flex items-center justify-center gap-2 disabled:opacity-50"
|
|
>
|
|
{connecting
|
|
? <><div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white" /> Connecting…</>
|
|
: <><Link2 className="h-4 w-4" /> Connect</>
|
|
}
|
|
</button>
|
|
</div>
|
|
|
|
<div className="mt-4 bg-blue-50 border border-blue-200 rounded-lg p-3">
|
|
<p className="text-xs text-blue-800 font-medium mb-1">How it works</p>
|
|
<ol className="text-xs text-blue-700 space-y-1 list-decimal list-inside">
|
|
<li>Cell A copies its invite and sends it to Cell B's admin</li>
|
|
<li>Cell B pastes the invite and clicks Connect</li>
|
|
<li>Cell B copies its invite and sends it back to Cell A</li>
|
|
<li>Cell A pastes Cell B's invite and clicks Connect</li>
|
|
<li>Both cells can now reach each other's VPN peers and services</li>
|
|
</ol>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── Connected cells ────────────────────────────────────────────── */}
|
|
<div className="card lg:col-span-2">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<div className="flex items-center gap-2">
|
|
<Globe className="h-5 w-5 text-primary-500" />
|
|
<h3 className="text-lg font-medium text-gray-900">Connected Cells</h3>
|
|
</div>
|
|
<button onClick={loadConnections} className="text-gray-400 hover:text-gray-600" title="Refresh">
|
|
<RefreshCw className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
|
|
{connsLoading ? (
|
|
<div className="flex justify-center py-6">
|
|
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary-600" />
|
|
</div>
|
|
) : connections.length === 0 ? (
|
|
<div className="text-center py-8 text-gray-400">
|
|
<Wifi className="h-10 w-10 mx-auto mb-2 opacity-30" />
|
|
<p className="text-sm">No cells connected yet.</p>
|
|
<p className="text-xs mt-1">Use the panels above to establish the first cell link.</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-3">
|
|
{connections.map(conn => (
|
|
<CellPanel
|
|
key={conn.cell_name}
|
|
conn={conn}
|
|
onDisconnect={handleDisconnect}
|
|
addToast={addToast}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|