Browse Source

Jobs (#1062)

* More progress with jobs

* Jobs V1

* Tests updated

* Fix jobs.

* Fix languages.

* Fix exception handling.

* Revert restore endpoint behavior.

* Fix endpoint.
pull/1063/head
Sebastian Stehle 2 years ago
committed by GitHub
parent
commit
0852d21bb0
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 70
      backend/i18n/frontend_en.json
  2. 70
      backend/i18n/frontend_fr.json
  3. 70
      backend/i18n/frontend_it.json
  4. 70
      backend/i18n/frontend_nl.json
  5. 70
      backend/i18n/frontend_pt.json
  6. 70
      backend/i18n/frontend_zh.json
  7. 15
      backend/i18n/source/backend_en.json
  8. 70
      backend/i18n/source/frontend_en.json
  9. 24
      backend/i18n/translator/Squidex.Translator/Processes/Helper.cs
  10. 11
      backend/src/Migrations/MigrationPath.cs
  11. 6
      backend/src/Migrations/Migrations/Backup/BackupJob.cs
  12. 50
      backend/src/Migrations/Migrations/Backup/BackupState.cs
  13. 9
      backend/src/Migrations/Migrations/Backup/BackupStatus.cs
  14. 39
      backend/src/Migrations/Migrations/Backup/ConvertBackup.cs
  15. 4
      backend/src/Squidex.Domain.Apps.Core.Operations/IUrlGenerator.cs
  16. 7
      backend/src/Squidex.Domain.Apps.Entities/Apps/AppSettingsSearchSource.cs
  17. 141
      backend/src/Squidex.Domain.Apps.Entities/Backup/BackupJob.cs
  18. 54
      backend/src/Squidex.Domain.Apps.Entities/Backup/BackupProcessor.Run.cs
  19. 269
      backend/src/Squidex.Domain.Apps.Entities/Backup/BackupProcessor.cs
  20. 98
      backend/src/Squidex.Domain.Apps.Entities/Backup/BackupService.cs
  21. 88
      backend/src/Squidex.Domain.Apps.Entities/Backup/BackupWorker.cs
  22. 26
      backend/src/Squidex.Domain.Apps.Entities/Backup/IBackupJob.cs
  23. 381
      backend/src/Squidex.Domain.Apps.Entities/Backup/RestoreJob.cs
  24. 57
      backend/src/Squidex.Domain.Apps.Entities/Backup/RestoreProcessor.Run.cs
  25. 445
      backend/src/Squidex.Domain.Apps.Entities/Backup/RestoreProcessor.cs
  26. 29
      backend/src/Squidex.Domain.Apps.Entities/Backup/State/BackupState.cs
  27. 43
      backend/src/Squidex.Domain.Apps.Entities/Backup/State/RestoreJob.cs
  28. 93
      backend/src/Squidex.Domain.Apps.Entities/Jobs/DefaultJobService.cs
  29. 31
      backend/src/Squidex.Domain.Apps.Entities/Jobs/IJobRunner.cs
  30. 17
      backend/src/Squidex.Domain.Apps.Entities/Jobs/IJobService.cs
  31. 33
      backend/src/Squidex.Domain.Apps.Entities/Jobs/Job.cs
  32. 11
      backend/src/Squidex.Domain.Apps.Entities/Jobs/JobFile.cs
  33. 15
      backend/src/Squidex.Domain.Apps.Entities/Jobs/JobLogMessage.cs
  34. 207
      backend/src/Squidex.Domain.Apps.Entities/Jobs/JobProcessor.cs
  35. 23
      backend/src/Squidex.Domain.Apps.Entities/Jobs/JobRequest.cs
  36. 75
      backend/src/Squidex.Domain.Apps.Entities/Jobs/JobRunContext.cs
  37. 3
      backend/src/Squidex.Domain.Apps.Entities/Jobs/JobStatus.cs
  38. 79
      backend/src/Squidex.Domain.Apps.Entities/Jobs/JobWorker.cs
  39. 38
      backend/src/Squidex.Domain.Apps.Entities/Jobs/JobsState.cs
  40. 12
      backend/src/Squidex.Domain.Apps.Entities/Jobs/Messages.cs
  41. 38
      backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/DefaultRuleRunnerService.cs
  42. 2
      backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/IRuleRunnerService.cs
  43. 206
      backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/RuleRunnerJob.cs
  44. 298
      backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/RuleRunnerProcessor.cs
  45. 23
      backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/RuleRunnerState.cs
  46. 78
      backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/RuleRunnerWorker.cs
  47. 11
      backend/src/Squidex.Shared/PermissionIds.cs
  48. 45
      backend/src/Squidex.Shared/Texts.fr.resx
  49. 45
      backend/src/Squidex.Shared/Texts.it.resx
  50. 45
      backend/src/Squidex.Shared/Texts.nl.resx
  51. 45
      backend/src/Squidex.Shared/Texts.pt.resx
  52. 45
      backend/src/Squidex.Shared/Texts.resx
  53. 45
      backend/src/Squidex.Shared/Texts.zh.resx
  54. 8
      backend/src/Squidex.Web/Resources.cs
  55. 10
      backend/src/Squidex.Web/Services/UrlGenerator.cs
  56. 8
      backend/src/Squidex/Areas/Api/Controllers/Apps/Models/AppDto.cs
  57. 27
      backend/src/Squidex/Areas/Api/Controllers/Backups/BackupContentController.cs
  58. 27
      backend/src/Squidex/Areas/Api/Controllers/Backups/BackupsController.cs
  59. 20
      backend/src/Squidex/Areas/Api/Controllers/Backups/Models/BackupJobDto.cs
  60. 7
      backend/src/Squidex/Areas/Api/Controllers/Backups/Models/BackupJobsDto.cs
  61. 16
      backend/src/Squidex/Areas/Api/Controllers/Backups/Models/RestoreJobDto.cs
  62. 16
      backend/src/Squidex/Areas/Api/Controllers/Backups/RestoreController.cs
  63. 67
      backend/src/Squidex/Areas/Api/Controllers/Jobs/JobsContentController.cs
  64. 70
      backend/src/Squidex/Areas/Api/Controllers/Jobs/JobsController.cs
  65. 98
      backend/src/Squidex/Areas/Api/Controllers/Jobs/Models/JobDto.cs
  66. 29
      backend/src/Squidex/Areas/Api/Controllers/Jobs/Models/JobLogMessageDto.cs
  67. 45
      backend/src/Squidex/Areas/Api/Controllers/Jobs/Models/JobsDto.cs
  68. 2
      backend/src/Squidex/Areas/Api/Controllers/Rules/Models/RulesDto.cs
  69. 3
      backend/src/Squidex/Areas/Api/Controllers/Rules/RulesController.cs
  70. 1
      backend/src/Squidex/Areas/IdentityServer/Views/Profile/Profile.cshtml
  71. 11
      backend/src/Squidex/Config/Domain/BackupsServices.cs
  72. 25
      backend/src/Squidex/Config/Messaging/MessagingServices.cs
  73. 23
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/AppSettingsSearchSourceTests.cs
  74. 1
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/DomainObject/AppDomainObjectTests.cs
  75. 3
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/DomainObject/AssetDomainObjectTests.cs
  76. 161
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Backup/BackupServiceTests.cs
  77. 1
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/DomainObject/ContentDomainObjectTests.cs
  78. 10
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/TestData/FakeUrlGenerator.cs
  79. 162
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Jobs/DefaultJobsServiceTests.cs
  80. 4
      frontend/src/app/features/administration/pages/event-consumers/event-consumers-page.component.ts
  81. 16
      frontend/src/app/features/administration/pages/restore/restore-page.component.html
  82. 10
      frontend/src/app/features/administration/pages/restore/restore-page.component.ts
  83. 2
      frontend/src/app/features/settings/pages/asset-scripts/asset-scripts-page.component.html
  84. 49
      frontend/src/app/features/settings/pages/backups/backup.component.html
  85. 51
      frontend/src/app/features/settings/pages/backups/backup.component.ts
  86. 51
      frontend/src/app/features/settings/pages/backups/backups-page.component.html
  87. 2
      frontend/src/app/features/settings/pages/backups/backups-page.component.scss
  88. 60
      frontend/src/app/features/settings/pages/jobs/job.component.html
  89. 32
      frontend/src/app/features/settings/pages/jobs/job.component.scss
  90. 73
      frontend/src/app/features/settings/pages/jobs/job.component.ts
  91. 47
      frontend/src/app/features/settings/pages/jobs/jobs-page.component.html
  92. 0
      frontend/src/app/features/settings/pages/jobs/jobs-page.component.scss
  93. 30
      frontend/src/app/features/settings/pages/jobs/jobs-page.component.ts
  94. 10
      frontend/src/app/features/settings/routes.ts
  95. 6
      frontend/src/app/features/settings/settings-menu.component.html
  96. 6
      frontend/src/app/shared/internal.ts
  97. 12
      frontend/src/app/shared/services/apps.service.spec.ts
  98. 6
      frontend/src/app/shared/services/apps.service.ts
  99. 12
      frontend/src/app/shared/services/assets.service.spec.ts
  100. 16
      frontend/src/app/shared/services/contents.service.spec.ts

70
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",

70
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",

70
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",

70
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",

70
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",

70
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",

15
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.",

70
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",

24
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 class Helper
private static bool HasInvalidPrefixes(HashSet<string> 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;
}
}

11
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 sealed class MigrationPath : IMigrationPath
yield return serviceProvider.GetRequiredService<ConvertRuleEventsJson>();
}
// 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<CopyRuleStatistics>();
}
// Version 27: General jobs state.
if (version < 27)
{
yield return serviceProvider.GetRequiredService<ConvertBackup>();
}
}
}

6
backend/src/Squidex.Domain.Apps.Entities/Backup/State/BackupJob.cs → 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; }
}

50
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<BackupJob> 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}")
]
};
}
}

9
backend/src/Squidex.Domain.Apps.Entities/Backup/State/BackupRestoreState.cs → 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
}

39
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<BackupState> stateBackups;
private readonly ISnapshotStore<JobsState> stateJobs;
public ConvertBackup(
ISnapshotStore<BackupState> stateBackups,
ISnapshotStore<JobsState> 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<JobsState>(state.Key, job, 0), ct);
}
await stateBackups.ClearAsync(ct);
}
}

4
backend/src/Squidex.Domain.Apps.Core.Operations/IUrlGenerator.cs

@ -28,8 +28,6 @@ public interface IUrlGenerator
string AssetContentBase(string appName);
string BackupsUI(NamedId<DomainId> appId);
string ClientsUI(NamedId<DomainId> appId);
string ContentCDNBase();
@ -46,6 +44,8 @@ public interface IUrlGenerator
string LanguagesUI(NamedId<DomainId> appId);
string JobsUI(NamedId<DomainId> appId);
string PatternsUI(NamedId<DomainId> appId);
string PlansUI(NamedId<DomainId> appId);

