Creating your own Dashboard Chart plugin

You can create your own Dashboard Chart plugin to add a new analytics widget to the dashboard.

Conditions:

  • The new Dashboard Chart class must implement the "DashboardChart" interface.
  • The new Dashboard Chart class must be declared under the package "com.openkm.plugin.dashboard".
  • The new Dashboard Chart class must be annotated with "@PluginImplementation".
  • The new Dashboard Chart class must extend "BasePlugin".

DashboardChart interface:

package com.openkm.plugin.dashboard;

import com.openkm.core.DatabaseException;
import com.openkm.core.RepositoryException;
import net.xeoh.plugins.base.Plugin;

import java.util.Calendar;
import java.util.List;

public interface DashboardChart extends Plugin {

	enum ChartType {
		BAR, BAR_HORIZONTAL, PIE, LINE, TABLE, NESTED_TABLE
	}

	enum Frequency {
		DAILY, WEEKLY, MONTHLY, NEVER
	}

	// Filter key used when the chart has no user-selectable filters
	String DEFAULT_FILTER = "DEFAULT";

	// Common filter keys for a week/month/year breakdown
	String WEEK = "WEEK";
	String MONTH = "MONTH";
	String YEAR = "YEAR";

	String getName();

	String getTitle();

	String getDescription();

	ChartType getChartType();

	Frequency getFrequency();

	int getOrder();

	default int getColumnWidth() {
		return 6;
	}

	default boolean isMultiChart() {
		return false;
	}

	default List<String> getChartInstances() throws DatabaseException {
		return List.of();
	}

	default List<String> getChartInstances(String parent) throws DatabaseException {
		return getChartInstances();
	}

	default String getMultipleLabel() {
		return null;
	}

	default List<String> getFilters() {
		return List.of();
	}

	default List<String> getColumnNames() {
		return List.of();
	}

	default boolean isRefreshEnabled() {
		return false;
	}

	default List<String> getPeriods(String filter, String parent) throws DatabaseException {
		return List.of();
	}

	default boolean isHistoryEnabled() {
		return false;
	}

	void execute() throws RepositoryException, DatabaseException;

	default List<ChartSeries> execute(Calendar from, Calendar to) throws RepositoryException, DatabaseException {
		return List.of();
	}

	default void rebuild() throws RepositoryException, DatabaseException {
	}

	List<ChartSeries> getData(String filter) throws DatabaseException;

	default List<ChartSeries> getData(String filter, String period) throws DatabaseException {
		return getData(filter);
	}

	default List<ChartSeries> getData(String filter, String period, String instance) throws DatabaseException {
		return getData(filter, period);
	}
}

The new class must be loaded into the package com.openkm.plugin.dashboard because the application plugin system will try to load it from there.

Do not miss the tag @PluginImplementation otherwise the application plugin system will not be able to retrieve the new class.

More information about Register a new plugin.

Method descriptions

MethodTypeDescription

getName()

String

Internal identifier, also reused internally by some plugins (e.g. as a persisted series name) - not necessarily meant to be shown as-is.

getTitle()

String

User-facing title shown in the widget header.

getDescription()

String

User-facing description shown in the widget's info tooltip.

getChartType()

ChartType

The widget type used to render this chart. See "Chart types" below.

getFrequency()

Frequency

How often the scheduled job calls execute() to recompute and persist this chart. See "Frequency" below.

getOrder()

int

Sort order among the other charts in the dashboard's plugin list, ascending - lower first.

getColumnWidth()

int

Bootstrap grid width this widget occupies, out of 12. Defaults to 6 (half a row).

isMultiChart()

boolean

When true, this plugin is rendered as one independent card per getChartInstances() entry instead of a single card. Defaults to false.

getChartInstances()

List<String>

The distinct instances this chart is currently split into - meaningful only when isMultiChart() or getMultipleLabel() is set. Empty by default.

getChartInstances(parent)

List<String>

Same as getChartInstances(), scoped one level down by parent - only meaningful for ChartType.NESTED_TABLE. Defaults to getChartInstances().

getMultipleLabel()

String

Label for a dropdown that switches between getChartInstances() within a single card, instead of one card per instance. Null (no dropdown) by default.

getFilters()

List<String>

Filter keys shown as buttons in the widget header (e.g. WEEK/MONTH/YEAR). Empty by default.

getColumnNames()

List<String>

Column headers for a ChartType.TABLE chart. Empty by default.

isRefreshEnabled()

boolean

Whether the UI offers a manual "refresh now" icon that calls execute() on demand. Defaults to false.

getPeriods(filter, parent)

List<String>

