diff --git a/docs/screenshots/README.md b/docs/screenshots/README.md new file mode 100644 index 0000000..2b9760a --- /dev/null +++ b/docs/screenshots/README.md @@ -0,0 +1,7 @@ +# Backups home and record screenshots + +Taken 2026-10-12 against `BackupsDemoServer` (src/test/.../demo, memory backend, real engine history) and the Next UI 1.0.0-RC.14 jar. +Light and dark at 390, 600, 768, 1024, 1440, 1920 px (home) and 390, 1440 px (records). + +- `.` : this branch (foundation base): home, plan, run, failed run, lists. +- `with-restore-verify/` : a scratch merge of PRs #5 (verify), #6 (restore), #7 (prune/schedule) onto this branch, so verifications, a restore run and retention classes exist. Not part of this PR. diff --git a/docs/screenshots/home-dark-1024.png b/docs/screenshots/home-dark-1024.png new file mode 100644 index 0000000..6c74ec9 Binary files /dev/null and b/docs/screenshots/home-dark-1024.png differ diff --git a/docs/screenshots/home-dark-1440.png b/docs/screenshots/home-dark-1440.png new file mode 100644 index 0000000..da9b456 Binary files /dev/null and b/docs/screenshots/home-dark-1440.png differ diff --git a/docs/screenshots/home-dark-1920.png b/docs/screenshots/home-dark-1920.png new file mode 100644 index 0000000..a668b0a Binary files /dev/null and b/docs/screenshots/home-dark-1920.png differ diff --git a/docs/screenshots/home-dark-390.png b/docs/screenshots/home-dark-390.png new file mode 100644 index 0000000..374505f Binary files /dev/null and b/docs/screenshots/home-dark-390.png differ diff --git a/docs/screenshots/home-dark-600.png b/docs/screenshots/home-dark-600.png new file mode 100644 index 0000000..4c494d9 Binary files /dev/null and b/docs/screenshots/home-dark-600.png differ diff --git a/docs/screenshots/home-dark-768.png b/docs/screenshots/home-dark-768.png new file mode 100644 index 0000000..907531c Binary files /dev/null and b/docs/screenshots/home-dark-768.png differ diff --git a/docs/screenshots/home-light-1024.png b/docs/screenshots/home-light-1024.png new file mode 100644 index 0000000..f33d0d4 Binary files /dev/null and b/docs/screenshots/home-light-1024.png differ diff --git a/docs/screenshots/home-light-1440.png b/docs/screenshots/home-light-1440.png new file mode 100644 index 0000000..1e6ba96 Binary files /dev/null and b/docs/screenshots/home-light-1440.png differ diff --git a/docs/screenshots/home-light-1920.png b/docs/screenshots/home-light-1920.png new file mode 100644 index 0000000..7d2935d Binary files /dev/null and b/docs/screenshots/home-light-1920.png differ diff --git a/docs/screenshots/home-light-390.png b/docs/screenshots/home-light-390.png new file mode 100644 index 0000000..50e6bc4 Binary files /dev/null and b/docs/screenshots/home-light-390.png differ diff --git a/docs/screenshots/home-light-600.png b/docs/screenshots/home-light-600.png new file mode 100644 index 0000000..03d7d1c Binary files /dev/null and b/docs/screenshots/home-light-600.png differ diff --git a/docs/screenshots/home-light-768.png b/docs/screenshots/home-light-768.png new file mode 100644 index 0000000..109b52b Binary files /dev/null and b/docs/screenshots/home-light-768.png differ diff --git a/docs/screenshots/plan-dark-1440.png b/docs/screenshots/plan-dark-1440.png new file mode 100644 index 0000000..215ca42 Binary files /dev/null and b/docs/screenshots/plan-dark-1440.png differ diff --git a/docs/screenshots/plan-dark-390.png b/docs/screenshots/plan-dark-390.png new file mode 100644 index 0000000..3165c37 Binary files /dev/null and b/docs/screenshots/plan-dark-390.png differ diff --git a/docs/screenshots/plan-failing-dark-1440.png b/docs/screenshots/plan-failing-dark-1440.png new file mode 100644 index 0000000..6777047 Binary files /dev/null and b/docs/screenshots/plan-failing-dark-1440.png differ diff --git a/docs/screenshots/plan-failing-dark-390.png b/docs/screenshots/plan-failing-dark-390.png new file mode 100644 index 0000000..b16e001 Binary files /dev/null and b/docs/screenshots/plan-failing-dark-390.png differ diff --git a/docs/screenshots/plan-failing-light-1440.png b/docs/screenshots/plan-failing-light-1440.png new file mode 100644 index 0000000..dc89b83 Binary files /dev/null and b/docs/screenshots/plan-failing-light-1440.png differ diff --git a/docs/screenshots/plan-failing-light-390.png b/docs/screenshots/plan-failing-light-390.png new file mode 100644 index 0000000..31ab4f3 Binary files /dev/null and b/docs/screenshots/plan-failing-light-390.png differ diff --git a/docs/screenshots/plan-light-1440.png b/docs/screenshots/plan-light-1440.png new file mode 100644 index 0000000..9de9047 Binary files /dev/null and b/docs/screenshots/plan-light-1440.png differ diff --git a/docs/screenshots/plan-light-390.png b/docs/screenshots/plan-light-390.png new file mode 100644 index 0000000..56b1724 Binary files /dev/null and b/docs/screenshots/plan-light-390.png differ diff --git a/docs/screenshots/plans-dark-1440.png b/docs/screenshots/plans-dark-1440.png new file mode 100644 index 0000000..797ceba Binary files /dev/null and b/docs/screenshots/plans-dark-1440.png differ diff --git a/docs/screenshots/plans-dark-390.png b/docs/screenshots/plans-dark-390.png new file mode 100644 index 0000000..f72c18f Binary files /dev/null and b/docs/screenshots/plans-dark-390.png differ diff --git a/docs/screenshots/plans-light-1440.png b/docs/screenshots/plans-light-1440.png new file mode 100644 index 0000000..eaf52b8 Binary files /dev/null and b/docs/screenshots/plans-light-1440.png differ diff --git a/docs/screenshots/plans-light-390.png b/docs/screenshots/plans-light-390.png new file mode 100644 index 0000000..84618f2 Binary files /dev/null and b/docs/screenshots/plans-light-390.png differ diff --git a/docs/screenshots/run-dark-1440.png b/docs/screenshots/run-dark-1440.png new file mode 100644 index 0000000..e49352a Binary files /dev/null and b/docs/screenshots/run-dark-1440.png differ diff --git a/docs/screenshots/run-dark-390.png b/docs/screenshots/run-dark-390.png new file mode 100644 index 0000000..f76b8ad Binary files /dev/null and b/docs/screenshots/run-dark-390.png differ diff --git a/docs/screenshots/run-failed-dark-1440.png b/docs/screenshots/run-failed-dark-1440.png new file mode 100644 index 0000000..fc9ec30 Binary files /dev/null and b/docs/screenshots/run-failed-dark-1440.png differ diff --git a/docs/screenshots/run-failed-dark-390.png b/docs/screenshots/run-failed-dark-390.png new file mode 100644 index 0000000..84e6ca1 Binary files /dev/null and b/docs/screenshots/run-failed-dark-390.png differ diff --git a/docs/screenshots/run-failed-light-1440.png b/docs/screenshots/run-failed-light-1440.png new file mode 100644 index 0000000..9270be6 Binary files /dev/null and b/docs/screenshots/run-failed-light-1440.png differ diff --git a/docs/screenshots/run-failed-light-390.png b/docs/screenshots/run-failed-light-390.png new file mode 100644 index 0000000..8c5d972 Binary files /dev/null and b/docs/screenshots/run-failed-light-390.png differ diff --git a/docs/screenshots/run-light-1440.png b/docs/screenshots/run-light-1440.png new file mode 100644 index 0000000..ef72749 Binary files /dev/null and b/docs/screenshots/run-light-1440.png differ diff --git a/docs/screenshots/run-light-390.png b/docs/screenshots/run-light-390.png new file mode 100644 index 0000000..f2a4a9a Binary files /dev/null and b/docs/screenshots/run-light-390.png differ diff --git a/docs/screenshots/runs-dark-1440.png b/docs/screenshots/runs-dark-1440.png new file mode 100644 index 0000000..e6941e1 Binary files /dev/null and b/docs/screenshots/runs-dark-1440.png differ diff --git a/docs/screenshots/runs-dark-390.png b/docs/screenshots/runs-dark-390.png new file mode 100644 index 0000000..c8f72f0 Binary files /dev/null and b/docs/screenshots/runs-dark-390.png differ diff --git a/docs/screenshots/runs-light-1440.png b/docs/screenshots/runs-light-1440.png new file mode 100644 index 0000000..3b8dea7 Binary files /dev/null and b/docs/screenshots/runs-light-1440.png differ diff --git a/docs/screenshots/runs-light-390.png b/docs/screenshots/runs-light-390.png new file mode 100644 index 0000000..70335c8 Binary files /dev/null and b/docs/screenshots/runs-light-390.png differ diff --git a/docs/screenshots/with-restore-verify/home-dark-1024.png b/docs/screenshots/with-restore-verify/home-dark-1024.png new file mode 100644 index 0000000..16497df Binary files /dev/null and b/docs/screenshots/with-restore-verify/home-dark-1024.png differ diff --git a/docs/screenshots/with-restore-verify/home-dark-1440.png b/docs/screenshots/with-restore-verify/home-dark-1440.png new file mode 100644 index 0000000..0b082d7 Binary files /dev/null and b/docs/screenshots/with-restore-verify/home-dark-1440.png differ diff --git a/docs/screenshots/with-restore-verify/home-dark-1920.png b/docs/screenshots/with-restore-verify/home-dark-1920.png new file mode 100644 index 0000000..be209a5 Binary files /dev/null and b/docs/screenshots/with-restore-verify/home-dark-1920.png differ diff --git a/docs/screenshots/with-restore-verify/home-dark-390.png b/docs/screenshots/with-restore-verify/home-dark-390.png new file mode 100644 index 0000000..ba69cc7 Binary files /dev/null and b/docs/screenshots/with-restore-verify/home-dark-390.png differ diff --git a/docs/screenshots/with-restore-verify/home-dark-600.png b/docs/screenshots/with-restore-verify/home-dark-600.png new file mode 100644 index 0000000..ee6c29d Binary files /dev/null and b/docs/screenshots/with-restore-verify/home-dark-600.png differ diff --git a/docs/screenshots/with-restore-verify/home-dark-768.png b/docs/screenshots/with-restore-verify/home-dark-768.png new file mode 100644 index 0000000..87f8407 Binary files /dev/null and b/docs/screenshots/with-restore-verify/home-dark-768.png differ diff --git a/docs/screenshots/with-restore-verify/home-light-1024.png b/docs/screenshots/with-restore-verify/home-light-1024.png new file mode 100644 index 0000000..a6b8974 Binary files /dev/null and b/docs/screenshots/with-restore-verify/home-light-1024.png differ diff --git a/docs/screenshots/with-restore-verify/home-light-1440.png b/docs/screenshots/with-restore-verify/home-light-1440.png new file mode 100644 index 0000000..a537211 Binary files /dev/null and b/docs/screenshots/with-restore-verify/home-light-1440.png differ diff --git a/docs/screenshots/with-restore-verify/home-light-1920.png b/docs/screenshots/with-restore-verify/home-light-1920.png new file mode 100644 index 0000000..52c4f7b Binary files /dev/null and b/docs/screenshots/with-restore-verify/home-light-1920.png differ diff --git a/docs/screenshots/with-restore-verify/home-light-390.png b/docs/screenshots/with-restore-verify/home-light-390.png new file mode 100644 index 0000000..512d304 Binary files /dev/null and b/docs/screenshots/with-restore-verify/home-light-390.png differ diff --git a/docs/screenshots/with-restore-verify/home-light-600.png b/docs/screenshots/with-restore-verify/home-light-600.png new file mode 100644 index 0000000..d67b794 Binary files /dev/null and b/docs/screenshots/with-restore-verify/home-light-600.png differ diff --git a/docs/screenshots/with-restore-verify/home-light-768.png b/docs/screenshots/with-restore-verify/home-light-768.png new file mode 100644 index 0000000..57039bd Binary files /dev/null and b/docs/screenshots/with-restore-verify/home-light-768.png differ diff --git a/docs/screenshots/with-restore-verify/plan-dark-1440.png b/docs/screenshots/with-restore-verify/plan-dark-1440.png new file mode 100644 index 0000000..11ed803 Binary files /dev/null and b/docs/screenshots/with-restore-verify/plan-dark-1440.png differ diff --git a/docs/screenshots/with-restore-verify/plan-dark-390.png b/docs/screenshots/with-restore-verify/plan-dark-390.png new file mode 100644 index 0000000..aefc16a Binary files /dev/null and b/docs/screenshots/with-restore-verify/plan-dark-390.png differ diff --git a/docs/screenshots/with-restore-verify/plan-light-1440.png b/docs/screenshots/with-restore-verify/plan-light-1440.png new file mode 100644 index 0000000..0dbd836 Binary files /dev/null and b/docs/screenshots/with-restore-verify/plan-light-1440.png differ diff --git a/docs/screenshots/with-restore-verify/plan-light-390.png b/docs/screenshots/with-restore-verify/plan-light-390.png new file mode 100644 index 0000000..cfbb6e3 Binary files /dev/null and b/docs/screenshots/with-restore-verify/plan-light-390.png differ diff --git a/docs/screenshots/with-restore-verify/restore-run-dark-1440.png b/docs/screenshots/with-restore-verify/restore-run-dark-1440.png new file mode 100644 index 0000000..a00d09d Binary files /dev/null and b/docs/screenshots/with-restore-verify/restore-run-dark-1440.png differ diff --git a/docs/screenshots/with-restore-verify/restore-run-dark-390.png b/docs/screenshots/with-restore-verify/restore-run-dark-390.png new file mode 100644 index 0000000..b111daa Binary files /dev/null and b/docs/screenshots/with-restore-verify/restore-run-dark-390.png differ diff --git a/docs/screenshots/with-restore-verify/restore-run-light-1440.png b/docs/screenshots/with-restore-verify/restore-run-light-1440.png new file mode 100644 index 0000000..6295791 Binary files /dev/null and b/docs/screenshots/with-restore-verify/restore-run-light-1440.png differ diff --git a/docs/screenshots/with-restore-verify/restore-run-light-390.png b/docs/screenshots/with-restore-verify/restore-run-light-390.png new file mode 100644 index 0000000..e15e99c Binary files /dev/null and b/docs/screenshots/with-restore-verify/restore-run-light-390.png differ diff --git a/docs/screenshots/with-restore-verify/restores-dark-1440.png b/docs/screenshots/with-restore-verify/restores-dark-1440.png new file mode 100644 index 0000000..4f9b11c Binary files /dev/null and b/docs/screenshots/with-restore-verify/restores-dark-1440.png differ diff --git a/docs/screenshots/with-restore-verify/restores-dark-390.png b/docs/screenshots/with-restore-verify/restores-dark-390.png new file mode 100644 index 0000000..e529e56 Binary files /dev/null and b/docs/screenshots/with-restore-verify/restores-dark-390.png differ diff --git a/docs/screenshots/with-restore-verify/restores-light-1440.png b/docs/screenshots/with-restore-verify/restores-light-1440.png new file mode 100644 index 0000000..48bdeb6 Binary files /dev/null and b/docs/screenshots/with-restore-verify/restores-light-1440.png differ diff --git a/docs/screenshots/with-restore-verify/restores-light-390.png b/docs/screenshots/with-restore-verify/restores-light-390.png new file mode 100644 index 0000000..eb3a1ef Binary files /dev/null and b/docs/screenshots/with-restore-verify/restores-light-390.png differ diff --git a/docs/screenshots/with-restore-verify/run-actions-menu-light-1440.png b/docs/screenshots/with-restore-verify/run-actions-menu-light-1440.png new file mode 100644 index 0000000..bf80159 Binary files /dev/null and b/docs/screenshots/with-restore-verify/run-actions-menu-light-1440.png differ diff --git a/docs/screenshots/with-restore-verify/run-dark-1440.png b/docs/screenshots/with-restore-verify/run-dark-1440.png new file mode 100644 index 0000000..ccd1143 Binary files /dev/null and b/docs/screenshots/with-restore-verify/run-dark-1440.png differ diff --git a/docs/screenshots/with-restore-verify/run-dark-390.png b/docs/screenshots/with-restore-verify/run-dark-390.png new file mode 100644 index 0000000..4e3d196 Binary files /dev/null and b/docs/screenshots/with-restore-verify/run-dark-390.png differ diff --git a/docs/screenshots/with-restore-verify/run-light-1440.png b/docs/screenshots/with-restore-verify/run-light-1440.png new file mode 100644 index 0000000..cced5bc Binary files /dev/null and b/docs/screenshots/with-restore-verify/run-light-1440.png differ diff --git a/docs/screenshots/with-restore-verify/run-light-390.png b/docs/screenshots/with-restore-verify/run-light-390.png new file mode 100644 index 0000000..0e3f1fb Binary files /dev/null and b/docs/screenshots/with-restore-verify/run-light-390.png differ diff --git a/docs/screenshots/with-restore-verify/verification-dark-1440.png b/docs/screenshots/with-restore-verify/verification-dark-1440.png new file mode 100644 index 0000000..92f0004 Binary files /dev/null and b/docs/screenshots/with-restore-verify/verification-dark-1440.png differ diff --git a/docs/screenshots/with-restore-verify/verification-dark-390.png b/docs/screenshots/with-restore-verify/verification-dark-390.png new file mode 100644 index 0000000..919d7c3 Binary files /dev/null and b/docs/screenshots/with-restore-verify/verification-dark-390.png differ diff --git a/docs/screenshots/with-restore-verify/verification-light-1440.png b/docs/screenshots/with-restore-verify/verification-light-1440.png new file mode 100644 index 0000000..b91f9f7 Binary files /dev/null and b/docs/screenshots/with-restore-verify/verification-light-1440.png differ diff --git a/docs/screenshots/with-restore-verify/verification-light-390.png b/docs/screenshots/with-restore-verify/verification-light-390.png new file mode 100644 index 0000000..f667c8f Binary files /dev/null and b/docs/screenshots/with-restore-verify/verification-light-390.png differ diff --git a/docs/screenshots/with-restore-verify/verifications-dark-1440.png b/docs/screenshots/with-restore-verify/verifications-dark-1440.png new file mode 100644 index 0000000..c00c837 Binary files /dev/null and b/docs/screenshots/with-restore-verify/verifications-dark-1440.png differ diff --git a/docs/screenshots/with-restore-verify/verifications-dark-390.png b/docs/screenshots/with-restore-verify/verifications-dark-390.png new file mode 100644 index 0000000..94d0a45 Binary files /dev/null and b/docs/screenshots/with-restore-verify/verifications-dark-390.png differ diff --git a/docs/screenshots/with-restore-verify/verifications-light-1440.png b/docs/screenshots/with-restore-verify/verifications-light-1440.png new file mode 100644 index 0000000..1e1a39e Binary files /dev/null and b/docs/screenshots/with-restore-verify/verifications-light-1440.png differ diff --git a/docs/screenshots/with-restore-verify/verifications-light-390.png b/docs/screenshots/with-restore-verify/verifications-light-390.png new file mode 100644 index 0000000..9f5be97 Binary files /dev/null and b/docs/screenshots/with-restore-verify/verifications-light-390.png differ diff --git a/pom.xml b/pom.xml index 147d0d8..6a81570 100644 --- a/pom.xml +++ b/pom.xml @@ -102,6 +102,19 @@ test + + + com.kingsrook.qqq + qqq-middleware-javalin + test + + + com.kingsrook.qqq + qqq-frontend-next + 1.0.0-RC.14 + test + + org.junit.jupiter junit-jupiter diff --git a/src/main/java/com/kingsrook/qbits/backups/BackupsQBitConfig.java b/src/main/java/com/kingsrook/qbits/backups/BackupsQBitConfig.java index 31338a4..f0fb776 100644 --- a/src/main/java/com/kingsrook/qbits/backups/BackupsQBitConfig.java +++ b/src/main/java/com/kingsrook/qbits/backups/BackupsQBitConfig.java @@ -45,6 +45,7 @@ public class BackupsQBitConfig implements QBitConfig private String appName = "backups"; private String appLabel = "Backups"; private String parentAppName; + private String tablePathPrefix; private String schedulerName; private String instanceName; @@ -179,6 +180,28 @@ public class BackupsQBitConfig implements QBitConfig + /******************************************************************************* + ** Getter for tablePathPrefix (the front of the links the home makes to tables and + ** records, "/app" for the Next dashboard, which routes /app/{table}/{id}). + *******************************************************************************/ + public String getTablePathPrefix() + { + return (tablePathPrefix == null ? "/app" : tablePathPrefix); + } + + + + /******************************************************************************* + ** Fluent setter for tablePathPrefix + *******************************************************************************/ + public BackupsQBitConfig withTablePathPrefix(String tablePathPrefix) + { + this.tablePathPrefix = tablePathPrefix; + return (this); + } + + + /******************************************************************************* ** Getter for schedulerName (the QQQ scheduler used for plan jobs and the SLA checker) *******************************************************************************/ diff --git a/src/main/java/com/kingsrook/qbits/backups/BackupsQBitProducer.java b/src/main/java/com/kingsrook/qbits/backups/BackupsQBitProducer.java index 2da2edc..3f3a1d4 100644 --- a/src/main/java/com/kingsrook/qbits/backups/BackupsQBitProducer.java +++ b/src/main/java/com/kingsrook/qbits/backups/BackupsQBitProducer.java @@ -158,8 +158,20 @@ public class BackupsQBitProducer implements QBitMetaDataProducer app.getName().equals(child.getName())))) + { + parent.withChild(app); + } return (new com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput()); } diff --git a/src/main/java/com/kingsrook/qbits/backups/app/BackupsApp.java b/src/main/java/com/kingsrook/qbits/backups/app/BackupsApp.java index 41d534e..540b09c 100644 --- a/src/main/java/com/kingsrook/qbits/backups/app/BackupsApp.java +++ b/src/main/java/com/kingsrook/qbits/backups/app/BackupsApp.java @@ -29,7 +29,6 @@ import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType; import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData; import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData; -import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppSection; import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon; import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; @@ -62,7 +61,7 @@ public final class BackupsApp public static List widgets() { return (List.of( - new QWidgetMetaData().withName(HOME_WIDGET).withLabel("Backups").withType(WidgetType.HTML.getType()) + new QWidgetMetaData().withName(HOME_WIDGET).withLabel("Overview").withType(WidgetType.HTML.getType()) .withCodeReference(new QCodeReference(HomeRenderer.class)).withGridColumns(12).withIsCard(false).withPermissionRules(BackupsTables.permissionRules()), new QWidgetMetaData().withName(HEALTH_CARD_WIDGET).withLabel("Backups").withType(WidgetType.HTML.getType()) .withCodeReference(new QCodeReference(HealthCardRenderer.class)).withGridColumns(12).withIsCard(true).withPermissionRules(BackupsTables.permissionRules()))); @@ -80,13 +79,7 @@ public final class BackupsApp .withLabel(config.getAppLabel()) .withIcon(new QIcon("backup")) .withPermissionRules(BackupsTables.permissionRules()) - .withWidgets(List.of(HOME_WIDGET)) - .withSection(new QAppSection().withName("backups").withLabel("Backups").withIcon(new QIcon("backup")) - .withTables(List.of(BackupsTables.PLAN, BackupsTables.RUN))) - .withSection(new QAppSection().withName("recovery").withLabel("Restore and verify").withIcon(new QIcon("restore")) - .withTables(List.of(BackupsTables.RESTORE_RUN, BackupsTables.VERIFICATION))) - .withSection(new QAppSection().withName("files").withLabel("Files").withIcon(new QIcon("attach_file")) - .withTables(List.of(BackupsTables.FILE_OBJECT))); + .withWidgets(List.of(HOME_WIDGET)); if(config.getParentAppName() != null) { app.setParentAppName(config.getParentAppName()); @@ -143,10 +136,6 @@ public final class BackupsApp static String pathPrefix() { var config = com.kingsrook.qbits.backups.BackupsRuntime.getConfig(); - if(config == null) - { - return ("/backups"); - } - return (config.getParentAppName() != null ? "/" + config.getParentAppName() + "/" + config.getAppName() : "/" + config.getAppName()); + return (config == null ? "/app" : config.getTablePathPrefix()); } } diff --git a/src/main/java/com/kingsrook/qbits/backups/app/BackupsHomeData.java b/src/main/java/com/kingsrook/qbits/backups/app/BackupsHomeData.java index b2d3dc5..45844f1 100644 --- a/src/main/java/com/kingsrook/qbits/backups/app/BackupsHomeData.java +++ b/src/main/java/com/kingsrook/qbits/backups/app/BackupsHomeData.java @@ -159,12 +159,53 @@ public class BackupsHomeData + /******************************************************************************* + ** The status to show for a plan: the stored slaStatus when it is a problem state, + ** otherwise LATE when the age of the last success is past the SLA (the SLA + ** checker may not have run yet), otherwise the stored status (UNKNOWN when none). + *******************************************************************************/ + public String effectiveStatus(QRecord plan) + { + String stored = plan.getValueString("slaStatus"); + if(stored != null && ATTENTION.contains(stored)) + { + return (stored); + } + Long age = ageHours(plan); + Integer sla = plan.getValueInteger("slaHours"); + if(age != null && sla != null && age > sla) + { + return ("LATE"); + } + return (stored == null ? "UNKNOWN" : stored); + } + + + /******************************************************************************* ** Plans that need attention. *******************************************************************************/ public List plansNeedingAttention() { - return (plans.stream().filter(p -> Boolean.TRUE.equals(p.getValueBoolean("isActive")) && ATTENTION.contains(p.getValueString("slaStatus"))).toList()); + return (plans.stream().filter(p -> Boolean.TRUE.equals(p.getValueBoolean("isActive")) && ATTENTION.contains(effectiveStatus(p))).toList()); + } + + + + /******************************************************************************* + ** The last run of a plan (from the recent runs), or null. + *******************************************************************************/ + public QRecord lastRun(QRecord plan) + { + Integer lastRunId = plan.getValueInteger("lastRunId"); + for(QRecord run : recentRuns) + { + if(lastRunId != null && lastRunId.equals(run.getValueInteger("id"))) + { + return (run); + } + } + return (null); } @@ -206,7 +247,7 @@ public class BackupsHomeData Integer runId = verification.getValueInteger("backupRunId"); for(QRecord run : recentRuns) { - if(runId != null && runId.equals(run.getValueInteger("id")) && planId.equals(run.getValueInteger("backupPlanId"))) + if(runId != null && planId != null && runId.equals(run.getValueInteger("id")) && planId.equals(run.getValueInteger("backupPlanId"))) { return (verification); } @@ -224,6 +265,10 @@ public class BackupsHomeData public List successfulRuns(Integer planId) { List rs = new ArrayList<>(); + if(planId == null) + { + return (rs); + } for(QRecord run : recentRuns) { if(planId.equals(run.getValueInteger("backupPlanId")) && run.getValueString("status") != null && run.getValueString("status").startsWith("SUCCEEDED")) @@ -242,6 +287,10 @@ public class BackupsHomeData public Map retentionCounts(Integer planId) { Map counts = new LinkedHashMap<>(); + if(planId == null) + { + return (counts); + } for(QRecord run : recentRuns) { if(planId.equals(run.getValueInteger("backupPlanId")) && run.getValueString("status") != null && run.getValueString("status").startsWith("SUCCEEDED") diff --git a/src/main/java/com/kingsrook/qbits/backups/app/BackupsHomeView.java b/src/main/java/com/kingsrook/qbits/backups/app/BackupsHomeView.java index e4af3cd..513622a 100644 --- a/src/main/java/com/kingsrook/qbits/backups/app/BackupsHomeView.java +++ b/src/main/java/com/kingsrook/qbits/backups/app/BackupsHomeView.java @@ -27,21 +27,30 @@ import com.kingsrook.qqq.backend.core.model.data.QRecord; /******************************************************************************* - ** Renders the Backups home: one card per concern, each with an honest empty - ** state. Cards stack to one column on narrow screens, long names wrap, and - ** there are no tables and no inner horizontal scroll (definition lists and - ** lists only), so it reads at every width in light and dark mode (colors are - ** inherited, not set). + ** Renders the Backups home: a plan status row per plan across the full width, + ** then cards for what needs attention, the latest test restore, recent activity, + ** the size trend and retention. Every card has an honest empty state. Layout + ** is flow and grid only (no tables, no fixed widths, no inner scrolling): plan + ** rows wrap their cells and the cards stack to one column on narrow screens, + ** long names wrap, and colors are inherited (only the status dot is colored), so + ** it reads at every width in light and dark mode. *******************************************************************************/ public final class BackupsHomeView { private static final DateTimeFormatter WHEN = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm 'UTC'").withZone(ZoneOffset.UTC); - private static final String GRID = "display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,22rem),1fr));gap:1rem;"; + private static final String ROOT = "display:grid;gap:1rem;min-width:0;"; + private static final String GRID = "display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,24rem),1fr));gap:1rem;align-items:start;min-width:0;"; private static final String CARD = "border:1px solid rgba(127,127,127,.3);border-radius:8px;padding:1rem;min-width:0;overflow-wrap:anywhere;"; + private static final String ROW = "border:1px solid rgba(127,127,127,.25);border-radius:6px;padding:.75rem;margin:0 0 .75rem;min-width:0;"; + private static final String CELLS = "display:grid;grid-template-columns:repeat(auto-fit,minmax(9.5rem,1fr));gap:.6rem 1rem;margin:.6rem 0 0;font-size:.9rem;"; private static final String LIST = "display:grid;grid-template-columns:minmax(7rem,auto) 1fr;gap:.25rem .75rem;margin:0 0 .75rem;font-size:.9rem;"; - private static final String ITEMS = "margin:0 0 .75rem;padding-left:1.25rem;font-size:.9rem;"; + private static final String ITEMS = "margin:0 0 .75rem;padding-left:1.25rem;list-style:disc;font-size:.9rem;display:grid;gap:.35rem;"; private static final String MUTED = "opacity:.75;"; + private static final String LINK = "color:inherit;text-decoration:underline;text-underline-offset:2px;"; + private static final String CHIP = "display:inline-flex;align-items:center;gap:.4rem;padding:.05rem .6rem;border-radius:999px;border:1px solid rgba(127,127,127,.4);font-size:.8rem;white-space:nowrap;"; + + private static final int TREND_BARS = 30; @@ -59,14 +68,15 @@ public final class BackupsHomeView *******************************************************************************/ public static String render(BackupsHomeData data, String pathPrefix) { - StringBuilder html = new StringBuilder("
"); + StringBuilder html = new StringBuilder("
"); planStatus(html, data, pathPrefix); + html.append("
"); needsAttention(html, data, pathPrefix); latestTestRestore(html, data, pathPrefix); - trend(html, data); recentActivity(html, data, pathPrefix); + trend(html, data); retention(html, data); - return (html.append("
").toString()); + return (html.append("
").toString()); } @@ -82,13 +92,13 @@ public final class BackupsHomeView case "ATTENTION" -> "Needs attention: see the Backups home."; default -> "All backups are within their SLA."; }; - return ("
Backups

