1276 lines
47 KiB
TypeScript
1276 lines
47 KiB
TypeScript
/**
|
|
* AutomationsDashboardPage
|
|
*
|
|
* System-level automation page with two tabs:
|
|
* - Dashboard: Metrics + workflow runs table (backend-paginated)
|
|
* - Workflows: Central management of all RBAC-accessible workflows across instances
|
|
*/
|
|
|
|
import React, { useState, useCallback, useEffect, useMemo, useRef } from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { FaSync, FaPlay, FaCog, FaChartBar, FaDownload, FaCheck, FaBan, FaPen, FaEye, FaTimes, FaStream, FaStop } from 'react-icons/fa';
|
|
import { FormGeneratorTable, type ColumnConfig } from '../components/FormGenerator';
|
|
import { Tabs } from '../components/UiComponents/Tabs';
|
|
import { useToast } from '../contexts/ToastContext';
|
|
import { usePrompt } from '../hooks/usePrompt';
|
|
import { useApiRequest } from '../hooks/useApi';
|
|
import { formatUnixTimestamp } from '../utils/time';
|
|
import { updateWorkflow, executeGraph, deleteSystemWorkflow, fetchWorkspaceRuns, fetchWorkspaceRunDetail, type WorkspaceRun } from '../api/workflowApi';
|
|
import { fetchAttributes } from '../api/attributesApi';
|
|
import type { AttributeDefinition } from '../api/attributesApi';
|
|
import { resolveColumnTypes } from '../utils/columnTypeResolver';
|
|
import api from '../api';
|
|
import { useLanguage } from '../providers/language/LanguageContext';
|
|
import { useNavigation, type DynamicBlock } from '../hooks/useNavigation';
|
|
import styles from './admin/Admin.module.css';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Shared types & helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
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;
|
|
mandateLabel?: string;
|
|
featureInstanceId?: string;
|
|
instanceLabel?: string;
|
|
ownerId?: string;
|
|
status: string;
|
|
costTokens?: number;
|
|
costCredits?: number;
|
|
sysCreatedAt?: number;
|
|
sysModifiedAt?: number;
|
|
}
|
|
|
|
interface SystemWorkflow {
|
|
id: string;
|
|
mandateId: string;
|
|
featureInstanceId: string;
|
|
featureCode?: string;
|
|
label: string;
|
|
active: boolean;
|
|
isRunning?: boolean;
|
|
activeRunId?: string;
|
|
stuckAtNodeLabel?: string;
|
|
stuckAtNodeId?: string;
|
|
createdAt?: number;
|
|
sysCreatedAt?: number;
|
|
lastStartedAt?: number;
|
|
runCount?: number;
|
|
mandateLabel?: string;
|
|
instanceLabel?: string;
|
|
canEdit?: boolean;
|
|
canDelete?: boolean;
|
|
canExecute?: boolean;
|
|
invocations?: Array<{ id: string; enabled: boolean; kind: string }>;
|
|
graph?: Record<string, any>;
|
|
}
|
|
|
|
const _FEATURES_WITH_EDITOR = new Set(['graphicalEditor', 'workspace']);
|
|
|
|
const _ROLE_PRIORITY: Record<string, number> = { admin: 3, user: 2, viewer: 1 };
|
|
|
|
function _bestEditorInstance(
|
|
dynamicBlock: DynamicBlock | null,
|
|
mandateId: string,
|
|
): { instanceId: string; featureCode: string } | null {
|
|
if (!dynamicBlock) return null;
|
|
const mandate = dynamicBlock.mandates.find((m) => m.id === mandateId);
|
|
if (!mandate) return null;
|
|
|
|
let best: { instanceId: string; featureCode: string; score: number } | null = null;
|
|
for (const feat of mandate.features) {
|
|
for (const inst of feat.instances) {
|
|
const fc = inst.featureCode
|
|
|| feat.uiComponent.replace(/^feature\./, '');
|
|
if (!_FEATURES_WITH_EDITOR.has(fc)) continue;
|
|
let score = 0;
|
|
if (inst.isAdmin) {
|
|
score = 10;
|
|
} else {
|
|
for (const v of inst.views) {
|
|
const key = v.objectKey || '';
|
|
for (const [suffix, prio] of Object.entries(_ROLE_PRIORITY)) {
|
|
if (key.endsWith(suffix) && prio > score) score = prio;
|
|
}
|
|
}
|
|
}
|
|
if (!best || score > best.score) {
|
|
best = { instanceId: inst.id, featureCode: fc, score };
|
|
}
|
|
}
|
|
}
|
|
return best ? { instanceId: best.instanceId, featureCode: best.featureCode } : null;
|
|
}
|
|
|
|
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)',
|
|
stopped: 'var(--warning-color, #ffc107)',
|
|
cancelled: 'var(--text-secondary, #666)',
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// MetricCard
|
|
// ---------------------------------------------------------------------------
|
|
|
|
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>
|
|
);
|
|
|
|
// ===========================================================================
|
|
// Live Run Tracing Modal (SSE-based, can be opened/closed freely)
|
|
// ===========================================================================
|
|
|
|
interface _TracingStep {
|
|
id: string;
|
|
nodeId: string;
|
|
nodeType: string;
|
|
status: string;
|
|
startedAt?: number;
|
|
completedAt?: number;
|
|
durationMs?: number;
|
|
error?: string;
|
|
tokensUsed?: number;
|
|
inputSnapshot?: Record<string, any>;
|
|
output?: Record<string, any>;
|
|
retryCount?: number;
|
|
}
|
|
|
|
const _STATUS_ICONS: Record<string, string> = {
|
|
pending: '○', running: '◉', completed: '✓', failed: '✗', stopped: '■', skipped: '—',
|
|
};
|
|
|
|
function _formatStepTs(ts: number | string | null | undefined): string {
|
|
if (!ts) return '';
|
|
const d = typeof ts === 'number' ? new Date(ts * 1000) : new Date(ts);
|
|
if (isNaN(d.getTime())) return '';
|
|
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
|
}
|
|
|
|
function _truncateJson(obj: unknown, maxLen = 300): string {
|
|
if (!obj || (typeof obj === 'object' && Object.keys(obj as object).length === 0)) return '';
|
|
try {
|
|
const s = JSON.stringify(obj, null, 2);
|
|
return s.length > maxLen ? s.slice(0, maxLen) + '\n...' : s;
|
|
} catch {
|
|
return String(obj);
|
|
}
|
|
}
|
|
|
|
const _CollapsibleSection: React.FC<{ label: string; content: string }> = ({ label, content }) => {
|
|
const [open, setOpen] = useState(false);
|
|
if (!content) return null;
|
|
return (
|
|
<div style={{ marginTop: 4 }}>
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); setOpen(!open); }}
|
|
style={{
|
|
background: 'none', border: 'none', cursor: 'pointer', padding: 0,
|
|
color: 'var(--text-link, #0969da)', fontSize: 11, textDecoration: 'underline',
|
|
}}
|
|
>
|
|
{open ? '▾' : '▸'} {label}
|
|
</button>
|
|
{open && (
|
|
<pre style={{
|
|
margin: '4px 0 0', padding: 6, borderRadius: 4,
|
|
background: 'var(--bg-secondary, #f6f8fa)', fontSize: 11,
|
|
whiteSpace: 'pre-wrap', wordBreak: 'break-all', maxHeight: 200, overflowY: 'auto',
|
|
}}>
|
|
{content}
|
|
</pre>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
interface _RunTracingModalProps {
|
|
run: WorkflowRun;
|
|
onClose: () => void;
|
|
}
|
|
|
|
const _RunTracingModal: React.FC<_RunTracingModalProps> = ({ run, onClose }) => {
|
|
const { t } = useLanguage();
|
|
const [steps, setSteps] = useState<_TracingStep[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [sseConnected, setSseConnected] = useState(false);
|
|
const eventSourceRef = useRef<EventSource | null>(null);
|
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
|
|
const _loadSteps = useCallback(async () => {
|
|
setLoading(true);
|
|
try {
|
|
const resp = await api.get(`/api/system/workflow-runs/${run.id}/steps`);
|
|
setSteps(resp.data?.steps || []);
|
|
} catch (e) {
|
|
console.error('[RunTracing] Failed to load steps:', e);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [run.id]);
|
|
|
|
const isRunning = run.status === 'running' || run.status === 'paused';
|
|
|
|
useEffect(() => {
|
|
_loadSteps();
|
|
|
|
if (!isRunning) return;
|
|
|
|
const baseUrl = api.defaults.baseURL || '';
|
|
const url = `${baseUrl}/api/system/workflow-runs/${run.id}/stream`;
|
|
const es = new EventSource(url, { withCredentials: true });
|
|
eventSourceRef.current = es;
|
|
|
|
es.onopen = () => setSseConnected(true);
|
|
es.onmessage = (event) => {
|
|
try {
|
|
const payload = JSON.parse(event.data);
|
|
if (payload.type === 'keepalive') return;
|
|
if (payload.type === 'run_complete' || payload.type === 'run_failed') {
|
|
_loadSteps();
|
|
es.close();
|
|
setSseConnected(false);
|
|
return;
|
|
}
|
|
if (payload.status === 'running') {
|
|
setSteps((prev) => {
|
|
const exists = prev.some((s) => s.id === payload.id);
|
|
if (exists) return prev.map((s) => s.id === payload.id ? { ...s, ...payload } : s);
|
|
return [...prev, payload as _TracingStep];
|
|
});
|
|
} else {
|
|
setSteps((prev) => prev.map((s) => s.id === payload.id ? { ...s, ...payload } : s));
|
|
}
|
|
} catch { /* ignore parse errors */ }
|
|
};
|
|
es.onerror = () => {
|
|
setSseConnected(false);
|
|
es.close();
|
|
};
|
|
|
|
return () => {
|
|
es.close();
|
|
eventSourceRef.current = null;
|
|
setSseConnected(false);
|
|
};
|
|
}, [run.id, run.status]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
// Polling fallback: reload steps periodically while run is active
|
|
useEffect(() => {
|
|
if (!isRunning) return;
|
|
const interval = setInterval(() => { _loadSteps(); }, 5000);
|
|
return () => clearInterval(interval);
|
|
}, [isRunning, _loadSteps]);
|
|
|
|
useEffect(() => {
|
|
if (scrollRef.current) {
|
|
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
|
}
|
|
}, [steps]);
|
|
|
|
return (
|
|
<div className={styles.modalOverlay}>
|
|
<div
|
|
className={styles.modal}
|
|
style={{ maxWidth: 800, height: '80vh' }}
|
|
>
|
|
<div className={styles.modalHeader}>
|
|
<div>
|
|
<h3 className={styles.modalTitle}>
|
|
{t('Run-Tracing')}: {run.workflowLabel || run.workflowId}
|
|
</h3>
|
|
<div style={{ fontSize: '0.8rem', color: 'var(--text-secondary, #666)', marginTop: 2 }}>
|
|
<span style={{ color: _STATUS_COLORS[run.status] || 'inherit', fontWeight: 600 }}>
|
|
{run.status}
|
|
</span>
|
|
{sseConnected && (
|
|
<span style={{ marginLeft: 8, color: 'var(--success-color, #28a745)' }}>● {t('Live')}</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<button className={styles.modalClose} onClick={onClose} title={t('Schliessen')}>
|
|
<FaTimes />
|
|
</button>
|
|
</div>
|
|
<div className={styles.modalContent} ref={scrollRef} style={{ overflowY: 'auto', flex: 1 }}>
|
|
{loading && steps.length === 0 && (
|
|
<div style={{ color: 'var(--text-secondary, #888)', fontSize: 13 }}>{t('Wird geladen…')}</div>
|
|
)}
|
|
{!loading && steps.length === 0 && (
|
|
<div style={{ color: 'var(--text-secondary, #888)', fontSize: 13 }}>{t('Noch keine Schritte aufgezeichnet')}</div>
|
|
)}
|
|
{steps.map((step) => {
|
|
const startStr = _formatStepTs(step.startedAt);
|
|
const endStr = _formatStepTs(step.completedAt);
|
|
const inputStr = _truncateJson(step.inputSnapshot);
|
|
const outputStr = _truncateJson(step.output);
|
|
const isLoop = step.inputSnapshot?._loopIndex != null;
|
|
|
|
return (
|
|
<div
|
|
key={step.id}
|
|
style={{
|
|
padding: '8px 12px', marginBottom: 6, borderRadius: 6,
|
|
border: `1px solid ${_STATUS_COLORS[step.status] || '#ddd'}`,
|
|
background: 'var(--bg-primary, #fff)', fontSize: 13,
|
|
marginLeft: isLoop ? 16 : 0,
|
|
}}
|
|
>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
<span>
|
|
<span style={{ color: _STATUS_COLORS[step.status] || '#999', marginRight: 6 }}>
|
|
{_STATUS_ICONS[step.status] || '?'}
|
|
</span>
|
|
<strong>{step.nodeType}</strong>
|
|
<span style={{ color: '#888', marginLeft: 6 }}>({step.nodeId})</span>
|
|
{isLoop && (
|
|
<span style={{ color: '#666', marginLeft: 6, fontSize: 11 }}>
|
|
[iter {step.inputSnapshot!._loopIndex}]
|
|
</span>
|
|
)}
|
|
</span>
|
|
<span style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
|
{(step.retryCount ?? 0) > 0 && (
|
|
<span style={{ color: '#f0ad4e', fontSize: 11 }}>
|
|
{step.retryCount}x {t('Wiederholung')}
|
|
</span>
|
|
)}
|
|
{step.durationMs != null && (
|
|
<span style={{ color: '#888', fontSize: 12 }}>{step.durationMs}ms</span>
|
|
)}
|
|
</span>
|
|
</div>
|
|
{(startStr || endStr) && (
|
|
<div style={{ color: '#888', fontSize: 11, marginTop: 2 }}>
|
|
{startStr && <span>{startStr}</span>}
|
|
{startStr && endStr && <span> → </span>}
|
|
{endStr && <span>{endStr}</span>}
|
|
</div>
|
|
)}
|
|
{step.error && (
|
|
<div style={{ color: '#dc3545', fontSize: 12, marginTop: 4 }}>{step.error}</div>
|
|
)}
|
|
{(step.tokensUsed ?? 0) > 0 && (
|
|
<div style={{ color: '#888', fontSize: 11, marginTop: 2 }}>
|
|
{step.tokensUsed} {t('Tokens')}
|
|
</div>
|
|
)}
|
|
<_CollapsibleSection label={t('Eingabe')} content={inputStr} />
|
|
<_CollapsibleSection label={t('Ausgabe')} content={outputStr} />
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ===========================================================================
|
|
// DashboardTab — Metrics + Runs table with backend pagination
|
|
// ===========================================================================
|
|
|
|
const _DashboardTab: React.FC = () => {
|
|
const { t } = useLanguage();
|
|
const { request } = useApiRequest();
|
|
const { showError } = useToast();
|
|
|
|
const [metrics, setMetrics] = useState<WorkflowRunMetrics | null>(null);
|
|
const [runs, setRuns] = useState<WorkflowRun[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [paginationMeta, setPaginationMeta] = useState<any>(null);
|
|
const [tracingRun, setTracingRun] = useState<WorkflowRun | null>(null);
|
|
const lastPaginationParamsRef = useRef<any>(null);
|
|
const [backendAttributes, setBackendAttributes] = useState<AttributeDefinition[]>([]);
|
|
|
|
useEffect(() => {
|
|
fetchAttributes(request, 'AutoRun')
|
|
.then(setBackendAttributes)
|
|
.catch((err) => { console.error('[automations] fetchAttributes AutoRun failed', err); });
|
|
}, [request]);
|
|
|
|
const _loadMetrics = useCallback(async () => {
|
|
try {
|
|
const resp = await api.get('/api/system/workflow-runs/metrics');
|
|
setMetrics(resp.data);
|
|
} catch (e: any) {
|
|
const msg = e?.response?.data?.detail || e?.message || String(e);
|
|
console.error('[automations] metrics load failed', e);
|
|
showError(t('Metriken konnten nicht geladen werden: {msg}', { msg }));
|
|
}
|
|
}, [showError, t]);
|
|
|
|
const _loadRuns = useCallback(async (paginationParams?: any) => {
|
|
if (paginationParams !== undefined) {
|
|
lastPaginationParamsRef.current = paginationParams;
|
|
}
|
|
const effectiveParams = paginationParams ?? lastPaginationParamsRef.current;
|
|
setLoading(true);
|
|
try {
|
|
const defaultSort = [{ field: 'sysCreatedAt', direction: 'desc' }];
|
|
const pag = {
|
|
page: effectiveParams?.page || 1,
|
|
pageSize: effectiveParams?.pageSize || 25,
|
|
sort: effectiveParams?.sort || defaultSort,
|
|
...(effectiveParams?.search ? { search: effectiveParams.search } : {}),
|
|
...(effectiveParams?.filters ? { filters: effectiveParams.filters } : {}),
|
|
};
|
|
const params: Record<string, any> = { pagination: JSON.stringify(pag) };
|
|
const resp = await api.get('/api/system/workflow-runs', { params });
|
|
const data = resp.data;
|
|
setRuns(data?.runs || []);
|
|
const total = data?.total ?? 0;
|
|
const pageSize = pag.pageSize;
|
|
setPaginationMeta({
|
|
currentPage: pag.page,
|
|
pageSize,
|
|
totalItems: total,
|
|
totalPages: Math.ceil(total / pageSize),
|
|
});
|
|
} catch (e) {
|
|
console.error('[automations] runs load failed', e);
|
|
showError(t('Fehler beim Laden der Workflow-Runs'));
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [showError, t]);
|
|
|
|
useEffect(() => {
|
|
_loadMetrics();
|
|
_loadRuns();
|
|
}, [_loadMetrics, _loadRuns]);
|
|
|
|
const hasRunningRuns = runs.some((r) => r.status === 'running' || r.status === 'paused');
|
|
useEffect(() => {
|
|
if (!hasRunningRuns) return;
|
|
const interval = setInterval(() => {
|
|
_loadRuns();
|
|
_loadMetrics();
|
|
}, 5000);
|
|
return () => clearInterval(interval);
|
|
}, [hasRunningRuns, _loadRuns, _loadMetrics]);
|
|
|
|
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 _rawRunColumns: ColumnConfig[] = useMemo(() => [
|
|
{
|
|
key: 'workflowLabel',
|
|
label: t('Workflow'),
|
|
width: 200,
|
|
sortable: true,
|
|
formatter: (v: string, row: WorkflowRun) => v || row.workflowId || t('—'),
|
|
},
|
|
{
|
|
key: 'mandateId',
|
|
label: t('Mandant'),
|
|
width: 140,
|
|
sortable: true,
|
|
filterable: true,
|
|
displayField: 'mandateLabel',
|
|
},
|
|
{
|
|
key: 'featureInstanceId',
|
|
label: t('Instanz'),
|
|
width: 140,
|
|
sortable: true,
|
|
filterable: true,
|
|
displayField: 'instanceLabel',
|
|
},
|
|
{ key: 'status', width: 110, sortable: true, filterable: true },
|
|
{
|
|
key: 'startedAt',
|
|
label: t('Gestartet'),
|
|
width: 150,
|
|
sortable: true,
|
|
filterable: true,
|
|
formatter: (v: number) => _formatTs(v),
|
|
},
|
|
{
|
|
key: 'completedAt',
|
|
label: t('Beendet'),
|
|
width: 150,
|
|
sortable: true,
|
|
filterable: true,
|
|
formatter: (v: number) => _formatTs(v),
|
|
},
|
|
], [t]);
|
|
|
|
const _runColumns = useMemo(
|
|
() => resolveColumnTypes(_rawRunColumns, backendAttributes),
|
|
[_rawRunColumns, backendAttributes],
|
|
);
|
|
|
|
const _hookData = useMemo(() => ({
|
|
refetch: _loadRuns,
|
|
pagination: paginationMeta,
|
|
}), [_loadRuns, paginationMeta]);
|
|
|
|
return (
|
|
<>
|
|
<div className={styles.pageHeader}>
|
|
<div>
|
|
<p className={styles.pageSubtitle}>{t('Workflow-Runs über alle Features und Mandanten')}</p>
|
|
</div>
|
|
<div className={styles.headerActions}>
|
|
<button className={styles.secondaryButton} onClick={() => { _loadMetrics(); _loadRuns(); }} disabled={loading}>
|
|
<FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 12, marginBottom: 24, flexShrink: 0 }}>
|
|
<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, flexShrink: 0 }}>
|
|
<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, flexShrink: 0 }}>
|
|
{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, flexShrink: 0 }}>{t('Letzte Runs')}</h3>
|
|
<div className={styles.tableContainer}>
|
|
<FormGeneratorTable<WorkflowRun>
|
|
data={runs}
|
|
columns={_runColumns}
|
|
loading={loading}
|
|
pagination={true}
|
|
pageSize={25}
|
|
searchable={true}
|
|
filterable={true}
|
|
sortable={true}
|
|
selectable={true}
|
|
initialSort={[{ key: 'startedAt', direction: 'desc' }]}
|
|
apiEndpoint="/api/system/workflow-runs"
|
|
customActions={[
|
|
{
|
|
id: 'tracing',
|
|
icon: <FaStream />,
|
|
title: t('Run-Tracing anzeigen'),
|
|
onClick: (row) => setTracingRun(row),
|
|
},
|
|
{
|
|
id: 'download',
|
|
icon: <FaDownload />,
|
|
title: t('Tracing-Protokoll herunterladen'),
|
|
onClick: (row) => _downloadRunTracing(row),
|
|
},
|
|
]}
|
|
hookData={_hookData}
|
|
emptyMessage={t('Noch keine Workflow-Runs vorhanden.')}
|
|
/>
|
|
</div>
|
|
{tracingRun && (
|
|
<_RunTracingModal run={tracingRun} onClose={() => setTracingRun(null)} />
|
|
)}
|
|
</>
|
|
);
|
|
};
|
|
|
|
// ===========================================================================
|
|
// WorkflowsTab — Central workflow management across all instances
|
|
// ===========================================================================
|
|
|
|
const _WorkflowsTab: React.FC = () => {
|
|
const { t } = useLanguage();
|
|
const navigate = useNavigate();
|
|
const { request } = useApiRequest();
|
|
const { showSuccess, showError } = useToast();
|
|
const { prompt: promptInput, PromptDialog } = usePrompt();
|
|
const { dynamicBlock } = useNavigation();
|
|
|
|
const [workflows, setWorkflows] = useState<SystemWorkflow[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [executingId, setExecutingId] = useState<string | null>(null);
|
|
const [togglingId, setTogglingId] = useState<string | null>(null);
|
|
const [activeFilter, setActiveFilter] = useState<'all' | 'active' | 'inactive'>('all');
|
|
const [paginationMeta, setPaginationMeta] = useState<any>(null);
|
|
const lastPaginationParamsRef = useRef<any>(null);
|
|
const [backendAttributes, setBackendAttributes] = useState<AttributeDefinition[]>([]);
|
|
|
|
useEffect(() => {
|
|
fetchAttributes(request, 'Automation2WorkflowView')
|
|
.then(setBackendAttributes)
|
|
.catch((err) => { console.error('[automations] fetchAttributes Automation2WorkflowView failed', err); });
|
|
}, [request]);
|
|
|
|
const _load = useCallback(async (paginationParams?: any) => {
|
|
if (paginationParams !== undefined) {
|
|
lastPaginationParamsRef.current = paginationParams;
|
|
}
|
|
const effectiveParams = paginationParams ?? lastPaginationParamsRef.current;
|
|
setLoading(true);
|
|
try {
|
|
const params: Record<string, any> = {};
|
|
if (activeFilter === 'active') params.active = true;
|
|
if (activeFilter === 'inactive') params.active = false;
|
|
|
|
const defaultSort = [{ field: 'sysCreatedAt', direction: 'desc' }];
|
|
const pag = {
|
|
page: effectiveParams?.page || 1,
|
|
pageSize: effectiveParams?.pageSize || 25,
|
|
sort: effectiveParams?.sort || defaultSort,
|
|
...(effectiveParams?.search ? { search: effectiveParams.search } : {}),
|
|
...(effectiveParams?.filters ? { filters: effectiveParams.filters } : {}),
|
|
};
|
|
params.pagination = JSON.stringify(pag);
|
|
|
|
const resp = await api.get('/api/system/workflow-runs/workflows', { params });
|
|
const data = resp.data;
|
|
setWorkflows(data?.items || []);
|
|
setPaginationMeta(data?.pagination || null);
|
|
} catch (e) {
|
|
console.error('[automations] load system workflows failed', e);
|
|
showError(t('Fehler beim Laden der Workflows'));
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [activeFilter, showError, t]);
|
|
|
|
useEffect(() => {
|
|
_load();
|
|
}, [_load]);
|
|
|
|
const hasRunningWorkflows = workflows.some((w) => w.isRunning);
|
|
useEffect(() => {
|
|
if (!hasRunningWorkflows) return;
|
|
const interval = setInterval(() => { _load(); }, 5000);
|
|
return () => clearInterval(interval);
|
|
}, [hasRunningWorkflows, _load]);
|
|
|
|
const _handleEdit = useCallback((row: SystemWorkflow) => {
|
|
if (!row.mandateId) return;
|
|
const fc = row.featureCode || '';
|
|
if (_FEATURES_WITH_EDITOR.has(fc)) {
|
|
navigate(`/mandates/${row.mandateId}/${fc}/${row.featureInstanceId}/editor?workflowId=${row.id}`);
|
|
return;
|
|
}
|
|
const editor = _bestEditorInstance(dynamicBlock, row.mandateId);
|
|
if (!editor) {
|
|
showError(t('Kein Editor verfügbar für diesen Mandanten'));
|
|
return;
|
|
}
|
|
navigate(`/mandates/${row.mandateId}/${editor.featureCode}/${editor.instanceId}/editor?workflowId=${row.id}`);
|
|
}, [navigate, showError, t, dynamicBlock]);
|
|
|
|
const _handleDelete = useCallback(async (workflowId: string): Promise<boolean> => {
|
|
try {
|
|
await deleteSystemWorkflow(request, workflowId);
|
|
showSuccess(t('Workflow gelöscht'));
|
|
await _load();
|
|
return true;
|
|
} catch (e: any) {
|
|
showError(t('Fehler: {msg}', { msg: e?.message || t('Löschen fehlgeschlagen') }));
|
|
return false;
|
|
}
|
|
}, [request, showSuccess, showError, _load, t]);
|
|
|
|
const _handleToggleActive = useCallback(async (row: SystemWorkflow) => {
|
|
if (!row.featureInstanceId) return;
|
|
const next = !(row.active !== false);
|
|
setTogglingId(row.id);
|
|
try {
|
|
await updateWorkflow(request, row.featureInstanceId, row.id, { active: next });
|
|
showSuccess(next ? t('Workflow aktiviert') : t('Workflow deaktiviert'));
|
|
await _load();
|
|
} catch (e: any) {
|
|
showError(t('Fehler: {msg}', { msg: e?.message || t('Status-Update fehlgeschlagen') }));
|
|
} finally {
|
|
setTogglingId(null);
|
|
}
|
|
}, [request, showSuccess, showError, _load, t]);
|
|
|
|
const _handleRename = useCallback(async (row: SystemWorkflow) => {
|
|
if (!row.featureInstanceId) return;
|
|
const newLabel = await promptInput(t('Neuer Name:'), {
|
|
title: t('Workflow umbenennen'),
|
|
defaultValue: row.label,
|
|
placeholder: t('Workflow-Name'),
|
|
});
|
|
if (!newLabel || newLabel.trim() === row.label) return;
|
|
try {
|
|
await updateWorkflow(request, row.featureInstanceId, row.id, { label: newLabel.trim() });
|
|
showSuccess(t('Workflow umbenannt'));
|
|
await _load();
|
|
} catch (e: any) {
|
|
showError(t('Fehler: {msg}', { msg: e?.message || t('Umbenennen fehlgeschlagen') }));
|
|
}
|
|
}, [request, promptInput, showSuccess, showError, _load, t]);
|
|
|
|
const _handleExecute = useCallback(async (row: SystemWorkflow) => {
|
|
if (!row.featureInstanceId) return;
|
|
setExecutingId(row.id);
|
|
// Track outcome of the fire-and-forget executeGraph promise so the
|
|
// intermediate "Workflow gestartet" toast is only shown when the call has
|
|
// not already failed/finished within the 1s observation window. Without
|
|
// this we always toasted "gestartet" — even when the run had already
|
|
// errored — producing contradictory toasts and hiding real failures.
|
|
let observedFailure = false;
|
|
let observedSuccess = false;
|
|
try {
|
|
const invs = row.invocations || [];
|
|
const primary =
|
|
invs.find((i) => i.enabled && i.kind === 'manual') ||
|
|
invs.find((i) => i.enabled && (i.kind === 'form' || i.kind === 'api'));
|
|
const emptyGraph = { nodes: [], connections: [] };
|
|
const exec = executeGraph(request, row.featureInstanceId, emptyGraph as any, row.id, {
|
|
...(primary ? { entryPointId: primary.id } : {}),
|
|
}).then((result) => {
|
|
if (result?.success) {
|
|
observedSuccess = true;
|
|
showSuccess(result?.paused
|
|
? t('Workflow pausiert bei Human Task.')
|
|
: t('Workflow abgeschlossen'));
|
|
} else {
|
|
observedFailure = true;
|
|
showError(result?.error || t('Ausführung fehlgeschlagen'));
|
|
}
|
|
_load();
|
|
}).catch((e: any) => {
|
|
observedFailure = true;
|
|
showError(t('Fehler: {msg}', { msg: e?.message || t('Ausführung fehlgeschlagen') }));
|
|
_load();
|
|
});
|
|
await Promise.race([
|
|
exec,
|
|
new Promise((r) => setTimeout(r, 1000)),
|
|
]);
|
|
await _load();
|
|
if (!observedFailure && !observedSuccess) {
|
|
showSuccess(t('Workflow gestartet'));
|
|
}
|
|
} finally {
|
|
setExecutingId(null);
|
|
}
|
|
}, [request, showSuccess, showError, _load, t]);
|
|
|
|
const [stoppingId, setStoppingId] = useState<string | null>(null);
|
|
|
|
const _handleStop = useCallback(async (row: SystemWorkflow) => {
|
|
if (!row.activeRunId) return;
|
|
setStoppingId(row.id);
|
|
try {
|
|
await api.post(`/api/system/workflow-runs/${row.activeRunId}/stop`);
|
|
showSuccess(t('Stop-Signal gesendet'));
|
|
await _load();
|
|
} catch (e: any) {
|
|
showError(t('Fehler: {msg}', { msg: e?.message || t('Stoppen fehlgeschlagen') }));
|
|
} finally {
|
|
setStoppingId(null);
|
|
}
|
|
}, [showSuccess, showError, _load, t]);
|
|
|
|
const _hasManualTrigger = useCallback((row: SystemWorkflow): boolean => {
|
|
const invs = row.invocations || [];
|
|
return invs.some((i) => i.enabled && (i.kind === 'manual' || i.kind === 'api'));
|
|
}, []);
|
|
|
|
const _rawColumns: ColumnConfig[] = useMemo(() => [
|
|
{ key: 'label', label: t('Workflow'), width: 200, sortable: true, filterable: true },
|
|
{
|
|
key: 'mandateId',
|
|
label: t('Mandant'),
|
|
width: 140,
|
|
sortable: true,
|
|
filterable: true,
|
|
displayField: 'mandateLabel',
|
|
},
|
|
{
|
|
key: 'featureInstanceId',
|
|
label: t('Instanz'),
|
|
width: 140,
|
|
sortable: true,
|
|
filterable: true,
|
|
displayField: 'instanceLabel',
|
|
},
|
|
{
|
|
key: 'active',
|
|
label: t('Aktiv'),
|
|
width: 80,
|
|
sortable: true,
|
|
filterable: true,
|
|
},
|
|
{
|
|
key: 'isRunning',
|
|
label: t('Läuft'),
|
|
width: 80,
|
|
sortable: true,
|
|
filterable: true,
|
|
},
|
|
{
|
|
key: 'sysCreatedAt',
|
|
label: t('Erstellt'),
|
|
width: 140,
|
|
sortable: true,
|
|
filterable: true,
|
|
formatter: (v: number) => _formatTs(v),
|
|
},
|
|
{
|
|
key: 'lastStartedAt',
|
|
label: t('Zuletzt gestartet'),
|
|
width: 160,
|
|
sortable: true,
|
|
filterable: true,
|
|
formatter: (v: number) => _formatTs(v),
|
|
},
|
|
{
|
|
key: 'runCount',
|
|
label: t('Läufe'),
|
|
width: 80,
|
|
sortable: true,
|
|
filterable: true,
|
|
formatter: (v: number) => (v != null ? String(v) : '0'),
|
|
},
|
|
], [t]);
|
|
|
|
const _columns = useMemo(
|
|
() => resolveColumnTypes(_rawColumns, backendAttributes),
|
|
[_rawColumns, backendAttributes],
|
|
);
|
|
|
|
const _hookData = useMemo(() => ({
|
|
refetch: _load,
|
|
handleDelete: (id: string) => _handleDelete(id),
|
|
pagination: paginationMeta,
|
|
}), [_load, _handleDelete, paginationMeta]);
|
|
|
|
return (
|
|
<>
|
|
<div className={styles.pageHeader}>
|
|
<div>
|
|
<p className={styles.pageSubtitle}>
|
|
{t('Alle Workflows über alle Features und Mandanten')}
|
|
</p>
|
|
</div>
|
|
<div className={styles.headerActions} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
<div style={{ display: 'flex', gap: 4 }}>
|
|
{(['all', 'active', 'inactive'] as const).map((f) => (
|
|
<button
|
|
key={f}
|
|
className={activeFilter === f ? styles.primaryButton : styles.secondaryButton}
|
|
onClick={() => setActiveFilter(f)}
|
|
disabled={loading}
|
|
>
|
|
{f === 'all' ? t('Alle') : f === 'active' ? t('Aktiv') : t('Inaktiv')}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<button className={styles.secondaryButton} onClick={() => _load()} disabled={loading}>
|
|
<FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className={styles.tableContainer}>
|
|
<FormGeneratorTable<SystemWorkflow>
|
|
data={workflows}
|
|
columns={_columns}
|
|
loading={loading}
|
|
pagination={true}
|
|
pageSize={25}
|
|
searchable={true}
|
|
filterable={true}
|
|
sortable={true}
|
|
selectable={true}
|
|
initialSort={[{ key: 'sysCreatedAt', direction: 'desc' }]}
|
|
apiEndpoint="/api/system/workflow-runs/workflows"
|
|
actionButtons={[
|
|
{
|
|
type: 'edit',
|
|
title: t('bearbeiten'),
|
|
onAction: _handleEdit,
|
|
visible: (row: SystemWorkflow) => row.canEdit === true,
|
|
},
|
|
{
|
|
type: 'delete',
|
|
title: t('löschen'),
|
|
visible: (row: SystemWorkflow) => row.canDelete === true,
|
|
},
|
|
]}
|
|
customActions={[
|
|
{
|
|
id: 'view',
|
|
icon: <FaEye />,
|
|
title: t('anzeigen'),
|
|
onClick: (row) => _handleEdit(row),
|
|
visible: (row) => row.canEdit !== true,
|
|
},
|
|
{
|
|
id: 'rename',
|
|
icon: <FaPen />,
|
|
title: t('umbenennen'),
|
|
onClick: (row) => _handleRename(row),
|
|
visible: (row) => row.canEdit === true,
|
|
},
|
|
{
|
|
id: 'activate',
|
|
icon: <FaCheck />,
|
|
title: t('aktivieren'),
|
|
onClick: (row) => _handleToggleActive(row),
|
|
loading: (row) => togglingId === row.id,
|
|
visible: (row) => row.canEdit === true && row.active === false,
|
|
},
|
|
{
|
|
id: 'deactivate',
|
|
icon: <FaBan />,
|
|
title: t('deaktivieren'),
|
|
onClick: (row) => _handleToggleActive(row),
|
|
loading: (row) => togglingId === row.id,
|
|
visible: (row) => row.canEdit === true && row.active !== false,
|
|
},
|
|
{
|
|
id: 'execute',
|
|
icon: <FaPlay />,
|
|
title: t('ausführen'),
|
|
onClick: (row) => _handleExecute(row),
|
|
loading: (row) => executingId === row.id,
|
|
visible: (row) => row.canExecute === true && _hasManualTrigger(row) && !row.isRunning,
|
|
},
|
|
{
|
|
id: 'stop',
|
|
icon: <FaStop />,
|
|
title: t('stoppen'),
|
|
onClick: (row) => _handleStop(row),
|
|
loading: (row) => stoppingId === row.id,
|
|
visible: (row) => row.isRunning === true && !!row.activeRunId,
|
|
},
|
|
]}
|
|
onDelete={(row) => _handleDelete(row.id)}
|
|
hookData={_hookData}
|
|
emptyMessage={t('Keine Workflows gefunden.')}
|
|
/>
|
|
</div>
|
|
<PromptDialog />
|
|
</>
|
|
);
|
|
};
|
|
|
|
// ===========================================================================
|
|
// Workspace Tab (user-facing workflow run history)
|
|
// ===========================================================================
|
|
|
|
const _WorkspaceTab: React.FC = () => {
|
|
const { t } = useLanguage();
|
|
const { request } = useApiRequest();
|
|
const navigate = useNavigate();
|
|
const [runs, setRuns] = useState<WorkspaceRun[]>([]);
|
|
const [total, setTotal] = useState(0);
|
|
const [loading, setLoading] = useState(true);
|
|
const [scope, setScope] = useState<'mine' | 'mandate'>('mine');
|
|
const [statusFilter, setStatusFilter] = useState<string>('');
|
|
const [selectedRunId, setSelectedRunId] = useState<string | null>(null);
|
|
const [runDetail, setRunDetail] = useState<Awaited<ReturnType<typeof fetchWorkspaceRunDetail>> | null>(null);
|
|
const [detailLoading, setDetailLoading] = useState(false);
|
|
|
|
const _loadRuns = useCallback(async () => {
|
|
setLoading(true);
|
|
try {
|
|
const data = await fetchWorkspaceRuns(request, {
|
|
scope,
|
|
status: statusFilter || undefined,
|
|
limit: 50,
|
|
});
|
|
setRuns(data.runs || []);
|
|
setTotal(data.total || 0);
|
|
} catch (e) {
|
|
console.error('Workspace runs load failed', e);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [request, scope, statusFilter]);
|
|
|
|
useEffect(() => { _loadRuns(); }, [_loadRuns]);
|
|
|
|
const _loadDetail = useCallback(async (runId: string) => {
|
|
setDetailLoading(true);
|
|
try {
|
|
const detail = await fetchWorkspaceRunDetail(request, runId);
|
|
setRunDetail(detail);
|
|
} catch (e) {
|
|
console.error('Workspace run detail failed', e);
|
|
} finally {
|
|
setDetailLoading(false);
|
|
}
|
|
}, [request]);
|
|
|
|
useEffect(() => {
|
|
if (selectedRunId) _loadDetail(selectedRunId);
|
|
else setRunDetail(null);
|
|
}, [selectedRunId, _loadDetail]);
|
|
|
|
if (selectedRunId && runDetail) {
|
|
const { run, steps, files, workflow } = runDetail;
|
|
return (
|
|
<div style={{ padding: '1rem' }}>
|
|
<button type="button" className="btn-link" onClick={() => setSelectedRunId(null)} style={{ marginBottom: '1rem' }}>
|
|
← {t('Zurück zur Liste')}
|
|
</button>
|
|
<h3 style={{ margin: '0.5rem 0' }}>{run.workflowLabel || run.workflowId}</h3>
|
|
<div style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap', fontSize: '0.85rem', color: 'var(--text-secondary)', marginBottom: '1rem' }}>
|
|
<span><strong>{t('Status')}:</strong> {run.status}</span>
|
|
{run.startedAt && <span><strong>{t('Start')}:</strong> {formatUnixTimestamp(run.startedAt)}</span>}
|
|
{run.completedAt && <span><strong>{t('Ende')}:</strong> {formatUnixTimestamp(run.completedAt)}</span>}
|
|
{workflow?.targetFeatureInstanceId && <span><strong>{t('Ziel-Instanz')}:</strong> {run.targetInstanceLabel || workflow.targetFeatureInstanceId}</span>}
|
|
{(run.costTokens ?? 0) > 0 && <span><strong>Tokens:</strong> {run.costTokens}</span>}
|
|
</div>
|
|
{run.error && (
|
|
<div style={{ padding: '0.5rem', background: 'rgba(220,53,69,0.1)', borderRadius: 6, marginBottom: '1rem', color: 'var(--danger-color)' }}>
|
|
{run.error}
|
|
</div>
|
|
)}
|
|
<h4 style={{ margin: '1rem 0 0.5rem' }}>{t('Schritte')}</h4>
|
|
{steps.length === 0 ? (
|
|
<p style={{ color: 'var(--text-secondary)' }}>{t('Keine Schritte protokolliert.')}</p>
|
|
) : (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
|
{steps.map((step) => (
|
|
<details key={step.id} style={{ border: '1px solid var(--border-color)', borderRadius: 6, padding: '0.5rem' }}>
|
|
<summary style={{ cursor: 'pointer', fontWeight: 500 }}>
|
|
<span style={{ marginRight: '0.5rem', fontSize: '0.75rem', padding: '2px 6px', borderRadius: 4, background: step.status === 'completed' ? 'rgba(40,167,69,0.15)' : step.status === 'failed' ? 'rgba(220,53,69,0.15)' : 'rgba(0,123,255,0.15)', color: step.status === 'completed' ? 'var(--success-color)' : step.status === 'failed' ? 'var(--danger-color)' : 'var(--primary-color)' }}>
|
|
{step.status}
|
|
</span>
|
|
{step.nodeType} ({step.nodeId})
|
|
{step.durationMs != null && <span style={{ marginLeft: '0.5rem', fontSize: '0.75rem', color: 'var(--text-secondary)' }}>{step.durationMs}ms</span>}
|
|
</summary>
|
|
{step.output && Object.keys(step.output).length > 0 && (
|
|
<pre style={{ fontSize: '0.75rem', maxHeight: 300, overflow: 'auto', marginTop: '0.5rem', background: 'var(--bg-secondary)', padding: '0.5rem', borderRadius: 4 }}>
|
|
{JSON.stringify(step.output, null, 2)}
|
|
</pre>
|
|
)}
|
|
{step.error && <p style={{ color: 'var(--danger-color)', marginTop: '0.25rem' }}>{step.error}</p>}
|
|
</details>
|
|
))}
|
|
</div>
|
|
)}
|
|
{files.length > 0 && (
|
|
<>
|
|
<h4 style={{ margin: '1rem 0 0.5rem' }}>{t('Dokumente')}</h4>
|
|
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
|
{files.map((f) => (
|
|
<a
|
|
key={f.id}
|
|
href={`/api/files/${f.id}/download`}
|
|
download
|
|
style={{ padding: '0.5rem 1rem', border: '1px solid var(--border-color)', borderRadius: 6, textDecoration: 'none', color: 'var(--primary-color)', fontSize: '0.85rem' }}
|
|
>
|
|
<FaDownload style={{ marginRight: 4 }} />
|
|
{f.fileName || f.id}
|
|
</a>
|
|
))}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div style={{ padding: '1rem' }}>
|
|
<div style={{ display: 'flex', gap: '1rem', alignItems: 'center', marginBottom: '1rem' }}>
|
|
<select value={scope} onChange={(e) => setScope(e.target.value as 'mine' | 'mandate')} style={{ padding: '0.3rem 0.5rem' }}>
|
|
<option value="mine">{t('Meine Runs')}</option>
|
|
<option value="mandate">{t('Alle zugänglichen')}</option>
|
|
</select>
|
|
<select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)} style={{ padding: '0.3rem 0.5rem' }}>
|
|
<option value="">{t('Alle Status')}</option>
|
|
<option value="completed">{t('Abgeschlossen')}</option>
|
|
<option value="running">{t('Läuft')}</option>
|
|
<option value="failed">{t('Fehlgeschlagen')}</option>
|
|
<option value="paused">{t('Pausiert')}</option>
|
|
</select>
|
|
<button type="button" onClick={_loadRuns} style={{ padding: '0.3rem 0.8rem' }}>
|
|
<FaSync style={{ marginRight: 4 }} /> {t('Aktualisieren')}
|
|
</button>
|
|
<span style={{ fontSize: '0.85rem', color: 'var(--text-secondary)' }}>{total} {t('Runs')}</span>
|
|
</div>
|
|
{loading ? (
|
|
<p>{t('Laden…')}</p>
|
|
) : runs.length === 0 ? (
|
|
<p style={{ color: 'var(--text-secondary)' }}>{t('Keine Workflow-Runs gefunden.')}</p>
|
|
) : (
|
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.85rem' }}>
|
|
<thead>
|
|
<tr style={{ borderBottom: '2px solid var(--border-color)', textAlign: 'left' }}>
|
|
<th style={{ padding: '0.5rem' }}>{t('Workflow')}</th>
|
|
<th style={{ padding: '0.5rem' }}>{t('Status')}</th>
|
|
<th style={{ padding: '0.5rem' }}>{t('Gestartet')}</th>
|
|
<th style={{ padding: '0.5rem' }}>{t('Ziel-Instanz')}</th>
|
|
<th style={{ padding: '0.5rem' }}>Tokens</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{runs.map((run) => (
|
|
<tr
|
|
key={run.id}
|
|
onClick={() => setSelectedRunId(run.id)}
|
|
style={{ borderBottom: '1px solid var(--border-color)', cursor: 'pointer' }}
|
|
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--bg-hover, rgba(0,0,0,0.03))')}
|
|
onMouseLeave={(e) => (e.currentTarget.style.background = '')}
|
|
>
|
|
<td style={{ padding: '0.5rem' }}>{run.workflowLabel || run.workflowId}</td>
|
|
<td style={{ padding: '0.5rem' }}>
|
|
<span style={{ padding: '2px 8px', borderRadius: 10, fontSize: '0.75rem', fontWeight: 600, background: run.status === 'completed' ? 'rgba(40,167,69,0.15)' : run.status === 'failed' ? 'rgba(220,53,69,0.15)' : run.status === 'running' ? 'rgba(0,123,255,0.15)' : 'rgba(255,193,7,0.15)', color: run.status === 'completed' ? 'var(--success-color)' : run.status === 'failed' ? 'var(--danger-color)' : run.status === 'running' ? 'var(--primary-color)' : 'var(--warning-color)' }}>
|
|
{run.status}
|
|
</span>
|
|
</td>
|
|
<td style={{ padding: '0.5rem' }}>{run.startedAt ? formatUnixTimestamp(run.startedAt) : '—'}</td>
|
|
<td style={{ padding: '0.5rem' }}>{run.targetInstanceLabel || '—'}</td>
|
|
<td style={{ padding: '0.5rem' }}>{run.costTokens ?? 0}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ===========================================================================
|
|
// Main page with Tabs
|
|
// ===========================================================================
|
|
|
|
export const AutomationsDashboardPage: React.FC = () => {
|
|
const { t } = useLanguage();
|
|
|
|
const tabs = useMemo(() => [
|
|
{
|
|
id: 'dashboard',
|
|
label: t('Dashboard'),
|
|
content: <_DashboardTab />,
|
|
},
|
|
{
|
|
id: 'workflows',
|
|
label: t('Workflows'),
|
|
content: <_WorkflowsTab />,
|
|
},
|
|
{
|
|
id: 'workspace',
|
|
label: t('Workspace'),
|
|
content: <_WorkspaceTab />,
|
|
},
|
|
], [t]);
|
|
|
|
return (
|
|
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
|
|
<h1 className={styles.pageTitle} style={{ flexShrink: 0 }}>{t('Automatisierung')}</h1>
|
|
<Tabs tabs={tabs} defaultTabId="dashboard" />
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default AutomationsDashboardPage;
|