Period keys one level below parent, backing the year/month/week history drill-down. Empty by default.

isHistoryEnabled()

boolean

Whether the UI offers the history drill-down on top of the plain filter switcher. Defaults to false.

execute()

void

Computes every filter and persists the result. Called by the scheduled job.

execute(from, to)

List<ChartSeries>

Computes this chart's data for one specific historical window, independent of the scheduled job. No-op by default.

rebuild()

void

Wipes and fully recomputes this chart's persisted history via execute(from, to). No-op by default.

getData(filter)

List<ChartSeries>

This chart's series for one filter, served live to the UI on every request.

getData(filter, period)

List<ChartSeries>

Same as getData(filter), for one historical period. Defaults to getData(filter).

getData(filter, period, instance)

List<ChartSeries>

Same as getData(filter, period), scoped to one getChartInstances() entry. Defaults to getData(filter, period).

Chart types

ValueDescription

BAR

Vertical bar chart.

BAR_HORIZONTAL

Same data as BAR, drawn with categories on the Y axis - better suited to a ranking whose labels are long or variable-width (e.g. usernames).

PIE

Pie chart. Rendered as a single normal pie for one series, or as several compact mini-pies when getData() returns more than one series.

LINE

Line chart, one line per series.

TABLE

Plain data table, with columns from getColumnNames().

NESTED_TABLE

A TABLE always split by getChartInstances() (like isMultiChart), where each resulting card also carries its own getMultipleLabel() dropdown scoped by getChartInstances(parent) - e.g. one card per workflow, each with a dropdown to pick which of that workflow's tasks to show.

Frequency

ValueDescription

DAILY / WEEKLY / MONTHLY

How often the scheduled job calls execute() to recompute and persist this chart.

NEVER

The scheduled job never calls execute() for this chart; getData() is expected to compute the result live on every request instead.

Testing every combination with SampleChart

SampleChart ships as a test fixture only (getOrder() 999, never assigned to a real profile). It is entirely driven by chart.sample_chart.* OKM_CONFIG keys, so every UI combination a widget can be in can be exercised just by changing a key and reloading the dashboard - no code change or redeploy needed.

KeyTypeDefault

chart.sample_chart.type

string

"pie" (also: "bar", "bar_horizontal", "line", "table", "nested_table")

chart.sample_chart.multipart

bool

false

chart.sample_chart.multiple_label

string

empty (no-op); any non-empty text enables dropdown mode

chart.sample_chart.series_count

int

1

chart.sample_chart.filters_enabled

bool

false

chart.sample_chart.history_enabled

bool

false

chart.sample_chart.refresh_enabled

bool

false

Two combinations are excluded from every matrix below because they have no distinct, observable effect:

  • history_enabled=true with filters_enabled=false: the history button lives nested inside the same header block as the filter buttons, so without visible filters the result is identical to having it disabled.
  • multipart=true with a non-empty multiple_label: multipleLabel takes priority over isMultiChart, so with both set there is no split by instance at all - only the single card with a dropdown, identical to leaving multipart=false.

PIE (chart.sample_chart.type=pie)

Categories: the 4 fruit names ("Apples", "Pears", "Bananas", "Grapes").

#multipartmultiple_labelseries_countfilters_enabledhistory_enabledrefresh_enabledWhat it verifies
1falseempty1falsefalsefalseBase case: a single card, a plain pie with a side legend.
2falseempty3falsefalsefalseSeveral series in one card ? compact mode (mini-pies in a row, legend below).
3trueempty1falsefalsefalseisMultiChart: 3 separate cards (one per instance), each with a plain pie.
4trueempty3falsefalsefalseisMultiChart + compact mode combined: 3 cards, each with its own mini-pies.
5falseInstance1falsefalsefalsemultipleLabel: a single card with a dropdown on top; changing the selection recomputes the pie.
6falseInstance3falsefalsefalsemultipleLabel + compact mode: one card, dropdown on top, mini-pies inside.
7falseempty1truefalsefalseWEEK/MONTH/YEAR filter buttons visible in the header, no history.
8falseempty1truetruefalseFilter + history button + drill-down (year/month/week selects).
9trueempty1truetruefalseMulti-card + filter + history combined: each card has its own selector, independent of the rest.
10falseempty1falsefalsetrueOnly the refresh icon visible in the header.
11trueempty3truetruetrueMaximum meaningful combination: multi-card + compact mode + filter + history + refresh, all at once.

BAR / BAR_HORIZONTAL (chart.sample_chart.type=bar / bar_horizontal)

Both types share the same Vue component under the hood; the only difference is orientation, so the combination matrix is identical for both - only the type value changes.

