Files
pic/webui/src/pages/Dashboard.jsx
T
roof e79ee08c63 fix: WireGuard routing, DNS, service access, and UI improvements
- Fix CoreDNS not loading .cell zones (wrong Corefile path, now uses -conf flag)
- Fix WireGuard server address conflict (172.20.0.1/16 overlapped with Docker
  network; changed to 10.0.0.1/24 to eliminate duplicate routes)
- Add SERVERMODE=true and sysctls to WireGuard docker-compose for server mode
- Fix DNS zone file parser to handle 4-field records (name IN type value)
- Add get_dns_records() to NetworkManager; mount data/dns into API container
- Fix peer config endpoint: look up IP/key from registry, use real endpoint
- Add bulk peer statuses endpoint keyed by public_key
- Normalize snake_case API fields to camelCase in WireGuard UI
- Add port check endpoint (checks via live handshake, not unreliable TCP probe)
- Add Caddy virtual hosts for ui/calendar/files/mail .cell domains (HTTP only)
- Fix cell config domain default from cell.local to cell
- Fix Routing Network Config tab (was calling hardcoded localhost:3000)
- Fix DNS records display (record.value not record.ip)
- Move service access guide to top of Dashboard with login hints
- Add /api/routing/setup endpoint

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 12:43:23 -04:00

354 lines
13 KiB
React

