Files
pic/webui/src/pages/Dashboard.jsx
T
roof 66500bb128
Unit Tests / test (push) Successful in 11m32s
fix: use effective_domain for service links and clean up stale DNS records
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>
2026-05-28 05:06:52 -04:00

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;