diff --git a/api/src/org/labkey/api/ApiModule.java b/api/src/org/labkey/api/ApiModule.java index b8c0867b594..b391b5dd413 100644 --- a/api/src/org/labkey/api/ApiModule.java +++ b/api/src/org/labkey/api/ApiModule.java @@ -83,12 +83,14 @@ import org.labkey.api.data.WorkbookContainerType; import org.labkey.api.data.dialect.JdbcMetaDataTest; import org.labkey.api.data.dialect.ParameterSubstitutionTest; +import org.labkey.api.data.dialect.PostgresSnapshot; import org.labkey.api.data.dialect.StandardDialectStringHandler; import org.labkey.api.dataiterator.CachingDataIterator; import org.labkey.api.dataiterator.DataIteratorUtil; import org.labkey.api.dataiterator.DiskCachingDataIterator; import org.labkey.api.dataiterator.ExistingRecordDataIterator; import org.labkey.api.dataiterator.GenerateUniqueDataIterator; +import org.labkey.api.dataiterator.QueryDataIteratorBuilder; import org.labkey.api.dataiterator.RemoveDuplicatesDataIterator; import org.labkey.api.dataiterator.ResultSetDataIterator; import org.labkey.api.dataiterator.SimpleTranslator; @@ -548,7 +550,9 @@ public void registerServlets(ServletContext servletCtx) NestedGroupsTest.class, ParameterSubstitutionTest.class, Portal.TestCase.class, + PostgresSnapshot.TestCase.class, PropertyManager.TestCase.class, + QueryDataIteratorBuilder.TestCase.class, RecordFactory.TestCase.class, ResultSetDataIterator.TestCase.class, ResultSetSelectorTestCase.class, diff --git a/api/src/org/labkey/api/action/ApiQueryResponse.java b/api/src/org/labkey/api/action/ApiQueryResponse.java index bbcaba1b7b9..66fc4d6af26 100644 --- a/api/src/org/labkey/api/action/ApiQueryResponse.java +++ b/api/src/org/labkey/api/action/ApiQueryResponse.java @@ -75,6 +75,7 @@ public class ApiQueryResponse implements ApiResponse private RenderContext _ctx = null; private boolean _schemaEditable = false; private boolean _metaDataOnly; + private boolean _countOnly = false; private DataRegion _dataRegion; private TableInfo _tinfo = null; private List _displayColumns = null; @@ -147,6 +148,16 @@ public void render(ApiResponseWriter writer) throws Exception if (_metaDataOnlyIncludesEmptyRowset) writeEmptyRowset(writer); } + else if (_countOnly) + { + loadTotalRowsOnly(); + if (_dataRegion.getTotalRows() != null) + _rowCount = _dataRegion.getTotalRows(); + + if (_includeMetaData) + writeMetaData(writer); + writeEmptyRowset(writer); + } else { // First run the query, so on potential SQLException we only serialize the exception instead of outputting all the metadata before the exception @@ -170,8 +181,11 @@ public void render(ApiResponseWriter writer) throws Exception _rowCount = _dataRegion.getTotalRows(); } } + } - long rowCount = _rowCount > 0 ? _rowCount : _offset + _numRespRows; + if (!_metaDataOnly) + { + long rowCount = _rowCount > 0 || _countOnly ? _rowCount : _offset + _numRespRows; writer.writeProperty("rowCount", rowCount); if (_includeMetaData) @@ -268,9 +282,7 @@ protected void writeMetaData(ApiResponseWriter writer) throws Exception protected Results getResults() throws Exception { - // If we're going to be writing JSON back, which is tolerant of extra spaces, allow async so we - // can monitor if the client has stopped listening. XML doesn't take kindly to leading spaces - _dataRegion.setAllowAsync(ApiResponseWriter.getResponseFormat(_viewContext.getRequest(), ApiResponseWriter.Format.JSON).isJson()); + _dataRegion.setAllowAsync(isAllowAsync()); try { return _dataRegion.getResults(_ctx); @@ -281,6 +293,26 @@ protected Results getResults() throws Exception } } + protected void loadTotalRowsOnly() + { + _dataRegion.setAllowAsync(isAllowAsync()); + try + { + _dataRegion.loadTotalRowsOnly(_ctx); + } + catch (ConversionException e) + { + throw new ApiUsageException(e); + } + } + + // If we're going to be writing JSON back, which is tolerant of extra spaces, allow async so we + // can monitor if the client has stopped listening. XML doesn't take kindly to leading spaces + private boolean isAllowAsync() + { + return ApiResponseWriter.getResponseFormat(_viewContext.getRequest(), ApiResponseWriter.Format.JSON).isJson(); + } + public List getFieldKeys() { return _fieldKeys; @@ -328,6 +360,12 @@ public void includeStyle(boolean withStyle) _doItWithStyle = withStyle; } + /** Return only rowCount (and metadata, if requested) via a COUNT(*) query, skipping the rows query. */ + public void countOnly(boolean countOnly) + { + _countOnly = countOnly; + } + protected double getFormatVersion() { return 8.3; diff --git a/api/src/org/labkey/api/assay/AssayProtocolSchema.java b/api/src/org/labkey/api/assay/AssayProtocolSchema.java index 8e51ce79517..042cbd8c1f0 100644 --- a/api/src/org/labkey/api/assay/AssayProtocolSchema.java +++ b/api/src/org/labkey/api/assay/AssayProtocolSchema.java @@ -41,8 +41,8 @@ import org.labkey.api.data.RenderContext; import org.labkey.api.data.Results; import org.labkey.api.data.SQLFragment; +import org.labkey.api.data.ShowRows; import org.labkey.api.data.Sort; -import org.labkey.api.data.Table; import org.labkey.api.data.TableInfo; import org.labkey.api.exp.PropertyDescriptor; import org.labkey.api.exp.api.ExpProtocol; @@ -711,7 +711,7 @@ public void addQCWarningIndicator(QueryView baseQueryView, ViewContext context, QuerySettings qs = getSettings(viewContext, settings.getDataRegionName(), settings.getQueryName()); // we want all the rows - qs.setMaxRows(Table.ALL_ROWS); + qs.setShowRows(ShowRows.ALL); QueryView allResultsQueryView = createAllResultsQueryView(viewContext, qs); DataView dataView = allResultsQueryView.createDataView(); diff --git a/api/src/org/labkey/api/data/DataRegion.java b/api/src/org/labkey/api/data/DataRegion.java index 0aeb5ec9a1b..6ce8ff60826 100644 --- a/api/src/org/labkey/api/data/DataRegion.java +++ b/api/src/org/labkey/api/data/DataRegion.java @@ -851,16 +851,7 @@ public Map> getAggregateResults(RenderContext ctx newAggregates.add(Aggregate.createCountStar()); _aggregateResults = ctx.getAggregates(_displayColumns, getTable(), getSettings(), getName(), newAggregates, getQueryParameters(), isAllowAsync()); - List result = _aggregateResults.remove(Aggregate.STAR); - - //Issue 14863: add null check - if (result != null && !result.isEmpty()) - { - Aggregate.Result countStarResult = result.getFirst(); - _totalRows = 0L; - if (countStarResult.getValue() instanceof Number) - _totalRows = ((Number) countStarResult.getValue()).longValue(); - } + setTotalRowsFromCountStar(_aggregateResults.remove(Aggregate.STAR)); } } else @@ -876,6 +867,42 @@ public Map> getAggregateResults(RenderContext ctx return _aggregateResults; } + /** Sets the total row count from a COUNT(*) aggregate alone, without selecting any rows. */ + public void loadTotalRowsOnly(RenderContext ctx) + { + if (!hasPermission(ctx, ReadPermission.class)) + throw new UnauthorizedException(); + + TableInfo table = getTable(); + if (null == table) + throw new NotFoundException("Table or query not found: " + getSettings().getQueryName()); + + DataRegion oldRegion = ctx.getCurrentRegion(); + ctx.setCurrentRegion(this); + try + { + Map> aggregates = ctx.getAggregates(_displayColumns, table, getSettings(), getName(), List.of(Aggregate.createCountStar()), getQueryParameters(), isAllowAsync()); + setTotalRowsFromCountStar(aggregates.get(Aggregate.STAR)); + _aggregateResults = Collections.emptyMap(); + } + finally + { + ctx.setCurrentRegion(oldRegion); + } + } + + private void setTotalRowsFromCountStar(@Nullable List result) + { + //Issue 14863: add null check + if (result == null || result.isEmpty()) + return; + + Aggregate.Result countStarResult = result.getFirst(); + _totalRows = 0L; + if (countStarResult.getValue() instanceof Number n) + _totalRows = n.longValue(); + } + @NotNull private List getSummaryStatsAggregates(List providers) { diff --git a/api/src/org/labkey/api/data/RenderContext.java b/api/src/org/labkey/api/data/RenderContext.java index db90b930c62..9aa89c5c0f1 100644 --- a/api/src/org/labkey/api/data/RenderContext.java +++ b/api/src/org/labkey/api/data/RenderContext.java @@ -350,7 +350,10 @@ public Map> getAggregates(List dis if (null != QueryService.get()) cols = QueryService.get().ensureRequiredColumns(tinfo, cols, filter, sort, ignoredAggregateFilters); - if (!ignoredAggregateFilters.equals(_ignoredColumnFilters)) + // Count-only requests skip the main query, so there's nothing to match; adopt the aggregate's set for filter messages + if (_results == null) + _ignoredColumnFilters.addAll(ignoredAggregateFilters); + else if (!ignoredAggregateFilters.equals(_ignoredColumnFilters)) { // This should never happen, but if it did, the totals wouldn't match, so we won't calculate them. _log.error("Aggregate filter columns do not match main. Aggregate:{} Main:{}", ignoredAggregateFilters, _ignoredColumnFilters); diff --git a/api/src/org/labkey/api/data/dialect/PostgresSnapshot.java b/api/src/org/labkey/api/data/dialect/PostgresSnapshot.java new file mode 100644 index 00000000000..3202db467fe --- /dev/null +++ b/api/src/org/labkey/api/data/dialect/PostgresSnapshot.java @@ -0,0 +1,192 @@ +/* + * Copyright (c) 2026 LabKey Corporation + * + * 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 org.labkey.api.data.dialect; + +import org.apache.logging.log4j.Level; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.json.JSONObject; +import org.junit.Assert; +import org.junit.Assume; +import org.junit.Test; +import org.labkey.api.data.CoreSchema; +import org.labkey.api.data.DbSchema; +import org.labkey.api.data.SqlSelector; +import org.springframework.dao.DataAccessException; + +import java.util.Set; + +/** + * Read-only capture of Postgres configuration and cumulative statistics as a single JSON document. Differencing a + * capture taken before a workload against one taken after it shows the work the database actually did. + */ +public class PostgresSnapshot +{ + public enum StatementsStatus + { + AVAILABLE, + NOT_INSTALLED, + /** CREATE EXTENSION succeeds without shared_preload_libraries, but every read of the view then fails */ + NOT_LOADED + } + + private record StatementsSource(@NotNull StatementsStatus status, @Nullable String view) {} + + private static final int PG_16 = 160000; + private static final int PG_17 = 170000; + + // Counter columns are captured with to_jsonb() rather than named individually because their names move between + // major versions (blk_read_time became shared_blk_read_time in PG 17), so one query serves every version and + // pg_settings records which version produced the capture. Views that don't exist in a version fail at parse time, + // so those subqueries are chosen in Java. + private static final String SNAPSHOT_SQL = """ + SELECT json_build_object( + 'takenAt', to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"'), + 'serverVersionNum', current_setting('server_version_num')::int, + 'database', ( + SELECT to_jsonb(d) FROM pg_stat_database d WHERE datname = current_database() + ), + 'bgwriter', ( + SELECT to_jsonb(b) FROM pg_stat_bgwriter b + ), + 'checkpointer', (%s), + 'wal', ( + SELECT to_jsonb(w) FROM pg_stat_wal w + ), + 'io', (%s), + 'settings', ( + SELECT json_object_agg(name, json_build_object( + 'value', setting, + 'unit', unit, + 'source', source, + 'pending_restart', pending_restart, + 'boot_val', boot_val, + 'reset_val', reset_val + )) + FROM pg_settings + ), + 'statementsStatus', '%s', + 'statements', (%s) + )::text"""; + + private static final String CHECKPOINTER_SQL = "SELECT to_jsonb(c) FROM pg_stat_checkpointer c"; + + // Checkpoint counters live in pg_stat_bgwriter until PG 17 + private static final String CHECKPOINTER_FROM_BGWRITER_SQL = "SELECT to_jsonb(b) FROM pg_stat_bgwriter b"; + + private static final String IO_SQL = """ + SELECT json_object_agg(backend_type || ':' || object || ':' || context, to_jsonb(i)) + FROM pg_stat_io i"""; + + private static final String NULL_SQL = "SELECT NULL::json"; + + // A row's identity is (userid, dbid, queryid, toplevel), so queryid alone collides across roles and nesting levels. + // Without pg_read_all_stats or superuser this silently returns only the LabKey role's own statements. + private static final String STATEMENTS_SQL = """ + SELECT COALESCE(json_object_agg(queryid::text || ':' || userid::text || ':' || toplevel::text, to_jsonb(s)), '{}'::json) + FROM %s s + WHERE dbid = (SELECT oid FROM pg_database WHERE datname = current_database()) + AND queryid IS NOT NULL"""; + + private static final String NO_STATEMENTS_SQL = "SELECT '{}'::json"; + + public static @NotNull String capture() + { + DbSchema schema = CoreSchema.getInstance().getSchema(); + int version = new SqlSelector(schema, "SELECT current_setting('server_version_num')::int").getObject(Integer.class); + StatementsSource statements = resolveStatements(schema); + String statementsSql = StatementsStatus.AVAILABLE == statements.status() ? STATEMENTS_SQL.formatted(statements.view()) : NO_STATEMENTS_SQL; + + String sql = SNAPSHOT_SQL.formatted( + version >= PG_17 ? CHECKPOINTER_SQL : CHECKPOINTER_FROM_BGWRITER_SQL, + version >= PG_16 ? IO_SQL : NULL_SQL, + statements.status().name(), + statementsSql + ); + + return new SqlSelector(schema, sql).getObject(String.class); + } + + /** Per-query statistics come from pg_stat_statements; snapshots are far less useful without it. */ + public static @NotNull StatementsStatus getStatementsStatus() + { + return resolveStatements(CoreSchema.getInstance().getSchema()).status(); + } + + /** + * The extension's schema isn't necessarily on the search_path, and referencing a view that doesn't exist fails at + * parse time, so the view name has to be assembled from what's actually there. + */ + private static @NotNull StatementsSource resolveStatements(DbSchema schema) + { + String view = new SqlSelector(schema, """ + SELECT quote_ident(n.nspname) || '.pg_stat_statements' + FROM pg_extension e INNER JOIN pg_namespace n ON n.oid = e.extnamespace + WHERE e.extname = 'pg_stat_statements'""").getObject(String.class); + + if (null == view) + return new StatementsSource(StatementsStatus.NOT_INSTALLED, null); + + try + { + new SqlSelector(schema, "SELECT 1 FROM " + view).setLogLevel(Level.OFF).exists(); + return new StatementsSource(StatementsStatus.AVAILABLE, view); + } + catch (DataAccessException e) + { + return new StatementsSource(StatementsStatus.NOT_LOADED, view); + } + } + + public static class TestCase extends Assert + { + @Test + public void testCapture() + { + Assume.assumeTrue("Requires Postgres", CoreSchema.getInstance().getSqlDialect().isPostgreSQL()); + + // JSONObject rejects duplicate keys, so parsing also verifies statement keys are unique + JSONObject snapshot = new JSONObject(capture()); + + for (String key : Set.of("takenAt", "serverVersionNum", "database", "bgwriter", "checkpointer", "wal", "io", "settings", "statementsStatus", "statements")) + assertTrue("Missing key: " + key, snapshot.has(key)); + + int version = snapshot.getInt("serverVersionNum"); + assertTrue(snapshot.getJSONObject("database").has("xact_commit")); + assertTrue(snapshot.getJSONObject("settings").has("shared_buffers")); + assertTrue(snapshot.getJSONObject("wal").has("wal_records")); + assertTrue(snapshot.getJSONObject("checkpointer").has(version >= PG_17 ? "num_timed" : "checkpoints_timed")); + assertEquals(version >= PG_16, !snapshot.isNull("io")); + + StatementsStatus status = StatementsStatus.valueOf(snapshot.getString("statementsStatus")); + assertEquals(getStatementsStatus(), status); + + JSONObject statements = snapshot.getJSONObject("statements"); + if (StatementsStatus.AVAILABLE != status) + { + assertTrue(statements.isEmpty()); + } + else + { + for (String key : statements.keySet()) + { + JSONObject row = statements.getJSONObject(key); + assertEquals(row.get("queryid") + ":" + row.get("userid") + ":" + row.get("toplevel"), key); + } + } + } + } +} diff --git a/api/src/org/labkey/api/dataiterator/QueryDataIteratorBuilder.java b/api/src/org/labkey/api/dataiterator/QueryDataIteratorBuilder.java index 85315afd919..08c786473b2 100644 --- a/api/src/org/labkey/api/dataiterator/QueryDataIteratorBuilder.java +++ b/api/src/org/labkey/api/dataiterator/QueryDataIteratorBuilder.java @@ -15,6 +15,8 @@ */ package org.labkey.api.dataiterator; +import org.junit.Assert; +import org.junit.Test; import org.labkey.api.collections.CaseInsensitiveHashMap; import org.labkey.api.data.ColumnInfo; import org.labkey.api.data.Container; @@ -32,7 +34,11 @@ import org.labkey.api.query.UserSchema; import org.labkey.api.query.ValidationException; import org.labkey.api.security.User; +import org.labkey.api.sql.LabKeySql; +import org.labkey.api.util.JunitUtil; +import org.labkey.api.util.TestContext; +import java.io.IOException; import java.sql.ResultSet; import java.util.ArrayList; import java.util.Collection; @@ -118,7 +124,7 @@ public DataIterator getDataIterator(DataIteratorContext context) String sql; if (null != _queryName) - sql = "SELECT * FROM " + s.getDbSchema().getSqlDialect().quoteIdentifier(_queryName); + sql = "SELECT * FROM " + LabKeySql.quoteIdentifier(_queryName); else sql = _sql; @@ -137,6 +143,17 @@ public DataIterator getDataIterator(DataIteratorContext context) // Issue 42242: Don't include suggested columns in QueryDataIterator TableInfo t = qd.getTable((UserSchema)_schema, qerrors, true, true); + if (!qerrors.isEmpty()) + { + context.getErrors().addRowError(new ValidationException(qerrors.getFirst().getMessage())); + return null; + } + if (null == t) + { + context.getErrors().addRowError(new ValidationException("Query not found: " + sql)); + return null; + } + Collection selectCols = t.getColumns(); if (null != _columns && !_columns.isEmpty()) { @@ -146,12 +163,6 @@ public DataIterator getDataIterator(DataIteratorContext context) selectCols = qs.getColumns(t, keys).values(); } - if (!qerrors.isEmpty()) - { - context.getErrors().addRowError(new ValidationException(qerrors.getFirst().getMessage())); - return null; - } - /* if (null != _containerFilter && t instanceof ContainerFilterable && t.supportsContainerFilter()) { @@ -171,4 +182,48 @@ public DataIterator getDataIterator(DataIteratorContext context) } return null; } + + public static class TestCase extends Assert + { + private DataIterator run(String queryName, DataIteratorContext context) + { + QuerySchema core = QueryService.get().getUserSchema(TestContext.get().getUser(), JunitUtil.getTestContainer(), "core"); + return new QueryDataIteratorBuilder(core, queryName, null, null).getDataIterator(context); + } + + @Test + public void existingQuery() throws IOException + { + DataIteratorContext context = new DataIteratorContext(); + try (DataIterator it = run("Users", context)) + { + assertNotNull(it); + assertFalse(context.getErrors().hasErrors()); + } + } + + @Test + public void bogusQueryName() + { + DataIteratorContext context = new DataIteratorContext(); + assertNull(run("NoSuchQuery", context)); + assertTrue(context.getErrors().hasErrors()); + String message = context.getErrors().getRowErrors().getFirst().getMessage(); + assertTrue(message, message.contains("NoSuchQuery")); + } + + @Test + public void quoteCharactersInQueryName() + { + // the name is quoted as LabKey SQL, so characters other dialects quote with are just part of the name + for (String name : List.of("No`Such`Query", "No\"Such\"Query", "No[Such]Query")) + { + DataIteratorContext context = new DataIteratorContext(); + assertNull(run(name, context)); + assertTrue(context.getErrors().hasErrors()); + String message = context.getErrors().getRowErrors().getFirst().getMessage(); + assertTrue(message, message.contains("not found")); + } + } + } } diff --git a/api/webapp/clientapi/dom/DataRegion.js b/api/webapp/clientapi/dom/DataRegion.js index 7cea7215eef..497c6589497 100644 --- a/api/webapp/clientapi/dom/DataRegion.js +++ b/api/webapp/clientapi/dom/DataRegion.js @@ -3829,7 +3829,7 @@ if (!LABKEY.DataRegions) { containerPath: region.containerPath, filterArray: LABKEY.Filter.getFiltersFromParameters({ ...params, ...jsonData.filters }, params.dataRegionName), sort: undefined, - maxRows: 1, + maxRows: 0, // GH Issue 1607: with includeTotalCount, returns only the row count offset: 0, includeMetadata: false, includeDetailsColumn: false, diff --git a/core/src/org/labkey/core/CoreModule.java b/core/src/org/labkey/core/CoreModule.java index e7abdb046c9..20156e4e0ad 100644 --- a/core/src/org/labkey/core/CoreModule.java +++ b/core/src/org/labkey/core/CoreModule.java @@ -79,7 +79,6 @@ import org.labkey.api.data.dialect.BasePostgreSqlDialect; import org.labkey.api.data.dialect.PostgreSqlService; import org.labkey.api.data.dialect.SqlDialect; -import org.labkey.api.data.dialect.SqlDialect.DataSourcePropertyReader; import org.labkey.api.data.dialect.SqlDialectManager; import org.labkey.api.data.dialect.SqlDialectRegistry; import org.labkey.api.data.statistics.StatsService; diff --git a/core/src/org/labkey/core/admin/AdminController.java b/core/src/org/labkey/core/admin/AdminController.java index 3b60f00cae9..8103f7834c0 100644 --- a/core/src/org/labkey/core/admin/AdminController.java +++ b/core/src/org/labkey/core/admin/AdminController.java @@ -138,6 +138,7 @@ import org.labkey.api.data.TransactionFilter; import org.labkey.api.data.WorkbookContainerType; import org.labkey.api.data.dialect.BasePostgreSqlDialect; +import org.labkey.api.data.dialect.PostgresSnapshot; import org.labkey.api.data.dialect.SqlDialect.ExecutionPlanType; import org.labkey.api.data.queryprofiler.QueryProfiler; import org.labkey.api.data.queryprofiler.QueryProfiler.QueryStatTsvWriter; @@ -510,6 +511,7 @@ public static void registerAdminConsoleLinks() { AdminConsole.addLink(Diagnostics, "postgres activity", new ActionURL(PostgresStatActivityAction.class, root)); AdminConsole.addLink(Diagnostics, "postgres locks", new ActionURL(PostgresLocksAction.class, root)); + AdminConsole.addLink(Diagnostics, "postgres snapshot", new ActionURL(PostgresSnapshotAction.class, root)); AdminConsole.addLink(Diagnostics, "postgres table sizes", new ActionURL(PostgresTableSizesAction.class, root)); } @@ -2737,6 +2739,57 @@ public PostgresTableSizesAction() } } + private static void validatePostgresSnapshotRequest(Container c) + { + if (!c.isRoot()) + throw new NotFoundException("Available only in the root container"); + + if (!CoreSchema.getInstance().getSqlDialect().isPostgreSQL()) + throw new NotFoundException("Available only with Postgres as the primary database"); + } + + @RequiresPermission(TroubleshooterPermission.class) + public static class PostgresSnapshotAction extends SimpleViewAction + { + @Override + public ModelAndView getView(Object form, BindException errors) + { + validatePostgresSnapshotRequest(getContainer()); + + String warning = switch (PostgresSnapshot.getStatementsStatus()) + { + case AVAILABLE -> null; + case NOT_INSTALLED -> "Snapshots will omit per-query statistics because the pg_stat_statements extension is not installed in this database. To install it, add pg_stat_statements to shared_preload_libraries in postgresql.conf, restart Postgres, then execute CREATE EXTENSION pg_stat_statements;"; + case NOT_LOADED -> "Snapshots will omit per-query statistics because the pg_stat_statements extension is installed but its library is not loaded. To load it, add pg_stat_statements to shared_preload_libraries in postgresql.conf, then restart Postgres."; + }; + + return new HtmlView(DIV( + null == warning ? null : DIV(cl("labkey-warning-messages"), warning), + P("Captures Postgres configuration and cumulative statistics as a JSON file. Comparing a snapshot taken before a workload against one taken after it shows the work the database actually did and can be useful for evaluating bottlenecks and optimizing resources."), + PageFlowUtil.button("Download Snapshot").href(new ActionURL(DownloadPostgresSnapshotAction.class, getContainer())).getHtmlString() + )); + } + + @Override + public void addNavTrail(NavTree root) + { + addAdminNavTrail(root, "Postgres Snapshot", this.getClass(), getContainer()); + } + } + + @RequiresPermission(TroubleshooterPermission.class) + public static class DownloadPostgresSnapshotAction extends ExportAction + { + @Override + public void export(Object form, HttpServletResponse response, BindException errors) throws Exception + { + validatePostgresSnapshotRequest(getContainer()); + + String filename = FileUtil.makeFileNameWithTimestamp("pg-snapshot", "json"); + PageFlowUtil.streamFileBytes(response, filename, PostgresSnapshot.capture().getBytes(StringUtilsLabKey.DEFAULT_CHARSET), true); + } + } + @AdminConsoleAction public class DumpHeapAction extends ConfirmAction { diff --git a/pipeline/src/client/CreatePipelineTrigger/CreatePipelineTrigger.tsx b/pipeline/src/client/CreatePipelineTrigger/CreatePipelineTrigger.tsx index 818caa8cb85..473c9d80f19 100644 --- a/pipeline/src/client/CreatePipelineTrigger/CreatePipelineTrigger.tsx +++ b/pipeline/src/client/CreatePipelineTrigger/CreatePipelineTrigger.tsx @@ -7,7 +7,6 @@ import React, { ChangeEvent, Dispatch, FC, Reducer, useCallback, useEffect, useR import { ActionURL, Ajax, Utils } from '@labkey/api'; import { Alert, AutoForm, cancelEvent, FormSchema, naturalSort, redirect } from '@labkey/components'; -// eslint-disable-next-line import/no-unassigned-import import './CreatePipelineTrigger.scss'; const HELP_TEXT = 'Fields marked with an asterisk * are required. '; @@ -36,7 +35,7 @@ interface TriggerConfiguration { rowId: number; } -type CustomConfiguration = Record; +type CustomConfiguration = Record; interface CustomParameterModel { id: number; // used internally as a key for react @@ -74,13 +73,13 @@ interface FormState { // The omitted fields are computed by initializeFormState type InitialState = Omit< FormState, - | 'customParameters' | 'customConfigValid' + | 'customParameters' | 'detailsValid' | 'isDirty' | 'saveError' - | 'saving' | 'saveSuccessful' + | 'saving' | 'triggerConfigValid' >; @@ -98,7 +97,7 @@ enum ActionType { // eslint-disable-next-line @typescript-eslint/no-explicit-any interface FieldAction { field: string; - type: ActionType.UPDATE_DETAILS | ActionType.UPDATE_TRIGGER_CONFIG | ActionType.UPDATE_CUSTOM_CONFIG; + type: ActionType.UPDATE_CUSTOM_CONFIG | ActionType.UPDATE_DETAILS | ActionType.UPDATE_TRIGGER_CONFIG; value: T; } @@ -131,12 +130,12 @@ interface SetSavingAction { } type FormStateAction = - | FieldAction - | ViewAction | AddCustomParamAction + | FieldAction | RemoveCustomParamAction + | SetSavingAction | UpdateCustomParamAction - | SetSavingAction; + | ViewAction; const validateValues = (formSchema: FormSchema, values: Record): boolean => { // Not every trigger type has a custom form schema, so this can be null/undefined. @@ -162,6 +161,29 @@ const validateValues = (formSchema: FormSchema, values: Record): bo const formStateReducer = (state: FormState, action: FormStateAction): FormState => { switch (action.type) { + case ActionType.ADD_CUSTOM_PARAM: { + const { customParameters } = state; + const integerIds = Object.keys(customParameters).map(k => parseInt(k, 10)); + const id = integerIds.length > 0 ? Math.max(...integerIds) + 1 : 0; + return { + ...state, + customParameters: { + ...customParameters, + [id]: { key: '', value: '', id }, + }, + isDirty: true, + }; + } + case ActionType.REMOVE_CUSTOM_PARAM: { + const { id } = action; + const customParameters = { ...state.customParameters }; + delete customParameters[id]; + return { ...state, customParameters, isDirty: true }; + } + case ActionType.SET_SAVING: { + const { saving, saveError, saveSuccessful } = action; + return { ...state, saving, saveError, saveSuccessful }; + } case ActionType.SET_VIEW: { // Don't navigate to the Configuration form if the user hasn't filled out the required fields in Details. if (!state.detailsValid) { @@ -170,6 +192,36 @@ const formStateReducer = (state: FormState, action: FormStateAction): FormState return { ...state, view: action.view }; } + case ActionType.UPDATE_CUSTOM_CONFIG: { + const customConfig = { ...state.customConfig, [action.field]: action.value }; + const formSchema = state.customFieldFormSchemas[state.details.pipelineId]; + return { + ...state, + isDirty: true, + customConfig, + customConfigValid: validateValues(formSchema, customConfig), + }; + } + case ActionType.UPDATE_CUSTOM_PARAM: { + const { customParameters } = state; + const { id, key, value } = action; + const keyIsSame = customParameters[id].key === key; + const valueIsSame = customParameters[id].value === value; + + if (keyIsSame && valueIsSame) { + // If nothing changed, no-op. + return state; + } + + return { + ...state, + customParameters: { + ...customParameters, + [id]: { key, value, id }, + }, + isDirty: true, + }; + } case ActionType.UPDATE_DETAILS: { const { customConfig, customFieldFormSchemas, details, taskFormSchemas, triggerConfig } = state; const { field, value } = action; @@ -180,7 +232,6 @@ const formStateReducer = (state: FormState, action: FormStateAction): FormState if (field === 'pipelineId') { // Set default values on the customConfig based on the appropriate FormSchema. resetCustomConfig = {}; - // eslint-disable-next-line no-unused-expressions customFieldFormSchemas[value]?.fields.forEach(f => { if (f.defaultValue !== null) { resetCustomConfig[f.name] = f.defaultValue; @@ -193,6 +244,10 @@ const formStateReducer = (state: FormState, action: FormStateAction): FormState if (taskFormSchema) { Object.keys(resetTriggerConfig).forEach(key => { + // parameterFunction isn't task-specific, and users without permission must save it back + // unchanged + if (key === 'parameterFunction') return; + if (taskFormSchema.fields.find(f => f.name === key) === undefined) { delete resetTriggerConfig[key]; } @@ -221,59 +276,6 @@ const formStateReducer = (state: FormState, action: FormStateAction): FormState triggerConfigValid: validateValues(formSchema, triggerConfig), }; } - case ActionType.UPDATE_CUSTOM_CONFIG: { - const customConfig = { ...state.customConfig, [action.field]: action.value }; - const formSchema = state.customFieldFormSchemas[state.details.pipelineId]; - return { - ...state, - isDirty: true, - customConfig, - customConfigValid: validateValues(formSchema, customConfig), - }; - } - case ActionType.ADD_CUSTOM_PARAM: { - const { customParameters } = state; - const integerIds = Object.keys(customParameters).map(k => parseInt(k, 10)); - const id = integerIds.length > 0 ? Math.max(...integerIds) + 1 : 0; - return { - ...state, - customParameters: { - ...customParameters, - [id]: { key: '', value: '', id }, - }, - isDirty: true, - }; - } - case ActionType.REMOVE_CUSTOM_PARAM: { - const { id } = action; - const customParameters = { ...state.customParameters }; - delete customParameters[id]; - return { ...state, customParameters, isDirty: true }; - } - case ActionType.UPDATE_CUSTOM_PARAM: { - const { customParameters } = state; - const { id, key, value } = action; - const keyIsSame = customParameters[id].key === key; - const valueIsSame = customParameters[id].value === value; - - if (keyIsSame && valueIsSame) { - // If nothing changed, no-op. - return state; - } - - return { - ...state, - customParameters: { - ...customParameters, - [id]: { key, value, id }, - }, - isDirty: true, - }; - } - case ActionType.SET_SAVING: { - const { saving, saveError, saveSuccessful } = action; - return { ...state, saving, saveError, saveSuccessful }; - } default: { // Throw error for unhandled actions. // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -425,10 +427,10 @@ const DetailsForm: FC = ({ dispatch, formState, onNext, return
- - + cancel
@@ -476,7 +478,7 @@ const CustomParameter: FC = ({ customParameter, remove, up
-
@@ -507,13 +509,14 @@ const CustomParameters: FC = ({ customParameters, dispatc {Object.keys(customParameters) .sort(naturalSort) .map(id => ( - + ))} ); }; interface ConfigurationFormProps { + canEditParameterFunction: boolean; dispatch: Dispatch; formState: FormState; onBack: () => void; @@ -522,7 +525,7 @@ interface ConfigurationFormProps { } const ConfigurationForm: FC = props => { - const { formState, dispatch, onBack, onSubmit, returnUrl } = props; + const { canEditParameterFunction, formState, dispatch, onBack, onSubmit, returnUrl } = props; const { customConfig, customConfigValid, @@ -569,19 +572,21 @@ const ConfigurationForm: FC = props => { {showAdvanced && (
-
- -
-