7
backend/src/Squidex.Domain.Apps.Entities/Apps/AppSettingsSearchSource.cs

@ -46,8 +46,11 @@ public sealed class AppSettingsSearchSource : ISearchSource
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);

141
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<string, string>
{
[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);
}
}

54
backend/src/Squidex.Domain.Apps.Entities/Backup/BackupProcessor.Run.cs

@ -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<IBackupHandler> 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.
}
}
}
}

269
backend/src/Squidex.Domain.Apps.Entities/Backup/BackupProcessor.cs

@ -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<BackupProcessor> log;
private readonly SimpleState<BackupState> 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<BackupState> persistenceFactory,
IUserResolver userResolver,
ILogger<BackupProcessor> 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<BackupState>(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);
}
}

98
backend/src/Squidex.Domain.Apps.Entities/Backup/BackupService.cs

@ -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<BackupRestoreState> restoreState;
private readonly IPersistenceFactory<BackupState> persistenceFactoryBackup;
private readonly IMessageBus messaging;
public BackupService(
IPersistenceFactory<BackupRestoreState> persistenceFactoryRestore,
IPersistenceFactory<BackupState> persistenceFactoryBackup,
IMessageBus messaging)
{
this.persistenceFactoryBackup = persistenceFactoryBackup;
this.messaging = messaging;
restoreState = new SimpleState<BackupRestoreState>(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<IRestoreJob> GetRestoreAsync(
CancellationToken ct = default)
{
await restoreState.LoadAsync(ct);
return restoreState.Value.Job ?? new RestoreJob();
}
public async Task<List<IBackupJob>> GetBackupsAsync(DomainId appId,
CancellationToken ct = default)
{
var state = await GetStateAsync(appId, ct);
return state.Value.Jobs.OfType<IBackupJob>().ToList();
}
public async Task<IBackupJob?> 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<SimpleState<BackupState>> GetStateAsync(DomainId appId,
CancellationToken ct)
{
var state = new SimpleState<BackupState>(persistenceFactoryBackup, GetType(), appId);
await state.LoadAsync(ct);
return state;
}
}

88
backend/src/Squidex.Domain.Apps.Entities/Backup/BackupWorker.cs

@ -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<BackupRestore>,
IMessageHandler<BackupStart>,
IMessageHandler<BackupDelete>,
IMessageHandler<BackupClear>,
IInitializable
{
private readonly Dictionary<DomainId, Task<BackupProcessor>> backupProcessors = [];
private readonly Func<DomainId, BackupProcessor> 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<RestoreProcessor>();
}
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<BackupProcessor> GetBackupProcessorAsync(DomainId appId)
{
lock (backupProcessors)
{
return backupProcessors.GetOrAdd(appId, async key =>
{
var processor = backupFactory(key);
await processor.LoadAsync(default);
return processor;
});
}
}
}

26
backend/src/Squidex.Domain.Apps.Entities/Backup/IBackupJob.cs

@ -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; }
}

381
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<RestoreJob> log;
// Use a run to store all state that is necessary for a single run.
private sealed class State
{
public NamedId<DomainId> AppId { get; set; }
public IEnumerable<IBackupHandler> 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<RestoreJob> 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<string, string>
{
[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<CommandContext> 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<IBackupReader> 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<IEvent> 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<IEvent> @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);
}
}

57
backend/src/Squidex.Domain.Apps.Entities/Backup/RestoreProcessor.Run.cs

@ -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<IBackupHandler> 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.
}
}
}
}

445
backend/src/Squidex.Domain.Apps.Entities/Backup/RestoreProcessor.cs

@ -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<RestoreProcessor> log;
private readonly ReentrantScheduler scheduler = new ReentrantScheduler(1);
private readonly SimpleState<BackupRestoreState> 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<BackupRestoreState> persistenceFactory,
IUserResolver userResolver,
ILogger<RestoreProcessor> 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<BackupRestoreState>(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<IBackupReader> 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<IEvent> 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<IEvent> @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);
}
}

29
backend/src/Squidex.Domain.Apps.Entities/Backup/State/BackupState.cs

@ -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<BackupJob> 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 }));
}
}
}

43
backend/src/Squidex.Domain.Apps.Entities/Backup/State/RestoreJob.cs

@ -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<DomainId> 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<string> 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"));
}
}
}

93
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<IJobRunner> runners;
private readonly IPersistenceFactory<JobsState> persistence;
public DefaultJobService(IMessageBus messaging, IEnumerable<IJobRunner> runners, IPersistenceFactory<JobsState> 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<List<Job>> GetJobsAsync(DomainId ownerId,
CancellationToken ct = default)
{
var state = await GetStateAsync(ownerId, ct);
return state.Jobs;
}
private async Task<JobsState> GetStateAsync(DomainId ownerId,
CancellationToken ct = default)
{
var state = new SimpleState<JobsState>(persistence, typeof(JobProcessor), ownerId);
await state.LoadAsync(ct);
return state.Value;
}
}

31
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;
}
}

17
backend/src/Squidex.Domain.Apps.Entities/Backup/IBackupService.cs → 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<List<Job>> GetJobsAsync(DomainId ownerId,
CancellationToken ct = default);
Task<IRestoreJob> GetRestoreAsync(
Task CancelAsync(DomainId ownerId, string? taskName = null,
CancellationToken ct = default);
Task<List<IBackupJob>> GetBackupsAsync(DomainId appId,
Task DeleteJobAsync(DomainId ownerId, DomainId jobId,
CancellationToken ct = default);
Task<IBackupJob?> GetBackupAsync(DomainId appId, DomainId backupId,
CancellationToken ct = default);
Task DeleteBackupAsync(DomainId appId, DomainId backupId,
Task DownloadAsync(Job job, Stream stream,
CancellationToken ct = default);
}

33
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<string, string> Arguments { get; init; }
public List<JobLogMessage> Log { get; set; } = [];
public JobStatus Status { get; set; }
}

11
backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/Messages.cs → 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)
{
}

15
backend/src/Squidex.Domain.Apps.Entities/Backup/IRestoreJob.cs → 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<string> Log { get; }
JobStatus Status { get; }
}
public record struct JobLogMessage(Instant Timestamp, string Message);

207
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<IJobRunner> runners;
private readonly ILocalCache localCache;
private readonly ILogger<JobProcessor> log;
private readonly SimpleState<JobsState> state;
private readonly ReentrantScheduler scheduler = new ReentrantScheduler(1);
private JobRunContext? currentRun;
public IClock Clock { get; init; } = SystemClock.Instance;
public JobProcessor(DomainId ownerId,
IEnumerable<IJobRunner> runners,
ILocalCache localCache,
IPersistenceFactory<JobsState> persistenceFactory,
ILogger<JobProcessor> log)
{
this.ownerId = ownerId;
this.runners = runners;
this.localCache = localCache;
this.log = log;
state = new SimpleState<JobsState>(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);
}
}

23
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<string, string> Arguments)
{
public static JobRequest Create(RefToken actor, string taskName, Dictionary<string, string>? arguments = null)
{
var args = arguments?.ToReadonlyDictionary() ?? ReadonlyDictionary.Empty<string, string>();
return new JobRequest(actor, taskName, args);
}
}

75
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<JobsState> 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<JobsState> 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.
}
}
}

3
backend/src/Squidex.Domain.Apps.Entities/Backup/JobStatus.cs → 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
}

79
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<JobStart>,
IMessageHandler<JobDelete>,
IMessageHandler<JobCancel>,
IMessageHandler<JobClear>
{
private readonly Dictionary<DomainId, Task<JobProcessor>> processors = [];
private readonly Func<DomainId, JobProcessor> 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<JobProcessor> GetJobProcessorAsync(DomainId appId)
{
lock (processors)
{
return processors.GetOrAdd(appId, async key =>
{
var processor = processorFactory(key);
await processor.LoadAsync(default);
return processor;
});
}
}
}

38
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<Job> 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 }));
}
}
}

12
backend/src/Squidex.Domain.Apps.Entities/Backup/Messages.cs → backend/src/Squidex.Domain.Apps.Entities/Jobs/Messages.cs

@ -10,12 +10,14 @@ 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.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);

38
backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/DefaultRuleRunnerService.cs

@ -9,36 +9,32 @@ using NodaTime;
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<RuleRunnerState> persistenceFactory;
private readonly IJobService jobService;
private readonly IEventFormatter eventFormatter;
private readonly IEventStore eventStore;
private readonly IRuleService ruleService;
private readonly IMessageBus messaging;
public DefaultRuleRunnerService(
IPersistenceFactory<RuleRunnerState> 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<List<SimulatedRuleEvent>> SimulateAsync(Rule rule,
@ -120,30 +116,24 @@ public sealed class DefaultRuleRunnerService : IRuleRunnerService
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<DomainId?> 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<SimpleState<RuleRunnerState>> GetStateAsync(DomainId appId,
CancellationToken ct)
public async Task<DomainId?> GetRunningRuleIdAsync(DomainId appId,
CancellationToken ct = default)
{
var state = new SimpleState<RuleRunnerState>(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);
}
}

2
backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/IRuleRunnerService.cs

@ -18,7 +18,7 @@ public interface IRuleRunnerService
Task<List<SimulatedRuleEvent>> 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,

206
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<RuleRunnerJob> log;
public string Name => TaskName;
public RuleRunnerJob(
IAppProvider appProvider,
IEventFormatter eventFormatter,
IEventStore eventStore,
IRuleEventRepository ruleEventRepository,
IRuleService ruleService,
IRuleUsageTracker ruleUsageTracker,
ILogger<RuleRunnerJob> 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<string, string>
{
[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();
}
}

298
backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/RuleRunnerProcessor.cs

@ -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<RuleRunnerProcessor> log;
private readonly SimpleState<RuleRunnerState> 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<RuleRunnerState> persistenceFactory,
IRuleEventRepository ruleEventRepository,
IRuleService ruleService,
IRuleUsageTracker ruleUsageTracker,
ILogger<RuleRunnerProcessor> 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<RuleRunnerState>(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);
}
}
}

23
backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/RuleRunnerState.cs

@ -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; }
}

78
backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/RuleRunnerWorker.cs

@ -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<RuleRunnerRun>,
IMessageHandler<RuleRunnerCancel>
{
private readonly Dictionary<DomainId, Task<RuleRunnerProcessor>> processors = [];
private readonly Func<DomainId, RuleRunnerProcessor> processorFactory;
private readonly ISnapshotStore<RuleRunnerState> snapshotStore;
public RuleRunnerWorker(IServiceProvider serviceProvider, ISnapshotStore<RuleRunnerState> 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<RuleRunnerProcessor> 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;
});
}
}
}

