Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
664f467d11 | ||
|
|
4fb182e824 | ||
|
|
1de38f03ae
|
||
|
|
cf2d027f9a | ||
|
|
714ef906fe
|
||
|
|
26c174f7bf
|
||
|
|
284f0cec8c |
No files matched your search
@@ -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.
|
||||
|
After Width: | Height: | Size: 163 KiB |
|
After Width: | Height: | Size: 160 KiB |
|
After Width: | Height: | Size: 165 KiB |
|
After Width: | Height: | Size: 138 KiB |
|
After Width: | Height: | Size: 143 KiB |
|
After Width: | Height: | Size: 167 KiB |
|
After Width: | Height: | Size: 163 KiB |
|
After Width: | Height: | Size: 161 KiB |
|
After Width: | Height: | Size: 166 KiB |
|
After Width: | Height: | Size: 138 KiB |
|
After Width: | Height: | Size: 144 KiB |
|
After Width: | Height: | Size: 168 KiB |
|
After Width: | Height: | Size: 170 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 146 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 147 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 171 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 161 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 140 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 141 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 163 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 134 KiB |
|
After Width: | Height: | Size: 221 KiB |
|
After Width: | Height: | Size: 135 KiB |
|
After Width: | Height: | Size: 216 KiB |
|
After Width: | Height: | Size: 171 KiB |
|
After Width: | Height: | Size: 166 KiB |
|
After Width: | Height: | Size: 169 KiB |
|
After Width: | Height: | Size: 144 KiB |
|
After Width: | Height: | Size: 151 KiB |
|
After Width: | Height: | Size: 176 KiB |
|
After Width: | Height: | Size: 172 KiB |
|
After Width: | Height: | Size: 167 KiB |
|
After Width: | Height: | Size: 170 KiB |
|
After Width: | Height: | Size: 144 KiB |
|
After Width: | Height: | Size: 151 KiB |
|
After Width: | Height: | Size: 177 KiB |
|
After Width: | Height: | Size: 177 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 178 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 169 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 171 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 66 KiB |
@@ -91,7 +91,7 @@
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
<artifactId>qbit-object-storage</artifactId>
|
||||
<version>0.1.0-SNAPSHOT</version>
|
||||
<version>0.1.0-RC.2</version>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
|
||||
@@ -102,6 +102,19 @@
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- Demo server only (src/test/.../demo): never shipped. -->
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-middleware-javalin</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-frontend-next</artifactId>
|
||||
<version>1.0.0-RC.14</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
|
||||
@@ -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)
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -158,8 +158,20 @@ public class BackupsQBitProducer implements QBitMetaDataProducer<BackupsQBitConf
|
||||
{
|
||||
output.add(widget);
|
||||
}
|
||||
output.add(com.kingsrook.qbits.backups.app.BackupsApp.app(qBitConfig, tables));
|
||||
for(var widget : com.kingsrook.qbits.backups.model.BackupsTables.widgets())
|
||||
{
|
||||
output.add(widget);
|
||||
}
|
||||
com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData app = com.kingsrook.qbits.backups.app.BackupsApp.app(qBitConfig, tables);
|
||||
output.add(app);
|
||||
output.addSelfToInstance(qInstance);
|
||||
|
||||
// A parent app only lists the apps that are its children, so attach ourselves (for example Admin, then Backups).
|
||||
com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData parent = qBitConfig.getParentAppName() == null ? null : qInstance.getApp(qBitConfig.getParentAppName());
|
||||
if(parent != null && (parent.getChildren() == null || parent.getChildren().stream().noneMatch(child -> app.getName().equals(child.getName()))))
|
||||
{
|
||||
parent.withChild(app);
|
||||
}
|
||||
return (new com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput());
|
||||
}
|
||||
|
||||
|
||||
@@ -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<QWidgetMetaData> 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());
|
||||
}
|
||||
}
|
||||
@@ -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<QRecord> 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<QRecord> successfulRuns(Integer planId)
|
||||
{
|
||||
List<QRecord> 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<String, Integer> retentionCounts(Integer planId)
|
||||
{
|
||||
Map<String, Integer> 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")
|
||||
|
||||
@@ -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("<div class=\"backups-home\" style=\"" + GRID + "\">");
|
||||
StringBuilder html = new StringBuilder("<div class=\"backups-home\" style=\"" + ROOT + "\">");
|
||||
planStatus(html, data, pathPrefix);
|
||||
html.append("<div style=\"").append(GRID).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("</div>").toString());
|
||||
return (html.append("</div></div>").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 ("<div style=\"" + CARD + "\"><strong>Backups</strong><p>" + esc(text) + "</p><a href=\"" + esc(pathPrefix) + "\">Open the Backups home</a></div>");
|
||||
return ("<div style=\"" + CARD + "\"><strong>Backups</strong><p>" + esc(text) + "</p><a style=\"" + LINK + "\" href=\"" + esc(pathPrefix) + "\">Open the Backups home</a></div>");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 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("<dl style=\"").append(LIST).append("\">");
|
||||
html.append("<dt><strong>Plan</strong></dt><dd><a href=\"").append(esc(prefix + "/" + BackupsTables.PLAN + "/" + plan.getValue("id"))).append("\">")
|
||||
.append(esc(plan.getValueString("name"))).append("</a>").append(Boolean.TRUE.equals(plan.getValueBoolean("isActive")) ? "" : " (not active)").append("</dd>");
|
||||
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("<div style=\"").append(ROW).append("\"><div style=\"display:flex;flex-wrap:wrap;align-items:center;gap:.5rem .75rem;\">");
|
||||
html.append("<strong>").append(recordLink(prefix, BackupsTables.PLAN, plan.getValue("id"), plan.getValueString("name"))).append("</strong>");
|
||||
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("<span style=\"").append(MUTED).append("\">Not active</span>");
|
||||
}
|
||||
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("</div><div style=\"").append(CELLS).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("</dl>");
|
||||
html.append("</div></div>");
|
||||
}
|
||||
link(html, prefix, BackupsTables.PLAN, "Backup plans");
|
||||
close(html);
|
||||
@@ -144,13 +159,13 @@ public final class BackupsHomeView
|
||||
html.append("<ul style=\"").append(ITEMS).append("\">");
|
||||
for(QRecord plan : plans)
|
||||
{
|
||||
html.append("<li><a href=\"").append(esc(prefix + "/" + BackupsTables.PLAN + "/" + plan.getValue("id"))).append("\">").append(esc(plan.getValueString("name")))
|
||||
.append("</a>: ").append(esc(label(plan.getValueString("slaStatus")))).append("</li>");
|
||||
html.append("<li>").append(recordLink(prefix, BackupsTables.PLAN, plan.getValue("id"), plan.getValueString("name")))
|
||||
.append(": ").append(esc(label(data.effectiveStatus(plan)))).append("</li>");
|
||||
}
|
||||
for(QRecord run : data.failedRuns())
|
||||
{
|
||||
html.append("<li><a href=\"").append(esc(prefix + "/" + BackupsTables.RUN + "/" + run.getValue("id"))).append("\">Run ").append(esc(run.getValueString("runKey")))
|
||||
.append("</a> ").append(esc(label(run.getValueString("status")))).append("</li>");
|
||||
html.append("<li>Run ").append(recordLink(prefix, BackupsTables.RUN, run.getValue("id"), run.getValueString("runKey")))
|
||||
.append(": ").append(esc(label(run.getValueString("status")))).append("</li>");
|
||||
}
|
||||
html.append("</ul>");
|
||||
}
|
||||
@@ -178,7 +193,8 @@ public final class BackupsHomeView
|
||||
html.append("<dl style=\"").append(LIST).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("</dl>");
|
||||
}
|
||||
@@ -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("<div style=\"margin:0 0 .75rem;\"><strong style=\"font-size:.9rem;\">").append(esc(plan.getValueString("name"))).append("</strong>");
|
||||
bars(html, runs);
|
||||
html.append("<dl style=\"").append(LIST).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("</dl>");
|
||||
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("</dl></div>");
|
||||
}
|
||||
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<QRecord> runs)
|
||||
{
|
||||
List<QRecord> 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("<div role=\"img\" aria-label=\"Stored size of the last ").append(shown.size()).append(" successful runs\" style=\"display:flex;align-items:flex-end;gap:2px;height:2.5rem;margin:.4rem 0 .6rem;\">");
|
||||
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("<span title=\"").append(esc(title)).append("\" style=\"flex:1 1 0;min-width:2px;max-width:1.5rem;height:").append(pct).append("%;background:currentColor;opacity:.55;border-radius:2px 2px 0 0;\"></span>");
|
||||
}
|
||||
html.append("</div>");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 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("<li>Backup <a href=\"").append(esc(prefix + "/" + BackupsTables.RUN + "/" + run.getValue("id"))).append("\">").append(esc(run.getValueString("runKey")))
|
||||
.append("</a>: ").append(esc(label(run.getValueString("status")))).append(start == null ? "" : ", " + WHEN.format(start)).append("</li>");
|
||||
html.append("<li>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("</li>");
|
||||
});
|
||||
data.restores().stream().limit(2).forEach(restore -> html.append("<li>Restore <a href=\"").append(esc(prefix + "/" + BackupsTables.RESTORE_RUN + "/" + restore.getValue("id")))
|
||||
.append("\">#").append(esc(String.valueOf(restore.getValue("id")))).append("</a>: ").append(esc(label(restore.getValueString("status")))).append("</li>"));
|
||||
data.verifications().stream().limit(2).forEach(v -> html.append("<li>Verify <a href=\"").append(esc(prefix + "/" + BackupsTables.VERIFICATION + "/" + v.getValue("id")))
|
||||
.append("\">").append(esc(label(v.getValueString("level")))).append("</a>: ").append(esc(label(v.getValueString("status")))).append("</li>"));
|
||||
data.restores().stream().limit(2).forEach(restore -> html.append("<li>Restore ").append(recordLink(prefix, BackupsTables.RESTORE_RUN, restore.getValue("id"), "#" + restore.getValue("id")))
|
||||
.append(": ").append(esc(label(restore.getValueString("status")))).append("</li>"));
|
||||
data.verifications().stream().limit(2).forEach(v -> html.append("<li>Verify ").append(recordLink(prefix, BackupsTables.VERIFICATION, v.getValue("id"), label(v.getValueString("level"))))
|
||||
.append(": ").append(esc(label(v.getValueString("status")))).append("</li>"));
|
||||
html.append("</ul>");
|
||||
}
|
||||
link(html, prefix, BackupsTables.RUN, "All runs");
|
||||
@@ -274,9 +316,10 @@ public final class BackupsHomeView
|
||||
any = true;
|
||||
html.append("<dl style=\"").append(LIST).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<QRecord> 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<QRecord> 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("</dl>");
|
||||
}
|
||||
@@ -290,17 +333,17 @@ public final class BackupsHomeView
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
** Open a card.
|
||||
*******************************************************************************/
|
||||
private static void open(StringBuilder html, String title)
|
||||
{
|
||||
html.append("<section style=\"").append(CARD).append("\"><h3 style=\"margin:0 0 .75rem;font-size:1rem;\">").append(esc(title)).append("</h3>");
|
||||
html.append("<section style=\"").append(CARD).append("\"><h3 style=\"margin:0 0 .75rem;font-size:1rem;font-weight:600;\">").append(esc(title)).append("</h3>");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
** 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("<p style=\"").append(MUTED).append("margin:0 0 .75rem;\">").append(esc(text)).append("</p>");
|
||||
html.append("<p style=\"").append(MUTED).append("margin:0 0 .75rem;font-size:.9rem;\">").append(esc(text)).append("</p>");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
** 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("<a href=\"").append(esc(prefix + "/" + table)).append("\">").append(esc(text)).append("</a>");
|
||||
html.append("<div style=\"min-width:0;\"><div style=\"").append(MUTED).append("font-size:.75rem;\">").append(esc(name)).append("</div><div>").append(esc(value)).append("</div></div>");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 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 ("<span style=\"" + CHIP + "\"><span aria-hidden=\"true\" style=\"width:.55rem;height:.55rem;border-radius:50%;background:" + color + ";display:inline-block;\"></span>" + esc(label(status)) + "</span>");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 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("<a style=\"").append(LINK).append("font-size:.9rem;\" href=\"").append(esc(prefix + "/" + table)).append("\">").append(esc(text)).append("</a>");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A link to a record.
|
||||
*******************************************************************************/
|
||||
private static String recordLink(String prefix, String table, Object id, String text)
|
||||
{
|
||||
return ("<a style=\"" + LINK + "\" href=\"" + esc(prefix + "/" + table + "/" + id) + "\">" + esc(text == null ? String.valueOf(id) : text) + "</a>");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 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)
|
||||
{
|
||||
|
||||
@@ -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<String> 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<String> NAV_TABLES = List.of(PLAN, RUN, RESTORE_RUN, VERIFICATION, FILE_OBJECT);
|
||||
public static final List<String> NAV_TABLES = List.of(PLAN, RUN, RESTORE_RUN, VERIFICATION);
|
||||
|
||||
public static final List<String> 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<QTableMetaData> 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<Object[]> 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<String> placed = new java.util.HashSet<>();
|
||||
for(int i = 0; i < layout.size(); i++)
|
||||
{
|
||||
Object[] spec = layout.get(i);
|
||||
List<String> 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<String> columns)
|
||||
{
|
||||
}
|
||||
|
||||
/** The relations shown on the record screens (sub-tables are child lists here, not in the nav). */
|
||||
static final List<ChildList> 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<QWidgetMetaData> widgets()
|
||||
{
|
||||
Map<String, QJoinMetaData> byName = new LinkedHashMap<>();
|
||||
joins().forEach(join -> byName.put(join.getName(), join));
|
||||
List<QWidgetMetaData> 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))
|
||||
|
||||
@@ -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<String, Serializable> 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");
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
**
|
||||
** <pre>
|
||||
** ./mvnw -o test-compile exec:java -Dexec.classpathScope=test \
|
||||
** -Dexec.mainClass=com.kingsrook.qbits.backups.demo.BackupsDemoServer -Dexec.args=8098
|
||||
** </pre>
|
||||
*******************************************************************************/
|
||||
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<Duration> 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<String, BackupStorage> 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<String, Serializable> values) throws QException
|
||||
{
|
||||
new InsertAction().execute(new InsertInput(table).withRecord(record(values)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A record of values.
|
||||
*******************************************************************************/
|
||||
private static QRecord record(Map<String, Serializable> 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<String, Serializable> 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<String, Serializable> overrides, List<String[]> scope) throws Exception
|
||||
{
|
||||
Map<String, Serializable> 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.<String[]>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");
|
||||
}
|
||||
}
|
||||