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.tsRequired fields:
| Field | Type | Notes |
|---|---|---|
name | string | Unique across the catalog. Used as the lookup key. |
sql | string | VersionedSql[] | A plain SQL string, or an array of { since, sql } entries for version-aware queries. |
columns | string[] | Column names returned by the query, in display order. Must have at least one entry. |
Commonly used optional fields:
| Field | Type | Notes |
|---|---|---|
description | string | Short human-readable description shown in the dashboard. |
columnFormats | Record<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. |
columnDescriptions | Record<string, string> | Tooltip text per column. |
defaultParams | Record<string, string | number | boolean> | Default values for {param: Type} placeholders in the SQL. |
optional | boolean | When true, the dashboard skips gracefully if the target table does not exist (default: false). |
tableCheck | string | string[] | Table(s) to check for existence before running the query. Useful with optional: true. |
refreshInterval | number | Auto-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. |
docs | string | Help text shown when the table is missing (a sentence with links — not required to be a bare URL). |
suggestion | string | A short suggestion/tip rendered with the view. |
columnSizing | Record<string, { size?, minSize?, maxSize? }> | Per-column width hints (pixels). |
tableBehavior | { enableColumnResizing?, enableSorting?, … } | Table interaction toggles. |
sortingFns | Record<string, SortingFn> | Named sort function per column. Allowed: sort_column_using_pct, sort_column_using_pct_inverted, sort_column_using_actual_value. |
clickhouseSettings | Record<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 / bulkActionKey | string[] / string | Bulk 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 checkThe 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:
| Operator | Meaning | Coercion |
|---|---|---|
gt / gte / lt / lte | numeric comparison against value | cell coerced with Number() |
truthy / falsy | numeric truthiness (non-zero / zero) | cell coerced with Number() |
nonempty / empty | string is non-empty / empty | cell coerced with String() |
all / any | AND / 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.