stabilize
This commit is contained in:
@@ -8,6 +8,7 @@ export interface RuntimeExecutor<TIn, TOut> {
|
||||
inputs: Ref<TIn>;
|
||||
outputs: Ref<TOut | undefined>;
|
||||
error: Ref<string | undefined>;
|
||||
statusCode: Ref<number | undefined>;
|
||||
duration: Ref<number>;
|
||||
}
|
||||
|
||||
@@ -22,15 +23,17 @@ export const runtime = {
|
||||
const duration = shallowRef(0);
|
||||
const running = shallowRef(false);
|
||||
const error = shallowRef<string>();
|
||||
const statusCode = shallowRef<number>();
|
||||
|
||||
const execute = async (method: string): Promise<TOut | undefined> => {
|
||||
console.trace('executing...', className, method);
|
||||
const startTime = performance.now();
|
||||
try {
|
||||
error.value = undefined;
|
||||
statusCode.value = undefined;
|
||||
running.value = true;
|
||||
duration.value = 0;
|
||||
const response = await fetch(`${API_BASE_URL}/api/runtime/execute`, {
|
||||
const request = fetch(`${API_BASE_URL}/api/runtime/execute`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -42,9 +45,17 @@ export const runtime = {
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
let response: Response | undefined;
|
||||
await request.then(r => {
|
||||
response = r;
|
||||
statusCode.value = response.status;
|
||||
});
|
||||
if (!response) return;
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || `HTTP error! status: ${response.status}`);
|
||||
throw new Error(errorData.error || `HTTP error! ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
@@ -54,11 +65,14 @@ export const runtime = {
|
||||
}
|
||||
|
||||
outputs.value = data.outputs || {};
|
||||
if (data.error) {
|
||||
error.value = data.error;
|
||||
}
|
||||
|
||||
return outputs.value;
|
||||
} catch (err) {
|
||||
console.error('Runtime execution error:', err);
|
||||
error.value = err instanceof Error ? err.message : 'An error occurred';
|
||||
|
||||
if (!silentErrors) {
|
||||
throw err;
|
||||
}
|
||||
@@ -78,6 +92,7 @@ export const runtime = {
|
||||
inputs,
|
||||
outputs,
|
||||
error,
|
||||
statusCode,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -46,16 +46,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row justify-end q-mt-md">
|
||||
<q-btn
|
||||
color="primary"
|
||||
@click="execute('Calc')"
|
||||
:loading="running"
|
||||
size="lg"
|
||||
icon="calculate"
|
||||
label="Calculate"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -63,7 +53,7 @@
|
||||
<script setup lang="ts">
|
||||
import { runtime } from '../components/s8n-runtime';
|
||||
|
||||
const { execute, running, error, duration, outputs, inputs } = runtime.createExecutor<
|
||||
const { execute, statusCode, running, error, duration, outputs, inputs } = runtime.createExecutor<
|
||||
{
|
||||
operator: string;
|
||||
args: number[];
|
||||
@@ -75,6 +65,16 @@ const { execute, running, error, duration, outputs, inputs } = runtime.createExe
|
||||
operator: 'add',
|
||||
args: [0, 0],
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
execute,
|
||||
running,
|
||||
error,
|
||||
duration,
|
||||
outputs,
|
||||
inputs,
|
||||
statusCode,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@@ -128,16 +128,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row justify-end q-mt-md">
|
||||
<q-btn
|
||||
color="primary"
|
||||
@click="execute('Execute')"
|
||||
:loading="running"
|
||||
size="lg"
|
||||
icon="mdi-send"
|
||||
label="Send Request"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -173,7 +163,7 @@ const httpMethods = [
|
||||
|
||||
const bodyText = ref('');
|
||||
|
||||
const { execute, running, error, outputs, inputs } = runtime.createExecutor<
|
||||
const { execute, running, error, duration, outputs, inputs, statusCode } = runtime.createExecutor<
|
||||
HttpRequestInputs,
|
||||
HttpRequestOutputs
|
||||
>('S8n.Components.Basics.HttpRequest', {
|
||||
@@ -183,6 +173,16 @@ const { execute, running, error, outputs, inputs } = runtime.createExecutor<
|
||||
body: null,
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
execute,
|
||||
running,
|
||||
error,
|
||||
duration,
|
||||
outputs,
|
||||
inputs,
|
||||
statusCode,
|
||||
});
|
||||
|
||||
const showBodyInput = computed(() => {
|
||||
const method = inputs.value.method.toUpperCase();
|
||||
return ['POST', 'PUT', 'PATCH'].includes(method);
|
||||
|
||||
@@ -6,7 +6,17 @@
|
||||
|
||||
<q-toolbar-title> Quasar App </q-toolbar-title>
|
||||
|
||||
<div>Quasar v{{ $q.version }}</div>
|
||||
<div class="row items-center q-gutter-sm">
|
||||
<div>Quasar v{{ $q.version }}</div>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
round
|
||||
:icon="$q.dark.isActive ? 'mdi-weather-night' : 'mdi-weather-sunny'"
|
||||
aria-label="Toggle Dark Mode"
|
||||
@click="toggleDarkMode"
|
||||
/>
|
||||
</div>
|
||||
</q-toolbar>
|
||||
</q-header>
|
||||
|
||||
@@ -25,9 +35,12 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useQuasar } from 'quasar';
|
||||
import EssentialLink, { type EssentialLinkProps } from 'components/EssentialLink.vue';
|
||||
|
||||
const $q = useQuasar();
|
||||
|
||||
const linksList: EssentialLinkProps[] = [
|
||||
{
|
||||
title: 'Home',
|
||||
@@ -41,53 +54,38 @@ const linksList: EssentialLinkProps[] = [
|
||||
icon: 'mdi-cube-outline',
|
||||
link: '/#/playground',
|
||||
},
|
||||
{
|
||||
title: 'Docs',
|
||||
caption: 'quasar.dev',
|
||||
icon: 'school',
|
||||
link: 'https://quasar.dev',
|
||||
},
|
||||
{
|
||||
title: 'Github',
|
||||
caption: 'github.com/quasarframework',
|
||||
icon: 'code',
|
||||
link: 'https://github.com/quasarframework',
|
||||
},
|
||||
{
|
||||
title: 'Discord Chat Channel',
|
||||
caption: 'chat.quasar.dev',
|
||||
icon: 'chat',
|
||||
link: 'https://chat.quasar.dev',
|
||||
},
|
||||
{
|
||||
title: 'Forum',
|
||||
caption: 'forum.quasar.dev',
|
||||
icon: 'record_voice_over',
|
||||
link: 'https://forum.quasar.dev',
|
||||
},
|
||||
{
|
||||
title: 'Twitter',
|
||||
caption: '@quasarframework',
|
||||
icon: 'rss_feed',
|
||||
link: 'https://twitter.quasar.dev',
|
||||
},
|
||||
{
|
||||
title: 'Facebook',
|
||||
caption: '@QuasarFramework',
|
||||
icon: 'public',
|
||||
link: 'https://facebook.quasar.dev',
|
||||
},
|
||||
{
|
||||
title: 'Quasar Awesome',
|
||||
caption: 'Community Quasar projects',
|
||||
icon: 'favorite',
|
||||
link: 'https://awesome.quasar.dev',
|
||||
},
|
||||
];
|
||||
|
||||
const leftDrawerOpen = ref(false);
|
||||
|
||||
const DARK_MODE_STORAGE_KEY = 's8n_dark_mode';
|
||||
|
||||
function loadDarkModePreference() {
|
||||
const stored = localStorage.getItem(DARK_MODE_STORAGE_KEY);
|
||||
if (stored !== null) {
|
||||
const isDark = JSON.parse(stored);
|
||||
$q.dark.set(isDark);
|
||||
} else {
|
||||
// Default to system preference if no stored preference
|
||||
$q.dark.set('auto');
|
||||
}
|
||||
}
|
||||
|
||||
function saveDarkModePreference(isDark: boolean) {
|
||||
localStorage.setItem(DARK_MODE_STORAGE_KEY, JSON.stringify(isDark));
|
||||
}
|
||||
|
||||
function toggleDarkMode() {
|
||||
const newDarkMode = !$q.dark.isActive;
|
||||
$q.dark.set(newDarkMode);
|
||||
saveDarkModePreference(newDarkMode);
|
||||
}
|
||||
|
||||
function toggleLeftDrawer() {
|
||||
leftDrawerOpen.value = !leftDrawerOpen.value;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadDarkModePreference();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
53
QuasarFrontend/src/services/callHistoryStorage.ts
Normal file
53
QuasarFrontend/src/services/callHistoryStorage.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import type { CallHistoryItem } from '../pages/PlaygroundPage.vue';
|
||||
|
||||
export interface CallHistoryStorage {
|
||||
load(): CallHistoryItem[];
|
||||
save(items: CallHistoryItem[]): void;
|
||||
clear(): void;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 's8n_call_history';
|
||||
|
||||
export class LocalStorageCallHistory implements CallHistoryStorage {
|
||||
load(): CallHistoryItem[] {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (!stored) return [];
|
||||
try {
|
||||
return JSON.parse(stored);
|
||||
} catch (e) {
|
||||
console.error('Failed to parse call history from localStorage', e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
save(items: CallHistoryItem[]): void {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(items));
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
// For future backend storage
|
||||
export class BackendCallHistory implements CallHistoryStorage {
|
||||
// TODO: Implement API calls
|
||||
load(): CallHistoryItem[] {
|
||||
console.warn('Backend storage not implemented yet, returning empty array');
|
||||
return [];
|
||||
}
|
||||
|
||||
save(items: CallHistoryItem[]): void {
|
||||
console.warn('Backend storage not implemented yet, not saving', items.length);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
console.warn('Backend storage not implemented yet, not clearing');
|
||||
}
|
||||
}
|
||||
|
||||
// Factory to switch between storage implementations
|
||||
export function createCallHistoryStorage(): CallHistoryStorage {
|
||||
// For now, use localStorage. In the future, we can check environment or config
|
||||
return new LocalStorageCallHistory();
|
||||
}
|
||||
Reference in New Issue
Block a user