11
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";

45
backend/src/Squidex.Shared/Texts.fr.resx

@ -184,21 +184,6 @@
<data name="assets.referenced" xml:space="preserve">
<value>Les actifs sont référencés par un contenu et ne peuvent pas être supprimés.</value>
</data>
<data name="backups.alreadyRunning" xml:space="preserve">
<value>Un autre processus de sauvegarde est déjà en cours d'exécution.</value>
</data>
<data name="backups.maxReached" xml:space="preserve">
<value>Vous ne pouvez pas avoir plus de {max} sauvegardes.</value>
</data>
<data name="backups.restoreRunning" xml:space="preserve">
<value>Une opération de restauration est déjà en cours.</value>
</data>
<data name="comments.noPermissions" xml:space="preserve">
<value>Vous ne pouvez accéder qu'à vos notifications.</value>
</data>
<data name="comments.notUserComment" xml:space="preserve">
<value>Le commentaire est créé par un autre utilisateur.</value>
</data>
<data name="common.action" xml:space="preserve">
<value>Action</value>
</data>
@ -886,12 +871,36 @@
<data name="history.teams.updated" xml:space="preserve">
<value>paramètres généraux mis à jour et nom renommé en {[Name]}.</value>
</data>
<data name="job.backup" xml:space="preserve">
<value>Backup</value>
</data>
<data name="job.restore" xml:space="preserve">
<value>Restore</value>
</data>
<data name="job.ruleRun" xml:space="preserve">
<value>Replay Rule.</value>
</data>
<data name="job.ruleRunNamed" xml:space="preserve">
<value>Replay Rule '{name}'.</value>
</data>
<data name="job.ruleRunNamedSnapshot" xml:space="preserve">
<value>Replay Rule '{name}' from states.</value>
</data>
<data name="job.ruleRunSnapshot" xml:space="preserve">
<value>Replay Rule from states</value>
</data>
<data name="jobs.alreadyRunning" xml:space="preserve">
<value>Another job is already running.</value>
</data>
<data name="jobs.invalidTaskName" xml:space="preserve">
<value>Invalid task name</value>
</data>
<data name="jobs.maxReached" xml:space="preserve">
<value>You cannot have more than {max} backups.</value>
</data>
<data name="login.githubPrivateEmail" xml:space="preserve">
<value>Votre adresse e-mail est définie sur privé dans Github. Veuillez le définir sur public pour utiliser la connexion Github.</value>
</data>
<data name="rules.ruleAlreadyRunning" xml:space="preserve">
<value>Une autre règle est déjà en cours d'exécution.</value>
</data>
<data name="schemas.dateTimeCalculatedDefaultAndDefaultError" xml:space="preserve">
<value>La valeur par défaut calculée et la valeur par défaut ne peuvent pas être utilisées ensemble.</value>
</data>

45
backend/src/Squidex.Shared/Texts.it.resx

@ -184,21 +184,6 @@
<data name="assets.referenced" xml:space="preserve">
<value>La risorsa è collegata ad un contenuto pertanto non può essere cancellata.</value>
</data>
<data name="backups.alreadyRunning" xml:space="preserve">
<value>È in esecuzione una altro processo di backup.</value>
</data>
<data name="backups.maxReached" xml:space="preserve">
<value>Non puoi avere più di {max} backup.</value>
</data>
<data name="backups.restoreRunning" xml:space="preserve">
<value>È in esecuzione un'operazione di restore.</value>
</data>
<data name="comments.noPermissions" xml:space="preserve">
<value>Puoi solo accedere alle tue notifiche.</value>
</data>
<data name="comments.notUserComment" xml:space="preserve">
<value>È stato creato un commento da un altro utente.</value>
</data>
<data name="common.action" xml:space="preserve">
<value>Azione</value>
</data>
@ -886,12 +871,36 @@
<data name="history.teams.updated" xml:space="preserve">
<value>updated general settings and renamed name to {[Name]}.</value>
</data>
<data name="job.backup" xml:space="preserve">
<value>Backup</value>
</data>
<data name="job.restore" xml:space="preserve">
<value>Restore</value>
</data>
<data name="job.ruleRun" xml:space="preserve">
<value>Replay Rule.</value>
</data>
<data name="job.ruleRunNamed" xml:space="preserve">
<value>Replay Rule '{name}'.</value>
</data>
<data name="job.ruleRunNamedSnapshot" xml:space="preserve">
<value>Replay Rule '{name}' from states.</value>
</data>
<data name="job.ruleRunSnapshot" xml:space="preserve">
<value>Replay Rule from states</value>
</data>
<data name="jobs.alreadyRunning" xml:space="preserve">
<value>Another job is already running.</value>
</data>
<data name="jobs.invalidTaskName" xml:space="preserve">
<value>Invalid task name</value>
</data>
<data name="jobs.maxReached" xml:space="preserve">
<value>You cannot have more than {max} backups.</value>
</data>
<data name="login.githubPrivateEmail" xml:space="preserve">
<value>Il tuo indirizzo email è impostato su privato in Github. Impostalo come pubblico per poter utilizzare il login Github.</value>
</data>
<data name="rules.ruleAlreadyRunning" xml:space="preserve">
<value>È in esecuzione un'altra regola.</value>
</data>
<data name="schemas.dateTimeCalculatedDefaultAndDefaultError" xml:space="preserve">
<value>Il valore predefinito calcolato e il valore predefinito non possono essere utilizzati insieme.</value>
</data>

45
backend/src/Squidex.Shared/Texts.nl.resx

@ -184,21 +184,6 @@
<data name="assets.referenced" xml:space="preserve">
<value>Er wordt naar dit bestand verwezen door een contentitem en kan niet worden verwijderd.</value>
</data>
<data name="backups.alreadyRunning" xml:space="preserve">
<value>Er wordt al een ander back-upproces uitgevoerd.</value>
</data>
<data name="backups.maxReached" xml:space="preserve">
<value>Je kunt niet meer dan {max} backups hebben.</value>
</data>
<data name="backups.restoreRunning" xml:space="preserve">
<value>Er wordt al een herstelbewerking uitgevoerd.</value>
</data>
<data name="comments.noPermissions" xml:space="preserve">
<value>Je hebt alleen toegang tot jouw notificaties.</value>
</data>
<data name="comments.notUserComment" xml:space="preserve">
<value>Reactie is gemaakt door een andere gebruiker.</value>
</data>
<data name="common.action" xml:space="preserve">
<value>Actie</value>
</data>
@ -886,12 +871,36 @@
<data name="history.teams.updated" xml:space="preserve">
<value>updated general settings and renamed name to {[Name]}.</value>
</data>
<data name="job.backup" xml:space="preserve">
<value>Backup</value>
</data>
<data name="job.restore" xml:space="preserve">
<value>Restore</value>
</data>
<data name="job.ruleRun" xml:space="preserve">
<value>Replay Rule.</value>
</data>
<data name="job.ruleRunNamed" xml:space="preserve">
<value>Replay Rule '{name}'.</value>
</data>
<data name="job.ruleRunNamedSnapshot" xml:space="preserve">
<value>Replay Rule '{name}' from states.</value>
</data>
<data name="job.ruleRunSnapshot" xml:space="preserve">
<value>Replay Rule from states</value>
</data>
<data name="jobs.alreadyRunning" xml:space="preserve">
<value>Another job is already running.</value>
</data>
<data name="jobs.invalidTaskName" xml:space="preserve">
<value>Invalid task name</value>
</data>
<data name="jobs.maxReached" xml:space="preserve">
<value>You cannot have more than {max} backups.</value>
</data>
<data name="login.githubPrivateEmail" xml:space="preserve">
<value>Jouw e-mailadres is ingesteld op privé in Github. Stel het in op openbaar om Github-login te gebruiken.</value>
</data>
<data name="rules.ruleAlreadyRunning" xml:space="preserve">
<value>Er wordt al een andere regel uitgevoerd.</value>
</data>
<data name="schemas.dateTimeCalculatedDefaultAndDefaultError" xml:space="preserve">
<value>Berekende standaardwaarde en standaardwaarde kunnen niet samen worden gebruikt.</value>
</data>

45
backend/src/Squidex.Shared/Texts.pt.resx

@ -184,21 +184,6 @@
<data name="assets.referenced" xml:space="preserve">
<value>Os ficheiros são referenciados por um conteúdo e não podem ser excluídos.</value>
</data>
<data name="backups.alreadyRunning" xml:space="preserve">
<value>Já se encontra um processo de backup em processamento.</value>
</data>
<data name="backups.maxReached" xml:space="preserve">
<value>Não pode ter mais de {max} backups.</value>
</data>
<data name="backups.restoreRunning" xml:space="preserve">
<value>Um processamento de restauro já se encontra a correr.</value>
</data>
<data name="comments.noPermissions" xml:space="preserve">
<value>Só pode aceder as suas notificações.</value>
</data>
<data name="comments.notUserComment" xml:space="preserve">
<value>Comentário foi criado por outro utilizador.</value>
</data>
<data name="common.action" xml:space="preserve">
<value>Acção</value>
</data>
@ -886,12 +871,36 @@
<data name="history.teams.updated" xml:space="preserve">
<value>actualizadas configurações gerais e renomeado para {[Name]}.</value>
</data>
<data name="job.backup" xml:space="preserve">
<value>Backup</value>
</data>
<data name="job.restore" xml:space="preserve">
<value>Restore</value>
</data>
<data name="job.ruleRun" xml:space="preserve">
<value>Replay Rule.</value>
</data>
<data name="job.ruleRunNamed" xml:space="preserve">
<value>Replay Rule '{name}'.</value>
</data>
<data name="job.ruleRunNamedSnapshot" xml:space="preserve">
<value>Replay Rule '{name}' from states.</value>
</data>
<data name="job.ruleRunSnapshot" xml:space="preserve">
<value>Replay Rule from states</value>
</data>
<data name="jobs.alreadyRunning" xml:space="preserve">
<value>Another job is already running.</value>
</data>
<data name="jobs.invalidTaskName" xml:space="preserve">
<value>Invalid task name</value>
</data>
<data name="jobs.maxReached" xml:space="preserve">
<value>You cannot have more than {max} backups.</value>
</data>
<data name="login.githubPrivateEmail" xml:space="preserve">
<value>O seu Email é privado no Github. Altere para publico no Github e tente novamente.</value>
</data>
<data name="rules.ruleAlreadyRunning" xml:space="preserve">
<value>Outra regra já se encontra a correr.</value>
</data>
<data name="schemas.dateTimeCalculatedDefaultAndDefaultError" xml:space="preserve">
<value>Valor por defeito calculado e valor por defeito não podem ser usado em conjunto.</value>
</data>

45
backend/src/Squidex.Shared/Texts.resx

