feat(M5): Plugin-MiniApps — dms, mail, wiki, graph_rag, automation (#363)
Check Cross-Plugin Imports / check (push) Has been cancelled

- 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
This commit is contained in:
Agent Zero
2026-08-31 00:10:31 +02:00
parent 24dc78977c
commit 7ed5349e86
12 changed files with 524 additions and 2 deletions
@@ -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 <div className="animate-pulse h-24 bg-secondary-100 rounded" data-testid="automation-status-loading" />;
}
if (isError) {
return (
<p className="text-sm text-secondary-500" data-testid="automation-status-error">
{t('dashboard.widgetError')}
</p>
);
}
const automations = data ?? [];
const active = automations.filter((a) => a.active).length;
const inactive = automations.length - active;
return (
<div className="space-y-3" data-testid="automation-status-widget">
<div className="flex items-center gap-2">
<Workflow className="w-5 h-5 text-primary-600" aria-hidden="true" />
<span className="text-sm text-secondary-700">
{active} {t('automation.active', 'aktiv')} · {inactive} {t('automation.inactive', 'inaktiv')}
</span>
</div>
{automations.length === 0 && (
<p className="text-sm text-secondary-500">{t('automation.none', 'Keine Automationen definiert.')}</p>
)}
{automations.slice(0, maxItems).map((a) => (
<div key={a.id} className="flex items-center justify-between gap-2 text-sm">
<span className="text-secondary-700 truncate" title={a.description || a.name}>
{a.name}
</span>
{a.active ? (
<Play className="w-3.5 h-3.5 text-success-600 shrink-0" aria-label={t('automation.active', 'aktiv')} />
) : (
<Pause className="w-3.5 h-3.5 text-secondary-400 shrink-0" aria-label={t('automation.inactive', 'inaktiv')} />
)}
</div>
))}
</div>
);
}
@@ -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 <div className="animate-pulse h-24 bg-secondary-100 rounded" data-testid="dms-folders-loading" />;
}
if (isError) {
return (
<p className="text-sm text-secondary-500" data-testid="dms-folders-error">
{t('dashboard.widgetError')}
</p>
);
}
// fetchFolders returns a tree — flatten top-level folders first
const folders = (data ?? []).slice(0, maxItems);
if (folders.length === 0) {
return (
<p className="text-sm text-secondary-500" data-testid="dms-folders-empty">
{t('dms.noFolders', 'Keine Ordner vorhanden.')}
</p>
);
}
return (
<div className="space-y-2" data-testid="dms-folders-widget">
{folders.map((f) => (
<div key={f.id} className="flex items-center justify-between gap-2">
<span className="flex items-center gap-2 text-sm text-secondary-700 truncate">
<FolderOpen className="w-4 h-4 text-primary-600 shrink-0" aria-hidden="true" />
{f.name}
</span>
<span className="text-sm font-medium text-secondary-900" data-testid={`dms-folder-count-${f.id}`}>
{f.file_count ?? 0}
</span>
</div>
))}
</div>
);
}
@@ -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 <div className="animate-pulse h-24 bg-secondary-100 rounded" data-testid="graph-overview-loading" />;
}
if (isError) {
return (
<p className="text-sm text-secondary-500" data-testid="graph-overview-error">
{t('dashboard.widgetError')}
</p>
);
}
const items = data?.items ?? [];
if (items.length === 0) {
return (
<p className="text-sm text-secondary-500" data-testid="graph-overview-empty">
{t('knowledge.noRelationships', 'Keine Beziehungen vorhanden.')}
</p>
);
}
return (
<div className="space-y-2" data-testid="graph-overview-widget">
<div className="flex items-center gap-2 text-sm text-secondary-500">
<Share2 className="w-4 h-4" aria-hidden="true" />
{data?.total ?? items.length} {t('knowledge.relationships', 'Beziehungen')}
</div>
{items.map((r) => (
<div key={r.id} className="flex items-center justify-between gap-2 text-sm">
<span className="text-secondary-700 truncate">{r.source_type}</span>
<span className="text-xs px-1.5 py-0.5 rounded bg-primary-50 text-primary-700 shrink-0">
{r.relationship_type}
</span>
<span className="text-secondary-700 truncate">{r.target_type}</span>
</div>
))}
</div>
);
}
@@ -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 <div className="animate-pulse h-24 bg-secondary-100 rounded" data-testid="mail-unread-loading" />;
}
if (isError) {
return (
<p className="text-sm text-secondary-500" data-testid="mail-unread-error">
{t('dashboard.widgetError')}
</p>
);
}
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 (
<div className="flex items-center gap-2 text-sm text-secondary-500" data-testid="mail-unread-empty">
<Mail className="w-4 h-4" aria-hidden="true" />
{t('mail.noUnread', 'Keine ungelesenen E-Mails.')}
</div>
);
}
return (
<div className="space-y-2" data-testid="mail-unread-widget">
{rows.slice(0, maxItems).map((r) => (
<div key={r.key} className="flex items-center justify-between gap-2">
<span className="text-sm text-secondary-700 truncate" title={`${r.account} / ${r.folder}`}>
{r.folder}
</span>
<span className="text-sm font-semibold text-primary-700" data-testid={`mail-unread-${r.key}`}>
{r.unread}
</span>
</div>
))}
</div>
);
}
@@ -36,6 +36,21 @@ const widgetRegistry: Record<string, React.LazyExoticComponent<React.ComponentTy
'@/components/dashboard/SystemMetricsWidget': lazy(() =>
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 {
@@ -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 <div className="animate-pulse h-24 bg-secondary-100 rounded" data-testid="wiki-recent-loading" />;
}
if (isError) {
return (
<p className="text-sm text-secondary-500" data-testid="wiki-recent-error">
{t('dashboard.widgetError')}
</p>
);
}
const articles = data?.items ?? [];
if (articles.length === 0) {
return (
<p className="text-sm text-secondary-500" data-testid="wiki-recent-empty">
{t('wiki.noArticles', 'Keine Artikel vorhanden.')}
</p>
);
}
return (
<div className="space-y-2" data-testid="wiki-recent-widget">
{articles.map((a) => (
<div key={a.id} className="flex items-center gap-2">
<BookOpen className="w-4 h-4 text-primary-600 shrink-0" aria-hidden="true" />
<span className="text-sm text-secondary-700 truncate flex-1" title={a.title}>
{a.title}
</span>
<span className="text-xs text-secondary-400 shrink-0">v{a.version}</span>
</div>
))}
</div>
);
}