From 7ed5349e862688dc3eac4b5102a190d154f0731f Mon Sep 17 00:00:00 2001 From: Agent Zero Date: Mon, 31 Aug 2026 00:10:31 +0200 Subject: [PATCH] =?UTF-8?q?feat(M5):=20Plugin-MiniApps=20=E2=80=94=20dms,?= =?UTF-8?q?=20mail,=20wiki,=20graph=5Frag,=20automation=20(#363)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 5 Manifest-Beiträge (MiniAppContribution): dms_folders (dms:read), mail_unread (mail:read), wiki_recent (wiki:read), graph_overview (graph:read), automation_status (automation:read) — je settings_schema max_items, order 60-100 - 5 Frontend-Widgets auf bestehenden API-Clients (keine neuen Backend-Endpoints): DmsFoldersWidget, MailUnreadWidget, WikiRecentWidget, GraphOverviewWidget, AutomationStatusWidget - MiniAppHost-Registry +5; tsc clean, build OK - Tests: M5 7/7 (TDD rot->gruen), Backend-Regression 53/53, Vitest 22/22 --- app/plugins/builtins/automation/plugin.py | 20 +++++ app/plugins/builtins/dms/plugin.py | 20 +++++ app/plugins/builtins/graph_rag/plugin.py | 21 ++++- app/plugins/builtins/mail/plugin.py | 20 +++++ app/plugins/builtins/wiki/plugin.py | 27 +++++- .../dashboard/AutomationStatusWidget.tsx | 59 +++++++++++++ .../components/dashboard/DmsFoldersWidget.tsx | 60 +++++++++++++ .../dashboard/GraphOverviewWidget.tsx | 61 +++++++++++++ .../components/dashboard/MailUnreadWidget.tsx | 80 +++++++++++++++++ .../src/components/dashboard/MiniAppHost.tsx | 15 ++++ .../components/dashboard/WikiRecentWidget.tsx | 57 ++++++++++++ tests/test_m5_plugin_miniapps.py | 86 +++++++++++++++++++ 12 files changed, 524 insertions(+), 2 deletions(-) create mode 100644 frontend/src/components/dashboard/AutomationStatusWidget.tsx create mode 100644 frontend/src/components/dashboard/DmsFoldersWidget.tsx create mode 100644 frontend/src/components/dashboard/GraphOverviewWidget.tsx create mode 100644 frontend/src/components/dashboard/MailUnreadWidget.tsx create mode 100644 frontend/src/components/dashboard/WikiRecentWidget.tsx create mode 100644 tests/test_m5_plugin_miniapps.py 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 ( +
+
+
+ {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.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 ( +
+
+
+ {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 ( +
+
+ ); + } + + 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) => ( +
+
+ ))} +
+ ); +} 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" + )