" + esc(text) + "

Open the Backups home
"); + return ("
Backups

" + esc(text) + "

Open the Backups home
"); } /******************************************************************************* - ** Plan status: last successful backup, age against the SLA, last run, verification. + ** Plan status: one row per plan, the cells wrap to the width. *******************************************************************************/ private static void planStatus(StringBuilder html, BackupsHomeData data, String prefix) { @@ -99,24 +109,29 @@ public final class BackupsHomeView } for(QRecord plan : data.plans()) { - Long age = data.ageHours(plan); - Integer sla = plan.getValueInteger("slaHours"); - html.append("
"); - html.append("
Plan
") - .append(esc(plan.getValueString("name"))).append("").append(Boolean.TRUE.equals(plan.getValueBoolean("isActive")) ? "" : " (not active)").append("
"); - row(html, "Status", label(plan.getValueString("slaStatus"))); - row(html, "Last backup", plan.getValueInstant("lastSuccessDate") == null ? "No successful backup yet" : WHEN.format(plan.getValueInstant("lastSuccessDate"))); - if(age != null && sla != null) + Long age = data.ageHours(plan); + Integer sla = plan.getValueInteger("slaHours"); + String status = data.effectiveStatus(plan); + QRecord last = data.lastRun(plan); + + html.append("
"); + html.append("").append(recordLink(prefix, BackupsTables.PLAN, plan.getValue("id"), plan.getValueString("name"))).append(""); + html.append(chip(status)); + if(!Boolean.TRUE.equals(plan.getValueBoolean("isActive"))) { - row(html, "Age against SLA", age + " h of " + sla + " h" + (age > sla ? " - late" : "")); + html.append("Not active"); } - row(html, "Last verified", plan.getValueInstant("lastVerifiedDate") == null ? "Not verified yet" : WHEN.format(plan.getValueInstant("lastVerifiedDate"))); - row(html, "Schedule", plan.getValueString("cronExpression") == null ? "Manual only" : plan.getValueString("cronExpression")); + html.append("
"); + cell(html, "Last backup", plan.getValueInstant("lastSuccessDate") == null ? "No successful backup yet" : WHEN.format(plan.getValueInstant("lastSuccessDate"))); + cell(html, "Age against SLA", age != null && sla != null ? age + " h of " + sla + " h" : (sla == null ? "No SLA set" : "Not run yet")); + cell(html, "Last run", last == null ? "None yet" : label(last.getValueString("status")) + (last.getValueInstant("startDate") == null ? "" : ", " + WHEN.format(last.getValueInstant("startDate")))); + cell(html, "Schedule", plan.getValueString("cronExpression") == null ? "Manual only" : plan.getValueString("cronExpression")); + cell(html, "Last verified", plan.getValueInstant("lastVerifiedDate") == null ? "Not verified yet" : WHEN.format(plan.getValueInstant("lastVerifiedDate"))); if(plan.getValueString("objectLockMode") != null && !"NONE".equals(plan.getValueString("objectLockMode"))) { - row(html, "Object lock", plan.getValueString("objectLockMode") + ", " + plan.getValueInteger("objectLockDays") + " days"); + cell(html, "Object lock", plan.getValueString("objectLockMode") + (plan.getValueInteger("objectLockDays") == null ? "" : ", " + plan.getValueInteger("objectLockDays") + " days")); } - html.append("
"); + html.append(""); } link(html, prefix, BackupsTables.PLAN, "Backup plans"); close(html); @@ -144,13 +159,13 @@ public final class BackupsHomeView html.append("
    "); for(QRecord plan : plans) { - html.append("
  • ").append(esc(plan.getValueString("name"))) - .append(": ").append(esc(label(plan.getValueString("slaStatus")))).append("
  • "); + html.append("
  • ").append(recordLink(prefix, BackupsTables.PLAN, plan.getValue("id"), plan.getValueString("name"))) + .append(": ").append(esc(label(data.effectiveStatus(plan)))).append("
  • "); } for(QRecord run : data.failedRuns()) { - html.append("
  • Run ").append(esc(run.getValueString("runKey"))) - .append(" ").append(esc(label(run.getValueString("status")))).append("
  • "); + html.append("
  • Run ").append(recordLink(prefix, BackupsTables.RUN, run.getValue("id"), run.getValueString("runKey"))) + .append(": ").append(esc(label(run.getValueString("status")))).append("
  • "); } html.append("