@ -184,21 +184,6 @@
<data name="assets.referenced" xml:space="preserve">
<value>Assets is referenced by a content and cannot be deleted.</value>
</data>
<data name="backups.alreadyRunning" xml:space="preserve">
<value>Another backup process is already running.</value>
</data>
<data name="backups.maxReached" xml:space="preserve">
<value>You cannot have more than {max} backups.</value>
</data>
<data name="backups.restoreRunning" xml:space="preserve">
<value>A restore operation is already running.</value>
</data>
<data name="comments.noPermissions" xml:space="preserve">
<value>You can only access your notifications.</value>
</data>
<data name="comments.notUserComment" xml:space="preserve">
<value>Comment is created by another user.</value>
</data>
<data name="common.action" xml:space="preserve">
<value>Action</value>
</data>
@ -886,12 +871,36 @@
<data name="history.teams.updated" xml:space="preserve">
<value>updated general settings and renamed name to {[Name]}.</value>
</data>
<data name="job.backup" xml:space="preserve">
<value>Backup</value>
</data>
<data name="job.restore" xml:space="preserve">
<value>Restore</value>
</data>
<data name="job.ruleRun" xml:space="preserve">
<value>Replay Rule.</value>
</data>
<data name="job.ruleRunNamed" xml:space="preserve">
<value>Replay Rule '{name}'.</value>
</data>
<data name="job.ruleRunNamedSnapshot" xml:space="preserve">
<value>Replay Rule '{name}' from states.</value>
</data>
<data name="job.ruleRunSnapshot" xml:space="preserve">
<value>Replay Rule from states</value>
</data>
<data name="jobs.alreadyRunning" xml:space="preserve">
<value>Another job is already running.</value>
</data>
<data name="jobs.invalidTaskName" xml:space="preserve">
<value>Invalid task name</value>
</data>
<data name="jobs.maxReached" xml:space="preserve">
<value>You cannot have more than {max} backups.</value>
</data>
<data name="login.githubPrivateEmail" xml:space="preserve">
<value>Your email address is set to private in Github. Please set it to public to use Github login.</value>
</data>
<data name="rules.ruleAlreadyRunning" xml:space="preserve">
<value>Another rule is already running.</value>
</data>
<data name="schemas.dateTimeCalculatedDefaultAndDefaultError" xml:space="preserve">
<value>Calculated default value and default value cannot be used together.</value>
</data>

45
backend/src/Squidex.Shared/Texts.zh.resx

@ -184,21 +184,6 @@
<data name="assets.referenced" xml:space="preserve">
<value>资源被内容引用,无法删除。</value>
</data>
<data name="backups.alreadyRunning" xml:space="preserve">
<value>另一个备份进程已经在运行。</value>
</data>
<data name="backups.maxReached" xml:space="preserve">
<value>您不能拥有超过 {max} 个备份。</value>
</data>
<data name="backups.restoreRunning" xml:space="preserve">
<value>还原操作已经在运行。</value>
</data>
<data name="comments.noPermissions" xml:space="preserve">
<value>您只能访问您的通知。</value>
</data>
<data name="comments.notUserComment" xml:space="preserve">
<value>评论是由另一个用户创建的。</value>
</data>
<data name="common.action" xml:space="preserve">
<value>动作</value>
</data>
@ -886,12 +871,36 @@
<data name="history.teams.updated" xml:space="preserve">
<value>updated general settings and renamed name to {[Name]}.</value>
</data>
<data name="job.backup" xml:space="preserve">
<value>Backup</value>
</data>
<data name="job.restore" xml:space="preserve">
<value>Restore</value>
</data>
<data name="job.ruleRun" xml:space="preserve">
<value>Replay Rule.</value>
</data>
<data name="job.ruleRunNamed" xml:space="preserve">
<value>Replay Rule '{name}'.</value>
</data>
<data name="job.ruleRunNamedSnapshot" xml:space="preserve">
<value>Replay Rule '{name}' from states.</value>
</data>
<data name="job.ruleRunSnapshot" xml:space="preserve">
<value>Replay Rule from states</value>
</data>
<data name="jobs.alreadyRunning" xml:space="preserve">
<value>Another job is already running.</value>
</data>
<data name="jobs.invalidTaskName" xml:space="preserve">
<value>Invalid task name</value>
</data>
<data name="jobs.maxReached" xml:space="preserve">
<value>You cannot have more than {max} backups.</value>
</data>
<data name="login.githubPrivateEmail" xml:space="preserve">
<value>您的邮箱在 Github 中设置为私有。请设置为公开以使用 Github 登录。</value>
</data>
<data name="rules.ruleAlreadyRunning" xml:space="preserve">
<value>另一个规则已经在运行。</value>
</data>
<data name="schemas.dateTimeCalculatedDefaultAndDefaultError" xml:space="preserve">
<value>计算出的默认值和默认值不能一起使用。</value>
</data>

8
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();

10
backend/src/Squidex.Web/Services/UrlGenerator.cs

@ -82,11 +82,6 @@ public sealed class UrlGenerator : IUrlGenerator
return urlGenerator.BuildUrl($"app/{appId.Name}/assets", false) + @ref != null ? $"?ref={@ref}" : string.Empty;
}
public string BackupsUI(NamedId<DomainId> appId)
{
return urlGenerator.BuildUrl($"app/{appId.Name}/settings/backups", false);
}
public string ClientsUI(NamedId<DomainId> appId)
{
return urlGenerator.BuildUrl($"app/{appId.Name}/settings/clients", false);
@ -122,6 +117,11 @@ public sealed class UrlGenerator : IUrlGenerator
return urlGenerator.BuildUrl($"app/{appId.Name}", false);
}
public string JobsUI(NamedId<DomainId> appId)
{
return urlGenerator.BuildUrl($"app/{appId.Name}/settings/jobs", false);
}
public string LanguagesUI(NamedId<DomainId> appId)
{
return urlGenerator.BuildUrl($"app/{appId.Name}/settings/languages", false);

8
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 @@ public sealed class AppDto : Resource
resources.Url<AssetsController>(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<BackupsController>(x => nameof(x.GetBackups), values));
AddGetLink("jobs",
resources.Url<JobsController>(x => nameof(x.GetJobs), values));
}
if (resources.IsAllowed(PermissionIds.AppClientsRead, Name, additional: permissions))

27
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.
/// </summary>
[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;
}
/// <summary>
@ -45,6 +43,7 @@ public class BackupContentController : ApiController
[ProducesResponseType(typeof(FileResult), StatusCodes.Status200OK)]
[ApiCosts(0)]
[AllowAnonymous]
[Obsolete("Use Jobs endpoint.")]
public Task<IActionResult> GetBackupContent(string app, DomainId id)
{
return GetBackupAsync(AppId, app, id);
@ -64,6 +63,7 @@ public class BackupContentController : ApiController
[ProducesResponseType(typeof(FileResult), StatusCodes.Status200OK)]
[ApiCosts(0)]
[AllowAnonymous]
[Obsolete("Use Jobs endpoint.")]
public Task<IActionResult> GetBackupContentV2(DomainId id, [FromQuery] DomainId appId = default, [FromQuery] string app = "")
{
return GetBackupAsync(appId, app, id);
@ -71,23 +71,22 @@ public class BackupContentController : ApiController
private async Task<IActionResult> 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
};

27
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;
}
/// <summary>
@ -39,15 +40,16 @@ public class BackupsController : ApiController
[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<IActionResult> 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);
}
/// <summary>
@ -60,11 +62,13 @@ public class BackupsController : ApiController
[HttpPost]
[Route("apps/{app}/backups/")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ApiPermissionOrAnonymous(PermissionIds.AppBackupsCreate)]
[ApiPermissionOrAnonymous(PermissionIds.AppJobsCreate)]
[ApiCosts(0)]
public async Task<IActionResult> 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 class BackupsController : ApiController
[HttpDelete]
[Route("apps/{app}/backups/{id}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ApiPermissionOrAnonymous(PermissionIds.AppBackupsDelete)]
[ApiPermissionOrAnonymous(PermissionIds.AppJobsDelete)]
[ApiCosts(0)]
[Obsolete("Use Jobs endpoint.")]
public async Task<IActionResult> DeleteBackup(string app, DomainId id)
{
await backupService.DeleteBackupAsync(AppId, id, HttpContext.RequestAborted);
await jobService.DeleteJobAsync(App.Id, id, default);
return NoContent();
}

20
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
{
/// <summary>
@ -45,16 +47,16 @@ public sealed class BackupJobDto : Resource
/// </summary>
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 @@ public sealed class BackupJobDto : Resource
resources.Url<BackupsController>(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<BackupContentController>(x => nameof(x.GetBackupContentV2), values));
resources.Url<JobsContentController>(x => nameof(x.GetJobContent), values));
}
return this;

7
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
{
/// <summary>
@ -17,11 +18,11 @@ public sealed class BackupJobsDto : Resource
/// </summary>
public BackupJobDto[] Items { get; set; }
public static BackupJobsDto FromDomain(IEnumerable<IBackupJob> backups, Resources resources)
public static BackupJobsDto FromDomain(IEnumerable<Job> 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);

16
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
/// <summary>
/// The status log.
/// </summary>
public List<string> Log { get; set; }
public List<string> Log { get; set; } = [];
/// <summary>
/// The time when the job has been started.
@ -38,8 +39,17 @@ public sealed class RestoreJobDto
/// </summary>
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;
}
}

16
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;
}
/// <summary>
@ -40,11 +41,12 @@ public class RestoreController : ApiController
[ApiPermission(PermissionIds.AdminRestore)]
public async Task<IActionResult> 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 class RestoreController : ApiController
[ApiPermission(PermissionIds.AdminRestore)]
public async Task<IActionResult> 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();
}

67
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;
/// <summary>
/// Update and query jobs for app.
/// </summary>
[ApiExplorerSettings(GroupName = nameof(Jobs))]
public class JobsContentController : ApiController
{
private readonly IJobService jobService;
public JobsContentController(ICommandBus commandBus,
IJobService jobService)
: base(commandBus)
{
this.jobService = jobService;
}
/// <summary>
/// Get the job content.
/// </summary>
/// <param name="id">The ID of the job.</param>
/// <param name="appId">The ID of the app.</param>
/// <response code="200">Job found and content returned.</response>
/// <response code="404">Job or app not found.</response>
[HttpGet]
[Route("apps/jobs/{id}")]
[ResponseCache(Duration = 3600 * 24 * 30)]
[ProducesResponseType(typeof(FileResult), StatusCodes.Status200OK)]
[ApiCosts(0)]
[AllowAnonymous]
public async Task<IActionResult> 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
};
}
}

