feat: Quick Search record developer panel provider #41
No files matched your search
@@ -2,6 +2,13 @@
|
||||
|
||||
All notable changes to this project are documented here. The format follows Keep a Changelog; versions follow semantic versioning.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
Requires QQQ 4.1.0-RC.4 or later (the developer panel contract, QRun-IO/qqq#1076).
|
||||
|
||||
### Added
|
||||
- Record developer panel: `QuickSearchDeveloperPanelProvider` (id `quickSearch`) adds a "Quick Search" panel to the developer view of every record of an indexed table (`/app/{table}/{id}/dev` in the Next UI), for users who may read Quick Search Index. It shows the table's index row (enabled, status, record count, last basepull and full reindex, searchable fields), the latest index run, the record's indexed document compared field by field with the database (Inspect Search Document's comparison; needs `debugPermissionRules`), the record's failed index events, the Inspect Search Document and Reindex in Search actions the user may run, and links to the admin app and the index row. An unreachable OpenSearch shows a warning in the panel. Registered by `QuickSearchDeveloperPanelsMetaDataProducer`.
|
||||
|
||||
## [1.1.0-RC.1] - 2026-10-11
|
||||
|
||||
First release candidate of 1.1, published to Maven Central from `release/1.1`. It contains every change listed under 1.1.0 and 1.0.0 below (1.0.0 has not been released as GA). Requires QQQ 4.1.0-RC.2 or later. Upgrade notes: `docs/MIGRATION-1.1.md`.
|
||||
|
||||
@@ -51,7 +51,7 @@ Integration tests use `@ExtendWith(RequiresDockerCondition.class)`: skipped with
|
||||
## Dependencies
|
||||
|
||||
- Java 21
|
||||
- Parent `com.kingsrook:qbit-build-parent:2.0.0` (qqq 4.0.0). 1.0 needs qqq 4.1, so the `qqq-snapshot` profile is active by default and imports `qqq-bom-pom` ahead of the parent's BOM; remove that activation when the parent moves to 4.1 (ADR-0007). 1.1 imports `4.1.0-RC.2`, the first QQQ release with the record-search provider SPI
|
||||
- Parent `com.kingsrook:qbit-build-parent:2.0.0` (qqq 4.0.0). 1.0 needs qqq 4.1, so the `qqq-snapshot` profile is active by default and imports `qqq-bom-pom` ahead of the parent's BOM; remove that activation when the parent moves to 4.1 (ADR-0007). 1.1 imports `4.1.0-RC.4`: the record-search provider SPI arrived in RC.2, the developer panel contract (`DeveloperPanelProviderInterface`) in RC.4
|
||||
- OpenSearch Java Client 3.10.0; optional AWS SDK v2 `apache-client` and `sts` for `AWS_SIGV4`
|
||||
- Apache HttpClient5 and jackson-datatype-jsr310, versions managed by the qqq BOM (keep them unpinned so they match its httpcore5 and jackson)
|
||||
- JUnit 5 + AssertJ + Mockito for testing
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
Annotate entity classes or declare tables in config, point the QBit at an OpenSearch cluster, produce it into your `QInstance`, and you get real-time indexing of committed writes, a scheduled catch-up and reconcile, a blackout-free full rebuild, and a permission-aware search API with relevance scoring, typeahead, highlights and pagination.
|
||||
|
||||
Requires QQQ 4.1 (the QBit uses 4.1's record-change listeners, after-commit callbacks and runtime services). Serving core search (`POST /qqq/v1/search`, below) needs QQQ 4.1.0-RC.2 or later, the first release that carries the record-search provider SPI; 1.1 builds against 4.1.0-RC.2.
|
||||
Requires QQQ 4.1 (the QBit uses 4.1's record-change listeners, after-commit callbacks and runtime services). Serving core search (`POST /qqq/v1/search`, below) needs QQQ 4.1.0-RC.2 or later, the first release that carries the record-search provider SPI. The record developer panel (below) needs QQQ 4.1.0-RC.4 or later, which carries the developer panel contract; the 1.1 line builds against 4.1.0-RC.4 from the next release candidate on.
|
||||
|
||||
## What it does
|
||||
|
||||
@@ -177,6 +177,21 @@ Search failures are recorded too: when the provider's OpenSearch request fails o
|
||||
|
||||
Permissions: the read-only tools and the widget use `debugPermissionRules` (default `quickSearchDebug.hasAccess`); Reindex in Search uses `adminPermissionRules` (`quickSearchAdmin.hasAccess`); the two history tables use `tablePermissionRules` (grant `.read` only: rows are written by the QBit). Impersonation ("search as user X") is deliberately not offered: a fabricated session would not carry the real user's security key values, so its results would mislead.
|
||||
|
||||
### Record developer panel
|
||||
|
||||
With QQQ 4.1.0-RC.4 or later the QBit registers a record developer panel provider (`QuickSearchDeveloperPanelProvider`, id `quickSearch`, in QQQ's `DeveloperPanelsMetaData`). Frontends that render developer panels (the Next UI, `/app/{table}/{id}/dev`) show a "Quick Search" panel on every record of an indexed table, to users who may read Quick Search Index. Every part is permission-checked:
|
||||
|
||||
| Part | Shown when |
|
||||
|---|---|
|
||||
| Index: enabled, status, record count, last basepull, last full reindex, searchable fields, link to the index row | always (read on Quick Search Index) |
|
||||
| Latest index run: type, status, start and end, processed, indexed, errors | the user may read Quick Search Index Run |
|
||||
| Search document: verdict (current, stale, missing) and a Field / Database / Index / Match table | `enableDiagnosticTools` and the debug permission (`debugPermissionRules`); otherwise a note says why it is missing |
|
||||
| Failed index events of the record | the user may read Quick Search Failed Event and the record has any |
|
||||
| Actions: Inspect search document, Reindex this record | the record actions exist and the user may run them (debug and admin permission) |
|
||||
| Link to Quick Search Admin | the user may open the app |
|
||||
|
||||
The panel's status is Indexed, Stale, Not indexed, Disabled (the table is turned off in Quick Search Index), or OpenSearch unavailable. When OpenSearch cannot be reached the panel still shows the database-side state, with a warning in place of the document comparison. Hosts without a developer-panel frontend are unaffected.
|
||||
|
||||
## Configuration reference
|
||||
|
||||
| Field | Default | Description |
|
||||
@@ -239,7 +254,7 @@ CI=true ./mvnw verify # plus Testcontainers integration te
|
||||
./mvnw verify -Dopensearch.test.image=opensearchproject/opensearch:3.9.0
|
||||
```
|
||||
|
||||
Use the wrapper: Maven 3.10 cannot read the published parent POM. The build imports qqq 4.1.0-RC.2 through the default-active `qqq-snapshot` profile until `qbit-build-parent` 2.1.0 ships (override with `-Dqqq.snapshot.version=...`). Without Docker the integration tests are skipped locally; with `CI=true` they fail instead.
|
||||
Use the wrapper: Maven 3.10 cannot read the published parent POM. The build imports qqq 4.1.0-RC.4 through the default-active `qqq-snapshot` profile until `qbit-build-parent` 2.1.0 ships (override with `-Dqqq.snapshot.version=...`). Without Docker the integration tests are skipped locally; with `CI=true` they fail instead.
|
||||
|
||||
## Upgrading from 1.0
|
||||
|
||||
|
||||
@@ -266,13 +266,15 @@
|
||||
<!-- activation when the parent moves to 4.1 (ADR-0007). -->
|
||||
<!-- 1.1: the record-search provider needs the core SPI -->
|
||||
<!-- (RecordSearchProviderInterface), first released in qqq 4.1.0-RC.2. -->
|
||||
<!-- The record developer panel provider needs the developer panel -->
|
||||
<!-- contract (DeveloperPanelProviderInterface), first in qqq 4.1.0-RC.4. -->
|
||||
<profile>
|
||||
<id>qqq-snapshot</id>
|
||||
<activation>
|
||||
<activeByDefault>true</activeByDefault>
|
||||
</activation>
|
||||
<properties>
|
||||
<qqq.snapshot.version>4.1.0-RC.2</qqq.snapshot.version>
|
||||
<qqq.snapshot.version>4.1.0-RC.4</qqq.snapshot.version>
|
||||
</properties>
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
|
||||
+535
@@ -0,0 +1,535 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.quicksearch.diagnostics;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionCheckResult;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.TablePermissionSubType;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
|
||||
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.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.developer.DeveloperPanel;
|
||||
import com.kingsrook.qqq.backend.core.model.developer.DeveloperPanelAction;
|
||||
import com.kingsrook.qqq.backend.core.model.developer.DeveloperPanelBlock;
|
||||
import com.kingsrook.qqq.backend.core.model.developer.DeveloperPanelContext;
|
||||
import com.kingsrook.qqq.backend.core.model.developer.DeveloperPanelKeyValue;
|
||||
import com.kingsrook.qqq.backend.core.model.developer.DeveloperPanelLink;
|
||||
import com.kingsrook.qqq.backend.core.model.developer.DeveloperPanelProviderInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.developer.DeveloperPanelTone;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
|
||||
import com.kingsrook.qbits.quicksearch.QuickSearchQBitConfig;
|
||||
import com.kingsrook.qbits.quicksearch.QuickSearchRuntime;
|
||||
import com.kingsrook.qbits.quicksearch.QuickSearchableTableConfig;
|
||||
import com.kingsrook.qbits.quicksearch.metadata.QuickSearchAdminAppMetaDataProducer;
|
||||
import com.kingsrook.qbits.quicksearch.metadata.QuickSearchDiagnosticsProcessMetaDataHelper;
|
||||
import com.kingsrook.qbits.quicksearch.model.QuickSearchFailedEvent;
|
||||
import com.kingsrook.qbits.quicksearch.opensearch.OpenSearchDocument;
|
||||
import com.kingsrook.qbits.quicksearch.opensearch.QuickSearchDiagnosticsOperations.DocumentView;
|
||||
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The "Quick Search" panel of a record's developer view (QQQ's
|
||||
** DeveloperPanelProviderInterface, /app/{table}/{id}/dev).
|
||||
**
|
||||
** Shown for tables this QBit indexes, to users who may read the Quick Search
|
||||
** Index table. It shows the table's index row (enabled, status, counts, last
|
||||
** basepull and full reindex, searchable fields), the latest index run, the
|
||||
** record's indexed document compared with the database (Inspect Search
|
||||
** Document's comparison; needs the debug permission), the record's failed
|
||||
** index events, and the Inspect / Reindex record actions and admin links the
|
||||
** user may use. Every read checks the user's permissions; an unreachable
|
||||
** OpenSearch shows a warning instead of failing the panel.
|
||||
*******************************************************************************/
|
||||
public class QuickSearchDeveloperPanelProvider implements DeveloperPanelProviderInterface
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(QuickSearchDeveloperPanelProvider.class);
|
||||
|
||||
public static final String ID = "quickSearch";
|
||||
public static final String TITLE = "Quick Search";
|
||||
|
||||
public static final String STATUS_INDEXED = "Indexed";
|
||||
public static final String STATUS_STALE = "Stale";
|
||||
public static final String STATUS_NOT_INDEXED = "Not indexed";
|
||||
public static final String STATUS_DISABLED = "Disabled";
|
||||
public static final String STATUS_ENABLED = "Enabled";
|
||||
public static final String STATUS_UNAVAILABLE = "OpenSearch unavailable";
|
||||
|
||||
static final int MAX_FAILED_EVENTS = 20;
|
||||
static final int MAX_VALUE_LENGTH = 300;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getId()
|
||||
{
|
||||
return (ID);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getTitle()
|
||||
{
|
||||
return (TITLE);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Integer getOrder()
|
||||
{
|
||||
return (400);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getIcon()
|
||||
{
|
||||
return ("search");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Available for a table this QBit indexes, when the user may read the Quick
|
||||
** Search Index table (the panel shows admin-level indexing state).
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public boolean isAvailable(DeveloperPanelContext context)
|
||||
{
|
||||
QuickSearchRuntime runtime = QuickSearchRuntime.get();
|
||||
if(runtime == null || context == null || runtime.getTableConfig(context.getTableName()) == null)
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
return (mayRead(runtime.getConfig().getQuickSearchIndexTableName()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Build the panel.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public DeveloperPanel build(DeveloperPanelContext context) throws QException
|
||||
{
|
||||
QuickSearchRuntime runtime = QuickSearchRuntime.getOrThrow();
|
||||
QuickSearchQBitConfig config = runtime.getConfig();
|
||||
String tableName = context.getTableName();
|
||||
QuickSearchableTableConfig tableConfig = runtime.getTableConfig(tableName);
|
||||
Serializable primaryKey = context.getPrimaryKey();
|
||||
String recordId = primaryKey == null ? null : String.valueOf(primaryKey);
|
||||
String indexTable = config.getQuickSearchIndexTableName();
|
||||
|
||||
DeveloperPanel panel = new DeveloperPanel()
|
||||
.withDescription("How Quick Search (OpenSearch) indexes this table, and this record's search document.");
|
||||
|
||||
//////////////////////////////////////////////////////////////
|
||||
// the table's index row (readable: checked by isAvailable) //
|
||||
//////////////////////////////////////////////////////////////
|
||||
QRecord indexRow = null;
|
||||
if(mayRead(indexTable))
|
||||
{
|
||||
indexRow = queryFirst(indexTable, new QQueryFilter(new QFilterCriteria("tableName", QCriteriaOperator.EQUALS, tableName)));
|
||||
}
|
||||
boolean enabled = indexRow == null || !Boolean.FALSE.equals(indexRow.getValueBoolean("enabled"));
|
||||
panel.withBlock(indexBlock(config, tableConfig, indexTable, indexRow, enabled));
|
||||
|
||||
//////////////////////
|
||||
// latest index run //
|
||||
//////////////////////
|
||||
String runTable = config.getQuickSearchIndexRunTableName();
|
||||
if(indexRow != null && mayRead(runTable))
|
||||
{
|
||||
QRecord run = queryFirst(runTable, new QQueryFilter(new QFilterCriteria("quickSearchIndexId", QCriteriaOperator.EQUALS, indexRow.getValue("id")))
|
||||
.withOrderBy(new QFilterOrderBy("id", false)));
|
||||
panel.withBlock(run == null
|
||||
? DeveloperPanelBlock.text("No index runs are recorded for this table yet.", DeveloperPanelTone.INFO).withLabel("Latest index run")
|
||||
: runBlock(runTable, run));
|
||||
}
|
||||
|
||||
/////////////////////////////////////////
|
||||
// this record's document vs. database //
|
||||
/////////////////////////////////////////
|
||||
String verdict = null;
|
||||
boolean openSearchFailed = false;
|
||||
if(recordId != null)
|
||||
{
|
||||
if(!Boolean.TRUE.equals(config.getEnableDiagnosticTools()))
|
||||
{
|
||||
panel.withBlock(DeveloperPanelBlock.text("Quick Search diagnostic tools are disabled (enableDiagnosticTools), so this record's search document is not compared.", DeveloperPanelTone.INFO)
|
||||
.withLabel("Search document"));
|
||||
}
|
||||
else if(!mayInspect(config, tableName))
|
||||
{
|
||||
panel.withBlock(DeveloperPanelBlock.text("You do not have the Quick Search debug permission, so this record's search document is not shown.", DeveloperPanelTone.INFO)
|
||||
.withLabel("Search document"));
|
||||
}
|
||||
else
|
||||
{
|
||||
try
|
||||
{
|
||||
RecordInspector.Inspection inspection = inspect(runtime, tableName, recordId, new QueryInput(tableName));
|
||||
verdict = inspection.verdict();
|
||||
addInspectionBlocks(panel, inspection);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
openSearchFailed = true;
|
||||
LOG.info("Quick Search developer panel could not read the search document", logPair("tableName", tableName), logPair("recordId", recordId), logPair("error", e.getMessage()));
|
||||
panel.withBlock(DeveloperPanelBlock.text("OpenSearch could not be reached, so this record's search document cannot be shown: "
|
||||
+ DiagnosticsSupport.rootCause(e).getMessage(), DeveloperPanelTone.WARNING).withLabel("Search document"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/////////////////////////////////
|
||||
// failed index events, if any //
|
||||
/////////////////////////////////
|
||||
String failedTable = config.applyPrefix(QuickSearchFailedEvent.TABLE_NAME);
|
||||
if(recordId != null && mayRead(failedTable))
|
||||
{
|
||||
DeveloperPanelBlock failedBlock = failedEventsBlock(failedTable, tableName, recordId);
|
||||
if(failedBlock != null)
|
||||
{
|
||||
panel.withBlock(failedBlock);
|
||||
}
|
||||
}
|
||||
|
||||
//////////////////////////////
|
||||
// actions the user may run //
|
||||
//////////////////////////////
|
||||
if(recordId != null)
|
||||
{
|
||||
DeveloperPanelBlock actions = DeveloperPanelBlock.actions("Actions");
|
||||
addActionIfPermitted(actions, QuickSearchDiagnosticsProcessMetaDataHelper.inspectProcessName(config, tableName), tableName, "Inspect search document",
|
||||
"Compare the indexed document with the database, and see why the record does or does not match a term.");
|
||||
addActionIfPermitted(actions, QuickSearchDiagnosticsProcessMetaDataHelper.reindexRecordsProcessName(config, tableName), tableName, "Reindex this record",
|
||||
"Rebuild this record's search document from the database now.");
|
||||
if(!actions.getActions().isEmpty())
|
||||
{
|
||||
panel.withBlock(actions);
|
||||
}
|
||||
}
|
||||
|
||||
///////////
|
||||
// links //
|
||||
///////////
|
||||
DeveloperPanelBlock links = DeveloperPanelBlock.links("Links");
|
||||
String appName = config.applyPrefix(QuickSearchAdminAppMetaDataProducer.APP_NAME);
|
||||
if(QContext.getQInstance().getApp(appName) != null && PermissionsHelper.hasAppPermission(new QueryInput(indexTable), appName))
|
||||
{
|
||||
links.withLink(new DeveloperPanelLink().withLabel("Quick Search Admin").withHref("/app/" + appName).withDescription("Index status, runs, failed events and diagnostic tools."));
|
||||
}
|
||||
if(indexRow != null)
|
||||
{
|
||||
links.withLink(new DeveloperPanelLink().withLabel("Quick Search Index row for " + tableName).withHref(recordHref(indexTable, indexRow.getValue("id"))));
|
||||
}
|
||||
if(!links.getLinks().isEmpty())
|
||||
{
|
||||
panel.withBlock(links);
|
||||
}
|
||||
|
||||
setStatus(panel, enabled, verdict, openSearchFailed);
|
||||
return (panel);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Inspect one record (overridable for tests).
|
||||
*******************************************************************************/
|
||||
protected RecordInspector.Inspection inspect(QuickSearchRuntime runtime, String tableName, String recordId, AbstractActionInput permissionInput) throws QException
|
||||
{
|
||||
return (new RecordInspector().inspect(runtime, tableName, recordId, null, permissionInput));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The table's index row as key/values.
|
||||
*******************************************************************************/
|
||||
private DeveloperPanelBlock indexBlock(QuickSearchQBitConfig config, QuickSearchableTableConfig tableConfig, String indexTable, QRecord indexRow, boolean enabled)
|
||||
{
|
||||
DeveloperPanelBlock block = DeveloperPanelBlock.keyValues("Index");
|
||||
if(indexRow == null)
|
||||
{
|
||||
block.withItem("Index row", "none yet (the first indexing run creates it)");
|
||||
}
|
||||
else
|
||||
{
|
||||
block.withItem(new DeveloperPanelKeyValue().withLabel("Index row").withValue("#" + indexRow.getValueString("id")).withHref(recordHref(indexTable, indexRow.getValue("id"))));
|
||||
}
|
||||
block.withItem("Enabled", enabled ? "Yes" : "No");
|
||||
block.withItem("Status", indexRow == null ? null : indexRow.getValueString("status"));
|
||||
block.withItem("Indexed records", indexRow == null ? null : indexRow.getValueString("recordCount"));
|
||||
block.withItem("Last basepull", indexRow == null ? null : indexRow.getValueString("lastBasepullTime"));
|
||||
block.withItem("Last full reindex", indexRow == null ? null : indexRow.getValueString("lastFullReindexTime"));
|
||||
block.withItem("Searchable fields", String.join(", ", CollectionUtils.nonNullList(tableConfig.getSearchableFields())));
|
||||
block.withItem(new DeveloperPanelKeyValue().withLabel("OpenSearch index (alias)").withValue(config.getOpensearchIndexName()).withMonospace(true));
|
||||
block.withItem("Real-time indexing", Boolean.TRUE.equals(config.getEnableRealTimeIndexing()) ? "On" : "Off");
|
||||
return (block);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The latest index run as key/values.
|
||||
*******************************************************************************/
|
||||
private DeveloperPanelBlock runBlock(String runTable, QRecord run)
|
||||
{
|
||||
DeveloperPanelBlock block = DeveloperPanelBlock.keyValues("Latest index run")
|
||||
.withItem(new DeveloperPanelKeyValue().withLabel("Run").withValue("#" + run.getValueString("id")).withHref(recordHref(runTable, run.getValue("id"))))
|
||||
.withItem("Type", run.getValueString("runType"))
|
||||
.withItem("Status", run.getValueString("status"))
|
||||
.withItem("Started", run.getValueString("startTime"))
|
||||
.withItem("Ended", run.getValueString("endTime"))
|
||||
.withItem("Processed", run.getValueString("recordsProcessed"))
|
||||
.withItem("Indexed", run.getValueString("recordsIndexed"))
|
||||
.withItem("Errors", run.getValueString("errorCount"));
|
||||
if(run.getValueString("errorMessage") != null)
|
||||
{
|
||||
block.withItem("Error", truncate(run.getValueString("errorMessage")));
|
||||
}
|
||||
return (block);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The inspection: verdict text, document facts, field comparison table.
|
||||
*******************************************************************************/
|
||||
private void addInspectionBlocks(DeveloperPanel panel, RecordInspector.Inspection inspection)
|
||||
{
|
||||
if(inspection.error() != null)
|
||||
{
|
||||
panel.withBlock(DeveloperPanelBlock.text(inspection.error(), RecordInspector.VERDICT_NOT_INDEXED.equals(inspection.verdict()) ? DeveloperPanelTone.INFO : DeveloperPanelTone.WARNING)
|
||||
.withLabel("Search document"));
|
||||
return;
|
||||
}
|
||||
|
||||
switch(inspection.verdict())
|
||||
{
|
||||
case RecordInspector.VERDICT_CURRENT -> panel.withBlock(DeveloperPanelBlock.text("Indexed and current: the search document matches the database.", DeveloperPanelTone.SUCCESS)
|
||||
.withLabel("Search document"));
|
||||
case RecordInspector.VERDICT_STALE -> panel.withBlock(DeveloperPanelBlock.text("Indexed but stale: some values differ from the database. Reindex this record to refresh it.", DeveloperPanelTone.WARNING)
|
||||
.withLabel("Search document"));
|
||||
default -> panel.withBlock(DeveloperPanelBlock.text("Not in the index: the global search cannot find this record. Reindex this record to add it (or wait for the next basepull or reconcile).",
|
||||
DeveloperPanelTone.ERROR).withLabel("Search document"));
|
||||
}
|
||||
|
||||
DocumentView indexed = inspection.indexed();
|
||||
panel.withBlock(DeveloperPanelBlock.keyValues("Document")
|
||||
.withItem(new DeveloperPanelKeyValue().withLabel("Document id").withValue(OpenSearchDocument.buildDocumentId(inspection.tableName(), inspection.recordId())).withMonospace(true))
|
||||
.withItem(new DeveloperPanelKeyValue().withLabel("Physical index").withValue(indexed == null ? null : indexed.physicalIndex()).withMonospace(true))
|
||||
.withItem("Indexed version", indexed == null || indexed.version() == null ? null : String.valueOf(indexed.version()))
|
||||
.withItem("Database version", inspection.expected() == null || inspection.expected().getVersion() == null ? "not versioned (no timestamp field)" : String.valueOf(inspection.expected().getVersion()))
|
||||
.withItem("Indexed at", indexed == null || indexed.source() == null ? null : indexed.source().path("indexedAt").asText(null)));
|
||||
|
||||
DeveloperPanelBlock table = DeveloperPanelBlock.table("Database vs index", List.of("Field", "Database", "Index", "Match"));
|
||||
for(RecordInspector.FieldDiff diff : CollectionUtils.nonNullList(inspection.diffs()))
|
||||
{
|
||||
table.withRow(row(diff.name(), truncate(diff.databaseValue()), truncate(diff.indexValue()), diff.isSame() ? "yes" : "NO"));
|
||||
}
|
||||
panel.withBlock(table);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The record's failed index events, or null when there are none.
|
||||
*******************************************************************************/
|
||||
private DeveloperPanelBlock failedEventsBlock(String failedTable, String tableName, String recordId) throws QException
|
||||
{
|
||||
QueryInput queryInput = new QueryInput(failedTable);
|
||||
queryInput.setFilter(new QQueryFilter(
|
||||
new QFilterCriteria("tableName", QCriteriaOperator.EQUALS, tableName),
|
||||
new QFilterCriteria("recordId", QCriteriaOperator.EQUALS, recordId))
|
||||
.withOrderBy(new QFilterOrderBy("id", false))
|
||||
.withLimit(MAX_FAILED_EVENTS));
|
||||
List<QRecord> events = CollectionUtils.nonNullList(new QueryAction().execute(queryInput).getRecords());
|
||||
if(events.isEmpty())
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
|
||||
DeveloperPanelBlock block = DeveloperPanelBlock.table("Failed index events" + (events.size() >= MAX_FAILED_EVENTS ? " (latest " + MAX_FAILED_EVENTS + ")" : ""),
|
||||
List.of("Id", "Action", "Status", "Attempts", "Created", "Error"));
|
||||
for(QRecord event : events)
|
||||
{
|
||||
block.withRow(row(event.getValueString("id"), event.getValueString("action"), event.getValueString("status"), event.getValueString("attempts"),
|
||||
event.getValueString("createDate"), truncate(event.getValueString("errorMessage"))));
|
||||
}
|
||||
return (block);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Add a record action when the process exists and the user may run it.
|
||||
*******************************************************************************/
|
||||
private void addActionIfPermitted(DeveloperPanelBlock actions, String processName, String tableName, String label, String description)
|
||||
{
|
||||
if(QContext.getQInstance().getProcess(processName) != null && PermissionsHelper.hasProcessPermission(new QueryInput(tableName), processName))
|
||||
{
|
||||
actions.withAction(new DeveloperPanelAction().withLabel(label).withProcessName(processName).withTableName(tableName).withDescription(description));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether the user may inspect records: the Inspect Search Document
|
||||
** process's permission (debugPermissionRules), evaluated on the
|
||||
** registered process, or on an unregistered copy when the record actions
|
||||
** are turned off.
|
||||
*******************************************************************************/
|
||||
private boolean mayInspect(QuickSearchQBitConfig config, String tableName)
|
||||
{
|
||||
String processName = QuickSearchDiagnosticsProcessMetaDataHelper.inspectProcessName(config, tableName);
|
||||
if(QContext.getQInstance().getProcess(processName) != null)
|
||||
{
|
||||
return (PermissionsHelper.hasProcessPermission(new QueryInput(tableName), processName));
|
||||
}
|
||||
return (PermissionsHelper.getPermissionCheckResult(new QueryInput(tableName), QuickSearchDiagnosticsProcessMetaDataHelper.buildInspectProcess(config, tableName))
|
||||
== PermissionCheckResult.ALLOW);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Status label and tone from the enabled flag and the inspection verdict.
|
||||
*******************************************************************************/
|
||||
private void setStatus(DeveloperPanel panel, boolean enabled, String verdict, boolean openSearchFailed)
|
||||
{
|
||||
if(!enabled)
|
||||
{
|
||||
panel.withStatusLabel(STATUS_DISABLED).withStatusTone(DeveloperPanelTone.WARNING);
|
||||
}
|
||||
else if(openSearchFailed)
|
||||
{
|
||||
panel.withStatusLabel(STATUS_UNAVAILABLE).withStatusTone(DeveloperPanelTone.WARNING);
|
||||
}
|
||||
else if(RecordInspector.VERDICT_CURRENT.equals(verdict))
|
||||
{
|
||||
panel.withStatusLabel(STATUS_INDEXED).withStatusTone(DeveloperPanelTone.SUCCESS);
|
||||
}
|
||||
else if(RecordInspector.VERDICT_STALE.equals(verdict))
|
||||
{
|
||||
panel.withStatusLabel(STATUS_STALE).withStatusTone(DeveloperPanelTone.WARNING);
|
||||
}
|
||||
else if(RecordInspector.VERDICT_MISSING.equals(verdict))
|
||||
{
|
||||
panel.withStatusLabel(STATUS_NOT_INDEXED).withStatusTone(DeveloperPanelTone.ERROR);
|
||||
}
|
||||
else
|
||||
{
|
||||
panel.withStatusLabel(STATUS_ENABLED).withStatusTone(DeveloperPanelTone.INFO);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether the table exists and the user may read it.
|
||||
*******************************************************************************/
|
||||
private static boolean mayRead(String tableName)
|
||||
{
|
||||
QInstance qInstance = QContext.getQInstance();
|
||||
return (qInstance != null && qInstance.getTable(tableName) != null && PermissionsHelper.hasTablePermission(new QueryInput(tableName), tableName, TablePermissionSubType.READ));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** First row of a query (as the user), or null.
|
||||
*******************************************************************************/
|
||||
private static QRecord queryFirst(String tableName, QQueryFilter filter) throws QException
|
||||
{
|
||||
QueryInput queryInput = new QueryInput(tableName);
|
||||
queryInput.setFilter(filter.withLimit(1));
|
||||
List<QRecord> records = new QueryAction().execute(queryInput).getRecords();
|
||||
return (CollectionUtils.nullSafeHasContents(records) ? records.get(0) : null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** App-relative link to a record.
|
||||
*******************************************************************************/
|
||||
private static String recordHref(String tableName, Serializable id)
|
||||
{
|
||||
return ("/app/" + tableName + "/" + id);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A table row (null cells as empty strings).
|
||||
*******************************************************************************/
|
||||
private static List<String> row(String... cells)
|
||||
{
|
||||
List<String> row = new ArrayList<>();
|
||||
for(String cell : cells)
|
||||
{
|
||||
row.add(cell == null ? "" : cell);
|
||||
}
|
||||
return (row);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Long values cut for display.
|
||||
*******************************************************************************/
|
||||
static String truncate(String value)
|
||||
{
|
||||
if(value == null || value.length() <= MAX_VALUE_LENGTH)
|
||||
{
|
||||
return (value);
|
||||
}
|
||||
return (value.substring(0, MAX_VALUE_LENGTH) + "... (" + value.length() + " characters)");
|
||||
}
|
||||
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.quicksearch.metadata;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.developer.DeveloperPanelsMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitComponentMetaDataProducer;
|
||||
import com.kingsrook.qbits.quicksearch.QuickSearchQBitConfig;
|
||||
import com.kingsrook.qbits.quicksearch.diagnostics.QuickSearchDeveloperPanelProvider;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Registers the Quick Search record developer panel
|
||||
** ({@link QuickSearchDeveloperPanelProvider}) in the instance's developer
|
||||
** panels. The provider decides per table and user whether the panel shows
|
||||
** (indexed tables, users who may read Quick Search Index). Registration is
|
||||
** idempotent, so producing the QBit again adds nothing twice.
|
||||
*******************************************************************************/
|
||||
public class QuickSearchDeveloperPanelsMetaDataProducer extends QBitComponentMetaDataProducer<DeveloperPanelsMetaData, QuickSearchQBitConfig>
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public DeveloperPanelsMetaData produce(QInstance qInstance) throws QException
|
||||
{
|
||||
return (DeveloperPanelsMetaData.ofOrWithNew(qInstance).withProvider(new QCodeReference(QuickSearchDeveloperPanelProvider.class)));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -20,6 +20,9 @@ package com.kingsrook.qbits.quicksearch;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qqq.backend.core.actions.developer.DeveloperPanelsAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.TablePermissionSubType;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
@@ -33,6 +36,8 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.developer.DeveloperPanel;
|
||||
import com.kingsrook.qqq.backend.core.model.developer.DeveloperPanelContext;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
@@ -47,6 +52,7 @@ import com.kingsrook.qbits.quicksearch.diagnostics.AnalyzerTester;
|
||||
import com.kingsrook.qbits.quicksearch.diagnostics.DiagnosticsHtml.Check;
|
||||
import com.kingsrook.qbits.quicksearch.diagnostics.DiagnosticsHtml.Level;
|
||||
import com.kingsrook.qbits.quicksearch.diagnostics.IndexValidator;
|
||||
import com.kingsrook.qbits.quicksearch.diagnostics.QuickSearchDeveloperPanelProvider;
|
||||
import com.kingsrook.qbits.quicksearch.diagnostics.RecordInspector;
|
||||
import com.kingsrook.qbits.quicksearch.diagnostics.RecordReindexer;
|
||||
import com.kingsrook.qbits.quicksearch.diagnostics.SearchPathDiagnosis;
|
||||
@@ -283,6 +289,35 @@ class DiagnosticsIntegrationTest
|
||||
|
||||
|
||||
|
||||
@Test
|
||||
@Order(3)
|
||||
void testDeveloperPanel_currentRecordIsIndexed() throws Exception
|
||||
{
|
||||
QSession previous = QContext.getQSession();
|
||||
try
|
||||
{
|
||||
QContext.setQSession(new QSession()
|
||||
.withPermission(PermissionsHelper.getTablePermissionName(runtime.getConfig().getQuickSearchIndexTableName(), TablePermissionSubType.READ))
|
||||
.withPermission(QuickSearchQBitConfig.DEFAULT_DEBUG_PERMISSION_BASE_NAME + ".hasAccess"));
|
||||
|
||||
DeveloperPanelContext context = new DeveloperPanelContext()
|
||||
.withTable(QContext.getQInstance().getTable(PRODUCT))
|
||||
.withRecord(new QRecord().withValue("id", 1));
|
||||
DeveloperPanel panel = new DeveloperPanelsAction().execute(context).stream()
|
||||
.filter(p -> QuickSearchDeveloperPanelProvider.ID.equals(p.getId())).findFirst().orElseThrow();
|
||||
|
||||
assertThat(panel.getError()).isNull();
|
||||
assertThat(panel.getStatusLabel()).isEqualTo(QuickSearchDeveloperPanelProvider.STATUS_INDEXED);
|
||||
assertThat(panel.getBlocks()).anyMatch(block -> "Database vs index".equals(block.getLabel()) && block.getRows().stream().allMatch(row -> "yes".equals(row.get(3))));
|
||||
}
|
||||
finally
|
||||
{
|
||||
QContext.setQSession(previous);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Test
|
||||
@Order(4)
|
||||
void testInspect_staleAndMissing() throws Exception
|
||||
|
||||
+428
@@ -0,0 +1,428 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.quicksearch.diagnostics;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qqq.backend.core.actions.developer.DeveloperPanelsAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.TablePermissionSubType;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.developer.DeveloperPanel;
|
||||
import com.kingsrook.qqq.backend.core.model.developer.DeveloperPanelBlock;
|
||||
import com.kingsrook.qqq.backend.core.model.developer.DeveloperPanelBlockType;
|
||||
import com.kingsrook.qqq.backend.core.model.developer.DeveloperPanelContext;
|
||||
import com.kingsrook.qqq.backend.core.model.developer.DeveloperPanelKeyValue;
|
||||
import com.kingsrook.qqq.backend.core.model.developer.DeveloperPanelTone;
|
||||
import com.kingsrook.qqq.backend.core.model.developer.DeveloperPanelsMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryBackendModule;
|
||||
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryRecordStore;
|
||||
import com.kingsrook.qbits.quicksearch.QuickSearchQBitConfig;
|
||||
import com.kingsrook.qbits.quicksearch.QuickSearchQBitContext;
|
||||
import com.kingsrook.qbits.quicksearch.QuickSearchQBitProducer;
|
||||
import com.kingsrook.qbits.quicksearch.QuickSearchRuntime;
|
||||
import com.kingsrook.qbits.quicksearch.QuickSearchStartupMode;
|
||||
import com.kingsrook.qbits.quicksearch.SearchableFieldConfig;
|
||||
import com.kingsrook.qbits.quicksearch.metadata.QuickSearchAdminAppMetaDataProducer;
|
||||
import com.kingsrook.qbits.quicksearch.metadata.QuickSearchDeveloperPanelsMetaDataProducer;
|
||||
import com.kingsrook.qbits.quicksearch.metadata.QuickSearchDiagnosticsProcessMetaDataHelper;
|
||||
import com.kingsrook.qbits.quicksearch.model.QuickSearchFailedEvent;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Unit test for {@link QuickSearchDeveloperPanelProvider}: registration,
|
||||
** availability (indexed table, index read permission), the panel's content
|
||||
** with OpenSearch unreachable (a warning, not a failure) and with a faked
|
||||
** inspection, and actions and blocks hidden without permission.
|
||||
*******************************************************************************/
|
||||
class QuickSearchDeveloperPanelProviderTest
|
||||
{
|
||||
private static final String BACKEND = "memory";
|
||||
private static final String PRODUCT = "product";
|
||||
private static final String NOTE = "note";
|
||||
|
||||
private QuickSearchQBitConfig config;
|
||||
private QuickSearchRuntime runtime;
|
||||
private Integer indexRowId;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** An indexed product table and an unindexed note table; Quick Search
|
||||
** produced in DEGRADED mode against a closed port (OpenSearch "down").
|
||||
*******************************************************************************/
|
||||
@BeforeEach
|
||||
void setUp() throws QException
|
||||
{
|
||||
MemoryRecordStore.getInstance().reset();
|
||||
|
||||
QInstance qInstance = new QInstance();
|
||||
qInstance.addBackend(new QBackendMetaData().withName(BACKEND).withBackendType(MemoryBackendModule.class));
|
||||
qInstance.withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("anonymous").withType(QAuthenticationType.FULLY_ANONYMOUS));
|
||||
qInstance.addTable(table(PRODUCT));
|
||||
qInstance.addTable(table(NOTE));
|
||||
QContext.init(qInstance, new QSession());
|
||||
|
||||
config = new QuickSearchQBitConfig()
|
||||
.withStartupMode(QuickSearchStartupMode.DEGRADED)
|
||||
.withBackendName(BACKEND)
|
||||
.withOpensearchHost("127.0.0.1")
|
||||
.withOpensearchPort(1)
|
||||
.withConnectTimeoutMillis(500)
|
||||
.withResponseTimeoutMillis(1000)
|
||||
.withOpensearchIndexName("devpanel-test")
|
||||
.withEnableRealTimeIndexing(false)
|
||||
.withSearchableTable(PRODUCT, List.of(new SearchableFieldConfig("name")));
|
||||
new QuickSearchQBitProducer().withConfig(config).produce(qInstance);
|
||||
runtime = config.getRuntime();
|
||||
|
||||
///////////////////////////////////////////////////////////////
|
||||
// data, inserted by a session that may write every table //
|
||||
///////////////////////////////////////////////////////////////
|
||||
QContext.setQSession(new QSession().withPermissions(allTablePermissions()));
|
||||
new InsertAction().execute(new InsertInput(PRODUCT).withRecords(List.of(
|
||||
new QRecord().withValue("id", 1).withValue("name", "Blue Widget").withValue("modifyDate", Instant.now()))));
|
||||
indexRowId = new InsertAction().execute(new InsertInput(config.getQuickSearchIndexTableName()).withRecords(List.of(
|
||||
new QRecord().withValue("tableName", PRODUCT).withValue("enabled", true).withValue("status", "ACTIVE").withValue("recordCount", 1)
|
||||
.withValue("lastBasepullTime", Instant.parse("2026-10-01T10:00:00Z"))))).getRecords().get(0).getValueInteger("id");
|
||||
new InsertAction().execute(new InsertInput(config.getQuickSearchIndexRunTableName()).withRecords(List.of(
|
||||
new QRecord().withValue("quickSearchIndexId", indexRowId).withValue("runType", "BASEPULL").withValue("status", "COMPLETED")
|
||||
.withValue("startTime", Instant.parse("2026-10-01T10:00:00Z")).withValue("recordsProcessed", 1).withValue("recordsIndexed", 1).withValue("errorCount", 0))));
|
||||
new InsertAction().execute(new InsertInput(config.applyPrefix(QuickSearchFailedEvent.TABLE_NAME)).withRecords(List.of(
|
||||
new QRecord().withValue("tableName", PRODUCT).withValue("recordId", "1").withValue("action", "UPSERT").withValue("errorMessage", "connection refused")
|
||||
.withValue("attempts", 3).withValue("status", QuickSearchFailedEvent.STATUS_PENDING))));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
*******************************************************************************/
|
||||
@AfterEach
|
||||
void tearDown()
|
||||
{
|
||||
if(runtime != null)
|
||||
{
|
||||
runtime.close();
|
||||
}
|
||||
QContext.clear();
|
||||
QuickSearchQBitContext.clear();
|
||||
MemoryRecordStore.getInstance().reset();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
*******************************************************************************/
|
||||
private static QTableMetaData table(String name)
|
||||
{
|
||||
return (new QTableMetaData()
|
||||
.withName(name)
|
||||
.withLabel(name)
|
||||
.withBackendName(BACKEND)
|
||||
.withPrimaryKeyField("id")
|
||||
.withField(new QFieldMetaData("id", QFieldType.INTEGER))
|
||||
.withField(new QFieldMetaData("name", QFieldType.STRING))
|
||||
.withField(new QFieldMetaData("modifyDate", QFieldType.DATE_TIME)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Read and write permissions on the three operational tables.
|
||||
*******************************************************************************/
|
||||
private List<String> allTablePermissions()
|
||||
{
|
||||
List<String> permissions = new ArrayList<>();
|
||||
for(String tableName : List.of(config.getQuickSearchIndexTableName(), config.getQuickSearchIndexRunTableName(), config.applyPrefix(QuickSearchFailedEvent.TABLE_NAME)))
|
||||
{
|
||||
for(TablePermissionSubType subType : TablePermissionSubType.values())
|
||||
{
|
||||
permissions.add(PermissionsHelper.getTablePermissionName(tableName, subType));
|
||||
}
|
||||
}
|
||||
return (permissions);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A session with read on the operational tables, plus the debug and admin
|
||||
** permissions.
|
||||
*******************************************************************************/
|
||||
private void loginAsAdmin()
|
||||
{
|
||||
List<String> permissions = new ArrayList<>(allTablePermissions());
|
||||
permissions.add(QuickSearchQBitConfig.DEFAULT_DEBUG_PERMISSION_BASE_NAME + ".hasAccess");
|
||||
permissions.add(QuickSearchQBitConfig.DEFAULT_ADMIN_PERMISSION_BASE_NAME + ".hasAccess");
|
||||
QContext.setQSession(new QSession().withPermissions(permissions));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
*******************************************************************************/
|
||||
private static DeveloperPanelContext context(String tableName, Integer id)
|
||||
{
|
||||
return (new DeveloperPanelContext()
|
||||
.withTable(QContext.getQInstance().getTable(tableName))
|
||||
.withRecord(new QRecord().withValue("id", id).withValue("name", "Blue Widget")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
*******************************************************************************/
|
||||
private static DeveloperPanel quickSearchPanel(DeveloperPanelContext context)
|
||||
{
|
||||
return (new DeveloperPanelsAction().execute(context).stream()
|
||||
.filter(panel -> QuickSearchDeveloperPanelProvider.ID.equals(panel.getId()))
|
||||
.findFirst().orElse(null));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
*******************************************************************************/
|
||||
private static DeveloperPanelBlock block(DeveloperPanel panel, String label)
|
||||
{
|
||||
return (panel.getBlocks().stream().filter(block -> label.equals(block.getLabel())).findFirst().orElse(null));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
*******************************************************************************/
|
||||
private static DeveloperPanelKeyValue item(DeveloperPanelBlock block, String label)
|
||||
{
|
||||
return (block.getItems().stream().filter(item -> label.equals(item.getLabel())).findFirst().orElseThrow());
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Test
|
||||
void testProducerRegistersProviderOnce() throws QException
|
||||
{
|
||||
QInstance qInstance = QContext.getQInstance();
|
||||
new QuickSearchDeveloperPanelsMetaDataProducer().produce(qInstance).addSelfToInstance(qInstance);
|
||||
|
||||
assertThat(DeveloperPanelsMetaData.of(qInstance).getProviders())
|
||||
.filteredOn(reference -> QuickSearchDeveloperPanelProvider.class.getName().equals(reference.getName()))
|
||||
.hasSize(1);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Test
|
||||
void testAvailability()
|
||||
{
|
||||
QuickSearchDeveloperPanelProvider provider = new QuickSearchDeveloperPanelProvider();
|
||||
|
||||
loginAsAdmin();
|
||||
assertThat(provider.isAvailable(context(PRODUCT, 1))).isTrue();
|
||||
assertThat(provider.isAvailable(context(NOTE, 1))).as("table Quick Search does not index").isFalse();
|
||||
|
||||
QContext.setQSession(new QSession());
|
||||
assertThat(provider.isAvailable(context(PRODUCT, 1))).as("no read permission on Quick Search Index").isFalse();
|
||||
assertThat(quickSearchPanel(context(PRODUCT, 1))).isNull();
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Test
|
||||
void testPanelWithOpenSearchDown_warnsInsteadOfFailing()
|
||||
{
|
||||
loginAsAdmin();
|
||||
DeveloperPanel panel = quickSearchPanel(context(PRODUCT, 1));
|
||||
|
||||
assertThat(panel).isNotNull();
|
||||
assertThat(panel.getError()).isNull();
|
||||
assertThat(panel.getTitle()).isEqualTo("Quick Search");
|
||||
assertThat(panel.getOrder()).isEqualTo(400);
|
||||
assertThat(panel.getStatusLabel()).isEqualTo(QuickSearchDeveloperPanelProvider.STATUS_UNAVAILABLE);
|
||||
assertThat(panel.getStatusTone()).isEqualTo(DeveloperPanelTone.WARNING);
|
||||
|
||||
DeveloperPanelBlock index = block(panel, "Index");
|
||||
assertThat(index.getType()).isEqualTo(DeveloperPanelBlockType.KEY_VALUES);
|
||||
assertThat(item(index, "Index row").getHref()).isEqualTo("/app/" + config.getQuickSearchIndexTableName() + "/" + indexRowId);
|
||||
assertThat(item(index, "Enabled").getValue()).isEqualTo("Yes");
|
||||
assertThat(item(index, "Status").getValue()).isEqualTo("ACTIVE");
|
||||
assertThat(item(index, "Indexed records").getValue()).isEqualTo("1");
|
||||
assertThat(item(index, "Last basepull").getValue()).contains("2026-10-01");
|
||||
assertThat(item(index, "Searchable fields").getValue()).isEqualTo("name");
|
||||
|
||||
DeveloperPanelBlock run = block(panel, "Latest index run");
|
||||
assertThat(item(run, "Type").getValue()).isEqualTo("BASEPULL");
|
||||
assertThat(item(run, "Status").getValue()).isEqualTo("COMPLETED");
|
||||
assertThat(item(run, "Run").getHref()).startsWith("/app/" + config.getQuickSearchIndexRunTableName() + "/");
|
||||
|
||||
DeveloperPanelBlock document = block(panel, "Search document");
|
||||
assertThat(document.getType()).isEqualTo(DeveloperPanelBlockType.TEXT);
|
||||
assertThat(document.getTone()).isEqualTo(DeveloperPanelTone.WARNING);
|
||||
assertThat(document.getText()).contains("OpenSearch could not be reached");
|
||||
|
||||
DeveloperPanelBlock failed = block(panel, "Failed index events");
|
||||
assertThat(failed.getType()).isEqualTo(DeveloperPanelBlockType.TABLE);
|
||||
assertThat(failed.getRows()).hasSize(1);
|
||||
assertThat(failed.getRows().get(0)).contains("UPSERT", QuickSearchFailedEvent.STATUS_PENDING, "3", "connection refused");
|
||||
|
||||
DeveloperPanelBlock actions = block(panel, "Actions");
|
||||
assertThat(actions.getActions()).extracting(action -> action.getProcessName()).containsExactly(
|
||||
QuickSearchDiagnosticsProcessMetaDataHelper.inspectProcessName(config, PRODUCT),
|
||||
QuickSearchDiagnosticsProcessMetaDataHelper.reindexRecordsProcessName(config, PRODUCT));
|
||||
assertThat(actions.getActions()).allMatch(action -> PRODUCT.equals(action.getTableName()));
|
||||
|
||||
DeveloperPanelBlock links = block(panel, "Links");
|
||||
assertThat(links.getLinks()).extracting(link -> link.getHref()).contains(
|
||||
"/app/" + config.applyPrefix(QuickSearchAdminAppMetaDataProducer.APP_NAME),
|
||||
"/app/" + config.getQuickSearchIndexTableName() + "/" + indexRowId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Test
|
||||
void testWithoutDebugOrAdminPermission_noDocumentNoActions()
|
||||
{
|
||||
QContext.setQSession(new QSession().withPermission(PermissionsHelper.getTablePermissionName(config.getQuickSearchIndexTableName(), TablePermissionSubType.READ)));
|
||||
DeveloperPanel panel = quickSearchPanel(context(PRODUCT, 1));
|
||||
|
||||
assertThat(panel).isNotNull();
|
||||
assertThat(panel.getError()).isNull();
|
||||
assertThat(block(panel, "Search document").getText()).contains("debug permission");
|
||||
assertThat(block(panel, "Actions")).isNull();
|
||||
assertThat(block(panel, "Latest index run")).as("run table not readable").isNull();
|
||||
assertThat(block(panel, "Failed index events")).as("failed event table not readable").isNull();
|
||||
assertThat(block(panel, "Links").getLinks()).extracting(link -> link.getHref())
|
||||
.doesNotContain("/app/" + config.applyPrefix(QuickSearchAdminAppMetaDataProducer.APP_NAME));
|
||||
assertThat(panel.getStatusLabel()).isEqualTo(QuickSearchDeveloperPanelProvider.STATUS_ENABLED);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Test
|
||||
void testDebugWithoutAdmin_inspectActionOnly()
|
||||
{
|
||||
QContext.setQSession(new QSession()
|
||||
.withPermission(PermissionsHelper.getTablePermissionName(config.getQuickSearchIndexTableName(), TablePermissionSubType.READ))
|
||||
.withPermission(QuickSearchQBitConfig.DEFAULT_DEBUG_PERMISSION_BASE_NAME + ".hasAccess"));
|
||||
DeveloperPanel panel = quickSearchPanel(context(PRODUCT, 1));
|
||||
|
||||
assertThat(block(panel, "Actions").getActions()).extracting(action -> action.getProcessName())
|
||||
.containsExactly(QuickSearchDiagnosticsProcessMetaDataHelper.inspectProcessName(config, PRODUCT));
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Test
|
||||
void testFakedInspection_currentAndStale() throws QException
|
||||
{
|
||||
loginAsAdmin();
|
||||
|
||||
DeveloperPanel current = new FakeInspectionProvider(RecordInspector.VERDICT_CURRENT,
|
||||
List.of(new RecordInspector.FieldDiff("fieldValues.name", "Blue Widget", "Blue Widget"))).build(context(PRODUCT, 1));
|
||||
assertThat(current.getStatusLabel()).isEqualTo(QuickSearchDeveloperPanelProvider.STATUS_INDEXED);
|
||||
assertThat(current.getStatusTone()).isEqualTo(DeveloperPanelTone.SUCCESS);
|
||||
DeveloperPanelBlock comparison = block(current, "Database vs index");
|
||||
assertThat(comparison.getColumns()).containsExactly("Field", "Database", "Index", "Match");
|
||||
assertThat(comparison.getRows()).containsExactly(List.of("fieldValues.name", "Blue Widget", "Blue Widget", "yes"));
|
||||
|
||||
DeveloperPanel stale = new FakeInspectionProvider(RecordInspector.VERDICT_STALE,
|
||||
List.of(new RecordInspector.FieldDiff("fieldValues.name", "Blue Widget", "Old Name"))).build(context(PRODUCT, 1));
|
||||
assertThat(stale.getStatusLabel()).isEqualTo(QuickSearchDeveloperPanelProvider.STATUS_STALE);
|
||||
assertThat(block(stale, "Database vs index").getRows().get(0)).containsExactly("fieldValues.name", "Blue Widget", "Old Name", "NO");
|
||||
|
||||
DeveloperPanel missing = new FakeInspectionProvider(RecordInspector.VERDICT_MISSING, List.of()).build(context(PRODUCT, 1));
|
||||
assertThat(missing.getStatusLabel()).isEqualTo(QuickSearchDeveloperPanelProvider.STATUS_NOT_INDEXED);
|
||||
assertThat(missing.getStatusTone()).isEqualTo(DeveloperPanelTone.ERROR);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Test
|
||||
void testDisabledTable() throws QException
|
||||
{
|
||||
QContext.setQSession(new QSession().withPermissions(allTablePermissions()));
|
||||
new UpdateAction().execute(new UpdateInput(config.getQuickSearchIndexTableName()).withRecords(List.of(new QRecord().withValue("id", indexRowId).withValue("enabled", false))));
|
||||
|
||||
loginAsAdmin();
|
||||
DeveloperPanel panel = new FakeInspectionProvider(RecordInspector.VERDICT_CURRENT, List.of()).build(context(PRODUCT, 1));
|
||||
assertThat(panel.getStatusLabel()).isEqualTo(QuickSearchDeveloperPanelProvider.STATUS_DISABLED);
|
||||
assertThat(item(block(panel, "Index"), "Enabled").getValue()).isEqualTo("No");
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Test
|
||||
void testTruncate()
|
||||
{
|
||||
assertThat(QuickSearchDeveloperPanelProvider.truncate(null)).isNull();
|
||||
assertThat(QuickSearchDeveloperPanelProvider.truncate("short")).isEqualTo("short");
|
||||
assertThat(QuickSearchDeveloperPanelProvider.truncate("x".repeat(QuickSearchDeveloperPanelProvider.MAX_VALUE_LENGTH + 5))).endsWith("(" + (QuickSearchDeveloperPanelProvider.MAX_VALUE_LENGTH + 5) + " characters)");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A provider whose inspection is a fixed result (no OpenSearch).
|
||||
*******************************************************************************/
|
||||
private static class FakeInspectionProvider extends QuickSearchDeveloperPanelProvider
|
||||
{
|
||||
private final String verdict;
|
||||
private final List<RecordInspector.FieldDiff> diffs;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
*******************************************************************************/
|
||||
FakeInspectionProvider(String verdict, List<RecordInspector.FieldDiff> diffs)
|
||||
{
|
||||
this.verdict = verdict;
|
||||
this.diffs = diffs;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected RecordInspector.Inspection inspect(QuickSearchRuntime runtime, String tableName, String recordId, AbstractActionInput permissionInput)
|
||||
{
|
||||
return (new RecordInspector.Inspection(tableName, recordId, verdict, diffs, null, null, List.of(), null, null));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
Reference in new issue
Block a user