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
pnpm run test:query-config
# Biome lint (must pass before PR)
pnpm 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, billing, security, logs, settings, cluster, operations, actions, mcp, peerdb, docs, about). defaultAccess is public | authenticated; operation is read | write. Both are optional.
Local overrides (self-hosted only)
Everything above describes contributing a check to the repo's own catalog via
a PR. There's a second, lower-friction path for a self-hosted operator who
wants a custom check on their own instance without forking the repo: mount a
directory of *.yaml files and point CHM_CONFIG_DIRECTORY at it (default
/etc/chmonitor/queries.d).
At startup, every *.yaml file directly inside that directory is validated
against the same schema described above. A file that fails validation (bad
YAML syntax, a schema violation, or a name that collides with another file
in the directory) is skipped with a warning in the logs — it never blocks
boot. This is checked before CHM_CONFIG_SOURCE is even consulted, so a
local file with the same name as a built-in ts or declarative check
overrides it regardless of that setting; a name that doesn't match any
built-in check adds a new one.
# /etc/chmonitor/queries.d/my-check.yaml
name: my-custom-check
description: Something specific to my cluster
sql: "SELECT hostName(), uptime()"
columns: [hostname, uptime_seconds]Requires a real filesystem, so it works on Docker and Kubernetes but not the
Cloudflare Worker deploy. docker-compose.yml ships a ready-made, commented-out
example:
volumes:
- ./queries.d:/etc/chmonitor/queries.d:roThe directory is scanned once per process — restart the container/pod to pick up added, changed, or removed files.