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 descriptions
Section titled “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 |
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). |
Chart types
Section titled “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
Section titled “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
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 |
PIE (chart.sample_chart.type=pie)
Section titled “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)
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. |
LINE (chart.sample_chart.type=line)
Section titled “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)
Section titled “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)
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. |
Example
Section titled “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@PluginImplementationpublic 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; } }}