From c5aca4ee8a5e620231c0552db74c05d8c69dace8 Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Fri, 31 Jul 2026 11:24:27 -0400 Subject: [PATCH 1/2] Refresh backup migrate restore admin UI Refs #1140 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- application/single_app/config.py | 2 +- .../single_app/functions_data_management.py | 189 ++++++++- .../route_backend_data_management.py | 45 ++ application/single_app/static/css/styles.css | 11 + .../static/js/admin/admin_data_management.js | 389 +++++++++++++++++- .../static/js/admin/admin_sidebar_nav.js | 1 + .../single_app/templates/_sidebar_nav.html | 9 +- .../single_app/templates/admin_settings.html | 385 +++++++++++++++-- .../DATA_MANAGEMENT_BACKUP_MIGRATION.md | 44 +- .../DATA_MANAGEMENT_MIGRATION_RESILIENCE.md | 17 +- docs/explanation/release_notes.md | 10 + ..._management_destination_cosmos_capacity.py | 127 +++++- ..._management_incremental_migration_modes.py | 9 +- ...ta_management_restore_workflow_contract.py | 130 ++++++ .../test_data_management_security_patterns.py | 17 +- .../test_admin_data_management_settings_ui.py | 46 ++- 16 files changed, 1328 insertions(+), 103 deletions(-) create mode 100644 functional_tests/test_data_management_restore_workflow_contract.py diff --git a/application/single_app/config.py b/application/single_app/config.py index 9f8af2cc3..e7b780d6e 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -95,7 +95,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.250.105" +VERSION = "0.250.106" IS_DEVELOPMENT = is_development_env_enabled() SESSION_COOKIE_SAMESITE = os.getenv('SESSION_COOKIE_SAMESITE', 'Lax') diff --git a/application/single_app/functions_data_management.py b/application/single_app/functions_data_management.py index e46f16504..edcebf2d1 100644 --- a/application/single_app/functions_data_management.py +++ b/application/single_app/functions_data_management.py @@ -234,7 +234,7 @@ DATA_MANAGEMENT_MIGRATION_MODE_DELTA_UPSERT, DATA_MANAGEMENT_MIGRATION_MODE_MIRROR, } -DATA_MANAGEMENT_MIRROR_CONFIRMATION = "MIRROR WITH DELETIONS" +DATA_MANAGEMENT_MIRROR_CONFIRMATION = "MAKE DESTINATION MATCH SOURCE" DATA_MANAGEMENT_SEARCH_WRITE_FREEZE_CONFIRMATION_ERROR = ( "Confirm that external destination AI Search writers are frozen before migrating AI Search documents." ) @@ -997,29 +997,62 @@ def test_target_cosmos_connection(settings=None, migration_plan=None): normalized_settings, normalized_plan, ) - capacity = None - if normalized_settings.get("migration_temporary_destination_ru_enabled"): - inspected_capacity = _inspect_target_cosmos_migration_capacity( - normalized_settings, - normalized_plan, - ) - capacity = { - "target_ru": inspected_capacity.get("target_ru"), - "database_mode": inspected_capacity.get("database_mode"), - "database_current_ru": inspected_capacity.get("database_current_ru"), - "targets": inspected_capacity.get("targets"), - } result = { "success": True, "target": "cosmos", "database_name": properties.get("id") or DATA_MANAGEMENT_TARGET_COSMOS_DATABASE_NAME, "authentication_type": normalized_settings.get("target_cosmos_authentication_type"), "migration_access": migration_access, - "capacity": capacity, } return result +def test_target_cosmos_capacity_management(settings=None, migration_plan=None): + """Validate destination Cosmos ARM throughput permissions for RU Boost.""" + normalized_settings = _normalize_data_management_settings_from_payload(settings) + normalized_plan = normalize_data_management_migration_plan({ + "migration_plan": migration_plan if isinstance(migration_plan, dict) else {}, + }) + inspected_capacity = _inspect_target_cosmos_migration_capacity( + normalized_settings, + normalized_plan, + ) + write_results = [] + for target in inspected_capacity.get("targets") or []: + current_ru = _safe_int(target.get("current_ru"), default=0, minimum=0) + if not current_ru: + raise DataManagementSettingsValidationError( + "Destination Cosmos RU Boost test could not determine the current RU/s value." + ) + scale_result = set_database_throughput( + inspected_capacity["management_settings"], + current_ru, + initiated_by="data_management_ru_boost_test", + reason="validate_data_management_ru_boost_permissions", + decision={ + "scope": target.get("scope"), + "container_name": target.get("container_name") or "", + "target_mode": target.get("mode"), + }, + ) + write_results.append({ + "scope": target.get("scope"), + "container_name": target.get("container_name") or "", + "mode": target.get("mode"), + "current_ru": current_ru, + "write_verified": True, + "verified_ru": scale_result.get("to_ru", current_ru), + }) + return { + "success": True, + "target": "cosmos_ru_boost", + "target_ru": inspected_capacity.get("target_ru"), + "database_mode": inspected_capacity.get("database_mode"), + "database_current_ru": inspected_capacity.get("database_current_ru"), + "targets": write_results, + } + + def test_target_search_connection(settings=None): normalized_settings = _normalize_data_management_settings_from_payload(settings) endpoint = _safe_text(normalized_settings.get("target_ai_search_endpoint")) @@ -1676,7 +1709,7 @@ def _validate_incremental_baseline_job(candidate_job, current_job, current_confi source_cutoff_at = _safe_text(candidate_state.get("source_cutoff_at")) if _parse_iso_datetime(source_cutoff_at) is None: raise DataManagementSettingsValidationError( - "Incremental migration baseline does not contain a valid source watermark." + "Previous migration does not contain a valid source checkpoint." ) return source_cutoff_at @@ -9026,6 +9059,132 @@ def get_data_management_job_detail(job_id): } +def review_data_management_restore(restore_plan=None): + """Return a safe restore workflow preflight for the selected backup.""" + plan = restore_plan if isinstance(restore_plan, dict) else {} + backup_job_id = _safe_text(plan.get("backup_job_id")) + checks = [] + if not backup_job_id: + return { + "supported": False, + "ready": False, + "blocker_count": 1, + "warning_count": 0, + "summary": {}, + "checks": [{ + "id": "backup_required", + "label": "Backup selection", + "status": "block", + "summary": "Choose an available backup before running restore preflight.", + }], + } + + backup_job = get_data_management_job(backup_job_id) + if not backup_job or backup_job.get("operation") != DATA_MANAGEMENT_OPERATION_BACKUP: + return { + "supported": False, + "ready": False, + "blocker_count": 1, + "warning_count": 0, + "summary": {"backup_job_id": backup_job_id}, + "checks": [{ + "id": "backup_found", + "label": "Backup found", + "status": "block", + "summary": "The selected backup job was not found or is not a backup.", + }], + } + + backup_plan = backup_job.get("backup_plan") if isinstance(backup_job.get("backup_plan"), dict) else {} + backup_result = backup_job.get("result") if isinstance(backup_job.get("result"), dict) else {} + backup_artifacts = ( + backup_result.get("artifacts") + if isinstance(backup_result.get("artifacts"), list) + else [] + ) + backup_state = ( + backup_job.get("backup_state") + if isinstance(backup_job.get("backup_state"), dict) + else {} + ) + terminal_status = backup_job.get("status") in { + DATA_MANAGEMENT_STATUS_COMPLETED, + DATA_MANAGEMENT_STATUS_COMPLETED_WITH_WARNINGS, + } + has_manifest = bool(backup_result.get("manifest_path") or backup_plan.get("manifest_path")) + artifacts_ready = bool(backup_artifacts) + + checks.append({ + "id": "backup_status", + "label": "Backup status", + "status": "pass" if terminal_status else "block", + "summary": ( + "The backup completed and can be reviewed." + if terminal_status else + "Only completed backups can be considered for restore." + ), + }) + checks.append({ + "id": "backup_artifacts", + "label": "Backup artifacts", + "status": "pass" if artifacts_ready else "warning", + "summary": ( + f"{len(backup_artifacts)} backup artifact records are available." + if artifacts_ready else + "No artifact records were found in the selected backup result." + ), + }) + checks.append({ + "id": "manifest", + "label": "Restore manifest", + "status": "pass" if has_manifest else "warning", + "summary": ( + "A manifest location is recorded for this backup." + if has_manifest else + "No manifest path was recorded; restore readiness may be incomplete." + ), + }) + checks.append({ + "id": "restore_execution", + "label": "Restore execution", + "status": "block", + "summary": ( + "Restore execution is not enabled in this build. The workflow can stage " + "and explain the restore plan, but it will not queue a placeholder job." + ), + }) + + warning_count = sum(1 for check in checks if check["status"] == "warning") + blocker_count = sum(1 for check in checks if check["status"] == "block") + sanitized_job = sanitize_data_management_job_for_admin(backup_job) or {} + sanitized_result = ( + sanitized_job.get("result") + if isinstance(sanitized_job.get("result"), dict) + else {} + ) + return { + "supported": False, + "ready": False, + "blocker_count": blocker_count, + "warning_count": warning_count, + "summary": { + "backup_job_id": backup_job_id, + "backup_type": backup_job.get("backup_type"), + "status": backup_job.get("status"), + "completed_at": backup_job.get("completed_at"), + "source_cutoff_at": backup_job.get("source_cutoff_at"), + "include_cosmos": bool(backup_plan.get("include_cosmos")), + "include_ai_search": bool(backup_plan.get("include_ai_search")), + "include_source_blobs": bool(backup_plan.get("include_source_blobs")), + "encrypted": bool(backup_plan.get("encryption_enabled")), + "artifact_count": len(sanitized_result.get("artifacts") or []), + "warning_count": len(backup_job.get("warnings") or []), + "backup_phase": backup_state.get("phase"), + }, + "checks": checks, + } + + def get_data_management_job_progress(job_id): job = get_data_management_job(job_id) if not job: diff --git a/application/single_app/route_backend_data_management.py b/application/single_app/route_backend_data_management.py index 6e8aacb86..5137ffc78 100644 --- a/application/single_app/route_backend_data_management.py +++ b/application/single_app/route_backend_data_management.py @@ -40,12 +40,14 @@ retry_data_management_backup_job, resolve_data_management_migration_manifest_item, retry_data_management_migration_job, + review_data_management_restore, sanitize_data_management_job_for_admin, sanitize_data_management_settings_for_admin, save_data_management_cosmos_editor_document, summarize_data_management_migration_plan, submit_data_management_job, test_backup_storage_connection, + test_target_cosmos_capacity_management, test_target_cosmos_connection, test_target_enhanced_citation_storage_connection, test_target_search_connection, @@ -230,6 +232,30 @@ def test_admin_data_management_target_cosmos(): return jsonify({"success": False, "error": "Target Cosmos connection test failed."}), 400 return jsonify(result), 200 + @bp.route("/api/admin/data-management/target/cosmos/ru-boost/test", methods=["POST"]) + @swagger_route(security=get_auth_security()) + @login_required + @admin_required + def test_admin_data_management_target_cosmos_ru_boost(): + payload = request.get_json(silent=True) or {} + settings_payload = payload.get("settings") if isinstance(payload.get("settings"), dict) else None + migration_plan = payload.get("migration_plan") if isinstance(payload.get("migration_plan"), dict) else None + try: + result = test_target_cosmos_capacity_management( + settings=settings_payload, + migration_plan=migration_plan, + ) + except DataManagementSettingsValidationError as exc: + return jsonify({"success": False, "error": str(exc)}), 400 + except Exception as exc: + log_event( + "[DataManagement] Target Cosmos RU Boost permission test failed.", + {"error": str(exc)}, + level=logging.WARNING, + ) + return jsonify({"success": False, "error": "Target Cosmos RU Boost permission test failed."}), 400 + return jsonify(result), 200 + @bp.route("/api/admin/data-management/target/search/test", methods=["POST"]) @swagger_route(security=get_auth_security()) @login_required @@ -615,6 +641,25 @@ def get_admin_data_management_migration_catalog(target_type): return jsonify({"success": False, "error": str(exc)}), 400 return jsonify({"success": True, **catalog}), 200 + @bp.route("/api/admin/data-management/restore/review", methods=["POST"]) + @swagger_route(security=get_auth_security()) + @login_required + @admin_required + def review_admin_data_management_restore(): + payload = request.get_json(silent=True) or {} + restore_plan = payload.get("restore_plan") if isinstance(payload.get("restore_plan"), dict) else {} + try: + review = review_data_management_restore(restore_plan) + except Exception as exc: + log_event( + "[DataManagement] Restore review failed.", + {"error": str(exc)}, + level=logging.ERROR, + exceptionTraceback=True, + ) + return jsonify({"success": False, "error": "Restore review could not be completed."}), 400 + return jsonify({"success": True, "review": review}), 200 + @bp.route("/api/admin/data-management/migration/summary", methods=["POST"]) @swagger_route(security=get_auth_security()) @login_required diff --git a/application/single_app/static/css/styles.css b/application/single_app/static/css/styles.css index 65230c0fa..5e35e2b01 100644 --- a/application/single_app/static/css/styles.css +++ b/application/single_app/static/css/styles.css @@ -1732,6 +1732,17 @@ main { border-top: 1px solid var(--bs-border-color); } +.restore-stepper { + grid-template-columns: repeat(5, minmax(8rem, 1fr)); + margin: 0; + border: 1px solid var(--bs-border-color); + border-radius: var(--bs-border-radius-lg); +} + +.restore-stage { + min-height: 24rem; +} + @media (max-width: 991.98px) { .migration-stepper { grid-auto-flow: column; diff --git a/application/single_app/static/js/admin/admin_data_management.js b/application/single_app/static/js/admin/admin_data_management.js index f28b18178..bb1233cd2 100644 --- a/application/single_app/static/js/admin/admin_data_management.js +++ b/application/single_app/static/js/admin/admin_data_management.js @@ -7,7 +7,7 @@ const backupStorageAuthManagedIdentity = "managed_identity"; const backupStorageAuthConnectionString = "connection_string"; const targetCosmosDatabaseName = "SimpleChat"; const cosmosEditorConfirmationPhrase = "I understand this can damage system data"; -const migrationMirrorConfirmationPhrase = "MIRROR WITH DELETIONS"; +const migrationMirrorConfirmationPhrase = "MAKE DESTINATION MATCH SOURCE"; const elements = {}; let dataManagementModified = false; let storedBackupConnectionStringAvailable = false; @@ -34,6 +34,7 @@ const historyListState = { }; const migrationTargetTypes = ["users", "groups", "public_workspaces"]; const migrationWorkflowSteps = ["target", "scope", "options", "review", "confirm", "progress"]; +const restoreWorkflowSteps = ["backup", "options", "review", "confirm", "progress"]; const migrationSelections = { users: new Map(), groups: new Map(), @@ -63,6 +64,14 @@ const migrationWorkflowState = { submissionAccepted: false, currentJob: null, }; +const restoreWorkflowState = { + activeStepIndex: 0, + highestVisitedStepIndex: 0, + selectedBackup: null, + review: null, + reviewStale: true, + currentJob: null, +}; document.addEventListener("DOMContentLoaded", () => { bindElements(); @@ -146,6 +155,7 @@ function bindElements() { "data_management_target_cosmos_subscription_id", "data_management_target_cosmos_resource_group", "data-management-test-target-cosmos-btn", + "data-management-test-target-cosmos-ru-boost-btn", "data-management-target-ai-search-section", "data_management_target_ai_search_auth", "data_management_target_ai_search_endpoint", @@ -212,6 +222,24 @@ function bindElements() { "data-management-partial-backup-count", "data-management-available-backup-count", "data-management-backups-tbody", + "data-management-restore-modal", + "data-management-restore-status", + "data-management-restore-selected-backup", + "data_management_restore_mode", + "data_management_restore_include_cosmos", + "data_management_restore_include_ai_search", + "data_management_restore_include_source_blobs", + "data-management-restore-review-btn", + "data-management-restore-review-empty", + "data-management-restore-review-checks", + "data-management-restore-confirm-summary", + "data_management_restore_final_confirmation", + "data-management-execute-restore-btn", + "data-management-restore-progress-empty", + "data-management-restore-progress-state", + "data-management-restore-back-btn", + "data-management-restore-next-btn", + "data-management-restore-step-position", "data_management_backup_status_filter", "data_management_backup_scheduled_filter", "data_management_backup_created_from", @@ -306,6 +334,7 @@ function bindEvents() { elements.dataManagementGenerateKeyBtn?.addEventListener("click", generateEncryptionKey); elements.dataManagementTestStorageBtn?.addEventListener("click", testBackupStorage); elements.dataManagementTestTargetCosmosBtn?.addEventListener("click", testTargetCosmos); + elements.dataManagementTestTargetCosmosRuBoostBtn?.addEventListener("click", testTargetCosmosRuBoost); elements.dataManagementTestMigrationAccessBtn?.addEventListener("click", testMigrationAccess); elements.dataManagementTestTargetSearchBtn?.addEventListener("click", testTargetSearch); elements.dataManagementTestTargetEcStorageBtn?.addEventListener("click", testTargetEnhancedCitationStorage); @@ -329,6 +358,18 @@ function bindEvents() { ].forEach((filter) => filter?.addEventListener("change", () => resetAndLoadHistory("backups"))); elements.dataManagementBackupPreviousPageBtn?.addEventListener("click", () => loadPreviousHistoryPage("backups")); elements.dataManagementBackupNextPageBtn?.addEventListener("click", () => loadNextHistoryPage("backups")); + elements.dataManagementRestoreModal?.addEventListener("hidden.bs.modal", resetRestoreWorkflowState); + elements.dataManagementRestoreReviewBtn?.addEventListener("click", runRestoreReview); + elements.dataManagementRestoreBackBtn?.addEventListener("click", () => showRestoreWorkflowStep(restoreWorkflowState.activeStepIndex - 1)); + elements.dataManagementRestoreNextBtn?.addEventListener("click", () => showRestoreWorkflowStep(restoreWorkflowState.activeStepIndex + 1)); + elements.datamanagementrestorefinalconfirmation?.addEventListener("change", updateRestoreExecutionButtonState); + elements.dataManagementExecuteRestoreBtn?.addEventListener("click", queueRestore); + [ + elements.datamanagementrestoremode, + elements.datamanagementrestoreincludecosmos, + elements.datamanagementrestoreincludeaisearch, + elements.datamanagementrestoreincludesourceblobs, + ].forEach((element) => element?.addEventListener("change", invalidateRestoreReview)); elements.dataManagementRefreshJobsBtn?.addEventListener("click", loadDataManagementJobs); [ elements.datamanagementjoboperationfilter, @@ -796,9 +837,16 @@ function setMigrationTargetVisibility() { function updateMigrationCapacityVisibility() { const enabled = Boolean(elements.datamanagementmigrationtemporarydestinationruenabled?.checked); setElementVisible(elements.dataManagementMigrationTemporaryRuField, enabled); - if (elements.datamanagementmigrationtemporarydestinationru) { - elements.datamanagementmigrationtemporarydestinationru.disabled = !enabled; - } + [ + elements.datamanagementmigrationtemporarydestinationru, + elements.datamanagementtargetcosmossubscriptionid, + elements.datamanagementtargetcosmosresourcegroup, + elements.dataManagementTestTargetCosmosRuBoostBtn, + ].forEach((element) => { + if (element) { + element.disabled = !enabled; + } + }); } function updateBackupCapacityVisibility() { @@ -1466,8 +1514,7 @@ async function testTargetCosmos() { }); const verifiedCount = Number(data.migration_access?.container_count || 0); const accessText = verifiedCount ? ` Verified ${formatNumber(verifiedCount)} planned container(s).` : ""; - const capacityText = data.capacity?.target_ru ? ` Temporary capacity can reach ${formatNumber(data.capacity.target_ru)} RU/s.` : ""; - setStatus(`Target Cosmos connection succeeded. Database: ${data.database_name || targetCosmosDatabaseName}.${accessText}${capacityText}`, "success"); + setStatus(`Target Cosmos data access succeeded. Database: ${data.database_name || targetCosmosDatabaseName}.${accessText}`, "success"); showToast("Target Cosmos connection succeeded.", "success"); } catch (error) { setStatus(error.message || "Target Cosmos connection test failed.", "danger"); @@ -1477,6 +1524,24 @@ async function testTargetCosmos() { } } +async function testTargetCosmosRuBoost() { + setBusy(elements.dataManagementTestTargetCosmosRuBoostBtn, true, "Testing..."); + try { + const data = await requestJson("/api/admin/data-management/target/cosmos/ru-boost/test", { + method: "POST", + body: JSON.stringify({ settings: collectSettings(), migration_plan: buildMigrationPlan() }), + }); + const targetCount = Array.isArray(data.targets) ? data.targets.length : 0; + setStatus(`RU Boost permission test succeeded. Verified ${formatNumber(targetCount)} destination capacity target(s) up to ${formatNumber(data.target_ru || 0)} RU/s.`, "success"); + showToast("RU Boost permission test succeeded.", "success"); + } catch (error) { + setStatus(error.message || "RU Boost permission test failed.", "danger"); + showToast(error.message || "RU Boost permission test failed.", "danger"); + } finally { + setBusy(elements.dataManagementTestTargetCosmosRuBoostBtn, false); + } +} + async function testMigrationAccess() { setBusy(elements.dataManagementTestMigrationAccessBtn, true, "Validating..."); try { @@ -1485,8 +1550,7 @@ async function testMigrationAccess() { body: JSON.stringify({ settings: collectSettings(), migration_plan: buildMigrationPlan() }), }); const verifiedCount = Number(data.migration_access?.container_count || 0); - const capacityText = data.capacity?.target_ru ? ` Destination capacity management is ready up to ${formatNumber(data.capacity.target_ru)} RU/s.` : ""; - setStatus(`Cosmos migration access validation succeeded. ${formatNumber(verifiedCount)} planned Cosmos container(s) can be read and written.${capacityText}`, "success"); + setStatus(`Cosmos data-copy access validation succeeded. ${formatNumber(verifiedCount)} planned Cosmos container(s) can be read and written. Use Test RU Boost for destination capacity permissions.`, "success"); showToast("Cosmos migration access validation succeeded.", "success"); } catch (error) { setStatus(error.message || "Cosmos migration access validation failed.", "danger"); @@ -1621,7 +1685,7 @@ async function queueMigration(dryRun) { migrationPlan.migration_mode === "mirror_with_deletions" && migrationPlan.mirror_confirmation !== migrationMirrorConfirmationPhrase ) { - const message = `Type ${migrationMirrorConfirmationPhrase} before running a mirror migration.`; + const message = `Type ${migrationMirrorConfirmationPhrase} before running this destination cleanup.`; elements.datamanagementmigrationmirrorconfirmationphrase?.classList.add("is-invalid"); elements.datamanagementmigrationmirrorconfirmationphrase?.focus(); setStatus(message, "danger"); @@ -2160,8 +2224,8 @@ function updateMigrationModeVisibility() { elements.dataManagementMigrationMirrorConfirmation?.classList.toggle("d-none", !isMirror); const descriptions = { new_only: "Copies source items that are absent from the destination. Existing destination data is never updated or deleted.", - delta_upsert: "Copies new items and updates changed migration-owned items since the prior successful watermark. Destination-only data is retained.", - mirror_with_deletions: "Runs delta/upsert, then deletes destination-only items that carry successful SimpleChat migration ownership. Unowned data is retained.", + delta_upsert: "Copies new items and updates changed migration-owned items from the previous completed migration. Destination-only data is retained.", + mirror_with_deletions: "Catches up changed items, then removes destination-only items that were created by SimpleChat migration. Unowned data is retained.", }; if (elements.dataManagementMigrationModeDescription) { elements.dataManagementMigrationModeDescription.textContent = descriptions[migrationMode] || descriptions.new_only; @@ -2317,6 +2381,13 @@ function createMigrationSummaryCard(targetType, targetSummary) { function renderMigrationReviewChecks(checks) { const container = elements.dataManagementMigrationReviewChecks; + if (!container) { + return; + } + renderDataManagementReviewChecks(container, checks); +} + +function renderDataManagementReviewChecks(container, checks) { if (!container) { return; } @@ -2399,6 +2470,9 @@ function formatOperation(operation, backupType) { if (operation === "backup") { return `${backupType || "manual"} backup`; } + if (operation === "restore") { + return "restore"; + } return operation.replace(/_/g, " "); } @@ -2916,7 +2990,7 @@ function createBackupRow(backup) { row.appendChild(createBackupStorageCell(backup)); row.appendChild(createBackupProtectionCell(backup)); row.appendChild(createBackupWarningCell(backup)); - row.appendChild(createJobActionCell(backup.id)); + row.appendChild(createBackupActionCell(backup)); return row; } @@ -2984,6 +3058,289 @@ function createBackupWarningCell(backup) { return cell; } +function createBackupActionCell(backup) { + const cell = document.createElement("td"); + const buttonGroup = document.createElement("div"); + buttonGroup.className = "d-flex flex-wrap gap-1"; + + const viewButton = document.createElement("button"); + viewButton.type = "button"; + viewButton.className = "btn btn-outline-primary btn-sm"; + viewButton.disabled = !backup.id; + viewButton.append(createIcon("bi bi-list-check me-1"), document.createTextNode("View Log")); + viewButton.addEventListener("click", () => loadDataManagementJobDetail(backup.id)); + buttonGroup.appendChild(viewButton); + + const restoreButton = document.createElement("button"); + restoreButton.type = "button"; + restoreButton.className = "btn btn-outline-warning btn-sm"; + restoreButton.disabled = !backup.id || !["completed", "completed_with_warnings"].includes(backup.status); + restoreButton.append(createIcon("bi bi-arrow-counterclockwise me-1"), document.createTextNode("Restore")); + restoreButton.addEventListener("click", () => openRestoreWorkflow(backup)); + buttonGroup.appendChild(restoreButton); + + cell.appendChild(buttonGroup); + return cell; +} + +function openRestoreWorkflow(backup) { + restoreWorkflowState.selectedBackup = backup; + restoreWorkflowState.review = null; + restoreWorkflowState.reviewStale = true; + restoreWorkflowState.currentJob = null; + restoreWorkflowState.activeStepIndex = 0; + restoreWorkflowState.highestVisitedStepIndex = 0; + setChecked(elements.datamanagementrestorefinalconfirmation, false); + setRestoreStatus("", "info"); + renderRestoreSelectedBackup(); + renderRestoreReviewState(); + updateRestoreExecutionButtonState(); + showRestoreWorkflowStep(0, { skipValidation: true }); + if (elements.dataManagementRestoreModal && window.bootstrap?.Modal) { + window.bootstrap.Modal.getOrCreateInstance(elements.dataManagementRestoreModal).show(); + } +} + +function resetRestoreWorkflowState() { + restoreWorkflowState.activeStepIndex = 0; + restoreWorkflowState.highestVisitedStepIndex = 0; + restoreWorkflowState.selectedBackup = null; + restoreWorkflowState.review = null; + restoreWorkflowState.reviewStale = true; + restoreWorkflowState.currentJob = null; + setChecked(elements.datamanagementrestorefinalconfirmation, false); + setRestoreStatus("", "info"); +} + +function setRestoreStatus(message, variant = "info") { + const statusElement = elements.dataManagementRestoreStatus; + if (!statusElement) { + return; + } + statusElement.textContent = message || ""; + statusElement.className = `alert alert-${variant}`; + statusElement.classList.toggle("d-none", !message); +} + +function renderRestoreSelectedBackup() { + const container = elements.dataManagementRestoreSelectedBackup; + const backup = restoreWorkflowState.selectedBackup || {}; + if (!container) { + return; + } + if (!backup.id) { + container.replaceChildren(createSmallMutedElement("Choose Restore from Backup Inventory.")); + return; + } + const summary = document.createElement("div"); + summary.className = "migration-review-grid"; + [ + ["Backup type", formatBackupType(backup.backup_type)], + ["Status", formatStatusLabel(backup.status || "unknown")], + ["Completed", formatDate(backup.completed_at || backup.created_at)], + ["Artifacts", formatNumber(backup.artifact_count || 0)], + ["Records / blobs", formatBackupRecordCounts(backup)], + ["Protection", backup.encrypted ? "Encrypted" : "Not encrypted"], + ].forEach(([label, value]) => { + const metric = document.createElement("div"); + metric.className = "migration-review-metric"; + const metricLabel = document.createElement("span"); + metricLabel.textContent = label; + const metricValue = document.createElement("strong"); + metricValue.textContent = value; + metric.append(metricLabel, metricValue); + summary.appendChild(metric); + }); + const backupId = document.createElement("p"); + backupId.className = "small text-muted text-break mt-3 mb-0"; + backupId.textContent = `Backup job ID: ${backup.id}`; + container.replaceChildren(summary, backupId); +} + +function buildRestorePlan() { + const backup = restoreWorkflowState.selectedBackup || {}; + return { + backup_job_id: backup.id || "", + restore_mode: getValue(elements.datamanagementrestoremode) || "review_only", + include_cosmos: Boolean(elements.datamanagementrestoreincludecosmos?.checked), + include_ai_search: Boolean(elements.datamanagementrestoreincludeaisearch?.checked), + include_source_blobs: Boolean(elements.datamanagementrestoreincludesourceblobs?.checked), + }; +} + +function invalidateRestoreReview() { + restoreWorkflowState.review = null; + restoreWorkflowState.reviewStale = true; + setChecked(elements.datamanagementrestorefinalconfirmation, false); + renderRestoreReviewState(); + updateRestoreExecutionButtonState(); +} + +async function runRestoreReview() { + if (!restoreWorkflowState.selectedBackup?.id) { + setRestoreStatus("Choose a backup before running restore preflight.", "warning"); + return; + } + setBusy(elements.dataManagementRestoreReviewBtn, true, "Running..."); + setElementVisible(elements.dataManagementRestoreReviewEmpty, false); + setElementVisible(elements.dataManagementRestoreReviewChecks, true); + elements.dataManagementRestoreReviewChecks?.replaceChildren(createMigrationCatalogLoadingState()); + try { + const data = await requestJson("/api/admin/data-management/restore/review", { + method: "POST", + body: JSON.stringify({ restore_plan: buildRestorePlan() }), + }); + restoreWorkflowState.review = data.review || null; + restoreWorkflowState.reviewStale = false; + renderRestoreReviewState(); + updateRestoreExecutionButtonState(); + const supported = restoreWorkflowState.review?.supported === true; + const ready = restoreWorkflowState.review?.ready === true; + const message = supported && ready + ? "Restore preflight is ready for confirmation." + : "Restore preflight completed. Restore execution is not enabled for this selection."; + setRestoreStatus(message, supported && ready ? "success" : "warning"); + } catch (error) { + restoreWorkflowState.review = null; + restoreWorkflowState.reviewStale = true; + setRestoreStatus(error.message || "Restore preflight could not be completed.", "danger"); + elements.dataManagementRestoreReviewChecks?.replaceChildren(createSmallMutedElement(error.message || "Restore preflight failed.")); + } finally { + setBusy(elements.dataManagementRestoreReviewBtn, false); + } +} + +function renderRestoreReviewState() { + const review = restoreWorkflowState.review || {}; + if (!review.checks) { + setElementVisible(elements.dataManagementRestoreReviewEmpty, true); + setElementVisible(elements.dataManagementRestoreReviewChecks, false); + renderRestoreConfirmSummary(); + return; + } + setElementVisible(elements.dataManagementRestoreReviewEmpty, false); + setElementVisible(elements.dataManagementRestoreReviewChecks, true); + renderDataManagementReviewChecks( + elements.dataManagementRestoreReviewChecks, + Array.isArray(review.checks) ? review.checks : [] + ); + renderRestoreConfirmSummary(); +} + +function renderRestoreConfirmSummary() { + const container = elements.dataManagementRestoreConfirmSummary; + if (!container) { + return; + } + const review = restoreWorkflowState.review || {}; + const summary = review.summary || {}; + if (!review.checks) { + container.replaceChildren(createSmallMutedElement("Run restore preflight before confirmation.")); + return; + } + const grid = document.createElement("div"); + grid.className = "migration-review-grid"; + [ + ["Backup type", formatBackupType(summary.backup_type)], + ["Preflight", review.ready ? "Ready" : "Blocked"], + ["Execution support", review.supported ? "Enabled" : "Not enabled"], + ["Cosmos", summary.include_cosmos ? "Included" : "Not included"], + ["AI Search", summary.include_ai_search ? "Included" : "Not included"], + ["Source blobs", summary.include_source_blobs ? "Included" : "Not included"], + ].forEach(([label, value]) => { + const metric = document.createElement("div"); + metric.className = "migration-review-metric"; + const metricLabel = document.createElement("span"); + metricLabel.textContent = label; + const metricValue = document.createElement("strong"); + metricValue.textContent = value; + metric.append(metricLabel, metricValue); + grid.appendChild(metric); + }); + container.replaceChildren(grid); +} + +function showRestoreWorkflowStep(stepIndex, options = {}) { + const normalizedIndex = Math.max(0, Math.min(restoreWorkflowSteps.length - 1, Number(stepIndex))); + if (!options.skipValidation && normalizedIndex > restoreWorkflowState.activeStepIndex) { + if (!validateRestoreWorkflowStep(restoreWorkflowState.activeStepIndex)) { + return; + } + } + restoreWorkflowState.activeStepIndex = normalizedIndex; + restoreWorkflowState.highestVisitedStepIndex = Math.max( + restoreWorkflowState.highestVisitedStepIndex, + normalizedIndex + ); + const activeStep = restoreWorkflowSteps[normalizedIndex]; + document.querySelectorAll("[data-restore-step-panel]").forEach((panel) => { + const isActive = panel.dataset.restoreStepPanel === activeStep; + panel.classList.toggle("d-none", !isActive); + panel.setAttribute("aria-hidden", String(!isActive)); + }); + document.querySelectorAll("[data-restore-step-button]").forEach((button) => { + const buttonIndex = restoreWorkflowSteps.indexOf(button.dataset.restoreStepButton); + const isActive = buttonIndex === normalizedIndex; + button.classList.toggle("is-active", isActive); + button.classList.toggle("is-complete", buttonIndex < normalizedIndex); + if (isActive) { + button.setAttribute("aria-current", "step"); + } else { + button.removeAttribute("aria-current"); + } + }); + updateRestoreWorkflowFooter(); + updateRestoreExecutionButtonState(); +} + +function validateRestoreWorkflowStep(stepIndex) { + const step = restoreWorkflowSteps[stepIndex]; + if (step === "backup" && !restoreWorkflowState.selectedBackup?.id) { + setRestoreStatus("Choose a backup from Backup Inventory before continuing.", "warning"); + return false; + } + if (step === "review" && restoreWorkflowState.reviewStale) { + setRestoreStatus("Run restore preflight before continuing.", "warning"); + return false; + } + return true; +} + +function updateRestoreWorkflowFooter() { + const isFirst = restoreWorkflowState.activeStepIndex === 0; + const isLast = restoreWorkflowState.activeStepIndex === restoreWorkflowSteps.length - 1; + setButtonDisabled(elements.dataManagementRestoreBackBtn, isFirst); + setButtonDisabled(elements.dataManagementRestoreNextBtn, isLast); + setText( + elements.dataManagementRestoreStepPosition, + `Step ${formatNumber(restoreWorkflowState.activeStepIndex + 1)} of ${formatNumber(restoreWorkflowSteps.length)}` + ); +} + +function updateRestoreExecutionButtonState() { + const canExecute = Boolean( + restoreWorkflowState.review?.supported === true && + restoreWorkflowState.review?.ready === true && + elements.datamanagementrestorefinalconfirmation?.checked + ); + setButtonDisabled(elements.dataManagementExecuteRestoreBtn, !canExecute); +} + +async function queueRestore() { + if (!restoreWorkflowState.review?.supported || !restoreWorkflowState.review?.ready) { + setRestoreStatus("Restore execution is not enabled for this backup in the current build.", "warning"); + return; + } + const data = await queueOperation("restore", null, { restore_plan: buildRestorePlan() }, elements.dataManagementExecuteRestoreBtn); + if (data?.job) { + restoreWorkflowState.currentJob = data.job; + setElementVisible(elements.dataManagementRestoreProgressEmpty, false); + setElementVisible(elements.dataManagementRestoreProgressState, true); + elements.dataManagementRestoreProgressState?.replaceChildren(createSmallMutedElement(`Restore job queued: ${data.job.id}`)); + showRestoreWorkflowStep(restoreWorkflowSteps.indexOf("progress"), { skipValidation: true }); + } +} + function createLabeledValue(label, value, allowBreak = false) { const wrapper = document.createElement("div"); const labelElement = document.createElement("span"); @@ -4058,6 +4415,14 @@ function formatStatusLabel(value) { } function formatActivityLabel(value) { + const friendlyLabels = { + new_only: "Copy Missing Items Only", + delta_upsert: "Catch Up Changed Items", + mirror_with_deletions: "Make Destination Match Source", + }; + if (friendlyLabels[value]) { + return friendlyLabels[value]; + } return String(value || "") .replace(/[_-]/g, " ") .replace(/\b\w/g, (letter) => letter.toUpperCase()); diff --git a/application/single_app/static/js/admin/admin_sidebar_nav.js b/application/single_app/static/js/admin/admin_sidebar_nav.js index 0b9c9d600..fbdb4f357 100644 --- a/application/single_app/static/js/admin/admin_sidebar_nav.js +++ b/application/single_app/static/js/admin/admin_sidebar_nav.js @@ -226,6 +226,7 @@ function scrollToSection(sectionId) { // Security tab sections 'keyvault-section': 'keyvault-section', // Data Management tab sections + 'data-management-readiness-section': 'data-management-readiness-section', 'data-management-backup-section': 'data-management-backup-section', 'data-management-schedule-section': 'data-management-schedule-section', 'data-management-storage-section': 'data-management-storage-section', diff --git a/application/single_app/templates/_sidebar_nav.html b/application/single_app/templates/_sidebar_nav.html index 17fa91517..d093fcd97 100644 --- a/application/single_app/templates/_sidebar_nav.html +++ b/application/single_app/templates/_sidebar_nav.html @@ -669,9 +669,14 @@