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,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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user