Compare commits
28
Commits
v0.1.0-RC.1
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a20d36c108
|
||
|
|
a992133053
|
||
|
|
4b3ca697e5
|
||
|
|
1a800da73d
|
||
|
|
17a094d427
|
||
|
|
009e940ee6
|
||
|
|
b6c73cc3a5
|
||
|
|
5d4b212b56
|
||
|
|
1828c7f9d8
|
||
|
|
055e33c99b
|
||
|
|
912f1dc3a9
|
||
|
|
cd995254fd
|
||
|
|
da332ce0fb | ||
|
|
22a40ad295 | ||
|
|
c92df228b9 | ||
|
|
5af79ae05d | ||
|
|
469e727884 | ||
|
|
6434453666 | ||
|
|
f2584be8c5 | ||
|
|
43d65e2e58 | ||
|
|
6dbbdacd0e
|
||
|
|
913bd497ed | ||
|
|
8cb487343a
|
||
|
|
b8bc1a7719 | ||
|
|
dd06743824
|
||
|
|
7a1f236c45 | ||
|
|
80b0b66c49
|
||
|
|
e5c6a71493
|
No files matched your search
+11
-57
@@ -1,60 +1,14 @@
|
||||
version: 2.1
|
||||
|
||||
setup: true
|
||||
orbs:
|
||||
qqq-orb: kingsrook/qqq-orb@0.6.9
|
||||
|
||||
commands:
|
||||
configure-package-install:
|
||||
steps:
|
||||
- run:
|
||||
name: Keep package installation unattended
|
||||
command: |
|
||||
# These build machines are discarded after the job.
|
||||
sudo install -d /etc/needrestart/conf.d
|
||||
printf '%s\n' '$nrconf{restart} = "l";' | sudo tee /etc/needrestart/conf.d/99-qqq-ci.conf >/dev/null
|
||||
|
||||
qqq-orb: kingsrook/qqq-orb@0.8
|
||||
workflows:
|
||||
test_only:
|
||||
jobs:
|
||||
- qqq-orb/mvn_test_only:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [qqq-maven-registry-credentials]
|
||||
filters:
|
||||
branches:
|
||||
ignore: /(develop|release\/.*)/
|
||||
|
||||
publish_snapshot:
|
||||
jobs:
|
||||
- qqq-orb/mvn_publish:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [qqq-maven-registry-credentials]
|
||||
branch_type: snapshot
|
||||
filters:
|
||||
branches:
|
||||
only: [develop]
|
||||
|
||||
publish_release_candidate:
|
||||
jobs:
|
||||
- qqq-orb/mvn_publish:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [qqq-maven-registry-credentials]
|
||||
branch_type: release_candidate
|
||||
filters:
|
||||
branches:
|
||||
only: [/release\/.*/]
|
||||
|
||||
publish_release:
|
||||
jobs:
|
||||
- qqq-orb/mvn_publish:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [qqq-maven-registry-credentials]
|
||||
branch_type: release
|
||||
filters:
|
||||
branches:
|
||||
ignore: /.*/
|
||||
tags:
|
||||
only: [/^v\d+\.\d+\.\d+$/]
|
||||
setup:
|
||||
jobs:
|
||||
- qqq-orb/generate_pipeline:
|
||||
orb_version: '0.8'
|
||||
filters:
|
||||
branches:
|
||||
ignore: automation/qbit-rc-pending
|
||||
tags:
|
||||
only: /^v\d+\.\d+\.\d+$/
|
||||
@@ -0,0 +1,7 @@
|
||||
name: build
|
||||
on:
|
||||
push:
|
||||
jobs:
|
||||
maven:
|
||||
uses: self:KofTwentyTwo/ci-workflows/.gitea/workflows/maven.yaml@v1
|
||||
secrets: inherit
|
||||
@@ -0,0 +1,23 @@
|
||||
name: CodeQL
|
||||
'on':
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
- release/**
|
||||
- hotfix/**
|
||||
pull_request: {}
|
||||
schedule:
|
||||
- cron: 41 16 * * 0
|
||||
workflow_dispatch: {}
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
security-events: write
|
||||
packages: read
|
||||
concurrency:
|
||||
group: codeql-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
jobs:
|
||||
analyze:
|
||||
uses: QRun-IO/qqq-orb/.github/workflows/qbit-codeql.yml@c288a8864fe9054f68410e1d78f5863aea4c4d22
|
||||
@@ -1,25 +1,74 @@
|
||||
# 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:
|
||||
'on':
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: Print the plan without merging or creating branches
|
||||
description: Plan a cut without changing release state
|
||||
type: boolean
|
||||
default: false
|
||||
finalize:
|
||||
description: Retry finalization of an existing cut; do not cut another RC
|
||||
type: boolean
|
||||
default: false
|
||||
recover:
|
||||
description: Recover the same published RC after a deployment timeout; never
|
||||
reupload
|
||||
type: boolean
|
||||
default: false
|
||||
circle_build:
|
||||
description: Original Circle publisher build number (recovery only)
|
||||
type: string
|
||||
default: ''
|
||||
merge_sha:
|
||||
description: Existing cut merge_sha (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
bump_sha:
|
||||
description: Existing cut bump_sha (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
branch:
|
||||
description: Existing cut branch (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
version:
|
||||
description: Existing cut version (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
pull_request:
|
||||
types: [closed]
|
||||
branches: [develop]
|
||||
|
||||
types:
|
||||
- closed
|
||||
branches:
|
||||
- develop
|
||||
repository_dispatch:
|
||||
types:
|
||||
- qbit-rc-published
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
actions: read
|
||||
checks: read
|
||||
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
|
||||
if: github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch'
|
||||
|| (github.event_name == 'pull_request' && github.event.pull_request.merged
|
||||
== true && github.event.pull_request.base.ref == 'develop' && contains(github.event.pull_request.labels.*.name,
|
||||
'release:rc'))
|
||||
uses: QRun-IO/qqq-orb/.github/workflows/qbit-release-rc.yml@cc30f599e63c8e41629c99d23395bac1adb8bc44
|
||||
with:
|
||||
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }}
|
||||
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || false
|
||||
}}
|
||||
finalize: ${{ github.event_name == 'repository_dispatch' || (github.event_name
|
||||
== 'workflow_dispatch' && inputs.finalize) }}
|
||||
recover: ${{ github.event_name == 'workflow_dispatch' && inputs.recover || false
|
||||
}}
|
||||
circle_build: ${{ github.event_name == 'workflow_dispatch' && inputs.circle_build
|
||||
|| '' }}
|
||||
merge_sha: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.merge_sha
|
||||
|| inputs.merge_sha || '' }}
|
||||
bump_sha: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.bump_sha
|
||||
|| inputs.bump_sha || '' }}
|
||||
branch: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.branch
|
||||
|| inputs.branch || '' }}
|
||||
version: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.version
|
||||
|| inputs.version || '' }}
|
||||
secrets: inherit
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
profile: qbit
|
||||
orb_version: '0.8'
|
||||
default_branch: develop
|
||||
java_version: '21'
|
||||
browsers: true
|
||||
api_checks: false
|
||||
docs_only: false
|
||||
release_on_main: false
|
||||
release_tag_policy: stable
|
||||
test_branch_policy: non-release
|
||||
publish:
|
||||
snapshot: true
|
||||
release_candidate: true
|
||||
release: true
|
||||
asciidoc: false
|
||||
release_completion: true
|
||||
@@ -5,12 +5,33 @@ and [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.1.0] - Unreleased
|
||||
|
||||
### Security
|
||||
|
||||
- The Jobs app and its five non-admin home widgets now require `<appName>.hasAccess`
|
||||
(default `jobs.hasAccess`) and are hidden otherwise. Behaviour change: hosts must grant
|
||||
this permission to the users who should see the Jobs app. Workers still needs `jobs_admin`.
|
||||
|
||||
### Changed
|
||||
|
||||
- Built on QQQ 4.1.0-RC.7; stand-ins removed.
|
||||
|
||||
### Added
|
||||
|
||||
- RC.8 optional job progress fields: phase labels, explicit percent with legacy fallback,
|
||||
message, binary byte counts, ETA and dropped-log notice in typed widgets.
|
||||
- Full-width owner-scoped Log child list after Live Progress, ascending by log ID.
|
||||
- System-session retention of log lines, including 64-bit cursors and 200-row batches.
|
||||
- Host `parentAppName` configuration for Admin > Operations > Jobs placement.
|
||||
- Progress/log, owner isolation, real core log permission and navigation tests.
|
||||
|
||||
### Changed
|
||||
|
||||
- Default QQQ BOM from 4.1.0-RC.5 to 4.1.0-RC.8; existing `qqq.snapshot.version`
|
||||
override verifies against locally installed 4.1.0-SNAPSHOT without the demo profile.
|
||||
- Storage adapter test now verifies failure/reference preservation with the current API.
|
||||
|
||||
### Earlier additions
|
||||
|
||||
- Optional effects outbox child list, viewer-scoped attention review counts and typed
|
||||
Live Progress effect statistics; Data Analysis includes `jobs_effect`.
|
||||
- `jobs.effect-unresolved` warning alert for effects requiring outcome review.
|
||||
|
||||
@@ -35,12 +35,16 @@ The tables are core's, prefixed `jobs_`. This qbit adds no tables.
|
||||
| `jobs_chunk` | The leased units of work, with attempts, checkpoints and per-chunk counts. Child list on the job. |
|
||||
| `jobs_row_error` | Which rows failed and why. Child list on the job; reportable. |
|
||||
| `jobs_effect` | Side effects outside the database (outbox): email, HTTP, S3, other databases; child list on the job. |
|
||||
| `jobs_log` | Append-only, owner-scoped log lines (level, time, phase, message); optional child list on the job. |
|
||||
| `jobs_event` | The job's timeline (launched, reclaimed, paused, completed and so on). Child list on the job. |
|
||||
| `jobs_worker` | The pods that run jobs. |
|
||||
| `jobs_upload` | Uploaded files (refs, owners, expiry). |
|
||||
|
||||
## Permissions
|
||||
- Users see their own jobs: core's record lock on `jobs_job.userId` through the `jobs_user` key.
|
||||
- The Jobs app and its home widgets require `<appName>.hasAccess` (default `jobs.hasAccess`, from
|
||||
`JobsQBitConfig.getHasAccessPermissionName()`); without it they are hidden. Hosts must grant it to the
|
||||
users who should see the Jobs app. The header Jobs tray and record locks are unaffected.
|
||||
- `jobs_admin` sees and controls every job, and sees the Workers widget.
|
||||
- Apply the keys in your auth customizer with `DurableJobsSecurity.applyToSession(session)`.
|
||||
|
||||
@@ -52,7 +56,17 @@ The tables are core's, prefixed `jobs_`. This qbit adds no tables.
|
||||
- `schedulerName` (null; set to a host scheduler name to enable the schedule);
|
||||
- `homeRefreshSeconds` (5);
|
||||
- `recordRefreshSeconds` (3);
|
||||
- `appName` (`jobs`).
|
||||
- `appName` (`jobs`);
|
||||
- `parentAppName` (null; an existing host app, such as `operations` under Admin).
|
||||
|
||||
To place Jobs at **Admin > Operations > Jobs**, register the host Admin and Operations
|
||||
apps first (Operations has Admin as its parent), then produce with:
|
||||
```java
|
||||
new JobsQBitProducer().withQBitConfig(new JobsQBitConfig()
|
||||
.withParentAppName("operations")).produce(qInstance);
|
||||
```
|
||||
The parent must exist and differ from `appName`; Jobs attaches to its children.
|
||||
With no parent configured, Jobs remains a top-level app. Placement does not grant permissions.
|
||||
|
||||
## Adopting in another app
|
||||
1. Enable durable jobs in core (above) and create the tables. `docs/ddl/postgres.sql` is the Postgres
|
||||
@@ -76,12 +90,17 @@ On Windows use `./mvnw.cmd` in place of `./mvnw`.
|
||||
|
||||
## Display
|
||||
|
||||
The job progress fields and the Log list need QQQ 4.1.0-RC.8 or later; on older metadata
|
||||
the optional progress fields may be absent and the Log list is left out.
|
||||
|
||||
The app produces typed data only, with no HTML. The widget label is each card's title,
|
||||
so the data carries no second title.
|
||||
- **Tables.** The running, attention and worker tables have two or three flexible
|
||||
columns. Their cells are composite widget blocks:
|
||||
- the job's label links to its record, with the process label under it;
|
||||
- a progress bar shows the percent and rows done;
|
||||
- a progress bar uses `percentDone` when present (including zero), otherwise rows/chunks;
|
||||
- phase labels come from `progressJson.phases` with `currentPhase` as fallback;
|
||||
- plain text shows the progress message and optional bytes in B/KiB/MiB/GiB;
|
||||
- the status line is a label (or the waiting reason) with the rate and ETA.
|
||||
Each row also keeps its plain values (`Job`, `Process`, `Status`, `Rows`, `Rate`,
|
||||
`ETA`, `Path` and so on) for exports and other clients.
|
||||
@@ -97,6 +116,12 @@ Its output contains standard process summary lines for jobs purged, chunk object
|
||||
removed during purge, and input objects removed (uploads plus retained-job inputs).
|
||||
Jobs with effects outside SENT/FAILED, or with effects needing review, keep their rows
|
||||
and inputs. When any are skipped, the summary includes "N jobs kept: effects still unsettled".
|
||||
The Log list follows Live Progress, before Events: level, time, phase and message,
|
||||
newest first, full width, with the standard child-list 25-row limit and View all link.
|
||||
Live Progress also shows the ETA and a notice when `logDropped` is positive. The log is
|
||||
read-only for users, including admins; retention deletes it through the system session in
|
||||
keyset batches with 64-bit log cursors, before deleting chunks and jobs.
|
||||
|
||||
Needs attention shows each listed job's effect-review count; Live Progress adds an Effects
|
||||
group (Sent, Pending, Failed, Needs review) when the job has effects.
|
||||
|
||||
|
||||
+27
-4
@@ -23,6 +23,29 @@ and phase 4. Owner decisions: all of that spec's recommendations are accepted (2
|
||||
- **R6, storage.** Retention deletes finished jobs and their chunk objects from object storage. The
|
||||
qbit reads and writes no files.
|
||||
|
||||
## Progress and log adoption (core RC.8)
|
||||
|
||||
Implements sections 2 and 7 of core `JOB-PROGRESS-AND-JOBS-TRAY.md`. The default
|
||||
BOM is 4.1.0-RC.8; local verification uses `-Dqqq.snapshot.version=4.1.0-SNAPSHOT`.
|
||||
Optional names remain in `JobsCoreContract` without importing `core.jobs`.
|
||||
|
||||
- Home and Live Progress prefer nullable `percentDone`, including explicit zero,
|
||||
then retain rows/chunks derivation. The snapshot supplies the current phase label;
|
||||
missing or invalid JSON falls back to `currentPhase`. Both views include the
|
||||
progress message, optional binary byte counts, and explicit `etaSeconds` ahead
|
||||
of the existing rate-derived ETA. No HTML, fixed widths, or theme-specific colors.
|
||||
- Log is a full-width standard child list immediately after Live Progress, before
|
||||
Events, only if both table and join exist. It displays level, loggedAt, phase and
|
||||
message, sorted by id ascending, with the standard 25-row preview/View all.
|
||||
The panel reads through the current viewer and core joined owner lock.
|
||||
- A positive `logDropped` produces an earlier-lines-not-kept notice in Live Progress.
|
||||
- Retention deletes logs before chunks/jobs, using the existing 200-row keyset loop
|
||||
with a long child cursor. Core `JobLogPermissions` explicitly permits system
|
||||
sessions and denies user mutations even for admins. Tests load the actual checker.
|
||||
- Hosts set `parentAppName` to their existing Operations app under Admin; the qbit
|
||||
sets the parent and attaches Jobs as a child, while retaining configurable appName.
|
||||
Missing/self parents fail validation before registration. Default remains top-level.
|
||||
|
||||
## The app
|
||||
- `jobs` app, label **Jobs**. The nav lists `jobs_job` only. Chunks, row errors, effects and events are child
|
||||
lists on the job record.
|
||||
@@ -57,7 +80,7 @@ and phase 4. Owner decisions: all of that spec's recommendations are accepted (2
|
||||
without needsReview), Failed and Needs review. Review effects appear only in Needs review.
|
||||
The group is omitted when there are no effects or the effects table is absent.
|
||||
It refreshes every 3s while the job is active.
|
||||
- Child lists for **Chunks**, **Row errors**, **Effects** and **Events**. Effects is
|
||||
- Child lists for **Log**, **Chunks**, **Row errors**, **Effects** and **Events**. Effects is
|
||||
included only when the effects table and its inferred job join exist; it uses the same
|
||||
joined owner lock as row errors.
|
||||
- A **Changes made by this job** link to the audit table, filtered to messages containing
|
||||
@@ -68,7 +91,7 @@ and phase 4. Owner decisions: all of that spec's recommendations are accepted (2
|
||||
## Retention
|
||||
`jobsPurgeFinished` is a hidden scheduled process, not in the nav. It runs daily by default.
|
||||
- It deletes jobs that finished more than `retentionDays` ago (default 30), with their chunks, row
|
||||
errors, effects, events and chunk objects. Effects are removed before chunks and jobs.
|
||||
errors, effects, logs, events and chunk objects. Effects are removed before chunks and jobs.
|
||||
- Jobs with any effect outside SENT/FAILED, or any effect needing review, keep their rows
|
||||
and inputs. The summary reports "N jobs kept: effects still unsettled" when any are skipped.
|
||||
- For COMPLETED jobs with no failed rows, it deletes the chunk input objects after
|
||||
@@ -80,8 +103,8 @@ and phase 4. Owner decisions: all of that spec's recommendations are accepted (2
|
||||
these to qqq-esb, qbit-quick-search and qbit-data-analysis.
|
||||
|
||||
## Dependencies
|
||||
Requires QQQ 4.1.0-RC.7 or later. The default `qqq-snapshot` profile and the opt-in
|
||||
`demo` profile both use released RC.7. Core provides table metadata, joins, possible
|
||||
Requires QQQ 4.1.0-RC.8 or later (job progress and `jobs_log`). The default `qqq-snapshot` profile and the opt-in
|
||||
`demo` profile both use released RC.8. Core provides table metadata, joins, possible
|
||||
values, session security, signal identifiers and storage deletion. The qbit declares
|
||||
the alert types and retains only field names that core does not export.
|
||||
The demo server under `src/demo/java` uses the same released kernel.
|
||||
@@ -21,6 +21,15 @@ CREATE TABLE jobs_job
|
||||
semantics VARCHAR(20),
|
||||
chunk_size INTEGER,
|
||||
max_parallel_chunks INTEGER,
|
||||
current_phase VARCHAR(250),
|
||||
percent_done INTEGER,
|
||||
progress_message VARCHAR(1000),
|
||||
bytes_done BIGINT,
|
||||
bytes_total BIGINT,
|
||||
eta_seconds BIGINT,
|
||||
progress_json TEXT,
|
||||
log_seq BIGINT,
|
||||
log_dropped INTEGER,
|
||||
rows_total INTEGER,
|
||||
rows_done INTEGER,
|
||||
rows_ok INTEGER,
|
||||
@@ -173,3 +182,17 @@ CREATE TABLE jobs_upload
|
||||
job_id INTEGER,
|
||||
create_date TIMESTAMPTZ
|
||||
);
|
||||
|
||||
-- RC.8 job progress log; host migrations must also add the jobs_job columns above.
|
||||
CREATE TABLE jobs_log
|
||||
(
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
job_id INTEGER NOT NULL REFERENCES jobs_job(id) ON DELETE CASCADE,
|
||||
chunk_id INTEGER REFERENCES jobs_chunk(id) ON DELETE SET NULL,
|
||||
attempt INTEGER,
|
||||
level VARCHAR(10) NOT NULL,
|
||||
logged_at TIMESTAMPTZ NOT NULL,
|
||||
phase VARCHAR(250),
|
||||
message VARCHAR(1000)
|
||||
);
|
||||
CREATE INDEX jobs_log_cursor_idx ON jobs_log(job_id, id);
|
||||
@@ -18,6 +18,7 @@
|
||||
<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>
|
||||
<qqq.version>4.1.0-RC.26</qqq.version> <!-- feeds qbit-build-parent's qqq-bom-pom import -->
|
||||
<revision>0.1.0-SNAPSHOT</revision>
|
||||
<maven.compiler.source>21</maven.compiler.source>
|
||||
<maven.compiler.target>21</maven.compiler.target>
|
||||
@@ -27,7 +28,7 @@
|
||||
<parent>
|
||||
<groupId>com.kingsrook</groupId>
|
||||
<artifactId>qbit-build-parent</artifactId>
|
||||
<version>2.0.0</version>
|
||||
<version>2.2.1</version>
|
||||
</parent>
|
||||
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
@@ -171,7 +172,7 @@
|
||||
|
||||
<profiles>
|
||||
<!-- Built against qqq 4.1. 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.7 ahead of the parent's -->
|
||||
<!-- profile is active by default and imports qqq-bom-pom 4.1.0-RC.8 ahead of the parent's -->
|
||||
<!-- 4.0.0 BOM (ADR-0007, as in the other qbits). -->
|
||||
<profile>
|
||||
<id>qqq-snapshot</id>
|
||||
@@ -179,7 +180,7 @@
|
||||
<activeByDefault>true</activeByDefault>
|
||||
</activation>
|
||||
<properties>
|
||||
<qqq.snapshot.version>4.1.0-RC.7</qqq.snapshot.version>
|
||||
<qqq.snapshot.version>4.1.0-RC.26</qqq.snapshot.version>
|
||||
</properties>
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
@@ -192,18 +193,6 @@
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
<repositories>
|
||||
<repository>
|
||||
<id>sonatype-snapshots</id>
|
||||
<url>https://central.sonatype.com/repository/maven-snapshots/</url>
|
||||
<releases>
|
||||
<enabled>false</enabled>
|
||||
</releases>
|
||||
<snapshots>
|
||||
<enabled>true</enabled>
|
||||
</snapshots>
|
||||
</repository>
|
||||
</repositories>
|
||||
</profile>
|
||||
|
||||
<!-- Local demo server and screenshots: builds src/demo/java against released QQQ RC.7. -->
|
||||
@@ -211,7 +200,7 @@
|
||||
<profile>
|
||||
<id>demo</id>
|
||||
<properties>
|
||||
<qqq.snapshot.version>4.1.0-RC.7</qqq.snapshot.version>
|
||||
<qqq.snapshot.version>4.1.0-RC.26</qqq.snapshot.version>
|
||||
</properties>
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
@@ -238,7 +227,7 @@
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-frontend-next</artifactId>
|
||||
<version>1.0.0-RC.19</version>
|
||||
<version>1.0.0-RC.38</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
|
||||
@@ -39,6 +39,21 @@ import com.kingsrook.qqq.backend.core.model.audits.AuditsMetaDataProvider;
|
||||
*******************************************************************************/
|
||||
public final class JobsCoreContract
|
||||
{
|
||||
// Optional progress and log contract (core 4.1.0-RC.8).
|
||||
public static final String JOBS_LOG = DurableJobsTables.TABLE_LOG;
|
||||
public static final String CURRENT_PHASE = "currentPhase";
|
||||
public static final String PERCENT_DONE = "percentDone";
|
||||
public static final String PROGRESS_MESSAGE = "progressMessage";
|
||||
public static final String BYTES_DONE = "bytesDone";
|
||||
public static final String BYTES_TOTAL = "bytesTotal";
|
||||
public static final String ETA_SECONDS = "etaSeconds";
|
||||
public static final String LOG_SEQ = "logSeq";
|
||||
public static final String LOG_DROPPED = "logDropped";
|
||||
public static final String PROGRESS_JSON = "progressJson";
|
||||
public static final String LEVEL = "level";
|
||||
public static final String LOGGED_AT = "loggedAt";
|
||||
public static final String PHASE = "phase";
|
||||
|
||||
public static final String JOBS_JOB = DurableJobsTables.TABLE_JOB;
|
||||
public static final String JOBS_CHUNK = DurableJobsTables.TABLE_CHUNK;
|
||||
public static final String JOBS_ROW_ERROR = DurableJobsTables.TABLE_ROW_ERROR;
|
||||
|
||||
@@ -34,6 +34,7 @@ import org.quartz.CronExpression;
|
||||
public class JobsQBitConfig implements QBitConfig
|
||||
{
|
||||
private String appName = "jobs";
|
||||
private String parentAppName;
|
||||
private int retentionDays = 30;
|
||||
private int inputRetentionDays = 7;
|
||||
private String purgeCronExpression = "0 30 3 * * ?";
|
||||
@@ -41,6 +42,31 @@ public class JobsQBitConfig implements QBitConfig
|
||||
private int recordRefreshSeconds = 3;
|
||||
private String schedulerName = null;
|
||||
|
||||
/*******************************************************************************
|
||||
** Get the optional host parent app.
|
||||
*******************************************************************************/
|
||||
public String getParentAppName()
|
||||
{
|
||||
return (parentAppName);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Set the optional host parent app.
|
||||
*******************************************************************************/
|
||||
public void setParentAppName(String parentAppName)
|
||||
{
|
||||
this.parentAppName = parentAppName;
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Place Jobs under an existing host app, such as Operations under Admin.
|
||||
*******************************************************************************/
|
||||
public JobsQBitConfig withParentAppName(String parentAppName)
|
||||
{
|
||||
this.parentAppName = parentAppName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Report invalid settings before adding any metadata.
|
||||
*******************************************************************************/
|
||||
@@ -48,6 +74,7 @@ public class JobsQBitConfig implements QBitConfig
|
||||
public void validate(QInstance instance, List<String> errors)
|
||||
{
|
||||
assertCondition(appName != null && appName.matches("[A-Za-z][A-Za-z0-9_]*"), "appName must be a valid name", errors);
|
||||
assertCondition(parentAppName == null || (!parentAppName.equals(appName) && instance.getApp(parentAppName) != null), "parentAppName must name an existing, different app", errors);
|
||||
assertCondition(retentionDays > 0, "retentionDays must be positive", errors);
|
||||
assertCondition(inputRetentionDays > 0 && inputRetentionDays <= retentionDays, "inputRetentionDays must be positive and no greater than retentionDays", errors);
|
||||
assertCondition(homeRefreshSeconds > 0 && recordRefreshSeconds > 0, "refresh seconds must be positive", errors);
|
||||
@@ -55,6 +82,15 @@ public class JobsQBitConfig implements QBitConfig
|
||||
assertCondition(schedulerName == null || !schedulerName.isBlank(), "schedulerName must be null or nonblank", errors);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The permission a user needs to see the Jobs app and its home widgets,
|
||||
** "<appName>.hasAccess" (default "jobs.hasAccess"). Hosts must grant it.
|
||||
*******************************************************************************/
|
||||
public String getHasAccessPermissionName()
|
||||
{
|
||||
return (appName + ".hasAccess");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Get appName.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -45,6 +45,7 @@ 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.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.DenyBehavior;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
|
||||
@@ -66,7 +67,12 @@ import static com.kingsrook.qbits.jobs.JobsCoreContract.JOBS_CHUNK;
|
||||
import static com.kingsrook.qbits.jobs.JobsCoreContract.JOBS_EFFECT;
|
||||
import static com.kingsrook.qbits.jobs.JobsCoreContract.JOBS_EVENT;
|
||||
import static com.kingsrook.qbits.jobs.JobsCoreContract.JOBS_JOB;
|
||||
import static com.kingsrook.qbits.jobs.JobsCoreContract.JOBS_LOG;
|
||||
import static com.kingsrook.qbits.jobs.JobsCoreContract.JOBS_ROW_ERROR;
|
||||
import static com.kingsrook.qbits.jobs.JobsCoreContract.LEVEL;
|
||||
import static com.kingsrook.qbits.jobs.JobsCoreContract.LOGGED_AT;
|
||||
import static com.kingsrook.qbits.jobs.JobsCoreContract.MESSAGE;
|
||||
import static com.kingsrook.qbits.jobs.JobsCoreContract.PHASE;
|
||||
import static com.kingsrook.qbits.jobs.JobsCoreContract.ROW_NUMBER;
|
||||
import static com.kingsrook.qbits.jobs.JobsCoreContract.SEQ;
|
||||
|
||||
@@ -162,10 +168,28 @@ public class JobsQBitProducer implements QBitMetaDataProducer<JobsQBitConfig>
|
||||
QWidgetMetaData workers = registerWidget(instance, "jobsHomeWorkers", "Workers", WidgetType.TABLE, qBitConfig.getHomeRefreshSeconds());
|
||||
workers.withPermissionRules(adminRules());
|
||||
registerWidget(instance, "jobsHomeHistory", "Job history", WidgetType.STACKED_BAR_CHART, qBitConfig.getHomeRefreshSeconds());
|
||||
instance.addApp(new QAppMetaData().withName(qBitConfig.getAppName()).withLabel("Jobs").withIcon(new QIcon("pending_actions"))
|
||||
.withChild(instance.getTable(JOBS_JOB)).withWidgets(new ArrayList<>(HOME_WIDGETS)));
|
||||
for(String name : HOME_WIDGETS)
|
||||
{
|
||||
if(!"jobsHomeWorkers".equals(name))
|
||||
{
|
||||
instance.getWidget(name).setPermissionRules(appAccessRules());
|
||||
}
|
||||
}
|
||||
QAppMetaData app = new QAppMetaData().withName(qBitConfig.getAppName()).withLabel("Jobs").withIcon(new QIcon("pending_actions"))
|
||||
.withChild(instance.getTable(JOBS_JOB)).withWidgets(new ArrayList<>(HOME_WIDGETS))
|
||||
.withPermissionRules(appAccessRules());
|
||||
app.setParentAppName(qBitConfig.getParentAppName());
|
||||
instance.addApp(app);
|
||||
if(qBitConfig.getParentAppName() != null)
|
||||
{
|
||||
instance.getApp(qBitConfig.getParentAppName()).withChild(app);
|
||||
}
|
||||
registerWidget(instance, "jobsJobProgress", "Live Progress", WidgetType.MULTI_STATISTICS, qBitConfig.getRecordRefreshSeconds());
|
||||
addRecordSection(instance, "jobsJobProgress", "Live Progress");
|
||||
if(instance.getTable(JOBS_LOG) != null && instance.getJoin(JobsCoreContract.childJoin(JOBS_LOG)) != null)
|
||||
{
|
||||
child(instance, JOBS_LOG, "jobsJobLog", "Log", ID, false);
|
||||
}
|
||||
child(instance, JOBS_CHUNK, "jobsJobChunks", "Chunks", SEQ, true);
|
||||
child(instance, JOBS_ROW_ERROR, "jobsJobRowErrors", "Row Errors", ROW_NUMBER, true);
|
||||
if(instance.getTable(JOBS_EFFECT) != null && instance.getJoin(JobsCoreContract.childJoin(JOBS_EFFECT)) != null)
|
||||
@@ -203,6 +227,10 @@ public class JobsQBitProducer implements QBitMetaDataProducer<JobsQBitConfig>
|
||||
.withOrderBys(List.of(new QFilterOrderBy(order, ascending))).withMaxRows(25).getWidgetMetaData()
|
||||
.withName(name).withLabel(label).withGridColumns(12).withCodeReference(new QCodeReference(JobsChildRecordListRenderer.class))
|
||||
.withDefaultValue("refreshSeconds", qBitConfig.getRecordRefreshSeconds());
|
||||
if(JOBS_LOG.equals(table))
|
||||
{
|
||||
widget.withDefaultValue(ChildRecordListRenderer.KEY_ONLY_INCLUDE_FIELD_NAMES, new ArrayList<>(List.of(LEVEL, LOGGED_AT, PHASE, MESSAGE)));
|
||||
}
|
||||
instance.addWidget(widget);
|
||||
addRecordSection(instance, name, label);
|
||||
}
|
||||
@@ -216,6 +244,16 @@ public class JobsQBitProducer implements QBitMetaDataProducer<JobsQBitConfig>
|
||||
.withTier(Tier.T2).withWidgetName(widgetName).withGridColumns(12));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The Jobs app and its home widgets require "<appName>.hasAccess"; users
|
||||
** without it never see the app or its widgets (hidden, not locked).
|
||||
*******************************************************************************/
|
||||
private QPermissionRules appAccessRules()
|
||||
{
|
||||
return (new QPermissionRules().withLevel(PermissionLevel.HAS_ACCESS_PERMISSION)
|
||||
.withPermissionBaseName(qBitConfig.getAppName()).withDenyBehavior(DenyBehavior.HIDDEN));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Match the exact core permission, without a .hasAccess suffix.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -60,6 +60,7 @@ import static com.kingsrook.qbits.jobs.JobsCoreContract.JOBS_CHUNK;
|
||||
import static com.kingsrook.qbits.jobs.JobsCoreContract.JOBS_EFFECT;
|
||||
import static com.kingsrook.qbits.jobs.JobsCoreContract.JOBS_EVENT;
|
||||
import static com.kingsrook.qbits.jobs.JobsCoreContract.JOBS_JOB;
|
||||
import static com.kingsrook.qbits.jobs.JobsCoreContract.JOBS_LOG;
|
||||
import static com.kingsrook.qbits.jobs.JobsCoreContract.JOBS_ROW_ERROR;
|
||||
import static com.kingsrook.qbits.jobs.JobsCoreContract.JOBS_UPLOAD;
|
||||
import static com.kingsrook.qbits.jobs.JobsCoreContract.JOB_ID;
|
||||
@@ -159,6 +160,10 @@ public class JobsPurgeFinishedStep implements BackendStep
|
||||
{
|
||||
removeChildren(JOBS_EFFECT, lastId, counts);
|
||||
}
|
||||
if(QContext.getQInstance().getTable(JOBS_LOG) != null)
|
||||
{
|
||||
removeChildren(JOBS_LOG, lastId, counts);
|
||||
}
|
||||
removeChildren(JOBS_ROW_ERROR, lastId, counts);
|
||||
removeChildren(JOBS_EVENT, lastId, counts);
|
||||
removeChildren(JOBS_CHUNK, lastId, counts);
|
||||
@@ -191,7 +196,7 @@ public class JobsPurgeFinishedStep implements BackendStep
|
||||
*******************************************************************************/
|
||||
private void removeChildren(String table, int jobId, int[] counts) throws QException
|
||||
{
|
||||
int lastId = 0;
|
||||
long lastId = 0;
|
||||
while(true)
|
||||
{
|
||||
List<QRecord> records = batch(table, new QQueryFilter().withCriteria(JOB_ID, QCriteriaOperator.EQUALS, jobId), lastId);
|
||||
@@ -201,7 +206,7 @@ public class JobsPurgeFinishedStep implements BackendStep
|
||||
}
|
||||
for(QRecord record : records)
|
||||
{
|
||||
lastId = record.getValueInteger(ID);
|
||||
lastId = record.getValueLong(ID);
|
||||
if(JOBS_CHUNK.equals(table) && storage.delete(storage.chunkStorageTable(), record.getValueString(INPUT_STORAGE_REF)))
|
||||
{
|
||||
counts[1]++;
|
||||
@@ -276,7 +281,7 @@ public class JobsPurgeFinishedStep implements BackendStep
|
||||
/*******************************************************************************
|
||||
** Keyset pagination remains correct when rows are deleted or updated.
|
||||
*******************************************************************************/
|
||||
private List<QRecord> batch(String table, QQueryFilter filter, int lastId) throws QException
|
||||
private List<QRecord> batch(String table, QQueryFilter filter, long lastId) throws QException
|
||||
{
|
||||
return (new QueryAction().execute(new QueryInput().withTableName(table).withFilter(filter.withCriteria(ID, QCriteriaOperator.GREATER_THAN, lastId)
|
||||
.withOrderBy(new QFilterOrderBy(ID)).withLimit(BATCH_SIZE))).getRecords());
|
||||
|
||||
@@ -52,6 +52,24 @@ public final class JobsFormat
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Binary byte units, with one decimal place above bytes.
|
||||
*******************************************************************************/
|
||||
public static String bytes(long value)
|
||||
{
|
||||
double amount = Math.max(0, value);
|
||||
String[] units = {"B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB"};
|
||||
int unit = 0;
|
||||
while(amount >= 1024 && unit < units.length - 1)
|
||||
{
|
||||
amount /= 1024;
|
||||
unit++;
|
||||
}
|
||||
return (unit == 0 ? count((long) amount) + " B" : String.format(Locale.US, "%.1f %s", amount, units[unit]));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A count with thousands separators.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
package com.kingsrook.qbits.jobs.widgets;
|
||||
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.Serializable;
|
||||
import java.math.BigDecimal;
|
||||
import java.net.URLEncoder;
|
||||
@@ -43,6 +44,7 @@ import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.CountAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
@@ -73,14 +75,18 @@ import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
|
||||
import static com.kingsrook.qbits.jobs.JobsCoreContract.ATTENTION_STATUSES;
|
||||
import static com.kingsrook.qbits.jobs.JobsCoreContract.AUDIT;
|
||||
import static com.kingsrook.qbits.jobs.JobsCoreContract.BYTES_DONE;
|
||||
import static com.kingsrook.qbits.jobs.JobsCoreContract.BYTES_TOTAL;
|
||||
import static com.kingsrook.qbits.jobs.JobsCoreContract.CHUNKS_DONE;
|
||||
import static com.kingsrook.qbits.jobs.JobsCoreContract.CHUNKS_FAILED;
|
||||
import static com.kingsrook.qbits.jobs.JobsCoreContract.CHUNKS_TOTAL;
|
||||
import static com.kingsrook.qbits.jobs.JobsCoreContract.COMPLETED;
|
||||
import static com.kingsrook.qbits.jobs.JobsCoreContract.COMPLETED_WITH_ERRORS;
|
||||
import static com.kingsrook.qbits.jobs.JobsCoreContract.CURRENT_PHASE;
|
||||
import static com.kingsrook.qbits.jobs.JobsCoreContract.DONE;
|
||||
import static com.kingsrook.qbits.jobs.JobsCoreContract.DRAINING;
|
||||
import static com.kingsrook.qbits.jobs.JobsCoreContract.ERROR_MESSAGE;
|
||||
import static com.kingsrook.qbits.jobs.JobsCoreContract.ETA_SECONDS;
|
||||
import static com.kingsrook.qbits.jobs.JobsCoreContract.FAILED;
|
||||
import static com.kingsrook.qbits.jobs.JobsCoreContract.FINISHED_AT;
|
||||
import static com.kingsrook.qbits.jobs.JobsCoreContract.HEARTBEAT_AT;
|
||||
@@ -93,12 +99,16 @@ import static com.kingsrook.qbits.jobs.JobsCoreContract.JOBS_WORKER;
|
||||
import static com.kingsrook.qbits.jobs.JobsCoreContract.JOB_ID;
|
||||
import static com.kingsrook.qbits.jobs.JobsCoreContract.LABEL;
|
||||
import static com.kingsrook.qbits.jobs.JobsCoreContract.LAST_PROGRESS_AT;
|
||||
import static com.kingsrook.qbits.jobs.JobsCoreContract.LOG_DROPPED;
|
||||
import static com.kingsrook.qbits.jobs.JobsCoreContract.MESSAGE;
|
||||
import static com.kingsrook.qbits.jobs.JobsCoreContract.NEEDS_REVIEW_FIELD;
|
||||
import static com.kingsrook.qbits.jobs.JobsCoreContract.PENDING;
|
||||
import static com.kingsrook.qbits.jobs.JobsCoreContract.PERCENT_DONE;
|
||||
import static com.kingsrook.qbits.jobs.JobsCoreContract.POD_ID;
|
||||
import static com.kingsrook.qbits.jobs.JobsCoreContract.PRIORITY;
|
||||
import static com.kingsrook.qbits.jobs.JobsCoreContract.PROCESS_NAME;
|
||||
import static com.kingsrook.qbits.jobs.JobsCoreContract.PROGRESS_JSON;
|
||||
import static com.kingsrook.qbits.jobs.JobsCoreContract.PROGRESS_MESSAGE;
|
||||
import static com.kingsrook.qbits.jobs.JobsCoreContract.QUEUED_AT;
|
||||
import static com.kingsrook.qbits.jobs.JobsCoreContract.QUEUED_STATUSES;
|
||||
import static com.kingsrook.qbits.jobs.JobsCoreContract.ROWS_DONE;
|
||||
@@ -305,6 +315,13 @@ public class JobsWidgetRenderer extends AbstractWidgetRenderer
|
||||
progress.withBlock(text("Unknown total", null, null));
|
||||
}
|
||||
progress.withBlock(small(rowText + " rows"));
|
||||
for(String detail : progressDetails(job))
|
||||
{
|
||||
progress.withBlock(small(detail));
|
||||
}
|
||||
row.put("Phase", phaseLabel(job));
|
||||
row.put("Message", job.getValueString(PROGRESS_MESSAGE));
|
||||
row.put("Bytes", bytes(job));
|
||||
|
||||
row.put(JOB_CELL, jobCell(job, null));
|
||||
row.put(PROGRESS_CELL, progress);
|
||||
@@ -498,7 +515,7 @@ public class JobsWidgetRenderer extends AbstractWidgetRenderer
|
||||
rowStats.add(statistic("Total", number(job, ROWS_TOTAL), null));
|
||||
}
|
||||
MultiStatisticsData.StatisticsGroupData rows = new MultiStatisticsData.StatisticsGroupData().withHeader("Rows")
|
||||
.withSubheader(job.getValue(ROWS_TOTAL) == null ? "Total not known yet" : percent + " done").withStatisticList(rowStats);
|
||||
.withSubheader(percentValue(job) == null ? "Total not known yet" : percent + " done").withStatisticList(rowStats);
|
||||
|
||||
List<MultiStatisticsData.StatisticsGroupData.Statistic> chunkStats = new ArrayList<>();
|
||||
chunkStats.add(statistic("Done", number(job, CHUNKS_DONE), null));
|
||||
@@ -514,7 +531,7 @@ public class JobsWidgetRenderer extends AbstractWidgetRenderer
|
||||
{
|
||||
speedNote = statusLabel(status) + " " + JobsFormat.time(job.getValueInstant(FINISHED_AT), now);
|
||||
}
|
||||
else if(RUNNING_STATUSES.contains(status))
|
||||
else if(job.getValue(ETA_SECONDS) != null || RUNNING_STATUSES.contains(status))
|
||||
{
|
||||
speedNote = "ETA " + eta(job, now);
|
||||
}
|
||||
@@ -525,6 +542,17 @@ public class JobsWidgetRenderer extends AbstractWidgetRenderer
|
||||
MultiStatisticsData.StatisticsGroupData speed = new MultiStatisticsData.StatisticsGroupData().withHeader("Speed").withSubheader(speedNote)
|
||||
.withStatisticList(List.of(statistic("Rows per minute", Math.round(rate(job, now)), null)));
|
||||
List<MultiStatisticsData.StatisticsGroupData> groups = new ArrayList<>(List.of(rows, chunks, speed));
|
||||
List<String> details = progressDetails(job);
|
||||
if(!details.isEmpty())
|
||||
{
|
||||
groups.addFirst(new MultiStatisticsData.StatisticsGroupData().withHeader("Progress")
|
||||
.withSubheader(String.join(" | ", details)).withStatisticList(List.of()));
|
||||
}
|
||||
if(number(job, LOG_DROPPED) > 0)
|
||||
{
|
||||
groups.add(new MultiStatisticsData.StatisticsGroupData().withHeader("Log")
|
||||
.withSubheader(JobsFormat.count(number(job, LOG_DROPPED)) + " earlier lines not kept").withStatisticList(List.of()));
|
||||
}
|
||||
if(QContext.getQInstance().getTable(JOBS_EFFECT) != null)
|
||||
{
|
||||
List<QRecord> effects = query(JOBS_EFFECT, new QQueryFilter().withCriteria(JOB_ID, QCriteriaOperator.EQUALS, job.getValue(ID)));
|
||||
@@ -760,10 +788,69 @@ public class JobsWidgetRenderer extends AbstractWidgetRenderer
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Percent done from rows, else from chunks; null when neither total is known.
|
||||
** Resolve the phase label from the optional snapshot; old or malformed data
|
||||
** still displays the reported phase name.
|
||||
*******************************************************************************/
|
||||
private static String phaseLabel(QRecord job)
|
||||
{
|
||||
String current = job.getValueString(CURRENT_PHASE);
|
||||
String json = job.getValueString(PROGRESS_JSON);
|
||||
if(current != null && json != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
JsonNode snapshot = JsonUtils.toObject(json, JsonNode.class);
|
||||
for(JsonNode phase : snapshot == null ? List.<JsonNode>of() : snapshot.path("phases"))
|
||||
{
|
||||
if(current.equals(phase.path("name").asText()) && !phase.path("label").asText().isBlank())
|
||||
{
|
||||
return (phase.path("label").asText());
|
||||
}
|
||||
}
|
||||
}
|
||||
catch(IOException ignored)
|
||||
{
|
||||
// The denormalized progress remains usable without a valid snapshot.
|
||||
}
|
||||
}
|
||||
return (current);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Optional byte counts, retaining unknown totals rather than inventing zero.
|
||||
*******************************************************************************/
|
||||
private static String bytes(QRecord job)
|
||||
{
|
||||
String done = job.getValue(BYTES_DONE) == null ? null : JobsFormat.bytes(number(job, BYTES_DONE));
|
||||
String total = job.getValue(BYTES_TOTAL) == null ? null : JobsFormat.bytes(number(job, BYTES_TOTAL));
|
||||
return (total == null ? done : done == null ? total + " total" : done + " / " + total);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Plain typed text shared by the home and record progress views.
|
||||
*******************************************************************************/
|
||||
private static List<String> progressDetails(QRecord job)
|
||||
{
|
||||
List<String> details = new ArrayList<>();
|
||||
for(String detail : new String[] {phaseLabel(job), job.getValueString(PROGRESS_MESSAGE), bytes(job)})
|
||||
{
|
||||
if(detail != null && !detail.isBlank())
|
||||
{
|
||||
details.add(detail);
|
||||
}
|
||||
}
|
||||
return (details);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Explicit percent, else rows then chunks; null when no total is known.
|
||||
*******************************************************************************/
|
||||
private static BigDecimal percentValue(QRecord job)
|
||||
{
|
||||
if(job.getValue(PERCENT_DONE) != null)
|
||||
{
|
||||
return (BigDecimal.valueOf(Math.max(0, Math.min(100, number(job, PERCENT_DONE)))));
|
||||
}
|
||||
long total = number(job, ROWS_TOTAL);
|
||||
long done = number(job, ROWS_DONE);
|
||||
if(total <= 0)
|
||||
@@ -805,6 +892,10 @@ public class JobsWidgetRenderer extends AbstractWidgetRenderer
|
||||
*******************************************************************************/
|
||||
private static String eta(QRecord job, Instant now)
|
||||
{
|
||||
if(job.getValue(ETA_SECONDS) != null)
|
||||
{
|
||||
return ("in " + JobsFormat.span(number(job, ETA_SECONDS)));
|
||||
}
|
||||
double rate = rate(job, now);
|
||||
if(!RUNNING_STATUSES.contains(job.getValueString(STATUS)) || rate <= 0 || job.getValue(ROWS_TOTAL) == null)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* 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
|
||||
*
|
||||
* https://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.jobs;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import com.kingsrook.qqq.backend.core.actions.metadata.MetaDataAction;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.metadata.MetaDataInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.metadata.MetaDataOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.DenyBehavior;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The Jobs app and its home widgets are hidden from users without
|
||||
** "jobs.hasAccess"; Workers additionally needs jobs_admin.
|
||||
*******************************************************************************/
|
||||
class JobsAppPermissionTest extends BaseJobsTest
|
||||
{
|
||||
private static final List<String> NON_ADMIN_WIDGETS = JobsQBitProducer.HOME_WIDGETS.stream().filter(w -> !"jobsHomeWorkers".equals(w)).toList();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The permission name is public and follows the configured app name.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void permissionNameAndRules()
|
||||
{
|
||||
assertThat(config.getHasAccessPermissionName()).isEqualTo("jobs.hasAccess");
|
||||
assertThat(new JobsQBitConfig().withAppName("bg").getHasAccessPermissionName()).isEqualTo("bg.hasAccess");
|
||||
assertThat(instance.getApp("jobs").getPermissionRules().getLevel()).isEqualTo(PermissionLevel.HAS_ACCESS_PERMISSION);
|
||||
assertThat(instance.getApp("jobs").getPermissionRules().getPermissionBaseName()).isEqualTo("jobs");
|
||||
assertThat(instance.getApp("jobs").getPermissionRules().getDenyBehavior()).isEqualTo(DenyBehavior.HIDDEN);
|
||||
assertThat(instance.getWidget("jobsJobProgress").getPermissionRules().getLevel()).isNotEqualTo(PermissionLevel.HAS_ACCESS_PERMISSION);
|
||||
for(String name : NON_ADMIN_WIDGETS)
|
||||
{
|
||||
assertThat(instance.getWidget(name).getPermissionRules().getPermissionBaseName()).isEqualTo("jobs");
|
||||
assertThat(instance.getWidget(name).getPermissionRules().getDenyBehavior()).isEqualTo(DenyBehavior.HIDDEN);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** No permissions: no app, no home widgets, every render denied.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void noPermissionsSeesNothing() throws Exception
|
||||
{
|
||||
instance.setEnforcePermissionsInCoreActions(true);
|
||||
login("nobody", false);
|
||||
MetaDataOutput output = new MetaDataAction().execute(new MetaDataInput());
|
||||
assertThat(output.getApps()).doesNotContainKey("jobs");
|
||||
assertThat(output.getWidgets()).doesNotContainKeys(JobsQBitProducer.HOME_WIDGETS.toArray(String[]::new));
|
||||
for(String name : JobsQBitProducer.HOME_WIDGETS)
|
||||
{
|
||||
assertThatThrownBy(() -> render(name, null)).isInstanceOf(QPermissionDeniedException.class);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** jobs.hasAccess: app and the five widgets, Workers only with jobs_admin.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void hasAccessSeesAppAndWidgets() throws Exception
|
||||
{
|
||||
instance.setEnforcePermissionsInCoreActions(true);
|
||||
login("user", false).withPermission(config.getHasAccessPermissionName());
|
||||
MetaDataOutput output = new MetaDataAction().execute(new MetaDataInput());
|
||||
assertThat(output.getApps()).containsKey("jobs");
|
||||
assertThat(output.getWidgets()).containsKeys(NON_ADMIN_WIDGETS.toArray(String[]::new));
|
||||
assertThat(output.getWidgets()).doesNotContainKey("jobsHomeWorkers");
|
||||
for(String name : NON_ADMIN_WIDGETS)
|
||||
{
|
||||
assertThat(render(name, null)).isNotNull();
|
||||
}
|
||||
assertThatThrownBy(() -> render("jobsHomeWorkers", null)).isInstanceOf(QPermissionDeniedException.class);
|
||||
|
||||
login("admin", true).withPermission(config.getHasAccessPermissionName());
|
||||
assertThat(new MetaDataAction().execute(new MetaDataInput()).getWidgets()).containsKey("jobsHomeWorkers");
|
||||
assertThat(render("jobsHomeWorkers", null)).isNotNull();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* 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
|
||||
*
|
||||
* https://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.jobs;
|
||||
|
||||
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.jobs.processes.JobsPurgeFinishedStep;
|
||||
import com.kingsrook.qbits.jobs.widgets.JobsFormat;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.instances.QInstanceEnricher;
|
||||
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.tables.delete.DeleteInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.ChildRecordListData;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.MultiStatisticsData;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
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.joins.JoinOn;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.joins.QJoinMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QFieldSection;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static com.kingsrook.qbits.jobs.JobsCoreContract.BYTES_DONE;
|
||||
import static com.kingsrook.qbits.jobs.JobsCoreContract.BYTES_TOTAL;
|
||||
import static com.kingsrook.qbits.jobs.JobsCoreContract.CHUNKS_TOTAL;
|
||||
import static com.kingsrook.qbits.jobs.JobsCoreContract.COMPLETED;
|
||||
import static com.kingsrook.qbits.jobs.JobsCoreContract.CURRENT_PHASE;
|
||||
import static com.kingsrook.qbits.jobs.JobsCoreContract.ETA_SECONDS;
|
||||
import static com.kingsrook.qbits.jobs.JobsCoreContract.ID;
|
||||
import static com.kingsrook.qbits.jobs.JobsCoreContract.JOBS_JOB;
|
||||
import static com.kingsrook.qbits.jobs.JobsCoreContract.JOBS_LOG;
|
||||
import static com.kingsrook.qbits.jobs.JobsCoreContract.JOBS_USER;
|
||||
import static com.kingsrook.qbits.jobs.JobsCoreContract.JOB_ID;
|
||||
import static com.kingsrook.qbits.jobs.JobsCoreContract.LEVEL;
|
||||
import static com.kingsrook.qbits.jobs.JobsCoreContract.LOGGED_AT;
|
||||
import static com.kingsrook.qbits.jobs.JobsCoreContract.LOG_DROPPED;
|
||||
import static com.kingsrook.qbits.jobs.JobsCoreContract.LOG_SEQ;
|
||||
import static com.kingsrook.qbits.jobs.JobsCoreContract.MESSAGE;
|
||||
import static com.kingsrook.qbits.jobs.JobsCoreContract.PERCENT_DONE;
|
||||
import static com.kingsrook.qbits.jobs.JobsCoreContract.PHASE;
|
||||
import static com.kingsrook.qbits.jobs.JobsCoreContract.PROGRESS_JSON;
|
||||
import static com.kingsrook.qbits.jobs.JobsCoreContract.PROGRESS_MESSAGE;
|
||||
import static com.kingsrook.qbits.jobs.JobsCoreContract.ROWS_TOTAL;
|
||||
import static com.kingsrook.qbits.jobs.JobsCoreContract.RUNNING;
|
||||
import static com.kingsrook.qbits.jobs.JobsCoreContract.USER_ID;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Optional RC.8 progress and logs, including the actual core log permission checker.
|
||||
*******************************************************************************/
|
||||
class JobsProgressTest extends BaseJobsTest
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Add optional fields and a log using the real kernel permission checker by
|
||||
** name, preserving the default suite's coverage of older kernel metadata.
|
||||
*******************************************************************************/
|
||||
private void enableProgress() throws Exception
|
||||
{
|
||||
for(String field : List.of(CURRENT_PHASE, PROGRESS_MESSAGE, PROGRESS_JSON))
|
||||
{
|
||||
instance.getTable(JOBS_JOB).withField(new QFieldMetaData(field, QFieldType.TEXT));
|
||||
}
|
||||
for(String field : List.of(PERCENT_DONE, LOG_DROPPED))
|
||||
{
|
||||
instance.getTable(JOBS_JOB).withField(new QFieldMetaData(field, QFieldType.INTEGER));
|
||||
}
|
||||
for(String field : List.of(BYTES_DONE, BYTES_TOTAL, ETA_SECONDS, LOG_SEQ))
|
||||
{
|
||||
instance.getTable(JOBS_JOB).withField(new QFieldMetaData(field, QFieldType.LONG));
|
||||
}
|
||||
instance.setEnforcePermissionsInCoreActions(true);
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
// core 4.1.0-RC.8 defines jobs_log; older metadata gets this mirror //
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
if(instance.getTable(JOBS_LOG) != null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
instance.addTable(new QTableMetaData().withName(JOBS_LOG).withBackendName("memory").withPrimaryKeyField(ID)
|
||||
.withField(new QFieldMetaData(ID, QFieldType.LONG))
|
||||
.withField(new QFieldMetaData(JOB_ID, QFieldType.INTEGER))
|
||||
.withField(new QFieldMetaData(LEVEL, QFieldType.STRING))
|
||||
.withField(new QFieldMetaData(LOGGED_AT, QFieldType.DATE_TIME))
|
||||
.withField(new QFieldMetaData(PHASE, QFieldType.STRING))
|
||||
.withField(new QFieldMetaData(MESSAGE, QFieldType.STRING))
|
||||
.withPermissionRules(new QPermissionRules().withCustomPermissionChecker(new QCodeReference(
|
||||
Class.forName("com.kingsrook.qqq.backend.core.jobs.progress.JobLogPermissions"))))
|
||||
.withRecordSecurityLock(new RecordSecurityLock().withSecurityKeyType(JOBS_USER).withFieldName(JOBS_JOB + "." + USER_ID)
|
||||
.withJoinNameChain(List.of(JobsCoreContract.childJoin(JOBS_LOG)))));
|
||||
instance.addJoin(new QJoinMetaData().withLeftTable(JOBS_JOB).withRightTable(JOBS_LOG).withType(JoinType.ONE_TO_MANY)
|
||||
.withJoinOn(new JoinOn(ID, JOB_ID)).withInferredName());
|
||||
clearPresentation();
|
||||
new JobsQBitProducer().withQBitConfig(config).produce(instance);
|
||||
new QInstanceEnricher(instance).enrich();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Rich fields override counters, resolve labels, and remain typed and flexible.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void richProgressOnHomeAndRecord() throws Exception
|
||||
{
|
||||
enableProgress();
|
||||
QRecord job = job(USER_A, RUNNING, null);
|
||||
update(job, new QRecord().withValue(CURRENT_PHASE, "upload").withValue(PERCENT_DONE, 73)
|
||||
.withValue(PROGRESS_MESSAGE, "Uploading manifest").withValue(BYTES_DONE, 1536L).withValue(BYTES_TOTAL, 1048576L)
|
||||
.withValue(ETA_SECONDS, 90L).withValue(LOG_DROPPED, 12)
|
||||
.withValue(PROGRESS_JSON, "{\"phases\":[{\"name\":\"upload\",\"label\":\"Upload backup\"}]}"));
|
||||
login(USER_A, false).withPermission(config.getHasAccessPermissionName());
|
||||
TableData home = (TableData) render("jobsHomeActive", null);
|
||||
assertThat(home.getRows()).singleElement().satisfies(row -> assertThat(row)
|
||||
.containsEntry("Progress", "73%").containsEntry("Phase", "Upload backup")
|
||||
.containsEntry("Message", "Uploading manifest").containsEntry("Bytes", "1.5 KiB / 1.0 MiB").containsEntry("ETA", "in 1m"));
|
||||
assertThat(home.getColumns()).hasSize(3).allSatisfy(column -> assertThat(column.getWidth()).isNull());
|
||||
MultiStatisticsData record = (MultiStatisticsData) render("jobsJobProgress", job.getValueString(ID));
|
||||
assertThat(record.getStatisticsGroupData()).extracting(MultiStatisticsData.StatisticsGroupData::getSubheader)
|
||||
.contains("Upload backup | Uploading manifest | 1.5 KiB / 1.0 MiB", "73% done", "ETA in 1m", "12 earlier lines not kept");
|
||||
login(USER_B, false).withPermission(config.getHasAccessPermissionName());
|
||||
assertThat(((TableData) render("jobsHomeActive", null)).getRows()).isEmpty();
|
||||
assertThat(((MultiStatisticsData) render("jobsJobProgress", job.getValueString(ID))).getStatisticsGroupData()).isEmpty();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Null percent uses rows then chunks, zero is explicit, malformed JSON is safe.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void fallbacksAndPartialProgress() throws Exception
|
||||
{
|
||||
enableProgress();
|
||||
QRecord job = job(USER_A, RUNNING, null);
|
||||
update(job, new QRecord().withValue(CURRENT_PHASE, "copy").withValue(PROGRESS_JSON, "bad json").withValue(BYTES_TOTAL, 1024L));
|
||||
assertThat(((TableData) render("jobsHomeActive", null)).getRows().getFirst())
|
||||
.containsEntry("Progress", "45%").containsEntry("Phase", "copy").containsEntry("Bytes", "1.0 KiB total");
|
||||
update(job, new QRecord().withValue(ROWS_TOTAL, null).withValue(PROGRESS_JSON, "null"));
|
||||
assertThat(((TableData) render("jobsHomeActive", null)).getRows().getFirst()).containsEntry("Progress", "40%");
|
||||
update(job, new QRecord().withValue(PERCENT_DONE, 0).withValue(BYTES_TOTAL, null).withValue(BYTES_DONE, 0L));
|
||||
assertThat(((TableData) render("jobsHomeActive", null)).getRows().getFirst()).containsEntry("Progress", "0%").containsEntry("Bytes", "0 B");
|
||||
update(job, new QRecord().withValue(PERCENT_DONE, null).withValue(CHUNKS_TOTAL, null));
|
||||
assertThat(((TableData) render("jobsHomeActive", null)).getRows().getFirst()).containsEntry("Progress", "Unknown");
|
||||
assertThat(JobsFormat.bytes(1073741824L)).isEqualTo("1.0 GiB");
|
||||
assertThat(JobsFormat.bytes(Long.MAX_VALUE)).isEqualTo("8.0 EiB");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The log is ordered, full width and owner scoped, with no user mutations.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void logPanelAndPermissions() throws Exception
|
||||
{
|
||||
enableProgress();
|
||||
QRecord job = job(USER_A, RUNNING, null);
|
||||
log(job, 1000000002L);
|
||||
log(job, 1000000001L);
|
||||
assertThat(instance.getWidget("jobsJobLog").getGridColumns()).isEqualTo(12);
|
||||
assertThat(instance.getTable(JOBS_JOB).getSections()).extracting(QFieldSection::getWidgetName)
|
||||
.containsSubsequence("jobsJobProgress", "jobsJobLog", "jobsJobEvents");
|
||||
login(USER_A, false).withPermission("jobs_job.read");
|
||||
ChildRecordListData data = (ChildRecordListData) render("jobsJobLog", job.getValueString(ID));
|
||||
assertThat(data.getQueryOutput().getRecords()).extracting(record -> record.getValueLong(ID)).containsExactly(1000000002L, 1000000001L);
|
||||
assertThat(data.getOnlyIncludeFieldNames()).containsExactly(LEVEL, LOGGED_AT, PHASE, MESSAGE);
|
||||
assertThat(data.getCanAddChildRecord()).isFalse();
|
||||
assertThatThrownBy(() -> new DeleteAction().execute(new DeleteInput().withTableName(JOBS_LOG).withPrimaryKey(1000000001L))).hasMessageContaining("read-only");
|
||||
assertThatThrownBy(() -> log(job, 1000000003L)).hasMessageContaining("read-only");
|
||||
login("admin", true).withPermission("jobs_job.read");
|
||||
assertThatThrownBy(() -> new UpdateAction().execute(new UpdateInput().withTableName(JOBS_LOG)
|
||||
.withRecord(new QRecord().withValue(ID, 1000000001L).withValue(MESSAGE, "changed")))).hasMessageContaining("read-only");
|
||||
login(USER_B, false).withPermission("jobs_job.read");
|
||||
assertThatThrownBy(() -> render("jobsJobLog", job.getValueString(ID))).hasMessageContaining("Could not find");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The system purge crosses a full batch with LONG IDs; active logs remain.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void purgeLogBatchesWithSystemPermission() throws Exception
|
||||
{
|
||||
enableProgress();
|
||||
QRecord old = job(USER_A, COMPLETED, now.minus(31, ChronoUnit.DAYS));
|
||||
QRecord active = job(USER_B, RUNNING, null);
|
||||
for(long id = 1000000000L; id < 1000000205L; id++)
|
||||
{
|
||||
log(old, id);
|
||||
}
|
||||
log(active, 1000000300L);
|
||||
QRecord held = job(USER_A, COMPLETED, now.minus(31, ChronoUnit.DAYS));
|
||||
effect(held, JobsCoreContract.PENDING, false);
|
||||
log(held, 1000000301L);
|
||||
var caller = login("admin", true);
|
||||
new JobsPurgeFinishedStep().run(new RunBackendStepInput(), new RunBackendStepOutput());
|
||||
assertThat(QContext.getQSession()).isSameAs(caller);
|
||||
QContext.setQSession(new QSystemUserSession());
|
||||
assertThat(records(JOBS_LOG)).extracting(record -> record.getValue(JOB_ID)).containsExactlyInAnyOrder(active.getValue(ID), held.getValue(ID));
|
||||
assertThat(records(JOBS_JOB)).extracting(record -> record.getValue(ID)).containsExactlyInAnyOrder(active.getValue(ID), held.getValue(ID));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Log metadata is optional; hosts choose the Admin/Operations parent.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void optionalLogAndHostNavigation() throws Exception
|
||||
{
|
||||
assertThat(instance.getWidget("jobsJobLog")).as("core RC.8 defines jobs_log, so the Log list is produced").isNotNull();
|
||||
enableProgress();
|
||||
instance.getJoins().remove(JobsCoreContract.childJoin(JOBS_LOG));
|
||||
instance.getWidgets().remove("jobsJobLog");
|
||||
clearPresentation();
|
||||
new JobsQBitProducer().produce(instance);
|
||||
assertThat(instance.getWidget("jobsJobLog")).isNull();
|
||||
QAppMetaData admin = new QAppMetaData().withName("admin").withLabel("Admin");
|
||||
QAppMetaData operations = new QAppMetaData().withName("operations").withLabel("Operations");
|
||||
operations.setParentAppName("admin");
|
||||
instance.addApp(admin.withChild(operations));
|
||||
instance.addApp(operations);
|
||||
config.withAppName("backgroundJobs").withParentAppName("operations");
|
||||
clearPresentation();
|
||||
new JobsQBitProducer().withQBitConfig(config).produce(instance);
|
||||
assertThat(instance.getApp("backgroundJobs").getParentAppName()).isEqualTo("operations");
|
||||
assertThat(operations.getChildren()).extracting(child -> child.getName()).containsExactly("backgroundJobs");
|
||||
config.setParentAppName("missing");
|
||||
assertThatThrownBy(() -> config.validate(instance)).hasMessageContaining("parentAppName");
|
||||
config.setParentAppName("backgroundJobs");
|
||||
assertThatThrownBy(() -> config.validate(instance)).hasMessageContaining("parentAppName");
|
||||
config.setParentAppName(null);
|
||||
config.validate(instance);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Rebuild presentation against changed optional metadata in these tests.
|
||||
*******************************************************************************/
|
||||
private void clearPresentation()
|
||||
{
|
||||
instance.getQBits().clear();
|
||||
instance.getWidgets().clear();
|
||||
instance.getApps().remove("jobs");
|
||||
instance.getProcesses().remove(JobsQBitProducer.PURGE_PROCESS);
|
||||
instance.getTable(JOBS_JOB).getSections().removeIf(section -> section.getWidgetName() != null);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Persist progress through core actions.
|
||||
*******************************************************************************/
|
||||
private void update(QRecord job, QRecord values) throws Exception
|
||||
{
|
||||
var output = new UpdateAction().execute(new UpdateInput().withTableName(JOBS_JOB).withRecord(values.withValue(ID, job.getValue(ID))));
|
||||
assertThat(output.getRecords().getFirst().getErrors()).isNullOrEmpty();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Explicit LONG cursors make ordering assertions deterministic. Core memory
|
||||
** sequence bookkeeping currently restricts inserts to the integer range.
|
||||
*******************************************************************************/
|
||||
private void log(QRecord job, long id) throws Exception
|
||||
{
|
||||
insert(JOBS_LOG, new QRecord().withValue(ID, id).withValue(JOB_ID, job.getValue(ID)).withValue(LEVEL, "INFO")
|
||||
.withValue(LOGGED_AT, now).withValue(PHASE, "upload").withValue(MESSAGE, "Wrote manifest"));
|
||||
}
|
||||
}
|
||||
@@ -97,13 +97,13 @@ class JobsQBitProducerTest extends BaseJobsTest
|
||||
@Test
|
||||
void navigationWidgetsAndRecordSections()
|
||||
{
|
||||
assertThat(instance.getTables()).hasSize(11);
|
||||
assertThat(instance.getTables()).hasSize(12);
|
||||
assertThat(instance.getApp("jobs").getChildren()).extracting(child -> child.getName()).containsExactly(JOBS_JOB);
|
||||
assertThat(instance.getApp("jobs").getWidgets()).containsExactlyElementsOf(JobsQBitProducer.HOME_WIDGETS);
|
||||
assertThat(instance.getWidgets()).hasSize(12);
|
||||
assertThat(instance.getWidgets()).hasSize(13);
|
||||
assertThat(instance.getWidgets().values()).allSatisfy(widget -> assertThat(widget.getType()).isNotEqualTo(WidgetType.HTML.getType()));
|
||||
assertThat(instance.getTable(JOBS_JOB).getSections()).extracting(QFieldSection::getWidgetName)
|
||||
.containsSubsequence("jobsJobProgress", "jobsJobChunks", "jobsJobRowErrors", "jobsJobEffects", "jobsJobEvents", "jobsJobAudit");
|
||||
.containsSubsequence("jobsJobProgress", "jobsJobLog", "jobsJobChunks", "jobsJobRowErrors", "jobsJobEffects", "jobsJobEvents", "jobsJobAudit");
|
||||
for(String name : JobsQBitProducer.HOME_WIDGETS)
|
||||
{
|
||||
assertThat(instance.getWidget(name).getDefaultValues()).containsEntry("refreshSeconds", 5);
|
||||
@@ -132,7 +132,7 @@ class JobsQBitProducerTest extends BaseJobsTest
|
||||
.withPurgeCronExpression("0 0 4 * * ?").withHomeRefreshSeconds(9).withRecordRefreshSeconds(8);
|
||||
new JobsQBitProducer().withQBitConfig(custom).produce(other);
|
||||
assertThat(other.getWidget("jobsJobAudit")).isNull();
|
||||
assertThat(other.getWidgets()).hasSize(11);
|
||||
assertThat(other.getWidgets()).hasSize(12);
|
||||
assertThat(other.getApp("backgroundJobs")).isNotNull();
|
||||
QScheduleMetaData schedule = other.getProcess(JobsQBitProducer.PURGE_PROCESS).getSchedule();
|
||||
assertThat(schedule.getSchedulerName()).isEqualTo("scheduler");
|
||||
|
||||
Reference in new issue
Block a user