Compare commits

...
Author SHA1 Message Date
james.maesandCodex 18905b0067 ci: use the shared branch-aware CodeQL worker
Co-Authored-By: Codex <noreply@openai.com>
2026-10-21 10:02:17 -05:00
james.maesandCodex 9db6d4bb59 ci: adopt central orb 0.7.12 and stable gates (#31)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 08:58:18 -05:00
james.maesandCodex 9f581996d0 ci: centralize rc release completion in qqq orb (#30)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 02:30:33 -05:00
james.maesandCodex c9d324acd4 ci: adopt shared orb 0.7.11 (#29)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 01:01:51 -05:00
james.maes cd2128f78f Merge pull request #27 from QRun-IO/feature/central-ci
ci: adopt verified central QQQ orb 0.7.1
2026-10-20 18:53:02 -05:00
james.maesandCodex 3d336c3dfe ci: adopt verified central QQQ orb 0.7.2
Co-Authored-By: Codex <noreply@openai.com>
2026-10-20 18:42:43 -05:00
james.maes 73411423df Merge pull request #26 from QRun-IO/feature/edi-business-home
fix(app): business-only EDI home; system widgets for the Admin home
2026-10-16 10:19:39 -05:00
james.maesandClaude Opus 5.5 cccd0ac0ab fix(app): business-only EDI home; system widgets for the Admin home
Move System, Dead letters, Certificates expiring and Setup status off the
EDI home into EdiHome.ADMIN_WIDGET_NAMES, gated to edi.admin.hasAccess
(hidden when denied) and full width, for a host to place on its Admin
home. Every remaining home widget requires edi.hasAccess (hidden when
denied); none is NOT_PROTECTED.

Add "Inbound documents are delayed": an alert at the top of the home,
shown only when the oldest unprocessed inbound document is older than
inboundDelayedAfterMinutes (default 60), with its age and a link to the
filtered inbound documents.

Setup counts that fail show "Unavailable" and are logged, never -1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 09:28:46 -05:00
james.maes 2681447b07 Merge pull request #25 from QRun-IO/ci/release-rc-secrets-inherit
ci(release-rc): pass secrets so the shared workflow can sign the RC tag
2026-10-16 08:05:13 -05:00
james.maesandClaude Opus 5.5 55a28c7031 ci(release-rc): pass secrets to the shared workflow so it can sign the RC tag
QRun-IO/.github#2 adds a tag-and-release job that signs vX with the
QRUN_TAG_SIGNING_KEY secret after Maven Central confirms the artifact.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 16:19:12 -05:00
james.maes 2491669f23 Merge pull request #24 from QRun-IO/feature/ui-api-1.6
feat(ui-api): extension for QQQ UI API 1.6.0 (operationIds, BasicErrorResponseV1, QQQ 4.1.0-RC.15)
2026-10-15 16:13:04 -05:00
15 changed files with 486 additions and 101 deletions

No files matched your search

+11 -57
View File
@@ -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.7.12
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.7.12
filters:
branches:
ignore: automation/qbit-rc-pending
tags:
only: /^v\d+\.\d+\.\d+$/
+23
View File
@@ -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
+62 -13
View File
@@ -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
View File
@@ -0,0 +1,16 @@
profile: qbit
orb_version: 0.7.12
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
+2
View File
@@ -6,6 +6,8 @@ Following [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and [Semantic
### Changed
- EDI home is business-only: System, Dead letters, Certificates expiring and Setup status move to `EdiHome.ADMIN_WIDGET_NAMES` for the host's Admin home (gated to `edi.admin.hasAccess`, full width); every home widget now requires `edi.hasAccess` and is hidden without it. A new "Inbound documents are delayed" alert links to the delayed inbound documents once the oldest is older than `inboundDelayedAfterMinutes` (default 60). Setup counts that fail show "Unavailable" and are logged instead of -1.
- UI API extension edi 1.1.0 (requires QQQ 4.1.0-RC.15, UI API 1.6.0): operationIds, standard error responses with BasicErrorResponseV1, extension tags, and retained deprecated EdiErrorV1 schema.
- Derive qbit metadata version from the Maven artifact version, keeping release metadata and help bundle versions in sync.
+11 -3
View File
@@ -200,8 +200,9 @@ Everything else is host or runtime data, and every table explains itself when em
4. Agreement documents: one per transaction set and direction, with the guide and optionally a map.
5. Transport (AS2, SFTP, HTTPS), linked on the agreement. Certificates only for AS2 signing and encryption.
The **Setup status** section on the home shows each step, whether it is done (counting only the
active rows the runtime uses) and the next step.
The **Setup status** widget (`edi_home_setup`, for the host's Admin home) shows each step, whether
it is done (counting only the active rows the runtime uses) and the next step. A count that fails
shows "Unavailable" and is logged.
Maps start empty. The **Install sample maps** action on Maps adds the 32 bundled synthetic examples
as draft maps with no partner and no agreement document, by name and never over an existing map. Point
@@ -229,7 +230,14 @@ Full table permissions include `edi.partner.read|insert|edit|delete`, `edi.map.*
| Scheduled workers / ESB enqueue | `edi.admin` |
Partner locks use `edi_partner` with `edi.admin` all-access. Home queries explicitly enforce
the viewing user's permissions. Processes are table/record actions, never navigation entries.
the viewing user's permissions.
The EDI home shows business work only. Its widgets (`EdiHome.HOME_WIDGET_NAMES`) need
`edi.hasAccess` and are hidden without it. "Inbound documents delayed" appears only when the oldest
unprocessed inbound document is older than `inboundDelayedAfterMinutes` (default 60), with a link to
those documents. System widgets (`EdiHome.ADMIN_WIDGET_NAMES`: System, Dead letters, Certificates
expiring, Setup status) are not on the EDI home; they need `edi.admin.hasAccess`, are hidden without
it, and a host places them on its Admin home. Processes are table/record actions, never navigation entries.
## Configuration
+3 -1
View File
@@ -5,5 +5,7 @@
- No schema change and no Liquibase include.
- Behavior change: an inactive identity (`isActive` false) now stops X12 routing and sending, as it already did for EDIFACT. Inbound documents from or to it are rejected as an unknown partner, and outbound requests for its agreements wait until it is active. Identities default to active, so only identities someone switched off are affected.
- Hosts that already call `EdiRuntime.initialize()` at startup keep getting the 41 built-in guides and code lists. A host that never called it will now see "Needs setup" for Guides and code lists on the home.
- New home section "Setup status" (widget `edi_home_setup`).
- New widget "Setup status" (`edi_home_setup`).
- Behavior change: the EDI home shows business widgets only (`EdiHome.HOME_WIDGET_NAMES`), each now requiring `edi.hasAccess` (hidden without it). `edi_home_system`, `edi_home_dead`, `edi_home_certificates` and `edi_home_setup` left the EDI home: they are listed in `EdiHome.ADMIN_WIDGET_NAMES`, require `edi.admin.hasAccess` (hidden without it), and the host places them on its Admin home.
- New business widget `edi_home_delayed` ("Inbound documents are delayed"), shown only when the oldest unprocessed inbound document is older than the new config value `inboundDelayedAfterMinutes` (default 60).
- New table action "Install sample maps" on Maps, permission `edi.admin`. It is never run automatically and only adds draft maps whose name does not exist yet.
@@ -89,6 +89,7 @@ public class EdiQBitConfig implements QBitConfig
private String appLabel = "EDI";
private String parentAppName;
private String publicBaseUrl;
private Integer inboundDelayedAfterMinutes = 60;
@@ -114,6 +115,10 @@ public class EdiQBitConfig implements QBitConfig
{
errors.add("secretsResolver and partnerLocks are required");
}
if(inboundDelayedAfterMinutes == null || inboundDelayedAfterMinutes < 1)
{
errors.add("inboundDelayedAfterMinutes must be at least 1");
}
if(appName == null || appName.isBlank())
{
errors.add("appName is required");
@@ -390,4 +395,22 @@ public class EdiQBitConfig implements QBitConfig
publicBaseUrl = value;
return (this);
}
/*******************************************************************************
** How old (in minutes) the oldest unprocessed inbound document may get before
** the EDI home says "Inbound documents are delayed". Default 60.
*******************************************************************************/
public Integer getInboundDelayedAfterMinutes()
{
return (inboundDelayedAfterMinutes);
}
/*******************************************************************************
** Set the inbound delay threshold in minutes.
*******************************************************************************/
public EdiQBitConfig withInboundDelayedAfterMinutes(Integer value)
{
inboundDelayedAfterMinutes = value;
return (this);
}
}
@@ -40,6 +40,8 @@ import com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement;
import com.kingsrook.qqq.backend.core.actions.tables.CountAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
@@ -47,6 +49,7 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.AlertData;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.StatisticsData;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType;
@@ -56,9 +59,11 @@ 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.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.tables.QTableMetaData;
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
/*******************************************************************************
** Typed EDI home. Every data query explicitly enforces the viewing user's access.
@@ -67,9 +72,31 @@ public class EdiHome extends AbstractWidgetRenderer
{
public static final List<String> ACTIVITY = List.of("edi_transaction", "edi_interchange", "edi_exception");
public static final List<String> SETUP = List.of("edi_partner", "edi_agreement", "edi_transport", "edi_certificate", "edi_guide", "edi_code_list", "edi_map");
private static final Map<String, String> LABELS = new LinkedHashMap<>();
/*******************************************************************************
** System-audience widgets (component and queue health, dead letters,
** certificate expiry, setup and configuration). They are not on the EDI
** business home: a host places them on its Admin home. Each is gated to
** ADMIN_PERMISSION_BASE_NAME and hidden from everyone else.
*******************************************************************************/
public static final List<String> ADMIN_WIDGET_NAMES = List.of("edi_home_system", "edi_home_dead", "edi_home_certificates", "edi_home_setup");
/*******************************************************************************
** Business widgets on the EDI home, in order. Each is gated to the EDI app
** permission (edi.hasAccess) and hidden from everyone else.
*******************************************************************************/
public static final List<String> HOME_WIDGET_NAMES = List.of("edi_home_delayed", "edi_home_in_today", "edi_home_out_today", "edi_home_exceptions",
"edi_home_overdue", "edi_home_queue", "edi_home_acks", "edi_home_errors", "edi_home_volume");
/*******************************************************************************
** The EDI administrator permission (edi.admin.hasAccess), as on the admin processes.
*******************************************************************************/
public static final String ADMIN_PERMISSION_BASE_NAME = EdiQBitConfig.PERMISSION_BASE_NAME + ".admin";
private static final QLogger LOG = QLogger.getLogger(EdiHome.class);
private static final Map<String, String> LABELS = new LinkedHashMap<>();
private static final Map<String, String> DOCUMENT_NAMES = new HashMap<>();
private static final List<String> TABLES = List.of("setup", "queue", "acks", "errors", "volume", "system");
private static final List<String> TABLES = List.of("setup", "queue", "acks", "errors", "volume", "system");
static
{
@@ -79,6 +106,7 @@ public class EdiHome extends AbstractWidgetRenderer
Map.entry("835", "Remittance"), Map.entry("270", "Eligibility Inquiry"), Map.entry("271", "Eligibility Response"), Map.entry("276", "Claim Status Inquiry"),
Map.entry("277", "Claim Status Response"), Map.entry("834", "Enrollment"), Map.entry("997", "Functional Ack"), Map.entry("999", "Implementation Ack"),
Map.entry("TA1", "Interchange Ack")));
LABELS.put("delayed", "Inbound documents delayed");
LABELS.put("in_today", "Inbound today");
LABELS.put("out_today", "Outbound today");
LABELS.put("exceptions", "Open exceptions");
@@ -99,7 +127,7 @@ public class EdiHome extends AbstractWidgetRenderer
public static QAppMetaData app(EdiQBitConfig config, List<QTableMetaData> tables)
{
QAppMetaData app = new QAppMetaData().withName(config.getAppName()).withLabel(config.getAppLabel()).withIcon(new QIcon("swap_horiz"))
.withWidgets(LABELS.keySet().stream().map(n -> "edi_home_" + n).toList())
.withWidgets(HOME_WIDGET_NAMES)
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.NOT_PROTECTED))
.withSection(new QAppSection().withName("activity").withLabel("Activity").withTables(ACTIVITY))
.withSection(new QAppSection().withName("setup").withLabel("Setup").withTables(SETUP));
@@ -109,15 +137,23 @@ public class EdiHome extends AbstractWidgetRenderer
}
/*******************************************************************************
** RC.5 typed statistics and tables; no raw markup in metadata or widget data.
** RC.5 typed statistics, tables and one alert. Business widgets need the EDI
** app permission; the ADMIN_WIDGET_NAMES need the EDI administrator
** permission. Denied widgets are hidden, never shown as locked shells.
*******************************************************************************/
public static List<QWidgetMetaData> widgets()
{
List<QWidgetMetaData> widgets = new ArrayList<>();
LABELS.forEach((name, label) -> widgets.add(new QWidgetMetaData().withName("edi_home_" + name).withLabel(label)
.withType((TABLES.contains(name) ? WidgetType.TABLE : WidgetType.STATISTICS).getType())
.withGridColumns(TABLES.contains(name) ? 12 : 2).withCodeReference(new QCodeReference(EdiHome.class))
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.NOT_PROTECTED))));
LABELS.forEach((name, label) ->
{
String widgetName = "edi_home_" + name;
boolean admin = ADMIN_WIDGET_NAMES.contains(widgetName);
String type = name.equals("delayed") ? WidgetType.ALERT.getType() : (TABLES.contains(name) ? WidgetType.TABLE : WidgetType.STATISTICS).getType();
widgets.add(new QWidgetMetaData().withName(widgetName).withLabel(label).withType(type)
.withGridColumns(TABLES.contains(name) || admin || name.equals("delayed") ? 12 : 2).withCodeReference(new QCodeReference(EdiHome.class))
.withPermissionRules(new QPermissionRules().withPermissionBaseName(admin ? ADMIN_PERMISSION_BASE_NAME : EdiQBitConfig.PERMISSION_BASE_NAME)
.withLevel(PermissionLevel.HAS_ACCESS_PERMISSION).withDenyBehavior(DenyBehavior.HIDDEN)));
});
return (widgets);
}
@@ -137,6 +173,7 @@ public class EdiHome extends AbstractWidgetRenderer
case "exceptions" -> count("edi_exception", openExceptions());
case "overdue" -> count("edi_functional_group", overdueAcks(now));
case "dead" -> count("edi_exception", openExceptions().withCriteria("type", QCriteriaOperator.IN, "DEAD_LETTER", "TRANSPORT"));
case "delayed" -> delayed(now);
case "certificates" -> count("edi_certificate", new QQueryFilter().withCriteria("notAfter", QCriteriaOperator.LESS_THAN_OR_EQUALS, now.plus(30, ChronoUnit.DAYS))
.withCriteria("status", QCriteriaOperator.EQUALS, "ACTIVE"));
case "setup" -> setup();
@@ -219,9 +256,10 @@ public class EdiHome extends AbstractWidgetRenderer
/*******************************************************************************
** A count the viewer may not be allowed to read: -1 instead of an error.
** A count that may fail (for example a table the viewer cannot read): null,
** shown as "Unavailable", and logged; never a made-up number.
*******************************************************************************/
private static int safeCount(String table, QQueryFilter filter)
static Integer safeCount(String table, QQueryFilter filter)
{
try
{
@@ -229,12 +267,58 @@ public class EdiHome extends AbstractWidgetRenderer
}
catch(QException | RuntimeException e)
{
return (-1);
LOG.warn("EDI setup count unavailable", e, logPair("table", table));
return (null);
}
}
/*******************************************************************************
** The business face of a stalled inbound flow: when the oldest unprocessed
** inbound document is older than the configured threshold, a warning with its
** age that links to those documents. Hidden when nothing is delayed, or when
** the viewer cannot read the inbound documents.
*******************************************************************************/
private static RenderWidgetOutput delayed(Instant now) throws QException
{
Integer minutes = EdiRuntime.current().config().getInboundDelayedAfterMinutes();
Instant cutoff = now.minus(minutes, ChronoUnit.MINUTES);
List<QRecord> oldest = List.of();
try
{
oldest = query("edi_inbox", delayedInbound(cutoff).withOrderBy(new QFilterOrderBy("receivedAt", true)).withLimit(1));
}
catch(QPermissionDeniedException e)
{
///////////////////////////////////////////////////////////
// omitted, not locked: the viewer cannot open the inbox //
///////////////////////////////////////////////////////////
}
if(oldest.isEmpty())
{
return (new RenderWidgetOutput(new AlertData().withHideWidget(true)));
}
int count = countOf("edi_inbox", delayedInbound(cutoff));
String age = humanize(Duration.between(oldest.getFirst().getValueInstant("receivedAt"), now));
String html = "<strong>Inbound documents are delayed.</strong> " + count + (count == 1 ? " document has" : " documents have")
+ " waited more than " + humanize(Duration.ofMinutes(minutes)) + " to be processed; the oldest has waited " + age + ". "
+ "<a href=\"" + link("edi_inbox", delayedInbound(cutoff)) + "\">View delayed documents</a>";
return (new RenderWidgetOutput(new AlertData(AlertData.AlertType.WARNING, html)));
}
/*******************************************************************************
** Unprocessed inbound documents received before the cutoff.
*******************************************************************************/
static QQueryFilter delayedInbound(Instant cutoff)
{
return (inboxBacklog().withCriteria("receivedAt", QCriteriaOperator.LESS_THAN, cutoff));
}
/*******************************************************************************
** Open exceptions, oldest first, in four narrow columns.
*******************************************************************************/
@@ -34,11 +34,12 @@ public final class EdiSetupStatus
{
/*******************************************************************************
** DONE: working. TODO: the exchange needs it. BUILT_IN: provided by the
** qbit. OPTIONAL: only some setups need it. UNKNOWN: the viewer cannot see it.
** qbit. OPTIONAL: only some setups need it. UNKNOWN: its count failed (for
** example the viewer cannot read the table), shown as "Unavailable".
*******************************************************************************/
public enum State
{
DONE("Set up"), TODO("Needs setup"), BUILT_IN("Built in"), OPTIONAL("Optional"), UNKNOWN("Not visible to you");
DONE("Set up"), TODO("Needs setup"), BUILT_IN("Built in"), OPTIONAL("Optional"), UNKNOWN("Unavailable");
private final String label;
@@ -67,11 +68,13 @@ public final class EdiSetupStatus
}
/*******************************************************************************
** The row counts the card is built from; -1 means the viewer cannot read it.
** Active counts are rows with isActive true (what the runtime uses).
** The row counts the card is built from; null means the count failed and the
** step shows as unavailable. Active counts are rows with isActive true (what
** the runtime uses).
*******************************************************************************/
public record Counts(int partners, int ownIdentities, int partnerIdentities, int agreements, int activeAgreements, int agreementDocuments,
int activeAgreementDocuments, int transports, int activeTransports, int certificates, int guides, int codeLists, int maps, int activeMaps)
public record Counts(Integer partners, Integer ownIdentities, Integer partnerIdentities, Integer agreements, Integer activeAgreements, Integer agreementDocuments,
Integer activeAgreementDocuments, Integer transports, Integer activeTransports, Integer certificates, Integer guides, Integer codeLists,
Integer maps, Integer activeMaps)
{
}
@@ -96,15 +99,15 @@ public final class EdiSetupStatus
"Add one per transaction set and direction on Agreement Documents: pick the guide, and a map if it should become records. A document type with no row is not processed."));
items.add(item("5. Transport", "edi_transport", c.transports(), c.activeTransports(), "transport",
"Add the AS2, SFTP or HTTPS connection on Transports (secrets as variable names), link it on the agreement, then run Test Connection."));
items.add(c.certificates() < 0 ? new Item("Certificates", "edi_certificate", State.UNKNOWN, "", "")
items.add(c.certificates() == null ? new Item("Certificates", "edi_certificate", State.UNKNOWN, "", "")
: c.certificates() == 0
? new Item("Certificates", "edi_certificate", State.OPTIONAL, "None.", "Needed only for AS2 signing and encryption: add yours and the partner's on Certificates.")
: new Item("Certificates", "edi_certificate", State.DONE, c.certificates() + (c.certificates() == 1 ? " certificate." : " certificates."), ""));
items.add(c.guides() < 0 || c.codeLists() < 0 ? new Item("Guides and code lists", "edi_guide", State.UNKNOWN, "", "")
items.add(c.guides() == null || c.codeLists() == null ? new Item("Guides and code lists", "edi_guide", State.UNKNOWN, "", "")
: c.guides() == 0 || c.codeLists() == 0
? new Item("Guides and code lists", "edi_guide", State.TODO, "The built-in guides are not loaded.", "The host must call EdiRuntime.initialize() at startup, after the schema exists.")
: new Item("Guides and code lists", "edi_guide", State.BUILT_IN, c.guides() + " guides and " + c.codeLists() + " code lists.", ""));
items.add(c.maps() < 0 ? new Item("Maps", "edi_map", State.UNKNOWN, "", "")
items.add(c.maps() == null || c.activeMaps() == null ? new Item("Maps", "edi_map", State.UNKNOWN, "", "")
: c.maps() == 0
? new Item("Maps", "edi_map", State.OPTIONAL, "None.", "Needed to turn documents into records. Run Install sample maps on Maps to start from an example.")
: c.activeMaps() == 0
@@ -119,7 +122,7 @@ public final class EdiSetupStatus
private static Item identities(Counts c)
{
String step = "2. Identities";
if(c.ownIdentities() < 0 || c.partnerIdentities() < 0)
if(c.ownIdentities() == null || c.partnerIdentities() == null)
{
return (new Item(step, "edi_identity", State.UNKNOWN, "", ""));
}
@@ -137,9 +140,9 @@ public final class EdiSetupStatus
/*******************************************************************************
** A step that needs at least one active row.
*******************************************************************************/
private static Item item(String step, String table, int total, int active, String noun, String next)
private static Item item(String step, String table, Integer total, Integer active, String noun, String next)
{
if(total < 0 || active < 0)
if(total == null || active == null)
{
return (new Item(step, table, State.UNKNOWN, "", ""));
}
+2
View File
@@ -1693,6 +1693,8 @@ entries:
summary: Compare document volume by partner and document type over seven days.
- key: widget:edi_home_system;slot:label
summary: Review the EDI system status and processing activity.
- key: widget:edi_home_delayed;slot:label
summary: Warns when incoming documents have waited longer than expected to be processed.
- key: possibleValueSource:edi_standard;slot:help
summary: The available choices for standard.
- key: possibleValueSource:edi_partner_status;slot:help
@@ -0,0 +1,217 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. 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.edi;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import java.time.temporal.ChronoUnit;
import java.util.List;
import com.kingsrook.qbits.edi.app.EdiHome;
import com.kingsrook.qbits.edi.support.SystemWrites;
import com.kingsrook.qqq.backend.core.actions.dashboard.RenderWidgetAction;
import com.kingsrook.qqq.backend.core.actions.metadata.MetaDataAction;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
import com.kingsrook.qqq.backend.core.context.QContext;
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.widgets.RenderWidgetInput;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.AlertData;
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.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
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.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** The EDI home is for business users: system widgets live in a separate list
** for the host's Admin home, gated to the EDI administrator; a stalled inbound
** flow shows as one business item; every widget is permission-protected.
*******************************************************************************/
class EdiHomeAudienceTest
{
private static final Instant RECEIVED = Instant.parse("2026-10-16T12:00:00Z");
/*******************************************************************************
**
*******************************************************************************/
@AfterEach
void clear()
{
QContext.clear();
}
/*******************************************************************************
** The app's widget list is the business list; system widgets stay defined,
** are gated to edi.admin and hidden, and nothing is NOT_PROTECTED.
*******************************************************************************/
@Test
void homeWidgetListExcludesSystemWidgets() throws Exception
{
QInstance instance = H2Support.create();
List<String> appWidgets = instance.getApp(EdiRuntime.current().config().getAppName()).getWidgets();
assertThat(appWidgets).containsExactlyElementsOf(EdiHome.HOME_WIDGET_NAMES).doesNotContainAnyElementsOf(EdiHome.ADMIN_WIDGET_NAMES);
assertThat(EdiHome.ADMIN_WIDGET_NAMES).containsExactlyInAnyOrder("edi_home_system", "edi_home_dead", "edi_home_certificates", "edi_home_setup");
assertThat(appWidgets.getFirst()).as("attention comes first").isEqualTo("edi_home_delayed");
for(String name : EdiHome.ADMIN_WIDGET_NAMES)
{
QWidgetMetaData widget = (QWidgetMetaData) instance.getWidget(name);
assertThat(widget).as(name).isNotNull();
assertThat(widget.getPermissionRules().getPermissionBaseName()).as(name).isEqualTo("edi.admin");
assertThat(widget.getGridColumns()).as(name + " is full width in its Admin placement").isEqualTo(12);
}
for(String name : EdiHome.HOME_WIDGET_NAMES)
{
assertThat(((QWidgetMetaData) instance.getWidget(name)).getPermissionRules().getPermissionBaseName()).as(name).isEqualTo("edi");
}
instance.getWidgets().values().stream().filter(w -> w.getName().startsWith("edi_home_")).forEach(widget ->
{
assertThat(widget.getPermissionRules().getLevel()).as(widget.getName()).isEqualTo(PermissionLevel.HAS_ACCESS_PERMISSION);
assertThat(widget.getPermissionRules().getDenyBehavior()).as(widget.getName()).isEqualTo(DenyBehavior.HIDDEN);
});
}
/*******************************************************************************
** A business EDI user (edi.hasAccess, no edi.admin) gets no system widget, in
** metadata or by rendering; an EDI administrator gets them; a user without
** edi.hasAccess gets no home widget at all.
*******************************************************************************/
@Test
void businessUserCannotRenderSystemWidgets() throws Exception
{
QInstance instance = H2Support.create();
QContext.setQSession(readers(new QSession().withPermissions("edi.hasAccess")));
assertThat(new MetaDataAction().execute(new MetaDataInput()).getWidgets().keySet())
.containsAll(EdiHome.HOME_WIDGET_NAMES).doesNotContainAnyElementsOf(EdiHome.ADMIN_WIDGET_NAMES);
for(String name : EdiHome.ADMIN_WIDGET_NAMES)
{
assertThatThrownBy(() -> new RenderWidgetAction().execute(render(instance, name)))
.as(name).isInstanceOf(QPermissionDeniedException.class);
}
assertThat(new RenderWidgetAction().execute(render(instance, "edi_home_queue")).getWidgetData()).isInstanceOf(TableData.class);
QContext.setQSession(readers(new QSession().withPermissions("edi.hasAccess", "edi.admin.hasAccess")));
assertThat(new MetaDataAction().execute(new MetaDataInput()).getWidgets().keySet()).containsAll(EdiHome.ADMIN_WIDGET_NAMES);
for(String name : EdiHome.ADMIN_WIDGET_NAMES)
{
assertThat(new RenderWidgetAction().execute(render(instance, name)).getWidgetData()).as(name).isNotNull();
}
QContext.setQSession(readers(new QSession()));
assertThat(new MetaDataAction().execute(new MetaDataInput()).getWidgets().keySet()).noneMatch(name -> name.startsWith("edi_home_"));
}
/*******************************************************************************
** "Inbound documents are delayed" appears only past the threshold, with the
** age and a link to the filtered inbound documents; hidden otherwise.
*******************************************************************************/
@Test
void delayedInboundOnlyPastThreshold() throws Exception
{
QInstance instance = H2Support.create();
assertThat(EdiRuntime.current().config().getInboundDelayedAfterMinutes()).as("single-node default").isEqualTo(60);
QContext.setQSession(readers(new QSession().withPermissions("edi.hasAccess")));
assertThat(delayed(instance, RECEIVED).getHideWidget()).as("nothing waiting").isTrue();
SystemWrites.call(() -> SystemWrites.insert("edi_inbox", new QRecord().withValue("transportType", "SFTP").withValue("externalId", "synthetic-delayed")
.withValue("fileName", "synthetic.edi").withValue("payloadRef", "synthetic").withValue("payloadSha256", "0").withValue("sizeBytes", 1)
.withValue("receivedAt", RECEIVED).withValue("status", "PENDING").withValue("attempts", 0).withValue("nextAttemptAt", RECEIVED)));
assertThat(delayed(instance, RECEIVED.plus(30, ChronoUnit.MINUTES)).getHideWidget()).as("within the threshold").isTrue();
AlertData alert = delayed(instance, RECEIVED.plus(95, ChronoUnit.MINUTES));
assertThat(alert.getHideWidget()).isFalse();
assertThat(alert.getAlertType()).isEqualTo(AlertData.AlertType.WARNING);
assertThat(alert.getHtml()).contains("Inbound documents are delayed", "1 document has waited more than 1 h", "the oldest has waited 1 h 35 m")
.contains("href=\"/app/edi_inbox?filter=").doesNotContain("-1");
String filter = URLDecoder.decode(alert.getHtml().replaceAll(".*\\?filter=([^\"]*)\".*", "$1"), StandardCharsets.UTF_8);
assertThat(filter).contains("receivedAt", "LESS_THAN", "PENDING");
EdiRuntime.current().config().withInboundDelayedAfterMinutes(120);
assertThat(delayed(instance, RECEIVED.plus(95, ChronoUnit.MINUTES)).getHideWidget()).as("configurable threshold").isTrue();
EdiRuntime.current().config().withInboundDelayedAfterMinutes(60);
QContext.setQSession(new QSession().withPermissions("edi.hasAccess").withSecurityKeyValue("edi.admin", true));
assertThat(delayed(instance, RECEIVED.plus(95, ChronoUnit.MINUTES)).getHideWidget()).as("viewer cannot read inbound documents").isTrue();
}
/*******************************************************************************
** A setup count that fails shows "Unavailable", never -1.
*******************************************************************************/
@Test
void setupCountFailureIsUnavailable() throws Exception
{
QInstance instance = H2Support.create();
QSession session = readers(new QSession().withPermissions("edi.hasAccess", "edi.admin.hasAccess"));
session.getPermissions().remove("edi.transport.read");
QContext.setQSession(session);
var data = (TableData) new RenderWidgetAction().execute(render(instance, "edi_home_setup")).getWidgetData();
assertThat(data.getRows()).anySatisfy(row -> assertThat(row).containsEntry("Step", "5. Transport").containsEntry("Status", "Unavailable"));
assertThat(data.getRows()).allSatisfy(row -> assertThat(row.values()).noneMatch(value -> String.valueOf(value).contains("-1")));
}
/*******************************************************************************
** Render the delayed-inbound alert at a given time.
*******************************************************************************/
private static AlertData delayed(QInstance instance, Instant now) throws Exception
{
EdiRuntime.current().config().withClock(Clock.fixed(now, ZoneOffset.UTC));
return ((AlertData) new RenderWidgetAction().execute(render(instance, "edi_home_delayed")).getWidgetData());
}
/*******************************************************************************
** A render input that enforces the viewer's permissions, as the UI API does.
*******************************************************************************/
private static RenderWidgetInput render(QInstance instance, String widgetName)
{
RenderWidgetInput input = new RenderWidgetInput().withWidgetMetaData(instance.getWidget(widgetName));
input.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
return (input);
}
/*******************************************************************************
** Add read on every EDI table and all-partner row access.
*******************************************************************************/
private static QSession readers(QSession session)
{
QInstance instance = QContext.getQInstance();
for(QTableMetaData table : instance.getTables().values())
{
if(table.getName().startsWith("edi_"))
{
session.withPermissions(PermissionsHelper.getEffectivePermissionBaseName(PermissionsHelper.getEffectivePermissionRules(table, instance), table.getName()) + ".read");
}
}
return (session.withSecurityKeyValue("edi.admin", true));
}
}
@@ -77,8 +77,9 @@ class EdiSetupStatusTest
items = EdiSetupStatus.items(new EdiSetupStatus.Counts(1, 1, 1, 2, 0, 1, 1, 1, 1, 0, 41, 20, 0, 0));
assertThat(items.get(2).state()).as("agreements exist but none is active, so the runtime ignores them").isEqualTo(EdiSetupStatus.State.TODO);
items = EdiSetupStatus.items(new EdiSetupStatus.Counts(-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1));
items = EdiSetupStatus.items(new EdiSetupStatus.Counts(null, null, null, null, null, null, null, null, null, null, null, null, null, null));
assertThat(items).allSatisfy(item -> assertThat(item.state()).isEqualTo(EdiSetupStatus.State.UNKNOWN));
assertThat(EdiSetupStatus.State.UNKNOWN.label()).as("a failed count is never shown as a number").isEqualTo("Unavailable");
}
/*******************************************************************************
@@ -90,7 +91,8 @@ class EdiSetupStatusTest
QInstance instance = H2Support.create();
EdiRuntime.current().initialize();
assertThat(instance.getWidget("edi_home_setup")).isNotNull();
assertThat(instance.getApp(EdiRuntime.current().config().getAppName()).getWidgets()).contains("edi_home_setup");
assertThat(instance.getApp(EdiRuntime.current().config().getAppName()).getWidgets()).as("setup is for the Admin home").doesNotContain("edi_home_setup");
assertThat(com.kingsrook.qbits.edi.app.EdiHome.ADMIN_WIDGET_NAMES).contains("edi_home_setup");
Map<String, String> status = status(instance);
assertThat(status).containsKeys("1. Partner", "5. Transport");
@@ -359,7 +359,7 @@ class PhiCanaryTest
var enforcement = instance.getEnforcePermissionsInCoreActions();
instance.setEnforcePermissionsInCoreActions(true);
var fullSession = QContext.getQSession();
var viewer = new com.kingsrook.qqq.backend.core.model.session.QSession().withPermissions("edi.admin").withSecurityKeyValue("edi.admin", true).withSecurityKeyValue("edi.guide.loader", true);
var viewer = new com.kingsrook.qqq.backend.core.model.session.QSession().withPermissions("edi.admin", "edi.hasAccess", "edi.admin.hasAccess").withSecurityKeyValue("edi.admin", true).withSecurityKeyValue("edi.guide.loader", true);
for(var table : instance.getTables().values())
{
if(!table.getName().startsWith("edi_") || table.getName().equals(PreviewDownloadBackend.TABLE))
@@ -68,7 +68,7 @@ class EdiDemoServerTest
Set<String> properties = new HashSet<>();
new com.fasterxml.jackson.databind.ObjectMapper().readTree(json).fieldNames().forEachRemaining(properties::add);
assertThat(properties).isSubsetOf("sendAsEdiTables", "defaultBackendNameForTables", "backendName", "appName", "appLabel", "parentAppName",
"schedulerName", "maxSegmentsPerTransaction", "partnerLocks", "payloadBucketAlias", "esbDestinationName");
"schedulerName", "maxSegmentsPerTransaction", "partnerLocks", "payloadBucketAlias", "esbDestinationName", "inboundDelayedAfterMinutes");
assertThat(calls.get()).isZero();
}
}