feat: pending-restart banner + Apply button for config changes
When ip_range changes, a persistent amber banner appears at the top of
every page showing what changed and a "Apply Now" button. Clicking it
shows a confirmation modal ("containers will restart briefly"), then
calls POST /api/config/apply which runs docker compose up -d from inside
the API container — no manual make start needed.
Backend:
- _set_pending_restart() / _clear_pending_restart() helpers track state
in config_manager so it survives page refresh
- GET /api/config/pending returns { needs_restart, changed_at, changes }
- POST /api/config/apply runs docker compose up -d via the mounted
docker.sock, using the project working_dir label to resolve host paths
- docker-compose.yml mounts docker-compose.yml itself read-only into
the API container so docker compose can read it from inside
Frontend (App.jsx):
- Polls /api/config/pending every 5 s alongside the health check
- PendingRestartBanner component with confirmation modal
- Optimistically clears banner on Apply click; API and containers
restart in the background
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+127
-28
@@ -1,22 +1,24 @@
|
||||
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Home,
|
||||
Users,
|
||||
Network,
|
||||
Shield,
|
||||
Mail,
|
||||
Calendar as CalendarIcon,
|
||||
FolderOpen,
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Home,
|
||||
Users,
|
||||
Network,
|
||||
Shield,
|
||||
Mail,
|
||||
Calendar as CalendarIcon,
|
||||
FolderOpen,
|
||||
Activity,
|
||||
Wifi,
|
||||
Server,
|
||||
Key,
|
||||
Package2,
|
||||
Settings as SettingsIcon,
|
||||
Link2
|
||||
Link2,
|
||||
RefreshCw,
|
||||
AlertTriangle,
|
||||
} from 'lucide-react';
|
||||
import { healthAPI } from './services/api';
|
||||
import { healthAPI, cellAPI } from './services/api';
|
||||
import { ConfigProvider } from './contexts/ConfigContext';
|
||||
import Sidebar from './components/Sidebar';
|
||||
import Dashboard from './pages/Dashboard';
|
||||
@@ -33,27 +35,120 @@ import Vault from './pages/Vault';
|
||||
import ContainerDashboard from './components/ContainerDashboard';
|
||||
import CellNetwork from './pages/CellNetwork';
|
||||
|
||||
function PendingRestartBanner({ pending, onApply }) {
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [applying, setApplying] = useState(false);
|
||||
|
||||
const handleApply = async () => {
|
||||
setApplying(true);
|
||||
setConfirming(false);
|
||||
try {
|
||||
await onApply();
|
||||
} finally {
|
||||
setApplying(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>
|
||||
<button
|
||||
onClick={() => setConfirming(true)}
|
||||
disabled={applying}
|
||||
className="ml-4 flex-shrink-0 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>
|
||||
|
||||
{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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
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(() => {
|
||||
const checkHealth = async () => {
|
||||
try {
|
||||
await healthAPI.check();
|
||||
setIsOnline(true);
|
||||
} catch (error) {
|
||||
console.error('Backend not available:', error);
|
||||
setIsOnline(false);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
checkHealth();
|
||||
const interval = setInterval(checkHealth, 5000); // Check every 30 seconds
|
||||
checkPending();
|
||||
const healthInterval = setInterval(checkHealth, 5000);
|
||||
const pendingInterval = setInterval(checkPending, 5000);
|
||||
return () => {
|
||||
clearInterval(healthInterval);
|
||||
clearInterval(pendingInterval);
|
||||
};
|
||||
}, [checkHealth, checkPending]);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
const handleApply = useCallback(async () => {
|
||||
await cellAPI.applyPending();
|
||||
// Optimistically clear the banner; containers are restarting
|
||||
setPending({ needs_restart: false, changes: [] });
|
||||
}, []);
|
||||
|
||||
const navigation = [
|
||||
@@ -88,7 +183,7 @@ function App() {
|
||||
<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">
|
||||
@@ -104,7 +199,7 @@ function App() {
|
||||
</h3>
|
||||
<div className="mt-2 text-sm text-danger-700">
|
||||
<p>
|
||||
Unable to connect to the Personal Internet Cell backend.
|
||||
Unable to connect to the Personal Internet Cell backend.
|
||||
Please ensure the API server is running on port 3000.
|
||||
</p>
|
||||
</div>
|
||||
@@ -112,7 +207,11 @@ function App() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{isOnline && pending.needs_restart && (
|
||||
<PendingRestartBanner pending={pending} onApply={handleApply} />
|
||||
)}
|
||||
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard isOnline={isOnline} />} />
|
||||
<Route path="/peers" element={<Peers />} />
|
||||
|
||||
Reference in New Issue
Block a user