agent-zero/webui/components/settings/backup/backup-store.js
Alessandro d1827e6c66
Some checks are pending
Build And Publish Docker Images / plan (push) Waiting to run
Build And Publish Docker Images / build (push) Blocked by required conditions
Refactor: use user locale for time displays
Add user-configurable timezone and 12/24-hour preferences, then wire them through settings, runtime snapshots, scheduler payloads, wait handling, notifications, backups, memory, plugin metadata, and frontend formatters.

Keep UTC as the boundary for absolute instants while serializing user-facing dates in the configured or browser-resolved timezone. Preserve scheduler wall-clock inputs in the selected timezone, propagate TZ into desktop/runtime process environments, and restart active desktop sessions when the runtime timezone changes.

Cover the risky paths with timezone regression tests for settings normalization, auto and fixed timezone resolution, scheduler round-trips, memory timestamp conversion, and desktop timezone sync.
2026-05-21 15:26:00 +02:00

872 lines
27 KiB
JavaScript

import { createStore } from "/js/AlpineStore.js";
import {
formatDateTime,
getCurrentUserDateString,
getCurrentUserISOString,
getUserHour12,
getUserTimezone,
} from "/js/time-utils.js";
// Global function references
const sendJsonData = globalThis.sendJsonData;
const toast = globalThis.toast;
const fetchApi = globalThis.fetchApi;
// ⚠️ CRITICAL: The .env file contains API keys and essential configuration.
// This file is REQUIRED for Agent Zero to function and must be backed up.
const model = {
// State
mode: 'backup', // 'backup' or 'restore'
loading: false,
loadingMessage: '',
error: '',
// File operations log (shared between backup and restore)
fileOperationsLog: '',
// Backup state
backupMetadataConfig: null,
includeHidden: false,
previewStats: { total: 0, truncated: false },
backupEditor: null,
// Enhanced file preview state
previewMode: 'grouped', // 'grouped' or 'flat'
previewFiles: [],
previewGroups: [],
filteredPreviewFiles: [],
fileSearchFilter: '',
expandedGroups: new Set(),
// Progress state
progressData: null,
progressEventSource: null,
// Restore state
backupFile: null,
backupMetadata: null,
restorePatterns: '',
overwritePolicy: 'overwrite',
cleanBeforeRestore: false,
restoreEditor: null,
restoreResult: null,
// Initialization
async initBackup() {
this.mode = 'backup';
this.resetState();
await this.initBackupEditor();
await this.updatePreview();
},
async initRestore() {
this.mode = 'restore';
this.resetState();
await this.initRestoreEditor();
},
resetState() {
this.loading = false;
this.error = '';
this.backupFile = null;
this.backupMetadata = null;
this.restoreResult = null;
this.fileOperationsLog = '';
},
// File operations logging
addFileOperation(message) {
const timestamp = new Intl.DateTimeFormat(undefined, {
timeStyle: "medium",
hour12: getUserHour12(),
timeZone: getUserTimezone(),
}).format(new Date());
this.fileOperationsLog += `[${timestamp}] ${message}\n`;
// Auto-scroll to bottom - use setTimeout since $nextTick is not available in stores
setTimeout(() => {
const textarea = document.getElementById(this.mode === 'backup' ? 'backup-file-list' : 'restore-file-list');
if (textarea) {
textarea.scrollTop = textarea.scrollHeight;
}
}, 0);
},
clearFileOperations() {
this.fileOperationsLog = '';
},
createDownloadToastGroup(prefix) {
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
},
showDownloadPreparingToast(group) {
window.toastFrontendInfo?.("Preparing download...", "Download", 0, group, undefined, true);
},
showDownloadStartedToast(group) {
window.toastFrontendInfo?.("Downloading...", "Download", 3, group, undefined, true);
},
showDownloadErrorToast(group, message) {
window.toastFrontendError?.(message || "Download failed", "Download Error", 8, group, undefined, true);
},
// Cleanup method for modal close
onClose() {
this.resetState();
if (this.backupEditor) {
this.backupEditor.destroy();
this.backupEditor = null;
}
if (this.restoreEditor) {
this.restoreEditor.destroy();
this.restoreEditor = null;
}
},
// Get default backup metadata with resolved patterns from backend
async getDefaultBackupMetadata() {
const timestamp = getCurrentUserISOString();
try {
// Get resolved default patterns from backend
const response = await sendJsonData("backup_get_defaults", {});
if (response.success) {
// Use patterns from backend with resolved absolute paths
const include_patterns = response.default_patterns.include_patterns;
const exclude_patterns = response.default_patterns.exclude_patterns;
return {
backup_name: `agent-zero-backup-${getCurrentUserDateString()}`,
include_hidden: true,
include_patterns: include_patterns,
exclude_patterns: exclude_patterns,
backup_config: {
compression_level: 6,
integrity_check: true
}
};
}
} catch (error) {
console.warn("Failed to get default patterns from backend, using fallback");
}
// Fallback patterns (will be overridden by backend on first use)
return {
backup_name: `agent-zero-backup-${timestamp.slice(0, 10)}`,
include_hidden: true,
include_patterns: [
// These will be replaced with resolved absolute paths by backend
"# Loading default patterns from backend..."
],
exclude_patterns: [],
backup_config: {
compression_level: 6,
integrity_check: true
}
};
},
// Editor Management - Following Agent Zero ACE editor patterns
async initBackupEditor() {
const container = document.getElementById("backup-metadata-editor");
if (container) {
const editor = ace.edit("backup-metadata-editor");
const dark = localStorage.getItem("darkMode");
if (dark != "false") {
editor.setTheme("ace/theme/github_dark");
} else {
editor.setTheme("ace/theme/tomorrow");
}
editor.session.setMode("ace/mode/json");
// Initialize with default backup metadata
const defaultMetadata = await this.getDefaultBackupMetadata();
editor.setValue(JSON.stringify(defaultMetadata, null, 2));
editor.clearSelection();
// Auto-update preview on changes (debounced)
let timeout;
editor.on('change', () => {
clearTimeout(timeout);
timeout = setTimeout(() => {
this.updatePreview();
}, 1000);
});
this.backupEditor = editor;
}
},
async initRestoreEditor() {
const container = document.getElementById("restore-metadata-editor");
if (container) {
const editor = ace.edit("restore-metadata-editor");
const dark = localStorage.getItem("darkMode");
if (dark != "false") {
editor.setTheme("ace/theme/github_dark");
} else {
editor.setTheme("ace/theme/tomorrow");
}
editor.session.setMode("ace/mode/json");
editor.setValue('{}');
editor.clearSelection();
// Auto-validate JSON on changes
editor.on('change', () => {
this.validateRestoreMetadata();
});
this.restoreEditor = editor;
}
},
// Unified editor value getter (following MCP servers pattern)
getEditorValue() {
const editor = this.mode === 'backup' ? this.backupEditor : this.restoreEditor;
return editor ? editor.getValue() : '{}';
},
// Unified JSON formatting (following MCP servers pattern)
formatJson() {
const editor = this.mode === 'backup' ? this.backupEditor : this.restoreEditor;
if (!editor) return;
try {
const currentContent = editor.getValue();
const parsed = JSON.parse(currentContent);
const formatted = JSON.stringify(parsed, null, 2);
editor.setValue(formatted);
editor.clearSelection();
editor.navigateFileStart();
} catch (error) {
console.error("Failed to format JSON:", error);
this.error = "Invalid JSON: " + error.message;
}
},
// Enhanced File Preview Operations
async updatePreview() {
try {
const metadataText = this.getEditorValue();
const metadata = JSON.parse(metadataText);
if (!metadata.include_patterns || metadata.include_patterns.length === 0) {
this.previewStats = { total: 0, truncated: false };
this.previewFiles = [];
this.previewGroups = [];
return;
}
// Convert patterns arrays back to string format for API
const patternsString = this.convertPatternsToString(metadata.include_patterns, metadata.exclude_patterns);
// Get grouped preview for better UX
const response = await sendJsonData("backup_preview_grouped", {
patterns: patternsString,
include_hidden: metadata.include_hidden ?? true,
max_depth: 3,
search_filter: this.fileSearchFilter
});
if (response.success) {
this.previewGroups = response.groups;
this.previewStats = response.stats;
// Flatten groups for flat view
this.previewFiles = [];
response.groups.forEach(group => {
this.previewFiles.push(...group.files);
});
this.applyFileSearch();
} else {
this.error = response.error;
}
} catch (error) {
this.error = `Preview error: ${error.message}`;
}
},
// Convert pattern arrays to string format for backend API
convertPatternsToString(includePatterns, excludePatterns) {
const patterns = [];
// Add include patterns
if (includePatterns) {
patterns.push(...includePatterns);
}
// Add exclude patterns with '!' prefix
if (excludePatterns) {
excludePatterns.forEach(pattern => {
patterns.push(`!${pattern}`);
});
}
return patterns.join('\n');
},
// Validation for backup metadata
validateBackupMetadata() {
try {
const metadataText = this.getEditorValue();
const metadata = JSON.parse(metadataText);
// Validate required fields
if (!Array.isArray(metadata.include_patterns)) {
throw new Error('include_patterns must be an array');
}
if (!Array.isArray(metadata.exclude_patterns)) {
throw new Error('exclude_patterns must be an array');
}
if (!metadata.backup_name || typeof metadata.backup_name !== 'string') {
throw new Error('backup_name must be a non-empty string');
}
this.backupMetadataConfig = metadata;
this.error = '';
return true;
} catch (error) {
this.error = `Invalid backup metadata: ${error.message}`;
return false;
}
},
// File Preview UI Management
initFilePreview() {
this.fileSearchFilter = '';
this.expandedGroups.clear();
this.previewMode = localStorage.getItem('backupPreviewMode') || 'grouped';
},
togglePreviewMode() {
this.previewMode = this.previewMode === 'grouped' ? 'flat' : 'grouped';
localStorage.setItem('backupPreviewMode', this.previewMode);
},
toggleGroup(groupPath) {
if (this.expandedGroups.has(groupPath)) {
this.expandedGroups.delete(groupPath);
} else {
this.expandedGroups.add(groupPath);
}
},
isGroupExpanded(groupPath) {
return this.expandedGroups.has(groupPath);
},
debounceFileSearch() {
clearTimeout(this.searchTimeout);
this.searchTimeout = setTimeout(() => {
this.applyFileSearch();
}, 300);
},
clearFileSearch() {
this.fileSearchFilter = '';
this.applyFileSearch();
},
applyFileSearch() {
if (!this.fileSearchFilter.trim()) {
this.filteredPreviewFiles = this.previewFiles;
} else {
const search = this.fileSearchFilter.toLowerCase();
this.filteredPreviewFiles = this.previewFiles.filter(file =>
file.path.toLowerCase().includes(search)
);
}
},
async exportFileList() {
const fileList = this.previewFiles.map(f => f.path).join('\n');
const blob = new Blob([fileList], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'backup-file-list.txt';
a.click();
URL.revokeObjectURL(url);
},
async copyFileListToClipboard() {
const fileList = this.previewFiles.map(f => f.path).join('\n');
try {
await navigator.clipboard.writeText(fileList);
window.toastFrontendInfo('File list copied to clipboard', 'Clipboard');
} catch (error) {
window.toastFrontendError('Failed to copy to clipboard', 'Clipboard Error');
}
},
// Backup Creation using direct API call
async createBackup() {
// Validate backup metadata first
if (!this.validateBackupMetadata()) {
return;
}
const downloadToastGroup = this.createDownloadToastGroup("backup-create");
try {
this.loading = true;
this.loadingMessage = 'Creating backup...';
this.error = '';
this.clearFileOperations();
this.addFileOperation('Starting backup creation...');
this.showDownloadPreparingToast(downloadToastGroup);
const metadata = this.backupMetadataConfig;
// Use fetch directly since backup_create returns a file download, not JSON
const response = await fetchApi('/backup_create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
include_patterns: metadata.include_patterns,
exclude_patterns: metadata.exclude_patterns,
include_hidden: metadata.include_hidden ?? true,
backup_name: metadata.backup_name
})
});
if (response.ok) {
// Handle file download
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${metadata.backup_name}.zip`;
a.click();
window.URL.revokeObjectURL(url);
this.addFileOperation('Backup created and downloaded successfully!');
this.showDownloadStartedToast(downloadToastGroup);
} else {
// Try to parse error response
const errorText = await response.text();
try {
const errorJson = JSON.parse(errorText);
this.error = errorJson.error || 'Backup creation failed';
} catch {
this.error = `Backup creation failed: ${response.status} ${response.statusText}`;
}
this.addFileOperation(`Error: ${this.error}`);
this.showDownloadErrorToast(downloadToastGroup, this.error);
}
} catch (error) {
this.error = `Backup error: ${error.message}`;
this.addFileOperation(`Error: ${error.message}`);
this.showDownloadErrorToast(downloadToastGroup, this.error);
} finally {
this.loading = false;
}
},
async downloadBackup(backupPath, backupName) {
const downloadToastGroup = this.createDownloadToastGroup("backup-download");
try {
this.showDownloadPreparingToast(downloadToastGroup);
const response = await fetchApi('/backup_download', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ backup_path: backupPath })
});
if (response.ok) {
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${backupName}.zip`;
a.click();
window.URL.revokeObjectURL(url);
this.showDownloadStartedToast(downloadToastGroup);
} else {
const errorText = await response.text();
this.error = errorText || `Download failed: ${response.status}`;
this.showDownloadErrorToast(downloadToastGroup, this.error);
}
} catch (error) {
console.error('Download error:', error);
this.error = error.message || 'Download failed';
this.showDownloadErrorToast(downloadToastGroup, this.error);
}
},
cancelBackup() {
if (this.progressEventSource) {
this.progressEventSource.close();
this.progressEventSource = null;
}
this.loading = false;
this.progressData = null;
},
resetToDefaults() {
this.getDefaultBackupMetadata().then(defaultMetadata => {
if (this.backupEditor) {
this.backupEditor.setValue(JSON.stringify(defaultMetadata, null, 2));
this.backupEditor.clearSelection();
}
this.updatePreview();
});
},
// Dry run functionality
async dryRun() {
if (this.mode === 'backup') {
await this.dryRunBackup();
} else if (this.mode === 'restore') {
await this.dryRunRestore();
}
},
async dryRunBackup() {
// Validate backup metadata first
if (!this.validateBackupMetadata()) {
return;
}
try {
this.loading = true;
this.loadingMessage = 'Performing dry run...';
this.error = '';
this.clearFileOperations();
this.addFileOperation('Starting backup dry run...');
const metadata = this.backupMetadataConfig;
const patternsString = this.convertPatternsToString(metadata.include_patterns, metadata.exclude_patterns);
const response = await sendJsonData("backup_test", {
patterns: patternsString,
include_hidden: metadata.include_hidden ?? true,
max_files: 10000
});
if (response.success) {
this.addFileOperation(`Found ${response.files.length} files that would be backed up:`);
response.files.forEach((file, index) => {
this.addFileOperation(`${index + 1}. ${file.path} (${this.formatFileSize(file.size)})`);
});
this.addFileOperation(`\nTotal: ${response.files.length} files, ${this.formatFileSize(response.files.reduce((sum, f) => sum + f.size, 0))}`);
this.addFileOperation('Dry run completed successfully.');
} else {
this.error = response.error;
this.addFileOperation(`Error: ${response.error}`);
}
} catch (error) {
this.error = `Dry run error: ${error.message}`;
this.addFileOperation(`Error: ${error.message}`);
} finally {
this.loading = false;
}
},
async dryRunRestore() {
if (!this.backupFile) {
this.error = 'Please select a backup file first';
return;
}
try {
this.loading = true;
this.loadingMessage = 'Performing restore dry run...';
this.error = '';
this.restoreResult = null;
this.clearFileOperations();
this.addFileOperation('Starting restore dry run...');
const formData = new FormData();
formData.append('backup_file', this.backupFile);
formData.append('metadata', this.getEditorValue());
formData.append('overwrite_policy', this.overwritePolicy);
formData.append('clean_before_restore', this.cleanBeforeRestore);
const response = await fetchApi('/backup_restore_preview', {
method: 'POST',
body: formData
});
const result = await response.json();
if (result.success) {
// Show delete operations if clean before restore is enabled
if (result.files_to_delete && result.files_to_delete.length > 0) {
this.addFileOperation(`Clean before restore - ${result.files_to_delete.length} files would be deleted:`);
result.files_to_delete.forEach((file, index) => {
this.addFileOperation(`${index + 1}. DELETE: ${file.path}`);
});
this.addFileOperation('');
}
// Show restore operations
if (result.files_to_restore && result.files_to_restore.length > 0) {
this.addFileOperation(`${result.files_to_restore.length} files would be restored:`);
result.files_to_restore.forEach((file, index) => {
this.addFileOperation(`${index + 1}. RESTORE: ${file.original_path} -> ${file.target_path}`);
});
}
// Show skipped files
if (result.skipped_files && result.skipped_files.length > 0) {
this.addFileOperation(`\nSkipped ${result.skipped_files.length} files:`);
result.skipped_files.forEach((file, index) => {
this.addFileOperation(`${index + 1}. ${file.original_path} (${file.reason})`);
});
}
const deleteCount = result.delete_count || 0;
const restoreCount = result.restore_count || 0;
const skippedCount = result.skipped_files?.length || 0;
this.addFileOperation(`\nSummary: ${deleteCount} to delete, ${restoreCount} to restore, ${skippedCount} skipped`);
this.addFileOperation('Dry run completed successfully.');
} else {
this.error = result.error;
this.addFileOperation(`Error: ${result.error}`);
}
} catch (error) {
this.error = `Dry run error: ${error.message}`;
this.addFileOperation(`Error: ${error.message}`);
} finally {
this.loading = false;
}
},
// Enhanced Restore Operations with Metadata Display
async handleFileUpload(event) {
const file = event.target.files[0];
if (!file) return;
this.backupFile = file;
this.error = '';
this.restoreResult = null;
try {
this.loading = true;
this.loadingMessage = 'Inspecting backup archive...';
const formData = new FormData();
formData.append('backup_file', file);
const response = await fetchApi('/backup_inspect', {
method: 'POST',
body: formData
});
const result = await response.json();
if (result.success) {
this.backupMetadata = result.metadata;
// Load complete metadata for JSON editing
this.restoreMetadata = JSON.parse(JSON.stringify(result.metadata)); // Deep copy
// Initialize restore editor with complete metadata JSON
if (this.restoreEditor) {
this.restoreEditor.setValue(JSON.stringify(this.restoreMetadata, null, 2));
this.restoreEditor.clearSelection();
}
// Validate backup compatibility
this.validateBackupCompatibility();
} else {
this.error = result.error;
this.backupMetadata = null;
}
} catch (error) {
this.error = `Inspection error: ${error.message}`;
this.backupMetadata = null;
} finally {
this.loading = false;
}
},
validateBackupCompatibility() {
if (!this.backupMetadata) return;
const warnings = [];
// Check Agent Zero version compatibility
// Note: Both backup and current versions are obtained via git.get_git_info()
const backupVersion = this.backupMetadata.agent_zero_version;
const currentVersion = globalThis.gitinfo.version; // Retrieved from git.get_git_info() on backend
if (backupVersion !== currentVersion && backupVersion !== "development") {
warnings.push(`Backup created with Agent Zero ${backupVersion}, current version is ${currentVersion}`);
}
// Check backup age
const backupDate = new Date(this.backupMetadata.timestamp);
const daysSinceBackup = (Date.now() - backupDate) / (1000 * 60 * 60 * 24);
if (daysSinceBackup > 30) {
warnings.push(`Backup is ${Math.floor(daysSinceBackup)} days old`);
}
// Check system compatibility
const systemInfo = this.backupMetadata.system_info;
if (systemInfo && systemInfo.system) {
// Could add platform-specific warnings here
}
if (warnings.length > 0) {
window.toastFrontendWarning(`Compatibility warnings: ${warnings.join(', ')}`, 'Backup Compatibility');
}
},
async performRestore() {
if (!this.backupFile) {
this.error = 'Please select a backup file';
return;
}
try {
this.loading = true;
this.loadingMessage = 'Restoring files...';
this.error = '';
this.restoreResult = null;
this.clearFileOperations();
this.addFileOperation('Starting file restoration...');
const formData = new FormData();
formData.append('backup_file', this.backupFile);
formData.append('metadata', this.getEditorValue());
formData.append('overwrite_policy', this.overwritePolicy);
formData.append('clean_before_restore', this.cleanBeforeRestore);
const response = await fetchApi('/backup_restore', {
method: 'POST',
body: formData
});
const result = await response.json();
if (result.success) {
// Log deleted files if clean before restore was enabled
if (result.deleted_files && result.deleted_files.length > 0) {
this.addFileOperation(`Clean before restore - Successfully deleted ${result.deleted_files.length} files:`);
result.deleted_files.forEach((file, index) => {
this.addFileOperation(`${index + 1}. DELETED: ${file.path}`);
});
this.addFileOperation('');
}
// Log restored files
this.addFileOperation(`Successfully restored ${result.restored_files.length} files:`);
result.restored_files.forEach((file, index) => {
this.addFileOperation(`${index + 1}. RESTORED: ${file.archive_path} -> ${file.target_path}`);
});
// Log skipped files
if (result.skipped_files && result.skipped_files.length > 0) {
this.addFileOperation(`\nSkipped ${result.skipped_files.length} files:`);
result.skipped_files.forEach((file, index) => {
this.addFileOperation(`${index + 1}. ${file.original_path} (${file.reason})`);
});
}
// Log errors
if (result.errors && result.errors.length > 0) {
this.addFileOperation(`\nErrors during restoration:`);
result.errors.forEach((error, index) => {
this.addFileOperation(`${index + 1}. ${error.original_path}: ${error.error}`);
});
}
const deletedCount = result.deleted_files?.length || 0;
const restoredCount = result.restored_files.length;
const skippedCount = result.skipped_files?.length || 0;
const errorCount = result.errors?.length || 0;
this.addFileOperation(`\nRestore completed: ${deletedCount} deleted, ${restoredCount} restored, ${skippedCount} skipped, ${errorCount} errors`);
this.restoreResult = result;
window.toastFrontendInfo('Restore completed successfully', 'Restore Status');
} else {
this.error = result.error;
this.addFileOperation(`Error: ${result.error}`);
}
} catch (error) {
this.error = `Restore error: ${error.message}`;
this.addFileOperation(`Error: ${error.message}`);
} finally {
this.loading = false;
}
},
// JSON Metadata Utilities
validateRestoreMetadata() {
try {
const metadataText = this.getEditorValue();
const metadata = JSON.parse(metadataText);
// Validate required fields
if (!Array.isArray(metadata.include_patterns)) {
throw new Error('include_patterns must be an array');
}
if (!Array.isArray(metadata.exclude_patterns)) {
throw new Error('exclude_patterns must be an array');
}
this.restoreMetadata = metadata;
this.error = '';
return true;
} catch (error) {
this.error = `Invalid JSON metadata: ${error.message}`;
return false;
}
},
getCurrentRestoreMetadata() {
if (this.validateRestoreMetadata()) {
return this.restoreMetadata;
}
return null;
},
// Restore Operations - Metadata Control
resetToOriginalMetadata() {
if (this.backupMetadata) {
this.restoreMetadata = JSON.parse(JSON.stringify(this.backupMetadata)); // Deep copy
if (this.restoreEditor) {
this.restoreEditor.setValue(JSON.stringify(this.restoreMetadata, null, 2));
this.restoreEditor.clearSelection();
}
}
},
// Utility
formatTimestamp(timestamp) {
if (!timestamp) return 'Unknown';
return formatDateTime(timestamp, "full");
},
formatFileSize(bytes) {
if (!bytes) return '0 B';
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${sizes[i]}`;
},
formatDate(dateString) {
if (!dateString) return 'Unknown';
return formatDateTime(dateString, "date");
}
};
const store = createStore("backupStore", model);
export { store };