tr]:last:border-b-0", className)}
+ {...props} />
+))
+TableFooter.displayName = "TableFooter"
+
+const TableRow = React.forwardRef(({ className, ...props }, ref) => (
+
+))
+TableRow.displayName = "TableRow"
+
+const TableHead = React.forwardRef(({ className, ...props }, ref) => (
+ [role=checkbox]]:translate-y-[2px]",
+ className
+ )}
+ {...props} />
+))
+TableHead.displayName = "TableHead"
+
+const TableCell = React.forwardRef(({ className, ...props }, ref) => (
+ | [role=checkbox]]:translate-y-[2px]",
+ className
+ )}
+ {...props} />
+))
+TableCell.displayName = "TableCell"
+
+const TableCaption = React.forwardRef(({ className, ...props }, ref) => (
+
+))
+TableCaption.displayName = "TableCaption"
+
+export {
+ Table,
+ TableHeader,
+ TableBody,
+ TableFooter,
+ TableHead,
+ TableRow,
+ TableCell,
+ TableCaption,
+}
diff --git a/TestMasterUI/src/components/ui/tabs.jsx b/TestMasterUI/src/components/ui/tabs.jsx
new file mode 100644
index 0000000..b674eb9
--- /dev/null
+++ b/TestMasterUI/src/components/ui/tabs.jsx
@@ -0,0 +1,41 @@
+import * as React from "react"
+import * as TabsPrimitive from "@radix-ui/react-tabs"
+
+import { cn } from "@/lib/utils"
+
+const Tabs = TabsPrimitive.Root
+
+const TabsList = React.forwardRef(({ className, ...props }, ref) => (
+
+))
+TabsList.displayName = TabsPrimitive.List.displayName
+
+const TabsTrigger = React.forwardRef(({ className, ...props }, ref) => (
+
+))
+TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
+
+const TabsContent = React.forwardRef(({ className, ...props }, ref) => (
+
+))
+TabsContent.displayName = TabsPrimitive.Content.displayName
+
+export { Tabs, TabsList, TabsTrigger, TabsContent }
diff --git a/TestMasterUI/src/components/ui/tooltip.jsx b/TestMasterUI/src/components/ui/tooltip.jsx
new file mode 100644
index 0000000..e24da6b
--- /dev/null
+++ b/TestMasterUI/src/components/ui/tooltip.jsx
@@ -0,0 +1,26 @@
+import * as React from "react"
+import * as TooltipPrimitive from "@radix-ui/react-tooltip"
+
+import { cn } from "@/lib/utils"
+
+const TooltipProvider = TooltipPrimitive.Provider
+
+const Tooltip = TooltipPrimitive.Root
+
+const TooltipTrigger = TooltipPrimitive.Trigger
+
+const TooltipContent = React.forwardRef(({ className, sideOffset = 4, ...props }, ref) => (
+
+
+
+))
+TooltipContent.displayName = TooltipPrimitive.Content.displayName
+
+export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
diff --git a/TestMasterUI/src/config/monaco.js b/TestMasterUI/src/config/monaco.js
new file mode 100644
index 0000000..706d199
--- /dev/null
+++ b/TestMasterUI/src/config/monaco.js
@@ -0,0 +1,18 @@
+export const monacoConfig = {
+ getWorkerUrl: function(moduleId, label) {
+ const contextPath = document.querySelector('meta[name="context-path"]')?.getAttribute('content') || '/';
+ const basePath = contextPath.endsWith('/') ? contextPath : `${contextPath}/`;
+
+ const workers = {
+ json: 'json.worker.bundle.js',
+ css: 'css.worker.bundle.js',
+ html: 'html.worker.bundle.js',
+ typescript: 'ts.worker.bundle.js',
+ javascript: 'ts.worker.bundle.js',
+ default: 'editor.worker.bundle.js'
+ };
+
+ const workerPath = workers[label] || workers.default;
+ return `${basePath}plugins/apitestmanager/${workerPath}`;
+ }
+};
diff --git a/TestMasterUI/src/contexts/APIContext.js b/TestMasterUI/src/contexts/APIContext.js
new file mode 100644
index 0000000..e69de29
diff --git a/TestMasterUI/src/contexts/LoadTestContext.js b/TestMasterUI/src/contexts/LoadTestContext.js
new file mode 100644
index 0000000..e69de29
diff --git a/TestMasterUI/src/hooks/use-mobile.jsx b/TestMasterUI/src/hooks/use-mobile.jsx
new file mode 100644
index 0000000..9c47a0d
--- /dev/null
+++ b/TestMasterUI/src/hooks/use-mobile.jsx
@@ -0,0 +1,19 @@
+import * as React from "react"
+
+const MOBILE_BREAKPOINT = 768
+
+export function useIsMobile() {
+ const [isMobile, setIsMobile] = React.useState(undefined)
+
+ React.useEffect(() => {
+ const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
+ const onChange = () => {
+ setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
+ }
+ mql.addEventListener("change", onChange)
+ setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
+ return () => mql.removeEventListener("change", onChange);
+ }, [])
+
+ return !!isMobile
+}
diff --git a/TestMasterUI/src/hooks/useAPITester.js b/TestMasterUI/src/hooks/useAPITester.js
new file mode 100644
index 0000000..e69de29
diff --git a/TestMasterUI/src/hooks/useLoadTester.js b/TestMasterUI/src/hooks/useLoadTester.js
new file mode 100644
index 0000000..e69de29
diff --git a/TestMasterUI/src/hooks/useMonaco.js b/TestMasterUI/src/hooks/useMonaco.js
new file mode 100644
index 0000000..445cc74
--- /dev/null
+++ b/TestMasterUI/src/hooks/useMonaco.js
@@ -0,0 +1,68 @@
+import { useEffect, useRef, useState } from 'react';
+import * as monaco from 'monaco-editor';
+
+export const useMonaco = ({
+ defaultValue = '',
+ language = 'javascript',
+ readOnly = false,
+ automaticLayout = true,
+ quickSuggestions = false
+}) => {
+ const [editor, setEditor] = useState(null);
+ const containerRef = useRef(null);
+ const [value, setValue] = useState(defaultValue);
+
+ useEffect(() => {
+ if (!containerRef.current) return;
+
+ const instance = monaco.editor.create(containerRef.current, {
+ value: defaultValue,
+ language,
+ automaticLayout,
+ quickSuggestions,
+ readOnly,
+ minimap: {
+ enabled: false
+ },
+ scrollBeyondLastLine: false,
+ theme: 'vs-dark'
+ });
+
+ setEditor(instance);
+
+ instance.onDidChangeModelContent(() => {
+ setValue(instance.getValue());
+ });
+
+ const resizeHandler = () => {
+ // Monaco editor sometimes needs a manual resize trigger
+ instance.layout({
+ width: 0,
+ height: 0
+ });
+ instance.layout();
+ };
+
+ window.addEventListener('resize', resizeHandler);
+
+ return () => {
+ window.removeEventListener('resize', resizeHandler);
+ instance.dispose();
+ };
+ }, [defaultValue, language, automaticLayout, quickSuggestions, readOnly]);
+
+ // Sync external value changes
+ useEffect(() => {
+ if (editor && defaultValue !== value) {
+ editor.setValue(defaultValue);
+ }
+ }, [defaultValue, editor]);
+
+ return {
+ editor,
+ containerRef,
+ value
+ };
+};
+
+export default useMonaco;
diff --git a/TestMasterUI/src/hooks/useScenario.js b/TestMasterUI/src/hooks/useScenario.js
new file mode 100644
index 0000000..e69de29
diff --git a/TestMasterUI/src/index.css b/TestMasterUI/src/index.css
new file mode 100644
index 0000000..f124e40
--- /dev/null
+++ b/TestMasterUI/src/index.css
@@ -0,0 +1,171 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+/* ... */
+
+:root {
+ font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
+ line-height: 1.5;
+ font-weight: 400;
+
+ color-scheme: light dark;
+ color: rgba(255, 255, 255, 0.87);
+ background-color: #242424;
+
+ font-synthesis: none;
+ text-rendering: optimizeLegibility;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+a {
+ font-weight: 500;
+ color: #646cff;
+ text-decoration: inherit;
+}
+a:hover {
+ color: #535bf2;
+}
+
+body {
+ margin: 0;
+ display: flex;
+ place-items: center;
+ min-width: 320px;
+ min-height: 100vh;
+}
+
+h1 {
+ font-size: 3.2em;
+ line-height: 1.1;
+}
+
+button {
+ border-radius: 8px;
+ border: 1px solid transparent;
+ padding: 0.6em 1.2em;
+ font-size: 1em;
+ font-weight: 500;
+ font-family: inherit;
+ background-color: #1a1a1a;
+ cursor: pointer;
+ transition: border-color 0.25s;
+}
+button:hover {
+ border-color: #646cff;
+}
+button:focus,
+button:focus-visible {
+ outline: 4px auto -webkit-focus-ring-color;
+}
+
+@media (prefers-color-scheme: light) {
+ :root {
+ color: #213547;
+ background-color: #ffffff;
+ }
+ a:hover {
+ color: #747bff;
+ }
+ button {
+ background-color: #f9f9f9;
+ }
+}
+
+@layer base {
+ :root {
+ --background: 0 0% 100%;
+ --foreground: 240 10% 3.9%;
+ --card: 0 0% 100%;
+ --card-foreground: 240 10% 3.9%;
+ --popover: 0 0% 100%;
+ --popover-foreground: 240 10% 3.9%;
+ --primary: 240 5.9% 10%;
+ --primary-foreground: 0 0% 98%;
+ --secondary: 240 4.8% 95.9%;
+ --secondary-foreground: 240 5.9% 10%;
+ --muted: 240 4.8% 95.9%;
+ --muted-foreground: 240 3.8% 46.1%;
+ --accent: 240 4.8% 95.9%;
+ --accent-foreground: 240 5.9% 10%;
+ --destructive: 0 84.2% 60.2%;
+ --destructive-foreground: 0 0% 98%;
+ --border: 240 5.9% 90%;
+ --input: 240 5.9% 90%;
+ --ring: 240 10% 3.9%;
+ --chart-1: 12 76% 61%;
+ --chart-2: 173 58% 39%;
+ --chart-3: 197 37% 24%;
+ --chart-4: 43 74% 66%;
+ --chart-5: 27 87% 67%;
+ --radius: 0.5rem;
+ --sidebar-background: 0 0% 98%;
+ --sidebar-foreground: 240 5.3% 26.1%;
+ --sidebar-primary: 240 5.9% 10%;
+ --sidebar-primary-foreground: 0 0% 98%;
+ --sidebar-accent: 240 4.8% 95.9%;
+ --sidebar-accent-foreground: 240 5.9% 10%;
+ --sidebar-border: 220 13% 91%;
+ --sidebar-ring: 217.2 91.2% 59.8%;
+ }
+ .dark {
+ --background: 240 10% 3.9%;
+ --foreground: 0 0% 98%;
+ --card: 240 10% 3.9%;
+ --card-foreground: 0 0% 98%;
+ --popover: 240 10% 3.9%;
+ --popover-foreground: 0 0% 98%;
+ --primary: 0 0% 98%;
+ --primary-foreground: 240 5.9% 10%;
+ --secondary: 240 3.7% 15.9%;
+ --secondary-foreground: 0 0% 98%;
+ --muted: 240 3.7% 15.9%;
+ --muted-foreground: 240 5% 64.9%;
+ --accent: 240 3.7% 15.9%;
+ --accent-foreground: 0 0% 98%;
+ --destructive: 0 62.8% 30.6%;
+ --destructive-foreground: 0 0% 98%;
+ --border: 240 3.7% 15.9%;
+ --input: 240 3.7% 15.9%;
+ --ring: 240 4.9% 83.9%;
+ --chart-1: 220 70% 50%;
+ --chart-2: 160 60% 45%;
+ --chart-3: 30 80% 55%;
+ --chart-4: 280 65% 60%;
+ --chart-5: 340 75% 55%;
+ --sidebar-background: 240 5.9% 10%;
+ --sidebar-foreground: 240 4.8% 95.9%;
+ --sidebar-primary: 224.3 76.3% 48%;
+ --sidebar-primary-foreground: 0 0% 100%;
+ --sidebar-accent: 240 3.7% 15.9%;
+ --sidebar-accent-foreground: 240 4.8% 95.9%;
+ --sidebar-border: 240 3.7% 15.9%;
+ --sidebar-ring: 217.2 91.2% 59.8%;
+ }
+}
+
+@layer base {
+ * {
+ @apply border-border;
+ }
+ body {
+ @apply bg-background text-foreground;
+ }
+}
+
+.sidebar-menu-action {
+ padding: 0; /* 내부 여백 제거 */
+ margin: 0; /* 외부 여백 제거 */
+ height: auto; /* 높이 자동화 */
+ display: flex;
+ align-items: center; /* 세로 중앙 정렬 */
+ justify-content: center; /* 가로 중앙 정렬 */
+}
+
+.sidebar-menu-sub-button {
+ flex: 1; /* 남은 공간을 채움 */
+ display: flex;
+ justify-content: start; /* 왼쪽 정렬 */
+ gap: 0.5rem; /* 아이콘과 텍스트 간 여백 */
+}
diff --git a/TestMasterUI/src/lib/ApiLayoutItemTreeBuilder.js b/TestMasterUI/src/lib/ApiLayoutItemTreeBuilder.js
new file mode 100644
index 0000000..21b9d62
--- /dev/null
+++ b/TestMasterUI/src/lib/ApiLayoutItemTreeBuilder.js
@@ -0,0 +1,200 @@
+class ApiLayoutItemTreeBuilder {
+ static createTree(layoutItems) {
+ layoutItems.sort((a, b) => a.loutItemSerno - b.loutItemSerno);
+ const rootId = layoutItems.length === 0 ? 0 : parseInt(layoutItems[0].parent);
+ const root = {id: rootId, children: [], loutItemSerno: 0, loutItemName: 'Root', loutItemDesc: 'Root', loutItemType: '', loutItemDepth: 0, parent: null};
+
+ const nodeMap = new Map();
+ nodeMap.set(rootId, root);
+
+ for (const item of layoutItems) {
+ if (['Grid', 'Group'].includes(item.loutItemType)) {
+ if (item.loutItemOccCnt !== null) {
+ if (item.loutItemOccCnt === '*') {
+ let refItem = layoutItems.find(p => p.loutItemName === item.loutItemOccRef);
+ if (refItem) {
+ item.childOccCnt = parseInt(refItem.value, 10);
+ }
+ } else {
+ item.childOccCnt = parseInt(item.loutItemOccCnt, 10);
+ }
+ }
+ }
+
+ if (nodeMap.has(parseInt(item.parent))) {
+ nodeMap.get(parseInt(item.parent)).children.push(item);
+ // console.log(item.id + ' to ' + item.parent);
+ } else {
+ console.log('Parent not found: ' + item.parent);
+ }
+
+ nodeMap.set(item.id, item);
+ }
+
+ return root;
+ }
+
+ static moveNode(root, node, moveUp) {
+ const parent = this.getParentNode(root, node);
+ if (parent === null) {
+ // Node is the root node, cannot be moved
+ return;
+ }
+
+ const siblings = parent.children;
+ const currentIndex = siblings.indexOf(node);
+ if (currentIndex === -1) {
+ // Node not found in parent's children list
+ return;
+ }
+
+ const newIndex = moveUp ? currentIndex - 1 : currentIndex + 1;
+ if (newIndex >= 0 && newIndex < siblings.length) {
+ // Swap the positions of the current node and the node at the new index
+ [siblings[currentIndex], siblings[newIndex]] = [siblings[newIndex], siblings[currentIndex]];
+ }
+ }
+
+ static getParentNode(root, node) {
+ if (node.parent === null) {
+ return null; // Node is the root node
+ }
+
+ return this.getNodeById(root, node.parent);
+ }
+
+ static getNodeIndex(parent, node) {
+ return parent.children.indexOf(node);
+ }
+
+ static getNodeById(node, id) {
+ if (node.id == id) {
+ return node;
+ }
+
+ for (const child of node.children) {
+ const found = this.getNodeById(child, id);
+ if (found !== null) {
+ return found;
+ }
+ }
+
+ return null;
+ }
+
+ static findParent(root, node) {
+ if (root === null || node === null) {
+ return null; // Invalid input
+ }
+
+ if (root.children.includes(node)) {
+ return root; // The root is the parent of the node
+ }
+
+ for (const child of root.children) {
+ const parent = this.findParent(child, node);
+ if (parent !== null) {
+ return parent;
+ }
+ }
+
+ return null; // Node not found in the tree
+ }
+
+ static printTree(root) {
+ console.log('====================================');
+ this.printNode(root, 0);
+ console.log('====================================');
+ }
+
+ static printNode(node, level) {
+ if (node === null) {
+ return;
+ }
+
+ // Print indentation based on the level
+ let indent = '';
+ for (let i = 0; i < level; i++) {
+ indent += ' ';
+ }
+
+ // Print the node information
+ console.log(`${indent}${node.id}: ${node.loutName}[${node.loutItemType}, Level: ${node.loutItemDepth}, Serno: ${node.loutItemSerno}]`);
+
+ // Recursively print the children
+ if (node.children.length > 0) {
+ for (const child of node.children) {
+ this.printNode(child, level + 1);
+ }
+ }
+ }
+
+ static moveNodeDepth(root, node, newDepth) {
+ if (node === null || root === null || newDepth < 0) {
+ // Invalid input
+ return;
+ }
+
+ const parent = this.getParentNode(root, node);
+ if (parent === null) {
+ // Node is the root node, cannot be moved to a different level
+ return;
+ }
+
+ const currentDepth = node.loutItemDepth;
+ const nodeIndex = this.getNodeIndex(parent, node);
+ const nextSibling = this.findClosestSibling(parent.children, nodeIndex);
+ parent.children.splice(nodeIndex, 1);
+
+ if (newDepth > currentDepth) {
+ // Moving to a higher depth
+ if (nextSibling !== null) {
+ nextSibling.children.push(node);
+ node.parent = nextSibling.id;
+ node.loutItemDepth = newDepth;
+ this.adjustChildrenDepth(node, newDepth + 1);
+ } else {
+ // No next sibling found, add the node back to the original parent
+ parent.children.push(node);
+ }
+ } else {
+ // Moving to a lower depth
+ const newParent = this.findParent(root, parent);
+ if (newParent !== null) {
+ newParent.children.push(node);
+ node.parent = newParent.id;
+ node.loutItemDepth = newDepth;
+ this.adjustChildrenDepth(node, newDepth + 1);
+ } else {
+ // No parent found at the desired level, add the node back to the original parent
+ parent.children.push(node);
+ }
+ }
+ }
+
+ static adjustChildrenDepth(node, depth) {
+ for (const child of node.children) {
+ child.loutItemDepth = depth;
+ this.adjustChildrenDepth(child, depth + 1);
+ }
+ }
+
+ static findNextSibling(siblings) {
+ if (siblings === null || siblings.length === 0) {
+ return null;
+ }
+ return siblings[0];
+ }
+
+ static findClosestSibling(siblings, nodeIndex) {
+ if (siblings === null || siblings.length === 0) {
+ return null;
+ }
+ if (nodeIndex < 1 || nodeIndex >= siblings.length) {
+ return this.findNextSibling(siblings);
+ }
+ return siblings[nodeIndex - 1];
+ }
+}
+
+export default ApiLayoutItemTreeBuilder;
diff --git a/TestMasterUI/src/lib/ApiLayoutItemTreeDataBuilder.js b/TestMasterUI/src/lib/ApiLayoutItemTreeDataBuilder.js
new file mode 100644
index 0000000..3dec408
--- /dev/null
+++ b/TestMasterUI/src/lib/ApiLayoutItemTreeDataBuilder.js
@@ -0,0 +1,51 @@
+class ApiLayoutItemTreeDataBuilder {
+ constructor(startId = 10000) {
+ this.idCounter = this.generateId(startId);
+ this.nodeMap = new Map();
+ }
+
+ * generateId(start) {
+ let id = start;
+ while (true) {
+ yield id++;
+ }
+ }
+
+ cloneNode(node, parent) {
+
+ // Create a shallow copy of the node, except for the children array
+ let newNode = {
+ ...node,
+ id: this.idCounter.next().value, // Update with a new unique ID
+ parent: parent ? parent.id : undefined // Set the new parent's ID if parent exists
+ };
+
+ // Remove children property to handle it separately
+ delete newNode.children;
+ this.nodeMap.set(newNode.id, newNode);
+ return newNode;
+ }
+
+ replicateTree(node, parent = null) {
+ const newNode = this.cloneNode(node, parent);
+ newNode.children = []; // Initialize children array
+ if (node.children && node.children.length > 0) {
+ node.children.forEach(child => {
+ const childCount = node.childOccCnt || 1;
+ for (let i = 0; i < childCount; i++) {
+ const childReplicas = this.replicateTree(child, newNode);
+ newNode.children = newNode.children.concat(childReplicas);
+ }
+ });
+ }
+
+ return [newNode]; // Return an array containing only the new singular parent node with replicated children
+ }
+
+ createReplicatedTree(originalTree) {
+ // console.log({originalTree});
+ return this.replicateTree(originalTree)[0]; // Return the first element to maintain the tree structure
+ }
+}
+
+export default ApiLayoutItemTreeDataBuilder;
diff --git a/TestMasterUI/src/lib/api-client.js b/TestMasterUI/src/lib/api-client.js
new file mode 100644
index 0000000..8ce1a85
--- /dev/null
+++ b/TestMasterUI/src/lib/api-client.js
@@ -0,0 +1,272 @@
+export const fillPadding = async (apiRequest, value, dataLength) => {
+ let paddedValue = value; // In real app, this would be processed by APIClient
+ while (paddedValue.length < dataLength) {
+ paddedValue = ' ' + paddedValue;
+ }
+ return paddedValue;
+};
+
+class APIClient {
+ constructor() {
+ // Initialize global variables store
+ window.globals = window.globals || {};
+ this.abortController = null;
+ this.contextPath = document.querySelector('meta[name="context-path"]')?.getAttribute('content') || '/';
+
+ if (!this.contextPath.endsWith('/')) {
+ this.contextPath += '/';
+ }
+ }
+
+ getClientUrl() {
+ return `${this.contextPath}mgmt/api/test.do`;
+ }
+
+ // Format response for consistency
+ formatResponse(data) {
+ return {
+ body: data.body,
+ status: data.status,
+ headers: data.headers.reduce((acc, header) => {
+ acc[header.key] = header.value;
+ return acc;
+ }, {}),
+ size: data.size
+ };
+ }
+
+ // Abort ongoing requests
+ abort() {
+ if (this.abortController) {
+ this.abortController.abort();
+ this.abortController = null;
+ }
+ }
+
+ // Make the actual API request
+ async makeAjaxRequest(processedRequest) {
+ this.abortController = new AbortController();
+
+ try {
+ const response = await fetch(this.getClientUrl(), {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-XSRF-TOKEN': document.querySelector('meta[name="_csrf"]')?.getAttribute('content')
+ },
+ body: JSON.stringify(processedRequest),
+ signal: this.abortController.signal
+ });
+
+ if (!response.ok) {
+ throw new Error('Network request failed');
+ }
+
+ return response;
+
+ } catch (error) {
+ if (error.name === 'AbortError') {
+ // Handle abort silently
+ return null;
+ }
+ throw error;
+ }
+ }
+
+ // Process pre-script and variables
+ async processPreScriptAndVariables(model, value, consoleOutput) {
+ const variables = {};
+ const updatedModel = await this.executePreRequestScript(model, variables, consoleOutput);
+ return this.replacePlaceholderValueWithVariables(updatedModel, value, variables);
+ }
+
+ // Replace placeholder values with actual variables
+ replacePlaceholderValueWithVariables(model, originalValue, variables = {}) {
+ const mergedVariables = {
+ ...window.globals,
+ ...variables
+ };
+
+ return Object.entries(mergedVariables).reduce((value, [key, replacement]) => {
+ const placeholder = `{{${key}}}`;
+ return value.replace(new RegExp(placeholder, 'g'), replacement);
+ }, originalValue);
+ }
+
+ // Main method to send API requests
+ async sendAPIRequest(model, preProcessCallback, consoleOutput) {
+ try {
+ // Execute pre-request script and get variables
+ const variables = {};
+ const updatedModel = await this.executePreRequestScript(model, variables, consoleOutput);
+
+ // Process request with variables
+ const processedRequest = this.replacePlaceholdersWithVariables(updatedModel, variables);
+
+ // Allow pre-processing if callback provided
+ if (preProcessCallback) {
+ preProcessCallback(processedRequest);
+ }
+
+ // Make the request
+ const response = await this.makeAjaxRequest(processedRequest, consoleOutput);
+ if (!response) return null;
+
+ const responseData = await response.json();
+
+ if (!response.ok) {
+ throw new Error(responseData.error || 'Unknown error');
+ }
+
+ // Format response
+ const finalResponse = this.formatResponse(responseData);
+
+ // Execute post-request script
+ return this.executePostRequestScript(
+ model.postRequestScript,
+ variables,
+ processedRequest,
+ finalResponse,
+ consoleOutput
+ ).catch(error => {
+ console.error('Post-request script error:', error);
+ return finalResponse;
+ });
+
+ } catch (error) {
+ throw new Error(`API 호출 중 오류가 발생했습니다. 사유[${error.message}]`);
+ }
+ }
+
+ // Replace placeholders in request with variables
+ replacePlaceholdersWithVariables(original, variables = {}) {
+ const request = JSON.parse(JSON.stringify(original));
+ const mergedVariables = {
+ ...window.globals,
+ ...variables
+ };
+
+ Object.entries(mergedVariables).forEach(([key, value]) => {
+ const placeholder = new RegExp(`{{${key}}}`, 'g');
+
+ // Replace in path
+ request.path = request.path.replace(placeholder, value);
+
+ // Replace in request body
+ if (request.requestBody) {
+ request.requestBody = request.requestBody.replace(placeholder, value);
+ }
+
+ // Replace in headers
+ if (request.headers?.length) {
+ request.headers = JSON.parse(
+ JSON.stringify(request.headers).replace(placeholder, value)
+ );
+ }
+
+ // Replace in query params
+ if (request.queryParams?.length) {
+ request.queryParams = JSON.parse(
+ JSON.stringify(request.queryParams).replace(placeholder, value)
+ );
+ }
+ });
+
+ return request;
+ }
+
+ // Execute pre-request script in an iframe
+ executePreRequestScript(model, variables, consoleOutput) {
+ return new Promise((resolve, reject) => {
+ const resultFrame = document.getElementById('preRequest');
+ if (!resultFrame) {
+ console.warn('preRequest iframe not found');
+ resolve(model);
+ return;
+ }
+
+ window.preRequestComplete = (updatedModel, error) => {
+ if (error) {
+ reject(error);
+ } else {
+ resolve(updatedModel);
+ }
+ };
+
+ window.temp_variable = variables;
+ window.currentRequest = model;
+ window.consoleOutput = consoleOutput;
+
+ resultFrame.srcdoc = `
+
+ `;
+ });
+ }
+
+ // Execute post-request script in an iframe
+ executePostRequestScript(script, variables, request, response, consoleOutput) {
+ return new Promise((resolve, reject) => {
+ const resultFrame = document.getElementById('postRequest');
+ if (!resultFrame) {
+ console.warn('postRequest iframe not found');
+ resolve(response);
+ return;
+ }
+
+ window.request = request;
+ window.response = response;
+ window.temp_variable = variables;
+ window.postRequestComplete = (response, error) => {
+ if (error) {
+ reject(error);
+ } else {
+ resolve(response);
+ }
+ };
+ window.consoleOutput = consoleOutput;
+
+ resultFrame.srcdoc = `
+
+ `;
+ });
+ }
+}
+
+export default APIClient;
diff --git a/TestMasterUI/src/lib/utils.js b/TestMasterUI/src/lib/utils.js
new file mode 100644
index 0000000..b20bf01
--- /dev/null
+++ b/TestMasterUI/src/lib/utils.js
@@ -0,0 +1,6 @@
+import { clsx } from "clsx";
+import { twMerge } from "tailwind-merge"
+
+export function cn(...inputs) {
+ return twMerge(clsx(inputs));
+}
diff --git a/TestMasterUI/src/lib/utils/helpers.js b/TestMasterUI/src/lib/utils/helpers.js
new file mode 100644
index 0000000..e69de29
diff --git a/TestMasterUI/src/lib/utils/layout.js b/TestMasterUI/src/lib/utils/layout.js
new file mode 100644
index 0000000..e69de29
diff --git a/TestMasterUI/src/lib/utils/server-utils.js b/TestMasterUI/src/lib/utils/server-utils.js
new file mode 100644
index 0000000..6b6fb71
--- /dev/null
+++ b/TestMasterUI/src/lib/utils/server-utils.js
@@ -0,0 +1,18 @@
+export const basePath = (servers, serverId) => {
+ if (!serverId) return '';
+ return servers.find(server => server.id == serverId)?.basePath || '';
+};
+
+export const getServer = (servers, serverId) => {
+ return servers.find(server => server.id == serverId);
+};
+
+export const getHeader = (headers = [], key) => {
+ const header = headers.find(header => header.key === key);
+ if (!header) {
+ const newHeader = { key, value: '', enabled: true };
+ headers.push(newHeader);
+ return newHeader;
+ }
+ return header;
+};
diff --git a/TestMasterUI/src/main.jsx b/TestMasterUI/src/main.jsx
new file mode 100644
index 0000000..b9a1a6d
--- /dev/null
+++ b/TestMasterUI/src/main.jsx
@@ -0,0 +1,10 @@
+import { StrictMode } from 'react'
+import { createRoot } from 'react-dom/client'
+import './index.css'
+import App from './App.jsx'
+
+createRoot(document.getElementById('root')).render(
+
+
+ ,
+)
diff --git a/TestMasterUI/src/providers/APIProvider.jsx b/TestMasterUI/src/providers/APIProvider.jsx
new file mode 100644
index 0000000..937ebde
--- /dev/null
+++ b/TestMasterUI/src/providers/APIProvider.jsx
@@ -0,0 +1,259 @@
+import React, {createContext, useState, useContext, useCallback, useEffect} from 'react';
+import APIClient from '../lib/api-client';
+import {collectionService} from '@/services/collectionService';
+import {apiRequestService} from '@/services/apiRequestService';
+import {serverService} from '@/services/serverService';
+import {authService} from '@/services/authService';
+
+const APIContext = createContext(null);
+
+export const useAPI = () => {
+ const context = useContext(APIContext);
+ if (!context) {
+ throw new Error('useAPI must be used within an APIProvider');
+ }
+ return context;
+};
+
+export const APIProvider = ({
+ children,
+ contextPath = '/'
+}) => {
+ const [apiClient] = useState(() => new APIClient());
+ const [collections, setCollections] = useState([]);
+ const [apiRequests, setApiRequests] = useState([]);
+ const [servers, setServers] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const [isAuthenticated, setIsAuthenticated] = useState(false);
+
+ // Initialize services
+ const collections$ = collectionService(contextPath);
+ const apiRequests$ = apiRequestService(contextPath);
+ const servers$ = serverService();
+ const auth$ = authService();
+
+ const handleLogout = useCallback(async () => {
+ try {
+ setIsAuthenticated(false);
+ setCollections([]);
+ setApiRequests([]);
+ setServers([]);
+ return true;
+ } catch (err) {
+ console.error('Logout failed:', err);
+ return false;
+ }
+ }, []);
+
+ const loadServers = useCallback(async () => {
+ setLoading(true);
+ setError(null);
+ try {
+ const validatedServers = await servers$.loadServers();
+ setServers(validatedServers);
+ console.log({validatedServers});
+ return validatedServers;
+ } catch (err) {
+ setError(err.message);
+ console.log({err});
+ return [];
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ const getServer = useCallback((serverId) => {
+ const server = servers.find(server => server.id === serverId);
+ console.log(server);
+ return server;
+ }, [servers]);
+
+ const loadCollections = useCallback(async () => {
+ setLoading(true);
+ setError(null);
+ try {
+ const processedCollections = await collections$.loadCollections();
+ setCollections(processedCollections);
+ return processedCollections;
+ } catch (err) {
+ setError(err.message);
+ return [];
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ const createCollection = useCallback(async (name) => {
+ setLoading(true);
+ setError(null);
+ try {
+ await collections$.createCollection(name);
+ const updatedCollections = await collections$.loadCollections();
+ setCollections(updatedCollections);
+ } catch (err) {
+ setError(err.message);
+ return null;
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ const updateCollection = useCallback(async (id, name) => {
+ setLoading(true);
+ setError(null);
+ try {
+ await collections$.updateCollection(id, name);
+ setCollections(prev =>
+ prev.map(collection =>
+ collection.id === id ? {...collection, name} : collection
+ )
+ );
+ return true;
+ } catch (err) {
+ setError(err.message);
+ return false;
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ const deleteCollection = useCallback(async (id) => {
+ setLoading(true);
+ setError(null);
+ try {
+ await collections$.deleteCollection(id);
+ setCollections(prev => prev.filter(collection => collection.id !== id));
+ return true;
+ } catch (err) {
+ setError(err.message);
+ return false;
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ const createAPIRequest = useCallback(async (collectionId, apiData) => {
+ setLoading(true);
+ setError(null);
+ try {
+ const newApiRequest = await apiRequests$.createAPIRequest(collectionId, apiData);
+
+ setCollections(prev =>
+ prev.map(collection =>
+ collection.id === collectionId
+ ? {...collection, apis: [...(collection.apis || []), newApiRequest]}
+ : collection
+ )
+ );
+
+ setApiRequests(prev => [...prev, newApiRequest]);
+ return newApiRequest;
+ } catch (err) {
+ setError(err.message);
+ return null;
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ const updateAPIRequest = useCallback(async (apiRequest) => {
+ setLoading(true);
+ setError(null);
+ try {
+ await apiRequests$.updateAPIRequest(apiRequest);
+
+ setCollections(prev =>
+ prev.map(collection => {
+ if (collection.id === apiRequest.collectionId) {
+ return {
+ ...collection,
+ apis: collection.apis.map(api =>
+ api.id === apiRequest.id ? apiRequest : api
+ )
+ };
+ }
+ return collection;
+ })
+ );
+
+ setApiRequests(prev =>
+ prev.map(req => req.id === apiRequest.id ? apiRequest : req)
+ );
+
+ return apiRequest;
+ } catch (err) {
+ setError(err.message);
+ return null;
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ const deleteAPIRequest = useCallback(async (collectionId, apiId) => {
+ setLoading(true);
+ setError(null);
+ try {
+ await apiRequests$.deleteAPIRequest(collectionId, apiId);
+
+ setCollections(prev =>
+ prev.map(collection => {
+ if (collection.id === collectionId) {
+ return {
+ ...collection,
+ apis: collection.apis.filter(api => api.id !== apiId)
+ };
+ }
+ return collection;
+ })
+ );
+
+ setApiRequests(prev => prev.filter(req => req.id !== apiId));
+ return true;
+ } catch (err) {
+ setError(err.message);
+ return false;
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ const sendAPIRequest = useCallback(async (apiRequest, preProcessCallback, consoleOutput) => {
+ try {
+ return await apiClient.sendAPIRequest(apiRequest, preProcessCallback, consoleOutput);
+ } catch (err) {
+ setError(err.message);
+ return null;
+ }
+ }, [apiClient]);
+
+ const contextValue = {
+ apiClient,
+ collections,
+ apiRequests,
+ servers,
+ loadServers,
+ getServer,
+ loading,
+ error,
+ loadCollections,
+ createCollection,
+ updateCollection,
+ deleteCollection,
+ createAPIRequest,
+ updateAPIRequest,
+ deleteAPIRequest,
+ sendAPIRequest,
+ isAuthenticated,
+ setIsAuthenticated,
+ handleLogout
+ };
+
+ return (
+
+ {children}
+
+ );
+};
+
+export default APIProvider;
diff --git a/TestMasterUI/src/providers/LoadTestProvider.jsx b/TestMasterUI/src/providers/LoadTestProvider.jsx
new file mode 100644
index 0000000..8cb5941
--- /dev/null
+++ b/TestMasterUI/src/providers/LoadTestProvider.jsx
@@ -0,0 +1,230 @@
+import React, { createContext, useState, useContext, useCallback } from 'react';
+
+// Create the context
+const LoadTestContext = createContext();
+
+// Provider component
+export const LoadTestProvider = ({ children, contextPath = '/' }) => {
+ const [tests, setTests] = useState([]);
+ const [scenarios, setScenarios] = useState([]);
+ const [currentTest, setCurrentTest] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ // Load scenario list
+ const loadScenarioList = useCallback(async () => {
+ try {
+ const response = await fetch(`${contextPath}mgmt/api_scenario/list.do`, {
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-XSRF-TOKEN': document.querySelector('meta[name="_csrf"]')?.getAttribute('content')
+ }
+ });
+
+ if (!response.ok) throw new Error('Failed to load scenarios');
+
+ const data = await response.json();
+ setScenarios(data);
+ return data;
+ } catch (err) {
+ setError(err.message);
+ return [];
+ }
+ }, [contextPath]);
+
+ // Load test list
+ const loadTestList = useCallback(async () => {
+ setLoading(true);
+ try {
+ const response = await fetch(`${contextPath}mgmt/load_test/list.do`, {
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-XSRF-TOKEN': document.querySelector('meta[name="_csrf"]')?.getAttribute('content')
+ }
+ });
+
+ if (!response.ok) throw new Error('Failed to load tests');
+
+ const data = await response.json();
+ setTests(data);
+ return data;
+ } catch (err) {
+ setError(err.message);
+ return [];
+ } finally {
+ setLoading(false);
+ }
+ }, [contextPath]);
+
+ // Create a new test
+ const createTest = useCallback(async (testName) => {
+ setLoading(true);
+ try {
+ const response = await fetch(`${contextPath}mgmt/load_test/create.do`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-XSRF-TOKEN': document.querySelector('meta[name="_csrf"]')?.getAttribute('content')
+ },
+ body: JSON.stringify({ name: testName })
+ });
+
+ if (!response.ok) throw new Error('Failed to create test');
+
+ const newTest = await response.json();
+ await loadTestList();
+ return newTest;
+ } catch (err) {
+ setError(err.message);
+ return null;
+ } finally {
+ setLoading(false);
+ }
+ }, [contextPath, loadTestList]);
+
+ // Update a test
+ const updateTest = useCallback(async (test) => {
+ setLoading(true);
+ try {
+ const response = await fetch(`${contextPath}mgmt/load_test/update.do`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-XSRF-TOKEN': document.querySelector('meta[name="_csrf"]')?.getAttribute('content')
+ },
+ body: JSON.stringify(test)
+ });
+
+ if (!response.ok) throw new Error('Failed to update test');
+
+ // Update local state
+ setTests(prevTests =>
+ prevTests.map(t => t.id === test.id ? { ...t, ...test } : t)
+ );
+
+ return test;
+ } catch (err) {
+ setError(err.message);
+ return null;
+ } finally {
+ setLoading(false);
+ }
+ }, [contextPath]);
+
+ // Delete a test
+ const deleteTest = useCallback(async (testId) => {
+ setLoading(true);
+ try {
+ const response = await fetch(`${contextPath}mgmt/load_test/delete.do`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-XSRF-TOKEN': document.querySelector('meta[name="_csrf"]')?.getAttribute('content')
+ },
+ body: JSON.stringify({ id: testId })
+ });
+
+ if (!response.ok) throw new Error('Failed to delete test');
+
+ // Remove test from local state
+ setTests(prevTests => prevTests.filter(t => t.id !== testId));
+
+ // Clear current test if deleted
+ if (currentTest?.id === testId) {
+ setCurrentTest(null);
+ }
+
+ return true;
+ } catch (err) {
+ setError(err.message);
+ return false;
+ } finally {
+ setLoading(false);
+ }
+ }, [contextPath, currentTest]);
+
+ // Start a load test
+ const startTest = useCallback(async (test) => {
+ setLoading(true);
+ try {
+ const response = await fetch(`${contextPath}startLoadTest`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-XSRF-TOKEN': document.querySelector('meta[name="_csrf"]')?.getAttribute('content')
+ },
+ body: JSON.stringify(test)
+ });
+
+ if (!response.ok) throw new Error('Failed to start test');
+
+ return true;
+ } catch (err) {
+ setError(err.message);
+ return false;
+ } finally {
+ setLoading(false);
+ }
+ }, [contextPath]);
+
+ // Stop a load test
+ const stopTest = useCallback(async (test) => {
+ setLoading(true);
+ try {
+ const response = await fetch(`${contextPath}stopLoadTest`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-XSRF-TOKEN': document.querySelector('meta[name="_csrf"]')?.getAttribute('content')
+ },
+ body: JSON.stringify(test)
+ });
+
+ if (!response.ok) throw new Error('Failed to stop test');
+
+ return true;
+ } catch (err) {
+ setError(err.message);
+ return false;
+ } finally {
+ setLoading(false);
+ }
+ }, [contextPath]);
+
+ // Prepare context value
+ const contextValue = {
+ tests,
+ scenarios,
+ currentTest,
+ loading,
+ error,
+ setCurrentTest,
+ loadScenarioList,
+ loadTestList,
+ createTest,
+ updateTest,
+ deleteTest,
+ startTest,
+ stopTest,
+ setError
+ };
+
+ return (
+
+ {children}
+
+ );
+};
+
+// Custom hook to use the LoadTestContext
+export const useLoadTest = () => {
+ const context = useContext(LoadTestContext);
+
+ if (!context) {
+ throw new Error('useLoadTest must be used within a LoadTestProvider');
+ }
+
+ return context;
+};
+
+export default LoadTestProvider;
diff --git a/TestMasterUI/src/services/apiRequestService.js b/TestMasterUI/src/services/apiRequestService.js
new file mode 100644
index 0000000..26f2f03
--- /dev/null
+++ b/TestMasterUI/src/services/apiRequestService.js
@@ -0,0 +1,57 @@
+import {getCSRFToken} from '@/services/token.js';
+
+export const apiRequestService = (contextPath = '/') => {
+
+ const headers = {
+ 'Content-Type': 'application/json',
+ 'X-XSRF-TOKEN': getCSRFToken()
+ };
+
+ return {
+ async createAPIRequest(collectionId, apiData) {
+ const response = await fetch(`${contextPath}mgmt/collections/${collectionId}/apis/save.do`, {
+ method: 'POST',
+ headers,
+ body: JSON.stringify(apiData)
+ });
+
+ if (!response.ok) throw new Error('Failed to create API request');
+
+ const newApiId = await response.json();
+ return {
+ ...apiData,
+ id: newApiId,
+ collectionId,
+ responseModel: {
+ status: 0,
+ time: 0,
+ size: 0,
+ headers: [],
+ body: ''
+ }
+ };
+ },
+
+ async updateAPIRequest(apiRequest) {
+ const response = await fetch(`${contextPath}mgmt/collections/${apiRequest.collectionId}/apis/save.do`, {
+ method: 'POST',
+ headers,
+ body: JSON.stringify(apiRequest)
+ });
+
+ if (!response.ok) throw new Error('Failed to update API request');
+ return apiRequest;
+ },
+
+ async deleteAPIRequest(collectionId, apiId) {
+ const response = await fetch(`${contextPath}mgmt/collections/${collectionId}/apis/delete.do`, {
+ method: 'POST',
+ headers,
+ body: JSON.stringify({ id: apiId })
+ });
+
+ if (!response.ok) throw new Error('Failed to delete API request');
+ return true;
+ }
+ };
+};
diff --git a/TestMasterUI/src/services/authService.js b/TestMasterUI/src/services/authService.js
new file mode 100644
index 0000000..f00f141
--- /dev/null
+++ b/TestMasterUI/src/services/authService.js
@@ -0,0 +1,15 @@
+export const authService = () => {
+ return {
+ async checkAuth() {
+ const response = await fetch('/api/account', {
+ headers: {
+ 'X-Requested-With': 'XMLHttpRequest'
+ }
+ });
+
+ if (!response.ok) return { isAuthenticated: false };
+ const data = await response.json();
+ return { isAuthenticated: !!data.authorities, data };
+ }
+ };
+};
diff --git a/TestMasterUI/src/services/collectionService.js b/TestMasterUI/src/services/collectionService.js
new file mode 100644
index 0000000..05a0eaa
--- /dev/null
+++ b/TestMasterUI/src/services/collectionService.js
@@ -0,0 +1,52 @@
+import {getCSRFToken} from '@/services/token.js';
+
+export const collectionService = (contextPath = '/') => {
+
+ const headers = {
+ 'Content-Type': 'application/json',
+ 'X-XSRF-TOKEN': getCSRFToken()
+ };
+
+ return {
+ async loadCollections() {
+ const response = await fetch(`${contextPath}mgmt/collections/list.do`, { headers });
+ if (!response.ok) throw new Error('Failed to fetch collections');
+
+ const data = await response.json();
+ return data.map(collection => ({
+ ...collection,
+ apis: collection.apis?.map(api => ({...api, collectionId: collection.id})) || []
+ }));
+ },
+
+ async createCollection(name) {
+ await fetch(`${contextPath}mgmt/collections/create.do`, {
+ method: 'POST',
+ headers,
+ body: JSON.stringify({ name })
+ });
+ },
+
+ async updateCollection(id, name) {
+ const response = await fetch(`${contextPath}mgmt/collections/update.do`, {
+ method: 'POST',
+ headers,
+ body: JSON.stringify({ id, name })
+ });
+
+ if (!response.ok) throw new Error('Failed to update collection');
+ return true;
+ },
+
+ async deleteCollection(id) {
+ const response = await fetch(`${contextPath}mgmt/collections/delete.do`, {
+ method: 'POST',
+ headers,
+ body: JSON.stringify({ id })
+ });
+
+ if (!response.ok) throw new Error('Failed to delete collection');
+ return true;
+ }
+ };
+};
diff --git a/TestMasterUI/src/services/serverService.js b/TestMasterUI/src/services/serverService.js
new file mode 100644
index 0000000..06d881f
--- /dev/null
+++ b/TestMasterUI/src/services/serverService.js
@@ -0,0 +1,18 @@
+export const serverService = () => {
+ return {
+ async loadServers() {
+ const response = await fetch('/servers/list.do');
+ if (!response.ok) throw new Error(`Failed to fetch servers: ${response.statusText}`);
+
+ const data = await response.json();
+ if (!Array.isArray(data)) throw new Error('Invalid server data format');
+
+ return data.map(server => ({
+ id: server?.id || crypto.randomUUID(),
+ name: server?.name || 'Unnamed Server',
+ basePath: server?.basePath || '',
+ environment: server?.environment || 'unknown'
+ }));
+ }
+ };
+};
diff --git a/TestMasterUI/src/services/token.js b/TestMasterUI/src/services/token.js
new file mode 100644
index 0000000..1e80efb
--- /dev/null
+++ b/TestMasterUI/src/services/token.js
@@ -0,0 +1,17 @@
+// Get CSRF token from meta tag or cookie
+export const getCSRFToken = () => {
+ // First try to get from meta tag
+ const metaToken = document.querySelector('meta[name="_csrf"]')?.getAttribute('content');
+ if (metaToken) {
+ return metaToken;
+ }
+
+ // If meta tag token is not found, try to get from cookie
+ const cookies = document.cookie.split(';');
+ const xsrfCookie = cookies.find(cookie => cookie.trim().startsWith('XSRF-TOKEN='));
+ if (xsrfCookie) {
+ return xsrfCookie.split('=')[1].trim();
+ }
+
+ return null;
+};
diff --git a/TestMasterUI/tailwind.config.js b/TestMasterUI/tailwind.config.js
new file mode 100644
index 0000000..9af205d
--- /dev/null
+++ b/TestMasterUI/tailwind.config.js
@@ -0,0 +1,68 @@
+/** @type {import('tailwindcss').Config} */
+export default {
+ darkMode: ["class"],
+ content: ["./index.html", "./src/**/*.{ts,tsx,js,jsx}"],
+ theme: {
+ extend: {
+ borderRadius: {
+ lg: 'var(--radius)',
+ md: 'calc(var(--radius) - 2px)',
+ sm: 'calc(var(--radius) - 4px)'
+ },
+ colors: {
+ background: 'hsl(var(--background))',
+ foreground: 'hsl(var(--foreground))',
+ card: {
+ DEFAULT: 'hsl(var(--card))',
+ foreground: 'hsl(var(--card-foreground))'
+ },
+ popover: {
+ DEFAULT: 'hsl(var(--popover))',
+ foreground: 'hsl(var(--popover-foreground))'
+ },
+ primary: {
+ DEFAULT: 'hsl(var(--primary))',
+ foreground: 'hsl(var(--primary-foreground))'
+ },
+ secondary: {
+ DEFAULT: 'hsl(var(--secondary))',
+ foreground: 'hsl(var(--secondary-foreground))'
+ },
+ muted: {
+ DEFAULT: 'hsl(var(--muted))',
+ foreground: 'hsl(var(--muted-foreground))'
+ },
+ accent: {
+ DEFAULT: 'hsl(var(--accent))',
+ foreground: 'hsl(var(--accent-foreground))'
+ },
+ destructive: {
+ DEFAULT: 'hsl(var(--destructive))',
+ foreground: 'hsl(var(--destructive-foreground))'
+ },
+ border: 'hsl(var(--border))',
+ input: 'hsl(var(--input))',
+ ring: 'hsl(var(--ring))',
+ chart: {
+ '1': 'hsl(var(--chart-1))',
+ '2': 'hsl(var(--chart-2))',
+ '3': 'hsl(var(--chart-3))',
+ '4': 'hsl(var(--chart-4))',
+ '5': 'hsl(var(--chart-5))'
+ },
+ sidebar: {
+ DEFAULT: 'hsl(var(--sidebar-background))',
+ foreground: 'hsl(var(--sidebar-foreground))',
+ primary: 'hsl(var(--sidebar-primary))',
+ 'primary-foreground': 'hsl(var(--sidebar-primary-foreground))',
+ accent: 'hsl(var(--sidebar-accent))',
+ 'accent-foreground': 'hsl(var(--sidebar-accent-foreground))',
+ border: 'hsl(var(--sidebar-border))',
+ ring: 'hsl(var(--sidebar-ring))'
+ }
+ }
+ }
+ },
+ plugins: [require("tailwindcss-animate")],
+}
+
diff --git a/TestMasterUI/vite.config.js b/TestMasterUI/vite.config.js
new file mode 100644
index 0000000..0e307b4
--- /dev/null
+++ b/TestMasterUI/vite.config.js
@@ -0,0 +1,63 @@
+import path from "path"
+import react from '@vitejs/plugin-react'
+import { defineConfig } from 'vite'
+import monacoEditorPlugin from './vite.monaco-editor';
+
+export default defineConfig({
+ define: {
+ global: {},
+ },
+ plugins: [react(),monacoEditorPlugin()],
+ resolve: {
+ alias: {
+ "@": path.resolve(__dirname, "./src"),
+ },
+ },
+ server: {
+ proxy: {
+ '/mgmt': {
+ target: 'http://localhost:38080',
+ changeOrigin: true,
+ secure: false,
+ },
+ '/api': {
+ target: 'http://localhost:38080',
+ changeOrigin: true,
+ secure: false,
+ },
+ '/actionLogin.do': {
+ target: 'http://localhost:38080',
+ changeOrigin: true,
+ secure: false,
+ },
+ '/actionLogout.do': {
+ target: 'http://localhost:38080',
+ changeOrigin: true,
+ secure: false,
+ },
+ '/img': {
+ target: 'http://localhost:38080',
+ changeOrigin: true,
+ secure: false,
+ },
+ '/servers': {
+ target: 'http://localhost:38080',
+ changeOrigin: true,
+ secure: false,
+ }
+ }
+ },
+ optimizeDeps: {
+ include: ['@monaco-editor/react'],
+ },
+ build: {
+ rollupOptions: {
+ output: {
+ manualChunks: {
+ // Split Monaco Editor into a separate chunk
+ 'monaco-editor': ['monaco-editor'],
+ },
+ },
+ },
+ },
+})
diff --git a/TestMasterUI/vite.monaco-editor.js b/TestMasterUI/vite.monaco-editor.js
new file mode 100644
index 0000000..4c9b3da
--- /dev/null
+++ b/TestMasterUI/vite.monaco-editor.js
@@ -0,0 +1,20 @@
+// vite.monaco-editor.js
+export default function monacoEditorPlugin() {
+ return {
+ name: 'monaco-editor-plugin',
+ config(config) {
+ config.define = {
+ ...config.define,
+ 'process.platform': JSON.stringify('darwin'),
+ 'process.env': {},
+ };
+ },
+ configureServer(server) {
+ return () => {
+ server.middlewares.use('/_monaco', (req, res) => {
+ res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
+ });
+ };
+ }
+ };
+}
diff --git a/src/main/java/com/eactive/testmaster/socket/NettyServer.java b/src/main/java/com/eactive/testmaster/socket/NettyServer.java
new file mode 100644
index 0000000..0d75b4c
--- /dev/null
+++ b/src/main/java/com/eactive/testmaster/socket/NettyServer.java
@@ -0,0 +1,88 @@
+package com.eactive.testmaster.socket;
+
+import io.netty.bootstrap.ServerBootstrap;
+import io.netty.channel.*;
+import io.netty.channel.nio.NioEventLoopGroup;
+import io.netty.channel.socket.nio.NioServerSocketChannel;
+import io.netty.util.concurrent.DefaultThreadFactory;
+
+public class NettyServer {
+ private final int port;
+ private EventLoopGroup bossGroup;
+ private EventLoopGroup workerGroup;
+ private Channel serverChannel;
+ private volatile boolean isInitialized = false;
+ private final String script;
+
+ public NettyServer(int port, String script) {
+ this.port = port;
+ this.script = script;
+ }
+
+ public synchronized void start() throws InterruptedException {
+ if (isInitialized) {
+ return;
+ }
+
+ try {
+ // Use custom thread factories to avoid naming conflicts
+ bossGroup = new NioEventLoopGroup(1, new DefaultThreadFactory("netty-boss"));
+ workerGroup = new NioEventLoopGroup(
+ Runtime.getRuntime().availableProcessors() * 2,
+ new DefaultThreadFactory("netty-worker")
+ );
+
+ ServerBootstrap serverBootstrap = new ServerBootstrap();
+ serverBootstrap
+ .group(bossGroup, workerGroup)
+ .channel(NioServerSocketChannel.class)
+ .option(ChannelOption.SO_BACKLOG, 128)
+ .childOption(ChannelOption.SO_KEEPALIVE, true)
+ .childHandler(new ChannelInitializer() {
+ @Override
+ protected void initChannel(Channel ch) {
+ ch.pipeline().addLast(new NettySocketHandler(script));
+ }
+ });
+
+ // Bind and wait for the server socket
+ ChannelFuture channelFuture = serverBootstrap.bind(port).sync();
+ if (!channelFuture.isSuccess()) {
+ throw new RuntimeException("Failed to bind to port " + port);
+ }
+
+ serverChannel = channelFuture.channel();
+ isInitialized = true;
+
+ System.out.println("Netty server started on port " + port);
+ } catch (Exception e) {
+ // Clean up resources if initialization fails
+ stop();
+ throw new RuntimeException("Failed to start Netty server", e);
+ }
+ }
+
+ public synchronized void stop() throws InterruptedException {
+ try {
+ if (serverChannel != null) {
+ serverChannel.close().sync();
+ serverChannel = null;
+ }
+ } finally {
+ // Always attempt to shut down the event loop groups
+ try {
+ if (workerGroup != null) {
+ workerGroup.shutdownGracefully().sync();
+ workerGroup = null;
+ }
+ } finally {
+ if (bossGroup != null) {
+ bossGroup.shutdownGracefully().sync();
+ bossGroup = null;
+ }
+ }
+ isInitialized = false;
+ System.out.println("Netty server stopped on port " + port);
+ }
+ }
+}
diff --git a/src/main/java/com/eactive/testmaster/socket/NettySocketHandler.java b/src/main/java/com/eactive/testmaster/socket/NettySocketHandler.java
new file mode 100644
index 0000000..70b74e2
--- /dev/null
+++ b/src/main/java/com/eactive/testmaster/socket/NettySocketHandler.java
@@ -0,0 +1,114 @@
+package com.eactive.testmaster.socket;
+
+import io.netty.buffer.ByteBuf;
+import io.netty.channel.ChannelHandlerContext;
+import io.netty.channel.ChannelInboundHandlerAdapter;
+import io.netty.util.ReferenceCountUtil;
+import javax.script.Invocable;
+import javax.script.ScriptEngine;
+import javax.script.ScriptEngineManager;
+import javax.script.ScriptException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class NettySocketHandler extends ChannelInboundHandlerAdapter {
+
+ private static final Logger logger = LoggerFactory.getLogger(NettySocketHandler.class);
+ private final ScriptEngine engine;
+
+ public NettySocketHandler(String scriptContent) {
+ ScriptEngineManager manager = new ScriptEngineManager();
+ this.engine = manager.getEngineByName("nashorn");
+ try {
+ engine.eval(scriptContent);
+ } catch (ScriptException e) {
+ logger.error("Failed to initialize script", e);
+ throw new RuntimeException("Script initialization failed", e);
+ }
+ }
+
+ @Override
+ public void channelActive(ChannelHandlerContext ctx) {
+ logger.info("Client connected: {}", ctx.channel().remoteAddress());
+ }
+
+ @Override
+ public void channelInactive(ChannelHandlerContext ctx) {
+ logger.info("Client disconnected: {}", ctx.channel().remoteAddress());
+ }
+
+ @Override
+ public void channelRead(ChannelHandlerContext ctx, Object msg) {
+ ByteBuf in = (ByteBuf) msg;
+ try {
+ logger.debug("Received new message from client: {}", ctx.channel().remoteAddress());
+ logger.debug("Total readable bytes: {}", in.readableBytes());
+
+ // Read the entire message as it comes
+ byte[] messageBytes = new byte[in.readableBytes()];
+ in.readBytes(messageBytes);
+ String message = new String(messageBytes);
+
+ logger.debug("Message processing details:");
+ logger.debug("- Raw message: {}", message);
+ logger.debug("- Hex dump: {}", bytesToHex(messageBytes));
+
+ // Process the message, removing the length prefix
+ String messageContent = message.substring(4);
+ logger.debug("- Message content (without length): {}", messageContent);
+
+ String result;
+ try {
+ logger.debug("Executing script processor for message");
+ Invocable invocable = (Invocable) engine;
+ Object jsResult = invocable.invokeFunction("processMessage", messageContent);
+ result = String.valueOf(jsResult);
+ logger.debug("Script processing successful. Result: {}", result);
+ } catch (ScriptException | NoSuchMethodException e) {
+ logger.error("Script execution error", e);
+ result = "Error: " + e.getMessage();
+ logger.error("Script execution failed. Using error message as result: {}", result);
+ }
+
+ // Format response with length prefix
+ byte[] responseBytes = result.getBytes();
+ String lengthPrefix = String.format("%04d", responseBytes.length + 4);
+ logger.debug("Preparing response:");
+ logger.debug("- Response content: {}", result);
+ logger.debug("- Response length: {}", responseBytes.length);
+ logger.debug("- Length prefix: {}", lengthPrefix);
+
+ ByteBuf response = ctx.alloc().buffer(4 + responseBytes.length);
+ response.writeBytes(lengthPrefix.getBytes());
+ response.writeBytes(responseBytes);
+
+ logger.debug("Sending response to client: {}", ctx.channel().remoteAddress());
+ logger.debug("- Total response size: {} bytes", 4 + responseBytes.length);
+ ctx.writeAndFlush(response);
+
+ } catch (Exception e) {
+ logger.error("Error processing message from client: {}", ctx.channel().remoteAddress(), e);
+ logger.error("Error occurred at reader index: {}", in.readerIndex());
+ } finally {
+ logger.debug("Releasing ByteBuf resources");
+ ReferenceCountUtil.release(msg);
+ }
+ }
+
+ /**
+ * Utility method to convert byte array to hexadecimal string for logging
+ */
+ private String bytesToHex(byte[] bytes) {
+ StringBuilder sb = new StringBuilder();
+ for (byte b : bytes) {
+ sb.append(String.format("%02X ", b));
+ }
+ return sb.toString().trim();
+ }
+
+ @Override
+ public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
+ logger.error("Channel error:", cause);
+ ctx.close();
+ }
+}
diff --git a/src/main/java/com/eactive/testmaster/socket/controller/NettyServerController.java b/src/main/java/com/eactive/testmaster/socket/controller/NettyServerController.java
new file mode 100644
index 0000000..3171518
--- /dev/null
+++ b/src/main/java/com/eactive/testmaster/socket/controller/NettyServerController.java
@@ -0,0 +1,143 @@
+package com.eactive.testmaster.socket.controller;
+
+import com.eactive.testmaster.socket.entity.NettyServerEntity;
+import com.eactive.testmaster.socket.service.NettyServerService;
+import java.util.List;
+import javax.validation.Valid;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.access.annotation.Secured;
+import org.springframework.stereotype.Controller;
+import org.springframework.ui.ModelMap;
+import org.springframework.validation.BindingResult;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.ModelAttribute;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.servlet.ModelAndView;
+
+@Controller
+@RequestMapping("/mgmt/socket")
+public class NettyServerController {
+
+ private static final String NETTY_SERVER = "nettyServer";
+ private static final String NETTY_SERVER_LIST_VIEW = "page/socket/serverList";
+ private static final String NETTY_SERVER_EDIT = "page/socket/serverEdit";
+ private static final String REDIRECT_TO_LIST = "redirect:/mgmt/socket/list_view.do";
+
+ private final NettyServerService nettyServerService;
+
+ public NettyServerController(NettyServerService nettyServerService) {
+ this.nettyServerService = nettyServerService;
+ }
+
+ @GetMapping("/list_view.do")
+ @Secured("ROLE_MANAGE_NETTY")
+ public ModelAndView listView(@ModelAttribute("serverSearch") NettyServerSearch serverSearch,
+ Pageable pageable) {
+ ModelAndView mav = new ModelAndView(NETTY_SERVER_LIST_VIEW);
+ Page page = nettyServerService.findAll(serverSearch.buildSpecification(), pageable);
+ mav.addObject("page", page);
+ mav.addObject("pageable", pageable);
+ mav.addObject("modelSearch", serverSearch);
+ return mav;
+ }
+
+ @GetMapping("/create_view.do")
+ @Secured("ROLE_MANAGE_NETTY")
+ public ModelAndView createView() {
+ ModelAndView mav = new ModelAndView(NETTY_SERVER_EDIT);
+ mav.addObject(NETTY_SERVER, new NettyServerEntity());
+ return mav;
+ }
+
+ @GetMapping("/update_view.do")
+ @Secured("ROLE_MANAGE_NETTY")
+ public ModelAndView updateView(@RequestParam("id") Long id) {
+ ModelAndView mav = new ModelAndView(NETTY_SERVER_EDIT);
+ NettyServerEntity server = nettyServerService.findById(id);
+ mav.addObject(NETTY_SERVER, server);
+ return mav;
+ }
+
+ @PostMapping("/register.do")
+ @Secured("ROLE_MANAGE_NETTY")
+ public String register(@Valid @ModelAttribute(NETTY_SERVER) NettyServerEntity server,
+ BindingResult bindingResult,
+ ModelMap model) {
+ if (bindingResult.hasErrors()) {
+ model.addAttribute(NETTY_SERVER, server);
+ return NETTY_SERVER_EDIT;
+ }
+
+ try {
+ nettyServerService.createServer(server);
+ return REDIRECT_TO_LIST;
+ } catch (Exception e) {
+ bindingResult.rejectValue("port", "error.port.inuse",
+ "Port is already in use or invalid");
+ model.addAttribute(NETTY_SERVER, server);
+ return NETTY_SERVER_EDIT;
+ }
+ }
+
+ @PostMapping("/update.do")
+ @Secured("ROLE_MANAGE_NETTY")
+ public String update(@RequestParam("id") Long id,
+ @Valid @ModelAttribute(NETTY_SERVER) NettyServerEntity server,
+ BindingResult bindingResult,
+ ModelMap model) {
+ if (bindingResult.hasErrors()) {
+ model.addAttribute(NETTY_SERVER, server);
+ return NETTY_SERVER_EDIT;
+ }
+
+ try {
+ nettyServerService.updateServer(id, server);
+ return REDIRECT_TO_LIST;
+ } catch (Exception e) {
+ bindingResult.rejectValue("port", "error.update.failed",
+ "Failed to update server");
+ model.addAttribute(NETTY_SERVER, server);
+ return NETTY_SERVER_EDIT;
+ }
+ }
+
+ @PostMapping("/delete.do")
+ @Secured("ROLE_MANAGE_NETTY")
+ public String delete(@RequestParam("checkedIdForDel") List ids) {
+ for (String id : ids) {
+ try {
+ nettyServerService.deleteServer(Long.parseLong(id));
+ } catch (Exception e) {
+ // Log error but continue with other deletions
+ }
+ }
+ return REDIRECT_TO_LIST;
+ }
+
+ @PostMapping("/servers/{id}/start")
+ @Secured("ROLE_MANAGE_NETTY")
+ public ResponseEntity startServer(@PathVariable Long id) {
+ try {
+ nettyServerService.startServer(id);
+ return ResponseEntity.ok().build();
+ } catch (Exception e) {
+ return ResponseEntity.internalServerError().build();
+ }
+ }
+
+ @PostMapping("/servers/{id}/stop")
+ @Secured("ROLE_MANAGE_NETTY")
+ public ResponseEntity stopServer(@PathVariable Long id) {
+ try {
+ nettyServerService.stopServer(id);
+ return ResponseEntity.ok().build();
+ } catch (Exception e) {
+ return ResponseEntity.internalServerError().build();
+ }
+ }
+}
diff --git a/src/main/java/com/eactive/testmaster/socket/controller/NettyServerSearch.java b/src/main/java/com/eactive/testmaster/socket/controller/NettyServerSearch.java
new file mode 100644
index 0000000..48b02d0
--- /dev/null
+++ b/src/main/java/com/eactive/testmaster/socket/controller/NettyServerSearch.java
@@ -0,0 +1,39 @@
+package com.eactive.testmaster.socket.controller;
+
+import com.eactive.testmaster.common.search.BaseSearch;
+import com.eactive.testmaster.common.search.ColumnSearchModel;
+import com.eactive.testmaster.common.search.SearchCondition;
+import com.eactive.testmaster.common.search.SearchModel;
+import com.eactive.testmaster.socket.entity.NettyServerEntity;
+import java.util.ArrayList;
+import java.util.List;
+
+public class NettyServerSearch implements BaseSearch {
+
+ private String name;
+ private Long port;
+
+ @Override
+ public List buildSearchCondition() {
+ List models = new ArrayList<>();
+ models.add(new ColumnSearchModel("name", SearchCondition.LIKE, name));
+ models.add(new ColumnSearchModel("port", SearchCondition.EQUAL, port));
+ return models;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public void setName(String name) {
+ this.name = name;
+ }
+
+ public Long getPort() {
+ return port;
+ }
+
+ public void setPort(Long port) {
+ this.port = port;
+ }
+}
diff --git a/src/main/java/com/eactive/testmaster/socket/entity/NettyServerEntity.java b/src/main/java/com/eactive/testmaster/socket/entity/NettyServerEntity.java
new file mode 100644
index 0000000..890421b
--- /dev/null
+++ b/src/main/java/com/eactive/testmaster/socket/entity/NettyServerEntity.java
@@ -0,0 +1,101 @@
+package com.eactive.testmaster.socket.entity;
+
+import java.time.LocalDateTime;
+import javax.persistence.Column;
+import javax.persistence.Entity;
+import javax.persistence.GeneratedValue;
+import javax.persistence.GenerationType;
+import javax.persistence.Id;
+import javax.persistence.Lob;
+import javax.persistence.PreUpdate;
+import javax.persistence.Table;
+import org.hibernate.annotations.Type;
+
+@Entity
+@Table(name = "netty_servers")
+public class NettyServerEntity {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ private String name;
+
+ @Column(unique = true, nullable = false)
+ private Integer port;
+
+ @Column(nullable = false)
+ private Boolean active = true;
+
+ @Lob
+ private String script;
+
+ @Type(type = "org.hibernate.type.LocalDateTimeType")
+ private LocalDateTime createdAt = LocalDateTime.now();
+
+
+ @Type(type = "org.hibernate.type.LocalDateTimeType")
+ private LocalDateTime lastModified = LocalDateTime.now();
+
+ // Standard getters and setters
+ public Long getId() {
+ return id;
+ }
+
+ public void setId(Long id) {
+ this.id = id;
+ }
+
+ public Integer getPort() {
+ return port;
+ }
+
+ public void setPort(Integer port) {
+ this.port = port;
+ }
+
+ public Boolean getActive() {
+ return active;
+ }
+
+ public void setActive(Boolean active) {
+ this.active = active;
+ }
+
+ public LocalDateTime getCreatedAt() {
+ return createdAt;
+ }
+
+ public void setCreatedAt(LocalDateTime createdAt) {
+ this.createdAt = createdAt;
+ }
+
+ public LocalDateTime getLastModified() {
+ return lastModified;
+ }
+
+ public void setLastModified(LocalDateTime lastModified) {
+ this.lastModified = lastModified;
+ }
+
+ @PreUpdate
+ protected void onUpdate() {
+ lastModified = LocalDateTime.now();
+ }
+
+ public String getScript() {
+ return script;
+ }
+
+ public void setScript(String script) {
+ this.script = script;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public void setName(String name) {
+ this.name = name;
+ }
+}
diff --git a/src/main/java/com/eactive/testmaster/socket/repository/NettyServerRepository.java b/src/main/java/com/eactive/testmaster/socket/repository/NettyServerRepository.java
new file mode 100644
index 0000000..fff5d21
--- /dev/null
+++ b/src/main/java/com/eactive/testmaster/socket/repository/NettyServerRepository.java
@@ -0,0 +1,18 @@
+package com.eactive.testmaster.socket.repository;
+
+
+import com.eactive.testmaster.socket.entity.NettyServerEntity;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
+import org.springframework.stereotype.Repository;
+
+import java.util.List;
+import java.util.Optional;
+
+@Repository
+public interface NettyServerRepository extends JpaRepository, JpaSpecificationExecutor {
+
+ Optional findByPort(Integer port);
+
+ List findByActive(Boolean active);
+}
diff --git a/src/main/java/com/eactive/testmaster/socket/service/NettyServerService.java b/src/main/java/com/eactive/testmaster/socket/service/NettyServerService.java
new file mode 100644
index 0000000..59e452b
--- /dev/null
+++ b/src/main/java/com/eactive/testmaster/socket/service/NettyServerService.java
@@ -0,0 +1,153 @@
+package com.eactive.testmaster.socket.service;
+
+import com.eactive.testmaster.socket.NettyServer;
+import com.eactive.testmaster.socket.entity.NettyServerEntity;
+import com.eactive.testmaster.socket.repository.NettyServerRepository;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
+import javax.persistence.EntityNotFoundException;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.jpa.domain.Specification;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+@Service
+@Transactional
+public class NettyServerService {
+ private final NettyServerRepository repository;
+ private final Map activeServers = new HashMap<>();
+
+ public NettyServerService(NettyServerRepository repository) {
+ this.repository = repository;
+ }
+
+ @Transactional(readOnly = true)
+ public Page findAll(Specification spec, Pageable pageable) {
+ return repository.findAll(spec, pageable);
+ }
+
+ @Transactional(readOnly = true)
+ public NettyServerEntity findById(Long id) {
+ return repository.findById(id)
+ .orElseThrow(() -> new EntityNotFoundException("Server not found with id: " + id));
+ }
+
+ public NettyServerEntity createServer(NettyServerEntity server) {
+ validatePort(server.getPort());
+ server.setActive(true);
+ return repository.save(server);
+ }
+
+ public NettyServerEntity updateServer(Long id, NettyServerEntity updatedServer) {
+ NettyServerEntity existingServer = findById(id);
+
+ // If port is being changed, validate the new port
+ if (!existingServer.getPort().equals(updatedServer.getPort())) {
+ validatePort(updatedServer.getPort());
+
+ // Stop the server if it's running on the old port
+ try {
+ stopServerIfRunning(existingServer.getPort());
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new RuntimeException("Failed to stop server during port update", e);
+ }
+ }
+
+ // Update all fields
+ existingServer.setPort(updatedServer.getPort());
+ existingServer.setActive(updatedServer.getActive());
+ existingServer.setName(updatedServer.getName());
+ existingServer.setScript(updatedServer.getScript()); // Add this line to update the script
+
+ return repository.save(existingServer);
+ }
+
+ public void deleteServer(Long id) throws InterruptedException {
+ NettyServerEntity server = findById(id);
+ stopServerIfRunning(server.getPort());
+ repository.deleteById(id);
+ }
+
+ public void startServer(Long id) throws InterruptedException {
+ NettyServerEntity entity = findById(id);
+
+ if (!entity.getActive()) {
+ throw new IllegalStateException("Cannot start inactive server: " + id);
+ }
+
+ if (!activeServers.containsKey(entity.getPort())) {
+ NettyServer server = new NettyServer(entity.getPort(), entity.getScript());
+ server.start();
+ activeServers.put(entity.getPort(), server);
+ }
+ }
+
+ @Transactional
+ public void stopServer(Long id) throws InterruptedException {
+ NettyServerEntity entity = findById(id);
+ stopServerIfRunning(entity.getPort());
+ }
+
+ private void stopServerIfRunning(Integer port) throws InterruptedException {
+ NettyServer server = activeServers.remove(port);
+ if (server != null) {
+ server.stop();
+ }
+ }
+
+// @Transactional
+// public void deactivateServer(Long id) throws InterruptedException {
+// NettyServerEntity entity = findById(id);
+// stopServerIfRunning(entity.getPort());
+// entity.setActive(false);
+// repository.save(entity);
+// }
+
+// @Transactional(readOnly = true)
+// public List getAllServers() {
+// return repository.findAll();
+// }
+
+// @Transactional(readOnly = true)
+// public List getActiveServers() {
+// return repository.findByActive(true);
+// }
+
+// @Transactional(readOnly = true)
+// public boolean isServerRunning(Integer port) {
+// return activeServers.containsKey(port);
+// }
+
+// @Transactional(readOnly = true)
+// public Map getServerStatuses(List ports) {
+// Map statuses = new HashMap<>();
+// for (Integer port : ports) {
+// statuses.put(port, activeServers.containsKey(port));
+// }
+// return statuses;
+// }
+
+ private void validatePort(Integer port) {
+ if (port == null) {
+ throw new IllegalArgumentException("Port cannot be null");
+ }
+
+ if (port < 1024 || port > 65535) {
+ throw new IllegalArgumentException("Port must be between 1024 and 65535");
+ }
+
+ if (port >= 5701 && port <= 5801) {
+ throw new IllegalArgumentException(
+ "Port " + port + " conflicts with Hazelcast port range (5701-5801)"
+ );
+ }
+
+ Optional existingServer = repository.findByPort(port);
+ if (existingServer.isPresent()) {
+ throw new IllegalArgumentException("Port " + port + " is already in use");
+ }
+ }
+}
diff --git a/src/main/resources/templates/page/socket/serverEdit.html b/src/main/resources/templates/page/socket/serverEdit.html
new file mode 100644
index 0000000..6eb97b7
--- /dev/null
+++ b/src/main/resources/templates/page/socket/serverEdit.html
@@ -0,0 +1,128 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/main/resources/templates/page/socket/serverList.html b/src/main/resources/templates/page/socket/serverList.html
new file mode 100644
index 0000000..c3bcd96
--- /dev/null
+++ b/src/main/resources/templates/page/socket/serverList.html
@@ -0,0 +1,247 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
|