diff --git a/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.html b/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.html index d660ba6..489cd3a 100644 --- a/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.html +++ b/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.html @@ -70,7 +70,10 @@ } @for (executionInput of editableInputs(); track executionInput.key) {
-
{{ executionInput.label }}
+
+
{{ executionInput.title }}
+
{{ executionInput.subtitle }}
+
Type: {{ executionInput.type }}
@if (isFileInput(executionInput)) { diff --git a/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.ts b/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.ts index 9ba5a06..fe970c2 100644 --- a/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.ts +++ b/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.ts @@ -7,7 +7,8 @@ export type EditableExecutionInput = { key: string; nodeId: string; inputName: string; - label: string; + title: string; + subtitle: string; type: string; value: string; }; diff --git a/src/app/shared/task-execution-viewer/task-execution-viewer.css b/src/app/shared/task-execution-viewer/task-execution-viewer.css index 780064d..f8c8b0c 100644 --- a/src/app/shared/task-execution-viewer/task-execution-viewer.css +++ b/src/app/shared/task-execution-viewer/task-execution-viewer.css @@ -54,3 +54,75 @@ white-space: pre-wrap; word-break: break-word; } + +.execution-aside-tab { + border: 0; + border-bottom: 2px solid transparent; + background: transparent; + color: #64748b; + font-size: 12px; + font-weight: 700; + padding: 9px 12px; + cursor: pointer; +} + +.execution-aside-tab-active { + border-bottom-color: #2563eb; + color: #1d4ed8; +} + +.execution-aside-tab:disabled { + cursor: not-allowed; + opacity: 0.45; +} + +.execution-output-modal { + margin: 0; + border: 1px solid #e2e8f0; + border-radius: 10px; + background: #f8fafc; + color: #0f172a; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + font-size: 12px; + line-height: 1.45; + padding: 14px; + white-space: pre-wrap; + word-break: break-word; +} + +.execution-output-badge { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 52px; + border: 1px solid #bfdbfe; + border-radius: 10px; + background: linear-gradient(180deg, #eff6ff 0%, #dbeafe 100%); + color: #1d4ed8; + line-height: 1.2; + padding: 8px 12px; + text-align: center; +} + +.execution-output-title { + font-size: 14px; + font-weight: 800; +} + +.execution-output-subtitle { + margin-top: 2px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: #2563eb; +} + +.execution-output-preview { + white-space: pre-wrap; + word-break: break-word; + color: #0f172a; + font-size: 12px; + line-height: 1.45; +} diff --git a/src/app/shared/task-execution-viewer/task-execution-viewer.html b/src/app/shared/task-execution-viewer/task-execution-viewer.html index 605461c..3c90e5b 100644 --- a/src/app/shared/task-execution-viewer/task-execution-viewer.html +++ b/src/app/shared/task-execution-viewer/task-execution-viewer.html @@ -63,26 +63,88 @@ @if (contextAsideOpen()) { -
+ @if (outputPreviewModal(); as outputModal) { +
+
+
+
+
+

{{ outputModal.title }}

+

{{ outputModal.subtitle }}

+
+ +
+
+
{{ outputModal.value }}
+
+
+
+ } } @else {
Select an execution from the left panel diff --git a/src/app/shared/task-execution-viewer/task-execution-viewer.ts b/src/app/shared/task-execution-viewer/task-execution-viewer.ts index 076dcbb..238c1b9 100644 --- a/src/app/shared/task-execution-viewer/task-execution-viewer.ts +++ b/src/app/shared/task-execution-viewer/task-execution-viewer.ts @@ -14,6 +14,15 @@ import { import { ReteEditor } from '@shared/rete-editor/rete-editor'; import { TaskExecutionsService } from '@services/task-executions/task-executions'; +type ExecutionOutputEntry = { + key: string; + title: string; + subtitle: string; + value: string; + preview: string; + isLong: boolean; +}; + @Component({ selector: 'app-task-execution-viewer', imports: [CommonModule, ReteEditor, TaskExecutionInputsPanelComponent], @@ -27,6 +36,7 @@ export class TaskExecutionViewerComponent implements OnDestroy { private lastExecutionId: string | null = null; readonly execution = input(null); readonly contextAsideOpen = signal(true); + readonly activeAsideTab = signal<'inputs' | 'output'>('inputs'); readonly startInProgress = signal(false); readonly savingInputs = signal>({}); readonly savingErrors = signal>({}); @@ -34,6 +44,7 @@ export class TaskExecutionViewerComponent implements OnDestroy { readonly pendingAuthorizationValues = signal>({}); readonly savingAuthorizations = signal>({}); readonly authorizationErrors = signal>({}); + readonly outputPreviewModal = signal(null); constructor() { effect(() => { @@ -43,6 +54,14 @@ export class TaskExecutionViewerComponent implements OnDestroy { this.pendingAuthorizationValues.set({}); this.savingAuthorizations.set({}); this.authorizationErrors.set({}); + this.activeAsideTab.set('inputs'); + this.outputPreviewModal.set(null); + }); + + effect(() => { + if (!this.executionOutputTabEnabled() && this.activeAsideTab() === 'output') { + this.activeAsideTab.set('inputs'); + } }); } @@ -109,6 +128,35 @@ export class TaskExecutionViewerComponent implements OnDestroy { return this.formatDuration(context.startTime, context.endTime); }); + readonly executionOutputTabEnabled = computed(() => { + const status = String(this.execution()?.context.status ?? '').toUpperCase(); + return status === 'SUCCESS' || status === 'COMPLETED'; + }); + + readonly executionOutputs = computed(() => { + const steps = this.execution()?.context.steps ?? {}; + const resultMap = { + ...(this.execution()?.context.result ?? {}), + ...(this.execution()?.context.executionResult ?? {}) + }; + + return Object.entries(resultMap) + .map(([key, rawValue]) => { + const value = this.stringifyOutputValue(rawValue); + const isLong = value.length > 160; + const label = this.formatExecutionOutputLabel(key, steps); + return { + key, + title: label.title, + subtitle: label.subtitle, + value, + preview: isLong ? `${value.slice(0, 160)}...` : value, + isLong + }; + }) + .sort((a, b) => a.title.localeCompare(b.title) || a.subtitle.localeCompare(b.subtitle)); + }); + readonly inputsReadOnly = computed(() => { const status = this.execution()?.context.status; return getExecutionStatusGroup(status) !== 'INIT'; @@ -168,20 +216,39 @@ export class TaskExecutionViewerComponent implements OnDestroy { key, nodeId: step.id, inputName, - label: `${step.block.name}.${inputName}`, + title: step.block.name, + subtitle: inputName, type: String(input.descriptor?.type ?? 'TEXT').toUpperCase(), value: pendingValue ?? (rawValue == null ? '' : String(rawValue)) }); } } - return entries.sort((a, b) => a.label.localeCompare(b.label)); + return entries.sort((a, b) => a.title.localeCompare(b.title) || a.subtitle.localeCompare(b.subtitle)); }); toggleContextAside() { this.contextAsideOpen.update((open) => !open); } + selectAsideTab(tab: 'inputs' | 'output') { + if (tab === 'output' && !this.executionOutputTabEnabled()) return; + this.activeAsideTab.set(tab); + } + + openOutputPreview(output: ExecutionOutputEntry, event?: Event) { + event?.preventDefault(); + event?.stopPropagation(); + if (!output.isLong) return; + this.outputPreviewModal.set(output); + } + + closeOutputPreview(event?: Event) { + event?.preventDefault(); + event?.stopPropagation(); + this.outputPreviewModal.set(null); + } + startExecution() { const executionId = this.execution()?.id; if (!executionId || !this.canStartExecution() || this.startInProgress()) return; @@ -326,6 +393,37 @@ export class TaskExecutionViewerComponent implements OnDestroy { }); } + private stringifyOutputValue(value: unknown): string { + if (value == null) return ''; + if (typeof value === 'string') return value; + + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } + } + + private formatExecutionOutputLabel( + key: string, + steps: Record + ): { title: string; subtitle: string } { + const separatorIndex = key.indexOf(':'); + if (separatorIndex < 0) { + return { title: key, subtitle: 'Execution output' }; + } + + const nodeId = key.slice(0, separatorIndex); + const outputName = key.slice(separatorIndex + 1); + const step = steps[nodeId]; + const blockName = step?.block?.name?.trim(); + + return { + title: blockName || key, + subtitle: outputName || 'Execution output' + }; + } + private clearDebounceTimer(timerKey: string) { const timer = this.textInputDebounceTimers.get(timerKey); if (!timer) return;