Skip to content

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.

Method Type Description
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 The distinct instances this chart is currently split into - meaningful only when isMultiChart() or getMultipleLabel() is set. Empty by default.
getChartInstances(parent) List 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 Filter keys shown as buttons in the widget header (e.g. WEEK/MONTH/YEAR). Empty by default.
getColumnNames() List 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 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 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 This chart’s series for one filter, served live to the UI on every request.
getData(filter, period) List Same as getData(filter), for one historical period. Defaults to getData(filter).
getData(filter, period, instance) List Same as getData(filter, period), scoped to one getChartInstances() entry. Defaults to getData(filter, period).
Value Description
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.
Value Description
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

Section titled “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.

Key Type Default
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

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

# multipart multiple_label series_count filters_enabled history_enabled refresh_enabled What it verifies
1 false empty 1 false false false Base case: a single card, a plain pie with a side legend.
2 false empty 3 false false false Several series in one card → compact mode (mini-pies in a row, legend below).
3 true empty 1 false false false isMultiChart: 3 separate cards (one per instance), each with a plain pie.
4 true empty 3 false false false isMultiChart + compact mode combined: 3 cards, each with its own mini-pies.
5 false Instance 1 false false false multipleLabel: a single card with a dropdown on top; changing the selection recomputes the pie.
6 false Instance 3 false false false multipleLabel + compact mode: one card, dropdown on top, mini-pies inside.
7 false empty 1 true false false WEEK/MONTH/YEAR filter buttons visible in the header, no history.
8 false empty 1 true true false Filter + history button + drill-down (year/month/week selects).
9 true empty 1 true true false Multi-card + filter + history combined: each card has its own selector, independent of the rest.
10 false empty 1 false false true Only the refresh icon visible in the header.
11 true empty 3 true true true Maximum meaningful combination: multi-card + compact mode + filter + history + refresh, all at once.

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

Section titled “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.

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

# multipart multiple_label filters_enabled history_enabled refresh_enabled What it verifies
1 false empty false false false Base case: a single card, vertical bars (or horizontal with bar_horizontal).
2 true empty false false false isMultiChart: 3 separate cards, one per instance.
3 false Instance false false false multipleLabel: a single card with a dropdown on top.
4 false empty true false false WEEK/MONTH/YEAR filter buttons visible in the header.
5 false empty true true false Filter + history button + drill-down.
6 true empty true true false Multi-card + filter + history combined.
7 false empty false false true Only the refresh icon visible.
8 true empty true true true Maximum meaningful combination: multi-card + filter + history + refresh.

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.

# multipart multiple_label series_count filters_enabled history_enabled refresh_enabled What it verifies
1 false empty 1 false false false Base case: a single card, one line with the last 7 days.
2 false empty 3 false false false Several series in one card → 3 overlaid lines with a legend on top.
3 true empty 1 false false false isMultiChart: 3 separate cards, each with one line.
4 true empty 3 false false false isMultiChart + several series combined: 3 cards, each with 3 lines.
5 false Instance 1 false false false multipleLabel: a single card with a dropdown on top; changing the selection recomputes the line.
6 false Instance 3 false false false multipleLabel + several series: one card, dropdown on top, 3 lines inside.
7 false empty 1 true false false WEEK/MONTH/YEAR filter buttons visible in the header, no history.
8 false empty 1 true true false Filter + history button + drill-down (year/month/week selects).
9 true empty 1 true true false Multi-card + filter + history combined.
10 false empty 1 false false true Only the refresh icon visible in the header.
11 true empty 3 true true true Maximum meaningful combination: multi-card + several series + filter + history + refresh.

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().

# multipart multiple_label filters_enabled history_enabled refresh_enabled What it verifies
1 false empty false false false Base case: a single card, “Fruit” rows with no suffix (no active instance).
2 true empty false false false isMultiChart: 3 separate cards, rows suffixed with their own instance in each.
3 false Instance false false false multipleLabel: one card with a dropdown; changing the selection changes the row suffix.
4 false empty true false false WEEK/MONTH/YEAR filter buttons visible in the header.
5 false empty true true false Filter + history button + drill-down.
6 true empty true true false Multi-card + filter + history combined.
7 false empty false false true Only the refresh icon visible.
8 true empty true true true Maximum meaningful combination: multi-card + filter + history + refresh.

NESTED_TABLE (chart.sample_chart.type=nested_table)

Section titled “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_label What it verifies
1 Task 2 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.

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;
}
}
}