feat: implement plugin architecture and application settings with Svelte UI
- Added plugin base and loader for backend extensibility - Implemented application settings management with config persistence - Created Svelte-based frontend with Dashboard and Settings pages - Added API routes for plugins, tasks, and settings - Updated documentation and specifications - Improved project structure and developer tools
This commit is contained in:
0
frontend/.vscode/extensions.json
vendored
Normal file → Executable file
0
frontend/.vscode/extensions.json
vendored
Normal file → Executable file
0
frontend/README.md
Normal file → Executable file
0
frontend/README.md
Normal file → Executable file
0
frontend/index.html
Normal file → Executable file
0
frontend/index.html
Normal file → Executable file
0
frontend/jsconfig.json
Normal file → Executable file
0
frontend/jsconfig.json
Normal file → Executable file
9
frontend/package-lock.json
generated
Normal file → Executable file
9
frontend/package-lock.json
generated
Normal file → Executable file
@@ -883,6 +883,7 @@
|
||||
"integrity": "sha512-YZs/OSKOQAQCnJvM/P+F1URotNnYNeU3P2s4oIpzm1uFaqUEqRxUB0g5ejMjEb5Gjb9/PiBI5Ktrq4rUUF8UVQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@sveltejs/vite-plugin-svelte-inspector": "^5.0.0",
|
||||
"debug": "^4.4.1",
|
||||
@@ -929,6 +930,7 @@
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -1077,6 +1079,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -1514,6 +1517,7 @@
|
||||
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"jiti": "bin/jiti.js"
|
||||
}
|
||||
@@ -1721,6 +1725,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -2058,6 +2063,7 @@
|
||||
"integrity": "sha512-ZhLtvroYxUxr+HQJfMZEDRsGsmU46x12RvAv/zi9584f5KOX7bUrEbhPJ7cKFmUvZTJXi/CFZUYwDC6M1FigPw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@jridgewell/remapping": "^2.3.4",
|
||||
"@jridgewell/sourcemap-codec": "^1.5.0",
|
||||
@@ -2181,6 +2187,7 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -2252,6 +2259,7 @@
|
||||
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.27.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -2345,6 +2353,7 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
|
||||
0
frontend/package.json
Normal file → Executable file
0
frontend/package.json
Normal file → Executable file
10
frontend/postcss.config.js
Normal file → Executable file
10
frontend/postcss.config.js
Normal file → Executable file
@@ -1,6 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
0
frontend/public/vite.svg
Normal file → Executable file
0
frontend/public/vite.svg
Normal file → Executable file
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
78
frontend/src/App.svelte
Normal file → Executable file
78
frontend/src/App.svelte
Normal file → Executable file
@@ -1,28 +1,91 @@
|
||||
<!--
|
||||
[DEF:App:Component]
|
||||
@SEMANTICS: main, entrypoint, layout, navigation
|
||||
@PURPOSE: The root component of the frontend application. Manages navigation and layout.
|
||||
@LAYER: UI
|
||||
@RELATION: DEPENDS_ON -> frontend/src/pages/Dashboard.svelte
|
||||
@RELATION: DEPENDS_ON -> frontend/src/pages/Settings.svelte
|
||||
@RELATION: DEPENDS_ON -> frontend/src/lib/stores.js
|
||||
|
||||
@PROPS: None
|
||||
@EVENTS: None
|
||||
@INVARIANT: Navigation state must be persisted in the currentPage store.
|
||||
-->
|
||||
<script>
|
||||
import Dashboard from './pages/Dashboard.svelte';
|
||||
import { selectedPlugin, selectedTask } from './lib/stores.js';
|
||||
import Settings from './pages/Settings.svelte';
|
||||
import { selectedPlugin, selectedTask, currentPage } from './lib/stores.js';
|
||||
import TaskRunner from './components/TaskRunner.svelte';
|
||||
import DynamicForm from './components/DynamicForm.svelte';
|
||||
import { api } from './lib/api.js';
|
||||
import Toast from './components/Toast.svelte';
|
||||
|
||||
// [DEF:handleFormSubmit:Function]
|
||||
// @PURPOSE: Handles form submission for task creation.
|
||||
// @PARAM: event (CustomEvent) - The submit event from DynamicForm.
|
||||
async function handleFormSubmit(event) {
|
||||
console.log("[App.handleFormSubmit][Action] Handling form submission for task creation.");
|
||||
const params = event.detail;
|
||||
const task = await api.createTask($selectedPlugin.id, params);
|
||||
selectedTask.set(task);
|
||||
selectedPlugin.set(null);
|
||||
try {
|
||||
const task = await api.createTask($selectedPlugin.id, params);
|
||||
selectedTask.set(task);
|
||||
selectedPlugin.set(null);
|
||||
console.log(`[App.handleFormSubmit][Coherence:OK] Task created context={{'id': '${task.id}'}}`);
|
||||
} catch (error) {
|
||||
console.error(`[App.handleFormSubmit][Coherence:Failed] Task creation failed context={{'error': '${error}'}}`);
|
||||
}
|
||||
}
|
||||
// [/DEF:handleFormSubmit]
|
||||
|
||||
// [DEF:navigate:Function]
|
||||
// @PURPOSE: Changes the current page and resets state.
|
||||
// @PARAM: page (string) - Target page name.
|
||||
function navigate(page) {
|
||||
console.log(`[App.navigate][Action] Navigating to ${page}.`);
|
||||
// Reset selection first
|
||||
if (page !== $currentPage) {
|
||||
selectedPlugin.set(null);
|
||||
selectedTask.set(null);
|
||||
}
|
||||
// Then set page
|
||||
currentPage.set(page);
|
||||
}
|
||||
// [/DEF:navigate]
|
||||
</script>
|
||||
|
||||
<Toast />
|
||||
|
||||
<main class="bg-gray-50 min-h-screen">
|
||||
<header class="bg-white shadow-md p-4">
|
||||
<h1 class="text-3xl font-bold text-gray-800">Superset Tools</h1>
|
||||
<header class="bg-white shadow-md p-4 flex justify-between items-center">
|
||||
<button
|
||||
type="button"
|
||||
class="text-3xl font-bold text-gray-800 focus:outline-none"
|
||||
on:click={() => navigate('dashboard')}
|
||||
>
|
||||
Superset Tools
|
||||
</button>
|
||||
<nav class="space-x-4">
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => navigate('dashboard')}
|
||||
class="text-gray-600 hover:text-blue-600 font-medium {$currentPage === 'dashboard' ? 'text-blue-600 border-b-2 border-blue-600' : ''}"
|
||||
>
|
||||
Dashboard
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => navigate('settings')}
|
||||
class="text-gray-600 hover:text-blue-600 font-medium {$currentPage === 'settings' ? 'text-blue-600 border-b-2 border-blue-600' : ''}"
|
||||
>
|
||||
Settings
|
||||
</button>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<div class="p-4">
|
||||
{#if $selectedTask}
|
||||
{#if $currentPage === 'settings'}
|
||||
<Settings />
|
||||
{:else if $selectedTask}
|
||||
<TaskRunner />
|
||||
<button on:click={() => selectedTask.set(null)} class="mt-4 bg-blue-500 text-white p-2 rounded">
|
||||
Back to Task List
|
||||
@@ -38,3 +101,4 @@
|
||||
{/if}
|
||||
</div>
|
||||
</main>
|
||||
<!-- [/DEF:App] -->
|
||||
|
||||
0
frontend/src/app.css
Normal file → Executable file
0
frontend/src/app.css
Normal file → Executable file
0
frontend/src/assets/svelte.svg
Normal file → Executable file
0
frontend/src/assets/svelte.svg
Normal file → Executable file
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.9 KiB |
135
frontend/src/components/DynamicForm.svelte
Normal file → Executable file
135
frontend/src/components/DynamicForm.svelte
Normal file → Executable file
@@ -1,56 +1,79 @@
|
||||
<script>
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
export let schema;
|
||||
let formData = {};
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
function handleSubmit() {
|
||||
dispatch('submit', formData);
|
||||
}
|
||||
|
||||
// Initialize form data with default values from the schema
|
||||
if (schema && schema.properties) {
|
||||
for (const key in schema.properties) {
|
||||
formData[key] = schema.properties[key].default || '';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<form on:submit|preventDefault={handleSubmit} class="space-y-4">
|
||||
{#if schema && schema.properties}
|
||||
{#each Object.entries(schema.properties) as [key, prop]}
|
||||
<div class="flex flex-col">
|
||||
<label for={key} class="mb-1 font-semibold text-gray-700">{prop.title || key}</label>
|
||||
{#if prop.type === 'string'}
|
||||
<input
|
||||
type="text"
|
||||
id={key}
|
||||
bind:value={formData[key]}
|
||||
placeholder={prop.description || ''}
|
||||
class="p-2 border rounded-md"
|
||||
/>
|
||||
{:else if prop.type === 'number' || prop.type === 'integer'}
|
||||
<input
|
||||
type="number"
|
||||
id={key}
|
||||
bind:value={formData[key]}
|
||||
placeholder={prop.description || ''}
|
||||
class="p-2 border rounded-md"
|
||||
/>
|
||||
{:else if prop.type === 'boolean'}
|
||||
<input
|
||||
type="checkbox"
|
||||
id={key}
|
||||
bind:checked={formData[key]}
|
||||
class="h-5 w-5"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
<button type="submit" class="w-full bg-green-500 text-white p-2 rounded-md hover:bg-green-600">
|
||||
Run Task
|
||||
</button>
|
||||
{/if}
|
||||
</form>
|
||||
<!--
|
||||
[DEF:DynamicForm:Component]
|
||||
@SEMANTICS: form, schema, dynamic, json-schema
|
||||
@PURPOSE: Generates a form dynamically based on a JSON schema.
|
||||
@LAYER: UI
|
||||
@RELATION: DEPENDS_ON -> svelte:createEventDispatcher
|
||||
|
||||
@PROPS:
|
||||
- schema: Object - JSON schema for the form.
|
||||
@EVENTS:
|
||||
- submit: Object - Dispatched when the form is submitted, containing the form data.
|
||||
-->
|
||||
<script>
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
export let schema;
|
||||
let formData = {};
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
// [DEF:handleSubmit:Function]
|
||||
// @PURPOSE: Dispatches the submit event with the form data.
|
||||
function handleSubmit() {
|
||||
console.log("[DynamicForm][Action] Submitting form data.", { formData });
|
||||
dispatch('submit', formData);
|
||||
}
|
||||
// [/DEF:handleSubmit]
|
||||
|
||||
// [DEF:initializeForm:Function]
|
||||
// @PURPOSE: Initialize form data with default values from the schema.
|
||||
function initializeForm() {
|
||||
if (schema && schema.properties) {
|
||||
for (const key in schema.properties) {
|
||||
formData[key] = schema.properties[key].default || '';
|
||||
}
|
||||
}
|
||||
}
|
||||
// [/DEF:initializeForm]
|
||||
|
||||
initializeForm();
|
||||
</script>
|
||||
|
||||
<form on:submit|preventDefault={handleSubmit} class="space-y-4">
|
||||
{#if schema && schema.properties}
|
||||
{#each Object.entries(schema.properties) as [key, prop]}
|
||||
<div class="flex flex-col">
|
||||
<label for={key} class="mb-1 font-semibold text-gray-700">{prop.title || key}</label>
|
||||
{#if prop.type === 'string'}
|
||||
<input
|
||||
type="text"
|
||||
id={key}
|
||||
bind:value={formData[key]}
|
||||
placeholder={prop.description || ''}
|
||||
class="p-2 border rounded-md"
|
||||
/>
|
||||
{:else if prop.type === 'number' || prop.type === 'integer'}
|
||||
<input
|
||||
type="number"
|
||||
id={key}
|
||||
bind:value={formData[key]}
|
||||
placeholder={prop.description || ''}
|
||||
class="p-2 border rounded-md"
|
||||
/>
|
||||
{:else if prop.type === 'boolean'}
|
||||
<input
|
||||
type="checkbox"
|
||||
id={key}
|
||||
bind:checked={formData[key]}
|
||||
class="h-5 w-5"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
<button type="submit" class="w-full bg-green-500 text-white p-2 rounded-md hover:bg-green-600">
|
||||
Run Task
|
||||
</button>
|
||||
{/if}
|
||||
</form>
|
||||
<!-- [/DEF:DynamicForm] -->
|
||||
127
frontend/src/components/TaskRunner.svelte
Normal file → Executable file
127
frontend/src/components/TaskRunner.svelte
Normal file → Executable file
@@ -1,54 +1,73 @@
|
||||
<script>
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { selectedTask, taskLogs } from '../lib/stores.js';
|
||||
|
||||
let ws;
|
||||
|
||||
onMount(() => {
|
||||
if ($selectedTask) {
|
||||
taskLogs.set([]); // Clear previous logs
|
||||
const wsUrl = `ws://localhost:8000/ws/logs/${$selectedTask.id}`;
|
||||
ws = new WebSocket(wsUrl);
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log('WebSocket connection established');
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const logEntry = JSON.parse(event.data);
|
||||
taskLogs.update(logs => [...logs, logEntry]);
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error('WebSocket error:', error);
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
console.log('WebSocket connection closed');
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (ws) {
|
||||
ws.close();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="p-4 border rounded-lg bg-white shadow-md">
|
||||
{#if $selectedTask}
|
||||
<h2 class="text-xl font-semibold mb-2">Task: {$selectedTask.plugin_id}</h2>
|
||||
<div class="bg-gray-900 text-white font-mono text-sm p-4 rounded-md h-96 overflow-y-auto">
|
||||
{#each $taskLogs as log}
|
||||
<div>
|
||||
<span class="text-gray-400">{new Date(log.timestamp).toLocaleTimeString()}</span>
|
||||
<span class="{log.level === 'ERROR' ? 'text-red-500' : 'text-green-400'}">[{log.level}]</span>
|
||||
<span>{log.message}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p>No task selected.</p>
|
||||
{/if}
|
||||
</div>
|
||||
<!--
|
||||
[DEF:TaskRunner:Component]
|
||||
@SEMANTICS: task, runner, logs, websocket
|
||||
@PURPOSE: Connects to a WebSocket to display real-time logs for a running task.
|
||||
@LAYER: UI
|
||||
@RELATION: DEPENDS_ON -> frontend/src/lib/stores.js
|
||||
|
||||
@PROPS: None
|
||||
@EVENTS: None
|
||||
-->
|
||||
<script>
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { selectedTask, taskLogs } from '../lib/stores.js';
|
||||
|
||||
let ws;
|
||||
|
||||
// [DEF:onMount:Function]
|
||||
// @PURPOSE: Initialize WebSocket connection for task logs.
|
||||
onMount(() => {
|
||||
if ($selectedTask) {
|
||||
console.log(`[TaskRunner][Entry] Connecting to logs for task: ${$selectedTask.id}`);
|
||||
taskLogs.set([]); // Clear previous logs
|
||||
const wsUrl = `ws://localhost:8000/ws/logs/${$selectedTask.id}`;
|
||||
ws = new WebSocket(wsUrl);
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log('[TaskRunner][Coherence:OK] WebSocket connection established');
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const logEntry = JSON.parse(event.data);
|
||||
taskLogs.update(logs => [...logs, logEntry]);
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error('[TaskRunner][Coherence:Failed] WebSocket error:', error);
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
console.log('[TaskRunner][Exit] WebSocket connection closed');
|
||||
};
|
||||
}
|
||||
});
|
||||
// [/DEF:onMount]
|
||||
|
||||
// [DEF:onDestroy:Function]
|
||||
// @PURPOSE: Close WebSocket connection when the component is destroyed.
|
||||
onDestroy(() => {
|
||||
if (ws) {
|
||||
console.log("[TaskRunner][Action] Closing WebSocket connection.");
|
||||
ws.close();
|
||||
}
|
||||
});
|
||||
// [/DEF:onDestroy]
|
||||
</script>
|
||||
|
||||
<div class="p-4 border rounded-lg bg-white shadow-md">
|
||||
{#if $selectedTask}
|
||||
<h2 class="text-xl font-semibold mb-2">Task: {$selectedTask.plugin_id}</h2>
|
||||
<div class="bg-gray-900 text-white font-mono text-sm p-4 rounded-md h-96 overflow-y-auto">
|
||||
{#each $taskLogs as log}
|
||||
<div>
|
||||
<span class="text-gray-400">{new Date(log.timestamp).toLocaleTimeString()}</span>
|
||||
<span class="{log.level === 'ERROR' ? 'text-red-500' : 'text-green-400'}">[{log.level}]</span>
|
||||
<span>{log.message}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p>No task selected.</p>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- [/DEF:TaskRunner] -->
|
||||
41
frontend/src/components/Toast.svelte
Normal file → Executable file
41
frontend/src/components/Toast.svelte
Normal file → Executable file
@@ -1,15 +1,26 @@
|
||||
<script>
|
||||
import { toasts } from '../lib/toasts.js';
|
||||
</script>
|
||||
|
||||
<div class="fixed bottom-0 right-0 p-4 space-y-2">
|
||||
{#each $toasts as toast (toast.id)}
|
||||
<div class="p-4 rounded-md shadow-lg text-white
|
||||
{toast.type === 'info' && 'bg-blue-500'}
|
||||
{toast.type === 'success' && 'bg-green-500'}
|
||||
{toast.type === 'error' && 'bg-red-500'}
|
||||
">
|
||||
{toast.message}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<!--
|
||||
[DEF:Toast:Component]
|
||||
@SEMANTICS: toast, notification, feedback, ui
|
||||
@PURPOSE: Displays transient notifications (toasts) in the bottom-right corner.
|
||||
@LAYER: UI
|
||||
@RELATION: DEPENDS_ON -> frontend/src/lib/toasts.js
|
||||
|
||||
@PROPS: None
|
||||
@EVENTS: None
|
||||
-->
|
||||
<script>
|
||||
import { toasts } from '../lib/toasts.js';
|
||||
</script>
|
||||
|
||||
<div class="fixed bottom-0 right-0 p-4 space-y-2">
|
||||
{#each $toasts as toast (toast.id)}
|
||||
<div class="p-4 rounded-md shadow-lg text-white
|
||||
{toast.type === 'info' && 'bg-blue-500'}
|
||||
{toast.type === 'success' && 'bg-green-500'}
|
||||
{toast.type === 'error' && 'bg-red-500'}
|
||||
">
|
||||
{toast.message}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<!-- [/DEF:Toast] -->
|
||||
0
frontend/src/lib/Counter.svelte
Normal file → Executable file
0
frontend/src/lib/Counter.svelte
Normal file → Executable file
158
frontend/src/lib/api.js
Normal file → Executable file
158
frontend/src/lib/api.js
Normal file → Executable file
@@ -1,55 +1,103 @@
|
||||
import { addToast } from './toasts.js';
|
||||
|
||||
const API_BASE_URL = 'http://localhost:8000';
|
||||
|
||||
/**
|
||||
* Fetches data from the API.
|
||||
* @param {string} endpoint The API endpoint to fetch data from.
|
||||
* @returns {Promise<any>} The JSON response from the API.
|
||||
*/
|
||||
async function fetchApi(endpoint) {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}${endpoint}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`API request failed with status ${response.status}`);
|
||||
}
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error(`Error fetching from ${endpoint}:`, error);
|
||||
addToast(error.message, 'error');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Posts data to the API.
|
||||
* @param {string} endpoint The API endpoint to post data to.
|
||||
* @param {object} body The data to post.
|
||||
* @returns {Promise<any>} The JSON response from the API.
|
||||
*/
|
||||
async function postApi(endpoint, body) {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`API request failed with status ${response.status}`);
|
||||
}
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error(`Error posting to ${endpoint}:`, error);
|
||||
addToast(error.message, 'error');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export const api = {
|
||||
getPlugins: () => fetchApi('/plugins'),
|
||||
getTasks: () => fetchApi('/tasks'),
|
||||
getTask: (taskId) => fetchApi(`/tasks/${taskId}`),
|
||||
createTask: (pluginId, params) => postApi('/tasks', { plugin_id: pluginId, params }),
|
||||
};
|
||||
// [DEF:api_module:Module]
|
||||
// @SEMANTICS: api, client, fetch, rest
|
||||
// @PURPOSE: Handles all communication with the backend API.
|
||||
// @LAYER: Infra-API
|
||||
|
||||
import { addToast } from './toasts.js';
|
||||
|
||||
const API_BASE_URL = 'http://localhost:8000';
|
||||
|
||||
// [DEF:fetchApi:Function]
|
||||
// @PURPOSE: Generic GET request wrapper.
|
||||
// @PARAM: endpoint (string) - API endpoint.
|
||||
// @RETURN: Promise<any> - JSON response.
|
||||
async function fetchApi(endpoint) {
|
||||
try {
|
||||
console.log(`[api.fetchApi][Action] Fetching from context={{'endpoint': '${endpoint}'}}`);
|
||||
const response = await fetch(`${API_BASE_URL}${endpoint}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`API request failed with status ${response.status}`);
|
||||
}
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error(`[api.fetchApi][Coherence:Failed] Error fetching from ${endpoint}:`, error);
|
||||
addToast(error.message, 'error');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
// [/DEF:fetchApi]
|
||||
|
||||
// [DEF:postApi:Function]
|
||||
// @PURPOSE: Generic POST request wrapper.
|
||||
// @PARAM: endpoint (string) - API endpoint.
|
||||
// @PARAM: body (object) - Request payload.
|
||||
// @RETURN: Promise<any> - JSON response.
|
||||
async function postApi(endpoint, body) {
|
||||
try {
|
||||
console.log(`[api.postApi][Action] Posting to context={{'endpoint': '${endpoint}'}}`);
|
||||
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`API request failed with status ${response.status}`);
|
||||
}
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error(`[api.postApi][Coherence:Failed] Error posting to ${endpoint}:`, error);
|
||||
addToast(error.message, 'error');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
// [/DEF:postApi]
|
||||
|
||||
// [DEF:api:Data]
|
||||
// @PURPOSE: API client object with specific methods.
|
||||
export const api = {
|
||||
getPlugins: () => fetchApi('/plugins/'),
|
||||
getTasks: () => fetchApi('/tasks/'),
|
||||
getTask: (taskId) => fetchApi(`/tasks/${taskId}`),
|
||||
createTask: (pluginId, params) => postApi('/tasks', { plugin_id: pluginId, params }),
|
||||
|
||||
// Settings
|
||||
getSettings: () => fetchApi('/settings'),
|
||||
updateGlobalSettings: (settings) => {
|
||||
return fetch(`${API_BASE_URL}/settings/global`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(settings)
|
||||
}).then(res => res.json());
|
||||
},
|
||||
getEnvironments: () => fetchApi('/settings/environments'),
|
||||
addEnvironment: (env) => postApi('/settings/environments', env),
|
||||
updateEnvironment: (id, env) => {
|
||||
return fetch(`${API_BASE_URL}/settings/environments/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(env)
|
||||
}).then(res => res.json());
|
||||
},
|
||||
deleteEnvironment: (id) => {
|
||||
return fetch(`${API_BASE_URL}/settings/environments/${id}`, {
|
||||
method: 'DELETE'
|
||||
}).then(res => res.json());
|
||||
},
|
||||
testEnvironmentConnection: (id) => postApi(`/settings/environments/${id}/test`, {}),
|
||||
};
|
||||
// [/DEF:api_module]
|
||||
|
||||
// Export individual functions for easier use in components
|
||||
export const getPlugins = api.getPlugins;
|
||||
export const getTasks = api.getTasks;
|
||||
export const getTask = api.getTask;
|
||||
export const createTask = api.createTask;
|
||||
export const getSettings = api.getSettings;
|
||||
export const updateGlobalSettings = api.updateGlobalSettings;
|
||||
export const getEnvironments = api.getEnvironments;
|
||||
export const addEnvironment = api.addEnvironment;
|
||||
export const updateEnvironment = api.updateEnvironment;
|
||||
export const deleteEnvironment = api.deleteEnvironment;
|
||||
export const testEnvironmentConnection = api.testEnvironmentConnection;
|
||||
|
||||
100
frontend/src/lib/stores.js
Normal file → Executable file
100
frontend/src/lib/stores.js
Normal file → Executable file
@@ -1,40 +1,60 @@
|
||||
import { writable } from 'svelte/store';
|
||||
import { api } from './api.js';
|
||||
|
||||
// Store for the list of available plugins
|
||||
export const plugins = writable([]);
|
||||
|
||||
// Store for the list of tasks
|
||||
export const tasks = writable([]);
|
||||
|
||||
// Store for the currently selected plugin
|
||||
export const selectedPlugin = writable(null);
|
||||
|
||||
// Store for the currently selected task
|
||||
export const selectedTask = writable(null);
|
||||
|
||||
// Store for the logs of the currently selected task
|
||||
export const taskLogs = writable([]);
|
||||
|
||||
// Function to fetch plugins from the API
|
||||
export async function fetchPlugins() {
|
||||
try {
|
||||
const data = await api.getPlugins();
|
||||
console.log('Fetched plugins:', data); // Add console log
|
||||
plugins.set(data);
|
||||
} catch (error) {
|
||||
console.error('Error fetching plugins:', error);
|
||||
// Handle error appropriately in the UI
|
||||
}
|
||||
}
|
||||
|
||||
// Function to fetch tasks from the API
|
||||
export async function fetchTasks() {
|
||||
try {
|
||||
const data = await api.getTasks();
|
||||
tasks.set(data);
|
||||
} catch (error) {
|
||||
console.error('Error fetching tasks:', error);
|
||||
// Handle error appropriately in the UI
|
||||
}
|
||||
}
|
||||
// [DEF:stores_module:Module]
|
||||
// @SEMANTICS: state, stores, svelte, plugins, tasks
|
||||
// @PURPOSE: Global state management using Svelte stores.
|
||||
// @LAYER: UI-State
|
||||
|
||||
import { writable } from 'svelte/store';
|
||||
import { api } from './api.js';
|
||||
|
||||
// [DEF:plugins:Data]
|
||||
// @PURPOSE: Store for the list of available plugins.
|
||||
export const plugins = writable([]);
|
||||
|
||||
// [DEF:tasks:Data]
|
||||
// @PURPOSE: Store for the list of tasks.
|
||||
export const tasks = writable([]);
|
||||
|
||||
// [DEF:selectedPlugin:Data]
|
||||
// @PURPOSE: Store for the currently selected plugin.
|
||||
export const selectedPlugin = writable(null);
|
||||
|
||||
// [DEF:selectedTask:Data]
|
||||
// @PURPOSE: Store for the currently selected task.
|
||||
export const selectedTask = writable(null);
|
||||
|
||||
// [DEF:currentPage:Data]
|
||||
// @PURPOSE: Store for the current page.
|
||||
export const currentPage = writable('dashboard');
|
||||
|
||||
// [DEF:taskLogs:Data]
|
||||
// @PURPOSE: Store for the logs of the currently selected task.
|
||||
export const taskLogs = writable([]);
|
||||
|
||||
// [DEF:fetchPlugins:Function]
|
||||
// @PURPOSE: Fetches plugins from the API and updates the plugins store.
|
||||
export async function fetchPlugins() {
|
||||
try {
|
||||
console.log("[stores.fetchPlugins][Action] Fetching plugins.");
|
||||
const data = await api.getPlugins();
|
||||
console.log("[stores.fetchPlugins][Coherence:OK] Plugins fetched context={{'count': " + data.length + "}}");
|
||||
plugins.set(data);
|
||||
} catch (error) {
|
||||
console.error(`[stores.fetchPlugins][Coherence:Failed] Error fetching plugins context={{'error': '${error}'}}`);
|
||||
}
|
||||
}
|
||||
// [/DEF:fetchPlugins]
|
||||
|
||||
// [DEF:fetchTasks:Function]
|
||||
// @PURPOSE: Fetches tasks from the API and updates the tasks store.
|
||||
export async function fetchTasks() {
|
||||
try {
|
||||
console.log("[stores.fetchTasks][Action] Fetching tasks.");
|
||||
const data = await api.getTasks();
|
||||
console.log("[stores.fetchTasks][Coherence:OK] Tasks fetched context={{'count': " + data.length + "}}");
|
||||
tasks.set(data);
|
||||
} catch (error) {
|
||||
console.error(`[stores.fetchTasks][Coherence:Failed] Error fetching tasks context={{'error': '${error}'}}`);
|
||||
}
|
||||
}
|
||||
// [/DEF:fetchTasks]
|
||||
// [/DEF:stores_module]
|
||||
46
frontend/src/lib/toasts.js
Normal file → Executable file
46
frontend/src/lib/toasts.js
Normal file → Executable file
@@ -1,13 +1,33 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export const toasts = writable([]);
|
||||
|
||||
export function addToast(message, type = 'info', duration = 3000) {
|
||||
const id = Math.random().toString(36).substr(2, 9);
|
||||
toasts.update(all => [...all, { id, message, type }]);
|
||||
setTimeout(() => removeToast(id), duration);
|
||||
}
|
||||
|
||||
function removeToast(id) {
|
||||
toasts.update(all => all.filter(t => t.id !== id));
|
||||
}
|
||||
// [DEF:toasts_module:Module]
|
||||
// @SEMANTICS: notification, toast, feedback, state
|
||||
// @PURPOSE: Manages toast notifications using a Svelte writable store.
|
||||
// @LAYER: UI-State
|
||||
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
// [DEF:toasts:Data]
|
||||
// @PURPOSE: Writable store containing the list of active toasts.
|
||||
export const toasts = writable([]);
|
||||
|
||||
// [DEF:addToast:Function]
|
||||
// @PURPOSE: Adds a new toast message.
|
||||
// @PARAM: message (string) - The message text.
|
||||
// @PARAM: type (string) - The type of toast (info, success, error).
|
||||
// @PARAM: duration (number) - Duration in ms before the toast is removed.
|
||||
export function addToast(message, type = 'info', duration = 3000) {
|
||||
const id = Math.random().toString(36).substr(2, 9);
|
||||
console.log(`[toasts.addToast][Action] Adding toast context={{'id': '${id}', 'type': '${type}', 'message': '${message}'}}`);
|
||||
toasts.update(all => [...all, { id, message, type }]);
|
||||
setTimeout(() => removeToast(id), duration);
|
||||
}
|
||||
// [/DEF:addToast]
|
||||
|
||||
// [DEF:removeToast:Function]
|
||||
// @PURPOSE: Removes a toast message by ID.
|
||||
// @PARAM: id (string) - The ID of the toast to remove.
|
||||
function removeToast(id) {
|
||||
console.log(`[toasts.removeToast][Action] Removing toast context={{'id': '${id}'}}`);
|
||||
toasts.update(all => all.filter(t => t.id !== id));
|
||||
}
|
||||
// [/DEF:removeToast]
|
||||
// [/DEF:toasts_module]
|
||||
8
frontend/src/main.js
Normal file → Executable file
8
frontend/src/main.js
Normal file → Executable file
@@ -1,9 +1,17 @@
|
||||
// [DEF:main:Module]
|
||||
// @SEMANTICS: entrypoint, svelte, init
|
||||
// @PURPOSE: Entry point for the Svelte application.
|
||||
// @LAYER: UI-Entry
|
||||
|
||||
import './app.css'
|
||||
import App from './App.svelte'
|
||||
|
||||
// [DEF:app_instance:Data]
|
||||
// @PURPOSE: Initialized Svelte app instance.
|
||||
const app = new App({
|
||||
target: document.getElementById('app'),
|
||||
props: {}
|
||||
})
|
||||
|
||||
export default app
|
||||
// [/DEF:main]
|
||||
|
||||
76
frontend/src/pages/Dashboard.svelte
Normal file → Executable file
76
frontend/src/pages/Dashboard.svelte
Normal file → Executable file
@@ -1,28 +1,48 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { plugins, fetchPlugins, selectedPlugin } from '../lib/stores.js';
|
||||
|
||||
onMount(async () => {
|
||||
await fetchPlugins();
|
||||
});
|
||||
|
||||
function selectPlugin(plugin) {
|
||||
selectedPlugin.set(plugin);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="container mx-auto p-4">
|
||||
<h1 class="text-2xl font-bold mb-4">Available Tools</h1>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{#each $plugins as plugin}
|
||||
<div
|
||||
class="border rounded-lg p-4 cursor-pointer hover:bg-gray-100"
|
||||
on:click={() => selectPlugin(plugin)}
|
||||
>
|
||||
<h2 class="text-xl font-semibold">{plugin.name}</h2>
|
||||
<p class="text-gray-600">{plugin.description}</p>
|
||||
<span class="text-sm text-gray-400">v{plugin.version}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<!--
|
||||
[DEF:Dashboard:Component]
|
||||
@SEMANTICS: dashboard, plugins, tools, list
|
||||
@PURPOSE: Displays the list of available plugins and allows selecting one.
|
||||
@LAYER: UI
|
||||
@RELATION: DEPENDS_ON -> frontend/src/lib/stores.js
|
||||
|
||||
@PROPS: None
|
||||
@EVENTS: None
|
||||
-->
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { plugins, fetchPlugins, selectedPlugin } from '../lib/stores.js';
|
||||
|
||||
// [DEF:onMount:Function]
|
||||
// @PURPOSE: Fetch plugins when the component mounts.
|
||||
onMount(async () => {
|
||||
console.log("[Dashboard][Entry] Component mounted, fetching plugins.");
|
||||
await fetchPlugins();
|
||||
});
|
||||
// [/DEF:onMount]
|
||||
|
||||
// [DEF:selectPlugin:Function]
|
||||
// @PURPOSE: Selects a plugin to display its form.
|
||||
// @PARAM: plugin (Object) - The plugin object to select.
|
||||
function selectPlugin(plugin) {
|
||||
console.log(`[Dashboard][Action] Selecting plugin: ${plugin.id}`);
|
||||
selectedPlugin.set(plugin);
|
||||
}
|
||||
// [/DEF:selectPlugin]
|
||||
</script>
|
||||
|
||||
<div class="container mx-auto p-4">
|
||||
<h1 class="text-2xl font-bold mb-4">Available Tools</h1>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{#each $plugins as plugin}
|
||||
<div
|
||||
class="border rounded-lg p-4 cursor-pointer hover:bg-gray-100"
|
||||
on:click={() => selectPlugin(plugin)}
|
||||
>
|
||||
<h2 class="text-xl font-semibold">{plugin.name}</h2>
|
||||
<p class="text-gray-600">{plugin.description}</p>
|
||||
<span class="text-sm text-gray-400">v{plugin.version}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<!-- [/DEF:Dashboard] -->
|
||||
207
frontend/src/pages/Settings.svelte
Executable file
207
frontend/src/pages/Settings.svelte
Executable file
@@ -0,0 +1,207 @@
|
||||
<!--
|
||||
[DEF:Settings:Component]
|
||||
@SEMANTICS: settings, ui, configuration
|
||||
@PURPOSE: The main settings page for the application, allowing management of environments and global settings.
|
||||
@LAYER: UI
|
||||
@RELATION: CALLS -> api.js
|
||||
@RELATION: USES -> stores.js
|
||||
|
||||
@PROPS:
|
||||
None
|
||||
@EVENTS:
|
||||
None
|
||||
@INVARIANT: Settings changes must be saved to the backend.
|
||||
-->
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { getSettings, updateGlobalSettings, getEnvironments, addEnvironment, updateEnvironment, deleteEnvironment, testEnvironmentConnection } from '../lib/api';
|
||||
import { addToast } from '../lib/toasts';
|
||||
|
||||
let settings = {
|
||||
environments: [],
|
||||
settings: {
|
||||
backup_path: '',
|
||||
default_environment_id: null
|
||||
}
|
||||
};
|
||||
|
||||
let newEnv = {
|
||||
id: '',
|
||||
name: '',
|
||||
url: '',
|
||||
username: '',
|
||||
password: '',
|
||||
is_default: false
|
||||
};
|
||||
|
||||
let editingEnvId = null;
|
||||
|
||||
async function loadSettings() {
|
||||
try {
|
||||
const data = await getSettings();
|
||||
settings = data;
|
||||
} catch (error) {
|
||||
addToast('Failed to load settings', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveGlobal() {
|
||||
try {
|
||||
await updateGlobalSettings(settings.settings);
|
||||
addToast('Global settings saved', 'success');
|
||||
} catch (error) {
|
||||
addToast('Failed to save global settings', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddOrUpdateEnv() {
|
||||
try {
|
||||
if (editingEnvId) {
|
||||
await updateEnvironment(editingEnvId, newEnv);
|
||||
addToast('Environment updated', 'success');
|
||||
} else {
|
||||
await addEnvironment(newEnv);
|
||||
addToast('Environment added', 'success');
|
||||
}
|
||||
resetEnvForm();
|
||||
await loadSettings();
|
||||
} catch (error) {
|
||||
addToast('Failed to save environment', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteEnv(id) {
|
||||
if (confirm('Are you sure you want to delete this environment?')) {
|
||||
try {
|
||||
await deleteEnvironment(id);
|
||||
addToast('Environment deleted', 'success');
|
||||
await loadSettings();
|
||||
} catch (error) {
|
||||
addToast('Failed to delete environment', 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTestEnv(id) {
|
||||
try {
|
||||
const result = await testEnvironmentConnection(id);
|
||||
if (result.status === 'success') {
|
||||
addToast('Connection successful', 'success');
|
||||
} else {
|
||||
addToast(`Connection failed: ${result.message}`, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
addToast('Failed to test connection', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function editEnv(env) {
|
||||
newEnv = { ...env };
|
||||
editingEnvId = env.id;
|
||||
}
|
||||
|
||||
function resetEnvForm() {
|
||||
newEnv = {
|
||||
id: '',
|
||||
name: '',
|
||||
url: '',
|
||||
username: '',
|
||||
password: '',
|
||||
is_default: false
|
||||
};
|
||||
editingEnvId = null;
|
||||
}
|
||||
|
||||
onMount(loadSettings);
|
||||
</script>
|
||||
|
||||
<div class="container mx-auto p-4">
|
||||
<h1 class="text-2xl font-bold mb-6">Settings</h1>
|
||||
|
||||
<section class="mb-8 bg-white p-6 rounded shadow">
|
||||
<h2 class="text-xl font-semibold mb-4">Global Settings</h2>
|
||||
<div class="grid grid-cols-1 gap-4">
|
||||
<div>
|
||||
<label for="backup_path" class="block text-sm font-medium text-gray-700">Backup Storage Path</label>
|
||||
<input type="text" id="backup_path" bind:value={settings.settings.backup_path} class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2" />
|
||||
</div>
|
||||
<button on:click={handleSaveGlobal} class="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600 w-max">
|
||||
Save Global Settings
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mb-8 bg-white p-6 rounded shadow">
|
||||
<h2 class="text-xl font-semibold mb-4">Superset Environments</h2>
|
||||
|
||||
<div class="mb-6 overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">URL</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Username</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Default</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
{#each settings.environments as env}
|
||||
<tr>
|
||||
<td class="px-6 py-4 whitespace-nowrap">{env.name}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">{env.url}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">{env.username}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">{env.is_default ? 'Yes' : 'No'}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<button on:click={() => handleTestEnv(env.id)} class="text-green-600 hover:text-green-900 mr-4">Test</button>
|
||||
<button on:click={() => editEnv(env)} class="text-indigo-600 hover:text-indigo-900 mr-4">Edit</button>
|
||||
<button on:click={() => handleDeleteEnv(env.id)} class="text-red-600 hover:text-red-900">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-50 p-4 rounded">
|
||||
<h3 class="text-lg font-medium mb-4">{editingEnvId ? 'Edit' : 'Add'} Environment</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="env_id" class="block text-sm font-medium text-gray-700">ID</label>
|
||||
<input type="text" id="env_id" bind:value={newEnv.id} disabled={!!editingEnvId} class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="env_name" class="block text-sm font-medium text-gray-700">Name</label>
|
||||
<input type="text" id="env_name" bind:value={newEnv.name} class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="env_url" class="block text-sm font-medium text-gray-700">URL</label>
|
||||
<input type="text" id="env_url" bind:value={newEnv.url} class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="env_user" class="block text-sm font-medium text-gray-700">Username</label>
|
||||
<input type="text" id="env_user" bind:value={newEnv.username} class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="env_pass" class="block text-sm font-medium text-gray-700">Password</label>
|
||||
<input type="password" id="env_pass" bind:value={newEnv.password} class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2" />
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<input type="checkbox" id="env_default" bind:checked={newEnv.is_default} class="h-4 w-4 text-blue-600 border-gray-300 rounded" />
|
||||
<label for="env_default" class="ml-2 block text-sm text-gray-900">Default Environment</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 flex gap-2">
|
||||
<button on:click={handleAddOrUpdateEnv} class="bg-green-500 text-white px-4 py-2 rounded hover:bg-green-600">
|
||||
{editingEnvId ? 'Update' : 'Add'} Environment
|
||||
</button>
|
||||
{#if editingEnvId}
|
||||
<button on:click={resetEnvForm} class="bg-gray-500 text-white px-4 py-2 rounded hover:bg-gray-600">
|
||||
Cancel
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<!-- [/DEF:Settings] -->
|
||||
0
frontend/svelte.config.js
Normal file → Executable file
0
frontend/svelte.config.js
Normal file → Executable file
20
frontend/tailwind.config.js
Normal file → Executable file
20
frontend/tailwind.config.js
Normal file → Executable file
@@ -1,11 +1,11 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{svelte,js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{svelte,js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
0
frontend/vite.config.js
Normal file → Executable file
0
frontend/vite.config.js
Normal file → Executable file
Reference in New Issue
Block a user