feat(M5): Plugin-MiniApps — dms, mail, wiki, graph_rag, automation (#363)
Check Cross-Plugin Imports / check (push) Has been cancelled
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user