70
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;
/// <summary>
/// Update and query jobs for apps.
/// </summary>
[ApiExplorerSettings(GroupName = nameof(Jobs))]
public class JobsController : ApiController
{
private readonly IJobService jobService;
public JobsController(ICommandBus commandBus, IJobService jobService)
: base(commandBus)
{
this.jobService = jobService;
}
/// <summary>
/// Get all jobs.
/// </summary>
/// <param name="app">The name of the app.</param>
/// <response code="200">Jobs returned.</response>
/// <response code="404">App not found.</response>
[HttpGet]
[Route("apps/{app}/jobs/")]
[ProducesResponseType(typeof(JobsDto), StatusCodes.Status200OK)]
[ApiPermissionOrAnonymous(PermissionIds.AppJobsRead)]
[ApiCosts(0)]
public async Task<IActionResult> GetJobs(string app)
{
var jobs = await jobService.GetJobsAsync(App.Id, HttpContext.RequestAborted);
var result = JobsDto.FromDomain(jobs, Resources);
return Ok(result);
}
/// <summary>
/// Delete a job.
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="id">The ID of the jobs to delete.</param>
/// <response code="204">Job deleted.</response>
/// <response code="404">Job or app not found.</response>
[HttpDelete]
[Route("apps/{app}/jobs/{id}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ApiPermissionOrAnonymous(PermissionIds.AppJobsDelete)]
[ApiCosts(0)]
public async Task<IActionResult> DeleteJob(string app, DomainId id)
{
await jobService.DeleteJobAsync(App.Id, id, default);
return NoContent();
}
}

98
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
{
/// <summary>
/// The ID of the job.
/// </summary>
public DomainId Id { get; set; }
/// <summary>
/// The time when the job has been started.
/// </summary>
public Instant Started { get; set; }
/// <summary>
/// The time when the job has been stopped.
/// </summary>
public Instant? Stopped { get; set; }
/// <summary>
/// The status of the operation.
/// </summary>
public JobStatus Status { get; set; }
/// <summary>
/// The name of the task.
/// </summary>
public string TaskName { get; set; }
/// <summary>
/// The description of the job.
/// </summary>
public string Description { get; set; }
/// <summary>
/// The arguments for the job.
/// </summary>
public ReadonlyDictionary<string, string> TaskArguments { get; set; }
/// <summary>
/// The list of log items.
/// </summary>
public List<JobLogMessageDto> Log { get; set; } = [];
/// <summary>
/// Indicates whether the job can be downloaded.
/// </summary>
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<JobsController>(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<JobsContentController>(x => nameof(x.GetJobContent), values));
}
return this;
}
}

29
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
{
/// <summary>
/// The timestamp.
/// </summary>
public Instant Timestamp { get; set; }
/// <summary>
/// The log message.
/// </summary>
public string Message { get; set; }
public static JobLogMessageDto FromDomain(JobLogMessage source)
{
return new JobLogMessageDto { Timestamp = source.Timestamp, Message = source.Message };
}
}

45
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
{
/// <summary>
/// The jobs.
/// </summary>
public JobDto[] Items { get; set; }
public static JobsDto FromDomain(IEnumerable<Job> 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<JobsController>(x => nameof(x.GetJobs), values));
if (resources.CanCreateBackup)
{
AddPostLink("create/backups",
resources.Url<BackupsController>(x => nameof(x.PostBackup), values));
}
return this;
}
}

2
backend/src/Squidex/Areas/Api/Controllers/Rules/Models/RulesDto.cs

@ -27,7 +27,7 @@ public sealed class RulesDto : Resource
public static async Task<RulesDto> FromRulesAsync(IEnumerable<EnrichedRule> 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
{

3
backend/src/Squidex/Areas/Api/Controllers/Rules/RulesController.cs

@ -20,6 +20,7 @@ using Squidex.Domain.Apps.Entities.Rules.Repositories;
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 sealed class RulesController : ApiController
[ApiCosts(1)]
public async Task<IActionResult> 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();
}

1
backend/src/Squidex/Areas/IdentityServer/Views/Profile/Profile.cshtml

@ -97,6 +97,7 @@
<select type="password" class="form-select" asp-for="CompanyRole">
<option></option>
<option value="RoleEmployee">@T.Get("users.profile.roleEmployee")</option>
<option value="RoleBusinessOwner">@T.Get("users.profile.roleBusinessOwner")</option>
<option value="RoleProductManager">@T.Get("users.profile.roleProductManager")</option>
<option value="RoleContentCreator">@T.Get("users.profile.roleContentCreator")</option>
<option value="RoleSoftwareDeveloper">@T.Get("users.profile.roleSoftwareDeveloper")</option>

11
backend/src/Squidex/Config/Domain/BackupsServices.cs

@ -5,13 +5,14 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Domain.Apps.Entities;
using Migrations.Migrations.Backup;
using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Entities.Assets;
using Squidex.Domain.Apps.Entities.Backup;
using Squidex.Domain.Apps.Entities.Contents;
using Squidex.Domain.Apps.Entities.Rules;
using Squidex.Domain.Apps.Entities.Schemas;
using Squidex.Infrastructure.Migrations;
namespace Squidex.Config.Domain;
@ -33,9 +34,6 @@ public static class BackupsServices
services.AddSingletonAs<DefaultBackupArchiveStore>()
.As<IBackupArchiveStore>();
services.AddTransientAs<BackupService>()
.As<IBackupService>().As<IDeleter>();
services.AddTransientAs<BackupApps>()
.As<IBackupHandler>();
@ -51,7 +49,10 @@ public static class BackupsServices
services.AddTransientAs<BackupSchemas>()
.As<IBackupHandler>();
services.AddTransientAs<RestoreProcessor>()
services.AddTransientAs<RestoreJob>()
.AsSelf();
services.AddTransientAs<ConvertBackup>()
.As<IMigration>();
}
}

25
backend/src/Squidex/Config/Messaging/MessagingServices.cs

