a10fe11136
Unit Tests / test (push) Successful in 11m24s
Email, calendar, and files no longer appear in the nav or as usable pages unless they are installed. The nav refreshes whenever a service is installed or removed via the new pic-services-changed CustomEvent. Changes: - routes/services.py: add GET /api/services/active endpoint - api.js: add servicesAPI.listActive() - App.jsx: replace hardcoded coreServiceChildren with dynamic state fetched from /api/services/active; SERVICE_META maps ids to nav entry shapes - ServiceNotInstalledBanner.jsx: new component — admin gets catalog link, peer gets "contact admin" message - EmailPage/CalendarPage/FilesPage: show banner when service not installed - ServicesIndex.jsx: remove CoreServiceCard + CORE_SERVICES "Built-in" section; rename Remove → Uninstall; dispatch pic-services-changed on install/uninstall success - MyServices.jsx: conditionally render service cards based on active list; placeholder card when absent; page-level notice when nothing is installed - tests/test_services_active_endpoint.py: 4 new endpoint tests Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
415 lines
17 KiB
React
415 lines
17 KiB
React
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
|
import { useState, useEffect, useCallback } from 'react';
|
|
import {
|
|
Home,
|
|
Users,
|
|
Network,
|
|
Shield,
|
|
Mail,
|
|
Calendar as CalendarIcon,
|
|
FolderOpen,
|
|
Activity,
|
|
Wifi,
|
|
Server,
|
|
Key,
|
|
Package,
|
|
Package2,
|
|
Settings as SettingsIcon,
|
|
Link2,
|
|
RefreshCw,
|
|
AlertTriangle,
|
|
User,
|
|
} from 'lucide-react';
|
|
import { healthAPI, cellAPI, servicesAPI } from './services/api';
|
|
import { ConfigProvider } from './contexts/ConfigContext';
|
|
import { DraftConfigProvider, useDraftConfig } from './contexts/DraftConfigContext';
|
|
import { AuthProvider, useAuth } from './contexts/AuthContext';
|
|
import PrivateRoute from './components/PrivateRoute';
|
|
import Sidebar from './components/Sidebar';
|
|
import Dashboard from './pages/Dashboard';
|
|
import Peers from './pages/Peers';
|
|
import NetworkServices from './pages/NetworkServices';
|
|
import WireGuard from './pages/WireGuard';
|
|
import Routing from './pages/Routing';
|
|
import Logs from './pages/Logs';
|
|
import Settings from './pages/Settings';
|
|
import Vault from './pages/Vault';
|
|
import ContainerDashboard from './components/ContainerDashboard';
|
|
import CellNetwork from './pages/CellNetwork';
|
|
import Login from './pages/Login';
|
|
import AccountSettings from './pages/AccountSettings';
|
|
import PeerDashboard from './pages/PeerDashboard';
|
|
import MyServices from './pages/MyServices';
|
|
import ServicesIndex from './pages/ServicesIndex';
|
|
import EmailPage from './pages/services/EmailPage';
|
|
import CalendarPage from './pages/services/CalendarPage';
|
|
import FilesPage from './pages/services/FilesPage';
|
|
import Connectivity from './pages/Connectivity';
|
|
import Setup from './pages/Setup';
|
|
import SetupGuard from './components/SetupGuard';
|
|
|
|
const SERVICE_META = {
|
|
email: { name: 'Email', href: '/services/email', icon: Mail },
|
|
calendar: { name: 'Calendar', href: '/services/calendar', icon: CalendarIcon },
|
|
files: { name: 'File Storage', href: '/services/files', icon: FolderOpen },
|
|
};
|
|
|
|
function PendingRestartBanner({ pending, onApply, onCancel }) {
|
|
const [confirming, setConfirming] = useState(false);
|
|
const [applying, setApplying] = useState(false);
|
|
const [cancelling, setCancelling] = useState(false);
|
|
|
|
const handleApply = async () => {
|
|
setApplying(true);
|
|
setConfirming(false);
|
|
try {
|
|
await onApply();
|
|
} finally {
|
|
setApplying(false);
|
|
}
|
|
};
|
|
|
|
const handleCancel = async () => {
|
|
setCancelling(true);
|
|
try {
|
|
await onCancel();
|
|
} finally {
|
|
setCancelling(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<div className="mb-6 bg-warning-50 border border-warning-300 rounded-lg p-4">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-start gap-3">
|
|
<AlertTriangle className="h-5 w-5 text-warning-500 mt-0.5 flex-shrink-0" />
|
|
<div>
|
|
<p className="text-sm font-medium text-warning-800">
|
|
Configuration changes pending — containers need restart
|
|
</p>
|
|
{pending.changes?.length > 0 && (
|
|
<ul className="mt-1 text-xs text-warning-700 list-disc list-inside">
|
|
{pending.changes.map((c, i) => <li key={i}>{c}</li>)}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="ml-4 flex-shrink-0 flex items-center gap-2">
|
|
<button
|
|
onClick={handleCancel}
|
|
disabled={applying || cancelling}
|
|
className="flex items-center gap-1.5 px-3 py-1.5 bg-white hover:bg-gray-50 disabled:opacity-50 text-warning-700 text-sm font-medium rounded-md border border-warning-300 transition-colors"
|
|
>
|
|
{cancelling ? 'Discarding…' : 'Discard'}
|
|
</button>
|
|
<button
|
|
onClick={() => setConfirming(true)}
|
|
disabled={applying || cancelling}
|
|
className="flex items-center gap-1.5 px-3 py-1.5 bg-warning-600 hover:bg-warning-700 disabled:opacity-50 text-white text-sm font-medium rounded-md transition-colors"
|
|
>
|
|
<RefreshCw className={`h-4 w-4 ${applying ? 'animate-spin' : ''}`} />
|
|
{applying ? 'Restarting…' : 'Apply Now'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{confirming && (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
|
<div className="bg-white rounded-xl shadow-2xl p-6 max-w-sm w-full mx-4">
|
|
<div className="flex items-center gap-3 mb-3">
|
|
<AlertTriangle className="h-6 w-6 text-warning-500 flex-shrink-0" />
|
|
<h3 className="text-base font-semibold text-gray-900">Restart containers?</h3>
|
|
</div>
|
|
<p className="text-sm text-gray-600 mb-5">
|
|
All containers will be restarted to apply the new configuration.
|
|
The UI will be briefly unavailable during the restart.
|
|
</p>
|
|
<div className="flex gap-3 justify-end">
|
|
<button
|
|
onClick={() => setConfirming(false)}
|
|
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-md transition-colors"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
onClick={handleApply}
|
|
className="px-4 py-2 text-sm font-medium text-white bg-warning-600 hover:bg-warning-700 rounded-md transition-colors"
|
|
>
|
|
Restart now
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
// AppCore is the real application — it consumes DraftConfigContext and must
|
|
// be rendered inside DraftConfigProvider (see App below).
|
|
function AppCore() {
|
|
const [isOnline, setIsOnline] = useState(false);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [pending, setPending] = useState({ needs_restart: false, changes: [] });
|
|
|
|
const checkHealth = useCallback(async () => {
|
|
try {
|
|
await healthAPI.check();
|
|
setIsOnline(true);
|
|
} catch {
|
|
setIsOnline(false);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
const checkPending = useCallback(async () => {
|
|
try {
|
|
const res = await cellAPI.getPending();
|
|
setPending(res.data);
|
|
} catch {
|
|
// ignore — not critical
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
checkHealth();
|
|
checkPending();
|
|
const healthInterval = setInterval(checkHealth, 5000);
|
|
const pendingInterval = setInterval(checkPending, 5000);
|
|
return () => {
|
|
clearInterval(healthInterval);
|
|
clearInterval(pendingInterval);
|
|
};
|
|
}, [checkHealth, checkPending]);
|
|
|
|
const [applyStatus, setApplyStatus] = useState(null); // null | 'saving' | 'restarting' | 'done' | 'timeout' | 'error'
|
|
const [applyError, setApplyError] = useState('');
|
|
|
|
const { flushAll, hasDirty } = useDraftConfig();
|
|
|
|
const handleApply = useCallback(async () => {
|
|
setApplyError('');
|
|
if (hasDirty()) {
|
|
setApplyStatus('saving');
|
|
try {
|
|
await flushAll();
|
|
} catch {
|
|
// flush errors are shown via Settings toasts; continue with apply
|
|
}
|
|
}
|
|
try {
|
|
await cellAPI.applyPending();
|
|
} catch (err) {
|
|
setApplyStatus('error');
|
|
setApplyError(err?.response?.data?.error || 'Apply request failed');
|
|
setTimeout(() => setApplyStatus(null), 6000);
|
|
return;
|
|
}
|
|
setPending({ needs_restart: false, changes: [] });
|
|
setApplyStatus('restarting');
|
|
|
|
// Poll health until API responds again (max 45 s; it may briefly drop if cell-api restarts)
|
|
const deadline = Date.now() + 45000;
|
|
while (Date.now() < deadline) {
|
|
await new Promise(r => setTimeout(r, 2000));
|
|
try {
|
|
await healthAPI.check();
|
|
setIsOnline(true);
|
|
setApplyStatus('done');
|
|
setTimeout(() => setApplyStatus(null), 4000);
|
|
return;
|
|
} catch {
|
|
setIsOnline(false);
|
|
}
|
|
}
|
|
setApplyStatus('timeout');
|
|
setApplyError('Containers may still be starting — check docker logs if services are unavailable');
|
|
setTimeout(() => setApplyStatus(null), 8000);
|
|
}, [flushAll, hasDirty]);
|
|
|
|
const handleCancel = useCallback(async () => {
|
|
await cellAPI.cancelPending();
|
|
setPending({ needs_restart: false, changes: [] });
|
|
window.dispatchEvent(new CustomEvent('pic-config-discarded'));
|
|
}, []);
|
|
|
|
const [activeServiceChildren, setActiveServiceChildren] = useState([]);
|
|
|
|
const fetchActiveServices = useCallback(async () => {
|
|
try {
|
|
const resp = await servicesAPI.listActive();
|
|
const children = (resp.data || [])
|
|
.filter(svc => SERVICE_META[svc.id])
|
|
.map(svc => SERVICE_META[svc.id]);
|
|
setActiveServiceChildren(children);
|
|
} catch {
|
|
// silent — empty nav children is safe
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
fetchActiveServices();
|
|
window.addEventListener('pic-services-changed', fetchActiveServices);
|
|
return () => window.removeEventListener('pic-services-changed', fetchActiveServices);
|
|
}, [fetchActiveServices]);
|
|
|
|
const adminNavigation = [
|
|
{ name: 'Dashboard', href: '/', icon: Home },
|
|
{ name: 'Peers', href: '/peers', icon: Users },
|
|
{ name: 'Network Services', href: '/network', icon: Network },
|
|
{ name: 'WireGuard', href: '/wireguard', icon: Shield },
|
|
{ name: 'Services', href: '/services', icon: Package, children: activeServiceChildren },
|
|
{ name: 'Routing', href: '/routing', icon: Wifi },
|
|
{ name: 'Vault', href: '/vault', icon: Key },
|
|
{ name: 'Containers', href: '/containers', icon: Package2 },
|
|
{ name: 'Cell Network', href: '/cell-network', icon: Link2 },
|
|
{ name: 'Connectivity', href: '/connectivity', icon: Network },
|
|
{ name: 'Logs', href: '/logs', icon: Activity },
|
|
{ name: 'Settings', href: '/settings', icon: SettingsIcon },
|
|
{ name: 'Account', href: '/account', icon: User },
|
|
];
|
|
|
|
const peerNavigation = [
|
|
{ name: 'Dashboard', href: '/', icon: Home },
|
|
{ name: 'My Services', href: '/my-services', icon: Wifi },
|
|
{ name: 'Services', href: '/services', icon: Package, children: activeServiceChildren },
|
|
{ name: 'Account', href: '/account', icon: User },
|
|
];
|
|
|
|
const { user } = useAuth();
|
|
const navigation = user?.role === 'peer' ? peerNavigation : adminNavigation;
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
|
<div className="text-center">
|
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto"></div>
|
|
<p className="mt-4 text-gray-600">Connecting to Personal Internet Cell...</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Router>
|
|
<SetupGuard>
|
|
<Routes>
|
|
<Route path="/setup" element={<Setup />} />
|
|
<Route path="/login" element={<Login />} />
|
|
<Route path="*" element={
|
|
<ConfigProvider>
|
|
<div className="min-h-screen bg-gray-50">
|
|
<Sidebar navigation={navigation} isOnline={isOnline} />
|
|
<div className="lg:pl-72">
|
|
<main className="py-10">
|
|
<div className="px-4 sm:px-6 lg:px-8">
|
|
{!isOnline && (
|
|
<div className="mb-6 bg-danger-50 border border-danger-200 rounded-lg p-4">
|
|
<div className="flex">
|
|
<div className="flex-shrink-0">
|
|
<Server className="h-5 w-5 text-danger-400" />
|
|
</div>
|
|
<div className="ml-3">
|
|
<h3 className="text-sm font-medium text-danger-800">
|
|
Backend Unavailable
|
|
</h3>
|
|
<div className="mt-2 text-sm text-danger-700">
|
|
<p>
|
|
Unable to connect to the Personal Internet Cell backend.
|
|
Please ensure the API server is running on port 3000.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{isOnline && pending.needs_restart && !applyStatus && (
|
|
<PendingRestartBanner pending={pending} onApply={handleApply} onCancel={handleCancel} />
|
|
)}
|
|
|
|
{applyStatus === 'saving' && (
|
|
<div className="mb-6 bg-blue-50 border border-blue-200 rounded-lg p-4 flex items-center gap-3">
|
|
<RefreshCw className="h-5 w-5 text-blue-500 animate-spin flex-shrink-0" />
|
|
<span className="text-sm font-medium text-blue-800">Saving settings…</span>
|
|
</div>
|
|
)}
|
|
|
|
{applyStatus === 'restarting' && (
|
|
<div className="mb-6 bg-blue-50 border border-blue-200 rounded-lg p-4 flex items-center gap-3">
|
|
<RefreshCw className="h-5 w-5 text-blue-500 animate-spin flex-shrink-0" />
|
|
<span className="text-sm font-medium text-blue-800">Restarting containers — please wait…</span>
|
|
</div>
|
|
)}
|
|
|
|
{applyStatus === 'done' && (
|
|
<div className="mb-6 bg-green-50 border border-green-200 rounded-lg p-4 flex items-center gap-3">
|
|
<span className="h-5 w-5 text-green-500 flex-shrink-0 text-lg leading-none">✓</span>
|
|
<span className="text-sm font-medium text-green-800">Containers restarted successfully</span>
|
|
</div>
|
|
)}
|
|
|
|
{(applyStatus === 'timeout' || applyStatus === 'error') && (
|
|
<div className="mb-6 bg-danger-50 border border-danger-200 rounded-lg p-4 flex items-center gap-3">
|
|
<AlertTriangle className="h-5 w-5 text-danger-500 flex-shrink-0" />
|
|
<span className="text-sm font-medium text-danger-800">{applyError}</span>
|
|
</div>
|
|
)}
|
|
|
|
<Routes>
|
|
<Route path="/" element={<PrivateRoute><RoleHome isOnline={isOnline} /></PrivateRoute>} />
|
|
<Route path="/account" element={<PrivateRoute><AccountSettings /></PrivateRoute>} />
|
|
<Route path="/my-services" element={<PrivateRoute requireRole="peer"><MyServices /></PrivateRoute>} />
|
|
{/* Service pages — accessible to both admin and peer (role-conditional content inside) */}
|
|
<Route path="/services" element={<PrivateRoute requireRole="admin"><ServicesIndex /></PrivateRoute>} />
|
|
<Route path="/services/email" element={<PrivateRoute><EmailPage /></PrivateRoute>} />
|
|
<Route path="/services/calendar" element={<PrivateRoute><CalendarPage /></PrivateRoute>} />
|
|
<Route path="/services/files" element={<PrivateRoute><FilesPage /></PrivateRoute>} />
|
|
{/* Legacy URL redirects */}
|
|
<Route path="/email" element={<Navigate to="/services/email" replace />} />
|
|
<Route path="/calendar" element={<Navigate to="/services/calendar" replace />} />
|
|
<Route path="/files" element={<Navigate to="/services/files" replace />} />
|
|
<Route path="/store" element={<Navigate to="/services" replace />} />
|
|
<Route path="/peers" element={<PrivateRoute requireRole="admin"><Peers /></PrivateRoute>} />
|
|
<Route path="/network" element={<PrivateRoute requireRole="admin"><NetworkServices /></PrivateRoute>} />
|
|
<Route path="/wireguard" element={<PrivateRoute requireRole="admin"><WireGuard /></PrivateRoute>} />
|
|
<Route path="/routing" element={<PrivateRoute requireRole="admin"><Routing /></PrivateRoute>} />
|
|
<Route path="/vault" element={<PrivateRoute requireRole="admin"><Vault /></PrivateRoute>} />
|
|
<Route path="/containers" element={<PrivateRoute requireRole="admin"><ContainerDashboard /></PrivateRoute>} />
|
|
<Route path="/cell-network" element={<PrivateRoute requireRole="admin"><CellNetwork /></PrivateRoute>} />
|
|
<Route path="/connectivity" element={<PrivateRoute requireRole="admin"><Connectivity /></PrivateRoute>} />
|
|
<Route path="/logs" element={<PrivateRoute requireRole="admin"><Logs /></PrivateRoute>} />
|
|
<Route path="/settings" element={<PrivateRoute requireRole="admin"><Settings /></PrivateRoute>} />
|
|
</Routes>
|
|
</div>
|
|
</main>
|
|
</div>
|
|
</div>
|
|
</ConfigProvider>
|
|
} />
|
|
</Routes>
|
|
</SetupGuard>
|
|
</Router>
|
|
);
|
|
}
|
|
|
|
function RoleHome({ isOnline }) {
|
|
const { user } = useAuth();
|
|
return user?.role === 'peer' ? <PeerDashboard /> : <Dashboard isOnline={isOnline} />;
|
|
}
|
|
|
|
function App() {
|
|
return (
|
|
<AuthProvider>
|
|
<DraftConfigProvider>
|
|
<AppCore />
|
|
</DraftConfigProvider>
|
|
</AuthProvider>
|
|
);
|
|
}
|
|
|
|
export default App;
|