update quality

This commit is contained in:
2026-02-11 02:12:03 +03:00
parent 091565f020
commit e4c3d7afb1
18 changed files with 1036 additions and 564 deletions

View 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>

View File

@@ -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>

View File

@@ -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>

View 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>

View File

@@ -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>

View File

@@ -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';

View File

@@ -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;

View 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,
}
}

View 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,
}
}

View 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,
}
}

View File

@@ -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>

View File

@@ -0,0 +1,3 @@
import { ref } from 'vue';
export const leftMenuOpened = ref(true);

View File

@@ -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>

View File

@@ -1,4 +1,4 @@
import type { CallHistoryItem } from '../pages/PlaygroundPage.vue';
import type { CallHistoryItem } from '../composables/useCallHistory';
export interface CallHistoryStorage {
load(): CallHistoryItem[];