Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
acf51b6749
|
||
|
|
a825360ba1 | ||
|
|
01db11b123 | ||
|
|
6dea78881d
|
||
|
|
89f218937a
|
||
|
|
85f4ca0a6f
|
||
|
|
e2d07b275a
|
||
|
|
48e7320228 | ||
|
|
a5297e709d | ||
|
|
f2af36ec5f | ||
|
|
054f6d90d7
|
||
|
|
5b7106b981
|
||
|
|
32034e5762 | ||
|
|
bc97d59b96
|
||
|
|
506741bd90 | ||
|
|
95b971a5fb
|
||
|
|
e56cc1b021 | ||
|
|
664f467d11 | ||
|
|
4fb182e824 | ||
|
|
1de38f03ae
|
||
|
|
cf2d027f9a | ||
|
|
714ef906fe
|
||
|
|
26c174f7bf
|
||
|
|
284f0cec8c |
No files matched your search
@@ -1,7 +1,7 @@
|
||||
version: 2.1
|
||||
|
||||
orbs:
|
||||
qqq-orb: kingsrook/qqq-orb@0.6.8
|
||||
qqq-orb: kingsrook/qqq-orb@0.6.9
|
||||
|
||||
commands:
|
||||
configure-package-install:
|
||||
@@ -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\/.*/]
|
||||
@@ -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 }}
|
||||
@@ -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.6` 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
|
||||
|
||||
@@ -5,4 +5,19 @@ All notable changes to this project are documented here. The format follows Keep
|
||||
## [0.1.0] - Unreleased
|
||||
|
||||
### Added
|
||||
- Complete host-neutral in-app help bundle with live references and a help coverage gate. Hosts can override entries with a HOST bundle.
|
||||
- Guided empty states: help bundle `help/qbit-backups.yaml` (app and all nine tables, with "How rows get created") applied by `BackupsHelp`, and a Setup card on the Backups home (plan, scope rules, storage, schedule, verification, prune bucket).
|
||||
- "Create default plan" table action on Backup plans: adds "Nightly full" for a storage bucket alias; insert-only.
|
||||
- `backupVerifyPlan`: the scheduled verification process. A plan verify cron created a scheduled job for a process that did not exist, so scheduled verification never ran.
|
||||
- Repository scaffold: `BackupsQBitConfig` and `BackupsQBitProducer` (table prefix `backups_`, permissions `backups.*`), build, CI and repository standard files.
|
||||
|
||||
### Changed
|
||||
- Pin QQQ to released 4.1.0-RC.7 and replace the temporary help loader with core HelpBundles registration.
|
||||
- Saving a plan with Use read replica on is refused (it was never read); the not-yet-available settings (read replica, native dump, incremental, PARQUET, ZSTD) say so in their field help; scheduledJobId is documented as unused.
|
||||
- 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.
|
||||
- Target QQQ 4.1.0-RC.6 and replace the fingerprint, keyset pager and reflective restore bridges with typed core APIs.
|
||||
- New manifests identify `qqq-table-schema-v1` and carry core schema descriptors; restore and content verification still accept pre-RC.6 manifests with the original fingerprint algorithm.
|
||||
- Preserve primary-key ordering, exclusive resume cursors, bounded pages and caller-owned transactions; reject non-orderable primary keys and scope filters with skip/limit.
|
||||
- Require the core `backups.restore` permission during Review when using a core restore writer, including anonymous-mode hosts; preserve supplied timestamps through `RestoreWriteFlag`.
|
||||
@@ -10,7 +10,7 @@
|
||||
## Requirements
|
||||
|
||||
- Java 21.
|
||||
- QQQ 4.1 (built against `4.1.0-RC.4`).
|
||||
- QQQ `4.1.0-RC.7` or later.
|
||||
|
||||
## Install
|
||||
|
||||
@@ -22,6 +22,10 @@ new BackupsQBitProducer()
|
||||
.produce(qInstance);
|
||||
```
|
||||
|
||||
## Setup
|
||||
|
||||
This qbit ships no DDL and seeds nothing from a changelog: its one setup value, the storage bucket alias, belongs to your application. On a fresh install open Backups, read the Setup card, and run **Create default plan** on Backup plans (it asks only for the alias of a bucket your storage resolver provides). It creates the insert-only "Nightly full" plan: whole instance, data and audit tables, JSONL and gzip, 02:00 UTC, verified Sundays 04:00 UTC. Edit any setting afterwards. Help for every table, including how its rows get created, ships in `help/qbit-backups.yaml`.
|
||||
|
||||
## Naming
|
||||
|
||||
Tables are `backups_<name>` (lowercase snake_case, backend name equal to the table name). Permissions are `backups.<action>`.
|
||||
@@ -36,8 +40,38 @@ Follow the QQQ [adoption checklist](https://github.com/QRun-IO/qqq/blob/develop/
|
||||
./mvnw verify
|
||||
```
|
||||
|
||||
See [the RC.6 migration notes](docs/RC6-MIGRATION.md) for fingerprint compatibility,
|
||||
paging guarantees and restore permissions.
|
||||
|
||||
## In-app help
|
||||
|
||||
The producer registers [help/qbit-backups.yaml](src/main/resources/help/qbit-backups.yaml)
|
||||
as a QBIT bundle. Hosts can register a `layer: HOST` bundle with matching entry keys
|
||||
and roles to override its text. Core's help startup sync applies the bundle when the
|
||||
host installs the help content table; registration also works without that table.
|
||||
|
||||
`BackupPlanScheduleTest` checks coverage with `HelpBundleAssertions`, including
|
||||
live references and optional scheduler processes. `BackupsTablesGuardrailTest`
|
||||
checks empty-state, widget and possible-value-source coverage. Run it with
|
||||
`./mvnw verify`. Keep the bundle's literal version aligned with the POM version.
|
||||
|
||||
Always use the Maven wrapper (Maven 3.9.11); Maven 3.10 cannot read the parent POM.
|
||||
|
||||
## 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.
|
||||
@@ -0,0 +1,7 @@
|
||||
# Upgrading
|
||||
|
||||
## Unreleased
|
||||
|
||||
- No schema change and no new include line. Hosts get the help text and the Setup card automatically.
|
||||
- Plans with `use_read_replica` true are now refused on the next save (the setting was never used). Turn it off.
|
||||
- Hosts that set a plan `verifyCronExpression` and a scheduler now get working scheduled verification: the `backupVerifyPlan` process exists. It verifies the plan's latest successful run through the plan's `verifyLevel` (CONTENT when unset). Expect verification rows and, on failure, alerts.
|
||||
@@ -0,0 +1,100 @@
|
||||
# RC.6 core migration (issue #19, F3)
|
||||
|
||||
The POM targets `4.1.0-RC.6`. Until that release is available, local verification
|
||||
uses `./mvnw -B verify -Dqqq.snapshot.version=4.1.0-ARCHRC6-SNAPSHOT` against the
|
||||
installed build from `qqq-wt-archrc6`. No core source changes are needed.
|
||||
|
||||
## Fingerprint compatibility
|
||||
|
||||
The algorithms are **not byte-compatible**, even for identical table metadata:
|
||||
|
||||
| Descriptor property | Pre-RC.6 backup algorithm | Core algorithm |
|
||||
| --- | --- | --- |
|
||||
| Table and key | `name`, `primaryKey` | `table`, `primaryKeyField` |
|
||||
| Fields | Map keyed by name | Sorted list with explicit `name` |
|
||||
| Field flags | `required`, `pvs`, `heavy`, `sensitive` | `isRequired`, `possibleValueSource`, `isHeavy`, `isSensitive` |
|
||||
| Unique keys | Sorted `+`-joined strings | Sorted lists of field names |
|
||||
| Associations | Sorted `name->table` strings | Objects with name, associatedTable and joinName |
|
||||
| Null backend type | JSON null | Empty string |
|
||||
| Escaping | Historical hand-written escaping | Jackson JSON escaping |
|
||||
|
||||
New table entries contain `fingerprintAlgorithm: "qqq-table-schema-v1"`, the core
|
||||
`schema` descriptor, and `fingerprint: "sha256:" + core.getHash()`. The outer
|
||||
manifest remains version 1; row parts, checksums, compression and storage paths
|
||||
are unchanged. The instance/catalog hash still combines the ordered table
|
||||
fingerprint strings, so its value changes too. New manifests require this RC.6
|
||||
reader; older readers do not understand the new descriptor shape.
|
||||
|
||||
An absent algorithm identifies the old `qbit-backups-table-v1` algorithm. The
|
||||
reader also accepts that explicit identifier. `LegacyTableFingerprint` freezes
|
||||
the original descriptor and hash implementation solely for comparison against
|
||||
existing backups; no new backup uses it. Restore and content verification select
|
||||
the declared algorithm. Unknown identifiers never match a known fingerprint.
|
||||
Restore normalizes either field shape for its existing compatibility analysis
|
||||
when a table has changed. Fingerprint equality is not required for a compatible
|
||||
schema migration, as before.
|
||||
|
||||
`src/test/resources/manifests/legacy-schema.json` freezes descriptors and hashes
|
||||
produced by the original stand-in for the blog fixture. Tests place these into a
|
||||
real backup manifest without the algorithm field, verify its content, mutate the
|
||||
live data, then restore and compare the complete content hash including dates.
|
||||
They also cover optional additions and incompatible type changes in old schemas.
|
||||
|
||||
## Paging and consistency
|
||||
|
||||
`TableReader.Keyset` delegates to core `KeysetPager`. Every query uses
|
||||
`(scope filter) AND pk > lastKey ORDER BY pk ASC LIMIT pageSize`, with no offset.
|
||||
The resume key is exclusive. Page size is a row bound, **not a JDBC fetch-size
|
||||
setting**. Core's normal bounded QueryAction path retains backend driver defaults;
|
||||
this adapter does not opt into the large-result streaming query hint or change
|
||||
JDBC fetch size. Heavy/hidden/password fields and untranslated stored values keep
|
||||
the same query settings and existing backup permission boundary.
|
||||
|
||||
Every page receives the caller's transaction. The pager does not open, commit,
|
||||
close, or replace that transaction. A test opens a real core memory snapshot,
|
||||
changes a later page's data, and proves every page still sees the original state.
|
||||
The existing BackupEngine continues to report BEST_EFFORT and reject
|
||||
SNAPSHOT_REQUIRED: adopting the consistent-read opening API at engine level is
|
||||
separate work. This migration does not claim snapshot guarantees for ordinary
|
||||
backup runs.
|
||||
|
||||
Core can stream non-orderable keys without deterministic key order; backups
|
||||
explicitly reject those keys instead. Core rejects scope filters carrying skip
|
||||
or limit, and replaces user ordering with ascending primary-key ordering. Tests
|
||||
cover OR filters, resume, full and partial pages, empty scans and invalid inputs.
|
||||
|
||||
## Restore writes
|
||||
|
||||
`StandardRestoreWriter` now constructs `RestoreWriteFlag` directly and calls
|
||||
`QBackendModuleInterface.resyncKeySequences`, consuming typed `SequenceResyncResult`
|
||||
values. Only RESYNCED results are reported as sequence changes; NOT_NEEDED and
|
||||
NOT_APPLICABLE remain normal outcomes. There is no reflection or unavailable-core
|
||||
fallback. The core flag preserves supplied dates and carries the requested
|
||||
customizer, automation, audit and event settings.
|
||||
|
||||
Core always requires an actual session permission `backups.restore` for its
|
||||
restore flag, including hosts with general core enforcement disabled. The qbit's
|
||||
anonymous-mode convenience setting does not grant that permission. Review now
|
||||
reports this requirement before executing a restore; test sessions explicitly
|
||||
hold it. Custom writers that do not use the core flag keep their existing qbit
|
||||
permission checks.
|
||||
|
||||
## HTTP operations and wire changes
|
||||
|
||||
No HTTP operations are added, removed or modified: method, path and operationId
|
||||
are unchanged (no operation list entries for this migration). The RC.6 UI API
|
||||
extension documentation was reviewed; this task introduces no Javalin route or
|
||||
middleware extension. Wire changes are confined to the manifest fields and
|
||||
fingerprint values described above, plus an earlier restore permission error in
|
||||
Review for anonymous sessions lacking `backups.restore`.
|
||||
|
||||
## Local verification (2026-10-13)
|
||||
|
||||
- `./mvnw -B clean verify -Dqqq.snapshot.version=4.1.0-ARCHRC6-SNAPSHOT`:
|
||||
BUILD SUCCESS; 158 tests, zero failures/errors/skips; both JaCoCo gates passed.
|
||||
Existing backup, restore and MinIO integration tests ran.
|
||||
- Separate Checkstyle 14.3.0 audit of all 16 changed Java files (main and test):
|
||||
zero violations, using the shared Kingsrook-derived rules with this repository's
|
||||
Apache file header. This repository has no Checkstyle execution configured in
|
||||
its POM; audit configuration and results are local under `.cache/style/`.
|
||||
- `git diff --check`: passed. No commit, push or core source modification.
|
||||
@@ -0,0 +1,7 @@
|
||||
# Backups home and record screenshots
|
||||
|
||||
Taken 2026-10-12 against `BackupsDemoServer` (src/test/.../demo, memory backend, real engine history) and the Next UI 1.0.0-RC.14 jar.
|
||||
Light and dark at 390, 600, 768, 1024, 1440, 1920 px (home) and 390, 1440 px (records).
|
||||
|
||||
- `.` : this branch (foundation base): home, plan, run, failed run, lists.
|
||||
- `with-restore-verify/` : a scratch merge of PRs #5 (verify), #6 (restore), #7 (prune/schedule) onto this branch, so verifications, a restore run and retention classes exist. Not part of this PR.
|
||||
|
After Width: | Height: | Size: 163 KiB |
|
After Width: | Height: | Size: 160 KiB |
|
After Width: | Height: | Size: 165 KiB |
|
After Width: | Height: | Size: 138 KiB |
|
After Width: | Height: | Size: 143 KiB |
|
After Width: | Height: | Size: 167 KiB |
|
After Width: | Height: | Size: 163 KiB |
|
After Width: | Height: | Size: 161 KiB |
|
After Width: | Height: | Size: 166 KiB |
|
After Width: | Height: | Size: 138 KiB |
|
After Width: | Height: | Size: 144 KiB |
|
After Width: | Height: | Size: 168 KiB |
|
After Width: | Height: | Size: 170 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 146 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 147 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 171 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 161 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 140 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 141 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 163 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 134 KiB |
|
After Width: | Height: | Size: 221 KiB |
|
After Width: | Height: | Size: 135 KiB |
|
After Width: | Height: | Size: 216 KiB |
|
After Width: | Height: | Size: 171 KiB |
|
After Width: | Height: | Size: 166 KiB |
|
After Width: | Height: | Size: 169 KiB |
|
After Width: | Height: | Size: 144 KiB |
|
After Width: | Height: | Size: 151 KiB |
|
After Width: | Height: | Size: 176 KiB |
|
After Width: | Height: | Size: 172 KiB |
|
After Width: | Height: | Size: 167 KiB |
|
After Width: | Height: | Size: 170 KiB |
|
After Width: | Height: | Size: 144 KiB |
|
After Width: | Height: | Size: 151 KiB |
|
After Width: | Height: | Size: 177 KiB |
|
After Width: | Height: | Size: 177 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 178 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 169 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 171 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 66 KiB |
@@ -18,6 +18,8 @@
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
|
||||
<properties>
|
||||
<qbit.guardrails.enforce>true</qbit.guardrails.enforce>
|
||||
<qbit.guardrails.strictUiApiRoutes>true</qbit.guardrails.strictUiApiRoutes>
|
||||
<revision>0.1.0-SNAPSHOT</revision>
|
||||
<maven.compiler.source>21</maven.compiler.source>
|
||||
<maven.compiler.target>21</maven.compiler.target>
|
||||
@@ -27,7 +29,7 @@
|
||||
<parent>
|
||||
<groupId>com.kingsrook</groupId>
|
||||
<artifactId>qbit-build-parent</artifactId>
|
||||
<version>2.0.0</version>
|
||||
<version>2.1.0</version>
|
||||
</parent>
|
||||
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
@@ -91,7 +93,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 +104,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 +194,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.7 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.7</qqq.snapshot.version>
|
||||
</properties>
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
|
||||
@@ -45,7 +45,10 @@ public class BackupsQBitConfig implements QBitConfig
|
||||
private String appName = "backups";
|
||||
private String appLabel = "Backups";
|
||||
private String parentAppName;
|
||||
private String tablePathPrefix;
|
||||
private String schedulerName;
|
||||
private String hostSchedulerDescription;
|
||||
private Boolean hostSchedulerRunsVerification = false;
|
||||
private String instanceName;
|
||||
|
||||
private Integer maxConcurrentRuns = 1;
|
||||
@@ -179,6 +182,73 @@ 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 hostSchedulerDescription: when the application runs plan crons with
|
||||
** its own tick instead of a QQQ scheduler (schedulerName), a short name for it.
|
||||
** The Setup card then treats plan crons as firing.
|
||||
*******************************************************************************/
|
||||
public String getHostSchedulerDescription()
|
||||
{
|
||||
return (hostSchedulerDescription);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for hostSchedulerDescription.
|
||||
*******************************************************************************/
|
||||
public BackupsQBitConfig withHostSchedulerDescription(String hostSchedulerDescription)
|
||||
{
|
||||
this.hostSchedulerDescription = hostSchedulerDescription;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for hostSchedulerRunsVerification: whether the host's own tick also runs
|
||||
** the verify cron (by calling the backupVerifyPlan process). False by default.
|
||||
*******************************************************************************/
|
||||
public Boolean getHostSchedulerRunsVerification()
|
||||
{
|
||||
return (hostSchedulerRunsVerification);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for hostSchedulerRunsVerification.
|
||||
*******************************************************************************/
|
||||
public BackupsQBitConfig withHostSchedulerRunsVerification(Boolean hostSchedulerRunsVerification)
|
||||
{
|
||||
this.hostSchedulerRunsVerification = hostSchedulerRunsVerification;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for schedulerName (the QQQ scheduler used for plan jobs and the SLA checker)
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
package com.kingsrook.qbits.backups;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundles;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaDataProducer;
|
||||
|
||||
@@ -119,13 +120,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 +154,22 @@ 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);
|
||||
// Registration is metadata-only; core guards startup sync when helpContent is absent.
|
||||
HelpBundles.register(qInstance, "help/qbit-backups.yaml");
|
||||
|
||||
// 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,16 @@ 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);
|
||||
setup(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 +93,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 +110,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);
|
||||
@@ -124,6 +140,50 @@ public final class BackupsHomeView
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The Setup card: what a working backup needs, whether each part is in place,
|
||||
** and the next step, linked to its table.
|
||||
*******************************************************************************/
|
||||
private static void setup(StringBuilder html, BackupsHomeData data, String prefix)
|
||||
{
|
||||
List<BackupsSetupStatus.Item> items = BackupsSetupStatus.load(data.plans());
|
||||
open(html, "Setup");
|
||||
long todo = items.stream().filter(i -> i.state() == BackupsSetupStatus.State.TODO).count();
|
||||
html.append("<p style=\"margin:0 0 .6rem;font-size:.9rem;\">")
|
||||
.append(todo == 0 ? "Everything backups need is set up." : esc(todo + (todo == 1 ? " item needs" : " items need") + " attention."))
|
||||
.append("</p><ul style=\"list-style:none;margin:0;padding:0;\">");
|
||||
for(BackupsSetupStatus.Item item : items)
|
||||
{
|
||||
String color = switch(item.state())
|
||||
{
|
||||
case OK -> "#16a34a";
|
||||
case TODO -> "#dc2626";
|
||||
case DEFAULT -> "#d97706";
|
||||
case OPTIONAL -> "#9ca3af";
|
||||
};
|
||||
String label = switch(item.state())
|
||||
{
|
||||
case OK -> "Set up";
|
||||
case TODO -> "Needs setup";
|
||||
case DEFAULT -> "Using defaults";
|
||||
case OPTIONAL -> "Optional";
|
||||
};
|
||||
html.append("<li style=\"").append(ROW).append("\"><div style=\"display:flex;flex-wrap:wrap;align-items:center;gap:.5rem .75rem;\">")
|
||||
.append("<strong>").append("<a style=\"").append(LINK).append("\" href=\"").append(esc(prefix + "/" + item.tableName())).append("\">").append(esc(item.label())).append("</a></strong>")
|
||||
.append("<span style=\"").append(CHIP).append("\"><span aria-hidden=\"true\" style=\"width:.55rem;height:.55rem;border-radius:50%;background:").append(color).append(";display:inline-block;\"></span>").append(esc(label)).append("</span>")
|
||||
.append("</div><div style=\"font-size:.9rem;margin-top:.35rem;\">").append(esc(item.detail())).append("</div>");
|
||||
if(item.nextStep() != null)
|
||||
{
|
||||
html.append("<div style=\"").append(MUTED).append("font-size:.9rem;margin-top:.2rem;\">Next: ").append(esc(item.nextStep())).append("</div>");
|
||||
}
|
||||
html.append("</li>");
|
||||
}
|
||||
html.append("</ul>");
|
||||
close(html);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Needs attention.
|
||||
*******************************************************************************/
|
||||
@@ -144,13 +204,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 +238,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 +254,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 +271,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 +290,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 +329,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 +361,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 +378,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 +398,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 +418,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 +472,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 +483,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 +516,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 +530,7 @@ public final class BackupsHomeView
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
** A number with separators.
|
||||
*******************************************************************************/
|
||||
static String number(Long value)
|
||||
{
|
||||
@@ -387,7 +540,7 @@ public final class BackupsHomeView
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
** HTML-escape.
|
||||
*******************************************************************************/
|
||||
static String esc(String s)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
/*
|
||||
* 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.util.ArrayList;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.backups.BackupsQBitConfig;
|
||||
import com.kingsrook.qbits.backups.BackupsRuntime;
|
||||
import com.kingsrook.qbits.backups.model.BackupsTables;
|
||||
import com.kingsrook.qbits.backups.processes.DefaultPlanProcess;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The Setup card on the Backups home: what a working backup needs (a plan, a
|
||||
** storage bucket that resolves, a schedule, verification, a separate prune
|
||||
** bucket), whether each is in place, and the next step. Computed from the plans
|
||||
** the home already loaded and the runtime's storage resolver; it reads nothing
|
||||
** else.
|
||||
*******************************************************************************/
|
||||
public final class BackupsSetupStatus
|
||||
{
|
||||
/*******************************************************************************
|
||||
** OK: in place. TODO: backups need it. DEFAULT: working, with a default worth a
|
||||
** look. OPTIONAL: a feature that is off until set up.
|
||||
*******************************************************************************/
|
||||
public enum State
|
||||
{
|
||||
OK, TODO, DEFAULT, OPTIONAL
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One line of the card.
|
||||
*******************************************************************************/
|
||||
public record Item(String label, String tableName, State state, String detail, String nextStep)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private BackupsSetupStatus()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Build the items from the plans.
|
||||
*******************************************************************************/
|
||||
public static List<Item> load(List<QRecord> plans)
|
||||
{
|
||||
BackupsQBitConfig config = BackupsRuntime.isInstalled() ? BackupsRuntime.getConfig() : null;
|
||||
return (load(plans, config != null && hasContent(config.getSchedulerName()), config == null ? null : config.getHostSchedulerDescription(),
|
||||
config != null && Boolean.TRUE.equals(config.getHostSchedulerRunsVerification())));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Build the items from the plans, given whether a scheduler is configured (a plan
|
||||
** cron only fires through the scheduler the qbit config names).
|
||||
*******************************************************************************/
|
||||
public static List<Item> load(List<QRecord> plans, boolean schedulerConfigured)
|
||||
{
|
||||
return (load(plans, schedulerConfigured, null, false));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Build the items from the plans. A host that runs plan crons with its own tick
|
||||
** (hostScheduler names it) has crons that fire; hostRunsVerification says whether
|
||||
** that tick also runs the verify cron.
|
||||
*******************************************************************************/
|
||||
public static List<Item> load(List<QRecord> plans, boolean schedulerConfigured, String hostScheduler, boolean hostRunsVerification)
|
||||
{
|
||||
boolean host = hasContent(hostScheduler);
|
||||
List<Item> items = new ArrayList<>();
|
||||
List<QRecord> active = plans.stream().filter(p -> Boolean.TRUE.equals(p.getValueBoolean("isActive"))).toList();
|
||||
|
||||
///////////
|
||||
// plans //
|
||||
///////////
|
||||
if(plans.isEmpty())
|
||||
{
|
||||
items.add(new Item("Backup plans", BackupsTables.PLAN, State.TODO, "No plan: nothing is backed up.",
|
||||
"Run Create default plan on Backup plans (it asks only for the storage bucket alias), or add a plan with New."));
|
||||
items.add(new Item("Plan scope rules", BackupsTables.PLAN_SCOPE, State.OPTIONAL, "None needed yet.",
|
||||
"A plan that backs up everything (scope Instance) needs no rules. Add them on a plan that is limited to apps, qbits or tables."));
|
||||
return (items);
|
||||
}
|
||||
items.add(active.isEmpty()
|
||||
? new Item("Backup plans", BackupsTables.PLAN, State.TODO, plans.size() + (plans.size() == 1 ? " plan, but it is" : " plans, but none is") + " active: nothing runs on its own.", "Turn a plan on (Active).")
|
||||
: new Item("Backup plans", BackupsTables.PLAN, State.OK, active.size() + " active plan" + (active.size() == 1 ? "" : "s") + " of " + plans.size() + ".", null));
|
||||
|
||||
/////////////////////////////////////////
|
||||
// scope rules: needed unless INSTANCE //
|
||||
/////////////////////////////////////////
|
||||
List<String> limited = plans.stream().filter(p -> p.getValueString("scopeType") != null && !"INSTANCE".equals(p.getValueString("scopeType"))).map(p -> p.getValueString("name")).toList();
|
||||
items.add(limited.isEmpty()
|
||||
? new Item("Plan scope rules", BackupsTables.PLAN_SCOPE, State.OPTIONAL, "Every plan backs up the whole instance, so no rules are needed.",
|
||||
"Add rules to a plan only to include or leave out apps, qbits, tables or roles.")
|
||||
: new Item("Plan scope rules", BackupsTables.PLAN_SCOPE, State.OK, limited.size() + (limited.size() == 1 ? " plan is" : " plans are") + " limited by scope rules: " + String.join(", ", limited) + ".",
|
||||
"Check each plan's Scope rules to see what it covers."));
|
||||
|
||||
///////////////////////////////////////////////
|
||||
// storage: every alias a plan names resolves //
|
||||
///////////////////////////////////////////////
|
||||
Set<String> aliases = new LinkedHashSet<>();
|
||||
for(QRecord plan : plans)
|
||||
{
|
||||
for(String field : List.of("storageBucketAlias", "pruneBucketAlias"))
|
||||
{
|
||||
if(plan.getValueString(field) != null)
|
||||
{
|
||||
aliases.add(plan.getValueString(field));
|
||||
}
|
||||
}
|
||||
}
|
||||
List<String> broken = new ArrayList<>();
|
||||
for(String alias : aliases)
|
||||
{
|
||||
try
|
||||
{
|
||||
BackupsRuntime.storageFor(alias);
|
||||
}
|
||||
catch(RuntimeException e)
|
||||
{
|
||||
broken.add(alias);
|
||||
}
|
||||
}
|
||||
items.add(broken.isEmpty()
|
||||
? new Item("Storage", BackupsTables.PLAN, State.OK, aliases.size() + " storage bucket alias" + (aliases.size() == 1 ? "" : "es") + " in use, and each resolves.", "Run Test destination on a plan to prove it can write.")
|
||||
: new Item("Storage", BackupsTables.PLAN, State.TODO, "These aliases do not resolve to a bucket: " + String.join(", ", broken) + ".",
|
||||
"Fix the alias on the plan, or add the bucket to the application's storage resolver."));
|
||||
|
||||
/////////////////////////////////////////
|
||||
// schedule: an active plan has a cron //
|
||||
/////////////////////////////////////////
|
||||
long scheduled = active.stream().filter(p -> hasContent(p.getValueString("cronExpression"))).count();
|
||||
String noScheduler = "No scheduler is configured (schedulerName on the qbit config), so plan crons are saved but never fire";
|
||||
items.add(!schedulerConfigured && !host
|
||||
? new Item("Schedule", BackupsTables.PLAN, State.TODO, noScheduler + ". Backups run only when someone clicks Run backup now.",
|
||||
"Set schedulerName on the qbit config (or run your own tick if the application does), then give each plan a cron expression.")
|
||||
: scheduled == 0
|
||||
? new Item("Schedule", BackupsTables.PLAN, State.TODO, "No active plan has a schedule: backups run only when someone clicks Run backup now.", "Set a Cron expression on a plan (for example " + DefaultPlanProcess.CRON + ").")
|
||||
: new Item("Schedule", BackupsTables.PLAN, State.OK, scheduled + " active plan" + (scheduled == 1 ? " runs" : "s run") + " on a schedule"
|
||||
+ (host && !schedulerConfigured ? ", run by " + hostScheduler + " (the application scheduler)." : "."), null));
|
||||
|
||||
//////////////////
|
||||
// verification //
|
||||
//////////////////
|
||||
long verified = active.stream().filter(p -> hasContent(p.getValueString("verifyCronExpression"))).count();
|
||||
items.add(host && !schedulerConfigured && !hostRunsVerification
|
||||
? new Item("Verification", BackupsTables.VERIFICATION, State.OPTIONAL, "Not run by " + hostScheduler + ": verify cron expressions are saved but nothing fires them.",
|
||||
"Run Verify backup on a run by hand, or have the application scheduler run the backupVerifyPlan process for each plan.")
|
||||
: !schedulerConfigured && !host
|
||||
? new Item("Verification", BackupsTables.VERIFICATION, State.TODO, noScheduler + ", so no verification is scheduled.",
|
||||
"Set schedulerName on the qbit config, then a verify cron expression on a plan; or run Verify backup on a run by hand.")
|
||||
: verified == 0
|
||||
? new Item("Verification", BackupsTables.VERIFICATION, State.OPTIONAL, "No plan verifies its backups on a schedule.",
|
||||
"Set a Verify cron expression on a plan, or run Verify backup on a run. A backup nobody has restored is a guess.")
|
||||
: new Item("Verification", BackupsTables.VERIFICATION, State.OK, verified + " active plan" + (verified == 1 ? " verifies" : "s verify") + " its latest backup on a schedule.", null));
|
||||
|
||||
////////////////////////////////////////////
|
||||
// prune bucket: separate delete authority //
|
||||
////////////////////////////////////////////
|
||||
long noPrune = plans.stream().filter(p -> !hasContent(p.getValueString("pruneBucketAlias"))).count();
|
||||
items.add(noPrune == 0
|
||||
? new Item("Prune bucket", BackupsTables.PLAN, State.OK, "Every plan prunes through its own delete-capable bucket alias.", null)
|
||||
: new Item("Prune bucket", BackupsTables.PLAN, State.DEFAULT, noPrune + (noPrune == 1 ? " plan has" : " plans have") + " no prune bucket: Prune uses the backup bucket, so one key can both write and delete backups.",
|
||||
"Give the plan a separate delete-capable Prune bucket alias."));
|
||||
return (items);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static boolean hasContent(String value)
|
||||
{
|
||||
return (value != null && !value.isBlank());
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,7 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
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.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
|
||||
import com.kingsrook.qqq.middleware.health.HealthIndicator;
|
||||
import com.kingsrook.qqq.middleware.health.model.metadata.HealthCheckResult;
|
||||
import com.kingsrook.qqq.middleware.health.model.metadata.HealthStatus;
|
||||
@@ -79,7 +80,7 @@ public class BackupsHealthIndicator implements HealthIndicator
|
||||
boolean ownsContext = QContext.getQInstance() == null;
|
||||
if(ownsContext)
|
||||
{
|
||||
QContext.init(qInstance, new QSession());
|
||||
QContext.init(qInstance, systemSession(qInstance));
|
||||
}
|
||||
try
|
||||
{
|
||||
@@ -117,4 +118,16 @@ public class BackupsHealthIndicator implements HealthIndicator
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The session for a health request that has no QQQ context. It must be the
|
||||
** core system user: token-based authentication modules (OAuth2, Auth0)
|
||||
** reject any other tokenless session, which failed the check on such hosts.
|
||||
*******************************************************************************/
|
||||
static QSession systemSession(QInstance qInstance)
|
||||
{
|
||||
return (new QSystemUserSession(qInstance));
|
||||
}
|
||||
}
|
||||
@@ -137,6 +137,7 @@ public record BackupManifest(
|
||||
String backend,
|
||||
String primaryKey,
|
||||
String fingerprint,
|
||||
String fingerprintAlgorithm,
|
||||
Map<String, Object> schema,
|
||||
String filter,
|
||||
List<ExcludedField> excludedFields,
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
/*
|
||||
* 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.manifest;
|
||||
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.TreeMap;
|
||||
import com.kingsrook.qbits.backups.codec.HashingOutputStream;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.Association;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.UniqueKey;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Frozen pre-RC.6 fingerprint algorithm, used only to read existing manifests.
|
||||
** Keep its descriptor and escaping byte-compatible with the original writer.
|
||||
*******************************************************************************/
|
||||
final class LegacyTableFingerprint
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private LegacyTableFingerprint()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The descriptor of a table (sorted fields; plain maps and lists, so it is also
|
||||
** what goes in the manifest schema).
|
||||
*******************************************************************************/
|
||||
public static Map<String, Object> descriptor(QTableMetaData table, String backendType)
|
||||
{
|
||||
Map<String, Object> d = new LinkedHashMap<>();
|
||||
d.put("name", table.getName());
|
||||
d.put("primaryKey", table.getPrimaryKeyField());
|
||||
d.put("backendType", backendType);
|
||||
|
||||
Map<String, Object> fields = new TreeMap<>();
|
||||
for(QFieldMetaData field : new TreeMap<>(table.getFields()).values())
|
||||
{
|
||||
Map<String, Object> f = new LinkedHashMap<>();
|
||||
f.put("type", field.getType().name());
|
||||
f.put("required", field.getIsRequired());
|
||||
f.put("maxLength", field.getMaxLength());
|
||||
f.put("pvs", field.getPossibleValueSourceName());
|
||||
f.put("heavy", field.getIsHeavy());
|
||||
f.put("sensitive", field.getIsSensitive());
|
||||
fields.put(field.getName(), f);
|
||||
}
|
||||
d.put("fields", fields);
|
||||
|
||||
List<String> uniqueKeys = new ArrayList<>();
|
||||
if(table.getUniqueKeys() != null)
|
||||
{
|
||||
for(UniqueKey key : table.getUniqueKeys())
|
||||
{
|
||||
uniqueKeys.add(String.join("+", key.getFieldNames().stream().sorted().toList()));
|
||||
}
|
||||
}
|
||||
uniqueKeys.sort(String::compareTo);
|
||||
d.put("uniqueKeys", uniqueKeys);
|
||||
|
||||
List<String> associations = new ArrayList<>();
|
||||
if(table.getAssociations() != null)
|
||||
{
|
||||
for(Association association : table.getAssociations())
|
||||
{
|
||||
associations.add(association.getName() + "->" + association.getAssociatedTableName());
|
||||
}
|
||||
}
|
||||
associations.sort(String::compareTo);
|
||||
d.put("associations", associations);
|
||||
return (d);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The fingerprint of a table: sha256:hex of the canonical descriptor.
|
||||
*******************************************************************************/
|
||||
public static String of(QTableMetaData table, String backendType)
|
||||
{
|
||||
return (fingerprintOf(descriptor(table, backendType)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The fingerprint of a descriptor (maps sorted by key at every level, no whitespace).
|
||||
*******************************************************************************/
|
||||
public static String fingerprintOf(Map<String, Object> descriptor)
|
||||
{
|
||||
StringBuilder sb = new StringBuilder();
|
||||
canonical(descriptor, sb);
|
||||
return ("sha256:" + HashingOutputStream.sha256Hex(sb.toString().getBytes(StandardCharsets.UTF_8)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@SuppressWarnings("unchecked")
|
||||
private static void canonical(Object o, StringBuilder sb)
|
||||
{
|
||||
if(o == null)
|
||||
{
|
||||
sb.append("null");
|
||||
}
|
||||
else if(o instanceof Map<?, ?> map)
|
||||
{
|
||||
sb.append('{');
|
||||
boolean first = true;
|
||||
for(Map.Entry<String, Object> e : new TreeMap<>((Map<String, Object>) map).entrySet())
|
||||
{
|
||||
if(!first)
|
||||
{
|
||||
sb.append(',');
|
||||
}
|
||||
first = false;
|
||||
sb.append('"').append(e.getKey()).append("\":");
|
||||
canonical(e.getValue(), sb);
|
||||
}
|
||||
sb.append('}');
|
||||
}
|
||||
else if(o instanceof List<?> list)
|
||||
{
|
||||
sb.append('[');
|
||||
for(int i = 0; i < list.size(); i++)
|
||||
{
|
||||
if(i > 0)
|
||||
{
|
||||
sb.append(',');
|
||||
}
|
||||
canonical(list.get(i), sb);
|
||||
}
|
||||
sb.append(']');
|
||||
}
|
||||
else if(o instanceof Number || o instanceof Boolean)
|
||||
{
|
||||
sb.append(o);
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.append('"').append(String.valueOf(o).replace("\\", "\\\\").replace("\"", "\\\"")).append('"');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,28 +18,23 @@ package com.kingsrook.qbits.backups.manifest;
|
||||
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.TreeMap;
|
||||
import com.kingsrook.qbits.backups.codec.HashingOutputStream;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.Association;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.UniqueKey;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.TableSchemaFingerprint;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 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
|
||||
** feature/schema-fingerprint). When that ships in the next 4.1.x RC, this class
|
||||
** delegates to it and the descriptor below is kept only for reading old manifests.
|
||||
** Labels, adornments and help text are excluded, so cosmetic changes do not count.
|
||||
** Core schema fingerprints with read compatibility for pre-RC.6 manifests.
|
||||
*******************************************************************************/
|
||||
public final class TableFingerprints
|
||||
{
|
||||
public static final String CORE_ALGORITHM = "qqq-table-schema-v1";
|
||||
public static final String LEGACY_ALGORITHM = "qbit-backups-table-v1";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
@@ -51,134 +46,83 @@ public final class TableFingerprints
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The descriptor of a table (sorted fields; plain maps and lists, so it is also
|
||||
** what goes in the manifest schema).
|
||||
** The core descriptor stored in new manifests.
|
||||
*******************************************************************************/
|
||||
public static Map<String, Object> descriptor(QTableMetaData table, String backendType)
|
||||
{
|
||||
Map<String, Object> d = new LinkedHashMap<>();
|
||||
d.put("name", table.getName());
|
||||
d.put("primaryKey", table.getPrimaryKeyField());
|
||||
d.put("backendType", backendType);
|
||||
|
||||
Map<String, Object> fields = new TreeMap<>();
|
||||
for(QFieldMetaData field : new TreeMap<>(table.getFields()).values())
|
||||
{
|
||||
Map<String, Object> f = new LinkedHashMap<>();
|
||||
f.put("type", field.getType().name());
|
||||
f.put("required", field.getIsRequired());
|
||||
f.put("maxLength", field.getMaxLength());
|
||||
f.put("pvs", field.getPossibleValueSourceName());
|
||||
f.put("heavy", field.getIsHeavy());
|
||||
f.put("sensitive", field.getIsSensitive());
|
||||
fields.put(field.getName(), f);
|
||||
}
|
||||
d.put("fields", fields);
|
||||
|
||||
List<String> uniqueKeys = new ArrayList<>();
|
||||
if(table.getUniqueKeys() != null)
|
||||
{
|
||||
for(UniqueKey key : table.getUniqueKeys())
|
||||
{
|
||||
uniqueKeys.add(String.join("+", key.getFieldNames().stream().sorted().toList()));
|
||||
}
|
||||
}
|
||||
uniqueKeys.sort(String::compareTo);
|
||||
d.put("uniqueKeys", uniqueKeys);
|
||||
|
||||
List<String> associations = new ArrayList<>();
|
||||
if(table.getAssociations() != null)
|
||||
{
|
||||
for(Association association : table.getAssociations())
|
||||
{
|
||||
associations.add(association.getName() + "->" + association.getAssociatedTableName());
|
||||
}
|
||||
}
|
||||
associations.sort(String::compareTo);
|
||||
d.put("associations", associations);
|
||||
return (d);
|
||||
return (TableSchemaFingerprint.of(table, backendType).getDescriptor());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The fingerprint of a table: sha256:hex of the canonical descriptor.
|
||||
** Retain the manifest's sha256 prefix around the core hash.
|
||||
*******************************************************************************/
|
||||
public static String of(QTableMetaData table, String backendType)
|
||||
{
|
||||
return (fingerprintOf(descriptor(table, backendType)));
|
||||
return ("sha256:" + TableSchemaFingerprint.of(table, backendType).getHash());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The fingerprint of a descriptor (maps sorted by key at every level, no whitespace).
|
||||
** An absent algorithm identifies manifests written before RC.6. Unknown
|
||||
** algorithms must not silently fall back to a different fingerprint.
|
||||
*******************************************************************************/
|
||||
public static String fingerprintOf(Map<String, Object> descriptor)
|
||||
public static boolean matches(BackupManifest.TableEntry entry, QTableMetaData table, String backendType)
|
||||
{
|
||||
StringBuilder sb = new StringBuilder();
|
||||
canonical(descriptor, sb);
|
||||
return ("sha256:" + HashingOutputStream.sha256Hex(sb.toString().getBytes(StandardCharsets.UTF_8)));
|
||||
if(entry.fingerprint() == null)
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
String algorithm = entry.fingerprintAlgorithm();
|
||||
if(algorithm == null || LEGACY_ALGORITHM.equals(algorithm))
|
||||
{
|
||||
return (entry.fingerprint().equals(LegacyTableFingerprint.of(table, backendType)));
|
||||
}
|
||||
return (CORE_ALGORITHM.equals(algorithm) && entry.fingerprint().equals(of(table, backendType)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The fingerprint of a whole instance's tables: a hash of the table fingerprints.
|
||||
** Normalize both descriptor versions to field-name maps for restore's
|
||||
** compatibility analysis. Core fields use a list and explicit flag names.
|
||||
*******************************************************************************/
|
||||
@SuppressWarnings("unchecked")
|
||||
public static Map<String, Object> fields(Map<String, Object> descriptor)
|
||||
{
|
||||
Object fields = descriptor == null ? null : descriptor.get("fields");
|
||||
if(fields instanceof Map<?, ?> map)
|
||||
{
|
||||
return ((Map<String, Object>) map);
|
||||
}
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
if(fields instanceof List<?> list)
|
||||
{
|
||||
for(Object item : list)
|
||||
{
|
||||
Map<String, Object> field = (Map<String, Object>) item;
|
||||
Map<String, Object> normalized = new LinkedHashMap<>();
|
||||
normalized.put("type", field.get("type"));
|
||||
normalized.put("required", field.get("isRequired"));
|
||||
normalized.put("maxLength", field.get("maxLength"));
|
||||
normalized.put("pvs", field.get("possibleValueSource"));
|
||||
normalized.put("heavy", field.get("isHeavy"));
|
||||
normalized.put("sensitive", field.get("isSensitive"));
|
||||
result.put((String) field.get("name"), normalized);
|
||||
}
|
||||
}
|
||||
return (result);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The instance fingerprint remains a hash of the ordered table fingerprints.
|
||||
*******************************************************************************/
|
||||
public static String combine(List<String> tableFingerprints)
|
||||
{
|
||||
return ("sha256:" + HashingOutputStream.sha256Hex(String.join("\n", tableFingerprints).getBytes(StandardCharsets.UTF_8)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@SuppressWarnings("unchecked")
|
||||
private static void canonical(Object o, StringBuilder sb)
|
||||
{
|
||||
if(o == null)
|
||||
{
|
||||
sb.append("null");
|
||||
}
|
||||
else if(o instanceof Map<?, ?> map)
|
||||
{
|
||||
sb.append('{');
|
||||
boolean first = true;
|
||||
for(Map.Entry<String, Object> e : new TreeMap<>((Map<String, Object>) map).entrySet())
|
||||
{
|
||||
if(!first)
|
||||
{
|
||||
sb.append(',');
|
||||
}
|
||||
first = false;
|
||||
sb.append('"').append(e.getKey()).append("\":");
|
||||
canonical(e.getValue(), sb);
|
||||
}
|
||||
sb.append('}');
|
||||
}
|
||||
else if(o instanceof List<?> list)
|
||||
{
|
||||
sb.append('[');
|
||||
for(int i = 0; i < list.size(); i++)
|
||||
{
|
||||
if(i > 0)
|
||||
{
|
||||
sb.append(',');
|
||||
}
|
||||
canonical(list.get(i), sb);
|
||||
}
|
||||
sb.append(']');
|
||||
}
|
||||
else if(o instanceof Number || o instanceof Boolean)
|
||||
{
|
||||
sb.append(o);
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.append('"').append(String.valueOf(o).replace("\\", "\\\\").replace("\"", "\\\"")).append('"');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -31,8 +31,14 @@ 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.help.QHelpContent;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinOn;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.joins.QJoinMetaData;
|
||||
@@ -70,7 +76,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 +216,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);
|
||||
}
|
||||
|
||||
|
||||
@@ -337,8 +447,8 @@ public final class BackupsTables
|
||||
.withField(new QFieldMetaData("pruneBucketAlias", QFieldType.STRING).withMaxLength(100))
|
||||
.withField(new QFieldMetaData("prefix", QFieldType.STRING).withMaxLength(255))
|
||||
.withField(new QFieldMetaData("critical", QFieldType.BOOLEAN).withDefaultValue(false))
|
||||
.withField(enumField("format", "format", "JSONL"))
|
||||
.withField(enumField("compression", "compression", "GZIP"))
|
||||
.withField(enumField("format", "format", "JSONL").withHelpContent(new QHelpContent("JSONL, CSV and JSONL_AND_CSV are available. PARQUET is not available yet and is refused when the plan is saved.")))
|
||||
.withField(enumField("compression", "compression", "GZIP").withHelpContent(new QHelpContent("GZIP and NONE are available. ZSTD is not available yet and is refused when the plan is saved.")))
|
||||
.withField(enumField("encryptionMode", "encryption_mode", "NONE"))
|
||||
.withField(new QFieldMetaData("encryptionKeyRef", QFieldType.STRING).withMaxLength(100))
|
||||
.withField(enumField("sensitiveFieldPolicy", "sensitive_policy", "EXCLUDE"))
|
||||
@@ -346,12 +456,15 @@ public final class BackupsTables
|
||||
.withField(enumField("fileObjectPolicy", "file_policy", "COPY"))
|
||||
.withField(enumField("fileMissingPolicy", "file_missing_policy", "WARN"))
|
||||
.withField(enumField("consistency", "consistency", "SNAPSHOT_PREFERRED"))
|
||||
.withField(new QFieldMetaData("useReadReplica", QFieldType.BOOLEAN).withDefaultValue(false))
|
||||
.withField(enumField("incrementalMode", "incremental_mode", "FULL"))
|
||||
.withField(enumField("nativeDump", "native_dump", "NONE"))
|
||||
.withField(new QFieldMetaData("useReadReplica", QFieldType.BOOLEAN).withDefaultValue(false)
|
||||
.withHelpContent(new QHelpContent("Not available yet: backups always read from the application's own backend. Leave this off; turning it on is refused when the plan is saved.")))
|
||||
.withField(enumField("incrementalMode", "incremental_mode", "FULL")
|
||||
.withHelpContent(new QHelpContent("Only FULL is available yet. CHANGED_SINCE_LAST is not available yet and is refused when the plan is saved.")))
|
||||
.withField(enumField("nativeDump", "native_dump", "NONE")
|
||||
.withHelpContent(new QHelpContent("Only NONE is available yet. Native database dumps (PG_DUMP_CUSTOM) are not available yet and are refused when the plan is saved.")))
|
||||
.withField(new QFieldMetaData("cronExpression", QFieldType.STRING).withMaxLength(100))
|
||||
.withField(new QFieldMetaData("cronTimeZoneId", QFieldType.STRING).withMaxLength(60))
|
||||
.withField(roInt("scheduledJobId"))
|
||||
.withField(roInt("scheduledJobId").withHelpContent(new QHelpContent("Not used: a plan's schedules are scheduled jobs found by their foreign key (backups_plan for the backup, backups_plan_verify for the verification), so look there.")))
|
||||
.withField(new QFieldMetaData("slaHours", QFieldType.INTEGER).withDefaultValue(26))
|
||||
.withField(new QFieldMetaData("verifySlaDays", QFieldType.INTEGER).withDefaultValue(8))
|
||||
.withField(new QFieldMetaData("verifyFileSamplePercent", QFieldType.INTEGER).withDefaultValue(100))
|
||||
@@ -404,8 +517,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 +622,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))
|
||||
|
||||
@@ -48,7 +48,7 @@ import com.kingsrook.qqq.backend.core.model.statusmessages.QWarningMessage;
|
||||
public class BackupPlanTableCustomizer implements TableCustomizerInterface
|
||||
{
|
||||
private static final List<String> CONFIGURATION_FIELDS = List.of("planKey", "isActive", "scopeType", "includeRoles", "storageBucketAlias", "pruneBucketAlias",
|
||||
"format", "compression", "encryptionMode", "encryptionKeyRef", "sensitiveFieldPolicy", "incrementalMode", "nativeDump", "cronExpression", "cronTimeZoneId",
|
||||
"format", "compression", "encryptionMode", "encryptionKeyRef", "sensitiveFieldPolicy", "incrementalMode", "nativeDump", "useReadReplica", "cronExpression", "cronTimeZoneId",
|
||||
"verifyCronExpression", "retainDaily", "retainWeekly", "retainMonthly", "retainYearly", "retainMinimum", "objectLockMode", "objectLockDays",
|
||||
"slaHours", "pageSize", "ownerUserId");
|
||||
|
||||
|
||||
@@ -176,6 +176,10 @@ public final class BackupPlanValidator
|
||||
{
|
||||
errors.add("Native database dumps are not available yet. Use NONE.");
|
||||
}
|
||||
if(settings.bool("useReadReplica", false))
|
||||
{
|
||||
errors.add("Reading from a replica is not available yet: backups read from the application's own backend. Turn Use read replica off.");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -72,6 +72,7 @@ public final class BackupsProcesses
|
||||
List<QProcessMetaData> processes = new ArrayList<>();
|
||||
processes.add(permission(etl(RUN_PLAN, "Run backup now", BackupsTables.PLAN, RunPlanTransform.class, RunPlanLoad.class,
|
||||
"This is a preview of what the backup will include. Nothing is written until you continue."), BackupsPermissions.RUN));
|
||||
processes.add(DefaultPlanProcess.process());
|
||||
processes.addAll(VerifyProcesses.processes());
|
||||
processes.addAll(RestoreProcesses.processes());
|
||||
processes.addAll(CatalogProcesses.processes());
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
/*
|
||||
* 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.processes;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.backups.model.BackupsTables;
|
||||
import com.kingsrook.qbits.backups.permissions.BackupsPermissions;
|
||||
import com.kingsrook.qbits.backups.plan.BackupPlanValidator;
|
||||
import com.kingsrook.qbits.backups.run.BackupCatalog;
|
||||
import com.kingsrook.qbits.backups.scope.PlanSettings;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.Status;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.help.QHelpContent;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QComponentType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendComponentMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendStepMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractExtractStep;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractLoadStep;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractTransformStep;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ProcessSummaryProviderInterface;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** "Create default plan": a table action on backups_plan that adds one working
|
||||
** plan, "Nightly full", so a fresh install has something to run. The only thing
|
||||
** it asks for is the storage bucket alias, because that is the one value the qbit
|
||||
** cannot know (it names a bucket the host's storage resolver provides). Review
|
||||
** shows the plan that will be created and checks it against the same rules a
|
||||
** hand-made plan must pass; Result links the new plan.
|
||||
**
|
||||
** Insert-only: when a plan with the key nightly-full exists (edited or not), nothing
|
||||
** is created or changed. The plan goes through the normal insert, so the owner is
|
||||
** the user running the action and the schedule syncs like any other plan.
|
||||
*******************************************************************************/
|
||||
public final class DefaultPlanProcess
|
||||
{
|
||||
public static final String NAME = "backupsPlanCreateDefault";
|
||||
public static final String FIELD_ALIAS = "storageBucketAlias";
|
||||
public static final String PLAN_KEY = "nightly-full";
|
||||
public static final String PLAN_NAME = "Nightly full";
|
||||
public static final String CRON = "0 0 2 * * ?";
|
||||
public static final String VERIFY_CRON = "0 0 4 ? * SUN";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private DefaultPlanProcess()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The process: a table-level action with one question (the bucket alias).
|
||||
*******************************************************************************/
|
||||
public static QProcessMetaData process()
|
||||
{
|
||||
Map<String, Serializable> values = new HashMap<>();
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_SOURCE_TABLE, BackupsTables.PLAN);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_DESTINATION_TABLE, BackupsTables.PLAN);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_SUPPORTS_FULL_VALIDATION, true);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_DO_FULL_VALIDATION, true);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_PREVIEW_MESSAGE, "This plan will be created. Nothing is written until you continue. You can edit every setting afterwards.");
|
||||
QProcessMetaData process = StreamedETLWithFrontendProcess.defineProcessMetaData(Extract.class, Transform.class, Load.class, values);
|
||||
process.setName(NAME);
|
||||
process.setLabel("Create default plan");
|
||||
process.setIcon(new QIcon("playlist_add"));
|
||||
process.setTableName(BackupsTables.PLAN);
|
||||
process.withStep(0, new QFrontendStepMetaData()
|
||||
.withName("input")
|
||||
.withLabel("Where should the backups go?")
|
||||
.withComponent(new QFrontendComponentMetaData().withType(QComponentType.EDIT_FORM))
|
||||
.withFormField(new QFieldMetaData(FIELD_ALIAS, QFieldType.STRING).withLabel("Storage bucket alias").withIsRequired(true)
|
||||
.withHelpContent(new QHelpContent("The alias of a bucket your application provides to Backups (never a key or an endpoint). It becomes the plan's Storage bucket alias."))));
|
||||
process.setMinInputRecords(0);
|
||||
process.setPermissionRules(BackupsTables.permissionRules());
|
||||
return (BackupsProcesses.permission(process, BackupsPermissions.ADMIN));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The values of the default plan for a bucket alias. Every setting is explicit
|
||||
** so the plan does not depend on the backend applying field defaults.
|
||||
*******************************************************************************/
|
||||
public static Map<String, Serializable> planValues(String alias)
|
||||
{
|
||||
Map<String, Serializable> plan = new LinkedHashMap<>();
|
||||
plan.put("name", PLAN_NAME);
|
||||
plan.put("planKey", PLAN_KEY);
|
||||
plan.put("description", "Every night: all data and audit tables of the application, and the files they reference, to the storage bucket. Edit any setting on this plan.");
|
||||
plan.put("isActive", true);
|
||||
plan.put("scopeType", "INSTANCE");
|
||||
plan.put("includeRoles", "DATA,AUDIT");
|
||||
plan.put("storageBucketAlias", alias);
|
||||
plan.put("critical", true);
|
||||
plan.put("format", "JSONL");
|
||||
plan.put("compression", "GZIP");
|
||||
plan.put("encryptionMode", "NONE");
|
||||
plan.put("sensitiveFieldPolicy", "EXCLUDE");
|
||||
plan.put("heavyFieldPolicy", "INLINE");
|
||||
plan.put("fileObjectPolicy", "COPY");
|
||||
plan.put("fileMissingPolicy", "WARN");
|
||||
plan.put("consistency", "SNAPSHOT_PREFERRED");
|
||||
plan.put("useReadReplica", false);
|
||||
plan.put("incrementalMode", "FULL");
|
||||
plan.put("nativeDump", "NONE");
|
||||
plan.put("cronExpression", CRON);
|
||||
plan.put("cronTimeZoneId", "UTC");
|
||||
plan.put("slaHours", 26);
|
||||
plan.put("verifySlaDays", 8);
|
||||
plan.put("verifyFileSamplePercent", 100);
|
||||
plan.put("verifyCronExpression", VERIFY_CRON);
|
||||
plan.put("verifyLevel", "CONTENT");
|
||||
plan.put("retainDaily", 7);
|
||||
plan.put("retainWeekly", 4);
|
||||
plan.put("retainMonthly", 12);
|
||||
plan.put("retainYearly", 0);
|
||||
plan.put("retainMinimum", 3);
|
||||
plan.put("objectLockMode", "NONE");
|
||||
plan.put("maxDurationMinutes", 240);
|
||||
plan.put("pageSize", 5000);
|
||||
plan.put("targetPartBytes", 268435456L);
|
||||
return (plan);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether the default plan already exists.
|
||||
*******************************************************************************/
|
||||
private static boolean exists() throws QException
|
||||
{
|
||||
return (!new BackupCatalog().where(BackupsTables.PLAN, "planKey", PLAN_KEY).isEmpty());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Extract: the plan to create, or nothing when it exists already.
|
||||
*******************************************************************************/
|
||||
public static class Extract extends AbstractExtractStep
|
||||
{
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
String alias = input.getValueString(FIELD_ALIAS);
|
||||
if(alias == null || alias.isBlank())
|
||||
{
|
||||
throw (new QUserFacingException("Enter the storage bucket alias the backups should go to."));
|
||||
}
|
||||
if(!exists())
|
||||
{
|
||||
QRecord record = new QRecord().withTableName(BackupsTables.PLAN);
|
||||
record.setValues(new LinkedHashMap<>(planValues(alias.strip())));
|
||||
getRecordPipe().addRecord(record);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Review: the plan, checked against the rules a plan must pass.
|
||||
*******************************************************************************/
|
||||
public static class Transform extends AbstractTransformStep implements ProcessSummaryProviderInterface
|
||||
{
|
||||
private final ProcessSummaryLine create = BackupsProcesses.line(Status.OK, "plan will be created", "plans will be created", "plan was created", "plans were created");
|
||||
private final ProcessSummaryLine invalid = BackupsProcesses.line(Status.ERROR, "plan cannot be created", "plans cannot be created", "plan could not be created", "plans could not be created");
|
||||
private final List<String> problems = new ArrayList<>();
|
||||
private boolean sawRecord;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
for(QRecord record : input.getRecords())
|
||||
{
|
||||
sawRecord = true;
|
||||
BackupPlanValidator.Result result = BackupPlanValidator.validate(PlanSettings.of(record), null, false, List.of());
|
||||
boolean nameTaken = !new BackupCatalog().where(BackupsTables.PLAN, "name", record.getValueString("name")).isEmpty();
|
||||
if(nameTaken)
|
||||
{
|
||||
problems.add("A plan named " + record.getValueString("name") + " already exists. Rename it or edit that plan.");
|
||||
}
|
||||
if(result.ok() && !nameTaken)
|
||||
{
|
||||
create.incrementCountAndAddPrimaryKey(record.getValueString("planKey"));
|
||||
output.getRecords().add(record);
|
||||
}
|
||||
else
|
||||
{
|
||||
invalid.incrementCount();
|
||||
problems.addAll(result.errors());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
|
||||
{
|
||||
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
|
||||
create.addSelfToListIfAnyCount(lines);
|
||||
invalid.setBulletsOfText(new ArrayList<>(problems));
|
||||
invalid.addSelfToListIfAnyCount(lines);
|
||||
if(!sawRecord)
|
||||
{
|
||||
lines.add(new ProcessSummaryLine(Status.INFO, null, "A plan with the key " + PLAN_KEY + " already exists, so nothing will be created. Edit that plan instead."));
|
||||
}
|
||||
return (lines);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Load: insert through the normal insert (validation, owner, schedule).
|
||||
*******************************************************************************/
|
||||
public static class Load extends AbstractLoadStep implements ProcessSummaryProviderInterface
|
||||
{
|
||||
private final ProcessSummaryLine created = BackupsProcesses.line(Status.OK, "plan will be created", "plans will be created", "plan was created", "plans were created");
|
||||
private final ProcessSummaryLine failed = BackupsProcesses.line(Status.ERROR, "plan could not be created", "plans could not be created", "plan could not be created", "plans could not be created");
|
||||
private final List<String> problems = new ArrayList<>();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
InsertInput insert = new InsertInput(BackupsTables.PLAN);
|
||||
insert.setRecords(input.getRecords());
|
||||
InsertOutput inserted = new InsertAction().execute(insert);
|
||||
for(QRecord record : inserted.getRecords())
|
||||
{
|
||||
if(record.getErrors() == null || record.getErrors().isEmpty())
|
||||
{
|
||||
created.incrementCountAndAddPrimaryKey(record.getValue("id"));
|
||||
output.getRecords().add(record);
|
||||
}
|
||||
else
|
||||
{
|
||||
failed.incrementCount();
|
||||
record.getErrors().forEach(error -> problems.add(error.getMessage()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
|
||||
{
|
||||
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
|
||||
created.addSelfToListIfAnyCount(lines);
|
||||
failed.setBulletsOfText(new ArrayList<>(problems));
|
||||
failed.addSelfToListIfAnyCount(lines);
|
||||
return (lines);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,7 @@ public final class LifecycleProcesses
|
||||
{
|
||||
List<QProcessMetaData> processes = new ArrayList<>();
|
||||
processes.add(BackupScheduledRunProcess.processMetaData());
|
||||
processes.add(com.kingsrook.qbits.backups.schedule.BackupScheduledVerifyProcess.processMetaData());
|
||||
processes.addAll(CancelRunProcess.processes());
|
||||
processes.add(ResumeRunProcess.process());
|
||||
processes.add(TestDestinationProcess.process());
|
||||
|
||||