frontend_nyla/src/pages/AutomationsDashboardPage.tsx
2026-04-11 00:07:30 +02:00

294 lines
9.3 KiB
TypeScript

/**
* AutomationsDashboardPage
*
* System-level dashboard for workflow runs across all features and mandates.
* Uses /api/system/workflow-runs endpoints with RBAC scoping.
*/
import React, { useState, useCallback, useEffect, useMemo } from 'react';
import { FaSync, FaPlay, FaCog, FaChartBar, FaDownload } from 'react-icons/fa';
import { FormGeneratorTable, type ColumnConfig } from '../components/FormGenerator';
import { useToast } from '../contexts/ToastContext';
import { formatUnixTimestamp } from '../utils/time';
import api from '../api';
import { useLanguage } from '../providers/language/LanguageContext';
import styles from './admin/Admin.module.css';
interface WorkflowRunMetrics {
totalRuns: number;
runsByStatus: Record<string, number>;
totalTokens: number;
totalCredits: number;
workflowCount: number;
activeWorkflows: number;
}
interface WorkflowRun {
id: string;
workflowId: string;
workflowLabel?: string;
mandateId?: string;
ownerId?: string;
status: string;
costTokens?: number;
costCredits?: number;
sysCreatedAt?: number;
sysModifiedAt?: number;
}
function _formatTs(ts?: number): string {
if (ts == null || ts <= 0) return '—';
const sec = ts < 1e12 ? ts : ts / 1000;
const { time } = formatUnixTimestamp(sec, undefined, {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
return time;
}
const _STATUS_COLORS: Record<string, string> = {
completed: 'var(--success-color, #28a745)',
failed: 'var(--danger-color, #dc3545)',
running: 'var(--primary-color, #007bff)',
paused: 'var(--warning-color, #ffc107)',
cancelled: 'var(--text-secondary, #666)',
};
interface MetricCardProps {
icon: React.ReactNode;
label: string;
value: string | number;
color?: string;
}
const MetricCard: React.FC<MetricCardProps> = ({ icon, label, value, color }) => (
<div
style={{
background: 'var(--bg-primary, #fff)',
border: '1px solid var(--border-color, #e0e0e0)',
borderRadius: 8,
padding: '16px 20px',
display: 'flex',
alignItems: 'center',
gap: 14,
minWidth: 180,
flex: '1 1 180px',
}}
>
<div style={{ fontSize: 22, color: color || 'var(--primary-color, #007bff)', display: 'flex', alignItems: 'center' }}>
{icon}
</div>
<div>
<div style={{ fontSize: '0.8rem', color: 'var(--text-secondary, #666)', marginBottom: 2 }}>{label}</div>
<div style={{ fontSize: '1.3rem', fontWeight: 700 }}>{value}</div>
</div>
</div>
);
export const AutomationsDashboardPage: React.FC = () => {
const { t } = useLanguage();
const { showError } = useToast();
const [metrics, setMetrics] = useState<WorkflowRunMetrics | null>(null);
const [runs, setRuns] = useState<WorkflowRun[]>([]);
const [loading, setLoading] = useState(true);
const _load = useCallback(async () => {
setLoading(true);
try {
const [metricsResp, runsResp] = await Promise.all([
api.get('/api/system/workflow-runs/metrics'),
api.get('/api/system/workflow-runs', { params: { limit: 50 } }),
]);
setMetrics(metricsResp.data);
setRuns(runsResp.data?.runs || []);
} catch (e) {
console.error('[automations] dashboard load failed', e);
showError(t('Fehler beim Laden des Automations-Dashboards'));
} finally {
setLoading(false);
}
}, [showError, t]);
useEffect(() => {
_load();
}, [_load]);
const _downloadRunTracing = useCallback(async (run: WorkflowRun) => {
if (!run.id) return;
try {
const resp = await api.get(`/api/system/workflow-runs/${run.id}/steps`);
const steps = resp.data?.steps || [];
const report = {
runId: run.id,
workflowId: run.workflowId,
workflowLabel: run.workflowLabel,
status: run.status,
startedAt: _formatTs(run.sysCreatedAt),
endedAt: _formatTs(run.sysModifiedAt),
steps,
};
const blob = new Blob([JSON.stringify(report, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `run-tracing-${run.id.slice(0, 8)}.json`;
a.click();
URL.revokeObjectURL(url);
} catch (e) {
console.error('[automations] download tracing failed', e);
showError(t('Download fehlgeschlagen'));
}
}, [showError, t]);
const _runColumns: ColumnConfig[] = useMemo(() => [
{
key: 'workflowLabel',
label: t('Workflow'),
type: 'string',
width: 200,
sortable: true,
formatter: (v: string, row: WorkflowRun) => v || row.workflowId || t('—'),
},
{
key: 'mandateId',
label: t('Mandant'),
type: 'string',
width: 120,
sortable: true,
filterable: true,
formatter: (v: string) => v ? v.slice(0, 8) + '…' : t('—'),
},
{
key: 'status',
label: t('Status'),
type: 'string',
width: 110,
sortable: true,
filterable: true,
formatter: (v: string) => (
<span style={{ color: _STATUS_COLORS[v] || 'inherit', fontWeight: 600, textTransform: 'capitalize' }}>
{v}
</span>
),
},
{
key: 'sysCreatedAt',
label: t('Gestartet'),
type: 'number',
width: 150,
sortable: true,
formatter: (v: number) => _formatTs(v),
},
{
key: 'sysModifiedAt',
label: t('Beendet'),
type: 'number',
width: 150,
formatter: (v: number) => _formatTs(v),
},
{
key: 'id',
label: '',
type: 'string',
width: 50,
sortable: false,
formatter: (_v: string, row: WorkflowRun) => (
<button
onClick={(e) => { e.stopPropagation(); _downloadRunTracing(row); }}
title={t('Tracing-Protokoll herunterladen')}
style={{
border: 'none', background: 'transparent', cursor: 'pointer',
color: 'var(--text-secondary, #666)', fontSize: 14, padding: 4,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}
>
<FaDownload />
</button>
),
},
], [t, _downloadRunTracing]);
return (
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
<div className={styles.pageHeader}>
<div>
<h1 className={styles.pageTitle}>{t('Automations')}</h1>
<p className={styles.pageSubtitle}>{t('Workflow-Runs über alle Features und Mandanten')}</p>
</div>
<div className={styles.headerActions}>
<button className={styles.secondaryButton} onClick={() => _load()} disabled={loading}>
<FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')}
</button>
</div>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 12, marginBottom: 24 }}>
<MetricCard icon={<FaCog />} label={t('Workflows')} value={metrics?.workflowCount ?? t('—')} />
<MetricCard icon={<FaPlay />} label={t('Aktive Workflows')} value={metrics?.activeWorkflows ?? t('—')} color="var(--success-color, #28a745)" />
<MetricCard icon={<FaChartBar />} label={t('Runs gesamt')} value={metrics?.totalRuns ?? t('—')} />
</div>
{metrics?.runsByStatus && Object.keys(metrics.runsByStatus).length > 0 && (
<div style={{ marginBottom: 24 }}>
<h3 style={{ fontSize: '0.95rem', fontWeight: 600, marginBottom: 8 }}>{t('Läufe nach Status')}</h3>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
{Object.entries(metrics.runsByStatus).map(([status, count]) => (
<span
key={status}
style={{
padding: '4px 12px',
borderRadius: 12,
fontSize: '0.85rem',
fontWeight: 600,
background: 'var(--bg-secondary, #f5f5f5)',
color: _STATUS_COLORS[status] || 'inherit',
}}
>
{status}: {count}
</span>
))}
</div>
</div>
)}
{metrics && (metrics.totalTokens > 0 || metrics.totalCredits > 0) && (
<div style={{ marginBottom: 24, display: 'flex', gap: 24 }}>
{metrics.totalTokens > 0 && (
<div>
<span style={{ fontSize: '0.8rem', color: 'var(--text-secondary, #666)' }}>{t('Tokens gesamt:')} </span>
<strong>{metrics.totalTokens.toLocaleString('de-DE')}</strong>
</div>
)}
{metrics.totalCredits > 0 && (
<div>
<span style={{ fontSize: '0.8rem', color: 'var(--text-secondary, #666)' }}>{t('Credits gesamt:')} </span>
<strong>{metrics.totalCredits.toLocaleString('de-DE', { minimumFractionDigits: 2 })}</strong>
</div>
)}
</div>
)}
<h3 style={{ fontSize: '0.95rem', fontWeight: 600, marginBottom: 8 }}>{t('Letzte Runs')}</h3>
<div className={styles.tableContainer}>
<FormGeneratorTable<WorkflowRun>
data={runs}
columns={_runColumns}
loading={loading}
pagination={true}
pageSize={15}
searchable={true}
filterable={true}
sortable={true}
selectable={false}
emptyMessage={t('Noch keine Workflow-Runs vorhanden.')}
/>
</div>
</div>
);
};
export default AutomationsDashboardPage;