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í.
Descripción de métodos
Sección titulada «Descripción de métodos»| 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). |
Tipos de gráfico
Sección titulada «Tipos de gráfico»| 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. |
Frecuencia
Sección titulada «Frecuencia»| 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. |
Probando cada combinación con SampleChart
Sección titulada «Probando cada combinación con SampleChart»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 |
PIE (chart.sample_chart.type=pie)
Sección titulada «PIE (chart.sample_chart.type=pie)»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. |
LINE (chart.sample_chart.type=line)
Sección titulada «LINE (chart.sample_chart.type=line)»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. |
TABLE (chart.sample_chart.type=table)
Sección titulada «TABLE (chart.sample_chart.type=table)»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. |
Ejemplo
Sección titulada «Ejemplo»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@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; } }}