
Kibana Vega
- 2.3k installs
- 546 repo stars
- Updated July 22, 2026
- elastic/agent-skills
kibana-vega is an agent skill for
About
The kibana-vega skill documents agent workflows from the repository SKILL.md. It covers h: 10 - Compact bar charts ≤7 items , fits above fold. Key workflows include h: 12-13 - Standard charts, timelines. Vega is a declarative visualization grammar for creating custom charts in Kibana. Combined with ES|QL queries, it enables highly customized visualizations beyond standard Kibana charts. Important Version Requirement: This skill strictly supports ES|QL data sources and requires Serverless Kibana or version 9.4+ SNAPSHOT . It will not work reliably on older versions or with older Lucene/KQL data sou Developers invoke kibana-vega when the task matches the triggers and reference files in SKILL.md for grounded, stepwise execution. Reference files and progressive disclosure keep context focused while preserving concrete commands, configuration fields, and validation checks copied from the upstream documentation.
- h: 10 - Compact bar charts ≤7 items , fits above fold
- h: 12-13 - Standard charts, timelines
- h: 15+ - Detailed views, use below fold
- Above the fold : Primary info in top ~22 height units 1080p
- Compact heights : Use h:10 for bar charts with ≤7 items
Kibana Vega by the numbers
- 2,261 all-time installs (skills.sh)
- +166 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #359 of 2,153 Testing & QA skills by installs in the Skillselion catalog
- Security screen: HIGH risk (skills.sh audit)
- Data as of Aug 4, 2026 (Skillselion catalog sync)
kibana-vega capabilities & compatibility
- Capabilities
- h: 10 compact bar charts ≤7 items , fits above · h: 12 13 standard charts, timelines · h: 15+ detailed views, use below fold · above the fold : primary info in top ~22 height · compact heights : use h:10 for bar charts with ≤
- Use cases
- documentation
What kibana-vega says it does
Create Vega and Vega-Lite visualizations with ES|QL data sources in Kibana. Use
when building custom charts, dashboards, or programmatic panel layouts beyond standard
npx skills add https://github.com/elastic/agent-skills --skill kibana-vegaAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 2.3k |
|---|---|
| repo stars | ★ 546 |
| Security audit | 2 / 3 scanners passed |
| Last updated | July 22, 2026 |
| Repository | elastic/agent-skills ↗ |
What problem does kibana-vega solve for developers using the documented workflows?
The kibana-vega skill documents agent workflows from the repository SKILL.md. It covers h: 10 - Compact bar charts ≤7 items , fits above fold. Key workflows include h: 12-13 - Standard charts, ti
Who is it for?
Developers working with kibana-vega patterns described in the skill documentation.
Skip if: Skip when docs are empty or the task is outside the skill documented scope.
When should I use this skill?
Use when
What you get
Actionable kibana-vega guidance grounded in SKILL.md workflows and reference files.
- vega visualization spec
- vega-lite panel config
- esql-backed dashboard layout
By the numbers
- Skill metadata version 0.1.0 in elastic/agent-skills manifest
Files
Kibana Vega
Create and manage Kibana dashboards and Vega visualizations with ES|QL data sources.
Overview
Vega is a declarative visualization grammar for creating custom charts in Kibana. Combined with ES|QL queries, it enables highly customized visualizations beyond standard Kibana charts.
Important Version Requirement: This skill strictly supports ES|QL data sources and requires Serverless Kibana or version 9.4+ (SNAPSHOT). It will not work reliably on older versions or with older Lucene/KQL data source definitions.
Quick Start
Environment Configuration
Kibana connection is configured via environment variables. Run node scripts/kibana-vega.js test to verify the connection. If the test fails, suggest these setup options to the user, then stop. Do not try to explore further until a successful connection test.
Option 1: Elastic Cloud (recommended for production)
export KIBANA_CLOUD_ID="deployment-name:base64encodedcloudid"
export KIBANA_API_KEY="base64encodedapikey"Option 2: Direct URL with API Key
export KIBANA_URL="https://your-kibana:5601"
export KIBANA_API_KEY="base64encodedapikey"Option 3: Basic Authentication
export KIBANA_URL="https://your-kibana:5601"
export KIBANA_USERNAME="elastic"
export KIBANA_PASSWORD="changeme"Option 4: Local Development with start-local
For local development and testing, use start-local to quickly spin up Elasticsearch and Kibana using Docker or Podman:
curl -fsSL https://elastic.co/start-local | shAfter installation completes, Elasticsearch runs at http://localhost:9200 and Kibana at http://localhost:5601. The script generates a random password for the elastic user, stored in the .env file inside the created elastic-start-local folder.
To configure the environment variables for this skill, source the .env file and export the connection settings:
source elastic-start-local/.env
export KIBANA_URL="$KB_LOCAL_URL"
export KIBANA_USERNAME="elastic"
export KIBANA_PASSWORD="$ES_LOCAL_PASSWORD"Then run node scripts/kibana-vega.js test to verify the connection.
Optional: Skip TLS verification (development only)
export KIBANA_INSECURE="true"Basic Workflow
# Test connection
node scripts/kibana-vega.js test
# Create visualization directly from stdin (no intermediate file needed)
echo '<json-spec>' | node scripts/kibana-vega.js visualizations create "My Chart" -
# Get visualization spec for review/modification
node scripts/kibana-vega.js visualizations get <vis-id>
# Update visualization from stdin
echo '<json-spec>' | node scripts/kibana-vega.js visualizations update <vis-id> -
# Create dashboard
node scripts/kibana-vega.js dashboards create "My Dashboard"
# Add visualization with grid position
node scripts/kibana-vega.js dashboards add-panel <dashboard-id> <vis-id> --x 0 --y 0 --w 24 --h 15
# Apply a complete layout from stdin
echo '<layout-json>' | node scripts/kibana-vega.js dashboards apply-layout <dashboard-id> -Note: Use - as the file argument to read JSON from stdin. This enables direct spec creation without intermediate files.
Minimal Vega Spec with ES|QL
IMPORTANT: Always use proper JSON format (not HJSON with triple quotes) to avoid parse errors.
{
"$schema": "https://vega.github.io/schema/vega-lite/v6.json",
"title": "My Chart",
"autosize": { "type": "fit", "contains": "padding" },
"config": {
"axis": { "domainColor": "#444", "tickColor": "#444" },
"view": { "stroke": null }
},
"data": {
"url": {
"%type%": "esql",
"query": "FROM logs-* | STATS count = COUNT() BY status | RENAME status AS category"
}
},
"mark": { "type": "bar", "color": "#6092C0" },
"encoding": {
"x": { "field": "category", "type": "nominal" },
"y": { "field": "count", "type": "quantitative" }
}
}ES|QL Data Source Options
| Property | Description | | --------------------------- | ------------------------------------------ | --------- | | %type%: "esql" | Required. Use ES | QL parser | | %context%: true | Apply dashboard filters | | %timefield%: "@timestamp" | Enable time range with ?_tstart/?_tend |
Examples
Stdin Examples
# Create visualization directly from JSON
echo '{"$schema":"https://vega.github.io/schema/vega-lite/v6.json",...}' | \
node scripts/kibana-vega.js visualizations create "My Chart" -
# Update visualization
echo '{"$schema":...}' | node scripts/kibana-vega.js visualizations update <id> -
# Apply layout directly
echo '{"panels":[{"visualization":"<id>","x":0,"y":0,"w":24,"h":10}]}' | \
node scripts/kibana-vega.js dashboards apply-layout <dash-id> -Dashboard Layout Design
Grid System
Kibana dashboards use a 48-column grid:
| Width | Columns | Use Case |
|---|---|---|
| Full | 48 | Timelines, heatmaps, wide charts |
| Half | 24 | Side-by-side comparisons |
| Third | 16 | Three-column layouts |
| Quarter | 12 | KPI metrics, small summaries |
Above the Fold (Critical)
Primary information must be visible without scrolling.
| Resolution | Visible Height | Layout Budget |
|---|---|---|
| 1080p | ~30 units | 2 rows: h:10 + h:12 |
| 1440p | ~40 units | 3 rows: h:12 + h:12 + h:12 |
Height guidelines:
h: 10— Compact bar charts (≤7 items), fits above foldh: 12-13— Standard charts, timelinesh: 15+— Detailed views, use below fold
Layout Pattern: Operational Dashboard
┌───────────────────────┬───────────────────────┐ y:0
│ Current State A │ Current State B │ h:10 (compact)
├───────────────────────┴───────────────────────┤ y:10
│ Primary Timeline │ h:12 (main trend)
├ ─ ─ ─ ─ ─ ─ ─ FOLD ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┤ y:22 (1080p fold)
│ Secondary Timeline │ h:12 (below fold OK)
├───────────────────────┬───────────────────────┤ y:34
│ Complementary 1 │ Complementary 2 │ h:10
└───────────────────────┴───────────────────────┘Creating Layouts
Option 1: Add panels with positions
# Row 1: Two compact half-width charts (above fold)
node scripts/kibana-vega.js dashboards add-panel $DASH $VIS1 --x 0 --y 0 --w 24 --h 10
node scripts/kibana-vega.js dashboards add-panel $DASH $VIS2 --x 24 --y 0 --w 24 --h 10
# Row 2: Full-width timeline (above fold)
node scripts/kibana-vega.js dashboards add-panel $DASH $VIS3 --x 0 --y 10 --w 48 --h 12
# Row 3: Below fold content
node scripts/kibana-vega.js dashboards add-panel $DASH $VIS4 --x 0 --y 22 --w 48 --h 12Option 2: Apply layout file
Create layout.json:
{
"title": "My Dashboard",
"panels": [
{ "visualization": "<vis-id-1>", "x": 0, "y": 0, "w": 24, "h": 10 },
{ "visualization": "<vis-id-2>", "x": 24, "y": 0, "w": 24, "h": 10 },
{ "visualization": "<vis-id-3>", "x": 0, "y": 10, "w": 48, "h": 12 },
{ "visualization": "<vis-id-4>", "x": 0, "y": 22, "w": 48, "h": 12 }
]
}Apply it:
node scripts/kibana-vega.js dashboards apply-layout <dashboard-id> layout.jsonDesign Checklist
1. Above the fold: Primary info in top ~22 height units (1080p) 2. Compact heights: Use h:10 for bar charts with ≤7 items 3. Prioritize: Most important info top-left 4. Group: Related charts side-by-side for comparison 5. Timelines: Full width (w:48), h:12 for compact 6. Below fold: Complementary/detailed panels OK to scroll
Guidelines
1. Use JSON, not HJSON triple-quotes — ''' multi-line strings cause parse errors in Kibana; use single-line queries with escaped quotes \" 2. Rename dotted fields — room.name breaks Vega (interpreted as nested path); use ES|QL RENAME room.name AS room 3. Don't set width/height — use autosize: { type: fit, contains: padding } 4. Set labelLimit on axes — horizontal bar chart labels truncate; use axis: { "labelLimit": 150 } 5. Sort bars by value — pre-sort in ES|QL with SORT field DESC and use sort: null in encoding (preserves data order); avoid sort: "-x" in layered specs (bar + text labels) as it causes "conflicting sort properties" warnings 6. Time axis: no rotated labels — use axis: { "labelAngle": 0, "tickCount": 8 }, let Vega auto-format dates 7. Descriptive titles replace axis titles — good title/subtitle makes axis titles redundant; use title: null on axes 8. Use color sparingly — color is a precious visual attribute; use a single default color (#6092C0) for bar charts where position already encodes value; reserve color encoding for categorical distinction (e.g., multiple lines in a time series) 9. Dark theme compatibility — always include config to avoid bright white borders:
"config": {
"axis": { "domainColor": "#444", "tickColor": "#444" },
"view": { "stroke": null }
}CLI Commands
# Dashboards
node scripts/kibana-vega.js dashboards list [search]
node scripts/kibana-vega.js dashboards get <id>
node scripts/kibana-vega.js dashboards create <title>
node scripts/kibana-vega.js dashboards delete <id>
node scripts/kibana-vega.js dashboards add-panel <dash-id> <vis-id> [--x N] [--y N] [--w N] [--h N]
node scripts/kibana-vega.js dashboards apply-layout <dash-id> <file|->
# Visualizations (use - for stdin instead of file)
node scripts/kibana-vega.js visualizations list [vega]
node scripts/kibana-vega.js visualizations get <id>
node scripts/kibana-vega.js visualizations create <title> <file|->
node scripts/kibana-vega.js visualizations update <id> <file|->
node scripts/kibana-vega.js visualizations delete <id>Full Documentation
- Dashboard Layout Reference — Grid system, layout patterns, design best
practices
- Vega-Lite Reference — Complete Vega-Lite grammar, chart patterns, best practices
- ES|QL in Vega Reference — ES|QL data source configuration, time filtering,
parameters
- Example Specs — Ready-to-use chart templates
Common Issues
| Error | Solution |
|---|---|
| "End of input while parsing an object" | Don't use HJSON ''' triple-quotes; use JSON with single-line queries |
| Labels show "undefined" | Rename dotted fields: RENAME room.name AS room |
| Bars invisible / not rendering | Remove complex scale.domain, use simpler color schemes |
| Y-axis labels truncated | Add axis: { "labelLimit": 150 } to encoding |
| Panels stacked vertically | Use --x --y --w --h options or apply-layout command |
| "width/height ignored" | Remove dimensions, use autosize |
| Bright white borders on dark theme | Add config: { "view": { "stroke": null }, "axis": { "domainColor": "#444", "tickColor": "#444" } } |
| "401 Unauthorized" | Check KIBANA_USERNAME/PASSWORD |
| "conflicting sort properties" | Don't use sort: "-x" in layered specs; pre-sort in ES\ |
| "404 Not Found" | Verify dashboard/visualization ID |
{
// Heatmap showing activity by hour and day of week
$schema: https://vega.github.io/schema/vega-lite/v6.json
title: Activity Heatmap (Hour vs Day)
data: {
url: {
"%type%": "esql"
"%context%": true
"%timefield%": "@timestamp"
query: '''
FROM logs-*
| WHERE @timestamp >= ?_tstart AND @timestamp <= ?_tend
| EVAL hour = DATE_EXTRACT("HOUR_OF_DAY", @timestamp)
| EVAL day = DATE_EXTRACT("DAY_OF_WEEK", @timestamp)
| EVAL day_name = CASE(
day == 1, "Mon",
day == 2, "Tue",
day == 3, "Wed",
day == 4, "Thu",
day == 5, "Fri",
day == 6, "Sat",
day == 7, "Sun",
"Unknown"
)
| STATS count = COUNT() BY hour, day_name
'''
}
}
mark: rect
encoding: {
x: {
field: hour
type: ordinal
axis: {
title: "Hour of Day"
labelAngle: 0
}
}
y: {
field: day_name
type: ordinal
sort: ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
axis: { title: "Day of Week" }
}
color: {
field: count
type: quantitative
scale: { scheme: "blues" }
legend: { title: "Events" }
}
tooltip: [
{ field: day_name, type: nominal, title: "Day" }
{ field: hour, type: quantitative, title: "Hour" }
{ field: count, type: quantitative, title: "Events" }
]
}
config: {
axis: { grid: true, tickBand: "extent" }
}
}
{
// Time series line chart with ES|QL
// Shows request count over time with automatic time filtering
$schema: https://vega.github.io/schema/vega-lite/v6.json
title: Request Rate Over Time
autosize: { type: fit, contains: padding }
data: {
url: {
"%type%": "esql"
"%context%": true
"%timefield%": "@timestamp"
query: '''
FROM logs-*
| WHERE @timestamp >= ?_tstart AND @timestamp <= ?_tend
| STATS requests = COUNT() BY time_bucket = DATE_TRUNC(5 minutes, @timestamp)
| SORT time_bucket ASC
'''
}
}
mark: {
type: line
point: false
tooltip: true
strokeWidth: 2
}
encoding: {
x: {
field: time_bucket
type: temporal
title: null
axis: { labelAngle: 0, tickCount: 8 }
}
y: {
field: requests
type: quantitative
title: "Requests"
}
}
}
{
// Horizontal bar chart showing top N items
// Useful for showing top errors, services, hosts, etc.
$schema: https://vega.github.io/schema/vega-lite/v6.json
title: Top 10 Services by Error Count
data: {
url: {
"%type%": "esql"
"%context%": true
"%timefield%": "@timestamp"
query: '''
FROM logs-*
| WHERE @timestamp >= ?_tstart AND @timestamp <= ?_tend
| WHERE log.level == "error"
| STATS error_count = COUNT() BY service.name
| SORT error_count DESC
| LIMIT 10
'''
}
}
mark: bar
encoding: {
y: {
field: service.name
type: nominal
sort: "-x"
axis: { title: "Service" }
}
x: {
field: error_count
type: quantitative
axis: { title: "Error Count" }
}
color: {
field: error_count
type: quantitative
scale: { scheme: "reds" }
legend: null
}
tooltip: [
{ field: service.name, type: nominal, title: "Service" }
{ field: error_count, type: quantitative, title: "Errors" }
]
}
}
{
"name": "kibana-vega",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "kibana-vega",
"version": "1.0.0",
"license": "tbd",
"dependencies": {
"hjson": "^3.2.2"
}
},
"node_modules/hjson": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/hjson/-/hjson-3.2.2.tgz",
"integrity": "sha512-MkUeB0cTIlppeSsndgESkfFD21T2nXPRaBStLtf3cAYA2bVEFdXlodZB0TukwZiobPD1Ksax5DK4RTZeaXCI3Q==",
"license": "MIT",
"bin": {
"hjson": "bin/hjson"
}
}
}
}
{
"name": "kibana-vega",
"version": "1.0.0",
"type": "module",
"description": "CRUD operations for Kibana dashboards and Vega visualizations",
"author": "elastic",
"license": "tbd",
"dependencies": {
"hjson": "^3.2.2"
}
}
Dashboard Layout Reference
This guide provides best practices for designing effective Kibana dashboard layouts.
Kibana Grid System
Kibana dashboards use a 48-column grid system:
- Full width:
w: 48 - Half width:
w: 24 - Third width:
w: 16 - Quarter width:
w: 12
Height guidelines (grid units):
- Compact:
h: 8-10— KPIs, small bar charts (≤7 items) - Standard:
h: 12-13— Most charts, bar charts with labels - Tall:
h: 15-18— Complex charts, detailed timelines
Above the Fold Design
Critical principle: The most important information must be visible without scrolling.
A typical screen at 1080p shows approximately h: 28-32 grid units above the fold (accounting for Kibana header, filters bar, and panel margins). At 1440p, this extends to h: 38-42.
Above the Fold Budget
| Resolution | Visible Height | Recommended Layout |
|---|---|---|
| 1080p | ~30 units | 2 rows: h:12 + h:12, or h:10 + h:10 + h:8 |
| 1440p | ~40 units | 3 rows: h:12 + h:12 + h:12 |
| 4K | ~60 units | 4+ rows comfortably |
Design Strategy
1. Primary panels above the fold: Current state, key metrics, main trend 2. Complementary panels below: Detailed breakdowns, secondary trends, historical data 3. Compact heights for bar charts: Use h: 10-12 instead of h: 15 for charts with ≤10 items
Dashboard Design Principles
1. Information Hierarchy
Place the most important information at the top-left where users look first:
┌─────────────────────────────────────────────────────┐
│ KPIs / Summary Metrics (top row) │
├────────────────────────┬────────────────────────────┤
│ Primary Chart │ Secondary Chart │
│ (main insight) │ (supporting data) │
├────────────────────────┴────────────────────────────┤
│ Timeline / Trend Chart (full width) │
├────────────────────────┬────────────────────────────┤
│ Detail Chart 1 │ Detail Chart 2 │
└────────────────────────┴────────────────────────────┘2. Chart Type Placement Guidelines
| Chart Type | Recommended Width | Recommended Height | Placement |
|---|---|---|---|
| KPI/Metric | 12 (quarter) | 8-10 | Top row |
| Bar Chart (horizontal) | 24 (half) | 15-20 | Side by side |
| Bar Chart (vertical) | 24-48 | 15-20 | Flexible |
| Line/Area (timeline) | 48 (full) | 15-20 | Own row |
| Pie/Donut | 16-24 | 15-18 | Grouped with related |
| Heatmap | 48 (full) | 20-25 | Own row |
| Table | 24-48 | 15-25 | Bottom section |
3. Common Layout Patterns
Operational Dashboard (Monitoring)
Best for: System health, real-time monitoring, alerts
┌──────────┬──────────┬──────────┬──────────┐
│ KPI 1 │ KPI 2 │ KPI 3 │ KPI 4 │ <- Status at a glance
├──────────┴──────────┴──────────┴──────────┤
│ Main Timeline (trends) │ <- Primary metric over time
├───────────────────────┬───────────────────┤
│ Breakdown Chart 1 │ Breakdown Chart 2│ <- Drill-down by dimension
├───────────────────────┴───────────────────┤
│ Secondary Timeline │ <- Supporting trends
└───────────────────────────────────────────┘Grid coordinates:
- KPIs:
{x:0, y:0, w:12, h:8},{x:12, y:0, w:12, h:8},{x:24, y:0, w:12, h:8},{x:36, y:0, w:12, h:8} - Main Timeline:
{x:0, y:8, w:48, h:15} - Breakdown 1:
{x:0, y:23, w:24, h:15} - Breakdown 2:
{x:24, y:23, w:24, h:15} - Secondary Timeline:
{x:0, y:38, w:48, h:15}
Analytical Dashboard (Exploration)
Best for: Data analysis, comparisons, deep dives
┌───────────────────────┬───────────────────┐
│ │ Filter/Summary │
│ Primary Analysis ├───────────────────┤
│ (large chart) │ Top-N List │
├───────────────────────┴───────────────────┤
│ Comparison Chart │
├───────────────────────┬───────────────────┤
│ Dimension A │ Dimension B │
└───────────────────────────────────────────┘Executive Dashboard (Overview)
Best for: High-level summaries, stakeholder reports
┌──────────┬──────────┬──────────┬──────────┐
│ KPI 1 │ KPI 2 │ KPI 3 │ KPI 4 │
├──────────┴──────────┼──────────┴──────────┤
│ Trend Chart 1 │ Trend Chart 2 │
├─────────────────────┴─────────────────────┤
│ Distribution / Breakdown │
└───────────────────────────────────────────┘Smart Home Dashboard Example
For IoT/smart home data with temperature, humidity, and device activity:
Compact Layout (Above the Fold)
Optimized for 1080p screens — all primary info visible without scrolling:
┌───────────────────────┬───────────────────────┐ y:0
│ Avg Temp by Room │ Avg Humidity by Room │ h:10 (compact bars)
│ (7 rooms) │ (6 rooms) │
├───────────────────────┴───────────────────────┤ y:10
│ Temperature Timeline │ h:12 (primary trend)
├───────────────────────┴───────────────────────┤ y:22
│ Humidity Timeline │ h:12 (below fold on 1080p)
├───────────────────────┬───────────────────────┤ y:34
│ Device Activity │ (future: alerts) │ h:10 (complementary)
└───────────────────────┴───────────────────────┘Above fold (y < 22-24): Bar charts + temperature timeline Below fold: Humidity timeline + device activity
Layout specification:
[
{ "id": "temp-by-room", "x": 0, "y": 0, "w": 24, "h": 10 },
{ "id": "humidity-by-room", "x": 24, "y": 0, "w": 24, "h": 10 },
{ "id": "temp-timeline", "x": 0, "y": 10, "w": 48, "h": 12 },
{ "id": "humidity-timeline", "x": 0, "y": 22, "w": 48, "h": 12 },
{ "id": "device-activity", "x": 0, "y": 34, "w": 24, "h": 10 }
]Alternative: Three-Row Above Fold (1440p+)
For larger screens, fit more content above the fold:
[
{ "id": "temp-by-room", "x": 0, "y": 0, "w": 24, "h": 12 },
{ "id": "humidity-by-room", "x": 24, "y": 0, "w": 24, "h": 12 },
{ "id": "temp-timeline", "x": 0, "y": 12, "w": 48, "h": 13 },
{ "id": "humidity-timeline", "x": 0, "y": 25, "w": 48, "h": 13 },
{ "id": "device-activity", "x": 0, "y": 38, "w": 24, "h": 10 }
]CLI Usage with Layout
Method 1: Individual Panels with Position
# Add panels with explicit grid positions
node scripts/kibana-vega.js dashboards add-panel <dashboard-id> <vis-id> --x 0 --y 0 --w 24 --h 15
node scripts/kibana-vega.js dashboards add-panel <dashboard-id> <vis-id> --x 24 --y 0 --w 24 --h 15Method 2: Layout File
Create a layout file (dashboard-layout.json):
{
"title": "Smart Home Operations",
"panels": [
{ "visualization": "temp-by-room-id", "x": 0, "y": 0, "w": 24, "h": 15 },
{ "visualization": "humidity-by-room-id", "x": 24, "y": 0, "w": 24, "h": 15 },
{ "visualization": "temp-timeline-id", "x": 0, "y": 15, "w": 48, "h": 15 },
{ "visualization": "humidity-timeline-id", "x": 0, "y": 30, "w": 48, "h": 15 },
{ "visualization": "device-activity-id", "x": 0, "y": 45, "w": 24, "h": 15 }
]
}Then apply:
node scripts/kibana-vega.js dashboards apply-layout <dashboard-id> dashboard-layout.jsonDesign Checklist
Before creating a dashboard, answer these questions:
1. Who is the audience? (Operators, analysts, executives) 2. What's the primary question? (Current status, trends, comparisons) 3. What actions should it enable? (Alerting, investigation, reporting)
Then follow this process:
1. Sketch the layout on paper or whiteboard first 2. Identify chart types for each data point 3. Prioritize - most important info top-left 4. Group related charts side-by-side for comparison 5. Use full width for timelines and trends 6. Keep KPIs small - they're glanceable summaries 7. Leave room for growth - dashboards evolve
Panel Size Quick Reference
| Purpose | Width | Height | Grid Config | Notes |
|---|---|---|---|---|
| KPI metric | 12 | 6-8 | w:12, h:7 | Single number display |
| Compact bar chart | 24 | 10 | w:24, h:10 | ≤7 items, above fold |
| Standard bar chart | 24 | 12-13 | w:24, h:12 | 8-12 items |
| Tall bar chart | 24 | 15 | w:24, h:15 | 13+ items or detailed |
| Compact timeline | 48 | 10-12 | w:48, h:11 | Above fold priority |
| Standard timeline | 48 | 13-15 | w:48, h:13 | Detailed view |
| Large visualization | 48 | 18-20 | w:48, h:18 | Heatmaps, detailed |
Above the Fold Cheat Sheet
For 1080p (~30 units visible):
- 2 rows:
h:10 + h:12 = 22✓ fits with margin - 2 rows:
h:12 + h:12 = 24✓ fits tight - 2 rows:
h:15 + h:15 = 30✗ second row cut off
For 1440p (~40 units visible):
- 3 rows:
h:12 + h:12 + h:12 = 36✓ fits comfortably - 3 rows:
h:13 + h:13 + h:13 = 39✓ fits tight
Vega ES|QL Reference
Complete reference for using ES|QL queries in Kibana Vega visualizations.
Overview
Kibana's Vega plugin supports ES|QL as a data source through the %type%: "esql" URL configuration. This allows you to use Vega/Vega-Lite grammar with ES|QL's powerful piped query language.
CRITICAL: Always use proper JSON format for Vega specs. HJSON triple-quoted strings (''') cause parse errors inKibana's Vega plugin. Use single-line queries with escaped quotes instead.
Data URL Configuration
Basic ES|QL Query
{
"data": {
"url": {
"%type%": "esql",
"query": "FROM logs-* | STATS count=COUNT() BY status"
}
}
}Full Configuration Options
| Property | Type | Required | Default | Description | | ----------------- | -------------------------------- | -------- | ------- | ----------------------------------------------------------------- | --------------- | | %type% | "esql" | Yes | - | Specifies the ES | QL query parser | | query | string | Yes | - | The ES | QL query string | | %context% | boolean | No | false | Apply dashboard filters to the query | | %timefield% | string | No | - | Field name for time-based filtering (enables ?_tstart/?_tend) | | dropNullColumns | boolean | No | true | Remove columns with all null values | | params | Array<Record<string, unknown>> | No | [] | Custom named parameters for the query |
Time Range Integration
Enabling Time Filtering
Set %timefield% to enable automatic time parameter injection:
{
data: {
url: {
"%type%": "esql"
"%timefield%": "@timestamp"
query: "FROM logs-* | WHERE @timestamp >= ?_tstart AND @timestamp <= ?_tend | STATS count=COUNT()"
}
}
}Time Parameters
When %timefield% is set and your query contains these parameters, they are automatically populated:
| Parameter | Description |
|---|---|
?_tstart | Start of the time range (from Kibana time picker) |
?_tend | End of the time range (from Kibana time picker) |
Note: Parameters are case-insensitive (?_TSTART works too).
Example: Time Series
{
$schema: https://vega.github.io/schema/vega-lite/v6.json
data: {
url: {
"%type%": "esql"
"%timefield%": "@timestamp"
query: '''
FROM metrics-*
| WHERE @timestamp >= ?_tstart AND @timestamp <= ?_tend
| STATS avg_cpu = AVG(system.cpu.total.pct)
BY bucket = DATE_TRUNC(5 minutes, @timestamp)
| SORT bucket ASC
'''
}
}
mark: line
encoding: {
x: { field: bucket, type: temporal }
y: { field: avg_cpu, type: quantitative }
}
}Dashboard Context (Filters)
Applying Dashboard Filters
Enable %context% to have dashboard-level filters automatically applied:
{
data: {
url: {
"%type%": "esql"
"%context%": true
query: "FROM logs-* | STATS count=COUNT() BY host.name"
}
}
}When a user adds filters in the dashboard (e.g., host.name: "server-01"), those filters are passed to the ES|QL query.
Combining Context and Time
{
data: {
url: {
"%type%": "esql"
"%context%": true
"%timefield%": "@timestamp"
query: '''
FROM logs-*
| WHERE @timestamp >= ?_tstart AND @timestamp <= ?_tend
| STATS error_count = COUNT() BY service.name
| WHERE error_count > 0
| SORT error_count DESC
'''
}
}
}Custom Parameters
Using Named Parameters
Pass custom values to your ES|QL query:
{
data: {
url: {
"%type%": "esql"
query: "FROM logs-* | WHERE level = ?level | STATS count=COUNT()"
params: [{ level: "ERROR" }]
}
}
}Multiple Parameters
{
data: {
url: {
"%type%": "esql"
query: '''
FROM logs-*
| WHERE level = ?level AND service.name = ?service
| STATS count=COUNT()
'''
params: [
{ level: "ERROR" }
{ service: "api-gateway" }
]
}
}
}Combining with Time Parameters
{
data: {
url: {
"%type%": "esql"
"%timefield%": "@timestamp"
query: '''
FROM logs-*
| WHERE @timestamp >= ?_tstart AND level = ?level
| STATS count=COUNT()
'''
params: [{ level: "ERROR" }]
}
}
}Response Transformation
ES|QL returns columnar data which is automatically transformed to row-based format for Vega.
ES|QL Response Format
{
"columns": [
{ "name": "country", "type": "keyword" },
{ "name": "count", "type": "long" }
],
"values": [
["US", 100],
["UK", 50]
]
}Transformed Vega Data
[
{ "country": "US", "count": 100 },
{ "country": "UK", "count": 50 }
]Handling Null Values
By default, dropNullColumns: true removes columns where all values are null. Set to false to preserve them:
{
data: {
url: {
"%type%": "esql"
query: "FROM logs-* | STATS count=COUNT(), errors=SUM(error) BY host.name"
dropNullColumns: false
}
}
}Multiple Data Sources
Named Data Sources
{
$schema: https://vega.github.io/schema/vega-lite/v6.json
data: {
name: "main_data"
url: {
"%type%": "esql"
query: "FROM logs-* | STATS count=COUNT() BY status"
}
}
// Additional data can be defined in layer or other sections
}Layered Charts with Different Queries
{
$schema: https://vega.github.io/schema/vega-lite/v6.json
layer: [
{
data: {
url: {
"%type%": "esql"
"%timefield%": "@timestamp"
query: "FROM logs-* | WHERE @timestamp >= ?_tstart | STATS requests=COUNT() BY bucket=DATE_TRUNC(1h, @timestamp)"
}
}
mark: line
encoding: {
x: { field: bucket, type: temporal }
y: { field: requests, type: quantitative }
}
}
{
data: {
url: {
"%type%": "esql"
"%timefield%": "@timestamp"
query: "FROM logs-* | WHERE @timestamp >= ?_tstart AND level == 'error' | STATS errors=COUNT() BY bucket=DATE_TRUNC(1h, @timestamp)"
}
}
mark: { type: line, color: red }
encoding: {
x: { field: bucket, type: temporal }
y: { field: errors, type: quantitative }
}
}
]
}Kibana Configuration
Config Block
Use config.kibana for Kibana-specific settings:
{
$schema: https://vega.github.io/schema/vega-lite/v6.json
config: {
kibana: {
hideWarnings: true
type: "map" // For map visualizations
renderer: "svg" // or "canvas"
}
}
// ... rest of spec
}Kibana Config Options
| Option | Type | Default | Description |
|---|---|---|---|
hideWarnings | boolean | false | Suppress Vega warnings |
type | string | - | Set to "map" for map visualizations |
renderer | "svg" \ | "canvas" | "canvas" |
controlsLocation | string | "bottom" | Position of controls: "top", "bottom", "left", "right" |
controlsDirection | string | "vertical" | Control layout: "horizontal" or "vertical" |
Best Practices
1. Don't Set Width/Height - Use Autosize
Kibana controls the visualization size through the dashboard panel. Let Kibana manage dimensions:
// Good - let Kibana control size
{
$schema: https://vega.github.io/schema/vega-lite/v6.json
autosize: {
type: fit
contains: padding
}
// ... rest of spec (no width/height)
}
// Bad - explicit dimensions conflict with Kibana
{
$schema: https://vega.github.io/schema/vega-lite/v6.json
width: 600
height: 300
// ...
}2. Always Use Time Filtering for Time Series
// Good
{
data: {
url: {
"%type%": "esql"
"%timefield%": "@timestamp"
query: "FROM logs-* | WHERE @timestamp >= ?_tstart AND @timestamp <= ?_tend | ..."
}
}
}
// Bad - queries all data
{
data: {
url: {
"%type%": "esql"
query: "FROM logs-* | ..."
}
}
}2. Use Context for Dashboard Integration
// When the chart should respond to dashboard filters
{
data: {
url: {
"%type%": "esql"
"%context%": true
// ...
}
}
}3. Limit Results
Always use LIMIT or aggregations to control result size:
{
data: {
url: {
"%type%": "esql"
query: "FROM logs-* | LIMIT 1000" // Or use STATS for aggregation
}
}
}4. Pre-sort Data for Time Series
{
data: {
url: {
"%type%": "esql"
query: '''
FROM logs-*
| STATS count=COUNT() BY bucket=DATE_TRUNC(1h, @timestamp)
| SORT bucket ASC // Important for line charts
'''
}
}
}5. Always Rename Dotted Fields
Dotted field names like service.name or room.name break Vega-Lite (they're interpreted as nested object paths). Always rename them:
{
"data": {
"url": {
"%type%": "esql",
"query": "FROM logs-* | STATS count=COUNT() BY service.name | RENAME service.name AS service"
}
}
}6. Use Single-Line Queries (Avoid Triple Quotes)
HJSON triple-quoted strings (''') cause parse errors in Kibana. Use single-line queries with escaped quotes:
{
"data": {
"url": {
"%type%": "esql",
"query": "FROM logs-* | WHERE @timestamp >= ?_tstart AND @timestamp <= ?_tend | STATS total = COUNT(), errors = COUNT(level == \"error\" OR NULL) BY service.name | RENAME service.name AS service | EVAL error_rate = errors / total * 100 | SORT error_rate DESC | LIMIT 20"
}
}
}Comparison: ES|QL vs Elasticsearch DSL
ES|QL Approach
{
data: {
url: {
"%type%": "esql"
query: "FROM logs-* | STATS count=COUNT() BY status | SORT count DESC"
}
}
}Elasticsearch DSL Approach (Traditional)
{
data: {
url: {
index: "logs-*"
body: {
size: 0
aggs: {
by_status: {
terms: { field: "status", order: { _count: "desc" } }
}
}
}
}
format: { property: "aggregations.by_status.buckets" }
}
}ES|QL advantages:
- More readable syntax
- Easier to write and maintain
- Supports complex transformations inline
- Better for ad-hoc analysis
Vega-Lite Reference for Kibana
Complete reference for creating data visualizations using Vega-Lite in Kibana. This guide follows best practices from the UW Interactive Data Lab Visualization Curriculum.
Philosophy
At its core, a visualization maps data to visual properties. Vega-Lite embodies a grammar of graphics: you describe _what_ you want to visualize, not _how_ to draw it. This enables:
- Concise specifications
- Automatic inference of scales, axes, legends
- Composable multi-view displays
- Reproducible visualizations
---
Critical Pitfalls (Read First!)
⚠️ These issues cause silent failures. Your chart will render but show wrong/missing data.
1. Dot-Notation Field Names
Problem: Field names containing dots (e.g., room.name, host.ip, metric.value) are interpreted as nested object paths.
// Data from ES|QL: {"room.name": "Kitchen", "temp": 21}
// Vega-Lite looks for: {room: {name: "Kitchen"}}
// Result: "undefined" in labels, collapsed bars, broken legendsSolution: Use ES|QL RENAME to create simple field names:
FROM logs-*
| STATS count=COUNT() BY service.name
| RENAME service.name AS service2. Don't Set Width/Height - Use Autosize
Problem: Kibana controls the panel size. Explicit dimensions cause conflicts.
// ❌ BAD - conflicts with Kibana panel sizing
{
width: 600
height: 300
// ...
}
// ✅ GOOD - let Kibana control size
{
autosize: {
type: fit
contains: padding
}
// ...
}3. Horizontal Bar Chart Label Truncation
Y-axis labels get cut off on horizontal bar charts. Always set labelLimit:
"y": {"field": "category", "axis": {"labelLimit": 200}}4. Legends vs Direct Labels
Legends force the reader's eye to jump back and forth. Label lines directly when possible:
{
"layer": [
{ "mark": "line" },
{
"mark": { "type": "text", "align": "left", "dx": 5 },
"transform": [
{
"window": [{ "op": "row_number", "as": "rank" }],
"sort": [{ "field": "x", "order": "descending" }],
"groupby": ["series"]
},
{ "filter": "datum.rank === 1" }
],
"encoding": { "text": { "field": "series" } }
}
]
}5. HJSON Triple-Quoted Strings Break Kibana
Problem: HJSON multi-line strings with ''' cause parse errors in Kibana's Vega plugin.
Error: End of input while parsing an object (missing '}')Solution: Use proper JSON format with single-line queries and escaped quotes:
{
"data": {
"url": {
"%type%": "esql",
"query": "FROM logs-* | WHERE level == \"error\" | STATS count=COUNT() BY host"
}
}
}6. Color Schemes Invisible on Dark Themes
Problem: Some color schemes with reverse: true render invisible on Kibana's dark theme.
// ❌ BAD - invisible bars on dark theme
"color": {
"scale": { "scheme": "redyellowgreen", "reverse": true }
}Solution: Use dark-theme-friendly schemes:
// ✅ GOOD - visible on both light and dark themes
"color": {
"scale": { "scheme": "blues" } // or: viridis, warmgreys, teals, purples
}Safe color schemes: blues, greens, purples, teals, viridis, warmgreys, cividis
7. Sort Conflicts in Layered Specs
Problem: Using sort: "-x" on a shared encoding in a layered spec (e.g., bar + text value labels) causes:
Domains that should be unioned has conflicting sort properties. Sort will be set to true.Vega-Lite tries to union the scale domains across layers and finds conflicting sort specifications.
Solution: Pre-sort data in ES|QL with SORT field DESC and use sort: null in encoding to preserve data order:
// ❌ BAD - causes "conflicting sort properties" warning in layered specs
"y": { "field": "category", "type": "nominal", "sort": "-x" }
// ✅ GOOD - pre-sort in ES|QL, use sort: null to preserve data order
// ES|QL: ... | SORT revenue DESC
"y": { "field": "category", "type": "nominal", "sort": null }Note: sort: "-x" is fine in single-mark specs (no layers). The conflict only occurs in layer compositions where multiple marks share the same encoding axis.
8. Time Axis: No Rotated Labels
Problem: Rotated date labels create visual clutter and are hard to read.
// ❌ BAD - cluttered, hard to read
"axis": { "format": "%b %d %H:%M", "labelAngle": -45 }Solution: Keep labels horizontal, let Vega auto-format, limit tick count:
// ✅ GOOD - clean horizontal labels, auto-formatted
"axis": { "labelAngle": 0, "tickCount": 8 }Time axis best practices:
- Never rotate — use
"labelAngle": 0 - Let Vega auto-format — omit
formatfor intelligent date display - Limit ticks — use
"tickCount": 6-10to prevent crowding - Remove redundant title — use
"title": nullwhen axis is self-explanatory - Compact y-axis titles — use "°C" or "%" instead of "Temperature (°C)"
// Optimal time series encoding
"encoding": {
"x": {
"field": "timestamp",
"type": "temporal",
"title": null,
"axis": { "labelAngle": 0, "tickCount": 8 }
},
"y": {
"field": "value",
"type": "quantitative",
"title": "°C",
"scale": { "zero": false }
}
}---
Specification Structure
A Vega-Lite specification is a JSON/HJSON object:
{
$schema: https://vega.github.io/schema/vega-lite/v6.json
// Let Kibana control sizing
autosize: { type: fit, contains: padding }
data: { ... }
mark: "..."
encoding: { ... }
}Core Components
| Component | Description | | ----------- | --------------------------------------------------- | ----------------------------- | | data | Input data (ES | QL query, inline values, URL) | | mark | Geometric shape (bar, line, point, area, etc.) | | encoding | Mapping of data fields to visual channels | | transform | Data transformations (filter, aggregate, calculate) | | config | Styling defaults |
---
Data Types
Understanding data types is fundamental to choosing appropriate visual encodings.
| Type | Symbol | Description | Example | Appropriate Channels |
|---|---|---|---|---|
| Nominal | N | Categories without order | country, product type | color hue, shape, row/column |
| Ordinal | O | Ordered categories | rating (low/med/high), month | position, color value, size |
| Quantitative | Q | Continuous numbers | temperature, revenue | position, size, color gradient |
| Temporal | T | Date/time values | timestamp, date | position (time axis) |
Type Selection Guidelines
- Nominal: Use when equality comparison matters (A = B?)
- Ordinal: Use when rank order matters (A < B?)
- Quantitative: Use when magnitude/distance matters (A - B = ?)
- Temporal: Use for time-based data with calendar semantics
---
Encoding Channels
Channels map data fields to visual properties.
Position Channels
"encoding": {
"x": {"field": "date", "type": "temporal"},
"y": {"field": "value", "type": "quantitative"},
"x2": {"field": "end_date"},
"y2": {"field": "high_value"}
}| Channel | Description | Best For |
|---|---|---|
x, y | Primary position | All data types |
x2, y2 | Secondary position (ranges) | Range bars, error bars |
xOffset, yOffset | Position offset within band | Grouped/dodged bars |
Mark Property Channels
"encoding": {
"color": {"field": "category", "type": "nominal"},
"size": {"field": "population", "type": "quantitative"},
"shape": {"field": "region", "type": "nominal"},
"opacity": {"field": "confidence", "type": "quantitative"}
}| Channel | Description | Best For |
|---|---|---|
color | Fill/stroke color | Nominal (hue), Quantitative (gradient) |
size | Mark size/area | Quantitative values |
shape | Point symbol shape | Nominal (≤6 categories) |
opacity | Transparency | Quantitative, overlapping data |
strokeWidth | Line thickness | Quantitative |
strokeDash | Dash pattern | Nominal (≤3 categories) |
Text & Tooltip Channels
"encoding": {
"text": {"field": "label"},
"tooltip": [
{"field": "name", "title": "Country"},
{"field": "value", "title": "GDP", "format": ",.0f"}
]
}Facet Channels
"encoding": {
"row": {"field": "region", "type": "nominal"},
"column": {"field": "year", "type": "ordinal"}
}---
Mark Types
Basic Marks
| Mark | Use Case | Example |
|---|---|---|
point | Scatter plots | "mark": "point" |
circle | Filled scatter plots | "mark": "circle" |
bar | Bar charts | "mark": "bar" |
line | Time series, trends | "mark": "line" |
area | Volume over time | "mark": "area" |
tick | Strip plots | "mark": "tick" |
rule | Reference lines | "mark": "rule" |
text | Labels | "mark": "text" |
rect | Heatmaps | "mark": "rect" |
arc | Pie/donut charts | "mark": "arc" |
Composite Marks
| Mark | Use Case |
|---|---|
boxplot | Distribution summary |
errorbar | Uncertainty visualization |
errorband | Confidence intervals |
Mark Properties
"mark": {
"type": "bar",
"color": "#4c78a8",
"opacity": 0.8,
"cornerRadius": 2,
"strokeWidth": 0
}---
Scales
Scales map data values to visual values.
Scale Types
| Type | Description | Use For |
|---|---|---|
linear | Linear mapping | Quantitative data |
log | Logarithmic | Wide-ranging values, ratios |
sqrt | Square root | Area-based size encoding |
time | Time-based | Temporal data |
ordinal | Discrete categories | Nominal/ordinal |
band | Discrete with width | Bar charts |
Scale Configuration
"encoding": {
"x": {
"field": "value",
"type": "quantitative",
"scale": {
"domain": [0, 100],
"zero": true,
"nice": true
}
}
}Color Scales
"encoding": {
"color": {
"field": "temperature",
"type": "quantitative",
"scale": {
"scheme": "viridis",
"domain": [-10, 40]
}
}
}Recommended Color Schemes:
| Type | Schemes |
|---|---|
| Sequential | viridis, blues, greens, oranges, reds |
| Diverging | redblue, redyellowblue, spectral |
| Categorical | category10, tableau10, set1 |
---
Transforms
Data transformations within the spec.
Filter
"transform": [
{"filter": "datum.year == 2020"},
{"filter": {"field": "country", "oneOf": ["USA", "China", "India"]}}
]Calculate
"transform": [
{"calculate": "datum.revenue - datum.cost", "as": "profit"},
{"calculate": "datum.value * 100 / datum.total", "as": "percentage"}
]Aggregate
"transform": [
{
"aggregate": [
{"op": "mean", "field": "temperature", "as": "avg_temp"},
{"op": "count", "as": "n"}
],
"groupby": ["month", "location"]
}
]Aggregation Operations: count, sum, mean, median, min, max, stdev, variance, q1, q3, distinct
Bin
"encoding": {
"x": {
"bin": true,
"field": "temperature"
},
"y": {"aggregate": "count"}
}Time Unit
"encoding": {
"x": {
"timeUnit": "yearmonth",
"field": "date"
}
}Time Units: year, quarter, month, week, day, hours, minutes, yearmonth, yearmonthdate, hoursminutes
Window
"transform": [
{
"window": [
{"op": "row_number", "as": "rank"},
{"op": "sum", "field": "value", "as": "cumulative"}
],
"sort": [{"field": "value", "order": "descending"}]
}
]Fold (Unpivot)
"transform": [
{"fold": ["temp_min", "temp_max"], "as": ["measure", "value"]}
]Regression
"transform": [
{"regression": "y", "on": "x", "method": "linear"}
]---
Multi-View Composition
Layer
Superimpose multiple marks on shared axes.
{
"layer": [
{
"mark": { "type": "area", "opacity": 0.3 }
},
{
"mark": { "type": "line", "color": "black" }
}
],
"encoding": {
"x": { "field": "date", "type": "temporal" },
"y": { "field": "value", "type": "quantitative" }
}
}Horizontal Concatenation (hconcat)
{
"hconcat": [
{"mark": "bar", "encoding": {...}},
{"mark": "line", "encoding": {...}}
]
}Vertical Concatenation (vconcat)
{
"vconcat": [
{"mark": "bar", "encoding": {...}},
{"mark": "line", "encoding": {...}}
]
}Facet (Small Multiples)
{
"mark": "bar",
"encoding": {
"x": { "field": "value", "type": "quantitative" },
"y": { "field": "category", "type": "nominal" },
"column": { "field": "region", "type": "nominal" }
}
}Resolve
Control how scales/axes/legends are shared or independent.
{
"layer": [...],
"resolve": {
"scale": {"y": "independent"},
"axis": {"y": "independent"}
}
}---
Common Chart Patterns for Kibana
All examples use ES|QL data sources and proper Kibana sizing.
Horizontal Bar Chart with Value Labels
{
$schema: https://vega.github.io/schema/vega-lite/v6.json
title: { text: "Sales by Region", anchor: "start" }
autosize: { type: fit, contains: padding }
data: {
url: {
"%type%": "esql"
"%context%": true
query: '''
FROM sales-*
| STATS sales = SUM(amount) BY region
| SORT sales DESC
| LIMIT 10
'''
}
}
layer: [
{ mark: { type: bar, cornerRadiusEnd: 3 } }
{
mark: { type: text, align: left, dx: 5, fontSize: 11 }
encoding: { text: { field: sales, format: "," } }
}
]
// Use sort: null with layered specs; data is pre-sorted by ES|QL SORT
encoding: {
y: {
field: region
type: nominal
sort: null
title: null
axis: { labelLimit: 150 }
}
x: { field: sales, type: quantitative, title: "Sales ($)" }
color: { value: "#4c78a8" }
}
}Time Series with Area and Line
{
$schema: https://vega.github.io/schema/vega-lite/v6.json
title: {
text: "Request Rate"
subtitle: "Requests per minute"
anchor: start
}
autosize: { type: fit, contains: padding }
data: {
url: {
"%type%": "esql"
"%context%": true
"%timefield%": "@timestamp"
query: '''
FROM logs-*
| WHERE @timestamp >= ?_tstart AND @timestamp <= ?_tend
| STATS requests = COUNT() BY bucket = DATE_TRUNC(1 minute, @timestamp)
| SORT bucket ASC
'''
}
}
layer: [
{ mark: { type: area, opacity: 0.2, color: "#4c78a8" } }
{ mark: { type: line, color: "#4c78a8", strokeWidth: 2 } }
]
encoding: {
x: {
field: bucket
type: temporal
title: null
axis: { labelAngle: 0, tickCount: 8 }
}
y: {
field: requests
type: quantitative
title: "Requests"
}
}
}Multi-Line Chart with Direct Labels
{
$schema: https://vega.github.io/schema/vega-lite/v6.json
title: { text: "Service Performance", anchor: start }
autosize: { type: fit, contains: padding }
data: {
url: {
"%type%": "esql"
"%context%": true
"%timefield%": "@timestamp"
query: '''
FROM metrics-*
| WHERE @timestamp >= ?_tstart AND @timestamp <= ?_tend
| STATS avg_latency = AVG(latency) BY bucket = DATE_TRUNC(5 minutes, @timestamp), service
| SORT bucket ASC
'''
}
}
layer: [
{
mark: { type: line, strokeWidth: 2 }
}
{
transform: [
{
window: [{ op: "row_number", as: "rank" }]
sort: [{ field: "bucket", order: "descending" }]
groupby: ["service"]
}
{ filter: "datum.rank === 1" }
]
mark: { type: text, align: left, dx: 8, fontSize: 12, fontWeight: bold }
encoding: { text: { field: service } }
}
]
encoding: {
x: {
field: bucket
type: temporal
title: null
axis: { labelAngle: 0, tickCount: 8 }
}
y: { field: avg_latency, type: quantitative, title: "ms" }
color: { field: service, type: nominal, legend: null }
}
}Heatmap
{
$schema: https://vega.github.io/schema/vega-lite/v6.json
title: { text: "Activity by Day and Hour", anchor: start }
autosize: { type: fit, contains: padding }
data: {
url: {
"%type%": "esql"
"%context%": true
"%timefield%": "@timestamp"
query: '''
FROM logs-*
| WHERE @timestamp >= ?_tstart AND @timestamp <= ?_tend
| EVAL hour = DATE_EXTRACT("HOUR_OF_DAY", @timestamp)
| EVAL day = DATE_FORMAT("EEE", @timestamp)
| STATS activity = COUNT() BY hour, day
'''
}
}
mark: { type: rect, cornerRadius: 2 }
encoding: {
x: { field: hour, type: ordinal, title: "Hour" }
y: { field: day, type: nominal, title: null }
color: {
field: activity
type: quantitative
scale: { scheme: blues }
title: "Events"
}
}
}Grouped Bar Chart
{
$schema: https://vega.github.io/schema/vega-lite/v6.json
autosize: { type: fit, contains: padding }
data: {
url: {
"%type%": "esql"
query: '''
FROM sales-*
| STATS revenue = SUM(amount) BY category, quarter
| SORT category, quarter
'''
}
}
mark: { type: bar, cornerRadius: 2 }
encoding: {
x: { field: category, type: nominal, title: null }
y: { field: revenue, type: quantitative, title: "Revenue" }
xOffset: { field: quarter, type: nominal }
color: {
field: quarter
type: nominal
title: "Quarter"
scale: { range: ["#4c78a8", "#72b7b2"] }
}
}
}Scatter Plot with Trend Line
{
$schema: https://vega.github.io/schema/vega-lite/v6.json
autosize: { type: fit, contains: padding }
data: {
url: {
"%type%": "esql"
query: '''
FROM metrics-*
| STATS cpu = AVG(cpu_percent), memory = AVG(memory_percent) BY host
'''
}
}
layer: [
{
mark: { type: point, filled: true, size: 60, opacity: 0.7 }
}
{
mark: { type: line, color: "firebrick", strokeWidth: 2 }
transform: [{ regression: "memory", on: "cpu" }]
}
]
encoding: {
x: { field: cpu, type: quantitative, title: "CPU %" }
y: { field: memory, type: quantitative, title: "Memory %" }
}
}---
Best Practices
1. Never Use Pie or Donut Charts
Humans cannot accurately compare arc lengths or angles. Always use sorted bar charts instead.
// ❌ AVOID - pie/donut charts
{ mark: { type: arc, innerRadius: 50 } }
// ✅ USE - sorted horizontal bar chart (pre-sort in ES|QL, use sort: null for layered specs)
{
mark: bar
encoding: {
y: { field: category, sort: null }
x: { field: value }
}
}2. Use Color to Encode Data, Not Decorate
- Single series = single color (don't add rainbow gradients)
- Reserve color for encoding meaningful data dimensions
- Use sequential schemes for quantitative data
- Use categorical schemes only for nominal data (≤10 categories)
3. Sort by Value, Not Alphabetically
Pre-sort data in ES|QL (SORT value DESC) and use sort: null to preserve that order. This is required for layered specs (bar + text labels) to avoid "conflicting sort properties" warnings. For single-mark specs, sort: "-x" also works.
// ✅ PREFERRED - works in all specs (single-mark and layered)
// ES|QL: ... | SORT revenue DESC
"encoding": {
"y": {"field": "category", "sort": null}
}
// ⚠️ OK for single-mark only - causes warnings in layered specs
"encoding": {
"y": {"field": "category", "sort": "-x"}
}4. Annotate Values Directly on Bars
Use sort: null on the categorical axis (not sort: "-x") since this is a layered spec. Pre-sort data via ES|QL.
{
layer: [
{ mark: bar }
{
mark: { type: text, align: left, dx: 5, fontSize: 11 }
encoding: { text: { field: value, format: "," } }
}
]
// Data pre-sorted by ES|QL: ... | SORT value DESC
encoding: {
y: { field: category, sort: null }
x: { field: value }
}
}5. Direct Label Instead of Legends
Place labels directly on data points:
{
layer: [
{
mark: { type: line, strokeWidth: 2 }
encoding: { color: { field: series, legend: null } }
}
{
mark: { type: text, align: left, dx: 8, fontWeight: bold }
transform: [
{
window: [{ op: "row_number", as: "rank" }]
sort: [{ field: "x", order: "descending" }]
groupby: ["series"]
}
{ filter: "datum.rank === 1" }
]
encoding: {
text: { field: series }
color: { field: series, legend: null }
}
}
]
}6. Add Reference Lines for Context
{
layer: [
{ mark: bar, encoding: {...} }
{
mark: { type: rule, strokeDash: [4, 4], color: "#999" }
encoding: { y: { datum: 20 } }
}
{
mark: { type: text, align: left, dx: 5, color: "#666" }
encoding: {
y: { datum: 20 }
text: { value: "Target: 20" }
}
}
]
}7. Descriptive Titles Replace Axis Titles
A good title/subtitle makes axis titles redundant. Remove them to reduce clutter.
// ❌ REDUNDANT - title and axis titles say the same thing
{
"title": "Temperature Over Time",
"encoding": {
"x": { "field": "time", "title": "Time" },
"y": { "field": "temp", "title": "Temperature (°C)" }
}
}
// ✅ CLEAN - descriptive title, no axis titles needed
{
"title": {
"text": "Temperature Over Time",
"subtitle": "Hourly readings, December 2025"
},
"encoding": {
"x": { "field": "time", "title": null },
"y": { "field": "temp", "title": null }
}
}When to keep axis titles:
- Units aren't obvious (use compact: "°C", "%", "ms")
- Multiple y-axes with different scales
- Scientific/technical charts where precision matters
"title": {
"text": "Room Climate Comparison",
"subtitle": "Temperature and humidity by room",
"anchor": "start"
}8. Use Small Multiples Over Complexity
Instead of overloading one chart, use faceting:
{
mark: line
encoding: {
column: { field: region, type: nominal }
x: { field: date, type: temporal }
y: { field: value, type: quantitative }
}
}---
Kibana-Specific Configuration
Config Block for Kibana
{
$schema: https://vega.github.io/schema/vega-lite/v6.json
config: {
kibana: {
hideWarnings: true
renderer: "svg" // or "canvas"
}
view: { stroke: null }
axis: { labelFontSize: 12, titleFontSize: 14 }
}
// ... rest of spec
}Kibana Config Options
| Option | Type | Default | Description |
|---|---|---|---|
hideWarnings | boolean | false | Suppress Vega warnings |
type | string | - | Set to "map" for map visualizations |
renderer | "svg" \ | "canvas" | "canvas" |
---
Professional Theming
Light Theme
{
$schema: https://vega.github.io/schema/vega-lite/v6.json
background: white
config: {
title: {
color: "#171717"
subtitleColor: "#737373"
fontSize: 16
subtitleFontSize: 12
anchor: start
}
axis: {
labelColor: "#525252"
titleColor: "#525252"
gridColor: "#e5e5e5"
domainColor: "#d4d4d4"
tickColor: "#d4d4d4"
}
legend: {
labelColor: "#525252"
titleColor: "#525252"
}
view: { stroke: null }
}
}Dark Theme
{
$schema: https://vega.github.io/schema/vega-lite/v6.json
background: "#0a0a0a"
config: {
title: {
color: "#e5e5e5"
subtitleColor: "#a3a3a3"
fontSize: 16
subtitleFontSize: 12
anchor: start
}
axis: {
labelColor: "#a3a3a3"
titleColor: "#a3a3a3"
gridColor: "#262626"
domainColor: "#404040"
tickColor: "#404040"
}
legend: {
labelColor: "#a3a3a3"
titleColor: "#a3a3a3"
}
view: { stroke: null }
}
}---
Quick Reference Checklist
Before finalizing any chart, verify:
- [ ] Descriptive title/subtitle — makes axis titles unnecessary
- [ ] Remove redundant axis titles — use
title: nullwhen chart title is clear - [ ] No width/height set — use
autosize: { type: fit, contains: padding } - [ ] Simple field names — use RENAME in ES|QL for dotted fields
- [ ] Sort bars by value — pre-sort in ES|QL, use
sort: nullin layered specs (notsort: "-x") - [ ] Time axis horizontal —
labelAngle: 0,tickCount: 8, auto-format - [ ] Value labels on bars for precise reading
- [ ] Direct labels on lines instead of legends (or legend at right)
- [ ] Compact units if needed — use "°C" not "Temperature (°C)"
- [ ] Reference lines for thresholds/targets with labels
- [ ] Consistent theming — same colors mean same things
References
#!/usr/bin/env node
/**
* CRUD operations for Kibana dashboards and Vega visualizations.
*
* Usage:
* ./kibana-vega.js dashboards list - List all dashboards
* ./kibana-vega.js dashboards get <id> - Get dashboard by ID
* ./kibana-vega.js dashboards create <title> [file] - Create dashboard
* ./kibana-vega.js dashboards update <id> <file> - Update dashboard
* ./kibana-vega.js dashboards delete <id> - Delete dashboard
*
* ./kibana-vega.js visualizations list [type] - List visualizations
* ./kibana-vega.js visualizations get <id> - Get visualization
* ./kibana-vega.js visualizations create <title> <file|-> - Create Vega visualization (use - for stdin)
* ./kibana-vega.js visualizations update <id> <file|-> - Update Vega visualization (use - for stdin)
* ./kibana-vega.js visualizations delete <id> - Delete visualization
*
* ./kibana-vega.js test - Test Kibana connection
*/
import { readFileSync, existsSync } from "fs";
import { randomUUID } from "crypto";
import hjson from "hjson";
// =============================================================================
// Stdin Reading
// =============================================================================
async function readStdin() {
return new Promise((resolve, reject) => {
let data = "";
// Set encoding
process.stdin.setEncoding("utf8");
// Check if stdin is a TTY (no piped input)
if (process.stdin.isTTY) {
reject(new Error("No input provided via stdin. Use a file path or pipe JSON input."));
return;
}
process.stdin.on("readable", () => {
let chunk;
while ((chunk = process.stdin.read()) !== null) {
data += chunk;
}
});
process.stdin.on("end", () => {
resolve(data);
});
process.stdin.on("error", (err) => {
reject(err);
});
});
}
// =============================================================================
// Kibana Client Setup
// =============================================================================
function getKibanaConfig() {
const cloudId = process.env.KIBANA_CLOUD_ID || process.env.ELASTICSEARCH_CLOUD_ID;
let url = process.env.KIBANA_URL;
if (!url && cloudId) {
try {
const parts = cloudId.split(":");
if (parts.length === 2) {
const decoded = Buffer.from(parts[1], "base64").toString("utf8");
const decodedParts = decoded.split("$");
if (decodedParts.length >= 3 && decodedParts[2]) {
const domain = decodedParts[0];
const kibanaUuid = decodedParts[2];
let host = domain;
let port = "";
if (domain.includes(":")) {
const splitDomain = domain.split(":");
host = splitDomain[0];
port = `:${splitDomain[1]}`;
} else {
port = ":443";
}
url = `https://${kibanaUuid}.${host}${port}`;
}
}
} catch (e) {
console.error("Error parsing Cloud ID:", e.message);
}
}
const username = process.env.KIBANA_USERNAME || process.env.ELASTICSEARCH_USERNAME;
const password = process.env.KIBANA_PASSWORD || process.env.ELASTICSEARCH_PASSWORD;
const apiKey = process.env.KIBANA_API_KEY || process.env.ELASTICSEARCH_API_KEY;
const spaceId = process.env.KIBANA_SPACE_ID;
const insecure = process.env.KIBANA_INSECURE === "true";
if (!url) {
console.error("Error: No Kibana connection configured.");
console.error("");
console.error("Set one of these environment variable combinations:");
console.error(" 1. Elastic Cloud: KIBANA_CLOUD_ID + KIBANA_API_KEY");
console.error(" 2. URL + API Key: KIBANA_URL + KIBANA_API_KEY");
console.error(" 3. Basic Auth: KIBANA_URL + KIBANA_USERNAME + KIBANA_PASSWORD");
console.error("");
console.error("For local development, use start-local to run Elasticsearch and Kibana via Docker:");
console.error(" https://github.com/elastic/start-local");
console.error("");
console.error(" curl -fsSL https://elastic.co/start-local | sh");
console.error("");
console.error("Then configure the environment:");
console.error(" source elastic-start-local/.env");
console.error(' export KIBANA_URL="$KB_LOCAL_URL"');
console.error(' export KIBANA_USERNAME="elastic"');
console.error(' export KIBANA_PASSWORD="$ES_LOCAL_PASSWORD"');
process.exit(1);
}
return { url, username, password, apiKey, spaceId, insecure };
}
function getHeaders(config) {
const headers = {
"Content-Type": "application/json",
"kbn-xsrf": "true",
"User-Agent": "elastic-agentic",
};
if (config.apiKey) {
headers["Authorization"] = `ApiKey ${config.apiKey}`;
} else if (config.username && config.password) {
const auth = Buffer.from(`${config.username}:${config.password}`).toString("base64");
headers["Authorization"] = `Basic ${auth}`;
}
return headers;
}
function getBasePath(config) {
let basePath = config.url.replace(/\/$/, "");
if (config.spaceId && config.spaceId !== "default") {
basePath += `/s/${config.spaceId}`;
}
return basePath;
}
async function kibanaFetch(path, options = {}) {
const config = getKibanaConfig();
const basePath = getBasePath(config);
const url = `${basePath}${path}`;
const fetchOptions = {
...options,
headers: {
...getHeaders(config),
...options.headers,
},
};
// Handle insecure TLS
if (config.insecure && typeof process !== "undefined") {
process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
}
try {
const response = await fetch(url, fetchOptions);
const contentType = response.headers.get("content-type");
let data;
if (contentType && contentType.includes("application/json")) {
data = await response.json();
} else {
data = await response.text();
}
if (!response.ok) {
return {
success: false,
status: response.status,
error: data.message || data.error || `HTTP ${response.status}`,
details: data,
};
}
return { success: true, data };
} catch (error) {
return {
success: false,
error: error.message,
details: error,
};
}
}
// =============================================================================
// Import API (serverless-compatible upsert for saved objects)
// =============================================================================
async function importSavedObjects(objects) {
const config = getKibanaConfig();
const basePath = getBasePath(config);
const url = `${basePath}/api/saved_objects/_import?overwrite=true`;
const ndjson = objects.map((obj) => JSON.stringify(obj)).join("\n");
const blob = new Blob([ndjson], { type: "application/x-ndjson" });
const formData = new FormData();
formData.append("file", blob, "import.ndjson");
const headers = getHeaders(config);
delete headers["Content-Type"];
if (config.insecure) {
process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
}
try {
const response = await fetch(url, { method: "POST", headers, body: formData });
const data = await response.json();
if (!response.ok || !data.success) {
const errors = data.errors?.map((e) => e.error?.message || JSON.stringify(e.error)).join("; ");
return {
success: false,
status: response.status,
error: errors || data.message || `HTTP ${response.status}`,
details: data,
};
}
return { success: true, data };
} catch (error) {
return { success: false, error: error.message, details: error };
}
}
async function upsertSavedObject(type, id, attributes, references = []) {
const result = await importSavedObjects([{ type, id, attributes, references }]);
if (!result.success) return result;
const successResult = result.data.successResults?.[0];
return {
success: true,
data: { id: successResult?.id || id, type, attributes, references },
};
}
// =============================================================================
// Export API (serverless-compatible read for saved objects)
// =============================================================================
async function exportSavedObjects(typeOrObjects, includeRefs = false) {
const body = Array.isArray(typeOrObjects)
? { objects: typeOrObjects, includeReferencesDeep: includeRefs }
: { type: typeOrObjects, includeReferencesDeep: includeRefs };
const result = await kibanaFetch("/api/saved_objects/_export", {
method: "POST",
body: JSON.stringify(body),
});
if (!result.success) return result;
const lines = (typeof result.data === "string" ? result.data : "").trim().split("\n");
const objects = [];
let summary = {};
for (const line of lines) {
if (!line.trim()) continue;
try {
const obj = JSON.parse(line);
if (obj.exportedCount !== undefined) {
summary = obj;
} else if (obj.type) {
objects.push(obj);
}
} catch {
// skip unparseable lines
}
}
return { success: true, data: { saved_objects: objects, total: summary.exportedCount || objects.length } };
}
async function exportSavedObjectById(type, id) {
const result = await exportSavedObjects([{ type, id }]);
if (!result.success) return result;
const obj = result.data.saved_objects.find((o) => o.id === id);
if (!obj) {
return { success: false, error: `${type} ${id} not found` };
}
return { success: true, data: obj };
}
// =============================================================================
// Delete (serverless-compatible: try direct DELETE, then fallback to _bulk_delete)
// =============================================================================
async function deleteSavedObject(type, id) {
const directResult = await kibanaFetch(`/api/saved_objects/${type}/${id}`, { method: "DELETE" });
if (directResult.success) return directResult;
const bulkResult = await kibanaFetch("/api/saved_objects/_bulk_delete?force=true", {
method: "POST",
body: JSON.stringify([{ type, id }]),
});
if (bulkResult.success) return bulkResult;
return {
success: false,
error: `Delete not available on this Kibana instance (serverless). Remove the object manually via Kibana UI: Stack Management > Saved Objects.`,
};
}
// =============================================================================
// Dashboard Operations
// =============================================================================
async function listDashboards(searchTerm = "") {
const result = await exportSavedObjects("dashboard");
if (!result.success) return result;
if (searchTerm) {
const term = searchTerm.toLowerCase();
result.data.saved_objects = result.data.saved_objects.filter((obj) =>
(obj.attributes?.title || "").toLowerCase().includes(term),
);
result.data.total = result.data.saved_objects.length;
}
return result;
}
async function getDashboard(id) {
return exportSavedObjectById("dashboard", id);
}
async function createDashboard(title, panels = [], options = {}) {
return upsertSavedObject("dashboard", randomUUID(), {
title,
description: "",
panelsJSON: JSON.stringify(panels),
optionsJSON: JSON.stringify({
useMargins: true,
syncColors: true,
syncTooltips: true,
syncCursor: true,
...options,
}),
timeRestore: false,
kibanaSavedObjectMeta: { searchSourceJSON: "{}" },
});
}
async function updateDashboard(id, attributes) {
return upsertSavedObject("dashboard", id, attributes);
}
async function deleteDashboard(id) {
return deleteSavedObject("dashboard", id);
}
// =============================================================================
// Visualization Operations (Import/Export API — serverless-compatible)
// =============================================================================
async function listVisualizations(type = "") {
const result = await exportSavedObjects("visualization");
if (!result.success) return result;
if (type) {
result.data.saved_objects = result.data.saved_objects.filter((obj) => {
try {
const visState = JSON.parse(obj.attributes?.visState || "{}");
return visState.type === type;
} catch {
return false;
}
});
result.data.total = result.data.saved_objects.length;
}
return result;
}
async function getVisualization(id) {
return exportSavedObjectById("visualization", id);
}
function buildVegaVisAttributes(title, spec) {
const specString = typeof spec === "string" ? spec : JSON.stringify(spec, null, 2);
const visState = { title, type: "vega", params: { spec: specString }, aggs: [] };
return {
title,
visState: JSON.stringify(visState),
uiStateJSON: "{}",
description: "",
kibanaSavedObjectMeta: { searchSourceJSON: "{}" },
};
}
async function createVegaVisualization(title, spec) {
return upsertSavedObject("visualization", randomUUID(), buildVegaVisAttributes(title, spec));
}
async function updateVegaVisualization(id, title, spec) {
return upsertSavedObject("visualization", id, buildVegaVisAttributes(title, spec));
}
async function deleteVisualization(id) {
return deleteSavedObject("visualization", id);
}
// =============================================================================
// Add Visualization to Dashboard
// =============================================================================
async function addVisualizationToDashboard(dashboardId, visualizationId, gridConfig = {}) {
// First get the current dashboard
const dashboardResult = await getDashboard(dashboardId);
if (!dashboardResult.success) {
return dashboardResult;
}
const dashboard = dashboardResult.data;
let panels = [];
// Parse existing panels from saved_objects format
try {
panels = JSON.parse(dashboard.attributes?.panelsJSON || "[]");
} catch {
panels = [];
}
// Calculate next available position (only if y not specified)
let maxY = 0;
for (const panel of panels) {
const gridData = panel.gridData || {};
const panelBottom = (gridData.y || 0) + (gridData.h || 0);
if (panelBottom > maxY) {
maxY = panelBottom;
}
}
// Panel index is a string number
const panelIndex = String(panels.length + 1);
const panelRefName = `panel_${panels.length}`;
// Create new panel in Kibana's expected format
// Default to full width (48) for better layouts
// Hide panel titles by default — Vega specs include their own titles
const newPanel = {
embeddableConfig: { hidePanelTitles: true },
gridData: {
x: gridConfig.x ?? 0,
y: gridConfig.y ?? maxY,
w: gridConfig.w ?? 48,
h: gridConfig.h ?? 15,
i: panelIndex,
},
panelIndex: panelIndex,
panelRefName: panelRefName,
type: "visualization",
};
panels.push(newPanel);
// Build new reference
const newReference = {
id: visualizationId,
name: panelRefName,
type: "visualization",
};
// Update dashboard with new attributes
const updatedAttributes = {
title: dashboard.attributes?.title || "Untitled",
description: dashboard.attributes?.description || "",
panelsJSON: JSON.stringify(panels),
optionsJSON: dashboard.attributes?.optionsJSON || '{"useMargins":true}',
timeRestore: dashboard.attributes?.timeRestore || false,
kibanaSavedObjectMeta: dashboard.attributes?.kibanaSavedObjectMeta || {
searchSourceJSON: '{"query":{"language":"kuery","query":""}}',
},
};
return upsertSavedObject("dashboard", dashboardId, updatedAttributes, [
...(dashboard.references || []),
newReference,
]);
}
// Apply a complete layout to a dashboard (replaces all panels)
async function applyDashboardLayout(dashboardId, layoutConfig) {
const dashboardResult = await getDashboard(dashboardId);
if (!dashboardResult.success) {
return dashboardResult;
}
const dashboard = dashboardResult.data;
const panels = [];
const references = [];
for (let i = 0; i < layoutConfig.panels.length; i++) {
const panelConfig = layoutConfig.panels[i];
const panelIndex = String(i + 1);
const panelRefName = `panel_${i}`;
panels.push({
embeddableConfig: { hidePanelTitles: true },
gridData: {
x: panelConfig.x ?? 0,
y: panelConfig.y ?? i * 15,
w: panelConfig.w ?? 48,
h: panelConfig.h ?? 15,
i: panelIndex,
},
panelIndex: panelIndex,
panelRefName: panelRefName,
type: "visualization",
});
references.push({
id: panelConfig.visualization,
name: panelRefName,
type: "visualization",
});
}
const updatedAttributes = {
title: layoutConfig.title || dashboard.attributes?.title || "Untitled",
description: layoutConfig.description || dashboard.attributes?.description || "",
panelsJSON: JSON.stringify(panels),
optionsJSON: dashboard.attributes?.optionsJSON || '{"useMargins":true}',
timeRestore: dashboard.attributes?.timeRestore || false,
kibanaSavedObjectMeta: dashboard.attributes?.kibanaSavedObjectMeta || {
searchSourceJSON: '{"query":{"language":"kuery","query":""}}',
},
};
return upsertSavedObject("dashboard", dashboardId, updatedAttributes, references);
}
// =============================================================================
// Test Connection
// =============================================================================
function parseVersion(versionString) {
if (!versionString) return { major: 0, minor: 0, patch: 0, snapshot: false, raw: "unknown" };
const clean = versionString.replace(/-SNAPSHOT.*$/, "");
const [major, minor, patch] = clean.split(".").map(Number);
return { major, minor, patch: patch || 0, snapshot: versionString.includes("-SNAPSHOT"), raw: versionString };
}
async function testConnection() {
const result = await kibanaFetch("/api/status");
if (result.success) {
const status = result.data;
const versionString = status.version?.number || "unknown";
const buildFlavor = status.version?.build_flavor || "default";
// Serverless Kibana can be identified by build_flavor or a non-semver version string
const isSemver = /^\d+\.\d+\.\d+/.test(versionString);
const isServerless = buildFlavor === "serverless" || (!isSemver && versionString !== "unknown");
const parsed = isSemver ? parseVersion(versionString) : parseVersion("8.11.0");
return {
success: true,
version: isServerless && !isSemver ? `${versionString} (Serverless)` : versionString,
parsed,
buildFlavor: isServerless ? "serverless" : buildFlavor,
isServerless,
status: status.status?.overall?.level || "unknown",
name: status.name || "unknown",
};
}
return result;
}
// =============================================================================
// Spec File Loading
// =============================================================================
function loadSpecFile(filePath) {
if (!existsSync(filePath)) {
throw new Error(`File not found: ${filePath}`);
}
const content = readFileSync(filePath, "utf-8");
return parseSpec(content);
}
async function loadSpec(filePathOrStdin) {
let content;
if (filePathOrStdin === "-" || filePathOrStdin === "--stdin") {
content = await readStdin();
} else {
if (!existsSync(filePathOrStdin)) {
throw new Error(`File not found: ${filePathOrStdin}`);
}
content = readFileSync(filePathOrStdin, "utf-8");
}
return parseSpec(content);
}
function parseSpec(content) {
// Try HJSON first (more permissive), then JSON
try {
return hjson.parse(content);
} catch {
try {
return JSON.parse(content);
} catch {
// Return as string if neither works (raw Vega spec)
return content;
}
}
}
// =============================================================================
// Output Formatting
// =============================================================================
function formatDashboardList(savedObjects) {
if (!savedObjects || savedObjects.length === 0) {
return "No dashboards found.";
}
const lines = ["ID".padEnd(40) + " | " + "Title"];
lines.push("-".repeat(40) + "-+-" + "-".repeat(50));
for (const obj of savedObjects) {
const id = obj.id || "unknown";
const title = obj.attributes?.title || "Untitled";
lines.push(`${id.padEnd(40)} | ${title}`);
}
return lines.join("\n");
}
function formatVisualizationList(savedObjects) {
if (!savedObjects || savedObjects.length === 0) {
return "No visualizations found.";
}
const lines = ["ID".padEnd(40) + " | " + "Type".padEnd(15) + " | " + "Title"];
lines.push("-".repeat(40) + "-+-" + "-".repeat(15) + "-+-" + "-".repeat(40));
for (const obj of savedObjects) {
const id = obj.id || "unknown";
const title = obj.attributes?.title || "Untitled";
let type = "unknown";
try {
const visState = JSON.parse(obj.attributes?.visState || "{}");
type = visState.type || "unknown";
} catch {
// ignore
}
lines.push(`${id.padEnd(40)} | ${type.padEnd(15)} | ${title}`);
}
return lines.join("\n");
}
function formatDashboard(dashboard) {
const lines = [];
lines.push("=== Dashboard ===");
lines.push(`ID: ${dashboard.id}`);
lines.push(`Title: ${dashboard.attributes?.title || "Untitled"}`);
lines.push(`Description: ${dashboard.attributes?.description || "(none)"}`);
let panels = [];
try {
panels = JSON.parse(dashboard.attributes?.panelsJSON || "[]");
} catch {
panels = [];
}
lines.push(`\nPanels: ${panels.length}`);
if (panels.length > 0) {
lines.push("\n--- Panels ---");
for (const panel of panels) {
const grid = panel.gridData || {};
const gridInfo = `[${grid.x || 0},${grid.y || 0}] ${grid.w || 0}x${grid.h || 0}`;
lines.push(` ${panel.panelIndex || "unknown"}: ${panel.type} ${gridInfo}`);
if (panel.panelRefName) {
// Find referenced visualization
const ref = (dashboard.references || []).find((r) => r.name === panel.panelRefName);
if (ref) {
lines.push(` -> ${ref.type}: ${ref.id}`);
}
}
}
}
return lines.join("\n");
}
function formatVisualization(visualization) {
const lines = [];
lines.push("=== Visualization ===");
lines.push(`ID: ${visualization.id}`);
lines.push(`Title: ${visualization.attributes?.title || "Untitled"}`);
try {
const visState = JSON.parse(visualization.attributes?.visState || "{}");
lines.push(`Type: ${visState.type || "unknown"}`);
if (visState.type === "vega" && visState.params?.spec) {
lines.push("\n--- Vega Spec ---");
// Try to pretty print the spec
try {
const spec = hjson.parse(visState.params.spec);
lines.push(JSON.stringify(spec, null, 2));
} catch {
lines.push(visState.params.spec);
}
}
} catch {
lines.push("Type: unknown");
}
return lines.join("\n");
}
// =============================================================================
// Main CLI
// =============================================================================
async function main() {
const args = process.argv.slice(2);
if (args.length === 0 || args[0] === "help" || args[0] === "--help" || args[0] === "-h") {
printUsage();
process.exit(args.length === 0 ? 1 : 0);
}
const [resource, action, ...params] = args;
try {
switch (resource) {
case "test":
await handleTest();
break;
case "dashboards":
case "dashboard":
await handleDashboards(action, params);
break;
case "visualizations":
case "visualization":
case "vis":
await handleVisualizations(action, params);
break;
default:
console.error(`Unknown resource: ${resource}`);
printUsage();
process.exit(1);
}
} catch (error) {
console.error("Error:", error.message);
process.exit(1);
}
}
async function handleTest() {
console.log("=== Testing Kibana Connection ===\n");
const result = await testConnection();
if (result.success) {
const { parsed, isServerless, buildFlavor } = result;
const { major, minor } = parsed;
console.log("✓ Connected successfully!");
console.log(` Name: ${result.name}`);
console.log(` Version: ${result.version}`);
console.log(` Build flavor: ${buildFlavor}`);
if (parsed.snapshot) console.log(` Snapshot: yes (treating as ${major}.${minor})`);
if (isServerless) console.log(" NOTE: Serverless — features available regardless of reported version");
console.log(` Status: ${result.status}`);
const isSupportedVersion = isServerless || major > 9 || (major === 9 && minor >= 4);
if (!isSupportedVersion) {
console.log(
"\n⚠ WARNING: This skill requires Serverless Kibana or version 9.4+ (SNAPSHOT) for proper ES|QL Vega support.",
);
console.log(` Current version (${major}.${minor}) may lack necessary ES|QL data source capabilities.`);
console.log(" Ensure you strictly use ES|QL for data queries if you proceed.");
} else {
console.log("\n✓ ES|QL Vega data source features are fully supported on this instance.");
}
} else {
console.error("✗ Connection failed:", result.error);
if (result.details) {
console.error("Details:", JSON.stringify(result.details, null, 2));
}
process.exit(1);
}
}
async function handleDashboards(action, params) {
switch (action) {
case "list":
case "ls": {
const searchTerm = params[0] || "";
const result = await listDashboards(searchTerm);
if (result.success) {
console.log("=== Dashboards ===\n");
console.log(formatDashboardList(result.data.saved_objects));
console.log(`\nTotal: ${result.data.total || 0}`);
} else {
console.error("Error:", result.error);
process.exit(1);
}
break;
}
case "get": {
const id = params[0];
if (!id) {
console.error("Error: Dashboard ID required");
console.error("Usage: ./kibana-vega.js dashboards get <id>");
process.exit(1);
}
const result = await getDashboard(id);
if (result.success) {
console.log(formatDashboard(result.data));
} else {
console.error("Error:", result.error);
process.exit(1);
}
break;
}
case "create": {
const title = params[0];
if (!title) {
console.error("Error: Dashboard title required");
console.error("Usage: ./kibana-vega.js dashboards create <title> [panels-file.json]");
process.exit(1);
}
let panels = [];
if (params[1]) {
panels = loadSpecFile(params[1]);
}
const result = await createDashboard(title, panels);
if (result.success) {
console.log("✓ Dashboard created successfully!");
console.log(` ID: ${result.data.id}`);
console.log(` Title: ${result.data.attributes?.title || title}`);
} else {
console.error("Error:", result.error);
if (result.details) {
console.error("Details:", JSON.stringify(result.details, null, 2));
}
process.exit(1);
}
break;
}
case "update": {
const id = params[0];
const file = params[1];
if (!id || !file) {
console.error("Error: Dashboard ID and file required");
console.error("Usage: ./kibana-vega.js dashboards update <id> <file.json>");
process.exit(1);
}
const data = loadSpecFile(file);
const result = await updateDashboard(id, data);
if (result.success) {
console.log("✓ Dashboard updated successfully!");
} else {
console.error("Error:", result.error);
process.exit(1);
}
break;
}
case "delete":
case "rm": {
const id = params[0];
if (!id) {
console.error("Error: Dashboard ID required");
console.error("Usage: ./kibana-vega.js dashboards delete <id>");
process.exit(1);
}
const result = await deleteDashboard(id);
if (result.success) {
console.log("✓ Dashboard deleted successfully!");
} else {
console.error("Error:", result.error);
process.exit(1);
}
break;
}
case "add-panel": {
const dashboardId = params[0];
const visualizationId = params[1];
if (!dashboardId || !visualizationId) {
console.error("Error: Dashboard ID and Visualization ID required");
console.error(
"Usage: ./kibana-vega.js dashboards add-panel <dashboard-id> <visualization-id> [--x N] [--y N] [--w N] [--h N]",
);
process.exit(1);
}
// Parse grid options from remaining params
const gridConfig = {};
for (let i = 2; i < params.length; i++) {
if (params[i] === "--x" && params[i + 1]) {
gridConfig.x = parseInt(params[++i], 10);
} else if (params[i] === "--y" && params[i + 1]) {
gridConfig.y = parseInt(params[++i], 10);
} else if (params[i] === "--w" && params[i + 1]) {
gridConfig.w = parseInt(params[++i], 10);
} else if (params[i] === "--h" && params[i + 1]) {
gridConfig.h = parseInt(params[++i], 10);
}
}
const result = await addVisualizationToDashboard(dashboardId, visualizationId, gridConfig);
if (result.success) {
const grid = gridConfig;
const posInfo =
Object.keys(grid).length > 0
? ` at [${grid.x ?? 0},${grid.y ?? "auto"}] ${grid.w ?? 48}x${grid.h ?? 15}`
: "";
console.log(`✓ Panel added to dashboard successfully!${posInfo}`);
} else {
console.error("Error:", result.error);
process.exit(1);
}
break;
}
case "apply-layout": {
const dashboardId = params[0];
const layoutFile = params[1];
if (!dashboardId || !layoutFile) {
console.error("Error: Dashboard ID and layout file/stdin required");
console.error("Usage: ./kibana-vega.js dashboards apply-layout <dashboard-id> <layout-file.json>");
console.error(" ./kibana-vega.js dashboards apply-layout <dashboard-id> - < layout.json");
process.exit(1);
}
const layoutConfig = await loadSpec(layoutFile);
if (!layoutConfig.panels || !Array.isArray(layoutConfig.panels)) {
console.error('Error: Layout must contain a "panels" array');
process.exit(1);
}
const result = await applyDashboardLayout(dashboardId, layoutConfig);
if (result.success) {
console.log(`✓ Dashboard layout applied successfully!`);
console.log(` Panels: ${layoutConfig.panels.length}`);
} else {
console.error("Error:", result.error);
process.exit(1);
}
break;
}
default:
console.error(`Unknown dashboard action: ${action}`);
console.error("Available actions: list, get, create, update, delete, add-panel");
process.exit(1);
}
}
async function handleVisualizations(action, params) {
switch (action) {
case "list":
case "ls": {
const typeFilter = params[0] || "";
const result = await listVisualizations(typeFilter);
if (result.success) {
console.log("=== Visualizations ===\n");
console.log(formatVisualizationList(result.data.saved_objects));
console.log(`\nTotal: ${result.data.total || 0}`);
} else {
console.error("Error:", result.error);
process.exit(1);
}
break;
}
case "get": {
const id = params[0];
if (!id) {
console.error("Error: Visualization ID required");
console.error("Usage: ./kibana-vega.js visualizations get <id>");
process.exit(1);
}
const result = await getVisualization(id);
if (result.success) {
console.log(formatVisualization(result.data));
} else {
console.error("Error:", result.error);
process.exit(1);
}
break;
}
case "create": {
const title = params[0];
const file = params[1];
if (!title || !file) {
console.error("Error: Title and spec file/stdin required");
console.error("Usage: ./kibana-vega.js visualizations create <title> <spec-file.hjson>");
console.error(" ./kibana-vega.js visualizations create <title> - < spec.json");
console.error(" echo '{\"$schema\":...}' | ./kibana-vega.js visualizations create <title> -");
process.exit(1);
}
const spec = await loadSpec(file);
const result = await createVegaVisualization(title, spec);
if (result.success) {
console.log("✓ Vega visualization created successfully!");
console.log(` ID: ${result.data.id}`);
console.log(` Title: ${title}`);
} else {
console.error("Error:", result.error);
if (result.details) {
console.error("Details:", JSON.stringify(result.details, null, 2));
}
process.exit(1);
}
break;
}
case "update": {
const id = params[0];
const file = params[1];
if (!id || !file) {
console.error("Error: Visualization ID and spec file/stdin required");
console.error("Usage: ./kibana-vega.js visualizations update <id> <spec-file.hjson>");
console.error(" ./kibana-vega.js visualizations update <id> - < spec.json");
console.error(" echo '{\"$schema\":...}' | ./kibana-vega.js visualizations update <id> -");
process.exit(1);
}
// Get current visualization to preserve title
const current = await getVisualization(id);
if (!current.success) {
console.error("Error: Could not fetch visualization:", current.error);
process.exit(1);
}
const title = current.data.attributes?.title || "Untitled";
const spec = await loadSpec(file);
const result = await updateVegaVisualization(id, title, spec);
if (result.success) {
console.log("✓ Vega visualization updated successfully!");
} else {
console.error("Error:", result.error);
process.exit(1);
}
break;
}
case "delete":
case "rm": {
const id = params[0];
if (!id) {
console.error("Error: Visualization ID required");
console.error("Usage: ./kibana-vega.js visualizations delete <id>");
process.exit(1);
}
const result = await deleteVisualization(id);
if (result.success) {
console.log("✓ Visualization deleted successfully!");
} else {
console.error("Error:", result.error);
process.exit(1);
}
break;
}
default:
console.error(`Unknown visualization action: ${action}`);
console.error("Available actions: list, get, create, update, delete");
process.exit(1);
}
}
function printUsage() {
console.log(`
Kibana Vega - Dashboard and Visualization Manager
Usage:
./kibana-vega.js <resource> <action> [options]
Resources:
dashboards Manage Kibana dashboards
visualizations Manage Vega visualizations
test Test Kibana connection
Dashboard Actions:
list [search] List dashboards (optional search filter)
get <id> Get dashboard by ID
create <title> [panels-file] Create a new dashboard
update <id> <file> Update dashboard from file
delete <id> Delete dashboard
add-panel <dash-id> <vis-id> [opts] Add visualization with position options
apply-layout <dash-id> <file|-> Apply complete layout (use - for stdin)
Visualization Actions:
list [type] List visualizations (optional type filter: vega)
get <id> Get visualization by ID (returns JSON spec)
create <title> <file|-> Create Vega visualization (use - for stdin)
update <id> <file|-> Update Vega visualization (use - for stdin)
delete <id> Delete visualization
Environment Variables:
KIBANA_CLOUD_ID Elastic Cloud deployment ID (if KIBANA_URL is not set)
KIBANA_URL Kibana URL (required if KIBANA_CLOUD_ID is not set)
KIBANA_USERNAME Username for basic auth (or ELASTICSEARCH_USERNAME)
KIBANA_PASSWORD Password for basic auth (or ELASTICSEARCH_PASSWORD)
KIBANA_API_KEY API key for authentication (or ELASTICSEARCH_API_KEY)
KIBANA_SPACE_ID Kibana space ID (optional, default: "default")
KIBANA_INSECURE Set to "true" to skip TLS verification
Grid System:
Kibana uses a 48-column grid. Common widths:
48 = full width 24 = half width
16 = third width 12 = quarter width
Examples:
# Test connection
./kibana-vega.js test
# List all dashboards
./kibana-vega.js dashboards list
# Create visualization from file
./kibana-vega.js visualizations create "My Chart" ./my-chart.hjson
# Create visualization from stdin (no intermediate file needed)
echo '{"$schema":"https://vega.github.io/schema/vega-lite/v6.json",...}' | \\
./kibana-vega.js visualizations create "My Chart" -
# Update visualization from stdin
./kibana-vega.js visualizations update <id> - <<< '{"$schema":...}'
# Get visualization spec, modify, and update (pipe workflow)
./kibana-vega.js visualizations get <id> # Review current spec
# Then update with new spec via stdin
# Add panel with explicit grid position
./kibana-vega.js dashboards add-panel <dashboard-id> <vis-id> --x 0 --y 0 --w 24 --h 15
# Apply layout from stdin
echo '{"panels":[...]}' | ./kibana-vega.js dashboards apply-layout <dashboard-id> -
# List only Vega visualizations
./kibana-vega.js visualizations list vega
`);
}
main().catch((error) => {
console.error("Fatal error:", error.message);
process.exit(1);
});
Related skills
Forks & variants (1)
Kibana Vega has 1 known copy in the catalog totaling 2 installs. They canonicalize to this original listing.
- elastic - 2 installs
How it compares
Use for custom Vega/ES|QL panels; use standard Kibana Lens skills when built-in chart types already cover the visualization need.
FAQ
Who is kibana-vega for?
Developers and software engineers working with kibana-vega patterns described in the skill documentation.
When should I use kibana-vega?
When .
Is kibana-vega safe to install?
Review the Security Audits panel on this page before installing in production.