plana+c implemented
This commit is contained in:
parent
28951a7d22
commit
8cecf3b320
10 changed files with 635 additions and 52 deletions
|
|
@ -144,6 +144,8 @@ export interface Automation2Workflow {
|
|||
label: string;
|
||||
graph: Automation2Graph;
|
||||
active?: boolean;
|
||||
/** Target feature instance for execution data scope (NULL for templates) */
|
||||
targetFeatureInstanceId?: string | null;
|
||||
/** Entry points (Starts) — how this workflow may be invoked */
|
||||
invocations?: WorkflowEntryPoint[];
|
||||
/** Enriched: run count */
|
||||
|
|
@ -986,3 +988,87 @@ export async function loadClickupListTasksForDropdown(
|
|||
acc.sort((a, b) => a.name.localeCompare(b.name, 'de'));
|
||||
return acc;
|
||||
}
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// AUTOMATION WORKSPACE API (user-facing run workspace)
|
||||
// ============================================================================
|
||||
|
||||
export interface WorkspaceRun {
|
||||
id: string;
|
||||
workflowId: string;
|
||||
workflowLabel?: string;
|
||||
status: string;
|
||||
startedAt?: number;
|
||||
completedAt?: number;
|
||||
ownerId?: string;
|
||||
mandateId?: string;
|
||||
mandateLabel?: string;
|
||||
targetFeatureInstanceId?: string;
|
||||
targetInstanceLabel?: string;
|
||||
costTokens?: number;
|
||||
costCredits?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface WorkspaceRunDetail {
|
||||
run: WorkspaceRun & { nodeOutputs?: Record<string, unknown> };
|
||||
workflow: {
|
||||
id: string;
|
||||
label: string;
|
||||
targetFeatureInstanceId?: string;
|
||||
featureInstanceId?: string;
|
||||
tags?: string[];
|
||||
} | null;
|
||||
steps: Array<{
|
||||
id: string;
|
||||
runId: string;
|
||||
nodeId: string;
|
||||
nodeType: string;
|
||||
status: string;
|
||||
output?: Record<string, unknown>;
|
||||
error?: string;
|
||||
startedAt?: number;
|
||||
completedAt?: number;
|
||||
durationMs?: number;
|
||||
tokensUsed?: number;
|
||||
}>;
|
||||
files: Array<{
|
||||
id: string;
|
||||
fileName?: string;
|
||||
contentType?: string;
|
||||
sizeBytes?: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export async function fetchWorkspaceRuns(
|
||||
request: ApiRequestOptions['request'],
|
||||
params: {
|
||||
scope?: 'mine' | 'mandate';
|
||||
status?: string;
|
||||
targetInstanceId?: string;
|
||||
workflowId?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
} = {},
|
||||
): Promise<{ runs: WorkspaceRun[]; total: number }> {
|
||||
const query = new URLSearchParams();
|
||||
if (params.scope) query.set('scope', params.scope);
|
||||
if (params.status) query.set('status', params.status);
|
||||
if (params.targetInstanceId) query.set('targetInstanceId', params.targetInstanceId);
|
||||
if (params.workflowId) query.set('workflowId', params.workflowId);
|
||||
if (params.limit) query.set('limit', String(params.limit));
|
||||
if (params.offset) query.set('offset', String(params.offset));
|
||||
const qs = query.toString();
|
||||
const url = `/api/automations/runs${qs ? `?${qs}` : ''}`;
|
||||
const resp = await request({ url, method: 'GET' });
|
||||
return resp as { runs: WorkspaceRun[]; total: number };
|
||||
}
|
||||
|
||||
export async function fetchWorkspaceRunDetail(
|
||||
request: ApiRequestOptions['request'],
|
||||
runId: string,
|
||||
): Promise<WorkspaceRunDetail> {
|
||||
const resp = await request({ url: `/api/automations/runs/${runId}/detail`, method: 'GET' });
|
||||
return resp as WorkspaceRunDetail;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ import styles from './Automation2FlowEditor.module.css';
|
|||
|
||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||
import { useToast } from '../../../contexts/ToastContext';
|
||||
import { useFeatureStore } from '../../../stores/featureStore';
|
||||
|
||||
const LOG = '[Automation2]';
|
||||
|
||||
|
|
@ -133,6 +134,15 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
|||
const [currentVersionId, setCurrentVersionId] = useState<string | null>(null);
|
||||
const [versionLoading, setVersionLoading] = useState(false);
|
||||
|
||||
const [targetFeatureInstanceId, setTargetFeatureInstanceId] = useState<string | null>(instanceId);
|
||||
const featureStore = useFeatureStore();
|
||||
const targetInstanceOptions = useMemo(() => {
|
||||
const allInstances = featureStore.getAllInstances();
|
||||
return allInstances
|
||||
.filter((inst) => inst.mandateId === mandateId || !mandateId)
|
||||
.map((inst) => ({ id: inst.id, label: inst.instanceLabel || inst.featureCode || inst.id }));
|
||||
}, [featureStore, mandateId]);
|
||||
|
||||
const [leftPanelWidth, setLeftPanelWidth] = useState(() => {
|
||||
try { const v = parseInt(localStorage.getItem('flowEditor.leftPanelWidth') ?? ''); return v >= 240 && v <= 600 ? v : 340; } catch { return 340; }
|
||||
});
|
||||
|
|
@ -297,7 +307,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
|||
setSaving(true);
|
||||
try {
|
||||
if (currentWorkflowId) {
|
||||
await updateWorkflow(request, instanceId, currentWorkflowId, { graph, invocations });
|
||||
await updateWorkflow(request, instanceId, currentWorkflowId, { graph, invocations, targetFeatureInstanceId });
|
||||
setExecuteResult(_buildSaveResult());
|
||||
} else {
|
||||
const label = await promptInput(t('Workflow-Name:'), {
|
||||
|
|
@ -313,6 +323,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
|||
label: label.trim() || t('Neuer Workflow'),
|
||||
graph,
|
||||
invocations,
|
||||
targetFeatureInstanceId,
|
||||
});
|
||||
setCurrentWorkflowId(created.id);
|
||||
if (created.invocations?.length) setInvocations(created.invocations);
|
||||
|
|
@ -324,7 +335,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
|||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [request, instanceId, canvasNodes, canvasConnections, currentWorkflowId, promptInput, invocations, t, nodeErrors]);
|
||||
}, [request, instanceId, canvasNodes, canvasConnections, currentWorkflowId, promptInput, invocations, t, nodeErrors, targetFeatureInstanceId]);
|
||||
|
||||
const handleLoad = useCallback(
|
||||
async (workflowId: string) => {
|
||||
|
|
@ -335,6 +346,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
|||
} else {
|
||||
applyGraphWithSync({ nodes: [], connections: [] }, wf.invocations);
|
||||
}
|
||||
setTargetFeatureInstanceId(wf.targetFeatureInstanceId ?? instanceId);
|
||||
setWorkflows((prev) => {
|
||||
const idx = prev.findIndex((w) => w.id === workflowId);
|
||||
if (idx === -1) return [...prev, wf];
|
||||
|
|
@ -661,6 +673,17 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
|||
[request, instanceId, handleFromApiGraph]
|
||||
);
|
||||
|
||||
const handleTargetInstanceChange = useCallback(async (newTargetId: string) => {
|
||||
setTargetFeatureInstanceId(newTargetId || null);
|
||||
if (currentWorkflowId && newTargetId) {
|
||||
try {
|
||||
await updateWorkflow(request, instanceId, currentWorkflowId, { targetFeatureInstanceId: newTargetId });
|
||||
} catch (e: unknown) {
|
||||
console.error(`${LOG} target instance update failed`, e);
|
||||
}
|
||||
}
|
||||
}, [request, instanceId, currentWorkflowId]);
|
||||
|
||||
const handleWorkflowRename = useCallback(async (workflowId: string, newName: string) => {
|
||||
try {
|
||||
await updateWorkflow(request, instanceId, workflowId, { label: newName });
|
||||
|
|
@ -836,6 +859,9 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
|||
onAutoLayout={handleAutoLayout}
|
||||
verboseSchema={verboseSchema}
|
||||
onVerboseSchemaChange={setVerboseSchema}
|
||||
targetFeatureInstanceId={targetFeatureInstanceId}
|
||||
onTargetInstanceChange={handleTargetInstanceChange}
|
||||
targetInstanceOptions={targetInstanceOptions}
|
||||
/>
|
||||
<div className={styles.canvasArea} style={{ display: 'flex', flex: 1, minWidth: 0 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
|
|
|
|||
|
|
@ -10,6 +10,11 @@ import styles from './Automation2FlowEditor.module.css';
|
|||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||
import { getUserDataCache } from '../../../utils/userCache';
|
||||
|
||||
interface TargetInstanceOption {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface CanvasHeaderProps {
|
||||
workflows: Automation2Workflow[];
|
||||
currentWorkflowId: string | null;
|
||||
|
|
@ -45,6 +50,9 @@ interface CanvasHeaderProps {
|
|||
* "Schema (Typ-Referenz)" block and per-parameter type-badges. */
|
||||
verboseSchema?: boolean;
|
||||
onVerboseSchemaChange?: (next: boolean) => void;
|
||||
targetFeatureInstanceId?: string | null;
|
||||
onTargetInstanceChange?: (instanceId: string) => void;
|
||||
targetInstanceOptions?: TargetInstanceOption[];
|
||||
}
|
||||
|
||||
function _getStatusBadge(t: (key: string) => string): Record<string, { label: string; color: string }> {
|
||||
|
|
@ -84,6 +92,9 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
|
|||
onAutoLayout,
|
||||
verboseSchema,
|
||||
onVerboseSchemaChange,
|
||||
targetFeatureInstanceId,
|
||||
onTargetInstanceChange,
|
||||
targetInstanceOptions,
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
const _isSysAdmin = getUserDataCache()?.isSysAdmin === true;
|
||||
|
|
@ -209,6 +220,21 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
|
|||
<FaCog />
|
||||
</button>
|
||||
)}
|
||||
{targetInstanceOptions && targetInstanceOptions.length > 0 && onTargetInstanceChange && (
|
||||
<select
|
||||
className={styles.canvasHeaderWorkflowSelect}
|
||||
value={targetFeatureInstanceId ?? ''}
|
||||
onChange={(e) => onTargetInstanceChange(e.target.value)}
|
||||
aria-label={t('Ziel-Instanz')}
|
||||
title={t('Ziel-Instanz für Daten-Scope')}
|
||||
style={{ maxWidth: 200, fontSize: '0.8rem' }}
|
||||
>
|
||||
<option value="">{t('Ziel-Instanz wählen…')}</option>
|
||||
{targetInstanceOptions.map((opt) => (
|
||||
<option key={opt.id} value={opt.id}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.canvasHeaderActionPanel} role="toolbar" aria-label={t('Workflow-Aktionen')}>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,171 @@
|
|||
/**
|
||||
* TemplateTextarea — Freitext mit eingebetteten {{nodeId.path}} Tokens.
|
||||
* Tokens werden zur Laufzeit von resolveParameterReferences aufgeloest (Gateway).
|
||||
*/
|
||||
|
||||
import React, { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import type { FieldRendererProps } from './index';
|
||||
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
|
||||
import { DataPicker } from '../shared/DataPicker';
|
||||
import { formatRefLabel, isRef, isSystemVar, type DataRef, type SystemVarRef } from '../shared/dataRef';
|
||||
import { useLanguage } from '../../../../providers/language/LanguageContext';
|
||||
import styles from '../../editor/Automation2FlowEditor.module.css';
|
||||
|
||||
const _TEMPLATE_TOKEN_RE = /\{\{\s*([^}]+?)\s*\}\}/g;
|
||||
|
||||
function _refToTemplateToken(ref: DataRef): string {
|
||||
const pathSegs = (ref.path ?? []).map((p) => String(p));
|
||||
if (pathSegs.length === 0) {
|
||||
return `{{${ref.nodeId}}}`;
|
||||
}
|
||||
return `{{${ref.nodeId}.${pathSegs.join('.')}}}`;
|
||||
}
|
||||
|
||||
function _insertAtCursor(
|
||||
text: string,
|
||||
insert: string,
|
||||
start: number,
|
||||
end: number,
|
||||
): { next: string; caret: number } {
|
||||
const next = text.slice(0, start) + insert + text.slice(end);
|
||||
const caret = start + insert.length;
|
||||
return { next, caret };
|
||||
}
|
||||
|
||||
function _parseTokensInTemplate(
|
||||
template: string,
|
||||
nodes: Array<{ id: string; title?: string }>,
|
||||
getNodeLabel: (n: { id: string; title?: string }) => string,
|
||||
): Array<{ raw: string; label: string }> {
|
||||
const out: Array<{ raw: string; label: string }> = [];
|
||||
const seen = new Set<string>();
|
||||
let m: RegExpExecArray | null;
|
||||
const re = new RegExp(_TEMPLATE_TOKEN_RE.source, 'g');
|
||||
while ((m = re.exec(template)) !== null) {
|
||||
const inner = m[1].trim();
|
||||
if (seen.has(inner)) continue;
|
||||
seen.add(inner);
|
||||
const parts = inner.split('.');
|
||||
const nodeId = parts[0];
|
||||
if (!nodeId) continue;
|
||||
const path = parts.slice(1).map((seg) => (/^\d+$/.test(seg) ? parseInt(seg, 10) : seg));
|
||||
const ref: DataRef = { type: 'ref', nodeId, path };
|
||||
const label = formatRefLabel(ref, nodes, (id) =>
|
||||
getNodeLabel(nodes.find((n) => n.id === id) ?? { id }),
|
||||
);
|
||||
out.push({ raw: m[0], label });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export const TemplateTextareaRenderer: React.FC<FieldRendererProps> = ({ param, value, onChange }) => {
|
||||
const { t } = useLanguage();
|
||||
const dataFlow = useAutomation2DataFlow();
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
|
||||
const strVal = typeof value === 'string' ? value : value != null ? String(value) : '';
|
||||
|
||||
const sourceIds = dataFlow?.getAvailableSourceIds() ?? [];
|
||||
const hasSources = sourceIds.some((id) => {
|
||||
const n = dataFlow?.nodes.find((x) => x.id === id);
|
||||
return n?.type !== 'trigger.manual';
|
||||
});
|
||||
|
||||
const tokenLegend = useMemo(() => {
|
||||
if (!dataFlow || !strVal.includes('{{')) return [];
|
||||
return _parseTokensInTemplate(strVal, dataFlow.nodes, dataFlow.getNodeLabel);
|
||||
}, [strVal, dataFlow]);
|
||||
|
||||
const handlePick = useCallback(
|
||||
(picked: DataRef | SystemVarRef) => {
|
||||
if (isSystemVar(picked)) {
|
||||
setPickerOpen(false);
|
||||
return;
|
||||
}
|
||||
if (!isRef(picked)) {
|
||||
setPickerOpen(false);
|
||||
return;
|
||||
}
|
||||
const token = _refToTemplateToken(picked);
|
||||
const el = textareaRef.current;
|
||||
const start = el?.selectionStart ?? strVal.length;
|
||||
const end = el?.selectionEnd ?? strVal.length;
|
||||
const { next, caret } = _insertAtCursor(strVal, token, start, end);
|
||||
onChange(next);
|
||||
setPickerOpen(false);
|
||||
requestAnimationFrame(() => {
|
||||
const ta = textareaRef.current;
|
||||
if (ta) {
|
||||
ta.focus();
|
||||
ta.setSelectionRange(caret, caret);
|
||||
}
|
||||
});
|
||||
},
|
||||
[onChange, strVal],
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>{param.description || param.name}</label>
|
||||
<div style={{ display: 'flex', gap: 6, marginBottom: 4, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.startsInput}
|
||||
disabled={!hasSources}
|
||||
onClick={() => setPickerOpen(true)}
|
||||
title={hasSources ? t('Variable aus vorherigem Node einfügen') : t('Keine vorherigen Nodes verfügbar')}
|
||||
>
|
||||
{t('Variable einfügen…')}
|
||||
</button>
|
||||
{!hasSources && (
|
||||
<span style={{ fontSize: 11, color: 'var(--text-secondary)' }}>{t('Keine vorherigen Nodes verfügbar')}</span>
|
||||
)}
|
||||
</div>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={strVal}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={param.name}
|
||||
rows={6}
|
||||
spellCheck={false}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '6px 8px',
|
||||
borderRadius: 4,
|
||||
border: '1px solid #ccc',
|
||||
resize: 'vertical',
|
||||
fontFamily: 'ui-monospace, monospace',
|
||||
fontSize: 12,
|
||||
minHeight: 120,
|
||||
}}
|
||||
/>
|
||||
{tokenLegend.length > 0 && (
|
||||
<div style={{ marginTop: 6, fontSize: 11, color: 'var(--text-secondary)' }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 2 }}>{t('Eingebundene Variablen')}</div>
|
||||
<ul style={{ margin: 0, paddingLeft: 18 }}>
|
||||
{tokenLegend.map((row) => (
|
||||
<li key={row.raw} style={{ marginBottom: 2 }}>
|
||||
<code style={{ fontSize: 10 }}>{row.raw}</code>
|
||||
{' — '}
|
||||
{row.label}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{dataFlow && (
|
||||
<DataPicker
|
||||
open={pickerOpen}
|
||||
onClose={() => setPickerOpen(false)}
|
||||
onPick={handlePick}
|
||||
availableSourceIds={sourceIds}
|
||||
nodes={dataFlow.nodes}
|
||||
nodeOutputsPreview={dataFlow.nodeOutputsPreview}
|
||||
getNodeLabel={dataFlow.getNodeLabel}
|
||||
expectedParamType={param.type}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -33,6 +33,7 @@ import { postUpstreamPaths } from '../../../../api/workflowApi';
|
|||
import type { CanvasNode } from '../../editor/FlowCanvas';
|
||||
import { DataRefRenderer } from './DataRefRenderer';
|
||||
import { FeatureInstancePicker } from './FeatureInstancePicker';
|
||||
import { TemplateTextareaRenderer } from './TemplateTextareaRenderer';
|
||||
|
||||
const TextInput: React.FC<FieldRendererProps> = ({ param, value, onChange }) => (
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
|
|
@ -743,6 +744,7 @@ const FilterExpressionEditor: React.FC<FieldRendererProps> = ({ param, value, on
|
|||
export const FRONTEND_TYPE_RENDERERS: Record<string, FieldRendererComponent> = {
|
||||
text: TextInput,
|
||||
textarea: TextareaInput,
|
||||
templateTextarea: TemplateTextareaRenderer,
|
||||
number: NumberInput,
|
||||
checkbox: CheckboxInput,
|
||||
date: DateInput,
|
||||
|
|
|
|||
|
|
@ -91,6 +91,37 @@ function buildFormSchemaPayloadPaths(params: Record<string, unknown>): Array<{
|
|||
return out;
|
||||
}
|
||||
|
||||
function buildLoopCurrentItemPaths(preview: unknown): Array<{ path: (string | number)[]; pathLabel: string }> {
|
||||
const paths: Array<{ path: (string | number)[]; pathLabel: string }> = [
|
||||
{ path: ['currentItem'], pathLabel: 'currentItem' },
|
||||
{ path: ['currentIndex'], pathLabel: 'currentIndex' },
|
||||
{ path: ['count'], pathLabel: 'count' },
|
||||
];
|
||||
if (preview && typeof preview === 'object') {
|
||||
const ci = (preview as Record<string, unknown>).currentItem;
|
||||
if (ci && typeof ci === 'object' && !Array.isArray(ci)) {
|
||||
for (const [k, v] of Object.entries(ci as Record<string, unknown>)) {
|
||||
paths.push(...buildPickablePaths(v, ['currentItem', k]));
|
||||
}
|
||||
}
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
function buildAiPromptPaths(preview: unknown): Array<{ path: (string | number)[]; pathLabel: string }> {
|
||||
const paths = buildPickablePaths(preview);
|
||||
if (preview && typeof preview === 'object') {
|
||||
const rd = (preview as Record<string, unknown>).responseData;
|
||||
if (rd && typeof rd === 'object' && !Array.isArray(rd)) {
|
||||
for (const k of Object.keys(rd as Record<string, unknown>)) {
|
||||
const p = { path: ['responseData', k], pathLabel: `responseData.${k}` };
|
||||
if (!paths.some((x) => x.pathLabel === p.pathLabel)) paths.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
export function pickPathsForNode(
|
||||
node: { type?: string; parameters?: Record<string, unknown> } | undefined,
|
||||
preview: unknown,
|
||||
|
|
@ -113,6 +144,12 @@ export function pickPathsForNode(
|
|||
if (nt.startsWith('clickup.')) {
|
||||
return buildClickUpOutputPaths(preview);
|
||||
}
|
||||
if (nt === 'flow.loop') {
|
||||
return buildLoopCurrentItemPaths(preview);
|
||||
}
|
||||
if (nt === 'ai.prompt') {
|
||||
return buildAiPromptPaths(preview);
|
||||
}
|
||||
return buildPickablePaths(preview);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -76,6 +76,32 @@ export function buildNodeOutputPreview(
|
|||
return _buildSchemaPreview(port0.schema);
|
||||
}
|
||||
|
||||
function _buildEmailItemPreview(): Record<string, unknown> {
|
||||
return {
|
||||
from: { emailAddress: { address: 'sender@example.com', name: 'Sender' } },
|
||||
subject: '...',
|
||||
body: { contentType: 'HTML', content: '...' },
|
||||
receivedDateTime: '2026-01-01T00:00:00Z',
|
||||
toRecipients: [],
|
||||
hasAttachments: false,
|
||||
id: '...',
|
||||
};
|
||||
}
|
||||
|
||||
function _buildAiResponseDataPreview(params: Record<string, unknown>): Record<string, unknown> | null {
|
||||
if (params.resultType !== 'json') return null;
|
||||
const prompt = String(params.aiPrompt || params.prompt || '');
|
||||
if (!prompt) return null;
|
||||
const fields: Record<string, unknown> = {};
|
||||
const re = /["']?(\w+)["']?\s*:\s*(?:true|false|"[^"]*"|'[^']*'|\d+|boolean|string|number|bool)/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(prompt)) !== null) {
|
||||
const f = m[1];
|
||||
if (f && !['type', 'value', 'key'].includes(f)) fields[f] = '...';
|
||||
}
|
||||
return Object.keys(fields).length > 0 ? fields : null;
|
||||
}
|
||||
|
||||
/** Build full nodeOutputsPreview map from graph */
|
||||
export function buildNodeOutputsPreview(
|
||||
nodes: CanvasNode[],
|
||||
|
|
@ -92,5 +118,32 @@ export function buildNodeOutputsPreview(
|
|||
result[n.id] = buildNodeOutputPreview(n, typeMap.get(n.type));
|
||||
}
|
||||
}
|
||||
|
||||
for (const n of nodes) {
|
||||
if (n.id in (nodeOutputsFromRun ?? {})) continue;
|
||||
|
||||
if (n.type === 'flow.loop') {
|
||||
const items = n.parameters?.items;
|
||||
if (items && typeof items === 'object' && (items as { type?: string }).type === 'ref') {
|
||||
const ref = items as { nodeId: string; path?: (string | number)[] };
|
||||
const sourceNode = nodes.find((sn) => sn.id === ref.nodeId);
|
||||
const sourceDef = sourceNode ? typeMap.get(sourceNode.type) : undefined;
|
||||
const sourceSchema = sourceDef?.outputPorts?.[0]?.schema;
|
||||
if (sourceSchema === 'EmailList') {
|
||||
const existing = (result[n.id] ?? {}) as Record<string, unknown>;
|
||||
result[n.id] = { ...existing, currentItem: _buildEmailItemPreview() };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (n.type === 'ai.prompt' && n.parameters) {
|
||||
const rdPreview = _buildAiResponseDataPreview(n.parameters);
|
||||
if (rdPreview) {
|
||||
const existing = (result[n.id] ?? {}) as Record<string, unknown>;
|
||||
result[n.id] = { ...existing, responseData: rdPreview };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import { useToast } from '../contexts/ToastContext';
|
|||
import { usePrompt } from '../hooks/usePrompt';
|
||||
import { useApiRequest } from '../hooks/useApi';
|
||||
import { formatUnixTimestamp } from '../utils/time';
|
||||
import { updateWorkflow, executeGraph, deleteSystemWorkflow } from '../api/workflowApi';
|
||||
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';
|
||||
|
|
@ -1060,6 +1060,186 @@ const _WorkflowsTab: React.FC = () => {
|
|||
);
|
||||
};
|
||||
|
||||
// ===========================================================================
|
||||
// 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
|
||||
// ===========================================================================
|
||||
|
|
@ -1078,6 +1258,11 @@ export const AutomationsDashboardPage: React.FC = () => {
|
|||
label: t('Workflows'),
|
||||
content: <_WorkflowsTab />,
|
||||
},
|
||||
{
|
||||
id: 'workspace',
|
||||
label: t('Workspace'),
|
||||
content: <_WorkspaceTab />,
|
||||
},
|
||||
], [t]);
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||
import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
|
||||
import { useToast } from '../../../contexts/ToastContext';
|
||||
import api from '../../../api';
|
||||
|
|
@ -76,6 +76,7 @@ type RunState = 'idle' | 'starting' | 'running' | 'completed' | 'error';
|
|||
|
||||
export const TrusteeAbschlussView: React.FC = () => {
|
||||
const { t } = useLanguage();
|
||||
const navigate = useNavigate();
|
||||
const { instanceId } = useCurrentInstance();
|
||||
const { showSuccess, showError } = useToast();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
|
@ -325,6 +326,25 @@ export const TrusteeAbschlussView: React.FC = () => {
|
|||
{runState === 'error' && t('Fehler')}
|
||||
{runSummary && <div style={{ marginTop: '0.25rem' }}>{runSummary}</div>}
|
||||
{runError && <div style={{ marginTop: '0.25rem' }}>{runError}</div>}
|
||||
{runState === 'completed' && runId && (
|
||||
<div style={{ marginTop: '0.5rem' }}>
|
||||
<a
|
||||
href={`/automations?tab=workspace&runId=${runId}`}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
navigate(`/automations?tab=workspace&runId=${runId}`);
|
||||
}}
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: '0.375rem',
|
||||
padding: '0.375rem 0.75rem', borderRadius: '6px',
|
||||
background: 'var(--primary-color, #007bff)', color: '#fff',
|
||||
fontSize: '0.8125rem', textDecoration: 'none', fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{t('Im Workspace ansehen')}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||
import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
|
||||
import { useToast } from '../../../contexts/ToastContext';
|
||||
import api from '../../../api';
|
||||
|
|
@ -102,6 +102,7 @@ type RunState = 'idle' | 'starting' | 'running' | 'completed' | 'error';
|
|||
|
||||
export const TrusteeAnalyseView: React.FC = () => {
|
||||
const { t } = useLanguage();
|
||||
const navigate = useNavigate();
|
||||
const { instanceId } = useCurrentInstance();
|
||||
const { showSuccess, showError } = useToast();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
|
@ -481,58 +482,34 @@ export const TrusteeAnalyseView: React.FC = () => {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Results */}
|
||||
{runState === 'completed' && (resultText || resultDocuments.length > 0) && (
|
||||
{/* Workspace link (replaces inline results) */}
|
||||
{runState === 'completed' && runId && (
|
||||
<div style={{
|
||||
marginTop: '0.5rem',
|
||||
padding: '0.75rem 1rem',
|
||||
border: '1px solid var(--border-color, #e0e0e0)',
|
||||
borderRadius: '8px',
|
||||
overflow: 'hidden',
|
||||
background: 'rgba(40,167,69,0.08)',
|
||||
display: 'flex', alignItems: 'center', gap: '0.75rem',
|
||||
}}>
|
||||
{resultDocuments.length > 0 && (
|
||||
<div style={{
|
||||
padding: '0.75rem 1rem',
|
||||
background: 'var(--bg-secondary, #f9f9f9)',
|
||||
borderBottom: resultText ? '1px solid var(--border-color, #e0e0e0)' : 'none',
|
||||
display: 'flex', flexWrap: 'wrap', gap: '0.5rem', alignItems: 'center',
|
||||
}}>
|
||||
<strong style={{ fontSize: '0.8125rem' }}>{t('Generierte Dokumente:')}</strong>
|
||||
{resultDocuments.map((doc, idx) => {
|
||||
const docId = doc.id || (typeof doc === 'string' ? doc : null);
|
||||
const docName = doc.fileName || `Dokument ${idx + 1}`;
|
||||
if (!docId) return null;
|
||||
return (
|
||||
<a
|
||||
key={docId}
|
||||
href={`${api.defaults.baseURL || ''}/api/files/${docId}/download`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: '0.375rem',
|
||||
padding: '0.375rem 0.75rem', borderRadius: '6px',
|
||||
background: 'var(--primary-color, #007bff)', color: '#fff',
|
||||
fontSize: '0.8125rem', textDecoration: 'none', fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
📄 {docName}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{resultText && (
|
||||
<div style={{
|
||||
padding: '1rem',
|
||||
fontSize: '0.875rem',
|
||||
lineHeight: 1.6,
|
||||
whiteSpace: 'pre-wrap',
|
||||
maxHeight: '400px',
|
||||
overflowY: 'auto',
|
||||
background: 'var(--bg-primary, #fff)',
|
||||
}}>
|
||||
{resultText}
|
||||
</div>
|
||||
)}
|
||||
<span style={{ fontSize: '0.875rem', color: 'var(--success-color, #28a745)' }}>
|
||||
{t('Workflow abgeschlossen.')}
|
||||
</span>
|
||||
<a
|
||||
href={`/automations?tab=workspace&runId=${runId}`}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
navigate(`/automations?tab=workspace&runId=${runId}`);
|
||||
}}
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: '0.375rem',
|
||||
padding: '0.375rem 0.75rem', borderRadius: '6px',
|
||||
background: 'var(--primary-color, #007bff)', color: '#fff',
|
||||
fontSize: '0.8125rem', textDecoration: 'none', fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{t('Im Workspace ansehen')}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in a new issue