@ -7,10 +7,12 @@
using System.Text.Json;
using Squidex.Domain.Apps.Core.Subscriptions;
using Squidex.Domain.Apps.Entities;
using Squidex.Domain.Apps.Entities.Assets;
using Squidex.Domain.Apps.Entities.Backup;
using Squidex.Domain.Apps.Entities.Billing;
using Squidex.Domain.Apps.Entities.Contents;
using Squidex.Domain.Apps.Entities.Jobs;
using Squidex.Domain.Apps.Entities.Rules;
using Squidex.Domain.Apps.Entities.Rules.Runner;
using Squidex.Domain.Apps.Entities.Rules.UsageTracking;
@ -49,10 +51,7 @@ public static class MessagingServices
services.AddSingletonAs<EventConsumerWorker>()
.AsSelf().As<IMessageHandler>();
services.AddSingletonAs<RuleRunnerWorker>()
.AsSelf().As<IMessageHandler>();
services.AddSingletonAs<BackupWorker>()
services.AddSingletonAs<JobWorker>()
.AsSelf().As<IMessageHandler>();
services.AddSingletonAs<UsageNotifierWorker>()
@ -60,6 +59,15 @@ public static class MessagingServices
services.AddSingletonAs<UsageTrackerWorker>()
.AsSelf().As<IMessageHandler>();
services.AddSingletonAs<BackupJob>()
.As<IJobRunner>();
services.AddSingletonAs<RestoreJob>()
.As<IJobRunner>();
services.AddSingletonAs<RuleRunnerJob>()
.As<IJobRunner>();
}
services.AddSingleton<IMessagingSerializer>(c =>
@ -71,6 +79,9 @@ public static class MessagingServices
services.AddSingletonAs<EventMessageEvaluator>()
.As<IMessageEvaluator>();
services.AddSingletonAs<DefaultJobService>()
.As<IJobService>().As<IDeleter>();
services.AddReplicatedCacheMessaging(isCaching, options =>
{
options.TransportSelector = (transport, _) => transport.First(x => x is NullTransport != isCaching);
@ -85,9 +96,9 @@ public static class MessagingServices
services.AddMessagingTransport(config);
services.AddMessaging(options =>
{
options.Routing.Add(m => m is RuleRunnerRun, channelRules);
options.Routing.Add(m => m is BackupStart, channelBackupStart);
options.Routing.Add(m => m is BackupRestore, channelBackupRestore);
options.Routing.Add(m => m is JobStart r && r.Request.TaskName == BackupJob.TaskName, channelBackupStart);
options.Routing.Add(m => m is JobStart r && r.Request.TaskName == RestoreJob.TaskName, channelBackupRestore);
options.Routing.Add(m => m is JobStart r && r.Request.TaskName == RuleRunnerJob.TaskName, channelRules);
options.Routing.AddFallback(channelFallback);
});

23
backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/AppSettingsSearchSourceTests.cs

@ -125,16 +125,31 @@ public sealed class AppSettingsSearchSourceTests : GivenContext
[Fact]
public async Task Should_return_backups_result_if_matching_and_permission_given()
{
var requestContext = SetupContext(PermissionIds.AppBackupsRead);
var requestContext = SetupContext(PermissionIds.AppJobsRead);
A.CallTo(() => urlGenerator.BackupsUI(AppId))
.Returns("backups-url");
A.CallTo(() => urlGenerator.JobsUI(AppId))
.Returns("jobs-url");
var actual = await sut.SearchAsync("backups", requestContext, CancellationToken);
actual.Should().BeEquivalentTo(
new SearchResults()
.Add("Backups", SearchResultType.Setting, "backups-url"));
.Add("Backups", SearchResultType.Setting, "jobs-url"));
}
[Fact]
public async Task Should_return_jobs_result_if_matching_and_permission_given()
{
var requestContext = SetupContext(PermissionIds.AppJobsRead);
A.CallTo(() => urlGenerator.JobsUI(AppId))
.Returns("jobs-url");
var actual = await sut.SearchAsync("jobs", requestContext, CancellationToken);
actual.Should().BeEquivalentTo(
new SearchResults()
.Add("Jobs", SearchResultType.Setting, "jobs-url"));
}
[Fact]

1
backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/DomainObject/AppDomainObjectTests.cs

@ -14,7 +14,6 @@ using Squidex.Domain.Apps.Core.TestHelpers;
using Squidex.Domain.Apps.Entities.Apps.Commands;
using Squidex.Domain.Apps.Entities.Billing;
using Squidex.Domain.Apps.Entities.TestHelpers;
using Squidex.Domain.Apps.Events.Apps;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Json.Objects;
using Squidex.Shared.Users;

3
backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/DomainObject/AssetDomainObjectTests.cs

@ -5,7 +5,6 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Esprima;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Squidex.Assets;
@ -15,9 +14,7 @@ using Squidex.Domain.Apps.Core.Tags;
using Squidex.Domain.Apps.Entities.Assets.Commands;
using Squidex.Domain.Apps.Entities.Contents;
using Squidex.Domain.Apps.Entities.Contents.Repositories;
using Squidex.Domain.Apps.Entities.Rules.Commands;
using Squidex.Domain.Apps.Entities.TestHelpers;
using Squidex.Domain.Apps.Events.Assets;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands;

161
backend/tests/Squidex.Domain.Apps.Entities.Tests/Backup/BackupServiceTests.cs

@ -1,161 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using NodaTime;
using Squidex.Domain.Apps.Entities.Backup.State;
using Squidex.Domain.Apps.Entities.TestHelpers;
using Squidex.Infrastructure;
using Squidex.Infrastructure.TestHelpers;
using Squidex.Messaging;
namespace Squidex.Domain.Apps.Entities.Backup;
public class BackupServiceTests : GivenContext
{
private readonly TestState<BackupState> stateBackup;
private readonly TestState<BackupRestoreState> stateRestore;
private readonly IMessageBus messaging = A.Fake<IMessageBus>();
private readonly DomainId backupId = DomainId.NewGuid();
private readonly BackupService sut;
public BackupServiceTests()
{
stateRestore = new TestState<BackupRestoreState>("Default");
stateBackup = new TestState<BackupState>(AppId.Id);
sut = new BackupService(
stateRestore.PersistenceFactory,
stateBackup.PersistenceFactory, messaging);
}
[Fact]
public async Task Should_send_message_to_restore_backup()
{
var restoreUrl = new Uri("http://squidex.io");
var restoreAppName = "New App";
await sut.StartRestoreAsync(User, restoreUrl, restoreAppName, CancellationToken);
A.CallTo(() => messaging.PublishAsync(new BackupRestore(User, restoreUrl, restoreAppName), null, CancellationToken))
.MustHaveHappened();
}
[Fact]
public async Task Should_send_message_to_start_backup()
{
await sut.StartBackupAsync(AppId.Id, User, CancellationToken);
A.CallTo(() => messaging.PublishAsync(new BackupStart(AppId.Id, User), null, CancellationToken))
.MustHaveHappened();
}
[Fact]
public async Task Should_send_message_to_delete_backup()
{
await sut.DeleteBackupAsync(AppId.Id, backupId, CancellationToken);
A.CallTo(() => messaging.PublishAsync(new BackupDelete(AppId.Id, backupId), null, CancellationToken))
.MustHaveHappened();
}
[Fact]
public async Task Should_send_message_to_clear_backups()
{
await ((IDeleter)sut).DeleteAppAsync(App, CancellationToken);
A.CallTo(() => messaging.PublishAsync(new BackupClear(AppId.Id), null, CancellationToken))
.MustHaveHappened();
}
[Fact]
public async Task Should_throw_exception_when_restore_already_running()
{
stateRestore.Snapshot = new BackupRestoreState
{
Job = new RestoreJob
{
Status = JobStatus.Started
}
};
var restoreUrl = new Uri("http://squidex.io");
await Assert.ThrowsAnyAsync<DomainException>(() => sut.StartRestoreAsync(User, restoreUrl, null, CancellationToken));
}
[Fact]
public async Task Should_throw_exception_when_backup_has_too_many_jobs()
{
for (var i = 0; i < 10; i++)
{
stateBackup.Snapshot.Jobs.Add(new BackupJob());
}
await Assert.ThrowsAnyAsync<DomainException>(() => sut.StartBackupAsync(AppId.Id, User, CancellationToken));
}
[Fact]
public async Task Should_throw_exception_when_backup_has_one_running_job()
{
for (var i = 0; i < 2; i++)
{
stateBackup.Snapshot.Jobs.Add(new BackupJob { Status = JobStatus.Started });
}
await Assert.ThrowsAnyAsync<DomainException>(() => sut.StartBackupAsync(AppId.Id, User, CancellationToken));
}
[Fact]
public async Task Should_get_restore_state_from_store()
{
stateRestore.Snapshot = new BackupRestoreState
{
Job = new RestoreJob
{
Stopped = SystemClock.Instance.GetCurrentInstant()
}
};
var actual = await sut.GetRestoreAsync(CancellationToken);
actual.Should().BeEquivalentTo(stateRestore.Snapshot.Job);
}
[Fact]
public async Task Should_get_backups_state_from_store()
{
var job = new BackupJob
{
Id = backupId,
Started = SystemClock.Instance.GetCurrentInstant(),
Stopped = SystemClock.Instance.GetCurrentInstant()
};
stateBackup.Snapshot.Jobs.Add(job);
var actual = await sut.GetBackupsAsync(AppId.Id, CancellationToken);
actual.Should().BeEquivalentTo(stateBackup.Snapshot.Jobs);
}
[Fact]
public async Task Should_get_backup_state_from_store()
{
var job = new BackupJob
{
Id = backupId,
Started = SystemClock.Instance.GetCurrentInstant(),
Stopped = SystemClock.Instance.GetCurrentInstant()
};
stateBackup.Snapshot.Jobs.Add(job);
var actual = await sut.GetBackupAsync(AppId.Id, backupId, CancellationToken);
actual.Should().BeEquivalentTo(job);
}
}

1
backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/DomainObject/ContentDomainObjectTests.cs

@ -6,7 +6,6 @@
// ==========================================================================
using System.Security.Claims;
using Elasticsearch.Net.Specification.WatcherApi;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using NodaTime;

10
backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/TestData/FakeUrlGenerator.cs

@ -70,11 +70,6 @@ public sealed class FakeUrlGenerator : IUrlGenerator
throw new NotSupportedException();
}
public string BackupsUI(NamedId<DomainId> appId)
{
throw new NotSupportedException();
}
public string ClientsUI(NamedId<DomainId> appId)
{
throw new NotSupportedException();
@ -100,6 +95,11 @@ public sealed class FakeUrlGenerator : IUrlGenerator
throw new NotSupportedException();
}
public string JobsUI(NamedId<DomainId> appId)
{
throw new NotSupportedException();
}
public string PatternsUI(NamedId<DomainId> appId)
{
throw new NotSupportedException();

162
backend/tests/Squidex.Domain.Apps.Entities.Tests/Jobs/DefaultJobsServiceTests.cs

@ -0,0 +1,162 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using NodaTime;
using Squidex.Domain.Apps.Entities.TestHelpers;
using Squidex.Infrastructure;
using Squidex.Infrastructure.TestHelpers;
using Squidex.Messaging;
namespace Squidex.Domain.Apps.Entities.Jobs;
public class DefaultJobsServiceTests : GivenContext
{
private readonly TestState<JobsState> state;
private readonly IJobRunner runner1 = A.Fake<IJobRunner>();
private readonly IJobRunner runner2 = A.Fake<IJobRunner>();
private readonly IMessageBus messaging = A.Fake<IMessageBus>();
private readonly Stream stream = new MemoryStream();
private readonly DomainId jobId = DomainId.NewGuid();
private readonly DefaultJobService sut;
public DefaultJobsServiceTests()
{
state = new TestState<JobsState>(AppId.Id);
A.CallTo(() => runner1.Name)
.Returns("job1");
A.CallTo(() => runner1.MaxJobs)
.Returns(2);
A.CallTo(() => runner2.Name)
.Returns("job2");
sut = new DefaultJobService(messaging, new[] { runner1, runner2 }, state.PersistenceFactory);
}
[Fact]
public async Task Should_send_message_to_start_job()
{
var request = JobRequest.Create(User, "job1");
await sut.StartAsync(AppId.Id, request, CancellationToken);
A.CallTo(() => messaging.PublishAsync(new JobStart(AppId.Id, request), null, CancellationToken))
.MustHaveHappened();
}
[Fact]
public async Task Should_send_message_to_delete_backup()
{
await sut.DeleteJobAsync(AppId.Id, jobId, CancellationToken);
A.CallTo(() => messaging.PublishAsync(new JobDelete(AppId.Id, jobId), null, CancellationToken))
.MustHaveHappened();
}
[Fact]
public async Task Should_send_message_to_clear_backups()
{
await ((IDeleter)sut).DeleteAppAsync(App, CancellationToken);
A.CallTo(() => messaging.PublishAsync(new JobClear(AppId.Id), null, CancellationToken))
.MustHaveHappened();
}
[Fact]
public async Task Should_throw_exception_when_job_is_invalid()
{
var request = JobRequest.Create(User, "unknown");
await Assert.ThrowsAnyAsync<DomainException>(() => sut.StartAsync(App.Id, request, CancellationToken));
}
[Fact]
public async Task Should_throw_exception_when_job_is_already_running()
{
state.Snapshot.Jobs.Add(new Job { Status = JobStatus.Started });
var request = JobRequest.Create(User, "job1");
await Assert.ThrowsAnyAsync<DomainException>(() => sut.StartAsync(App.Id, request, CancellationToken));
}
[Fact]
public async Task Should_throw_exception_when_backup_has_too_many_jobs()
{
state.Snapshot.Jobs.Add(new Job { TaskName = "job1", File = new JobFile("file", "type") });
state.Snapshot.Jobs.Add(new Job { TaskName = "job1", File = new JobFile("file", "type") });
var request = JobRequest.Create(User, "job1");
await Assert.ThrowsAnyAsync<DomainException>(() => sut.StartAsync(App.Id, request, CancellationToken));
}
[Fact]
public async Task Should_not_throw_exception_when_backup_has_too_many_jobs_without_files()
{
state.Snapshot.Jobs.Add(new Job { TaskName = "job1" });
state.Snapshot.Jobs.Add(new Job { TaskName = "job1" });
var request = JobRequest.Create(User, "job1");
await sut.StartAsync(App.Id, request, CancellationToken);
}
[Fact]
public async Task Should_get_backups_state_from_store()
{
var job = new Job
{
Id = jobId,
Started = SystemClock.Instance.GetCurrentInstant(),
Stopped = SystemClock.Instance.GetCurrentInstant()
};
state.Snapshot.Jobs.Add(job);
var actual = await sut.GetJobsAsync(AppId.Id, CancellationToken);
actual.Should().BeEquivalentTo(state.Snapshot.Jobs);
}
[Fact]
public async Task Should_download_file()
{
var job = new Job { TaskName = "job2", Status = JobStatus.Completed, File = new JobFile("file", "type") };
await sut.DownloadAsync(job, stream, CancellationToken);
A.CallTo(() => runner2.DownloadAsync(job, stream, CancellationToken))
.MustHaveHappened();
}
[Fact]
public async Task Should_throw_exception_if_job_to_download_has_no_file()
{
var job = new Job { TaskName = "job2", Status = JobStatus.Completed, File = null };
await Assert.ThrowsAsync<InvalidOperationException>(() => sut.DownloadAsync(job, stream, CancellationToken));
}
[Fact]
public async Task Should_throw_exception_if_job_is_not_completed()
{
var job = new Job { TaskName = "job2", Status = JobStatus.Started, File = new JobFile("file", "type") };
await Assert.ThrowsAsync<InvalidOperationException>(() => sut.DownloadAsync(job, stream, CancellationToken));
}
[Fact]
public async Task Should_throw_exception_if_job_has_invalid_task_name()
{
var job = new Job { TaskName = "invalid", Status = JobStatus.Completed, File = new JobFile("file", "type") };
await Assert.ThrowsAsync<InvalidOperationException>(() => sut.DownloadAsync(job, stream, CancellationToken));
}
}

4
frontend/src/app/features/administration/pages/event-consumers/event-consumers-page.component.ts

@ -53,7 +53,9 @@ export class EventConsumersPageComponent implements OnInit {
public ngOnInit() {
this.eventConsumersState.load();
this.subscriptions.add(timer(1000, 1000).pipe(switchMap(() => this.eventConsumersState.load(false, true))));
this.subscriptions.add(
timer(1000, 1000).pipe(
switchMap(() => this.eventConsumersState.load(false, true))));
}
public reload() {

16
frontend/src/app/features/administration/pages/restore/restore-page.component.html

@ -1,6 +1,6 @@
<sqx-title message="i18n:backups.restorePageTitle"></sqx-title>
<sqx-title message="i18n:jobs.restorePageTitle"></sqx-title>
<sqx-layout layout="main" titleText="i18n:backups.restoreTitle" titleIcon="backup" innerWidth="70">
<sqx-layout layout="main" titleText="i18n:jobs.restoreTitle" titleIcon="backup" innerWidth="70">
<ng-container>
<sqx-list-view innerWidth="70rem">
<div class="card section" *ngIf="restoreJob | async; let job">
@ -19,7 +19,7 @@
</div>
<div class="col">
<h3>{{ 'backups.restoreLastStatus' | sqxTranslate }}</h3>
<h3>{{ 'jobs.restoreLastStatus' | sqxTranslate }}</h3>
</div>
<div class="col text-end restore-url">
@ -35,10 +35,10 @@
<div class="card-footer small text-muted">
<div class="row">
<div class="col">
{{ 'backups.restoreStartedLabel' | sqxTranslate }}: {{job.started | sqxISODate}}
{{ 'jobs.restoreStartedLabel' | sqxTranslate }}: {{job.started | sqxISODate}}
</div>
<div class="col text-end" *ngIf="job.stopped">
{{ 'backups.restoreStoppedLabel' | sqxTranslate }}: {{job.stopped | sqxISODate}}
{{ 'jobs.restoreStoppedLabel' | sqxTranslate }}: {{job.stopped | sqxISODate}}
</div>
</div>
</div>
@ -50,16 +50,16 @@
<div class="col">
<sqx-control-errors for="url"></sqx-control-errors>
<input class="form-control" formControlName="url" placeholder="{{ 'backups.restoreLastUrl' | sqxTranslate }}">
<input class="form-control" formControlName="url" placeholder="{{ 'jobs.restoreLastUrl' | sqxTranslate }}">
</div>
<div class="col">
<sqx-control-errors for="name"></sqx-control-errors>
<input class="form-control" formControlName="name" placeholder="{{ 'backups.restoreNewAppName' | sqxTranslate }}">
<input class="form-control" formControlName="name" placeholder="{{ 'jobs.restoreNewAppName' | sqxTranslate }}">
</div>
<div class="col-auto">
<button type="submit" class="btn btn-success" [disabled]="restoreForm.hasNoUrl | async">
{{ 'backups.restore' | sqxTranslate }}
{{ 'jobs.restore' | sqxTranslate }}
</button>
</div>
</div>

10
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);

2
frontend/src/app/features/settings/pages/asset-scripts/asset-scripts-page.component.html

@ -4,7 +4,7 @@
<sqx-layout layout="main" titleText="i18n:common.assetScripts" titleIcon="assets">
<ng-container menu>
<button type="button" class="btn btn-text-secondary me-2" (click)="reload()" title="i18n:backups.refreshTooltip" shortcut="CTRL + B">
<button type="button" class="btn btn-text-secondary me-2" (click)="reload()" title="i18n:jobs.refreshTooltip" shortcut="CTRL + B">
<i class="icon-reset"></i> {{ 'common.refresh' | sqxTranslate }}
</button>
</ng-container>

49
frontend/src/app/features/settings/pages/backups/backup.component.html

@ -1,49 +0,0 @@
<div class="table-items-row table-items-row-summary">
<div class="row">
<div class="col-auto" [ngSwitch]="backup.status">
<sqx-status-icon size="lg" [status]="backup.status"></sqx-status-icon>
</div>
<div class="col-auto">
<div>
{{ 'backups.startedLabel' | sqxTranslate }}:
</div>
<div>
{{ 'backups.backupDuration' | sqxTranslate }}:
</div>
</div>
<div class="col text-nowrap">
<div>
{{backup.started | sqxFromNow}}
</div>
<div *ngIf="backup.stopped">
{{duration}}
</div>
</div>
<div class="col">
<div class="text-nowrap">
<span title="i18n:backups.backupCountEventsTooltip">
{{ 'backups.backupCountEventsLabel' | sqxTranslate }}: <strong class="backup-progress">{{backup.handledEvents | sqxKNumber}}</strong>
</span>,
<span title="i18n:backups.backupCountAssetsTooltip">
{{ 'backups.backupCountAssetsLabel' | sqxTranslate }}: <strong class="backup-progress">{{backup.handledAssets | sqxKNumber}}</strong>
</span>
</div>
<div *ngIf="backup.canDownload">
{{ 'backups.backupDownload' | sqxTranslate }}:
<a href="{{apiUrl.buildUrl(backup.downloadUrl)}}" sqxExternalLink="noicon">
{{ 'backups.backupDownloadLink' | sqxTranslate }} <i class="icon-external-link"></i>
</a>
</div>
</div>
<div class="col-auto">
<button type="button" class="btn btn-text-danger mt-1" [disabled]="!backup.canDelete"
(sqxConfirmClick)="delete()"
confirmTitle="i18n:backups.deleteConfirmTitle"
confirmText="i18n:backups.deleteConfirmText"
confirmRememberKey="deleteBackup">
<i class="icon-bin2"></i>
</button>
</div>
</div>
</div>

51
frontend/src/app/features/settings/pages/backups/backup.component.ts

@ -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<this>) {
if (changes.backup) {
this.duration = Duration.create(this.backup.started, this.backup.stopped!).toString();
}
}
public delete() {
this.backupsState.delete(this.backup);
}
}

51
frontend/src/app/features/settings/pages/backups/backups-page.component.html

@ -1,51 +0,0 @@
<sqx-title message="i18n:common.backups"></sqx-title>
<sqx-layout layout="main" titleText="i18n:common.backups" titleIcon="backups" innerWidth="50">
<ng-container menu>
<button type="button" class="btn btn-text-secondary me-2" (click)="reload()" title="i18n:backups.refreshTooltip" shortcut="CTRL + B">
<i class="icon-reset"></i> {{ 'common.refresh' | sqxTranslate }}
</button>
<button type="button" class="btn btn-success" [disabled]="backupsState.maxBackupsReached | async" *ngIf="backupsState.canCreate | async" (click)="start()">
{{ 'backups.start' | sqxTranslate }}
</button>
</ng-container>
<ng-container>
<sqx-list-view innerWidth="50rem" [isLoading]="backupsState.isLoading | async">
<div class="alert alert-danger mb-4" *ngIf="backupsState.maxBackupsReached | async">
{{ 'backups.maximumReached' | sqxTranslate }}
</div>
<ng-container *ngIf="(backupsState.isLoaded | async) && (backupsState.backups | async); let backups">
<div class="table-items-row table-items-row-summary table-items-row-empty" *ngIf="backups.length === 0">
{{ 'backups.empty' | sqxTranslate }}
<button type="button" class="btn btn-success btn-sm me-2" (click)="start()" *ngIf="backupsState.canCreate | async">
{{ 'backups.start' | sqxTranslate }}
</button>
</div>
<sqx-backup *ngFor="let backup of backups; trackBy: trackByBackup"
[backup]="backup">
</sqx-backup>
</ng-container>
</sqx-list-view>
</ng-container>
<ng-template sidebarMenu>
<div class="panel-nav">
<a class="panel-link"
routerLink="help"
routerLinkActive="active"
queryParamsHandling="preserve"
title="i18n:common.help"
titlePosition="left"
sqxTourStep="help">
<i class="icon-help2"></i>
</a>
</div>
</ng-template>
</sqx-layout>
<router-outlet></router-outlet>

2
frontend/src/app/features/settings/pages/backups/backups-page.component.scss

@ -1,2 +0,0 @@
@import 'mixins';
@import 'vars';

60
frontend/src/app/features/settings/pages/jobs/job.component.html

@ -0,0 +1,60 @@
<div class="table-items-row table-items-row-expandable">
<div class="table-items-row-summary">
<div class="row align-items-center">
<div class="col-auto pe-4" [ngSwitch]="job.status">
<sqx-status-icon size="lg" [status]="job.status"></sqx-status-icon>
</div>
<div class="col">
<div>
<h4>{{job.description}}</h4>
</div>
<div class="row">
<div class="col">
{{ 'jobs.startedLabel' | sqxTranslate }}:
<span>
{{job.started | sqxFromNow}}
</span>
</div>
<div class="col">
{{ 'jobs.jobDuration' | sqxTranslate }}:
<span *ngIf="job.stopped">
{{duration}}
</span>
</div>
</div>
</div>
<div class="col-options text-right">
<a class="btn btn-text-secondary" href="{{apiUrl.buildUrl(job.downloadUrl || '')}}" [class.invisible]="!job.downloadUrl" sqxExternalLink="noicon">
<i class="icon-download"></i>
</a>
<button type="button" class="btn btn-outline-secondary btn-expand ms-1" [class.expanded]="expanded" (click)="toggleExpanded()">
<span class="hidden">{{ 'common.settings' | sqxTranslate }}</span>
<i class="icon-settings"></i>
</button>
<button type="button" class="btn btn-text-danger ms-1" [disabled]="!job.canDelete"
(sqxConfirmClick)="delete()"
confirmTitle="i18n:jobs.deleteConfirmTitle"
confirmText="i18n:jobs.deleteConfirmText"
confirmRememberKey="deleteBackup">
<i class="icon-bin2"></i>
</button>
</div>
</div>
</div>
<div class="table-items-row-details" *ngIf="expanded">
<div class="job-header">
<h4>{{ 'common.details' | sqxTranslate }}</h4>
</div>
<div class="row job-dump">
<div class="col-12">
<sqx-code-editor [ngModel]="details" disabled="true" wordWrap="true" height="auto" mode="ace/mode/text"></sqx-code-editor>
</div>
</div>
</div>
</div>

32
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;
}

