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()
# 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')
if ip_utils.write_env_file(new_range, env_file):
all_warnings.append(
'Container IPs updated — run `make start` to apply to running containers')
else:
all_warnings.append(
'Could not write .env — run `make setup && make start` to apply container IP changes')
ip_utils.write_env_file(new_range, env_file)
# Mark containers as needing restart
_set_pending_restart([f'ip_range changed to {new_range} — container IPs updated'])
logger.info(f"Updated config, restarted: {all_restarted}")
return jsonify({
@@ -510,6 +507,88 @@ def update_config():
logger.error(f"Error updating config: {e}")
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
@app.route('/api/config/backup', methods=['POST'])
def create_config_backup():
+1
View File
@@ -202,6 +202,7 @@ services:
- ./data/logs:/app/api/data/logs
- /var/run/docker.sock:/var/run/docker.sock
- ./.env:/app/.env.compose
- ./docker-compose.yml:/app/docker-compose.yml:ro
pid: host
restart: unless-stopped
networks:
+109 -10
View File
@@ -1,5 +1,5 @@
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import { useState, useEffect } from 'react';
import { useState, useEffect, useCallback } from 'react';
import {
Home,
Users,
@@ -14,9 +14,11 @@ import {
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: [] });
useEffect(() => {
const checkHealth = async () => {
const checkHealth = useCallback(async () => {
try {
await healthAPI.check();
setIsOnline(true);
} catch (error) {
console.error('Backend not available:', error);
} 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();
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 = [
@@ -113,6 +208,10 @@ function App() {
</div>
)}
{isOnline && pending.needs_restart && (
<PendingRestartBanner pending={pending} onApply={handleApply} />
)}
<Routes>
<Route path="/" element={<Dashboard isOnline={isOnline} />} />
<Route path="/peers" element={<Peers />} />
+2
View File
@@ -43,6 +43,8 @@ export const cellAPI = {
deleteBackup: (id) => api.delete(`/api/config/backups/${id}`),
exportConfig: (format = 'json') => api.get('/api/config/export', { params: { 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