From f300951e8ae0136c479188bc0981c2ba1967f876 Mon Sep 17 00:00:00 2001 From: xTITUSMAXIMUSX Date: Wed, 25 Feb 2026 16:01:13 -0600 Subject: [PATCH] Add commit confirm option --- backend/commit_confirm_state.py | 91 +++++++++ backend/middleware/session.py | 6 + backend/routers/config/config.py | 78 ++++++++ backend/routers/firewall/groups.py | 1 - backend/routers/session/session.py | 32 +++- backend/session_vyos_service.py | 3 + backend/vyos_service.py | 145 +++++++++++++- .../migration.sql | 3 + frontend/prisma/schema.prisma | 4 + .../config/UnsavedChangesBanner.tsx | 179 ++++++++++++++---- .../components/sites/CreateInstanceModal.tsx | 46 +++++ .../components/sites/EditInstanceModal.tsx | 48 +++++ frontend/src/lib/api/config.ts | 23 +++ frontend/src/lib/api/session.ts | 6 + 14 files changed, 622 insertions(+), 43 deletions(-) create mode 100644 backend/commit_confirm_state.py create mode 100644 frontend/prisma/migrations/20260225_add_commit_confirm_fields/migration.sql diff --git a/backend/commit_confirm_state.py b/backend/commit_confirm_state.py new file mode 100644 index 00000000..b950b8e9 --- /dev/null +++ b/backend/commit_confirm_state.py @@ -0,0 +1,91 @@ +""" +Commit-Confirm State Tracker + +Tracks active commit-confirm sessions per VyOS instance. +When a commit-confirm is active, no further changes are allowed +until the user confirms or the timer expires (and VyOS auto-reverts). +""" + +from datetime import datetime, timedelta, timezone +from typing import Optional, Dict +from dataclasses import dataclass, field +import logging + +logger = logging.getLogger(__name__) + + +@dataclass +class CommitConfirmSession: + instance_id: str + started_at: datetime + expires_at: datetime + confirm_time_minutes: int + action: str # "reload" or "reboot" + + def seconds_remaining(self) -> int: + delta = self.expires_at - datetime.now(timezone.utc) + return max(0, int(delta.total_seconds())) + + def is_expired(self) -> bool: + return datetime.now(timezone.utc) >= self.expires_at + + def to_dict(self) -> dict: + return { + "active": True, + "instance_id": self.instance_id, + "confirm_time_minutes": self.confirm_time_minutes, + "action": self.action, + "started_at": self.started_at.isoformat(), + "expires_at": self.expires_at.isoformat(), + "seconds_remaining": self.seconds_remaining(), + } + + +# In-memory store keyed by instance_id +_sessions: Dict[str, CommitConfirmSession] = {} + + +def set_active( + instance_id: str, + confirm_time_minutes: int, + action: str = "reload", +) -> CommitConfirmSession: + """Record a new active commit-confirm session for an instance.""" + now = datetime.now(timezone.utc) + session = CommitConfirmSession( + instance_id=instance_id, + started_at=now, + expires_at=now + timedelta(minutes=confirm_time_minutes), + confirm_time_minutes=confirm_time_minutes, + action=action, + ) + _sessions[instance_id] = session + logger.info( + "Commit-confirm started for instance %s (%d min, action=%s)", + instance_id, confirm_time_minutes, action, + ) + return session + + +def get_active(instance_id: str) -> Optional[CommitConfirmSession]: + """Return the active session if it exists and hasn't expired.""" + session = _sessions.get(instance_id) + if session is None: + return None + if session.is_expired(): + logger.info("Commit-confirm expired for instance %s — VyOS should have reverted", instance_id) + del _sessions[instance_id] + return None + return session + + +def is_active(instance_id: str) -> bool: + """Return True if there is an active (non-expired) commit-confirm.""" + return get_active(instance_id) is not None + + +def clear(instance_id: str) -> None: + """Clear the commit-confirm session (after confirm or explicit discard).""" + if instance_id in _sessions: + del _sessions[instance_id] + logger.info("Commit-confirm cleared for instance %s", instance_id) diff --git a/backend/middleware/session.py b/backend/middleware/session.py index 465ec0b0..f1bc16a6 100644 --- a/backend/middleware/session.py +++ b/backend/middleware/session.py @@ -131,6 +131,8 @@ async def dispatch(self, request: Request, call_next): i."vyosVersion" as vyos_version, i.protocol, i."verifySsl" as verify_ssl, + i."commitConfirmEnabled" as commit_confirm_enabled, + i."commitConfirmMinutes" as commit_confirm_minutes, s.name as site_name, 'ADMIN' as user_role FROM active_sessions a @@ -158,6 +160,8 @@ async def dispatch(self, request: Request, call_next): i."vyosVersion" as vyos_version, i.protocol, i."verifySsl" as verify_ssl, + i."commitConfirmEnabled" as commit_confirm_enabled, + i."commitConfirmMinutes" as commit_confirm_minutes, s.name as site_name, uir.role as user_role FROM active_sessions a @@ -215,6 +219,8 @@ async def dispatch(self, request: Request, call_next): "vyos_version": session.get("vyos_version"), "protocol": session.get("protocol"), "verify_ssl": session.get("verify_ssl"), + "commit_confirm_enabled": session.get("commit_confirm_enabled") or False, + "commit_confirm_minutes": session.get("commit_confirm_minutes") or 5, } request.state.site = { "id": session["site_id"], diff --git a/backend/routers/config/config.py b/backend/routers/config/config.py index 78bb86d0..cbcd0f14 100644 --- a/backend/routers/config/config.py +++ b/backend/routers/config/config.py @@ -12,6 +12,7 @@ from session_vyos_service import get_session_vyos_service from fastapi_permissions import require_read_permission, require_write_permission from rbac_permissions import FeatureGroup +import commit_confirm_state import json import logging logger = logging.getLogger(__name__) @@ -256,3 +257,80 @@ async def refresh_config(request: Request): raise HTTPException(status_code=500, detail="Internal server error") +# ======================================================================== +# Commit-Confirm Endpoints +# ======================================================================== + +class CommitConfirmStatusResponse(BaseModel): + active: bool + instance_id: Optional[str] = None + confirm_time_minutes: Optional[int] = None + action: Optional[str] = None + seconds_remaining: Optional[int] = None + expires_at: Optional[str] = None + + +class CommitConfirmRequest(BaseModel): + confirm_time_minutes: int = 5 + action: str = "reload" + + +@router.get("/commit-confirm/status", response_model=CommitConfirmStatusResponse) +async def get_commit_confirm_status(request: Request): + """ + Get the current commit-confirm status for the active instance. + + Returns active=True with countdown info if a commit-confirm is in progress, + or active=False if no commit-confirm is active (or it has expired). + """ + await require_read_permission(request, FeatureGroup.CONFIGURATION) + try: + instance_id = request.state.instance["id"] + session = commit_confirm_state.get_active(instance_id) + if session is None: + return CommitConfirmStatusResponse(active=False) + return CommitConfirmStatusResponse(**session.to_dict()) + except HTTPException: + raise + except Exception: + logger.exception("Unhandled error in /config/commit-confirm/status") + raise HTTPException(status_code=500, detail="Internal server error") + + +@router.post("/commit-confirm/confirm", response_model=SaveConfigResponse) +async def confirm_commit(request: Request): + """ + Confirm the active commit-confirm, stopping the rollback timer. + + This makes the previously applied changes permanent and saves the + configuration to disk. + """ + await require_write_permission(request, FeatureGroup.CONFIGURATION) + try: + service = get_session_vyos_service(request) + instance_id = request.state.instance["id"] + + response = await run_in_threadpool(service.confirm_commit, instance_id) + + if response.status != 200: + return SaveConfigResponse( + success=False, + message="Failed to confirm commit", + error=response.error or "Unknown error", + ) + + # Refresh the config cache so the unsaved-changes banner reflects + # the new running config, prompting the user to save when ready. + await run_in_threadpool(service.get_full_config, refresh=True) + + return SaveConfigResponse( + success=True, + message="Commit confirmed — changes are live. Save configuration when ready.", + ) + except HTTPException: + raise + except Exception: + logger.exception("Unhandled error in /config/commit-confirm/confirm") + raise HTTPException(status_code=500, detail="Internal server error") + + diff --git a/backend/routers/firewall/groups.py b/backend/routers/firewall/groups.py index 5f4f6afa..15a66009 100644 --- a/backend/routers/firewall/groups.py +++ b/backend/routers/firewall/groups.py @@ -704,7 +704,6 @@ async def configure_group_batch(http_request: Request, request: GroupBatchReques detail=f"Unsupported operation: {op_type}" ) - # Execute the batch response = service.execute_batch(batch) # Handle empty string result (convert to None for Pydantic validation) diff --git a/backend/routers/session/session.py b/backend/routers/session/session.py index 5ef66de0..74553c99 100644 --- a/backend/routers/session/session.py +++ b/backend/routers/session/session.py @@ -76,6 +76,8 @@ class InstanceResponse(BaseModel): ssh_port: int = 22 ssh_username: Optional[str] = None ssh_key_configured: bool = False + commit_confirm_enabled: bool = False + commit_confirm_minutes: int = 5 created_at: datetime updated_at: datetime @@ -95,6 +97,8 @@ class InstanceCreateRequest(BaseModel): is_active: bool = Field(default=True, description="Whether instance is active") ssh_port: int = Field(default=22, ge=1, le=65535, description="SSH port for monitoring") ssh_username: Optional[str] = Field(None, description="SSH username for monitoring") + commit_confirm_enabled: bool = Field(default=False, description="Use commit-confirm for all changes (VyOS 1.5+ only)") + commit_confirm_minutes: int = Field(default=5, ge=1, le=60, description="Minutes before auto-revert if not confirmed") class InstanceUpdateRequest(BaseModel): @@ -112,6 +116,8 @@ class InstanceUpdateRequest(BaseModel): site_id: Optional[str] = Field(None, description="Move to different site") ssh_port: Optional[int] = Field(None, ge=1, le=65535, description="SSH port for monitoring") ssh_username: Optional[str] = Field(None, description="SSH username for monitoring") + commit_confirm_enabled: Optional[bool] = Field(None, description="Use commit-confirm for all changes (VyOS 1.5+ only)") + commit_confirm_minutes: Optional[int] = Field(None, ge=1, le=60, description="Minutes before auto-revert if not confirmed") class ActiveSessionResponse(BaseModel): @@ -583,6 +589,7 @@ async def list_site_instances(request: Request, site_id: str): """ SELECT id, "siteId", name, description, host, port, protocol, "verifySsl", "isActive", "vyosVersion", "sshPort", "sshUsername", "sshKeyConfigured", + "commitConfirmEnabled", "commitConfirmMinutes", "createdAt", "updatedAt" FROM instances WHERE "siteId" = $1 @@ -596,6 +603,7 @@ async def list_site_instances(request: Request, site_id: str): """ SELECT DISTINCT i.id, i."siteId", i.name, i.description, i.host, i.port, i.protocol, i."verifySsl", i."isActive", i."vyosVersion", i."sshPort", i."sshUsername", i."sshKeyConfigured", + i."commitConfirmEnabled", i."commitConfirmMinutes", i."createdAt", i."updatedAt" FROM instances i JOIN user_instance_roles uir ON i.id = uir."instanceId" @@ -624,6 +632,8 @@ async def list_site_instances(request: Request, site_id: str): ssh_port=inst["sshPort"], ssh_username=inst["sshUsername"], ssh_key_configured=inst["sshKeyConfigured"], + commit_confirm_enabled=inst.get("commitConfirmEnabled") or False, + commit_confirm_minutes=inst.get("commitConfirmMinutes") or 5, created_at=inst["createdAt"], updated_at=inst["updatedAt"], ) @@ -923,11 +933,13 @@ async def create_instance(request: Request, body: InstanceCreateRequest): id, "siteId", name, description, host, port, username, password, "apiKey", "vyosVersion", protocol, "verifySsl", "isActive", "sshPort", "sshUsername", + "commitConfirmEnabled", "commitConfirmMinutes", "createdAt", "updatedAt" ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, NOW(), NOW()) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, NOW(), NOW()) RETURNING id, "siteId", name, description, host, port, protocol, "verifySsl", "vyosVersion", "isActive", "sshPort", "sshUsername", "sshKeyConfigured", + "commitConfirmEnabled", "commitConfirmMinutes", "createdAt", "updatedAt" """, instance_id, @@ -945,6 +957,8 @@ async def create_instance(request: Request, body: InstanceCreateRequest): body.is_active, body.ssh_port, body.ssh_username, + body.commit_confirm_enabled, + body.commit_confirm_minutes, ) clear_session_cache(instance_id) @@ -963,6 +977,8 @@ async def create_instance(request: Request, body: InstanceCreateRequest): ssh_port=instance["sshPort"], ssh_username=instance["sshUsername"], ssh_key_configured=instance["sshKeyConfigured"], + commit_confirm_enabled=instance.get("commitConfirmEnabled") or False, + commit_confirm_minutes=instance.get("commitConfirmMinutes") or 5, created_at=instance["createdAt"], updated_at=instance["updatedAt"], ) @@ -1096,12 +1112,23 @@ async def update_instance(request: Request, instance_id: str, body: InstanceUpda params.append(body.ssh_username) param_num += 1 + if body.commit_confirm_enabled is not None: + updates.append(f'"commitConfirmEnabled" = ${param_num}') + params.append(body.commit_confirm_enabled) + param_num += 1 + + if body.commit_confirm_minutes is not None: + updates.append(f'"commitConfirmMinutes" = ${param_num}') + params.append(body.commit_confirm_minutes) + param_num += 1 + if not updates: # No fields to update, return current instance instance = await conn.fetchrow( """ SELECT id, "siteId", name, description, host, port, protocol, "verifySsl", "vyosVersion", "isActive", "sshPort", "sshUsername", "sshKeyConfigured", + "commitConfirmEnabled", "commitConfirmMinutes", "createdAt", "updatedAt" FROM instances WHERE id = $1 """, @@ -1115,6 +1142,7 @@ async def update_instance(request: Request, instance_id: str, body: InstanceUpda WHERE id = $1 RETURNING id, "siteId", name, description, host, port, protocol, "verifySsl", "vyosVersion", "isActive", "sshPort", "sshUsername", "sshKeyConfigured", + "commitConfirmEnabled", "commitConfirmMinutes", "createdAt", "updatedAt" """ instance = await conn.fetchrow(query, *params) @@ -1139,6 +1167,8 @@ async def update_instance(request: Request, instance_id: str, body: InstanceUpda ssh_port=instance["sshPort"], ssh_username=instance["sshUsername"], ssh_key_configured=instance["sshKeyConfigured"], + commit_confirm_enabled=instance.get("commitConfirmEnabled") or False, + commit_confirm_minutes=instance.get("commitConfirmMinutes") or 5, created_at=instance["createdAt"], updated_at=instance["updatedAt"], ) diff --git a/backend/session_vyos_service.py b/backend/session_vyos_service.py index 31cfaec1..e15a780b 100644 --- a/backend/session_vyos_service.py +++ b/backend/session_vyos_service.py @@ -90,6 +90,9 @@ async def get_interfaces(request: Request): port=instance.get("port", 443), verify=verify, timeout=30, + commit_confirm_enabled=instance.get("commit_confirm_enabled") or False, + commit_confirm_minutes=instance.get("commit_confirm_minutes") or 5, + instance_id=instance_id, ) # Register the service diff --git a/backend/vyos_service.py b/backend/vyos_service.py index 482fec0d..1113233a 100644 --- a/backend/vyos_service.py +++ b/backend/vyos_service.py @@ -7,9 +7,12 @@ from typing import Optional, Union, Dict, Any, List from contextlib import contextmanager +import json +import requests as _requests from pyvyos import VyDevice from pyvyos.core.rest_client import ApiResponse +import commit_confirm_state from vyos_builders import ( EthernetBatchBuilder, DummyBatchBuilder, @@ -33,6 +36,9 @@ def __init__( port: int = 443, verify: bool = False, timeout: int = 10, + commit_confirm_enabled: bool = False, + commit_confirm_minutes: int = 5, + instance_id: str = "", ): self.hostname = hostname self.apikey = apikey @@ -41,6 +47,9 @@ def __init__( self.port = port self.verify = verify self.timeout = timeout + self.commit_confirm_enabled = commit_confirm_enabled + self.commit_confirm_minutes = commit_confirm_minutes + self.instance_id = instance_id class VyOSService: @@ -117,13 +126,147 @@ def execute_batch( SystemPerformanceBatchBuilder, ], ) -> ApiResponse: - """Execute a batch of operations using configure_multiple_op.""" + """Execute a batch of operations using configure_multiple_op. + + If commit-confirm is enabled for this instance (and the VyOS version + supports it), the batch is executed with a rollback timer automatically. + No router changes are needed — the feature is fully transparent. + """ + if self.config.commit_confirm_enabled and self.config.instance_id: + return self.execute_batch_with_confirm(batch, self.config.instance_id) + if batch.is_empty(): raise ValueError("Cannot execute empty batch") operations = batch.get_operations() return self.device.configure_multiple_op(op_path=operations) + def execute_batch_with_confirm( + self, + batch, + instance_id: str, + confirm_time_minutes: int = 5, + action: str = "reload", + ) -> ApiResponse: + """ + Execute a batch using VyOS commit-confirm. + + Applies operations to the running config with an automatic rollback + timer. The caller must confirm via confirm_commit() before the timer + expires, otherwise VyOS will revert the changes. + + Raises: + HTTPException 409: If a commit-confirm is already active for this instance. + ValueError: If the batch is empty. + """ + from fastapi import HTTPException + + # Fall back to regular commit if feature is disabled or VyOS version + # doesn't support commit-confirm (requires 1.5+). + version = self.config.version or "" + supports_commit_confirm = "1.5" in version or "1.6" in version + if not self.config.commit_confirm_enabled or not supports_commit_confirm: + return self.execute_batch(batch) + + if commit_confirm_state.is_active(instance_id): + session = commit_confirm_state.get_active(instance_id) + raise HTTPException( + status_code=409, + detail=( + f"A commit-confirm is already active for this instance. " + f"Please confirm or wait {session.seconds_remaining()} seconds for it to expire." + ), + ) + + if batch.is_empty(): + raise ValueError("Cannot execute empty batch") + + # Use the per-instance configured confirm time + confirm_time_minutes = self.config.commit_confirm_minutes + + operations = batch.get_operations() + + # Step 1: Execute all operations as a normal atomic batch. + batch_response = self.device.configure_multiple_op(op_path=operations) + if batch_response.status != 200: + return batch_response + + # Step 2: Arm the commit-confirm rollback timer. + # + # VyOS only arms the rollback when confirm_time is inside a SINGLE + # operation JSON object (not an array). Sending the last operation again + # as a single object with confirm_time is idempotent (set is a no-op if + # already set; delete is a no-op if already deleted) and properly arms + # the timer so VyOS will revert if the user doesn't confirm in time. + last_op = {**operations[-1], "confirm_time": confirm_time_minutes} + url = f"{self.config.protocol}://{self.config.hostname}:{self.config.port}/configure" + payload = { + "data": json.dumps(last_op), + "key": self.config.apikey, + } + + try: + resp = _requests.post( + url, + data=payload, + verify=self.config.verify, + timeout=self.config.timeout, + ) + resp.raise_for_status() + body = resp.json() + if body.get("success"): + commit_confirm_state.set_active(instance_id, confirm_time_minutes, action) + return ApiResponse(status=200, request={}, result=body.get("data") or {}, error=False) + else: + error_msg = body.get("error", "Unknown error from VyOS API") + return ApiResponse(status=400, request={}, result={}, error=error_msg) + except _requests.exceptions.Timeout: + return ApiResponse(status=504, request={}, result={}, error="Request timed out") + except _requests.exceptions.RequestException as exc: + return ApiResponse(status=503, request={}, result={}, error=str(exc)) + + def confirm_commit(self, instance_id: str) -> ApiResponse: + """ + Confirm an active commit-confirm session, stopping the rollback timer. + + Raises: + HTTPException 409: If no active commit-confirm session exists. + """ + from fastapi import HTTPException + + if not commit_confirm_state.is_active(instance_id): + raise HTTPException( + status_code=409, + detail="No active commit-confirm session for this instance.", + ) + + # Tested format: confirm goes to /config-file with op: confirm + url = f"{self.config.protocol}://{self.config.hostname}:{self.config.port}/config-file" + payload = { + "data": json.dumps({"op": "confirm"}), + "key": self.config.apikey, + } + + try: + resp = _requests.post( + url, + data=payload, + verify=self.config.verify, + timeout=self.config.timeout, + ) + resp.raise_for_status() + body = resp.json() + if body.get("success"): + commit_confirm_state.clear(instance_id) + return ApiResponse(status=200, request={}, result=body.get("data") or {}, error=False) + else: + error_msg = body.get("error", "Unknown error from VyOS API") + return ApiResponse(status=400, request={}, result={}, error=error_msg) + except _requests.exceptions.Timeout: + return ApiResponse(status=504, request={}, result={}, error="Request timed out") + except _requests.exceptions.RequestException as exc: + return ApiResponse(status=503, request={}, result={}, error=str(exc)) + def configure_batch(self, commands: List[str]) -> Dict[str, Any]: """ Execute a batch of VyOS configuration commands. diff --git a/frontend/prisma/migrations/20260225_add_commit_confirm_fields/migration.sql b/frontend/prisma/migrations/20260225_add_commit_confirm_fields/migration.sql new file mode 100644 index 00000000..837dc0a8 --- /dev/null +++ b/frontend/prisma/migrations/20260225_add_commit_confirm_fields/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "instances" ADD COLUMN "commitConfirmEnabled" BOOLEAN NOT NULL DEFAULT false; +ALTER TABLE "instances" ADD COLUMN "commitConfirmMinutes" INTEGER NOT NULL DEFAULT 5; diff --git a/frontend/prisma/schema.prisma b/frontend/prisma/schema.prisma index 539492f4..00482937 100644 --- a/frontend/prisma/schema.prisma +++ b/frontend/prisma/schema.prisma @@ -176,6 +176,10 @@ model Instance { verifySsl Boolean @default(false) // Verify SSL certificate isActive Boolean @default(true) + // Commit-confirm settings + commitConfirmEnabled Boolean @default(false) // Use commit-confirm for all changes + commitConfirmMinutes Int @default(5) // Minutes before auto-revert if not confirmed + // SSH / Monitoring fields sshPort Int @default(22) sshUsername String? diff --git a/frontend/src/components/config/UnsavedChangesBanner.tsx b/frontend/src/components/config/UnsavedChangesBanner.tsx index 31dfc51e..6821012b 100644 --- a/frontend/src/components/config/UnsavedChangesBanner.tsx +++ b/frontend/src/components/config/UnsavedChangesBanner.tsx @@ -1,9 +1,9 @@ "use client"; -import { useState, useEffect } from "react"; -import { AlertTriangle, Save, FileText } from "lucide-react"; +import { useState, useEffect, useRef } from "react"; +import { AlertTriangle, Save, FileText, CheckCircle, Clock } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { configService, type ConfigDiff } from "@/lib/api/config"; +import { configService, type ConfigDiff, type CommitConfirmStatus } from "@/lib/api/config"; import { ConfigDiffModal } from "./ConfigDiffModal"; import { cn } from "@/lib/utils"; import { useToast } from "@/hooks/useToast"; @@ -11,73 +11,175 @@ import { ApiError } from "@/lib/types/api"; export function UnsavedChangesBanner() { const [diff, setDiff] = useState(null); - const [loading, setLoading] = useState(false); + const [commitConfirm, setCommitConfirm] = useState(null); + const [confirming, setConfirming] = useState(false); const [saving, setSaving] = useState(false); const [showDiffModal, setShowDiffModal] = useState(false); const [error, setError] = useState(null); + // Tick every second to update countdown display when commit-confirm is active + const [, setTick] = useState(0); + const tickRef = useRef | null>(null); const { toast } = useToast(); - // Poll for changes every 10 seconds + // Manage the per-second tick for the countdown useEffect(() => { - const checkForChanges = async () => { + if (commitConfirm?.active) { + tickRef.current = setInterval(() => setTick((t) => t + 1), 1000); + } else { + if (tickRef.current) { + clearInterval(tickRef.current); + tickRef.current = null; + } + } + return () => { + if (tickRef.current) clearInterval(tickRef.current); + }; + }, [commitConfirm?.active]); + + // Poll commit-confirm status every 5 s, config diff every 10 s + useEffect(() => { + const checkStatus = async () => { try { - setLoading(true); - const diffResult = await configService.getDiff(); + const [diffResult, ccStatus] = await Promise.all([ + configService.getDiff(), + configService.getCommitConfirmStatus(), + ]); setDiff(diffResult); + setCommitConfirm(ccStatus); setError(null); } catch (err) { - // Extract error message for logging and display - const errorMessage = (err as ApiError).message || (err as ApiError).message || (err as ApiError).message || "Failed to check for changes"; - console.error("Failed to check for config changes:", errorMessage); - setError(errorMessage); - } finally { - setLoading(false); + const msg = (err as ApiError).message || "Failed to check configuration status"; + console.error("Banner status check failed:", msg); + setError(msg); } }; - // Check immediately - checkForChanges(); - - // Then poll every 10 seconds - const interval = setInterval(checkForChanges, 10000); - + checkStatus(); + const interval = setInterval(checkStatus, 5000); return () => clearInterval(interval); }, []); + const handleConfirm = async () => { + setConfirming(true); + setError(null); + try { + const result = await configService.confirmCommit(); + if (!result.success) { + const msg = result.error || "Failed to confirm commit"; + setError(msg); + toast.error("Confirm Failed", msg); + return; + } + toast.success("Changes Confirmed", "Your changes are live. Save configuration when ready."); + setCommitConfirm({ active: false }); + const newDiff = await configService.getDiff(); + setDiff(newDiff); + } catch (err) { + const msg = err instanceof Error ? err.message : "Failed to confirm commit"; + setError(msg); + toast.error("Confirm Failed", msg); + } finally { + setConfirming(false); + } + }; + const handleSave = async () => { setSaving(true); setError(null); - try { const result = await configService.saveConfig(); - if (!result.success) { - const errorMsg = result.error || "Failed to save configuration"; - setError(errorMsg); - toast.error("Save Failed", errorMsg); + const msg = result.error || "Failed to save configuration"; + setError(msg); + toast.error("Save Failed", msg); return; } - - // Success! - toast.success("Configuration Saved", "Your changes have been written to disk successfully"); - - // Refresh diff after save + toast.success("Configuration Saved", "Your changes have been written to disk successfully."); const newDiff = await configService.getDiff(); setDiff(newDiff); - - // Clear any previous errors setError(null); } catch (err) { - console.error("Failed to save configuration:", err); - const errorMsg = err instanceof Error ? (err as ApiError).message : "Failed to save configuration"; - setError(errorMsg); - toast.error("Save Failed", errorMsg); + const msg = err instanceof Error ? (err as ApiError).message : "Failed to save configuration"; + setError(msg); + toast.error("Save Failed", msg); } finally { setSaving(false); } }; - // Don't show if no changes + // Calculate live seconds remaining from expires_at (more accurate than polled value) + const secondsRemaining = (() => { + if (!commitConfirm?.active || !commitConfirm.expires_at) return 0; + const diff = new Date(commitConfirm.expires_at).getTime() - Date.now(); + return Math.max(0, Math.floor(diff / 1000)); + })(); + + const formatCountdown = (secs: number) => { + const m = Math.floor(secs / 60); + const s = secs % 60; + return `${m}:${String(s).padStart(2, "0")}`; + }; + + // ── Commit-confirm active: show countdown banner (highest priority) ── + if (commitConfirm?.active) { + const isUrgent = secondsRemaining <= 60; + return ( + <> +
+
+
+
+ +
+

