Ir al contenido

Creando su propio plugin de Dashboard Chart

Puede crear su propio plugin de Dashboard Chart para añadir un nuevo widget de analítica al dashboard.

Condiciones:

  • La nueva clase Dashboard Chart debe implementar la interfaz “DashboardChart”.
  • La nueva clase Dashboard Chart debe declararse bajo el paquete “com.openkm.plugin.dashboard”.
  • La nueva clase Dashboard Chart debe anotarse con@PluginImplementation”.
  • La nueva clase Dashboard Chart debe extender “BasePlugin”.

Interfaz DashboardChart:

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

La nueva clase debe cargarse en el paquete com.openkm.plugin.dashboard porque el sistema de plugins de la aplicación intentará cargarla desde ahí.

Método Tipo Descripción
getName() String Identificador interno, también reutilizado internamente por algunos plugins (p. ej. como nombre de serie persistido) - no necesariamente pensado para mostrarse tal cual.
getTitle() String Título orientado al usuario que se muestra en la cabecera del widget.
getDescription() String Descripción orientada al usuario que se muestra en el tooltip de información del widget.
getChartType() ChartType El tipo de widget usado para renderizar este gráfico. Ver “Tipos de gráfico” más abajo.
getFrequency() Frequency Con qué frecuencia la tarea programada llama a execute() para recalcular y persistir este gráfico. Ver “Frecuencia” más abajo.
getOrder() int Orden de clasificación entre el resto de gráficos de la lista de plugins del dashboard, ascendente - primero el más bajo.
getColumnWidth() int Anchura de la cuadrícula Bootstrap que ocupa este widget, sobre 12. Por defecto 6 (media fila).
isMultiChart() boolean Cuando es true, este plugin se renderiza como una tarjeta independiente por cada entrada de getChartInstances() en lugar de una única tarjeta. Por defecto false.
getChartInstances() List Las instancias distintas en las que se divide actualmente este gráfico - solo tiene sentido cuando isMultiChart() o getMultipleLabel() están activos. Vacío por defecto.
getChartInstances(parent) List Igual que getChartInstances(), acotado un nivel por debajo mediante parent - solo tiene sentido para ChartType.NESTED_TABLE. Por defecto, getChartInstances().
getMultipleLabel() String Etiqueta para un desplegable que cambia entre getChartInstances() dentro de una misma tarjeta, en lugar de una tarjeta por instancia. Null (sin desplegable) por defecto.
getFilters() List Claves de filtro mostradas como botones en la cabecera del widget (p. ej. WEEK/MONTH/YEAR). Vacío por defecto.
getColumnNames() List Cabeceras de columna para un gráfico ChartType.TABLE. Vacío por defecto.
isRefreshEnabled() boolean Si la interfaz ofrece un icono manual de “actualizar ahora” que llama a execute() bajo demanda. Por defecto false.
getPeriods(filter, parent) List Claves de periodo un nivel por debajo de parent, que sustentan el desglose histórico por año/mes/semana. Vacío por defecto.
isHistoryEnabled() boolean Si la interfaz ofrece el desglose histórico además del simple selector de filtros. Por defecto false.
execute() void Calcula todos los filtros y persiste el resultado. Llamado por la tarea programada.
execute(from, to) List Calcula los datos de este gráfico para una ventana histórica concreta, independientemente de la tarea programada. Sin efecto por defecto.
rebuild() void Borra y recalcula por completo el histórico persistido de este gráfico mediante execute(from, to). Sin efecto por defecto.
getData(filter) List Las series de este gráfico para un filtro, servidas en vivo a la interfaz en cada petición.
getData(filter, period) List Igual que getData(filter), para un periodo histórico concreto. Por defecto, getData(filter).
getData(filter, period, instance) List Igual que getData(filter, period), acotado a una entrada de getChartInstances(). Por defecto, getData(filter, period).
Valor Descripción
BAR Gráfico de barras vertical.
BAR_HORIZONTAL Los mismos datos que BAR, dibujados con las categorías en el eje Y - más adecuado para un ranking cuyas etiquetas son largas o de anchura variable (p. ej. nombres de usuario).
PIE Gráfico circular. Se renderiza como un único gráfico circular normal para una serie, o como varios mini-gráficos compactos cuando getData() devuelve más de una serie.
LINE Gráfico de líneas, una línea por serie.
TABLE Tabla de datos simple, con columnas de getColumnNames().
NESTED_TABLE Una TABLE siempre dividida por getChartInstances() (como isMultiChart), donde cada tarjeta resultante también lleva su propio desplegable getMultipleLabel() acotado por getChartInstances(parent) - p. ej. una tarjeta por workflow, cada una con un desplegable para elegir qué tarea de ese workflow mostrar.
Valor Descripción
DAILY / WEEKLY / MONTHLY Con qué frecuencia la tarea programada llama a execute() para recalcular y persistir este gráfico.
NEVER La tarea programada nunca llama a execute() para este gráfico; se espera que getData() calcule el resultado en vivo en cada petición.

