stabilize

This commit is contained in:
2026-02-10 23:25:42 +03:00
parent d9cc66e35c
commit 091565f020
10 changed files with 664 additions and 102 deletions

View File

@@ -106,7 +106,9 @@ export default defineConfig((ctx) => {
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-file#framework
framework: {
config: {},
config: {
dark: 'auto',
},
// iconSet: 'material-icons', // Quasar icon set
// lang: 'en-US', // Quasar language pack

View File

@@ -8,6 +8,7 @@ export interface RuntimeExecutor<TIn, TOut> {
inputs: Ref<TIn>;
outputs: Ref<TOut | undefined>;
error: Ref<string | undefined>;
statusCode: Ref<number | undefined>;
duration: Ref<number>;
}
@@ -22,15 +23,17 @@ export const runtime = {
const duration = shallowRef(0);
const running = shallowRef(false);
const error = shallowRef<string>();
const statusCode = shallowRef<number>();
const execute = async (method: string): Promise<TOut | undefined> => {
console.trace('executing...', className, method);
const startTime = performance.now();
try {
error.value = undefined;
statusCode.value = undefined;
running.value = true;
duration.value = 0;
const response = await fetch(`${API_BASE_URL}/api/runtime/execute`, {
const request = fetch(`${API_BASE_URL}/api/runtime/execute`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -42,9 +45,17 @@ export const runtime = {
}),
});
let response: Response | undefined;
await request.then(r => {
response = r;
statusCode.value = response.status;
});
if (!response) return;
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || `HTTP error! status: ${response.status}`);
throw new Error(errorData.error || `HTTP error! ${response.statusText}`);
}
const data = await response.json();
@@ -54,11 +65,14 @@ export const runtime = {
}
outputs.value = data.outputs || {};
if (data.error) {
error.value = data.error;
}
return outputs.value;
} catch (err) {
console.error('Runtime execution error:', err);
error.value = err instanceof Error ? err.message : 'An error occurred';
if (!silentErrors) {
throw err;
}
@@ -78,6 +92,7 @@ export const runtime = {
inputs,
outputs,
error,
statusCode,
};
},
};

View File

@@ -46,16 +46,6 @@
</div>
</div>
<div class="row justify-end q-mt-md">
<q-btn
color="primary"
@click="execute('Calc')"
:loading="running"
size="lg"
icon="calculate"
label="Calculate"
/>
</div>
</div>
</div>
</template>
@@ -63,7 +53,7 @@
<script setup lang="ts">
import { runtime } from '../components/s8n-runtime';
const { execute, running, error, duration, outputs, inputs } = runtime.createExecutor<
const { execute, statusCode, running, error, duration, outputs, inputs } = runtime.createExecutor<
{
operator: string;
args: number[];
@@ -75,6 +65,16 @@ const { execute, running, error, duration, outputs, inputs } = runtime.createExe
operator: 'add',
args: [0, 0],
});
defineExpose({
execute,
running,
error,
duration,
outputs,
inputs,
statusCode,
});
</script>
<style lang="scss" scoped>

View File

@@ -128,16 +128,6 @@
</div>
</div>
<div class="row justify-end q-mt-md">
<q-btn
color="primary"
@click="execute('Execute')"
:loading="running"
size="lg"
icon="mdi-send"
label="Send Request"
/>
</div>
</div>
</div>
</template>
@@ -173,7 +163,7 @@ const httpMethods = [
const bodyText = ref('');
const { execute, running, error, outputs, inputs } = runtime.createExecutor<
const { execute, running, error, duration, outputs, inputs, statusCode } = runtime.createExecutor<
HttpRequestInputs,
HttpRequestOutputs
>('S8n.Components.Basics.HttpRequest', {
@@ -183,6 +173,16 @@ const { execute, running, error, outputs, inputs } = runtime.createExecutor<
body: null,
});
defineExpose({
execute,
running,
error,
duration,
outputs,
inputs,
statusCode,
});
const showBodyInput = computed(() => {
const method = inputs.value.method.toUpperCase();
return ['POST', 'PUT', 'PATCH'].includes(method);

View File

@@ -6,7 +6,17 @@
<q-toolbar-title> Quasar App </q-toolbar-title>
<div>Quasar v{{ $q.version }}</div>
<div class="row items-center q-gutter-sm">
<div>Quasar v{{ $q.version }}</div>
<q-btn
flat
dense
round
:icon="$q.dark.isActive ? 'mdi-weather-night' : 'mdi-weather-sunny'"
aria-label="Toggle Dark Mode"
@click="toggleDarkMode"
/>
</div>
</q-toolbar>
</q-header>
@@ -25,9 +35,12 @@
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { ref, onMounted } from 'vue';
import { useQuasar } from 'quasar';
import EssentialLink, { type EssentialLinkProps } from 'components/EssentialLink.vue';
const $q = useQuasar();
const linksList: EssentialLinkProps[] = [
{
title: 'Home',
@@ -41,53 +54,38 @@ const linksList: EssentialLinkProps[] = [
icon: 'mdi-cube-outline',
link: '/#/playground',
},
{
title: 'Docs',
caption: 'quasar.dev',
icon: 'school',
link: 'https://quasar.dev',
},
{
title: 'Github',
caption: 'github.com/quasarframework',
icon: 'code',
link: 'https://github.com/quasarframework',
},
{
title: 'Discord Chat Channel',
caption: 'chat.quasar.dev',
icon: 'chat',
link: 'https://chat.quasar.dev',
},
{
title: 'Forum',
caption: 'forum.quasar.dev',
icon: 'record_voice_over',
link: 'https://forum.quasar.dev',
},
{
title: 'Twitter',
caption: '@quasarframework',
icon: 'rss_feed',
link: 'https://twitter.quasar.dev',
},
{
title: 'Facebook',
caption: '@QuasarFramework',
icon: 'public',
link: 'https://facebook.quasar.dev',
},
{
title: 'Quasar Awesome',
caption: 'Community Quasar projects',
icon: 'favorite',
link: 'https://awesome.quasar.dev',
},
];
const leftDrawerOpen = ref(false);
const DARK_MODE_STORAGE_KEY = 's8n_dark_mode';
function loadDarkModePreference() {
const stored = localStorage.getItem(DARK_MODE_STORAGE_KEY);
if (stored !== null) {
const isDark = JSON.parse(stored);
$q.dark.set(isDark);
} else {
// Default to system preference if no stored preference
$q.dark.set('auto');
}
}
function saveDarkModePreference(isDark: boolean) {
localStorage.setItem(DARK_MODE_STORAGE_KEY, JSON.stringify(isDark));
}
function toggleDarkMode() {
const newDarkMode = !$q.dark.isActive;
$q.dark.set(newDarkMode);
saveDarkModePreference(newDarkMode);
}
function toggleLeftDrawer() {
leftDrawerOpen.value = !leftDrawerOpen.value;
}
onMounted(() => {
loadDarkModePreference();
});
</script>

View File

@@ -7,11 +7,32 @@
<q-card-section>
<div class="text-h6">Components</div>
<div class="text-caption text-grey">Select a component to interact with</div>
<q-input
v-model="searchQuery"
placeholder="Search by name, description, or tags"
dense
outlined
class="q-mt-sm"
clearable
>
<template v-slot:prepend>
<q-icon name="mdi-magnify" />
</template>
</q-input>
</q-card-section>
<q-list separator>
<div v-if="loading" class="q-pa-md text-center">
<q-spinner size="2em" />
<div class="q-mt-sm">Loading components...</div>
</div>
<div v-else-if="error" class="q-pa-md text-negative">
<q-icon name="mdi-alert-circle" size="2em" />
<div class="q-mt-sm">Failed to load components: {{ error }}</div>
<q-btn flat color="primary" label="Retry" @click="fetchComponents" class="q-mt-sm" />
</div>
<q-list separator v-else>
<q-item
v-for="component in availableComponents"
v-for="component in filteredComponents"
:key="component.code"
clickable
:active="selectedComponent === component.code"
@@ -26,6 +47,17 @@
<q-item-label caption :class="selectedComponent === component.code ? 'text-white' : 'text-grey'">
{{ component.description }}
</q-item-label>
<div v-if="component.tags.length" class="q-mt-xs">
<q-badge
v-for="tag in component.tags"
:key="tag"
color="secondary"
class="q-mr-xs q-mb-xs"
size="sm"
>
{{ tag }}
</q-badge>
</div>
</q-item-section>
</q-item>
</q-list>
@@ -48,22 +80,122 @@
</div>
</q-card-section>
</q-card>
<!-- Call History Card -->
<q-card class="q-mt-md">
<q-card-section class="row items-center justify-between">
<div class="text-subtitle2">Call History</div>
<div class="row items-center q-gutter-sm">
<q-checkbox
v-model="keepRequestInfo"
label="Keep request info"
dense
size="sm"
title="Store request details (body, headers) for reproduction"
/>
<q-btn
v-if="callHistory.length > 0"
flat
dense
icon="mdi-delete"
color="negative"
@click="clearCallHistory"
size="sm"
title="Clear all history"
/>
</div>
</q-card-section>
<q-card-section v-if="callHistory.length === 0" class="text-center text-grey q-pa-md">
<q-icon name="mdi-history" size="48px" class="q-mb-sm" />
<div>No calls yet. Execute a component to see history.</div>
</q-card-section>
<q-list v-else separator dense>
<q-item
v-for="item in callHistory.slice(0, 10)"
:key="item.id"
clickable
@click="selectHistoryItem(item)"
class="q-py-xs"
>
<q-item-section avatar>
<q-icon
:name="item.error ? 'mdi-alert-circle' : 'mdi-check-circle'"
:color="item.error ? 'negative' : 'positive'"
size="sm"
/>
</q-item-section>
<q-item-section>
<q-item-label class="text-caption">{{ item.componentName }}</q-item-label>
<q-item-label caption>
{{ new Date(item.timestamp).toLocaleString() }}
<span v-if="item.duration"> {{ item.duration }}ms</span>
<div v-if="item.error" class="text-negative q-ml-xs"> {{ truncateError(item.error) }}</div>
</q-item-label>
</q-item-section>
<q-item-section side>
<div class="row items-center q-gutter-xs">
<q-btn
v-if="item.inputs"
icon="mdi-repeat"
size="xs"
flat
dense
color="primary"
@click.stop="reproduceHistoryItem(item)"
title="Reproduce this call"
/>
<div class="column items-end">
<q-badge
v-if="item.statusCode !== undefined"
:color="getStatusColor(item.statusCode)"
:label="item.statusCode"
size="sm"
class="q-mb-xs"
/>
<q-badge
:color="item.error ? 'negative' : 'positive'"
:label="item.error ? 'Error' : 'Success'"
size="sm"
/>
</div>
</div>
</q-item-section>
</q-item>
</q-list>
<q-card-actions v-if="callHistory.length > 10" class="q-px-md q-pb-md">
<div class="text-caption text-grey">
Showing 10 of {{ callHistory.length }} calls
</div>
</q-card-actions>
</q-card>
</div>
<!-- Main Component Area -->
<div class="col-12 col-md">
<q-card class="full-height">
<q-card-section>
<div class="text-h6">{{ currentComponentInfo?.name || 'Select a Component' }}</div>
<div class="text-caption text-grey" v-if="currentComponentInfo">
{{ currentComponentInfo.description }}
<q-card-section class="row items-center justify-between">
<div>
<div class="text-h6">{{ currentComponentInfo?.name || 'Select a Component' }}</div>
<div class="text-caption text-grey" v-if="currentComponentInfo">
{{ currentComponentInfo.description }}
</div>
</div>
<q-btn
v-if="currentComponentInfo"
color="primary"
@click="executeComponent"
:loading="isRunning"
size="lg"
icon="mdi-play"
label="Execute"
/>
</q-card-section>
<q-card-section class="q-pa-md">
<component
:is="currentComponentComponent"
v-if="currentComponentComponent"
ref="componentRef"
/>
<div v-else class="text-center text-grey q-pa-xl">
<q-icon name="mdi-cube-outline" size="64px" class="q-mb-md" />
@@ -80,7 +212,7 @@
</template>
<script setup lang="ts">
import { computed, ref, defineAsyncComponent } from 'vue';
import { computed, ref, defineAsyncComponent, onMounted, watch, nextTick } from 'vue';
interface ComponentInfo {
code: string;
@@ -89,35 +221,322 @@ interface ComponentInfo {
icon: string;
className: string;
method: string;
tags: string[];
component: ReturnType<typeof defineAsyncComponent>;
}
const selectedComponent = ref<string>('');
interface ApiComponent {
code: string;
name: string;
description: string;
icon: string;
class: string;
methods: string[];
gui: string;
category: string;
tags: string[];
inputs: Array<{
name: string;
type: string;
description: string;
enum?: string[];
required: boolean;
}>;
outputs: Array<{
name: string;
type: string;
description: string;
}>;
source: string;
}
// Define available components
const availableComponents: ComponentInfo[] = [
{
code: 'calculator',
name: 'Calculator',
description: 'Perform mathematical operations on numbers',
icon: 'mdi-calculator',
className: 'S8n.Components.Basics.Calculator',
method: 'Calc',
component: defineAsyncComponent(() => import('../components_s8n/ComponentCalculator.vue')),
},
{
code: 'httprequest',
name: 'HTTP Request',
description: 'Make HTTP requests to any URL',
icon: 'mdi-web',
className: 'S8n.Components.Basics.HttpRequest',
method: 'Execute',
component: defineAsyncComponent(() => import('../components_s8n/ComponentHttpRequest.vue')),
},
];
const selectedComponent = ref<string>('');
const availableComponents = ref<ComponentInfo[]>([]);
const loading = ref(false);
const error = ref<string | null>(null);
const searchQuery = ref('');
const componentRef = ref<ComponentInstance>();
interface ComponentInstance {
execute: (method: string) => Promise<unknown>;
running: boolean;
error?: string;
duration?: number;
inputs: unknown;
outputs?: unknown;
statusCode?: number;
}
export interface CallHistoryItem {
id: string;
componentCode: string;
componentName: string;
timestamp: number;
inputs: unknown;
error?: string | undefined;
duration: number;
statusCode?: number | undefined;
}
const callHistory = ref<CallHistoryItem[]>([]);
const HISTORY_STORAGE_KEY = 's8n_call_history';
const MAX_HISTORY_ITEMS = 50;
const KEEP_REQUEST_INFO_KEY = 's8n_keep_request_info';
const keepRequestInfo = ref(false);
const loadKeepRequestInfo = () => {
const stored = localStorage.getItem(KEEP_REQUEST_INFO_KEY);
if (stored) {
try {
keepRequestInfo.value = JSON.parse(stored);
} catch (e) {
console.error('Failed to parse keep request info', e);
}
}
};
const saveKeepRequestInfo = () => {
localStorage.setItem(KEEP_REQUEST_INFO_KEY, JSON.stringify(keepRequestInfo.value));
};
const loadCallHistory = () => {
const stored = localStorage.getItem(HISTORY_STORAGE_KEY);
if (stored) {
try {
callHistory.value = JSON.parse(stored);
} catch (e) {
console.error('Failed to parse call history', e);
}
}
};
const saveCallHistory = () => {
localStorage.setItem(HISTORY_STORAGE_KEY, JSON.stringify(callHistory.value));
};
const addCallHistory = (item: Omit<CallHistoryItem, 'id'>) => {
// Deep clone inputs to prevent reference sharing
const clonedItem = {
...item,
inputs: item.inputs !== undefined ? JSON.parse(JSON.stringify(item.inputs)) : item.inputs,
};
// If keepRequestInfo is false, strip sensitive or large input data
const processedItem = { ...clonedItem };
if (!keepRequestInfo.value) {
// Keep only essential metadata about inputs, not the full data
if (processedItem.inputs && typeof processedItem.inputs === 'object') {
const inputs = processedItem.inputs as Record<string, unknown>;
// Create a minimal representation showing input structure without values
const minimalInputs: Record<string, unknown> = {};
for (const key in inputs) {
if (Object.prototype.hasOwnProperty.call(inputs, key)) {
const value = inputs[key];
if (value === null || value === undefined) {
minimalInputs[key] = value;
} else if (Array.isArray(value)) {
minimalInputs[key] = `[Array of ${value.length} items]`;
} else if (typeof value === 'object') {
minimalInputs[key] = `{Object with ${Object.keys(value).length} keys}`;
} else if (typeof value === 'string' && value.length > 50) {
minimalInputs[key] = `${value.substring(0, 47)}...`;
} else {
minimalInputs[key] = value;
}
}
}
processedItem.inputs = minimalInputs;
}
}
const newItem = {
...processedItem,
id: Date.now().toString() + Math.random().toString(36).substring(2),
};
callHistory.value.unshift(newItem);
if (callHistory.value.length > MAX_HISTORY_ITEMS) {
callHistory.value = callHistory.value.slice(0, MAX_HISTORY_ITEMS);
}
saveCallHistory();
};
const clearCallHistory = () => {
callHistory.value = [];
saveCallHistory();
};
// Helper to get color for a status code
const getStatusColor = (statusCode: number): string => {
if (statusCode >= 200 && statusCode < 300) return 'positive';
if (statusCode >= 300 && statusCode < 400) return 'info';
if (statusCode >= 400 && statusCode < 500) return 'warning';
if (statusCode >= 500) return 'negative';
return 'grey';
};
const truncateError = (error: string): string => {
if (error.length <= 50) return error;
return error.substring(0, 47) + '...';
};
const selectHistoryItem = (item: CallHistoryItem) => {
// For now, just log the selection
console.log('Selected history item:', item);
// Could implement functionality to load the component and inputs
};
const reproduceHistoryItem = async (item: CallHistoryItem) => {
// Find the component by code
const component = availableComponents.value.find(c => c.code === item.componentCode);
if (!component) {
console.warn('Component not found:', item.componentCode);
return;
}
// Switch to that component
selectedComponent.value = component.code;
// Wait for component to be loaded (next tick) then set inputs
await nextTick();
if (componentRef.value && componentRef.value.inputs) {
// inputs is a Ref<TIn> from runtime.createExecutor
const inputsRef = componentRef.value.inputs as { value: unknown };
if (typeof inputsRef === 'object' && inputsRef !== null && 'value' in inputsRef) {
inputsRef.value = item.inputs;
} else {
// Fallback: assign directly (might not be reactive)
componentRef.value.inputs = item.inputs;
}
console.log('Inputs reproduced for', item.componentName);
}
};
const executeComponent = async () => {
if (!componentRef.value?.execute) return;
const startTime = Date.now();
try {
await componentRef.value.execute('Execute');
const duration = componentRef.value.duration ?? Date.now() - startTime;
const statusCode = componentRef.value.statusCode;
// Get the actual inputs value (not the Ref)
const inputsValue = componentRef.value.inputs && typeof componentRef.value.inputs === 'object' && 'value' in componentRef.value.inputs
? (componentRef.value.inputs as { value: unknown }).value
: componentRef.value.inputs;
const historyItem: Omit<CallHistoryItem, 'id'> = {
componentCode: currentComponentInfo.value?.code ?? '',
componentName: currentComponentInfo.value?.name ?? '',
timestamp: Date.now(),
inputs: inputsValue,
duration,
statusCode: statusCode,
error: componentRef.value.error,
};
// Omit error property when undefined to satisfy exactOptionalPropertyTypes
addCallHistory(historyItem);
} catch (err) {
const duration = componentRef.value.duration ?? Date.now() - startTime;
// Get the actual inputs value (not the Ref)
const inputsValue = componentRef.value.inputs && typeof componentRef.value.inputs === 'object' && 'value' in componentRef.value.inputs
? (componentRef.value.inputs as { value: unknown }).value
: componentRef.value.inputs;
addCallHistory({
componentCode: currentComponentInfo.value?.code ?? '',
componentName: currentComponentInfo.value?.name ?? '',
timestamp: Date.now(),
inputs: inputsValue,
error: err instanceof Error ? err.message : String(err),
duration,
});
}
};
const isRunning = computed(() => componentRef.value?.running ?? false);
// Map API component to ComponentInfo
const mapApiComponent = (apiComponent: ApiComponent): ComponentInfo => {
// Extract short code from full code (e.g., "basics.calculator" -> "calculator")
const shortCode = apiComponent.code.split('.').pop() || apiComponent.code;
// Determine which Vue component to load based on gui field
let componentImport;
switch (apiComponent.gui) {
case 'ComponentCalculator.vue':
componentImport = () => import('../components_s8n/ComponentCalculator.vue');
break;
case 'ComponentHttpRequest.vue':
componentImport = () => import('../components_s8n/ComponentHttpRequest.vue');
break;
default:
// Fallback to generic component if needed
componentImport = () => import('../components_s8n/ComponentCalculator.vue');
}
return {
code: shortCode,
name: apiComponent.name,
description: apiComponent.description,
icon: apiComponent.icon,
className: apiComponent.class,
method: apiComponent.methods[0] || 'Execute',
tags: apiComponent.tags || [],
component: defineAsyncComponent(componentImport as any), // eslint-disable-line @typescript-eslint/no-explicit-any
};
};
// Fetch components from backend
const fetchComponents = async () => {
loading.value = true;
error.value = null;
try {
const response = await fetch('/api/components');
if (!response.ok) {
throw new Error(`Failed to fetch components: ${response.statusText}`);
}
const data: ApiComponent[] = await response.json();
availableComponents.value = data.map(mapApiComponent);
// Auto-select first component if none selected
if (availableComponents.value.length > 0 && !selectedComponent.value) {
selectedComponent.value = availableComponents.value[0]!.code;
}
} catch (err) {
error.value = err instanceof Error ? err.message : 'Unknown error';
console.error('Failed to load components:', err);
} finally {
loading.value = false;
}
};
onMounted(() => {
void fetchComponents();
loadCallHistory();
loadKeepRequestInfo();
});
// Watch keepRequestInfo to save changes
watch(keepRequestInfo, () => {
saveKeepRequestInfo();
});
const currentComponentInfo = computed(() => {
return availableComponents.find(c => c.code === selectedComponent.value);
return availableComponents.value.find(c => c.code === selectedComponent.value);
});
const filteredComponents = computed(() => {
const query = searchQuery.value.trim().toLowerCase();
if (!query) return availableComponents.value;
return availableComponents.value.filter(comp => {
// Search in name, description, tags
const nameMatch = comp.name.toLowerCase().includes(query);
const descMatch = comp.description.toLowerCase().includes(query);
const tagsMatch = comp.tags.some(tag => tag.toLowerCase().includes(query));
return nameMatch || descMatch || tagsMatch;
});
});
const currentComponentComponent = computed(() => {

View File

@@ -0,0 +1,53 @@
import type { CallHistoryItem } from '../pages/PlaygroundPage.vue';
export interface CallHistoryStorage {
load(): CallHistoryItem[];
save(items: CallHistoryItem[]): void;
clear(): void;
}
const STORAGE_KEY = 's8n_call_history';
export class LocalStorageCallHistory implements CallHistoryStorage {
load(): CallHistoryItem[] {
const stored = localStorage.getItem(STORAGE_KEY);
if (!stored) return [];
try {
return JSON.parse(stored);
} catch (e) {
console.error('Failed to parse call history from localStorage', e);
return [];
}
}
save(items: CallHistoryItem[]): void {
localStorage.setItem(STORAGE_KEY, JSON.stringify(items));
}
clear(): void {
localStorage.removeItem(STORAGE_KEY);
}
}
// For future backend storage
export class BackendCallHistory implements CallHistoryStorage {
// TODO: Implement API calls
load(): CallHistoryItem[] {
console.warn('Backend storage not implemented yet, returning empty array');
return [];
}
save(items: CallHistoryItem[]): void {
console.warn('Backend storage not implemented yet, not saving', items.length);
}
clear(): void {
console.warn('Backend storage not implemented yet, not clearing');
}
}
// Factory to switch between storage implementations
export function createCallHistoryStorage(): CallHistoryStorage {
// For now, use localStorage. In the future, we can check environment or config
return new LocalStorageCallHistory();
}