diff --git a/app/plugins/builtins/automation/plugin.py b/app/plugins/builtins/automation/plugin.py
index 8d9fac2..34c2d15 100644
--- a/app/plugins/builtins/automation/plugin.py
+++ b/app/plugins/builtins/automation/plugin.py
@@ -19,6 +19,7 @@ from app.plugins.manifest import (
FrontendMenuItem,
FrontendPageRoute,
FrontendSettingsPage,
+ MiniAppContribution,
PluginManifest,
PluginRouteDef,
)
@@ -63,6 +64,25 @@ class AutomationPlugin(BasePlugin):
"workflow.timeout",
],
migrations=["0001_initial.sql", "0002_agent_subtasks.sql", "0003_skill_definitions.sql", "0004_run_steps_phase_f.sql"],
+ miniapps=[
+ MiniAppContribution(
+ app_id="automation_status",
+ name="Automationen",
+ icon="Workflow",
+ description="Aktive und inaktive Automations-Definitionen auf einen Blick.",
+ permission="automation:read",
+ settings_schema={
+ "fields": [
+ {"name": "max_items", "label": "Max. Einträge", "type": "number", "default": 6},
+ ]
+ },
+ col_span=2,
+ row_span=1,
+ hosts=["chat", "dashboard", "window"],
+ component="@/components/dashboard/AutomationStatusWidget",
+ order=100,
+ ),
+ ],
permissions=[
"automation:read",
"automation:write",
diff --git a/app/plugins/builtins/dms/plugin.py b/app/plugins/builtins/dms/plugin.py
index 3326efc..1b6e8d2 100644
--- a/app/plugins/builtins/dms/plugin.py
+++ b/app/plugins/builtins/dms/plugin.py
@@ -6,6 +6,7 @@ from app.plugins.base import BasePlugin
from app.plugins.manifest import (
FrontendMenuItem,
FrontendPageRoute,
+ MiniAppContribution,
PluginManifest,
PluginRouteDef,
)
@@ -29,6 +30,25 @@ class DmsPlugin(BasePlugin):
],
events=[],
migrations=["0001_initial.sql"],
+ miniapps=[
+ MiniAppContribution(
+ app_id="dms_folders",
+ name="DMS-Ordner",
+ icon="FolderOpen",
+ description="Ordnerübersicht des Dokumentenmanagements mit Dateizählern.",
+ permission="dms:read",
+ settings_schema={
+ "fields": [
+ {"name": "max_items", "label": "Max. Ordner", "type": "number", "default": 6},
+ ]
+ },
+ col_span=2,
+ row_span=1,
+ hosts=["chat", "dashboard", "window"],
+ component="@/components/dashboard/DmsFoldersWidget",
+ order=60,
+ ),
+ ],
permissions=[
"dms:read",
"dms:write",
diff --git a/app/plugins/builtins/graph_rag/plugin.py b/app/plugins/builtins/graph_rag/plugin.py
index a52da51..e46329e 100644
--- a/app/plugins/builtins/graph_rag/plugin.py
+++ b/app/plugins/builtins/graph_rag/plugin.py
@@ -3,7 +3,7 @@
from __future__ import annotations
from app.plugins.base import BasePlugin
-from app.plugins.manifest import PluginManifest, PluginRouteDef
+from app.plugins.manifest import MiniAppContribution, PluginManifest, PluginRouteDef
class GraphRAGPlugin(BasePlugin):
@@ -24,6 +24,25 @@ class GraphRAGPlugin(BasePlugin):
],
events=[],
migrations=["0001_initial.sql"],
+ miniapps=[
+ MiniAppContribution(
+ app_id="graph_overview",
+ name="Wissens-Graph",
+ icon="Share2",
+ description="Beziehungsübersicht des Knowledge-Graphs.",
+ permission="graph:read",
+ settings_schema={
+ "fields": [
+ {"name": "max_items", "label": "Max. Beziehungen", "type": "number", "default": 6},
+ ]
+ },
+ col_span=2,
+ row_span=1,
+ hosts=["chat", "dashboard", "window"],
+ component="@/components/dashboard/GraphOverviewWidget",
+ order=90,
+ ),
+ ],
permissions=[
"graph:read",
"graph:write",
diff --git a/app/plugins/builtins/mail/plugin.py b/app/plugins/builtins/mail/plugin.py
index 7a7a818..152bd36 100644
--- a/app/plugins/builtins/mail/plugin.py
+++ b/app/plugins/builtins/mail/plugin.py
@@ -12,6 +12,7 @@ from app.plugins.manifest import (
FrontendMenuItem,
FrontendPageRoute,
FrontendSettingsPage,
+ MiniAppContribution,
PluginManifest,
PluginRouteDef,
)
@@ -138,6 +139,25 @@ class MailPlugin(BasePlugin):
],
events=[],
migrations=["0001_initial.sql", "0006_flag_type.sql", "0007_sync_queue.sql", "0008_sync_queue_deleted_at.sql", "0009_remove_mail_soft_delete.sql", "0010_add_deleted_at.sql"],
+ miniapps=[
+ MiniAppContribution(
+ app_id="mail_unread",
+ name="Postfach-Status",
+ icon="Mail",
+ description="Ungelesene E-Mails je Konto und Ordner.",
+ permission="mail:read",
+ settings_schema={
+ "fields": [
+ {"name": "max_items", "label": "Max. Ordner", "type": "number", "default": 6},
+ ]
+ },
+ col_span=2,
+ row_span=1,
+ hosts=["chat", "dashboard", "window"],
+ component="@/components/dashboard/MailUnreadWidget",
+ order=70,
+ ),
+ ],
permissions=["mail:read", "mail:send", "mail:config", "mail:share", "mail:write", "mail:delete"],
menu_items=[
FrontendMenuItem(label_key='nav.mail', label='E-Mail', path='/mail', icon='Mail', order=30, permission='mail:read'),
diff --git a/app/plugins/builtins/wiki/plugin.py b/app/plugins/builtins/wiki/plugin.py
index 20575e2..ecf0840 100644
--- a/app/plugins/builtins/wiki/plugin.py
+++ b/app/plugins/builtins/wiki/plugin.py
@@ -4,7 +4,13 @@ from __future__ import annotations
import logging
from app.plugins.base import BasePlugin
-from app.plugins.manifest import FrontendMenuItem, FrontendPageRoute, PluginManifest, PluginRouteDef
+from app.plugins.manifest import (
+ FrontendMenuItem,
+ FrontendPageRoute,
+ MiniAppContribution,
+ PluginManifest,
+ PluginRouteDef,
+)
logger = logging.getLogger(__name__)
@@ -19,6 +25,25 @@ class WikiPlugin(BasePlugin):
routes=[
PluginRouteDef(path="/api/v1/wiki", module="app.plugins.builtins.wiki.routes", router_attr="router"),
],
+ miniapps=[
+ MiniAppContribution(
+ app_id="wiki_recent",
+ name="Wiki-Neuigkeiten",
+ icon="BookOpen",
+ description="Zuletzt aktualisierte Wiki-Artikel.",
+ permission="wiki:read",
+ settings_schema={
+ "fields": [
+ {"name": "max_items", "label": "Max. Artikel", "type": "number", "default": 5},
+ ]
+ },
+ col_span=2,
+ row_span=1,
+ hosts=["chat", "dashboard", "window"],
+ component="@/components/dashboard/WikiRecentWidget",
+ order=80,
+ ),
+ ],
permissions=["wiki:read", "wiki:write", "wiki:delete", "wiki:admin"],
menu_items=[FrontendMenuItem(label_key="wiki.menu.wiki", label="Wiki", path="/wiki", icon="BookOpen", permission="wiki:read")],
page_routes=[FrontendPageRoute(path="/wiki", component="@/pages/Wiki", permission="wiki:read")],
diff --git a/frontend/src/components/dashboard/AutomationStatusWidget.tsx b/frontend/src/components/dashboard/AutomationStatusWidget.tsx
new file mode 100644
index 0000000..70b3195
--- /dev/null
+++ b/frontend/src/components/dashboard/AutomationStatusWidget.tsx
@@ -0,0 +1,59 @@
+/**
+ * AutomationStatusWidget — automation definitions overview (Phase M5).
+ *
+ * Plugin miniapp `automation_status` (automation plugin, automation:read).
+ * Uses the existing automation API client (useAutomations hook).
+ */
+
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+import { Workflow, Play, Pause } from 'lucide-react';
+import type { WidgetComponentProps } from '@/components/dashboard/MiniAppHost';
+import { useAutomations } from '@/api/automation';
+
+export function AutomationStatusWidget({ settings }: WidgetComponentProps) {
+ const { t } = useTranslation();
+ const maxItems = Math.max(1, Math.min(24, Number(settings?.max_items ?? 6) || 6));
+ const { data, isLoading, isError } = useAutomations();
+
+ if (isLoading) {
+ return
;
+ }
+ if (isError) {
+ return (
+
+ {t('dashboard.widgetError')}
+
+ );
+ }
+
+ const automations = data ?? [];
+ const active = automations.filter((a) => a.active).length;
+ const inactive = automations.length - active;
+
+ return (
+
+
+
+
+ {active} {t('automation.active', 'aktiv')} · {inactive} {t('automation.inactive', 'inaktiv')}
+
+
+ {automations.length === 0 && (
+
{t('automation.none', 'Keine Automationen definiert.')}
+ )}
+ {automations.slice(0, maxItems).map((a) => (
+
+
+ {a.name}
+
+ {a.active ? (
+
+ ) : (
+
+ )}
+
+ ))}
+
+ );
+}
diff --git a/frontend/src/components/dashboard/DmsFoldersWidget.tsx b/frontend/src/components/dashboard/DmsFoldersWidget.tsx
new file mode 100644
index 0000000..c87d9ba
--- /dev/null
+++ b/frontend/src/components/dashboard/DmsFoldersWidget.tsx
@@ -0,0 +1,60 @@
+/**
+ * DmsFoldersWidget — DMS folder overview with file counts (Phase M5).
+ *
+ * Plugin miniapp `dms_folders` (dms plugin, dms:read). Uses the existing
+ * DMS API client — no new backend endpoints.
+ */
+
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+import { useQuery } from '@tanstack/react-query';
+import { FolderOpen } from 'lucide-react';
+import type { WidgetComponentProps } from '@/components/dashboard/MiniAppHost';
+import { fetchFolders } from '@/api/dms';
+
+export function DmsFoldersWidget({ settings }: WidgetComponentProps) {
+ const { t } = useTranslation();
+ const maxItems = Math.max(1, Math.min(24, Number(settings?.max_items ?? 6) || 6));
+ const { data, isLoading, isError } = useQuery({
+ queryKey: ['dmsFolders'],
+ queryFn: () => fetchFolders(),
+ staleTime: 60 * 1000,
+ });
+
+ if (isLoading) {
+ return ;
+ }
+ if (isError) {
+ return (
+
+ {t('dashboard.widgetError')}
+
+ );
+ }
+
+ // fetchFolders returns a tree — flatten top-level folders first
+ const folders = (data ?? []).slice(0, maxItems);
+ if (folders.length === 0) {
+ return (
+
+ {t('dms.noFolders', 'Keine Ordner vorhanden.')}
+
+ );
+ }
+
+ return (
+
+ {folders.map((f) => (
+
+
+
+ {f.name}
+
+
+ {f.file_count ?? 0}
+
+
+ ))}
+
+ );
+}
diff --git a/frontend/src/components/dashboard/GraphOverviewWidget.tsx b/frontend/src/components/dashboard/GraphOverviewWidget.tsx
new file mode 100644
index 0000000..3f1a1f3
--- /dev/null
+++ b/frontend/src/components/dashboard/GraphOverviewWidget.tsx
@@ -0,0 +1,61 @@
+/**
+ * GraphOverviewWidget — knowledge graph relationship overview (Phase M5).
+ *
+ * Plugin miniapp `graph_overview` (graph_rag plugin, graph:read). Uses the
+ * existing knowledge API client (graph relationships endpoint).
+ */
+
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+import { useQuery } from '@tanstack/react-query';
+import { Share2 } from 'lucide-react';
+import type { WidgetComponentProps } from '@/components/dashboard/MiniAppHost';
+import { fetchGraphRelationships } from '@/api/knowledge';
+
+export function GraphOverviewWidget({ settings }: WidgetComponentProps) {
+ const { t } = useTranslation();
+ const maxItems = Math.max(1, Math.min(24, Number(settings?.max_items ?? 6) || 6));
+ const { data, isLoading, isError } = useQuery({
+ queryKey: ['graphOverview', maxItems],
+ queryFn: () => fetchGraphRelationships({ page: 1, page_size: maxItems }),
+ staleTime: 60 * 1000,
+ });
+
+ if (isLoading) {
+ return ;
+ }
+ if (isError) {
+ return (
+
+ {t('dashboard.widgetError')}
+
+ );
+ }
+
+ const items = data?.items ?? [];
+ if (items.length === 0) {
+ return (
+
+ {t('knowledge.noRelationships', 'Keine Beziehungen vorhanden.')}
+
+ );
+ }
+
+ return (
+
+
+
+ {data?.total ?? items.length} {t('knowledge.relationships', 'Beziehungen')}
+
+ {items.map((r) => (
+
+ {r.source_type}
+
+ {r.relationship_type}
+
+ {r.target_type}
+
+ ))}
+
+ );
+}
diff --git a/frontend/src/components/dashboard/MailUnreadWidget.tsx b/frontend/src/components/dashboard/MailUnreadWidget.tsx
new file mode 100644
index 0000000..1e55e05
--- /dev/null
+++ b/frontend/src/components/dashboard/MailUnreadWidget.tsx
@@ -0,0 +1,80 @@
+/**
+ * MailUnreadWidget — unread mail counts per account/folder (Phase M5).
+ *
+ * Plugin miniapp `mail_unread` (mail plugin, mail:read). Uses the existing
+ * mail API client (accounts + folders with unread_count).
+ */
+
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+import { useQuery } from '@tanstack/react-query';
+import { Mail } from 'lucide-react';
+import type { WidgetComponentProps } from '@/components/dashboard/MiniAppHost';
+import { fetchAccounts, fetchFolders } from '@/api/mail';
+
+export function MailUnreadWidget({ settings }: WidgetComponentProps) {
+ const { t } = useTranslation();
+ const maxItems = Math.max(1, Math.min(24, Number(settings?.max_items ?? 6) || 6));
+ const { data: accounts } = useQuery({
+ queryKey: ['mailAccounts'],
+ queryFn: () => fetchAccounts(),
+ staleTime: 60 * 1000,
+ });
+
+ const activeAccounts = (accounts ?? []).filter((a) => a.is_active);
+ const { data, isLoading, isError } = useQuery({
+ queryKey: ['mailFolders', activeAccounts.map((a) => a.id).join(',')],
+ queryFn: async () => {
+ const results = await Promise.all(
+ activeAccounts.map(async (account) => ({
+ account,
+ folders: await fetchFolders(account.id),
+ }))
+ );
+ return results;
+ },
+ enabled: activeAccounts.length > 0,
+ staleTime: 60 * 1000,
+ });
+
+ if (isLoading) {
+ return ;
+ }
+ if (isError) {
+ return (
+
+ {t('dashboard.widgetError')}
+
+ );
+ }
+
+ const rows = (data ?? []).flatMap(({ account, folders }) =>
+ folders
+ .filter((f) => f.unread_count > 0)
+ .map((f) => ({ key: `${account.id}-${f.id}`, account: account.email, folder: f.name, unread: f.unread_count }))
+ );
+
+ if (rows.length === 0) {
+ return (
+
+
+ {t('mail.noUnread', 'Keine ungelesenen E-Mails.')}
+
+ );
+ }
+
+ return (
+
+ {rows.slice(0, maxItems).map((r) => (
+
+
+ {r.folder}
+
+
+ {r.unread}
+
+
+ ))}
+
+ );
+}
diff --git a/frontend/src/components/dashboard/MiniAppHost.tsx b/frontend/src/components/dashboard/MiniAppHost.tsx
index c5c5cfe..10362e8 100644
--- a/frontend/src/components/dashboard/MiniAppHost.tsx
+++ b/frontend/src/components/dashboard/MiniAppHost.tsx
@@ -36,6 +36,21 @@ const widgetRegistry: Record
import('@/components/dashboard/SystemMetricsWidget').then((m) => ({ default: m.SystemMetricsWidget }))
),
+ '@/components/dashboard/DmsFoldersWidget': lazy(() =>
+ import('@/components/dashboard/DmsFoldersWidget').then((m) => ({ default: m.DmsFoldersWidget }))
+ ),
+ '@/components/dashboard/MailUnreadWidget': lazy(() =>
+ import('@/components/dashboard/MailUnreadWidget').then((m) => ({ default: m.MailUnreadWidget }))
+ ),
+ '@/components/dashboard/WikiRecentWidget': lazy(() =>
+ import('@/components/dashboard/WikiRecentWidget').then((m) => ({ default: m.WikiRecentWidget }))
+ ),
+ '@/components/dashboard/GraphOverviewWidget': lazy(() =>
+ import('@/components/dashboard/GraphOverviewWidget').then((m) => ({ default: m.GraphOverviewWidget }))
+ ),
+ '@/components/dashboard/AutomationStatusWidget': lazy(() =>
+ import('@/components/dashboard/AutomationStatusWidget').then((m) => ({ default: m.AutomationStatusWidget }))
+ ),
};
interface MiniAppHostProps {
diff --git a/frontend/src/components/dashboard/WikiRecentWidget.tsx b/frontend/src/components/dashboard/WikiRecentWidget.tsx
new file mode 100644
index 0000000..adeafdf
--- /dev/null
+++ b/frontend/src/components/dashboard/WikiRecentWidget.tsx
@@ -0,0 +1,57 @@
+/**
+ * WikiRecentWidget — recently updated wiki articles (Phase M5).
+ *
+ * Plugin miniapp `wiki_recent` (wiki plugin, wiki:read). Uses the existing
+ * knowledge API client (wiki articles endpoint).
+ */
+
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+import { useQuery } from '@tanstack/react-query';
+import { BookOpen } from 'lucide-react';
+import type { WidgetComponentProps } from '@/components/dashboard/MiniAppHost';
+import { fetchWikiArticles } from '@/api/knowledge';
+
+export function WikiRecentWidget({ settings }: WidgetComponentProps) {
+ const { t } = useTranslation();
+ const maxItems = Math.max(1, Math.min(20, Number(settings?.max_items ?? 5) || 5));
+ const { data, isLoading, isError } = useQuery({
+ queryKey: ['wikiRecent', maxItems],
+ queryFn: () => fetchWikiArticles({ page: 1, page_size: maxItems }),
+ staleTime: 60 * 1000,
+ });
+
+ if (isLoading) {
+ return ;
+ }
+ if (isError) {
+ return (
+
+ {t('dashboard.widgetError')}
+
+ );
+ }
+
+ const articles = data?.items ?? [];
+ if (articles.length === 0) {
+ return (
+
+ {t('wiki.noArticles', 'Keine Artikel vorhanden.')}
+
+ );
+ }
+
+ return (
+
+ {articles.map((a) => (
+
+
+
+ {a.title}
+
+ v{a.version}
+
+ ))}
+
+ );
+}
diff --git a/tests/test_m5_plugin_miniapps.py b/tests/test_m5_plugin_miniapps.py
new file mode 100644
index 0000000..662dee9
--- /dev/null
+++ b/tests/test_m5_plugin_miniapps.py
@@ -0,0 +1,86 @@
+"""M5 — Plugin-MiniApps tests.
+
+Each business plugin contributes its MiniApps via the manifest
+contribution (same pattern as contacts_stats from M4): dms, mail, wiki,
+graph_rag and automation ship one dashboard app each, wired to the
+plugin's own permission and frontend component.
+"""
+
+from __future__ import annotations
+
+import pytest
+
+
+@pytest.fixture(autouse=True)
+def _fresh_registry():
+ from app.plugins.miniapp_registry import reset_miniapp_registry
+
+ reset_miniapp_registry()
+ yield
+ reset_miniapp_registry()
+
+
+# (plugin_name, app_id, permission, component, order)
+EXPECTED_APPS: list[tuple[str, str, str, str, int]] = [
+ ("dms", "dms_folders", "dms:read", "@/components/dashboard/DmsFoldersWidget", 60),
+ ("mail", "mail_unread", "mail:read", "@/components/dashboard/MailUnreadWidget", 70),
+ ("wiki", "wiki_recent", "wiki:read", "@/components/dashboard/WikiRecentWidget", 80),
+ ("graph_rag", "graph_overview", "graph:read", "@/components/dashboard/GraphOverviewWidget", 90),
+ ("automation", "automation_status", "automation:read", "@/components/dashboard/AutomationStatusWidget", 100),
+]
+
+
+class TestPluginMiniAppContributions:
+ @pytest.mark.parametrize("plugin_name,app_id,permission,component,order", EXPECTED_APPS)
+ def test_manifest_contains_miniapp(
+ self, plugin_name: str, app_id: str, permission: str, component: str, order: int
+ ):
+ """The plugin manifest declares the MiniApp with the right fields."""
+ import importlib
+
+ module = importlib.import_module(f"app.plugins.builtins.{plugin_name}.plugin")
+ plugin_cls = getattr(module, f"{plugin_name.replace('_', '').title().replace('Graphrag', 'GraphRAG')}Plugin")
+ contributions = plugin_cls().manifest.miniapps
+ found = [m for m in contributions if m.app_id == app_id]
+ assert len(found) == 1, f"{plugin_name}: miniapp {app_id} missing"
+ app = found[0]
+ assert app.permission == permission
+ assert app.component == component
+ assert app.order == order
+ assert "dashboard" in app.hosts
+
+ def test_lifecycle_registers_all_plugin_miniapps(self):
+ """BasePlugin._register_manifest_miniapps carries component (M4 fix)
+ for every contributing plugin — all five apps land in the registry."""
+ from app.plugins.miniapp_registry import get_miniapp_registry
+
+ registry = get_miniapp_registry()
+ for plugin_name, app_id, _permission, component, _order in EXPECTED_APPS:
+ import importlib
+
+ module = importlib.import_module(f"app.plugins.builtins.{plugin_name}.plugin")
+ plugin_cls = getattr(
+ module, f"{plugin_name.replace('_', '').title().replace('Graphrag', 'GraphRAG')}Plugin"
+ )
+ plugin_cls()._register_manifest_miniapps()
+
+ app = registry.get_app(app_id)
+ assert app is not None, f"{app_id} not registered"
+ assert app.component == component, f"{app_id}: component lost"
+
+ def test_settings_schemas_present(self):
+ """List-style widgets expose max_items in their settings_schema so the
+ generic settings form (M3) can render it."""
+ import importlib
+
+ for plugin_name, app_id, *_rest in EXPECTED_APPS:
+ module = importlib.import_module(f"app.plugins.builtins.{plugin_name}.plugin")
+ plugin_cls = getattr(
+ module, f"{plugin_name.replace('_', '').title().replace('Graphrag', 'GraphRAG')}Plugin"
+ )
+ contributions = plugin_cls().manifest.miniapps
+ app = next(m for m in contributions if m.app_id == app_id)
+ fields = app.settings_schema.get("fields", [])
+ assert any(f["name"] == "max_items" for f in fields), (
+ f"{app_id}: max_items missing in settings_schema"
+ )