SampleChart se distribuye solo como fixture de prueba (getOrder() 999, nunca asignado a un perfil real). Está totalmente gobernado por las claves OKM_CONFIG chart.sample_chart.*, así que cada combinación de interfaz posible en un widget puede probarse simplemente cambiando una clave y recargando el dashboard - sin necesidad de cambiar código ni redesplegar.

Clave Tipo Valor por defecto
chart.sample_chart.type string “pie” (también: “bar”, “bar_horizontal”, “line”, “table”, “nested_table”)
chart.sample_chart.multipart bool false
chart.sample_chart.multiple_label string vacío (sin efecto); cualquier texto no vacío activa el modo desplegable
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

Categorías: los 4 nombres de fruta (“Apples”, “Pears”, “Bananas”, “Grapes”).

# multipart multiple_label series_count filters_enabled history_enabled refresh_enabled Qué verifica
1 false vacío 1 false false false Caso base: una única tarjeta, un gráfico circular simple con leyenda lateral.
2 false vacío 3 false false false Varias series en una tarjeta → modo compacto (mini-gráficos circulares en fila, leyenda debajo).
3 true vacío 1 false false false isMultiChart: 3 tarjetas separadas (una por instancia), cada una con un gráfico circular simple.
4 true vacío 3 false false false isMultiChart + modo compacto combinados: 3 tarjetas, cada una con sus propios mini-gráficos circulares.
5 false Instance 1 false false false multipleLabel: una única tarjeta con un desplegable arriba; cambiar la selección recalcula el gráfico circular.
6 false Instance 3 false false false multipleLabel + modo compacto: una tarjeta, desplegable arriba, mini-gráficos circulares dentro.
7 false vacío 1 true false false Botones de filtro WEEK/MONTH/YEAR visibles en la cabecera, sin histórico.
8 false vacío 1 true true false Filtro + botón de histórico + desglose (selectores año/mes/semana).
9 true vacío 1 true true false Multi-tarjeta + filtro + histórico combinados: cada tarjeta tiene su propio selector, independiente del resto.
10 false vacío 1 false false true Solo el icono de actualizar visible en la cabecera.
11 true vacío 3 true true true Combinación máxima con sentido: multi-tarjeta + modo compacto + filtro + histórico + actualizar, todo a la vez.

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

Sección titulada «BAR / BAR_HORIZONTAL (chart.sample_chart.type=bar / bar_horizontal)»

Ambos tipos comparten el mismo componente Vue por debajo; la única diferencia es la orientación, así que la matriz de combinaciones es idéntica para ambos - solo cambia el valor de type.

Categorías: los 4 nombres de fruta, ordenados de forma descendente por valor.

# multipart multiple_label filters_enabled history_enabled refresh_enabled Qué verifica
1 false vacío false false false Caso base: una única tarjeta, barras verticales (u horizontales con bar_horizontal).
2 true vacío false false false isMultiChart: 3 tarjetas separadas, una por instancia.
3 false Instance false false false multipleLabel: una única tarjeta con un desplegable arriba.
4 false vacío true false false Botones de filtro WEEK/MONTH/YEAR visibles en la cabecera.
5 false vacío true true false Filtro + botón de histórico + desglose.
6 true vacío true true false Multi-tarjeta + filtro + histórico combinados.
7 false vacío false false true Solo el icono de actualizar visible.
8 true vacío true true true Combinación máxima con sentido: multi-tarjeta + filtro + histórico + actualizar.