+ Commit-Confirm Active — confirm before changes revert +

+

+ Auto-{commitConfirm.action ?? "reload"} in{" "} + + {formatCountdown(secondsRemaining)} + + {" "}· {commitConfirm.confirm_time_minutes} min window +

+
+
+ +
+ {error && ( + + {error} + + )} + +
+
+
+
+ + {/* Spacer so content isn't hidden under the fixed banner */} +
+ + ); + } + + + // ── No commit-confirm: show unsaved-changes banner if there are diffs ── if (!diff?.has_changes) { return null; } @@ -95,7 +197,6 @@ export function UnsavedChangesBanner() { >
- {/* Left side - Icon and message */}
@@ -114,7 +215,6 @@ export function UnsavedChangesBanner() {
- {/* Right side - Actions */}
{error && ( @@ -149,7 +249,6 @@ export function UnsavedChangesBanner() { {/* Spacer to push content down when banner is visible */}
- {/* Diff Modal */} (null); @@ -65,6 +67,8 @@ export function CreateInstanceModal({ setIsActive(true); setSshPort("22"); setSshUsername(""); + setCommitConfirmEnabled(false); + setCommitConfirmMinutes("5"); setError(null); onOpenChange(false); }; @@ -113,6 +117,8 @@ export function CreateInstanceModal({ is_active: isActive, ssh_port: isNaN(sshPortNum) ? 22 : sshPortNum, ssh_username: sshUsername.trim() || undefined, + commit_confirm_enabled: commitConfirmEnabled, + commit_confirm_minutes: parseInt(commitConfirmMinutes) || 5, }); handleClose(); @@ -220,6 +226,46 @@ export function CreateInstanceModal({ Instance is active
+ + {/* Commit-Confirm */} +
+
+ setCommitConfirmEnabled(checked as boolean)} + disabled={loading || vyosVersion === "1.4"} + /> +
+ +

+ {vyosVersion === "1.4" + ? "Not supported on VyOS 1.4" + : "All changes will require confirmation or VyOS will auto-revert"} +

+
+
+ {commitConfirmEnabled && ( +
+ + setCommitConfirmMinutes(e.target.value)} + disabled={loading} + className="w-20" + /> + minutes +
+ )} +
diff --git a/frontend/src/components/sites/EditInstanceModal.tsx b/frontend/src/components/sites/EditInstanceModal.tsx index a831dd0f..7dfc15c8 100644 --- a/frontend/src/components/sites/EditInstanceModal.tsx +++ b/frontend/src/components/sites/EditInstanceModal.tsx @@ -53,6 +53,8 @@ export function EditInstanceModal({ const [siteId, setSiteId] = useState(""); const [sshPort, setSshPort] = useState("22"); const [sshUsername, setSshUsername] = useState(""); + const [commitConfirmEnabled, setCommitConfirmEnabled] = useState(false); + const [commitConfirmMinutes, setCommitConfirmMinutes] = useState("5"); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -67,6 +69,8 @@ export function EditInstanceModal({ setSiteId(instance.site_id); setSshPort((instance.ssh_port ?? 22).toString()); setSshUsername(instance.ssh_username || ""); + setCommitConfirmEnabled(instance.commit_confirm_enabled ?? false); + setCommitConfirmMinutes((instance.commit_confirm_minutes ?? 5).toString()); // Don't populate API key for security setApiKey(""); setProtocol(instance.protocol || "https"); @@ -87,6 +91,8 @@ export function EditInstanceModal({ setSiteId(""); setSshPort("22"); setSshUsername(""); + setCommitConfirmEnabled(false); + setCommitConfirmMinutes("5"); setError(null); onOpenChange(false); }; @@ -132,6 +138,8 @@ export function EditInstanceModal({ ssh_port: sshPortNum, ssh_username: sshUsername.trim() || null, verify_ssl: verifySsl, + commit_confirm_enabled: commitConfirmEnabled, + commit_confirm_minutes: parseInt(commitConfirmMinutes) || 5, }; if (apiKey.trim()) { @@ -277,6 +285,46 @@ export function EditInstanceModal({
+ {/* Commit-Confirm */} +
+
+ setCommitConfirmEnabled(checked as boolean)} + disabled={loading || vyosVersion === "1.4"} + /> +
+ +

+ {vyosVersion === "1.4" + ? "Not supported on VyOS 1.4" + : "All changes will require confirmation or VyOS will auto-revert"} +

+
+
+ {commitConfirmEnabled && ( +
+ + setCommitConfirmMinutes(e.target.value)} + disabled={loading} + className="w-20" + /> + minutes +
+ )} +
+