series_count has no visible effect here, so it doesn't appear as a column: unlike PIE (mini-pies) and LINE (several lines), the bar widget only reads the first series and discards the rest - it does not support grouped bars from several series.

Categories: the 4 fruit names, sorted by descending value.

#multipartmultiple_labelfilters_enabledhistory_enabledrefresh_enabledWhat it verifies
1falseemptyfalsefalsefalseBase case: a single card, vertical bars (or horizontal with bar_horizontal).
2trueemptyfalsefalsefalseisMultiChart: 3 separate cards, one per instance.
3falseInstancefalsefalsefalsemultipleLabel: a single card with a dropdown on top.
4falseemptytruefalsefalseWEEK/MONTH/YEAR filter buttons visible in the header.
5falseemptytruetruefalseFilter + history button + drill-down.
6trueemptytruetruefalseMulti-card + filter + history combined.
7falseemptyfalsefalsetrueOnly the refresh icon visible.
8trueemptytruetruetrueMaximum meaningful combination: multi-card + filter + history + refresh.

LINE (chart.sample_chart.type=line)

Unlike PIE/BAR, the categories here are not fruit: SampleChart detects type=line and uses 7 consecutive days instead, so the line reads as a trend rather than connecting fruit names in alphabetical order.

#multipartmultiple_labelseries_countfilters_enabledhistory_enabledrefresh_enabledWhat it verifies
1falseempty1falsefalsefalseBase case: a single card, one line with the last 7 days.
2falseempty3falsefalsefalseSeveral series in one card ? 3 overlaid lines with a legend on top.
3trueempty1falsefalsefalseisMultiChart: 3 separate cards, each with one line.
4trueempty3falsefalsefalseisMultiChart + several series combined: 3 cards, each with 3 lines.
5falseInstance1falsefalsefalsemultipleLabel: a single card with a dropdown on top; changing the selection recomputes the line.
6falseInstance3falsefalsefalsemultipleLabel + several series: one card, dropdown on top, 3 lines inside.
7falseempty1truefalsefalseWEEK/MONTH/YEAR filter buttons visible in the header, no history.
8falseempty1truetruefalseFilter + history button + drill-down (year/month/week selects).
9trueempty1truetruefalseMulti-card + filter + history combined.
10falseempty1falsefalsetrueOnly the refresh icon visible in the header.
11trueempty3truetruetrueMaximum meaningful combination: multi-card + several series + filter + history + refresh.

TABLE (chart.sample_chart.type=table)

Supports everything PIE/BAR/LINE do, except several series per card. Since a table never shows a series name anywhere, SampleChart suffixes each row with the resolved instance for this type only - "Apples ? North region" instead of just "Apples" - so the instance value (from either multipart or multiple_label) is visibly confirmed to reach getData().

#multipartmultiple_labelfilters_enabledhistory_enabledrefresh_enabledWhat it verifies
1falseemptyfalsefalsefalseBase case: a single card, "Fruit" rows with no suffix (no active instance).
2trueemptyfalsefalsefalseisMultiChart: 3 separate cards, rows suffixed with their own instance in each.
3falseInstancefalsefalsefalsemultipleLabel: one card with a dropdown; changing the selection changes the row suffix.
4falseemptytruefalsefalseWEEK/MONTH/YEAR filter buttons visible in the header.
5falseemptytruetruefalseFilter + history button + drill-down.
6trueemptytruetruefalseMulti-card + filter + history combined.
7falseemptyfalsefalsetrueOnly the refresh icon visible.
8trueemptytruetruetrueMaximum meaningful combination: multi-card + filter + history + refresh.

NESTED_TABLE (chart.sample_chart.type=nested_table)

A distinct type, not a combination of flags on top of TABLE. It is the only case where the outer split and the inner dropdown combine - and they always do, regardless of multipart, which is ignored entirely for this type.

It uses its own two-level invented dataset, independent of the fruit/instance one used above: 2 workflows ("Contract approval", "Expense report"), each with its own 2-3 tasks, and 3 users (Alice, Bob, Carol) as rows. Set chart.sample_chart.multiple_label=Task so the inner dropdown has a label to identify it by.

#multiple_labelWhat it verifies
1Task2 separate cards (one per workflow); each with its own dropdown showing only that workflow's own tasks (not the other workflow's); changing the selection recomputes the users table for that specific workflow + task.

If a card's dropdown shows tasks mixed in from the other workflow, the bug is that parent is not reaching getChartInstances(parent) for your plugin.

Example

SampleChart class:

package com.openkm.plugin.dashboard;