import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Server,
Users,
Shield,
Mail,
Calendar,
FolderOpen,
Wifi,
Activity,
CheckCircle,
XCircle,
AlertCircle,
Play,
Square,
RotateCcw
} from 'lucide-react';
import { cellAPI, servicesAPI } from '../services/api';
const SERVICES = [
{ name: 'Cell Home', url: 'http://mycell.cell', desc: 'Main UI — no login needed' },
{ name: 'Calendar', url: 'http://calendar.cell', desc: 'Login: your WireGuard username' },
{ name: 'Files', url: 'http://files.cell', desc: 'Login: admin / admin123' },
{ name: 'Webmail', url: 'http://mail.cell', desc: 'Login: admin@rainloop.net / 12345' },
];
function Dashboard({ isOnline }) {
const navigate = useNavigate();
const [cellStatus, setCellStatus] = useState(null);
const [servicesStatus, setServicesStatus] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [serviceControls, setServiceControls] = useState({});
useEffect(() => {
fetchData();
const interval = setInterval(fetchData, 5000); // Refresh every 30 seconds
return () => clearInterval(interval);
}, [isOnline]);
const getStatusIcon = (status) => {
if (status === true || status?.status === 'online' || status?.running === true) {
return <CheckCircle className="h-5 w-5 text-success-500" />;
} else if (status === false || status?.status === 'offline' || status?.running === false) {
return <XCircle className="h-5 w-5 text-danger-500" />;
} else {
return <AlertCircle className="h-5 w-5 text-warning-500" />;
}
};
const getStatusText = (status) => {
if (status === true || status?.status === 'online' || status?.running === true) {
return 'Online';
} else if (status === false || status?.status === 'offline' || status?.running === false) {
return 'Offline';
} else {
return 'Unknown';
}
};
const getStatusColor = (status) => {
if (status === true || status?.status === 'online' || status?.running === true) {
return 'text-success-600';
} else if (status === false || status?.status === 'offline' || status?.running === false) {
return 'text-danger-600';
} else {
return 'text-warning-600';
}
};
const formatUptime = (seconds) => {
if (!seconds || seconds < 0) return '0s';
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
const parts = [];
if (days > 0) parts.push(`${days}d`);
if (hours > 0) parts.push(`${hours}h`);
if (minutes > 0) parts.push(`${minutes}m`);
if (secs > 0 || parts.length === 0) parts.push(`${secs}s`);
return parts.join(' ');
};
const handleServiceControl = async (serviceName, action) => {
if (!isOnline) return;
setServiceControls(prev => ({ ...prev, [serviceName]: { ...prev[serviceName], [action]: 'loading' } }));
try {
let response;
switch (action) {
case 'start':
response = await servicesAPI.startService(serviceName);
break;
case 'stop':
response = await servicesAPI.stopService(serviceName);
break;
case 'restart':
response = await servicesAPI.restartService(serviceName);
break;
default:
throw new Error('Invalid action');
}
if (response.data.success || response.data.message) {
// Refresh status after successful control action
setTimeout(() => {
fetchData();
}, 1000);
}
} catch (error) {
console.error(`Failed to ${action} ${serviceName}:`, error);
} finally {
setServiceControls(prev => ({ ...prev, [serviceName]: { ...prev[serviceName], [action]: 'idle' } }));
}
};
const renderServiceCard = (serviceName, icon, displayName, status) => {
return (
<div className="card">
<div className="flex items-center justify-between">
<div className="flex items-center">
{icon}
<span className="ml-3 text-sm font-medium text-gray-900">{displayName}</span>
</div>
<div className="flex items-center space-x-2">
<div className="flex items-center">
{getStatusIcon(status)}
<span className={`ml-2 text-sm font-medium ${getStatusColor(status)}`}>
{getStatusText(status)}
</span>
</div>
<div className="flex space-x-1">
<button
onClick={() => handleServiceControl(serviceName, 'start')}
disabled={serviceControls[serviceName]?.start === 'loading' || status?.running}
className="p-1 text-green-600 hover:text-green-800 disabled:opacity-50 disabled:cursor-not-allowed"
title={`Start ${displayName} Service`}
>
<Play className="h-4 w-4" />
</button>
<button
onClick={() => handleServiceControl(serviceName, 'stop')}
disabled={serviceControls[serviceName]?.stop === 'loading' || !status?.running}
className="p-1 text-red-600 hover:text-red-800 disabled:opacity-50 disabled:cursor-not-allowed"
title={`Stop ${displayName} Service`}
>
<Square className="h-4 w-4" />
</button>
<button
onClick={() => handleServiceControl(serviceName, 'restart')}
disabled={serviceControls[serviceName]?.restart === 'loading'}
className="p-1 text-blue-600 hover:text-blue-800 disabled:opacity-50 disabled:cursor-not-allowed"
title={`Restart ${displayName} Service`}
>
<RotateCcw className="h-4 w-4" />
</button>
</div>
</div>
</div>
</div>
);
};
const fetchData = async () => {
if (!isOnline) {
setIsLoading(false);
return;
}
try {
const [statusResponse, servicesResponse] = await Promise.all([
cellAPI.getStatus(),
servicesAPI.getAllStatus()
]);
setCellStatus(statusResponse.data);
// Transform services data to match expected structure
const servicesData = servicesResponse.data;
const transformedServices = {
wireguard: servicesData.wireguard || { running: false, status: 'offline' },
email: servicesData.email || { running: false, status: 'offline' },
calendar: servicesData.calendar || { running: false, status: 'offline' },
files: servicesData.files || { running: false, status: 'offline' },
routing: servicesData.routing || { running: false, status: 'offline' },
network: servicesData.network || { running: false, status: 'offline' }
};
setServicesStatus(transformedServices);
} catch (error) {
console.error('Failed to fetch dashboard data:', error);
} finally {
setIsLoading(false);
}
};
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
</div>
);
}
return (
<div>
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900">Dashboard</h1>
<p className="mt-1 text-gray-600">Personal Internet Cell connect via WireGuard to access services</p>
</div>
{/* Access Services — shown first, no scroll needed */}
<div className="mb-8">
<h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">Services (connect via WireGuard first)</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
{SERVICES.map(svc => (
<a
key={svc.url}
href={svc.url}
target="_blank"
rel="noopener noreferrer"
className="card hover:shadow-md transition-shadow group border border-gray-100"
>
<p className="text-sm font-semibold text-primary-700 group-hover:text-primary-900">{svc.name}</p>
<p className="font-mono text-xs text-gray-400 mt-0.5 truncate">{svc.url}</p>
<p className="text-xs text-gray-500 mt-1">{svc.desc}</p>
</a>
))}
</div>
</div>
{/* Cell Status */}
{cellStatus && (
<div className="mb-8">
<h2 className="text-lg font-semibold text-gray-900 mb-4">Cell Status</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="card">
<div className="flex items-center">
<Server className="h-8 w-8 text-primary-500" />
<div className="ml-4">
<p className="text-sm font-medium text-gray-500">Cell Name</p>
<p className="text-lg font-semibold text-gray-900">{cellStatus.cell_name}</p>
</div>
</div>
</div>
<div className="card">
<div className="flex items-center">
<Users className="h-8 w-8 text-primary-500" />
<div className="ml-4">
<p className="text-sm font-medium text-gray-500">Peers</p>
<p className="text-lg font-semibold text-gray-900">{cellStatus.peers_count}</p>
</div>
</div>
</div>
<div className="card">
<div className="flex items-center">
<Activity className="h-8 w-8 text-primary-500" />
<div className="ml-4">
<p className="text-sm font-medium text-gray-500">Uptime</p>
<p className="text-lg font-semibold text-gray-900">
{formatUptime(cellStatus.uptime || 0)}
</p>
</div>
</div>
</div>
<div className="card">
<div className="flex items-center">
<div className="h-8 w-8 rounded-full bg-primary-100 flex items-center justify-center">
<div className="h-3 w-3 rounded-full bg-primary-600"></div>
</div>
<div className="ml-4">
<p className="text-sm font-medium text-gray-500">Status</p>
<p className="text-lg font-semibold text-gray-900">Active</p>
</div>
</div>
</div>
</div>
</div>
)}
{/* Services Status */}
{servicesStatus && (
<div className="mb-8">
<h2 className="text-lg font-semibold text-gray-900 mb-4">Services Status</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{renderServiceCard('wireguard', <Shield className="h-6 w-6 text-primary-500" />, 'WireGuard', servicesStatus.wireguard)}
{renderServiceCard('email', <Mail className="h-6 w-6 text-primary-500" />, 'Email', servicesStatus.email)}
{renderServiceCard('calendar', <Calendar className="h-6 w-6 text-primary-500" />, 'Calendar', servicesStatus.calendar)}
{renderServiceCard('files', <FolderOpen className="h-6 w-6 text-primary-500" />, 'Files', servicesStatus.files)}
{renderServiceCard('routing', <Wifi className="h-6 w-6 text-primary-500" />, 'Routing', servicesStatus.routing)}
{renderServiceCard('network', <Server className="h-6 w-6 text-primary-500" />, 'Network', servicesStatus.network)}
</div>
</div>
)}
{/* Quick Actions */}
<div>
<h2 className="text-lg font-semibold text-gray-900 mb-4">Quick Actions</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<button
onClick={() => navigate('/peers')}
className="card hover:shadow-md transition-shadow cursor-pointer"
>
<div className="flex items-center">
<Users className="h-6 w-6 text-primary-500" />
<span className="ml-3 text-sm font-medium text-gray-900">Manage Peers</span>
</div>
</button>
<button
onClick={() => navigate('/wireguard')}
className="card hover:shadow-md transition-shadow cursor-pointer"
>
<div className="flex items-center">
<Shield className="h-6 w-6 text-primary-500" />
<span className="ml-3 text-sm font-medium text-gray-900">WireGuard Config</span>
</div>
</button>
<button
onClick={() => navigate('/routing')}
className="card hover:shadow-md transition-shadow cursor-pointer"
>
<div className="flex items-center">
<Wifi className="h-6 w-6 text-primary-500" />
<span className="ml-3 text-sm font-medium text-gray-900">Routing Rules</span>
</div>
</button>
<button
onClick={() => navigate('/logs')}
className="card hover:shadow-md transition-shadow cursor-pointer"
>
<div className="flex items-center">
<Activity className="h-6 w-6 text-primary-500" />
<span className="ml-3 text-sm font-medium text-gray-900">View Logs</span>
</div>
</button>
</div>
</div>
</div>
);
}
export default Dashboard;