feat(deploy): redeploy dialog with keep-data or fresh-deploy option

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-10 21:34:26 +01:00
6 changed files with 117 additions and 12 deletions

View File

@@ -2,7 +2,7 @@
import logging import logging
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, status from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.database import SessionLocal, get_db from app.database import SessionLocal, get_db
@@ -19,6 +19,14 @@ router = APIRouter()
async def manual_deploy( async def manual_deploy(
customer_id: int, customer_id: int,
background_tasks: BackgroundTasks, background_tasks: BackgroundTasks,
keep_data: bool = Query(
False,
description=(
"If True, preserve existing NetBird data (database, keys, peers). "
"Containers are recreated without wiping the instance directory. "
"If False (default), the instance is fully removed and redeployed from scratch."
),
),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
@@ -29,6 +37,7 @@ async def manual_deploy(
Args: Args:
customer_id: Customer ID. customer_id: Customer ID.
keep_data: Whether to preserve existing NetBird data.
Returns: Returns:
Acknowledgement dict. Acknowledgement dict.
@@ -40,12 +49,12 @@ async def manual_deploy(
customer.status = "deploying" customer.status = "deploying"
db.commit() db.commit()
async def _deploy_bg(cid: int) -> None: async def _deploy_bg(cid: int, keep: bool) -> None:
bg_db = SessionLocal() bg_db = SessionLocal()
try: try:
# Remove existing deployment if present
existing = bg_db.query(Deployment).filter(Deployment.customer_id == cid).first() existing = bg_db.query(Deployment).filter(Deployment.customer_id == cid).first()
if existing: if existing and not keep:
# Full redeploy: remove everything first
await netbird_service.undeploy_customer(bg_db, cid) await netbird_service.undeploy_customer(bg_db, cid)
await netbird_service.deploy_customer(bg_db, cid) await netbird_service.deploy_customer(bg_db, cid)
except Exception: except Exception:
@@ -53,7 +62,7 @@ async def manual_deploy(
finally: finally:
bg_db.close() bg_db.close()
background_tasks.add_task(_deploy_bg, customer_id) background_tasks.add_task(_deploy_bg, customer_id, keep_data)
return {"message": "Deployment started in background.", "status": "deploying"} return {"message": "Deployment started in background.", "status": "deploying"}

View File

@@ -264,10 +264,12 @@ async def deploy_customer(db: Session, customer_id: int) -> dict[str, Any]:
_log_action(db, customer_id, "deploy", "info", _log_action(db, customer_id, "deploy", "info",
"Auto-setup failed — admin must complete setup manually.") "Auto-setup failed — admin must complete setup manually.")
# Step 9: Create NPM proxy host (production only) # Step 9: Create NPM proxy host (production only).
npm_proxy_id = None # If an existing deployment already has an NPM proxy, reuse it — this happens
npm_stream_id = None # when keep_data=True was passed and undeploy_customer was NOT called beforehand.
if not local_mode: npm_proxy_id = existing_deployment.npm_proxy_id if existing_deployment else None
npm_stream_id = existing_deployment.npm_stream_id if existing_deployment else None
if not local_mode and not npm_proxy_id:
forward_host = npm_service._get_forward_host() forward_host = npm_service._get_forward_host()
npm_result = await npm_service.create_proxy_host( npm_result = await npm_service.create_proxy_host(
api_url=config.npm_api_url, api_url=config.npm_api_url,
@@ -304,9 +306,14 @@ async def deploy_customer(db: Session, customer_id: int) -> dict[str, Any]:
"SSL certificate not created automatically. " "SSL certificate not created automatically. "
"Please create it manually in NPM or ensure DNS resolves and port 80 is reachable, then re-deploy.", "Please create it manually in NPM or ensure DNS resolves and port 80 is reachable, then re-deploy.",
) )
elif npm_proxy_id and not local_mode:
_log_action(db, customer_id, "deploy", "info",
f"Reusing existing NPM proxy (ID {npm_proxy_id}) — data preserved.")
# Step 10: Create Windows DNS A-record (non-fatal — failure does not abort deployment) # Step 10: Create Windows DNS A-record (non-fatal — failure does not abort deployment).
if config.dns_enabled and config.dns_server and config.dns_zone and config.dns_record_ip: # Skip if an existing deployment is being kept (DNS record already exists).
if config.dns_enabled and config.dns_server and config.dns_zone and config.dns_record_ip \
and not existing_deployment:
try: try:
dns_result = await dns_service.create_dns_record(customer.subdomain, config) dns_result = await dns_service.create_dns_record(customer.subdomain, config)
if dns_result["ok"]: if dns_result["ok"]:

View File

@@ -1267,6 +1267,49 @@
</div> </div>
</div> </div>
<!-- Modal: Redeploy Confirmation -->
<div class="modal fade" id="redeploy-modal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header bg-primary text-white">
<h5 class="modal-title" data-i18n="redeployModal.title">Redeploy Customer</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p><span data-i18n="redeployModal.intro">How should</span> <strong id="redeploy-customer-name"></strong> <span data-i18n="redeployModal.intro2">be redeployed?</span></p>
<input type="hidden" id="redeploy-customer-id">
<div class="row g-3 mt-1">
<div class="col-md-6">
<div class="card h-100 border-success" style="cursor:pointer" onclick="confirmRedeploy(true)" id="redeploy-card-keep">
<div class="card-body">
<h6 class="card-title text-success"><i class="bi bi-shield-check me-2"></i><span data-i18n="redeployModal.keepTitle">Keep Data</span></h6>
<p class="card-text small" data-i18n="redeployModal.keepDesc">Containers are stopped and restarted. The NetBird database, peer configurations, and encryption keys are preserved. Use this after a config change or image update.</p>
</div>
<div class="card-footer bg-success bg-opacity-10 text-success small" data-i18n="redeployModal.keepNote">
Peers stay connected after restart.
</div>
</div>
</div>
<div class="col-md-6">
<div class="card h-100 border-danger" style="cursor:pointer" onclick="confirmRedeploy(false)" id="redeploy-card-fresh">
<div class="card-body">
<h6 class="card-title text-danger"><i class="bi bi-trash me-2"></i><span data-i18n="redeployModal.freshTitle">Fresh Deploy</span></h6>
<p class="card-text small" data-i18n="redeployModal.freshDesc">All existing data is deleted — containers, volumes, config files, and the NetBird database. A completely new instance is created. All peers must re-enroll.</p>
</div>
<div class="card-footer bg-danger bg-opacity-10 text-danger small" data-i18n="redeployModal.freshNote">
All peer data is lost. Cannot be undone.
</div>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal" data-i18n="common.cancel">Cancel</button>
</div>
</div>
</div>
</div>
<!-- Modal: Delete Confirmation --> <!-- Modal: Delete Confirmation -->
<div class="modal fade" id="delete-modal" tabindex="-1"> <div class="modal fade" id="delete-modal" tabindex="-1">
<div class="modal-dialog"> <div class="modal-dialog">

View File

@@ -667,9 +667,13 @@ async function confirmDeleteCustomer() {
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Customer Actions (start/stop/restart) // Customer Actions (start/stop/restart/deploy)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
async function customerAction(id, action) { async function customerAction(id, action) {
if (action === 'deploy') {
showRedeployModal(id);
return;
}
try { try {
await api('POST', `/customers/${id}/${action}`); await api('POST', `/customers/${id}/${action}`);
if (currentPage === 'dashboard') loadCustomers(); if (currentPage === 'dashboard') loadCustomers();
@@ -679,6 +683,26 @@ async function customerAction(id, action) {
} }
} }
function showRedeployModal(id) {
const row = document.querySelector(`tr[data-customer-id="${id}"]`);
const name = row ? row.querySelector('td')?.textContent?.trim() : `#${id}`;
document.getElementById('redeploy-customer-id').value = id;
document.getElementById('redeploy-customer-name').textContent = name;
new bootstrap.Modal(document.getElementById('redeploy-modal')).show();
}
async function confirmRedeploy(keepData) {
const id = document.getElementById('redeploy-customer-id').value;
bootstrap.Modal.getInstance(document.getElementById('redeploy-modal'))?.hide();
try {
await api('POST', `/customers/${id}/deploy?keep_data=${keepData}`);
if (currentPage === 'dashboard') loadCustomers();
if (currentCustomerId == id) viewCustomer(id);
} catch (err) {
alert(t('errors.actionFailed', { action: 'deploy', error: err.message }));
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Customer Detail // Customer Detail
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@@ -354,6 +354,17 @@
"saveAndDeploy": "Speichern & Bereitstellen", "saveAndDeploy": "Speichern & Bereitstellen",
"saveChanges": "Änderungen speichern" "saveChanges": "Änderungen speichern"
}, },
"redeployModal": {
"title": "Kunde neu bereitstellen",
"intro": "Wie soll",
"intro2": "neu bereitgestellt werden?",
"keepTitle": "Daten behalten",
"keepDesc": "Container werden gestoppt und neu gestartet. Die NetBird-Datenbank, Peer-Konfigurationen und Verschlüsselungsschlüssel bleiben erhalten. Verwenden Sie dies nach einer Konfigurationsänderung oder einem Image-Update.",
"keepNote": "Peers bleiben nach dem Neustart verbunden.",
"freshTitle": "Neu aufsetzen",
"freshDesc": "Alle bestehenden Daten werden gelöscht — Container, Volumes, Konfigurationsdateien und die NetBird-Datenbank. Eine komplett neue Instanz wird erstellt. Alle Peers müssen sich neu registrieren.",
"freshNote": "Alle Peer-Daten gehen verloren. Kann nicht rückgängig gemacht werden."
},
"deleteModal": { "deleteModal": {
"title": "Löschen bestätigen", "title": "Löschen bestätigen",
"confirmText": "Möchten Sie den Kunden wirklich löschen:", "confirmText": "Möchten Sie den Kunden wirklich löschen:",

View File

@@ -107,6 +107,17 @@
"saveAndDeploy": "Save & Deploy", "saveAndDeploy": "Save & Deploy",
"saveChanges": "Save Changes" "saveChanges": "Save Changes"
}, },
"redeployModal": {
"title": "Redeploy Customer",
"intro": "How should",
"intro2": "be redeployed?",
"keepTitle": "Keep Data",
"keepDesc": "Containers are stopped and restarted. The NetBird database, peer configurations, and encryption keys are preserved. Use this after a config change or image update.",
"keepNote": "Peers stay connected after restart.",
"freshTitle": "Fresh Deploy",
"freshDesc": "All existing data is deleted — containers, volumes, config files, and the NetBird database. A completely new instance is created. All peers must re-enroll.",
"freshNote": "All peer data is lost. Cannot be undone."
},
"deleteModal": { "deleteModal": {
"title": "Confirm Deletion", "title": "Confirm Deletion",
"confirmText": "Are you sure you want to delete customer", "confirmText": "Are you sure you want to delete customer",