A diferencia de PIE/BAR, las categorías aquí no son frutas: SampleChart detecta type=line y usa 7 días consecutivos en su lugar, de modo que la línea se lee como una tendencia en lugar de conectar nombres de fruta por orden alfabético.

# multipart multiple_label series_count filters_enabled history_enabled refresh_enabled Qué verifica
1 false vacío 1 false false false Caso base: una única tarjeta, una línea con los últimos 7 días.
2 false vacío 3 false false false Varias series en una tarjeta → 3 líneas superpuestas con una leyenda arriba.
3 true vacío 1 false false false isMultiChart: 3 tarjetas separadas, cada una con una línea.
4 true vacío 3 false false false isMultiChart + varias series combinadas: 3 tarjetas, cada una con 3 líneas.
5 false Instance 1 false false false multipleLabel: una única tarjeta con un desplegable arriba; cambiar la selección recalcula la línea.
6 false Instance 3 false false false multipleLabel + varias series: una tarjeta, desplegable arriba, 3 líneas dentro.
7 false vacío 1 true false false Botones de filtro WEEK/MONTH/YEAR visibles en la cabecera, sin histórico.
8 false vacío 1 true true false Filtro + botón de histórico + desglose (selectores año/mes/semana).
9 true vacío 1 true true false Multi-tarjeta + filtro + histórico combinados.
10 false vacío 1 false false true Solo el icono de actualizar visible en la cabecera.
11 true vacío 3 true true true Combinación máxima con sentido: multi-tarjeta + varias series + filtro + histórico + actualizar.

Admite todo lo que PIE/BAR/LINE, excepto varias series por tarjeta. Como una tabla nunca muestra el nombre de una serie en ningún sitio, SampleChart añade a cada fila el sufijo de la instancia resuelta solo para este tipo - “Apples — North region” en lugar de solo “Apples” - de modo que se confirma visiblemente que el valor de instancia (ya sea de multipart o de multiple_label) llega a getData().

# multipart multiple_label filters_enabled history_enabled refresh_enabled Qué verifica
1 false vacío false false false Caso base: una única tarjeta, filas “Fruit” sin sufijo (sin instancia activa).
2 true vacío false false false isMultiChart: 3 tarjetas separadas, filas con el sufijo de su propia instancia en cada una.
3 false Instance false false false multipleLabel: una tarjeta con un desplegable; cambiar la selección cambia el sufijo de la fila.
4 false vacío true false false Botones de filtro WEEK/MONTH/YEAR visibles en la cabecera.
5 false vacío true true false Filtro + botón de histórico + desglose.
6 true vacío true true false Multi-tarjeta + filtro + histórico combinados.
7 false vacío false false true Solo el icono de actualizar visible.
8 true vacío true true true Combinación máxima con sentido: multi-tarjeta + filtro + histórico + actualizar.

NESTED_TABLE (chart.sample_chart.type=nested_table)

Sección titulada «NESTED_TABLE (chart.sample_chart.type=nested_table)»

Un tipo distinto, no una combinación de flags sobre TABLE. Es el único caso donde la división externa y el desplegable interno se combinan - y siempre lo hacen, independientemente de multipart, que se ignora por completo para este tipo.

Usa su propio conjunto de datos inventado de dos niveles, independiente del de fruta/instancia usado arriba: 2 workflows (“Contract approval”, “Expense report”), cada uno con sus propias 2-3 tareas, y 3 usuarios (Alice, Bob, Carol) como filas. Establezca chart.sample_chart.multiple_label=Task para que el desplegable interno tenga una etiqueta que lo identifique.

# multiple_label Qué verifica
1 Task 2 tarjetas separadas (una por workflow); cada una con su propio desplegable que muestra solo las tareas de ese workflow (no las del otro workflow); cambiar la selección recalcula la tabla de usuarios para ese workflow + tarea específicos.

Clase SampleChart:

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