diff --git a/backend/i18n/frontend_en.json b/backend/i18n/frontend_en.json index f1879a4ea7..ebe07471ce 100644 --- a/backend/i18n/frontend_en.json +++ b/backend/i18n/frontend_en.json @@ -136,38 +136,7 @@ "assets.uploadHint": "Drop file on existing item to replace the asset with a newer version.", "assets.viewReferences": "View all content items referencing this asset.", "assetScripts.reloaded": "Asset Scripts reloaded.", - "backups.backupCountAssetsLabel": "Assets", - "backups.backupCountAssetsTooltip": "Archived assets", - "backups.backupCountEventsLabel": "Events", - "backups.backupCountEventsTooltip": "Archived events", - "backups.backupDownload": "Download", - "backups.backupDownloadLink": "Ready", - "backups.backupDuration": "Duration", - "backups.deleteConfirmText": "Do you really want to delete the backup?", - "backups.deleteConfirmTitle": "Delete backup", - "backups.deleted": "Backup is about to be deleted.", - "backups.deleteFailed": "Failed to delete backup.", - "backups.empty": "No backups created yet.", - "backups.loadFailed": "Failed to load backups.", - "backups.maximumReached": "Your have reached the maximum number of backups: 10.", - "backups.refreshTooltip": "Refresh backups", - "backups.reloaded": "Backups reloaded.", - "backups.restore": "Restore Backup", - "backups.restoreFailed": "Failed to start restore.", - "backups.restoreLastStatus": "Last Restore Operation", - "backups.restoreLastUrl": "Url to backup", - "backups.restoreNewAppName": "Optional app name", - "backups.restorePageTitle": "Restore Backup", - "backups.restoreStarted": "Restore started, it can take several minutes to complete.", - "backups.restoreStartedLabel": "Started", - "backups.restoreStoppedLabel": "Stopped", - "backups.restoreTitle": "Restore Backup", - "backups.start": "Start Backup", - "backups.started": "Backup started, it can take several minutes to complete.", - "backups.startedLabel": "Started", - "backups.startFailed": "Failed to start backup.", "chat.answer": "Here is my answer:", - "chat.answers": "Answers", "chat.answersEmpty": "The ChatBot does not provide an answer or has not been configured yet.", "chat.ask": "Ask", "chat.describeFormat": "Also add the desired format (for example Markdown or HTML) to your prompt, dependending on the editor that you use.", @@ -196,12 +165,6 @@ "clients.connectWizard.cliStep3": "Add your app name the CLI config", "clients.connectWizard.cliStep3Hint": "You can manage configuration to multiple apps in the CLI and switch to an app.", "clients.connectWizard.cliStep4": "Switch to your app in the CLI", - "clients.connectWizard.javascriptSdk": "Use the JavaScript SDK", - "clients.connectWizard.javascriptSdkDocumentation": "Documentations for the JavaScript SDK is available: ", - "clients.connectWizard.javascriptSdkHint": "Install the SDK and establish a connection to this app.", - "clients.connectWizard.javascriptSdkStep1": "Install the Javascript SDK", - "clients.connectWizard.javascriptSdkStep1Download": "The SDK is available on [npm](https://www.npmjs.com/package/@squidex/squidex)", - "clients.connectWizard.javascriptSdkStep2": "Create a client", "clients.connectWizard.manually": "Connect manually", "clients.connectWizard.manuallyHint": "Get instructions how to establish a connection with Postman or curl.", "clients.connectWizard.manuallyStep1": "Get a token using curl", @@ -225,14 +188,10 @@ "clients.revokeFailed": "Failed to revoke client. Please reload.", "clients.tokenFailed": "Failed to create token. Please retry.", "comments.create": "Create a comment", - "comments.createFailed": "Failed to create comment.", "comments.deleteConfirmText": "Do you really want to delete the comment?", "comments.deleteConfirmTitle": "Delete comment", - "comments.deleteFailed": "Failed to delete comment.", "comments.follow": "Follow", - "comments.loadFailed": "Failed to load comments.", "comments.title": "Comments", - "comments.updateFailed": "Failed to update comment.", "common.actions": "Actions", "common.administration": "Administration", "common.administrationPageTitle": "Administration", @@ -259,6 +218,7 @@ "common.close": "Close", "common.cluster": "Cluster", "common.clusterPageTitle": "Cluster", + "common.collapse": "Collapse", "common.comments": "Comments", "common.components": "Components", "common.condition": "Condition", @@ -328,6 +288,8 @@ "common.httpLimit": "You have exceeded the maximum limit of API calls.", "common.id": "Identity", "common.in": "in", + "common.jobs": "Jobs", + "common.jobsBackups": "Jobs & Backups", "common.label": "Label", "common.language": "Language", "common.languages": "Languages", @@ -628,6 +590,32 @@ "features.loadFailed": "Failed to load features. Please reload.", "history.loadFailed": "Failed to load history. Please reload.", "history.title": "Activity", + "jobs.backupFailed": "Failed to start backup.", + "jobs.backupMaximumReached": "Your have reached the maximum number of backups: 10.", + "jobs.backupStart": "Start Backup", + "jobs.deleteConfirmText": "Do you really want to delete the job?", + "jobs.deleteConfirmTitle": "Delete Job", + "jobs.deleted": "Job is about to be deleted.", + "jobs.deleteFailed": "Failed to delete job.", + "jobs.empty": "No jobs created yet.", + "jobs.jobDownload": "Download", + "jobs.jobDownloadLink": "Ready", + "jobs.jobDuration": "Duration", + "jobs.loadFailed": "Failed to load jobs.", + "jobs.refreshTooltip": "Refresh jobs", + "jobs.reloaded": "Jobs reloaded.", + "jobs.restore": "Restore Backup", + "jobs.restoreFailed": "Failed to start restore.", + "jobs.restoreLastStatus": "Last Restore Operation", + "jobs.restoreLastUrl": "Url to backup", + "jobs.restoreNewAppName": "Optional app name", + "jobs.restorePageTitle": "Restore Backup", + "jobs.restoreStarted": "Restore started, it can take several minutes to complete.", + "jobs.restoreStartedLabel": "Started", + "jobs.restoreStoppedLabel": "Stopped", + "jobs.restoreTitle": "Restore Backup", + "jobs.started": "Job started, it can take several minutes to complete.", + "jobs.startedLabel": "Started", "languages.add": "Add Language", "languages.add.description": "Add a new language that you want to support for your content.", "languages.add.title": "Add a new Language", diff --git a/backend/i18n/frontend_fr.json b/backend/i18n/frontend_fr.json index b26b26eed9..7327a6b83d 100644 --- a/backend/i18n/frontend_fr.json +++ b/backend/i18n/frontend_fr.json @@ -136,38 +136,7 @@ "assets.uploadHint": "Déposez le fichier sur un élément existant pour remplacer l'actif par une version plus récente.", "assets.viewReferences": "Afficher tous les éléments de contenu faisant référence à cet élément.", "assetScripts.reloaded": "Scripts d'actif rechargés.", - "backups.backupCountAssetsLabel": "Actifs", - "backups.backupCountAssetsTooltip": "Actifs archivés", - "backups.backupCountEventsLabel": "Événements", - "backups.backupCountEventsTooltip": "Événements archivés", - "backups.backupDownload": "Télécharger", - "backups.backupDownloadLink": "Prêt", - "backups.backupDuration": "Durée", - "backups.deleteConfirmText": "Voulez-vous vraiment supprimer la sauvegarde\u00A0?", - "backups.deleteConfirmTitle": "Supprimer la sauvegarde", - "backups.deleted": "La sauvegarde est sur le point d'être supprimée.", - "backups.deleteFailed": "Échec de la suppression de la sauvegarde.", - "backups.empty": "Aucune sauvegarde n'a encore été créée.", - "backups.loadFailed": "Échec du chargement des sauvegardes.", - "backups.maximumReached": "Vous avez atteint le nombre maximum de sauvegardes\u00A0: 10.", - "backups.refreshTooltip": "Actualiser les sauvegardes", - "backups.reloaded": "Sauvegardes rechargées.", - "backups.restore": "Restaurer la sauvegarde", - "backups.restoreFailed": "Échec du démarrage de la restauration.", - "backups.restoreLastStatus": "Dernière opération de restauration", - "backups.restoreLastUrl": "Url à sauvegarder", - "backups.restoreNewAppName": "Nom d'application facultatif", - "backups.restorePageTitle": "Restaurer la sauvegarde", - "backups.restoreStarted": "La restauration a commencé, cela peut prendre plusieurs minutes.", - "backups.restoreStartedLabel": "Commencé", - "backups.restoreStoppedLabel": "Arrêté", - "backups.restoreTitle": "Restaurer la sauvegarde", - "backups.start": "Démarrer la sauvegarde", - "backups.started": "La sauvegarde a commencé, cela peut prendre plusieurs minutes.", - "backups.startedLabel": "Commencé", - "backups.startFailed": "Échec du démarrage de la sauvegarde.", "chat.answer": "Here is my answer:", - "chat.answers": "Answers", "chat.answersEmpty": "The ChatBot does not provide an answer or has not been configured yet.", "chat.ask": "Ask", "chat.describeFormat": "Also add the desired format (for example Markdown or HTML) to your prompt, dependending on the editor that you use.", @@ -196,12 +165,6 @@ "clients.connectWizard.cliStep3": "Ajoutez le nom de votre application à la configuration CLI", "clients.connectWizard.cliStep3Hint": "Vous pouvez gérer la configuration de plusieurs applications dans l'interface de ligne de commande et basculer vers une application.", "clients.connectWizard.cliStep4": "Basculez vers votre application dans la CLI", - "clients.connectWizard.javascriptSdk": "Use the JavaScript SDK", - "clients.connectWizard.javascriptSdkDocumentation": "Documentations for the JavaScript SDK is available: ", - "clients.connectWizard.javascriptSdkHint": "Install the SDK and establish a connection to this app.", - "clients.connectWizard.javascriptSdkStep1": "Install the Javascript SDK", - "clients.connectWizard.javascriptSdkStep1Download": "The SDK is available on [npm](https://www.npmjs.com/package/@squidex/squidex)", - "clients.connectWizard.javascriptSdkStep2": "Create a client", "clients.connectWizard.manually": "Connectez-vous manuellement", "clients.connectWizard.manuallyHint": "Obtenez des instructions pour établir une connexion avec Postman ou curl.", "clients.connectWizard.manuallyStep1": "Obtenir un jeton en utilisant curl", @@ -225,14 +188,10 @@ "clients.revokeFailed": "Échec de la révocation du client. Veuillez recharger.", "clients.tokenFailed": "Échec de la création du jeton. Veuillez réessayer.", "comments.create": "Créer un commentaire", - "comments.createFailed": "Échec de la création du commentaire.", "comments.deleteConfirmText": "Voulez-vous vraiment supprimer le commentaire\u00A0?", "comments.deleteConfirmTitle": "Supprimer le commentaire", - "comments.deleteFailed": "Impossible de supprimer le commentaire.", "comments.follow": "Suivre", - "comments.loadFailed": "Échec du chargement des commentaires.", "comments.title": "commentaires", - "comments.updateFailed": "Échec de la mise à jour du commentaire.", "common.actions": "Actions", "common.administration": "Administration", "common.administrationPageTitle": "Administration", @@ -259,6 +218,7 @@ "common.close": "Close", "common.cluster": "Grappe", "common.clusterPageTitle": "Grappe", + "common.collapse": "Collapse", "common.comments": "commentaires", "common.components": "Composants", "common.condition": "Condition", @@ -328,6 +288,8 @@ "common.httpLimit": "Vous avez dépassé la limite maximale d'appels d'API.", "common.id": "Identité", "common.in": "dans", + "common.jobs": "Jobs", + "common.jobsBackups": "Jobs & Backups", "common.label": "Étiqueter", "common.language": "Langue", "common.languages": "Langues", @@ -628,6 +590,32 @@ "features.loadFailed": "Échec du chargement des fonctionnalités. Veuillez recharger.", "history.loadFailed": "Échec du chargement de l'historique. Veuillez recharger.", "history.title": "Activité", + "jobs.backupFailed": "Failed to start backup.", + "jobs.backupMaximumReached": "Your have reached the maximum number of backups: 10.", + "jobs.backupStart": "Start Backup", + "jobs.deleteConfirmText": "Do you really want to delete the job?", + "jobs.deleteConfirmTitle": "Delete Job", + "jobs.deleted": "Job is about to be deleted.", + "jobs.deleteFailed": "Failed to delete job.", + "jobs.empty": "No jobs created yet.", + "jobs.jobDownload": "Download", + "jobs.jobDownloadLink": "Ready", + "jobs.jobDuration": "Duration", + "jobs.loadFailed": "Failed to load jobs.", + "jobs.refreshTooltip": "Refresh jobs", + "jobs.reloaded": "Jobs reloaded.", + "jobs.restore": "Restore Backup", + "jobs.restoreFailed": "Failed to start restore.", + "jobs.restoreLastStatus": "Last Restore Operation", + "jobs.restoreLastUrl": "Url to backup", + "jobs.restoreNewAppName": "Optional app name", + "jobs.restorePageTitle": "Restore Backup", + "jobs.restoreStarted": "Restore started, it can take several minutes to complete.", + "jobs.restoreStartedLabel": "Started", + "jobs.restoreStoppedLabel": "Stopped", + "jobs.restoreTitle": "Restore Backup", + "jobs.started": "Job started, it can take several minutes to complete.", + "jobs.startedLabel": "Started", "languages.add": "Ajouter une langue", "languages.add.description": "Ajoutez une nouvelle langue que vous souhaitez prendre en charge pour votre contenu.", "languages.add.title": "Ajouter une nouvelle langue", diff --git a/backend/i18n/frontend_it.json b/backend/i18n/frontend_it.json index 814febc78b..5c1762860a 100644 --- a/backend/i18n/frontend_it.json +++ b/backend/i18n/frontend_it.json @@ -136,38 +136,7 @@ "assets.uploadHint": "Trascina il file sull'elemento esistente per poterlo sostituire con una versione più recente.", "assets.viewReferences": "View all content items referencing this asset.", "assetScripts.reloaded": "Asset Scripts reloaded.", - "backups.backupCountAssetsLabel": "Risorse", - "backups.backupCountAssetsTooltip": "Risorse archiviate", - "backups.backupCountEventsLabel": "Eventi", - "backups.backupCountEventsTooltip": "Eventi archiviati", - "backups.backupDownload": "Scarica", - "backups.backupDownloadLink": "Pronto", - "backups.backupDuration": "Durata", - "backups.deleteConfirmText": "Sei sicuro di voler cancellare il backup?", - "backups.deleteConfirmTitle": "Cancella il backup", - "backups.deleted": "Il backup sta per essere cancellato.", - "backups.deleteFailed": "Non è stato possibile cancellare il backup.", - "backups.empty": "Nessun backup è stato ancora creato.", - "backups.loadFailed": "Non è stato possibile caricare i backup.", - "backups.maximumReached": "Hai raggiunto il numero massimo di backup: 10.", - "backups.refreshTooltip": "Aggiorna i backup", - "backups.reloaded": "Backup aggiornati.", - "backups.restore": "Backup ripristinato", - "backups.restoreFailed": "Non è stato possibile avviare il ripristino.", - "backups.restoreLastStatus": "Ultima operazione di ripristino", - "backups.restoreLastUrl": "Url per il backup", - "backups.restoreNewAppName": "Nome dell'app opzionale", - "backups.restorePageTitle": "Ripristinare il Backup", - "backups.restoreStarted": "Ripristino avviato, il suo completamento potrebbe richiedere alcuni minuti.", - "backups.restoreStartedLabel": "Avviato", - "backups.restoreStoppedLabel": "Fermato", - "backups.restoreTitle": "Ripristinare il Backup", - "backups.start": "Avvia Backup", - "backups.started": "Backup avviato, il suo completamento potrebbe richiedere alcuni minuti.", - "backups.startedLabel": "Avviato", - "backups.startFailed": "Non è stato possibile avviare il backup.", "chat.answer": "Here is my answer:", - "chat.answers": "Answers", "chat.answersEmpty": "The ChatBot does not provide an answer or has not been configured yet.", "chat.ask": "Ask", "chat.describeFormat": "Also add the desired format (for example Markdown or HTML) to your prompt, dependending on the editor that you use.", @@ -196,12 +165,6 @@ "clients.connectWizard.cliStep3": "Inserisci il nome della tua app per la configurazione della CLI", "clients.connectWizard.cliStep3Hint": "È possibile gestire le configurazione per le diverse appi all'interno della CLI e passare ad un'app.", "clients.connectWizard.cliStep4": "Passa alla tua app usando CLI", - "clients.connectWizard.javascriptSdk": "Use the JavaScript SDK", - "clients.connectWizard.javascriptSdkDocumentation": "Documentations for the JavaScript SDK is available: ", - "clients.connectWizard.javascriptSdkHint": "Install the SDK and establish a connection to this app.", - "clients.connectWizard.javascriptSdkStep1": "Install the Javascript SDK", - "clients.connectWizard.javascriptSdkStep1Download": "The SDK is available on [npm](https://www.npmjs.com/package/@squidex/squidex)", - "clients.connectWizard.javascriptSdkStep2": "Create a client", "clients.connectWizard.manually": "Connetti manualmente", "clients.connectWizard.manuallyHint": "Leggi le istruzioni su come stabilire una connessione utilizzando Postman o curl.", "clients.connectWizard.manuallyStep1": "Ottenere un token usando curl", @@ -225,14 +188,10 @@ "clients.revokeFailed": "Non è stato possibile rimuovere il client. Per favore ricarica.", "clients.tokenFailed": "Non è stato possibile creare il token. Per favore riprova.", "comments.create": "Creare un commento", - "comments.createFailed": "Non è stato possibile creare un commento.", "comments.deleteConfirmText": "Sei sicuro di voler cancellare il commento?", "comments.deleteConfirmTitle": "Cancella il comment", - "comments.deleteFailed": "Non è stato possibile cancellare il commento.", "comments.follow": "Segui", - "comments.loadFailed": "Non è stato possibile caricare i commenti.", "comments.title": "Commenti", - "comments.updateFailed": "Non è stato possibile aggiornare il commento.", "common.actions": "Azioni", "common.administration": "Amministrazione", "common.administrationPageTitle": "Amministrazione", @@ -259,6 +218,7 @@ "common.close": "Close", "common.cluster": "Cluster", "common.clusterPageTitle": "Cluster", + "common.collapse": "Collapse", "common.comments": "Commenti", "common.components": "Components", "common.condition": "Condition", @@ -328,6 +288,8 @@ "common.httpLimit": "Hai superato il limite massimo di chiamate API.", "common.id": "Identificativo", "common.in": "in", + "common.jobs": "Jobs", + "common.jobsBackups": "Jobs & Backups", "common.label": "Etichetta", "common.language": "Lingua", "common.languages": "Lingue", @@ -628,6 +590,32 @@ "features.loadFailed": "Non è stato possibile caricare le funzionalità. Per favore ricarica.", "history.loadFailed": "Non è stato possibile caricare la cronologia. Per favore ricarica.", "history.title": "Attività", + "jobs.backupFailed": "Failed to start backup.", + "jobs.backupMaximumReached": "Your have reached the maximum number of backups: 10.", + "jobs.backupStart": "Start Backup", + "jobs.deleteConfirmText": "Do you really want to delete the job?", + "jobs.deleteConfirmTitle": "Delete Job", + "jobs.deleted": "Job is about to be deleted.", + "jobs.deleteFailed": "Failed to delete job.", + "jobs.empty": "No jobs created yet.", + "jobs.jobDownload": "Download", + "jobs.jobDownloadLink": "Ready", + "jobs.jobDuration": "Duration", + "jobs.loadFailed": "Failed to load jobs.", + "jobs.refreshTooltip": "Refresh jobs", + "jobs.reloaded": "Jobs reloaded.", + "jobs.restore": "Restore Backup", + "jobs.restoreFailed": "Failed to start restore.", + "jobs.restoreLastStatus": "Last Restore Operation", + "jobs.restoreLastUrl": "Url to backup", + "jobs.restoreNewAppName": "Optional app name", + "jobs.restorePageTitle": "Restore Backup", + "jobs.restoreStarted": "Restore started, it can take several minutes to complete.", + "jobs.restoreStartedLabel": "Started", + "jobs.restoreStoppedLabel": "Stopped", + "jobs.restoreTitle": "Restore Backup", + "jobs.started": "Job started, it can take several minutes to complete.", + "jobs.startedLabel": "Started", "languages.add": "Aggiungi lingua", "languages.add.description": "Add a new language that you want to support for your content.", "languages.add.title": "Add a new Language", diff --git a/backend/i18n/frontend_nl.json b/backend/i18n/frontend_nl.json index cef707dc8a..8d5111209e 100644 --- a/backend/i18n/frontend_nl.json +++ b/backend/i18n/frontend_nl.json @@ -136,38 +136,7 @@ "assets.uploadHint": "Zet het bestand neer op bestaand item om het bestand te vervangen door een nieuwere versie.", "assets.viewReferences": "View all content items referencing this asset.", "assetScripts.reloaded": "Asset Scripts reloaded.", - "backups.backupCountAssetsLabel": "Bestanden", - "backups.backupCountAssetsTooltip": "Gearchiveerde middelen", - "backups.backupCountEventsLabel": "Evenementen", - "backups.backupCountEventsTooltip": "Gearchiveerde gebeurtenissen", - "backups.backupDownload": "Downloaden", - "backups.backupDownloadLink": "Klaar", - "backups.backupDuration": "Duur", - "backups.deleteConfirmText": "Wilt je de back-up echt verwijderen?", - "backups.deleteConfirmTitle": "Back-up verwijderen", - "backups.deleted": "Back-up wordt binnenkort verwijderd.", - "backups.deleteFailed": "Verwijderen van back-up is mislukt.", - "backups.empty": "Nog geen back-ups gemaakt.", - "backups.loadFailed": "Laden van back-ups is mislukt.", - "backups.maximumReached": "Je hebt het maximale aantal back-ups bereikt: 10", - "backups.refreshTooltip": "Vernieuw back-ups", - "backups.reloaded": "Back-ups herladen.", - "backups.restore": "Back-up herstellen", - "backups.restoreFailed": "Starten van herstel is mislukt.", - "backups.restoreLastStatus": "Laatste herstelbewerking", - "backups.restoreLastUrl": "URL voor back-up", - "backups.restoreNewAppName": "Optionele app-naam", - "backups.restorePageTitle": "Back-up herstellen", - "backups.restoreStarted": "Herstel gestart, het kan enkele minuten duren.", - "backups.restoreStartedLabel": "Gestart", - "backups.restoreStoppedLabel": "Gestopt", - "backups.restoreTitle": "Back-up herstellen", - "backups.start": "Start back-up", - "backups.started": "Back-up gestart, het kan enkele minuten duren om te voltooien.", - "backups.startedLabel": "Gestart", - "backups.startFailed": "Starten van back-up is mislukt.", "chat.answer": "Here is my answer:", - "chat.answers": "Answers", "chat.answersEmpty": "The ChatBot does not provide an answer or has not been configured yet.", "chat.ask": "Ask", "chat.describeFormat": "Also add the desired format (for example Markdown or HTML) to your prompt, dependending on the editor that you use.", @@ -196,12 +165,6 @@ "clients.connectWizard.cliStep3": "Voeg uw app-naam toe aan de CLI-configuratie", "clients.connectWizard.cliStep3Hint": "Je kunt de configuratie voor meerdere apps in de CLI beheren en overschakelen naar een app.", "clients.connectWizard.cliStep4": "Schakel over naar uw app in de CLI", - "clients.connectWizard.javascriptSdk": "Use the JavaScript SDK", - "clients.connectWizard.javascriptSdkDocumentation": "Documentations for the JavaScript SDK is available: ", - "clients.connectWizard.javascriptSdkHint": "Install the SDK and establish a connection to this app.", - "clients.connectWizard.javascriptSdkStep1": "Install the Javascript SDK", - "clients.connectWizard.javascriptSdkStep1Download": "The SDK is available on [npm](https://www.npmjs.com/package/@squidex/squidex)", - "clients.connectWizard.javascriptSdkStep2": "Create a client", "clients.connectWizard.manually": "Handmatig verbinden", "clients.connectWizard.manuallyHint": "Krijg instructies om een ​​verbinding tot stand te brengen met Postman of curl.", "clients.connectWizard.manuallyStep1": "Verkrijg een token met curl", @@ -225,14 +188,10 @@ "clients.revokeFailed": "Kan client niet intrekken. Laad opnieuw.", "clients.tokenFailed": "Maken van token is mislukt. Probeer het opnieuw.", "comments.create": "Maak een opmerking", - "comments.createFailed": "Aanmaken van commentaar mislukt.", "comments.deleteConfirmText": "Wil je de opmerking echt verwijderen?", "comments.deleteConfirmTitle": "Verwijder opmerking", - "comments.deleteFailed": "Verwijderen van opmerking is mislukt.", "comments.follow": "Volgen", - "comments.loadFailed": "Kan commentaar niet laden.", "comments.title": "Reacties", - "comments.updateFailed": "Update reactie mislukt.", "common.actions": "Acties", "common.administration": "Administratie", "common.administrationPageTitle": "Administratie", @@ -259,6 +218,7 @@ "common.close": "Close", "common.cluster": "Cluster", "common.clusterPageTitle": "Cluster", + "common.collapse": "Collapse", "common.comments": "Reacties", "common.components": "Componenten", "common.condition": "Condition", @@ -328,6 +288,8 @@ "common.httpLimit": "Je hebt de maximale limiet van API-aanroepen overschreden.", "common.id": "Identiteit", "common.in": "in", + "common.jobs": "Jobs", + "common.jobsBackups": "Jobs & Backups", "common.label": "Label", "common.language": "Taal", "common.languages": "Talen", @@ -628,6 +590,32 @@ "features.loadFailed": "Laden van functies is mislukt. Laad opnieuw.", "history.loadFailed": "Kan geschiedenis niet laden. Laad opnieuw.", "history.title": "Activiteit", + "jobs.backupFailed": "Failed to start backup.", + "jobs.backupMaximumReached": "Your have reached the maximum number of backups: 10.", + "jobs.backupStart": "Start Backup", + "jobs.deleteConfirmText": "Do you really want to delete the job?", + "jobs.deleteConfirmTitle": "Delete Job", + "jobs.deleted": "Job is about to be deleted.", + "jobs.deleteFailed": "Failed to delete job.", + "jobs.empty": "No jobs created yet.", + "jobs.jobDownload": "Download", + "jobs.jobDownloadLink": "Ready", + "jobs.jobDuration": "Duration", + "jobs.loadFailed": "Failed to load jobs.", + "jobs.refreshTooltip": "Refresh jobs", + "jobs.reloaded": "Jobs reloaded.", + "jobs.restore": "Restore Backup", + "jobs.restoreFailed": "Failed to start restore.", + "jobs.restoreLastStatus": "Last Restore Operation", + "jobs.restoreLastUrl": "Url to backup", + "jobs.restoreNewAppName": "Optional app name", + "jobs.restorePageTitle": "Restore Backup", + "jobs.restoreStarted": "Restore started, it can take several minutes to complete.", + "jobs.restoreStartedLabel": "Started", + "jobs.restoreStoppedLabel": "Stopped", + "jobs.restoreTitle": "Restore Backup", + "jobs.started": "Job started, it can take several minutes to complete.", + "jobs.startedLabel": "Started", "languages.add": "Taal toevoegen", "languages.add.description": "Voeg een nieuwe taal toe die u wilt ondersteunen voor uw inhoud.", "languages.add.title": "Nieuwe taal toevoegen", diff --git a/backend/i18n/frontend_pt.json b/backend/i18n/frontend_pt.json index 21d37da088..691784db86 100644 --- a/backend/i18n/frontend_pt.json +++ b/backend/i18n/frontend_pt.json @@ -136,38 +136,7 @@ "assets.uploadHint": "Deixe cair o ficheiro no item existente para substituir o ficheiro por uma versão mais recente.", "assets.viewReferences": "View all content items referencing this asset.", "assetScripts.reloaded": "Scripts de ficheiros recarregados.", - "backups.backupCountAssetsLabel": "Ficheiros", - "backups.backupCountAssetsTooltip": "Ficheiros arquivados", - "backups.backupCountEventsLabel": "Eventos", - "backups.backupCountEventsTooltip": "Eventos arquivados", - "backups.backupDownload": "Baixar", - "backups.backupDownloadLink": "Pronto", - "backups.backupDuration": "Duração", - "backups.deleteConfirmText": "Quer mesmo apagar a cópia de segurança?", - "backups.deleteConfirmTitle": "Eliminar backup", - "backups.deleted": "A cópia de segurança está prestes a ser apagada.", - "backups.deleteFailed": "Falhou em eliminar a cópia de segurança.", - "backups.empty": "Ainda não foram criados reforços.", - "backups.loadFailed": "Falhou em carregar backups.", - "backups.maximumReached": "Atingiu o número máximo de reforços: 10.", - "backups.refreshTooltip": "Atualizar backups", - "backups.reloaded": "Reforços recarregados.", - "backups.restore": "Restaurar backup", - "backups.restoreFailed": "Falhou em começar a restaurar.", - "backups.restoreLastStatus": "Última Operação De Restauro", - "backups.restoreLastUrl": "Url para backup", - "backups.restoreNewAppName": "Nome de aplicativo opcional", - "backups.restorePageTitle": "Restaurar backup", - "backups.restoreStarted": "A restauração começou, pode levar vários minutos para ser concluída.", - "backups.restoreStartedLabel": "Começou", - "backups.restoreStoppedLabel": "Parado", - "backups.restoreTitle": "Restaurar backup", - "backups.start": "Iniciar backup", - "backups.started": "O reforço começou, pode levar vários minutos para ser concluído.", - "backups.startedLabel": "Começou", - "backups.startFailed": "Falhou em começar o backup.", "chat.answer": "Here is my answer:", - "chat.answers": "Answers", "chat.answersEmpty": "The ChatBot does not provide an answer or has not been configured yet.", "chat.ask": "Ask", "chat.describeFormat": "Also add the desired format (for example Markdown or HTML) to your prompt, dependending on the editor that you use.", @@ -196,12 +165,6 @@ "clients.connectWizard.cliStep3": "Adicione o nome da sua aplicação ao CLI config", "clients.connectWizard.cliStep3Hint": "Pode gerir a configuração de várias aplicações no CLI e mudar para uma aplicação.", "clients.connectWizard.cliStep4": "Mude para a sua aplicação no CLI", - "clients.connectWizard.javascriptSdk": "Use the JavaScript SDK", - "clients.connectWizard.javascriptSdkDocumentation": "Documentations for the JavaScript SDK is available: ", - "clients.connectWizard.javascriptSdkHint": "Install the SDK and establish a connection to this app.", - "clients.connectWizard.javascriptSdkStep1": "Install the Javascript SDK", - "clients.connectWizard.javascriptSdkStep1Download": "The SDK is available on [npm](https://www.npmjs.com/package/@squidex/squidex)", - "clients.connectWizard.javascriptSdkStep2": "Create a client", "clients.connectWizard.manually": "Conecte-se manualmente", "clients.connectWizard.manuallyHint": "Obtenha instruções sobre como estabelecer uma ligação com o Carteiro ou o caracol.", "clients.connectWizard.manuallyStep1": "Obter um símbolo usando caracóis", @@ -225,14 +188,10 @@ "clients.revokeFailed": "Falhou em revogar o cliente. Por favor, recarregue.", "clients.tokenFailed": "Falhou em criar um símbolo. Por favor, reda o redando.", "comments.create": "Criar um comentário", - "comments.createFailed": "Falhou em criar comentários.", "comments.deleteConfirmText": "Quer mesmo apagar o comentário?", "comments.deleteConfirmTitle": "Apagar comentário", - "comments.deleteFailed": "Não eliminou comentários.", "comments.follow": "Seguir", - "comments.loadFailed": "Falhou em carregar comentários.", "comments.title": "Comentários", - "comments.updateFailed": "Falhou em atualizar comentários.", "common.actions": "Ações", "common.administration": "Administração", "common.administrationPageTitle": "Administração", @@ -259,6 +218,7 @@ "common.close": "Close", "common.cluster": "Cluster", "common.clusterPageTitle": "Cluster", + "common.collapse": "Collapse", "common.comments": "Comentários", "common.components": "Componentes", "common.condition": "Condição", @@ -328,6 +288,8 @@ "common.httpLimit": "Excedeu o limite máximo de chamadas da API.", "common.id": "Identidade", "common.in": "in", + "common.jobs": "Jobs", + "common.jobsBackups": "Jobs & Backups", "common.label": "Etiqueta", "common.language": "Língua", "common.languages": "Línguas", @@ -628,6 +590,32 @@ "features.loadFailed": "Falhou na carga das características. Por favor, recarregue.", "history.loadFailed": "Falhou em carregar a história. Por favor, recarregue.", "history.title": "Atividade", + "jobs.backupFailed": "Failed to start backup.", + "jobs.backupMaximumReached": "Your have reached the maximum number of backups: 10.", + "jobs.backupStart": "Start Backup", + "jobs.deleteConfirmText": "Do you really want to delete the job?", + "jobs.deleteConfirmTitle": "Delete Job", + "jobs.deleted": "Job is about to be deleted.", + "jobs.deleteFailed": "Failed to delete job.", + "jobs.empty": "No jobs created yet.", + "jobs.jobDownload": "Download", + "jobs.jobDownloadLink": "Ready", + "jobs.jobDuration": "Duration", + "jobs.loadFailed": "Failed to load jobs.", + "jobs.refreshTooltip": "Refresh jobs", + "jobs.reloaded": "Jobs reloaded.", + "jobs.restore": "Restore Backup", + "jobs.restoreFailed": "Failed to start restore.", + "jobs.restoreLastStatus": "Last Restore Operation", + "jobs.restoreLastUrl": "Url to backup", + "jobs.restoreNewAppName": "Optional app name", + "jobs.restorePageTitle": "Restore Backup", + "jobs.restoreStarted": "Restore started, it can take several minutes to complete.", + "jobs.restoreStartedLabel": "Started", + "jobs.restoreStoppedLabel": "Stopped", + "jobs.restoreTitle": "Restore Backup", + "jobs.started": "Job started, it can take several minutes to complete.", + "jobs.startedLabel": "Started", "languages.add": "Adicionar linguagem", "languages.add.description": "Adicione um novo idioma que pretende apoiar para o seu conteúdo.", "languages.add.title": "Adicione uma nova linguagem", diff --git a/backend/i18n/frontend_zh.json b/backend/i18n/frontend_zh.json index 1ebe910020..3ea22fdb01 100644 --- a/backend/i18n/frontend_zh.json +++ b/backend/i18n/frontend_zh.json @@ -136,38 +136,7 @@ "assets.uploadHint": "在现有项目上放置文件以使用更新版本替换资源。", "assets.viewReferences": "View all content items referencing this asset.", "assetScripts.reloaded": "Asset Scripts reloaded.", - "backups.backupCountAssetsLabel": "资源", - "backups.backupCountAssetsTooltip": "存档资源", - "backups.backupCountEventsLabel": "事件", - "backups.backupCountEventsTooltip": "存档事件", - "backups.backupDownload": "下载", - "backups.backupDownloadLink": "准备就绪", - "backups.backupDuration": "持续时间", - "backups.deleteConfirmText": "你真的要删除备份吗?", - "backups.deleteConfirmTitle": "删除备份", - "backups.deleted": "备份即将被删除。", - "backups.deleteFailed": "删除备份失败。", - "backups.empty": "尚未创建备份。", - "backups.loadFailed": "加载备份失败。", - "backups.maximumReached": "您已达到最大备份数:10。", - "backups.refreshTooltip": "刷新备份", - "backups.reloaded": "备份已重新加载。", - "backups.restore": "恢复备份", - "backups.restoreFailed": "无法开始恢复。", - "backups.restoreLastStatus": "上次还原操作", - "backups.restoreLastUrl": "要备份的网址", - "backups.restoreNewAppName": "可选的应用程序名称", - "backups.restorePageTitle": "恢复备份", - "backups.restoreStarted": "恢复开始,可能需要几分钟才能完成。", - "backups.restoreStartedLabel": "开始", - "backups.restoreStoppedLabel": "已停止", - "backups.restoreTitle": "恢复备份", - "backups.start": "开始备份", - "backups.started": "备份已开始,可能需要几分钟才能完成。", - "backups.startedLabel": "开始", - "backups.startFailed": "启动备份失败。", "chat.answer": "Here is my answer:", - "chat.answers": "Answers", "chat.answersEmpty": "The ChatBot does not provide an answer or has not been configured yet.", "chat.ask": "Ask", "chat.describeFormat": "Also add the desired format (for example Markdown or HTML) to your prompt, dependending on the editor that you use.", @@ -196,12 +165,6 @@ "clients.connectWizard.cliStep3": "在 CLI 配置中添加你的应用名称", "clients.connectWizard.cliStep3Hint": "您可以在 CLI 中管理多个应用程序的配置并切换到一个应用程序。", "clients.connectWizard.cliStep4": "在 CLI 中切换到您的应用程序", - "clients.connectWizard.javascriptSdk": "Use the JavaScript SDK", - "clients.connectWizard.javascriptSdkDocumentation": "Documentations for the JavaScript SDK is available: ", - "clients.connectWizard.javascriptSdkHint": "Install the SDK and establish a connection to this app.", - "clients.connectWizard.javascriptSdkStep1": "Install the Javascript SDK", - "clients.connectWizard.javascriptSdkStep1Download": "The SDK is available on [npm](https://www.npmjs.com/package/@squidex/squidex)", - "clients.connectWizard.javascriptSdkStep2": "Create a client", "clients.connectWizard.manually": "手动连接", "clients.connectWizard.manuallyHint": "获取如何与 Postman 或 curl 建立连接的说明。", "clients.connectWizard.manuallyStep1": "使用 curl 获取令牌", @@ -225,14 +188,10 @@ "clients.revokeFailed": "撤销客户端失败。请重新加载。", "clients.tokenFailed": "创建令牌失败。请重试。", "comments.create": "创建评论", - "comments.createFailed": "创建评论失败。", "comments.deleteConfirmText": "你真的要删除评论吗?", "comments.deleteConfirmTitle": "删除评论", - "comments.deleteFailed": "删除评论失败。", "comments.follow": "关注", - "comments.loadFailed": "加载评论失败。", "comments.title": "评论", - "comments.updateFailed": "更新评论失败。", "common.actions": "动作", "common.administration": "管理", "common.administrationPageTitle": "管理", @@ -259,6 +218,7 @@ "common.close": "Close", "common.cluster": "集群", "common.clusterPageTitle": "集群", + "common.collapse": "Collapse", "common.comments": "评论", "common.components": "组件", "common.condition": "Condition", @@ -328,6 +288,8 @@ "common.httpLimit": "您已超出 API 调用的最大限制。", "common.id": "身份", "common.in": "in", + "common.jobs": "Jobs", + "common.jobsBackups": "Jobs & Backups", "common.label": "标签", "common.language": "语言", "common.languages": "语言", @@ -628,6 +590,32 @@ "features.loadFailed": "加载功能失败。请重新加载。", "history.loadFailed": "加载历史记录失败。请重新加载。", "history.title": "活动", + "jobs.backupFailed": "Failed to start backup.", + "jobs.backupMaximumReached": "Your have reached the maximum number of backups: 10.", + "jobs.backupStart": "Start Backup", + "jobs.deleteConfirmText": "Do you really want to delete the job?", + "jobs.deleteConfirmTitle": "Delete Job", + "jobs.deleted": "Job is about to be deleted.", + "jobs.deleteFailed": "Failed to delete job.", + "jobs.empty": "No jobs created yet.", + "jobs.jobDownload": "Download", + "jobs.jobDownloadLink": "Ready", + "jobs.jobDuration": "Duration", + "jobs.loadFailed": "Failed to load jobs.", + "jobs.refreshTooltip": "Refresh jobs", + "jobs.reloaded": "Jobs reloaded.", + "jobs.restore": "Restore Backup", + "jobs.restoreFailed": "Failed to start restore.", + "jobs.restoreLastStatus": "Last Restore Operation", + "jobs.restoreLastUrl": "Url to backup", + "jobs.restoreNewAppName": "Optional app name", + "jobs.restorePageTitle": "Restore Backup", + "jobs.restoreStarted": "Restore started, it can take several minutes to complete.", + "jobs.restoreStartedLabel": "Started", + "jobs.restoreStoppedLabel": "Stopped", + "jobs.restoreTitle": "Restore Backup", + "jobs.started": "Job started, it can take several minutes to complete.", + "jobs.startedLabel": "Started", "languages.add": "添加语言", "languages.add.description": "Add a new language that you want to support for your content.", "languages.add.title": "Add a new Language", diff --git a/backend/i18n/source/backend_en.json b/backend/i18n/source/backend_en.json index 312f6214d1..95dcd4aae3 100644 --- a/backend/i18n/source/backend_en.json +++ b/backend/i18n/source/backend_en.json @@ -33,11 +33,6 @@ "assets.folderRecursion": "Cannot add folder to its own child.", "assets.maxSizeReached": "You have reached your max asset size.", "assets.referenced": "Assets is referenced by a content and cannot be deleted.", - "backups.alreadyRunning": "Another backup process is already running.", - "backups.maxReached": "You cannot have more than {max} backups.", - "backups.restoreRunning": "A restore operation is already running.", - "comments.noPermissions": "You can only access your notifications.", - "comments.notUserComment": "Comment is created by another user.", "common.action": "Action", "common.aspectHeight": "Aspect height", "common.aspectWidth": "Aspect width", @@ -267,8 +262,16 @@ "history.teams.planChanged": "changed plan to {[Plan]}.", "history.teams.planReset": "resetted plan.", "history.teams.updated": "updated general settings and renamed name to {[Name]}.", + "job.backup": "Backup", + "job.restore": "Restore", + "job.ruleRun": "Replay Rule.", + "job.ruleRunNamed": "Replay Rule '{name}'.", + "job.ruleRunNamedSnapshot": "Replay Rule '{name}' from states.", + "job.ruleRunSnapshot": "Replay Rule from states", + "jobs.alreadyRunning": "Another job is already running.", + "jobs.invalidTaskName": "Invalid task name", + "jobs.maxReached": "You cannot have more than {max} backups.", "login.githubPrivateEmail": "Your email address is set to private in Github. Please set it to public to use Github login.", - "rules.ruleAlreadyRunning": "Another rule is already running.", "schemas.dateTimeCalculatedDefaultAndDefaultError": "Calculated default value and default value cannot be used together.", "schemas.duplicateFieldName": "Field '{field}' has been added twice.", "schemas.fieldCannotBeUIField": "Field cannot be an UI field.", diff --git a/backend/i18n/source/frontend_en.json b/backend/i18n/source/frontend_en.json index f1879a4ea7..ebe07471ce 100644 --- a/backend/i18n/source/frontend_en.json +++ b/backend/i18n/source/frontend_en.json @@ -136,38 +136,7 @@ "assets.uploadHint": "Drop file on existing item to replace the asset with a newer version.", "assets.viewReferences": "View all content items referencing this asset.", "assetScripts.reloaded": "Asset Scripts reloaded.", - "backups.backupCountAssetsLabel": "Assets", - "backups.backupCountAssetsTooltip": "Archived assets", - "backups.backupCountEventsLabel": "Events", - "backups.backupCountEventsTooltip": "Archived events", - "backups.backupDownload": "Download", - "backups.backupDownloadLink": "Ready", - "backups.backupDuration": "Duration", - "backups.deleteConfirmText": "Do you really want to delete the backup?", - "backups.deleteConfirmTitle": "Delete backup", - "backups.deleted": "Backup is about to be deleted.", - "backups.deleteFailed": "Failed to delete backup.", - "backups.empty": "No backups created yet.", - "backups.loadFailed": "Failed to load backups.", - "backups.maximumReached": "Your have reached the maximum number of backups: 10.", - "backups.refreshTooltip": "Refresh backups", - "backups.reloaded": "Backups reloaded.", - "backups.restore": "Restore Backup", - "backups.restoreFailed": "Failed to start restore.", - "backups.restoreLastStatus": "Last Restore Operation", - "backups.restoreLastUrl": "Url to backup", - "backups.restoreNewAppName": "Optional app name", - "backups.restorePageTitle": "Restore Backup", - "backups.restoreStarted": "Restore started, it can take several minutes to complete.", - "backups.restoreStartedLabel": "Started", - "backups.restoreStoppedLabel": "Stopped", - "backups.restoreTitle": "Restore Backup", - "backups.start": "Start Backup", - "backups.started": "Backup started, it can take several minutes to complete.", - "backups.startedLabel": "Started", - "backups.startFailed": "Failed to start backup.", "chat.answer": "Here is my answer:", - "chat.answers": "Answers", "chat.answersEmpty": "The ChatBot does not provide an answer or has not been configured yet.", "chat.ask": "Ask", "chat.describeFormat": "Also add the desired format (for example Markdown or HTML) to your prompt, dependending on the editor that you use.", @@ -196,12 +165,6 @@ "clients.connectWizard.cliStep3": "Add your app name the CLI config", "clients.connectWizard.cliStep3Hint": "You can manage configuration to multiple apps in the CLI and switch to an app.", "clients.connectWizard.cliStep4": "Switch to your app in the CLI", - "clients.connectWizard.javascriptSdk": "Use the JavaScript SDK", - "clients.connectWizard.javascriptSdkDocumentation": "Documentations for the JavaScript SDK is available: ", - "clients.connectWizard.javascriptSdkHint": "Install the SDK and establish a connection to this app.", - "clients.connectWizard.javascriptSdkStep1": "Install the Javascript SDK", - "clients.connectWizard.javascriptSdkStep1Download": "The SDK is available on [npm](https://www.npmjs.com/package/@squidex/squidex)", - "clients.connectWizard.javascriptSdkStep2": "Create a client", "clients.connectWizard.manually": "Connect manually", "clients.connectWizard.manuallyHint": "Get instructions how to establish a connection with Postman or curl.", "clients.connectWizard.manuallyStep1": "Get a token using curl", @@ -225,14 +188,10 @@ "clients.revokeFailed": "Failed to revoke client. Please reload.", "clients.tokenFailed": "Failed to create token. Please retry.", "comments.create": "Create a comment", - "comments.createFailed": "Failed to create comment.", "comments.deleteConfirmText": "Do you really want to delete the comment?", "comments.deleteConfirmTitle": "Delete comment", - "comments.deleteFailed": "Failed to delete comment.", "comments.follow": "Follow", - "comments.loadFailed": "Failed to load comments.", "comments.title": "Comments", - "comments.updateFailed": "Failed to update comment.", "common.actions": "Actions", "common.administration": "Administration", "common.administrationPageTitle": "Administration", @@ -259,6 +218,7 @@ "common.close": "Close", "common.cluster": "Cluster", "common.clusterPageTitle": "Cluster", + "common.collapse": "Collapse", "common.comments": "Comments", "common.components": "Components", "common.condition": "Condition", @@ -328,6 +288,8 @@ "common.httpLimit": "You have exceeded the maximum limit of API calls.", "common.id": "Identity", "common.in": "in", + "common.jobs": "Jobs", + "common.jobsBackups": "Jobs & Backups", "common.label": "Label", "common.language": "Language", "common.languages": "Languages", @@ -628,6 +590,32 @@ "features.loadFailed": "Failed to load features. Please reload.", "history.loadFailed": "Failed to load history. Please reload.", "history.title": "Activity", + "jobs.backupFailed": "Failed to start backup.", + "jobs.backupMaximumReached": "Your have reached the maximum number of backups: 10.", + "jobs.backupStart": "Start Backup", + "jobs.deleteConfirmText": "Do you really want to delete the job?", + "jobs.deleteConfirmTitle": "Delete Job", + "jobs.deleted": "Job is about to be deleted.", + "jobs.deleteFailed": "Failed to delete job.", + "jobs.empty": "No jobs created yet.", + "jobs.jobDownload": "Download", + "jobs.jobDownloadLink": "Ready", + "jobs.jobDuration": "Duration", + "jobs.loadFailed": "Failed to load jobs.", + "jobs.refreshTooltip": "Refresh jobs", + "jobs.reloaded": "Jobs reloaded.", + "jobs.restore": "Restore Backup", + "jobs.restoreFailed": "Failed to start restore.", + "jobs.restoreLastStatus": "Last Restore Operation", + "jobs.restoreLastUrl": "Url to backup", + "jobs.restoreNewAppName": "Optional app name", + "jobs.restorePageTitle": "Restore Backup", + "jobs.restoreStarted": "Restore started, it can take several minutes to complete.", + "jobs.restoreStartedLabel": "Started", + "jobs.restoreStoppedLabel": "Stopped", + "jobs.restoreTitle": "Restore Backup", + "jobs.started": "Job started, it can take several minutes to complete.", + "jobs.startedLabel": "Started", "languages.add": "Add Language", "languages.add.description": "Add a new language that you want to support for your content.", "languages.add.title": "Add a new Language", diff --git a/backend/i18n/translator/Squidex.Translator/Processes/Helper.cs b/backend/i18n/translator/Squidex.Translator/Processes/Helper.cs index ae9437fe52..fa5b45507a 100644 --- a/backend/i18n/translator/Squidex.Translator/Processes/Helper.cs +++ b/backend/i18n/translator/Squidex.Translator/Processes/Helper.cs @@ -11,6 +11,18 @@ namespace Squidex.Translator.Processes; public static class Helper { + private static readonly string[][] AllowedPrefixCombos = + [ + [ + "apps", + "team" + ], + [ + "chatBot", + "translate" + ] + ]; + public static string RelativeName(FileInfo file, DirectoryInfo folder) { return file.FullName[folder.FullName.Length..].Replace("\\", "/", StringComparison.Ordinal); @@ -166,6 +178,16 @@ public static ISet CheckForFile(TranslationService service, string relat private static bool HasInvalidPrefixes(HashSet prefixes) { - return prefixes.Count > 1; + if (prefixes.Count <= 1) + { + return false; + } + + if (AllowedPrefixCombos.Any(x => prefixes.Count == x.Length && x.All(y => prefixes.Contains(y)))) + { + return false; + } + + return true; } } diff --git a/backend/src/Migrations/MigrationPath.cs b/backend/src/Migrations/MigrationPath.cs index caafeb8113..be44434d7d 100644 --- a/backend/src/Migrations/MigrationPath.cs +++ b/backend/src/Migrations/MigrationPath.cs @@ -7,6 +7,7 @@ using Microsoft.Extensions.DependencyInjection; using Migrations.Migrations; +using Migrations.Migrations.Backup; using Migrations.Migrations.MongoDb; using Squidex.Infrastructure; using Squidex.Infrastructure.Migrations; @@ -15,7 +16,7 @@ namespace Migrations; public sealed class MigrationPath : IMigrationPath { - private const int CurrentVersion = 26; + private const int CurrentVersion = 27; private readonly IServiceProvider serviceProvider; public MigrationPath(IServiceProvider serviceProvider) @@ -120,10 +121,16 @@ public MigrationPath(IServiceProvider serviceProvider) yield return serviceProvider.GetRequiredService(); } - // Version 27: New rule statistics using normal usage collection. + // Version 26: New rule statistics using normal usage collection. if (version < 26) { yield return serviceProvider.GetRequiredService(); } + + // Version 27: General jobs state. + if (version < 27) + { + yield return serviceProvider.GetRequiredService(); + } } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Backup/State/BackupJob.cs b/backend/src/Migrations/Migrations/Backup/BackupJob.cs similarity index 82% rename from backend/src/Squidex.Domain.Apps.Entities/Backup/State/BackupJob.cs rename to backend/src/Migrations/Migrations/Backup/BackupJob.cs index 8267a0f4eb..862dbe2d25 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Backup/State/BackupJob.cs +++ b/backend/src/Migrations/Migrations/Backup/BackupJob.cs @@ -8,9 +8,9 @@ using NodaTime; using Squidex.Infrastructure; -namespace Squidex.Domain.Apps.Entities.Backup.State; +namespace Migrations.Migrations.Backup; -public sealed class BackupJob : IBackupJob +public sealed class BackupJob { public DomainId Id { get; set; } @@ -22,5 +22,5 @@ public sealed class BackupJob : IBackupJob public int HandledAssets { get; set; } - public JobStatus Status { get; set; } + public BackupStatus Status { get; set; } } diff --git a/backend/src/Migrations/Migrations/Backup/BackupState.cs b/backend/src/Migrations/Migrations/Backup/BackupState.cs new file mode 100644 index 0000000000..299ef7e795 --- /dev/null +++ b/backend/src/Migrations/Migrations/Backup/BackupState.cs @@ -0,0 +1,50 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Domain.Apps.Entities.Jobs; + +namespace Migrations.Migrations.Backup; + +public sealed class BackupState +{ + public List Jobs { get; set; } = []; + + public JobsState ToJob() + { + var result = new JobsState + { + Jobs = Jobs.Select(ToState).ToList() + }; + + return result; + } + + private static Job ToState(BackupJob source) + { + return new Job + { + Arguments = [], + Id = source.Id, + TaskName = "backup", + Started = source.Started, + Stopped = source.Stopped, + File = new JobFile($"app-{source.Started:yyyy-MM-dd}.zip", "application/zip"), + Status = source.Status switch + { + BackupStatus.Completed => JobStatus.Completed, + BackupStatus.Created => JobStatus.Created, + BackupStatus.Failed => JobStatus.Failed, + BackupStatus.Started => JobStatus.Started, + _ => JobStatus.Failed + }, + Log = + [ + new JobLogMessage(source.Stopped ?? source.Started, $"Total events: {source.HandledEvents}, assets: {source.HandledAssets}") + ] + }; + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Backup/State/BackupRestoreState.cs b/backend/src/Migrations/Migrations/Backup/BackupStatus.cs similarity index 74% rename from backend/src/Squidex.Domain.Apps.Entities/Backup/State/BackupRestoreState.cs rename to backend/src/Migrations/Migrations/Backup/BackupStatus.cs index b0930fbd3a..c796ecf4a2 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Backup/State/BackupRestoreState.cs +++ b/backend/src/Migrations/Migrations/Backup/BackupStatus.cs @@ -5,9 +5,12 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -namespace Squidex.Domain.Apps.Entities.Backup.State; +namespace Migrations.Migrations.Backup; -public class BackupRestoreState +public enum BackupStatus { - public RestoreJob? Job { get; set; } + Created, + Started, + Completed, + Failed } diff --git a/backend/src/Migrations/Migrations/Backup/ConvertBackup.cs b/backend/src/Migrations/Migrations/Backup/ConvertBackup.cs new file mode 100644 index 0000000000..a0837fafa3 --- /dev/null +++ b/backend/src/Migrations/Migrations/Backup/ConvertBackup.cs @@ -0,0 +1,39 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Domain.Apps.Entities.Jobs; +using Squidex.Infrastructure.Migrations; +using Squidex.Infrastructure.States; + +namespace Migrations.Migrations.Backup; + +public sealed class ConvertBackup : IMigration +{ + private readonly ISnapshotStore stateBackups; + private readonly ISnapshotStore stateJobs; + + public ConvertBackup( + ISnapshotStore stateBackups, + ISnapshotStore stateJobs) + { + this.stateBackups = stateBackups; + this.stateJobs = stateJobs; + } + + public async Task UpdateAsync( + CancellationToken ct) + { + await foreach (var state in stateBackups.ReadAllAsync(ct)) + { + var job = state.Value.ToJob(); + + await stateJobs.WriteAsync(new SnapshotWriteJob(state.Key, job, 0), ct); + } + + await stateBackups.ClearAsync(ct); + } +} diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/IUrlGenerator.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/IUrlGenerator.cs index e363d9d5da..f076fb3c98 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/IUrlGenerator.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/IUrlGenerator.cs @@ -28,8 +28,6 @@ public interface IUrlGenerator string AssetContentBase(string appName); - string BackupsUI(NamedId appId); - string ClientsUI(NamedId appId); string ContentCDNBase(); @@ -46,6 +44,8 @@ public interface IUrlGenerator string LanguagesUI(NamedId appId); + string JobsUI(NamedId appId); + string PatternsUI(NamedId appId); string PlansUI(NamedId appId); diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/AppSettingsSearchSource.cs b/backend/src/Squidex.Domain.Apps.Entities/Apps/AppSettingsSearchSource.cs index 366ece44db..afd3cf2458 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Apps/AppSettingsSearchSource.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Apps/AppSettingsSearchSource.cs @@ -46,8 +46,11 @@ void Search(string term, string permissionId, Func, string> ge Search("Assets", PermissionIds.AppAssetsRead, a => urlGenerator.AssetsUI(a), SearchResultType.Asset); - Search("Backups", PermissionIds.AppBackupsRead, - urlGenerator.BackupsUI, SearchResultType.Setting); + Search("Backups", PermissionIds.AppJobsRead, + urlGenerator.JobsUI, SearchResultType.Setting); + + Search("Jobs", PermissionIds.AppJobsRead, + urlGenerator.JobsUI, SearchResultType.Setting); Search("Clients", PermissionIds.AppClientsRead, urlGenerator.ClientsUI, SearchResultType.Setting); diff --git a/backend/src/Squidex.Domain.Apps.Entities/Backup/BackupJob.cs b/backend/src/Squidex.Domain.Apps.Entities/Backup/BackupJob.cs new file mode 100644 index 0000000000..85e78fabe9 --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Entities/Backup/BackupJob.cs @@ -0,0 +1,141 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Domain.Apps.Core.Apps; +using Squidex.Domain.Apps.Entities.Jobs; +using Squidex.Domain.Apps.Events; +using Squidex.Infrastructure; +using Squidex.Infrastructure.EventSourcing; +using Squidex.Infrastructure.Translations; +using Squidex.Shared.Users; + +namespace Squidex.Domain.Apps.Entities.Backup; + +public sealed class BackupJob : IJobRunner +{ + public const string TaskName = "backup"; + public const string ArgAppId = "appId"; + public const string ArgAppName = "appName"; + + private readonly IBackupArchiveLocation backupArchiveLocation; + private readonly IBackupArchiveStore backupArchiveStore; + private readonly IBackupHandlerFactory backupHandlerFactory; + private readonly IEventFormatter eventFormatter; + private readonly IEventStore eventStore; + private readonly IUserResolver userResolver; + + public string Name => TaskName; + + public int MaxJobs => 10; + + public BackupJob( + IBackupArchiveLocation backupArchiveLocation, + IBackupArchiveStore backupArchiveStore, + IBackupHandlerFactory backupHandlerFactory, + IEventFormatter eventFormatter, + IEventStore eventStore, + IUserResolver userResolver) + { + this.backupArchiveLocation = backupArchiveLocation; + this.backupArchiveStore = backupArchiveStore; + this.backupHandlerFactory = backupHandlerFactory; + this.eventFormatter = eventFormatter; + this.eventStore = eventStore; + this.userResolver = userResolver; + } + + public static JobRequest BuildRequest(RefToken actor, App app) + { + return JobRequest.Create( + actor, + TaskName, + new Dictionary + { + [ArgAppId] = app.Id.ToString(), + [ArgAppName] = app.Name + }); + } + + public Task DownloadAsync(Job state, Stream stream, + CancellationToken ct) + { + return backupArchiveStore.DownloadAsync(state.Id, stream, ct); + } + + public Task CleanupAsync(Job state) + { + return backupArchiveStore.DeleteAsync(state.Id, default); + } + + public async Task RunAsync(JobRunContext context, + CancellationToken ct) + { + var appId = context.OwnerId; + var appName = context.Job.Arguments.GetValueOrDefault(ArgAppName, "app"); + + // We store the file in a the asset store and make the information available. + context.Job.File = new JobFile($"backup-{appName}-{context.Job.Started:yyyy-MM-dd_HH-mm-ss}.zip", "application/zip"); + + // Use a readable name to describe the job. + context.Job.Description = T.Get("job.backup"); + + var handlers = backupHandlerFactory.CreateMany(); + + await using var stream = backupArchiveLocation.OpenStream(context.Job.Id); + + using (var writer = await backupArchiveLocation.OpenWriterAsync(stream, ct)) + { + await writer.WriteVersionAsync(); + + var backupUsers = new UserMapping(context.Actor); + var backupContext = new BackupContext(appId, backupUsers, writer); + + var streamFilter = StreamFilter.Prefix($"[^\\-]*-{appId}"); + + await foreach (var storedEvent in eventStore.QueryAllAsync(streamFilter, ct: ct)) + { + var @event = eventFormatter.Parse(storedEvent); + + if (@event.Payload is SquidexEvent { Actor: { } } squidexEvent) + { + backupUsers.Backup(squidexEvent.Actor); + } + + foreach (var handler in handlers) + { + await handler.BackupEventAsync(@event, backupContext, ct); + } + + writer.WriteEvent(storedEvent, ct); + + await context.LogAsync($"Total events: {writer.WrittenEvents}, assets: {writer.WrittenAttachments}", true); + } + + foreach (var handler in handlers) + { + ct.ThrowIfCancellationRequested(); + + await handler.BackupAsync(backupContext, ct); + } + + foreach (var handler in handlers) + { + ct.ThrowIfCancellationRequested(); + + await handler.CompleteBackupAsync(backupContext); + } + + await backupUsers.StoreAsync(writer, userResolver, ct); + } + + stream.Position = 0; + + ct.ThrowIfCancellationRequested(); + + await backupArchiveStore.UploadAsync(context.Job.Id, stream, ct); + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Backup/BackupProcessor.Run.cs b/backend/src/Squidex.Domain.Apps.Entities/Backup/BackupProcessor.Run.cs deleted file mode 100644 index dc73c2b787..0000000000 --- a/backend/src/Squidex.Domain.Apps.Entities/Backup/BackupProcessor.Run.cs +++ /dev/null @@ -1,54 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschraenkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using Squidex.Domain.Apps.Entities.Backup.State; -using Squidex.Infrastructure; - -#pragma warning disable MA0040 // Flow the cancellation token - -namespace Squidex.Domain.Apps.Entities.Backup; - -public sealed partial class BackupProcessor -{ - // Use a run to store all state that is necessary for a single run. - private sealed class Run : IDisposable - { - private readonly CancellationTokenSource cancellationSource = new CancellationTokenSource(); - private readonly CancellationTokenSource cancellationLinked; - - public IEnumerable Handlers { get; init; } - - public RefToken Actor { get; init; } - - public BackupJob Job { get; init; } - - public CancellationToken CancellationToken => cancellationLinked.Token; - - public Run(CancellationToken ct) - { - cancellationLinked = CancellationTokenSource.CreateLinkedTokenSource(ct, cancellationSource.Token); - } - - public void Dispose() - { - cancellationSource.Dispose(); - cancellationLinked.Dispose(); - } - - public void Cancel() - { - try - { - cancellationSource.Cancel(); - } - catch (ObjectDisposedException) - { - // Cancellation token might have been disposed, if the run is completed. - } - } - } -} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Backup/BackupProcessor.cs b/backend/src/Squidex.Domain.Apps.Entities/Backup/BackupProcessor.cs deleted file mode 100644 index b61844a623..0000000000 --- a/backend/src/Squidex.Domain.Apps.Entities/Backup/BackupProcessor.cs +++ /dev/null @@ -1,269 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschraenkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using Microsoft.Extensions.Logging; -using NodaTime; -using Squidex.Domain.Apps.Entities.Backup.State; -using Squidex.Domain.Apps.Events; -using Squidex.Infrastructure; -using Squidex.Infrastructure.EventSourcing; -using Squidex.Infrastructure.States; -using Squidex.Infrastructure.Tasks; -using Squidex.Infrastructure.Translations; -using Squidex.Shared.Users; - -#pragma warning disable MA0040 // Flow the cancellation token - -namespace Squidex.Domain.Apps.Entities.Backup; - -public sealed partial class BackupProcessor -{ - private readonly IBackupArchiveLocation backupArchiveLocation; - private readonly IBackupArchiveStore backupArchiveStore; - private readonly IBackupHandlerFactory backupHandlerFactory; - private readonly IEventFormatter eventFormatter; - private readonly IEventStore eventStore; - private readonly IUserResolver userResolver; - private readonly ILogger log; - private readonly SimpleState state; - private readonly ReentrantScheduler scheduler = new ReentrantScheduler(1); - private readonly DomainId appId; - private Run? currentRun; - - public IClock Clock { get; set; } = SystemClock.Instance; - - public BackupProcessor( - DomainId appId, - IBackupArchiveLocation backupArchiveLocation, - IBackupArchiveStore backupArchiveStore, - IBackupHandlerFactory backupHandlerFactory, - IEventFormatter eventFormatter, - IEventStore eventStore, - IPersistenceFactory persistenceFactory, - IUserResolver userResolver, - ILogger log) - { - this.appId = appId; - this.backupArchiveLocation = backupArchiveLocation; - this.backupArchiveStore = backupArchiveStore; - this.backupHandlerFactory = backupHandlerFactory; - this.eventFormatter = eventFormatter; - this.eventStore = eventStore; - this.userResolver = userResolver; - this.log = log; - - // Enable locking for the parallel operations that might write stuff. - state = new SimpleState(persistenceFactory, GetType(), appId, true); - } - - public async Task LoadAsync( - CancellationToken ct) - { - await state.LoadAsync(ct); - - if (state.Value.Jobs.RemoveAll(x => x.Stopped == null) > 0) - { - // This should actually never happen, so we log with warning. - log.LogWarning("Removed unfinished backups for app {appId} after start.", appId); - - await state.WriteAsync(ct); - } - } - - public Task ClearAsync() - { - return scheduler.ScheduleAsync(async _ => - { - log.LogInformation("Clearing backups for app {appId}.", appId); - - foreach (var backup in state.Value.Jobs) - { - await backupArchiveStore.DeleteAsync(backup.Id, default); - } - - await state.ClearAsync(default); - }); - } - - public Task BackupAsync(RefToken actor, - CancellationToken ct) - { - return scheduler.ScheduleAsync(async _ => - { - if (currentRun != null) - { - throw new DomainException(T.Get("backups.alreadyRunning")); - } - - state.Value.EnsureCanStart(); - - // Set the current run first to indicate that we are running a rule at the moment. - var run = currentRun = new Run(ct) - { - Actor = actor, - Job = new BackupJob - { - Id = DomainId.NewGuid(), - Started = Clock.GetCurrentInstant(), - Status = JobStatus.Started - }, - Handlers = backupHandlerFactory.CreateMany() - }; - - log.LogInformation("Starting new backup with backup id '{backupId}' for app {appId}.", run.Job.Id, appId); - - state.Value.Jobs.Insert(0, run.Job); - try - { - await ProcessAsync(run, run.CancellationToken); - } - finally - { - // Unset the run to indicate that we are done. - currentRun.Dispose(); - currentRun = null; - } - }, ct); - } - - private async Task ProcessAsync(Run run, - CancellationToken ct) - { - try - { - await state.WriteAsync(run.CancellationToken); - - await using (var stream = backupArchiveLocation.OpenStream(run.Job.Id)) - { - using (var writer = await backupArchiveLocation.OpenWriterAsync(stream, ct)) - { - await writer.WriteVersionAsync(); - - var backupUsers = new UserMapping(run.Actor); - var backupContext = new BackupContext(appId, backupUsers, writer); - - var streamFilter = StreamFilter.Prefix($"[^\\-]*-{appId}"); - - await foreach (var storedEvent in eventStore.QueryAllAsync(streamFilter, ct: ct)) - { - var @event = eventFormatter.Parse(storedEvent); - - if (@event.Payload is SquidexEvent { Actor: { } } squidexEvent) - { - backupUsers.Backup(squidexEvent.Actor); - } - - foreach (var handler in run.Handlers) - { - await handler.BackupEventAsync(@event, backupContext, ct); - } - - writer.WriteEvent(storedEvent, ct); - - await LogAsync(run, writer.WrittenEvents, writer.WrittenAttachments); - } - - foreach (var handler in run.Handlers) - { - ct.ThrowIfCancellationRequested(); - - await handler.BackupAsync(backupContext, ct); - } - - foreach (var handler in run.Handlers) - { - ct.ThrowIfCancellationRequested(); - - await handler.CompleteBackupAsync(backupContext); - } - - await backupUsers.StoreAsync(writer, userResolver, ct); - } - - stream.Position = 0; - - ct.ThrowIfCancellationRequested(); - - await backupArchiveStore.UploadAsync(run.Job.Id, stream, ct); - } - - await SetStatusAsync(run, JobStatus.Completed); - } - catch (Exception ex) - { - await SetStatusAsync(run, JobStatus.Failed); - - log.LogError(ex, "Failed to make backup with backup id '{backupId}'.", run.Job.Id); - } - } - - public Task DeleteAsync(DomainId id) - { - return scheduler.ScheduleAsync(async _ => - { - var job = state.Value.Jobs.Find(x => x.Id == id); - - if (job == null) - { - throw new DomainObjectNotFoundException(id.ToString()); - } - - log.LogInformation("Deleting backup with backup id '{backupId}' for app {appId}.", job.Id, appId); - - if (currentRun?.Job == job) - { - currentRun.Cancel(); - } - else - { - await RemoveAsync(job); - } - }); - } - - private async Task RemoveAsync(BackupJob job) - { - try - { - await backupArchiveStore.DeleteAsync(job.Id); - } - catch (Exception ex) - { - log.LogError(ex, "Failed to make remove with backup id '{backupId}'.", job.Id); - } - - state.Value.Jobs.Remove(job); - - await state.WriteAsync(); - } - - private Task SetStatusAsync(Run run, JobStatus status) - { - var now = Clock.GetCurrentInstant(); - - run.Job.Status = status; - - if (status == JobStatus.Failed || status == JobStatus.Completed) - { - run.Job.Stopped = now; - } - else if (status == JobStatus.Started) - { - run.Job.Started = now; - } - - return state.WriteAsync(ct: default); - } - - private Task LogAsync(Run run, int numEvents, int numAttachments) - { - run.Job.HandledEvents = numEvents; - run.Job.HandledAssets = numAttachments; - - return state.WriteAsync(100, run.CancellationToken); - } -} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Backup/BackupService.cs b/backend/src/Squidex.Domain.Apps.Entities/Backup/BackupService.cs deleted file mode 100644 index 2adb86034c..0000000000 --- a/backend/src/Squidex.Domain.Apps.Entities/Backup/BackupService.cs +++ /dev/null @@ -1,98 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschraenkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using Squidex.Domain.Apps.Core.Apps; -using Squidex.Domain.Apps.Entities.Backup.State; -using Squidex.Infrastructure; -using Squidex.Infrastructure.States; -using Squidex.Messaging; - -namespace Squidex.Domain.Apps.Entities.Backup; - -public sealed class BackupService : IBackupService, IDeleter -{ - private readonly SimpleState restoreState; - private readonly IPersistenceFactory persistenceFactoryBackup; - private readonly IMessageBus messaging; - - public BackupService( - IPersistenceFactory persistenceFactoryRestore, - IPersistenceFactory persistenceFactoryBackup, - IMessageBus messaging) - { - this.persistenceFactoryBackup = persistenceFactoryBackup; - this.messaging = messaging; - - restoreState = new SimpleState(persistenceFactoryRestore, GetType(), "Default"); - } - - Task IDeleter.DeleteAppAsync(App app, - CancellationToken ct) - { - return messaging.PublishAsync(new BackupClear(app.Id), ct: ct); - } - - public async Task StartBackupAsync(DomainId appId, RefToken actor, - CancellationToken ct = default) - { - var state = await GetStateAsync(appId, ct); - - state.Value.EnsureCanStart(); - - await messaging.PublishAsync(new BackupStart(appId, actor), ct: ct); - } - - public async Task StartRestoreAsync(RefToken actor, Uri url, string? newAppName, - CancellationToken ct = default) - { - await restoreState.LoadAsync(ct); - - restoreState.Value.Job?.EnsureCanStart(); - - await messaging.PublishAsync(new BackupRestore(actor, url, newAppName), ct: ct); - } - - public Task DeleteBackupAsync(DomainId appId, DomainId backupId, - CancellationToken ct = default) - { - return messaging.PublishAsync(new BackupDelete(appId, backupId), ct: ct); - } - - public async Task GetRestoreAsync( - CancellationToken ct = default) - { - await restoreState.LoadAsync(ct); - - return restoreState.Value.Job ?? new RestoreJob(); - } - - public async Task> GetBackupsAsync(DomainId appId, - CancellationToken ct = default) - { - var state = await GetStateAsync(appId, ct); - - return state.Value.Jobs.OfType().ToList(); - } - - public async Task GetBackupAsync(DomainId appId, DomainId backupId, - CancellationToken ct = default) - { - var state = await GetStateAsync(appId, ct); - - return state.Value.Jobs.Find(x => x.Id == backupId); - } - - private async Task> GetStateAsync(DomainId appId, - CancellationToken ct) - { - var state = new SimpleState(persistenceFactoryBackup, GetType(), appId); - - await state.LoadAsync(ct); - - return state; - } -} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Backup/BackupWorker.cs b/backend/src/Squidex.Domain.Apps.Entities/Backup/BackupWorker.cs deleted file mode 100644 index 3b0f619af2..0000000000 --- a/backend/src/Squidex.Domain.Apps.Entities/Backup/BackupWorker.cs +++ /dev/null @@ -1,88 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschraenkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using Microsoft.Extensions.DependencyInjection; -using Squidex.Hosting; -using Squidex.Infrastructure; -using Squidex.Messaging; - -namespace Squidex.Domain.Apps.Entities.Backup; - -public sealed class BackupWorker : - IMessageHandler, - IMessageHandler, - IMessageHandler, - IMessageHandler, - IInitializable -{ - private readonly Dictionary> backupProcessors = []; - private readonly Func backupFactory; - private readonly RestoreProcessor restoreProcessor; - - public BackupWorker(IServiceProvider serviceProvider) - { - var objectFactory = ActivatorUtilities.CreateFactory(typeof(BackupProcessor), [typeof(DomainId)]); - - backupFactory = key => - { - return (BackupProcessor)objectFactory(serviceProvider, new object[] { key }); - }; - - restoreProcessor = serviceProvider.GetRequiredService(); - } - - public Task InitializeAsync( - CancellationToken ct) - { - return restoreProcessor.LoadAsync(ct); - } - - public Task HandleAsync(BackupRestore message, - CancellationToken ct) - { - return restoreProcessor.RestoreAsync(message.Url, message.Actor, message.NewAppName, ct); - } - - public async Task HandleAsync(BackupStart message, - CancellationToken ct) - { - var processor = await GetBackupProcessorAsync(message.AppId); - - await processor.BackupAsync(message.Actor, ct); - } - - public async Task HandleAsync(BackupDelete message, - CancellationToken ct) - { - var processor = await GetBackupProcessorAsync(message.AppId); - - await processor.DeleteAsync(message.Id); - } - - public async Task HandleAsync(BackupClear message, - CancellationToken ct) - { - var processor = await GetBackupProcessorAsync(message.AppId); - - await processor.ClearAsync(); - } - - private Task GetBackupProcessorAsync(DomainId appId) - { - lock (backupProcessors) - { - return backupProcessors.GetOrAdd(appId, async key => - { - var processor = backupFactory(key); - - await processor.LoadAsync(default); - - return processor; - }); - } - } -} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Backup/IBackupJob.cs b/backend/src/Squidex.Domain.Apps.Entities/Backup/IBackupJob.cs deleted file mode 100644 index 848110f6bf..0000000000 --- a/backend/src/Squidex.Domain.Apps.Entities/Backup/IBackupJob.cs +++ /dev/null @@ -1,26 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschraenkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using NodaTime; -using Squidex.Infrastructure; - -namespace Squidex.Domain.Apps.Entities.Backup; - -public interface IBackupJob -{ - DomainId Id { get; } - - Instant Started { get; } - - Instant? Stopped { get; } - - int HandledEvents { get; } - - int HandledAssets { get; } - - JobStatus Status { get; } -} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Backup/RestoreJob.cs b/backend/src/Squidex.Domain.Apps.Entities/Backup/RestoreJob.cs new file mode 100644 index 0000000000..32538d0c6f --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Entities/Backup/RestoreJob.cs @@ -0,0 +1,381 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Runtime.CompilerServices; +using Microsoft.Extensions.Logging; +using Squidex.Domain.Apps.Core.Apps; +using Squidex.Domain.Apps.Entities.Apps.Commands; +using Squidex.Domain.Apps.Entities.Jobs; +using Squidex.Domain.Apps.Events; +using Squidex.Domain.Apps.Events.Apps; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Commands; +using Squidex.Infrastructure.EventSourcing; +using Squidex.Infrastructure.States; +using Squidex.Infrastructure.Tasks; +using Squidex.Infrastructure.Translations; +using Squidex.Shared.Users; + +namespace Squidex.Domain.Apps.Entities.Backup; + +public sealed class RestoreJob : IJobRunner +{ + public const string TaskName = "restore"; + public const string ArgUrl = "url"; + public const string ArgName = "name"; + + private readonly IBackupArchiveLocation backupArchiveLocation; + private readonly IBackupHandlerFactory backupHandlerFactory; + private readonly ICommandBus commandBus; + private readonly IEventFormatter eventFormatter; + private readonly IEventStore eventStore; + private readonly IEventStreamNames eventStreamNames; + private readonly IUserResolver userResolver; + private readonly ILogger log; + + // Use a run to store all state that is necessary for a single run. + private sealed class State + { + public NamedId AppId { get; set; } + + public IEnumerable Handlers { get; init; } + + public IBackupReader Reader { get; set; } + + public RestoreContext Context { get; set; } + + public StreamMapper StreamMapper { get; set; } + + public string? NewAppName { get; init; } + + public Uri Url { get; internal set; } + } + + public string Name => TaskName; + + public RestoreJob( + IBackupArchiveLocation backupArchiveLocation, + IBackupHandlerFactory backupHandlerFactory, + ICommandBus commandBus, + IEventFormatter eventFormatter, + IEventStore eventStore, + IEventStreamNames eventStreamNames, + IUserResolver userResolver, + ILogger log) + { + this.backupArchiveLocation = backupArchiveLocation; + this.backupHandlerFactory = backupHandlerFactory; + this.commandBus = commandBus; + this.eventFormatter = eventFormatter; + this.eventStore = eventStore; + this.eventStreamNames = eventStreamNames; + this.userResolver = userResolver; + this.log = log; + } + + public static JobRequest BuildRequest(RefToken actor, Uri url, string? appName) + { + return JobRequest.Create( + actor, + TaskName, + new Dictionary + { + [ArgUrl] = url.ToString(), + [ArgName] = appName ?? string.Empty + }); + } + + public async Task RunAsync(JobRunContext context, + CancellationToken ct) + { + if (!context.Job.Arguments.TryGetValue(ArgUrl, out var urlValue) || !Uri.TryCreate(urlValue, UriKind.Absolute, out var url)) + { + throw new DomainException("Argument missing."); + } + + var state = new State + { + Handlers = backupHandlerFactory.CreateMany(), + // Required argument. + Url = url, + // Optional argument. + NewAppName = context.Job.Arguments.GetValueOrDefault(ArgName) + }; + + // Use a readable name to describe the job. + context.Job.Description = T.Get("job.restore"); + + try + { + await context.LogAsync("Started. The restore process has the following steps:"); + await context.LogAsync(" * Download backup"); + await context.LogAsync(" * Restore events and attachments."); + await context.LogAsync(" * Restore all objects like app, schemas and contents"); + await context.LogAsync(" * Complete the restore operation for all objects"); + await context.FlushAsync(); + + log.LogInformation("Backup with job id {backupId} with from URL '{url}' started.", context.Job.Id, state.Url); + + state.Reader = await DownloadAsync(context, state, ct); + + await state.Reader.CheckCompatibilityAsync(); + + using (Telemetry.Activities.StartActivity("ReadEvents")) + { + await ReadEventsAsync(context, state, ct); + } + + if (state.Context == null) + { + throw new BackupRestoreException("Backup has no event."); + } + + foreach (var handler in state.Handlers) + { + using (Telemetry.Activities.StartActivity($"{handler.GetType().Name}/RestoreAsync")) + { + await handler.RestoreAsync(state.Context, ct); + } + + await context.LogAsync($"Restored {handler.Name}"); + } + + foreach (var handler in state.Handlers) + { + using (Telemetry.Activities.StartActivity($"{handler.GetType().Name}/CompleteRestoreAsync")) + { + await handler.CompleteRestoreAsync(state.Context, state.NewAppName!); + } + + await context.LogAsync($"Completed {handler.Name}"); + } + + // Add the current user to the app, so that the admin can see it and verify integrity. + await AssignContributorAsync(context, state); + + await context.LogAsync("Completed, Yeah!"); + + log.LogInformation("Backup with job id {backupId} from URL '{url}' completed.", context.Job.Id, state.Url); + } + catch (Exception ex) + { + // Cleanup as soon as possible. + await CleanupAsync(state); + + var message = "Failed with internal error."; + + switch (ex) + { + case BackupRestoreException backupException: + message = backupException.Message; + break; + case FileNotFoundException fileNotFoundException: + message = fileNotFoundException.Message; + break; + } + + await context.LogAsync(message); + + log.LogError(ex, "Backup with job id {backupId} from URL '{url}' failed.", context.Job.Id, state.Url); + throw; + } + } + + private async Task AssignContributorAsync(JobRunContext run, State state) + { + if (run.Actor?.IsUser != true) + { + await run.LogAsync("Current user not assigned because restore was triggered by client."); + return; + } + + try + { + // Add the current user to the app, so that the admin can see it and verify integrity. + await PublishAsync(run, state, new AssignContributor + { + ContributorId = run.Actor.Identifier, + IgnoreActor = true, + IgnorePlans = true, + Role = Role.Owner + }); + + await run.LogAsync("Assigned current user."); + } + catch (DomainException ex) + { + await run.LogAsync($"Failed to assign contributor: {ex.Message}"); + } + } + + private Task PublishAsync(JobRunContext run, State state, AppCommand command) + { + command.Actor = run.Actor; + + if (command is IAppCommand appCommand) + { + appCommand.AppId = state.AppId; + } + + return commandBus.PublishAsync(command, default); + } + + private async Task CleanupAsync(State state) + { + if (state.AppId == null) + { + return; + } + + foreach (var handler in state.Handlers) + { + try + { + await handler.CleanupRestoreErrorAsync(state.AppId.Id); + } + catch (Exception ex) + { + log.LogError(ex, "Failed to clean up restore."); + } + } + } + + private async Task DownloadAsync(JobRunContext run, State state, + CancellationToken ct) + { + using (Telemetry.Activities.StartActivity("Download")) + { + await run.LogAsync("Downloading Backup"); + + var reader = await backupArchiveLocation.OpenReaderAsync(state.Url, run.Job.Id, ct); + + await run.LogAsync("Downloaded Backup"); + + return reader; + } + } + + private async Task ReadEventsAsync(JobRunContext run, State state, + CancellationToken ct) + { + // Run batch first, because it is cheaper as it has less items. + var events = HandleEventsAsync(run, state, ct).Batch(100, ct).Buffered(2, ct); + + var handled = 0; + + await Parallel.ForEachAsync(events, new ParallelOptions + { + CancellationToken = ct, + // The event store cannot insert events in parallel. + MaxDegreeOfParallelism = 1, + }, + async (batch, ct) => + { + var commits = + batch.Select(item => + EventCommit.Create( + item.Stream, + item.Offset, + item.Event, + eventFormatter)); + + await eventStore.AppendUnsafeAsync(commits, ct); + + // Just in case we use parallel inserts later. + Interlocked.Increment(ref handled); + + await run.LogAsync($"Reading {state.Reader.ReadEvents}/{handled} events and {state.Reader.ReadAttachments} attachments completed.", true); + }); + } + + private async IAsyncEnumerable<(string Stream, long Offset, Envelope Event)> HandleEventsAsync(JobRunContext run, State state, + [EnumeratorCancellation] CancellationToken ct) + { + var @events = state.Reader.ReadEventsAsync(eventStreamNames, eventFormatter, ct); + + await foreach (var (stream, @event) in events.WithCancellation(ct)) + { + var (newStream, handled) = await HandleEventAsync(run, state, stream, @event, ct); + + if (handled) + { + var offset = state.StreamMapper.GetStreamOffset(newStream); + + yield return (newStream, offset, @event); + } + } + } + + private async Task<(string StreamName, bool Handled)> HandleEventAsync(JobRunContext run, State state, string stream, Envelope @event, + CancellationToken ct = default) + { + if (@event.Payload is AppCreated appCreated) + { + var previousAppId = appCreated.AppId.Id; + + if (!string.IsNullOrWhiteSpace(state.NewAppName)) + { + appCreated.Name = state.NewAppName; + + state.AppId = NamedId.Of(DomainId.NewGuid(), state.NewAppName); + } + else + { + state.AppId = NamedId.Of(DomainId.NewGuid(), appCreated.Name); + } + + await CreateContextAsync(run, state, previousAppId, ct); + + state.StreamMapper = new StreamMapper(state.Context); + } + + if (@event.Payload is SquidexEvent { Actor: { } } squidexEvent) + { + if (state.Context.UserMapping.TryMap(squidexEvent.Actor, out var newUser)) + { + squidexEvent.Actor = newUser; + } + } + + if (@event.Payload is AppEvent appEvent) + { + appEvent.AppId = state.AppId; + } + + var (newStream, id) = state.StreamMapper.Map(stream); + + @event.SetAggregateId(id); + @event.SetRestored(); + + foreach (var handler in state.Handlers) + { + if (!await handler.RestoreEventAsync(@event, state.Context, ct)) + { + return (newStream, false); + } + } + + return (newStream, true); + } + + private async Task CreateContextAsync(JobRunContext run, State state, DomainId previousAppId, + CancellationToken ct) + { + var userMapping = new UserMapping(run.Actor); + + using (Telemetry.Activities.StartActivity("CreateUsers")) + { + await run.LogAsync("Creating Users"); + + await userMapping.RestoreAsync(state.Reader, userResolver, ct); + + await run.LogAsync("Created Users"); + } + + state.Context = new RestoreContext(state.AppId.Id, userMapping, state.Reader, previousAppId); + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Backup/RestoreProcessor.Run.cs b/backend/src/Squidex.Domain.Apps.Entities/Backup/RestoreProcessor.Run.cs deleted file mode 100644 index 2f066da070..0000000000 --- a/backend/src/Squidex.Domain.Apps.Entities/Backup/RestoreProcessor.Run.cs +++ /dev/null @@ -1,57 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschraenkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using Squidex.Domain.Apps.Entities.Backup.State; - -namespace Squidex.Domain.Apps.Entities.Backup; - -public sealed partial class RestoreProcessor -{ - // Use a run to store all state that is necessary for a single run. - private sealed class Run : IDisposable - { - private readonly CancellationTokenSource cancellationSource = new CancellationTokenSource(); - private readonly CancellationTokenSource cancellationLinked; - - public IEnumerable Handlers { get; init; } - - public IBackupReader Reader { get; set; } - - public RestoreJob Job { get; init; } - - public RestoreContext Context { get; set; } - - public StreamMapper StreamMapper { get; set; } - - public CancellationToken CancellationToken => cancellationLinked.Token; - - public Run(CancellationToken ct) - { - cancellationLinked = CancellationTokenSource.CreateLinkedTokenSource(ct, cancellationSource.Token); - } - - public void Dispose() - { - Reader?.Dispose(); - - cancellationSource.Dispose(); - cancellationLinked.Dispose(); - } - - public void Cancel() - { - try - { - cancellationSource.Cancel(); - } - catch (ObjectDisposedException) - { - // Cancellation token might have been disposed, if the run is completed. - } - } - } -} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Backup/RestoreProcessor.cs b/backend/src/Squidex.Domain.Apps.Entities/Backup/RestoreProcessor.cs deleted file mode 100644 index acaa48957d..0000000000 --- a/backend/src/Squidex.Domain.Apps.Entities/Backup/RestoreProcessor.cs +++ /dev/null @@ -1,445 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschraenkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System.Runtime.CompilerServices; -using Microsoft.Extensions.Logging; -using NodaTime; -using Squidex.Domain.Apps.Core.Apps; -using Squidex.Domain.Apps.Entities.Apps.Commands; -using Squidex.Domain.Apps.Entities.Backup.State; -using Squidex.Domain.Apps.Events; -using Squidex.Domain.Apps.Events.Apps; -using Squidex.Infrastructure; -using Squidex.Infrastructure.Commands; -using Squidex.Infrastructure.EventSourcing; -using Squidex.Infrastructure.States; -using Squidex.Infrastructure.Tasks; -using Squidex.Infrastructure.Translations; -using Squidex.Shared.Users; - -namespace Squidex.Domain.Apps.Entities.Backup; - -public sealed partial class RestoreProcessor -{ - private readonly IBackupArchiveLocation backupArchiveLocation; - private readonly IBackupHandlerFactory backupHandlerFactory; - private readonly ICommandBus commandBus; - private readonly IEventFormatter eventFormatter; - private readonly IEventStore eventStore; - private readonly IEventStreamNames eventStreamNames; - private readonly IUserResolver userResolver; - private readonly ILogger log; - private readonly ReentrantScheduler scheduler = new ReentrantScheduler(1); - private readonly SimpleState state; - private Run? currentRun; - - public IClock Clock { get; set; } = SystemClock.Instance; - - public RestoreProcessor( - IBackupArchiveLocation backupArchiveLocation, - IBackupHandlerFactory backupHandlerFactory, - ICommandBus commandBus, - IEventFormatter eventFormatter, - IEventStore eventStore, - IEventStreamNames eventStreamNames, - IPersistenceFactory persistenceFactory, - IUserResolver userResolver, - ILogger log) - { - this.backupArchiveLocation = backupArchiveLocation; - this.backupHandlerFactory = backupHandlerFactory; - this.commandBus = commandBus; - this.eventFormatter = eventFormatter; - this.eventStore = eventStore; - this.eventStreamNames = eventStreamNames; - this.userResolver = userResolver; - this.log = log; - - // Enable locking for the parallel operations that might write stuff. - state = new SimpleState(persistenceFactory, GetType(), "Default", true); - } - - public async Task LoadAsync( - CancellationToken ct) - { - await state.LoadAsync(ct); - - if (state.Value.Job?.Status == JobStatus.Started) - { - state.Value.Job.Status = JobStatus.Failed; - - await state.WriteAsync(ct); - } - } - - public Task RestoreAsync(Uri url, RefToken actor, string? newAppName, - CancellationToken ct) - { - Guard.NotNull(url); - Guard.NotNull(actor); - - if (!string.IsNullOrWhiteSpace(newAppName)) - { - Guard.ValidSlug(newAppName); - } - - return scheduler.ScheduleAsync(async ct => - { - if (currentRun != null) - { - throw new DomainException(T.Get("backups.restoreRunning")); - } - - state.Value.Job?.EnsureCanStart(); - - // Set the current run first to indicate that we are running a rule at the moment. - var run = currentRun = new Run(ct) - { - Job = new RestoreJob - { - Id = DomainId.NewGuid(), - NewAppName = newAppName, - Actor = actor, - Started = Clock.GetCurrentInstant(), - Status = JobStatus.Started, - Url = url - }, - Handlers = backupHandlerFactory.CreateMany() - }; - - state.Value.Job = run.Job; - try - { - await ProcessAsync(run, run.CancellationToken); - } - finally - { - // Unset the run to indicate that we are done. - currentRun.Dispose(); - currentRun = null; - } - }, ct); - } - - private async Task ProcessAsync(Run run, - CancellationToken ct) - { - using (Telemetry.Activities.StartActivity("RestoreBackup")) - { - try - { - await state.WriteAsync(run.CancellationToken); - - await LogAsync(run, "Started. The restore process has the following steps:"); - await LogAsync(run, " * Download backup"); - await LogAsync(run, " * Restore events and attachments."); - await LogAsync(run, " * Restore all objects like app, schemas and contents"); - await LogAsync(run, " * Complete the restore operation for all objects"); - await LogFlushAsync(run); - - log.LogInformation("Backup with job id {backupId} with from URL '{url}' started.", run.Job.Id, run.Job.Url); - - run.Reader = await DownloadAsync(run, ct); - - await run.Reader.CheckCompatibilityAsync(); - - using (Telemetry.Activities.StartActivity("ReadEvents")) - { - await ReadEventsAsync(run, ct); - } - - if (run.Context == null) - { - throw new BackupRestoreException("Backup has no event."); - } - - foreach (var handler in run.Handlers) - { - using (Telemetry.Activities.StartActivity($"{handler.GetType().Name}/RestoreAsync")) - { - await handler.RestoreAsync(run.Context, ct); - } - - await LogAsync(run, $"Restored {handler.Name}"); - } - - foreach (var handler in run.Handlers) - { - using (Telemetry.Activities.StartActivity($"{handler.GetType().Name}/CompleteRestoreAsync")) - { - await handler.CompleteRestoreAsync(run.Context, run.Job.NewAppName!); - } - - await LogAsync(run, $"Completed {handler.Name}"); - } - - // Add the current user to the app, so that the admin can see it and verify integrity. - await AssignContributorAsync(run); - - await SetStatusAsync(run, JobStatus.Completed, "Completed, Yeah!"); - - log.LogInformation("Backup with job id {backupId} from URL '{url}' completed.", run.Job.Id, run.Job.Url); - } - catch (Exception ex) - { - // Cleanup as soon as possible. - await CleanupAsync(run); - - var message = "Failed with internal error."; - - switch (ex) - { - case BackupRestoreException backupException: - message = backupException.Message; - break; - case FileNotFoundException fileNotFoundException: - message = fileNotFoundException.Message; - break; - } - - await SetStatusAsync(run, JobStatus.Failed, message); - - log.LogError(ex, "Backup with job id {backupId} from URL '{url}' failed.", run.Job.Id, run.Job.Url); - } - } - } - - private async Task AssignContributorAsync(Run run) - { - if (run.Job.Actor?.IsUser != true) - { - await LogAsync(run, "Current user not assigned because restore was triggered by client."); - return; - } - - try - { - // Add the current user to the app, so that the admin can see it and verify integrity. - await PublishAsync(run, new AssignContributor - { - ContributorId = run.Job.Actor.Identifier, - IgnoreActor = true, - IgnorePlans = true, - Role = Role.Owner - }); - - await LogAsync(run, "Assigned current user."); - } - catch (DomainException ex) - { - await LogAsync(run, $"Failed to assign contributor: {ex.Message}"); - } - } - - private Task PublishAsync(Run run, AppCommand command) - { - command.Actor = run.Job.Actor; - - if (command is IAppCommand appCommand) - { - appCommand.AppId = run.Job.AppId; - } - - return commandBus.PublishAsync(command, default); - } - - private async Task CleanupAsync(Run run) - { - if (run.Job.AppId == null) - { - return; - } - - foreach (var handler in run.Handlers) - { - try - { - await handler.CleanupRestoreErrorAsync(run.Job.AppId.Id); - } - catch (Exception ex) - { - log.LogError(ex, "Failed to clean up restore."); - } - } - } - - private async Task DownloadAsync(Run run, - CancellationToken ct) - { - using (Telemetry.Activities.StartActivity("Download")) - { - await LogAsync(run, "Downloading Backup"); - - var reader = await backupArchiveLocation.OpenReaderAsync(run.Job.Url, run.Job.Id, ct); - - await LogAsync(run, "Downloaded Backup"); - - return reader; - } - } - - private async Task ReadEventsAsync(Run run, - CancellationToken ct) - { - // Run batch first, because it is cheaper as it has less items. - var events = HandleEventsAsync(run, ct).Batch(100, ct).Buffered(2, ct); - - var handled = 0; - - await Parallel.ForEachAsync(events, new ParallelOptions - { - CancellationToken = ct, - // The event store cannot insert events in parallel. - MaxDegreeOfParallelism = 1, - }, - async (batch, ct) => - { - var commits = - batch.Select(item => - EventCommit.Create( - item.Stream, - item.Offset, - item.Event, - eventFormatter)); - - await eventStore.AppendUnsafeAsync(commits, ct); - - // Just in case we use parallel inserts later. - Interlocked.Increment(ref handled); - - await LogAsync(run, $"Reading {run.Reader.ReadEvents}/{handled} events and {run.Reader.ReadAttachments} attachments completed.", true); - }); - } - - private async IAsyncEnumerable<(string Stream, long Offset, Envelope Event)> HandleEventsAsync(Run run, - [EnumeratorCancellation] CancellationToken ct) - { - var @events = run.Reader.ReadEventsAsync(eventStreamNames, eventFormatter, ct); - - await foreach (var (stream, @event) in events.WithCancellation(ct)) - { - var (newStream, handled) = await HandleEventAsync(run, stream, @event, ct); - - if (handled) - { - var offset = run.StreamMapper.GetStreamOffset(newStream); - - yield return (newStream, offset, @event); - } - } - } - - private async Task<(string StreamName, bool Handled)> HandleEventAsync(Run run, string stream, Envelope @event, - CancellationToken ct = default) - { - if (@event.Payload is AppCreated appCreated) - { - var previousAppId = appCreated.AppId.Id; - - if (!string.IsNullOrWhiteSpace(run.Job.NewAppName)) - { - appCreated.Name = run.Job.NewAppName; - - run.Job.AppId = NamedId.Of(DomainId.NewGuid(), run.Job.NewAppName); - } - else - { - run.Job.AppId = NamedId.Of(DomainId.NewGuid(), appCreated.Name); - } - - await CreateContextAsync(run, previousAppId, ct); - - run.StreamMapper = new StreamMapper(run.Context); - } - - if (@event.Payload is SquidexEvent { Actor: { } } squidexEvent) - { - if (run.Context.UserMapping.TryMap(squidexEvent.Actor, out var newUser)) - { - squidexEvent.Actor = newUser; - } - } - - if (@event.Payload is AppEvent appEvent) - { - appEvent.AppId = run.Job.AppId; - } - - var (newStream, id) = run.StreamMapper.Map(stream); - - @event.SetAggregateId(id); - @event.SetRestored(); - - foreach (var handler in run.Handlers) - { - if (!await handler.RestoreEventAsync(@event, run.Context, ct)) - { - return (newStream, false); - } - } - - return (newStream, true); - } - - private async Task CreateContextAsync(Run run, DomainId previousAppId, - CancellationToken ct) - { - var userMapping = new UserMapping(run.Job.Actor); - - using (Telemetry.Activities.StartActivity("CreateUsers")) - { - await LogAsync(run, "Creating Users"); - - await userMapping.RestoreAsync(run.Reader, userResolver, ct); - - await LogAsync(run, "Created Users"); - } - - run.Context = new RestoreContext(run.Job.AppId.Id, userMapping, run.Reader, previousAppId); - } - - private Task SetStatusAsync(Run run, JobStatus status, string message) - { - var now = Clock.GetCurrentInstant(); - - run.Job.Status = status; - - if (status == JobStatus.Failed || status == JobStatus.Completed) - { - run.Job.Stopped = now; - } - else if (status == JobStatus.Started) - { - run.Job.Started = now; - } - - run.Job.Log.Add($"{now}: {message}"); - - return state.WriteAsync(default); - } - - private Task LogAsync(Run run, string message, bool replace = false) - { - var now = Clock.GetCurrentInstant(); - - if (replace && run.Job.Log.Count > 0) - { - run.Job.Log[^1] = $"{now}: {message}"; - } - else - { - run.Job.Log.Add($"{now}: {message}"); - } - - return state.WriteAsync(100, run.CancellationToken); - } - - private Task LogFlushAsync(Run run) - { - return state.WriteAsync(run.CancellationToken); - } -} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Backup/State/BackupState.cs b/backend/src/Squidex.Domain.Apps.Entities/Backup/State/BackupState.cs deleted file mode 100644 index 9e9a648d33..0000000000 --- a/backend/src/Squidex.Domain.Apps.Entities/Backup/State/BackupState.cs +++ /dev/null @@ -1,29 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschraenkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using Squidex.Infrastructure; -using Squidex.Infrastructure.Translations; - -namespace Squidex.Domain.Apps.Entities.Backup.State; - -public sealed class BackupState -{ - public List Jobs { get; set; } = []; - - public void EnsureCanStart() - { - if (Jobs.Exists(x => x.Status == JobStatus.Started)) - { - throw new DomainException(T.Get("backups.alreadyRunning")); - } - - if (Jobs.Count >= 10) - { - throw new DomainException(T.Get("backups.maxReached", new { max = 10 })); - } - } -} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Backup/State/RestoreJob.cs b/backend/src/Squidex.Domain.Apps.Entities/Backup/State/RestoreJob.cs deleted file mode 100644 index 179e05186c..0000000000 --- a/backend/src/Squidex.Domain.Apps.Entities/Backup/State/RestoreJob.cs +++ /dev/null @@ -1,43 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschraenkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using NodaTime; -using Squidex.Infrastructure; -using Squidex.Infrastructure.Translations; - -namespace Squidex.Domain.Apps.Entities.Backup.State; - -public sealed class RestoreJob : IRestoreJob -{ - public string AppName { get; set; } - - public DomainId Id { get; set; } - - public NamedId AppId { get; set; } - - public RefToken Actor { get; set; } - - public Uri Url { get; set; } - - public Instant Started { get; set; } - - public Instant? Stopped { get; set; } - - public List Log { get; set; } = []; - - public JobStatus Status { get; set; } - - public string? NewAppName { get; set; } - - public void EnsureCanStart() - { - if (Status == JobStatus.Started) - { - throw new DomainException(T.Get("backups.restoreRunning")); - } - } -} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Jobs/DefaultJobService.cs b/backend/src/Squidex.Domain.Apps.Entities/Jobs/DefaultJobService.cs new file mode 100644 index 0000000000..2ea883fb97 --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Entities/Jobs/DefaultJobService.cs @@ -0,0 +1,93 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Domain.Apps.Core.Apps; +using Squidex.Infrastructure; +using Squidex.Infrastructure.States; +using Squidex.Infrastructure.Translations; +using Squidex.Messaging; + +namespace Squidex.Domain.Apps.Entities.Jobs; + +public sealed class DefaultJobService : IJobService, IDeleter +{ + private readonly IMessageBus messaging; + private readonly IEnumerable runners; + private readonly IPersistenceFactory persistence; + + public DefaultJobService(IMessageBus messaging, IEnumerable runners, IPersistenceFactory persistence) + { + this.messaging = messaging; + this.runners = runners; + this.persistence = persistence; + } + + Task IDeleter.DeleteAppAsync(App app, CancellationToken ct) + { + return messaging.PublishAsync(new JobClear(app.Id), null, ct); + } + + public async Task DownloadAsync(Job job, Stream stream, + CancellationToken ct = default) + { + Guard.NotNull(job); + Guard.NotNull(stream); + + if (job.File == null || job.Status != JobStatus.Completed) + { + throw new InvalidOperationException("Invalid job."); + } + + var runner = runners.FirstOrDefault(x => x.Name == job.TaskName) ?? + throw new InvalidOperationException("Invalid job."); + + await runner.DownloadAsync(job, stream, ct); + } + + public async Task StartAsync(DomainId ownerId, JobRequest request, + CancellationToken ct = default) + { + var runner = runners.FirstOrDefault(x => x.Name == request.TaskName) ?? + throw new DomainException(T.Get("jobs.invalidTaskName")); + + var state = await GetStateAsync(ownerId, ct); + + state.EnsureCanStart(runner); + + await messaging.PublishAsync(new JobStart(ownerId, request), null, ct); + } + + public Task CancelAsync(DomainId ownerId, string? taskName = null, + CancellationToken ct = default) + { + return messaging.PublishAsync(new JobCancel(ownerId, taskName), null, ct); + } + + public Task DeleteJobAsync(DomainId ownerId, DomainId jobId, + CancellationToken ct = default) + { + return messaging.PublishAsync(new JobDelete(ownerId, jobId), null, ct); + } + + public async Task> GetJobsAsync(DomainId ownerId, + CancellationToken ct = default) + { + var state = await GetStateAsync(ownerId, ct); + + return state.Jobs; + } + + private async Task GetStateAsync(DomainId ownerId, + CancellationToken ct = default) + { + var state = new SimpleState(persistence, typeof(JobProcessor), ownerId); + + await state.LoadAsync(ct); + + return state.Value; + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Jobs/IJobRunner.cs b/backend/src/Squidex.Domain.Apps.Entities/Jobs/IJobRunner.cs new file mode 100644 index 0000000000..435b1aa517 --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Entities/Jobs/IJobRunner.cs @@ -0,0 +1,31 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +namespace Squidex.Domain.Apps.Entities.Jobs; + +public interface IJobRunner +{ + static string TaskName { get; } + + string Name { get; } + + int MaxJobs => 3; + + Task RunAsync(JobRunContext context, + CancellationToken ct); + + Task DownloadAsync(Job job, Stream stream, + CancellationToken ct) + { + return Task.CompletedTask; + } + + Task CleanupAsync(Job job) + { + return Task.CompletedTask; + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Backup/IBackupService.cs b/backend/src/Squidex.Domain.Apps.Entities/Jobs/IJobService.cs similarity index 56% rename from backend/src/Squidex.Domain.Apps.Entities/Backup/IBackupService.cs rename to backend/src/Squidex.Domain.Apps.Entities/Jobs/IJobService.cs index 866114a442..42ea8aadc4 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Backup/IBackupService.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Jobs/IJobService.cs @@ -7,25 +7,22 @@ using Squidex.Infrastructure; -namespace Squidex.Domain.Apps.Entities.Backup; +namespace Squidex.Domain.Apps.Entities.Jobs; -public interface IBackupService +public interface IJobService { - Task StartBackupAsync(DomainId appId, RefToken actor, + Task StartAsync(DomainId ownerId, JobRequest request, CancellationToken ct = default); - Task StartRestoreAsync(RefToken actor, Uri url, string? newAppName, + Task> GetJobsAsync(DomainId ownerId, CancellationToken ct = default); - Task GetRestoreAsync( + Task CancelAsync(DomainId ownerId, string? taskName = null, CancellationToken ct = default); - Task> GetBackupsAsync(DomainId appId, + Task DeleteJobAsync(DomainId ownerId, DomainId jobId, CancellationToken ct = default); - Task GetBackupAsync(DomainId appId, DomainId backupId, - CancellationToken ct = default); - - Task DeleteBackupAsync(DomainId appId, DomainId backupId, + Task DownloadAsync(Job job, Stream stream, CancellationToken ct = default); } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Jobs/Job.cs b/backend/src/Squidex.Domain.Apps.Entities/Jobs/Job.cs new file mode 100644 index 0000000000..4dc106b1ee --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Entities/Jobs/Job.cs @@ -0,0 +1,33 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using NodaTime; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Collections; + +namespace Squidex.Domain.Apps.Entities.Jobs; + +public sealed class Job +{ + public DomainId Id { get; init; } + + public Instant Started { get; set; } + + public Instant? Stopped { get; set; } + + public string TaskName { get; init; } + + public string Description { get; set; } + + public JobFile? File { get; set; } + + public ReadonlyDictionary Arguments { get; init; } + + public List Log { get; set; } = []; + + public JobStatus Status { get; set; } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/Messages.cs b/backend/src/Squidex.Domain.Apps.Entities/Jobs/JobFile.cs similarity index 60% rename from backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/Messages.cs rename to backend/src/Squidex.Domain.Apps.Entities/Jobs/JobFile.cs index 7f5575708e..0ac703e6de 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/Messages.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Jobs/JobFile.cs @@ -5,13 +5,10 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using Squidex.Infrastructure; - -#pragma warning disable MA0048 // File name must match type name #pragma warning disable SA1313 // Parameter names should begin with lower-case letter -namespace Squidex.Domain.Apps.Entities.Rules.Runner; - -public sealed record RuleRunnerRun(DomainId AppId, DomainId RuleId, bool FromSnapshots); +namespace Squidex.Domain.Apps.Entities.Jobs; -public sealed record RuleRunnerCancel(DomainId AppId); +public record JobFile(string Name, string MimeType) +{ +} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Backup/IRestoreJob.cs b/backend/src/Squidex.Domain.Apps.Entities/Jobs/JobLogMessage.cs similarity index 63% rename from backend/src/Squidex.Domain.Apps.Entities/Backup/IRestoreJob.cs rename to backend/src/Squidex.Domain.Apps.Entities/Jobs/JobLogMessage.cs index 95029c8530..0ae330e704 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Backup/IRestoreJob.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Jobs/JobLogMessage.cs @@ -7,17 +7,8 @@ using NodaTime; -namespace Squidex.Domain.Apps.Entities.Backup; +#pragma warning disable SA1313 // Parameter names should begin with lower-case letter -public interface IRestoreJob -{ - Uri Url { get; } +namespace Squidex.Domain.Apps.Entities.Jobs; - Instant Started { get; } - - Instant? Stopped { get; } - - List Log { get; } - - JobStatus Status { get; } -} +public record struct JobLogMessage(Instant Timestamp, string Message); diff --git a/backend/src/Squidex.Domain.Apps.Entities/Jobs/JobProcessor.cs b/backend/src/Squidex.Domain.Apps.Entities/Jobs/JobProcessor.cs new file mode 100644 index 0000000000..291632e30a --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Entities/Jobs/JobProcessor.cs @@ -0,0 +1,207 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Microsoft.Extensions.Logging; +using NodaTime; +using Squidex.Caching; +using Squidex.Infrastructure; +using Squidex.Infrastructure.States; +using Squidex.Infrastructure.Tasks; +using Squidex.Infrastructure.Translations; + +namespace Squidex.Domain.Apps.Entities.Jobs; + +public sealed class JobProcessor +{ + private readonly DomainId ownerId; + private readonly IEnumerable runners; + private readonly ILocalCache localCache; + private readonly ILogger log; + private readonly SimpleState state; + private readonly ReentrantScheduler scheduler = new ReentrantScheduler(1); + private JobRunContext? currentRun; + + public IClock Clock { get; init; } = SystemClock.Instance; + + public JobProcessor(DomainId ownerId, + IEnumerable runners, + ILocalCache localCache, + IPersistenceFactory persistenceFactory, + ILogger log) + { + this.ownerId = ownerId; + this.runners = runners; + this.localCache = localCache; + this.log = log; + + state = new SimpleState(persistenceFactory, GetType(), ownerId); + } + + public async Task LoadAsync( + CancellationToken ct) + { + await state.LoadAsync(ct); + + if (state.Value.Jobs.RemoveAll(x => x.Stopped == null) > 0) + { + // This should actually never happen, so we log with warning. + log.LogWarning("Removed unfinished backups for owner {ownerId} after start.", ownerId); + + await state.WriteAsync(ct); + } + } + + public Task DeleteAsync(DomainId jobId) + { + return scheduler.ScheduleAsync(async _ => + { + log.LogInformation("Clearing jobs for owner {ownerId}.", ownerId); + + var job = state.Value.Jobs.Find(x => x.Id == jobId); + + if (job == null) + { + return; + } + + var runner = runners.FirstOrDefault(x => x.Name == job.TaskName); + + if (runner != null) + { + await runner.CleanupAsync(job); + } + + await state.UpdateAsync(state => state.Jobs.RemoveAll(x => x.Id == jobId) > 0, ct: default); + }, default); + } + + public Task ClearAsync() + { + return scheduler.ScheduleAsync(async _ => + { + log.LogInformation("Clearing jobs for owner {ownerId}.", ownerId); + + foreach (var job in state.Value.Jobs) + { + var runner = runners.FirstOrDefault(x => x.Name == job.TaskName); + + if (runner != null) + { + await runner.CleanupAsync(job); + } + } + + await state.ClearAsync(default); + }, default); + } + + public Task CancelAsync(string? taskName) + { + // Ensure that only one thread is accessing the current state at a time. + return scheduler.Schedule(() => + { + if (taskName == null || currentRun?.Job.TaskName == taskName) + { + currentRun?.Cancel(); + } + }); + } + + public Task RunAsync(JobRequest request, + CancellationToken ct) + { + return scheduler.ScheduleAsync(async ct => + { + if (currentRun != null) + { + throw new DomainException(T.Get("jobs.alreadyRunning")); + } + + var runner = runners.FirstOrDefault(x => x.Name == request.TaskName) ?? + throw new DomainException(T.Get("jobs.invalidTaskName")); + + state.Value.EnsureCanStart(runner); + + // Set the current run first to indicate that we are running a rule at the moment. + var context = currentRun = new JobRunContext(state, Clock, ct) + { + Actor = request.Actor, + Job = new Job + { + Id = DomainId.NewGuid(), + Arguments = request.Arguments, + Description = request.TaskName, + Started = default, + Status = JobStatus.Created, + TaskName = request.TaskName + }, + OwnerId = ownerId + }; + + log.LogInformation("Starting new backup with backup id '{backupId}' for owner {ownerId}.", context.Job.Id, ownerId); + + state.Value.Jobs.Insert(0, context.Job); + try + { + await ProcessAsync(context, runner, context.CancellationToken); + } + finally + { + // Unset the run to indicate that we are done. + currentRun.Dispose(); + currentRun = null; + } + }, ct); + } + + private async Task ProcessAsync(JobRunContext context, IJobRunner runner, + CancellationToken ct) + { + try + { + await SetStatusAsync(context, JobStatus.Started); + + using (localCache.StartContext()) + { + await runner.RunAsync(context, ct); + } + + await SetStatusAsync(context, JobStatus.Completed); + } + catch (OperationCanceledException) + { + await SetStatusAsync(context, JobStatus.Cancelled); + } + catch (Exception ex) + { + log.LogError(ex, "Failed to run job with ID {jobId}.", context.Job.Id); + + await SetStatusAsync(context, JobStatus.Failed); + } + } + + private Task SetStatusAsync(JobRunContext context, JobStatus status) + { + var now = Clock.GetCurrentInstant(); + + return state.UpdateAsync(_ => + { + context.Job.Status = status; + + if (status == JobStatus.Started) + { + context.Job.Started = now; + } + else if (status != JobStatus.Created) + { + context.Job.Stopped = now; + } + + return true; + }, ct: default); + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Jobs/JobRequest.cs b/backend/src/Squidex.Domain.Apps.Entities/Jobs/JobRequest.cs new file mode 100644 index 0000000000..9a71445a41 --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Entities/Jobs/JobRequest.cs @@ -0,0 +1,23 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Infrastructure; +using Squidex.Infrastructure.Collections; + +#pragma warning disable SA1313 // Parameter names should begin with lower-case letter + +namespace Squidex.Domain.Apps.Entities.Jobs; + +public record struct JobRequest(RefToken Actor, string TaskName, ReadonlyDictionary Arguments) +{ + public static JobRequest Create(RefToken actor, string taskName, Dictionary? arguments = null) + { + var args = arguments?.ToReadonlyDictionary() ?? ReadonlyDictionary.Empty(); + + return new JobRequest(actor, taskName, args); + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Jobs/JobRunContext.cs b/backend/src/Squidex.Domain.Apps.Entities/Jobs/JobRunContext.cs new file mode 100644 index 0000000000..6f7fd1dd80 --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Entities/Jobs/JobRunContext.cs @@ -0,0 +1,75 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using NodaTime; +using Squidex.Infrastructure; +using Squidex.Infrastructure.States; + +namespace Squidex.Domain.Apps.Entities.Jobs; + +public sealed class JobRunContext : IDisposable +{ + private readonly CancellationTokenSource cancellationSource = new CancellationTokenSource(); + private readonly CancellationTokenSource cancellationLinked; + private readonly SimpleState state; + private readonly IClock clock; + + required public RefToken Actor { get; init; } + + required public Job Job { get; init; } + + required public DomainId OwnerId { get; init; } + + public CancellationToken CancellationToken => cancellationLinked.Token; + + public JobRunContext(SimpleState state, IClock clock, CancellationToken ct) + { + this.state = state; + this.clock = clock; + + cancellationLinked = CancellationTokenSource.CreateLinkedTokenSource(ct, cancellationSource.Token); + } + + public void Dispose() + { + cancellationSource.Dispose(); + cancellationLinked.Dispose(); + } + + public Task LogAsync(string message, bool replace = false) + { + var item = new JobLogMessage(clock.GetCurrentInstant(), message); + + if (replace && Job.Log.Count > 0) + { + Job.Log[^1] = item; + } + else + { + Job.Log.Add(item); + } + + return state.WriteAsync(100, CancellationToken); + } + + public Task FlushAsync() + { + return state.WriteAsync(CancellationToken); + } + + public void Cancel() + { + try + { + cancellationSource.Cancel(); + } + catch (ObjectDisposedException) + { + // Cancellation token might have been disposed, if the run is completed. + } + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Backup/JobStatus.cs b/backend/src/Squidex.Domain.Apps.Entities/Jobs/JobStatus.cs similarity index 88% rename from backend/src/Squidex.Domain.Apps.Entities/Backup/JobStatus.cs rename to backend/src/Squidex.Domain.Apps.Entities/Jobs/JobStatus.cs index 6ab7109dd0..4a726cf5dd 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Backup/JobStatus.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Jobs/JobStatus.cs @@ -5,12 +5,13 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -namespace Squidex.Domain.Apps.Entities.Backup; +namespace Squidex.Domain.Apps.Entities.Jobs; public enum JobStatus { Created, Started, Completed, + Cancelled, Failed } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Jobs/JobWorker.cs b/backend/src/Squidex.Domain.Apps.Entities/Jobs/JobWorker.cs new file mode 100644 index 0000000000..246c280045 --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Entities/Jobs/JobWorker.cs @@ -0,0 +1,79 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Microsoft.Extensions.DependencyInjection; +using Squidex.Infrastructure; +using Squidex.Messaging; + +namespace Squidex.Domain.Apps.Entities.Jobs; + +public sealed class JobWorker : + IMessageHandler, + IMessageHandler, + IMessageHandler, + IMessageHandler +{ + private readonly Dictionary> processors = []; + private readonly Func processorFactory; + + public JobWorker(IServiceProvider serviceProvider) + { + var objectFactory = ActivatorUtilities.CreateFactory(typeof(JobProcessor), [typeof(DomainId)]); + + processorFactory = key => + { + return (JobProcessor)objectFactory(serviceProvider, new object[] { key }); + }; + } + + public async Task HandleAsync(JobStart message, + CancellationToken ct) + { + var processor = await GetJobProcessorAsync(message.OwnerId); + + await processor.RunAsync(message.Request, ct); + } + + public async Task HandleAsync(JobCancel message, + CancellationToken ct) + { + var processor = await GetJobProcessorAsync(message.OwnerId); + + await processor.CancelAsync(message.TaskName); + } + + public async Task HandleAsync(JobDelete message, + CancellationToken ct) + { + var processor = await GetJobProcessorAsync(message.OwnerId); + + await processor.DeleteAsync(message.JobId); + } + + public async Task HandleAsync(JobClear message, + CancellationToken ct) + { + var processor = await GetJobProcessorAsync(message.OwnerId); + + await processor.ClearAsync(); + } + + private Task GetJobProcessorAsync(DomainId appId) + { + lock (processors) + { + return processors.GetOrAdd(appId, async key => + { + var processor = processorFactory(key); + + await processor.LoadAsync(default); + + return processor; + }); + } + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Jobs/JobsState.cs b/backend/src/Squidex.Domain.Apps.Entities/Jobs/JobsState.cs new file mode 100644 index 0000000000..c75e1a030f --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Entities/Jobs/JobsState.cs @@ -0,0 +1,38 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Infrastructure; +using Squidex.Infrastructure.Translations; + +namespace Squidex.Domain.Apps.Entities.Jobs; + +public sealed class JobsState +{ + public List Jobs { get; set; } = []; + + public void EnsureCanStart(IJobRunner runner) + { + if (Jobs.Exists(x => x.Status == JobStatus.Started)) + { + throw new DomainException(T.Get("jobs.alreadyRunning")); + } + + var max = runner.MaxJobs; + + var jobs = Jobs.Where(x => x.TaskName == runner.Name && x.File == null).Skip(max - 1).ToList(); + + foreach (var job in jobs) + { + Jobs.Remove(job); + } + + if (Jobs.Count(x => x.TaskName == runner.Name) >= max) + { + throw new DomainException(T.Get("jobs.maxReached", new { max })); + } + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Backup/Messages.cs b/backend/src/Squidex.Domain.Apps.Entities/Jobs/Messages.cs similarity index 55% rename from backend/src/Squidex.Domain.Apps.Entities/Backup/Messages.cs rename to backend/src/Squidex.Domain.Apps.Entities/Jobs/Messages.cs index df7537bcf7..3a62f3bc21 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Backup/Messages.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Jobs/Messages.cs @@ -10,12 +10,14 @@ #pragma warning disable MA0048 // File name must match type name #pragma warning disable SA1313 // Parameter names should begin with lower-case letter -namespace Squidex.Domain.Apps.Entities.Backup; +namespace Squidex.Domain.Apps.Entities.Jobs; -public sealed record BackupRestore(RefToken Actor, Uri Url, string? NewAppName = null); +public sealed record JobStart(DomainId OwnerId, JobRequest Request) : JobMessage(OwnerId); -public sealed record BackupStart(DomainId AppId, RefToken Actor); +public sealed record JobCancel(DomainId OwnerId, string? TaskName) : JobMessage(OwnerId); -public sealed record BackupDelete(DomainId AppId, DomainId Id); +public sealed record JobDelete(DomainId OwnerId, DomainId JobId) : JobMessage(OwnerId); -public sealed record BackupClear(DomainId AppId); +public sealed record JobClear(DomainId OwnerId) : JobMessage(OwnerId); + +public abstract record JobMessage(DomainId OwnerId); diff --git a/backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/DefaultRuleRunnerService.cs b/backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/DefaultRuleRunnerService.cs index 76a447515c..ffdd0039b4 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/DefaultRuleRunnerService.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/DefaultRuleRunnerService.cs @@ -9,36 +9,32 @@ using Squidex.Domain.Apps.Core.HandleRules; using Squidex.Domain.Apps.Core.Rules; using Squidex.Domain.Apps.Core.Rules.Triggers; +using Squidex.Domain.Apps.Entities.Jobs; using Squidex.Domain.Apps.Events; using Squidex.Infrastructure; using Squidex.Infrastructure.Collections; using Squidex.Infrastructure.EventSourcing; -using Squidex.Infrastructure.States; -using Squidex.Messaging; namespace Squidex.Domain.Apps.Entities.Rules.Runner; public sealed class DefaultRuleRunnerService : IRuleRunnerService { private const int MaxSimulatedEvents = 100; - private readonly IPersistenceFactory persistenceFactory; + private readonly IJobService jobService; private readonly IEventFormatter eventFormatter; private readonly IEventStore eventStore; private readonly IRuleService ruleService; - private readonly IMessageBus messaging; public DefaultRuleRunnerService( - IPersistenceFactory persistenceFactory, + IJobService jobService, IEventFormatter eventFormatter, IEventStore eventStore, - IRuleService ruleService, - IMessageBus messaging) + IRuleService ruleService) { + this.jobService = jobService; this.eventFormatter = eventFormatter; - this.persistenceFactory = persistenceFactory; this.eventStore = eventStore; this.ruleService = ruleService; - this.messaging = messaging; } public Task> SimulateAsync(Rule rule, @@ -120,30 +116,24 @@ public bool CanRunFromSnapshots(Rule rule) public Task CancelAsync(DomainId appId, CancellationToken ct = default) { - return messaging.PublishAsync(new RuleRunnerCancel(appId), ct: ct); - } + var taskName = RuleRunnerJob.TaskName; - public Task RunAsync(DomainId appId, DomainId ruleId, bool fromSnapshots = false, - CancellationToken ct = default) - { - return messaging.PublishAsync(new RuleRunnerRun(appId, ruleId, fromSnapshots), ct: ct); + return jobService.CancelAsync(appId, taskName, ct); } - public async Task GetRunningRuleIdAsync(DomainId appId, + public Task RunAsync(RefToken actor, DomainId appId, DomainId ruleId, bool fromSnapshots = false, CancellationToken ct = default) { - var state = await GetStateAsync(appId, ct); + var job = RuleRunnerJob.BuildRequest(actor, ruleId, fromSnapshots); - return state.Value.RuleId; + return jobService.StartAsync(appId, job, ct); } - private async Task> GetStateAsync(DomainId appId, - CancellationToken ct) + public async Task GetRunningRuleIdAsync(DomainId appId, + CancellationToken ct = default) { - var state = new SimpleState(persistenceFactory, GetType(), appId); - - await state.LoadAsync(ct); + var jobs = await jobService.GetJobsAsync(appId, ct); - return state; + return jobs.Select(RuleRunnerJob.GetRunningRuleId).FirstOrDefault(x => x != null); } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/IRuleRunnerService.cs b/backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/IRuleRunnerService.cs index d7f71b7b30..375653521c 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/IRuleRunnerService.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/IRuleRunnerService.cs @@ -18,7 +18,7 @@ Task> SimulateAsync(NamedId appId, DomainId r Task> SimulateAsync(Rule rule, CancellationToken ct = default); - Task RunAsync(DomainId appId, DomainId ruleId, bool fromSnapshots = false, + Task RunAsync(RefToken actor, DomainId appId, DomainId ruleId, bool fromSnapshots = false, CancellationToken ct = default); Task CancelAsync(DomainId appId, diff --git a/backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/RuleRunnerJob.cs b/backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/RuleRunnerJob.cs new file mode 100644 index 0000000000..e8f0be5c17 --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/RuleRunnerJob.cs @@ -0,0 +1,206 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Microsoft.Extensions.Logging; +using Squidex.Domain.Apps.Core.HandleRules; +using Squidex.Domain.Apps.Core.Rules; +using Squidex.Domain.Apps.Entities.Jobs; +using Squidex.Domain.Apps.Entities.Rules.Repositories; +using Squidex.Infrastructure; +using Squidex.Infrastructure.EventSourcing; +using Squidex.Infrastructure.Translations; + +namespace Squidex.Domain.Apps.Entities.Rules.Runner; + +public sealed class RuleRunnerJob : IJobRunner +{ + public const string TaskName = "run-rule"; + public const string ArgRuleId = "ruleId"; + public const string ArgSnapshot = "snapshots"; + + private const int MaxErrors = 10; + private readonly IAppProvider appProvider; + private readonly IEventFormatter eventFormatter; + private readonly IEventStore eventStore; + private readonly IRuleEventRepository ruleEventRepository; + private readonly IRuleService ruleService; + private readonly IRuleUsageTracker ruleUsageTracker; + private readonly ILogger log; + + public string Name => TaskName; + + public RuleRunnerJob( + IAppProvider appProvider, + IEventFormatter eventFormatter, + IEventStore eventStore, + IRuleEventRepository ruleEventRepository, + IRuleService ruleService, + IRuleUsageTracker ruleUsageTracker, + ILogger log) + { + this.appProvider = appProvider; + this.eventStore = eventStore; + this.eventFormatter = eventFormatter; + this.ruleEventRepository = ruleEventRepository; + this.ruleService = ruleService; + this.ruleUsageTracker = ruleUsageTracker; + this.log = log; + } + + public static DomainId? GetRunningRuleId(Job job) + { + if (job.TaskName != TaskName || job.Status != JobStatus.Started) + { + return null; + } + + if (!job.Arguments.TryGetValue(ArgRuleId, out var ruleId)) + { + return null; + } + + return DomainId.Create(ruleId); + } + + public static JobRequest BuildRequest(RefToken actor, DomainId ruleId, bool snapshot) + { + return JobRequest.Create( + actor, + TaskName, + new Dictionary + { + [ArgRuleId] = ruleId.ToString(), + [ArgSnapshot] = snapshot.ToString() + }); + } + + public async Task RunAsync(JobRunContext context, + CancellationToken ct) + { + if (!context.Job.Arguments.TryGetValue(ArgRuleId, out var ruleId)) + { + throw new DomainException("Argument missing."); + } + + var rule = await appProvider.GetRuleAsync(context.OwnerId, DomainId.Create(ruleId), ct) + ?? throw new DomainObjectNotFoundException(ruleId); + + var fromSnapshot = string.Equals(context.Job.Arguments.GetValueOrDefault(ArgSnapshot), "true", StringComparison.OrdinalIgnoreCase); + + // Use a readable name to describe the job. + SetDescription(context, rule, fromSnapshot); + + // Also run disabled rules, because we want to enable rules to be only used with manual trigger. + var ruleContext = new RuleContext + { + AppId = rule.AppId, + IncludeStale = true, + IncludeSkipped = true, + Rule = rule, + }; + + if (fromSnapshot && ruleService.CanCreateSnapshotEvents(rule)) + { + await EnqueueFromSnapshotsAsync(ruleContext, ct); + } + else + { + await EnqueueFromEventsAsync(context, ruleContext, ct); + } + } + + private static void SetDescription(JobRunContext run, Rule rule, bool fromSnapshot) + { + if (!string.IsNullOrWhiteSpace(rule.Name)) + { + var key = fromSnapshot ? + "job.ruleRunNamedSnapshot" : + "job.ruleRunName"; + + run.Job.Description = T.Get(key, new { name = rule.Name }); + } + else + { + var key = fromSnapshot ? + "job.ruleRunSnapshot" : + "job.ruleRun"; + + run.Job.Description = T.Get(key); + } + } + + private async Task EnqueueFromSnapshotsAsync(RuleContext context, + CancellationToken ct) + { + // We collect errors and allow a few erors before we throw an exception. + var errors = 0; + + // Write in batches of 100 items for better performance. Using completes the last write. + await using var batch = new RuleQueueWriter(ruleEventRepository, ruleUsageTracker, null); + + await foreach (var result in ruleService.CreateSnapshotJobsAsync(context, ct)) + { + await batch.WriteAsync(result); + + if (result.EnrichmentError != null) + { + errors++; + + // We accept a few errors and stop the process if there are too many errors. + if (errors >= MaxErrors) + { + throw result.EnrichmentError; + } + + log.LogWarning(result.EnrichmentError, "Failed to run rule with ID {ruleId}, continue with next job.", result.Rule?.Id); + } + } + } + + private async Task EnqueueFromEventsAsync(JobRunContext run, RuleContext context, + CancellationToken ct) + { + // We collect errors and allow a few erors before we throw an exception. + var errors = 0; + + // Write in batches of 100 items for better performance. Using completes the last write. + await using var batch = new RuleQueueWriter(ruleEventRepository, ruleUsageTracker, null); + + // Use a prefix query so that the storage can use an index for the query. + var streamFilter = StreamFilter.Prefix($"([a-zA-Z0-9]+)\\-{run.OwnerId}"); + + await foreach (var storedEvent in eventStore.QueryAllAsync(streamFilter, ct: ct)) + { + var @event = eventFormatter.ParseIfKnown(storedEvent); + + if (@event == null) + { + continue; + } + + await foreach (var result in ruleService.CreateJobsAsync(@event, context.ToRulesContext(), ct)) + { + await batch.WriteAsync(result); + + if (result.EnrichmentError != null) + { + errors++; + + // We accept a few errors and stop the process if there are too many errors. + if (errors >= MaxErrors) + { + throw result.EnrichmentError; + } + + log.LogWarning(result.EnrichmentError, "Failed to run rule with ID {ruleId}, continue with next job.", result.Rule?.Id); + } + } + } + + await batch.FlushAsync(); + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/RuleRunnerProcessor.cs b/backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/RuleRunnerProcessor.cs deleted file mode 100644 index 3699ce3e6f..0000000000 --- a/backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/RuleRunnerProcessor.cs +++ /dev/null @@ -1,298 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschraenkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using Microsoft.Extensions.Logging; -using Squidex.Caching; -using Squidex.Domain.Apps.Core.HandleRules; -using Squidex.Domain.Apps.Entities.Rules.Repositories; -using Squidex.Infrastructure; -using Squidex.Infrastructure.EventSourcing; -using Squidex.Infrastructure.States; -using Squidex.Infrastructure.Tasks; -using Squidex.Infrastructure.Translations; -using TaskHelper = Squidex.Infrastructure.Tasks.TaskExtensions; - -namespace Squidex.Domain.Apps.Entities.Rules.Runner; - -public sealed class RuleRunnerProcessor -{ - private const int MaxErrors = 10; - private readonly IAppProvider appProvider; - private readonly IEventFormatter eventFormatter; - private readonly IEventStore eventStore; - private readonly ILocalCache localCache; - private readonly IRuleEventRepository ruleEventRepository; - private readonly IRuleService ruleService; - private readonly IRuleUsageTracker ruleUsageTracker; - private readonly ILogger log; - private readonly SimpleState state; - private readonly ReentrantScheduler scheduler = new ReentrantScheduler(1); - private readonly DomainId appId; - private Run? currentRun; - - // Use a run to store all state that is necessary for a single run. - private sealed class Run : IDisposable - { - private readonly CancellationTokenSource cancellationSource = new CancellationTokenSource(); - private readonly CancellationTokenSource cancellationLinked; - - public RuleRunnerState Job { get; init; } - - public RuleContext Context { get; set; } - - public CancellationToken CancellationToken => cancellationLinked.Token; - - public Run(CancellationToken ct) - { - cancellationLinked = CancellationTokenSource.CreateLinkedTokenSource(ct, cancellationSource.Token); - } - - public void Dispose() - { - cancellationSource.Dispose(); - cancellationLinked.Dispose(); - } - - public void Cancel() - { - try - { - cancellationSource.Cancel(); - } - catch (ObjectDisposedException) - { - // Cancellation token might have been disposed, if the run is completed. - } - } - } - - public RuleRunnerProcessor( - DomainId appId, - IAppProvider appProvider, - IEventFormatter eventFormatter, - IEventStore eventStore, - ILocalCache localCache, - IPersistenceFactory persistenceFactory, - IRuleEventRepository ruleEventRepository, - IRuleService ruleService, - IRuleUsageTracker ruleUsageTracker, - ILogger log) - { - this.appId = appId; - this.appProvider = appProvider; - this.localCache = localCache; - this.eventStore = eventStore; - this.eventFormatter = eventFormatter; - this.ruleEventRepository = ruleEventRepository; - this.ruleService = ruleService; - this.ruleUsageTracker = ruleUsageTracker; - this.log = log; - - state = new SimpleState(persistenceFactory, GetType(), appId); - } - - public async Task LoadAsync( - CancellationToken ct = default) - { - await state.LoadAsync(ct); - - if (!state.Value.RunFromSnapshots && state.Value.RuleId != default) - { - TaskHelper.Forget(RunAsync(state.Value.RuleId, false, default)); - } - else - { - await state.ClearAsync(ct); - } - } - - public Task CancelAsync() - { - // Ensure that only one thread is accessing the current state at a time. - return scheduler.Schedule(() => - { - currentRun?.Cancel(); - }); - } - - public Task RunAsync(DomainId ruleId, bool fromSnapshots, - CancellationToken ct) - { - return scheduler.ScheduleAsync(async ct => - { - // There is no continuation token for snapshots, therefore we cannot continue with the run. - if (currentRun?.Job.RunFromSnapshots == true) - { - throw new DomainException(T.Get("rules.ruleAlreadyRunning")); - } - - var previousJob = state.Value; - - // If we have not removed the state, we have not completed the previous run and can therefore just continue. - var position = - previousJob.RuleId == ruleId && !previousJob.RunFromSnapshots ? - previousJob.Position : - null; - - // Set the current run first to indicate that we are running a rule at the moment. - var run = currentRun = new Run(ct) - { - Job = new RuleRunnerState - { - RuleId = ruleId, - RunId = DomainId.NewGuid(), - RunFromSnapshots = fromSnapshots, - Position = position - } - }; - - state.Value = run.Job; - try - { - await state.WriteAsync(run.CancellationToken); - - await ProcessAsync(run, run.CancellationToken); - } - finally - { - // Unset the run to indicate that we are done. - currentRun.Dispose(); - currentRun = null; - } - }, ct); - } - - private async Task ProcessAsync(Run run, - CancellationToken ct) - { - try - { - var rule = await appProvider.GetRuleAsync(appId, run.Job.RuleId, ct); - - // The rule might have been deleted in the meantime. - if (rule == null) - { - throw new DomainObjectNotFoundException(run.Job.RuleId.ToString()!); - } - - using (localCache.StartContext()) - { - // Also run disabled rules, because we want to enable rules to be only used with manual trigger. - run.Context = new RuleContext - { - AppId = rule.AppId, - IncludeStale = true, - IncludeSkipped = true, - Rule = rule, - }; - - if (run.Job.RunFromSnapshots && ruleService.CanCreateSnapshotEvents(rule)) - { - await EnqueueFromSnapshotsAsync(run, ct); - } - else - { - await EnqueueFromEventsAsync(run, ct); - } - } - } - catch (OperationCanceledException) - { - return; - } - catch (Exception ex) - { - log.LogError(ex, "Failed to run rule with ID {ruleId}.", run.Job.RuleId); - } - finally - { - // Remove the state to indicate that the run has been completed. - await state.ClearAsync(default); - } - } - - private async Task EnqueueFromSnapshotsAsync(Run run, - CancellationToken ct) - { - // We collect errors and allow a few erors before we throw an exception. - var errors = 0; - - // Write in batches of 100 items for better performance. Using completes the last write. - await using var batch = new RuleQueueWriter(ruleEventRepository, ruleUsageTracker, null); - - await foreach (var result in ruleService.CreateSnapshotJobsAsync(run.Context, ct)) - { - await batch.WriteAsync(result); - - if (result.EnrichmentError != null) - { - errors++; - - // We accept a few errors and stop the process if there are too many errors. - if (errors >= MaxErrors) - { - throw result.EnrichmentError; - } - - log.LogWarning(result.EnrichmentError, "Failed to run rule with ID {ruleId}, continue with next job.", result.Rule?.Id); - } - } - } - - private async Task EnqueueFromEventsAsync(Run run, - CancellationToken ct) - { - // We collect errors and allow a few erors before we throw an exception. - var errors = 0; - - // Write in batches of 100 items for better performance. Using completes the last write. - await using var batch = new RuleQueueWriter(ruleEventRepository, ruleUsageTracker, null); - - // Use a prefix query so that the storage can use an index for the query. - var streamFilter = StreamFilter.Prefix($"([a-zA-Z0-9]+)\\-{appId}"); - - await foreach (var storedEvent in eventStore.QueryAllAsync(streamFilter, run.Job.Position, ct: ct)) - { - var @event = eventFormatter.ParseIfKnown(storedEvent); - - if (@event == null) - { - continue; - } - - run.Job.Position = storedEvent.EventPosition; - - await foreach (var result in ruleService.CreateJobsAsync(@event, run.Context.ToRulesContext(), ct)) - { - if (await batch.WriteAsync(result)) - { - // Update the process when something has been written. - await state.WriteAsync(ct); - } - - if (result.EnrichmentError != null) - { - errors++; - - // We accept a few errors and stop the process if there are too many errors. - if (errors >= MaxErrors) - { - throw result.EnrichmentError; - } - - log.LogWarning(result.EnrichmentError, "Failed to run rule with ID {ruleId}, continue with next job.", result.Rule?.Id); - } - } - } - - if (await batch.FlushAsync()) - { - // Update the process when something has been written. - await state.WriteAsync(ct); - } - } -} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/RuleRunnerState.cs b/backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/RuleRunnerState.cs deleted file mode 100644 index b26fae1beb..0000000000 --- a/backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/RuleRunnerState.cs +++ /dev/null @@ -1,23 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschraenkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using Squidex.Infrastructure; -using Squidex.Infrastructure.States; - -namespace Squidex.Domain.Apps.Entities.Rules.Runner; - -[CollectionName("Rules_Runner")] -public sealed class RuleRunnerState -{ - public DomainId RuleId { get; set; } - - public DomainId RunId { get; set; } - - public string? Position { get; set; } - - public bool RunFromSnapshots { get; set; } -} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/RuleRunnerWorker.cs b/backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/RuleRunnerWorker.cs deleted file mode 100644 index c3cb4ce3e5..0000000000 --- a/backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/RuleRunnerWorker.cs +++ /dev/null @@ -1,78 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschraenkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using Microsoft.Extensions.DependencyInjection; -using Squidex.Hosting; -using Squidex.Infrastructure; -using Squidex.Infrastructure.States; -using Squidex.Messaging; - -namespace Squidex.Domain.Apps.Entities.Rules.Runner; - -public sealed class RuleRunnerWorker : - IBackgroundProcess, - IMessageHandler, - IMessageHandler -{ - private readonly Dictionary> processors = []; - private readonly Func processorFactory; - private readonly ISnapshotStore snapshotStore; - - public RuleRunnerWorker(IServiceProvider serviceProvider, ISnapshotStore snapshotStore) - { - var objectFactory = ActivatorUtilities.CreateFactory(typeof(RuleRunnerProcessor), [typeof(DomainId)]); - - processorFactory = key => - { - return (RuleRunnerProcessor)objectFactory(serviceProvider, new object[] { key }); - }; - - this.snapshotStore = snapshotStore; - } - - public async Task StartAsync( - CancellationToken ct) - { - await foreach (var snapshot in snapshotStore.ReadAllAsync(ct)) - { - await GetProcessorAsync(snapshot.Key, ct); - } - } - - public async Task HandleAsync(RuleRunnerRun message, - CancellationToken ct) - { - var processor = await GetProcessorAsync(message.AppId, ct); - - await processor.RunAsync(message.RuleId, message.FromSnapshots, ct); - } - - public async Task HandleAsync(RuleRunnerCancel message, - CancellationToken ct) - { - var processor = await GetProcessorAsync(message.AppId, ct); - - await processor.CancelAsync(); - } - - private Task GetProcessorAsync(DomainId appId, - CancellationToken ct) - { - // Use a normal dictionary to avoid double creations. - lock (processors) - { - return processors.GetOrAdd(appId, async key => - { - var processor = processorFactory(key); - - await processor.LoadAsync(ct); - - return processor; - }); - } - } -} diff --git a/backend/src/Squidex.Shared/PermissionIds.cs b/backend/src/Squidex.Shared/PermissionIds.cs index 576702f2b3..186d5e9340 100644 --- a/backend/src/Squidex.Shared/PermissionIds.cs +++ b/backend/src/Squidex.Shared/PermissionIds.cs @@ -139,11 +139,14 @@ public static class PermissionIds public const string AppWorkflowsDelete = "squidex.apps.{app}.workflows.delete"; // App Backups - public const string AppBackups = "squidex.apps.{app}.backups"; - public const string AppBackupsRead = "squidex.apps.{app}.backups.read"; public const string AppBackupsCreate = "squidex.apps.{app}.backups.create"; - public const string AppBackupsDelete = "squidex.apps.{app}.backups.delete"; - public const string AppBackupsDownload = "squidex.apps.{app}.backups.download"; + + // App Jobs + public const string AppJobs = "squidex.apps.{app}.jobs"; + public const string AppJobsRead = "squidex.apps.{app}.jobs.read"; + public const string AppJobsCreate = "squidex.apps.{app}.jobs.create"; + public const string AppJobsDelete = "squidex.apps.{app}.jobs.delete"; + public const string AppJobsDownload = "squidex.apps.{app}.jobs.download"; // App Plans public const string AppPlans = "squidex.apps.{app}.plans"; diff --git a/backend/src/Squidex.Shared/Texts.fr.resx b/backend/src/Squidex.Shared/Texts.fr.resx index c6db31ea00..bf504ee17a 100644 --- a/backend/src/Squidex.Shared/Texts.fr.resx +++ b/backend/src/Squidex.Shared/Texts.fr.resx @@ -184,21 +184,6 @@ Les actifs sont référencés par un contenu et ne peuvent pas être supprimés. - - Un autre processus de sauvegarde est déjà en cours d'exécution. - - - Vous ne pouvez pas avoir plus de {max} sauvegardes. - - - Une opération de restauration est déjà en cours. - - - Vous ne pouvez accéder qu'à vos notifications. - - - Le commentaire est créé par un autre utilisateur. - Action @@ -886,12 +871,36 @@ paramètres généraux mis à jour et nom renommé en {[Name]}. + + Backup + + + Restore + + + Replay Rule. + + + Replay Rule '{name}'. + + + Replay Rule '{name}' from states. + + + Replay Rule from states + + + Another job is already running. + + + Invalid task name + + + You cannot have more than {max} backups. + Votre adresse e-mail est définie sur privé dans Github. Veuillez le définir sur public pour utiliser la connexion Github. - - Une autre règle est déjà en cours d'exécution. - La valeur par défaut calculée et la valeur par défaut ne peuvent pas être utilisées ensemble. diff --git a/backend/src/Squidex.Shared/Texts.it.resx b/backend/src/Squidex.Shared/Texts.it.resx index 2700af4b14..4594f4a80b 100644 --- a/backend/src/Squidex.Shared/Texts.it.resx +++ b/backend/src/Squidex.Shared/Texts.it.resx @@ -184,21 +184,6 @@ La risorsa è collegata ad un contenuto pertanto non può essere cancellata. - - È in esecuzione una altro processo di backup. - - - Non puoi avere più di {max} backup. - - - È in esecuzione un'operazione di restore. - - - Puoi solo accedere alle tue notifiche. - - - È stato creato un commento da un altro utente. - Azione @@ -886,12 +871,36 @@ updated general settings and renamed name to {[Name]}. + + Backup + + + Restore + + + Replay Rule. + + + Replay Rule '{name}'. + + + Replay Rule '{name}' from states. + + + Replay Rule from states + + + Another job is already running. + + + Invalid task name + + + You cannot have more than {max} backups. + Il tuo indirizzo email è impostato su privato in Github. Impostalo come pubblico per poter utilizzare il login Github. - - È in esecuzione un'altra regola. - Il valore predefinito calcolato e il valore predefinito non possono essere utilizzati insieme. diff --git a/backend/src/Squidex.Shared/Texts.nl.resx b/backend/src/Squidex.Shared/Texts.nl.resx index b3962d3438..3a17daefc0 100644 --- a/backend/src/Squidex.Shared/Texts.nl.resx +++ b/backend/src/Squidex.Shared/Texts.nl.resx @@ -184,21 +184,6 @@ Er wordt naar dit bestand verwezen door een contentitem en kan niet worden verwijderd. - - Er wordt al een ander back-upproces uitgevoerd. - - - Je kunt niet meer dan {max} backups hebben. - - - Er wordt al een herstelbewerking uitgevoerd. - - - Je hebt alleen toegang tot jouw notificaties. - - - Reactie is gemaakt door een andere gebruiker. - Actie @@ -886,12 +871,36 @@ updated general settings and renamed name to {[Name]}. + + Backup + + + Restore + + + Replay Rule. + + + Replay Rule '{name}'. + + + Replay Rule '{name}' from states. + + + Replay Rule from states + + + Another job is already running. + + + Invalid task name + + + You cannot have more than {max} backups. + Jouw e-mailadres is ingesteld op privé in Github. Stel het in op openbaar om Github-login te gebruiken. - - Er wordt al een andere regel uitgevoerd. - Berekende standaardwaarde en standaardwaarde kunnen niet samen worden gebruikt. diff --git a/backend/src/Squidex.Shared/Texts.pt.resx b/backend/src/Squidex.Shared/Texts.pt.resx index 48d69a37d9..83f4578257 100644 --- a/backend/src/Squidex.Shared/Texts.pt.resx +++ b/backend/src/Squidex.Shared/Texts.pt.resx @@ -184,21 +184,6 @@ Os ficheiros são referenciados por um conteúdo e não podem ser excluídos. - - Já se encontra um processo de backup em processamento. - - - Não pode ter mais de {max} backups. - - - Um processamento de restauro já se encontra a correr. - - - Só pode aceder as suas notificações. - - - Comentário foi criado por outro utilizador. - Acção @@ -886,12 +871,36 @@ actualizadas configurações gerais e renomeado para {[Name]}. + + Backup + + + Restore + + + Replay Rule. + + + Replay Rule '{name}'. + + + Replay Rule '{name}' from states. + + + Replay Rule from states + + + Another job is already running. + + + Invalid task name + + + You cannot have more than {max} backups. + O seu Email é privado no Github. Altere para publico no Github e tente novamente. - - Outra regra já se encontra a correr. - Valor por defeito calculado e valor por defeito não podem ser usado em conjunto. diff --git a/backend/src/Squidex.Shared/Texts.resx b/backend/src/Squidex.Shared/Texts.resx index f70e915585..61b3dfe969 100644 --- a/backend/src/Squidex.Shared/Texts.resx +++ b/backend/src/Squidex.Shared/Texts.resx @@ -184,21 +184,6 @@ Assets is referenced by a content and cannot be deleted. - - Another backup process is already running. - - - You cannot have more than {max} backups. - - - A restore operation is already running. - - - You can only access your notifications. - - - Comment is created by another user. - Action @@ -886,12 +871,36 @@ updated general settings and renamed name to {[Name]}. + + Backup + + + Restore + + + Replay Rule. + + + Replay Rule '{name}'. + + + Replay Rule '{name}' from states. + + + Replay Rule from states + + + Another job is already running. + + + Invalid task name + + + You cannot have more than {max} backups. + Your email address is set to private in Github. Please set it to public to use Github login. - - Another rule is already running. - Calculated default value and default value cannot be used together. diff --git a/backend/src/Squidex.Shared/Texts.zh.resx b/backend/src/Squidex.Shared/Texts.zh.resx index 1f0e42986c..916f06cb5f 100644 --- a/backend/src/Squidex.Shared/Texts.zh.resx +++ b/backend/src/Squidex.Shared/Texts.zh.resx @@ -184,21 +184,6 @@ 资源被内容引用,无法删除。 - - 另一个备份进程已经在运行。 - - - 您不能拥有超过 {max} 个备份。 - - - 还原操作已经在运行。 - - - 您只能访问您的通知。 - - - 评论是由另一个用户创建的。 - 动作 @@ -886,12 +871,36 @@ updated general settings and renamed name to {[Name]}. + + Backup + + + Restore + + + Replay Rule. + + + Replay Rule '{name}'. + + + Replay Rule '{name}' from states. + + + Replay Rule from states + + + Another job is already running. + + + Invalid task name + + + You cannot have more than {max} backups. + 您的邮箱在 Github 中设置为私有。请设置为公开以使用 Github 登录。 - - 另一个规则已经在运行。 - 计算出的默认值和默认值不能一起使用。 diff --git a/backend/src/Squidex.Web/Resources.cs b/backend/src/Squidex.Web/Resources.cs index 85362c3f71..fd9d2b8487 100644 --- a/backend/src/Squidex.Web/Resources.cs +++ b/backend/src/Squidex.Web/Resources.cs @@ -140,12 +140,14 @@ public sealed class Resources // Backups public bool CanRestoreBackup => Can(PermissionIds.AdminRestore); - public bool CanCreateBackup => Can(PermissionIds.AppBackupsCreate); + public bool CanCreateBackup => Can(PermissionIds.AppJobs); - public bool CanDeleteBackup => Can(PermissionIds.AppBackupsDelete); + // Jobs + public bool CanDeleteJob => Can(PermissionIds.AppJobsCreate); - public bool CanDownloadBackup => Can(PermissionIds.AppBackupsDownload); + public bool CanDownloadJob => Can(PermissionIds.AppJobsDownload); + // Context public Context Context { get; set; } public string? App => GetAppName(); diff --git a/backend/src/Squidex.Web/Services/UrlGenerator.cs b/backend/src/Squidex.Web/Services/UrlGenerator.cs index acdaeb28d7..3656c5a368 100644 --- a/backend/src/Squidex.Web/Services/UrlGenerator.cs +++ b/backend/src/Squidex.Web/Services/UrlGenerator.cs @@ -82,11 +82,6 @@ public string AssetsUI(NamedId appId, string? @ref = null) return urlGenerator.BuildUrl($"app/{appId.Name}/assets", false) + @ref != null ? $"?ref={@ref}" : string.Empty; } - public string BackupsUI(NamedId appId) - { - return urlGenerator.BuildUrl($"app/{appId.Name}/settings/backups", false); - } - public string ClientsUI(NamedId appId) { return urlGenerator.BuildUrl($"app/{appId.Name}/settings/clients", false); @@ -122,6 +117,11 @@ public string DashboardUI(NamedId appId) return urlGenerator.BuildUrl($"app/{appId.Name}", false); } + public string JobsUI(NamedId appId) + { + return urlGenerator.BuildUrl($"app/{appId.Name}/settings/jobs", false); + } + public string LanguagesUI(NamedId appId) { return urlGenerator.BuildUrl($"app/{appId.Name}/settings/languages", false); diff --git a/backend/src/Squidex/Areas/Api/Controllers/Apps/Models/AppDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Apps/Models/AppDto.cs index 4aa5c65726..2a83e88a60 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Apps/Models/AppDto.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Apps/Models/AppDto.cs @@ -7,7 +7,7 @@ using NodaTime; using Squidex.Areas.Api.Controllers.Assets; -using Squidex.Areas.Api.Controllers.Backups; +using Squidex.Areas.Api.Controllers.Jobs; using Squidex.Areas.Api.Controllers.Ping; using Squidex.Areas.Api.Controllers.Plans; using Squidex.Areas.Api.Controllers.Rules; @@ -182,10 +182,10 @@ private AppDto CreateLinks(App app, Resources resources, PermissionSet permissio resources.Url(x => nameof(x.GetAssets), values)); } - if (resources.IsAllowed(PermissionIds.AppBackupsRead, Name, additional: permissions)) + if (resources.IsAllowed(PermissionIds.AppJobsRead, Name, additional: permissions)) { - AddGetLink("backups", - resources.Url(x => nameof(x.GetBackups), values)); + AddGetLink("jobs", + resources.Url(x => nameof(x.GetJobs), values)); } if (resources.IsAllowed(PermissionIds.AppClientsRead, Name, additional: permissions)) diff --git a/backend/src/Squidex/Areas/Api/Controllers/Backups/BackupContentController.cs b/backend/src/Squidex/Areas/Api/Controllers/Backups/BackupContentController.cs index 15199d298f..56d21424c7 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Backups/BackupContentController.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Backups/BackupContentController.cs @@ -7,7 +7,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -using Squidex.Domain.Apps.Entities.Backup; +using Squidex.Domain.Apps.Entities.Jobs; using Squidex.Infrastructure; using Squidex.Infrastructure.Commands; using Squidex.Web; @@ -18,18 +18,16 @@ namespace Squidex.Areas.Api.Controllers.Backups; /// Update and query backups for app. /// [ApiExplorerSettings(GroupName = nameof(Backups))] +[Obsolete("Use Jobs endpoint.")] public class BackupContentController : ApiController { - private readonly IBackupArchiveStore backupArchiveStore; - private readonly IBackupService backupservice; + private readonly IJobService jobService; public BackupContentController(ICommandBus commandBus, - IBackupArchiveStore backupArchiveStore, - IBackupService backupservice) + IJobService jobService) : base(commandBus) { - this.backupArchiveStore = backupArchiveStore; - this.backupservice = backupservice; + this.jobService = jobService; } /// @@ -45,6 +43,7 @@ public BackupContentController(ICommandBus commandBus, [ProducesResponseType(typeof(FileResult), StatusCodes.Status200OK)] [ApiCosts(0)] [AllowAnonymous] + [Obsolete("Use Jobs endpoint.")] public Task GetBackupContent(string app, DomainId id) { return GetBackupAsync(AppId, app, id); @@ -64,6 +63,7 @@ public Task GetBackupContent(string app, DomainId id) [ProducesResponseType(typeof(FileResult), StatusCodes.Status200OK)] [ApiCosts(0)] [AllowAnonymous] + [Obsolete("Use Jobs endpoint.")] public Task GetBackupContentV2(DomainId id, [FromQuery] DomainId appId = default, [FromQuery] string app = "") { return GetBackupAsync(appId, app, id); @@ -71,23 +71,22 @@ public Task GetBackupContentV2(DomainId id, [FromQuery] DomainId private async Task GetBackupAsync(DomainId appId, string app, DomainId id) { - var backup = await backupservice.GetBackupAsync(appId, id, HttpContext.RequestAborted); + var jobs = await jobService.GetJobsAsync(appId, HttpContext.RequestAborted); + var job = jobs.Find(x => x.Id == id); - if (backup is not { Status: JobStatus.Completed }) + if (job is not { Status: JobStatus.Completed } || job.File == null) { return NotFound(); } - var fileName = $"backup-{app}-{backup.Started:yyyy-MM-dd_HH-mm-ss}.zip"; - var callback = new FileCallback((body, range, ct) => { - return backupArchiveStore.DownloadAsync(id, body, ct); + return jobService.DownloadAsync(job, body, ct); }); - return new FileCallbackResult("application/zip", callback) + return new FileCallbackResult(job.File.MimeType, callback) { - FileDownloadName = fileName, + FileDownloadName = job.File.Name, FileSize = null, ErrorAs404 = true }; diff --git a/backend/src/Squidex/Areas/Api/Controllers/Backups/BackupsController.cs b/backend/src/Squidex/Areas/Api/Controllers/Backups/BackupsController.cs index 75b7d9eb68..b2fa55e7c2 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Backups/BackupsController.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Backups/BackupsController.cs @@ -8,6 +8,7 @@ using Microsoft.AspNetCore.Mvc; using Squidex.Areas.Api.Controllers.Backups.Models; using Squidex.Domain.Apps.Entities.Backup; +using Squidex.Domain.Apps.Entities.Jobs; using Squidex.Infrastructure; using Squidex.Infrastructure.Commands; using Squidex.Infrastructure.Security; @@ -22,12 +23,12 @@ namespace Squidex.Areas.Api.Controllers.Backups; [ApiExplorerSettings(GroupName = nameof(Backups))] public class BackupsController : ApiController { - private readonly IBackupService backupService; + private readonly IJobService jobService; - public BackupsController(ICommandBus commandBus, IBackupService backupService) + public BackupsController(ICommandBus commandBus, IJobService jobService) : base(commandBus) { - this.backupService = backupService; + this.jobService = jobService; } /// @@ -39,15 +40,16 @@ public BackupsController(ICommandBus commandBus, IBackupService backupService) [HttpGet] [Route("apps/{app}/backups/")] [ProducesResponseType(typeof(BackupJobsDto), StatusCodes.Status200OK)] - [ApiPermissionOrAnonymous(PermissionIds.AppBackupsRead)] + [ApiPermissionOrAnonymous(PermissionIds.AppJobsRead)] [ApiCosts(0)] + [Obsolete("Use Jobs endpoint.")] public async Task GetBackups(string app) { - var jobs = await backupService.GetBackupsAsync(AppId, HttpContext.RequestAborted); + var jobs = await jobService.GetJobsAsync(App.Id, HttpContext.RequestAborted); - var response = BackupJobsDto.FromDomain(jobs, Resources); + var result = BackupJobsDto.FromDomain(jobs.Where(x => x.TaskName == BackupJob.TaskName), Resources); - return Ok(response); + return Ok(result); } /// @@ -60,11 +62,13 @@ public async Task GetBackups(string app) [HttpPost] [Route("apps/{app}/backups/")] [ProducesResponseType(StatusCodes.Status204NoContent)] - [ApiPermissionOrAnonymous(PermissionIds.AppBackupsCreate)] + [ApiPermissionOrAnonymous(PermissionIds.AppJobsCreate)] [ApiCosts(0)] public async Task PostBackup(string app) { - await backupService.StartBackupAsync(App.Id, User.Token()!, HttpContext.RequestAborted); + var job = BackupJob.BuildRequest(User.Token()!, App); + + await jobService.StartAsync(App.Id, job, default); return NoContent(); } @@ -79,11 +83,12 @@ public async Task PostBackup(string app) [HttpDelete] [Route("apps/{app}/backups/{id}")] [ProducesResponseType(StatusCodes.Status204NoContent)] - [ApiPermissionOrAnonymous(PermissionIds.AppBackupsDelete)] + [ApiPermissionOrAnonymous(PermissionIds.AppJobsDelete)] [ApiCosts(0)] + [Obsolete("Use Jobs endpoint.")] public async Task DeleteBackup(string app, DomainId id) { - await backupService.DeleteBackupAsync(AppId, id, HttpContext.RequestAborted); + await jobService.DeleteJobAsync(App.Id, id, default); return NoContent(); } diff --git a/backend/src/Squidex/Areas/Api/Controllers/Backups/Models/BackupJobDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Backups/Models/BackupJobDto.cs index fbf41decf5..1862cce47f 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Backups/Models/BackupJobDto.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Backups/Models/BackupJobDto.cs @@ -6,13 +6,15 @@ // ========================================================================== using NodaTime; -using Squidex.Domain.Apps.Entities.Backup; +using Squidex.Areas.Api.Controllers.Jobs; +using Squidex.Domain.Apps.Entities.Jobs; using Squidex.Infrastructure; using Squidex.Infrastructure.Reflection; using Squidex.Web; namespace Squidex.Areas.Api.Controllers.Backups.Models; +[Obsolete("Use Jobs endpoint.")] public sealed class BackupJobDto : Resource { /// @@ -45,16 +47,16 @@ public sealed class BackupJobDto : Resource /// public JobStatus Status { get; set; } - public static BackupJobDto FromDomain(IBackupJob backup, Resources resources) + public static BackupJobDto FromDomain(Job job, Resources resources) { - var result = SimpleMapper.Map(backup, new BackupJobDto()); + var result = SimpleMapper.Map(job, new BackupJobDto()); - return result.CreateLinks(resources); + return result.CreateLinks(job, resources); } - private BackupJobDto CreateLinks(Resources resources) + private BackupJobDto CreateLinks(Job job, Resources resources) { - if (resources.CanDeleteBackup) + if (resources.CanDeleteJob) { var values = new { app = resources.App, id = Id }; @@ -62,12 +64,12 @@ private BackupJobDto CreateLinks(Resources resources) resources.Url(x => nameof(x.DeleteBackup), values)); } - if (resources.CanDownloadBackup) + if (resources.CanDownloadJob && Status == JobStatus.Completed && job.File != null) { - var values = new { app = resources.App, appId = resources.AppId, id = Id }; + var values = new { appId = resources.AppId, id = Id }; AddGetLink("download", - resources.Url(x => nameof(x.GetBackupContentV2), values)); + resources.Url(x => nameof(x.GetJobContent), values)); } return this; diff --git a/backend/src/Squidex/Areas/Api/Controllers/Backups/Models/BackupJobsDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Backups/Models/BackupJobsDto.cs index d43cf5606b..ee611b904d 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Backups/Models/BackupJobsDto.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Backups/Models/BackupJobsDto.cs @@ -5,11 +5,12 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using Squidex.Domain.Apps.Entities.Backup; +using Squidex.Domain.Apps.Entities.Jobs; using Squidex.Web; namespace Squidex.Areas.Api.Controllers.Backups.Models; +[Obsolete("Use Jobs endpoint.")] public sealed class BackupJobsDto : Resource { /// @@ -17,11 +18,11 @@ public sealed class BackupJobsDto : Resource /// public BackupJobDto[] Items { get; set; } - public static BackupJobsDto FromDomain(IEnumerable backups, Resources resources) + public static BackupJobsDto FromDomain(IEnumerable jobs, Resources resources) { var result = new BackupJobsDto { - Items = backups.Select(x => BackupJobDto.FromDomain(x, resources)).ToArray() + Items = jobs.Select(x => BackupJobDto.FromDomain(x, resources)).ToArray() }; return result.CreateLinks(resources); diff --git a/backend/src/Squidex/Areas/Api/Controllers/Backups/Models/RestoreJobDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Backups/Models/RestoreJobDto.cs index 429e39f8ab..35ff7c4431 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Backups/Models/RestoreJobDto.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Backups/Models/RestoreJobDto.cs @@ -7,6 +7,7 @@ using NodaTime; using Squidex.Domain.Apps.Entities.Backup; +using Squidex.Domain.Apps.Entities.Jobs; using Squidex.Infrastructure.Reflection; namespace Squidex.Areas.Api.Controllers.Backups.Models; @@ -21,7 +22,7 @@ public sealed class RestoreJobDto /// /// The status log. /// - public List Log { get; set; } + public List Log { get; set; } = []; /// /// The time when the job has been started. @@ -38,8 +39,17 @@ public sealed class RestoreJobDto /// public JobStatus Status { get; set; } - public static RestoreJobDto FromDomain(IRestoreJob job) + public static RestoreJobDto FromDomain(Job job) { - return SimpleMapper.Map(job, new RestoreJobDto()); + var result = SimpleMapper.Map(job, new RestoreJobDto()); + + if (job.Arguments.TryGetValue(RestoreJob.ArgUrl, out var urlString) && Uri.TryCreate(urlString, UriKind.Absolute, out var url)) + { + result.Url = url; + } + + result.Log = job.Log.Select(x => $"{x.Timestamp}: {x.Message}").ToList(); + + return result; } } diff --git a/backend/src/Squidex/Areas/Api/Controllers/Backups/RestoreController.cs b/backend/src/Squidex/Areas/Api/Controllers/Backups/RestoreController.cs index dd93f220ea..c71832a07d 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Backups/RestoreController.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Backups/RestoreController.cs @@ -8,6 +8,7 @@ using Microsoft.AspNetCore.Mvc; using Squidex.Areas.Api.Controllers.Backups.Models; using Squidex.Domain.Apps.Entities.Backup; +using Squidex.Domain.Apps.Entities.Jobs; using Squidex.Infrastructure.Commands; using Squidex.Infrastructure.Security; using Squidex.Shared; @@ -22,12 +23,12 @@ namespace Squidex.Areas.Api.Controllers.Backups; [ApiModelValidation(true)] public class RestoreController : ApiController { - private readonly IBackupService backupService; + private readonly IJobService jobService; - public RestoreController(ICommandBus commandBus, IBackupService backupService) + public RestoreController(ICommandBus commandBus, IJobService jobService) : base(commandBus) { - this.backupService = backupService; + this.jobService = jobService; } /// @@ -40,11 +41,12 @@ public RestoreController(ICommandBus commandBus, IBackupService backupService) [ApiPermission(PermissionIds.AdminRestore)] public async Task GetRestoreJob() { - var job = await backupService.GetRestoreAsync(HttpContext.RequestAborted); + var jobs = await jobService.GetJobsAsync(default, HttpContext.RequestAborted); + var job = jobs.Find(x => x.TaskName == RestoreJob.TaskName); if (job == null) { - return NotFound(); + return Ok(new RestoreJobDto()); } var response = RestoreJobDto.FromDomain(job); @@ -63,7 +65,9 @@ public async Task GetRestoreJob() [ApiPermission(PermissionIds.AdminRestore)] public async Task PostRestoreJob([FromBody] RestoreRequestDto request) { - await backupService.StartRestoreAsync(User.Token()!, request.Url, request.Name, HttpContext.RequestAborted); + var job = RestoreJob.BuildRequest(User.Token()!, request.Url, request.Name); + + await jobService.StartAsync(default, job, HttpContext.RequestAborted); return NoContent(); } diff --git a/backend/src/Squidex/Areas/Api/Controllers/Jobs/JobsContentController.cs b/backend/src/Squidex/Areas/Api/Controllers/Jobs/JobsContentController.cs new file mode 100644 index 0000000000..1b945fa7dc --- /dev/null +++ b/backend/src/Squidex/Areas/Api/Controllers/Jobs/JobsContentController.cs @@ -0,0 +1,67 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Squidex.Domain.Apps.Entities.Jobs; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Commands; +using Squidex.Web; + +namespace Squidex.Areas.Api.Controllers.Jobs; + +/// +/// Update and query jobs for app. +/// +[ApiExplorerSettings(GroupName = nameof(Jobs))] +public class JobsContentController : ApiController +{ + private readonly IJobService jobService; + + public JobsContentController(ICommandBus commandBus, + IJobService jobService) + : base(commandBus) + { + this.jobService = jobService; + } + + /// + /// Get the job content. + /// + /// The ID of the job. + /// The ID of the app. + /// Job found and content returned. + /// Job or app not found. + [HttpGet] + [Route("apps/jobs/{id}")] + [ResponseCache(Duration = 3600 * 24 * 30)] + [ProducesResponseType(typeof(FileResult), StatusCodes.Status200OK)] + [ApiCosts(0)] + [AllowAnonymous] + public async Task GetJobContent(DomainId id, [FromQuery] DomainId appId = default) + { + var jobs = await jobService.GetJobsAsync(appId, HttpContext.RequestAborted); + var job = jobs.Find(x => x.Id == id); + + if (job is not { Status: JobStatus.Completed } || job.File == null) + { + return NotFound(); + } + + var callback = new FileCallback((body, range, ct) => + { + return jobService.DownloadAsync(job, body, ct); + }); + + return new FileCallbackResult(job.File.MimeType, callback) + { + FileDownloadName = job.File.Name, + FileSize = null, + ErrorAs404 = true + }; + } +} diff --git a/backend/src/Squidex/Areas/Api/Controllers/Jobs/JobsController.cs b/backend/src/Squidex/Areas/Api/Controllers/Jobs/JobsController.cs new file mode 100644 index 0000000000..f874e398b6 --- /dev/null +++ b/backend/src/Squidex/Areas/Api/Controllers/Jobs/JobsController.cs @@ -0,0 +1,70 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Microsoft.AspNetCore.Mvc; +using Squidex.Areas.Api.Controllers.Jobs.Models; +using Squidex.Domain.Apps.Entities.Jobs; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Commands; +using Squidex.Shared; +using Squidex.Web; + +namespace Squidex.Areas.Api.Controllers.Jobs; + +/// +/// Update and query jobs for apps. +/// +[ApiExplorerSettings(GroupName = nameof(Jobs))] +public class JobsController : ApiController +{ + private readonly IJobService jobService; + + public JobsController(ICommandBus commandBus, IJobService jobService) + : base(commandBus) + { + this.jobService = jobService; + } + + /// + /// Get all jobs. + /// + /// The name of the app. + /// Jobs returned. + /// App not found. + [HttpGet] + [Route("apps/{app}/jobs/")] + [ProducesResponseType(typeof(JobsDto), StatusCodes.Status200OK)] + [ApiPermissionOrAnonymous(PermissionIds.AppJobsRead)] + [ApiCosts(0)] + public async Task GetJobs(string app) + { + var jobs = await jobService.GetJobsAsync(App.Id, HttpContext.RequestAborted); + + var result = JobsDto.FromDomain(jobs, Resources); + + return Ok(result); + } + + /// + /// Delete a job. + /// + /// The name of the app. + /// The ID of the jobs to delete. + /// Job deleted. + /// Job or app not found. + [HttpDelete] + [Route("apps/{app}/jobs/{id}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ApiPermissionOrAnonymous(PermissionIds.AppJobsDelete)] + [ApiCosts(0)] + public async Task DeleteJob(string app, DomainId id) + { + await jobService.DeleteJobAsync(App.Id, id, default); + + return NoContent(); + } +} diff --git a/backend/src/Squidex/Areas/Api/Controllers/Jobs/Models/JobDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Jobs/Models/JobDto.cs new file mode 100644 index 0000000000..65b74ab648 --- /dev/null +++ b/backend/src/Squidex/Areas/Api/Controllers/Jobs/Models/JobDto.cs @@ -0,0 +1,98 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using NodaTime; +using Squidex.Domain.Apps.Entities.Jobs; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Collections; +using Squidex.Infrastructure.Reflection; +using Squidex.Web; + +namespace Squidex.Areas.Api.Controllers.Jobs.Models; + +public sealed class JobDto : Resource +{ + /// + /// The ID of the job. + /// + public DomainId Id { get; set; } + + /// + /// The time when the job has been started. + /// + public Instant Started { get; set; } + + /// + /// The time when the job has been stopped. + /// + public Instant? Stopped { get; set; } + + /// + /// The status of the operation. + /// + public JobStatus Status { get; set; } + + /// + /// The name of the task. + /// + public string TaskName { get; set; } + + /// + /// The description of the job. + /// + public string Description { get; set; } + + /// + /// The arguments for the job. + /// + public ReadonlyDictionary TaskArguments { get; set; } + + /// + /// The list of log items. + /// + public List Log { get; set; } = []; + + /// + /// Indicates whether the job can be downloaded. + /// + public bool CanDownload { get; set; } + + public static JobDto FromDomain(Job job, Resources resources) + { + var result = SimpleMapper.Map(job, new JobDto()); + + if (job.Log?.Count > 0) + { + result.Log = job.Log.Select(JobLogMessageDto.FromDomain).ToList(); + } + + result.TaskArguments = job.Arguments; + + return result.CreateLinks(job, resources); + } + + private JobDto CreateLinks(Job job, Resources resources) + { + if (resources.CanDeleteJob) + { + var values = new { app = resources.App, id = Id }; + + AddDeleteLink("delete", + resources.Url(x => nameof(x.DeleteJob), values)); + } + + if (resources.CanDownloadJob && Status == JobStatus.Completed && job.File != null) + { + var values = new { appId = resources.AppId, id = Id }; + + AddGetLink("download", + resources.Url(x => nameof(x.GetJobContent), values)); + } + + return this; + } +} diff --git a/backend/src/Squidex/Areas/Api/Controllers/Jobs/Models/JobLogMessageDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Jobs/Models/JobLogMessageDto.cs new file mode 100644 index 0000000000..38120f0335 --- /dev/null +++ b/backend/src/Squidex/Areas/Api/Controllers/Jobs/Models/JobLogMessageDto.cs @@ -0,0 +1,29 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using NodaTime; +using Squidex.Domain.Apps.Entities.Jobs; + +namespace Squidex.Areas.Api.Controllers.Jobs.Models; + +public class JobLogMessageDto +{ + /// + /// The timestamp. + /// + public Instant Timestamp { get; set; } + + /// + /// The log message. + /// + public string Message { get; set; } + + public static JobLogMessageDto FromDomain(JobLogMessage source) + { + return new JobLogMessageDto { Timestamp = source.Timestamp, Message = source.Message }; + } +} diff --git a/backend/src/Squidex/Areas/Api/Controllers/Jobs/Models/JobsDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Jobs/Models/JobsDto.cs new file mode 100644 index 0000000000..e0ec0d5c87 --- /dev/null +++ b/backend/src/Squidex/Areas/Api/Controllers/Jobs/Models/JobsDto.cs @@ -0,0 +1,45 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Areas.Api.Controllers.Backups; +using Squidex.Domain.Apps.Entities.Jobs; +using Squidex.Web; + +namespace Squidex.Areas.Api.Controllers.Jobs.Models; + +public sealed class JobsDto : Resource +{ + /// + /// The jobs. + /// + public JobDto[] Items { get; set; } + + public static JobsDto FromDomain(IEnumerable jobs, Resources resources) + { + var result = new JobsDto + { + Items = jobs.Select(x => JobDto.FromDomain(x, resources)).ToArray() + }; + + return result.CreateLinks(resources); + } + + private JobsDto CreateLinks(Resources resources) + { + var values = new { app = resources.App }; + + AddSelfLink(resources.Url(x => nameof(x.GetJobs), values)); + + if (resources.CanCreateBackup) + { + AddPostLink("create/backups", + resources.Url(x => nameof(x.PostBackup), values)); + } + + return this; + } +} diff --git a/backend/src/Squidex/Areas/Api/Controllers/Rules/Models/RulesDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Rules/Models/RulesDto.cs index 5ff4381eb6..10e876b44e 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Rules/Models/RulesDto.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Rules/Models/RulesDto.cs @@ -27,7 +27,7 @@ public sealed class RulesDto : Resource public static async Task FromRulesAsync(IEnumerable items, IRuleRunnerService ruleRunnerService, Resources resources) { var runningRuleId = await ruleRunnerService.GetRunningRuleIdAsync(resources.Context.App.Id); - var runningAvailable = runningRuleId != default; + var runningAvailable = runningRuleId == null; var result = new RulesDto { diff --git a/backend/src/Squidex/Areas/Api/Controllers/Rules/RulesController.cs b/backend/src/Squidex/Areas/Api/Controllers/Rules/RulesController.cs index c310caab39..53ef4bd376 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Rules/RulesController.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Rules/RulesController.cs @@ -20,6 +20,7 @@ using Squidex.Domain.Apps.Entities.Rules.Runner; using Squidex.Infrastructure; using Squidex.Infrastructure.Commands; +using Squidex.Infrastructure.Security; using Squidex.Shared; using Squidex.Web; @@ -240,7 +241,7 @@ public async Task TriggerRule(string app, DomainId id) [ApiCosts(1)] public async Task PutRuleRun(string app, DomainId id, [FromQuery] bool fromSnapshots = false) { - await ruleRunnerService.RunAsync(App.Id, id, fromSnapshots, HttpContext.RequestAborted); + await ruleRunnerService.RunAsync(User.Token()!, App.Id, id, fromSnapshots, HttpContext.RequestAborted); return NoContent(); } diff --git a/backend/src/Squidex/Areas/IdentityServer/Views/Profile/Profile.cshtml b/backend/src/Squidex/Areas/IdentityServer/Views/Profile/Profile.cshtml index 26820f0e55..87fdc873b6 100644 --- a/backend/src/Squidex/Areas/IdentityServer/Views/Profile/Profile.cshtml +++ b/backend/src/Squidex/Areas/IdentityServer/Views/Profile/Profile.cshtml @@ -97,6 +97,7 @@ +
- +
diff --git a/frontend/src/app/features/administration/pages/restore/restore-page.component.ts b/frontend/src/app/features/administration/pages/restore/restore-page.component.ts index dc61b1864c..c69aa0197d 100644 --- a/frontend/src/app/features/administration/pages/restore/restore-page.component.ts +++ b/frontend/src/app/features/administration/pages/restore/restore-page.component.ts @@ -10,7 +10,7 @@ import { Component } from '@angular/core'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router'; import { timer } from 'rxjs'; -import { AuthService, BackupsService, ControlErrorsComponent, DialogService, ISODatePipe, LayoutComponent, ListViewComponent, RestoreForm, SidebarMenuDirective, switchSafe, TitleComponent, TooltipDirective, TourStepDirective, TranslatePipe } from '@app/shared'; +import { AuthService, ControlErrorsComponent, DialogService, ISODatePipe, JobsService, LayoutComponent, ListViewComponent, RestoreForm, SidebarMenuDirective, switchSafe, TitleComponent, TooltipDirective, TourStepDirective, TranslatePipe } from '@app/shared'; @Component({ standalone: true, @@ -41,12 +41,12 @@ export class RestorePageComponent { public restoreForm = new RestoreForm(); public restoreJob = - timer(0, 2000).pipe(switchSafe(() => this.backupsService.getRestore())); + timer(0, 2000).pipe(switchSafe(() => this.jobsService.getRestore())); constructor( public readonly authState: AuthService, - private readonly backupsService: BackupsService, private readonly dialogs: DialogService, + private readonly jobsService: JobsService, ) { } @@ -56,10 +56,10 @@ export class RestorePageComponent { if (value) { this.restoreForm.submitCompleted(); - this.backupsService.postRestore(value) + this.jobsService.postRestore(value) .subscribe({ next: () => { - this.dialogs.notifyInfo('i18n:backups.restoreStarted'); + this.dialogs.notifyInfo('i18n:jobs.restoreStarted'); }, error: error => { this.dialogs.notifyError(error); diff --git a/frontend/src/app/features/settings/pages/asset-scripts/asset-scripts-page.component.html b/frontend/src/app/features/settings/pages/asset-scripts/asset-scripts-page.component.html index 1748363c4c..1b6f913d32 100644 --- a/frontend/src/app/features/settings/pages/asset-scripts/asset-scripts-page.component.html +++ b/frontend/src/app/features/settings/pages/asset-scripts/asset-scripts-page.component.html @@ -4,7 +4,7 @@ - diff --git a/frontend/src/app/features/settings/pages/backups/backup.component.html b/frontend/src/app/features/settings/pages/backups/backup.component.html deleted file mode 100644 index f8c256b893..0000000000 --- a/frontend/src/app/features/settings/pages/backups/backup.component.html +++ /dev/null @@ -1,49 +0,0 @@ -
-
-
- -
-
-
- {{ 'backups.startedLabel' | sqxTranslate }}: -
-
- {{ 'backups.backupDuration' | sqxTranslate }}: -
-
-
-
- {{backup.started | sqxFromNow}} -
-
- {{duration}} -
-
-
-
- - {{ 'backups.backupCountEventsLabel' | sqxTranslate }}: {{backup.handledEvents | sqxKNumber}} - , - - {{ 'backups.backupCountAssetsLabel' | sqxTranslate }}: {{backup.handledAssets | sqxKNumber}} - -
-
- {{ 'backups.backupDownload' | sqxTranslate }}: - - - {{ 'backups.backupDownloadLink' | sqxTranslate }} - -
-
-
- -
-
-
\ No newline at end of file diff --git a/frontend/src/app/features/settings/pages/backups/backup.component.ts b/frontend/src/app/features/settings/pages/backups/backup.component.ts deleted file mode 100644 index afa9f246c6..0000000000 --- a/frontend/src/app/features/settings/pages/backups/backup.component.ts +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Squidex Headless CMS - * - * @license - * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. - */ - -import { NgIf, NgSwitch } from '@angular/common'; -import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; -import { ApiUrlConfig, BackupDto, BackupsState, ConfirmClickDirective, Duration, ExternalLinkDirective, FromNowPipe, KNumberPipe, StatusIconComponent, TooltipDirective, TranslatePipe, TypedSimpleChanges } from '@app/shared'; - -@Component({ - standalone: true, - selector: 'sqx-backup', - styleUrls: ['./backup.component.scss'], - templateUrl: './backup.component.html', - changeDetection: ChangeDetectionStrategy.OnPush, - imports: [ - ConfirmClickDirective, - ExternalLinkDirective, - FromNowPipe, - KNumberPipe, - NgIf, - NgSwitch, - StatusIconComponent, - TooltipDirective, - TranslatePipe, - ], -}) -export class BackupComponent { - @Input({ required: true }) - public backup!: BackupDto; - - public duration = ''; - - constructor( - public readonly apiUrl: ApiUrlConfig, - private readonly backupsState: BackupsState, - ) { - } - - public ngOnChanges(changes: TypedSimpleChanges) { - if (changes.backup) { - this.duration = Duration.create(this.backup.started, this.backup.stopped!).toString(); - } - } - - public delete() { - this.backupsState.delete(this.backup); - } -} diff --git a/frontend/src/app/features/settings/pages/backups/backups-page.component.html b/frontend/src/app/features/settings/pages/backups/backups-page.component.html deleted file mode 100644 index f9c99b0f76..0000000000 --- a/frontend/src/app/features/settings/pages/backups/backups-page.component.html +++ /dev/null @@ -1,51 +0,0 @@ - - - - - - - - - - - -
- {{ 'backups.maximumReached' | sqxTranslate }} -
- - -
- {{ 'backups.empty' | sqxTranslate }} - - -
- - - -
-
-
- - -
- - - -
-
-
- - diff --git a/frontend/src/app/features/settings/pages/backups/backups-page.component.scss b/frontend/src/app/features/settings/pages/backups/backups-page.component.scss deleted file mode 100644 index 2742d895e7..0000000000 --- a/frontend/src/app/features/settings/pages/backups/backups-page.component.scss +++ /dev/null @@ -1,2 +0,0 @@ -@import 'mixins'; -@import 'vars'; \ No newline at end of file diff --git a/frontend/src/app/features/settings/pages/jobs/job.component.html b/frontend/src/app/features/settings/pages/jobs/job.component.html new file mode 100644 index 0000000000..9a7f5d3de6 --- /dev/null +++ b/frontend/src/app/features/settings/pages/jobs/job.component.html @@ -0,0 +1,60 @@ +
+
+
+
+ +
+
+
+

{{job.description}}

+
+ +
+
+ {{ 'jobs.startedLabel' | sqxTranslate }}: + + + {{job.started | sqxFromNow}} + +
+
+ {{ 'jobs.jobDuration' | sqxTranslate }}: + + + {{duration}} + +
+
+
+
+ + + + + + + +
+
+
+ +
+
+

{{ 'common.details' | sqxTranslate }}

+
+
+
+ +
+
+
+
\ No newline at end of file diff --git a/frontend/src/app/features/settings/pages/jobs/job.component.scss b/frontend/src/app/features/settings/pages/jobs/job.component.scss new file mode 100644 index 0000000000..060ce4a145 --- /dev/null +++ b/frontend/src/app/features/settings/pages/jobs/job.component.scss @@ -0,0 +1,32 @@ +@import 'mixins'; +@import 'vars'; + +.col { + &-options { + max-width: 160px; + } +} + +.job { + &-header, + &-dump { + padding: .75rem 1.25rem; + } + + &-header { + background: $color-border-light; + border: 0; + border-bottom: 2px solid $color-border; + position: relative; + + h4 { + font-size: 1rem; + font-weight: 500; + margin: 0; + } + } +} + +.btn-expand.expanded::before { + bottom: -1.35rem; +} \ No newline at end of file diff --git a/frontend/src/app/features/settings/pages/jobs/job.component.ts b/frontend/src/app/features/settings/pages/jobs/job.component.ts new file mode 100644 index 0000000000..5010faf146 --- /dev/null +++ b/frontend/src/app/features/settings/pages/jobs/job.component.ts @@ -0,0 +1,73 @@ +/* + * Squidex Headless CMS + * + * @license + * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. + */ + +import { NgIf, NgSwitch } from '@angular/common'; +import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { ApiUrlConfig, CodeEditorComponent, ConfirmClickDirective, Duration, ExternalLinkDirective, FromNowPipe, JobDto, JobsState, KNumberPipe, StatusIconComponent, TooltipDirective, TranslatePipe, TypedSimpleChanges } from '@app/shared'; + +@Component({ + standalone: true, + selector: 'sqx-job', + styleUrls: ['./job.component.scss'], + templateUrl: './job.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + CodeEditorComponent, + ConfirmClickDirective, + ExternalLinkDirective, + FormsModule, + FromNowPipe, + KNumberPipe, + NgIf, + NgSwitch, + StatusIconComponent, + TooltipDirective, + TranslatePipe, + ], +}) +export class JobComponent { + @Input({ required: true }) + public job!: JobDto; + + public duration = ''; + public details = ''; + + public expanded = false; + + constructor( + public readonly apiUrl: ApiUrlConfig, + private readonly jobsState: JobsState, + ) { + } + + public ngOnChanges(changes: TypedSimpleChanges) { + if (changes.job) { + this.duration = Duration.create(this.job.started, this.job.stopped!).toString(); + + this.details = ''; + this.details += 'Arguments:\n'; + this.details += JSON.stringify(this.job.taskArguments, undefined, 2); + + if (this.job.log.length > 0) { + this.details += '\n\nLog:'; + + for (const log of this.job.log) { + this.details += `\n${log.timestamp.toISODateUTC()} ${log.message}`; + } + } + } + } + + public delete() { + this.jobsState.delete(this.job); + } + + public toggleExpanded() { + this.expanded = !this.expanded; + } +} diff --git a/frontend/src/app/features/settings/pages/jobs/jobs-page.component.html b/frontend/src/app/features/settings/pages/jobs/jobs-page.component.html new file mode 100644 index 0000000000..f7711f562f --- /dev/null +++ b/frontend/src/app/features/settings/pages/jobs/jobs-page.component.html @@ -0,0 +1,47 @@ + + + + + + + + + + + +
+ {{ 'jobs.backupMaximumReached' | sqxTranslate }} +
+ + +
+ {{ 'jobs.empty' | sqxTranslate }} +
+ + + +
+
+
+ + +
+ + + +
+
+
+ + diff --git a/frontend/src/app/features/settings/pages/backups/backup.component.scss b/frontend/src/app/features/settings/pages/jobs/jobs-page.component.scss similarity index 100% rename from frontend/src/app/features/settings/pages/backups/backup.component.scss rename to frontend/src/app/features/settings/pages/jobs/jobs-page.component.scss diff --git a/frontend/src/app/features/settings/pages/backups/backups-page.component.ts b/frontend/src/app/features/settings/pages/jobs/jobs-page.component.ts similarity index 53% rename from frontend/src/app/features/settings/pages/backups/backups-page.component.ts rename to frontend/src/app/features/settings/pages/jobs/jobs-page.component.ts index e6e3ad059a..8875942d3b 100644 --- a/frontend/src/app/features/settings/pages/backups/backups-page.component.ts +++ b/frontend/src/app/features/settings/pages/jobs/jobs-page.component.ts @@ -10,17 +10,17 @@ import { Component, OnInit } from '@angular/core'; import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router'; import { timer } from 'rxjs'; import { switchMap } from 'rxjs/operators'; -import { ApiUrlConfig, BackupDto, BackupsState, LayoutComponent, ListViewComponent, ShortcutDirective, SidebarMenuDirective, Subscriptions, TitleComponent, TooltipDirective, TourStepDirective, TranslatePipe } from '@app/shared'; -import { BackupComponent } from './backup.component'; +import { ApiUrlConfig, JobDto, JobsState, LayoutComponent, ListViewComponent, ShortcutDirective, SidebarMenuDirective, Subscriptions, TitleComponent, TooltipDirective, TourStepDirective, TranslatePipe } from '@app/shared'; +import { JobComponent } from './job.component'; @Component({ standalone: true, - selector: 'sqx-backups-page', - styleUrls: ['./backups-page.component.scss'], - templateUrl: './backups-page.component.html', + selector: 'sqx-jobs-page', + styleUrls: ['./jobs-page.component.scss'], + templateUrl: './jobs-page.component.html', imports: [ AsyncPipe, - BackupComponent, + JobComponent, LayoutComponent, ListViewComponent, NgFor, @@ -36,30 +36,32 @@ import { BackupComponent } from './backup.component'; TranslatePipe, ], }) -export class BackupsPageComponent implements OnInit { +export class JobsPageComponent implements OnInit { private readonly subscriptions = new Subscriptions(); constructor( public readonly apiUrl: ApiUrlConfig, - public readonly backupsState: BackupsState, + public readonly jobsState: JobsState, ) { } public ngOnInit() { - this.backupsState.load(true); + this.jobsState.load(); - this.subscriptions.add(timer(3000, 3000).pipe(switchMap(() => this.backupsState.load(false, true)))); + this.subscriptions.add( + timer(3000, 3000).pipe( + switchMap(() => this.jobsState.load(false, true)))); } public reload() { - this.backupsState.load(true, false); + this.jobsState.load(true, false); } - public start() { - this.backupsState.start(); + public startBackup() { + this.jobsState.startBackup(); } - public trackByBackup(_index: number, item: BackupDto) { + public trackByJob(_index: number, item: JobDto) { return item.id; } } diff --git a/frontend/src/app/features/settings/routes.ts b/frontend/src/app/features/settings/routes.ts index 3140bf28f9..d70d5233af 100644 --- a/frontend/src/app/features/settings/routes.ts +++ b/frontend/src/app/features/settings/routes.ts @@ -8,9 +8,9 @@ import { Routes } from '@angular/router'; import { HelpComponent, HistoryComponent } from '@app/shared'; import { AssetScriptsPageComponent } from './pages/asset-scripts/asset-scripts-page.component'; -import { BackupsPageComponent } from './pages/backups/backups-page.component'; import { ClientsPageComponent } from './pages/clients/clients-page.component'; import { ContributorsPageComponent } from './pages/contributors/contributors-page.component'; +import { JobsPageComponent } from './pages/jobs/jobs-page.component'; import { LanguagesPageComponent } from './pages/languages/languages-page.component'; import { MorePageComponent } from './pages/more/more-page.component'; import { PlansPageComponent } from './pages/plans/plans-page.component'; @@ -47,13 +47,17 @@ export const SETTINGS_ROUTES: Routes = [ }, { path: 'backups', - component: BackupsPageComponent, + redirectTo: 'jobs', + }, + { + path: 'jobs', + component: JobsPageComponent, children: [ { path: 'help', component: HelpComponent, data: { - helpPage: '05-integrated/backups', + helpPage: '05-integrated/jobs', }, }, ], diff --git a/frontend/src/app/features/settings/settings-menu.component.html b/frontend/src/app/features/settings/settings-menu.component.html index f280669670..85a437e058 100644 --- a/frontend/src/app/features/settings/settings-menu.component.html +++ b/frontend/src/app/features/settings/settings-menu.component.html @@ -50,9 +50,9 @@ {{ 'common.templates' | sqxTranslate }} -