A companion plugin that adds a rows + columns dashboard to Matomo, plus Markdown text blocks you can drop in beside the normal report widgets to add section headers and explanatory notes.
It runs alongside the built-in Dashboard plugin rather than modifying it, so your standard dashboards keep working and nothing here is overwritten when you upgrade Matomo.
Status. Tested against a live Matomo 5.11/5.12 instance (Docker, PHP 8.4): activation, SPA + standalone rendering, widget placement in any column, Markdown editing with live preview, layout persistence (including quotes and HTML entities in Markdown), importing a core-Dashboard-shaped layout, and the Markdown XSS defenses were all exercised end-to-end in a real browser. The companion dev environment lives in
../matomo-dev-docker-compose.
- A new Flexible Dashboard entry in the reporting menu (its own category), separate from the standard Dashboard.
- Dashboards made of rows. Each row has its own column layout
(
100,50-50,67-33,33-67,33-33-33,40-30-30,30-40-30,30-30-40,25-25-25-25). - Any Matomo report widget can be placed in any column (reusing Matomo's own widget rendering).
- Markdown text blocks for headers / descriptions / links, rendered safely (see security model).
- Drag-and-drop to reorder blocks within and across columns and rows, and to reorder rows.
- Create / rename / reset / remove dashboards, copy a dashboard to another user (admins), and "set current layout as the default".
- Matomo 5.x
- The core
Dashboardplugin must be active. This plugin deliberately reuses Matomo's existing client-side widget machinery (widgetsHelper, the$.fn.dashboardWidgetjQuery plugin, theWidgetFactory, and@Dashboard/_widgetFactoryTemplate.twig) instead of duplicating it.
- Copy the
FlexibleDashboardfolder into your Matomoplugins/directory:matomo/plugins/FlexibleDashboard/ - Activate it:
- Administration → Plugins → FlexibleDashboard → Activate, or
- CLI:
./console plugin:activate FlexibleDashboard
- Reload the reporting UI. A Flexible Dashboard section appears in the left reporting menu.
Activation creates one database table, <prefix>flexible_dashboard
(login, iddashboard, name, layout MEDIUMTEXT). Uninstalling drops it.
Your core user_dashboard table is never touched.
- Add row (toolbar) appends a new full-width row. Hover a row to reveal its controls: change row layout, add widget, add text block, remove row, and a drag handle to reorder rows.
- Hovering a row also reveals a small Add widget / Add text block control at the bottom of every column, so blocks can be placed directly into any column without dragging.
- Add text block inserts a Markdown block and opens the editor. The editor has a live, server-rendered preview.
- Drag a block by its top bar to move it between columns or rows.
- Manage dashboard (toolbar) → create / rename / reset / remove / copy / set-as-default.
The layout is stored as JSON:
{
"config": { "layout": "rows" },
"rows": [
{
"config": { "layout": "100" },
"columns": [
[ { "type": "markdown", "id": "md_intro", "markdown": "# Overview" } ]
]
},
{
"config": { "layout": "50-50" },
"columns": [
[ { "uniqueId": "widget...", "parameters": { "module": "VisitsSummary", "action": "getEvolutionGraph" } } ],
[ { "uniqueId": "widget...", "parameters": { "module": "VisitsSummary", "action": "get" } } ]
]
}
]
}A block in a column is either:
- a widget:
{ uniqueId, parameters, isHidden }(identical to the core Dashboard widget object), or - a Markdown block:
{ type: "markdown", id, markdown }.
The loader accepts older shapes and normalises them to rows automatically:
- a bare array of columns (very old core layout) → wrapped as one row;
- the modern core shape
{ config, columns: [...] }→ wrapped as one row using the same column layout.
So if you paste/copy a standard dashboard's layout into a flexible dashboard, it
keeps working as a single row that you can then split into multiple rows. (A
one-click "import from standard dashboard" button was intentionally left out to
keep the first version small; the conversion logic it would need already exists
in FlexibleDashboard::migrateToRows().)
Because dashboards can be copied between users, custom HTML would be a stored-XSS
vector. This plugin therefore accepts Markdown only and renders it with an
escape-first converter (Markdown/SafeMarkdown.php):
- The entire input is
htmlspecialchars-escaped first. Any raw HTML a user types (e.g.<script>or<img onerror=…>) becomes inert text, never live markup. - Only a fixed allow-list of tags is ever emitted, all generated by the
converter itself:
h1–h6,p,br,strong,em,code,pre,ul,ol,li,blockquote,hr,a. - Links are restricted to
http(s),mailto:and relative/in-page targets (/…,#…).javascript:,data:,vbscript:,file:and protocol-relative//hostURLs are downgraded to plain text. Every link getsrel="noopener noreferrer". Image syntaxrenders as a plain link to the image — inline images are deliberately not supported, since remote images would leak dashboard viewers' IP addresses. - Input is capped (100 KB per block) to avoid resource abuse.
Rendering always happens on the server (Controller::renderMarkdown), both
for the saved block and for the editor's live preview, so the client is never
trusted to sanitise. Because the only HTML injected into the page for a block is
the server's sanitised output, the saved layout JSON itself is not
strip_tags-ed (that would corrupt legitimate Markdown such as a < b); instead
it is validated as well-formed JSON within a 2 MB cap before being stored.
If you prefer a different parser later (e.g. a CommonMark library), swap the body
of SafeMarkdown::toHtml() — but keep the escape-first + allow-list approach, or
re-introduce a sanitiser, before emitting anything.
FlexibleDashboard/
├── plugin.json Plugin manifest (requires Matomo 5)
├── FlexibleDashboard.php Plugin class: events, assets, default layout, migration
├── API.php getDashboards / create / remove / copy / reset
├── Controller.php index, layout get/save, reset, renderMarkdown, row presets
├── Model.php CRUD over the flexible_dashboard table
├── Markdown/SafeMarkdown.php Escape-first Markdown → safe HTML
├── Categories/FlexibleDashboardCategory.php Own reporting category
├── config/config.php, config/tracker.php
├── javascripts/
│ ├── flexibleDashboardObject.js Rows+columns engine ($.fn.flexibleDashboard)
│ ├── markdownBlock.js Markdown block: render + editor + sortable chrome
│ └── flexibleDashboard.js Management dialogs + bootstrap (no Vue build needed)
├── stylesheets/flexibleDashboard.less Rows, blocks, editor, picker, layout chooser
├── templates/
│ ├── embeddedIndex.twig Toolbar, dialogs, editors, container
│ ├── index.twig Standalone wrapper
│ └── _header.twig
└── lang/en.json
Exercised end-to-end against Matomo 5.11/5.12 (official Docker image, PHP 8.4), driven both via HTTP and via a real browser:
- Initialisation without Vue works: the jQuery +
MutationObserverbootstrap mounts the dashboard on first page load, on SPA menu navigation, and when switching between dashboards (subcategories). - Core widget machinery renders inside arbitrary columns: report widgets (graphs, sparklines, realtime log, maps) paint correctly in every row layout.
- Persistence round-trips exactly: layouts containing quotes, HTML-as-text
and entities in Markdown save and reload byte-identical. Layouts saved by
this plugin are decoded as plain JSON; the legacy entity/backslash
de-escaping is only applied as a fallback when importing a layout stored by
the core Dashboard plugin (see
FlexibleDashboard::decodeLayout()). - Markdown editor live preview, save-in-place, and the XSS defenses
(script tags,
javascript:/data:/protocol-relative links, attribute breakout attempts) behave as designed. - Anonymous users still deserve a manual check: session-stored layouts (view/reset) are implemented like core but were not part of the automated test run.