update quality
This commit is contained in:
@@ -2,6 +2,7 @@ using System.Reflection;
|
||||
using System.Text.Json;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using Scalar.AspNetCore;
|
||||
|
||||
// Ensure S8n.Components.Packages assembly is loaded
|
||||
@@ -85,7 +86,7 @@ app.MapGet("/api/components", () =>
|
||||
.WithName("GetComponents");
|
||||
|
||||
// Runtime execution endpoint
|
||||
app.MapPost("/api/runtime/execute", (RuntimeRequest request) =>
|
||||
app.MapPost("/api/runtime/execute", async (RuntimeRequest request) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -166,6 +167,43 @@ app.MapPost("/api/runtime/execute", (RuntimeRequest request) =>
|
||||
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
|
||||
{
|
||||
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;
|
||||
await request.then(r => {
|
||||
await request.then((r) => {
|
||||
response = r;
|
||||
statusCode.value = response.status;
|
||||
});
|
||||
@@ -63,13 +62,16 @@ export const runtime = {
|
||||
if (data.error) {
|
||||
throw new Error(data.error);
|
||||
}
|
||||
|
||||
outputs.value = data.outputs || {};
|
||||
if (data.error) {
|
||||
error.value = data.error;
|
||||
}
|
||||
|
||||
return outputs.value;
|
||||
if (method === 'Execute') {
|
||||
outputs.value = data.outputs || {};
|
||||
return outputs.value;
|
||||
} else {
|
||||
return data.outputs || {};
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Runtime execution error:', err);
|
||||
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>
|
||||
{{ error }}
|
||||
</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)
|
||||
</div>
|
||||
</div>
|
||||
@@ -83,7 +83,7 @@ defineExpose({
|
||||
width: 100%;
|
||||
|
||||
.result-section {
|
||||
min-height: 60px;
|
||||
min-height: 3em;
|
||||
display: flex;
|
||||
align-items: 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>
|
||||
<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>
|
||||
|
||||
<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-drawer v-model="leftMenuOpened" show-if-above bordered>
|
||||
<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" />
|
||||
</q-list>
|
||||
@@ -35,11 +16,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useQuasar } from 'quasar';
|
||||
import EssentialLink, { type EssentialLinkProps } from 'components/EssentialLink.vue';
|
||||
|
||||
const $q = useQuasar();
|
||||
import { leftMenuOpened } from './layoutVariables';
|
||||
|
||||
const linksList: EssentialLinkProps[] = [
|
||||
{
|
||||
@@ -55,37 +33,4 @@ const linksList: EssentialLinkProps[] = [
|
||||
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>
|
||||
|
||||
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>
|
||||
<q-page class="playground-page">
|
||||
<div class="row q-pa-md q-gutter-md">
|
||||
<!-- Component Selection Sidebar -->
|
||||
<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>
|
||||
<!-- Toolbar -->
|
||||
<PlaygroundToolbar @refresh="fetchComponents" />
|
||||
|
||||
<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 filteredComponents"
|
||||
:key="component.code"
|
||||
clickable
|
||||
:active="selectedComponent === component.code"
|
||||
@click="selectedComponent = 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>
|
||||
|
||||
<!-- Component Info Card -->
|
||||
<q-card class="q-mt-md" v-if="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 -->
|
||||
<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 class="row q-pa-md q-col-gutter-md">
|
||||
<!-- Main Component Area -->
|
||||
<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>
|
||||
|
||||
<!-- 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>
|
||||
<!-- Component Selection Sidebar -->
|
||||
<div class="col-12 col-md-3" v-show="sidebarOpen">
|
||||
<ComponentSelectionSidebar
|
||||
:selected-component="selectedComponent"
|
||||
:loading="loading"
|
||||
:error="error"
|
||||
:search-query="searchQuery"
|
||||
:filtered-components="filteredComponents"
|
||||
@update:selected-component="selectedComponent = $event"
|
||||
@update:search-query="searchQuery = $event"
|
||||
@refresh="fetchComponents"
|
||||
/>
|
||||
|
||||
<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>
|
||||
<!-- Component Info Card -->
|
||||
<ComponentInfoCard :component-info="currentComponentInfo" />
|
||||
|
||||
<!-- Call History Card -->
|
||||
<CallHistoryCard
|
||||
:current-component-code="currentComponentInfo?.code"
|
||||
@select-history="selectHistoryItem"
|
||||
@reproduce-history="reproduceHistoryItem"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</q-page>
|
||||
</template>
|
||||
|
||||
<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 {
|
||||
code: string;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
className: string;
|
||||
method: string;
|
||||
tags: string[];
|
||||
component: ReturnType<typeof defineAsyncComponent>;
|
||||
}
|
||||
// Component store
|
||||
const {
|
||||
selectedComponent,
|
||||
availableComponents,
|
||||
loading,
|
||||
error,
|
||||
searchQuery,
|
||||
fetchComponents,
|
||||
currentComponentInfo,
|
||||
filteredComponents,
|
||||
} = useComponentStore()
|
||||
|
||||
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;
|
||||
}
|
||||
// Call history
|
||||
const { addCallHistory, loadCallHistory, loadKeepRequestInfo } = useCallHistory(() =>
|
||||
currentComponentInfo.value?.code
|
||||
)
|
||||
|
||||
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>();
|
||||
// Dark mode
|
||||
const { loadDarkModePreference } = useDarkMode()
|
||||
|
||||
interface ComponentInstance {
|
||||
execute: (method: string) => Promise<unknown>;
|
||||
running: boolean;
|
||||
error?: string;
|
||||
duration?: number;
|
||||
inputs: unknown;
|
||||
outputs?: unknown;
|
||||
statusCode?: number;
|
||||
}
|
||||
// Sidebar state
|
||||
const sidebarOpen = ref(true)
|
||||
|
||||
export interface CallHistoryItem {
|
||||
id: string;
|
||||
componentCode: string;
|
||||
componentName: string;
|
||||
timestamp: number;
|
||||
inputs: unknown;
|
||||
error?: string | undefined;
|
||||
duration: number;
|
||||
statusCode?: number | undefined;
|
||||
}
|
||||
// Main area ref
|
||||
const mainAreaRef = ref<InstanceType<typeof MainComponentArea>>()
|
||||
|
||||
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';
|
||||
// Component instance ref
|
||||
const componentRef = computed(() => mainAreaRef.value?.componentRef)
|
||||
|
||||
const keepRequestInfo = ref(false);
|
||||
const isRunning = computed(() => componentRef.value?.running ?? 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 currentComponentComponent = computed(() => currentComponentInfo.value?.component)
|
||||
|
||||
// Execute component
|
||||
const executeComponent = async () => {
|
||||
if (!componentRef.value?.execute) return;
|
||||
const startTime = Date.now();
|
||||
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;
|
||||
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 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 ?? '',
|
||||
@@ -429,19 +113,20 @@ const executeComponent = async () => {
|
||||
timestamp: Date.now(),
|
||||
inputs: inputsValue,
|
||||
duration,
|
||||
statusCode: statusCode,
|
||||
error: componentRef.value.error,
|
||||
|
||||
};
|
||||
// Omit error property when undefined to satisfy exactOptionalPropertyTypes
|
||||
addCallHistory(historyItem);
|
||||
...(statusCode !== undefined ? { statusCode } : {}),
|
||||
...(componentRef.value.error !== undefined ? { error: componentRef.value.error } : {}),
|
||||
}
|
||||
addCallHistory(historyItem)
|
||||
} 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)
|
||||
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 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 ?? '',
|
||||
@@ -450,98 +135,60 @@ const executeComponent = async () => {
|
||||
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;
|
||||
// Custom actions
|
||||
const executeCustomAction = async (method: string) => {
|
||||
if (!componentRef.value?.execute) return
|
||||
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;
|
||||
const result = await componentRef.value.execute(method)
|
||||
if (result) {
|
||||
componentRef.value.inputs = result
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Unknown error';
|
||||
console.error('Failed to load components:', err);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
console.error('Custom action failed:', err)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// 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(() => {
|
||||
void fetchComponents();
|
||||
loadCallHistory();
|
||||
loadKeepRequestInfo();
|
||||
});
|
||||
|
||||
// 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;
|
||||
});
|
||||
void fetchComponents()
|
||||
loadCallHistory()
|
||||
loadKeepRequestInfo()
|
||||
loadDarkModePreference()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { CallHistoryItem } from '../pages/PlaygroundPage.vue';
|
||||
import type { CallHistoryItem } from '../composables/useCallHistory';
|
||||
|
||||
export interface CallHistoryStorage {
|
||||
load(): CallHistoryItem[];
|
||||
|
||||
@@ -58,4 +58,14 @@ public class Calculator
|
||||
|
||||
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.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace S8n.Components.Basics;
|
||||
|
||||
@@ -30,7 +31,7 @@ public class HttpRequest
|
||||
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))
|
||||
{
|
||||
@@ -77,11 +78,11 @@ public class HttpRequest
|
||||
}
|
||||
|
||||
// Execute request
|
||||
using var response = _httpClient.SendAsync(request).GetAwaiter().GetResult();
|
||||
using var response = await _httpClient.SendAsync(request);
|
||||
stopwatch.Stop();
|
||||
|
||||
// Read response content
|
||||
var responseContent = response.Content.ReadAsStringAsync().GetAwaiter().GetResult();
|
||||
var responseContent = await response.Content.ReadAsStringAsync();
|
||||
|
||||
// Extract response headers
|
||||
var responseHeaders = new Dictionary<string, string>();
|
||||
|
||||
Submodule components updated: 053c4fba60...2964949153
Reference in New Issue
Block a user