"); } @@ -178,7 +193,8 @@ public final class BackupsHomeView html.append("
"); row(html, "Plan", plan.getValueString("name")); row(html, "Result", label(v.getValueString("status"))); - row(html, "Compared", v.getValueInteger("tablesChecked") + " tables, " + v.getValueLong("rowsChecked") + " rows, " + v.getValueInteger("mismatches") + " mismatches"); + row(html, "Compared", number(v.getValueLong("rowsChecked")) + " rows in " + number(toLong(v.getValueInteger("tablesChecked"))) + " tables, " + + number(toLong(v.getValueInteger("mismatches"))) + " mismatches"); row(html, "Scratch target", v.getValueString("scratchTarget") == null ? "Not recorded" : v.getValueString("scratchTarget")); html.append("
"); } @@ -193,7 +209,8 @@ public final class BackupsHomeView /******************************************************************************* - ** Size and duration trend per plan, as numbers next to each other. + ** Size trend per plan: bars for the stored size of each successful run (oldest + ** left), then the numbers. *******************************************************************************/ private static void trend(StringBuilder html, BackupsHomeData data) { @@ -209,13 +226,14 @@ public final class BackupsHomeView any = true; QRecord first = runs.get(0); QRecord last = runs.get(runs.size() - 1); + html.append("
").append(esc(plan.getValueString("name"))).append(""); + bars(html, runs); html.append("
"); - row(html, "Plan", plan.getValueString("name")); row(html, "Successful runs", String.valueOf(runs.size())); - row(html, "Stored (first, latest)", bytes(first.getValueLong("bytesStored")) + ", " + bytes(last.getValueLong("bytesStored"))); - row(html, "Rows (first, latest)", number(first.getValueLong("rowsTotal")) + ", " + number(last.getValueLong("rowsTotal"))); - row(html, "Duration (latest)", last.getValueLong("durationMs") == null ? "Not recorded" : last.getValueLong("durationMs") / 1000 + " s"); - html.append("
"); + row(html, "Stored", bytes(first.getValueLong("bytesStored")) + " at first, " + bytes(last.getValueLong("bytesStored")) + " latest"); + row(html, "Rows", number(first.getValueLong("rowsTotal")) + " at first, " + number(last.getValueLong("rowsTotal")) + " latest"); + row(html, "Duration", last.getValueLong("durationMs") == null ? "Not recorded" : duration(last.getValueLong("durationMs")) + " latest"); + html.append("
"); } if(!any) { @@ -227,7 +245,31 @@ public final class BackupsHomeView /******************************************************************************* - ** Recent activity: the last runs and restores. + ** A row of bars, one per run, tall in proportion to the stored bytes. + *******************************************************************************/ + private static void bars(StringBuilder html, List runs) + { + List shown = runs.size() > TREND_BARS ? runs.subList(runs.size() - TREND_BARS, runs.size()) : runs; + long max = 1; + for(QRecord run : shown) + { + max = Math.max(max, run.getValueLong("bytesStored") == null ? 0 : run.getValueLong("bytesStored")); + } + html.append("
"); + for(QRecord run : shown) + { + long value = run.getValueLong("bytesStored") == null ? 0 : run.getValueLong("bytesStored"); + long pct = Math.max(4, Math.round(100.0 * value / max)); + String title = (run.getValueInstant("startDate") == null ? "" : WHEN.format(run.getValueInstant("startDate")) + ": ") + bytes(run.getValueLong("bytesStored")); + html.append(""); + } + html.append("
"); + } + + + + /******************************************************************************* + ** Recent activity: the last runs, restores and verifications. *******************************************************************************/ private static void recentActivity(StringBuilder html, BackupsHomeData data, String prefix) { @@ -242,13 +284,13 @@ public final class BackupsHomeView data.recentRuns().stream().limit(6).forEach(run -> { Instant start = run.getValueInstant("startDate"); - html.append("
  • Backup ").append(esc(run.getValueString("runKey"))) - .append(": ").append(esc(label(run.getValueString("status")))).append(start == null ? "" : ", " + WHEN.format(start)).append("
  • "); + html.append("
  • Backup ").append(recordLink(prefix, BackupsTables.RUN, run.getValue("id"), run.getValueString("runKey"))) + .append(": ").append(esc(label(run.getValueString("status")))).append(start == null ? "" : ", " + esc(WHEN.format(start))).append("
  • "); }); - data.restores().stream().limit(2).forEach(restore -> html.append("
  • Restore #").append(esc(String.valueOf(restore.getValue("id")))).append(": ").append(esc(label(restore.getValueString("status")))).append("
  • ")); - data.verifications().stream().limit(2).forEach(v -> html.append("
  • Verify ").append(esc(label(v.getValueString("level")))).append(": ").append(esc(label(v.getValueString("status")))).append("
  • ")); + data.restores().stream().limit(2).forEach(restore -> html.append("
  • Restore ").append(recordLink(prefix, BackupsTables.RESTORE_RUN, restore.getValue("id"), "#" + restore.getValue("id"))) + .append(": ").append(esc(label(restore.getValueString("status")))).append("
  • ")); + data.verifications().stream().limit(2).forEach(v -> html.append("
  • Verify ").append(recordLink(prefix, BackupsTables.VERIFICATION, v.getValue("id"), label(v.getValueString("level")))) + .append(": ").append(esc(label(v.getValueString("status")))).append("
  • ")); html.append(""); } link(html, prefix, BackupsTables.RUN, "All runs"); @@ -274,9 +316,10 @@ public final class BackupsHomeView any = true; html.append("
    "); row(html, "Plan", plan.getValueString("name")); - row(html, "Kept", counts.entrySet().stream().map(e -> e.getValue() + " " + label(e.getKey()).toLowerCase()).reduce((a, b) -> a + ", " + b).orElse("")); - List runs = data.successfulRuns(plan.getValueInteger("id")); - Instant oldest = runs.isEmpty() ? null : runs.get(0).getValueInstant("startDate"); + row(html, "Kept", counts.entrySet().stream().map(e -> e.getValue() + " " + ("UNCLASSIFIED".equals(e.getKey()) ? "not classified yet (no prune has run)" : label(e.getKey()).toLowerCase())) + .reduce((a, b) -> a + ", " + b).orElse("")); + List runs = data.successfulRuns(plan.getValueInteger("id")); + Instant oldest = runs.isEmpty() ? null : runs.get(0).getValueInstant("startDate"); row(html, "Oldest restorable", oldest == null ? "Not known" : WHEN.format(oldest)); html.append("
    "); } @@ -290,17 +333,17 @@ public final class BackupsHomeView /******************************************************************************* - ** + ** Open a card. *******************************************************************************/ private static void open(StringBuilder html, String title) { - html.append("

    ").append(esc(title)).append("

    "); + html.append("

    ").append(esc(title)).append("

    "); } /******************************************************************************* - ** + ** Close a card. *******************************************************************************/ private static void close(StringBuilder html) { @@ -310,17 +353,17 @@ public final class BackupsHomeView /******************************************************************************* - ** + ** A muted empty-state line. *******************************************************************************/ private static void empty(StringBuilder html, String text) { - html.append("

    ").append(esc(text)).append("

    "); + html.append("

    ").append(esc(text)).append("

    "); } /******************************************************************************* - ** + ** A name and value pair in a definition list. *******************************************************************************/ private static void row(StringBuilder html, String name, String value) { @@ -330,17 +373,53 @@ public final class BackupsHomeView /******************************************************************************* - ** + ** A small label over its value (a plan row cell). *******************************************************************************/ - private static void link(StringBuilder html, String prefix, String table, String text) + private static void cell(StringBuilder html, String name, String value) { - html.append("").append(esc(text)).append(""); + html.append("
    ").append(esc(name)).append("
    ").append(esc(value)).append("
    "); } /******************************************************************************* - ** SOME_STATUS to Some status. + ** A status chip: a colored dot (the only color) and the status label. + *******************************************************************************/ + private static String chip(String status) + { + String color = switch(status == null ? "UNKNOWN" : status) + { + case "OK" -> "#16a34a"; + case "LATE", "UNVERIFIED", "UNKNOWN" -> "#d97706"; + default -> "#dc2626"; + }; + return ("" + esc(label(status)) + ""); + } + + + + /******************************************************************************* + ** A link to the table list at the end of a card. + *******************************************************************************/ + private static void link(StringBuilder html, String prefix, String table, String text) + { + html.append("").append(esc(text)).append(""); + } + + + + /******************************************************************************* + ** A link to a record. + *******************************************************************************/ + private static String recordLink(String prefix, String table, Object id, String text) + { + return ("" + esc(text == null ? String.valueOf(id) : text) + ""); + } + + + + /******************************************************************************* + ** SOME_STATUS to Some status (OK stays OK). *******************************************************************************/ static String label(String status) { @@ -348,6 +427,10 @@ public final class BackupsHomeView { return ("Unknown"); } + if("OK".equals(status)) + { + return ("OK"); + } String s = status.replace('_', ' ').toLowerCase(); return (Character.toUpperCase(s.charAt(0)) + s.substring(1)); } @@ -355,7 +438,32 @@ public final class BackupsHomeView /******************************************************************************* - ** + ** An Integer as a Long, null-safe. + *******************************************************************************/ + private static Long toLong(Integer value) + { + return (value == null ? null : value.longValue()); + } + + + + /******************************************************************************* + ** Milliseconds as a short duration. + *******************************************************************************/ + static String duration(long millis) + { + if(millis < 1000) + { + return (millis + " ms"); + } + long seconds = millis / 1000; + return (seconds < 60 ? seconds + " s" : seconds / 60 + " min " + seconds % 60 + " s"); + } + + + + /******************************************************************************* + ** Bytes as a short size. *******************************************************************************/ static String bytes(Long value) { @@ -363,7 +471,7 @@ public final class BackupsHomeView { return ("Not recorded"); } - double v = value; + double v = value; String[] units = { "B", "KB", "MB", "GB", "TB" }; int i = 0; while(v >= 1024 && i < units.length - 1) @@ -377,7 +485,7 @@ public final class BackupsHomeView /******************************************************************************* - ** + ** A number with separators. *******************************************************************************/ static String number(Long value) { @@ -387,7 +495,7 @@ public final class BackupsHomeView /******************************************************************************* - ** + ** HTML-escape. *******************************************************************************/ static String esc(String s) { diff --git a/src/main/java/com/kingsrook/qbits/backups/model/BackupsTables.java b/src/main/java/com/kingsrook/qbits/backups/model/BackupsTables.java index d6411b7..a0a0453 100644 --- a/src/main/java/com/kingsrook/qbits/backups/model/BackupsTables.java +++ b/src/main/java/com/kingsrook/qbits/backups/model/BackupsTables.java @@ -31,7 +31,12 @@ import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers; import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReferenceWithProperties; import com.kingsrook.qqq.backend.core.scheduler.processes.BaseSyncToScheduledJobTableCustomizer; import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; +import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.ChildRecordListRenderer; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy; +import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData; import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QFieldSection; +import com.kingsrook.qqq.backend.core.model.metadata.tables.Tier; import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType; import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinOn; import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinType; @@ -70,7 +75,7 @@ public final class BackupsTables public static final List PROCESS_WRITTEN = List.of(RUN, ARTIFACT, VERIFICATION, RESTORE_RUN, RESTORE_RUN_TABLE, FILE_OBJECT, ID_MAP); /** The tables shown in the nav (sub-tables appear as relations on their parents). */ - public static final List NAV_TABLES = List.of(PLAN, RUN, RESTORE_RUN, VERIFICATION, FILE_OBJECT); + public static final List NAV_TABLES = List.of(PLAN, RUN, RESTORE_RUN, VERIFICATION); public static final List ALL = List.of(PLAN, PLAN_SCOPE, RUN, ARTIFACT, VERIFICATION, RESTORE_RUN, RESTORE_RUN_TABLE, FILE_OBJECT, ID_MAP); @@ -210,7 +215,111 @@ public final class BackupsTables public static List tables(BackupsQBitConfig config) { return (List.of(plan(config), planScope(config), run(config), artifact(config), verification(config), - restoreRun(config), restoreRunTable(config), fileObject(config), idMap(config))); + restoreRun(config), restoreRunTable(config), fileObject(config), idMap(config)).stream().map(BackupsTables::withSections).toList()); + } + + + + /******************************************************************************* + ** Group a table's fields into record-screen sections (the identity and status + ** first, then one section per concern, then the system fields), so a record reads + ** as a few short groups instead of one long list. Fields not named here land in + ** the last section. + *******************************************************************************/ + static QTableMetaData withSections(QTableMetaData table) + { + List layout = switch(table.getName()) + { + case PLAN -> List.of( + new Object[] { "plan", "Plan", "backup", Tier.T1, "name", "description", "planKey", "isActive", "scopeType", "slaStatus", "lastSuccessDate", "lastVerifiedDate" }, + new Object[] { "schedule", "Schedule and SLA", "schedule", Tier.T2, "cronExpression", "cronTimeZoneId", "scheduledJobId", "slaHours", "verifySlaDays", "verifyCronExpression", "verifyLevel" }, + new Object[] { "where", "Scope and storage", "storage", Tier.T2, "includeRoles", "storageBucketAlias", "pruneBucketAlias", "prefix", "critical", "consistency", "useReadReplica", "incrementalMode", "nativeDump" }, + new Object[] { "format", "Format and safety", "shield", Tier.T2, "format", "compression", "encryptionMode", "encryptionKeyRef", "sensitiveFieldPolicy", "heavyFieldPolicy", "fileObjectPolicy", "fileMissingPolicy" }, + new Object[] { "retention", "Retention and lock", "lock_clock", Tier.T2, "retainDaily", "retainWeekly", "retainMonthly", "retainYearly", "retainMinimum", "objectLockMode", "objectLockDays" }, + new Object[] { "system", "Limits and history", "history", Tier.T3, "maxDurationMinutes", "pageSize", "targetPartBytes", "ownerUserId", "lastRunId", "lastSuccessRunId", "lastVerifiedRunId", "id", "createDate", "modifyDate" }); + case RUN -> List.of( + new Object[] { "run", "Run", "history", Tier.T1, "runKey", "backupPlanId", "status", "trigger", "startDate", "endDate", "durationMs", "errorMessage" }, + new Object[] { "volume", "Rows and size", "data_usage", Tier.T2, "tablesPlanned", "tablesDone", "rowsTotal", "bytesRaw", "bytesStored", "consistencyAchieved", "warningCount" }, + new Object[] { "files", "Files", "attach_file", Tier.T2, "filesReferenced", "filesCopied", "filesDeduplicated", "filesMissing", "fileBytesCopied", "fileBytesReferenced" }, + new Object[] { "storage", "Storage and integrity", "storage", Tier.T2, "storageBucketAlias", "runPrefix", "manifestKey", "manifestSha256", "snapshotId", "schemaFingerprint", "securityScope", "encryptionKeyRef", "encryptionKeyFingerprint", "verificationStatus" }, + new Object[] { "retention", "Retention", "lock_clock", Tier.T2, "retentionClass", "lockedUntil", "prunedDate", "prunedByUserId" }, + new Object[] { "system", "System", "settings", Tier.T3, "requestedByUserId", "heartbeatDate", "qqqVersion", "appVersion", "instanceName", "errorDetail", "id", "createDate", "modifyDate" }); + case VERIFICATION -> List.of( + new Object[] { "result", "Result", "verified", Tier.T1, "backupRunId", "level", "status", "tablesChecked", "rowsChecked", "mismatches", "scratchTarget", "startDate", "endDate" }, + new Object[] { "system", "Detail", "settings", Tier.T3, "trigger", "requestedByUserId", "detailJson", "id", "createDate", "modifyDate" }); + case RESTORE_RUN -> List.of( + new Object[] { "restore", "Restore", "restore", Tier.T1, "sourceBackupRunId", "status", "mode", "idStrategy", "matchOn", "targetInstanceName", "startDate", "endDate", "errorMessage" }, + new Object[] { "rows", "Rows", "table_rows", Tier.T2, "rowsInserted", "rowsUpdated", "rowsUnchanged", "rowsDeleted", "rowsSkipped", "rowsErrored", "sequencesResynced" }, + new Object[] { "files", "Files", "attach_file", Tier.T2, "filesRestored", "filesAlreadyPresent", "filesConflicting", "filesMissingFromBackup", "fileBytesRestored" }, + new Object[] { "system", "Safety and system", "settings", Tier.T3, "preRestoreBackupRunId", "scopeSummary", "automationsSuppressed", "esbSuppressed", "confirmationText", "requestedByUserId", "id", "createDate", "modifyDate" }); + default -> List.of(); + }; + if(layout.isEmpty()) + { + return (table); + } + java.util.Set placed = new java.util.HashSet<>(); + for(int i = 0; i < layout.size(); i++) + { + Object[] spec = layout.get(i); + List fields = new ArrayList<>(); + for(int f = 4; f < spec.length; f++) + { + fields.add((String) spec[f]); + } + if(i == layout.size() - 1) + { + table.getFields().keySet().stream().filter(name -> !placed.contains(name) && !fields.contains(name)).forEach(fields::add); + } + placed.addAll(fields); + table.withSection(new QFieldSection((String) spec[0], (String) spec[1], new QIcon((String) spec[2]), (Tier) spec[3], fields)); + } + for(ChildList childList : CHILD_LISTS) + { + if(childList.parent().equals(table.getName())) + { + table.withSection(new QFieldSection(childList.key(), new QIcon(childList.icon()), Tier.T2).withLabel(childList.label()).withWidgetName(childList.joinName())); + } + } + return (table); + } + + + + /** A child list on a parent record: its join, the columns shown, and the order. */ + record ChildList(String parent, String joinName, String key, String label, String icon, int maxRows, boolean canAdd, String orderField, List columns) + { + } + + /** The relations shown on the record screens (sub-tables are child lists here, not in the nav). */ + static final List CHILD_LISTS = List.of( + new ChildList(PLAN, "backupsPlanScopeJoinPlan", "scopeRules", "Scope rules", "rule", 50, true, "id", List.of("action", "selectorType", "selectorValue", "includeAssociatedChildren", "fieldExclusions")), + new ChildList(PLAN, "backupsRunJoinPlan", "runs", "Runs", "history", 10, false, "id", List.of("runKey", "status", "startDate", "durationMs", "rowsTotal", "bytesStored")), + new ChildList(RUN, "backupsArtifactJoinRun", "artifacts", "Artifacts", "inventory_2", 100, false, "id", List.of("tableName", "artifactType", "partNumber", "rowCount", "bytesStored", "status")), + new ChildList(RUN, "backupsVerificationJoinRun", "verifications", "Verifications", "verified", 20, false, "id", List.of("level", "status", "tablesChecked", "rowsChecked", "mismatches", "startDate")), + new ChildList(RUN, "backupsRestoreRunJoinRun", "restores", "Restores", "restore", 20, false, "id", List.of("status", "mode", "startDate", "rowsInserted", "rowsUpdated", "rowsErrored")), + new ChildList(RESTORE_RUN, "backupsRestoreRunTableJoinRestoreRun", "restoreTables", "Tables restored", "table_rows", 100, false, "id", + List.of("tableName", "targetTableName", "rowsInserted", "rowsUpdated", "rowsUnchanged", "rowsErrored", "schemaStatus"))); + + + + /******************************************************************************* + ** The child-list widgets for the record screens (see CHILD_LISTS). Each shows a + ** few readable columns, newest first, and never scrolls sideways. + *******************************************************************************/ + public static List widgets() + { + Map byName = new LinkedHashMap<>(); + joins().forEach(join -> byName.put(join.getName(), join)); + List rs = new ArrayList<>(); + for(ChildList childList : CHILD_LISTS) + { + rs.add(ChildRecordListRenderer.widgetMetaDataBuilder(byName.get(childList.joinName())).withLabel(childList.label()).withMaxRows(childList.maxRows()) + .withCanAddChildRecord(childList.canAdd()).withOnlyIncludeFieldNames(childList.columns()) + .withOrderBys(List.of(new QFilterOrderBy(childList.orderField(), !PLAN.equals(childList.parent()) || childList.canAdd()))) + .getWidgetMetaData().withPermissionRules(permissionRules())); + } + return (rs); } @@ -404,8 +513,6 @@ public final class BackupsTables return (base(config, RUN, "Backup Run", "history") .withRecordLabelFormatAndFields("%s", "runKey") .withUniqueKey(new UniqueKey("runKey")) - .withAssociation(new Association().withName("artifacts").withAssociatedTableName(ARTIFACT).withJoinName("backupsArtifactJoinRun")) - .withAssociation(new Association().withName("verifications").withAssociatedTableName(VERIFICATION).withJoinName("backupsVerificationJoinRun")) .withField(ro("backupPlanId", QFieldType.INTEGER).withPossibleValueSourceName(PLAN)) .withField(ro("runKey", QFieldType.STRING)) .withField(roEnum("trigger", "run_trigger")) @@ -511,7 +618,6 @@ public final class BackupsTables { return (base(config, RESTORE_RUN, "Backup Restore", "restore") .withRecordLabelFormatAndFields("Restore %s", "id") - .withAssociation(new Association().withName("tables").withAssociatedTableName(RESTORE_RUN_TABLE).withJoinName("backupsRestoreRunTableJoinRestoreRun")) .withField(ro("sourceBackupRunId", QFieldType.INTEGER).withPossibleValueSourceName(RUN)) .withField(ro("requestedByUserId", QFieldType.STRING)) .withField(ro("startDate", QFieldType.DATE_TIME)) diff --git a/src/test/java/com/kingsrook/qbits/backups/app/BackupsHomeTest.java b/src/test/java/com/kingsrook/qbits/backups/app/BackupsHomeTest.java new file mode 100644 index 0000000..23a9d56 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/backups/app/BackupsHomeTest.java @@ -0,0 +1,178 @@ +/* + * Copyright 2026 Kingsrook, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.backups.app; + + +import java.io.Serializable; +import java.time.Duration; +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import com.kingsrook.qbits.backups.BackupsRuntime; +import com.kingsrook.qbits.backups.BackupsTestUtils; +import com.kingsrook.qbits.backups.model.BackupsTables; +import com.kingsrook.qbits.backups.run.BackupCatalog; +import com.kingsrook.qbits.backups.run.BackupEngine; +import com.kingsrook.qbits.backups.run.BackupRunFactory; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppChildMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; + + +/******************************************************************************* + ** The Backups home: null-safe against plans that have no SLA status, honest empty + ** states, working links, and the nav and relations shape. + *******************************************************************************/ +class BackupsHomeTest +{ + private QInstance instance; + + + + /******************************************************************************* + ** + *******************************************************************************/ + @BeforeEach + void setUp() throws Exception + { + instance = BackupsTestUtils.init(); + BackupsTestUtils.loadBlog(5); + } + + + + /******************************************************************************* + ** A plan with a null slaStatus (a fresh plan nobody has run) used to throw an NPE. + *******************************************************************************/ + @Test + void testPlanWithoutSlaStatusDoesNotThrow() throws Exception + { + new BackupCatalog().insert(BackupsTables.PLAN, new LinkedHashMap<>(Map.of("name", "Fresh", "planKey", "fresh", "isActive", true, "scopeType", "INSTANCE", "storageBucketAlias", "backups"))); + + BackupsHomeData data = BackupsHomeData.load(); + assertThat(data.plansNeedingAttention()).isEmpty(); + assertThat(data.overallState()).isEqualTo("OK"); + assertThat(data.latestTestRestore(null)).isNull(); + assertThat(data.successfulRuns(null)).isEmpty(); + assertThat(data.retentionCounts(null)).isEmpty(); + + String html = BackupsHomeView.render(data, "/app"); + assertThat(html).contains("Fresh").contains("No successful backup yet").contains("Not run yet").contains("Unknown"); + } + + + + /******************************************************************************* + ** Empty states are honest, and the whole home renders with no data at all. + *******************************************************************************/ + @Test + void testEmptyStates() + { + String html = BackupsHomeView.render(new BackupsHomeData(List.of(), List.of(), List.of(), List.of(), List.of(), Instant.now()), "/app"); + assertThat(html).contains("No backup plans yet").contains("Nothing to check yet").contains("No test restore yet") + .contains("Nothing has run yet").contains("Trends appear after the first successful run").contains("Retention starts once runs exist"); + assertThat(BackupsHomeView.renderHealthCard(new BackupsHomeData(List.of(), List.of(), List.of(), List.of(), List.of(), Instant.now()), "/app")).contains("No backup plans yet"); + } + + + + /******************************************************************************* + ** After a real run the home links to records under the table path prefix, and + ** a plan whose last success is older than its SLA shows as late. + *******************************************************************************/ + @Test + void testRealRunLinksAndLateness() throws Exception + { + BackupCatalog catalog = new BackupCatalog(); + Map values = new LinkedHashMap<>(Map.of("name", "Nightly", "planKey", "nightly", "isActive", true, "scopeType", "INSTANCE", "storageBucketAlias", "backups", "slaHours", 26)); + Integer planId = catalog.insert(BackupsTables.PLAN, values); + new BackupEngine().execute(BackupRunFactory.forPlan(catalog.get(BackupsTables.PLAN, planId).orElseThrow(), "MANUAL")); + + BackupsHomeData fresh = BackupsHomeData.load(); + assertThat(fresh.plansNeedingAttention()).isEmpty(); + String html = BackupsHomeView.render(fresh, "/app"); + assertThat(html).contains("href=\"/app/backups_plan/" + planId + "\"").contains("href=\"/app/backups_run/1\"").contains("Succeeded with warnings"); + + BackupsHomeData later = new BackupsHomeData(fresh.plans(), fresh.recentRuns(), List.of(), List.of(), List.of(), fresh.now().plus(Duration.ofHours(30))); + assertThat(later.effectiveStatus(later.plans().get(0))).isEqualTo("LATE"); + assertThat(later.plansNeedingAttention()).hasSize(1); + assertThat(BackupsHomeView.render(later, "/app")).contains("Needs attention").contains("Late"); + assertThat(later.overallState()).isEqualTo("ATTENTION"); + } + + + + /******************************************************************************* + ** The links follow the configured table path prefix. + *******************************************************************************/ + @Test + void testPathPrefixFollowsConfig() + { + assertThat(BackupsApp.pathPrefix()).isEqualTo("/app"); + BackupsRuntime.getConfig().withTablePathPrefix("/backups"); + assertThat(BackupsApp.pathPrefix()).isEqualTo("/backups"); + } + + + + /******************************************************************************* + ** The nav lists only the main tables; sub-tables are child lists on their + ** parents; the one home widget is on the app. + *******************************************************************************/ + @Test + void testNavAndRelations() + { + QAppMetaData app = instance.getApp("backups"); + assertThat(app.getChildren().stream().map(QAppChildMetaData::getName)).containsExactlyInAnyOrder( + BackupsTables.PLAN, BackupsTables.RUN, BackupsTables.RESTORE_RUN, BackupsTables.VERIFICATION); + assertThat(app.getWidgets()).containsExactly(BackupsApp.HOME_WIDGET); + + for(String child : List.of("artifacts", "verifications", "restores")) + { + assertThat(instance.getTable(BackupsTables.RUN).getSections().stream().map(s -> s.getName())).contains(child); + } + assertThat(instance.getTable(BackupsTables.PLAN).getSections().stream().map(s -> s.getName())).contains("scopeRules", "runs"); + assertThat(instance.getTable(BackupsTables.RESTORE_RUN).getSections().stream().map(s -> s.getName())).contains("restoreTables"); + assertThat(QContext.getQInstance().getWidget("backupsArtifactJoinRun")).isNotNull(); + } + + + + /******************************************************************************* + ** A parent app lists the Backups app as its child. + *******************************************************************************/ + @Test + void testAttachesToParentApp() throws Exception + { + QInstance withParent = new QInstance(); + withParent.withInstanceDefaultAuthentication(instance.getAuthentication()); + withParent.addBackend(instance.getBackend(BackupsTestUtils.BACKEND)); + withParent.addApp(new QAppMetaData().withName("admin").withLabel("Admin")); + new com.kingsrook.qbits.backups.BackupsQBitProducer().withNamespace("backups") + .withQBitConfig(new com.kingsrook.qbits.backups.BackupsQBitConfig().withBackendName(BackupsTestUtils.BACKEND).withParentAppName("admin") + .withStorageResolver(alias -> BackupsTestUtils.BACKUPS)) + .produce(withParent); + + assertThat(withParent.getApp("admin").getChildren().stream().map(QAppChildMetaData::getName)).contains("backups"); + assertThat(withParent.getApp("backups").getParentAppName()).isEqualTo("admin"); + } +} diff --git a/src/test/java/com/kingsrook/qbits/backups/demo/BackupsDemoServer.java b/src/test/java/com/kingsrook/qbits/backups/demo/BackupsDemoServer.java new file mode 100644 index 0000000..65b68dc --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/backups/demo/BackupsDemoServer.java @@ -0,0 +1,346 @@ +/* + * Copyright 2026 Kingsrook, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.backups.demo; + + +import java.io.Serializable; +import java.math.BigDecimal; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; +import java.time.temporal.ChronoUnit; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import com.kingsrook.qbits.backups.BackupsQBitConfig; +import com.kingsrook.qbits.backups.BackupsQBitProducer; +import com.kingsrook.qbits.backups.BackupsRuntime; +import com.kingsrook.qbits.backups.model.BackupsTables; +import com.kingsrook.qbits.backups.run.BackupCatalog; +import com.kingsrook.qbits.backups.run.BackupEngine; +import com.kingsrook.qbits.backups.run.BackupRunFactory; +import com.kingsrook.qbits.backups.scope.BackupTableMetaData; +import com.kingsrook.qbits.backups.storage.BackupStorage; +import com.kingsrook.qbits.backups.storage.InMemoryBackupStorage; +import com.kingsrook.qqq.backend.core.actions.tables.InsertAction; +import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.instances.AbstractQQQApplication; +import com.kingsrook.qqq.backend.core.instances.QInstanceValidator; +import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType; +import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType; +import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon; +import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; +import com.kingsrook.qqq.backend.core.model.session.QSession; +import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryBackendModule; +import com.kingsrook.qqq.middleware.javalin.QApplicationJavalinServer; + + +/******************************************************************************* + ** A local demo (for screenshots and manual checks, never shipped): the memory + ** backend, a small blog (authors, posts with media files), the Backups qbit under + ** an Admin app with in-memory storage, and a fortnight of history made by running + ** the real backup engine against a moving clock (including one run that fails + ** because the storage refused writes). + ** + **
    + ** ./mvnw -o test-compile exec:java -Dexec.classpathScope=test \
    + **    -Dexec.mainClass=com.kingsrook.qbits.backups.demo.BackupsDemoServer -Dexec.args=8098
    + ** 
    + *******************************************************************************/ +public class BackupsDemoServer extends AbstractQQQApplication +{ + public static final String BACKEND = "memory"; + + static final InMemoryBackupStorage BACKUPS = new InMemoryBackupStorage(); + static final InMemoryBackupStorage MEDIA = new InMemoryBackupStorage(); + static final AtomicBoolean STORAGE_DOWN = new AtomicBoolean(false); + static final DemoClock CLOCK = new DemoClock(); + + + + /******************************************************************************* + ** A clock the seeder moves: the real time shifted by an offset, so a run still + ** takes the real time it takes (durations are real) but happens "in the past". + *******************************************************************************/ + static class DemoClock extends Clock + { + private final AtomicReference offset = new AtomicReference<>(Duration.ZERO); + + + + /******************************************************************************* + ** Set the time. + *******************************************************************************/ + void set(Instant instant) + { + offset.set(Duration.between(Instant.now(), instant)); + } + + + + /******************************************************************************* + ** Zone. + *******************************************************************************/ + @Override + public ZoneId getZone() + { + return (ZoneId.of("UTC")); + } + + + + /******************************************************************************* + ** Zone. + *******************************************************************************/ + @Override + public Clock withZone(ZoneId zone) + { + return (this); + } + + + + /******************************************************************************* + ** Now. + *******************************************************************************/ + @Override + public Instant instant() + { + return (Instant.now().plus(offset.get())); + } + } + + + + /******************************************************************************* + ** The instance: a blog, an Admin app and the Backups qbit under it. + *******************************************************************************/ + @Override + public QInstance defineQInstance() throws QException + { + QInstance instance = new QInstance(); + instance.withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("anonymous").withType(QAuthenticationType.FULLY_ANONYMOUS)); + instance.addBackend(new QBackendMetaData().withName(BACKEND).withBackendType(MemoryBackendModule.class)); + + QTableMetaData author = new QTableMetaData().withName("author").withLabel("Author").withBackendName(BACKEND).withPrimaryKeyField("id") + .withRecordLabelFormatAndFields("%s", "name") + .withField(new QFieldMetaData("id", QFieldType.INTEGER)) + .withField(new QFieldMetaData("name", QFieldType.STRING)) + .withField(new QFieldMetaData("bio", QFieldType.TEXT)); + QTableMetaData post = new QTableMetaData().withName("blogPost").withLabel("Blog Post").withBackendName(BACKEND).withPrimaryKeyField("id") + .withRecordLabelFormatAndFields("%s", "title") + .withField(new QFieldMetaData("id", QFieldType.INTEGER)) + .withField(new QFieldMetaData("authorId", QFieldType.INTEGER).withPossibleValueSourceName("author")) + .withField(new QFieldMetaData("title", QFieldType.STRING)) + .withField(new QFieldMetaData("body", QFieldType.TEXT)) + .withField(new QFieldMetaData("price", QFieldType.DECIMAL)) + .withField(new QFieldMetaData("published", QFieldType.BOOLEAN)) + .withField(new QFieldMetaData("publishedOn", QFieldType.DATE)) + .withField(new QFieldMetaData("createDate", QFieldType.DATE_TIME)) + .withField(new QFieldMetaData("mediaKey", QFieldType.STRING)) + .withField(new QFieldMetaData("secretNote", QFieldType.STRING).withIsSensitive(true)) + .withSupplementalMetaData(new BackupTableMetaData().withFileReferenceFields(List.of("mediaKey")).withFileBucketAlias("media")); + instance.addTable(author); + instance.addTable(post); + instance.addPossibleValueSource(QPossibleValueSource.newForTable("author")); + instance.addApp(new QAppMetaData().withName("blog").withLabel("Blog").withIcon(new QIcon("article")).withChild(author).withChild(post)); + instance.addApp(new QAppMetaData().withName("admin").withLabel("Admin").withIcon(new QIcon("admin_panel_settings"))); + + Map buckets = Map.of("backups", BACKUPS, "media", MEDIA); + BackupsQBitConfig config = new BackupsQBitConfig().withBackendName(BACKEND).withInstanceName("blog-demo").withParentAppName("admin") + .withStorageResolver(buckets::get).withOpenWhenAnonymous(true); + new BackupsQBitProducer().withQBitConfig(config).withNamespace("backups").produce(instance); + instance.setHomeAppName("backups"); + return (instance); + } + + + + /******************************************************************************* + ** Insert one record. + *******************************************************************************/ + private static void insert(String table, Map values) throws QException + { + new InsertAction().execute(new InsertInput(table).withRecord(record(values))); + } + + + + /******************************************************************************* + ** A record of values. + *******************************************************************************/ + private static QRecord record(Map values) + { + QRecord record = new QRecord(); + record.setValues(new LinkedHashMap<>(values)); + return (record); + } + + + + /******************************************************************************* + ** Add blog posts from first to last. + *******************************************************************************/ + private static void addPosts(int first, int last, Instant when) throws QException + { + for(int i = first; i <= last; i++) + { + Map row = new LinkedHashMap<>(); + row.put("id", i); + row.put("authorId", 1 + (i % 3)); + row.put("title", "Post " + i + ": notes on " + List.of("storage", "backups", "restores", "retention", "checksums", "scheduling").get(i % 6)); + row.put("body", ("Body of post " + i + ". Plain words about keeping data safe, repeated so it has some size. ").repeat(12)); + row.put("price", new BigDecimal("12.3400").add(BigDecimal.valueOf(i))); + row.put("published", i % 4 != 0); + row.put("publishedOn", java.time.LocalDate.of(2026, 1, 1).plusDays(i % 280)); + row.put("createDate", when); + row.put("secretNote", "internal-note-" + i); + if(i % 3 != 0) + { + String key = "media/photo-" + (i % 9) + ".jpg"; + row.put("mediaKey", key); + if(MEDIA.getBytes(key) == null) + { + MEDIA.putBytes(key, ("photo bytes " + (i % 9) + " ").repeat(2000).getBytes()); + } + } + insert("blogPost", row); + } + } + + + + /******************************************************************************* + ** Create a plan with the given values; returns the record. + *******************************************************************************/ + private static QRecord plan(Map overrides, List scope) throws Exception + { + Map values = new LinkedHashMap<>(Map.of("isActive", true, "storageBucketAlias", "backups")); + values.putAll(overrides); + BackupCatalog catalog = new BackupCatalog(); + Integer id = catalog.insert(BackupsTables.PLAN, values); + for(String[] rule : scope) + { + catalog.insert(BackupsTables.PLAN_SCOPE, new LinkedHashMap<>(Map.of("backupPlanId", id, "action", "INCLUDE", "selectorType", rule[0], "selectorValue", rule[1]))); + } + return (catalog.get(BackupsTables.PLAN, id).orElseThrow()); + } + + + + /******************************************************************************* + ** Run a plan with the real engine, as it is right now (the clock decides when). + *******************************************************************************/ + private static void run(Integer planId, String trigger) throws Exception + { + QRecord plan = new BackupCatalog().get(BackupsTables.PLAN, planId).orElseThrow(); + new BackupEngine().execute(BackupRunFactory.forPlan(plan, trigger)); + } + + + + /******************************************************************************* + ** Seed: a fortnight of nightly runs, a weekly content plan, and a plan whose + ** latest run fails because the storage refused writes. + *******************************************************************************/ + static void seed(QInstance instance) throws Exception + { + QContext.init(instance, new QSession()); + BackupsRuntime.setClock(CLOCK); + Instant today = Instant.now().truncatedTo(ChronoUnit.HOURS); + + CLOCK.set(today.minus(Duration.ofDays(15))); + insert("author", Map.of("id", 1, "name", "Ada", "bio", "Writes about storage.")); + insert("author", Map.of("id", 2, "name", "Grace", "bio", "Writes about compilers and restores.")); + insert("author", Map.of("id", 3, "name", "Linus", "bio", "")); + addPosts(1, 240, CLOCK.instant()); + + QRecord nightly = plan(Map.of("name", "Nightly full backup", "planKey", "nightly", "scopeType", "INSTANCE", "slaHours", 26, "verifySlaDays", 8, + "cronExpression", "0 0 2 * * ?", "cronTimeZoneId", "UTC", "retainDaily", 7, "retainWeekly", 4, "description", "Everything, every night, kept 7 daily and 4 weekly."), List.of()); + QRecord content = plan(Map.of("name", "Blog content weekly", "planKey", "blog-weekly", "scopeType", "TABLES", "slaHours", 170, "verifySlaDays", 14, + "cronExpression", "0 0 3 ? * SUN", "cronTimeZoneId", "UTC", "description", "Authors and posts only, every Sunday."), + List.of(new String[] { "TABLE", "author" }, new String[] { "TABLE", "blogPost" })); + QRecord archive = plan(Map.of("name", "Posts to cold storage", "planKey", "posts-cold", "scopeType", "TABLES", "slaHours", 26, "format", "CSV", + "description", "Posts as CSV for the archive bucket."), List.of(new String[] { "TABLE", "blogPost" })); + + int post = 240; + for(int day = 14; day >= 0; day--) + { + CLOCK.set(today.minus(Duration.ofDays(day)).minus(Duration.ofHours(today.atZone(ZoneId.of("UTC")).getHour())).plus(Duration.ofHours(2))); + if(day > 0) + { + addPosts(post + 1, post + 4, CLOCK.instant()); + post += 4; + } + run(nightly.getValueInteger("id"), "SCHEDULED"); + if(day % 7 == 0) + { + CLOCK.set(CLOCK.instant().plus(Duration.ofHours(1))); + run(content.getValueInteger("id"), "SCHEDULED"); + } + if(day == 1) + { + run(archive.getValueInteger("id"), "MANUAL"); + } + } + + // One run that fails for real: the archive bucket refuses writes. + CLOCK.set(today.minus(Duration.ofHours(3))); + STORAGE_DOWN.set(true); + run(archive.getValueInteger("id"), "MANUAL"); + STORAGE_DOWN.set(false); + + // An edit after the last run, so a restore would have something to do. + new UpdateAction().execute(new UpdateInput("blogPost").withRecord(new QRecord().withValue("id", 1).withValue("title", "Post 1: edited after the last backup"))); + CLOCK.set(Instant.now()); + BackupsRuntime.setClock(Clock.systemUTC()); + QContext.clear(); + } + + + + /******************************************************************************* + ** Run. + *******************************************************************************/ + public static void main(String[] args) throws Exception + { + int port = args.length > 0 ? Integer.parseInt(args[0]) : 8098; + BACKUPS.withClock(CLOCK).withFailWritesFor(key -> STORAGE_DOWN.get()); + + BackupsDemoServer application = new BackupsDemoServer(); + QInstance seedInstance = application.defineQInstance(); + new QInstanceValidator().validate(seedInstance); + seed(seedInstance); + + new QApplicationJavalinServer(application).withPort(port).withServeFrontendNext(true).start(); + System.out.println("Backups demo on http://localhost:" + port + "/app/admin/backups"); + } +}