feat(M4): System-Rueckbau — Dashboard-Inhalte als MiniApps, Core = reiner Host (#362)
Check Cross-Plugin Imports / check (push) Has been cancelled
Check Cross-Plugin Imports / check (push) Has been cancelled
- system_miniapps.py: audit_activity (audit:read, settings max_items) + system_metrics (settings:read) als Core-Apps in Registry - base.py-Fix: native Manifest-MiniApps reichen component durch (M1-Luecke) - contacts-Manifest: contacts_stats (ContactsStatsWidget, contacts:read, show_companies/show_persons) - Seed-Fix: nur renderbare Apps (component) landen im Dashboard-Layout - Frontend: ContactsStatsWidget, AuditActivityWidget, SystemMetricsWidget; MiniAppHost-Registry +3 - Dashboard.tsx = reiner Host (26 Z.); Page-Tests auf Pure-Host umgeschrieben - Tests: M4 7/7 (TDD rot->gruen), Backend-Regression 46/46, Vitest 22/22, tsc clean, build OK
This commit is contained in:
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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 }) => (
|
||||
<div data-testid={`miniapp-${appId}`}>app:{appId}</div>
|
||||
// DashboardBuilder is covered by its own suite — render a stub here
|
||||
vi.mock('@/components/dashboard/DashboardBuilder', () => ({
|
||||
DashboardBuilder: () => (
|
||||
<div data-testid="dashboard-builder-stub">builder</div>
|
||||
),
|
||||
}));
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 (
|
||||
<p className="text-sm text-secondary-500" data-testid="audit-activity-error">
|
||||
{t('dashboard.activityUnavailable')}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div data-testid="audit-activity-widget">
|
||||
<ActivityFeed activities={activities} title={t('dashboard.recentActivity')} maxItems={maxItems} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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: <Building2 className="w-4 h-4 text-primary-600" aria-hidden="true" />,
|
||||
testId: 'contacts-stats-companies',
|
||||
});
|
||||
}
|
||||
if (showPersons) {
|
||||
cards.push({
|
||||
key: 'persons',
|
||||
label: t('dashboard.totalContacts'),
|
||||
value: personsData?.total ?? 0,
|
||||
icon: <User className="w-4 h-4 text-primary-600" aria-hidden="true" />,
|
||||
testId: 'contacts-stats-persons',
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4" data-testid="contacts-stats-widget">
|
||||
{cards.map((c) => (
|
||||
<div key={c.key} className="border border-secondary-200 rounded-lg p-4 bg-white">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
{c.icon}
|
||||
<span className="text-sm font-medium text-secondary-900">{c.label}</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-secondary-900" data-testid={c.testId}>
|
||||
{c.value}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -27,6 +27,15 @@ const widgetRegistry: Record<string, React.LazyExoticComponent<React.ComponentTy
|
||||
'@/components/dashboard/CalendarUpcomingWidget': lazy(() =>
|
||||
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 {
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex items-center gap-2 p-3 text-sm text-secondary-500" data-testid="system-metrics-forbidden">
|
||||
<ShieldAlert className="w-4 h-4" aria-hidden="true" />
|
||||
{t('dashboard.systemMetricsNoAccess', 'Systemmetriken erfordern Admin-Rechte.')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!systemData) {
|
||||
return <div className="animate-pulse h-24 bg-secondary-100 rounded" data-testid="system-metrics-loading" />;
|
||||
}
|
||||
|
||||
const metrics = [
|
||||
{ key: 'db', icon: <Database className="w-4 h-4 text-primary-600" aria-hidden="true" />, label: 'Database', up: systemData.database?.status === 'up', sub: systemData.database?.db_size_human || '—', testId: 'system-metrics-db' },
|
||||
{ key: 'redis', icon: <Server className="w-4 h-4 text-primary-600" aria-hidden="true" />, label: 'Redis', up: systemData.redis?.status === 'up', sub: systemData.redis?.used_memory_human || '—', testId: 'system-metrics-redis' },
|
||||
{ key: 'worker', icon: <Cpu className="w-4 h-4 text-primary-600" aria-hidden="true" />, label: 'Worker', up: systemData.worker?.status === 'up', sub: `Queue: ${systemData.worker?.queue_length ?? '—'}`, testId: 'system-metrics-worker' },
|
||||
{ key: 'api', icon: <Activity className="w-4 h-4 text-primary-600" aria-hidden="true" />, label: 'API', up: true, sub: `Errors: ${systemData.api?.error_count ?? 0}`, testId: 'system-metrics-api' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4" data-testid="system-metrics-widget">
|
||||
{metrics.map((m) => (
|
||||
<div key={m.key} className="border border-secondary-200 rounded-lg p-4 bg-white">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
{m.icon}
|
||||
<span className="text-sm font-medium text-secondary-900">{m.label}</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-secondary-900" data-testid={m.testId}>
|
||||
{m.up ? '✅' : '❌'}
|
||||
</p>
|
||||
<p className="text-xs text-secondary-500">{m.sub}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -162,7 +162,8 @@
|
||||
"narrower": "Schmaler",
|
||||
"taller": "Höher",
|
||||
"shorter": "Niedriger"
|
||||
}
|
||||
},
|
||||
"systemMetricsNoAccess": "Systemmetriken erfordern Admin-Rechte."
|
||||
},
|
||||
"companies": {
|
||||
"title": "Firmen",
|
||||
|
||||
@@ -162,7 +162,8 @@
|
||||
"narrower": "Narrower",
|
||||
"taller": "Taller",
|
||||
"shorter": "Shorter"
|
||||
}
|
||||
},
|
||||
"systemMetricsNoAccess": "System metrics require admin rights."
|
||||
},
|
||||
"companies": {
|
||||
"title": "Companies",
|
||||
|
||||
@@ -1,158 +1,25 @@
|
||||
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 (
|
||||
<div className="p-6 max-w-7xl mx-auto" data-testid="dashboard-page">
|
||||
<h1 className="text-2xl font-bold text-secondary-900 mb-6">{t('dashboard.title')}</h1>
|
||||
|
||||
{/* Personal dashboard builder (Phase M3) — replaces the old plugin widget grid */}
|
||||
<div className="mb-10">
|
||||
<DashboardBuilder />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
||||
<StatCard
|
||||
label={t('dashboard.totalCompanies')}
|
||||
value={totalCompanies}
|
||||
testId="stat-companies"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('dashboard.totalContacts')}
|
||||
value={totalContacts}
|
||||
testId="stat-contacts"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('dashboard.activeThisWeek')}
|
||||
value={activeThisWeek}
|
||||
testId="stat-active-week"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('dashboard.newThisMonth')}
|
||||
value={newThisMonth}
|
||||
testId="stat-new-month"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* System Metrics (Admin only) — I-DASH, I-COST, I-USE (M4: becomes system MiniApp) */}
|
||||
{isAdmin && systemData && (
|
||||
<div className="mb-8">
|
||||
<h2 className="text-lg font-semibold text-secondary-800 mb-4">{t('dashboard.systemMetrics', 'System Status')}</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="border border-secondary-200 rounded-lg p-4 bg-white">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Database className="w-4 h-4 text-primary-600" />
|
||||
<span className="text-sm font-medium text-secondary-900">Database</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-secondary-900">{systemData.database?.status === 'up' ? '✅' : '❌'}</p>
|
||||
<p className="text-xs text-secondary-500">{systemData.database?.db_size_human || '—'}</p>
|
||||
</div>
|
||||
<div className="border border-secondary-200 rounded-lg p-4 bg-white">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Server className="w-4 h-4 text-primary-600" />
|
||||
<span className="text-sm font-medium text-secondary-900">Redis</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-secondary-900">{systemData.redis?.status === 'up' ? '✅' : '❌'}</p>
|
||||
<p className="text-xs text-secondary-500">{systemData.redis?.used_memory_human || '—'}</p>
|
||||
</div>
|
||||
<div className="border border-secondary-200 rounded-lg p-4 bg-white">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Cpu className="w-4 h-4 text-primary-600" />
|
||||
<span className="text-sm font-medium text-secondary-900">Worker</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-secondary-900">{systemData.worker?.status === 'up' ? '✅' : '❌'}</p>
|
||||
<p className="text-xs text-secondary-500">Queue: {systemData.worker?.queue_length ?? '—'}</p>
|
||||
</div>
|
||||
<div className="border border-secondary-200 rounded-lg p-4 bg-white">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Activity className="w-4 h-4 text-primary-600" />
|
||||
<span className="text-sm font-medium text-secondary-900">API</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-secondary-900">{systemData.api?.total_requests ?? '—'}</p>
|
||||
<p className="text-xs text-secondary-500">Errors: {systemData.api?.error_count ?? 0}</p>
|
||||
</div>
|
||||
</div>
|
||||
{systemData.llm && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 mt-4">
|
||||
<div className="border border-secondary-200 rounded-lg p-4 bg-white">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<DollarSign className="w-4 h-4 text-warning-600" />
|
||||
<span className="text-sm font-medium text-secondary-900">LLM Cost (24h)</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-secondary-900">${systemData.llm.last_24h_cost?.toFixed(2) ?? '0.00'}</p>
|
||||
</div>
|
||||
<div className="border border-secondary-200 rounded-lg p-4 bg-white">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<TrendingUp className="w-4 h-4 text-primary-600" />
|
||||
<span className="text-sm font-medium text-secondary-900">LLM Tokens (24h)</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-secondary-900">{systemData.llm.last_24h_tokens?.toLocaleString() ?? '0'}</p>
|
||||
</div>
|
||||
<div className="border border-secondary-200 rounded-lg p-4 bg-white">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Activity className="w-4 h-4 text-primary-600" />
|
||||
<span className="text-sm font-medium text-secondary-900">Active Plugins</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-secondary-900">{systemData.plugins?.active_plugins?.length ?? '—'}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{auditError ? (
|
||||
<p className="text-sm text-secondary-500" data-testid="activity-unavailable">
|
||||
{t('dashboard.activityUnavailable')}
|
||||
</p>
|
||||
) : (
|
||||
<ActivityFeed activities={activities} title={t('dashboard.recentActivity')} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user