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:
2026-04-22 11:29:26 -04:00
parent 1c939249e4
commit c3b2c8d8e5
4 changed files with 215 additions and 34 deletions
+85 -6
View File
@@ -493,12 +493,9 @@ def update_config():
firewall_manager.ensure_caddy_virtual_ips() firewall_manager.ensure_caddy_virtual_ips()
# Write new .env so docker-compose picks up new container IPs on next start # Write new .env so docker-compose picks up new container IPs on next start
env_file = os.environ.get('COMPOSE_ENV_FILE', '/app/.env.compose') env_file = os.environ.get('COMPOSE_ENV_FILE', '/app/.env.compose')
if ip_utils.write_env_file(new_range, env_file): ip_utils.write_env_file(new_range, env_file)
all_warnings.append( # Mark containers as needing restart
'Container IPs updated — run `make start` to apply to running containers') _set_pending_restart([f'ip_range changed to {new_range} — container IPs updated'])
else:
all_warnings.append(
'Could not write .env — run `make setup && make start` to apply container IP changes')
logger.info(f"Updated config, restarted: {all_restarted}") logger.info(f"Updated config, restarted: {all_restarted}")
return jsonify({ return jsonify({
@@ -510,6 +507,88 @@ def update_config():
logger.error(f"Error updating config: {e}") logger.error(f"Error updating config: {e}")
return jsonify({"error": str(e)}), 500 return jsonify({"error": str(e)}), 500
# ---------------------------------------------------------------------------
# Pending-restart helpers
# ---------------------------------------------------------------------------
def _set_pending_restart(changes: list):
"""Record that containers need to be restarted to apply configuration."""
from datetime import datetime as _dt
config_manager.configs['_pending_restart'] = {
'needs_restart': True,
'changed_at': _dt.utcnow().isoformat(),
'changes': changes,
}
config_manager._save_all_configs()
def _clear_pending_restart():
config_manager.configs['_pending_restart'] = {'needs_restart': False, 'changes': []}
config_manager._save_all_configs()
@app.route('/api/config/pending', methods=['GET'])
def get_pending_config():
"""Return whether there are unapplied configuration changes that require a restart."""
pending = config_manager.configs.get('_pending_restart', {})
return jsonify({
'needs_restart': pending.get('needs_restart', False),
'changed_at': pending.get('changed_at'),
'changes': pending.get('changes', []),
})
@app.route('/api/config/apply', methods=['POST'])
def apply_pending_config():
"""Apply pending configuration by restarting containers via docker compose up -d."""
try:
pending = config_manager.configs.get('_pending_restart', {})
if not pending.get('needs_restart'):
return jsonify({'message': 'No pending changes to apply'})
# Get project working dir from our own container labels (set by docker-compose)
project_dir = '/home/roof/pic'
try:
import docker as _docker_sdk
_client = _docker_sdk.from_env()
_self = _client.containers.get('cell-api')
project_dir = _self.labels.get('com.docker.compose.project.working_dir', project_dir)
except Exception:
pass
# Clear pending flag before we restart so it shows cleared after the new container starts
_clear_pending_restart()
# Run docker compose up -d in a background thread; the 0.3s delay lets Flask
# finish sending this response before cell-api itself gets recreated.
def _do_apply():
import time as _time
_time.sleep(0.3)
result = subprocess.run(
['docker', 'compose',
'--project-directory', project_dir,
'-f', '/app/docker-compose.yml',
'--env-file', '/app/.env.compose',
'up', '-d'],
capture_output=True, text=True, timeout=120
)
if result.returncode != 0:
logger.error(f"docker compose up failed: {result.stderr.strip()}")
else:
logger.info('docker compose up -d completed successfully')
threading.Thread(target=_do_apply, daemon=False).start()
return jsonify({
'message': 'Applying configuration — containers are restarting',
'restart_in_progress': True,
})
except Exception as e:
logger.error(f"Error applying config: {e}")
return jsonify({'error': str(e)}), 500
# Configuration management endpoints # Configuration management endpoints
@app.route('/api/config/backup', methods=['POST']) @app.route('/api/config/backup', methods=['POST'])
def create_config_backup(): def create_config_backup():
+1
View File
@@ -202,6 +202,7 @@ services:
- ./data/logs:/app/api/data/logs - ./data/logs:/app/api/data/logs
- /var/run/docker.sock:/var/run/docker.sock - /var/run/docker.sock:/var/run/docker.sock
- ./.env:/app/.env.compose - ./.env:/app/.env.compose
- ./docker-compose.yml:/app/docker-compose.yml:ro
pid: host pid: host
restart: unless-stopped restart: unless-stopped
networks: networks:
+127 -28
View File
@@ -1,22 +1,24 @@
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'; import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import { useState, useEffect } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { import {
Home, Home,
Users, Users,
Network, Network,
Shield, Shield,
Mail, Mail,
Calendar as CalendarIcon, Calendar as CalendarIcon,
FolderOpen, FolderOpen,
Activity, Activity,
Wifi, Wifi,
Server, Server,
Key, Key,
Package2, Package2,
Settings as SettingsIcon, Settings as SettingsIcon,
Link2 Link2,
RefreshCw,
AlertTriangle,
} from 'lucide-react'; } from 'lucide-react';
import { healthAPI } from './services/api'; import { healthAPI, cellAPI } from './services/api';
import { ConfigProvider } from './contexts/ConfigContext'; import { ConfigProvider } from './contexts/ConfigContext';
import Sidebar from './components/Sidebar'; import Sidebar from './components/Sidebar';
import Dashboard from './pages/Dashboard'; import Dashboard from './pages/Dashboard';
@@ -33,27 +35,120 @@ import Vault from './pages/Vault';
import ContainerDashboard from './components/ContainerDashboard'; import ContainerDashboard from './components/ContainerDashboard';
import CellNetwork from './pages/CellNetwork'; 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() { function App() {
const [isOnline, setIsOnline] = useState(false); const [isOnline, setIsOnline] = useState(false);
const [isLoading, setIsLoading] = useState(true); 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(() => { useEffect(() => {
const checkHealth = async () => {
try {
await healthAPI.check();
setIsOnline(true);
} catch (error) {
console.error('Backend not available:', error);
setIsOnline(false);
} finally {
setIsLoading(false);
}
};
checkHealth(); 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 = [ const navigation = [
@@ -88,7 +183,7 @@ function App() {
<ConfigProvider> <ConfigProvider>
<div className="min-h-screen bg-gray-50"> <div className="min-h-screen bg-gray-50">
<Sidebar navigation={navigation} isOnline={isOnline} /> <Sidebar navigation={navigation} isOnline={isOnline} />
<div className="lg:pl-72"> <div className="lg:pl-72">
<main className="py-10"> <main className="py-10">
<div className="px-4 sm:px-6 lg:px-8"> <div className="px-4 sm:px-6 lg:px-8">
@@ -104,7 +199,7 @@ function App() {
</h3> </h3>
<div className="mt-2 text-sm text-danger-700"> <div className="mt-2 text-sm text-danger-700">
<p> <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. Please ensure the API server is running on port 3000.
</p> </p>
</div> </div>
@@ -112,7 +207,11 @@ function App() {
</div> </div>
</div> </div>
)} )}
{isOnline && pending.needs_restart && (
<PendingRestartBanner pending={pending} onApply={handleApply} />
)}
<Routes> <Routes>
<Route path="/" element={<Dashboard isOnline={isOnline} />} /> <Route path="/" element={<Dashboard isOnline={isOnline} />} />
<Route path="/peers" element={<Peers />} /> <Route path="/peers" element={<Peers />} />
+2
View File
@@ -43,6 +43,8 @@ export const cellAPI = {
deleteBackup: (id) => api.delete(`/api/config/backups/${id}`), deleteBackup: (id) => api.delete(`/api/config/backups/${id}`),
exportConfig: (format = 'json') => api.get('/api/config/export', { params: { format } }), exportConfig: (format = 'json') => api.get('/api/config/export', { params: { format } }),
importConfig: (config, format = 'json') => api.post('/api/config/import', { config, format }), importConfig: (config, format = 'json') => api.post('/api/config/import', { config, format }),
getPending: () => api.get('/api/config/pending'),
applyPending: () => api.post('/api/config/apply'),
}; };
// Network Services API // Network Services API