Compare commits

...
Author SHA1 Message Date
james.maesandClaude Opus 5.5 bc97d59b96 refactor(alerts): bind core QAlerts directly (QQQ 4.1.0-RC.5)
Core QAlerts shipped in 4.1.0-RC.5. Replace the reflective
AlertsQBitAlertSink stand-in with a compiled binding:
- raise maps to QAlerts.raise, resolve to QAlerts.resolve and heartbeat to
  QAlerts.heartbeat;
- the backup alert types register as QAlertTypeMetaData.
It stays quiet when no alerts implementation is installed, and an alerting
failure never fails a backup. The fake SPI test classes are deleted.

RC.5 rejects underscores in alert type names, so two types are renamed:
- backups.run.missing_in_storage becomes backups.run.missing-in-storage;
- backups.plan.owner_invalid becomes backups.plan.owner-invalid.

TableFingerprints, TableReader and StandardRestoreWriter are tagged
STAND-IN(..., removeBy=4.1.0-RC.6) for TableSchemaFingerprint and
KeysetPager.

Refs #19 (architecture review QRun-IO/qqq#1150, finding F3).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:33:55 -05:00
james.maes 506741bd90 Merge pull request #18 from QRun-IO/feature/release-rc-automation
ci: automate RC releases; hotfix/* pushes no longer publish
2026-10-13 14:42:59 -05:00
james.maesandClaude Opus 5.5 95b971a5fb ci: automate RC releases; hotfix/* pushes no longer publish
Removes publish_hotfix_release: a hotfix/* branch push published a GA
release to Maven Central with no tag or review (QRun-IO/qbit-cdn#12,
QRun-IO/qqq#1121). hotfix/* branches now run test_only; a hotfix
release publishes from a reviewed vX.Y.Z tag through publish_release.
Adds .github/workflows/release-rc.yml (reusable QRun-IO/.github
qbit-release-rc): a manual dispatch, or merging a develop PR labelled
release:rc, merges develop into the release line and the release/* push
runs the existing CircleCI RC publish.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:29:57 -05:00
james.maes e56cc1b021 Merge pull request #17 from QRun-IO/feature/object-storage-rc2
build(deps): qbit-object-storage 0.1.0-RC.2 (AWS SDK security fix)
2026-10-13 11:07:46 -05:00
james.maesandClaude Opus 5.5 664f467d11 build(deps): qbit-object-storage 0.1.0-RC.2 (AWS SDK GHSA-443w-3rq3-5m5h)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 11:05:06 -05:00
james.maes 4fb182e824 Merge pull request #16 from QRun-IO/chore/object-storage-rc1
build: depend on qbit-object-storage 0.1.0-RC.1 (no SNAPSHOT dependency)
2026-10-12 19:15:20 -05:00
james.maesandClaude Sonnet 5.5 1de38f03ae build: depend on qbit-object-storage 0.1.0-RC.1 (Central), not a SNAPSHOT
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 19:11:25 -05:00
james.maes cf2d027f9a Merge pull request #9 from QRun-IO/feature/home-polish
feat(home): readable Backups home, record screens, runnable demo
2026-10-12 19:10:47 -05:00
james.maesandClaude Sonnet 5.5 714ef906fe feat(home): drop duplicate app section, bounded trend bars, screenshots
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 19:07:45 -05:00
james.maesandClaude Sonnet 5.5 26c174f7bf feat(home): null-safe home, readable plan rows, record sections and child lists, runnable demo
- Fix the NPE in BackupsHomeData.plansNeedingAttention when slaStatus is null (and null plan ids)
- Home: full-width plan status rows, derived lateness, links that work in the Next UI (tablePathPrefix), bars for the size trend
- The Backups app attaches itself to its parent app (Admin) so the nav shows it
- Nav lists the main tables only; artifacts, verifications, restores and per-table results are child lists on their parents
- Record screens grouped into sections
- Test-scope demo server (memory backend, real engine history incl. a failed run)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 19:07:45 -05:00
james.maes 284f0cec8c Merge pull request #8 from QRun-IO/feature/object-storage-adapter
feat(storage): BackupStorage adapter over qbit-object-storage (MinIO tested)
2026-10-12 19:07:11 -05:00
103 changed files with 1208 additions and 741 deletions

No files matched your search

+1 -12
View File
@@ -22,7 +22,7 @@ workflows:
context: [qqq-maven-registry-credentials]
filters:
branches:
ignore: /(develop|release\/.*|hotfix\/.*)/
ignore: /(develop|release\/.*)/
publish_snapshot:
jobs:
@@ -58,14 +58,3 @@ workflows:
ignore: /.*/
tags:
only: [/^v\d+\.\d+\.\d+$/]
publish_hotfix_release:
jobs:
- qqq-orb/mvn_publish:
pre-steps:
- configure-package-install
context: [qqq-maven-registry-credentials]
branch_type: hotfix
filters:
branches:
only: [/hotfix\/.*/]
+25
View File
@@ -0,0 +1,25 @@
# Cut the next release candidate (QRun-IO/qqq docs/QRUN-QBIT-REPO-STANDARD.md, C6):
# run this workflow by hand, or merge a develop PR labelled `release:rc`. It merges develop into
# the release line and the release/* push runs CircleCI publish_release_candidate.
name: Release candidate
on:
workflow_dispatch:
inputs:
dry_run:
description: Print the plan without merging or creating branches
type: boolean
default: false
pull_request:
types: [closed]
branches: [develop]
permissions:
contents: write
jobs:
release-rc:
if: github.event_name == 'workflow_dispatch' || (github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'release:rc'))
uses: QRun-IO/.github/.github/workflows/qbit-release-rc.yml@main
with:
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }}
+1 -1
View File
@@ -35,7 +35,7 @@ This file provides guidance to Codex when working with code in this repository.
## Dependencies
- Java 21.
- Parent `com.kingsrook:qbit-build-parent:2.0.0` (qqq 4.0.0). The default-active `qqq-snapshot` profile imports `qqq-bom-pom:4.1.0-RC.4` ahead of it, as in qbit-quick-search (ADR-0007). Remove that activation when the parent moves to 4.1.
- Parent `com.kingsrook:qbit-build-parent:2.0.0` (qqq 4.0.0). The default-active `qqq-snapshot` profile imports `qqq-bom-pom:4.1.0-RC.5` ahead of it, as in qbit-quick-search (ADR-0007). Remove that activation when the parent moves to 4.1.
- Tests: JUnit 5, AssertJ.
## Release
+6
View File
@@ -6,3 +6,9 @@ All notable changes to this project are documented here. The format follows Keep
### Added
- Repository scaffold: `BackupsQBitConfig` and `BackupsQBitProducer` (table prefix `backups_`, permissions `backups.*`), build, CI and repository standard files.
### Changed
- Build against the released QQQ BOM 4.1.0-RC.5; replace reflective alerts binding with core QAlerts and real sink integration tests.
- Register backup alert types for every instance while preserving custom backup sinks and no-op behavior without a configured core sink.
- Use RC.5-valid alert names `backups.run.missing-in-storage` and `backups.plan.owner-invalid` (and matching deduplication keys). Hosts using the old underscore names must update routing rules.
- Mark fingerprint, keyset reader, and restore bridges for removal in RC.6.
+16 -1
View File
@@ -10,7 +10,7 @@
## Requirements
- Java 21.
- QQQ 4.1 (built against `4.1.0-RC.4`).
- QQQ 4.1 (built against `4.1.0-RC.5`).
## Install
@@ -41,3 +41,18 @@ Always use the Maven wrapper (Maven 3.9.11); Maven 3.10 cannot read the parent P
## License
Apache License 2.0. See [LICENSE](LICENSE) and [NOTICE](NOTICE).
### Alerts integration
Backups binds directly to core QAlerts in QQQ 4.1.0-RC.5 and declares its nine
alert types on each instance. Hosts install an `AlertSinkInterface` through
`AlertsInstanceMetaData.withSink(QCodeReference)`. Without a configured sink,
backup alert calls remain no-ops (they do not use core's logging fallback).
Custom `BackupAlertSink` bindings are preserved. Core handles dispatch failures;
adapter failures are caught and logged once, so alerting cannot fail a backup.
Raise and resolve follow core's transaction-aware dispatch; heartbeats run immediately.
The backup SLA checker still owns expected intervals and grace periods.
RC.5 requires hyphenated alert names: `backups.run.missing-in-storage` and
`backups.plan.owner-invalid` replace the former underscore spellings, including
their deduplication keys. Update host routing rules that used those spellings.
+7
View File
@@ -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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

+16 -3
View File
@@ -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>
@@ -179,14 +192,14 @@
<profiles>
<!-- Built against qqq 4.1 (QBitMetaDataProducer). Until qbit-build-parent -->
<!-- imports the released 4.1 BOM, this profile is active by default and -->
<!-- imports qqq-bom-pom 4.1.0-RC.4 ahead of the parent's 4.0.0 BOM. -->
<!-- imports qqq-bom-pom 4.1.0-RC.5 ahead of the parent's 4.0.0 BOM. -->
<profile>
<id>qqq-snapshot</id>
<activation>
<activeByDefault>true</activeByDefault>
</activation>
<properties>
<qqq.snapshot.version>4.1.0-RC.4</qqq.snapshot.version>
<qqq.snapshot.version>4.1.0-RC.5</qqq.snapshot.version>
</properties>
<dependencyManagement>
<dependencies>
@@ -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)
*******************************************************************************/
@@ -119,13 +119,8 @@ public class BackupsQBitProducer implements QBitMetaDataProducer<BackupsQBitConf
qInstance.addQBit(qBit);
BackupsRuntime.install(qBitConfig);
///////////////////////////////////////////////////////////////////////////////
// bind the Alerts qbit producer SPI when it is on the class path (optional) //
///////////////////////////////////////////////////////////////////////////////
if(com.kingsrook.qbits.backups.alerts.AlertsQBitAlertSink.bindIfPresent())
{
new com.kingsrook.qbits.backups.alerts.AlertsQBitAlertSink().registerAlertTypes(qInstance);
}
com.kingsrook.qbits.backups.alerts.AlertsQBitAlertSink.bindIfAbsent();
new com.kingsrook.qbits.backups.alerts.AlertsQBitAlertSink().registerAlertTypes(qInstance);
com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput output = new com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput();
for(var possibleValueSource : com.kingsrook.qbits.backups.model.BackupsTables.possibleValueSources())
@@ -158,8 +153,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());
}
@@ -17,105 +17,39 @@
package com.kingsrook.qbits.backups.alerts;
import java.lang.reflect.Method;
import java.util.Map;
import java.util.stream.Collectors;
import com.kingsrook.qbits.backups.BackupsRuntime;
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
import com.kingsrook.qqq.backend.core.alerts.AlertsInstanceMetaData;
import com.kingsrook.qqq.backend.core.alerts.QAlertTypeMetaData;
import com.kingsrook.qqq.backend.core.alerts.QAlerts;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
/*******************************************************************************
** The BackupAlertSink adapter for the Alerts qbit producer SPI (QRun-IO/qqq
** PR #1108, com.kingsrook.qqq.backend.core.alerts: QAlerts, AlertSignal,
** AlertResolution, AlertSeverity, QAlertTypeMetaData; design spec
** docs/specs/2026-10-12-qbit-alerts-design.md).
**
** STAND-IN: the SPI is an open core PR and is not in QQQ 4.1.0-RC.4, so this
** adapter is bound REFLECTIVELY and optionally. It compiles against nothing in
** the SPI; it looks the classes up by name, and every call is guarded:
** <ul>
** <li>no SPI classes on the class path: {@link #bindIfPresent()} binds nothing
** and the sink stays the no-op;</li>
** <li>the SPI is present but the instance has no alerts meta-data (the Alerts
** qbit is not installed): every call is a no-op;</li>
** <li>any reflective or SPI failure is logged once and swallowed, because an
** alerting problem must never fail a backup.</li>
** </ul>
** Replace with a direct binding (QAlerts.raise and friends) when RC.5 ships.
**
** Mapping: raise to QAlerts.raise(AlertSignal), resolve to QAlerts.resolve(type,
** dedupKey, note), heartbeat to QAlerts.heartbeat(key). The SPI has no per-plan
** heartbeat registration (monitors are declared on the alert type), so the
** expected interval and grace are not passed on; the SLA checker raises
** backups.plan.missed itself, independent of any heartbeat (spec 10.4).
** Direct adapter to core QAlerts. Calls are no-ops without a configured alerts
** sink, including when only the backup alert types have been registered.
** QAlerts catches and logs dispatch failures; adapter failures are logged once.
** Heartbeat intervals remain the responsibility of the backup SLA checker.
*******************************************************************************/
public class AlertsQBitAlertSink implements BackupAlertSink
{
private static final QLogger LOG = QLogger.getLogger(AlertsQBitAlertSink.class);
/** The package of the Alerts producer SPI in qqq-backend-core. */
public static final String DEFAULT_API_PACKAGE = "com.kingsrook.qqq.backend.core.alerts";
private final String apiPackage;
private boolean warned = false;
private Class<?> qAlerts;
private Class<?> alertSignal;
private Class<?> alertSeverity;
private Class<?> instanceMetaData;
/*******************************************************************************
** Constructor for the real SPI package.
** Bind the core adapter unless the host already supplied a backup sink.
*******************************************************************************/
public AlertsQBitAlertSink()
public static boolean bindIfAbsent()
{
this(DEFAULT_API_PACKAGE);
}
/*******************************************************************************
** Constructor for another package (a test double of the SPI shape).
*******************************************************************************/
public AlertsQBitAlertSink(String apiPackage)
{
this.apiPackage = apiPackage;
}
/*******************************************************************************
** Whether the SPI classes are on the class path.
*******************************************************************************/
public static boolean isPresent(String apiPackage)
{
try
{
Class.forName(apiPackage + ".QAlerts");
Class.forName(apiPackage + ".AlertSignal");
return (true);
}
catch(ClassNotFoundException | LinkageError e)
{
return (false);
}
}
/*******************************************************************************
** Bind this adapter as the runtime's alert sink when the SPI is present and no
** other sink was bound already. Returns whether it bound.
*******************************************************************************/
public static boolean bindIfPresent()
{
if(!(BackupsRuntime.getAlertSink() instanceof NoOpBackupAlertSink) || !isPresent(DEFAULT_API_PACKAGE))
if(!(BackupsRuntime.getAlertSink() instanceof NoOpBackupAlertSink))
{
return (false);
}
@@ -126,38 +60,32 @@ public class AlertsQBitAlertSink implements BackupAlertSink
/*******************************************************************************
** Declare the backup alert types with the Alerts qbit (QAlertTypeMetaData), when
** the SPI is present. Returns how many were added.
** Declare missing backup alert types on each instance, independently of sink
** installation order. Returns how many were added.
*******************************************************************************/
public int registerAlertTypes(QInstance qInstance)
{
int added = 0;
try
for(BackupAlerts.AlertType alertType : BackupAlerts.alertTypes())
{
Class<?> typeClass = Class.forName(apiPackage + ".QAlertTypeMetaData");
load();
for(BackupAlerts.AlertType alertType : BackupAlerts.alertTypes())
try
{
try
if(QAlertTypeMetaData.of(qInstance, alertType.name()) == null)
{
Object type = typeClass.getConstructor().newInstance();
typeClass.getMethod("withName", String.class).invoke(type, alertType.name());
typeClass.getMethod("withLabel", String.class).invoke(type, alertType.label());
typeClass.getMethod("withGroupName", String.class).invoke(type, "Backups");
typeClass.getMethod("withHowToFix", String.class).invoke(type, alertType.howToFix());
typeClass.getMethod("withDefaultSeverity", alertSeverity).invoke(type, severity(alertType.defaultSeverity()));
typeClass.getMethod("addSelfToInstance", QInstance.class).invoke(type, qInstance);
new QAlertTypeMetaData()
.withName(alertType.name())
.withLabel(alertType.label())
.withGroupName("Backups")
.withHowToFix(alertType.howToFix())
.withDefaultSeverity(AlertSeverity.valueOf(alertType.defaultSeverity().name()))
.addSelfToInstance(qInstance);
added++;
}
catch(ReflectiveOperationException | RuntimeException e)
{
warnOnce("Could not declare the alert type " + alertType.name(), e);
}
}
}
catch(ReflectiveOperationException | LinkageError e)
{
warnOnce("The alerts SPI is not available", e);
catch(RuntimeException | LinkageError e)
{
warnOnce("Could not declare the alert type " + alertType.name(), e);
}
}
return (added);
}
@@ -170,28 +98,24 @@ public class AlertsQBitAlertSink implements BackupAlertSink
@Override
public void raise(String type, String dedupeKey, Severity severity, String title, Map<String, String> detail)
{
if(!isAlertsQBitInstalled())
{
return;
}
try
{
Object signal = alertSignal.getConstructor(String.class).newInstance(type);
alertSignal.getMethod("setDedupKey", String.class).invoke(signal, dedupeKey);
alertSignal.getMethod("setSeverity", alertSeverity).invoke(signal, severity(severity));
alertSignal.getMethod("setTitle", String.class).invoke(signal, title);
if(!isAlertsQBitInstalled())
{
return;
}
AlertSignal signal = new AlertSignal(type)
.withDedupKey(dedupeKey)
.withSeverity(AlertSeverity.valueOf(severity.name()))
.withTitle(title);
if(detail != null && !detail.isEmpty())
{
alertSignal.getMethod("setSummary", String.class).invoke(signal, detail.entrySet().stream().map(e -> e.getKey() + "=" + e.getValue()).collect(Collectors.joining(", ")));
Method withAttribute = alertSignal.getMethod("withAttribute", String.class, String.class);
for(Map.Entry<String, String> entry : detail.entrySet())
{
withAttribute.invoke(signal, entry.getKey(), entry.getValue());
}
signal.withSummary(detail.entrySet().stream().map(e -> e.getKey() + "=" + e.getValue()).collect(Collectors.joining(", ")));
detail.forEach(signal::withAttribute);
}
qAlerts.getMethod("raise", alertSignal).invoke(null, signal);
QAlerts.raise(signal);
}
catch(ReflectiveOperationException | RuntimeException e)
catch(RuntimeException | LinkageError e)
{
warnOnce("Could not raise an alert", e);
}
@@ -205,15 +129,14 @@ public class AlertsQBitAlertSink implements BackupAlertSink
@Override
public void resolve(String type, String dedupeKey)
{
if(!isAlertsQBitInstalled())
{
return;
}
try
{
qAlerts.getMethod("resolve", String.class, String.class, String.class).invoke(null, type, dedupeKey, "Cleared by the Backups qbit");
if(isAlertsQBitInstalled())
{
QAlerts.resolve(type, dedupeKey, "Cleared by the Backups qbit");
}
}
catch(ReflectiveOperationException | RuntimeException e)
catch(RuntimeException | LinkageError e)
{
warnOnce("Could not resolve an alert", e);
}
@@ -227,15 +150,14 @@ public class AlertsQBitAlertSink implements BackupAlertSink
@Override
public void heartbeat(String monitorKey, long expectedEverySeconds, long graceSeconds)
{
if(!isAlertsQBitInstalled())
{
return;
}
try
{
qAlerts.getMethod("heartbeat", String.class).invoke(null, monitorKey);
if(isAlertsQBitInstalled())
{
QAlerts.heartbeat(monitorKey);
}
}
catch(ReflectiveOperationException | RuntimeException e)
catch(RuntimeException | LinkageError e)
{
warnOnce("Could not check in a heartbeat", e);
}
@@ -244,67 +166,26 @@ public class AlertsQBitAlertSink implements BackupAlertSink
/*******************************************************************************
** The SPI classes load, and the instance has alerts meta-data (the Alerts qbit
** is installed). Checked on every call so the order qbits are produced in
** does not matter.
** Check on every call so installing the alerts sink after this qbit works.
** Type declarations alone do not mean an alerts implementation is installed.
*******************************************************************************/
boolean isAlertsQBitInstalled()
{
try
{
load();
QInstance qInstance = QContext.getQInstance();
if(qInstance == null)
{
return (false);
}
return (instanceMetaData.getMethod("of", QInstance.class).invoke(null, qInstance) != null);
}
catch(ReflectiveOperationException | RuntimeException | LinkageError e)
{
warnOnce("The alerts SPI is not usable", e);
return (false);
}
AlertsInstanceMetaData metaData = AlertsInstanceMetaData.of(QContext.getQInstance());
return (metaData != null && metaData.getSink() != null);
}
/*******************************************************************************
**
** Log adapter failures once; dispatch failures are already logged by core.
*******************************************************************************/
private synchronized void load() throws ClassNotFoundException
{
if(qAlerts == null)
{
qAlerts = Class.forName(apiPackage + ".QAlerts");
alertSignal = Class.forName(apiPackage + ".AlertSignal");
alertSeverity = Class.forName(apiPackage + ".AlertSeverity");
instanceMetaData = Class.forName(apiPackage + ".AlertsInstanceMetaData");
}
}
/*******************************************************************************
** Map our severity to the SPI's enum constant of the same name.
*******************************************************************************/
@SuppressWarnings({ "unchecked", "rawtypes" })
private Object severity(Severity severity)
{
return (Enum.valueOf((Class<Enum>) alertSeverity, severity.name()));
}
/*******************************************************************************
**
*******************************************************************************/
private void warnOnce(String message, Throwable t)
private synchronized void warnOnce(String message, Throwable t)
{
if(!warned)
{
warned = true;
LOG.warn(message + " (the Alerts qbit adapter will keep failing quietly)", t, logPair("apiPackage", apiPackage));
LOG.warn(message + " (the Alerts qbit adapter will keep failing quietly)", t);
}
}
}
@@ -36,11 +36,11 @@ public final class BackupAlerts
{
public static final String PLAN_MISSED = "backups.plan.missed";
public static final String RUN_FAILED = "backups.run.failed";
public static final String RUN_MISSING = "backups.run.missing_in_storage";
public static final String RUN_MISSING = "backups.run.missing-in-storage";
public static final String FILES_MISSING = "backups.files.missing";
public static final String VERIFY_FAILED = "backups.verify.failed";
public static final String VERIFY_OVERDUE = "backups.verify.overdue";
public static final String PLAN_OWNER_INVALID = "backups.plan.owner_invalid";
public static final String PLAN_OWNER_INVALID = "backups.plan.owner-invalid";
public static final String STORAGE_DEBRIS = "backups.storage.debris";
public static final String RESTORE_COMPLETED = "backups.restore.completed";
@@ -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,6 +31,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.tables.UniqueKey;
/*******************************************************************************
** STAND-IN(core=QRun-IO/qqq#1113, removeBy=4.1.0-RC.6)
** A stable schema descriptor and SHA-256 fingerprint of a table (spec 5.5).
**
** This is an internal stand-in for the QQQ core TableSchemaFingerprint (core PR
@@ -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))
@@ -45,6 +45,8 @@ import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
/*******************************************************************************
** STAND-IN(core=QRun-IO/qqq#1111, removeBy=4.1.0-RC.6)
** STAND-IN(core=QRun-IO/qqq#1112, removeBy=4.1.0-RC.6)
** The default RestoreWriter: the standard Insert, Update and Delete actions.
**
** Stand-ins (replace with the core API when QQQ 4.1.0-RC.5 ships):
@@ -34,6 +34,7 @@ import com.kingsrook.qqq.backend.core.utils.JsonUtils;
/*******************************************************************************
** STAND-IN(core=QRun-IO/qqq#1114, removeBy=4.1.0-RC.6)
** Reads the rows of one table in primary key order, in keyset pages (WHERE pk >
** lastKey ORDER BY pk LIMIT n), so the cost stays flat with depth and a read can
** resume from a key. A read raw: hidden fields, passwords and heavy fields are
@@ -50,7 +50,7 @@ import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
** run is a full-instance, system-session backup (securityScope FULL, requested by
** system:scheduler), which is why the plan owner is validated first: a plan whose
** owner has gone, or lost backups.admin, is not run silently. It raises
** backups.plan.owner_invalid, marks the plan OWNER_INVALID, and stops.
** backups.plan.owner-invalid, marks the plan OWNER_INVALID, and stops.
**
** Everything goes through the run coordinator, so a trigger that finds the plan
** already running is skipped visibly, and a full instance queues the run.
@@ -141,11 +141,11 @@ public class BackupScheduledRunProcess implements BackendStep
if(problem != null)
{
catalog.update(BackupsTables.PLAN, plan.getValueInteger("id"), Map.of("slaStatus", "OWNER_INVALID"));
BackupsRuntime.getAlertSink().raise("backups.plan.owner_invalid", "backups.plan.owner_invalid:" + plan.getValueString("planKey"), BackupAlertSink.Severity.WARNING,
BackupsRuntime.getAlertSink().raise("backups.plan.owner-invalid", "backups.plan.owner-invalid:" + plan.getValueString("planKey"), BackupAlertSink.Severity.WARNING,
"A scheduled backup did not run: " + problem, Map.of("plan", plan.getValueString("planKey")));
throw (new QUserFacingException("The scheduled backup did not run: " + problem + "."));
}
BackupsRuntime.getAlertSink().resolve("backups.plan.owner_invalid", "backups.plan.owner_invalid:" + plan.getValueString("planKey"));
BackupsRuntime.getAlertSink().resolve("backups.plan.owner-invalid", "backups.plan.owner-invalid:" + plan.getValueString("planKey"));
BackupRunResult result = new BackupRunCoordinator().runPlan(plan, "SCHEDULED");
output.addValue("status", result.getStatus());
@@ -18,16 +18,24 @@ package com.kingsrook.qbits.backups.alerts;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.backups.BackupsRuntime;
import com.kingsrook.qbits.backups.VerifyTestSupport;
import com.kingsrook.qbits.backups.alerts.fakeapi.AlertsInstanceMetaData;
import com.kingsrook.qbits.backups.alerts.fakeapi.QAlertTypeMetaData;
import com.kingsrook.qbits.backups.alerts.fakeapi.QAlerts;
import com.kingsrook.qbits.backups.sla.SlaChecker;
import com.kingsrook.qqq.backend.core.alerts.AlertResolution;
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
import com.kingsrook.qqq.backend.core.alerts.AlertSinkInterface;
import com.kingsrook.qqq.backend.core.alerts.AlertsInstanceMetaData;
import com.kingsrook.qqq.backend.core.alerts.QAlerts;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.instances.QInstanceValidator;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -35,14 +43,10 @@ import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** The Alerts qbit adapter, bound reflectively. The SPI is not in QQQ 4.1.0-RC.4,
** so these tests run it against a test double with the same shape as the real
** com.kingsrook.qqq.backend.core.alerts package (QRun-IO/qqq PR #1108).
** The backup adapter against the released core alerts API and a registered sink.
*******************************************************************************/
class AlertsQBitAlertSinkTest
{
private static final String FAKE_API = "com.kingsrook.qbits.backups.alerts.fakeapi";
/*******************************************************************************
@@ -52,9 +56,11 @@ class AlertsQBitAlertSinkTest
void setUp() throws Exception
{
VerifyTestSupport.setUp(3);
QAlerts.reset();
QAlertTypeMetaData.ADDED.clear();
AlertsInstanceMetaData.installed = true;
RecordingSink.raised.clear();
RecordingSink.resolved.clear();
RecordingSink.heartbeats.clear();
RecordingSink.failWith = null;
AlertsInstanceMetaData.ofOrWithNew(QContext.getQInstance()).withSink(new QCodeReference(RecordingSink.class));
}
@@ -65,8 +71,8 @@ class AlertsQBitAlertSinkTest
@AfterEach
void tearDown()
{
AlertsInstanceMetaData.installed = false;
QAlerts.reset();
BackupsRuntime.reset();
QContext.clear();
}
@@ -78,7 +84,7 @@ class AlertsQBitAlertSinkTest
@Test
void testRaiseResolveHeartbeat()
{
AlertsQBitAlertSink sink = new AlertsQBitAlertSink(FAKE_API);
AlertsQBitAlertSink sink = new AlertsQBitAlertSink();
Map<String, String> detail = new LinkedHashMap<>();
detail.put("plan", "nightly");
detail.put("slaHours", "26");
@@ -87,60 +93,90 @@ class AlertsQBitAlertSinkTest
sink.resolve("backups.plan.missed", "backups.plan.missed:nightly");
sink.heartbeat("backups.plan:nightly", 93600, 0);
assertThat(QAlerts.RAISED).hasSize(1);
var signal = QAlerts.RAISED.get(0);
assertThat(signal.alertTypeName).isEqualTo("backups.plan.missed");
assertThat(signal.dedupKey).isEqualTo("backups.plan.missed:nightly");
assertThat(signal.severity.name()).isEqualTo("CRITICAL");
assertThat(signal.title).isEqualTo("A backup did not run in time");
assertThat(signal.summary).isEqualTo("plan=nightly, slaHours=26");
assertThat(signal.attributes).containsEntry("plan", "nightly").containsEntry("slaHours", "26");
assertThat(QAlerts.RESOLVED).containsExactly("backups.plan.missed|backups.plan.missed:nightly|Cleared by the Backups qbit");
assertThat(QAlerts.HEARTBEATS).containsExactly("backups.plan:nightly");
assertThat(RecordingSink.raised).hasSize(1);
var signal = RecordingSink.raised.get(0);
assertThat(signal.getAlertTypeName()).isEqualTo("backups.plan.missed");
assertThat(signal.getDedupKey()).isEqualTo("backups.plan.missed:nightly");
assertThat(signal.getSeverity().name()).isEqualTo("CRITICAL");
assertThat(signal.getTitle()).isEqualTo("A backup did not run in time");
assertThat(signal.getSummary()).isEqualTo("plan=nightly, slaHours=26");
assertThat(signal.getAttributes()).containsEntry("plan", "nightly").containsEntry("slaHours", "26");
assertThat(RecordingSink.resolved).singleElement().satisfies(resolution ->
{
assertThat(resolution.getAlertTypeName()).isEqualTo("backups.plan.missed");
assertThat(resolution.getDedupKey()).isEqualTo("backups.plan.missed:nightly");
assertThat(resolution.getNote()).isEqualTo("Cleared by the Backups qbit");
});
assertThat(RecordingSink.heartbeats).containsExactly("backups.plan:nightly");
sink.raise("backups.storage.debris", "k", BackupAlertSink.Severity.INFO, "no detail", null);
assertThat(QAlerts.RAISED.get(1).summary).isNull();
assertThat(RecordingSink.raised.get(1).getSummary()).isNull();
}
/*******************************************************************************
** Without the Alerts qbit installed (no alerts meta-data on the instance) every
** Without a configured sink, even with alert type metadata present, every
** call is a no-op.
*******************************************************************************/
@Test
void testNoOpWithoutTheAlertsQBit()
{
AlertsInstanceMetaData.installed = false;
AlertsQBitAlertSink sink = new AlertsQBitAlertSink(FAKE_API);
sink.raise("t", "k", BackupAlertSink.Severity.INFO, "x", Map.of());
sink.resolve("t", "k");
sink.heartbeat("m", 1, 1);
assertThat(QAlerts.RAISED).isEmpty();
assertThat(QAlerts.RESOLVED).isEmpty();
assertThat(QAlerts.HEARTBEATS).isEmpty();
AlertsInstanceMetaData.of(QContext.getQInstance()).withSink(null);
AlertsQBitAlertSink sink = new AlertsQBitAlertSink();
long failures = QAlerts.getProducerFailureCount();
sink.raise(null, "k", BackupAlertSink.Severity.INFO, "x", Map.of());
sink.resolve(null, "k");
sink.heartbeat(null, 1, 1);
assertThat(QAlerts.getProducerFailureCount()).isEqualTo(failures);
assertThat(RecordingSink.raised).isEmpty();
assertThat(RecordingSink.resolved).isEmpty();
assertThat(RecordingSink.heartbeats).isEmpty();
}
/*******************************************************************************
** A broken or missing SPI never throws into the backup.
** The same adapter follows the current instance and a sink installed later.
*******************************************************************************/
@Test
void testLateInstallationAndInstanceSwitch()
{
var original = QContext.getQInstance();
QContext.setQInstance(new QInstance());
AlertsQBitAlertSink sink = new AlertsQBitAlertSink();
assertThat(sink.isAlertsQBitInstalled()).isFalse();
sink.raise("backups.plan.missed", "k", BackupAlertSink.Severity.INFO, "x", Map.of());
assertThat(RecordingSink.raised).isEmpty();
sink.registerAlertTypes(QContext.getQInstance());
assertThat(sink.isAlertsQBitInstalled()).isFalse();
AlertsInstanceMetaData.of(QContext.getQInstance()).withSink(new QCodeReference(RecordingSink.class));
sink.raise("backups.plan.missed", "k", BackupAlertSink.Severity.INFO, "x", Map.of());
assertThat(RecordingSink.raised).hasSize(1);
assertThat(RecordingSink.raised.get(0).getSummary()).isNull();
QContext.setQInstance(original);
assertThat(sink.isAlertsQBitInstalled()).isTrue();
}
/*******************************************************************************
** Sink and adapter failures never throw into the backup.
*******************************************************************************/
@Test
void testFailuresAreSwallowed()
{
AlertsQBitAlertSink missing = new AlertsQBitAlertSink("no.such.package");
missing.raise("t", "k", BackupAlertSink.Severity.INFO, "x", Map.of());
missing.resolve("t", "k");
missing.heartbeat("m", 1, 1);
assertThat(missing.registerAlertTypes(QContext.getQInstance())).isZero();
QAlerts.failWith = new IllegalStateException("alerts down");
AlertsQBitAlertSink broken = new AlertsQBitAlertSink(FAKE_API);
long failures = QAlerts.getProducerFailureCount();
RecordingSink.failWith = new IllegalStateException("alerts down");
AlertsQBitAlertSink broken = new AlertsQBitAlertSink();
broken.raise("t", "k", BackupAlertSink.Severity.INFO, "x", Map.of());
broken.resolve("t", "k");
broken.heartbeat("m", 1, 1);
assertThat(QAlerts.RAISED).isEmpty();
assertThat(RecordingSink.raised).isEmpty();
assertThat(QAlerts.getProducerFailureCount()).isEqualTo(failures + 3);
broken.raise("t", "k", null, "bad severity", Map.of());
broken.raise("t", "k", null, "bad severity again", Map.of());
assertThat(broken.registerAlertTypes(null)).isZero();
}
@@ -149,41 +185,41 @@ class AlertsQBitAlertSinkTest
** The backup alert types are declared with the Alerts qbit.
*******************************************************************************/
@Test
void testRegisterAlertTypes()
void testRegisterAlertTypes() throws Exception
{
int added = new AlertsQBitAlertSink(FAKE_API).registerAlertTypes(QContext.getQInstance());
QInstance instance = new QInstance();
AlertsQBitAlertSink sink = new AlertsQBitAlertSink();
int added = sink.registerAlertTypes(instance);
var types = new ArrayList<>(AlertsInstanceMetaData.of(instance).getAlertTypes().values());
assertThat(added).isEqualTo(BackupAlerts.alertTypes().size()).isEqualTo(9);
assertThat(QAlertTypeMetaData.ADDED).extracting(t -> t.name).containsExactly(
"backups.plan.missed", "backups.run.failed", "backups.run.missing_in_storage", "backups.files.missing", "backups.verify.failed",
"backups.verify.overdue", "backups.plan.owner_invalid", "backups.storage.debris", "backups.restore.completed");
assertThat(QAlertTypeMetaData.ADDED).allSatisfy(t ->
assertThat(types).extracting(t -> t.getName()).containsExactly(
"backups.plan.missed", "backups.run.failed", "backups.run.missing-in-storage", "backups.files.missing", "backups.verify.failed",
"backups.verify.overdue", "backups.plan.owner-invalid", "backups.storage.debris", "backups.restore.completed");
assertThat(types).allSatisfy(t ->
{
assertThat(t.groupName).isEqualTo("Backups");
assertThat(t.howToFix).isNotBlank();
assertThat(t.label).isNotBlank();
assertThat(t.getGroupName()).isEqualTo("Backups");
assertThat(t.getHowToFix()).isNotBlank();
assertThat(t.getLabel()).isNotBlank();
});
assertThat(QAlertTypeMetaData.ADDED.get(2).defaultSeverity.name()).isEqualTo("CRITICAL");
assertThat(types.get(2).getDefaultSeverity().name()).isEqualTo("CRITICAL");
assertThat(sink.registerAlertTypes(instance)).isZero();
new QInstanceValidator().validate(QContext.getQInstance());
}
/*******************************************************************************
** Detection: the SPI is present or not by class name; the real SPI is not in
** RC.4, so nothing is bound by default there.
** Default binding preserves a host-provided backup sink.
*******************************************************************************/
@Test
void testDetectionAndBinding()
{
assertThat(AlertsQBitAlertSink.isPresent(FAKE_API)).isTrue();
assertThat(AlertsQBitAlertSink.isPresent("no.such.package")).isFalse();
boolean realSpiPresent = AlertsQBitAlertSink.isPresent(AlertsQBitAlertSink.DEFAULT_API_PACKAGE);
assertThat(AlertsQBitAlertSink.bindIfPresent()).isEqualTo(realSpiPresent);
if(realSpiPresent)
{
assertThat(BackupsRuntime.getAlertSink()).isInstanceOf(AlertsQBitAlertSink.class);
assertThat(AlertsQBitAlertSink.bindIfPresent()).as("an already bound sink is not replaced").isFalse();
}
assertThat(AlertsQBitAlertSink.bindIfAbsent()).isFalse();
assertThat(BackupsRuntime.getAlertSink()).isSameAs(VerifyTestSupport.sink);
BackupsRuntime.setAlertSink(null);
assertThat(AlertsQBitAlertSink.bindIfAbsent()).isTrue();
assertThat(BackupsRuntime.getAlertSink()).isInstanceOf(AlertsQBitAlertSink.class);
assertThat(AlertsQBitAlertSink.bindIfAbsent()).isFalse();
}
@@ -194,22 +230,80 @@ class AlertsQBitAlertSinkTest
@Test
void testSlaAlertsThroughTheAdapter() throws Exception
{
BackupsRuntime.setAlertSink(new AlertsQBitAlertSink(FAKE_API));
BackupsRuntime.setAlertSink(new AlertsQBitAlertSink());
QRecord plan = VerifyTestSupport.plan(Map.of());
VerifyTestSupport.backup(plan);
assertThat(QAlerts.HEARTBEATS).contains("backups.plan:nightly");
assertThat(RecordingSink.heartbeats).contains("backups.plan:nightly");
VerifyTestSupport.clock.advance(Duration.ofHours(30));
new SlaChecker().check(VerifyTestSupport.reload(plan));
assertThat(QAlerts.RAISED).anySatisfy(s ->
assertThat(RecordingSink.raised).anySatisfy(s ->
{
assertThat(s.alertTypeName).isEqualTo("backups.plan.missed");
assertThat(s.dedupKey).isEqualTo("backups.plan.missed:nightly");
assertThat(s.severity.name()).isEqualTo("ERROR");
assertThat(s.getAlertTypeName()).isEqualTo("backups.plan.missed");
assertThat(s.getDedupKey()).isEqualTo("backups.plan.missed:nightly");
assertThat(s.getSeverity().name()).isEqualTo("ERROR");
});
VerifyTestSupport.backup(plan);
new SlaChecker().check(VerifyTestSupport.reload(plan));
assertThat(QAlerts.RESOLVED).anyMatch(r -> r.startsWith("backups.plan.missed|backups.plan.missed:nightly|"));
assertThat(RecordingSink.resolved).anyMatch(r -> r.getAlertTypeName().equals("backups.plan.missed") && r.getDedupKey().equals("backups.plan.missed:nightly"));
}
/*******************************************************************************
** Sink loaded by the real QCodeLoader through AlertsInstanceMetaData.
*******************************************************************************/
public static class RecordingSink implements AlertSinkInterface
{
static final List<AlertSignal> raised = new ArrayList<>();
static final List<AlertResolution> resolved = new ArrayList<>();
static final List<String> heartbeats = new ArrayList<>();
static RuntimeException failWith;
/*******************************************************************************
** {@inheritDoc}
*******************************************************************************/
@Override
public void raise(AlertSignal signal)
{
if(failWith != null)
{
throw (failWith);
}
raised.add(signal);
}
/*******************************************************************************
** {@inheritDoc}
*******************************************************************************/
@Override
public void resolve(AlertResolution resolution)
{
if(failWith != null)
{
throw (failWith);
}
resolved.add(resolution);
}
/*******************************************************************************
** {@inheritDoc}
*******************************************************************************/
@Override
public void heartbeat(String heartbeatKey, Instant at)
{
if(failWith != null)
{
throw (failWith);
}
heartbeats.add(heartbeatKey);
}
}
}
@@ -70,8 +70,8 @@ class BackupAlertsTest
void testTheSpecTable()
{
Set<String> names = BackupAlerts.alertTypes().stream().map(BackupAlerts.AlertType::name).collect(Collectors.toSet());
assertThat(names).containsExactlyInAnyOrder("backups.plan.missed", "backups.run.failed", "backups.run.missing_in_storage", "backups.files.missing",
"backups.verify.failed", "backups.verify.overdue", "backups.plan.owner_invalid", "backups.storage.debris", "backups.restore.completed");
assertThat(names).containsExactlyInAnyOrder("backups.plan.missed", "backups.run.failed", "backups.run.missing-in-storage", "backups.files.missing",
"backups.verify.failed", "backups.verify.overdue", "backups.plan.owner-invalid", "backups.storage.debris", "backups.restore.completed");
assertThat(BackupAlerts.alertTypes()).allSatisfy(t -> assertThat(t.howToFix()).isNotBlank());
assertThat(BackupAlerts.alertTypes().stream().filter(t -> t.name().equals(BackupAlerts.RUN_MISSING)).findFirst().orElseThrow().defaultSeverity())
.isEqualTo(BackupAlertSink.Severity.CRITICAL);
@@ -1,30 +0,0 @@
/*
* 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.alerts.fakeapi;
/*******************************************************************************
** Test double of the Alerts producer SPI (QRun-IO/qqq PR #1108): the same shape as
** com.kingsrook.qqq.backend.core.alerts.AlertSeverity.
*******************************************************************************/
public enum AlertSeverity
{
INFO,
WARNING,
ERROR,
CRITICAL
}
@@ -1,96 +0,0 @@
/*
* 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.alerts.fakeapi;
import java.util.LinkedHashMap;
import java.util.Map;
/*******************************************************************************
** Test double of the Alerts producer SPI AlertSignal (only what the adapter uses).
*******************************************************************************/
public class AlertSignal
{
public final String alertTypeName;
public String dedupKey;
public AlertSeverity severity;
public String title;
public String summary;
public final Map<String, String> attributes = new LinkedHashMap<>();
/*******************************************************************************
** Constructor.
*******************************************************************************/
public AlertSignal(String alertTypeName)
{
this.alertTypeName = alertTypeName;
}
/*******************************************************************************
** Setter.
*******************************************************************************/
public void setDedupKey(String dedupKey)
{
this.dedupKey = dedupKey;
}
/*******************************************************************************
** Setter.
*******************************************************************************/
public void setSeverity(AlertSeverity severity)
{
this.severity = severity;
}
/*******************************************************************************
** Setter.
*******************************************************************************/
public void setTitle(String title)
{
this.title = title;
}
/*******************************************************************************
** Setter.
*******************************************************************************/
public void setSummary(String summary)
{
this.summary = summary;
}
/*******************************************************************************
** Fluent attribute.
*******************************************************************************/
public AlertSignal withAttribute(String key, String value)
{
attributes.put(key, value);
return (this);
}
}
@@ -1,40 +0,0 @@
/*
* 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.alerts.fakeapi;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
/*******************************************************************************
** Test double of the Alerts producer SPI AlertsInstanceMetaData: of(instance) is
** non-null only when a test says the Alerts qbit is installed.
*******************************************************************************/
public class AlertsInstanceMetaData
{
public static boolean installed = false;
/*******************************************************************************
** The alerts meta-data of an instance, or null.
*******************************************************************************/
public static AlertsInstanceMetaData of(QInstance qInstance)
{
return (installed ? new AlertsInstanceMetaData() : null);
}
}
@@ -1,103 +0,0 @@
/*
* 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.alerts.fakeapi;
import java.util.ArrayList;
import java.util.List;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
/*******************************************************************************
** Test double of the Alerts producer SPI QAlertTypeMetaData: records the types
** added to an instance.
*******************************************************************************/
public class QAlertTypeMetaData
{
public static final List<QAlertTypeMetaData> ADDED = new ArrayList<>();
public String name;
public String label;
public String groupName;
public String howToFix;
public AlertSeverity defaultSeverity;
/*******************************************************************************
** Fluent.
*******************************************************************************/
public QAlertTypeMetaData withName(String name)
{
this.name = name;
return (this);
}
/*******************************************************************************
** Fluent.
*******************************************************************************/
public QAlertTypeMetaData withLabel(String label)
{
this.label = label;
return (this);
}
/*******************************************************************************
** Fluent.
*******************************************************************************/
public QAlertTypeMetaData withGroupName(String groupName)
{
this.groupName = groupName;
return (this);
}
/*******************************************************************************
** Fluent.
*******************************************************************************/
public QAlertTypeMetaData withHowToFix(String howToFix)
{
this.howToFix = howToFix;
return (this);
}
/*******************************************************************************
** Fluent.
*******************************************************************************/
public QAlertTypeMetaData withDefaultSeverity(AlertSeverity defaultSeverity)
{
this.defaultSeverity = defaultSeverity;
return (this);
}
/*******************************************************************************
** Add to the instance (recorded).
*******************************************************************************/
public void addSelfToInstance(QInstance qInstance)
{
ADDED.add(this);
}
}
@@ -1,104 +0,0 @@
/*
* 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.alerts.fakeapi;
import java.util.ArrayList;
import java.util.List;
/*******************************************************************************
** Test double of the Alerts producer SPI QAlerts: static calls that record what
** they were given.
*******************************************************************************/
public final class QAlerts
{
public static final List<AlertSignal> RAISED = new ArrayList<>();
public static final List<String> RESOLVED = new ArrayList<>();
public static final List<String> HEARTBEATS = new ArrayList<>();
/** When set, every call throws it (the SPI is meant not to, but the adapter must survive). */
public static RuntimeException failWith;
/*******************************************************************************
** Utility class.
*******************************************************************************/
private QAlerts()
{
}
/*******************************************************************************
** Forget everything.
*******************************************************************************/
public static void reset()
{
RAISED.clear();
RESOLVED.clear();
HEARTBEATS.clear();
failWith = null;
}
/*******************************************************************************
** Raise.
*******************************************************************************/
public static void raise(AlertSignal signal)
{
maybeFail();
RAISED.add(signal);
}
/*******************************************************************************
** Resolve.
*******************************************************************************/
public static void resolve(String alertTypeName, String dedupKey, String note)
{
maybeFail();
RESOLVED.add(alertTypeName + "|" + dedupKey + "|" + note);
}
/*******************************************************************************
** Heartbeat.
*******************************************************************************/
public static void heartbeat(String heartbeatKey)
{
maybeFail();
HEARTBEATS.add(heartbeatKey);
}
/*******************************************************************************
**
*******************************************************************************/
private static void maybeFail()
{
if(failWith != null)
{
throw (failWith);
}
}
}
@@ -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");
}
}
Loaded 100 of 103 files, more files were not shown because too many files have changed in this diff. Show more