66500bb128
Unit Tests / test (push) Successful in 11m32s
Dashboard, Email, Calendar, and Files pages were building service URLs with the internal LAN zone name (e.g. 'cell') instead of the public effective domain (e.g. 'pic2.pic.ngo'), and always using http:// even in DDNS mode where HTTPS is available. Changes: - Dashboard/Email/Calendar/Files: read effective_domain + domain_mode from ConfigContext; use effective_domain in non-LAN mode and https:// for all DDNS domain modes. - Calendar: show port 443 instead of 80 in DDNS mode. - network_manager.update_split_horizon_zone: when the primary internal zone name is a parent of the effective DDNS domain (e.g. pic.ngo is a parent of pic2.pic.ngo), remove stale bootstrap service records (api, calendar, files, mail, webmail, webdav) that pollute the DNS display and would shadow public DNS responses. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
357 lines
14 KiB
React
357 lines
14 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';
|
|
import { useConfig } from '../contexts/ConfigContext';
|
|
|
|
function Dashboard({ isOnline }) {
|
|
const navigate = useNavigate();
|
|
const { domain = 'cell', cell_name = 'mycell', effective_domain, domain_mode = 'lan' } = useConfig();
|
|
const svcDomain = (domain_mode !== 'lan' && effective_domain) ? effective_domain : domain;
|
|
const proto = domain_mode === 'lan' ? 'http' : 'https';
|
|
const SERVICES = [
|
|
{ name: 'Cell Home', url: domain_mode === 'lan' ? `http://${cell_name}.${domain}` : `https://${svcDomain}`, desc: 'Main UI — no login needed' },
|
|
{ name: 'Calendar', url: `${proto}://calendar.${svcDomain}`, desc: 'Use your configured account credentials' },
|
|
{ name: 'Files', url: `${proto}://files.${svcDomain}`, desc: 'Use your configured account credentials' },
|
|
{ name: 'Webmail', url: `${proto}://mail.${svcDomain}`, desc: 'Use your configured account credentials' },
|
|
];
|
|
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 min-w-0">
|
|
<p className="text-sm font-medium text-gray-500">Cell Name</p>
|
|
<p className="text-lg font-semibold text-gray-900 truncate" title={cellStatus.cell_name}>{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; |