294 lines
9.3 KiB
TypeScript
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;
|