
Kibana Vega
- 2 installs
- 31 repo stars
- Updated May 28, 2026
- elastic/cursor-plugins
This is a copy of kibana-vega by elastic - installs and ranking accrue to the original listing.
Helps with ai & agent building tasks.
About
kibana-vega is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- kibana-vega
- AI & Agent Building
- AI-coding skill
Kibana Vega by the numbers
- 2 all-time installs (skills.sh)
- Data as of Jul 24, 2026 (Skillselion catalog sync)
npx skills add https://github.com/elastic/cursor-plugins --skill kibana-vegaAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 2 |
|---|---|
| repo stars | ★ 31 |
| Last updated | May 28, 2026 |
| Repository | elastic/cursor-plugins ↗ |
What it does
Helps with ai & agent building tasks.
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);
});