stabilize

This commit is contained in:
2026-02-10 23:25:42 +03:00
parent d9cc66e35c
commit 091565f020
10 changed files with 664 additions and 102 deletions

View File

@@ -7,11 +7,32 @@
<q-card-section>
<div class="text-h6">Components</div>
<div class="text-caption text-grey">Select a component to interact with</div>
<q-input
v-model="searchQuery"
placeholder="Search by name, description, or tags"
dense
outlined
class="q-mt-sm"
clearable
>
<template v-slot:prepend>
<q-icon name="mdi-magnify" />
</template>
</q-input>
</q-card-section>
<q-list separator>
<div v-if="loading" class="q-pa-md text-center">
<q-spinner size="2em" />
<div class="q-mt-sm">Loading components...</div>
</div>
<div v-else-if="error" class="q-pa-md text-negative">
<q-icon name="mdi-alert-circle" size="2em" />
<div class="q-mt-sm">Failed to load components: {{ error }}</div>
<q-btn flat color="primary" label="Retry" @click="fetchComponents" class="q-mt-sm" />
</div>
<q-list separator v-else>
<q-item
v-for="component in availableComponents"
v-for="component in filteredComponents"
:key="component.code"
clickable
:active="selectedComponent === component.code"
@@ -26,6 +47,17 @@
<q-item-label caption :class="selectedComponent === component.code ? 'text-white' : 'text-grey'">
{{ component.description }}
</q-item-label>
<div v-if="component.tags.length" class="q-mt-xs">
<q-badge
v-for="tag in component.tags"
:key="tag"
color="secondary"
class="q-mr-xs q-mb-xs"
size="sm"
>
{{ tag }}
</q-badge>
</div>
</q-item-section>
</q-item>
</q-list>
@@ -48,22 +80,122 @@
</div>
</q-card-section>
</q-card>
<!-- Call History Card -->
<q-card class="q-mt-md">
<q-card-section class="row items-center justify-between">
<div class="text-subtitle2">Call History</div>
<div class="row items-center q-gutter-sm">
<q-checkbox
v-model="keepRequestInfo"
label="Keep request info"
dense
size="sm"
title="Store request details (body, headers) for reproduction"
/>
<q-btn
v-if="callHistory.length > 0"
flat
dense
icon="mdi-delete"
color="negative"
@click="clearCallHistory"
size="sm"
title="Clear all history"
/>
</div>
</q-card-section>
<q-card-section v-if="callHistory.length === 0" class="text-center text-grey q-pa-md">
<q-icon name="mdi-history" size="48px" class="q-mb-sm" />
<div>No calls yet. Execute a component to see history.</div>
</q-card-section>
<q-list v-else separator dense>
<q-item
v-for="item in callHistory.slice(0, 10)"
:key="item.id"
clickable
@click="selectHistoryItem(item)"
class="q-py-xs"
>
<q-item-section avatar>
<q-icon
:name="item.error ? 'mdi-alert-circle' : 'mdi-check-circle'"
:color="item.error ? 'negative' : 'positive'"
size="sm"
/>
</q-item-section>
<q-item-section>
<q-item-label class="text-caption">{{ item.componentName }}</q-item-label>
<q-item-label caption>
{{ new Date(item.timestamp).toLocaleString() }}
<span v-if="item.duration"> {{ item.duration }}ms</span>
<div v-if="item.error" class="text-negative q-ml-xs"> {{ truncateError(item.error) }}</div>
</q-item-label>
</q-item-section>
<q-item-section side>
<div class="row items-center q-gutter-xs">
<q-btn
v-if="item.inputs"
icon="mdi-repeat"
size="xs"
flat
dense
color="primary"
@click.stop="reproduceHistoryItem(item)"
title="Reproduce this call"
/>
<div class="column items-end">
<q-badge
v-if="item.statusCode !== undefined"
:color="getStatusColor(item.statusCode)"
:label="item.statusCode"
size="sm"
class="q-mb-xs"
/>
<q-badge
:color="item.error ? 'negative' : 'positive'"
:label="item.error ? 'Error' : 'Success'"
size="sm"
/>
</div>
</div>
</q-item-section>
</q-item>
</q-list>
<q-card-actions v-if="callHistory.length > 10" class="q-px-md q-pb-md">
<div class="text-caption text-grey">
Showing 10 of {{ callHistory.length }} calls
</div>
</q-card-actions>
</q-card>
</div>
<!-- Main Component Area -->
<div class="col-12 col-md">
<q-card class="full-height">
<q-card-section>
<div class="text-h6">{{ currentComponentInfo?.name || 'Select a Component' }}</div>
<div class="text-caption text-grey" v-if="currentComponentInfo">
{{ currentComponentInfo.description }}
<q-card-section class="row items-center justify-between">
<div>
<div class="text-h6">{{ currentComponentInfo?.name || 'Select a Component' }}</div>
<div class="text-caption text-grey" v-if="currentComponentInfo">
{{ currentComponentInfo.description }}
</div>
</div>
<q-btn
v-if="currentComponentInfo"
color="primary"
@click="executeComponent"
:loading="isRunning"
size="lg"
icon="mdi-play"
label="Execute"
/>
</q-card-section>
<q-card-section class="q-pa-md">
<component
:is="currentComponentComponent"
v-if="currentComponentComponent"
ref="componentRef"
/>
<div v-else class="text-center text-grey q-pa-xl">
<q-icon name="mdi-cube-outline" size="64px" class="q-mb-md" />
@@ -80,7 +212,7 @@
</template>
<script setup lang="ts">
import { computed, ref, defineAsyncComponent } from 'vue';
import { computed, ref, defineAsyncComponent, onMounted, watch, nextTick } from 'vue';
interface ComponentInfo {
code: string;
@@ -89,35 +221,322 @@ interface ComponentInfo {
icon: string;
className: string;
method: string;
tags: string[];
component: ReturnType<typeof defineAsyncComponent>;
}
const selectedComponent = ref<string>('');
interface ApiComponent {
code: string;
name: string;
description: string;
icon: string;
class: string;
methods: string[];
gui: string;
category: string;
tags: string[];
inputs: Array<{
name: string;
type: string;
description: string;
enum?: string[];
required: boolean;
}>;
outputs: Array<{
name: string;
type: string;
description: string;
}>;
source: string;
}
// Define available components
const availableComponents: ComponentInfo[] = [
{
code: 'calculator',
name: 'Calculator',
description: 'Perform mathematical operations on numbers',
icon: 'mdi-calculator',
className: 'S8n.Components.Basics.Calculator',
method: 'Calc',
component: defineAsyncComponent(() => import('../components_s8n/ComponentCalculator.vue')),
},
{
code: 'httprequest',
name: 'HTTP Request',
description: 'Make HTTP requests to any URL',
icon: 'mdi-web',
className: 'S8n.Components.Basics.HttpRequest',
method: 'Execute',
component: defineAsyncComponent(() => import('../components_s8n/ComponentHttpRequest.vue')),
},
];
const selectedComponent = ref<string>('');
const availableComponents = ref<ComponentInfo[]>([]);
const loading = ref(false);
const error = ref<string | null>(null);
const searchQuery = ref('');
const componentRef = ref<ComponentInstance>();
interface ComponentInstance {
execute: (method: string) => Promise<unknown>;
running: boolean;
error?: string;
duration?: number;
inputs: unknown;
outputs?: unknown;
statusCode?: number;
}
export interface CallHistoryItem {
id: string;
componentCode: string;
componentName: string;
timestamp: number;
inputs: unknown;
error?: string | undefined;
duration: number;
statusCode?: number | undefined;
}
const callHistory = ref<CallHistoryItem[]>([]);
const HISTORY_STORAGE_KEY = 's8n_call_history';
const MAX_HISTORY_ITEMS = 50;
const KEEP_REQUEST_INFO_KEY = 's8n_keep_request_info';
const keepRequestInfo = ref(false);
const loadKeepRequestInfo = () => {
const stored = localStorage.getItem(KEEP_REQUEST_INFO_KEY);
if (stored) {
try {
keepRequestInfo.value = JSON.parse(stored);
} catch (e) {
console.error('Failed to parse keep request info', e);
}
}
};
const saveKeepRequestInfo = () => {
localStorage.setItem(KEEP_REQUEST_INFO_KEY, JSON.stringify(keepRequestInfo.value));
};
const loadCallHistory = () => {
const stored = localStorage.getItem(HISTORY_STORAGE_KEY);
if (stored) {
try {
callHistory.value = JSON.parse(stored);
} catch (e) {
console.error('Failed to parse call history', e);
}
}
};
const saveCallHistory = () => {
localStorage.setItem(HISTORY_STORAGE_KEY, JSON.stringify(callHistory.value));
};
const addCallHistory = (item: Omit<CallHistoryItem, 'id'>) => {
// Deep clone inputs to prevent reference sharing
const clonedItem = {
...item,
inputs: item.inputs !== undefined ? JSON.parse(JSON.stringify(item.inputs)) : item.inputs,
};
// If keepRequestInfo is false, strip sensitive or large input data
const processedItem = { ...clonedItem };
if (!keepRequestInfo.value) {
// Keep only essential metadata about inputs, not the full data
if (processedItem.inputs && typeof processedItem.inputs === 'object') {
const inputs = processedItem.inputs as Record<string, unknown>;
// Create a minimal representation showing input structure without values
const minimalInputs: Record<string, unknown> = {};
for (const key in inputs) {
if (Object.prototype.hasOwnProperty.call(inputs, key)) {
const value = inputs[key];
if (value === null || value === undefined) {
minimalInputs[key] = value;
} else if (Array.isArray(value)) {
minimalInputs[key] = `[Array of ${value.length} items]`;
} else if (typeof value === 'object') {
minimalInputs[key] = `{Object with ${Object.keys(value).length} keys}`;
} else if (typeof value === 'string' && value.length > 50) {
minimalInputs[key] = `${value.substring(0, 47)}...`;
} else {
minimalInputs[key] = value;
}
}
}
processedItem.inputs = minimalInputs;
}
}
const newItem = {
...processedItem,
id: Date.now().toString() + Math.random().toString(36).substring(2),
};
callHistory.value.unshift(newItem);
if (callHistory.value.length > MAX_HISTORY_ITEMS) {
callHistory.value = callHistory.value.slice(0, MAX_HISTORY_ITEMS);
}
saveCallHistory();
};
const clearCallHistory = () => {
callHistory.value = [];
saveCallHistory();
};
// Helper to get color for a status code
const getStatusColor = (statusCode: number): string => {
if (statusCode >= 200 && statusCode < 300) return 'positive';
if (statusCode >= 300 && statusCode < 400) return 'info';
if (statusCode >= 400 && statusCode < 500) return 'warning';
if (statusCode >= 500) return 'negative';
return 'grey';
};
const truncateError = (error: string): string => {
if (error.length <= 50) return error;
return error.substring(0, 47) + '...';
};
const selectHistoryItem = (item: CallHistoryItem) => {
// For now, just log the selection
console.log('Selected history item:', item);
// Could implement functionality to load the component and inputs
};
const reproduceHistoryItem = async (item: CallHistoryItem) => {
// Find the component by code
const component = availableComponents.value.find(c => c.code === item.componentCode);
if (!component) {
console.warn('Component not found:', item.componentCode);
return;
}
// Switch to that component
selectedComponent.value = component.code;
// Wait for component to be loaded (next tick) then set inputs
await nextTick();
if (componentRef.value && componentRef.value.inputs) {
// inputs is a Ref<TIn> from runtime.createExecutor
const inputsRef = componentRef.value.inputs as { value: unknown };
if (typeof inputsRef === 'object' && inputsRef !== null && 'value' in inputsRef) {
inputsRef.value = item.inputs;
} else {
// Fallback: assign directly (might not be reactive)
componentRef.value.inputs = item.inputs;
}
console.log('Inputs reproduced for', item.componentName);
}
};
const executeComponent = async () => {
if (!componentRef.value?.execute) return;
const startTime = Date.now();
try {
await componentRef.value.execute('Execute');
const duration = componentRef.value.duration ?? Date.now() - startTime;
const statusCode = componentRef.value.statusCode;
// Get the actual inputs value (not the Ref)
const inputsValue = componentRef.value.inputs && typeof componentRef.value.inputs === 'object' && 'value' in componentRef.value.inputs
? (componentRef.value.inputs as { value: unknown }).value
: componentRef.value.inputs;
const historyItem: Omit<CallHistoryItem, 'id'> = {
componentCode: currentComponentInfo.value?.code ?? '',
componentName: currentComponentInfo.value?.name ?? '',
timestamp: Date.now(),
inputs: inputsValue,
duration,
statusCode: statusCode,
error: componentRef.value.error,
};
// Omit error property when undefined to satisfy exactOptionalPropertyTypes
addCallHistory(historyItem);
} catch (err) {
const duration = componentRef.value.duration ?? Date.now() - startTime;
// Get the actual inputs value (not the Ref)
const inputsValue = componentRef.value.inputs && typeof componentRef.value.inputs === 'object' && 'value' in componentRef.value.inputs
? (componentRef.value.inputs as { value: unknown }).value
: componentRef.value.inputs;
addCallHistory({
componentCode: currentComponentInfo.value?.code ?? '',
componentName: currentComponentInfo.value?.name ?? '',
timestamp: Date.now(),
inputs: inputsValue,
error: err instanceof Error ? err.message : String(err),
duration,
});
}
};
const isRunning = computed(() => componentRef.value?.running ?? false);
// Map API component to ComponentInfo
const mapApiComponent = (apiComponent: ApiComponent): ComponentInfo => {
// Extract short code from full code (e.g., "basics.calculator" -> "calculator")
const shortCode = apiComponent.code.split('.').pop() || apiComponent.code;
// Determine which Vue component to load based on gui field
let componentImport;
switch (apiComponent.gui) {
case 'ComponentCalculator.vue':
componentImport = () => import('../components_s8n/ComponentCalculator.vue');
break;
case 'ComponentHttpRequest.vue':
componentImport = () => import('../components_s8n/ComponentHttpRequest.vue');
break;
default:
// Fallback to generic component if needed
componentImport = () => import('../components_s8n/ComponentCalculator.vue');
}
return {
code: shortCode,
name: apiComponent.name,
description: apiComponent.description,
icon: apiComponent.icon,
className: apiComponent.class,
method: apiComponent.methods[0] || 'Execute',
tags: apiComponent.tags || [],
component: defineAsyncComponent(componentImport as any), // eslint-disable-line @typescript-eslint/no-explicit-any
};
};
// Fetch components from backend
const fetchComponents = async () => {
loading.value = true;
error.value = null;
try {
const response = await fetch('/api/components');
if (!response.ok) {
throw new Error(`Failed to fetch components: ${response.statusText}`);
}
const data: ApiComponent[] = await response.json();
availableComponents.value = data.map(mapApiComponent);
// Auto-select first component if none selected
if (availableComponents.value.length > 0 && !selectedComponent.value) {
selectedComponent.value = availableComponents.value[0]!.code;
}
} catch (err) {
error.value = err instanceof Error ? err.message : 'Unknown error';
console.error('Failed to load components:', err);
} finally {
loading.value = false;
}
};
onMounted(() => {
void fetchComponents();
loadCallHistory();
loadKeepRequestInfo();
});
// Watch keepRequestInfo to save changes
watch(keepRequestInfo, () => {
saveKeepRequestInfo();
});
const currentComponentInfo = computed(() => {
return availableComponents.find(c => c.code === selectedComponent.value);
return availableComponents.value.find(c => c.code === selectedComponent.value);
});
const filteredComponents = computed(() => {
const query = searchQuery.value.trim().toLowerCase();
if (!query) return availableComponents.value;
return availableComponents.value.filter(comp => {
// Search in name, description, tags
const nameMatch = comp.name.toLowerCase().includes(query);
const descMatch = comp.description.toLowerCase().includes(query);
const tagsMatch = comp.tags.some(tag => tag.toLowerCase().includes(query));
return nameMatch || descMatch || tagsMatch;
});
});
const currentComponentComponent = computed(() => {