diff --git a/app/core/system_miniapps.py b/app/core/system_miniapps.py
new file mode 100644
index 0000000..e4cf8b4
--- /dev/null
+++ b/app/core/system_miniapps.py
@@ -0,0 +1,63 @@
+"""Core-owned system MiniApps (Phase M4).
+
+Host-level MiniApps that are not owned by a single plugin: audit activity
+feed and system metrics. They register in the universal registry with
+``plugin_name="system"`` at app startup and unregister with the registry
+reset (tests) — they never depend on plugin activation state.
+
+Permissions follow the owning data source:
+- audit_activity -> audit:read (audit log route guard, CORE_PERMISSIONS)
+- system_metrics -> settings:read (Roadmap M4; the /system/dashboard
+ endpoint itself stays require_admin — the widget degrades gracefully
+ with a permission hint for non-admins)
+"""
+
+from __future__ import annotations
+
+from app.plugins.miniapp_registry import get_miniapp_registry
+
+SYSTEM_PLUGIN_NAME = "system"
+
+
+def register_system_miniapps() -> None:
+ """Register the core system MiniApps in the universal registry."""
+ registry = get_miniapp_registry()
+
+ registry.register(
+ app_id="audit_activity",
+ name="Aktivitäten",
+ icon="History",
+ description="Letzte Aktivitäten aus dem Audit-Log (Benutzer, Aktion, Zeitpunkt).",
+ plugin_name=SYSTEM_PLUGIN_NAME,
+ permission="audit:read",
+ settings_schema={
+ "fields": [
+ {
+ "name": "max_items",
+ "label": "Max. Einträge",
+ "type": "number",
+ "default": 10,
+ }
+ ]
+ },
+ col_span=2,
+ row_span=1,
+ hosts=["chat", "dashboard", "window"],
+ component="@/components/dashboard/AuditActivityWidget",
+ order=40,
+ )
+
+ registry.register(
+ app_id="system_metrics",
+ name="System Status",
+ icon="Server",
+ description="Datenbank-, Redis-, Worker- und API-Metriken (Administration).",
+ plugin_name=SYSTEM_PLUGIN_NAME,
+ permission="settings:read",
+ settings_schema={},
+ col_span=2,
+ row_span=1,
+ hosts=["chat", "dashboard", "window"],
+ component="@/components/dashboard/SystemMetricsWidget",
+ order=50,
+ )
diff --git a/app/main.py b/app/main.py
index 8972ace..9e9eafc 100644
--- a/app/main.py
+++ b/app/main.py
@@ -218,6 +218,11 @@ async def lifespan(app: FastAPI):
registry.initialize(get_migration_engine(), app)
registry.discover_builtins()
+ # Core system MiniApps (Phase M4): host-level, independent of plugin state
+ from app.core.system_miniapps import register_system_miniapps
+
+ register_system_miniapps()
+
# Install discovered builtin plugins and activate only those marked active in DB
from sqlalchemy import select as sa_select
from sqlalchemy.ext.asyncio import async_sessionmaker
diff --git a/app/plugins/base.py b/app/plugins/base.py
index 7a99fde..745201c 100644
--- a/app/plugins/base.py
+++ b/app/plugins/base.py
@@ -116,6 +116,7 @@ class BasePlugin(ABC):
col_span=getattr(m, "col_span", 1),
row_span=getattr(m, "row_span", 1),
hosts=getattr(m, "hosts", None),
+ component=getattr(m, "component", ""),
order=getattr(m, "order", 100),
)
diff --git a/app/plugins/builtins/contacts/plugin.py b/app/plugins/builtins/contacts/plugin.py
index 0b3c4e4..2f68598 100644
--- a/app/plugins/builtins/contacts/plugin.py
+++ b/app/plugins/builtins/contacts/plugin.py
@@ -13,6 +13,7 @@ from app.plugins.manifest import (
FrontendDashboardWidget,
FrontendMenuItem,
FrontendPageRoute,
+ MiniAppContribution,
PluginManifest,
PluginRouteDef,
)
@@ -60,6 +61,26 @@ class ContactsPlugin(BasePlugin):
],
events=[],
migrations=[],
+ miniapps=[
+ MiniAppContribution(
+ app_id="contacts_stats",
+ name="Kontakt-Zähler",
+ icon="Building2",
+ description="Firmen- und Kontakt-Zähler (persönliche StatCards).",
+ permission="contacts:read",
+ settings_schema={
+ "fields": [
+ {"name": "show_companies", "label": "Firmen anzeigen", "type": "boolean", "default": True},
+ {"name": "show_persons", "label": "Personen anzeigen", "type": "boolean", "default": True},
+ ]
+ },
+ col_span=2,
+ row_span=1,
+ hosts=["chat", "dashboard", "window"],
+ component="@/components/dashboard/ContactsStatsWidget",
+ order=5,
+ ),
+ ],
dashboard_widgets=[
FrontendDashboardWidget(
id="recent_contacts",
diff --git a/app/routes/dashboards.py b/app/routes/dashboards.py
index 0194d6b..4be5023 100644
--- a/app/routes/dashboards.py
+++ b/app/routes/dashboards.py
@@ -110,7 +110,9 @@ async def _seed_default_dashboard(
apps = [
a
for a in registry.list_apps(host="dashboard")
- if user_permits(current_user, a) and "dashboard" in (a.get("hosts") or [])
+ if user_permits(current_user, a)
+ and "dashboard" in (a.get("hosts") or [])
+ and a.get("component") # renderable only (M4: chat apps stay off layouts)
]
apps.sort(key=lambda a: a.get("order", 100))
diff --git a/frontend/src/__tests__/dashboard/Dashboard.test.tsx b/frontend/src/__tests__/dashboard/Dashboard.test.tsx
index ef8ba77..959a38d 100644
--- a/frontend/src/__tests__/dashboard/Dashboard.test.tsx
+++ b/frontend/src/__tests__/dashboard/Dashboard.test.tsx
@@ -5,48 +5,21 @@ import { MemoryRouter } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { DashboardPage } from '@/pages/Dashboard';
-vi.mock('@/api/hooks', () => ({
- useUnifiedContacts: (page: number, pageSize: number, search: any, type: string) => ({
- data: { items: [], total: type === 'company' ? 24 : 156, page: 1, page_size: 25 },
- isLoading: false,
- }),
- useAuditLog: () => ({
- data: {
- items: [
- { timestamp: '2025-06-29T10:00:00Z', user: 'anna.schmidt', action: 'create', entity: 'company', entity_id: '1', details: 'Firma erstellt' },
- { timestamp: '2025-06-28T14:00:00Z', user: 'max.mustermann', action: 'update', entity: 'contact', entity_id: '2', details: 'Kontakt aktualisiert' },
- { timestamp: '2025-06-27T09:00:00Z', user: 'admin', action: 'delete', entity: 'company', entity_id: '3', details: 'Firma gelöscht' },
- ],
- total: 3,
- },
- isError: false,
- }),
+/**
+ * DashboardPage tests (Phase M4): the page is a pure host — all content
+ * (stat cards, activity feed, system metrics) lives in MiniApp instances
+ * managed by the DashboardBuilder. The former legacy assertions
+ * (stat-companies etc.) moved to the widget level in M4.
+ */
+
+vi.mock('react-i18next', () => ({
+ useTranslation: () => ({ t: (key: string) => key }),
}));
-vi.mock('@/api/dashboard', () => ({
- useDashboardWidgets: () => ({
- data: { items: [], total: 0 },
- isError: false,
- }),
-}));
-
-// M3: DashboardPage renders the DashboardBuilder — mock its data layer
-vi.mock('@/api/dashboards', () => ({
- useDashboards: () => ({ data: [], isLoading: false }),
- useCreateDashboard: () => ({ mutate: vi.fn(), isPending: false }),
- useUpdateDashboard: () => ({ mutate: vi.fn(), isPending: false }),
- useDeleteDashboard: () => ({ mutate: vi.fn() }),
- useSetDefaultDashboard: () => ({ mutate: vi.fn() }),
-}));
-
-vi.mock('@/api/miniapps', () => ({
- useMiniapps: () => ({ data: { items: [], total: 0 } }),
- renderableDashboardApps: (apps: unknown[]) => (apps as never[]),
-}));
-
-vi.mock('@/components/dashboard/MiniAppHost', () => ({
- MiniAppHost: ({ appId }: { appId: string }) => (
-
app:{appId}
+// DashboardBuilder is covered by its own suite — render a stub here
+vi.mock('@/components/dashboard/DashboardBuilder', () => ({
+ DashboardBuilder: () => (
+ builder
),
}));
@@ -64,64 +37,27 @@ function renderPage() {
);
}
-describe('DashboardPage', () => {
- it('renders dashboard page', () => {
+describe('DashboardPage (pure host, M4)', () => {
+ it('renders the dashboard page shell', () => {
renderPage();
expect(screen.getByTestId('dashboard-page')).toBeInTheDocument();
});
- it('renders page title', () => {
+ it('renders the page title', () => {
renderPage();
- expect(screen.getByText('Dashboard')).toBeInTheDocument();
+ expect(screen.getByText('dashboard.title')).toBeInTheDocument();
});
- it('renders stat card for companies', () => {
+ it('renders the DashboardBuilder as its only content area', () => {
renderPage();
- expect(screen.getByTestId('stat-companies')).toBeInTheDocument();
+ expect(screen.getByTestId('dashboard-builder-stub')).toBeInTheDocument();
});
- it('renders stat card for contacts', () => {
+ it('does not render legacy hard-coded blocks anymore', () => {
renderPage();
- expect(screen.getByTestId('stat-contacts')).toBeInTheDocument();
- });
-
- it('renders stat card for active this week', () => {
- renderPage();
- expect(screen.getByTestId('stat-active-week')).toBeInTheDocument();
- });
-
- it('renders stat card for new this month', () => {
- renderPage();
- expect(screen.getByTestId('stat-new-month')).toBeInTheDocument();
- });
-
- it('renders correct company count in stat card', () => {
- renderPage();
- const statCompanies = screen.getByTestId('stat-companies');
- expect(statCompanies).toHaveTextContent('24');
- });
-
- it('renders correct contact count in stat card', () => {
- renderPage();
- const statContacts = screen.getByTestId('stat-contacts');
- expect(statContacts).toHaveTextContent('156');
- });
-
- it('renders activity feed', () => {
- renderPage();
- expect(screen.getByTestId('activity-feed')).toBeInTheDocument();
- });
-
- it('renders activity feed with user names from audit log', () => {
- renderPage();
- expect(screen.getByText('anna.schmidt')).toBeInTheDocument();
- expect(screen.getByText('max.mustermann')).toBeInTheDocument();
- });
-
- it('renders activity feed with action descriptions', () => {
- renderPage();
- expect(screen.getByText(/create/)).toBeInTheDocument();
- expect(screen.getByText(/update/)).toBeInTheDocument();
- expect(screen.getByText(/delete/)).toBeInTheDocument();
+ expect(screen.queryByTestId('stat-companies')).not.toBeInTheDocument();
+ expect(screen.queryByTestId('stat-contacts')).not.toBeInTheDocument();
+ expect(screen.queryByTestId('stat-active-week')).not.toBeInTheDocument();
+ expect(screen.queryByTestId('activity-feed')).not.toBeInTheDocument();
});
});
diff --git a/frontend/src/components/dashboard/AuditActivityWidget.tsx b/frontend/src/components/dashboard/AuditActivityWidget.tsx
new file mode 100644
index 0000000..5811569
--- /dev/null
+++ b/frontend/src/components/dashboard/AuditActivityWidget.tsx
@@ -0,0 +1,43 @@
+/**
+ * AuditActivityWidget — recent activity from the audit log (Phase M4).
+ *
+ * Successor of the hard-coded ActivityFeed block. Uses the same audit
+ * API as the /activity page; max_items comes from per-instance settings
+ * (settings_schema, default 10).
+ */
+
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+import { ActivityFeed, type ActivityItem } from '@/components/shared/ActivityFeed';
+import type { WidgetComponentProps } from '@/components/dashboard/MiniAppHost';
+import { useAuditLog } from '@/api/audit';
+import { formatDateTime } from '@/utils/date';
+
+export function AuditActivityWidget({ settings }: WidgetComponentProps) {
+ const { t } = useTranslation();
+ const maxItems = Math.max(1, Math.min(50, Number(settings?.max_items ?? 10) || 10));
+ const { data, isError } = useAuditLog(1, maxItems);
+
+ if (isError) {
+ return (
+
+ {t('dashboard.activityUnavailable')}
+
+ );
+ }
+
+ const entries = data?.items ?? [];
+ const activities: ActivityItem[] = entries.map((entry) => ({
+ id: `${entry.timestamp}-${entry.user}-${entry.action}`,
+ user: entry.user || 'System',
+ action: entry.action || '',
+ time: entry.timestamp ? (formatDateTime(entry.timestamp) || '') : '',
+ avatarUrl: null,
+ }));
+
+ return (
+
+ );
+}
diff --git a/frontend/src/components/dashboard/ContactsStatsWidget.tsx b/frontend/src/components/dashboard/ContactsStatsWidget.tsx
new file mode 100644
index 0000000..5b57519
--- /dev/null
+++ b/frontend/src/components/dashboard/ContactsStatsWidget.tsx
@@ -0,0 +1,58 @@
+/**
+ * ContactsStatsWidget — companies/persons counters (Phase M4).
+ *
+ * Successor of the hard-coded StatCards block: same data source
+ * (contacts contract counts via unified contacts), but as a personal
+ * MiniApp instance with per-instance settings.
+ */
+
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+import { Building2, User } from 'lucide-react';
+import type { WidgetComponentProps } from '@/components/dashboard/MiniAppHost';
+import { useUnifiedContacts } from '@/api/hooks';
+
+export function ContactsStatsWidget({ settings }: WidgetComponentProps) {
+ const { t } = useTranslation();
+ const showCompanies = settings?.show_companies !== false;
+ const showPersons = settings?.show_persons !== false;
+
+ const { data: companiesData } = useUnifiedContacts(1, 1, undefined, 'company');
+ const { data: personsData } = useUnifiedContacts(1, 1, undefined, 'person');
+
+ const cards: { key: string; label: string; value: number; icon: React.ReactNode; testId: string }[] = [];
+ if (showCompanies) {
+ cards.push({
+ key: 'companies',
+ label: t('dashboard.totalCompanies'),
+ value: companiesData?.total ?? 0,
+ icon: ,
+ testId: 'contacts-stats-companies',
+ });
+ }
+ if (showPersons) {
+ cards.push({
+ key: 'persons',
+ label: t('dashboard.totalContacts'),
+ value: personsData?.total ?? 0,
+ icon: ,
+ testId: 'contacts-stats-persons',
+ });
+ }
+
+ return (
+
+ {cards.map((c) => (
+
+
+ {c.icon}
+ {c.label}
+
+
+ {c.value}
+
+
+ ))}
+
+ );
+}
diff --git a/frontend/src/components/dashboard/MiniAppHost.tsx b/frontend/src/components/dashboard/MiniAppHost.tsx
index 8994584..c5c5cfe 100644
--- a/frontend/src/components/dashboard/MiniAppHost.tsx
+++ b/frontend/src/components/dashboard/MiniAppHost.tsx
@@ -27,6 +27,15 @@ const widgetRegistry: Record
import('@/components/dashboard/CalendarUpcomingWidget').then((m) => ({ default: m.CalendarUpcomingWidget }))
),
+ '@/components/dashboard/ContactsStatsWidget': lazy(() =>
+ import('@/components/dashboard/ContactsStatsWidget').then((m) => ({ default: m.ContactsStatsWidget }))
+ ),
+ '@/components/dashboard/AuditActivityWidget': lazy(() =>
+ import('@/components/dashboard/AuditActivityWidget').then((m) => ({ default: m.AuditActivityWidget }))
+ ),
+ '@/components/dashboard/SystemMetricsWidget': lazy(() =>
+ import('@/components/dashboard/SystemMetricsWidget').then((m) => ({ default: m.SystemMetricsWidget }))
+ ),
};
interface MiniAppHostProps {
diff --git a/frontend/src/components/dashboard/SystemMetricsWidget.tsx b/frontend/src/components/dashboard/SystemMetricsWidget.tsx
new file mode 100644
index 0000000..604bc37
--- /dev/null
+++ b/frontend/src/components/dashboard/SystemMetricsWidget.tsx
@@ -0,0 +1,56 @@
+/**
+ * SystemMetricsWidget — DB/Redis/Worker/API metrics (Phase M4).
+ *
+ * Successor of the hard-coded admin-only System Metrics block. The
+ * backend endpoint /system/dashboard stays require_admin; users without
+ * admin rights see a compact permission hint (the MiniApp itself is
+ * gated by settings:read — the stricter endpoint check still applies).
+ */
+
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+import { Database, Server, Cpu, Activity, ShieldAlert } from 'lucide-react';
+import type { WidgetComponentProps } from '@/components/dashboard/MiniAppHost';
+import { useSystemDashboard } from '@/api/systemDashboard';
+
+export function SystemMetricsWidget(_props: WidgetComponentProps) {
+ const { t } = useTranslation();
+ const { data: systemData, isError } = useSystemDashboard();
+
+ if (isError) {
+ return (
+
+
+ {t('dashboard.systemMetricsNoAccess', 'Systemmetriken erfordern Admin-Rechte.')}
+
+ );
+ }
+
+ if (!systemData) {
+ return ;
+ }
+
+ const metrics = [
+ { key: 'db', icon: , label: 'Database', up: systemData.database?.status === 'up', sub: systemData.database?.db_size_human || '—', testId: 'system-metrics-db' },
+ { key: 'redis', icon: , label: 'Redis', up: systemData.redis?.status === 'up', sub: systemData.redis?.used_memory_human || '—', testId: 'system-metrics-redis' },
+ { key: 'worker', icon: , label: 'Worker', up: systemData.worker?.status === 'up', sub: `Queue: ${systemData.worker?.queue_length ?? '—'}`, testId: 'system-metrics-worker' },
+ { key: 'api', icon: , label: 'API', up: true, sub: `Errors: ${systemData.api?.error_count ?? 0}`, testId: 'system-metrics-api' },
+ ];
+
+ return (
+
+ {metrics.map((m) => (
+
+
+ {m.icon}
+ {m.label}
+
+
+ {m.up ? '✅' : '❌'}
+
+
{m.sub}
+
+ ))}
+
+ );
+}
diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json
index 02efc21..2b84a9d 100644
--- a/frontend/src/i18n/locales/de.json
+++ b/frontend/src/i18n/locales/de.json
@@ -162,7 +162,8 @@
"narrower": "Schmaler",
"taller": "Höher",
"shorter": "Niedriger"
- }
+ },
+ "systemMetricsNoAccess": "Systemmetriken erfordern Admin-Rechte."
},
"companies": {
"title": "Firmen",
diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json
index 3c8229c..b2f2dfc 100644
--- a/frontend/src/i18n/locales/en.json
+++ b/frontend/src/i18n/locales/en.json
@@ -162,7 +162,8 @@
"narrower": "Narrower",
"taller": "Taller",
"shorter": "Shorter"
- }
+ },
+ "systemMetricsNoAccess": "System metrics require admin rights."
},
"companies": {
"title": "Companies",
diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx
index a6e655a..81712c5 100644
--- a/frontend/src/pages/Dashboard.tsx
+++ b/frontend/src/pages/Dashboard.tsx
@@ -1,157 +1,24 @@
import { useTranslation } from 'react-i18next';
-import { StatCard } from '@/components/shared/StatCard';
-import { ActivityFeed, ActivityItem } from '@/components/shared/ActivityFeed';
import { DashboardBuilder } from '@/components/dashboard/DashboardBuilder';
-import { useUnifiedContacts, useAuditLog } from '@/api/hooks';
-import { useSystemDashboard } from '@/api/systemDashboard';
-import { useAuthStore } from '@/store/authStore';
-import { formatDateTime } from '@/utils/date';
-import { Database, Server, Cpu, DollarSign, Activity, TrendingUp } from 'lucide-react';
+
+/**
+ * Dashboard page — pure host (Phase M4).
+ *
+ * All content (stat cards, activity feed, system metrics, plugin widgets)
+ * now lives in personal MiniApp instances managed by the builder. The
+ * former hard-coded blocks were migrated to widgets in M4:
+ * - StatCards -> ContactsStatsWidget (contacts plugin, contacts:read)
+ * - ActivityFeed -> AuditActivityWidget (system, audit:read)
+ * - System Metrics -> SystemMetricsWidget (system, settings:read)
+ */
export function DashboardPage() {
const { t } = useTranslation();
- const { data: companiesData } = useUnifiedContacts(1, 1, undefined, 'company');
- const { data: contactsData } = useUnifiedContacts(1, 1, undefined, 'person');
- const { data: auditData, isError: auditError } = useAuditLog(1, 5);
- const { data: systemData } = useSystemDashboard();
- const user = useAuthStore((s) => s.user);
- const isAdmin = user?.is_system_admin ?? false;
-
- const totalCompanies = companiesData?.total ?? 0;
- const totalContacts = contactsData?.total ?? 0;
-
- const auditEntries = auditData?.items ?? [];
- const activeThisWeek = auditEntries.filter((e) => {
- if (!e.timestamp) return false;
- const d = new Date(e.timestamp);
- const weekAgo = new Date();
- weekAgo.setDate(weekAgo.getDate() - 7);
- return d >= weekAgo;
- }).length;
-
- const newThisMonth = auditEntries.filter((e) => {
- if (!e.timestamp) return false;
- const d = new Date(e.timestamp);
- const monthAgo = new Date();
- monthAgo.setMonth(monthAgo.getMonth() - 1);
- return d >= monthAgo;
- }).length;
-
- const activities: ActivityItem[] = auditError
- ? []
- : auditEntries.map((entry) => ({
- id: `${entry.timestamp}-${entry.user}-${entry.action}`,
- user: entry.user || 'System',
- action: entry.action || '',
- time: entry.timestamp ? (formatDateTime(entry.timestamp) || '') : '',
- avatarUrl: null,
- }));
return (
{t('dashboard.title')}
-
- {/* Personal dashboard builder (Phase M3) — replaces the old plugin widget grid */}
-
-
-
-
-
-
-
-
-
-
-
- {/* System Metrics (Admin only) — I-DASH, I-COST, I-USE (M4: becomes system MiniApp) */}
- {isAdmin && systemData && (
-
-
{t('dashboard.systemMetrics', 'System Status')}
-
-
-
-
- Database
-
-
{systemData.database?.status === 'up' ? '✅' : '❌'}
-
{systemData.database?.db_size_human || '—'}
-
-
-
-
- Redis
-
-
{systemData.redis?.status === 'up' ? '✅' : '❌'}
-
{systemData.redis?.used_memory_human || '—'}
-
-
-
-
- Worker
-
-
{systemData.worker?.status === 'up' ? '✅' : '❌'}
-
Queue: {systemData.worker?.queue_length ?? '—'}
-
-
-
-
{systemData.api?.total_requests ?? '—'}
-
Errors: {systemData.api?.error_count ?? 0}
-
-
- {systemData.llm && (
-
-
-
-
- LLM Cost (24h)
-
-
${systemData.llm.last_24h_cost?.toFixed(2) ?? '0.00'}
-
-
-
-
- LLM Tokens (24h)
-
-
{systemData.llm.last_24h_tokens?.toLocaleString() ?? '0'}
-
-
-
-
{systemData.plugins?.active_plugins?.length ?? '—'}
-
-
- )}
-
- )}
-
- {auditError ? (
-
- {t('dashboard.activityUnavailable')}
-
- ) : (
-
- )}
+
);
}
diff --git a/tests/test_dashboards_backend.py b/tests/test_dashboards_backend.py
index e6d55ff..e6fa156 100644
--- a/tests/test_dashboards_backend.py
+++ b/tests/test_dashboards_backend.py
@@ -35,7 +35,10 @@ def _clean_miniapp_registry():
def _register_apps(*specs: tuple[str, str, int, int]) -> None:
- """Register test MiniApps: (app_id, permission, order, col_span)."""
+ """Register test MiniApps: (app_id, permission, order, col_span).
+
+ M4: seed only places renderable apps, so test apps carry a component.
+ """
from app.plugins.miniapp_registry import get_miniapp_registry
reg = get_miniapp_registry()
@@ -48,6 +51,7 @@ def _register_apps(*specs: tuple[str, str, int, int]) -> None:
col_span=col_span,
row_span=1,
hosts=["chat", "dashboard", "window"],
+ component="@/components/dashboard/TestWidget",
order=order,
)
diff --git a/tests/test_m4_system_miniapps.py b/tests/test_m4_system_miniapps.py
new file mode 100644
index 0000000..98f6b06
--- /dev/null
+++ b/tests/test_m4_system_miniapps.py
@@ -0,0 +1,159 @@
+"""M4 — System-Rückbau tests.
+
+Core-owned MiniApps (audit_activity, system_metrics) registered by the
+host; contacts_stats as a native manifest miniapp with component (fixes
+the base.py gap where native miniapps did not carry their frontend
+component); the dashboard seed only places renderable apps (component
+present) — chat interaction apps without a component stay off dashboard
+layouts.
+"""
+
+from __future__ import annotations
+
+import pytest
+from httpx import AsyncClient
+
+from tests.conftest import ORIGIN_HEADER, login_client, seed_tenant_and_users
+
+
+@pytest.fixture(autouse=True)
+def _registry_with_system_apps():
+ """Fresh registry per test WITH the core system miniapps registered."""
+ from app.plugins.miniapp_registry import (
+ reset_miniapp_registry,
+ )
+
+ reset_miniapp_registry()
+ from app.core.system_miniapps import register_system_miniapps
+
+ register_system_miniapps()
+ yield
+ reset_miniapp_registry()
+
+
+# ═══════════════════════════════════════════════════════════════
+# Unit: system miniapp definitions
+# ═══════════════════════════════════════════════════════════════
+
+
+class TestSystemMiniAppDefs:
+ def test_audit_activity_definition(self):
+ from app.plugins.miniapp_registry import get_miniapp_registry
+
+ app = get_miniapp_registry().get_app("audit_activity")
+ assert app is not None, "audit_activity must be registered"
+ assert app.plugin_name == "system"
+ assert app.permission == "audit:read"
+ assert app.component == "@/components/dashboard/AuditActivityWidget"
+ assert "dashboard" in app.hosts
+ # settings_schema drives the generic settings form (max_items)
+ fields = app.settings_schema.get("fields", [])
+ assert any(f["name"] == "max_items" for f in fields)
+
+ def test_system_metrics_definition(self):
+ from app.plugins.miniapp_registry import get_miniapp_registry
+
+ app = get_miniapp_registry().get_app("system_metrics")
+ assert app is not None, "system_metrics must be registered"
+ assert app.plugin_name == "system"
+ assert app.permission == "settings:read"
+ assert app.component == "@/components/dashboard/SystemMetricsWidget"
+ assert app.col_span >= 2
+
+ def test_native_manifest_miniapp_carries_component(self):
+ """contacts_stats lives in the native miniapps manifest section and
+ carries its frontend component (base.py must pass it through)."""
+ from app.plugins.builtins.contacts.plugin import ContactsPlugin
+
+ contributions = ContactsPlugin().manifest.miniapps
+ stats = [m for m in contributions if m.app_id == "contacts_stats"]
+ assert len(stats) == 1
+ assert stats[0].component == "@/components/dashboard/ContactsStatsWidget"
+ assert stats[0].permission == "contacts:read"
+
+ def test_base_plugin_registers_native_miniapp_with_component(self):
+ """The lifecycle path registers native miniapps INCLUDING component
+ (M4 fix for the M1 gap where only the dashboard_widgets alias
+ passed components through)."""
+ from app.plugins.builtins.contacts.plugin import ContactsPlugin
+ from app.plugins.miniapp_registry import get_miniapp_registry
+
+ plugin = ContactsPlugin()
+ plugin._register_manifest_miniapps()
+ app = get_miniapp_registry().get_app("contacts_stats")
+ assert app is not None
+ assert app.component == "@/components/dashboard/ContactsStatsWidget"
+ # dashboard_widgets alias still works
+ assert get_miniapp_registry().get_app("recent_contacts") is not None
+
+
+# ═══════════════════════════════════════════════════════════════
+# API: permission-filtered visibility
+# ═══════════════════════════════════════════════════════════════
+
+
+@pytest.mark.asyncio
+class TestSystemMiniAppsApi:
+ async def test_admin_sees_system_apps(self, client: AsyncClient, db_session):
+ await seed_tenant_and_users(db_session)
+ await login_client(client, "admin@tenanta.com")
+ resp = await client.get("/api/v1/miniapps", headers=ORIGIN_HEADER)
+ assert resp.status_code == 200
+ app_ids = {i["app_id"] for i in resp.json()["items"]}
+ assert "audit_activity" in app_ids
+ assert "system_metrics" in app_ids
+
+ async def test_viewer_does_not_see_system_apps(
+ self, client: AsyncClient, db_session
+ ):
+ """Viewer has neither audit:read nor settings:read -> fail-closed."""
+ await seed_tenant_and_users(db_session)
+ await login_client(client, "viewer@tenanta.com")
+ resp = await client.get("/api/v1/miniapps", headers=ORIGIN_HEADER)
+ assert resp.status_code == 200
+ app_ids = {i["app_id"] for i in resp.json()["items"]}
+ assert "audit_activity" not in app_ids
+ assert "system_metrics" not in app_ids
+
+
+# ═══════════════════════════════════════════════════════════════
+# Seed: only renderable apps (component) go onto dashboard layouts
+# ═══════════════════════════════════════════════════════════════
+
+
+@pytest.mark.asyncio
+class TestSeedComponentFilter:
+ async def test_seed_excludes_apps_without_component(
+ self, client: AsyncClient, db_session
+ ):
+ """Chat interaction apps without a frontend component must not be
+ seeded onto dashboard layouts (production measurement 2026-08-30:
+ 9 seeded widgets, only 3 renderable)."""
+ await seed_tenant_and_users(db_session)
+ await login_client(client, "admin@tenanta.com")
+
+ from app.plugins.miniapp_registry import get_miniapp_registry
+
+ reg = get_miniapp_registry()
+ reg.register(
+ app_id="renderable_app", name="Renderable", plugin_name="test",
+ permission="", component="@/components/x", order=90,
+ )
+ reg.register(
+ app_id="chat_only_app", name="Chat Only", plugin_name="test",
+ permission="", component="", order=95,
+ )
+
+ resp = await client.get("/api/v1/dashboards", headers=ORIGIN_HEADER)
+ assert resp.status_code == 200
+ items = resp.json()
+ assert len(items) == 1
+ seeded_ids = {
+ w["app_id"]
+ for w in items[0]["layout"]["tabs"][0]["widgets"]
+ }
+ assert "renderable_app" in seeded_ids
+ assert "chat_only_app" not in seeded_ids
+ # system apps are renderable and (for the admin) permitted -> seeded
+ assert "audit_activity" in seeded_ids
+ assert "system_metrics" in seeded_ids