import com.openkm.core.DatabaseException;
import com.openkm.db.service.ConfigSrv;
import com.openkm.plugin.BasePlugin;
import lombok.extern.slf4j.Slf4j;
import net.xeoh.plugins.base.annotations.PluginImplementation;
import org.springframework.beans.factory.annotation.Autowired;

import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;

/**
 * Throwaway test fixture, not a real metric - invented data entirely driven by the chart.sample_chart.*
 * OKM_CONFIG keys, to exercise every UI combination a widget can be in without touching code or
 * redeploying. See "Testing every combination with SampleChart" above for the full list of keys and
 * combinations.
 */
@Slf4j
@PluginImplementation
public class SampleChart extends BasePlugin implements DashboardChart {
	private static final String TYPE_CONFIG_KEY = "chart.sample_chart.type";
	private static final String MULTI_CHART_CONFIG_KEY = "chart.sample_chart.multipart";
	private static final String MULTIPLE_LABEL_CONFIG_KEY = "chart.sample_chart.multiple_label";
	private static final String SERIES_COUNT_CONFIG_KEY = "chart.sample_chart.series_count";
	private static final String FILTERS_ENABLED_CONFIG_KEY = "chart.sample_chart.filters_enabled";
	private static final String HISTORY_ENABLED_CONFIG_KEY = "chart.sample_chart.history_enabled";
	private static final String REFRESH_ENABLED_CONFIG_KEY = "chart.sample_chart.refresh_enabled";

	private static final List<String> INSTANCES = List.of("North region", "South region", "East region");
	private static final List<String> FRUITS = List.of("Apples", "Pears", "Bananas", "Grapes");
	private static final List<String> YEARS = List.of("2024", "2025", "2026");

	// NESTED_TABLE's own two-level invented dataset (outer workflow -> inner tasks)
	private static final List<String> WORKFLOWS = List.of("Contract approval", "Expense report");
	private static final Map<String, List<String>> TASKS_BY_WORKFLOW = Map.of(
			"Contract approval", List.of("Legal review", "Manager sign-off", "Archive"),
			"Expense report", List.of("Manager approval", "Finance check"));
	private static final List<String> USERS = List.of("Alice", "Bob", "Carol");

	private static final int LINE_DAYS = 7;

	@Autowired
	private ConfigSrv configSrv;

	@Override
	public String getName() {
		return "Sample chart (test)";
	}

	@Override
	public String getTitle() {
		return "Sample chart (test)";
	}

	@Override
	public String getDescription() {
		return "Throwaway fixture with invented data, entirely driven by chart.sample_chart.* OKM_CONFIG keys.";
	}

	@Override
	public ChartType getChartType() {
		return switch (configString(TYPE_CONFIG_KEY, "pie").toLowerCase()) {
			case "bar" -> ChartType.BAR;
			case "bar_horizontal" -> ChartType.BAR_HORIZONTAL;
			case "line" -> ChartType.LINE;
			case "table" -> ChartType.TABLE;
			case "nested_table" -> ChartType.NESTED_TABLE;
			default -> ChartType.PIE;
		};
	}

	@Override
	public Frequency getFrequency() {
		return Frequency.NEVER;
	}

	@Override
	public int getOrder() {
		return 999;
	}

	@Override
	public boolean isMultiChart() {
		return configBoolean(MULTI_CHART_CONFIG_KEY, false);
	}

	@Override
	public List<String> getChartInstances() {
		return getChartType() == ChartType.NESTED_TABLE ? WORKFLOWS : INSTANCES;
	}

	@Override
	public List<String> getChartInstances(String parent) {
		if (getChartType() == ChartType.NESTED_TABLE && parent != null) {
			return TASKS_BY_WORKFLOW.getOrDefault(parent, List.of());
		}

		return getChartInstances();
	}

	@Override
	public String getMultipleLabel() {
		String label = configString(MULTIPLE_LABEL_CONFIG_KEY, "");
		return label.isBlank() ? null : label;
	}

	@Override
	public List<String> getFilters() {
		return configBoolean(FILTERS_ENABLED_CONFIG_KEY, false) ? List.of(WEEK, MONTH, YEAR) : List.of();
	}

	@Override
	public boolean isHistoryEnabled() {
		return configBoolean(HISTORY_ENABLED_CONFIG_KEY, false);
	}

	@Override
	public List<String> getPeriods(String filter, String parent) {
		if (!isHistoryEnabled()) {
			return List.of();
		}

		return switch (filter) {
			case YEAR -> YEARS;
			case MONTH -> parent != null ? monthsOf(parent) : List.of();
			case WEEK -> parent != null ? weeksOf(parent) : List.of();
			default -> List.of();
		};
	}