73
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<this>) {
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;
}
}

47
frontend/src/app/features/settings/pages/jobs/jobs-page.component.html

@ -0,0 +1,47 @@
<sqx-title message="i18n:common.jobsBackups"></sqx-title>
<sqx-layout layout="main" titleText="i18n:common.jobsBackups" titleIcon="jobs" innerWidth="50">
<ng-container menu>
<button type="button" class="btn btn-text-secondary me-2" (click)="reload()" title="i18n:jobs.refreshTooltip" shortcut="CTRL + B">
<i class="icon-reset"></i> {{ 'common.refresh' | sqxTranslate }}
</button>
<button type="button" class="btn btn-success" [disabled]="jobsState.maxBackupsReached | async" *ngIf="jobsState.canCreateBackup | async" (click)="startBackup()">
{{ 'jobs.backupStart' | sqxTranslate }}
</button>
</ng-container>
<ng-container>
<sqx-list-view innerWidth="50rem" [isLoading]="jobsState.isLoading | async">
<div class="alert alert-danger mb-4" *ngIf="jobsState.maxBackupsReached | async">
{{ 'jobs.backupMaximumReached' | sqxTranslate }}
</div>
<ng-container *ngIf="(jobsState.isLoaded | async) && (jobsState.jobs | async); let jobs">
<div class="table-items-row table-items-row-summary table-items-row-empty" *ngIf="jobs.length === 0">
{{ 'jobs.empty' | sqxTranslate }}
</div>
<sqx-job *ngFor="let job of jobs; trackBy: trackByJob"
[job]="job">
</sqx-job>
</ng-container>
</sqx-list-view>
</ng-container>
<ng-template sidebarMenu>
<div class="panel-nav">
<a class="panel-link"
routerLink="help"
routerLinkActive="active"
queryParamsHandling="preserve"
title="i18n:common.help"
titlePosition="left"
sqxTourStep="help">
<i class="icon-help2"></i>
</a>
</div>
</ng-template>
</sqx-layout>
<router-outlet></router-outlet>

