kibana-plugin-dev — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited kibana-plugin-dev (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
This skill provides comprehensive knowledge for developing Kibana plugins across all major subsystems.
Kibana plugins consist of:
server/): Routes, saved objects, background taskspublic/): React UI, embeddables, applicationscommon/): Shared types and constants// server/plugin.ts
export class MyPlugin implements Plugin {
setup(core: CoreSetup) {
// Register routes, saved objects, capabilities
// Called once at startup
}
start(core: CoreStart) {
// Access other plugins' start contracts
// Called after all plugins are set up
}
stop() {
// Cleanup
}
}setup(), not start()await context.core in route handlers (async since 8.1)common/ for shared code between server and browserSaved Objects are Kibana's primary persistence layer for plugin data that needs to be managed, imported/exported, migrated across versions, and scoped to Kibana Spaces. Use Saved Objects when your data belongs to the Kibana application layer (configurations, user-created resources, plugin settings) rather than raw Elasticsearch data.
When to use Saved Objects vs plain ES indices:
Register custom saved object types in the server plugin's setup() method:
// server/saved_objects/my_custom_type.ts
import { SavedObjectsType } from '@kbn/core/server';
export const MY_CUSTOM_TYPE = 'my-plugin-config';
export const myCustomType: SavedObjectsType = {
name: MY_CUSTOM_TYPE,
hidden: false,
namespaceType: 'single', // 'single' | 'multiple' | 'agnostic'
mappings: {
dynamic: false,
properties: {
title: { type: 'text' },
name: { type: 'keyword' },
description: { type: 'text' },
enabled: { type: 'boolean' },
priority: { type: 'integer' },
config: { type: 'object', dynamic: false },
tags: { type: 'keyword' },
created_at: { type: 'date' },
updated_at: { type: 'date' },
created_by: { type: 'keyword' },
},
},
management: {
importableAndExportable: true,
icon: 'gear',
defaultSearchField: 'title',
getTitle(obj) {
return obj.attributes.title || obj.attributes.name;
},
getInAppUrl(obj) {
return {
path: `/app/myPlugin#/config/${obj.id}`,
uiCapabilitiesPath: 'myPlugin.show',
};
},
},
migrations: {
// Version-keyed migration functions
'1.1.0': migrateV1_1_0,
'2.0.0': migrateV2_0_0,
},
};// server/plugin.ts — register in setup()
import { myCustomType } from './saved_objects/my_custom_type';
public setup(core: CoreSetup) {
core.savedObjects.registerType(myCustomType);
}| Type | Behavior | Use Case |
|---|---|---|
single | Belongs to exactly one Space. Isolated between spaces. | User configs, space-specific dashboards |
multiple | Can be shared across multiple Spaces. | Shared templates, reusable configs |
agnostic | Global — not scoped to any Space. | Global settings, license data, system state |
single is the default and most common choicemultiple requires the multiple-isolated or multiple sharing mode and is more complex to manageagnostic types are visible everywhere and cannot be restricted to a spaceEvery attribute must be mapped. Kibana uses these mappings to create the Elasticsearch index. Unmapped fields are silently dropped.
mappings: {
dynamic: false, // Always set to false — prevents mapping explosions
properties: {
// Text fields (full-text search)
title: { type: 'text' },
description: { type: 'text' },
// Keyword fields (exact match, aggregations, sorting)
name: { type: 'keyword' },
status: { type: 'keyword' },
type: { type: 'keyword' },
tags: { type: 'keyword' }, // arrays of keywords work fine
// Numeric
count: { type: 'integer' },
score: { type: 'float' },
size: { type: 'long' },
// Boolean
enabled: { type: 'boolean' },
is_default: { type: 'boolean' },
// Date
created_at: { type: 'date' },
updated_at: { type: 'date' },
// Nested/Object (for structured sub-objects)
config: { type: 'object', dynamic: false },
metadata: {
properties: {
version: { type: 'keyword' },
source: { type: 'keyword' },
},
},
// Flattened (for arbitrary key-value data without mapping each field)
labels: { type: 'flattened' },
// Binary (base64 encoded, not searchable)
icon: { type: 'binary' },
},
}Important: dynamic: false is mandatory for saved object types. Setting it to true risks index mapping explosions from user-supplied data.
Migrations transform saved objects when Kibana upgrades. Every time you change the mappings or attribute structure of a saved object type, you must provide a migration.
// server/saved_objects/migrations/index.ts
import { SavedObjectMigrationMap } from '@kbn/core/server';
import { migrateToV1_1_0 } from './to_v1_1_0';
import { migrateToV2_0_0 } from './to_v2_0_0';
export const myTypeMigrations: SavedObjectMigrationMap = {
'1.1.0': migrateToV1_1_0,
'2.0.0': migrateToV2_0_0,
};// server/saved_objects/migrations/to_v1_1_0.ts
import { SavedObjectMigrationFn, SavedObjectUnsanitizedDoc } from '@kbn/core/server';
// Adding a new field with a default value
export const migrateToV1_1_0: SavedObjectMigrationFn = (doc) => {
return {
...doc,
attributes: {
...doc.attributes,
// Add new 'priority' field, default to 0
priority: doc.attributes.priority ?? 0,
// Rename a field
name: doc.attributes.name ?? doc.attributes.title,
},
};
};// server/saved_objects/migrations/to_v2_0_0.ts
import { SavedObjectMigrationFn } from '@kbn/core/server';
// Restructuring attributes
export const migrateToV2_0_0: SavedObjectMigrationFn = (doc) => {
const { oldField, deprecatedSetting, ...rest } = doc.attributes;
return {
...doc,
attributes: {
...rest,
// Move old flat fields into a nested config object
config: {
...(doc.attributes.config || {}),
setting: deprecatedSetting ?? 'default',
},
// Remove the old field by not including it
},
};
};Migration rules:
?? operator)Create a service class that wraps SavedObjectsClient operations:
// server/services/my_config_service.ts
import {
SavedObjectsClientContract,
SavedObject,
SavedObjectsFindResponse,
SavedObjectsErrorHelpers,
} from '@kbn/core/server';
import { MY_CUSTOM_TYPE } from '../saved_objects/my_custom_type';
export interface MyConfig {
title: string;
name: string;
description?: string;
enabled: boolean;
priority: number;
tags: string[];
config: Record<string, unknown>;
created_at: string;
updated_at: string;
created_by: string;
}
export class MyConfigService {
constructor(private readonly savedObjectsClient: SavedObjectsClientContract) {}
async create(
attributes: Omit<MyConfig, 'created_at' | 'updated_at'>,
references?: Array<{ id: string; type: string; name: string }>
): Promise<SavedObject<MyConfig>> {
const now = new Date().toISOString();
return this.savedObjectsClient.create<MyConfig>(
MY_CUSTOM_TYPE,
{
...attributes,
created_at: now,
updated_at: now,
},
{ references }
);
}
async get(id: string): Promise<SavedObject<MyConfig>> {
return this.savedObjectsClient.get<MyConfig>(MY_CUSTOM_TYPE, id);
}
async find(params: {
page?: number;
perPage?: number;
search?: string;
searchFields?: string[];
sortField?: string;
sortOrder?: 'asc' | 'desc';
filter?: string;
}): Promise<SavedObjectsFindResponse<MyConfig>> {
return this.savedObjectsClient.find<MyConfig>({
type: MY_CUSTOM_TYPE,
page: params.page ?? 1,
perPage: params.perPage ?? 20,
search: params.search,
searchFields: params.searchFields ?? ['title', 'name', 'description'],
sortField: params.sortField ?? 'updated_at',
sortOrder: params.sortOrder ?? 'desc',
filter: params.filter,
});
}
async update(
id: string,
attributes: Partial<MyConfig>,
references?: Array<{ id: string; type: string; name: string }>
): Promise<SavedObject<MyConfig>> {
return this.savedObjectsClient.update<MyConfig>(
MY_CUSTOM_TYPE,
id,
{
...attributes,
updated_at: new Date().toISOString(),
},
{ references }
);
}
async delete(id: string): Promise<{}> {
return this.savedObjectsClient.delete(MY_CUSTOM_TYPE, id);
}
async bulkCreate(
objects: Array<{
attributes: Omit<MyConfig, 'created_at' | 'updated_at'>;
id?: string;
references?: Array<{ id: string; type: string; name: string }>;
}>
): Promise<SavedObject<MyConfig>[]> {
const now = new Date().toISOString();
const result = await this.savedObjectsClient.bulkCreate<MyConfig>(
objects.map((obj) => ({
type: MY_CUSTOM_TYPE,
id: obj.id,
attributes: {
...obj.attributes,
created_at: now,
updated_at: now,
} as MyConfig,
references: obj.references,
}))
);
return result.saved_objects;
}
async bulkGet(ids: string[]): Promise<SavedObject<MyConfig>[]> {
const result = await this.savedObjectsClient.bulkGet<MyConfig>(
ids.map((id) => ({ id, type: MY_CUSTOM_TYPE }))
);
return result.saved_objects;
}
}// server/routes/config_routes.ts
import { IRouter, Logger } from '@kbn/core/server';
import { schema } from '@kbn/config-schema';
import { MyConfigService } from '../services/my_config_service';
import { MY_CUSTOM_TYPE } from '../saved_objects/my_custom_type';
export function registerConfigRoutes(router: IRouter, logger: Logger) {
// List
router.get(
{
path: '/api/my_plugin/configs',
validate: {
query: schema.object({
page: schema.number({ defaultValue: 1, min: 1 }),
perPage: schema.number({ defaultValue: 20, min: 1, max: 100 }),
search: schema.maybe(schema.string()),
sortField: schema.string({ defaultValue: 'updated_at' }),
sortOrder: schema.oneOf(
[schema.literal('asc'), schema.literal('desc')],
{ defaultValue: 'desc' }
),
}),
},
},
async (context, request, response) => {
try {
const coreContext = await context.core;
const client = coreContext.savedObjects.client;
const service = new MyConfigService(client);
const result = await service.find(request.query);
return response.ok({
body: {
items: result.saved_objects.map((so) => ({
id: so.id,
...so.attributes,
references: so.references,
})),
total: result.total,
page: result.page,
perPage: result.per_page,
},
});
} catch (error) {
logger.error(`Error listing configs: ${error}`);
return response.customError({
statusCode: 500,
body: { message: 'Failed to list configs' },
});
}
}
);
// Get by ID
router.get(
{
path: '/api/my_plugin/configs/{id}',
validate: {
params: schema.object({ id: schema.string() }),
},
},
async (context, request, response) => {
try {
const client = (await context.core).savedObjects.client;
const service = new MyConfigService(client);
const result = await service.get(request.params.id);
return response.ok({
body: { id: result.id, ...result.attributes, references: result.references },
});
} catch (error: any) {
if (error?.output?.statusCode === 404) {
return response.notFound({ body: { message: `Config ${request.params.id} not found` } });
}
logger.error(`Error getting config: ${error}`);
return response.customError({ statusCode: 500, body: { message: 'Failed to get config' } });
}
}
);
// Create
router.post(
{
path: '/api/my_plugin/configs',
validate: {
body: schema.object({
title: schema.string({ minLength: 1, maxLength: 255 }),
name: schema.string({ minLength: 1, maxLength: 100 }),
description: schema.maybe(schema.string()),
enabled: schema.boolean({ defaultValue: true }),
priority: schema.number({ defaultValue: 0, min: 0 }),
tags: schema.arrayOf(schema.string(), { defaultValue: [] }),
config: schema.recordOf(schema.string(), schema.any(), { defaultValue: {} }),
references: schema.maybe(
schema.arrayOf(
schema.object({
id: schema.string(),
type: schema.string(),
name: schema.string(),
})
)
),
}),
},
},
async (context, request, response) => {
try {
const coreContext = await context.core;
const client = coreContext.savedObjects.client;
const user = coreContext.security.authc.getCurrentUser();
const service = new MyConfigService(client);
const { references, ...attributes } = request.body;
const result = await service.create(
{ ...attributes, created_by: user?.username ?? 'unknown' },
references
);
return response.ok({
body: { id: result.id, ...result.attributes },
});
} catch (error) {
logger.error(`Error creating config: ${error}`);
return response.customError({ statusCode: 500, body: { message: 'Failed to create config' } });
}
}
);
// Update
router.put(
{
path: '/api/my_plugin/configs/{id}',
validate: {
params: schema.object({ id: schema.string() }),
body: schema.object({
title: schema.maybe(schema.string({ minLength: 1, maxLength: 255 })),
name: schema.maybe(schema.string({ minLength: 1, maxLength: 100 })),
description: schema.maybe(schema.string()),
enabled: schema.maybe(schema.boolean()),
priority: schema.maybe(schema.number({ min: 0 })),
tags: schema.maybe(schema.arrayOf(schema.string())),
config: schema.maybe(schema.recordOf(schema.string(), schema.any())),
references: schema.maybe(
schema.arrayOf(
schema.object({
id: schema.string(),
type: schema.string(),
name: schema.string(),
})
)
),
}),
},
},
async (context, request, response) => {
try {
const client = (await context.core).savedObjects.client;
const service = new MyConfigService(client);
const { references, ...attributes } = request.body;
const result = await service.update(request.params.id, attributes, references);
return response.ok({
body: { id: result.id, ...result.attributes },
});
} catch (error: any) {
if (error?.output?.statusCode === 404) {
return response.notFound({ body: { message: `Config ${request.params.id} not found` } });
}
logger.error(`Error updating config: ${error}`);
return response.customError({ statusCode: 500, body: { message: 'Failed to update config' } });
}
}
);
// Delete
router.delete(
{
path: '/api/my_plugin/configs/{id}',
validate: {
params: schema.object({ id: schema.string() }),
},
},
async (context, request, response) => {
try {
const client = (await context.core).savedObjects.client;
const service = new MyConfigService(client);
await service.delete(request.params.id);
return response.ok({ body: { success: true } });
} catch (error: any) {
if (error?.output?.statusCode === 404) {
return response.notFound({ body: { message: `Config ${request.params.id} not found` } });
}
logger.error(`Error deleting config: ${error}`);
return response.customError({ statusCode: 500, body: { message: 'Failed to delete config' } });
}
}
);
}Hidden saved object types are not visible in the Saved Objects management UI and cannot be accessed via the standard client. Use them for internal plugin state, secrets, or system data.
export const myHiddenType: SavedObjectsType = {
name: 'my-plugin-internal-state',
hidden: true, // Not visible in management UI
namespaceType: 'agnostic',
mappings: {
dynamic: false,
properties: {
state: { type: 'object', dynamic: false },
last_run: { type: 'date' },
},
},
};To access hidden types, request a client with explicit access:
// In a route handler
const client = (await context.core).savedObjects.getClient({
includedHiddenTypes: ['my-plugin-internal-state'],
});
// Or from the start contract
const client = core.savedObjects.createInternalRepository(['my-plugin-internal-state']);Saved object references create trackable links between objects. Kibana uses references for:
// Creating with references
const dashboard = await savedObjectsClient.create(
'my-plugin-widget',
{
title: 'My Widget',
// DO NOT store the dashboard ID here
},
{
references: [
{
id: 'some-dashboard-id',
type: 'dashboard',
name: 'linked_dashboard', // Stable reference name (not the ID)
},
{
id: 'some-index-pattern-id',
type: 'index-pattern',
name: 'primary_data_view',
},
],
}
);
// Reading references back
const widget = await savedObjectsClient.get('my-plugin-widget', widgetId);
const dashboardRef = widget.references.find((ref) => ref.name === 'linked_dashboard');
if (dashboardRef) {
const linkedDashboard = await savedObjectsClient.get('dashboard', dashboardRef.id);
}Why references instead of embedded IDs:
Types with management.importableAndExportable: true can be exported and imported through the Kibana UI or API.
management: {
importableAndExportable: true,
icon: 'gear',
defaultSearchField: 'title',
getTitle(obj) {
return obj.attributes.title;
},
// Optional: custom URL for "View in app" link in management
getInAppUrl(obj) {
return {
path: `/app/myPlugin#/config/${obj.id}`,
uiCapabilitiesPath: 'myPlugin.show',
};
},
// Optional: handle import conflicts
onImport(savedObject) {
// Return warnings or errors
return { warnings: [] };
},
// Optional: run after all objects are imported
onExport(savedObject) {
return savedObject;
},
},Server-side export/import APIs (for programmatic use):
// Export
const exportStream = await savedObjects.createExporter(savedObjectsClient).exportByTypes({
types: [MY_CUSTOM_TYPE],
hasReference: undefined,
includeReferencesDeep: true,
});
// Import
const result = await savedObjects.createImporter(savedObjectsClient).import({
readStream: importStream,
overwrite: true,
createNewCopies: false,
});Access saved objects from the public side via the HTTP client (preferred) or the savedObjects client:
// Option 1: Via your plugin's API routes (preferred — routes add validation and business logic)
const response = await http.get('/api/my_plugin/configs');
// Option 2: Direct savedObjects client (simpler but bypasses your route logic)
const result = await core.savedObjects.client.find({
type: 'my-plugin-config',
perPage: 100,
search: 'keyword',
searchFields: ['title'],
});For most plugins, use Option 1 — wrap saved object operations in server routes that add validation, authorization, and business logic. Direct client access is fine for simple reads.
multiple or agnostic when you have a clear reasonhidden: truesecurity.authc.getCurrentUser() for audit trailsEmbeddables are reusable, stateful components that can be rendered inside Kibana Dashboards and other container contexts. If your plugin produces visualizations or widgets that users should be able to place on dashboards, you need the Embeddables framework.
When to use Embeddables:
When NOT to use Embeddables:
Dashboard (Container Embeddable)
├── Panel 1: Visualization Embeddable
├── Panel 2: Saved Search Embeddable
├── Panel 3: Your Custom Embeddable ← this is what you build
└── Panel 4: Map EmbeddableKey concepts:
id, timeRange, filters, query, and your custom fields// public/embeddable/types.ts
import { EmbeddableInput, EmbeddableOutput } from '@kbn/embeddable-plugin/public';
// Input: what the dashboard/user provides to configure this embeddable
export interface MyWidgetInput extends EmbeddableInput {
// EmbeddableInput already includes: id, title, timeRange, filters, query,
// hidePanelTitles, enhancements, disabledActions, searchSessionId
// Your custom input fields:
indexPattern: string;
metricField: string;
aggregationType: 'avg' | 'sum' | 'min' | 'max' | 'count';
colorThreshold?: number;
savedObjectId?: string;
}
// Output: what this embeddable tells the container about its state
export interface MyWidgetOutput extends EmbeddableOutput {
// EmbeddableOutput already includes: loading, error, editUrl, editApp,
// defaultTitle, title, editable, savedObjectId
// Your custom output fields:
currentValue?: number;
indexPatternId?: string;
}// public/embeddable/my_widget_embeddable.tsx
import React from 'react';
import ReactDOM from 'react-dom';
import { Subscription } from 'rxjs';
import { Embeddable, IContainer } from '@kbn/embeddable-plugin/public';
import { CoreStart } from '@kbn/core/public';
import { MyWidgetInput, MyWidgetOutput } from './types';
import { MyWidgetComponent } from './my_widget_component';
export const MY_WIDGET_EMBEDDABLE = 'MY_WIDGET_EMBEDDABLE';
export class MyWidgetEmbeddable extends Embeddable<MyWidgetInput, MyWidgetOutput> {
public readonly type = MY_WIDGET_EMBEDDABLE;
private node: HTMLElement | null = null;
private subscription: Subscription | undefined;
constructor(
input: MyWidgetInput,
private readonly services: { core: CoreStart },
parent?: IContainer
) {
super(input, {}, parent);
}
public render(node: HTMLElement): void {
this.node = node;
// Subscribe to input changes to re-render
this.subscription = this.getInput$().subscribe(() => {
this.renderComponent();
});
this.renderComponent();
}
private renderComponent(): void {
if (!this.node) return;
const input = this.getInput();
ReactDOM.render(
<MyWidgetComponent
indexPattern={input.indexPattern}
metricField={input.metricField}
aggregationType={input.aggregationType}
colorThreshold={input.colorThreshold}
timeRange={input.timeRange}
filters={input.filters}
query={input.query}
http={this.services.core.http}
onValueChange={(value) => {
this.updateOutput({ currentValue: value });
}}
onError={(error) => {
this.updateOutput({ error });
}}
onLoading={(loading) => {
this.updateOutput({ loading });
}}
/>,
this.node
);
}
public reload(): void {
// Called when the dashboard wants to force a refresh
this.renderComponent();
}
public destroy(): void {
super.destroy();
if (this.subscription) {
this.subscription.unsubscribe();
}
if (this.node) {
ReactDOM.unmountComponentAtNode(this.node);
}
}
}// public/embeddable/my_widget_factory.ts
import { i18n } from '@kbn/i18n';
import {
EmbeddableFactoryDefinition,
EmbeddableFactory,
IContainer,
} from '@kbn/embeddable-plugin/public';
import { CoreStart } from '@kbn/core/public';
import {
MyWidgetEmbeddable,
MY_WIDGET_EMBEDDABLE,
} from './my_widget_embeddable';
import { MyWidgetInput, MyWidgetOutput } from './types';
export type MyWidgetEmbeddableFactory = EmbeddableFactory<
MyWidgetInput,
MyWidgetOutput,
MyWidgetEmbeddable
>;
export class MyWidgetEmbeddableFactoryDefinition
implements EmbeddableFactoryDefinition<MyWidgetInput, MyWidgetOutput, MyWidgetEmbeddable>
{
public readonly type = MY_WIDGET_EMBEDDABLE;
public readonly isContainerType = false;
// Grouping controls where this appears in the "Add panel" menu
public readonly grouping = [
{
id: 'my_plugin',
getDisplayName: () => 'My Plugin',
getIconType: () => 'logoElastic',
},
];
constructor(private getCore: () => CoreStart) {}
public getDisplayName(): string {
return i18n.translate('myPlugin.embeddable.widget.displayName', {
defaultMessage: 'My Widget',
});
}
public getIconType(): string {
return 'visMetric';
}
public getDescription(): string {
return i18n.translate('myPlugin.embeddable.widget.description', {
defaultMessage: 'Displays a custom metric from your data.',
});
}
public async isEditable(): Promise<boolean> {
return true;
}
public canCreateNew(): boolean {
return true;
}
// Called when user clicks "Add panel" > "My Widget" on the dashboard
public async getExplicitInput(): Promise<Partial<MyWidgetInput>> {
// Option 1: Return defaults (no wizard)
return {
indexPattern: 'logs-*',
metricField: 'response_time',
aggregationType: 'avg',
};
// Option 2: Show a modal and let user configure
// return new Promise((resolve) => {
// const modal = this.getCore().overlays.openModal(
// toMountPoint(<ConfigWizard onSave={(config) => {
// modal.close();
// resolve(config);
// }} />)
// );
// });
}
public async create(
input: MyWidgetInput,
parent?: IContainer
): Promise<MyWidgetEmbeddable> {
return new MyWidgetEmbeddable(input, { core: this.getCore() }, parent);
}
}// public/plugin.ts
import { CoreSetup, CoreStart, Plugin } from '@kbn/core/public';
import { EmbeddableSetup, EmbeddableStart } from '@kbn/embeddable-plugin/public';
import { MyWidgetEmbeddableFactoryDefinition } from './embeddable/my_widget_factory';
import { MY_WIDGET_EMBEDDABLE } from './embeddable/my_widget_embeddable';
interface MyPluginSetupDeps {
embeddable: EmbeddableSetup;
}
interface MyPluginStartDeps {
embeddable: EmbeddableStart;
}
export class MyPlugin implements Plugin<void, void, MyPluginSetupDeps, MyPluginStartDeps> {
private coreStart: CoreStart | undefined;
public setup(core: CoreSetup<MyPluginStartDeps>, { embeddable }: MyPluginSetupDeps): void {
// Register the factory during setup
const factory = new MyWidgetEmbeddableFactoryDefinition(
() => this.coreStart!
);
embeddable.registerEmbeddableFactory(factory.type, factory);
}
public start(core: CoreStart): void {
this.coreStart = core;
}
}Don't forget to add embeddable to your kibana.jsonc:
{
"plugin": {
"requiredPlugins": ["embeddable"],
"requiredBundles": ["embeddable"]
}
}// public/embeddable/my_widget_component.tsx
import React, { useEffect, useState, useMemo } from 'react';
import {
EuiPanel,
EuiText,
EuiLoadingSpinner,
EuiEmptyPrompt,
EuiCallOut,
} from '@elastic/eui';
import { HttpSetup } from '@kbn/core/public';
import { TimeRange, Filter, Query } from '@kbn/es-query';
interface MyWidgetComponentProps {
indexPattern: string;
metricField: string;
aggregationType: string;
colorThreshold?: number;
timeRange?: TimeRange;
filters?: Filter[];
query?: Query;
http: HttpSetup;
onValueChange: (value: number) => void;
onError: (error: Error) => void;
onLoading: (loading: boolean) => void;
}
export const MyWidgetComponent: React.FC<MyWidgetComponentProps> = ({
indexPattern,
metricField,
aggregationType,
colorThreshold,
timeRange,
filters,
query,
http,
onValueChange,
onError,
onLoading,
}) => {
const [value, setValue] = useState<number | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
// Memoize the query params to avoid unnecessary re-fetches
const queryParams = useMemo(
() => ({
indexPattern,
metricField,
aggregationType,
timeRange: timeRange ? JSON.stringify(timeRange) : undefined,
filters: filters ? JSON.stringify(filters) : undefined,
query: query ? JSON.stringify(query) : undefined,
}),
[indexPattern, metricField, aggregationType, timeRange, filters, query]
);
useEffect(() => {
const abortController = new AbortController();
const fetchData = async () => {
try {
setLoading(true);
onLoading(true);
const result = await http.get('/api/my_plugin/metric', {
query: queryParams,
signal: abortController.signal,
});
setValue(result.value);
onValueChange(result.value);
setError(null);
} catch (err: any) {
if (err.name !== 'AbortError') {
setError(err);
onError(err);
}
} finally {
setLoading(false);
onLoading(false);
}
};
fetchData();
return () => {
abortController.abort();
};
}, [queryParams, http, onValueChange, onError, onLoading]);
if (loading) {
return (
<EuiPanel hasShadow={false} style={{ textAlign: 'center', padding: 40 }}>
<EuiLoadingSpinner size="xl" />
</EuiPanel>
);
}
if (error) {
return (
<EuiCallOut title="Error loading widget" color="danger" iconType="alert">
{error.message}
</EuiCallOut>
);
}
if (value === null) {
return (
<EuiEmptyPrompt
iconType="visMetric"
title={<h3>No data</h3>}
body={<p>No data found for the selected time range and filters.</p>}
/>
);
}
const color = colorThreshold && value > colorThreshold ? 'danger' : 'success';
return (
<EuiPanel hasShadow={false} style={{ textAlign: 'center', padding: 20 }}>
<EuiText size="s" color="subdued">
{aggregationType.toUpperCase()} of {metricField}
</EuiText>
<EuiText color={color} style={{ fontSize: 48, fontWeight: 700 }}>
{value.toLocaleString()}
</EuiText>
</EuiPanel>
);
};Newer Kibana versions introduce a simpler React-first embeddable pattern:
// public/embeddable/my_react_embeddable.tsx
import React, { useState, useEffect } from 'react';
import { ReactEmbeddableFactory } from '@kbn/embeddable-plugin/public';
import { EuiPanel, EuiText, EuiLoadingSpinner } from '@elastic/eui';
export const MY_REACT_EMBEDDABLE_TYPE = 'MY_REACT_WIDGET';
interface MyReactWidgetState {
indexPattern: string;
metricField: string;
aggregationType: string;
}
export const myReactWidgetFactory: ReactEmbeddableFactory<MyReactWidgetState> = {
type: MY_REACT_EMBEDDABLE_TYPE,
deserializeState: (state) => {
// Transform persisted state into runtime state
return state.rawState as MyReactWidgetState;
},
buildEmbeddable: async (state, buildApi, uuid, parentApi) => {
const api = buildApi(
{
serializeState: () => ({
rawState: state,
}),
},
{
// Comparators for detecting state changes
indexPattern: [() => state.indexPattern, (val: string) => { state.indexPattern = val; }],
metricField: [() => state.metricField, (val: string) => { state.metricField = val; }],
}
);
return {
api,
Component: () => {
const [value, setValue] = useState<number | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
// Fetch data using state.indexPattern, state.metricField, etc.
setLoading(true);
fetch(`/api/my_plugin/metric?index=${state.indexPattern}&field=${state.metricField}`)
.then((res) => res.json())
.then((data) => {
setValue(data.value);
setLoading(false);
});
}, []);
if (loading) return <EuiLoadingSpinner />;
return (
<EuiPanel hasShadow={false} style={{ textAlign: 'center' }}>
<EuiText style={{ fontSize: 48 }}>{value}</EuiText>
</EuiPanel>
);
},
};
},
};Register with:
embeddable.registerReactEmbeddableFactory(MY_REACT_EMBEDDABLE_TYPE, async () => myReactWidgetFactory);If your embeddable's configuration is persisted as a saved object:
// In the factory
public savedObjectMetaData = {
name: i18n.translate('myPlugin.embeddable.savedObject.name', {
defaultMessage: 'My Widget',
}),
type: 'my-plugin-widget-config', // Your saved object type
getIconForSavedObject: () => 'visMetric',
};
public async createFromSavedObject(
savedObjectId: string,
input: Partial<MyWidgetInput>,
parent?: IContainer
): Promise<MyWidgetEmbeddable> {
const savedObject = await this.getCore().savedObjects.client.get(
'my-plugin-widget-config',
savedObjectId
);
return new MyWidgetEmbeddable(
{
...input,
...savedObject.attributes,
savedObjectId,
} as MyWidgetInput,
{ core: this.getCore() },
parent
);
}This enables the Dashboard's "Add from library" flow — users can select from previously saved widget configurations.
getInput$() and re-render when timeRange, filters, or query changedistinctUntilChanged() on input subscriptions, memoize derived valuesUI Actions is Kibana's inter-plugin communication system. It connects user interactions (clicks, selections, context menus) to executable behaviors — across plugin boundaries. Dashboard panels use it for context menus, drilldowns, click handlers, and panel badges.
When to use UI Actions:
Trigger (event) → Action (handler)
↑ ↑
Fired by Registered by
any plugin any plugin
Example:
CONTEXT_MENU_TRIGGER → OpenInMyPluginAction
VALUE_CLICK_TRIGGER → FilterByValueAction
MY_CUSTOM_TRIGGER → ShowDetailFlyoutActionA Trigger is an event type (e.g. "user clicked a value"). An Action is a handler (e.g. "open a flyout"). Multiple actions can attach to one trigger. Multiple triggers can fire one action.
// public/triggers/my_row_click_trigger.ts
import { Trigger } from '@kbn/ui-actions-plugin/public';
export const MY_PLUGIN_ROW_CLICK_TRIGGER = 'MY_PLUGIN_ROW_CLICK_TRIGGER';
export const myRowClickTrigger: Trigger = {
id: MY_PLUGIN_ROW_CLICK_TRIGGER,
title: 'My Plugin Row Click',
description: 'Fires when a user clicks a row in My Plugin tables.',
};Register in setup:
// public/plugin.ts
import { myRowClickTrigger, MY_PLUGIN_ROW_CLICK_TRIGGER } from './triggers/my_row_click_trigger';
public setup(core: CoreSetup, { uiActions }: MyPluginSetupDeps) {
uiActions.registerTrigger(myRowClickTrigger);
}Fire the trigger from your component:
// In a React component
const { uiActions } = useKibana().services;
const handleRowClick = (item: MyItem) => {
uiActions.getTrigger(MY_PLUGIN_ROW_CLICK_TRIGGER).exec({
itemId: item.id,
itemType: item.type,
indexPattern: item.indexPattern,
});
};// public/actions/open_detail_action.ts
import { Action, createAction } from '@kbn/ui-actions-plugin/public';
import { CoreStart } from '@kbn/core/public';
export const OPEN_DETAIL_ACTION = 'MY_PLUGIN_OPEN_DETAIL_ACTION';
interface OpenDetailContext {
itemId: string;
itemType: string;
}
export function createOpenDetailAction(getCore: () => CoreStart): Action<OpenDetailContext> {
return createAction<OpenDetailContext>({
id: OPEN_DETAIL_ACTION,
type: OPEN_DETAIL_ACTION,
getDisplayName: () => 'Open in My Plugin',
getIconType: () => 'inspect',
// Only show when context has the right shape
isCompatible: async (context: OpenDetailContext) => {
return Boolean(context.itemId && context.itemType === 'config');
},
// What happens when the action executes
execute: async (context: OpenDetailContext) => {
const core = getCore();
core.application.navigateToApp('myPlugin', {
path: `/detail/${context.itemId}`,
});
},
// Optional: enables "open in new tab"
getHref: async (context: OpenDetailContext) => {
return `/app/myPlugin#/detail/${context.itemId}`;
},
});
}// public/actions/show_flyout_action.tsx
import React from 'react';
import { Action } from '@kbn/ui-actions-plugin/public';
import { CoreStart } from '@kbn/core/public';
import { toMountPoint } from '@kbn/react-kibana-mount';
export const SHOW_FLYOUT_ACTION = 'MY_PLUGIN_SHOW_FLYOUT_ACTION';
interface FlyoutContext {
embeddable?: { type: string };
data?: { id: string; title: string };
}
export class ShowFlyoutAction implements Action<FlyoutContext> {
public readonly id = SHOW_FLYOUT_ACTION;
public readonly type = SHOW_FLYOUT_ACTION;
public readonly order = 100; // Higher = appears earlier in menus
constructor(private readonly getCore: () => CoreStart) {}
public getDisplayName(): string {
return 'Show details';
}
public getIconType(): string {
return 'eye';
}
public async isCompatible(context: FlyoutContext): Promise<boolean> {
// Only show for specific embeddable types or data conditions
return context.data?.id !== undefined;
}
public async execute(context: FlyoutContext): Promise<void> {
const core = this.getCore();
const flyoutSession = core.overlays.openFlyout(
toMountPoint(
<DetailFlyout
itemId={context.data!.id}
title={context.data!.title}
http={core.http}
onClose={() => flyoutSession.close()}
/>
),
{
size: 'm',
'data-test-subj': 'myPluginDetailFlyout',
ownFocus: true,
}
);
}
}// public/plugin.ts
import { UiActionsSetup } from '@kbn/ui-actions-plugin/public';
import { CONTEXT_MENU_TRIGGER, VALUE_CLICK_TRIGGER } from '@kbn/ui-actions-plugin/public';
import { createOpenDetailAction, OPEN_DETAIL_ACTION } from './actions/open_detail_action';
import { ShowFlyoutAction, SHOW_FLYOUT_ACTION } from './actions/show_flyout_action';
import { myRowClickTrigger, MY_PLUGIN_ROW_CLICK_TRIGGER } from './triggers/my_row_click_trigger';
export class MyPlugin {
private coreStart: CoreStart | undefined;
public setup(core: CoreSetup, { uiActions }: { uiActions: UiActionsSetup }) {
// Register custom trigger
uiActions.registerTrigger(myRowClickTrigger);
// Register actions
const openDetailAction = createOpenDetailAction(() => this.coreStart!);
const showFlyoutAction = new ShowFlyoutAction(() => this.coreStart!);
uiActions.registerAction(openDetailAction);
uiActions.registerAction(showFlyoutAction);
// Attach actions to triggers
// "Open in My Plugin" appears in dashboard panel context menus
uiActions.attachAction(CONTEXT_MENU_TRIGGER, OPEN_DETAIL_ACTION);
// Show flyout when clicking a value in a visualization
uiActions.attachAction(VALUE_CLICK_TRIGGER, SHOW_FLYOUT_ACTION);
// Both actions available on custom row click
uiActions.attachAction(MY_PLUGIN_ROW_CLICK_TRIGGER, OPEN_DETAIL_ACTION);
uiActions.attachAction(MY_PLUGIN_ROW_CLICK_TRIGGER, SHOW_FLYOUT_ACTION);
}
public start(core: CoreStart) {
this.coreStart = core;
}
}The most common use case — adding an item to the "..." menu on dashboard panels:
import { CONTEXT_MENU_TRIGGER } from '@kbn/ui-actions-plugin/public';
import { isFilterableEmbeddable } from '@kbn/embeddable-plugin/public';
const analyzeAction = createAction({
id: 'MY_PLUGIN_ANALYZE_PANEL',
type: 'MY_PLUGIN_ANALYZE_PANEL',
getDisplayName: () => 'Analyze in My Plugin',
getIconType: () => 'inspect',
order: 50,
isCompatible: async ({ embeddable }) => {
// Only show for visualization panels, not saved searches
return embeddable?.type === 'visualization' || embeddable?.type === 'lens';
},
execute: async ({ embeddable }) => {
const input = embeddable.getInput();
const filters = input.filters || [];
const timeRange = input.timeRange;
// Navigate to your plugin with the panel's context
core.application.navigateToApp('myPlugin', {
path: `/analyze?filters=${encodeURIComponent(JSON.stringify(filters))}&timeRange=${encodeURIComponent(JSON.stringify(timeRange))}`,
});
},
});
uiActions.registerAction(analyzeAction);
uiActions.attachAction(CONTEXT_MENU_TRIGGER, analyzeAction.id);React to clicks on visualization values:
import { VALUE_CLICK_TRIGGER } from '@kbn/ui-actions-plugin/public';
const filterByValueAction = createAction({
id: 'MY_PLUGIN_FILTER_BY_VALUE',
type: 'MY_PLUGIN_FILTER_BY_VALUE',
getDisplayName: () => 'Filter by this value',
getIconType: () => 'filter',
isCompatible: async (context) => {
return Boolean(context.data?.data?.length);
},
execute: async (context) => {
const { data } = context;
// data.data contains the click point information
// Use data plugin to create filters from the click
const filters = await dataPlugin.actions.createFiltersFromValueClickAction({
data: data.data,
});
// Apply filters to the dashboard
dataPlugin.query.filterManager.addFilters(filters);
},
});true for everything clutter every context menuisCompatible() logicKibana's Expressions framework is a pipeline-based execution engine that powers Canvas, Lens, and Dashboard visualizations. Expression functions are composable, stateless transformations that chain together: essql 'SELECT * FROM logs' | mapColumn name='status_label' expression={...} | render type='my_chart'.
When to use Expressions:
When NOT to use Expressions:
An expression function takes an input, applies arguments, and produces an output:
// common/expressions/my_metric.ts
import { ExpressionFunctionDefinition } from '@kbn/expressions-plugin/common';
import { Datatable } from '@kbn/expressions-plugin/common';
interface MyMetricArguments {
column: string;
operation: 'avg' | 'sum' | 'min' | 'max' | 'count';
decimals: number;
}
interface MyMetricResult {
type: 'my_metric_result';
value: number;
label: string;
operation: string;
}
export const myMetricFunction: ExpressionFunctionDefinition<
'my_metric', // Function name
Datatable, // Input type
MyMetricArguments, // Arguments
MyMetricResult // Output type
> = {
name: 'my_metric',
type: 'my_metric_result',
inputTypes: ['datatable'],
help: 'Calculates a metric from a datatable column.',
args: {
column: {
types: ['string'],
help: 'Column name to aggregate.',
required: true,
},
operation: {
types: ['string'],
help: 'Aggregation operation.',
default: 'avg',
options: ['avg', 'sum', 'min', 'max', 'count'],
},
decimals: {
types: ['number'],
help: 'Decimal places in the result.',
default: 2,
},
},
fn(input: Datatable, args: MyMetricArguments): MyMetricResult {
const values = input.rows
.map((row) => row[args.column])
.filter((v): v is number => typeof v === 'number');
let value: number;
switch (args.operation) {
case 'sum':
value = values.reduce((a, b) => a + b, 0);
break;
case 'min':
value = Math.min(...values);
break;
case 'max':
value = Math.max(...values);
break;
case 'count':
value = values.length;
break;
case 'avg':
default:
value = values.length > 0 ? values.reduce((a, b) => a + b, 0) / values.length : 0;
break;
}
return {
type: 'my_metric_result',
value: Number(value.toFixed(args.decimals)),
label: `${args.operation}(${args.column})`,
operation: args.operation,
};
},
};Register the function:
// In server/plugin.ts AND/OR public/plugin.ts
import { myMetricFunction } from '../common/expressions/my_metric';
public setup(core: CoreSetup, { expressions }: MyPluginSetupDeps) {
expressions.registerFunction(myMetricFunction);
}Functions defined in common/ can be registered on both server and browser — the server execution is used for Canvas server-side rendering and reporting.
Renderers take a render config and mount a visualization into a DOM node:
// public/expression_renderers/my_chart_renderer.tsx
import React from 'react';
import ReactDOM from 'react-dom';
import { ExpressionRenderDefinition } from '@kbn/expressions-plugin/common';
import { MyChartComponent } from './my_chart_component';
interface MyChartConfig {
type: 'my_chart_config';
data: Array<{ label: string; value: number }>;
colorScheme: string;
showLegend: boolean;
}
export const myChartRenderer: ExpressionRenderDefinition<MyChartConfig> = {
name: 'my_chart',
displayName: 'My Chart',
help: 'Renders a custom chart visualization.',
reuseDomNode: true, // Reuse DOM node on re-render (better performance)
render(domNode: HTMLElement, config: MyChartConfig, handlers) {
// handlers.done() MUST be called when rendering is complete
// handlers.onDestroy() registers cleanup logic
handlers.onDestroy(() => {
ReactDOM.unmountComponentAtNode(domNode);
});
ReactDOM.render(
<MyChartComponent
data={config.data}
colorScheme={config.colorScheme}
showLegend={config.showLegend}
onRenderComplete={() => handlers.done()}
/>,
domNode
);
},
};Register:
// public/plugin.ts — renderers are browser-only
public setup(core: CoreSetup, { expressions }: MyPluginSetupDeps) {
expressions.registerRenderer(myChartRenderer);
}A render function converts processed data into a render config that a renderer can display:
// common/expressions/my_chart_render.ts
import { ExpressionFunctionDefinition } from '@kbn/expressions-plugin/common';
interface MyChartRenderArgs {
colorScheme: string;
showLegend: boolean;
}
interface MyChartRenderConfig {
type: 'render';
as: 'my_chart'; // Must match the renderer name
value: {
type: 'my_chart_config';
data: Array<{ label: string; value: number }>;
colorScheme: string;
showLegend: boolean;
};
}
export const myChartRenderFunction: ExpressionFunctionDefinition<
'my_chart_render',
{ type: 'my_metric_result'; value: number; label: string } | any,
MyChartRenderArgs,
MyChartRenderConfig
> = {
name: 'my_chart_render',
type: 'render',
inputTypes: ['my_metric_result', 'datatable'],
help: 'Prepares data for the my_chart renderer.',
args: {
colorScheme: {
types: ['string'],
help: 'Color scheme name.',
default: 'default',
},
showLegend: {
types: ['boolean'],
help: 'Whether to show the legend.',
default: true,
},
},
fn(input, args): MyChartRenderConfig {
// Transform input into the renderer's expected config
let data: Array<{ label: string; value: number }>;
if (input.type === 'my_metric_result') {
data = [{ label: input.label, value: input.value }];
} else {
// Assume datatable
data = input.rows.map((row: any) => ({
label: String(row.label || ''),
value: Number(row.value || 0),
}));
}
return {
type: 'render',
as: 'my_chart', // References the renderer name
value: {
type: 'my_chart_config',
data,
colorScheme: args.colorScheme,
showLegend: args.showLegend,
},
};
},
};If your functions use a custom data shape, register it as an expression type:
// common/expressions/my_metric_result_type.ts
import { ExpressionTypeDefinition } from '@kbn/expressions-plugin/common';
export const myMetricResultType: ExpressionTypeDefinition<
'my_metric_result',
{ type: 'my_metric_result'; value: number; label: string; operation: string }
> = {
name: 'my_metric_result',
validate: (value) => {
if (typeof value.value !== 'number') {
throw new Error('my_metric_result must have a numeric value');
}
},
// Convert from other types
from: {
number: (value: number) => ({
type: 'my_metric_result' as const,
value,
label: 'value',
operation: 'raw',
}),
},
// Convert to other types
to: {
number: (result) => result.value,
datatable: (result) => ({
type: 'datatable' as const,
columns: [
{ id: 'label', name: 'Label', meta: { type: 'string' } },
{ id: 'value', name: 'Value', meta: { type: 'number' } },
],
rows: [{ label: result.label, value: result.value }],
}),
},
};Register:
expressions.registerType(myMetricResultType);The datatable type is the standard interchange format — most functions consume and produce datatables:
interface Datatable {
type: 'datatable';
columns: DatatableColumn[];
rows: DatatableRow[];
}
interface DatatableColumn {
id: string; // Unique column identifier
name: string; // Display name
meta: {
type: 'number' | 'string' | 'boolean' | 'date' | 'null' | 'unknown';
field?: string; // Source ES field name
index?: string; // Source index
params?: { // Formatter parameters
id?: string; // Formatter ID (e.g. 'number', 'bytes', 'date')
params?: Record<string, unknown>;
};
source?: string; // Which expression produced this column
sourceParams?: Record<string, unknown>;
};
}
type DatatableRow = Record<string, unknown>;Common operations on datatables:
// Filter rows
const filtered = {
...input,
rows: input.rows.filter((row) => row.status === 'active'),
};
// Add a column
const withColumn = {
...input,
columns: [...input.columns, { id: 'new_col', name: 'New Column', meta: { type: 'string' } }],
rows: input.rows.map((row) => ({
...row,
new_col: computeValue(row),
})),
};
// Sort rows
const sorted = {
...input,
rows: [...input.rows].sort((a, b) =>
(a[sortField] as number) - (b[sortField] as number)
),
};Once registered, your functions and renderers can be used in Canvas expressions:
essql "SELECT category, AVG(response_time) as avg_time FROM logs GROUP BY category"
| my_metric column="avg_time" operation="max"
| my_chart_render colorScheme="warm" showLegend=trueOr via the expression editor in Canvas workpads.
Run expressions from your plugin code:
// In a React component
const { expressions } = useKibana().services;
const result = await expressions
.execute('my_metric', null, {
column: 'response_time',
operation: 'avg',
})
.getData();Or using the expression loader for rendering:
import { ReactExpressionRenderer } from '@kbn/expressions-plugin/public';
<ReactExpressionRenderer
expression='essql "SELECT * FROM logs" | my_metric column="value" | my_chart_render'
onData$={(data) => console.log('Expression result:', data)}
onRenderError={(error) => console.error('Render error:', error)}
/>Kibana has multiple state management layers that plugins can use. The right choice depends on where the state lives, how long it persists, and whether it needs to be shareable via URL.
State lifecycle in Kibana:
Kibana's @kbn/kibana-utils-plugin provides utilities to sync application state with the URL. This is how dashboards preserve filters, time range, and panel layout in the URL.
// public/state/use_url_state_sync.ts
import {
createStateContainer,
syncState,
createKbnUrlStateStorage,
IKbnUrlStateStorage,
} from '@kbn/kibana-utils-plugin/public';
import { CoreStart } from '@kbn/core/public';
import { History } from 'history';
interface MyAppState {
selectedTab: string;
viewMode: 'table' | 'grid' | 'detail';
sortField: string;
sortDirection: 'asc' | 'desc';
pageIndex: number;
}
const defaultState: MyAppState = {
selectedTab: 'overview',
viewMode: 'table',
sortField: 'updated_at',
sortDirection: 'desc',
pageIndex: 0,
};
export function setupUrlStateSync(history: History) {
// 1. Create a state container (observable store)
const stateContainer = createStateContainer<MyAppState>(defaultState);
// 2. Create URL storage that reads/writes the URL hash
const kbnUrlStateStorage = createKbnUrlStateStorage({
useHash: false, // Use query params (readable) vs hash (compact)
history,
});
// 3. Sync the state container ↔ URL
const { start, stop } = syncState({
storageKey: '_a', // URL key: ?_a=(selectedTab:overview,viewMode:table,...)
stateContainer: {
get: () => stateContainer.get(),
set: (state) => stateContainer.set(state),
state$: stateContainer.state$,
},
stateStorage: kbnUrlStateStorage,
});
// 4. Start syncing — reads initial state from URL if present
start();
// 5. If URL had no state, push defaults to URL
if (!kbnUrlStateStorage.get('_a')) {
kbnUrlStateStorage.set('_a', defaultState);
}
return {
stateContainer,
stop, // Call this in your app's unmount function
};
}Use in a React component:
// public/app.tsx
import React, { useEffect, useState } from 'react';
import { useHistory } from 'react-router-dom';
import { setupUrlStateSync } from './state/use_url_state_sync';
export const MyApp: React.FC = () => {
const history = useHistory();
const [appState, setAppState] = useState(null);
useEffect(() => {
const { stateContainer, stop } = setupUrlStateSync(history);
// Subscribe to state changes
const sub = stateContainer.state$.subscribe((state) => {
setAppState({ ...state });
});
// Initial state
setAppState(stateContainer.get());
return () => {
sub.unsubscribe();
stop();
};
}, [history]);
if (!appState) return null;
const handleTabChange = (tab: string) => {
stateContainer.set({ ...stateContainer.get(), selectedTab: tab });
};
// URL automatically updates when stateContainer changes
return <MyContent state={appState} onTabChange={handleTabChange} />;
};Dashboard-level state (time range, filters, refresh interval) is managed by the data plugin. Access it through the query service:
// Reading global state
const { data } = useKibana().services;
// Time range
const timeRange = data.query.timefilter.timefilter.getTime();
// { from: 'now-15m', to: 'now' }
// Filters
const filters = data.query.filterManager.getFilters();
// Query (KQL or Lucene)
const query = data.query.queryString.getQuery();
// { language: 'kuery', query: 'status: active' }
// Refresh interval
const refreshInterval = data.query.timefilter.timefilter.getRefreshInterval();
// { pause: false, value: 5000 }// Subscribing to global state changes
useEffect(() => {
const timeSub = data.query.timefilter.timefilter.getTimeUpdate$().subscribe(() => {
const newTimeRange = data.query.timefilter.timefilter.getTime();
fetchData(newTimeRange);
});
const filterSub = data.query.filterManager.getUpdates$().subscribe(() => {
const newFilters = data.query.filterManager.getFilters();
fetchData(undefined, newFilters);
});
return () => {
timeSub.unsubscribe();
filterSub.unsubscribe();
};
}, [data.query]);// Writing global state
data.query.timefilter.timefilter.setTime({ from: 'now-1h', to: 'now' });
data.query.filterManager.addFilters([{
meta: { alias: null, disabled: false, negate: false },
query: { match_phrase: { status: 'error' } },
}]);
data.query.queryString.setQuery({ language: 'kuery', query: 'host.name: "web-01"' });State containers are lightweight observable stores. Use them for plugin-internal state that doesn't need URL sync:
import { createStateContainer } from '@kbn/kibana-utils-plugin/public';
interface PluginState {~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.