chmonitor

Contributing a config / check

How to add a declarative monitoring check to the chmonitor catalog.

A declarative query config is a monitoring check expressed as data — a plain TypeScript object that the dashboard validates against a Zod schema and renders as a table or card view. No JSX, no functions, no component imports. This page walks through the contract, the safety rules, and the step-by-step flow for adding one.

Current status: opt-in

The declarative catalog is opt-in. Set CHM_CONFIG_SOURCE=declarative to route runtime lookups to it. The default (CHM_CONFIG_SOURCE=ts) still uses the TypeScript query configs. A drop-in community catalog directory (where contributions live outside the repo) is planned but not yet built. For now, contributing a check means adding a file to the repo's catalog directory and opening a PR.

The contract

Every declarative config must satisfy the schema in:

apps/dashboard/src/lib/query-config/declarative/schema.ts

Required fields:

FieldTypeNotes
namestringUnique across the catalog. Used as the lookup key.
sqlstring | VersionedSql[]A plain SQL string, or an array of { since, sql } entries for version-aware queries.
columnsstring[]Column names returned by the query, in display order. Must have at least one entry.

Commonly used optional fields:

FieldTypeNotes
descriptionstringShort human-readable description shown in the dashboard.
columnFormatsRecord<string, ColumnFormat>Display format per column. Allowed values: badge, duration, number, number-short, background-bar, colored-badge, boolean, code, text, none, and others — see schema for the full list.
columnDescriptionsRecord<string, string>Tooltip text per column.
defaultParamsRecord<string, string | number | boolean>Default values for {param: Type} placeholders in the SQL.
optionalbooleanWhen true, the dashboard skips gracefully if the target table does not exist (default: false).
tableCheckstring | string[]Table(s) to check for existence before running the query. Useful with optional: true.
refreshIntervalnumberAuto-refresh interval in milliseconds.
relatedCharts(string | [string, Record])[]Chart names (or [name, params] tuples) to render above the table.
card{ primary?, badges?, metrics?, hidden? }Card view layout hints.
defaultView'table' | 'cards' | 'auto'Default display mode.
docsstringHelp text shown when the table is missing (a sentence with links — not required to be a bare URL).
suggestionstringA short suggestion/tip rendered with the view.
columnSizingRecord<string, { size?, minSize?, maxSize? }>Per-column width hints (pixels).
tableBehavior{ enableColumnResizing?, enableSorting?, … }Table interaction toggles.
sortingFnsRecord<string, SortingFn>Named sort function per column. Allowed: sort_column_using_pct, sort_column_using_pct_inverted, sort_column_using_actual_value.
clickhouseSettingsRecord<string, string | number | boolean>Execution-time ClickHouse settings applied per query (e.g. { allow_introspection_functions: 1 }).
rowStyle{ rules: [{ when, className }], default? }Conditional row CSS classes — the declarative replacement for rowClassName. See Conditional row styling.
permission{ feature, defaultAccess?, operation? }Feature-permission gate. See Feature permissions.
bulkActions / bulkActionKeystring[] / stringBulk row actions and the row key they operate on.

Fields intentionally absent from the declarative format (runtime-only, cannot be serialized): expandable, columnIcons, filterSchema. If your check needs any of these, it must stay as a TypeScript config for now.

Two former exclusions are now data

rowClassName is now expressible via rowStyle rules, and permission via the permission field.

Safety rule: SELECT only

All checks must be read-only. The schema validator and the SQL validator in apps/dashboard/src/lib/api/shared/validators/sql.ts reject DDL and DML (CREATE, INSERT, DROP, ALTER, TRUNCATE, etc.). Do not set disableSqlValidation: true on contributed checks.

Worked example

A minimal check that surfaces the last 100 slow queries. Based on the real query-count-baseline pattern in the anomaly catalog:

import type { DeclarativeQueryConfig } from '../../schema'

export const slowQueriesRecentDeclarative: DeclarativeQueryConfig = {
  name: 'slow-queries-recent',
  description: 'Queries that took longer than 5 seconds in the last hour',
  sql: [
    {
      since: '23.8',
      sql: `
        SELECT
          query_id,
          user,
          query_duration_ms,
          formatReadableTimeDelta(query_duration_ms / 1000) AS readable_duration,
          round(memory_usage / 1048576, 1) AS memory_mb,
          left(query, 200) AS query_snippet,
          event_time
        FROM system.query_log
        WHERE type = 'QueryFinish'
          AND query_duration_ms > 5000
          AND event_time >= now() - INTERVAL 1 HOUR
        ORDER BY query_duration_ms DESC
        LIMIT 100
      `,
    },
  ],
  columns: [
    'query_id',
    'user',
    'query_duration_ms',
    'readable_duration',
    'memory_mb',
    'query_snippet',
    'event_time',
  ],
  columnFormats: {
    query_duration_ms: 'number',
    memory_mb: 'number',
    query_snippet: 'code',
  },
  columnDescriptions: {
    query_duration_ms: 'Wall-clock duration in milliseconds',
    memory_mb: 'Peak memory usage in MiB',
  },
  defaultParams: {},
  optional: false,
}