0
frontend/src/app/features/settings/pages/backups/backup.component.scss → frontend/src/app/features/settings/pages/jobs/jobs-page.component.scss

30
frontend/src/app/features/settings/pages/backups/backups-page.component.ts → 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;
}
}

10
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',
},
},
],

6
frontend/src/app/features/settings/settings-menu.component.html

@ -50,9 +50,9 @@
<i class="icon-download"></i> {{ 'common.templates' | sqxTranslate }}
</a>
</li>
<li class="nav-item" *ngIf="app.canReadBackups" sqxTourStep="backups">
<a class="nav-link" routerLink="backups" routerLinkActive="active">
<i class="icon-backups"></i> {{ 'common.backups' | sqxTranslate }}
<li class="nav-item" *ngIf="app.canReadJobs" sqxTourStep="jobs">
<a class="nav-link" routerLink="jobs" routerLinkActive="active">
<i class="icon-backups"></i> {{ 'common.jobsBackups' | sqxTranslate }}
</a>
</li>
<li class="nav-item" *ngIf="app.canReadPlans" sqxTourStep="plans">

6
frontend/src/app/shared/internal.ts

@ -13,13 +13,13 @@ export * from './services/apps.service';
export * from './services/assets.service';
export * from './services/auth.service';
export * from './services/autosave.service';
export * from './services/backups.service';
export * from './services/clients.service';
export * from './services/collaboration.service';
export * from './services/contents.service';
export * from './services/contributors.service';
export * from './services/help.service';
export * from './services/history.service';
export * from './services/jobs.service';
export * from './services/languages.service';
export * from './services/news.service';
export * from './services/plans.service';
@ -47,17 +47,17 @@ export * from './state/asset-uploader.state';
export * from './state/assets.forms';
export * from './state/assets.state';
export * from './state/backups.forms';
export * from './state/backups.state';
export * from './state/clients.forms';
export * from './state/clients.state';
export * from './state/comments.state';
export * from './state/comments.form';
export * from './state/comments.state';
export * from './state/contents.forms-helpers';
export * from './state/contents.forms.visitors';
export * from './state/contents.forms';
export * from './state/contents.state';
export * from './state/contributors.forms';
export * from './state/contributors.state';
export * from './state/jobs.state';
export * from './state/languages.forms';
export * from './state/languages.state';
export * from './state/plans.state';

12
frontend/src/app/shared/services/apps.service.spec.ts

@ -364,9 +364,9 @@ describe('AppsService', () => {
return {
id: `id${id}`,
created: `${id % 1000 + 2000}-12-12T10:10:00Z`,
created: buildDate(id, 10),
createdBy: `creator${id}`,
lastModified: `${id % 1000 + 2000}-11-11T10:10:00Z`,
lastModified: buildDate(id, 20),
lastModifiedBy: `modifier${id}`,
version: key,
name: `app-name${key}`,
@ -427,8 +427,8 @@ export function createApp(id: number, suffix = '') {
return new AppDto(links,
`id${id}`,
DateTime.parseISO(`${id % 1000 + 2000}-12-12T10:10:00Z`), `creator${id}`,
DateTime.parseISO(`${id % 1000 + 2000}-11-11T10:10:00Z`), `modifier${id}`,
DateTime.parseISO(buildDate(id, 10)), `creator${id}`,
DateTime.parseISO(buildDate(id, 20)), `modifier${id}`,
new Version(key),
`app-name${key}`,
`app-label${key}`,
@ -480,3 +480,7 @@ function createProperties(id: number) {
return result;
}
function buildDate(id: number, add = 0) {
return `${id % 1000 + 2000 + add}-12-11T10:09:08Z`;
}

6
frontend/src/app/shared/services/apps.service.ts

@ -19,9 +19,9 @@ export class AppDto {
public readonly canLeave: boolean;
public readonly canReadAssets: boolean;
public readonly canReadAssetsScripts: boolean;
public readonly canReadBackups: boolean;
public readonly canReadClients: boolean;
public readonly canReadContributors: boolean;
public readonly canReadJobs: boolean;
public readonly canReadLanguages: boolean;
public readonly canReadPatterns: boolean;
public readonly canReadPlans: boolean;
@ -64,9 +64,9 @@ export class AppDto {
this.canLeave = hasAnyLink(links, 'leave');
this.canReadAssets = hasAnyLink(links, 'assets');
this.canReadAssetsScripts = hasAnyLink(links, 'assets/scripts');
this.canReadBackups = hasAnyLink(links, 'backups');
this.canReadClients = hasAnyLink(links, 'clients');
this.canReadContributors = hasAnyLink(links, 'contributors');
this.canReadJobs = hasAnyLink(links, 'jobs');
this.canReadLanguages = hasAnyLink(links, 'languages');
this.canReadPatterns = hasAnyLink(links, 'patterns');
this.canReadPlans = hasAnyLink(links, 'plans');
@ -74,9 +74,9 @@ export class AppDto {
this.canReadRules = hasAnyLink(links, 'rules');
this.canReadSchemas = hasAnyLink(links, 'schemas');
this.canReadWorkflows = hasAnyLink(links, 'workflows');
this.canUpdateTeam = hasAnyLink(links, 'transfer');
this.canUpdateGeneral = hasAnyLink(links, 'update');
this.canUpdateImage = hasAnyLink(links, 'image/upload');
this.canUpdateTeam = hasAnyLink(links, 'transfer');
this.canUploadAssets = hasAnyLink(links, 'assets/create');
this.image = getLinkUrl(links, 'image') as string;
}

12
frontend/src/app/shared/services/assets.service.spec.ts

@ -495,9 +495,9 @@ describe('AssetsService', () => {
return {
id: `id${id}`,
created: `${id % 1000 + 2000}-12-12T10:10:00Z`,
created: buildDate(id, 10),
createdBy: `creator${id}`,
lastModified: `${id % 1000 + 2000}-11-11T10:10:00Z`,
lastModified: buildDate(id, 20),
lastModifiedBy: `modifier${id}`,
fileName: `My Name${key}.png`,
fileHash: `My Hash${key}`,
@ -560,8 +560,8 @@ export function createAsset(id: number, tags?: ReadonlyArray<string>, suffix = '
return new AssetDto(links, meta,
`id${id}`,
DateTime.parseISO(`${id % 1000 + 2000}-12-12T10:10:00Z`), `creator${id}`,
DateTime.parseISO(`${id % 1000 + 2000}-11-11T10:10:00Z`), `modifier${id}`,
DateTime.parseISO(buildDate(id, 10)), `creator${id}`,
DateTime.parseISO(buildDate(id, 20)), `modifier${id}`,
new Version(key),
`My Name${key}.png`,
`My Hash${key}`,
@ -595,3 +595,7 @@ export function createAssetFolder(id: number, suffix = '', parentId?: string) {
return new AssetFolderDto(links, `id${id}`, `My Folder${key}`, parentId, new Version(`${id}`));
}
function buildDate(id: number, add = 0) {
return `${id % 1000 + 2000 + add}-12-11T10:09:08Z`;
}

16
frontend/src/app/shared/services/contents.service.spec.ts

@ -422,15 +422,15 @@ describe('ContentsService', () => {
statusColor: 'black',
newStatus: `StatusNew${id}`,
newStatusColor: 'black',
created: `${id % 1000 + 2000}-12-12T10:10:00Z`,
created: buildDate(id, 10),
createdBy: `creator${id}`,
lastModified: `${id % 1000 + 2000}-11-11T10:10:00Z`,
lastModified: buildDate(id, 20),
lastModifiedBy: `modifier${id}`,
scheduleJob: {
status: 'Draft',
scheduledBy: `Scheduler${id}`,
color: 'red',
dueTime: `${id % 1000 + 2000}-11-11T10:10:00Z`,
dueTime: buildDate(id, 30),
},
isDeleted: false,
data: {},
@ -455,14 +455,14 @@ export function createContent(id: number, suffix = '') {
return new ContentDto(links,
`id${id}`,
DateTime.parseISO(`${id % 1000 + 2000}-12-12T10:10:00Z`), `creator${id}`,
DateTime.parseISO(`${id % 1000 + 2000}-11-11T10:10:00Z`), `modifier${id}`,
DateTime.parseISO(buildDate(id, 10)), `creator${id}`,
DateTime.parseISO(buildDate(id, 20)), `modifier${id}`,
new Version(key),
`Status${key}`,
'black',
`StatusNew${key}`,
'black',
new ScheduleDto('Draft', `Scheduler${id}`, 'red', DateTime.parseISO(`${id % 1000 + 2000}-11-11T10:10:00Z`)),
new ScheduleDto('Draft', `Scheduler${id}`, 'red', DateTime.parseISO(buildDate(id, 30))),
false,
{},
'my-schema',
@ -470,3 +470,7 @@ export function createContent(id: number, suffix = '') {
{},
[]);
}
function buildDate(id: number, add = 0) {
return `${id % 1000 + 2000 + add}-12-11T10:09:08Z`;
}

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save