import { useState, useEffect } from 'react'; import { Link2, Link2Off, Copy, CheckCheck, RefreshCw, Plug, Unplug, Globe, Wifi, Calendar, FolderOpen, Mail, HardDrive, ChevronDown, ChevronRight, ArrowUpFromLine } 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 SyncBadge({ conn }) { const { last_push_status, last_push_at, last_push_error, pending_push, next_retry_at } = conn; let color, label, tip; if (last_push_status === 'never' || (!last_push_at && !pending_push)) { color = 'bg-gray-300'; label = 'Sync pending'; tip = 'Permissions not yet synced to remote cell'; } else if (!pending_push && last_push_status === 'ok') { color = 'bg-green-500'; label = `Synced${last_push_at ? ' ' + relativeTime(last_push_at) : ''}`; tip = `Permissions last synced ${last_push_at ? relativeTime(last_push_at) : ''}`; } else { color = 'bg-amber-400'; label = 'Out of sync'; tip = last_push_error ? `Sync failed: ${last_push_error}` : 'Permissions pending sync'; if (next_retry_at) { const retryIn = Math.max(0, Math.round((new Date(next_retry_at) - Date.now()) / 60000)); tip += ` — next retry in ~${retryIn}m`; } } return ( {label} ); } 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 [exitOffered, setExitOffered] = useState(!!conn.exit_offered); const [savingExit, setSavingExit] = 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 handleExitToggle = async (newValue) => { setSavingExit(true); try { await cellLinkAPI.setExitOffer(conn.cell_name, newValue); setExitOffered(newValue); addToast(`Internet sharing ${newValue ? 'offered' : 'withdrawn'}`); } catch { addToast('Failed to update internet sharing', 'error'); } finally { setSavingExit(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.

{/* ── Internet sharing ───────────────────────────────────── */}

Internet Sharing

{conn.remote_exit_offered ? <>{conn.cell_name} offers internet : <>{conn.cell_name} doesn't offer internet}
{exitOffered && (

The {conn.cell_name} admin can route their peers' internet through your connection via Peers → Edit peer → Internet Exit.

)} {conn.remote_exit_offered && (

You can route any local peer's internet through {conn.cell_name} via Peers → Edit peer → Internet Exit.

)}
{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(); // API returns {pubkey: {online, last_handshake, ...}} — no .peers wrapper const raw = sr.data || {}; const entries = Array.isArray(raw.peers) ? raw.peers.map(p => [p.public_key, p]) : Object.entries(raw); entries.forEach(([pk, info]) => { if (pk) statusByKey[pk] = info; }); } 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: