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
| 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<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
| 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. |
Frequency
| 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
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 |
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").
| # | 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)
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.
| # | 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. |
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.
| # | 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. |
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().
| # | 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)
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. |
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;
}
}
}