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 ( ); } function StatusDot({ online }) { if (online === null || online === undefined) { return ; } return online ? : ; } function Toast({ toasts }) { return (
{toasts.map(t => (
{t.msg}
))}
); } 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 (

Disconnect "{cellName}"?

This will remove the WireGuard tunnel and all sharing permissions between your cells.

The other cell's admin will need to remove the connection on their end too. Shared services will become inaccessible immediately.

); } function ServiceShareToggle({ serviceKey, label, Icon, enabled, saving, onChange }) { return ( ); } function InboundServiceBadge({ label, Icon, active }) { return ( {label} ); } 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 && ( { setConfirmDisconnect(false); onDisconnect(conn.cell_name); }} onCancel={() => setConfirmDisconnect(false)} /> )}
{open && (

I share with {conn.cell_name}

{SERVICE_DEFS.map(({ key, label, Icon }) => ( handleToggle(key, v)} /> ))}
{hasRevokedService && (

Services you stop sharing become unreachable from {conn.cell_name} immediately.

)}

{conn.cell_name} shares with me

{(conn.permissions?.outbound && Object.values(conn.permissions.outbound).some(Boolean)) ? (
{SERVICE_DEFS.map(({ key, label, Icon }) => ( ))}
) : (

Nothing shared yet.

)}

Inbound sharing is set by the other cell's admin.

{conn.vpn_subnet &&
Subnet
{conn.vpn_subnet}
} {conn.endpoint &&
Endpoint
{conn.endpoint}
}
)}
); } 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 (

Cell Network

Connect multiple PIC cells into a mesh — site-to-site WireGuard tunnels with automatic DNS forwarding.

{/* ── This cell's invite ─────────────────────────────────────────── */}

Your Cell's Invite

{inviteLoading ? (
) : invite ? (
Cell {invite.cell_name}
Domain {invite.domain}
Endpoint {invite.endpoint || '(no external IP)'}
VPN subnet {invite.vpn_subnet}
Invite JSON
                  {inviteJson}
                
{inviteQr && (

Or scan with phone camera

Invite QR
)}

Share this JSON with the admin of another PIC cell. They paste it in "Connect to Cell" on their side.

) : (

Could not load invite.

)}
{/* ── Connect to another cell ────────────────────────────────────── */}

Connect to Another Cell

Paste the invite JSON from the other cell's "Your Cell's Invite" panel: