Improve execution input and output presentation
This commit is contained in:
parent
3496344e9d
commit
dc45a5581b
|
|
@ -70,7 +70,10 @@
|
|||
}
|
||||
@for (executionInput of editableInputs(); track executionInput.key) {
|
||||
<div class="rounded-md border border-slate-200 bg-slate-50 p-2">
|
||||
<div class="text-xs font-semibold text-slate-700 mb-1">{{ executionInput.label }}</div>
|
||||
<div class="mb-3 flex min-h-[52px] flex-col items-center justify-center rounded-[10px] border border-blue-200 bg-gradient-to-b from-blue-50 to-blue-100 px-3 py-2 text-center text-blue-700">
|
||||
<div class="text-[14px] font-extrabold leading-tight">{{ executionInput.title }}</div>
|
||||
<div class="mt-1 text-[11px] font-semibold uppercase tracking-[0.04em] text-blue-600">{{ executionInput.subtitle }}</div>
|
||||
</div>
|
||||
<div class="text-[10px] text-slate-500 mb-2">Type: {{ executionInput.type }}</div>
|
||||
|
||||
@if (isFileInput(executionInput)) {
|
||||
|
|
|
|||
|
|
@ -7,7 +7,8 @@ export type EditableExecutionInput = {
|
|||
key: string;
|
||||
nodeId: string;
|
||||
inputName: string;
|
||||
label: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
type: string;
|
||||
value: string;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,26 +63,88 @@
|
|||
</button>
|
||||
|
||||
@if (contextAsideOpen()) {
|
||||
<aside class="w-[360px] border border-slate-200 rounded-md bg-white min-h-0 overflow-auto">
|
||||
<div class="px-3 py-2 text-xs font-semibold text-slate-600 border-b border-slate-200 bg-slate-50">Execution Inputs</div>
|
||||
<app-task-execution-inputs-panel
|
||||
[editableInputs]="editableInputs()"
|
||||
[authorizationRequirements]="missingAuthorizationRequirements()"
|
||||
[authorizationValues]="pendingAuthorizationValues()"
|
||||
[savingAuthorizations]="savingAuthorizations()"
|
||||
[authorizationErrors]="authorizationErrors()"
|
||||
[savingInputs]="savingInputs()"
|
||||
[savingErrors]="savingErrors()"
|
||||
[readOnly]="inputsReadOnly()"
|
||||
(authorizationValueChange)="onAuthorizationValueChange($event.requirement, $event.value)"
|
||||
(authorizationSubmit)="submitAuthorization($event)"
|
||||
(textInputChange)="onTextInputChange($event.input, $event.value)"
|
||||
(fileInputChange)="onFileInputChange($event.input, $event.file)">
|
||||
</app-task-execution-inputs-panel>
|
||||
<aside class="w-[360px] border border-slate-200 rounded-md bg-white min-h-0 overflow-hidden flex flex-col">
|
||||
<div class="flex border-b border-slate-200 bg-slate-50 px-2 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
class="execution-aside-tab"
|
||||
[class.execution-aside-tab-active]="activeAsideTab() === 'inputs'"
|
||||
(click)="selectAsideTab('inputs')">
|
||||
Execution Inputs
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="execution-aside-tab"
|
||||
[class.execution-aside-tab-active]="activeAsideTab() === 'output'"
|
||||
[disabled]="!executionOutputTabEnabled()"
|
||||
(click)="selectAsideTab('output')">
|
||||
Execution Output
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="min-h-0 flex-1 overflow-auto">
|
||||
@if (activeAsideTab() === 'inputs') {
|
||||
<app-task-execution-inputs-panel
|
||||
[editableInputs]="editableInputs()"
|
||||
[authorizationRequirements]="missingAuthorizationRequirements()"
|
||||
[authorizationValues]="pendingAuthorizationValues()"
|
||||
[savingAuthorizations]="savingAuthorizations()"
|
||||
[authorizationErrors]="authorizationErrors()"
|
||||
[savingInputs]="savingInputs()"
|
||||
[savingErrors]="savingErrors()"
|
||||
[readOnly]="inputsReadOnly()"
|
||||
(authorizationValueChange)="onAuthorizationValueChange($event.requirement, $event.value)"
|
||||
(authorizationSubmit)="submitAuthorization($event)"
|
||||
(textInputChange)="onTextInputChange($event.input, $event.value)"
|
||||
(fileInputChange)="onFileInputChange($event.input, $event.file)">
|
||||
</app-task-execution-inputs-panel>
|
||||
} @else {
|
||||
<div class="p-3 space-y-3">
|
||||
@if (!executionOutputs().length) {
|
||||
<div class="text-xs text-slate-500">No execution output available.</div>
|
||||
} @else {
|
||||
@for (output of executionOutputs(); track output.key) {
|
||||
<div class="rounded-md border border-slate-200 bg-slate-50 p-3">
|
||||
<div class="execution-output-badge">
|
||||
<div class="execution-output-title">{{ output.title }}</div>
|
||||
<div class="execution-output-subtitle">{{ output.subtitle }}</div>
|
||||
</div>
|
||||
<div class="execution-output-preview mt-3">{{ output.preview }}</div>
|
||||
@if (output.isLong) {
|
||||
<button
|
||||
type="button"
|
||||
class="mt-3 text-xs font-semibold text-blue-600 hover:text-blue-700"
|
||||
(click)="openOutputPreview(output, $event)">
|
||||
View full output
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</aside>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@if (outputPreviewModal(); as outputModal) {
|
||||
<div class="fixed inset-0 z-9999 flex items-center justify-center p-4">
|
||||
<div class="absolute inset-0 bg-black/45" (click)="closeOutputPreview($event)"></div>
|
||||
<div class="relative z-10 flex max-h-[90vh] w-full max-w-4xl flex-col overflow-hidden rounded-xl border border-slate-200 bg-white shadow-2xl">
|
||||
<div class="flex items-start justify-between gap-4 border-b border-slate-200 px-5 py-4">
|
||||
<div class="min-w-0">
|
||||
<h3 class="text-lg font-semibold text-slate-900">{{ outputModal.title }}</h3>
|
||||
<p class="mt-1 break-all text-xs text-slate-500">{{ outputModal.subtitle }}</p>
|
||||
</div>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" (click)="closeOutputPreview($event)">Close</button>
|
||||
</div>
|
||||
<div class="min-h-0 flex-1 overflow-auto px-5 py-4">
|
||||
<pre class="execution-output-modal">{{ outputModal.value }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
} @else {
|
||||
<div class="h-full bg-white border border-slate-200 rounded-md text-slate-400 text-center flex items-center justify-center">
|
||||
Select an execution from the left panel
|
||||
|
|
|
|||
|
|
@ -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<TaskExecution | null>(null);
|
||||
readonly contextAsideOpen = signal(true);
|
||||
readonly activeAsideTab = signal<'inputs' | 'output'>('inputs');
|
||||
readonly startInProgress = signal(false);
|
||||
readonly savingInputs = signal<Record<string, boolean>>({});
|
||||
readonly savingErrors = signal<Record<string, string>>({});
|
||||
|
|
@ -34,6 +44,7 @@ export class TaskExecutionViewerComponent implements OnDestroy {
|
|||
readonly pendingAuthorizationValues = signal<Record<string, string>>({});
|
||||
readonly savingAuthorizations = signal<Record<string, boolean>>({});
|
||||
readonly authorizationErrors = signal<Record<string, string>>({});
|
||||
readonly outputPreviewModal = signal<ExecutionOutputEntry | null>(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<ExecutionOutputEntry[]>(() => {
|
||||
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<string, TaskExecutionStep>
|
||||
): { 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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue