update quality
This commit is contained in:
@@ -2,6 +2,7 @@ using System.Reflection;
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
|
using System.Threading.Tasks;
|
||||||
using Scalar.AspNetCore;
|
using Scalar.AspNetCore;
|
||||||
|
|
||||||
// Ensure S8n.Components.Packages assembly is loaded
|
// Ensure S8n.Components.Packages assembly is loaded
|
||||||
@@ -85,7 +86,7 @@ app.MapGet("/api/components", () =>
|
|||||||
.WithName("GetComponents");
|
.WithName("GetComponents");
|
||||||
|
|
||||||
// Runtime execution endpoint
|
// Runtime execution endpoint
|
||||||
app.MapPost("/api/runtime/execute", (RuntimeRequest request) =>
|
app.MapPost("/api/runtime/execute", async (RuntimeRequest request) =>
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -166,6 +167,43 @@ app.MapPost("/api/runtime/execute", (RuntimeRequest request) =>
|
|||||||
result = method.Invoke(instance, null);
|
result = method.Invoke(instance, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Console.Error.WriteLine($"Invocation result type: {result?.GetType()}");
|
||||||
|
// If method returns a Task, await it
|
||||||
|
if (result is Task taskResult)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"Detected Task, awaiting...");
|
||||||
|
await taskResult.ConfigureAwait(false);
|
||||||
|
Console.Error.WriteLine($"Task completed, status: {taskResult.Status}");
|
||||||
|
|
||||||
|
// If it's a Task<T>, get the result via reflection
|
||||||
|
var resultType = taskResult.GetType();
|
||||||
|
Console.Error.WriteLine($"Task result type: {resultType}");
|
||||||
|
if (typeof(Task).IsAssignableFrom(resultType))
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"Generic Task detected");
|
||||||
|
// Get the Result property
|
||||||
|
var resultProperty = resultType.GetProperty("Result");
|
||||||
|
if (resultProperty != null)
|
||||||
|
{
|
||||||
|
result = resultProperty.GetValue(taskResult);
|
||||||
|
Console.Error.WriteLine($"Result value: {result}");
|
||||||
|
Console.Error.WriteLine($"Result value type: {result?.GetType()}");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"Result property not found");
|
||||||
|
result = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Task (non-generic) returns nothing
|
||||||
|
Console.Error.WriteLine($"Non-generic Task, setting result to null");
|
||||||
|
result = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.Error.WriteLine($"Final result: {result}");
|
||||||
return Results.Ok(new RuntimeResponse
|
return Results.Ok(new RuntimeResponse
|
||||||
{
|
{
|
||||||
Outputs = result
|
Outputs = result
|
||||||
|
|||||||
138
QuasarFrontend/src/components/playground/CallHistoryCard.vue
Normal file
138
QuasarFrontend/src/components/playground/CallHistoryCard.vue
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
<template>
|
||||||
|
<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-btn-toggle
|
||||||
|
v-model="callHistoryFilter"
|
||||||
|
:options="[
|
||||||
|
{ label: 'All', value: 'all' },
|
||||||
|
{ label: 'Current', value: 'current' },
|
||||||
|
]"
|
||||||
|
dense
|
||||||
|
size="sm"
|
||||||
|
class="q-mr-md"
|
||||||
|
/>
|
||||||
|
<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="filteredCallHistory.length === 0"
|
||||||
|
class="text-center text-grey q-pa-md"
|
||||||
|
>
|
||||||
|
<q-icon name="mdi-history" size="48px" class="q-mb-sm" />
|
||||||
|
<div v-if="callHistory.length === 0">
|
||||||
|
No calls yet. Execute a component to see history.
|
||||||
|
</div>
|
||||||
|
<div v-else>No calls match the current filter.</div>
|
||||||
|
</q-card-section>
|
||||||
|
<q-list v-else separator dense>
|
||||||
|
<q-item
|
||||||
|
v-for="item in filteredCallHistory.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="filteredCallHistory.length > 10" class="q-px-md q-pb-md">
|
||||||
|
<div class="text-caption text-grey">
|
||||||
|
Showing 10 of {{ filteredCallHistory.length }} calls
|
||||||
|
</div>
|
||||||
|
</q-card-actions>
|
||||||
|
</q-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useCallHistory } from 'src/composables/useCallHistory'
|
||||||
|
import type { CallHistoryItem } from 'src/composables/useCallHistory'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
currentComponentCode: string | undefined
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
selectHistory: [item: CallHistoryItem]
|
||||||
|
reproduceHistory: [item: CallHistoryItem]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const {
|
||||||
|
callHistory,
|
||||||
|
keepRequestInfo,
|
||||||
|
callHistoryFilter,
|
||||||
|
clearCallHistory,
|
||||||
|
getStatusColor,
|
||||||
|
truncateError,
|
||||||
|
filteredCallHistory,
|
||||||
|
} = useCallHistory(() => props.currentComponentCode)
|
||||||
|
|
||||||
|
const selectHistoryItem = (item: CallHistoryItem) => {
|
||||||
|
emit('selectHistory', item)
|
||||||
|
}
|
||||||
|
|
||||||
|
const reproduceHistoryItem = (item: CallHistoryItem) => {
|
||||||
|
emit('reproduceHistory', item)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
<template>
|
||||||
|
<q-card class="q-mt-md" v-if="componentInfo">
|
||||||
|
<q-card-section class="row items-center q-gutter-sm">
|
||||||
|
<q-icon :name="componentInfo.icon" size="24px" />
|
||||||
|
<div class="text-subtitle2">{{ componentInfo.name }}</div>
|
||||||
|
<q-badge color="primary" size="sm">{{ componentInfo.category }}</q-badge>
|
||||||
|
<q-space />
|
||||||
|
<q-btn flat dense icon="mdi-information-outline" size="sm" />
|
||||||
|
</q-card-section>
|
||||||
|
<q-separator />
|
||||||
|
<q-card-section>
|
||||||
|
<div class="text-caption q-mb-sm">{{ componentInfo.description }}</div>
|
||||||
|
<div class="row q-gutter-xs q-mb-sm">
|
||||||
|
<q-badge
|
||||||
|
v-for="tag in componentInfo.tags"
|
||||||
|
:key="tag"
|
||||||
|
color="secondary"
|
||||||
|
size="sm"
|
||||||
|
:label="tag"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="text-caption"><strong>Code:</strong> {{ componentInfo.code }}</div>
|
||||||
|
<div class="text-caption">
|
||||||
|
<strong>Class:</strong> {{ componentInfo.className }}
|
||||||
|
</div>
|
||||||
|
<div class="text-caption">
|
||||||
|
<strong>Method:</strong> {{ componentInfo.method }}
|
||||||
|
</div>
|
||||||
|
<div class="text-caption">
|
||||||
|
<strong>Source:</strong> {{ componentInfo.source }}
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
<q-expansion-item
|
||||||
|
label="Inputs"
|
||||||
|
dense
|
||||||
|
header-class="text-caption"
|
||||||
|
v-if="componentInfo.inputs.length > 0"
|
||||||
|
>
|
||||||
|
<q-card>
|
||||||
|
<q-card-section class="q-pa-sm">
|
||||||
|
<div v-for="input in componentInfo.inputs" :key="input.name" class="q-mb-xs">
|
||||||
|
<div class="row items-center">
|
||||||
|
<div class="col-4 text-weight-medium">{{ input.name }}</div>
|
||||||
|
<div class="col-3">
|
||||||
|
<q-badge color="info" size="xs">{{ input.type }}</q-badge>
|
||||||
|
<q-badge v-if="input.required" color="negative" size="xs" class="q-ml-xs"
|
||||||
|
>required</q-badge
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="col-5 text-caption">{{ input.description }}</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="input.enum" class="q-pl-md text-caption text-grey">
|
||||||
|
Options: {{ input.enum.join(', ') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-expansion-item>
|
||||||
|
<q-expansion-item
|
||||||
|
label="Outputs"
|
||||||
|
dense
|
||||||
|
header-class="text-caption"
|
||||||
|
v-if="componentInfo.outputs.length > 0"
|
||||||
|
>
|
||||||
|
<q-card>
|
||||||
|
<q-card-section class="q-pa-sm">
|
||||||
|
<div
|
||||||
|
v-for="output in componentInfo.outputs"
|
||||||
|
:key="output.name"
|
||||||
|
class="q-mb-xs"
|
||||||
|
>
|
||||||
|
<div class="row items-center">
|
||||||
|
<div class="col-4 text-weight-medium">{{ output.name }}</div>
|
||||||
|
<div class="col-3">
|
||||||
|
<q-badge color="positive" size="xs">{{ output.type }}</q-badge>
|
||||||
|
</div>
|
||||||
|
<div class="col-5 text-caption">{{ output.description }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-expansion-item>
|
||||||
|
</q-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { ComponentInfo } from 'src/composables/useComponentStore'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
componentInfo: ComponentInfo | undefined
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
<template>
|
||||||
|
<q-card>
|
||||||
|
<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="searchQueryModel"
|
||||||
|
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>
|
||||||
|
|
||||||
|
<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="refresh" class="q-mt-sm" />
|
||||||
|
</div>
|
||||||
|
<q-list separator v-else>
|
||||||
|
<q-item
|
||||||
|
v-for="component in filteredComponents"
|
||||||
|
:key="component.code"
|
||||||
|
clickable
|
||||||
|
:active="selectedComponent === component.code"
|
||||||
|
@click="selectComponent(component.code)"
|
||||||
|
active-class="bg-primary text-white"
|
||||||
|
>
|
||||||
|
<q-item-section avatar>
|
||||||
|
<q-icon :name="component.icon" />
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label>{{ component.name }}</q-item-label>
|
||||||
|
<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>
|
||||||
|
</q-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import type { ComponentInfo } from 'src/composables/useComponentStore'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
selectedComponent: string
|
||||||
|
loading: boolean
|
||||||
|
error: string | null
|
||||||
|
searchQuery: string
|
||||||
|
filteredComponents: ComponentInfo[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:selectedComponent': [code: string]
|
||||||
|
'update:searchQuery': [query: string]
|
||||||
|
refresh: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const searchQueryModel = computed({
|
||||||
|
get: () => props.searchQuery,
|
||||||
|
set: (value) => emit('update:searchQuery', value),
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectComponent = (code: string) => {
|
||||||
|
emit('update:selectedComponent', code)
|
||||||
|
}
|
||||||
|
|
||||||
|
const refresh = () => {
|
||||||
|
emit('refresh')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
125
QuasarFrontend/src/components/playground/MainComponentArea.vue
Normal file
125
QuasarFrontend/src/components/playground/MainComponentArea.vue
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
<template>
|
||||||
|
<q-card class="full-height">
|
||||||
|
<q-card-section class="row items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div class="text-h6">{{ componentInfo?.name || 'Select a Component' }}</div>
|
||||||
|
<div class="text-caption text-grey" v-if="componentInfo">
|
||||||
|
{{ componentInfo.description }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row items-center q-gutter-xs">
|
||||||
|
<q-btn
|
||||||
|
v-if="componentInfo"
|
||||||
|
color="primary"
|
||||||
|
@click="executeComponent"
|
||||||
|
:loading="isRunning"
|
||||||
|
size="lg"
|
||||||
|
icon="mdi-play"
|
||||||
|
label="Execute"
|
||||||
|
/>
|
||||||
|
<q-btn
|
||||||
|
v-if="componentInfo"
|
||||||
|
color="secondary"
|
||||||
|
@click="toggleSidebar"
|
||||||
|
flat
|
||||||
|
:icon="sidebarOpen ? 'mdi-chevron-right' : 'mdi-chevron-left'"
|
||||||
|
size="lg"
|
||||||
|
:title="sidebarOpen ? 'Hide sidebar' : 'Show sidebar'"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<q-card-section class="q-pa-md">
|
||||||
|
<component
|
||||||
|
:is="componentComponent"
|
||||||
|
v-if="componentComponent"
|
||||||
|
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" />
|
||||||
|
<div class="text-h6">Welcome to S8n Playground</div>
|
||||||
|
<div class="text-body1">
|
||||||
|
Select a component from the sidebar to start interacting with it.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-section>
|
||||||
|
<div
|
||||||
|
class="row q-pa-md"
|
||||||
|
style="background-color: rgba(130, 130, 130, 0.3)"
|
||||||
|
v-if="componentInfo?.methods"
|
||||||
|
>
|
||||||
|
<q-btn
|
||||||
|
v-for="method in customActions"
|
||||||
|
:key="method"
|
||||||
|
color="secondary"
|
||||||
|
@click="executeCustomAction(method)"
|
||||||
|
:loading="isRunning"
|
||||||
|
size="md"
|
||||||
|
:icon="actionIcon(method)"
|
||||||
|
:label="method"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, type Component } from 'vue'
|
||||||
|
import type { ComponentInfo } from 'src/composables/useComponentStore'
|
||||||
|
|
||||||
|
interface ComponentInstance {
|
||||||
|
execute: (method: string) => Promise<unknown>
|
||||||
|
running: boolean
|
||||||
|
error?: string
|
||||||
|
duration?: number
|
||||||
|
inputs: unknown
|
||||||
|
outputs?: unknown
|
||||||
|
statusCode?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
componentInfo: ComponentInfo | undefined
|
||||||
|
componentComponent: Component | undefined
|
||||||
|
sidebarOpen: boolean
|
||||||
|
isRunning: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
execute: []
|
||||||
|
toggleSidebar: []
|
||||||
|
customAction: [method: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const componentRef = ref<ComponentInstance>()
|
||||||
|
|
||||||
|
const customActions = computed(() => {
|
||||||
|
const methods = props.componentInfo?.methods ?? []
|
||||||
|
return methods.filter((m) => m !== 'Execute')
|
||||||
|
})
|
||||||
|
|
||||||
|
const actionIcon = (method: string) => {
|
||||||
|
const icons: Record<string, string> = {
|
||||||
|
Reset: 'mdi-refresh',
|
||||||
|
Clear: 'mdi-close',
|
||||||
|
Default: 'mdi-cog',
|
||||||
|
}
|
||||||
|
return icons[method] || 'mdi-cog'
|
||||||
|
}
|
||||||
|
|
||||||
|
const executeComponent = () => {
|
||||||
|
emit('execute')
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleSidebar = () => {
|
||||||
|
emit('toggleSidebar')
|
||||||
|
}
|
||||||
|
|
||||||
|
const executeCustomAction = (method: string) => {
|
||||||
|
emit('customAction', method)
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
componentRef,
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<template>
|
||||||
|
<q-toolbar class="bg-primary text-white q-px-md q-py-sm">
|
||||||
|
<q-btn flat round dense icon="mdi-menu" @click="toggleLeftMenu" />
|
||||||
|
<q-toolbar-title class="text-h6">S8n Playground</q-toolbar-title>
|
||||||
|
<q-space />
|
||||||
|
<div class="row items-center q-gutter-sm">
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
round
|
||||||
|
dense
|
||||||
|
icon="mdi-refresh"
|
||||||
|
@click="refreshComponents"
|
||||||
|
title="Reload components"
|
||||||
|
/>
|
||||||
|
<q-btn flat round dense icon="mdi-cog" />
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
round
|
||||||
|
:icon="darkModeIcon"
|
||||||
|
aria-label="Toggle Dark Mode"
|
||||||
|
@click="toggleDarkMode"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</q-toolbar>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { leftMenuOpened } from 'src/layouts/layoutVariables'
|
||||||
|
import { useDarkMode } from 'src/composables/useDarkMode'
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
refresh: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { toggleDarkMode, isDark } = useDarkMode()
|
||||||
|
|
||||||
|
const darkModeIcon = computed(() => (isDark ? 'mdi-weather-night' : 'mdi-weather-sunny'))
|
||||||
|
|
||||||
|
const toggleLeftMenu = () => {
|
||||||
|
leftMenuOpened.value = !leftMenuOpened.value
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshComponents = () => {
|
||||||
|
emit('refresh')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -45,9 +45,8 @@ export const runtime = {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
let response: Response | undefined;
|
let response: Response | undefined;
|
||||||
await request.then(r => {
|
await request.then((r) => {
|
||||||
response = r;
|
response = r;
|
||||||
statusCode.value = response.status;
|
statusCode.value = response.status;
|
||||||
});
|
});
|
||||||
@@ -63,13 +62,16 @@ export const runtime = {
|
|||||||
if (data.error) {
|
if (data.error) {
|
||||||
throw new Error(data.error);
|
throw new Error(data.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
outputs.value = data.outputs || {};
|
|
||||||
if (data.error) {
|
if (data.error) {
|
||||||
error.value = data.error;
|
error.value = data.error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (method === 'Execute') {
|
||||||
|
outputs.value = data.outputs || {};
|
||||||
return outputs.value;
|
return outputs.value;
|
||||||
|
} else {
|
||||||
|
return data.outputs || {};
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Runtime execution error:', err);
|
console.error('Runtime execution error:', err);
|
||||||
error.value = err instanceof Error ? err.message : 'An error occurred';
|
error.value = err instanceof Error ? err.message : 'An error occurred';
|
||||||
|
|||||||
@@ -41,7 +41,7 @@
|
|||||||
<q-banner v-if="error" class="bg-negative text-white" rounded>
|
<q-banner v-if="error" class="bg-negative text-white" rounded>
|
||||||
{{ error }}
|
{{ error }}
|
||||||
</q-banner>
|
</q-banner>
|
||||||
<div v-else-if="outputs !== undefined" class="text-h5 text-primary">
|
<div v-else-if="outputs !== undefined" class="text-subtitle1 text-primary">
|
||||||
Result: {{ outputs.result }} ({{ duration }}ms)
|
Result: {{ outputs.result }} ({{ duration }}ms)
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -83,7 +83,7 @@ defineExpose({
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
.result-section {
|
.result-section {
|
||||||
min-height: 60px;
|
min-height: 3em;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|||||||
154
QuasarFrontend/src/composables/useCallHistory.ts
Normal file
154
QuasarFrontend/src/composables/useCallHistory.ts
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
import { ref, computed, watch } from 'vue'
|
||||||
|
|
||||||
|
export interface CallHistoryItem {
|
||||||
|
id: string
|
||||||
|
componentCode: string
|
||||||
|
componentName: string
|
||||||
|
timestamp: number
|
||||||
|
inputs: unknown
|
||||||
|
error?: string
|
||||||
|
duration: number
|
||||||
|
statusCode?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const HISTORY_STORAGE_KEY = 's8n_call_history'
|
||||||
|
const MAX_HISTORY_ITEMS = 50
|
||||||
|
const KEEP_REQUEST_INFO_KEY = 's8n_keep_request_info'
|
||||||
|
|
||||||
|
export function useCallHistory(currentComponentCode?: () => string | undefined) {
|
||||||
|
const callHistory = ref<CallHistoryItem[]>([])
|
||||||
|
const keepRequestInfo = ref(false)
|
||||||
|
const callHistoryFilter = ref<'all' | 'current'>('all')
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
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 filteredCallHistory = computed(() => {
|
||||||
|
const history = callHistory.value
|
||||||
|
if (callHistoryFilter.value === 'all') {
|
||||||
|
return history
|
||||||
|
}
|
||||||
|
// filter by current component
|
||||||
|
const currentCode = currentComponentCode ? currentComponentCode() : undefined
|
||||||
|
if (!currentCode) {
|
||||||
|
return history
|
||||||
|
}
|
||||||
|
return history.filter((item) => item.componentCode === currentCode)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Watch keepRequestInfo to save changes
|
||||||
|
watch(keepRequestInfo, () => {
|
||||||
|
saveKeepRequestInfo()
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
callHistory,
|
||||||
|
keepRequestInfo,
|
||||||
|
callHistoryFilter,
|
||||||
|
loadKeepRequestInfo,
|
||||||
|
loadCallHistory,
|
||||||
|
addCallHistory,
|
||||||
|
clearCallHistory,
|
||||||
|
getStatusColor,
|
||||||
|
truncateError,
|
||||||
|
selectHistoryItem,
|
||||||
|
filteredCallHistory,
|
||||||
|
}
|
||||||
|
}
|
||||||
139
QuasarFrontend/src/composables/useComponentStore.ts
Normal file
139
QuasarFrontend/src/composables/useComponentStore.ts
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { defineAsyncComponent, type AsyncComponentLoader } from 'vue'
|
||||||
|
|
||||||
|
export interface ComponentInput {
|
||||||
|
name: string
|
||||||
|
type: string
|
||||||
|
description: string
|
||||||
|
enum?: string[]
|
||||||
|
required: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ComponentOutput {
|
||||||
|
name: string
|
||||||
|
type: string
|
||||||
|
description: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiComponent {
|
||||||
|
code: string
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
icon: string
|
||||||
|
class: string
|
||||||
|
methods: string[]
|
||||||
|
gui: string
|
||||||
|
category: string
|
||||||
|
tags: string[]
|
||||||
|
inputs: ComponentInput[]
|
||||||
|
outputs: ComponentOutput[]
|
||||||
|
source: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ComponentInfo {
|
||||||
|
code: string
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
icon: string
|
||||||
|
className: string
|
||||||
|
method: string
|
||||||
|
methods: string[]
|
||||||
|
tags: string[]
|
||||||
|
component: ReturnType<typeof defineAsyncComponent>
|
||||||
|
inputs: ComponentInput[]
|
||||||
|
outputs: ComponentOutput[]
|
||||||
|
source: string
|
||||||
|
category: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useComponentStore() {
|
||||||
|
const selectedComponent = ref<string>('')
|
||||||
|
const availableComponents = ref<ComponentInfo[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
const searchQuery = ref('')
|
||||||
|
|
||||||
|
// Map API component to ComponentInfo
|
||||||
|
const mapApiComponent = (apiComponent: ApiComponent): ComponentInfo => {
|
||||||
|
const shortCode = apiComponent.code.split('.').pop() || apiComponent.code
|
||||||
|
|
||||||
|
let componentImport: AsyncComponentLoader
|
||||||
|
switch (apiComponent.gui) {
|
||||||
|
case 'ComponentCalculator.vue':
|
||||||
|
componentImport = () => import('../components_s8n/ComponentCalculator.vue')
|
||||||
|
break
|
||||||
|
case 'ComponentHttpRequest.vue':
|
||||||
|
componentImport = () => import('../components_s8n/ComponentHttpRequest.vue')
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
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',
|
||||||
|
methods: apiComponent.methods,
|
||||||
|
tags: apiComponent.tags || [],
|
||||||
|
component: defineAsyncComponent(componentImport),
|
||||||
|
inputs: apiComponent.inputs,
|
||||||
|
outputs: apiComponent.outputs,
|
||||||
|
source: apiComponent.source,
|
||||||
|
category: apiComponent.category,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentComponentInfo = computed(() => {
|
||||||
|
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) => {
|
||||||
|
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
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
selectedComponent,
|
||||||
|
availableComponents,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
searchQuery,
|
||||||
|
fetchComponents,
|
||||||
|
currentComponentInfo,
|
||||||
|
filteredComponents,
|
||||||
|
}
|
||||||
|
}
|
||||||
34
QuasarFrontend/src/composables/useDarkMode.ts
Normal file
34
QuasarFrontend/src/composables/useDarkMode.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { useQuasar } from 'quasar'
|
||||||
|
|
||||||
|
const DARK_MODE_STORAGE_KEY = 's8n_dark_mode'
|
||||||
|
|
||||||
|
export function useDarkMode() {
|
||||||
|
const $q = useQuasar()
|
||||||
|
|
||||||
|
const toggleDarkMode = () => {
|
||||||
|
const newDarkMode = !$q.dark.isActive
|
||||||
|
$q.dark.set(newDarkMode)
|
||||||
|
saveDarkModePreference(newDarkMode)
|
||||||
|
}
|
||||||
|
|
||||||
|
const 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')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveDarkModePreference = (isDark: boolean) => {
|
||||||
|
localStorage.setItem(DARK_MODE_STORAGE_KEY, JSON.stringify(isDark))
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
toggleDarkMode,
|
||||||
|
loadDarkModePreference,
|
||||||
|
isDark: $q.dark.isActive,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,28 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<q-layout view="lHh Lpr lFf">
|
<q-layout view="lHh Lpr lFf">
|
||||||
<q-header elevated>
|
|
||||||
<q-toolbar>
|
|
||||||
<q-btn flat dense round icon="menu" aria-label="Menu" @click="toggleLeftDrawer" />
|
|
||||||
|
|
||||||
<q-toolbar-title> Quasar App </q-toolbar-title>
|
<q-drawer v-model="leftMenuOpened" show-if-above bordered>
|
||||||
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<q-drawer v-model="leftDrawerOpen" show-if-above bordered>
|
|
||||||
<q-list>
|
<q-list>
|
||||||
<q-item-label header> Essential Links </q-item-label>
|
<q-item-label header> Navigation </q-item-label>
|
||||||
|
|
||||||
<EssentialLink v-for="link in linksList" :key="link.title" v-bind="link" />
|
<EssentialLink v-for="link in linksList" :key="link.title" v-bind="link" />
|
||||||
</q-list>
|
</q-list>
|
||||||
@@ -35,11 +16,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue';
|
|
||||||
import { useQuasar } from 'quasar';
|
|
||||||
import EssentialLink, { type EssentialLinkProps } from 'components/EssentialLink.vue';
|
import EssentialLink, { type EssentialLinkProps } from 'components/EssentialLink.vue';
|
||||||
|
import { leftMenuOpened } from './layoutVariables';
|
||||||
const $q = useQuasar();
|
|
||||||
|
|
||||||
const linksList: EssentialLinkProps[] = [
|
const linksList: EssentialLinkProps[] = [
|
||||||
{
|
{
|
||||||
@@ -55,37 +33,4 @@ const linksList: EssentialLinkProps[] = [
|
|||||||
link: '/#/playground',
|
link: '/#/playground',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
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>
|
</script>
|
||||||
|
|||||||
3
QuasarFrontend/src/layouts/layoutVariables.ts
Normal file
3
QuasarFrontend/src/layouts/layoutVariables.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
export const leftMenuOpened = ref(true);
|
||||||
@@ -1,427 +1,111 @@
|
|||||||
<template>
|
<template>
|
||||||
<q-page class="playground-page">
|
<q-page class="playground-page">
|
||||||
<div class="row q-pa-md q-gutter-md">
|
<!-- Toolbar -->
|
||||||
<!-- Component Selection Sidebar -->
|
<PlaygroundToolbar @refresh="fetchComponents" />
|
||||||
<div class="col-12 col-md-3">
|
|
||||||
<q-card>
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<div v-if="loading" class="q-pa-md text-center">
|
<div class="row q-pa-md q-col-gutter-md">
|
||||||
<q-spinner size="2em" />
|
<!-- Main Component Area -->
|
||||||
<div class="q-mt-sm">Loading components...</div>
|
<div :class="sidebarOpen ? 'col-12 col-md-9' : 'col-12'">
|
||||||
|
<MainComponentArea
|
||||||
|
:component-info="currentComponentInfo"
|
||||||
|
:component-component="currentComponentComponent"
|
||||||
|
:sidebar-open="sidebarOpen"
|
||||||
|
:is-running="isRunning"
|
||||||
|
@execute="executeComponent"
|
||||||
|
@toggle-sidebar="sidebarOpen = !sidebarOpen"
|
||||||
|
@custom-action="executeCustomAction"
|
||||||
|
ref="mainAreaRef"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="error" class="q-pa-md text-negative">
|
|
||||||
<q-icon name="mdi-alert-circle" size="2em" />
|
<!-- Component Selection Sidebar -->
|
||||||
<div class="q-mt-sm">Failed to load components: {{ error }}</div>
|
<div class="col-12 col-md-3" v-show="sidebarOpen">
|
||||||
<q-btn flat color="primary" label="Retry" @click="fetchComponents" class="q-mt-sm" />
|
<ComponentSelectionSidebar
|
||||||
</div>
|
:selected-component="selectedComponent"
|
||||||
<q-list separator v-else>
|
:loading="loading"
|
||||||
<q-item
|
:error="error"
|
||||||
v-for="component in filteredComponents"
|
:search-query="searchQuery"
|
||||||
:key="component.code"
|
:filtered-components="filteredComponents"
|
||||||
clickable
|
@update:selected-component="selectedComponent = $event"
|
||||||
:active="selectedComponent === component.code"
|
@update:search-query="searchQuery = $event"
|
||||||
@click="selectedComponent = component.code"
|
@refresh="fetchComponents"
|
||||||
active-class="bg-primary text-white"
|
/>
|
||||||
>
|
|
||||||
<q-item-section avatar>
|
|
||||||
<q-icon :name="component.icon" />
|
|
||||||
</q-item-section>
|
|
||||||
<q-item-section>
|
|
||||||
<q-item-label>{{ component.name }}</q-item-label>
|
|
||||||
<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>
|
|
||||||
</q-card>
|
|
||||||
|
|
||||||
<!-- Component Info Card -->
|
<!-- Component Info Card -->
|
||||||
<q-card class="q-mt-md" v-if="currentComponentInfo">
|
<ComponentInfoCard :component-info="currentComponentInfo" />
|
||||||
<q-card-section>
|
|
||||||
<div class="text-subtitle2">Component Info</div>
|
|
||||||
</q-card-section>
|
|
||||||
<q-card-section>
|
|
||||||
<div class="text-caption">
|
|
||||||
<strong>Code:</strong> {{ currentComponentInfo.code }}
|
|
||||||
</div>
|
|
||||||
<div class="text-caption">
|
|
||||||
<strong>Class:</strong> {{ currentComponentInfo.className }}
|
|
||||||
</div>
|
|
||||||
<div class="text-caption">
|
|
||||||
<strong>Method:</strong> {{ currentComponentInfo.method }}
|
|
||||||
</div>
|
|
||||||
</q-card-section>
|
|
||||||
</q-card>
|
|
||||||
|
|
||||||
<!-- Call History Card -->
|
<!-- Call History Card -->
|
||||||
<q-card class="q-mt-md">
|
<CallHistoryCard
|
||||||
<q-card-section class="row items-center justify-between">
|
:current-component-code="currentComponentInfo?.code"
|
||||||
<div class="text-subtitle2">Call History</div>
|
@select-history="selectHistoryItem"
|
||||||
<div class="row items-center q-gutter-sm">
|
@reproduce-history="reproduceHistoryItem"
|
||||||
<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 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" />
|
|
||||||
<div class="text-h6">Welcome to S8n Playground</div>
|
|
||||||
<div class="text-body1">
|
|
||||||
Select a component from the sidebar to start interacting with it.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</q-card-section>
|
|
||||||
</q-card>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</q-page>
|
</q-page>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, defineAsyncComponent, onMounted, watch, nextTick } from 'vue';
|
import { ref, computed, onMounted, nextTick } from 'vue'
|
||||||
|
import { useComponentStore } from 'src/composables/useComponentStore'
|
||||||
|
import { useCallHistory } from 'src/composables/useCallHistory'
|
||||||
|
import { useDarkMode } from 'src/composables/useDarkMode'
|
||||||
|
import PlaygroundToolbar from 'src/components/playground/PlaygroundToolbar.vue'
|
||||||
|
import MainComponentArea from 'src/components/playground/MainComponentArea.vue'
|
||||||
|
import ComponentSelectionSidebar from 'src/components/playground/ComponentSelectionSidebar.vue'
|
||||||
|
import ComponentInfoCard from 'src/components/playground/ComponentInfoCard.vue'
|
||||||
|
import CallHistoryCard from 'src/components/playground/CallHistoryCard.vue'
|
||||||
|
import type { CallHistoryItem } from 'src/composables/useCallHistory'
|
||||||
|
|
||||||
interface ComponentInfo {
|
// Component store
|
||||||
code: string;
|
const {
|
||||||
name: string;
|
selectedComponent,
|
||||||
description: string;
|
availableComponents,
|
||||||
icon: string;
|
loading,
|
||||||
className: string;
|
error,
|
||||||
method: string;
|
searchQuery,
|
||||||
tags: string[];
|
fetchComponents,
|
||||||
component: ReturnType<typeof defineAsyncComponent>;
|
currentComponentInfo,
|
||||||
}
|
filteredComponents,
|
||||||
|
} = useComponentStore()
|
||||||
|
|
||||||
interface ApiComponent {
|
// Call history
|
||||||
code: string;
|
const { addCallHistory, loadCallHistory, loadKeepRequestInfo } = useCallHistory(() =>
|
||||||
name: string;
|
currentComponentInfo.value?.code
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectedComponent = ref<string>('');
|
// Dark mode
|
||||||
const availableComponents = ref<ComponentInfo[]>([]);
|
const { loadDarkModePreference } = useDarkMode()
|
||||||
const loading = ref(false);
|
|
||||||
const error = ref<string | null>(null);
|
|
||||||
const searchQuery = ref('');
|
|
||||||
const componentRef = ref<ComponentInstance>();
|
|
||||||
|
|
||||||
interface ComponentInstance {
|
// Sidebar state
|
||||||
execute: (method: string) => Promise<unknown>;
|
const sidebarOpen = ref(true)
|
||||||
running: boolean;
|
|
||||||
error?: string;
|
|
||||||
duration?: number;
|
|
||||||
inputs: unknown;
|
|
||||||
outputs?: unknown;
|
|
||||||
statusCode?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CallHistoryItem {
|
// Main area ref
|
||||||
id: string;
|
const mainAreaRef = ref<InstanceType<typeof MainComponentArea>>()
|
||||||
componentCode: string;
|
|
||||||
componentName: string;
|
|
||||||
timestamp: number;
|
|
||||||
inputs: unknown;
|
|
||||||
error?: string | undefined;
|
|
||||||
duration: number;
|
|
||||||
statusCode?: number | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const callHistory = ref<CallHistoryItem[]>([]);
|
// Component instance ref
|
||||||
const HISTORY_STORAGE_KEY = 's8n_call_history';
|
const componentRef = computed(() => mainAreaRef.value?.componentRef)
|
||||||
const MAX_HISTORY_ITEMS = 50;
|
|
||||||
const KEEP_REQUEST_INFO_KEY = 's8n_keep_request_info';
|
|
||||||
|
|
||||||
const keepRequestInfo = ref(false);
|
const isRunning = computed(() => componentRef.value?.running ?? false)
|
||||||
|
|
||||||
const loadKeepRequestInfo = () => {
|
const currentComponentComponent = computed(() => currentComponentInfo.value?.component)
|
||||||
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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
|
// Execute component
|
||||||
const executeComponent = async () => {
|
const executeComponent = async () => {
|
||||||
if (!componentRef.value?.execute) return;
|
if (!componentRef.value?.execute) return
|
||||||
const startTime = Date.now();
|
const startTime = Date.now()
|
||||||
try {
|
try {
|
||||||
await componentRef.value.execute('Execute');
|
await componentRef.value.execute('Execute')
|
||||||
const duration = componentRef.value.duration ?? Date.now() - startTime;
|
const duration = componentRef.value.duration ?? Date.now() - startTime
|
||||||
const statusCode = componentRef.value.statusCode;
|
const statusCode = componentRef.value.statusCode
|
||||||
|
|
||||||
// Get the actual inputs value (not the Ref)
|
// Get the actual inputs value (not the Ref)
|
||||||
const inputsValue = componentRef.value.inputs && typeof componentRef.value.inputs === 'object' && 'value' in componentRef.value.inputs
|
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 as { value: unknown }).value
|
||||||
: componentRef.value.inputs;
|
: componentRef.value.inputs
|
||||||
|
|
||||||
const historyItem: Omit<CallHistoryItem, 'id'> = {
|
const historyItem: Omit<CallHistoryItem, 'id'> = {
|
||||||
componentCode: currentComponentInfo.value?.code ?? '',
|
componentCode: currentComponentInfo.value?.code ?? '',
|
||||||
@@ -429,19 +113,20 @@ const executeComponent = async () => {
|
|||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
inputs: inputsValue,
|
inputs: inputsValue,
|
||||||
duration,
|
duration,
|
||||||
statusCode: statusCode,
|
...(statusCode !== undefined ? { statusCode } : {}),
|
||||||
error: componentRef.value.error,
|
...(componentRef.value.error !== undefined ? { error: componentRef.value.error } : {}),
|
||||||
|
}
|
||||||
};
|
addCallHistory(historyItem)
|
||||||
// Omit error property when undefined to satisfy exactOptionalPropertyTypes
|
|
||||||
addCallHistory(historyItem);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const duration = componentRef.value.duration ?? Date.now() - startTime;
|
const duration = componentRef.value.duration ?? Date.now() - startTime
|
||||||
|
|
||||||
// Get the actual inputs value (not the Ref)
|
// Get the actual inputs value (not the Ref)
|
||||||
const inputsValue = componentRef.value.inputs && typeof componentRef.value.inputs === 'object' && 'value' in componentRef.value.inputs
|
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 as { value: unknown }).value
|
||||||
: componentRef.value.inputs;
|
: componentRef.value.inputs
|
||||||
|
|
||||||
addCallHistory({
|
addCallHistory({
|
||||||
componentCode: currentComponentInfo.value?.code ?? '',
|
componentCode: currentComponentInfo.value?.code ?? '',
|
||||||
@@ -450,98 +135,60 @@ const executeComponent = async () => {
|
|||||||
inputs: inputsValue,
|
inputs: inputsValue,
|
||||||
error: err instanceof Error ? err.message : String(err),
|
error: err instanceof Error ? err.message : String(err),
|
||||||
duration,
|
duration,
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
const isRunning = computed(() => componentRef.value?.running ?? false);
|
// Custom actions
|
||||||
|
const executeCustomAction = async (method: string) => {
|
||||||
// Map API component to ComponentInfo
|
if (!componentRef.value?.execute) return
|
||||||
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 {
|
try {
|
||||||
const response = await fetch('/api/components');
|
const result = await componentRef.value.execute(method)
|
||||||
if (!response.ok) {
|
if (result) {
|
||||||
throw new Error(`Failed to fetch components: ${response.statusText}`);
|
componentRef.value.inputs = result
|
||||||
}
|
|
||||||
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) {
|
} catch (err) {
|
||||||
error.value = err instanceof Error ? err.message : 'Unknown error';
|
console.error('Custom action failed:', err)
|
||||||
console.error('Failed to load components:', err);
|
|
||||||
} finally {
|
|
||||||
loading.value = false;
|
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
|
// History item selection
|
||||||
|
const selectHistoryItem = (item: CallHistoryItem) => {
|
||||||
|
console.log('Selected history item:', item)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reproduce history item
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
void fetchComponents();
|
void fetchComponents()
|
||||||
loadCallHistory();
|
loadCallHistory()
|
||||||
loadKeepRequestInfo();
|
loadKeepRequestInfo()
|
||||||
});
|
loadDarkModePreference()
|
||||||
|
})
|
||||||
// Watch keepRequestInfo to save changes
|
|
||||||
watch(keepRequestInfo, () => {
|
|
||||||
saveKeepRequestInfo();
|
|
||||||
});
|
|
||||||
|
|
||||||
const currentComponentInfo = computed(() => {
|
|
||||||
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(() => {
|
|
||||||
return currentComponentInfo.value?.component;
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { CallHistoryItem } from '../pages/PlaygroundPage.vue';
|
import type { CallHistoryItem } from '../composables/useCallHistory';
|
||||||
|
|
||||||
export interface CallHistoryStorage {
|
export interface CallHistoryStorage {
|
||||||
load(): CallHistoryItem[];
|
load(): CallHistoryItem[];
|
||||||
|
|||||||
@@ -58,4 +58,14 @@ public class Calculator
|
|||||||
|
|
||||||
return new { Result = result };
|
return new { Result = result };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public object Reset(string @operator, object[] args)
|
||||||
|
{
|
||||||
|
// Return default inputs (operator = "add", args = [0, 0])
|
||||||
|
return new
|
||||||
|
{
|
||||||
|
@operator = "add",
|
||||||
|
args = new decimal[] { 0, 0 }
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
namespace S8n.Components.Basics;
|
namespace S8n.Components.Basics;
|
||||||
|
|
||||||
@@ -30,7 +31,7 @@ public class HttpRequest
|
|||||||
return "http://" + url;
|
return "http://" + url;
|
||||||
}
|
}
|
||||||
|
|
||||||
public object Execute(string method, string url, Dictionary<string, string>? headers = null, object? body = null)
|
public async Task<object> Execute(string method, string url, Dictionary<string, string>? headers = null, object? body = null)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(url))
|
if (string.IsNullOrWhiteSpace(url))
|
||||||
{
|
{
|
||||||
@@ -77,11 +78,11 @@ public class HttpRequest
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Execute request
|
// Execute request
|
||||||
using var response = _httpClient.SendAsync(request).GetAwaiter().GetResult();
|
using var response = await _httpClient.SendAsync(request);
|
||||||
stopwatch.Stop();
|
stopwatch.Stop();
|
||||||
|
|
||||||
// Read response content
|
// Read response content
|
||||||
var responseContent = response.Content.ReadAsStringAsync().GetAwaiter().GetResult();
|
var responseContent = await response.Content.ReadAsStringAsync();
|
||||||
|
|
||||||
// Extract response headers
|
// Extract response headers
|
||||||
var responseHeaders = new Dictionary<string, string>();
|
var responseHeaders = new Dictionary<string, string>();
|
||||||
|
|||||||
Submodule components updated: 053c4fba60...2964949153
Reference in New Issue
Block a user