stabilize
This commit is contained in:
@@ -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(() => {
|
||||
|
||||
Reference in New Issue
Block a user