	@Override
	public boolean isRefreshEnabled() {
		return configBoolean(REFRESH_ENABLED_CONFIG_KEY, false);
	}

	@Override
	public List<String> getColumnNames() {
		return getChartType() == ChartType.NESTED_TABLE ? List.of("User", "Average time (invented)") : List.of("Fruit", "Sales");
	}

	@Override
	public void execute() {
		// Never called - see getFrequency()
	}

	@Override
	public List<ChartSeries> getData(String filter) {
		return getData(filter, null, INSTANCES.get(0));
	}

	@Override
	public List<ChartSeries> getData(String filter, String period, String instance) {
		if (getChartType() == ChartType.NESTED_TABLE) {
			return nestedTableData(instance);
		}

		boolean isLine = getChartType() == ChartType.LINE;
		boolean isTable = getChartType() == ChartType.TABLE;
		List<String> categories = isLine ? lastDays(LINE_DAYS) : FRUITS;
		String baseName = isLine ? "Daily activity (invented)" : "Fruit sales (invented)";
		int seriesCount = Math.max(1, configInt(SERIES_COUNT_CONFIG_KEY, 1));
		List<ChartSeries> series = new ArrayList<>();

		for (int s = 0; s < seriesCount; s++) {
			List<ChartDataPoint> points = new ArrayList<>();

			for (String category : categories) {
				points.add(new ChartDataPoint(rowLabel(category, instance, isTable), fakeValue(instance, filter, period, s, category)));
			}

			String seriesName = seriesCount == 1 ? baseName : baseName + " - batch " + (s + 1);
			series.add(new ChartSeries(seriesName, points));
		}

		return series;
	}

	// instance arrives as "workflow::task" - see ChartControls.vue's effectiveInstance
	private List<ChartSeries> nestedTableData(String instance) {
		if (instance == null || !instance.contains("::")) {
			return List.of();
		}

		String[] parts = instance.split("::", 2);
		String workflow = parts[0];
		String task = parts[1];

		List<ChartDataPoint> points = USERS.stream()
				.map(user -> new ChartDataPoint(user, fakeValue(instance, null, null, 0, user)))
				.collect(Collectors.toList());

		return List.of(new ChartSeries("Average time (invented) ? " + workflow + " / " + task, points));
	}

	// TABLE-only: suffix each row with the instance so it's visible which instance produced it
	private String rowLabel(String category, String instance, boolean isTable) {
		return (isTable && instance != null) ? category + " ? " + instance : category;
	}

	private List<String> lastDays(int days) {
		LocalDate today = LocalDate.now();
		List<String> labels = new ArrayList<>();

		for (int i = days - 1; i >= 0; i--) {
			labels.add(today.minusDays(i).toString());
		}

		return labels;
	}

	// Deterministic (not random) so the same combination always renders the same numbers
	private double fakeValue(String instance, String filter, String period, int seriesIndex, String category) {
		int seed = mix32(Objects.hash(instance, filter, period, seriesIndex, category));
		return 5 + (Math.abs(seed) % 46);
	}

	private int mix32(int x) {
		x ^= (x >>> 16);
		x *= 0x7feb352d;
		x ^= (x >>> 15);
		x *= 0x846ca68b;
		x ^= (x >>> 16);
		return x;
	}

	private List<String> monthsOf(String year) {
		List<String> months = new ArrayList<>();
		for (int month = 1; month <= 12; month++) {
			months.add(String.format("%s-%02d", year, month));
		}
		return months;
	}

	private List<String> weeksOf(String month) {
		List<String> weeks = new ArrayList<>();
		for (int week = 1; week <= 4; week++) {
			weeks.add(month + "-W" + week);
		}
		return weeks;
	}

	private String configString(String key, String defaultValue) {
		try {
			return configSrv.getString(key, defaultValue);
		} catch (DatabaseException e) {
			log.warn("Could not read '{}', defaulting to '{}': {}", key, defaultValue, e.getMessage());
			return defaultValue;
		}
	}

	private boolean configBoolean(String key, boolean defaultValue) {
		try {
			return configSrv.getBoolean(key, defaultValue);
		} catch (DatabaseException e) {
			log.warn("Could not read '{}', defaulting to {}: {}", key, defaultValue, e.getMessage());
			return defaultValue;
		}
	}

	private int configInt(String key, int defaultValue) {
		try {
			return configSrv.getInteger(key, defaultValue);
		} catch (DatabaseException e) {
			log.warn("Could not read '{}', defaulting to {}: {}", key, defaultValue, e.getMessage());
			return defaultValue;
		}
	}
}