From db45111d292e739bdd9d8a410e034c8d9f07f977 Mon Sep 17 00:00:00 2001 From: Gareth Date: Thu, 25 Dec 2025 00:35:08 -0800 Subject: [PATCH] more progress --- webui/messages/ar.json | 5 - webui/messages/bn.json | 5 - webui/messages/de.json | 5 - webui/messages/en.json | 15 +- webui/messages/es.json | 5 - webui/messages/fr.json | 5 - webui/messages/hi.json | 5 - webui/messages/id.json | 5 - webui/messages/it.json | 5 - webui/messages/pt.json | 5 - webui/messages/ru.json | 5 - webui/messages/zh.json | 14 +- webui/pnpm-lock.yaml | 99 ++- webui/src/app/App.tsx | 23 +- webui/src/components/common/Alerts.tsx | 5 +- webui/src/components/common/EnumSelector.tsx | 81 ++ webui/src/components/common/HooksFormList.tsx | 162 ++-- .../components/common/ScheduleFormItem.tsx | 16 +- webui/src/features/auth/LoginModal.tsx | 4 +- .../src/features/operations/OperationRow.tsx | 4 +- webui/src/features/plans/AddPlanModal.tsx | 120 ++- webui/src/features/plans/PlanView.tsx | 70 +- .../features/repositories/AddRepoModal.tsx | 776 +++++++++++++----- webui/src/features/repositories/RepoView.tsx | 88 +- webui/src/features/settings/SettingsModal.tsx | 4 +- 25 files changed, 1005 insertions(+), 526 deletions(-) create mode 100644 webui/src/components/common/EnumSelector.tsx diff --git a/webui/messages/ar.json b/webui/messages/ar.json index 336344d3..1ccaaf78 100644 --- a/webui/messages/ar.json +++ b/webui/messages/ar.json @@ -222,7 +222,6 @@ "add_repo_modal_field_cpu_priority_low": "CPU_LOW - أولوية أقل من الأولوية الافتراضية", "add_repo_modal_field_cpu_priority_placeholder": "حدد أولوية وحدة المعالجة المركزية", "add_repo_modal_field_auto_unlock": "فتح تلقائي", - "add_repo_modal_field_auto_unlock_tooltip": "ستؤدي خاصية الفتح التلقائي إلى إزالة ملفات القفل عند بدء عمليات النسيان والحذف. قد يكون هذا غير آمن إذا تمت مشاركة المستودع من قبل عدة أجهزة عميلة. هذه الخاصية معطلة افتراضيًا.", "add_repo_modal_preview_json": "ملف إعدادات المستودع بصيغة JSON", "add_repo_modal_error_missing_password": "كلمة مرور المستودع مفقودة. يرجى إدخال كلمة مرور أو تعيين أحد متغيرات البيئة التالية: RESTIC_PASSWORD، RESTIC_PASSWORD_COMMAND، RESTIC_PASSWORD_FILE.", "dashboard_error_fetch": "فشل في جلب بيانات الملخص: ", @@ -294,10 +293,6 @@ "op_row_cancel_error": "فشل إلغاء العملية: ", "op_row_logs_title": "سجلات العملية {name} في {time}", "op_row_view_logs": "[عرض السجلات]", - "op_row_confirm_cancel": "[تأكيد إلغاء؟]", - "op_row_cancel_op": "[إلغاء العملية]", - "op_row_confirm_delete": "[تأكيد الحذف؟]", - "op_row_delete": "[يمسح]", "op_row_backup_details": "تفاصيل النسخ الاحتياطي", "op_row_item_errors": "أخطاء في العنصر", "op_row_error_on_item": "خطأ في العنصر: {item}", diff --git a/webui/messages/bn.json b/webui/messages/bn.json index 8ac8e267..d8cdf0c5 100644 --- a/webui/messages/bn.json +++ b/webui/messages/bn.json @@ -222,7 +222,6 @@ "add_repo_modal_field_cpu_priority_low": "CPU_LOW - ডিফল্ট অগ্রাধিকারের চেয়ে কম", "add_repo_modal_field_cpu_priority_placeholder": "একটি CPU অগ্রাধিকার নির্বাচন করুন", "add_repo_modal_field_auto_unlock": "অটো আনলক", - "add_repo_modal_field_auto_unlock_tooltip": "ফরগেট এবং প্রুন অপারেশনের শুরুতে অটো-আনলক লকফাইলগুলি সরিয়ে ফেলবে। একাধিক ক্লায়েন্ট ডিভাইস দ্বারা রেপো শেয়ার করা থাকলে এটি সম্ভাব্যভাবে অনিরাপদ। ডিফল্টরূপে অক্ষম।", "add_repo_modal_preview_json": "JSON হিসেবে রেপো কনফিগারেশন", "add_repo_modal_error_missing_password": "রেপো পাসওয়ার্ড নেই। হয় একটি পাসওয়ার্ড দিন অথবা env ভেরিয়েবলগুলির মধ্যে একটি সেট করুন: RESTIC_PASSWORD, RESTIC_PASSWORD_COMMAND, RESTIC_PASSWORD_FILE।", "dashboard_error_fetch": "সারাংশ তথ্য আনতে ব্যর্থ: ", @@ -294,10 +293,6 @@ "op_row_cancel_error": "অপারেশন বাতিল করতে ব্যর্থ: ", "op_row_logs_title": "{name} এ {time}", "op_row_view_logs": "[লগ দেখুন]", - "op_row_confirm_cancel": "[বাতিল নিশ্চিত করবেন?]", - "op_row_cancel_op": "[কার্যক্রম বাতিল করুন]", - "op_row_confirm_delete": "[মুছে ফেলা নিশ্চিত করবেন?]", - "op_row_delete": "[মুছে ফেলুন]", "op_row_backup_details": "ব্যাকআপের বিবরণ", "op_row_item_errors": "আইটেম ত্রুটি", "op_row_error_on_item": "আইটেমটিতে ত্রুটি: {item}", diff --git a/webui/messages/de.json b/webui/messages/de.json index 53f92a1e..256c0d5b 100644 --- a/webui/messages/de.json +++ b/webui/messages/de.json @@ -222,7 +222,6 @@ "add_repo_modal_field_cpu_priority_low": "CPU_LOW – niedrigere Priorität als die Standardpriorität", "add_repo_modal_field_cpu_priority_placeholder": "Wählen Sie eine CPU-Priorität aus.", "add_repo_modal_field_auto_unlock": "Automatisches Entsperren", - "add_repo_modal_field_auto_unlock_tooltip": "Die automatische Entsperrung entfernt Sperrdateien zu Beginn von Lösch- und Bereinigungsvorgängen. Dies kann ein Sicherheitsrisiko darstellen, wenn das Repository von mehreren Clientgeräten gemeinsam genutzt wird. Standardmäßig deaktiviert.", "add_repo_modal_preview_json": "Repository-Konfiguration als JSON", "add_repo_modal_error_missing_password": "Fehlendes Repository-Passwort. Geben Sie entweder ein Passwort an oder setzen Sie eine der Umgebungsvariablen RESTIC_PASSWORD, RESTIC_PASSWORD_COMMAND oder RESTIC_PASSWORD_FILE.", "dashboard_error_fetch": "Zusammenfassende Daten konnten nicht abgerufen werden: ", @@ -294,10 +293,6 @@ "op_row_cancel_error": "Vorgang konnte nicht abgebrochen werden: ", "op_row_logs_title": "Protokolle für die Operation {name} um {time}", "op_row_view_logs": "[Protokolle anzeigen]", - "op_row_confirm_cancel": "[Bestätigen Abbrechen?]", - "op_row_cancel_op": "[Vorgang abbrechen]", - "op_row_confirm_delete": "[Löschen bestätigen?]", - "op_row_delete": "[Löschen]", "op_row_backup_details": "Sicherungsdetails", "op_row_item_errors": "Artikelfehler", "op_row_error_on_item": "Fehler beim Element: {item}", diff --git a/webui/messages/en.json b/webui/messages/en.json index 29bfe0a7..aaee6411 100644 --- a/webui/messages/en.json +++ b/webui/messages/en.json @@ -4,7 +4,7 @@ "add_plan_modal_error_template_not_found": "template not found", "add_plan_modal_error_plan_delete_not_found": "failed to update config, plan to delete not found", "add_plan_modal_success_plan_deleted": "Plan deleted from config, but not from restic repo. Snapshots will remain in storage and operations will be tracked until manually deleted. Reusing a deleted plan ID is not recommended if backups have already been performed.", - "add_plan_modal_error_destroy_prefix": "Destroy error:", + "add_plan_modal_error_destroy_prefix": "Destroy error: ", "add_plan_modal_error_operation_prefix": "Operation error: ", "add_plan_modal_title_update": "Update Plan", "add_plan_modal_title_add": "Add Plan", @@ -197,10 +197,6 @@ "op_row_cancel_error": "Failed to cancel operation: ", "op_row_logs_title": "Logs for operation {name} at {time}", "op_row_view_logs": "[View Logs]", - "op_row_confirm_cancel": "[Confirm Cancel?]", - "op_row_cancel_op": "[Cancel Operation]", - "op_row_confirm_delete": "[Confirm Delete?]", - "op_row_delete": "[Delete]", "op_row_backup_details": "Backup Details", "op_row_item_errors": "Item Errors", "op_row_error_on_item": "Error on item: {item}", @@ -293,13 +289,12 @@ "add_repo_modal_field_io_priority_idle": "IO_IDLE - only runs when disk bandwidth is idle (e.g. no other operations are queued)", "add_repo_modal_field_io_priority_placeholder": "Select an IO priority", "add_repo_modal_field_cpu_priority": "CPU Priority:", - "add_repo_modal_field_cpu_priority_tooltip_intro": "Available CPU priority modes:", + "add_repo_modal_field_cpu_priority_tooltip_intro": "Available CPU priority modes", "add_repo_modal_field_cpu_priority_default": "CPU_DEFAULT - no change in priority", "add_repo_modal_field_cpu_priority_high": "CPU_HIGH - higher than default priority (backrest must be running as root)", "add_repo_modal_field_cpu_priority_low": "CPU_LOW - lower than default priority", "add_repo_modal_field_cpu_priority_placeholder": "Select a CPU priority", "add_repo_modal_field_auto_unlock": "Auto Unlock", - "add_repo_modal_field_auto_unlock_tooltip": "Auto-unlock will remove lockfiles at the start of forget and prune operations. This is potentially unsafe if the repo is shared by multiple client devices. Disabled by default.", "add_repo_modal_preview_json": "Repo Config as JSON", "add_repo_modal_error_missing_password": "Missing repo password. Either provide a password or set one of the env variables RESTIC_PASSWORD, RESTIC_PASSWORD_COMMAND, RESTIC_PASSWORD_FILE.", "dashboard_error_fetch": "Failed to fetch summary data: ", @@ -353,5 +348,9 @@ "add_plan_modal_retention_policy_keep_regardless_label": "Latest snapshots to keep regardless of age", "add_plan_modal_retention_policy_subhourly_helper": "Schedule runs frequently; keep some recent snapshots.", "op_row_error_path": "Path", - "op_row_error_message": "Message" + "op_row_error_message": "Message", + "add_repo_modal_env_vars_placeholder": "KEY=VALUE (One per line)", + "app_menu_edit_plan": "app_menu_edit_plan", + "add_repo_modal_field_auto_unlock_tooltip": "Auto-unlock will remove lockfiles at the start of most operations. This is potentially unsafe if the repo is shared by multiple client devices, otherwise is recommended for single-client setups.", + "op_row_cancel_op": "Cancel Operation" } diff --git a/webui/messages/es.json b/webui/messages/es.json index e34eebbf..fcdb3705 100644 --- a/webui/messages/es.json +++ b/webui/messages/es.json @@ -222,7 +222,6 @@ "add_repo_modal_field_cpu_priority_low": "CPU_LOW - prioridad inferior a la predeterminada", "add_repo_modal_field_cpu_priority_placeholder": "Seleccionar una prioridad de CPU", "add_repo_modal_field_auto_unlock": "Desbloqueo automático", - "add_repo_modal_field_auto_unlock_tooltip": "El desbloqueo automático eliminará los archivos de bloqueo al iniciar las operaciones de olvido y poda. Esto puede ser peligroso si el repositorio se comparte entre varios dispositivos cliente. Está deshabilitado por defecto.", "add_repo_modal_preview_json": "Configuración del repositorio como JSON", "add_repo_modal_error_missing_password": "Falta la contraseña del repositorio. Proporcione una contraseña o configure una de las variables de entorno RESTIC_PASSWORD, RESTIC_PASSWORD_COMMAND o RESTIC_PASSWORD_FILE.", "dashboard_error_fetch": "No se pudieron obtener los datos de resumen: ", @@ -294,10 +293,6 @@ "op_row_cancel_error": "No se pudo cancelar la operación: ", "op_row_logs_title": "Registros de la operación {name} a las {time}", "op_row_view_logs": "[Ver registros]", - "op_row_confirm_cancel": "[¿Confirmar Cancelar?]", - "op_row_cancel_op": "[Cancelar operación]", - "op_row_confirm_delete": "[¿Confirmar eliminación?]", - "op_row_delete": "[Borrar]", "op_row_backup_details": "Detalles de la copia de seguridad", "op_row_item_errors": "Errores de artículos", "op_row_error_on_item": "Error en el artículo: {item}", diff --git a/webui/messages/fr.json b/webui/messages/fr.json index f83bf9b7..732a1139 100644 --- a/webui/messages/fr.json +++ b/webui/messages/fr.json @@ -222,7 +222,6 @@ "add_repo_modal_field_cpu_priority_low": "CPU_LOW - priorité inférieure à la priorité par défaut", "add_repo_modal_field_cpu_priority_placeholder": "Sélectionnez une priorité du processeur", "add_repo_modal_field_auto_unlock": "Déverrouillage automatique", - "add_repo_modal_field_auto_unlock_tooltip": "Le déverrouillage automatique supprime les fichiers de verrouillage au début des opérations d'oubli et d'élagage. Cette opération peut s'avérer dangereuse si le dépôt est partagé par plusieurs appareils clients. Elle est désactivée par défaut.", "add_repo_modal_preview_json": "Configuration du dépôt au format JSON", "add_repo_modal_error_missing_password": "Mot de passe du dépôt manquant. Veuillez fournir un mot de passe ou définir l'une des variables d'environnement RESTIC_PASSWORD, RESTIC_PASSWORD_COMMAND ou RESTIC_PASSWORD_FILE.", "dashboard_error_fetch": "Impossible de récupérer les données récapitulatives : ", @@ -294,10 +293,6 @@ "op_row_cancel_error": "L'annulation de l'opération a échoué : ", "op_row_logs_title": "Journaux pour l'opération {name} à {time}", "op_row_view_logs": "[Afficher les journaux]", - "op_row_confirm_cancel": "[Confirmer Annuler ?]", - "op_row_cancel_op": "[Annuler l'opération]", - "op_row_confirm_delete": "[Confirmer la suppression ?]", - "op_row_delete": "[Supprimer]", "op_row_backup_details": "Détails de la sauvegarde", "op_row_item_errors": "Erreurs liées aux articles", "op_row_error_on_item": "Erreur sur l'élément : {item}", diff --git a/webui/messages/hi.json b/webui/messages/hi.json index 4c7e1b79..c73e7df6 100644 --- a/webui/messages/hi.json +++ b/webui/messages/hi.json @@ -222,7 +222,6 @@ "add_repo_modal_field_cpu_priority_low": "CPU_LOW - डिफ़ॉल्ट प्राथमिकता से कम", "add_repo_modal_field_cpu_priority_placeholder": "सीपीयू प्राथमिकता का चयन करें", "add_repo_modal_field_auto_unlock": "ऑटो अनलॉक", - "add_repo_modal_field_auto_unlock_tooltip": "ऑटो-अनलॉक, फॉरगेट और प्रून ऑपरेशन शुरू होने पर लॉकफाइल हटा देगा। यदि रिपॉजिटरी कई क्लाइंट डिवाइसों द्वारा साझा की जाती है, तो यह संभावित रूप से असुरक्षित हो सकता है। डिफ़ॉल्ट रूप से अक्षम।", "add_repo_modal_preview_json": "JSON फॉर्मेट में रेपो कॉन्फ़िगरेशन", "add_repo_modal_error_missing_password": "रिपॉजिटरी पासवर्ड गायब है। कृपया पासवर्ड प्रदान करें या RESTIC_PASSWORD, RESTIC_PASSWORD_COMMAND या RESTIC_PASSWORD_FILE में से किसी एक एनवायरनमेंट वेरिएबल को सेट करें।", "dashboard_error_fetch": "सारांश डेटा प्राप्त करने में विफल: ", @@ -294,10 +293,6 @@ "op_row_cancel_error": "ऑपरेशन रद्द करने में विफल: ", "op_row_logs_title": "ऑपरेशन {name} के लॉग {time}", "op_row_view_logs": "[लॉग देखें]", - "op_row_confirm_cancel": "[रद्द करने की पुष्टि करें?]", - "op_row_cancel_op": "[संचालन रद्द करें]", - "op_row_confirm_delete": "[डिलीट की पुष्टि करें?]", - "op_row_delete": "[मिटाना]", "op_row_backup_details": "बैकअप विवरण", "op_row_item_errors": "वस्तु त्रुटियाँ", "op_row_error_on_item": "आइटम पर त्रुटि: {item}", diff --git a/webui/messages/id.json b/webui/messages/id.json index 67419eba..9f6fe6fc 100644 --- a/webui/messages/id.json +++ b/webui/messages/id.json @@ -222,7 +222,6 @@ "add_repo_modal_field_cpu_priority_low": "CPU_LOW - prioritas lebih rendah dari prioritas default", "add_repo_modal_field_cpu_priority_placeholder": "Pilih prioritas CPU", "add_repo_modal_field_auto_unlock": "Buka Kunci Otomatis", - "add_repo_modal_field_auto_unlock_tooltip": "Fitur buka kunci otomatis akan menghapus file kunci di awal operasi lupakan dan pangkas. Ini berpotensi tidak aman jika repositori digunakan bersama oleh beberapa perangkat klien. Dinonaktifkan secara default.", "add_repo_modal_preview_json": "Konfigurasi Repositori sebagai JSON", "add_repo_modal_error_missing_password": "Kata sandi repositori hilang. Berikan kata sandi atau atur salah satu variabel lingkungan RESTIC_PASSWORD, RESTIC_PASSWORD_COMMAND, RESTIC_PASSWORD_FILE.", "dashboard_error_fetch": "Gagal mengambil data ringkasan: ", @@ -294,10 +293,6 @@ "op_row_cancel_error": "Pembatalan operasi gagal: ", "op_row_logs_title": "Log untuk operasi {name} pada {time}", "op_row_view_logs": "[Lihat Log]", - "op_row_confirm_cancel": "[Konfirmasi Batal?]", - "op_row_cancel_op": "[Batalkan Operasi]", - "op_row_confirm_delete": "[Konfirmasi Penghapusan?]", - "op_row_delete": "[Menghapus]", "op_row_backup_details": "Detail Cadangan", "op_row_item_errors": "Kesalahan Item", "op_row_error_on_item": "Kesalahan pada item: {item}", diff --git a/webui/messages/it.json b/webui/messages/it.json index c9ed8899..cd4689de 100644 --- a/webui/messages/it.json +++ b/webui/messages/it.json @@ -222,7 +222,6 @@ "add_repo_modal_field_cpu_priority_low": "CPU_LOW - priorità inferiore a quella predefinita", "add_repo_modal_field_cpu_priority_placeholder": "Seleziona una priorità della CPU", "add_repo_modal_field_auto_unlock": "Sblocco automatico", - "add_repo_modal_field_auto_unlock_tooltip": "Lo sblocco automatico rimuoverà i file di blocco all'inizio delle operazioni di forget e prune. Questa opzione è potenzialmente pericolosa se il repository è condiviso da più dispositivi client. Disabilitata per impostazione predefinita.", "add_repo_modal_preview_json": "Configurazione del repository come JSON", "add_repo_modal_error_missing_password": "Password del repository mancante. Fornire una password o impostare una delle variabili di ambiente RESTIC_PASSWORD, RESTIC_PASSWORD_COMMAND, RESTIC_PASSWORD_FILE.", "dashboard_error_fetch": "Impossibile recuperare i dati di riepilogo: ", @@ -294,10 +293,6 @@ "op_row_cancel_error": "Impossibile annullare l'operazione: ", "op_row_logs_title": "Registri per l'operazione {name} a {time}", "op_row_view_logs": "[Visualizza registri]", - "op_row_confirm_cancel": "[Confermare l'annullamento?]", - "op_row_cancel_op": "[Annulla operazione]", - "op_row_confirm_delete": "[Confermare l'eliminazione?]", - "op_row_delete": "[Eliminare]", "op_row_backup_details": "Dettagli del backup", "op_row_item_errors": "Errori dell'articolo", "op_row_error_on_item": "Errore sull'elemento: {item}", diff --git a/webui/messages/pt.json b/webui/messages/pt.json index 7dba5828..02df1796 100644 --- a/webui/messages/pt.json +++ b/webui/messages/pt.json @@ -222,7 +222,6 @@ "add_repo_modal_field_cpu_priority_low": "CPU_LOW - prioridade inferior à padrão", "add_repo_modal_field_cpu_priority_placeholder": "Selecione uma prioridade de CPU", "add_repo_modal_field_auto_unlock": "Desbloqueio automático", - "add_repo_modal_field_auto_unlock_tooltip": "O desbloqueio automático removerá os arquivos de bloqueio no início das operações de esquecimento e limpeza. Isso pode ser inseguro se o repositório for compartilhado por vários dispositivos clientes. Desativado por padrão.", "add_repo_modal_preview_json": "Configuração do repositório em formato JSON", "add_repo_modal_error_missing_password": "Senha do repositório ausente. Forneça uma senha ou defina uma das variáveis de ambiente RESTIC_PASSWORD, RESTIC_PASSWORD_COMMAND ou RESTIC_PASSWORD_FILE.", "dashboard_error_fetch": "Falha ao obter os dados resumidos: ", @@ -294,10 +293,6 @@ "op_row_cancel_error": "Falha ao cancelar a operação: ", "op_row_logs_title": "Registros da operação {name} em {time}", "op_row_view_logs": "[Ver registos]", - "op_row_confirm_cancel": "[Confirmar Cancelar?]", - "op_row_cancel_op": "[Cancelar operação]", - "op_row_confirm_delete": "[Confirmar exclusão?]", - "op_row_delete": "[Excluir]", "op_row_backup_details": "Detalhes do backup", "op_row_item_errors": "Erros no item", "op_row_error_on_item": "Erro no item: {item}", diff --git a/webui/messages/ru.json b/webui/messages/ru.json index 489b9c34..e451fc2f 100644 --- a/webui/messages/ru.json +++ b/webui/messages/ru.json @@ -222,7 +222,6 @@ "add_repo_modal_field_cpu_priority_low": "CPU_LOW - более низкий приоритет, чем по умолчанию", "add_repo_modal_field_cpu_priority_placeholder": "Выберите приоритет ЦП", "add_repo_modal_field_auto_unlock": "Автоматическая разблокировка", - "add_repo_modal_field_auto_unlock_tooltip": "Функция автоматической разблокировки удалит файлы блокировки в начале операций удаления и очистки. Это потенциально небезопасно, если репозиторий используется несколькими клиентскими устройствами. По умолчанию отключено.", "add_repo_modal_preview_json": "Конфигурация репозитория в формате JSON", "add_repo_modal_error_missing_password": "Отсутствует пароль репозитория. Укажите пароль или задайте одну из переменных окружения RESTIC_PASSWORD, RESTIC_PASSWORD_COMMAND, RESTIC_PASSWORD_FILE.", "dashboard_error_fetch": "Не удалось получить сводные данные: ", @@ -294,10 +293,6 @@ "op_row_cancel_error": "Операция не была отменена: ", "op_row_logs_title": "Журналы операции {name} в {time}", "op_row_view_logs": "[Просмотреть журналы]", - "op_row_confirm_cancel": "[Подтвердить отмену?]", - "op_row_cancel_op": "[Отменить операцию]", - "op_row_confirm_delete": "[Подтвердить удаление?]", - "op_row_delete": "[Удалить]", "op_row_backup_details": "Сведения о резервном копировании", "op_row_item_errors": "Ошибки в элементах", "op_row_error_on_item": "Ошибка в элементе: {item}", diff --git a/webui/messages/zh.json b/webui/messages/zh.json index 34c42fd9..f8ee56a1 100644 --- a/webui/messages/zh.json +++ b/webui/messages/zh.json @@ -222,7 +222,6 @@ "add_repo_modal_field_cpu_priority_low": "CPU_LOW - 优先级低于默认优先级", "add_repo_modal_field_cpu_priority_placeholder": "选择 CPU 优先级", "add_repo_modal_field_auto_unlock": "自动解锁", - "add_repo_modal_field_auto_unlock_tooltip": "自动解锁功能会在执行遗忘和清理操作时移除锁定文件。如果多个客户端设备共享同一个仓库,这可能存在安全隐患。此功能默认禁用。", "add_repo_modal_preview_json": "仓库配置(JSON 格式)", "add_repo_modal_error_missing_password": "缺少仓库密码。请提供密码或设置环境变量 RESTIC_PASSWORD、RESTIC_PASSWORD_COMMAND 或 RESTIC_PASSWORD_FILE 之一。", "dashboard_error_fetch": "获取摘要数据失败: ", @@ -294,10 +293,6 @@ "op_row_cancel_error": "取消操作失败: ", "op_row_logs_title": "操作{name}在{time}", "op_row_view_logs": "[查看日志]", - "op_row_confirm_cancel": "[确认取消?]", - "op_row_cancel_op": "【取消操作】", - "op_row_confirm_delete": "[确认删除?]", - "op_row_delete": "[删除]", "op_row_backup_details": "备份详情", "op_row_item_errors": "项目错误", "op_row_error_on_item": "错误项: {item}", @@ -363,5 +358,12 @@ "add_plan_modal_retention_policy_daily_label": "日常的", "add_plan_modal_retention_policy_hourly_label": "按小时", "add_plan_modal_retention_policy_keep_regardless_label": "无论年龄大小,都要保留最新的快照。", - "add_plan_modal_retention_policy_subhourly_helper": "计划程序会频繁运行;请保留一些最近的快照。" + "add_plan_modal_retention_policy_subhourly_helper": "计划程序会频繁运行;请保留一些最近的快照。", + "add_repo_modal_env_vars_placeholder": "键=值(每行一个)", + "app_menu_edit_plan": "app_menu_edit_plan", + "add_repo_modal_field_auto_unlock_tooltip": "自动解锁功能会在大多数操作开始时移除锁定文件。如果多个客户端设备共享同一个仓库,这可能存在安全隐患;否则,建议在单客户端环境下启用此功能。", + "op_row_cancel_op": "取消操作", + "op_row_confirm_cancel": "确认取消?", + "op_row_delete": "删除操作", + "op_row_confirm_delete": "确认删除?" } diff --git a/webui/pnpm-lock.yaml b/webui/pnpm-lock.yaml index 2f58f4b8..99cea6af 100644 --- a/webui/pnpm-lock.yaml +++ b/webui/pnpm-lock.yaml @@ -7,9 +7,6 @@ settings: importers: .: dependencies: - '@ant-design/icons': - specifier: ^5.6.1 - version: 5.6.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@bufbuild/protobuf': specifier: ^2.5.2 version: 2.5.2 @@ -22,15 +19,24 @@ importers: '@connectrpc/connect-web': specifier: ^2.0.2 version: 2.0.2(@bufbuild/protobuf@2.5.2)(@connectrpc/connect@2.0.2(@bufbuild/protobuf@2.5.2)) + '@dnd-kit/core': + specifier: ^6.3.1 + version: 6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@dnd-kit/sortable': + specifier: ^10.0.0 + version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) + '@dnd-kit/utilities': + specifier: ^3.2.2 + version: 3.2.2(react@18.3.1) '@emotion/react': specifier: ^11.14.0 version: 11.14.0(@types/react@18.3.23)(react@18.3.1) - antd: - specifier: ^5.26.1 - version: 5.26.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) buffer: specifier: ^6.0.3 version: 6.0.3 + cronstrue: + specifier: ^3.9.0 + version: 3.9.0 cross-env: specifier: ^7.0.3 version: 7.0.3 @@ -95,6 +101,9 @@ importers: '@vitejs/plugin-react': specifier: ^5.1.2 version: 5.1.2(vite@7.3.0(@types/node@20.19.1)(lightningcss@1.30.1)(sass@1.97.1)) + prettier: + specifier: ^3.7.4 + version: 3.7.4 rc-collapse: specifier: ^3.9.0 version: 3.9.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -365,6 +374,40 @@ packages: peerDependencies: '@bufbuild/protobuf': ^2.2.0 + '@dnd-kit/accessibility@3.1.1': + resolution: + { + integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==, + } + peerDependencies: + react: '>=16.8.0' + + '@dnd-kit/core@6.3.1': + resolution: + { + integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==, + } + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@dnd-kit/sortable@10.0.0': + resolution: + { + integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==, + } + peerDependencies: + '@dnd-kit/core': ^6.3.0 + react: '>=16.8.0' + + '@dnd-kit/utilities@3.2.2': + resolution: + { + integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==, + } + peerDependencies: + react: '>=16.8.0' + '@emotion/babel-plugin@11.13.5': resolution: { @@ -2031,6 +2074,13 @@ packages: } engines: { node: '>=10' } + cronstrue@3.9.0: + resolution: + { + integrity: sha512-T3S35zmD0Ai2B4ko6+mEM+k9C6tipe2nB9RLiGT6QL2Wn0Vsn2cCZAC8Oeuf4CaE00GZWVdpYitbpWCNlIWqdA==, + } + hasBin: true + cross-env@7.0.3: resolution: { @@ -2853,6 +2903,14 @@ packages: } engines: { node: ^10 || ^12 || >=14 } + prettier@3.7.4: + resolution: + { + integrity: sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==, + } + engines: { node: '>=14' } + hasBin: true + process@0.11.10: resolution: { @@ -4026,6 +4084,31 @@ snapshots: dependencies: '@bufbuild/protobuf': 2.5.2 + '@dnd-kit/accessibility@3.1.1(react@18.3.1)': + dependencies: + react: 18.3.1 + tslib: 2.8.1 + + '@dnd-kit/core@6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@dnd-kit/accessibility': 3.1.1(react@18.3.1) + '@dnd-kit/utilities': 3.2.2(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + tslib: 2.8.1 + + '@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)': + dependencies: + '@dnd-kit/core': 6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@dnd-kit/utilities': 3.2.2(react@18.3.1) + react: 18.3.1 + tslib: 2.8.1 + + '@dnd-kit/utilities@3.2.2(react@18.3.1)': + dependencies: + react: 18.3.1 + tslib: 2.8.1 + '@emotion/babel-plugin@11.13.5': dependencies: '@babel/helper-module-imports': 7.27.1 @@ -5270,6 +5353,8 @@ snapshots: path-type: 4.0.0 yaml: 1.10.2 + cronstrue@3.9.0: {} + cross-env@7.0.3: dependencies: cross-spawn: 7.0.6 @@ -5673,6 +5758,8 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + prettier@3.7.4: {} + process@0.11.10: {} prop-types@15.8.1: diff --git a/webui/src/app/App.tsx b/webui/src/app/App.tsx index 4628885b..1f99efca 100644 --- a/webui/src/app/App.tsx +++ b/webui/src/app/App.tsx @@ -10,6 +10,7 @@ import { FiRadio, FiActivity, // Added as a placeholder/guess for ActivityBar if needed, or stick to component FiServer, + FiEdit2, } from "react-icons/fi"; import { @@ -20,7 +21,9 @@ import { Text, Spinner, Separator, + IconButton, } from "@chakra-ui/react"; +import { Tooltip } from "../components/ui/tooltip"; import { keyframes } from "@emotion/react"; import { @@ -256,7 +259,7 @@ const Sidebar = () => { pr={2} py={1} _hover={{ bg: "bg.subtle" }} - role="group" + className="group" borderRadius="md" mx={2} > @@ -276,18 +279,18 @@ const Sidebar = () => { _groupHover={{ opacity: 1 }} transition="opacity 0.2s" > - + + ); @@ -328,7 +331,7 @@ const Sidebar = () => { pr={2} py={1} _hover={{ bg: "bg.subtle" }} - role="group" + className="group" borderRadius="md" mx={2} > @@ -353,18 +356,18 @@ const Sidebar = () => { _groupHover={{ opacity: 1 }} transition="opacity 0.2s" > - + + ); diff --git a/webui/src/components/common/Alerts.tsx b/webui/src/components/common/Alerts.tsx index ac476a34..88276864 100644 --- a/webui/src/components/common/Alerts.tsx +++ b/webui/src/components/common/Alerts.tsx @@ -56,5 +56,8 @@ export const formatErrorAlert = (error: any, prefix?: string) => { ); } - return `${prefix}: ${contents}`; + if (prefix.indexOf(":") === -1) { + prefix += ":"; + } + return `${prefix} ${contents}`; }; diff --git a/webui/src/components/common/EnumSelector.tsx b/webui/src/components/common/EnumSelector.tsx new file mode 100644 index 00000000..6a6a082b --- /dev/null +++ b/webui/src/components/common/EnumSelector.tsx @@ -0,0 +1,81 @@ +import React from "react"; +import { + SelectRoot, + SelectTrigger, + SelectValueText, + SelectContent, + SelectItem, + SelectHiddenSelect, +} from "../ui/select"; +import { Box, Text, createListCollection } from "@chakra-ui/react"; + +export interface EnumOption { + value: T; + label?: string; + description?: string; +} + +export interface EnumSelectorProps { + value: T | T[]; + onChange: (value: T | T[]) => void; + options: EnumOption[]; + multiSelect?: boolean; + placeholder?: string; + size?: "xs" | "sm" | "md" | "lg"; +} + +export const EnumSelector = ({ + value, + onChange, + options, + multiSelect = false, + placeholder = "Select...", + size = "sm", +}: EnumSelectorProps) => { + const collection = createListCollection({ + items: options.map((opt) => ({ + label: opt.label || opt.value, + value: opt.value, + description: opt.description, + })), + }); + + const handleValueChange = (e: { value: string[] }) => { + if (multiSelect) { + onChange(e.value as T[]); + } else { + onChange(e.value[0] as T); + } + }; + + const currentValue = Array.isArray(value) ? value : value ? [value] : []; + + return ( + + + + + + + {collection.items.map((item: any) => ( + + + {item.label} + {item.description && ( + + - {item.description} + + )} + + + ))} + + + ); +}; diff --git a/webui/src/components/common/HooksFormList.tsx b/webui/src/components/common/HooksFormList.tsx index d1a26572..84348ed1 100644 --- a/webui/src/components/common/HooksFormList.tsx +++ b/webui/src/components/common/HooksFormList.tsx @@ -19,23 +19,15 @@ import { } from "@chakra-ui/react"; import { FiPlus, FiTrash2, FiInfo } from "react-icons/fi"; import { - SelectRoot, - SelectTrigger, - SelectValueText, - SelectContent, - SelectItem, - SelectHiddenSelect, -} from "../ui/select"; -import { + MenuTrigger, MenuContent, MenuItem, MenuItemText, MenuRoot, - MenuTrigger, } from "../ui/menu"; import { Tooltip } from "../ui/tooltip"; -import { createListCollection } from "@chakra-ui/react"; import { Link } from "../ui/link"; +import { EnumSelector, EnumOption } from "./EnumSelector"; export interface HookFields { conditions: string[]; @@ -98,20 +90,20 @@ const hookConditionDescriptions: Record = { CONDITION_ANY_ERROR: "Triggered when any operation fails", }; -const conditionCollection = createListCollection({ - items: Hook_ConditionSchema.values.map((v) => ({ +const conditionOptions: EnumOption[] = Hook_ConditionSchema.values.map( + (v) => ({ label: v.name, value: v.name, description: hookConditionDescriptions[v.name], - })), -}); + }), +); -const onErrorCollection = createListCollection({ - items: Hook_OnErrorSchema.values.map((v) => ({ +const onErrorOptions: EnumOption[] = Hook_OnErrorSchema.values.map( + (v) => ({ label: v.name, value: v.name, - })), -}); + }), +); interface HooksFormListProps { value?: HookFields[]; @@ -205,8 +197,11 @@ const HookItem = ({ const typeName = findHookTypeName(hook); // @ts-ignore - const handleConditionChange = (details: { value: string[] }) => { - onChange({ ...hook, conditions: details.value }); + const handleConditionChange = (value: string | string[]) => { + onChange({ + ...hook, + conditions: Array.isArray(value) ? value : [value], + }); }; return ( @@ -229,41 +224,16 @@ const HookItem = ({ - - {/* @ts-ignore */} - - - - {hook.conditions && hook.conditions.length > 0 ? ( - hook.conditions.join(", ") - ) : ( - Runs when... - )} - - - - {conditionCollection.items.map((item: any) => ( - // @ts-ignore - - - {item.label} - {item.description && ( - - - {item.description} - - )} - - - ))} - - + + + @@ -441,48 +411,23 @@ const hookTypes: { onChange={(e) => updateGotify("template", e.target.value)} size="sm" /> - - updateGotify("priority", parseInt(e.value[0])) + + updateGotify("priority", parseInt(val as string)) } + placeholder="Priority" size="sm" - > - {/* @ts-ignore */} - - - {/* @ts-ignore */} - - - - {[ - { label: "0 - No notification", value: "0" }, - { label: "1 - Icon in notification bar", value: "1" }, - { label: "4 - Icon in notification bar + Sound", value: "4" }, - { - label: "8 - Icon in notification bar + Sound + Vibration", - value: "8", - }, - ].map((item) => ( - // @ts-ignore - - {item.label} - - ))} - - + /> ); }, @@ -675,28 +620,13 @@ const ItemOnErrorSelector = ({ - onChange({ ...hook, onError: e.value[0] })} + onChange({ ...hook, onError: val as string })} + placeholder="Error behavior..." size="sm" - > - {/* @ts-ignore */} - - - {/* @ts-ignore */} - - - - {onErrorCollection.items.map((item: any) => ( - // @ts-ignore - - {item.label} - - ))} - - + /> ); }; diff --git a/webui/src/components/common/ScheduleFormItem.tsx b/webui/src/components/common/ScheduleFormItem.tsx index db5389ff..27b1b070 100644 --- a/webui/src/components/common/ScheduleFormItem.tsx +++ b/webui/src/components/common/ScheduleFormItem.tsx @@ -1,11 +1,4 @@ -import { - Flex, - Stack, - Input, - Text, - Card, - Button, -} from "@chakra-ui/react"; +import { Flex, Stack, Input, Text, Card, Button } from "@chakra-ui/react"; import { Checkbox } from "../ui/checkbox"; import { Radio, RadioGroup } from "../ui/radio"; import { NumberInputField } from "./NumberInput"; // Assuming I have this wrapper or standard NumberInput @@ -110,7 +103,6 @@ export const ScheduleFormItem = ({ - {[ { value: "disabled", label: "Disabled" }, @@ -206,13 +198,13 @@ export const ScheduleFormItem = ({ )} {mode === "disabled" && ( - Automatic snapshots are disabled for this plan. You can still - run backups manually. + Automatic snapshots are disabled for this plan. You can still run + backups manually. )} - + ); }; diff --git a/webui/src/features/auth/LoginModal.tsx b/webui/src/features/auth/LoginModal.tsx index 84ddaa43..60d66769 100644 --- a/webui/src/features/auth/LoginModal.tsx +++ b/webui/src/features/auth/LoginModal.tsx @@ -36,12 +36,12 @@ export const LoginModal = () => { try { const loginResponse = await authenticationService.login(loginReq); setAuthToken(loginResponse.token); - alerts.success(m.login_success(), 5); + alerts.success(m.login_success()); setTimeout(() => { window.location.reload(); }, 500); } catch (e: any) { - alerts.error(m.login_error() + (e.message ? e.message : "" + e), 10); + alerts.error(m.login_error() + (e.message ? e.message : "" + e)); setLoading(false); } }; diff --git a/webui/src/features/operations/OperationRow.tsx b/webui/src/features/operations/OperationRow.tsx index f15339f6..3a75be68 100644 --- a/webui/src/features/operations/OperationRow.tsx +++ b/webui/src/features/operations/OperationRow.tsx @@ -216,7 +216,7 @@ export const OperationRow = ({ confirmText={m.op_row_confirm_cancel()} color="fg.error" > - {m.op_row_cancel_op()} + {m.op_row_cancel_op()} , ); } else if (showDelete) { @@ -271,7 +271,7 @@ export const OperationRow = ({ {e.item} - {e.message || "Unknown error"} + {e.message || e.item} ))} diff --git a/webui/src/features/plans/AddPlanModal.tsx b/webui/src/features/plans/AddPlanModal.tsx index eb6a0067..5cd346b6 100644 --- a/webui/src/features/plans/AddPlanModal.tsx +++ b/webui/src/features/plans/AddPlanModal.tsx @@ -36,11 +36,7 @@ import { type RetentionPolicy, type Schedule, } from "../../../gen/ts/v1/config_pb"; -import { - FiPlus as Plus, - FiMinus as Minus, - FiMenu, -} from "react-icons/fi"; +import { FiPlus as Plus, FiMinus as Minus, FiMenu } from "react-icons/fi"; import { BsCalculator as Calculator } from "react-icons/bs"; import { alerts, formatErrorAlert } from "../../components/common/Alerts"; import { namePattern } from "../../lib/util"; @@ -172,11 +168,10 @@ export const AddPlanModal = ({ template }: { template: Plan | null }) => { configCopy.plans.splice(idx, 1); setConfig(await backrestService.setConfig(configCopy)); showModal(null); - alerts.success(m.add_plan_modal_success_plan_deleted(), 30); + alerts.success(m.add_plan_modal_success_plan_deleted()); } catch (e: any) { alerts.error( formatErrorAlert(e, m.add_plan_modal_error_destroy_prefix()), - 15, ); } finally { setConfirmLoading(false); @@ -186,7 +181,45 @@ export const AddPlanModal = ({ template }: { template: Plan | null }) => { const handleOk = async () => { setConfirmLoading(true); try { - // TODO: Validation (manual or schema based) + // Validation + if (!formData.id?.trim()) { + throw new Error(m.add_plan_modal_validation_plan_name_required()); + } + if (!namePattern.test(formData.id)) { + throw new Error(m.add_plan_modal_validation_plan_name_pattern()); + } + if (!template && config.plans.find((p) => p.id === formData.id)) { + throw new Error(m.add_plan_modal_validation_plan_exists()); + } + if (!formData.repo) { + throw new Error(m.add_plan_modal_validation_repository_required()); + } + if (!formData.paths || formData.paths.length === 0) { + throw new Error(m.add_plan_modal_validation_paths_required()); + } + if ( + formData.backup_flags && + formData.backup_flags.some((f: string) => !/^\-\-?.*$/.test(f)) + ) { + throw new Error(m.add_plan_modal_validation_flag_pattern()); + } + + // Check retention for sub-hourly schedules + const scheduleValue = formData.schedule?.schedule?.value; + const isCron = formData.schedule?.schedule?.case === "cron"; + const isSubHourly = + isCron && scheduleValue && !/^\d+ /.test(scheduleValue); + + if ( + isSubHourly && + formData.retention?.policyTimeBucketed && + !(formData.retention.policyTimeBucketed.keepLastN > 1) + ) { + throw new Error( + "Your schedule runs more than once per hour; please specify a 'Latest (Count)' greater than 1 in Retention Policy.", + ); + } + const plan = fromJson(PlanSchema, formData, { ignoreUnknownFields: true, }); @@ -218,7 +251,6 @@ export const AddPlanModal = ({ template }: { template: Plan | null }) => { } catch (e: any) { alerts.error( formatErrorAlert(e, m.add_plan_modal_error_operation_prefix()), - 15, ); } finally { setConfirmLoading(false); @@ -287,6 +319,17 @@ export const AddPlanModal = ({ template }: { template: Plan | null }) => { label={m.add_plan_modal_field_plan_name()} helperText={m.add_plan_modal_field_plan_name_tooltip()} required + invalid={ + !!formData.id && + (!namePattern.test(formData.id) || + (!template && + !!config.plans.find((p) => p.id === formData.id))) + } + errorText={ + !!formData.id && !namePattern.test(formData.id) + ? m.add_plan_modal_validation_plan_name_pattern() + : m.add_plan_modal_validation_plan_exists() + } > { label={m.add_plan_modal_field_repository()} helperText={m.add_plan_modal_field_repository_tooltip()} required + invalid={!getField(["repo"]) && confirmLoading} > { {/* Retention Policy */}
- updateField(["retention"], v)} - /> + updateField(["retention"], v)} + />
{/* Advanced */} @@ -416,23 +460,25 @@ export const AddPlanModal = ({ template }: { template: Plan | null }) => { - updateField(["backup_flags"], items)} - tooltip={m.add_plan_modal_field_backup_flags_tooltip()} - placeholder="--flag" - /> + + updateField(["backup_flags"], items) + } + tooltip={m.add_plan_modal_field_backup_flags_tooltip()} + placeholder="--flag" + /> - - updateField(["hooks"], v)} - /> - + + updateField(["hooks"], v)} + /> + @@ -460,15 +506,22 @@ export const AddPlanModal = ({ template }: { template: Plan | null }) => { - ); }; -const Section = ({ title, children }: { title: string; children: React.ReactNode }) => ( +const Section = ({ + title, + children, +}: { + title: string; + children: React.ReactNode; +}) => ( - {title} + + {title} + {children} ); @@ -658,4 +711,3 @@ const RetentionPolicyView = ({ schedule, retention, onChange }: any) => { ); }; - diff --git a/webui/src/features/plans/PlanView.tsx b/webui/src/features/plans/PlanView.tsx index ccc8b9f4..992f3889 100644 --- a/webui/src/features/plans/PlanView.tsx +++ b/webui/src/features/plans/PlanView.tsx @@ -1,7 +1,8 @@ import React, { useEffect, useState } from "react"; import { Plan } from "../../../gen/ts/v1/config_pb"; import { Button } from "../../components/ui/button"; -import { Flex, Heading, Text, Box } from "@chakra-ui/react"; +import { Flex, Heading, Text, Box, Group, IconButton } from "@chakra-ui/react"; +import { FiChevronDown } from "react-icons/fi"; import { Tabs, TabsList, @@ -9,6 +10,12 @@ import { TabsContent, TabsRoot, } from "../../components/ui/tabs"; +import { + MenuContent, + MenuItem, + MenuRoot, + MenuTrigger, +} from "../../components/ui/menu"; import { Tooltip } from "../../components/ui/tooltip"; import { alerts } from "../../components/common/Alerts"; import { MAX_OPERATION_HISTORY } from "../../constants"; @@ -87,35 +94,42 @@ export const PlanView = ({ plan }: React.PropsWithChildren<{ plan: Plan }>) => { {plan.id} + - - {m.plan_button_backup()} - - - - - - - - - {m.repo_button_unlock()} + + + {m.plan_button_backup()} - - - - - {m.plan_button_clear_history()} - - + + + + + + + + { + const { RunCommandModal } = + await import("../operations/RunCommandModal"); + showModal(); + }} + > + {m.repo_button_run_command()} + + + {m.repo_button_unlock()} + + + {m.plan_button_clear_history()} + + + + diff --git a/webui/src/features/repositories/AddRepoModal.tsx b/webui/src/features/repositories/AddRepoModal.tsx index fad97750..e4aa353e 100644 --- a/webui/src/features/repositories/AddRepoModal.tsx +++ b/webui/src/features/repositories/AddRepoModal.tsx @@ -1,21 +1,23 @@ import { - Flex, Stack, + Flex, Input, - Textarea, - createListCollection, - SelectContent, - SelectItem, - SelectLabel, - SelectRoot, - SelectTrigger, - SelectValueText, + Card, + Text as CText, + Grid, + Code, + Box, } from "@chakra-ui/react"; +import { EnumSelector, EnumOption } from "../../components/common/EnumSelector"; import { Checkbox } from "../../components/ui/checkbox"; +import { + AccordionItem, + AccordionItemContent, + AccordionItemTrigger, + AccordionRoot, +} from "../../components/ui/accordion"; import React, { useEffect, useState } from "react"; import { useShowModal } from "../../components/common/ModalManager"; -// removed ../state/repo_state import -// valid imports import { CommandPrefix_CPUNiceLevel, CommandPrefix_CPUNiceLevelSchema, @@ -30,12 +32,11 @@ import { URIAutocomplete } from "../../components/common/URIAutocomplete"; import { alerts, formatErrorAlert } from "../../components/common/Alerts"; import { namePattern } from "../../lib/util"; import { backrestService } from "../../api/client"; -// import { HooksFormList } from "../components/HooksFormList"; // TODO: Migrate -import { ConfirmButton } from "../../components/common/SpinButton"; +import { ConfirmButton, SpinButton } from "../../components/common/SpinButton"; import { useConfig } from "../../app/provider"; import { ScheduleFormItem, - ScheduleDefaultsDaily, + ScheduleDefaultsInfrequent, } from "../../components/common/ScheduleFormItem"; import { isWindows } from "../../state/buildcfg"; import { create, fromJson, toJson, JsonValue } from "@bufbuild/protobuf"; @@ -45,13 +46,12 @@ import { Button } from "../../components/ui/button"; import { Field } from "../../components/ui/field"; import { PasswordInput } from "../../components/ui/password-input"; import { Tooltip } from "../../components/ui/tooltip"; -import { toaster } from "../../components/ui/toaster"; import { NumberInputField } from "../../components/common/NumberInput"; - import { HooksFormList, hooksListTooltipText, } from "../../components/common/HooksFormList"; +import { DynamicList } from "../../components/common/DynamicList"; const repoDefaults = create(RepoSchema, { prunePolicy: { @@ -59,7 +59,7 @@ const repoDefaults = create(RepoSchema, { schedule: { schedule: { case: "cron", - value: "0 0 1 * *", + value: "0 0 1 * *", // 1st of the month }, clock: Schedule_Clock.LAST_RUN_TIME, }, @@ -68,7 +68,7 @@ const repoDefaults = create(RepoSchema, { schedule: { schedule: { case: "cron", - value: "0 0 1 * *", + value: "0 0 1 * *", // 1st of the month }, clock: Schedule_Clock.LAST_RUN_TIME, }, @@ -85,19 +85,26 @@ export const AddRepoModal = ({ template }: { template: Repo | null }) => { const [config, setConfig] = useConfig(); // Local state for form fields - // Using a simple object state to mimic the form values const [formData, setFormData] = useState( template ? toJson(RepoSchema, template, { alwaysEmitImplicit: true }) : toJson(RepoSchema, repoDefaults, { alwaysEmitImplicit: true }), ); + useEffect(() => { + setFormData( + template + ? toJson(RepoSchema, template, { alwaysEmitImplicit: true }) + : toJson(RepoSchema, repoDefaults, { alwaysEmitImplicit: true }), + ); + }, [template]); + const updateField = (path: string[], value: any) => { setFormData((prev: any) => { const next = { ...prev }; let curr = next; for (let i = 0; i < path.length - 1; i++) { - if (!curr[path[i]]) curr[path[i]] = {}; + curr[path[i]] = curr[path[i]] ? { ...curr[path[i]] } : {}; curr = curr[path[i]]; } curr[path[path.length - 1]] = value; @@ -116,6 +123,33 @@ export const AddRepoModal = ({ template }: { template: Repo | null }) => { if (!config) return null; + const validateLocal = async () => { + const id = getField(["id"]); + if (!id?.trim()) { + throw new Error(m.add_repo_modal_error_repo_name_required()); + } + if (!namePattern.test(id)) { + throw new Error(m.add_plan_modal_validation_plan_name_pattern()); + } + if (!template && config.repos.find((r) => r.id === id)) { + throw new Error(m.add_repo_modal_error_repo_exists()); + } + + const uri = getField(["uri"]); + if (!uri?.trim()) { + throw new Error(m.add_repo_modal_error_uri_required()); + } + + // Env and Password validation + await envVarSetValidator(formData); + + // Flags validation + const flags = getField(["flags"]); + if (flags && flags.some((f: string) => !/^\-\-?.*$/.test(f))) { + throw new Error(m.add_repo_modal_error_flag_format()); + } + }; + const handleDestroy = async () => { setConfirmLoading(true); try { @@ -134,7 +168,6 @@ export const AddRepoModal = ({ template }: { template: Repo | null }) => { } catch (e: any) { alerts.error( formatErrorAlert(e, m.add_plan_modal_error_destroy_prefix()), - 15, ); } finally { setConfirmLoading(false); @@ -144,7 +177,8 @@ export const AddRepoModal = ({ template }: { template: Repo | null }) => { const handleOk = async () => { setConfirmLoading(true); try { - // Validation logic would go here + await validateLocal(); + const repo = fromJson(RepoSchema, formData, { ignoreUnknownFields: true, }); @@ -159,38 +193,81 @@ export const AddRepoModal = ({ template }: { template: Repo | null }) => { alerts.success(m.add_repo_modal_success_added({ uri: repo.uri })); } - // Trigger generic "check" or "list snapshots" to verify try { await backrestService.listSnapshots({ repoId: repo.id }); } catch (e: any) { alerts.error( formatErrorAlert(e, m.add_repo_modal_error_list_snapshots()), - 10, ); } } catch (e: any) { alerts.error( formatErrorAlert(e, m.add_plan_modal_error_operation_prefix()), - 10, ); } finally { setConfirmLoading(false); } }; - const ioNiceOptions = createListCollection({ - items: CommandPrefix_IONiceLevelSchema.values.map((v) => ({ - label: v.name, - value: v.name, - })), - }); + const handleTest = async () => { + try { + await validateLocal(); + const repo = fromJson(RepoSchema, formData, { + ignoreUnknownFields: true, + }); + const exists = await backrestService.checkRepoExists(repo); + if (exists.value) { + alerts.success( + m.add_repo_modal_test_success_existing({ uri: repo.uri }), + ); + } else { + alerts.success(m.add_repo_modal_test_success_new({ uri: repo.uri })); + } + } catch (e: any) { + alerts.error(formatErrorAlert(e, m.add_repo_modal_test_error())); + } + }; - const cpuNiceOptions = createListCollection({ - items: CommandPrefix_CPUNiceLevelSchema.values.map((v) => ({ - label: v.name, - value: v.name, - })), - }); + const ioNiceOptions: EnumOption[] = [ + { + label: "IO_BEST_EFFORT_LOW", + value: "IO_BEST_EFFORT_LOW", + description: m.add_repo_modal_field_io_priority_low(), + }, + { + label: "IO_BEST_EFFORT_HIGH", + value: "IO_BEST_EFFORT_HIGH", + description: m.add_repo_modal_field_io_priority_high(), + }, + { + label: "IO_IDLE", + value: "IO_IDLE", + description: m.add_repo_modal_field_io_priority_idle(), + }, + { + label: "IO_DEFAULT", + value: "IO_DEFAULT", + description: "Default system priority", + }, + ]; + + const cpuNiceOptions: EnumOption[] = [ + { + label: "CPU_DEFAULT", + value: "CPU_DEFAULT", + description: m.add_repo_modal_field_cpu_priority_default(), + }, + { + label: "CPU_HIGH", + value: "CPU_HIGH", + description: m.add_repo_modal_field_cpu_priority_high(), + }, + { + label: "CPU_LOW", + value: "CPU_LOW", + description: m.add_repo_modal_field_cpu_priority_low(), + }, + ]; return ( { {template && ( {m.add_plan_modal_button_delete()} )} + + {m.add_repo_modal_test_config()} + @@ -225,187 +305,467 @@ export const AddRepoModal = ({ template }: { template: Repo | null }) => { } > - - ) => - updateField(["id"], e.target.value) - } - disabled={!!template} - placeholder={"repo" + ((config?.repos?.length || 0) + 1)} - /> - - - - updateField(["uri"], val)} - /> - - - - - ) => - updateField(["password"], e.target.value) - } - disabled={!!template} - flex={1} - /> - {!template && ( - - )} - - - - {/* Env Vars Placeholder */} - -