The field names above (name, sql[].since, sql[].sql, columns, columnFormats, columnDescriptions, optional) map directly to declarativeQueryConfigSchema in schema.ts.

Step-by-step: adding a check

Pick the right domain directory

Existing domains under apps/dashboard/src/lib/query-config/declarative/catalog/: anomaly, explorer, keeper, logs, merges, more, queries, security, system, tables. Add a new file to the closest domain, or create a new subdirectory if the check doesn't fit any existing one.

Write the config

Export a const typed as DeclarativeQueryConfig. Use sql: [{ since: 'X.Y', sql: '...' }] when querying a system column that was added in a specific ClickHouse version (check docs/clickhouse-schemas/tables/ for column availability). Use a plain sql: '...' string when the query works across all supported versions.

Register it in the catalog index

Import your export in apps/dashboard/src/lib/query-config/declarative/catalog/index.ts and add it to the ALL_DECLARATIVE array. The index enforces unique names at module load time and throws if a duplicate is detected.

Validate locally

bun run test:query-config

# Biome lint (must pass before PR)
bun run check

The test:query-config suite runs the schema validator against every catalog entry and compares serializable fields against the legacy TS configs for migrated checks.

Note the opt-in flag

Your check is live in the catalog but the dashboard only reads from it when CHM_CONFIG_SOURCE=declarative is set in the environment. The default is ts. Document this in your PR if relevant.

Open a PR

Open a PR against main. The CI unit-tests job runs the full bun test suite including the declarative catalog tests.

Version-aware SQL

When a system table column was introduced in a specific ClickHouse release, use the sql array instead of a plain string. The loader picks the entry whose since version is the highest that is still ≤ the connected cluster's version.

sql: [
  {
    since: '23.8',
    sql: `SELECT col1 FROM system.query_log LIMIT 10`,
  },
  {
    since: '24.1',
    sql: `SELECT col1, new_col FROM system.query_log LIMIT 10`,
    description: 'Added new_col (available from 24.1)',
  },
],

Version strings must match X.Y or X.Y.Z (e.g. "23.8", "24.1", "25.6.0"). The description field on each entry is optional but encouraged.

Optional tables

If your check queries a table that may not exist (e.g. system.backup_log, system.zookeeper, system.error_log), set optional: true and provide a tableCheck:

optional: true,
tableCheck: 'system.backup_log',

The dashboard will surface a graceful "table not available" message instead of an error.

Conditional row styling

To highlight rows based on their data (e.g. errored or stuck rows), use rowStyle instead of a rowClassName function. It is an ordered list of { when, className } rules; the first matching rule's className wins, and default (or nothing) applies when no rule matches.

rowStyle: {
  rules: [
    // first match wins — put the most severe condition first
    { when: { column: 'is_stuck', op: 'truthy' }, className: 'bg-red-50 dark:bg-red-950/20' },
    {
      when: {
        all: [
          { column: 'is_done', op: 'falsy' },
          { column: 'elapsed', op: 'gt', value: 300 },
        ],
      },
      className: 'bg-amber-50 dark:bg-amber-950/20',
    },
  ],
  default: '',
},

Condition operators:

OperatorMeaningCoercion
gt / gte / lt / ltenumeric comparison against valuecell coerced with Number()
truthy / falsynumeric truthiness (non-zero / zero)cell coerced with Number()
nonempty / emptystring is non-empty / emptycell coerced with String()
all / anyAND / OR of nested conditions

Comparison operators (gt/gte/lt/lte) require a numeric value. className is any Tailwind class string.

Feature permissions

To gate a view behind a feature, set permission. This is plain data (no import needed) mirroring the app's FeaturePermission:

permission: { feature: 'queries' },
// or, with explicit access/operation:
permission: { feature: 'metrics', defaultAccess: 'authenticated', operation: 'read' },

feature must be one of the known feature ids (overview, agent, insights, health, queries, tables, metrics, dashboard, security, logs, settings, cluster, operations, actions, mcp, peerdb, docs, about). defaultAccess is public | authenticated; operation is read | write. Both are optional.

On this page